1use std::ffi::c_int;
4use std::path::{Path, PathBuf};
5use std::time::Duration;
6
7use rusqlite::{Connection, ErrorCode};
8
9use crate::envelope::Key;
10
11pub const SCHEMA_VERSION: i64 = 2;
14
15const VCON_STORED_AT: &str = "
20ALTER TABLE vcon ADD COLUMN stored_at INTEGER NOT NULL DEFAULT 0;
21UPDATE vcon SET stored_at =
22 coalesce((SELECT min(stored_at) FROM version WHERE version.uuid = vcon.uuid), 0);
23CREATE INDEX vcon_listed ON vcon (stored_at, uuid);
24";
25
26pub const BUSY_TIMEOUT: Duration = Duration::from_secs(5);
31
32#[derive(Debug)]
34pub enum CatalogError {
35 WrongKey,
37 UnknownSchema(i64),
39 Sqlite(rusqlite::Error),
41 Io(std::io::Error),
43 Access {
47 path: PathBuf,
48 error: std::io::Error,
49 },
50}
51
52impl std::fmt::Display for CatalogError {
53 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54 match self {
55 CatalogError::WrongKey => {
56 f.write_str("the key is not the one the catalog was made with")
57 }
58 CatalogError::UnknownSchema(version) => {
59 write!(f, "schema version {version} is not one this build knows")
60 }
61 CatalogError::Sqlite(_) => f.write_str("sqlite failed"),
62 CatalogError::Io(_) => {
63 f.write_str("a file around the catalog could not be read, written or renamed")
64 }
65 CatalogError::Access { path, .. } => {
66 write!(f, "this process may not read and write {}", path.display())
67 }
68 }
69 }
70}
71
72impl std::error::Error for CatalogError {
73 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
74 match self {
75 CatalogError::Sqlite(e) => Some(e),
76 CatalogError::Io(e) | CatalogError::Access { error: e, .. } => Some(e),
77 CatalogError::WrongKey | CatalogError::UnknownSchema(_) => None,
78 }
79 }
80}
81
82impl From<rusqlite::Error> for CatalogError {
83 fn from(e: rusqlite::Error) -> CatalogError {
84 CatalogError::Sqlite(e)
85 }
86}
87
88pub fn open(path: &Path, key: &Key, schema: &str) -> Result<Connection, CatalogError> {
91 if let Some(dir) = path.parent() {
95 may_use(dir, libc::W_OK | libc::X_OK)?;
96 }
97 if path.exists() {
98 may_use(path, libc::R_OK | libc::W_OK)?;
99 } else {
100 make_file(path)?;
101 }
102 let conn = Connection::open(path)?;
103 conn.busy_timeout(BUSY_TIMEOUT)?;
104 conn.execute_batch("PRAGMA cipher_log_level = NONE")?;
109 set_key(&conn, key)?;
110 match conn.query_row("SELECT count(*) FROM sqlite_master", [], |_| Ok(())) {
113 Err(e) if e.sqlite_error_code() == Some(ErrorCode::NotADatabase) => {
114 return Err(CatalogError::WrongKey);
115 }
116 other => other?,
117 }
118 let mode: String = conn.pragma_update_and_check(None, "journal_mode", "WAL", |r| r.get(0))?;
122 debug_assert_eq!(mode, "wal");
123 conn.pragma_update(None, "synchronous", "FULL")?;
124 conn.pragma_update(None, "secure_delete", "ON")?;
125 conn.pragma_update(None, "temp_store", "MEMORY")?;
126 match conn.pragma_query_value(None, "user_version", |r| r.get(0))? {
127 0 => {
128 let tx = conn.unchecked_transaction()?;
129 tx.execute_batch(schema)?;
130 tx.pragma_update(None, "user_version", SCHEMA_VERSION)?;
131 tx.commit()?;
132 }
133 1 => {
134 let tx = conn.unchecked_transaction()?;
136 let tenant: bool = tx.query_row(
137 "SELECT count(*) > 0 FROM sqlite_master WHERE type = 'table' AND name = 'vcon'",
138 [],
139 |r| r.get(0),
140 )?;
141 if tenant {
142 tx.execute_batch(VCON_STORED_AT)?;
143 }
144 tx.pragma_update(None, "user_version", SCHEMA_VERSION)?;
145 tx.commit()?;
146 }
147 SCHEMA_VERSION => {}
148 other => return Err(CatalogError::UnknownSchema(other)),
149 }
150 Ok(conn)
151}
152
153pub(crate) fn may_use(path: &Path, how: c_int) -> Result<(), CatalogError> {
156 let denied = |error| CatalogError::Access {
157 path: path.to_owned(),
158 error,
159 };
160 let text = std::ffi::CString::new(path.as_os_str().as_encoded_bytes())
161 .map_err(|_| denied(std::io::ErrorKind::InvalidInput.into()))?;
162 if unsafe { libc::faccessat(libc::AT_FDCWD, text.as_ptr(), how, libc::AT_EACCESS) } != 0 {
165 return Err(denied(std::io::Error::last_os_error()));
166 }
167 Ok(())
168}
169
170pub fn backup(source: &Connection, to: &Path, key: &Key) -> Result<(), CatalogError> {
178 let io = CatalogError::Io;
179 make_file(to)?;
180 {
181 let mut copy = Connection::open(to)?;
182 copy.execute_batch("PRAGMA cipher_log_level = NONE")?;
183 set_key(©, key)?;
184 let backup = rusqlite::backup::Backup::new(source, &mut copy)?;
185 match backup.step(-1)? {
187 rusqlite::backup::StepResult::Done => {}
188 _ => {
189 return Err(io(std::io::Error::other(
190 "the catalog was busy and could not be copied in one step",
191 )));
192 }
193 }
194 }
195 std::fs::File::open(to)
196 .and_then(|f| f.sync_all())
197 .map_err(io)?;
198 let dir = to.parent().unwrap_or(Path::new("."));
199 std::fs::File::open(dir)
200 .and_then(|f| f.sync_all())
201 .map_err(io)?;
202 Ok(())
203}
204
205pub fn reencrypt(path: &Path, old: &Key, new: &Key) -> Result<(), CatalogError> {
212 let copy = copy_under(path, old, new)?;
213 replace(path, ©)
214}
215
216pub fn copy_under(path: &Path, old: &Key, new: &Key) -> Result<PathBuf, CatalogError> {
221 let target = sibling(path, ".rekey");
222 for stale in [
223 target.clone(),
224 sibling(&target, "-wal"),
225 sibling(&target, "-shm"),
226 ] {
227 remove_if_there(&stale)?;
228 }
229 make_file(&target)?;
230 let conn = open(path, old, "")?;
231 let version: i64 = conn.pragma_query_value(None, "user_version", |r| r.get(0))?;
232 let target_text = target
233 .to_str()
234 .ok_or_else(|| CatalogError::Io(std::io::Error::other("the catalog path is not UTF-8")))?;
235 conn.execute("ATTACH DATABASE ?1 AS rekeyed", [target_text])?;
236 set_key_of(&conn, c"rekeyed", new)?;
237 conn.query_row("SELECT sqlcipher_export('rekeyed')", [], |_| Ok(()))?;
238 conn.execute_batch(&format!("PRAGMA rekeyed.user_version = {version}"))?;
241 conn.execute("DETACH DATABASE rekeyed", [])?;
242 Ok(target)
243}
244
245pub fn replace(path: &Path, copy: &Path) -> Result<(), CatalogError> {
250 for file in [path, copy] {
251 let wal = sibling(file, "-wal");
252 if std::fs::metadata(&wal).is_ok_and(|m| m.len() > 0) {
253 return Err(CatalogError::Io(std::io::Error::other(
254 "a catalog's WAL was not emptied",
255 )));
256 }
257 remove_if_there(&wal)?;
258 remove_if_there(&sibling(file, "-shm"))?;
259 }
260 let io = CatalogError::Io;
261 std::fs::File::open(copy)
262 .and_then(|f| f.sync_all())
263 .map_err(io)?;
264 std::fs::rename(copy, path).map_err(io)?;
265 let dir = path.parent().unwrap_or(Path::new("."));
266 std::fs::File::open(dir)
267 .and_then(|f| f.sync_all())
268 .map_err(io)?;
269 Ok(())
270}
271
272fn make_file(path: &Path) -> Result<(), CatalogError> {
276 use std::os::unix::fs::OpenOptionsExt;
277 std::fs::OpenOptions::new()
278 .write(true)
279 .create_new(true)
280 .mode(crate::fs::FILE_MODE)
281 .open(path)
282 .map(drop)
283 .map_err(CatalogError::Io)
284}
285
286fn remove_if_there(file: &Path) -> Result<(), CatalogError> {
287 match std::fs::remove_file(file) {
288 Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(CatalogError::Io(e)),
289 _ => Ok(()),
290 }
291}
292
293fn sibling(path: &Path, suffix: &str) -> PathBuf {
295 let mut name = path.as_os_str().to_owned();
296 name.push(suffix);
297 PathBuf::from(name)
298}
299
300fn set_key_of(conn: &Connection, schema: &std::ffi::CStr, key: &Key) -> Result<(), CatalogError> {
302 let literal = key.sqlcipher_raw();
303 let len = c_int::try_from(literal.len()).expect("a 67-byte literal");
304 let rc = unsafe {
308 rusqlite::ffi::sqlite3_key_v2(conn.handle(), schema.as_ptr(), literal.as_ptr().cast(), len)
309 };
310 if rc != rusqlite::ffi::SQLITE_OK {
311 return Err(rusqlite::Error::SqliteFailure(rusqlite::ffi::Error::new(rc), None).into());
312 }
313 Ok(())
314}
315
316fn set_key(conn: &Connection, key: &Key) -> Result<(), CatalogError> {
320 let literal = key.sqlcipher_raw();
321 let len = c_int::try_from(literal.len()).expect("a 67-byte literal");
322 let rc = unsafe { rusqlite::ffi::sqlite3_key(conn.handle(), literal.as_ptr().cast(), len) };
325 if rc != rusqlite::ffi::SQLITE_OK {
328 return Err(rusqlite::Error::SqliteFailure(rusqlite::ffi::Error::new(rc), None).into());
329 }
330 Ok(())
331}
332
333#[cfg(test)]
335pub(crate) mod testing {
336 use rusqlite::Connection;
337
338 pub(crate) fn columns_taking_null(
342 conn: &Connection,
343 table: &str,
344 row: &[(&str, &str)],
345 ) -> Vec<String> {
346 let insert = |empty: Option<&str>| {
347 let columns: Vec<&str> = row.iter().map(|(c, _)| *c).collect();
348 let values: Vec<&str> = row
349 .iter()
350 .map(|(c, v)| if Some(*c) == empty { "NULL" } else { *v })
351 .collect();
352 let sql = format!(
353 "INSERT INTO {table} ({}) VALUES ({})",
354 columns.join(", "),
355 values.join(", ")
356 );
357 let tx = conn.unchecked_transaction().expect("begin");
358 let taken = tx.execute(&sql, []).is_ok();
359 drop(tx);
360 taken
361 };
362 assert!(insert(None), "a whole {table} row inserts");
363 row.iter()
364 .filter(|(column, _)| insert(Some(column)))
365 .map(|(column, _)| format!("{table}.{column}"))
366 .collect()
367 }
368}
369
370#[cfg(test)]
371pub(crate) mod tests {
372 use super::*;
373
374 const SCHEMA: &str = "CREATE TABLE note (text TEXT NOT NULL);";
375 const MARKER: &str = "plain-record-marker-5f3c9a";
376
377 fn path(dir: &tempfile::TempDir) -> std::path::PathBuf {
378 dir.path().join("catalog.sqlite")
379 }
380
381 pub(crate) fn cipher_setting(conn: &Connection, name: &str) -> String {
384 conn.query_row(&format!("PRAGMA {name}"), [], |r| r.get(0))
385 .expect(name)
386 }
387
388 #[test]
389 fn a_catalog_runs_with_the_required_settings() {
390 let dir = tempfile::tempdir().expect("temp dir");
391 let key = Key::generate().expect("key");
392 let conn = open(&path(&dir), &key, SCHEMA).expect("opens");
393 let text = |p: &str| -> String { conn.pragma_query_value(None, p, |r| r.get(0)).expect(p) };
394 let number = |p: &str| -> i64 { conn.pragma_query_value(None, p, |r| r.get(0)).expect(p) };
395 assert_eq!(text("journal_mode"), "wal");
396 assert_eq!(number("synchronous"), 2, "FULL");
397 assert_eq!(number("secure_delete"), 1);
398 assert_eq!(number("temp_store"), 2, "MEMORY");
399 assert_eq!(number("busy_timeout"), 5000, "milliseconds");
400 assert_eq!(cipher_setting(&conn, "cipher_provider"), "openssl");
401 assert_eq!(
402 cipher_setting(&conn, "cipher_log_level"),
403 "NONE",
404 "SQLCipher writes no log of its own"
405 );
406 }
407
408 #[test]
415 fn sqlcipher_settings_read_the_same_through_the_pragma_helpers() {
416 let dir = tempfile::tempdir().expect("temp dir");
417 let key = Key::generate().expect("key");
418 let conn = open(&path(&dir), &key, SCHEMA).expect("opens");
419 let names = ["cipher_log_level", "cipher_provider", "cipher_page_size"];
420 let plain: Vec<String> = names.iter().map(|n| cipher_setting(&conn, n)).collect();
421
422 let helpers = rusqlite_fork::Connection::open(path(&dir)).expect("opens again");
423 let literal = key.sqlcipher_raw();
424 let len = c_int::try_from(literal.len()).expect("a 67-byte literal");
425 let rc = unsafe {
428 rusqlite_fork::ffi::sqlite3_key(helpers.handle(), literal.as_ptr().cast(), len)
429 };
430 assert_eq!(rc, rusqlite_fork::ffi::SQLITE_OK);
431 helpers
434 .pragma_update(None, "cipher_log_level", "NONE")
435 .expect("cipher_log_level through pragma_update");
436 let notes: i64 = helpers
437 .query_row("SELECT count(*) FROM note", [], |r| r.get(0))
438 .expect("the key opens the same catalog");
439 assert_eq!(notes, 0);
440 let read: Vec<String> = names
441 .iter()
442 .map(|n| {
443 helpers
444 .pragma_query_value(None, n, |r| r.get(0))
445 .unwrap_or_else(|e| panic!("{n} through pragma_query_value: {e}"))
446 })
447 .collect();
448 assert_eq!(read, plain);
449 assert_eq!(plain[0], "NONE");
450 }
451
452 #[test]
457 fn crates_io_rusqlite_refuses_sqlcipher_names() {
458 let conn = Connection::open_in_memory().expect("opens");
459 let refused = conn.pragma_update(None, "cipher_log_level", "NONE");
460 assert!(
461 matches!(&refused, Err(rusqlite::Error::SqliteFailure(_, Some(m)))
462 if m == "Invalid pragma \"cipher_log_level\""),
463 "this rusqlite accepts SQLCipher's pragma names ({refused:?}): set them with \
464 its pragma helpers in catalog::open and cipher_setting, and remove the \
465 rusqlite_fork dev-dependency and its patch (see the Development page)"
466 );
467 }
468
469 mod fork_docs {
472 use std::path::PathBuf;
473
474 const FORK: &str = "https://github.com/NormB/rusqlite";
475
476 fn read(relative: &str) -> String {
477 let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(relative);
478 std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{}: {e}", path.display()))
479 }
480
481 fn table(text: &str) -> toml::Table {
482 text.parse().expect("toml")
483 }
484
485 fn pinned() -> String {
488 let manifest = table(&read("Cargo.toml"));
489 let server = &manifest["dependencies"]["rusqlite"];
490 assert!(
491 server.get("version").is_some(),
492 "the server's rusqlite has a version"
493 );
494 assert!(
495 server.get("git").is_none() && server.get("path").is_none(),
496 "the server's rusqlite comes from crates.io"
497 );
498 let fork = &manifest["dev-dependencies"]["rusqlite_fork"];
499 assert_eq!(fork["package"].as_str(), Some("rusqlite"));
500 assert_eq!(fork["git"].as_str(), Some(FORK));
501 let rev = fork["rev"].as_str().expect("a rev").to_owned();
502 assert!(
503 rev.len() == 40 && rev.bytes().all(|b| b.is_ascii_hexdigit()),
504 "a full commit id: {rev}"
505 );
506 rev
507 }
508
509 #[test]
510 fn only_the_fork_is_patched() {
511 pinned();
512 let workspace = table(&read("../../Cargo.toml"));
513 let patch = workspace["patch"].as_table().expect("a patch table");
514 assert_eq!(
515 patch.keys().collect::<Vec<_>>(),
516 [FORK],
517 "no crates-io patch"
518 );
519 let sys: Vec<&String> = patch[FORK].as_table().expect("a table").keys().collect();
520 assert_eq!(sys, ["libsqlite3-sys"]);
521 }
522
523 #[test]
524 fn the_development_page_names_the_pinned_commit() {
525 let page = read("../../website/content/docs/development.md");
526 let section = page
527 .split("\n## ")
528 .find(|s| s.starts_with("Test dependency from a fork\n"))
529 .expect("a section on the fork");
530 assert!(
531 section.contains(&format!("`{}`", pinned())),
532 "the pinned commit"
533 );
534 assert!(section.contains(FORK), "the fork's address");
535 let lock = read("../../Cargo.lock");
536 let version = lock
537 .split("[[package]]")
538 .find(|p| p.contains(&format!("source = \"git+{FORK}?rev=")))
539 .and_then(|p| p.lines().find_map(|l| l.strip_prefix("version = \"")))
540 .map(|v| v.trim_end_matches('"'))
541 .expect("the fork in Cargo.lock");
542 assert!(
543 section.contains(&format!("`{version}`")),
544 "the fork's version {version}"
545 );
546 let source = include_str!("catalog.rs");
547 for test in [
548 "sqlcipher_settings_read_the_same_through_the_pragma_helpers",
549 "crates_io_rusqlite_refuses_sqlcipher_names",
550 ] {
551 assert!(section.contains(&format!("`{test}`")), "names {test}");
552 assert!(source.contains(&format!("fn {test}()")), "{test} exists");
553 }
554 }
555
556 #[test]
557 fn the_install_page_says_what_a_build_fetches() {
558 let page = read("../../website/content/docs/install.md");
559 let build = page
560 .split("\n## ")
561 .find(|s| s.starts_with("Build from source\n"))
562 .expect("a Build from source section");
563 assert!(
564 build.contains(FORK.trim_start_matches("https://")),
565 "names the fork Cargo.toml pins, from the one constant both checks read"
566 );
567 assert!(
568 build.contains("mkdir -p .cargo && cargo vendor >> .cargo/config.toml\n"),
569 "the vendoring command"
570 );
571 assert!(build.contains("cargo build --release --offline\n"));
572 let release = page
573 .split("\n## ")
574 .find(|s| s.starts_with("Download a release\n"))
575 .expect("a release section");
576 assert!(
577 release.contains("crates.io"),
578 "says which rusqlite a release holds"
579 );
580 }
581 }
582
583 #[test]
584 fn the_schema_is_made_once_at_the_current_version() {
585 let dir = tempfile::tempdir().expect("temp dir");
586 let key = Key::generate().expect("key");
587 let conn = open(&path(&dir), &key, SCHEMA).expect("opens");
588 let version: i64 = conn
589 .pragma_query_value(None, "user_version", |r| r.get(0))
590 .expect("version");
591 assert_eq!(version, SCHEMA_VERSION);
592 conn.execute("INSERT INTO note (text) VALUES ('kept')", [])
593 .expect("insert");
594 drop(conn);
595 let conn =
596 open(&path(&dir), &key, SCHEMA).expect("reopens without making the schema again");
597 let kept: String = conn
598 .query_row("SELECT text FROM note", [], |r| r.get(0))
599 .expect("row");
600 assert_eq!(kept, "kept");
601 }
602
603 #[test]
604 fn an_unknown_schema_version_is_refused() {
605 let dir = tempfile::tempdir().expect("temp dir");
606 let key = Key::generate().expect("key");
607 let conn = open(&path(&dir), &key, SCHEMA).expect("opens");
608 conn.pragma_update(None, "user_version", SCHEMA_VERSION + 1)
609 .expect("set version");
610 drop(conn);
611 assert!(matches!(
612 open(&path(&dir), &key, SCHEMA),
613 Err(CatalogError::UnknownSchema(v)) if v == SCHEMA_VERSION + 1
614 ));
615 }
616
617 #[test]
621 fn a_version_1_catalog_is_upgraded_to_version_2() {
622 let dir = tempfile::tempdir().expect("temp dir");
623 let key = Key::generate().expect("key");
624 let conn = open(&path(&dir), &key, SCHEMA).expect("opens");
625 conn.execute("INSERT INTO note (text) VALUES ('kept')", [])
626 .expect("insert");
627 conn.pragma_update(None, "user_version", 1)
628 .expect("set version");
629 drop(conn);
630 let conn = open(&path(&dir), &key, "").expect("upgrades");
631 let version: i64 = conn
632 .pragma_query_value(None, "user_version", |r| r.get(0))
633 .expect("version");
634 assert_eq!(version, 2);
635 assert_eq!(notes(&conn), vec!["kept"]);
636 }
637
638 fn rekeyed(dir: &tempfile::TempDir) -> std::path::PathBuf {
640 dir.path().join("catalog.sqlite.rekey")
641 }
642
643 fn notes(conn: &Connection) -> Vec<String> {
644 conn.prepare("SELECT text FROM note ORDER BY rowid")
645 .expect("prepare")
646 .query_map([], |r| r.get(0))
647 .expect("query")
648 .collect::<Result<_, _>>()
649 .expect("rows")
650 }
651
652 #[test]
653 fn a_reencrypted_catalog_opens_under_the_new_key_only_and_keeps_every_row() {
654 let dir = tempfile::tempdir().expect("temp dir");
655 let (old, new) = (Key::generate().expect("key"), Key::generate().expect("key"));
656 let conn = open(&path(&dir), &old, SCHEMA).expect("opens");
657 for n in 0..300 {
658 conn.execute("INSERT INTO note (text) VALUES (?1)", [format!("note {n}")])
659 .expect("insert");
660 }
661 drop(conn);
662 reencrypt(&path(&dir), &old, &new).expect("re-encrypted");
663 let raw = std::fs::read(path(&dir)).expect("the file");
664 assert!(
665 !raw.windows(8).any(|w| w == b"note 299"),
666 "no row is written in the clear"
667 );
668 assert!(matches!(
669 open(&path(&dir), &old, SCHEMA),
670 Err(CatalogError::WrongKey)
671 ));
672 let conn = open(&path(&dir), &new, SCHEMA).expect("opens under the new key");
673 let kept = notes(&conn);
674 assert_eq!(
675 kept,
676 (0..300).map(|n| format!("note {n}")).collect::<Vec<_>>(),
677 "every row is kept, each with its own text, in order"
678 );
679 let version: i64 = conn
680 .pragma_query_value(None, "user_version", |r| r.get(0))
681 .expect("version");
682 assert_eq!(version, SCHEMA_VERSION, "the schema version is kept");
683 let mode: String = conn
684 .pragma_query_value(None, "journal_mode", |r| r.get(0))
685 .expect("mode");
686 assert_eq!(mode, "wal");
687 drop(conn);
688 let mut left: Vec<String> = std::fs::read_dir(dir.path())
689 .expect("list")
690 .map(|e| e.expect("entry").file_name().to_string_lossy().into_owned())
691 .collect();
692 left.sort();
693 assert_eq!(left, ["catalog.sqlite"], "no temporary file is left");
694 }
695
696 #[test]
697 fn a_file_left_by_an_interrupted_reencryption_is_replaced() {
698 let dir = tempfile::tempdir().expect("temp dir");
699 let (old, new) = (Key::generate().expect("key"), Key::generate().expect("key"));
700 let conn = open(&path(&dir), &old, SCHEMA).expect("opens");
701 conn.execute("INSERT INTO note (text) VALUES ('kept')", [])
702 .expect("insert");
703 drop(conn);
704 std::fs::write(rekeyed(&dir), b"half a file").expect("leftover");
705 reencrypt(&path(&dir), &old, &new).expect("re-encrypted");
706 let conn = open(&path(&dir), &new, SCHEMA).expect("opens");
707 assert_eq!(notes(&conn), ["kept"]);
708 assert!(!rekeyed(&dir).exists());
709 }
710
711 #[test]
715 fn a_replace_refuses_while_either_wal_holds_frames() {
716 let dir = tempfile::tempdir().expect("temp dir");
717 let key = Key::generate().expect("key");
718 let copy = dir.path().join("catalog.sqlite.rekey");
719 for busy in [path(&dir), copy.clone()] {
720 for file in [path(&dir), copy.clone()] {
721 for leftover in ["", "-wal", "-shm"] {
722 let _ = std::fs::remove_file(sibling(&file, leftover));
723 }
724 drop(open(&file, &key, SCHEMA).expect("made"));
725 }
726 let held = open(&busy, &key, SCHEMA).expect("opens");
727 held.pragma_update(None, "wal_autocheckpoint", 0)
728 .expect("no checkpoint");
729 held.execute("INSERT INTO note (text) VALUES ('in the wal')", [])
730 .expect("insert");
731 assert!(
732 std::fs::metadata(sibling(&busy, "-wal")).is_ok_and(|m| m.len() > 0),
733 "the WAL holds the row"
734 );
735 assert!(
736 matches!(replace(&path(&dir), ©), Err(CatalogError::Io(_))),
737 "{}",
738 busy.display()
739 );
740 assert!(copy.exists(), "nothing was renamed");
741 drop(held);
742 }
743 }
744
745 #[test]
746 fn reencrypting_under_the_wrong_key_changes_nothing() {
747 let dir = tempfile::tempdir().expect("temp dir");
748 let (old, new) = (Key::generate().expect("key"), Key::generate().expect("key"));
749 drop(open(&path(&dir), &old, SCHEMA).expect("opens"));
750 let other = Key::generate().expect("key");
751 assert!(matches!(
752 reencrypt(&path(&dir), &other, &new),
753 Err(CatalogError::WrongKey)
754 ));
755 assert!(
756 open(&path(&dir), &old, SCHEMA).is_ok(),
757 "still under the old key"
758 );
759 }
760
761 #[test]
762 fn a_backup_holds_every_committed_row_and_opens_under_the_same_key_only() {
763 let dir = tempfile::tempdir().expect("temp dir");
764 let key = Key::generate().expect("key");
765 let source = open(&path(&dir), &key, SCHEMA).expect("opens");
766 source
767 .execute("INSERT INTO note (text) VALUES ('committed')", [])
768 .expect("insert");
769 let writer = open(&path(&dir), &key, SCHEMA).expect("opens");
772 writer.execute_batch("BEGIN IMMEDIATE").expect("begin");
773 writer
774 .execute("INSERT INTO note (text) VALUES ('uncommitted')", [])
775 .expect("insert");
776 let copy = dir.path().join("copy.sqlite");
777 backup(&source, ©, &key).expect("backs up");
778 writer.execute_batch("COMMIT").expect("commit");
779 assert!(
780 !sibling(©, "-wal").exists()
781 || std::fs::metadata(sibling(©, "-wal"))
782 .expect("wal")
783 .len()
784 == 0,
785 "the copy is whole in its own file"
786 );
787 let reopened = open(©, &key, "").expect("the copy opens under the same key");
788 let notes: Vec<String> = reopened
789 .prepare("SELECT text FROM note")
790 .expect("prepare")
791 .query_map([], |r| r.get(0))
792 .expect("query")
793 .collect::<Result<_, _>>()
794 .expect("rows");
795 assert_eq!(notes, ["committed"]);
796 let version: i64 = reopened
797 .pragma_query_value(None, "user_version", |r| r.get(0))
798 .expect("version");
799 assert_eq!(version, SCHEMA_VERSION);
800 drop(reopened);
801 let other = Key::generate().expect("key");
802 assert!(matches!(
803 open(©, &other, ""),
804 Err(CatalogError::WrongKey)
805 ));
806 }
807
808 #[test]
809 fn a_backup_holds_rows_still_in_the_source_wal_and_no_plain_text() {
810 let dir = tempfile::tempdir().expect("temp dir");
811 let key = Key::generate().expect("key");
812 let source = open(&path(&dir), &key, SCHEMA).expect("opens");
813 source
814 .execute("INSERT INTO note (text) VALUES (?1)", [MARKER])
815 .expect("insert");
816 assert!(
817 std::fs::metadata(sibling(&path(&dir), "-wal"))
818 .expect("wal")
819 .len()
820 > 0,
821 "the row is still in the WAL"
822 );
823 let copy = dir.path().join("copy.sqlite");
824 backup(&source, ©, &key).expect("backs up");
825 let bytes = std::fs::read(©).expect("read the copy");
826 assert!(
827 !bytes.windows(MARKER.len()).any(|w| w == MARKER.as_bytes()),
828 "the copy is encrypted"
829 );
830 let reopened = open(©, &key, "").expect("opens");
831 let count: i64 = reopened
832 .query_row("SELECT count(*) FROM note WHERE text = ?1", [MARKER], |r| {
833 r.get(0)
834 })
835 .expect("count");
836 assert_eq!(count, 1);
837 }
838
839 #[test]
840 fn a_backup_never_writes_over_a_file() {
841 let dir = tempfile::tempdir().expect("temp dir");
842 let key = Key::generate().expect("key");
843 let source = open(&path(&dir), &key, SCHEMA).expect("opens");
844 let copy = dir.path().join("copy.sqlite");
845 std::fs::write(©, b"something else").expect("write");
846 assert!(matches!(
847 backup(&source, ©, &key),
848 Err(CatalogError::Io(_))
849 ));
850 assert_eq!(std::fs::read(©).expect("read"), b"something else");
851 }
852
853 #[test]
854 fn a_wrong_key_does_not_open_the_catalog() {
855 let dir = tempfile::tempdir().expect("temp dir");
856 let key = Key::generate().expect("key");
857 drop(open(&path(&dir), &key, SCHEMA).expect("opens"));
858 let other = Key::generate().expect("key");
859 assert!(matches!(
860 open(&path(&dir), &other, SCHEMA),
861 Err(CatalogError::WrongKey)
862 ));
863 assert!(
864 open(&path(&dir), &key, SCHEMA).is_ok(),
865 "the right key still opens it"
866 );
867 }
868
869 #[test]
872 fn a_catalog_the_process_may_not_write_is_an_access_error_naming_the_path() {
873 use std::os::unix::fs::PermissionsExt;
874 let mode = |p: &Path, m| {
875 std::fs::set_permissions(p, std::fs::Permissions::from_mode(m)).expect("chmod")
876 };
877 let dir = tempfile::tempdir().expect("temp dir");
878 let key = Key::generate().expect("key");
879 drop(open(&path(&dir), &key, SCHEMA).expect("opens"));
880 for (target, read_only) in [(path(&dir), 0o400), (dir.path().to_owned(), 0o500)] {
881 mode(&target, read_only);
882 let refused = open(&path(&dir), &key, SCHEMA);
883 mode(&target, if target.is_dir() { 0o700 } else { 0o600 });
884 match refused {
885 Err(CatalogError::Access { path, error }) => {
886 assert_eq!(path, target);
887 assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied);
888 }
889 other => panic!("{}: {other:?}", target.display()),
890 }
891 }
892 assert!(
893 open(&path(&dir), &key, SCHEMA).is_ok(),
894 "opens once allowed"
895 );
896 }
897
898 #[test]
901 #[should_panic(expected = "a whole note row inserts")]
902 fn the_null_check_needs_a_row_that_inserts() {
903 let dir = tempfile::tempdir().expect("temp dir");
904 let key = Key::generate().expect("key");
905 let conn = open(&path(&dir), &key, SCHEMA).expect("opens");
906 testing::columns_taking_null(&conn, "note", &[("misspelt", "'a'")]);
907 }
908
909 #[test]
911 fn no_plain_record_reaches_the_catalog_or_its_wal() {
912 let dir = tempfile::tempdir().expect("temp dir");
913 let key = Key::generate().expect("key");
914 let conn = open(&path(&dir), &key, SCHEMA).expect("opens");
915 conn.execute("INSERT INTO note (text) VALUES (?1)", [MARKER])
916 .expect("insert");
917 let wal = std::fs::metadata(dir.path().join("catalog.sqlite-wal")).expect("a WAL");
918 assert!(wal.len() > 0, "the commit is in the WAL");
919 for entry in std::fs::read_dir(dir.path()).expect("list") {
920 let file = entry.expect("entry").path();
921 let bytes = std::fs::read(&file).expect("read");
922 assert!(
923 !bytes.windows(MARKER.len()).any(|w| w == MARKER.as_bytes()),
924 "plain record in {}",
925 file.display()
926 );
927 }
928 drop(conn);
929 }
930}