1use rusqlite::{params, Connection, OptionalExtension};
26
27pub const CHIO_SQLITE_APPLICATION_ID: i32 = 0x4348_494f;
29
30const SCHEMA_VERSION_TABLE: &str = "chio_store_schema_versions";
35
36#[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
53pub 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
117fn 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
143fn 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
174pub 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;")?; 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 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 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 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 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 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 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 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 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 #[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}