§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).
36 KiB
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 (commit9ca9922). Finding W3-2 (upstream, pinned with slack — no alktype bug): serde_json's non-float_roundtripparser 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 bareValueequality with a one-ulp structural comparison; the artifact bytes are seed-047 (commitsa8e955c/b7ead99). Finding W3-3 (real engine bug — fixed, commita0dd3d2):AlkTypeEngine::read_field/write_fieldtreated an alignedmaxLengthreservation (ADR-003 strategy 2,VARCHAR(N): raw zero-padded window, NUL-trimmed on read — exactly what the materializer andvalidate_bytesimplement) as length-prefixed, parsing the window's first four raw bytes as a u32 length. Every aligned schema declaringmaxLengthbroke 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:VariableEncodinggainsMaxLengthReserved(additive variant, ADR-003 strategy 2);OffsetMap::computerecords it for maxLength fields with the default encoding (maxLength+offset-indirectstaysOffsetIndirect, preserving W3-1's combination semantics);read_field/write_fielddispatch through newdata_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_arrayreturnedOkfor 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 inplan_read_arraywith an end-vs-buffer bounds check + Access error naming the array field, regression testarray_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). Thefuzz/workspace, 136 committed seeds (38bast_compile+ 98data_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 buildclean; corpus replay 4/4 green; main crate untouched (569 tests, clippy-D warningsclean). One dict-format fix on the way: libFuzzer's dictionary parser does not accept\uescapes ("\u0000"→ the\xABform) and needs fully quoted lines — caught by the campaign launcher, not the fuzzer. - 2026-09-30 — smoke campaigns clean (see §5).
data_accesssaturated (pure decode core, the alkcallchunk_headerprofile);bast_compilestill 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)
- 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.
- 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_alignedare doc+bytes). - Infrastructure is proven and cheap; the crate is the simplest
consumer yet. Six siblings run the layout; alktype is sync, has
zero
unsafe, zerounwrap/expectoutside 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 plaincargo test— the standing fuzz gate (alkcall §7.9 tier-3 deliverable; no hosted CI in this repo by policy). Thefuzz_target!binaries are thin wrappers. - Root
Cargo.tomlneeds an explicit[workspace]table (members = ["."],exclude = ["fuzz"]); without it auto-discovery pullsfuzz/shared/into the main workspace and the stable toolchain builds nightly-consumed dev-deps. alkcall hit this trap. fuzz/rust-toolchain.tomlpins nightly socargo fuzz buildworks from any CWD; nightly stays confined tofuzz/, MSRV 1.85 untouched here.fuzz/joins the publishexcludelist..gitignoreadditions: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=25and detaches viasetsid+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 hasdefault = []and no feature flags, so the shared crate rides the main crate build unconditionally (unlike alkhttp's gatedopenapi/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 minimalfuzzinghub only if a needed item turns outpub(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 feedsSome(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 cleanAlkTypeError(Schema/Offset/Validationpayload classes,src/error.rs:11-28), never a panic or a silent bogus engine; - meta-schema gate ordering: any doc that fails
validate_bast_docmust surfaceSchema(...)and must never reach layout/plan walks (shape partition); - parse-time caps hold exactly:
align > 4096, arrays aboveMAX_ARRAY_ELEMENTS/MAX_ARRAY_BYTES,maxLength > MAX_LENGTH, depth > 128, and$refcycles all reject at compile with the documented error classes (the walk-guardcheck_ref_graph, compile-depth, and cycle-seenmachinery 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,
$refdiamond), 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
pubread functions directly with the fuzzer choosing buffer, offset (including far-past-end and huge values), and endianness; lanes forread_bytes/read_string(u32 length prefix),read_bytes_indirect/read_string_indirect(the{offset,length}pair),read_enum,read_boolstrictness, and each fixed-width kind from the macro family. - Invariants:
- no-panic for any (buffer, offset, endian) triple;
boolaccepts exactly 0x00/0x01 and rejects everything else (:134-144— the strictness is contract, pin it);- invalid UTF-8 in
read_stringerrors (Access), never a lossy silently-corrupting parse (:195-208); - bounds partition: an error implies
checked_add-overflow orend > buffer_lenwith the offendingfield_pathnamed; 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 satisfiesdata_offset + data_length ≤ buffer_lenon Ok, and neither field can push arithmetic past the buffer without an error (the twou32widening casts at:334, :341widening-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
resetrestores the exact initial state; cursor never exceeds the buffer); read_nextreturns fields exactly in plan order andNoneexactly at plan end; interleavedread_fieldfor 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
countloops (src/materialize.rs:439-442,:817-820,src/sequential_reader.rs:985-988) consume ≥ 4 verified bytes per iteration, so iterations are bounded byremaining_bytes / 4— a hostile count fails fast withAccess(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_sizesmaps and write values (FieldValueshapes). - Invariants:
- no-panic across adversarial size maps (zero, huge, mismatched
with
max_length/countdeclarations); - 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
buildare disjoint and in-bounds for the reported total size; data_offset/lengthpairs written bywrite_string_indirect/write_bytes_indirectalways satisfy the read-sideread_*_indirectbounds partition above — the write side and the read side of the pair are one contract (round-trip pair;:373-417guards verified by:733-750-style assertions).
- no-panic across adversarial size maps (zero, huge, mismatched
with
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 drivevalidate_bytes,read_field(arbitrary field paths, including junk paths),materialize_packed/materialize_aligned, andread_nextunder the compiled plan. - Invariants:
- no-panic for any (doc, bytes) pair, either mode;
- validate/read/materialize agreement lattice:
validate_bytesOk ⇒materialize_*Ok and everyread_fieldover a declared path Ok;materialize_*error ⇒validate_byteserror on the same buffer (exact agreement direction pinned per the code's actual contract — determine the strict/loose ordering from thevalidate_bytesimplementation, don't assume); - non-finite floats (NaN/Inf) surfaced by
materializeare alwaysAccesserrors, never silentlyNull/0.0(src/materialize.rs:876-883); - unknown field-path strings always error with
Accessnaming the path, never panic, never index the map by substring drift; Valueoutput is serde-safe:materialize_*output round-trips throughserde_json::to_vecand back to a structurally equalValue(structural only — this crate's serde_json builds withpreserve_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; everyAlkTypeKindonce; each reject class (align4097/65536/u32-max,countover cap,maxLengthover cap, depth-129 nesting both inline-nested and via$refchains,$refcycle,$refto missing def, root not a struct, missingtype, unknown kind string, duplicate field names first-wins probe, non-object doc, deeply-nested JSON at serde_json's own 128 limit);json.dictcarries 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::MAXprefixes; 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/mismatchedvar_sizes; overwrite-everything write; indirect-pair overflow write, plus the write-then-read pair fixture.read_opseqseeds 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 adecode_lands_on_the_intended_variantsreplay 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 touchingsrc/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.dicton 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 buildmust never be a prerequisite forcargo 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/0at 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_paircrash artifacts are all pinned as committed seeds/regression tests (W3-1seed-044, W3-2seed-047, W3-3 reproduced by the aligned maxLength fixtures);bast_compile/data_access/read_opseq/layout_buildartifacts 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 adversarialvar_sizesmap 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 alkcallchunk_headerprofile).- Both exited 0 with empty artifact directories;
oom/timeout/crash: 0/0/0on every fork job. - Seeds regenerate deterministically:
python3 fuzz/gen_fuzz_seeds.py. - Toolchain notes live in
fuzz/README.md; nightly stays confined tofuzz/.
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.
-
Wire-controlled
Recordcount loops (src/materialize.rs:439- 442,:817-820,src/sequential_reader.rs:985-988): the only buffer-derived loop counts (read_u32(..)? as usizethenfor i in 0..count). Each iteration performs at least one bounds-checked read, so a hostile count should fail fast withAccess— bounded byremaining_bytes / 4, no allocation, no spin. Correct as designed if and only if that holds; theread_opseqtarget 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. -
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 (eachu32, up to 2³²−1). The pattern (widening cast →checked_addpair-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). -
Duplicate field-path tolerance by design (
OffsetMap::build_ index,src/offset_map.rs:199-205: first-wins;BastStruct:: parsedoes not reject duplicates): ambiguous lookups are documented behavior. Encode as an invariant — a successful engine'sread_fieldresolves duplicates deterministically (first wins) — so a future "reject duplicates" change shows up as a deliberate contract change, not silent drift. -
align_up/round_upplain+arithmetic (src/offset_map.rs: 618-639): un-checked+ align - remin 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 — abast_compilecorpus entry with align at the cap pinning the boundary keeps it that way if the cap ever moves. -
bast_validation::validate_valuerecompiles aValidationPlanper 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. -
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:898pre-fix) computedendand returnedOkwithout the check. A truncated fixed-stride array reportedOk(Some(...))withelement_start + count × stridepast 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 returningOkover a short buffer. Found by hand-running theread_opseqdrive before the campaign; fixed with an end-vs-buffer check returningAccessnaming the array field; regression test insrc/sequential_reader.rs. The wave-2 seeds' array-truncation fixtures keep the boundary pinned. -
Packed-mode
encoding: offset-indirectis a silent no-op — PINNED, open design question (wave 2).BastField::parserecords 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 testpacked_mode_is_always_inline_length_prefixedpins 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. -
Aligned
maxLengthreservation read paths disagreed — CONFIRMED as a real engine bug and FIXED (wave 3, finding W3-3, found by thevalidate_paircampaign). The materializer andvalidate_bytesimplement ADR-003 strategy 2 correctly (raw zero-padded window, NUL-trimmed), butAlkTypeEngine::read_fielddispatched every aligned String/Bytes leaf through the length-prefixeddata_access::read_string/read_bytes— parsing the reservation window's first four raw bytes as a u32 length — andwrite_fieldwrote prefix+data into the raw window. Any aligned schema declaringmaxLengthwhose 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 inLeafMeta's encoding (VariableEncoding::MaxLengthReserved, additive variant) and dispatching read/write through newdata_access::read_reservation{,_string}/write_reservationsharing the materializer's exact semantics. Regression tests insrc/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
- ✅ Wave 1 (2026-09-30, commit
ed41d77) — infra (workspaceexclude, toolchain pin, runner, seed generator, README,.gitignore) + targets 1–2 + corpora (136 seeds) + corpus replay + AGENTS.md gate + smoke campaigns (clean, see §5). - ✅ 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_arraybounds check, §6 candidate 6); packed-mode offset-indirect pinned as a documented no-op (§6 candidate 7). - ✅ 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 byread_field/write_field— fixed withVariableEncoding:: MaxLengthReserved+data_access::read_reservation*/write_reservation, commita0dd3d2). Post-fix validate_pair campaign clean to budget end. Fuzzing complete per §2 scope: corpus replay 30/30 is the standing gate. - 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,fuzzinghub 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)