Files
alkstore/docs/research/fuzzing.md
T
glm-5.3-flash 62cddade00 fuzzing decision: adopted as wave 7, release readiness renumbered to wave 8
docs/research/fuzzing.md (new) — the adoption record and campaign plan,
inheriting the alkcall/alktunnels/alksocks/alktty pattern wholesale:
fuzz/ sidecar workspace with nightly pinned inside fuzz/ only, stable
fuzz/shared corpus replay as the fuzz gate, detached campaigns. Wave-1
targets: payload_codec, every_parse (three-way differential over the
per-engine parsers), name_validation; mem_opseq deferred post-release.
Named design points for decomposition: pub(crate) parser exposure
(lean: doc(hidden), the Job::from_row precedent), AGENTS.md gate
flip, workspace/publish exclusion.

docs/plans/implementation.md — wave 7 inserted (fuzzing; decided
2026-10-10, not yet decomposed), release readiness moved to wave 8,
dependency line + wave table + decided points + review-gate list
updated, forward 'wave 7' references re-pointed with a historical note
for pre-renumber records.

AGENTS.md — no-fuzzing posture flipped to adopted; corpus-replay gate
joins the verification list when wave 7 lands.
2026-10-10 14:02:50 +00:00

18 KiB
Raw Blame History

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/<target>/      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::Values. Drives encode_payload and payload_as::<T> (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::<Value> 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 Strings (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<i64, String>),
  • Postgres: alkstore-postgres/src/scheduler.rs (Result<i64> → InvalidSpec),
  • Mem: alkstore-mem/src/scheduler.rs (Result<i64> → 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 NameKinds, 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

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 <target>                       # 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).