150 lines
7.5 KiB
Markdown
150 lines
7.5 KiB
Markdown
---
|
|
id: fuzz-target-payload-codec
|
|
name: Fuzz target — `payload_codec` (core payload encode/decode invariants)
|
|
status: completed
|
|
depends_on: [fuzz-scaffold]
|
|
scope: moderate
|
|
risk: low
|
|
impact: component
|
|
level: implementation
|
|
tags: [wave-7, fuzzing]
|
|
---
|
|
|
|
## Description
|
|
|
|
The §3.1 target: `payload_codec`, driving core's payload surface
|
|
(`alkstore/src/payload.rs` — `encode_payload`, `payload_as::<T>`) —
|
|
already-`pub` API, no exposure needed.
|
|
|
|
- **`fuzz_targets/payload_codec.rs`** — the thin `fuzz_target!`
|
|
wrapper (3-line pattern) delegating to
|
|
`alkstore_fuzz_shared::payload_codec::fuzz_payload_codec(data)`;
|
|
the `[[bin]]` entry lands in `fuzz/Cargo.toml`.
|
|
- **`shared/src/payload_codec.rs`** — the invariant logic, with the
|
|
`#[cfg(test)]` corpus-replay module (the gate). Input: raw `&[u8]`
|
|
plus `arbitrary`-derived `serde_json::Value`s (the
|
|
`JsonValue` bounded-depth helper from the scaffold). Invariants
|
|
(§3.1, verbatim):
|
|
- **no-panic on any byte sequence** (the baseline);
|
|
- **typed-failure partition**: every decode failure is
|
|
`Error::Codec` — no other variant, no panic (the ADR-023 §1
|
|
fallibility pin, demonstrated across the input space);
|
|
- **deep-input loudness**: serde_json's recursion limit surfaces as
|
|
typed `Codec`, never a stack overflow;
|
|
- **Value round-trip**: `encode_payload(v)` → `payload_as::<Value>`
|
|
yields `v` for every arbitrary `Value` (canonicalization is
|
|
serde_json's, pinned-not-regressed);
|
|
- **typed round-trip**: for a representative typed-payload set,
|
|
`encode(Value::from(t))` decodes back to `t` (the ADR-020 §4
|
|
decode side).
|
|
- **Seeds** (§3.1's list, via `gen_fuzz_seeds.py` gaining the
|
|
`payload_codec` function): valid JSON of every Value variant,
|
|
truncations, deep nesting at and past the recursion limit, non-UTF-8
|
|
bytes, trailing garbage, oversized numbers, duplicate keys, the
|
|
`Null`-decode shape (ADR-023 §1's empty-byte-store corruption note).
|
|
Corpus committed under `fuzz/corpus/payload_codec/`; the
|
|
`fuzz/.gitignore` gains the `!corpus/payload_codec` negation.
|
|
- **`fuzz/README.md`** targets-table row.
|
|
- `shared`'s manifest gains `alkstore` (path) if the scaffold didn't
|
|
already carry it, plus `serde_json` + `arbitrary`.
|
|
|
|
Campaign flags are the campaign task's concern (`-max_len=65536
|
|
-dict=json.dict` starting point, §7); this task lands target + seeds +
|
|
replay.
|
|
|
|
## Acceptance Criteria
|
|
|
|
- [x] `fuzz_targets/payload_codec.rs` + `shared/src/payload_codec.rs`
|
|
exist; `cargo fuzz build` builds the target (nightly, ASAN)
|
|
- [x] All five §3.1 invariants implemented in `shared` and exercised by
|
|
both the fuzz path and the `#[cfg(test)]` replay module
|
|
- [x] Seed corpus committed (`fuzz/corpus/payload_codec/`), generated
|
|
by `python3 fuzz/gen_fuzz_seeds.py` (deterministic — re-running
|
|
is a no-op diff); gitignore negation in place
|
|
- [x] `cargo test --manifest-path fuzz/shared/Cargo.toml` green
|
|
(replays the committed corpus through the invariants)
|
|
- [x] `shared` clippy/fmt gates green
|
|
(`cargo clippy --manifest-path fuzz/shared/Cargo.toml
|
|
--all-targets -- -D warnings`, `fmt --check`)
|
|
- [x] Root workspace gates still green
|
|
|
|
## References
|
|
|
|
- docs/research/fuzzing.md §3.1 (target spec), §4 (wave-1 scope), §7
|
|
(flags)
|
|
- docs/architecture/decisions/020-payload-encoding.md §4 (the encoding
|
|
posture the round-trips pin)
|
|
- docs/architecture/decisions/023-fourth-review-round.md §1 (encode
|
|
fallibility)
|
|
- /workspace/@alkdev/alksocks/fuzz/shared/src/datagram_codec.rs (the
|
|
invariant-module + replay-test pattern)
|
|
|
|
## Notes
|
|
|
|
- **serde added to `shared`'s manifest** with `derive` (beyond the
|
|
spec's manifest list of `alkstore`/`serde_json`/`arbitrary`): the
|
|
typed round-trip needs a representative *struct* payload
|
|
(`TestJobPayload` — string/int/vec/option/map fields, the consumer
|
|
payload shape), and `#[derive(Serialize, Deserialize)]` requires the
|
|
direct dep for the bounds. `serde_json` re-exports nothing derive-able;
|
|
core already depends on serde transitively.
|
|
- **The typed-failure partition is exercised four ways**: over `Value`,
|
|
`TestJobPayload`, `String`, and `Vec<Value>` decodes of the raw
|
|
input — each failure must be `Error::Codec` (the ADR-023 §1 pin),
|
|
each success is then driven back through `encode_payload` →
|
|
`payload_as::<Value>` for input-closure Value round-trip
|
|
(canonicalization is serde_json's: duplicate-key last-write-wins
|
|
etc., pinned-not-regressed, not re-derived).
|
|
- **Deep-input loudness probes**: raw nested-array strings at 4x and
|
|
past-4096 depth decode → `Err(Codec)` (both `Value` and typed);
|
|
a constructed `Value` nested one past the 128 limit encodes fine
|
|
(the serializer's recursion is unbounded but shallow here) and its
|
|
stored bytes decode → `Err(Codec)` — the stored-deep-form loudness
|
|
the ADR-023 §1 posture wants. Encode is deliberately NOT probed at
|
|
extreme depth (serializer recursion would overflow at absurd
|
|
depths); 129 + shallow-8 form the pair. serde_json's 128 limit is
|
|
pinned as `RECURSION_LIMIT`; a future serde_json bump failing these
|
|
probes is the intended loud signal, not a fragility.
|
|
- **`arbitrary` path** runs `Unstructured::new(data)` → up to two
|
|
`JsonValue`s from the same bytes (exhaustion → skip, the raw path
|
|
already covers it): encode Ok, decode == v, plus a typed-stability
|
|
check for arbitrary values that happen to decode as `TestJobPayload`.
|
|
- **Placeholder target removed** per the scaffold's lifecycle note —
|
|
`payload_codec`'s `[[bin]]` replaces it; the two remaining wave-1
|
|
target tasks still add their own bins.
|
|
- **Seeds came out at 97** (spec list expanded: per-prefix truncations
|
|
of two docs, depth probes at 8/127/128/129/256/1024 for arrays and
|
|
8/200 for objects, plus the loud-fail shapes NaN/Infinity/+1/01/.5/
|
|
1./--1/lone-key/missing-value). Replay-floor set at ≥ 64.
|
|
- One behavior surfaced by the corpus: `payload_as::<Value>` of
|
|
deep/sub-limit vs shallow shapes needed the round-trip battery's
|
|
constructed `Value` to match the probe doc's leaf (`0`, not `Null`)
|
|
— fixed during implementation; the final replay is green.
|
|
|
|
## Summary
|
|
|
|
Landed the §3.1 `payload_codec` fuzz target: `fuzz_targets/payload_codec.rs`
|
|
(the thin `fuzz_target!` wrapper, replacing the scaffold placeholder's
|
|
`[[bin]]`) and `fuzz/shared/src/payload_codec.rs` implementing all five
|
|
§3.1 invariants — no-panic baseline via the four-way typed decode
|
|
partition (`Value` / `TestJobPayload` / `String` / `Vec<Value>`),
|
|
deep-input loudness at serde_json's recursion limit (raw-byte probes
|
|
and the stored-deep-form probe), empty-byte-store loudness (the ADR-023
|
|
§1 corruption note), the `Value` round-trip (fixed battery + raw-input
|
|
closure + arbitrary `JsonValue`s from the bounded-depth helper), and
|
|
the typed round-trip over a representative payload set (derived struct
|
|
+ std types). 97 deterministic seeds committed under
|
|
`fuzz/corpus/payload_codec/` (generated by `gen_fuzz_seeds.py`'s new
|
|
`payload_codec_seeds()`, re-run is a no-op), with the `.gitignore`
|
|
negation and the README targets-table row. `fuzz/shared/Cargo.toml`
|
|
gained `serde` (derive) — no other manifest changes; `fuzz/Cargo.lock`
|
|
updated.
|
|
|
|
Verified: `cargo test --manifest-path fuzz/shared/Cargo.toml` green
|
|
(corpus replay 97/97 + invariant unit probes); shared clippy
|
|
`-D warnings` + `fmt --check` green; `cargo fuzz build` green from
|
|
`fuzz/` (nightly/ASAN binary at
|
|
`target/x86_64-unknown-linux-gnu/release/payload_codec`); root
|
|
workspace `cargo build` / `cargo test` (all suites pass) / clippy
|
|
`-D warnings` / `fmt --check` green. Campaign flags remain the
|
|
campaigns-and-gate task's concern. |