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): CHANTRACE channel-scoped extended trace (P11 slice 287)

The channel-scoped sibling of TRACE/ETRACE/MASKTRACE, completing the trace
quartet: CHANTRACE <#channel> reports every member of a named channel in
ETRACE's extended 708 column format (folded-nick order), closing 262.

Gate (charybdis-faithful ordering): 461 (missing channel) -> 403 (no such
channel, existence before membership) -> 442 unless the requester is a member
or holds OperPrivilege::Trace (the operspy-equivalent bypass). Reuses
etrace::etrace_line, no new numeric.

Divergences (leveva-native, no oracle): emits 708 not charybdis 709; reports
the full roster incl. remote members (unlike local-only ETRACE/MASKTRACE) but
no S2S propagation; no IP-hiding (host==host, no separate IP column).

Tests (TDD, inverse invariants + fuzzing): 11 unit (403-before-442, PART
removes match, non-member admitted once joined, outsider never appears),
golden_chantrace (real binary), chantrace_proptest (4 properties, 512 cases).

+738 -1
+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: 286 slices** (286 = MASKTRACE: `MASKTRACE <nick!user@host mask> [<gecos mask>]` — a charybdis-style mask-filtered extended trace, the sibling of TRACE (26) and ETRACE: reports every local client whose `nick!user@host` glob-matches (and optionally whose realname matches a 2nd gecos glob) in ETRACE's extended column format; `461` param gate before the `OperPrivilege::Trace` oper gate (`481`), one reused `708 RPL_ETRACEFULL` line per match in folded-nick order via `matching::HostMask` + the promoted `etrace::etrace_line`, closing with `262 RPL_TRACEEND` (no new numeric); `MASKTRACE.md` help; leveva-native (2.11 has no MASKTRACE), local-only (no S2S); 285 = TESTLINE: `TESTLINE <[nick!]user@host | ip/cidr | nick | #channel>` — a charybdis-style **read-only** operator diagnostic, the capstone of the ban family (RESV 272 / TKLINE 281 / DLINE 284): it reports which **active** ban would match a mask, laying/lifting nothing. Param gate (`461`) then a plain oper-bit gate (any oper, no per-ban privilege → `481`); a pure `probe()` classifier routes by mask shape (`user@host`→D-line-then-K-line, `#channel`→channel RESV, bare token→D-line-as-IP-then-nick-RESV) and reports the first hit in priority **D > K > R** as `725 RPL_TESTLINE` (`<type K/D/R> <minutes-remaining, 0 for permanent RESV> <ban-mask> :<reason>`), else `726 RPL_NOTESTLINE`; expiry honoured via `find_active`; new numerics 725/726, `TESTLINE.md` help; leveva-native (no oracle — 2.11 has no TESTLINE), single-best-match, no I-line arm, **local-only** (no S2S, mirroring charybdis); 284 = D-lines: `DLINE <duration> <ip/cidr> [:reason]` / `UNDLINE <ip/cidr>` — charybdis-style IP-level operator bans, the IP/CIDR sibling of the temporary K-line family (281–283); new `DlineStore` (mirrors `KlineStore`, CIDR-aware `host_component_matches`, same `database {}` SQLite write-through + boot reload), `OperPrivilege::Dline` (bit `0x400000`), registration gate **before** the K-line gate matching the pre-cloak connect IP (`465`+`ERROR`+REJ snomask, exemption reuses `kline-exempt`), local reap by `orighost`, S2S `ENCAP * DLINE`/`UNDLINE` propagation + burst re-assertion (slice-281 KLINE shape), `STATS d` → `250 RPL_STATSDLINE`, `DLINE.md`/`UNDLINE.md` help; leveva-native, no oracle differential; 283 = SQLite persistence for permanent (`+P`) channels — a `+P` channel (slice 244, survives empty) now also survives a **restart**, persisting to the same `database {}` SQLite file that backs chat-history (274) and K-lines/reservations (282): its identity + `created_at` TS + full `ChannelModes` + topic write through to a `channel` table (JSON `modes`/`topic` columns, the serde codec chat-history uses) on every durable mutation, via the single `Channels::reconcile_persist` chokepoint (upsert when `+P`, **delete on `-P`**) hooked into all eight durable-state mutators — `apply_modes`/`apply_modes_oper`/`apply_modes_as_server` (MODE incl. the `+P`/`-P` toggle, key, limit, all `+b/+e/+I/+R` lists), `reset_flag_state`/`wipe_list_masks` (CHANTS-merge), `set_topic`/`set_topic_as_server` (TOPIC), and `create_permanent_from_burst` (an empty `+P` channel learned from a peer burst persists so it outlives *this* server's restart too, mirroring 282's inbound-S2S persist); recreated **empty** at boot (members/INVITE-tokens/join-throttle counters never persist — an empty `+P` channel is exactly what 244/269 make valid), with `created_at` carried so TS6 merge arbitration is stable post-reboot; a `persisted: HashSet` of folded keys makes a `reconcile_persist` on a never-persisted (non-`+P`) channel a pure in-memory miss — **no disk write per MODE on an ordinary channel** — and the two leaf locks (`db`/`persisted`) are never held simultaneously so there is no lock-order hazard with the held `by_name` guard; a `new()`/no-`database{}` table stays in-memory only (every persistence path a no-op); `ChannelModes`/`Topic` gained `#[derive(Serialize, Deserialize)]`; `from_config` opens the table against `cfg.database.path` (log + fall back to in-memory on error, never fatal), exactly the 282 klines/resvs shape; 282 = SQLite persistence for K-lines + reservations — operator-laid `TKLINE` bans and `RESV` reservations (lost on restart before) now persist to the **same `database {}` SQLite file** that backs chat-history (slice 274), so they survive a reboot: each `KlineStore`/`ResvStore` gains an optional write-through connection (`open(path)` creates a `kline`/`resv` table + loads existing rows; `add`/`remove`/`prune` mirror to it; a `new()`/no-`database{}` store stays in-memory only, every persistence path a no-op), the in-memory `Vec` staying the hot-path authority; K-lines drop `expires <= now` rows on load + prune; only **runtime** (`conf == false`) reservations persist (config `resv {}` re-derives from `ircd.kdl`, so `seed_config` bypasses persistence and a `REHASH` never drops a learned reservation); `COLLATE NOCASE` primary keys make a case-differing re-add upsert not duplicate; open/write errors log and fall back to in-memory (never fatal); inbound S2S `apply_encap_kline`/`apply_encap_resv` persist what a server learns from the network too (all via `add`); 281 = S2S `KLINE`/`UNKLINE` propagation — the K-line analogue of slice 280: a `TKLINE`/`UNTKLINE` (leveva's temporary K-line family, there is no separate permanent `KLINE`) now propagates network-wide as `ENCAP * KLINE <duration> <user> <host> :<reason>`/`ENCAP * UNKLINE <user> <host>` (server-prefixed, leveva-native, the **remaining** seconds on the wire so each server re-clocks `expires = now + duration` against its own clock — no absolute-timestamp skew), wired into `command/tkline.rs` after the param/privilege/format gates; every server applies the ban to its own `KlineStore` (gating future registrations) **and reaps its own local matching clients** via the now-`is_local_uid`-guarded `reap_matching` helper (which also fixed a latent over-reap of remote users in the local `TKLINE`); `kline-exempt` clients are spared; `UNKLINE` lifts the gate only (never un-kills) and propagates unconditionally; re-asserted to a freshly-linked peer by `s2s/burst.rs::kline_burst` (active bans only, with remaining time; expired bursts nothing); new `s2s/kline.rs` modelled on `s2s/resv.rs`, ENCAP dispatch in `s2s/forward.rs`; 280 = S2S `RESV`/`UNRESV` propagation — an operator-laid nick/channel reservation (and its lift) now propagates network-wide as `ENCAP * RESV`/`UNRESV` (server-prefixed, kind re-derived from the mask prefix on inbound apply), wired into `command/resv.rs` after the param/privilege gates (a `461`/`481` reject propagates nothing; `UNRESV` propagates unconditionally), applied + split-horizon-relayed by `s2s/forward.rs`, and re-asserted to a freshly-linked peer via `s2s/burst.rs::resv_burst` (runtime `conf == false` reservations only — config `resv {}` blocks stay per-server local policy); closes the slice-272/273 S2S-propagation follow-on, mirroring the METADATA/KNOCK ENCAP carriers; 279 = remote-originated JOIN/PART/QUIT membership events are now recorded into chat-history — the membership-event parallel to slice 276 (which did PRIVMSG/NOTICE): a **live** `NJOIN` (`s2s/njoin.rs`, never a burst — bursts are bulk state-sync), a bare `JOIN`/`PART` (`s2s/relay.rs`), and a **discrete** remote `QUIT`/`KILL` (`s2s/squit.rs::quit_one_remote_user`, *not* a netsplit mass teardown) each record an event keyed by a locally-minted msgid + clock, gated on `has_local_member` (only a local member can `CHATHISTORY` it; a pure hub stores nothing); an unmirrored `NJOIN`-only ghost has no mask so its QUIT is skipped; replay/query side unchanged (slice 274 already gates these behind `draft/event-playback`); 278 = `+R` auto-reop is now enforced for **local** members only — the `enforce_reop` chokepoint gained an explicit `is_local_uid` filter, so a `+R` mask matching a `UNICK`-introduced remote user's `nick!user@host` no longer wrongly ops it locally and relays a `+o` we have no authority to emit (the locality guarantee previously rested on the false premise that remote members lack a registry record); 277 = netsplit batches are no longer duplicated to `batch`-capable clients under concurrent teardown — when a dying peer is detected on two paths at once (abrupt socket drop + a sibling's inbound `SQUIT`, in separate tasks), the `NJOIN`-only ghost-member sweep raced (`co_members` then `remove_everywhere`, no single-shot gate) and double-relayed a casualty's `QUIT`; new atomic `Channels::detach_member` fuses recipient-gather + removal in one critical section so only the first teardown to reach a member relays it (the loser gets `None`), mirroring the already-atomic `remove_behind_sid` mirror drain; new `s2s_netsplit_concurrency_proptest` fuzz; 276 = remote-originated channel messages are now recorded into chat-history — the S2S relay's inbound channel fan (`s2s::relay::inbound_message`) records the delivered line on this server too, keyed by the carried network msgid (slice 130) + `@time` (slice 134), gated on this server having a local member of the channel (only a local member can `CHATHISTORY` it; no whole-network accumulation on a hub); the `+z` op-redirect/non-channel branches never record, mirroring the local plane; closes the slice-274 gap where `CHATHISTORY` held only locally-sent lines; 275 = IRCv3 `message-redaction` now deletes from chat-history — an accepted channel `REDACT <chan> <msgid>` deletes the slice-274 stored line (`History::delete_by_msgid`, keyed `(target, msgid)`) so `CHATHISTORY` can never replay it; wired in both the local `CanSend::Ok` path and inbound `ENCAP * REDACT`, per-server; mirrors recording — a `+z` op-moderated/"shown to ops" attempt is never recorded so never deleted; authorization stays a stateless relay; 274 = IRCv3 `draft/chathistory` — SQLite-backed (`rusqlite`, bundled) channel message history behind an optional `ircd.kdl` `database {}` block; all channels auto-enrolled (PRIVMSG/NOTICE + JOIN/PART/QUIT recorded at the delivery plane), joining clients auto-replayed `replay-lines` recent lines unless they negotiated `draft/chathistory` (then exempt, per spec), full `CHATHISTORY LATEST/BEFORE/AFTER/BETWEEN/AROUND/TARGETS` (timestamp=/msgid= selectors) framed as a `chathistory` batch, `draft/chathistory`+`draft/event-playback` dynamic caps + `CHATHISTORY`/`MSGREFTYPES` ISUPPORT, age-based `retention-days` prune ticker; `Message` gained serde; 273 = registration-time nick `RESV` enforcement — a connection may no longer *register* directly as a reserved nick, only the post-registration `NICK`/JOIN were gated before; refuse `432` + reopen registration, mirroring the `433` nick-in-use path; 272 = charybdis `RESV`/`UNRESV` nick/channel reservation — JOIN→`437`/NICK→`432` gates, opers exempt, new `OperPrivilege::Resv`; defaults declared in `resv {}` config blocks, boot-seeded + REHASH-re-seeded keeping runtime `RESV`s). The slices span the full client command surface (registration; channels + the complete channel-mode surface; WHO/WHOIS/WHOX; OPER/STATS; and the query/util commands incl. OPERWALL/LOCOPS/USERIP); 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; CertFP; the **IRCv3 cap 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 **complete charybdis channel-mode set** (`+C/+c/+S/+j/+z/+T/+g/+r/+f/+Q/+F/+P/+L`, with `+L` the last standard one) and **user-mode set** (`+D/+R/+Q/+G/+z/+S/+l`, plus the product decision that leveva has no local-only umodes — `SEND_UMODES` is every umode); and the `+s` server-notice **snomask** (a charybdis category mask with a producer wired for every category — the matrix is complete). Most const limits (`max-penalty`/`max-bans`/`service-string`/`default-snomask`/…) have been promoted to REHASH-able `options { … }` knobs. **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: 287 slices** (287 = CHANTRACE: `CHANTRACE <#channel>` — the channel-scoped sibling of TRACE (26)/ETRACE/MASKTRACE (286), completing the trace quartet: reports every member of a named channel in ETRACE's extended `708 RPL_ETRACEFULL` column format, folded-nick order, closing `262 RPL_TRACEEND`; gate is `461` (missing channel) → `403` (no such channel, existence before membership) → `442` unless the requester is a member **or** holds `OperPrivilege::Trace` (the operspy-equivalent bypass; leveva has no operspy `!` prefix); reuses `etrace::etrace_line`, no new numeric; `CHANTRACE.md` help; leveva-native (2.11 has no CHANTRACE), reports the **full roster incl. remote members** (unlike local-only ETRACE/MASKTRACE) but no S2S propagation, no IP-hiding (host==host, no separate IP column); 286 = MASKTRACE: `MASKTRACE <nick!user@host mask> [<gecos mask>]` — a charybdis-style mask-filtered extended trace, the sibling of TRACE (26) and ETRACE: reports every local client whose `nick!user@host` glob-matches (and optionally whose realname matches a 2nd gecos glob) in ETRACE's extended column format; `461` param gate before the `OperPrivilege::Trace` oper gate (`481`), one reused `708 RPL_ETRACEFULL` line per match in folded-nick order via `matching::HostMask` + the promoted `etrace::etrace_line`, closing with `262 RPL_TRACEEND` (no new numeric); `MASKTRACE.md` help; leveva-native (2.11 has no MASKTRACE), local-only (no S2S); 285 = TESTLINE: `TESTLINE <[nick!]user@host | ip/cidr | nick | #channel>` — a charybdis-style **read-only** operator diagnostic, the capstone of the ban family (RESV 272 / TKLINE 281 / DLINE 284): it reports which **active** ban would match a mask, laying/lifting nothing. Param gate (`461`) then a plain oper-bit gate (any oper, no per-ban privilege → `481`); a pure `probe()` classifier routes by mask shape (`user@host`→D-line-then-K-line, `#channel`→channel RESV, bare token→D-line-as-IP-then-nick-RESV) and reports the first hit in priority **D > K > R** as `725 RPL_TESTLINE` (`<type K/D/R> <minutes-remaining, 0 for permanent RESV> <ban-mask> :<reason>`), else `726 RPL_NOTESTLINE`; expiry honoured via `find_active`; new numerics 725/726, `TESTLINE.md` help; leveva-native (no oracle — 2.11 has no TESTLINE), single-best-match, no I-line arm, **local-only** (no S2S, mirroring charybdis); 284 = D-lines: `DLINE <duration> <ip/cidr> [:reason]` / `UNDLINE <ip/cidr>` — charybdis-style IP-level operator bans, the IP/CIDR sibling of the temporary K-line family (281–283); new `DlineStore` (mirrors `KlineStore`, CIDR-aware `host_component_matches`, same `database {}` SQLite write-through + boot reload), `OperPrivilege::Dline` (bit `0x400000`), registration gate **before** the K-line gate matching the pre-cloak connect IP (`465`+`ERROR`+REJ snomask, exemption reuses `kline-exempt`), local reap by `orighost`, S2S `ENCAP * DLINE`/`UNDLINE` propagation + burst re-assertion (slice-281 KLINE shape), `STATS d` → `250 RPL_STATSDLINE`, `DLINE.md`/`UNDLINE.md` help; leveva-native, no oracle differential; 283 = SQLite persistence for permanent (`+P`) channels — a `+P` channel (slice 244, survives empty) now also survives a **restart**, persisting to the same `database {}` SQLite file that backs chat-history (274) and K-lines/reservations (282): its identity + `created_at` TS + full `ChannelModes` + topic write through to a `channel` table (JSON `modes`/`topic` columns, the serde codec chat-history uses) on every durable mutation, via the single `Channels::reconcile_persist` chokepoint (upsert when `+P`, **delete on `-P`**) hooked into all eight durable-state mutators — `apply_modes`/`apply_modes_oper`/`apply_modes_as_server` (MODE incl. the `+P`/`-P` toggle, key, limit, all `+b/+e/+I/+R` lists), `reset_flag_state`/`wipe_list_masks` (CHANTS-merge), `set_topic`/`set_topic_as_server` (TOPIC), and `create_permanent_from_burst` (an empty `+P` channel learned from a peer burst persists so it outlives *this* server's restart too, mirroring 282's inbound-S2S persist); recreated **empty** at boot (members/INVITE-tokens/join-throttle counters never persist — an empty `+P` channel is exactly what 244/269 make valid), with `created_at` carried so TS6 merge arbitration is stable post-reboot; a `persisted: HashSet` of folded keys makes a `reconcile_persist` on a never-persisted (non-`+P`) channel a pure in-memory miss — **no disk write per MODE on an ordinary channel** — and the two leaf locks (`db`/`persisted`) are never held simultaneously so there is no lock-order hazard with the held `by_name` guard; a `new()`/no-`database{}` table stays in-memory only (every persistence path a no-op); `ChannelModes`/`Topic` gained `#[derive(Serialize, Deserialize)]`; `from_config` opens the table against `cfg.database.path` (log + fall back to in-memory on error, never fatal), exactly the 282 klines/resvs shape; 282 = SQLite persistence for K-lines + reservations — operator-laid `TKLINE` bans and `RESV` reservations (lost on restart before) now persist to the **same `database {}` SQLite file** that backs chat-history (slice 274), so they survive a reboot: each `KlineStore`/`ResvStore` gains an optional write-through connection (`open(path)` creates a `kline`/`resv` table + loads existing rows; `add`/`remove`/`prune` mirror to it; a `new()`/no-`database{}` store stays in-memory only, every persistence path a no-op), the in-memory `Vec` staying the hot-path authority; K-lines drop `expires <= now` rows on load + prune; only **runtime** (`conf == false`) reservations persist (config `resv {}` re-derives from `ircd.kdl`, so `seed_config` bypasses persistence and a `REHASH` never drops a learned reservation); `COLLATE NOCASE` primary keys make a case-differing re-add upsert not duplicate; open/write errors log and fall back to in-memory (never fatal); inbound S2S `apply_encap_kline`/`apply_encap_resv` persist what a server learns from the network too (all via `add`); 281 = S2S `KLINE`/`UNKLINE` propagation — the K-line analogue of slice 280: a `TKLINE`/`UNTKLINE` (leveva's temporary K-line family, there is no separate permanent `KLINE`) now propagates network-wide as `ENCAP * KLINE <duration> <user> <host> :<reason>`/`ENCAP * UNKLINE <user> <host>` (server-prefixed, leveva-native, the **remaining** seconds on the wire so each server re-clocks `expires = now + duration` against its own clock — no absolute-timestamp skew), wired into `command/tkline.rs` after the param/privilege/format gates; every server applies the ban to its own `KlineStore` (gating future registrations) **and reaps its own local matching clients** via the now-`is_local_uid`-guarded `reap_matching` helper (which also fixed a latent over-reap of remote users in the local `TKLINE`); `kline-exempt` clients are spared; `UNKLINE` lifts the gate only (never un-kills) and propagates unconditionally; re-asserted to a freshly-linked peer by `s2s/burst.rs::kline_burst` (active bans only, with remaining time; expired bursts nothing); new `s2s/kline.rs` modelled on `s2s/resv.rs`, ENCAP dispatch in `s2s/forward.rs`; 280 = S2S `RESV`/`UNRESV` propagation — an operator-laid nick/channel reservation (and its lift) now propagates network-wide as `ENCAP * RESV`/`UNRESV` (server-prefixed, kind re-derived from the mask prefix on inbound apply), wired into `command/resv.rs` after the param/privilege gates (a `461`/`481` reject propagates nothing; `UNRESV` propagates unconditionally), applied + split-horizon-relayed by `s2s/forward.rs`, and re-asserted to a freshly-linked peer via `s2s/burst.rs::resv_burst` (runtime `conf == false` reservations only — config `resv {}` blocks stay per-server local policy); closes the slice-272/273 S2S-propagation follow-on, mirroring the METADATA/KNOCK ENCAP carriers; 279 = remote-originated JOIN/PART/QUIT membership events are now recorded into chat-history — the membership-event parallel to slice 276 (which did PRIVMSG/NOTICE): a **live** `NJOIN` (`s2s/njoin.rs`, never a burst — bursts are bulk state-sync), a bare `JOIN`/`PART` (`s2s/relay.rs`), and a **discrete** remote `QUIT`/`KILL` (`s2s/squit.rs::quit_one_remote_user`, *not* a netsplit mass teardown) each record an event keyed by a locally-minted msgid + clock, gated on `has_local_member` (only a local member can `CHATHISTORY` it; a pure hub stores nothing); an unmirrored `NJOIN`-only ghost has no mask so its QUIT is skipped; replay/query side unchanged (slice 274 already gates these behind `draft/event-playback`); 278 = `+R` auto-reop is now enforced for **local** members only — the `enforce_reop` chokepoint gained an explicit `is_local_uid` filter, so a `+R` mask matching a `UNICK`-introduced remote user's `nick!user@host` no longer wrongly ops it locally and relays a `+o` we have no authority to emit (the locality guarantee previously rested on the false premise that remote members lack a registry record); 277 = netsplit batches are no longer duplicated to `batch`-capable clients under concurrent teardown — when a dying peer is detected on two paths at once (abrupt socket drop + a sibling's inbound `SQUIT`, in separate tasks), the `NJOIN`-only ghost-member sweep raced (`co_members` then `remove_everywhere`, no single-shot gate) and double-relayed a casualty's `QUIT`; new atomic `Channels::detach_member` fuses recipient-gather + removal in one critical section so only the first teardown to reach a member relays it (the loser gets `None`), mirroring the already-atomic `remove_behind_sid` mirror drain; new `s2s_netsplit_concurrency_proptest` fuzz; 276 = remote-originated channel messages are now recorded into chat-history — the S2S relay's inbound channel fan (`s2s::relay::inbound_message`) records the delivered line on this server too, keyed by the carried network msgid (slice 130) + `@time` (slice 134), gated on this server having a local member of the channel (only a local member can `CHATHISTORY` it; no whole-network accumulation on a hub); the `+z` op-redirect/non-channel branches never record, mirroring the local plane; closes the slice-274 gap where `CHATHISTORY` held only locally-sent lines; 275 = IRCv3 `message-redaction` now deletes from chat-history — an accepted channel `REDACT <chan> <msgid>` deletes the slice-274 stored line (`History::delete_by_msgid`, keyed `(target, msgid)`) so `CHATHISTORY` can never replay it; wired in both the local `CanSend::Ok` path and inbound `ENCAP * REDACT`, per-server; mirrors recording — a `+z` op-moderated/"shown to ops" attempt is never recorded so never deleted; authorization stays a stateless relay; 274 = IRCv3 `draft/chathistory` — SQLite-backed (`rusqlite`, bundled) channel message history behind an optional `ircd.kdl` `database {}` block; all channels auto-enrolled (PRIVMSG/NOTICE + JOIN/PART/QUIT recorded at the delivery plane), joining clients auto-replayed `replay-lines` recent lines unless they negotiated `draft/chathistory` (then exempt, per spec), full `CHATHISTORY LATEST/BEFORE/AFTER/BETWEEN/AROUND/TARGETS` (timestamp=/msgid= selectors) framed as a `chathistory` batch, `draft/chathistory`+`draft/event-playback` dynamic caps + `CHATHISTORY`/`MSGREFTYPES` ISUPPORT, age-based `retention-days` prune ticker; `Message` gained serde; 273 = registration-time nick `RESV` enforcement — a connection may no longer *register* directly as a reserved nick, only the post-registration `NICK`/JOIN were gated before; refuse `432` + reopen registration, mirroring the `433` nick-in-use path; 272 = charybdis `RESV`/`UNRESV` nick/channel reservation — JOIN→`437`/NICK→`432` gates, opers exempt, new `OperPrivilege::Resv`; defaults declared in `resv {}` config blocks, boot-seeded + REHASH-re-seeded keeping runtime `RESV`s). The slices span the full client command surface (registration; channels + the complete channel-mode surface; WHO/WHOIS/WHOX; OPER/STATS; and the query/util commands incl. OPERWALL/LOCOPS/USERIP); 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; CertFP; the **IRCv3 cap 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 **complete charybdis channel-mode set** (`+C/+c/+S/+j/+z/+T/+g/+r/+f/+Q/+F/+P/+L`, with `+L` the last standard one) and **user-mode set** (`+D/+R/+Q/+G/+z/+S/+l`, plus the product decision that leveva has no local-only umodes — `SEND_UMODES` is every umode); and the `+s` server-notice **snomask** (a charybdis category mask with a producer wired for every category — the matrix is complete). Most const limits (`max-penalty`/`max-bans`/`service-string`/`default-snomask`/…) have been promoted to REHASH-able `options { … }` knobs. **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 ---
+58
docs/progress-log/p11.md
··· 15685 15685 15686 15686 **Gate:** `cargo test -p leveva` green; `cargo clippy -p leveva --tests` clean; `cargo build 15687 15687 --workspace` 0 warnings. 15688 + 15689 + ## 2026-06-19 — P11 slice 287: `CHANTRACE` channel-scoped extended trace 15690 + 15691 + The channel-scoped sibling of `TRACE` (slice 26), `ETRACE` (extended) and `MASKTRACE` 15692 + (slice 286): a charybdis-style diagnostic that reports every member of a named channel in 15693 + the same extended column format `ETRACE`/`MASKTRACE` emit. Read-only — lays/lifts nothing. 15694 + Completes the trace-family quartet (`TRACE` / `ETRACE` / `MASKTRACE` / `CHANTRACE`). 15695 + 15696 + ### Mechanism 15697 + - **Command (`command/chantrace.rs`).** `CHANTRACE <#channel>`. Gate, charybdis-faithful 15698 + ordering: param gate first (missing/empty channel → `461 ERR_NEEDMOREPARAMS`), then 15699 + existence (channel not in `ctx.channels.members` → `403 ERR_NOSUCHCHANNEL`, checked 15700 + *before* membership so a nonexistent channel never leaks the membership gate), then the 15701 + membership gate — the requester must be a member (`ctx.channels.member_status(chan, 15702 + &client.uid).is_some()`) **or** hold [`OperPrivilege::Trace`], else `442 15703 + ERR_NOTONCHANNEL`. The oper bypass replaces charybdis's operspy `!` prefix (leveva has no 15704 + operspy concept); a plain member may trace their own channel. 15705 + - **Wire — no new numeric.** Each member UID is resolved to its [`registry::ClientRecord`] 15706 + (local *or* remote), sorted by folded nick (the trace family's canonical order), and 15707 + rendered as one `708 RPL_ETRACEFULL` line via the reused [`etrace::etrace_line`]; closes 15708 + with one `262 RPL_TRACEEND` `:End of TRACE` (the generic terminator charybdis shares for 15709 + `MASKTRACE`/`CHANTRACE`, already used by `MASKTRACE`). 15710 + - **Wiring.** `mod chantrace;` + `"CHANTRACE" => chantrace::chantrace(...)` in 15711 + `command/mod.rs`; `"CHANTRACE"` added to the `command::help` COMMANDS allowlist; 15712 + `help/CHANTRACE.md` page. 15713 + 15714 + ### Divergences (leveva-native — IRCnet 2.11 has no `CHANTRACE`; modeled on charybdis) 15715 + - Emits `708` (leveva's `RplEtracefull`), not charybdis's `709` — the same 708-vs-709 15716 + divergence `ETRACE`/`MASKTRACE` document. 15717 + - **Full roster, local + remote.** Unlike `ETRACE`/`MASKTRACE` (which sweep *local* 15718 + clients), `CHANTRACE` reports the channel's full member roster including remote 15719 + (UNICK-introduced) users — a channel-membership diagnostic, faithful to charybdis's 15720 + purpose. Still **local-only** in the S2S sense: a read of this server's channel state, no 15721 + propagation. 15722 + - **No IP-hiding.** charybdis shows `255.255.255.255` in the ip column to non-oper 15723 + requesters; leveva renders `host` == `host` (no separate numeric IP), so there is no IP 15724 + column to mask — the same `class`/`ip`/XLINE-column treatment `ETRACE` uses. 15725 + 15726 + ### Tests (TDD — failing first; inverse invariants + mandated fuzzing) 15727 + - `command/chantrace.rs` units (11): `461` (missing channel), `403`-before-`442` (existence 15728 + beats membership), `442` (non-member non-oper), member sweep `708…262` in folded order, 15729 + oper-non-member bypass, the full extended column set + Oper/User label, the `262` 15730 + terminator naming server+version; and the inverse invariants — a PART removes the match, a 15731 + non-member is refused **and** admitted once joined, a registered outsider never appears. 15732 + - `leveva/tests/golden_chantrace.rs` (real binary, `dline.kdl` oper `root` carries `trace`): 15733 + alice (oper, member) + bob (member) in `#rust`, alice `CHANTRACE #rust` → `708` per member 15734 + + `262`; carol (non-member non-oper) → `442`; `CHANTRACE #ghost` → `403`. Host/ip columns 15735 + masked in the snapshot. 15736 + - `leveva/tests/chantrace_proptest.rs` (4 properties, 512 cases each): 15737 + `member_sweep_in_folded_order` (one `708` per member, folded order, then `262`), 15738 + `non_member_is_442_unless_oper` (outsider → `442`, with trace → the bypass sweep), 15739 + `missing_channel_is_always_461` (param gate precedes everything), `arbitrary_never_panics` 15740 + (only `{461,403,442,708,262}` ever). 15741 + 15742 + **Plan:** `docs/superpowers/plans/2026-06-19-p11-slice287-chantrace.md`. 15743 + 15744 + **Gate:** `cargo test -p leveva` green (2565 lib + golden_chantrace + chantrace_proptest); 15745 + `cargo clippy -p leveva --tests` clean; `cargo build --workspace` 0 warnings.
+84
docs/superpowers/plans/2026-06-19-p11-slice287-chantrace.md
··· 1 + # P11 slice 287 — `CHANTRACE` channel-scoped extended trace 2 + 3 + ## Goal 4 + 5 + Add `CHANTRACE <#channel>` — the **channel-scoped** sibling of `TRACE` (slice 26), 6 + `ETRACE` (extended) and `MASKTRACE` (slice 286): a charybdis-style operator/member 7 + diagnostic that reports every member of a named channel in the same extended column 8 + format `ETRACE`/`MASKTRACE` emit. Read-only — lays/lifts nothing. Completes the 9 + trace-family quartet (`TRACE` / `ETRACE` / `MASKTRACE` / `CHANTRACE`). 10 + 11 + ## Locked decisions 12 + 13 + - **leveva-native.** IRCnet 2.11 has no `CHANTRACE`; modeled on charybdis `m_chantrace`. 14 + No oracle differential. 15 + - **Gate (charybdis-faithful, resolved to leveva):** 16 + 1. Param gate first — missing/empty channel → `461 ERR_NEEDMOREPARAMS` (the 17 + trace/ban-family ordering, like `MASKTRACE`). 18 + 2. Channel must exist → else `403 ERR_NOSUCHCHANNEL` (charybdis checks existence 19 + before membership). 20 + 3. Requester must be **a member of the channel** *or* hold `OperPrivilege::Trace` 21 + → else `442 ERR_NOTONCHANNEL`. The oper bypass replaces charybdis's operspy `!` 22 + prefix (leveva has no operspy concept); a plain member may trace their own 23 + channel exactly as charybdis allows. 24 + - **Wire — no new numeric.** Each member → one `708 RPL_ETRACEFULL` line via the 25 + reused `etrace::etrace_line`; always closes with one `262 RPL_TRACEEND` 26 + `:End of TRACE` (the generic terminator charybdis shares for `MASKTRACE`/`CHANTRACE`, 27 + already used by `MASKTRACE`). 28 + - **Member order:** resolve each member UID to its `ClientRecord` and sort by folded 29 + nick — deterministic and consistent with the rest of the trace family (the channel 30 + `members` map is `BTreeMap<Uid,_>`, but folded-nick is the family's canonical order). 31 + 32 + ## Design 33 + 34 + `leveva/src/command/chantrace.rs`: 35 + 36 + ``` 37 + pub(crate) fn chantrace(client, msg, ctx) -> Vec<Message>: 38 + chan = params[0] non-empty else 461 39 + members = ctx.channels.members(chan) else 403 # existence 40 + is_member = ctx.channels.member_status(chan, &client.uid).is_some() 41 + if !is_member && !client.has_privilege(Trace): 442 42 + recs = members.filter_map(|u| ctx.registry.record_of(&u)) 43 + recs.sort_by(folded nick) 44 + out = recs.map(|r| etrace::etrace_line(ctx, &client.nick, &r)) 45 + out.push(262 RPL_TRACEEND) # local chantrace_end, masktrace_end shape 46 + ``` 47 + 48 + Wiring: `mod chantrace;` + `"CHANTRACE" => chantrace::chantrace(...)` in 49 + `command/mod.rs`; `"CHANTRACE"` added to the `command::help` COMMANDS allowlist; 50 + `leveva/help/CHANTRACE.md` page. 51 + 52 + ## Divergences (documented) 53 + 54 + - **Wire numeric.** Emits `708` (leveva's `RplEtracefull`), not charybdis's `709` — 55 + the same 708-vs-709 divergence `ETRACE`/`MASKTRACE` document. 56 + - **Full roster, local + remote.** Unlike `ETRACE`/`MASKTRACE` (which sweep *local* 57 + clients), `CHANTRACE` reports the channel's **full member roster** including 58 + remote (UNICK-introduced) users — a channel-membership diagnostic, faithful to 59 + charybdis's purpose. Still **local-only** in the S2S sense: a read of *this* 60 + server's channel state, no propagation. 61 + - **No IP-hiding.** charybdis shows `255.255.255.255` in the ip column to non-oper 62 + requesters; leveva renders `host` == `host` (no separate numeric IP), so there is 63 + no IP column to mask — the same `class`/`ip`/XLINE-column treatment `ETRACE` uses. 64 + 65 + ## Tests (TDD — failing first; inverse invariants + mandated fuzzing) 66 + 67 + - `command/chantrace.rs` units: `461` (missing channel), `403` (no such channel), 68 + `442` (non-member non-oper), member sweep `708…262` in folded order, oper-non-member 69 + bypass, the full extended column set + Oper/User label; **inverse invariants** — a 70 + PART removes the match, a non-member is refused (and admitted once joined), an empty 71 + channel reports just the `262` for an oper, the `403`-before-`442` ordering. 72 + - `leveva/tests/golden_chantrace.rs` (real binary, `dline.kdl` oper carries `trace`): 73 + members in a channel → `708` per member + `262`; an oper tracing a channel they're 74 + not in (bypass) succeeds; a non-member non-oper → `442`; a nonexistent channel → 75 + `403`. Host/ip columns masked. 76 + - `leveva/tests/chantrace_proptest.rs` (4 properties, 512 cases each): 77 + `member_sweep_in_folded_order`, `non_member_is_442_unless_oper`, 78 + `missing_channel_is_always_461`, `arbitrary_never_panics` (only 79 + `{461,403,442,708,262}` ever). 80 + 81 + ## Gate 82 + 83 + `cargo test -p leveva` green; `cargo clippy -p leveva --tests` clean; 84 + `cargo build --workspace` 0 warnings.
+25
leveva/help/CHANTRACE.md
··· 1 + CHANTRACE <#channel> 2 + 3 + Reports a channel-scoped extended trace: every member of the named channel is 4 + reported in the same full column format ETRACE and MASKTRACE use. Each member 5 + is one line (numeric 708, RPL_ETRACEFULL) carrying the class, nick, user, host, 6 + ip, and realname columns, labelled Oper or User depending on operator status, 7 + in folded-nick order. The report always closes with a single 262 8 + (RPL_TRACEEND) naming this server and its version. 9 + 10 + The channel argument is required; without it you get 461 11 + (ERR_NEEDMOREPARAMS). A channel that does not exist gives 403 12 + (ERR_NOSUCHCHANNEL). You may CHANTRACE a channel you are a member of; an 13 + operator with the trace privilege may CHANTRACE any channel. A non-member 14 + without that privilege gets 442 (ERR_NOTONCHANNEL). 15 + 16 + Unlike ETRACE and MASKTRACE, which sweep this server's local clients, 17 + CHANTRACE reports the channel's full member roster, including users on other 18 + servers. It lays and lifts nothing; it only reads, and it does not propagate 19 + across the network. 20 + 21 + Examples: 22 + CHANTRACE #rust 23 + CHANTRACE #staff 24 + 25 + See also: ETRACE, MASKTRACE, TRACE, NAMES, WHO.
+262
leveva/src/command/chantrace.rs
··· 1 + use crate::casemap; 2 + use crate::command::*; 3 + 4 + /// `CHANTRACE <#channel>` — the **channel-scoped** extended trace: every member of a named 5 + /// channel reported in the same extended column format [`ETRACE`](super::etrace) / 6 + /// [`MASKTRACE`](super::masktrace) emit. 7 + /// 8 + /// Models the charybdis `m_chantrace` resolved to single-server leveva. Read-only diagnostic: 9 + /// it reports a channel's roster, laying/lifting nothing. Completes the trace family 10 + /// ([`TRACE`](super::trace) / [`ETRACE`](super::etrace) / [`MASKTRACE`](super::masktrace) / 11 + /// `CHANTRACE`). 12 + /// 13 + /// Gate (charybdis-faithful ordering): 14 + /// - **≥ 1 arg** (the channel) → too few → `461 ERR_NEEDMOREPARAMS`, checked **first** (the 15 + /// trace/ban-family ordering: param gate before everything). 16 + /// - **Channel must exist** → else `403 ERR_NOSUCHCHANNEL` (charybdis checks existence before 17 + /// membership, so a nonexistent channel never reveals whether you'd be allowed). 18 + /// - **Membership-gated** — the requester must be a member of the channel *or* hold 19 + /// [`OperPrivilege::Trace`] → else `442 ERR_NOTONCHANNEL`. The oper bypass replaces 20 + /// charybdis's operspy `!` prefix (leveva has no operspy concept); a plain member may trace 21 + /// their own channel exactly as charybdis allows. 22 + /// 23 + /// Each member → one `708 RPL_ETRACEFULL` line (the [`super::etrace::etrace_line`] column 24 + /// format, reused verbatim), in deterministic folded-nick order. Always closes with one 25 + /// `262 RPL_TRACEEND` `:End of TRACE` (the generic trace terminator charybdis shares for 26 + /// `MASKTRACE`/`CHANTRACE`). 27 + /// 28 + /// Documented divergences (leveva-native — IRCnet 2.11 has no `CHANTRACE`; modeled on 29 + /// charybdis): 30 + /// - **Wire numeric.** Emits `708` ([`Numeric::RplEtracefull`]), not charybdis's `709` — the 31 + /// same 708-vs-709 divergence [`ETRACE`](super::etrace) / [`MASKTRACE`](super::masktrace) 32 + /// already document. 33 + /// - **Full roster, local + remote.** Unlike [`ETRACE`](super::etrace) / 34 + /// [`MASKTRACE`](super::masktrace) (which sweep *local* clients), `CHANTRACE` reports the 35 + /// channel's **full member roster** including remote (UNICK-introduced) users — a 36 + /// channel-membership diagnostic. Still **local-only** in the S2S sense (a read of *this* 37 + /// server's channel state, no propagation). 38 + /// - **No IP-hiding.** charybdis shows `255.255.255.255` in the ip column to non-oper 39 + /// requesters; leveva renders `host` == `host` (no separate numeric IP), so there is no IP 40 + /// column to mask — the same `class`/`ip`/XLINE-column treatment [`ETRACE`](super::etrace) 41 + /// uses. 42 + pub(crate) fn chantrace(client: &Registered, msg: &Message, ctx: &ServerContext) -> Vec<Message> { 43 + // Param gate first (trace-family ordering). 44 + let Some(chan) = msg.params().first().filter(|s| !s.is_empty()) else { 45 + return vec![need_more_params(ctx, &client.nick, "CHANTRACE")]; 46 + }; 47 + // Existence before membership (a nonexistent channel never reveals the membership gate). 48 + let Some(members) = ctx.channels.members(chan) else { 49 + return vec![no_such_channel(ctx, &client.nick, chan)]; 50 + }; 51 + // Member of the channel, or an oper holding ACL_TRACE (the operspy-equivalent bypass). 52 + let is_member = ctx.channels.member_status(chan, &client.uid).is_some(); 53 + if !is_member && !client.has_privilege(OperPrivilege::Trace) { 54 + return vec![not_on_channel(ctx, &client.nick, chan)]; 55 + } 56 + 57 + let to = &client.nick; 58 + // Resolve every member UID to its stored record (local or remote), then report in 59 + // deterministic folded-nick order — the trace family's canonical ordering. 60 + let mut recs: Vec<crate::registry::ClientRecord> = members 61 + .iter() 62 + .filter_map(|uid| ctx.registry.record_of(uid)) 63 + .collect(); 64 + recs.sort_by_key(|rec| casemap::fold(&rec.nick)); 65 + 66 + let mut out: Vec<Message> = recs 67 + .iter() 68 + .map(|rec| super::etrace::etrace_line(ctx, to, rec)) 69 + .collect(); 70 + out.push(chantrace_end(ctx, to)); 71 + out 72 + } 73 + 74 + /// The closing `262 RPL_TRACEEND <server> <version> :End of TRACE` (the generic trace end 75 + /// charybdis reuses for `CHANTRACE`, mirroring [`super::masktrace`]'s terminator). 76 + fn chantrace_end(ctx: &ServerContext, to: &str) -> Message { 77 + Message::builder(Numeric::RplTraceend) 78 + .prefix(&ctx.name) 79 + .param(to) 80 + .param(&ctx.name) 81 + .param(ctx.version) 82 + .trailing("End of TRACE") 83 + .build() 84 + } 85 + 86 + /// `442 ERR_NOTONCHANNEL` for `chan` (the requester is neither a member nor a trace-oper). 87 + fn not_on_channel(ctx: &ServerContext, nick: &str, chan: &str) -> Message { 88 + Message::builder(Numeric::ErrNotonchannel) 89 + .prefix(&ctx.name) 90 + .param(nick) 91 + .param(chan) 92 + .trailing("You're not on that channel") 93 + .build() 94 + } 95 + 96 + #[cfg(test)] 97 + mod tests { 98 + use super::*; 99 + use crate::command::testutil::*; 100 + use crate::UserMode; 101 + 102 + /// Claim `nick` (user `u`, host `h`, realname `real`) and place it into `chan`; mirror 103 + /// `modes` so the `Oper`/`User` label is right. 104 + fn seed_member(ctx: &ServerContext, nick: &str, chan: &str, modes: u32) { 105 + let _rx = claim_observed(ctx, nick); 106 + ctx.channels.join(chan, &uid_for(nick), 0); 107 + ctx.registry.set_modes(&uid_for(nick), modes); 108 + } 109 + 110 + /// A registered (registry) client *not* in any channel. 111 + fn seed(ctx: &ServerContext, nick: &str, modes: u32) { 112 + let _rx = claim_observed(ctx, nick); 113 + ctx.registry.set_modes(&uid_for(nick), modes); 114 + } 115 + 116 + /// The requester as `nick`, optionally an operator holding `ACL_TRACE`. 117 + fn requester(nick: &str, trace_oper: bool) -> Registered { 118 + let mut c = client(); 119 + c.uid = uid_for(nick); 120 + c.nick = nick.to_string(); 121 + c.user = nick.to_string(); 122 + if trace_oper { 123 + c.modes |= UserMode::Oper.bit(); 124 + c.privileges = vec![OperPrivilege::Trace]; 125 + } 126 + c 127 + } 128 + 129 + fn drive(ctx: &ServerContext, who: &Registered, line: &str) -> Vec<Message> { 130 + let mut w = who.clone(); 131 + dispatch(&mut w, &Message::parse(line).unwrap(), ctx) 132 + } 133 + 134 + #[test] 135 + fn missing_channel_gives_461() { 136 + let ctx = ctx(); 137 + assert_eq!(codes(&drive(&ctx, &client(), "CHANTRACE")), &["461"]); 138 + } 139 + 140 + #[test] 141 + fn nonexistent_channel_gives_403() { 142 + let ctx = ctx(); 143 + // Even an oper-trace requester gets 403 — existence is checked before membership. 144 + let r = drive(&ctx, &requester("alice", true), "CHANTRACE #ghost"); 145 + assert_eq!(codes(&r), &["403"]); 146 + } 147 + 148 + #[test] 149 + fn non_member_non_oper_gets_442() { 150 + let ctx = ctx(); 151 + seed_member(&ctx, "bob", "#rust", 0); // bob is in #rust; alice is not 152 + let r = drive(&ctx, &requester("alice", false), "CHANTRACE #rust"); 153 + assert_eq!(codes(&r), &["442"]); 154 + } 155 + 156 + #[test] 157 + fn member_sweep_emits_708_per_member_then_262_in_folded_order() { 158 + let ctx = ctx(); 159 + seed_member(&ctx, "carol", "#rust", 0); 160 + seed_member(&ctx, "alice", "#rust", UserMode::Oper.bit()); 161 + seed_member(&ctx, "bob", "#rust", 0); 162 + // alice is a member, so she may trace without oper. 163 + let r = drive(&ctx, &requester("alice", false), "CHANTRACE #rust"); 164 + assert_eq!(codes(&r), &["708", "708", "708", "262"]); 165 + let names: Vec<&str> = r[..3].iter().map(|m| m.params()[3].as_str()).collect(); 166 + assert_eq!(names, &["alice", "bob", "carol"]); // folded-nick order 167 + } 168 + 169 + #[test] 170 + fn oper_traces_a_channel_they_are_not_in() { 171 + let ctx = ctx(); 172 + seed_member(&ctx, "bob", "#rust", 0); 173 + // alice is NOT in #rust but holds ACL_TRACE → the operspy-equivalent bypass. 174 + let r = drive(&ctx, &requester("alice", true), "CHANTRACE #rust"); 175 + assert_eq!(codes(&r), &["708", "262"]); 176 + assert_eq!(r[0].params()[3], "bob"); 177 + } 178 + 179 + #[test] 180 + fn the_708_columns_are_the_extended_etrace_format() { 181 + let ctx = ctx(); 182 + seed_member(&ctx, "bob", "#rust", UserMode::Oper.bit()); // an oper → the `Oper` label 183 + let r = drive(&ctx, &requester("alice", true), "CHANTRACE #rust"); 184 + assert_eq!(codes(&r), &["708", "262"]); 185 + let line = &r[0]; 186 + // 708 <to> <Oper|User> <class> <nick> <user> <host> <ip> - - :<realname> 187 + assert_eq!(line.params()[0], "alice"); // to (the requester) 188 + assert_eq!(line.params()[1], "Oper"); // bob is an oper 189 + assert_eq!(line.params()[3], "bob"); // nick 190 + assert_eq!(line.params()[4], "u"); // user 191 + assert_eq!(line.params()[5], "h"); // host 192 + assert_eq!(line.params()[6], "h"); // ip == host (no rDNS) 193 + assert_eq!(line.params()[7], "-"); 194 + assert_eq!(line.params()[8], "-"); 195 + assert_eq!(line.trailing(), Some("real")); 196 + } 197 + 198 + #[test] 199 + fn ordinary_member_is_labelled_user() { 200 + let ctx = ctx(); 201 + seed_member(&ctx, "bob", "#rust", 0); 202 + let r = drive(&ctx, &requester("alice", true), "CHANTRACE #rust"); 203 + assert_eq!(r[0].params()[1], "User"); 204 + } 205 + 206 + // --- inverse invariants --- 207 + 208 + #[test] 209 + fn a_part_removes_the_member_from_the_trace() { 210 + let ctx = ctx(); 211 + seed_member(&ctx, "alice", "#rust", 0); 212 + seed_member(&ctx, "bob", "#rust", 0); 213 + // bob parts → only alice remains in the trace. 214 + ctx.channels.part("#rust", &uid_for("bob")); 215 + let r = drive(&ctx, &requester("alice", false), "CHANTRACE #rust"); 216 + assert_eq!(codes(&r), &["708", "262"]); 217 + assert_eq!(r[0].params()[3], "alice"); 218 + } 219 + 220 + #[test] 221 + fn a_non_member_is_admitted_once_they_join() { 222 + let ctx = ctx(); 223 + seed_member(&ctx, "bob", "#rust", 0); 224 + // Before joining: refused. 225 + assert_eq!( 226 + codes(&drive(&ctx, &requester("alice", false), "CHANTRACE #rust")), 227 + &["442"] 228 + ); 229 + // After joining: allowed, and the trace now lists alice too. 230 + seed_member(&ctx, "alice", "#rust", 0); 231 + let r = drive(&ctx, &requester("alice", false), "CHANTRACE #rust"); 232 + assert_eq!(codes(&r), &["708", "708", "262"]); 233 + let names: Vec<&str> = r[..2].iter().map(|m| m.params()[3].as_str()).collect(); 234 + assert_eq!(names, &["alice", "bob"]); 235 + } 236 + 237 + #[test] 238 + fn a_registered_outsider_is_not_in_the_trace() { 239 + let ctx = ctx(); 240 + seed_member(&ctx, "alice", "#rust", 0); 241 + seed(&ctx, "mallory", 0); // registered but not in #rust 242 + let r = drive(&ctx, &requester("alice", false), "CHANTRACE #rust"); 243 + assert_eq!(codes(&r), &["708", "262"]); 244 + assert_eq!(r[0].params()[3], "alice"); 245 + // mallory never appears. 246 + assert!(!r.iter().any(|m| m.params().get(3).map(String::as_str) == Some("mallory"))); 247 + } 248 + 249 + #[test] 250 + fn the_262_terminator_names_server_and_version() { 251 + let ctx = ctx(); 252 + seed_member(&ctx, "alice", "#rust", 0); 253 + let end = drive(&ctx, &requester("alice", false), "CHANTRACE #rust") 254 + .last() 255 + .unwrap() 256 + .clone(); 257 + assert_eq!(end.command(), "262"); 258 + assert_eq!(end.params()[1], "leveva.test"); // <server> 259 + assert!(end.params()[2].starts_with("leveva-")); // <version> 260 + assert_eq!(end.trailing(), Some("End of TRACE")); 261 + } 262 + }
+1
leveva/src/command/help.rs
··· 100 100 "INFO", 101 101 "LUSERS", 102 102 "USERS", "SUMMON", "MOTD", "HELP", "LINKS", "MAP", "STATS", "TRACE", "ETRACE", "MASKTRACE", 103 + "CHANTRACE", 103 104 "TESTLINE", 104 105 "TKLINE", 105 106 "UNTKLINE", "DLINE", "UNDLINE", "RESV", "UNRESV", "REHASH", "RESTART", "DIE", "SQUIT",
+2
leveva/src/command/mod.rs
··· 171 171 "TRACE" => trace::trace(client, msg, ctx), 172 172 "ETRACE" => etrace::etrace(client, msg, ctx), 173 173 "MASKTRACE" => masktrace::masktrace(client, msg, ctx), 174 + "CHANTRACE" => chantrace::chantrace(client, msg, ctx), 174 175 "TESTLINE" => testline::testline(client, msg, ctx), 175 176 "TKLINE" => tkline::tkline(client, msg, ctx), 176 177 "UNTKLINE" => tkline::untkline(client, msg, ctx), ··· 587 588 mod accept; 588 589 mod admin; 589 590 pub(crate) mod away; 591 + mod chantrace; 590 592 mod chathistory; 591 593 pub(crate) mod chghost; 592 594 mod connect;
+209
leveva/tests/chantrace_proptest.rs
··· 1 + //! Property-based fuzzing of `CHANTRACE` at the [`dispatch`](leveva::command::dispatch) layer 2 + //! (P11 slice 287) — the channel-scoped sibling of `masktrace_proptest`. 3 + //! 4 + //! CHANTRACE requires a channel argument, gates on existence (403) then membership-or-trace 5 + //! (442), and reports the channel roster in folded-nick order, so the fuzzing surface is: the 6 + //! param gate (no channel → exactly `[461]`); the existence gate (a channel nobody is in → 7 + //! `[403]`); the membership gate (non-member non-oper → `[442]`); the member/oper sweep (one 8 + //! `708` per member in folded-nick order, then one `262`); and arbitrary args / nick — none of 9 + //! which may panic the handler or emit a non-trace numeric. 10 + //! 11 + //! - **`member_sweep_in_folded_order`** — every seeded person joins `#chan`; a member requester 12 + //! gets one `708` per member (folded order) then one closing `262`. 13 + //! - **`non_member_is_442_unless_oper`** — an outsider requester: without `trace` → `[442]`; 14 + //! with it (the operspy-equivalent bypass) → the full member sweep. 15 + //! - **`missing_channel_is_always_461`** — the param gate runs before everything: an empty 16 + //! `CHANTRACE` is `[461]` for member, outsider and oper alike. 17 + //! - **`arbitrary_never_panics`** — arbitrary printable args / nick / privilege never panic and 18 + //! emit only `{461, 403, 442, 708, 262}`. 19 + 20 + use std::sync::Arc; 21 + 22 + use leveva::channel::Channels; 23 + use leveva::command::{dispatch, Registered}; 24 + use leveva::config::{Admin, OperPrivilege}; 25 + use leveva::registry::Registry; 26 + use leveva::server::{Counters, ServerContext}; 27 + use leveva::{casemap, Message, Sid, Uid, UserMode}; 28 + use proptest::prelude::*; 29 + use tokio::sync::mpsc::unbounded_channel; 30 + 31 + const SERVER: &str = "leveva.test"; 32 + const CHAN: &str = "#chan"; 33 + 34 + fn ctx() -> Arc<ServerContext> { 35 + Arc::new(ServerContext { 36 + name: SERVER.to_string(), 37 + description: "T".to_string(), 38 + network: "TestNet".to_string(), 39 + version: "leveva-0.0.0", 40 + created: "FIXED".to_string(), 41 + motd: None, 42 + counters: Counters::default(), 43 + registry: Registry::new(), 44 + channels: Channels::new(), 45 + whowas: leveva::whowas::WhowasHistory::default(), 46 + uids: leveva::uid::UidGenerator::new(Sid::try_from("0ABC").unwrap()), 47 + default_user_modes: 0, 48 + default_channel_modes: 0, 49 + operators: Vec::new(), 50 + stats_conf: leveva::server::StatsConf::default(), 51 + klines: leveva::kline::KlineStore::new(), 52 + dlines: leveva::dline::DlineStore::new(), 53 + net: leveva::s2s::Network::new(), 54 + peers: leveva::s2s::PeerLinks::new(), 55 + monitors: leveva::monitor::Monitors::new(), 56 + conn_limits: leveva::connlimit::ConnLimits::new(), 57 + linking: leveva::link::LinkingSet::new(), 58 + knock_throttle: leveva::knock_throttle::KnockThrottle::new(), 59 + knock_user_throttle: leveva::knock_throttle::KnockThrottle::new(), 60 + resvs: leveva::resv::ResvStore::new(), 61 + history: leveva::history::History::disabled(), 62 + auth: leveva::server::AuthConfig::default(), 63 + admin: Admin::default(), 64 + }) 65 + } 66 + 67 + /// A distinct UID for seed index `i` (≤ 19, the strategy cap): `0ABCAAAA{A..T}`. 68 + fn uid_for(i: usize) -> Uid { 69 + Uid::try_from(format!("0ABCAAAA{}", (b'A' + i as u8) as char).as_str()).unwrap() 70 + } 71 + 72 + /// Seed `nick` (index `i`) into the registry and join it to `#chan`; return its UID. 73 + fn seed_member(ctx: &ServerContext, i: usize, nick: &str) -> Uid { 74 + let uid = uid_for(i); 75 + let (tx, _rx) = unbounded_channel(); 76 + ctx.registry 77 + .try_claim(&uid, nick, "u", "h", "r", tx) 78 + .expect("free nick"); 79 + ctx.channels.join(CHAN, &uid, 0); 80 + uid 81 + } 82 + 83 + /// Seed `nick` (index `i`) into the registry **without** joining any channel; return its UID. 84 + fn seed_outsider(ctx: &ServerContext, i: usize, nick: &str) -> Uid { 85 + let uid = uid_for(i); 86 + let (tx, _rx) = unbounded_channel(); 87 + ctx.registry 88 + .try_claim(&uid, nick, "u", "h", "r", tx) 89 + .expect("free nick"); 90 + uid 91 + } 92 + 93 + fn client(nick: &str, uid: Uid, trace_priv: bool) -> Registered { 94 + Registered { 95 + uid, 96 + nick: nick.to_string(), 97 + user: "u".into(), 98 + host: "127.0.0.1".into(), 99 + realname: "R".into(), 100 + modes: if trace_priv { UserMode::Oper.bit() } else { 0 }, 101 + privileges: if trace_priv { 102 + vec![OperPrivilege::Trace] 103 + } else { 104 + Vec::new() 105 + }, 106 + caps: Default::default(), 107 + } 108 + } 109 + 110 + fn codes(ms: &[Message]) -> Vec<&str> { 111 + ms.iter().map(|m| m.command()).collect() 112 + } 113 + 114 + /// A parse-safe nick token. The first entry is always the requester. 115 + fn members() -> impl Strategy<Value = Vec<String>> { 116 + prop::collection::vec("[a-zA-Z][a-zA-Z0-9]{0,5}", 1..8) 117 + } 118 + 119 + /// Dedup `raw` by folded nick (first wins), preserving order — registry claims are keyed by 120 + /// folded nick, so collisions would otherwise fail the seed. 121 + fn dedup(raw: &[String]) -> Vec<String> { 122 + let mut seen = std::collections::HashSet::new(); 123 + raw.iter() 124 + .filter(|n| seen.insert(casemap::fold(n))) 125 + .cloned() 126 + .collect() 127 + } 128 + 129 + proptest! { 130 + #![proptest_config(ProptestConfig::with_cases(512))] 131 + 132 + /// Every seeded person joins `#chan`; the first is the (member) requester. The sweep is one 133 + /// `708` per member in folded-nick order, then one `262`. 134 + #[test] 135 + fn member_sweep_in_folded_order(raw in members()) { 136 + let people = dedup(&raw); 137 + let ctx = ctx(); 138 + let mut req_uid = uid_for(0); 139 + for (i, nick) in people.iter().enumerate() { 140 + let uid = seed_member(&ctx, i, nick); 141 + if i == 0 { req_uid = uid; } 142 + } 143 + let me = client(&people[0], req_uid, false); // a plain member may trace 144 + let r = dispatch(&mut me.clone(), &Message::parse("CHANTRACE #chan").unwrap(), &ctx); 145 + let c = codes(&r); 146 + 147 + let mut folded: Vec<String> = people.iter().map(|n| casemap::fold(n)).collect(); 148 + folded.sort(); 149 + let mut want: Vec<&str> = vec!["708"; folded.len()]; 150 + want.push("262"); 151 + prop_assert_eq!(&c, &want); 152 + // The 708 nick column follows folded order; the 262 names the server. 153 + let got_nicks: Vec<String> = 154 + r[..r.len() - 1].iter().map(|m| casemap::fold(&m.params()[3])).collect(); 155 + prop_assert_eq!(got_nicks, folded); 156 + prop_assert_eq!(r.last().unwrap().params()[1].as_str(), SERVER); 157 + } 158 + 159 + /// An outsider requester (never joined): without `trace` → `[442]`; with it → the member 160 + /// sweep (the operspy-equivalent bypass). The channel exists (the seeded members are in it). 161 + #[test] 162 + fn non_member_is_442_unless_oper(raw in members(), trace_priv in any::<bool>()) { 163 + let people = dedup(&raw); 164 + let ctx = ctx(); 165 + for (i, nick) in people.iter().enumerate() { 166 + seed_member(&ctx, i, nick); 167 + } 168 + // The requester is a distinct outsider (index after the members), never in #chan. 169 + let out_uid = seed_outsider(&ctx, people.len(), "zoutsider"); 170 + let me = client("zoutsider", out_uid, trace_priv); 171 + let r = dispatch(&mut me.clone(), &Message::parse("CHANTRACE #chan").unwrap(), &ctx); 172 + let c = codes(&r); 173 + 174 + if !trace_priv { 175 + prop_assert_eq!(&c, &["442"]); 176 + } else { 177 + // bypass → one 708 per member (the outsider is not in #chan) then 262. 178 + let mut want: Vec<&str> = vec!["708"; people.len()]; 179 + want.push("262"); 180 + prop_assert_eq!(&c, &want); 181 + } 182 + } 183 + 184 + /// The param gate precedes everything: an empty `CHANTRACE` is `[461]` regardless of 185 + /// membership or privilege. 186 + #[test] 187 + fn missing_channel_is_always_461(nick in "[a-zA-Z][a-zA-Z0-9]{0,8}", trace_priv in any::<bool>()) { 188 + let ctx = ctx(); 189 + let uid = seed_member(&ctx, 0, &nick); 190 + let me = client(&nick, uid, trace_priv); 191 + let r = dispatch(&mut me.clone(), &Message::parse("CHANTRACE").unwrap(), &ctx); 192 + prop_assert_eq!(codes(&r), vec!["461"]); 193 + } 194 + 195 + /// Arbitrary args / nick / privilege never panic and emit only trace/gate numerics. 196 + #[test] 197 + fn arbitrary_never_panics( 198 + nick in "[a-zA-Z][a-zA-Z0-9]{0,8}", 199 + trace_priv in any::<bool>(), 200 + args in prop::collection::vec("[a-zA-Z0-9.*?_!@#/-]{0,12}", 0..4), 201 + ) { 202 + let ctx = ctx(); 203 + let uid = seed_member(&ctx, 0, &nick); // #chan exists with one member 204 + let line = format!("CHANTRACE {}", args.join(" ")); 205 + let me = client(&nick, uid, trace_priv); 206 + let r = dispatch(&mut me.clone(), &Message::parse(line.trim()).unwrap(), &ctx); 207 + prop_assert!(codes(&r).iter().all(|x| matches!(*x, "461" | "403" | "442" | "708" | "262"))); 208 + } 209 + }
+87
leveva/tests/golden_chantrace.rs
··· 1 + //! Boot-level golden: `CHANTRACE` end-to-end through the real `leveva` binary (P11 slice 287). 2 + //! 3 + //! 1. `alice` registers, opers up (`dline.kdl` operator `root` carries `trace`) and joins `#rust`. 4 + //! 2. `bob` registers and joins `#rust`. 5 + //! 3. `carol` registers but joins nothing. 6 + //! 4. `alice` `CHANTRACE #rust` → a `708` per member (alice + bob) then the `262` terminator. 7 + //! 5. `carol` (a non-member non-oper) `CHANTRACE #rust` → `442` (learns nothing). 8 + //! 6. `alice` `CHANTRACE #ghost` → `403` (a nonexistent channel, even for a trace-oper). 9 + 10 + mod harness; 11 + use harness::{boot_fixture, Client, PORT}; 12 + 13 + /// Keep the `708`/`262`/`442`/`403` reply lines from a transcript, labelled, masking the volatile 14 + /// host/ip columns of a `708` (loopback connection address) so the snapshot is stable. 15 + fn keep(out: &mut String, src: &str, label: &str) { 16 + for line in src.split("\r\n") { 17 + if line.contains(" 708 ") 18 + || line.contains(" 262 ") 19 + || line.contains(" 442 ") 20 + || line.contains(" 403 ") 21 + { 22 + out.push_str(label); 23 + out.push_str(&mask_hostip(line)); 24 + out.push('\n'); 25 + } 26 + } 27 + } 28 + 29 + /// `:srv 708 to User class nick user <host> <ip> - - :realname` → host/ip columns (indices 7,8) → `H`. 30 + fn mask_hostip(line: &str) -> String { 31 + if !line.contains(" 708 ") { 32 + return line.to_string(); 33 + } 34 + let mut words: Vec<String> = line.split(' ').map(str::to_string).collect(); 35 + if words.len() > 8 { 36 + words[7] = "H".to_string(); 37 + words[8] = "H".to_string(); 38 + } 39 + words.join(" ") 40 + } 41 + 42 + #[test] 43 + fn chantrace_reports_channel_members_and_gates_outsiders() { 44 + let _srv = boot_fixture("dline.kdl", PORT); 45 + 46 + let mut alice = Client::connect(); 47 + alice.send("NICK alice"); 48 + alice.send("USER alice 0 * :Alice Tester"); 49 + alice.read_until(" 422 "); 50 + alice.send("OPER root hunter2"); 51 + alice.read_until(" 381 "); 52 + alice.send("JOIN #rust"); 53 + alice.read_until(" 366 "); 54 + 55 + let mut bob = Client::connect(); 56 + bob.send("NICK bob"); 57 + bob.send("USER bobby 0 * :Bob Tester"); 58 + bob.read_until(" 422 "); 59 + bob.send("JOIN #rust"); 60 + bob.read_until(" 366 "); 61 + 62 + let mut carol = Client::connect(); 63 + carol.send("NICK carol"); 64 + carol.send("USER carol 0 * :Carol Tester"); 65 + carol.read_until(" 422 "); 66 + 67 + // Let alice see bob is in the channel before tracing. 68 + alice.wait_visible("bob"); 69 + 70 + alice.send("CHANTRACE #rust"); 71 + let members = alice.read_until(" 262 "); 72 + 73 + // A non-member non-oper learns nothing. 74 + carol.send("CHANTRACE #rust"); 75 + let denied = carol.read_until(" 442 "); 76 + 77 + // A nonexistent channel → 403, even for a trace-oper. 78 + alice.send("CHANTRACE #ghost"); 79 + let ghost = alice.read_until(" 403 "); 80 + 81 + let mut transcript = String::new(); 82 + keep(&mut transcript, &members, "alice: "); 83 + keep(&mut transcript, &denied, "carol: "); 84 + keep(&mut transcript, &ghost, "alice: "); 85 + 86 + insta::assert_snapshot!(transcript); 87 + }
+9
leveva/tests/snapshots/golden_chantrace__chantrace_reports_channel_members_and_gates_outsiders.snap
··· 1 + --- 2 + source: leveva/tests/golden_chantrace.rs 3 + expression: transcript 4 + --- 5 + alice: :leveva.test 708 alice Oper c alice alice H H - - :Alice Tester 6 + alice: :leveva.test 708 alice User c bob bobby H H - - :Bob Tester 7 + alice: :leveva.test 262 alice leveva.test leveva-0.2.0 :End of TRACE 8 + carol: :leveva.test 442 carol #rust :You're not on that channel 9 + alice: :leveva.test 403 alice #ghost :No such channel