lez-fuzzing/docs/mutants-not-fuzzable.md
2026-07-09 12:50:46 +02:00

18 KiB
Raw Blame History

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 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.)

Note

Line numbers drift as the LEZ source evolves. Reconcile by function name + mutation operator, not by line number. The Location column below was last refreshed against logos-execution-zone at e37876a64 (2026-07-08). If a mutants-protocol run reports a documented mutation at a different line, it is the same catalogued miss — update the line here rather than treating it as new.

# Location Mutation Category Covered by
1 lee/state_machine/src/program.rs:15:51 */ (cycle limit 32) Valid-tx unit test transfer-execution tests
2 lee/state_machine/src/program.rs:15:51 *+ (cycle limit 33 792) Valid-tx unit test transfer-execution tests
3 lee/state_machine/src/program.rs:15:58 */ (cycle limit 32 768) Valid-tx unit test transfer-execution tests
4 lee/state_machine/src/program.rs:15:58 *+ (cycle limit 1 048 608) Near-equivalent — genuine gap nothing (see below)
5 lee/state_machine/src/validated_state_diff.rs:160:21 ||&& Valid-tx unit test transfer-execution tests
6 lee/state_machine/src/validated_state_diff.rs:316:34 !=== Misbehaving-program unit test public_changer_claimer_*
7 lee/state_machine/src/validated_state_diff.rs:319:20 ==!= Misbehaving-program unit test public_changer_claimer_* + validity-window tests
8 lee/state_machine/src/privacy_preserving_transaction/circuit.rs:90:32 >=< Valid-PP-tx unit test PP transition tests
9 lee/state_machine/src/state.rs:302:16 delete ! Valid-PP-tx unit test PP transition tests
10 lez/common/src/transaction.rs:173:17 delete balance field Bridge-guard gap (see Category D) nothing yet
11 lez/common/src/transaction.rs:176:27 ==!= Misbehaving-program unit test (see Category D) nothing yet
12 lez/common/src/transaction.rs:176:35 &&|| Misbehaving-program unit test (see Category D) nothing yet
13 lez/common/src/transaction.rs:176:51 <=> Bridge-guard gap (see Category D) nothing yet
14 lee/state_machine/src/signature/private_key.rs:71:12 delete ! in tweak Crypto key-derivation unit test signature::private_key::tests::tweak_deterministic

Category A — Covered by lee unit tests, requires a valid executing transaction (13, 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.

  • 13 (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.

Category D — System-bridge guard (1013) and key tweak (14)

Added after the original catalogue; both live behind a valid executing transaction and so are structurally unreachable by the byte-mutating fuzzer for the same reason as Categories A/B.

1013 — LeeTransaction::validate_bridge_account_modification (lez/common/src/transaction.rs:154). This guard runs inside validate_on_state and only does anything when the transaction's public_diff already modifies the system bridge account:

let Some(post) = diff.public_diff().get(&bridge_account_id).cloned() else {
    return Ok(());                       // bridge untouched → nothing to check
};
// …
let only_balance_increased = {
    let expected_pre = lee::Account { balance: pre.balance, ..post.clone() };
    (expected_pre == pre) && (pre.balance <= post.balance)   // lines 172176
};

Getting the bridge into public_diff at all is exactly what a random-bytes fuzzer cannot do:

  • A plain signed authenticated_transfer that credits the bridge is rejected by program execution with ClaimedUnauthorizedAccount (the bridge is not a signer), so it never lands in the diff. Verified by constructing such a transfer and running it through validate_on_state.
  • The only transactions that legitimately touch the bridge are sequencer-built deposits (programs::bridge() + bridge_core::Instruction::Deposit, empty witness set). Those change the bridge account's data, not just its balance, and are run through the guard-bypassing execute_without_system_accounts_check_on_state path — never through validate_on_state.

So no fuzz input can reach this guard, and common's existing validate_on_state-based tests pass trivially (the transfer errors before the guard). These four mutants are currently caught by neither plane — an genuine Plane-A gap on protocol-critical system-account-protection logic, not just a Plane-B miss:

# Mutation Killed by a test that asserts…
10 delete balance: pre.balance override a pure bridge balance increase is accepted
11 ==!= in expected_pre == pre a pure bridge balance increase is accepted
12 &&|| a bridge modification that also changes data is rejected
13 <=> a pure bridge balance increase is accepted

Recommended follow-up (in logos-execution-zone, not the fuzzing corpus): a common unit test that reaches the guard with a genuine bridge-modifying diff. It needs the bridge/vault programs and a bridge-owned account (i.e. the sequencer deposit construction), which common's test deps do not currently pull in — hence this is filed as a gap rather than resolved here. Mutant 12 also has a misbehaving-program flavour (a program that alters the bridge's data while raising its balance), mirroring Category B.

14 — delete ! in PrivateKey::tweak (lee/state_machine/src/signature/private_key.rs:71). tweak derives the "tweaked secret key" used for BIP-340 Schnorr signatures; it is called from key_protocol key derivation, never from the transaction-validation path a fuzzer drives, so raw bytes cannot reach it. Removing the ! inverts the up-front validity check (if !is_valid_key { return Err }), causing a valid key to be rejected. Covered by Plane A: signature::private_key::tests::tweak_deterministic tweaks [1; 32] and .unwrap()s the result — under the mutation that call returns Err(InvalidPrivateKey) and the test fails. Verified: applying the mutation fails tweak_deterministic (4 passed / 1 failed) under cargo test -p lee --lib.


🔁 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_refVec::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_innervec![] / vec![0] / vec![1] …::circuit::tests::proof_inner_roundtrip (new)
Message::into_bytecodevec![] / 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_accountDefault / delete balance / delete program_owner state::tests::genesis_system_accounts_have_expected_contents (new)
system_bridge_accountDefault / delete program_owner genesis_system_accounts_have_expected_contents (new)
commitment_set_digestDefault 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_idDefault 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_diffHashMap::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:

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 -- <mutated-file>       # 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. Caught by neither plane today: mutation #4 (the near-equivalent cycle-limit weak mutant) and mutations #10#13 (the system-bridge guard, Category D) — the latter a genuine Plane-A gap awaiting a common unit test that reaches the guard with a real bridge-modifying diff.

Tip

when reverting, prefer reverse-editing only the mutated line rather than git checkout -- <file> if you have uncommitted unit tests in the same file — a whole-file checkout would discard them too.