From 0bd4e6d6b5c7e5ac5356d197340b548c6db756d4 Mon Sep 17 00:00:00 2001 From: Erick Cestari Date: Wed, 8 Jul 2026 17:11:54 -0300 Subject: [PATCH 1/3] feat: create initial fuzzing harness Co-authored-by: Leonardo Lima --- fuzz/.gitignore | 4 + fuzz/Cargo.toml | 37 ++ fuzz/fuzz_targets/local_chain_apply_update.rs | 315 +++++++++++++++ .../local_chain_apply_update_header.rs | 381 ++++++++++++++++++ 4 files changed, 737 insertions(+) create mode 100644 fuzz/.gitignore create mode 100644 fuzz/Cargo.toml create mode 100644 fuzz/fuzz_targets/local_chain_apply_update.rs create mode 100644 fuzz/fuzz_targets/local_chain_apply_update_header.rs diff --git a/fuzz/.gitignore b/fuzz/.gitignore new file mode 100644 index 0000000000..1a45eee776 --- /dev/null +++ b/fuzz/.gitignore @@ -0,0 +1,4 @@ +target +corpus +artifacts +coverage diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml new file mode 100644 index 0000000000..dfcdb437b7 --- /dev/null +++ b/fuzz/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "bdk_chain-fuzz" +version = "0.0.0" +publish = false +edition = "2021" + +[package.metadata] +cargo-fuzz = true + +[workspace] +members = ["."] + +[dependencies] +libfuzzer-sys = { version = "0.4", optional = true } +arbitrary = "1.4.1" +honggfuzz = { version = "0.5.61", optional = true } +afl = { version = "0.18.2", optional = true } +bdk_chain = { path = "../crates/chain" } + +[features] +afl_fuzz = ["afl"] +honggfuzz_fuzz = ["honggfuzz"] +libfuzzer_fuzz = ["libfuzzer-sys"] + +[[bin]] +name = "local_chain_apply_update" +path = "fuzz_targets/local_chain_apply_update.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "local_chain_apply_update_header" +path = "fuzz_targets/local_chain_apply_update_header.rs" +test = false +doc = false +bench = false diff --git a/fuzz/fuzz_targets/local_chain_apply_update.rs b/fuzz/fuzz_targets/local_chain_apply_update.rs new file mode 100644 index 0000000000..0548c42995 --- /dev/null +++ b/fuzz/fuzz_targets/local_chain_apply_update.rs @@ -0,0 +1,315 @@ +#![cfg_attr(feature = "libfuzzer_fuzz", no_main)] + +use std::collections::BTreeMap; + +use arbitrary::{Arbitrary, Unstructured}; +use bdk_chain::bitcoin::block::{Header, Version}; +use bdk_chain::bitcoin::hashes::Hash; +use bdk_chain::bitcoin::{BlockHash, CompactTarget, TxMerkleNode}; +use bdk_chain::local_chain::{ChangeSet, LocalChain, MissingGenesisError}; +use bdk_chain::{BlockId, CheckPoint}; + +fn arbitrary_hash(u: &mut Unstructured) -> arbitrary::Result { + Ok(BlockHash::from_byte_array(<[u8; 32]>::arbitrary(u)?)) +} + +/// Builds a header for `apply_header(_connected_to)`. Its `prev_blockhash` is usually taken +/// from an existing checkpoint (so the header connects to the chain), sometimes arbitrary. +fn arbitrary_header(u: &mut Unstructured, chain: &LocalChain) -> arbitrary::Result<(Header, u32)> { + let (prev_blockhash, height) = if u.ratio(3, 4)? { + let block_ids: Vec = chain.iter_checkpoints().map(|cp| cp.block_id()).collect(); + let connect_at = u.choose(&block_ids)?; + (connect_at.hash, connect_at.height.saturating_add(1)) + } else { + (arbitrary_hash(u)?, u32::arbitrary(u)?) + }; + let header = Header { + version: Version::from_consensus(i32::arbitrary(u)?), + prev_blockhash, + merkle_root: TxMerkleNode::from_byte_array(<[u8; 32]>::arbitrary(u)?), + time: u32::arbitrary(u)?, + bits: CompactTarget::from_consensus(u32::arbitrary(u)?), + nonce: u32::arbitrary(u)?, + }; + Ok((header, height)) +} + +fn arbitrary_chain(u: &mut Unstructured) -> arbitrary::Result> { + let raw_blocks: BTreeMap = BTreeMap::arbitrary(u)?; + println!("{:#?}", raw_blocks); + let blocks: BTreeMap = raw_blocks + .into_iter() + .map(|(height, hash)| (height, BlockHash::from_byte_array(hash))) + .collect(); + + let constructed = match u.int_in_range(0..=2)? { + 0 => LocalChain::from_blocks(blocks.clone()).ok(), + 1 => { + let changeset = ChangeSet { + blocks: blocks.iter().map(|(&h, &hash)| (h, Some(hash))).collect(), + }; + LocalChain::from_changeset(changeset).ok() + } + _ => CheckPoint::from_blocks(blocks.clone()) + .ok() + .and_then(|tip| LocalChain::from_tip(tip).ok()), + }; + let chain = match constructed { + Some(chain) => chain, + None => return Ok(None), + }; + + let tip = chain.tip(); + let (&tip_height, &tip_hash) = blocks.last_key_value().expect("chain is non-empty"); + assert_eq!(tip.block_id().height, tip_height); + assert_eq!(tip.block_id().hash, tip_hash); + assert_eq!(chain.genesis_hash(), blocks[&0]); + + Ok(Some(chain)) +} + +/// Picks a block id for an operation: either a checkpoint of `chain` or an arbitrary one. +fn arbitrary_block_id(u: &mut Unstructured, chain: &LocalChain) -> arbitrary::Result { + if bool::arbitrary(u)? { + let block_ids: Vec = chain.iter_checkpoints().map(|cp| cp.block_id()).collect(); + Ok(*u.choose(&block_ids)?) + } else { + Ok(BlockId { + height: u32::arbitrary(u)?, + hash: arbitrary_hash(u)?, + }) + } +} + +/// On success, `pre`-state plus the returned changeset must reconstruct the post-state. +/// On failure, the chain must be left untouched. +fn check_op_result(pre: LocalChain, post: &LocalChain, result: &Result) { + match result { + Ok(changeset) => { + let mut reconstructed = pre; + reconstructed + .apply_changeset(changeset) + .expect("applying an op's changeset to the pre-state must succeed"); + assert_eq!(&reconstructed, post); + } + Err(_) => assert_eq!(&pre, post, "a failed op must not modify the chain"), + } +} + +fn check_chain(chain: &LocalChain) { + let heights: Vec = chain.iter_checkpoints().map(|cp| cp.height()).collect(); + assert!( + heights.windows(2).all(|w| w[0] > w[1]), + "checkpoint heights must be strictly decreasing from tip" + ); + assert_eq!(heights.last(), Some(&0), "genesis must be present"); + + let tip = chain.chain_tip(); + assert_eq!(tip, chain.tip().block_id()); + for cp in chain.iter_checkpoints() { + assert_eq!( + chain.is_block_in_chain(cp.block_id(), tip), + Some(true), + "every checkpoint must be in the chain of its own tip" + ); + let mut flipped = cp.hash().to_byte_array(); + flipped[0] ^= 1; + let wrong = BlockId { + height: cp.height(), + hash: BlockHash::from_byte_array(flipped), + }; + assert_eq!( + chain.is_block_in_chain(wrong, tip), + Some(false), + "a conflicting hash at an occupied height must not be in chain" + ); + } +} + +fn do_test(data: &[u8]) { + let mut u = Unstructured::new(data); + + let op_count = match u.int_in_range(1..=16) { + Ok(count) => count, + Err(_) => return, + }; + + let mut chain: Option = None; + for _ in 0..op_count { + if chain.is_none() { + match arbitrary_chain(&mut u) { + Ok(Some(initial)) => chain = Some(initial), + Ok(None) => continue, + Err(_) => break, + } + continue; + } + let chain = chain.as_mut().expect("initialized above"); + + let op = match u.int_in_range::(0..=5) { + Ok(op) => op, + Err(_) => break, + }; + let pre = chain.clone(); + match op { + 0 => { + let update = match arbitrary_chain(&mut u) { + Ok(Some(update)) => update, + Ok(None) => continue, + Err(_) => break, + }; + let result = chain.apply_update(update.tip()); + check_op_result(pre, chain, &result); + } + 1 => { + let (height, hash) = match u32::arbitrary(&mut u) + .and_then(|height| arbitrary_hash(&mut u).map(|hash| (height, hash))) + { + Ok(block) => block, + Err(_) => break, + }; + let result = chain.insert_block(height, hash); + check_op_result(pre, chain, &result); + match &result { + Ok(_) => { + assert_eq!(chain.get(height).map(|cp| cp.hash()), Some(hash)); + } + Err(err) => { + assert_eq!( + chain.get(err.height).map(|cp| cp.hash()), + Some(err.original_hash), + "insert conflict must report the existing checkpoint" + ); + } + } + } + 2 => { + let block_id = match arbitrary_block_id(&mut u, chain) { + Ok(block_id) => block_id, + Err(_) => break, + }; + let result = chain.disconnect_from(block_id); + check_op_result(pre, chain, &result); + match &result { + Ok(changeset) if !changeset.blocks.is_empty() => { + assert!(chain.tip().height() < block_id.height); + } + Ok(_) => {} + Err(MissingGenesisError) => { + assert_eq!(block_id.height, 0); + assert_eq!(block_id.hash, chain.genesis_hash()); + } + } + } + 3 => { + let (header, height) = match arbitrary_header(&mut u, chain) { + Ok(header) => header, + Err(_) => break, + }; + let result = chain.apply_header(&header, height); + check_op_result(pre, chain, &result); + if result.is_ok() { + assert_eq!( + chain.get(height).map(|cp| cp.hash()), + Some(header.block_hash()) + ); + } + } + 4 => { + let params: arbitrary::Result<_> = (|| { + let (header, height) = arbitrary_header(&mut u, chain)?; + let connected_to = arbitrary_block_id(&mut u, chain)?; + Ok((header, height, connected_to)) + })(); + let (header, height, connected_to) = match params { + Ok(params) => params, + Err(_) => break, + }; + let result = chain.apply_header_connected_to(&header, height, connected_to); + check_op_result(pre, chain, &result); + if result.is_ok() { + assert_eq!( + chain.get(height).map(|cp| cp.hash()), + Some(header.block_hash()) + ); + } + } + _ => { + // Derived update: mutate the chain's own tip so the update shares `Arc` + // nodes with the original, exercising `merge_chains`' `eq_ptr` fast path. + let params: arbitrary::Result<_> = (|| { + let insert = bool::arbitrary(&mut u)?; + let height = u32::arbitrary(&mut u)?; + let hash = arbitrary_hash(&mut u)?; + Ok((insert, height, hash)) + })(); + let (insert, height, hash) = match params { + Ok(params) => params, + Err(_) => break, + }; + let (update_tip, height) = if insert { + // Height 0 would panic (genesis is immutable in `CheckPoint::insert`). + let height = height.max(1); + (chain.tip().insert(height, hash), height) + } else { + let height = match chain.tip().height().checked_add(1 + height % 4) { + Some(height) => height, + None => continue, + }; + match chain.tip().extend([(height, hash)]) { + Ok(tip) => (tip, height), + Err(_) => continue, + } + }; + let result = chain.apply_update(update_tip); + check_op_result(pre, chain, &result); + if result.is_ok() { + assert_eq!(chain.get(height).map(|cp| cp.hash()), Some(hash)); + } + } + } + check_chain(chain); + } + + if let Some(chain) = chain { + check_chain(&chain); + } +} + +#[cfg(feature = "afl_fuzz")] +#[macro_use] +extern crate afl; +#[cfg(feature = "afl_fuzz")] +fn main() { + fuzz!(|data| { do_test(data) }); +} + +#[cfg(feature = "honggfuzz_fuzz")] +#[macro_use] +extern crate honggfuzz; +#[cfg(feature = "honggfuzz_fuzz")] +fn main() { + loop { + fuzz!(|data| { do_test(data) }); + } +} + +#[cfg(feature = "libfuzzer_fuzz")] +#[macro_use] +extern crate libfuzzer_sys; +#[cfg(feature = "libfuzzer_fuzz")] +fuzz_target!(|data: &[u8]| do_test(data)); + +/// Replays corpus files passed as arguments. Used for coverage reports and +/// reproducing crashes without a fuzzer attached. +#[cfg(not(any( + feature = "afl_fuzz", + feature = "honggfuzz_fuzz", + feature = "libfuzzer_fuzz" +)))] +fn main() { + for path in std::env::args().skip(1) { + let data = std::fs::read(&path).unwrap_or_else(|e| panic!("failed to read {path}: {e}")); + do_test(&data); + } +} diff --git a/fuzz/fuzz_targets/local_chain_apply_update_header.rs b/fuzz/fuzz_targets/local_chain_apply_update_header.rs new file mode 100644 index 0000000000..168008046b --- /dev/null +++ b/fuzz/fuzz_targets/local_chain_apply_update_header.rs @@ -0,0 +1,381 @@ +//! Fuzzes `LocalChain
`, where checkpoint data knows its `prev_blockhash`. +//! +//! Unlike the `BlockHash`-based target, gaps between checkpoints imply *placeholder* +//! entries (`CheckPointEntry::Placeholder`), exercising the placeholder resolution +//! paths in `apply_update` and `CheckPoint::entry_iter`. +//! +//! Headers are derived from a "virtual chain" of properly linked headers. Each operation +//! draws headers from it (or arbitrary foreign ones), so operations share block hashes +//! with the chain under test (connection points, placeholder fills) and reorgs regenerate +//! headers above an arbitrary fork height (conflicts, invalidation). +#![cfg_attr(feature = "libfuzzer_fuzz", no_main)] + +use std::collections::BTreeMap; + +use arbitrary::{Arbitrary, Unstructured}; +use bdk_chain::bitcoin::block::{Header, Version}; +use bdk_chain::bitcoin::hashes::Hash; +use bdk_chain::bitcoin::{BlockHash, CompactTarget, TxMerkleNode}; +use bdk_chain::local_chain::{ChangeSet, LocalChain}; +use bdk_chain::{BlockId, CheckPoint, CheckPointEntry}; + +const MAX_HEIGHT: u32 = 32; + +fn arbitrary_header(u: &mut Unstructured, prev_blockhash: BlockHash) -> arbitrary::Result
{ + Ok(Header { + version: Version::from_consensus(i32::arbitrary(u)?), + prev_blockhash, + merkle_root: TxMerkleNode::from_byte_array(<[u8; 32]>::arbitrary(u)?), + time: u32::arbitrary(u)?, + bits: CompactTarget::from_consensus(u32::arbitrary(u)?), + nonce: u32::arbitrary(u)?, + }) +} + +/// Regenerates `headers` from `fork_height` upward with fresh arbitrary fields, keeping +/// `prev_blockhash` links intact so contiguous checkpoints remain valid. +fn reorg( + u: &mut Unstructured, + headers: &mut [Header], + fork_height: usize, +) -> arbitrary::Result<()> { + for height in fork_height..headers.len() { + let prev_blockhash = match height.checked_sub(1) { + Some(prev) => headers[prev].block_hash(), + None => BlockHash::all_zeros(), + }; + headers[height] = arbitrary_header(u, prev_blockhash)?; + } + Ok(()) +} + +/// Picks a header for an operation at `height`: usually the virtual chain's (shares hashes +/// with the chain under test), sometimes a foreign one (conflicts). +fn header_at(u: &mut Unstructured, headers: &[Header], height: usize) -> arbitrary::Result
{ + if u.ratio(7, 8)? { + Ok(headers[height]) + } else { + let prev_blockhash = BlockHash::from_byte_array(<[u8; 32]>::arbitrary(u)?); + arbitrary_header(u, prev_blockhash) + } +} + +/// Builds a `LocalChain
` occupying an arbitrary subset of the virtual chain's +/// heights (genesis always included), via an arbitrarily chosen constructor. +fn arbitrary_chain( + u: &mut Unstructured, + headers: &[Header], +) -> arbitrary::Result>> { + let occupied_mask = u32::arbitrary(u)? | 1; + let blocks: BTreeMap = headers + .iter() + .enumerate() + .map(|(height, header)| (height as u32, *header)) + .filter(|(height, _)| occupied_mask & (1u32 << (height % MAX_HEIGHT)) != 0) + .collect(); + + let constructed = match u.int_in_range(0..=2)? { + 0 => LocalChain::from_blocks(blocks.clone()).ok(), + 1 => { + let changeset = ChangeSet { + blocks: blocks + .iter() + .map(|(&h, &header)| (h, Some(header))) + .collect(), + }; + LocalChain::from_changeset(changeset).ok() + } + _ => CheckPoint::from_blocks(blocks.clone()) + .ok() + .and_then(|tip| LocalChain::from_tip(tip).ok()), + }; + let chain = match constructed { + Some(chain) => chain, + None => return Ok(None), + }; + + let (&tip_height, tip_header) = blocks.last_key_value().expect("chain is non-empty"); + assert_eq!(chain.tip().block_id().height, tip_height); + assert_eq!(chain.tip().block_id().hash, tip_header.block_hash()); + assert_eq!(chain.genesis_hash(), blocks[&0].block_hash()); + + Ok(Some(chain)) +} + +/// Picks a block id for an operation: a checkpoint of `chain`, a virtual chain block, or +/// an arbitrary one. +fn arbitrary_block_id( + u: &mut Unstructured, + chain: &LocalChain
, + headers: &[Header], +) -> arbitrary::Result { + match u.int_in_range(0..=2)? { + 0 => { + let block_ids: Vec = + chain.iter_checkpoints().map(|cp| cp.block_id()).collect(); + Ok(*u.choose(&block_ids)?) + } + 1 => { + let height = u.int_in_range(0..=headers.len() - 1)?; + Ok(BlockId { + height: height as u32, + hash: headers[height].block_hash(), + }) + } + _ => Ok(BlockId { + height: u32::arbitrary(u)?, + hash: BlockHash::from_byte_array(<[u8; 32]>::arbitrary(u)?), + }), + } +} + +/// On success, `pre`-state plus the returned changeset must reconstruct the post-state. +/// On failure, the chain must be left untouched. +fn check_op_result( + pre: LocalChain
, + post: &LocalChain
, + result: &Result, E>, +) { + match result { + Ok(changeset) => { + let mut reconstructed = pre; + reconstructed + .apply_changeset(changeset) + .expect("applying an op's changeset to the pre-state must succeed"); + assert_eq!(&reconstructed, post); + } + Err(_) => assert_eq!(&pre, post, "a failed op must not modify the chain"), + } +} + +/// Checks checkpoint and entry invariants, including placeholder consistency. +fn check_chain(chain: &LocalChain
) { + let occupied: BTreeMap = chain + .iter_checkpoints() + .map(|cp| (cp.height(), cp.hash())) + .collect(); + let heights: Vec = occupied.keys().rev().copied().collect(); + assert!( + chain + .iter_checkpoints() + .map(|cp| cp.height()) + .eq(heights.iter().copied()), + "checkpoint heights must be strictly decreasing from tip" + ); + assert_eq!(heights.last(), Some(&0), "genesis must be present"); + + let mut entry_heights = Vec::new(); + for entry in chain.tip().entry_iter() { + entry_heights.push(entry.height()); + match &entry { + CheckPointEntry::Placeholder { + block_id, + checkpoint_above, + } => { + assert!( + !occupied.contains_key(&block_id.height), + "placeholder height must not hold a real checkpoint" + ); + assert_eq!(checkpoint_above.height(), block_id.height + 1); + assert_eq!(checkpoint_above.data_ref().prev_blockhash, block_id.hash); + } + CheckPointEntry::Occupied(cp) => { + assert_eq!(occupied.get(&cp.height()), Some(&cp.hash())); + } + } + } + assert!( + entry_heights.windows(2).all(|w| w[0] > w[1]), + "entry heights must be strictly decreasing from tip" + ); + let occupied_entries = entry_heights + .iter() + .filter(|h| occupied.contains_key(h)) + .count(); + assert_eq!( + occupied_entries, + occupied.len(), + "entry_iter must yield every real checkpoint" + ); +} + +fn do_test(data: &[u8]) { + let mut u = Unstructured::new(data); + + let height_count = match u.int_in_range(1..=MAX_HEIGHT) { + Ok(count) => count, + Err(_) => return, + }; + let mut headers = vec![ + Header { + version: Version::NO_SOFT_FORK_SIGNALLING, + prev_blockhash: BlockHash::all_zeros(), + merkle_root: TxMerkleNode::all_zeros(), + time: 0, + bits: CompactTarget::from_consensus(0), + nonce: 0, + }; + height_count as usize + ]; + if reorg(&mut u, &mut headers, 0).is_err() { + return; + } + + let op_count = match u.int_in_range(1..=16) { + Ok(count) => count, + Err(_) => return, + }; + + let mut chain: Option> = None; + for _ in 0..op_count { + if chain.is_none() { + match arbitrary_chain(&mut u, &headers) { + Ok(Some(initial)) => chain = Some(initial), + Ok(None) => continue, + Err(_) => break, + } + continue; + } + let chain = chain.as_mut().expect("initialized above"); + + let op = match u.int_in_range::(0..=3) { + Ok(op) => op, + Err(_) => break, + }; + let pre = chain.clone(); + match op { + 0 => { + let update = match arbitrary_chain(&mut u, &headers) { + Ok(Some(update)) => update, + Ok(None) => continue, + Err(_) => break, + }; + let result = chain.apply_update(update.tip()); + check_op_result(pre, chain, &result); + } + 1 => { + let (height, header) = match u + .int_in_range(0..=headers.len() - 1) + .and_then(|height| header_at(&mut u, &headers, height).map(|h| (height, h))) + { + Ok(block) => block, + Err(_) => break, + }; + let result = chain.insert_block(height as u32, header); + check_op_result(pre, chain, &result); + match &result { + Ok(_) => { + assert_eq!( + chain.get(height as u32).map(|cp| cp.hash()), + Some(header.block_hash()) + ); + } + Err(err) => { + assert_eq!( + chain.get(err.height).map(|cp| cp.hash()), + Some(err.original_hash), + "insert conflict must report the existing checkpoint" + ); + } + } + } + 2 => { + let block_id = match arbitrary_block_id(&mut u, chain, &headers) { + Ok(block_id) => block_id, + Err(_) => break, + }; + let result = chain.disconnect_from(block_id); + check_op_result(pre, chain, &result); + match &result { + Ok(changeset) if !changeset.blocks.is_empty() => { + assert!(chain.tip().height() < block_id.height); + } + Ok(_) => {} + Err(_missing_genesis) => { + assert_eq!(block_id.height, 0); + assert_eq!(block_id.hash, chain.genesis_hash()); + } + } + } + _ => { + // Derived update: insert into the chain's own tip so the update shares + // `Arc` nodes with the original, exercising `merge_chains`' `eq_ptr` + // fast path. Heights 0 and 1 are excluded: after a reorg the virtual + // headers may imply a different genesis, which `CheckPoint::insert` + // rejects with a panic. + if headers.len() < 3 { + continue; + } + let params: arbitrary::Result<_> = (|| { + let height = u.int_in_range(2..=headers.len() - 1)?; + let header = header_at(&mut u, &headers, height)?; + Ok((height as u32, header)) + })(); + let (height, header) = match params { + Ok(params) => params, + Err(_) => break, + }; + let update_tip = chain.tip().insert(height, header); + let result = chain.apply_update(update_tip); + check_op_result(pre, chain, &result); + if result.is_ok() { + assert_eq!( + chain.get(height).map(|cp| cp.hash()), + Some(header.block_hash()) + ); + } + } + } + check_chain(chain); + + let fork_height = match u.int_in_range(0..=headers.len()) { + Ok(fork_height) => fork_height, + Err(_) => break, + }; + if fork_height < headers.len() && reorg(&mut u, &mut headers, fork_height).is_err() { + break; + } + } + + if let Some(chain) = chain { + check_chain(&chain); + } +} + +#[cfg(feature = "afl_fuzz")] +#[macro_use] +extern crate afl; +#[cfg(feature = "afl_fuzz")] +fn main() { + fuzz!(|data| { do_test(data) }); +} + +#[cfg(feature = "honggfuzz_fuzz")] +#[macro_use] +extern crate honggfuzz; +#[cfg(feature = "honggfuzz_fuzz")] +fn main() { + loop { + fuzz!(|data| { do_test(data) }); + } +} + +#[cfg(feature = "libfuzzer_fuzz")] +#[macro_use] +extern crate libfuzzer_sys; +#[cfg(feature = "libfuzzer_fuzz")] +fuzz_target!(|data: &[u8]| do_test(data)); + +/// Replays corpus files passed as arguments. Used for coverage reports and +/// reproducing crashes without a fuzzer attached. +#[cfg(not(any( + feature = "afl_fuzz", + feature = "honggfuzz_fuzz", + feature = "libfuzzer_fuzz" +)))] +fn main() { + for path in std::env::args().skip(1) { + let data = std::fs::read(&path).unwrap_or_else(|e| panic!("failed to read {path}: {e}")); + do_test(&data); + } +} From 86fdfd8de2c65788ffc0faebefd09a4ce3ff6bee Mon Sep 17 00:00:00 2001 From: Erick Cestari Date: Wed, 8 Jul 2026 20:01:05 -0300 Subject: [PATCH 2/3] ci: add new fuzzing job Co-authored-by: Leonardo Lima --- .github/workflows/cont_integration.yml | 67 ++++++++++++++++++++++++++ .gitignore | 1 + 2 files changed, 68 insertions(+) diff --git a/.github/workflows/cont_integration.yml b/.github/workflows/cont_integration.yml index 1a5c384449..da92439b28 100644 --- a/.github/workflows/cont_integration.yml +++ b/.github/workflows/cont_integration.yml @@ -158,3 +158,70 @@ jobs: cache: true - name: Check docs run: RUSTDOCFLAGS='-D warnings' cargo doc --workspace --no-deps + + fuzz: + needs: prepare + name: Fuzz (${{ matrix.fuzzer }}, ${{ matrix.target }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + fuzzer: [afl, honggfuzz, libfuzzer] + target: [local_chain_apply_update, local_chain_apply_update_header] + env: + FUZZ_TARGET: ${{ matrix.target }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + persist-credentials: false + - name: Install Rust toolchain + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + # cargo-fuzz (libFuzzer) requires nightly; AFL and honggfuzz work on stable. + toolchain: ${{ matrix.fuzzer == 'libfuzzer' && 'nightly' || needs.prepare.outputs.rust_version }} + override: true + cache: true + - name: Install honggfuzz build dependencies + if: matrix.fuzzer == 'honggfuzz' + run: sudo apt-get update && sudo apt-get install -y binutils-dev libunwind-dev + - name: Install cargo-afl + if: matrix.fuzzer == 'afl' + run: | + cargo install cargo-afl --force + cargo afl config --build --force + - name: Install cargo-hfuzz + if: matrix.fuzzer == 'honggfuzz' + run: cargo install honggfuzz + - name: Install cargo-fuzz + if: matrix.fuzzer == 'libfuzzer' + run: cargo install cargo-fuzz + - name: Fuzz for 5 minutes (AFL) + if: matrix.fuzzer == 'afl' + working-directory: ./fuzz + env: + AFL_NO_UI: 1 + AFL_SKIP_CPUFREQ: 1 + AFL_I_DONT_CARE_ABOUT_MISSING_CRASHES: 1 + run: | + mkdir -p ci-seeds && printf 'bdk-fuzz-seed' > ci-seeds/seed + cargo afl build --features afl_fuzz --bin "$FUZZ_TARGET" + cargo afl fuzz -i ci-seeds -o afl-out -V 300 -- "target/debug/$FUZZ_TARGET" + crashes=$(find afl-out -path '*crashes*' -name 'id:*') + if [ -n "$crashes" ]; then + echo "AFL found crashes:"; echo "$crashes"; exit 1 + fi + - name: Fuzz for 5 minutes (honggfuzz) + if: matrix.fuzzer == 'honggfuzz' + working-directory: ./fuzz + env: + HFUZZ_BUILD_ARGS: --features honggfuzz_fuzz + HFUZZ_RUN_ARGS: --run_time 300 --exit_upon_crash -v + run: | + cargo hfuzz run "$FUZZ_TARGET" + if [ -f "hfuzz_workspace/$FUZZ_TARGET/HONGGFUZZ.REPORT.TXT" ]; then + cat "hfuzz_workspace/$FUZZ_TARGET/HONGGFUZZ.REPORT.TXT"; exit 1 + fi + - name: Fuzz for 5 minutes (libFuzzer) + if: matrix.fuzzer == 'libfuzzer' + run: cargo fuzz run "$FUZZ_TARGET" --features libfuzzer_fuzz -- -max_total_time=300 diff --git a/.gitignore b/.gitignore index 2d21124218..3303e062fb 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ Cargo.lock *.sqlite* crates/electrum/target +fuzz/target From 45164f604166e42338aaa354e50295a7f281dbc4 Mon Sep 17 00:00:00 2001 From: Leonardo Lima Date: Fri, 17 Jul 2026 17:06:39 -0300 Subject: [PATCH 3/3] refactor(fuzz): extract shared harness library - Move input generators into arbitrary module, generic over the checkpoint data type - Move differential changeset and checkpoint-order assertions into checks module - Add fuzz_main! macro generating AFL/honggfuzz/libFuzzer entry points and a corpus-replay fallback main - Replace integer op dispatch with explicit Op enums - Remove leftover debug println --- fuzz/Cargo.toml | 2 +- fuzz/fuzz_targets/local_chain_apply_update.rs | 211 ++++----------- .../local_chain_apply_update_header.rs | 253 ++++-------------- fuzz/src/arbitrary.rs | 182 +++++++++++++ fuzz/src/checks.rs | 45 ++++ fuzz/src/engines.rs | 37 +++ fuzz/src/lib.rs | 9 + 7 files changed, 372 insertions(+), 367 deletions(-) create mode 100644 fuzz/src/arbitrary.rs create mode 100644 fuzz/src/checks.rs create mode 100644 fuzz/src/engines.rs create mode 100644 fuzz/src/lib.rs diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index dfcdb437b7..6ad99efab8 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -12,7 +12,7 @@ members = ["."] [dependencies] libfuzzer-sys = { version = "0.4", optional = true } -arbitrary = "1.4.1" +arbitrary = { version = "1.4.1", features = ["derive"] } honggfuzz = { version = "0.5.61", optional = true } afl = { version = "0.18.2", optional = true } bdk_chain = { path = "../crates/chain" } diff --git a/fuzz/fuzz_targets/local_chain_apply_update.rs b/fuzz/fuzz_targets/local_chain_apply_update.rs index 0548c42995..448b2af2ca 100644 --- a/fuzz/fuzz_targets/local_chain_apply_update.rs +++ b/fuzz/fuzz_targets/local_chain_apply_update.rs @@ -1,108 +1,33 @@ #![cfg_attr(feature = "libfuzzer_fuzz", no_main)] -use std::collections::BTreeMap; - -use arbitrary::{Arbitrary, Unstructured}; -use bdk_chain::bitcoin::block::{Header, Version}; use bdk_chain::bitcoin::hashes::Hash; -use bdk_chain::bitcoin::{BlockHash, CompactTarget, TxMerkleNode}; -use bdk_chain::local_chain::{ChangeSet, LocalChain, MissingGenesisError}; -use bdk_chain::{BlockId, CheckPoint}; - -fn arbitrary_hash(u: &mut Unstructured) -> arbitrary::Result { - Ok(BlockHash::from_byte_array(<[u8; 32]>::arbitrary(u)?)) -} - -/// Builds a header for `apply_header(_connected_to)`. Its `prev_blockhash` is usually taken -/// from an existing checkpoint (so the header connects to the chain), sometimes arbitrary. -fn arbitrary_header(u: &mut Unstructured, chain: &LocalChain) -> arbitrary::Result<(Header, u32)> { - let (prev_blockhash, height) = if u.ratio(3, 4)? { - let block_ids: Vec = chain.iter_checkpoints().map(|cp| cp.block_id()).collect(); - let connect_at = u.choose(&block_ids)?; - (connect_at.hash, connect_at.height.saturating_add(1)) - } else { - (arbitrary_hash(u)?, u32::arbitrary(u)?) - }; - let header = Header { - version: Version::from_consensus(i32::arbitrary(u)?), - prev_blockhash, - merkle_root: TxMerkleNode::from_byte_array(<[u8; 32]>::arbitrary(u)?), - time: u32::arbitrary(u)?, - bits: CompactTarget::from_consensus(u32::arbitrary(u)?), - nonce: u32::arbitrary(u)?, - }; - Ok((header, height)) +use bdk_chain::bitcoin::BlockHash; +use bdk_chain::local_chain::{LocalChain, MissingGenesisError}; +use bdk_chain::BlockId; +use bdk_chain_fuzz::arbitrary::{self, Arbitrary, Unstructured}; +use bdk_chain_fuzz::checks::{assert_changeset_against_chains, assert_checkpoint_order}; + +/// An operation to perform against the chain under test. +#[derive(Arbitrary, Debug, Clone, Copy)] +enum Op { + /// `apply_update` with an independently constructed chain as the update. + ApplyUpdate, + /// `insert_block` with an arbitrary height and hash. + InsertBlock, + /// `disconnect_from` an existing checkpoint or an arbitrary block id. + DisconnectFrom, + /// `apply_header` with a header that usually connects to an existing checkpoint. + ApplyHeader, + /// `apply_header_connected_to` with an arbitrarily picked connection point. + ApplyHeaderConnectedTo, + /// `apply_update` with an update derived by mutating the chain's own tip, so the + /// update shares `Arc` nodes with the original and exercises `merge_chains`' + /// `eq_ptr` fast path. + ApplyDerivedUpdate, } -fn arbitrary_chain(u: &mut Unstructured) -> arbitrary::Result> { - let raw_blocks: BTreeMap = BTreeMap::arbitrary(u)?; - println!("{:#?}", raw_blocks); - let blocks: BTreeMap = raw_blocks - .into_iter() - .map(|(height, hash)| (height, BlockHash::from_byte_array(hash))) - .collect(); - - let constructed = match u.int_in_range(0..=2)? { - 0 => LocalChain::from_blocks(blocks.clone()).ok(), - 1 => { - let changeset = ChangeSet { - blocks: blocks.iter().map(|(&h, &hash)| (h, Some(hash))).collect(), - }; - LocalChain::from_changeset(changeset).ok() - } - _ => CheckPoint::from_blocks(blocks.clone()) - .ok() - .and_then(|tip| LocalChain::from_tip(tip).ok()), - }; - let chain = match constructed { - Some(chain) => chain, - None => return Ok(None), - }; - - let tip = chain.tip(); - let (&tip_height, &tip_hash) = blocks.last_key_value().expect("chain is non-empty"); - assert_eq!(tip.block_id().height, tip_height); - assert_eq!(tip.block_id().hash, tip_hash); - assert_eq!(chain.genesis_hash(), blocks[&0]); - - Ok(Some(chain)) -} - -/// Picks a block id for an operation: either a checkpoint of `chain` or an arbitrary one. -fn arbitrary_block_id(u: &mut Unstructured, chain: &LocalChain) -> arbitrary::Result { - if bool::arbitrary(u)? { - let block_ids: Vec = chain.iter_checkpoints().map(|cp| cp.block_id()).collect(); - Ok(*u.choose(&block_ids)?) - } else { - Ok(BlockId { - height: u32::arbitrary(u)?, - hash: arbitrary_hash(u)?, - }) - } -} - -/// On success, `pre`-state plus the returned changeset must reconstruct the post-state. -/// On failure, the chain must be left untouched. -fn check_op_result(pre: LocalChain, post: &LocalChain, result: &Result) { - match result { - Ok(changeset) => { - let mut reconstructed = pre; - reconstructed - .apply_changeset(changeset) - .expect("applying an op's changeset to the pre-state must succeed"); - assert_eq!(&reconstructed, post); - } - Err(_) => assert_eq!(&pre, post, "a failed op must not modify the chain"), - } -} - -fn check_chain(chain: &LocalChain) { - let heights: Vec = chain.iter_checkpoints().map(|cp| cp.height()).collect(); - assert!( - heights.windows(2).all(|w| w[0] > w[1]), - "checkpoint heights must be strictly decreasing from tip" - ); - assert_eq!(heights.last(), Some(&0), "genesis must be present"); +fn assert_chain(chain: &LocalChain) { + assert_checkpoint_order(chain); let tip = chain.chain_tip(); assert_eq!(tip, chain.tip().block_id()); @@ -137,7 +62,7 @@ fn do_test(data: &[u8]) { let mut chain: Option = None; for _ in 0..op_count { if chain.is_none() { - match arbitrary_chain(&mut u) { + match arbitrary::blockhash_chain(&mut u) { Ok(Some(initial)) => chain = Some(initial), Ok(None) => continue, Err(_) => break, @@ -146,30 +71,30 @@ fn do_test(data: &[u8]) { } let chain = chain.as_mut().expect("initialized above"); - let op = match u.int_in_range::(0..=5) { + let op = match Op::arbitrary(&mut u) { Ok(op) => op, Err(_) => break, }; let pre = chain.clone(); match op { - 0 => { - let update = match arbitrary_chain(&mut u) { + Op::ApplyUpdate => { + let update = match arbitrary::blockhash_chain(&mut u) { Ok(Some(update)) => update, Ok(None) => continue, Err(_) => break, }; let result = chain.apply_update(update.tip()); - check_op_result(pre, chain, &result); + assert_changeset_against_chains(pre, chain, &result); } - 1 => { + Op::InsertBlock => { let (height, hash) = match u32::arbitrary(&mut u) - .and_then(|height| arbitrary_hash(&mut u).map(|hash| (height, hash))) + .and_then(|height| arbitrary::hash(&mut u).map(|hash| (height, hash))) { Ok(block) => block, Err(_) => break, }; let result = chain.insert_block(height, hash); - check_op_result(pre, chain, &result); + assert_changeset_against_chains(pre, chain, &result); match &result { Ok(_) => { assert_eq!(chain.get(height).map(|cp| cp.hash()), Some(hash)); @@ -183,13 +108,13 @@ fn do_test(data: &[u8]) { } } } - 2 => { - let block_id = match arbitrary_block_id(&mut u, chain) { + Op::DisconnectFrom => { + let block_id = match arbitrary::block_id(&mut u, chain, &[]) { Ok(block_id) => block_id, Err(_) => break, }; let result = chain.disconnect_from(block_id); - check_op_result(pre, chain, &result); + assert_changeset_against_chains(pre, chain, &result); match &result { Ok(changeset) if !changeset.blocks.is_empty() => { assert!(chain.tip().height() < block_id.height); @@ -201,13 +126,13 @@ fn do_test(data: &[u8]) { } } } - 3 => { - let (header, height) = match arbitrary_header(&mut u, chain) { + Op::ApplyHeader => { + let (header, height) = match arbitrary::connectable_header(&mut u, chain) { Ok(header) => header, Err(_) => break, }; let result = chain.apply_header(&header, height); - check_op_result(pre, chain, &result); + assert_changeset_against_chains(pre, chain, &result); if result.is_ok() { assert_eq!( chain.get(height).map(|cp| cp.hash()), @@ -215,10 +140,10 @@ fn do_test(data: &[u8]) { ); } } - 4 => { + Op::ApplyHeaderConnectedTo => { let params: arbitrary::Result<_> = (|| { - let (header, height) = arbitrary_header(&mut u, chain)?; - let connected_to = arbitrary_block_id(&mut u, chain)?; + let (header, height) = arbitrary::connectable_header(&mut u, chain)?; + let connected_to = arbitrary::block_id(&mut u, chain, &[])?; Ok((header, height, connected_to)) })(); let (header, height, connected_to) = match params { @@ -226,7 +151,7 @@ fn do_test(data: &[u8]) { Err(_) => break, }; let result = chain.apply_header_connected_to(&header, height, connected_to); - check_op_result(pre, chain, &result); + assert_changeset_against_chains(pre, chain, &result); if result.is_ok() { assert_eq!( chain.get(height).map(|cp| cp.hash()), @@ -234,13 +159,11 @@ fn do_test(data: &[u8]) { ); } } - _ => { - // Derived update: mutate the chain's own tip so the update shares `Arc` - // nodes with the original, exercising `merge_chains`' `eq_ptr` fast path. + Op::ApplyDerivedUpdate => { let params: arbitrary::Result<_> = (|| { let insert = bool::arbitrary(&mut u)?; let height = u32::arbitrary(&mut u)?; - let hash = arbitrary_hash(&mut u)?; + let hash = arbitrary::hash(&mut u)?; Ok((insert, height, hash)) })(); let (insert, height, hash) = match params { @@ -262,54 +185,18 @@ fn do_test(data: &[u8]) { } }; let result = chain.apply_update(update_tip); - check_op_result(pre, chain, &result); + assert_changeset_against_chains(pre, chain, &result); if result.is_ok() { assert_eq!(chain.get(height).map(|cp| cp.hash()), Some(hash)); } } } - check_chain(chain); + assert_chain(chain); } if let Some(chain) = chain { - check_chain(&chain); - } -} - -#[cfg(feature = "afl_fuzz")] -#[macro_use] -extern crate afl; -#[cfg(feature = "afl_fuzz")] -fn main() { - fuzz!(|data| { do_test(data) }); -} - -#[cfg(feature = "honggfuzz_fuzz")] -#[macro_use] -extern crate honggfuzz; -#[cfg(feature = "honggfuzz_fuzz")] -fn main() { - loop { - fuzz!(|data| { do_test(data) }); + assert_chain(&chain); } } -#[cfg(feature = "libfuzzer_fuzz")] -#[macro_use] -extern crate libfuzzer_sys; -#[cfg(feature = "libfuzzer_fuzz")] -fuzz_target!(|data: &[u8]| do_test(data)); - -/// Replays corpus files passed as arguments. Used for coverage reports and -/// reproducing crashes without a fuzzer attached. -#[cfg(not(any( - feature = "afl_fuzz", - feature = "honggfuzz_fuzz", - feature = "libfuzzer_fuzz" -)))] -fn main() { - for path in std::env::args().skip(1) { - let data = std::fs::read(&path).unwrap_or_else(|e| panic!("failed to read {path}: {e}")); - do_test(&data); - } -} +bdk_chain_fuzz::fuzz_main!(do_test); diff --git a/fuzz/fuzz_targets/local_chain_apply_update_header.rs b/fuzz/fuzz_targets/local_chain_apply_update_header.rs index 168008046b..4d34f98b8a 100644 --- a/fuzz/fuzz_targets/local_chain_apply_update_header.rs +++ b/fuzz/fuzz_targets/local_chain_apply_update_header.rs @@ -12,157 +12,38 @@ use std::collections::BTreeMap; -use arbitrary::{Arbitrary, Unstructured}; use bdk_chain::bitcoin::block::{Header, Version}; use bdk_chain::bitcoin::hashes::Hash; use bdk_chain::bitcoin::{BlockHash, CompactTarget, TxMerkleNode}; -use bdk_chain::local_chain::{ChangeSet, LocalChain}; -use bdk_chain::{BlockId, CheckPoint, CheckPointEntry}; +use bdk_chain::local_chain::LocalChain; +use bdk_chain::CheckPointEntry; +use bdk_chain_fuzz::arbitrary::{self, Arbitrary, Unstructured}; +use bdk_chain_fuzz::checks::{assert_changeset_against_chains, assert_checkpoint_order}; const MAX_HEIGHT: u32 = 32; -fn arbitrary_header(u: &mut Unstructured, prev_blockhash: BlockHash) -> arbitrary::Result
{ - Ok(Header { - version: Version::from_consensus(i32::arbitrary(u)?), - prev_blockhash, - merkle_root: TxMerkleNode::from_byte_array(<[u8; 32]>::arbitrary(u)?), - time: u32::arbitrary(u)?, - bits: CompactTarget::from_consensus(u32::arbitrary(u)?), - nonce: u32::arbitrary(u)?, - }) -} - -/// Regenerates `headers` from `fork_height` upward with fresh arbitrary fields, keeping -/// `prev_blockhash` links intact so contiguous checkpoints remain valid. -fn reorg( - u: &mut Unstructured, - headers: &mut [Header], - fork_height: usize, -) -> arbitrary::Result<()> { - for height in fork_height..headers.len() { - let prev_blockhash = match height.checked_sub(1) { - Some(prev) => headers[prev].block_hash(), - None => BlockHash::all_zeros(), - }; - headers[height] = arbitrary_header(u, prev_blockhash)?; - } - Ok(()) -} - -/// Picks a header for an operation at `height`: usually the virtual chain's (shares hashes -/// with the chain under test), sometimes a foreign one (conflicts). -fn header_at(u: &mut Unstructured, headers: &[Header], height: usize) -> arbitrary::Result
{ - if u.ratio(7, 8)? { - Ok(headers[height]) - } else { - let prev_blockhash = BlockHash::from_byte_array(<[u8; 32]>::arbitrary(u)?); - arbitrary_header(u, prev_blockhash) - } -} - -/// Builds a `LocalChain
` occupying an arbitrary subset of the virtual chain's -/// heights (genesis always included), via an arbitrarily chosen constructor. -fn arbitrary_chain( - u: &mut Unstructured, - headers: &[Header], -) -> arbitrary::Result>> { - let occupied_mask = u32::arbitrary(u)? | 1; - let blocks: BTreeMap = headers - .iter() - .enumerate() - .map(|(height, header)| (height as u32, *header)) - .filter(|(height, _)| occupied_mask & (1u32 << (height % MAX_HEIGHT)) != 0) - .collect(); - - let constructed = match u.int_in_range(0..=2)? { - 0 => LocalChain::from_blocks(blocks.clone()).ok(), - 1 => { - let changeset = ChangeSet { - blocks: blocks - .iter() - .map(|(&h, &header)| (h, Some(header))) - .collect(), - }; - LocalChain::from_changeset(changeset).ok() - } - _ => CheckPoint::from_blocks(blocks.clone()) - .ok() - .and_then(|tip| LocalChain::from_tip(tip).ok()), - }; - let chain = match constructed { - Some(chain) => chain, - None => return Ok(None), - }; - - let (&tip_height, tip_header) = blocks.last_key_value().expect("chain is non-empty"); - assert_eq!(chain.tip().block_id().height, tip_height); - assert_eq!(chain.tip().block_id().hash, tip_header.block_hash()); - assert_eq!(chain.genesis_hash(), blocks[&0].block_hash()); - - Ok(Some(chain)) -} - -/// Picks a block id for an operation: a checkpoint of `chain`, a virtual chain block, or -/// an arbitrary one. -fn arbitrary_block_id( - u: &mut Unstructured, - chain: &LocalChain
, - headers: &[Header], -) -> arbitrary::Result { - match u.int_in_range(0..=2)? { - 0 => { - let block_ids: Vec = - chain.iter_checkpoints().map(|cp| cp.block_id()).collect(); - Ok(*u.choose(&block_ids)?) - } - 1 => { - let height = u.int_in_range(0..=headers.len() - 1)?; - Ok(BlockId { - height: height as u32, - hash: headers[height].block_hash(), - }) - } - _ => Ok(BlockId { - height: u32::arbitrary(u)?, - hash: BlockHash::from_byte_array(<[u8; 32]>::arbitrary(u)?), - }), - } -} - -/// On success, `pre`-state plus the returned changeset must reconstruct the post-state. -/// On failure, the chain must be left untouched. -fn check_op_result( - pre: LocalChain
, - post: &LocalChain
, - result: &Result, E>, -) { - match result { - Ok(changeset) => { - let mut reconstructed = pre; - reconstructed - .apply_changeset(changeset) - .expect("applying an op's changeset to the pre-state must succeed"); - assert_eq!(&reconstructed, post); - } - Err(_) => assert_eq!(&pre, post, "a failed op must not modify the chain"), - } +/// An operation to perform against the chain under test. +#[derive(Arbitrary, Debug, Clone, Copy)] +enum Op { + /// `apply_update` with an independently constructed subset of the virtual chain. + ApplyUpdate, + /// `insert_block` with a virtual chain header (or sometimes a foreign one). + InsertBlock, + /// `disconnect_from` a checkpoint, a virtual chain block, or an arbitrary block id. + DisconnectFrom, + /// `apply_update` with an update derived by inserting into the chain's own tip, so + /// the update shares `Arc` nodes with the original and exercises `merge_chains`' + /// `eq_ptr` fast path. + ApplyDerivedUpdate, } /// Checks checkpoint and entry invariants, including placeholder consistency. -fn check_chain(chain: &LocalChain
) { +fn assert_chain(chain: &LocalChain
) { + assert_checkpoint_order(chain); let occupied: BTreeMap = chain .iter_checkpoints() .map(|cp| (cp.height(), cp.hash())) .collect(); - let heights: Vec = occupied.keys().rev().copied().collect(); - assert!( - chain - .iter_checkpoints() - .map(|cp| cp.height()) - .eq(heights.iter().copied()), - "checkpoint heights must be strictly decreasing from tip" - ); - assert_eq!(heights.last(), Some(&0), "genesis must be present"); let mut entry_heights = Vec::new(); for entry in chain.tip().entry_iter() { @@ -217,7 +98,7 @@ fn do_test(data: &[u8]) { }; height_count as usize ]; - if reorg(&mut u, &mut headers, 0).is_err() { + if arbitrary::reorg(&mut u, &mut headers, 0).is_err() { return; } @@ -229,7 +110,7 @@ fn do_test(data: &[u8]) { let mut chain: Option> = None; for _ in 0..op_count { if chain.is_none() { - match arbitrary_chain(&mut u, &headers) { + match arbitrary::header_chain(&mut u, &headers) { Ok(Some(initial)) => chain = Some(initial), Ok(None) => continue, Err(_) => break, @@ -238,31 +119,31 @@ fn do_test(data: &[u8]) { } let chain = chain.as_mut().expect("initialized above"); - let op = match u.int_in_range::(0..=3) { + let op = match Op::arbitrary(&mut u) { Ok(op) => op, Err(_) => break, }; let pre = chain.clone(); match op { - 0 => { - let update = match arbitrary_chain(&mut u, &headers) { + Op::ApplyUpdate => { + let update = match arbitrary::header_chain(&mut u, &headers) { Ok(Some(update)) => update, Ok(None) => continue, Err(_) => break, }; let result = chain.apply_update(update.tip()); - check_op_result(pre, chain, &result); + assert_changeset_against_chains(pre, chain, &result); } - 1 => { - let (height, header) = match u - .int_in_range(0..=headers.len() - 1) - .and_then(|height| header_at(&mut u, &headers, height).map(|h| (height, h))) - { - Ok(block) => block, - Err(_) => break, - }; + Op::InsertBlock => { + let (height, header) = + match u.int_in_range(0..=headers.len() - 1).and_then(|height| { + arbitrary::header_at(&mut u, &headers, height).map(|h| (height, h)) + }) { + Ok(block) => block, + Err(_) => break, + }; let result = chain.insert_block(height as u32, header); - check_op_result(pre, chain, &result); + assert_changeset_against_chains(pre, chain, &result); match &result { Ok(_) => { assert_eq!( @@ -279,13 +160,13 @@ fn do_test(data: &[u8]) { } } } - 2 => { - let block_id = match arbitrary_block_id(&mut u, chain, &headers) { + Op::DisconnectFrom => { + let block_id = match arbitrary::block_id(&mut u, chain, &headers) { Ok(block_id) => block_id, Err(_) => break, }; let result = chain.disconnect_from(block_id); - check_op_result(pre, chain, &result); + assert_changeset_against_chains(pre, chain, &result); match &result { Ok(changeset) if !changeset.blocks.is_empty() => { assert!(chain.tip().height() < block_id.height); @@ -297,18 +178,16 @@ fn do_test(data: &[u8]) { } } } - _ => { - // Derived update: insert into the chain's own tip so the update shares - // `Arc` nodes with the original, exercising `merge_chains`' `eq_ptr` - // fast path. Heights 0 and 1 are excluded: after a reorg the virtual - // headers may imply a different genesis, which `CheckPoint::insert` - // rejects with a panic. + Op::ApplyDerivedUpdate => { + // Heights 0 and 1 are excluded: after a reorg the virtual headers may + // imply a different genesis, which `CheckPoint::insert` rejects with a + // panic. if headers.len() < 3 { continue; } let params: arbitrary::Result<_> = (|| { let height = u.int_in_range(2..=headers.len() - 1)?; - let header = header_at(&mut u, &headers, height)?; + let header = arbitrary::header_at(&mut u, &headers, height)?; Ok((height as u32, header)) })(); let (height, header) = match params { @@ -317,7 +196,7 @@ fn do_test(data: &[u8]) { }; let update_tip = chain.tip().insert(height, header); let result = chain.apply_update(update_tip); - check_op_result(pre, chain, &result); + assert_changeset_against_chains(pre, chain, &result); if result.is_ok() { assert_eq!( chain.get(height).map(|cp| cp.hash()), @@ -326,56 +205,22 @@ fn do_test(data: &[u8]) { } } } - check_chain(chain); + assert_chain(chain); let fork_height = match u.int_in_range(0..=headers.len()) { Ok(fork_height) => fork_height, Err(_) => break, }; - if fork_height < headers.len() && reorg(&mut u, &mut headers, fork_height).is_err() { + if fork_height < headers.len() + && arbitrary::reorg(&mut u, &mut headers, fork_height).is_err() + { break; } } if let Some(chain) = chain { - check_chain(&chain); + assert_chain(&chain); } } -#[cfg(feature = "afl_fuzz")] -#[macro_use] -extern crate afl; -#[cfg(feature = "afl_fuzz")] -fn main() { - fuzz!(|data| { do_test(data) }); -} - -#[cfg(feature = "honggfuzz_fuzz")] -#[macro_use] -extern crate honggfuzz; -#[cfg(feature = "honggfuzz_fuzz")] -fn main() { - loop { - fuzz!(|data| { do_test(data) }); - } -} - -#[cfg(feature = "libfuzzer_fuzz")] -#[macro_use] -extern crate libfuzzer_sys; -#[cfg(feature = "libfuzzer_fuzz")] -fuzz_target!(|data: &[u8]| do_test(data)); - -/// Replays corpus files passed as arguments. Used for coverage reports and -/// reproducing crashes without a fuzzer attached. -#[cfg(not(any( - feature = "afl_fuzz", - feature = "honggfuzz_fuzz", - feature = "libfuzzer_fuzz" -)))] -fn main() { - for path in std::env::args().skip(1) { - let data = std::fs::read(&path).unwrap_or_else(|e| panic!("failed to read {path}: {e}")); - do_test(&data); - } -} +bdk_chain_fuzz::fuzz_main!(do_test); diff --git a/fuzz/src/arbitrary.rs b/fuzz/src/arbitrary.rs new file mode 100644 index 0000000000..f8b7707007 --- /dev/null +++ b/fuzz/src/arbitrary.rs @@ -0,0 +1,182 @@ +//! Arbitrary-driven generators for headers, chains, and block ids. +//! +//! Re-exports the `arbitrary` crate's items, so targets can reach both the crate +//! (`arbitrary::Arbitrary`, `arbitrary::Result`) and the generators +//! (`arbitrary::blockhash_chain`) through this one module without the crate and +//! module names shadowing each other. + +pub use ::arbitrary::*; + +use std::collections::BTreeMap; + +use bdk_chain::bitcoin::block::{Header, Version}; +use bdk_chain::bitcoin::hashes::Hash; +use bdk_chain::bitcoin::{BlockHash, CompactTarget, TxMerkleNode}; +use bdk_chain::local_chain::{ChangeSet, LocalChain}; +use bdk_chain::{BlockId, CheckPoint, ToBlockHash}; + +pub fn hash(u: &mut Unstructured) -> arbitrary::Result { + Ok(BlockHash::from_byte_array(<[u8; 32]>::arbitrary(u)?)) +} + +/// Builds a header with arbitrary fields on top of `prev_blockhash`. +pub fn header(u: &mut Unstructured, prev_blockhash: BlockHash) -> arbitrary::Result
{ + Ok(Header { + version: Version::from_consensus(i32::arbitrary(u)?), + prev_blockhash, + merkle_root: TxMerkleNode::from_byte_array(<[u8; 32]>::arbitrary(u)?), + time: u32::arbitrary(u)?, + bits: CompactTarget::from_consensus(u32::arbitrary(u)?), + nonce: u32::arbitrary(u)?, + }) +} + +/// Builds a header for `apply_header(_connected_to)`. Its `prev_blockhash` is usually taken +/// from an existing checkpoint (so the header connects to the chain), sometimes arbitrary. +pub fn connectable_header( + u: &mut Unstructured, + chain: &LocalChain, +) -> arbitrary::Result<(Header, u32)> +where + D: ToBlockHash + std::fmt::Debug + Clone, +{ + let (prev_blockhash, height) = if u.ratio(3, 4)? { + let block_ids: Vec = chain.iter_checkpoints().map(|cp| cp.block_id()).collect(); + let connect_at = u.choose(&block_ids)?; + (connect_at.hash, connect_at.height.saturating_add(1)) + } else { + (hash(u)?, u32::arbitrary(u)?) + }; + let header = header(u, prev_blockhash)?; + Ok((header, height)) +} + +/// Regenerates `headers` from `fork_height` upward with fresh arbitrary fields, keeping +/// `prev_blockhash` links intact so contiguous checkpoints remain valid. +pub fn reorg( + u: &mut Unstructured, + headers: &mut [Header], + fork_height: usize, +) -> arbitrary::Result<()> { + for height in fork_height..headers.len() { + let prev_blockhash = match height.checked_sub(1) { + Some(prev) => headers[prev].block_hash(), + None => BlockHash::all_zeros(), + }; + headers[height] = header(u, prev_blockhash)?; + } + Ok(()) +} + +/// Picks a header for an operation at `height`: usually the virtual chain's (shares hashes +/// with the chain under test), sometimes a foreign one (conflicts). +pub fn header_at( + u: &mut Unstructured, + headers: &[Header], + height: usize, +) -> arbitrary::Result
{ + if u.ratio(7, 8)? { + Ok(headers[height]) + } else { + let prev_blockhash = hash(u)?; + header(u, prev_blockhash) + } +} + +/// Builds a `LocalChain` from `blocks` via an arbitrarily chosen constructor. +/// +/// Returns `None` when `blocks` does not form a valid chain (empty, missing genesis, or +/// inconsistent `prev_blockhash` links). On success, asserts that the constructed chain +/// agrees with `blocks` on tip and genesis. +pub fn chain_from_blocks( + u: &mut Unstructured, + blocks: BTreeMap, +) -> arbitrary::Result>> +where + D: ToBlockHash + std::fmt::Debug + Clone, +{ + let constructed = match u.int_in_range(0..=2)? { + 0 => LocalChain::from_blocks(blocks.clone()).ok(), + 1 => { + let changeset = ChangeSet { + blocks: blocks + .iter() + .map(|(&height, data)| (height, Some(data.clone()))) + .collect(), + }; + LocalChain::from_changeset(changeset).ok() + } + _ => CheckPoint::from_blocks(blocks.clone()) + .ok() + .and_then(|tip| LocalChain::from_tip(tip).ok()), + }; + let chain = match constructed { + Some(chain) => chain, + None => return Ok(None), + }; + + let (&tip_height, tip_data) = blocks.last_key_value().expect("chain is non-empty"); + assert_eq!(chain.tip().block_id().height, tip_height); + assert_eq!(chain.tip().block_id().hash, tip_data.to_blockhash()); + assert_eq!(chain.genesis_hash(), blocks[&0].to_blockhash()); + + Ok(Some(chain)) +} + +/// Builds a `LocalChain` from an arbitrary height-to-hash map, via an +/// arbitrarily chosen constructor. +pub fn blockhash_chain(u: &mut Unstructured) -> arbitrary::Result> { + let blocks: BTreeMap = BTreeMap::::arbitrary(u)? + .into_iter() + .map(|(height, hash)| (height, BlockHash::from_byte_array(hash))) + .collect(); + chain_from_blocks(u, blocks) +} + +/// Builds a `LocalChain
` occupying an arbitrary subset of the virtual chain's +/// heights (genesis always included), via an arbitrarily chosen constructor. +pub fn header_chain( + u: &mut Unstructured, + headers: &[Header], +) -> arbitrary::Result>> { + // The mask is 32 bits wide, so occupancy repeats for heights >= 32. + let occupied_mask = u32::arbitrary(u)? | 1; + let blocks: BTreeMap = headers + .iter() + .enumerate() + .map(|(height, header)| (height as u32, *header)) + .filter(|(height, _)| occupied_mask & (1u32 << (height % 32)) != 0) + .collect(); + chain_from_blocks(u, blocks) +} + +/// Picks a block id for an operation: a checkpoint of `chain`, one of `headers` (when +/// non-empty), or an arbitrary one. +pub fn block_id( + u: &mut Unstructured, + chain: &LocalChain, + headers: &[Header], +) -> arbitrary::Result +where + D: ToBlockHash + std::fmt::Debug + Clone, +{ + let variant_count = if headers.is_empty() { 1 } else { 2 }; + match u.int_in_range(0..=variant_count)? { + 0 => { + let block_ids: Vec = + chain.iter_checkpoints().map(|cp| cp.block_id()).collect(); + Ok(*u.choose(&block_ids)?) + } + 1 if !headers.is_empty() => { + let height = u.int_in_range(0..=headers.len() - 1)?; + Ok(BlockId { + height: height as u32, + hash: headers[height].block_hash(), + }) + } + _ => Ok(BlockId { + height: u32::arbitrary(u)?, + hash: hash(u)?, + }), + } +} diff --git a/fuzz/src/checks.rs b/fuzz/src/checks.rs new file mode 100644 index 0000000000..2d88768355 --- /dev/null +++ b/fuzz/src/checks.rs @@ -0,0 +1,45 @@ +//! Invariant checks the harnesses run after each operation. + +use bdk_chain::local_chain::{ChangeSet, LocalChain}; +use bdk_chain::ToBlockHash; + +/// Checks the changeset returned by an operation against the `LocalChain` states +/// surrounding it. +/// +/// On success, applying the returned changeset to `chain_before` must reconstruct +/// `chain_after`. On failure, the operation must not have modified the chain, so +/// `chain_before` and `chain_after` must be equal. +pub fn assert_changeset_against_chains( + chain_before: LocalChain, + chain_after: &LocalChain, + op_result: &Result, E>, +) where + D: ToBlockHash + std::fmt::Debug + Clone, +{ + match op_result { + Ok(changeset) => { + let mut reconstructed = chain_before; + reconstructed + .apply_changeset(changeset) + .expect("applying an op's changeset to the pre-state must succeed"); + assert_eq!(&reconstructed, chain_after); + } + Err(_) => assert_eq!( + &chain_before, chain_after, + "a failed op must not modify the chain" + ), + } +} + +/// Checks that checkpoint heights strictly decrease from tip and genesis is present. +pub fn assert_checkpoint_order(chain: &LocalChain) +where + D: ToBlockHash + std::fmt::Debug + Clone, +{ + let heights: Vec = chain.iter_checkpoints().map(|cp| cp.height()).collect(); + assert!( + heights.windows(2).all(|w| w[0] > w[1]), + "checkpoint heights must be strictly decreasing from tip" + ); + assert_eq!(heights.last(), Some(&0), "genesis must be present"); +} diff --git a/fuzz/src/engines.rs b/fuzz/src/engines.rs new file mode 100644 index 0000000000..4ae286138d --- /dev/null +++ b/fuzz/src/engines.rs @@ -0,0 +1,37 @@ +//! Entry-point plumbing for the supported fuzzing engines. + +/// Generates the fuzzing entry point for the enabled engine feature, plus a fallback +/// `main` that replays corpus files passed as arguments (used for coverage reports and +/// reproducing crashes without a fuzzer attached). +#[macro_export] +macro_rules! fuzz_main { + ($do_test:path) => { + #[cfg(feature = "afl_fuzz")] + fn main() { + ::afl::fuzz!(|data| { $do_test(data) }); + } + + #[cfg(feature = "honggfuzz_fuzz")] + fn main() { + loop { + ::honggfuzz::fuzz!(|data| { $do_test(data) }); + } + } + + #[cfg(feature = "libfuzzer_fuzz")] + ::libfuzzer_sys::fuzz_target!(|data: &[u8]| $do_test(data)); + + #[cfg(not(any( + feature = "afl_fuzz", + feature = "honggfuzz_fuzz", + feature = "libfuzzer_fuzz" + )))] + fn main() { + for path in ::std::env::args().skip(1) { + let data = + ::std::fs::read(&path).unwrap_or_else(|e| panic!("failed to read {path}: {e}")); + $do_test(&data); + } + } + }; +} diff --git a/fuzz/src/lib.rs b/fuzz/src/lib.rs new file mode 100644 index 0000000000..60d611287d --- /dev/null +++ b/fuzz/src/lib.rs @@ -0,0 +1,9 @@ +//! Helpers shared by the `LocalChain` fuzz targets. +//! +//! - [`arbitrary`]: arbitrary-driven arbitrary for headers, chains, and block ids. +//! - [`checks`]: invariant checks the harnesses run after each operation. +//! - [`fuzz_main`]: generates the fuzzing entry point for the enabled engine feature. + +pub mod arbitrary; +pub mod checks; +pub mod engines;