Skip to main content

vcon_server/
catalog.rs

1//! An SQLite catalog, encrypted with SQLCipher under its catalog key.
2
3use std::ffi::c_int;
4use std::path::{Path, PathBuf};
5use std::time::Duration;
6
7use rusqlite::{Connection, ErrorCode};
8
9use crate::envelope::Key;
10
11/// The schema version this build makes and reads. Version 2 added the
12/// time each uuid was first stored to a tenant catalog's vcon table.
13pub const SCHEMA_VERSION: i64 = 2;
14
15/// Brings a version-1 tenant catalog to version 2: each uuid's first store
16/// time, taken from its earliest kept version (the closest record left of
17/// it), and the index the list reads it by. The column is declared as a
18/// new catalog declares it.
19const 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
26/// How long a statement waits for another connection's lock before it
27/// fails busy. It is rusqlite's own default, stated so it is chosen, not
28/// inherited; a caller that must not wait inside SQLite sets zero for the
29/// call and restores this.
30pub const BUSY_TIMEOUT: Duration = Duration::from_secs(5);
31
32/// Why a catalog did not open.
33#[derive(Debug)]
34pub enum CatalogError {
35    /// The key is not the one the catalog was made with.
36    WrongKey,
37    /// The catalog's schema version is not one this build knows.
38    UnknownSchema(i64),
39    /// SQLite failed.
40    Sqlite(rusqlite::Error),
41    /// A file around the catalog could not be read, written or renamed.
42    Io(std::io::Error),
43}
44
45impl From<rusqlite::Error> for CatalogError {
46    fn from(e: rusqlite::Error) -> CatalogError {
47        CatalogError::Sqlite(e)
48    }
49}
50
51/// Opens the catalog at `path` under `key`, making it with `schema` if it
52/// is new.
53pub fn open(path: &Path, key: &Key, schema: &str) -> Result<Connection, CatalogError> {
54    let conn = Connection::open(path)?;
55    conn.busy_timeout(BUSY_TIMEOUT)?;
56    // SQLCipher otherwise writes its own error lines to standard error, a
57    // wrong key among them; this program says what went wrong itself.
58    // SQLCipher's settings are not in SQLite's pragma list, which rusqlite's
59    // pragma helpers check names against, so they go as plain SQL.
60    conn.execute_batch("PRAGMA cipher_log_level = NONE")?;
61    set_key(&conn, key)?;
62    // SQLCipher reads nothing until the first query, so a wrong key shows
63    // here, as a file that is not a database.
64    match conn.query_row("SELECT count(*) FROM sqlite_master", [], |_| Ok(())) {
65        Err(e) if e.sqlite_error_code() == Some(ErrorCode::NotADatabase) => {
66            return Err(CatalogError::WrongKey);
67        }
68        other => other?,
69    }
70    // Each setting is stated even where this build's default already
71    // matches (synchronous and secure_delete do), so a change of build
72    // flags cannot drop one; the tests read each back.
73    let mode: String = conn.pragma_update_and_check(None, "journal_mode", "WAL", |r| r.get(0))?;
74    debug_assert_eq!(mode, "wal");
75    conn.pragma_update(None, "synchronous", "FULL")?;
76    conn.pragma_update(None, "secure_delete", "ON")?;
77    conn.pragma_update(None, "temp_store", "MEMORY")?;
78    match conn.pragma_query_value(None, "user_version", |r| r.get(0))? {
79        0 => {
80            let tx = conn.unchecked_transaction()?;
81            tx.execute_batch(schema)?;
82            tx.pragma_update(None, "user_version", SCHEMA_VERSION)?;
83            tx.commit()?;
84        }
85        1 => {
86            // One transaction, so a failed upgrade leaves version 1 whole.
87            let tx = conn.unchecked_transaction()?;
88            let tenant: bool = tx.query_row(
89                "SELECT count(*) > 0 FROM sqlite_master WHERE type = 'table' AND name = 'vcon'",
90                [],
91                |r| r.get(0),
92            )?;
93            if tenant {
94                tx.execute_batch(VCON_STORED_AT)?;
95            }
96            tx.pragma_update(None, "user_version", SCHEMA_VERSION)?;
97            tx.commit()?;
98        }
99        SCHEMA_VERSION => {}
100        other => return Err(CatalogError::UnknownSchema(other)),
101    }
102    Ok(conn)
103}
104
105/// Re-encrypts the catalog at `path`, kept under `old`, so that it is kept
106/// under `new`: every row, the schema and its version are written to a new
107/// file under `new`, which then replaces the old one by rename. A crash
108/// leaves either the whole old file or the whole new one, and nothing of
109/// the old file's pages is carried into the new one. A file left by an
110/// interrupted run is replaced.
111pub fn reencrypt(path: &Path, old: &Key, new: &Key) -> Result<(), CatalogError> {
112    let copy = copy_under(path, old, new)?;
113    replace(path, &copy)
114}
115
116/// Writes every row, the schema and its version of the catalog at `path`,
117/// kept under `old`, to a new file beside it kept under `new`, and returns
118/// that file's path. The old file is only read. A file left there by an
119/// interrupted run is replaced.
120pub fn copy_under(path: &Path, old: &Key, new: &Key) -> Result<PathBuf, CatalogError> {
121    let target = sibling(path, ".rekey");
122    for stale in [
123        target.clone(),
124        sibling(&target, "-wal"),
125        sibling(&target, "-shm"),
126    ] {
127        remove_if_there(&stale)?;
128    }
129    let conn = open(path, old, "")?;
130    let version: i64 = conn.pragma_query_value(None, "user_version", |r| r.get(0))?;
131    let target_text = target
132        .to_str()
133        .ok_or_else(|| CatalogError::Io(std::io::Error::other("the catalog path is not UTF-8")))?;
134    conn.execute("ATTACH DATABASE ?1 AS rekeyed", [target_text])?;
135    set_key_of(&conn, c"rekeyed", new)?;
136    conn.query_row("SELECT sqlcipher_export('rekeyed')", [], |_| Ok(()))?;
137    // A pragma takes no bound parameter; the version is a number read
138    // from the catalog itself.
139    conn.execute_batch(&format!("PRAGMA rekeyed.user_version = {version}"))?;
140    conn.execute("DETACH DATABASE rekeyed", [])?;
141    Ok(target)
142}
143
144/// Makes `copy` the catalog at `path`: it is synced, renamed over `path`,
145/// and the directory synced. Every connection to either file must be
146/// closed. Their WAL files must be empty, as a last close leaves them; one
147/// still holding frames would sit beside the wrong file, so it is refused.
148pub fn replace(path: &Path, copy: &Path) -> Result<(), CatalogError> {
149    for file in [path, copy] {
150        let wal = sibling(file, "-wal");
151        if std::fs::metadata(&wal).is_ok_and(|m| m.len() > 0) {
152            return Err(CatalogError::Io(std::io::Error::other(
153                "a catalog's WAL was not emptied",
154            )));
155        }
156        remove_if_there(&wal)?;
157        remove_if_there(&sibling(file, "-shm"))?;
158    }
159    let io = CatalogError::Io;
160    std::fs::File::open(copy)
161        .and_then(|f| f.sync_all())
162        .map_err(io)?;
163    std::fs::rename(copy, path).map_err(io)?;
164    let dir = path.parent().unwrap_or(Path::new("."));
165    std::fs::File::open(dir)
166        .and_then(|f| f.sync_all())
167        .map_err(io)?;
168    Ok(())
169}
170
171fn remove_if_there(file: &Path) -> Result<(), CatalogError> {
172    match std::fs::remove_file(file) {
173        Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(CatalogError::Io(e)),
174        _ => Ok(()),
175    }
176}
177
178/// `path` with `suffix` added to its file name.
179fn sibling(path: &Path, suffix: &str) -> PathBuf {
180    let mut name = path.as_os_str().to_owned();
181    name.push(suffix);
182    PathBuf::from(name)
183}
184
185/// Keys the attached database `schema` as `set_key` keys the main one.
186fn set_key_of(conn: &Connection, schema: &std::ffi::CStr, key: &Key) -> Result<(), CatalogError> {
187    let literal = key.sqlcipher_raw();
188    let len = c_int::try_from(literal.len()).expect("a 67-byte literal");
189    // SAFETY: the handle is this open connection's; the name is a valid C
190    // string and the key pointer and length are the literal's, both of
191    // which outlive the call.
192    let rc = unsafe {
193        rusqlite::ffi::sqlite3_key_v2(conn.handle(), schema.as_ptr(), literal.as_ptr().cast(), len)
194    };
195    if rc != rusqlite::ffi::SQLITE_OK {
196        return Err(rusqlite::Error::SqliteFailure(rusqlite::ffi::Error::new(rc), None).into());
197    }
198    Ok(())
199}
200
201/// Gives SQLCipher the key as a raw key, never a passphrase. sqlite3_key
202/// takes the literal straight from its zeroing buffer; a PRAGMA would copy
203/// it into SQL text that is not zeroed.
204fn set_key(conn: &Connection, key: &Key) -> Result<(), CatalogError> {
205    let literal = key.sqlcipher_raw();
206    let len = c_int::try_from(literal.len()).expect("a 67-byte literal");
207    // SAFETY: the handle is this open connection's, and the pointer and
208    // length are the literal's, which outlives the call.
209    let rc = unsafe { rusqlite::ffi::sqlite3_key(conn.handle(), literal.as_ptr().cast(), len) };
210    // sqlite3_key does not fail on an open connection, so no test reaches
211    // this check; it stays to fail closed.
212    if rc != rusqlite::ffi::SQLITE_OK {
213        return Err(rusqlite::Error::SqliteFailure(rusqlite::ffi::Error::new(rc), None).into());
214    }
215    Ok(())
216}
217
218/// Checks on a catalog's schema, for tests.
219#[cfg(test)]
220pub(crate) mod testing {
221    use rusqlite::Connection;
222
223    /// The columns of `table` that take NULL. `row` gives a value for each
224    /// column; each column is tried as NULL in that row, and nothing is
225    /// kept. The whole row must insert, so a refusal is the column's.
226    pub(crate) fn columns_taking_null(
227        conn: &Connection,
228        table: &str,
229        row: &[(&str, &str)],
230    ) -> Vec<String> {
231        let insert = |empty: Option<&str>| {
232            let columns: Vec<&str> = row.iter().map(|(c, _)| *c).collect();
233            let values: Vec<&str> = row
234                .iter()
235                .map(|(c, v)| if Some(*c) == empty { "NULL" } else { *v })
236                .collect();
237            let sql = format!(
238                "INSERT INTO {table} ({}) VALUES ({})",
239                columns.join(", "),
240                values.join(", ")
241            );
242            let tx = conn.unchecked_transaction().expect("begin");
243            let taken = tx.execute(&sql, []).is_ok();
244            drop(tx);
245            taken
246        };
247        assert!(insert(None), "a whole {table} row inserts");
248        row.iter()
249            .filter(|(column, _)| insert(Some(column)))
250            .map(|(column, _)| format!("{table}.{column}"))
251            .collect()
252    }
253}
254
255#[cfg(test)]
256pub(crate) mod tests {
257    use super::*;
258
259    const SCHEMA: &str = "CREATE TABLE note (text TEXT NOT NULL);";
260    const MARKER: &str = "plain-record-marker-5f3c9a";
261
262    fn path(dir: &tempfile::TempDir) -> std::path::PathBuf {
263        dir.path().join("catalog.sqlite")
264    }
265
266    /// Reads one of SQLCipher's own settings, which rusqlite's pragma
267    /// helpers refuse by name.
268    pub(crate) fn cipher_setting(conn: &Connection, name: &str) -> String {
269        conn.query_row(&format!("PRAGMA {name}"), [], |r| r.get(0))
270            .expect(name)
271    }
272
273    #[test]
274    fn a_catalog_runs_with_the_required_settings() {
275        let dir = tempfile::tempdir().expect("temp dir");
276        let key = Key::generate().expect("key");
277        let conn = open(&path(&dir), &key, SCHEMA).expect("opens");
278        let text = |p: &str| -> String { conn.pragma_query_value(None, p, |r| r.get(0)).expect(p) };
279        let number = |p: &str| -> i64 { conn.pragma_query_value(None, p, |r| r.get(0)).expect(p) };
280        assert_eq!(text("journal_mode"), "wal");
281        assert_eq!(number("synchronous"), 2, "FULL");
282        assert_eq!(number("secure_delete"), 1);
283        assert_eq!(number("temp_store"), 2, "MEMORY");
284        assert_eq!(number("busy_timeout"), 5000, "milliseconds");
285        assert_eq!(cipher_setting(&conn, "cipher_provider"), "openssl");
286        assert_eq!(
287            cipher_setting(&conn, "cipher_log_level"),
288            "NONE",
289            "SQLCipher writes no log of its own"
290        );
291    }
292
293    /// The SQLCipher settings a catalog runs with, read back through
294    /// rusqlite's pragma helpers on a second connection to the same file,
295    /// equal what the plain SQL that `open` and `cipher_setting` use reads.
296    /// crates.io rusqlite 0.41.0 refuses these names in its helpers, so
297    /// this runs on the fork that accepts them; the values must not differ
298    /// between the two ways of setting and reading them.
299    #[test]
300    fn sqlcipher_settings_read_the_same_through_the_pragma_helpers() {
301        let dir = tempfile::tempdir().expect("temp dir");
302        let key = Key::generate().expect("key");
303        let conn = open(&path(&dir), &key, SCHEMA).expect("opens");
304        let names = ["cipher_log_level", "cipher_provider", "cipher_page_size"];
305        let plain: Vec<String> = names.iter().map(|n| cipher_setting(&conn, n)).collect();
306
307        let helpers = rusqlite_fork::Connection::open(path(&dir)).expect("opens again");
308        let literal = key.sqlcipher_raw();
309        let len = c_int::try_from(literal.len()).expect("a 67-byte literal");
310        // SAFETY: the handle is this open connection's, and the pointer and
311        // length are the literal's, which outlives the call.
312        let rc = unsafe {
313            rusqlite_fork::ffi::sqlite3_key(helpers.handle(), literal.as_ptr().cast(), len)
314        };
315        assert_eq!(rc, rusqlite_fork::ffi::SQLITE_OK);
316        // Keyed first: the helper reads SQLite's pragma list, which an
317        // encrypted file without its key cannot answer.
318        helpers
319            .pragma_update(None, "cipher_log_level", "NONE")
320            .expect("cipher_log_level through pragma_update");
321        let notes: i64 = helpers
322            .query_row("SELECT count(*) FROM note", [], |r| r.get(0))
323            .expect("the key opens the same catalog");
324        assert_eq!(notes, 0);
325        let read: Vec<String> = names
326            .iter()
327            .map(|n| {
328                helpers
329                    .pragma_query_value(None, n, |r| r.get(0))
330                    .unwrap_or_else(|e| panic!("{n} through pragma_query_value: {e}"))
331            })
332            .collect();
333        assert_eq!(read, plain);
334        assert_eq!(plain[0], "NONE");
335    }
336
337    /// The server's rusqlite still refuses SQLCipher's pragma names in its
338    /// helpers. When a rusqlite release accepts them, this fails: the plain
339    /// SQL in `open` and `cipher_setting`, the rusqlite fork and its patch
340    /// are then no longer needed.
341    #[test]
342    fn crates_io_rusqlite_refuses_sqlcipher_names() {
343        let conn = Connection::open_in_memory().expect("opens");
344        let refused = conn.pragma_update(None, "cipher_log_level", "NONE");
345        assert!(
346            matches!(&refused, Err(rusqlite::Error::SqliteFailure(_, Some(m)))
347                if m == "Invalid pragma \"cipher_log_level\""),
348            "this rusqlite accepts SQLCipher's pragma names ({refused:?}): set them with \
349             its pragma helpers in catalog::open and cipher_setting, and remove the \
350             rusqlite_fork dev-dependency and its patch (see the Development page)"
351        );
352    }
353
354    /// The rusqlite fork is a test-only dependency, and the Development and
355    /// Getting started pages say so with the commit Cargo.toml pins.
356    mod fork_docs {
357        use std::path::PathBuf;
358
359        const FORK: &str = "https://github.com/NormB/rusqlite";
360
361        fn read(relative: &str) -> String {
362            let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(relative);
363            std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{}: {e}", path.display()))
364        }
365
366        fn table(text: &str) -> toml::Table {
367            text.parse().expect("toml")
368        }
369
370        /// The commit the dev-dependency pins, after checking that only the
371        /// tests take rusqlite from the fork.
372        fn pinned() -> String {
373            let manifest = table(&read("Cargo.toml"));
374            let server = &manifest["dependencies"]["rusqlite"];
375            assert!(
376                server.get("version").is_some(),
377                "the server's rusqlite has a version"
378            );
379            assert!(
380                server.get("git").is_none() && server.get("path").is_none(),
381                "the server's rusqlite comes from crates.io"
382            );
383            let fork = &manifest["dev-dependencies"]["rusqlite_fork"];
384            assert_eq!(fork["package"].as_str(), Some("rusqlite"));
385            assert_eq!(fork["git"].as_str(), Some(FORK));
386            let rev = fork["rev"].as_str().expect("a rev").to_owned();
387            assert!(
388                rev.len() == 40 && rev.bytes().all(|b| b.is_ascii_hexdigit()),
389                "a full commit id: {rev}"
390            );
391            rev
392        }
393
394        #[test]
395        fn only_the_fork_is_patched() {
396            pinned();
397            let workspace = table(&read("../../Cargo.toml"));
398            let patch = workspace["patch"].as_table().expect("a patch table");
399            assert_eq!(
400                patch.keys().collect::<Vec<_>>(),
401                [FORK],
402                "no crates-io patch"
403            );
404            let sys: Vec<&String> = patch[FORK].as_table().expect("a table").keys().collect();
405            assert_eq!(sys, ["libsqlite3-sys"]);
406        }
407
408        #[test]
409        fn the_development_page_names_the_pinned_commit() {
410            let page = read("../../website/content/docs/development.md");
411            let section = page
412                .split("\n## ")
413                .find(|s| s.starts_with("Test dependency from a fork\n"))
414                .expect("a section on the fork");
415            assert!(
416                section.contains(&format!("`{}`", pinned())),
417                "the pinned commit"
418            );
419            assert!(section.contains(FORK), "the fork's address");
420            let lock = read("../../Cargo.lock");
421            let version = lock
422                .split("[[package]]")
423                .find(|p| p.contains(&format!("source = \"git+{FORK}?rev=")))
424                .and_then(|p| p.lines().find_map(|l| l.strip_prefix("version = \"")))
425                .map(|v| v.trim_end_matches('"'))
426                .expect("the fork in Cargo.lock");
427            assert!(
428                section.contains(&format!("`{version}`")),
429                "the fork's version {version}"
430            );
431            let source = include_str!("catalog.rs");
432            for test in [
433                "sqlcipher_settings_read_the_same_through_the_pragma_helpers",
434                "crates_io_rusqlite_refuses_sqlcipher_names",
435            ] {
436                assert!(section.contains(&format!("`{test}`")), "names {test}");
437                assert!(source.contains(&format!("fn {test}()")), "{test} exists");
438            }
439        }
440
441        #[test]
442        fn getting_started_says_what_a_build_fetches() {
443            let page = read("../../website/content/docs/getting-started.md");
444            let build = page
445                .split("\n## ")
446                .find(|s| s.starts_with("Build\n"))
447                .expect("a Build section");
448            assert!(
449                build.contains("github.com/NormB/rusqlite"),
450                "names the fork"
451            );
452            assert!(
453                build.contains("mkdir -p .cargo && cargo vendor >> .cargo/config.toml\n"),
454                "the vendoring command"
455            );
456            assert!(build.contains("cargo build --release --offline\n"));
457            let release = page
458                .split("\n## ")
459                .find(|s| s.starts_with("Or download a release\n"))
460                .expect("a release section");
461            assert!(
462                release.contains("crates.io"),
463                "says which rusqlite a release holds"
464            );
465        }
466    }
467
468    #[test]
469    fn the_schema_is_made_once_at_the_current_version() {
470        let dir = tempfile::tempdir().expect("temp dir");
471        let key = Key::generate().expect("key");
472        let conn = open(&path(&dir), &key, SCHEMA).expect("opens");
473        let version: i64 = conn
474            .pragma_query_value(None, "user_version", |r| r.get(0))
475            .expect("version");
476        assert_eq!(version, SCHEMA_VERSION);
477        conn.execute("INSERT INTO note (text) VALUES ('kept')", [])
478            .expect("insert");
479        drop(conn);
480        let conn =
481            open(&path(&dir), &key, SCHEMA).expect("reopens without making the schema again");
482        let kept: String = conn
483            .query_row("SELECT text FROM note", [], |r| r.get(0))
484            .expect("row");
485        assert_eq!(kept, "kept");
486    }
487
488    #[test]
489    fn an_unknown_schema_version_is_refused() {
490        let dir = tempfile::tempdir().expect("temp dir");
491        let key = Key::generate().expect("key");
492        let conn = open(&path(&dir), &key, SCHEMA).expect("opens");
493        conn.pragma_update(None, "user_version", SCHEMA_VERSION + 1)
494            .expect("set version");
495        drop(conn);
496        assert!(matches!(
497            open(&path(&dir), &key, SCHEMA),
498            Err(CatalogError::UnknownSchema(v)) if v == SCHEMA_VERSION + 1
499        ));
500    }
501
502    /// A catalog made at version 1 opens at version 2, its rows kept. One
503    /// with no vCon table, as server.sqlite is, changes only its number;
504    /// the tenant catalog's upgrade is tested with the Store.
505    #[test]
506    fn a_version_1_catalog_is_upgraded_to_version_2() {
507        let dir = tempfile::tempdir().expect("temp dir");
508        let key = Key::generate().expect("key");
509        let conn = open(&path(&dir), &key, SCHEMA).expect("opens");
510        conn.execute("INSERT INTO note (text) VALUES ('kept')", [])
511            .expect("insert");
512        conn.pragma_update(None, "user_version", 1)
513            .expect("set version");
514        drop(conn);
515        let conn = open(&path(&dir), &key, "").expect("upgrades");
516        let version: i64 = conn
517            .pragma_query_value(None, "user_version", |r| r.get(0))
518            .expect("version");
519        assert_eq!(version, 2);
520        assert_eq!(notes(&conn), vec!["kept"]);
521    }
522
523    /// The path a re-encryption writes before its rename.
524    fn rekeyed(dir: &tempfile::TempDir) -> std::path::PathBuf {
525        dir.path().join("catalog.sqlite.rekey")
526    }
527
528    fn notes(conn: &Connection) -> Vec<String> {
529        conn.prepare("SELECT text FROM note ORDER BY rowid")
530            .expect("prepare")
531            .query_map([], |r| r.get(0))
532            .expect("query")
533            .collect::<Result<_, _>>()
534            .expect("rows")
535    }
536
537    #[test]
538    fn a_reencrypted_catalog_opens_under_the_new_key_only_and_keeps_every_row() {
539        let dir = tempfile::tempdir().expect("temp dir");
540        let (old, new) = (Key::generate().expect("key"), Key::generate().expect("key"));
541        let conn = open(&path(&dir), &old, SCHEMA).expect("opens");
542        for n in 0..300 {
543            conn.execute("INSERT INTO note (text) VALUES (?1)", [format!("note {n}")])
544                .expect("insert");
545        }
546        drop(conn);
547        reencrypt(&path(&dir), &old, &new).expect("re-encrypted");
548        let raw = std::fs::read(path(&dir)).expect("the file");
549        assert!(
550            !raw.windows(8).any(|w| w == b"note 299"),
551            "no row is written in the clear"
552        );
553        assert!(matches!(
554            open(&path(&dir), &old, SCHEMA),
555            Err(CatalogError::WrongKey)
556        ));
557        let conn = open(&path(&dir), &new, SCHEMA).expect("opens under the new key");
558        let kept = notes(&conn);
559        assert_eq!(kept.len(), 300);
560        assert_eq!(kept[299], "note 299");
561        let version: i64 = conn
562            .pragma_query_value(None, "user_version", |r| r.get(0))
563            .expect("version");
564        assert_eq!(version, SCHEMA_VERSION, "the schema version is kept");
565        let mode: String = conn
566            .pragma_query_value(None, "journal_mode", |r| r.get(0))
567            .expect("mode");
568        assert_eq!(mode, "wal");
569        drop(conn);
570        let mut left: Vec<String> = std::fs::read_dir(dir.path())
571            .expect("list")
572            .map(|e| e.expect("entry").file_name().to_string_lossy().into_owned())
573            .collect();
574        left.sort();
575        assert_eq!(left, ["catalog.sqlite"], "no temporary file is left");
576    }
577
578    #[test]
579    fn a_file_left_by_an_interrupted_reencryption_is_replaced() {
580        let dir = tempfile::tempdir().expect("temp dir");
581        let (old, new) = (Key::generate().expect("key"), Key::generate().expect("key"));
582        let conn = open(&path(&dir), &old, SCHEMA).expect("opens");
583        conn.execute("INSERT INTO note (text) VALUES ('kept')", [])
584            .expect("insert");
585        drop(conn);
586        std::fs::write(rekeyed(&dir), b"half a file").expect("leftover");
587        reencrypt(&path(&dir), &old, &new).expect("re-encrypted");
588        let conn = open(&path(&dir), &new, SCHEMA).expect("opens");
589        assert_eq!(notes(&conn), ["kept"]);
590        assert!(!rekeyed(&dir).exists());
591    }
592
593    /// A WAL holding frames belongs to its own file: renaming that file
594    /// without them would lose committed rows, and leaving the old one's
595    /// beside the new file would corrupt it. Either refuses the rename.
596    #[test]
597    fn a_replace_refuses_while_either_wal_holds_frames() {
598        let dir = tempfile::tempdir().expect("temp dir");
599        let key = Key::generate().expect("key");
600        let copy = dir.path().join("catalog.sqlite.rekey");
601        for busy in [path(&dir), copy.clone()] {
602            for file in [path(&dir), copy.clone()] {
603                for leftover in ["", "-wal", "-shm"] {
604                    let _ = std::fs::remove_file(sibling(&file, leftover));
605                }
606                drop(open(&file, &key, SCHEMA).expect("made"));
607            }
608            let held = open(&busy, &key, SCHEMA).expect("opens");
609            held.pragma_update(None, "wal_autocheckpoint", 0)
610                .expect("no checkpoint");
611            held.execute("INSERT INTO note (text) VALUES ('in the wal')", [])
612                .expect("insert");
613            assert!(
614                std::fs::metadata(sibling(&busy, "-wal")).is_ok_and(|m| m.len() > 0),
615                "the WAL holds the row"
616            );
617            assert!(
618                matches!(replace(&path(&dir), &copy), Err(CatalogError::Io(_))),
619                "{}",
620                busy.display()
621            );
622            assert!(copy.exists(), "nothing was renamed");
623            drop(held);
624        }
625    }
626
627    #[test]
628    fn reencrypting_under_the_wrong_key_changes_nothing() {
629        let dir = tempfile::tempdir().expect("temp dir");
630        let (old, new) = (Key::generate().expect("key"), Key::generate().expect("key"));
631        drop(open(&path(&dir), &old, SCHEMA).expect("opens"));
632        let other = Key::generate().expect("key");
633        assert!(matches!(
634            reencrypt(&path(&dir), &other, &new),
635            Err(CatalogError::WrongKey)
636        ));
637        assert!(
638            open(&path(&dir), &old, SCHEMA).is_ok(),
639            "still under the old key"
640        );
641    }
642
643    #[test]
644    fn a_wrong_key_does_not_open_the_catalog() {
645        let dir = tempfile::tempdir().expect("temp dir");
646        let key = Key::generate().expect("key");
647        drop(open(&path(&dir), &key, SCHEMA).expect("opens"));
648        let other = Key::generate().expect("key");
649        assert!(matches!(
650            open(&path(&dir), &other, SCHEMA),
651            Err(CatalogError::WrongKey)
652        ));
653        assert!(
654            open(&path(&dir), &key, SCHEMA).is_ok(),
655            "the right key still opens it"
656        );
657    }
658
659    /// A row that cannot insert at all would make every column look as if
660    /// it refused NULL.
661    #[test]
662    #[should_panic(expected = "a whole note row inserts")]
663    fn the_null_check_needs_a_row_that_inserts() {
664        let dir = tempfile::tempdir().expect("temp dir");
665        let key = Key::generate().expect("key");
666        let conn = open(&path(&dir), &key, SCHEMA).expect("opens");
667        testing::columns_taking_null(&conn, "note", &[("misspelt", "'a'")]);
668    }
669
670    /// Run while the connection is open, so the commit is still in the WAL.
671    #[test]
672    fn no_plain_record_reaches_the_catalog_or_its_wal() {
673        let dir = tempfile::tempdir().expect("temp dir");
674        let key = Key::generate().expect("key");
675        let conn = open(&path(&dir), &key, SCHEMA).expect("opens");
676        conn.execute("INSERT INTO note (text) VALUES (?1)", [MARKER])
677            .expect("insert");
678        let wal = std::fs::metadata(dir.path().join("catalog.sqlite-wal")).expect("a WAL");
679        assert!(wal.len() > 0, "the commit is in the WAL");
680        for entry in std::fs::read_dir(dir.path()).expect("list") {
681            let file = entry.expect("entry").path();
682            let bytes = std::fs::read(&file).expect("read");
683            assert!(
684                !bytes.windows(MARKER.len()).any(|w| w == MARKER.as_bytes()),
685                "plain record in {}",
686                file.display()
687            );
688        }
689        drop(conn);
690    }
691}