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