Skip to main content

vti_common/store/
mod.rs

1use std::collections::HashMap;
2use std::time::Duration;
3
4use crate::config::StoreConfig;
5use crate::error::AppError;
6use fjall::{KeyspaceCreateOptions, PersistMode};
7use serde::Serialize;
8use serde::de::DeserializeOwned;
9use tracing::info;
10
11pub mod counter;
12
13#[cfg(feature = "encryption")]
14pub(crate) mod encryption;
15
16#[cfg(feature = "vsock-store")]
17pub mod vsock;
18
19/// Timeout for blocking fjall operations. Prevents indefinite hangs if the
20/// store deadlocks or I/O stalls.
21const STORE_OP_TIMEOUT: Duration = Duration::from_secs(30);
22
23/// Run a blocking operation with timeout.
24async fn blocking_with_timeout<F, T>(f: F) -> Result<T, AppError>
25where
26    F: FnOnce() -> Result<T, AppError> + Send + 'static,
27    T: Send + 'static,
28{
29    match tokio::time::timeout(STORE_OP_TIMEOUT, tokio::task::spawn_blocking(f)).await {
30        Ok(Ok(result)) => result,
31        Ok(Err(e)) => Err(AppError::Internal(format!("blocking task panicked: {e}"))),
32        Err(_) => Err(AppError::Internal(format!(
33            "store operation timed out after {}s",
34            STORE_OP_TIMEOUT.as_secs()
35        ))),
36    }
37}
38
39/// A key-value pair of raw bytes from a prefix scan.
40pub type RawKvPair = (Vec<u8>, Vec<u8>);
41
42/// fjall's on-disk "this directory is a database root" marker
43/// (`fjall::file::VERSION_MARKER`, which the crate does not re-export).
44const FJALL_VERSION_MARKER: &str = "version";
45
46/// Does `data_dir` already hold a local (fjall) database?
47///
48/// Probes for fjall's own version marker — the same file
49/// [`fjall::Database`] consults to decide "recover" versus "create new" —
50/// so this cannot drift from what actually opening the store would do.
51///
52/// Deliberately **not** `data_dir.exists()`. A Docker bind mount, a
53/// Kubernetes PVC, and an operator's `mkdir` all produce an
54/// existing-but-storeless directory, which is a perfectly good target for
55/// a fresh store. Only the marker means "there is state here".
56pub fn local_store_exists(data_dir: &std::path::Path) -> bool {
57    data_dir.join(FJALL_VERSION_MARKER).is_file()
58}
59
60// ===========================================================================
61// Store — dispatches to local (fjall) or vsock backend
62// ===========================================================================
63
64/// Persistent key-value store.
65///
66/// Wraps either a local fjall database or a vsock-proxied store on the parent
67/// EC2 instance. All consumers use this type uniformly.
68#[derive(Clone)]
69pub enum Store {
70    /// Local fjall database (standard mode).
71    Local(LocalStore),
72    /// Vsock-proxied store on the parent (Nitro Enclave mode).
73    #[cfg(feature = "vsock-store")]
74    Vsock(vsock::VsockStore),
75}
76
77impl Store {
78    /// Open a local fjall-backed store.
79    pub fn open(config: &StoreConfig) -> Result<Self, AppError> {
80        Ok(Store::Local(LocalStore::open(config)?))
81    }
82
83    /// Connect to the parent's vsock storage proxy.
84    #[cfg(feature = "vsock-store")]
85    pub async fn connect_vsock(port: Option<u32>) -> Result<Self, AppError> {
86        Ok(Store::Vsock(vsock::VsockStore::connect(port).await?))
87    }
88
89    pub fn keyspace(&self, name: &str) -> Result<KeyspaceHandle, AppError> {
90        match self {
91            Store::Local(s) => Ok(KeyspaceHandle::Local(s.keyspace(name)?)),
92            #[cfg(feature = "vsock-store")]
93            Store::Vsock(s) => Ok(KeyspaceHandle::Vsock(s.keyspace(name)?)),
94        }
95    }
96
97    pub async fn persist(&self) -> Result<(), AppError> {
98        match self {
99            Store::Local(s) => s.persist().await,
100            #[cfg(feature = "vsock-store")]
101            Store::Vsock(s) => s.persist().await,
102        }
103    }
104}
105
106// ===========================================================================
107// KeyspaceHandle — dispatches to local (fjall) or vsock backend
108// ===========================================================================
109
110/// Handle to a keyspace with optional transparent encryption.
111///
112/// Wraps either a local fjall keyspace or a vsock-proxied keyspace.
113/// Encryption is always applied locally (before data leaves the enclave).
114#[derive(Clone)]
115pub enum KeyspaceHandle {
116    Local(LocalKeyspaceHandle),
117    #[cfg(feature = "vsock-store")]
118    Vsock(vsock::VsockKeyspaceHandle),
119}
120
121impl KeyspaceHandle {
122    #[cfg(feature = "encryption")]
123    pub fn with_encryption(self, key: [u8; 32]) -> Self {
124        match self {
125            KeyspaceHandle::Local(h) => KeyspaceHandle::Local(h.with_encryption(key)),
126            #[cfg(feature = "vsock-store")]
127            KeyspaceHandle::Vsock(h) => KeyspaceHandle::Vsock(h.with_encryption(key)),
128        }
129    }
130
131    pub fn is_encrypted(&self) -> bool {
132        match self {
133            KeyspaceHandle::Local(h) => h.is_encrypted(),
134            #[cfg(feature = "vsock-store")]
135            KeyspaceHandle::Vsock(h) => h.is_encrypted(),
136        }
137    }
138
139    /// Re-encrypt every legacy plaintext row in this keyspace under `key`,
140    /// in place, so a store first written before encryption-at-rest was
141    /// enabled can be read by an encrypted handle.
142    ///
143    /// Must be called on a **bare** handle (no encryption configured): it
144    /// reads raw bytes *without* decrypting, then writes each plaintext
145    /// row back through an encrypted handle. Returns the number of rows
146    /// newly encrypted.
147    ///
148    /// **Idempotent and crash-safe.** Rows already in the v1 encrypted
149    /// format ([`encryption::is_v1_encrypted`]) are skipped, so an
150    /// interrupted run leaves a mix of encrypted + plaintext rows that a
151    /// re-run completes. The format magic (`VAE1`) is what distinguishes
152    /// the two; no value this is used for (serde-JSON state, raw key
153    /// bytes) begins with those four bytes, so detection is unambiguous.
154    ///
155    /// This deliberately does **not** add a lenient read-fallback to the
156    /// decrypt path — that would reintroduce the cut-and-paste downgrade
157    /// hole [`encryption`] documents. The store stays strictly
158    /// fail-closed; migration is a one-shot forward conversion.
159    #[cfg(feature = "encryption")]
160    pub async fn migrate_to_encrypted(&self, key: [u8; 32]) -> Result<usize, AppError> {
161        if self.is_encrypted() {
162            return Err(AppError::Internal(
163                "migrate_to_encrypted must be called on a bare (unencrypted) keyspace handle"
164                    .into(),
165            ));
166        }
167        // Bare read: returns raw on-disk bytes with no decryption, so
168        // both legacy plaintext rows and any already-encrypted rows from
169        // a prior partial run come back verbatim.
170        let rows = self.prefix_iter_raw(Vec::<u8>::new()).await?;
171        let encrypted = self.clone().with_encryption(key);
172        let mut migrated = 0usize;
173        for (k, v) in rows {
174            if encryption::is_v1_encrypted(&v) {
175                continue;
176            }
177            // insert_raw on the encrypted handle re-encrypts the value
178            // bound to its (keyspace, key) AAD location.
179            encrypted.insert_raw(k, v).await?;
180            migrated += 1;
181        }
182        Ok(migrated)
183    }
184
185    /// Durably flush the store to disk (a write barrier).
186    ///
187    /// Persistence is store-wide, not per-keyspace: the local backend
188    /// fsyncs the shared fjall journal, the vsock backend asks the
189    /// parent proxy to flush. Call after security-critical writes whose
190    /// loss on crash would violate an invariant (carve-out close,
191    /// counter allocation) — once this returns, the writes survive
192    /// power loss.
193    pub async fn persist(&self) -> Result<(), AppError> {
194        match self {
195            KeyspaceHandle::Local(h) => h.persist().await,
196            #[cfg(feature = "vsock-store")]
197            KeyspaceHandle::Vsock(h) => h.persist().await,
198        }
199    }
200
201    pub async fn insert<V: Serialize>(
202        &self,
203        key: impl Into<Vec<u8>>,
204        value: &V,
205    ) -> Result<(), AppError> {
206        match self {
207            KeyspaceHandle::Local(h) => h.insert(key, value).await,
208            #[cfg(feature = "vsock-store")]
209            KeyspaceHandle::Vsock(h) => h.insert(key, value).await,
210        }
211    }
212
213    /// Insert `value` at `key` only if `key` is currently absent.
214    /// Returns `true` when the insert happened, `false` when the key
215    /// already existed (the stored value is left untouched).
216    ///
217    /// On the [`KeyspaceHandle::Local`] variant the check and insert
218    /// run inside one blocking closure, so exactly one of two racing
219    /// callers observes `true`. On the [`KeyspaceHandle::Vsock`]
220    /// variant the vsock RPC does not yet carry a native
221    /// insert-if-absent opcode; the fallback is `get_raw` + `insert`,
222    /// which has a TOCTOU window across two vsock round-trips — the
223    /// same documented gap as [`KeyspaceHandle::take_raw`] (TEE
224    /// enclaves are single-replica, so the window is per-connection
225    /// rather than cross-replica).
226    pub async fn insert_if_absent<V: Serialize>(
227        &self,
228        key: impl Into<Vec<u8>>,
229        value: &V,
230    ) -> Result<bool, AppError> {
231        match self {
232            KeyspaceHandle::Local(h) => h.insert_if_absent(key, value).await,
233            #[cfg(feature = "vsock-store")]
234            KeyspaceHandle::Vsock(h) => {
235                tracing::warn!(
236                    "KeyspaceHandle::Vsock::insert_if_absent using non-atomic get+insert \
237                     fallback; vsock proto lacks a native insert-if-absent opcode. \
238                     Single-replica TEE deployments are unaffected in practice."
239                );
240                let key = key.into();
241                if h.get_raw(key.clone()).await?.is_some() {
242                    return Ok(false);
243                }
244                h.insert(key, value).await?;
245                Ok(true)
246            }
247        }
248    }
249
250    /// Raw-bytes variant of [`KeyspaceHandle::insert_if_absent`] — same
251    /// semantics and the same vsock TOCTOU caveat, for values that are
252    /// stored via `insert_raw`/`get_raw` rather than as serde JSON.
253    pub async fn insert_raw_if_absent(
254        &self,
255        key: impl Into<Vec<u8>>,
256        value: impl Into<Vec<u8>>,
257    ) -> Result<bool, AppError> {
258        match self {
259            KeyspaceHandle::Local(h) => h.insert_raw_if_absent(key, value).await,
260            #[cfg(feature = "vsock-store")]
261            KeyspaceHandle::Vsock(h) => {
262                tracing::warn!(
263                    "KeyspaceHandle::Vsock::insert_raw_if_absent using non-atomic get+insert \
264                     fallback; vsock proto lacks a native insert-if-absent opcode. \
265                     Single-replica TEE deployments are unaffected in practice."
266                );
267                let key = key.into();
268                if h.get_raw(key.clone()).await?.is_some() {
269                    return Ok(false);
270                }
271                h.insert_raw(key, value).await?;
272                Ok(true)
273            }
274        }
275    }
276
277    pub async fn get<V: DeserializeOwned + Send + 'static>(
278        &self,
279        key: impl Into<Vec<u8>>,
280    ) -> Result<Option<V>, AppError> {
281        match self {
282            KeyspaceHandle::Local(h) => h.get(key).await,
283            #[cfg(feature = "vsock-store")]
284            KeyspaceHandle::Vsock(h) => h.get(key).await,
285        }
286    }
287
288    pub async fn remove(&self, key: impl Into<Vec<u8>>) -> Result<(), AppError> {
289        match self {
290            KeyspaceHandle::Local(h) => h.remove(key).await,
291            #[cfg(feature = "vsock-store")]
292            KeyspaceHandle::Vsock(h) => h.remove(key).await,
293        }
294    }
295
296    /// Atomic `GET` + `DELETE` — see
297    /// [`LocalKeyspaceHandle::take_raw`].
298    ///
299    /// On the [`KeyspaceHandle::Vsock`] variant the vsock RPC does
300    /// not yet carry a native `take` opcode. The fallback is
301    /// `get_raw` + `remove`, which has a TOCTOU window across two
302    /// vsock round-trips — two concurrent presenters could both
303    /// observe `Some`. The canonical refresh-token claim treats
304    /// this as a documented gap (TEE enclaves are single-replica,
305    /// so the window is per-connection rather than cross-replica)
306    /// and emits a `warn!` on every call so it stays visible
307    /// until the vsock proto gains a `take` opcode.
308    pub async fn take_raw(&self, key: impl Into<Vec<u8>>) -> Result<Option<Vec<u8>>, AppError> {
309        let key = key.into();
310        match self {
311            KeyspaceHandle::Local(h) => h.take_raw(key).await,
312            #[cfg(feature = "vsock-store")]
313            KeyspaceHandle::Vsock(h) => {
314                tracing::warn!(
315                    "KeyspaceHandle::Vsock::take_raw using non-atomic get+remove fallback; \
316                     vsock proto lacks a native take opcode. Single-replica TEE deployments \
317                     are unaffected in practice."
318                );
319                let val = h.get_raw(key.clone()).await?;
320                if val.is_some() {
321                    h.remove(key).await?;
322                }
323                Ok(val)
324            }
325        }
326    }
327
328    pub async fn insert_raw(
329        &self,
330        key: impl Into<Vec<u8>>,
331        value: impl Into<Vec<u8>>,
332    ) -> Result<(), AppError> {
333        match self {
334            KeyspaceHandle::Local(h) => h.insert_raw(key, value).await,
335            #[cfg(feature = "vsock-store")]
336            KeyspaceHandle::Vsock(h) => h.insert_raw(key, value).await,
337        }
338    }
339
340    pub async fn get_raw(&self, key: impl Into<Vec<u8>>) -> Result<Option<Vec<u8>>, AppError> {
341        match self {
342            KeyspaceHandle::Local(h) => h.get_raw(key).await,
343            #[cfg(feature = "vsock-store")]
344            KeyspaceHandle::Vsock(h) => h.get_raw(key).await,
345        }
346    }
347
348    pub async fn prefix_iter_raw(
349        &self,
350        prefix: impl Into<Vec<u8>>,
351    ) -> Result<Vec<RawKvPair>, AppError> {
352        match self {
353            KeyspaceHandle::Local(h) => h.prefix_iter_raw(prefix).await,
354            #[cfg(feature = "vsock-store")]
355            KeyspaceHandle::Vsock(h) => h.prefix_iter_raw(prefix).await,
356        }
357    }
358
359    /// Iterate key/value pairs whose key is `>= from` (inclusive lower
360    /// bound, unbounded above), in ascending key order. Unlike
361    /// [`Self::prefix_iter_raw`] this **seeks** to `from` rather than
362    /// scanning from the start of the keyspace — used by the registry
363    /// syncer's audit-tail walk to skip already-processed history
364    /// (audit keys are `<rfc3339-ts>:<event_id>`, which sort
365    /// chronologically), so per-tick cost is proportional to new rows
366    /// rather than the whole audit log.
367    pub async fn range_from_raw(
368        &self,
369        from: impl Into<Vec<u8>>,
370    ) -> Result<Vec<RawKvPair>, AppError> {
371        match self {
372            KeyspaceHandle::Local(h) => h.range_from_raw(from).await,
373            #[cfg(feature = "vsock-store")]
374            KeyspaceHandle::Vsock(h) => h.range_from_raw(from).await,
375        }
376    }
377
378    pub async fn prefix_keys(&self, prefix: impl Into<Vec<u8>>) -> Result<Vec<Vec<u8>>, AppError> {
379        match self {
380            KeyspaceHandle::Local(h) => h.prefix_keys(prefix).await,
381            #[cfg(feature = "vsock-store")]
382            KeyspaceHandle::Vsock(h) => h.prefix_keys(prefix).await,
383        }
384    }
385
386    pub async fn approximate_len(&self) -> Result<usize, AppError> {
387        match self {
388            KeyspaceHandle::Local(h) => h.approximate_len().await,
389            #[cfg(feature = "vsock-store")]
390            KeyspaceHandle::Vsock(h) => h.approximate_len().await,
391        }
392    }
393
394    pub async fn swap<V: Serialize>(
395        &self,
396        old_key: impl Into<Vec<u8>>,
397        new_key: impl Into<Vec<u8>>,
398        value: &V,
399    ) -> Result<bool, AppError> {
400        match self {
401            KeyspaceHandle::Local(h) => h.swap(old_key, new_key, value).await,
402            #[cfg(feature = "vsock-store")]
403            KeyspaceHandle::Vsock(h) => h.swap(old_key, new_key, value).await,
404        }
405    }
406}
407
408// ===========================================================================
409// LocalStore — fjall-backed implementation (original code)
410// ===========================================================================
411
412/// Per-keyspace write locks shared by every handle the store hands out.
413///
414/// fjall serialises *individual* operations, not sequences of them: two
415/// check-then-write closures running on separate `spawn_blocking`
416/// threads interleave freely. The multi-op methods that promise
417/// atomicity ([`LocalKeyspaceHandle::take_raw`],
418/// [`LocalKeyspaceHandle::swap`],
419/// [`LocalKeyspaceHandle::insert_if_absent`]) therefore serialise
420/// through this lock. It is keyed by keyspace *name* and owned by the
421/// store, so handles obtained from separate `keyspace(name)` calls
422/// still exclude each other.
423type WriteLocks =
424    std::sync::Arc<std::sync::Mutex<HashMap<String, std::sync::Arc<std::sync::Mutex<()>>>>>;
425
426#[derive(Clone)]
427pub struct LocalStore {
428    db: fjall::Database,
429    write_locks: WriteLocks,
430}
431
432#[derive(Clone)]
433pub struct LocalKeyspaceHandle {
434    keyspace: fjall::Keyspace,
435    /// Keyspace name, bound into the AES-GCM associated data so a value
436    /// cannot be relocated to another keyspace (which shares the storage
437    /// key) and still authenticate. See [`encryption`].
438    name: String,
439    /// The owning database, kept so the handle can fsync the shared
440    /// journal ([`LocalKeyspaceHandle::persist`]) — fjall only exposes
441    /// persistence at the database level.
442    db: fjall::Database,
443    /// Shared with every other handle for the same keyspace name — see
444    /// [`WriteLocks`].
445    write_lock: std::sync::Arc<std::sync::Mutex<()>>,
446    #[cfg(feature = "encryption")]
447    encryption_key: Option<std::sync::Arc<zeroize::Zeroizing<[u8; 32]>>>,
448}
449
450/// Acquire a write lock inside a blocking closure, recovering from
451/// poisoning: the lock only guards check-then-write sequencing, and
452/// every critical section re-reads store state, so a panicked holder
453/// leaves nothing logically inconsistent to inherit.
454fn lock_writes(lock: &std::sync::Mutex<()>) -> std::sync::MutexGuard<'_, ()> {
455    lock.lock()
456        .unwrap_or_else(std::sync::PoisonError::into_inner)
457}
458
459impl LocalStore {
460    pub fn open(config: &StoreConfig) -> Result<Self, AppError> {
461        std::fs::create_dir_all(&config.data_dir).map_err(AppError::Io)?;
462        info!(path = %config.data_dir.display(), "opening store");
463        let db = fjall::Database::builder(&config.data_dir).open()?;
464        Ok(Self {
465            db,
466            write_locks: WriteLocks::default(),
467        })
468    }
469
470    pub fn keyspace(&self, name: &str) -> Result<LocalKeyspaceHandle, AppError> {
471        let keyspace = self.db.keyspace(name, KeyspaceCreateOptions::default)?;
472        let write_lock = self
473            .write_locks
474            .lock()
475            .unwrap_or_else(std::sync::PoisonError::into_inner)
476            .entry(name.to_string())
477            .or_default()
478            .clone();
479        Ok(LocalKeyspaceHandle {
480            keyspace,
481            name: name.to_string(),
482            db: self.db.clone(),
483            write_lock,
484            #[cfg(feature = "encryption")]
485            encryption_key: None,
486        })
487    }
488
489    pub async fn persist(&self) -> Result<(), AppError> {
490        let db = self.db.clone();
491        tokio::task::spawn_blocking(move || db.persist(PersistMode::SyncAll))
492            .await
493            .map_err(|e| AppError::Internal(format!("blocking task panicked: {e}")))??;
494        Ok(())
495    }
496}
497
498impl LocalKeyspaceHandle {
499    #[cfg(feature = "encryption")]
500    pub fn with_encryption(mut self, key: [u8; 32]) -> Self {
501        self.encryption_key = Some(std::sync::Arc::new(zeroize::Zeroizing::new(key)));
502        self
503    }
504
505    pub fn is_encrypted(&self) -> bool {
506        #[cfg(feature = "encryption")]
507        {
508            self.encryption_key.is_some()
509        }
510        #[cfg(not(feature = "encryption"))]
511        {
512            false
513        }
514    }
515
516    /// Fsync the owning database's journal — see
517    /// [`KeyspaceHandle::persist`].
518    pub async fn persist(&self) -> Result<(), AppError> {
519        let db = self.db.clone();
520        blocking_with_timeout(move || Ok(db.persist(PersistMode::SyncAll)?)).await
521    }
522
523    pub async fn insert<V: Serialize>(
524        &self,
525        key: impl Into<Vec<u8>>,
526        value: &V,
527    ) -> Result<(), AppError> {
528        let key = key.into();
529        let bytes = serde_json::to_vec(value)?;
530        let bytes = self.maybe_encrypt(&key, bytes)?;
531        let ks = self.keyspace.clone();
532        blocking_with_timeout(move || Ok(ks.insert(key, bytes)?)).await
533    }
534
535    pub async fn get<V: DeserializeOwned + Send + 'static>(
536        &self,
537        key: impl Into<Vec<u8>>,
538    ) -> Result<Option<V>, AppError> {
539        let key = key.into();
540        let ks = self.keyspace.clone();
541        #[cfg(feature = "encryption")]
542        let enc_key = self.encryption_key.clone();
543        #[cfg(feature = "encryption")]
544        let name = self.name.clone();
545        blocking_with_timeout(move || match ks.get(&key)? {
546            Some(bytes) => {
547                #[cfg(feature = "encryption")]
548                let bytes = {
549                    let k = enc_key.as_ref().map(|arc| &***arc);
550                    encryption::maybe_decrypt_bytes(k, &name, &key, &bytes)?
551                };
552                #[cfg(not(feature = "encryption"))]
553                let bytes = bytes.to_vec();
554                Ok(Some(serde_json::from_slice(&bytes)?))
555            }
556            None => Ok(None),
557        })
558        .await
559    }
560
561    pub async fn remove(&self, key: impl Into<Vec<u8>>) -> Result<(), AppError> {
562        let key = key.into();
563        let ks = self.keyspace.clone();
564        blocking_with_timeout(move || Ok(ks.remove(key)?)).await
565    }
566
567    /// Atomically `GET` + `DELETE` (the classic Redis `GETDEL`).
568    ///
569    /// The `get` and `remove` run under the per-keyspace write lock
570    /// (see [`WriteLocks`]) so they are atomic with respect to any
571    /// other `take_raw`/`swap`/`insert_if_absent` racing on the same
572    /// keyspace — exactly one caller observes `Some`. (fjall alone
573    /// does NOT provide this: it serialises individual operations,
574    /// not check-then-write sequences across blocking threads.)
575    ///
576    /// Used by the canonical refresh-token claim
577    /// ([`crate::auth::session::take_session_id_by_refresh`]) to
578    /// close the rotation TOCTOU: a leaked refresh token can be
579    /// presented exactly once even under concurrent retries.
580    pub async fn take_raw(&self, key: impl Into<Vec<u8>>) -> Result<Option<Vec<u8>>, AppError> {
581        let key = key.into();
582        let ks = self.keyspace.clone();
583        let lock = self.write_lock.clone();
584        #[cfg(feature = "encryption")]
585        let enc_key = self.encryption_key.clone();
586        #[cfg(feature = "encryption")]
587        let name = self.name.clone();
588        blocking_with_timeout(move || {
589            let _guard = lock_writes(&lock);
590            match ks.get(&key)? {
591                Some(bytes) => {
592                    ks.remove(&key)?;
593                    #[cfg(feature = "encryption")]
594                    let bytes = {
595                        let k = enc_key.as_ref().map(|arc| &***arc);
596                        encryption::maybe_decrypt_bytes(k, &name, &key, &bytes)?
597                    };
598                    #[cfg(not(feature = "encryption"))]
599                    let bytes = bytes.to_vec();
600                    Ok(Some(bytes))
601                }
602                None => Ok(None),
603            }
604        })
605        .await
606    }
607
608    pub async fn insert_raw(
609        &self,
610        key: impl Into<Vec<u8>>,
611        value: impl Into<Vec<u8>>,
612    ) -> Result<(), AppError> {
613        let key = key.into();
614        let value = self.maybe_encrypt(&key, value.into())?;
615        let ks = self.keyspace.clone();
616        blocking_with_timeout(move || Ok(ks.insert(key, value)?)).await
617    }
618
619    pub async fn get_raw(&self, key: impl Into<Vec<u8>>) -> Result<Option<Vec<u8>>, AppError> {
620        let key = key.into();
621        let ks = self.keyspace.clone();
622        #[cfg(feature = "encryption")]
623        let enc_key = self.encryption_key.clone();
624        #[cfg(feature = "encryption")]
625        let name = self.name.clone();
626        blocking_with_timeout(move || match ks.get(&key)? {
627            Some(bytes) => {
628                #[cfg(feature = "encryption")]
629                let bytes = {
630                    let k = enc_key.as_ref().map(|arc| &***arc);
631                    encryption::maybe_decrypt_bytes(k, &name, &key, &bytes)?
632                };
633                #[cfg(not(feature = "encryption"))]
634                let bytes = bytes.to_vec();
635                Ok(Some(bytes))
636            }
637            None => Ok(None),
638        })
639        .await
640    }
641
642    pub async fn prefix_iter_raw(
643        &self,
644        prefix: impl Into<Vec<u8>>,
645    ) -> Result<Vec<RawKvPair>, AppError> {
646        let prefix = prefix.into();
647        let ks = self.keyspace.clone();
648        #[cfg(feature = "encryption")]
649        let enc_key = self.encryption_key.clone();
650        #[cfg(feature = "encryption")]
651        let name = self.name.clone();
652        blocking_with_timeout(move || {
653            let mut results = Vec::new();
654            for guard in ks.prefix(&prefix) {
655                let (key, value) = guard.into_inner()?;
656                #[cfg(feature = "encryption")]
657                let value = {
658                    let k = enc_key.as_ref().map(|arc| &***arc);
659                    encryption::maybe_decrypt_bytes(k, &name, &key, &value)?
660                };
661                #[cfg(not(feature = "encryption"))]
662                let value = value.to_vec();
663                results.push((key.to_vec(), value));
664            }
665            Ok(results)
666        })
667        .await
668    }
669
670    /// See [`KeyspaceHandle::range_from_raw`]. fjall's `range` seeks to
671    /// the lower bound, so this reads only keys `>= from`.
672    pub async fn range_from_raw(
673        &self,
674        from: impl Into<Vec<u8>>,
675    ) -> Result<Vec<RawKvPair>, AppError> {
676        let from = from.into();
677        let ks = self.keyspace.clone();
678        #[cfg(feature = "encryption")]
679        let enc_key = self.encryption_key.clone();
680        #[cfg(feature = "encryption")]
681        let name = self.name.clone();
682        blocking_with_timeout(move || {
683            let mut results = Vec::new();
684            for guard in ks.range(from..) {
685                let (key, value) = guard.into_inner()?;
686                #[cfg(feature = "encryption")]
687                let value = {
688                    let k = enc_key.as_ref().map(|arc| &***arc);
689                    encryption::maybe_decrypt_bytes(k, &name, &key, &value)?
690                };
691                #[cfg(not(feature = "encryption"))]
692                let value = value.to_vec();
693                results.push((key.to_vec(), value));
694            }
695            Ok(results)
696        })
697        .await
698    }
699
700    pub async fn prefix_keys(&self, prefix: impl Into<Vec<u8>>) -> Result<Vec<Vec<u8>>, AppError> {
701        let prefix = prefix.into();
702        let ks = self.keyspace.clone();
703        blocking_with_timeout(move || {
704            let mut results = Vec::new();
705            for guard in ks.prefix(&prefix) {
706                let (key, _value) = guard.into_inner()?;
707                results.push(key.to_vec());
708            }
709            Ok(results)
710        })
711        .await
712    }
713
714    pub async fn approximate_len(&self) -> Result<usize, AppError> {
715        let ks = self.keyspace.clone();
716        blocking_with_timeout(move || Ok(ks.approximate_len())).await
717    }
718
719    pub async fn swap<V: Serialize>(
720        &self,
721        old_key: impl Into<Vec<u8>>,
722        new_key: impl Into<Vec<u8>>,
723        value: &V,
724    ) -> Result<bool, AppError> {
725        let old_key = old_key.into();
726        let new_key = new_key.into();
727        let bytes = serde_json::to_vec(value)?;
728        // The value lands at `new_key`, so bind the AAD to `new_key`.
729        let bytes = self.maybe_encrypt(&new_key, bytes)?;
730        let ks = self.keyspace.clone();
731        let lock = self.write_lock.clone();
732        blocking_with_timeout(move || {
733            let _guard = lock_writes(&lock);
734            if ks.contains_key(&new_key)? {
735                return Ok(false);
736            }
737            ks.insert(&new_key, bytes)?;
738            ks.remove(&old_key)?;
739            Ok(true)
740        })
741        .await
742    }
743
744    /// Insert only if `key` is absent. The check and insert run under
745    /// the per-keyspace write lock (see [`WriteLocks`]), so exactly one
746    /// of two racing callers observes `true`.
747    pub async fn insert_if_absent<V: Serialize>(
748        &self,
749        key: impl Into<Vec<u8>>,
750        value: &V,
751    ) -> Result<bool, AppError> {
752        let key = key.into();
753        let bytes = serde_json::to_vec(value)?;
754        self.insert_bytes_if_absent(key, bytes).await
755    }
756
757    /// Raw-bytes variant of [`LocalKeyspaceHandle::insert_if_absent`] —
758    /// same lock, same exactly-one-winner guarantee.
759    pub async fn insert_raw_if_absent(
760        &self,
761        key: impl Into<Vec<u8>>,
762        value: impl Into<Vec<u8>>,
763    ) -> Result<bool, AppError> {
764        self.insert_bytes_if_absent(key.into(), value.into()).await
765    }
766
767    /// Shared body: check and insert run under the per-keyspace write
768    /// lock (see [`WriteLocks`]), so exactly one of two racing callers
769    /// observes `true`.
770    async fn insert_bytes_if_absent(&self, key: Vec<u8>, bytes: Vec<u8>) -> Result<bool, AppError> {
771        let bytes = self.maybe_encrypt(&key, bytes)?;
772        let ks = self.keyspace.clone();
773        let lock = self.write_lock.clone();
774        blocking_with_timeout(move || {
775            let _guard = lock_writes(&lock);
776            if ks.contains_key(&key)? {
777                return Ok(false);
778            }
779            ks.insert(&key, bytes)?;
780            Ok(true)
781        })
782        .await
783    }
784
785    fn maybe_encrypt(&self, store_key: &[u8], plaintext: Vec<u8>) -> Result<Vec<u8>, AppError> {
786        #[cfg(feature = "encryption")]
787        {
788            match self.encryption_key.as_ref().map(|arc| &***arc) {
789                Some(key) => encryption::encrypt_value(key, &self.name, store_key, &plaintext),
790                None => Ok(plaintext),
791            }
792        }
793        #[cfg(not(feature = "encryption"))]
794        {
795            let _ = store_key;
796            Ok(plaintext)
797        }
798    }
799}
800
801#[cfg(test)]
802mod tests {
803    use super::*;
804
805    fn temp_store() -> (Store, tempfile::TempDir) {
806        let dir = tempfile::tempdir().expect("failed to create temp dir");
807        let config = StoreConfig {
808            data_dir: dir.path().to_path_buf(),
809        };
810        let store = Store::open(&config).expect("failed to open store");
811        (store, dir)
812    }
813
814    #[test]
815    fn local_store_exists_ignores_a_bare_directory() {
816        // The case that motivated this probe: a Docker volume / PVC /
817        // hand-created directory exists before setup ever runs. That is
818        // not "there is a store here", and setup must not treat it as a
819        // conflict.
820        let dir = tempfile::tempdir().expect("tempdir");
821        assert!(
822            !local_store_exists(dir.path()),
823            "an empty directory holds no store"
824        );
825
826        std::fs::write(dir.path().join("did.jsonl"), b"{}").expect("write stray file");
827        assert!(
828            !local_store_exists(dir.path()),
829            "a non-empty directory without fjall's marker still holds no store"
830        );
831
832        assert!(
833            !local_store_exists(&dir.path().join("does-not-exist")),
834            "an absent directory holds no store"
835        );
836    }
837
838    #[test]
839    fn local_store_exists_sees_an_opened_store() {
840        let (_store, dir) = temp_store();
841        assert!(
842            local_store_exists(dir.path()),
843            "opening a store must make the probe report it"
844        );
845    }
846
847    #[tokio::test]
848    async fn persist_survives_store_reopen() {
849        // persist() is the durability barrier mint_mode_b relies on
850        // before returning the admin bundle. Prove a persisted write
851        // survives dropping and reopening the store from the same dir
852        // (the closest a unit test gets to a power-loss boundary).
853        let dir = tempfile::tempdir().expect("tempdir");
854        let path = dir.path().to_path_buf();
855        {
856            let store = Store::open(&StoreConfig {
857                data_dir: path.clone(),
858            })
859            .expect("open store");
860            let ks = store.keyspace("keys").unwrap();
861            ks.insert_raw("carveout:closed", b"admin-did".to_vec())
862                .await
863                .unwrap();
864            ks.persist().await.unwrap();
865            // store dropped here without an explicit graceful shutdown
866        }
867        let store = Store::open(&StoreConfig { data_dir: path }).expect("reopen store");
868        let ks = store.keyspace("keys").unwrap();
869        assert_eq!(
870            ks.get_raw("carveout:closed").await.unwrap().as_deref(),
871            Some(b"admin-did".as_slice()),
872            "a persisted write must survive a store reopen"
873        );
874    }
875
876    #[tokio::test]
877    async fn insert_if_absent_claims_only_once() {
878        let (store, _dir) = temp_store();
879        let ks = store.keyspace("test").unwrap();
880
881        assert!(
882            ks.insert_if_absent("k", &"first".to_string())
883                .await
884                .unwrap(),
885            "first claim must succeed"
886        );
887        assert!(
888            !ks.insert_if_absent("k", &"second".to_string())
889                .await
890                .unwrap(),
891            "second claim must be refused"
892        );
893        let got: String = ks.get("k").await.unwrap().unwrap();
894        assert_eq!(got, "first", "loser must not overwrite the stored value");
895    }
896
897    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
898    async fn insert_if_absent_under_concurrency_admits_exactly_one() {
899        let (store, _dir) = temp_store();
900        let ks = store.keyspace("test").unwrap();
901
902        let mut handles = Vec::new();
903        for i in 0..16u32 {
904            let ks = ks.clone();
905            handles.push(tokio::spawn(async move {
906                ks.insert_if_absent("contested", &format!("writer-{i}"))
907                    .await
908                    .unwrap()
909            }));
910        }
911        let mut winners = 0;
912        for h in handles {
913            if h.await.unwrap() {
914                winners += 1;
915            }
916        }
917        assert_eq!(winners, 1, "exactly one racing claim may win");
918    }
919
920    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
921    async fn take_raw_under_concurrency_admits_exactly_one() {
922        // Pins the refresh-token single-use guarantee: N concurrent
923        // take_raw calls on one key — exactly one observes Some.
924        // Handles are obtained via separate keyspace() calls to prove
925        // the write lock is shared per keyspace name, not per handle.
926        let (store, _dir) = temp_store();
927        store
928            .keyspace("test")
929            .unwrap()
930            .insert("token", &"refresh".to_string())
931            .await
932            .unwrap();
933
934        let mut handles = Vec::new();
935        for _ in 0..16 {
936            let ks = store.keyspace("test").unwrap();
937            handles.push(tokio::spawn(
938                async move { ks.take_raw("token").await.unwrap() },
939            ));
940        }
941        let mut claimed = 0;
942        for h in handles {
943            if h.await.unwrap().is_some() {
944                claimed += 1;
945            }
946        }
947        assert_eq!(claimed, 1, "exactly one concurrent take_raw may claim");
948    }
949
950    #[tokio::test]
951    async fn test_basic_roundtrip() {
952        let (store, _dir) = temp_store();
953        let ks = store.keyspace("test").unwrap();
954
955        #[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq)]
956        struct TestRecord {
957            id: String,
958            value: u64,
959        }
960
961        let record = TestRecord {
962            id: "test-1".into(),
963            value: 42,
964        };
965
966        ks.insert("key:test-1", &record).await.unwrap();
967        let got: TestRecord = ks.get("key:test-1").await.unwrap().unwrap();
968        assert_eq!(got, record);
969    }
970
971    #[tokio::test]
972    async fn test_prefix_iter() {
973        let (store, _dir) = temp_store();
974        let ks = store.keyspace("test").unwrap();
975
976        for i in 0..5 {
977            ks.insert_raw(format!("prefix:{i}"), format!("value-{i}").into_bytes())
978                .await
979                .unwrap();
980        }
981
982        let raw = ks.prefix_iter_raw("prefix:").await.unwrap();
983        assert_eq!(raw.len(), 5);
984    }
985
986    #[tokio::test]
987    async fn test_range_from_raw_seeks_to_lower_bound() {
988        let (store, _dir) = temp_store();
989        let ks = store.keyspace("test").unwrap();
990
991        // Timestamp-like keys (the audit-tail use case): lexical order
992        // == chronological order.
993        for k in ["2026-01:a", "2026-02:b", "2026-03:c", "2026-04:d"] {
994            ks.insert_raw(k.as_bytes().to_vec(), b"v".to_vec())
995                .await
996                .unwrap();
997        }
998
999        // Seek from "2026-03:" → only the rows at-or-after it.
1000        let rows = ks.range_from_raw(b"2026-03:".to_vec()).await.unwrap();
1001        let keys: Vec<String> = rows
1002            .iter()
1003            .map(|(k, _)| String::from_utf8(k.clone()).unwrap())
1004            .collect();
1005        assert_eq!(keys, vec!["2026-03:c", "2026-04:d"]);
1006
1007        // An empty lower bound returns everything (ascending).
1008        assert_eq!(ks.range_from_raw(Vec::new()).await.unwrap().len(), 4);
1009        // A bound past the end returns nothing.
1010        assert!(
1011            ks.range_from_raw(b"2026-99:".to_vec())
1012                .await
1013                .unwrap()
1014                .is_empty()
1015        );
1016    }
1017
1018    #[tokio::test]
1019    async fn test_remove() {
1020        let (store, _dir) = temp_store();
1021        let ks = store.keyspace("test").unwrap();
1022
1023        ks.insert_raw("key", b"value".to_vec()).await.unwrap();
1024        assert!(ks.get_raw("key").await.unwrap().is_some());
1025
1026        ks.remove("key").await.unwrap();
1027        assert!(ks.get_raw("key").await.unwrap().is_none());
1028    }
1029
1030    #[tokio::test]
1031    async fn test_swap() {
1032        let (store, _dir) = temp_store();
1033        let ks = store.keyspace("test").unwrap();
1034
1035        ks.insert("old", &"value").await.unwrap();
1036        let swapped = ks.swap("old", "new", &"value").await.unwrap();
1037        assert!(swapped);
1038        assert!(ks.get::<String>("old").await.unwrap().is_none());
1039        assert!(ks.get::<String>("new").await.unwrap().is_some());
1040    }
1041
1042    #[tokio::test]
1043    async fn test_passthrough_mode_no_encryption() {
1044        let (store, _dir) = temp_store();
1045        let ks = store.keyspace("plain").unwrap();
1046        assert!(!ks.is_encrypted());
1047
1048        ks.insert_raw("test", b"visible".to_vec()).await.unwrap();
1049        let raw = ks.get_raw("test").await.unwrap().unwrap();
1050        assert_eq!(raw, b"visible");
1051    }
1052
1053    #[cfg(feature = "encryption")]
1054    #[tokio::test]
1055    async fn test_encrypted_roundtrip() {
1056        let (store, _dir) = temp_store();
1057        let ks = store
1058            .keyspace("encrypted")
1059            .unwrap()
1060            .with_encryption([0xAB; 32]);
1061
1062        assert!(ks.is_encrypted());
1063
1064        // Raw bytes roundtrip
1065        ks.insert_raw("raw:test", b"hello world".to_vec())
1066            .await
1067            .unwrap();
1068        let raw = ks.get_raw("raw:test").await.unwrap().unwrap();
1069        assert_eq!(raw, b"hello world");
1070
1071        // JSON roundtrip
1072        ks.insert("json:test", &"encrypted value").await.unwrap();
1073        let got: String = ks.get("json:test").await.unwrap().unwrap();
1074        assert_eq!(got, "encrypted value");
1075    }
1076
1077    /// End-to-end AAD enforcement through the real handle (P0.1): a
1078    /// ciphertext written at one key must not decrypt when an attacker
1079    /// who controls the store relocates it to another key — even within
1080    /// the same keyspace and storage key. Without AAD this paste
1081    /// succeeds and resurrects e.g. a revoked ACL row.
1082    #[cfg(feature = "encryption")]
1083    #[tokio::test]
1084    async fn encrypted_value_cannot_be_pasted_to_another_key() {
1085        let (store, _dir) = temp_store();
1086        let key = [0x55; 32];
1087        let ks = store.keyspace("acl").unwrap().with_encryption(key);
1088
1089        ks.insert_raw("acl:victim", b"admin-row".to_vec())
1090            .await
1091            .unwrap();
1092
1093        // Simulate a hostile store operator copying the raw ciphertext
1094        // from one key to another (writing it back via an unencrypted
1095        // handle so no re-encryption happens).
1096        let raw = store.keyspace("acl").unwrap();
1097        let stolen = raw.get_raw("acl:victim").await.unwrap().unwrap();
1098        raw.insert_raw("acl:attacker", stolen).await.unwrap();
1099
1100        // Reading the relocated ciphertext through the encrypted handle
1101        // must fail AAD authentication, not silently return the value.
1102        let err = ks.get_raw("acl:attacker").await;
1103        assert!(
1104            err.is_err(),
1105            "a ciphertext pasted to a different key must fail AAD authentication"
1106        );
1107        // The original location still decrypts fine.
1108        assert_eq!(
1109            ks.get_raw("acl:victim").await.unwrap().unwrap(),
1110            b"admin-row"
1111        );
1112    }
1113
1114    #[cfg(feature = "encryption")]
1115    #[tokio::test]
1116    async fn test_encrypted_data_is_actually_encrypted_on_disk() {
1117        let (store, _dir) = temp_store();
1118        let enc_key = [0x42; 32];
1119
1120        // Write with encryption
1121        let ks_enc = store.keyspace("secrets").unwrap().with_encryption(enc_key);
1122        ks_enc
1123            .insert_raw("test", b"plaintext secret".to_vec())
1124            .await
1125            .unwrap();
1126
1127        // Read the same keyspace WITHOUT encryption — should get raw ciphertext
1128        let ks_raw = store.keyspace("secrets").unwrap();
1129        let on_disk = ks_raw.get_raw("test").await.unwrap().unwrap();
1130
1131        // The on-disk value should NOT be the plaintext
1132        assert_ne!(on_disk, b"plaintext secret");
1133        // It should be nonce (12) + ciphertext + tag (16) = at least 28 + plaintext len
1134        assert!(on_disk.len() >= 12 + 16 + 16);
1135
1136        // But reading with the correct encryption key should work
1137        let decrypted = ks_enc.get_raw("test").await.unwrap().unwrap();
1138        assert_eq!(decrypted, b"plaintext secret");
1139    }
1140
1141    /// P0.7: a keyspace first written in plaintext (pre-encryption-at-rest)
1142    /// can be migrated in place so an encrypted handle reads it, and the
1143    /// migrated rows are genuinely ciphertext on disk.
1144    #[cfg(feature = "encryption")]
1145    #[tokio::test]
1146    async fn migrate_to_encrypted_converts_legacy_plaintext() {
1147        let (store, _dir) = temp_store();
1148        let key = [0x33; 32];
1149
1150        // Seed legacy plaintext rows via a bare handle.
1151        let bare = store.keyspace("install").unwrap();
1152        bare.insert_raw("token:a", b"ephemeral-key-bytes".to_vec())
1153            .await
1154            .unwrap();
1155        bare.insert("token:b", &"json-state".to_string())
1156            .await
1157            .unwrap();
1158
1159        // Migrate.
1160        let migrated = bare.migrate_to_encrypted(key).await.unwrap();
1161        assert_eq!(migrated, 2, "both legacy rows must be encrypted");
1162
1163        // On disk (bare read) the rows are now ciphertext, not the
1164        // original plaintext.
1165        let on_disk = bare.get_raw("token:a").await.unwrap().unwrap();
1166        assert_ne!(on_disk, b"ephemeral-key-bytes");
1167        assert!(
1168            on_disk.starts_with(b"VAE1"),
1169            "migrated row must carry the v1 encryption magic"
1170        );
1171
1172        // An encrypted handle reads the original values back.
1173        let enc = store.keyspace("install").unwrap().with_encryption(key);
1174        assert_eq!(
1175            enc.get_raw("token:a").await.unwrap().unwrap(),
1176            b"ephemeral-key-bytes"
1177        );
1178        let b: String = enc.get("token:b").await.unwrap().unwrap();
1179        assert_eq!(b, "json-state");
1180    }
1181
1182    /// Re-running the migration is a no-op: already-encrypted rows are
1183    /// detected by their format magic and skipped, so an interrupted run
1184    /// is completed (not double-encrypted) by a re-run.
1185    #[cfg(feature = "encryption")]
1186    #[tokio::test]
1187    async fn migrate_to_encrypted_is_idempotent_and_crash_safe() {
1188        let (store, _dir) = temp_store();
1189        let key = [0x44; 32];
1190
1191        let bare = store.keyspace("passkey").unwrap();
1192        bare.insert_raw("row:1", b"plaintext-one".to_vec())
1193            .await
1194            .unwrap();
1195
1196        // First pass encrypts the one legacy row.
1197        assert_eq!(bare.migrate_to_encrypted(key).await.unwrap(), 1);
1198
1199        // A new legacy row lands (simulating a crash mid-migration that
1200        // left one row plaintext) alongside the already-encrypted one.
1201        bare.insert_raw("row:2", b"plaintext-two".to_vec())
1202            .await
1203            .unwrap();
1204
1205        // Second pass skips the encrypted row and only converts the new
1206        // one — never double-encrypting.
1207        assert_eq!(bare.migrate_to_encrypted(key).await.unwrap(), 1);
1208
1209        // Third pass is a pure no-op.
1210        assert_eq!(bare.migrate_to_encrypted(key).await.unwrap(), 0);
1211
1212        let enc = store.keyspace("passkey").unwrap().with_encryption(key);
1213        assert_eq!(
1214            enc.get_raw("row:1").await.unwrap().unwrap(),
1215            b"plaintext-one"
1216        );
1217        assert_eq!(
1218            enc.get_raw("row:2").await.unwrap().unwrap(),
1219            b"plaintext-two"
1220        );
1221    }
1222
1223    /// Calling the migration on an already-encrypted handle is a usage
1224    /// error — it would try to decrypt legacy plaintext and fail. Guard
1225    /// against it explicitly rather than corrupting data.
1226    #[cfg(feature = "encryption")]
1227    #[tokio::test]
1228    async fn migrate_to_encrypted_rejects_encrypted_handle() {
1229        let (store, _dir) = temp_store();
1230        let enc = store
1231            .keyspace("install")
1232            .unwrap()
1233            .with_encryption([0x55; 32]);
1234        assert!(enc.migrate_to_encrypted([0x55; 32]).await.is_err());
1235    }
1236}