From b1c7b7fe026cba1e57f23cce96df2ddf03d08e6b Mon Sep 17 00:00:00 2001 From: Nikolai Golub Date: Tue, 27 Oct 2026 16:49:42 +0100 Subject: [PATCH] fuzz: check witnesses, proofs and reopen in api_surface - Verify the session witness: each path proof against the previous root, witnessed read values against the model, the read/write key sets, and that applying the witnessed writes yields the new root. - Add a Prove call that checks Session::prove against the session's base. - Derive new keys from existing ones so tries get deep and span pages. - Fuzz page/leaf cache sizes, upper levels and prepopulation; do not preallocate the hash table. - Add a Reopen call that checks the root and every committed value, and keep the temp dir alive for the whole run. - Check the root against an independent recursive reference, shared with proof_verify via a new reference module. Co-Authored-By: Claude Opus 5.5 --- fuzz/fuzz_targets/api_surface.rs | 219 ++++++++++++++++++++++++++---- fuzz/fuzz_targets/proof_verify.rs | 44 +----- fuzz/fuzz_targets/reference.rs | 43 ++++++ 3 files changed, 244 insertions(+), 62 deletions(-) create mode 100644 fuzz/fuzz_targets/reference.rs diff --git a/fuzz/fuzz_targets/api_surface.rs b/fuzz/fuzz_targets/api_surface.rs index 9a958933ef..930ed176bd 100644 --- a/fuzz/fuzz_targets/api_surface.rs +++ b/fuzz/fuzz_targets/api_surface.rs @@ -1,23 +1,41 @@ #![no_main] -use std::collections::{BTreeMap, HashMap, HashSet}; +mod reference; + +use std::{ + collections::{BTreeMap, HashMap, HashSet}, + path::{Path, PathBuf}, +}; use arbitrary::Arbitrary; +use bitvec::view::BitView; use libfuzzer_sys::fuzz_target; use nomt::{ hasher::{Blake3Hasher, ValueHasher}, - trie::{KeyPath, ValueHash}, - KeyReadWrite, Nomt, Options, SessionParams, Value, WitnessMode, + proof::{verify_update, PathUpdate}, + trie::{KeyPath, LeafData, Node, ValueHash}, + KeyReadWrite, Nomt, Options, SessionParams, Value, Witness, WitnessMode, }; use nomt_core::update::build_trie; fuzz_target!(|run: Run| { - let db = open_db(run.commit_concurrency); + let tempdir = tempfile::tempdir().unwrap(); + let path = tempdir.path().join("db"); + let mut db = Nomt::::open(run.open.options(&path)).unwrap(); let mut model = BTreeMap::::new(); for call in run.calls.calls { match call { + NomtCall::Reopen => { + drop(db); + db = Nomt::open(run.open.options(&path)).unwrap(); + assert_eq!(db.root().into_inner(), reference_root(&model)); + for (key_path, value_hash) in &model { + let value = db.read(*key_path).unwrap().unwrap(); + assert_eq!(Blake3Hasher::hash_value(&value), *value_hash); + } + } NomtCall::BeginSession { session_calls } => { let session = db.begin_session( SessionParams::default().witness_mode(WitnessMode::read_write()), @@ -35,7 +53,33 @@ fuzz_target!(|run: Run| { SessionCall::TentativeWrite { key_path } => { session.warm_up(key_path); } + SessionCall::Prove { key_path } => { + let verified = session + .prove(key_path) + .unwrap() + .verify::( + key_path.view_bits(), + session.prev_root().into_inner(), + ) + .unwrap(); + match model.get(&key_path) { + Some(value_hash) => assert!(verified + .confirm_value(&LeafData { + key_path, + value_hash: *value_hash, + }) + .unwrap()), + None => { + assert!(verified.confirm_nonexistence(&key_path).unwrap()) + } + } + } SessionCall::CommitAndProve { keys } => { + let prev_root = db.root().into_inner(); + let mut finished = session.finish(keys.clone()).unwrap(); + let witness = finished.take_witness().unwrap(); + check_witness(witness, &keys, &model, prev_root, finished.root()); + for (key_path, key_rw) in &keys { match key_rw { KeyReadWrite::Write(Some(value)) @@ -49,11 +93,15 @@ fuzz_target!(|run: Run| { KeyReadWrite::Read(_) => {} } } - let _ = session.finish(keys).unwrap().commit(&db).unwrap(); - let expected_root = build_trie::( - 0, - model.iter().map(|(k, v)| (*k, *v)), - |_| {}, + finished.commit(&db).unwrap(); + let expected_root = reference_root(&model); + assert_eq!( + build_trie::( + 0, + model.iter().map(|(k, v)| (*k, *v)), + |_| {}, + ), + expected_root ); assert_eq!(db.root().into_inner(), expected_root); break; @@ -69,10 +117,95 @@ fuzz_target!(|run: Run| { } }); +fn reference_root(model: &BTreeMap) -> Node { + let leaves = model.iter().map(|(k, v)| (*k, *v)).collect::>(); + reference::node(&leaves, 0) +} + +/// Checks that the witness proves exactly the session's reads and writes against `prev_root`, with +/// the values in `model`, and that applying its writes yields `new_root`. +fn check_witness( + witness: Witness, + keys: &[(KeyPath, KeyReadWrite)], + model: &BTreeMap, + prev_root: Node, + new_root: nomt::Root, +) { + let verified = witness + .path_proofs + .iter() + .map(|p| { + p.inner + .verify::(p.path.path(), prev_root) + .unwrap() + }) + .collect::>(); + + let mut reads = Vec::new(); + for read in &witness.operations.reads { + assert_eq!(read.value, model.get(&read.key).copied()); + let proof = &verified[read.path_index]; + match read.value { + Some(value_hash) => assert!(proof + .confirm_value(&LeafData { + key_path: read.key, + value_hash, + }) + .unwrap()), + None => assert!(proof.confirm_nonexistence(&read.key).unwrap()), + } + reads.push(read.key); + } + reads.sort_unstable(); + let expected_reads = keys + .iter() + .filter(|(_, rw)| matches!(rw, KeyReadWrite::Read(_) | KeyReadWrite::ReadThenWrite(..))) + .map(|(k, _)| *k) + .collect::>(); + assert_eq!(reads, expected_reads); + + let mut writes = Vec::new(); + let mut ops_by_path = BTreeMap::)>>::new(); + for write in &witness.operations.writes { + writes.push((write.key, write.value)); + ops_by_path + .entry(write.path_index) + .or_default() + .push((write.key, write.value)); + } + writes.sort_unstable(); + let expected_writes = keys + .iter() + .filter_map(|(k, rw)| match rw { + KeyReadWrite::Write(v) | KeyReadWrite::ReadThenWrite(_, v) => { + Some((*k, v.as_ref().map(|v| Blake3Hasher::hash_value(v)))) + } + KeyReadWrite::Read(_) => None, + }) + .collect::>(); + assert_eq!(writes, expected_writes); + + let mut updates = ops_by_path + .into_iter() + .map(|(i, ops)| PathUpdate { + inner: verified[i].clone(), + ops, + }) + .collect::>(); + // Witness path proofs may be emitted in worker order rather than trie path order. + updates.sort_by(|a, b| a.inner.path().cmp(b.inner.path())); + assert_eq!( + verify_update::(prev_root, &updates).unwrap(), + new_root.into_inner() + ); +} + struct Context { /// All the key paths that have been created, irregardless of whether they have been committed /// or not. key_paths: HashSet, + /// The same key paths in creation order, so that picking one is deterministic. + key_path_list: Vec, /// The key value pairs that have been committed into the store. /// /// The u32 is the index of the key path in `key_paths`. @@ -86,9 +219,9 @@ impl Context { ) -> arbitrary::Result> { let mut attempts = 0; loop { - let mut key_path: [u8; 32] = [0; 32]; - input.fill_buffer(&mut key_path)?; + let key_path = reference::new_key(input, &self.key_path_list)?; if self.key_paths.insert(key_path) { + self.key_path_list.push(key_path); return Ok(Some(key_path)); } attempts += 1; @@ -125,23 +258,56 @@ impl Context { #[derive(Debug)] struct Run { - commit_concurrency: usize, + open: OpenParams, calls: NomtCalls, } impl<'a> Arbitrary<'a> for Run { fn arbitrary(input: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result { let run = Run { - commit_concurrency: input.int_in_range(1..=4)?, + open: OpenParams { + commit_concurrency: input.int_in_range(1..=4)?, + page_cache_size: input.int_in_range(1..=32)?, + leaf_cache_size: input.int_in_range(1..=32)?, + page_cache_upper_levels: input.int_in_range(0..=2)?, + prepopulate_page_cache: input.arbitrary()?, + }, calls: input.arbitrary()?, }; Ok(run) } } +#[derive(Debug)] +struct OpenParams { + commit_concurrency: usize, + page_cache_size: usize, + leaf_cache_size: usize, + page_cache_upper_levels: usize, + prepopulate_page_cache: bool, +} + +impl OpenParams { + fn options(&self, path: &Path) -> Options { + let mut options = Options::new(); + options.path(PathBuf::from(path)); + options.commit_concurrency(self.commit_concurrency); + options.page_cache_size(self.page_cache_size); + options.leaf_cache_size(self.leaf_cache_size); + options.page_cache_upper_levels(self.page_cache_upper_levels); + options.prepopulate_page_cache(self.prepopulate_page_cache); + options.preallocate_ht(false); + options + } +} + #[derive(Debug)] enum NomtCall { - BeginSession { session_calls: Vec }, + BeginSession { + session_calls: Vec, + }, + /// Close and reopen the database, then check every committed value. + Reopen, } #[derive(Debug)] @@ -153,7 +319,11 @@ enum SessionCall { TentativeWrite { key_path: KeyPath, }, - /// Commit and prove the given keys. + /// Prove the key against the session's base root. + Prove { + key_path: KeyPath, + }, + /// Commit the given keys and check the witness. CommitAndProve { keys: Vec<(KeyPath, KeyReadWrite)>, }, @@ -173,10 +343,15 @@ impl<'a> Arbitrary<'a> for NomtCalls { let mut cx = Context { key_paths: HashSet::new(), + key_path_list: Vec::new(), committed: HashMap::new(), }; for _ in 0..call_cnt { + if input.ratio(1, 8)? { + calls.push(NomtCall::Reopen); + } + let session_sz = input.int_in_range(0..=1000)?; // Collect all the available keys into a vector and destroy the non-determinism @@ -230,6 +405,11 @@ impl<'a> Arbitrary<'a> for NomtCalls { // api. let mut session_calls = Vec::new(); for (key_path, key_rw) in &session { + if input.ratio(1, 8)? { + session_calls.push(SessionCall::Prove { + key_path: *key_path, + }); + } match key_rw { KeyReadWrite::ReadThenWrite(v, _) | KeyReadWrite::Read(v) => { session_calls.push(SessionCall::TentativeRead { @@ -271,12 +451,3 @@ impl<'a> Arbitrary<'a> for NomtCalls { Ok(NomtCalls { calls }) } } - -fn open_db(commit_concurrency: usize) -> Nomt { - let tempfile = tempfile::tempdir().unwrap(); - let db_path = tempfile.path().join("db"); - let mut options = Options::new(); - options.path(db_path); - options.commit_concurrency(commit_concurrency); - Nomt::open(options).unwrap() -} diff --git a/fuzz/fuzz_targets/proof_verify.rs b/fuzz/fuzz_targets/proof_verify.rs index 24bbae78d8..146e0f8143 100644 --- a/fuzz/fuzz_targets/proof_verify.rs +++ b/fuzz/fuzz_targets/proof_verify.rs @@ -1,20 +1,23 @@ #![no_main] +mod reference; + use std::collections::{BTreeMap, BTreeSet}; use arbitrary::{Arbitrary, Unstructured}; -use bitvec::{order::Msb0, view::BitView}; +use bitvec::view::BitView; use libfuzzer_sys::fuzz_target; use nomt_core::{ - hasher::{Blake3Hasher, NodeHasher}, + hasher::Blake3Hasher, proof::{ verify_multi_proof, verify_multi_proof_update, verify_update, KeyOutOfScope, MultiProof, PathProof, PathProofTerminal, PathUpdate, }, - trie::{InternalData, KeyPath, LeafData, Node, ValueHash, TERMINATOR}, + trie::{KeyPath, LeafData, Node, ValueHash}, trie_pos::TriePosition, update::build_trie, }; +use reference::{bit, new_key, node}; type H = Blake3Hasher; @@ -170,28 +173,6 @@ fn check_claims( } } -fn bit(key: &KeyPath, depth: usize) -> bool { - key.view_bits::()[depth] -} - -/// Reference hash of the sub-trie holding `leaves`: sorted, distinct, sharing the first `depth` bits. -fn node(leaves: &[(KeyPath, ValueHash)], depth: usize) -> Node { - match leaves { - [] => TERMINATOR, - [(key_path, value_hash)] => H::hash_leaf(&LeafData { - key_path: *key_path, - value_hash: *value_hash, - }), - _ => { - let mid = leaves.partition_point(|(k, _)| !bit(k, depth)); - H::hash_internal(&InternalData { - left: node(&leaves[..mid], depth + 1), - right: node(&leaves[mid..], depth + 1), - }) - } - } -} - fn path_proof(mut leaves: &[(KeyPath, ValueHash)], key: &KeyPath) -> PathProof { let mut siblings = Vec::new(); let mut depth = 0; @@ -269,19 +250,6 @@ impl<'a> Arbitrary<'a> for Run { } } -/// Mostly derive keys from existing ones, diverging at a chosen bit, so the trie gets deep. -fn new_key(input: &mut Unstructured<'_>, existing: &[KeyPath]) -> arbitrary::Result { - let mut key: KeyPath = input.arbitrary()?; - if !existing.is_empty() && input.ratio(3, 4)? { - let base = existing[input.choose_index(existing.len())?]; - let diverge_at = input.int_in_range(0..=255)?; - let bits = key.view_bits_mut::(); - bits[..diverge_at].copy_from_bitslice(&base.view_bits::()[..diverge_at]); - bits.set(diverge_at, !bit(&base, diverge_at)); - } - Ok(key) -} - #[derive(Debug, Arbitrary)] enum Mutation { FlipSiblingBit { index: usize, bit: u8 }, diff --git a/fuzz/fuzz_targets/reference.rs b/fuzz/fuzz_targets/reference.rs new file mode 100644 index 0000000000..785c75cea4 --- /dev/null +++ b/fuzz/fuzz_targets/reference.rs @@ -0,0 +1,43 @@ +//! A reference trie model shared by the fuzz targets, independent of the production trie builder. + +use arbitrary::Unstructured; +use bitvec::{order::Msb0, view::BitView}; +use nomt_core::{ + hasher::{Blake3Hasher, NodeHasher}, + trie::{InternalData, KeyPath, LeafData, Node, ValueHash, TERMINATOR}, +}; + +pub fn bit(key: &KeyPath, depth: usize) -> bool { + key.view_bits::()[depth] +} + +/// Reference hash of the sub-trie holding `leaves`: sorted, distinct, sharing the first `depth` bits. +pub fn node(leaves: &[(KeyPath, ValueHash)], depth: usize) -> Node { + match leaves { + [] => TERMINATOR, + [(key_path, value_hash)] => Blake3Hasher::hash_leaf(&LeafData { + key_path: *key_path, + value_hash: *value_hash, + }), + _ => { + let mid = leaves.partition_point(|(k, _)| !bit(k, depth)); + Blake3Hasher::hash_internal(&InternalData { + left: node(&leaves[..mid], depth + 1), + right: node(&leaves[mid..], depth + 1), + }) + } + } +} + +/// Mostly derive keys from existing ones, diverging at a chosen bit, so the trie gets deep. +pub fn new_key(input: &mut Unstructured<'_>, existing: &[KeyPath]) -> arbitrary::Result { + let mut key: KeyPath = input.arbitrary()?; + if !existing.is_empty() && input.ratio(3, 4)? { + let base = existing[input.choose_index(existing.len())?]; + let diverge_at = input.int_in_range(0..=255)?; + let bits = key.view_bits_mut::(); + bits[..diverge_at].copy_from_bitslice(&base.view_bits::()[..diverge_at]); + bits.set(diverge_at, !bit(&base, diverge_at)); + } + Ok(key) +}