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

Configure Feed

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

feat(leveva): persist K-lines + reservations to the database{} SQLite file — P11 slice 282

Xe Iaso (Jun 19, 2026, 7:45 PM EDT) b321ee14 0044f95f

+859 -42
+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: 281 slices** (281 = S2S `KLINE`/`UNKLINE` propagation — the K-line analogue of slice 280: a `TKLINE`/`UNTKLINE` (leveva's temporary K-line family, there is no separate permanent `KLINE`) now propagates network-wide as `ENCAP * KLINE <duration> <user> <host> :<reason>`/`ENCAP * UNKLINE <user> <host>` (server-prefixed, leveva-native, the **remaining** seconds on the wire so each server re-clocks `expires = now + duration` against its own clock — no absolute-timestamp skew), wired into `command/tkline.rs` after the param/privilege/format gates; every server applies the ban to its own `KlineStore` (gating future registrations) **and reaps its own local matching clients** via the now-`is_local_uid`-guarded `reap_matching` helper (which also fixed a latent over-reap of remote users in the local `TKLINE`); `kline-exempt` clients are spared; `UNKLINE` lifts the gate only (never un-kills) and propagates unconditionally; re-asserted to a freshly-linked peer by `s2s/burst.rs::kline_burst` (active bans only, with remaining time; expired bursts nothing); new `s2s/kline.rs` modelled on `s2s/resv.rs`, ENCAP dispatch in `s2s/forward.rs`; 280 = S2S `RESV`/`UNRESV` propagation — an operator-laid nick/channel reservation (and its lift) now propagates network-wide as `ENCAP * RESV`/`UNRESV` (server-prefixed, kind re-derived from the mask prefix on inbound apply), wired into `command/resv.rs` after the param/privilege gates (a `461`/`481` reject propagates nothing; `UNRESV` propagates unconditionally), applied + split-horizon-relayed by `s2s/forward.rs`, and re-asserted to a freshly-linked peer via `s2s/burst.rs::resv_burst` (runtime `conf == false` reservations only — config `resv {}` blocks stay per-server local policy); closes the slice-272/273 S2S-propagation follow-on, mirroring the METADATA/KNOCK ENCAP carriers; 279 = remote-originated JOIN/PART/QUIT membership events are now recorded into chat-history — the membership-event parallel to slice 276 (which did PRIVMSG/NOTICE): a **live** `NJOIN` (`s2s/njoin.rs`, never a burst — bursts are bulk state-sync), a bare `JOIN`/`PART` (`s2s/relay.rs`), and a **discrete** remote `QUIT`/`KILL` (`s2s/squit.rs::quit_one_remote_user`, *not* a netsplit mass teardown) each record an event keyed by a locally-minted msgid + clock, gated on `has_local_member` (only a local member can `CHATHISTORY` it; a pure hub stores nothing); an unmirrored `NJOIN`-only ghost has no mask so its QUIT is skipped; replay/query side unchanged (slice 274 already gates these behind `draft/event-playback`); 278 = `+R` auto-reop is now enforced for **local** members only — the `enforce_reop` chokepoint gained an explicit `is_local_uid` filter, so a `+R` mask matching a `UNICK`-introduced remote user's `nick!user@host` no longer wrongly ops it locally and relays a `+o` we have no authority to emit (the locality guarantee previously rested on the false premise that remote members lack a registry record); 277 = netsplit batches are no longer duplicated to `batch`-capable clients under concurrent teardown — when a dying peer is detected on two paths at once (abrupt socket drop + a sibling's inbound `SQUIT`, in separate tasks), the `NJOIN`-only ghost-member sweep raced (`co_members` then `remove_everywhere`, no single-shot gate) and double-relayed a casualty's `QUIT`; new atomic `Channels::detach_member` fuses recipient-gather + removal in one critical section so only the first teardown to reach a member relays it (the loser gets `None`), mirroring the already-atomic `remove_behind_sid` mirror drain; new `s2s_netsplit_concurrency_proptest` fuzz; 276 = remote-originated channel messages are now recorded into chat-history — the S2S relay's inbound channel fan (`s2s::relay::inbound_message`) records the delivered line on this server too, keyed by the carried network msgid (slice 130) + `@time` (slice 134), gated on this server having a local member of the channel (only a local member can `CHATHISTORY` it; no whole-network accumulation on a hub); the `+z` op-redirect/non-channel branches never record, mirroring the local plane; closes the slice-274 gap where `CHATHISTORY` held only locally-sent lines; 275 = IRCv3 `message-redaction` now deletes from chat-history — an accepted channel `REDACT <chan> <msgid>` deletes the slice-274 stored line (`History::delete_by_msgid`, keyed `(target, msgid)`) so `CHATHISTORY` can never replay it; wired in both the local `CanSend::Ok` path and inbound `ENCAP * REDACT`, per-server; mirrors recording — a `+z` op-moderated/"shown to ops" attempt is never recorded so never deleted; authorization stays a stateless relay; 274 = IRCv3 `draft/chathistory` — SQLite-backed (`rusqlite`, bundled) channel message history behind an optional `ircd.kdl` `database {}` block; all channels auto-enrolled (PRIVMSG/NOTICE + JOIN/PART/QUIT recorded at the delivery plane), joining clients auto-replayed `replay-lines` recent lines unless they negotiated `draft/chathistory` (then exempt, per spec), full `CHATHISTORY LATEST/BEFORE/AFTER/BETWEEN/AROUND/TARGETS` (timestamp=/msgid= selectors) framed as a `chathistory` batch, `draft/chathistory`+`draft/event-playback` dynamic caps + `CHATHISTORY`/`MSGREFTYPES` ISUPPORT, age-based `retention-days` prune ticker; `Message` gained serde; 273 = registration-time nick `RESV` enforcement — a connection may no longer *register* directly as a reserved nick, only the post-registration `NICK`/JOIN were gated before; refuse `432` + reopen registration, mirroring the `433` nick-in-use path; 272 = charybdis `RESV`/`UNRESV` nick/channel reservation — JOIN→`437`/NICK→`432` gates, opers exempt, new `OperPrivilege::Resv`; defaults declared in `resv {}` config blocks, boot-seeded + REHASH-re-seeded keeping runtime `RESV`s). The slices span the full client command surface (registration; channels + the complete channel-mode surface; WHO/WHOIS/WHOX; OPER/STATS; and the query/util commands incl. OPERWALL/LOCOPS/USERIP); the UID-based S2S link/burst/netsplit protocol (keepalive, live introduction, NJOIN chunking, TS merge arbitration); `leveva-iauth` (dnsbl/socks/webproxy/pipe/ident); config live-rehash across every block; CertFP; the **IRCv3 cap track** (message-tags/server-time/msgid/account/bot/oper/echo/labeled-response/CAP, `draft/metadata-2`, caller-id `+g`/`+G`, the **STS/TLS** sub-track); elemental channel modes + extbans; KNOCK; command-rate flood protection (fakelag); the **complete charybdis channel-mode set** (`+C/+c/+S/+j/+z/+T/+g/+r/+f/+Q/+F/+P/+L`, with `+L` the last standard one) and **user-mode set** (`+D/+R/+Q/+G/+z/+S/+l`, plus the product decision that leveva has no local-only umodes — `SEND_UMODES` is every umode); and the `+s` server-notice **snomask** (a charybdis category mask with a producer wired for every category — the matrix is complete). Most const limits (`max-penalty`/`max-bans`/`service-string`/`default-snomask`/…) have been promoted to REHASH-able `options { … }` knobs. **Full per-slice detail (scope, mechanism, divergences, gate) for every slice lives in [`docs/progress-log/p11.md`](docs/progress-log/p11.md); the standing testing plan is [`docs/superpowers/specs/2026-06-10-leveva-testing-plan.md`](docs/superpowers/specs/2026-06-10-leveva-testing-plan.md). This row stays thin.** | Per‑slice: leveva boots and serves the slice; leveva golden snapshot + proptest green; the `leveva-integration` differential agrees with `ircd-common` on the shared skeleton with divergences asserted. Across the eventual matrix: registration, channels, WHO/WHOIS/WHOX, OPER/STATS, server burst, netsplit, rehash, restart; iauth end‑to‑end. | 93 + | **P11 — `leveva` protocol layer → feature parity 🔶 IN PROGRESS** ([`docs/progress-log/p11.md`](docs/progress-log/p11.md)) | `leveva` (client/server state machine + handlers) | The bulk of the remaining work: the connection + registration state machine, the post‑registration command handlers (JOIN/PRIVMSG/MODE/KICK/TOPIC/WHO/WHOIS/…), channels + channel/user modes, the S2S link/burst protocol (UID‑based: UNICK/NJOIN/SAVE/EOB), and `leveva-iauth` (native async auth — dnsbl/socks/webproxy/pipe/ident). Each slice is built idiomatically (owned per‑connection state, typed identifiers end‑to‑end, `Result`/`thiserror`, `format!`/typed builders — **no** `sprintf`/global `buf`), unit‑tested + boot‑golden + proptest‑fuzzed, and **differentially pinned against `ircd-common`** (or `iauth-rs` for auth) wherever a pure oracle entry point exists — structural skeleton + documented divergences, since leveva emits a clean modern burst rather than a byte copy. **Done: 282 slices** (282 = SQLite persistence for K-lines + reservations — operator-laid `TKLINE` bans and `RESV` reservations (lost on restart before) now persist to the **same `database {}` SQLite file** that backs chat-history (slice 274), so they survive a reboot: each `KlineStore`/`ResvStore` gains an optional write-through connection (`open(path)` creates a `kline`/`resv` table + loads existing rows; `add`/`remove`/`prune` mirror to it; a `new()`/no-`database{}` store stays in-memory only, every persistence path a no-op), the in-memory `Vec` staying the hot-path authority; K-lines drop `expires <= now` rows on load + prune; only **runtime** (`conf == false`) reservations persist (config `resv {}` re-derives from `ircd.kdl`, so `seed_config` bypasses persistence and a `REHASH` never drops a learned reservation); `COLLATE NOCASE` primary keys make a case-differing re-add upsert not duplicate; open/write errors log and fall back to in-memory (never fatal); inbound S2S `apply_encap_kline`/`apply_encap_resv` persist what a server learns from the network too (all via `add`); 281 = S2S `KLINE`/`UNKLINE` propagation — the K-line analogue of slice 280: a `TKLINE`/`UNTKLINE` (leveva's temporary K-line family, there is no separate permanent `KLINE`) now propagates network-wide as `ENCAP * KLINE <duration> <user> <host> :<reason>`/`ENCAP * UNKLINE <user> <host>` (server-prefixed, leveva-native, the **remaining** seconds on the wire so each server re-clocks `expires = now + duration` against its own clock — no absolute-timestamp skew), wired into `command/tkline.rs` after the param/privilege/format gates; every server applies the ban to its own `KlineStore` (gating future registrations) **and reaps its own local matching clients** via the now-`is_local_uid`-guarded `reap_matching` helper (which also fixed a latent over-reap of remote users in the local `TKLINE`); `kline-exempt` clients are spared; `UNKLINE` lifts the gate only (never un-kills) and propagates unconditionally; re-asserted to a freshly-linked peer by `s2s/burst.rs::kline_burst` (active bans only, with remaining time; expired bursts nothing); new `s2s/kline.rs` modelled on `s2s/resv.rs`, ENCAP dispatch in `s2s/forward.rs`; 280 = S2S `RESV`/`UNRESV` propagation — an operator-laid nick/channel reservation (and its lift) now propagates network-wide as `ENCAP * RESV`/`UNRESV` (server-prefixed, kind re-derived from the mask prefix on inbound apply), wired into `command/resv.rs` after the param/privilege gates (a `461`/`481` reject propagates nothing; `UNRESV` propagates unconditionally), applied + split-horizon-relayed by `s2s/forward.rs`, and re-asserted to a freshly-linked peer via `s2s/burst.rs::resv_burst` (runtime `conf == false` reservations only — config `resv {}` blocks stay per-server local policy); closes the slice-272/273 S2S-propagation follow-on, mirroring the METADATA/KNOCK ENCAP carriers; 279 = remote-originated JOIN/PART/QUIT membership events are now recorded into chat-history — the membership-event parallel to slice 276 (which did PRIVMSG/NOTICE): a **live** `NJOIN` (`s2s/njoin.rs`, never a burst — bursts are bulk state-sync), a bare `JOIN`/`PART` (`s2s/relay.rs`), and a **discrete** remote `QUIT`/`KILL` (`s2s/squit.rs::quit_one_remote_user`, *not* a netsplit mass teardown) each record an event keyed by a locally-minted msgid + clock, gated on `has_local_member` (only a local member can `CHATHISTORY` it; a pure hub stores nothing); an unmirrored `NJOIN`-only ghost has no mask so its QUIT is skipped; replay/query side unchanged (slice 274 already gates these behind `draft/event-playback`); 278 = `+R` auto-reop is now enforced for **local** members only — the `enforce_reop` chokepoint gained an explicit `is_local_uid` filter, so a `+R` mask matching a `UNICK`-introduced remote user's `nick!user@host` no longer wrongly ops it locally and relays a `+o` we have no authority to emit (the locality guarantee previously rested on the false premise that remote members lack a registry record); 277 = netsplit batches are no longer duplicated to `batch`-capable clients under concurrent teardown — when a dying peer is detected on two paths at once (abrupt socket drop + a sibling's inbound `SQUIT`, in separate tasks), the `NJOIN`-only ghost-member sweep raced (`co_members` then `remove_everywhere`, no single-shot gate) and double-relayed a casualty's `QUIT`; new atomic `Channels::detach_member` fuses recipient-gather + removal in one critical section so only the first teardown to reach a member relays it (the loser gets `None`), mirroring the already-atomic `remove_behind_sid` mirror drain; new `s2s_netsplit_concurrency_proptest` fuzz; 276 = remote-originated channel messages are now recorded into chat-history — the S2S relay's inbound channel fan (`s2s::relay::inbound_message`) records the delivered line on this server too, keyed by the carried network msgid (slice 130) + `@time` (slice 134), gated on this server having a local member of the channel (only a local member can `CHATHISTORY` it; no whole-network accumulation on a hub); the `+z` op-redirect/non-channel branches never record, mirroring the local plane; closes the slice-274 gap where `CHATHISTORY` held only locally-sent lines; 275 = IRCv3 `message-redaction` now deletes from chat-history — an accepted channel `REDACT <chan> <msgid>` deletes the slice-274 stored line (`History::delete_by_msgid`, keyed `(target, msgid)`) so `CHATHISTORY` can never replay it; wired in both the local `CanSend::Ok` path and inbound `ENCAP * REDACT`, per-server; mirrors recording — a `+z` op-moderated/"shown to ops" attempt is never recorded so never deleted; authorization stays a stateless relay; 274 = IRCv3 `draft/chathistory` — SQLite-backed (`rusqlite`, bundled) channel message history behind an optional `ircd.kdl` `database {}` block; all channels auto-enrolled (PRIVMSG/NOTICE + JOIN/PART/QUIT recorded at the delivery plane), joining clients auto-replayed `replay-lines` recent lines unless they negotiated `draft/chathistory` (then exempt, per spec), full `CHATHISTORY LATEST/BEFORE/AFTER/BETWEEN/AROUND/TARGETS` (timestamp=/msgid= selectors) framed as a `chathistory` batch, `draft/chathistory`+`draft/event-playback` dynamic caps + `CHATHISTORY`/`MSGREFTYPES` ISUPPORT, age-based `retention-days` prune ticker; `Message` gained serde; 273 = registration-time nick `RESV` enforcement — a connection may no longer *register* directly as a reserved nick, only the post-registration `NICK`/JOIN were gated before; refuse `432` + reopen registration, mirroring the `433` nick-in-use path; 272 = charybdis `RESV`/`UNRESV` nick/channel reservation — JOIN→`437`/NICK→`432` gates, opers exempt, new `OperPrivilege::Resv`; defaults declared in `resv {}` config blocks, boot-seeded + REHASH-re-seeded keeping runtime `RESV`s). The slices span the full client command surface (registration; channels + the complete channel-mode surface; WHO/WHOIS/WHOX; OPER/STATS; and the query/util commands incl. OPERWALL/LOCOPS/USERIP); the UID-based S2S link/burst/netsplit protocol (keepalive, live introduction, NJOIN chunking, TS merge arbitration); `leveva-iauth` (dnsbl/socks/webproxy/pipe/ident); config live-rehash across every block; CertFP; the **IRCv3 cap track** (message-tags/server-time/msgid/account/bot/oper/echo/labeled-response/CAP, `draft/metadata-2`, caller-id `+g`/`+G`, the **STS/TLS** sub-track); elemental channel modes + extbans; KNOCK; command-rate flood protection (fakelag); the **complete charybdis channel-mode set** (`+C/+c/+S/+j/+z/+T/+g/+r/+f/+Q/+F/+P/+L`, with `+L` the last standard one) and **user-mode set** (`+D/+R/+Q/+G/+z/+S/+l`, plus the product decision that leveva has no local-only umodes — `SEND_UMODES` is every umode); and the `+s` server-notice **snomask** (a charybdis category mask with a producer wired for every category — the matrix is complete). Most const limits (`max-penalty`/`max-bans`/`service-string`/`default-snomask`/…) have been promoted to REHASH-able `options { … }` knobs. **Full per-slice detail (scope, mechanism, divergences, gate) for every slice lives in [`docs/progress-log/p11.md`](docs/progress-log/p11.md); the standing testing plan is [`docs/superpowers/specs/2026-06-10-leveva-testing-plan.md`](docs/superpowers/specs/2026-06-10-leveva-testing-plan.md). This row stays thin.** | Per‑slice: leveva boots and serves the slice; leveva golden snapshot + proptest green; the `leveva-integration` differential agrees with `ircd-common` on the shared skeleton with divergences asserted. Across the eventual matrix: registration, channels, WHO/WHOIS/WHOX, OPER/STATS, server burst, netsplit, rehash, restart; iauth end‑to‑end. | 94 94 | **P12 — Retire the mechanical port; `leveva` is the product ✅ DONE (2026‑06‑13)** ([`docs/progress-log/p12.md`](docs/progress-log/p12.md)) | deleted `ircd-common`/`ircd-rs` + C‑era `iauth-rs` + `leveva-integration` + the mechanical port's deployment rigging; workspace = `leveva` + `leveva-iauth` | Ran the differential suite a **final time green** (`cargo test -p leveva-integration`, skeleton‑identical to the oracle), then **deleted the mechanical port** (`ircd-common`, `ircd-rs`), its C‑era auth scaffolding (`iauth-rs`, long since subsumed by the native `leveva-iauth`), and the oracle differential crate (`leveva-integration`). The oracle tests retired with their oracle — the load‑bearing behavior was already carried by leveva's self‑contained 212‑file golden + proptest suite (verified: leveva/leveva-iauth have zero Cargo/`use` dep on the deleted crates). Also stripped the mechanical port's **deployment rigging** (`docker/Dockerfile.ircd`, the `ircd` bake target, the entire `docs/k8s/` example network) and updated the README to describe the finished strangler. This is the literal end of the strangler — same lifecycle the C tree had at P8. | **MET. Final differential run green; `ircd-common`/`ircd-rs`/`iauth-rs`/`leveva-integration` deleted; `cargo build --workspace` 0 warnings; `cargo clippy --workspace --tests` clean; `cargo test -p leveva -p leveva-iauth` green — the behavioral guarantees carry forward. The workspace is now `leveva` + `leveva-iauth`.** | 95 95 96 96 ---
+36
docs/claude-memory/leveva-s2s-notify-propagation.md
··· 343 343 matches charybdis (config RESVs not clustered). Fuzz: 2 in-crate proptests (apply total/consistent 344 344 + kind-matches-prefix; junk never panics + UNRESV targeted). Remaining slice-272 follow-ons: timed 345 345 `RESV <time>` + reaping an already-present holder. 346 + 347 + ## Slice 281 — KLINE/UNKLINE S2S propagation (`ENCAP * KLINE`/`UNKLINE`) 348 + 349 + The K-line analogue of slice 280. A `TKLINE`/`UNTKLINE` (leveva's temporary K-line family — there is 350 + NO separate permanent `KLINE` command) was single-server. New `s2s/kline.rs` (modelled on 351 + `s2s/resv.rs`): `propagate_kline`/`propagate_unkline` broadcast `:<sid> ENCAP * KLINE <duration> 352 + <user> <host> :<reason>` / `:<sid> ENCAP * UNKLINE <user> <host>` (no-op peerless); 353 + `apply_encap_kline`/`apply_encap_unkline` apply inbound. **Duration not absolute expiry on the wire** 354 + — each server computes `expires = now + duration` against its own clock (no skew); the live command 355 + propagates the clamped `secs`, `s2s/burst.rs::kline_burst` re-asserts the **remaining** time 356 + (`expires - now`, active bans only — expired burst nothing), inbound re-clamps `[0, TKLINE_MAXTIME]`. 357 + `<user> <host>` ride as two params (no `@`-resplit). **Inbound apply reaps LOCAL clients** (network 358 + ban reaps everywhere; a remote user is reaped by its own server) — this fixed a latent over-reap in 359 + the local `TKLINE` command by routing both through one `command/tkline.rs::reap_matching` helper now 360 + carrying the slice-278 `is_local_uid` guard (+ `kline-exempt` skip). `UNKLINE` does not un-kill 361 + (lifts the gate only), propagates unconditionally. `TKLINE_MAXTIME` + `now_secs()` promoted to 362 + `pub`/`pub(crate)` in `kline.rs`. `forward.rs` gained `KLINE`/`UNKLINE` arms. Fuzz: 2 in-crate 363 + proptests (apply total/consistent; junk never panics + UNKLINE targeted). 364 + 365 + ## Slice 282 — SQLite persistence for K-lines + reservations 366 + 367 + `TKLINE` bans + `RESV` reservations (lost on restart) now persist to the **same `database {}` SQLite 368 + file** that backs chat-history ([[leveva-chathistory]]). Each `KlineStore`/`ResvStore` gains 369 + `db: Option<Mutex<Connection>>` (manual Debug; `new()`/`default()` = in-memory only, every persist 370 + path a no-op — used by all tests + when no `database {}`): `open(path)` creates a `kline`/`resv` table 371 + (WAL, `COLLATE NOCASE` PK so a case-differing re-add upserts not duplicates) + **loads** existing rows; 372 + `add`/`remove`/`prune` write through after the Vec mutation (Vec stays hot-path authority). K-lines 373 + drop `expires <= now` rows on load + prune. Resvs persist **only runtime** (`conf == false`) — `add` 374 + persists, `seed_config` does NOT (config `resv {}` re-derives from `ircd.kdl`; loaded rows land 375 + `conf == false` so REHASH never drops them); boot order `open` (load runtime) → `seed_config`. 376 + `server.rs::from_config` opens each store against `cfg.database.path` (log + fall back to `new()` on 377 + error). Inbound S2S `apply_encap_kline`/`apply_encap_resv` persist what a server learns (all via 378 + `add`). Errors log, never fatal. `examples/`+`dist/ircd.kdl` `database {}` comment extended. Fuzz: 1 379 + proptest per store (`the_persisted_set_matches_after_reopen` — file-backed add/remove reloads to the 380 + in-memory set, 24 cases, unique temp DB + WAL/SHM cleanup). NO new config key — the existing 381 + `database {}` block transparently gained this.
+57
docs/progress-log/p11.md
··· 15433 15433 **Plan:** [`docs/superpowers/plans/2026-06-19-p11-slice281-s2s-kline-propagation.md`](../superpowers/plans/2026-06-19-p11-slice281-s2s-kline-propagation.md) 15434 15434 15435 15435 **Gate:** `cargo test -p leveva` green; `cargo clippy -p leveva --tests` clean; `cargo build -p leveva` 0 warnings. 15436 + 15437 + ## P11 slice 282 — SQLite persistence for K-lines + reservations 15438 + 15439 + **2026-06-19.** Operator-laid temporary K-lines (`TKLINE`) and nick/channel reservations (`RESV`) 15440 + were lost on restart — they lived only in the in-memory `KlineStore`/`ResvStore`. They now persist to 15441 + the **same `database {}` SQLite file** that already backs chat-history (slice 274), so an operator's 15442 + bans/reservations survive a reboot. No new config: the existing `database {}` block now also opens the 15443 + `kline`/`resv` tables. Without the block both stores stay in-memory only — exactly as before. 15444 + 15445 + - **Reuse the `database {}` file, new tables** — each store opens its own write-through SQLite 15446 + connection to `cfg.database.path` (multiple connections to one WAL file is standard), with a 15447 + `kline`/`resv` table. Mirrors the self-contained `History` pattern: the in-memory `Vec` stays the 15448 + hot-path authority, the connection is a durable mirror. 15449 + - **Write-through on mutate, load on boot** — `add`/`remove`/`prune` (kline) and `add`/`remove` (resv) 15450 + mirror to the table; `open(path)` creates the table then loads existing rows. A disabled store 15451 + (`db == None` — the `new()`/`default()` path used in tests and when no `database {}` is configured) 15452 + makes every persistence call a cheap no-op. 15453 + - **K-lines drop expired on load + prune** — a stored absolute `expires` stays valid across a reboot; 15454 + `load` deletes `expires <= now` rows up front, and `prune` deletes them from disk too. 15455 + - **Reservations: only runtime (`conf == false`) persist** — `add` (always a command/inbound runtime 15456 + reservation) is persisted; `seed_config` is **not** (config `resv {}` re-derives from `ircd.kdl` each 15457 + boot — persisting it would double-seed). `open` loads stored rows as `conf == false`, so a `REHASH` 15458 + (which replaces only `conf == true`) never drops a persisted reservation. Boot order: `open` (load 15459 + runtime) → `seed_config` (config alongside). 15460 + - **Case-insensitive primary key** — `COLLATE NOCASE` on `(user, host)` / `(mask, channel)` makes an 15461 + `INSERT OR REPLACE` of a case-differing re-add refresh the row rather than duplicate it, matching the 15462 + in-memory case-folded dedup. 15463 + - **Errors are logged, never fatal** — an open failure falls back to an in-memory store (boot 15464 + continues); a write error is logged. Persistence must never break a command or abort boot. 15465 + - **Inbound S2S bans/reservations persist too** — `apply_encap_kline`/`apply_encap_resv` call `add`, so 15466 + a server persists what it *learned* from the network (survives *its* restart) with no special 15467 + handling — persistence lives entirely in `add`/`remove`. 15468 + 15469 + **Mechanism.** `kline.rs`/`resv.rs` each gain a `db: Option<Mutex<Connection>>` field (manual `Debug`, 15470 + `#[derive(Default)]` keeps `new()` in-memory), an `open(path)` (WAL + busy_timeout + DDL + `load`), a 15471 + `load` (kline: delete-expired-then-SELECT; resv: SELECT as `conf == false`), and `persist_add`/ 15472 + `persist_remove` write-through helpers called from `add`/`remove`/`prune` after the Vec mutation; 15473 + `server.rs::from_config` opens each store against `cfg.database.path` (log + fall back to `new()` on 15474 + error), resv then `seed_config`s alongside; the `database {}` block comment in `examples/`+`dist/ircd.kdl` 15475 + notes the extended use. 15476 + 15477 + **Tests (TDD + fuzzing).** 15478 + - `kline.rs` units (5): a persisted ban survives a reopen (+ inverse a removed one is gone); a 15479 + case-differing re-add does not duplicate on disk; an expired ban is not reloaded; `prune` drops 15480 + expired rows from disk; an in-memory store writes nothing. 15481 + - `resv.rs` units (3): a persisted reservation (channel + nick) survives a reopen (kind + `conf == 15482 + false` round-trip; inverse a removed one gone); config reservations are **not** persisted; an 15483 + in-memory store writes nothing. 15484 + - `server.rs` end-to-end (2): a `database {}` block persists a kline + resv across two `from_config` 15485 + boots; **inverse** with no block nothing persists. 15486 + - **Fuzz** (2, one per store): an arbitrary add/remove sequence on a file-backed store reloads to 15487 + exactly the in-memory live set (`the_persisted_set_matches_after_reopen`), 24 cases, unique temp DB 15488 + per case cleaned up with its WAL/SHM sidecars. 15489 + 15490 + **Plan:** [`docs/superpowers/plans/2026-06-19-p11-slice282-sqlite-ban-resv-persistence.md`](../superpowers/plans/2026-06-19-p11-slice282-sqlite-ban-resv-persistence.md) 15491 + 15492 + **Gate:** `cargo test -p leveva` green; `cargo clippy -p leveva --tests` clean; `cargo build -p leveva` 0 warnings.
+78
docs/superpowers/plans/2026-06-19-p11-slice282-sqlite-ban-resv-persistence.md
··· 1 + # P11 slice 282 — SQLite persistence for K-lines + reservations 2 + 3 + ## Goal 4 + 5 + Operator-laid temporary K-lines (`TKLINE`) and nick/channel reservations (`RESV`) are lost on 6 + restart — they live only in the in-memory `KlineStore`/`ResvStore`. Persist them to the **same 7 + `database {}` SQLite file** that already backs chat-history (slice 274), so an operator's bans and 8 + reservations survive a reboot. No new config: the existing `database {}` block now also opens the 9 + ban/resv tables. Without the block, both stores stay in-memory only — exactly as before. 10 + 11 + ## Scope decisions (locked) 12 + 13 + - **Reuse the `database {}` file, new tables.** Each store opens its own write-through SQLite 14 + connection to `cfg.database.path` (multiple connections to one WAL file is standard), with a 15 + `kline` / `resv` table. Mirrors the self-contained `History` pattern; the in-memory `Vec` stays 16 + the hot-path authority, the connection is a durable mirror. 17 + - **Write-through on mutate, load on boot.** `add`/`remove`/`prune` (kline) and `add`/`remove` 18 + (resv) mirror to the table; `open(path)` creates the table then loads existing rows into the Vec. 19 + Disabled stores (`db == None`, the `new()`/`default()` path used everywhere in tests and when no 20 + `database {}` is configured) make every persistence call a cheap no-op. 21 + - **K-lines: drop expired on load + prune.** Stored absolute `expires` stays valid across a reboot; 22 + `load` deletes `expires <= now` rows up front so an expired ban neither reloads nor lingers, and 23 + `prune` deletes them from disk too. 24 + - **Reservations: only runtime (`conf == false`) persist.** `add` (always a command/inbound runtime 25 + reservation) is persisted; `seed_config` is **not** (config `resv {}` re-derives from `ircd.kdl` 26 + each boot — persisting it would double-seed). `open` loads stored rows as `conf == false`, so a 27 + `REHASH` (which replaces only `conf == true`) never drops a persisted reservation. Boot order: 28 + `open` (load runtime) → `seed_config` (add config alongside). 29 + - **Case-insensitive primary key.** `COLLATE NOCASE` on `(user, host)` / `(mask, channel)` makes an 30 + `INSERT OR REPLACE` of a case-differing re-add refresh the row rather than duplicate it, matching 31 + the in-memory case-folded dedup. 32 + - **Errors are logged, never fatal.** An open failure falls back to an in-memory store (boot 33 + continues); a write error is logged — persistence must never break a command or abort boot. 34 + - **Inbound S2S bans/reservations persist too** — `apply_encap_kline`/`apply_encap_resv` call 35 + `add`, so a server persists what it *learned* from the network (survives *its* restart). No 36 + special handling — persistence lives entirely in `add`/`remove`. 37 + 38 + ## Mechanism 39 + 40 + ### `kline.rs` (`KlineStore`) 41 + - `db: Option<Mutex<Connection>>` field; manual `Debug`; `#[derive(Default)]` keeps `new()` 42 + in-memory. 43 + - `open(path)` (WAL + busy_timeout + DDL + `load`); `load` (delete-expired then SELECT into Vec); 44 + `persist_add` (`INSERT OR REPLACE`), `persist_remove` (`DELETE … WHERE user=? AND host=?`). 45 + - `add`/`remove`/`prune` call the write-through after mutating the Vec (Vec guard dropped first). 46 + 47 + ### `resv.rs` (`ResvStore`) 48 + - Same shape: `db` field, manual `Debug`, `open`/`load`/`persist_add`/`persist_remove`. `load` 49 + reads rows as `conf == false`. `add`/`remove` write through; `seed_config` does **not**. 50 + 51 + ### `server.rs` (`from_config`) 52 + - `klines`: `KlineStore::open(path)` when `cfg.database` is `Some` (log + fall back to `new()` on 53 + error); else `new()`. 54 + - `resvs`: `ResvStore::open(path)`-or-`new()`, then `seed_config(config entries)`. 55 + 56 + ### `examples/ircd.kdl` + `dist/ircd.kdl` 57 + - Extend the `database {}` block comment to note it now also persists K-lines + reservations. 58 + 59 + ## Tests (TDD + fuzzing) 60 + 61 + - **`kline.rs` units:** a persisted ban survives a reopen (+ inverse: a removed one is gone); a 62 + case-differing re-add does not duplicate on disk; an expired ban is not reloaded; `prune` drops 63 + expired rows from disk; an in-memory store writes nothing. **Fuzz:** an arbitrary add/remove 64 + sequence on a file-backed store reloads to exactly the in-memory live set. 65 + - **`resv.rs` units:** a persisted reservation (channel + nick) survives a reopen (kind + `conf == 66 + false` round-trip; inverse: a removed one gone); config reservations are **not** persisted; an 67 + in-memory store writes nothing. **Fuzz:** add/remove sequence reloads to the in-memory set. 68 + - **`server.rs` end-to-end:** a `database {}` block persists a kline + resv across two 69 + `from_config` boots; **inverse:** with no block nothing persists. 70 + 71 + Each test uses a unique temp DB path (`temp_dir()` + pid + atomic counter) cleaned up with its 72 + WAL/SHM sidecars; proptests are capped at 24 cases (file I/O). 73 + 74 + ## Gate 75 + 76 + `cargo test -p leveva` green; `cargo clippy -p leveva --tests` clean; `cargo build -p leveva` 77 + 0 warnings. Then PLAN.md P11 row slice count bump, `docs/progress-log/p11.md` entry, `kline.rs`/ 78 + `resv.rs` module-doc persistence note, a memory touch.
+5
leveva/dist/ircd.kdl
··· 332 332 // NOTICE and each JOIN / PART / QUIT is appended to the database — and a 333 333 // joining client is replayed recent lines (unless it negotiated 334 334 // draft/chathistory, in which case it queries history itself). 335 + // 336 + // This same database file ALSO persists operator-laid K-lines (TKLINE) and 337 + // nick/channel reservations (RESV) so they survive a restart: each is written 338 + // through on change and reloaded at boot. Without this block they are kept in 339 + // memory only (lost on restart), exactly as before. 335 340 // =========================================================================== 336 341 database { 337 342 // SQLite database file (created if absent). ":memory:" keeps history only
+5
leveva/examples/ircd.kdl
··· 332 332 // NOTICE and each JOIN / PART / QUIT is appended to the database — and a 333 333 // joining client is replayed recent lines (unless it negotiated 334 334 // draft/chathistory, in which case it queries history itself). 335 + // 336 + // This same database file ALSO persists operator-laid K-lines (TKLINE) and 337 + // nick/channel reservations (RESV) so they survive a restart: each is written 338 + // through on change and reloaded at boot. Without this block they are kept in 339 + // memory only (lost on restart), exactly as before. 335 340 // =========================================================================== 336 341 database { 337 342 // SQLite database file (created if absent). ":memory:" keeps history only
+315 -19
leveva/src/kline.rs
··· 15 15 //! **own local** matching clients (a remote user is reaped by *its* server). The store itself is 16 16 //! unchanged; the network plane lives entirely in [`crate::s2s::kline`]. 17 17 //! 18 + //! **Persistence (P11 slice 282).** When opened against a `database {}` SQLite file 19 + //! ([`KlineStore::open`]) the store is write-through: bans survive a restart (see the 20 + //! [`KlineStore`] docs). A `new()` store is in-memory only, as before. 21 + //! 18 22 //! This mirrors the IRCnet 2.11 `s_conf.c` TKLINE cluster (the `tkconf` list + 19 23 //! `do_kline`/`tkline_expire`), but idiomatically: an owned [`Vec`] behind a 20 24 //! [`Mutex`] rather than an intrusive `aConfItem` chain, glob masks matched with ··· 34 38 //! arithmetic is `i32`-wrapping before widening to `i64`, exactly as the C does. 35 39 36 40 use std::sync::Mutex; 41 + 42 + use rusqlite::{params, Connection}; 37 43 38 44 use crate::matching; 39 45 ··· 143 149 } 144 150 } 145 151 152 + /// The DDL for the optional persistence table. `COLLATE NOCASE` makes the `(user, host)` primary 153 + /// key case-insensitive, matching the in-memory store's case-folded dedup, so an `INSERT OR REPLACE` 154 + /// of a case-differing re-`TKLINE` refreshes the row rather than stacking a duplicate. 155 + const KLINE_SCHEMA: &str = " 156 + CREATE TABLE IF NOT EXISTS kline ( 157 + user TEXT NOT NULL COLLATE NOCASE, 158 + host TEXT NOT NULL COLLATE NOCASE, 159 + reason TEXT NOT NULL, 160 + expires INTEGER NOT NULL, 161 + PRIMARY KEY (user, host) 162 + ); 163 + "; 164 + 146 165 /// The shared temporary-ban list. Authority on who is banned, consulted both when a 147 166 /// ban is laid (to reap matching connections) and at every registration (to refuse 148 167 /// matching clients). Cheap to clone-by-`Arc` via [`crate::server::ServerContext`]. 149 - #[derive(Debug, Default)] 168 + /// 169 + /// **Persistence (P11 slice 282).** When opened against a `database {}` file ([`KlineStore::open`]) 170 + /// the store carries a write-through SQLite connection: every [`add`](Self::add)/[`remove`](Self::remove)/ 171 + /// [`prune`](Self::prune) is mirrored to the `kline` table, and [`open`](Self::open) loads the 172 + /// surviving (non-expired) bans back into memory at boot, so an operator's bans outlive a restart. 173 + /// A store built with [`new`](Self::new)/[`default`](Self::default) (`db == None`) is in-memory only 174 + /// — every persistence path is a no-op — exactly as before the slice. 175 + #[derive(Default)] 150 176 pub struct KlineStore { 151 177 inner: Mutex<Vec<Kline>>, 178 + /// `None` for an in-memory-only store; otherwise the write-through SQLite connection to the 179 + /// `database {}` file. The in-memory [`inner`](Self::inner) Vec stays the hot-path authority; 180 + /// this is a durable mirror updated on every mutation. 181 + db: Option<Mutex<Connection>>, 182 + } 183 + 184 + impl std::fmt::Debug for KlineStore { 185 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 186 + f.debug_struct("KlineStore") 187 + .field("len", &self.len()) 188 + .field("persistent", &self.db.is_some()) 189 + .finish() 190 + } 152 191 } 153 192 154 193 impl KlineStore { 155 - /// An empty store. 194 + /// An empty, in-memory-only store (no persistence). 156 195 pub fn new() -> Self { 157 196 Self::default() 158 197 } 159 198 199 + /// Open (creating if absent) the SQLite database at `path`, initialise the `kline` table, and 200 + /// **load** the surviving (not-yet-expired) bans into memory — so a `TKLINE` laid before a 201 + /// restart is still in force after it. `path` may be `:memory:` (an ephemeral per-connection 202 + /// database, used by tests). Returns the rusqlite error on failure so the caller can log and 203 + /// fall back to an in-memory store. 204 + pub fn open(path: &str) -> rusqlite::Result<KlineStore> { 205 + let conn = Connection::open(path)?; 206 + let _ = conn.pragma_update(None, "journal_mode", "WAL"); 207 + let _ = conn.busy_timeout(std::time::Duration::from_secs(5)); 208 + conn.execute_batch(KLINE_SCHEMA)?; 209 + let store = KlineStore { 210 + inner: Mutex::new(Vec::new()), 211 + db: Some(Mutex::new(conn)), 212 + }; 213 + store.load(); 214 + Ok(store) 215 + } 216 + 217 + /// Load the persisted bans into memory at boot: drop any row that has already expired, then 218 + /// read the rest into the in-memory Vec. A no-op for an in-memory-only store. Errors are logged, 219 + /// never propagated — a corrupt history row must not abort boot. 220 + fn load(&self) { 221 + let Some(lock) = &self.db else { return }; 222 + let now = now_secs(); 223 + let loaded: Vec<Kline> = { 224 + let conn = lock.lock().unwrap(); 225 + // Reap rows that expired while we were down, so they neither load nor linger. 226 + let _ = conn.execute("DELETE FROM kline WHERE expires <= ?1", params![now]); 227 + let mut stmt = match conn.prepare("SELECT user, host, reason, expires FROM kline") { 228 + Ok(s) => s, 229 + Err(e) => { 230 + tracing::warn!(error = %e, "kline: prepare load failed"); 231 + return; 232 + } 233 + }; 234 + let rows = stmt.query_map([], |r| { 235 + Ok(Kline { 236 + user: r.get(0)?, 237 + host: r.get(1)?, 238 + reason: r.get(2)?, 239 + expires: r.get(3)?, 240 + }) 241 + }); 242 + match rows { 243 + Ok(it) => it.filter_map(Result::ok).collect(), 244 + Err(e) => { 245 + tracing::warn!(error = %e, "kline: load query failed"); 246 + return; 247 + } 248 + } 249 + }; 250 + *self.inner.lock().unwrap() = loaded; 251 + } 252 + 253 + /// Write-through helper: upsert one ban into the `kline` table. A no-op when not persistent. 254 + fn persist_add(&self, user: &str, host: &str, reason: &str, expires: i64) { 255 + let Some(lock) = &self.db else { return }; 256 + let conn = lock.lock().unwrap(); 257 + if let Err(e) = conn.execute( 258 + "INSERT OR REPLACE INTO kline (user, host, reason, expires) VALUES (?1, ?2, ?3, ?4)", 259 + params![user, host, reason, expires], 260 + ) { 261 + tracing::warn!(error = %e, "kline: persist add failed"); 262 + } 263 + } 264 + 265 + /// Write-through helper: delete one ban (by case-insensitive `(user, host)`). No-op when not 266 + /// persistent. 267 + fn persist_remove(&self, user: &str, host: &str) { 268 + let Some(lock) = &self.db else { return }; 269 + let conn = lock.lock().unwrap(); 270 + if let Err(e) = conn.execute( 271 + "DELETE FROM kline WHERE user = ?1 AND host = ?2", 272 + params![user, host], 273 + ) { 274 + tracing::warn!(error = %e, "kline: persist remove failed"); 275 + } 276 + } 277 + 160 278 /// Add a ban, or **refresh** an existing one. The key is `(user, host)` matched 161 279 /// case-insensitively (the C `do_kline` walks `tkconf` with `strcasecmp` on both 162 280 /// fields); a re-`TKLINE` of the same mask updates its reason and expiry rather 163 281 /// than stacking a duplicate. 164 282 pub fn add(&self, user: &str, host: &str, reason: &str, expires: i64) { 165 - let mut v = self.inner.lock().unwrap(); 166 - if let Some(k) = v 167 - .iter_mut() 168 - .find(|k| k.user.eq_ignore_ascii_case(user) && k.host.eq_ignore_ascii_case(host)) 169 283 { 170 - k.reason = reason.to_string(); 171 - k.expires = expires; 172 - return; 284 + let mut v = self.inner.lock().unwrap(); 285 + if let Some(k) = v 286 + .iter_mut() 287 + .find(|k| k.user.eq_ignore_ascii_case(user) && k.host.eq_ignore_ascii_case(host)) 288 + { 289 + k.reason = reason.to_string(); 290 + k.expires = expires; 291 + } else { 292 + v.push(Kline { 293 + user: user.to_string(), 294 + host: host.to_string(), 295 + reason: reason.to_string(), 296 + expires, 297 + }); 298 + } 173 299 } 174 - v.push(Kline { 175 - user: user.to_string(), 176 - host: host.to_string(), 177 - reason: reason.to_string(), 178 - expires, 179 - }); 300 + // Mirror to the durable store (no-op when in-memory-only). The case-insensitive PK upserts 301 + // a refresh in place, matching the in-memory dedup above. 302 + self.persist_add(user, host, reason, expires); 180 303 } 181 304 182 305 /// Remove the ban whose mask equals `(user, host)` (case-insensitively). 183 306 /// Returns whether anything was removed (the C `m_untkline` only notices on a 184 307 /// real deletion). 185 308 pub fn remove(&self, user: &str, host: &str) -> bool { 186 - let mut v = self.inner.lock().unwrap(); 187 - let before = v.len(); 188 - v.retain(|k| !(k.user.eq_ignore_ascii_case(user) && k.host.eq_ignore_ascii_case(host))); 189 - v.len() != before 309 + let removed = { 310 + let mut v = self.inner.lock().unwrap(); 311 + let before = v.len(); 312 + v.retain(|k| !(k.user.eq_ignore_ascii_case(user) && k.host.eq_ignore_ascii_case(host))); 313 + v.len() != before 314 + }; 315 + // Always mirror the delete (cheap, idempotent) so a persisted ban is dropped even if the 316 + // in-memory copy was already gone. 317 + self.persist_remove(user, host); 318 + removed 190 319 } 191 320 192 321 /// The first **active** (not-yet-expired at `now`) kline whose mask covers the ··· 204 333 /// entries, so this only reclaims memory. 205 334 pub fn prune(&self, now: i64) { 206 335 self.inner.lock().unwrap().retain(|k| now < k.expires); 336 + // Drop the same expired rows from the durable store (no-op when in-memory-only). 337 + if let Some(lock) = &self.db { 338 + let conn = lock.lock().unwrap(); 339 + if let Err(e) = conn.execute("DELETE FROM kline WHERE expires <= ?1", params![now]) { 340 + tracing::warn!(error = %e, "kline: persist prune failed"); 341 + } 342 + } 207 343 } 208 344 209 345 /// A snapshot of the current bans (for tests / introspection). ··· 459 595 let hit = config_ban_match(&bans, "bob", "10.0.0.7", "r").expect("matched"); 460 596 // The broad earlier block wins (config order), not the narrower later one. 461 597 assert_eq!(hit.mask, "*@*"); 598 + } 599 + 600 + // ── slice 282: SQLite persistence (write-through + boot reload) ──────────────── 601 + 602 + /// A unique temp DB path per call, so parallel tests never collide on one file. 603 + fn unique_db_path(tag: &str) -> std::path::PathBuf { 604 + use std::sync::atomic::{AtomicU64, Ordering}; 605 + static N: AtomicU64 = AtomicU64::new(0); 606 + let n = N.fetch_add(1, Ordering::Relaxed); 607 + std::env::temp_dir().join(format!( 608 + "leveva-kline-{tag}-{}-{n}.db", 609 + std::process::id() 610 + )) 611 + } 612 + 613 + /// Remove a temp DB and its WAL/SHM sidecars. 614 + fn cleanup(p: &std::path::Path) { 615 + let _ = std::fs::remove_file(p); 616 + let _ = std::fs::remove_file(format!("{}-wal", p.display())); 617 + let _ = std::fs::remove_file(format!("{}-shm", p.display())); 618 + } 619 + 620 + #[test] 621 + fn a_persisted_kline_survives_a_reopen() { 622 + let path = unique_db_path("survive"); 623 + cleanup(&path); 624 + let ps = path.to_str().unwrap(); 625 + let now = now_secs(); 626 + { 627 + let s = KlineStore::open(ps).unwrap(); 628 + s.add("bob", "*.evil.net", "spam", now + 3600); 629 + s.add("eve", "*.bad.org", "abuse", now + 7200); 630 + } // drop → connection closed, like a restart 631 + // Reopen: both bans are back and active. 632 + let s2 = KlineStore::open(ps).unwrap(); 633 + assert_eq!(s2.len(), 2, "both bans reloaded"); 634 + assert_eq!( 635 + s2.find_active("bob", "mail.evil.net", now).unwrap().reason, 636 + "spam" 637 + ); 638 + // Inverse: lift one, reopen again — only it is gone. 639 + assert!(s2.remove("bob", "*.evil.net")); 640 + drop(s2); 641 + let s3 = KlineStore::open(ps).unwrap(); 642 + assert_eq!(s3.len(), 1); 643 + assert!(s3.find_active("bob", "mail.evil.net", now).is_none()); 644 + assert!(s3.find_active("eve", "x.bad.org", now).is_some()); 645 + cleanup(&path); 646 + } 647 + 648 + #[test] 649 + fn a_refreshed_kline_does_not_duplicate_on_disk() { 650 + let path = unique_db_path("refresh"); 651 + cleanup(&path); 652 + let ps = path.to_str().unwrap(); 653 + let now = now_secs(); 654 + { 655 + let s = KlineStore::open(ps).unwrap(); 656 + s.add("bob", "*.evil.net", "first", now + 3600); 657 + // Case-differing re-add refreshes in place (NOCASE primary key), not a 2nd row. 658 + s.add("BOB", "*.EVIL.NET", "second", now + 9999); 659 + } 660 + let s2 = KlineStore::open(ps).unwrap(); 661 + assert_eq!(s2.len(), 1, "the refresh upserted, not duplicated"); 662 + assert_eq!( 663 + s2.find_active("bob", "x.evil.net", now).unwrap().reason, 664 + "second" 665 + ); 666 + cleanup(&path); 667 + } 668 + 669 + #[test] 670 + fn an_expired_kline_is_not_reloaded() { 671 + let path = unique_db_path("expired"); 672 + cleanup(&path); 673 + let ps = path.to_str().unwrap(); 674 + let now = now_secs(); 675 + { 676 + let s = KlineStore::open(ps).unwrap(); 677 + s.add("live", "*.h", "r", now + 3600); 678 + s.add("dead", "*.h", "r", now - 10); // already expired when written 679 + } 680 + // Reopen well after `dead` expired: only the live ban loads (boot drops expired rows). 681 + let s2 = KlineStore::open(ps).unwrap(); 682 + assert_eq!(s2.len(), 1, "the expired ban is not reloaded"); 683 + assert!(s2.find_active("dead", "x.h", now).is_none()); 684 + assert!(s2.find_active("live", "x.h", now).is_some()); 685 + cleanup(&path); 686 + } 687 + 688 + #[test] 689 + fn prune_drops_expired_rows_from_disk_too() { 690 + let path = unique_db_path("prune"); 691 + cleanup(&path); 692 + let ps = path.to_str().unwrap(); 693 + let now = now_secs(); 694 + { 695 + let s = KlineStore::open(ps).unwrap(); 696 + s.add("a", "h1", "r", now + 100); 697 + s.add("b", "h2", "r", now + 3600); 698 + s.prune(now + 200); // expires the first 699 + } 700 + // The pruned row is gone from disk, not just memory. 701 + let s2 = KlineStore::open(ps).unwrap(); 702 + let users: Vec<String> = s2.entries().iter().map(|k| k.user.clone()).collect(); 703 + assert_eq!(users, vec!["b".to_string()]); 704 + cleanup(&path); 705 + } 706 + 707 + #[test] 708 + fn an_in_memory_store_writes_nothing() { 709 + // A `new()` store has no DB — add/remove never touch disk and never panic. 710 + let s = KlineStore::new(); 711 + s.add("bob", "h", "r", 100); 712 + assert!(s.remove("bob", "h")); 713 + s.prune(0); // no-op 714 + } 715 + 716 + proptest::proptest! { 717 + #![proptest_config(proptest::test_runner::Config::with_cases(24))] 718 + 719 + /// After an arbitrary add/remove sequence on a file-backed store, **reopening** it yields 720 + /// exactly the live `(user, host)` set that was in memory before the close — write-through 721 + /// persistence is faithful and total. 722 + #[test] 723 + fn the_persisted_set_matches_after_reopen( 724 + ops in proptest::collection::vec( 725 + (proptest::bool::ANY, "[a-c]{1,2}", "[a-c.]{1,3}"), 726 + 1..14, 727 + ), 728 + ) { 729 + use std::collections::BTreeSet; 730 + let path = unique_db_path("fuzz"); 731 + cleanup(&path); 732 + let ps = path.to_str().unwrap(); 733 + let now = now_secs(); 734 + let in_memory: BTreeSet<(String, String)> = { 735 + let s = KlineStore::open(ps).unwrap(); 736 + for (is_add, user, host) in &ops { 737 + if *is_add { 738 + s.add(user, host, "r", now + 3600); 739 + } else { 740 + s.remove(user, host); 741 + } 742 + } 743 + s.entries() 744 + .iter() 745 + .map(|k| (k.user.to_ascii_lowercase(), k.host.to_ascii_lowercase())) 746 + .collect() 747 + }; 748 + // Reopen and compare. 749 + let reloaded: BTreeSet<(String, String)> = KlineStore::open(ps) 750 + .unwrap() 751 + .entries() 752 + .iter() 753 + .map(|k| (k.user.to_ascii_lowercase(), k.host.to_ascii_lowercase())) 754 + .collect(); 755 + cleanup(&path); 756 + proptest::prop_assert_eq!(reloaded, in_memory); 757 + } 462 758 } 463 759 464 760 proptest::proptest! {
+256 -18
leveva/src/resv.rs
··· 25 25 //! network-wide** as `ENCAP * RESV`/`UNRESV` and re-assert to a freshly-linked peer via the 26 26 //! netburst (slice 280, [`crate::s2s::resv`]); config `resv {}` reservations stay per-server local 27 27 //! policy. Timed `RESV <time>` and reaping an already-present holder remain documented follow-ons. 28 + //! 29 + //! **Persistence (P11 slice 282).** When opened against a `database {}` SQLite file 30 + //! ([`ResvStore::open`]) the store is write-through: runtime (`conf == false`) reservations survive a 31 + //! restart (config-seeded ones re-derive from `ircd.kdl`; see the [`ResvStore`] docs). A `new()` 32 + //! store is in-memory only, as before. 28 33 29 34 use std::sync::Mutex; 35 + use std::time::Duration; 36 + 37 + use rusqlite::{params, Connection}; 30 38 31 39 use crate::matching; 32 40 ··· 61 69 mask.replace('#', "\\#") 62 70 } 63 71 72 + /// The DDL for the optional persistence table. Only **runtime** (operator-laid, `conf == false`) 73 + /// reservations are stored — config `resv {}` entries are re-derived from `ircd.kdl` each boot, so 74 + /// persisting them would double-seed. `COLLATE NOCASE` on the mask makes the `(mask, channel)` 75 + /// primary key case-insensitive, matching the in-memory dedup. 76 + const RESV_SCHEMA: &str = " 77 + CREATE TABLE IF NOT EXISTS resv ( 78 + mask TEXT NOT NULL COLLATE NOCASE, 79 + reason TEXT NOT NULL, 80 + channel INTEGER NOT NULL, 81 + PRIMARY KEY (mask, channel) 82 + ); 83 + "; 84 + 64 85 /// The shared reservation list. Authority on which names are reserved; consulted at every 65 86 /// non-operator JOIN (channel reservations) and NICK (nick reservations). Cheap to 66 87 /// clone-by-`Arc` via [`crate::server::ServerContext`]. 67 - #[derive(Debug, Default)] 88 + /// 89 + /// **Persistence (P11 slice 282).** When opened against a `database {}` file ([`ResvStore::open`]) 90 + /// the store carries a write-through SQLite connection: every runtime [`add`](Self::add)/ 91 + /// [`remove`](Self::remove) is mirrored to the `resv` table, and [`open`](Self::open) loads the 92 + /// stored reservations back at boot, so an operator's `RESV`s outlive a restart. Config-seeded 93 + /// reservations ([`seed_config`](Self::seed_config)) are **not** persisted (they re-derive from 94 + /// `ircd.kdl`). A store built with [`new`](Self::new)/[`default`](Self::default) (`db == None`) is 95 + /// in-memory only — every persistence path is a no-op. 96 + #[derive(Default)] 68 97 pub struct ResvStore { 69 98 inner: Mutex<Vec<Resv>>, 99 + /// `None` for an in-memory-only store; otherwise the write-through SQLite connection to the 100 + /// `database {}` file. The in-memory Vec stays the hot-path authority; this is a durable mirror 101 + /// of the **runtime** reservations only. 102 + db: Option<Mutex<Connection>>, 103 + } 104 + 105 + impl std::fmt::Debug for ResvStore { 106 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 107 + f.debug_struct("ResvStore") 108 + .field("len", &self.len()) 109 + .field("persistent", &self.db.is_some()) 110 + .finish() 111 + } 70 112 } 71 113 72 114 impl ResvStore { 73 - /// An empty store. 115 + /// An empty, in-memory-only store (no persistence). 74 116 pub fn new() -> Self { 75 117 Self::default() 76 118 } 77 119 120 + /// Open (creating if absent) the SQLite database at `path`, initialise the `resv` table, and 121 + /// **load** the stored runtime reservations into memory (as `conf == false`). `path` may be 122 + /// `:memory:` (an ephemeral per-connection database, used by tests). Returns the rusqlite error 123 + /// on failure so the caller can log and fall back to an in-memory store. Call 124 + /// [`seed_config`](Self::seed_config) **after** `open` so config reservations sit alongside the 125 + /// loaded runtime ones. 126 + pub fn open(path: &str) -> rusqlite::Result<ResvStore> { 127 + let conn = Connection::open(path)?; 128 + let _ = conn.pragma_update(None, "journal_mode", "WAL"); 129 + let _ = conn.busy_timeout(Duration::from_secs(5)); 130 + conn.execute_batch(RESV_SCHEMA)?; 131 + let store = ResvStore { 132 + inner: Mutex::new(Vec::new()), 133 + db: Some(Mutex::new(conn)), 134 + }; 135 + store.load(); 136 + Ok(store) 137 + } 138 + 139 + /// Load the persisted runtime reservations into memory at boot. A no-op for an in-memory-only 140 + /// store. Errors are logged, never propagated. 141 + fn load(&self) { 142 + let Some(lock) = &self.db else { return }; 143 + let loaded: Vec<Resv> = { 144 + let conn = lock.lock().unwrap(); 145 + let mut stmt = match conn.prepare("SELECT mask, reason, channel FROM resv") { 146 + Ok(s) => s, 147 + Err(e) => { 148 + tracing::warn!(error = %e, "resv: prepare load failed"); 149 + return; 150 + } 151 + }; 152 + let rows = stmt.query_map([], |r| { 153 + Ok(Resv { 154 + mask: r.get(0)?, 155 + reason: r.get(1)?, 156 + channel: r.get::<_, i64>(2)? != 0, 157 + conf: false, 158 + }) 159 + }); 160 + match rows { 161 + Ok(it) => it.filter_map(Result::ok).collect(), 162 + Err(e) => { 163 + tracing::warn!(error = %e, "resv: load query failed"); 164 + return; 165 + } 166 + } 167 + }; 168 + *self.inner.lock().unwrap() = loaded; 169 + } 170 + 171 + /// Write-through helper: upsert one runtime reservation into the `resv` table. No-op when not 172 + /// persistent. 173 + fn persist_add(&self, mask: &str, reason: &str, channel: bool) { 174 + let Some(lock) = &self.db else { return }; 175 + let conn = lock.lock().unwrap(); 176 + if let Err(e) = conn.execute( 177 + "INSERT OR REPLACE INTO resv (mask, reason, channel) VALUES (?1, ?2, ?3)", 178 + params![mask, reason, channel as i64], 179 + ) { 180 + tracing::warn!(error = %e, "resv: persist add failed"); 181 + } 182 + } 183 + 184 + /// Write-through helper: delete reservation(s) by case-insensitive mask (either kind). No-op 185 + /// when not persistent. 186 + fn persist_remove(&self, mask: &str) { 187 + let Some(lock) = &self.db else { return }; 188 + let conn = lock.lock().unwrap(); 189 + if let Err(e) = conn.execute("DELETE FROM resv WHERE mask = ?1", params![mask]) { 190 + tracing::warn!(error = %e, "resv: persist remove failed"); 191 + } 192 + } 193 + 78 194 /// Add a reservation, or **refresh** an existing one of the same mask **and kind** 79 195 /// (case-insensitive mask key). A re-`RESV` of the same mask updates its reason rather 80 196 /// than stacking a duplicate. A channel and a nick reservation that happen to share a 81 197 /// mask string are kept as separate entries (different `channel` flag). 82 198 pub fn add(&self, mask: &str, reason: &str, channel: bool) { 83 - let mut v = self.inner.lock().unwrap(); 84 - if let Some(r) = v 85 - .iter_mut() 86 - .find(|r| r.channel == channel && r.mask.eq_ignore_ascii_case(mask)) 87 199 { 88 - r.reason = reason.to_string(); 89 - return; 200 + let mut v = self.inner.lock().unwrap(); 201 + if let Some(r) = v 202 + .iter_mut() 203 + .find(|r| r.channel == channel && r.mask.eq_ignore_ascii_case(mask)) 204 + { 205 + r.reason = reason.to_string(); 206 + } else { 207 + v.push(Resv { 208 + mask: mask.to_string(), 209 + reason: reason.to_string(), 210 + channel, 211 + conf: false, 212 + }); 213 + } 90 214 } 91 - v.push(Resv { 92 - mask: mask.to_string(), 93 - reason: reason.to_string(), 94 - channel, 95 - conf: false, 96 - }); 215 + // Mirror to the durable store (no-op when in-memory-only). A command/inbound `add` is 216 + // always a runtime reservation, so it is persisted; config seeding bypasses this path. 217 + self.persist_add(mask, reason, channel); 97 218 } 98 219 99 220 /// Replace all **config-sourced** reservations with `entries` (each `(mask, reason)`; the ··· 119 240 /// kind. Returns whether anything was removed (charybdis `m_unresv` only confirms on a 120 241 /// real deletion; leveva sends no confirmation either way — see [`crate::command::resv`]). 121 242 pub fn remove(&self, mask: &str) -> bool { 122 - let mut v = self.inner.lock().unwrap(); 123 - let before = v.len(); 124 - v.retain(|r| !r.mask.eq_ignore_ascii_case(mask)); 125 - v.len() != before 243 + let removed = { 244 + let mut v = self.inner.lock().unwrap(); 245 + let before = v.len(); 246 + v.retain(|r| !r.mask.eq_ignore_ascii_case(mask)); 247 + v.len() != before 248 + }; 249 + // Always mirror the delete (cheap, idempotent). A config reservation isn't in the table, so 250 + // a delete of its mask is a harmless no-op. 251 + self.persist_remove(mask); 252 + removed 126 253 } 127 254 128 255 /// The first **channel** reservation whose mask glob-matches `name`, if any. Consulted by ··· 276 403 // … and the operator-laid runtime reservation survives the rehash. 277 404 assert!(s.nick_reserved("Runtime").is_some()); 278 405 assert_eq!(s.len(), 2); 406 + } 407 + 408 + // ── slice 282: SQLite persistence (write-through + boot reload) ──────────────── 409 + 410 + fn unique_db_path(tag: &str) -> std::path::PathBuf { 411 + use std::sync::atomic::{AtomicU64, Ordering}; 412 + static N: AtomicU64 = AtomicU64::new(0); 413 + let n = N.fetch_add(1, Ordering::Relaxed); 414 + std::env::temp_dir().join(format!("leveva-resv-{tag}-{}-{n}.db", std::process::id())) 415 + } 416 + 417 + fn cleanup(p: &std::path::Path) { 418 + let _ = std::fs::remove_file(p); 419 + let _ = std::fs::remove_file(format!("{}-wal", p.display())); 420 + let _ = std::fs::remove_file(format!("{}-shm", p.display())); 421 + } 422 + 423 + #[test] 424 + fn a_persisted_resv_survives_a_reopen() { 425 + let path = unique_db_path("survive"); 426 + cleanup(&path); 427 + let ps = path.to_str().unwrap(); 428 + { 429 + let s = ResvStore::open(ps).unwrap(); 430 + s.add("#warez", "no warez", true); 431 + s.add("Clone*", "no clones", false); 432 + } // drop → like a restart 433 + let s2 = ResvStore::open(ps).unwrap(); 434 + assert_eq!(s2.len(), 2, "both reservations reloaded"); 435 + // The kind round-trips: the channel one answers a channel query, the nick one a nick query. 436 + assert!(s2.channel_reserved("#warez").is_some()); 437 + assert!(s2.nick_reserved("Clone7").is_some()); 438 + // Both are runtime (conf == false), so a later REHASH never drops them. 439 + assert!(s2.entries().iter().all(|r| !r.conf)); 440 + // Inverse: lift one, reopen — only it is gone. 441 + assert!(s2.remove("#warez")); 442 + drop(s2); 443 + let s3 = ResvStore::open(ps).unwrap(); 444 + assert_eq!(s3.len(), 1); 445 + assert!(s3.channel_reserved("#warez").is_none()); 446 + assert!(s3.nick_reserved("Clone7").is_some()); 447 + cleanup(&path); 448 + } 449 + 450 + #[test] 451 + fn config_reservations_are_not_persisted() { 452 + let path = unique_db_path("config"); 453 + cleanup(&path); 454 + let ps = path.to_str().unwrap(); 455 + { 456 + let s = ResvStore::open(ps).unwrap(); 457 + s.seed_config(&[("#services", "svc"), ("NickServ", "svc nick")]); 458 + s.add("Clone*", "runtime", false); // the only persisted entry 459 + assert_eq!(s.len(), 3); 460 + } 461 + // Reopen: only the runtime reservation is reloaded; config ones come from `ircd.kdl`. 462 + let s2 = ResvStore::open(ps).unwrap(); 463 + assert_eq!(s2.len(), 1, "only the runtime RESV persisted"); 464 + assert!(s2.nick_reserved("Clone7").is_some()); 465 + assert!(s2.channel_reserved("#services").is_none()); 466 + cleanup(&path); 467 + } 468 + 469 + #[test] 470 + fn an_in_memory_store_writes_nothing() { 471 + let s = ResvStore::new(); 472 + s.add("Clone*", "r", false); 473 + assert!(s.remove("Clone*")); 474 + s.seed_config(&[("#x", "y")]); // no panic, no disk 475 + } 476 + 477 + proptest::proptest! { 478 + #![proptest_config(proptest::test_runner::Config::with_cases(24))] 479 + 480 + /// After an arbitrary add/remove sequence on a file-backed store, **reopening** yields 481 + /// exactly the live reservation set that was in memory before the close. 482 + #[test] 483 + fn the_persisted_set_matches_after_reopen( 484 + ops in proptest::collection::vec( 485 + (proptest::bool::ANY, "[#a-c*]{1,4}"), 486 + 1..14, 487 + ), 488 + ) { 489 + use std::collections::BTreeSet; 490 + let path = unique_db_path("fuzz"); 491 + cleanup(&path); 492 + let ps = path.to_str().unwrap(); 493 + // (mask_lc, channel) is the persisted identity. 494 + let in_memory: BTreeSet<(String, bool)> = { 495 + let s = ResvStore::open(ps).unwrap(); 496 + for (is_add, mask) in &ops { 497 + if *is_add { 498 + s.add(mask, "r", is_channel_target(mask)); 499 + } else { 500 + s.remove(mask); 501 + } 502 + } 503 + s.entries() 504 + .iter() 505 + .map(|r| (r.mask.to_ascii_lowercase(), r.channel)) 506 + .collect() 507 + }; 508 + let reloaded: BTreeSet<(String, bool)> = ResvStore::open(ps) 509 + .unwrap() 510 + .entries() 511 + .iter() 512 + .map(|r| (r.mask.to_ascii_lowercase(), r.channel)) 513 + .collect(); 514 + cleanup(&path); 515 + proptest::prop_assert_eq!(reloaded, in_memory); 516 + } 279 517 } 280 518 281 519 #[test]
+106 -4
leveva/src/server.rs
··· 329 329 preload: cfg.options.sts_preload, 330 330 }, 331 331 }, 332 - klines: KlineStore::new(), 332 + // Temporary K-lines (slice 282): persist to the `database {}` file if configured, so 333 + // an operator's bans survive a restart; else an in-memory-only store. An open failure 334 + // is logged and falls back to in-memory rather than aborting boot. 335 + klines: match &cfg.database { 336 + None => KlineStore::new(), 337 + Some(db) => match KlineStore::open(&db.path) { 338 + Ok(s) => { 339 + tracing::info!(path = %db.path, "persistent K-lines enabled"); 340 + s 341 + } 342 + Err(e) => { 343 + tracing::error!(path = %db.path, error = %e, 344 + "cannot open K-line database; K-lines in-memory only"); 345 + KlineStore::new() 346 + } 347 + }, 348 + }, 333 349 net: crate::s2s::Network::new(), 334 350 peers: crate::s2s::PeerLinks::new(), 335 351 auth: AuthConfig { ··· 342 358 linking: crate::link::LinkingSet::new(), 343 359 knock_throttle: crate::knock_throttle::KnockThrottle::new(), 344 360 knock_user_throttle: crate::knock_throttle::KnockThrottle::new(), 345 - // Seed the default `resv {}` reservations (slice 272). `REHASH` re-seeds via 346 - // `command::rehash` (replacing this config-sourced set, keeping runtime `RESV`s). 361 + // Reservations: persist runtime `RESV`s to the `database {}` file if configured (slice 362 + // 282; `open` loads the stored runtime reservations), else an in-memory store. Then seed 363 + // the config `resv {}` defaults (slice 272) **alongside** the loaded runtime ones — 364 + // `seed_config` retains the runtime entries. `REHASH` re-seeds via `command::rehash` 365 + // (replacing the config-sourced set, keeping runtime `RESV`s). 347 366 resvs: { 348 - let store = crate::resv::ResvStore::new(); 367 + let store = match &cfg.database { 368 + None => crate::resv::ResvStore::new(), 369 + Some(db) => match crate::resv::ResvStore::open(&db.path) { 370 + Ok(s) => { 371 + tracing::info!(path = %db.path, "persistent reservations enabled"); 372 + s 373 + } 374 + Err(e) => { 375 + tracing::error!(path = %db.path, error = %e, 376 + "cannot open reservation database; reservations in-memory only"); 377 + crate::resv::ResvStore::new() 378 + } 379 + }, 380 + }; 349 381 let entries: Vec<(String, String)> = cfg 350 382 .resvs 351 383 .iter() ··· 404 436 #[test] 405 437 fn version_is_leveva_prefixed() { 406 438 assert!(VERSION.starts_with("leveva-")); 439 + } 440 + 441 + /// End-to-end (slice 282): a `database {}` block wires write-through persistence into the 442 + /// `klines`/`resvs` stores, so a ban/reservation laid on one boot is reloaded on the next. 443 + #[test] 444 + fn a_database_block_persists_klines_and_resvs_across_a_reboot() { 445 + use std::sync::atomic::{AtomicU64, Ordering}; 446 + static N: AtomicU64 = AtomicU64::new(0); 447 + let n = N.fetch_add(1, Ordering::Relaxed); 448 + let path = std::env::temp_dir().join(format!( 449 + "leveva-ctx-persist-{}-{n}.db", 450 + std::process::id() 451 + )); 452 + let _ = std::fs::remove_file(&path); 453 + let kdl = format!( 454 + r#" 455 + server {{ name "leveva.test"; description "T"; sid "0ABC" }} 456 + admin {{ name "A"; email "a@test"; network "Net" }} 457 + class "c" {{ ping-freq 90; max-links 10; sendq 1000 }} 458 + database {{ path "{}" }} 459 + "#, 460 + path.display() 461 + ); 462 + let cfg = crate::config::Config::from_kdl(&kdl).expect("valid config"); 463 + 464 + // Boot 1: lay a kline and a resv (both runtime), then drop the context (a "restart"). 465 + { 466 + let ctx = ServerContext::from_config(&cfg, "now".into(), None); 467 + ctx.klines 468 + .add("bob", "*.evil.net", "spam", crate::kline::now_secs() + 3600); 469 + ctx.resvs.add("Clone*", "no clones", false); 470 + } 471 + 472 + // Boot 2: a fresh context from the same config reloads both from disk. 473 + let ctx2 = ServerContext::from_config(&cfg, "now".into(), None); 474 + assert!( 475 + ctx2.klines 476 + .find_active("bob", "mail.evil.net", crate::kline::now_secs()) 477 + .is_some(), 478 + "the kline reloaded across the reboot" 479 + ); 480 + assert!( 481 + ctx2.resvs.nick_reserved("Clone7").is_some(), 482 + "the reservation reloaded across the reboot" 483 + ); 484 + 485 + let _ = std::fs::remove_file(&path); 486 + let _ = std::fs::remove_file(format!("{}-wal", path.display())); 487 + let _ = std::fs::remove_file(format!("{}-shm", path.display())); 488 + } 489 + 490 + /// Inverse: with **no** `database {}` block the stores are in-memory only — nothing persists, 491 + /// and a second boot starts clean (no panic, no shared file). 492 + #[test] 493 + fn without_a_database_block_nothing_persists() { 494 + let kdl = r#" 495 + server { name "leveva.test"; description "T"; sid "0ABC" } 496 + admin { name "A"; email "a@test"; network "Net" } 497 + class "c" { ping-freq 90; max-links 10; sendq 1000 } 498 + "#; 499 + let cfg = crate::config::Config::from_kdl(kdl).expect("valid config"); 500 + { 501 + let ctx = ServerContext::from_config(&cfg, "now".into(), None); 502 + ctx.klines.add("bob", "h", "x", crate::kline::now_secs() + 3600); 503 + ctx.resvs.add("Clone*", "x", false); 504 + } 505 + let ctx2 = ServerContext::from_config(&cfg, "now".into(), None); 506 + assert!(ctx2.klines.is_empty(), "no persistence without a database block"); 507 + // The resv store still seeds any config reservations (none here) but no runtime ones. 508 + assert!(ctx2.resvs.runtime_entries().is_empty()); 407 509 } 408 510 }