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 [`put_batch`](Self::put_batch) commits all-or-nothing.
73    fn supports_atomic_batch(&self) -> bool {
74        false
75    }
76
77    /// Store every entry. When [`supports_atomic_batch`](Self::supports_atomic_batch)
78    /// is `true` either all entries land or none do; the default writes them
79    /// one by one, so an error can leave a prefix of `entries` written.
80    async fn put_batch(&self, entries: &[(String, Value)]) -> Result<(), FaucetError> {
81        for (key, value) in entries {
82            self.put(key, value).await?;
83        }
84        Ok(())
85    }
86}
87
88/// Version of the [`StateExport`] document this build writes and reads.
89pub const STATE_EXPORT_VERSION: u32 = 1;
90
91/// The key prefix every state key of pipeline `name` starts with.
92pub fn namespace_prefix(pipeline: &str) -> String {
93    format!("{pipeline}::")
94}
95
96/// A portable snapshot of one pipeline's durable state — every key under its
97/// namespace (`{pipeline}::…`) with its stored value, exactly as the store
98/// holds it (exactly-once envelopes included).
99///
100/// Serialized as `{ "version": 1, "pipeline": …, "exported_at": …, "keys": {…} }`.
101#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
102pub struct StateExport {
103    /// Document version — [`STATE_EXPORT_VERSION`] for documents this build writes.
104    pub version: u32,
105    /// The pipeline whose namespace the keys belong to.
106    pub pipeline: String,
107    /// When the snapshot was taken (RFC 3339), informational.
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub exported_at: Option<String>,
110    /// Every key and its stored value.
111    #[serde(default)]
112    pub keys: std::collections::BTreeMap<String, Value>,
113}
114
115impl StateExport {
116    /// A version-[`STATE_EXPORT_VERSION`] snapshot of `pipeline`.
117    pub fn new(pipeline: impl Into<String>) -> Self {
118        Self {
119            version: STATE_EXPORT_VERSION,
120            pipeline: pipeline.into(),
121            exported_at: None,
122            keys: Default::default(),
123        }
124    }
125
126    /// Parse and [`validate`](Self::validate) a document. A version newer than
127    /// this build understands is refused rather than half-read.
128    pub fn from_value(value: Value) -> Result<Self, FaucetError> {
129        let version = match value.get("version") {
130            None => {
131                return Err(FaucetError::State(
132                    "state export has no `version` field".into(),
133                ));
134            }
135            Some(v) => v.as_u64().ok_or_else(|| {
136                FaucetError::State(format!(
137                    "state export `version` must be an integer, got {v}"
138                ))
139            })?,
140        };
141        if version == 0 || version > u64::from(STATE_EXPORT_VERSION) {
142            return Err(FaucetError::State(format!(
143                "state export version {version} is not supported by this build (supported: \
144                 {STATE_EXPORT_VERSION}) — import it with the faucet release that wrote it or newer"
145            )));
146        }
147        let export: Self = serde_json::from_value(value)
148            .map_err(|e| FaucetError::State(format!("malformed state export: {e}")))?;
149        export.validate()?;
150        Ok(export)
151    }
152
153    /// Every key must be a valid state key inside the pipeline's namespace.
154    pub fn validate(&self) -> Result<(), FaucetError> {
155        validate_state_key(&self.pipeline)
156            .map_err(|e| FaucetError::State(format!("state export pipeline name: {e}")))?;
157        let ns = namespace_prefix(&self.pipeline);
158        for key in self.keys.keys() {
159            validate_state_key(key)?;
160            if !key.starts_with(&ns) {
161                return Err(FaucetError::State(format!(
162                    "state export key '{key}' is outside the namespace '{ns}' of pipeline '{}'",
163                    self.pipeline
164                )));
165            }
166        }
167        Ok(())
168    }
169}
170
171/// Read every key under `pipeline`'s namespace into a [`StateExport`].
172pub async fn export_namespace(
173    store: &dyn StateStore,
174    pipeline: &str,
175) -> Result<StateExport, FaucetError> {
176    let mut export = StateExport::new(pipeline);
177    for key in store.list(&namespace_prefix(pipeline)).await? {
178        if let Some(value) = store.get(&key).await? {
179            export.keys.insert(key, value);
180        }
181    }
182    Ok(export)
183}
184
185/// What [`import_namespace`] did.
186#[derive(Debug, Clone, Default, PartialEq, serde::Serialize)]
187pub struct ImportReport {
188    /// Keys written, in write order.
189    pub written: Vec<String>,
190    /// Keys that existed in the namespace but not in the export and were
191    /// removed (only with `replace`).
192    pub deleted: Vec<String>,
193    /// Whether the writes went through one all-or-nothing batch.
194    pub atomic: bool,
195    /// The first failure, if any; with a non-atomic store `written` then lists
196    /// exactly what landed before it.
197    #[serde(skip_serializing_if = "Option::is_none")]
198    pub error: Option<String>,
199}
200
201/// Write `export` into `store`. With `replace`, keys already in the namespace
202/// that the export does not hold are deleted afterwards, so the namespace ends
203/// up exactly as exported. Validates the document first.
204pub async fn import_namespace(
205    store: &dyn StateStore,
206    export: &StateExport,
207    replace: bool,
208) -> Result<ImportReport, FaucetError> {
209    export.validate()?;
210    let entries: Vec<(String, Value)> = export
211        .keys
212        .iter()
213        .map(|(k, v)| (k.clone(), v.clone()))
214        .collect();
215    let stale: Vec<String> = if replace {
216        store
217            .list(&namespace_prefix(&export.pipeline))
218            .await?
219            .into_iter()
220            .filter(|k| !export.keys.contains_key(k))
221            .collect()
222    } else {
223        Vec::new()
224    };
225    let mut report = ImportReport {
226        atomic: store.supports_atomic_batch(),
227        ..Default::default()
228    };
229    if report.atomic {
230        if let Err(e) = store.put_batch(&entries).await {
231            report.error = Some(e.to_string());
232            return Ok(report);
233        }
234        report.written = entries.into_iter().map(|(k, _)| k).collect();
235    } else {
236        for (key, value) in &entries {
237            if let Err(e) = store.put(key, value).await {
238                report.error = Some(format!("writing '{key}': {e}"));
239                return Ok(report);
240            }
241            report.written.push(key.clone());
242        }
243    }
244    for key in stale {
245        if let Err(e) = store.delete(&key).await {
246            report.error = Some(format!("deleting stale '{key}': {e}"));
247            return Ok(report);
248        }
249        report.deleted.push(key);
250    }
251    Ok(report)
252}
253
254/// Sentinel key used by state-store `check()` probes. Valid per
255/// [`validate_state_key`] and deleted after the probe so it leaves no residue.
256pub const DOCTOR_SENTINEL_KEY: &str = "faucet_doctor_probe";
257
258/// Reject keys that could escape the storage namespace or break filename
259/// rules on common filesystems. Allowed: ASCII letters, digits, `_`, `-`,
260/// `:`, `.`. Empty keys are rejected.
261pub fn validate_state_key(key: &str) -> Result<(), FaucetError> {
262    if key.is_empty() {
263        return Err(FaucetError::State("state key must not be empty".into()));
264    }
265    if key.len() > 256 {
266        return Err(FaucetError::State(format!(
267            "state key '{key}' exceeds 256 characters"
268        )));
269    }
270    for (i, c) in key.char_indices() {
271        let ok = c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | ':' | '.' | '/');
272        if !ok {
273            return Err(FaucetError::State(format!(
274                "state key '{key}' contains illegal character {c:?} at byte {i}"
275            )));
276        }
277    }
278    if key == "." || key == ".." || key.starts_with('.') {
279        return Err(FaucetError::State(format!(
280            "state key '{key}' must not begin with a dot"
281        )));
282    }
283    // `/` separates a hub owner namespace from a name (`acme/erp::…`);
284    // it must never read as a path: no empty, `.` or `..` segments, no
285    // leading or trailing separator.
286    if key.contains('/')
287        && key
288            .split('/')
289            .any(|seg| seg.is_empty() || seg == "." || seg == ".." || seg.starts_with('.'))
290    {
291        return Err(FaucetError::State(format!(
292            "state key '{key}' has a path-like segment — `/` may only separate non-empty, non-dot segments"
293        )));
294    }
295    Ok(())
296}
297
298// ── MemoryStateStore ────────────────────────────────────────────────────────
299
300/// In-memory `StateStore` for tests and ephemeral pipelines.
301#[derive(Default)]
302pub struct MemoryStateStore {
303    inner: Mutex<HashMap<String, Value>>,
304}
305
306impl MemoryStateStore {
307    /// Create an empty in-memory store.
308    pub fn new() -> Self {
309        Self::default()
310    }
311}
312
313#[async_trait]
314impl StateStore for MemoryStateStore {
315    async fn get(&self, key: &str) -> Result<Option<Value>, FaucetError> {
316        validate_state_key(key)?;
317        Ok(self.inner.lock().await.get(key).cloned())
318    }
319
320    async fn put(&self, key: &str, value: &Value) -> Result<(), FaucetError> {
321        validate_state_key(key)?;
322        self.inner
323            .lock()
324            .await
325            .insert(key.to_owned(), value.clone());
326        Ok(())
327    }
328
329    async fn delete(&self, key: &str) -> Result<(), FaucetError> {
330        validate_state_key(key)?;
331        self.inner.lock().await.remove(key);
332        Ok(())
333    }
334
335    fn supports_list(&self) -> bool {
336        true
337    }
338
339    async fn list(&self, prefix: &str) -> Result<Vec<String>, FaucetError> {
340        let mut keys: Vec<String> = self
341            .inner
342            .lock()
343            .await
344            .keys()
345            .filter(|k| k.starts_with(prefix))
346            .cloned()
347            .collect();
348        keys.sort();
349        Ok(keys)
350    }
351
352    fn supports_atomic_batch(&self) -> bool {
353        true
354    }
355
356    async fn put_batch(&self, entries: &[(String, Value)]) -> Result<(), FaucetError> {
357        for (key, _) in entries {
358            validate_state_key(key)?;
359        }
360        let mut map = self.inner.lock().await;
361        for (key, value) in entries {
362            map.insert(key.clone(), value.clone());
363        }
364        Ok(())
365    }
366
367    async fn check(
368        &self,
369        _ctx: &crate::check::CheckContext,
370    ) -> Result<crate::check::CheckReport, FaucetError> {
371        // In-process map — always reachable.
372        Ok(crate::check::CheckReport::single(
373            crate::check::Probe::pass("sentinel", std::time::Duration::ZERO),
374        ))
375    }
376}
377
378// ── FileStateStore ──────────────────────────────────────────────────────────
379
380/// Map a logical state key to a filesystem-safe filename stem. The key
381/// grammar allows `:` (used by the documented `pipeline:rest:issues`
382/// convention), but `:` is illegal in a filename on Windows/NTFS, so it is
383/// percent-encoded as `%3A`. This is collision-free because `%` can never
384/// appear in a valid key (see [`validate_state_key`]) (#78 LOW).
385fn safe_filename(key: &str) -> String {
386    key.replace(':', "%3A").replace('/', "%2F")
387}
388
389/// Inverse of [`safe_filename`]; `None` when the stem is not a valid key.
390fn key_from_filename(stem: &str) -> Option<String> {
391    let key = stem.replace("%3A", ":").replace("%2F", "/");
392    validate_state_key(&key).ok().map(|()| key)
393}
394
395/// File-backed `StateStore`. Each key maps to a JSON file at
396/// `{root}/{safe_filename(key)}.json`, written via atomic rename. The
397/// filename stem percent-encodes `:` as `%3A` so keys using the
398/// `pipeline:rest:issues` convention are valid on Windows.
399///
400/// Each `put` writes a temp file, fsyncs it, renames it over the final path,
401/// and (on Unix) fsyncs the parent directory — so once `put` returns the
402/// bookmark is durable across a crash or power loss, not merely sitting in the
403/// page cache.
404///
405/// The store creates its root directory on first use. All writes are
406/// serialized through a per-store mutex so two concurrent `put`s for the
407/// same key cannot race on the temp filename. Different processes pointing
408/// at the same directory rely on the filesystem's atomic rename guarantee.
409pub struct FileStateStore {
410    root: PathBuf,
411    write_lock: Mutex<()>,
412    /// Optional at-rest encryption (#207). When set, `put` seals the JSON
413    /// bytes before the atomic write and `get` unseals; plaintext files
414    /// remain readable (and are sealed on their next write).
415    #[cfg(feature = "encryption")]
416    encryption: Option<crate::encryption::CompiledEncryption>,
417}
418
419impl FileStateStore {
420    /// Open or create a file-backed state store rooted at `root`.
421    pub fn new(root: impl Into<PathBuf>) -> Self {
422        Self {
423            root: root.into(),
424            write_lock: Mutex::new(()),
425            #[cfg(feature = "encryption")]
426            encryption: None,
427        }
428    }
429
430    /// Seal bookmark files at rest with the given policy (#207). Reads accept
431    /// both sealed and (legacy) plaintext files; every write seals.
432    #[cfg(feature = "encryption")]
433    pub fn with_encryption(mut self, encryption: crate::encryption::CompiledEncryption) -> Self {
434        self.encryption = Some(encryption);
435        self
436    }
437
438    fn entry_path(&self, key: &str) -> PathBuf {
439        self.root.join(format!("{}.json", safe_filename(key)))
440    }
441
442    fn temp_path(&self, key: &str) -> PathBuf {
443        // Unique per write: a per-process random token + a monotonic counter, so
444        // two writers (different processes sharing the directory, or two store
445        // instances in one process) never share a temp file. A *fixed* temp name
446        // let a second writer `File::create`-truncate the first's half-written
447        // temp, which the first could then `rename` over the final path —
448        // yielding torn/truncated state JSON that breaks resume (audit #146 H10).
449        //
450        // The token MUST NOT be derived from the process id: containers on a
451        // shared NFS/EFS/host volume routinely reuse the same PID (each starts at
452        // PID 1), so a PID + per-process counter collides across processes and
453        // reintroduces exactly that torn-bookmark corruption (F50). A random v4
454        // UUID minted once per process is unguessable and collision-free across
455        // processes regardless of PID; the counter keeps writers within one
456        // process distinct. The per-store `write_lock` only serializes writers
457        // within one process. Orphaned `.tmp` files from an interrupted write are
458        // harmless: `get` only ever reads the final `.json` path.
459        use std::sync::OnceLock;
460        use std::sync::atomic::{AtomicU64, Ordering};
461        static PROC_TOKEN: OnceLock<String> = OnceLock::new();
462        static SEQ: AtomicU64 = AtomicU64::new(0);
463        let token = PROC_TOKEN.get_or_init(|| uuid::Uuid::new_v4().simple().to_string());
464        let seq = SEQ.fetch_add(1, Ordering::Relaxed);
465        self.root
466            .join(format!("{}.{}.{}.json.tmp", safe_filename(key), token, seq))
467    }
468
469    async fn ensure_root(&self) -> Result<(), FaucetError> {
470        tokio::fs::create_dir_all(&self.root).await.map_err(|e| {
471            FaucetError::State(format!(
472                "failed to create state dir {}: {e}",
473                self.root.display()
474            ))
475        })
476    }
477
478    /// Returns the root directory this store writes into.
479    pub fn root(&self) -> &Path {
480        &self.root
481    }
482}
483
484#[async_trait]
485impl StateStore for FileStateStore {
486    async fn get(&self, key: &str) -> Result<Option<Value>, FaucetError> {
487        validate_state_key(key)?;
488        let path = self.entry_path(key);
489        match tokio::fs::read(&path).await {
490            Ok(bytes) => {
491                #[cfg(feature = "encryption")]
492                let bytes: Vec<u8> = if crate::encryption::is_encrypted(&bytes) {
493                    match &self.encryption {
494                        Some(enc) => enc.decrypt(&bytes).map_err(|e| {
495                            // Wrong/rotated key must be a loud, typed error —
496                            // treating it as "no bookmark" would silently
497                            // trigger a full re-sync.
498                            FaucetError::State(format!(
499                                "state file {} could not be decrypted: {e}",
500                                path.display()
501                            ))
502                        })?,
503                        None => {
504                            return Err(FaucetError::State(format!(
505                                "state file {} is encrypted but no `encryption` block is \
506                                 configured on the file state store — add \
507                                 `state.config.encryption` with the original key",
508                                path.display()
509                            )));
510                        }
511                    }
512                } else {
513                    bytes
514                };
515                #[cfg(not(feature = "encryption"))]
516                let bytes = {
517                    // Built without the `encryption` feature: refuse to parse a
518                    // sealed file as JSON garbage.
519                    if bytes.starts_with(b"FCT1") {
520                        return Err(FaucetError::State(format!(
521                            "state file {} is encrypted but this build of faucet has no \
522                             `encryption` feature",
523                            path.display()
524                        )));
525                    }
526                    bytes
527                };
528                let value: Value = serde_json::from_slice(&bytes).map_err(|e| {
529                    FaucetError::State(format!(
530                        "failed to parse state file {}: {e}",
531                        path.display()
532                    ))
533                })?;
534                Ok(Some(value))
535            }
536            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
537            Err(e) => Err(FaucetError::State(format!(
538                "failed to read state file {}: {e}",
539                path.display()
540            ))),
541        }
542    }
543
544    async fn put(&self, key: &str, value: &Value) -> Result<(), FaucetError> {
545        validate_state_key(key)?;
546        let _guard = self.write_lock.lock().await;
547        self.ensure_root().await?;
548        let bytes = serde_json::to_vec(value).map_err(|e| {
549            FaucetError::State(format!("failed to serialize state for key '{key}': {e}"))
550        })?;
551        #[cfg(feature = "encryption")]
552        let bytes = match &self.encryption {
553            Some(enc) => enc.encrypt(&bytes),
554            None => bytes,
555        };
556        let final_path = self.entry_path(key);
557        let tmp_path = self.temp_path(key);
558
559        // Write the temp file and fsync it BEFORE the rename. `fs::write`
560        // alone leaves the bytes in the page cache: a crash after `put`
561        // returns could surface a zero-length or stale file, breaking the
562        // durability guarantee this store documents (#78/#8). `sync_all`
563        // flushes the file's data and metadata to disk.
564        {
565            let mut file = tokio::fs::File::create(&tmp_path).await.map_err(|e| {
566                FaucetError::State(format!(
567                    "failed to create temp state file {}: {e}",
568                    tmp_path.display()
569                ))
570            })?;
571            file.write_all(&bytes).await.map_err(|e| {
572                FaucetError::State(format!(
573                    "failed to write temp state file {}: {e}",
574                    tmp_path.display()
575                ))
576            })?;
577            file.sync_all().await.map_err(|e| {
578                FaucetError::State(format!(
579                    "failed to fsync temp state file {}: {e}",
580                    tmp_path.display()
581                ))
582            })?;
583        }
584
585        tokio::fs::rename(&tmp_path, &final_path)
586            .await
587            .map_err(|e| {
588                FaucetError::State(format!(
589                    "failed to commit state file {}: {e}",
590                    final_path.display()
591                ))
592            })?;
593
594        // fsync the parent directory so the rename itself is durable — an
595        // atomic rename can still be lost on crash if the directory entry was
596        // never flushed. Directory fsync is a POSIX concept; on platforms that
597        // don't allow opening a directory as a file (e.g. Windows) it is
598        // skipped.
599        #[cfg(unix)]
600        {
601            let dir = tokio::fs::File::open(&self.root).await.map_err(|e| {
602                FaucetError::State(format!(
603                    "failed to open state dir {} for fsync: {e}",
604                    self.root.display()
605                ))
606            })?;
607            dir.sync_all().await.map_err(|e| {
608                FaucetError::State(format!(
609                    "failed to fsync state dir {}: {e}",
610                    self.root.display()
611                ))
612            })?;
613        }
614
615        tracing::debug!(
616            key,
617            path = %final_path.display(),
618            "state file written"
619        );
620        Ok(())
621    }
622
623    async fn delete(&self, key: &str) -> Result<(), FaucetError> {
624        validate_state_key(key)?;
625        let path = self.entry_path(key);
626        match tokio::fs::remove_file(&path).await {
627            Ok(()) => Ok(()),
628            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
629            Err(e) => Err(FaucetError::State(format!(
630                "failed to delete state file {}: {e}",
631                path.display()
632            ))),
633        }
634    }
635
636    fn supports_list(&self) -> bool {
637        true
638    }
639
640    async fn list(&self, prefix: &str) -> Result<Vec<String>, FaucetError> {
641        let mut dir = match tokio::fs::read_dir(&self.root).await {
642            Ok(d) => d,
643            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
644            Err(e) => {
645                return Err(FaucetError::State(format!(
646                    "failed to list state dir {}: {e}",
647                    self.root.display()
648                )));
649            }
650        };
651        let mut keys = Vec::new();
652        loop {
653            let entry = dir.next_entry().await.map_err(|e| {
654                FaucetError::State(format!(
655                    "failed to list state dir {}: {e}",
656                    self.root.display()
657                ))
658            })?;
659            let Some(entry) = entry else { break };
660            let name = entry.file_name();
661            let Some(stem) = name.to_str().and_then(|n| n.strip_suffix(".json")) else {
662                continue;
663            };
664            if let Some(key) = key_from_filename(stem)
665                && key.starts_with(prefix)
666            {
667                keys.push(key);
668            }
669        }
670        keys.sort();
671        Ok(keys)
672    }
673
674    async fn check(
675        &self,
676        _ctx: &crate::check::CheckContext,
677    ) -> Result<crate::check::CheckReport, FaucetError> {
678        use crate::check::{CheckReport, Probe};
679        // Exercise the real put → get → delete cycle on a sentinel key. `put`
680        // creates the root dir if needed, so this validates "dir exists +
681        // writable" via the actual code path and leaves no residue.
682        let start = std::time::Instant::now();
683        let probe = match self.sentinel_roundtrip().await {
684            Ok(()) => Probe::pass("sentinel", start.elapsed()),
685            Err(e) => Probe::fail_hint(
686                "sentinel",
687                start.elapsed(),
688                e.to_string(),
689                format!("ensure {} exists and is writable", self.root.display()),
690            ),
691        };
692        Ok(CheckReport::single(probe))
693    }
694}
695
696impl FileStateStore {
697    /// Write, read back, and delete a sentinel key — the body of the `check()`
698    /// probe, factored out so the happy path stays linear.
699    async fn sentinel_roundtrip(&self) -> Result<(), FaucetError> {
700        let probe = serde_json::json!({ "faucet_doctor": true });
701        self.put(DOCTOR_SENTINEL_KEY, &probe).await?;
702        let got = self.get(DOCTOR_SENTINEL_KEY).await?;
703        // Best-effort cleanup regardless of the read result.
704        let _ = self.delete(DOCTOR_SENTINEL_KEY).await;
705        match got {
706            Some(v) if v == probe => Ok(()),
707            _ => Err(FaucetError::State(
708                "sentinel readback did not match what was written".into(),
709            )),
710        }
711    }
712}
713
714#[cfg(test)]
715mod tests {
716    use super::*;
717    use serde_json::json;
718    use std::sync::Arc;
719    use tempfile::TempDir;
720
721    // ── key validation ─────────────────────────────────────────────────────
722
723    #[test]
724    fn rejects_empty_key() {
725        let err = validate_state_key("").unwrap_err();
726        assert!(matches!(err, FaucetError::State(_)));
727    }
728
729    #[test]
730    fn rejects_path_traversal_segments() {
731        for k in [
732            "../etc/passwd",
733            "a/../b",
734            "a/./b",
735            "a//b",
736            "/a",
737            "a/",
738            "a/.x",
739            "a\\b",
740            "..",
741            ".",
742        ] {
743            assert!(validate_state_key(k).is_err(), "expected reject for {k:?}");
744        }
745        // A `/` that separates a hub owner from a name is fine (#682).
746        assert!(validate_state_key("acme/erp::invoices").is_ok());
747    }
748
749    #[test]
750    fn rejects_leading_dot() {
751        assert!(validate_state_key(".hidden").is_err());
752    }
753
754    #[test]
755    fn rejects_over_long_key() {
756        let k = "a".repeat(257);
757        assert!(validate_state_key(&k).is_err());
758    }
759
760    #[test]
761    fn accepts_typical_keys() {
762        for k in [
763            "github_issues",
764            "pipeline:rest:issues",
765            "with.dot",
766            "with-dash_and_underscore",
767            "lower-Case_99",
768        ] {
769            validate_state_key(k).unwrap_or_else(|e| panic!("expected ok for {k:?}: {e}"));
770        }
771    }
772
773    // ── MemoryStateStore ────────────────────────────────────────────────────
774
775    #[tokio::test]
776    async fn memory_get_returns_none_for_missing_key() {
777        let s = MemoryStateStore::new();
778        assert!(s.get("nope").await.unwrap().is_none());
779    }
780
781    #[tokio::test]
782    async fn memory_put_then_get_round_trips() {
783        let s = MemoryStateStore::new();
784        s.put("k", &json!({"cursor": "abc", "n": 7})).await.unwrap();
785        let got = s.get("k").await.unwrap().unwrap();
786        assert_eq!(got["cursor"], "abc");
787        assert_eq!(got["n"], 7);
788    }
789
790    #[tokio::test]
791    async fn memory_put_overwrites_previous_value() {
792        let s = MemoryStateStore::new();
793        s.put("k", &json!(1)).await.unwrap();
794        s.put("k", &json!(2)).await.unwrap();
795        assert_eq!(s.get("k").await.unwrap().unwrap(), json!(2));
796    }
797
798    #[tokio::test]
799    async fn memory_delete_makes_get_return_none() {
800        let s = MemoryStateStore::new();
801        s.put("k", &json!("v")).await.unwrap();
802        s.delete("k").await.unwrap();
803        assert!(s.get("k").await.unwrap().is_none());
804    }
805
806    #[tokio::test]
807    async fn memory_delete_missing_key_is_ok() {
808        let s = MemoryStateStore::new();
809        s.delete("absent").await.unwrap();
810    }
811
812    #[tokio::test]
813    async fn memory_rejects_invalid_keys() {
814        let s = MemoryStateStore::new();
815        assert!(s.get("a b").await.is_err());
816        assert!(s.put("a b", &json!(1)).await.is_err());
817        assert!(s.delete("a b").await.is_err());
818    }
819
820    // ── FileStateStore ──────────────────────────────────────────────────────
821
822    #[tokio::test]
823    async fn file_get_returns_none_for_missing_key() {
824        let dir = TempDir::new().unwrap();
825        let s = FileStateStore::new(dir.path());
826        assert!(s.get("nope").await.unwrap().is_none());
827    }
828
829    #[tokio::test]
830    async fn file_put_creates_root_directory_lazily() {
831        let dir = TempDir::new().unwrap();
832        let root = dir.path().join("nested/state");
833        let s = FileStateStore::new(&root);
834        s.put("k", &json!("v")).await.unwrap();
835        assert!(root.is_dir(), "root dir should be created on first put");
836    }
837
838    #[tokio::test]
839    async fn file_put_then_get_round_trips() {
840        let dir = TempDir::new().unwrap();
841        let s = FileStateStore::new(dir.path());
842        let value = json!({"cursor": "abc", "n": 42, "nested": {"flag": true}});
843        s.put("github_issues", &value).await.unwrap();
844        let got = s.get("github_issues").await.unwrap().unwrap();
845        assert_eq!(got, value);
846    }
847
848    #[test]
849    fn temp_path_is_unique_and_not_pid_derived() {
850        // Regression for F50: the temp filename must not embed the process id.
851        // Containers on a shared volume reuse PIDs (each starts at PID 1), so a
852        // PID + per-process counter collides across processes and reintroduces
853        // the torn-bookmark corruption the unique-temp scheme prevents. The
854        // token is a per-process random UUID instead.
855        let dir = TempDir::new().unwrap();
856        let s = FileStateStore::new(dir.path());
857
858        let a = s.temp_path("k");
859        let b = s.temp_path("k");
860        // Distinct temp paths within one process (the monotonic counter differs).
861        assert_ne!(a, b);
862
863        let name_a = a.file_name().unwrap().to_str().unwrap();
864        let pid = std::process::id().to_string();
865        // The PID must NOT appear as a dotted segment of the temp name.
866        assert!(
867            !name_a.split('.').any(|seg| seg == pid),
868            "temp filename {name_a} must not embed the process id ({pid})"
869        );
870        assert!(name_a.ends_with(".json.tmp"));
871    }
872
873    #[test]
874    fn safe_filename_percent_encodes_colon() {
875        assert_eq!(
876            safe_filename("pipeline:rest:issues"),
877            "pipeline%3Arest%3Aissues"
878        );
879        assert_eq!(safe_filename("plain_key-1.v2"), "plain_key-1.v2");
880        // Owner-scoped hub ids (`acme/erp::invoices`) must not become
881        // nested directories.
882        assert_eq!(
883            safe_filename("acme/erp::invoices"),
884            "acme%2Ferp%3A%3Ainvoices"
885        );
886        assert!(validate_state_key("acme/erp::invoices").is_ok());
887        assert!(validate_state_key("a b").is_err());
888    }
889
890    #[tokio::test]
891    async fn file_round_trips_colon_keys_with_safe_filename() {
892        // Regression for #78 LOW: the documented `pipeline:rest:issues` key
893        // convention must round-trip and produce a Windows-legal filename
894        // (no `:` on disk).
895        let dir = TempDir::new().unwrap();
896        let s = FileStateStore::new(dir.path());
897        let value = json!({"cursor": "z"});
898        s.put("pipeline:rest:issues", &value).await.unwrap();
899        assert_eq!(s.get("pipeline:rest:issues").await.unwrap().unwrap(), value);
900        // On-disk filename must not contain a colon.
901        assert!(dir.path().join("pipeline%3Arest%3Aissues.json").exists());
902        let mut has_colon = false;
903        for entry in std::fs::read_dir(dir.path()).unwrap() {
904            if entry.unwrap().file_name().to_string_lossy().contains(':') {
905                has_colon = true;
906            }
907        }
908        assert!(!has_colon, "no state filename may contain ':'");
909    }
910
911    /// True if any `.json.tmp` residue remains in `dir`. Temp names are unique
912    /// per write since #146 H10, so we glob for the suffix rather than check a
913    /// fixed name (which would never exist now and pass vacuously).
914    fn has_tmp_residue(dir: &std::path::Path) -> bool {
915        std::fs::read_dir(dir)
916            .unwrap()
917            .filter_map(|e| e.ok())
918            .any(|e| e.file_name().to_string_lossy().ends_with(".json.tmp"))
919    }
920
921    #[tokio::test]
922    async fn file_put_overwrites_previous_value_atomically() {
923        let dir = TempDir::new().unwrap();
924        let s = FileStateStore::new(dir.path());
925        s.put("k", &json!({"v": 1})).await.unwrap();
926        s.put("k", &json!({"v": 2})).await.unwrap();
927        assert_eq!(s.get("k").await.unwrap().unwrap(), json!({"v": 2}));
928        // No temp file left behind.
929        assert!(!has_tmp_residue(dir.path()), "no temp residue after put");
930    }
931
932    #[test]
933    fn file_temp_paths_are_unique_per_write() {
934        // H10 (audit #146): the temp path must be unique per write so two
935        // writers (different processes, or two store instances in one process
936        // with independent write_locks) never `File::create`-truncate a shared
937        // temp that the other then renames over the final file (torn state).
938        let dir = TempDir::new().unwrap();
939        let s = FileStateStore::new(dir.path());
940        let a = s.temp_path("k");
941        let b = s.temp_path("k");
942        assert_ne!(a, b, "each write must get a distinct temp path");
943        // The committed (final) path stays stable across writes.
944        assert_eq!(s.entry_path("k"), s.entry_path("k"));
945    }
946
947    #[tokio::test]
948    async fn file_put_writes_complete_durable_file_with_no_temp_residue() {
949        // Regression for #78/#8. `put` must produce a fully-written, parseable
950        // file and leave no temp file behind. (The fsync that makes this
951        // durable across a power loss can't be observed on a healthy
952        // filesystem, but a regression in the write/rename path — truncation,
953        // a leftover .tmp, or an unwritten file — is caught here.) A large
954        // payload makes a partial/unflushed write detectable on read-back.
955        let dir = TempDir::new().unwrap();
956        let s = FileStateStore::new(dir.path());
957        let big: Vec<Value> = (0..1_000)
958            .map(|i| json!({"i": i, "s": "x".repeat(20)}))
959            .collect();
960        let value = json!({"cursor": "abc", "rows": big});
961
962        s.put("github_issues", &value).await.unwrap();
963
964        // Read the raw file directly (bypassing get) to confirm it is complete.
965        let raw = tokio::fs::read(dir.path().join("github_issues.json"))
966            .await
967            .expect("state file must exist after put");
968        assert!(!raw.is_empty(), "state file must not be zero-length");
969        let parsed: Value = serde_json::from_slice(&raw).expect("state file must be valid JSON");
970        assert_eq!(parsed, value);
971
972        // No temp file left behind.
973        assert!(!has_tmp_residue(dir.path()), "no temp residue after put");
974    }
975
976    #[tokio::test]
977    async fn file_delete_removes_file() {
978        let dir = TempDir::new().unwrap();
979        let s = FileStateStore::new(dir.path());
980        s.put("k", &json!("v")).await.unwrap();
981        s.delete("k").await.unwrap();
982        assert!(s.get("k").await.unwrap().is_none());
983        assert!(!dir.path().join("k.json").exists());
984    }
985
986    #[tokio::test]
987    async fn file_delete_missing_key_is_ok() {
988        let dir = TempDir::new().unwrap();
989        let s = FileStateStore::new(dir.path());
990        s.delete("absent").await.unwrap();
991    }
992
993    #[tokio::test]
994    async fn file_get_returns_error_for_corrupt_json() {
995        let dir = TempDir::new().unwrap();
996        let s = FileStateStore::new(dir.path());
997        tokio::fs::create_dir_all(dir.path()).await.unwrap();
998        tokio::fs::write(dir.path().join("bad.json"), b"not json")
999            .await
1000            .unwrap();
1001        let err = s.get("bad").await.unwrap_err();
1002        match err {
1003            FaucetError::State(msg) => assert!(msg.contains("bad.json")),
1004            other => panic!("expected State error, got {other:?}"),
1005        }
1006    }
1007
1008    #[tokio::test]
1009    async fn file_concurrent_puts_do_not_corrupt_or_leak_temp() {
1010        let dir = TempDir::new().unwrap();
1011        let s = Arc::new(FileStateStore::new(dir.path()));
1012        let mut handles = vec![];
1013        for i in 0..50 {
1014            let s = Arc::clone(&s);
1015            handles.push(tokio::spawn(async move {
1016                s.put("k", &json!({"i": i})).await.unwrap();
1017            }));
1018        }
1019        for h in handles {
1020            h.await.unwrap();
1021        }
1022        // The final value must be one of the 50 we wrote.
1023        let got = s.get("k").await.unwrap().unwrap();
1024        let i = got["i"].as_i64().unwrap();
1025        assert!((0..50).contains(&i));
1026        // And no temp file should remain.
1027        assert!(
1028            !has_tmp_residue(dir.path()),
1029            "no temp residue after concurrent puts"
1030        );
1031    }
1032
1033    #[tokio::test]
1034    async fn file_store_works_through_trait_object() {
1035        let dir = TempDir::new().unwrap();
1036        let s: Box<dyn StateStore> = Box::new(FileStateStore::new(dir.path()));
1037        s.put("k", &json!(1)).await.unwrap();
1038        assert_eq!(s.get("k").await.unwrap().unwrap(), json!(1));
1039    }
1040
1041    // ── check() ──────────────────────────────────────────────────────────────
1042
1043    #[tokio::test]
1044    async fn memory_check_passes() {
1045        let s = MemoryStateStore::new();
1046        let report = s
1047            .check(&crate::check::CheckContext::default())
1048            .await
1049            .unwrap();
1050        assert_eq!(report.failed_count(), 0);
1051        assert!(
1052            report
1053                .probes
1054                .iter()
1055                .all(|p| matches!(p.status, crate::check::ProbeStatus::Pass))
1056        );
1057    }
1058
1059    #[tokio::test]
1060    async fn file_check_passes_for_writable_root() {
1061        let dir = TempDir::new().unwrap();
1062        let s = FileStateStore::new(dir.path());
1063        let report = s
1064            .check(&crate::check::CheckContext::default())
1065            .await
1066            .unwrap();
1067        assert_eq!(report.failed_count(), 0, "writable root should pass");
1068        // The sentinel probe must leave no residue.
1069        let leftovers: Vec<_> = std::fs::read_dir(dir.path()).unwrap().collect();
1070        assert!(leftovers.is_empty(), "check() must not leave files behind");
1071    }
1072
1073    #[tokio::test]
1074    async fn file_store_root_returns_configured_directory() {
1075        let dir = TempDir::new().unwrap();
1076        let s = FileStateStore::new(dir.path());
1077        assert_eq!(s.root(), dir.path());
1078    }
1079
1080    #[tokio::test]
1081    async fn default_check_reports_not_implemented() {
1082        // A StateStore that does not override `check()` falls back to the
1083        // trait default, which yields a single not-implemented (skipped) probe.
1084        struct BareStore;
1085        #[async_trait]
1086        impl StateStore for BareStore {
1087            async fn get(&self, _key: &str) -> Result<Option<Value>, FaucetError> {
1088                Ok(None)
1089            }
1090            async fn put(&self, _key: &str, _value: &Value) -> Result<(), FaucetError> {
1091                Ok(())
1092            }
1093            async fn delete(&self, _key: &str) -> Result<(), FaucetError> {
1094                Ok(())
1095            }
1096        }
1097        let s = BareStore;
1098        let report = s
1099            .check(&crate::check::CheckContext::default())
1100            .await
1101            .unwrap();
1102        // not_implemented() reports no failures and is not a pass.
1103        assert_eq!(report.failed_count(), 0);
1104        assert!(
1105            report
1106                .probes
1107                .iter()
1108                .any(|p| matches!(p.status, crate::check::ProbeStatus::Skip { .. })),
1109            "default check must surface a skipped (not-implemented) probe"
1110        );
1111    }
1112
1113    #[tokio::test]
1114    async fn file_check_fails_when_root_unusable() {
1115        // Root whose parent is a regular file → create_dir_all fails.
1116        let dir = TempDir::new().unwrap();
1117        let file = dir.path().join("not_a_dir");
1118        std::fs::write(&file, b"x").unwrap();
1119        let s = FileStateStore::new(file.join("state"));
1120        let report = s
1121            .check(&crate::check::CheckContext::default())
1122            .await
1123            .unwrap();
1124        assert_eq!(report.failed_count(), 1, "unusable root should fail");
1125    }
1126
1127    #[cfg(feature = "encryption")]
1128    mod encryption_at_rest {
1129        use super::*;
1130        use crate::encryption::{CompiledEncryption, EncryptionSpec, is_encrypted};
1131        use serde_json::json;
1132
1133        fn enc(key: &str) -> CompiledEncryption {
1134            CompiledEncryption::compile(&EncryptionSpec {
1135                key: key.into(),
1136                previous_keys: vec![],
1137                algorithm: Default::default(),
1138            })
1139            .unwrap()
1140        }
1141
1142        #[tokio::test]
1143        async fn encrypted_round_trip_and_ciphertext_on_disk() {
1144            let dir = tempfile::tempdir().unwrap();
1145            let store = FileStateStore::new(dir.path()).with_encryption(enc("k1"));
1146            store.put("bk", &json!({"lsn": 42})).await.unwrap();
1147            assert_eq!(store.get("bk").await.unwrap(), Some(json!({"lsn": 42})));
1148
1149            // On disk it is ciphertext, not JSON.
1150            let raw = std::fs::read(dir.path().join("bk.json")).unwrap();
1151            assert!(is_encrypted(&raw));
1152            assert!(serde_json::from_slice::<Value>(&raw).is_err());
1153
1154            store.delete("bk").await.unwrap();
1155            assert_eq!(store.get("bk").await.unwrap(), None);
1156        }
1157
1158        #[tokio::test]
1159        async fn plaintext_file_stays_readable_and_is_sealed_on_next_write() {
1160            let dir = tempfile::tempdir().unwrap();
1161            // A pre-encryption bookmark written by an older config.
1162            let plain = FileStateStore::new(dir.path());
1163            plain.put("bk", &json!("legacy")).await.unwrap();
1164            let before = std::fs::read(dir.path().join("bk.json")).unwrap();
1165            assert!(!is_encrypted(&before));
1166
1167            let sealed = FileStateStore::new(dir.path()).with_encryption(enc("k1"));
1168            assert_eq!(sealed.get("bk").await.unwrap(), Some(json!("legacy")));
1169            sealed.put("bk", &json!("updated")).await.unwrap();
1170            let after = std::fs::read(dir.path().join("bk.json")).unwrap();
1171            assert!(is_encrypted(&after), "next write must seal the file");
1172            assert_eq!(sealed.get("bk").await.unwrap(), Some(json!("updated")));
1173        }
1174
1175        #[tokio::test]
1176        async fn wrong_key_is_a_typed_error_not_a_missing_bookmark() {
1177            let dir = tempfile::tempdir().unwrap();
1178            let a = FileStateStore::new(dir.path()).with_encryption(enc("right"));
1179            a.put("bk", &json!(1)).await.unwrap();
1180
1181            let b = FileStateStore::new(dir.path()).with_encryption(enc("wrong"));
1182            let err = b.get("bk").await.unwrap_err();
1183            assert!(matches!(err, FaucetError::State(_)));
1184            assert!(err.to_string().contains("could not be decrypted"), "{err}");
1185        }
1186
1187        #[tokio::test]
1188        async fn encrypted_file_with_unconfigured_store_is_a_typed_error() {
1189            let dir = tempfile::tempdir().unwrap();
1190            let sealed = FileStateStore::new(dir.path()).with_encryption(enc("k1"));
1191            sealed.put("bk", &json!(1)).await.unwrap();
1192
1193            let plain = FileStateStore::new(dir.path());
1194            let err = plain.get("bk").await.unwrap_err();
1195            assert!(err.to_string().contains("no `encryption` block"), "{err}");
1196        }
1197
1198        #[tokio::test]
1199        async fn rotation_reads_old_key_files() {
1200            let dir = tempfile::tempdir().unwrap();
1201            let old = FileStateStore::new(dir.path()).with_encryption(enc("old"));
1202            old.put("bk", &json!("v1")).await.unwrap();
1203
1204            let rotated = FileStateStore::new(dir.path()).with_encryption(
1205                CompiledEncryption::compile(&EncryptionSpec {
1206                    key: "new".into(),
1207                    previous_keys: vec!["old".into()],
1208                    algorithm: Default::default(),
1209                })
1210                .unwrap(),
1211            );
1212            assert_eq!(rotated.get("bk").await.unwrap(), Some(json!("v1")));
1213            // Re-seal under the new key; a store knowing only "new" can read it.
1214            rotated.put("bk", &json!("v2")).await.unwrap();
1215            let new_only = FileStateStore::new(dir.path()).with_encryption(enc("new"));
1216            assert_eq!(new_only.get("bk").await.unwrap(), Some(json!("v2")));
1217        }
1218
1219        #[tokio::test]
1220        async fn no_temp_files_left_behind() {
1221            let dir = tempfile::tempdir().unwrap();
1222            let store = FileStateStore::new(dir.path()).with_encryption(enc("k1"));
1223            store.put("bk", &json!(1)).await.unwrap();
1224            let leftovers: Vec<_> = std::fs::read_dir(dir.path())
1225                .unwrap()
1226                .filter_map(Result::ok)
1227                .filter(|e| e.path().to_string_lossy().ends_with(".tmp"))
1228                .collect();
1229            assert!(
1230                leftovers.is_empty(),
1231                "atomic write must leave no temp files"
1232            );
1233        }
1234    }
1235}
1236
1237#[cfg(test)]
1238mod list_export_tests {
1239    use super::*;
1240    use serde_json::json;
1241
1242    struct Bare(MemoryStateStore);
1243
1244    #[async_trait]
1245    impl StateStore for Bare {
1246        async fn get(&self, key: &str) -> Result<Option<Value>, FaucetError> {
1247            self.0.get(key).await
1248        }
1249        async fn put(&self, key: &str, value: &Value) -> Result<(), FaucetError> {
1250            if key.ends_with("::boom") {
1251                return Err(FaucetError::State("disk full".into()));
1252            }
1253            self.0.put(key, value).await
1254        }
1255        async fn delete(&self, key: &str) -> Result<(), FaucetError> {
1256            if key.ends_with("::stuck") {
1257                return Err(FaucetError::State("locked".into()));
1258            }
1259            self.0.delete(key).await
1260        }
1261    }
1262
1263    #[tokio::test]
1264    async fn defaults_cannot_list_and_batch_sequentially() {
1265        let s = Bare(MemoryStateStore::new());
1266        assert!(!s.supports_list());
1267        assert!(!s.supports_atomic_batch());
1268        assert!(s.list("p::").await.is_err());
1269        s.put_batch(&[("p::a".into(), json!(1)), ("p::b".into(), json!(2))])
1270            .await
1271            .unwrap();
1272        assert_eq!(s.get("p::b").await.unwrap(), Some(json!(2)));
1273        let err = s
1274            .put_batch(&[("p::c".into(), json!(1)), ("p::boom".into(), json!(2))])
1275            .await
1276            .unwrap_err();
1277        assert!(err.to_string().contains("disk full"));
1278        assert_eq!(
1279            s.get("p::c").await.unwrap(),
1280            Some(json!(1)),
1281            "prefix landed"
1282        );
1283    }
1284
1285    #[tokio::test]
1286    async fn memory_lists_by_prefix_sorted_and_batches_atomically() {
1287        let s = MemoryStateStore::new();
1288        for k in ["p::b", "p::a", "q::a", "p::a::__sla__"] {
1289            s.put(k, &json!(k)).await.unwrap();
1290        }
1291        assert!(s.supports_list());
1292        assert_eq!(
1293            s.list("p::").await.unwrap(),
1294            vec!["p::a", "p::a::__sla__", "p::b"]
1295        );
1296        assert!(s.supports_atomic_batch());
1297        let err = s
1298            .put_batch(&[("p::x".into(), json!(1)), ("../bad".into(), json!(2))])
1299            .await
1300            .unwrap_err();
1301        assert!(matches!(err, FaucetError::State(_)));
1302        assert!(s.get("p::x").await.unwrap().is_none(), "nothing written");
1303        s.put_batch(&[("p::x".into(), json!(1))]).await.unwrap();
1304        assert_eq!(s.get("p::x").await.unwrap(), Some(json!(1)));
1305    }
1306
1307    #[tokio::test]
1308    async fn file_lists_decoded_keys_ignoring_temp_and_foreign_files() {
1309        let dir = tempfile::tempdir().unwrap();
1310        let s = FileStateStore::new(dir.path());
1311        assert!(s.list("p::").await.unwrap().is_empty(), "missing root");
1312        for k in ["p::a", "p::a::__sla__", "acme/p::x", "q::a"] {
1313            s.put(k, &json!(1)).await.unwrap();
1314        }
1315        std::fs::write(dir.path().join("p%3A%3Az.json.tmp"), b"{}").unwrap();
1316        std::fs::write(dir.path().join("notes.txt"), b"x").unwrap();
1317        std::fs::write(dir.path().join(".hidden.json"), b"{}").unwrap();
1318        assert!(s.supports_list());
1319        assert_eq!(s.list("p::").await.unwrap(), vec!["p::a", "p::a::__sla__"]);
1320        assert_eq!(s.list("acme/").await.unwrap(), vec!["acme/p::x"]);
1321        assert!(!s.supports_atomic_batch());
1322    }
1323
1324    #[cfg(unix)]
1325    #[tokio::test]
1326    async fn file_list_reports_unreadable_root() {
1327        let dir = tempfile::tempdir().unwrap();
1328        let file = dir.path().join("not-a-dir");
1329        std::fs::write(&file, b"x").unwrap();
1330        let s = FileStateStore::new(&file);
1331        let err = s.list("p::").await.unwrap_err();
1332        assert!(
1333            err.to_string().contains("failed to list state dir"),
1334            "{err}"
1335        );
1336    }
1337
1338    #[test]
1339    fn export_document_parses_and_validates() {
1340        let ok = json!({
1341            "version": 1,
1342            "pipeline": "orders",
1343            "exported_at": "2026-09-26T00:00:00Z",
1344            "keys": {"orders::a": {"id": 3}, "orders::a::__sla__": {}}
1345        });
1346        let e = StateExport::from_value(ok).unwrap();
1347        assert_eq!(e.keys.len(), 2);
1348        assert_eq!(namespace_prefix("orders"), "orders::");
1349
1350        let cases = [
1351            (json!({"pipeline": "o", "keys": {}}), "no `version`"),
1352            (
1353                json!({"version": "1", "pipeline": "o"}),
1354                "must be an integer",
1355            ),
1356            (json!({"version": 2, "pipeline": "o"}), "not supported"),
1357            (json!({"version": 0, "pipeline": "o"}), "not supported"),
1358            (json!({"version": 1}), "malformed"),
1359            (json!({"version": 1, "pipeline": "../o"}), "pipeline name"),
1360            (
1361                json!({"version": 1, "pipeline": "o", "keys": {"other::a": 1}}),
1362                "outside the namespace",
1363            ),
1364            (
1365                json!({"version": 1, "pipeline": "o", "keys": {"o::a b": 1}}),
1366                "illegal character",
1367            ),
1368        ];
1369        for (doc, needle) in cases {
1370            let err = StateExport::from_value(doc.clone())
1371                .unwrap_err()
1372                .to_string();
1373            assert!(err.contains(needle), "{doc} → {err}");
1374        }
1375    }
1376
1377    #[tokio::test]
1378    async fn export_then_import_round_trips_and_replace_drops_stale_keys() {
1379        let from = MemoryStateStore::new();
1380        from.put("o::a", &json!({"c": 1})).await.unwrap();
1381        from.put("o::b", &json!(2)).await.unwrap();
1382        from.put("other::a", &json!(9)).await.unwrap();
1383        let export = export_namespace(&from, "o").await.unwrap();
1384        assert_eq!(export.keys.len(), 2);
1385
1386        let to = MemoryStateStore::new();
1387        to.put("o::stale", &json!(0)).await.unwrap();
1388        let r = import_namespace(&to, &export, false).await.unwrap();
1389        assert!(r.atomic && r.error.is_none());
1390        assert_eq!(r.written, vec!["o::a", "o::b"]);
1391        assert!(
1392            to.get("o::stale").await.unwrap().is_some(),
1393            "kept without replace"
1394        );
1395
1396        let r = import_namespace(&to, &export, true).await.unwrap();
1397        assert_eq!(r.deleted, vec!["o::stale"]);
1398        assert!(to.get("o::stale").await.unwrap().is_none());
1399        assert_eq!(to.get("o::a").await.unwrap(), Some(json!({"c": 1})));
1400    }
1401
1402    #[tokio::test]
1403    async fn import_reports_partial_progress_on_a_non_atomic_store() {
1404        let mut export = StateExport::new("o");
1405        export.keys.insert("o::a".into(), json!(1));
1406        export.keys.insert("o::boom".into(), json!(2));
1407        let s = Bare(MemoryStateStore::new());
1408        let r = import_namespace(&s, &export, false).await.unwrap();
1409        assert!(!r.atomic);
1410        assert_eq!(r.written, vec!["o::a"]);
1411        assert!(r.error.unwrap().contains("o::boom"));
1412
1413        let mut bad = StateExport::new("o");
1414        bad.keys.insert("x::a".into(), json!(1));
1415        assert!(import_namespace(&s, &bad, false).await.is_err());
1416    }
1417
1418    #[tokio::test]
1419    async fn import_reports_batch_and_delete_failures() {
1420        struct Failing;
1421        #[async_trait]
1422        impl StateStore for Failing {
1423            async fn get(&self, _: &str) -> Result<Option<Value>, FaucetError> {
1424                Ok(None)
1425            }
1426            async fn put(&self, _: &str, _: &Value) -> Result<(), FaucetError> {
1427                Ok(())
1428            }
1429            async fn delete(&self, _: &str) -> Result<(), FaucetError> {
1430                Ok(())
1431            }
1432            fn supports_atomic_batch(&self) -> bool {
1433                true
1434            }
1435            async fn put_batch(&self, _: &[(String, Value)]) -> Result<(), FaucetError> {
1436                Err(FaucetError::State("tx aborted".into()))
1437            }
1438        }
1439        assert_eq!(Failing.get("o::a").await.unwrap(), None);
1440        Failing.put("o::a", &json!(1)).await.unwrap();
1441        Failing.delete("o::a").await.unwrap();
1442        let mut export = StateExport::new("o");
1443        export.keys.insert("o::a".into(), json!(1));
1444        let r = import_namespace(&Failing, &export, false).await.unwrap();
1445        assert!(r.written.is_empty());
1446        assert!(r.error.unwrap().contains("tx aborted"));
1447
1448        // A replace needs to list the namespace first.
1449        assert!(import_namespace(&Failing, &export, true).await.is_err());
1450
1451        let s = Bare(MemoryStateStore::new());
1452        struct Listing(Bare);
1453        #[async_trait]
1454        impl StateStore for Listing {
1455            async fn get(&self, k: &str) -> Result<Option<Value>, FaucetError> {
1456                self.0.get(k).await
1457            }
1458            async fn put(&self, k: &str, v: &Value) -> Result<(), FaucetError> {
1459                self.0.put(k, v).await
1460            }
1461            async fn delete(&self, k: &str) -> Result<(), FaucetError> {
1462                self.0.delete(k).await
1463            }
1464            async fn list(&self, p: &str) -> Result<Vec<String>, FaucetError> {
1465                self.0.0.list(p).await
1466            }
1467        }
1468        s.put("o::stuck", &json!(1)).await.unwrap();
1469        let listing = Listing(s);
1470        assert_eq!(listing.get("o::stuck").await.unwrap(), Some(json!(1)));
1471        let r = import_namespace(&listing, &export, true).await.unwrap();
1472        assert_eq!(r.written, vec!["o::a"]);
1473        assert!(r.error.unwrap().contains("deleting stale 'o::stuck'"));
1474    }
1475}