Files
alktype/docs/plans/fuzzing.md
T
glm-5.3-flash f809f6ca7e docs: fuzzing wave-3 status — release-budget campaigns, W3-1/2/3 findings, all five targets in-tree
§Status now covers waves 1-3. §5 gains the release-budget numbers
(bast_compile 818k execs still growing; data_access 11.3M execs with
value-profile lifting the saturated edge count 379 -> 684;
read_opseq/layout_build 63k execs each with the stateful search
active through budget; validate_pair 37k execs clean after the fixes).
§3 target-5 marked implemented; §4 records the 48-seed menu; §6
gains candidate 8 (the W3-3 aligned maxLength reservation bug,
confirmed+fixed) and rewrites the evidence summary; §7 wave 3 is
checked off — corpus replay 30/30 stands as the gate.

Verification: cargo doc clean; 573 main-crate tests + 30 replay tests
green; clippy -D warnings clean (crate + shared).
2026-09-30 08:55:30 +00:00

36 KiB
Raw Blame History

Plan: alktype fuzzing

Adopted from alkhttp's docs/plans/fuzzing.md (the pattern is operational in six sibling crates: alkcall, alktty, alktunnels, alksocks, alkhttp — each with the same fuzz/ layout, the detached runner, and the corpus-replay-as-plain-test gate). The rationale research lives in alkcall's docs/research/fuzzing.md (tool landscape, comparable-crate survey, the no-hosted-CI policy §7.9); this plan stays focused on what alktype fuzzes and in what order.

Rationale for this crate in one paragraph (the detailed version applies by reference from the two docs above): alktype is the binary engine the alk* family consumes — alkcall's hub/spoke accepts BAST schema documents from arbitrary internet peers, and those documents flow into this crate's compile paths downstream; both untrusted-input shapes exist here (attacker-shaped JSON BAST docs → AlkTypeEngine::compile, and attacker-shaped byte buffers read according to a schema → validate_bytes / SequentialReader / tunion dispatch / materialize), and the byte side is hand-rolled decode (data_access, tunion discriminators, indirect {offset,length} pairs) — exactly the shapes where example tests miss off-by-one bugs. The crate is fully synchronous, so targets are simpler than alkcall/alkhttp's (no current-thread runtime shims anywhere).

Status: waves 1–3 implemented and verified (2026-09-30). The pre-fuzzing inventory (§6) is verified against the code at 0.3.0. All five targets and the full infrastructure are in-tree (commits ed41d77, wave 1; 16b9023, wave 2; aef8d9f + findings commits, wave 3); the smoke campaigns ran clean (§5); the release-budget campaigns across all five targets are complete (§5) — two targets surfaced real findings: the wave-2 read_opseq packing bug (§6 candidate 6, fixed same-day) and three wave-3 validate_pair findings (W3-1 harness pin, W3-2 upstream serde_json pin, W3-3 a real engine bug — read_field/write_field misread aligned maxLength reservations — fixed same-day with regression tests).

Progress log:

  • 2026-09-30 — wave 3 landed. Target 5 (validate_pair): the two-input harness (10-lane schema menu incl. a raw-JSON-bytes lane fused with the buffer), 48 committed seeds, decode-pin tests, and the §3 target-5 invariants (mode agreement incl. the documented aligned-rejection taxonomy, the materialize⇄validate_bytes verdict lattice with verbatim error propagation, unknown-path echo, non-finite-float Access pin, enum-Validation pin, record spin bound). Pre-campaign hand-drives all held. Release-budget campaigns (§5): 45–46 min across all five targets. bast_compile 818k execs / 19,894 edges (still growing at budget end); data_access 11.3M execs / 684 edges (a value-profile run lifted the saturated corpus from 379 to 684 edges); read_opseq 63k execs / 17,519 edges; layout_build 63k execs / 17,659 edges; validate_pair 37k execs / 10,850 edges. Finding W3-1 (harness invariant corrected — the fuzzer fired an over-assertion): the harness claimed validate_bytes Ok ⇒ every offset-map leaf's range.end ≤ buffer.len() — false for offset-indirect entries, whose pair points absolutely into the buffer while a maxLength window may dwarf the validated buffer; the wave-1 data_access bounds partition is the real contract. Fixed the invariant, pinned the artifact bytes as seed-044 + a named regression test (commit 9ca9922). Finding W3-2 (upstream, pinned with slack — no alktype bug): serde_json's non-float_roundtrip parser drifts one ulp when re-parsing its own emitted shortest repr of adversarial f64 values (probe: 0x5bffffffffffffff emits 1.4536774485912136e+135 and parses back one ulp low; std's parser and ryu's own float parse are exact — the concise reparse is the drift). The harness replaced bare Value equality with a one-ulp structural comparison; the artifact bytes are seed-047 (commits a8e955c/b7ead99). Finding W3-3 (real engine bug — fixed, commit a0dd3d2): AlkTypeEngine::read_field/write_field treated an aligned maxLength reservation (ADR-003 strategy 2, VARCHAR(N): raw zero-padded window, NUL-trimmed on read — exactly what the materializer and validate_bytes implement) as length-prefixed, parsing the window's first four raw bytes as a u32 length. Every aligned schema declaring maxLength broke the validate_bytes⇒read_field lattice whenever the reservation's first bytes looked like a large prefix (validate Ok, read_field Access with bogus bounds; write_field wrote prefix+data into a raw window). Third crash artifact (W3-1's shape family: the campaign re-found the disagreement space after W3-1's invariant was corrected). Fix: VariableEncoding gains MaxLengthReserved (additive variant, ADR-003 strategy 2); OffsetMap::compute records it for maxLength fields with the default encoding (maxLength+offset-indirect stays OffsetIndirect, preserving W3-1's combination semantics); read_field/write_field dispatch through new data_access::read_reservation{,_string}/ write_reservation (single source of truth with the materializer); three engine regression tests + the W3-1/W3-2 artifacts as committed corpus seeds. Post-fix restart: 30-min validate_pair campaign clean to budget end (37k execs, 10,850 edges, exit 0).
  • 2026-09-30 — wave 2 landed. Targets 3 (read_opseq) and 4 (layout_build) with 73 committed seeds (58 + 15), decode-pin tests for the arbitrary 1.4.2 derive encoding, and the plan §6 candidate-1 spin bound encoded as the explicit End-op assertion. Smoke campaigns: see §5. Finding W2-1 (fixed same session): plan_read_array returned Ok for a fixed-stride array whose declared window (count × stride) extended past the buffer — the bounds check was missing entirely from the array arm (struct/union arms had theirs). A truncated array reported success with the failure deferred to the next field read (wrong field path), or masked entirely when the array was the last field. Found by hand-running the target-3 drive before the campaign (the first semantic fixture); fixed in plan_read_array with an end-vs-buffer bounds check + Access error naming the array field, regression test array_truncated_below_fixed_stride_window_is_access_error_not_ok. Finding W2-2 (pinned, not a bug): packed mode compiles "encoding": "offset-indirect" fields but the sequential reader always reads them inline length-prefixed — this matches bast-format.md §"Default strategy selection" ("Packed sequential mode: always inline length-prefixing"), so the annotation is a no-op in packed mode. Pinned as a corpus-replay invariant (packed_mode_is_always_inline_length_prefixed) so any future change to the packed reader's encoding awareness is deliberate. The aligned materializer honors the encoding correctly. An upstream question — should packed compile either honor the encoding or reject the declaration — is recorded in §6 candidate 7.
  • 2026-09-30 — wave 1 landed (commit ed41d77). The fuzz/ workspace, 136 committed seeds (38 bast_compile + 98 data_access), the detached runner, the corpus-replay gate (AGENTS.md checklist gains the line), nightly pinned subtree, explicit root [workspace] exclusion, publish-exclude gain, json.dict. cargo fuzz build clean; corpus replay 4/4 green; main crate untouched (569 tests, clippy -D warnings clean). One dict-format fix on the way: libFuzzer's dictionary parser does not accept \u escapes ("\u0000" → the \xAB form) and needs fully quoted lines — caught by the campaign launcher, not the fuzzer.
  • 2026-09-30 — smoke campaigns clean (see §5). data_access saturated (pure decode core, the alkcall chunk_header profile); bast_compile still discovering coverage at budget end (longer campaigns keep paying). No crate findings — the §6 candidates (record-count loops, indirect pairs) held under the parser-level drives; both remain encoded as wave-2/3 invariants in the stateful targets.
  • 2026-09-30 — wave-2 smoke campaigns (see §5). Results recorded there alongside the wave-1 numbers.

1. Why alktype fuzzes (the if)

  1. Downstream of the trust boundary. alktype is compiled against in alkcall (the integration crate), whose peers are untrusted and whose wire payloads carry schema-shaped JSON. A panic on a malicious BAST doc or bytes read under one is the quinn-CVE class (RUSTSEC-2026-0037) at one further hop: the alk* stack parses documents it never vetted, and alktype is where they get walked.
  2. Both input shapes, one crate. Sibling crates each had mostly one parse shape (wire bytes); alktype has the schema-JSON shape and the raw-buffer shape, plus two-input combined paths (read_field/write_field, materialize_aligned are doc+bytes).
  3. Infrastructure is proven and cheap; the crate is the simplest consumer yet. Six siblings run the layout; alktype is sync, has zero unsafe, zero unwrap/expect outside tests, and no allocation-from-wire-count anywhere (grep-verified inventory). The marginal cost is target logic only.

Honest caveat (alkcall §1's shape): the code is already well hardened — checked_add/check_bounds everywhere, parse-time caps (MAX_ARRAY_ELEMENTS, MAX_ARRAY_BYTES, MAX_ALIGN = 4096, MAX_LENGTH), MAX_GRAPH_DEPTH/MAX_COMPILE_DEPTH = 128, meta-schema gate before any walker. Expected yield is low-moderate: the residual candidates in §6 are the first things to probe; a clean first campaign is the successful negative result — "we think the engine is robust" converted into a demonstrated property.

2. Infrastructure (identical to the siblings)

Layout (copy of alkcall/alkhttp):

fuzz/
├── Cargo.toml            alktype-fuzz (nightly-only bins; own [workspace])
├── rust-toolchain.toml   pins nightly + llvm-tools for this subtree only
├── fuzz_targets/         thin fuzz_target! wrappers (3 lines each)
├── shared/               alktype-fuzz-shared — STABLE-toolchain library:
│                         invariant logic + corpus-replay tests
├── corpus/<target>/      committed seeds (generated by gen_fuzz_seeds.py)
├── artifacts/            gitignored crash/oom/timeout artifacts + logs
├── gen_fuzz_seeds.py     deterministic seed generator (quiche pattern)
├── json.dict             JSON/BAST token dictionary (bast_compile)
├── run-detached.sh       detached campaign runner (copied from the siblings)
└── README.md             operational cheat-sheet

Load-bearing details (all six siblings hit these; alkcall's doc is the deep reference):

  • Invariant logic lives in fuzz/shared/, not the target binaries. The stable-toolchain shared crate replays every committed seed through the identical invariant functions as plain cargo test — the standing fuzz gate (alkcall §7.9 tier-3 deliverable; no hosted CI in this repo by policy). The fuzz_target! binaries are thin wrappers.
  • Root Cargo.toml needs an explicit [workspace] table (members = ["."], exclude = ["fuzz"]); without it auto-discovery pulls fuzz/shared/ into the main workspace and the stable toolchain builds nightly-consumed dev-deps. alkcall hit this trap.
  • fuzz/rust-toolchain.toml pins nightly so cargo fuzz build works from any CWD; nightly stays confined to fuzz/, MSRV 1.85 untouched here. fuzz/ joins the publish exclude list.
  • .gitignore additions: fuzz/artifacts/, grown-corpus dirs (committed seeds stay).
  • Detached runner (non-negotiable operating rule). Campaigns never run as a foreground child of an agent session; the runner pins -fork=1 -rss_limit_mb=2048 -malloc_limit_mb=2048 -timeout=25 and detaches via setsid + nohup + log redirect; the agent polls the log and artifact directory, never waits. Copied from the siblings.
  • No feature-gating needed in fuzz/shared: alktype has default = [] and no feature flags, so the shared crate rides the main crate build unconditionally (unlike alkhttp's gated openapi/mcp).
  • Exposure needs are minimal. The inventory found every target entry point already pub (compile, data_access::*, SequentialReader, LayoutBuilder, tunion::*, materialize::*, validate_bast_doc, build_validator). No #[cfg(fuzzing)] hub is expected — the first choice remains a minimal fuzzing hub only if a needed item turns out pub(crate), per the sibling pattern (alkcall never needed one).

Verification-gate change: cargo test --manifest-path fuzz/shared/Cargo.toml (corpus replay) joins AGENTS.md's verification checklist, as the siblings did.

3. Target inventory (5, in waves)

# Target Drives Input style Status
1 bast_compile AlkTypeEngine::compile both modes (via bast_meta → BastDoc → plans → layout → validator) raw bytes → serde_json → BAST doc implemented 2026-09-30
2 data_access the hand-rolled decode core (src/data_access.rs, read + write side) raw &[u8] + chosen (offset, endian) implemented 2026-09-30
3 read_opseq stateful SequentialReader op sequences over hostile bytes under a fixed plan #[derive(Arbitrary)] op enum implemented 2026-09-30
4 layout_build LayoutBuilder::build with adversarial var_sizes #[derive(Arbitrary)] map shapes implemented 2026-09-30
5 validate_pair two-input structured: compile a schema once per exec, hammer hostile bytes through validate_bytes/read_field/materialize #[derive(Arbitrary)] (doc, bytes) pair implemented 2026-09-30

Target 1 — bast_compile (the whole schema side, one choke point)

AlkTypeEngine::compile (src/engine.rs:127-176) fans out through the entire untrusted-JSON surface: bast_meta::validate_bast_doc → BastDoc::new → ValidationPlan::compile → LayoutBuilder::new + ReadPlan::compile (packed) or OffsetMap::compute (aligned) → validation::build_validator (when json_schema is Some).

  • Drives: raw bytes → serde_json → compile(value, root_name, mode, None) in both modes; a second lane feeds Some(schema) with a second attacker-shaped JSON value for the jsonschema-build path.
  • Invariants:
    • no-panic on any JSON document, both modes;
    • compile is always Result — every rejection is a clean AlkTypeError (Schema/Offset/Validation payload classes, src/error.rs:11-28), never a panic or a silent bogus engine;
    • meta-schema gate ordering: any doc that fails validate_bast_doc must surface Schema(...) and must never reach layout/plan walks (shape partition);
    • parse-time caps hold exactly: align > 4096, arrays above MAX_ARRAY_ELEMENTS/MAX_ARRAY_BYTES, maxLength > MAX_LENGTH, depth > 128, and $ref cycles all reject at compile with the documented error classes (the walk-guard check_ref_graph, compile-depth, and cycle-seen machinery pinned by adversarial corpus entries);
    • if compile fails in packed it must also fail in aligned (mode independence of the schema-gate layer — the parse layers are shared; divergence means a mode-specific parse bug);
    • a successfully compiled engine's endian() equals the root struct's declared endianness.
  • Seeds: the full BAST feature menu (each kind, endian ×2, TUnion byte/field/enum discriminators, records, arrays, string/bytes encodings, $ref diamond), each reject-class corpus entry, plus the hostile menu in §4.

Target 2 — data_access (the decode core)

Every byte-touching decode funnels through read_array<const N: usize> (src/data_access.rs:48-75): checked_add(N) → check_bounds → .get(..) → try_into. The widest attacker-influenced values in the crate are read_bytes_indirect's absolute {offset,length} pair (src/data_access.rs:328-350).

  • Drives: the pub read functions directly with the fuzzer choosing buffer, offset (including far-past-end and huge values), and endianness; lanes for read_bytes/read_string (u32 length prefix), read_bytes_indirect/read_string_indirect (the {offset,length} pair), read_enum, read_bool strictness, and each fixed-width kind from the macro family.
  • Invariants:
    • no-panic for any (buffer, offset, endian) triple;
    • bool accepts exactly 0x00/0x01 and rejects everything else (:134-144 — the strictness is contract, pin it);
    • invalid UTF-8 in read_string errors (Access), never a lossy silently-corrupting parse (:195-208);
    • bounds partition: an error implies checked_add-overflow or end > buffer_len with the offending field_path named; an Ok implies the field sits fully inside the buffer;
    • nothing before/end-of-buffer is read: the decode consumes exactly its declared width (offset unchanged on error paths);
    • read_bytes_indirect's data region always satisfies data_offset + data_length ≤ buffer_len on Ok, and neither field can push arithmetic past the buffer without an error (the two u32 widening casts at :334, :341 widening-only, verified by the partition).

Target 3 — read_opseq (stateful, wave 2)

SequentialReader is stateful against attacker bytes (mutable cursor: field_index, position; src/sequential_reader.rs:144-148) and fuzzer-reachable operations are read_next, read_next_borrowed, read_field (out-of-order names), reset (:157-300).

  • Drives: #[derive(Arbitrary)] op sequences (Next, Field(name choice), Reset, End) against a compiled plan — the plan built once per exec from a fixed small schema menu, bytes adversarial.
  • Invariants:
    • no-panic over any op interleaving and any buffer;
    • cursor discipline: a failed read leaves the reader usable (a subsequent reset restores the exact initial state; cursor never exceeds the buffer);
    • read_next returns fields exactly in plan order and None exactly at plan end; interleaved read_field for any field at any cursor state never panics and never mutates the sequential cursor (its offset argument comes from the plan, not the reader);
    • record-count spin bound: wire-controlled count loops (src/materialize.rs:439-442, :817-820, src/sequential_reader.rs:985-988) consume ≥ 4 verified bytes per iteration, so iterations are bounded by remaining_bytes / 4 — a hostile count fails fast with Access (encode as an explicit per-exec assertion, not just no-panic/OOM);
    • engine-issued readers are independent: two readers over the same plan and buffer never observe each other's cursors (ADR-007's owned-fresh-reader contract).

Target 4 — layout_build (wave 2)

LayoutBuilder::new parses once (src/layout_builder.rs:154-156); build(&HashMap<String, usize>) (:189) is repeatable with attacker-shaped var_sizes driving write-position arithmetic in walk_struct.

  • Drives: a fixed schema menu containing every variable-width encoding × #[derive(Arbitrary)] var_sizes maps and write values (FieldValue shapes).
  • Invariants:
    • no-panic across adversarial size maps (zero, huge, mismatched with max_length/count declarations);
    • every failed write leaves the buffer untouched (byte-equal to the pre-call snapshot) or documented-partial exactly where the contract allows — pin the actual contract the code implements;
    • field positions from a successful build are disjoint and in-bounds for the reported total size;
    • data_offset/length pairs written by write_string_indirect/write_bytes_indirect always satisfy the read-side read_*_indirect bounds partition above — the write side and the read side of the pair are one contract (round-trip pair; :373-417 guards verified by :733-750-style assertions).

Target 5 — validate_pair (two-input structured, wave 3)

The integration target: schema and bytes are both adversarial.

  • Drives: #[derive(Arbitrary)] (doc, bytes) — compile once per exec with whichever mode the fuzzer picks, then drive validate_bytes, read_field (arbitrary field paths, including junk paths), materialize_packed/materialize_aligned, and read_next under the compiled plan.
  • Invariants:
    • no-panic for any (doc, bytes) pair, either mode;
    • validate/read/materialize agreement lattice: validate_bytes Ok ⇒ materialize_* Ok and every read_field over a declared path Ok; materialize_* error ⇒ validate_bytes error on the same buffer (exact agreement direction pinned per the code's actual contract — determine the strict/loose ordering from the validate_bytes implementation, don't assume);
    • non-finite floats (NaN/Inf) surfaced by materialize are always Access errors, never silently Null/0.0 (src/materialize.rs:876-883);
    • unknown field-path strings always error with Access naming the path, never panic, never index the map by substring drift;
    • Value output is serde-safe: materialize_* output round-trips through serde_json::to_vec and back to a structurally equal Value (structural only — this crate's serde_json builds with preserve_order, so object key order is preserved; byte-identity round-trips are acceptable only where the docs say lossless, per the alkcall §7.3 false-positive trap when they don't).

4. Corpus policy

Committed hand-made seeds per target, generated by fuzz/gen_fuzz_seeds.py (deterministic, in-tree, quiche pattern); grown corpora and artifacts gitignored. Seed menus:

  • bast_compile: a minimal valid packed doc and aligned doc; every AlkTypeKind once; each reject class (align 4097/65536/u32-max, count over cap, maxLength over cap, depth-129 nesting both inline-nested and via $ref chains, $ref cycle, $ref to missing def, root not a struct, missing type, unknown kind string, duplicate field names first-wins probe, non-object doc, deeply-nested JSON at serde_json's own 128 limit); json.dict carries the BAST token set.
  • data_access: minimal valid encodings per kind per endianness; truncation at every prefix length (1..N-1 for each width); len = 0 / MAX_LENGTH / u32::MAX prefixes; the indirect pair at {0,0}, {len, big}, {big, 0}, {u32::MAX, u32::MAX}; offset one-past- end, offset u32-magnitude; 0x02 bool byte; invalid UTF-8 in a string; enum value out of range; NaN/Inf bytes.
  • read_opseq / layout_build (wave 2, done 2026-09-30): the semantic fixtures — full sequential walk, reset-mid-walk then full walk again, failed read then reset, record loop with a hostile count under a real buffer, junk field paths; zero/huge/mismatched var_sizes; overwrite-everything write; indirect-pair overflow write, plus the write-then-read pair fixture. read_opseq seeds also encode truncation at every prefix of the full-walk buffer. The hand-written seeds are byte-encoded against the pinned arbitrary 1.4.2 derive layout and pinned by decode tests (both targets carry a decode_lands_on_the_intended_variants replay test, the alkhttp target-4 pattern).
  • validate_pair (wave 3, done 2026-09-30): hostile-schema/ valid-bytes, valid-schema/hostile-bytes, valid/valid — plus the §6 candidate shapes as pinned reproducers. 48 committed seeds incl. the W3-1/W3-2 artifact bytes, the aligned maxLength fixtures, and the mode-agreement pins (ADR-006/ADR-008 lanes).

Stateful Arbitrary seeds are hand-encoded against the arbitrary 1.4.x derive layout with per-element keep-going bytes, pinned by seed-decode tests (alkhttp's target-4 pattern).

5. Campaign + gate policy

Identical to the siblings (alkcall §7.9 posture; no hosted CI in this repo):

  • Corpus replay is the standing fuzz gate: cargo test --manifest-path fuzz/shared/Cargo.toml — joins AGENTS.md's verification checklist.
  • Campaigns run detached via fuzz/run-detached.sh; budget 10 min per target for a smoke campaign, 30–45 min before a release or after touching src/data_access.rs, src/schema.rs, the compile walks, or the sequential reader.
  • Grown corpora stay gitignored (hash-named files ignored via pattern; committed seed-* files stay); merge worthy entries into seeds only deliberately.
  • libFuzzer flags worth pinning: -rss_limit_mb=2048, -malloc_limit_mb=2048, -timeout=25, -max_len=65536, -use_value_profile=1, and -dict=json.dict on the JSON targets (alkcall §7.2's set, minus the CI-tier concerns).
  • Nightly stays confined to fuzz/; the main crate's stable build, MSRV, and wasm target are untouched — cargo fuzz build must never be a prerequisite for cargo test/clippy/build.

Release-budget campaign results (2026-09-30, 45–46 min per target, §5 policy, detached, -use_value_profile=1, -dict=json.dict on the JSON lanes): all five exited 0.

  • bast_compile — 818k execs at ~300–500/s (2,756 s), coverage 19,894 edges / 86,455 features / 4,439 in-memory corpus entries, still growing at budget end (2× the wave-1 45-min smoke edge count). Longest campaigns keep paying on the schema side.
  • data_access — 11.3M execs at ~3.7–4.7k/s (2,761 s), coverage 684 edges / 6,208 features — value-profile lifted the "saturated" 379 edges to 684 (the wave-1 ceiling was the no-profile ceiling).
  • read_opseq — 63k execs at ~23/s (2,737 s), coverage 17,519 edges / 65,059 features / 1,668 entries; the stateful search kept adding features across the full budget (assertion-throughput-bound).
  • layout_build — 63k execs at ~23/s (2,751 s), coverage 17,659 edges / 71,477 features / 1,873 entries; write-side contracts held.
  • validate_pair — three crashes over two runs before and one clean run after the W3-1/W3-2/W3-3 fixes (final post-fix campaign 30 min): 37k execs (final 1,820 s run), coverage 10,850 edges / 38,364 features / 1,544 entries, oom/timeout/crash: 0/0/0 at budget end. The two-input harness is the highest-yield target in the crate: 1 real engine bug + 2 pinned contracts in its first campaigns.
  • Artifact totals: the three validate_pair crash artifacts are all pinned as committed seeds/regression tests (W3-1 seed-044, W3-2 seed-047, W3-3 reproduced by the aligned maxLength fixtures); bast_compile/data_access/read_opseq/layout_build artifacts empty. No OOM, timeout, or leak on any fork job of any target.

Wave-2 smoke campaign results (2026-09-30, 10 min per target, §5 policy, detached):

  • read_opseq — 52.1k+ execs at ~90/s, exit 0, empty artifact dir, no oom/timeout/crash on any fork job. The typed-input decode plus the per-op assertion work makes this the slowest target per exec in the crate so far; coverage ~9,365 edges / 15,797 features / 293 in-memory corpus entries. The heavy semantic invariants (replay-after-failure, full-walk spin bounds, reader independence, plan-order assertion) run per op, not per exec — the campaign is assertion-throughput-bound, not coverage-bound; the stateful search (op × cursor × buffer shape) was still adding features at budget end.
  • layout_build — 42.4k+ execs at ~84/s, exit 0, empty artifact dir, no oom/timeout/crash on any fork job; coverage 9,432 edges / 19,204 features / 181 in-memory corpus entries. The adversarial var_sizes map space over five schema menus exercised the missing-size / unknown-discriminator / overflow rejection paths; the write-side contracts (failed write leaves buffer byte-identical, positional disjointness and bounds) held everywhere.

Wave-1 smoke campaign results (2026-09-30, 10 min per target, both exited 0, artifact dirs empty — no crash/hang/OOM/leak):

  • bast_compile — 543k execs at ~1.1k/s (each exec compiles two engines through the whole fan-out — meta gate, parse, plans, layout walks — in both modes, so per-exec work is heavy), coverage 10,830 edges / 25,326 features / 1,293 in-memory corpus entries, still growing at budget end — longer campaigns keep paying.
  • data_access — 3.5M+ execs at ~7–8k/s, coverage saturated at 379 edges / 574 features / 37 corpus entries (the pure-decode-core ceiling is fully enumerated; the alkcall chunk_header profile).
  • Both exited 0 with empty artifact directories; oom/timeout/crash: 0/0/0 on every fork job.
  • Seeds regenerate deterministically: python3 fuzz/gen_fuzz_seeds.py.
  • Toolchain notes live in fuzz/README.md; nightly stays confined to fuzz/.

6. Pre-fuzzing candidate findings (confirm or refute)

These are pre-fuzzing code-review findings from the 0.3.0 inventory, verified against the code. They define what the targets must encode as invariants and are the first corpus entries to add; the fuzzer confirms or refutes them.

  1. Wire-controlled Record count loops (src/materialize.rs:439- 442, :817-820, src/sequential_reader.rs:985-988): the only buffer-derived loop counts (read_u32(..)? as usize then for i in 0..count). Each iteration performs at least one bounds-checked read, so a hostile count should fail fast with Access — bounded by remaining_bytes / 4, no allocation, no spin. Correct as designed if and only if that holds; the read_opseq target encodes it as an explicit assertion (§3 target 3) so the fuzzer can break it the moment any per-entry cost stops being ≥ 4 verified bytes.

  2. Attacker-controlled absolute {offset,length} pairs (read_bytes_indirect/read_string_indirect, src/data_access.rs:307-350): the widest attacker-influenced values in the crate (each u32, up to 2³²−1). The pattern (widening cast → checked_add pair-sum → full bounds check) looks correct; campaign confirms the bounds partition on every (buffer, pair) input, both sides of the write/read contract (§3 target 2 / target 4).

  3. Duplicate field-path tolerance by design (OffsetMap::build_ index, src/offset_map.rs:199-205: first-wins; BastStruct:: parse does not reject duplicates): ambiguous lookups are documented behavior. Encode as an invariant — a successful engine's read_field resolves duplicates deterministically (first wins) — so a future "reject duplicates" change shows up as a deliberate contract change, not silent drift.

  4. align_up/round_up plain + arithmetic (src/offset_map.rs: 618-639): un-checked + align - rem in a field of schema-controlled values. Overflow-infeasible today because align is capped at parse (MAX_ALIGN = 4096) and the running offset is monotonically checked — a bast_compile corpus entry with align at the cap pinning the boundary keeps it that way if the cap ever moves.

  5. bast_validation::validate_value recompiles a ValidationPlan per call (src/bast_validation.rs:64-65): a repeated-op DoS surface if any consumer compiles-per-call. Not a bug in this crate's API; fuzz targets compile once per exec, and the doc records the compile cost as the consumer's responsibility.

  6. Missing bounds check in plan_read_array — CONFIRMED and FIXED (wave 2, finding W2-1, 2026-09-30). The struct arm (plan_bounds_check) and the union fixed-size arm both verify the computed end against the buffer; plan_read_array (src/sequential_reader.rs:898 pre-fix) computed end and returned Ok without the check. A truncated fixed-stride array reported Ok(Some(...)) with element_start + count × stride past the buffer; the failure surfaced at the next field (naming the wrong field in the error), or never when the array was the last field — a completed walk returning Ok over a short buffer. Found by hand-running the read_opseq drive before the campaign; fixed with an end-vs-buffer check returning Access naming the array field; regression test in src/sequential_reader.rs. The wave-2 seeds' array-truncation fixtures keep the boundary pinned.

  7. Packed-mode encoding: offset-indirect is a silent no-op — PINNED, open design question (wave 2). BastField::parse records the annotation; the packed reader (plan_read_primitive) ignores it and reads inline length-prefixed — correct per bast-format.md's "Default strategy selection" table, but nothing rejects the declaration in packed mode (aligned mode consumes it; aligned rejects it on record fields only). The wave-2 replay test packed_mode_is_always_inline_length_prefixed pins the current behavior. If the packed reader ever grows encoding awareness, the pin flips deliberately; if the format instead wants the declaration rejected in packed mode, that is a schema-gate change with the mode-agreement invariant to re-verify.

  8. Aligned maxLength reservation read paths disagreed — CONFIRMED as a real engine bug and FIXED (wave 3, finding W3-3, found by the validate_pair campaign). The materializer and validate_bytes implement ADR-003 strategy 2 correctly (raw zero-padded window, NUL-trimmed), but AlkTypeEngine::read_field dispatched every aligned String/Bytes leaf through the length-prefixed data_access::read_string/read_bytes — parsing the reservation window's first four raw bytes as a u32 length — and write_field wrote prefix+data into the raw window. Any aligned schema declaring maxLength whose first reservation bytes looked like a large prefix broke the validate_bytes ⇒ read_field lattice (validate Ok, read Access with bogus bounds). Fixed by recording the strategy in LeafMeta's encoding (VariableEncoding::MaxLengthReserved, additive variant) and dispatching read/write through new data_access::read_reservation{,_string}/write_reservation sharing the materializer's exact semantics. Regression tests in src/engine.rs + data_access.rs; the W3-1 artifact bytes are the committed reproducer (seed-044).

None of these rises to the alkcall §6.2 / alkhttp FWD-20 class; they are boundary-confirmations, which is exactly the expected profile of this crate (§1 honest caveat). Smoke-campaign evidence: no panics or OOM/timeouts on any of the 383k+ wave-1/2 combined execs; the release-budget campaigns added 12.3M+ execs with the W3 findings above. Candidates 1, 2, and 4 held under the parser-level drives — candidates 1 and 2 got their explicit stateful assertions in waves 2–3 (targets 3–5), and 4 keeps its boundary corpus entry. Candidate 3 (duplicate first-wins) is pinned by the wave-2 layout_build index assertions and the existing crate tests; candidate 6 was confirmed as a genuine bug and fixed in wave 2; candidate 7 is pinned with an open design question; candidate 8 was confirmed as a genuine bug and fixed in wave 3.

7. Sequencing

  1. ✅ Wave 1 (2026-09-30, commit ed41d77) — infra (workspace exclude, toolchain pin, runner, seed generator, README, .gitignore) + targets 1–2 + corpora (136 seeds) + corpus replay + AGENTS.md gate + smoke campaigns (clean, see §5).
  2. ✅ Wave 2 (2026-09-30) — targets 3–4 (stateful read_opseq, layout_build) + 73 seeds + decode-pin tests + smoke campaigns. One real bug found and fixed first-session (plan_read_array bounds check, §6 candidate 6); packed-mode offset-indirect pinned as a documented no-op (§6 candidate 7).
  3. ✅ Wave 3 (2026-09-30) — target 5 validate_pair (the two-input structured harness) + 48 seeds + decode-pin tests + 45-min release-budget campaigns across all five targets. Three findings: W3-1 (harness over-assertion corrected + pinned), W3-2 (upstream serde_json one-ulp f64 parse drift pinned with slack), W3-3 (real engine bug — aligned maxLength reservation misread by read_field/write_field — fixed with VariableEncoding:: MaxLengthReserved + data_access::read_reservation*/ write_reservation, commit a0dd3d2). Post-fix validate_pair campaign clean to budget end. Fuzzing complete per §2 scope: corpus replay 30/30 is the standing gate.
  4. Everything else inherited verbatim: no hosted CI, OSS-Fuzz out, no Actions/workflow files anywhere in the repo (alkcall §7.9).

8. References

  • alkcall docs/research/fuzzing.md — rationale, tool landscape, campaign containment (§7.6), no-hosted-CI policy (§7.9)
  • alkhttp docs/plans/fuzzing.md — the live pattern this plan copies (waves, findings log, fuzzing hub convention)
  • RUSTSEC-2026-0037 / CVE-2026-31812 (quinn-proto) — the remote-DoS class this crate's peers are exposed to
  • Internal: ADR-002 (layout modes), ADR-004 (error/validation strategy), ADR-006 (aligned-mode variable-field rejection), ADR-008 (aligned-mode TUnion rejection), ADR-010 (validate_bytes, materialize-then-validate)