# 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// 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` (`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)` (`: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)