Skip to main content

walletkit_sqlite/
cipher.rs

1//! `sqlite3mc` encryption configuration.
2//!
3//! # Encryption flow
4//!
5//! This crate uses `sqlite3mc` (`SQLite3` Multiple Ciphers) to encrypt
6//! `SQLite` databases at rest. The encryption is transparent to SQL -- once a
7//! database is opened and keyed, all reads and writes are automatically
8//! encrypted/decrypted by the `SQLite` pager layer.
9//!
10//! The flow when opening a database is:
11//!
12//! 1. **Open** -- `sqlite3_open_v2` creates or opens the database file.
13//!    At this point the file is opaque (encrypted) and no data can be read.
14//!
15//! 2. **Configure cipher** -- `PRAGMA cipher = 'chacha20'` fixes the on-disk
16//!    cipher before the key activates it.
17//!
18//! 3. **Detect and encrypt or unlock** -- A read from `sqlite_master` succeeds
19//!    for a plaintext (or new) database. Such a database is moved out of WAL
20//!    mode and atomically encrypted with `PRAGMA rekey`. If the read returns
21//!    `SQLITE_NOTADB`, the database is treated as encrypted and unlocked with
22//!    `PRAGMA key`. Both PRAGMAs receive the 32-byte `K_intermediate` as a raw
23//!    hex key, bypassing the passphrase KDF.
24//!
25//! 4. **Verify** -- We read from `sqlite_master` after rekeying or keying. A
26//!    wrong key returns `SQLITE_NOTADB` because the decrypted page header does
27//!    not match the expected `SQLite` magic bytes.
28//!
29//! 5. **Configure connection** -- The target-specific journal mode and every
30//!    connection-level invariant are set and verified.
31//!
32//! The default cipher is **ChaCha20-Poly1305** (authenticated encryption).
33//! All crypto is built into the `sqlite3mc` amalgamation -- no OpenSSL or
34//! other external crypto library is needed on any platform.
35
36use std::path::Path;
37
38use secrecy::{ExposeSecret, SecretBox};
39use zeroize::Zeroizing;
40
41use super::connection::Connection;
42use super::error::{DbResult, Error};
43
44const CIPHER_CHACHA20: &str = "chacha20";
45const FOREIGN_KEYS_ON: i64 = 1;
46const SYNCHRONOUS_FULL: i64 = 2;
47const SECURE_DELETE_ON: i64 = 1;
48const TEMP_STORE_MEMORY: i64 = 2;
49
50/// Opens a writable database, applies the encryption key, and configures the connection.
51///
52/// This is the standard open sequence for databases: open -> select cipher ->
53/// encrypt plaintext or unlock encrypted data -> verify -> configure policy.
54///
55/// See the [module-level documentation](self) for the full encryption flow.
56///
57/// # Errors
58///
59/// Returns `Error` if opening, keying, or configuring the connection fails.
60pub fn open_encrypted(
61    path: &Path,
62    k_intermediate: &SecretBox<[u8; 32]>,
63) -> DbResult<Connection> {
64    #[cfg(not(target_arch = "wasm32"))]
65    let conn = Connection::open(path, false)?;
66    #[cfg(target_arch = "wasm32")]
67    let conn = Connection::open_with_opfs_vfs(path, false)?;
68    configure_connection(&conn, k_intermediate)?;
69    Ok(conn)
70}
71
72/// Configures durable journal settings, foreign keys, and secure deletion.
73///
74/// - Native uses WAL for concurrent readers during writes.
75/// - WASM uses a rollback journal because SAH-pool has no WAL shared-memory
76///   methods; WAL would require exclusive locking and provide no concurrency.
77/// - `synchronous = FULL` -- maximizes crash consistency by flushing required
78///   journal writes before the transaction is reported as committed.
79/// - `foreign_keys = ON` -- enforces referential integrity constraints.
80/// - `secure_delete = ON` -- overwrites deleted content with zeroes so
81///   sensitive data does not linger in free pages.
82fn configure_connection(
83    conn: &Connection,
84    k_intermediate: &SecretBox<[u8; 32]>,
85) -> DbResult<()> {
86    ensure_cipher(conn)?;
87    encrypt_or_unlock(conn, k_intermediate)?;
88
89    #[cfg(not(target_arch = "wasm32"))]
90    ensure_journal_mode(conn, "WAL")?;
91    // SAH-pool does not expose WAL shared-memory methods. WAL would therefore
92    // require locking_mode=EXCLUSIVE before the first database access and
93    // provide no concurrency benefit, so WASM deliberately uses the rollback
94    // journal until benchmarks justify that extra complexity.
95    #[cfg(target_arch = "wasm32")]
96    ensure_journal_mode(conn, "DELETE")?;
97
98    ensure_foreign_keys(conn)?;
99    ensure_synchronous_full(conn)?;
100    ensure_secure_delete(conn)?;
101    ensure_temp_store_memory(conn)?;
102    Ok(())
103}
104
105/// Encrypts an accessible plaintext database or unlocks an encrypted one.
106///
107/// Only `SQLITE_NOTADB` identifies the expected encrypted-file case. Other
108/// probe failures (I/O errors, corruption, locking failures) are returned
109/// unchanged so they cannot accidentally initiate a migration.
110fn encrypt_or_unlock(
111    conn: &Connection,
112    k_intermediate: &SecretBox<[u8; 32]>,
113) -> DbResult<()> {
114    match verify_schema_readable(conn) {
115        Ok(()) => {
116            // sqlite3mc cannot rekey a WAL database. Switching to DELETE also
117            // checkpoints a prior plaintext WAL before encryption. If another
118            // connection prevents the transition, fail without modifying data.
119            ensure_journal_mode(conn, "DELETE")?;
120            apply_rekey(conn, k_intermediate)?;
121            verify_schema_readable(conn).map_err(|e| {
122                Error::new(
123                    e.code.0,
124                    format!(
125                        "plaintext database encryption verification failed: {}",
126                        e.message
127                    ),
128                )
129            })
130        }
131        Err(error) if error.code.0 & 0xff == super::ffi::SQLITE_NOTADB => {
132            apply_key(conn, k_intermediate)
133        }
134        Err(error) => Err(error),
135    }
136}
137
138/// Selects and verifies the on-disk cipher before the key activates it.
139///
140/// Pinning the cipher prevents a future compile-time default change from
141/// silently creating or interpreting databases with a different format.
142fn ensure_cipher(conn: &Connection) -> DbResult<()> {
143    conn.execute_batch(&format!("PRAGMA cipher = '{CIPHER_CHACHA20}';"))?;
144    let actual = conn.query_row("PRAGMA cipher;", &[], |row| Ok(row.column_text(0)))?;
145    if actual.eq_ignore_ascii_case(CIPHER_CHACHA20) {
146        Ok(())
147    } else {
148        Err(Error::new(
149            -1,
150            format!(
151                "could not ensure sqlite3mc cipher {CIPHER_CHACHA20}: SQLite selected {actual}"
152            ),
153        ))
154    }
155}
156
157/// Applies the `sqlite3mc` encryption key to an open connection.
158///
159/// The 32-byte `k_intermediate` is hex-encoded and passed as a raw key via
160/// `PRAGMA key = "x'<64-hex-chars>'"`. `sqlite3mc` interprets the `x'...'`
161/// prefix as a raw key (as opposed to a passphrase that would be run through
162/// a KDF first).
163///
164/// After keying, a lightweight read (`SELECT count(*) FROM sqlite_master`)
165/// verifies the key is correct. If it's wrong, `sqlite3mc` fails with
166/// `SQLITE_NOTADB` on the first page read.
167fn apply_key(conn: &Connection, k_intermediate: &SecretBox<[u8; 32]>) -> DbResult<()> {
168    let pragma = raw_key_pragma("key", k_intermediate);
169
170    // execute_batch_zeroized ensures the internal CString copy of the PRAGMA
171    // (which contains the hex key) is zeroized after the FFI call returns.
172    conn.execute_batch_zeroized(&pragma)?;
173
174    // Touch a page to verify the key works. On failure this produces a clear
175    // error rather than a confusing "not a database" later during schema setup.
176    verify_schema_readable(conn).map_err(|e| {
177        Error::new(
178            e.code.0,
179            format!(
180                "encryption key verification failed (is the key correct?): {}",
181                e.message
182            ),
183        )
184    })?;
185
186    // k_intermediate and pragma are zeroized on drop regardless of which exit
187    // path we took. raw_key_pragma zeroizes its temporary hex buffer too.
188    Ok(())
189}
190
191/// Encrypts a readable plaintext database in place with the supplied raw key.
192fn apply_rekey(
193    conn: &Connection,
194    k_intermediate: &SecretBox<[u8; 32]>,
195) -> DbResult<()> {
196    let pragma = raw_key_pragma("rekey", k_intermediate);
197    conn.execute_batch_zeroized(&pragma).map_err(|e| {
198        Error::new(
199            e.code.0,
200            format!("failed to encrypt plaintext database: {}", e.message),
201        )
202    })
203}
204
205fn raw_key_pragma(
206    operation: &str,
207    k_intermediate: &SecretBox<[u8; 32]>,
208) -> Zeroizing<String> {
209    let key_hex = Zeroizing::new(hex::encode(k_intermediate.expose_secret()));
210    Zeroizing::new(format!("PRAGMA {operation} = \"x'{}'\";", key_hex.as_str()))
211}
212
213fn verify_schema_readable(conn: &Connection) -> DbResult<()> {
214    conn.execute_batch("SELECT count(*) FROM sqlite_master;")
215}
216
217/// Ensures the target-specific journal policy actually took effect.
218///
219/// Assigning `journal_mode` returns the effective mode because `SQLite` may
220/// retain the previous mode when the requested transition is unavailable.
221fn ensure_journal_mode(conn: &Connection, requested: &str) -> DbResult<()> {
222    let actual =
223        conn.query_row(&format!("PRAGMA journal_mode = {requested};"), &[], |row| {
224            Ok(row.column_text(0))
225        })?;
226    if actual.eq_ignore_ascii_case(requested) {
227        Ok(())
228    } else {
229        Err(Error::new(
230            -1,
231            format!(
232                "could not ensure journal mode {requested}: SQLite selected {actual}"
233            ),
234        ))
235    }
236}
237
238/// Enables foreign-key enforcement for every connection.
239///
240/// `SQLite` defaults this setting to off and may silently ignore the assignment
241/// inside a transaction or when foreign-key support was omitted at build time.
242fn ensure_foreign_keys(conn: &Connection) -> DbResult<()> {
243    conn.execute_batch("PRAGMA foreign_keys = ON;")?;
244    let actual =
245        conn.query_row("PRAGMA foreign_keys;", &[], |row| Ok(row.column_i64(0)))?;
246    if actual == FOREIGN_KEYS_ON {
247        Ok(())
248    } else {
249        Err(Error::new(
250            -1,
251            format!(
252                "could not ensure PRAGMA foreign_keys = ON: expected {FOREIGN_KEYS_ON}, got {actual}"
253            ),
254        ))
255    }
256}
257
258/// Uses `SQLite`'s strongest ordinary durability policy.
259///
260/// `FULL` ensures `SQLite` flushes journal content before reporting a transaction
261/// as committed, reducing the risk of corruption after a crash or power loss.
262fn ensure_synchronous_full(conn: &Connection) -> DbResult<()> {
263    conn.execute_batch("PRAGMA synchronous = FULL;")?;
264    let actual =
265        conn.query_row("PRAGMA synchronous;", &[], |row| Ok(row.column_i64(0)))?;
266    if actual == SYNCHRONOUS_FULL {
267        Ok(())
268    } else {
269        Err(Error::new(
270            -1,
271            format!(
272                "could not ensure PRAGMA synchronous = FULL: expected {SYNCHRONOUS_FULL}, got {actual}"
273            ),
274        ))
275    }
276}
277
278/// Overwrites deleted content instead of leaving it in reusable database pages.
279///
280/// This limits plaintext remnants while the encrypted database is open and
281/// accessible with its key.
282fn ensure_secure_delete(conn: &Connection) -> DbResult<()> {
283    conn.execute_batch("PRAGMA secure_delete = ON;")?;
284    let actual =
285        conn.query_row("PRAGMA secure_delete;", &[], |row| Ok(row.column_i64(0)))?;
286    if actual == SECURE_DELETE_ON {
287        Ok(())
288    } else {
289        Err(Error::new(
290            -1,
291            format!(
292                "could not ensure PRAGMA secure_delete = ON: expected {SECURE_DELETE_ON}, got {actual}"
293            ),
294        ))
295    }
296}
297
298/// Keeps temporary tables and indices in memory.
299///
300/// `sqlite3mc` does not encrypt temporary databases, so allowing temporary
301/// storage to spill to a filesystem could expose plaintext at rest.
302fn ensure_temp_store_memory(conn: &Connection) -> DbResult<()> {
303    conn.execute_batch("PRAGMA temp_store = MEMORY;")?;
304    let actual =
305        conn.query_row("PRAGMA temp_store;", &[], |row| Ok(row.column_i64(0)))?;
306    if actual == TEMP_STORE_MEMORY {
307        Ok(())
308    } else {
309        Err(Error::new(
310            -1,
311            format!(
312                "could not ensure PRAGMA temp_store = MEMORY: expected {TEMP_STORE_MEMORY}, got {actual}"
313            ),
314        ))
315    }
316}
317
318/// Creates a plaintext (unencrypted) copy of an already-open encrypted database.
319///
320/// The copy is produced by `ATTACH`-ing a new unencrypted database and copying
321/// the caller-specified tables via `CREATE TABLE ... AS SELECT *`. The
322/// destination file must not already exist.
323///
324/// We use `ATTACH` + SQL instead of the `sqlite3_backup` API because
325/// `sqlite3mc` requires both source and destination to share the same
326/// encryption configuration. Since the destination is unencrypted, the
327/// backup API cannot be used.
328///
329/// # Errors
330///
331/// Returns `Error` if the `ATTACH`, copy, or `DETACH` fails.
332pub fn export_plaintext_copy(
333    conn: &Connection,
334    dest_path: &Path,
335    tables: &[&str],
336) -> DbResult<()> {
337    let dest_str = dest_path.to_string_lossy();
338    let attach_sql = format!(
339        "ATTACH DATABASE '{}' AS backup KEY '';",
340        dest_str.replace('\'', "''")
341    );
342    conn.execute_batch(&attach_sql)?;
343
344    let result = (|| {
345        let tx = conn.transaction()?;
346        for table in tables {
347            tx.execute_batch(&format!(
348                "CREATE TABLE backup.{table} AS SELECT * FROM {table};"
349            ))?;
350        }
351        tx.commit()
352    })();
353
354    // Always detach, even if the copy failed.
355    let detach_result = conn.execute_batch("DETACH DATABASE backup;");
356
357    result?;
358    detach_result?;
359    Ok(())
360}
361
362/// Imports data from a plaintext (unencrypted) database into an already-open
363/// encrypted database.
364///
365/// The source database is `ATTACH`ed with an empty key and its contents are
366/// copied into the main (empty) encrypted database.
367///
368/// See [`export_plaintext_copy`] for why `ATTACH` + SQL is used instead of
369/// the `sqlite3_backup` API.
370///
371/// **Schema migration:** The import uses `SELECT *`, so column changes are
372/// handled automatically as long as both sides share the same schema. If a
373/// caller's schema evolves (e.g. new columns with `NOT NULL` constraints),
374/// restoring an older backup into a newer schema will fail. When that happens,
375/// the caller needs version-aware import logic.
376///
377/// # Errors
378///
379/// Returns `Error` if the `ATTACH`, copy, or `DETACH` fails.
380pub fn import_plaintext_copy(
381    conn: &Connection,
382    source_path: &Path,
383    tables: &[&str],
384) -> DbResult<()> {
385    if !source_path.exists() {
386        return Err(Error::new(
387            -1,
388            format!("backup file does not exist: {}", source_path.display()),
389        ));
390    }
391
392    let source_str = source_path.to_string_lossy();
393    let attach_sql = format!(
394        "ATTACH DATABASE '{}' AS backup KEY '';",
395        source_str.replace('\'', "''")
396    );
397    conn.execute_batch(&attach_sql)?;
398
399    // Verify the destination tables are empty before importing. Importing into
400    // a non-empty destination could silently merge data if primary keys don't
401    // collide.
402    let result = (|| {
403        for table in tables {
404            let count: i64 =
405                conn.query_row(&format!("SELECT COUNT(*) FROM {table}"), &[], |row| {
406                    Ok(row.column_i64(0))
407                })?;
408            if count > 0 {
409                return Err(Error::new(
410                    -1,
411                    format!("cannot import into non-empty table: {table}"),
412                ));
413            }
414        }
415
416        // Wrap in a transaction so the restore is atomic — if any INSERT
417        // fails, everything is rolled back and the destination stays empty for
418        // a retry.
419        let tx = conn.transaction()?;
420        for table in tables {
421            tx.execute_batch(&format!(
422                "INSERT INTO {table} SELECT * FROM backup.{table};"
423            ))?;
424        }
425        tx.commit()
426    })();
427
428    // Always detach, even if the import failed.
429    let detach_result = conn.execute_batch("DETACH DATABASE backup;");
430
431    result?;
432    detach_result?;
433    Ok(())
434}
435
436/// Runs `PRAGMA integrity_check` and returns whether the database is healthy.
437///
438/// # Errors
439///
440/// Returns `Error` if the integrity check query fails.
441pub fn integrity_check(conn: &Connection) -> DbResult<bool> {
442    let result = conn.query_row("PRAGMA integrity_check;", &[], |stmt| {
443        Ok(stmt.column_text(0))
444    })?;
445    Ok(result.trim() == "ok")
446}
447
448#[cfg(test)]
449mod tests {
450    use super::{
451        export_plaintext_copy, import_plaintext_copy, integrity_check, open_encrypted,
452    };
453    use crate::params;
454    use crate::test_utils::init_sqlite;
455    use crate::Connection;
456    use secrecy::SecretBox;
457
458    #[test]
459    fn test_cipher_encrypted_round_trip() {
460        init_sqlite();
461        let dir = tempfile::tempdir().expect("create temp dir");
462        let path = dir.path().join("cipher-test.sqlite");
463        let key = SecretBox::init_with(|| [0xABu8; 32]);
464
465        // Create and write
466        {
467            let conn = open_encrypted(&path, &key).expect("open encrypted");
468            conn.execute_batch(
469                "CREATE TABLE secret (id INTEGER PRIMARY KEY, val TEXT);",
470            )
471            .expect("create table");
472            conn.execute("INSERT INTO secret (id, val) VALUES (1, 'top-secret')", &[])
473                .expect("insert");
474        }
475
476        // Re-open with correct key
477        {
478            let conn = open_encrypted(&path, &key).expect("reopen encrypted");
479            let val = conn
480                .query_row("SELECT val FROM secret WHERE id = 1", &[], |stmt| {
481                    Ok(stmt.column_text(0))
482                })
483                .expect("query");
484            assert_eq!(val, "top-secret");
485        }
486
487        // Wrong key should fail
488        {
489            let wrong_key = SecretBox::init_with(|| [0xCDu8; 32]);
490            let result = open_encrypted(&path, &wrong_key);
491            assert!(result.is_err(), "wrong key should fail");
492        }
493    }
494
495    #[test]
496    fn test_plaintext_wal_database_is_rekeyed_in_place() {
497        init_sqlite();
498        let dir = tempfile::tempdir().expect("create temp dir");
499        let path = dir.path().join("plaintext.sqlite");
500        let key = SecretBox::init_with(|| [0x42u8; 32]);
501
502        {
503            let conn = Connection::open(&path, false).expect("open plaintext");
504            let mode = conn
505                .query_row("PRAGMA journal_mode = WAL", &[], |row| {
506                    Ok(row.column_text(0))
507                })
508                .expect("enable plaintext WAL");
509            assert_eq!(mode.to_ascii_lowercase(), "wal");
510            conn.execute_batch(
511                "CREATE TABLE secret (id INTEGER PRIMARY KEY, val TEXT);\
512                 INSERT INTO secret VALUES (1, 'preserve-me');",
513            )
514            .expect("write plaintext data");
515        }
516        assert!(
517            std::fs::read(&path)
518                .expect("read plaintext")
519                .starts_with(b"SQLite format 3\0"),
520            "fixture must start as plaintext SQLite"
521        );
522
523        {
524            let conn = open_encrypted(&path, &key).expect("migrate plaintext");
525            let value = conn
526                .query_row("SELECT val FROM secret WHERE id = 1", &[], |row| {
527                    Ok(row.column_text(0))
528                })
529                .expect("read migrated data");
530            assert_eq!(value, "preserve-me");
531        }
532
533        let encrypted_bytes = std::fs::read(&path).expect("read encrypted");
534        assert!(
535            !encrypted_bytes.starts_with(b"SQLite format 3\0"),
536            "rekey must remove the plaintext SQLite header"
537        );
538
539        {
540            let conn = open_encrypted(&path, &key).expect("reopen migrated database");
541            let value = conn
542                .query_row("SELECT val FROM secret WHERE id = 1", &[], |row| {
543                    Ok(row.column_text(0))
544                })
545                .expect("read migrated data after reopen");
546            assert_eq!(value, "preserve-me");
547        }
548
549        let wrong_key = SecretBox::init_with(|| [0x43u8; 32]);
550        assert!(
551            open_encrypted(&path, &wrong_key).is_err(),
552            "migrated database must reject the wrong key"
553        );
554        assert_eq!(
555            std::fs::read(&path).expect("read after wrong-key open"),
556            encrypted_bytes,
557            "wrong-key open must not modify migrated data"
558        );
559    }
560
561    #[test]
562    fn test_integrity_check() {
563        init_sqlite();
564        let conn = Connection::open_in_memory().expect("open in-memory db");
565        let ok = integrity_check(&conn).expect("check");
566        assert!(ok);
567    }
568
569    #[test]
570    fn test_cipher_plaintext_export_import_roundtrip() {
571        init_sqlite();
572        let dir = tempfile::tempdir().expect("create temp dir");
573        let src_path = dir.path().join("source.sqlite");
574        let dest_path = dir.path().join("backup.plain.sqlite");
575        let restore_path = dir.path().join("restore.sqlite");
576        let key = SecretBox::init_with(|| [0x11u8; 32]);
577
578        {
579            let conn = open_encrypted(&src_path, &key).expect("open src");
580            conn.execute_batch(
581                "CREATE TABLE widgets (id INTEGER PRIMARY KEY, val TEXT NOT NULL);",
582            )
583            .expect("create table");
584            conn.execute(
585                "INSERT INTO widgets (id, val) VALUES (?1, ?2)",
586                params![1_i64, "alpha"],
587            )
588            .expect("insert");
589            conn.execute(
590                "INSERT INTO widgets (id, val) VALUES (?1, ?2)",
591                params![2_i64, "beta"],
592            )
593            .expect("insert");
594
595            export_plaintext_copy(&conn, &dest_path, &["widgets"]).expect("export");
596        }
597
598        {
599            let conn = open_encrypted(&restore_path, &key).expect("open restore");
600            conn.execute_batch(
601                "CREATE TABLE widgets (id INTEGER PRIMARY KEY, val TEXT NOT NULL);",
602            )
603            .expect("create table");
604            import_plaintext_copy(&conn, &dest_path, &["widgets"]).expect("import");
605
606            let count: i64 = conn
607                .query_row("SELECT COUNT(*) FROM widgets", &[], |row| {
608                    Ok(row.column_i64(0))
609                })
610                .expect("count");
611            assert_eq!(count, 2);
612
613            let val = conn
614                .query_row("SELECT val FROM widgets WHERE id = 2", &[], |row| {
615                    Ok(row.column_text(0))
616                })
617                .expect("query");
618            assert_eq!(val, "beta");
619        }
620    }
621
622    #[test]
623    fn test_cipher_import_rejects_non_empty_destination() {
624        init_sqlite();
625        let dir = tempfile::tempdir().expect("create temp dir");
626        let src_path = dir.path().join("source.sqlite");
627        let dest_path = dir.path().join("backup.plain.sqlite");
628        let restore_path = dir.path().join("restore.sqlite");
629        let key = SecretBox::init_with(|| [0x22u8; 32]);
630
631        {
632            let conn = open_encrypted(&src_path, &key).expect("open src");
633            conn.execute_batch(
634                "CREATE TABLE widgets (id INTEGER PRIMARY KEY, val TEXT NOT NULL);",
635            )
636            .expect("create table");
637            conn.execute(
638                "INSERT INTO widgets (id, val) VALUES (?1, ?2)",
639                params![1_i64, "alpha"],
640            )
641            .expect("insert");
642            export_plaintext_copy(&conn, &dest_path, &["widgets"]).expect("export");
643        }
644
645        let conn = open_encrypted(&restore_path, &key).expect("open restore");
646        conn.execute_batch(
647            "CREATE TABLE widgets (id INTEGER PRIMARY KEY, val TEXT NOT NULL);",
648        )
649        .expect("create table");
650        conn.execute(
651            "INSERT INTO widgets (id, val) VALUES (?1, ?2)",
652            params![99_i64, "preexisting"],
653        )
654        .expect("insert");
655
656        let err = import_plaintext_copy(&conn, &dest_path, &["widgets"])
657            .expect_err("import should refuse non-empty destination");
658        assert!(
659            err.to_string().contains("non-empty table"),
660            "expected non-empty-table error, got: {err}"
661        );
662    }
663}