mirror of
https://github.com/kc1awv/rrcd.git
synced 2026-05-17 13:24:45 -07:00
Merge pull request #3 from kc1awv/the_departed
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`
|
link. Clients should treat `K_NICK` as optional and fall back to `K_SRC`
|
||||||
when it is missing.
|
when it is missing.
|
||||||
|
|
||||||
Nickname policy (current implementation): trimmed Unicode string, UTF-8
|
Nicknames are advisory only; clients should treat them as display hints.
|
||||||
encodable on the wire, maximum 32 characters.
|
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:
|
Configure trusted operators and banned identities in the TOML config:
|
||||||
|
|
||||||
|
|||||||
@@ -121,6 +121,10 @@ room_invite_timeout_s = 900.0
|
|||||||
# Optional behaviors.
|
# Optional behaviors.
|
||||||
include_joined_member_list = false
|
include_joined_member_list = false
|
||||||
|
|
||||||
|
# Nickname policy.
|
||||||
|
# Maximum accepted nickname length (Unicode characters). 0 disables length limiting.
|
||||||
|
nick_max_chars = 32
|
||||||
|
|
||||||
# Limits.
|
# Limits.
|
||||||
max_rooms_per_session = 32
|
max_rooms_per_session = 32
|
||||||
max_room_name_len = 64
|
max_room_name_len = 64
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ class HubRuntimeConfig:
|
|||||||
# Invite timeout for keyed rooms (+k). Invites are removed on join or expiry.
|
# Invite timeout for keyed rooms (+k). Invites are removed on join or expiry.
|
||||||
room_invite_timeout_s: float = 900.0
|
room_invite_timeout_s: float = 900.0
|
||||||
include_joined_member_list: bool = False
|
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_rooms_per_session: int = 32
|
||||||
max_room_name_len: int = 64
|
max_room_name_len: int = 64
|
||||||
rate_limit_msgs_per_minute: int = 240
|
rate_limit_msgs_per_minute: int = 240
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ K_SRC = 4
|
|||||||
K_ROOM = 5
|
K_ROOM = 5
|
||||||
K_BODY = 6
|
K_BODY = 6
|
||||||
K_NICK = 7
|
K_NICK = 7
|
||||||
NICK_MAX_CHARS = 32
|
|
||||||
|
|
||||||
# Message types
|
# Message types
|
||||||
T_HELLO = 1
|
T_HELLO = 1
|
||||||
@@ -20,6 +19,7 @@ T_WELCOME = 2
|
|||||||
T_JOIN = 10
|
T_JOIN = 10
|
||||||
T_JOINED = 11
|
T_JOINED = 11
|
||||||
T_PART = 12
|
T_PART = 12
|
||||||
|
T_PARTED = 13
|
||||||
|
|
||||||
T_MSG = 20
|
T_MSG = 20
|
||||||
T_NOTICE = 21
|
T_NOTICE = 21
|
||||||
|
|||||||
@@ -3,18 +3,7 @@ from __future__ import annotations
|
|||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from .constants import (
|
from .constants import K_BODY, K_ID, K_NICK, K_ROOM, K_SRC, K_T, K_TS, K_V, RRC_VERSION
|
||||||
K_BODY,
|
|
||||||
K_ID,
|
|
||||||
K_NICK,
|
|
||||||
K_ROOM,
|
|
||||||
K_SRC,
|
|
||||||
K_T,
|
|
||||||
K_TS,
|
|
||||||
K_V,
|
|
||||||
NICK_MAX_CHARS,
|
|
||||||
RRC_VERSION,
|
|
||||||
)
|
|
||||||
from .util import normalize_nick
|
from .util import normalize_nick
|
||||||
|
|
||||||
|
|
||||||
@@ -103,17 +92,5 @@ def validate_envelope(env: dict) -> None:
|
|||||||
nick = env[K_NICK]
|
nick = env[K_NICK]
|
||||||
if not isinstance(nick, str):
|
if not isinstance(nick, str):
|
||||||
raise TypeError("nickname must be a string")
|
raise TypeError("nickname must be a string")
|
||||||
if nick.strip() == "":
|
# Per spec, nicknames are advisory and may be empty or "ridiculous".
|
||||||
raise ValueError("nickname must not be empty")
|
# Type-check only; implementations may sanitize/ignore for display.
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ from .constants import (
|
|||||||
T_MSG,
|
T_MSG,
|
||||||
T_NOTICE,
|
T_NOTICE,
|
||||||
T_PART,
|
T_PART,
|
||||||
|
T_PARTED,
|
||||||
T_PING,
|
T_PING,
|
||||||
T_PONG,
|
T_PONG,
|
||||||
T_WELCOME,
|
T_WELCOME,
|
||||||
@@ -671,6 +672,7 @@ class HubService:
|
|||||||
f"banned={len(old_banned)}->{len(new_banned)} "
|
f"banned={len(old_banned)}->{len(new_banned)} "
|
||||||
f"registered_rooms={len(old_registry)}->{len(new_registry)}"
|
f"registered_rooms={len(old_registry)}->{len(new_registry)}"
|
||||||
)
|
)
|
||||||
|
lines.append(f"policy: nick_max_chars={new_cfg.nick_max_chars}")
|
||||||
|
|
||||||
if cfg_changes:
|
if cfg_changes:
|
||||||
lines.append("config_changes:")
|
lines.append("config_changes:")
|
||||||
@@ -1209,7 +1211,8 @@ class HubService:
|
|||||||
lines.append(
|
lines.append(
|
||||||
f"limits: rate_limit_msgs_per_minute={self.config.rate_limit_msgs_per_minute} "
|
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_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(
|
lines.append(
|
||||||
f"features: ping_interval_s={self.config.ping_interval_s} "
|
f"features: ping_interval_s={self.config.ping_interval_s} "
|
||||||
@@ -2151,7 +2154,7 @@ class HubService:
|
|||||||
|
|
||||||
if isinstance(body, dict):
|
if isinstance(body, dict):
|
||||||
nick = body.get(B_HELLO_NICK)
|
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:
|
if n is not None:
|
||||||
sess["nick"] = n
|
sess["nick"] = n
|
||||||
|
|
||||||
@@ -2296,6 +2299,21 @@ class HubService:
|
|||||||
self._persist_room_state_to_registry(link, r)
|
self._persist_room_state_to_registry(link, r)
|
||||||
if st is not None and not st.get("registered"):
|
if st is not None and not st.get("registered"):
|
||||||
self._room_state.pop(r, None)
|
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
|
return
|
||||||
|
|
||||||
if t in (T_MSG, T_NOTICE):
|
if t in (T_MSG, T_NOTICE):
|
||||||
@@ -2352,8 +2370,9 @@ class HubService:
|
|||||||
# Backwards-compatible extension: hub can attach the nickname learned
|
# Backwards-compatible extension: hub can attach the nickname learned
|
||||||
# from HELLO so clients can render a human-friendly name.
|
# from HELLO so clients can render a human-friendly name.
|
||||||
nick = sess.get("nick")
|
nick = sess.get("nick")
|
||||||
if isinstance(nick, str) and nick.strip():
|
n = normalize_nick(nick, max_chars=self.config.nick_max_chars)
|
||||||
env[K_NICK] = nick.strip()
|
if n is not None:
|
||||||
|
env[K_NICK] = n
|
||||||
else:
|
else:
|
||||||
# Prevent client-supplied spoofed nicknames.
|
# Prevent client-supplied spoofed nicknames.
|
||||||
env.pop(K_NICK, None)
|
env.pop(K_NICK, None)
|
||||||
|
|||||||
11
rrcd/util.py
11
rrcd/util.py
@@ -2,14 +2,14 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from .constants import NICK_MAX_CHARS
|
_DEFAULT_NICK_MAX_CHARS = 32
|
||||||
|
|
||||||
|
|
||||||
def expand_path(p: str) -> str:
|
def expand_path(p: str) -> str:
|
||||||
return os.path.expanduser(os.path.expandvars(p))
|
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):
|
if not isinstance(value, str):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -17,7 +17,12 @@ def normalize_nick(value) -> str | None:
|
|||||||
if not s:
|
if not s:
|
||||||
return None
|
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
|
return None
|
||||||
|
|
||||||
# Keep this conservative: avoid embedded newlines or NUL, which frequently
|
# 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)
|
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 = make_envelope(T_HELLO, src=b"peer", body=None)
|
||||||
env[K_NICK] = "a" * 33
|
env[K_NICK] = ""
|
||||||
with pytest.raises(ValueError):
|
validate_envelope(env)
|
||||||
validate_envelope(env)
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_rejects_nick_with_whitespace() -> None:
|
|
||||||
env = make_envelope(T_HELLO, src=b"peer", body=None)
|
env = make_envelope(T_HELLO, src=b"peer", body=None)
|
||||||
env[K_NICK] = " alice "
|
env[K_NICK] = " "
|
||||||
with pytest.raises(ValueError):
|
validate_envelope(env)
|
||||||
validate_envelope(env)
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_rejects_missing_required_key() -> None:
|
def test_validate_rejects_missing_required_key() -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user