From 64227be96a49aacb91a129725b96bc95a4d51383 Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Sun, 18 Jan 2026 12:16:54 +0100 Subject: [PATCH 1/5] Add tests for length limits in ISUPPORT --- irctest/server_tests/away.py | 44 +++++++- irctest/server_tests/isupport_limits.py | 128 ++++++++++++++++++++++++ irctest/server_tests/kick.py | 51 ++++++++++ irctest/server_tests/topic.py | 36 +++++++ irctest/specifications.py | 8 +- pytest.ini | 6 ++ 6 files changed, 271 insertions(+), 2 deletions(-) create mode 100644 irctest/server_tests/isupport_limits.py diff --git a/irctest/server_tests/away.py b/irctest/server_tests/away.py index 36841d6f..585be01b 100644 --- a/irctest/server_tests/away.py +++ b/irctest/server_tests/away.py @@ -3,7 +3,7 @@ `Modern `__) """ -from irctest import cases +from irctest import cases, runner from irctest.numerics import ( RPL_AWAY, RPL_NOWAWAY, @@ -181,3 +181,45 @@ def testAwayEmptyMessage(self): replies = self.getMessages("qux") self.assertIn(RPL_WHOISUSER, [msg.command for msg in replies]) self.assertNotIn(RPL_AWAY, [msg.command for msg in replies]) + + @cases.mark_specifications("Modern") + @cases.mark_isupport("AWAYLEN") + def testAwaylen(self): + """ + "AWAYLEN= + The AWAYLEN parameter indicates the maximum length for the of an AWAY command." + -- https://modern.ircdocs.horse/#awaylen-parameter + """ + self.connectClient("foo") + + if "AWAYLEN" not in self.server_support: + raise runner.IsupportTokenNotSupported("AWAYLEN") + + awaylen = int(self.server_support["AWAYLEN"]) + + # Set away message at exactly the limit + valid_away = "a" * awaylen + self.sendLine(1, f"AWAY :{valid_away}") + self.assertMessageMatch( + self.getMessage(1), command="306", params=["foo", ANYSTR] + ) # RPL_NOWAWAY + + # Set away message longer than the limit + long_away = "b" * (awaylen + 50) + self.sendLine(1, f"AWAY :{long_away}") + self.getMessages(1) + + # Check the away message + self.connectClient("bar") + self.sendLine(2, "WHOIS foo") + msgs = self.getMessages(2) + + away_msgs = [m for m in msgs if m.command == "301"] + self.assertMessageMatch( + away_msgs[0], command="301", params=["bar", "foo", ANYSTR] + ) + self.assertLessEqual( + len(away_msgs[0].params[2]), + awaylen, + f"Server sent away message longer than AWAYLEN {awaylen}", + ) diff --git a/irctest/server_tests/isupport_limits.py b/irctest/server_tests/isupport_limits.py new file mode 100644 index 00000000..dfc57da8 --- /dev/null +++ b/irctest/server_tests/isupport_limits.py @@ -0,0 +1,128 @@ +""" +Tests for ISUPPORT limit enforcement (`Modern +`__) +""" + +import pytest + +from irctest import cases, runner +from irctest.numerics import ( + ERR_ERRONEUSNICKNAME, + ERR_NOSUCHCHANNEL, + ERR_TOOMANYCHANNELS, +) +from irctest.patma import ANYSTR + + +class IsupportLimitTestCase(cases.BaseServerTestCase): + @cases.mark_specifications("Modern") + @cases.mark_isupport("CHANLIMIT") + @pytest.mark.parametrize("prefix", ["#", "&"]) + def testChanlimit(self, prefix): + """ + "CHANLIMIT=:[limit]{,:[limit]}" + -- https://modern.ircdocs.horse/#chanlimit-parameter + """ + self.connectClient("foo") + + if "CHANLIMIT" not in self.server_support: + raise runner.IsupportTokenNotSupported("CHANLIMIT") + + pairs = [ + part.split(":", 1) for part in self.server_support["CHANLIMIT"].split(",") + ] + limits = {pair[0]: int(pair[1]) if pair[1] else None for pair in pairs} + + self.assertTrue(limits, "CHANLIMIT is empty") + + limit = limits.get(prefix) + if limit is None: + raise runner.ImplementationChoice(f"No limit for {prefix} channels") + + # Join up to the limit + for i in range(limit): + self.sendLine(1, f"JOIN {prefix}chan{i}") + self.assertMessageMatch( + self.getMessage(1), + command="JOIN", + params=[f"{prefix}chan{i}"], + fail_msg=f"Failed to join channel {i + 1}/{limit}", + ) + self.getMessages(1) # clear any remaining messages + + # Try to join one more - should fail + self.sendLine(1, f"JOIN {prefix}chan") + self.assertMessageMatch( + self.getMessage(1), + command=ERR_TOOMANYCHANNELS, + params=["foo", f"{prefix}chan", ANYSTR], + ) + + @cases.mark_specifications("Modern") + @cases.mark_isupport("CHANNELLEN") + @pytest.mark.parametrize("prefix", ["#", "&"]) + def testChannellen(self, prefix): + """ + "CHANNELLEN= + The CHANNELLEN parameter specifies the maximum length of a channel name that a client may join." + -- https://modern.ircdocs.horse/#channellen-parameter + """ + self.connectClient("foo") + + if "CHANNELLEN" not in self.server_support: + raise runner.IsupportTokenNotSupported("CHANNELLEN") + + chantypes = self.server_support.get("CHANTYPES", "#") + if prefix not in chantypes: + raise runner.NotImplementedByController( + f"Server does not support {prefix} channels" + ) + + channellen = int(self.server_support["CHANNELLEN"]) + + # Try a channel name at exactly the limit + valid_chan = prefix + "a" * (channellen - 1) + self.sendLine(1, f"JOIN {valid_chan}") + self.assertMessageMatch(self.getMessage(1), command="JOIN", params=[valid_chan]) + + # Try a channel name longer than the limit + self.getMessages(1) # clear + invalid_chan = prefix + "b" * channellen + self.sendLine(1, f"JOIN {invalid_chan}") + self.assertMessageMatch( + self.getMessage(1), + command=ERR_NOSUCHCHANNEL, + params=["foo", invalid_chan, ANYSTR], + ) + + @cases.mark_specifications("Modern") + @cases.mark_isupport("NICKLEN") + def testNicklen(self): + """ + "NICKLEN= + "The NICKLEN parameter indicates the maximum length of a nickname that a client may set. + Clients on the network MAY have longer nicks than this. + The value MUST be specified and MUST be a positive integer. + 30 or 31 are typical values for this parameter advertised by servers today." + -- https://modern.ircdocs.horse/#nicklen-parameter + """ + self.connectClient("foo") + + if "NICKLEN" not in self.server_support: + raise runner.IsupportTokenNotSupported("NICKLEN") + + nicklen = int(self.server_support["NICKLEN"]) + + # Try a nick at exactly the limit + valid_nick = "a" * nicklen + self.sendLine(1, f"NICK {valid_nick}") + self.assertMessageMatch(self.getMessage(1), command="NICK", params=[valid_nick]) + + # Try a nick longer than the limit + invalid_nick = "b" * (nicklen + 5) + self.sendLine(1, f"NICK {invalid_nick}") + self.assertMessageMatch( + self.getMessage(1), + command=ERR_ERRONEUSNICKNAME, + params=[valid_nick, invalid_nick, ANYSTR], + ) diff --git a/irctest/server_tests/kick.py b/irctest/server_tests/kick.py index ac511dee..632a3643 100644 --- a/irctest/server_tests/kick.py +++ b/irctest/server_tests/kick.py @@ -272,3 +272,54 @@ def testDoubleKickMessages(self, multiple_targets): m1.params[1], m2.params[1] ) ) + + @cases.mark_specifications("Modern") + @cases.mark_isupport("KICKLEN") + def testKicklen(self): + """ + "KICKLEN= + The KICKLEN parameter indicates the maximum length for the of a KICK command." + -- https://modern.ircdocs.horse/#kicklen-parameter + """ + self.connectClient("foo") + self.joinChannel(1, "#test") + + self.connectClient("bar") + self.joinChannel(2, "#test") + + if "KICKLEN" not in self.server_support: + raise runner.IsupportTokenNotSupported("KICKLEN") + + kicklen = int(self.server_support["KICKLEN"]) + + # Kick with a reason at exactly the limit + valid_reason = "a" * kicklen + self.sendLine(1, f"KICK #test bar :{valid_reason}") + msg = self.getMessage(1) + + if msg.command != "KICK": + raise runner.ImplementationChoice("User doesn't have permission to kick") + + self.assertMessageMatch(msg, command="KICK", params=["#test", "bar", ANYSTR]) + self.assertLessEqual( + len(msg.params[2]), + kicklen, + f"Server sent kick reason longer than KICKLEN {kicklen}", + ) + + # Rejoin and kick with a reason longer than the limit + self.getMessages(1) + self.getMessages(2) + self.sendLine(2, "JOIN #test") + self.getMessages(2) + self.getMessages(1) + + long_reason = "b" * (kicklen + 50) + self.sendLine(1, f"KICK #test bar :{long_reason}") + msg = self.getMessage(1) + self.assertMessageMatch(msg, command="KICK", params=["#test", "bar", ANYSTR]) + self.assertLessEqual( + len(msg.params[2]), + kicklen, + f"Server truncated kick reason to KICKLEN {kicklen}", + ) diff --git a/irctest/server_tests/topic.py b/irctest/server_tests/topic.py index d001fe68..e3f79ab3 100644 --- a/irctest/server_tests/topic.py +++ b/irctest/server_tests/topic.py @@ -7,6 +7,7 @@ from irctest import cases, client_mock, runner from irctest.numerics import ERR_CHANOPRIVSNEEDED, RPL_NOTOPIC, RPL_TOPIC, RPL_TOPICTIME +from irctest.patma import ANYSTR class TopicTestCase(cases.BaseServerTestCase): @@ -221,3 +222,38 @@ def testTopicPrivileges(self): self.assertEqual( len([msg for msg in replies if msg.command == RPL_TOPICTIME]), 1 ) + + @cases.mark_specifications("Modern") + @cases.mark_isupport("TOPICLEN") + def testTopiclen(self): + """ + "TOPICLEN= + The TOPICLEN parameter indicates the maximum length of a topic that a client may set on a channel." + -- https://modern.ircdocs.horse/#topiclen-parameter + """ + self.connectClient("foo") + self.joinChannel(1, "#test") + + if "TOPICLEN" not in self.server_support: + raise runner.IsupportTokenNotSupported("TOPICLEN") + + topiclen = int(self.server_support["TOPICLEN"]) + + # Set a topic at exactly the limit + valid_topic = "a" * topiclen + self.sendLine(1, f"TOPIC #test :{valid_topic}") + self.assertMessageMatch( + self.getMessage(1), command="TOPIC", params=["#test", valid_topic] + ) + + # Try a topic longer than the limit + self.getMessages(1) # clear + long_topic = "b" * (topiclen + 50) + self.sendLine(1, f"TOPIC #test :{long_topic}") + msg = self.getMessage(1) + self.assertMessageMatch(msg, command="TOPIC", params=["#test", ANYSTR]) + self.assertLessEqual( + len(msg.params[1]), + topiclen, + f"Server set topic longer than TOPICLEN {topiclen}", + ) diff --git a/irctest/specifications.py b/irctest/specifications.py index e8533472..ae4e7d60 100644 --- a/irctest/specifications.py +++ b/irctest/specifications.py @@ -53,13 +53,19 @@ def from_name(cls, name: str) -> Capabilities: @enum.unique class IsupportTokens(enum.Enum): + AWAYLEN = "AWAYLEN" BOT = "BOT" + CHANLIMIT = "CHANLIMIT" + CHANNELLEN = "CHANNELLEN" ELIST = "ELIST" INVEX = "INVEX" - PREFIX = "PREFIX" + KICKLEN = "KICKLEN" MONITOR = "MONITOR" + NICKLEN = "NICKLEN" + PREFIX = "PREFIX" STATUSMSG = "STATUSMSG" TARGMAX = "TARGMAX" + TOPICLEN = "TOPICLEN" UTF8ONLY = "UTF8ONLY" WHOX = "WHOX" diff --git a/pytest.ini b/pytest.ini index 8f023dbd..51a63afb 100644 --- a/pytest.ini +++ b/pytest.ini @@ -36,13 +36,19 @@ markers = sts # isupport tokens + AWAYLEN BOT + CHANLIMIT + CHANNELLEN ELIST INVEX + KICKLEN MONITOR + NICKLEN PREFIX STATUSMSG TARGMAX + TOPICLEN UTF8ONLY WHOX From 8020c5424379db90e5ee1472440f82a24ee579eb Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Sun, 18 Jan 2026 13:00:16 +0100 Subject: [PATCH 2/5] move testTopiclen to the right class --- irctest/server_tests/topic.py | 70 +++++++++++++++++------------------ 1 file changed, 35 insertions(+), 35 deletions(-) diff --git a/irctest/server_tests/topic.py b/irctest/server_tests/topic.py index e3f79ab3..7f2680e5 100644 --- a/irctest/server_tests/topic.py +++ b/irctest/server_tests/topic.py @@ -173,6 +173,41 @@ def testUnsetTopicResponses(self): # topic is once again unset, shouldn't send RPL_NOTOPIC on initial join self.assertNotIn(RPL_NOTOPIC, [m.command for m in messages]) + @cases.mark_specifications("Modern") + @cases.mark_isupport("TOPICLEN") + def testTopiclen(self): + """ + "TOPICLEN= + The TOPICLEN parameter indicates the maximum length of a topic that a client may set on a channel." + -- https://modern.ircdocs.horse/#topiclen-parameter + """ + self.connectClient("foo") + self.joinChannel(1, "#test") + + if "TOPICLEN" not in self.server_support: + raise runner.IsupportTokenNotSupported("TOPICLEN") + + topiclen = int(self.server_support["TOPICLEN"]) + + # Set a topic at exactly the limit + valid_topic = "a" * topiclen + self.sendLine(1, f"TOPIC #test :{valid_topic}") + self.assertMessageMatch( + self.getMessage(1), command="TOPIC", params=["#test", valid_topic] + ) + + # Try a topic longer than the limit + self.getMessages(1) # clear + long_topic = "b" * (topiclen + 50) + self.sendLine(1, f"TOPIC #test :{long_topic}") + msg = self.getMessage(1) + self.assertMessageMatch(msg, command="TOPIC", params=["#test", ANYSTR]) + self.assertLessEqual( + len(msg.params[1]), + topiclen, + f"Server set topic longer than TOPICLEN {topiclen}", + ) + class TopicPrivilegesTestCase(cases.BaseServerTestCase): @cases.mark_specifications("RFC2812") @@ -222,38 +257,3 @@ def testTopicPrivileges(self): self.assertEqual( len([msg for msg in replies if msg.command == RPL_TOPICTIME]), 1 ) - - @cases.mark_specifications("Modern") - @cases.mark_isupport("TOPICLEN") - def testTopiclen(self): - """ - "TOPICLEN= - The TOPICLEN parameter indicates the maximum length of a topic that a client may set on a channel." - -- https://modern.ircdocs.horse/#topiclen-parameter - """ - self.connectClient("foo") - self.joinChannel(1, "#test") - - if "TOPICLEN" not in self.server_support: - raise runner.IsupportTokenNotSupported("TOPICLEN") - - topiclen = int(self.server_support["TOPICLEN"]) - - # Set a topic at exactly the limit - valid_topic = "a" * topiclen - self.sendLine(1, f"TOPIC #test :{valid_topic}") - self.assertMessageMatch( - self.getMessage(1), command="TOPIC", params=["#test", valid_topic] - ) - - # Try a topic longer than the limit - self.getMessages(1) # clear - long_topic = "b" * (topiclen + 50) - self.sendLine(1, f"TOPIC #test :{long_topic}") - msg = self.getMessage(1) - self.assertMessageMatch(msg, command="TOPIC", params=["#test", ANYSTR]) - self.assertLessEqual( - len(msg.params[1]), - topiclen, - f"Server set topic longer than TOPICLEN {topiclen}", - ) From e615fa2ef3c68c3176e634445e2a4db532d44e5b Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Sun, 18 Jan 2026 13:48:05 +0100 Subject: [PATCH 3/5] Fix race --- irctest/server_tests/kick.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/irctest/server_tests/kick.py b/irctest/server_tests/kick.py index 632a3643..13252e2c 100644 --- a/irctest/server_tests/kick.py +++ b/irctest/server_tests/kick.py @@ -284,12 +284,13 @@ def testKicklen(self): self.connectClient("foo") self.joinChannel(1, "#test") - self.connectClient("bar") - self.joinChannel(2, "#test") - if "KICKLEN" not in self.server_support: raise runner.IsupportTokenNotSupported("KICKLEN") + self.connectClient("bar") + self.joinChannel(2, "#test") + self.getMessages(1) + kicklen = int(self.server_support["KICKLEN"]) # Kick with a reason at exactly the limit From b63115bfa80d58059ecd19f29f87b4e15830d932 Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Sun, 18 Jan 2026 13:48:18 +0100 Subject: [PATCH 4/5] stricter assertions --- irctest/server_tests/isupport_limits.py | 7 +++++-- irctest/server_tests/kick.py | 6 +----- irctest/server_tests/topic.py | 9 ++++++++- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/irctest/server_tests/isupport_limits.py b/irctest/server_tests/isupport_limits.py index dfc57da8..9c852e6d 100644 --- a/irctest/server_tests/isupport_limits.py +++ b/irctest/server_tests/isupport_limits.py @@ -8,10 +8,13 @@ from irctest import cases, runner from irctest.numerics import ( ERR_ERRONEUSNICKNAME, + ERR_FORBIDDENCHANNEL, ERR_NOSUCHCHANNEL, ERR_TOOMANYCHANNELS, ) -from irctest.patma import ANYSTR +from irctest.patma import ANYSTR, Either + +ERR_BADCHANNAME = "479" # Hybrid only, and conflicts with others class IsupportLimitTestCase(cases.BaseServerTestCase): @@ -91,7 +94,7 @@ def testChannellen(self, prefix): self.sendLine(1, f"JOIN {invalid_chan}") self.assertMessageMatch( self.getMessage(1), - command=ERR_NOSUCHCHANNEL, + command=Either(ERR_NOSUCHCHANNEL, ERR_BADCHANNAME, ERR_FORBIDDENCHANNEL), params=["foo", invalid_chan, ANYSTR], ) diff --git a/irctest/server_tests/kick.py b/irctest/server_tests/kick.py index 13252e2c..254d926c 100644 --- a/irctest/server_tests/kick.py +++ b/irctest/server_tests/kick.py @@ -297,10 +297,6 @@ def testKicklen(self): valid_reason = "a" * kicklen self.sendLine(1, f"KICK #test bar :{valid_reason}") msg = self.getMessage(1) - - if msg.command != "KICK": - raise runner.ImplementationChoice("User doesn't have permission to kick") - self.assertMessageMatch(msg, command="KICK", params=["#test", "bar", ANYSTR]) self.assertLessEqual( len(msg.params[2]), @@ -322,5 +318,5 @@ def testKicklen(self): self.assertLessEqual( len(msg.params[2]), kicklen, - f"Server truncated kick reason to KICKLEN {kicklen}", + f"Server did not truncate kick reason to KICKLEN {kicklen}", ) diff --git a/irctest/server_tests/topic.py b/irctest/server_tests/topic.py index 7f2680e5..48816858 100644 --- a/irctest/server_tests/topic.py +++ b/irctest/server_tests/topic.py @@ -196,6 +196,13 @@ def testTopiclen(self): self.getMessage(1), command="TOPIC", params=["#test", valid_topic] ) + self.connectClient("bar") + self.sendLine(2, "JOIN #test") + (msg,) = [msg for msg in self.getMessages(2) if msg.command == RPL_TOPIC] + self.assertMessageMatch( + msg, command=RPL_TOPIC, params=["bar", "#test", valid_topic] + ) + # Try a topic longer than the limit self.getMessages(1) # clear long_topic = "b" * (topiclen + 50) @@ -250,7 +257,7 @@ def testTopicPrivileges(self): self.connectClient("buzz", name="buzz") self.sendLine("buzz", "JOIN #chan") replies = self.getMessages("buzz") - rpl_topic = [msg for msg in replies if msg.command == RPL_TOPIC][0] + (rpl_topic,) = [msg for msg in replies if msg.command == RPL_TOPIC] self.assertMessageMatch( rpl_topic, command=RPL_TOPIC, params=["buzz", "#chan", "new topic"] ) From da57efa9cb3b86a398ebb410384eac2b411cf9ca Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Sun, 18 Jan 2026 14:25:27 +0100 Subject: [PATCH 5/5] Add ERR_BADCHANMASK for Insp --- irctest/server_tests/isupport_limits.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/irctest/server_tests/isupport_limits.py b/irctest/server_tests/isupport_limits.py index 9c852e6d..822e0bec 100644 --- a/irctest/server_tests/isupport_limits.py +++ b/irctest/server_tests/isupport_limits.py @@ -7,6 +7,7 @@ from irctest import cases, runner from irctest.numerics import ( + ERR_BADCHANMASK, ERR_ERRONEUSNICKNAME, ERR_FORBIDDENCHANNEL, ERR_NOSUCHCHANNEL, @@ -94,7 +95,12 @@ def testChannellen(self, prefix): self.sendLine(1, f"JOIN {invalid_chan}") self.assertMessageMatch( self.getMessage(1), - command=Either(ERR_NOSUCHCHANNEL, ERR_BADCHANNAME, ERR_FORBIDDENCHANNEL), + command=Either( + ERR_NOSUCHCHANNEL, + ERR_BADCHANNAME, + ERR_FORBIDDENCHANNEL, + ERR_BADCHANMASK, + ), params=["foo", invalid_chan, ANYSTR], )