1use std::fs;
2use std::path::Path;
3use std::sync::{Arc, Mutex, MutexGuard};
4
5use chio_kernel::budget_store::{
6 BudgetEventAuthority, BudgetGuaranteeLevel, RevocationCommitMetadata,
7};
8use chio_kernel::{RevocationObservation, RevocationRecord, RevocationStore, RevocationStoreError};
9use rusqlite::{params, Connection, OptionalExtension, Transaction, TransactionBehavior};
10
11#[derive(Clone)]
12pub struct SqliteRevocationStore {
13 connection: Arc<Mutex<Connection>>,
14 serving_owner: Option<Arc<crate::serving_owner::SqliteServingOwner>>,
15 ephemeral: bool,
20}
21
22fn path_opens_in_memory(path: &Path) -> bool {
27 let Some(value) = path.to_str() else {
28 return false;
29 };
30 if value.eq_ignore_ascii_case(":memory:") {
31 return true;
32 }
33 let Some(rest) = value.strip_prefix("file:") else {
34 return false;
35 };
36 let (name, query) = match rest.split_once('?') {
37 Some((name, query)) => (name, Some(query)),
38 None => (rest, None),
39 };
40 if name.eq_ignore_ascii_case(":memory:") {
41 return true;
42 }
43 query.is_some_and(|query| {
44 query
45 .split('&')
46 .any(|param| param.eq_ignore_ascii_case("mode=memory"))
47 })
48}
49
50pub(crate) const REVOCATION_STORE_SUPPORTED_SCHEMA_VERSION: i32 = 2;
52const REVOCATION_STORE_SCHEMA_KEY: &str = "revocation";
55const REVOCATION_STORE_LEGACY_ANCHOR_TABLES: &[&str] = &["revoked_capabilities"];
58
59impl SqliteRevocationStore {
60 pub fn open(path: impl AsRef<Path>) -> Result<Self, RevocationStoreError> {
61 let path = path.as_ref();
62 let ephemeral = path_opens_in_memory(path);
63 if !ephemeral {
64 if let Some(parent) = crate::sqlite_parent_dir_to_create(path) {
69 fs::create_dir_all(&parent)?;
70 }
71 }
72
73 let mut connection = Connection::open(path)?;
74 if let Some(epoch) = crate::serving_owner::provisioned_owner_epoch(&connection)
75 .map_err(|error| RevocationStoreError::Sync(error.to_string()))?
76 {
77 return Err(RevocationStoreError::Sync(format!(
78 "provisioned sqlite authority store requires joint serving owner at epoch {epoch}"
79 )));
80 }
81 crate::check_schema_version(
82 &connection,
83 REVOCATION_STORE_SCHEMA_KEY,
84 REVOCATION_STORE_SUPPORTED_SCHEMA_VERSION,
85 REVOCATION_STORE_LEGACY_ANCHOR_TABLES,
86 )
87 .map_err(|error| RevocationStoreError::Sync(error.to_string()))?;
88 initialize_revocation_schema(&mut connection, false)?;
89 crate::stamp_schema_version(
90 &connection,
91 REVOCATION_STORE_SCHEMA_KEY,
92 REVOCATION_STORE_SUPPORTED_SCHEMA_VERSION,
93 )
94 .map_err(|error| RevocationStoreError::Sync(error.to_string()))?;
95 verify_revocation_foreign_keys(&connection)?;
96
97 Ok(Self {
98 connection: Arc::new(Mutex::new(connection)),
99 serving_owner: None,
100 ephemeral,
101 })
102 }
103
104 pub(crate) fn open_alongside(
105 connection: Arc<Mutex<Connection>>,
106 serving_owner: Arc<crate::serving_owner::SqliteServingOwner>,
107 ) -> Self {
108 Self {
109 connection,
110 serving_owner: Some(serving_owner),
111 ephemeral: false,
112 }
113 }
114
115 fn connection(&self) -> Result<MutexGuard<'_, Connection>, RevocationStoreError> {
116 self.connection.lock().map_err(|_| {
117 RevocationStoreError::Sync("sqlite revocation store lock poisoned".to_string())
118 })
119 }
120
121 fn begin_write<'a>(
122 &self,
123 connection: &'a mut Connection,
124 ) -> Result<Transaction<'a>, RevocationStoreError> {
125 let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
126 crate::serving_owner::verify_revocation_fence(&transaction, self.serving_owner.as_deref())?;
127 if let Some(owner) = self.serving_owner.as_ref() {
128 owner
129 .verify_authority_anchor(&transaction)
130 .map_err(map_serving_owner_error)?;
131 }
132 Ok(transaction)
133 }
134
135 fn read<T>(
136 &self,
137 query: impl FnOnce(&Transaction<'_>) -> Result<T, RevocationStoreError>,
138 ) -> Result<T, RevocationStoreError> {
139 let mut connection = self.connection()?;
140 let transaction = connection.transaction_with_behavior(TransactionBehavior::Deferred)?;
141 crate::serving_owner::verify_revocation_fence(&transaction, self.serving_owner.as_deref())?;
142 if let Some(owner) = self.serving_owner.as_ref() {
143 owner
144 .verify_authority_anchor(&transaction)
145 .map_err(map_serving_owner_error)?;
146 }
147 let value = query(&transaction)?;
148 transaction.rollback()?;
149 Ok(value)
150 }
151
152 pub fn latest_revocation_index(&self) -> Result<u64, RevocationStoreError> {
153 let sql = if self.serving_owner.is_some() {
154 "SELECT head_index FROM admission_authority_meta WHERE singleton = 1"
155 } else {
156 "SELECT next_index FROM revocation_replication_meta WHERE singleton = 1"
157 };
158 let value = self.read(|transaction| {
159 transaction
160 .query_row(sql, [], |row| row.get::<_, i64>(0))
161 .map_err(Into::into)
162 })?;
163 u64::try_from(value)
164 .map_err(|_| RevocationStoreError::Sync("negative revocation index".to_string()))
165 }
166
167 pub fn list_revocations(
168 &self,
169 limit: usize,
170 capability_id: Option<&str>,
171 ) -> Result<Vec<RevocationRecord>, RevocationStoreError> {
172 self.read(|transaction| {
173 let mut statement = transaction.prepare(
174 r#"
175 SELECT capability_id, revoked_at
176 FROM revoked_capabilities
177 WHERE (?1 IS NULL OR capability_id = ?1)
178 ORDER BY revoked_at DESC, capability_id ASC
179 LIMIT ?2
180 "#,
181 )?;
182 let rows = statement.query_map(params![capability_id, limit as i64], |row| {
183 Ok(RevocationRecord {
184 capability_id: row.get(0)?,
185 revoked_at: row.get(1)?,
186 })
187 })?;
188 rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
189 })
190 }
191
192 pub fn list_revocations_after(
193 &self,
194 limit: usize,
195 after_revoked_at: Option<i64>,
196 after_capability_id: Option<&str>,
197 ) -> Result<Vec<RevocationRecord>, RevocationStoreError> {
198 self.read(|transaction| {
199 let mut statement = transaction.prepare(
200 r#"
201 SELECT capability_id, revoked_at
202 FROM revoked_capabilities
203 WHERE (
204 ?1 IS NULL
205 OR revoked_at > ?1
206 OR (revoked_at = ?1 AND ?2 IS NOT NULL AND capability_id > ?2)
207 )
208 ORDER BY revoked_at ASC, capability_id ASC
209 LIMIT ?3
210 "#,
211 )?;
212 let rows = statement.query_map(
213 params![after_revoked_at, after_capability_id, limit as i64],
214 |row| {
215 Ok(RevocationRecord {
216 capability_id: row.get(0)?,
217 revoked_at: row.get(1)?,
218 })
219 },
220 )?;
221 rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
222 })
223 }
224
225 pub fn upsert_revocation(&self, record: &RevocationRecord) -> Result<(), RevocationStoreError> {
226 let mut connection = self.connection()?;
227 let transaction = self.begin_write(&mut connection)?;
228 let existing = transaction
229 .query_row(
230 "SELECT revoked_at FROM revoked_capabilities WHERE capability_id = ?1",
231 params![&record.capability_id],
232 |row| row.get::<_, i64>(0),
233 )
234 .optional()?;
235 if existing.is_some_and(|revoked_at| revoked_at >= record.revoked_at) {
236 transaction.rollback()?;
237 return Ok(());
238 }
239 let joint = self.serving_owner.is_some();
240 let revocation_index = allocate_revocation_index(&transaction, joint)?;
241 if joint {
242 transaction.execute(
243 r#"
244 INSERT INTO revoked_capabilities (
245 capability_id, revoked_at,
246 admission_authority_commit_index
247 ) VALUES (?1, ?2, ?3)
248 ON CONFLICT(capability_id) DO UPDATE SET
249 revoked_at = excluded.revoked_at,
250 admission_authority_commit_index =
251 excluded.admission_authority_commit_index
252 "#,
253 params![record.capability_id, record.revoked_at, revocation_index],
254 )?;
255 append_admission_revocation_commit(
256 &transaction,
257 revocation_index,
258 &record.capability_id,
259 )?;
260 self.serving_owner
261 .as_ref()
262 .ok_or_else(|| {
263 RevocationStoreError::Sync(
264 "joint revocation mutation lost its serving owner".to_string(),
265 )
266 })?
267 .append_global_commit(
268 &transaction,
269 "revocation_upsert",
270 "revocation",
271 &record.capability_id,
272 stored_index(revocation_index)?,
273 )
274 .map_err(map_serving_owner_error)?;
275 } else {
276 transaction.execute(
277 r#"
278 INSERT INTO revoked_capabilities (
279 capability_id, revoked_at, revocation_index
280 ) VALUES (?1, ?2, ?3)
281 ON CONFLICT(capability_id) DO UPDATE SET
282 revoked_at = excluded.revoked_at,
283 revocation_index = excluded.revocation_index
284 "#,
285 params![record.capability_id, record.revoked_at, revocation_index],
286 )?;
287 }
288 commit_mutation(self, transaction)?;
289 if let Some(owner) = self.serving_owner.as_ref() {
290 owner
291 .sync_authority_anchor(&connection)
292 .map_err(map_serving_owner_error)?;
293 }
294 Ok(())
295 }
296
297 pub fn latest_revocation_cursor(&self) -> Result<Option<(i64, String)>, RevocationStoreError> {
301 self.read(|transaction| {
302 transaction
303 .query_row(
304 "SELECT revoked_at, capability_id FROM revoked_capabilities \
305 ORDER BY revoked_at DESC, capability_id DESC LIMIT 1",
306 [],
307 |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)),
308 )
309 .optional()
310 .map_err(Into::into)
311 })
312 }
313}
314
315impl RevocationStore for SqliteRevocationStore {
316 fn is_revoked(&self, capability_id: &str) -> Result<bool, RevocationStoreError> {
317 let exists = self.read(|transaction| {
318 transaction
319 .query_row(
320 "SELECT EXISTS(SELECT 1 FROM revoked_capabilities WHERE capability_id = ?1)",
321 params![capability_id],
322 |row| row.get::<_, i64>(0),
323 )
324 .map_err(Into::into)
325 })?;
326 Ok(exists != 0)
327 }
328
329 fn revoke(&self, capability_id: &str) -> Result<bool, RevocationStoreError> {
330 let revoked_at = std::time::SystemTime::now()
331 .duration_since(std::time::UNIX_EPOCH)
332 .map(|duration| duration.as_secs() as i64)
333 .unwrap_or(0);
334 let mut connection = self.connection()?;
335 let transaction = self.begin_write(&mut connection)?;
336 let exists = transaction.query_row(
337 "SELECT EXISTS(SELECT 1 FROM revoked_capabilities WHERE capability_id = ?1)",
338 params![capability_id],
339 |row| row.get::<_, bool>(0),
340 )?;
341 if exists {
342 transaction.rollback()?;
343 return Ok(false);
344 }
345 let joint = self.serving_owner.is_some();
346 let revocation_index = allocate_revocation_index(&transaction, joint)?;
347 let index_column = if joint {
348 "admission_authority_commit_index"
349 } else {
350 "revocation_index"
351 };
352 let inserted = transaction
353 .query_row(
354 &format!(
355 r#"
356 INSERT INTO revoked_capabilities (
357 capability_id, revoked_at, {index_column}
358 ) VALUES (?1, ?2, ?3)
359 ON CONFLICT(capability_id) DO NOTHING RETURNING 1
360 "#
361 ),
362 params![capability_id, revoked_at, revocation_index],
363 |row| row.get::<_, i64>(0),
364 )
365 .optional()?;
366 if inserted.is_some() {
367 if joint {
368 append_admission_revocation_commit(&transaction, revocation_index, capability_id)?;
369 self.serving_owner
370 .as_ref()
371 .ok_or_else(|| {
372 RevocationStoreError::Sync(
373 "joint revocation mutation lost its serving owner".to_string(),
374 )
375 })?
376 .append_global_commit(
377 &transaction,
378 "revocation_revoke",
379 "revocation",
380 capability_id,
381 stored_index(revocation_index)?,
382 )
383 .map_err(map_serving_owner_error)?;
384 }
385 commit_mutation(self, transaction)?;
386 if let Some(owner) = self.serving_owner.as_ref() {
387 owner
388 .sync_authority_anchor(&connection)
389 .map_err(map_serving_owner_error)?;
390 }
391 } else {
392 transaction.rollback()?;
393 }
394 Ok(inserted.is_some())
395 }
396
397 fn observe_revocation(
398 &self,
399 capability_id: &str,
400 ) -> Result<RevocationObservation, RevocationStoreError> {
401 let sql = if self.serving_owner.is_some() {
402 r#"
403 SELECT EXISTS(
404 SELECT 1 FROM revoked_capabilities
405 WHERE capability_id = ?1
406 ),
407 head_index
408 FROM admission_authority_meta WHERE singleton = 1
409 "#
410 } else {
411 r#"
412 SELECT EXISTS(
413 SELECT 1 FROM revoked_capabilities
414 WHERE capability_id = ?1
415 ),
416 next_index
417 FROM revocation_replication_meta WHERE singleton = 1
418 "#
419 };
420 let (revoked, commit_index) = self.read(|transaction| {
421 transaction
422 .query_row(sql, params![capability_id], |row| {
423 Ok((row.get::<_, bool>(0)?, row.get::<_, i64>(1)?))
424 })
425 .map_err(Into::into)
426 })?;
427 Ok(RevocationObservation {
428 revoked,
429 commit: self
430 .serving_owner
431 .as_ref()
432 .map(|owner| {
433 Ok::<_, RevocationStoreError>(RevocationCommitMetadata {
434 authority: BudgetEventAuthority {
435 authority_id: owner.fence.store_uuid.clone(),
436 lease_id: owner.fence.lease_id.clone(),
437 lease_epoch: owner.fence.owner_epoch,
438 },
439 guarantee_level: BudgetGuaranteeLevel::SingleNodeAtomic,
440 commit_index: u64::try_from(commit_index).map_err(|_| {
441 RevocationStoreError::Sync(
442 "negative revocation commit index".to_string(),
443 )
444 })?,
445 })
446 })
447 .transpose()?,
448 })
449 }
450
451 fn is_ephemeral(&self) -> bool {
452 self.ephemeral
453 }
454}
455
456pub(crate) fn initialize_revocation_schema(
457 connection: &mut Connection,
458 shared_authority_sequence: bool,
459) -> Result<(), RevocationStoreError> {
460 connection.execute_batch(
461 r#"
462 PRAGMA journal_mode = WAL;
463 PRAGMA synchronous = FULL;
464 PRAGMA busy_timeout = 5000;
465 PRAGMA foreign_keys = ON;
466
467 CREATE TABLE IF NOT EXISTS revoked_capabilities (
468 capability_id TEXT PRIMARY KEY,
469 revoked_at INTEGER NOT NULL,
470 revocation_index INTEGER UNIQUE,
471 admission_authority_commit_index INTEGER UNIQUE
472 );
473
474 CREATE INDEX IF NOT EXISTS idx_revoked_capabilities_revoked_at
475 ON revoked_capabilities(revoked_at);
476
477 "#,
478 )?;
479 ensure_revocation_index_column(connection)?;
480 ensure_admission_authority_index_column(connection)?;
481
482 if !shared_authority_sequence {
483 connection.execute_batch(
484 r#"
485 CREATE TABLE IF NOT EXISTS revocation_replication_meta (
486 singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
487 next_index INTEGER NOT NULL CHECK (next_index >= 0)
488 );
489 "#,
490 )?;
491 } else {
492 connection.execute_batch(
493 r#"
494 CREATE TABLE IF NOT EXISTS admission_authority_meta (
495 singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
496 head_index INTEGER NOT NULL CHECK (head_index > 0)
497 );
498
499 CREATE TABLE IF NOT EXISTS admission_authority_commits (
500 commit_index INTEGER PRIMARY KEY CHECK (commit_index > 0),
501 kind TEXT NOT NULL CHECK (kind IN ('genesis', 'revocation')),
502 capability_id TEXT,
503 CHECK (
504 (kind = 'genesis' AND capability_id IS NULL)
505 OR
506 (kind = 'revocation' AND capability_id IS NOT NULL)
507 ),
508 FOREIGN KEY (capability_id)
509 REFERENCES revoked_capabilities(capability_id)
510 );
511
512 CREATE TABLE IF NOT EXISTS chio_authority_migrations (
513 migration_key TEXT PRIMARY KEY CHECK (migration_key <> ''),
514 completed_index INTEGER NOT NULL CHECK (completed_index > 0)
515 );
516 "#,
517 )?;
518 }
519
520 let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
521 if shared_authority_sequence {
522 transaction.execute(
523 r#"
524 INSERT INTO admission_authority_meta (singleton, head_index)
525 VALUES (1, 1)
526 ON CONFLICT(singleton) DO NOTHING
527 "#,
528 [],
529 )?;
530 transaction.execute(
531 r#"
532 INSERT INTO admission_authority_commits (
533 commit_index, kind, capability_id
534 ) VALUES (1, 'genesis', NULL)
535 ON CONFLICT(commit_index) DO NOTHING
536 "#,
537 [],
538 )?;
539 let converted = transaction.query_row(
540 r#"
541 SELECT EXISTS(
542 SELECT 1 FROM chio_authority_migrations
543 WHERE migration_key = 'revocation-admission-authority-v1'
544 )
545 "#,
546 [],
547 |row| row.get::<_, bool>(0),
548 )?;
549 if !converted {
550 transaction.execute(
551 "UPDATE revoked_capabilities SET admission_authority_commit_index = NULL",
552 [],
553 )?;
554 transaction.execute(
555 "DELETE FROM admission_authority_commits WHERE kind = 'revocation'",
556 [],
557 )?;
558 }
559 let mut next_index = if converted {
560 transaction.query_row(
561 r#"
562 SELECT MAX(
563 (SELECT head_index FROM admission_authority_meta
564 WHERE singleton = 1),
565 COALESCE((SELECT MAX(commit_index)
566 FROM admission_authority_commits), 1)
567 )
568 "#,
569 [],
570 |row| row.get::<_, i64>(0),
571 )?
572 } else {
573 1
574 };
575 let capability_ids = {
576 let mut statement = transaction.prepare(
577 r#"
578 SELECT capability_id FROM revoked_capabilities
579 WHERE admission_authority_commit_index IS NULL
580 ORDER BY revoked_at, capability_id
581 "#,
582 )?;
583 let rows = statement
584 .query_map([], |row| row.get::<_, String>(0))?
585 .collect::<Result<Vec<_>, _>>()?;
586 rows
587 };
588 for capability_id in capability_ids {
589 next_index = next_index.checked_add(1).ok_or_else(|| {
590 RevocationStoreError::Sync(
591 "admission authority commit index overflowed i64".to_string(),
592 )
593 })?;
594 transaction.execute(
595 r#"
596 UPDATE revoked_capabilities
597 SET admission_authority_commit_index = ?2
598 WHERE capability_id = ?1
599 "#,
600 params![&capability_id, next_index],
601 )?;
602 append_admission_revocation_commit(&transaction, next_index, &capability_id)?;
603 }
604 transaction.execute(
605 "UPDATE admission_authority_meta SET head_index = ?1 WHERE singleton = 1",
606 params![next_index],
607 )?;
608 transaction.execute(
609 r#"
610 INSERT INTO chio_authority_migrations (
611 migration_key, completed_index
612 ) VALUES ('revocation-admission-authority-v1', ?1)
613 ON CONFLICT(migration_key) DO NOTHING
614 "#,
615 params![next_index],
616 )?;
617 let invalid = transaction.query_row(
618 r#"
619 SELECT EXISTS(
620 SELECT 1 FROM revoked_capabilities AS revoked
621 WHERE admission_authority_commit_index IS NULL
622 OR NOT EXISTS (
623 SELECT 1 FROM admission_authority_commits AS committed
624 WHERE committed.commit_index =
625 revoked.admission_authority_commit_index
626 AND committed.kind = 'revocation'
627 AND committed.capability_id = revoked.capability_id
628 )
629 )
630 "#,
631 [],
632 |row| row.get::<_, bool>(0),
633 )?;
634 if invalid {
635 return Err(RevocationStoreError::Sync(
636 "admission authority revocation projection is incomplete".to_string(),
637 ));
638 }
639 } else {
640 let mut next_index = transaction.query_row(
641 "SELECT COALESCE(MAX(revocation_index), 0) FROM revoked_capabilities",
642 [],
643 |row| row.get::<_, i64>(0),
644 )?;
645 let capability_ids = {
646 let mut statement = transaction.prepare(
647 r#"
648 SELECT capability_id FROM revoked_capabilities
649 WHERE revocation_index IS NULL
650 ORDER BY revoked_at, capability_id
651 "#,
652 )?;
653 let rows = statement
654 .query_map([], |row| row.get::<_, String>(0))?
655 .collect::<Result<Vec<_>, _>>()?;
656 rows
657 };
658 for capability_id in capability_ids {
659 next_index = next_index.checked_add(1).ok_or_else(|| {
660 RevocationStoreError::Sync("revocation index overflowed i64".to_string())
661 })?;
662 transaction.execute(
663 "UPDATE revoked_capabilities SET revocation_index = ?2 WHERE capability_id = ?1",
664 params![capability_id, next_index],
665 )?;
666 }
667 transaction.execute(
668 r#"
669 INSERT INTO revocation_replication_meta (singleton, next_index)
670 VALUES (1, ?1)
671 ON CONFLICT(singleton) DO UPDATE SET
672 next_index = MAX(next_index, excluded.next_index)
673 "#,
674 params![next_index],
675 )?;
676 }
677 transaction.commit()?;
678 if shared_authority_sequence {
679 verify_admission_authority_invariants(connection)?;
680 }
681 Ok(())
682}
683
684fn ensure_revocation_index_column(connection: &Connection) -> Result<(), RevocationStoreError> {
685 let mut statement = connection.prepare("PRAGMA table_info(revoked_capabilities)")?;
686 let columns = statement
687 .query_map([], |row| row.get::<_, String>(1))?
688 .collect::<Result<Vec<_>, _>>()?;
689 if !columns.iter().any(|column| column == "revocation_index") {
690 connection.execute(
691 "ALTER TABLE revoked_capabilities ADD COLUMN revocation_index INTEGER",
692 [],
693 )?;
694 connection.execute(
695 "CREATE UNIQUE INDEX idx_revoked_capabilities_index ON revoked_capabilities(revocation_index)",
696 [],
697 )?;
698 }
699 Ok(())
700}
701
702fn ensure_admission_authority_index_column(
703 connection: &Connection,
704) -> Result<(), RevocationStoreError> {
705 let mut statement = connection.prepare("PRAGMA table_info(revoked_capabilities)")?;
706 let columns = statement
707 .query_map([], |row| row.get::<_, String>(1))?
708 .collect::<Result<Vec<_>, _>>()?;
709 if !columns
710 .iter()
711 .any(|column| column == "admission_authority_commit_index")
712 {
713 connection.execute(
714 "ALTER TABLE revoked_capabilities ADD COLUMN admission_authority_commit_index INTEGER",
715 [],
716 )?;
717 }
718 connection.execute(
719 r#"
720 CREATE UNIQUE INDEX IF NOT EXISTS idx_revoked_capabilities_admission_authority
721 ON revoked_capabilities(admission_authority_commit_index)
722 "#,
723 [],
724 )?;
725 Ok(())
726}
727
728fn allocate_revocation_index(
729 transaction: &Transaction<'_>,
730 shared_authority_sequence: bool,
731) -> Result<i64, RevocationStoreError> {
732 let (table, column) = if shared_authority_sequence {
733 ("admission_authority_meta", "head_index")
734 } else {
735 ("revocation_replication_meta", "next_index")
736 };
737 let current = transaction.query_row(
738 &format!("SELECT {column} FROM {table} WHERE singleton = 1"),
739 [],
740 |row| row.get::<_, i64>(0),
741 )?;
742 let next = current
743 .checked_add(1)
744 .ok_or_else(|| RevocationStoreError::Sync("revocation index overflowed i64".to_string()))?;
745 transaction.execute(
746 &format!("UPDATE {table} SET {column} = ?1 WHERE singleton = 1"),
747 params![next],
748 )?;
749 Ok(next)
750}
751
752fn append_admission_revocation_commit(
753 transaction: &Transaction<'_>,
754 commit_index: i64,
755 capability_id: &str,
756) -> Result<(), RevocationStoreError> {
757 transaction.execute(
758 r#"
759 INSERT INTO admission_authority_commits (
760 commit_index, kind, capability_id
761 ) VALUES (?1, 'revocation', ?2)
762 "#,
763 params![commit_index, capability_id],
764 )?;
765 Ok(())
766}
767
768fn stored_index(value: i64) -> Result<u64, RevocationStoreError> {
769 u64::try_from(value)
770 .map_err(|_| RevocationStoreError::Sync("negative revocation index".to_string()))
771}
772
773fn map_serving_owner_error(
774 error: crate::serving_owner::SqliteServingOwnerError,
775) -> RevocationStoreError {
776 match error {
777 crate::serving_owner::SqliteServingOwnerError::OutcomeUnknown(detail) => {
778 RevocationStoreError::OutcomeUnknown(detail)
779 }
780 error => RevocationStoreError::Sync(error.to_string()),
781 }
782}
783
784fn commit_mutation(
785 store: &SqliteRevocationStore,
786 transaction: Transaction<'_>,
787) -> Result<(), RevocationStoreError> {
788 transaction.commit().map_err(|error| {
789 let detail = format!("sqlite revocation commit outcome is unknown: {error}");
790 match store.serving_owner.as_ref() {
791 Some(owner) => map_serving_owner_error(owner.outcome_unknown(detail)),
792 None => RevocationStoreError::OutcomeUnknown(detail),
793 }
794 })
795}
796
797pub(crate) fn verify_admission_authority_invariants(
798 connection: &Connection,
799) -> Result<(), RevocationStoreError> {
800 let (head, commit_count, max_commit, genesis_count, migration_count) = connection.query_row(
801 r#"
802 SELECT
803 (SELECT head_index FROM admission_authority_meta
804 WHERE singleton = 1),
805 (SELECT COUNT(*) FROM admission_authority_commits),
806 (SELECT COALESCE(MAX(commit_index), 0)
807 FROM admission_authority_commits),
808 (SELECT COUNT(*) FROM admission_authority_commits
809 WHERE commit_index = 1 AND kind = 'genesis'
810 AND capability_id IS NULL),
811 (SELECT COUNT(*) FROM chio_authority_migrations
812 WHERE migration_key = 'revocation-admission-authority-v1')
813 "#,
814 [],
815 |row| {
816 Ok((
817 row.get::<_, i64>(0)?,
818 row.get::<_, i64>(1)?,
819 row.get::<_, i64>(2)?,
820 row.get::<_, i64>(3)?,
821 row.get::<_, i64>(4)?,
822 ))
823 },
824 )?;
825 let invalid_projection = connection.query_row(
826 r#"
827 SELECT EXISTS(
828 SELECT 1 FROM revoked_capabilities AS revoked
829 WHERE admission_authority_commit_index IS NULL
830 OR NOT EXISTS (
831 SELECT 1 FROM admission_authority_commits AS committed
832 WHERE committed.commit_index =
833 revoked.admission_authority_commit_index
834 AND committed.kind = 'revocation'
835 AND committed.capability_id = revoked.capability_id
836 )
837 )
838 "#,
839 [],
840 |row| row.get::<_, bool>(0),
841 )?;
842 if head <= 0
843 || commit_count != head
844 || max_commit != head
845 || genesis_count != 1
846 || migration_count != 1
847 || invalid_projection
848 {
849 return Err(RevocationStoreError::Sync(
850 "admission authority commit log is not a dense valid projection".to_string(),
851 ));
852 }
853 Ok(())
854}
855
856fn verify_revocation_foreign_keys(connection: &Connection) -> Result<(), RevocationStoreError> {
857 let violation = connection
858 .query_row("PRAGMA foreign_key_check", [], |row| {
859 Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
860 })
861 .optional()?;
862 if let Some((table, rowid)) = violation {
863 return Err(RevocationStoreError::Sync(format!(
864 "sqlite foreign key violation in `{table}` row {rowid}"
865 )));
866 }
867 Ok(())
868}
869
870#[cfg(test)]
871#[allow(clippy::expect_used, clippy::unwrap_used)]
872mod tests {
873 use std::time::{SystemTime, UNIX_EPOCH};
874
875 use super::*;
876
877 fn unique_db_path(prefix: &str) -> std::path::PathBuf {
878 let nonce = SystemTime::now()
879 .duration_since(UNIX_EPOCH)
880 .expect("time before epoch")
881 .as_nanos();
882 std::env::temp_dir().join(format!("{prefix}-{nonce}.sqlite3"))
883 }
884
885 #[test]
886 fn sqlite_revocation_store_persists_across_reopen() {
887 let path = unique_db_path("chio-revocations");
888 {
889 let store = SqliteRevocationStore::open(&path).unwrap();
890 assert!(!store.is_revoked("cap-1").unwrap());
891 assert!(store.revoke("cap-1").unwrap());
892 assert!(store.is_revoked("cap-1").unwrap());
893 assert!(!store.revoke("cap-1").unwrap());
894 }
895
896 let reopened = SqliteRevocationStore::open(&path).unwrap();
897 assert!(reopened.is_revoked("cap-1").unwrap());
898
899 let _ = fs::remove_file(path);
900 }
901
902 #[test]
903 fn latest_revocation_cursor_returns_head_or_none() -> Result<(), Box<dyn std::error::Error>> {
904 let path = unique_db_path("chio-rev-head");
905 let store = SqliteRevocationStore::open(&path)?;
906 assert_eq!(store.latest_revocation_cursor()?, None);
907 store.upsert_revocation(&RevocationRecord {
908 capability_id: "cap-a".to_string(),
909 revoked_at: 10,
910 })?;
911 store.upsert_revocation(&RevocationRecord {
912 capability_id: "cap-b".to_string(),
913 revoked_at: 25,
914 })?;
915 assert_eq!(
916 store.latest_revocation_cursor()?,
917 Some((25, "cap-b".to_string()))
918 );
919 let _ = fs::remove_file(&path);
920 Ok(())
921 }
922
923 #[test]
924 fn file_backed_revocation_store_reports_durable() {
925 let path = unique_db_path("chio-rev-durable");
926 let store = SqliteRevocationStore::open(&path).unwrap();
927 assert!(
928 !store.is_ephemeral(),
929 "a filesystem-backed revocation store is durable"
930 );
931 let _ = fs::remove_file(path);
932 }
933
934 #[test]
935 fn in_memory_revocation_store_reports_ephemeral() {
936 for path in [":memory:", "file::memory:", "file:rev?mode=memory"] {
937 let store = SqliteRevocationStore::open(path).unwrap();
938 assert!(
939 store.is_ephemeral(),
940 "in-memory revocation store {path} must report ephemeral so the durability gate refuses it"
941 );
942 }
943 }
944
945 #[test]
946 fn open_creates_parent_dirs_for_a_file_uri_with_query() {
947 let nonce = SystemTime::now()
948 .duration_since(UNIX_EPOCH)
949 .expect("time before epoch")
950 .as_nanos();
951 let base = std::env::temp_dir().join(format!("chio-rev-uri-{nonce}"));
952 let db = base.join("nested").join("receipts.db.revocations");
953 let parent = db.parent().expect("db path has a parent");
954 assert!(
955 !parent.exists(),
956 "precondition: the parent dir must not exist yet"
957 );
958
959 let uri = format!("file:{}?mode=rwc", db.display());
963 let store = SqliteRevocationStore::open(uri.as_str()).unwrap();
964
965 assert!(
966 !store.is_ephemeral(),
967 "a file: URI to a real filesystem path is durable"
968 );
969 assert!(
970 parent.exists(),
971 "the real parent directory must be created before SQLite opens the URI"
972 );
973
974 let _ = fs::remove_dir_all(&base);
975 }
976
977 #[test]
978 fn sqlite_revocation_store_lists_filtered_entries() {
979 let path = unique_db_path("chio-revocations-filtered");
980 let store = SqliteRevocationStore::open(&path).unwrap();
981 assert!(store.revoke("cap-1").unwrap());
982 assert!(store.revoke("cap-2").unwrap());
983
984 let all = store.list_revocations(10, None).unwrap();
985 assert_eq!(all.len(), 2);
986
987 let filtered = store.list_revocations(10, Some("cap-1")).unwrap();
988 assert_eq!(filtered.len(), 1);
989 assert_eq!(filtered[0].capability_id, "cap-1");
990
991 let _ = fs::remove_file(path);
992 }
993}