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    crate::state_key::StateKey::pipeline(pipeline).children_prefix()
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/// How long a compare-and-put waits for another writer's lock.
333const LOCK_WAIT: std::time::Duration = std::time::Duration::from_secs(10);
334
335/// An exclusive OS lock (`flock` / `LockFileEx`) on a lock file, released
336/// when dropped or when the holding process dies.
337///
338/// A crashed writer's lock is released by the OS, so no staleness rule is
339/// needed (#862 M-009). On Unix `delete` unlinks the lock file while holding
340/// it; a waiter that then wins the lock on the unlinked file sees that the path
341/// no longer names it and locks the path afresh, so two holders never share a
342/// path.
343struct LockFile {
344    _file: std::fs::File,
345}
346
347impl LockFile {
348    /// Remove the lock file while holding it (Unix only; Windows keeps it,
349    /// because an open file cannot be unlinked there).
350    fn remove(&self, path: &Path) -> Result<(), FaucetError> {
351        if cfg!(unix) {
352            match std::fs::remove_file(path) {
353                Ok(()) => {}
354                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
355                Err(e) => {
356                    return Err(FaucetError::State(format!(
357                        "failed to delete state lock {}: {e}",
358                        path.display()
359                    )));
360                }
361            }
362        }
363        Ok(())
364    }
365}
366
367/// Whether `path` still names the file `file` has open.
368#[cfg(unix)]
369fn names_open_file(file: &std::fs::File, path: &Path) -> Result<bool, FaucetError> {
370    use std::os::unix::fs::MetadataExt;
371    let open = file
372        .metadata()
373        .map_err(|e| FaucetError::State(format!("failed to stat state lock: {e}")))?;
374    match std::fs::metadata(path) {
375        Ok(m) => Ok(m.dev() == open.dev() && m.ino() == open.ino()),
376        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
377        Err(e) => Err(FaucetError::State(format!(
378            "failed to stat state lock {}: {e}",
379            path.display()
380        ))),
381    }
382}
383
384#[cfg(not(unix))]
385fn names_open_file(_file: &std::fs::File, _path: &Path) -> Result<bool, FaucetError> {
386    Ok(true)
387}
388
389async fn open_lock_file(path: &Path) -> Result<std::fs::File, FaucetError> {
390    Ok(tokio::fs::OpenOptions::new()
391        .create(true)
392        .truncate(false)
393        .write(true)
394        .open(path)
395        .await
396        .map_err(|e| {
397            FaucetError::State(format!(
398                "failed to create state lock {}: {e}",
399                path.display()
400            ))
401        })?
402        .into_std()
403        .await)
404}
405
406#[allow(clippy::disallowed_methods)] // a runtime timer, not a connector backend call
407async fn acquire_lock_file(path: &Path) -> Result<LockFile, FaucetError> {
408    let started = tokio::time::Instant::now();
409    let mut file = open_lock_file(path).await?;
410    loop {
411        match file.try_lock() {
412            Ok(()) => {
413                if names_open_file(&file, path)? {
414                    return Ok(LockFile { _file: file });
415                }
416                file = open_lock_file(path).await?;
417            }
418            Err(std::fs::TryLockError::WouldBlock) => {
419                if started.elapsed() > LOCK_WAIT {
420                    return Err(FaucetError::State(format!(
421                        "state lock {} is held by another writer",
422                        path.display()
423                    )));
424                }
425                tokio::time::sleep(std::time::Duration::from_millis(10)).await;
426            }
427            Err(std::fs::TryLockError::Error(e)) => {
428                return Err(FaucetError::State(format!(
429                    "failed to lock state lock {}: {e}",
430                    path.display()
431                )));
432            }
433        }
434    }
435}
436
437// ── MemoryStateStore ────────────────────────────────────────────────────────
438
439/// In-memory `StateStore` for tests and ephemeral pipelines.
440#[derive(Default)]
441pub struct MemoryStateStore {
442    inner: Mutex<HashMap<String, Value>>,
443}
444
445impl MemoryStateStore {
446    /// Create an empty in-memory store.
447    pub fn new() -> Self {
448        Self::default()
449    }
450}
451
452#[async_trait]
453impl StateStore for MemoryStateStore {
454    async fn get(&self, key: &str) -> Result<Option<Value>, FaucetError> {
455        validate_state_key(key)?;
456        Ok(self.inner.lock().await.get(key).cloned())
457    }
458
459    async fn put(&self, key: &str, value: &Value) -> Result<(), FaucetError> {
460        validate_state_key(key)?;
461        self.inner
462            .lock()
463            .await
464            .insert(key.to_owned(), value.clone());
465        Ok(())
466    }
467
468    async fn delete(&self, key: &str) -> Result<(), FaucetError> {
469        validate_state_key(key)?;
470        self.inner.lock().await.remove(key);
471        Ok(())
472    }
473
474    fn supports_list(&self) -> bool {
475        true
476    }
477
478    async fn list(&self, prefix: &str) -> Result<Vec<String>, FaucetError> {
479        let mut keys: Vec<String> = self
480            .inner
481            .lock()
482            .await
483            .keys()
484            .filter(|k| k.starts_with(prefix))
485            .cloned()
486            .collect();
487        keys.sort();
488        Ok(keys)
489    }
490
491    fn supports_compare_and_put(&self) -> bool {
492        true
493    }
494
495    async fn compare_and_put(
496        &self,
497        key: &str,
498        expected: Option<&Value>,
499        value: &Value,
500    ) -> Result<bool, FaucetError> {
501        validate_state_key(key)?;
502        let mut map = self.inner.lock().await;
503        if map.get(key) != expected {
504            return Ok(false);
505        }
506        map.insert(key.to_owned(), value.clone());
507        Ok(true)
508    }
509
510    fn supports_atomic_batch(&self) -> bool {
511        true
512    }
513
514    async fn put_batch(&self, entries: &[(String, Value)]) -> Result<(), FaucetError> {
515        for (key, _) in entries {
516            validate_state_key(key)?;
517        }
518        let mut map = self.inner.lock().await;
519        for (key, value) in entries {
520            map.insert(key.clone(), value.clone());
521        }
522        Ok(())
523    }
524
525    async fn check(
526        &self,
527        _ctx: &crate::check::CheckContext,
528    ) -> Result<crate::check::CheckReport, FaucetError> {
529        // In-process map — always reachable.
530        Ok(crate::check::CheckReport::single(
531            crate::check::Probe::pass("sentinel", std::time::Duration::ZERO),
532        ))
533    }
534}
535
536// ── FileStateStore ──────────────────────────────────────────────────────────
537
538/// Longest filename stem [`safe_filename`] emits before it hashes the key.
539/// The temp name adds `.{32-hex}.{seq}.json.tmp` (at most 63 bytes), so a
540/// stem of this length keeps every file the store writes under the 255-byte
541/// name limit of common filesystems (CORE-29).
542const MAX_STEM: usize = 160;
543
544/// Map a logical state key to a filesystem-safe filename stem.
545///
546/// - `:` and `/` are percent-encoded as `%3A` / `%2F` (`:` is illegal in an
547///   NTFS filename, `/` is a path separator).
548/// - Every uppercase ASCII letter is percent-encoded (`A` → `%41`), so the
549///   stem holds no uppercase letters outside escapes and two keys that differ
550///   only in case can never share a file on a case-insensitive filesystem
551///   (APFS, NTFS) (CORE-28).
552/// - A stem longer than [`MAX_STEM`] keeps a readable prefix and ends in
553///   `~<sha256 of the key>`; `~` never appears in a key or an escape, so the
554///   two shapes cannot collide. The full key is kept in a `<stem>.key`
555///   sidecar so [`StateStore::list`] can still report it.
556///
557/// Collision-free because `%` and `~` can never appear in a valid key (see
558/// [`validate_state_key`]).
559fn safe_filename(key: &str) -> String {
560    let mut out = String::with_capacity(key.len() + 8);
561    for c in key.chars() {
562        match c {
563            ':' => out.push_str("%3A"),
564            '/' => out.push_str("%2F"),
565            'A'..='Z' => out.push_str(&format!("%{:02X}", c as u32)),
566            other => out.push(other),
567        }
568    }
569    if out.len() <= MAX_STEM {
570        return out;
571    }
572    use sha2::Digest;
573    let digest = sha2::Sha256::digest(key.as_bytes());
574    let hex: String = digest.iter().map(|b| format!("{b:02x}")).collect();
575    let mut cut = MAX_STEM - hex.len() - 1;
576    // Never split a `%XX` escape.
577    while cut > 0
578        && out.as_bytes()[..cut]
579            .iter()
580            .rev()
581            .take(2)
582            .any(|b| *b == b'%')
583    {
584        cut -= 1;
585    }
586    format!("{}~{hex}", &out[..cut])
587}
588
589/// Inverse of [`safe_filename`] for un-hashed stems (both the current and the
590/// legacy shape decode); `None` when the stem is hashed or not a valid key.
591fn key_from_filename(stem: &str) -> Option<String> {
592    if stem.contains('~') {
593        return None;
594    }
595    let bytes = stem.as_bytes();
596    let mut key = String::with_capacity(stem.len());
597    let mut i = 0;
598    while i < bytes.len() {
599        if bytes[i] == b'%' {
600            let hex = stem.get(i + 1..i + 3)?;
601            let code = u8::from_str_radix(hex, 16).ok()?;
602            key.push(char::from(code));
603            i += 3;
604        } else {
605            key.push(char::from(bytes[i]));
606            i += 1;
607        }
608    }
609    validate_state_key(&key).ok().map(|()| key)
610}
611
612/// File-backed `StateStore`. Each key maps to a JSON file at
613/// `{root}/{safe_filename(key)}.json`, written via atomic rename. The
614/// filename stem percent-encodes `:`, `/` and uppercase letters, so keys are
615/// valid on Windows and keys differing only in case never share a file on a
616/// case-insensitive filesystem; over-long keys are hashed.
617///
618/// Each `put` writes a temp file, fsyncs it, renames it over the final path,
619/// and (on Unix) fsyncs the parent directory — so once `put` returns the
620/// bookmark is durable across a crash or power loss, not merely sitting in the
621/// page cache.
622///
623/// The store creates its root directory on first use. All writes are
624/// serialized through a per-store mutex so two concurrent `put`s for the
625/// same key cannot race on the temp filename. Different processes pointing
626/// at the same directory rely on the filesystem's atomic rename guarantee.
627pub struct FileStateStore {
628    root: PathBuf,
629    write_lock: Mutex<()>,
630    migrated: tokio::sync::OnceCell<()>,
631    /// Optional at-rest encryption (#207). When set, `put` seals the JSON
632    /// bytes before the atomic write and `get` unseals; plaintext files
633    /// remain readable (and are sealed on their next write).
634    #[cfg(feature = "encryption")]
635    encryption: Option<crate::encryption::CompiledEncryption>,
636}
637
638impl FileStateStore {
639    /// Open or create a file-backed state store rooted at `root`.
640    pub fn new(root: impl Into<PathBuf>) -> Self {
641        Self {
642            root: root.into(),
643            write_lock: Mutex::new(()),
644            migrated: tokio::sync::OnceCell::new(),
645            #[cfg(feature = "encryption")]
646            encryption: None,
647        }
648    }
649
650    /// Seal bookmark files at rest with the given policy (#207). Reads accept
651    /// both sealed and (legacy) plaintext files; every write seals.
652    #[cfg(feature = "encryption")]
653    pub fn with_encryption(mut self, encryption: crate::encryption::CompiledEncryption) -> Self {
654        self.encryption = Some(encryption);
655        self
656    }
657
658    fn entry_path(&self, key: &str) -> PathBuf {
659        self.root.join(format!("{}.json", safe_filename(key)))
660    }
661
662    /// The file `key` is stored in, so a store that writes the same layout
663    /// another way (with tighter permissions, say) reads back through this one.
664    pub fn path_for(&self, key: &str) -> PathBuf {
665        self.entry_path(key)
666    }
667
668    fn temp_path(&self, key: &str) -> PathBuf {
669        // Unique per write: a per-process random token + a monotonic counter, so
670        // two writers (different processes sharing the directory, or two store
671        // instances in one process) never share a temp file. A *fixed* temp name
672        // let a second writer `File::create`-truncate the first's half-written
673        // temp, which the first could then `rename` over the final path —
674        // yielding torn/truncated state JSON that breaks resume (audit #146 H10).
675        //
676        // The token MUST NOT be derived from the process id: containers on a
677        // shared NFS/EFS/host volume routinely reuse the same PID (each starts at
678        // PID 1), so a PID + per-process counter collides across processes and
679        // reintroduces exactly that torn-bookmark corruption (F50). A random v4
680        // UUID minted once per process is unguessable and collision-free across
681        // processes regardless of PID; the counter keeps writers within one
682        // process distinct. The per-store `write_lock` only serializes writers
683        // within one process. Orphaned `.tmp` files from an interrupted write are
684        // harmless: `get` only ever reads the final `.json` path.
685        use std::sync::OnceLock;
686        use std::sync::atomic::{AtomicU64, Ordering};
687        static PROC_TOKEN: OnceLock<String> = OnceLock::new();
688        static SEQ: AtomicU64 = AtomicU64::new(0);
689        let token = PROC_TOKEN.get_or_init(|| uuid::Uuid::new_v4().simple().to_string());
690        let seq = SEQ.fetch_add(1, Ordering::Relaxed);
691        self.root
692            .join(format!("{}.{}.{}.json.tmp", safe_filename(key), token, seq))
693    }
694
695    fn lock_path(&self, key: &str) -> PathBuf {
696        self.root.join(format!("{}.json.lock", safe_filename(key)))
697    }
698
699    async fn ensure_root(&self) -> Result<(), FaucetError> {
700        tokio::fs::create_dir_all(&self.root).await.map_err(|e| {
701            FaucetError::State(format!(
702                "failed to create state dir {}: {e}",
703                self.root.display()
704            ))
705        })
706    }
707
708    async fn read_entry(&self, key: &str, path: &Path) -> Result<Option<Value>, FaucetError> {
709        #[cfg(not(feature = "encryption"))]
710        let _ = key;
711        match tokio::fs::read(path).await {
712            Ok(bytes) => {
713                #[cfg(feature = "encryption")]
714                let bytes: Vec<u8> = if crate::encryption::is_encrypted(&bytes) {
715                    match &self.encryption {
716                        Some(enc) => enc.decrypt_bound(&bytes, key.as_bytes()).map_err(|e| {
717                            // Wrong/rotated key must be a loud, typed error —
718                            // treating it as "no bookmark" would silently
719                            // trigger a full re-sync.
720                            FaucetError::State(format!(
721                                "state file {} could not be decrypted: {e}",
722                                path.display()
723                            ))
724                        })?,
725                        None => {
726                            return Err(FaucetError::State(format!(
727                                "state file {} is encrypted but no `encryption` block is \
728                                 configured on the file state store — add \
729                                 `state.config.encryption` with the original key",
730                                path.display()
731                            )));
732                        }
733                    }
734                } else {
735                    bytes
736                };
737                #[cfg(not(feature = "encryption"))]
738                let bytes = {
739                    // Built without the `encryption` feature: refuse to parse a
740                    // sealed file as JSON garbage.
741                    if bytes.starts_with(b"FCT1") {
742                        return Err(FaucetError::State(format!(
743                            "state file {} is encrypted but this build of faucet has no \
744                             `encryption` feature",
745                            path.display()
746                        )));
747                    }
748                    bytes
749                };
750                let value: Value = serde_json::from_slice(&bytes).map_err(|e| {
751                    FaucetError::State(format!(
752                        "failed to parse state file {}: {e}",
753                        path.display()
754                    ))
755                })?;
756                Ok(Some(value))
757            }
758            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
759            Err(e) => Err(FaucetError::State(format!(
760                "failed to read state file {}: {e}",
761                path.display()
762            ))),
763        }
764    }
765
766    /// Rename every file written under an older naming scheme (uppercase
767    /// letters unescaped, long keys unhashed) to its current name, once per
768    /// store. Done eagerly rather than as a read fallback because on a
769    /// case-insensitive filesystem the current name of `users:x` would open the
770    /// legacy file of `Users:x`.
771    async fn migrate_legacy_names(&self) -> Result<(), FaucetError> {
772        self.migrated
773            .get_or_try_init(|| async {
774                let mut dir = match tokio::fs::read_dir(&self.root).await {
775                    Ok(d) => d,
776                    Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
777                    Err(e) => {
778                        return Err(FaucetError::State(format!(
779                            "failed to list state dir {}: {e}",
780                            self.root.display()
781                        )));
782                    }
783                };
784                let mut renames = Vec::new();
785                while let Some(entry) = dir.next_entry().await.map_err(|e| {
786                    FaucetError::State(format!(
787                        "failed to list state dir {}: {e}",
788                        self.root.display()
789                    ))
790                })? {
791                    let name = entry.file_name();
792                    let Some(stem) = name.to_str().and_then(|n| n.strip_suffix(".json")) else {
793                        continue;
794                    };
795                    if let Some(key) = key_from_filename(stem)
796                        && safe_filename(&key) != stem
797                    {
798                        renames.push((key, entry.path()));
799                    }
800                }
801                for (key, from) in renames {
802                    if let Some(sidecar) = self.key_sidecar(&key) {
803                        tokio::fs::write(&sidecar, key.as_bytes())
804                            .await
805                            .map_err(|e| {
806                                FaucetError::State(format!(
807                                    "failed to write state key file {}: {e}",
808                                    sidecar.display()
809                                ))
810                            })?;
811                    }
812                    let to = self.entry_path(&key);
813                    if tokio::fs::try_exists(&to).await.unwrap_or(false) {
814                        let _ = tokio::fs::remove_file(&from).await;
815                        continue;
816                    }
817                    match tokio::fs::rename(&from, &to).await {
818                        Ok(()) => {}
819                        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
820                        Err(e) => {
821                            return Err(FaucetError::State(format!(
822                                "failed to rename legacy state file {} to {}: {e}",
823                                from.display(),
824                                to.display()
825                            )));
826                        }
827                    }
828                }
829                Ok(())
830            })
831            .await
832            .map(|_| ())
833    }
834
835    fn key_sidecar(&self, key: &str) -> Option<PathBuf> {
836        let stem = safe_filename(key);
837        stem.contains('~')
838            .then(|| self.root.join(format!("{stem}.key")))
839    }
840
841    /// Returns the root directory this store writes into.
842    pub fn root(&self) -> &Path {
843        &self.root
844    }
845}
846
847#[async_trait]
848impl StateStore for FileStateStore {
849    async fn get(&self, key: &str) -> Result<Option<Value>, FaucetError> {
850        validate_state_key(key)?;
851        self.migrate_legacy_names().await?;
852        self.read_entry(key, &self.entry_path(key)).await
853    }
854
855    async fn put(&self, key: &str, value: &Value) -> Result<(), FaucetError> {
856        validate_state_key(key)?;
857        let _guard = self.write_lock.lock().await;
858        self.ensure_root().await?;
859        let bytes = serde_json::to_vec(value).map_err(|e| {
860            FaucetError::State(format!("failed to serialize state for key '{key}': {e}"))
861        })?;
862        #[cfg(feature = "encryption")]
863        let bytes = match &self.encryption {
864            Some(enc) => enc.encrypt_bound(&bytes, key.as_bytes()),
865            None => bytes,
866        };
867        self.migrate_legacy_names().await?;
868        let final_path = self.entry_path(key);
869        let tmp_path = self.temp_path(key);
870
871        if let Some(sidecar) = self.key_sidecar(key) {
872            tokio::fs::write(&sidecar, key.as_bytes())
873                .await
874                .map_err(|e| {
875                    FaucetError::State(format!(
876                        "failed to write state key file {}: {e}",
877                        sidecar.display()
878                    ))
879                })?;
880        }
881
882        // Write the temp file and fsync it BEFORE the rename. `fs::write`
883        // alone leaves the bytes in the page cache: a crash after `put`
884        // returns could surface a zero-length or stale file, breaking the
885        // durability guarantee this store documents (#78/#8). `sync_all`
886        // flushes the file's data and metadata to disk. Any failure removes
887        // the temp file so retries do not accumulate orphans (CORE-96).
888        let written: Result<(), FaucetError> = async {
889            let mut file = tokio::fs::File::create(&tmp_path).await.map_err(|e| {
890                FaucetError::State(format!(
891                    "failed to create temp state file {}: {e}",
892                    tmp_path.display()
893                ))
894            })?;
895            file.write_all(&bytes).await.map_err(|e| {
896                FaucetError::State(format!(
897                    "failed to write temp state file {}: {e}",
898                    tmp_path.display()
899                ))
900            })?;
901            file.sync_all().await.map_err(|e| {
902                FaucetError::State(format!(
903                    "failed to fsync temp state file {}: {e}",
904                    tmp_path.display()
905                ))
906            })?;
907            drop(file);
908            tokio::fs::rename(&tmp_path, &final_path)
909                .await
910                .map_err(|e| {
911                    FaucetError::State(format!(
912                        "failed to commit state file {}: {e}",
913                        final_path.display()
914                    ))
915                })
916        }
917        .await;
918        if let Err(e) = written {
919            let _ = tokio::fs::remove_file(&tmp_path).await;
920            return Err(e);
921        }
922
923        // fsync the parent directory so the rename itself is durable — an
924        // atomic rename can still be lost on crash if the directory entry was
925        // never flushed. Directory fsync is a POSIX concept; on platforms that
926        // don't allow opening a directory as a file (e.g. Windows) it is
927        // skipped.
928        #[cfg(unix)]
929        {
930            let dir = tokio::fs::File::open(&self.root).await.map_err(|e| {
931                FaucetError::State(format!(
932                    "failed to open state dir {} for fsync: {e}",
933                    self.root.display()
934                ))
935            })?;
936            dir.sync_all().await.map_err(|e| {
937                FaucetError::State(format!(
938                    "failed to fsync state dir {}: {e}",
939                    self.root.display()
940                ))
941            })?;
942        }
943
944        tracing::debug!(
945            key,
946            path = %final_path.display(),
947            "state file written"
948        );
949        Ok(())
950    }
951
952    async fn delete(&self, key: &str) -> Result<(), FaucetError> {
953        validate_state_key(key)?;
954        self.migrate_legacy_names().await?;
955        let lock_path = self.lock_path(key);
956        let held = if tokio::fs::try_exists(&self.root).await.unwrap_or(false) {
957            Some(acquire_lock_file(&lock_path).await?)
958        } else {
959            None
960        };
961        let mut paths = vec![self.entry_path(key)];
962        paths.extend(self.key_sidecar(key));
963        for path in paths {
964            match tokio::fs::remove_file(&path).await {
965                Ok(()) => {}
966                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
967                Err(e) => {
968                    return Err(FaucetError::State(format!(
969                        "failed to delete state file {}: {e}",
970                        path.display()
971                    )));
972                }
973            }
974        }
975        if let Some(held) = held {
976            held.remove(&lock_path)?;
977        }
978        Ok(())
979    }
980
981    fn supports_compare_and_put(&self) -> bool {
982        true
983    }
984
985    /// Atomic across processes sharing the directory: the read-compare-write
986    /// runs while holding an exclusive OS lock on `<key>.json.lock`, which the
987    /// OS releases if the writer dies. The lock serializes compare-and-puts
988    /// only: a plain [`put`](StateStore::put) or [`delete`](StateStore::delete)
989    /// of the same key does not take it, so a key managed by compare-and-put
990    /// must not also be written with them.
991    async fn compare_and_put(
992        &self,
993        key: &str,
994        expected: Option<&Value>,
995        value: &Value,
996    ) -> Result<bool, FaucetError> {
997        validate_state_key(key)?;
998        self.ensure_root().await?;
999        let _held = acquire_lock_file(&self.lock_path(key)).await?;
1000        if self.get(key).await?.as_ref() != expected {
1001            return Ok(false);
1002        }
1003        self.put(key, value).await?;
1004        Ok(true)
1005    }
1006
1007    fn supports_list(&self) -> bool {
1008        true
1009    }
1010
1011    async fn list(&self, prefix: &str) -> Result<Vec<String>, FaucetError> {
1012        self.migrate_legacy_names().await?;
1013        let mut dir = match tokio::fs::read_dir(&self.root).await {
1014            Ok(d) => d,
1015            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
1016            Err(e) => {
1017                return Err(FaucetError::State(format!(
1018                    "failed to list state dir {}: {e}",
1019                    self.root.display()
1020                )));
1021            }
1022        };
1023        let mut keys = Vec::new();
1024        loop {
1025            let entry = dir.next_entry().await.map_err(|e| {
1026                FaucetError::State(format!(
1027                    "failed to list state dir {}: {e}",
1028                    self.root.display()
1029                ))
1030            })?;
1031            let Some(entry) = entry else { break };
1032            let name = entry.file_name();
1033            let Some(stem) = name.to_str().and_then(|n| n.strip_suffix(".json")) else {
1034                continue;
1035            };
1036            let key = if stem.contains('~') {
1037                tokio::fs::read_to_string(self.root.join(format!("{stem}.key")))
1038                    .await
1039                    .ok()
1040                    .filter(|k| validate_state_key(k).is_ok())
1041            } else {
1042                key_from_filename(stem)
1043            };
1044            if let Some(key) = key
1045                && key.starts_with(prefix)
1046            {
1047                keys.push(key);
1048            }
1049        }
1050        keys.sort();
1051        keys.dedup();
1052        Ok(keys)
1053    }
1054
1055    async fn check(
1056        &self,
1057        _ctx: &crate::check::CheckContext,
1058    ) -> Result<crate::check::CheckReport, FaucetError> {
1059        use crate::check::{CheckReport, Probe};
1060        // Exercise the real put → get → delete cycle on a sentinel key. `put`
1061        // creates the root dir if needed, so this validates "dir exists +
1062        // writable" via the actual code path and leaves no residue.
1063        let start = std::time::Instant::now();
1064        let probe = match self.sentinel_roundtrip().await {
1065            Ok(()) => Probe::pass("sentinel", start.elapsed()),
1066            Err(e) => Probe::fail_hint(
1067                "sentinel",
1068                start.elapsed(),
1069                e.to_string(),
1070                format!("ensure {} exists and is writable", self.root.display()),
1071            ),
1072        };
1073        Ok(CheckReport::single(probe))
1074    }
1075}
1076
1077impl FileStateStore {
1078    /// Write, read back, and delete a sentinel key — the body of the `check()`
1079    /// probe, factored out so the happy path stays linear.
1080    async fn sentinel_roundtrip(&self) -> Result<(), FaucetError> {
1081        let probe = serde_json::json!({ "faucet_doctor": true });
1082        let key = doctor_sentinel_key();
1083        self.put(&key, &probe).await?;
1084        let got = self.get(&key).await?;
1085        // Best-effort cleanup regardless of the read result.
1086        let _ = self.delete(&key).await;
1087        match got {
1088            Some(v) if v == probe => Ok(()),
1089            _ => Err(FaucetError::State(
1090                "sentinel readback did not match what was written".into(),
1091            )),
1092        }
1093    }
1094}
1095
1096#[cfg(test)]
1097#[allow(clippy::disallowed_methods)] // tests drive time and raw clients directly
1098mod tests {
1099    use super::*;
1100    use serde_json::json;
1101    use std::sync::Arc;
1102    use tempfile::TempDir;
1103
1104    // ── key validation ─────────────────────────────────────────────────────
1105
1106    #[tokio::test]
1107    async fn a_lock_that_cannot_be_created_is_a_state_error() {
1108        let dir = TempDir::new().unwrap();
1109        let path = dir.path().join("missing-dir").join("k.lock");
1110        let err = acquire_lock_file(&path).await.err().unwrap().to_string();
1111        assert!(err.contains("failed to create state lock"), "{err}");
1112    }
1113
1114    #[test]
1115    fn rejects_empty_key() {
1116        let err = validate_state_key("").unwrap_err();
1117        assert!(matches!(err, FaucetError::State(_)));
1118    }
1119
1120    #[test]
1121    fn rejects_path_traversal_segments() {
1122        for k in [
1123            "../etc/passwd",
1124            "a/../b",
1125            "a/./b",
1126            "a//b",
1127            "/a",
1128            "a/",
1129            "a/.x",
1130            "a\\b",
1131            "..",
1132            ".",
1133        ] {
1134            assert!(validate_state_key(k).is_err(), "expected reject for {k:?}");
1135        }
1136        // A `/` that separates a hub owner from a name is fine (#682).
1137        assert!(validate_state_key("acme/erp::invoices").is_ok());
1138    }
1139
1140    #[test]
1141    fn rejects_leading_dot() {
1142        assert!(validate_state_key(".hidden").is_err());
1143    }
1144
1145    #[test]
1146    fn rejects_over_long_key() {
1147        let k = "a".repeat(257);
1148        assert!(validate_state_key(&k).is_err());
1149    }
1150
1151    #[test]
1152    fn accepts_typical_keys() {
1153        for k in [
1154            "github_issues",
1155            "pipeline:rest:issues",
1156            "with.dot",
1157            "with-dash_and_underscore",
1158            "lower-Case_99",
1159        ] {
1160            validate_state_key(k).unwrap_or_else(|e| panic!("expected ok for {k:?}: {e}"));
1161        }
1162    }
1163
1164    // ── MemoryStateStore ────────────────────────────────────────────────────
1165
1166    #[tokio::test]
1167    async fn compare_and_put_writes_only_over_the_expected_value() {
1168        let mem = MemoryStateStore::new();
1169        assert!(mem.supports_compare_and_put());
1170        let a = serde_json::json!({"v": 1});
1171        let b = serde_json::json!({"v": 2});
1172        assert!(mem.compare_and_put("k", None, &a).await.unwrap());
1173        assert!(!mem.compare_and_put("k", None, &b).await.unwrap());
1174        assert!(!mem.compare_and_put("k", Some(&b), &b).await.unwrap());
1175        assert!(mem.compare_and_put("k", Some(&a), &b).await.unwrap());
1176        assert_eq!(mem.get("k").await.unwrap(), Some(b.clone()));
1177        assert!(mem.compare_and_put("bad key!", None, &a).await.is_err());
1178
1179        let dir = tempfile::tempdir().unwrap();
1180        let file = FileStateStore::new(dir.path());
1181        assert!(file.supports_compare_and_put());
1182        assert!(file.compare_and_put("k", None, &a).await.unwrap());
1183        assert!(!file.compare_and_put("k", Some(&b), &b).await.unwrap());
1184        assert!(file.compare_and_put("k", Some(&a), &b).await.unwrap());
1185        assert_eq!(file.get("k").await.unwrap(), Some(b));
1186    }
1187
1188    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1189    async fn file_compare_and_put_is_exclusive() {
1190        let dir = tempfile::tempdir().unwrap();
1191        let stores: Vec<std::sync::Arc<FileStateStore>> = (0..8)
1192            .map(|_| std::sync::Arc::new(FileStateStore::new(dir.path())))
1193            .collect();
1194        for round in 0..20 {
1195            let key = format!("lease{round}");
1196            let tasks: Vec<_> = stores
1197                .iter()
1198                .enumerate()
1199                .map(|(i, s)| {
1200                    let (s, key) = (std::sync::Arc::clone(s), key.clone());
1201                    tokio::spawn(async move {
1202                        s.compare_and_put(&key, None, &json!({"by": i}))
1203                            .await
1204                            .unwrap()
1205                    })
1206                })
1207                .collect();
1208            let mut wins = 0;
1209            for t in tasks {
1210                wins += usize::from(t.await.unwrap());
1211            }
1212            assert_eq!(wins, 1, "exactly one writer takes an absent key");
1213        }
1214    }
1215
1216    /// #862 M-009: a lock file a crashed writer left behind holds no OS lock,
1217    /// so the next writer takes it at once instead of waiting it out.
1218    #[tokio::test]
1219    async fn file_compare_and_put_takes_a_lock_left_by_a_dead_writer() {
1220        let dir = tempfile::tempdir().unwrap();
1221        let store = FileStateStore::new(dir.path());
1222        std::fs::write(dir.path().join("lease.json.lock"), b"").unwrap();
1223        let started = tokio::time::Instant::now();
1224        assert!(
1225            store
1226                .compare_and_put("lease", None, &json!(1))
1227                .await
1228                .unwrap()
1229        );
1230        assert!(started.elapsed() < std::time::Duration::from_secs(1));
1231        assert!(
1232            store
1233                .compare_and_put("lease", Some(&json!(1)), &json!(2))
1234                .await
1235                .unwrap(),
1236            "the lock is released after each compare-and-put"
1237        );
1238        assert_eq!(store.list("").await.unwrap(), vec!["lease".to_string()]);
1239    }
1240
1241    #[cfg(unix)]
1242    #[tokio::test]
1243    async fn file_delete_removes_the_lock_file_and_the_key_stays_usable() {
1244        let dir = tempfile::tempdir().unwrap();
1245        let store = FileStateStore::new(dir.path());
1246        assert!(store.compare_and_put("k", None, &json!(1)).await.unwrap());
1247        assert!(dir.path().join("k.json.lock").exists());
1248        store.delete("k").await.unwrap();
1249        assert!(!dir.path().join("k.json.lock").exists());
1250        assert!(store.get("k").await.unwrap().is_none());
1251        assert!(store.compare_and_put("k", None, &json!(2)).await.unwrap());
1252        assert_eq!(store.get("k").await.unwrap(), Some(json!(2)));
1253        FileStateStore::new(dir.path().join("never-created"))
1254            .delete("k")
1255            .await
1256            .unwrap();
1257    }
1258
1259    /// A waiter that wins the lock on a file `delete` unlinked must not keep
1260    /// it: a third writer could lock a fresh file at the same path.
1261    #[cfg(unix)]
1262    #[tokio::test]
1263    async fn a_lock_won_on_an_unlinked_file_is_retaken_on_the_path() {
1264        use std::os::unix::fs::MetadataExt;
1265        let dir = tempfile::tempdir().unwrap();
1266        let path = dir.path().join("k.json.lock");
1267        let holder = std::fs::File::create(&path).unwrap();
1268        holder.lock().unwrap();
1269        let waiter = tokio::spawn({
1270            let path = path.clone();
1271            async move { acquire_lock_file(&path).await }
1272        });
1273        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1274        std::fs::remove_file(&path).unwrap();
1275        drop(holder);
1276        let held = waiter.await.unwrap().unwrap();
1277        let open = held._file.metadata().unwrap();
1278        let on_disk = std::fs::metadata(&path).unwrap();
1279        assert_eq!((open.dev(), open.ino()), (on_disk.dev(), on_disk.ino()));
1280    }
1281
1282    #[tokio::test(start_paused = true)]
1283    async fn file_compare_and_put_gives_up_on_a_live_lock() {
1284        let dir = tempfile::tempdir().unwrap();
1285        let store = FileStateStore::new(dir.path());
1286        let held = std::fs::File::create(dir.path().join("k.json.lock")).unwrap();
1287        held.lock().unwrap();
1288        let err = store
1289            .compare_and_put("k", None, &json!(1))
1290            .await
1291            .unwrap_err()
1292            .to_string();
1293        assert!(err.contains("held by another writer"), "{err}");
1294        drop(held);
1295        assert!(store.compare_and_put("k", None, &json!(1)).await.unwrap());
1296    }
1297
1298    #[tokio::test]
1299    async fn memory_get_returns_none_for_missing_key() {
1300        let s = MemoryStateStore::new();
1301        assert!(s.get("nope").await.unwrap().is_none());
1302    }
1303
1304    #[tokio::test]
1305    async fn memory_put_then_get_round_trips() {
1306        let s = MemoryStateStore::new();
1307        s.put("k", &json!({"cursor": "abc", "n": 7})).await.unwrap();
1308        let got = s.get("k").await.unwrap().unwrap();
1309        assert_eq!(got["cursor"], "abc");
1310        assert_eq!(got["n"], 7);
1311    }
1312
1313    #[tokio::test]
1314    async fn memory_put_overwrites_previous_value() {
1315        let s = MemoryStateStore::new();
1316        s.put("k", &json!(1)).await.unwrap();
1317        s.put("k", &json!(2)).await.unwrap();
1318        assert_eq!(s.get("k").await.unwrap().unwrap(), json!(2));
1319    }
1320
1321    #[tokio::test]
1322    async fn memory_delete_makes_get_return_none() {
1323        let s = MemoryStateStore::new();
1324        s.put("k", &json!("v")).await.unwrap();
1325        s.delete("k").await.unwrap();
1326        assert!(s.get("k").await.unwrap().is_none());
1327    }
1328
1329    #[tokio::test]
1330    async fn memory_delete_missing_key_is_ok() {
1331        let s = MemoryStateStore::new();
1332        s.delete("absent").await.unwrap();
1333    }
1334
1335    #[tokio::test]
1336    async fn memory_rejects_invalid_keys() {
1337        let s = MemoryStateStore::new();
1338        assert!(s.get("a b").await.is_err());
1339        assert!(s.put("a b", &json!(1)).await.is_err());
1340        assert!(s.delete("a b").await.is_err());
1341    }
1342
1343    // ── FileStateStore ──────────────────────────────────────────────────────
1344
1345    #[tokio::test]
1346    async fn file_get_returns_none_for_missing_key() {
1347        let dir = TempDir::new().unwrap();
1348        let s = FileStateStore::new(dir.path());
1349        assert!(s.get("nope").await.unwrap().is_none());
1350    }
1351
1352    #[tokio::test]
1353    async fn file_put_creates_root_directory_lazily() {
1354        let dir = TempDir::new().unwrap();
1355        let root = dir.path().join("nested/state");
1356        let s = FileStateStore::new(&root);
1357        s.put("k", &json!("v")).await.unwrap();
1358        assert!(root.is_dir(), "root dir should be created on first put");
1359    }
1360
1361    #[tokio::test]
1362    async fn file_put_then_get_round_trips() {
1363        let dir = TempDir::new().unwrap();
1364        let s = FileStateStore::new(dir.path());
1365        let value = json!({"cursor": "abc", "n": 42, "nested": {"flag": true}});
1366        s.put("github_issues", &value).await.unwrap();
1367        let got = s.get("github_issues").await.unwrap().unwrap();
1368        assert_eq!(got, value);
1369    }
1370
1371    #[test]
1372    fn temp_path_is_unique_and_not_pid_derived() {
1373        // Regression for F50: the temp filename must not embed the process id.
1374        // Containers on a shared volume reuse PIDs (each starts at PID 1), so a
1375        // PID + per-process counter collides across processes and reintroduces
1376        // the torn-bookmark corruption the unique-temp scheme prevents. The
1377        // token is a per-process random UUID instead.
1378        let dir = TempDir::new().unwrap();
1379        let s = FileStateStore::new(dir.path());
1380
1381        let a = s.temp_path("k");
1382        let b = s.temp_path("k");
1383        // Distinct temp paths within one process (the monotonic counter differs).
1384        assert_ne!(a, b);
1385
1386        let name_a = a.file_name().unwrap().to_str().unwrap();
1387        let pid = std::process::id().to_string();
1388        // The PID must NOT appear as a dotted segment of the temp name.
1389        assert!(
1390            !name_a.split('.').any(|seg| seg == pid),
1391            "temp filename {name_a} must not embed the process id ({pid})"
1392        );
1393        assert!(name_a.ends_with(".json.tmp"));
1394    }
1395
1396    #[test]
1397    fn safe_filename_percent_encodes_colon() {
1398        assert_eq!(
1399            safe_filename("pipeline:rest:issues"),
1400            "pipeline%3Arest%3Aissues"
1401        );
1402        assert_eq!(safe_filename("plain_key-1.v2"), "plain_key-1.v2");
1403        // Owner-scoped hub ids (`acme/erp::invoices`) must not become
1404        // nested directories.
1405        assert_eq!(
1406            safe_filename("acme/erp::invoices"),
1407            "acme%2Ferp%3A%3Ainvoices"
1408        );
1409        assert!(validate_state_key("acme/erp::invoices").is_ok());
1410        assert!(validate_state_key("a b").is_err());
1411    }
1412
1413    #[tokio::test]
1414    async fn file_round_trips_colon_keys_with_safe_filename() {
1415        // Regression for #78 LOW: the documented `pipeline:rest:issues` key
1416        // convention must round-trip and produce a Windows-legal filename
1417        // (no `:` on disk).
1418        let dir = TempDir::new().unwrap();
1419        let s = FileStateStore::new(dir.path());
1420        let value = json!({"cursor": "z"});
1421        s.put("pipeline:rest:issues", &value).await.unwrap();
1422        assert_eq!(s.get("pipeline:rest:issues").await.unwrap().unwrap(), value);
1423        // On-disk filename must not contain a colon.
1424        assert!(dir.path().join("pipeline%3Arest%3Aissues.json").exists());
1425        let mut has_colon = false;
1426        for entry in std::fs::read_dir(dir.path()).unwrap() {
1427            if entry.unwrap().file_name().to_string_lossy().contains(':') {
1428                has_colon = true;
1429            }
1430        }
1431        assert!(!has_colon, "no state filename may contain ':'");
1432    }
1433
1434    #[test]
1435    fn safe_filename_escapes_uppercase_and_round_trips() {
1436        assert_eq!(safe_filename("Users::x"), "%55sers%3A%3Ax");
1437        assert_ne!(
1438            safe_filename("Users::x").to_lowercase(),
1439            safe_filename("users::x").to_lowercase()
1440        );
1441        for k in ["Users::x", "a/B:c", "plain"] {
1442            assert_eq!(key_from_filename(&safe_filename(k)).as_deref(), Some(k));
1443        }
1444        // Legacy (unescaped) stems still decode.
1445        assert_eq!(key_from_filename("Users%3Ax").as_deref(), Some("Users:x"));
1446        assert_eq!(key_from_filename("bad%Z"), None);
1447        assert_eq!(key_from_filename("a~b"), None);
1448    }
1449
1450    #[test]
1451    fn long_keys_hash_into_a_bounded_stem() {
1452        let key = format!("p::{}", "A:".repeat(120));
1453        let stem = safe_filename(&key);
1454        assert!(stem.len() <= MAX_STEM, "{}", stem.len());
1455        assert!(stem.contains('~'));
1456        let other = format!("p::{}B", "A:".repeat(119));
1457        assert_ne!(safe_filename(&other), stem);
1458    }
1459
1460    #[tokio::test]
1461    async fn keys_differing_only_in_case_keep_separate_files() {
1462        let dir = TempDir::new().unwrap();
1463        let s = FileStateStore::new(dir.path());
1464        s.put("Users::x", &json!(1)).await.unwrap();
1465        s.put("users::x", &json!(2)).await.unwrap();
1466        assert_eq!(s.get("Users::x").await.unwrap(), Some(json!(1)));
1467        assert_eq!(s.get("users::x").await.unwrap(), Some(json!(2)));
1468        assert_eq!(s.list("").await.unwrap(), vec!["Users::x", "users::x"]);
1469    }
1470
1471    #[tokio::test]
1472    async fn a_long_key_round_trips_lists_and_deletes() {
1473        let dir = TempDir::new().unwrap();
1474        let s = FileStateStore::new(dir.path());
1475        let key = format!("pipe::row::{}", "parent:".repeat(34));
1476        assert!(key.len() <= 256);
1477        s.put(&key, &json!({"c": 1})).await.unwrap();
1478        assert_eq!(s.get(&key).await.unwrap(), Some(json!({"c": 1})));
1479        assert_eq!(s.list("pipe").await.unwrap(), vec![key.clone()]);
1480        s.delete(&key).await.unwrap();
1481        assert_eq!(s.get(&key).await.unwrap(), None);
1482        assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0);
1483    }
1484
1485    #[tokio::test]
1486    async fn legacy_named_files_are_read_and_migrated() {
1487        let dir = TempDir::new().unwrap();
1488        std::fs::write(dir.path().join("Users%3Ax.json"), b"{\"c\":7}").unwrap();
1489        let s = FileStateStore::new(dir.path());
1490        // A lowercase key must not read the uppercase key's legacy file.
1491        assert_eq!(s.get("users:x").await.unwrap(), None);
1492        assert_eq!(s.get("Users:x").await.unwrap(), Some(json!({"c": 7})));
1493        s.put("Users:x", &json!({"c": 8})).await.unwrap();
1494        let names: Vec<String> = std::fs::read_dir(dir.path())
1495            .unwrap()
1496            .map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
1497            .collect();
1498        assert_eq!(names, vec!["%55sers%3Ax.json".to_string()]);
1499        assert_eq!(s.get("Users:x").await.unwrap(), Some(json!({"c": 8})));
1500        s.delete("Users:x").await.unwrap();
1501        assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0);
1502
1503        // A long legacy name is migrated to its hashed name.
1504        let dir = TempDir::new().unwrap();
1505        let key = format!("p::{}", "q".repeat(170));
1506        std::fs::write(
1507            dir.path().join(format!("p%3A%3A{}.json", "q".repeat(170))),
1508            b"3",
1509        )
1510        .unwrap();
1511        let s = FileStateStore::new(dir.path());
1512        assert_eq!(s.list("p").await.unwrap(), vec![key.clone()]);
1513        assert_eq!(s.get(&key).await.unwrap(), Some(json!(3)));
1514    }
1515
1516    #[tokio::test]
1517    async fn a_failed_put_leaves_no_temp_file() {
1518        let dir = TempDir::new().unwrap();
1519        let s = FileStateStore::new(dir.path());
1520        // A directory where the final file should be makes the rename fail.
1521        std::fs::create_dir(dir.path().join("k.json")).unwrap();
1522        std::fs::write(dir.path().join("k.json").join("x"), b"x").unwrap();
1523        assert!(s.put("k", &json!(1)).await.is_err());
1524        assert!(!has_tmp_residue(dir.path()));
1525    }
1526
1527    #[test]
1528    fn doctor_sentinel_keys_are_unique_and_valid() {
1529        let a = doctor_sentinel_key();
1530        assert_ne!(a, doctor_sentinel_key());
1531        assert!(a.starts_with(DOCTOR_SENTINEL_KEY));
1532        validate_state_key(&a).unwrap();
1533    }
1534
1535    /// True if any `.json.tmp` residue remains in `dir`. Temp names are unique
1536    /// per write since #146 H10, so we glob for the suffix rather than check a
1537    /// fixed name (which would never exist now and pass vacuously).
1538    fn has_tmp_residue(dir: &std::path::Path) -> bool {
1539        std::fs::read_dir(dir)
1540            .unwrap()
1541            .filter_map(|e| e.ok())
1542            .any(|e| e.file_name().to_string_lossy().ends_with(".json.tmp"))
1543    }
1544
1545    #[tokio::test]
1546    async fn file_put_overwrites_previous_value_atomically() {
1547        let dir = TempDir::new().unwrap();
1548        let s = FileStateStore::new(dir.path());
1549        s.put("k", &json!({"v": 1})).await.unwrap();
1550        s.put("k", &json!({"v": 2})).await.unwrap();
1551        assert_eq!(s.get("k").await.unwrap().unwrap(), json!({"v": 2}));
1552        // No temp file left behind.
1553        assert!(!has_tmp_residue(dir.path()), "no temp residue after put");
1554    }
1555
1556    #[test]
1557    fn file_temp_paths_are_unique_per_write() {
1558        // H10 (audit #146): the temp path must be unique per write so two
1559        // writers (different processes, or two store instances in one process
1560        // with independent write_locks) never `File::create`-truncate a shared
1561        // temp that the other then renames over the final file (torn state).
1562        let dir = TempDir::new().unwrap();
1563        let s = FileStateStore::new(dir.path());
1564        let a = s.temp_path("k");
1565        let b = s.temp_path("k");
1566        assert_ne!(a, b, "each write must get a distinct temp path");
1567        // The committed (final) path stays stable across writes.
1568        assert_eq!(s.entry_path("k"), s.entry_path("k"));
1569    }
1570
1571    #[tokio::test]
1572    async fn file_put_writes_complete_durable_file_with_no_temp_residue() {
1573        // Regression for #78/#8. `put` must produce a fully-written, parseable
1574        // file and leave no temp file behind. (The fsync that makes this
1575        // durable across a power loss can't be observed on a healthy
1576        // filesystem, but a regression in the write/rename path — truncation,
1577        // a leftover .tmp, or an unwritten file — is caught here.) A large
1578        // payload makes a partial/unflushed write detectable on read-back.
1579        let dir = TempDir::new().unwrap();
1580        let s = FileStateStore::new(dir.path());
1581        let big: Vec<Value> = (0..1_000)
1582            .map(|i| json!({"i": i, "s": "x".repeat(20)}))
1583            .collect();
1584        let value = json!({"cursor": "abc", "rows": big});
1585
1586        s.put("github_issues", &value).await.unwrap();
1587
1588        // Read the raw file directly (bypassing get) to confirm it is complete.
1589        let raw = tokio::fs::read(dir.path().join("github_issues.json"))
1590            .await
1591            .expect("state file must exist after put");
1592        assert!(!raw.is_empty(), "state file must not be zero-length");
1593        let parsed: Value = serde_json::from_slice(&raw).expect("state file must be valid JSON");
1594        assert_eq!(parsed, value);
1595
1596        // No temp file left behind.
1597        assert!(!has_tmp_residue(dir.path()), "no temp residue after put");
1598    }
1599
1600    #[tokio::test]
1601    async fn file_delete_removes_file() {
1602        let dir = TempDir::new().unwrap();
1603        let s = FileStateStore::new(dir.path());
1604        s.put("k", &json!("v")).await.unwrap();
1605        s.delete("k").await.unwrap();
1606        assert!(s.get("k").await.unwrap().is_none());
1607        assert!(!dir.path().join("k.json").exists());
1608    }
1609
1610    #[tokio::test]
1611    async fn file_delete_missing_key_is_ok() {
1612        let dir = TempDir::new().unwrap();
1613        let s = FileStateStore::new(dir.path());
1614        s.delete("absent").await.unwrap();
1615    }
1616
1617    #[tokio::test]
1618    async fn file_get_returns_error_for_corrupt_json() {
1619        let dir = TempDir::new().unwrap();
1620        let s = FileStateStore::new(dir.path());
1621        tokio::fs::create_dir_all(dir.path()).await.unwrap();
1622        tokio::fs::write(dir.path().join("bad.json"), b"not json")
1623            .await
1624            .unwrap();
1625        let err = s.get("bad").await.unwrap_err();
1626        match err {
1627            FaucetError::State(msg) => assert!(msg.contains("bad.json")),
1628            other => panic!("expected State error, got {other:?}"),
1629        }
1630    }
1631
1632    #[tokio::test]
1633    async fn file_concurrent_puts_do_not_corrupt_or_leak_temp() {
1634        let dir = TempDir::new().unwrap();
1635        let s = Arc::new(FileStateStore::new(dir.path()));
1636        let mut handles = vec![];
1637        for i in 0..50 {
1638            let s = Arc::clone(&s);
1639            handles.push(tokio::spawn(async move {
1640                s.put("k", &json!({"i": i})).await.unwrap();
1641            }));
1642        }
1643        for h in handles {
1644            h.await.unwrap();
1645        }
1646        // The final value must be one of the 50 we wrote.
1647        let got = s.get("k").await.unwrap().unwrap();
1648        let i = got["i"].as_i64().unwrap();
1649        assert!((0..50).contains(&i));
1650        // And no temp file should remain.
1651        assert!(
1652            !has_tmp_residue(dir.path()),
1653            "no temp residue after concurrent puts"
1654        );
1655    }
1656
1657    #[tokio::test]
1658    async fn file_store_works_through_trait_object() {
1659        let dir = TempDir::new().unwrap();
1660        let s: Box<dyn StateStore> = Box::new(FileStateStore::new(dir.path()));
1661        s.put("k", &json!(1)).await.unwrap();
1662        assert_eq!(s.get("k").await.unwrap().unwrap(), json!(1));
1663    }
1664
1665    // ── check() ──────────────────────────────────────────────────────────────
1666
1667    #[tokio::test]
1668    async fn memory_check_passes() {
1669        let s = MemoryStateStore::new();
1670        let report = s
1671            .check(&crate::check::CheckContext::default())
1672            .await
1673            .unwrap();
1674        assert_eq!(report.failed_count(), 0);
1675        assert!(
1676            report
1677                .probes
1678                .iter()
1679                .all(|p| matches!(p.status, crate::check::ProbeStatus::Pass))
1680        );
1681    }
1682
1683    #[tokio::test]
1684    async fn file_check_passes_for_writable_root() {
1685        let dir = TempDir::new().unwrap();
1686        let s = FileStateStore::new(dir.path());
1687        let report = s
1688            .check(&crate::check::CheckContext::default())
1689            .await
1690            .unwrap();
1691        assert_eq!(report.failed_count(), 0, "writable root should pass");
1692        // The sentinel probe must leave no residue.
1693        let leftovers: Vec<_> = std::fs::read_dir(dir.path()).unwrap().collect();
1694        assert!(leftovers.is_empty(), "check() must not leave files behind");
1695    }
1696
1697    #[tokio::test]
1698    async fn file_store_root_returns_configured_directory() {
1699        let dir = TempDir::new().unwrap();
1700        let s = FileStateStore::new(dir.path());
1701        assert_eq!(s.root(), dir.path());
1702    }
1703
1704    #[tokio::test]
1705    async fn default_check_reports_not_implemented() {
1706        // A StateStore that does not override `check()` falls back to the
1707        // trait default, which yields a single not-implemented (skipped) probe.
1708        struct BareStore;
1709        #[async_trait]
1710        impl StateStore for BareStore {
1711            async fn get(&self, _key: &str) -> Result<Option<Value>, FaucetError> {
1712                Ok(None)
1713            }
1714            async fn put(&self, _key: &str, _value: &Value) -> Result<(), FaucetError> {
1715                Ok(())
1716            }
1717            async fn delete(&self, _key: &str) -> Result<(), FaucetError> {
1718                Ok(())
1719            }
1720        }
1721        let s = BareStore;
1722        let report = s
1723            .check(&crate::check::CheckContext::default())
1724            .await
1725            .unwrap();
1726        // not_implemented() reports no failures and is not a pass.
1727        assert_eq!(report.failed_count(), 0);
1728        assert!(
1729            report
1730                .probes
1731                .iter()
1732                .any(|p| matches!(p.status, crate::check::ProbeStatus::Skip { .. })),
1733            "default check must surface a skipped (not-implemented) probe"
1734        );
1735    }
1736
1737    #[tokio::test]
1738    async fn file_check_fails_when_root_unusable() {
1739        // Root whose parent is a regular file → create_dir_all fails.
1740        let dir = TempDir::new().unwrap();
1741        let file = dir.path().join("not_a_dir");
1742        std::fs::write(&file, b"x").unwrap();
1743        let s = FileStateStore::new(file.join("state"));
1744        let report = s
1745            .check(&crate::check::CheckContext::default())
1746            .await
1747            .unwrap();
1748        assert_eq!(report.failed_count(), 1, "unusable root should fail");
1749    }
1750
1751    #[cfg(feature = "encryption")]
1752    mod encryption_at_rest {
1753        use super::*;
1754        use crate::encryption::{CompiledEncryption, EncryptionSpec, is_encrypted};
1755        use serde_json::json;
1756
1757        fn enc(key: &str) -> CompiledEncryption {
1758            CompiledEncryption::compile(&EncryptionSpec {
1759                key: key.into(),
1760                previous_keys: vec![],
1761                algorithm: Default::default(),
1762            })
1763            .unwrap()
1764        }
1765
1766        #[tokio::test]
1767        async fn encrypted_round_trip_and_ciphertext_on_disk() {
1768            let dir = tempfile::tempdir().unwrap();
1769            let store = FileStateStore::new(dir.path()).with_encryption(enc("k1"));
1770            store.put("bk", &json!({"lsn": 42})).await.unwrap();
1771            assert_eq!(store.get("bk").await.unwrap(), Some(json!({"lsn": 42})));
1772
1773            // On disk it is ciphertext, not JSON.
1774            let raw = std::fs::read(dir.path().join("bk.json")).unwrap();
1775            assert!(is_encrypted(&raw));
1776            assert!(serde_json::from_slice::<Value>(&raw).is_err());
1777
1778            store.delete("bk").await.unwrap();
1779            assert_eq!(store.get("bk").await.unwrap(), None);
1780        }
1781
1782        #[tokio::test]
1783        async fn plaintext_file_stays_readable_and_is_sealed_on_next_write() {
1784            let dir = tempfile::tempdir().unwrap();
1785            // A pre-encryption bookmark written by an older config.
1786            let plain = FileStateStore::new(dir.path());
1787            plain.put("bk", &json!("legacy")).await.unwrap();
1788            let before = std::fs::read(dir.path().join("bk.json")).unwrap();
1789            assert!(!is_encrypted(&before));
1790
1791            let sealed = FileStateStore::new(dir.path()).with_encryption(enc("k1"));
1792            assert_eq!(sealed.get("bk").await.unwrap(), Some(json!("legacy")));
1793            sealed.put("bk", &json!("updated")).await.unwrap();
1794            let after = std::fs::read(dir.path().join("bk.json")).unwrap();
1795            assert!(is_encrypted(&after), "next write must seal the file");
1796            assert_eq!(sealed.get("bk").await.unwrap(), Some(json!("updated")));
1797        }
1798
1799        #[tokio::test]
1800        async fn wrong_key_is_a_typed_error_not_a_missing_bookmark() {
1801            let dir = tempfile::tempdir().unwrap();
1802            let a = FileStateStore::new(dir.path()).with_encryption(enc("right"));
1803            a.put("bk", &json!(1)).await.unwrap();
1804
1805            let b = FileStateStore::new(dir.path()).with_encryption(enc("wrong"));
1806            let err = b.get("bk").await.unwrap_err();
1807            assert!(matches!(err, FaucetError::State(_)));
1808            assert!(err.to_string().contains("could not be decrypted"), "{err}");
1809        }
1810
1811        #[tokio::test]
1812        async fn encrypted_file_with_unconfigured_store_is_a_typed_error() {
1813            let dir = tempfile::tempdir().unwrap();
1814            let sealed = FileStateStore::new(dir.path()).with_encryption(enc("k1"));
1815            sealed.put("bk", &json!(1)).await.unwrap();
1816
1817            let plain = FileStateStore::new(dir.path());
1818            let err = plain.get("bk").await.unwrap_err();
1819            assert!(err.to_string().contains("no `encryption` block"), "{err}");
1820        }
1821
1822        #[tokio::test]
1823        async fn rotation_reads_old_key_files() {
1824            let dir = tempfile::tempdir().unwrap();
1825            let old = FileStateStore::new(dir.path()).with_encryption(enc("old"));
1826            old.put("bk", &json!("v1")).await.unwrap();
1827
1828            let rotated = FileStateStore::new(dir.path()).with_encryption(
1829                CompiledEncryption::compile(&EncryptionSpec {
1830                    key: "new".into(),
1831                    previous_keys: vec!["old".into()],
1832                    algorithm: Default::default(),
1833                })
1834                .unwrap(),
1835            );
1836            assert_eq!(rotated.get("bk").await.unwrap(), Some(json!("v1")));
1837            // Re-seal under the new key; a store knowing only "new" can read it.
1838            rotated.put("bk", &json!("v2")).await.unwrap();
1839            let new_only = FileStateStore::new(dir.path()).with_encryption(enc("new"));
1840            assert_eq!(new_only.get("bk").await.unwrap(), Some(json!("v2")));
1841        }
1842
1843        #[tokio::test]
1844        async fn no_temp_files_left_behind() {
1845            let dir = tempfile::tempdir().unwrap();
1846            let store = FileStateStore::new(dir.path()).with_encryption(enc("k1"));
1847            store.put("bk", &json!(1)).await.unwrap();
1848            let leftovers: Vec<_> = std::fs::read_dir(dir.path())
1849                .unwrap()
1850                .filter_map(Result::ok)
1851                .filter(|e| e.path().to_string_lossy().ends_with(".tmp"))
1852                .collect();
1853            assert!(
1854                leftovers.is_empty(),
1855                "atomic write must leave no temp files"
1856            );
1857        }
1858    }
1859}
1860
1861#[cfg(test)]
1862mod list_export_tests {
1863    use super::*;
1864    use serde_json::json;
1865
1866    struct Bare(MemoryStateStore);
1867
1868    #[async_trait]
1869    impl StateStore for Bare {
1870        async fn get(&self, key: &str) -> Result<Option<Value>, FaucetError> {
1871            self.0.get(key).await
1872        }
1873        async fn put(&self, key: &str, value: &Value) -> Result<(), FaucetError> {
1874            if key.ends_with("::boom") {
1875                return Err(FaucetError::State("disk full".into()));
1876            }
1877            self.0.put(key, value).await
1878        }
1879        async fn delete(&self, key: &str) -> Result<(), FaucetError> {
1880            if key.ends_with("::stuck") {
1881                return Err(FaucetError::State("locked".into()));
1882            }
1883            self.0.delete(key).await
1884        }
1885    }
1886
1887    #[tokio::test]
1888    async fn defaults_cannot_list_and_batch_sequentially() {
1889        let s = Bare(MemoryStateStore::new());
1890        assert!(!s.supports_list());
1891        assert!(!s.supports_atomic_batch());
1892        assert!(s.list("p::").await.is_err());
1893        s.put_batch(&[("p::a".into(), json!(1)), ("p::b".into(), json!(2))])
1894            .await
1895            .unwrap();
1896        assert_eq!(s.get("p::b").await.unwrap(), Some(json!(2)));
1897        let err = s
1898            .put_batch(&[("p::c".into(), json!(1)), ("p::boom".into(), json!(2))])
1899            .await
1900            .unwrap_err();
1901        assert!(err.to_string().contains("disk full"));
1902        assert_eq!(
1903            s.get("p::c").await.unwrap(),
1904            Some(json!(1)),
1905            "prefix landed"
1906        );
1907    }
1908
1909    #[tokio::test]
1910    async fn memory_lists_by_prefix_sorted_and_batches_atomically() {
1911        let s = MemoryStateStore::new();
1912        for k in ["p::b", "p::a", "q::a", "p::a::__sla__"] {
1913            s.put(k, &json!(k)).await.unwrap();
1914        }
1915        assert!(s.supports_list());
1916        assert_eq!(
1917            s.list("p::").await.unwrap(),
1918            vec!["p::a", "p::a::__sla__", "p::b"]
1919        );
1920        assert!(s.supports_atomic_batch());
1921        let err = s
1922            .put_batch(&[("p::x".into(), json!(1)), ("../bad".into(), json!(2))])
1923            .await
1924            .unwrap_err();
1925        assert!(matches!(err, FaucetError::State(_)));
1926        assert!(s.get("p::x").await.unwrap().is_none(), "nothing written");
1927        s.put_batch(&[("p::x".into(), json!(1))]).await.unwrap();
1928        assert_eq!(s.get("p::x").await.unwrap(), Some(json!(1)));
1929    }
1930
1931    #[tokio::test]
1932    async fn file_lists_decoded_keys_ignoring_temp_and_foreign_files() {
1933        let dir = tempfile::tempdir().unwrap();
1934        let s = FileStateStore::new(dir.path());
1935        assert!(s.list("p::").await.unwrap().is_empty(), "missing root");
1936        for k in ["p::a", "p::a::__sla__", "acme/p::x", "q::a"] {
1937            s.put(k, &json!(1)).await.unwrap();
1938        }
1939        std::fs::write(dir.path().join("p%3A%3Az.json.tmp"), b"{}").unwrap();
1940        std::fs::write(dir.path().join("notes.txt"), b"x").unwrap();
1941        std::fs::write(dir.path().join(".hidden.json"), b"{}").unwrap();
1942        assert!(s.supports_list());
1943        assert_eq!(s.list("p::").await.unwrap(), vec!["p::a", "p::a::__sla__"]);
1944        assert_eq!(s.list("acme/").await.unwrap(), vec!["acme/p::x"]);
1945        assert!(!s.supports_atomic_batch());
1946    }
1947
1948    #[cfg(unix)]
1949    #[tokio::test]
1950    async fn file_list_reports_unreadable_root() {
1951        let dir = tempfile::tempdir().unwrap();
1952        let file = dir.path().join("not-a-dir");
1953        std::fs::write(&file, b"x").unwrap();
1954        let s = FileStateStore::new(&file);
1955        let err = s.list("p::").await.unwrap_err();
1956        assert!(
1957            err.to_string().contains("failed to list state dir"),
1958            "{err}"
1959        );
1960    }
1961
1962    #[test]
1963    fn export_document_parses_and_validates() {
1964        let ok = json!({
1965            "version": 1,
1966            "pipeline": "orders",
1967            "exported_at": "2026-09-26T00:00:00Z",
1968            "keys": {"orders::a": {"id": 3}, "orders::a::__sla__": {}}
1969        });
1970        let e = StateExport::from_value(ok).unwrap();
1971        assert_eq!(e.keys.len(), 2);
1972        assert_eq!(namespace_prefix("orders"), "orders::");
1973
1974        let cases = [
1975            (json!({"pipeline": "o", "keys": {}}), "no `version`"),
1976            (
1977                json!({"version": "1", "pipeline": "o"}),
1978                "must be an integer",
1979            ),
1980            (json!({"version": 2, "pipeline": "o"}), "not supported"),
1981            (json!({"version": 0, "pipeline": "o"}), "not supported"),
1982            (json!({"version": 1}), "malformed"),
1983            (json!({"version": 1, "pipeline": "../o"}), "pipeline name"),
1984            (
1985                json!({"version": 1, "pipeline": "o", "keys": {"other::a": 1}}),
1986                "outside the namespace",
1987            ),
1988            (
1989                json!({"version": 1, "pipeline": "o", "keys": {"o::a b": 1}}),
1990                "illegal character",
1991            ),
1992        ];
1993        for (doc, needle) in cases {
1994            let err = StateExport::from_value(doc.clone())
1995                .unwrap_err()
1996                .to_string();
1997            assert!(err.contains(needle), "{doc} → {err}");
1998        }
1999    }
2000
2001    #[tokio::test]
2002    async fn export_then_import_round_trips_and_replace_drops_stale_keys() {
2003        let from = MemoryStateStore::new();
2004        from.put("o::a", &json!({"c": 1})).await.unwrap();
2005        from.put("o::b", &json!(2)).await.unwrap();
2006        from.put("other::a", &json!(9)).await.unwrap();
2007        let export = export_namespace(&from, "o").await.unwrap();
2008        assert_eq!(export.keys.len(), 2);
2009
2010        let to = MemoryStateStore::new();
2011        to.put("o::stale", &json!(0)).await.unwrap();
2012        let r = import_namespace(&to, &export, false).await.unwrap();
2013        assert!(r.atomic && r.error.is_none());
2014        assert_eq!(r.written, vec!["o::a", "o::b"]);
2015        assert!(
2016            to.get("o::stale").await.unwrap().is_some(),
2017            "kept without replace"
2018        );
2019
2020        let r = import_namespace(&to, &export, true).await.unwrap();
2021        assert_eq!(r.deleted, vec!["o::stale"]);
2022        assert!(to.get("o::stale").await.unwrap().is_none());
2023        assert_eq!(to.get("o::a").await.unwrap(), Some(json!({"c": 1})));
2024    }
2025
2026    #[tokio::test]
2027    async fn import_reports_partial_progress_on_a_non_atomic_store() {
2028        let mut export = StateExport::new("o");
2029        export.keys.insert("o::a".into(), json!(1));
2030        export.keys.insert("o::boom".into(), json!(2));
2031        let s = Bare(MemoryStateStore::new());
2032        let r = import_namespace(&s, &export, false).await.unwrap();
2033        assert!(!r.atomic);
2034        assert_eq!(r.written, vec!["o::a"]);
2035        assert!(r.error.unwrap().contains("o::boom"));
2036
2037        let mut bad = StateExport::new("o");
2038        bad.keys.insert("x::a".into(), json!(1));
2039        assert!(import_namespace(&s, &bad, false).await.is_err());
2040    }
2041
2042    #[tokio::test]
2043    async fn import_reports_batch_and_delete_failures() {
2044        struct Failing;
2045        #[async_trait]
2046        impl StateStore for Failing {
2047            async fn get(&self, _: &str) -> Result<Option<Value>, FaucetError> {
2048                Ok(None)
2049            }
2050            async fn put(&self, _: &str, _: &Value) -> Result<(), FaucetError> {
2051                Ok(())
2052            }
2053            async fn delete(&self, _: &str) -> Result<(), FaucetError> {
2054                Ok(())
2055            }
2056            fn supports_atomic_batch(&self) -> bool {
2057                true
2058            }
2059            async fn put_batch(&self, _: &[(String, Value)]) -> Result<(), FaucetError> {
2060                Err(FaucetError::State("tx aborted".into()))
2061            }
2062        }
2063        assert_eq!(Failing.get("o::a").await.unwrap(), None);
2064        Failing.put("o::a", &json!(1)).await.unwrap();
2065        Failing.delete("o::a").await.unwrap();
2066        let mut export = StateExport::new("o");
2067        export.keys.insert("o::a".into(), json!(1));
2068        let r = import_namespace(&Failing, &export, false).await.unwrap();
2069        assert!(r.written.is_empty());
2070        assert!(r.error.unwrap().contains("tx aborted"));
2071
2072        // A replace needs to list the namespace first.
2073        assert!(import_namespace(&Failing, &export, true).await.is_err());
2074
2075        let s = Bare(MemoryStateStore::new());
2076        struct Listing(Bare);
2077        #[async_trait]
2078        impl StateStore for Listing {
2079            async fn get(&self, k: &str) -> Result<Option<Value>, FaucetError> {
2080                self.0.get(k).await
2081            }
2082            async fn put(&self, k: &str, v: &Value) -> Result<(), FaucetError> {
2083                self.0.put(k, v).await
2084            }
2085            async fn delete(&self, k: &str) -> Result<(), FaucetError> {
2086                self.0.delete(k).await
2087            }
2088            async fn list(&self, p: &str) -> Result<Vec<String>, FaucetError> {
2089                self.0.0.list(p).await
2090            }
2091        }
2092        s.put("o::stuck", &json!(1)).await.unwrap();
2093        let listing = Listing(s);
2094        assert_eq!(listing.get("o::stuck").await.unwrap(), Some(json!(1)));
2095        let r = import_namespace(&listing, &export, true).await.unwrap();
2096        assert_eq!(r.written, vec!["o::a"]);
2097        assert!(r.error.unwrap().contains("deleting stale 'o::stuck'"));
2098    }
2099}