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