diff --git a/.github/workflows/cont_integration.yml b/.github/workflows/cont_integration.yml index d97fccbeff..41c3565ef0 100644 --- a/.github/workflows/cont_integration.yml +++ b/.github/workflows/cont_integration.yml @@ -337,3 +337,70 @@ jobs: cache: true - name: Check docs run: RUSTDOCFLAGS='-D warnings' cargo doc --workspace --all-features --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 1 minute (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 60 -- "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 1 minute (honggfuzz) + if: matrix.fuzzer == 'honggfuzz' + working-directory: ./fuzz + env: + HFUZZ_BUILD_ARGS: --features honggfuzz_fuzz + HFUZZ_RUN_ARGS: --run_time 60 --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 1 minute (libFuzzer) + if: matrix.fuzzer == 'libfuzzer' + run: cargo fuzz run "$FUZZ_TARGET" --features libfuzzer_fuzz -- -max_total_time=60 -print_final_stats=1 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 diff --git a/crates/chain/src/local_chain.rs b/crates/chain/src/local_chain.rs index ee1d67fe0d..0cc5dcdbe2 100644 --- a/crates/chain/src/local_chain.rs +++ b/crates/chain/src/local_chain.rs @@ -375,7 +375,23 @@ where } /// Apply the given `changeset`. + /// + /// # Errors + /// + /// [`ApplyBlockError::MissingGenesis`] occurs if the `changeset` would remove or replace the + /// genesis block. [`ApplyBlockError::PrevBlockhashMismatch`] occurs if it would break a + /// `prev_blockhash` link. + /// + /// The chain is left untouched when this fails. pub fn apply_changeset(&mut self, changeset: &ChangeSet) -> Result<(), ApplyBlockError> { + // Genesis is immutable: a changeset that swaps it belongs to a different chain. Removing + // it is caught further down by `LocalChain::from_blocks`. + if let Some(Some(data)) = changeset.blocks.get(&0) { + if data.to_blockhash() != self.genesis_hash() { + return Err(ApplyBlockError::MissingGenesis); + } + } + let old_tip = self.tip.clone(); let new_tip = apply_changeset_to_checkpoint(old_tip, changeset)?; self.tip = new_tip; @@ -553,7 +569,7 @@ impl FromIterator<(u32, D)> for ChangeSet { /// Error when applying blocks to a local chain. #[derive(Clone, Debug, PartialEq)] pub enum ApplyBlockError { - /// Genesis block is missing. + /// Genesis block is missing, or would be replaced by a different block. MissingGenesis, /// Block's `prev_blockhash` doesn't match the expected block. PrevBlockhashMismatch { @@ -566,7 +582,7 @@ impl core::fmt::Display for ApplyBlockError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { ApplyBlockError::MissingGenesis => { - write!(f, "genesis block is missing") + write!(f, "genesis block is missing or would be replaced") } ApplyBlockError::PrevBlockhashMismatch { expected } => write!( f, 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..7ebbfc5f03 --- /dev/null +++ b/fuzz/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "bdk_fuzz" +version = "0.0.0" +publish = false +edition = "2021" + +[package.metadata] +cargo-fuzz = true + +[workspace] +members = ["."] + +[dependencies] +bdk_chain = { path = "../crates/chain" } +arbitrary = { version = "1.4.1", features = ["derive"] } +libfuzzer-sys = { version = "0.4", optional = true } +honggfuzz = { version = "0.5.61", optional = true } +afl = { version = "0.18.2", optional = true } + +[features] +afl_fuzz = ["afl"] +honggfuzz_fuzz = ["honggfuzz"] +libfuzzer_fuzz = ["libfuzzer-sys"] + +[[bin]] +name = "local_chain_apply_update" +path = "fuzz_targets/chain/local_chain_apply_update.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "local_chain_apply_update_header" +path = "fuzz_targets/chain/local_chain_apply_update_header.rs" +test = false +doc = false +bench = false diff --git a/fuzz/README.md b/fuzz/README.md new file mode 100644 index 0000000000..ba53295f50 --- /dev/null +++ b/fuzz/README.md @@ -0,0 +1,101 @@ +# Fuzzing + +Fuzz targets for the BDK crates. All commands run from this directory (`fuzz/`). + +Targets are grouped per crate: harnesses live in `fuzz_targets//` and the +generators and invariant checks they share live in `src//`. Engine plumbing +(`src/engines.rs`) is shared by every target. + +## Targets + +### `bdk_chain` + +| Target | What it fuzzes | +| ---------------------------------- | ------------------------------------------------------- | +| `local_chain_apply_update` | `LocalChain` | +| `local_chain_apply_update_header` | `LocalChain
` (checkpoint gaps become placeholders) | + +Each target drives a chain through a sequence of arbitrary operations +(`apply_update`, `apply_changeset`, `insert_block`, `disconnect_from`, +`apply_header{,_connected_to}`) and asserts the invariants in +`src/chain/checks.rs` after every one. + +## libFuzzer + +Requires nightly. + +```sh +cargo install cargo-fuzz +cargo +nightly fuzz run local_chain_apply_update --features libfuzzer_fuzz -- -max_total_time=300 +``` + +Crashes land in `artifacts//`. Replay one with: + +```sh +cargo +nightly fuzz run local_chain_apply_update --features libfuzzer_fuzz artifacts/local_chain_apply_update/crash- +``` + +## honggfuzz + +```sh +sudo apt-get install -y binutils-dev libunwind-dev # build dependencies +cargo install honggfuzz + +HFUZZ_BUILD_ARGS="--features honggfuzz_fuzz" \ +HFUZZ_RUN_ARGS="--run_time 300 --exit_upon_crash -v" \ + cargo hfuzz run local_chain_apply_update +``` + +A crash writes `hfuzz_workspace//HONGGFUZZ.REPORT.TXT`. + +## AFL++ + +```sh +cargo install cargo-afl +cargo afl config --build + +mkdir -p afl-seeds && printf 'bdk-fuzz-seed' > afl-seeds/seed +cargo afl build --features afl_fuzz --bin local_chain_apply_update +cargo afl fuzz -i afl-seeds -o afl-out -V 300 -- target/debug/local_chain_apply_update +``` + +Crashes land in `afl-out/*/crashes/`. + +## Replaying without a fuzzer + +Built with no engine feature, each target gets a `main` that replays the corpus +files passed as arguments. Useful for debugging a crash under `rust-gdb` or with +a backtrace: + +```sh +cargo build --bin local_chain_apply_update +RUST_BACKTRACE=1 ./target/debug/local_chain_apply_update corpus/local_chain_apply_update/* +``` + +## Coverage report + +`cargo fuzz coverage` runs a target over its corpus and writes +`coverage//coverage.profdata`. Report on that profile against the same +target's binary: + +```sh +HOST=$(rustc +nightly -vV | sed -n 's/^host: //p') +LLVM_BIN="$(rustc +nightly --print sysroot)/lib/rustlib/$HOST/bin" +BUILD_DIR="target/$HOST/coverage/$HOST/release" +SOURCES="../crates/chain/src/local_chain.rs ../crates/core/src/checkpoint.rs" + +for target in local_chain_apply_update local_chain_apply_update_header; do + cargo +nightly fuzz coverage "$target" --features libfuzzer_fuzz + + echo "=== $target" + "$LLVM_BIN/llvm-cov" report \ + -instr-profile="coverage/$target/coverage.profdata" \ + -object "$BUILD_DIR/$target" \ + -sources $SOURCES +done +``` + +Swap `report` for `show ... --format=html --output-dir=coverage/$target/report` +to get a line-by-line HTML report per target. + +Requires the `llvm-tools` rustup component (`rustup component add llvm-tools`). diff --git a/fuzz/fuzz_targets/chain/local_chain_apply_update.rs b/fuzz/fuzz_targets/chain/local_chain_apply_update.rs new file mode 100644 index 0000000000..3f4c75dd4e --- /dev/null +++ b/fuzz/fuzz_targets/chain/local_chain_apply_update.rs @@ -0,0 +1,250 @@ +#![cfg_attr(feature = "libfuzzer_fuzz", no_main)] + +use bdk_chain::bitcoin::hashes::Hash; +use bdk_chain::bitcoin::BlockHash; +use bdk_chain::local_chain::{LocalChain, MissingGenesisError}; +use bdk_chain::BlockId; +use bdk_fuzz::chain::arbitrary::{self, Arbitrary, Unstructured}; +use bdk_fuzz::chain::checks::{ + assert_changeset_against_chains, assert_changeset_applied, assert_checkpoint_order, + assert_initial_changeset_roundtrip, +}; + +/// 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, + /// `apply_changeset` with an arbitrary mix of insertions and removals. + ApplyChangeSet, +} + +fn assert_chain(chain: &LocalChain) { + assert_checkpoint_order(chain); + assert_initial_changeset_roundtrip(chain); + + 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); + + // TODO: (@oleonardolima) I think we should definitely increase this to do more operations. + 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::blockhash_chain(&mut u) { + Ok(Some(initial)) => chain = Some(initial), + Ok(None) => continue, + Err(_) => break, + } + continue; + } + + let chain = chain.as_mut().expect("It SHOULD be initialized above!"); + let prev_chain = chain.clone(); + let genesis = chain.genesis_hash(); + + let op = match Op::arbitrary(&mut u) { + Ok(op) => op, + Err(_) => break, + }; + + match op { + 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()); + assert_changeset_against_chains(prev_chain, chain, &result); + } + Op::InsertBlock => { + let (height, hash) = match u32::arbitrary(&mut u) + .and_then(|height| arbitrary::block_hash(&mut u).map(|hash| (height, hash))) + { + Ok(block) => block, + Err(_) => break, + }; + + let result = chain.insert_block(height, hash); + assert_changeset_against_chains(prev_chain, 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" + ); + } + } + } + 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); + assert_changeset_against_chains(prev_chain, 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()); + } + } + } + Op::ApplyHeader => { + let (header, height) = match arbitrary::connectable_header(&mut u, chain) { + Ok(header) => header, + Err(_) => break, + }; + + let result = chain.apply_header(&header, height); + assert_changeset_against_chains(prev_chain, chain, &result); + + if result.is_ok() { + assert_eq!( + chain.get(height).map(|cp| cp.hash()), + Some(header.block_hash()) + ); + } + } + Op::ApplyHeaderConnectedTo => { + let params: arbitrary::Result<_> = (|| { + 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 { + Ok(params) => params, + Err(_) => break, + }; + + let result = chain.apply_header_connected_to(&header, height, connected_to); + assert_changeset_against_chains(prev_chain, chain, &result); + + if result.is_ok() { + assert_eq!( + chain.get(height).map(|cp| cp.hash()), + Some(header.block_hash()) + ); + } + } + Op::ApplyDerivedUpdate => { + let params: arbitrary::Result<_> = (|| { + let insert = bool::arbitrary(&mut u)?; + let height = u32::arbitrary(&mut u)?; + let hash = arbitrary::block_hash(&mut u)?; + Ok((insert, height, hash)) + })(); + + let (insert, height, hash) = match params { + Ok(params) => params, + Err(_) => break, + }; + + let (update_tip, height) = match insert { + true => { + // Height 0 would panic (genesis is immutable in `CheckPoint::insert`). + let height = height.max(1); + (chain.tip().insert(height, hash), height) + } + false => { + 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); + assert_changeset_against_chains(prev_chain, chain, &result); + + if result.is_ok() { + assert_eq!(chain.get(height).map(|cp| cp.hash()), Some(hash)); + } + } + Op::ApplyChangeSet => { + let changeset = match arbitrary::changeset(&mut u, chain, |u, _height| { + arbitrary::block_hash(u) + }) { + Ok(changeset) => changeset, + Err(_) => break, + }; + + let result = chain.apply_changeset(&changeset); + assert_changeset_applied(&prev_chain, chain, &changeset, &result); + } + } + + assert_eq!( + genesis, + chain.genesis_hash(), + "NO operation SHOULD replace the genesis block!" + ); + + assert_chain(chain); + } + + if let Some(chain) = chain { + assert_chain(&chain); + } +} + +bdk_fuzz::fuzz_main!(do_test); diff --git a/fuzz/fuzz_targets/chain/local_chain_apply_update_header.rs b/fuzz/fuzz_targets/chain/local_chain_apply_update_header.rs new file mode 100644 index 0000000000..f9ce40fd8d --- /dev/null +++ b/fuzz/fuzz_targets/chain/local_chain_apply_update_header.rs @@ -0,0 +1,253 @@ +//! 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 bdk_chain::bitcoin::block::{Header, Version}; +use bdk_chain::bitcoin::hashes::Hash; +use bdk_chain::bitcoin::{BlockHash, CompactTarget, TxMerkleNode}; +use bdk_chain::local_chain::LocalChain; +use bdk_chain::CheckPointEntry; +use bdk_fuzz::chain::arbitrary::{self, Arbitrary, Unstructured}; +use bdk_fuzz::chain::checks::{ + assert_changeset_against_chains, assert_changeset_applied, assert_checkpoint_order, + assert_initial_changeset_roundtrip, +}; + +const MAX_HEIGHT: u32 = 32; + +/// 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, + /// `apply_changeset` with a mix of insertions (usually virtual chain headers, so + /// `prev_blockhash` links can resolve) and removals. + ApplyChangeSet, +} + +/// Checks checkpoint and entry invariants, including placeholder consistency. +fn assert_chain(chain: &LocalChain
) { + assert_checkpoint_order(chain); + assert_initial_changeset_roundtrip(chain); + let occupied: BTreeMap = chain + .iter_checkpoints() + .map(|cp| (cp.height(), cp.hash())) + .collect(); + + 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 arbitrary::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::header_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 Op::arbitrary(&mut u) { + Ok(op) => op, + Err(_) => break, + }; + let pre = chain.clone(); + let genesis = chain.genesis_hash(); + match op { + 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()); + assert_changeset_against_chains(pre, chain, &result); + } + 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); + assert_changeset_against_chains(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" + ); + } + } + } + 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); + assert_changeset_against_chains(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()); + } + } + } + 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 = arbitrary::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); + assert_changeset_against_chains(pre, chain, &result); + if result.is_ok() { + assert_eq!( + chain.get(height).map(|cp| cp.hash()), + Some(header.block_hash()) + ); + } + } + Op::ApplyChangeSet => { + let changeset = match arbitrary::changeset(&mut u, chain, |u, height| { + if (height as usize) < headers.len() { + return arbitrary::header_at(u, &headers, height as usize); + } + let prev_blockhash = arbitrary::block_hash(u)?; + arbitrary::header(u, prev_blockhash) + }) { + Ok(changeset) => changeset, + Err(_) => break, + }; + let result = chain.apply_changeset(&changeset); + assert_changeset_applied(&pre, chain, &changeset, &result); + } + } + assert_eq!( + genesis, + chain.genesis_hash(), + "no operation may replace the genesis block" + ); + 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() + && arbitrary::reorg(&mut u, &mut headers, fork_height).is_err() + { + break; + } + } + + if let Some(chain) = chain { + assert_chain(&chain); + } +} + +bdk_fuzz::fuzz_main!(do_test); diff --git a/fuzz/src/chain/arbitrary.rs b/fuzz/src/chain/arbitrary.rs new file mode 100644 index 0000000000..be84e81e0c --- /dev/null +++ b/fuzz/src/chain/arbitrary.rs @@ -0,0 +1,210 @@ +//! Arbitrary-driven generators for headers, chains, and block ids. + +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}; + +/// Builds a [`BlockHash`] with arbitrary data. +pub fn block_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`] from arbitrary data, that connects to one of the existing [`BlockId`]'s in +/// the [`LocalChain`]. +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 { + (block_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 = block_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 data. +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) +} + +/// Builds a `ChangeSet` for `apply_changeset`. +/// +/// Heights are usually drawn from the chain's checkpoints (or just above one), so entries land +/// on the boundaries that matter: replacing a checkpoint, removing one, extending past the tip. +/// Each entry is either an insertion, with data from `data_at`, or a removal (`None`). +pub fn changeset<'a, D, F>( + u: &mut Unstructured<'a>, + chain: &LocalChain, + mut data_at: F, +) -> arbitrary::Result> +where + D: ToBlockHash + std::fmt::Debug + Clone, + F: FnMut(&mut Unstructured<'a>, u32) -> arbitrary::Result, +{ + let heights: Vec = chain.iter_checkpoints().map(|cp| cp.height()).collect(); + let entry_count = u.int_in_range(1..=4)?; + let mut blocks = BTreeMap::new(); + for _ in 0..entry_count { + let height = match u.int_in_range(0..=2)? { + 0 => *u.choose(&heights)?, + 1 => u.choose(&heights)?.saturating_add(1), + _ => u32::arbitrary(u)?, + }; + let data = if u.ratio(3, 4)? { + Some(data_at(u, height)?) + } else { + None + }; + blocks.insert(height, data); + } + Ok(ChangeSet { 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: block_hash(u)?, + }), + } +} diff --git a/fuzz/src/chain/checks.rs b/fuzz/src/chain/checks.rs new file mode 100644 index 0000000000..45acf837df --- /dev/null +++ b/fuzz/src/chain/checks.rs @@ -0,0 +1,114 @@ +//! Invariant checks the harnesses run after each operation. + +use bdk_chain::local_chain::{ApplyBlockError, ChangeSet, LocalChain}; +use bdk_chain::{BlockId, 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 an `apply_changeset` call against the [`LocalChain`] states surrounding it. +/// +/// On success, every insertion must be in `chain_after` at its height, every removal must be +/// gone, the checkpoints below the changeset must be untouched, and re-applying must be a no-op +/// (`apply_changeset` is idempotent: the second call recomputes the same extension). On failure, +/// the chain must be unmodified. +pub fn assert_changeset_applied( + chain_before: &LocalChain, + chain_after: &LocalChain, + changeset: &ChangeSet, + result: &Result<(), ApplyBlockError>, +) where + D: ToBlockHash + std::fmt::Debug + Clone, +{ + if result.is_err() { + assert_eq!( + chain_before, chain_after, + "a failed apply must not modify the chain" + ); + return; + } + + for (&height, data) in &changeset.blocks { + let cp_hash = chain_after.get(height).map(|cp| cp.hash()); + match data { + Some(data) => assert_eq!( + cp_hash, + Some(data.to_blockhash()), + "an inserted block must be in the chain at its height" + ), + None => assert_eq!(cp_hash, None, "a removed block must not be in the chain"), + } + } + + // The changeset's lowest height is the point of agreement: nothing below it can move. + if let Some(&start_height) = changeset.blocks.keys().next() { + let block_ids = |chain: &LocalChain| -> Vec { + chain + .range(..start_height) + .map(|cp| cp.block_id()) + .collect() + }; + assert_eq!( + block_ids(chain_before), + block_ids(chain_after), + "blocks below the changeset must be untouched" + ); + } + + let mut reapplied = chain_after.clone(); + reapplied + .apply_changeset(changeset) + .expect("re-applying an applied changeset must succeed"); + assert_eq!( + &reapplied, chain_after, + "applying a changeset twice must equal applying it once" + ); +} + +/// Checks that the [`LocalChain`] is recoverable from its own initial [`ChangeSet`]. +pub fn assert_initial_changeset_roundtrip(chain: &LocalChain) +where + D: ToBlockHash + std::fmt::Debug + Clone, +{ + let recovered = LocalChain::from_changeset(chain.initial_changeset()) + .expect("a chain's initial changeset must rebuild it"); + assert_eq!(&recovered, 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/chain/mod.rs b/fuzz/src/chain/mod.rs new file mode 100644 index 0000000000..1ae52d6225 --- /dev/null +++ b/fuzz/src/chain/mod.rs @@ -0,0 +1,4 @@ +//! Fuzzing helpers for the `bdk_chain` targets. + +pub mod arbitrary; +pub mod checks; 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..d749b4633e --- /dev/null +++ b/fuzz/src/lib.rs @@ -0,0 +1,7 @@ +//! Helpers shared by the fuzz targets. +//! +//! - [`chain`]: generators and invariant checks for the `bdk_chain` targets. +//! - [`fuzz_main`]: generates the fuzzing entry point for the enabled engine feature. + +pub mod chain; +pub mod engines;