Refine nickname handling and implement PARTED message type

This commit is contained in:
kc1awv
2025-12-30 16:44:45 -05:00
parent aeee26954a
commit 423f6e45ac
8 changed files with 56 additions and 45 deletions

View File

@@ -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:

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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)

View File

@@ -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

View File

@@ -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: