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    /// This process may not read and write `path`, the catalog or the
44    /// directory that holds it (where SQLite makes its `-wal` and `-shm`
45    /// files), such as when another user owns it.
46    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
58/// Opens the catalog at `path` under `key`, making it with `schema` if it
59/// is new.
60pub fn open(path: &Path, key: &Key, schema: &str) -> Result<Connection, CatalogError> {
61    // SQLite opens a file it may not write read-only and fails later with
62    // its own code, which no longer says why; asking the system first
63    // gives the path and its error.
64    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    // SQLCipher otherwise writes its own error lines to standard error, a
73    // wrong key among them; this program says what went wrong itself.
74    // SQLCipher's settings are not in SQLite's pragma list, which rusqlite's
75    // pragma helpers check names against, so they go as plain SQL.
76    conn.execute_batch("PRAGMA cipher_log_level = NONE")?;
77    set_key(&conn, key)?;
78    // SQLCipher reads nothing until the first query, so a wrong key shows
79    // here, as a file that is not a database.
80    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    // Each setting is stated even where this build's default already
87    // matches (synchronous and secure_delete do), so a change of build
88    // flags cannot drop one; the tests read each back.
89    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            // One transaction, so a failed upgrade leaves version 1 whole.
103            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
121/// Whether this process, by its effective ids, may use `path` as `how`
122/// asks (`R_OK`, `W_OK`, `X_OK`); if not, the path and the system's error.
123pub(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    // SAFETY: `text` is a NUL-terminated path that outlives the call, and
131    // faccessat reads only it and the flags.
132    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
138/// Copies the catalog `source` is open on to a new file at `to`, kept
139/// under `key`, which must be the key `source` was opened with. It uses
140/// SQLite's online backup API and copies every page in one step, so the
141/// copy is the catalog as one commit left it, the WAL's committed frames
142/// included, while other connections go on writing. `to` must not exist:
143/// it is made empty first, so a file already there is never written over.
144/// The copy and its directory are synced.
145pub 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(&copy, key)?;
156        let backup = rusqlite::backup::Backup::new(source, &mut copy)?;
157        // A negative count copies every remaining page in this one step.
158        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
177/// Re-encrypts the catalog at `path`, kept under `old`, so that it is kept
178/// under `new`: every row, the schema and its version are written to a new
179/// file under `new`, which then replaces the old one by rename. A crash
180/// leaves either the whole old file or the whole new one, and nothing of
181/// the old file's pages is carried into the new one. A file left by an
182/// interrupted run is replaced.
183pub fn reencrypt(path: &Path, old: &Key, new: &Key) -> Result<(), CatalogError> {
184    let copy = copy_under(path, old, new)?;
185    replace(path, &copy)
186}
187
188/// Writes every row, the schema and its version of the catalog at `path`,
189/// kept under `old`, to a new file beside it kept under `new`, and returns
190/// that file's path. The old file is only read. A file left there by an
191/// interrupted run is replaced.
192pub 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    // A pragma takes no bound parameter; the version is a number read
210    // from the catalog itself.
211    conn.execute_batch(&format!("PRAGMA rekeyed.user_version = {version}"))?;
212    conn.execute("DETACH DATABASE rekeyed", [])?;
213    Ok(target)
214}
215
216/// Makes `copy` the catalog at `path`: it is synced, renamed over `path`,
217/// and the directory synced. Every connection to either file must be
218/// closed. Their WAL files must be empty, as a last close leaves them; one
219/// still holding frames would sit beside the wrong file, so it is refused.
220pub 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
250/// `path` with `suffix` added to its file name.
251fn 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
257/// Keys the attached database `schema` as `set_key` keys the main one.
258fn 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    // SAFETY: the handle is this open connection's; the name is a valid C
262    // string and the key pointer and length are the literal's, both of
263    // which outlive the call.
264    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
273/// Gives SQLCipher the key as a raw key, never a passphrase. sqlite3_key
274/// takes the literal straight from its zeroing buffer; a PRAGMA would copy
275/// it into SQL text that is not zeroed.
276fn 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    // SAFETY: the handle is this open connection's, and the pointer and
280    // length are the literal's, which outlives the call.
281    let rc = unsafe { rusqlite::ffi::sqlite3_key(conn.handle(), literal.as_ptr().cast(), len) };
282    // sqlite3_key does not fail on an open connection, so no test reaches
283    // this check; it stays to fail closed.
284    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/// Checks on a catalog's schema, for tests.
291#[cfg(test)]
292pub(crate) mod testing {
293    use rusqlite::Connection;
294
295    /// The columns of `table` that take NULL. `row` gives a value for each
296    /// column; each column is tried as NULL in that row, and nothing is
297    /// kept. The whole row must insert, so a refusal is the column's.
298    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    /// Reads one of SQLCipher's own settings, which rusqlite's pragma
339    /// helpers refuse by name.
340    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    /// The SQLCipher settings a catalog runs with, read back through
366    /// rusqlite's pragma helpers on a second connection to the same file,
367    /// equal what the plain SQL that `open` and `cipher_setting` use reads.
368    /// crates.io rusqlite 0.41.0 refuses these names in its helpers, so
369    /// this runs on the fork that accepts them; the values must not differ
370    /// between the two ways of setting and reading them.
371    #[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        // SAFETY: the handle is this open connection's, and the pointer and
383        // length are the literal's, which outlives the call.
384        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        // Keyed first: the helper reads SQLite's pragma list, which an
389        // encrypted file without its key cannot answer.
390        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    /// The server's rusqlite still refuses SQLCipher's pragma names in its
410    /// helpers. When a rusqlite release accepts them, this fails: the plain
411    /// SQL in `open` and `cipher_setting`, the rusqlite fork and its patch
412    /// are then no longer needed.
413    #[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    /// The rusqlite fork is a test-only dependency, and the Development and
427    /// Getting started pages say so with the commit Cargo.toml pins.
428    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        /// The commit the dev-dependency pins, after checking that only the
443        /// tests take rusqlite from the fork.
444        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    /// A catalog made at version 1 opens at version 2, its rows kept. One
575    /// with no vCon table, as server.sqlite is, changes only its number;
576    /// the tenant catalog's upgrade is tested with the Store.
577    #[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    /// The path a re-encryption writes before its rename.
596    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    /// A WAL holding frames belongs to its own file: renaming that file
666    /// without them would lose committed rows, and leaving the old one's
667    /// beside the new file would corrupt it. Either refuses the rename.
668    #[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), &copy), 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        // A second connection holds a write transaction open: its row is
724        // not committed, so the copy must not hold it.
725        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, &copy, &key).expect("backs up");
732        writer.execute_batch("COMMIT").expect("commit");
733        assert!(
734            !sibling(&copy, "-wal").exists()
735                || std::fs::metadata(sibling(&copy, "-wal"))
736                    .expect("wal")
737                    .len()
738                    == 0,
739            "the copy is whole in its own file"
740        );
741        let reopened = open(&copy, &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(&copy, &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, &copy, &key).expect("backs up");
779        let bytes = std::fs::read(&copy).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(&copy, &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(&copy, b"something else").expect("write");
800        assert!(matches!(
801            backup(&source, &copy, &key),
802            Err(CatalogError::Io(_))
803        ));
804        assert_eq!(std::fs::read(&copy).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    /// A catalog file or directory this process may not write is named,
824    /// with the system's error, and never taken for a wrong key.
825    #[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    /// A row that cannot insert at all would make every column look as if
853    /// it refused NULL.
854    #[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    /// Run while the connection is open, so the commit is still in the WAL.
864    #[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}