# P0.2 Fuzzing Harness Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Add a `bolero`-based fuzzing harness over `cesr`'s decode/parse surface that proves "no panic on untrusted bytes," runs deterministic corpus-replay inside `nix flake check` on stable, and is drivable under nightly libFuzzer+ASan for scheduled deep fuzzing.

**Architecture:** A new `fuzz/` crate that is its own isolated Cargo workspace (empty `[workspace]` table) with a path dependency on the parent crate. Fuzz targets are `bolero::check!` tests grouped by module. Heavy fuzzing deps stay out of the main crate. A new `cesr-fuzz-replay` flake check runs the targets on stable; a scheduled GitHub Actions workflow runs them under nightly sanitizers.

**Tech Stack:** Rust 2024 / stable 1.95.0 (replay) + nightly (deep fuzz only), `bolero`, `bolero-generator`, Nix flake (crane), GitHub Actions.

---

## Design reference

Spec: `docs/superpowers/specs/2026-06-30-p0.2-fuzzing-design.md`. Issue: #26.

## Verified entry-point signatures (from the codebase — do not re-derive)

```rust
// cesr::core::matter::builder::MatterBuilder
MatterBuilder::new().from_qualified_base64(input: impl Into<Cow<'a,[u8]>>)
    -> Result<Matter<'a, MatterCode>, OneOf<(ParsingError, ValidationError)>>
MatterBuilder::new().from_qualified_base2(stream: &[u8])
    -> Result<Matter<'_, MatterCode>, OneOf<(ParsingError, ValidationError)>>
MatterBuilder::new().with_code(code: C).with_raw(raw: impl Into<Cow<'a,[u8]>>)? .build()
    -> Result<Matter<'a, C>, OneOf<(ParsingError, ValidationError)>>
// Matter accessors: matter.code() -> &C ; matter.raw() -> &[u8]
// cesr::core::matter::code::MatterCode::Ed25519N  // 'B', fixed 32-byte raw

// cesr::core::indexer::IndexerBuilder
IndexerBuilder::new().from_qb64(stream: &[u8]) -> Result<(Indexer<'static>, usize), OneOf<(ParseError,)>>
IndexerBuilder::new().from_qb2(stream: &[u8])  -> Result<(Indexer<'static>, usize), OneOf<(ParseError,)>>

// cesr::stream
qb64_to_qb2(qb64: &[u8]) -> Result<Vec<u8>, ParseError>
qb2_to_qb64(qb2: &[u8])  -> Result<Vec<u8>, ParseError>
parse_group(input: &[u8])    -> Result<(CesrGroup, &[u8]), ParseError>
parse_group_v2(input: &[u8]) -> Result<(CesrGroup, &[u8]), ParseError>
groups(input: &[u8]) -> Groups<'_>            // iterator of Result items
parse_message(input: &[u8]) -> Result<CesrMessage<'_>, ParseError>
parse_version_string(input: &[u8]) -> Result<(VersionString<'_>, &[u8]), ParseError>

// matter_to_qb64 PANICS on variable-size codes by contract — only call it on
// fixed-size codes (the round-trip target uses Ed25519N, which is fixed).
matter_to_qb64<C: CesrCode>(matter: &Matter<'_, C>) -> Vec<u8>   // cesr::stream::encode
```

> **Note on Counter:** there is no public Counter byte-decode entry point (only
> `CounterCode::from_hard(&str)`). Counter decoding is exercised transitively
> through `parse_group` / `groups` (counter-led groups), so there is no standalone
> counter target. This is an intentional deviation from the card's "Counter parse"
> wording, documented here and to be noted in the PR.

## Bolero harness idioms (from bolero docs)

- Raw-bytes panic-hunter: `bolero::check!().for_each(|input: &[u8]| { /* call decoder */ });`
- Structured: `bolero::check!().with_type::<[u8; 32]>().for_each(|raw| { ... });`
- Closures MUST NOT `unwrap`/`expect` on the crate-under-test's `Result` — that
  would be a false-positive crash. Use `let Ok(x) = … else { return };`. A
  deliberate `panic!`/`assert_eq!` is the *only* allowed failure signal, and only
  to assert a round-trip invariant that must hold.
- On stable, `cargo test` runs each `check!` target via the `DefaultEngine`:
  replays any on-disk `corpus`/`crashes` files and runs bounded generated inputs.

---

## Task 1: Scaffold the isolated `fuzz/` crate

**Files:**
- Create: `fuzz/Cargo.toml`
- Create: `fuzz/tests/smoke.rs`
- Create: `fuzz/.gitignore`

- [ ] **Step 1: Create `fuzz/Cargo.toml`**

```toml
# Isolated fuzzing workspace. The empty [workspace] table makes this crate its
# OWN workspace root so the parent crate stays single-crate (CLAUDE.md) and the
# fuzzing dependency tree never enters the main crate's audit/deny/dev surface.
[workspace]

[package]
name = "cesr-fuzz"
version = "0.0.0"
edition = "2024"
rust-version = "1.95.0"
publish = false
license = "MIT OR Apache-2.0"

[dependencies]
cesr = { path = "..", features = ["stream"] }

# Versions are resolved by `cargo add` in Step 2 (latest) and pinned in
# fuzz/Cargo.lock — not hardcoded here.

# bolero requires a `fuzz` profile to exist.
[profile.fuzz]
inherits = "dev"
opt-level = 3
incremental = false
codegen-units = 1
```

- [ ] **Step 2: Add bolero deps at their latest versions**

Run (inside the nix dev shell):

```bash
nix develop --command bash -c "cd fuzz && cargo add bolero && cargo add bolero-generator"
```

Expected: `fuzz/Cargo.toml` gains `bolero` and `bolero-generator` under
`[dependencies]` at the latest published versions; `fuzz/Cargo.lock` is created.

- [ ] **Step 3: Create `fuzz/.gitignore`**

```gitignore
/target
```

- [ ] **Step 4: Create a smoke target `fuzz/tests/smoke.rs`**

```rust
//! Smoke target: proves the bolero harness builds and runs under `cargo test`.

#[test]
fn smoke() {
    bolero::check!().for_each(|input: &[u8]| {
        // Trivial total function: can never panic. Confirms wiring only.
        let _ = input.len();
    });
}
```

- [ ] **Step 5: Run the smoke target on stable**

Run:

```bash
nix develop --command bash -c "cd fuzz && cargo test --test smoke"
```

Expected: PASS. `test smoke ... ok`. This confirms bolero's `DefaultEngine` runs
on the pinned stable toolchain with no nightly and no fuzzer installed.

- [ ] **Step 6: Commit**

```bash
git add fuzz/Cargo.toml fuzz/Cargo.lock fuzz/.gitignore fuzz/tests/smoke.rs
git commit -m "test(fuzz): scaffold isolated bolero fuzz crate (#26)"
```

---

## Task 2: Matter targets

**Files:**
- Create: `fuzz/tests/matter.rs`

- [ ] **Step 1: Write the Matter targets**

```rust
//! Fuzz targets for the `Matter` decode/encode surface.
//!
//! Panic-hunters feed raw bytes to the decoders; any panic fails the test.
//! The round-trip target constructs a fixed-size primitive, encodes it, decodes
//! it back, and asserts raw-byte stability.

use cesr::core::matter::builder::MatterBuilder;
use cesr::core::matter::code::MatterCode;
use cesr::stream::encode::matter_to_qb64;

#[test]
fn matter_from_qb64() {
    bolero::check!().for_each(|input: &[u8]| {
        // Must return Ok/Err, never panic, on arbitrary bytes.
        let _ = MatterBuilder::new().from_qualified_base64(input);
    });
}

#[test]
fn matter_from_qb2() {
    bolero::check!().for_each(|input: &[u8]| {
        let _ = MatterBuilder::new().from_qualified_base2(input);
    });
}

#[test]
fn matter_roundtrip() {
    // Ed25519N ('B') is a fixed-size code with a 32-byte raw, so `matter_to_qb64`
    // is safe (it only panics on variable-size codes).
    bolero::check!()
        .with_type::<[u8; 32]>()
        .for_each(|raw| {
            let Ok(builder) = MatterBuilder::new()
                .with_code(MatterCode::Ed25519N)
                .with_raw(&raw[..])
            else {
                return;
            };
            let Ok(matter) = builder.build() else { return };

            let qb64 = matter_to_qb64(&matter);

            let Ok(decoded) = MatterBuilder::new().from_qualified_base64(&qb64[..]) else {
                panic!("re-decoding self-encoded Matter must succeed");
            };
            assert_eq!(
                decoded.raw(),
                &raw[..],
                "qb64 round-trip must preserve raw bytes",
            );
        });
}
```

- [ ] **Step 2: Run the Matter targets on stable**

Run:

```bash
nix develop --command bash -c "cd fuzz && cargo test --test matter"
```

Expected: PASS — `matter_from_qb64 ... ok`, `matter_from_qb2 ... ok`,
`matter_roundtrip ... ok`. If `matter_roundtrip` fails, a real round-trip bug
exists — STOP and file it as a bug per the acceptance criteria.

- [ ] **Step 3: Commit**

```bash
git add fuzz/tests/matter.rs
git commit -m "test(fuzz): add Matter decode + round-trip targets (#26)"
```

---

## Task 3: Indexer targets

**Files:**
- Create: `fuzz/tests/indexer.rs`

- [ ] **Step 1: Write the Indexer targets**

```rust
//! Fuzz targets for the `Indexer` (indexed-signature) decode surface.

use cesr::core::indexer::IndexerBuilder;

#[test]
fn indexer_from_qb64() {
    bolero::check!().for_each(|input: &[u8]| {
        let _ = IndexerBuilder::new().from_qb64(input);
    });
}

#[test]
fn indexer_from_qb2() {
    bolero::check!().for_each(|input: &[u8]| {
        let _ = IndexerBuilder::new().from_qb2(input);
    });
}
```

- [ ] **Step 2: Run the Indexer targets on stable**

Run:

```bash
nix develop --command bash -c "cd fuzz && cargo test --test indexer"
```

Expected: PASS — `indexer_from_qb64 ... ok`, `indexer_from_qb2 ... ok`.

- [ ] **Step 3: Commit**

```bash
git add fuzz/tests/indexer.rs
git commit -m "test(fuzz): add Indexer decode targets (#26)"
```

---

## Task 4: Stream targets (broadest parse surface)

**Files:**
- Create: `fuzz/tests/stream.rs`

- [ ] **Step 1: Write the stream targets**

```rust
//! Fuzz targets for the `stream` parse surface — counter-led groups, the
//! `groups()` iterator (broadest), and message/version-string parsers. Counter
//! decoding is exercised transitively here (no standalone counter entry point).

use cesr::stream::{groups, parse_group, parse_group_v2, parse_message, parse_version_string};

#[test]
fn stream_parse_group() {
    bolero::check!().for_each(|input: &[u8]| {
        let _ = parse_group(input);
    });
}

#[test]
fn stream_parse_group_v2() {
    bolero::check!().for_each(|input: &[u8]| {
        let _ = parse_group_v2(input);
    });
}

#[test]
fn stream_groups() {
    bolero::check!().for_each(|input: &[u8]| {
        // Drive the whole iterator; each item is a Result. Never panic.
        for item in groups(input) {
            let _ = item;
        }
    });
}

#[test]
fn stream_parse_message() {
    bolero::check!().for_each(|input: &[u8]| {
        let _ = parse_message(input);
    });
}

#[test]
fn stream_parse_version_string() {
    bolero::check!().for_each(|input: &[u8]| {
        let _ = parse_version_string(input);
    });
}
```

- [ ] **Step 2: Run the stream targets on stable**

Run:

```bash
nix develop --command bash -c "cd fuzz && cargo test --test stream"
```

Expected: PASS for all five targets. Any panic here is exactly the DoS-safety
bug class this card defends — STOP and file it.

- [ ] **Step 3: Commit**

```bash
git add fuzz/tests/stream.rs
git commit -m "test(fuzz): add stream group/message/version parse targets (#26)"
```

---

## Task 5: Binary round-trip target

**Files:**
- Create: `fuzz/tests/binary.rs`

- [ ] **Step 1: Write the qb64<->qb2 round-trip target**

```rust
//! Fuzz target for the qb64<->qb2 binary conversions. Feeds arbitrary bytes to
//! `qb64_to_qb2`; when that succeeds, asserts the qb2->qb64->qb2 direction is
//! stable (a successfully-decoded primitive must re-encode identically).

use cesr::stream::{qb2_to_qb64, qb64_to_qb2};

#[test]
fn qb64_qb2_roundtrip() {
    bolero::check!().for_each(|input: &[u8]| {
        let Ok(qb2) = qb64_to_qb2(input) else { return };
        let Ok(qb64) = qb2_to_qb64(&qb2) else {
            panic!("qb2 from a valid qb64 must convert back to qb64");
        };
        let Ok(qb2_again) = qb64_to_qb2(&qb64) else {
            panic!("re-encoded qb64 must convert back to qb2");
        };
        assert_eq!(qb2, qb2_again, "qb2->qb64->qb2 must be stable");
    });
}
```

- [ ] **Step 2: Run the binary target on stable**

Run:

```bash
nix develop --command bash -c "cd fuzz && cargo test --test binary"
```

Expected: PASS — `qb64_qb2_roundtrip ... ok`.

- [ ] **Step 3: Commit**

```bash
git add fuzz/tests/binary.rs
git commit -m "test(fuzz): add qb64<->qb2 round-trip target (#26)"
```

---

## Task 6: Seed corpora from real vectors

bolero replays any files in each target's on-disk `corpus/` directory. Seeding
with real, valid frames makes replay meaningful (it exercises the success path,
not just random rejects) and gives the deep-fuzz job a head start.

**Files:**
- Create: `fuzz/tests/corpus/<target>/seed-*` (raw input bytes, one file per seed)

- [ ] **Step 1: Discover the exact corpus directory bolero uses**

The corpus path is version-dependent, so discover it empirically rather than
assume. Run one target so bolero creates its directory layout, then locate it:

```bash
nix develop --command bash -c "cd fuzz && cargo test --test matter -- matter_from_qb64"
find fuzz -type d \( -name corpus -o -name crashes \) -not -path '*/target/*'
```

Expected: prints the bolero-managed `corpus`/`crashes` directories (historically
`fuzz/tests/<target>/corpus` and `.../crashes`). Use the printed `corpus` path
for `<target>` in the steps below. If no directory is created, bolero stores the
default corpus under `fuzz/tests/<target>/corpus`; create it explicitly.

- [ ] **Step 2: Seed the Matter qb64 corpus with real vectors**

These qb64 strings are valid CESR primitives (from `benches/matter.rs`, which
took them from `src/core/matter/test_vectors.rs`, generated from KERIpy
v2.0.0-dev5). Write each as a raw file (no trailing newline):

```bash
cd fuzz
mkdir -p tests/matter_from_qb64/corpus
printf '%s' 'BDhylkfP3gHCziiybFdHJzf1w1YaF2EYW9hYmkPOC7p1' > tests/matter_from_qb64/corpus/seed-ed25519n
printf '%s' 'ENfExg460gjOUGZEEDbp8ZHgt1A2p39l4uqkdSRDIz--' > tests/matter_from_qb64/corpus/seed-blake3-256
printf '%s' '4AACnhE8oa_r' > tests/matter_from_qb64/corpus/seed-strb64-l0
printf '%s' 'AKMcBr1GPjkjvBqtveSLFpdsCAcXNzuBmgaPMrems4tr' > tests/matter_from_qb64/corpus/seed-ed25519-seed
cd ..
```

> If Step 1 revealed a different corpus path, substitute it for
> `tests/matter_from_qb64/corpus` above and in the steps that follow.

- [ ] **Step 3: Seed the stream corpus with a real multi-group frame**

Build a valid attachment stream the same way `benches/stream.rs` does and dump
it to the `stream_groups` corpus. Add this throwaway helper test to
`fuzz/tests/stream.rs`, run it once, then delete it:

```rust
#[test]
#[ignore = "run manually to (re)generate the seed corpus"]
fn write_stream_seed() {
    use cesr::core::counter::CounterCodeV1;
    use cesr::core::indexer::IndexerBuilder;
    use cesr::core::indexer::code::IndexedSigCode;
    use cesr::stream::encode::encode_counter_v1;
    use std::io::Write;

    let build_siger = |index: u32| -> Option<Vec<u8>> {
        let indexer = IndexerBuilder::new()
            .with_code(IndexedSigCode::Ed25519)
            .with_index(index)
            .ok()?
            .with_raw(vec![0u8; 64])
            .ok()?;
        Some(indexer.to_qb64().into_bytes())
    };

    let mut input = encode_counter_v1(CounterCodeV1::ControllerIdxSigs, 2).unwrap();
    input.extend_from_slice(&build_siger(0).unwrap());
    input.extend_from_slice(&build_siger(1).unwrap());

    std::fs::create_dir_all("tests/stream_groups/corpus").unwrap();
    let mut f = std::fs::File::create("tests/stream_groups/corpus/seed-controller-2sig").unwrap();
    f.write_all(&input).unwrap();
}
```

Run it, then remove the helper:

```bash
nix develop --command bash -c "cd fuzz && cargo test --test stream -- --ignored write_stream_seed"
ls -la fuzz/tests/stream_groups/corpus/
# then delete the write_stream_seed fn from fuzz/tests/stream.rs
```

Expected: `seed-controller-2sig` exists and is non-empty. (The `.unwrap()`s are
acceptable here: this is a throwaway, manually-run generator, not a fuzz target —
it is deleted before commit, so the no-unwrap rule for harness closures still
holds. Verify it is gone with `grep -n write_stream_seed fuzz/tests/stream.rs`
returning nothing.)

- [ ] **Step 4: Re-run replay to confirm seeds load**

```bash
nix develop --command bash -c "cd fuzz && cargo test"
```

Expected: PASS. Output shows the seed files being replayed as inputs for their
targets (e.g. lines referencing `corpus/seed-*`).

- [ ] **Step 5: Commit the seed corpora**

```bash
git add fuzz/tests/*/corpus/
git add fuzz/tests/stream.rs   # only if the helper deletion changed it
git commit -m "test(fuzz): seed corpora from real KERIpy-derived vectors (#26)"
```

---

## Task 7: Wire `cesr-fuzz-replay` into `nix flake check`

This adds a stable, no-nightly corpus-replay check to the gate, mirroring the
existing `cesr-wasm` / `cesr-nostd` custom derivations.

**Files:**
- Modify: `flake.nix` (the `checks` attrset)

- [ ] **Step 1: Read the existing custom checks for the exact pattern**

Run:

```bash
sed -n '/cesr-nostd/,/);/p' flake.nix
```

Expected: shows the `craneLib.mkCargoDerivation` shape used by `cesr-nostd`
(pname, src, cargoArtifacts, buildPhaseCargoCommand). Copy this shape.

- [ ] **Step 2: Add the `cesr-fuzz-replay` check**

In the `checks = { … }` attrset in `flake.nix`, add (adapting paths/vars to the
names the surrounding checks use — the `sed` output from Step 1 shows them):

```nix
          # Deterministic corpus replay on STABLE — runs the bolero targets via
          # `cargo test` (DefaultEngine). No nightly, no sanitizers. Guards every
          # PR against panics on the seeded/known inputs. Deep coverage-guided
          # fuzzing lives in .github/workflows/fuzz.yml (nightly), not here.
          cesr-fuzz-replay = craneLib.mkCargoDerivation {
            pnameSuffix = "-fuzz-replay";
            src = craneLib.cleanCargoSource ./.;
            cargoArtifacts = null;
            buildPhaseCargoCommand = ''
              cd fuzz
              cargo test --profile dev
            '';
          };
```

> **Crane note:** the `fuzz/` crate is its own workspace with its own
> `fuzz/Cargo.lock`, so it vendors independently of the parent. If crane cannot
> resolve `fuzz/`'s deps offline with the snippet above, supply the lockfile
> explicitly via `craneLib.vendorCargoDeps { cargoLock = ./fuzz/Cargo.lock; }`
> and point `CARGO_HOME` at it in `buildPhaseCargoCommand`. Confirm the exact
> crane API against the flake's pinned crane input before finalizing.

- [ ] **Step 3: Run only the new check**

Run:

```bash
nix build '.#checks.aarch64-darwin.cesr-fuzz-replay' -L
```

Expected: builds and the cargo-test phase passes. If crane fails on offline
vendoring, apply the fallback in the Crane note and re-run.

- [ ] **Step 4: Commit**

```bash
git add flake.nix
git commit -m "ci: add cesr-fuzz-replay corpus-replay check to flake gate (#26)"
```

---

## Task 8: Scheduled deep-fuzz workflow (nightly, quarantined)

**Files:**
- Create: `.github/workflows/fuzz.yml`

- [ ] **Step 1: Read an existing workflow for repo conventions**

Run:

```bash
sed -n '1,40p' .github/workflows/checks.yml
```

Expected: shows how the repo sets up jobs (runner, checkout, nix/toolchain
setup). Match its style (runner image, permissions block).

- [ ] **Step 2: Create `.github/workflows/fuzz.yml`**

```yaml
name: deep-fuzz

on:
  schedule:
    - cron: "0 3 * * *" # nightly 03:00 UTC
  workflow_dispatch:
    inputs:
      duration:
        description: "Seconds to fuzz each target"
        required: false
        default: "120"

permissions:
  contents: read

jobs:
  deep-fuzz:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        target:
          - matter_from_qb64
          - matter_from_qb2
          - matter_roundtrip
          - indexer_from_qb64
          - indexer_from_qb2
          - stream_parse_group
          - stream_parse_group_v2
          - stream_groups
          - stream_parse_message
          - stream_parse_version_string
          - qb64_qb2_roundtrip
    steps:
      - uses: actions/checkout@v4

      - name: Install nightly toolchain
        uses: dtolnay/rust-toolchain@nightly

      - name: System deps for cargo-bolero
        run: sudo apt-get update && sudo apt-get install -y binutils-dev libunwind-dev

      - name: Install cargo-bolero
        run: cargo install cargo-bolero --force

      - name: Fuzz ${{ matrix.target }}
        working-directory: fuzz
        run: |
          cargo bolero test ${{ matrix.target }} \
            --sanitizer address \
            --engine libfuzzer \
            -- -max_total_time=${{ github.event.inputs.duration || '120' }}

      - name: Upload crashes
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: crashes-${{ matrix.target }}
          path: fuzz/tests/${{ matrix.target }}/crashes/
          if-no-files-found: ignore
```

> **Verify before relying on the run:** the exact `cargo bolero test` flag
> spelling (`--sanitizer`, `--engine`, and the libFuzzer `-max_total_time`
> passthrough after `--`) against the installed `cargo-bolero --help`. Adjust
> flags to match; the matrix and artifact logic stay the same.

- [ ] **Step 3: Lint the workflow (the pre-commit hook runs actionlint on it)**

Run:

```bash
nix run nixpkgs#actionlint -- .github/workflows/fuzz.yml
```

Expected: no output (passes). Fix any reported issues.

- [ ] **Step 4: Commit**

```bash
git add .github/workflows/fuzz.yml
git commit -m "ci: add scheduled nightly deep-fuzz workflow (#26)"
```

---

## Task 9: Documentation

**Files:**
- Create: `fuzz/README.md`
- Modify: `README.md` (add a `## Fuzzing` section after `## Benchmarks`)

- [ ] **Step 1: Write `fuzz/README.md`**

````markdown
# cesr fuzzing

`bolero`-based fuzz targets over the decode/parse surface. Selected over
cargo-fuzz because bolero runs corpus-replay on **stable** Rust (so it joins
`nix flake check` with no nightly) while still driving libFuzzer+ASan under
nightly for deep fuzzing. See `docs/superpowers/specs/2026-06-30-p0.2-fuzzing-design.md`.

This crate is its own isolated workspace (note the empty `[workspace]` table in
`Cargo.toml`), so its dependencies never enter the main `cesr` crate.

## Replay the committed corpus (stable, what CI runs every PR)

```bash
cd fuzz
cargo test
```

This replays every seed in `tests/<target>/corpus/` plus bounded generated
inputs, on the pinned stable toolchain. A panic = a DoS-safety bug.

## Deep fuzz a target (nightly, coverage-guided)

```bash
cargo install cargo-bolero
cd fuzz
cargo bolero test stream_groups --sanitizer address --engine libfuzzer
```

Discovered inputs are saved under `tests/<target>/corpus/`; crashes under
`tests/<target>/crashes/`. Commit interesting new corpus files.

## Reproduce a crash

```bash
cd fuzz
cargo test --test <module> -- <target>   # replays tests/<target>/crashes/ too
```

## Targets

| Target | Entry point |
|--------|-------------|
| `matter_from_qb64` / `matter_from_qb2` | `MatterBuilder::from_qualified_base64` / `from_qualified_base2` |
| `matter_roundtrip` | construct → `matter_to_qb64` → decode (Ed25519N) |
| `indexer_from_qb64` / `indexer_from_qb2` | `IndexerBuilder::from_qb64` / `from_qb2` |
| `stream_parse_group` / `_v2` | `parse_group` / `parse_group_v2` |
| `stream_groups` | `groups()` iterator (broadest) |
| `stream_parse_message` / `stream_parse_version_string` | `parse_message` / `parse_version_string` |
| `qb64_qb2_roundtrip` | `qb64_to_qb2` / `qb2_to_qb64` |
````

- [ ] **Step 2: Add a `## Fuzzing` section to the root `README.md`**

Insert after the `## Benchmarks` section (which ends just before `## Security`):

```markdown
## Fuzzing

Fuzz targets live in [`fuzz/`](./fuzz) and use
[bolero](https://github.com/camshaft/bolero) over the decode/parse surface
(Matter, Indexer, stream groups/messages, qb64↔qb2). bolero runs **corpus
replay on stable Rust**, so a deterministic replay (`cesr-fuzz-replay`) is part
of `nix flake check` on every PR — no nightly. Coverage-guided deep fuzzing
(libFuzzer + AddressSanitizer) runs nightly via
[`.github/workflows/fuzz.yml`](./.github/workflows/fuzz.yml).

```bash
# replay the committed corpus (what CI runs)
nix develop --command bash -c "cd fuzz && cargo test"
```

See [`fuzz/README.md`](./fuzz/README.md) for deep-fuzzing and crash-reproduction.
```

- [ ] **Step 3: Commit**

```bash
git add fuzz/README.md README.md
git commit -m "docs: document the fuzzing harness (#26)"
```

---

## Task 10: Full gate + PR

- [ ] **Step 1: Run the complete gate**

Run:

```bash
nix flake check
```

Expected: all checks green, including `cesr-fuzz-replay`. If clippy flags the
fuzz crate, note that the fuzz crate does not inherit the main crate's `[lints]`
table; fix any genuine issues but do not relax the main crate's lints.

- [ ] **Step 2: Push and open the PR**

```bash
git push -u origin test/p0.2-fuzzing
gh pr create --title "test: fuzzing the decode/parse surface (P0.2, #26)" \
  --body "$(cat <<'BODY'
Implements #26. Adds a bolero-based fuzz harness over the decode/parse surface.

## Decision
bolero over cargo-fuzz/afl/honggfuzz — only bolero runs corpus-replay on stable,
so `cesr-fuzz-replay` joins `nix flake check` with no nightly, while deep fuzzing
(libFuzzer+ASan, nightly) runs in a scheduled workflow. Diverges from
docs/strategy.md's cargo-fuzz sketch; rationale in the design doc + issue comment.

## What landed
- Isolated `fuzz/` crate (own workspace; fuzzing deps stay out of the main crate).
- Targets: Matter qb64/qb2 + round-trip, Indexer qb64/qb2, stream
  group/group_v2/groups/message/version-string, qb64<->qb2 round-trip.
- Seed corpora from real KERIpy-derived vectors.
- `cesr-fuzz-replay` flake check (stable, every PR).
- Nightly `deep-fuzz` workflow.
- Docs in `fuzz/README.md` + root README.

## Deviations (called out per CLAUDE.md)
- No standalone Counter target: no public Counter byte-decode entry point exists;
  counter decoding is covered transitively via `parse_group`/`groups`.
- OSS-Fuzz deferred to a follow-up.

## Acceptance
- [x] Research recommendation captured (issue comment + design doc).
- [x] Targets build and run; seeded corpus exists; no immediate crashes.
- [x] `nix flake check` green.
BODY
)"
```

Expected: PR opens against `main`.

---

## Self-review notes (author checklist — already verified)

- **Spec coverage:** every target in the spec maps to a task (Matter/Indexer/
  stream/binary → Tasks 2–5; corpus → Task 6; CI replay → Task 7; scheduled deep
  fuzz → Task 8; docs → Task 9). The spec's `matter_roundtrip` uses the verified
  `with_code`/`with_raw`/`build` + `matter_to_qb64` path on fixed Ed25519N.
- **Counter deviation** is explicit (no public byte-decode entry; covered via
  groups) — surfaced in the plan, README, and PR body.
- **No invented APIs:** all signatures are copied from the codebase (see the
  verified-signatures block). The one empirically-discovered fact (bolero's exact
  corpus path) is handled by a discovery step, not a guess.
- **Type consistency:** target function names are identical across the test files,
  the flake check (`cargo test` runs all), the workflow matrix, and the README
  table.
