Skip to main content

faucet_core/
state.rs

1//! Pluggable state store for incremental replication bookmarks.
2//!
3//! Sources that support incremental replication need to remember where they
4//! left off across runs. This module defines the [`StateStore`] trait that
5//! the pipeline orchestrator uses to read and persist that progress, plus two
6//! ready-to-use implementations:
7//!
8//! - [`MemoryStateStore`] — in-process map. Useful for tests and for runs
9//!   that intentionally start fresh each time.
10//! - [`FileStateStore`] — one JSON file per key, written via atomic rename
11//!   so a crash mid-update never leaves the bookmark torn.
12//!
13//! Heavier backends (Redis, PostgreSQL) live in their own crates so
14//! `faucet-core` stays dependency-light. They implement the same trait.
15
16use crate::error::FaucetError;
17use async_trait::async_trait;
18use serde_json::Value;
19use std::collections::HashMap;
20use std::path::{Path, PathBuf};
21use tokio::io::AsyncWriteExt;
22use tokio::sync::Mutex;
23
24/// Persistent key/value store for replication bookmarks and pipeline checkpoints.
25///
26/// Implementations must be safe to call from multiple tasks at once. The
27/// trait is intentionally minimal — three operations cover every bookmark
28/// flow the pipeline orchestrator needs.
29#[async_trait]
30pub trait StateStore: Send + Sync {
31    /// Return the value stored under `key`, or `None` if no entry exists.
32    async fn get(&self, key: &str) -> Result<Option<Value>, FaucetError>;
33
34    /// Store `value` under `key`, replacing any previous entry.
35    ///
36    /// Implementations should make the update durable before returning so a
37    /// crash immediately after `put` does not lose the bookmark.
38    async fn put(&self, key: &str, value: &Value) -> Result<(), FaucetError>;
39
40    /// Remove the entry for `key`. A missing key is not an error.
41    async fn delete(&self, key: &str) -> Result<(), FaucetError>;
42
43    /// Run a fast, non-mutating preflight probe (used by `faucet doctor`).
44    ///
45    /// The default returns
46    /// [`CheckReport::not_implemented`](crate::check::CheckReport::not_implemented).
47    /// Built-in stores override this with a reachability + sentinel
48    /// get/put/delete probe that leaves no residue.
49    async fn check(
50        &self,
51        _ctx: &crate::check::CheckContext,
52    ) -> Result<crate::check::CheckReport, FaucetError> {
53        Ok(crate::check::CheckReport::not_implemented())
54    }
55
56    /// Whether [`list`](Self::list) can enumerate keys. Every built-in store
57    /// can; the default is `false` so a third-party store keeps compiling.
58    fn supports_list(&self) -> bool {
59        false
60    }
61
62    /// Every stored key that starts with `prefix`, sorted ascending.
63    ///
64    /// Used to enumerate a pipeline's namespace (`{name}::…`) for
65    /// `faucet state show|export`. The default returns a typed error.
66    async fn list(&self, _prefix: &str) -> Result<Vec<String>, FaucetError> {
67        Err(FaucetError::State(
68            "this state store cannot enumerate its keys".into(),
69        ))
70    }
71
72    /// Whether [`compare_and_put`](Self::compare_and_put) is atomic.
73    fn supports_compare_and_put(&self) -> bool {
74        false
75    }
76
77    /// Store `value` under `key` only when the entry still equals `expected`
78    /// (`None` = no entry). Returns whether it was written. Two holders of one
79    /// value (a rotated credential, say) use it so the second writer does not
80    /// clobber the first.
81    ///
82    /// The default reads, compares and writes — not atomic across processes;
83    /// stores that can do better override it and
84    /// [`supports_compare_and_put`](Self::supports_compare_and_put).
85    async fn compare_and_put(
86        &self,
87        key: &str,
88        expected: Option<&Value>,
89        value: &Value,
90    ) -> Result<bool, FaucetError> {
91        if self.get(key).await?.as_ref() != expected {
92            return Ok(false);
93        }
94        self.put(key, value).await?;
95        Ok(true)
96    }
97
98    /// Whether [`put_batch`](Self::put_batch) commits all-or-nothing.
99    fn supports_atomic_batch(&self) -> bool {
100        false
101    }
102
103    /// Store every entry. When [`supports_atomic_batch`](Self::supports_atomic_batch)
104    /// is `true` either all entries land or none do; the default writes them
105    /// one by one, so an error can leave a prefix of `entries` written.
106    async fn put_batch(&self, entries: &[(String, Value)]) -> Result<(), FaucetError> {
107        for (key, value) in entries {
108            self.put(key, value).await?;
109        }
110        Ok(())
111    }
112}
113
114/// Version of the [`StateExport`] document this build writes and reads.
115pub const STATE_EXPORT_VERSION: u32 = 1;
116
117/// The key prefix every state key of pipeline `name` starts with.
118pub fn namespace_prefix(pipeline: &str) -> String {
119    format!("{pipeline}::")
120}
121
122/// A portable snapshot of one pipeline's durable state — every key under its
123/// namespace (`{pipeline}::…`) with its stored value, exactly as the store
124/// holds it (exactly-once envelopes included).
125///
126/// Serialized as `{ "version": 1, "pipeline": …, "exported_at": …, "keys": {…} }`.
127#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
128pub struct StateExport {
129    /// Document version — [`STATE_EXPORT_VERSION`] for documents this build writes.
130    pub version: u32,
131    /// The pipeline whose namespace the keys belong to.
132    pub pipeline: String,
133    /// When the snapshot was taken (RFC 3339), informational.
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub exported_at: Option<String>,
136    /// Every key and its stored value.
137    #[serde(default)]
138    pub keys: std::collections::BTreeMap<String, Value>,
139}
140
141impl StateExport {
142    /// A version-[`STATE_EXPORT_VERSION`] snapshot of `pipeline`.
143    pub fn new(pipeline: impl Into<String>) -> Self {
144        Self {
145            version: STATE_EXPORT_VERSION,
146            pipeline: pipeline.into(),
147            exported_at: None,
148            keys: Default::default(),
149        }
150    }
151
152    /// Parse and [`validate`](Self::validate) a document. A version newer than
153    /// this build understands is refused rather than half-read.
154    pub fn from_value(value: Value) -> Result<Self, FaucetError> {
155        let version = match value.get("version") {
156            None => {
157                return Err(FaucetError::State(
158                    "state export has no `version` field".into(),
159                ));
160            }
161            Some(v) => v.as_u64().ok_or_else(|| {
162                FaucetError::State(format!(
163                    "state export `version` must be an integer, got {v}"
164                ))
165            })?,
166        };
167        if version == 0 || version > u64::from(STATE_EXPORT_VERSION) {
168            return Err(FaucetError::State(format!(
169                "state export version {version} is not supported by this build (supported: \
170                 {STATE_EXPORT_VERSION}) — import it with the faucet release that wrote it or newer"
171            )));
172        }
173        let export: Self = serde_json::from_value(value)
174            .map_err(|e| FaucetError::State(format!("malformed state export: {e}")))?;
175        export.validate()?;
176        Ok(export)
177    }
178
179    /// Every key must be a valid state key inside the pipeline's namespace.
180    pub fn validate(&self) -> Result<(), FaucetError> {
181        validate_state_key(&self.pipeline)
182            .map_err(|e| FaucetError::State(format!("state export pipeline name: {e}")))?;
183        let ns = namespace_prefix(&self.pipeline);
184        for key in self.keys.keys() {
185            validate_state_key(key)?;
186            if !key.starts_with(&ns) {
187                return Err(FaucetError::State(format!(
188                    "state export key '{key}' is outside the namespace '{ns}' of pipeline '{}'",
189                    self.pipeline
190                )));
191            }
192        }
193        Ok(())
194    }
195}
196
197/// Read every key under `pipeline`'s namespace into a [`StateExport`].
198pub async fn export_namespace(
199    store: &dyn StateStore,
200    pipeline: &str,
201) -> Result<StateExport, FaucetError> {
202    let mut export = StateExport::new(pipeline);
203    for key in store.list(&namespace_prefix(pipeline)).await? {
204        if let Some(value) = store.get(&key).await? {
205            export.keys.insert(key, value);
206        }
207    }
208    Ok(export)
209}
210
211/// What [`import_namespace`] did.
212#[derive(Debug, Clone, Default, PartialEq, serde::Serialize)]
213pub struct ImportReport {
214    /// Keys written, in write order.
215    pub written: Vec<String>,
216    /// Keys that existed in the namespace but not in the export and were
217    /// removed (only with `replace`).
218    pub deleted: Vec<String>,
219    /// Whether the writes went through one all-or-nothing batch.
220    pub atomic: bool,
221    /// The first failure, if any; with a non-atomic store `written` then lists
222    /// exactly what landed before it.
223    #[serde(skip_serializing_if = "Option::is_none")]
224    pub error: Option<String>,
225}
226
227/// Write `export` into `store`. With `replace`, keys already in the namespace
228/// that the export does not hold are deleted afterwards, so the namespace ends
229/// up exactly as exported. Validates the document first.
230pub async fn import_namespace(
231    store: &dyn StateStore,
232    export: &StateExport,
233    replace: bool,
234) -> Result<ImportReport, FaucetError> {
235    export.validate()?;
236    let entries: Vec<(String, Value)> = export
237        .keys
238        .iter()
239        .map(|(k, v)| (k.clone(), v.clone()))
240        .collect();
241    let stale: Vec<String> = if replace {
242        store
243            .list(&namespace_prefix(&export.pipeline))
244            .await?
245            .into_iter()
246            .filter(|k| !export.keys.contains_key(k))
247            .collect()
248    } else {
249        Vec::new()
250    };
251    let mut report = ImportReport {
252        atomic: store.supports_atomic_batch(),
253        ..Default::default()
254    };
255    if report.atomic {
256        if let Err(e) = store.put_batch(&entries).await {
257            report.error = Some(e.to_string());
258            return Ok(report);
259        }
260        report.written = entries.into_iter().map(|(k, _)| k).collect();
261    } else {
262        for (key, value) in &entries {
263            if let Err(e) = store.put(key, value).await {
264                report.error = Some(format!("writing '{key}': {e}"));
265                return Ok(report);
266            }
267            report.written.push(key.clone());
268        }
269    }
270    for key in stale {
271        if let Err(e) = store.delete(&key).await {
272            report.error = Some(format!("deleting stale '{key}': {e}"));
273            return Ok(report);
274        }
275        report.deleted.push(key);
276    }
277    Ok(report)
278}
279
280/// Prefix of the sentinel key used by state-store `check()` probes. Valid per
281/// [`validate_state_key`] and deleted after the probe so it leaves no residue.
282/// Probes use [`doctor_sentinel_key`], which makes each one unique.
283pub const DOCTOR_SENTINEL_KEY: &str = "faucet_doctor_probe";
284
285/// A sentinel key unique to one `check()` probe, so concurrent probes against
286/// a shared store never delete each other's sentinel between its put and get
287/// (CORE-57).
288pub fn doctor_sentinel_key() -> String {
289    format!("{DOCTOR_SENTINEL_KEY}.{}", uuid::Uuid::new_v4().simple())
290}
291
292/// Reject keys that could escape the storage namespace or break filename
293/// rules on common filesystems. Allowed: ASCII letters, digits, `_`, `-`,
294/// `:`, `.`. Empty keys are rejected.
295pub fn validate_state_key(key: &str) -> Result<(), FaucetError> {
296    if key.is_empty() {
297        return Err(FaucetError::State("state key must not be empty".into()));
298    }
299    if key.len() > 256 {
300        return Err(FaucetError::State(format!(
301            "state key '{key}' exceeds 256 characters"
302        )));
303    }
304    for (i, c) in key.char_indices() {
305        let ok = c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | ':' | '.' | '/');
306        if !ok {
307            return Err(FaucetError::State(format!(
308                "state key '{key}' contains illegal character {c:?} at byte {i}"
309            )));
310        }
311    }
312    if key == "." || key == ".." || key.starts_with('.') {
313        return Err(FaucetError::State(format!(
314            "state key '{key}' must not begin with a dot"
315        )));
316    }
317    // `/` separates a hub owner namespace from a name (`acme/erp::…`);
318    // it must never read as a path: no empty, `.` or `..` segments, no
319    // leading or trailing separator.
320    if key.contains('/')
321        && key
322            .split('/')
323            .any(|seg| seg.is_empty() || seg == "." || seg == ".." || seg.starts_with('.'))
324    {
325        return Err(FaucetError::State(format!(
326            "state key '{key}' has a path-like segment — `/` may only separate non-empty, non-dot segments"
327        )));
328    }
329    Ok(())
330}
331
332/// A lock file older than this belongs to a writer that died holding it.
333const STALE_LOCK: std::time::Duration = std::time::Duration::from_secs(30);
334/// How long a compare-and-put waits for another writer's lock.
335const LOCK_WAIT: std::time::Duration = std::time::Duration::from_secs(10);
336
337/// Removes the lock file when dropped.
338struct LockFile(PathBuf);
339
340impl Drop for LockFile {
341    fn drop(&mut self) {
342        let _ = std::fs::remove_file(&self.0);
343    }
344}
345
346async fn acquire_lock_file(path: &Path) -> Result<LockFile, FaucetError> {
347    let started = tokio::time::Instant::now();
348    loop {
349        match tokio::fs::OpenOptions::new()
350            .write(true)
351            .create_new(true)
352            .open(path)
353            .await
354        {
355            Ok(_) => return Ok(LockFile(path.to_path_buf())),
356            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
357                let stale = tokio::fs::metadata(path)
358                    .await
359                    .ok()
360                    .and_then(|m| m.modified().ok())
361                    .and_then(|t| t.elapsed().ok())
362                    .is_some_and(|age| age > STALE_LOCK);
363                if stale {
364                    let _ = tokio::fs::remove_file(path).await;
365                    continue;
366                }
367                if started.elapsed() > LOCK_WAIT {
368                    return Err(FaucetError::State(format!(
369                        "state lock {} is held by another writer",
370                        path.display()
371                    )));
372                }
373                tokio::time::sleep(std::time::Duration::from_millis(10)).await;
374            }
375            Err(e) => {
376                return Err(FaucetError::State(format!(
377                    "failed to create state lock {}: {e}",
378                    path.display()
379                )));
380            }
381        }
382    }
383}
384
385// ── MemoryStateStore ────────────────────────────────────────────────────────
386
387/// In-memory `StateStore` for tests and ephemeral pipelines.
388#[derive(Default)]
389pub struct MemoryStateStore {
390    inner: Mutex<HashMap<String, Value>>,
391}
392
393impl MemoryStateStore {
394    /// Create an empty in-memory store.
395    pub fn new() -> Self {
396        Self::default()
397    }
398}
399
400#[async_trait]
401impl StateStore for MemoryStateStore {
402    async fn get(&self, key: &str) -> Result<Option<Value>, FaucetError> {
403        validate_state_key(key)?;
404        Ok(self.inner.lock().await.get(key).cloned())
405    }
406
407    async fn put(&self, key: &str, value: &Value) -> Result<(), FaucetError> {
408        validate_state_key(key)?;
409        self.inner
410            .lock()
411            .await
412            .insert(key.to_owned(), value.clone());
413        Ok(())
414    }
415
416    async fn delete(&self, key: &str) -> Result<(), FaucetError> {
417        validate_state_key(key)?;
418        self.inner.lock().await.remove(key);
419        Ok(())
420    }
421
422    fn supports_list(&self) -> bool {
423        true
424    }
425
426    async fn list(&self, prefix: &str) -> Result<Vec<String>, FaucetError> {
427        let mut keys: Vec<String> = self
428            .inner
429            .lock()
430            .await
431            .keys()
432            .filter(|k| k.starts_with(prefix))
433            .cloned()
434            .collect();
435        keys.sort();
436        Ok(keys)
437    }
438
439    fn supports_compare_and_put(&self) -> bool {
440        true
441    }
442
443    async fn compare_and_put(
444        &self,
445        key: &str,
446        expected: Option<&Value>,
447        value: &Value,
448    ) -> Result<bool, FaucetError> {
449        validate_state_key(key)?;
450        let mut map = self.inner.lock().await;
451        if map.get(key) != expected {
452            return Ok(false);
453        }
454        map.insert(key.to_owned(), value.clone());
455        Ok(true)
456    }
457
458    fn supports_atomic_batch(&self) -> bool {
459        true
460    }
461
462    async fn put_batch(&self, entries: &[(String, Value)]) -> Result<(), FaucetError> {
463        for (key, _) in entries {
464            validate_state_key(key)?;
465        }
466        let mut map = self.inner.lock().await;
467        for (key, value) in entries {
468            map.insert(key.clone(), value.clone());
469        }
470        Ok(())
471    }
472
473    async fn check(
474        &self,
475        _ctx: &crate::check::CheckContext,
476    ) -> Result<crate::check::CheckReport, FaucetError> {
477        // In-process map — always reachable.
478        Ok(crate::check::CheckReport::single(
479            crate::check::Probe::pass("sentinel", std::time::Duration::ZERO),
480        ))
481    }
482}
483
484// ── FileStateStore ──────────────────────────────────────────────────────────
485
486/// Longest filename stem [`safe_filename`] emits before it hashes the key.
487/// The temp name adds `.{32-hex}.{seq}.json.tmp` (at most 63 bytes), so a
488/// stem of this length keeps every file the store writes under the 255-byte
489/// name limit of common filesystems (CORE-29).
490const MAX_STEM: usize = 160;
491
492/// Map a logical state key to a filesystem-safe filename stem.
493///
494/// - `:` and `/` are percent-encoded as `%3A` / `%2F` (`:` is illegal in an
495///   NTFS filename, `/` is a path separator).
496/// - Every uppercase ASCII letter is percent-encoded (`A` → `%41`), so the
497///   stem holds no uppercase letters outside escapes and two keys that differ
498///   only in case can never share a file on a case-insensitive filesystem
499///   (APFS, NTFS) (CORE-28).
500/// - A stem longer than [`MAX_STEM`] keeps a readable prefix and ends in
501///   `~<sha256 of the key>`; `~` never appears in a key or an escape, so the
502///   two shapes cannot collide. The full key is kept in a `<stem>.key`
503///   sidecar so [`StateStore::list`] can still report it.
504///
505/// Collision-free because `%` and `~` can never appear in a valid key (see
506/// [`validate_state_key`]).
507fn safe_filename(key: &str) -> String {
508    let mut out = String::with_capacity(key.len() + 8);
509    for c in key.chars() {
510        match c {
511            ':' => out.push_str("%3A"),
512            '/' => out.push_str("%2F"),
513            'A'..='Z' => out.push_str(&format!("%{:02X}", c as u32)),
514            other => out.push(other),
515        }
516    }
517    if out.len() <= MAX_STEM {
518        return out;
519    }
520    use sha2::Digest;
521    let digest = sha2::Sha256::digest(key.as_bytes());
522    let hex: String = digest.iter().map(|b| format!("{b:02x}")).collect();
523    let mut cut = MAX_STEM - hex.len() - 1;
524    // Never split a `%XX` escape.
525    while cut > 0
526        && out.as_bytes()[..cut]
527            .iter()
528            .rev()
529            .take(2)
530            .any(|b| *b == b'%')
531    {
532        cut -= 1;
533    }
534    format!("{}~{hex}", &out[..cut])
535}
536
537/// Inverse of [`safe_filename`] for un-hashed stems (both the current and the
538/// legacy shape decode); `None` when the stem is hashed or not a valid key.
539fn key_from_filename(stem: &str) -> Option<String> {
540    if stem.contains('~') {
541        return None;
542    }
543    let bytes = stem.as_bytes();
544    let mut key = String::with_capacity(stem.len());
545    let mut i = 0;
546    while i < bytes.len() {
547        if bytes[i] == b'%' {
548            let hex = stem.get(i + 1..i + 3)?;
549            let code = u8::from_str_radix(hex, 16).ok()?;
550            key.push(char::from(code));
551            i += 3;
552        } else {
553            key.push(char::from(bytes[i]));
554            i += 1;
555        }
556    }
557    validate_state_key(&key).ok().map(|()| key)
558}
559
560/// File-backed `StateStore`. Each key maps to a JSON file at
561/// `{root}/{safe_filename(key)}.json`, written via atomic rename. The
562/// filename stem percent-encodes `:`, `/` and uppercase letters, so keys are
563/// valid on Windows and keys differing only in case never share a file on a
564/// case-insensitive filesystem; over-long keys are hashed.
565///
566/// Each `put` writes a temp file, fsyncs it, renames it over the final path,
567/// and (on Unix) fsyncs the parent directory — so once `put` returns the
568/// bookmark is durable across a crash or power loss, not merely sitting in the
569/// page cache.
570///
571/// The store creates its root directory on first use. All writes are
572/// serialized through a per-store mutex so two concurrent `put`s for the
573/// same key cannot race on the temp filename. Different processes pointing
574/// at the same directory rely on the filesystem's atomic rename guarantee.
575pub struct FileStateStore {
576    root: PathBuf,
577    write_lock: Mutex<()>,
578    migrated: tokio::sync::OnceCell<()>,
579    /// Optional at-rest encryption (#207). When set, `put` seals the JSON
580    /// bytes before the atomic write and `get` unseals; plaintext files
581    /// remain readable (and are sealed on their next write).
582    #[cfg(feature = "encryption")]
583    encryption: Option<crate::encryption::CompiledEncryption>,
584}
585
586impl FileStateStore {
587    /// Open or create a file-backed state store rooted at `root`.
588    pub fn new(root: impl Into<PathBuf>) -> Self {
589        Self {
590            root: root.into(),
591            write_lock: Mutex::new(()),
592            migrated: tokio::sync::OnceCell::new(),
593            #[cfg(feature = "encryption")]
594            encryption: None,
595        }
596    }
597
598    /// Seal bookmark files at rest with the given policy (#207). Reads accept
599    /// both sealed and (legacy) plaintext files; every write seals.
600    #[cfg(feature = "encryption")]
601    pub fn with_encryption(mut self, encryption: crate::encryption::CompiledEncryption) -> Self {
602        self.encryption = Some(encryption);
603        self
604    }
605
606    fn entry_path(&self, key: &str) -> PathBuf {
607        self.root.join(format!("{}.json", safe_filename(key)))
608    }
609
610    fn temp_path(&self, key: &str) -> PathBuf {
611        // Unique per write: a per-process random token + a monotonic counter, so
612        // two writers (different processes sharing the directory, or two store
613        // instances in one process) never share a temp file. A *fixed* temp name
614        // let a second writer `File::create`-truncate the first's half-written
615        // temp, which the first could then `rename` over the final path —
616        // yielding torn/truncated state JSON that breaks resume (audit #146 H10).
617        //
618        // The token MUST NOT be derived from the process id: containers on a
619        // shared NFS/EFS/host volume routinely reuse the same PID (each starts at
620        // PID 1), so a PID + per-process counter collides across processes and
621        // reintroduces exactly that torn-bookmark corruption (F50). A random v4
622        // UUID minted once per process is unguessable and collision-free across
623        // processes regardless of PID; the counter keeps writers within one
624        // process distinct. The per-store `write_lock` only serializes writers
625        // within one process. Orphaned `.tmp` files from an interrupted write are
626        // harmless: `get` only ever reads the final `.json` path.
627        use std::sync::OnceLock;
628        use std::sync::atomic::{AtomicU64, Ordering};
629        static PROC_TOKEN: OnceLock<String> = OnceLock::new();
630        static SEQ: AtomicU64 = AtomicU64::new(0);
631        let token = PROC_TOKEN.get_or_init(|| uuid::Uuid::new_v4().simple().to_string());
632        let seq = SEQ.fetch_add(1, Ordering::Relaxed);
633        self.root
634            .join(format!("{}.{}.{}.json.tmp", safe_filename(key), token, seq))
635    }
636
637    async fn ensure_root(&self) -> Result<(), FaucetError> {
638        tokio::fs::create_dir_all(&self.root).await.map_err(|e| {
639            FaucetError::State(format!(
640                "failed to create state dir {}: {e}",
641                self.root.display()
642            ))
643        })
644    }
645
646    async fn read_entry(&self, key: &str, path: &Path) -> Result<Option<Value>, FaucetError> {
647        #[cfg(not(feature = "encryption"))]
648        let _ = key;
649        match tokio::fs::read(path).await {
650            Ok(bytes) => {
651                #[cfg(feature = "encryption")]
652                let bytes: Vec<u8> = if crate::encryption::is_encrypted(&bytes) {
653                    match &self.encryption {
654                        Some(enc) => enc.decrypt_bound(&bytes, key.as_bytes()).map_err(|e| {
655                            // Wrong/rotated key must be a loud, typed error —
656                            // treating it as "no bookmark" would silently
657                            // trigger a full re-sync.
658                            FaucetError::State(format!(
659                                "state file {} could not be decrypted: {e}",
660                                path.display()
661                            ))
662                        })?,
663                        None => {
664                            return Err(FaucetError::State(format!(
665                                "state file {} is encrypted but no `encryption` block is \
666                                 configured on the file state store — add \
667                                 `state.config.encryption` with the original key",
668                                path.display()
669                            )));
670                        }
671                    }
672                } else {
673                    bytes
674                };
675                #[cfg(not(feature = "encryption"))]
676                let bytes = {
677                    // Built without the `encryption` feature: refuse to parse a
678                    // sealed file as JSON garbage.
679                    if bytes.starts_with(b"FCT1") {
680                        return Err(FaucetError::State(format!(
681                            "state file {} is encrypted but this build of faucet has no \
682                             `encryption` feature",
683                            path.display()
684                        )));
685                    }
686                    bytes
687                };
688                let value: Value = serde_json::from_slice(&bytes).map_err(|e| {
689                    FaucetError::State(format!(
690                        "failed to parse state file {}: {e}",
691                        path.display()
692                    ))
693                })?;
694                Ok(Some(value))
695            }
696            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
697            Err(e) => Err(FaucetError::State(format!(
698                "failed to read state file {}: {e}",
699                path.display()
700            ))),
701        }
702    }
703
704    /// Rename every file written under an older naming scheme (uppercase
705    /// letters unescaped, long keys unhashed) to its current name, once per
706    /// store. Done eagerly rather than as a read fallback because on a
707    /// case-insensitive filesystem the current name of `users:x` would open the
708    /// legacy file of `Users:x`.
709    async fn migrate_legacy_names(&self) -> Result<(), FaucetError> {
710        self.migrated
711            .get_or_try_init(|| async {
712                let mut dir = match tokio::fs::read_dir(&self.root).await {
713                    Ok(d) => d,
714                    Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
715                    Err(e) => {
716                        return Err(FaucetError::State(format!(
717                            "failed to list state dir {}: {e}",
718                            self.root.display()
719                        )));
720                    }
721                };
722                let mut renames = Vec::new();
723                while let Some(entry) = dir.next_entry().await.map_err(|e| {
724                    FaucetError::State(format!(
725                        "failed to list state dir {}: {e}",
726                        self.root.display()
727                    ))
728                })? {
729                    let name = entry.file_name();
730                    let Some(stem) = name.to_str().and_then(|n| n.strip_suffix(".json")) else {
731                        continue;
732                    };
733                    if let Some(key) = key_from_filename(stem)
734                        && safe_filename(&key) != stem
735                    {
736                        renames.push((key, entry.path()));
737                    }
738                }
739                for (key, from) in renames {
740                    if let Some(sidecar) = self.key_sidecar(&key) {
741                        tokio::fs::write(&sidecar, key.as_bytes())
742                            .await
743                            .map_err(|e| {
744                                FaucetError::State(format!(
745                                    "failed to write state key file {}: {e}",
746                                    sidecar.display()
747                                ))
748                            })?;
749                    }
750                    let to = self.entry_path(&key);
751                    if tokio::fs::try_exists(&to).await.unwrap_or(false) {
752                        let _ = tokio::fs::remove_file(&from).await;
753                        continue;
754                    }
755                    match tokio::fs::rename(&from, &to).await {
756                        Ok(()) => {}
757                        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
758                        Err(e) => {
759                            return Err(FaucetError::State(format!(
760                                "failed to rename legacy state file {} to {}: {e}",
761                                from.display(),
762                                to.display()
763                            )));
764                        }
765                    }
766                }
767                Ok(())
768            })
769            .await
770            .map(|_| ())
771    }
772
773    fn key_sidecar(&self, key: &str) -> Option<PathBuf> {
774        let stem = safe_filename(key);
775        stem.contains('~')
776            .then(|| self.root.join(format!("{stem}.key")))
777    }
778
779    /// Returns the root directory this store writes into.
780    pub fn root(&self) -> &Path {
781        &self.root
782    }
783}
784
785#[async_trait]
786impl StateStore for FileStateStore {
787    async fn get(&self, key: &str) -> Result<Option<Value>, FaucetError> {
788        validate_state_key(key)?;
789        self.migrate_legacy_names().await?;
790        self.read_entry(key, &self.entry_path(key)).await
791    }
792
793    async fn put(&self, key: &str, value: &Value) -> Result<(), FaucetError> {
794        validate_state_key(key)?;
795        let _guard = self.write_lock.lock().await;
796        self.ensure_root().await?;
797        let bytes = serde_json::to_vec(value).map_err(|e| {
798            FaucetError::State(format!("failed to serialize state for key '{key}': {e}"))
799        })?;
800        #[cfg(feature = "encryption")]
801        let bytes = match &self.encryption {
802            Some(enc) => enc.encrypt_bound(&bytes, key.as_bytes()),
803            None => bytes,
804        };
805        self.migrate_legacy_names().await?;
806        let final_path = self.entry_path(key);
807        let tmp_path = self.temp_path(key);
808
809        if let Some(sidecar) = self.key_sidecar(key) {
810            tokio::fs::write(&sidecar, key.as_bytes())
811                .await
812                .map_err(|e| {
813                    FaucetError::State(format!(
814                        "failed to write state key file {}: {e}",
815                        sidecar.display()
816                    ))
817                })?;
818        }
819
820        // Write the temp file and fsync it BEFORE the rename. `fs::write`
821        // alone leaves the bytes in the page cache: a crash after `put`
822        // returns could surface a zero-length or stale file, breaking the
823        // durability guarantee this store documents (#78/#8). `sync_all`
824        // flushes the file's data and metadata to disk. Any failure removes
825        // the temp file so retries do not accumulate orphans (CORE-96).
826        let written: Result<(), FaucetError> = async {
827            let mut file = tokio::fs::File::create(&tmp_path).await.map_err(|e| {
828                FaucetError::State(format!(
829                    "failed to create temp state file {}: {e}",
830                    tmp_path.display()
831                ))
832            })?;
833            file.write_all(&bytes).await.map_err(|e| {
834                FaucetError::State(format!(
835                    "failed to write temp state file {}: {e}",
836                    tmp_path.display()
837                ))
838            })?;
839            file.sync_all().await.map_err(|e| {
840                FaucetError::State(format!(
841                    "failed to fsync temp state file {}: {e}",
842                    tmp_path.display()
843                ))
844            })?;
845            drop(file);
846            tokio::fs::rename(&tmp_path, &final_path)
847                .await
848                .map_err(|e| {
849                    FaucetError::State(format!(
850                        "failed to commit state file {}: {e}",
851                        final_path.display()
852                    ))
853                })
854        }
855        .await;
856        if let Err(e) = written {
857            let _ = tokio::fs::remove_file(&tmp_path).await;
858            return Err(e);
859        }
860
861        // fsync the parent directory so the rename itself is durable — an
862        // atomic rename can still be lost on crash if the directory entry was
863        // never flushed. Directory fsync is a POSIX concept; on platforms that
864        // don't allow opening a directory as a file (e.g. Windows) it is
865        // skipped.
866        #[cfg(unix)]
867        {
868            let dir = tokio::fs::File::open(&self.root).await.map_err(|e| {
869                FaucetError::State(format!(
870                    "failed to open state dir {} for fsync: {e}",
871                    self.root.display()
872                ))
873            })?;
874            dir.sync_all().await.map_err(|e| {
875                FaucetError::State(format!(
876                    "failed to fsync state dir {}: {e}",
877                    self.root.display()
878                ))
879            })?;
880        }
881
882        tracing::debug!(
883            key,
884            path = %final_path.display(),
885            "state file written"
886        );
887        Ok(())
888    }
889
890    async fn delete(&self, key: &str) -> Result<(), FaucetError> {
891        validate_state_key(key)?;
892        self.migrate_legacy_names().await?;
893        let mut paths = vec![self.entry_path(key)];
894        paths.extend(self.key_sidecar(key));
895        for path in paths {
896            match tokio::fs::remove_file(&path).await {
897                Ok(()) => {}
898                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
899                Err(e) => {
900                    return Err(FaucetError::State(format!(
901                        "failed to delete state file {}: {e}",
902                        path.display()
903                    )));
904                }
905            }
906        }
907        Ok(())
908    }
909
910    fn supports_compare_and_put(&self) -> bool {
911        true
912    }
913
914    /// Atomic across processes sharing the directory: the read-compare-write
915    /// runs while holding `<key>.json.lock`, created with `O_EXCL`. A lock
916    /// left by a crashed writer is broken after 30 seconds.
917    async fn compare_and_put(
918        &self,
919        key: &str,
920        expected: Option<&Value>,
921        value: &Value,
922    ) -> Result<bool, FaucetError> {
923        validate_state_key(key)?;
924        self.ensure_root().await?;
925        let lock = self.root.join(format!("{}.json.lock", safe_filename(key)));
926        let _held = acquire_lock_file(&lock).await?;
927        if self.get(key).await?.as_ref() != expected {
928            return Ok(false);
929        }
930        self.put(key, value).await?;
931        Ok(true)
932    }
933
934    fn supports_list(&self) -> bool {
935        true
936    }
937
938    async fn list(&self, prefix: &str) -> Result<Vec<String>, FaucetError> {
939        self.migrate_legacy_names().await?;
940        let mut dir = match tokio::fs::read_dir(&self.root).await {
941            Ok(d) => d,
942            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
943            Err(e) => {
944                return Err(FaucetError::State(format!(
945                    "failed to list state dir {}: {e}",
946                    self.root.display()
947                )));
948            }
949        };
950        let mut keys = Vec::new();
951        loop {
952            let entry = dir.next_entry().await.map_err(|e| {
953                FaucetError::State(format!(
954                    "failed to list state dir {}: {e}",
955                    self.root.display()
956                ))
957            })?;
958            let Some(entry) = entry else { break };
959            let name = entry.file_name();
960            let Some(stem) = name.to_str().and_then(|n| n.strip_suffix(".json")) else {
961                continue;
962            };
963            let key = if stem.contains('~') {
964                tokio::fs::read_to_string(self.root.join(format!("{stem}.key")))
965                    .await
966                    .ok()
967                    .filter(|k| validate_state_key(k).is_ok())
968            } else {
969                key_from_filename(stem)
970            };
971            if let Some(key) = key
972                && key.starts_with(prefix)
973            {
974                keys.push(key);
975            }
976        }
977        keys.sort();
978        keys.dedup();
979        Ok(keys)
980    }
981
982    async fn check(
983        &self,
984        _ctx: &crate::check::CheckContext,
985    ) -> Result<crate::check::CheckReport, FaucetError> {
986        use crate::check::{CheckReport, Probe};
987        // Exercise the real put → get → delete cycle on a sentinel key. `put`
988        // creates the root dir if needed, so this validates "dir exists +
989        // writable" via the actual code path and leaves no residue.
990        let start = std::time::Instant::now();
991        let probe = match self.sentinel_roundtrip().await {
992            Ok(()) => Probe::pass("sentinel", start.elapsed()),
993            Err(e) => Probe::fail_hint(
994                "sentinel",
995                start.elapsed(),
996                e.to_string(),
997                format!("ensure {} exists and is writable", self.root.display()),
998            ),
999        };
1000        Ok(CheckReport::single(probe))
1001    }
1002}
1003
1004impl FileStateStore {
1005    /// Write, read back, and delete a sentinel key — the body of the `check()`
1006    /// probe, factored out so the happy path stays linear.
1007    async fn sentinel_roundtrip(&self) -> Result<(), FaucetError> {
1008        let probe = serde_json::json!({ "faucet_doctor": true });
1009        let key = doctor_sentinel_key();
1010        self.put(&key, &probe).await?;
1011        let got = self.get(&key).await?;
1012        // Best-effort cleanup regardless of the read result.
1013        let _ = self.delete(&key).await;
1014        match got {
1015            Some(v) if v == probe => Ok(()),
1016            _ => Err(FaucetError::State(
1017                "sentinel readback did not match what was written".into(),
1018            )),
1019        }
1020    }
1021}
1022
1023#[cfg(test)]
1024mod tests {
1025    use super::*;
1026    use serde_json::json;
1027    use std::sync::Arc;
1028    use tempfile::TempDir;
1029
1030    // ── key validation ─────────────────────────────────────────────────────
1031
1032    #[tokio::test]
1033    async fn a_lock_that_cannot_be_created_is_a_state_error() {
1034        let dir = TempDir::new().unwrap();
1035        let path = dir.path().join("missing-dir").join("k.lock");
1036        let err = acquire_lock_file(&path).await.err().unwrap().to_string();
1037        assert!(err.contains("failed to create state lock"), "{err}");
1038    }
1039
1040    #[test]
1041    fn rejects_empty_key() {
1042        let err = validate_state_key("").unwrap_err();
1043        assert!(matches!(err, FaucetError::State(_)));
1044    }
1045
1046    #[test]
1047    fn rejects_path_traversal_segments() {
1048        for k in [
1049            "../etc/passwd",
1050            "a/../b",
1051            "a/./b",
1052            "a//b",
1053            "/a",
1054            "a/",
1055            "a/.x",
1056            "a\\b",
1057            "..",
1058            ".",
1059        ] {
1060            assert!(validate_state_key(k).is_err(), "expected reject for {k:?}");
1061        }
1062        // A `/` that separates a hub owner from a name is fine (#682).
1063        assert!(validate_state_key("acme/erp::invoices").is_ok());
1064    }
1065
1066    #[test]
1067    fn rejects_leading_dot() {
1068        assert!(validate_state_key(".hidden").is_err());
1069    }
1070
1071    #[test]
1072    fn rejects_over_long_key() {
1073        let k = "a".repeat(257);
1074        assert!(validate_state_key(&k).is_err());
1075    }
1076
1077    #[test]
1078    fn accepts_typical_keys() {
1079        for k in [
1080            "github_issues",
1081            "pipeline:rest:issues",
1082            "with.dot",
1083            "with-dash_and_underscore",
1084            "lower-Case_99",
1085        ] {
1086            validate_state_key(k).unwrap_or_else(|e| panic!("expected ok for {k:?}: {e}"));
1087        }
1088    }
1089
1090    // ── MemoryStateStore ────────────────────────────────────────────────────
1091
1092    #[tokio::test]
1093    async fn compare_and_put_writes_only_over_the_expected_value() {
1094        let mem = MemoryStateStore::new();
1095        assert!(mem.supports_compare_and_put());
1096        let a = serde_json::json!({"v": 1});
1097        let b = serde_json::json!({"v": 2});
1098        assert!(mem.compare_and_put("k", None, &a).await.unwrap());
1099        assert!(!mem.compare_and_put("k", None, &b).await.unwrap());
1100        assert!(!mem.compare_and_put("k", Some(&b), &b).await.unwrap());
1101        assert!(mem.compare_and_put("k", Some(&a), &b).await.unwrap());
1102        assert_eq!(mem.get("k").await.unwrap(), Some(b.clone()));
1103        assert!(mem.compare_and_put("bad key!", None, &a).await.is_err());
1104
1105        let dir = tempfile::tempdir().unwrap();
1106        let file = FileStateStore::new(dir.path());
1107        assert!(file.supports_compare_and_put());
1108        assert!(file.compare_and_put("k", None, &a).await.unwrap());
1109        assert!(!file.compare_and_put("k", Some(&b), &b).await.unwrap());
1110        assert!(file.compare_and_put("k", Some(&a), &b).await.unwrap());
1111        assert_eq!(file.get("k").await.unwrap(), Some(b));
1112    }
1113
1114    #[tokio::test]
1115    async fn file_compare_and_put_is_exclusive_and_breaks_stale_locks() {
1116        let dir = tempfile::tempdir().unwrap();
1117        let stores: Vec<std::sync::Arc<FileStateStore>> = (0..8)
1118            .map(|_| std::sync::Arc::new(FileStateStore::new(dir.path())))
1119            .collect();
1120        let tasks: Vec<_> = stores
1121            .iter()
1122            .enumerate()
1123            .map(|(i, s)| {
1124                let s = std::sync::Arc::clone(s);
1125                tokio::spawn(async move {
1126                    s.compare_and_put("lease", None, &json!({"by": i}))
1127                        .await
1128                        .unwrap()
1129                })
1130            })
1131            .collect();
1132        let mut wins = 0;
1133        for t in tasks {
1134            wins += usize::from(t.await.unwrap());
1135        }
1136        assert_eq!(wins, 1, "exactly one writer takes an absent key");
1137        let lock = dir.path().join("lease.json.lock");
1138        assert!(!lock.exists(), "the lock is released");
1139
1140        let f = std::fs::File::create(&lock).unwrap();
1141        f.set_modified(std::time::SystemTime::now() - STALE_LOCK * 2)
1142            .unwrap();
1143        let held = stores[0].get("lease").await.unwrap();
1144        assert!(
1145            stores[0]
1146                .compare_and_put("lease", held.as_ref(), &json!({"by": "late"}))
1147                .await
1148                .unwrap()
1149        );
1150        assert_eq!(stores[0].list("").await.unwrap(), vec!["lease".to_string()]);
1151    }
1152
1153    #[tokio::test(start_paused = true)]
1154    async fn file_compare_and_put_gives_up_on_a_live_lock() {
1155        let dir = tempfile::tempdir().unwrap();
1156        let store = FileStateStore::new(dir.path());
1157        std::fs::write(dir.path().join("k.json.lock"), b"").unwrap();
1158        let err = store
1159            .compare_and_put("k", None, &json!(1))
1160            .await
1161            .unwrap_err()
1162            .to_string();
1163        assert!(err.contains("held by another writer"), "{err}");
1164    }
1165
1166    #[tokio::test]
1167    async fn memory_get_returns_none_for_missing_key() {
1168        let s = MemoryStateStore::new();
1169        assert!(s.get("nope").await.unwrap().is_none());
1170    }
1171
1172    #[tokio::test]
1173    async fn memory_put_then_get_round_trips() {
1174        let s = MemoryStateStore::new();
1175        s.put("k", &json!({"cursor": "abc", "n": 7})).await.unwrap();
1176        let got = s.get("k").await.unwrap().unwrap();
1177        assert_eq!(got["cursor"], "abc");
1178        assert_eq!(got["n"], 7);
1179    }
1180
1181    #[tokio::test]
1182    async fn memory_put_overwrites_previous_value() {
1183        let s = MemoryStateStore::new();
1184        s.put("k", &json!(1)).await.unwrap();
1185        s.put("k", &json!(2)).await.unwrap();
1186        assert_eq!(s.get("k").await.unwrap().unwrap(), json!(2));
1187    }
1188
1189    #[tokio::test]
1190    async fn memory_delete_makes_get_return_none() {
1191        let s = MemoryStateStore::new();
1192        s.put("k", &json!("v")).await.unwrap();
1193        s.delete("k").await.unwrap();
1194        assert!(s.get("k").await.unwrap().is_none());
1195    }
1196
1197    #[tokio::test]
1198    async fn memory_delete_missing_key_is_ok() {
1199        let s = MemoryStateStore::new();
1200        s.delete("absent").await.unwrap();
1201    }
1202
1203    #[tokio::test]
1204    async fn memory_rejects_invalid_keys() {
1205        let s = MemoryStateStore::new();
1206        assert!(s.get("a b").await.is_err());
1207        assert!(s.put("a b", &json!(1)).await.is_err());
1208        assert!(s.delete("a b").await.is_err());
1209    }
1210
1211    // ── FileStateStore ──────────────────────────────────────────────────────
1212
1213    #[tokio::test]
1214    async fn file_get_returns_none_for_missing_key() {
1215        let dir = TempDir::new().unwrap();
1216        let s = FileStateStore::new(dir.path());
1217        assert!(s.get("nope").await.unwrap().is_none());
1218    }
1219
1220    #[tokio::test]
1221    async fn file_put_creates_root_directory_lazily() {
1222        let dir = TempDir::new().unwrap();
1223        let root = dir.path().join("nested/state");
1224        let s = FileStateStore::new(&root);
1225        s.put("k", &json!("v")).await.unwrap();
1226        assert!(root.is_dir(), "root dir should be created on first put");
1227    }
1228
1229    #[tokio::test]
1230    async fn file_put_then_get_round_trips() {
1231        let dir = TempDir::new().unwrap();
1232        let s = FileStateStore::new(dir.path());
1233        let value = json!({"cursor": "abc", "n": 42, "nested": {"flag": true}});
1234        s.put("github_issues", &value).await.unwrap();
1235        let got = s.get("github_issues").await.unwrap().unwrap();
1236        assert_eq!(got, value);
1237    }
1238
1239    #[test]
1240    fn temp_path_is_unique_and_not_pid_derived() {
1241        // Regression for F50: the temp filename must not embed the process id.
1242        // Containers on a shared volume reuse PIDs (each starts at PID 1), so a
1243        // PID + per-process counter collides across processes and reintroduces
1244        // the torn-bookmark corruption the unique-temp scheme prevents. The
1245        // token is a per-process random UUID instead.
1246        let dir = TempDir::new().unwrap();
1247        let s = FileStateStore::new(dir.path());
1248
1249        let a = s.temp_path("k");
1250        let b = s.temp_path("k");
1251        // Distinct temp paths within one process (the monotonic counter differs).
1252        assert_ne!(a, b);
1253
1254        let name_a = a.file_name().unwrap().to_str().unwrap();
1255        let pid = std::process::id().to_string();
1256        // The PID must NOT appear as a dotted segment of the temp name.
1257        assert!(
1258            !name_a.split('.').any(|seg| seg == pid),
1259            "temp filename {name_a} must not embed the process id ({pid})"
1260        );
1261        assert!(name_a.ends_with(".json.tmp"));
1262    }
1263
1264    #[test]
1265    fn safe_filename_percent_encodes_colon() {
1266        assert_eq!(
1267            safe_filename("pipeline:rest:issues"),
1268            "pipeline%3Arest%3Aissues"
1269        );
1270        assert_eq!(safe_filename("plain_key-1.v2"), "plain_key-1.v2");
1271        // Owner-scoped hub ids (`acme/erp::invoices`) must not become
1272        // nested directories.
1273        assert_eq!(
1274            safe_filename("acme/erp::invoices"),
1275            "acme%2Ferp%3A%3Ainvoices"
1276        );
1277        assert!(validate_state_key("acme/erp::invoices").is_ok());
1278        assert!(validate_state_key("a b").is_err());
1279    }
1280
1281    #[tokio::test]
1282    async fn file_round_trips_colon_keys_with_safe_filename() {
1283        // Regression for #78 LOW: the documented `pipeline:rest:issues` key
1284        // convention must round-trip and produce a Windows-legal filename
1285        // (no `:` on disk).
1286        let dir = TempDir::new().unwrap();
1287        let s = FileStateStore::new(dir.path());
1288        let value = json!({"cursor": "z"});
1289        s.put("pipeline:rest:issues", &value).await.unwrap();
1290        assert_eq!(s.get("pipeline:rest:issues").await.unwrap().unwrap(), value);
1291        // On-disk filename must not contain a colon.
1292        assert!(dir.path().join("pipeline%3Arest%3Aissues.json").exists());
1293        let mut has_colon = false;
1294        for entry in std::fs::read_dir(dir.path()).unwrap() {
1295            if entry.unwrap().file_name().to_string_lossy().contains(':') {
1296                has_colon = true;
1297            }
1298        }
1299        assert!(!has_colon, "no state filename may contain ':'");
1300    }
1301
1302    #[test]
1303    fn safe_filename_escapes_uppercase_and_round_trips() {
1304        assert_eq!(safe_filename("Users::x"), "%55sers%3A%3Ax");
1305        assert_ne!(
1306            safe_filename("Users::x").to_lowercase(),
1307            safe_filename("users::x").to_lowercase()
1308        );
1309        for k in ["Users::x", "a/B:c", "plain"] {
1310            assert_eq!(key_from_filename(&safe_filename(k)).as_deref(), Some(k));
1311        }
1312        // Legacy (unescaped) stems still decode.
1313        assert_eq!(key_from_filename("Users%3Ax").as_deref(), Some("Users:x"));
1314        assert_eq!(key_from_filename("bad%Z"), None);
1315        assert_eq!(key_from_filename("a~b"), None);
1316    }
1317
1318    #[test]
1319    fn long_keys_hash_into_a_bounded_stem() {
1320        let key = format!("p::{}", "A:".repeat(120));
1321        let stem = safe_filename(&key);
1322        assert!(stem.len() <= MAX_STEM, "{}", stem.len());
1323        assert!(stem.contains('~'));
1324        let other = format!("p::{}B", "A:".repeat(119));
1325        assert_ne!(safe_filename(&other), stem);
1326    }
1327
1328    #[tokio::test]
1329    async fn keys_differing_only_in_case_keep_separate_files() {
1330        let dir = TempDir::new().unwrap();
1331        let s = FileStateStore::new(dir.path());
1332        s.put("Users::x", &json!(1)).await.unwrap();
1333        s.put("users::x", &json!(2)).await.unwrap();
1334        assert_eq!(s.get("Users::x").await.unwrap(), Some(json!(1)));
1335        assert_eq!(s.get("users::x").await.unwrap(), Some(json!(2)));
1336        assert_eq!(s.list("").await.unwrap(), vec!["Users::x", "users::x"]);
1337    }
1338
1339    #[tokio::test]
1340    async fn a_long_key_round_trips_lists_and_deletes() {
1341        let dir = TempDir::new().unwrap();
1342        let s = FileStateStore::new(dir.path());
1343        let key = format!("pipe::row::{}", "parent:".repeat(34));
1344        assert!(key.len() <= 256);
1345        s.put(&key, &json!({"c": 1})).await.unwrap();
1346        assert_eq!(s.get(&key).await.unwrap(), Some(json!({"c": 1})));
1347        assert_eq!(s.list("pipe").await.unwrap(), vec![key.clone()]);
1348        s.delete(&key).await.unwrap();
1349        assert_eq!(s.get(&key).await.unwrap(), None);
1350        assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0);
1351    }
1352
1353    #[tokio::test]
1354    async fn legacy_named_files_are_read_and_migrated() {
1355        let dir = TempDir::new().unwrap();
1356        std::fs::write(dir.path().join("Users%3Ax.json"), b"{\"c\":7}").unwrap();
1357        let s = FileStateStore::new(dir.path());
1358        // A lowercase key must not read the uppercase key's legacy file.
1359        assert_eq!(s.get("users:x").await.unwrap(), None);
1360        assert_eq!(s.get("Users:x").await.unwrap(), Some(json!({"c": 7})));
1361        s.put("Users:x", &json!({"c": 8})).await.unwrap();
1362        let names: Vec<String> = std::fs::read_dir(dir.path())
1363            .unwrap()
1364            .map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
1365            .collect();
1366        assert_eq!(names, vec!["%55sers%3Ax.json".to_string()]);
1367        assert_eq!(s.get("Users:x").await.unwrap(), Some(json!({"c": 8})));
1368        s.delete("Users:x").await.unwrap();
1369        assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0);
1370
1371        // A long legacy name is migrated to its hashed name.
1372        let dir = TempDir::new().unwrap();
1373        let key = format!("p::{}", "q".repeat(170));
1374        std::fs::write(
1375            dir.path().join(format!("p%3A%3A{}.json", "q".repeat(170))),
1376            b"3",
1377        )
1378        .unwrap();
1379        let s = FileStateStore::new(dir.path());
1380        assert_eq!(s.list("p").await.unwrap(), vec![key.clone()]);
1381        assert_eq!(s.get(&key).await.unwrap(), Some(json!(3)));
1382    }
1383
1384    #[tokio::test]
1385    async fn a_failed_put_leaves_no_temp_file() {
1386        let dir = TempDir::new().unwrap();
1387        let s = FileStateStore::new(dir.path());
1388        // A directory where the final file should be makes the rename fail.
1389        std::fs::create_dir(dir.path().join("k.json")).unwrap();
1390        std::fs::write(dir.path().join("k.json").join("x"), b"x").unwrap();
1391        assert!(s.put("k", &json!(1)).await.is_err());
1392        assert!(!has_tmp_residue(dir.path()));
1393    }
1394
1395    #[test]
1396    fn doctor_sentinel_keys_are_unique_and_valid() {
1397        let a = doctor_sentinel_key();
1398        assert_ne!(a, doctor_sentinel_key());
1399        assert!(a.starts_with(DOCTOR_SENTINEL_KEY));
1400        validate_state_key(&a).unwrap();
1401    }
1402
1403    /// True if any `.json.tmp` residue remains in `dir`. Temp names are unique
1404    /// per write since #146 H10, so we glob for the suffix rather than check a
1405    /// fixed name (which would never exist now and pass vacuously).
1406    fn has_tmp_residue(dir: &std::path::Path) -> bool {
1407        std::fs::read_dir(dir)
1408            .unwrap()
1409            .filter_map(|e| e.ok())
1410            .any(|e| e.file_name().to_string_lossy().ends_with(".json.tmp"))
1411    }
1412
1413    #[tokio::test]
1414    async fn file_put_overwrites_previous_value_atomically() {
1415        let dir = TempDir::new().unwrap();
1416        let s = FileStateStore::new(dir.path());
1417        s.put("k", &json!({"v": 1})).await.unwrap();
1418        s.put("k", &json!({"v": 2})).await.unwrap();
1419        assert_eq!(s.get("k").await.unwrap().unwrap(), json!({"v": 2}));
1420        // No temp file left behind.
1421        assert!(!has_tmp_residue(dir.path()), "no temp residue after put");
1422    }
1423
1424    #[test]
1425    fn file_temp_paths_are_unique_per_write() {
1426        // H10 (audit #146): the temp path must be unique per write so two
1427        // writers (different processes, or two store instances in one process
1428        // with independent write_locks) never `File::create`-truncate a shared
1429        // temp that the other then renames over the final file (torn state).
1430        let dir = TempDir::new().unwrap();
1431        let s = FileStateStore::new(dir.path());
1432        let a = s.temp_path("k");
1433        let b = s.temp_path("k");
1434        assert_ne!(a, b, "each write must get a distinct temp path");
1435        // The committed (final) path stays stable across writes.
1436        assert_eq!(s.entry_path("k"), s.entry_path("k"));
1437    }
1438
1439    #[tokio::test]
1440    async fn file_put_writes_complete_durable_file_with_no_temp_residue() {
1441        // Regression for #78/#8. `put` must produce a fully-written, parseable
1442        // file and leave no temp file behind. (The fsync that makes this
1443        // durable across a power loss can't be observed on a healthy
1444        // filesystem, but a regression in the write/rename path — truncation,
1445        // a leftover .tmp, or an unwritten file — is caught here.) A large
1446        // payload makes a partial/unflushed write detectable on read-back.
1447        let dir = TempDir::new().unwrap();
1448        let s = FileStateStore::new(dir.path());
1449        let big: Vec<Value> = (0..1_000)
1450            .map(|i| json!({"i": i, "s": "x".repeat(20)}))
1451            .collect();
1452        let value = json!({"cursor": "abc", "rows": big});
1453
1454        s.put("github_issues", &value).await.unwrap();
1455
1456        // Read the raw file directly (bypassing get) to confirm it is complete.
1457        let raw = tokio::fs::read(dir.path().join("github_issues.json"))
1458            .await
1459            .expect("state file must exist after put");
1460        assert!(!raw.is_empty(), "state file must not be zero-length");
1461        let parsed: Value = serde_json::from_slice(&raw).expect("state file must be valid JSON");
1462        assert_eq!(parsed, value);
1463
1464        // No temp file left behind.
1465        assert!(!has_tmp_residue(dir.path()), "no temp residue after put");
1466    }
1467
1468    #[tokio::test]
1469    async fn file_delete_removes_file() {
1470        let dir = TempDir::new().unwrap();
1471        let s = FileStateStore::new(dir.path());
1472        s.put("k", &json!("v")).await.unwrap();
1473        s.delete("k").await.unwrap();
1474        assert!(s.get("k").await.unwrap().is_none());
1475        assert!(!dir.path().join("k.json").exists());
1476    }
1477
1478    #[tokio::test]
1479    async fn file_delete_missing_key_is_ok() {
1480        let dir = TempDir::new().unwrap();
1481        let s = FileStateStore::new(dir.path());
1482        s.delete("absent").await.unwrap();
1483    }
1484
1485    #[tokio::test]
1486    async fn file_get_returns_error_for_corrupt_json() {
1487        let dir = TempDir::new().unwrap();
1488        let s = FileStateStore::new(dir.path());
1489        tokio::fs::create_dir_all(dir.path()).await.unwrap();
1490        tokio::fs::write(dir.path().join("bad.json"), b"not json")
1491            .await
1492            .unwrap();
1493        let err = s.get("bad").await.unwrap_err();
1494        match err {
1495            FaucetError::State(msg) => assert!(msg.contains("bad.json")),
1496            other => panic!("expected State error, got {other:?}"),
1497        }
1498    }
1499
1500    #[tokio::test]
1501    async fn file_concurrent_puts_do_not_corrupt_or_leak_temp() {
1502        let dir = TempDir::new().unwrap();
1503        let s = Arc::new(FileStateStore::new(dir.path()));
1504        let mut handles = vec![];
1505        for i in 0..50 {
1506            let s = Arc::clone(&s);
1507            handles.push(tokio::spawn(async move {
1508                s.put("k", &json!({"i": i})).await.unwrap();
1509            }));
1510        }
1511        for h in handles {
1512            h.await.unwrap();
1513        }
1514        // The final value must be one of the 50 we wrote.
1515        let got = s.get("k").await.unwrap().unwrap();
1516        let i = got["i"].as_i64().unwrap();
1517        assert!((0..50).contains(&i));
1518        // And no temp file should remain.
1519        assert!(
1520            !has_tmp_residue(dir.path()),
1521            "no temp residue after concurrent puts"
1522        );
1523    }
1524
1525    #[tokio::test]
1526    async fn file_store_works_through_trait_object() {
1527        let dir = TempDir::new().unwrap();
1528        let s: Box<dyn StateStore> = Box::new(FileStateStore::new(dir.path()));
1529        s.put("k", &json!(1)).await.unwrap();
1530        assert_eq!(s.get("k").await.unwrap().unwrap(), json!(1));
1531    }
1532
1533    // ── check() ──────────────────────────────────────────────────────────────
1534
1535    #[tokio::test]
1536    async fn memory_check_passes() {
1537        let s = MemoryStateStore::new();
1538        let report = s
1539            .check(&crate::check::CheckContext::default())
1540            .await
1541            .unwrap();
1542        assert_eq!(report.failed_count(), 0);
1543        assert!(
1544            report
1545                .probes
1546                .iter()
1547                .all(|p| matches!(p.status, crate::check::ProbeStatus::Pass))
1548        );
1549    }
1550
1551    #[tokio::test]
1552    async fn file_check_passes_for_writable_root() {
1553        let dir = TempDir::new().unwrap();
1554        let s = FileStateStore::new(dir.path());
1555        let report = s
1556            .check(&crate::check::CheckContext::default())
1557            .await
1558            .unwrap();
1559        assert_eq!(report.failed_count(), 0, "writable root should pass");
1560        // The sentinel probe must leave no residue.
1561        let leftovers: Vec<_> = std::fs::read_dir(dir.path()).unwrap().collect();
1562        assert!(leftovers.is_empty(), "check() must not leave files behind");
1563    }
1564
1565    #[tokio::test]
1566    async fn file_store_root_returns_configured_directory() {
1567        let dir = TempDir::new().unwrap();
1568        let s = FileStateStore::new(dir.path());
1569        assert_eq!(s.root(), dir.path());
1570    }
1571
1572    #[tokio::test]
1573    async fn default_check_reports_not_implemented() {
1574        // A StateStore that does not override `check()` falls back to the
1575        // trait default, which yields a single not-implemented (skipped) probe.
1576        struct BareStore;
1577        #[async_trait]
1578        impl StateStore for BareStore {
1579            async fn get(&self, _key: &str) -> Result<Option<Value>, FaucetError> {
1580                Ok(None)
1581            }
1582            async fn put(&self, _key: &str, _value: &Value) -> Result<(), FaucetError> {
1583                Ok(())
1584            }
1585            async fn delete(&self, _key: &str) -> Result<(), FaucetError> {
1586                Ok(())
1587            }
1588        }
1589        let s = BareStore;
1590        let report = s
1591            .check(&crate::check::CheckContext::default())
1592            .await
1593            .unwrap();
1594        // not_implemented() reports no failures and is not a pass.
1595        assert_eq!(report.failed_count(), 0);
1596        assert!(
1597            report
1598                .probes
1599                .iter()
1600                .any(|p| matches!(p.status, crate::check::ProbeStatus::Skip { .. })),
1601            "default check must surface a skipped (not-implemented) probe"
1602        );
1603    }
1604
1605    #[tokio::test]
1606    async fn file_check_fails_when_root_unusable() {
1607        // Root whose parent is a regular file → create_dir_all fails.
1608        let dir = TempDir::new().unwrap();
1609        let file = dir.path().join("not_a_dir");
1610        std::fs::write(&file, b"x").unwrap();
1611        let s = FileStateStore::new(file.join("state"));
1612        let report = s
1613            .check(&crate::check::CheckContext::default())
1614            .await
1615            .unwrap();
1616        assert_eq!(report.failed_count(), 1, "unusable root should fail");
1617    }
1618
1619    #[cfg(feature = "encryption")]
1620    mod encryption_at_rest {
1621        use super::*;
1622        use crate::encryption::{CompiledEncryption, EncryptionSpec, is_encrypted};
1623        use serde_json::json;
1624
1625        fn enc(key: &str) -> CompiledEncryption {
1626            CompiledEncryption::compile(&EncryptionSpec {
1627                key: key.into(),
1628                previous_keys: vec![],
1629                algorithm: Default::default(),
1630            })
1631            .unwrap()
1632        }
1633
1634        #[tokio::test]
1635        async fn encrypted_round_trip_and_ciphertext_on_disk() {
1636            let dir = tempfile::tempdir().unwrap();
1637            let store = FileStateStore::new(dir.path()).with_encryption(enc("k1"));
1638            store.put("bk", &json!({"lsn": 42})).await.unwrap();
1639            assert_eq!(store.get("bk").await.unwrap(), Some(json!({"lsn": 42})));
1640
1641            // On disk it is ciphertext, not JSON.
1642            let raw = std::fs::read(dir.path().join("bk.json")).unwrap();
1643            assert!(is_encrypted(&raw));
1644            assert!(serde_json::from_slice::<Value>(&raw).is_err());
1645
1646            store.delete("bk").await.unwrap();
1647            assert_eq!(store.get("bk").await.unwrap(), None);
1648        }
1649
1650        #[tokio::test]
1651        async fn plaintext_file_stays_readable_and_is_sealed_on_next_write() {
1652            let dir = tempfile::tempdir().unwrap();
1653            // A pre-encryption bookmark written by an older config.
1654            let plain = FileStateStore::new(dir.path());
1655            plain.put("bk", &json!("legacy")).await.unwrap();
1656            let before = std::fs::read(dir.path().join("bk.json")).unwrap();
1657            assert!(!is_encrypted(&before));
1658
1659            let sealed = FileStateStore::new(dir.path()).with_encryption(enc("k1"));
1660            assert_eq!(sealed.get("bk").await.unwrap(), Some(json!("legacy")));
1661            sealed.put("bk", &json!("updated")).await.unwrap();
1662            let after = std::fs::read(dir.path().join("bk.json")).unwrap();
1663            assert!(is_encrypted(&after), "next write must seal the file");
1664            assert_eq!(sealed.get("bk").await.unwrap(), Some(json!("updated")));
1665        }
1666
1667        #[tokio::test]
1668        async fn wrong_key_is_a_typed_error_not_a_missing_bookmark() {
1669            let dir = tempfile::tempdir().unwrap();
1670            let a = FileStateStore::new(dir.path()).with_encryption(enc("right"));
1671            a.put("bk", &json!(1)).await.unwrap();
1672
1673            let b = FileStateStore::new(dir.path()).with_encryption(enc("wrong"));
1674            let err = b.get("bk").await.unwrap_err();
1675            assert!(matches!(err, FaucetError::State(_)));
1676            assert!(err.to_string().contains("could not be decrypted"), "{err}");
1677        }
1678
1679        #[tokio::test]
1680        async fn encrypted_file_with_unconfigured_store_is_a_typed_error() {
1681            let dir = tempfile::tempdir().unwrap();
1682            let sealed = FileStateStore::new(dir.path()).with_encryption(enc("k1"));
1683            sealed.put("bk", &json!(1)).await.unwrap();
1684
1685            let plain = FileStateStore::new(dir.path());
1686            let err = plain.get("bk").await.unwrap_err();
1687            assert!(err.to_string().contains("no `encryption` block"), "{err}");
1688        }
1689
1690        #[tokio::test]
1691        async fn rotation_reads_old_key_files() {
1692            let dir = tempfile::tempdir().unwrap();
1693            let old = FileStateStore::new(dir.path()).with_encryption(enc("old"));
1694            old.put("bk", &json!("v1")).await.unwrap();
1695
1696            let rotated = FileStateStore::new(dir.path()).with_encryption(
1697                CompiledEncryption::compile(&EncryptionSpec {
1698                    key: "new".into(),
1699                    previous_keys: vec!["old".into()],
1700                    algorithm: Default::default(),
1701                })
1702                .unwrap(),
1703            );
1704            assert_eq!(rotated.get("bk").await.unwrap(), Some(json!("v1")));
1705            // Re-seal under the new key; a store knowing only "new" can read it.
1706            rotated.put("bk", &json!("v2")).await.unwrap();
1707            let new_only = FileStateStore::new(dir.path()).with_encryption(enc("new"));
1708            assert_eq!(new_only.get("bk").await.unwrap(), Some(json!("v2")));
1709        }
1710
1711        #[tokio::test]
1712        async fn no_temp_files_left_behind() {
1713            let dir = tempfile::tempdir().unwrap();
1714            let store = FileStateStore::new(dir.path()).with_encryption(enc("k1"));
1715            store.put("bk", &json!(1)).await.unwrap();
1716            let leftovers: Vec<_> = std::fs::read_dir(dir.path())
1717                .unwrap()
1718                .filter_map(Result::ok)
1719                .filter(|e| e.path().to_string_lossy().ends_with(".tmp"))
1720                .collect();
1721            assert!(
1722                leftovers.is_empty(),
1723                "atomic write must leave no temp files"
1724            );
1725        }
1726    }
1727}
1728
1729#[cfg(test)]
1730mod list_export_tests {
1731    use super::*;
1732    use serde_json::json;
1733
1734    struct Bare(MemoryStateStore);
1735
1736    #[async_trait]
1737    impl StateStore for Bare {
1738        async fn get(&self, key: &str) -> Result<Option<Value>, FaucetError> {
1739            self.0.get(key).await
1740        }
1741        async fn put(&self, key: &str, value: &Value) -> Result<(), FaucetError> {
1742            if key.ends_with("::boom") {
1743                return Err(FaucetError::State("disk full".into()));
1744            }
1745            self.0.put(key, value).await
1746        }
1747        async fn delete(&self, key: &str) -> Result<(), FaucetError> {
1748            if key.ends_with("::stuck") {
1749                return Err(FaucetError::State("locked".into()));
1750            }
1751            self.0.delete(key).await
1752        }
1753    }
1754
1755    #[tokio::test]
1756    async fn defaults_cannot_list_and_batch_sequentially() {
1757        let s = Bare(MemoryStateStore::new());
1758        assert!(!s.supports_list());
1759        assert!(!s.supports_atomic_batch());
1760        assert!(s.list("p::").await.is_err());
1761        s.put_batch(&[("p::a".into(), json!(1)), ("p::b".into(), json!(2))])
1762            .await
1763            .unwrap();
1764        assert_eq!(s.get("p::b").await.unwrap(), Some(json!(2)));
1765        let err = s
1766            .put_batch(&[("p::c".into(), json!(1)), ("p::boom".into(), json!(2))])
1767            .await
1768            .unwrap_err();
1769        assert!(err.to_string().contains("disk full"));
1770        assert_eq!(
1771            s.get("p::c").await.unwrap(),
1772            Some(json!(1)),
1773            "prefix landed"
1774        );
1775    }
1776
1777    #[tokio::test]
1778    async fn memory_lists_by_prefix_sorted_and_batches_atomically() {
1779        let s = MemoryStateStore::new();
1780        for k in ["p::b", "p::a", "q::a", "p::a::__sla__"] {
1781            s.put(k, &json!(k)).await.unwrap();
1782        }
1783        assert!(s.supports_list());
1784        assert_eq!(
1785            s.list("p::").await.unwrap(),
1786            vec!["p::a", "p::a::__sla__", "p::b"]
1787        );
1788        assert!(s.supports_atomic_batch());
1789        let err = s
1790            .put_batch(&[("p::x".into(), json!(1)), ("../bad".into(), json!(2))])
1791            .await
1792            .unwrap_err();
1793        assert!(matches!(err, FaucetError::State(_)));
1794        assert!(s.get("p::x").await.unwrap().is_none(), "nothing written");
1795        s.put_batch(&[("p::x".into(), json!(1))]).await.unwrap();
1796        assert_eq!(s.get("p::x").await.unwrap(), Some(json!(1)));
1797    }
1798
1799    #[tokio::test]
1800    async fn file_lists_decoded_keys_ignoring_temp_and_foreign_files() {
1801        let dir = tempfile::tempdir().unwrap();
1802        let s = FileStateStore::new(dir.path());
1803        assert!(s.list("p::").await.unwrap().is_empty(), "missing root");
1804        for k in ["p::a", "p::a::__sla__", "acme/p::x", "q::a"] {
1805            s.put(k, &json!(1)).await.unwrap();
1806        }
1807        std::fs::write(dir.path().join("p%3A%3Az.json.tmp"), b"{}").unwrap();
1808        std::fs::write(dir.path().join("notes.txt"), b"x").unwrap();
1809        std::fs::write(dir.path().join(".hidden.json"), b"{}").unwrap();
1810        assert!(s.supports_list());
1811        assert_eq!(s.list("p::").await.unwrap(), vec!["p::a", "p::a::__sla__"]);
1812        assert_eq!(s.list("acme/").await.unwrap(), vec!["acme/p::x"]);
1813        assert!(!s.supports_atomic_batch());
1814    }
1815
1816    #[cfg(unix)]
1817    #[tokio::test]
1818    async fn file_list_reports_unreadable_root() {
1819        let dir = tempfile::tempdir().unwrap();
1820        let file = dir.path().join("not-a-dir");
1821        std::fs::write(&file, b"x").unwrap();
1822        let s = FileStateStore::new(&file);
1823        let err = s.list("p::").await.unwrap_err();
1824        assert!(
1825            err.to_string().contains("failed to list state dir"),
1826            "{err}"
1827        );
1828    }
1829
1830    #[test]
1831    fn export_document_parses_and_validates() {
1832        let ok = json!({
1833            "version": 1,
1834            "pipeline": "orders",
1835            "exported_at": "2026-09-26T00:00:00Z",
1836            "keys": {"orders::a": {"id": 3}, "orders::a::__sla__": {}}
1837        });
1838        let e = StateExport::from_value(ok).unwrap();
1839        assert_eq!(e.keys.len(), 2);
1840        assert_eq!(namespace_prefix("orders"), "orders::");
1841
1842        let cases = [
1843            (json!({"pipeline": "o", "keys": {}}), "no `version`"),
1844            (
1845                json!({"version": "1", "pipeline": "o"}),
1846                "must be an integer",
1847            ),
1848            (json!({"version": 2, "pipeline": "o"}), "not supported"),
1849            (json!({"version": 0, "pipeline": "o"}), "not supported"),
1850            (json!({"version": 1}), "malformed"),
1851            (json!({"version": 1, "pipeline": "../o"}), "pipeline name"),
1852            (
1853                json!({"version": 1, "pipeline": "o", "keys": {"other::a": 1}}),
1854                "outside the namespace",
1855            ),
1856            (
1857                json!({"version": 1, "pipeline": "o", "keys": {"o::a b": 1}}),
1858                "illegal character",
1859            ),
1860        ];
1861        for (doc, needle) in cases {
1862            let err = StateExport::from_value(doc.clone())
1863                .unwrap_err()
1864                .to_string();
1865            assert!(err.contains(needle), "{doc} → {err}");
1866        }
1867    }
1868
1869    #[tokio::test]
1870    async fn export_then_import_round_trips_and_replace_drops_stale_keys() {
1871        let from = MemoryStateStore::new();
1872        from.put("o::a", &json!({"c": 1})).await.unwrap();
1873        from.put("o::b", &json!(2)).await.unwrap();
1874        from.put("other::a", &json!(9)).await.unwrap();
1875        let export = export_namespace(&from, "o").await.unwrap();
1876        assert_eq!(export.keys.len(), 2);
1877
1878        let to = MemoryStateStore::new();
1879        to.put("o::stale", &json!(0)).await.unwrap();
1880        let r = import_namespace(&to, &export, false).await.unwrap();
1881        assert!(r.atomic && r.error.is_none());
1882        assert_eq!(r.written, vec!["o::a", "o::b"]);
1883        assert!(
1884            to.get("o::stale").await.unwrap().is_some(),
1885            "kept without replace"
1886        );
1887
1888        let r = import_namespace(&to, &export, true).await.unwrap();
1889        assert_eq!(r.deleted, vec!["o::stale"]);
1890        assert!(to.get("o::stale").await.unwrap().is_none());
1891        assert_eq!(to.get("o::a").await.unwrap(), Some(json!({"c": 1})));
1892    }
1893
1894    #[tokio::test]
1895    async fn import_reports_partial_progress_on_a_non_atomic_store() {
1896        let mut export = StateExport::new("o");
1897        export.keys.insert("o::a".into(), json!(1));
1898        export.keys.insert("o::boom".into(), json!(2));
1899        let s = Bare(MemoryStateStore::new());
1900        let r = import_namespace(&s, &export, false).await.unwrap();
1901        assert!(!r.atomic);
1902        assert_eq!(r.written, vec!["o::a"]);
1903        assert!(r.error.unwrap().contains("o::boom"));
1904
1905        let mut bad = StateExport::new("o");
1906        bad.keys.insert("x::a".into(), json!(1));
1907        assert!(import_namespace(&s, &bad, false).await.is_err());
1908    }
1909
1910    #[tokio::test]
1911    async fn import_reports_batch_and_delete_failures() {
1912        struct Failing;
1913        #[async_trait]
1914        impl StateStore for Failing {
1915            async fn get(&self, _: &str) -> Result<Option<Value>, FaucetError> {
1916                Ok(None)
1917            }
1918            async fn put(&self, _: &str, _: &Value) -> Result<(), FaucetError> {
1919                Ok(())
1920            }
1921            async fn delete(&self, _: &str) -> Result<(), FaucetError> {
1922                Ok(())
1923            }
1924            fn supports_atomic_batch(&self) -> bool {
1925                true
1926            }
1927            async fn put_batch(&self, _: &[(String, Value)]) -> Result<(), FaucetError> {
1928                Err(FaucetError::State("tx aborted".into()))
1929            }
1930        }
1931        assert_eq!(Failing.get("o::a").await.unwrap(), None);
1932        Failing.put("o::a", &json!(1)).await.unwrap();
1933        Failing.delete("o::a").await.unwrap();
1934        let mut export = StateExport::new("o");
1935        export.keys.insert("o::a".into(), json!(1));
1936        let r = import_namespace(&Failing, &export, false).await.unwrap();
1937        assert!(r.written.is_empty());
1938        assert!(r.error.unwrap().contains("tx aborted"));
1939
1940        // A replace needs to list the namespace first.
1941        assert!(import_namespace(&Failing, &export, true).await.is_err());
1942
1943        let s = Bare(MemoryStateStore::new());
1944        struct Listing(Bare);
1945        #[async_trait]
1946        impl StateStore for Listing {
1947            async fn get(&self, k: &str) -> Result<Option<Value>, FaucetError> {
1948                self.0.get(k).await
1949            }
1950            async fn put(&self, k: &str, v: &Value) -> Result<(), FaucetError> {
1951                self.0.put(k, v).await
1952            }
1953            async fn delete(&self, k: &str) -> Result<(), FaucetError> {
1954                self.0.delete(k).await
1955            }
1956            async fn list(&self, p: &str) -> Result<Vec<String>, FaucetError> {
1957                self.0.0.list(p).await
1958            }
1959        }
1960        s.put("o::stuck", &json!(1)).await.unwrap();
1961        let listing = Listing(s);
1962        assert_eq!(listing.get("o::stuck").await.unwrap(), Some(json!(1)));
1963        let r = import_namespace(&listing, &export, true).await.unwrap();
1964        assert_eq!(r.written, vec!["o::a"]);
1965        assert!(r.error.unwrap().contains("deleting stale 'o::stuck'"));
1966    }
1967}