diff --git a/README.md b/README.md index e7230922c..f2f480dbb 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,7 @@ cargo fmt -- --check WalletKit is broken down into separate crates, offering the following functionality. - `walletkit-core` - Enables basic usage of a World ID to generate ZKPs using different credentials. +- `walletkit-db` - Generic encrypted `SQLite` (`sqlite3mc`) wrapper providing safe connection, transaction, and statement types plus encrypted-open and plaintext export/import helpers. Used by `walletkit-core` for credential storage and consumable by sibling SDKs that need an encrypted on-device store. ### World ID Secret diff --git a/walletkit-core/src/storage/cache/maintenance.rs b/walletkit-core/src/storage/cache/maintenance.rs index 8d7c904ca..2dd24206e 100644 --- a/walletkit-core/src/storage/cache/maintenance.rs +++ b/walletkit-core/src/storage/cache/maintenance.rs @@ -6,8 +6,7 @@ use std::path::Path; use secrecy::SecretBox; use crate::storage::error::StorageResult; -use walletkit_db::cipher; -use walletkit_db::Connection; +use walletkit_db::{cipher, Connection}; use super::schema; use super::util::{map_db_err, map_io_err}; diff --git a/walletkit-core/src/storage/credential_storage.rs b/walletkit-core/src/storage/credential_storage.rs index cbb2e37d0..04ec1c206 100644 --- a/walletkit-core/src/storage/credential_storage.rs +++ b/walletkit-core/src/storage/credential_storage.rs @@ -1281,7 +1281,7 @@ mod tests { #[test] fn test_import_vault_backup_transaction_atomicity() { - use walletkit_db::cipher::BACKUP_TABLES; + use crate::storage::vault::BACKUP_TABLES; use walletkit_db::Connection; use world_id_core::Credential as CoreCredential; diff --git a/walletkit-core/src/storage/keys.rs b/walletkit-core/src/storage/keys.rs index e1b20af37..d43cce8c5 100644 --- a/walletkit-core/src/storage/keys.rs +++ b/walletkit-core/src/storage/keys.rs @@ -1,4 +1,17 @@ //! Key hierarchy management for credential storage. +//! +//! ## Key structure +//! +//! - `K_device`: device-bound root key managed by `DeviceKeystore`. +//! - `account_keys.bin`: account key envelope stored via `AtomicBlobStore` and +//! containing `DeviceKeystore::seal` of `K_intermediate` with associated data +//! `worldid:account-key-envelope`. +//! - `K_intermediate`: 32-byte per-account key unsealed at init and kept in +//! memory for the lifetime of the storage handle. +//! - `SQLCipher` databases: `account.vault.sqlite` (authoritative) and +//! `account.cache.sqlite` (non-authoritative) are opened with `K_intermediate`. +//! - Derived keys: per relying-party session keys may be derived from +//! `K_intermediate` and cached in `account.cache.sqlite` for performance. use rand::{rngs::OsRng, RngCore}; use secrecy::SecretBox; @@ -48,9 +61,9 @@ impl StorageKeys { }) } else { let k_intermediate = random_key(); - // TODO: At this moment, the key needs to be temporarily heap allocated in order - // to be bridged via UniFFI. This needs to be improved to use pointers that can - // be zeroized after use. + // TODO: At this moment, the key needs to be temporarily heap + // allocated in order to be bridged via UniFFI. This needs to be + // improved to use pointers that can be zeroized after use. let wrapped_k_intermediate = keystore .seal(ACCOUNT_KEY_ENVELOPE_AD.to_vec(), k_intermediate.to_vec())?; let envelope = AccountKeyEnvelope::new(wrapped_k_intermediate, now); @@ -90,6 +103,7 @@ fn parse_key_32(bytes: &[u8], label: &str) -> StorageResult<[u8; 32]> { #[cfg(test)] mod tests { use super::*; + use crate::storage::error::StorageError; use crate::storage::lock::StorageLock; use crate::storage::tests_utils::{InMemoryBlobStore, InMemoryKeystore}; use secrecy::ExposeSecret; diff --git a/walletkit-core/src/storage/lock.rs b/walletkit-core/src/storage/lock.rs index b69f25761..ae36c3af1 100644 --- a/walletkit-core/src/storage/lock.rs +++ b/walletkit-core/src/storage/lock.rs @@ -11,8 +11,6 @@ use std::path::Path; use super::error::StorageResult; -// WASM: no-op lock (single-threaded worker, SQLITE_THREADSAFE=0) - #[cfg(target_arch = "wasm32")] mod imp { use super::*; @@ -43,8 +41,6 @@ mod imp { } } -// Native: file-backed exclusive lock (flock on Unix, LockFileEx on Windows) - #[cfg(not(target_arch = "wasm32"))] mod imp { use super::{Path, StorageResult}; @@ -125,8 +121,6 @@ mod imp { StorageError::Lock(err.to_string()) } - // ── Unix flock ────────────────────────────────────────────────────── - #[cfg(unix)] fn lock_exclusive(file: &File) -> std::io::Result<()> { let fd = std::os::unix::io::AsRawFd::as_raw_fd(file); @@ -180,8 +174,6 @@ mod imp { fn flock(fd: c_int, operation: c_int) -> c_int; } - // ── Windows LockFileEx ────────────────────────────────────────────── - #[cfg(windows)] fn lock_exclusive(file: &File) -> std::io::Result<()> { lock_file(file, 0) diff --git a/walletkit-core/src/storage/traits.rs b/walletkit-core/src/storage/traits.rs index 2d0304af6..8e9be0eba 100644 --- a/walletkit-core/src/storage/traits.rs +++ b/walletkit-core/src/storage/traits.rs @@ -1,18 +1,4 @@ //! Platform interfaces for credential storage. -//! -//! ## Key structure -//! -//! - `K_device`: device-bound root key managed by `DeviceKeystore`. -//! - `account_keys.bin`: account key envelope stored via `AtomicBlobStore` and -//! containing `DeviceKeystore::seal` of `K_intermediate` with associated data -//! `worldid:account-key-envelope`. -//! - `K_intermediate`: 32-byte per-account key unsealed at init and kept in -//! memory for the lifetime of the storage handle. -//! - `SQLCipher` databases: `account.vault.sqlite` (authoritative) and -//! `account.cache.sqlite` (non-authoritative) are opened with `K_intermediate`. -//! - Derived keys: per relying-party session keys may be derived from -//! `K_intermediate` and cached in `account.cache.sqlite` for performance. -//! cached in `account.cache.sqlite` for performance. use std::sync::Arc; diff --git a/walletkit-core/src/storage/vault/helpers.rs b/walletkit-core/src/storage/vault/helpers.rs index d93feb8e5..75242beb5 100644 --- a/walletkit-core/src/storage/vault/helpers.rs +++ b/walletkit-core/src/storage/vault/helpers.rs @@ -8,10 +8,10 @@ use walletkit_db::{DbError, Row}; const CONTENT_ID_PREFIX: &[u8] = b"worldid:blob"; -pub(super) fn compute_content_id(blob_kind: BlobKind, plaintext: &[u8]) -> ContentId { +pub(super) fn compute_content_id(kind: BlobKind, plaintext: &[u8]) -> ContentId { let mut hasher = Sha256::new(); hasher.update(CONTENT_ID_PREFIX); - hasher.update([blob_kind as u8]); + hasher.update([kind as u8]); hasher.update(plaintext); let digest = hasher.finalize(); let mut out = [0u8; 32]; diff --git a/walletkit-core/src/storage/vault/mod.rs b/walletkit-core/src/storage/vault/mod.rs index c22c572b5..8bb6d1994 100644 --- a/walletkit-core/src/storage/vault/mod.rs +++ b/walletkit-core/src/storage/vault/mod.rs @@ -13,8 +13,9 @@ use crate::storage::types::{BlobKind, CredentialRecord}; use helpers::{compute_content_id, map_db_err, map_record, to_i64, to_u64}; use schema::{ensure_schema, VAULT_SCHEMA_VERSION}; use secrecy::SecretBox; -use walletkit_db::cipher; -use walletkit_db::{params, Connection, StepResult, Value}; +use walletkit_db::{cipher, params, Connection, StepResult, Value}; + +pub(crate) use schema::BACKUP_TABLES; /// Encrypted vault database wrapper. #[derive(Debug)] @@ -35,7 +36,7 @@ impl VaultDb { ) -> StorageResult { let conn = cipher::open_encrypted(path, k_intermediate, false) .map_err(|e| map_db_err(&e))?; - ensure_schema(&conn)?; + ensure_schema(&conn).map_err(|err| map_db_err(&err))?; let db = Self { conn }; if !db.check_integrity()? { return Err(StorageError::CorruptedVault( @@ -371,13 +372,13 @@ impl VaultDb { dest: &Path, _lock: &StorageLockGuard, ) -> StorageResult<()> { - // Remove any stale export from a previous failed run. if dest.exists() { std::fs::remove_file(dest).map_err(|e| { StorageError::VaultDb(format!("failed to remove stale backup: {e}")) })?; } - cipher::export_plaintext_copy(&self.conn, dest).map_err(|e| map_db_err(&e)) + cipher::export_plaintext_copy(&self.conn, dest, BACKUP_TABLES) + .map_err(|e| map_db_err(&e)) } /// Imports credentials from a plaintext (unencrypted) vault backup into @@ -394,6 +395,13 @@ impl VaultDb { source: &Path, _lock: &StorageLockGuard, ) -> StorageResult<()> { - cipher::import_plaintext_copy(&self.conn, source).map_err(|e| map_db_err(&e)) + cipher::import_plaintext_copy(&self.conn, source, BACKUP_TABLES) + .map_err(|e| map_db_err(&e)) + } + + /// Borrows the underlying connection for direct SQL access. **Test-only.** + #[cfg(test)] + pub(super) const fn raw_connection(&self) -> &Connection { + &self.conn } } diff --git a/walletkit-core/src/storage/vault/schema.rs b/walletkit-core/src/storage/vault/schema.rs index 2602fb6f5..7389cc361 100644 --- a/walletkit-core/src/storage/vault/schema.rs +++ b/walletkit-core/src/storage/vault/schema.rs @@ -1,18 +1,26 @@ //! Vault database schema management. +//! +//! Owns the credential vault tables and backup-sensitive schema. -use crate::storage::error::StorageResult; -use walletkit_db::Connection; - -use super::helpers::map_db_err; +use walletkit_db::{Connection, DbResult}; pub(super) const VAULT_SCHEMA_VERSION: i64 = 1; +/// Tables included in plaintext vault backups. +/// +/// `vault_meta` is intentionally excluded: on restore, the destination vault +/// already has its own `vault_meta` (created by `ensure_schema` + +/// `init_leaf_index`) with the authoritative `leaf_index` from the +/// authenticator. +/// +/// **Note:** New tables added to the vault schema must be added here too. +pub const BACKUP_TABLES: &[&str] = &["credential_records", "blob_objects"]; + /// **Backup sensitivity:** Schema changes here affect vault backups made into the backup system. -/// - New tables must be added to `BACKUP_TABLES` in `walletkit-db/src/cipher.rs`. -/// - Column changes (especially new `NOT NULL` columns without defaults) will -/// break restoring older backups into a newer schema. See the schema migration -/// note on `import_plaintext_copy` in `walletkit-db/src/cipher.rs`. -pub(super) fn ensure_schema(conn: &Connection) -> StorageResult<()> { +/// - New tables must be added to [`BACKUP_TABLES`]. +/// - Column changes (especially new `NOT NULL` columns without defaults) can +/// break restoring older backups into a newer schema. +pub(super) fn ensure_schema(conn: &Connection) -> DbResult<()> { conn.execute_batch( "CREATE TABLE IF NOT EXISTS vault_meta ( schema_version INTEGER NOT NULL, @@ -57,9 +65,6 @@ pub(super) fn ensure_schema(conn: &Connection) -> StorageResult<()> { bytes BLOB NOT NULL, PRIMARY KEY (content_id) ); - ", ) - .map_err(|err| map_db_err(&err))?; - Ok(()) } diff --git a/walletkit-core/src/storage/vault/tests.rs b/walletkit-core/src/storage/vault/tests.rs index 77de8a010..a96572b80 100644 --- a/walletkit-core/src/storage/vault/tests.rs +++ b/walletkit-core/src/storage/vault/tests.rs @@ -200,7 +200,7 @@ fn test_content_id_deduplication() { ) .expect("store credential"); let count = db - .conn + .raw_connection() .query_row("SELECT COUNT(*) FROM blob_objects", &[], |stmt| { Ok(stmt.column_i64(0)) }) @@ -212,7 +212,7 @@ fn test_content_id_deduplication() { .expect("delete first credential"); let count_after_first_delete = db - .conn + .raw_connection() .query_row("SELECT COUNT(*) FROM blob_objects", &[], |stmt| { Ok(stmt.column_i64(0)) }) @@ -224,7 +224,7 @@ fn test_content_id_deduplication() { .expect("delete second credential"); let count_after_second_delete = db - .conn + .raw_connection() .query_row("SELECT COUNT(*) FROM blob_objects", &[], |stmt| { Ok(stmt.column_i64(0)) }) @@ -367,7 +367,7 @@ fn test_delete_credential_by_id() { .expect("store credential"); let blob_count_before = db - .conn + .raw_connection() .query_row("SELECT COUNT(*) FROM blob_objects", &[], |stmt| { Ok(stmt.column_i64(0)) }) @@ -382,7 +382,7 @@ fn test_delete_credential_by_id() { assert!(records.is_empty()); let blob_count_after = db - .conn + .raw_connection() .query_row("SELECT COUNT(*) FROM blob_objects", &[], |stmt| { Ok(stmt.column_i64(0)) }) @@ -429,7 +429,7 @@ fn test_delete_credential_cleans_up_orphaned_associated_data() { .expect("store credential"); let associated_before = db - .conn + .raw_connection() .query_row( "SELECT COUNT(*) FROM blob_objects WHERE blob_kind = ?1", params![BlobKind::AssociatedData.as_i64()], @@ -443,7 +443,7 @@ fn test_delete_credential_cleans_up_orphaned_associated_data() { .expect("delete credential"); let associated_after = db - .conn + .raw_connection() .query_row( "SELECT COUNT(*) FROM blob_objects WHERE blob_kind = ?1", params![BlobKind::AssociatedData.as_i64()], @@ -497,7 +497,7 @@ fn test_danger_delete_all_credentials() { assert!(records.is_empty()); let blob_count = db - .conn + .raw_connection() .query_row("SELECT COUNT(*) FROM blob_objects", &[], |stmt| { Ok(stmt.column_i64(0)) }) diff --git a/walletkit-db/Cargo.toml b/walletkit-db/Cargo.toml index cdc4971fd..71368eddf 100644 --- a/walletkit-db/Cargo.toml +++ b/walletkit-db/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "walletkit-db" -description = "Internal SQLite wrapper crate for WalletKit storage." +description = "Generic encrypted SQLite wrapper backed by sqlite3mc." publish = true version.workspace = true edition.workspace = true diff --git a/walletkit-db/src/lib.rs b/walletkit-db/src/lib.rs index b34162730..19c1f25cb 100644 --- a/walletkit-db/src/lib.rs +++ b/walletkit-db/src/lib.rs @@ -1,32 +1,20 @@ -//! Minimal safe `SQLite` wrapper backed by `sqlite3mc`. +//! Generic encrypted `SQLite` (`sqlite3mc`) wrapper. //! -//! This crate provides a small, safe Rust API over the `SQLite` C FFI. -//! The raw symbols are resolved at compile time: +//! The public API: //! -//! * **Native** (`not(wasm32)`): linked against the `sqlite3mc` static library -//! compiled from the downloaded amalgamation by `build.rs`. -//! * **WASM** (`wasm32`): delegated to `sqlite-wasm-rs` (with the `sqlite3mc` -//! feature) which ships its own WASM-compiled `sqlite3mc`. +//! - safe Rust connection / transaction / statement types +//! - encrypted open helpers and integrity checks +//! - plaintext export / import helpers parameterized by caller-owned tables //! -//! Consumer code (vault, cache, cipher config) uses only the safe types -//! defined here and never touches raw FFI directly. The `ffi` module is the -//! **only** file that contains `unsafe` code or C types. +//! Raw FFI lives behind the [`sqlite`] module; consumer crates own their own +//! schema, queries, and higher-level storage policy. -mod ffi; +pub mod sqlite; -mod connection; -pub mod error; -mod statement; -mod transaction; -pub mod value; - -pub mod cipher; - -pub use connection::Connection; -pub use error::DbError; -pub use statement::{Row, Statement, StepResult}; -pub use transaction::Transaction; -pub use value::Value; +pub use sqlite::{ + cipher, Connection, Error as DbError, Result as DbResult, Row, Statement, + StepResult, Transaction, Value, +}; #[cfg(test)] mod tests; diff --git a/walletkit-db/src/cipher.rs b/walletkit-db/src/sqlite/cipher.rs similarity index 75% rename from walletkit-db/src/cipher.rs rename to walletkit-db/src/sqlite/cipher.rs index 2051f04ba..26333d550 100644 --- a/walletkit-db/src/cipher.rs +++ b/walletkit-db/src/sqlite/cipher.rs @@ -2,10 +2,10 @@ //! //! # Encryption flow //! -//! The credential storage uses `sqlite3mc` (`SQLite3` Multiple Ciphers) to -//! encrypt both the vault and cache databases at rest. The encryption is -//! transparent to SQL -- once a database is opened and keyed, all reads and -//! writes are automatically encrypted/decrypted by the `SQLite` pager layer. +//! This crate uses `sqlite3mc` (`SQLite3` Multiple Ciphers) to encrypt +//! `SQLite` databases at rest. The encryption is transparent to SQL -- once a +//! database is opened and keyed, all reads and writes are automatically +//! encrypted/decrypted by the `SQLite` pager layer. //! //! The flow when opening a database is: //! @@ -37,23 +37,23 @@ use secrecy::{ExposeSecret, SecretBox}; use zeroize::Zeroizing; use super::connection::Connection; -use super::error::{DbError, DbResult}; +use super::error::{Error, Result}; /// Opens a database, applies the encryption key, and configures the connection. /// -/// This is the standard open sequence used by both vault and cache databases: -/// open -> key -> verify -> configure (WAL + foreign keys). +/// This is the standard open sequence for encrypted databases: open -> key -> +/// verify -> configure (WAL + foreign keys). /// /// See the [module-level documentation](self) for the full encryption flow. /// /// # Errors /// -/// Returns `DbError` if opening, keying, or configuring the connection fails. +/// Returns `Error` if opening, keying, or configuring the connection fails. pub fn open_encrypted( path: &Path, k_intermediate: &SecretBox<[u8; 32]>, read_only: bool, -) -> DbResult { +) -> Result { let conn = Connection::open(path, read_only)?; apply_key(&conn, k_intermediate)?; configure_connection(&conn)?; @@ -70,7 +70,7 @@ pub fn open_encrypted( /// After keying, a lightweight read (`SELECT count(*) FROM sqlite_master`) /// verifies the key is correct. If it's wrong, `sqlite3mc` fails with /// `SQLITE_NOTADB` on the first page read. -fn apply_key(conn: &Connection, k_intermediate: &SecretBox<[u8; 32]>) -> DbResult<()> { +fn apply_key(conn: &Connection, k_intermediate: &SecretBox<[u8; 32]>) -> Result<()> { // Hex-encode the key and build the PRAGMA. Both are zeroized on drop. let key_hex = Zeroizing::new(hex::encode(k_intermediate.expose_secret())); let pragma = Zeroizing::new(format!("PRAGMA key = \"x'{}'\";", key_hex.as_str())); @@ -83,7 +83,7 @@ fn apply_key(conn: &Connection, k_intermediate: &SecretBox<[u8; 32]>) -> DbResul // error rather than a confusing "not a database" later during schema setup. conn.execute_batch("SELECT count(*) FROM sqlite_master;") .map_err(|e| { - DbError::new( + Error::new( e.code.0, format!( "encryption key verification failed (is the key correct?): {}", @@ -105,7 +105,7 @@ fn apply_key(conn: &Connection, k_intermediate: &SecretBox<[u8; 32]>) -> DbResul /// - `foreign_keys = ON` -- enforces referential integrity constraints. /// - `secure_delete = ON` -- overwrites deleted content with zeroes so /// sensitive data does not linger in free pages. -fn configure_connection(conn: &Connection) -> DbResult<()> { +fn configure_connection(conn: &Connection) -> Result<()> { conn.execute_batch( "PRAGMA foreign_keys = ON; PRAGMA journal_mode = WAL; @@ -114,21 +114,11 @@ fn configure_connection(conn: &Connection) -> DbResult<()> { ) } -/// Tables included in plaintext vault backups. -/// -/// `vault_meta` is intentionally excluded: on restore, the destination vault -/// already has its own `vault_meta` (created by `ensure_schema` + `init_leaf_index`) -/// with the authoritative `leaf_index` from the authenticator. -/// -/// **Note:** If new tables are added to the vault schema, this list must be -/// updated to include them. -pub const BACKUP_TABLES: &[&str] = &["credential_records", "blob_objects"]; - /// Creates a plaintext (unencrypted) copy of an already-open encrypted database. /// /// The copy is produced by `ATTACH`-ing a new unencrypted database and copying -/// all rows via `CREATE TABLE ... AS SELECT *`. The destination file must not -/// already exist. +/// the caller-specified tables via `CREATE TABLE ... AS SELECT *`. The +/// destination file must not already exist. /// /// We use `ATTACH` + SQL instead of the `sqlite3_backup` API because /// `sqlite3mc` requires both source and destination to share the same @@ -137,8 +127,12 @@ pub const BACKUP_TABLES: &[&str] = &["credential_records", "blob_objects"]; /// /// # Errors /// -/// Returns `DbError` if the `ATTACH`, copy, or `DETACH` fails. -pub fn export_plaintext_copy(conn: &Connection, dest_path: &Path) -> DbResult<()> { +/// Returns `Error` if the `ATTACH`, copy, or `DETACH` fails. +pub fn export_plaintext_copy( + conn: &Connection, + dest_path: &Path, + tables: &[&str], +) -> Result<()> { let dest_str = dest_path.to_string_lossy(); let attach_sql = format!( "ATTACH DATABASE '{}' AS backup KEY '';", @@ -148,7 +142,7 @@ pub fn export_plaintext_copy(conn: &Connection, dest_path: &Path) -> DbResult<() let result = (|| { let tx = conn.transaction()?; - for table in BACKUP_TABLES { + for table in tables { tx.execute_batch(&format!( "CREATE TABLE backup.{table} AS SELECT * FROM {table};" ))?; @@ -168,24 +162,27 @@ pub fn export_plaintext_copy(conn: &Connection, dest_path: &Path) -> DbResult<() /// encrypted database. /// /// The source database is `ATTACH`ed with an empty key and its contents are -/// copied into the main (empty) encrypted database. This is intended for -/// restore on a fresh install where the vault tables exist but contain no data. +/// copied into the main (empty) encrypted database. /// /// See [`export_plaintext_copy`] for why `ATTACH` + SQL is used instead of /// the `sqlite3_backup` API. /// /// **Schema migration:** The import uses `SELECT *`, so column changes are -/// handled automatically as long as both sides share the same schema. If the -/// vault schema evolves (e.g. new columns with `NOT NULL` constraints), +/// handled automatically as long as both sides share the same schema. If a +/// caller's schema evolves (e.g. new columns with `NOT NULL` constraints), /// restoring an older backup into a newer schema will fail. When that happens, -/// this function will need version-aware import logic. +/// the caller needs version-aware import logic. /// /// # Errors /// -/// Returns `DbError` if the `ATTACH`, copy, or `DETACH` fails. -pub fn import_plaintext_copy(conn: &Connection, source_path: &Path) -> DbResult<()> { +/// Returns `Error` if the `ATTACH`, copy, or `DETACH` fails. +pub fn import_plaintext_copy( + conn: &Connection, + source_path: &Path, + tables: &[&str], +) -> Result<()> { if !source_path.exists() { - return Err(DbError::new( + return Err(Error::new( -1, format!("backup file does not exist: {}", source_path.display()), )); @@ -199,25 +196,27 @@ pub fn import_plaintext_copy(conn: &Connection, source_path: &Path) -> DbResult< conn.execute_batch(&attach_sql)?; // Verify the destination tables are empty before importing. Importing into - // a non-empty vault could silently merge data if primary keys don't collide. + // a non-empty destination could silently merge data if primary keys don't + // collide. let result = (|| { - for table in BACKUP_TABLES { + for table in tables { let count: i64 = conn.query_row(&format!("SELECT COUNT(*) FROM {table}"), &[], |row| { Ok(row.column_i64(0)) })?; if count > 0 { - return Err(DbError::new( + return Err(Error::new( -1, format!("cannot import into non-empty table: {table}"), )); } } - // Wrap in a transaction so the restore is atomic — if any INSERT fails, - // everything is rolled back and the vault stays empty for a retry. + // Wrap in a transaction so the restore is atomic — if any INSERT + // fails, everything is rolled back and the destination stays empty for + // a retry. let tx = conn.transaction()?; - for table in BACKUP_TABLES { + for table in tables { tx.execute_batch(&format!( "INSERT INTO {table} SELECT * FROM backup.{table};" ))?; @@ -237,8 +236,8 @@ pub fn import_plaintext_copy(conn: &Connection, source_path: &Path) -> DbResult< /// /// # Errors /// -/// Returns `DbError` if the integrity check query fails. -pub fn integrity_check(conn: &Connection) -> DbResult { +/// Returns `Error` if the integrity check query fails. +pub fn integrity_check(conn: &Connection) -> Result { let result = conn.query_row("PRAGMA integrity_check;", &[], |stmt| { Ok(stmt.column_text(0)) })?; diff --git a/walletkit-db/src/connection.rs b/walletkit-db/src/sqlite/connection.rs similarity index 74% rename from walletkit-db/src/connection.rs rename to walletkit-db/src/sqlite/connection.rs index 0b87ba48a..c7dff985f 100644 --- a/walletkit-db/src/connection.rs +++ b/walletkit-db/src/sqlite/connection.rs @@ -5,7 +5,7 @@ use std::path::Path; -use super::error::{DbError, DbResult}; +use super::error::{Error, Result}; use super::ffi::{self, RawDb}; use super::statement::{Row, Statement, StepResult}; use super::transaction::Transaction; @@ -25,8 +25,8 @@ impl Connection { /// /// # Errors /// - /// Returns `DbError` if `SQLite` cannot open the file. - pub fn open(path: &Path, read_only: bool) -> DbResult { + /// Returns `Error` if `SQLite` cannot open the file. + pub fn open(path: &Path, read_only: bool) -> Result { let path_str = path.to_string_lossy(); let flags = if read_only { ffi::SQLITE_OPEN_READONLY | ffi::SQLITE_OPEN_FULLMUTEX @@ -46,8 +46,8 @@ impl Connection { /// /// # Errors /// - /// Returns `DbError` if any statement fails. - pub fn execute_batch(&self, sql: &str) -> DbResult<()> { + /// Returns `Error` if any statement fails. + pub fn execute_batch(&self, sql: &str) -> Result<()> { self.db.exec(sql) } @@ -57,8 +57,8 @@ impl Connection { /// /// # Errors /// - /// Returns `DbError` if the statement fails. - pub fn execute_batch_zeroized(&self, sql: &str) -> DbResult<()> { + /// Returns `Error` if the statement fails. + pub fn execute_batch_zeroized(&self, sql: &str) -> Result<()> { self.db.exec_zeroized(sql) } @@ -66,8 +66,8 @@ impl Connection { /// /// # Errors /// - /// Returns `DbError` if the SQL is invalid. - pub fn prepare(&self, sql: &str) -> DbResult> { + /// Returns `Error` if the SQL is invalid. + pub fn prepare(&self, sql: &str) -> Result> { let raw_stmt = self.db.prepare(sql)?; Ok(Statement::new(raw_stmt)) } @@ -78,8 +78,8 @@ impl Connection { /// /// # Errors /// - /// Returns `DbError` if preparation or execution fails. - pub fn execute(&self, sql: &str, params: &[Value]) -> DbResult { + /// Returns `Error` if preparation or execution fails. + pub fn execute(&self, sql: &str, params: &[Value]) -> Result { let mut stmt = self.prepare(sql)?; stmt.bind_values(params)?; stmt.step()?; @@ -92,20 +92,20 @@ impl Connection { /// /// # Errors /// - /// Returns `DbError` if preparation, execution, or the mapper fails, + /// Returns `Error` if preparation, execution, or the mapper fails, /// or if the query returns no rows. pub fn query_row( &self, sql: &str, params: &[Value], - mapper: impl FnOnce(&Row<'_, '_>) -> DbResult, - ) -> DbResult { + mapper: impl FnOnce(&Row<'_, '_>) -> Result, + ) -> Result { let mut stmt = self.prepare(sql)?; stmt.bind_values(params)?; match stmt.step()? { StepResult::Row(row) => mapper(&row), StepResult::Done => { - Err(DbError::new(ffi::SQLITE_DONE, "query returned no rows")) + Err(Error::new(ffi::SQLITE_DONE, "query returned no rows")) } } } @@ -115,13 +115,13 @@ impl Connection { /// /// # Errors /// - /// Returns `DbError` if preparation, execution, or the mapper fails. + /// Returns `Error` if preparation, execution, or the mapper fails. pub fn query_row_optional( &self, sql: &str, params: &[Value], - mapper: impl FnOnce(&Row<'_, '_>) -> DbResult, - ) -> DbResult> { + mapper: impl FnOnce(&Row<'_, '_>) -> Result, + ) -> Result> { let mut stmt = self.prepare(sql)?; stmt.bind_values(params)?; match stmt.step()? { @@ -134,8 +134,8 @@ impl Connection { /// /// # Errors /// - /// Returns `DbError` if `BEGIN DEFERRED` fails. - pub fn transaction(&self) -> DbResult> { + /// Returns `Error` if `BEGIN DEFERRED` fails. + pub fn transaction(&self) -> Result> { Transaction::begin(self, false) } @@ -143,8 +143,8 @@ impl Connection { /// /// # Errors /// - /// Returns `DbError` if `BEGIN IMMEDIATE` fails. - pub fn transaction_immediate(&self) -> DbResult> { + /// Returns `Error` if `BEGIN IMMEDIATE` fails. + pub fn transaction_immediate(&self) -> Result> { Transaction::begin(self, true) } @@ -175,8 +175,8 @@ impl Connection { /// /// # Errors /// - /// Returns `DbError` if the in-memory database cannot be opened. - pub fn open_in_memory() -> DbResult { + /// Returns `Error` if the in-memory database cannot be opened. + pub fn open_in_memory() -> Result { Self::open(Path::new(":memory:"), false) } } diff --git a/walletkit-db/src/error.rs b/walletkit-db/src/sqlite/error.rs similarity index 66% rename from walletkit-db/src/error.rs rename to walletkit-db/src/sqlite/error.rs index 2fb42fbba..b342d594c 100644 --- a/walletkit-db/src/error.rs +++ b/walletkit-db/src/sqlite/error.rs @@ -1,12 +1,12 @@ -//! Database error types for the safe `SQLite` wrapper. +//! Error types for the safe `SQLite` wrapper. use std::fmt; /// Error code returned by `SQLite` operations. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct DbErrorCode(pub i32); +pub struct ErrorCode(pub i32); -impl fmt::Display for DbErrorCode { +impl fmt::Display for ErrorCode { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.0) } @@ -14,30 +14,30 @@ impl fmt::Display for DbErrorCode { /// Error returned by database operations. #[derive(Debug, PartialEq, Eq)] -pub struct DbError { +pub struct Error { /// `SQLite` result code. - pub code: DbErrorCode, + pub code: ErrorCode, /// Human-readable error message (from `sqlite3_errmsg` when available). pub message: String, } -impl DbError { +impl Error { /// Creates a new database error. pub(crate) fn new(code: i32, message: impl Into) -> Self { Self { - code: DbErrorCode(code), + code: ErrorCode(code), message: message.into(), } } } -impl fmt::Display for DbError { +impl fmt::Display for Error { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "sqlite error {}: {}", self.code, self.message) } } -impl std::error::Error for DbError {} +impl std::error::Error for Error {} -/// Result type for database operations. -pub type DbResult = Result; +/// Result alias for [`Error`]. +pub type Result = std::result::Result; diff --git a/walletkit-db/src/ffi.rs b/walletkit-db/src/sqlite/ffi.rs similarity index 94% rename from walletkit-db/src/ffi.rs rename to walletkit-db/src/sqlite/ffi.rs index 286cd4437..b5d0b7bfe 100644 --- a/walletkit-db/src/ffi.rs +++ b/walletkit-db/src/sqlite/ffi.rs @@ -3,7 +3,7 @@ //! This module is the **only** place in the codebase that contains `unsafe` code //! or C types (`*mut c_void`, `CString`, etc.). It exposes two safe handle types //! -- [`RawDb`] and [`RawStmt`] -- whose methods perform the underlying FFI calls -//! and translate results into idiomatic Rust ([`DbResult`], `String`, `Vec`). +//! and translate results into idiomatic Rust ([`Result`], `String`, `Vec`). //! //! Why `unsafe` is required: `SQLite` is a C library. Calling any C function from //! Rust is `unsafe` by definition because the Rust compiler cannot verify memory @@ -26,7 +26,7 @@ use std::os::raw::{c_char, c_int, c_void}; use zeroize::Zeroize; -use super::error::{DbError, DbResult}; +use super::error::{Error, Result}; // -- SQLite constants (plain `i32`, no C types leaked to callers) ------------- @@ -75,7 +75,7 @@ unsafe impl Send for RawDb {} impl RawDb { /// Opens (or creates) a database at the given `path`. - pub fn open(path: &str, flags: i32) -> DbResult { + pub fn open(path: &str, flags: i32) -> Result { let c_path = to_cstring(path)?; let mut ptr: *mut c_void = std::ptr::null_mut(); @@ -102,14 +102,14 @@ impl RawDb { } m }; - return Err(DbError::new(rc, msg)); + return Err(Error::new(rc, msg)); } Ok(Self { ptr }) } /// Executes one or more semicolon-separated SQL statements. No results. - pub fn exec(&self, sql: &str) -> DbResult<()> { + pub fn exec(&self, sql: &str) -> Result<()> { let c_sql = to_cstring(sql)?; let mut errmsg: *mut c_char = std::ptr::null_mut(); @@ -141,13 +141,13 @@ impl RawDb { } s }; - Err(DbError::new(rc, msg)) + Err(Error::new(rc, msg)) } /// Like [`exec`](Self::exec) but zeroizes the internal `CString` buffer /// after the FFI call. Use for SQL that contains sensitive material (e.g. /// `PRAGMA key`). - pub fn exec_zeroized(&self, sql: &str) -> DbResult<()> { + pub fn exec_zeroized(&self, sql: &str) -> Result<()> { let c_sql = to_cstring(sql)?; let mut errmsg: *mut c_char = std::ptr::null_mut(); @@ -183,11 +183,11 @@ impl RawDb { } s }; - Err(DbError::new(rc, msg)) + Err(Error::new(rc, msg)) } /// Prepares a single SQL statement for execution. - pub fn prepare(&self, sql: &str) -> DbResult> { + pub fn prepare(&self, sql: &str) -> Result> { let c_sql = to_cstring(sql)?; let mut stmt_ptr: *mut c_void = std::ptr::null_mut(); @@ -204,7 +204,7 @@ impl RawDb { }; if rc != SQLITE_OK as c_int || stmt_ptr.is_null() { - return Err(DbError::new(rc, self.errmsg())); + return Err(Error::new(rc, self.errmsg())); } Ok(RawStmt { @@ -252,37 +252,37 @@ impl std::fmt::Debug for RawDb { impl RawStmt<'_> { /// Executes a single step. Returns `SQLITE_ROW` or `SQLITE_DONE`. - pub fn step(&mut self) -> DbResult { + pub fn step(&mut self) -> Result { // Safety: self.ptr is a valid prepared statement. let rc = unsafe { raw::sqlite3_step(self.ptr) }; match rc { SQLITE_ROW => Ok(SQLITE_ROW), SQLITE_DONE => Ok(SQLITE_DONE), - other => Err(DbError::new(other, self.errmsg())), + other => Err(Error::new(other, self.errmsg())), } } /// Resets the statement so it can be stepped again. #[allow(dead_code)] - pub fn reset(&mut self) -> DbResult<()> { + pub fn reset(&mut self) -> Result<()> { // Safety: self.ptr is valid. let rc = unsafe { raw::sqlite3_reset(self.ptr) }; if rc == SQLITE_OK as c_int { Ok(()) } else { - Err(DbError::new(rc, self.errmsg())) + Err(Error::new(rc, self.errmsg())) } } // -- Binding -------------------------------------------------------------- - pub fn bind_i64(&mut self, idx: i32, value: i64) -> DbResult<()> { + pub fn bind_i64(&mut self, idx: i32, value: i64) -> Result<()> { // Safety: self.ptr is valid; idx is a 1-based parameter index. let rc = unsafe { raw::sqlite3_bind_int64(self.ptr, idx as c_int, value) }; check(rc, self) } - pub fn bind_blob(&mut self, idx: i32, value: &[u8]) -> DbResult<()> { + pub fn bind_blob(&mut self, idx: i32, value: &[u8]) -> Result<()> { // Safety: value.as_ptr() is valid for value.len() bytes. // SQLITE_TRANSIENT tells SQLite to copy the data immediately. let rc = unsafe { @@ -297,7 +297,7 @@ impl RawStmt<'_> { check(rc, self) } - pub fn bind_text(&mut self, idx: i32, value: &str) -> DbResult<()> { + pub fn bind_text(&mut self, idx: i32, value: &str) -> Result<()> { // Safety: value.as_ptr() is valid for value.len() bytes. // SQLITE_TRANSIENT tells SQLite to copy the data immediately. let rc = unsafe { @@ -312,7 +312,7 @@ impl RawStmt<'_> { check(rc, self) } - pub fn bind_null(&mut self, idx: i32) -> DbResult<()> { + pub fn bind_null(&mut self, idx: i32) -> Result<()> { // Safety: self.ptr is valid. let rc = unsafe { raw::sqlite3_bind_null(self.ptr, idx as c_int) }; check(rc, self) @@ -385,9 +385,9 @@ impl Drop for RawStmt<'_> { // -- Helpers (private) -------------------------------------------------------- -fn to_cstring(s: &str) -> DbResult { +fn to_cstring(s: &str) -> Result { CString::new(s) - .map_err(|e| DbError::new(SQLITE_ERROR, format!("nul byte in string: {e}"))) + .map_err(|e| Error::new(SQLITE_ERROR, format!("nul byte in string: {e}"))) } fn errmsg_from_ptr(db: *mut c_void) -> String { @@ -404,11 +404,11 @@ fn errmsg_from_ptr(db: *mut c_void) -> String { } } -fn check(rc: c_int, stmt: &RawStmt) -> DbResult<()> { +fn check(rc: c_int, stmt: &RawStmt) -> Result<()> { if rc == SQLITE_OK as c_int { Ok(()) } else { - Err(DbError::new(rc, stmt.errmsg())) + Err(Error::new(rc, stmt.errmsg())) } } diff --git a/walletkit-db/src/sqlite/mod.rs b/walletkit-db/src/sqlite/mod.rs new file mode 100644 index 000000000..0e10c8c22 --- /dev/null +++ b/walletkit-db/src/sqlite/mod.rs @@ -0,0 +1,28 @@ +//! Low-level `SQLCipher` (`sqlite3mc`) wrapper. +//! +//! Safe Rust types over the `SQLite` `C` FFI. Raw symbols are resolved at +//! compile time: +//! +//! - **Native** (`not(wasm32)`): linked against the `sqlite3mc` static library +//! compiled from the downloaded amalgamation by `build.rs`. +//! - **WASM** (`wasm32`): delegated to `sqlite-wasm-rs` (with the +//! `sqlite3mc` feature) which ships its own `WASM`-compiled `sqlite3mc`. +//! +//! The internal `ffi` module is the only file in this crate that contains +//! `unsafe` code or `C` types. + +mod ffi; + +pub mod cipher; +pub mod error; + +mod connection; +mod statement; +mod transaction; +mod value; + +pub use connection::Connection; +pub use error::{Error, Result}; +pub use statement::{Row, Statement, StepResult}; +pub use transaction::Transaction; +pub use value::Value; diff --git a/walletkit-db/src/statement.rs b/walletkit-db/src/sqlite/statement.rs similarity index 92% rename from walletkit-db/src/statement.rs rename to walletkit-db/src/sqlite/statement.rs index 3d859c301..ee5f5118f 100644 --- a/walletkit-db/src/statement.rs +++ b/walletkit-db/src/sqlite/statement.rs @@ -3,7 +3,7 @@ //! This file contains **no `unsafe` code**. All FFI interaction is delegated to //! [`ffi::RawStmt`] which encapsulates the raw pointers and C type conversions. -use super::error::DbResult; +use super::error::Result; use super::ffi::{self, RawStmt}; use super::value::Value; @@ -95,12 +95,12 @@ impl<'conn> Statement<'conn> { /// /// # Errors /// - /// Returns `DbError` if any bind call fails. + /// Returns `Error` if any bind call fails. /// /// # Panics /// /// Panics if the number of values exceeds `i32::MAX`. - pub fn bind_values(&mut self, values: &[Value]) -> DbResult<()> { + pub fn bind_values(&mut self, values: &[Value]) -> Result<()> { for (i, val) in values.iter().enumerate() { let idx = i32::try_from(i + 1).expect("parameter index overflow"); match val { @@ -117,8 +117,8 @@ impl<'conn> Statement<'conn> { /// /// # Errors /// - /// Returns `DbError` if the step fails. - pub fn step<'stmt>(&'stmt mut self) -> DbResult> { + /// Returns `Error` if the step fails. + pub fn step<'stmt>(&'stmt mut self) -> Result> { let rc = self.raw.step()?; if rc == ffi::SQLITE_ROW { Ok(StepResult::Row(Row { stmt: self })) diff --git a/walletkit-db/src/transaction.rs b/walletkit-db/src/sqlite/transaction.rs similarity index 75% rename from walletkit-db/src/transaction.rs rename to walletkit-db/src/sqlite/transaction.rs index e0b79d797..0cb2fb483 100644 --- a/walletkit-db/src/transaction.rs +++ b/walletkit-db/src/sqlite/transaction.rs @@ -3,7 +3,7 @@ //! Automatically rolls back on drop unless explicitly committed. use super::connection::Connection; -use super::error::DbResult; +use super::error::Result; use super::statement::{Row, Statement}; use super::value::Value; @@ -22,7 +22,7 @@ impl<'conn> Transaction<'conn> { /// /// When `immediate` is true, the transaction acquires a RESERVED lock /// immediately (`BEGIN IMMEDIATE`) rather than deferring it. - pub(super) fn begin(conn: &'conn Connection, immediate: bool) -> DbResult { + pub(super) fn begin(conn: &'conn Connection, immediate: bool) -> Result { let sql = if immediate { "BEGIN IMMEDIATE" } else { @@ -39,8 +39,8 @@ impl<'conn> Transaction<'conn> { /// /// # Errors /// - /// Returns `DbError` if the COMMIT statement fails. - pub fn commit(mut self) -> DbResult<()> { + /// Returns `Error` if the COMMIT statement fails. + pub fn commit(mut self) -> Result<()> { self.conn.execute_batch("COMMIT")?; self.committed = true; Ok(()) @@ -52,9 +52,9 @@ impl<'conn> Transaction<'conn> { /// /// # Errors /// - /// Returns `DbError` if any statement fails. + /// Returns `Error` if any statement fails. #[allow(dead_code)] - pub fn execute_batch(&self, sql: &str) -> DbResult<()> { + pub fn execute_batch(&self, sql: &str) -> Result<()> { self.conn.execute_batch(sql) } @@ -62,8 +62,8 @@ impl<'conn> Transaction<'conn> { /// /// # Errors /// - /// Returns `DbError` if preparation or execution fails. - pub fn execute(&self, sql: &str, params: &[Value]) -> DbResult { + /// Returns `Error` if preparation or execution fails. + pub fn execute(&self, sql: &str, params: &[Value]) -> Result { self.conn.execute(sql, params) } @@ -71,13 +71,13 @@ impl<'conn> Transaction<'conn> { /// /// # Errors /// - /// Returns `DbError` if preparation, execution, or the mapper fails. + /// Returns `Error` if preparation, execution, or the mapper fails. pub fn query_row( &self, sql: &str, params: &[Value], - mapper: impl FnOnce(&Row<'_, '_>) -> DbResult, - ) -> DbResult { + mapper: impl FnOnce(&Row<'_, '_>) -> Result, + ) -> Result { self.conn.query_row(sql, params, mapper) } @@ -85,8 +85,8 @@ impl<'conn> Transaction<'conn> { /// /// # Errors /// - /// Returns `DbError` if the SQL is invalid. - pub fn prepare(&self, sql: &str) -> DbResult> { + /// Returns `Error` if the SQL is invalid. + pub fn prepare(&self, sql: &str) -> Result> { self.conn.prepare(sql) } } diff --git a/walletkit-db/src/value.rs b/walletkit-db/src/sqlite/value.rs similarity index 95% rename from walletkit-db/src/value.rs rename to walletkit-db/src/sqlite/value.rs index 57a253a2f..8c0301d2e 100644 --- a/walletkit-db/src/value.rs +++ b/walletkit-db/src/sqlite/value.rs @@ -50,6 +50,6 @@ impl From<&str> for Value { #[macro_export] macro_rules! params { ($($val:expr),* $(,)?) => { - &[$($crate::Value::from($val)),*][..] + &[$($crate::sqlite::Value::from($val)),*][..] }; } diff --git a/walletkit-db/src/tests.rs b/walletkit-db/src/tests.rs index 4f7ab1a76..63abb52f2 100644 --- a/walletkit-db/src/tests.rs +++ b/walletkit-db/src/tests.rs @@ -2,7 +2,8 @@ use std::sync::OnceLock; -use super::*; +use crate::params; +use crate::sqlite::{cipher, Connection, Value}; use secrecy::SecretBox; /// Ensures sqlite3mc's global codec registration is complete before any test @@ -181,3 +182,100 @@ fn test_integrity_check() { let ok = cipher::integrity_check(&conn).expect("check"); assert!(ok); } + +#[test] +fn test_cipher_plaintext_export_import_roundtrip() { + init_sqlite(); + let dir = tempfile::tempdir().expect("create temp dir"); + let src_path = dir.path().join("source.sqlite"); + let dest_path = dir.path().join("backup.plain.sqlite"); + let restore_path = dir.path().join("restore.sqlite"); + let key = SecretBox::init_with(|| [0x11u8; 32]); + + { + let conn = cipher::open_encrypted(&src_path, &key, false).expect("open src"); + conn.execute_batch( + "CREATE TABLE widgets (id INTEGER PRIMARY KEY, val TEXT NOT NULL);", + ) + .expect("create table"); + conn.execute( + "INSERT INTO widgets (id, val) VALUES (?1, ?2)", + params![1_i64, "alpha"], + ) + .expect("insert"); + conn.execute( + "INSERT INTO widgets (id, val) VALUES (?1, ?2)", + params![2_i64, "beta"], + ) + .expect("insert"); + + cipher::export_plaintext_copy(&conn, &dest_path, &["widgets"]).expect("export"); + } + + { + let conn = + cipher::open_encrypted(&restore_path, &key, false).expect("open restore"); + conn.execute_batch( + "CREATE TABLE widgets (id INTEGER PRIMARY KEY, val TEXT NOT NULL);", + ) + .expect("create table"); + cipher::import_plaintext_copy(&conn, &dest_path, &["widgets"]).expect("import"); + + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM widgets", &[], |row| { + Ok(row.column_i64(0)) + }) + .expect("count"); + assert_eq!(count, 2); + + let val = conn + .query_row("SELECT val FROM widgets WHERE id = 2", &[], |row| { + Ok(row.column_text(0)) + }) + .expect("query"); + assert_eq!(val, "beta"); + } +} + +#[test] +fn test_cipher_import_rejects_non_empty_destination() { + init_sqlite(); + let dir = tempfile::tempdir().expect("create temp dir"); + let src_path = dir.path().join("source.sqlite"); + let dest_path = dir.path().join("backup.plain.sqlite"); + let restore_path = dir.path().join("restore.sqlite"); + let key = SecretBox::init_with(|| [0x22u8; 32]); + + { + let conn = cipher::open_encrypted(&src_path, &key, false).expect("open src"); + conn.execute_batch( + "CREATE TABLE widgets (id INTEGER PRIMARY KEY, val TEXT NOT NULL);", + ) + .expect("create table"); + conn.execute( + "INSERT INTO widgets (id, val) VALUES (?1, ?2)", + params![1_i64, "alpha"], + ) + .expect("insert"); + cipher::export_plaintext_copy(&conn, &dest_path, &["widgets"]).expect("export"); + } + + let conn = + cipher::open_encrypted(&restore_path, &key, false).expect("open restore"); + conn.execute_batch( + "CREATE TABLE widgets (id INTEGER PRIMARY KEY, val TEXT NOT NULL);", + ) + .expect("create table"); + conn.execute( + "INSERT INTO widgets (id, val) VALUES (?1, ?2)", + params![99_i64, "preexisting"], + ) + .expect("insert"); + + let err = cipher::import_plaintext_copy(&conn, &dest_path, &["widgets"]) + .expect_err("import should refuse non-empty destination"); + assert!( + err.to_string().contains("non-empty table"), + "expected non-empty-table error, got: {err}" + ); +}