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)]
524pub(crate) const LEGACY_FIXTURE_KEY: [u8; 32] = [0x61; 32];
525
526#[cfg(test)]
532pub(crate) const LEGACY_ENCRYPTED_HEADER_FIXTURE: &[u8] =
533 include_bytes!("../tests/fixtures/legacy_encrypted_header.sqlite");
534
535#[cfg(test)]
541pub(crate) fn open_fully_encrypted(
542 path: &Path,
543 key: &SecretBox<[u8; 32]>,
544) -> DbResult<Connection> {
545 #[cfg(not(target_arch = "wasm32"))]
546 let conn = Connection::open(path, false)?;
547 #[cfg(target_arch = "wasm32")]
548 let conn = Connection::open_with_opfs_vfs(path, false)?;
549 ensure_cipher(&conn)?;
550 encrypt_or_unlock_fully_encrypted(&conn, key)?;
551
552 #[cfg(not(target_arch = "wasm32"))]
553 ensure_journal_mode(&conn, "WAL")?;
554 #[cfg(target_arch = "wasm32")]
555 ensure_journal_mode(&conn, "DELETE")?;
556 Ok(conn)
557}
558
559#[cfg(test)]
560mod tests {
561 use super::{
562 export_plaintext_copy, import_plaintext_copy, integrity_check,
563 is_plaintext_header_probe_error, open_encrypted, open_fully_encrypted, Error,
564 LEGACY_ENCRYPTED_HEADER_FIXTURE, LEGACY_FIXTURE_KEY, SQLITE_CORRUPT,
565 SQLITE_ERROR,
566 };
567 use crate::params;
568 use crate::test_utils::init_sqlite;
569 use crate::Connection;
570 use secrecy::SecretBox;
571
572 #[test]
573 fn test_plaintext_header_probe_errors() {
574 assert!(is_plaintext_header_probe_error(&Error::new(
575 SQLITE_ERROR,
576 "unsupported file format",
577 )));
578 assert!(is_plaintext_header_probe_error(&Error::new(
579 SQLITE_CORRUPT,
580 "database disk image is malformed",
581 )));
582 assert!(!is_plaintext_header_probe_error(&Error::new(
583 SQLITE_CORRUPT,
584 "database or disk is full",
585 )));
586 }
587
588 #[test]
589 fn test_cipher_encrypted_round_trip() {
590 init_sqlite();
591 let dir = tempfile::tempdir().expect("create temp dir");
592 let path = dir.path().join("cipher-test.sqlite");
593 let key = SecretBox::init_with(|| [0xABu8; 32]);
594
595 {
597 let conn = open_encrypted(&path, &key).expect("open encrypted");
598 conn.execute_batch(
599 "CREATE TABLE secret (id INTEGER PRIMARY KEY, val TEXT);",
600 )
601 .expect("create table");
602 conn.execute("INSERT INTO secret (id, val) VALUES (1, 'top-secret')", &[])
603 .expect("insert");
604 }
605
606 {
608 let conn = open_encrypted(&path, &key).expect("reopen encrypted");
609 let val = conn
610 .query_row("SELECT val FROM secret WHERE id = 1", &[], |stmt| {
611 Ok(stmt.column_text(0))
612 })
613 .expect("query");
614 assert_eq!(val, "top-secret");
615 }
616
617 {
619 let wrong_key = SecretBox::init_with(|| [0xCDu8; 32]);
620 let result = open_encrypted(&path, &wrong_key);
621 assert!(result.is_err(), "wrong key should fail");
622 }
623 }
624
625 #[test]
626 fn test_plaintext_wal_database_migrates_to_plaintext_header() {
627 init_sqlite();
628 let dir = tempfile::tempdir().expect("create temp dir");
629 let path = dir.path().join("plaintext.sqlite");
630 let key = SecretBox::init_with(|| [0x42u8; 32]);
631
632 {
633 let conn = Connection::open(&path, false).expect("open plaintext");
634 let mode = conn
635 .query_row("PRAGMA journal_mode = WAL", &[], |row| {
636 Ok(row.column_text(0))
637 })
638 .expect("enable plaintext WAL");
639 assert_eq!(mode.to_ascii_lowercase(), "wal");
640 conn.execute_batch(
641 "CREATE TABLE secret (id INTEGER PRIMARY KEY, val TEXT);\
642 INSERT INTO secret VALUES (1, 'preserve-me');",
643 )
644 .expect("write plaintext data");
645 }
646 assert!(
647 std::fs::read(&path)
648 .expect("read plaintext")
649 .starts_with(b"SQLite format 3\0"),
650 "fixture must start as plaintext SQLite"
651 );
652
653 {
654 let conn = open_encrypted(&path, &key).expect("migrate plaintext");
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");
660 assert_eq!(value, "preserve-me");
661 }
662
663 let encrypted_bytes = std::fs::read(&path).expect("read encrypted");
664 assert!(
665 encrypted_bytes.starts_with(b"SQLite format 3\0"),
666 "migration must retain the plaintext SQLite header"
667 );
668 assert_eq!(encrypted_bytes[18], 2, "database must use WAL read mode");
669 assert_eq!(encrypted_bytes[19], 2, "database must use WAL write mode");
670 assert_eq!(
671 encrypted_bytes[20], 32,
672 "header must advertise the cipher's reserved bytes"
673 );
674 assert!(
675 !encrypted_bytes
676 .windows("preserve-me".len())
677 .any(|window| window == b"preserve-me"),
678 "database contents must be encrypted"
679 );
680
681 {
682 let conn = open_encrypted(&path, &key).expect("reopen migrated database");
683 let value = conn
684 .query_row("SELECT val FROM secret WHERE id = 1", &[], |row| {
685 Ok(row.column_text(0))
686 })
687 .expect("read migrated data after reopen");
688 assert_eq!(value, "preserve-me");
689 }
690
691 let wrong_key = SecretBox::init_with(|| [0x43u8; 32]);
692 assert!(
693 open_encrypted(&path, &wrong_key).is_err(),
694 "migrated database must reject the wrong key"
695 );
696 assert_eq!(
697 std::fs::read(&path).expect("read after wrong-key open"),
698 encrypted_bytes,
699 "wrong-key open must not modify migrated data"
700 );
701 }
702
703 #[test]
704 fn test_plaintext_header_encrypted_round_trip() {
705 init_sqlite();
706 let dir = tempfile::tempdir().expect("create temp dir");
707 let path = dir.path().join("plaintext-header.sqlite");
708 let key = SecretBox::init_with(|| [0x51_u8; 32]);
709
710 {
711 let conn =
712 open_encrypted(&path, &key).expect("create plaintext-header database");
713 conn.execute_batch(
714 "CREATE TABLE secret (id INTEGER PRIMARY KEY, val TEXT);\
715 INSERT INTO secret VALUES (1, 'visible-header');",
716 )
717 .expect("write encrypted data");
718 }
719
720 let encrypted_bytes = std::fs::read(&path).expect("read encrypted database");
721 assert!(
722 encrypted_bytes.starts_with(b"SQLite format 3\0"),
723 "the SQLite file header must remain visible"
724 );
725 assert_eq!(encrypted_bytes[18], 2, "database must use WAL read mode");
726 assert_eq!(encrypted_bytes[19], 2, "database must use WAL write mode");
727 assert_eq!(
728 encrypted_bytes[20], 32,
729 "header must advertise the cipher's reserved bytes"
730 );
731 assert!(
732 !encrypted_bytes
733 .windows("visible-header".len())
734 .any(|window| window == b"visible-header"),
735 "database contents must remain encrypted"
736 );
737
738 {
739 let conn =
740 open_encrypted(&path, &key).expect("reopen plaintext-header database");
741 let value = conn
742 .query_row("SELECT val FROM secret WHERE id = 1", &[], |row| {
743 Ok(row.column_text(0))
744 })
745 .expect("read encrypted data");
746 assert_eq!(value, "visible-header");
747 }
748
749 let wrong_key = SecretBox::init_with(|| [0x52_u8; 32]);
750 assert!(
751 open_encrypted(&path, &wrong_key).is_err(),
752 "plaintext-header database must reject the wrong key"
753 );
754 assert_eq!(
755 std::fs::read(&path).expect("read after wrong-key open"),
756 encrypted_bytes,
757 "wrong-key open must not modify encrypted data"
758 );
759 }
760
761 #[test]
762 #[ignore = "regenerates the checked-in legacy migration fixture"]
763 fn regenerate_legacy_encrypted_header_fixture() {
764 init_sqlite();
765 let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
766 .join("tests/fixtures/legacy_encrypted_header.sqlite");
767 std::fs::create_dir_all(path.parent().expect("fixture has a parent"))
768 .expect("create fixtures directory");
769 let _ = std::fs::remove_file(&path);
770 let key = SecretBox::init_with(|| LEGACY_FIXTURE_KEY);
771
772 {
773 let conn =
774 open_fully_encrypted(&path, &key).expect("create legacy fixture");
775 conn.execute_batch(
776 "CREATE TABLE secret (id INTEGER PRIMARY KEY, val TEXT);\
777 INSERT INTO secret VALUES (1, 'preserve-me');",
778 )
779 .expect("write legacy fixture");
780 }
781
782 let bytes = std::fs::read(&path).expect("read regenerated fixture");
783 assert!(
784 !bytes.starts_with(b"SQLite format 3\0"),
785 "regenerated fixture must use the fully-encrypted legacy header"
786 );
787 }
788
789 #[test]
790 fn test_frozen_legacy_encrypted_header_database_migrates() {
791 init_sqlite();
792 let dir = tempfile::tempdir().expect("create temp dir");
793 let path = dir.path().join("legacy-encrypted-header.sqlite");
794 let key = SecretBox::init_with(|| LEGACY_FIXTURE_KEY);
795 std::fs::write(&path, LEGACY_ENCRYPTED_HEADER_FIXTURE)
796 .expect("write fixture copy");
797
798 assert!(
799 !LEGACY_ENCRYPTED_HEADER_FIXTURE.starts_with(b"SQLite format 3\0"),
800 "fixture must use the fully-encrypted legacy header"
801 );
802 assert!(
803 !LEGACY_ENCRYPTED_HEADER_FIXTURE
804 .windows("preserve-me".len())
805 .any(|window| window == b"preserve-me"),
806 "fixture contents must be encrypted"
807 );
808
809 let wrong_key = SecretBox::init_with(|| [0x62u8; 32]);
810 assert!(
811 open_encrypted(&path, &wrong_key).is_err(),
812 "legacy fixture must reject the wrong key before migration"
813 );
814 assert_eq!(
815 std::fs::read(&path)
816 .expect("read fixture after wrong-key open")
817 .as_slice(),
818 LEGACY_ENCRYPTED_HEADER_FIXTURE,
819 "wrong-key open must not migrate or modify the frozen fixture"
820 );
821
822 {
823 let conn = open_encrypted(&path, &key).expect("migrate legacy fixture");
824 let value = conn
825 .query_row("SELECT val FROM secret WHERE id = 1", &[], |row| {
826 Ok(row.column_text(0))
827 })
828 .expect("read migrated data");
829 assert_eq!(value, "preserve-me");
830 }
831
832 let migrated_bytes = std::fs::read(&path).expect("read migrated database");
833 assert!(
834 migrated_bytes.starts_with(b"SQLite format 3\0"),
835 "migration must expose the SQLite header"
836 );
837 assert_eq!(migrated_bytes[18], 2, "database must use WAL read mode");
838 assert_eq!(migrated_bytes[19], 2, "database must use WAL write mode");
839 assert_eq!(
840 migrated_bytes[20], 32,
841 "header must advertise the cipher's reserved bytes"
842 );
843 assert_ne!(
844 migrated_bytes.as_slice(),
845 LEGACY_ENCRYPTED_HEADER_FIXTURE,
846 "migration must rewrite the encrypted on-disk format"
847 );
848 assert!(
849 !migrated_bytes
850 .windows("preserve-me".len())
851 .any(|window| window == b"preserve-me"),
852 "migrated database contents must remain encrypted"
853 );
854
855 {
856 let conn = open_encrypted(&path, &key).expect("reopen migrated database");
857 let value = conn
858 .query_row("SELECT val FROM secret WHERE id = 1", &[], |row| {
859 Ok(row.column_text(0))
860 })
861 .expect("read migrated data after reopen");
862 assert_eq!(value, "preserve-me");
863 }
864
865 assert!(
866 open_encrypted(&path, &wrong_key).is_err(),
867 "migrated database must reject the wrong key"
868 );
869 assert_eq!(
870 std::fs::read(&path).expect("read migrated database after wrong-key open"),
871 migrated_bytes,
872 "wrong-key open must not modify migrated data"
873 );
874 }
875
876 #[test]
877 fn test_integrity_check() {
878 init_sqlite();
879 let conn = Connection::open_in_memory().expect("open in-memory db");
880 let ok = integrity_check(&conn).expect("check");
881 assert!(ok);
882 }
883
884 #[test]
885 fn test_cipher_plaintext_export_import_roundtrip() {
886 init_sqlite();
887 let dir = tempfile::tempdir().expect("create temp dir");
888 let src_path = dir.path().join("source.sqlite");
889 let dest_path = dir.path().join("backup.plain.sqlite");
890 let restore_path = dir.path().join("restore.sqlite");
891 let key = SecretBox::init_with(|| [0x11u8; 32]);
892
893 {
894 let conn = open_encrypted(&src_path, &key).expect("open src");
895 conn.execute_batch(
896 "CREATE TABLE widgets (id INTEGER PRIMARY KEY, val TEXT NOT NULL);",
897 )
898 .expect("create table");
899 conn.execute(
900 "INSERT INTO widgets (id, val) VALUES (?1, ?2)",
901 params![1_i64, "alpha"],
902 )
903 .expect("insert");
904 conn.execute(
905 "INSERT INTO widgets (id, val) VALUES (?1, ?2)",
906 params![2_i64, "beta"],
907 )
908 .expect("insert");
909
910 export_plaintext_copy(&conn, &dest_path, &["widgets"]).expect("export");
911 }
912
913 {
914 let conn = open_encrypted(&restore_path, &key).expect("open restore");
915 conn.execute_batch(
916 "CREATE TABLE widgets (id INTEGER PRIMARY KEY, val TEXT NOT NULL);",
917 )
918 .expect("create table");
919 import_plaintext_copy(&conn, &dest_path, &["widgets"]).expect("import");
920
921 let count: i64 = conn
922 .query_row("SELECT COUNT(*) FROM widgets", &[], |row| {
923 Ok(row.column_i64(0))
924 })
925 .expect("count");
926 assert_eq!(count, 2);
927
928 let val = conn
929 .query_row("SELECT val FROM widgets WHERE id = 2", &[], |row| {
930 Ok(row.column_text(0))
931 })
932 .expect("query");
933 assert_eq!(val, "beta");
934 }
935 }
936
937 #[test]
938 fn test_cipher_import_rejects_non_empty_destination() {
939 init_sqlite();
940 let dir = tempfile::tempdir().expect("create temp dir");
941 let src_path = dir.path().join("source.sqlite");
942 let dest_path = dir.path().join("backup.plain.sqlite");
943 let restore_path = dir.path().join("restore.sqlite");
944 let key = SecretBox::init_with(|| [0x22u8; 32]);
945
946 {
947 let conn = open_encrypted(&src_path, &key).expect("open src");
948 conn.execute_batch(
949 "CREATE TABLE widgets (id INTEGER PRIMARY KEY, val TEXT NOT NULL);",
950 )
951 .expect("create table");
952 conn.execute(
953 "INSERT INTO widgets (id, val) VALUES (?1, ?2)",
954 params![1_i64, "alpha"],
955 )
956 .expect("insert");
957 export_plaintext_copy(&conn, &dest_path, &["widgets"]).expect("export");
958 }
959
960 let conn = open_encrypted(&restore_path, &key).expect("open restore");
961 conn.execute_batch(
962 "CREATE TABLE widgets (id INTEGER PRIMARY KEY, val TEXT NOT NULL);",
963 )
964 .expect("create table");
965 conn.execute(
966 "INSERT INTO widgets (id, val) VALUES (?1, ?2)",
967 params![99_i64, "preexisting"],
968 )
969 .expect("insert");
970
971 let err = import_plaintext_copy(&conn, &dest_path, &["widgets"])
972 .expect_err("import should refuse non-empty destination");
973 assert!(
974 err.to_string().contains("non-empty table"),
975 "expected non-empty-table error, got: {err}"
976 );
977 }
978}