chio_store_sqlite/
revocation_store.rs1use 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}