diff --git a/PLAN.md b/PLAN.md index 1a189d70..79787eb0 100644 --- a/PLAN.md +++ b/PLAN.md @@ -90,7 +90,7 @@ The leverage point: every `foo.c` already ships `foo_ext.h` (its public `extern` | **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.** | | **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. | | **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). | -| **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: 174 slices.** Most recent: 174 = TS6 loser-side list-mask wipe incl. `+R` reop (closing slice-171's documented divergence — on a `CHANTS` merge **loss** the younger side now drops **all** its list masks, `+b`/`+e`/`+I` **and** the `+R` reop list, and adopts the winner's, instead of unioning; the reop list is **never** exempt from the wipe — hard project rule; since the loser's own reop is gone, the slice-170 post-merge re-op is owned by the **winner's** `+R`, adopted from the post-`CHANTS` burst MODE lines and fired at `EOB` via a new `PeerLink.lost_merges` set + `chants::sweep_lost_merge_reop`; `Channels::wipe_list_masks` clears all four lists, the combined `-` reset is echoed to local clients + relayed to peers; CHANTS-first ordering is the contract, burst-only; proptest-fuzzed for wipe totality + no-loser-residue-after-adopt); 173 = msgid from unix-nanosecond time (the `msgid` tag's numeric half is now the unix-nanosecond wall clock at issue time in hex, not a process-global monotonic counter that reset to `0` on every boot — directly addressing the IRCv3 message-ids spec's warning against counters "being reset if the server restarts"; the id shape `-` is unchanged for clients; a `LAST_NANOS` high-water mark forces the per-process stream strictly increasing — `max(now, last+1)` — so a coarse/repeating/backward clock can never mint a duplicate; proptest-fuzzed for strict-monotonicity + clock-tracking over arbitrary `now` sequences); 172 = extban add-time validity rejection (the slice-164 follow-on — a chanop adding a malformed/unknown extban `$…` to `+b`/`+e`/`+I` now gets `:server NOTICE :Invalid extban mask: ` and the mask is dropped, instead of storing an inert never-matching ban; gated on the management threshold so a non-privileged setter still hits the `482` op-gate first, mirroring charybdis precedence; validity routes through the existing `extban::valid_extban` via a new `Channels::validate_extban` that builds the live `LiveChannels` view so `$c`/`$j` resolve referenced channels; removals + `+R` reop masks + plain `n!u@h` globs are never validated); 171 = CHANTS-first burst + TS6 winning-side gating (the per-channel burst now leads with the `ENCAP * CHANTS` timestamp, before `NJOIN`/`MODE` — TS6 sends `channelTS` first — so the receiver knows the merge verdict up front; a `PeerLink.won_merges` set records channels where our TS is older, and the following `NJOIN` strips the incoming member statuses + `MODE` ignores the incoming modes, fixing the winner-side leak where the loser's flags/ops were absorbed into `union(ours, theirs)`; grounded in Elemental-IRCd's TS6 docs); 170 = reop fires after a TS-merge deop (a channel-TS merge on a relink deopped the losing side and the ops stayed lost until a manual `-R+R * *` re-fired reop; now `enforce_reop` runs after the `CHANTS`/live-`NJOIN` reconcile demotions to re-op `+R`-matching local members in burst order, and the slice-168 inbound-`+R` reop is gated to live so the burst's reop is owned solely by `CHANTS`, avoiding deop→reop→deop thrash); 169 = the `draft/metadata-2` notification plane (a local `METADATA … SET`/`CLEAR` now pushes `: METADATA * [:]` to every local subscriber that can see the change — co-members of a user target / members of a channel target — gated on subscription + the `draft/metadata-2` cap, actor excluded, value omitted on removal; the behavior half slice 163 deferred); 168 = inbound S2S `+R` reops local members (a reop mask set on another server was stored but never re-checked the receiving server's own members — so under a `+R *` "everyone is op" mask, users resident on a server that *learned* `+R` over the link stayed un-opped while the origin server's locals were opped; `enforce_reop` now fires on the inbound `+R` add, `None`-actor, mirroring the local set-path); 167 = carry the channel creation-TS on the **live** `NJOIN` (`: NJOIN :`) and arbitrate it as members are added — fixes a production desync where a relinked/recreated channel deopped the wrong side (and, across flaps, *every* side: `#leveva` seen at `+nt`/zero-ops, `-o Xe`): each server used to re-clock a newly-learned channel locally, so the two disagreed on TS by propagation latency and every burst deopped whichever learned of it later; 166 = fire the reop invariant on `CHGHOST` (a host change that moves a member into a channel's `+R` reop set now auto-ops on the spot, not just on JOIN; additive-only, local-path-only); 165 = InspIRCd-style OJOIN (`+Y` official-join membership mode, sigil `!`, rank 128; oper `OJOIN ` grants `+Yo`, un-kickable, self-removable-only); 164 = charybdis-style extended bans (`$a/$o/$z/$r/$x/$s/$c/$j/$m`, ported from Elemental-IRCd, JOIN-gate-enforced); 163 = IRCv3 `draft/metadata-2`; 162 = S2S identity + channel-TS convergence fixes; 161 = SQUIT propagation; 160 = `netjoin` batch; 159 = SASL; 158 = elemental channel modes. The slices span the full client command surface (registration, channels + channel/user modes, WHO/WHOIS/WHOX, OPER/STATS), the UID-based S2S link/burst/netsplit protocol (+ keepalive, live introduction, NJOIN chunking), `leveva-iauth` (dnsbl/socks/webproxy/pipe/ident), config live-rehash, a channel-mode rework, CertFP, an ongoing **IRCv3 track**, and the native cross-daemon command-skeleton differential harness. **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. | +| **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: 175 slices.** Most recent: 175 = `REHASH` live-reloads `options { default-channel-modes }` (the last `options`-field rehash deferral, carried since slice 91 — a channel created *after* the rehash is stamped with the edited default via the slice-91 live-or-`ctx` `ConfStore` idiom; existing channels keep their creation-time modes; fuzzed in lockstep + boot-golden); 174 = TS6 loser-side list-mask wipe incl. `+R` reop (closing slice-171's documented divergence — on a `CHANTS` merge **loss** the younger side now drops **all** its list masks, `+b`/`+e`/`+I` **and** the `+R` reop list, and adopts the winner's, instead of unioning; the reop list is **never** exempt from the wipe — hard project rule; since the loser's own reop is gone, the slice-170 post-merge re-op is owned by the **winner's** `+R`, adopted from the post-`CHANTS` burst MODE lines and fired at `EOB` via a new `PeerLink.lost_merges` set + `chants::sweep_lost_merge_reop`; `Channels::wipe_list_masks` clears all four lists, the combined `-` reset is echoed to local clients + relayed to peers; CHANTS-first ordering is the contract, burst-only; proptest-fuzzed for wipe totality + no-loser-residue-after-adopt); 173 = msgid from unix-nanosecond time (the `msgid` tag's numeric half is now the unix-nanosecond wall clock at issue time in hex, not a process-global monotonic counter that reset to `0` on every boot — directly addressing the IRCv3 message-ids spec's warning against counters "being reset if the server restarts"; the id shape `-` is unchanged for clients; a `LAST_NANOS` high-water mark forces the per-process stream strictly increasing — `max(now, last+1)` — so a coarse/repeating/backward clock can never mint a duplicate; proptest-fuzzed for strict-monotonicity + clock-tracking over arbitrary `now` sequences); 172 = extban add-time validity rejection (the slice-164 follow-on — a chanop adding a malformed/unknown extban `$…` to `+b`/`+e`/`+I` now gets `:server NOTICE :Invalid extban mask: ` and the mask is dropped, instead of storing an inert never-matching ban; gated on the management threshold so a non-privileged setter still hits the `482` op-gate first, mirroring charybdis precedence; validity routes through the existing `extban::valid_extban` via a new `Channels::validate_extban` that builds the live `LiveChannels` view so `$c`/`$j` resolve referenced channels; removals + `+R` reop masks + plain `n!u@h` globs are never validated); 171 = CHANTS-first burst + TS6 winning-side gating (the per-channel burst now leads with the `ENCAP * CHANTS` timestamp, before `NJOIN`/`MODE` — TS6 sends `channelTS` first — so the receiver knows the merge verdict up front; a `PeerLink.won_merges` set records channels where our TS is older, and the following `NJOIN` strips the incoming member statuses + `MODE` ignores the incoming modes, fixing the winner-side leak where the loser's flags/ops were absorbed into `union(ours, theirs)`; grounded in Elemental-IRCd's TS6 docs); 170 = reop fires after a TS-merge deop (a channel-TS merge on a relink deopped the losing side and the ops stayed lost until a manual `-R+R * *` re-fired reop; now `enforce_reop` runs after the `CHANTS`/live-`NJOIN` reconcile demotions to re-op `+R`-matching local members in burst order, and the slice-168 inbound-`+R` reop is gated to live so the burst's reop is owned solely by `CHANTS`, avoiding deop→reop→deop thrash); 169 = the `draft/metadata-2` notification plane (a local `METADATA … SET`/`CLEAR` now pushes `: METADATA * [:]` to every local subscriber that can see the change — co-members of a user target / members of a channel target — gated on subscription + the `draft/metadata-2` cap, actor excluded, value omitted on removal; the behavior half slice 163 deferred); 168 = inbound S2S `+R` reops local members (a reop mask set on another server was stored but never re-checked the receiving server's own members — so under a `+R *` "everyone is op" mask, users resident on a server that *learned* `+R` over the link stayed un-opped while the origin server's locals were opped; `enforce_reop` now fires on the inbound `+R` add, `None`-actor, mirroring the local set-path); 167 = carry the channel creation-TS on the **live** `NJOIN` (`: NJOIN :`) and arbitrate it as members are added — fixes a production desync where a relinked/recreated channel deopped the wrong side (and, across flaps, *every* side: `#leveva` seen at `+nt`/zero-ops, `-o Xe`): each server used to re-clock a newly-learned channel locally, so the two disagreed on TS by propagation latency and every burst deopped whichever learned of it later; 166 = fire the reop invariant on `CHGHOST` (a host change that moves a member into a channel's `+R` reop set now auto-ops on the spot, not just on JOIN; additive-only, local-path-only); 165 = InspIRCd-style OJOIN (`+Y` official-join membership mode, sigil `!`, rank 128; oper `OJOIN ` grants `+Yo`, un-kickable, self-removable-only); 164 = charybdis-style extended bans (`$a/$o/$z/$r/$x/$s/$c/$j/$m`, ported from Elemental-IRCd, JOIN-gate-enforced); 163 = IRCv3 `draft/metadata-2`; 162 = S2S identity + channel-TS convergence fixes; 161 = SQUIT propagation; 160 = `netjoin` batch; 159 = SASL; 158 = elemental channel modes. The slices span the full client command surface (registration, channels + channel/user modes, WHO/WHOIS/WHOX, OPER/STATS), the UID-based S2S link/burst/netsplit protocol (+ keepalive, live introduction, NJOIN chunking), `leveva-iauth` (dnsbl/socks/webproxy/pipe/ident), config live-rehash, a channel-mode rework, CertFP, an ongoing **IRCv3 track**, and the native cross-daemon command-skeleton differential harness. **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. | | **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`.** | --- diff --git a/docs/progress-log/p11.md b/docs/progress-log/p11.md index c953c82a..4c06701d 100644 --- a/docs/progress-log/p11.md +++ b/docs/progress-log/p11.md @@ -9556,3 +9556,59 @@ deferred or excluded (overrules slice 171's deliberate "keep `+R` unioned"). Rec **Gate:** `cargo test -p leveva` green (channel/chants/eob units + the new proptest + golden_s2s_reop/ channel_ts/njoin_live/netsplit/netjoin_batch/netsplit_batch); `cargo clippy -p leveva --tests` clean; `cargo build --workspace` 0 warnings. + +## 2026-06-15 — P11 slice 175: `REHASH` live-reloads `options { default-channel-modes }` + +Closes the last `options`-field rehash deferral named in `control::rehash_config`'s doc comment +(*"…`default-channel-modes` which the live `Channels` table holds — each a future slice"*, carried +since slice 91). After an operator edits `options { default-channel-modes }` on disk and `REHASH`es, +a **newly**-created local channel is stamped with the edited flag set — no restart. Existing channels +keep the modes they were created with (the default applies only at local creation; the oracle's +`rehash()` doesn't retroactively re-mode live channels either). + +### Why it was deferred +Unlike the slice-91 `default-user-modes` (read fresh at registration finalize), the channel default +has **two** runtime read sites: `channel.rs::Channels::join` (stamps the new channel's internal +`ChannelModes.flags` via `self.default_modes`, set once by `Channels::with_default_modes`) and +`command/join.rs` (the authoritative `: MODE +nt` relayed to linked peers so they don't +hold the channel modeless). Both must agree and both were boot-immutable. + +### Mechanism — the slice-91 "live-or-ctx" idiom (no atomics, no ctx push) +Threaded the value through the existing `ConfStore`; both read sites prefer the live value, falling +back to their boot value when the global was never seeded (unit tests never seed it → stable). A +`REHASH` re-seeds the store via the existing `rehash_config()` path, so it "just works" — **no change +to `rehash.rs`'s control flow**, only its doc comment. +- `control.rs`: `ConfBlocks` += `default_channel_modes: u32`; `config_conf` extracts + `cfg.options.default_channel_modes`; `ConfStore` += `default_channel_modes: ArcSwapOption` + (`new`/`seed`/getter); `live_default_channel_modes() -> Option` global reader; doc moved + `default-channel-modes` from the deferred list to the reloaded set. +- `channel.rs::join`: `default_flags = control::live_default_channel_modes().unwrap_or(self.default_modes)`. +- `command/join.rs`: `let dcm = control::live_default_channel_modes().unwrap_or(ctx.default_channel_modes)` + used for both the `!= 0` gate and the per-mode filter (so the peer `MODE` relay matches what + `Channels::join` actually stamped). +- `main.rs`: seeds `default_channel_modes` in the boot `ConfBlocks`. +- `ServerContext.default_channel_modes` **stays** (the fallback) → the ~40 `ServerContext` test + literals carrying `default_channel_modes: 0` are untouched. + +### Divergences +- A `REHASH` re-stamps only **new** channels — existing channels keep their creation-time modes + (faithful: the default is a creation-time stamp, not a live channel property). +- leveva-native (no oracle `default-channel-modes` reload to diff) → no differential. + +### Tests (RED first; inverse invariants) +- `control.rs` units (fresh `ConfStore`, never the global): `config_conf` extracts + `default-channel-modes "+m"`; seed/getter round-trip; reseed replaces `+nt`→`+m`; empty/default + seed clears to `Some(0)`; fresh store `None`. +- `tests/rehash_conf_proptest.rs` (**fuzz**): added `expected_dcmodes(names)` (parity flip + `+nt`/`+m`) rendered into `options`; the lockstep model now asserts + `store.default_channel_modes()` equals the last successful reload (and is `None` until first seed / + unchanged on a failed reload). +- `tests/golden_rehash_channel_modes.rs` (**boot-golden**, real binary, live global path): boot + `+nt` → fresh `#a` is `+nt`; rewrite `+m` + `REHASH` → fresh `#b` is `+m` **and** the pre-existing + `#a` still reads `+nt` (no retroactive re-mode); inverse rewrite back `+nt` + `REHASH` → fresh `#c` + reverts live. + +**Plan:** [`docs/superpowers/plans/2026-06-15-p11-slice175-rehash-default-channel-modes.md`](../superpowers/plans/2026-06-15-p11-slice175-rehash-default-channel-modes.md) + +**Gate:** `cargo test -p leveva` green (control units + rehash_conf_proptest + golden_rehash_channel_modes); +`cargo clippy -p leveva --tests` clean; `cargo build --workspace` 0 warnings. diff --git a/docs/superpowers/plans/2026-06-15-p11-slice175-rehash-default-channel-modes.md b/docs/superpowers/plans/2026-06-15-p11-slice175-rehash-default-channel-modes.md new file mode 100644 index 00000000..ea75099e --- /dev/null +++ b/docs/superpowers/plans/2026-06-15-p11-slice175-rehash-default-channel-modes.md @@ -0,0 +1,65 @@ +# P11 slice 175 — `REHASH` live-reloads `options { default-channel-modes }` + +## Goal + +Close the last `options`-field rehash deferral named in `control::rehash_config`'s doc comment +(*"the other `options` fields … `default-channel-modes` which the live `Channels` table holds — +each a future slice"*, carried since slice 91). After an operator edits +`options { default-channel-modes }` on disk and `REHASH`es, a **newly**-created local channel is +stamped with the edited flag set — with no restart. Existing channels keep the modes they already +carry (you cannot retroactively re-mode a live channel — the oracle's `rehash()` doesn't either). + +## Why it was deferred + +Unlike the slice-91 `default-user-modes` (read fresh at registration finalize), the channel default +lives in **two** runtime read sites: + +1. `channel.rs::Channels::join` — stamps the new channel's internal `ChannelModes.flags` on local + creation (`self.default_modes`, set once via `Channels::with_default_modes`). +2. `command/join.rs` (~196) — builds the authoritative `: MODE +nt` relayed to linked + peers so they don't hold the channel modeless (reads `ctx.default_channel_modes`). + +Both must agree and both were boot-immutable. + +## Approach — the slice-91 "live-or-ctx" idiom (no atomics, no ctx push) + +Thread the value through the existing `ConfStore`; both read sites prefer the live value, falling +back to their boot value when the global was never seeded (unit tests). A `REHASH` re-seeds the +store via the existing `rehash_config()` path, so it "just works" — **no change to `rehash.rs`'s +control flow**, only its doc comment. + +### Changes + +- **`control.rs`** + - `ConfBlocks` += `pub default_channel_modes: u32`. + - `config_conf` extracts `cfg.options.default_channel_modes`. + - `ConfStore` += `default_channel_modes: ArcSwapOption` (in `new`/`seed`/getter). + - `live_default_channel_modes() -> Option` global reader. + - `rehash_config` doc: move `default-channel-modes` from the deferred list to the reloaded set. +- **`channel.rs`** — `join`: `default_flags = control::live_default_channel_modes().unwrap_or(self.default_modes)`. Field doc notes the live override. +- **`command/join.rs`** — compute `let dcm = control::live_default_channel_modes().unwrap_or(ctx.default_channel_modes);` once, use for both the `!= 0` gate and the filter. +- **`main.rs`** — seed `default_channel_modes: config.options.default_channel_modes` in the boot `ConfBlocks` (+ comment). +- **`command/rehash.rs`** — doc comment only. + +`ServerContext.default_channel_modes` **stays** (the fallback) — so the ~40 `ServerContext` +test literals carrying `default_channel_modes: 0` are untouched. + +## Tests (RED first) + +- **`control.rs` units** (fresh `ConfStore`, never the global — no inter-test races): + - `config_conf` extracts `default_channel_modes` (extend `SAMPLE_CONF2` with a non-default value). + - seed/getter round-trip; reseed replaces; empty/default-seed clears to `Some(0)`; fresh store `None`. +- **`tests/rehash_conf_proptest.rs`** (fuzzing) — derive `expected_dcmodes(names)` (parity flip), + render into `options`, assert `store.default_channel_modes()` tracks the last successful reload in + lockstep (and stays `None` until first seed / unchanged on a failed reload). +- **`tests/golden_rehash_channel_modes.rs`** (boot-golden, real binary, live global path): + 1. boot with `default-channel-modes "+nt"`; JOIN `#a`; `MODE #a` → `324 … +nt`. + 2. rewrite to `default-channel-modes "+mns"`; `REHASH` → `382`; JOIN a **new** `#b`; `MODE #b` + → `324` carries the new set; **and** the already-existing `#a` still reads `+nt` (no retroactive + re-mode). + 3. **inverse** — rewrite back to `+nt`, `REHASH`, a new `#c` reverts live (proves swap not add). + +## Gate + +`cargo test -p leveva` green (control units + the two test files) · `cargo clippy -p leveva --tests` +clean · `cargo build --workspace` 0 warnings. leveva-native (no oracle) → no differential. diff --git a/leveva/src/channel.rs b/leveva/src/channel.rs index 7efce9c2..596eb01d 100644 --- a/leveva/src/channel.rs +++ b/leveva/src/channel.rs @@ -639,7 +639,10 @@ pub struct Channels { /// The flag-mode bitmask stamped on a channel at **local** creation (the config /// `default-channel-modes`, default `+nt`). Zero for [`Channels::new`] — server- /// introduced channels ([`Channels::njoin_member`]) never read this; only a local - /// first-`JOIN` ([`Channels::join`]) does. + /// first-`JOIN` ([`Channels::join`]) does. This is the boot value; when the live + /// [`crate::control`] store is seeded (the running server) `join` prefers + /// [`crate::control::live_default_channel_modes`] so a `REHASH` edit takes effect for new + /// channels (slice 175). default_modes: u32, } @@ -671,8 +674,11 @@ impl Channels { let created = !g.contains_key(&key); // A freshly, *locally* created channel is stamped with the configured default // channel modes (`default-channel-modes`, default `+nt`). An existing channel - // keeps the modes it already carries. - let default_flags = self.default_modes; + // keeps the modes it already carries. The live store (re-seeded by `REHASH`, slice + // 175) wins when seeded — the running server reflects an edited default for *new* + // channels; unit tests never seed the global, so they fall back to `self.default_modes`. + let default_flags = + crate::control::live_default_channel_modes().unwrap_or(self.default_modes); let chan = g.entry(key).or_insert_with(|| Channel { name: display_name.to_string(), members: BTreeMap::new(), diff --git a/leveva/src/command/join.rs b/leveva/src/command/join.rs index 80f14e57..ab72e9ce 100644 --- a/leveva/src/command/join.rs +++ b/leveva/src/command/join.rs @@ -192,11 +192,14 @@ fn join_one( // creator is op and learns the modes via `MODE #chan` (324) — we send no client // MODE line. But a linked peer must be told authoritatively, else it would hold // the channel modeless (its inbound-JOIN handler does not impose local defaults). - // Relay `: MODE +nt`; a no-op when no peer is linked. - if joined.created && ctx.default_channel_modes != 0 { + // Relay `: MODE +nt`; a no-op when no peer is linked. The live store + // (re-seeded by `REHASH`, slice 175) wins when seeded, so this matches whatever + // `Channels::join` actually stamped; unit tests fall back to `ctx.default_channel_modes`. + let dcm = crate::control::live_default_channel_modes().unwrap_or(ctx.default_channel_modes); + if joined.created && dcm != 0 { let changes: Vec = ChanMode::ALL .iter() - .filter(|m| ctx.default_channel_modes & m.bit() != 0) + .filter(|m| dcm & m.bit() != 0) .map(|&mode| ModeChange::Flag { add: true, mode }) .collect(); crate::s2s::relay::server_channel_mode(ctx, &joined.display, &changes); diff --git a/leveva/src/command/rehash.rs b/leveva/src/command/rehash.rs index 724f2300..dfe67468 100644 --- a/leveva/src/command/rehash.rs +++ b/leveva/src/command/rehash.rs @@ -17,7 +17,9 @@ use crate::command::*; /// MOTD file falls back to `422`. It also **re-binds the `listen{}` set live** via /// [`crate::listeners::rehash`] (slice 137) — a newly-added port starts accepting, a removed /// one is closed, and an unchanged one keeps its established connections (the oracle's -/// `rehash()` → `open_listeners()`). +/// `rehash()` → `open_listeners()`). The config re-read also re-seeds the live +/// `options { default-channel-modes }` (slice 175), so a channel created *after* the `REHASH` +/// is stamped with the edited default (existing channels keep their modes). /// /// **Documented divergences:** no `SCH_NOTICE` server notice (leveva has no server-notice /// masks yet); beyond the MOTD (and the TLS certs, which hot-reload via their own watcher), diff --git a/leveva/src/control.rs b/leveva/src/control.rs index 7ec45fb4..45d9bfe8 100644 --- a/leveva/src/control.rs +++ b/leveva/src/control.rs @@ -128,7 +128,8 @@ pub fn config_path() -> Option<&'static Path> { /// Re-read the booted config file and hot-apply its hot-reloadable blocks — `operator` + /// `class` + `allow` (slices 81/83), plus the `admin` + `connect` (link) blocks and the -/// `options { default-user-modes }` field (slice 91) — to the live [`ConfStore`] (the work +/// `options { default-user-modes }` (slice 91) + `options { default-channel-modes }` (slice 175) +/// fields — to the live [`ConfStore`] (the work /// `REHASH` does beyond replying `382`). Returns the parse error as a string on failure; /// `Ok(())` when the on-disk config still parses, in which case the live blocks are swapped to /// the re-read set. @@ -138,9 +139,8 @@ pub fn config_path() -> Option<&'static Path> { /// `rehash()` likewise returns regardless). Still deferred to the immutable /// [`crate::server::ServerContext`]: `listeners` (need live socket-rebind infra), the /// auto-connect dial loop (keeps the boot link set — needs live-task management), and the -/// other `options` fields (`auto-connect`/`split-min-*`/`connection-accept`, -/// `default-channel-modes` which the live `Channels` table holds) — each a future slice. Only -/// the TLS certs hot-reload via their own watcher, and the MOTD via [`reload_motd`]. +/// other `options` fields (`auto-connect`/`split-min-*`/`connection-accept`) — each a future +/// slice. Only the TLS certs hot-reload via their own watcher, and the MOTD via [`reload_motd`]. pub fn rehash_config() -> Result<(), String> { let path = config_path().ok_or_else(|| "no config path recorded".to_string())?; let src = std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?; @@ -151,7 +151,7 @@ pub fn rehash_config() -> Result<(), String> { /// The hot-reloadable config blocks `REHASH` re-reads. Slices 81/83 carried /// `operators`/`classes`/`allows`; slice 91 adds the `admin`, `connect` (links), and the -/// reloadable `options` field `default_user_modes`. +/// reloadable `options` field `default_user_modes`; slice 175 adds `default_channel_modes`. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ConfBlocks { /// The `operator` blocks (slice 81). @@ -167,6 +167,9 @@ pub struct ConfBlocks { /// The `options { default-user-modes }` bitmask (slice 91) — applied to each client at /// registration finalize. pub default_user_modes: u32, + /// The `options { default-channel-modes }` flag bitmask (slice 175) — stamped on a + /// locally-created channel at first `JOIN`. Held live so `REHASH` re-stamps *new* channels. + pub default_channel_modes: u32, } /// Parse `src` and extract just the hot-reloadable [`ConfBlocks`] (the pure core @@ -181,6 +184,7 @@ pub fn config_conf(src: &str) -> Result { admin: cfg.admin, links: cfg.links, default_user_modes: cfg.options.default_user_modes, + default_channel_modes: cfg.options.default_channel_modes, }) } @@ -205,6 +209,7 @@ pub struct ConfStore { admin: ArcSwapOption, links: ArcSwapOption>, default_user_modes: ArcSwapOption, + default_channel_modes: ArcSwapOption, initialized: AtomicBool, } @@ -218,6 +223,7 @@ impl ConfStore { admin: ArcSwapOption::const_empty(), links: ArcSwapOption::const_empty(), default_user_modes: ArcSwapOption::const_empty(), + default_channel_modes: ArcSwapOption::const_empty(), initialized: AtomicBool::new(false), } } @@ -234,6 +240,8 @@ impl ConfStore { self.links.store(Some(Arc::new(b.links))); self.default_user_modes .store(Some(Arc::new(b.default_user_modes))); + self.default_channel_modes + .store(Some(Arc::new(b.default_channel_modes))); self.initialized.store(true, Ordering::SeqCst); } @@ -273,6 +281,13 @@ impl ConfStore { pub fn default_user_modes(&self) -> Option { self.default_user_modes.load_full().map(|a| *a) } + + /// The live `options { default-channel-modes }` flag bitmask (`None` ⇒ never seeded ⇒ the + /// channel-creation read sites fall back to `ctx.default_channel_modes` / the `Channels` + /// table's own boot value). + pub fn default_channel_modes(&self) -> Option { + self.default_channel_modes.load_full().map(|a| *a) + } } /// The process-global live config. @@ -322,6 +337,15 @@ pub fn live_default_user_modes() -> Option { LIVE_CONF.default_user_modes() } +/// The live `options { default-channel-modes }` flag bitmask, or `None` when the store was never +/// seeded (unit tests). The channel-creation read sites — [`crate::channel::Channels::join`]'s +/// internal stamp and `command::join`'s authoritative peer `MODE` relay — prefer this when `Some` +/// and fall back to their boot value otherwise (slice 175). A `REHASH` re-seeds it, so a newly +/// created channel picks up the edited default with no restart. +pub fn live_default_channel_modes() -> Option { + LIVE_CONF.default_channel_modes() +} + /// `basename` of the booted config path, for the `382 RPL_REHASHING` reply. Falls back /// to a literal when no path was recorded (unit tests). pub fn config_basename() -> String { @@ -573,7 +597,7 @@ mod tests { allow { host "*@10.0.0.*"; class "c" } connect "peer.test" { host "192.0.2.1"; port 6667; class "c" } operator "root" { mask "*@*"; password "pw"; class "c" } - options { default-user-modes "+i" } + options { default-user-modes "+i"; default-channel-modes "+m" } "#; #[test] @@ -587,6 +611,8 @@ mod tests { assert!(s.admin().is_none()); assert!(s.links().is_none()); assert!(s.default_user_modes().is_none()); + // slice 175 — likewise unseeded → the channel-creation read sites fall back. + assert!(s.default_channel_modes().is_none()); } #[test] @@ -614,6 +640,12 @@ mod tests { crate::UserMode::Invisible.bit(), "`default-user-modes \"+i\"` → just `+i`, not the `+iw` default" ); + // slice 175 — `default-channel-modes "+m"` → just `+m`, not the `+nt` default. + assert_eq!( + b.default_channel_modes, + crate::mode::ChanMode::Moderated.bit(), + "`default-channel-modes \"+m\"` → just `+m`, not the `+nt` default" + ); } #[test] @@ -645,6 +677,11 @@ mod tests { s.default_user_modes().unwrap(), crate::UserMode::Invisible.bit() ); + // slice 175 — `default-channel-modes "+m"` reads back from the fresh store. + assert_eq!( + s.default_channel_modes().unwrap(), + crate::mode::ChanMode::Moderated.bit() + ); } #[test] @@ -672,6 +709,13 @@ mod tests { crate::UserMode::Invisible.bit(), "the `+iw` of SAMPLE_CONF (default) must be gone, replaced by `+i`" ); + // slice 175 — the `+nt` of SAMPLE_CONF (default-channel-modes absent) must be gone, + // replaced by SAMPLE_CONF2's explicit `+m`. + assert_eq!( + s.default_channel_modes().unwrap(), + crate::mode::ChanMode::Moderated.bit(), + "the `+nt` default must be replaced by `+m`" + ); } #[test] @@ -688,6 +732,7 @@ mod tests { admin: Admin::default(), links: Vec::new(), default_user_modes: 0, + default_channel_modes: 0, }); assert!(s.initialized()); assert!( @@ -706,6 +751,8 @@ mod tests { "the old connect blocks must be gone" ); assert_eq!(s.default_user_modes().unwrap(), 0); + // slice 175 — the channel default clears too (reads `Some(0)`, not the stale `+m`). + assert_eq!(s.default_channel_modes().unwrap(), 0); } #[test] diff --git a/leveva/src/main.rs b/leveva/src/main.rs index 4814f6dc..64de64c8 100644 --- a/leveva/src/main.rs +++ b/leveva/src/main.rs @@ -145,8 +145,9 @@ async fn main() -> ExitCode { // Seed the live hot-reloadable config blocks so `REHASH` can apply them (slices 81/83/91): // `operator`/`class`/`allow` (the `OPER` / `STATS o` / `STATS y` / `STATS i` + registration // I-line gate read them) plus `admin` (the `ADMIN` command), `connect` (the `STATS c` + - // `CONNECT` target lookup), and `options { default-user-modes }` (each client's registration - // finalize). All read the live store, falling back to the boot `ServerContext`. + // `CONNECT` target lookup), `options { default-user-modes }` (each client's registration + // finalize), and `options { default-channel-modes }` (stamped on a locally-created channel, + // slice 175). All read the live store, falling back to the boot `ServerContext`. control::seed_live_conf(control::ConfBlocks { operators: config.operators.clone(), classes: config.classes.clone(), @@ -154,6 +155,7 @@ async fn main() -> ExitCode { admin: config.admin.clone(), links: config.links.clone(), default_user_modes: config.options.default_user_modes, + default_channel_modes: config.options.default_channel_modes, }); let ctx = ServerContext::from_config(&config, created, motd); diff --git a/leveva/tests/golden_rehash_channel_modes.rs b/leveva/tests/golden_rehash_channel_modes.rs new file mode 100644 index 00000000..8023dc8e --- /dev/null +++ b/leveva/tests/golden_rehash_channel_modes.rs @@ -0,0 +1,114 @@ +//! Boot-level golden: **`REHASH` live-reloads `options { default-channel-modes }`** end-to-end +//! through the real `leveva` binary (slice 175). +//! +//! Extends the rehash matrix (slices 65/81/83/91/137): an operator edits the default channel +//! modes on disk, `REHASH`es, and a channel created **after** the rehash is stamped with the +//! edited set — with no restart. A channel that already existed keeps the modes it was created +//! with (the default is applied only at local creation, the oracle's `rehash()` doesn't +//! retroactively re-mode live channels). +//! +//! 1. boot config A (`default-channel-modes "+nt"`) → a fresh `#a` is `+nt`. +//! 2. rewrite to config B (`default-channel-modes "+m"`); `REHASH` → `382`; a fresh `#b` is `+m`, +//! **and** the pre-existing `#a` still reads `+nt` (no retroactive re-mode). +//! 3. **inverse** — rewrite back to config A; `REHASH`; a fresh `#c` reverts to `+nt` live +//! (proving a reload swaps the default rather than only adding to it). +//! +//! Fixed text against a fixed server name ⇒ deterministic numerics, no canonicalization needed. + +mod harness; +use harness::{boot_with_config_file, Client, PORT}; + +/// A config bound to the harness port whose `default-channel-modes` is the reload-observable +/// value. Carries a rehash-privileged `operator "root"`. +fn config(default_channel_modes: &str) -> String { + format!( + r#" +server {{ name "leveva.test"; description "Leveva Test Server"; sid "0ABC" }} +admin {{ name "Admin"; email "admin@test"; location "Test Lab"; network "TestNet" }} +class "c" {{ ping-freq 90; max-links 10; sendq 1000 }} +listen {{ port {PORT} }} +allow {{ host "*@*"; class "c" }} +operator "root" {{ mask "*@*"; password "hunter2"; class "c"; privileges "rehash" }} +options {{ default-channel-modes "{default_channel_modes}" }} +"# + ) +} + +/// Register a fresh client under `nick`, returning it once the welcome burst has drained. +/// The minimal config has no MOTD, so the burst ends at `422` (never `376`). +fn register(nick: &str) -> Client { + let mut c = Client::connect(); + c.send(&format!("NICK {nick}")); + c.send(&format!("USER {nick} 0 * :{nick} Tester")); + c.read_until(" 422 "); + c +} + +/// Create `chan` (the creator is op) and return the `324`-reported mode token (e.g. `+nt`). +fn created_modes(c: &mut Client, chan: &str) -> String { + c.send(&format!("JOIN {chan}")); + c.read_until(" 366 "); + c.send(&format!("MODE {chan}")); + let modeis = c.read_until(" 324 "); + modeis + .lines() + .find(|l| l.contains(" 324 ") && l.contains(chan)) + .and_then(|l| l.split_whitespace().last()) + .unwrap_or("") + .to_string() +} + +#[test] +fn rehash_reloads_default_channel_modes() { + let path = + std::env::temp_dir().join(format!("leveva-rehash-chanmodes-{}.kdl", std::process::id())); + + let _srv = boot_with_config_file(&path, &config("+nt")); + + let mut root = register("root"); + root.send("OPER root hunter2"); + assert!( + root.read_until(" 381 ").contains(" 381 "), + "root should oper" + ); + + // --- config A: a locally-created channel defaults to +nt --------------------------------- + assert_eq!( + created_modes(&mut root, "#a"), + "+nt", + "config A default channel modes" + ); + + // --- rewrite to config B + REHASH: a NEW channel picks up +m live ------------------------ + std::fs::write(&path, config("+m")).unwrap(); + root.send("REHASH"); + root.read_until(" 382 "); + + assert_eq!( + created_modes(&mut root, "#b"), + "+m", + "a channel created after REHASH gets the config-B default (+m)" + ); + + // inverse — the channel that already existed keeps the modes it was created with. + root.send("MODE #a"); + let still = root.read_until(" 324 "); + assert!( + still.lines().any(|l| l.contains(" 324 #a +nt") + || (l.contains(" 324 ") && l.contains("#a") && l.ends_with("+nt"))), + "the pre-existing #a must keep its +nt — REHASH does not retroactively re-mode, got:\n{still}" + ); + + // --- inverse: rewrite back to config A + REHASH proves the reload reverts, not just adds - + std::fs::write(&path, config("+nt")).unwrap(); + root.send("REHASH"); + root.read_until(" 382 "); + + assert_eq!( + created_modes(&mut root, "#c"), + "+nt", + "default channel modes revert to config A (+nt) live" + ); + + let _ = std::fs::remove_file(&path); +} diff --git a/leveva/tests/rehash_conf_proptest.rs b/leveva/tests/rehash_conf_proptest.rs index a47e6608..104d86ef 100644 --- a/leveva/tests/rehash_conf_proptest.rs +++ b/leveva/tests/rehash_conf_proptest.rs @@ -1,5 +1,6 @@ -//! Property-based fuzzing of the slice-81/83/91 live config reload (`REHASH` → re-read the -//! `operator` + `class` + `allow` + `admin` + `connect` + `options` blocks). +//! Property-based fuzzing of the slice-81/83/91/175 live config reload (`REHASH` → re-read the +//! `operator` + `class` + `allow` + `admin` + `connect` + `options` blocks, the latter carrying +//! both `default-user-modes` and `default-channel-modes`). //! //! Two surfaces: //! @@ -15,6 +16,7 @@ //! values live), and `initialized()` tracks whether any seed has happened. use leveva::control::{config_conf, ConfStore}; +use leveva::mode::ChanMode; use leveva::UserMode; use proptest::prelude::*; @@ -34,6 +36,16 @@ fn expected_dumodes(names: &[String]) -> u32 { } } +/// The `default-channel-modes` derived from a reload's operator names (slice 175) — flips with +/// the count parity so a reload of a different length is observable: even → `+nt`, odd → `+m`. +fn expected_dcmodes(names: &[String]) -> u32 { + if names.len().is_multiple_of(2) { + ChanMode::NoExternalMessages.bit() | ChanMode::TopicOpsOnly.bit() + } else { + ChanMode::Moderated.bit() + } +} + /// Render a parseable config whose `operator` blocks (and one matching `allow` I-line + /// `connect` block each) are named `names` (one shared `class`). The allow host (`@*`), /// connect name (`.peer`), admin name, and `default-user-modes` are all derived from @@ -44,12 +56,17 @@ fn render_config(names: &[String]) -> String { } else { "+iw" }; + let cmodes = if expected_dcmodes(names) == ChanMode::Moderated.bit() { + "+m" + } else { + "+nt" + }; let mut s = format!( r#" server {{ name "leveva.test"; description "T"; sid "0ABC" }} admin {{ name "{}"; email "a@test"; network "TestNet" }} class "c" {{ ping-freq 90; max-links 10; sendq 1000 }} -options {{ default-user-modes "{modes}" }} +options {{ default-user-modes "{modes}"; default-channel-modes "{cmodes}" }} "#, expected_admin(names), ); @@ -133,6 +150,8 @@ proptest! { prop_assert!(store.admin().is_none()); prop_assert!(store.links().is_none()); prop_assert!(store.default_user_modes().is_none()); + // slice 175 — the channel default is likewise unseeded. + prop_assert!(store.default_channel_modes().is_none()); } Some(expected) => { let live_ops: Vec = store @@ -170,6 +189,11 @@ proptest! { store.default_user_modes().expect("seeded"), expected_dumodes(expected) ); + // slice 175 — the channel default tracks the last successful reload too. + prop_assert_eq!( + store.default_channel_modes().expect("seeded"), + expected_dcmodes(expected) + ); } } }