From 0bd4e6d6b5c7e5ac5356d197340b548c6db756d4 Mon Sep 17 00:00:00 2001 From: Erick Cestari Date: Wed, 8 Jul 2026 17:11:54 -0300 Subject: [PATCH 1/2] 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/2] 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