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 std::fmt::Display for CatalogError {
53    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54        match self {
55            CatalogError::WrongKey => {
56                f.write_str("the key is not the one the catalog was made with")
57            }
58            CatalogError::UnknownSchema(version) => {
59                write!(f, "schema version {version} is not one this build knows")
60            }
61            CatalogError::Sqlite(_) => f.write_str("sqlite failed"),
62            CatalogError::Io(_) => {
63                f.write_str("a file around the catalog could not be read, written or renamed")
64            }
65            CatalogError::Access { path, .. } => {
66                write!(f, "this process may not read and write {}", path.display())
67            }
68        }
69    }
70}
71
72impl std::error::Error for CatalogError {
73    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
74        match self {
75            CatalogError::Sqlite(e) => Some(e),
76            CatalogError::Io(e) | CatalogError::Access { error: e, .. } => Some(e),
77            CatalogError::WrongKey | CatalogError::UnknownSchema(_) => None,
78        }
79    }
80}
81
82impl From<rusqlite::Error> for CatalogError {
83    fn from(e: rusqlite::Error) -> CatalogError {
84        CatalogError::Sqlite(e)
85    }
86}
87
88/// Opens the catalog at `path` under `key`, making it with `schema` if it
89/// is new.
90pub fn open(path: &Path, key: &Key, schema: &str) -> Result<Connection, CatalogError> {
91    // SQLite opens a file it may not write read-only and fails later with
92    // its own code, which no longer says why; asking the system first
93    // gives the path and its error.
94    if let Some(dir) = path.parent() {
95        may_use(dir, libc::W_OK | libc::X_OK)?;
96    }
97    if path.exists() {
98        may_use(path, libc::R_OK | libc::W_OK)?;
99    } else {
100        make_file(path)?;
101    }
102    let conn = Connection::open(path)?;
103    conn.busy_timeout(BUSY_TIMEOUT)?;
104    // SQLCipher otherwise writes its own error lines to standard error, a
105    // wrong key among them; this program says what went wrong itself.
106    // SQLCipher's settings are not in SQLite's pragma list, which rusqlite's
107    // pragma helpers check names against, so they go as plain SQL.
108    conn.execute_batch("PRAGMA cipher_log_level = NONE")?;
109    set_key(&conn, key)?;
110    // SQLCipher reads nothing until the first query, so a wrong key shows
111    // here, as a file that is not a database.
112    match conn.query_row("SELECT count(*) FROM sqlite_master", [], |_| Ok(())) {
113        Err(e) if e.sqlite_error_code() == Some(ErrorCode::NotADatabase) => {
114            return Err(CatalogError::WrongKey);
115        }
116        other => other?,
117    }
118    // Each setting is stated even where this build's default already
119    // matches (synchronous and secure_delete do), so a change of build
120    // flags cannot drop one; the tests read each back.
121    let mode: String = conn.pragma_update_and_check(None, "journal_mode", "WAL", |r| r.get(0))?;
122    debug_assert_eq!(mode, "wal");
123    conn.pragma_update(None, "synchronous", "FULL")?;
124    conn.pragma_update(None, "secure_delete", "ON")?;
125    conn.pragma_update(None, "temp_store", "MEMORY")?;
126    match conn.pragma_query_value(None, "user_version", |r| r.get(0))? {
127        0 => {
128            let tx = conn.unchecked_transaction()?;
129            tx.execute_batch(schema)?;
130            tx.pragma_update(None, "user_version", SCHEMA_VERSION)?;
131            tx.commit()?;
132        }
133        1 => {
134            // One transaction, so a failed upgrade leaves version 1 whole.
135            let tx = conn.unchecked_transaction()?;
136            let tenant: bool = tx.query_row(
137                "SELECT count(*) > 0 FROM sqlite_master WHERE type = 'table' AND name = 'vcon'",
138                [],
139                |r| r.get(0),
140            )?;
141            if tenant {
142                tx.execute_batch(VCON_STORED_AT)?;
143            }
144            tx.pragma_update(None, "user_version", SCHEMA_VERSION)?;
145            tx.commit()?;
146        }
147        SCHEMA_VERSION => {}
148        other => return Err(CatalogError::UnknownSchema(other)),
149    }
150    Ok(conn)
151}
152
153/// Whether this process, by its effective ids, may use `path` as `how`
154/// asks (`R_OK`, `W_OK`, `X_OK`); if not, the path and the system's error.
155pub(crate) fn may_use(path: &Path, how: c_int) -> Result<(), CatalogError> {
156    let denied = |error| CatalogError::Access {
157        path: path.to_owned(),
158        error,
159    };
160    let text = std::ffi::CString::new(path.as_os_str().as_encoded_bytes())
161        .map_err(|_| denied(std::io::ErrorKind::InvalidInput.into()))?;
162    // SAFETY: `text` is a NUL-terminated path that outlives the call, and
163    // faccessat reads only it and the flags.
164    if unsafe { libc::faccessat(libc::AT_FDCWD, text.as_ptr(), how, libc::AT_EACCESS) } != 0 {
165        return Err(denied(std::io::Error::last_os_error()));
166    }
167    Ok(())
168}
169
170/// Copies the catalog `source` is open on to a new file at `to`, kept
171/// under `key`, which must be the key `source` was opened with. It uses
172/// SQLite's online backup API and copies every page in one step, so the
173/// copy is the catalog as one commit left it, the WAL's committed frames
174/// included, while other connections go on writing. `to` must not exist:
175/// it is made empty first, so a file already there is never written over.
176/// The copy and its directory are synced.
177pub fn backup(source: &Connection, to: &Path, key: &Key) -> Result<(), CatalogError> {
178    let io = CatalogError::Io;
179    make_file(to)?;
180    {
181        let mut copy = Connection::open(to)?;
182        copy.execute_batch("PRAGMA cipher_log_level = NONE")?;
183        set_key(&copy, key)?;
184        let backup = rusqlite::backup::Backup::new(source, &mut copy)?;
185        // A negative count copies every remaining page in this one step.
186        match backup.step(-1)? {
187            rusqlite::backup::StepResult::Done => {}
188            _ => {
189                return Err(io(std::io::Error::other(
190                    "the catalog was busy and could not be copied in one step",
191                )));
192            }
193        }
194    }
195    std::fs::File::open(to)
196        .and_then(|f| f.sync_all())
197        .map_err(io)?;
198    let dir = to.parent().unwrap_or(Path::new("."));
199    std::fs::File::open(dir)
200        .and_then(|f| f.sync_all())
201        .map_err(io)?;
202    Ok(())
203}
204
205/// Re-encrypts the catalog at `path`, kept under `old`, so that it is kept
206/// under `new`: every row, the schema and its version are written to a new
207/// file under `new`, which then replaces the old one by rename. A crash
208/// leaves either the whole old file or the whole new one, and nothing of
209/// the old file's pages is carried into the new one. A file left by an
210/// interrupted run is replaced.
211pub fn reencrypt(path: &Path, old: &Key, new: &Key) -> Result<(), CatalogError> {
212    let copy = copy_under(path, old, new)?;
213    replace(path, &copy)
214}
215
216/// Writes every row, the schema and its version of the catalog at `path`,
217/// kept under `old`, to a new file beside it kept under `new`, and returns
218/// that file's path. The old file is only read. A file left there by an
219/// interrupted run is replaced.
220pub fn copy_under(path: &Path, old: &Key, new: &Key) -> Result<PathBuf, CatalogError> {
221    let target = sibling(path, ".rekey");
222    for stale in [
223        target.clone(),
224        sibling(&target, "-wal"),
225        sibling(&target, "-shm"),
226    ] {
227        remove_if_there(&stale)?;
228    }
229    make_file(&target)?;
230    let conn = open(path, old, "")?;
231    let version: i64 = conn.pragma_query_value(None, "user_version", |r| r.get(0))?;
232    let target_text = target
233        .to_str()
234        .ok_or_else(|| CatalogError::Io(std::io::Error::other("the catalog path is not UTF-8")))?;
235    conn.execute("ATTACH DATABASE ?1 AS rekeyed", [target_text])?;
236    set_key_of(&conn, c"rekeyed", new)?;
237    conn.query_row("SELECT sqlcipher_export('rekeyed')", [], |_| Ok(()))?;
238    // A pragma takes no bound parameter; the version is a number read
239    // from the catalog itself.
240    conn.execute_batch(&format!("PRAGMA rekeyed.user_version = {version}"))?;
241    conn.execute("DETACH DATABASE rekeyed", [])?;
242    Ok(target)
243}
244
245/// Makes `copy` the catalog at `path`: it is synced, renamed over `path`,
246/// and the directory synced. Every connection to either file must be
247/// closed. Their WAL files must be empty, as a last close leaves them; one
248/// still holding frames would sit beside the wrong file, so it is refused.
249pub fn replace(path: &Path, copy: &Path) -> Result<(), CatalogError> {
250    for file in [path, copy] {
251        let wal = sibling(file, "-wal");
252        if std::fs::metadata(&wal).is_ok_and(|m| m.len() > 0) {
253            return Err(CatalogError::Io(std::io::Error::other(
254                "a catalog's WAL was not emptied",
255            )));
256        }
257        remove_if_there(&wal)?;
258        remove_if_there(&sibling(file, "-shm"))?;
259    }
260    let io = CatalogError::Io;
261    std::fs::File::open(copy)
262        .and_then(|f| f.sync_all())
263        .map_err(io)?;
264    std::fs::rename(copy, path).map_err(io)?;
265    let dir = path.parent().unwrap_or(Path::new("."));
266    std::fs::File::open(dir)
267        .and_then(|f| f.sync_all())
268        .map_err(io)?;
269    Ok(())
270}
271
272/// Makes an empty file at `path`, which must not exist, with the server's
273/// file mode: SQLite then opens it as a new database, and makes its WAL
274/// and shared-memory files with the same mode.
275fn make_file(path: &Path) -> Result<(), CatalogError> {
276    use std::os::unix::fs::OpenOptionsExt;
277    std::fs::OpenOptions::new()
278        .write(true)
279        .create_new(true)
280        .mode(crate::fs::FILE_MODE)
281        .open(path)
282        .map(drop)
283        .map_err(CatalogError::Io)
284}
285
286fn remove_if_there(file: &Path) -> Result<(), CatalogError> {
287    match std::fs::remove_file(file) {
288        Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(CatalogError::Io(e)),
289        _ => Ok(()),
290    }
291}
292
293/// `path` with `suffix` added to its file name.
294fn sibling(path: &Path, suffix: &str) -> PathBuf {
295    let mut name = path.as_os_str().to_owned();
296    name.push(suffix);
297    PathBuf::from(name)
298}
299
300/// Keys the attached database `schema` as `set_key` keys the main one.
301fn set_key_of(conn: &Connection, schema: &std::ffi::CStr, key: &Key) -> Result<(), CatalogError> {
302    let literal = key.sqlcipher_raw();
303    let len = c_int::try_from(literal.len()).expect("a 67-byte literal");
304    // SAFETY: the handle is this open connection's; the name is a valid C
305    // string and the key pointer and length are the literal's, both of
306    // which outlive the call.
307    let rc = unsafe {
308        rusqlite::ffi::sqlite3_key_v2(conn.handle(), schema.as_ptr(), literal.as_ptr().cast(), len)
309    };
310    if rc != rusqlite::ffi::SQLITE_OK {
311        return Err(rusqlite::Error::SqliteFailure(rusqlite::ffi::Error::new(rc), None).into());
312    }
313    Ok(())
314}
315
316/// Gives SQLCipher the key as a raw key, never a passphrase. sqlite3_key
317/// takes the literal straight from its zeroing buffer; a PRAGMA would copy
318/// it into SQL text that is not zeroed.
319fn set_key(conn: &Connection, key: &Key) -> Result<(), CatalogError> {
320    let literal = key.sqlcipher_raw();
321    let len = c_int::try_from(literal.len()).expect("a 67-byte literal");
322    // SAFETY: the handle is this open connection's, and the pointer and
323    // length are the literal's, which outlives the call.
324    let rc = unsafe { rusqlite::ffi::sqlite3_key(conn.handle(), literal.as_ptr().cast(), len) };
325    // sqlite3_key does not fail on an open connection, so no test reaches
326    // this check; it stays to fail closed.
327    if rc != rusqlite::ffi::SQLITE_OK {
328        return Err(rusqlite::Error::SqliteFailure(rusqlite::ffi::Error::new(rc), None).into());
329    }
330    Ok(())
331}
332
333/// Checks on a catalog's schema, for tests.
334#[cfg(test)]
335pub(crate) mod testing {
336    use rusqlite::Connection;
337
338    /// The columns of `table` that take NULL. `row` gives a value for each
339    /// column; each column is tried as NULL in that row, and nothing is
340    /// kept. The whole row must insert, so a refusal is the column's.
341    pub(crate) fn columns_taking_null(
342        conn: &Connection,
343        table: &str,
344        row: &[(&str, &str)],
345    ) -> Vec<String> {
346        let insert = |empty: Option<&str>| {
347            let columns: Vec<&str> = row.iter().map(|(c, _)| *c).collect();
348            let values: Vec<&str> = row
349                .iter()
350                .map(|(c, v)| if Some(*c) == empty { "NULL" } else { *v })
351                .collect();
352            let sql = format!(
353                "INSERT INTO {table} ({}) VALUES ({})",
354                columns.join(", "),
355                values.join(", ")
356            );
357            let tx = conn.unchecked_transaction().expect("begin");
358            let taken = tx.execute(&sql, []).is_ok();
359            drop(tx);
360            taken
361        };
362        assert!(insert(None), "a whole {table} row inserts");
363        row.iter()
364            .filter(|(column, _)| insert(Some(column)))
365            .map(|(column, _)| format!("{table}.{column}"))
366            .collect()
367    }
368}
369
370#[cfg(test)]
371pub(crate) mod tests {
372    use super::*;
373
374    const SCHEMA: &str = "CREATE TABLE note (text TEXT NOT NULL);";
375    const MARKER: &str = "plain-record-marker-5f3c9a";
376
377    fn path(dir: &tempfile::TempDir) -> std::path::PathBuf {
378        dir.path().join("catalog.sqlite")
379    }
380
381    /// Reads one of SQLCipher's own settings, which rusqlite's pragma
382    /// helpers refuse by name.
383    pub(crate) fn cipher_setting(conn: &Connection, name: &str) -> String {
384        conn.query_row(&format!("PRAGMA {name}"), [], |r| r.get(0))
385            .expect(name)
386    }
387
388    #[test]
389    fn a_catalog_runs_with_the_required_settings() {
390        let dir = tempfile::tempdir().expect("temp dir");
391        let key = Key::generate().expect("key");
392        let conn = open(&path(&dir), &key, SCHEMA).expect("opens");
393        let text = |p: &str| -> String { conn.pragma_query_value(None, p, |r| r.get(0)).expect(p) };
394        let number = |p: &str| -> i64 { conn.pragma_query_value(None, p, |r| r.get(0)).expect(p) };
395        assert_eq!(text("journal_mode"), "wal");
396        assert_eq!(number("synchronous"), 2, "FULL");
397        assert_eq!(number("secure_delete"), 1);
398        assert_eq!(number("temp_store"), 2, "MEMORY");
399        assert_eq!(number("busy_timeout"), 5000, "milliseconds");
400        assert_eq!(cipher_setting(&conn, "cipher_provider"), "openssl");
401        assert_eq!(
402            cipher_setting(&conn, "cipher_log_level"),
403            "NONE",
404            "SQLCipher writes no log of its own"
405        );
406    }
407
408    /// The SQLCipher settings a catalog runs with, read back through
409    /// rusqlite's pragma helpers on a second connection to the same file,
410    /// equal what the plain SQL that `open` and `cipher_setting` use reads.
411    /// crates.io rusqlite 0.41.0 refuses these names in its helpers, so
412    /// this runs on the fork that accepts them; the values must not differ
413    /// between the two ways of setting and reading them.
414    #[test]
415    fn sqlcipher_settings_read_the_same_through_the_pragma_helpers() {
416        let dir = tempfile::tempdir().expect("temp dir");
417        let key = Key::generate().expect("key");
418        let conn = open(&path(&dir), &key, SCHEMA).expect("opens");
419        let names = ["cipher_log_level", "cipher_provider", "cipher_page_size"];
420        let plain: Vec<String> = names.iter().map(|n| cipher_setting(&conn, n)).collect();
421
422        let helpers = rusqlite_fork::Connection::open(path(&dir)).expect("opens again");
423        let literal = key.sqlcipher_raw();
424        let len = c_int::try_from(literal.len()).expect("a 67-byte literal");
425        // SAFETY: the handle is this open connection's, and the pointer and
426        // length are the literal's, which outlives the call.
427        let rc = unsafe {
428            rusqlite_fork::ffi::sqlite3_key(helpers.handle(), literal.as_ptr().cast(), len)
429        };
430        assert_eq!(rc, rusqlite_fork::ffi::SQLITE_OK);
431        // Keyed first: the helper reads SQLite's pragma list, which an
432        // encrypted file without its key cannot answer.
433        helpers
434            .pragma_update(None, "cipher_log_level", "NONE")
435            .expect("cipher_log_level through pragma_update");
436        let notes: i64 = helpers
437            .query_row("SELECT count(*) FROM note", [], |r| r.get(0))
438            .expect("the key opens the same catalog");
439        assert_eq!(notes, 0);
440        let read: Vec<String> = names
441            .iter()
442            .map(|n| {
443                helpers
444                    .pragma_query_value(None, n, |r| r.get(0))
445                    .unwrap_or_else(|e| panic!("{n} through pragma_query_value: {e}"))
446            })
447            .collect();
448        assert_eq!(read, plain);
449        assert_eq!(plain[0], "NONE");
450    }
451
452    /// The server's rusqlite still refuses SQLCipher's pragma names in its
453    /// helpers. When a rusqlite release accepts them, this fails: the plain
454    /// SQL in `open` and `cipher_setting`, the rusqlite fork and its patch
455    /// are then no longer needed.
456    #[test]
457    fn crates_io_rusqlite_refuses_sqlcipher_names() {
458        let conn = Connection::open_in_memory().expect("opens");
459        let refused = conn.pragma_update(None, "cipher_log_level", "NONE");
460        assert!(
461            matches!(&refused, Err(rusqlite::Error::SqliteFailure(_, Some(m)))
462                if m == "Invalid pragma \"cipher_log_level\""),
463            "this rusqlite accepts SQLCipher's pragma names ({refused:?}): set them with \
464             its pragma helpers in catalog::open and cipher_setting, and remove the \
465             rusqlite_fork dev-dependency and its patch (see the Development page)"
466        );
467    }
468
469    /// The rusqlite fork is a test-only dependency, and the Development and
470    /// Getting started pages say so with the commit Cargo.toml pins.
471    mod fork_docs {
472        use std::path::PathBuf;
473
474        const FORK: &str = "https://github.com/NormB/rusqlite";
475
476        fn read(relative: &str) -> String {
477            let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(relative);
478            std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{}: {e}", path.display()))
479        }
480
481        fn table(text: &str) -> toml::Table {
482            text.parse().expect("toml")
483        }
484
485        /// The commit the dev-dependency pins, after checking that only the
486        /// tests take rusqlite from the fork.
487        fn pinned() -> String {
488            let manifest = table(&read("Cargo.toml"));
489            let server = &manifest["dependencies"]["rusqlite"];
490            assert!(
491                server.get("version").is_some(),
492                "the server's rusqlite has a version"
493            );
494            assert!(
495                server.get("git").is_none() && server.get("path").is_none(),
496                "the server's rusqlite comes from crates.io"
497            );
498            let fork = &manifest["dev-dependencies"]["rusqlite_fork"];
499            assert_eq!(fork["package"].as_str(), Some("rusqlite"));
500            assert_eq!(fork["git"].as_str(), Some(FORK));
501            let rev = fork["rev"].as_str().expect("a rev").to_owned();
502            assert!(
503                rev.len() == 40 && rev.bytes().all(|b| b.is_ascii_hexdigit()),
504                "a full commit id: {rev}"
505            );
506            rev
507        }
508
509        #[test]
510        fn only_the_fork_is_patched() {
511            pinned();
512            let workspace = table(&read("../../Cargo.toml"));
513            let patch = workspace["patch"].as_table().expect("a patch table");
514            assert_eq!(
515                patch.keys().collect::<Vec<_>>(),
516                [FORK],
517                "no crates-io patch"
518            );
519            let sys: Vec<&String> = patch[FORK].as_table().expect("a table").keys().collect();
520            assert_eq!(sys, ["libsqlite3-sys"]);
521        }
522
523        #[test]
524        fn the_development_page_names_the_pinned_commit() {
525            let page = read("../../website/content/docs/development.md");
526            let section = page
527                .split("\n## ")
528                .find(|s| s.starts_with("Test dependency from a fork\n"))
529                .expect("a section on the fork");
530            assert!(
531                section.contains(&format!("`{}`", pinned())),
532                "the pinned commit"
533            );
534            assert!(section.contains(FORK), "the fork's address");
535            let lock = read("../../Cargo.lock");
536            let version = lock
537                .split("[[package]]")
538                .find(|p| p.contains(&format!("source = \"git+{FORK}?rev=")))
539                .and_then(|p| p.lines().find_map(|l| l.strip_prefix("version = \"")))
540                .map(|v| v.trim_end_matches('"'))
541                .expect("the fork in Cargo.lock");
542            assert!(
543                section.contains(&format!("`{version}`")),
544                "the fork's version {version}"
545            );
546            let source = include_str!("catalog.rs");
547            for test in [
548                "sqlcipher_settings_read_the_same_through_the_pragma_helpers",
549                "crates_io_rusqlite_refuses_sqlcipher_names",
550            ] {
551                assert!(section.contains(&format!("`{test}`")), "names {test}");
552                assert!(source.contains(&format!("fn {test}()")), "{test} exists");
553            }
554        }
555
556        #[test]
557        fn the_install_page_says_what_a_build_fetches() {
558            let page = read("../../website/content/docs/install.md");
559            let build = page
560                .split("\n## ")
561                .find(|s| s.starts_with("Build from source\n"))
562                .expect("a Build from source section");
563            assert!(
564                build.contains(FORK.trim_start_matches("https://")),
565                "names the fork Cargo.toml pins, from the one constant both checks read"
566            );
567            assert!(
568                build.contains("mkdir -p .cargo && cargo vendor >> .cargo/config.toml\n"),
569                "the vendoring command"
570            );
571            assert!(build.contains("cargo build --release --offline\n"));
572            let release = page
573                .split("\n## ")
574                .find(|s| s.starts_with("Download a release\n"))
575                .expect("a release section");
576            assert!(
577                release.contains("crates.io"),
578                "says which rusqlite a release holds"
579            );
580        }
581    }
582
583    #[test]
584    fn the_schema_is_made_once_at_the_current_version() {
585        let dir = tempfile::tempdir().expect("temp dir");
586        let key = Key::generate().expect("key");
587        let conn = open(&path(&dir), &key, SCHEMA).expect("opens");
588        let version: i64 = conn
589            .pragma_query_value(None, "user_version", |r| r.get(0))
590            .expect("version");
591        assert_eq!(version, SCHEMA_VERSION);
592        conn.execute("INSERT INTO note (text) VALUES ('kept')", [])
593            .expect("insert");
594        drop(conn);
595        let conn =
596            open(&path(&dir), &key, SCHEMA).expect("reopens without making the schema again");
597        let kept: String = conn
598            .query_row("SELECT text FROM note", [], |r| r.get(0))
599            .expect("row");
600        assert_eq!(kept, "kept");
601    }
602
603    #[test]
604    fn an_unknown_schema_version_is_refused() {
605        let dir = tempfile::tempdir().expect("temp dir");
606        let key = Key::generate().expect("key");
607        let conn = open(&path(&dir), &key, SCHEMA).expect("opens");
608        conn.pragma_update(None, "user_version", SCHEMA_VERSION + 1)
609            .expect("set version");
610        drop(conn);
611        assert!(matches!(
612            open(&path(&dir), &key, SCHEMA),
613            Err(CatalogError::UnknownSchema(v)) if v == SCHEMA_VERSION + 1
614        ));
615    }
616
617    /// A catalog made at version 1 opens at version 2, its rows kept. One
618    /// with no vCon table, as server.sqlite is, changes only its number;
619    /// the tenant catalog's upgrade is tested with the Store.
620    #[test]
621    fn a_version_1_catalog_is_upgraded_to_version_2() {
622        let dir = tempfile::tempdir().expect("temp dir");
623        let key = Key::generate().expect("key");
624        let conn = open(&path(&dir), &key, SCHEMA).expect("opens");
625        conn.execute("INSERT INTO note (text) VALUES ('kept')", [])
626            .expect("insert");
627        conn.pragma_update(None, "user_version", 1)
628            .expect("set version");
629        drop(conn);
630        let conn = open(&path(&dir), &key, "").expect("upgrades");
631        let version: i64 = conn
632            .pragma_query_value(None, "user_version", |r| r.get(0))
633            .expect("version");
634        assert_eq!(version, 2);
635        assert_eq!(notes(&conn), vec!["kept"]);
636    }
637
638    /// The path a re-encryption writes before its rename.
639    fn rekeyed(dir: &tempfile::TempDir) -> std::path::PathBuf {
640        dir.path().join("catalog.sqlite.rekey")
641    }
642
643    fn notes(conn: &Connection) -> Vec<String> {
644        conn.prepare("SELECT text FROM note ORDER BY rowid")
645            .expect("prepare")
646            .query_map([], |r| r.get(0))
647            .expect("query")
648            .collect::<Result<_, _>>()
649            .expect("rows")
650    }
651
652    #[test]
653    fn a_reencrypted_catalog_opens_under_the_new_key_only_and_keeps_every_row() {
654        let dir = tempfile::tempdir().expect("temp dir");
655        let (old, new) = (Key::generate().expect("key"), Key::generate().expect("key"));
656        let conn = open(&path(&dir), &old, SCHEMA).expect("opens");
657        for n in 0..300 {
658            conn.execute("INSERT INTO note (text) VALUES (?1)", [format!("note {n}")])
659                .expect("insert");
660        }
661        drop(conn);
662        reencrypt(&path(&dir), &old, &new).expect("re-encrypted");
663        let raw = std::fs::read(path(&dir)).expect("the file");
664        assert!(
665            !raw.windows(8).any(|w| w == b"note 299"),
666            "no row is written in the clear"
667        );
668        assert!(matches!(
669            open(&path(&dir), &old, SCHEMA),
670            Err(CatalogError::WrongKey)
671        ));
672        let conn = open(&path(&dir), &new, SCHEMA).expect("opens under the new key");
673        let kept = notes(&conn);
674        assert_eq!(
675            kept,
676            (0..300).map(|n| format!("note {n}")).collect::<Vec<_>>(),
677            "every row is kept, each with its own text, in order"
678        );
679        let version: i64 = conn
680            .pragma_query_value(None, "user_version", |r| r.get(0))
681            .expect("version");
682        assert_eq!(version, SCHEMA_VERSION, "the schema version is kept");
683        let mode: String = conn
684            .pragma_query_value(None, "journal_mode", |r| r.get(0))
685            .expect("mode");
686        assert_eq!(mode, "wal");
687        drop(conn);
688        let mut left: Vec<String> = std::fs::read_dir(dir.path())
689            .expect("list")
690            .map(|e| e.expect("entry").file_name().to_string_lossy().into_owned())
691            .collect();
692        left.sort();
693        assert_eq!(left, ["catalog.sqlite"], "no temporary file is left");
694    }
695
696    #[test]
697    fn a_file_left_by_an_interrupted_reencryption_is_replaced() {
698        let dir = tempfile::tempdir().expect("temp dir");
699        let (old, new) = (Key::generate().expect("key"), Key::generate().expect("key"));
700        let conn = open(&path(&dir), &old, SCHEMA).expect("opens");
701        conn.execute("INSERT INTO note (text) VALUES ('kept')", [])
702            .expect("insert");
703        drop(conn);
704        std::fs::write(rekeyed(&dir), b"half a file").expect("leftover");
705        reencrypt(&path(&dir), &old, &new).expect("re-encrypted");
706        let conn = open(&path(&dir), &new, SCHEMA).expect("opens");
707        assert_eq!(notes(&conn), ["kept"]);
708        assert!(!rekeyed(&dir).exists());
709    }
710
711    /// A WAL holding frames belongs to its own file: renaming that file
712    /// without them would lose committed rows, and leaving the old one's
713    /// beside the new file would corrupt it. Either refuses the rename.
714    #[test]
715    fn a_replace_refuses_while_either_wal_holds_frames() {
716        let dir = tempfile::tempdir().expect("temp dir");
717        let key = Key::generate().expect("key");
718        let copy = dir.path().join("catalog.sqlite.rekey");
719        for busy in [path(&dir), copy.clone()] {
720            for file in [path(&dir), copy.clone()] {
721                for leftover in ["", "-wal", "-shm"] {
722                    let _ = std::fs::remove_file(sibling(&file, leftover));
723                }
724                drop(open(&file, &key, SCHEMA).expect("made"));
725            }
726            let held = open(&busy, &key, SCHEMA).expect("opens");
727            held.pragma_update(None, "wal_autocheckpoint", 0)
728                .expect("no checkpoint");
729            held.execute("INSERT INTO note (text) VALUES ('in the wal')", [])
730                .expect("insert");
731            assert!(
732                std::fs::metadata(sibling(&busy, "-wal")).is_ok_and(|m| m.len() > 0),
733                "the WAL holds the row"
734            );
735            assert!(
736                matches!(replace(&path(&dir), &copy), Err(CatalogError::Io(_))),
737                "{}",
738                busy.display()
739            );
740            assert!(copy.exists(), "nothing was renamed");
741            drop(held);
742        }
743    }
744
745    #[test]
746    fn reencrypting_under_the_wrong_key_changes_nothing() {
747        let dir = tempfile::tempdir().expect("temp dir");
748        let (old, new) = (Key::generate().expect("key"), Key::generate().expect("key"));
749        drop(open(&path(&dir), &old, SCHEMA).expect("opens"));
750        let other = Key::generate().expect("key");
751        assert!(matches!(
752            reencrypt(&path(&dir), &other, &new),
753            Err(CatalogError::WrongKey)
754        ));
755        assert!(
756            open(&path(&dir), &old, SCHEMA).is_ok(),
757            "still under the old key"
758        );
759    }
760
761    #[test]
762    fn a_backup_holds_every_committed_row_and_opens_under_the_same_key_only() {
763        let dir = tempfile::tempdir().expect("temp dir");
764        let key = Key::generate().expect("key");
765        let source = open(&path(&dir), &key, SCHEMA).expect("opens");
766        source
767            .execute("INSERT INTO note (text) VALUES ('committed')", [])
768            .expect("insert");
769        // A second connection holds a write transaction open: its row is
770        // not committed, so the copy must not hold it.
771        let writer = open(&path(&dir), &key, SCHEMA).expect("opens");
772        writer.execute_batch("BEGIN IMMEDIATE").expect("begin");
773        writer
774            .execute("INSERT INTO note (text) VALUES ('uncommitted')", [])
775            .expect("insert");
776        let copy = dir.path().join("copy.sqlite");
777        backup(&source, &copy, &key).expect("backs up");
778        writer.execute_batch("COMMIT").expect("commit");
779        assert!(
780            !sibling(&copy, "-wal").exists()
781                || std::fs::metadata(sibling(&copy, "-wal"))
782                    .expect("wal")
783                    .len()
784                    == 0,
785            "the copy is whole in its own file"
786        );
787        let reopened = open(&copy, &key, "").expect("the copy opens under the same key");
788        let notes: Vec<String> = reopened
789            .prepare("SELECT text FROM note")
790            .expect("prepare")
791            .query_map([], |r| r.get(0))
792            .expect("query")
793            .collect::<Result<_, _>>()
794            .expect("rows");
795        assert_eq!(notes, ["committed"]);
796        let version: i64 = reopened
797            .pragma_query_value(None, "user_version", |r| r.get(0))
798            .expect("version");
799        assert_eq!(version, SCHEMA_VERSION);
800        drop(reopened);
801        let other = Key::generate().expect("key");
802        assert!(matches!(
803            open(&copy, &other, ""),
804            Err(CatalogError::WrongKey)
805        ));
806    }
807
808    #[test]
809    fn a_backup_holds_rows_still_in_the_source_wal_and_no_plain_text() {
810        let dir = tempfile::tempdir().expect("temp dir");
811        let key = Key::generate().expect("key");
812        let source = open(&path(&dir), &key, SCHEMA).expect("opens");
813        source
814            .execute("INSERT INTO note (text) VALUES (?1)", [MARKER])
815            .expect("insert");
816        assert!(
817            std::fs::metadata(sibling(&path(&dir), "-wal"))
818                .expect("wal")
819                .len()
820                > 0,
821            "the row is still in the WAL"
822        );
823        let copy = dir.path().join("copy.sqlite");
824        backup(&source, &copy, &key).expect("backs up");
825        let bytes = std::fs::read(&copy).expect("read the copy");
826        assert!(
827            !bytes.windows(MARKER.len()).any(|w| w == MARKER.as_bytes()),
828            "the copy is encrypted"
829        );
830        let reopened = open(&copy, &key, "").expect("opens");
831        let count: i64 = reopened
832            .query_row("SELECT count(*) FROM note WHERE text = ?1", [MARKER], |r| {
833                r.get(0)
834            })
835            .expect("count");
836        assert_eq!(count, 1);
837    }
838
839    #[test]
840    fn a_backup_never_writes_over_a_file() {
841        let dir = tempfile::tempdir().expect("temp dir");
842        let key = Key::generate().expect("key");
843        let source = open(&path(&dir), &key, SCHEMA).expect("opens");
844        let copy = dir.path().join("copy.sqlite");
845        std::fs::write(&copy, b"something else").expect("write");
846        assert!(matches!(
847            backup(&source, &copy, &key),
848            Err(CatalogError::Io(_))
849        ));
850        assert_eq!(std::fs::read(&copy).expect("read"), b"something else");
851    }
852
853    #[test]
854    fn a_wrong_key_does_not_open_the_catalog() {
855        let dir = tempfile::tempdir().expect("temp dir");
856        let key = Key::generate().expect("key");
857        drop(open(&path(&dir), &key, SCHEMA).expect("opens"));
858        let other = Key::generate().expect("key");
859        assert!(matches!(
860            open(&path(&dir), &other, SCHEMA),
861            Err(CatalogError::WrongKey)
862        ));
863        assert!(
864            open(&path(&dir), &key, SCHEMA).is_ok(),
865            "the right key still opens it"
866        );
867    }
868
869    /// A catalog file or directory this process may not write is named,
870    /// with the system's error, and never taken for a wrong key.
871    #[test]
872    fn a_catalog_the_process_may_not_write_is_an_access_error_naming_the_path() {
873        use std::os::unix::fs::PermissionsExt;
874        let mode = |p: &Path, m| {
875            std::fs::set_permissions(p, std::fs::Permissions::from_mode(m)).expect("chmod")
876        };
877        let dir = tempfile::tempdir().expect("temp dir");
878        let key = Key::generate().expect("key");
879        drop(open(&path(&dir), &key, SCHEMA).expect("opens"));
880        for (target, read_only) in [(path(&dir), 0o400), (dir.path().to_owned(), 0o500)] {
881            mode(&target, read_only);
882            let refused = open(&path(&dir), &key, SCHEMA);
883            mode(&target, if target.is_dir() { 0o700 } else { 0o600 });
884            match refused {
885                Err(CatalogError::Access { path, error }) => {
886                    assert_eq!(path, target);
887                    assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied);
888                }
889                other => panic!("{}: {other:?}", target.display()),
890            }
891        }
892        assert!(
893            open(&path(&dir), &key, SCHEMA).is_ok(),
894            "opens once allowed"
895        );
896    }
897
898    /// A row that cannot insert at all would make every column look as if
899    /// it refused NULL.
900    #[test]
901    #[should_panic(expected = "a whole note row inserts")]
902    fn the_null_check_needs_a_row_that_inserts() {
903        let dir = tempfile::tempdir().expect("temp dir");
904        let key = Key::generate().expect("key");
905        let conn = open(&path(&dir), &key, SCHEMA).expect("opens");
906        testing::columns_taking_null(&conn, "note", &[("misspelt", "'a'")]);
907    }
908
909    /// Run while the connection is open, so the commit is still in the WAL.
910    #[test]
911    fn no_plain_record_reaches_the_catalog_or_its_wal() {
912        let dir = tempfile::tempdir().expect("temp dir");
913        let key = Key::generate().expect("key");
914        let conn = open(&path(&dir), &key, SCHEMA).expect("opens");
915        conn.execute("INSERT INTO note (text) VALUES (?1)", [MARKER])
916            .expect("insert");
917        let wal = std::fs::metadata(dir.path().join("catalog.sqlite-wal")).expect("a WAL");
918        assert!(wal.len() > 0, "the commit is in the WAL");
919        for entry in std::fs::read_dir(dir.path()).expect("list") {
920            let file = entry.expect("entry").path();
921            let bytes = std::fs::read(&file).expect("read");
922            assert!(
923                !bytes.windows(MARKER.len()).any(|w| w == MARKER.as_bytes()),
924                "plain record in {}",
925                file.display()
926            );
927        }
928        drop(conn);
929    }
930}