From f7de30e57e6ae1948fc971279ea1cfb515ac1807 Mon Sep 17 00:00:00 2001 From: "glm-5.3-flash" Date: Sat, 10 Oct 2026 22:38:34 +0000 Subject: [PATCH] =?UTF-8?q?fuzz=20campaigns=20+=20gate:=20wave=207=20close?= =?UTF-8?q?=20=E2=80=94=20three=2015-min=20detached=20campaigns=20clean=20?= =?UTF-8?q?(payload=5Fcodegen=20finding=20harness-model:=20serde=5Fjson=20?= =?UTF-8?q?default=20f64=20parse=20drifts=201-2=20ULPs;=20invariant=20pinn?= =?UTF-8?q?ed=20to=202-ULP=20float-leaf=20drift=20+=20decode=20determinism?= =?UTF-8?q?,=20crash=20input=20promoted=20as=20payload=5Fcodec/seed-900),?= =?UTF-8?q?=20fuzz/.gitignore=20retightened=20to=20seed-*=20tracking,=20co?= =?UTF-8?q?rpus-replay=20gate=20joins=20AGENTS.md=20verification=20list,?= =?UTF-8?q?=20research=20doc=20=C2=A79=20implementation=20log,=20README=20?= =?UTF-8?q?finalized?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 9 ++- docs/research/fuzzing.md | 92 +++++++++++++++++++++++++++--- fuzz/.gitignore | 6 ++ fuzz/README.md | 13 +++-- fuzz/corpus/payload_codec/seed-900 | 1 + fuzz/shared/src/payload_codec.rs | 80 ++++++++++++++++++++++++-- tasks/fuzz-campaigns-and-gate.md | 61 +++++++++++++++++++- 7 files changed, 237 insertions(+), 25 deletions(-) create mode 100644 fuzz/corpus/payload_codec/seed-900 diff --git a/AGENTS.md b/AGENTS.md index 814f9b8..34f81b8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -88,6 +88,7 @@ gate: ``` cargo build # workspace compiles cargo test # workspace tests (task-scoped: cargo test -p ) +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 diff --git a/docs/research/fuzzing.md b/docs/research/fuzzing.md index dc8d2fc..ea1cbb2 100644 --- a/docs/research/fuzzing.md +++ b/docs/research/fuzzing.md @@ -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`. @@ -355,4 +357,80 @@ Campaign flag starting points (per target, tuned at wave 7): ADR-020 §4 (payload encoding posture), ADR-023 §1 (encode fallibility), ADR-024 §5 (mem wasm acceptance); the wave-6 review gate's overflow findings (`tasks/review-wave-6.md`); - `docs/plans/implementation.md` §Decided points (the adoption record). \ No newline at end of file + `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). \ No newline at end of file diff --git a/fuzz/.gitignore b/fuzz/.gitignore index 2d90ce2..35d7f71 100644 --- a/fuzz/.gitignore +++ b/fuzz/.gitignore @@ -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 diff --git a/fuzz/README.md b/fuzz/README.md index ac9030a..fb87581 100644 --- a/fuzz/README.md +++ b/fuzz/README.md @@ -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//` — 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::` (`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::` (`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` 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 | diff --git a/fuzz/corpus/payload_codec/seed-900 b/fuzz/corpus/payload_codec/seed-900 new file mode 100644 index 0000000..4fe772e --- /dev/null +++ b/fuzz/corpus/payload_codec/seed-900 @@ -0,0 +1 @@ +46741844073709.51615 \ No newline at end of file diff --git a/fuzz/shared/src/payload_codec.rs b/fuzz/shared/src/payload_codec.rs index 16adf30..66c58d7 100644 --- a/fuzz/shared/src/payload_codec.rs +++ b/fuzz/shared/src/payload_codec.rs @@ -69,13 +69,23 @@ pub fn fuzz_payload_codec(data: &[u8]) { assert_decode_partition::>(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::(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(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 diff --git a/tasks/fuzz-campaigns-and-gate.md b/tasks/fuzz-campaigns-and-gate.md index c4e5d18..50ef3f2 100644 --- a/tasks/fuzz-campaigns-and-gate.md +++ b/tasks/fuzz-campaigns-and-gate.md @@ -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/`) + 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//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 \ No newline at end of file +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. \ No newline at end of file