fuzz campaigns + gate: wave 7 close — three 15-min detached campaigns clean (payload_codegen finding harness-model: serde_json default f64 parse drifts 1-2 ULPs; invariant pinned to 2-ULP float-leaf drift + decode determinism, crash input promoted as payload_codec/seed-900), fuzz/.gitignore retightened to seed-* tracking, corpus-replay gate joins AGENTS.md verification list, research doc §9 implementation log, README finalized

This commit is contained in:
glm-5.3-flash committed 2026-10-10 22:38:34 +00:00
1 parent 5043f7bcd8
commit f7de30e57e
7 files changed
+236 -24

No files matched your search

+4 -5
View File
@@ -88,6 +88,7 @@ gate:
```
cargo build # workspace compiles
cargo test # workspace tests (task-scoped: cargo test -p <crate>)
cargo test --manifest-path fuzz/shared/Cargo.toml # fuzz gate — corpus replay through the invariants
cargo clippy --all-targets -- -D warnings
cargo fmt --check
```
@@ -96,11 +97,9 @@ Fuzzing is adopted (2026-10-10 — wave 7; `docs/research/fuzzing.md`),
modeled on the alkcall/alktunnels/alksocks/alktty pattern: the `fuzz/`
sidecar workspace pins nightly inside `fuzz/` only, the stable
`fuzz/shared` crate replays the committed corpora as a plain
`cargo test` gate, and campaigns run only via the detached runner —
never as a foreground child of an agent session. The
corpus-replay gate (`cargo test --manifest-path fuzz/shared/Cargo.toml`)
joins the verification list above when wave 7 lands; until then there
is no fuzz gate to run.
`cargo test` gate (above), and campaigns run only via the detached
runner (`fuzz/run-detached.sh`) — never as a foreground child of an
agent session (§5 of the research doc).
## Architecture Context
+84 -6
View File
@@ -1,8 +1,10 @@
# Fuzzing alkstore: Research and Plan
**Status:** decided — adopted (2026-10-10), inserted as wave 7 in
`docs/plans/implementation.md` (release readiness renumbered to 8).
Targets not yet implemented; decomposition pending. Campaigns will run
**Status:** landed — adopted (2026-10-10), inserted as wave 7 in
`docs/plans/implementation.md` (release readiness renumbered to 8);
wave 1's three targets implemented, seeds committed, detached campaigns
run (§9 log), the corpus-replay gate on AGENTS.md's verification list.
Wave 2 (`mem_opseq`, §3.4) stays deferred post-release. Campaigns run
via the detached runner (§5) — **never as a foreground child of an
agent session** (the family's §5 rule, inherited verbatim).
**Date:** 2026-10-10
@@ -114,7 +116,7 @@ Upstream layout rules that carry over, in alkstore terms:
binaries — the stable-toolchain corpus replay
(`cargo test --manifest-path fuzz/shared/Cargo.toml`) runs the SAME
invariant functions the fuzzer runs. This is the fuzz gate (§6);
it joins AGENTS.md's verification commands when wave 7 lands.
it is on AGENTS.md's verification commands (landed with wave 7, §9).
- **Workspace/publish exclusion:** this repo's root `Cargo.toml` is a
workspace-only manifest (no root package) with an explicit members
list, so cargo will not auto-discover `fuzz/shared/` — but the
@@ -318,8 +320,8 @@ The standing policy applies identically (per all four upstream docs):
no hosted CI; no Actions/Gitea Actions workflow files. **Corpus replay
is the fuzz gate as plain `cargo test`**:
`cargo test --manifest-path fuzz/shared/Cargo.toml` replays every
committed seed through the invariant functions. It joins
AGENTS.md's verification commands when wave 7 lands. Campaigns run
committed seed through the invariant functions. It is on
AGENTS.md's verification commands (landed with wave 7, §9). Campaigns run
manually via the detached runner — e.g. before the release, or after
touching `payload.rs`, the three parsers, or `validation.rs`.
@@ -356,3 +358,79 @@ Campaign flag starting points (per target, tuned at wave 7):
fallibility), ADR-024 §5 (mem wasm acceptance); the wave-6 review
gate's overflow findings (`tasks/review-wave-6.md`);
`docs/plans/implementation.md` §Decided points (the adoption record).
---
## 9. Implementation log — wave 7 (2026-10-10)
- **2026-10-10 — wave 1's three targets implemented** (the
decomposition's target tasks, all via the §2 layout: stable
`fuzz/shared` invariant library, thin `fuzz_target!` binaries,
committed seed corpora from `gen_fuzz_seeds.py` — 97 + 66 + 44).
§3.2 resolved to the option-*(b)* `#[doc(hidden)]` exposure
(doc-hidden re-exports of the three parsers + `skip_forward`; the
every_parse campaign's two earlier findings — the boundary-arithmetic
division-by-zero/negative-interval wrap and the i64 gap-subtraction
overflow, fixed crate-side and registered as PROVENANCE D-38). The
§3.2 suite-companion option-*(a)* added no suite rows — suite-pinned
rows already carried equivalence; noted here per the decomposition's
resolution scope.
- **2026-10-10 — first campaign finding: the `Value` round-trip
invariant over-tightened (harness-model, not a crate bug).** The
`payload_codec` campaign crashed ~38 s in on raw input
`46741844073709.51615` (`crash-0ce050f1…`, exit 77):
`decode(encode(v)) ≠ v` — `46741844073709.52` vs `46741844073709.516`.
Reproduced and characterized probe-side (serde_json 1.0.152): its
**default fast-path f64 parser is not exactly correctly-rounded** —
parsing serde_json's own ryu serialization can drift 1–2 ULPs
(measured over 600k random-bits finite f64s: ~30% fail one
parse∘serialize∘parse pass, `max drift = 2.00` ULP, overwhelmingly
exactly 1 ULP; the one-pass re-parse is **not a fixpoint** at extreme
significand/exponent shapes), so the byte-level closure holds only
outside float leaves. This is serde_json's *documented shipping
behavior*, and [ADR-020](../architecture/decisions/020-enqueue-opt-semantics-and-bridges.md)
§4 pins exactly that — "not pinned to a serde_json *feature flavor*
… beyond default shipping behavior" — so enabling its
`float_roundtrip` feature would be an ADR deviation, not a fix.
The harness was the defect: it pinned the suite's example-based
round-trip equality (authored floats are ryu-stable) over the whole
raw input space. Fixed in `fuzz/shared`: the closure is now exact
outside `Number` float leaves, each float leaf drift-bounded at
2 ULPs (pinned-not-regressed), plus a decode-determinism pin (two
parses of the same stored bytes are identical). The crash input is
promoted to `corpus/payload_codec/seed-900`; the campaign re-ran
clean.
- **2026-10-10 — wave-1 15-minute detached campaigns run** (§6 runner,
`-fork=1 -rss_limit_mb=2048 -malloc_limit_mb=2048 -timeout=25`;
flags per §7): all three exited 0, `oom/timeout/crash: 0/0/0`
throughout, empty per-target artifact dirs at completion (the one
first-run crash artifact above is the promoted finding's evidence).
- `payload_codec` (post-fix re-run; the pre-fix run died at the
finding) — 927 s, ~586k execs (~650 exec/s), 41 fork jobs,
coverage 2395 edges (ft 7608, in-memory corp 1544 from the 98
committed seeds).
- `every_parse` — 927 s, ~3.7M execs (~4.1k exec/s), 41 fork jobs,
coverage saturated at 285 edges (ft 3898, corp 805 from the 66
committed seeds) — the three parsers' grammar space is fully
enumerated post-fix; the earlier boundary-arithmetic findings were
the campaign-relevant depth.
- `name_validation` — 939 s, ~59k execs (~66 exec/s), 41 fork jobs,
coverage 327 edges (ft 3248, corp 378 from the 44 committed
seeds); the corpus-replay seeds already covered the domain guards,
and the slow exec/s profile is the long-string shapes, not a
harness defect.
- Grown corpora were **not** merged into the committed seeds (the
§2 policy; `fuzz/.gitignore` now tracks only `seed-*` files —
the scaffold's directory-level negation leaked SHA-named grown
units into `git status` during the campaigns, fixed here).
- **NO crate bugs found by the campaigns.** The sole campaign finding
was the harness-model defect above (fixed harness-side); every
contract invariant held across the adversarial input space: the
typed-failure partition (`Codec` only), deep-input loudness at
serde_json's recursion limit, the empty-byte-store loudness
(ADR-023 §1), the typed round-trip batteries, the three-way parser
agreement and overflow posture, and name-validation
determinism/typedness. That is the family's
successful-negative-result posture (alksocks §7's first campaigns:
zero crate bugs after the two earlier wave-7 crate-side
boundary-arithmetic fixes, which the every_parse task itself landed).
+6
View File
@@ -1,7 +1,13 @@
target
corpus/*
!corpus/payload_codec
corpus/payload_codec/*
!corpus/payload_codec/seed-*
!corpus/every_parse
corpus/every_parse/*
!corpus/every_parse/seed-*
!corpus/name_validation
corpus/name_validation/*
!corpus/name_validation/seed-*
artifacts
coverage
+7 -6
View File
@@ -5,8 +5,7 @@ libFuzzer targets for alkstore's parse/codec surfaces (see
## Layout
- `fuzz_targets/` — one binary per target; thin `fuzz_target!` wrappers
(land with the wave-7 target tasks).
- `fuzz_targets/` — one binary per target; thin `fuzz_target!` wrappers.
- `shared/` — the invariant logic, as a plain library so normal
`cargo test` (stable toolchain) can replay the committed corpora
through the same invariants (`fuzz/shared/src/*.rs` `#[cfg(test)]`
@@ -14,9 +13,11 @@ libFuzzer targets for alkstore's parse/codec surfaces (see
the shared crate is stable-clean. It consumes alkstore's public API
only and must never become a dependency of any workspace crate.
- `corpus/<target>/` — committed hand-made seeds (regenerate with
`python3 fuzz/gen_fuzz_seeds.py` from the repo root). Grown corpora
and artifacts are gitignored; each target task adds its `!`-negation
line when its corpus lands.
`python3 fuzz/gen_fuzz_seeds.py` from the repo root — must be a no-op
diff on the committed set) plus promoted campaign crash inputs
(`seed-900`+ numbering). Only `seed-*` files are tracked; grown
corpora (SHA-named units), artifacts, and coverage output are
gitignored and are **not** merged back into the committed seeds.
- `run-detached.sh` — mandatory runner for agent sessions: wraps
`cargo fuzz run` in `setsid` + `nohup` + log redirection so an OOM
in a target can never take down the agent host (research doc §5).
@@ -27,7 +28,7 @@ libFuzzer targets for alkstore's parse/codec surfaces (see
| Target | Drives | Invariants |
|---|---|---|
| `payload_codec` | `encode_payload` / `payload_as::<T>` (`alkstore/src/payload.rs`) | no-panic on any byte sequence; every decode failure typed `Error::Codec`; deep nesting loud at serde_json's recursion limit; `Value` round-trip (serde_json canonicalization pinned); typed round-trip for a representative payload set |
| `payload_codec` | `encode_payload` / `payload_as::<T>` (`alkstore/src/payload.rs`) | no-panic on any byte sequence; every decode failure typed `Error::Codec`; deep nesting loud at serde_json's recursion limit; `Value` round-trip closes exactly outside `Number` float leaves, float leaves pinned to serde_json's default-parse drift bound (1–2 ULPs, see the research doc's implementation log — ADR-020 §4 pins default serde_json, not the `float_roundtrip` flavor); decode deterministic over stored bytes; typed round-trip for a representative payload set |
| `every_parse` | the three `parse_every_interval` implementations (differential, through the engines' `#[doc(hidden)]` re-exports) + each engine's `skip_forward` boundary arithmetic | no-panic on any UTF-8-lossy string (each parser, each boundary path); typed rejection (`Error::InvalidSpec` on pg/mem, the substrate's error under its mapping on SQLite); three-way agreement on the Ok/Err partition and the `Ok` interval value (error identity not pinned — the SQLite `Result<i64, String>` vs `InvalidSpec` asymmetry is known); overflow posture: `skip_forward` over adversarial `(next_fire_at, now, interval)` triples rejects-or-saturates, never wraps or panics; the `n <= 0` and giant-`n` arms (leading zeros, digit overflow at exactly `i64::MAX/86400`, positive-zero rejection) |
| `name_validation` | `validate_name` / `validate_shared_name` / `validate_local_name` (`alkstore/src/validation.rs`) | no-panic on any byte sequence (lossy-decoded and `arbitrary`-structured strings); deterministic accept/reject (double-invocation-pinned); rejection only via the typed taxonomy — `Error::InvalidName` for empty/whitespace-only on both kinds, `Error::ReservedName` for the leading byte-exact `__alkstore_` prefix on the shared kind only (the local kind takes the non-empty rule only, so a leading reserved prefix is legal there); the reserved-prefix rule exact in every casing/boundary form (casing deviations, missing trailing underscore, embedded and whitespace-led forms accept); the rejection carries the supplied name verbatim (names are not trimmed); verdict-class length-independence across the probed neighborhoods (0↔1, 11↔12, 63↔64, 4095↔4096, 65536) — no length cap is pinned (ADR-008 §4), so no length is ever a rejection cause |
+1
View File
@@ -0,0 +1 @@
46741844073709.51615
+76 -4
View File
@@ -69,13 +69,23 @@ pub fn fuzz_payload_codec(data: &[u8]) {
assert_decode_partition::<Vec<Value>>(data);
// A successfully-decoded `Value` re-encodes byte-stably: the Value
// round-trip closes over the raw input, with serde_json's
// canonicalization (duplicate-key last-write-wins, number-to-NormalizedNumber,
// key ordering) pinned — not re-derived, just not regressed.
// round-trip closes over the raw input up to serde_json's
// canonicalization (duplicate-key last-write-wins, key ordering) —
// pinned, not re-derived. serde_json's default fast-path f64
// parser is not exactly correctly-rounded (its parse of its own
// ryu serialization may drift 1–2 ULPs; the ADR-020 §4 posture
// pins default serde_json, not the `float_roundtrip` flavor), so
// float leaves are compared under the pinned drift bound; every
// other shape must be exactly equal.
if let Ok(v) = payload_as::<Value>(data) {
let bytes = encode_payload(&v).expect("a decoded `Value` must re-encode");
let back: Value = payload_as(&bytes).expect("the encoded `Value` must decode");
assert_eq!(back, v, "the `Value` round-trip must close over raw input");
assert_canonicalized_closure(&v, &back);
let back_again: Value = payload_as(&bytes).expect("the encoded `Value` must decode");
assert_eq!(
back, back_again,
"payload decode must be deterministic over stored bytes"
);
}
assert_deep_input_loudness();
@@ -92,6 +102,68 @@ fn assert_decode_partition<T: serde::de::DeserializeOwned>(data: &[u8]) {
}
}
/// serde_json's default fast-path f64 parse is not exactly
/// correctly-rounded: parsing its own ryu serialization can drift by
/// at most this many ULPs (one canonicalization step; measured over
/// randomized campaigns — see the research doc's implementation log).
const FLOAT_DRIFT_ULPS: f64 = 2.0;
fn assert_canonicalized_closure(v: &Value, back: &Value) {
match (v, back) {
(Value::Number(a), Value::Number(b)) => {
if a.is_f64() && b.is_f64() {
let x = a.as_f64().expect("checked is_f64");
let y = b.as_f64().expect("checked is_f64");
if y == x {
return;
}
let drift = (y - x).abs() / canonicalization_ulp(x);
assert!(
drift <= FLOAT_DRIFT_ULPS,
"float canonicalization drifted {drift} ULPs, over the \
{FLOAT_DRIFT_ULPS}-ULP bound: {x} -> {y}"
);
} else {
assert_eq!(a, b, "an integer leaf lost exactness across the round-trip");
}
}
(Value::Array(a), Value::Array(b)) => {
assert_eq!(
a.len(),
b.len(),
"array length changed across the round-trip"
);
for (x, y) in a.iter().zip(b.iter()) {
assert_canonicalized_closure(x, y);
}
}
(Value::Object(a), Value::Object(b)) => {
assert_eq!(
a.len(),
b.len(),
"object key set changed across the round-trip"
);
for (key, x) in a {
let y = b
.get(key)
.unwrap_or_else(|| panic!("key {key:?} lost across the round-trip"));
assert_canonicalized_closure(x, y);
}
}
_ => assert_eq!(v, back, "the `Value` round-trip must close over raw input"),
}
}
fn canonicalization_ulp(x: f64) -> f64 {
let a = x.abs();
let up = a.next_up();
if up > a {
up - a
} else {
a - a.next_down()
}
}
/// Deep inputs decode loud and typed: past serde_json's recursion
/// limit, deserialization fails with `Error::Codec` — never a stack
/// overflow, never an untyped panic. The encode side handles deep
+58 -3
View File
@@ -1,7 +1,7 @@
---
id: fuzz-campaigns-and-gate
name: Fuzz campaigns (detached) + corpus-replay gate wiring + AGENTS.md verification-list flip
status: pending
status: completed
depends_on: [fuzz-target-payload-codec, fuzz-target-every-parse, fuzz-target-name-validation]
scope: moderate
risk: medium
@@ -82,8 +82,63 @@ wiring deliverables the plan names:
## Notes
> To be filled by implementation agent
- **One campaign finding, harness-model — no crate bugs (§9 log in the
research doc carries the full disposition).** The `payload_codec`
campaign crashed ~38 s in on `"46741844073709.51615"`:
`decode(encode(v)) ≠ v` for f64 leaves. serde_json's default
fast-path f64 parse of its own ryu output drifts 1–2 ULPs
(probe-measured: ~30% of random-bits finite f64s fail one
parse∘serialize∘parse pass; max drift exactly 2.00 ULP; not a
fixpoint at extreme significand shapes). ADR-020 §4 pins default
serde_json ("not pinned to a feature flavor"), so the crate-side
`float_roundtrip` route is an ADR deviation, not a fix — harness
defect. Fixed in `fuzz/shared/src/payload_codec.rs`: exact closure
outside `Number` float leaves, float leaves drift-bounded at
2 ULPs (`FLOAT_DRIFT_ULPS`, pinned-not-regressed), plus a
decode-determinism pin. Crash input promoted to
`corpus/payload_codec/seed-900`; re-run clean.
- **`fuzz/.gitignore` retightened (its final state was this task's to
set).** The scaffold's directory-level negations (`!corpus/<target>`)
leaked SHA-named grown-corpus units into `git status` while
campaigns ran (libFuzzer fork mode merges new units into the first
corpus dir). Now: dirs re-included, contents ignored,
`!corpus/<target>/seed-*` back — committed seeds + promoted crash
inputs in git, grown corpora invisible.
- **Campaigns ran in parallel** (15-min budget each, `FUZZ_RUNTIME_SECS=
900`): logs `fuzz/artifacts/{payload_codec-20261010-221350,221951,
every_parse-20261010-221351,name_validation-20261010-221352}.log`
(the 221350 log is the pre-fix crash evidence; the 215619 log is the
every_parse task's earlier 180 s smoke run, superseded). Polling
note: the runner's children match `pgrep -af cargo-fuzz` (hyphen),
not `cargo fuzz`.
- **`gen_fuzz_seeds.py` no-op verified twice**: with the wave's edits
stashed and with them applied, a re-run produced a zero diff on the
committed corpus set (seed-900 is promotion, not generator output).
- **serde_json version pin observed**: the drift characterization was
against 1.0.152 (`fuzz/Cargo.lock`); the 2-ULP bound is
pinned-not-regressed — a serde_json upgrade may only tighten it.
## Summary
> To be filled on completion
Wave 7's operational close landed. Three 15-minute detached campaigns
(`run-detached.sh`, `-fork=1 -rss_limit_mb=2048 -malloc_limit_mb=2048
-timeout=25`; `payload_codec -max_len=65536 -dict=json.dict`,
`every_parse`/`name_validation -max_len=4096 -use_value_profile=1`)
all exited 0, `oom/timeout/crash: 0/0/0`, empty artifact dirs at
completion: `payload_codec` ~586k execs (~650 exec/s, 2395 edges, corp
1544); `every_parse` ~3.7M execs (~4.1k exec/s, 285 edges saturated,
corp 805); `name_validation` ~59k execs (327 edges, corp 378). The one
finding (payload_codec, ~38 s in) was harness-model — the `Value`
round-trip invariant over-tightened against serde_json's default f64
parse-precision posture — fixed harness-side crash-input-promoted, and
the re-run is clean; no crate bugs (the research doc's
successful-negative-result §9). Corpus policy final state verified:
only `seed-*` tracked, grown corpora ignored, generator re-run a
no-op. The gate joined AGENTS.md's Verification Commands and the
fuzzing paragraph reflects the landed state; `fuzz/README.md` finalized
(targets table incl. the float-drift pin, commands, corpus policy);
research doc §9 appended and its §2/§6 "when wave 7 lands" clauses
updated. Verified: workspace build/test, root + `shared`
clippy/fmt, the `shared` corpus-replay gate (6 tests incl. the 208
committed seeds), and `cargo check -p alkstore-mem --target
wasm32-unknown-unknown` all green.