mirror of
https://github.com/logos-blockchain/lez-fuzzing.git
synced 2026-07-02 07:49:45 +00:00
fix: mutants harness
This commit is contained in:
parent
4657620ab7
commit
6710c878cf
@ -60,6 +60,11 @@
|
||||
clippy::let_underscore_untyped,
|
||||
reason = "seed-generation IO errors are intentionally ignored in tests"
|
||||
)]
|
||||
#![allow(
|
||||
clippy::pub_with_shorthand,
|
||||
reason = "`pub(crate)` shorthand exposes generators to the test module; the \
|
||||
contradictory `pub_without_shorthand` restriction lint stays active"
|
||||
)]
|
||||
|
||||
pub mod arbitrary_types;
|
||||
pub mod generators;
|
||||
|
||||
@ -116,7 +116,7 @@ pub fn synthesize_passing_proof(
|
||||
/// `public_account_nonce_increment` panics on overflow. An uncapped nonce would let the
|
||||
/// fuzzer drive a signer to `u128::MAX` via a forced-pass post-state and then trip that
|
||||
/// panic — a self-inflicted artefact, not a protocol bug.
|
||||
fn arb_account(u: &mut Unstructured<'_>) -> ArbResult<Account> {
|
||||
pub(crate) fn arb_account(u: &mut Unstructured<'_>) -> ArbResult<Account> {
|
||||
Ok(Account {
|
||||
program_owner: <[u32; 8]>::arbitrary(u)?,
|
||||
balance: u128::arbitrary(u)?,
|
||||
@ -138,7 +138,7 @@ fn arb_account(u: &mut Unstructured<'_>) -> ArbResult<Account> {
|
||||
/// straddle the harness's `block_id` / `timestamp` range (both `< 6`), landing on both sides of the
|
||||
/// check. `try_from` rejects `from >= to`; that falls back to unbounded rather than biasing toward
|
||||
/// always-valid windows.
|
||||
fn arb_validity_window(u: &mut Unstructured<'_>) -> ArbResult<ValidityWindow<u64>> {
|
||||
pub(crate) fn arb_validity_window(u: &mut Unstructured<'_>) -> ArbResult<ValidityWindow<u64>> {
|
||||
if (u8::arbitrary(u)? % 4) != 0 {
|
||||
return Ok(ValidityWindow::new_unbounded());
|
||||
}
|
||||
|
||||
@ -1,6 +1,11 @@
|
||||
use crate::privacy::synthesize_passing_proof;
|
||||
use arbitrary::Unstructured;
|
||||
|
||||
use crate::generators::FuzzAccount;
|
||||
use crate::privacy::{
|
||||
arb_account, arb_privacy_preserving_tx, arb_validity_window, synthesize_passing_proof,
|
||||
};
|
||||
use nssa::privacy_preserving_transaction::{Message as PPMessage, WitnessSet as PPWitnessSet};
|
||||
use nssa::{AccountId, PrivacyPreservingTransaction, V03State};
|
||||
use nssa::{AccountId, PrivacyPreservingTransaction, PrivateKey, V03State};
|
||||
use nssa_core::Commitment;
|
||||
use nssa_core::account::Account;
|
||||
use nssa_core::program::{BlockValidityWindow, TimestampValidityWindow};
|
||||
@ -61,3 +66,303 @@ fn synthesized_proof_reaches_checks_5_6_and_applies() {
|
||||
"replayed transaction must be rejected after its commitment was inserted",
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Generator contract tests
|
||||
//
|
||||
// The `arb_*` helpers in `privacy.rs` shape the fuzz input. Their bounding
|
||||
// arithmetic, dedup guards, and branch conditions decide the *shape* of every
|
||||
// generated transaction — how many signers/commitments/nullifiers it carries,
|
||||
// which accounts it touches, whether its proof is a passing one or garbage — but
|
||||
// none of that is visible in the encoded bytes, so the encoding/executor tests
|
||||
// cannot observe it. The tests below assert those shape guarantees directly.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Tiny deterministic xorshift64 PRNG so the distributional generator test below
|
||||
/// is reproducible (no `rand`, no clock seeding) yet samples a wide spread of inputs.
|
||||
struct Rng(u64);
|
||||
|
||||
impl Rng {
|
||||
const fn new() -> Self {
|
||||
Self(0x9E37_79B9_7F4A_7C15)
|
||||
}
|
||||
|
||||
fn next_u64(&mut self) -> u64 {
|
||||
let mut x = self.0;
|
||||
x ^= x << 13_u32;
|
||||
x ^= x >> 7_u32;
|
||||
x ^= x << 17_u32;
|
||||
self.0 = x;
|
||||
x
|
||||
}
|
||||
|
||||
fn fill(&mut self, buf: &mut [u8]) {
|
||||
for chunk in buf.chunks_mut(8) {
|
||||
let bytes = self.next_u64().to_le_bytes();
|
||||
for (dst, src) in chunk.iter_mut().zip(bytes.iter()) {
|
||||
*dst = *src;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `arb_account` caps the nonce at `u128 % 1024` to keep a forced-pass post-state
|
||||
/// from driving a signer's nonce to `u128::MAX` (and tripping the protocol's
|
||||
/// overflow panic on the subsequent increment). The cap must hold for every
|
||||
/// generated account regardless of the fuzz bytes.
|
||||
#[test]
|
||||
fn arb_account_nonce_capped_below_1024() {
|
||||
let buf = vec![0xAB_u8; 1024];
|
||||
let mut u = Unstructured::new(&buf);
|
||||
for _ in 0_u32..8 {
|
||||
let acc = arb_account(&mut u).expect("arb_account never errors on fill_buffer primitives");
|
||||
assert!(
|
||||
acc.nonce.0 < 1024,
|
||||
"nonce {} must stay within the [0, 1024) cap for any input",
|
||||
acc.nonce.0
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Each of `arb_account`'s three explicit fields must be sourced from the fuzz
|
||||
/// bytes, not left at `Account::default()` — deleting any field assignment leaves
|
||||
/// the corresponding field at its (zero) default.
|
||||
#[test]
|
||||
fn arb_account_fields_are_populated_from_fuzz_bytes() {
|
||||
let buf = vec![0xAB_u8; 256];
|
||||
let mut u = Unstructured::new(&buf);
|
||||
let acc = arb_account(&mut u).expect("arb_account never errors on fill_buffer primitives");
|
||||
let default = Account::default();
|
||||
|
||||
assert_ne!(
|
||||
acc.program_owner, default.program_owner,
|
||||
"program_owner must be drawn from the fuzz bytes, not left at its default"
|
||||
);
|
||||
assert_ne!(
|
||||
acc.balance, default.balance,
|
||||
"balance must be drawn from the fuzz bytes, not left at its default"
|
||||
);
|
||||
assert_ne!(
|
||||
acc.nonce, default.nonce,
|
||||
"nonce must be drawn from the fuzz bytes, not left at its default"
|
||||
);
|
||||
}
|
||||
|
||||
/// `arb_validity_window` leaves the window unbounded for ~3 of every 4 selector
|
||||
/// bytes (`u8 % 4 != 0`) so the success path stays frequently reachable. A selector
|
||||
/// of `1` satisfies `1 % 4 != 0`, so the window must come back fully unbounded —
|
||||
/// the function returns before it ever reads the follow-on bound bytes.
|
||||
#[test]
|
||||
fn arb_validity_window_selector_nonzero_is_unbounded() {
|
||||
// [selector=1, from_bool=1, from_val=2, to_bool=1, to_val=5]
|
||||
let buf = vec![1_u8, 1, 2, 1, 5];
|
||||
let mut u = Unstructured::new(&buf);
|
||||
let w = arb_validity_window(&mut u).expect("arb_validity_window never errors");
|
||||
assert_eq!(
|
||||
w.start(),
|
||||
None,
|
||||
"selector 1 (1 % 4 != 0) must yield an unbounded window"
|
||||
);
|
||||
assert_eq!(w.end(), None, "selector 1 must yield an unbounded window");
|
||||
}
|
||||
|
||||
/// The remaining ~1 in 4 selectors (`u8 % 4 == 0`) take the bounded path, where the
|
||||
/// follow-on bytes set actual `[from, to)` bounds. A selector of `0` must therefore
|
||||
/// produce a window with at least one finite bound.
|
||||
#[test]
|
||||
fn arb_validity_window_selector_zero_is_bounded() {
|
||||
// [selector=0, from_bool=1, from_val=2, to_bool=1, to_val=5]
|
||||
let buf = vec![0_u8, 1, 2, 1, 5];
|
||||
let mut u = Unstructured::new(&buf);
|
||||
let w = arb_validity_window(&mut u).expect("arb_validity_window never errors");
|
||||
assert!(
|
||||
w.start().is_some() || w.end().is_some(),
|
||||
"selector 0 (0 % 4 == 0) must yield a bounded window"
|
||||
);
|
||||
}
|
||||
|
||||
/// On the bounded path both bounds are kept in `0..8` via `u8 % 8` so they straddle
|
||||
/// the harness's block/timestamp range. With `from_val = 8` (→ `8 % 8 = 0`) and
|
||||
/// `to_val = 5` (→ `5`) the resulting window must be exactly `[0, 5)`.
|
||||
#[test]
|
||||
fn arb_validity_window_bounds_use_modulo_8() {
|
||||
// [selector=0, from_bool=1, from_val=8, to_bool=1, to_val=5] → window [0, 5)
|
||||
let buf = vec![0_u8, 1, 8, 1, 5];
|
||||
let mut u = Unstructured::new(&buf);
|
||||
let w = arb_validity_window(&mut u).expect("arb_validity_window never errors");
|
||||
assert_eq!(w.start(), Some(0_u64), "from must be 8 % 8 = 0");
|
||||
assert_eq!(w.end(), Some(5_u64), "to must be 5 % 8 = 5");
|
||||
}
|
||||
|
||||
/// Drive `arb_privacy_preserving_tx` over many pseudo-random inputs and assert the
|
||||
/// structural guarantees of the transactions it builds: the bounded counts, the
|
||||
/// in-range account indexing, the deduplicated/non-empty field sets, and the
|
||||
/// passing-vs-garbage proof mix. Six distinct keyed accounts give the signer count
|
||||
/// headroom above its cap of 3 (so any over-counting shows up) and provide several
|
||||
/// valid indices (so off-by-one indexing would read past the slice and panic).
|
||||
///
|
||||
/// Two flavours of check run here: per-iteration upper bounds that must hold for
|
||||
/// *every* generated transaction, and end-of-run reachability checks that confirm
|
||||
/// the interesting shapes actually occur across the sampled inputs.
|
||||
#[test]
|
||||
fn arb_privacy_preserving_tx_generator_invariants() {
|
||||
let accounts: Vec<FuzzAccount> = (1..=6_u8)
|
||||
.map(|i| FuzzAccount {
|
||||
account_id: AccountId::new([i; 32]),
|
||||
balance: 1_000_000,
|
||||
private_key: PrivateKey::try_new([i; 32]).expect("nonzero scalar is a valid key"),
|
||||
})
|
||||
.collect();
|
||||
let genesis: Vec<(AccountId, u128)> =
|
||||
accounts.iter().map(|a| (a.account_id, a.balance)).collect();
|
||||
let state = V03State::new_with_genesis_accounts(&genesis, vec![], 0);
|
||||
|
||||
let mut rng = Rng::new();
|
||||
let mut buf = vec![0_u8; 8192];
|
||||
|
||||
let mut oks = 0_usize;
|
||||
let mut max_signers = 0_usize;
|
||||
let mut saw_signer = false;
|
||||
let mut saw_extra = false;
|
||||
let mut max_commitments = 0_usize;
|
||||
let mut max_nullifiers = 0_usize;
|
||||
let mut saw_empty_comm_nonempty_null = false;
|
||||
let mut garbage = 0_usize;
|
||||
let mut saw_garbage = false;
|
||||
|
||||
for _ in 0..2000_usize {
|
||||
rng.fill(&mut buf);
|
||||
let mut u = Unstructured::new(&buf);
|
||||
// Never returns Err: every leaf is a `fill_buffer`-backed primitive that
|
||||
// zero-pads rather than failing. (Indexing an account slice out of range
|
||||
// would instead panic — also a failure this test would surface.)
|
||||
let tx = arb_privacy_preserving_tx(&mut u, &state, &accounts)
|
||||
.expect("generator never returns Err for fill_buffer-backed primitives");
|
||||
oks += 1;
|
||||
let msg = tx.message();
|
||||
|
||||
let signer_ids: Vec<AccountId> = tx
|
||||
.witness_set()
|
||||
.signatures_and_public_keys()
|
||||
.iter()
|
||||
.map(|(_, pk)| AccountId::from(pk))
|
||||
.collect();
|
||||
let n_signers = signer_ids.len();
|
||||
max_signers = max_signers.max(n_signers);
|
||||
saw_signer |= n_signers >= 1;
|
||||
|
||||
// ── per-transaction upper bounds ──
|
||||
// The signer count is drawn modulo `max_signers + 1`, so it can never exceed
|
||||
// the cap of 3 distinct signers.
|
||||
assert!(n_signers <= 3, "n_signers {n_signers} exceeds the cap of 3");
|
||||
// Post-states are drawn modulo `public_account_ids.len() + 1`, so there is
|
||||
// never a post-state without a corresponding public account.
|
||||
assert!(
|
||||
msg.public_post_states.len() <= msg.public_account_ids.len(),
|
||||
"public_post_states {} exceeds public_account_ids {}",
|
||||
msg.public_post_states.len(),
|
||||
msg.public_account_ids.len()
|
||||
);
|
||||
// At most 3 signers plus at most 3 extra ids (both deduplicated).
|
||||
assert!(
|
||||
msg.public_account_ids.len() <= 6,
|
||||
"public_account_ids {} exceeds signers (<=3) + extras (<=3)",
|
||||
msg.public_account_ids.len()
|
||||
);
|
||||
// `new_commitments` count is drawn modulo 4 (0..=3).
|
||||
assert!(
|
||||
msg.new_commitments.len() <= 3,
|
||||
"new_commitments {} exceeds 3",
|
||||
msg.new_commitments.len()
|
||||
);
|
||||
// `new_nullifiers` count is drawn modulo 3 (0..=2).
|
||||
assert!(
|
||||
msg.new_nullifiers.len() <= 2,
|
||||
"new_nullifiers {} exceeds 2",
|
||||
msg.new_nullifiers.len()
|
||||
);
|
||||
// `encrypted_private_post_states` count is drawn modulo 3 (0..=2).
|
||||
assert!(
|
||||
msg.encrypted_private_post_states.len() <= 2,
|
||||
"encrypted_private_post_states {} exceeds 2",
|
||||
msg.encrypted_private_post_states.len()
|
||||
);
|
||||
|
||||
// An id that is not a signer can only be present because an extra was appended.
|
||||
if msg
|
||||
.public_account_ids
|
||||
.iter()
|
||||
.any(|id| !signer_ids.contains(id))
|
||||
{
|
||||
saw_extra = true;
|
||||
}
|
||||
|
||||
max_commitments = max_commitments.max(msg.new_commitments.len());
|
||||
max_nullifiers = max_nullifiers.max(msg.new_nullifiers.len());
|
||||
|
||||
// The fallback that guarantees "commitments or nullifiers non-empty" must fire
|
||||
// only when *both* are empty. So a message with empty commitments but non-empty
|
||||
// nullifiers is a valid, reachable shape — the fallback must leave it alone.
|
||||
if msg.new_commitments.is_empty() && !msg.new_nullifiers.is_empty() {
|
||||
saw_empty_comm_nonempty_null = true;
|
||||
}
|
||||
|
||||
// Which proof branch ran? A synthesized passing proof is a deterministic
|
||||
// function of (message, state, signers); re-synthesizing reproduces it
|
||||
// byte-for-byte, so anything else is the garbage-bytes branch.
|
||||
let synth = synthesize_passing_proof(msg, &state, &signer_ids);
|
||||
if tx.witness_set().proof() == &synth {
|
||||
// synthesized passing proof
|
||||
} else {
|
||||
garbage += 1;
|
||||
saw_garbage = true;
|
||||
}
|
||||
}
|
||||
|
||||
assert!(
|
||||
oks > 1000,
|
||||
"expected many successful generations, got {oks}"
|
||||
);
|
||||
|
||||
// ── reachability across the sampled inputs ──
|
||||
// With accounts present, transactions must sometimes carry signers.
|
||||
assert!(saw_signer, "no transaction ever carried a signer");
|
||||
// The full signer range up to the cap of 3 distinct signers must be reachable.
|
||||
assert_eq!(
|
||||
max_signers, 3,
|
||||
"the generator never reached 3 distinct signers"
|
||||
);
|
||||
// Extra public account ids must actually get appended.
|
||||
assert!(
|
||||
saw_extra,
|
||||
"the generator never appended an extra public account id"
|
||||
);
|
||||
// Multiple distinct commitments must be reachable (the dedup must keep, not drop).
|
||||
assert!(
|
||||
max_commitments >= 2,
|
||||
"the generator never produced >= 2 commitments"
|
||||
);
|
||||
// Multiple distinct nullifiers must be reachable (the dedup must keep, not drop).
|
||||
assert!(
|
||||
max_nullifiers >= 2,
|
||||
"the generator never produced >= 2 nullifiers"
|
||||
);
|
||||
// The empty-commitments + non-empty-nullifiers shape must be reachable, proving the
|
||||
// fallback does not over-fire.
|
||||
assert!(
|
||||
saw_empty_comm_nonempty_null,
|
||||
"the generator never produced empty commitments with non-empty nullifiers"
|
||||
);
|
||||
// The garbage-proof branch (~1 in 8) must be reachable at all.
|
||||
assert!(saw_garbage, "the generator never produced a garbage proof");
|
||||
// The garbage-proof rate must sit near the intended 1/8. Integer bands avoid float
|
||||
// arithmetic: it must fall within [1/16, 1/4].
|
||||
assert!(
|
||||
garbage * 4 <= oks,
|
||||
"garbage-proof rate {garbage}/{oks} is above 1/4 (expected ~1/8)"
|
||||
);
|
||||
assert!(
|
||||
garbage * 16 >= oks,
|
||||
"garbage-proof rate {garbage}/{oks} is below 1/16 (expected ~1/8)"
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user