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

Configure Feed

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

feat(leveva): message redaction deletes the chat-history line — P11 slice 275

Wire IRCv3 message-redaction (REDACT) to the slice-274 chat-history store:
an accepted channel `REDACT <chan> <msgid>` now deletes the stored line so
CHATHISTORY can never replay it. Closes the gap left by slice 274, where REDACT
was a pure relay and command/redact.rs still claimed "leveva keeps no message
history".

- history.rs: new History::delete_by_msgid(target, msgid) -> usize (DELETE WHERE
folded-target + msgid; no-op when disabled/unknown). Unit tests + a proptest
fuzzing an arbitrary redacted subset.
- command/redact.rs: delete in the channel CanSend::Ok arm only; refresh the
stale "no history" docstring (still a stateless authorization relay, but the
storage side now deletes).
- s2s/redact.rs: delete in the plain-channel inbound branch of apply_encap_redact
so each server drops its own recorded copy.

Load-bearing boundary: deletion mirrors recording (full-delivery only). A +z
op-moderated REDACT — local opmod broadcast and inbound @<chan> op-redirect — is
"shown to ops", never recorded, so never deleted. Nick/DM targets aren't channel
history.

Gate: history/command::redact/s2s::redact units; golden_chathistory end-to-end
(learn a msgid, REDACT it, re-query -> gone, sibling survives); two new
redact_proptest command-path fuzzes (random subset via real Session +
rejected-non-member-deletes-nothing). cargo clippy -p leveva --tests clean;
full cargo test -p leveva green.

+490 -7
+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: 274 slices** (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: 275 slices** (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 ---
+2
docs/claude-memory/leveva-chathistory.md
··· 25 25 **Gotchas:** every hand-rolled `ServerContext` literal (testutil + ~60 `leveva/tests/*.rs`) needs `history: History::disabled()`. `examples/ircd.kdl` must reference every parser field AND `dist/ircd.kdl` must be a verbatim copy (two config tests). `help/CHATHISTORY.md` required + `COMMANDS` list in `help.rs`. See [[help-files-per-command]], [[leveva-command-folder]], [[leveva-ircv3-track]], [[leveva-message-tags-seams]]. 26 26 27 27 Gate: `golden_chathistory.rs` (end-to-end) + `chathistory_proptest.rs` + the module units; clippy clean; full `cargo test -p leveva` green. 28 + 29 + **Slice 275 — REDACT deletes the stored line.** `History::delete_by_msgid(target, msgid)` (DELETE WHERE folded-target+msgid, no-op when disabled). Called in `command/redact.rs` (channel `CanSend::Ok` arm only) AND `s2s/redact.rs::apply_encap_redact` (plain-channel inbound branch) so each server deletes its own copy. **Load-bearing boundary:** deletion mirrors recording = full-delivery only — a `+z` op-moderated REDACT (local `local_opmod_redact_channel` / inbound `@<chan>` op-redirect) is "shown to ops", never recorded → never deleted; nick/DM targets aren't channel history. Authorization stays a stateless relay (no UNKNOWN_MSGID etc.); only storage gains state. The old `command/redact.rs` "leveva keeps no message history" docstring was stale — rewritten. Gate: `golden_chathistory.rs::redact_deletes_the_line_from_chathistory` + 2 new `redact_proptest.rs` command-path fuzzes + `history`/`redact`/`s2s::redact` units. See [[leveva-s2s-notify-propagation]] (REDACT/ENCAP plane).
+27
docs/progress-log/p11.md
··· 15133 15133 - **Command (`command/chathistory.rs`).** Full `LATEST/BEFORE/AFTER/BETWEEN/AROUND/TARGETS`; `INVALID_TARGET` (non-member or non-channel), `INVALID_PARAMS` (bad sub/limit/timestamp), `INVALID_MSGREFTYPE` (unknown reftype) FAILs; `MESSAGE_ERROR` when disabled. Reply is a `chathistory`/`draft/chathistory-targets` batch (or bare lines for a non-`batch` client), `draft/chathistory-end` on the open when the page isn't full. `event-playback` gates the non-message lines. 15134 15134 - **Recording + replay.** Channel PRIVMSG/NOTICE recorded in `command::message` (CanSend::Ok arm, sharing the per-event msgid + nanos); JOIN in `join_one`; PART in `part_one` (shared by SAPART); QUIT in `session::release` (one row per channel). `replay_on_join` (called from `join_one`) auto-replays `replay-lines` PRIVMSG/NOTICE to a local joiner's mailbox unless it negotiated `draft/chathistory` (spec exemption) — bypasses labeled-wrap, lands after the JOIN/NAMES burst. `main.rs` `history_prune_loop` hourly age-prune when `retention-days > 0`. 15135 15135 - **Gate.** `history`/`clock`/`message` serde + `cap`/`isupport`/`config` units; `command::chathistory` units; `golden_chathistory.rs` (ISUPPORT/CAP LS/auto-replay bare+batched/CHATHISTORY LATEST+tags+end/chathistory-client-exempt/non-member FAIL end-to-end through the real binary); `chathistory_proptest.rs` (selector totality, query bounds/order, persistence round-trip, prune). Example/dist `ircd.kdl` gained the `database {}` block; `help/CHATHISTORY.md` added. `cargo clippy -p leveva --tests` clean; full `cargo test -p leveva` green. 15136 + 15137 + - **2026-06-19 — P11 slice 275 (REDACT deletes the chat-history line) merged.** Wired IRCv3 15138 + `message-redaction` to the slice-274 chat-history store: an accepted channel `REDACT <chan> 15139 + <msgid>` now deletes the stored line so it can never be replayed via `CHATHISTORY`. Closes the 15140 + gap left by slice 274 (REDACT was a pure relay; `command/redact.rs` still claimed "leveva keeps 15141 + no message history"). 15142 + - **Storage (`history.rs`).** New `History::delete_by_msgid(target, msgid) -> usize` — `DELETE 15143 + FROM history WHERE target=?1 AND msgid=?2`, folds the target to the stored key, no-op (returns 15144 + 0) when disabled / unknown msgid / wrong channel. Unit tests (matching-row-only, case-insensitive 15145 + target, disabled no-op) + a proptest (`delete_by_msgid_removes_exactly_the_redacted_subset`: 15146 + delete an arbitrary subset, assert each redacted id vanishes from LATEST + is unrecoverable as a 15147 + CHATHISTORY anchor while every survivor stays, ascending). 15148 + - **Local path (`command/redact.rs`).** Call `ctx.history.delete_by_msgid(target, msgid)` in the 15149 + channel `CanSend::Ok` arm only (after the live fan + S2S broadcast). Stale docstring rewritten: 15150 + leveva is still a stateless *authorization* relay, but the *storage* side now deletes. 15151 + - **Inbound S2S (`s2s/redact.rs`).** Call it in the plain-channel branch of `apply_encap_redact` 15152 + so every server an `ENCAP * REDACT #chan` reaches deletes its own recorded copy. 15153 + - **The semantic boundary.** Deletion mirrors recording, which only happens on full delivery 15154 + (`CanSend::Ok`): a `+z` op-moderated REDACT — local `local_opmod_redact_channel` and inbound 15155 + `@<chan>` op-redirect — is a "shown to ops" attempt, never recorded and therefore never deleted. 15156 + Nick/DM targets are channel-history-exempt, so no deletion there. 15157 + - **Gate.** `history`/`command::redact`/`s2s::redact` units green; 15158 + `golden_chathistory.rs::redact_deletes_the_line_from_chathistory` (end-to-end through the real 15159 + binary: learn a msgid via `CHATHISTORY LATEST`, `REDACT` it, re-query → gone, sibling survives); 15160 + `redact_proptest.rs` gained two command-path fuzzes (`redact_deletes_exactly_the_named_channel_msgids` 15161 + via a real `Session`, + `rejected_channel_redact_never_deletes` — a non-member of a `+m` channel 15162 + deletes nothing). `cargo clippy -p leveva --tests` clean; full `cargo test -p leveva` green.
+54
docs/superpowers/plans/2026-06-19-p11-slice275-redact-deletes-history.md
··· 1 + # P11 slice 275 — message redaction deletes the chat-history line 2 + 3 + ## Goal 4 + 5 + Wire IRCv3 `message-redaction` (`REDACT`) to the slice-274 chat-history store: an accepted channel 6 + `REDACT <chan> <msgid>` deletes the stored line so it can never be replayed via `CHATHISTORY`. 7 + Before this slice, REDACT was a pure relay and `command/redact.rs` carried a now-stale "leveva keeps 8 + no message history" docstring. 9 + 10 + ## The semantic boundary (load-bearing) 11 + 12 + Recording happens **only on full delivery** — `command::message` records in the `CanSend::Ok` arm, 13 + *not* in the `+z` op-moderation arm (a muted/banned user's blocked message is shown to ops only and 14 + never recorded). Deletion mirrors this exactly: 15 + 16 + - **Delete** on a fully-accepted redaction: local origin `CanSend::Ok`, and inbound 17 + `ENCAP * REDACT #chan <id>` (the plain-channel form). 18 + - **Do NOT delete** on the `+z` op-redirect (`@<chan>`) form — it is a "shown to ops" attempt, not 19 + an applied redaction; the corresponding message was never recorded, so there is nothing to delete 20 + and deleting would be wrong. 21 + - Nick/DM targets are never recorded (history is channel-only), so no deletion there. 22 + 23 + Each server holds the history its own delivery plane recorded, so deletion is per-server: the 24 + originating server deletes in `command::redact`; every server an `ENCAP * REDACT` reaches deletes 25 + independently in `s2s::redact::apply_encap_redact`. Keyed on `(folded target, msgid)`. A delete on a 26 + missing msgid / disabled store is a silent no-op — redaction stays a best-effort relay. 27 + 28 + ## Changes 29 + 30 + 1. `history.rs` — `History::delete_by_msgid(&self, target, msgid) -> usize` (DELETE WHERE 31 + target=? AND msgid=?, folds target, no-op when disabled). Unit tests + a proptest 32 + (`delete_by_msgid_removes_exactly_the_redacted_subset`). 33 + 2. `command/redact.rs` — call `ctx.history.delete_by_msgid(target, msgid)` in the channel 34 + `CanSend::Ok` arm; refresh the stale docstring. Tests: deletes-the-line + rejected-redact- 35 + deletes-nothing. 36 + 3. `s2s/redact.rs` — call it in the plain-channel inbound branch of `apply_encap_redact` (not the 37 + `@<chan>` op-redirect). Tests: inbound-deletes + opmod-does-not-delete. 38 + 39 + ## Gate 40 + 41 + - `cargo test -p leveva --lib history:: / command::redact:: / s2s::redact::` green. 42 + - `golden_chathistory.rs::redact_deletes_the_line_from_chathistory` — end-to-end through the real 43 + binary: a client records two lines, learns a msgid via `CHATHISTORY LATEST`, `REDACT`s one, and a 44 + fresh `CHATHISTORY LATEST` no longer carries it while the other survives. 45 + - `redact_proptest.rs` — command-path fuzz: `redact_deletes_exactly_the_named_channel_msgids` 46 + (random redact subset via real `Session`) + `rejected_channel_redact_never_deletes` (a non-member 47 + of a `+m` channel deletes nothing). 48 + - `cargo clippy -p leveva --tests` clean. 49 + 50 + ## Divergences 51 + 52 + Still leveva-native — no oracle (IRCnet has no `message-redaction`/chat-history). Authorization 53 + remains a stateless relay (no authorship/window validation, no `UNKNOWN_MSGID`/`REDACT_FORBIDDEN`); 54 + only the *storage* side gains state.
+94 -6
leveva/src/command/redact.rs
··· 7 7 /// forwards `:sender!user@host REDACT <target> <msgid> [<reason>]` to every recipient that 8 8 /// negotiated `draft/message-redaction`, and to **no** recipient that hasn't (spec MUST NOT). 9 9 /// 10 - /// leveva keeps **no message history** (msgids are an in-memory counter, slice 66), so it is a 11 - /// **stateless relay**: it does no authorship / permission / msgid-existence validation and never 12 - /// emits `UNKNOWN_MSGID`/`REDACT_FORBIDDEN`/`REDACT_WINDOW_EXPIRED`. This is the spec's explicit 13 - /// relay model — a server "which did not keep [the message] in their history" relays `REDACT`, 14 - /// and clients ignore an unknown msgid. The one reachable error is `INVALID_TARGET` (the target 15 - /// can't be messaged), emitted as the standard-replies `FAIL REDACT INVALID_TARGET <target> :…`. 10 + /// For **authorization** leveva is a **stateless relay**: it does no authorship / permission / 11 + /// redaction-window validation and never emits 12 + /// `UNKNOWN_MSGID`/`REDACT_FORBIDDEN`/`REDACT_WINDOW_EXPIRED`. This is the spec's explicit relay 13 + /// model — a server "which did not keep [the message] in their history" relays `REDACT`, and 14 + /// clients ignore an unknown msgid. The one reachable error is `INVALID_TARGET` (the target can't 15 + /// be messaged), emitted as the standard-replies `FAIL REDACT INVALID_TARGET <target> :…`. 16 + /// 17 + /// For **storage** leveva *does* now keep channel chat-history (slice 274), so an accepted channel 18 + /// redaction also **deletes the stored line from chat-history** (slice 275) — keyed by 19 + /// `(target, msgid)` — so it can never be replayed via [`CHATHISTORY`](crate::command::chathistory). 20 + /// This mirrors recording, which happens only on full delivery: a `+z` op-moderated attempt (shown 21 + /// to ops only, never recorded) is never deleted. The delete is a best-effort no-op when the msgid 22 + /// is not in this server's store (a remote-authored line, a nick/DM target, or history disabled). 16 23 /// 17 24 /// Divergences (see the slice-68 plan): the sender-side `MUST negotiate` is not enforced (any 18 25 /// registered client may send `REDACT`, the TAGMSG precedent). Delivery propagates over the ··· 77 84 } 78 85 } 79 86 crate::s2s::redact::local_redact_channel(ctx, &client.uid, target, msgid, reason); 87 + // Storage side (slice 275): an accepted channel redaction deletes the line from 88 + // chat-history so it can never be replayed via CHATHISTORY. Mirrors recording, 89 + // which only happens on full delivery (`CanSend::Ok`) — a `+z` op-moderated 90 + // attempt below was never recorded, so it is never deleted. A no-op if the msgid 91 + // isn't in our store (a remote-authored line, history disabled, or unknown id). 92 + ctx.history.delete_by_msgid(target, msgid); 80 93 Vec::new() 81 94 } 82 95 CanSend::External | CanSend::Moderated | CanSend::Banned => { ··· 160 173 use crate::cap::ClientCaps; 161 174 use crate::command::dispatch; 162 175 use crate::command::testutil::*; 176 + 177 + /// A context with an in-memory chat-history database enabled (slice 275 deletion path). 178 + fn ctx_hist() -> std::sync::Arc<crate::server::ServerContext> { 179 + let cfg = crate::config::Config::from_kdl( 180 + r#" 181 + server { name "leveva.test"; description "T"; sid "0ABC" } 182 + admin { name "A"; email "a@test"; network "TestNet" } 183 + class "c" { ping-freq 90; max-links 10; sendq 1000 } 184 + options { default-channel-modes "+" } 185 + database { path ":memory:"; replay-lines 5; max-query 50 } 186 + "#, 187 + ) 188 + .unwrap(); 189 + crate::server::ServerContext::from_config(&cfg, "FIXED".to_string(), None) 190 + } 191 + 192 + /// Record a channel PRIVMSG into history under `id`. 193 + fn seed_history(ctx: &crate::server::ServerContext, chan: &str, id: &str, text: &str) { 194 + let m = crate::Message::builder("PRIVMSG") 195 + .prefix("alice!alice@127.0.0.1") 196 + .param(chan) 197 + .trailing(text) 198 + .build(); 199 + ctx.history.record(chan, 1000, id, "PRIVMSG", &m); 200 + } 201 + 202 + /// An accepted channel REDACT deletes the matching line from chat-history (slice 275) while 203 + /// leaving every other recorded line intact. 204 + #[test] 205 + fn redact_to_channel_deletes_the_stored_line() { 206 + let ctx = ctx_hist(); 207 + ctx.channels.join("#rust", &uid_for("alice"), 0); // alice creates → op, can send 208 + seed_history(&ctx, "#rust", "123", "secret"); 209 + seed_history(&ctx, "#rust", "456", "kept"); 210 + assert_eq!(ctx.history.latest("#rust", None, 10).len(), 2); 211 + 212 + let r = dispatch( 213 + &mut client(), 214 + &crate::Message::parse("REDACT #rust 123 :oops").unwrap(), 215 + &ctx, 216 + ); 217 + assert!(r.is_empty()); 218 + // The redacted line is gone; the unrelated line survives (the inverse). 219 + let ids: Vec<String> = ctx 220 + .history 221 + .latest("#rust", None, 10) 222 + .iter() 223 + .map(|e| e.msgid.clone()) 224 + .collect(); 225 + assert_eq!(ids, vec!["456"], "only msgid 123 deleted"); 226 + } 227 + 228 + /// Inverse storage check: a REDACT the channel **rejects** (`FAIL INVALID_TARGET`) must NOT 229 + /// delete anything — a non-member of a moderated channel cannot redact its history. 230 + #[test] 231 + fn rejected_channel_redact_deletes_nothing() { 232 + let ctx = ctx_hist(); 233 + // bob creates #mod (→ op), it goes +m; alice (a non-member) cannot send there. 234 + let _bob = claim_observed(&ctx, "bob"); 235 + ctx.channels.join("#mod", &uid_for("bob"), 0); 236 + force_flag(&ctx, "#mod", "bob", crate::mode::ChanMode::Moderated); 237 + seed_history(&ctx, "#mod", "123", "secret"); 238 + 239 + let r = dispatch( 240 + &mut client(), // alice, not in #mod, muted 241 + &crate::Message::parse("REDACT #mod 123").unwrap(), 242 + &ctx, 243 + ); 244 + assert_eq!(r[0].params()[1], "INVALID_TARGET"); 245 + assert_eq!( 246 + ctx.history.latest("#mod", None, 10).len(), 247 + 1, 248 + "a rejected redaction leaves history untouched" 249 + ); 250 + } 163 251 164 252 /// Claim `nick` with `draft/message-redaction` negotiated, returning its observable mailbox. 165 253 fn claim_redaction_consumer(
+90
leveva/src/history.rs
··· 220 220 } 221 221 } 222 222 223 + /// Delete the row(s) for channel `target` whose stored network `msgid` matches `msgid` — the 224 + /// storage side of IRCv3 [message-redaction](crate::command::redact) (slice 275). A 225 + /// `REDACT <chan> <msgid>` that leveva accepts for delivery removes the line from history so it 226 + /// can never be replayed via [`CHATHISTORY`](crate::command::chathistory) (nor re-located as a 227 + /// `msgid=` anchor). Keyed on `(folded target, msgid)` to mirror the stored/queried key. 228 + /// Returns the number of rows removed; `0` when disabled, the msgid is unknown, or the target 229 + /// has no history — redaction is a best-effort relay, never a hard error on a missing line. 230 + pub fn delete_by_msgid(&self, target: &str, msgid: &str) -> usize { 231 + let Some(lock) = &self.inner else { return 0 }; 232 + let conn = lock.lock().unwrap(); 233 + match conn.execute( 234 + "DELETE FROM history WHERE target=?1 AND msgid=?2", 235 + params![fold(target), msgid], 236 + ) { 237 + Ok(n) => n, 238 + Err(e) => { 239 + tracing::warn!(error = %e, target, "chathistory: redact delete failed"); 240 + 0 241 + } 242 + } 243 + } 244 + 223 245 /// Delete every row older than `cutoff_nanos` (the age-based retention sweep). Returns the 224 246 /// number of rows removed; `0` when disabled or nothing matched. 225 247 pub fn prune_older_than(&self, cutoff_nanos: u64) -> usize { ··· 631 653 } 632 654 633 655 #[test] 656 + fn delete_by_msgid_removes_the_matching_row_only() { 657 + let h = mem(); 658 + for (i, t) in ["a", "b", "c"].iter().enumerate() { 659 + rec(&h, "#c", (i as u64 + 1) * 1000, &format!("id{i}"), t); 660 + } 661 + // Redacting "id1" (the "b" row) removes exactly that row… 662 + assert_eq!(h.delete_by_msgid("#c", "id1"), 1); 663 + assert_eq!(bodies(&h.latest("#c", None, 10)), vec!["a", "c"]); 664 + // …and it can never be re-located by its msgid (the CHATHISTORY anchor is gone too). 665 + assert!(h.after("#c", &Selector::Msgid("id1".into()), 10).is_empty()); 666 + // Inverse: a second delete of the same id, an unknown id, and a wrong-channel id are 667 + // all silent no-ops that remove nothing. 668 + assert_eq!(h.delete_by_msgid("#c", "id1"), 0); 669 + assert_eq!(h.delete_by_msgid("#c", "nope"), 0); 670 + assert_eq!(h.delete_by_msgid("#other", "id0"), 0); 671 + assert_eq!(bodies(&h.latest("#c", None, 10)), vec!["a", "c"]); 672 + } 673 + 674 + #[test] 675 + fn delete_by_msgid_is_case_insensitive_in_the_target() { 676 + let h = mem(); 677 + rec(&h, "#Rust", 1000, "id0", "hi"); 678 + // A differently-cased target folds to the same key, exactly like recording/querying. 679 + assert_eq!(h.delete_by_msgid("#RUST", "id0"), 1); 680 + assert!(h.latest("#rust", None, 10).is_empty()); 681 + } 682 + 683 + #[test] 684 + fn delete_by_msgid_on_disabled_store_is_a_silent_noop() { 685 + let h = History::disabled(); 686 + assert_eq!(h.delete_by_msgid("#c", "id0"), 0); // no panic 687 + } 688 + 689 + #[test] 634 690 fn prune_removes_old_rows_only() { 635 691 let h = mem(); 636 692 rec(&h, "#c", 1000, "id0", "old"); ··· 731 787 let got_bodies: Vec<String> = 732 788 got.iter().map(|e| e.message.trailing().unwrap().to_string()).collect(); 733 789 prop_assert_eq!(got_bodies, bodies); 790 + } 791 + 792 + /// Redact (delete) an arbitrary subset of recorded msgids: every redacted id vanishes 793 + /// (no longer in LATEST, no longer locatable as a CHATHISTORY anchor) while every 794 + /// un-redacted id survives, and the surviving set stays ascending — the storage 795 + /// invariant the REDACT→delete wiring (slice 275) depends on. 796 + #[test] 797 + fn delete_by_msgid_removes_exactly_the_redacted_subset( 798 + n in 1usize..25, 799 + redact in proptest::collection::vec(any::<bool>(), 25), 800 + ) { 801 + let h = mem(); 802 + for i in 0..n { 803 + rec(&h, "#c", (i as u64 + 1) * 1000, &format!("id{i}"), "x"); 804 + } 805 + let mut survivors = Vec::new(); 806 + for (i, &drop) in redact.iter().take(n).enumerate() { 807 + let id = format!("id{i}"); 808 + if drop { 809 + prop_assert_eq!(h.delete_by_msgid("#c", &id), 1, "redacted id removed"); 810 + } else { 811 + survivors.push(id); 812 + } 813 + } 814 + // The surviving msgids are exactly the un-redacted ones, in order. 815 + let got: Vec<String> = h.latest("#c", None, 1000).iter().map(|e| e.msgid.clone()).collect(); 816 + prop_assert_eq!(&got, &survivors); 817 + // Every redacted id is unrecoverable as an anchor. 818 + for (i, &drop) in redact.iter().take(n).enumerate() { 819 + let id = format!("id{i}"); 820 + if drop { 821 + prop_assert!(h.around("#c", &Selector::Msgid(id.clone()), 5).iter().all(|e| e.msgid != id)); 822 + } 823 + } 734 824 } 735 825 736 826 /// For any two timestamp anchors, BEFORE and AFTER partition the set around the anchor
+69
leveva/src/s2s/redact.rs
··· 177 177 } 178 178 } 179 179 } 180 + // Storage side (slice 275): drop our local copy of the redacted line so this server's 181 + // CHATHISTORY can never replay it. Each server holds the history its own delivery plane 182 + // recorded, so every server an `ENCAP * REDACT` reaches deletes independently. The 183 + // `@<chan>` op-redirect branch above returned early — a `+z` attempt was never recorded, 184 + // so it is never deleted (symmetric with the originating server). No-op when our store 185 + // lacks the msgid. 186 + ctx.history.delete_by_msgid(target, msgid); 180 187 } else if let Some(uid) = ctx.registry.uid_of(target) { 181 188 // Only a *local* nick that negotiated the cap is delivered here; a remote target is 182 189 // reached by the caller's onward relay. ··· 218 225 "#; 219 226 let cfg = crate::config::Config::from_kdl(kdl).expect("valid test config"); 220 227 ServerContext::from_config(&cfg, "now".to_string(), None) 228 + } 229 + 230 + /// As [`ctx`] but with an in-memory chat-history store (slice 275 inbound deletion path). 231 + fn ctx_hist() -> Arc<ServerContext> { 232 + let kdl = r#" 233 + server { name "leveva.test"; description "Leveva Test"; sid "0ABC" } 234 + admin { name "A"; email "a@test"; network "Net" } 235 + class "c" { ping-freq 90; max-links 10; sendq 1000 } 236 + database { path ":memory:"; replay-lines 5; max-query 50 } 237 + "#; 238 + let cfg = crate::config::Config::from_kdl(kdl).expect("valid test config"); 239 + ServerContext::from_config(&cfg, "now".to_string(), None) 240 + } 241 + 242 + /// Record a channel PRIVMSG into history under `id`. 243 + fn seed_history(ctx: &ServerContext, chan: &str, id: &str) { 244 + let m = Message::builder("PRIVMSG") 245 + .prefix("rover!ruser@r.host") 246 + .param(chan) 247 + .trailing("secret") 248 + .build(); 249 + ctx.history.record(chan, 1000, id, "PRIVMSG", &m); 221 250 } 222 251 223 252 fn link_peer( ··· 478 507 // A cap-less op and a (capable) non-op member both get nothing. 479 508 assert!(plain_op_rx.try_recv().is_err(), "cap-less op gets nothing"); 480 509 assert!(member_rx.try_recv().is_err(), "non-op member gets nothing"); 510 + } 511 + 512 + /// Inbound (slice 275): an `ENCAP * REDACT #chan <msgid>` deletes this server's local copy of 513 + /// the line from chat-history, so its CHATHISTORY can never replay it. The unrelated line is 514 + /// left intact (the inverse). 515 + #[test] 516 + fn apply_encap_channel_redact_deletes_the_local_history_row() { 517 + let ctx = ctx_hist(); 518 + let (peer, _prx) = link_peer(&ctx, "0XYZ", "a.peer.test"); 519 + let _rover = introduce(&ctx, &peer, "0XYZAAAAA", "rover"); 520 + seed_history(&ctx, "#c", "123"); 521 + seed_history(&ctx, "#c", "456"); 522 + 523 + apply_encap_redact(&peer, &parse(":0XYZAAAAA ENCAP * REDACT #c 123 :oops"), &ctx); 524 + 525 + let ids: Vec<String> = ctx 526 + .history 527 + .latest("#c", None, 10) 528 + .iter() 529 + .map(|e| e.msgid.clone()) 530 + .collect(); 531 + assert_eq!(ids, vec!["456"], "only the redacted msgid is deleted locally"); 532 + } 533 + 534 + /// Inverse (slice 275): the `@<chan>` `+z` op-redirect form is a "shown to ops" attempt, not 535 + /// an applied redaction — it must NOT delete the stored line (symmetric with the originating 536 + /// server, which never recorded a `+z`-blocked message). 537 + #[test] 538 + fn apply_encap_opmod_redact_does_not_delete_history() { 539 + let ctx = ctx_hist(); 540 + let (peer, _prx) = link_peer(&ctx, "0XYZ", "a.peer.test"); 541 + let _rover = introduce(&ctx, &peer, "0XYZAAAAA", "rover"); 542 + seed_history(&ctx, "#c", "123"); 543 + 544 + apply_encap_redact(&peer, &parse(":0XYZAAAAA ENCAP * REDACT @#c 123 :oops"), &ctx); 545 + assert_eq!( 546 + ctx.history.latest("#c", None, 10).len(), 547 + 1, 548 + "an op-redirect attempt leaves history intact" 549 + ); 481 550 } 482 551 483 552 #[test]
+60
leveva/tests/golden_chathistory.rs
··· 194 194 ); 195 195 } 196 196 197 + /// Extract the `msgid=` tag of the first replayed line whose body contains `needle`. 198 + fn msgid_of(blob: &str, needle: &str) -> String { 199 + let line = blob 200 + .lines() 201 + .find(|l| l.contains(needle) && l.contains("msgid=")) 202 + .unwrap_or_else(|| panic!("no tagged line containing {needle:?} in:\n{blob}")); 203 + let after = &line[line.find("msgid=").unwrap() + "msgid=".len()..]; 204 + after 205 + .split([';', ' ']) 206 + .next() 207 + .expect("msgid value") 208 + .to_string() 209 + } 210 + 211 + /// End-to-end (slice 275): a `REDACT <chan> <msgid>` deletes the line from chat-history, so a 212 + /// later `CHATHISTORY LATEST` no longer replays it — while the unredacted line survives. 213 + #[test] 214 + fn redact_deletes_the_line_from_chathistory() { 215 + let dir = std::env::temp_dir().join(format!("leveva-chathist-redact-{}.kdl", std::process::id())); 216 + let _srv = boot_with_config_file(&dir, &config()); 217 + 218 + // bob negotiates the tags needed to read msgids back from CHATHISTORY. 219 + let mut bob = Client::connect(); 220 + bob.send("CAP LS 302"); 221 + bob.read_until(" LS :"); 222 + bob.send("CAP REQ :draft/chathistory batch message-tags server-time"); 223 + bob.read_until(" ACK "); 224 + bob.send("NICK bob"); 225 + bob.send("USER bob 0 * :Bob"); 226 + bob.send("CAP END"); 227 + bob.read_until(" 422 "); 228 + 229 + bob.send("JOIN #c"); 230 + bob.read_until(" 366 "); 231 + bob.send("PRIVMSG #c :alpha"); 232 + bob.send("PRIVMSG #c :bravo"); 233 + std::thread::sleep(std::time::Duration::from_millis(100)); 234 + 235 + // Learn the msgid of "alpha" from the history. 236 + bob.send("CHATHISTORY LATEST #c * 10"); 237 + let hist = bob.read_until("BATCH -"); 238 + let alpha_id = msgid_of(&hist, "PRIVMSG #c :alpha"); 239 + 240 + // Redact it, then give the server a moment to delete the row. 241 + bob.send(&format!("REDACT #c {alpha_id}")); 242 + std::thread::sleep(std::time::Duration::from_millis(100)); 243 + 244 + // A fresh CHATHISTORY LATEST no longer carries alpha; bravo is still there. 245 + bob.send("CHATHISTORY LATEST #c * 10"); 246 + let after = bob.read_until("BATCH -"); 247 + assert!( 248 + !after.contains("PRIVMSG #c :alpha"), 249 + "the redacted line must be gone from chat-history:\n{after}" 250 + ); 251 + assert!( 252 + after.contains("PRIVMSG #c :bravo"), 253 + "the unredacted line must survive:\n{after}" 254 + ); 255 + } 256 + 197 257 #[test] 198 258 fn chathistory_to_a_non_member_channel_fails() { 199 259 let dir = std::env::temp_dir().join(format!("leveva-chathist-fail-{}.kdl", std::process::id()));
+8
leveva/tests/redact_proptest.proptest-regressions
··· 1 + # Seeds for failure cases proptest has generated in the past. It is 2 + # automatically read and these particular cases re-run before any 3 + # novel cases are generated. 4 + # 5 + # It is recommended to check this file in to source control so that 6 + # everyone who runs the test benefits from these saved cases. 7 + cc 4e04530d0944219fb4fa32a518205908c050f720b76251556ada30e679f4c28b # shrinks to n = 1 8 + cc 012360bbe0dca65af485e223011d74ccb1d190a2ddf9f808be8fbe85c60d3d26 # shrinks to n = 1, redact = [false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false]
+85
leveva/tests/redact_proptest.rs
··· 50 50 }) 51 51 } 52 52 53 + /// As [`ctx`] but with an in-memory chat-history store (the slice-275 deletion path). 54 + fn ctx_hist() -> Arc<ServerContext> { 55 + let cfg = leveva::config::Config::from_kdl( 56 + r#" 57 + server { name "leveva.test"; description "T"; sid "0ABC" } 58 + admin { name "A"; email "a@test"; network "TestNet" } 59 + class "c" { ping-freq 90; max-links 10; sendq 1000 } 60 + options { default-channel-modes "+" } 61 + database { path ":memory:"; replay-lines 10; max-query 100 } 62 + "#, 63 + ) 64 + .unwrap(); 65 + ServerContext::from_config(&cfg, "FIXED".to_string(), None) 66 + } 67 + 53 68 /// Register a session holding `nick`, optionally negotiating `draft/message-redaction` first. 54 69 fn register( 55 70 ctx: &Arc<ServerContext>, ··· 134 149 let (mut alice, _arx) = register(&ctx_on, "alice", false); 135 150 let _ = alice.feed(format!("REDACT bob 1 :{reason}\r\n").as_bytes(), &ctx_on); 136 151 prop_assert_eq!(drain_redacts(&mut on_rx).len(), 1, "cap ⇒ one REDACT"); 152 + } 153 + 154 + /// Storage gate (slice 275): a member's accepted channel `REDACT #c <id>`, driven through a 155 + /// real `Session`, deletes exactly the recorded line(s) it names from chat-history — every 156 + /// redacted msgid vanishes from `CHATHISTORY LATEST`, every un-redacted one survives, and the 157 + /// command path never panics on the arbitrary redact subset. 158 + #[test] 159 + fn redact_deletes_exactly_the_named_channel_msgids( 160 + n in 1usize..16, 161 + redact in proptest::collection::vec(any::<bool>(), 16), 162 + ) { 163 + let ctx = ctx_hist(); 164 + let (mut bob, _brx) = register(&ctx, "bob", false); 165 + // bob creates #c (→ op) so his REDACTs are accepted (CanSend::Ok). 166 + let _ = bob.feed(b"JOIN #c\r\n", &ctx); 167 + 168 + // Seed n channel lines with deterministic msgids directly into history. 169 + for i in 0..n { 170 + let m = Message::builder("PRIVMSG") 171 + .prefix("bob!u@127.0.0.1") 172 + .param("#c") 173 + .trailing("x") 174 + .build(); 175 + ctx.history.record("#c", (i as u64 + 1) * 1000, &format!("id{i}"), "PRIVMSG", &m); 176 + } 177 + 178 + let mut survivors = Vec::new(); 179 + for (i, &drop) in redact.iter().take(n).enumerate() { 180 + if drop { 181 + let _ = bob.feed(format!("REDACT #c id{i}\r\n").as_bytes(), &ctx); 182 + } else { 183 + survivors.push(format!("id{i}")); 184 + } 185 + } 186 + 187 + // Only the seeded PRIVMSG lines (bob's own JOIN is also recorded, slice 274). 188 + let got: Vec<String> = ctx 189 + .history 190 + .latest("#c", None, 1000) 191 + .iter() 192 + .filter(|e| e.command == "PRIVMSG") 193 + .map(|e| e.msgid.clone()) 194 + .collect(); 195 + prop_assert_eq!(got, survivors, "only the redacted msgids are deleted"); 196 + } 197 + 198 + /// Inverse storage gate: a **non-member** of a `+m` channel cannot redact its history — every 199 + /// `REDACT` bounces `FAIL INVALID_TARGET` and the recorded line is left untouched. 200 + #[test] 201 + fn rejected_channel_redact_never_deletes(n in 1usize..8) { 202 + let ctx = ctx_hist(); 203 + // bob owns #m and sets it +m; alice (a non-member) is muted. 204 + let (mut bob, _brx) = register(&ctx, "bob", false); 205 + let _ = bob.feed(b"JOIN #m\r\nMODE #m +m\r\n", &ctx); 206 + let (mut alice, _arx) = register(&ctx, "alice", false); 207 + 208 + for i in 0..n { 209 + let m = Message::builder("PRIVMSG").prefix("bob!u@h").param("#m").trailing("x").build(); 210 + ctx.history.record("#m", (i as u64 + 1) * 1000, &format!("id{i}"), "PRIVMSG", &m); 211 + } 212 + for i in 0..n { 213 + let _ = alice.feed(format!("REDACT #m id{i}\r\n").as_bytes(), &ctx); 214 + } 215 + let kept = ctx 216 + .history 217 + .latest("#m", None, 1000) 218 + .iter() 219 + .filter(|e| e.command == "PRIVMSG") 220 + .count(); 221 + prop_assert_eq!(kept, n, "a rejected redaction leaves history intact"); 137 222 } 138 223 139 224 /// Panic-freedom: arbitrary bytes as the `<reason>` never panic; each delivered line is