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): +s/+u UNAUTH snomask producer — authorisation-failed-connection spy notices — P11 slice 263

The sixth producer for a slice-257 reserved-but-empty snomask category
(charybdis SNO_UNAUTH, `u`). A registering client refused because no
credential authorises it — bad connection password (464), no matching
I-line (465), or a missing required ident — fans an `Unauthorised
connection from <nick>!<user>@<host> (<reason>)` spy notice to local
`+s +u` watchers. Distinct from REJ (slice 262, an active ban/blocklist):
these are "no credentials" failures, reported with the resolved user
since all three gates run after username resolution. Local-only like
every snomask producer; no S2S plane. Closes the slice-262 follow-on,
leaving only C/d/b without a producer.

- snomask.rs: SnoMask::unauth() accessor (UNAUTH = 1<<6); doc-table row
- snotice.rs: pure unauth_notice() builder (the fuzz seam)
- session.rs: server_notice_cat(unauth()) at the three auth-failure gates
- tests: snotice/snomask units + session fan tests (3 gates + inverses)
- fuzz: tests/unauth_snomask_proptest.rs (panic-free + scaffold + verbatim)

+353 -2
+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: 262 slices.** The slices cover 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, TS merge arbitration), `leveva-iauth` (dnsbl/socks/webproxy/pipe/ident), config live-rehash across every block, a channel-mode rework, CertFP, the **IRCv3 track** (message-tags/server-time/msgid/account/bot/oper/echo/labeled-response/CAP, `draft/metadata-2`, caller-id `+g`/`+G`, the **STS/TLS** sub-track), elemental channel modes + extbans, KNOCK, command-rate flood protection (fakelag), the leveva-native channel modes `+C`/`+c`/`+S`/`+j`/`+z`/`+T`/`+g`/`+r` (the `+z` op-moderation track 235–238 — local + network-wide S2S op-redirect for PRIVMSG/NOTICE/TAGMSG/REDACT via the statusmsg `@<chan>` seam — now complete; `+T` no-NOTICE, charybdis `MODE_NONOTICE`, slice 239; `+g` free-invite, charybdis `MODE_FREEINVITE`, slice 240; `+r` registered-only join, charybdis `MODE_REGONLY`, slice 241; `+f` channel forwarding, charybdis `MODE_FORWARD`, slice 242 — a forwardable JOIN reject redirects to the `+f` target with `470`; `+Q`/`+F` forward-target control, charybdis `MODE_DISFORWARD`/`MODE_FREETARGET`, slice 243 — `+F` lifts the op-on-target requirement for setting `+f`, `+Q` makes a channel refuse incoming forwards; `+P` permanent channel, charybdis `MODE_PERMANENT`/`chm_staff`, slice 244 — an oper-only flag that stops an empty channel being destroyed, so its topic/modes persist with zero members; `+L` large ban list, charybdis `MODE_EXLIMIT`/`chm_staff`, slice 245 — an oper-only flag that raises the per-list size cap on `+b`/`+e`/`+I` from 100 to 500, `478 ERR_BANLISTFULL` when a normal list is full — **the last missing standard charybdis channel mode**; the `max-bans`/`max-bans-large` caps `+L` toggles between are promoted to REHASH-able `options { max-bans / max-bans-large }` knobs, slice 246 — the const→knob trajectory `MAX_PENALTY` took); the user mode `+D` deaf, charybdis `UMODE_DEAF`, slice 247 — a client-settable, purely-local umode whose holder receives no channel-addressed messages (the channel fan-out filter runs on the deaf user's home server, so it is not in `SEND_UMODES`); the user mode `+R` registered-only messages, charybdis `UMODE_REGONLYMSG`, slice 248 — a client-settable, purely-local umode whose holder refuses a **private** `PRIVMSG`/`NOTICE` from an unidentified sender (`486 ERR_NONONREG`), bypassed by an identified/operator/accepted sender; the local-sender plane (slice 248); the `+R` remote-sender S2S plane, slice 249 — the inbound `relay::inbound_message` re-runs the same `callerid::regonly_msg_blocks` gate on the target's home server for a *remote* sender (neither `+R` nor `+g`/`+G` is in `SEND_UMODES`), bouncing one `486 ERR_NONONREG` to the sender's uplink for a blocked PRIVMSG (no notify pair, unlike caller-id) and silently dropping a blocked NOTICE — the caller-id 216→217 split repeated, closing the `+R` family; the user mode `+Q` no-forward, charybdis `UMODE_NOFORWARD`, slice 250 — the *user*-side counterpart of the channel `+f` forwarding feature: a client-settable, purely-local umode whose holder is never redirected by a `+f` channel (a forwardable JOIN reject falls through to the source's original numeric instead of `470` + redirect; the gate runs on the joiner's home server, so not in `SEND_UMODES`; the disjoint channel `+Q` `MODE_DISFORWARD` reuses the same letter), closing the `+f` family; the user mode `+G` soft caller-id MODE echo fix, slice 251 — `command::mode::umode_diff` omitted `SoftCallerId`, so a bare `±G` set the bit but echoed an empty trailing (local-echo repair to the existing slice-218 mode; not in `SEND_UMODES`, no snapshot/004 change); the user mode `+z` operwall + the `OPERWALL` command, charybdis `UMODE_OPERWALL`, slice 252 — the oper-only sibling of `+w`/WALLOPS: an operator broadcasts to every `+z` operator network-wide (`OPERWALL` command propagates, each server fans to its own local `+z`), `+z` is oper-gated to set like `+s` and purely-local (not in `SEND_UMODES`), a near-exact mirror of the slice-178 WALLOPS machinery (`command/operwall.rs` + `s2s/operwall.rs`); the user mode `+S` service, charybdis `UMODE_SERVICE`, slice 253 — a **server-set-only** umode (settable only on an S2S burst, a client/oper `MODE +S` silently ignored like `+Z`) whose holder is **immune to an operator's `KILL`** (`481` "Cannot kill a network service"; an inbound *server* KILL is still honoured, matching charybdis's `MyClient`-gated `no_kill_services` hook); unlike the recent local-only umodes it **propagates** (`SEND_UMODES`) since a service is a service network-wide; the user mode `+l` locops + the `LOCOPS` command, charybdis `UMODE_LOCOPS`, slice 254 — the local-server sibling of `+z`/OPERWALL (an oper broadcasts to `+l` users on the originating server only; the command never crosses a link, the point of "LOCal OPerator Wall"), the last named standard charybdis umode candidate, shipped alongside a product decision that **leveva has no local-only user modes**: `SEND_UMODES` became *every* umode (so `+O`/`+x`/`+s`/`+g`/`+G`/`+D`/`+R`/`+Q`/`+z`/`+l` all propagate network-wide and a remote `WHOIS` shows them), while behavioural enforcement (caller-id/`+R` accept-list gating) stays on the target's home server; the WHOIS service display, slice 255 — a `+S` target reads `313` "is a Network Service" (precedence over the operator string) and its channels are hidden (no `319`), closing the `+S` track; the `options { service-string }` REHASH knob, slice 256 — promotes the slice-255 hard-coded WHOIS `313` service text to a configurable charybdis-`servicestring` knob via the `ConfStore` live-or-default seam (the const→knob trajectory `MAX_PENALTY` 208→210 and `max-bans` 245→246 took), closing the last slice-255 follow-on; the `+s` server-notice **snomask**, charybdis `snomask`, slice 257 — promotes the slice-184 all-or-nothing `+s` umode to a charybdis category mask: `+s` becomes the one parameterized umode (a following token is its snomask delta, a bare `+s` seeds every category for slice-184 back-compat), the per-record mask routes each notice by category (`c` connect/exit, `k` kill, `s` general/rehash/SA*), and `008 RPL_SNOMASK` reports the mask (an empty mask drops `+s`); the `n` NCHANGE snomask producer, charybdis `SNO_NCHANGE`, slice 258 — the first producer wired for a slice-257 reserved-but-empty category: a local nick change (via the shared `apply_local_nick_change` chokepoint, so both `NICK` and `SANICK`) fans a `Nick change: From <old> to <new> [<user>@<host>]` spy notice to local `+s +n` watchers, local-only like every snomask producer; the `x` EXTERNAL snomask producer, charybdis `SNO_EXTERNAL`, slice 259 — the second producer wired for a slice-257 reserved-but-empty category: a remote server introduced behind a linked peer (the `s2s::server::handle_server` success path) fans a `Server <name> being introduced by <peer>` spy notice to local `+s +x` watchers (`by` = the direct peer, charybdis `client_p->name`), local-only like every snomask producer; the `y` SPY snomask producer, charybdis `SNO_SPY`, slice 260 — the third producer wired for a slice-257 reserved-but-empty category: a client requesting `STATS` from this server (the `command::stats::stats` chokepoint) fans a `STATS <c> requested by <nick> (<user>@<host>) [<server>]` spy notice to local `+s +y` watchers, fired for any selector char (even unrecognized), skipped for a bare `STATS` or an unresolvable caller, local-only like every snomask producer; the `f` FULL snomask producer, charybdis `SNO_FULL`, slice 261 — the fourth producer wired for a slice-257 reserved-but-empty category: a registering client refused for a full connection *cap* (the two `session.rs` finalize denials — local class `try_admit` and global per-host `check_global_admission`) fans a `Too many connections for <nick>!<user>@<host> (<reason>)` spy notice to local `+s +f` watchers (charybdis varies the verb per cap, leveva carries the exact `CapDenial::reason()`), local-only like every snomask producer; I-line/config-ban (`465`) refusals are deliberately *not* FULL (those are the future REJ/UNAUTH categories); the `r` REJ snomask producer, charybdis `SNO_REJ`, slice 262 — the fifth producer wired for a slice-257 reserved-but-empty category: a client connection *actively rejected by a ban or blocklist* at registration (the three `session.rs` finalize active-ban refusals — dnsbl auth-deny, runtime K-line, static config-`ban`) fans a `Rejecting client <nick>!<user>@<host> (<reason>)` spy notice to local `+s +r` watchers, local-only like every snomask producer; the dnsbl-deny site reports the raw `USER` param (it runs before username resolution), the K-line/config-ban sites the resolved user; the authorization-*failure* refusals (no-I-line `465` / bad-password `464` / ident-required) are deliberately *not* REJ (the future UNAUTH `u` category); and the native cross-daemon command-skeleton differential harness. **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: 263 slices.** The slices cover 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, TS merge arbitration), `leveva-iauth` (dnsbl/socks/webproxy/pipe/ident), config live-rehash across every block, a channel-mode rework, CertFP, the **IRCv3 track** (message-tags/server-time/msgid/account/bot/oper/echo/labeled-response/CAP, `draft/metadata-2`, caller-id `+g`/`+G`, the **STS/TLS** sub-track), elemental channel modes + extbans, KNOCK, command-rate flood protection (fakelag), the leveva-native channel modes `+C`/`+c`/`+S`/`+j`/`+z`/`+T`/`+g`/`+r` (the `+z` op-moderation track 235–238 — local + network-wide S2S op-redirect for PRIVMSG/NOTICE/TAGMSG/REDACT via the statusmsg `@<chan>` seam — now complete; `+T` no-NOTICE, charybdis `MODE_NONOTICE`, slice 239; `+g` free-invite, charybdis `MODE_FREEINVITE`, slice 240; `+r` registered-only join, charybdis `MODE_REGONLY`, slice 241; `+f` channel forwarding, charybdis `MODE_FORWARD`, slice 242 — a forwardable JOIN reject redirects to the `+f` target with `470`; `+Q`/`+F` forward-target control, charybdis `MODE_DISFORWARD`/`MODE_FREETARGET`, slice 243 — `+F` lifts the op-on-target requirement for setting `+f`, `+Q` makes a channel refuse incoming forwards; `+P` permanent channel, charybdis `MODE_PERMANENT`/`chm_staff`, slice 244 — an oper-only flag that stops an empty channel being destroyed, so its topic/modes persist with zero members; `+L` large ban list, charybdis `MODE_EXLIMIT`/`chm_staff`, slice 245 — an oper-only flag that raises the per-list size cap on `+b`/`+e`/`+I` from 100 to 500, `478 ERR_BANLISTFULL` when a normal list is full — **the last missing standard charybdis channel mode**; the `max-bans`/`max-bans-large` caps `+L` toggles between are promoted to REHASH-able `options { max-bans / max-bans-large }` knobs, slice 246 — the const→knob trajectory `MAX_PENALTY` took); the user mode `+D` deaf, charybdis `UMODE_DEAF`, slice 247 — a client-settable, purely-local umode whose holder receives no channel-addressed messages (the channel fan-out filter runs on the deaf user's home server, so it is not in `SEND_UMODES`); the user mode `+R` registered-only messages, charybdis `UMODE_REGONLYMSG`, slice 248 — a client-settable, purely-local umode whose holder refuses a **private** `PRIVMSG`/`NOTICE` from an unidentified sender (`486 ERR_NONONREG`), bypassed by an identified/operator/accepted sender; the local-sender plane (slice 248); the `+R` remote-sender S2S plane, slice 249 — the inbound `relay::inbound_message` re-runs the same `callerid::regonly_msg_blocks` gate on the target's home server for a *remote* sender (neither `+R` nor `+g`/`+G` is in `SEND_UMODES`), bouncing one `486 ERR_NONONREG` to the sender's uplink for a blocked PRIVMSG (no notify pair, unlike caller-id) and silently dropping a blocked NOTICE — the caller-id 216→217 split repeated, closing the `+R` family; the user mode `+Q` no-forward, charybdis `UMODE_NOFORWARD`, slice 250 — the *user*-side counterpart of the channel `+f` forwarding feature: a client-settable, purely-local umode whose holder is never redirected by a `+f` channel (a forwardable JOIN reject falls through to the source's original numeric instead of `470` + redirect; the gate runs on the joiner's home server, so not in `SEND_UMODES`; the disjoint channel `+Q` `MODE_DISFORWARD` reuses the same letter), closing the `+f` family; the user mode `+G` soft caller-id MODE echo fix, slice 251 — `command::mode::umode_diff` omitted `SoftCallerId`, so a bare `±G` set the bit but echoed an empty trailing (local-echo repair to the existing slice-218 mode; not in `SEND_UMODES`, no snapshot/004 change); the user mode `+z` operwall + the `OPERWALL` command, charybdis `UMODE_OPERWALL`, slice 252 — the oper-only sibling of `+w`/WALLOPS: an operator broadcasts to every `+z` operator network-wide (`OPERWALL` command propagates, each server fans to its own local `+z`), `+z` is oper-gated to set like `+s` and purely-local (not in `SEND_UMODES`), a near-exact mirror of the slice-178 WALLOPS machinery (`command/operwall.rs` + `s2s/operwall.rs`); the user mode `+S` service, charybdis `UMODE_SERVICE`, slice 253 — a **server-set-only** umode (settable only on an S2S burst, a client/oper `MODE +S` silently ignored like `+Z`) whose holder is **immune to an operator's `KILL`** (`481` "Cannot kill a network service"; an inbound *server* KILL is still honoured, matching charybdis's `MyClient`-gated `no_kill_services` hook); unlike the recent local-only umodes it **propagates** (`SEND_UMODES`) since a service is a service network-wide; the user mode `+l` locops + the `LOCOPS` command, charybdis `UMODE_LOCOPS`, slice 254 — the local-server sibling of `+z`/OPERWALL (an oper broadcasts to `+l` users on the originating server only; the command never crosses a link, the point of "LOCal OPerator Wall"), the last named standard charybdis umode candidate, shipped alongside a product decision that **leveva has no local-only user modes**: `SEND_UMODES` became *every* umode (so `+O`/`+x`/`+s`/`+g`/`+G`/`+D`/`+R`/`+Q`/`+z`/`+l` all propagate network-wide and a remote `WHOIS` shows them), while behavioural enforcement (caller-id/`+R` accept-list gating) stays on the target's home server; the WHOIS service display, slice 255 — a `+S` target reads `313` "is a Network Service" (precedence over the operator string) and its channels are hidden (no `319`), closing the `+S` track; the `options { service-string }` REHASH knob, slice 256 — promotes the slice-255 hard-coded WHOIS `313` service text to a configurable charybdis-`servicestring` knob via the `ConfStore` live-or-default seam (the const→knob trajectory `MAX_PENALTY` 208→210 and `max-bans` 245→246 took), closing the last slice-255 follow-on; the `+s` server-notice **snomask**, charybdis `snomask`, slice 257 — promotes the slice-184 all-or-nothing `+s` umode to a charybdis category mask: `+s` becomes the one parameterized umode (a following token is its snomask delta, a bare `+s` seeds every category for slice-184 back-compat), the per-record mask routes each notice by category (`c` connect/exit, `k` kill, `s` general/rehash/SA*), and `008 RPL_SNOMASK` reports the mask (an empty mask drops `+s`); the `n` NCHANGE snomask producer, charybdis `SNO_NCHANGE`, slice 258 — the first producer wired for a slice-257 reserved-but-empty category: a local nick change (via the shared `apply_local_nick_change` chokepoint, so both `NICK` and `SANICK`) fans a `Nick change: From <old> to <new> [<user>@<host>]` spy notice to local `+s +n` watchers, local-only like every snomask producer; the `x` EXTERNAL snomask producer, charybdis `SNO_EXTERNAL`, slice 259 — the second producer wired for a slice-257 reserved-but-empty category: a remote server introduced behind a linked peer (the `s2s::server::handle_server` success path) fans a `Server <name> being introduced by <peer>` spy notice to local `+s +x` watchers (`by` = the direct peer, charybdis `client_p->name`), local-only like every snomask producer; the `y` SPY snomask producer, charybdis `SNO_SPY`, slice 260 — the third producer wired for a slice-257 reserved-but-empty category: a client requesting `STATS` from this server (the `command::stats::stats` chokepoint) fans a `STATS <c> requested by <nick> (<user>@<host>) [<server>]` spy notice to local `+s +y` watchers, fired for any selector char (even unrecognized), skipped for a bare `STATS` or an unresolvable caller, local-only like every snomask producer; the `f` FULL snomask producer, charybdis `SNO_FULL`, slice 261 — the fourth producer wired for a slice-257 reserved-but-empty category: a registering client refused for a full connection *cap* (the two `session.rs` finalize denials — local class `try_admit` and global per-host `check_global_admission`) fans a `Too many connections for <nick>!<user>@<host> (<reason>)` spy notice to local `+s +f` watchers (charybdis varies the verb per cap, leveva carries the exact `CapDenial::reason()`), local-only like every snomask producer; I-line/config-ban (`465`) refusals are deliberately *not* FULL (those are the future REJ/UNAUTH categories); the `r` REJ snomask producer, charybdis `SNO_REJ`, slice 262 — the fifth producer wired for a slice-257 reserved-but-empty category: a client connection *actively rejected by a ban or blocklist* at registration (the three `session.rs` finalize active-ban refusals — dnsbl auth-deny, runtime K-line, static config-`ban`) fans a `Rejecting client <nick>!<user>@<host> (<reason>)` spy notice to local `+s +r` watchers, local-only like every snomask producer; the dnsbl-deny site reports the raw `USER` param (it runs before username resolution), the K-line/config-ban sites the resolved user; the authorization-*failure* refusals (no-I-line `465` / bad-password `464` / ident-required) are deliberately *not* REJ (the future UNAUTH `u` category); the `u` UNAUTH snomask producer, charybdis `SNO_UNAUTH`, slice 263 — the sixth producer wired for a slice-257 reserved-but-empty category: a registering client refused because *no credential authorises it* (the three `session.rs` authorisation-*failure* refusals — bad connection password `464`, no matching I-line `465`, missing required ident) fans an `Unauthorised connection from <nick>!<user>@<host> (<reason>)` spy notice to local `+s +u` watchers, local-only like every snomask producer; distinct from REJ (an active ban/blocklist) — these are "no credentials" failures, reported with the resolved user since all three run after username resolution; closes the slice-262 follow-on, leaving only `C`/`d`/`b` without a producer; and the native cross-daemon command-skeleton differential harness. **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 ---
+45
docs/progress-log/p11.md
··· 14541 14541 --tests` clean; `cargo build --workspace` 0 warnings; no welcome-burst / `004` / `221` / 14542 14542 STATS-reply snapshot churn (the fan is a side-effect on watcher mailboxes on refusal paths; 14543 14543 touches no numerics). 14544 + 14545 + ## 2026-06-18 — P11 slice 263: `u` UNAUTH snomask producer (authorisation-failed-connection spy) 14546 + 14547 + **What:** wired the **sixth** producer for a slice-257 reserved-but-empty snomask category — 14548 + charybdis `SNO_UNAUTH` (`u`). A registering client refused because **no credential authorises 14549 + it** (as distinct from REJ's active ban/blocklist) fans an `Unauthorised connection from 14550 + <nick>!<user>@<host> (<reason>)` spy notice to local `+s +u` watchers. Closes the slice-262 14551 + follow-on; the only categories still without a producer are now `C` CCONNEXT, `d` DEBUG, `b` 14552 + BOTS (genuine future/feature work). 14553 + 14554 + **Mapping (the three authorisation-*failure* refusals in `session.rs`):** 14555 + - bad connection password (`Authorization::BadPassword` → `464`) — reason `"Bad Password"`. 14556 + - no matching I-line (`Authorization::NoAuthorization` → `465`) — reason `"No Authorization"`. 14557 + - missing required ident (`ident_requirement_met` false → `465`) — reason `"Install identd"`. 14558 + 14559 + All three run *after* `resolve_username`, so the resolved `user` is reported. The active-ban 14560 + refusals stay REJ (slice 262); the connection-cap refusals stay FULL (slice 261). 14561 + 14562 + **Mechanism:** 14563 + - `snomask.rs`: `SnoMask::unauth()` accessor (`UNAUTH = 1 << 6`, reserved since 257); doc-table 14564 + `u` row flipped to its producer; `unauth()` added to `producer_accessors`. 14565 + - `snotice.rs`: pure builder `unauth_notice(nick, user, host, reason)` (the fuzz seam), 14566 + mirroring `reject_notice`. 14567 + - `session.rs`: `server_notice_cat(ctx, SnoMask::unauth(), &unauth_notice(...))` fired at each of 14568 + the three gates just before `self.closing = true; return;`. 14569 + 14570 + **Divergences:** local-only (no S2S plane — the refused connection is physically here); one 14571 + builder form carrying a fixed per-gate phrase in parens (mirrors the existing `ERROR :Closing 14572 + Link` reasons), vs charybdis's per-kind strings; no self-delivery (the refused client is 14573 + unregistered, nick unclaimed). 14574 + 14575 + **Tests:** `snotice.rs` units (`unauth_notice` renders + embeds each field); `snomask.rs` 14576 + `producer_accessors` adds `u`; `session.rs` units — a `+s +u` watcher and the bare-`+s` (ALL) 14577 + watcher both see the spy for a no-I-line, bad-password, and require-ident refusal, with the 14578 + `+s +r`-only and `-s` inverses silent; a successful registration fans no UNAUTH; a K-line 14579 + refusal fans no UNAUTH (it is REJ). Fuzz `tests/unauth_snomask_proptest.rs`: over arbitrary 14580 + `(nick, user, host, reason)` the builder never panics, always carries the scaffold, embeds 14581 + each field. 14582 + 14583 + **Plan:** [`docs/superpowers/plans/2026-06-18-p11-slice263-unauth-snomask.md`](../superpowers/plans/2026-06-18-p11-slice263-unauth-snomask.md). 14584 + 14585 + **Gate:** `cargo test -p leveva` green (lib + the new proptest); `cargo clippy -p leveva 14586 + --tests` clean; `cargo build --workspace` 0 warnings; no welcome-burst / `004` / `221` / 14587 + STATS-reply snapshot churn (the fan is a side-effect on watcher mailboxes on refusal paths; 14588 + touches no numerics).
+84
docs/superpowers/plans/2026-06-18-p11-slice263-unauth-snomask.md
··· 1 + # P11 slice 263 — `u` UNAUTH snomask producer (authorisation-failed-connection spy) 2 + 3 + ## Context 4 + 5 + Slice 257 promoted `+s` to a charybdis category mask (snomask) and left several categories 6 + with valid subscriptions but no producer. Slices 258–262 wired five of them: `n` NCHANGE, 7 + `x` EXTERNAL, `y` SPY, `f` FULL, `r` REJ. Slice 262's note explicitly deferred the 8 + authorisation *failures*: 9 + 10 + > The authorization-*failure* refusals (no-I-line `465` / bad-password `464` / ident-required) 11 + > are deliberately **not** REJ (the future UNAUTH `u` category). 12 + 13 + This slice wires **`u` UNAUTH** (charybdis `SNO_UNAUTH`, "unauthorised connections"): the 14 + producer for connections refused because **no credential authorises them** — as distinct from 15 + REJ, which is an active ban/blocklist hit. With this, the only snomask categories still without 16 + a producer are `C` CCONNEXT, `d` DEBUG, `b` BOTS (each genuinely future/feature work). 17 + 18 + ## charybdis grounding / leveva mapping 19 + 20 + charybdis splits registration denials across snomask categories: 21 + - connection caps full → `SNO_FULL` (slice 261, done) 22 + - banned / blocklisted → `SNO_REJ` (slice 262, done) 23 + - no matching `auth{}` / bad password / failed identd → `SNO_UNAUTH` 24 + 25 + leveva-native mapping for **UNAUTH (`u`)** = the three authorisation-*failure* refusals in 26 + `session.rs::finalize_registration`, all of which run *after* `resolve_username` (so the 27 + resolved `user` is reported): 28 + 29 + 1. **bad connection password** (`Authorization::BadPassword` → `464`) — a passworded I-line 30 + matched but the supplied `PASS` was wrong. 31 + 2. **no matching I-line** (`Authorization::NoAuthorization` → `465`) — no `allow` block 32 + authorises this host. 33 + 3. **missing required ident** (`ident_requirement_met` false → `465`) — a `require-ident` 34 + I-line matched but no trusted ident reply arrived. 35 + 36 + The active-ban refusals (dnsbl deny / K-line / config-`ban`) stay REJ (slice 262), and the 37 + connection-cap refusals stay FULL (slice 261). 38 + 39 + ## Mechanism 40 + 41 + - **`snomask.rs`**: add `SnoMask::unauth()` accessor (the `u` bit, `UNAUTH = 1 << 6`, reserved 42 + since slice 257); flip the `u` doc-table row from "(none yet)" to its producer; add 43 + `unauth()` to the `producer_accessors` test. 44 + - **`snotice.rs`**: add the pure builder 45 + `unauth_notice(nick, user, host, reason) -> "Unauthorised connection from <nick>!<user>@<host> (<reason>)"` 46 + (the fuzz seam), mirroring `reject_notice`. 47 + - **`session.rs`**: at each of the three chokepoints, just before `self.closing = true; return;`, 48 + fire `snotice::server_notice_cat(ctx, SnoMask::unauth(), &snotice::unauth_notice(...))` with 49 + `&nick`, `&user`, `&self.host`, and the fixed per-gate phrase: 50 + - bad password: `"Bad Password"`. 51 + - no I-line: `"No Authorization"`. 52 + - require-ident: `"Install identd"`. 53 + 54 + ## Divergences (documented) 55 + 56 + - **Local-only**, like every snomask producer: the fan iterates the local registry records, so 57 + a failed authorisation on a different leveva node spies on that node, never here. No S2S plane 58 + (the refused connection is physically on this server). 59 + - charybdis uses distinct notice strings per failure kind; leveva uses **one** builder form 60 + carrying a fixed per-gate phrase in parens (the same shape `reject_notice`/`conn_full_notice` 61 + took). The phrases mirror the existing `ERROR :Closing Link (…)` reasons for traceability. 62 + - All three gates run after `resolve_username`, so the resolved `user` is reported (unlike the 63 + REJ dnsbl-deny site, which runs before resolution and reports the raw `USER` param). 64 + - The refused client is never a watcher (it is unregistered; no nick claimed at any of these 65 + gates), so no self-delivery, matching the other producers. 66 + 67 + ## Tests (RED first) 68 + 69 + - `snotice.rs` units: `unauth_notice` renders the form + embeds each field. 70 + - `snomask.rs` unit: `unauth()` round-trips to letter `u` (added to `producer_accessors`). 71 + - `session.rs` units: a `+s +u` watcher and the bare-`+s` (ALL) watcher both receive 72 + `*** Notice -- Unauthorised connection from …` for a **no-I-line** refusal, a **bad-password** 73 + refusal, and a **require-ident** refusal; inverses — a `+s +r`-only watcher and a `-s` client 74 + get nothing; a *successful* registration fans no UNAUTH spy; a **K-line** refusal fans no 75 + UNAUTH spy (it is REJ, not UNAUTH). 76 + - Fuzz `tests/unauth_snomask_proptest.rs`: over arbitrary `(nick, user, host, reason)` the 77 + builder never panics, always carries the `Unauthorised connection from …!…@… (…)` scaffold, 78 + embeds each field. 79 + 80 + ## Gate 81 + 82 + `cargo test -p leveva` green (lib + the new proptest); `cargo clippy -p leveva --tests` clean; 83 + `cargo build --workspace` 0 warnings; no welcome-burst / `004` / `221` / STATS-reply snapshot 84 + churn (the fan is a side-effect on watcher mailboxes on refusal paths; touches no numerics).
+140
leveva/src/session.rs
··· 1137 1137 .trailing(format!("Closing Link: {} (Bad Password)", self.host)) 1138 1138 .to_wire(), 1139 1139 ); 1140 + // UNAUTH snomask spy (slice 263): a passworded I-line matched but the supplied 1141 + // PASS was wrong — fan a `+s +u` notice (charybdis `SNO_UNAUTH`). The nick is not 1142 + // yet claimed here, so the requested nick is reported. 1143 + crate::snotice::server_notice_cat( 1144 + ctx, 1145 + crate::snomask::SnoMask::unauth(), 1146 + &crate::snotice::unauth_notice(&nick, &user, &self.host, "Bad Password"), 1147 + ); 1140 1148 self.closing = true; 1141 1149 return; 1142 1150 } ··· 1156 1164 .trailing(format!("Closing Link: {} (No Authorization)", self.host)) 1157 1165 .to_wire(), 1158 1166 ); 1167 + // UNAUTH snomask spy (slice 263): no I-line authorises this host — fan a 1168 + // `+s +u` notice (charybdis `SNO_UNAUTH`). The nick is not yet claimed here. 1169 + crate::snotice::server_notice_cat( 1170 + ctx, 1171 + crate::snomask::SnoMask::unauth(), 1172 + &crate::snotice::unauth_notice(&nick, &user, &self.host, "No Authorization"), 1173 + ); 1159 1174 self.closing = true; 1160 1175 return; 1161 1176 } ··· 1181 1196 Message::builder("ERROR") 1182 1197 .trailing(format!("Closing Link: {} (Install identd)", self.host)) 1183 1198 .to_wire(), 1199 + ); 1200 + // UNAUTH snomask spy (slice 263): a `require-ident` I-line matched but no trusted 1201 + // ident reply arrived — fan a `+s +u` notice (charybdis `SNO_UNAUTH`). The nick is 1202 + // not yet claimed here. 1203 + crate::snotice::server_notice_cat( 1204 + ctx, 1205 + crate::snomask::SnoMask::unauth(), 1206 + &crate::snotice::unauth_notice(&nick, &user, &self.host, "Install identd"), 1184 1207 ); 1185 1208 self.closing = true; 1186 1209 return; ··· 2939 2962 let out = text(&s.feed(b"NICK bob\r\nUSER bob 0 * :B\r\n", &ctx)); 2940 2963 assert!(out.contains(" 465 "), "expected I-line refusal: {out}"); 2941 2964 assert!(rw.try_recv().is_err(), "a no-I-line refusal is UNAUTH not REJ"); 2965 + } 2966 + 2967 + // ---- slice 263: `u` UNAUTH snomask producer (authorisation-failed-connection spy) ---- 2968 + 2969 + /// Assert the `+s +u` watcher and the bare-`+s` (ALL) watcher both saw an `Unauthorised 2970 + /// connection from <nick>!…@<host>` UNAUTH notice, and — the inverses — the `+s +r`-only 2971 + /// watcher and the plain `-s` client saw nothing. 2972 + fn assert_unauth_fan( 2973 + uw: &mut tokio::sync::mpsc::UnboundedReceiver<crate::registry::Envelope>, 2974 + all: &mut tokio::sync::mpsc::UnboundedReceiver<crate::registry::Envelope>, 2975 + rw: &mut tokio::sync::mpsc::UnboundedReceiver<crate::registry::Envelope>, 2976 + plain: &mut tokio::sync::mpsc::UnboundedReceiver<crate::registry::Envelope>, 2977 + nick: &str, 2978 + host: &str, 2979 + ) { 2980 + use crate::command::testutil::delivered; 2981 + let head = format!("*** Notice -- Unauthorised connection from {nick}!"); 2982 + for w in [uw, all] { 2983 + let m = delivered(w); 2984 + assert_eq!(m.command(), "NOTICE"); 2985 + let t = m.trailing().unwrap(); 2986 + assert!(t.starts_with(&head), "{t}"); 2987 + assert!(t.contains(&format!("@{host} (")), "{t}"); 2988 + } 2989 + // Inverses: the REJ-only watcher and the plain client are not subscribed to `u`. 2990 + assert!(rw.try_recv().is_err()); 2991 + assert!(plain.try_recv().is_err()); 2992 + } 2993 + 2994 + /// Claim the four standard UNAUTH-fan watchers (`+s +u`, bare `+s`, `+s +r`-only, plain `-s`). 2995 + fn unauth_watchers( 2996 + ctx: &ServerContext, 2997 + ) -> ( 2998 + tokio::sync::mpsc::UnboundedReceiver<crate::registry::Envelope>, 2999 + tokio::sync::mpsc::UnboundedReceiver<crate::registry::Envelope>, 3000 + tokio::sync::mpsc::UnboundedReceiver<crate::registry::Envelope>, 3001 + tokio::sync::mpsc::UnboundedReceiver<crate::registry::Envelope>, 3002 + ) { 3003 + use crate::command::testutil::claim_observed; 3004 + let uw = claim_observed(ctx, "uwatch"); // +s +u 3005 + let all = claim_observed(ctx, "allw"); // bare +s = ALL 3006 + let rw = claim_observed(ctx, "rwatch"); // +s +r only 3007 + let plain = claim_observed(ctx, "plain"); // -s 3008 + watch_mask(ctx, "uwatch", crate::snomask::SnoMask::unauth()); 3009 + watch_mask(ctx, "allw", crate::snomask::SnoMask::ALL); 3010 + watch_mask(ctx, "rwatch", crate::snomask::SnoMask::rej()); 3011 + (uw, all, rw, plain) 3012 + } 3013 + 3014 + /// A connection refused for **no matching I-line** (`465`) fans a `+s +u` (UNAUTH) spy 3015 + /// naming the unauthorised client, reaching the `+u` and ALL watchers but neither the 3016 + /// `+r`-only watcher nor a plain `-s` client. 3017 + #[test] 3018 + fn a_no_iline_refusal_fans_the_unauth_snomask() { 3019 + let ctx = ctx_from(r#"allow { host "*@10.0.0.*"; class "c" }"#); 3020 + let (mut uw, mut all, mut rw, mut plain) = unauth_watchers(&ctx); 3021 + 3022 + let mut s = Session::new("127.0.0.1"); // matches no allow block 3023 + let out = text(&s.feed(b"NICK bob\r\nUSER bob 0 * :B\r\n", &ctx)); 3024 + assert!(out.contains(" 465 bob "), "got: {out}"); 3025 + 3026 + assert_unauth_fan(&mut uw, &mut all, &mut rw, &mut plain, "bob", "127.0.0.1"); 3027 + } 3028 + 3029 + /// A connection refused for a **bad connection password** (`464`) fans the same `+s +u` spy. 3030 + #[test] 3031 + fn a_bad_password_refusal_fans_the_unauth_snomask() { 3032 + let ctx = ctx_passworded(); 3033 + let (mut uw, mut all, mut rw, mut plain) = unauth_watchers(&ctx); 3034 + 3035 + let mut s = Session::new("127.0.0.1"); 3036 + let out = text(&s.feed(b"PASS wrong\r\nNICK bob\r\nUSER bob 0 * :B\r\n", &ctx)); 3037 + assert!(out.contains(" 464 bob "), "got: {out}"); 3038 + 3039 + assert_unauth_fan(&mut uw, &mut all, &mut rw, &mut plain, "bob", "127.0.0.1"); 3040 + } 3041 + 3042 + /// A connection refused for a **missing required ident** fans the same `+s +u` spy. 3043 + #[test] 3044 + fn a_require_ident_refusal_fans_the_unauth_snomask() { 3045 + let ctx = ctx_require_ident(); 3046 + let (mut uw, mut all, mut rw, mut plain) = unauth_watchers(&ctx); 3047 + 3048 + let mut s = Session::new("127.0.0.1"); 3049 + assert!(s.feed(b"NICK bob\r\nUSER bob 0 * :B\r\n", &ctx).is_empty()); 3050 + // No ident answered → the require-ident gate refuses. 3051 + let out = text(&s.resolve_auth(leveva_iauth::AuthVerdict::default(), &ctx)); 3052 + assert!(out.contains("(Install identd)"), "got: {out}"); 3053 + 3054 + assert_unauth_fan(&mut uw, &mut all, &mut rw, &mut plain, "bob", "127.0.0.1"); 3055 + } 3056 + 3057 + /// Inverse: a *successful* registration is not an auth failure → fans no UNAUTH spy. 3058 + #[test] 3059 + fn a_successful_registration_fans_no_unauth_snomask() { 3060 + use crate::command::testutil::claim_observed; 3061 + let ctx = ctx(); 3062 + let mut uw = claim_observed(&ctx, "uwatch"); 3063 + watch_mask(&ctx, "uwatch", crate::snomask::SnoMask::unauth()); 3064 + 3065 + let mut s = Session::new("9.9.9.9"); 3066 + assert!(text(&s.feed(b"NICK bob\r\nUSER bob 0 * :B\r\n", &ctx)).contains(" 001 ")); 3067 + assert!(uw.try_recv().is_err(), "no UNAUTH spy on a clean registration"); 3068 + } 3069 + 3070 + /// Inverse: a K-line refusal is REJ, not UNAUTH → fans no UNAUTH spy (it fired `r`). 3071 + #[test] 3072 + fn a_kline_refusal_fans_no_unauth_snomask() { 3073 + use crate::command::testutil::claim_observed; 3074 + let ctx = ctx(); 3075 + ctx.klines.add("*", "127.0.0.1", "no entry", i64::MAX); 3076 + let mut uw = claim_observed(&ctx, "uwatch"); 3077 + watch_mask(&ctx, "uwatch", crate::snomask::SnoMask::unauth()); 3078 + 3079 + let mut s = Session::new("127.0.0.1"); 3080 + assert!(text(&s.feed(b"NICK bob\r\nUSER bob 0 * :B\r\n", &ctx)).contains(" 465 ")); 3081 + assert!(uw.try_recv().is_err(), "a K-line ban is REJ not UNAUTH"); 2942 3082 } 2943 3083 2944 3084 /// A registered client's later `PASS` is refused with 462, like a re-`USER`.
+9 -1
leveva/src/snomask.rs
··· 22 22 //! | `C` | CCONNEXT | (none yet — extended/global connection notices) | 23 23 //! | `n` | NCHANGE | local nick-change spy ([`crate::snotice::nchange_notice`], slice 258) | 24 24 //! | `r` | REJ | rejected/banned-connection spy ([`crate::snotice::reject_notice`], slice 262) | 25 - //! | `u` | UNAUTH | (none yet — unauthorised client) | 25 + //! | `u` | UNAUTH | authorisation-failed-connection spy ([`crate::snotice::unauth_notice`], slice 263) | 26 26 //! | `x` | EXTERNAL | remote-server introduction spy ([`crate::snotice::server_intro_notice`], slice 259) | 27 27 //! | `y` | SPY | STATS-request spy ([`crate::snotice::stats_spy_notice`], slice 260) | 28 28 //! | `d` | DEBUG | (none yet — debug) | ··· 121 121 #[inline] 122 122 pub const fn rej() -> u32 { 123 123 Self::REJ 124 + } 125 + 126 + /// The `u` (authorisation-failed-connection spy) producer bit — the category for 127 + /// [`crate::snotice::unauth_notice`] (P11 slice 263). 128 + #[inline] 129 + pub const fn unauth() -> u32 { 130 + Self::UNAUTH 124 131 } 125 132 } 126 133 ··· 239 246 assert_eq!(render(SnoMask::spy()), "y"); 240 247 assert_eq!(render(SnoMask::full()), "f"); 241 248 assert_eq!(render(SnoMask::rej()), "r"); 249 + assert_eq!(render(SnoMask::unauth()), "u"); 242 250 } 243 251 }
+31
leveva/src/snotice.rs
··· 116 116 format!("Rejecting client {nick}!{user}@{host} ({reason})") 117 117 } 118 118 119 + /// Build the authorisation-failed-connection spy text (charybdis `SNO_UNAUTH`, P11 slice 263) 120 + /// fanned to local `+s +u` watchers when a registering client is refused because it failed 121 + /// authorisation — no matching I-line (`465`), a bad connection password (`464`), or a missing 122 + /// required ident reply. `nick`/`user`/`host` identify the unauthorised client; `reason` is the 123 + /// fixed phrase for the failing gate (`No Authorization`, `Bad Password`, `Install identd`). 124 + /// Pure — the fuzz seam. (This is the authorisation-*failure* counterpart of [`reject_notice`]: 125 + /// REJ is an active ban/blocklist, UNAUTH is "no credentials authorise you".) 126 + pub fn unauth_notice(nick: &str, user: &str, host: &str, reason: &str) -> String { 127 + format!("Unauthorised connection from {nick}!{user}@{host} ({reason})") 128 + } 129 + 119 130 #[cfg(test)] 120 131 mod tests { 121 132 use super::*; ··· 232 243 assert!(s.contains(piece), "{piece:?} missing from {s:?}"); 233 244 } 234 245 assert!(s.starts_with("Rejecting client ")); 246 + assert!(s.contains("the-nick!u-ser@h.example")); 247 + assert!(s.ends_with(')')); 248 + } 249 + 250 + #[test] 251 + fn unauth_notice_renders_the_charybdis_form() { 252 + assert_eq!( 253 + unauth_notice("bob", "ru", "127.0.0.1", "No Authorization"), 254 + "Unauthorised connection from bob!ru@127.0.0.1 (No Authorization)" 255 + ); 256 + } 257 + 258 + /// Every supplied field is embedded verbatim and the unauth scaffold is present. 259 + #[test] 260 + fn unauth_notice_embeds_each_field() { 261 + let s = unauth_notice("the-nick", "u-ser", "h.example", "Install identd"); 262 + for piece in ["the-nick", "u-ser", "h.example", "Install identd"] { 263 + assert!(s.contains(piece), "{piece:?} missing from {s:?}"); 264 + } 265 + assert!(s.starts_with("Unauthorised connection from ")); 235 266 assert!(s.contains("the-nick!u-ser@h.example")); 236 267 assert!(s.ends_with(')')); 237 268 }
+43
leveva/tests/unauth_snomask_proptest.rs
··· 1 + //! Property-based fuzzing of the authorisation-failed-connection spy text builder 2 + //! ([`leveva::snotice::unauth_notice`], P11 slice 263, charybdis `SNO_UNAUTH`). 3 + //! 4 + //! `unauth_notice` is the pure seam behind the `+s +u` server-notice fan: it formats an 5 + //! `Unauthorised connection from <nick>!<user>@<host> (<reason>)` line from four borrowed 6 + //! fields. It must never panic, must embed each non-empty field verbatim, and must always 7 + //! carry the fixed unauth-spy structure for **any** input — `nick`/`user`/`host` derive from a 8 + //! refused registration and `reason` is a fixed per-gate phrase (no-I-line / bad-password / 9 + //! missing-ident), so the formatter is the one place this can't wobble. 10 + 11 + use leveva::snotice::unauth_notice; 12 + use proptest::prelude::*; 13 + 14 + proptest! { 15 + /// For any inputs the builder never panics and the fixed scaffold is present. 16 + #[test] 17 + fn unauth_notice_always_has_the_unauth_scaffold( 18 + nick in ".{0,40}", user in ".{0,40}", host in ".{0,60}", reason in ".{0,80}", 19 + ) { 20 + let s = unauth_notice(&nick, &user, &host, &reason); 21 + prop_assert!(s.starts_with("Unauthorised connection from ")); 22 + // The nick!user@host and (reason) groups are always present. 23 + prop_assert!(s.contains('!')); 24 + prop_assert!(s.contains('@')); 25 + prop_assert!(s.ends_with(')')); 26 + } 27 + 28 + /// Every supplied field is embedded verbatim (no escaping/truncation of the inputs). 29 + #[test] 30 + fn unauth_notice_embeds_each_field_verbatim( 31 + nick in "[A-Za-z0-9._-]{1,30}", 32 + user in "[A-Za-z0-9._~-]{1,20}", 33 + host in "[A-Za-z0-9.:-]{1,50}", 34 + reason in "[A-Za-z0-9 ,@.-]{1,60}", 35 + ) { 36 + let s = unauth_notice(&nick, &user, &host, &reason); 37 + for piece in [&nick, &user, &host, &reason] { 38 + prop_assert!(s.contains(piece.as_str()), "{piece:?} missing from {s:?}"); 39 + } 40 + let triple = format!("{}!{}@{}", nick, user, host); 41 + prop_assert!(s.contains(&triple)); 42 + } 43 + }