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