Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions .github/workflows/fuzz.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
name: Fuzz

on:
pull_request:
types: [ opened, synchronize, reopened ]
branches:
- '**'
push:
branches: [ "master" ]
schedule:
- cron: '41 5 * * *'
workflow_dispatch:

# Third-party actions are pinned to full commit SHAs (tag comments alongside).
permissions:
contents: read

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
# Longer runs on the nightly schedule; short smoke runs on PRs/pushes.
FUZZ_SECONDS: ${{ github.event_name == 'schedule' && '300' || '90' }}

jobs:
fuzz:
name: Fuzz (${{ matrix.target }})
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
target:
- changeset_roundtrip
- corrupt_rows
- anchor_identity
- descriptor_validation
steps:
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
- uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # nightly
with:
toolchain: nightly
components: rustfmt, clippy, rust-src
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
with:
workspaces: "fuzz -> fuzz/target"
- uses: taiki-e/install-action@7f4eb899022d8fe70b20c4f3de697aa85c309026 # v2
with:
tool: cargo-fuzz
- name: Check fuzz crate formatting
run: cargo +nightly fmt --manifest-path fuzz/Cargo.toml -- --check
- name: Lint fuzz crate
run: cargo +nightly clippy --manifest-path fuzz/Cargo.toml --all-targets -- -D warnings
- name: Build fuzz target
working-directory: fuzz
run: cargo +nightly fuzz build ${{ matrix.target }} --target x86_64-unknown-linux-gnu
- name: Run fuzz target
working-directory: fuzz
run: |
cargo +nightly fuzz run ${{ matrix.target }} --target x86_64-unknown-linux-gnu -- \
-max_total_time=${FUZZ_SECONDS} \
-max_len=4096 \
-rss_limit_mb=4096 \
-use_value_profile=1 \
-print_final_stats=1
- name: Upload failing corpus
if: failure()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: fuzz-artifacts-${{ matrix.target }}
path: fuzz/artifacts
if-no-files-found: ignore
5 changes: 5 additions & 0 deletions fuzz/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
target
artifacts
coverage
corpus/*/*
!corpus/*/seed
46 changes: 46 additions & 0 deletions fuzz/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
[package]
name = "bdk-sqlx-fuzz"
version = "0.0.0"
publish = false
edition = "2021"

[package.metadata]
cargo-fuzz = true

[dependencies]
bdk_sqlx = { path = ".." }
bdk_wallet = { version = "1.2.0" }
libfuzzer-sys = "0.4"
serde_json = "1"
tokio = { version = "1", features = ["rt"] }

[[bin]]
name = "changeset_roundtrip"
path = "fuzz_targets/changeset_roundtrip.rs"
test = false
doc = false

[[bin]]
name = "corrupt_rows"
path = "fuzz_targets/corrupt_rows.rs"
test = false
doc = false

[[bin]]
name = "anchor_identity"
path = "fuzz_targets/anchor_identity.rs"
test = false
doc = false

[[bin]]
name = "descriptor_validation"
path = "fuzz_targets/descriptor_validation.rs"
test = false
doc = false

[profile.release]
debug = 1
overflow-checks = true

[workspace]
members = ["."]
17 changes: 17 additions & 0 deletions fuzz/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Fuzz targets

These targets exercise the SQLite backend with arbitrary change sets and
corrupt database rows. They complement the deterministic regression tests in
`src/test.rs`.

```sh
cd fuzz
cargo +nightly fuzz run changeset_roundtrip
cargo +nightly fuzz run corrupt_rows
cargo +nightly fuzz run anchor_identity
cargo +nightly fuzz run descriptor_validation
```

The `fuzz` crate is intentionally outside the main package. CI runs a short
smoke pass of every target on pull requests and a longer pass on the weekly
schedule (see `.github/workflows/fuzz.yml`).
Binary file added fuzz/corpus/anchor_identity/seed
Binary file not shown.
Binary file added fuzz/corpus/changeset_roundtrip/seed
Binary file not shown.
Binary file added fuzz/corpus/corrupt_rows/seed
Binary file not shown.
1 change: 1 addition & 0 deletions fuzz/corpus/descriptor_validation/seed
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
seedwpkh(tpubD6NzVbkrYhZ4XHndKkuB8FifXm8r5FQHwrN6oZuWCz13qb93rtgKvD4PQsqC4HP4yhV3tA2fqr2RbY5mNXfM7RxXUoeABoDtsFUq2zJq6YK/<0;1>/*)
58 changes: 58 additions & 0 deletions fuzz/fuzz_targets/anchor_identity.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
#![no_main]

use bdk_sqlx_fuzz::{load, new_store, persist, Reader};
use bdk_wallet::bitcoin::hashes::Hash;
use bdk_wallet::bitcoin::{BlockHash, Txid};
use bdk_wallet::chain::{BlockId, ConfirmationBlockTime};
use bdk_wallet::ChangeSet;
use libfuzzer_sys::fuzz_target;

fuzz_target!(|data: &[u8]| {
let mut reader = Reader::new(data);
let wallet_name = reader.wallet_name();
let hash = BlockHash::from_byte_array(reader.array32());
let txid = Txid::from_byte_array(reader.array32());
let height = reader.u32() % 1_000_000;
let height_other = (height + 1) % 1_000_000;
let early = reader.u64() >> 1;
let late = early ^ 1;

let anchor = |height, confirmation_time| ConfirmationBlockTime {
block_id: BlockId { height, hash },
confirmation_time,
};
let same_block_early = anchor(height, early);
let same_block_late = anchor(height, late);
let other_height = anchor(height_other, early);

let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("tokio runtime");
runtime.block_on(async move {
let mut store = new_store(wallet_name).await;

let mut first = ChangeSet::default();
first.tx_graph.anchors.insert((same_block_late, txid));
persist(&mut store, &first)
.await
.expect("anchor without block or tx must persist");

// A stale observation of the same block, plus the same hash at another
// height, must each get their own row rather than replace the newer one.
let mut stale = ChangeSet::default();
stale.tx_graph.anchors.insert((same_block_early, txid));
stale.tx_graph.anchors.insert((other_height, txid));
persist(&mut store, &stale)
.await
.expect("distinct anchors must persist independently");

let loaded = load(&mut store).await.expect("load anchors");
assert_eq!(loaded.tx_graph.anchors.len(), 3);
assert!(loaded.tx_graph.anchors.contains(&(same_block_late, txid)));
assert!(loaded.tx_graph.anchors.contains(&(same_block_early, txid)));
assert!(loaded.tx_graph.anchors.contains(&(other_height, txid)));
assert!(loaded.tx_graph.txs.is_empty());
assert!(loaded.local_chain.blocks.is_empty());
});
});
46 changes: 46 additions & 0 deletions fuzz/fuzz_targets/changeset_roundtrip.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
#![no_main]

use bdk_sqlx::BdkSqlxError;
use bdk_sqlx_fuzz::{load, new_store, persist, random_changeset, Reader};
use bdk_wallet::chain::Merge;
use libfuzzer_sys::fuzz_target;

fuzz_target!(|data: &[u8]| {
let mut reader = Reader::new(data);
let wallet_name = reader.wallet_name();
let changeset = random_changeset(&mut reader);

let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("tokio runtime");
runtime.block_on(async move {
let mut store = new_store(wallet_name).await;

match persist(&mut store, &changeset).await {
Ok(()) => {
let first = load(&mut store)
.await
.expect("loading after a successful persist must succeed");
persist(&mut store, &first)
.await
.expect("re-persisting a loaded snapshot must succeed");
let second = load(&mut store)
.await
.expect("loading after re-persisting must succeed");
assert_eq!(first, second, "persist/load must be idempotent");
}
// These are the only structurally invalid changesets the generator
// can produce: one hash at multiple heights, or a value outside the
// schema's representable range.
Err(BdkSqlxError::IntOutOfRange { .. })
| Err(BdkSqlxError::DuplicateBlockHash { .. }) => {
let loaded = load(&mut store)
.await
.expect("loading after a rejected changeset must succeed");
assert!(loaded.is_empty(), "a rejected changeset must roll back");
}
Err(error) => panic!("unexpected persist error: {error:?}"),
}
});
});
Loading
Loading