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