Skip to main content

chio_store_sqlite/
execution_nonce_store.rs

1//! SQLite-backed `ExecutionNonceStore`.
2//!
3//! Durable replay-prevention for execution nonces so a kernel that
4//! crashes and restarts cannot be tricked into accepting a nonce that was
5//! already consumed by the previous process. Expiry is enforced by
6//! storing the nonce's `expires_at` alongside the consumed marker;
7//! `reserve` refuses to recycle a slot until the nonce is past its
8//! expiry.
9//!
10//! The schema is:
11//!
12//! ```sql
13//! CREATE TABLE chio_execution_nonces (
14//!     nonce_id    TEXT PRIMARY KEY,
15//!     consumed_at INTEGER NOT NULL,
16//!     expires_at  INTEGER NOT NULL
17//! );
18//! CREATE INDEX idx_chio_execution_nonces_expires_at
19//!     ON chio_execution_nonces(expires_at);
20//! ```
21
22use std::fs;
23use std::path::Path;
24use std::time::{SystemTime, UNIX_EPOCH};
25
26use chio_kernel::{ExecutionNonceStore, KernelError};
27use r2d2::Pool;
28use r2d2_sqlite::SqliteConnectionManager;
29use rusqlite::params;
30
31/// Default number of seconds a consumed-marker persists after its
32/// `expires_at` before the garbage collector reclaims the row. Keeps the
33/// table bounded without letting a replay slip through immediately after
34/// the nonce would have expired anyway.
35const RETENTION_GRACE_SECS: i64 = 60;
36
37/// Opaque error type returned by the SQLite nonce store.
38#[derive(Debug)]
39pub struct SqliteExecutionNonceStoreError(String);
40
41impl std::fmt::Display for SqliteExecutionNonceStoreError {
42    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43        write!(f, "sqlite execution nonce store error: {}", self.0)
44    }
45}
46
47impl std::error::Error for SqliteExecutionNonceStoreError {}
48
49impl From<rusqlite::Error> for SqliteExecutionNonceStoreError {
50    fn from(e: rusqlite::Error) -> Self {
51        Self(e.to_string())
52    }
53}
54
55impl From<std::io::Error> for SqliteExecutionNonceStoreError {
56    fn from(e: std::io::Error) -> Self {
57        Self(e.to_string())
58    }
59}
60
61impl From<r2d2::Error> for SqliteExecutionNonceStoreError {
62    fn from(e: r2d2::Error) -> Self {
63        Self(e.to_string())
64    }
65}
66
67/// SQLite-backed replay-prevention store for execution nonces.
68pub struct SqliteExecutionNonceStore {
69    pool: Pool<SqliteConnectionManager>,
70}
71
72/// Execution-nonce-store schema revision. Bump on every schema-affecting change.
73const EXECUTION_NONCE_STORE_SUPPORTED_SCHEMA_VERSION: i32 = 0;
74/// Stable key under which this store records its schema revision in the shared
75/// keyed metadata table, distinct from any co-located store's key.
76const EXECUTION_NONCE_STORE_SCHEMA_KEY: &str = "execution_nonce";
77/// Tables shipped before schema stamping existed, used to adopt a pre-stamping
78/// execution-nonce database rather than reject it as foreign.
79const EXECUTION_NONCE_STORE_LEGACY_ANCHOR_TABLES: &[&str] = &["chio_execution_nonces"];
80
81impl SqliteExecutionNonceStore {
82    /// Open the store at the given path. Creates the parent directory
83    /// if needed.
84    pub fn open(path: impl AsRef<Path>) -> Result<Self, SqliteExecutionNonceStoreError> {
85        let path = path.as_ref();
86        // Resolve any `file:` URI to its on-disk parent before creating it, so a
87        // URI-configured store creates the real backing directory rather than a
88        // bogus scheme-prefixed one.
89        if let Some(parent) = crate::sqlite_parent_dir_to_create(path) {
90            fs::create_dir_all(&parent)?;
91        }
92        let manager = SqliteConnectionManager::file(path);
93        let pool = Pool::builder().max_size(8).build(manager)?;
94        let store = Self { pool };
95        store.run_migrations()?;
96        Ok(store)
97    }
98
99    /// Open an in-memory store for tests.
100    pub fn open_in_memory() -> Result<Self, SqliteExecutionNonceStoreError> {
101        let manager = SqliteConnectionManager::memory();
102        let pool = Pool::builder().max_size(1).build(manager)?;
103        let store = Self { pool };
104        store.run_migrations()?;
105        Ok(store)
106    }
107
108    fn run_migrations(&self) -> Result<(), SqliteExecutionNonceStoreError> {
109        let conn = self
110            .pool
111            .get()
112            .map_err(|e| SqliteExecutionNonceStoreError(format!("pool acquire: {e}")))?;
113        crate::check_schema_version(
114            &conn,
115            EXECUTION_NONCE_STORE_SCHEMA_KEY,
116            EXECUTION_NONCE_STORE_SUPPORTED_SCHEMA_VERSION,
117            EXECUTION_NONCE_STORE_LEGACY_ANCHOR_TABLES,
118        )
119        .map_err(|error| SqliteExecutionNonceStoreError(error.to_string()))?;
120        conn.execute_batch(
121            r#"
122            PRAGMA journal_mode = WAL;
123            PRAGMA synchronous = FULL;
124            PRAGMA busy_timeout = 5000;
125
126            CREATE TABLE IF NOT EXISTS chio_execution_nonces (
127                nonce_id    TEXT PRIMARY KEY,
128                consumed_at INTEGER NOT NULL,
129                expires_at  INTEGER NOT NULL
130            );
131
132            CREATE INDEX IF NOT EXISTS idx_chio_execution_nonces_expires_at
133                ON chio_execution_nonces(expires_at);
134            "#,
135        )?;
136        crate::stamp_schema_version(
137            &conn,
138            EXECUTION_NONCE_STORE_SCHEMA_KEY,
139            EXECUTION_NONCE_STORE_SUPPORTED_SCHEMA_VERSION,
140        )
141        .map_err(|error| SqliteExecutionNonceStoreError(error.to_string()))?;
142        Ok(())
143    }
144
145    /// Reserve a nonce id. Shared code path for the trait impl and
146    /// tests -- takes an explicit `expires_at` for caller-controlled
147    /// retention (the trait method uses `now + RETENTION_GRACE_SECS`).
148    pub fn try_reserve(
149        &self,
150        nonce_id: &str,
151        now: i64,
152        expires_at: i64,
153    ) -> Result<bool, SqliteExecutionNonceStoreError> {
154        if nonce_id.trim().is_empty() || nonce_id.trim() != nonce_id {
155            return Err(SqliteExecutionNonceStoreError(
156                "nonce_id must be non-empty and unpadded".to_string(),
157            ));
158        }
159        let mut conn = self
160            .pool
161            .get()
162            .map_err(|e| SqliteExecutionNonceStoreError(format!("pool acquire: {e}")))?;
163
164        let tx = conn.transaction()?;
165
166        // First, prune any rows that are past their `expires_at` so a
167        // long-lived kernel doesn't accumulate garbage. Keeping the
168        // prune here (rather than a background job) is safe because the
169        // query is indexed on expires_at.
170        tx.execute(
171            "DELETE FROM chio_execution_nonces WHERE expires_at <= ?1",
172            params![now],
173        )?;
174
175        // Then attempt the reservation. A conflicting row means the
176        // nonce was already consumed and is still within the retention
177        // window; return `false`.
178        let rows = tx.execute(
179            r#"
180            INSERT INTO chio_execution_nonces (nonce_id, consumed_at, expires_at)
181            VALUES (?1, ?2, ?3)
182            ON CONFLICT(nonce_id) DO NOTHING
183            "#,
184            params![nonce_id, now, expires_at],
185        )?;
186        tx.commit()?;
187        Ok(rows > 0)
188    }
189}
190
191fn now_secs() -> i64 {
192    i64::try_from(
193        SystemTime::now()
194            .duration_since(UNIX_EPOCH)
195            .map(|d| d.as_secs())
196            .unwrap_or(0),
197    )
198    .unwrap_or(i64::MAX)
199}
200
201impl ExecutionNonceStore for SqliteExecutionNonceStore {
202    fn reserve(&self, nonce_id: &str) -> Result<bool, KernelError> {
203        // Back-compat path: callers that do not know the nonce's signed
204        // expiry estimate the kernel default TTL and delegate to
205        // `reserve_until` so the consumed marker survives the full
206        // cryptographic validity window.
207        let now = now_secs();
208        let estimated_nonce_expiry = now.saturating_add(
209            i64::try_from(chio_kernel::DEFAULT_EXECUTION_NONCE_TTL_SECS).unwrap_or(0),
210        );
211        self.reserve_until(nonce_id, estimated_nonce_expiry)
212    }
213
214    fn reserve_until(&self, nonce_id: &str, nonce_expires_at: i64) -> Result<bool, KernelError> {
215        // Retain the consumed marker for the full signed validity window
216        // plus a small grace, so a pruner cannot reclaim the row while
217        // the nonce is still cryptographically valid. Take the max of
218        // `nonce_expires_at + RETENTION_GRACE_SECS` and
219        // `now + RETENTION_GRACE_SECS`, preserving the original grace
220        // for clock-skew safety.
221        let now = now_secs();
222        let retention = nonce_expires_at.saturating_add(RETENTION_GRACE_SECS);
223        let baseline = now.saturating_add(RETENTION_GRACE_SECS);
224        let expires_at = retention.max(baseline);
225        self.try_reserve(nonce_id, now, expires_at)
226            .map_err(|e| KernelError::Internal(format!("sqlite execution nonce store: {e}")))
227    }
228
229    fn is_consumed(&self, nonce_id: &str) -> Result<bool, KernelError> {
230        let now = now_secs();
231        let conn = self.pool.get().map_err(|error| {
232            KernelError::Internal(format!(
233                "sqlite execution nonce store pool acquire: {error}"
234            ))
235        })?;
236        conn.query_row(
237            r#"
238            SELECT EXISTS (
239                SELECT 1
240                FROM chio_execution_nonces
241                WHERE nonce_id = ?1 AND expires_at > ?2
242            )
243            "#,
244            params![nonce_id, now],
245            |row| row.get(0),
246        )
247        .map_err(|error| {
248            KernelError::Internal(format!(
249                "sqlite execution nonce store consumed lookup: {error}"
250            ))
251        })
252    }
253}
254
255#[cfg(test)]
256#[allow(clippy::expect_used, clippy::unwrap_used)]
257mod tests {
258    use super::*;
259
260    fn unique_db_path(prefix: &str) -> std::path::PathBuf {
261        let nonce = SystemTime::now()
262            .duration_since(UNIX_EPOCH)
263            .expect("time before epoch")
264            .as_nanos();
265        std::env::temp_dir().join(format!("{prefix}-{nonce}.sqlite3"))
266    }
267
268    #[test]
269    fn fresh_nonce_is_reserved() {
270        let store = SqliteExecutionNonceStore::open_in_memory().unwrap();
271        assert!(<SqliteExecutionNonceStore as ExecutionNonceStore>::reserve(&store, "a").unwrap());
272    }
273
274    #[test]
275    fn duplicate_nonce_is_rejected() {
276        let store = SqliteExecutionNonceStore::open_in_memory().unwrap();
277        assert!(store.try_reserve("a", 1_000, 1_100).unwrap());
278        assert!(!store.try_reserve("a", 1_001, 1_100).unwrap());
279    }
280
281    #[test]
282    fn padded_nonce_id_is_rejected() {
283        let store = SqliteExecutionNonceStore::open_in_memory().unwrap();
284        let error = store.try_reserve(" nonce", 1_000, 1_100).unwrap_err();
285
286        assert!(
287            error.to_string().contains("nonce_id"),
288            "expected nonce_id validation error, got {error}"
289        );
290    }
291
292    #[test]
293    fn expired_row_is_pruned_and_slot_reusable() {
294        let store = SqliteExecutionNonceStore::open_in_memory().unwrap();
295        assert!(store.try_reserve("a", 1_000, 1_030).unwrap());
296        // "Now" after expiry + retention: prune removes the row and the
297        // same id can be re-reserved (this is benign because verify_
298        // execution_nonce also checks the signed expires_at).
299        assert!(store.try_reserve("a", 2_000, 2_030).unwrap());
300    }
301
302    #[test]
303    fn persists_across_reopen() {
304        let path = unique_db_path("chio-exec-nonce");
305        let now = now_secs();
306        let expires_at = now.saturating_add(120);
307        {
308            let store = SqliteExecutionNonceStore::open(&path).unwrap();
309            assert!(store
310                .try_reserve("persistent-nonce", now, expires_at)
311                .unwrap());
312            assert!(store.is_consumed("persistent-nonce").unwrap());
313        }
314        let reopened = SqliteExecutionNonceStore::open(&path).unwrap();
315        assert!(reopened.is_consumed("persistent-nonce").unwrap());
316        assert!(!reopened
317            .try_reserve("persistent-nonce", now, expires_at)
318            .unwrap());
319        let _ = fs::remove_file(path);
320    }
321
322    #[test]
323    fn consumed_lookup_ignores_expired_rows() {
324        let store = SqliteExecutionNonceStore::open_in_memory().unwrap();
325        assert!(store.try_reserve("expired-nonce", 1_000, 1_100).unwrap());
326        assert!(!store.is_consumed("expired-nonce").unwrap());
327    }
328}