diff --git a/README.md b/README.md index 082fa38..bc5b4a9 100644 --- a/README.md +++ b/README.md @@ -37,11 +37,20 @@ lez-fuzzing/ │ │ ├── fuzz_witness_set_verification.rs │ │ ├── fuzz_program_deployment_lifecycle.rs │ │ ├── fuzz_apply_state_diff_split_path.rs -│ │ └── fuzz_multi_block_state_sequence.rs +│ │ ├── fuzz_multi_block_state_sequence.rs +│ │ ├── fuzz_sequencer_vs_replayer.rs +│ │ ├── fuzz_merkle_tree.rs +│ │ ├── fuzz_transaction_properties.rs +│ │ ├── fuzz_privacy_preserving_witness.rs +│ │ ├── fuzz_encoding_privacy_preserving.rs +│ │ └── fuzz_nullifier_set_roundtrip.rs # 20 targets total — see table below │ └── corpus/ # Curated seed inputs (one dir per target) ├── .github/ │ └── workflows/ -│ └── fuzz.yml # CI: smoke-fuzz · regression · proptest · perf +│ ├── fuzz.yml # CI: smoke-fuzz · regression · proptest · perf (libFuzzer) +│ ├── fuzz-afl.yml # CI: AFL++ lane +│ ├── mutants.yml # CI: mutation testing (cargo-mutants) +│ └── lint.yml # CI: fmt + clippy ├── scripts/ │ └── add_fuzz_target.py # Automates new-target scaffolding (called by just new-target) └── docs/ @@ -130,6 +139,19 @@ just fuzz-props | `fuzz_program_deployment_lifecycle` | `V03State::transition_from_program_deployment_transaction` no-panic + BalanceIsolation (deployment must not move tokens) + StateIsolationOnFailure | `fuzz/fuzz_targets/fuzz_program_deployment_lifecycle.rs` | | `fuzz_apply_state_diff_split_path` | SplitPathEquivalence: `validate_on_state + apply_state_diff` == `execute_check_on_state` for all known accounts (balance, nonce, data, program_owner); NonceIncrementCorrectness | `fuzz/fuzz_targets/fuzz_apply_state_diff_split_path.rs` | | `fuzz_multi_block_state_sequence` | LongRangeBalanceConservation across up to 16 blocks + FailedTxNonceStability (nonce must not change on rejection) + PerBlockReplayRejection | `fuzz/fuzz_targets/fuzz_multi_block_state_sequence.rs` | +| `fuzz_sequencer_vs_replayer` | Differential: sequencer path (`validate_on_state` → `apply_state_diff`) vs replayer path (`execute_check_on_state`) — SequencerReplayerEquivalence + ReplayerAcceptsAllSequencerTxs + ClockConsistency | `fuzz/fuzz_targets/fuzz_sequencer_vs_replayer.rs` | +| `fuzz_merkle_tree` | Commitment Merkle tree via the commitment set: ProofSome · ProofValid (leaf + auth path recomputes the root) · NonMembershipNone · IndicesSequential | `fuzz/fuzz_targets/fuzz_merkle_tree.rs` | +| `fuzz_transaction_properties` | Transaction property invariants: HashDeterministic/HashNonDefault, SignerIds derived from witness keys & non-empty, AffectedAccountsContainSigners, PublicDiffNonEmptyOnSuccess | `fuzz/fuzz_targets/fuzz_transaction_properties.rs` | +| `fuzz_privacy_preserving_witness` | `privacy_preserving_transaction::WitnessSet`: CorrectVerification (witness for msg A passes `signatures_are_valid_for(A)`) + MessageIsolation + SignerIdsMatchWitnessKeys | `fuzz/fuzz_targets/fuzz_privacy_preserving_witness.rs` | +| `fuzz_encoding_privacy_preserving` | Privacy-preserving encoding: MessageEncodingRoundtrip + TxEncodingDeterministic/NonEmpty | `fuzz/fuzz_targets/fuzz_encoding_privacy_preserving.rs` | +| `fuzz_nullifier_set_roundtrip` | `NullifierSet` Borsh serialisation: NullifierSetRoundtrip (decode→encode identity for the hand-written impl) | `fuzz/fuzz_targets/fuzz_nullifier_set_roundtrip.rs` | + +> **Input-independent checks are not fuzz targets here.** Deterministic invariants +> that ignore their input (e.g. genesis-account contents, getter/round-trip +> identities, the system-account-modification guard) belong in `logos-execution-zone` +> unit tests, not the fuzz corpus. See +> [`docs/mutants-not-fuzzable.md`](docs/mutants-not-fuzzable.md) for the policy and +> the mutant→test mapping. --- @@ -187,20 +209,23 @@ just clean-all # All of the above ## CI -GitHub Actions runs four jobs on every push/PR and nightly: +GitHub Actions runs these workflows on every push/PR and nightly: -| Job | What it does | -|-----|-------------| -| `smoke-fuzz` (matrix, 9 targets) | Builds + runs each target for 60 s | -| `regression` (matrix, 9 targets) | Replays the saved corpus (`-runs=0`) | -| `proptest` | `cargo test -p fuzz_props --release` | -| `perf-baseline` (nightly only) | Measures exec/sec per target, uploads `perf_baseline.txt` | +| Workflow | What it does | +|----------|-------------| +| `fuzz.yml` — `smoke-fuzz` (matrix) | Builds + runs each libFuzzer target for 60 s | +| `fuzz.yml` — `regression` (matrix) | Replays the saved corpus (`-runs=0`) | +| `fuzz.yml` — `proptest` | `cargo test -p fuzz_props --release` | +| `fuzz.yml` — `perf-baseline` (nightly only) | Measures exec/sec per target, uploads `perf_baseline.txt` | +| `fuzz-afl.yml` | AFL++ lane over the same targets/corpus | +| `mutants.yml` | Mutation testing (`cargo-mutants`) | +| `lint.yml` | Formatting + Clippy | -> **Note:** The CI matrix currently lists the original 9 targets. The 5 new targets -> (`fuzz_state_serialization`, `fuzz_witness_set_verification`, -> `fuzz_program_deployment_lifecycle`, `fuzz_apply_state_diff_split_path`, -> `fuzz_multi_block_state_sequence`) need to be added to `.github/workflows/fuzz.yml` -> — see [`docs/fuzzing.md`](docs/fuzzing.md) for the manual fallback instructions. +> **Note:** The `fuzz.yml` matrix currently lists 15 of the 20 libFuzzer targets. +> Still missing: `fuzz_merkle_tree`, `fuzz_transaction_properties`, +> `fuzz_privacy_preserving_witness`, `fuzz_encoding_privacy_preserving`, and +> `fuzz_nullifier_set_roundtrip` — add them to `.github/workflows/fuzz.yml`. See +> [`docs/fuzzing.md`](docs/fuzzing.md) for the manual fallback instructions. --- diff --git a/current_vs_alternative_approach.md b/current_vs_alternative_approach.md index 8498316..ba97b25 100644 --- a/current_vs_alternative_approach.md +++ b/current_vs_alternative_approach.md @@ -11,8 +11,8 @@ The `lez-fuzzing` repository is a **coverage-guided, structured mutation fuzzing | Rich generators | [`fuzz_props::generators`](fuzz_props/src/generators.rs) adds `proptest` strategies for pathological sequences, phantom-account attacks, overflow amounts, replay sequences | | Protocol invariants | [`fuzz_props::invariants`](fuzz_props/src/invariants.rs) expresses zero-mutation-on-rejection and replay-rejection as reusable `ProtocolInvariant` objects | | ZK-awareness | `RISC0_DEV_MODE=1` stubs out `risc0-zkvm` proofs, enabling ~5 000–200 000 exec/sec depending on target | -| 15 dedicated targets | Covers encoding, signature verification, stateless checks, state transitions, state diffs, replay prevention, validate/execute consistency, block verification, state serialization, witness-set verification, program deployment lifecycle, split-path equivalence, multi-block sequences, sequencer-vs-replayer differential | -| CI integration | GitHub Actions smoke, regression, and performance-baseline jobs run on every PR | +| 20 dedicated targets | Covers encoding, signature verification, stateless checks, state transitions, state diffs, replay prevention, validate/execute consistency, block verification, state serialization, witness-set verification, program deployment lifecycle, split-path equivalence, multi-block sequences, sequencer-vs-replayer differential, Merkle-tree invariants, transaction properties, privacy-preserving witness/encoding, and nullifier-set round-trips. Input-independent invariant checks (genesis contents, getters, system-account guard) are kept as **LEZ unit tests**, not targets — see [`docs/mutants-not-fuzzable.md`](docs/mutants-not-fuzzable.md) | +| CI integration | GitHub Actions libFuzzer (`fuzz.yml`), AFL++ (`fuzz-afl.yml`), and mutation-testing (`mutants.yml`) workflows run on every PR / nightly | | Pre-seeded corpus | Hundreds of minimised seed files in [`fuzz/corpus/`](fuzz/corpus/) ensure regressions are caught instantly | --- @@ -32,7 +32,7 @@ The `lez-fuzzing` repository is a **coverage-guided, structured mutation fuzzing | CI ergonomics | Requires AFL++ binary in CI image | `cargo install cargo-fuzz` only | | Rust integration | `cargo-afl` | `cargo-fuzz` | -**Decision-maker view**: AFL++ and libFuzzer find *different* bugs because they use different mutation heuristics. Running both on the same corpus is the industry-standard "belt and suspenders" approach. [`docs/fuzzing.md`](docs/fuzzing.md:355) already lists `just fuzz-afl` as planned future work. **Incremental cost is low** — the same [`fuzz_props`](fuzz_props/src/lib.rs) crate and seed corpus work unchanged. +**Decision-maker view**: ✅ **Implemented.** AFL++ and libFuzzer find *different* bugs because they use different mutation heuristics, and running both on the same corpus is the industry-standard "belt and suspenders" approach. AFL++ is now a live lane: `just fuzz-afl` / `just fuzz-afl-parallel` and the `.github/workflows/fuzz-afl.yml` nightly job, sharing the same [`fuzz_props`](fuzz_props/src/lib.rs) crate and seed corpus at **zero migration cost**. --- @@ -111,7 +111,19 @@ The extension noted in [`docs/fuzzing.md`](docs/fuzzing.md:356) is: | Execution time | Slow (recompile per mutation) | Continuous | | Output | Surviving mutants = assertion gaps | Crash artifacts | -**Decision-maker view**: `cargo-mutants` would **audit the invariant assertions themselves** — revealing if [`assert_invariants()`](fuzz_props/src/invariants.rs) has gaps. Three invariants are fully implemented and registered in `assert_invariants()`: [`StateIsolationOnFailure`](fuzz_props/src/invariants.rs:60), [`BalanceConservation`](fuzz_props/src/invariants.rs:94), and [`FailedTxNonceStability`](fuzz_props/src/invariants.rs:130). Two additional invariants — [`ReplayRejection`](fuzz_props/src/invariants.rs:167) and [`NonceIncrementCorrectness`](fuzz_props/src/invariants.rs:194) — are enforced exclusively via standalone helpers (`assert_replay_rejection`, `assert_nonce_increment_correctness`) and are **not** in the `assert_invariants()` registry; this is intentional because they require data consumed before `InvariantCtx` is built. This is a **complementary quality gate**, not a fuzzing replacement. Low cost (~1 day), highly useful before an external security audit. +**Decision-maker view**: ✅ **Implemented.** `cargo-mutants` runs in two modes — +`just mutants-harness` (mutates `fuzz_props`, oracle = `cargo test`, auditing the +invariant assertions themselves) and `just mutants-protocol` (mutates the LEZ +`lee`/`common` crates, oracle = a fuzz-corpus replay), with a `mutants.yml` CI job. +The two oracles correspond to a deliberate **Plane A / Plane B** split — see +[`docs/mutants-not-fuzzable.md`](docs/mutants-not-fuzzable.md), which catalogues +the mutants each plane is and isn't expected to catch and why. (For reference, the +`fuzz_props` registry still implements [`StateIsolationOnFailure`](fuzz_props/src/invariants.rs), +[`BalanceConservation`](fuzz_props/src/invariants.rs), and +[`FailedTxNonceStability`](fuzz_props/src/invariants.rs) in `assert_invariants()`, +with `ReplayRejection` and `NonceIncrementCorrectness` enforced via standalone +helpers outside the registry.) This is a **complementary quality gate**, not a +fuzzing replacement. --- @@ -120,19 +132,34 @@ The extension noted in [`docs/fuzzing.md`](docs/fuzzing.md:356) is: | Approach | Bug-finding depth | CI cost | Impl. cost | Complements current? | Recommended action | |---|---|---|---|---|---| | **Current (cargo-fuzz/libFuzzer)** | High | Medium | ✅ Done | — | Maintain & expand | -| AFL++ | High (different bugs) | Medium | Low | ✅ Yes | Add `just fuzz-afl` (already planned) | +| AFL++ | High (different bugs) | Medium | ✅ Done | ✅ Yes | ✅ Implemented (`just fuzz-afl`, `fuzz-afl.yml`) | | Honggfuzz | High on Linux | Medium | Medium | ✅ Yes | Add for Linux CI only | | proptest-only | Low–medium | Low | ✅ Done | Already present | Keep as unit-test layer | | Differential (sequencer/replayer) | Very high (new bug class) | Medium | ✅ Done | ✅ Yes | ✅ Implemented (`fuzz_sequencer_vs_replayer`) | | Formal verification | Exhaustive (selected invariants) | Very high | Very high | ✅ Yes | Long-term supplement | -| Mutation testing (`cargo-mutants`) | Measures assertion quality | High | Low | ✅ Yes | Pre-audit quality gate | +| Mutation testing (`cargo-mutants`) | Measures assertion quality | High | ✅ Done | ✅ Yes | ✅ Implemented (`just mutants-harness` / `mutants-protocol`) | --- ## Decision-maker Recommendations -**Highest-ROI next steps, in priority order:** +**Already done** (was previously recommended here): -1. **Add AFL++ as a parallel fuzzing lane** (`just fuzz-afl`) — zero corpus migration cost, discovers different mutation paths through the same targets as libFuzzer. +- ✅ **AFL++ parallel lane** — `just fuzz-afl` + `fuzz-afl.yml`. +- ✅ **`cargo-mutants`** — `just mutants-harness` / `mutants-protocol` + `mutants.yml`, + with the Plane A / Plane B framework documented in + [`docs/mutants-not-fuzzable.md`](docs/mutants-not-fuzzable.md). +- ✅ **Differential testing** — `fuzz_sequencer_vs_replayer`. -2. **Add `cargo-mutants`** before any external security audit — proves the invariant assertions in [`fuzz_props/src/invariants.rs`](fuzz_props/src/invariants.rs) are actually capable of catching the bugs they claim to detect. \ No newline at end of file +**Remaining higher-ROI next steps, in priority order:** + +1. **Finish the `fuzz.yml` CI matrix** — it lists 15 of the 20 libFuzzer targets; + add `fuzz_merkle_tree`, `fuzz_transaction_properties`, + `fuzz_privacy_preserving_witness`, `fuzz_encoding_privacy_preserving`, and + `fuzz_nullifier_set_roundtrip`. + +2. **Honggfuzz on Linux CI only** — hardware-counter coverage finds different paths; + gated to Linux since Apple Silicon has no HW counters. + +3. **Formal verification of core invariants** (balance conservation, replay + prevention) — a long-term supplement, not a replacement. \ No newline at end of file diff --git a/docs/fuzzing.md b/docs/fuzzing.md index e8e1db5..5c3579e 100644 --- a/docs/fuzzing.md +++ b/docs/fuzzing.md @@ -659,10 +659,10 @@ Reconcile new `mutants-protocol` runs against that list: only a surviving mutant crate that owns the code. Three such targets once existed (`fuzz_common_invariants`, `fuzz_genesis_invariants`, `fuzz_system_account_protection`); their invariants were ported to LEZ unit tests -and the targets removed. The mutant→test mapping and verification are recorded in -[`input-independent-target-coverage.md`](input-independent-target-coverage.md). -When adding a target, drive it from `data`; if a check doesn't depend on the -input, write it as a unit test in `logos-execution-zone` instead. +and the targets removed. The mutant→test mapping is recorded under "Group 2" in +[`mutants-not-fuzzable.md`](mutants-not-fuzzable.md). When adding a target, drive it +from `data`; if a check doesn't depend on the input, write it as a unit test in +`logos-execution-zone` instead. --- diff --git a/docs/mutants-not-fuzzable.md b/docs/mutants-not-fuzzable.md new file mode 100644 index 0000000..0efdf03 --- /dev/null +++ b/docs/mutants-not-fuzzable.md @@ -0,0 +1,231 @@ +# Mutants Not Coverable by Fuzzing + +This document catalogues the source mutations (from `just mutants-protocol`, the +"Plane B" corpus-replay mutation run over the `lee` / `common` crates) that the +**fuzzing corpus is not the right tool to catch**, together with where each one is +actually covered. + +It exists to keep a clean separation between two questions that the tooling can +otherwise blur together: + +- **"Does a test catch this mutant?"** — answered by the `lee` crate's own unit + tests via `cargo test` (call this **Plane A**). +- **"Does the committed fuzz corpus catch this mutant?"** — answered by + `just mutants-protocol`, which replaces `cargo test` with a fuzz-corpus replay + (`cargo fuzz run … -runs=0`) as the oracle (call this **Plane B**). + +The mutants listed here are **expected Plane-B misses**. A future +`mutants-protocol` run that reports them as surviving is *not* a regression — it +is the documented, intended state. + +This file is the complete registry, in **two groups**: + +1. **Structurally unreachable by fuzzing** (Group 1) — mutants behind code that a + fuzzer cannot reach from raw bytes (they need a valid executing transaction or a + deliberately-misbehaving program). These were always unit-test territory. +2. **Migrated input-independent targets** (Group 2) — mutants that *were* caught by + input-independent fuzz targets (`fuzz_common_invariants`, + `fuzz_genesis_invariants`, `fuzz_system_account_protection`). Because an + input-independent target is a unit test in disguise, those targets were removed + and their invariants ported to LEZ unit tests; the mutants therefore now survive + Plane B by design. + +Reconcile new `mutants-protocol` runs against this registry; only a surviving +mutant on **neither** list warrants a new corpus input. + +--- + +## Why fuzzing is the wrong tool for these + +Fuzzing earns its keep by exploring a large, *unknown* input space to find inputs +a human wouldn't think of — malformed transactions, adversarial byte sequences, +surprising state-transition orderings. The corpus-replay oracle then re-runs those +discovered inputs cheaply as a regression net. + +The mutations below live behind code that is only reachable by a **specific, +valid, semantically rich object** that random bytes essentially never synthesise: + +1. **A fully-valid, executing transaction.** Reaching the post-execution + validation logic (authorization checks, claim checks, cycle limit) requires a + transaction whose signature matches its signer, whose nonce matches the + on-chain nonce, and whose program is deployed. A fuzzer mutating raw bytes + almost always breaks one of these and is rejected at the stateless/nonce gate + *before* any program runs — so the code never executes. Constructing such a + transaction is a deterministic "this exact scenario must hold" property, which + is the domain of **unit tests**, not input exploration. + +2. **A deliberately-misbehaving program.** Some validator checks only fire when a + program returns malformed output (claims an account it shouldn't, mutates a + default account without claiming it, etc.). The only such programs are the + test fixtures behind `V03State::with_test_programs()` (`program_owner_changer`, + `extra_output_program`, …). They are **never deployed** in genesis or + production, so they are unreachable through the public transaction API that the + fuzzer drives — by construction, no fuzz input can exercise them. + +In both cases the behaviour is pinned by deterministic unit tests in the `lee` / +`common` crates. Encoding such scenarios as **input-independent** fuzz targets +(targets that ignore their input and run a fixed battery) is an anti-pattern — it +duplicates the unit-test role, adds heavyweight zkVM work to every corpus replay, +and risks silent corpus rot, all to satisfy a metric (Plane B) better served by +documenting the boundary. `lez-fuzzing` therefore keeps **no** input-independent +targets: the public/privacy execution targets (which duplicated existing `lee` +tests) and the three genesis/common/system targets (whose invariants were ported +to new unit tests — see the companion doc) were all removed. + +--- + +## Catalogue (Group 1 — structurally unreachable by fuzzing) + +The nine mutations reported as MISSED by the `mutants-protocol` run for which +fuzzing is structurally the wrong tool, with their true coverage. Verified by +applying each mutation to the `logos-execution-zone` working tree and running the +cited tests (`RISC0_DEV_MODE=1 cargo test -p lee --lib`). (Group 2 — the migrated +input-independent-target mutants — is summarised further down.) + +| # | Location | Mutation | Category | Covered by | +|---|----------|----------|----------|------------| +| 1 | `lee/state_machine/src/program.rs:21:51` | `*` → `/` (cycle limit `32`) | Valid-tx unit test | transfer-execution tests | +| 2 | `lee/state_machine/src/program.rs:21:51` | `*` → `+` (cycle limit `33 792`) | Valid-tx unit test | transfer-execution tests | +| 3 | `lee/state_machine/src/program.rs:21:58` | `*` → `/` (cycle limit `32 768`) | Valid-tx unit test | transfer-execution tests | +| 4 | `lee/state_machine/src/program.rs:21:58` | `*` → `+` (cycle limit `1 048 608`) | **Near-equivalent — genuine gap** | nothing (see below) | +| 5 | `lee/state_machine/src/validated_state_diff.rs:155:21` | `\|\|` → `&&` | Valid-tx unit test | transfer-execution tests | +| 6 | `lee/state_machine/src/validated_state_diff.rs:311:34` | `!=` → `==` | Misbehaving-program unit test | `public_changer_claimer_*` | +| 7 | `lee/state_machine/src/validated_state_diff.rs:314:20` | `==` → `!=` | Misbehaving-program unit test | `public_changer_claimer_*` + validity-window tests | +| 8 | `lee/state_machine/src/privacy_preserving_transaction/circuit.rs:88:32` | `>=` → `<` | Valid-PP-tx unit test | PP transition tests | +| 9 | `lee/state_machine/src/state.rs:335:16` | delete `!` | Valid-PP-tx unit test | PP transition tests | + +### Category A — Covered by `lee` unit tests, requires a valid *executing* transaction (1–3, 5, 8, 9) + +These fire only after a fully-valid transaction reaches real program execution. +A fuzzer's random bytes are rejected at the nonce/signature gate first, so the +corpus never reaches them; the `lee` crate pins each with a deterministic test. + +- **1–3 (public cycle limit, the catchable variants).** + `MAX_NUM_CYCLES_PUBLIC_EXECUTION = 1024 * 1024 * 32` (= 33 554 432). A real + `authenticated_transfer` execution consumes **between 33 792 and 1 048 608** + RISC-V cycles, so any mutation lowering the limit below that range aborts + execution with *"Session limit exceeded"*. + Covered by `state::tests::transition_from_authenticated_transfer_program_invocation_*` + (and the ~66 other public-execution tests that run a transfer). Verified: limit + `33 792` → 66 tests fail. + +- **5 (`||` → `&&` in `is_authorized`, + `validated_state_diff.rs:155`).** With `&&`, the transaction signer is no longer + treated as authorized, so a valid transfer fails with + `InvalidAccountAuthorization`. Covered by the same transfer-execution tests. + Verified: 3 of 7 `transition_from*` tests fail. + +- **8 (`>=` → `<` in `execute_and_prove`, + `circuit.rs:88`).** With `<`, the chained-call guard fires on the first + iteration (`0 < MAX`) and proving aborts immediately with + `MaxChainedCallsDepthExceeded`. Covered by + `state::tests::transition_from_privacy_preserving_transaction_{shielded,private,deshielded}`. + Verified: 3 PP tests fail. + +- **9 (delete `!` in `check_nullifiers_are_valid`, + `state.rs:335`).** Removing the `!` inverts the digest check so a *recognised* + commitment-set digest is rejected, breaking every valid privacy-preserving + transfer that spends a private input. Covered by the same PP transition tests. + Verified: 3 PP tests fail. + +### Category B — Covered by `lee` unit tests, requires a *misbehaving* program (6, 7) + +These guard against a program returning malformed output (modifying or claiming a +default account incorrectly). Only the test-only fixtures behind +`V03State::with_test_programs()` misbehave this way; they are never deployed, so no +fuzz input can reach this code. The `lee` crate exercises them directly. + +- **6 (`!=` → `==`, `validated_state_diff.rs:311`)** — the + "only inspect uninitialised accounts" filter. Verified: 1 test fails under the + full `lee` suite. +- **7 (`==` → `!=`, `validated_state_diff.rs:314`)** — the + "skip unmodified accounts" guard. Verified: 16 tests fail, including + `state::tests::public_changer_claimer_data_change_no_claim_fails` and + `public_changer_claimer_no_data_change_no_claim_succeeds`. + +> Note: an earlier analysis guessed 6 and 7 were *equivalent mutants*. They are +> not — they are caught by Plane A, just not reachable by Plane B. They appear +> "equivalent" only if you restrict yourself to the deployed `authenticated_transfer` +> program, which is exactly the restriction fuzzing operates under. + +### Category C — The single genuine gap: near-equivalent weak mutant (4) + +- **4 (`*` → `+` at `program.rs:21:58`, cycle limit `1 048 608`).** + Catching this would require a *single* public program execution that consumes + **more than 1 048 608 RISC-V cycles**. The `authenticated_transfer` instruction + uses fewer than that (it is caught only by limits ≤ 33 792 — see category A), and + no deployed program's single instruction reaches ~1M cycles. The difference + between the mutated limit (1.05M) and the real limit (33.5M) is therefore + **unobservable for any realistic workload**, making this a practically + equivalent / weak mutant. Verified: survives the full `lee` suite (211/211 pass). + + It is not worth chasing in either plane. If a future deployed program legitimately + performs a >1M-cycle public execution, a normal execution test for that program + would catch this mutation incidentally. + +--- + +## Group 2 — migrated input-independent targets + +These mutants used to be caught by Plane B via input-independent fuzz targets. +Those targets were removed and their invariants ported to LEZ unit tests, so the +mutants now survive Plane B by design. They are **not** structurally unreachable +like Group 1 — a fuzzer could "catch" them, but only by running a fixed scenario +that ignores its input, which is a unit test, not fuzzing. + +Each port below was verified to kill its mutant (apply the mutation → run the named +test → observe a failure). Where a mutant had **no** prior unit-test coverage, the +port *added* coverage rather than merely relocating it; those are marked **(new)**. + +**From `fuzz_common_invariants`:** + +| Mutant | New unit test | +|---|---| +| `HashType::as_ref` → `Vec::leak(Vec::new())` / `vec![0]` / `vec![1]` | `common::tests::as_ref_returns_exact_inner_bytes` (`common/src/lib.rs`) **(new)** | +| `BasicAuth` `FromStr` delete `!` in `.filter(\|p\| !p.is_empty())` | `common::config::tests::parse_empty_password_is_none` (+ `parse_preserves_non_empty_password`) **(new)** | +| `Program::elf` → empty / `vec![0]` / `vec![1]` | `program::tests::elf_returns_the_program_bytecode_constant` (was already caught incidentally) | +| `Proof::into_inner` / `from_inner` → `vec![]` / `vec![0]` / `vec![1]` | `…::circuit::tests::proof_inner_roundtrip` **(new)** | +| `Message::into_bytecode` → `vec![]` / `vec![0]` / `vec![1]` | `program_deployment_transaction::message::tests::bytecode_roundtrip` **(new)** | + +**From `fuzz_genesis_invariants`** (all in `lee/state_machine/src/state.rs`): + +| Mutant | New unit test | +|---|---| +| `system_faucet_account` → `Default` / delete `balance` / delete `program_owner` | `state::tests::genesis_system_accounts_have_expected_contents` **(new)** | +| `system_bridge_account` → `Default` / delete `program_owner` | `genesis_system_accounts_have_expected_contents` **(new)** | +| `commitment_set_digest` → `Default` | `state::tests::genesis_commitment_set_digest_differs_from_empty_state` **(new)** | +| `add_pinata_token_program` delete `program_owner` / `data` | `state::tests::add_pinata_token_program_sets_non_default_owner_and_data` **(new)** | +| `system_faucet_account_id` / `system_bridge_account_id` → `Default` | `genesis_system_accounts_have_expected_contents` + `system_account_ids_are_distinct_and_non_default` (was already caught) | + +**From `fuzz_system_account_protection`:** + +| Mutant | New unit test | +|---|---| +| `validate_doesnt_modify_account` `!=` → `==` (`common/src/transaction.rs`) | `common::transaction::tests::validate_on_state_rejects_modifying_a_system_account` **(new)** | +| `public_diff` → `HashMap::new()` (`lee/.../validated_state_diff.rs`) | `validated_state_diff::tests::public_diff_reflects_a_successful_transfer` (+ the `validate_on_state_rejects…` test) **(new)** | +| `system_*_account_id` non-default / distinct | `common::transaction::tests::system_account_ids_are_distinct_and_non_default` (was already caught) | + +--- + +## Re-verifying + +From `logos-execution-zone/` with the fuzzing repo checked out as a sibling: + +```bash +export RISC0_DEV_MODE=1 + +# Pick a mutation from a table above, apply it to the cited line, then run the +# owning crate's tests (Plane A). A real failure ⇒ unit tests cover it. +cargo test -p lee --lib # lee-owned mutants +cargo test -p common # common-owned mutants (Group 2) +git checkout -- # always revert +``` + +A mutation that makes `cargo test` fail is covered by Plane A and belongs in this +registry; a mutation that the corpus replay (`just mutants-protocol`) catches +belongs in the corpus instead. Across both groups, mutation #4 (the near-equivalent +cycle-limit weak mutant) is the only one caught by **neither** plane. + +> Tip: when reverting, prefer reverse-editing only the mutated line rather than +> `git checkout -- ` if you have uncommitted unit tests in the same file — +> a whole-file checkout would discard them too.