# Fuzzing alkstore: Research and Plan **Status:** decided — adopted (2026-10-10), inserted as wave 7 in `docs/plans/implementation.md` (release readiness renumbered to 8). Targets not yet implemented; decomposition pending. Campaigns will run via the detached runner (§5) — **never as a foreground child of an agent session** (the family's §5 rule, inherited verbatim). **Date:** 2026-10-10 **Upstream precedent (inherited wholesale, not re-litigated):** - `/workspace/@alkdev/alkcall/docs/research/fuzzing.md` — the origin doc; one real finding (duplicate adopt/open destroying the live channel, fixed 0.8.1). - `/workspace/@alkdev/alktunnels/docs/research/fuzzing.md` — six harness-model findings, no crate bugs. - `/workspace/@alkdev/alksocks/docs/research/fuzzing.md` — the most recent full edition; this doc borrows its structure and §7's successful-negative-result disposition. - `/workspace/@alkdev/alktty/docs/plans/fuzzing.md` — the op-sequence-target pattern (`session_opseq`) for the deferred wave 2. Tooling, corpus policy, runner, and the no-hosted-CI posture come from those docs unchanged; this doc does not re-decide them, it scopes them to this crate. --- ## 1. Should this crate be fuzzed? **Yes — adopted 2026-10-10** (recorded in `implementation.md`'s decided points; AGENTS.md's prior no-fuzzing posture flips with this decision). Four lines: 1. **The family adopted it everywhere it had parse or arithmetic surfaces, and the pattern is mature.** Four sibling crates (alkcall, alktunnels, alksocks, alktty) run the identical layout: a `fuzz/` sidecar workspace (nightly pinned *inside* `fuzz/` only), thin `fuzz_target!` binaries, invariant logic in a stable-toolchain `fuzz/shared` crate so **corpus replay is plain `cargo test` — the fuzz gate** (which fits this repo's no-CI, coordinator-run-merge- gates posture exactly). The tooling and runner are copied wholesale; no new decisions are needed. 2. **This crate has already produced exactly the bug class fuzzing enumerates.** The wave-6 review gate caught three edge-domain defects by hand: the stream cursor read's `i64::MAX` overflow (`Bound::Excluded`), `trim_to`'s `i64::MAX` horizon endpoint, and the scheduler register path's first-boundary overflow rejection. All three live in hand-rolled arithmetic and parsing — and the crate deliberately owns *three* copies of that arithmetic (the `@every` parsers and boundary math per engine, ADR-012 §2's one-owner rule), plus the equal-jitter backoff curve and opts-stamping resolution per engine. Hand-rolled overflow-prone numeric code × three owners is precisely the shape the upstream docs adopted fuzzing for. Review found these spotwise; fuzzing demonstrates the domain guards across the whole input space. 3. **The contract suite's blind spot is fuzzing's strength.** The suite (`alkstore-contract-suite`) is example-based — curated stamps and inputs a human authored (no proptest). What it cannot provide: random *malformed* input exploration (payload bytes that are not what any engine would write, spec strings outside the grammar), stateful interleavings, and resource-bound assertions under adversarial sequences. The alksocks §1.3 argument applies as-is. 4. **Sequencing: cheapest now.** First release is the wave-8 deliverable; after that the public surface is frozen and targets become regression protection. All four upstream docs adopt at/just-before first publish for exactly this reason — and the plan's wave-7 deferral carrier is being discharged on schedule, before wave 8 decomposes. **The honest caveat** (mirroring all upstream docs): the crate has no `unsafe`, typed errors end-to-end (thiserror taxonomy, ADR-008 §5), a no-`unwrap()`/no-panic policy enforced by review, and payload decode delegates to serde_json — itself battle-fuzzed and recursion-limited (oversized nesting is a typed `Error::Codec`, not a stack overflow). Expected yield is therefore not memory-safety bugs. It is (a) the demonstration that the typed-error / no-panic contracts hold on adversarial input, (b) overflow-arm discovery in the hand-rolled arithmetic (the wave-6 finding class — the crate's own track record says these were *found by humans, spotwise*, so more arms plausibly exist), (c) regression protection for the published surface. If the campaigns find nothing crate-side, that is the family's successful-negative-result posture (alksocks' first campaigns: zero crate bugs, three harness-model findings, all valuable — the fuzzer-as-harness-checker pattern). ## 2. Toolchain (inherited, not re-decided) Identical to all four upstream docs: **cargo-fuzz + `arbitrary`**, nightly confined to the `fuzz/` subtree via `fuzz/rust-toolchain.toml` (the siblings pin `channel = "nightly"` + `llvm-tools-preview` there); the crates themselves stay on stable. AFL/bolero/libafl/honggfuzz/ OSS-Fuzz not adopted; sanitizer: `--sanitizer=address` with debug assertions (cargo-fuzz defaults) — no MSAN needed (no `unsafe`). **Layout (the upstream pattern, load-bearing):** ``` fuzz/ ├── Cargo.toml alkstore-fuzz (nightly-only bins; own [workspace]) ├── rust-toolchain.toml pins nightly for this subtree only ├── fuzz_targets/ thin fuzz_target! wrappers (3-line each) ├── shared/ alkstore-fuzz-shared — STABLE-toolchain library │ └── src/… invariant logic + corpus replay (§3) ├── corpus// committed hand-made seeds (§4) ├── gen_fuzz_seeds.py deterministic seed generator (quiche pattern) ├── run-detached.sh §5 detached runner (copy from alksocks; CWD-independent) └── README.md ``` Upstream layout rules that carry over, in alkstore terms: - **Invariant logic lives in `fuzz/shared/`**, not inline in the target binaries — the stable-toolchain corpus replay (`cargo test --manifest-path fuzz/shared/Cargo.toml`) runs the SAME invariant functions the fuzzer runs. This is the fuzz gate (§6); it joins AGENTS.md's verification commands when wave 7 lands. - **Workspace/publish exclusion:** this repo's root `Cargo.toml` is a workspace-only manifest (no root package) with an explicit members list, so cargo will not auto-discover `fuzz/shared/` — but the defensive `exclude = ["fuzz"]` on the workspace table is added anyway (the alkcall §2 auto-discovery failure's lesson, cheap insurance). Wave 8's publish-prep task verifies the fuzz sidecar cannot leak into any published package (`exclude` lists on the crates if needed). - **Corpus policy:** commit hand-made seeds, gitignore grown corpora (quinn/h2 model). Crash inputs found by campaigns are promoted to committed seeds (the alksocks seed-900/901 posture). - **wasm note:** the wave-6 constraint on `alkstore-mem` (compiles clean for `wasm32-unknown-unknown`, tokio sync+time only) is unaffected — the fuzz sidecar builds host-side only, and `shared` must not introduce a dependency that breaks the mem crate's wasm posture (it consumes the same public API). ## 3. Target inventory Two feasibility constraints shape the list: every target should drive already-`pub` API (the family's posture — no `#[cfg(fuzzing)]` exposure; see §3.2 for the one snag), and campaign targets must be in-process (the pg engine needs a live server — its paths are not campaign targets; §3.5). ### 3.1 `payload_codec` (wave 1 — core, `alkstore/src/payload.rs`) Input: raw `&[u8]`, plus `arbitrary`-derived `serde_json::Value`s. Drives `encode_payload` and `payload_as::` (over `Value` and a representative typed-payload set). Invariants: - **no-panic on any byte sequence** (the baseline). - **typed-failure partition**: every decode failure is `Error::Codec` — no other error variant, no panic (the ADR-023 §1 fallibility pin, demonstrated across the input space). - **deep-input loudness**: serde_json's recursion limit surfaces as typed `Codec`, never a stack overflow (the deep-nesting probes). - **Value round-trip**: `encode_payload(v)` → `payload_as::` yields `v` for every arbitrary `Value` (canonicalization is serde_json's, pinned-not-regressed, not re-derived). - **typed round-trip**: for the representative payload structs, `encode(Value::from(t))` decodes back to `t` — the ADR-020 §4 "engines store exactly these bytes" contract's decode side. Seeds: valid JSON of every Value variant, truncations, deep nesting at and past the recursion limit, non-UTF-8 bytes, trailing garbage, oversized numbers, duplicate keys, the `Null`-decode shape (ADR-023 §1's empty-byte-store corruption note). Dictionary: `json.dict` (copy from a sibling). ### 3.2 `every_parse` (wave 1 — the three parsers, differential) Input: arbitrary `&[u8]` → UTF-8-lossy `String`s (lossy is deliberate: the engines receive `&str`, which is always valid UTF-8 — but the generators should still stress the grammar's edge shapes). Drives all three `parse_every_interval` implementations: - SQLite: `alkstore-sqlite/src/substrate/queue_ops.rs` (`parse_every_interval`, substrate-owned, `Result`), - Postgres: `alkstore-postgres/src/scheduler.rs` (`Result` → `InvalidSpec`), - Mem: `alkstore-mem/src/scheduler.rs` (`Result` → `InvalidSpec`), plus each engine's next-boundary arithmetic (the checked-add / skip-forward paths — the wave-6 finding class). Invariants: - **no-panic on any string** (each parser, each boundary path). - **typed rejection**: every rejection is the parser's own typed error (`InvalidSpec` on pg/mem; the substrate's typed error under the engine's mapping — never a panic or an `Ok`). - **three-way agreement**: the accept-set and the interval value are identical across the three parsers for every input. The contract suite (suite-pinned rows) pins behavioral equivalence on *valid* specs; fuzzing extends that to *arbitrary* strings — cheap, and it pins the ADR-009 §2 grammar three ways continuously. - **overflow posture**: the boundary paths reject (checked-add → `InvalidSpec`/equivalent), never wrap or panic — the wave-6 scheduler-register fix's invariant, over the whole interval domain (`u64::MAX`-shaped digits, the 9223372036854775807 neighborhood, `d`-unit multipliers). - **the `n <= 0` and giant-`n` arms**: leading-zero forms, digit overflow at exactly `i64::MAX/86400`, positive-zero rejection. **The exposure design point (wave-7 decomposition resolves):** the three parsers are `pub(crate)` — the family's "drive already-`pub` APIs" posture has no precedent for this. Options named for decomposition: - **(a)** drive through the public `schedule()` path against the mem (and SQLite — in-process, temp files) factories with arbitrary spec strings: pure public surface, but slower per-exec and no byte-level differential (behavioral only). - **(b)** a `#[doc(hidden)]` narrow exposure mirroring the existing `Job::from_row` / `Schedule::new` constructor precedent (ADR-017 §3 consumer-read policy does not govern engine-internal test surfaces; the parsers are engine-owned, not contract surface) — keeps the fast byte-level differential target the upstream docs favor. - **(c)** a dev-only feature flag (`fuzzing`), rejected by the family's no-`#[cfg(fuzzing)]` experience unless the harness shape provably needs it. Lean: **(b)** for the fast differential, **(a)** as its in-suite companion (the suite already pins behavior; (a) adds no new rows, only optional corpus seeds). Decomposition decides; this doc does not pre-commit. ### 3.3 `name_validation` (wave 1 — core, `alkstore/src/validation.rs`) Input: arbitrary `&[u8]` → strings, all `NameKind`s, lengths spanning the caps. Drives `validate_name` / `validate_shared_name` / `validate_local_name`. Invariants: total (no panic); deterministic accept/reject; rejection only via typed `Error::InvalidName`; the reserved-prefix rule (`__alkstore_`, ADR-014 §1's shared namespace) rejects in every casing/boundary form the fuzzers find; length boundary exactness at the cap (the ±1 neighborhood). Cheapest target; low expected yield but keeps the entry-point domain guard demonstrated (the gate reviews audit guard-order discipline; this demonstrates it over the whole string space). ### 3.4 `mem_opseq` (wave 2 — deferred, post-release) The stateful op-sequence target, the alktty `session_opseq` / alkcall `manager_routing` analog: `MemStore::new()` + an `arbitrary`-derived op sequence (enqueue/claim/ack/reclaim/dead-letter/ sweep/publish/trim/save_offset/try_lock/lock-extend/notify/notify_tx/ schedule-tick/outbox enqueue, interleaved with tx begin/commit/drop and close), driven over a current-thread tokio runtime (the suite's mem-column flavor). Invariant sketch (decomposition firms it): - no-panic across any op sequence; - the ADR-010 queue state machine's legality (no transition the state machine forbids; reclaim-eats-attempt; dead-letter only from exhausted attempts); - per-consumer stream offsets monotone; receiver arms correct after trim and close; - tx overlay: commit-merge atomicity, drop-=-discard (no ghost rows), wake-at-commit — the suite's tx rows as invariants over random interleavings instead of authored ones; - lock TTL expiry/re-acquisition; the loser's clean `None`. Deferred for the same reasons alksocks deferred its wave 2: wave 1 covers the byte surfaces; this is a large harness; and the crate is pre-release — regression protection matters most once the surface freezes. Note the mem engine is the *only* engine fully fast enough for this (SQLite is feasible but file-bound per-op; pg needs a server). ### 3.5 What is deliberately NOT a target - **The pg engine's server-reachable paths** (forwarder, listener, pool open, DSN handling) — not in-process; campaigns cannot drive them without a live server. Their failure arms are owned by the wave-4/5/6 test suite (the forwarder reconnect tests etc.). - **The SQLite substrate internals beyond the public surface** — substrate edits are governed by the lineage-fidelity diff discipline (ADR-012 §3 / ADR-018), not by fuzzing; its inherited test floor remains the owner of that surface. The `every_parse` target touches it only through the public/hidden exposure decision (§3.2). - **Arithmetic beyond §3.2** (backoff curve, opts resolution) for wave 1 — the suite pins their outputs three-way; fuzzing them is a wave-2 candidate if the opseq target's invariants want them. The equal-jitter curve's determinism makes fuzz yield unlikely. - **wasm targets** — libFuzzer is host-side; the mem wasm acceptance stays compile-clean (§2's wasm note). ## 4. Scope: one implemented wave + a deferred second **Wave 1 (wave 7 in the plan, this session's scope):** the three targets of §3.1–3.3 — `payload_codec`, `every_parse`, `name_validation`. All in-process, all fast (byte/grammar-level), all invariants sketched above. Sequencing mirrors upstream: scaffold (layout of §2, runner, dict, seed generator) → targets in parallel → seeds committed → 10–30 min detached campaigns per target → fix anything found (crash inputs promoted to seeds) → corpus replay green. **Wave 2 (deferred — post-release):** `mem_opseq` (§3.4). Recorded in §3.4 so the deferral is a named scope decision, not a pending-client hedge. The wave's decomposition resolves the named open points: §3.2's exposure decision, the AGENTS.md verification-list flip (corpus replay joins the merge gates when the wave lands), and the publish-exclusion verification (§2). ## 5. Operational isolation: campaigns never share fate with the agent session **Inherited verbatim from the upstream docs (alksocks §5)** — an environment constraint, not a nice-to-have. Three layers, all mandatory: (1) libFuzzer soft limits (`-rss_limit_mb=2048` `-malloc_limit_mb=2048`) — never `ulimit -v` under ASAN; (2) fork mode (`-fork=1`) as blast-radius containment; (3) process detachment — `run-detached.sh` wraps `cargo fuzz run` in `setsid` + `nohup` + full redirection; poll, never `wait`. The alksocks runner is copied as-is (it is CWD-independent and repo-layout-agnostic). Also inherited: `cargo fuzz run` returns before its last fork-mode children fully exit — `pgrep -af` to LIST, kill by explicit pid from the runner's echoed `pid=`, never pattern-kill-and-recheck in one call. ## 6. No hosted CI The standing policy applies identically (per all four upstream docs): no hosted CI; no Actions/Gitea Actions workflow files. **Corpus replay is the fuzz gate as plain `cargo test`**: `cargo test --manifest-path fuzz/shared/Cargo.toml` replays every committed seed through the invariant functions. It joins AGENTS.md's verification commands when wave 7 lands. Campaigns run manually via the detached runner — e.g. before the release, or after touching `payload.rs`, the three parsers, or `validation.rs`. ## 7. Verification commands ```bash cargo test # workspace gates (stable) cargo test --manifest-path fuzz/shared/Cargo.toml # corpus replay (fuzz gate, from wave 7) cargo fuzz build # in fuzz/ (nightly, ASAN) fuzz/run-detached.sh # campaigns (never foreground) cargo clippy --all-targets -- -D warnings cargo fmt --check cargo clippy --manifest-path fuzz/shared/Cargo.toml --all-targets -- -D warnings cargo fmt --manifest-path fuzz/shared/Cargo.toml --check cargo check -p alkstore-mem --target wasm32-unknown-unknown # mem's wasm acceptance ``` Campaign flag starting points (per target, tuned at wave 7): `payload_codec -max_len=65536 -dict=json.dict`; `every_parse -max_len=4096 -use_value_profile=1`; `name_validation -max_len=4096 -use_value_profile=1`. ## 8. References - Upstream docs (authoritative for tooling, runner, corpus policy, no-CI posture): alkcall / alktunnels / alksocks / alktty, paths in the header. - RUSTSEC-2026-0037 (quinn-proto — the maintainers' fuzzing-coverage statement; the upstream §1 datapoint). - This crate: ADR-008 §5 (error taxonomy), ADR-009 §2 (the `@every` grammar), ADR-010 (queue state machine), ADR-012 §2 (one-owner parsers/arithmetic), ADR-017 §3–§4 (exposure/versioning policy), ADR-020 §4 (payload encoding posture), ADR-023 §1 (encode fallibility), ADR-024 §5 (mem wasm acceptance); the wave-6 review gate's overflow findings (`tasks/review-wave-6.md`); `docs/plans/implementation.md` §Decided points (the adoption record).