Proof of concept mechanical port of ircnet/ircd to Rust as part of a bit about C being insecure for network services
0

Configure Feed

Select the types of activity you want to include in your feed.

feat(leveva): caller-id (+g user mode + ACCEPT) — P11 slice 216

Add charybdis-style caller-id ("server-side ignore"): a user sets +g to
receive private PRIVMSG/NOTICE only from clients on their ACCEPT list. A
blocked PRIVMSG bounces 716 to the sender and notifies the target once
(717 to sender + 718 to target); a NOTICE is silently dropped.

The pure CallerId store (accept set + once-per-pair notify gate) lives
inside Registry — the metadata precedent — so it costs no ServerContext
churn; accept entries are stable UIDs (survive nick changes, quit-filtered
on read), capped at 30. +g is purely local (not in SEND_UMODES): the gate
runs on the target's home server, so a transit/origin server needs no copy.
ACCEPT supports add / -remove (mixed) / list (281+282), with 401/456/457/458;
self-accept is a no-op. CALLERID=g advertised in 005; 8 new numerics.

Picked after confirming no clean documented deferral remained open — the
STS-REHASH / S2S-KILL-notice / throttle-knob / MODE+VERSION-proptest /
UTF8ONLY "deferred" doc comments were all stale, closed by later slices.

Remote-sender gating (S2S) and +G soft caller-id are documented follow-ons.

Tests: callerid unit (6), accept (8), message gate (5), numeric round-trip,
isupport token, s2s/umode excludes +g; golden_callerid (2); callerid_proptest
(2, accept-list + notify model lockstep). Boot snapshots regenerated for the
new 004 'g' umode letter and the 005 CALLERID=g token.

Assisted-by: Claude Opus 4.8 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>

+1235 -29
+1 -1
PLAN.md
··· 90 90 | **P8 — Delete the C + retire the oracle harness ✅ DONE** (per-sub-phase: [`PLAN-P8-progress.md`](PLAN-P8-progress.md) + [`docs/progress-log/p8.md`](docs/progress-log/p8.md)) | remaining C (the ~25 variadic sender trampolines + residual data globals) + `ircd-sys` C build + `ircd-golden` (L2) + `ircd-testkit`/`cref_*` archives (L1) + the frozen reference‑C tree | With all C _logic_ already Rust (P7 exit), delete the last C: replace the variadic sender trampolines with a non‑variadic Rust sender API at every call site (faithful `wire!`/`reply_one!`/`format!` first; leveva typed messages adopted alongside the IRC senders), drop the residual data globals via the data‑symbol seam (bindgen `extern` decls resolve to `#[no_mangle]` Rust defs at link), then drop **all** C compilation from `ircd-sys` (no more `cc::Build`), retire the differential harness (`cref_*`/L1 `ircd-testkit`/`ircd-golden` L2 can no longer be built once the oracle is gone), and **migrate the load‑bearing tests into `ircd-common`** as self‑contained Rust tests (own fixtures, no `cref_` oracle). **Progress (P8a–P8r — full entries in the progress logs):** **every variadic sender trampoline is now Rust** (P8a–P8m, incl. the keystones `sendto_one`/`sendto_flag`/`esendto_*`), and the five data‑global `.o`s are dropped outright (`s_auth.o` iauth globals P8n, `send.o` P8o, `s_bsd.o` `local[]`/`highest_fd`/… P8p, `ircd.o` `me`/`client`/timers/… P8q). **P8r dropped the last C‑logic TU `support.o`** — `dgets`/`make_isupport` ported to Rust + the `ipv6string`/`minus_one` data globals defined as `#[no_mangle]` statics (`snprintf_append` is dead in Rust — WHOX is field‑by‑field — so it died with the `.o`; `irc_sprintf` is deleted not ported, in P11). **Every ircd C TU is now Rust:** the link set holds only the generated `version.o` + the L1 harness's `ctruth.o`. **P8s migrated all 115 L1 `cref_` differentials into self‑contained `ircd-common` `insta` snapshot tests** (drive only the Rust port via `link_anchor()`, no oracle; soundness via the capture chain) — the suite is green + 0 warnings. **P8t ported the last *generated* C TU `version.c` to Rust** (`ircd-common/src/version.rs`): the data globals `generation`/`creation`/`pass_version`/`infotext`/`isupport` are now `#[no_mangle]` statics via the data‑symbol seam — `generation`/`creation` sourced from crate metadata at build time (`CARGO_PKG_VERSION` + an `ircd-common/build.rs` UTC build stamp), `pass_version`/`infotext` copied verbatim. `version.c.SH` generation + the `version.o` compile/archive are gone from `ircd-sys/build.rs`; `libircd_c.a` now holds **only** the L1 harness `ctruth.o`. (`creation` is now genuinely build‑time volatile, so the golden canonicalizer masks RPL_CREATED 003, matching the existing 371 Birth‑Date rule.) **P8u (FINAL) retired the oracle.** Ran the differential suite a last time green — L1 `ircd-testkit` all pass; L2 `ircd-golden` 86 pass + only the documented reference‑C‑garbage `s_serv_stats` flake (where Rust is the *correct* side) — then **dropped all C compilation** from `ircd-sys/build.rs`: the `make` object build, the `cref_*`/`ctruth.o`/`res.o` recompiles, the `libircd_c.a` archive, and the whole‑archive link + `-lz`/`-lm`/`-lcrypt` (none needed by Rust — `pow` resolves from glibc 2.29+). `build.rs` now only runs `configure` + bindgen over the C *headers* (the struct view `ircd-common` still uses until P9–P12) and expands the install‑path Makefile vars. `ircd-testkit` (L1) + `ircd-golden` (L2) are `exclude`d from the workspace (mothballed, git‑recoverable); the `ctruth.c`/`layout.rs` drift‑net is deleted. **The workspace is now 100% Rust — zero C TUs compiled.** **P8v (literal end of C) deleted the C *source tree* itself + retired `ircd-sys`:** froze the generated `bindings.rs` into a committed `ircd-common/src/bindings.rs` (a self‑alias `extern crate self as ircd_sys;` keeps every `ircd_sys::bindings::*` path resolving, zero src churn) + the install‑path consts as literals, then deleted `ircd-sys`, the mothballed `ircd-testkit`/`ircd-golden` oracle crates, the `iauth-rs` C‑iauth differential, and **all** C source (`common/ ircd/ iauth/ support/ contrib/ cbuild/ configure .clang-format* clangformat.yml`). `ircd-rs` calls `ircd_common::ircd::c_ircd_main` directly. **Not one line of C remains in the repo.** | **MET. Final differential run green; oracle mothballed; `cargo build --workspace` 0 warnings links pure Rust; `cargo test --workspace` 695 pass / 0 fail (P8v; was 698 before deleting the C‑oracle tests); the `ircd-common` snapshot tests carry the reference‑C correctness forward. C source tree deleted — repo is 100% Rust at the file level.** | 91 91 | **P9 — std‑library cleanup ❌ RETIRED (2026‑06‑08)** | — (was: whole Rust tree) | **Skipped by decision — superseded by the `leveva` greenfield.** P9 assumed an *in‑place* idiomatic transformation of the mechanical port (`MyMalloc`/`MyFree`→`Box`/`Vec`, `dbuf` freelist→owned, raw `libc`→`std`/`nix`, `[c_char;N]`→`[u8;N]`). Because the idiomatic end‑state is now a separate greenfield crate (`leveva`), `ircd-common` is throwaway code (the oracle, deleted at parity), so polishing it earns nothing. Nothing was implemented; the row is kept for provenance. | n/a — retired. | 92 92 | **P10 — `leveva` greenfield foundations 🔶 IN PROGRESS** ([`docs/progress-log/p10.md`](docs/progress-log/p10.md)) | new `leveva` crate (lib) + `leveva-integration` differential tests | The idiomatic‑Rust product, built from scratch (not transformed from the port). **Done so far:** RFC 1459 case‑folding (`casemap`); typed identifier strings (`IrcStr`/`IrcString` + `Nick`/`ChanName`/`Uid`/`Sid`/`Cid`/`ServerName`/`UserName`/`HostName` validating newtypes via `ident_newtype!`); `Message`/`MessageBuilder`; the full `Numeric` reply/error enum (184 codes, discriminant = wire code); a generic safe `patricia` trie; glob `matching`; `mode`; KDL `config` (model/parse/password/privilege); `rustls`/`tokio` `tls` endpoint with hot reload; `ident`. The async boot path (`main.rs`) binds listeners + watches certs but currently drops accepted connections — no protocol layer yet. Idiomatic Rust allowed everywhere; pulls real crates (tokio/rustls/kdl/nom/clap). | `cargo test -p leveva` green + clippy clean per module; `leveva-integration` pins each greenfield reimplementation against `ircd-common` (the oracle) within their shared domain (documented divergences asserted explicitly). | 93 - | **P11 — `leveva` protocol layer → feature parity 🔶 IN PROGRESS** ([`docs/progress-log/p11.md`](docs/progress-log/p11.md)) | `leveva` (client/server state machine + handlers) | The bulk of the remaining work: the connection + registration state machine, the post‑registration command handlers (JOIN/PRIVMSG/MODE/KICK/TOPIC/WHO/WHOIS/…), channels + channel/user modes, the S2S link/burst protocol (UID‑based: UNICK/NJOIN/SAVE/EOB), and `leveva-iauth` (native async auth — dnsbl/socks/webproxy/pipe/ident). Each slice is built idiomatically (owned per‑connection state, typed identifiers end‑to‑end, `Result`/`thiserror`, `format!`/typed builders — **no** `sprintf`/global `buf`), unit‑tested + boot‑golden + proptest‑fuzzed, and **differentially pinned against `ircd-common`** (or `iauth-rs` for auth) wherever a pure oracle entry point exists — structural skeleton + documented divergences, since leveva emits a clean modern burst rather than a byte copy. **Done: 215 slices.** The slices span the full client command surface (registration, channels + channel/user modes, WHO/WHOIS/WHOX, OPER/STATS), the UID-based S2S link/burst/netsplit protocol (+ keepalive, live introduction, NJOIN chunking), `leveva-iauth` (dnsbl/socks/webproxy/pipe/ident), config live-rehash, a channel-mode rework, CertFP, an ongoing **IRCv3 track**, and the native cross-daemon command-skeleton differential harness; an emerging **STS/TLS** track; 195 = generalize the restricted-reop ban into the `enforce_reop` chokepoint so a restricted user is never auto-reopped by *any* trigger; 196 = a restricted user cannot change nick (`484`), completing the IRCnet `IsRestricted` family; 197 = close the slice-191 follow-on — a `kline-exempt` I-line (the C `FLAGS_EXEMPT`) now survives a *runtime* `TKLINE` reap, not just the registration-time K-line gate; 198 = `KNOCK` (leveva-native, charybdis-style) — a user kept out of a `+i`/`+k`/`+l` channel asks its operators for an invite (`710`/`711`), gated by the shared JOIN `check_join` so KNOCK and JOIN never disagree (banned/`+O`/open/already-member all refused), S2S propagation deferred; 199 = close that follow-on — KNOCK now propagates as `ENCAP * KNOCK` (modelled on `s2s::metadata`/`su`) so a channel operator on *any* server is notified, not just local ops (the shared `deliver_knock` fan; `712` throttle still deferred); 200 = close that last follow-on — a per-channel KNOCK flood throttle (`knock_throttle::KnockThrottle`, charybdis `knock_delay_channel`, 60s): a channel admits at most one KNOCK per window, a knock inside it is refused `712 ERR_TOOMANYKNOCK` and notifies nobody (the KNOCK family 198/199/200 is now complete); 201 = command-rate flood protection (the IRCnet "fakelag" penalty clock, deferred since the start) — every registered client's command charges `1 + len/100` onto a per-connection clock that idle-decays to real time, and a sustained flood is closed with `ERROR :Closing Link: <host> (Excess Flood)`, lighting up the previously-dead `can-flood`/`no-penalty` oper exemptions; pure `flood::FloodClock`, armed by the serve loop (the `heartbeat` opt-in precedent), kill-only (fakelag *delay* + per-command cost table deferred); 202 = close that follow-on — a per-command penalty cost table (`flood::command_penalty`): `PONG` is exempt, whole-table scans (`WHO`/`WHOIS`/`WHOWAS`/`LIST`/`NAMES`) cost +4 and channel ops (`JOIN`/`KNOCK`/`INVITE`) +2 over the base, so a flood of expensive commands trips Excess Flood faster than a flood of cheap ones (leveva-native cost tiers; the fakelag *delay* half + a pre-registration count guard remain the follow-ons); 203 = close the pre-registration guard follow-on — the slice-201 penalty clock only billed *registered* clients, so an unregistered connection could spam commands unboundedly (every unknown verb `451`-replied); now `Session::dispatch` charges the same `FloodClock` for the `Registering` phase too (after QUIT, before CAP; `PONG` exempt, no oper exemption pre-reg), closing a pre-registration flood with the oracle's exact `ERROR ... (Excess Flood)`; 204 = close the last flood follow-on but one — the kill ceiling (`flood::FLOOD_KILL_AHEAD`, was a fixed `const 60`) is now the tunable `options { flood-kill-ahead N }` (`charge` takes it as a parameter; parser rejects `0`; default 60), hot-reloaded by `REHASH` via the slice-91/175 `ConfStore` live-or-default seam (no `ServerContext` field — flood is serve-loop-armed, so `main` always seeds the live store first) — only the fakelag *delay/smoothing* half now remains deferred; 205 = **phase 13 (refinement)** opens — `REHASH` hot-reloads `options { auto-connect }`: the outgoing auto-connect master switch (was read once at boot to *spawn* the dial loop) is now read **per tick** via the slice-91/204 `ConfStore` live seam (`live_auto_connect`), so the loop always spawns but skips dialing while off and an operator can start/stop the dialer with no restart — no `AbortHandle` task-management (an idle ticker is cheap), no `ServerContext` field (slice-204 precedent, `main` seeds before spawning); 206 = enforce + REHASH-able `options { connection-accept on|off|split }` — the previously parsed-but-dead new-connection admission gate is now enforced at registration finalize (`On` accepts, `Off` refuses everyone with a bare `ERROR :Closing Link: <host> (Server is not accepting connections)`, `Split` accepts only while the net is split per the now-live `split-min-servers`/`split-min-users` thresholds), live via the canonical `live_*().unwrap_or(ctx.…)` `ConfStore` seam (a ctx field this time — the gate's read site is unit-tested, unlike 204/205 — bundled into `StatsConf` so the 62 hand-rolled `ServerContext` literals stay churn-free via `StatsConf::default()`, the slice-191 `bans` precedent); pure `connaccept::is_split`/`admits` (no `options` field remains boot-immutable); 207 = `REHASH` hot-reloads the `iauth` block (ident flag, per-probe timeout, and the dnsbl/socks/webproxy/pipe/ident module set) — the **last** boot-immutable config block — via the `ConfStore` live seam: `ConfBlocks` carries the plain `Iauth` struct while the *built* module set (`Arc<dyn Module>`, not `Debug/Eq`) is cached in a separate `ArcSwapOption` rebuilt only at seed/REHASH (`build_auth_modules` made `pub(crate)`, the server name supplied by a new boot `IRCD_NAME` OnceLock mirroring `CONFIG_PATH`), so live connections keep one shared resolver/DNS cache and a REHASH swaps the set atomically; read sites = the serve loop's auth task (modules+timeout), `resolve_username` (the `~` rule's ident flag), and `STATS a` (the 239 module list); no config block remains boot-immutable now (only the unchangeable `server {}` identity stays fixed by nature); 208 = close the **last** deferred flood follow-on — the fakelag *delay/smoothing* half (kill-only since slice 201): while the penalty clock runs more than `flood::MAX_PENALTY` (10s, the oracle's fixed `MAXPENALTY`) ahead but below the kill ceiling, the serve loop now **delays** the connection's next read (`tokio::time::sleep_until` on the `select!` read arm, output arms still live) instead of disconnecting, pacing a flooder's *input* toward one command per second while its fan-out/keepalive keep flowing — pure `FloodClock::delay_secs` + `Session::flood_delay_secs` (0 when disabled/within-budget/exempt), `charge` unchanged so all 201–204 tests hold; the flood family is now complete (only a `MAX_PENALTY` config knob remains as an optional future refinement); 209 = close the **last** deferred KNOCK gap — the per-*user* throttle (charybdis `knock_delay`, 300s) alongside slice 200's per-*channel* one: a single knocker may deliver at most one KNOCK per window across *all* channels (keyed by UID in a second `knock_user_throttle: KnockThrottle`), so one user can no longer spray operators on many fresh channels; the command peeks both throttles (new `cooling`/`arm` split, arming neither unless the knock is delivered — charybdis updates its clocks on success only) and answers `712 ERR_TOOMANYKNOCK` if either is cooling — the KNOCK throttle family (200/209) is now complete; 210 = turn the last hard-coded throttle thresholds into `REHASH`-able `options` knobs — `flood::MAX_PENALTY`→`max-penalty`, charybdis `knock_delay`→`knock-delay`, `knock_delay_channel`→`knock-delay-channel` (all via the slice-204 `ConfStore` live seam; `0` legal = aggressive-smoothing / throttle-disabled), so no throttle budget remains a boot-immutable const; 211 = `sts` (IRCv3 Strict Transport Security) — leveva advertises the advisory-only `sts` cap in `CAP LS` to a `302+` client (a plaintext client gets `sts=port=<tls-port>` to upgrade, a TLS client `sts=duration=<n>[,preload]` to persist), gated on a real TLS client listener + `options { sts-duration N; sts-preload }` (`0`/absent = off); never in `SUPPORTED` so `CAP REQ sts` NAKs and it never shows in `CAP LIST` (pure `sts::StsPolicy::ls_value` + a new `Session.secure` flag set by `serve_client`; REHASH-ability of the knobs + wss-as-secure deferred); 212 = close the REHASH follow-on — the whole `sts` policy now rides the live config seam: `sts-duration`/`sts-preload` move onto the `ConfStore` (slice-204/210 idiom, `live_sts_duration`/`live_sts_preload`) and the TLS port is re-derived **live** from the running listener set (`session::live_sts_port` over `listeners::specs()`, which slice 137 already rebinds on REHASH), so an operator enables/retunes/disables STS — including binding a fresh TLS listener — with no restart (only wss-as-secure now remains deferred from the `sts` track); 213 = close that last STS deferral (wss-as-secure) — a TLS-protected WebSocket is now flagged `secure` so its `CAP LS` `sts` token is the persistence `duration=` not the upgrade `port=`: `serve_ws` finally calls `Session::set_secure`, true when the listener TLS-terminated the `wss://` connection **or** a trusted reverse proxy (`proxy-ranges`) forwarded `X-Forwarded-Proto: https`/`wss` (the pure spoof-resistant `websocket::forwarded_proto_secure` reusing the `resolve_host` trust gate), making the `sts` track complete; 214 = close the deferred extban "PRIVMSG/quiet plane" (slice 164) — a `+b` ban (or extban) now **mutes** a non-voiced channel member on the speech path, not just at JOIN: `Channels::can_send` builds the same `extban::Subject`/`LiveChannels` view `check_join` uses and returns a new `CanSend::Banned` (→ `404 ERR_CANNOTSENDTOCHAN`) when the sender matches a `+b` with no `+e` and is below voice rank (voice+ bypasses, exactly as for `+m`), so a ban set *after* a user joined — or a dynamic extban (`$a:account`/`$r:realname`/`$x`) they later match — silences them; wired into `PRIVMSG`/`NOTICE` (`command::message`), `TAGMSG`, and `REDACT` via the shared `command::speech_subject` helper (a dedicated `+q` quiet *list* mode is out of scope — `+q` is the elemental OWNER prefix); 215 = fix a production relink desync (updating a server made it strip channel modes that should have survived a merge) — a channel learned **for the first time in a burst** was re-clocked to the receiver's local nanosecond clock instead of adopting the origin's `created_at` (the leading `CHANTS` is dropped on an unknown channel, and the burst `NJOIN` was TS-less), so two servers disagreed on a channel's TS by *when each learned it* and the next merge deopped the later learner (slice 167's divergence, left unfixed on the burst path); the burst `NJOIN` now carries the channel TS as a middle param (`:<sid> NJOIN <chan> <ts> :<members>`, the live form since slice 167) so the receiver adopts the origin's TS, with the chunk budget + boot-snapshot TS-mask updated to match. **Full per-slice detail (scope, mechanism, divergences, gate) for every slice lives in [`docs/progress-log/p11.md`](docs/progress-log/p11.md); the standing testing plan is [`docs/superpowers/specs/2026-06-10-leveva-testing-plan.md`](docs/superpowers/specs/2026-06-10-leveva-testing-plan.md). This row stays thin.** | Per‑slice: leveva boots and serves the slice; leveva golden snapshot + proptest green; the `leveva-integration` differential agrees with `ircd-common` on the shared skeleton with divergences asserted. Across the eventual matrix: registration, channels, WHO/WHOIS/WHOX, OPER/STATS, server burst, netsplit, rehash, restart; iauth end‑to‑end. | 93 + | **P11 — `leveva` protocol layer → feature parity 🔶 IN PROGRESS** ([`docs/progress-log/p11.md`](docs/progress-log/p11.md)) | `leveva` (client/server state machine + handlers) | The bulk of the remaining work: the connection + registration state machine, the post‑registration command handlers (JOIN/PRIVMSG/MODE/KICK/TOPIC/WHO/WHOIS/…), channels + channel/user modes, the S2S link/burst protocol (UID‑based: UNICK/NJOIN/SAVE/EOB), and `leveva-iauth` (native async auth — dnsbl/socks/webproxy/pipe/ident). Each slice is built idiomatically (owned per‑connection state, typed identifiers end‑to‑end, `Result`/`thiserror`, `format!`/typed builders — **no** `sprintf`/global `buf`), unit‑tested + boot‑golden + proptest‑fuzzed, and **differentially pinned against `ircd-common`** (or `iauth-rs` for auth) wherever a pure oracle entry point exists — structural skeleton + documented divergences, since leveva emits a clean modern burst rather than a byte copy. **Done: 216 slices.** The slices span the full client command surface (registration, channels + channel/user modes, WHO/WHOIS/WHOX, OPER/STATS), the UID-based S2S link/burst/netsplit protocol (+ keepalive, live introduction, NJOIN chunking), `leveva-iauth` (dnsbl/socks/webproxy/pipe/ident), config live-rehash, a channel-mode rework, CertFP, an ongoing **IRCv3 track**, and the native cross-daemon command-skeleton differential harness; an emerging **STS/TLS** track; 195 = generalize the restricted-reop ban into the `enforce_reop` chokepoint so a restricted user is never auto-reopped by *any* trigger; 196 = a restricted user cannot change nick (`484`), completing the IRCnet `IsRestricted` family; 197 = close the slice-191 follow-on — a `kline-exempt` I-line (the C `FLAGS_EXEMPT`) now survives a *runtime* `TKLINE` reap, not just the registration-time K-line gate; 198 = `KNOCK` (leveva-native, charybdis-style) — a user kept out of a `+i`/`+k`/`+l` channel asks its operators for an invite (`710`/`711`), gated by the shared JOIN `check_join` so KNOCK and JOIN never disagree (banned/`+O`/open/already-member all refused), S2S propagation deferred; 199 = close that follow-on — KNOCK now propagates as `ENCAP * KNOCK` (modelled on `s2s::metadata`/`su`) so a channel operator on *any* server is notified, not just local ops (the shared `deliver_knock` fan; `712` throttle still deferred); 200 = close that last follow-on — a per-channel KNOCK flood throttle (`knock_throttle::KnockThrottle`, charybdis `knock_delay_channel`, 60s): a channel admits at most one KNOCK per window, a knock inside it is refused `712 ERR_TOOMANYKNOCK` and notifies nobody (the KNOCK family 198/199/200 is now complete); 201 = command-rate flood protection (the IRCnet "fakelag" penalty clock, deferred since the start) — every registered client's command charges `1 + len/100` onto a per-connection clock that idle-decays to real time, and a sustained flood is closed with `ERROR :Closing Link: <host> (Excess Flood)`, lighting up the previously-dead `can-flood`/`no-penalty` oper exemptions; pure `flood::FloodClock`, armed by the serve loop (the `heartbeat` opt-in precedent), kill-only (fakelag *delay* + per-command cost table deferred); 202 = close that follow-on — a per-command penalty cost table (`flood::command_penalty`): `PONG` is exempt, whole-table scans (`WHO`/`WHOIS`/`WHOWAS`/`LIST`/`NAMES`) cost +4 and channel ops (`JOIN`/`KNOCK`/`INVITE`) +2 over the base, so a flood of expensive commands trips Excess Flood faster than a flood of cheap ones (leveva-native cost tiers; the fakelag *delay* half + a pre-registration count guard remain the follow-ons); 203 = close the pre-registration guard follow-on — the slice-201 penalty clock only billed *registered* clients, so an unregistered connection could spam commands unboundedly (every unknown verb `451`-replied); now `Session::dispatch` charges the same `FloodClock` for the `Registering` phase too (after QUIT, before CAP; `PONG` exempt, no oper exemption pre-reg), closing a pre-registration flood with the oracle's exact `ERROR ... (Excess Flood)`; 204 = close the last flood follow-on but one — the kill ceiling (`flood::FLOOD_KILL_AHEAD`, was a fixed `const 60`) is now the tunable `options { flood-kill-ahead N }` (`charge` takes it as a parameter; parser rejects `0`; default 60), hot-reloaded by `REHASH` via the slice-91/175 `ConfStore` live-or-default seam (no `ServerContext` field — flood is serve-loop-armed, so `main` always seeds the live store first) — only the fakelag *delay/smoothing* half now remains deferred; 205 = **phase 13 (refinement)** opens — `REHASH` hot-reloads `options { auto-connect }`: the outgoing auto-connect master switch (was read once at boot to *spawn* the dial loop) is now read **per tick** via the slice-91/204 `ConfStore` live seam (`live_auto_connect`), so the loop always spawns but skips dialing while off and an operator can start/stop the dialer with no restart — no `AbortHandle` task-management (an idle ticker is cheap), no `ServerContext` field (slice-204 precedent, `main` seeds before spawning); 206 = enforce + REHASH-able `options { connection-accept on|off|split }` — the previously parsed-but-dead new-connection admission gate is now enforced at registration finalize (`On` accepts, `Off` refuses everyone with a bare `ERROR :Closing Link: <host> (Server is not accepting connections)`, `Split` accepts only while the net is split per the now-live `split-min-servers`/`split-min-users` thresholds), live via the canonical `live_*().unwrap_or(ctx.…)` `ConfStore` seam (a ctx field this time — the gate's read site is unit-tested, unlike 204/205 — bundled into `StatsConf` so the 62 hand-rolled `ServerContext` literals stay churn-free via `StatsConf::default()`, the slice-191 `bans` precedent); pure `connaccept::is_split`/`admits` (no `options` field remains boot-immutable); 207 = `REHASH` hot-reloads the `iauth` block (ident flag, per-probe timeout, and the dnsbl/socks/webproxy/pipe/ident module set) — the **last** boot-immutable config block — via the `ConfStore` live seam: `ConfBlocks` carries the plain `Iauth` struct while the *built* module set (`Arc<dyn Module>`, not `Debug/Eq`) is cached in a separate `ArcSwapOption` rebuilt only at seed/REHASH (`build_auth_modules` made `pub(crate)`, the server name supplied by a new boot `IRCD_NAME` OnceLock mirroring `CONFIG_PATH`), so live connections keep one shared resolver/DNS cache and a REHASH swaps the set atomically; read sites = the serve loop's auth task (modules+timeout), `resolve_username` (the `~` rule's ident flag), and `STATS a` (the 239 module list); no config block remains boot-immutable now (only the unchangeable `server {}` identity stays fixed by nature); 208 = close the **last** deferred flood follow-on — the fakelag *delay/smoothing* half (kill-only since slice 201): while the penalty clock runs more than `flood::MAX_PENALTY` (10s, the oracle's fixed `MAXPENALTY`) ahead but below the kill ceiling, the serve loop now **delays** the connection's next read (`tokio::time::sleep_until` on the `select!` read arm, output arms still live) instead of disconnecting, pacing a flooder's *input* toward one command per second while its fan-out/keepalive keep flowing — pure `FloodClock::delay_secs` + `Session::flood_delay_secs` (0 when disabled/within-budget/exempt), `charge` unchanged so all 201–204 tests hold; the flood family is now complete (only a `MAX_PENALTY` config knob remains as an optional future refinement); 209 = close the **last** deferred KNOCK gap — the per-*user* throttle (charybdis `knock_delay`, 300s) alongside slice 200's per-*channel* one: a single knocker may deliver at most one KNOCK per window across *all* channels (keyed by UID in a second `knock_user_throttle: KnockThrottle`), so one user can no longer spray operators on many fresh channels; the command peeks both throttles (new `cooling`/`arm` split, arming neither unless the knock is delivered — charybdis updates its clocks on success only) and answers `712 ERR_TOOMANYKNOCK` if either is cooling — the KNOCK throttle family (200/209) is now complete; 210 = turn the last hard-coded throttle thresholds into `REHASH`-able `options` knobs — `flood::MAX_PENALTY`→`max-penalty`, charybdis `knock_delay`→`knock-delay`, `knock_delay_channel`→`knock-delay-channel` (all via the slice-204 `ConfStore` live seam; `0` legal = aggressive-smoothing / throttle-disabled), so no throttle budget remains a boot-immutable const; 211 = `sts` (IRCv3 Strict Transport Security) — leveva advertises the advisory-only `sts` cap in `CAP LS` to a `302+` client (a plaintext client gets `sts=port=<tls-port>` to upgrade, a TLS client `sts=duration=<n>[,preload]` to persist), gated on a real TLS client listener + `options { sts-duration N; sts-preload }` (`0`/absent = off); never in `SUPPORTED` so `CAP REQ sts` NAKs and it never shows in `CAP LIST` (pure `sts::StsPolicy::ls_value` + a new `Session.secure` flag set by `serve_client`; REHASH-ability of the knobs + wss-as-secure deferred); 212 = close the REHASH follow-on — the whole `sts` policy now rides the live config seam: `sts-duration`/`sts-preload` move onto the `ConfStore` (slice-204/210 idiom, `live_sts_duration`/`live_sts_preload`) and the TLS port is re-derived **live** from the running listener set (`session::live_sts_port` over `listeners::specs()`, which slice 137 already rebinds on REHASH), so an operator enables/retunes/disables STS — including binding a fresh TLS listener — with no restart (only wss-as-secure now remains deferred from the `sts` track); 213 = close that last STS deferral (wss-as-secure) — a TLS-protected WebSocket is now flagged `secure` so its `CAP LS` `sts` token is the persistence `duration=` not the upgrade `port=`: `serve_ws` finally calls `Session::set_secure`, true when the listener TLS-terminated the `wss://` connection **or** a trusted reverse proxy (`proxy-ranges`) forwarded `X-Forwarded-Proto: https`/`wss` (the pure spoof-resistant `websocket::forwarded_proto_secure` reusing the `resolve_host` trust gate), making the `sts` track complete; 214 = close the deferred extban "PRIVMSG/quiet plane" (slice 164) — a `+b` ban (or extban) now **mutes** a non-voiced channel member on the speech path, not just at JOIN: `Channels::can_send` builds the same `extban::Subject`/`LiveChannels` view `check_join` uses and returns a new `CanSend::Banned` (→ `404 ERR_CANNOTSENDTOCHAN`) when the sender matches a `+b` with no `+e` and is below voice rank (voice+ bypasses, exactly as for `+m`), so a ban set *after* a user joined — or a dynamic extban (`$a:account`/`$r:realname`/`$x`) they later match — silences them; wired into `PRIVMSG`/`NOTICE` (`command::message`), `TAGMSG`, and `REDACT` via the shared `command::speech_subject` helper (a dedicated `+q` quiet *list* mode is out of scope — `+q` is the elemental OWNER prefix); 215 = fix a production relink desync (updating a server made it strip channel modes that should have survived a merge) — a channel learned **for the first time in a burst** was re-clocked to the receiver's local nanosecond clock instead of adopting the origin's `created_at` (the leading `CHANTS` is dropped on an unknown channel, and the burst `NJOIN` was TS-less), so two servers disagreed on a channel's TS by *when each learned it* and the next merge deopped the later learner (slice 167's divergence, left unfixed on the burst path); the burst `NJOIN` now carries the channel TS as a middle param (`:<sid> NJOIN <chan> <ts> :<members>`, the live form since slice 167) so the receiver adopts the origin's TS, with the chunk budget + boot-snapshot TS-mask updated to match; 216 = caller-id — a leveva-native charybdis-style `+g` ("server-side ignore") user mode + `ACCEPT` command: a `+g` user receives private `PRIVMSG`/`NOTICE` only from clients on its accept list, else the sender gets `716` (+ once-per-sender `717`) and the target is told once (`718`), NOTICE silently dropped; the `ACCEPT` list (add/`-`remove/`*`list, `281`/`282`/`456`/`457`/`458`/`401`) is the pure `callerid::CallerId` store owned inside `Registry` (the `metadata` zero-churn precedent), entries are stable UIDs, `+g` is purely local (not in `SEND_UMODES` — the gate runs on the target's home server) so remote-sender gating + `+G` soft caller-id are documented follow-ons; `CALLERID=g` ISUPPORT. **Full per-slice detail (scope, mechanism, divergences, gate) for every slice lives in [`docs/progress-log/p11.md`](docs/progress-log/p11.md); the standing testing plan is [`docs/superpowers/specs/2026-06-10-leveva-testing-plan.md`](docs/superpowers/specs/2026-06-10-leveva-testing-plan.md). This row stays thin.** | Per‑slice: leveva boots and serves the slice; leveva golden snapshot + proptest green; the `leveva-integration` differential agrees with `ircd-common` on the shared skeleton with divergences asserted. Across the eventual matrix: registration, channels, WHO/WHOIS/WHOX, OPER/STATS, server burst, netsplit, rehash, restart; iauth end‑to‑end. | 94 94 | **P12 — Retire the mechanical port; `leveva` is the product ✅ DONE (2026‑06‑13)** ([`docs/progress-log/p12.md`](docs/progress-log/p12.md)) | deleted `ircd-common`/`ircd-rs` + C‑era `iauth-rs` + `leveva-integration` + the mechanical port's deployment rigging; workspace = `leveva` + `leveva-iauth` | Ran the differential suite a **final time green** (`cargo test -p leveva-integration`, skeleton‑identical to the oracle), then **deleted the mechanical port** (`ircd-common`, `ircd-rs`), its C‑era auth scaffolding (`iauth-rs`, long since subsumed by the native `leveva-iauth`), and the oracle differential crate (`leveva-integration`). The oracle tests retired with their oracle — the load‑bearing behavior was already carried by leveva's self‑contained 212‑file golden + proptest suite (verified: leveva/leveva-iauth have zero Cargo/`use` dep on the deleted crates). Also stripped the mechanical port's **deployment rigging** (`docker/Dockerfile.ircd`, the `ircd` bake target, the entire `docs/k8s/` example network) and updated the README to describe the finished strangler. This is the literal end of the strangler — same lifecycle the C tree had at P8. | **MET. Final differential run green; `ircd-common`/`ircd-rs`/`iauth-rs`/`leveva-integration` deleted; `cargo build --workspace` 0 warnings; `cargo clippy --workspace --tests` clean; `cargo test -p leveva -p leveva-iauth` green — the behavioral guarantees carry forward. The workspace is now `leveva` + `leveva-iauth`.** | 95 95 96 96 ---
+1
docs/claude-memory/README.md
··· 1 1 # Memory Index 2 2 3 + - [leveva caller-id (+g/ACCEPT)](leveva-callerid.md) — slice 216 charybdis caller-id; CallerId store lives in Registry; +g is local-only (not in SEND_UMODES); two deferred follow-ons (remote-sender S2S gating, +G soft caller-id) 3 4 - [Blog series](blog-series.md) — docs/blog/ numbered Xe-voice posts about the port; use the xe-writing-style skill, match existing format, which topics are taken 4 5 - [Rust migration plan](rust-migration-plan.md) — this ircd C codebase is being strangler-fig ported to Rust; locked decisions + phase order + plan-file path 5 6 - [leveva prod ShadowNET per-server probe](leveva-prod-shadownet-per-server-probe.md) — reach one prod server directly via `<servername>.alrest.xeserv.us:6667` (irc.xeserv.us load-balances); how to diagnose cross-server desyncs (LINKS/LUSERS/NAMES, bare-UID = slice-162 identity bug)
+43
docs/claude-memory/leveva-callerid.md
··· 1 + --- 2 + name: leveva-callerid 3 + description: "leveva caller-id (+g user mode + ACCEPT command, slice 216) — where it lives, what's enforced, and the two deferred follow-ons" 4 + metadata: 5 + node_type: memory 6 + type: project 7 + originSessionId: 5bb19fa3-2ebf-42ca-8e92-82f35af13ef3 8 + --- 9 + 10 + P11 slice 216 added charybdis-style **caller-id** to leveva (the canonical 11 + "expected but missing" feature; picked after confirming no clean *documented* 12 + deferral remained open — the STS-REHASH / S2S-KILL-notice / throttle-knob / 13 + MODE+VERSION-proptest / UTF8ONLY "deferred" doc comments were all stale, closed by 14 + later slices). 15 + 16 + **What it is.** `+g` user mode ("server-side ignore") + the `ACCEPT` command. A 17 + `+g` user receives private `PRIVMSG`/`NOTICE` only from clients on its accept list 18 + (or itself). A blocked PRIVMSG → `716` to sender + once-per-(target,sender) 19 + `717` (sender) / `718` (target); a NOTICE is silently dropped. 20 + 21 + **Where it lives.** 22 + - `leveva/src/callerid.rs` — the pure `CallerId` store, owned **inside 23 + `Registry`** (the `metadata` zero-`ServerContext`-churn precedent), accessed via 24 + `ctx.registry.callerid()`. Accept entries are stable `Uid`s; unresolvable UIDs 25 + (acceptee quit) filtered on read; owner's lists dropped in `Registry::release`. 26 + `ACCEPT_LIMIT=30`. `Registry::modes_of(uid)` accessor added (mirrors `caps_of`). 27 + - `UserMode::CallerId` = `+g`, bit `0x0400` (`mode.rs`); client-settable via the 28 + `i|w|B|x|g` arm in `command::mode::umode`; **NOT in `SEND_UMODES`** (purely local 29 + — the gate runs on the target's home server). `-g` clears the notify gate. 30 + - `command/accept.rs` (the `ACCEPT` handler); the speech gate in `command/message` 31 + (local-nick delivery arm); `CALLERID=g` ISUPPORT token; 8 numerics 32 + (281/282/456/457/458/716/717/718). 33 + 34 + **Two deferred follow-ons (genuinely open):** 35 + 1. **Remote-sender gating (S2S)** — a *remote* sender's PM to a local `+g` target 36 + (the `s2s::relay` inbound delivery path, not `command::message`) is NOT gated, 37 + and routing the `716`/`717` back across the link is unbuilt. (KNOCK 198→199 38 + local-then-S2S precedent.) 39 + 2. **`+G` soft caller-id** — also allow a co-channel member through (only strict 40 + `+g` is implemented). 41 + 42 + Recorded the slice per [[leveva-slice-recording]]; relates to [[leveva-restricted-enforcement]] 43 + (the other `+`-mode enforcement family) and [[leveva-server-notices]].
+65
docs/progress-log/p11.md
··· 12048 12048 **Gate:** `cargo test -p leveva` green (the pre-existing `golden_s2s_keepalive::silent_peer_times_out_*` 12049 12049 BrokenPipe flake aside — fails identically on clean HEAD); `cargo clippy -p leveva --tests` clean; 12050 12050 `cargo build --workspace` 0 warnings. 12051 + 12052 + ## 2026-06-16 — P11 slice 216: caller-id (`+g` user mode + `ACCEPT` command) 12053 + 12054 + Added charybdis-style **caller-id** ("server-side ignore"): a user sets `+g` to receive private 12055 + `PRIVMSG`/`NOTICE` only from clients on their `ACCEPT` list. The canonical "expected but missing" 12056 + feature in a charybdis-flavoured ircd — leveva had no `+g`, no `ACCEPT`, none of the 12057 + 281/282/456/457/458/716/717/718 numerics. Picked (over `SILENCE` / a channel mode) after confirming 12058 + no clean *documented* deferral remained open: the STS-REHASH / inbound-S2S-KILL-notice / 12059 + throttle-config-knob / MODE-proptest / VERSION-proptest / UTF8ONLY "deferred" doc comments were all 12060 + in fact closed by later slices (212/185/210/…) — only stale comments, not real gaps. 12061 + 12062 + ### Mechanism 12063 + 12064 + - **`UserMode::CallerId` = `+g`** (`mode.rs`, bit `0x0400`), appended last so the existing bits/order 12065 + are untouched. **Client-settable** (`MODE <nick> +g`/`-g`) via the `i|w|B|x|g` arm in 12066 + `command::mode::umode` + the `umode_diff` settable list; **not** in `SEND_UMODES` — the accept-list 12067 + gate runs on the target's home server, so a transit/origin server needs no copy (purely local, 12068 + like `+s`/`+x`/`+O`). Dropping `-g` clears the notify gate (re-enabling re-notifies fresh). 12069 + - **`callerid.rs`** (new) — the pure `CallerId` store, owned **inside `Registry`** (the `metadata` 12070 + precedent, so **zero `ServerContext`-literal churn**): per-owner accept set (`Vec<Uid>`, deduped, 12071 + capped at `ACCEPT_LIMIT`=30, insertion order) + a per-owner "already notified this sender" set that 12072 + rate-limits the `717`/`718` pair to once per (owner, sender) until an accept-that-added / 12073 + `clear_notified` / `forget` forgives it. Entries are stable `Uid`s (survive a nick change); 12074 + unresolvable UIDs (acceptee quit) are filtered on read; the owner's lists are dropped on 12075 + `Registry::release`. `modes_of(uid)` accessor added (mirrors `caps_of`). 12076 + - **`ACCEPT` command** (`command/accept.rs`, new) — `ACCEPT <t>[,<t>]*` add / `-t` remove (mixed in 12077 + one command), `ACCEPT`/`ACCEPT *` list (`281` per entry + `282`). Unknown nick → `401`; dup add → 12078 + `457`; absent remove → `458`; full → `456`; self-accept is a silent no-op. Registered in the 12079 + dispatch router + the `help.rs` `COMMANDS` list + a `help/ACCEPT.md` page + the help index. 12080 + - **Speech gate** (`command/message`) — a local target in `+g` receives this PM/NOTICE only from an 12081 + accepted sender or itself. Otherwise a **PRIVMSG** bounces `716 ERR_TARGUMODEG` to the sender + 12082 + (once per sender) `717 RPL_TARGNOTIFY` to the sender and `718 RPL_UMODEGMSG` to the target; a 12083 + **NOTICE** is silently dropped (the `errs == false` path). 12084 + - **`CALLERID=g` ISUPPORT** token (`isupport.rs`); 8 new numerics in `numeric.rs` 12085 + (281/282/456/457/458/716/717/718) with `name()`/`try_from`/round-trip. 12086 + 12087 + ### Divergences (leveva-native — no oracle) 12088 + 12089 + - The `281` rendering (one nick per line) and the 456/457/458/716/717/718 reply texts are leveva's 12090 + (charybdis-styled), pinned by unit tests; no differential (the mechanical-port oracle is gone and 12091 + never had caller-id). 12092 + - **Remote-sender gating (S2S)** — a *remote* sender's PM to a local `+g` target (the `s2s::relay` 12093 + inbound path) + routing the `716`/`717` back across the link — and **`+G` soft caller-id** 12094 + (also allow a co-channel member) are out of scope here, documented follow-ons (the KNOCK 12095 + 198→199 local-then-S2S precedent). 12096 + 12097 + ### Tests 12098 + 12099 + - **Units**: `callerid.rs` (6 — add/dedup/cap/Full, unaccept-reversible, notify-once, accept-forgives, 12100 + clear/forget reset); `command/accept.rs` (8 — list/add/remove/457/458/401/self-noop/mixed/quit-skip); 12101 + `command/message.rs` (5 — block+716+once-717/718, accepted passes, plain target receives, NOTICE 12102 + silent, self bypass); `numeric.rs` round-trip; `isupport.rs` token; `s2s/umode.rs` excludes `+g`. 12103 + - **Golden** `golden_callerid.rs` (2) — real binary: `+g` gates → `ACCEPT alice` opens → `ACCEPT 12104 + -alice` re-gates+re-notifies; the plain-target inverse. 12105 + - **Proptest** `callerid_proptest.rs` (2, `PROPTEST_CASES=4096`) — accept-list model lockstep (membership/ 12106 + count/order/cap over a pool that overflows the cap) + the notify/accept interaction model 12107 + (once-per-pair, forgiven only by accept-added/clear/forget); both total / panic-free. 12108 + - Boot snapshots regenerated for the new `004` umode letter `g` + the `005` `CALLERID=g` token 12109 + (`golden_bot_mode`/`golden_cap`×2/`golden_pass`/`golden_registration`/`golden_user_spaceless`/ 12110 + `golden_server_query`/`golden_help_users`); the `004` MYINFO pin in `registration.rs`. 12111 + 12112 + **Plan:** [`docs/superpowers/plans/2026-06-16-p11-slice216-callerid-accept.md`](../superpowers/plans/2026-06-16-p11-slice216-callerid-accept.md). 12113 + 12114 + **Gate:** `cargo test -p leveva` green; `PROPTEST_CASES=4096` on `callerid_proptest` green; `cargo 12115 + clippy -p leveva --tests` clean; `cargo build --workspace` 0 warnings.
+96
docs/superpowers/plans/2026-06-16-p11-slice216-callerid-accept.md
··· 1 + # P11 slice 216 — caller-id (`+g` user mode + `ACCEPT` command) 2 + 3 + ## Goal 4 + 5 + Add charybdis-style **caller-id**: a user sets `+g` ("server-side ignore") to receive private 6 + messages/notices only from clients on their `ACCEPT` list. The canonical "expected but missing" 7 + feature in a charybdis-flavoured ircd — leveva had no `+g`, no `ACCEPT`, none of the 8 + 716/717/718/281/282 numerics. Confirmed genuinely absent (the user picked it over SILENCE / a 9 + channel mode). 10 + 11 + ## Locked decisions 12 + 13 + 1. **`+g` is local-only and client-settable**, like `+x`/`+B`/`+s`: a client toggles it on 14 + itself (`MODE <nick> +g`/`-g`), it is **not** in `SEND_UMODES` (the gate happens on the 15 + target's home server, where the target is local — a transit/origin server needs no copy). 16 + 2. **Scope = local sender → local +g target** (the `command::message` path). A **remote** sender's 17 + PM to a local +g target (the `s2s::relay` inbound path) and routing the `716`/`717` back across 18 + the link is a **documented follow-on** (the KNOCK 198→199 local-then-S2S precedent). Local-to- 19 + local is the core of the feature and fully testable without S2S numeric routing. 20 + 3. **No oper/services bypass** and **no common-channel bypass** — strict `+g` (charybdis `+g`, not 21 + the soft `+G`). `+G` (soft caller-id: also allow a co-channel member) is a documented follow-on. 22 + 4. **Self-messaging bypasses the gate** (`PRIVMSG yourownnick` always delivers). 23 + 5. **Storage lives inside `Registry`** (the `metadata` precedent), so **no new `ServerContext` 24 + field** → zero churn on the ~62 hand-rolled `ServerContext` literals. 25 + 6. **Accept entries are stored as stable `Uid`s** (resolved from the `ACCEPT <nick>`), so a nick 26 + change does not break an accept; unresolvable UIDs (the accepted user quit) are **filtered on 27 + read** when listing/gating. The owner's lists are dropped on `release` (quit). 28 + 7. **Notify-once**: a blocked PRIVMSG always sends `716` to the sender; the `717` (to sender) + 29 + `718` (to target) "you were messaged" pair fires **once per (target, sender)** until the target 30 + accepts that sender or drops `-g` (both clear the notified record). A NOTICE is **silently 31 + dropped** (no 716, no notify — the `errs=false` path). 32 + 33 + ## Design 34 + 35 + | Piece | Where | Notes | 36 + |-------|-------|-------| 37 + | `UserMode::CallerId` = `'g'`, bit `0x0400` | `mode.rs` | appended last; `ALL`/`as_char`/`bit`/`flag_name`/`from_char`/`from_bit`/`Display` updated; **not** in `SEND_UMODES` | 38 + | client-settable `+g`/`-g` | `command/mode.rs` `umode` | add `'g'` to the `i\|w\|B\|x` arm; add to `umode_diff` settable list; on `-g`, `ctx.registry.callerid().clear_notified(uid)` | 39 + | 8 numerics | `numeric.rs` | `RplAcceptlist=281`, `RplEndofaccept=282`, `ErrAcceptfull=456`, `ErrAcceptexist=457`, `ErrAcceptnot=458`, `ErrTargumodeg=716`, `RplTargnotify=717`, `RplUmodegmsg=718` (+ `name()` + `try_from` arms) | 40 + | `CallerId` store | `callerid.rs` (new), field on `Registry` + `callerid()` accessor + `modes_of()` accessor | pure core; `Mutex<HashMap<Uid,Vec<Uid>>>` accept + `Mutex<HashMap<Uid,BTreeSet<Uid>>>` notified; `ACCEPT_LIMIT=30` | 41 + | `ACCEPT` command | `command/accept.rs` (new), `mod.rs` dispatch | `ACCEPT <t>[,<t>…]` add/`-t` remove (mixed), `ACCEPT`/`ACCEPT *` list (281×n + 282); unknown nick 401; dup 457; absent-remove 458; full 456; self = no-op | 42 + | `CALLERID=g` ISUPPORT token | `isupport.rs` | one token; pinned by the isupport unit test | 43 + | gate | `command/message.rs` local-nick branch | block iff `uid != sender && target +g && !is_accepted(target, sender)`; PRIVMSG→716 always + once 717/718; NOTICE silent | 44 + 45 + ### `CallerId` API (pure core) 46 + 47 + ``` 48 + accept(owner, target) -> AcceptOutcome { Added, Exists, Full } // dedup, cap=30, clears notified[owner∋target] 49 + unaccept(owner, target) -> RemoveOutcome { Removed, NotPresent } 50 + is_accepted(owner, sender) -> bool 51 + list(owner) -> Vec<Uid> // insertion order 52 + count(owner) -> usize 53 + mark_notified(owner, sender) -> bool // true the FIRST time (records), false after 54 + clear_notified(owner) // on -g 55 + forget(owner) // on release/quit 56 + ``` 57 + 58 + ## Tests (TDD — failing first; inverse invariants always) 59 + 60 + - **`callerid.rs` units**: add/dedup/cap (31st → Full); `is_accepted` true only for added; unaccept 61 + removes (→ `is_accepted` false; re-add works; absent → NotPresent); `mark_notified` true-once- 62 + then-false; `accept` clears a prior notify; `clear_notified`/`forget` reset; `list` insertion 63 + order + survives nothing. 64 + - **`command/accept.rs` units**: list empty → just 282; add → stored + 281/282 reflect it; `-nick` 65 + removes; dup → 457; absent remove → 458; unknown nick → 401; full → 456; self-accept no-op; 66 + mixed `a,-b` in one command. 67 + - **`command/mode.rs` units**: `+g` sets/echoes, query `221` shows `g`; `-g` clears + clears notify. 68 + - **`command/message.rs` units (the gate)**: +g target, non-accepted local sender PRIVMSG → 69 + sender gets 716 + (once) 717, target gets 718 and **no** PRIVMSG; second PRIVMSG → only 716; 70 + accepted sender → delivered, no 716; **inverse**: a `-g` (plain) target always receives; 71 + self-PM delivers; NOTICE to +g non-accepted → silently dropped (no 716/718), target gets nothing. 72 + - **`isupport.rs`**: `CALLERID=g` present. 73 + - **Golden** `golden_callerid.rs`: real binary, two clients — bob `+g`, alice (not accepted) 74 + PRIVMSGs → 716/717 to alice, 718 to bob, no PRIVMSG; bob `ACCEPT alice` → alice's PRIVMSG now 75 + delivered; bob `ACCEPT -alice` then alice PRIVMSG → blocked again (re-notified). Inverse: a plain 76 + target receives normally. 77 + - **Proptest** `callerid_proptest.rs` (model-lockstep + adversarial): (1) accept-list model — a 78 + random script of accept/unaccept/list over a nick pool equals a `HashSet` model, cap honoured, 79 + never panics; (2) gate model — for a random (target +g?/accepted?) matrix and PRIVMSG vs NOTICE, 80 + the delivered-set + the 716/717/718 emission equals the reference predicate, and a blocked 81 + message is **never** delivered; (3) notify-once — over a random repeat count the 718 fires 82 + exactly once per (target,sender) until an intervening accept/`-g`. 83 + 84 + ## Divergences (leveva-native — no oracle) 85 + 86 + - The 281 rendering (one nick per line) + the 456/457/458/716/717/718 reply texts are leveva's 87 + (charybdis-styled), pinned by unit tests; no differential (the mechanical-port oracle is gone and 88 + never had caller-id). 89 + - Remote-sender gating (S2S) and `+G` soft caller-id are **out of scope** here (documented 90 + follow-ons). 91 + 92 + ## Gate 93 + 94 + `cargo test -p leveva` green (new units + `golden_callerid` + `callerid_proptest`; existing 95 + suites unchanged) · `PROPTEST_CASES=4096` on the new property green · `cargo clippy -p leveva 96 + --tests` clean · `cargo build --workspace` 0 warnings.
+30
leveva/help/ACCEPT.md
··· 1 + ACCEPT <nick>{,<nick>} / ACCEPT -<nick>{,-<nick>} / ACCEPT * 2 + 3 + Manages your caller-id accept list — the set of users allowed to send you private 4 + messages while you have user mode +g ("server-side ignore") set. With +g, a 5 + PRIVMSG or NOTICE from anyone NOT on your accept list is dropped: the sender is 6 + told 716 ERR_TARGUMODEG, you are notified once with 718 RPL_UMODEGMSG that they 7 + tried to reach you, and the sender is told once with 717 RPL_TARGNOTIFY that you 8 + were informed. Messages to yourself always go through. 9 + 10 + ACCEPT <nick> Add one or more (comma-separated) nicks to your accept 11 + list, so their private messages reach you. 12 + ACCEPT -<nick> Remove one or more nicks from your accept list. 13 + ACCEPT List your accept list with 281 RPL_ACCEPTLIST (one per 14 + ACCEPT * entry), terminated by 282 RPL_ENDOFACCEPT. 15 + 16 + Adds and removes may be mixed in one command (e.g. ACCEPT bob,-carol). An entry 17 + remembers the user, so it keeps working if they change nick; an entry whose user 18 + has quit is dropped from the list. Accepting yourself does nothing. Errors: 19 + 401 ERR_NOSUCHNICK (no such user), 457 ERR_ACCEPTEXIST (already on your list), 20 + 458 ERR_ACCEPTNOT (a remove that was not on your list), 456 ERR_ACCEPTFULL (your 21 + list is full). Set caller-id with MODE <yournick> +g and clear it with -g; the 22 + server advertises support with the CALLERID token in 005 RPL_ISUPPORT. 23 + 24 + Examples: 25 + MODE alice +g 26 + ACCEPT bob 27 + ACCEPT bob,carol,-dave 28 + ACCEPT * 29 + 30 + See also: MODE, MONITOR, WHOIS.
+1 -1
leveva/help/index.md
··· 6 6 Channel commands: JOIN PART TOPIC NAMES LIST INVITE KICK MODE 7 7 Messaging: PRIVMSG NOTICE TAGMSG 8 8 Queries: WHO WHOIS WHOWAS USERHOST ISON MONITOR METADATA 9 - User state: NICK AWAY MODE 9 + User state: NICK AWAY MODE ACCEPT 10 10 Server info: VERSION TIME ADMIN INFO LUSERS USERS MOTD 11 11 LINKS MAP STATS 12 12 Operator commands: OPER WALLOPS KILL CHGHOST TRACE ETRACE TKLINE
+239
leveva/src/callerid.rs
··· 1 + //! Caller-id (`+g` "server-side ignore") accept lists (P11 slice 216). 2 + //! 3 + //! A user who sets [`UserMode::CallerId`](crate::UserMode::CallerId) (`+g`) receives private 4 + //! `PRIVMSG`/`NOTICE` only from clients on their **accept list**, managed with the 5 + //! [`ACCEPT`](crate::command::accept) command. This module is the **pure core**: the per-owner 6 + //! accept set plus the per-owner "already notified this sender" set that rate-limits the 7 + //! `717`/`718` "you were messaged" pair to once per (target, sender) pair. 8 + //! 9 + //! It lives **inside [`crate::registry::Registry`]** (the [`crate::metadata`] precedent) rather 10 + //! than as a [`crate::server::ServerContext`] field, so adding it costs no `ServerContext`-literal 11 + //! churn. Entries are stored as stable [`Uid`]s (resolved from the `ACCEPT <nick>`), so an accept 12 + //! survives the accepted user's nick change; an entry whose user has quit is filtered on read by 13 + //! the command/gate (its UID no longer resolves to a nick). 14 + //! 15 + //! ## Scope (slice 216) 16 + //! 17 + //! Strict charybdis `+g`: no oper/services bypass, no common-channel bypass (that is the soft 18 + //! `+G`, a follow-on), local sender → local target only (the [`crate::command::message`] path; a 19 + //! **remote** sender's gate + routing the `716` back across the link is a follow-on). Self- 20 + //! messaging always bypasses the gate. 21 + 22 + use std::collections::{BTreeSet, HashMap}; 23 + use std::sync::Mutex; 24 + 25 + use crate::ident::Uid; 26 + 27 + /// The maximum number of entries a client may keep on its accept list (charybdis `MAXACCEPT`). 28 + pub const ACCEPT_LIMIT: usize = 30; 29 + 30 + /// The outcome of [`CallerId::accept`]. 31 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 32 + pub enum AcceptOutcome { 33 + /// The target was newly added. 34 + Added, 35 + /// The target was already on the list (→ `457 ERR_ACCEPTEXIST`). 36 + Exists, 37 + /// The list is at [`ACCEPT_LIMIT`]; the target was not added (→ `456 ERR_ACCEPTFULL`). 38 + Full, 39 + } 40 + 41 + /// The outcome of [`CallerId::unaccept`]. 42 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 43 + pub enum RemoveOutcome { 44 + /// The target was on the list and removed. 45 + Removed, 46 + /// The target was not on the list (→ `458 ERR_ACCEPTNOT`). 47 + NotPresent, 48 + } 49 + 50 + /// The per-server caller-id store: every `+g` owner's accept list plus the senders it has already 51 + /// been notified about. Owned by [`crate::registry::Registry`]; all access is through shared 52 + /// `&self` methods (interior `Mutex`), like the rest of the registry. 53 + #[derive(Debug, Default)] 54 + pub struct CallerId { 55 + /// owner UID → accepted sender UIDs, in insertion order (deduped, capped at [`ACCEPT_LIMIT`]). 56 + accept: Mutex<HashMap<Uid, Vec<Uid>>>, 57 + /// owner UID → sender UIDs already notified (the `717`/`718` once-per-pair gate). 58 + notified: Mutex<HashMap<Uid, BTreeSet<Uid>>>, 59 + } 60 + 61 + impl CallerId { 62 + pub fn new() -> Self { 63 + Self::default() 64 + } 65 + 66 + /// Add `target` to `owner`'s accept list. Dedups, caps at [`ACCEPT_LIMIT`], and — since the 67 + /// owner has now explicitly accepted this sender — clears any prior "notified" record for the 68 + /// pair (so a later `unaccept` re-notifies on the next blocked message). 69 + pub fn accept(&self, owner: &Uid, target: &Uid) -> AcceptOutcome { 70 + let mut g = self.accept.lock().unwrap(); 71 + let list = g.entry(owner.clone()).or_default(); 72 + if list.iter().any(|u| u == target) { 73 + return AcceptOutcome::Exists; 74 + } 75 + if list.len() >= ACCEPT_LIMIT { 76 + return AcceptOutcome::Full; 77 + } 78 + list.push(target.clone()); 79 + drop(g); 80 + // Accepting a sender forgives the once-per-pair notify gate for them. 81 + if let Some(set) = self.notified.lock().unwrap().get_mut(owner) { 82 + set.remove(target); 83 + } 84 + AcceptOutcome::Added 85 + } 86 + 87 + /// Remove `target` from `owner`'s accept list. 88 + pub fn unaccept(&self, owner: &Uid, target: &Uid) -> RemoveOutcome { 89 + let mut g = self.accept.lock().unwrap(); 90 + let Some(list) = g.get_mut(owner) else { 91 + return RemoveOutcome::NotPresent; 92 + }; 93 + if let Some(i) = list.iter().position(|u| u == target) { 94 + list.remove(i); 95 + if list.is_empty() { 96 + g.remove(owner); 97 + } 98 + RemoveOutcome::Removed 99 + } else { 100 + RemoveOutcome::NotPresent 101 + } 102 + } 103 + 104 + /// Whether `sender` is on `owner`'s accept list (the delivery gate). 105 + pub fn is_accepted(&self, owner: &Uid, sender: &Uid) -> bool { 106 + self.accept 107 + .lock() 108 + .unwrap() 109 + .get(owner) 110 + .is_some_and(|l| l.iter().any(|u| u == sender)) 111 + } 112 + 113 + /// `owner`'s accept list in insertion order (for the `281` reply). 114 + pub fn list(&self, owner: &Uid) -> Vec<Uid> { 115 + self.accept 116 + .lock() 117 + .unwrap() 118 + .get(owner) 119 + .cloned() 120 + .unwrap_or_default() 121 + } 122 + 123 + /// How many entries are on `owner`'s accept list (the `456` check basis). 124 + pub fn count(&self, owner: &Uid) -> usize { 125 + self.accept.lock().unwrap().get(owner).map_or(0, Vec::len) 126 + } 127 + 128 + /// Record that `owner` has been notified that `sender` messaged them. Returns `true` the 129 + /// **first** time for a given (owner, sender) pair — the caller emits `717`/`718` only then — 130 + /// and `false` on every subsequent blocked message until [`accept`](Self::accept) or 131 + /// [`clear_notified`](Self::clear_notified) forgives the pair. 132 + pub fn mark_notified(&self, owner: &Uid, sender: &Uid) -> bool { 133 + self.notified 134 + .lock() 135 + .unwrap() 136 + .entry(owner.clone()) 137 + .or_default() 138 + .insert(sender.clone()) 139 + } 140 + 141 + /// Forget every notify record for `owner` — called when the owner drops `-g`, so re-enabling 142 + /// `+g` re-notifies fresh. 143 + pub fn clear_notified(&self, owner: &Uid) { 144 + self.notified.lock().unwrap().remove(owner); 145 + } 146 + 147 + /// Drop `owner`'s accept list and notify records — called from 148 + /// [`Registry::release`](crate::registry::Registry::release) when the connection ends so the 149 + /// store never leaks. 150 + pub fn forget(&self, owner: &Uid) { 151 + self.accept.lock().unwrap().remove(owner); 152 + self.notified.lock().unwrap().remove(owner); 153 + } 154 + } 155 + 156 + #[cfg(test)] 157 + mod tests { 158 + use super::*; 159 + 160 + fn uid(s: &str) -> Uid { 161 + s.parse().unwrap() 162 + } 163 + 164 + #[test] 165 + fn accept_adds_dedups_and_gates() { 166 + let c = CallerId::new(); 167 + let (o, a, b) = (uid("000AAAAAA"), uid("000AAAAAB"), uid("000AAAAAC")); 168 + assert_eq!(c.accept(&o, &a), AcceptOutcome::Added); 169 + assert!(c.is_accepted(&o, &a)); 170 + assert!(!c.is_accepted(&o, &b)); // inverse: a non-added sender is not accepted 171 + // A second add of the same target is a 457, not a duplicate entry. 172 + assert_eq!(c.accept(&o, &a), AcceptOutcome::Exists); 173 + assert_eq!(c.count(&o), 1); 174 + assert_eq!(c.list(&o), vec![a]); 175 + } 176 + 177 + #[test] 178 + fn unaccept_removes_and_is_reversible() { 179 + let c = CallerId::new(); 180 + let (o, a) = (uid("000AAAAAA"), uid("000AAAAAB")); 181 + assert_eq!(c.unaccept(&o, &a), RemoveOutcome::NotPresent); // inverse: nothing to remove 182 + c.accept(&o, &a); 183 + assert_eq!(c.unaccept(&o, &a), RemoveOutcome::Removed); 184 + assert!(!c.is_accepted(&o, &a)); // actually gone 185 + assert_eq!(c.count(&o), 0); 186 + // Re-adding after removal works cleanly (no stale duplicate). 187 + assert_eq!(c.accept(&o, &a), AcceptOutcome::Added); 188 + assert_eq!(c.list(&o), vec![a]); 189 + } 190 + 191 + #[test] 192 + fn list_is_full_at_the_limit() { 193 + let c = CallerId::new(); 194 + let o = uid("000AAAAAA"); 195 + for i in 0..ACCEPT_LIMIT { 196 + // distinct UIDs: 000AAAA00, 000AAAA01, … (9 chars) 197 + let s = format!("000AAAA{i:02}"); 198 + assert_eq!(c.accept(&o, &uid(&s)), AcceptOutcome::Added); 199 + } 200 + assert_eq!(c.count(&o), ACCEPT_LIMIT); 201 + assert_eq!(c.accept(&o, &uid("000AAAAZZ")), AcceptOutcome::Full); 202 + assert_eq!(c.count(&o), ACCEPT_LIMIT); // the over-limit add did not grow the list 203 + } 204 + 205 + #[test] 206 + fn notify_fires_once_then_is_silent() { 207 + let c = CallerId::new(); 208 + let (o, s) = (uid("000AAAAAA"), uid("000AAAAAB")); 209 + assert!(c.mark_notified(&o, &s)); // first time 210 + assert!(!c.mark_notified(&o, &s)); // already notified 211 + assert!(!c.mark_notified(&o, &s)); 212 + // A different sender notifies independently. 213 + assert!(c.mark_notified(&o, &uid("000AAAAAC"))); 214 + } 215 + 216 + #[test] 217 + fn accepting_forgives_the_notify_gate() { 218 + let c = CallerId::new(); 219 + let (o, s) = (uid("000AAAAAA"), uid("000AAAAAB")); 220 + assert!(c.mark_notified(&o, &s)); 221 + c.accept(&o, &s); // explicit accept forgives the pair 222 + c.unaccept(&o, &s); // …and after un-accepting they can be re-notified 223 + assert!(c.mark_notified(&o, &s)); 224 + } 225 + 226 + #[test] 227 + fn clear_and_forget_reset() { 228 + let c = CallerId::new(); 229 + let (o, s) = (uid("000AAAAAA"), uid("000AAAAAB")); 230 + c.accept(&o, &s); 231 + c.mark_notified(&o, &s); 232 + c.clear_notified(&o); 233 + assert!(c.mark_notified(&o, &s)); // notify state reset, accept untouched 234 + assert!(c.is_accepted(&o, &s)); 235 + c.forget(&o); 236 + assert!(!c.is_accepted(&o, &s)); // accept gone too 237 + assert_eq!(c.count(&o), 0); 238 + } 239 + }
+221
leveva/src/command/accept.rs
··· 1 + use crate::callerid::{AcceptOutcome, RemoveOutcome}; 2 + use crate::command::*; 3 + 4 + /// `ACCEPT <target>[,<target>]*` — manage the caller-id (`+g`) accept list (P11 slice 216). 5 + /// 6 + /// Each target is a nick to **add**, or `-nick` to **remove**; adds and removes may be mixed in 7 + /// one command (comma- or space-separated). `ACCEPT` with no target, or `ACCEPT *`, **lists** the 8 + /// current accept list (`281 RPL_ACCEPTLIST` per entry, terminated by `282 RPL_ENDOFACCEPT`). 9 + /// 10 + /// - Unknown nick → `401 ERR_NOSUCHNICK` (per target). 11 + /// - Adding a nick already on the list → `457 ERR_ACCEPTEXIST`. 12 + /// - Removing a nick not on the list → `458 ERR_ACCEPTNOT`. 13 + /// - The list is at [`ACCEPT_LIMIT`](crate::callerid::ACCEPT_LIMIT) → `456 ERR_ACCEPTFULL`. 14 + /// - Accepting **yourself** is a silent no-op (your own messages are never caller-id-gated). 15 + /// 16 + /// Entries are stored as the accepted user's stable [`Uid`](crate::ident::Uid), so an accept 17 + /// survives the accepted user's nick change; an entry whose user has quit is filtered out of the 18 + /// list reply (its UID no longer resolves to a nick). leveva-native (the charybdis/Hybrid 19 + /// convention); reply texts are leveva's, pinned by the unit tests. 20 + pub(crate) fn accept(client: &Registered, msg: &Message, ctx: &ServerContext) -> Vec<Message> { 21 + let args: Vec<&str> = msg 22 + .params() 23 + .iter() 24 + .map(String::as_str) 25 + .chain(msg.trailing()) 26 + .flat_map(|p| p.split(',')) 27 + .map(str::trim) 28 + .filter(|t| !t.is_empty()) 29 + .collect(); 30 + 31 + // No target, or a bare `*` → list the current accept list. 32 + if args.is_empty() || args == ["*"] { 33 + return list_reply(client, ctx); 34 + } 35 + 36 + let cid = ctx.registry.callerid(); 37 + let mut out = Vec::new(); 38 + for token in args { 39 + let (removing, name) = match token.strip_prefix('-') { 40 + Some(rest) => (true, rest), 41 + None => (false, token), 42 + }; 43 + // A bare `-`, or a `*` mixed into a modify command, names nobody → skip. 44 + if name.is_empty() || name == "*" { 45 + continue; 46 + } 47 + let Some(uid) = ctx.registry.uid_of(name) else { 48 + out.push(no_such_nick(ctx, &client.nick, name)); 49 + continue; 50 + }; 51 + let nick = ctx.registry.nick_of(&uid).unwrap_or_else(|| name.to_string()); 52 + if removing { 53 + if cid.unaccept(&client.uid, &uid) == RemoveOutcome::NotPresent { 54 + out.push(accept_err( 55 + ctx, 56 + &client.nick, 57 + Numeric::ErrAcceptnot, 58 + &nick, 59 + "is not on your accept list.", 60 + )); 61 + } 62 + } else if uid == client.uid { 63 + // Accepting yourself is a no-op — your own messages bypass the gate. 64 + continue; 65 + } else { 66 + match cid.accept(&client.uid, &uid) { 67 + AcceptOutcome::Added => {} 68 + AcceptOutcome::Exists => out.push(accept_err( 69 + ctx, 70 + &client.nick, 71 + Numeric::ErrAcceptexist, 72 + &nick, 73 + "is already on your accept list.", 74 + )), 75 + AcceptOutcome::Full => out.push( 76 + Message::builder(Numeric::ErrAcceptfull) 77 + .prefix(&ctx.name) 78 + .param(&client.nick) 79 + .trailing("Accept list is full") 80 + .build(), 81 + ), 82 + } 83 + } 84 + } 85 + out 86 + } 87 + 88 + /// The `281`-per-entry + `282` list reply for `client`'s accept list (entries whose user has quit 89 + /// are skipped — their UID no longer resolves to a nick). 90 + fn list_reply(client: &Registered, ctx: &ServerContext) -> Vec<Message> { 91 + let mut out: Vec<Message> = ctx 92 + .registry 93 + .callerid() 94 + .list(&client.uid) 95 + .into_iter() 96 + .filter_map(|uid| ctx.registry.nick_of(&uid)) 97 + .map(|nick| { 98 + Message::builder(Numeric::RplAcceptlist) 99 + .prefix(&ctx.name) 100 + .param(&client.nick) 101 + .param(nick) 102 + .build() 103 + }) 104 + .collect(); 105 + out.push( 106 + Message::builder(Numeric::RplEndofaccept) 107 + .prefix(&ctx.name) 108 + .param(&client.nick) 109 + .trailing("End of /ACCEPT list.") 110 + .build(), 111 + ); 112 + out 113 + } 114 + 115 + /// A `<nick> :<text>` accept error (`457`/`458`). 116 + fn accept_err(ctx: &ServerContext, to: &str, code: Numeric, nick: &str, text: &str) -> Message { 117 + Message::builder(code) 118 + .prefix(&ctx.name) 119 + .param(to) 120 + .param(nick) 121 + .trailing(text) 122 + .build() 123 + } 124 + 125 + #[cfg(test)] 126 + mod tests { 127 + use super::*; 128 + use crate::command::testutil::*; 129 + 130 + /// The nicks currently on alice's accept list, read back via `ACCEPT` (list mode). 131 + fn list(ctx: &ServerContext) -> Vec<String> { 132 + run(ctx, "ACCEPT") 133 + .iter() 134 + .filter(|m| m.command() == "281") 135 + .filter_map(|m| m.params().get(1).cloned()) 136 + .collect() 137 + } 138 + 139 + #[test] 140 + fn empty_list_is_just_282() { 141 + let ctx = ctx(); 142 + assert_eq!(codes(&run(&ctx, "ACCEPT")), &["282"]); 143 + // `ACCEPT *` is the explicit list form. 144 + assert_eq!(codes(&run(&ctx, "ACCEPT *")), &["282"]); 145 + } 146 + 147 + #[test] 148 + fn add_stores_and_list_reflects_it() { 149 + let ctx = ctx(); 150 + let _bob = claim_observed(&ctx, "bob"); 151 + assert!(run(&ctx, "ACCEPT bob").is_empty(), "a clean add is silent"); 152 + assert_eq!(list(&ctx), vec!["bob"]); 153 + } 154 + 155 + #[test] 156 + fn remove_unaccepts_and_is_reversible() { 157 + let ctx = ctx(); 158 + let _bob = claim_observed(&ctx, "bob"); 159 + run(&ctx, "ACCEPT bob"); 160 + assert!(run(&ctx, "ACCEPT -bob").is_empty(), "a clean remove is silent"); 161 + // Inverse: bob is actually gone from the list. 162 + assert!(list(&ctx).is_empty()); 163 + // …and re-adding works cleanly (no stale duplicate). 164 + run(&ctx, "ACCEPT bob"); 165 + assert_eq!(list(&ctx), vec!["bob"]); 166 + } 167 + 168 + #[test] 169 + fn duplicate_add_gives_457() { 170 + let ctx = ctx(); 171 + let _bob = claim_observed(&ctx, "bob"); 172 + run(&ctx, "ACCEPT bob"); 173 + assert_eq!(codes(&run(&ctx, "ACCEPT bob")), &["457"]); 174 + // Inverse: still exactly one entry. 175 + assert_eq!(list(&ctx), vec!["bob"]); 176 + } 177 + 178 + #[test] 179 + fn remove_absent_gives_458() { 180 + let ctx = ctx(); 181 + let _bob = claim_observed(&ctx, "bob"); 182 + assert_eq!(codes(&run(&ctx, "ACCEPT -bob")), &["458"]); 183 + } 184 + 185 + #[test] 186 + fn unknown_nick_gives_401() { 187 + let ctx = ctx(); 188 + assert_eq!(codes(&run(&ctx, "ACCEPT ghost")), &["401"]); 189 + assert!(list(&ctx).is_empty(), "a 401 stores nothing"); 190 + } 191 + 192 + #[test] 193 + fn accepting_yourself_is_a_noop() { 194 + let ctx = ctx(); 195 + let _alice = claim_observed(&ctx, "alice"); // alice is the caller (uid_for alice) 196 + assert!(run(&ctx, "ACCEPT alice").is_empty(), "no error"); 197 + assert!(list(&ctx).is_empty(), "self is not stored"); 198 + } 199 + 200 + #[test] 201 + fn mixed_add_and_remove_in_one_command() { 202 + let ctx = ctx(); 203 + let _bob = claim_observed(&ctx, "bob"); 204 + let _carol = claim_observed(&ctx, "carol"); 205 + run(&ctx, "ACCEPT bob"); 206 + // One command adds carol and removes bob. 207 + assert!(run(&ctx, "ACCEPT carol,-bob").is_empty()); 208 + assert_eq!(list(&ctx), vec!["carol"]); 209 + } 210 + 211 + #[test] 212 + fn list_skips_a_quit_acceptee() { 213 + let ctx = ctx(); 214 + let _bob = claim_observed(&ctx, "bob"); 215 + run(&ctx, "ACCEPT bob"); 216 + // bob quits: its UID no longer resolves, so the list reply omits it (but never panics). 217 + ctx.registry.release(&uid_for("bob")); 218 + assert!(list(&ctx).is_empty()); 219 + assert_eq!(codes(&run(&ctx, "ACCEPT")), &["282"]); 220 + } 221 + }
+2 -1
leveva/src/command/help.rs
··· 92 92 const COMMANDS: &[&str] = &[ 93 93 "NICK", "SANICK", "PRIVMSG", "NOTICE", "TAGMSG", "JOIN", "SAJOIN", "OJOIN", "PART", 94 94 "SAPART", "MODE", "SAMODE", "TOPIC", "KICK", "INVITE", "KNOCK", "NAMES", "LIST", "WHO", 95 - "WHOIS", "WHOWAS", "USERHOST", "ISON", "MONITOR", "METADATA", "AWAY", "PING", "OPER", 95 + "WHOIS", "WHOWAS", "USERHOST", "ISON", "MONITOR", "ACCEPT", "METADATA", "AWAY", "PING", 96 + "OPER", 96 97 "WALLOPS", "KILL", "CHGHOST", "SETNAME", "VERSION", "TIME", "ADMIN", "INFO", "LUSERS", 97 98 "USERS", "SUMMON", "MOTD", "HELP", "LINKS", "MAP", "STATS", "TRACE", "ETRACE", "TKLINE", 98 99 "UNTKLINE", "REHASH", "RESTART", "DIE", "SQUIT", "CONNECT", "MKPASSWD",
+121
leveva/src/command/message.rs
··· 293 293 // nick is routed to the peer in whose subtree it lives; an unknown nick 401s. 294 294 match ctx.registry.uid_of(target) { 295 295 Some(uid) if ctx.is_local_uid(&uid) => { 296 + // Caller-id (slice 216): a local target in `+g` ("server-side ignore") 297 + // receives this PM/NOTICE only from a sender on its accept list — or from 298 + // itself (self-messages always pass). Otherwise a PRIVMSG bounces `716` to 299 + // the sender and notifies the target once (`717` to sender + `718` to target); 300 + // a NOTICE is silently dropped (the `errs == false` path). 301 + if uid != client.uid 302 + && ctx.registry.modes_of(&uid) & crate::UserMode::CallerId.bit() != 0 303 + && !ctx.registry.callerid().is_accepted(&uid, &client.uid) 304 + { 305 + if errs { 306 + let tnick = ctx 307 + .registry 308 + .nick_of(&uid) 309 + .unwrap_or_else(|| target.to_string()); 310 + replies.push( 311 + Message::builder(Numeric::ErrTargumodeg) 312 + .prefix(&ctx.name) 313 + .param(&client.nick) 314 + .param(&tnick) 315 + .trailing("is in +g mode (server-side ignore.)") 316 + .build(), 317 + ); 318 + // The `717`/`718` "you were messaged" pair fires once per 319 + // (target, sender) until the target accepts the sender or drops `-g`. 320 + if ctx.registry.callerid().mark_notified(&uid, &client.uid) { 321 + replies.push( 322 + Message::builder(Numeric::RplTargnotify) 323 + .prefix(&ctx.name) 324 + .param(&client.nick) 325 + .param(&tnick) 326 + .trailing("has been informed that you messaged them.") 327 + .build(), 328 + ); 329 + ctx.registry.deliver_uid( 330 + &uid, 331 + Message::builder(Numeric::RplUmodegmsg) 332 + .prefix(&ctx.name) 333 + .param(&tnick) 334 + .param(&client.nick) 335 + .param(format!("{}@{}", client.user, client.host)) 336 + .trailing("is messaging you, and you have umode +g.") 337 + .build() 338 + .to_wire(), 339 + ); 340 + } 341 + } 342 + continue; 343 + } 296 344 ctx.registry 297 345 .deliver_uid(&uid, render(ctx.registry.caps_of(&uid))); 298 346 // PRIVMSG (never NOTICE, RFC 1459 §4.4.2) to an away user → `301 RPL_AWAY` ··· 413 461 assert_eq!(m.command(), "PRIVMSG"); 414 462 assert_eq!(m.params(), &["bob"]); 415 463 assert_eq!(m.trailing(), Some("hello there")); 464 + } 465 + 466 + /// Set the `+g` (caller-id) bit on a claimed target's registry record. 467 + fn set_callerid(ctx: &ServerContext, nick: &str) { 468 + ctx.registry 469 + .set_modes(&uid_for(nick), crate::UserMode::CallerId.bit()); 470 + } 471 + 472 + #[test] 473 + fn callerid_blocks_unaccepted_privmsg_and_notifies_once() { 474 + let ctx = ctx(); 475 + let mut bob = claim_observed(&ctx, "bob"); 476 + set_callerid(&ctx, "bob"); 477 + // alice (not accepted) PRIVMSGs bob → 716 + (once) 717 to alice; 718 to bob; no PRIVMSG. 478 + let r = run(&ctx, "PRIVMSG bob :hi"); 479 + assert_eq!(codes(&r), &["716", "717"]); 480 + assert_eq!(r[0].params(), &["alice", "bob"]); 481 + let n = delivered(&mut bob); 482 + assert_eq!(n.command(), "718"); 483 + assert_eq!(n.params(), &["bob", "alice", "alice@127.0.0.1"]); 484 + assert!( 485 + bob.try_recv().is_err(), 486 + "a +g target receives no PRIVMSG from a non-accepted sender" 487 + ); 488 + // Second message from the same sender: only 716, no repeat 717/718. 489 + let r2 = run(&ctx, "PRIVMSG bob :again"); 490 + assert_eq!(codes(&r2), &["716"]); 491 + assert!(bob.try_recv().is_err(), "no second 718, still no PRIVMSG"); 492 + } 493 + 494 + #[test] 495 + fn callerid_accepted_sender_passes() { 496 + let ctx = ctx(); 497 + let mut bob = claim_observed(&ctx, "bob"); 498 + let _alice = claim_observed(&ctx, "alice"); 499 + set_callerid(&ctx, "bob"); 500 + run_as(&ctx, "bob", "ACCEPT alice"); // bob accepts alice 501 + let r = run(&ctx, "PRIVMSG bob :hi"); 502 + assert!(r.is_empty(), "an accepted sender's PRIVMSG bounces nothing"); 503 + let m = delivered(&mut bob); 504 + assert_eq!(m.command(), "PRIVMSG"); 505 + assert_eq!(m.trailing(), Some("hi")); 506 + } 507 + 508 + #[test] 509 + fn callerid_plain_target_always_receives() { 510 + // Inverse: a target *without* +g receives normally (the gate is not over-broad). 511 + let ctx = ctx(); 512 + let mut bob = claim_observed(&ctx, "bob"); 513 + let r = run(&ctx, "PRIVMSG bob :hi"); 514 + assert!(r.is_empty()); 515 + assert_eq!(delivered(&mut bob).command(), "PRIVMSG"); 516 + } 517 + 518 + #[test] 519 + fn callerid_notice_is_silently_dropped() { 520 + let ctx = ctx(); 521 + let mut bob = claim_observed(&ctx, "bob"); 522 + set_callerid(&ctx, "bob"); 523 + let r = run(&ctx, "NOTICE bob :hi"); 524 + assert!(r.is_empty(), "NOTICE to +g bounces no numeric to the sender"); 525 + assert!(bob.try_recv().is_err(), "NOTICE silently dropped, no 718"); 526 + } 527 + 528 + #[test] 529 + fn callerid_self_message_bypasses_the_gate() { 530 + // A +g user can always message themselves. 531 + let ctx = ctx(); 532 + let mut alice = claim_observed(&ctx, "alice"); 533 + set_callerid(&ctx, "alice"); 534 + let r = run(&ctx, "PRIVMSG alice :note to self"); 535 + assert!(r.is_empty()); 536 + assert_eq!(delivered(&mut alice).command(), "PRIVMSG"); 416 537 } 417 538 418 539 #[test]
+2
leveva/src/command/mod.rs
··· 134 134 "USERHOST" => userhost::userhost(client, msg, ctx), 135 135 "ISON" => ison::ison(client, msg, ctx), 136 136 "MONITOR" => monitor::monitor(client, msg, ctx), 137 + "ACCEPT" => accept::accept(client, msg, ctx), 137 138 "METADATA" => metadata::metadata(client, msg, ctx), 138 139 // Missing target/text → 461 (min-params, both verbs, matching the oracle). 139 140 // PRIVMSG additionally generates 401/404/407 on failure; NOTICE stays silent there. ··· 569 570 .build() 570 571 } 571 572 573 + mod accept; 572 574 mod admin; 573 575 pub(crate) mod away; 574 576 pub(crate) mod chghost;
+13 -6
leveva/src/command/mode.rs
··· 588 588 match c { 589 589 '+' => adding = true, 590 590 '-' => adding = false, 591 - 'i' | 'w' | 'B' | 'x' => { 592 - // `B` (IRCv3 bot mode) and `x` (IP cloak) are leveva-native but, unlike 593 - // `W`, a client *may* set/clear them on itself (`MODE <nick> +B`/`+x`). 594 - // The `+x` host change itself is applied after the loop (it needs the 595 - // net old→new diff, not the per-char step). 591 + 'i' | 'w' | 'B' | 'x' | 'g' => { 592 + // `B` (IRCv3 bot mode), `x` (IP cloak) and `g` (caller-id, slice 216) are 593 + // leveva-native but, unlike `W`, a client *may* set/clear them on itself 594 + // (`MODE <nick> +B`/`+x`/`+g`). The `+x` host change itself is applied after 595 + // the loop (it needs the net old→new diff, not the per-char step); the `-g` 596 + // notify-state reset is likewise applied after the loop. 596 597 let bit = UserMode::from_char(c) 597 - .expect("i/w/B/x are user modes") 598 + .expect("i/w/B/x/g are user modes") 598 599 .bit(); 599 600 if adding { 600 601 modes |= bit; ··· 651 652 // `MODE :+x` then `396`. 652 653 if (old ^ modes) & UserMode::Cloak.bit() != 0 { 653 654 apply_cloak_change(client, ctx, &mut replies); 655 + } 656 + // Caller-id (slice 216): dropping `-g` forgives the once-per-pair `717`/`718` notify gate, 657 + // so re-enabling `+g` re-notifies fresh. (The accept list itself is retained.) 658 + if (old ^ modes) & UserMode::CallerId.bit() != 0 && modes & UserMode::CallerId.bit() == 0 { 659 + ctx.registry.callerid().clear_notified(&client.uid); 654 660 } 655 661 } 656 662 replies ··· 727 733 UserMode::Bot, 728 734 UserMode::Cloak, 729 735 UserMode::ServerNotice, 736 + UserMode::CallerId, 730 737 ] { 731 738 let was = old & m.bit() != 0; 732 739 let now = new & m.bit() != 0;
+6 -1
leveva/src/isupport.rs
··· 98 98 // `INVALID_UTF8` standard reply (see `session::Session::feed`), and the server never 99 99 // relays non-UTF-8 content. Advertised as a valueless ISUPPORT token, not a CAP. 100 100 "UTF8ONLY".to_string(), 101 + // Caller-id (slice 216): clients can set `+g` ("server-side ignore") and manage an 102 + // `ACCEPT` list. The token names the user mode letter, the charybdis convention. 103 + "CALLERID=g".to_string(), 101 104 format!("NETWORK={network}"), 102 105 ] 103 106 } ··· 123 126 124 127 #[test] 125 128 fn mode_strings_come_from_tables() { 126 - assert_eq!(user_modes_string(), "oOiwraWBxs"); 129 + assert_eq!(user_modes_string(), "oOiwraWBxsg"); 127 130 // ChanMode::ALL order: p s m n t i O o v q a h Y b e I R k l (Y = OJOIN, slice 165) 128 131 assert_eq!(chan_modes_string(), "psmntiOovqahYbeIRkl"); 129 132 } ··· 169 172 assert!(toks.iter().any(|t| t == "BOT=B")); 170 173 // UTF8ONLY advertised — leveva is a UTF-8-only network (IRCv3 utf8only, slice 76). 171 174 assert!(toks.iter().any(|t| t == "UTF8ONLY")); 175 + // CALLERID=g advertised — caller-id / server-side ignore (slice 216). 176 + assert!(toks.iter().any(|t| t == "CALLERID=g")); 172 177 // LINELEN=2048 advertised — leveva supports 2048-grapheme non-tag lines (slice 121). 173 178 assert!(toks.iter().any(|t| t == "LINELEN=2048")); 174 179 // EXTBAN advertised — charybdis-style extended bans (slice 164).
+1
leveva/src/lib.rs
··· 47 47 //! [`Prefix`]: patricia::Prefix 48 48 49 49 pub mod autoconnect; 50 + pub mod callerid; 50 51 pub mod cap; 51 52 pub mod casemap; 52 53 pub mod certfp;
+18 -1
leveva/src/mode.rs
··· 89 89 /// server, so `+s` is **not** in [`SEND_UMODES`](crate::s2s::umode::SEND_UMODES) and never 90 90 /// crosses a link. Appended **last** so the bits/order of every mode above are untouched. 91 91 ServerNotice, 92 + /// `+g` — caller-id / "server-side ignore" (P11 slice 216). 93 + /// 94 + /// **leveva-native — there is no C oracle for this** (the charybdis/Hybrid convention). A `+g` 95 + /// user receives private `PRIVMSG`/`NOTICE` only from clients on its `ACCEPT` list 96 + /// ([`crate::callerid`], [`crate::command::accept`]); a blocked PRIVMSG bounces `716` to the 97 + /// sender and notifies the target once (`717`/`718`). Like `+s` it is **client-settable** but 98 + /// **purely local** — the gate runs on the target's home server, so `+g` is **not** in 99 + /// [`SEND_UMODES`](crate::s2s::umode::SEND_UMODES) and never crosses a link. Appended **last** 100 + /// so the bits/order of every mode above are untouched. 101 + CallerId, 92 102 } 93 103 94 104 impl UserMode { 95 105 /// Every user mode, in `user_modes[]` table order (with the leveva-native 96 106 /// `+W` appended last). 97 - pub const ALL: [UserMode; 10] = [ 107 + pub const ALL: [UserMode; 11] = [ 98 108 UserMode::Oper, 99 109 UserMode::LocalOp, 100 110 UserMode::Invisible, ··· 105 115 UserMode::Bot, 106 116 UserMode::Cloak, 107 117 UserMode::ServerNotice, 118 + UserMode::CallerId, 108 119 ]; 109 120 110 121 /// The mode letter, e.g. `'o'`. ··· 121 132 UserMode::Bot => 'B', 122 133 UserMode::Cloak => 'x', 123 134 UserMode::ServerNotice => 's', 135 + UserMode::CallerId => 'g', 124 136 } 125 137 } 126 138 ··· 139 151 UserMode::Bot => 0x0080, // leveva-native (no C oracle) 140 152 UserMode::Cloak => 0x0100, // leveva-native (no C oracle) 141 153 UserMode::ServerNotice => 0x0200, // FLAGS_SERVNOTICE (re-introduced, slice 184) 154 + UserMode::CallerId => 0x0400, // leveva-native (no C oracle, slice 216) 142 155 } 143 156 } 144 157 ··· 157 170 UserMode::Bot => "FLAGS_BOT", 158 171 UserMode::Cloak => "FLAGS_CLOAK", 159 172 UserMode::ServerNotice => "FLAGS_SERVNOTICE", 173 + UserMode::CallerId => "FLAGS_CALLERID", 160 174 } 161 175 } 162 176 ··· 174 188 'B' => UserMode::Bot, 175 189 'x' => UserMode::Cloak, 176 190 's' => UserMode::ServerNotice, 191 + 'g' => UserMode::CallerId, 177 192 _ => return None, 178 193 }) 179 194 } ··· 192 207 0x0080 => UserMode::Bot, 193 208 0x0100 => UserMode::Cloak, 194 209 0x0200 => UserMode::ServerNotice, 210 + 0x0400 => UserMode::CallerId, 195 211 _ => return None, 196 212 }) 197 213 } ··· 229 245 UserMode::Bot => "B", 230 246 UserMode::Cloak => "x", 231 247 UserMode::ServerNotice => "s", 248 + UserMode::CallerId => "g", 232 249 }) 233 250 } 234 251 }
+64
leveva/src/numeric.rs
··· 202 202 RplList = 322, 203 203 /// `RPL_LISTEND` 204 204 RplListend = 323, 205 + /// `RPL_ACCEPTLIST` — caller-id (`+g`): one entry of the client's `ACCEPT` list, 206 + /// `<nick>` (leveva-native; the charybdis/Hybrid convention, slice 216). 207 + RplAcceptlist = 281, 208 + /// `RPL_ENDOFACCEPT` — caller-id: terminates an `ACCEPT` list reply, 209 + /// `:End of /ACCEPT list.` (slice 216). 210 + RplEndofaccept = 282, 205 211 /// `RPL_CHANNELMODEIS` 206 212 RplChannelmodeis = 324, 207 213 /// `RPL_UNIQOPIS` ··· 362 368 ErrUsersdisabled = 446, 363 369 /// `ERR_NOTREGISTERED` 364 370 ErrNotregistered = 451, 371 + /// `ERR_ACCEPTFULL` — caller-id: the `ACCEPT` list is at its limit, 372 + /// `:Accept list is full` (leveva-native; the charybdis convention, slice 216). 373 + ErrAcceptfull = 456, 374 + /// `ERR_ACCEPTEXIST` — caller-id: the nick is already on the `ACCEPT` list, 375 + /// `<nick> :is already on your accept list.` (slice 216). 376 + ErrAcceptexist = 457, 377 + /// `ERR_ACCEPTNOT` — caller-id: the nick is not on the `ACCEPT` list (a remove that missed), 378 + /// `<nick> :is not on your accept list.` (slice 216). 379 + ErrAcceptnot = 458, 365 380 /// `ERR_NEEDMOREPARAMS` 366 381 ErrNeedmoreparams = 461, 367 382 /// `ERR_ALREADYREGISTRED` ··· 434 449 /// `ERR_KNOCKONCHAN` — `KNOCK`ing on a channel you are already on: 435 450 /// `<channel> :You are already on that channel` (slice 198). 436 451 ErrKnockonchan = 714, 452 + /// `ERR_TARGUMODEG` — caller-id: the PRIVMSG target is in `+g` and you are not on its accept 453 + /// list, `<nick> :is in +g mode (server-side ignore.)` (leveva-native; the charybdis 454 + /// convention, slice 216). 455 + ErrTargumodeg = 716, 456 + /// `RPL_TARGNOTIFY` — caller-id: confirms to the sender that the `+g` target was told you 457 + /// messaged them, `<nick> :has been informed that you messaged them.` (slice 216). 458 + RplTargnotify = 717, 459 + /// `RPL_UMODEGMSG` — caller-id: tells a `+g` user that someone (not on their accept list) 460 + /// tried to message them, `<nick> <user>@<host> :is messaging you, and you have umode +g.` 461 + /// (slice 216). 462 + RplUmodegmsg = 718, 437 463 /// `RPL_MONONLINE` — a monitored target is online (IRCv3 `monitor`). 438 464 RplMononline = 730, 439 465 /// `RPL_MONOFFLINE` — a monitored target is offline (IRCv3 `monitor`). ··· 709 735 Numeric::ErrToomanyknock => "ERR_TOOMANYKNOCK", 710 736 Numeric::ErrChanopen => "ERR_CHANOPEN", 711 737 Numeric::ErrKnockonchan => "ERR_KNOCKONCHAN", 738 + Numeric::RplAcceptlist => "RPL_ACCEPTLIST", 739 + Numeric::RplEndofaccept => "RPL_ENDOFACCEPT", 740 + Numeric::ErrAcceptfull => "ERR_ACCEPTFULL", 741 + Numeric::ErrAcceptexist => "ERR_ACCEPTEXIST", 742 + Numeric::ErrAcceptnot => "ERR_ACCEPTNOT", 743 + Numeric::ErrTargumodeg => "ERR_TARGUMODEG", 744 + Numeric::RplTargnotify => "RPL_TARGNOTIFY", 745 + Numeric::RplUmodegmsg => "RPL_UMODEGMSG", 712 746 Numeric::RplEndofmonlist => "RPL_ENDOFMONLIST", 713 747 Numeric::ErrMonlistfull => "ERR_MONLISTFULL", 714 748 Numeric::RplEtraceend => "RPL_ETRACEEND", ··· 948 982 712 => Ok(Numeric::ErrToomanyknock), 949 983 713 => Ok(Numeric::ErrChanopen), 950 984 714 => Ok(Numeric::ErrKnockonchan), 985 + 281 => Ok(Numeric::RplAcceptlist), 986 + 282 => Ok(Numeric::RplEndofaccept), 987 + 456 => Ok(Numeric::ErrAcceptfull), 988 + 457 => Ok(Numeric::ErrAcceptexist), 989 + 458 => Ok(Numeric::ErrAcceptnot), 990 + 716 => Ok(Numeric::ErrTargumodeg), 991 + 717 => Ok(Numeric::RplTargnotify), 992 + 718 => Ok(Numeric::RplUmodegmsg), 951 993 730 => Ok(Numeric::RplMononline), 952 994 731 => Ok(Numeric::RplMonoffline), 953 995 732 => Ok(Numeric::RplMonlist), ··· 1042 1084 // false even for the conceptual ERR_* codes (the established 7xx/9xx convention). 1043 1085 assert!(!n.is_error()); 1044 1086 } 1087 + } 1088 + 1089 + #[test] 1090 + fn callerid_numerics_round_trip() { 1091 + for (n, code, name) in [ 1092 + (Numeric::RplAcceptlist, 281u16, "RPL_ACCEPTLIST"), 1093 + (Numeric::RplEndofaccept, 282, "RPL_ENDOFACCEPT"), 1094 + (Numeric::ErrAcceptfull, 456, "ERR_ACCEPTFULL"), 1095 + (Numeric::ErrAcceptexist, 457, "ERR_ACCEPTEXIST"), 1096 + (Numeric::ErrAcceptnot, 458, "ERR_ACCEPTNOT"), 1097 + (Numeric::ErrTargumodeg, 716, "ERR_TARGUMODEG"), 1098 + (Numeric::RplTargnotify, 717, "RPL_TARGNOTIFY"), 1099 + (Numeric::RplUmodegmsg, 718, "RPL_UMODEGMSG"), 1100 + ] { 1101 + assert_eq!(n.code(), code); 1102 + assert_eq!(Numeric::try_from(code), Ok(n)); 1103 + assert_eq!(n.name(), name); 1104 + } 1105 + // The 45x accept errors sit in the error range; the 28x/71x replies do not. 1106 + assert!(Numeric::ErrAcceptfull.is_error()); 1107 + assert!(!Numeric::RplAcceptlist.is_error()); 1108 + assert!(!Numeric::ErrTargumodeg.is_error()); 1045 1109 } 1046 1110 1047 1111 #[test]
+2 -2
leveva/src/registration.rs
··· 1255 1255 assert_eq!(p[1], S); 1256 1256 assert_eq!(p[2], "leveva-test"); 1257 1257 // oOiwra (the oracle set) + W (websocket) + B (bot) + x (cloak) leveva-native umodes 1258 - // + s (server notices, re-introduced slice 184). 1259 - assert_eq!(p[3], "oOiwraWBxs"); 1258 + // + s (server notices, re-introduced slice 184) + g (caller-id, slice 216). 1259 + assert_eq!(p[3], "oOiwraWBxsg"); 1260 1260 // + the leveva-native elemental membership letters q/a/h (slice 158) and Y (OJOIN, 165). 1261 1261 assert_eq!(p[4], "psmntiOovqahYbeIRkl"); 1262 1262 }
+26
leveva/src/registry.rs
··· 236 236 /// registry is the per-UID connection directory, and metadata is keyed on the same UIDs (and on 237 237 /// channel names). See [`crate::metadata`]. 238 238 metadata: crate::metadata::MetadataStore, 239 + /// Caller-id (`+g`) accept lists (P11 slice 216): per `+g` owner, the senders it accepts plus 240 + /// the senders it has already been notified about. Lives here for the same reason as `metadata` 241 + /// — keyed on the same connection UIDs. See [`crate::callerid`]. 242 + callerid: crate::callerid::CallerId, 239 243 } 240 244 241 245 #[derive(Debug, Default)] ··· 261 265 /// each connection's key subscriptions. See [`crate::metadata`]. 262 266 pub fn metadata(&self) -> &crate::metadata::MetadataStore { 263 267 &self.metadata 268 + } 269 + 270 + /// The caller-id (`+g`) accept-list store (P11 slice 216) — the per-owner accept set and the 271 + /// once-per-pair `717`/`718` notify gate. See [`crate::callerid`]. 272 + pub fn callerid(&self) -> &crate::callerid::CallerId { 273 + &self.callerid 274 + } 275 + 276 + /// The user-mode bitmask currently mirrored for `uid` (`0` if the client is unknown — an 277 + /// unknown target simply has no modes). Used by the caller-id (`+g`) delivery gate; mirrors 278 + /// [`caps_of`](Self::caps_of). 279 + pub fn modes_of(&self, uid: &Uid) -> u32 { 280 + self.inner 281 + .lock() 282 + .expect("registry mutex poisoned") 283 + .by_uid 284 + .get(uid) 285 + .map_or(0, |e| e.record.modes) 264 286 } 265 287 266 288 /// Claim `nick` for a newly-registered client identified by `uid`, recording the ··· 484 506 g.by_nick.remove(&key); 485 507 } 486 508 } 509 + drop(g); 510 + // Drop this connection's caller-id (`+g`) accept list + notify state (slice 216) so the 511 + // store never leaks. (Senders it appeared on *other* owners' lists are filtered on read.) 512 + self.callerid.forget(uid); 487 513 } 488 514 489 515 /// The UID currently holding the folded `nick`, if any. Resolves a nick target to
+16 -2
leveva/src/s2s/umode.rs
··· 275 275 0, 276 276 "server notices are local-only" 277 277 ); 278 + // `+g` (caller-id, slice 216) is purely local — the accept-list gate runs on the target's 279 + // home server, so a transit/origin server needs no copy — so like `+O`/`+x`/`+s` its bit 280 + // is excluded. 281 + assert_eq!( 282 + SEND_UMODES & UserMode::CallerId.bit(), 283 + 0, 284 + "caller-id is local-only" 285 + ); 278 286 // Every mode except the purely-local `+O` locop, the host-change-carried `+x`, and the 279 - // local-only `+s` server-notice flag is in the mask. 287 + // local-only `+s` server-notice / `+g` caller-id flags is in the mask. 280 288 for m in UserMode::ALL { 281 - if m == UserMode::LocalOp || m == UserMode::Cloak || m == UserMode::ServerNotice { 289 + if matches!( 290 + m, 291 + UserMode::LocalOp 292 + | UserMode::Cloak 293 + | UserMode::ServerNotice 294 + | UserMode::CallerId 295 + ) { 282 296 continue; 283 297 } 284 298 assert_ne!(SEND_UMODES & m.bit(), 0, "{m:?} propagates");
+172
leveva/tests/callerid_proptest.rs
··· 1 + //! Property-based fuzzing of the caller-id (`+g`) pure core ([`leveva::callerid::CallerId`], P11 2 + //! slice 216) — the accept-list store and the once-per-(owner,sender) `717`/`718` notify gate. 3 + //! 4 + //! 1. **Accept-list model agreement** — a random script of accept/unaccept over a UID pool that 5 + //! overflows the cap keeps [`CallerId`] in exact lockstep with an independent ordered-set model: 6 + //! membership ([`is_accepted`]), [`count`], the [`list`] order, and the [`ACCEPT_LIMIT`] cap all 7 + //! agree at every step; the store never panics. 8 + //! 2. **Notify / accept interaction** — a random script of message-attempts (`mark_notified`), 9 + //! accepts, unaccepts, `clear_notified` and `forget` keeps the once-per-pair notify decision in 10 + //! lockstep with a model: `mark_notified` returns `true` exactly the first time a pair is 11 + //! notified, and only an accept-that-added / `clear_notified` / `forget` forgives it. 12 + //! 13 + //! [`is_accepted`]: leveva::callerid::CallerId::is_accepted 14 + //! [`count`]: leveva::callerid::CallerId::count 15 + //! [`list`]: leveva::callerid::CallerId::list 16 + //! [`ACCEPT_LIMIT`]: leveva::callerid::ACCEPT_LIMIT 17 + 18 + use std::collections::HashSet; 19 + 20 + use leveva::callerid::{AcceptOutcome, CallerId, RemoveOutcome, ACCEPT_LIMIT}; 21 + use leveva::Uid; 22 + use proptest::prelude::*; 23 + 24 + /// `POOL_SIZE` distinct synthetic UIDs — larger than [`ACCEPT_LIMIT`] so the cap is exercised. 25 + const POOL_SIZE: usize = ACCEPT_LIMIT + 8; 26 + 27 + fn owner() -> Uid { 28 + "0ABCAAAAA".parse().expect("static UID parses") 29 + } 30 + 31 + /// The `i`-th pool UID: `0ABC` + a 5-digit index (all A-Z0-9, 9 chars → a valid UID). 32 + fn pool_uid(i: usize) -> Uid { 33 + format!("0ABC{i:05}").parse().expect("pool UID parses") 34 + } 35 + 36 + /// One accept-list op: add or remove the `i`-th pool UID. 37 + #[derive(Debug, Clone, Copy)] 38 + enum ListOp { 39 + Add(usize), 40 + Remove(usize), 41 + } 42 + 43 + fn list_op() -> impl Strategy<Value = ListOp> { 44 + (any::<bool>(), 0..POOL_SIZE).prop_map(|(add, i)| { 45 + if add { 46 + ListOp::Add(i) 47 + } else { 48 + ListOp::Remove(i) 49 + } 50 + }) 51 + } 52 + 53 + /// One notify-interaction event over a small sender pool (single owner). 54 + #[derive(Debug, Clone, Copy)] 55 + enum Event { 56 + /// A blocked message from sender `i` → `mark_notified`. 57 + Message(usize), 58 + Accept(usize), 59 + Unaccept(usize), 60 + ClearNotified, 61 + Forget, 62 + } 63 + 64 + fn event() -> impl Strategy<Value = Event> { 65 + prop_oneof![ 66 + (0usize..4).prop_map(Event::Message), 67 + (0usize..4).prop_map(Event::Accept), 68 + (0usize..4).prop_map(Event::Unaccept), 69 + Just(Event::ClearNotified), 70 + Just(Event::Forget), 71 + ] 72 + } 73 + 74 + proptest! { 75 + /// Property 1: the accept list (membership, count, order, cap) matches an ordered-set model. 76 + #[test] 77 + fn accept_list_matches_model(ops in prop::collection::vec(list_op(), 0..120)) { 78 + let cid = CallerId::new(); 79 + let o = owner(); 80 + // Model: insertion-ordered unique indices, capped at ACCEPT_LIMIT. 81 + let mut model: Vec<usize> = Vec::new(); 82 + for op in ops { 83 + match op { 84 + ListOp::Add(i) => { 85 + let outcome = cid.accept(&o, &pool_uid(i)); 86 + if model.contains(&i) { 87 + prop_assert_eq!(outcome, AcceptOutcome::Exists); 88 + } else if model.len() >= ACCEPT_LIMIT { 89 + prop_assert_eq!(outcome, AcceptOutcome::Full); 90 + } else { 91 + prop_assert_eq!(outcome, AcceptOutcome::Added); 92 + model.push(i); 93 + } 94 + } 95 + ListOp::Remove(i) => { 96 + let outcome = cid.unaccept(&o, &pool_uid(i)); 97 + if let Some(p) = model.iter().position(|&x| x == i) { 98 + prop_assert_eq!(outcome, RemoveOutcome::Removed); 99 + model.remove(p); 100 + } else { 101 + prop_assert_eq!(outcome, RemoveOutcome::NotPresent); 102 + } 103 + } 104 + } 105 + // Cap is never exceeded. 106 + prop_assert!(model.len() <= ACCEPT_LIMIT); 107 + prop_assert_eq!(cid.count(&o), model.len()); 108 + // Membership agrees for every pool member (incl. the never-added tail). 109 + for i in 0..POOL_SIZE { 110 + prop_assert_eq!(cid.is_accepted(&o, &pool_uid(i)), model.contains(&i)); 111 + } 112 + // The list order matches the model exactly. 113 + let got: Vec<Uid> = cid.list(&o); 114 + let want: Vec<Uid> = model.iter().map(|&i| pool_uid(i)).collect(); 115 + prop_assert_eq!(got, want); 116 + } 117 + } 118 + 119 + /// Property 2: the once-per-pair notify decision matches a model; an accept-that-added, 120 + /// `clear_notified` and `forget` all forgive the pair. 121 + #[test] 122 + fn notify_and_accept_interaction_matches_model(evs in prop::collection::vec(event(), 0..80)) { 123 + let cid = CallerId::new(); 124 + let o = owner(); 125 + // Model state for the single owner. 126 + let mut accepted: HashSet<usize> = HashSet::new(); 127 + let mut notified: HashSet<usize> = HashSet::new(); 128 + for ev in evs { 129 + match ev { 130 + Event::Message(i) => { 131 + // mark_notified returns true exactly the first time for an un-notified pair. 132 + let got = cid.mark_notified(&o, &pool_uid(i)); 133 + let want = notified.insert(i); // HashSet::insert → true if newly added 134 + prop_assert_eq!(got, want); 135 + } 136 + Event::Accept(i) => { 137 + let outcome = cid.accept(&o, &pool_uid(i)); 138 + if accepted.contains(&i) { 139 + prop_assert_eq!(outcome, AcceptOutcome::Exists); 140 + } else { 141 + // The pool is tiny (< cap), so a fresh accept always adds. 142 + prop_assert_eq!(outcome, AcceptOutcome::Added); 143 + accepted.insert(i); 144 + notified.remove(&i); // an add forgives the notify gate 145 + } 146 + } 147 + Event::Unaccept(i) => { 148 + let outcome = cid.unaccept(&o, &pool_uid(i)); 149 + let want = if accepted.remove(&i) { 150 + RemoveOutcome::Removed 151 + } else { 152 + RemoveOutcome::NotPresent 153 + }; 154 + prop_assert_eq!(outcome, want); 155 + } 156 + Event::ClearNotified => { 157 + cid.clear_notified(&o); 158 + notified.clear(); 159 + } 160 + Event::Forget => { 161 + cid.forget(&o); 162 + accepted.clear(); 163 + notified.clear(); 164 + } 165 + } 166 + // Accept membership stays in lockstep. 167 + for i in 0..4 { 168 + prop_assert_eq!(cid.is_accepted(&o, &pool_uid(i)), accepted.contains(&i)); 169 + } 170 + } 171 + } 172 + }
+82
leveva/tests/golden_callerid.rs
··· 1 + //! Boot-level golden: caller-id (`+g`) + `ACCEPT` end-to-end through the real `leveva` binary 2 + //! (P11 slice 216). 3 + //! 4 + //! A `+g` ("server-side ignore") user receives private messages only from clients on its `ACCEPT` 5 + //! list. An un-accepted sender's PRIVMSG bounces `716` (+ a once-per-sender `717`), and the target 6 + //! is told `718`. Accepting the sender opens delivery; un-accepting re-gates (and re-notifies). 7 + //! Caller-id is leveva-native, so this is a leveva-only golden — no differential against the 8 + //! mechanical port. 9 + 10 + mod harness; 11 + use harness::{boot, Client}; 12 + 13 + /// Register `name` and read through the welcome burst to the MOTD-missing 422. 14 + fn register(name: &str) -> Client { 15 + let mut c = Client::connect(); 16 + c.send(&format!("NICK {name}")); 17 + c.send(&format!("USER {name} 0 * :{name} Tester")); 18 + c.read_until(" 422 "); 19 + c 20 + } 21 + 22 + #[test] 23 + fn callerid_gates_then_accept_opens_then_unaccept_regates() { 24 + let _srv = boot(); 25 + 26 + // bob enables caller-id. 27 + let mut bob = register("bob"); 28 + bob.send("MODE bob +g"); 29 + bob.read_until("+g"); 30 + 31 + // alice (not accepted) PRIVMSGs bob → 716 + 717 to alice; 718 to bob; no PRIVMSG to bob. 32 + let mut alice = register("alice"); 33 + alice.send("PRIVMSG bob :hello there"); 34 + // 716 then 717 arrive back-to-back; read through to the 717 (the buffer carries both). 35 + let bounce = alice.read_until(" 717 "); 36 + assert!( 37 + bounce.contains(" 716 ") && bounce.contains("bob"), 38 + "expected 716 server-side-ignore bounce, got: {bounce}" 39 + ); 40 + assert!( 41 + bounce.contains(" 717 "), 42 + "expected 717 'has been informed', got: {bounce}" 43 + ); 44 + let told = bob.read_until(" 718 "); 45 + assert!( 46 + told.contains(" 718 ") && told.contains("alice"), 47 + "expected 718 'is messaging you' to the +g user, got: {told}" 48 + ); 49 + 50 + // bob accepts alice → her next PRIVMSG is delivered straight to bob. 51 + bob.send("ACCEPT alice"); 52 + alice.send("PRIVMSG bob :now you hear me"); 53 + let got = bob.read_until("now you hear me"); 54 + assert!( 55 + got.contains("PRIVMSG") && got.contains("now you hear me"), 56 + "accepted sender's PRIVMSG should be delivered, got: {got}" 57 + ); 58 + 59 + // Inverse: bob un-accepts alice → gated again, and re-notified (a fresh 716). 60 + bob.send("ACCEPT -alice"); 61 + alice.send("PRIVMSG bob :blocked again"); 62 + let again = alice.read_until(" 716 "); 63 + assert!( 64 + again.contains(" 716 "), 65 + "un-accepted sender should be gated again, got: {again}" 66 + ); 67 + } 68 + 69 + #[test] 70 + fn plain_target_receives_normally() { 71 + // Inverse: a target without +g is never gated. 72 + let _srv = boot(); 73 + 74 + let mut bob = register("bob"); 75 + let mut alice = register("alice"); 76 + alice.send("PRIVMSG bob :no gate here"); 77 + let got = bob.read_until("no gate here"); 78 + assert!( 79 + got.contains("PRIVMSG") && got.contains("no gate here"), 80 + "a plain target must receive normally, got: {got}" 81 + ); 82 + }
+1 -1
leveva/tests/snapshots/golden_bot_mode__bot_mode_isupport_whois_and_who.snap
··· 2 2 source: leveva/tests/golden_bot_mode.rs 3 3 expression: transcript 4 4 --- 5 - 005 BOT token: :leveva.test 005 alice BOT=B WHOX MONITOR=100 UTF8ONLY NETWORK=TestNet :are supported by this server 5 + 005 BOT token: :leveva.test 005 alice BOT=B WHOX MONITOR=100 UTF8ONLY CALLERID=g NETWORK=TestNet :are supported by this server 6 6 +B echo: :alice MODE alice :+B 7 7 WHOIS bot: :leveva.test 311 bob alice alice 127.0.0.1 * :Alice Tester 8 8 WHOIS bot: :leveva.test 335 bob alice :is a Bot
+2 -2
leveva/tests/snapshots/golden_cap__cap_302_negotiation_suspends_then_resumes.snap
··· 7 7 :leveva.test 001 dan :Welcome to the Internet Relay Network dan!d@127.0.0.1 8 8 :leveva.test 002 dan :Your host is leveva.test, running version leveva-<VER> 9 9 :leveva.test 003 dan :This server was created <MASKED> 10 - :leveva.test 004 dan leveva.test leveva-<VER> oOiwraWBxs psmntiOovqahYbeIRkl 10 + :leveva.test 004 dan leveva.test leveva-<VER> oOiwraWBxsg psmntiOovqahYbeIRkl 11 11 :leveva.test 005 dan CASEMAPPING=rfc1459 CHANTYPES=# CHANMODES=beIR,k,l,psmntiO PREFIX=(Yqaohv)!~&@%+ NICKLEN=15 CHANNELLEN=50 TOPICLEN=255 NAMELEN=50 LINELEN=2048 MODES=8 EXCEPTS=e INVEX=I EXTBAN=$,acjmorsxz :are supported by this server 12 - :leveva.test 005 dan BOT=B WHOX MONITOR=100 UTF8ONLY NETWORK=TestNet :are supported by this server 12 + :leveva.test 005 dan BOT=B WHOX MONITOR=100 UTF8ONLY CALLERID=g NETWORK=TestNet :are supported by this server 13 13 :leveva.test 251 dan :There are 1 users and 0 services on 1 servers 14 14 :leveva.test 253 dan 1 :unknown connection(s) 15 15 :leveva.test 254 dan 0 :channels formed
+2 -2
leveva/tests/snapshots/golden_cap__cap_ls_without_version_then_end.snap
··· 6 6 :leveva.test 001 jo :Welcome to the Internet Relay Network jo!j@127.0.0.1 7 7 :leveva.test 002 jo :Your host is leveva.test, running version leveva-<VER> 8 8 :leveva.test 003 jo :This server was created <MASKED> 9 - :leveva.test 004 jo leveva.test leveva-<VER> oOiwraWBxs psmntiOovqahYbeIRkl 9 + :leveva.test 004 jo leveva.test leveva-<VER> oOiwraWBxsg psmntiOovqahYbeIRkl 10 10 :leveva.test 005 jo CASEMAPPING=rfc1459 CHANTYPES=# CHANMODES=beIR,k,l,psmntiO PREFIX=(Yqaohv)!~&@%+ NICKLEN=15 CHANNELLEN=50 TOPICLEN=255 NAMELEN=50 LINELEN=2048 MODES=8 EXCEPTS=e INVEX=I EXTBAN=$,acjmorsxz :are supported by this server 11 - :leveva.test 005 jo BOT=B WHOX MONITOR=100 UTF8ONLY NETWORK=TestNet :are supported by this server 11 + :leveva.test 005 jo BOT=B WHOX MONITOR=100 UTF8ONLY CALLERID=g NETWORK=TestNet :are supported by this server 12 12 :leveva.test 251 jo :There are 1 users and 0 services on 1 servers 13 13 :leveva.test 253 jo 1 :unknown connection(s) 14 14 :leveva.test 254 jo 0 :channels formed
+1 -2
leveva/tests/snapshots/golden_help_users__help_and_users_queries.snap
··· 1 1 --- 2 2 source: leveva/tests/golden_help_users.rs 3 - assertion_line: 55 4 3 expression: transcript 5 4 --- 6 5 INDEX: :leveva.test NOTICE alice :*** leveva help *** ··· 12 11 INDEX: :leveva.test NOTICE alice :Channel commands: JOIN PART TOPIC NAMES LIST INVITE KICK MODE 13 12 INDEX: :leveva.test NOTICE alice :Messaging: PRIVMSG NOTICE TAGMSG 14 13 INDEX: :leveva.test NOTICE alice :Queries: WHO WHOIS WHOWAS USERHOST ISON MONITOR METADATA 15 - INDEX: :leveva.test NOTICE alice :User state: NICK AWAY MODE 14 + INDEX: :leveva.test NOTICE alice :User state: NICK AWAY MODE ACCEPT 16 15 INDEX: :leveva.test NOTICE alice :Server info: VERSION TIME ADMIN INFO LUSERS USERS MOTD 17 16 INDEX: :leveva.test NOTICE alice : LINKS MAP STATS 18 17 INDEX: :leveva.test NOTICE alice :Operator commands: OPER WALLOPS KILL CHGHOST TRACE ETRACE TKLINE
+2 -2
leveva/tests/snapshots/golden_pass__correct_password_welcomes.snap
··· 5 5 :leveva.test 001 alice :Welcome to the Internet Relay Network alice!alice@127.0.0.1 6 6 :leveva.test 002 alice :Your host is leveva.test, running version leveva-<VER> 7 7 :leveva.test 003 alice :This server was created <MASKED> 8 - :leveva.test 004 alice leveva.test leveva-<VER> oOiwraWBxs psmntiOovqahYbeIRkl 8 + :leveva.test 004 alice leveva.test leveva-<VER> oOiwraWBxsg psmntiOovqahYbeIRkl 9 9 :leveva.test 005 alice CASEMAPPING=rfc1459 CHANTYPES=# CHANMODES=beIR,k,l,psmntiO PREFIX=(Yqaohv)!~&@%+ NICKLEN=15 CHANNELLEN=50 TOPICLEN=255 NAMELEN=50 LINELEN=2048 MODES=8 EXCEPTS=e INVEX=I EXTBAN=$,acjmorsxz :are supported by this server 10 - :leveva.test 005 alice BOT=B WHOX MONITOR=100 UTF8ONLY NETWORK=TestNet :are supported by this server 10 + :leveva.test 005 alice BOT=B WHOX MONITOR=100 UTF8ONLY CALLERID=g NETWORK=TestNet :are supported by this server 11 11 :leveva.test 251 alice :There are 1 users and 0 services on 1 servers 12 12 :leveva.test 253 alice 1 :unknown connection(s) 13 13 :leveva.test 254 alice 0 :channels formed
+2 -2
leveva/tests/snapshots/golden_registration__registration_welcome_burst.snap
··· 5 5 :leveva.test 001 alice :Welcome to the Internet Relay Network alice!alice@127.0.0.1 6 6 :leveva.test 002 alice :Your host is leveva.test, running version leveva-<VER> 7 7 :leveva.test 003 alice :This server was created <MASKED> 8 - :leveva.test 004 alice leveva.test leveva-<VER> oOiwraWBxs psmntiOovqahYbeIRkl 8 + :leveva.test 004 alice leveva.test leveva-<VER> oOiwraWBxsg psmntiOovqahYbeIRkl 9 9 :leveva.test 005 alice CASEMAPPING=rfc1459 CHANTYPES=# CHANMODES=beIR,k,l,psmntiO PREFIX=(Yqaohv)!~&@%+ NICKLEN=15 CHANNELLEN=50 TOPICLEN=255 NAMELEN=50 LINELEN=2048 MODES=8 EXCEPTS=e INVEX=I EXTBAN=$,acjmorsxz :are supported by this server 10 - :leveva.test 005 alice BOT=B WHOX MONITOR=100 UTF8ONLY NETWORK=TestNet :are supported by this server 10 + :leveva.test 005 alice BOT=B WHOX MONITOR=100 UTF8ONLY CALLERID=g NETWORK=TestNet :are supported by this server 11 11 :leveva.test 251 alice :There are 1 users and 0 services on 1 servers 12 12 :leveva.test 253 alice 1 :unknown connection(s) 13 13 :leveva.test 254 alice 0 :channels formed
+1 -1
leveva/tests/snapshots/golden_server_query__server_query_target_resolution.snap
··· 4 4 --- 5 5 VER: :leveva.test 351 alice leveva-<VER> leveva.test :https://tangled.org/xeiaso.net/ircd.rs 6 6 VER: :leveva.test 005 alice CASEMAPPING=rfc1459 CHANTYPES=# CHANMODES=beIR,k,l,psmntiO PREFIX=(Yqaohv)!~&@%+ NICKLEN=15 CHANNELLEN=50 TOPICLEN=255 NAMELEN=50 LINELEN=2048 MODES=8 EXCEPTS=e INVEX=I EXTBAN=$,acjmorsxz :are supported by this server 7 - VER: :leveva.test 005 alice BOT=B WHOX MONITOR=100 UTF8ONLY NETWORK=TestNet :are supported by this server 7 + VER: :leveva.test 005 alice BOT=B WHOX MONITOR=100 UTF8ONLY CALLERID=g NETWORK=TestNet :are supported by this server 8 8 VER-LOCAL: :leveva.test 351 alice leveva-<VER> leveva.test :https://tangled.org/xeiaso.net/ircd.rs 9 9 VER-402: :leveva.test 402 alice nope.invalid :No such server 10 10 TIME-402: :leveva.test 402 alice nope.invalid :No such server
+2 -2
leveva/tests/snapshots/golden_user_spaceless__spaceless_user_realname_welcome_burst.snap
··· 5 5 :leveva.test 001 alice :Welcome to the Internet Relay Network alice!alice@127.0.0.1 6 6 :leveva.test 002 alice :Your host is leveva.test, running version leveva-<VER> 7 7 :leveva.test 003 alice :This server was created <MASKED> 8 - :leveva.test 004 alice leveva.test leveva-<VER> oOiwraWBxs psmntiOovqahYbeIRkl 8 + :leveva.test 004 alice leveva.test leveva-<VER> oOiwraWBxsg psmntiOovqahYbeIRkl 9 9 :leveva.test 005 alice CASEMAPPING=rfc1459 CHANTYPES=# CHANMODES=beIR,k,l,psmntiO PREFIX=(Yqaohv)!~&@%+ NICKLEN=15 CHANNELLEN=50 TOPICLEN=255 NAMELEN=50 LINELEN=2048 MODES=8 EXCEPTS=e INVEX=I EXTBAN=$,acjmorsxz :are supported by this server 10 - :leveva.test 005 alice BOT=B WHOX MONITOR=100 UTF8ONLY NETWORK=TestNet :are supported by this server 10 + :leveva.test 005 alice BOT=B WHOX MONITOR=100 UTF8ONLY CALLERID=g NETWORK=TestNet :are supported by this server 11 11 :leveva.test 251 alice :There are 1 users and 0 services on 1 servers 12 12 :leveva.test 253 alice 1 :unknown connection(s) 13 13 :leveva.test 254 alice 0 :channels formed