1use 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#[async_trait]
30pub trait StateStore: Send + Sync {
31 async fn get(&self, key: &str) -> Result<Option<Value>, FaucetError>;
33
34 async fn put(&self, key: &str, value: &Value) -> Result<(), FaucetError>;
39
40 async fn delete(&self, key: &str) -> Result<(), FaucetError>;
42
43 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 fn supports_list(&self) -> bool {
59 false
60 }
61
62 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 fn supports_atomic_batch(&self) -> bool {
74 false
75 }
76
77 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
88pub const STATE_EXPORT_VERSION: u32 = 1;
90
91pub fn namespace_prefix(pipeline: &str) -> String {
93 format!("{pipeline}::")
94}
95
96#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
102pub struct StateExport {
103 pub version: u32,
105 pub pipeline: String,
107 #[serde(default, skip_serializing_if = "Option::is_none")]
109 pub exported_at: Option<String>,
110 #[serde(default)]
112 pub keys: std::collections::BTreeMap<String, Value>,
113}
114
115impl StateExport {
116 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 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 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
171pub 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#[derive(Debug, Clone, Default, PartialEq, serde::Serialize)]
187pub struct ImportReport {
188 pub written: Vec<String>,
190 pub deleted: Vec<String>,
193 pub atomic: bool,
195 #[serde(skip_serializing_if = "Option::is_none")]
198 pub error: Option<String>,
199}
200
201pub 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
254pub const DOCTOR_SENTINEL_KEY: &str = "faucet_doctor_probe";
257
258pub 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 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#[derive(Default)]
302pub struct MemoryStateStore {
303 inner: Mutex<HashMap<String, Value>>,
304}
305
306impl MemoryStateStore {
307 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 Ok(crate::check::CheckReport::single(
373 crate::check::Probe::pass("sentinel", std::time::Duration::ZERO),
374 ))
375 }
376}
377
378fn safe_filename(key: &str) -> String {
386 key.replace(':', "%3A").replace('/', "%2F")
387}
388
389fn 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
395pub struct FileStateStore {
410 root: PathBuf,
411 write_lock: Mutex<()>,
412 #[cfg(feature = "encryption")]
416 encryption: Option<crate::encryption::CompiledEncryption>,
417}
418
419impl FileStateStore {
420 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 #[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 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 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 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 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 {
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 #[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 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 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 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 #[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 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 #[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 #[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 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 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 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 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 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 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 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 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 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 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 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 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 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 let got = s.get("k").await.unwrap().unwrap();
1024 let i = got["i"].as_i64().unwrap();
1025 assert!((0..50).contains(&i));
1026 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 #[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 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 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 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 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 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 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 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 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}