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
57/// Sentinel key used by state-store `check()` probes. Valid per
58/// [`validate_state_key`] and deleted after the probe so it leaves no residue.
59pub const DOCTOR_SENTINEL_KEY: &str = "faucet_doctor_probe";
60
61/// Reject keys that could escape the storage namespace or break filename
62/// rules on common filesystems. Allowed: ASCII letters, digits, `_`, `-`,
63/// `:`, `.`. Empty keys are rejected.
64pub fn validate_state_key(key: &str) -> Result<(), FaucetError> {
65    if key.is_empty() {
66        return Err(FaucetError::State("state key must not be empty".into()));
67    }
68    if key.len() > 256 {
69        return Err(FaucetError::State(format!(
70            "state key '{key}' exceeds 256 characters"
71        )));
72    }
73    for (i, c) in key.char_indices() {
74        let ok = c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | ':' | '.');
75        if !ok {
76            return Err(FaucetError::State(format!(
77                "state key '{key}' contains illegal character {c:?} at byte {i}"
78            )));
79        }
80    }
81    if key == "." || key == ".." || key.starts_with('.') {
82        return Err(FaucetError::State(format!(
83            "state key '{key}' must not begin with a dot"
84        )));
85    }
86    Ok(())
87}
88
89// ── MemoryStateStore ────────────────────────────────────────────────────────
90
91/// In-memory `StateStore` for tests and ephemeral pipelines.
92#[derive(Default)]
93pub struct MemoryStateStore {
94    inner: Mutex<HashMap<String, Value>>,
95}
96
97impl MemoryStateStore {
98    /// Create an empty in-memory store.
99    pub fn new() -> Self {
100        Self::default()
101    }
102}
103
104#[async_trait]
105impl StateStore for MemoryStateStore {
106    async fn get(&self, key: &str) -> Result<Option<Value>, FaucetError> {
107        validate_state_key(key)?;
108        Ok(self.inner.lock().await.get(key).cloned())
109    }
110
111    async fn put(&self, key: &str, value: &Value) -> Result<(), FaucetError> {
112        validate_state_key(key)?;
113        self.inner
114            .lock()
115            .await
116            .insert(key.to_owned(), value.clone());
117        Ok(())
118    }
119
120    async fn delete(&self, key: &str) -> Result<(), FaucetError> {
121        validate_state_key(key)?;
122        self.inner.lock().await.remove(key);
123        Ok(())
124    }
125
126    async fn check(
127        &self,
128        _ctx: &crate::check::CheckContext,
129    ) -> Result<crate::check::CheckReport, FaucetError> {
130        // In-process map — always reachable.
131        Ok(crate::check::CheckReport::single(
132            crate::check::Probe::pass("sentinel", std::time::Duration::ZERO),
133        ))
134    }
135}
136
137// ── FileStateStore ──────────────────────────────────────────────────────────
138
139/// Map a logical state key to a filesystem-safe filename stem. The key
140/// grammar allows `:` (used by the documented `pipeline:rest:issues`
141/// convention), but `:` is illegal in a filename on Windows/NTFS, so it is
142/// percent-encoded as `%3A`. This is collision-free because `%` can never
143/// appear in a valid key (see [`validate_state_key`]) (#78 LOW).
144fn safe_filename(key: &str) -> String {
145    key.replace(':', "%3A")
146}
147
148/// File-backed `StateStore`. Each key maps to a JSON file at
149/// `{root}/{safe_filename(key)}.json`, written via atomic rename. The
150/// filename stem percent-encodes `:` as `%3A` so keys using the
151/// `pipeline:rest:issues` convention are valid on Windows.
152///
153/// Each `put` writes a temp file, fsyncs it, renames it over the final path,
154/// and (on Unix) fsyncs the parent directory — so once `put` returns the
155/// bookmark is durable across a crash or power loss, not merely sitting in the
156/// page cache.
157///
158/// The store creates its root directory on first use. All writes are
159/// serialized through a per-store mutex so two concurrent `put`s for the
160/// same key cannot race on the temp filename. Different processes pointing
161/// at the same directory rely on the filesystem's atomic rename guarantee.
162pub struct FileStateStore {
163    root: PathBuf,
164    write_lock: Mutex<()>,
165    /// Optional at-rest encryption (#207). When set, `put` seals the JSON
166    /// bytes before the atomic write and `get` unseals; plaintext files
167    /// remain readable (and are sealed on their next write).
168    #[cfg(feature = "encryption")]
169    encryption: Option<crate::encryption::CompiledEncryption>,
170}
171
172impl FileStateStore {
173    /// Open or create a file-backed state store rooted at `root`.
174    pub fn new(root: impl Into<PathBuf>) -> Self {
175        Self {
176            root: root.into(),
177            write_lock: Mutex::new(()),
178            #[cfg(feature = "encryption")]
179            encryption: None,
180        }
181    }
182
183    /// Seal bookmark files at rest with the given policy (#207). Reads accept
184    /// both sealed and (legacy) plaintext files; every write seals.
185    #[cfg(feature = "encryption")]
186    pub fn with_encryption(mut self, encryption: crate::encryption::CompiledEncryption) -> Self {
187        self.encryption = Some(encryption);
188        self
189    }
190
191    fn entry_path(&self, key: &str) -> PathBuf {
192        self.root.join(format!("{}.json", safe_filename(key)))
193    }
194
195    fn temp_path(&self, key: &str) -> PathBuf {
196        // Unique per write: a per-process random token + a monotonic counter, so
197        // two writers (different processes sharing the directory, or two store
198        // instances in one process) never share a temp file. A *fixed* temp name
199        // let a second writer `File::create`-truncate the first's half-written
200        // temp, which the first could then `rename` over the final path —
201        // yielding torn/truncated state JSON that breaks resume (audit #146 H10).
202        //
203        // The token MUST NOT be derived from the process id: containers on a
204        // shared NFS/EFS/host volume routinely reuse the same PID (each starts at
205        // PID 1), so a PID + per-process counter collides across processes and
206        // reintroduces exactly that torn-bookmark corruption (F50). A random v4
207        // UUID minted once per process is unguessable and collision-free across
208        // processes regardless of PID; the counter keeps writers within one
209        // process distinct. The per-store `write_lock` only serializes writers
210        // within one process. Orphaned `.tmp` files from an interrupted write are
211        // harmless: `get` only ever reads the final `.json` path.
212        use std::sync::OnceLock;
213        use std::sync::atomic::{AtomicU64, Ordering};
214        static PROC_TOKEN: OnceLock<String> = OnceLock::new();
215        static SEQ: AtomicU64 = AtomicU64::new(0);
216        let token = PROC_TOKEN.get_or_init(|| uuid::Uuid::new_v4().simple().to_string());
217        let seq = SEQ.fetch_add(1, Ordering::Relaxed);
218        self.root
219            .join(format!("{}.{}.{}.json.tmp", safe_filename(key), token, seq))
220    }
221
222    async fn ensure_root(&self) -> Result<(), FaucetError> {
223        tokio::fs::create_dir_all(&self.root).await.map_err(|e| {
224            FaucetError::State(format!(
225                "failed to create state dir {}: {e}",
226                self.root.display()
227            ))
228        })
229    }
230
231    /// Returns the root directory this store writes into.
232    pub fn root(&self) -> &Path {
233        &self.root
234    }
235}
236
237#[async_trait]
238impl StateStore for FileStateStore {
239    async fn get(&self, key: &str) -> Result<Option<Value>, FaucetError> {
240        validate_state_key(key)?;
241        let path = self.entry_path(key);
242        match tokio::fs::read(&path).await {
243            Ok(bytes) => {
244                #[cfg(feature = "encryption")]
245                let bytes: Vec<u8> = if crate::encryption::is_encrypted(&bytes) {
246                    match &self.encryption {
247                        Some(enc) => enc.decrypt(&bytes).map_err(|e| {
248                            // Wrong/rotated key must be a loud, typed error —
249                            // treating it as "no bookmark" would silently
250                            // trigger a full re-sync.
251                            FaucetError::State(format!(
252                                "state file {} could not be decrypted: {e}",
253                                path.display()
254                            ))
255                        })?,
256                        None => {
257                            return Err(FaucetError::State(format!(
258                                "state file {} is encrypted but no `encryption` block is \
259                                 configured on the file state store — add \
260                                 `state.config.encryption` with the original key",
261                                path.display()
262                            )));
263                        }
264                    }
265                } else {
266                    bytes
267                };
268                #[cfg(not(feature = "encryption"))]
269                let bytes = {
270                    // Built without the `encryption` feature: refuse to parse a
271                    // sealed file as JSON garbage.
272                    if bytes.starts_with(b"FCT1") {
273                        return Err(FaucetError::State(format!(
274                            "state file {} is encrypted but this build of faucet has no \
275                             `encryption` feature",
276                            path.display()
277                        )));
278                    }
279                    bytes
280                };
281                let value: Value = serde_json::from_slice(&bytes).map_err(|e| {
282                    FaucetError::State(format!(
283                        "failed to parse state file {}: {e}",
284                        path.display()
285                    ))
286                })?;
287                Ok(Some(value))
288            }
289            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
290            Err(e) => Err(FaucetError::State(format!(
291                "failed to read state file {}: {e}",
292                path.display()
293            ))),
294        }
295    }
296
297    async fn put(&self, key: &str, value: &Value) -> Result<(), FaucetError> {
298        validate_state_key(key)?;
299        let _guard = self.write_lock.lock().await;
300        self.ensure_root().await?;
301        let bytes = serde_json::to_vec(value).map_err(|e| {
302            FaucetError::State(format!("failed to serialize state for key '{key}': {e}"))
303        })?;
304        #[cfg(feature = "encryption")]
305        let bytes = match &self.encryption {
306            Some(enc) => enc.encrypt(&bytes),
307            None => bytes,
308        };
309        let final_path = self.entry_path(key);
310        let tmp_path = self.temp_path(key);
311
312        // Write the temp file and fsync it BEFORE the rename. `fs::write`
313        // alone leaves the bytes in the page cache: a crash after `put`
314        // returns could surface a zero-length or stale file, breaking the
315        // durability guarantee this store documents (#78/#8). `sync_all`
316        // flushes the file's data and metadata to disk.
317        {
318            let mut file = tokio::fs::File::create(&tmp_path).await.map_err(|e| {
319                FaucetError::State(format!(
320                    "failed to create temp state file {}: {e}",
321                    tmp_path.display()
322                ))
323            })?;
324            file.write_all(&bytes).await.map_err(|e| {
325                FaucetError::State(format!(
326                    "failed to write temp state file {}: {e}",
327                    tmp_path.display()
328                ))
329            })?;
330            file.sync_all().await.map_err(|e| {
331                FaucetError::State(format!(
332                    "failed to fsync temp state file {}: {e}",
333                    tmp_path.display()
334                ))
335            })?;
336        }
337
338        tokio::fs::rename(&tmp_path, &final_path)
339            .await
340            .map_err(|e| {
341                FaucetError::State(format!(
342                    "failed to commit state file {}: {e}",
343                    final_path.display()
344                ))
345            })?;
346
347        // fsync the parent directory so the rename itself is durable — an
348        // atomic rename can still be lost on crash if the directory entry was
349        // never flushed. Directory fsync is a POSIX concept; on platforms that
350        // don't allow opening a directory as a file (e.g. Windows) it is
351        // skipped.
352        #[cfg(unix)]
353        {
354            let dir = tokio::fs::File::open(&self.root).await.map_err(|e| {
355                FaucetError::State(format!(
356                    "failed to open state dir {} for fsync: {e}",
357                    self.root.display()
358                ))
359            })?;
360            dir.sync_all().await.map_err(|e| {
361                FaucetError::State(format!(
362                    "failed to fsync state dir {}: {e}",
363                    self.root.display()
364                ))
365            })?;
366        }
367
368        tracing::debug!(
369            key,
370            path = %final_path.display(),
371            "state file written"
372        );
373        Ok(())
374    }
375
376    async fn delete(&self, key: &str) -> Result<(), FaucetError> {
377        validate_state_key(key)?;
378        let path = self.entry_path(key);
379        match tokio::fs::remove_file(&path).await {
380            Ok(()) => Ok(()),
381            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
382            Err(e) => Err(FaucetError::State(format!(
383                "failed to delete state file {}: {e}",
384                path.display()
385            ))),
386        }
387    }
388
389    async fn check(
390        &self,
391        _ctx: &crate::check::CheckContext,
392    ) -> Result<crate::check::CheckReport, FaucetError> {
393        use crate::check::{CheckReport, Probe};
394        // Exercise the real put → get → delete cycle on a sentinel key. `put`
395        // creates the root dir if needed, so this validates "dir exists +
396        // writable" via the actual code path and leaves no residue.
397        let start = std::time::Instant::now();
398        let probe = match self.sentinel_roundtrip().await {
399            Ok(()) => Probe::pass("sentinel", start.elapsed()),
400            Err(e) => Probe::fail_hint(
401                "sentinel",
402                start.elapsed(),
403                e.to_string(),
404                format!("ensure {} exists and is writable", self.root.display()),
405            ),
406        };
407        Ok(CheckReport::single(probe))
408    }
409}
410
411impl FileStateStore {
412    /// Write, read back, and delete a sentinel key — the body of the `check()`
413    /// probe, factored out so the happy path stays linear.
414    async fn sentinel_roundtrip(&self) -> Result<(), FaucetError> {
415        let probe = serde_json::json!({ "faucet_doctor": true });
416        self.put(DOCTOR_SENTINEL_KEY, &probe).await?;
417        let got = self.get(DOCTOR_SENTINEL_KEY).await?;
418        // Best-effort cleanup regardless of the read result.
419        let _ = self.delete(DOCTOR_SENTINEL_KEY).await;
420        match got {
421            Some(v) if v == probe => Ok(()),
422            _ => Err(FaucetError::State(
423                "sentinel readback did not match what was written".into(),
424            )),
425        }
426    }
427}
428
429#[cfg(test)]
430mod tests {
431    use super::*;
432    use serde_json::json;
433    use std::sync::Arc;
434    use tempfile::TempDir;
435
436    // ── key validation ─────────────────────────────────────────────────────
437
438    #[test]
439    fn rejects_empty_key() {
440        let err = validate_state_key("").unwrap_err();
441        assert!(matches!(err, FaucetError::State(_)));
442    }
443
444    #[test]
445    fn rejects_path_traversal_segments() {
446        for k in ["../etc/passwd", "a/b", "a\\b", "..", "."] {
447            assert!(validate_state_key(k).is_err(), "expected reject for {k:?}");
448        }
449    }
450
451    #[test]
452    fn rejects_leading_dot() {
453        assert!(validate_state_key(".hidden").is_err());
454    }
455
456    #[test]
457    fn rejects_over_long_key() {
458        let k = "a".repeat(257);
459        assert!(validate_state_key(&k).is_err());
460    }
461
462    #[test]
463    fn accepts_typical_keys() {
464        for k in [
465            "github_issues",
466            "pipeline:rest:issues",
467            "with.dot",
468            "with-dash_and_underscore",
469            "lower-Case_99",
470        ] {
471            validate_state_key(k).unwrap_or_else(|e| panic!("expected ok for {k:?}: {e}"));
472        }
473    }
474
475    // ── MemoryStateStore ────────────────────────────────────────────────────
476
477    #[tokio::test]
478    async fn memory_get_returns_none_for_missing_key() {
479        let s = MemoryStateStore::new();
480        assert!(s.get("nope").await.unwrap().is_none());
481    }
482
483    #[tokio::test]
484    async fn memory_put_then_get_round_trips() {
485        let s = MemoryStateStore::new();
486        s.put("k", &json!({"cursor": "abc", "n": 7})).await.unwrap();
487        let got = s.get("k").await.unwrap().unwrap();
488        assert_eq!(got["cursor"], "abc");
489        assert_eq!(got["n"], 7);
490    }
491
492    #[tokio::test]
493    async fn memory_put_overwrites_previous_value() {
494        let s = MemoryStateStore::new();
495        s.put("k", &json!(1)).await.unwrap();
496        s.put("k", &json!(2)).await.unwrap();
497        assert_eq!(s.get("k").await.unwrap().unwrap(), json!(2));
498    }
499
500    #[tokio::test]
501    async fn memory_delete_makes_get_return_none() {
502        let s = MemoryStateStore::new();
503        s.put("k", &json!("v")).await.unwrap();
504        s.delete("k").await.unwrap();
505        assert!(s.get("k").await.unwrap().is_none());
506    }
507
508    #[tokio::test]
509    async fn memory_delete_missing_key_is_ok() {
510        let s = MemoryStateStore::new();
511        s.delete("absent").await.unwrap();
512    }
513
514    #[tokio::test]
515    async fn memory_rejects_invalid_keys() {
516        let s = MemoryStateStore::new();
517        assert!(s.get("a/b").await.is_err());
518        assert!(s.put("a/b", &json!(1)).await.is_err());
519        assert!(s.delete("a/b").await.is_err());
520    }
521
522    // ── FileStateStore ──────────────────────────────────────────────────────
523
524    #[tokio::test]
525    async fn file_get_returns_none_for_missing_key() {
526        let dir = TempDir::new().unwrap();
527        let s = FileStateStore::new(dir.path());
528        assert!(s.get("nope").await.unwrap().is_none());
529    }
530
531    #[tokio::test]
532    async fn file_put_creates_root_directory_lazily() {
533        let dir = TempDir::new().unwrap();
534        let root = dir.path().join("nested/state");
535        let s = FileStateStore::new(&root);
536        s.put("k", &json!("v")).await.unwrap();
537        assert!(root.is_dir(), "root dir should be created on first put");
538    }
539
540    #[tokio::test]
541    async fn file_put_then_get_round_trips() {
542        let dir = TempDir::new().unwrap();
543        let s = FileStateStore::new(dir.path());
544        let value = json!({"cursor": "abc", "n": 42, "nested": {"flag": true}});
545        s.put("github_issues", &value).await.unwrap();
546        let got = s.get("github_issues").await.unwrap().unwrap();
547        assert_eq!(got, value);
548    }
549
550    #[test]
551    fn temp_path_is_unique_and_not_pid_derived() {
552        // Regression for F50: the temp filename must not embed the process id.
553        // Containers on a shared volume reuse PIDs (each starts at PID 1), so a
554        // PID + per-process counter collides across processes and reintroduces
555        // the torn-bookmark corruption the unique-temp scheme prevents. The
556        // token is a per-process random UUID instead.
557        let dir = TempDir::new().unwrap();
558        let s = FileStateStore::new(dir.path());
559
560        let a = s.temp_path("k");
561        let b = s.temp_path("k");
562        // Distinct temp paths within one process (the monotonic counter differs).
563        assert_ne!(a, b);
564
565        let name_a = a.file_name().unwrap().to_str().unwrap();
566        let pid = std::process::id().to_string();
567        // The PID must NOT appear as a dotted segment of the temp name.
568        assert!(
569            !name_a.split('.').any(|seg| seg == pid),
570            "temp filename {name_a} must not embed the process id ({pid})"
571        );
572        assert!(name_a.ends_with(".json.tmp"));
573    }
574
575    #[test]
576    fn safe_filename_percent_encodes_colon() {
577        assert_eq!(
578            safe_filename("pipeline:rest:issues"),
579            "pipeline%3Arest%3Aissues"
580        );
581        assert_eq!(safe_filename("plain_key-1.v2"), "plain_key-1.v2");
582    }
583
584    #[tokio::test]
585    async fn file_round_trips_colon_keys_with_safe_filename() {
586        // Regression for #78 LOW: the documented `pipeline:rest:issues` key
587        // convention must round-trip and produce a Windows-legal filename
588        // (no `:` on disk).
589        let dir = TempDir::new().unwrap();
590        let s = FileStateStore::new(dir.path());
591        let value = json!({"cursor": "z"});
592        s.put("pipeline:rest:issues", &value).await.unwrap();
593        assert_eq!(s.get("pipeline:rest:issues").await.unwrap().unwrap(), value);
594        // On-disk filename must not contain a colon.
595        assert!(dir.path().join("pipeline%3Arest%3Aissues.json").exists());
596        let mut has_colon = false;
597        for entry in std::fs::read_dir(dir.path()).unwrap() {
598            if entry.unwrap().file_name().to_string_lossy().contains(':') {
599                has_colon = true;
600            }
601        }
602        assert!(!has_colon, "no state filename may contain ':'");
603    }
604
605    /// True if any `.json.tmp` residue remains in `dir`. Temp names are unique
606    /// per write since #146 H10, so we glob for the suffix rather than check a
607    /// fixed name (which would never exist now and pass vacuously).
608    fn has_tmp_residue(dir: &std::path::Path) -> bool {
609        std::fs::read_dir(dir)
610            .unwrap()
611            .filter_map(|e| e.ok())
612            .any(|e| e.file_name().to_string_lossy().ends_with(".json.tmp"))
613    }
614
615    #[tokio::test]
616    async fn file_put_overwrites_previous_value_atomically() {
617        let dir = TempDir::new().unwrap();
618        let s = FileStateStore::new(dir.path());
619        s.put("k", &json!({"v": 1})).await.unwrap();
620        s.put("k", &json!({"v": 2})).await.unwrap();
621        assert_eq!(s.get("k").await.unwrap().unwrap(), json!({"v": 2}));
622        // No temp file left behind.
623        assert!(!has_tmp_residue(dir.path()), "no temp residue after put");
624    }
625
626    #[test]
627    fn file_temp_paths_are_unique_per_write() {
628        // H10 (audit #146): the temp path must be unique per write so two
629        // writers (different processes, or two store instances in one process
630        // with independent write_locks) never `File::create`-truncate a shared
631        // temp that the other then renames over the final file (torn state).
632        let dir = TempDir::new().unwrap();
633        let s = FileStateStore::new(dir.path());
634        let a = s.temp_path("k");
635        let b = s.temp_path("k");
636        assert_ne!(a, b, "each write must get a distinct temp path");
637        // The committed (final) path stays stable across writes.
638        assert_eq!(s.entry_path("k"), s.entry_path("k"));
639    }
640
641    #[tokio::test]
642    async fn file_put_writes_complete_durable_file_with_no_temp_residue() {
643        // Regression for #78/#8. `put` must produce a fully-written, parseable
644        // file and leave no temp file behind. (The fsync that makes this
645        // durable across a power loss can't be observed on a healthy
646        // filesystem, but a regression in the write/rename path — truncation,
647        // a leftover .tmp, or an unwritten file — is caught here.) A large
648        // payload makes a partial/unflushed write detectable on read-back.
649        let dir = TempDir::new().unwrap();
650        let s = FileStateStore::new(dir.path());
651        let big: Vec<Value> = (0..1_000)
652            .map(|i| json!({"i": i, "s": "x".repeat(20)}))
653            .collect();
654        let value = json!({"cursor": "abc", "rows": big});
655
656        s.put("github_issues", &value).await.unwrap();
657
658        // Read the raw file directly (bypassing get) to confirm it is complete.
659        let raw = tokio::fs::read(dir.path().join("github_issues.json"))
660            .await
661            .expect("state file must exist after put");
662        assert!(!raw.is_empty(), "state file must not be zero-length");
663        let parsed: Value = serde_json::from_slice(&raw).expect("state file must be valid JSON");
664        assert_eq!(parsed, value);
665
666        // No temp file left behind.
667        assert!(!has_tmp_residue(dir.path()), "no temp residue after put");
668    }
669
670    #[tokio::test]
671    async fn file_delete_removes_file() {
672        let dir = TempDir::new().unwrap();
673        let s = FileStateStore::new(dir.path());
674        s.put("k", &json!("v")).await.unwrap();
675        s.delete("k").await.unwrap();
676        assert!(s.get("k").await.unwrap().is_none());
677        assert!(!dir.path().join("k.json").exists());
678    }
679
680    #[tokio::test]
681    async fn file_delete_missing_key_is_ok() {
682        let dir = TempDir::new().unwrap();
683        let s = FileStateStore::new(dir.path());
684        s.delete("absent").await.unwrap();
685    }
686
687    #[tokio::test]
688    async fn file_get_returns_error_for_corrupt_json() {
689        let dir = TempDir::new().unwrap();
690        let s = FileStateStore::new(dir.path());
691        tokio::fs::create_dir_all(dir.path()).await.unwrap();
692        tokio::fs::write(dir.path().join("bad.json"), b"not json")
693            .await
694            .unwrap();
695        let err = s.get("bad").await.unwrap_err();
696        match err {
697            FaucetError::State(msg) => assert!(msg.contains("bad.json")),
698            other => panic!("expected State error, got {other:?}"),
699        }
700    }
701
702    #[tokio::test]
703    async fn file_concurrent_puts_do_not_corrupt_or_leak_temp() {
704        let dir = TempDir::new().unwrap();
705        let s = Arc::new(FileStateStore::new(dir.path()));
706        let mut handles = vec![];
707        for i in 0..50 {
708            let s = Arc::clone(&s);
709            handles.push(tokio::spawn(async move {
710                s.put("k", &json!({"i": i})).await.unwrap();
711            }));
712        }
713        for h in handles {
714            h.await.unwrap();
715        }
716        // The final value must be one of the 50 we wrote.
717        let got = s.get("k").await.unwrap().unwrap();
718        let i = got["i"].as_i64().unwrap();
719        assert!((0..50).contains(&i));
720        // And no temp file should remain.
721        assert!(
722            !has_tmp_residue(dir.path()),
723            "no temp residue after concurrent puts"
724        );
725    }
726
727    #[tokio::test]
728    async fn file_store_works_through_trait_object() {
729        let dir = TempDir::new().unwrap();
730        let s: Box<dyn StateStore> = Box::new(FileStateStore::new(dir.path()));
731        s.put("k", &json!(1)).await.unwrap();
732        assert_eq!(s.get("k").await.unwrap().unwrap(), json!(1));
733    }
734
735    // ── check() ──────────────────────────────────────────────────────────────
736
737    #[tokio::test]
738    async fn memory_check_passes() {
739        let s = MemoryStateStore::new();
740        let report = s
741            .check(&crate::check::CheckContext::default())
742            .await
743            .unwrap();
744        assert_eq!(report.failed_count(), 0);
745        assert!(
746            report
747                .probes
748                .iter()
749                .all(|p| matches!(p.status, crate::check::ProbeStatus::Pass))
750        );
751    }
752
753    #[tokio::test]
754    async fn file_check_passes_for_writable_root() {
755        let dir = TempDir::new().unwrap();
756        let s = FileStateStore::new(dir.path());
757        let report = s
758            .check(&crate::check::CheckContext::default())
759            .await
760            .unwrap();
761        assert_eq!(report.failed_count(), 0, "writable root should pass");
762        // The sentinel probe must leave no residue.
763        let leftovers: Vec<_> = std::fs::read_dir(dir.path()).unwrap().collect();
764        assert!(leftovers.is_empty(), "check() must not leave files behind");
765    }
766
767    #[tokio::test]
768    async fn file_store_root_returns_configured_directory() {
769        let dir = TempDir::new().unwrap();
770        let s = FileStateStore::new(dir.path());
771        assert_eq!(s.root(), dir.path());
772    }
773
774    #[tokio::test]
775    async fn default_check_reports_not_implemented() {
776        // A StateStore that does not override `check()` falls back to the
777        // trait default, which yields a single not-implemented (skipped) probe.
778        struct BareStore;
779        #[async_trait]
780        impl StateStore for BareStore {
781            async fn get(&self, _key: &str) -> Result<Option<Value>, FaucetError> {
782                Ok(None)
783            }
784            async fn put(&self, _key: &str, _value: &Value) -> Result<(), FaucetError> {
785                Ok(())
786            }
787            async fn delete(&self, _key: &str) -> Result<(), FaucetError> {
788                Ok(())
789            }
790        }
791        let s = BareStore;
792        let report = s
793            .check(&crate::check::CheckContext::default())
794            .await
795            .unwrap();
796        // not_implemented() reports no failures and is not a pass.
797        assert_eq!(report.failed_count(), 0);
798        assert!(
799            report
800                .probes
801                .iter()
802                .any(|p| matches!(p.status, crate::check::ProbeStatus::Skip { .. })),
803            "default check must surface a skipped (not-implemented) probe"
804        );
805    }
806
807    #[tokio::test]
808    async fn file_check_fails_when_root_unusable() {
809        // Root whose parent is a regular file → create_dir_all fails.
810        let dir = TempDir::new().unwrap();
811        let file = dir.path().join("not_a_dir");
812        std::fs::write(&file, b"x").unwrap();
813        let s = FileStateStore::new(file.join("state"));
814        let report = s
815            .check(&crate::check::CheckContext::default())
816            .await
817            .unwrap();
818        assert_eq!(report.failed_count(), 1, "unusable root should fail");
819    }
820
821    #[cfg(feature = "encryption")]
822    mod encryption_at_rest {
823        use super::*;
824        use crate::encryption::{CompiledEncryption, EncryptionSpec, is_encrypted};
825        use serde_json::json;
826
827        fn enc(key: &str) -> CompiledEncryption {
828            CompiledEncryption::compile(&EncryptionSpec {
829                key: key.into(),
830                previous_keys: vec![],
831                algorithm: Default::default(),
832            })
833            .unwrap()
834        }
835
836        #[tokio::test]
837        async fn encrypted_round_trip_and_ciphertext_on_disk() {
838            let dir = tempfile::tempdir().unwrap();
839            let store = FileStateStore::new(dir.path()).with_encryption(enc("k1"));
840            store.put("bk", &json!({"lsn": 42})).await.unwrap();
841            assert_eq!(store.get("bk").await.unwrap(), Some(json!({"lsn": 42})));
842
843            // On disk it is ciphertext, not JSON.
844            let raw = std::fs::read(dir.path().join("bk.json")).unwrap();
845            assert!(is_encrypted(&raw));
846            assert!(serde_json::from_slice::<Value>(&raw).is_err());
847
848            store.delete("bk").await.unwrap();
849            assert_eq!(store.get("bk").await.unwrap(), None);
850        }
851
852        #[tokio::test]
853        async fn plaintext_file_stays_readable_and_is_sealed_on_next_write() {
854            let dir = tempfile::tempdir().unwrap();
855            // A pre-encryption bookmark written by an older config.
856            let plain = FileStateStore::new(dir.path());
857            plain.put("bk", &json!("legacy")).await.unwrap();
858            let before = std::fs::read(dir.path().join("bk.json")).unwrap();
859            assert!(!is_encrypted(&before));
860
861            let sealed = FileStateStore::new(dir.path()).with_encryption(enc("k1"));
862            assert_eq!(sealed.get("bk").await.unwrap(), Some(json!("legacy")));
863            sealed.put("bk", &json!("updated")).await.unwrap();
864            let after = std::fs::read(dir.path().join("bk.json")).unwrap();
865            assert!(is_encrypted(&after), "next write must seal the file");
866            assert_eq!(sealed.get("bk").await.unwrap(), Some(json!("updated")));
867        }
868
869        #[tokio::test]
870        async fn wrong_key_is_a_typed_error_not_a_missing_bookmark() {
871            let dir = tempfile::tempdir().unwrap();
872            let a = FileStateStore::new(dir.path()).with_encryption(enc("right"));
873            a.put("bk", &json!(1)).await.unwrap();
874
875            let b = FileStateStore::new(dir.path()).with_encryption(enc("wrong"));
876            let err = b.get("bk").await.unwrap_err();
877            assert!(matches!(err, FaucetError::State(_)));
878            assert!(err.to_string().contains("could not be decrypted"), "{err}");
879        }
880
881        #[tokio::test]
882        async fn encrypted_file_with_unconfigured_store_is_a_typed_error() {
883            let dir = tempfile::tempdir().unwrap();
884            let sealed = FileStateStore::new(dir.path()).with_encryption(enc("k1"));
885            sealed.put("bk", &json!(1)).await.unwrap();
886
887            let plain = FileStateStore::new(dir.path());
888            let err = plain.get("bk").await.unwrap_err();
889            assert!(err.to_string().contains("no `encryption` block"), "{err}");
890        }
891
892        #[tokio::test]
893        async fn rotation_reads_old_key_files() {
894            let dir = tempfile::tempdir().unwrap();
895            let old = FileStateStore::new(dir.path()).with_encryption(enc("old"));
896            old.put("bk", &json!("v1")).await.unwrap();
897
898            let rotated = FileStateStore::new(dir.path()).with_encryption(
899                CompiledEncryption::compile(&EncryptionSpec {
900                    key: "new".into(),
901                    previous_keys: vec!["old".into()],
902                    algorithm: Default::default(),
903                })
904                .unwrap(),
905            );
906            assert_eq!(rotated.get("bk").await.unwrap(), Some(json!("v1")));
907            // Re-seal under the new key; a store knowing only "new" can read it.
908            rotated.put("bk", &json!("v2")).await.unwrap();
909            let new_only = FileStateStore::new(dir.path()).with_encryption(enc("new"));
910            assert_eq!(new_only.get("bk").await.unwrap(), Some(json!("v2")));
911        }
912
913        #[tokio::test]
914        async fn no_temp_files_left_behind() {
915            let dir = tempfile::tempdir().unwrap();
916            let store = FileStateStore::new(dir.path()).with_encryption(enc("k1"));
917            store.put("bk", &json!(1)).await.unwrap();
918            let leftovers: Vec<_> = std::fs::read_dir(dir.path())
919                .unwrap()
920                .filter_map(Result::ok)
921                .filter(|e| e.path().to_string_lossy().ends_with(".tmp"))
922                .collect();
923            assert!(
924                leftovers.is_empty(),
925                "atomic write must leave no temp files"
926            );
927        }
928    }
929}