Skip to main content

chio_store_sqlite/
schema_version.rs

1//! Schema stamping shared by every Chio operator store.
2//!
3//! Each store stamps `PRAGMA application_id` (to tell a Chio store apart from an
4//! unrelated SQLite file) and records its schema revision in a keyed metadata
5//! table, then refuses to open a database whose revision is newer than this
6//! binary understands or whose contents are not provably ours. The checks run
7//! on the open path only, so they add no cost to the append hot path.
8//!
9//! Per-store revisions live in a keyed table rather than the database-wide
10//! `PRAGMA user_version` because the sidecar co-locates several stores in one
11//! SQLite file. One global pragma cannot represent independent revisions, so a
12//! sibling store bumping its schema would otherwise make every unchanged
13//! co-located store refuse to open the shared file as if it were from the future.
14//!
15//! The `application_id` is shared by every Chio store, so it proves a file is a
16//! Chio store but not which one. To keep a path mistargeted at a sibling store
17//! (opening a budget or authority database as a receipt store) from writing this
18//! store's tables into it, a populated database must also carry one of this
19//! store's anchor tables; otherwise it is refused as belonging to another store.
20//!
21//! Fail-closed: a foreign, misdirected, or future database is refused before any
22//! write, so a rollback to an older binary or a mistargeted path is caught at
23//! open rather than after data has been commingled or a newer schema misread.
24
25use rusqlite::{params, Connection, OptionalExtension};
26
27/// ASCII "CHIO" as a big-endian `i32`, stamped into every Chio operator store.
28pub const CHIO_SQLITE_APPLICATION_ID: i32 = 0x4348_494f;
29
30/// Table holding each co-located store's schema revision, keyed by a stable
31/// per-store identifier. `PRAGMA user_version` is database-wide and so cannot
32/// give the receipt and approval stores (which the sidecar keeps in one file)
33/// independent revisions; a keyed table can.
34const SCHEMA_VERSION_TABLE: &str = "chio_store_schema_versions";
35
36/// Failure to validate or apply a store's schema stamp.
37#[derive(Debug, thiserror::Error)]
38pub enum SchemaVersionError {
39    #[error("sqlite error: {0}")]
40    Sqlite(#[from] rusqlite::Error),
41    #[error("database application_id {found:#x} is not a Chio store (expected {expected:#x})")]
42    ForeignDatabase { found: i32, expected: i32 },
43    #[error(
44        "database carries the Chio application_id but none of this store's tables ({expected_anchors:?}); refusing to open another store's database"
45    )]
46    MismatchedStore { expected_anchors: Vec<String> },
47    #[error(
48        "database schema version {found} is newer than this binary supports ({supported}); refusing to open"
49    )]
50    FutureSchema { found: i32, supported: i32 },
51}
52
53/// Read and validate the schema stamp, returning the on-disk revision so the
54/// caller can run additive migrations up to `supported_version`. `store_key`
55/// identifies this store's revision within the keyed metadata table, so
56/// co-located stores in one shared file each track their own revision.
57///
58/// An unstamped database (`application_id == 0`) is ambiguous: it is shared by a
59/// freshly created file, a legacy Chio store written before stamping existed,
60/// and countless unrelated SQLite files. It is adopted and stamped only when the
61/// contents prove provenance (the database is empty or carries one of this
62/// store's `legacy_tables`); otherwise it is refused as
63/// [`SchemaVersionError::ForeignDatabase`] rather than commingling Chio tables
64/// into a foreign file.
65///
66/// A database already stamped with the shared Chio `application_id` proves it is
67/// a Chio store, but not that it is *this* store's. A populated stamped database
68/// must therefore also carry one of `legacy_tables`, or it is refused as
69/// [`SchemaVersionError::MismatchedStore`] so a path aimed at a sibling store's
70/// database (a budget or authority file) never gets this store's tables written
71/// into it. An empty stamped database (freshly stamped, tables not yet created)
72/// is always accepted.
73pub fn check_schema_version(
74    conn: &Connection,
75    store_key: &str,
76    supported_version: i32,
77    legacy_tables: &[&str],
78) -> Result<i32, SchemaVersionError> {
79    let app_id: i32 = conn.query_row("PRAGMA application_id", [], |row| row.get(0))?;
80
81    if app_id == 0 {
82        if !database_belongs_to_store(conn, legacy_tables)? {
83            return Err(SchemaVersionError::ForeignDatabase {
84                found: app_id,
85                expected: CHIO_SQLITE_APPLICATION_ID,
86            });
87        }
88        conn.execute_batch(&format!(
89            "PRAGMA application_id = {CHIO_SQLITE_APPLICATION_ID};"
90        ))?;
91        return Ok(0);
92    }
93    if app_id != CHIO_SQLITE_APPLICATION_ID {
94        return Err(SchemaVersionError::ForeignDatabase {
95            found: app_id,
96            expected: CHIO_SQLITE_APPLICATION_ID,
97        });
98    }
99    if !database_belongs_to_store(conn, legacy_tables)? {
100        return Err(SchemaVersionError::MismatchedStore {
101            expected_anchors: legacy_tables
102                .iter()
103                .map(|table| table.to_string())
104                .collect(),
105        });
106    }
107    let on_disk_version = read_store_schema_version(conn, store_key)?;
108    if on_disk_version > supported_version {
109        return Err(SchemaVersionError::FutureSchema {
110            found: on_disk_version,
111            supported: supported_version,
112        });
113    }
114    Ok(on_disk_version)
115}
116
117/// The schema revision recorded for `store_key`, or 0 when this store has never
118/// stamped one into the database (a fresh file, a legacy pre-stamping store, or
119/// a co-located sibling that has not run yet). A missing metadata table is
120/// treated the same as a missing row: revision 0.
121fn read_store_schema_version(
122    conn: &Connection,
123    store_key: &str,
124) -> Result<i32, SchemaVersionError> {
125    let table_present: bool = conn.query_row(
126        "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1)",
127        [SCHEMA_VERSION_TABLE],
128        |row| row.get(0),
129    )?;
130    if !table_present {
131        return Ok(0);
132    }
133    let version: Option<i32> = conn
134        .query_row(
135            &format!("SELECT version FROM {SCHEMA_VERSION_TABLE} WHERE store_key = ?1"),
136            [store_key],
137            |row| row.get(0),
138        )
139        .optional()?;
140    Ok(version.unwrap_or(0))
141}
142
143/// Whether a database may be adopted as this store's. It qualifies when it has no
144/// user tables (a freshly created or freshly stamped file) or when it carries a
145/// known anchor table (a legacy pre-stamping store, or a reopened store of the
146/// same kind). This keeps an unrelated SQLite file from being written into and
147/// keeps a sibling Chio store's populated database from being mistaken for this
148/// one, without falsely rejecting an empty or same-kind database.
149fn database_belongs_to_store(
150    conn: &Connection,
151    legacy_tables: &[&str],
152) -> Result<bool, SchemaVersionError> {
153    let user_table_count: i64 = conn.query_row(
154        "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'",
155        [],
156        |row| row.get(0),
157    )?;
158    if user_table_count == 0 {
159        return Ok(true);
160    }
161    for table in legacy_tables {
162        let present: bool = conn.query_row(
163            "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1)",
164            [table],
165            |row| row.get(0),
166        )?;
167        if present {
168            return Ok(true);
169        }
170    }
171    Ok(false)
172}
173
174/// Record this store's schema revision in keyed metadata after migrations run.
175///
176/// The revision is keyed by `store_key` rather than written to the database-wide
177/// `PRAGMA user_version`, so co-located stores (the sidecar keeps the receipt and
178/// approval stores in one file) each track their own revision without a sibling's
179/// bump making this store refuse to open the shared file.
180///
181/// The table and column names are compile-time constants; `store_key` and
182/// `version` are store-owned values bound as parameters, never caller input.
183pub fn stamp_schema_version(
184    conn: &Connection,
185    store_key: &str,
186    version: i32,
187) -> Result<(), SchemaVersionError> {
188    conn.execute_batch(&format!(
189        "CREATE TABLE IF NOT EXISTS {SCHEMA_VERSION_TABLE} (
190             store_key TEXT PRIMARY KEY,
191             version INTEGER NOT NULL
192         );"
193    ))?;
194    conn.execute(
195        &format!(
196            "INSERT INTO {SCHEMA_VERSION_TABLE} (store_key, version) VALUES (?1, ?2) \
197             ON CONFLICT(store_key) DO UPDATE SET version = excluded.version"
198        ),
199        params![store_key, version],
200    )?;
201    Ok(())
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207    use rusqlite::Connection;
208
209    const SUPPORTED: i32 = 0;
210    const STORE_KEY: &str = "receipt";
211
212    #[test]
213    fn fresh_empty_db_adopts_v0_and_stamps_application_id() -> Result<(), Box<dyn std::error::Error>>
214    {
215        let conn = Connection::open_in_memory()?;
216        let version = check_schema_version(&conn, STORE_KEY, SUPPORTED, &["chio_tool_receipts"])?;
217        assert_eq!(version, 0);
218        let app_id: i32 = conn.query_row("PRAGMA application_id", [], |r| r.get(0))?;
219        assert_eq!(app_id, CHIO_SQLITE_APPLICATION_ID);
220        Ok(())
221    }
222
223    #[test]
224    fn legacy_unstamped_db_with_anchor_table_adopts_v0() -> Result<(), Box<dyn std::error::Error>> {
225        let conn = Connection::open_in_memory()?;
226        conn.execute_batch("CREATE TABLE chio_tool_receipts (id TEXT PRIMARY KEY);")?;
227        let version = check_schema_version(&conn, STORE_KEY, SUPPORTED, &["chio_tool_receipts"])?;
228        assert_eq!(version, 0);
229        let app_id: i32 = conn.query_row("PRAGMA application_id", [], |r| r.get(0))?;
230        assert_eq!(app_id, CHIO_SQLITE_APPLICATION_ID);
231        Ok(())
232    }
233
234    #[test]
235    fn foreign_db_with_unknown_tables_is_refused() -> Result<(), Box<dyn std::error::Error>> {
236        let conn = Connection::open_in_memory()?;
237        conn.execute_batch("CREATE TABLE someone_elses_table (id TEXT PRIMARY KEY);")?;
238        let error = check_schema_version(&conn, STORE_KEY, SUPPORTED, &["chio_tool_receipts"]);
239        assert!(matches!(
240            error,
241            Err(SchemaVersionError::ForeignDatabase { .. })
242        ));
243        let app_id: i32 = conn.query_row("PRAGMA application_id", [], |r| r.get(0))?;
244        assert_eq!(app_id, 0, "a foreign database is not stamped");
245        Ok(())
246    }
247
248    #[test]
249    fn foreign_application_id_is_refused() -> Result<(), Box<dyn std::error::Error>> {
250        let conn = Connection::open_in_memory()?;
251        conn.execute_batch("PRAGMA application_id = 305419896;")?; // 0x12345678
252        let error = check_schema_version(&conn, STORE_KEY, SUPPORTED, &["chio_tool_receipts"]);
253        assert!(matches!(
254            error,
255            Err(SchemaVersionError::ForeignDatabase { .. })
256        ));
257        Ok(())
258    }
259
260    #[test]
261    fn stamped_database_of_a_different_store_kind_is_refused(
262    ) -> Result<(), Box<dyn std::error::Error>> {
263        // A budget database carries the shared Chio application_id but none of
264        // the receipt store's anchor tables. Opening it as a receipt store must
265        // fail closed rather than write receipt tables into the budget file.
266        let conn = Connection::open_in_memory()?;
267        conn.execute_batch(&format!(
268            "PRAGMA application_id = {CHIO_SQLITE_APPLICATION_ID};
269             CREATE TABLE capability_grant_budgets (id TEXT PRIMARY KEY);"
270        ))?;
271        let error = check_schema_version(&conn, STORE_KEY, SUPPORTED, &["chio_tool_receipts"]);
272        assert!(matches!(
273            error,
274            Err(SchemaVersionError::MismatchedStore { .. })
275        ));
276        Ok(())
277    }
278
279    #[test]
280    fn stamped_database_carrying_a_store_anchor_is_accepted(
281    ) -> Result<(), Box<dyn std::error::Error>> {
282        let conn = Connection::open_in_memory()?;
283        conn.execute_batch(&format!(
284            "PRAGMA application_id = {CHIO_SQLITE_APPLICATION_ID};
285             CREATE TABLE chio_tool_receipts (receipt_id TEXT PRIMARY KEY);"
286        ))?;
287        assert_eq!(
288            check_schema_version(&conn, STORE_KEY, SUPPORTED, &["chio_tool_receipts"])?,
289            0
290        );
291        Ok(())
292    }
293
294    #[test]
295    fn future_schema_is_refused_without_mutation() -> Result<(), Box<dyn std::error::Error>> {
296        let conn = Connection::open_in_memory()?;
297        conn.execute_batch(&format!(
298            "PRAGMA application_id = {CHIO_SQLITE_APPLICATION_ID};
299             CREATE TABLE chio_tool_receipts (receipt_id TEXT PRIMARY KEY);"
300        ))?;
301        stamp_schema_version(&conn, STORE_KEY, 5)?;
302        let error = check_schema_version(&conn, STORE_KEY, 3, &["chio_tool_receipts"]);
303        assert!(matches!(
304            error,
305            Err(SchemaVersionError::FutureSchema {
306                found: 5,
307                supported: 3
308            })
309        ));
310        assert_eq!(
311            read_store_schema_version(&conn, STORE_KEY)?,
312            5,
313            "a refused future database is not mutated"
314        );
315        Ok(())
316    }
317
318    #[test]
319    fn stamp_then_reopen_reports_stamped_version() -> Result<(), Box<dyn std::error::Error>> {
320        let conn = Connection::open_in_memory()?;
321        check_schema_version(&conn, STORE_KEY, 2, &["chio_tool_receipts"])?;
322        conn.execute_batch("CREATE TABLE chio_tool_receipts (receipt_id TEXT PRIMARY KEY);")?;
323        stamp_schema_version(&conn, STORE_KEY, 2)?;
324        let version = check_schema_version(&conn, STORE_KEY, 2, &["chio_tool_receipts"])?;
325        assert_eq!(version, 2);
326        Ok(())
327    }
328
329    #[test]
330    fn co_located_stores_track_independent_schema_versions(
331    ) -> Result<(), Box<dyn std::error::Error>> {
332        // The receipt and approval stores share one sidecar SQLite file. A bump to
333        // one store's schema revision must not make an unchanged sibling refuse to
334        // open the file, so revisions are keyed metadata rather than the
335        // database-wide `PRAGMA user_version`.
336        let conn = Connection::open_in_memory()?;
337        check_schema_version(&conn, "approval", 0, &["chio_hitl_pending"])?;
338        conn.execute_batch("CREATE TABLE chio_hitl_pending (approval_id TEXT PRIMARY KEY);")?;
339        stamp_schema_version(&conn, "approval", 0)?;
340
341        // A newer co-located receipt store bumps its own revision in the shared
342        // file. The database-wide pragma must stay untouched, or every co-located
343        // store would read a future schema and refuse to open.
344        stamp_schema_version(&conn, "receipt", 1)?;
345        let database_wide: i32 = conn.query_row("PRAGMA user_version", [], |row| row.get(0))?;
346        assert_eq!(
347            database_wide, 0,
348            "per-store revisions must not be written to the database-wide pragma"
349        );
350
351        // The unchanged approval store still opens at v0; the receipt store at v1.
352        assert_eq!(
353            check_schema_version(&conn, "approval", 0, &["chio_hitl_pending"])?,
354            0
355        );
356        assert_eq!(
357            check_schema_version(&conn, "receipt", 1, &["chio_hitl_pending"])?,
358            1
359        );
360        Ok(())
361    }
362
363    #[test]
364    fn every_own_file_store_stamps_application_id() -> Result<(), Box<dyn std::error::Error>> {
365        let dir = tempfile::tempdir()?;
366        let stamped = |name: &str| -> Result<i32, Box<dyn std::error::Error>> {
367            let conn = Connection::open(dir.path().join(name))?;
368            Ok(conn.query_row("PRAGMA application_id", [], |r| r.get(0))?)
369        };
370
371        crate::SqliteRevocationStore::open(dir.path().join("revocation.db"))?;
372        crate::SqliteBudgetStore::open(dir.path().join("budget.db"))?;
373        crate::SqliteApprovalStore::open(dir.path().join("approval.db"))?;
374        crate::SqliteBatchApprovalStore::open(dir.path().join("batch.db"))?;
375        crate::SqliteExecutionNonceStore::open(dir.path().join("nonce.db"))?;
376        crate::SqliteMemoryProvenanceStore::open(dir.path().join("provenance.db"))?;
377        crate::SqliteEncryptedBlobStore::open(dir.path().join("blob.db"))?;
378        crate::SqliteCapabilityAuthority::open(dir.path().join("authority.db"))?;
379
380        for name in [
381            "revocation.db",
382            "budget.db",
383            "approval.db",
384            "batch.db",
385            "nonce.db",
386            "provenance.db",
387            "blob.db",
388            "authority.db",
389        ] {
390            assert_eq!(
391                stamped(name)?,
392                CHIO_SQLITE_APPLICATION_ID,
393                "{name} not stamped"
394            );
395        }
396        Ok(())
397    }
398
399    #[test]
400    fn approval_store_adopts_a_receipt_first_sidecar_file() -> Result<(), Box<dyn std::error::Error>>
401    {
402        // `chio api protect` opens the receipt store first on one shared sidecar
403        // file, then co-locates the approval store onto it. The approval store's
404        // co-located open must adopt the receipt-anchored file rather than reject
405        // it as another store's database.
406        let dir = tempfile::tempdir()?;
407        let path = dir.path().join("sidecar.db");
408        crate::SqliteReceiptStore::open(&path)?;
409        crate::SqliteApprovalStore::open_colocated_with_receipt_store(&path)?;
410        Ok(())
411    }
412
413    #[test]
414    fn receipt_store_refuses_a_standalone_approval_database(
415    ) -> Result<(), Box<dyn std::error::Error>> {
416        // A `--receipt-db` mistargeted at a standalone approval database must
417        // fail closed. The receipt store carries no approval table among its
418        // anchors, so it refuses the file rather than adopting it and commingling
419        // receipt tables into an approval store's database.
420        let dir = tempfile::tempdir()?;
421        let path = dir.path().join("approval.db");
422        crate::SqliteApprovalStore::open(&path)?;
423        assert!(crate::SqliteReceiptStore::open(&path).is_err());
424        Ok(())
425    }
426
427    #[test]
428    fn receipt_store_refuses_a_stamped_budget_database() -> Result<(), Box<dyn std::error::Error>> {
429        // A path mistargeted at a budget database must fail closed instead of
430        // writing receipt tables into another store's file.
431        let dir = tempfile::tempdir()?;
432        let path = dir.path().join("budget.db");
433        crate::SqliteBudgetStore::open(&path)?;
434        assert!(crate::SqliteReceiptStore::open(&path).is_err());
435        Ok(())
436    }
437
438    #[test]
439    fn approval_store_refuses_a_standalone_revocation_database(
440    ) -> Result<(), Box<dyn std::error::Error>> {
441        // The revocation store lives in its own file alongside the sidecar
442        // database. A `--receipt-store` mistargeted at that revocation file must
443        // fail closed: `chio api protect` opens the approval store first, so if a
444        // lone `revoked_capabilities` table let it adopt the file, it would write
445        // approval tables into the revocation database and the receipt store would
446        // then accept the commingled file too.
447        let dir = tempfile::tempdir()?;
448        let path = dir.path().join("sidecar.db.revocations");
449        crate::SqliteRevocationStore::open(&path)?;
450        assert!(crate::SqliteApprovalStore::open(&path).is_err());
451        assert!(crate::SqliteReceiptStore::open(&path).is_err());
452        Ok(())
453    }
454
455    // Table-driven schema-version monotonicity (this crate has no proptest
456    // dev-dependency): for every v_disk <= v_bin the reopen is stable and never
457    // downgrades; for v_disk > v_bin the open refuses and leaves the file
458    // unmodified.
459    #[test]
460    fn schema_version_monotonicity_across_binary_and_disk_versions(
461    ) -> Result<(), Box<dyn std::error::Error>> {
462        for v_bin in 0..4i32 {
463            for v_disk in 0..6i32 {
464                let conn = Connection::open_in_memory()?;
465                conn.execute_batch(&format!(
466                    "PRAGMA application_id = {CHIO_SQLITE_APPLICATION_ID};
467                     CREATE TABLE chio_tool_receipts (receipt_id TEXT PRIMARY KEY);"
468                ))?;
469                stamp_schema_version(&conn, STORE_KEY, v_disk)?;
470                let result = check_schema_version(&conn, STORE_KEY, v_bin, &["chio_tool_receipts"]);
471                let after = read_store_schema_version(&conn, STORE_KEY)?;
472                if v_disk > v_bin {
473                    assert!(matches!(
474                        result,
475                        Err(SchemaVersionError::FutureSchema { .. })
476                    ));
477                    assert_eq!(after, v_disk, "a refused database must not be mutated");
478                } else {
479                    assert_eq!(result?, v_disk);
480                    assert_eq!(after, v_disk, "check must not change the stored version");
481                }
482            }
483        }
484        Ok(())
485    }
486}