diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 075761e..c89901f 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -7,16 +7,78 @@ on: pull_request: types: [ opened, synchronize, reopened ] branches: - - master + - '**' + + # Weekly run so new RustSec advisories surface without waiting for a push. + schedule: + - cron: '17 4 * * 1' + +# Third-party actions are pinned to full commit SHAs (tag comments alongside) +# so a moved tag cannot inject code into this workflow. + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} env: - CARGO_TERM_COLOR: auto + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 DATABASE_TEST_URL: postgres://postgres:password@localhost:5432/postgres jobs: + fmt: + name: Rustfmt + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable + with: + toolchain: stable + components: rustfmt + - name: Check formatting + run: cargo fmt --all -- --check + + clippy: + name: Clippy + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable + with: + toolchain: stable + components: clippy + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + - name: Clippy (all targets, deny warnings) + run: cargo clippy --all-targets -- -Dwarnings + + docs: + name: Docs + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable + with: + toolchain: stable + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + - name: Build docs (deny warnings, including private items) + run: cargo doc --no-deps --document-private-items + env: + RUSTDOCFLAGS: -D warnings + test: - name: Test suite + name: Test suite (${{ matrix.rust }}) runs-on: ubuntu-latest + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + rust: [ stable, beta ] services: postgres: image: postgres:14 @@ -32,31 +94,89 @@ jobs: ports: - 5432:5432 steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 - - name: Test - run: cargo test -- --show-output + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable + with: + toolchain: ${{ matrix.rust }} + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + - name: Test (all targets) + run: cargo test --all-targets -- --show-output + - name: Doc tests + run: cargo test --doc - fmt-clippy: - name: Check + msrv: + name: MSRV (1.88) runs-on: ubuntu-latest + timeout-minutes: 30 steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # 1.88 with: - components: rustfmt, clippy - - uses: Swatinem/rust-cache@v2 - - name: Check fmt - run: cargo fmt --all -- --check - - name: Clippy - run: cargo clippy --all-targets -- -Dwarnings + toolchain: '1.88' + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + - name: Check with the minimum supported Rust version + run: cargo check --all-targets - audit: + coverage: + name: Coverage gate + runs-on: ubuntu-latest + timeout-minutes: 45 + services: + postgres: + image: postgres:14 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password + POSTGRES_DB: postgres + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable + with: + toolchain: stable + components: llvm-tools-preview + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + - uses: taiki-e/install-action@7f4eb899022d8fe70b20c4f3de697aa85c309026 # v2 + with: + tool: cargo-llvm-cov + - name: Measure coverage (fails under 97% line coverage) + run: cargo llvm-cov --workspace --fail-under-lines 97 --lcov --output-path lcov.info + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: coverage-lcov + path: lcov.info + + deny: name: Audit dependencies runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + - uses: taiki-e/install-action@7f4eb899022d8fe70b20c4f3de697aa85c309026 # v2 + with: + tool: cargo-deny + - name: cargo deny (advisories, licenses, bans, sources) + run: cargo deny check + + cross-check: + name: Check (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + os: [ macos-latest, windows-latest ] steps: - - uses: actions/checkout@v4 - - uses: rustsec/audit-check@v2 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable with: - token: ${{ secrets.GITHUB_TOKEN }} + toolchain: stable + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + - name: Check (all targets) + run: cargo check --all-targets diff --git a/Cargo.toml b/Cargo.toml index 2243270..5da9def 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,8 @@ name = "bdk_sqlx" version = "0.0.1" edition = "2021" +rust-version = "1.88" +license = "MIT" [dependencies] bdk_wallet = { version = "1.2.0" } diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000..35ae751 --- /dev/null +++ b/deny.toml @@ -0,0 +1,42 @@ +# cargo-deny configuration — enforced by the `deny` CI job. +# https://embarkstudios.github.io/cargo-deny/ + +[graph] +all-features = true + +[advisories] +# Any RustSec advisory (vulnerability, unmaintained, unsound, notice) fails CI. +version = 2 +yanked = "deny" + +[licenses] +version = 2 +# Licenses acceptable for this project's dependency tree. +allow = [ + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "BSD-2-Clause", + "BSD-3-Clause", + "CC0-1.0", + # Mozilla CA bundle data shipped by webpki-roots (rustls dev-dependency). + "CDLA-Permissive-2.0", + "ISC", + "MIT", + # MIT +no-false-attribs, used by hex_lit; as permissive as MIT. + "MITNFA", + "MPL-2.0", + "Unicode-3.0", + "Zlib", +] +confidence-threshold = 0.93 + +[bans] +# Duplicate crate versions bloat builds and hide supply-chain drift; surface +# them without failing CI, since the sqlx/bitcoin trees legitimately lag. +multiple-versions = "warn" +wildcards = "deny" + +[sources] +unknown-registry = "deny" +unknown-git = "deny" +allow-registry = ["https://github.com/rust-lang/crates.io-index"] diff --git a/src/sqlite.rs b/src/sqlite.rs index 376f3e8..159f5f2 100644 --- a/src/sqlite.rs +++ b/src/sqlite.rs @@ -162,7 +162,7 @@ impl crate::SqliteStoreBuilder { /// each sqlite connection gets its *own* private in-memory database, so a /// multi-connection pool silently reads and writes different databases (and /// per-connection `PRAGMA`s only apply to the connection that ran them). - /// Use [`SqliteStoreBuilder::build_with_url`] with `None` instead, which + /// Use [`crate::SqliteStoreBuilder::build_with_url`] with `None` instead, which /// configures a single-connection pool correctly. pub fn pool(mut self, pool: Pool) -> Self { self.pool = Some(pool); diff --git a/src/test.rs b/src/test.rs index 3c2bd88..e0f8a9d 100644 --- a/src/test.rs +++ b/src/test.rs @@ -3321,3 +3321,221 @@ async fn migration_05_drops_dead_schema() -> anyhow::Result<()> { } Ok(()) } + +// --------------------------------------------------------------------------- +// Fault injection: every query failure must surface as QueryError carrying the +// query's table label, on both backends +// --------------------------------------------------------------------------- + +/// Drops one wallet table out from under the store, so the next statement that +/// touches it fails. `CASCADE` (postgres) removes dependent foreign-key +/// constraints, not other tables, so the remaining tables stay queryable. +async fn drop_table(store: &TestStore, table: &str) -> anyhow::Result<()> { + match store { + TestStore::Postgres(store) => { + sqlx::query(&format!(r#"DROP TABLE "bdk_wallet"."{table}" CASCADE"#)) + .execute(&store.pool) + .await?; + } + TestStore::Sqlite(store) => { + sqlx::query(&format!("DROP TABLE {table}")) + .execute(&store.pool) + .await?; + } + } + Ok(()) +} + +/// Installs a trigger that aborts every `op` (`INSERT`/`UPDATE`/`DELETE`) on +/// `table`, for failures that cannot be produced by dropping the table (a +/// statement that must fail only after an earlier statement on the same table +/// succeeded). Row-level triggers only fire for affected rows, so a DELETE +/// matching nothing still succeeds. +async fn install_fault_trigger(store: &TestStore, table: &str, op: &str) -> anyhow::Result<()> { + match store { + TestStore::Postgres(store) => { + sqlx::query( + r#"CREATE OR REPLACE FUNCTION "bdk_wallet".inject_fault() RETURNS trigger + LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'injected fault'; END $$"#, + ) + .execute(&store.pool) + .await?; + sqlx::query(&format!( + r#"CREATE TRIGGER fault_trigger BEFORE {op} ON "bdk_wallet"."{table}" + FOR EACH ROW EXECUTE FUNCTION "bdk_wallet".inject_fault()"# + )) + .execute(&store.pool) + .await?; + } + TestStore::Sqlite(store) => { + sqlx::query(&format!( + "CREATE TRIGGER fault_trigger BEFORE {op} ON {table} + BEGIN SELECT RAISE(ABORT, 'injected fault'); END" + )) + .execute(&store.pool) + .await?; + } + } + Ok(()) +} + +#[track_caller] +fn assert_query_error(err: BdkSqlxError, want_table: &str) { + match err { + BdkSqlxError::QueryError { table, .. } => assert_eq!( + table, want_table, + "failure must carry the failing query's table label" + ), + other => panic!("expected QueryError({want_table}), got {other:?}"), + } +} + +/// Every SELECT on the read path maps a failure to QueryError with that +/// query's label: with one table missing, the read fails at exactly that +/// query (earlier reads still succeed on their intact tables). +#[tokio::test] +async fn read_query_failures_map_to_query_error() -> anyhow::Result<()> { + initialize(); + + const CASES: [(&str, &str); 6] = [ + ("network", "read network"), + ("keychain", "read keychain"), + ("tx", "select tx"), + ("txout", "select txout"), + ("anchor_tx", "select anchor tx"), + ("block", "select block"), + ]; + for (table, label) in CASES { + let wallet_name = format!("fault_read_{table}"); + for store in create_test_stores(wallet_name).await? { + drop_table(&store, table).await?; + let err = store.read().await.expect_err("read must fail"); + assert_query_error(err, label); + } + } + Ok(()) +} + +/// Every INSERT/UPDATE/DELETE on the write path maps a failure to QueryError +/// with that statement's label. Each scenario persists a changeset touching +/// only the statement under test, against a store whose target table is gone. +#[tokio::test] +async fn write_query_failures_map_to_query_error() -> anyhow::Result<()> { + initialize(); + + let (external_desc, _) = get_test_tr_single_sig_xprv_and_change_desc(); + let descriptor = parse_descriptor(external_desc); + let did = descriptor.descriptor_id(); + + let descriptor_cs = ChangeSet { + descriptor: Some(descriptor), + ..Default::default() + }; + let network_cs = ChangeSet { + network: Some(Regtest), + ..Default::default() + }; + let mut last_revealed_cs = ChangeSet::default(); + last_revealed_cs.indexer.last_revealed.insert(did, 5); + let mut txs_cs = ChangeSet::default(); + txs_cs.tx_graph.txs.insert(Arc::new(sample_tx(0, 50_000))); + let mut last_seen_cs = ChangeSet::default(); + last_seen_cs + .tx_graph + .last_seen + .insert(sample_tx(0, 50_000).compute_txid(), 42); + let mut txout_cs = ChangeSet::default(); + txout_cs.tx_graph.txouts.insert( + OutPoint { + txid: sample_tx(0, 50_000).compute_txid(), + vout: 0, + }, + TxOut { + value: Amount::from_sat(50_000), + script_pubkey: ScriptBuf::new(), + }, + ); + let mut anchor_cs = ChangeSet::default(); + anchor_cs.tx_graph.anchors.insert(( + anchor_at(10, block_hash(2), 12_345), + sample_tx(0, 50_000).compute_txid(), + )); + let mut block_cs = ChangeSet::default(); + block_cs.local_chain.blocks.insert(5, Some(block_hash(1))); + + let cases: [(&str, ChangeSet, &str); 8] = [ + ("keychain", descriptor_cs, "insert keychain"), + ("network", network_cs, "insert network"), + ("keychain", last_revealed_cs, "update keychain"), + ("tx", txs_cs, "insert tx"), + ("tx", last_seen_cs, "update tx"), + ("txout", txout_cs, "insert txout"), + ("anchor_tx", anchor_cs, "insert anchor tx"), + // the Some-hash branch issues its stale-row DELETE first + ("block", block_cs, "delete stale block"), + ]; + for (table, changeset, label) in cases { + let wallet_name = format!("fault_write_{label}").replace(' ', "_"); + for mut store in create_test_stores(wallet_name).await? { + drop_table(&store, table).await?; + let err = TestStore::persist(&mut store, &changeset) + .await + .expect_err("write must fail"); + assert_query_error(err, label); + } + } + Ok(()) +} + +/// The block upsert's INSERT and the None-hash DELETE can only fail after an +/// earlier statement on the block table succeeded, so their failures are +/// injected with triggers instead of dropped tables. +#[tokio::test] +async fn block_write_failures_map_to_query_error() -> anyhow::Result<()> { + initialize(); + + // Some-hash branch: the stale-row DELETE matches nothing and succeeds, + // then the INSERT fires the trigger. + let mut insert_cs = ChangeSet::default(); + insert_cs.local_chain.blocks.insert(5, Some(block_hash(1))); + for mut store in create_test_stores("fault_block_insert".to_string()).await? { + install_fault_trigger(&store, "block", "INSERT").await?; + let err = TestStore::persist(&mut store, &insert_cs) + .await + .expect_err("block insert must fail"); + assert_query_error(err, "insert block"); + } + + // None-hash branch: a row must exist for the row-level DELETE trigger to + // fire, so persist the block before arming the trigger. + let mut delete_cs = ChangeSet::default(); + delete_cs.local_chain.blocks.insert(5, None); + for mut store in create_test_stores("fault_block_delete".to_string()).await? { + TestStore::persist(&mut store, &insert_cs).await?; + install_fault_trigger(&store, "block", "DELETE").await?; + let err = TestStore::persist(&mut store, &delete_cs) + .await + .expect_err("block delete must fail"); + assert_query_error(err, "delete block"); + } + Ok(()) +} + +/// Cloning a store shares the connection pool: data persisted through the +/// original is visible through the clone. +#[tokio::test] +async fn cloned_store_shares_the_pool() -> anyhow::Result<()> { + initialize(); + + let wallet_name = "cloned_store_shares_the_pool".to_string(); + for mut store in create_test_stores(wallet_name).await? { + let cs = populated_changeset(); + TestStore::persist(&mut store, &cs).await?; + let loaded = match &store { + TestStore::Postgres(store) => store.clone().read().await?, + TestStore::Sqlite(store) => store.clone().read().await?, + }; + assert_populated(&loaded, &cs); + } + Ok(()) +}