test: add merkle tree target to catch 33 mutants

This commit is contained in:
Roman 2026-06-09 11:51:39 +08:00
parent 1cfe58ebf9
commit 2974cd5e30
No known key found for this signature in database
GPG Key ID: 583BDF43C238B83E
4 changed files with 139 additions and 0 deletions

1
fuzz/Cargo.lock generated
View File

@ -2084,6 +2084,7 @@ dependencies = [
"lee",
"lee_core",
"libfuzzer-sys",
"sha2",
"testnet_initial_state",
]

View File

@ -44,6 +44,7 @@ libfuzzer-sys = { version = "0.4", optional = true }
afl = { version = "0.15", optional = true }
arbitrary = { version = "1", features = ["derive"] }
borsh = "1"
sha2 = "0.10"
nssa = { path = "../../logos-execution-zone/lee/state_machine", package = "lee" }
nssa_core = { path = "../../logos-execution-zone/lee/state_machine/core", package = "lee_core" }
common = { path = "../../logos-execution-zone/lez/common" }
@ -119,3 +120,9 @@ name = "fuzz_sequencer_vs_replayer"
path = "fuzz_targets/fuzz_sequencer_vs_replayer.rs"
test = false
bench = false
[[bin]]
name = "fuzz_merkle_tree"
path = "fuzz_targets/fuzz_merkle_tree.rs"
test = false
bench = false

View File

@ -0,0 +1,130 @@
#![cfg_attr(feature = "fuzzer-libfuzzer", no_main)]
//! Fuzz target: `MerkleTree` structural invariants
//!
//! Covered code paths (all in `lee/state_machine/src/merkle_tree/mod.rs`):
//!
//! ```text
//! MerkleTree::with_capacity(1) ← initial capacity forces reallocate_to_double_capacity
//! MerkleTree::insert(value) ← per-value; also triggers reallocate_to_double_capacity
//! MerkleTree::root() ← sampled once after all inserts
//! MerkleTree::get_authentication_path_for(index) ← per-value
//! prev_power_of_two ← exercised inside reallocate_to_double_capacity
//! ```
//!
//! # Input format
//!
//! The raw fuzz bytes are sliced into 32-byte chunks; each chunk becomes one
//! value inserted into the tree. This makes the format trivial to reason about
//! and lets us seed the corpus with well-known test vectors.
//!
//! # Invariants checked
//!
//! 1. **InsertionIndex** — `insert(value)` returns the sequential 0-based index.
//! 2. **AuthPathSome** — `get_authentication_path_for(i)` is `Some` for every
//! `i < length`.
//! 3. **AuthPathValid** — every returned path re-hashes (SHA-256, same hash
//! functions used by the production code) to the value reported by `root()`.
//! 4. **OutOfBoundsNone** — `get_authentication_path_for(length)` returns `None`.
use sha2::{Digest as _, Sha256};
// ─── Reference hash helpers (mirrors the private functions in merkle_tree/mod.rs) ───
/// SHA-256 of a single 32-byte leaf value. Mirrors `hash_value`.
fn sha256_one(v: &[u8; 32]) -> [u8; 32] {
let mut h = Sha256::new();
h.update(v);
h.finalize().into()
}
/// SHA-256 of two concatenated 32-byte nodes. Mirrors `hash_two`.
fn sha256_two(left: &[u8; 32], right: &[u8; 32]) -> [u8; 32] {
let mut h = Sha256::new();
h.update(left);
h.update(right);
h.finalize().into()
}
/// Reference implementation of authentication-path verification.
///
/// Mirrors `verify_authentication_path` from the test module inside
/// `lee/state_machine/src/merkle_tree/mod.rs`.
///
/// Algorithm:
/// result ← SHA-256(value)
/// for each sibling in path:
/// if level_index is even → result is the LEFT child → hash(result, sibling)
/// if level_index is odd → result is the RIGHT child → hash(sibling, result)
/// level_index >>= 1
/// return result == root
fn verify_auth_path(value: &[u8; 32], index: usize, path: &[[u8; 32]], root: &[u8; 32]) -> bool {
let mut result = sha256_one(value);
let mut level_index = index;
for sibling in path {
let is_left_child = level_index & 1 == 0;
result = if is_left_child {
sha256_two(&result, sibling)
} else {
sha256_two(sibling, &result)
};
level_index >>= 1;
}
&result == root
}
fuzz_props::fuzz_entry!(|data: &[u8]| {
// Treat each 32-byte chunk as one leaf value. Discard any trailing
// incomplete chunk.
let values: Vec<[u8; 32]> = data
.chunks_exact(32)
.map(|c| c.try_into().expect("chunks_exact(32) always yields [u8;32]"))
.collect();
// Nothing to test with an empty input.
if values.is_empty() {
return;
}
// Start with capacity=1 so the very first pair of insertions triggers
// `reallocate_to_double_capacity`, and each subsequent power-of-two boundary
// triggers it again. This exercises `prev_power_of_two`, the copy loop,
// and the capacity / length bookkeeping inside the reallocation path.
let mut tree = nssa::merkle_tree::MerkleTree::with_capacity(1);
// ── INVARIANT [InsertionIndex] ────────────────────────────────────────────
// insert() must return 0, 1, 2, … in order.
for (expected_index, &value) in values.iter().enumerate() {
let actual_index = tree.insert(value);
assert_eq!(
actual_index,
expected_index,
"INVARIANT VIOLATION [InsertionIndex]: \
insert returned {actual_index} but expected {expected_index}",
);
}
let root = tree.root();
// ── INVARIANTS [AuthPathSome] and [AuthPathValid] ─────────────────────────
for (index, value) in values.iter().enumerate() {
let path = tree
.get_authentication_path_for(index)
.expect("INVARIANT VIOLATION [AuthPathSome]: \
get_authentication_path_for returned None for a valid index");
assert!(
verify_auth_path(value, index, &path, &root),
"INVARIANT VIOLATION [AuthPathValid]: \
authentication path for index {index} does not re-hash to root()",
);
}
// ── INVARIANT [OutOfBoundsNone] ───────────────────────────────────────────
// The index one past the last inserted element must yield None.
assert!(
tree.get_authentication_path_for(values.len()).is_none(),
"INVARIANT VIOLATION [OutOfBoundsNone]: \
get_authentication_path_for({}) should return None but returned Some",
values.len(),
);
});

View File

@ -36,6 +36,7 @@ targets=(
fuzz_apply_state_diff_split_path
fuzz_multi_block_state_sequence
fuzz_sequencer_vs_replayer
fuzz_merkle_tree
)
# cargo-fuzz requires the nightly toolchain (-Zsanitizer=address etc.).