diff --git a/.github/workflows/test-suite.yml b/.github/workflows/test-suite.yml index aa166f2..7f89b17 100644 --- a/.github/workflows/test-suite.yml +++ b/.github/workflows/test-suite.yml @@ -40,6 +40,8 @@ jobs: run: rustup update stable - name: Run tests run: cargo test --release + env: + PROPTEST_CASES: 100000 - name: Check all examples, binaries, etc run: cargo check --all-targets coverage: diff --git a/tree_hash/Cargo.toml b/tree_hash/Cargo.toml index e1ca5ea..4c4082f 100644 --- a/tree_hash/Cargo.toml +++ b/tree_hash/Cargo.toml @@ -18,6 +18,7 @@ smallvec = "1" typenum = "1" [dev-dependencies] +proptest = "1" rand = "0.9" tree_hash_derive = { path = "../tree_hash_derive" } ethereum_ssz_derive = "0.10" diff --git a/tree_hash/src/merkle_hasher.rs b/tree_hash/src/merkle_hasher.rs index 07d4352..f3647bd 100644 --- a/tree_hash/src/merkle_hasher.rs +++ b/tree_hash/src/merkle_hasher.rs @@ -128,8 +128,9 @@ pub struct MerkleHasher { half_nodes: SmallVec8, /// The depth of the tree that will be produced. /// - /// Depth is counted top-down (i.e., the root node is at depth 0). A tree with 1 leaf has a - /// depth of 1, a tree with 4 leaves has a depth of 3. + /// This is one greater than the depth of the deepest node (see `get_depth`), since a + /// single-node tree has one layer but its root is at node-depth 0. E.g., a tree with 1 leaf + /// has a depth of 1, and a tree with 4 leaves has a depth of 3. depth: usize, /// The next leaf that we are expecting to process. next_leaf: usize, @@ -144,11 +145,12 @@ fn get_parent(i: usize) -> usize { i / 2 } -/// Gets the depth of a node with an id of `i`. +/// Gets the depth of a node with an id of `i`, where the root node (`i == 1`) has depth 0 and +/// depth increases moving down the tree. /// /// It is a logic error to provide `i == 0`. /// -/// E.g., if `i` is 1, depth is 0. If `i` is is 1, depth is 1. +/// E.g., if `i` is 1, depth is 0. If `i` is 2 or 3, depth is 1. fn get_depth(i: usize) -> usize { let total_bits = mem::size_of::() * 8; total_bits - i.leading_zeros() as usize - 1 @@ -236,10 +238,12 @@ impl MerkleHasher { fn process_leaf(&mut self, leaf: &[u8]) -> Result<(), Error> { assert_eq!(leaf.len(), HASHSIZE, "a leaf must be 32 bytes"); - let max_leaves = 1 << (self.depth + 1); - - if self.next_leaf > max_leaves { - return Err(Error::MaximumLeavesExceeded { max_leaves }); + // Leaf ids occupy the range `2^(depth - 1)..2^depth`, so the tree is full once + // `next_leaf` reaches `2^depth`. + if self.next_leaf >= 1 << self.depth { + return Err(Error::MaximumLeavesExceeded { + max_leaves: 1 << (self.depth - 1), + }); } else if self.next_leaf == 1 { // A tree of depth one has a root that is equal to the first given leaf. self.root = Some(Hash256::from_slice(leaf)) diff --git a/tree_hash/src/merkleize_padded.rs b/tree_hash/src/merkleize_padded.rs index 36dc8d6..704f65c 100644 --- a/tree_hash/src/merkleize_padded.rs +++ b/tree_hash/src/merkleize_padded.rs @@ -302,7 +302,7 @@ mod test { use rand::RngCore; fn random_bytes(bytes: usize) -> Vec { - let mut bytes = Vec::with_capacity(bytes); + let mut bytes = vec![0; bytes]; rand::rng().fill_bytes(&mut bytes); bytes } diff --git a/tree_hash/tests/proptests.rs b/tree_hash/tests/proptests.rs new file mode 100644 index 0000000..eb30e1c --- /dev/null +++ b/tree_hash/tests/proptests.rs @@ -0,0 +1,98 @@ +//! Differential property tests between the three merkleization implementations. +//! +//! The equivalence is checked as a chain: +//! +//! - `merkleize_padded` against `merkleize_standard` (the naive reference), and +//! - `MerkleHasher` and `merkle_root` against `merkleize_padded`. + +use proptest::prelude::*; +use tree_hash::{ + merkle_root, merkleize_padded, merkleize_standard, Error, Hash256, MerkleHasher, + BYTES_PER_CHUNK, +}; + +const MAX_BYTES: usize = 2048; +const MAX_MIN_CHUNKS: usize = 70; + +/// Computes the root of `bytes` with the naive algorithm, padding the input out to `min_chunks` +/// (rounded to the next power of two) since `merkleize_standard` does not take a chunk count. +fn reference_root(bytes: &[u8], min_chunks: usize) -> Hash256 { + let mut padded = bytes.to_vec(); + padded.resize( + std::cmp::max( + bytes.len(), + min_chunks.next_power_of_two() * BYTES_PER_CHUNK, + ), + 0, + ); + merkleize_standard(&padded) +} + +proptest! { + #[test] + fn merkleize_padded_matches_standard( + bytes in proptest::collection::vec(any::(), 0..=MAX_BYTES), + min_chunks in 0..=MAX_MIN_CHUNKS, + ) { + prop_assert_eq!( + merkleize_padded(&bytes, min_chunks), + reference_root(&bytes, min_chunks) + ); + } + + #[test] + fn merkle_hasher_matches_merkleize_padded( + bytes in proptest::collection::vec(any::(), 0..=MAX_BYTES), + extra_leaves in 0_usize..=8, + write_size in 1_usize..=64, + ) { + let num_leaves = bytes.len().div_ceil(BYTES_PER_CHUNK) + extra_leaves; + + let mut hasher = MerkleHasher::with_leaves(num_leaves); + for chunk in bytes.chunks(write_size) { + hasher.write(chunk).expect("num_leaves is sufficient for bytes"); + } + let root = hasher.finish().expect("num_leaves is sufficient for bytes"); + + prop_assert_eq!(root, merkleize_padded(&bytes, num_leaves)); + } + + #[test] + fn merkle_hasher_rejects_too_many_bytes( + num_leaves in 0_usize..=MAX_MIN_CHUNKS, + extra_bytes in 1_usize..=3 * BYTES_PER_CHUNK, + write_size in 1_usize..=64, + ) { + // `with_leaves` rounds the leaf count up to the next power of two, so that is the true + // capacity of the tree. + let capacity = num_leaves.next_power_of_two(); + let bytes = vec![0xff_u8; capacity * BYTES_PER_CHUNK + extra_bytes]; + + // Any bytes beyond the tree's capacity must produce an error, never a root that silently + // ignores them. Depending on `extra_bytes` and `write_size` the error surfaces either in + // `write` (a whole excess leaf) or in `finish` (excess bytes still in the buffer). + let mut hasher = MerkleHasher::with_leaves(num_leaves); + let result = bytes + .chunks(write_size) + .try_for_each(|chunk| hasher.write(chunk)) + .and_then(|()| hasher.finish().map(|_| ())); + + prop_assert_eq!( + result, + Err(Error::MaximumLeavesExceeded { max_leaves: capacity }) + ); + } + + #[test] + fn merkle_root_matches_merkleize_padded( + bytes in proptest::collection::vec(any::(), 0..=MAX_BYTES), + min_leaves in 0..=MAX_MIN_CHUNKS, + ) { + // This exercises the 0, 1 and 2-leaf fast-paths in `merkle_root` as well as the + // `MerkleHasher` path. + prop_assert_eq!( + merkle_root(&bytes, min_leaves), + merkleize_padded(&bytes, min_leaves) + ); + } +}