Skip to main content

chio_store_sqlite/
revocation_store.rs

1use std::fs;
2use std::path::Path;
3
4use chio_kernel::{RevocationRecord, RevocationStore, RevocationStoreError};
5use rusqlite::{params, Connection};
6
7pub struct SqliteRevocationStore {
8    connection: Connection,
9}
10
11impl SqliteRevocationStore {
12    pub fn open(path: impl AsRef<Path>) -> Result<Self, RevocationStoreError> {
13        let path = path.as_ref();
14        if let Some(parent) = path.parent() {
15            fs::create_dir_all(parent)?;
16        }
17
18        let connection = Connection::open(path)?;
19        connection.execute_batch(
20            r#"
21            PRAGMA journal_mode = WAL;
22            PRAGMA synchronous = FULL;
23            PRAGMA busy_timeout = 5000;
24
25            CREATE TABLE IF NOT EXISTS revoked_capabilities (
26                capability_id TEXT PRIMARY KEY,
27                revoked_at INTEGER NOT NULL
28            );
29
30            CREATE INDEX IF NOT EXISTS idx_revoked_capabilities_revoked_at
31                ON revoked_capabilities(revoked_at);
32            "#,
33        )?;
34
35        Ok(Self { connection })
36    }
37
38    pub fn list_revocations(
39        &self,
40        limit: usize,
41        capability_id: Option<&str>,
42    ) -> Result<Vec<RevocationRecord>, RevocationStoreError> {
43        let mut statement = self.connection.prepare(
44            r#"
45            SELECT capability_id, revoked_at
46            FROM revoked_capabilities
47            WHERE (?1 IS NULL OR capability_id = ?1)
48            ORDER BY revoked_at DESC, capability_id ASC
49            LIMIT ?2
50            "#,
51        )?;
52        let rows = statement.query_map(params![capability_id, limit as i64], |row| {
53            Ok(RevocationRecord {
54                capability_id: row.get(0)?,
55                revoked_at: row.get(1)?,
56            })
57        })?;
58        rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
59    }
60
61    pub fn list_revocations_after(
62        &self,
63        limit: usize,
64        after_revoked_at: Option<i64>,
65        after_capability_id: Option<&str>,
66    ) -> Result<Vec<RevocationRecord>, RevocationStoreError> {
67        let mut statement = self.connection.prepare(
68            r#"
69            SELECT capability_id, revoked_at
70            FROM revoked_capabilities
71            WHERE (
72                ?1 IS NULL
73                OR revoked_at > ?1
74                OR (revoked_at = ?1 AND ?2 IS NOT NULL AND capability_id > ?2)
75            )
76            ORDER BY revoked_at ASC, capability_id ASC
77            LIMIT ?3
78            "#,
79        )?;
80        let rows = statement.query_map(
81            params![after_revoked_at, after_capability_id, limit as i64],
82            |row| {
83                Ok(RevocationRecord {
84                    capability_id: row.get(0)?,
85                    revoked_at: row.get(1)?,
86                })
87            },
88        )?;
89        rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
90    }
91
92    pub fn upsert_revocation(
93        &mut self,
94        record: &RevocationRecord,
95    ) -> Result<(), RevocationStoreError> {
96        self.connection.execute(
97            r#"
98            INSERT INTO revoked_capabilities (capability_id, revoked_at)
99            VALUES (?1, ?2)
100            ON CONFLICT(capability_id) DO UPDATE SET
101                revoked_at = MAX(revoked_at, excluded.revoked_at)
102            "#,
103            params![record.capability_id, record.revoked_at],
104        )?;
105        Ok(())
106    }
107}
108
109impl RevocationStore for SqliteRevocationStore {
110    fn is_revoked(&self, capability_id: &str) -> Result<bool, RevocationStoreError> {
111        let exists = self.connection.query_row(
112            "SELECT EXISTS(SELECT 1 FROM revoked_capabilities WHERE capability_id = ?1)",
113            params![capability_id],
114            |row| row.get::<_, i64>(0),
115        )?;
116        Ok(exists != 0)
117    }
118
119    fn revoke(&mut self, capability_id: &str) -> Result<bool, RevocationStoreError> {
120        let revoked_at = std::time::SystemTime::now()
121            .duration_since(std::time::UNIX_EPOCH)
122            .map(|duration| duration.as_secs() as i64)
123            .unwrap_or(0);
124        let rows = self.connection.execute(
125            r#"
126            INSERT INTO revoked_capabilities (capability_id, revoked_at)
127            VALUES (?1, ?2)
128            ON CONFLICT(capability_id) DO NOTHING
129            "#,
130            params![capability_id, revoked_at],
131        )?;
132        Ok(rows > 0)
133    }
134}
135
136#[cfg(test)]
137#[allow(clippy::expect_used, clippy::unwrap_used)]
138mod tests {
139    use std::time::{SystemTime, UNIX_EPOCH};
140
141    use super::*;
142
143    fn unique_db_path(prefix: &str) -> std::path::PathBuf {
144        let nonce = SystemTime::now()
145            .duration_since(UNIX_EPOCH)
146            .expect("time before epoch")
147            .as_nanos();
148        std::env::temp_dir().join(format!("{prefix}-{nonce}.sqlite3"))
149    }
150
151    #[test]
152    fn sqlite_revocation_store_persists_across_reopen() {
153        let path = unique_db_path("chio-revocations");
154        {
155            let mut store = SqliteRevocationStore::open(&path).unwrap();
156            assert!(!store.is_revoked("cap-1").unwrap());
157            assert!(store.revoke("cap-1").unwrap());
158            assert!(store.is_revoked("cap-1").unwrap());
159            assert!(!store.revoke("cap-1").unwrap());
160        }
161
162        let reopened = SqliteRevocationStore::open(&path).unwrap();
163        assert!(reopened.is_revoked("cap-1").unwrap());
164
165        let _ = fs::remove_file(path);
166    }
167
168    #[test]
169    fn sqlite_revocation_store_lists_filtered_entries() {
170        let path = unique_db_path("chio-revocations-filtered");
171        let mut store = SqliteRevocationStore::open(&path).unwrap();
172        assert!(store.revoke("cap-1").unwrap());
173        assert!(store.revoke("cap-2").unwrap());
174
175        let all = store.list_revocations(10, None).unwrap();
176        assert_eq!(all.len(), 2);
177
178        let filtered = store.list_revocations(10, Some("cap-1")).unwrap();
179        assert_eq!(filtered.len(), 1);
180        assert_eq!(filtered[0].capability_id, "cap-1");
181
182        let _ = fs::remove_file(path);
183    }
184}