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): IRCv3 draft/pre-away / accept AWAY before registration (P11 slice 75)

A client that negotiates draft/pre-away may send AWAY before registration
completes; the staged away is applied at finalize so WHOIS/WHO/PRIVMSG see it
from connect. AWAY * is stored verbatim. Without the cap, a pre-registration
AWAY is refused (451).

- cap.rs: DRAFT_PRE_AWAY const + SUPPORTED tail + ClientCaps.pre_away + mapping
- registration.rs: stage away (apply_pre_away/pre_away), truncated via the
shared command::away::truncate (AWAY_MAX); command::away made pub(crate)
- session.rs: intercept AWAY pre-CAP-END (before the pending_complete guard);
fold +a into seeded modes + mirror registry away at finalize
- tests: cap/registration/session units, golden_pre_away, pre_away_proptest;
regenerated the two golden_cap snapshots

+595 -26
+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: 74 slices** — full per‑slice detail (scope, mechanism, divergences, gate) lives in [`docs/progress-log/p11.md`](docs/progress-log/p11.md); the standing **testing plan** is at [`docs/superpowers/specs/2026-06-10-leveva-testing-plan.md`](docs/superpowers/specs/2026-06-10-leveva-testing-plan.md). Broad strokes: the parity matrix (channel lifecycle, burst/netsplit, WHO/WHOIS/WHOX, **rehash → live MOTD reload**), registration/STATS edge cases, live two‑node link integration, WebSocket transport, auto‑reop, and an ongoing **IRCv3 track** (slices 48+: `CAP`/`cap-notify`, `multi-prefix`, bot mode, the `message-tags` foundation + delivery plane, `away-notify`, `invite-notify`, `echo-message`, `server-time`, `userhost-in-names`, the `bot` message tag, `message-ids` (the `msgid` tag), `oper-tag` (the `draft/oper` tag), `message-redaction` (the `REDACT` command + `draft/message-redaction` cap), `standard-replies` (the `FAIL`/`WARN`/`NOTE` framework + `standard-replies` cap), `MONITOR` (the online/offline watch list + `730`–`734` + `MONITOR=` ISUPPORT), `chghost` (the operator self‑`CHGHOST` command + cap notify plane with the non‑cap QUIT/JOIN/MODE fallback + `ENCAP CHGHOST` S2S relay), `ip-cloaking` (the `+x` hidden-host usermode ported from elemental-ircd's `ip_cloaking.c` — deterministic FNV cloak distributed via the slice‑71 `chghost` plane, settable per‑client or network‑wide via `default-user-modes`, plus telling clients their user modes on connect), `no-implicit-names` (the cap that suppresses the implicit `353`/`366` NAMES burst after `JOIN`, gated per‑joiner; explicit `NAMES` still replies), `extended-monitor` (the cap that extends `MONITOR` to also push `AWAY`/`CHGHOST` notifications for watched nicks a client shares no channel with — a shared `monitor::notify_watchers` fan‑out gated on the matching base cap; `account-notify`/`setname` out of scope as leveva has neither)). | 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: 75 slices** — full per‑slice detail (scope, mechanism, divergences, gate) lives in [`docs/progress-log/p11.md`](docs/progress-log/p11.md); the standing **testing plan** is at [`docs/superpowers/specs/2026-06-10-leveva-testing-plan.md`](docs/superpowers/specs/2026-06-10-leveva-testing-plan.md). Broad strokes: the parity matrix (channel lifecycle, burst/netsplit, WHO/WHOIS/WHOX, **rehash → live MOTD reload**), registration/STATS edge cases, live two‑node link integration, WebSocket transport, auto‑reop, and an ongoing **IRCv3 track** (slices 48+: `CAP`/`cap-notify`, `multi-prefix`, bot mode, the `message-tags` foundation + delivery plane, `away-notify`, `invite-notify`, `echo-message`, `server-time`, `userhost-in-names`, the `bot` message tag, `message-ids` (the `msgid` tag), `oper-tag` (the `draft/oper` tag), `message-redaction` (the `REDACT` command + `draft/message-redaction` cap), `standard-replies` (the `FAIL`/`WARN`/`NOTE` framework + `standard-replies` cap), `MONITOR` (the online/offline watch list + `730`–`734` + `MONITOR=` ISUPPORT), `chghost` (the operator self‑`CHGHOST` command + cap notify plane with the non‑cap QUIT/JOIN/MODE fallback + `ENCAP CHGHOST` S2S relay), `ip-cloaking` (the `+x` hidden-host usermode ported from elemental-ircd's `ip_cloaking.c` — deterministic FNV cloak distributed via the slice‑71 `chghost` plane, settable per‑client or network‑wide via `default-user-modes`, plus telling clients their user modes on connect), `no-implicit-names` (the cap that suppresses the implicit `353`/`366` NAMES burst after `JOIN`, gated per‑joiner; explicit `NAMES` still replies), `extended-monitor` (the cap that extends `MONITOR` to also push `AWAY`/`CHGHOST` notifications for watched nicks a client shares no channel with — a shared `monitor::notify_watchers` fan‑out gated on the matching base cap; `account-notify`/`setname` out of scope as leveva has neither), `draft/pre-away` (the cap that lets a client set `AWAY` *before* registration completes — staged on the `Registration` state and applied at finalize so WHOIS/WHO/PRIVMSG see the away from connect; `AWAY *` stored verbatim)). | 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** | delete `ircd-common`/`ircd-rs` + C‑era `iauth-rs` scaffolding; workspace = `leveva` | Once `leveva` is at parity (P11 green across the matrix), run the differential suite a final time, then **delete the mechanical port** (`ircd-common`, `ircd-rs`) and fold/replace `iauth-rs` with leveva‑native auth. The `leveva-integration` oracle tests retire with their oracle; the load‑bearing characterizations carry forward as self‑contained `leveva` tests. Workspace becomes just `leveva` (+ any sub‑crates it wants). This is the literal end of the strangler — same lifecycle the C tree had at P8. | Final differential run green; `ircd-common`/`ircd-rs` deleted; `cargo test -p leveva` carries the behavioral guarantees forward; minimal `unsafe`; clean clippy. | 95 95 96 96 ---
+71
docs/progress-log/p11.md
··· 4813 4813 **Gate:** `cargo test -p leveva` green (new cap/monitor/away/chghost units + 4814 4814 `golden_extended_monitor` + `extended_monitor_proptest` + regenerated `golden_cap` snaps); 4815 4815 `cargo clippy -p leveva --tests` clean; `cargo build --workspace` 0 warnings. 4816 + 4817 + ## 2026-06-10 — P11 slice 75: IRCv3 `draft/pre-away` 4818 + 4819 + Implemented the work-in-progress IRCv3 [`draft/pre-away`](../rfcs/ircv3/pre-away.md) 4820 + capability: a client that negotiates it may send `AWAY` **before registration completes**, so 4821 + a (typically bouncer) connection can present its away status the moment it appears on the 4822 + network. The away staged pre-registration is applied at finalize, so WHOIS (`301`) / WHO (`G`) 4823 + / PRIVMSG-to-away (`301`) all see it from the start. leveva-native (classic ircd 2.11 has no 4824 + caps) → unit + golden + proptest, **no** `leveva-integration` differential. 4825 + 4826 + ### Mechanism 4827 + 4828 + - **`cap.rs`** — new `DRAFT_PRE_AWAY = "draft/pre-away"` const (kept the `draft/` prefix — the 4829 + spec is still WIP), a `SUPPORTED` tail entry (value `None`, now the declaration-order tail), a 4830 + `ClientCaps.pre_away` bool, and the `client_caps()` mapping. The advertised `CAP LS` set gains 4831 + the token at the end. 4832 + - **`registration.rs`** — `Registration` gains an `away: Option<String>` field plus 4833 + `apply_pre_away(&mut self, msg, server) -> Vec<Message>` (stage the away text — truncated to 4834 + `AWAY_MAX` via the shared `command::away::truncate`; empty/absent clears — and return the 4835 + normal `305`/`306`, addressed to the staged nick or `*` if none yet) and `pre_away(&self) -> 4836 + Option<&str>` (read at finalize). `command/away.rs`'s `AWAY_MAX` + `truncate` were made 4837 + `pub(crate)` (and `command::away` `pub(crate) mod`) so registration reuses the exact same cap. 4838 + - **`session.rs`** — 4839 + - `dispatch` intercepts `AWAY` when `self.cap.client_caps().pre_away` **before** the 4840 + `pending_complete` guard (mirroring CAP's phase-independence): the AWAY commonly arrives 4841 + after NICK/USER are gathered but before `CAP END`, so registration is suspended 4842 + (`pending_complete` set) — the guard would otherwise drop it. While the phase is 4843 + `Registering`, it routes to `reg.apply_pre_away`; a *registered* client's AWAY falls through 4844 + to the normal command dispatch. Without the cap, a pre-registration AWAY falls to 4845 + `reg.apply` → `451` (the inverse). 4846 + - `finalize_registration` reads the staged away off the still-`Registering` phase, folds `+a` 4847 + into the seeded `modes` (so the welcome-time `:nick MODE nick :+…a` line shows it) and 4848 + `ctx.registry.set_away(&uid, Some(text))` after the claim. 4849 + 4850 + ### Divergences 4851 + 4852 + - **`AWAY *`** ("not present, unspecified reason") is stored **verbatim** — leveva does not 4853 + substitute a human-readable message (the spec's "servers MAY substitute"). Post-registration 4854 + `AWAY *` already worked (the existing `away()` handler treats any non-empty text, including 4855 + `*`, as the away message) — no change there. 4856 + - Pre-registration AWAY "does not inhibit reporting of the change in nickname status, e.g. via 4857 + monitor" — leveva's `notify_online` (`730`) is unchanged and fires normally at finalize. 4858 + 4859 + ### Tests (TDD, inverse invariants) 4860 + 4861 + - **`cap.rs`** units: `draft/pre-away` advertised as the LS tail + ACK/REQ round-trip + 4862 + `client_caps()` bool + disable inverse; `supported_names()` lists it last. Updated the 4863 + full-string `CAP LS` asserts (append ` draft/pre-away`), relaxed `extended_monitor`'s tail 4864 + claim to a `contains`, and extended the exhaustive `supported_names` list. 4865 + - **`registration.rs`** units: `apply_pre_away` returns `306` + `pre_away()` is `Some` (target = 4866 + staged nick, or `*` before NICK); a bare/empty AWAY returns `305` + clears (**inverse**); 4867 + `AWAY *` stored verbatim; truncation at `AWAY_MAX`; staging never completes registration and a 4868 + fresh `Registration` has no staged away. 4869 + - **`session.rs`** units: a `draft/pre-away` client that sets `AWAY :out to lunch` before 4870 + `CAP END` registers **away** (`306` before the welcome burst, `:alice MODE alice :+iwa`, 4871 + registry away + `+a` mirrored); **inverse** — without the cap a pre-reg `AWAY` gives `451` and 4872 + the client registers not-away; a staged-then-bare-AWAY (present) registers not-away. 4873 + - **`golden_pre_away.rs`** (real binary): alice negotiates the cap, sets `AWAY :brb` before 4874 + `CAP END`, registers; bob's `WHOIS alice` shows `301 bob alice :brb`. Inverse — carol (no cap) 4875 + sending `AWAY` pre-registration gets `451` and registers not-away (a later WHOIS has no `301`). 4876 + - **`pre_away_proptest.rs`** (the fuzz target): for an arbitrary AWAY text + cap flag driven 4877 + through a real `Session` (AWAY sent *before* USER so registration is still open), registration 4878 + always completes and the registry away is `Some` **iff** `cap && non-empty text` — with the 4879 + stored text + `+a` matching; a connection that sends no pre-reg AWAY is never away. 4880 + - Regenerated the two `golden_cap` snapshots (the `CAP LS` line gains the trailing token). 4881 + 4882 + **Plan:** `docs/superpowers/plans/2026-06-10-p11-slice75-pre-away.md` 4883 + 4884 + **Gate:** `cargo test -p leveva` green (new cap/registration/session units + `golden_pre_away` 4885 + + `pre_away_proptest` + regenerated `golden_cap` snaps); `cargo clippy -p leveva --tests` clean; 4886 + `cargo build --workspace` 0 warnings.
+71
docs/superpowers/plans/2026-06-10-p11-slice75-pre-away.md
··· 1 + # P11 slice 75 — IRCv3 `draft/pre-away` 2 + 3 + ## Goal 4 + 5 + Implement the IRCv3 [`draft/pre-away`](../../rfcs/ircv3/pre-away.md) capability: a client that 6 + negotiates it may send `AWAY` **before registration completes**, so a (typically bouncer) 7 + connection can present its away status the moment it appears on the network. The away state 8 + staged pre-registration is applied at finalize, so WHOIS/WHO/PRIVMSG see it from the start. 9 + 10 + leveva-native (classic ircd 2.11 has no caps) → unit + golden + proptest, **no** 11 + `leveva-integration` differential. 12 + 13 + ## Spec points leveva honours 14 + 15 + - New CAP `draft/pre-away` (WIP → keep the `draft/` prefix). 16 + - If negotiated, the server MUST accept `AWAY` pre-registration with its normal semantics 17 + (305/306 reply; an empty/absent message clears, a non-empty message sets). 18 + - `AWAY *` means "not present, unspecified reason"; servers MAY substitute a human-readable 19 + message — leveva stores `*` **verbatim** (a documented, spec-permitted divergence: no 20 + substitution). Post-registration `AWAY *` already works (the existing `away()` handler treats 21 + any non-empty text, including `*`, as the away message — no change needed there). 22 + - Pre-registration AWAY "does not inhibit reporting of the change in nickname status, e.g. via 23 + monitor" — leveva's `notify_online` (730) is unchanged and fires normally at finalize. 24 + 25 + ## Mechanism 26 + 27 + 1. **`cap.rs`** — `DRAFT_PRE_AWAY = "draft/pre-away"` const; `SUPPORTED` tail entry (value 28 + `None`, now the declaration-order tail); `ClientCaps.pre_away` field; `client_caps()` 29 + mapping. The advertised `CAP LS` set gains the token at the end. 30 + 31 + 2. **`registration.rs`** — `Registration` gains an `away: Option<String>` field plus: 32 + - `apply_pre_away(&mut self, msg, server) -> Vec<Message>`: stage the away text (truncated to 33 + `AWAY_MAX` via the shared `command::away::truncate`; an empty/absent message clears) and 34 + return the normal `305`/`306`, addressed to the staged nick or `*` if none yet. 35 + - `pre_away(&self) -> Option<&str>`: read the staged away at finalize. 36 + 37 + 3. **`session.rs`** — 38 + - In the `Phase::Registering` dispatch arm, intercept `AWAY` when 39 + `self.cap.client_caps().pre_away` is set → route to `reg.apply_pre_away` (else it falls 40 + through to `reg.apply` → `451`, the inverse). 41 + - In `finalize_registration`, read the staged away from the still-`Registering` phase; fold 42 + `+a` into the seeded `modes` and `ctx.registry.set_away(&uid, Some(text))` after the claim, 43 + so the welcome-time MODE line shows `+a` and WHOIS/WHO/PRIVMSG see the away from the start. 44 + 45 + 4. **`command/away.rs`** — make `AWAY_MAX` + `truncate` `pub(crate)` so registration reuses them. 46 + 47 + ## Tests (TDD — write RED first; inverse invariants) 48 + 49 + - **`cap.rs`** units: `draft/pre-away` advertised as the LS tail + ACK/REQ round-trip + 50 + `client_caps()` bool + disable inverse; `supported_names()` lists it last. Update the 51 + full-string LS asserts (append ` draft/pre-away`), relax `extended_monitor`'s tail claim, and 52 + extend the exhaustive `supported_names` list. 53 + - **`registration.rs`** units: with pre-away staged, `apply_pre_away` returns `306` + `pre_away()` 54 + is `Some`; a bare/empty AWAY returns `305` + clears (**inverse**); `AWAY *` stores `*`; 55 + truncation at `AWAY_MAX`. 56 + - **`session.rs`** units: a `draft/pre-away` client that sends `AWAY :gone` then completes is 57 + registered **away** (`+a` in the welcome MODE line, registry away set); a client **without** 58 + the cap sending `AWAY` pre-registration gets `451` and registers **not** away (**inverse**). 59 + - **`golden_pre_away.rs`** (real binary): alice negotiates `draft/pre-away`, sends `AWAY :brb` 60 + before `CAP END`, registers; bob `WHOIS alice` → `301` with `brb`. A control client without 61 + the cap sending `AWAY` pre-registration gets `451`. 62 + - **`pre_away_proptest.rs`** (fuzz): for an arbitrary AWAY text and cap flag, driving a real 63 + `Session` through registration never panics; the registered client's away state is set **iff** 64 + the cap was negotiated **and** the message was non-empty; membership/registration always 65 + completes. 66 + - Regenerate the two `golden_cap` snapshots (`CAP LS` gains the trailing token). 67 + 68 + ## Gate 69 + 70 + `cargo test -p leveva` green; `cargo clippy -p leveva --tests` clean; `cargo build --workspace` 71 + 0 warnings.
+60 -17
leveva/src/cap.rs
··· 116 116 /// cap, read by [`crate::monitor::notify_watchers`]. 117 117 pub const EXTENDED_MONITOR: &str = "extended-monitor"; 118 118 119 + /// The IRCv3 [`draft/pre-away`](../docs/rfcs/ircv3/pre-away.md) capability name (still a 120 + /// work-in-progress spec → the `draft/` prefix). When negotiated, the server accepts the 121 + /// `AWAY` command **before** registration completes, letting a (typically bouncer) connection 122 + /// present its away status the moment it appears on the network. The staged away is applied at 123 + /// finalize ([`crate::session`]). leveva stores `AWAY *` ("not present, unspecified reason") 124 + /// verbatim rather than substituting a human-readable message (a spec-permitted divergence). 125 + /// The gate is the connection's own [`ClientCaps::pre_away`], read in [`crate::session`]. 126 + pub const DRAFT_PRE_AWAY: &str = "draft/pre-away"; 127 + 119 128 /// The CAP LS version at which capability **values**, **multiline** `LS`/`LIST`, and 120 129 /// implicit `cap-notify` unlock. 121 130 const CAP_302: u32 = 302; ··· 187 196 name: EXTENDED_MONITOR, 188 197 value: None, 189 198 }, 199 + Capability { 200 + name: DRAFT_PRE_AWAY, 201 + value: None, 202 + }, 190 203 ]; 191 204 192 205 /// A per-connection snapshot of the **behavior-bearing** capabilities a registered client ··· 237 250 /// this client's `MONITOR`-watched nicks that it shares no channel with (gated on the base 238 251 /// `away_notify`/`chghost` cap). Read by [`crate::monitor::notify_watchers`]. 239 252 pub extended_monitor: bool, 253 + /// IRCv3 [`draft/pre-away`](DRAFT_PRE_AWAY): this connection may send `AWAY` before 254 + /// registration completes; the staged away is applied at finalize. Read in 255 + /// [`crate::session`]. 256 + pub pre_away: bool, 240 257 } 241 258 242 259 /// The names leveva advertises (for callers — e.g. property tests — that need to model ··· 331 348 chghost: self.enabled.contains(CHGHOST), 332 349 no_implicit_names: self.enabled.contains(NO_IMPLICIT_NAMES), 333 350 extended_monitor: self.enabled.contains(EXTENDED_MONITOR), 351 + pre_away: self.enabled.contains(DRAFT_PRE_AWAY), 334 352 } 335 353 } 336 354 ··· 579 597 fn ls_without_version_lists_caps_bare() { 580 598 let mut c = CapState::new(); 581 599 let out = wire(&unreg(&mut c, "CAP LS")); 582 - assert_eq!(out, ":leveva.test CAP * LS :cap-notify multi-prefix message-tags away-notify invite-notify echo-message server-time userhost-in-names draft/oper-tag draft/message-redaction standard-replies chghost no-implicit-names extended-monitor\r\n"); 600 + assert_eq!(out, ":leveva.test CAP * LS :cap-notify multi-prefix message-tags away-notify invite-notify echo-message server-time userhost-in-names draft/oper-tag draft/message-redaction standard-replies chghost no-implicit-names extended-monitor draft/pre-away\r\n"); 583 601 // No version stored, no implicit cap-notify in the enabled set. 584 602 assert_eq!(c.version(), None); 585 603 assert!(!c.is_enabled(CAP_NOTIFY)); ··· 590 608 fn ls_302_stores_version_and_implicitly_enables_cap_notify() { 591 609 let mut c = CapState::new(); 592 610 let out = wire(&unreg(&mut c, "CAP LS 302")); 593 - assert_eq!(out, ":leveva.test CAP * LS :cap-notify multi-prefix message-tags away-notify invite-notify echo-message server-time userhost-in-names draft/oper-tag draft/message-redaction standard-replies chghost no-implicit-names extended-monitor\r\n"); 611 + assert_eq!(out, ":leveva.test CAP * LS :cap-notify multi-prefix message-tags away-notify invite-notify echo-message server-time userhost-in-names draft/oper-tag draft/message-redaction standard-replies chghost no-implicit-names extended-monitor draft/pre-away\r\n"); 594 612 assert_eq!(c.version(), Some(302)); 595 613 assert!(c.is_enabled(CAP_NOTIFY), "302 implies cap-notify"); 596 614 assert!(c.eligible_for_notify()); ··· 629 647 let mut c = CapState::new(); 630 648 // Advertised in LS (after cap-notify, declaration order). 631 649 let out = wire(&unreg(&mut c, "CAP LS")); 632 - assert_eq!(out, ":leveva.test CAP * LS :cap-notify multi-prefix message-tags away-notify invite-notify echo-message server-time userhost-in-names draft/oper-tag draft/message-redaction standard-replies chghost no-implicit-names extended-monitor\r\n"); 650 + assert_eq!(out, ":leveva.test CAP * LS :cap-notify multi-prefix message-tags away-notify invite-notify echo-message server-time userhost-in-names draft/oper-tag draft/message-redaction standard-replies chghost no-implicit-names extended-monitor draft/pre-away\r\n"); 633 651 // REQ acks it and the snapshot flips on; the inverse (-) flips it back off. 634 652 assert!(!c.client_caps().multi_prefix, "off until negotiated"); 635 653 let ack = wire(&unreg(&mut c, "CAP REQ :multi-prefix")); ··· 651 669 let out = wire(&unreg(&mut c, "CAP LS")); 652 670 assert_eq!( 653 671 out, 654 - ":leveva.test CAP * LS :cap-notify multi-prefix message-tags away-notify invite-notify echo-message server-time userhost-in-names draft/oper-tag draft/message-redaction standard-replies chghost no-implicit-names extended-monitor\r\n" 672 + ":leveva.test CAP * LS :cap-notify multi-prefix message-tags away-notify invite-notify echo-message server-time userhost-in-names draft/oper-tag draft/message-redaction standard-replies chghost no-implicit-names extended-monitor draft/pre-away\r\n" 655 673 ); 656 674 assert!(!c.client_caps().away_notify, "off until negotiated"); 657 675 let ack = wire(&unreg(&mut c, "CAP REQ :away-notify")); ··· 671 689 let out = wire(&unreg(&mut c, "CAP LS")); 672 690 assert_eq!( 673 691 out, 674 - ":leveva.test CAP * LS :cap-notify multi-prefix message-tags away-notify invite-notify echo-message server-time userhost-in-names draft/oper-tag draft/message-redaction standard-replies chghost no-implicit-names extended-monitor\r\n" 692 + ":leveva.test CAP * LS :cap-notify multi-prefix message-tags away-notify invite-notify echo-message server-time userhost-in-names draft/oper-tag draft/message-redaction standard-replies chghost no-implicit-names extended-monitor draft/pre-away\r\n" 675 693 ); 676 694 assert!(!c.client_caps().invite_notify, "off until negotiated"); 677 695 let ack = wire(&unreg(&mut c, "CAP REQ :invite-notify")); ··· 691 709 let out = wire(&unreg(&mut c, "CAP LS")); 692 710 assert_eq!( 693 711 out, 694 - ":leveva.test CAP * LS :cap-notify multi-prefix message-tags away-notify invite-notify echo-message server-time userhost-in-names draft/oper-tag draft/message-redaction standard-replies chghost no-implicit-names extended-monitor\r\n" 712 + ":leveva.test CAP * LS :cap-notify multi-prefix message-tags away-notify invite-notify echo-message server-time userhost-in-names draft/oper-tag draft/message-redaction standard-replies chghost no-implicit-names extended-monitor draft/pre-away\r\n" 695 713 ); 696 714 assert!(!c.client_caps().echo_message, "off until negotiated"); 697 715 let ack = wire(&unreg(&mut c, "CAP REQ :echo-message")); ··· 711 729 let out = wire(&unreg(&mut c, "CAP LS")); 712 730 assert_eq!( 713 731 out, 714 - ":leveva.test CAP * LS :cap-notify multi-prefix message-tags away-notify invite-notify echo-message server-time userhost-in-names draft/oper-tag draft/message-redaction standard-replies chghost no-implicit-names extended-monitor\r\n" 732 + ":leveva.test CAP * LS :cap-notify multi-prefix message-tags away-notify invite-notify echo-message server-time userhost-in-names draft/oper-tag draft/message-redaction standard-replies chghost no-implicit-names extended-monitor draft/pre-away\r\n" 715 733 ); 716 734 assert!(!c.client_caps().server_time, "off until negotiated"); 717 735 let ack = wire(&unreg(&mut c, "CAP REQ :server-time")); ··· 771 789 let out = wire(&unreg(&mut c, "CAP LS")); 772 790 assert_eq!( 773 791 out, 774 - ":leveva.test CAP * LS :cap-notify multi-prefix message-tags away-notify invite-notify echo-message server-time userhost-in-names draft/oper-tag draft/message-redaction standard-replies chghost no-implicit-names extended-monitor\r\n" 792 + ":leveva.test CAP * LS :cap-notify multi-prefix message-tags away-notify invite-notify echo-message server-time userhost-in-names draft/oper-tag draft/message-redaction standard-replies chghost no-implicit-names extended-monitor draft/pre-away\r\n" 775 793 ); 776 794 assert!(!c.client_caps().userhost_in_names, "off until negotiated"); 777 795 let ack = wire(&unreg(&mut c, "CAP REQ :userhost-in-names")); ··· 797 815 let out = wire(&unreg(&mut c, "CAP LS")); 798 816 assert_eq!( 799 817 out, 800 - ":leveva.test CAP * LS :cap-notify multi-prefix message-tags away-notify invite-notify echo-message server-time userhost-in-names draft/oper-tag draft/message-redaction standard-replies chghost no-implicit-names extended-monitor\r\n" 818 + ":leveva.test CAP * LS :cap-notify multi-prefix message-tags away-notify invite-notify echo-message server-time userhost-in-names draft/oper-tag draft/message-redaction standard-replies chghost no-implicit-names extended-monitor draft/pre-away\r\n" 801 819 ); 802 820 assert!(!c.client_caps().no_implicit_names, "off until negotiated"); 803 821 let ack = wire(&unreg(&mut c, "CAP REQ :no-implicit-names")); ··· 819 837 #[test] 820 838 fn extended_monitor_is_advertised_and_negotiable() { 821 839 let mut c = CapState::new(); 822 - // Advertised in LS as the declaration-order tail (after no-implicit-names). 840 + // Advertised in LS (after no-implicit-names; no longer the tail — draft/pre-away is). 823 841 let out = wire(&unreg(&mut c, "CAP LS")); 824 842 assert!( 825 - out.ends_with("no-implicit-names extended-monitor\r\n"), 826 - "extended-monitor is the LS tail: {out}" 843 + out.contains("no-implicit-names extended-monitor "), 844 + "extended-monitor advertised after no-implicit-names: {out}" 827 845 ); 828 - assert_eq!( 829 - *supported_names().last().unwrap(), 830 - EXTENDED_MONITOR, 831 - "supported_names() lists it last" 846 + assert!( 847 + supported_names().contains(&EXTENDED_MONITOR), 848 + "supported_names() lists extended-monitor" 832 849 ); 833 850 assert!(!c.client_caps().extended_monitor, "off until negotiated"); 834 851 let ack = wire(&unreg(&mut c, "CAP REQ :extended-monitor")); ··· 1005 1022 STANDARD_REPLIES, 1006 1023 CHGHOST, 1007 1024 NO_IMPLICIT_NAMES, 1008 - EXTENDED_MONITOR 1025 + EXTENDED_MONITOR, 1026 + DRAFT_PRE_AWAY 1009 1027 ] 1010 1028 ); 1029 + } 1030 + 1031 + #[test] 1032 + fn pre_away_is_advertised_and_negotiable() { 1033 + let mut c = CapState::new(); 1034 + // Advertised in LS as the declaration-order tail (after extended-monitor). 1035 + let out = wire(&unreg(&mut c, "CAP LS")); 1036 + assert!( 1037 + out.ends_with("extended-monitor draft/pre-away\r\n"), 1038 + "draft/pre-away is the LS tail: {out}" 1039 + ); 1040 + assert_eq!( 1041 + *supported_names().last().unwrap(), 1042 + DRAFT_PRE_AWAY, 1043 + "supported_names() lists it last" 1044 + ); 1045 + assert!(!c.client_caps().pre_away, "off until negotiated"); 1046 + let ack = wire(&unreg(&mut c, "CAP REQ :draft/pre-away")); 1047 + assert_eq!(ack, ":leveva.test CAP * ACK :draft/pre-away\r\n"); 1048 + assert!(c.is_enabled(DRAFT_PRE_AWAY)); 1049 + assert!(c.client_caps().pre_away, "snapshot reflects enable"); 1050 + // Inverse: disabling clears the snapshot. 1051 + let nak = wire(&unreg(&mut c, "CAP REQ :-draft/pre-away")); 1052 + assert_eq!(nak, ":leveva.test CAP * ACK :-draft/pre-away\r\n"); 1053 + assert!(!c.client_caps().pre_away, "disable clears the snapshot"); 1011 1054 } 1012 1055 1013 1056 #[test]
+2 -2
leveva/src/command/away.rs
··· 6 6 /// nearest char boundary ≤ this so the stored `String` stays valid UTF-8 (a documented 7 7 /// divergence — visible only for a >255-byte message containing a multibyte char astride 8 8 /// the boundary). 9 - const AWAY_MAX: usize = 255; 9 + pub(crate) const AWAY_MAX: usize = 255; 10 10 11 11 /// `AWAY [:<message>]` — set or clear this client's away status (the oracle `m_away`). 12 12 /// ··· 91 91 } 92 92 93 93 /// Truncate `s` to at most `max` bytes, backing off to the nearest char boundary. 94 - fn truncate(s: &str, max: usize) -> String { 94 + pub(crate) fn truncate(s: &str, max: usize) -> String { 95 95 if s.len() <= max { 96 96 return s.to_string(); 97 97 }
+1 -1
leveva/src/command/mod.rs
··· 412 412 } 413 413 414 414 mod admin; 415 - mod away; 415 + pub(crate) mod away; 416 416 pub(crate) mod chghost; 417 417 mod connect; 418 418 mod die;
+121
leveva/src/registration.rs
··· 18 18 /// `strncpyzt` overwrite of `cptr->passwd`). Checked at finalize against the matched 19 19 /// `allow` rule by [`connection_password_ok`]. 20 20 pass: Option<String>, 21 + /// IRCv3 `draft/pre-away`: an away message staged by an `AWAY` sent *before* registration 22 + /// completes (the cap gate is checked by the session, not here). Applied at finalize. 23 + /// `None` means present; `Some(text)` is the (already truncated) away message. 24 + away: Option<String>, 21 25 done: bool, 22 26 } 23 27 ··· 55 59 pub fn reopen_nick(&mut self) { 56 60 self.nick = None; 57 61 self.done = false; 62 + } 63 + 64 + /// The away message staged by a pre-registration `AWAY` (IRCv3 `draft/pre-away`), read at 65 + /// finalize. `None` means the connection is present. 66 + pub fn pre_away(&self) -> Option<&str> { 67 + self.away.as_deref() 68 + } 69 + 70 + /// IRCv3 `draft/pre-away`: stage (or clear) an `AWAY` sent before registration completes. 71 + /// The session routes here only when the connection negotiated `draft/pre-away`; otherwise 72 + /// `AWAY` falls through to [`apply`](Self::apply) → `451`. The away text is the trailing 73 + /// param (or a bare first middle param); empty/absent clears. Returns the normal 74 + /// `305`/`306` reply (addressed to the staged nick, or `*` if none yet). The stored message 75 + /// is truncated to [`AWAY_MAX`](crate::command::away::AWAY_MAX) so finalize can apply it 76 + /// verbatim, matching the post-registration [`away`](crate::command::away) handler. 77 + pub fn apply_pre_away(&mut self, msg: &Message, server: &str) -> Vec<Message> { 78 + let text = msg 79 + .trailing() 80 + .or_else(|| msg.params().first().map(String::as_str)) 81 + .filter(|s| !s.is_empty()); 82 + let target = self.nick.as_ref().map(Nick::as_str).unwrap_or("*"); 83 + match text { 84 + None => { 85 + self.away = None; 86 + vec![Message::builder(Numeric::RplUnaway) 87 + .prefix(server) 88 + .param(target) 89 + .trailing("You are no longer marked as being away") 90 + .build()] 91 + } 92 + Some(text) => { 93 + self.away = Some(crate::command::away::truncate(text, crate::command::away::AWAY_MAX)); 94 + vec![Message::builder(Numeric::RplNowaway) 95 + .prefix(server) 96 + .param(target) 97 + .trailing("You have been marked as being away") 98 + .build()] 99 + } 100 + } 58 101 } 59 102 60 103 /// Apply one parsed message during the pre-registration phase. `server` is this ··· 787 830 assert_eq!(c.iter().filter(|&&x| x == "372").count(), 2); 788 831 assert!(c.contains(&"376")); 789 832 assert!(!c.contains(&"422"), "MOTD present → no 422"); 833 + } 834 + 835 + // ---- IRCv3 draft/pre-away: staging AWAY before registration completes ---- 836 + 837 + fn pre_away(reg: &mut Registration, line: &str) -> Vec<Message> { 838 + reg.apply_pre_away(&Message::parse(line).unwrap(), S) 839 + } 840 + 841 + #[test] 842 + fn pre_away_with_message_gives_306_and_stages() { 843 + let mut r = Registration::new(); 844 + apply(&mut r, "NICK alice"); 845 + let reply = pre_away(&mut r, "AWAY :out to lunch"); 846 + assert_eq!(reply.len(), 1); 847 + assert_eq!(reply[0].command(), "306"); 848 + // Addressed to the staged nick (it was set first). 849 + assert_eq!(reply[0].params(), &["alice"]); 850 + assert_eq!(r.pre_away(), Some("out to lunch")); 851 + } 852 + 853 + #[test] 854 + fn pre_away_before_nick_addresses_star() { 855 + let mut r = Registration::new(); 856 + // No NICK yet → the 306 target is `*`. 857 + let reply = pre_away(&mut r, "AWAY :brb"); 858 + assert_eq!(reply[0].params(), &["*"]); 859 + assert_eq!(r.pre_away(), Some("brb")); 860 + } 861 + 862 + #[test] 863 + fn bare_pre_away_gives_305_and_clears() { 864 + let mut r = Registration::new(); 865 + pre_away(&mut r, "AWAY :gone"); 866 + assert_eq!(r.pre_away(), Some("gone")); 867 + // Inverse: a bare AWAY clears the staged message and replies 305. 868 + let reply = pre_away(&mut r, "AWAY"); 869 + assert_eq!(reply[0].command(), "305"); 870 + assert_eq!(r.pre_away(), None); 871 + } 872 + 873 + #[test] 874 + fn empty_trailing_pre_away_clears_like_a_bare_away() { 875 + let mut r = Registration::new(); 876 + pre_away(&mut r, "AWAY :here"); 877 + let reply = pre_away(&mut r, "AWAY :"); 878 + assert_eq!(reply[0].command(), "305"); 879 + assert_eq!(r.pre_away(), None); 880 + } 881 + 882 + #[test] 883 + fn pre_away_star_is_stored_verbatim() { 884 + let mut r = Registration::new(); 885 + // `AWAY *` ("not present, unspecified reason") — leveva stores `*` verbatim. 886 + let reply = pre_away(&mut r, "AWAY *"); 887 + assert_eq!(reply[0].command(), "306"); 888 + assert_eq!(r.pre_away(), Some("*")); 889 + } 890 + 891 + #[test] 892 + fn pre_away_message_truncated_to_away_max() { 893 + let mut r = Registration::new(); 894 + let long: String = "x".repeat(300); 895 + pre_away(&mut r, &format!("AWAY :{long}")); 896 + assert_eq!( 897 + r.pre_away().unwrap().len(), 898 + crate::command::away::AWAY_MAX, 899 + "pre-away text capped like the post-reg handler" 900 + ); 901 + } 902 + 903 + #[test] 904 + fn pre_away_does_not_complete_registration() { 905 + let mut r = Registration::new(); 906 + // Staging away alone never advances registration (no USER yet). 907 + pre_away(&mut r, "AWAY :gone"); 908 + assert!(!r.is_registered()); 909 + // A fresh connection has no staged away. 910 + assert_eq!(Registration::new().pre_away(), None); 790 911 } 791 912 }
+93 -3
leveva/src/session.rs
··· 337 337 } 338 338 return; 339 339 } 340 + // IRCv3 draft/pre-away: a connection that negotiated the cap may set its AWAY status 341 + // any time before registration finalizes — including after NICK/USER are gathered but 342 + // before `CAP END` (so `pending_complete` is set). Handle it here, before the 343 + // pending-complete guard, mirroring CAP's phase-independence. The staged message lives 344 + // on the still-`Registering` phase and is applied at finalize. A *registered* client's 345 + // AWAY falls through to the normal command dispatch below. 346 + if msg.command().eq_ignore_ascii_case("AWAY") && self.cap.client_caps().pre_away { 347 + if let Phase::Registering(reg) = &mut self.phase { 348 + for m in reg.apply_pre_away(msg, &ctx.name) { 349 + out.extend(m.to_wire()); 350 + } 351 + return; 352 + } 353 + } 340 354 // While registration is gathered and waiting only on the auth verdict, ignore 341 355 // further (non-QUIT) lines; finalize happens in [`Session::resolve_auth`]. The 342 356 // window is a few milliseconds, so a well-behaved client never lands here. ··· 349 363 out.extend(m.to_wire()); 350 364 } 351 365 } 352 - Phase::Registering(reg) => match reg.apply(msg, &ctx.name) { 366 + Phase::Registering(reg) => { 367 + match reg.apply(msg, &ctx.name) { 353 368 RegStep::Pending => {} 354 369 RegStep::Reply(msgs) => { 355 370 for m in msgs { ··· 388 403 self.finalize_registration(id, &verdict, ctx, out); 389 404 } 390 405 } 391 - }, 406 + } 407 + } 392 408 // A linked peer is driven by `serve_server`, not this session; if a Session 393 409 // somehow holds a Server phase, S2S lines route to the S2S dispatcher. 394 410 Phase::Server(link) => { ··· 564 580 } 565 581 return; 566 582 } 583 + // IRCv3 draft/pre-away: an away message staged by an `AWAY` sent before registration 584 + // (the phase is still `Registering` until the end of this fn). Applied below so the 585 + // welcome-time MODE line shows `+a` and WHOIS/WHO/PRIVMSG see the away from the start. 586 + let staged_away = match &self.phase { 587 + Phase::Registering(reg) => reg.pre_away().map(str::to_string), 588 + _ => None, 589 + }; 567 590 // Seed the client's default user modes (config `default-user-modes`, default 568 591 // `+iw`) into the shared record so other connections' WHO honours `+i` 569 592 // immediately. A WebSocket connection additionally carries the leveva-native 570 593 // `+W`. The client is told its modes right after the welcome burst (below). 571 - let modes = if self.websocket { 594 + let mut modes = if self.websocket { 572 595 ctx.default_user_modes | crate::UserMode::Websocket.bit() 573 596 } else { 574 597 ctx.default_user_modes 575 598 }; 599 + // Fold the pre-away `+a` into the seeded modes so the client learns it on connect. 600 + if staged_away.is_some() { 601 + modes |= crate::UserMode::Away.bit(); 602 + } 576 603 // If the seeded modes include `+x` (IP cloak), hide the host *now* — before the 577 604 // welcome burst, the MONITOR notify, and the network introduction — so the client 578 605 // (and the rest of the network) sees the cloaked host from the start. This is the ··· 586 613 } 587 614 } 588 615 ctx.registry.set_modes(&uid, modes); 616 + // IRCv3 draft/pre-away: mirror the staged away message into the shared record so 617 + // WHOIS (301), WHO (G), and PRIVMSG-to-away (301) see it from the start. 618 + if let Some(text) = &staged_away { 619 + ctx.registry.set_away(&uid, Some(text.clone())); 620 + } 589 621 // Mirror negotiated caps into the shared record so the delivery plane can gate 590 622 // client-only tags / TAGMSG to this client per `message-tags`. 591 623 ctx.registry.set_caps(&uid, self.cap.client_caps()); ··· 1614 1646 alice.release(&ctx); // second call is a no-op 1615 1647 assert!(bob_rx.try_recv().is_err(), "no second QUIT relayed"); 1616 1648 assert_eq!(ctx.counters.snapshot().users, 1); 1649 + } 1650 + 1651 + // ---- IRCv3 draft/pre-away ---- 1652 + 1653 + /// A client that negotiates `draft/pre-away`, sets AWAY before `CAP END`, and completes 1654 + /// registers **away**: `+a` in the welcome MODE line and the away message in the registry. 1655 + #[test] 1656 + fn pre_away_client_registers_already_away() { 1657 + let ctx = ctx(); 1658 + let mut s = Session::new("127.0.0.1"); 1659 + let out = text(&s.feed( 1660 + b"CAP LS 302\r\nCAP REQ :draft/pre-away\r\nNICK alice\r\nUSER alice 0 * :A\r\n\ 1661 + AWAY :out to lunch\r\nCAP END\r\n", 1662 + &ctx, 1663 + )); 1664 + assert!(s.is_registered(), "registration completes: {out}"); 1665 + // The pre-away 306 was sent before the welcome burst. 1666 + assert!(out.contains(" 306 "), "306 NOWAWAY before welcome: {out}"); 1667 + // The connect MODE notice carries the away bit `a` alongside the seeded `+iw` defaults. 1668 + assert!( 1669 + out.contains(":alice MODE alice :+iwa\r\n"), 1670 + "welcome MODE shows +a: {out}" 1671 + ); 1672 + // The registry mirror carries the staged away + the +a bit. 1673 + let rec = ctx.registry.get("alice").expect("record"); 1674 + assert_eq!(rec.away.as_deref(), Some("out to lunch")); 1675 + assert_ne!(rec.modes & crate::UserMode::Away.bit(), 0, "+a mirrored"); 1676 + } 1677 + 1678 + /// Inverse: without the cap, `AWAY` before registration is refused (`451`) and the client 1679 + /// registers **not** away. 1680 + #[test] 1681 + fn away_before_registration_without_the_cap_gives_451() { 1682 + let ctx = ctx(); 1683 + let mut s = Session::new("127.0.0.1"); 1684 + let out = text(&s.feed(b"NICK alice\r\nAWAY :gone\r\n", &ctx)); 1685 + assert!(out.contains(" 451 "), "no cap → 451 for pre-reg AWAY: {out}"); 1686 + // Finish registering; the client is not away. 1687 + s.feed(b"USER alice 0 * :A\r\n", &ctx); 1688 + assert!(s.is_registered()); 1689 + assert_eq!(ctx.registry.get("alice").expect("record").away, None); 1690 + } 1691 + 1692 + /// A bare `AWAY` (present) staged pre-registration leaves the client **not** away even with 1693 + /// the cap — the staged state is "present", so no `+a` and no registry away. 1694 + #[test] 1695 + fn pre_away_present_registers_not_away() { 1696 + let ctx = ctx(); 1697 + let mut s = Session::new("127.0.0.1"); 1698 + let out = text(&s.feed( 1699 + b"CAP LS 302\r\nCAP REQ :draft/pre-away\r\nNICK alice\r\nUSER alice 0 * :A\r\n\ 1700 + AWAY :gone\r\nAWAY\r\nCAP END\r\n", 1701 + &ctx, 1702 + )); 1703 + assert!(s.is_registered(), "{out}"); 1704 + let rec = ctx.registry.get("alice").expect("record"); 1705 + assert_eq!(rec.away, None, "the trailing bare AWAY cleared the staged away"); 1706 + assert_eq!(rec.modes & crate::UserMode::Away.bit(), 0, "no +a"); 1617 1707 } 1618 1708 }
+76
leveva/tests/golden_pre_away.rs
··· 1 + //! Boot-level golden: IRCv3 **`draft/pre-away`** end-to-end through the real `leveva` binary. 2 + //! 3 + //! `alice` negotiates `draft/pre-away` (`CAP LS 302` → `CAP REQ :draft/pre-away`), sets 4 + //! `AWAY :brb` **before** `CAP END`, then completes registration. `bob` (a plain client) 5 + //! `WHOIS alice` and sees `301 RPL_AWAY` with `brb` — proving the away status was applied at 6 + //! finalize from a pre-registration `AWAY`. 7 + //! 8 + //! The inverse: `carol` (no cap) sends `AWAY` before registration and gets `451` 9 + //! (ERR_NOTREGISTERED) — the command is refused without the cap. 10 + //! 11 + //! leveva-native (classic ircd 2.11 has no caps) → no `ircd-common` differential. 12 + 13 + mod harness; 14 + use harness::{boot, Client}; 15 + 16 + #[test] 17 + fn pre_away_status_is_visible_after_registration() { 18 + let _srv = boot(); 19 + 20 + // alice negotiates draft/pre-away and sets AWAY before CAP END. 21 + let mut alice = Client::connect(); 22 + alice.send("CAP LS 302"); 23 + alice.read_until(" LS :"); 24 + alice.send("CAP REQ :draft/pre-away"); 25 + alice.read_until(" ACK "); 26 + alice.send("NICK alice"); 27 + alice.send("USER alice 0 * :Alice Tester"); 28 + alice.send("AWAY :brb"); 29 + // The pre-registration AWAY is answered with 306 RPL_NOWAWAY. 30 + let pre = alice.read_until(" 306 "); 31 + assert!(pre.contains(" 306 "), "pre-reg AWAY → 306: {pre:?}"); 32 + alice.send("CAP END"); 33 + alice.read_until(" 422 "); 34 + 35 + // bob: plain client, WHOIS alice → 301 with the away message. 36 + let mut bob = Client::connect(); 37 + bob.send("NICK bob"); 38 + bob.send("USER bob 0 * :Bob Tester"); 39 + bob.read_until(" 422 "); 40 + bob.send("WHOIS alice"); 41 + let whois = bob.read_until(" 318 "); 42 + assert!( 43 + whois.contains(" 301 bob alice :brb"), 44 + "WHOIS shows the pre-away message: {whois:?}" 45 + ); 46 + } 47 + 48 + #[test] 49 + fn away_before_registration_without_the_cap_is_refused() { 50 + let _srv = boot(); 51 + 52 + // carol never negotiates draft/pre-away → a pre-registration AWAY is 451. 53 + let mut carol = Client::connect(); 54 + carol.send("NICK carol"); 55 + carol.send("AWAY :gone"); 56 + let refused = carol.read_until(" 451 "); 57 + assert!( 58 + refused.contains(" 451 "), 59 + "no cap → 451 for pre-reg AWAY: {refused:?}" 60 + ); 61 + 62 + // She then registers normally and is *not* away: a WHOIS from another client has no 301. 63 + carol.send("USER carol 0 * :Carol Tester"); 64 + carol.read_until(" 422 "); 65 + 66 + let mut dave = Client::connect(); 67 + dave.send("NICK dave"); 68 + dave.send("USER dave 0 * :Dave Tester"); 69 + dave.read_until(" 422 "); 70 + dave.send("WHOIS carol"); 71 + let whois = dave.read_until(" 318 "); 72 + assert!( 73 + !whois.contains(" 301 "), 74 + "carol registered not-away → no 301: {whois:?}" 75 + ); 76 + }
+95
leveva/tests/pre_away_proptest.rs
··· 1 + //! Property-based fuzzing of the IRCv3 **`draft/pre-away`** capability (slice 75). 2 + //! 3 + //! The target is registration driven end-to-end through a real [`Session`]: a connection 4 + //! that negotiates `draft/pre-away` and sends `AWAY <text>` before `CAP END` registers 5 + //! **away** iff the text was non-empty; a connection *without* the cap is refused (`451`) and 6 + //! registers **not** away (the inverse). Across an arbitrary away text and cap flag, the 7 + //! registration path never panics and always completes, and the resulting registry away state 8 + //! matches `cap && non-empty-text`. 9 + 10 + use std::sync::Arc; 11 + 12 + use leveva::config::Config; 13 + use leveva::server::ServerContext; 14 + use leveva::session::Session; 15 + use proptest::prelude::*; 16 + 17 + fn ctx() -> Arc<ServerContext> { 18 + let cfg = Config::from_kdl( 19 + r#" 20 + server { name "leveva.test"; description "T"; sid "0ABC" } 21 + admin { name "A"; email "a@test"; network "TestNet" } 22 + class "c" { ping-freq 90; max-links 10; sendq 1000 } 23 + "#, 24 + ) 25 + .unwrap(); 26 + ServerContext::from_config(&cfg, "FIXED".to_string(), None) 27 + } 28 + 29 + /// Drive a fresh session for `nick` through registration, optionally negotiating 30 + /// `draft/pre-away` and optionally sending `AWAY <text>` before `CAP END`. Returns the 31 + /// registered session. 32 + fn register(ctx: &Arc<ServerContext>, nick: &str, cap: bool, away: Option<&str>) -> Session { 33 + let mut sess = Session::new("127.0.0.1"); 34 + let _ = sess.take_mailbox(); 35 + let mut line = String::new(); 36 + if cap { 37 + line.push_str("CAP LS 302\r\nCAP REQ :draft/pre-away\r\n"); 38 + } 39 + // AWAY arrives *before* USER so registration is still open when it lands — otherwise a 40 + // no-cap NICK+USER finalizes immediately and the AWAY becomes an ordinary post-reg one. 41 + line.push_str(&format!("NICK {nick}\r\n")); 42 + if let Some(text) = away { 43 + line.push_str(&format!("AWAY :{text}\r\n")); 44 + } 45 + line.push_str("USER u 0 * :r\r\n"); 46 + if cap { 47 + line.push_str("CAP END\r\n"); 48 + } 49 + let _ = sess.feed(line.as_bytes(), ctx); 50 + sess 51 + } 52 + 53 + proptest! { 54 + #![proptest_config(ProptestConfig::with_cases(256))] 55 + 56 + /// The registered client's away state is `Some` iff the cap was negotiated AND the AWAY 57 + /// text was non-empty; registration always completes regardless. 58 + #[test] 59 + fn away_set_iff_cap_and_nonempty( 60 + cap in any::<bool>(), 61 + // Printable, non-`:`-leading text without IRC framing/control bytes. 62 + text in "[a-zA-Z0-9 ._!?-]{0,40}", 63 + ) { 64 + let ctx = ctx(); 65 + let nick = "joiner"; 66 + let sess = register(&ctx, nick, cap, Some(&text)); 67 + prop_assert!(sess.is_registered(), "registration always completes"); 68 + 69 + let rec = ctx.registry.get(nick).expect("registered record"); 70 + let expect_away = cap && !text.is_empty(); 71 + prop_assert_eq!( 72 + rec.away.is_some(), 73 + expect_away, 74 + "away set iff cap && non-empty: cap={}, text={:?}, away={:?}", 75 + cap, text, rec.away 76 + ); 77 + // When away, the stored text matches and the `+a` bit is mirrored. 78 + if expect_away { 79 + prop_assert_eq!(rec.away.as_deref(), Some(text.as_str())); 80 + prop_assert_ne!(rec.modes & leveva::UserMode::Away.bit(), 0, "+a mirrored"); 81 + } else { 82 + prop_assert_eq!(rec.modes & leveva::UserMode::Away.bit(), 0, "no +a"); 83 + } 84 + } 85 + 86 + /// A connection that sends no pre-registration AWAY registers not-away, cap or not — the 87 + /// cap alone changes nothing. 88 + #[test] 89 + fn no_away_means_not_away(cap in any::<bool>()) { 90 + let ctx = ctx(); 91 + let sess = register(&ctx, "joiner", cap, None); 92 + prop_assert!(sess.is_registered()); 93 + prop_assert_eq!(ctx.registry.get("joiner").expect("record").away, None); 94 + } 95 + }
+2 -1
leveva/tests/snapshots/golden_cap__cap_302_negotiation_suspends_then_resumes.snap
··· 1 1 --- 2 2 source: leveva/tests/golden_cap.rs 3 + assertion_line: 41 3 4 expression: canonicalize(&transcript) 4 5 --- 5 - :leveva.test CAP * LS :cap-notify multi-prefix message-tags away-notify invite-notify echo-message server-time userhost-in-names draft/oper-tag draft/message-redaction standard-replies chghost no-implicit-names extended-monitor 6 + :leveva.test CAP * LS :cap-notify multi-prefix message-tags away-notify invite-notify echo-message server-time userhost-in-names draft/oper-tag draft/message-redaction standard-replies chghost no-implicit-names extended-monitor draft/pre-away 6 7 :leveva.test CAP * ACK :cap-notify 7 8 :leveva.test 001 dan :Welcome to the Internet Relay Network dan!d@127.0.0.1 8 9 :leveva.test 002 dan :Your host is leveva.test, running version leveva-0.0.0
+2 -1
leveva/tests/snapshots/golden_cap__cap_ls_without_version_then_end.snap
··· 1 1 --- 2 2 source: leveva/tests/golden_cap.rs 3 + assertion_line: 61 3 4 expression: "canonicalize(&format!(\"{ls}{welcome}\"))" 4 5 --- 5 - :leveva.test CAP * LS :cap-notify multi-prefix message-tags away-notify invite-notify echo-message server-time userhost-in-names draft/oper-tag draft/message-redaction standard-replies chghost no-implicit-names extended-monitor 6 + :leveva.test CAP * LS :cap-notify multi-prefix message-tags away-notify invite-notify echo-message server-time userhost-in-names draft/oper-tag draft/message-redaction standard-replies chghost no-implicit-names extended-monitor draft/pre-away 6 7 :leveva.test 001 jo :Welcome to the Internet Relay Network jo!j@127.0.0.1 7 8 :leveva.test 002 jo :Your host is leveva.test, running version leveva-0.0.0 8 9 :leveva.test 003 jo :This server was created <MASKED>