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
370pub fn ensure_temp_store_memory(conn: &Connection) -> DbResult<()> {
378 conn.execute_batch("PRAGMA temp_store = MEMORY;")?;
379 let actual =
380 conn.query_row("PRAGMA temp_store;", &[], |row| Ok(row.column_i64(0)))?;
381 if actual == TEMP_STORE_MEMORY {
382 Ok(())
383 } else {
384 Err(Error::new(
385 -1,
386 format!(
387 "could not ensure PRAGMA temp_store = MEMORY: expected {TEMP_STORE_MEMORY}, got {actual}"
388 ),
389 ))
390 }
391}
392
393pub fn export_plaintext_copy(
408 conn: &Connection,
409 dest_path: &Path,
410 tables: &[&str],
411) -> DbResult<()> {
412 let dest_str = dest_path.to_string_lossy();
413 let attach_sql = format!(
414 "ATTACH DATABASE '{}' AS backup KEY '';",
415 dest_str.replace('\'', "''")
416 );
417 conn.execute_batch(&attach_sql)?;
418
419 let result = (|| {
420 let tx = conn.transaction()?;
421 for table in tables {
422 tx.execute_batch(&format!(
423 "CREATE TABLE backup.{table} AS SELECT * FROM {table};"
424 ))?;
425 }
426 tx.commit()
427 })();
428
429 let detach_result = conn.execute_batch("DETACH DATABASE backup;");
431
432 result?;
433 detach_result?;
434 Ok(())
435}
436
437pub fn import_plaintext_copy(
456 conn: &Connection,
457 source_path: &Path,
458 tables: &[&str],
459) -> DbResult<()> {
460 if !source_path.exists() {
461 return Err(Error::new(
462 -1,
463 format!("backup file does not exist: {}", source_path.display()),
464 ));
465 }
466
467 let source_str = source_path.to_string_lossy();
468 let attach_sql = format!(
469 "ATTACH DATABASE '{}' AS backup KEY '';",
470 source_str.replace('\'', "''")
471 );
472 conn.execute_batch(&attach_sql)?;
473
474 let result = (|| {
478 for table in tables {
479 let count: i64 =
480 conn.query_row(&format!("SELECT COUNT(*) FROM {table}"), &[], |row| {
481 Ok(row.column_i64(0))
482 })?;
483 if count > 0 {
484 return Err(Error::new(
485 -1,
486 format!("cannot import into non-empty table: {table}"),
487 ));
488 }
489 }
490
491 let tx = conn.transaction()?;
495 for table in tables {
496 tx.execute_batch(&format!(
497 "INSERT INTO {table} SELECT * FROM backup.{table};"
498 ))?;
499 }
500 tx.commit()
501 })();
502
503 let detach_result = conn.execute_batch("DETACH DATABASE backup;");
505
506 result?;
507 detach_result?;
508 Ok(())
509}
510
511pub fn integrity_check(conn: &Connection) -> DbResult<bool> {
517 let result = conn.query_row("PRAGMA integrity_check;", &[], |stmt| {
518 Ok(stmt.column_text(0))
519 })?;
520 Ok(result.trim() == "ok")
521}
522
523#[cfg(test)]
527pub(crate) const LEGACY_FIXTURE_KEY: [u8; 32] = [0x61; 32];
528
529#[cfg(test)]
535pub(crate) const LEGACY_ENCRYPTED_HEADER_FIXTURE: &[u8] =
536 include_bytes!("../tests/fixtures/legacy_encrypted_header.sqlite");
537
538#[cfg(test)]
544pub(crate) fn open_fully_encrypted(
545 path: &Path,
546 key: &SecretBox<[u8; 32]>,
547) -> DbResult<Connection> {
548 #[cfg(not(target_arch = "wasm32"))]
549 let conn = Connection::open(path, false)?;
550 #[cfg(target_arch = "wasm32")]
551 let conn = Connection::open_with_opfs_vfs(path, false)?;
552 ensure_cipher(&conn)?;
553 encrypt_or_unlock_fully_encrypted(&conn, key)?;
554
555 #[cfg(not(target_arch = "wasm32"))]
556 ensure_journal_mode(&conn, "WAL")?;
557 #[cfg(target_arch = "wasm32")]
558 ensure_journal_mode(&conn, "DELETE")?;
559 Ok(conn)
560}
561
562#[cfg(test)]
563mod tests {
564 use super::{
565 ensure_temp_store_memory, export_plaintext_copy, import_plaintext_copy,
566 integrity_check, is_plaintext_header_probe_error, open_encrypted,
567 open_fully_encrypted, Error, LEGACY_ENCRYPTED_HEADER_FIXTURE,
568 LEGACY_FIXTURE_KEY, SQLITE_CORRUPT, SQLITE_ERROR,
569 };
570 use crate::params;
571 use crate::test_utils::init_sqlite;
572 use crate::Connection;
573 use secrecy::SecretBox;
574
575 #[test]
576 fn test_temp_store_memory_overrides_file_storage() {
577 init_sqlite();
578 let conn = Connection::open_in_memory().unwrap();
579 conn.execute_batch("PRAGMA temp_store = FILE;").unwrap();
580 ensure_temp_store_memory(&conn).unwrap();
581 let temp_store = conn
582 .query_row("PRAGMA temp_store;", &[], |row| Ok(row.column_i64(0)))
583 .unwrap();
584 assert_eq!(temp_store, 2);
585 }
586
587 #[test]
588 fn test_plaintext_header_probe_errors() {
589 assert!(is_plaintext_header_probe_error(&Error::new(
590 SQLITE_ERROR,
591 "unsupported file format",
592 )));
593 assert!(is_plaintext_header_probe_error(&Error::new(
594 SQLITE_CORRUPT,
595 "database disk image is malformed",
596 )));
597 assert!(!is_plaintext_header_probe_error(&Error::new(
598 SQLITE_CORRUPT,
599 "database or disk is full",
600 )));
601 }
602
603 #[test]
604 fn test_cipher_encrypted_round_trip() {
605 init_sqlite();
606 let dir = tempfile::tempdir().expect("create temp dir");
607 let path = dir.path().join("cipher-test.sqlite");
608 let key = SecretBox::init_with(|| [0xABu8; 32]);
609
610 {
612 let conn = open_encrypted(&path, &key).expect("open encrypted");
613 conn.execute_batch(
614 "CREATE TABLE secret (id INTEGER PRIMARY KEY, val TEXT);",
615 )
616 .expect("create table");
617 conn.execute("INSERT INTO secret (id, val) VALUES (1, 'top-secret')", &[])
618 .expect("insert");
619 }
620
621 {
623 let conn = open_encrypted(&path, &key).expect("reopen encrypted");
624 let val = conn
625 .query_row("SELECT val FROM secret WHERE id = 1", &[], |stmt| {
626 Ok(stmt.column_text(0))
627 })
628 .expect("query");
629 assert_eq!(val, "top-secret");
630 }
631
632 {
634 let wrong_key = SecretBox::init_with(|| [0xCDu8; 32]);
635 let result = open_encrypted(&path, &wrong_key);
636 assert!(result.is_err(), "wrong key should fail");
637 }
638 }
639
640 #[test]
641 fn test_plaintext_wal_database_migrates_to_plaintext_header() {
642 init_sqlite();
643 let dir = tempfile::tempdir().expect("create temp dir");
644 let path = dir.path().join("plaintext.sqlite");
645 let key = SecretBox::init_with(|| [0x42u8; 32]);
646
647 {
648 let conn = Connection::open(&path, false).expect("open plaintext");
649 let mode = conn
650 .query_row("PRAGMA journal_mode = WAL", &[], |row| {
651 Ok(row.column_text(0))
652 })
653 .expect("enable plaintext WAL");
654 assert_eq!(mode.to_ascii_lowercase(), "wal");
655 conn.execute_batch(
656 "CREATE TABLE secret (id INTEGER PRIMARY KEY, val TEXT);\
657 INSERT INTO secret VALUES (1, 'preserve-me');",
658 )
659 .expect("write plaintext data");
660 }
661 assert!(
662 std::fs::read(&path)
663 .expect("read plaintext")
664 .starts_with(b"SQLite format 3\0"),
665 "fixture must start as plaintext SQLite"
666 );
667
668 {
669 let conn = open_encrypted(&path, &key).expect("migrate plaintext");
670 let value = conn
671 .query_row("SELECT val FROM secret WHERE id = 1", &[], |row| {
672 Ok(row.column_text(0))
673 })
674 .expect("read migrated data");
675 assert_eq!(value, "preserve-me");
676 }
677
678 let encrypted_bytes = std::fs::read(&path).expect("read encrypted");
679 assert!(
680 encrypted_bytes.starts_with(b"SQLite format 3\0"),
681 "migration must retain the plaintext SQLite header"
682 );
683 assert_eq!(encrypted_bytes[18], 2, "database must use WAL read mode");
684 assert_eq!(encrypted_bytes[19], 2, "database must use WAL write mode");
685 assert_eq!(
686 encrypted_bytes[20], 32,
687 "header must advertise the cipher's reserved bytes"
688 );
689 assert!(
690 !encrypted_bytes
691 .windows("preserve-me".len())
692 .any(|window| window == b"preserve-me"),
693 "database contents must be encrypted"
694 );
695
696 {
697 let conn = open_encrypted(&path, &key).expect("reopen migrated database");
698 let value = conn
699 .query_row("SELECT val FROM secret WHERE id = 1", &[], |row| {
700 Ok(row.column_text(0))
701 })
702 .expect("read migrated data after reopen");
703 assert_eq!(value, "preserve-me");
704 }
705
706 let wrong_key = SecretBox::init_with(|| [0x43u8; 32]);
707 assert!(
708 open_encrypted(&path, &wrong_key).is_err(),
709 "migrated database must reject the wrong key"
710 );
711 assert_eq!(
712 std::fs::read(&path).expect("read after wrong-key open"),
713 encrypted_bytes,
714 "wrong-key open must not modify migrated data"
715 );
716 }
717
718 #[test]
719 fn test_plaintext_header_encrypted_round_trip() {
720 init_sqlite();
721 let dir = tempfile::tempdir().expect("create temp dir");
722 let path = dir.path().join("plaintext-header.sqlite");
723 let key = SecretBox::init_with(|| [0x51_u8; 32]);
724
725 {
726 let conn =
727 open_encrypted(&path, &key).expect("create plaintext-header database");
728 conn.execute_batch(
729 "CREATE TABLE secret (id INTEGER PRIMARY KEY, val TEXT);\
730 INSERT INTO secret VALUES (1, 'visible-header');",
731 )
732 .expect("write encrypted data");
733 }
734
735 let encrypted_bytes = std::fs::read(&path).expect("read encrypted database");
736 assert!(
737 encrypted_bytes.starts_with(b"SQLite format 3\0"),
738 "the SQLite file header must remain visible"
739 );
740 assert_eq!(encrypted_bytes[18], 2, "database must use WAL read mode");
741 assert_eq!(encrypted_bytes[19], 2, "database must use WAL write mode");
742 assert_eq!(
743 encrypted_bytes[20], 32,
744 "header must advertise the cipher's reserved bytes"
745 );
746 assert!(
747 !encrypted_bytes
748 .windows("visible-header".len())
749 .any(|window| window == b"visible-header"),
750 "database contents must remain encrypted"
751 );
752
753 {
754 let conn =
755 open_encrypted(&path, &key).expect("reopen plaintext-header database");
756 let value = conn
757 .query_row("SELECT val FROM secret WHERE id = 1", &[], |row| {
758 Ok(row.column_text(0))
759 })
760 .expect("read encrypted data");
761 assert_eq!(value, "visible-header");
762 }
763
764 let wrong_key = SecretBox::init_with(|| [0x52_u8; 32]);
765 assert!(
766 open_encrypted(&path, &wrong_key).is_err(),
767 "plaintext-header database must reject the wrong key"
768 );
769 assert_eq!(
770 std::fs::read(&path).expect("read after wrong-key open"),
771 encrypted_bytes,
772 "wrong-key open must not modify encrypted data"
773 );
774 }
775
776 #[test]
777 #[ignore = "regenerates the checked-in legacy migration fixture"]
778 fn regenerate_legacy_encrypted_header_fixture() {
779 init_sqlite();
780 let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
781 .join("tests/fixtures/legacy_encrypted_header.sqlite");
782 std::fs::create_dir_all(path.parent().expect("fixture has a parent"))
783 .expect("create fixtures directory");
784 let _ = std::fs::remove_file(&path);
785 let key = SecretBox::init_with(|| LEGACY_FIXTURE_KEY);
786
787 {
788 let conn =
789 open_fully_encrypted(&path, &key).expect("create legacy fixture");
790 conn.execute_batch(
791 "CREATE TABLE secret (id INTEGER PRIMARY KEY, val TEXT);\
792 INSERT INTO secret VALUES (1, 'preserve-me');",
793 )
794 .expect("write legacy fixture");
795 }
796
797 let bytes = std::fs::read(&path).expect("read regenerated fixture");
798 assert!(
799 !bytes.starts_with(b"SQLite format 3\0"),
800 "regenerated fixture must use the fully-encrypted legacy header"
801 );
802 }
803
804 #[test]
805 fn test_frozen_legacy_encrypted_header_database_migrates() {
806 init_sqlite();
807 let dir = tempfile::tempdir().expect("create temp dir");
808 let path = dir.path().join("legacy-encrypted-header.sqlite");
809 let key = SecretBox::init_with(|| LEGACY_FIXTURE_KEY);
810 std::fs::write(&path, LEGACY_ENCRYPTED_HEADER_FIXTURE)
811 .expect("write fixture copy");
812
813 assert!(
814 !LEGACY_ENCRYPTED_HEADER_FIXTURE.starts_with(b"SQLite format 3\0"),
815 "fixture must use the fully-encrypted legacy header"
816 );
817 assert!(
818 !LEGACY_ENCRYPTED_HEADER_FIXTURE
819 .windows("preserve-me".len())
820 .any(|window| window == b"preserve-me"),
821 "fixture contents must be encrypted"
822 );
823
824 let wrong_key = SecretBox::init_with(|| [0x62u8; 32]);
825 assert!(
826 open_encrypted(&path, &wrong_key).is_err(),
827 "legacy fixture must reject the wrong key before migration"
828 );
829 assert_eq!(
830 std::fs::read(&path)
831 .expect("read fixture after wrong-key open")
832 .as_slice(),
833 LEGACY_ENCRYPTED_HEADER_FIXTURE,
834 "wrong-key open must not migrate or modify the frozen fixture"
835 );
836
837 {
838 let conn = open_encrypted(&path, &key).expect("migrate legacy fixture");
839 let value = conn
840 .query_row("SELECT val FROM secret WHERE id = 1", &[], |row| {
841 Ok(row.column_text(0))
842 })
843 .expect("read migrated data");
844 assert_eq!(value, "preserve-me");
845 }
846
847 let migrated_bytes = std::fs::read(&path).expect("read migrated database");
848 assert!(
849 migrated_bytes.starts_with(b"SQLite format 3\0"),
850 "migration must expose the SQLite header"
851 );
852 assert_eq!(migrated_bytes[18], 2, "database must use WAL read mode");
853 assert_eq!(migrated_bytes[19], 2, "database must use WAL write mode");
854 assert_eq!(
855 migrated_bytes[20], 32,
856 "header must advertise the cipher's reserved bytes"
857 );
858 assert_ne!(
859 migrated_bytes.as_slice(),
860 LEGACY_ENCRYPTED_HEADER_FIXTURE,
861 "migration must rewrite the encrypted on-disk format"
862 );
863 assert!(
864 !migrated_bytes
865 .windows("preserve-me".len())
866 .any(|window| window == b"preserve-me"),
867 "migrated database contents must remain encrypted"
868 );
869
870 {
871 let conn = open_encrypted(&path, &key).expect("reopen migrated database");
872 let value = conn
873 .query_row("SELECT val FROM secret WHERE id = 1", &[], |row| {
874 Ok(row.column_text(0))
875 })
876 .expect("read migrated data after reopen");
877 assert_eq!(value, "preserve-me");
878 }
879
880 assert!(
881 open_encrypted(&path, &wrong_key).is_err(),
882 "migrated database must reject the wrong key"
883 );
884 assert_eq!(
885 std::fs::read(&path).expect("read migrated database after wrong-key open"),
886 migrated_bytes,
887 "wrong-key open must not modify migrated data"
888 );
889 }
890
891 #[test]
892 fn test_integrity_check() {
893 init_sqlite();
894 let conn = Connection::open_in_memory().expect("open in-memory db");
895 let ok = integrity_check(&conn).expect("check");
896 assert!(ok);
897 }
898
899 #[test]
900 fn test_cipher_plaintext_export_import_roundtrip() {
901 init_sqlite();
902 let dir = tempfile::tempdir().expect("create temp dir");
903 let src_path = dir.path().join("source.sqlite");
904 let dest_path = dir.path().join("backup.plain.sqlite");
905 let restore_path = dir.path().join("restore.sqlite");
906 let key = SecretBox::init_with(|| [0x11u8; 32]);
907
908 {
909 let conn = open_encrypted(&src_path, &key).expect("open src");
910 conn.execute_batch(
911 "CREATE TABLE widgets (id INTEGER PRIMARY KEY, val TEXT NOT NULL);",
912 )
913 .expect("create table");
914 conn.execute(
915 "INSERT INTO widgets (id, val) VALUES (?1, ?2)",
916 params![1_i64, "alpha"],
917 )
918 .expect("insert");
919 conn.execute(
920 "INSERT INTO widgets (id, val) VALUES (?1, ?2)",
921 params![2_i64, "beta"],
922 )
923 .expect("insert");
924
925 export_plaintext_copy(&conn, &dest_path, &["widgets"]).expect("export");
926 }
927
928 {
929 let conn = open_encrypted(&restore_path, &key).expect("open restore");
930 conn.execute_batch(
931 "CREATE TABLE widgets (id INTEGER PRIMARY KEY, val TEXT NOT NULL);",
932 )
933 .expect("create table");
934 import_plaintext_copy(&conn, &dest_path, &["widgets"]).expect("import");
935
936 let count: i64 = conn
937 .query_row("SELECT COUNT(*) FROM widgets", &[], |row| {
938 Ok(row.column_i64(0))
939 })
940 .expect("count");
941 assert_eq!(count, 2);
942
943 let val = conn
944 .query_row("SELECT val FROM widgets WHERE id = 2", &[], |row| {
945 Ok(row.column_text(0))
946 })
947 .expect("query");
948 assert_eq!(val, "beta");
949 }
950 }
951
952 #[test]
953 fn test_cipher_import_rejects_non_empty_destination() {
954 init_sqlite();
955 let dir = tempfile::tempdir().expect("create temp dir");
956 let src_path = dir.path().join("source.sqlite");
957 let dest_path = dir.path().join("backup.plain.sqlite");
958 let restore_path = dir.path().join("restore.sqlite");
959 let key = SecretBox::init_with(|| [0x22u8; 32]);
960
961 {
962 let conn = open_encrypted(&src_path, &key).expect("open src");
963 conn.execute_batch(
964 "CREATE TABLE widgets (id INTEGER PRIMARY KEY, val TEXT NOT NULL);",
965 )
966 .expect("create table");
967 conn.execute(
968 "INSERT INTO widgets (id, val) VALUES (?1, ?2)",
969 params![1_i64, "alpha"],
970 )
971 .expect("insert");
972 export_plaintext_copy(&conn, &dest_path, &["widgets"]).expect("export");
973 }
974
975 let conn = open_encrypted(&restore_path, &key).expect("open restore");
976 conn.execute_batch(
977 "CREATE TABLE widgets (id INTEGER PRIMARY KEY, val TEXT NOT NULL);",
978 )
979 .expect("create table");
980 conn.execute(
981 "INSERT INTO widgets (id, val) VALUES (?1, ?2)",
982 params![99_i64, "preexisting"],
983 )
984 .expect("insert");
985
986 let err = import_plaintext_copy(&conn, &dest_path, &["widgets"])
987 .expect_err("import should refuse non-empty destination");
988 assert!(
989 err.to_string().contains("non-empty table"),
990 "expected non-empty-table error, got: {err}"
991 );
992 }
993}