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    ///
439    /// Gated with the feature it serves, like `encryption_key` below: every
440    /// read of it is an AAD construction, so a build without `encryption` has
441    /// nothing to read it and the compiler says so (`field is never read`,
442    /// which is what `pnm-cli` and every other default-feature consumer sees).
443    /// Carrying it regardless would leave a security-relevant field looking
444    /// like it might be doing something in builds where it cannot.
445    #[cfg(feature = "encryption")]
446    name: String,
447    /// The owning database, kept so the handle can fsync the shared
448    /// journal ([`LocalKeyspaceHandle::persist`]) — fjall only exposes
449    /// persistence at the database level.
450    db: fjall::Database,
451    /// Shared with every other handle for the same keyspace name — see
452    /// [`WriteLocks`].
453    write_lock: std::sync::Arc<std::sync::Mutex<()>>,
454    #[cfg(feature = "encryption")]
455    encryption_key: Option<std::sync::Arc<zeroize::Zeroizing<[u8; 32]>>>,
456}
457
458/// Acquire a write lock inside a blocking closure, recovering from
459/// poisoning: the lock only guards check-then-write sequencing, and
460/// every critical section re-reads store state, so a panicked holder
461/// leaves nothing logically inconsistent to inherit.
462fn lock_writes(lock: &std::sync::Mutex<()>) -> std::sync::MutexGuard<'_, ()> {
463    lock.lock()
464        .unwrap_or_else(std::sync::PoisonError::into_inner)
465}
466
467impl LocalStore {
468    pub fn open(config: &StoreConfig) -> Result<Self, AppError> {
469        std::fs::create_dir_all(&config.data_dir).map_err(AppError::Io)?;
470        info!(path = %config.data_dir.display(), "opening store");
471        let db = fjall::Database::builder(&config.data_dir).open()?;
472        Ok(Self {
473            db,
474            write_locks: WriteLocks::default(),
475        })
476    }
477
478    pub fn keyspace(&self, name: &str) -> Result<LocalKeyspaceHandle, AppError> {
479        let keyspace = self.db.keyspace(name, KeyspaceCreateOptions::default)?;
480        let write_lock = self
481            .write_locks
482            .lock()
483            .unwrap_or_else(std::sync::PoisonError::into_inner)
484            .entry(name.to_string())
485            .or_default()
486            .clone();
487        Ok(LocalKeyspaceHandle {
488            keyspace,
489            #[cfg(feature = "encryption")]
490            name: name.to_string(),
491            db: self.db.clone(),
492            write_lock,
493            #[cfg(feature = "encryption")]
494            encryption_key: None,
495        })
496    }
497
498    pub async fn persist(&self) -> Result<(), AppError> {
499        let db = self.db.clone();
500        tokio::task::spawn_blocking(move || db.persist(PersistMode::SyncAll))
501            .await
502            .map_err(|e| AppError::Internal(format!("blocking task panicked: {e}")))??;
503        Ok(())
504    }
505}
506
507impl LocalKeyspaceHandle {
508    #[cfg(feature = "encryption")]
509    pub fn with_encryption(mut self, key: [u8; 32]) -> Self {
510        self.encryption_key = Some(std::sync::Arc::new(zeroize::Zeroizing::new(key)));
511        self
512    }
513
514    pub fn is_encrypted(&self) -> bool {
515        #[cfg(feature = "encryption")]
516        {
517            self.encryption_key.is_some()
518        }
519        #[cfg(not(feature = "encryption"))]
520        {
521            false
522        }
523    }
524
525    /// Fsync the owning database's journal — see
526    /// [`KeyspaceHandle::persist`].
527    pub async fn persist(&self) -> Result<(), AppError> {
528        let db = self.db.clone();
529        blocking_with_timeout(move || Ok(db.persist(PersistMode::SyncAll)?)).await
530    }
531
532    pub async fn insert<V: Serialize>(
533        &self,
534        key: impl Into<Vec<u8>>,
535        value: &V,
536    ) -> Result<(), AppError> {
537        let key = key.into();
538        let bytes = serde_json::to_vec(value)?;
539        let bytes = self.maybe_encrypt(&key, bytes)?;
540        let ks = self.keyspace.clone();
541        blocking_with_timeout(move || Ok(ks.insert(key, bytes)?)).await
542    }
543
544    pub async fn get<V: DeserializeOwned + Send + 'static>(
545        &self,
546        key: impl Into<Vec<u8>>,
547    ) -> Result<Option<V>, AppError> {
548        let key = key.into();
549        let ks = self.keyspace.clone();
550        #[cfg(feature = "encryption")]
551        let enc_key = self.encryption_key.clone();
552        #[cfg(feature = "encryption")]
553        let name = self.name.clone();
554        blocking_with_timeout(move || match ks.get(&key)? {
555            Some(bytes) => {
556                #[cfg(feature = "encryption")]
557                let bytes = {
558                    let k = enc_key.as_ref().map(|arc| &***arc);
559                    encryption::maybe_decrypt_bytes(k, &name, &key, &bytes)?
560                };
561                #[cfg(not(feature = "encryption"))]
562                let bytes = bytes.to_vec();
563                Ok(Some(serde_json::from_slice(&bytes)?))
564            }
565            None => Ok(None),
566        })
567        .await
568    }
569
570    pub async fn remove(&self, key: impl Into<Vec<u8>>) -> Result<(), AppError> {
571        let key = key.into();
572        let ks = self.keyspace.clone();
573        blocking_with_timeout(move || Ok(ks.remove(key)?)).await
574    }
575
576    /// Atomically `GET` + `DELETE` (the classic Redis `GETDEL`).
577    ///
578    /// The `get` and `remove` run under the per-keyspace write lock
579    /// (see [`WriteLocks`]) so they are atomic with respect to any
580    /// other `take_raw`/`swap`/`insert_if_absent` racing on the same
581    /// keyspace — exactly one caller observes `Some`. (fjall alone
582    /// does NOT provide this: it serialises individual operations,
583    /// not check-then-write sequences across blocking threads.)
584    ///
585    /// Used by the canonical refresh-token claim
586    /// ([`crate::auth::session::take_session_id_by_refresh`]) to
587    /// close the rotation TOCTOU: a leaked refresh token can be
588    /// presented exactly once even under concurrent retries.
589    pub async fn take_raw(&self, key: impl Into<Vec<u8>>) -> Result<Option<Vec<u8>>, AppError> {
590        let key = key.into();
591        let ks = self.keyspace.clone();
592        let lock = self.write_lock.clone();
593        #[cfg(feature = "encryption")]
594        let enc_key = self.encryption_key.clone();
595        #[cfg(feature = "encryption")]
596        let name = self.name.clone();
597        blocking_with_timeout(move || {
598            let _guard = lock_writes(&lock);
599            match ks.get(&key)? {
600                Some(bytes) => {
601                    ks.remove(&key)?;
602                    #[cfg(feature = "encryption")]
603                    let bytes = {
604                        let k = enc_key.as_ref().map(|arc| &***arc);
605                        encryption::maybe_decrypt_bytes(k, &name, &key, &bytes)?
606                    };
607                    #[cfg(not(feature = "encryption"))]
608                    let bytes = bytes.to_vec();
609                    Ok(Some(bytes))
610                }
611                None => Ok(None),
612            }
613        })
614        .await
615    }
616
617    pub async fn insert_raw(
618        &self,
619        key: impl Into<Vec<u8>>,
620        value: impl Into<Vec<u8>>,
621    ) -> Result<(), AppError> {
622        let key = key.into();
623        let value = self.maybe_encrypt(&key, value.into())?;
624        let ks = self.keyspace.clone();
625        blocking_with_timeout(move || Ok(ks.insert(key, value)?)).await
626    }
627
628    pub async fn get_raw(&self, key: impl Into<Vec<u8>>) -> Result<Option<Vec<u8>>, AppError> {
629        let key = key.into();
630        let ks = self.keyspace.clone();
631        #[cfg(feature = "encryption")]
632        let enc_key = self.encryption_key.clone();
633        #[cfg(feature = "encryption")]
634        let name = self.name.clone();
635        blocking_with_timeout(move || match ks.get(&key)? {
636            Some(bytes) => {
637                #[cfg(feature = "encryption")]
638                let bytes = {
639                    let k = enc_key.as_ref().map(|arc| &***arc);
640                    encryption::maybe_decrypt_bytes(k, &name, &key, &bytes)?
641                };
642                #[cfg(not(feature = "encryption"))]
643                let bytes = bytes.to_vec();
644                Ok(Some(bytes))
645            }
646            None => Ok(None),
647        })
648        .await
649    }
650
651    pub async fn prefix_iter_raw(
652        &self,
653        prefix: impl Into<Vec<u8>>,
654    ) -> Result<Vec<RawKvPair>, AppError> {
655        let prefix = prefix.into();
656        let ks = self.keyspace.clone();
657        #[cfg(feature = "encryption")]
658        let enc_key = self.encryption_key.clone();
659        #[cfg(feature = "encryption")]
660        let name = self.name.clone();
661        blocking_with_timeout(move || {
662            let mut results = Vec::new();
663            for guard in ks.prefix(&prefix) {
664                let (key, value) = guard.into_inner()?;
665                #[cfg(feature = "encryption")]
666                let value = {
667                    let k = enc_key.as_ref().map(|arc| &***arc);
668                    encryption::maybe_decrypt_bytes(k, &name, &key, &value)?
669                };
670                #[cfg(not(feature = "encryption"))]
671                let value = value.to_vec();
672                results.push((key.to_vec(), value));
673            }
674            Ok(results)
675        })
676        .await
677    }
678
679    /// See [`KeyspaceHandle::range_from_raw`]. fjall's `range` seeks to
680    /// the lower bound, so this reads only keys `>= from`.
681    pub async fn range_from_raw(
682        &self,
683        from: impl Into<Vec<u8>>,
684    ) -> Result<Vec<RawKvPair>, AppError> {
685        let from = from.into();
686        let ks = self.keyspace.clone();
687        #[cfg(feature = "encryption")]
688        let enc_key = self.encryption_key.clone();
689        #[cfg(feature = "encryption")]
690        let name = self.name.clone();
691        blocking_with_timeout(move || {
692            let mut results = Vec::new();
693            for guard in ks.range(from..) {
694                let (key, value) = guard.into_inner()?;
695                #[cfg(feature = "encryption")]
696                let value = {
697                    let k = enc_key.as_ref().map(|arc| &***arc);
698                    encryption::maybe_decrypt_bytes(k, &name, &key, &value)?
699                };
700                #[cfg(not(feature = "encryption"))]
701                let value = value.to_vec();
702                results.push((key.to_vec(), value));
703            }
704            Ok(results)
705        })
706        .await
707    }
708
709    pub async fn prefix_keys(&self, prefix: impl Into<Vec<u8>>) -> Result<Vec<Vec<u8>>, AppError> {
710        let prefix = prefix.into();
711        let ks = self.keyspace.clone();
712        blocking_with_timeout(move || {
713            let mut results = Vec::new();
714            for guard in ks.prefix(&prefix) {
715                let (key, _value) = guard.into_inner()?;
716                results.push(key.to_vec());
717            }
718            Ok(results)
719        })
720        .await
721    }
722
723    pub async fn approximate_len(&self) -> Result<usize, AppError> {
724        let ks = self.keyspace.clone();
725        blocking_with_timeout(move || Ok(ks.approximate_len())).await
726    }
727
728    pub async fn swap<V: Serialize>(
729        &self,
730        old_key: impl Into<Vec<u8>>,
731        new_key: impl Into<Vec<u8>>,
732        value: &V,
733    ) -> Result<bool, AppError> {
734        let old_key = old_key.into();
735        let new_key = new_key.into();
736        let bytes = serde_json::to_vec(value)?;
737        // The value lands at `new_key`, so bind the AAD to `new_key`.
738        let bytes = self.maybe_encrypt(&new_key, bytes)?;
739        let ks = self.keyspace.clone();
740        let lock = self.write_lock.clone();
741        blocking_with_timeout(move || {
742            let _guard = lock_writes(&lock);
743            if ks.contains_key(&new_key)? {
744                return Ok(false);
745            }
746            ks.insert(&new_key, bytes)?;
747            ks.remove(&old_key)?;
748            Ok(true)
749        })
750        .await
751    }
752
753    /// Insert only if `key` is absent. The check and insert run under
754    /// the per-keyspace write lock (see [`WriteLocks`]), so exactly one
755    /// of two racing callers observes `true`.
756    pub async fn insert_if_absent<V: Serialize>(
757        &self,
758        key: impl Into<Vec<u8>>,
759        value: &V,
760    ) -> Result<bool, AppError> {
761        let key = key.into();
762        let bytes = serde_json::to_vec(value)?;
763        self.insert_bytes_if_absent(key, bytes).await
764    }
765
766    /// Raw-bytes variant of [`LocalKeyspaceHandle::insert_if_absent`] —
767    /// same lock, same exactly-one-winner guarantee.
768    pub async fn insert_raw_if_absent(
769        &self,
770        key: impl Into<Vec<u8>>,
771        value: impl Into<Vec<u8>>,
772    ) -> Result<bool, AppError> {
773        self.insert_bytes_if_absent(key.into(), value.into()).await
774    }
775
776    /// Shared body: check and insert run under the per-keyspace write
777    /// lock (see [`WriteLocks`]), so exactly one of two racing callers
778    /// observes `true`.
779    async fn insert_bytes_if_absent(&self, key: Vec<u8>, bytes: Vec<u8>) -> Result<bool, AppError> {
780        let bytes = self.maybe_encrypt(&key, bytes)?;
781        let ks = self.keyspace.clone();
782        let lock = self.write_lock.clone();
783        blocking_with_timeout(move || {
784            let _guard = lock_writes(&lock);
785            if ks.contains_key(&key)? {
786                return Ok(false);
787            }
788            ks.insert(&key, bytes)?;
789            Ok(true)
790        })
791        .await
792    }
793
794    fn maybe_encrypt(&self, store_key: &[u8], plaintext: Vec<u8>) -> Result<Vec<u8>, AppError> {
795        #[cfg(feature = "encryption")]
796        {
797            match self.encryption_key.as_ref().map(|arc| &***arc) {
798                Some(key) => encryption::encrypt_value(key, &self.name, store_key, &plaintext),
799                None => Ok(plaintext),
800            }
801        }
802        #[cfg(not(feature = "encryption"))]
803        {
804            let _ = store_key;
805            Ok(plaintext)
806        }
807    }
808}
809
810#[cfg(test)]
811mod tests {
812    use super::*;
813
814    fn temp_store() -> (Store, tempfile::TempDir) {
815        let dir = tempfile::tempdir().expect("failed to create temp dir");
816        let config = StoreConfig {
817            data_dir: dir.path().to_path_buf(),
818        };
819        let store = Store::open(&config).expect("failed to open store");
820        (store, dir)
821    }
822
823    #[test]
824    fn local_store_exists_ignores_a_bare_directory() {
825        // The case that motivated this probe: a Docker volume / PVC /
826        // hand-created directory exists before setup ever runs. That is
827        // not "there is a store here", and setup must not treat it as a
828        // conflict.
829        let dir = tempfile::tempdir().expect("tempdir");
830        assert!(
831            !local_store_exists(dir.path()),
832            "an empty directory holds no store"
833        );
834
835        std::fs::write(dir.path().join("did.jsonl"), b"{}").expect("write stray file");
836        assert!(
837            !local_store_exists(dir.path()),
838            "a non-empty directory without fjall's marker still holds no store"
839        );
840
841        assert!(
842            !local_store_exists(&dir.path().join("does-not-exist")),
843            "an absent directory holds no store"
844        );
845    }
846
847    #[test]
848    fn local_store_exists_sees_an_opened_store() {
849        let (_store, dir) = temp_store();
850        assert!(
851            local_store_exists(dir.path()),
852            "opening a store must make the probe report it"
853        );
854    }
855
856    #[tokio::test]
857    async fn persist_survives_store_reopen() {
858        // persist() is the durability barrier mint_mode_b relies on
859        // before returning the admin bundle. Prove a persisted write
860        // survives dropping and reopening the store from the same dir
861        // (the closest a unit test gets to a power-loss boundary).
862        let dir = tempfile::tempdir().expect("tempdir");
863        let path = dir.path().to_path_buf();
864        {
865            let store = Store::open(&StoreConfig {
866                data_dir: path.clone(),
867            })
868            .expect("open store");
869            let ks = store.keyspace("keys").unwrap();
870            ks.insert_raw("carveout:closed", b"admin-did".to_vec())
871                .await
872                .unwrap();
873            ks.persist().await.unwrap();
874            // store dropped here without an explicit graceful shutdown
875        }
876        let store = Store::open(&StoreConfig { data_dir: path }).expect("reopen store");
877        let ks = store.keyspace("keys").unwrap();
878        assert_eq!(
879            ks.get_raw("carveout:closed").await.unwrap().as_deref(),
880            Some(b"admin-did".as_slice()),
881            "a persisted write must survive a store reopen"
882        );
883    }
884
885    #[tokio::test]
886    async fn insert_if_absent_claims_only_once() {
887        let (store, _dir) = temp_store();
888        let ks = store.keyspace("test").unwrap();
889
890        assert!(
891            ks.insert_if_absent("k", &"first".to_string())
892                .await
893                .unwrap(),
894            "first claim must succeed"
895        );
896        assert!(
897            !ks.insert_if_absent("k", &"second".to_string())
898                .await
899                .unwrap(),
900            "second claim must be refused"
901        );
902        let got: String = ks.get("k").await.unwrap().unwrap();
903        assert_eq!(got, "first", "loser must not overwrite the stored value");
904    }
905
906    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
907    async fn insert_if_absent_under_concurrency_admits_exactly_one() {
908        let (store, _dir) = temp_store();
909        let ks = store.keyspace("test").unwrap();
910
911        let mut handles = Vec::new();
912        for i in 0..16u32 {
913            let ks = ks.clone();
914            handles.push(tokio::spawn(async move {
915                ks.insert_if_absent("contested", &format!("writer-{i}"))
916                    .await
917                    .unwrap()
918            }));
919        }
920        let mut winners = 0;
921        for h in handles {
922            if h.await.unwrap() {
923                winners += 1;
924            }
925        }
926        assert_eq!(winners, 1, "exactly one racing claim may win");
927    }
928
929    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
930    async fn take_raw_under_concurrency_admits_exactly_one() {
931        // Pins the refresh-token single-use guarantee: N concurrent
932        // take_raw calls on one key — exactly one observes Some.
933        // Handles are obtained via separate keyspace() calls to prove
934        // the write lock is shared per keyspace name, not per handle.
935        let (store, _dir) = temp_store();
936        store
937            .keyspace("test")
938            .unwrap()
939            .insert("token", &"refresh".to_string())
940            .await
941            .unwrap();
942
943        let mut handles = Vec::new();
944        for _ in 0..16 {
945            let ks = store.keyspace("test").unwrap();
946            handles.push(tokio::spawn(
947                async move { ks.take_raw("token").await.unwrap() },
948            ));
949        }
950        let mut claimed = 0;
951        for h in handles {
952            if h.await.unwrap().is_some() {
953                claimed += 1;
954            }
955        }
956        assert_eq!(claimed, 1, "exactly one concurrent take_raw may claim");
957    }
958
959    #[tokio::test]
960    async fn test_basic_roundtrip() {
961        let (store, _dir) = temp_store();
962        let ks = store.keyspace("test").unwrap();
963
964        #[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq)]
965        struct TestRecord {
966            id: String,
967            value: u64,
968        }
969
970        let record = TestRecord {
971            id: "test-1".into(),
972            value: 42,
973        };
974
975        ks.insert("key:test-1", &record).await.unwrap();
976        let got: TestRecord = ks.get("key:test-1").await.unwrap().unwrap();
977        assert_eq!(got, record);
978    }
979
980    #[tokio::test]
981    async fn test_prefix_iter() {
982        let (store, _dir) = temp_store();
983        let ks = store.keyspace("test").unwrap();
984
985        for i in 0..5 {
986            ks.insert_raw(format!("prefix:{i}"), format!("value-{i}").into_bytes())
987                .await
988                .unwrap();
989        }
990
991        let raw = ks.prefix_iter_raw("prefix:").await.unwrap();
992        assert_eq!(raw.len(), 5);
993    }
994
995    #[tokio::test]
996    async fn test_range_from_raw_seeks_to_lower_bound() {
997        let (store, _dir) = temp_store();
998        let ks = store.keyspace("test").unwrap();
999
1000        // Timestamp-like keys (the audit-tail use case): lexical order
1001        // == chronological order.
1002        for k in ["2026-01:a", "2026-02:b", "2026-03:c", "2026-04:d"] {
1003            ks.insert_raw(k.as_bytes().to_vec(), b"v".to_vec())
1004                .await
1005                .unwrap();
1006        }
1007
1008        // Seek from "2026-03:" → only the rows at-or-after it.
1009        let rows = ks.range_from_raw(b"2026-03:".to_vec()).await.unwrap();
1010        let keys: Vec<String> = rows
1011            .iter()
1012            .map(|(k, _)| String::from_utf8(k.clone()).unwrap())
1013            .collect();
1014        assert_eq!(keys, vec!["2026-03:c", "2026-04:d"]);
1015
1016        // An empty lower bound returns everything (ascending).
1017        assert_eq!(ks.range_from_raw(Vec::new()).await.unwrap().len(), 4);
1018        // A bound past the end returns nothing.
1019        assert!(
1020            ks.range_from_raw(b"2026-99:".to_vec())
1021                .await
1022                .unwrap()
1023                .is_empty()
1024        );
1025    }
1026
1027    #[tokio::test]
1028    async fn test_remove() {
1029        let (store, _dir) = temp_store();
1030        let ks = store.keyspace("test").unwrap();
1031
1032        ks.insert_raw("key", b"value".to_vec()).await.unwrap();
1033        assert!(ks.get_raw("key").await.unwrap().is_some());
1034
1035        ks.remove("key").await.unwrap();
1036        assert!(ks.get_raw("key").await.unwrap().is_none());
1037    }
1038
1039    #[tokio::test]
1040    async fn test_swap() {
1041        let (store, _dir) = temp_store();
1042        let ks = store.keyspace("test").unwrap();
1043
1044        ks.insert("old", &"value").await.unwrap();
1045        let swapped = ks.swap("old", "new", &"value").await.unwrap();
1046        assert!(swapped);
1047        assert!(ks.get::<String>("old").await.unwrap().is_none());
1048        assert!(ks.get::<String>("new").await.unwrap().is_some());
1049    }
1050
1051    #[tokio::test]
1052    async fn test_passthrough_mode_no_encryption() {
1053        let (store, _dir) = temp_store();
1054        let ks = store.keyspace("plain").unwrap();
1055        assert!(!ks.is_encrypted());
1056
1057        ks.insert_raw("test", b"visible".to_vec()).await.unwrap();
1058        let raw = ks.get_raw("test").await.unwrap().unwrap();
1059        assert_eq!(raw, b"visible");
1060    }
1061
1062    #[cfg(feature = "encryption")]
1063    #[tokio::test]
1064    async fn test_encrypted_roundtrip() {
1065        let (store, _dir) = temp_store();
1066        let ks = store
1067            .keyspace("encrypted")
1068            .unwrap()
1069            .with_encryption([0xAB; 32]);
1070
1071        assert!(ks.is_encrypted());
1072
1073        // Raw bytes roundtrip
1074        ks.insert_raw("raw:test", b"hello world".to_vec())
1075            .await
1076            .unwrap();
1077        let raw = ks.get_raw("raw:test").await.unwrap().unwrap();
1078        assert_eq!(raw, b"hello world");
1079
1080        // JSON roundtrip
1081        ks.insert("json:test", &"encrypted value").await.unwrap();
1082        let got: String = ks.get("json:test").await.unwrap().unwrap();
1083        assert_eq!(got, "encrypted value");
1084    }
1085
1086    /// End-to-end AAD enforcement through the real handle (P0.1): a
1087    /// ciphertext written at one key must not decrypt when an attacker
1088    /// who controls the store relocates it to another key — even within
1089    /// the same keyspace and storage key. Without AAD this paste
1090    /// succeeds and resurrects e.g. a revoked ACL row.
1091    #[cfg(feature = "encryption")]
1092    #[tokio::test]
1093    async fn encrypted_value_cannot_be_pasted_to_another_key() {
1094        let (store, _dir) = temp_store();
1095        let key = [0x55; 32];
1096        let ks = store.keyspace("acl").unwrap().with_encryption(key);
1097
1098        ks.insert_raw("acl:victim", b"admin-row".to_vec())
1099            .await
1100            .unwrap();
1101
1102        // Simulate a hostile store operator copying the raw ciphertext
1103        // from one key to another (writing it back via an unencrypted
1104        // handle so no re-encryption happens).
1105        let raw = store.keyspace("acl").unwrap();
1106        let stolen = raw.get_raw("acl:victim").await.unwrap().unwrap();
1107        raw.insert_raw("acl:attacker", stolen).await.unwrap();
1108
1109        // Reading the relocated ciphertext through the encrypted handle
1110        // must fail AAD authentication, not silently return the value.
1111        let err = ks.get_raw("acl:attacker").await;
1112        assert!(
1113            err.is_err(),
1114            "a ciphertext pasted to a different key must fail AAD authentication"
1115        );
1116        // The original location still decrypts fine.
1117        assert_eq!(
1118            ks.get_raw("acl:victim").await.unwrap().unwrap(),
1119            b"admin-row"
1120        );
1121    }
1122
1123    #[cfg(feature = "encryption")]
1124    #[tokio::test]
1125    async fn test_encrypted_data_is_actually_encrypted_on_disk() {
1126        let (store, _dir) = temp_store();
1127        let enc_key = [0x42; 32];
1128
1129        // Write with encryption
1130        let ks_enc = store.keyspace("secrets").unwrap().with_encryption(enc_key);
1131        ks_enc
1132            .insert_raw("test", b"plaintext secret".to_vec())
1133            .await
1134            .unwrap();
1135
1136        // Read the same keyspace WITHOUT encryption — should get raw ciphertext
1137        let ks_raw = store.keyspace("secrets").unwrap();
1138        let on_disk = ks_raw.get_raw("test").await.unwrap().unwrap();
1139
1140        // The on-disk value should NOT be the plaintext
1141        assert_ne!(on_disk, b"plaintext secret");
1142        // It should be nonce (12) + ciphertext + tag (16) = at least 28 + plaintext len
1143        assert!(on_disk.len() >= 12 + 16 + 16);
1144
1145        // But reading with the correct encryption key should work
1146        let decrypted = ks_enc.get_raw("test").await.unwrap().unwrap();
1147        assert_eq!(decrypted, b"plaintext secret");
1148    }
1149
1150    /// P0.7: a keyspace first written in plaintext (pre-encryption-at-rest)
1151    /// can be migrated in place so an encrypted handle reads it, and the
1152    /// migrated rows are genuinely ciphertext on disk.
1153    #[cfg(feature = "encryption")]
1154    #[tokio::test]
1155    async fn migrate_to_encrypted_converts_legacy_plaintext() {
1156        let (store, _dir) = temp_store();
1157        let key = [0x33; 32];
1158
1159        // Seed legacy plaintext rows via a bare handle.
1160        let bare = store.keyspace("install").unwrap();
1161        bare.insert_raw("token:a", b"ephemeral-key-bytes".to_vec())
1162            .await
1163            .unwrap();
1164        bare.insert("token:b", &"json-state".to_string())
1165            .await
1166            .unwrap();
1167
1168        // Migrate.
1169        let migrated = bare.migrate_to_encrypted(key).await.unwrap();
1170        assert_eq!(migrated, 2, "both legacy rows must be encrypted");
1171
1172        // On disk (bare read) the rows are now ciphertext, not the
1173        // original plaintext.
1174        let on_disk = bare.get_raw("token:a").await.unwrap().unwrap();
1175        assert_ne!(on_disk, b"ephemeral-key-bytes");
1176        assert!(
1177            on_disk.starts_with(b"VAE1"),
1178            "migrated row must carry the v1 encryption magic"
1179        );
1180
1181        // An encrypted handle reads the original values back.
1182        let enc = store.keyspace("install").unwrap().with_encryption(key);
1183        assert_eq!(
1184            enc.get_raw("token:a").await.unwrap().unwrap(),
1185            b"ephemeral-key-bytes"
1186        );
1187        let b: String = enc.get("token:b").await.unwrap().unwrap();
1188        assert_eq!(b, "json-state");
1189    }
1190
1191    /// Re-running the migration is a no-op: already-encrypted rows are
1192    /// detected by their format magic and skipped, so an interrupted run
1193    /// is completed (not double-encrypted) by a re-run.
1194    #[cfg(feature = "encryption")]
1195    #[tokio::test]
1196    async fn migrate_to_encrypted_is_idempotent_and_crash_safe() {
1197        let (store, _dir) = temp_store();
1198        let key = [0x44; 32];
1199
1200        let bare = store.keyspace("passkey").unwrap();
1201        bare.insert_raw("row:1", b"plaintext-one".to_vec())
1202            .await
1203            .unwrap();
1204
1205        // First pass encrypts the one legacy row.
1206        assert_eq!(bare.migrate_to_encrypted(key).await.unwrap(), 1);
1207
1208        // A new legacy row lands (simulating a crash mid-migration that
1209        // left one row plaintext) alongside the already-encrypted one.
1210        bare.insert_raw("row:2", b"plaintext-two".to_vec())
1211            .await
1212            .unwrap();
1213
1214        // Second pass skips the encrypted row and only converts the new
1215        // one — never double-encrypting.
1216        assert_eq!(bare.migrate_to_encrypted(key).await.unwrap(), 1);
1217
1218        // Third pass is a pure no-op.
1219        assert_eq!(bare.migrate_to_encrypted(key).await.unwrap(), 0);
1220
1221        let enc = store.keyspace("passkey").unwrap().with_encryption(key);
1222        assert_eq!(
1223            enc.get_raw("row:1").await.unwrap().unwrap(),
1224            b"plaintext-one"
1225        );
1226        assert_eq!(
1227            enc.get_raw("row:2").await.unwrap().unwrap(),
1228            b"plaintext-two"
1229        );
1230    }
1231
1232    /// Calling the migration on an already-encrypted handle is a usage
1233    /// error — it would try to decrypt legacy plaintext and fail. Guard
1234    /// against it explicitly rather than corrupting data.
1235    #[cfg(feature = "encryption")]
1236    #[tokio::test]
1237    async fn migrate_to_encrypted_rejects_encrypted_handle() {
1238        let (store, _dir) = temp_store();
1239        let enc = store
1240            .keyspace("install")
1241            .unwrap()
1242            .with_encryption([0x55; 32]);
1243        assert!(enc.migrate_to_encrypted([0x55; 32]).await.is_err());
1244    }
1245}