Files
alkstore/tasks/fuzz-target-payload-codec.md
T

7.5 KiB

id, name, status, depends_on, scope, risk, impact, level, tags
id name status depends_on scope risk impact level tags
fuzz-target-payload-codec Fuzz target — `payload_codec` (core payload encode/decode invariants) completed
fuzz-scaffold
moderate low component implementation
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::Values (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

  • fuzz_targets/payload_codec.rs + shared/src/payload_codec.rs exist; cargo fuzz build builds the target (nightly, ASAN)
  • All five §3.1 invariants implemented in shared and exercised by both the fuzz path and the #[cfg(test)] replay module
  • 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
  • cargo test --manifest-path fuzz/shared/Cargo.toml green (replays the committed corpus through the invariants)
  • shared clippy/fmt gates green (cargo clippy --manifest-path fuzz/shared/Cargo.toml --all-targets -- -D warnings, fmt --check)
  • 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 JsonValues 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 JsonValues 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.