Skip to main content

chio_store_sqlite/
revocation_store.rs

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    /// Whether the backing database lives only in process memory and so loses
16    /// every revocation on restart. Computed from the open path, not assumed
17    /// durable: an in-memory SQLite database must not satisfy the durability
18    /// gate the way a real filesystem path does.
19    ephemeral: bool,
20}
21
22/// Whether a SQLite path opens a database that lives only in memory for the life
23/// of the process. rusqlite enables URI filenames, so the bare `:memory:`
24/// sentinel, `file::memory:`, and any `file:...?mode=memory` URI all open a
25/// non-durable database that loses every revocation on restart.
26fn 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
50/// Revocation-store schema revision. Bump on every schema-affecting change.
51pub(crate) const REVOCATION_STORE_SUPPORTED_SCHEMA_VERSION: i32 = 2;
52/// Stable key under which this store records its schema revision in the shared
53/// keyed metadata table, distinct from any co-located store's key.
54const REVOCATION_STORE_SCHEMA_KEY: &str = "revocation";
55/// Tables shipped before schema stamping existed, used to adopt a pre-stamping
56/// revocation database rather than reject it as foreign.
57const 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            // Derive the directory from the resolved filesystem path: a `file:`
65            // URI sibling (`file:/var/lib/chio/receipts.db.revocations?mode=rwc`)
66            // has a query and scheme that a raw `parent()` would fold into a
67            // bogus directory, leaving the real one uncreated.
68            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    /// The head of the revocation stream as the pagination cursor tuple
298    /// (revoked_at, capability_id), or None when empty. list_revocations_after
299    /// paginates ascending, so the head is the descending row.
300    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        // A `file:` URI sibling path carrying a query. A raw `parent()` would
960        // resolve to `file:.../nested`, create a bogus relative directory, and
961        // leave the real parent uncreated, so SQLite would fail to open it.
962        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}