mirror of
https://github.com/kc1awv/rrcd.git
synced 2026-05-04 08:39:07 -07:00
Refine nickname handling and implement PARTED message type
This commit is contained in:
@@ -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:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
11
rrcd/util.py
11
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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user