1use std::path::Path;
42
43use secrecy::{ExposeSecret, SecretBox};
44use zeroize::Zeroizing;
45
46use super::connection::Connection;
47use super::error::{DbResult, Error};
48
49const CIPHER_CHACHA20: &str = "chacha20";
50const PLAINTEXT_HEADER_SIZE: i64 = 32;
51const SQLITE_ERROR: i32 = 1;
52const SQLITE_CORRUPT: i32 = 11;
53const FOREIGN_KEYS_ON: i64 = 1;
54const SYNCHRONOUS_FULL: i64 = 2;
55const SECURE_DELETE_ON: i64 = 1;
56const TEMP_STORE_MEMORY: i64 = 2;
57
58pub fn open_encrypted(
69 path: &Path,
70 k_intermediate: &SecretBox<[u8; 32]>,
71) -> DbResult<Connection> {
72 #[cfg(not(target_arch = "wasm32"))]
73 let conn = Connection::open(path, false)?;
74 #[cfg(target_arch = "wasm32")]
75 let conn = Connection::open_with_opfs_vfs(path, false)?;
76 configure_connection(&conn, k_intermediate)?;
77 Ok(conn)
78}
79
80fn configure_connection(
91 conn: &Connection,
92 k_intermediate: &SecretBox<[u8; 32]>,
93) -> DbResult<()> {
94 ensure_cipher(conn)?;
95 encrypt_or_unlock(conn, k_intermediate)?;
96
97 #[cfg(not(target_arch = "wasm32"))]
98 ensure_journal_mode(conn, "WAL")?;
99 #[cfg(target_arch = "wasm32")]
104 ensure_journal_mode(conn, "DELETE")?;
105
106 ensure_foreign_keys(conn)?;
107 ensure_synchronous_full(conn)?;
108 ensure_secure_delete(conn)?;
109 ensure_temp_store_memory(conn)?;
110 Ok(())
111}
112
113fn encrypt_or_unlock(
121 conn: &Connection,
122 k_intermediate: &SecretBox<[u8; 32]>,
123) -> DbResult<()> {
124 match verify_schema_readable(conn) {
125 Ok(()) => {
126 ensure_journal_mode(conn, "DELETE")?;
130 ensure_plaintext_header(conn)?;
131 apply_rekey(conn, k_intermediate)?;
132 verify_encryption(conn, "plaintext database encryption verification failed")
133 }
134 Err(error) if is_plaintext_header_probe_error(&error) => {
135 ensure_plaintext_header(conn)?;
138 apply_key(conn, k_intermediate)
139 }
140 Err(error) if error.code.0 & 0xff == super::ffi::SQLITE_NOTADB => {
141 apply_key(conn, k_intermediate)?;
145 ensure_journal_mode(conn, "DELETE")?;
146 ensure_plaintext_header(conn)?;
147 apply_rekey(conn, k_intermediate)?;
148 verify_encryption(conn, "plaintext-header migration verification failed")
149 }
150 Err(error) => Err(error),
151 }
152}
153
154fn is_plaintext_header_probe_error(error: &Error) -> bool {
159 let primary_code = error.code.0 & 0xff;
160 (primary_code == SQLITE_ERROR && error.message == "unsupported file format")
161 || (primary_code == SQLITE_CORRUPT
162 && error.message == "database disk image is malformed")
163}
164
165fn ensure_plaintext_header(conn: &Connection) -> DbResult<()> {
166 conn.execute_batch(&format!(
167 "PRAGMA plaintext_header_size = {PLAINTEXT_HEADER_SIZE};"
168 ))?;
169 let actual = conn.query_row("PRAGMA plaintext_header_size;", &[], |row| {
170 Ok(row.column_i64(0))
171 })?;
172 if actual == PLAINTEXT_HEADER_SIZE {
173 Ok(())
174 } else {
175 Err(Error::new(
176 -1,
177 format!(
178 "could not ensure plaintext header size {PLAINTEXT_HEADER_SIZE}: SQLite selected {actual}"
179 ),
180 ))
181 }
182}
183
184#[cfg(test)]
187fn encrypt_or_unlock_fully_encrypted(
188 conn: &Connection,
189 k_intermediate: &SecretBox<[u8; 32]>,
190) -> DbResult<()> {
191 match verify_schema_readable(conn) {
192 Ok(()) => {
193 ensure_journal_mode(conn, "DELETE")?;
194 apply_rekey(conn, k_intermediate)?;
195 verify_encryption(conn, "plaintext database encryption verification failed")
196 }
197 Err(error) if error.code.0 & 0xff == super::ffi::SQLITE_NOTADB => {
198 apply_key(conn, k_intermediate)
199 }
200 Err(error) => Err(error),
201 }
202}
203
204fn verify_encryption(conn: &Connection, context: &str) -> DbResult<()> {
205 verify_schema_readable(conn).map_err(|error| {
206 Error::new(error.code.0, format!("{context}: {}", error.message))
207 })
208}
209
210fn ensure_cipher(conn: &Connection) -> DbResult<()> {
215 conn.execute_batch(&format!("PRAGMA cipher = '{CIPHER_CHACHA20}';"))?;
216 let actual = conn.query_row("PRAGMA cipher;", &[], |row| Ok(row.column_text(0)))?;
217 if actual.eq_ignore_ascii_case(CIPHER_CHACHA20) {
218 Ok(())
219 } else {
220 Err(Error::new(
221 -1,
222 format!(
223 "could not ensure sqlite3mc cipher {CIPHER_CHACHA20}: SQLite selected {actual}"
224 ),
225 ))
226 }
227}
228
229fn apply_key(conn: &Connection, k_intermediate: &SecretBox<[u8; 32]>) -> DbResult<()> {
240 let pragma = raw_key_pragma("key", k_intermediate);
241
242 conn.execute_batch_zeroized(&pragma)?;
245
246 verify_schema_readable(conn).map_err(|e| {
249 Error::new(
250 e.code.0,
251 format!(
252 "encryption key verification failed (is the key correct?): {}",
253 e.message
254 ),
255 )
256 })?;
257
258 Ok(())
261}
262
263fn apply_rekey(
265 conn: &Connection,
266 k_intermediate: &SecretBox<[u8; 32]>,
267) -> DbResult<()> {
268 let pragma = raw_key_pragma("rekey", k_intermediate);
269 conn.execute_batch_zeroized(&pragma).map_err(|e| {
270 Error::new(
271 e.code.0,
272 format!("failed to encrypt plaintext database: {}", e.message),
273 )
274 })
275}
276
277fn raw_key_pragma(
278 operation: &str,
279 k_intermediate: &SecretBox<[u8; 32]>,
280) -> Zeroizing<String> {
281 let key_hex = Zeroizing::new(hex::encode(k_intermediate.expose_secret()));
282 Zeroizing::new(format!("PRAGMA {operation} = \"x'{}'\";", key_hex.as_str()))
283}
284
285fn verify_schema_readable(conn: &Connection) -> DbResult<()> {
286 conn.execute_batch("SELECT count(*) FROM sqlite_master;")
287}
288
289fn ensure_journal_mode(conn: &Connection, requested: &str) -> DbResult<()> {
294 let actual =
295 conn.query_row(&format!("PRAGMA journal_mode = {requested};"), &[], |row| {
296 Ok(row.column_text(0))
297 })?;
298 if actual.eq_ignore_ascii_case(requested) {
299 Ok(())
300 } else {
301 Err(Error::new(
302 -1,
303 format!(
304 "could not ensure journal mode {requested}: SQLite selected {actual}"
305 ),
306 ))
307 }
308}
309
310fn ensure_foreign_keys(conn: &Connection) -> DbResult<()> {
315 conn.execute_batch("PRAGMA foreign_keys = ON;")?;
316 let actual =
317 conn.query_row("PRAGMA foreign_keys;", &[], |row| Ok(row.column_i64(0)))?;
318 if actual == FOREIGN_KEYS_ON {
319 Ok(())
320 } else {
321 Err(Error::new(
322 -1,
323 format!(
324 "could not ensure PRAGMA foreign_keys = ON: expected {FOREIGN_KEYS_ON}, got {actual}"
325 ),
326 ))
327 }
328}
329
330fn ensure_synchronous_full(conn: &Connection) -> DbResult<()> {
335 conn.execute_batch("PRAGMA synchronous = FULL;")?;
336 let actual =
337 conn.query_row("PRAGMA synchronous;", &[], |row| Ok(row.column_i64(0)))?;
338 if actual == SYNCHRONOUS_FULL {
339 Ok(())
340 } else {
341 Err(Error::new(
342 -1,
343 format!(
344 "could not ensure PRAGMA synchronous = FULL: expected {SYNCHRONOUS_FULL}, got {actual}"
345 ),
346 ))
347 }
348}
349
350fn ensure_secure_delete(conn: &Connection) -> DbResult<()> {
355 conn.execute_batch("PRAGMA secure_delete = ON;")?;
356 let actual =
357 conn.query_row("PRAGMA secure_delete;", &[], |row| Ok(row.column_i64(0)))?;
358 if actual == SECURE_DELETE_ON {
359 Ok(())
360 } else {
361 Err(Error::new(
362 -1,
363 format!(
364 "could not ensure PRAGMA secure_delete = ON: expected {SECURE_DELETE_ON}, got {actual}"
365 ),
366 ))
367 }
368}
369
370fn ensure_temp_store_memory(conn: &Connection) -> DbResult<()> {
375 conn.execute_batch("PRAGMA temp_store = MEMORY;")?;
376 let actual =
377 conn.query_row("PRAGMA temp_store;", &[], |row| Ok(row.column_i64(0)))?;
378 if actual == TEMP_STORE_MEMORY {
379 Ok(())
380 } else {
381 Err(Error::new(
382 -1,
383 format!(
384 "could not ensure PRAGMA temp_store = MEMORY: expected {TEMP_STORE_MEMORY}, got {actual}"
385 ),
386 ))
387 }
388}
389
390pub fn export_plaintext_copy(
405 conn: &Connection,
406 dest_path: &Path,
407 tables: &[&str],
408) -> DbResult<()> {
409 let dest_str = dest_path.to_string_lossy();
410 let attach_sql = format!(
411 "ATTACH DATABASE '{}' AS backup KEY '';",
412 dest_str.replace('\'', "''")
413 );
414 conn.execute_batch(&attach_sql)?;
415
416 let result = (|| {
417 let tx = conn.transaction()?;
418 for table in tables {
419 tx.execute_batch(&format!(
420 "CREATE TABLE backup.{table} AS SELECT * FROM {table};"
421 ))?;
422 }
423 tx.commit()
424 })();
425
426 let detach_result = conn.execute_batch("DETACH DATABASE backup;");
428
429 result?;
430 detach_result?;
431 Ok(())
432}
433
434pub fn import_plaintext_copy(
453 conn: &Connection,
454 source_path: &Path,
455 tables: &[&str],
456) -> DbResult<()> {
457 if !source_path.exists() {
458 return Err(Error::new(
459 -1,
460 format!("backup file does not exist: {}", source_path.display()),
461 ));
462 }
463
464 let source_str = source_path.to_string_lossy();
465 let attach_sql = format!(
466 "ATTACH DATABASE '{}' AS backup KEY '';",
467 source_str.replace('\'', "''")
468 );
469 conn.execute_batch(&attach_sql)?;
470
471 let result = (|| {
475 for table in tables {
476 let count: i64 =
477 conn.query_row(&format!("SELECT COUNT(*) FROM {table}"), &[], |row| {
478 Ok(row.column_i64(0))
479 })?;
480 if count > 0 {
481 return Err(Error::new(
482 -1,
483 format!("cannot import into non-empty table: {table}"),
484 ));
485 }
486 }
487
488 let tx = conn.transaction()?;
492 for table in tables {
493 tx.execute_batch(&format!(
494 "INSERT INTO {table} SELECT * FROM backup.{table};"
495 ))?;
496 }
497 tx.commit()
498 })();
499
500 let detach_result = conn.execute_batch("DETACH DATABASE backup;");
502
503 result?;
504 detach_result?;
505 Ok(())
506}
507
508pub fn integrity_check(conn: &Connection) -> DbResult<bool> {
514 let result = conn.query_row("PRAGMA integrity_check;", &[], |stmt| {
515 Ok(stmt.column_text(0))
516 })?;
517 Ok(result.trim() == "ok")
518}
519
520#[cfg(test)]
521mod tests {
522 use super::{
523 encrypt_or_unlock_fully_encrypted, ensure_cipher, ensure_journal_mode,
524 export_plaintext_copy, import_plaintext_copy, integrity_check,
525 is_plaintext_header_probe_error, open_encrypted, Error, SQLITE_CORRUPT,
526 SQLITE_ERROR,
527 };
528 use crate::params;
529 use crate::test_utils::init_sqlite;
530 use crate::Connection;
531 use secrecy::SecretBox;
532
533 fn open_fully_encrypted(
534 path: &std::path::Path,
535 key: &SecretBox<[u8; 32]>,
536 ) -> crate::DbResult<Connection> {
537 let conn = Connection::open(path, false)?;
538 ensure_cipher(&conn)?;
539 encrypt_or_unlock_fully_encrypted(&conn, key)?;
540 ensure_journal_mode(&conn, "WAL")?;
541 Ok(conn)
542 }
543
544 #[test]
545 fn test_plaintext_header_probe_errors() {
546 assert!(is_plaintext_header_probe_error(&Error::new(
547 SQLITE_ERROR,
548 "unsupported file format",
549 )));
550 assert!(is_plaintext_header_probe_error(&Error::new(
551 SQLITE_CORRUPT,
552 "database disk image is malformed",
553 )));
554 assert!(!is_plaintext_header_probe_error(&Error::new(
555 SQLITE_CORRUPT,
556 "database or disk is full",
557 )));
558 }
559
560 #[test]
561 fn test_cipher_encrypted_round_trip() {
562 init_sqlite();
563 let dir = tempfile::tempdir().expect("create temp dir");
564 let path = dir.path().join("cipher-test.sqlite");
565 let key = SecretBox::init_with(|| [0xABu8; 32]);
566
567 {
569 let conn = open_encrypted(&path, &key).expect("open encrypted");
570 conn.execute_batch(
571 "CREATE TABLE secret (id INTEGER PRIMARY KEY, val TEXT);",
572 )
573 .expect("create table");
574 conn.execute("INSERT INTO secret (id, val) VALUES (1, 'top-secret')", &[])
575 .expect("insert");
576 }
577
578 {
580 let conn = open_encrypted(&path, &key).expect("reopen encrypted");
581 let val = conn
582 .query_row("SELECT val FROM secret WHERE id = 1", &[], |stmt| {
583 Ok(stmt.column_text(0))
584 })
585 .expect("query");
586 assert_eq!(val, "top-secret");
587 }
588
589 {
591 let wrong_key = SecretBox::init_with(|| [0xCDu8; 32]);
592 let result = open_encrypted(&path, &wrong_key);
593 assert!(result.is_err(), "wrong key should fail");
594 }
595 }
596
597 #[test]
598 fn test_plaintext_wal_database_migrates_to_plaintext_header() {
599 init_sqlite();
600 let dir = tempfile::tempdir().expect("create temp dir");
601 let path = dir.path().join("plaintext.sqlite");
602 let key = SecretBox::init_with(|| [0x42u8; 32]);
603
604 {
605 let conn = Connection::open(&path, false).expect("open plaintext");
606 let mode = conn
607 .query_row("PRAGMA journal_mode = WAL", &[], |row| {
608 Ok(row.column_text(0))
609 })
610 .expect("enable plaintext WAL");
611 assert_eq!(mode.to_ascii_lowercase(), "wal");
612 conn.execute_batch(
613 "CREATE TABLE secret (id INTEGER PRIMARY KEY, val TEXT);\
614 INSERT INTO secret VALUES (1, 'preserve-me');",
615 )
616 .expect("write plaintext data");
617 }
618 assert!(
619 std::fs::read(&path)
620 .expect("read plaintext")
621 .starts_with(b"SQLite format 3\0"),
622 "fixture must start as plaintext SQLite"
623 );
624
625 {
626 let conn = open_encrypted(&path, &key).expect("migrate plaintext");
627 let value = conn
628 .query_row("SELECT val FROM secret WHERE id = 1", &[], |row| {
629 Ok(row.column_text(0))
630 })
631 .expect("read migrated data");
632 assert_eq!(value, "preserve-me");
633 }
634
635 let encrypted_bytes = std::fs::read(&path).expect("read encrypted");
636 assert!(
637 encrypted_bytes.starts_with(b"SQLite format 3\0"),
638 "migration must retain the plaintext SQLite header"
639 );
640 assert_eq!(encrypted_bytes[18], 2, "database must use WAL read mode");
641 assert_eq!(encrypted_bytes[19], 2, "database must use WAL write mode");
642 assert_eq!(
643 encrypted_bytes[20], 32,
644 "header must advertise the cipher's reserved bytes"
645 );
646 assert!(
647 !encrypted_bytes
648 .windows("preserve-me".len())
649 .any(|window| window == b"preserve-me"),
650 "database contents must be encrypted"
651 );
652
653 {
654 let conn = open_encrypted(&path, &key).expect("reopen migrated database");
655 let value = conn
656 .query_row("SELECT val FROM secret WHERE id = 1", &[], |row| {
657 Ok(row.column_text(0))
658 })
659 .expect("read migrated data after reopen");
660 assert_eq!(value, "preserve-me");
661 }
662
663 let wrong_key = SecretBox::init_with(|| [0x43u8; 32]);
664 assert!(
665 open_encrypted(&path, &wrong_key).is_err(),
666 "migrated database must reject the wrong key"
667 );
668 assert_eq!(
669 std::fs::read(&path).expect("read after wrong-key open"),
670 encrypted_bytes,
671 "wrong-key open must not modify migrated data"
672 );
673 }
674
675 #[test]
676 fn test_plaintext_header_encrypted_round_trip() {
677 init_sqlite();
678 let dir = tempfile::tempdir().expect("create temp dir");
679 let path = dir.path().join("plaintext-header.sqlite");
680 let key = SecretBox::init_with(|| [0x51_u8; 32]);
681
682 {
683 let conn =
684 open_encrypted(&path, &key).expect("create plaintext-header database");
685 conn.execute_batch(
686 "CREATE TABLE secret (id INTEGER PRIMARY KEY, val TEXT);\
687 INSERT INTO secret VALUES (1, 'visible-header');",
688 )
689 .expect("write encrypted data");
690 }
691
692 let encrypted_bytes = std::fs::read(&path).expect("read encrypted database");
693 assert!(
694 encrypted_bytes.starts_with(b"SQLite format 3\0"),
695 "the SQLite file header must remain visible"
696 );
697 assert_eq!(encrypted_bytes[18], 2, "database must use WAL read mode");
698 assert_eq!(encrypted_bytes[19], 2, "database must use WAL write mode");
699 assert_eq!(
700 encrypted_bytes[20], 32,
701 "header must advertise the cipher's reserved bytes"
702 );
703 assert!(
704 !encrypted_bytes
705 .windows("visible-header".len())
706 .any(|window| window == b"visible-header"),
707 "database contents must remain encrypted"
708 );
709
710 {
711 let conn =
712 open_encrypted(&path, &key).expect("reopen plaintext-header database");
713 let value = conn
714 .query_row("SELECT val FROM secret WHERE id = 1", &[], |row| {
715 Ok(row.column_text(0))
716 })
717 .expect("read encrypted data");
718 assert_eq!(value, "visible-header");
719 }
720
721 let wrong_key = SecretBox::init_with(|| [0x52_u8; 32]);
722 assert!(
723 open_encrypted(&path, &wrong_key).is_err(),
724 "plaintext-header database must reject the wrong key"
725 );
726 assert_eq!(
727 std::fs::read(&path).expect("read after wrong-key open"),
728 encrypted_bytes,
729 "wrong-key open must not modify encrypted data"
730 );
731 }
732
733 #[test]
734 fn test_encrypted_header_database_migrates_to_plaintext_header() {
735 init_sqlite();
736 let dir = tempfile::tempdir().expect("create temp dir");
737 let path = dir.path().join("encrypted-header.sqlite");
738 let key = SecretBox::init_with(|| [0x61_u8; 32]);
739
740 {
741 let conn =
742 open_fully_encrypted(&path, &key).expect("create legacy database");
743 conn.execute_batch(
744 "CREATE TABLE secret (id INTEGER PRIMARY KEY, val TEXT);\
745 INSERT INTO secret VALUES (1, 'preserve-me');",
746 )
747 .expect("write legacy encrypted data");
748 }
749
750 let legacy_bytes = std::fs::read(&path).expect("read legacy database");
751 assert!(
752 !legacy_bytes.starts_with(b"SQLite format 3\0"),
753 "fixture must use the fully-encrypted legacy header"
754 );
755 assert!(
756 !legacy_bytes
757 .windows("preserve-me".len())
758 .any(|window| window == b"preserve-me"),
759 "legacy database contents must be encrypted"
760 );
761
762 let wrong_key = SecretBox::init_with(|| [0x62_u8; 32]);
763 assert!(
764 open_encrypted(&path, &wrong_key).is_err(),
765 "legacy database must reject the wrong key before migration"
766 );
767 assert_eq!(
768 std::fs::read(&path).expect("read legacy database after wrong-key open"),
769 legacy_bytes,
770 "wrong-key open must not migrate or modify the legacy database"
771 );
772
773 {
774 let conn = open_encrypted(&path, &key).expect("migrate legacy database");
775 let value = conn
776 .query_row("SELECT val FROM secret WHERE id = 1", &[], |row| {
777 Ok(row.column_text(0))
778 })
779 .expect("read migrated data");
780 assert_eq!(value, "preserve-me");
781 }
782
783 let migrated_bytes = std::fs::read(&path).expect("read migrated database");
784 assert!(
785 migrated_bytes.starts_with(b"SQLite format 3\0"),
786 "migration must expose the SQLite header"
787 );
788 assert_eq!(migrated_bytes[18], 2, "database must use WAL read mode");
789 assert_eq!(migrated_bytes[19], 2, "database must use WAL write mode");
790 assert_eq!(
791 migrated_bytes[20], 32,
792 "header must advertise the cipher's reserved bytes"
793 );
794 assert_ne!(
795 migrated_bytes, legacy_bytes,
796 "migration must rewrite the encrypted on-disk format"
797 );
798 assert!(
799 !migrated_bytes
800 .windows("preserve-me".len())
801 .any(|window| window == b"preserve-me"),
802 "migrated database contents must remain encrypted"
803 );
804
805 {
806 let conn = open_encrypted(&path, &key).expect("reopen migrated database");
807 let value = conn
808 .query_row("SELECT val FROM secret WHERE id = 1", &[], |row| {
809 Ok(row.column_text(0))
810 })
811 .expect("read migrated data after reopen");
812 assert_eq!(value, "preserve-me");
813 }
814
815 assert!(
816 open_encrypted(&path, &wrong_key).is_err(),
817 "migrated database must reject the wrong key"
818 );
819 assert_eq!(
820 std::fs::read(&path).expect("read migrated database after wrong-key open"),
821 migrated_bytes,
822 "wrong-key open must not modify migrated data"
823 );
824 }
825
826 #[test]
827 fn test_integrity_check() {
828 init_sqlite();
829 let conn = Connection::open_in_memory().expect("open in-memory db");
830 let ok = integrity_check(&conn).expect("check");
831 assert!(ok);
832 }
833
834 #[test]
835 fn test_cipher_plaintext_export_import_roundtrip() {
836 init_sqlite();
837 let dir = tempfile::tempdir().expect("create temp dir");
838 let src_path = dir.path().join("source.sqlite");
839 let dest_path = dir.path().join("backup.plain.sqlite");
840 let restore_path = dir.path().join("restore.sqlite");
841 let key = SecretBox::init_with(|| [0x11u8; 32]);
842
843 {
844 let conn = open_encrypted(&src_path, &key).expect("open src");
845 conn.execute_batch(
846 "CREATE TABLE widgets (id INTEGER PRIMARY KEY, val TEXT NOT NULL);",
847 )
848 .expect("create table");
849 conn.execute(
850 "INSERT INTO widgets (id, val) VALUES (?1, ?2)",
851 params![1_i64, "alpha"],
852 )
853 .expect("insert");
854 conn.execute(
855 "INSERT INTO widgets (id, val) VALUES (?1, ?2)",
856 params![2_i64, "beta"],
857 )
858 .expect("insert");
859
860 export_plaintext_copy(&conn, &dest_path, &["widgets"]).expect("export");
861 }
862
863 {
864 let conn = open_encrypted(&restore_path, &key).expect("open restore");
865 conn.execute_batch(
866 "CREATE TABLE widgets (id INTEGER PRIMARY KEY, val TEXT NOT NULL);",
867 )
868 .expect("create table");
869 import_plaintext_copy(&conn, &dest_path, &["widgets"]).expect("import");
870
871 let count: i64 = conn
872 .query_row("SELECT COUNT(*) FROM widgets", &[], |row| {
873 Ok(row.column_i64(0))
874 })
875 .expect("count");
876 assert_eq!(count, 2);
877
878 let val = conn
879 .query_row("SELECT val FROM widgets WHERE id = 2", &[], |row| {
880 Ok(row.column_text(0))
881 })
882 .expect("query");
883 assert_eq!(val, "beta");
884 }
885 }
886
887 #[test]
888 fn test_cipher_import_rejects_non_empty_destination() {
889 init_sqlite();
890 let dir = tempfile::tempdir().expect("create temp dir");
891 let src_path = dir.path().join("source.sqlite");
892 let dest_path = dir.path().join("backup.plain.sqlite");
893 let restore_path = dir.path().join("restore.sqlite");
894 let key = SecretBox::init_with(|| [0x22u8; 32]);
895
896 {
897 let conn = open_encrypted(&src_path, &key).expect("open src");
898 conn.execute_batch(
899 "CREATE TABLE widgets (id INTEGER PRIMARY KEY, val TEXT NOT NULL);",
900 )
901 .expect("create table");
902 conn.execute(
903 "INSERT INTO widgets (id, val) VALUES (?1, ?2)",
904 params![1_i64, "alpha"],
905 )
906 .expect("insert");
907 export_plaintext_copy(&conn, &dest_path, &["widgets"]).expect("export");
908 }
909
910 let conn = open_encrypted(&restore_path, &key).expect("open restore");
911 conn.execute_batch(
912 "CREATE TABLE widgets (id INTEGER PRIMARY KEY, val TEXT NOT NULL);",
913 )
914 .expect("create table");
915 conn.execute(
916 "INSERT INTO widgets (id, val) VALUES (?1, ?2)",
917 params![99_i64, "preexisting"],
918 )
919 .expect("insert");
920
921 let err = import_plaintext_copy(&conn, &dest_path, &["widgets"])
922 .expect_err("import should refuse non-empty destination");
923 assert!(
924 err.to_string().contains("non-empty table"),
925 "expected non-empty-table error, got: {err}"
926 );
927 }
928}