From 423f6e45ac06e9ce1fc8e9c8d194664a6ee0f1b4 Mon Sep 17 00:00:00 2001 From: kc1awv Date: Tue, 30 Dec 2025 16:44:45 -0500 Subject: [PATCH] Refine nickname handling and implement PARTED message type --- README.md | 8 ++++++-- rrcd/cli.py | 4 ++++ rrcd/config.py | 6 ++++++ rrcd/constants.py | 2 +- rrcd/envelope.py | 29 +++-------------------------- rrcd/service.py | 27 +++++++++++++++++++++++---- rrcd/util.py | 11 ++++++++--- tests/test_envelope.py | 14 +++++--------- 8 files changed, 56 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index bd94e39..1b56144 100644 --- a/README.md +++ b/README.md @@ -86,8 +86,12 @@ Wire-level extensions (backwards-compatible): link. Clients should treat `K_NICK` as optional and fall back to `K_SRC` when it is missing. - Nickname policy (current implementation): trimmed Unicode string, UTF-8 - encodable on the wire, maximum 32 characters. + Nicknames are advisory only; clients should treat them as display hints. + The hub may ignore, sanitize, replace, or omit them. + + Current hub sanitation policy: store/emit only trimmed nicknames that are + UTF-8 encodable, contain no newlines/NUL, and are at most `nick_max_chars` + characters (default: 32). Configure trusted operators and banned identities in the TOML config: diff --git a/rrcd/cli.py b/rrcd/cli.py index 1253db4..0f3af73 100644 --- a/rrcd/cli.py +++ b/rrcd/cli.py @@ -121,6 +121,10 @@ room_invite_timeout_s = 900.0 # Optional behaviors. include_joined_member_list = false +# Nickname policy. +# Maximum accepted nickname length (Unicode characters). 0 disables length limiting. +nick_max_chars = 32 + # Limits. max_rooms_per_session = 32 max_room_name_len = 64 diff --git a/rrcd/config.py b/rrcd/config.py index 5aa4fac..789c461 100644 --- a/rrcd/config.py +++ b/rrcd/config.py @@ -26,6 +26,12 @@ class HubRuntimeConfig: # Invite timeout for keyed rooms (+k). Invites are removed on join or expiry. room_invite_timeout_s: float = 900.0 include_joined_member_list: bool = False + + # Optional policy controls. + # Maximum accepted/stored nickname length (Unicode characters). 0 disables + # length limiting. + nick_max_chars: int = 32 + max_rooms_per_session: int = 32 max_room_name_len: int = 64 rate_limit_msgs_per_minute: int = 240 diff --git a/rrcd/constants.py b/rrcd/constants.py index 683a513..7c3db3b 100644 --- a/rrcd/constants.py +++ b/rrcd/constants.py @@ -11,7 +11,6 @@ K_SRC = 4 K_ROOM = 5 K_BODY = 6 K_NICK = 7 -NICK_MAX_CHARS = 32 # Message types T_HELLO = 1 @@ -20,6 +19,7 @@ T_WELCOME = 2 T_JOIN = 10 T_JOINED = 11 T_PART = 12 +T_PARTED = 13 T_MSG = 20 T_NOTICE = 21 diff --git a/rrcd/envelope.py b/rrcd/envelope.py index 9e99d06..a6e5be4 100644 --- a/rrcd/envelope.py +++ b/rrcd/envelope.py @@ -3,18 +3,7 @@ from __future__ import annotations import os import time -from .constants import ( - K_BODY, - K_ID, - K_NICK, - K_ROOM, - K_SRC, - K_T, - K_TS, - K_V, - NICK_MAX_CHARS, - RRC_VERSION, -) +from .constants import K_BODY, K_ID, K_NICK, K_ROOM, K_SRC, K_T, K_TS, K_V, RRC_VERSION from .util import normalize_nick @@ -103,17 +92,5 @@ def validate_envelope(env: dict) -> None: nick = env[K_NICK] if not isinstance(nick, str): raise TypeError("nickname must be a string") - if nick.strip() == "": - raise ValueError("nickname must not be empty") - - # Require normalized form on the wire. - if nick != nick.strip(): - raise ValueError("nickname must not have leading/trailing whitespace") - if len(nick) > int(NICK_MAX_CHARS): - raise ValueError("nickname too long") - if "\n" in nick or "\r" in nick or "\x00" in nick: - raise ValueError("nickname contains control characters") - try: - nick.encode("utf-8", "strict") - except UnicodeError as e: - raise ValueError(f"nickname is not valid UTF-8: {e}") from e + # Per spec, nicknames are advisory and may be empty or "ridiculous". + # Type-check only; implementations may sanitize/ignore for display. diff --git a/rrcd/service.py b/rrcd/service.py index f5bd0d7..7ccf5eb 100644 --- a/rrcd/service.py +++ b/rrcd/service.py @@ -29,6 +29,7 @@ from .constants import ( T_MSG, T_NOTICE, T_PART, + T_PARTED, T_PING, T_PONG, T_WELCOME, @@ -671,6 +672,7 @@ class HubService: f"banned={len(old_banned)}->{len(new_banned)} " f"registered_rooms={len(old_registry)}->{len(new_registry)}" ) + lines.append(f"policy: nick_max_chars={new_cfg.nick_max_chars}") if cfg_changes: lines.append("config_changes:") @@ -1209,7 +1211,8 @@ class HubService: lines.append( f"limits: rate_limit_msgs_per_minute={self.config.rate_limit_msgs_per_minute} " f"max_rooms_per_session={self.config.max_rooms_per_session} " - f"max_room_name_len={self.config.max_room_name_len}" + f"max_room_name_len={self.config.max_room_name_len} " + f"nick_max_chars={self.config.nick_max_chars}" ) lines.append( f"features: ping_interval_s={self.config.ping_interval_s} " @@ -2151,7 +2154,7 @@ class HubService: if isinstance(body, dict): nick = body.get(B_HELLO_NICK) - n = normalize_nick(nick) + n = normalize_nick(nick, max_chars=self.config.nick_max_chars) if n is not None: sess["nick"] = n @@ -2296,6 +2299,21 @@ class HubService: self._persist_room_state_to_registry(link, r) if st is not None and not st.get("registered"): self._room_state.pop(r, None) + + # Per spec: acknowledge PART with PARTED. + parted_body = None + if self.config.include_joined_member_list: + members: list[bytes] = [] + for member_link in self.rooms.get(r, set()): + s = self.sessions.get(member_link) + ph = s.get("peer") if s else None + if isinstance(ph, (bytes, bytearray)): + members.append(bytes(ph)) + parted_body = members + + if self.identity is not None: + parted = make_envelope(T_PARTED, src=self.identity.hash, room=r, body=parted_body) + self._queue_env(outgoing, link, parted) return if t in (T_MSG, T_NOTICE): @@ -2352,8 +2370,9 @@ class HubService: # Backwards-compatible extension: hub can attach the nickname learned # from HELLO so clients can render a human-friendly name. nick = sess.get("nick") - if isinstance(nick, str) and nick.strip(): - env[K_NICK] = nick.strip() + n = normalize_nick(nick, max_chars=self.config.nick_max_chars) + if n is not None: + env[K_NICK] = n else: # Prevent client-supplied spoofed nicknames. env.pop(K_NICK, None) diff --git a/rrcd/util.py b/rrcd/util.py index a2abae6..6e42069 100644 --- a/rrcd/util.py +++ b/rrcd/util.py @@ -2,14 +2,14 @@ from __future__ import annotations import os -from .constants import NICK_MAX_CHARS +_DEFAULT_NICK_MAX_CHARS = 32 def expand_path(p: str) -> str: return os.path.expanduser(os.path.expandvars(p)) -def normalize_nick(value) -> str | None: +def normalize_nick(value, *, max_chars: int = _DEFAULT_NICK_MAX_CHARS) -> str | None: if not isinstance(value, str): return None @@ -17,7 +17,12 @@ def normalize_nick(value) -> str | None: if not s: return None - if len(s) > int(NICK_MAX_CHARS): + try: + limit = int(max_chars) + except Exception: + limit = int(_DEFAULT_NICK_MAX_CHARS) + + if limit > 0 and len(s) > limit: return None # Keep this conservative: avoid embedded newlines or NUL, which frequently diff --git a/tests/test_envelope.py b/tests/test_envelope.py index 1dedb3c..afdaa57 100644 --- a/tests/test_envelope.py +++ b/tests/test_envelope.py @@ -26,18 +26,14 @@ def test_validate_accepts_optional_nick_extension() -> None: validate_envelope(env) -def test_validate_rejects_nick_too_long() -> None: +def test_validate_allows_ridiculous_or_empty_nick() -> None: env = make_envelope(T_HELLO, src=b"peer", body=None) - env[K_NICK] = "a" * 33 - with pytest.raises(ValueError): - validate_envelope(env) + env[K_NICK] = "" + validate_envelope(env) - -def test_validate_rejects_nick_with_whitespace() -> None: env = make_envelope(T_HELLO, src=b"peer", body=None) - env[K_NICK] = " alice " - with pytest.raises(ValueError): - validate_envelope(env) + env[K_NICK] = " " + validate_envelope(env) def test_validate_rejects_missing_required_key() -> None: