# P0.2 — Fuzzing the decode/parse surface

- **Issue:** [#26](https://github.com/devrandom-labs/cesr/issues/26) · Milestone: Phase 0 · Instruments
- **Date:** 2026-06-30
- **Status:** Design approved; implementation pending

## Problem

`cesr` parses untrusted bytes and carries **zero `unsafe`** — a strong base — but
there is **no fuzzing** (`fuzz/` does not exist). Fuzzing is what *proves* the
"no panic on malformed input" invariant (CLAUDE.md → Mandatory Rules → API
Design) and guards the aggressive zero-copy refactors of Phase 1 from
introducing crashes or silently-wrong parses. Phase 0 is "build the instruments,
then floor it"; this card builds the fuzzing instrument.

## Research recommendation (required before code)

The card mandates comparing `cargo-fuzz`, `afl.rs`, `honggfuzz`, and `bolero`,
picking one, and writing the recommendation before coding.

| Tool | Engine | Toolchain | Distinguishing trait |
|------|--------|-----------|----------------------|
| `cargo-fuzz` | libFuzzer | **nightly-only** (`-Zsanitizer`) | OSS-Fuzz-native standard; simplest multi-target setup |
| `afl.rs` | AFL++ | stable | CMPLOG; strong on structured/stateful inputs; single-engine |
| `honggfuzz-rs` | honggfuzz | stable | continues past crashes, leak detection; single-engine |
| **`bolero`** | *unifies* libFuzzer / AFL / honggfuzz + Kani | **stable** for the default path | one harness runs as plain `cargo test` corpus-replay **and** as a coverage-guided fuzzer |

**Decision: `bolero`.**

The deciding constraint is a clash with this repo's rules: CLAUDE.md mandates
**stable `1.95.0`, "No nightly," zero `#![feature]` gates**, but `cargo-fuzz` /
libFuzzer is *nightly-only*. The chosen CI strategy requires a deterministic
corpus-replay step *inside* `nix flake check`. Only `bolero` satisfies both:

- `bolero` "does not require nightly to execute test targets"; only **sanitizers**
  force `cargo +nightly`.
  ([bolero book — Works on Rust Stable](https://camshaft.github.io/bolero/features/rust-stable.html))
- Its `DefaultEngine` "mimics Rust's default test harness … test inputs will
  include any present `corpus` and `crashes` files, as well as generating"
  bounded inputs — so plain `cargo test` on **stable** replays the committed
  corpus deterministically.
  ([bolero book — Corpus Replay](https://camshaft.github.io/bolero/features/corpus-replay.html))
- The *same* harness is later driven by libFuzzer/AFL/honggfuzz under nightly for
  coverage-guided exploration, and exposes a Kani proof path that ties into the
  P0.3 differential/proof card.
  ([bolero book — Unified Interface](https://camshaft.github.io/bolero/features/unified-interface.html);
  [Kani + Bolero](https://model-checking.github.io/kani-verifier-blog/2022/10/27/using-kani-with-the-bolero-property-testing-framework.html))

The structured-input layer is `arbitrary` / `bolero-generator`'s `TypeGenerator`
derive (raw `&[u8]` for decoders, structured values for round-trips). This is
orthogonal to the engine — all four tools use it.
([bolero book — Library Installation](https://camshaft.github.io/bolero/library-installation.html))

> **Divergence from `docs/strategy.md`.** The strategy sketch (P0.2) names
> "`cargo-fuzz` + `arbitrary`" as shorthand for "fuzzing." This card's mandated
> comparison selects `bolero` instead, because `cargo-fuzz`'s nightly requirement
> cannot satisfy the stable in-gate replay the CI strategy needs. Called out per
> CLAUDE.md (decisions must be explicit). `bolero` can still drive libFuzzer for
> the deep-fuzz job, so the OSS-Fuzz path remains open.

## Architecture

A new **`fuzz/` crate** that is its **own isolated workspace** — `fuzz/Cargo.toml`
carries an empty `[workspace]` table so the parent is *not* forced into being a
workspace. This preserves "single crate, not a workspace" (CLAUDE.md) while
keeping the heavy fuzzing dependency tree (`bolero`, `bolero-generator`,
`arbitrary`) out of the **main crate's** dev-dependencies, `cargo audit`, and
`cargo deny` scans — supply-chain surface for the published crate stays minimal.

- `fuzz/Cargo.toml`: package `cesr-fuzz`; empty `[workspace]`; path dep
  `cesr = { path = "..", features = ["stream"] }`; dev/runtime deps `bolero`,
  `bolero-generator`; a `[profile.fuzz]` (`inherits = "dev"`, `opt-level = 3`,
  `codegen-units = 1`, `incremental = false`) as bolero requires.
- The fuzz crate is **std-only** (fuzzers need `std`); orthogonal to the crate's
  no_std/WASM story, which the existing `cesr-wasm` / `cesr-nostd` checks cover.
- Harness closures must obey the no-`unwrap`/`expect` discipline themselves
  (`let Ok(x) = … else { return }`), so a harness bug never masquerades as a
  crate crash (false positive).

## Targets (full decode/parse surface)

Each target is a `bolero::check!` test. Two flavors.

**Panic-hunters** — feed raw `&[u8]`; a panic is a test failure, an `Err` is fine:

1. `matter_from_qb64` — `MatterBuilder::new().from_qualified_base64(bytes)`
2. `matter_from_qb2` — Matter decode from qb2
3. `indexer_from_qb64` — `IndexerBuilder::from_qb64(bytes)`
4. `indexer_from_qb2` — `IndexerBuilder::from_qb2(bytes)`
5. `counter_parse` — counter parse entry point
6. `parse_group` / `parse_group_v2` — `stream::parse_group(bytes)`
7. `stream_groups` — `stream::groups(bytes).collect()` (broadest parser)
8. `parse_message` / `parse_version_string` — `stream::message` parsers

**Round-trip** — structured input via `arbitrary` / `TypeGenerator`; defends
`decode(encode(x)) == x`:

9. `qb64_qb2_roundtrip` — `qb64_to_qb2` / `qb2_to_qb64` stability + no panic
10. `matter_roundtrip` — build a `Matter` from a structured `(code, raw)`,
    encode → decode, assert equality

This maps 1:1 to the card's enumerated entry points (Matter decode,
Counter/Indexer parse, full stream parse) plus the binary conversions.

## Invariant defended

Parsing untrusted bytes must **never panic** — `bolero` treats any panic in a
`check!` closure as a failure. The panic-hunter targets are pure DoS-safety
probes; the round-trip targets additionally defend encode/decode stability.

## Seed corpus

Per-target corpus seeded from **real vectors**, committed to the repo so replay
is deterministic:

- Matter: the `qb64` strings in `src/core/matter/test_vectors.rs` (generated from
  KERIpy `v2.0.0-dev5`).
- Indexer / Counter / stream: valid frames constructed via the public API, the
  same way `benches/` builds its fixtures.

Round-trip targets also accrete corpus over time from the deep-fuzz job.

## CI integration

Chosen strategy: **deterministic replay in the gate + scheduled deep fuzz.**

1. **`nix flake check` → new `cesr-fuzz-replay` check.** Runs plain `cargo test`
   in `fuzz/` on the pinned **stable** toolchain: replays committed corpus +
   crashes + bounded generated inputs on every PR. No nightly. Implemented as a
   custom `mkCargoDerivation`, mirroring the existing `cesr-wasm` / `cesr-nostd`
   checks in `flake.nix`.
2. **`.github/workflows/fuzz.yml` → scheduled deep fuzz.** Cron (nightly) +
   `workflow_dispatch`. Installs a **nightly** toolchain and `cargo-bolero`, runs
   each target under **libFuzzer + AddressSanitizer** for a per-target time-box,
   uploads any crash artifacts. Nightly is quarantined to this workflow and never
   touches the flake gate.
3. **OSS-Fuzz.** Feasible later because `bolero` can drive libFuzzer; **deferred**
   to a follow-up card. Out of scope here.

## Docs

`fuzz/README.md`: how to run the replay (`cargo test` in `fuzz/`), run deep fuzz
(`cargo bolero test <target> --sanitizer address`), reproduce a crash from a
`crashes/` file, and seed/grow a corpus. Pointer added from the repo root.

## Acceptance criteria (from the card)

- [ ] Research recommendation captured (issue comment + this doc).
- [ ] Fuzz targets build and run; a seeded corpus exists; no immediate crashes
      (any found are filed as bugs, not silently fixed).
- [ ] `nix flake check` green (including the new `cesr-fuzz-replay` check).

## Out of scope (explicit)

- OSS-Fuzz onboarding (deferred follow-up).
- Differential fuzzing vs keripy — that is **P0.3**, a separate card; `bolero`'s
  Kani/proof path is noted only as forward-compatibility.
- Tuning the deep-fuzz time-box / corpus-growth-commit policy beyond an initial
  sane default.
