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_compare_and_put(&self) -> bool {
74 false
75 }
76
77 async fn compare_and_put(
86 &self,
87 key: &str,
88 expected: Option<&Value>,
89 value: &Value,
90 ) -> Result<bool, FaucetError> {
91 if self.get(key).await?.as_ref() != expected {
92 return Ok(false);
93 }
94 self.put(key, value).await?;
95 Ok(true)
96 }
97
98 fn supports_atomic_batch(&self) -> bool {
100 false
101 }
102
103 async fn put_batch(&self, entries: &[(String, Value)]) -> Result<(), FaucetError> {
107 for (key, value) in entries {
108 self.put(key, value).await?;
109 }
110 Ok(())
111 }
112}
113
114pub const STATE_EXPORT_VERSION: u32 = 1;
116
117pub fn namespace_prefix(pipeline: &str) -> String {
119 crate::state_key::StateKey::pipeline(pipeline).children_prefix()
120}
121
122#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
128pub struct StateExport {
129 pub version: u32,
131 pub pipeline: String,
133 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub exported_at: Option<String>,
136 #[serde(default)]
138 pub keys: std::collections::BTreeMap<String, Value>,
139}
140
141impl StateExport {
142 pub fn new(pipeline: impl Into<String>) -> Self {
144 Self {
145 version: STATE_EXPORT_VERSION,
146 pipeline: pipeline.into(),
147 exported_at: None,
148 keys: Default::default(),
149 }
150 }
151
152 pub fn from_value(value: Value) -> Result<Self, FaucetError> {
155 let version = match value.get("version") {
156 None => {
157 return Err(FaucetError::State(
158 "state export has no `version` field".into(),
159 ));
160 }
161 Some(v) => v.as_u64().ok_or_else(|| {
162 FaucetError::State(format!(
163 "state export `version` must be an integer, got {v}"
164 ))
165 })?,
166 };
167 if version == 0 || version > u64::from(STATE_EXPORT_VERSION) {
168 return Err(FaucetError::State(format!(
169 "state export version {version} is not supported by this build (supported: \
170 {STATE_EXPORT_VERSION}) — import it with the faucet release that wrote it or newer"
171 )));
172 }
173 let export: Self = serde_json::from_value(value)
174 .map_err(|e| FaucetError::State(format!("malformed state export: {e}")))?;
175 export.validate()?;
176 Ok(export)
177 }
178
179 pub fn validate(&self) -> Result<(), FaucetError> {
181 validate_state_key(&self.pipeline)
182 .map_err(|e| FaucetError::State(format!("state export pipeline name: {e}")))?;
183 let ns = namespace_prefix(&self.pipeline);
184 for key in self.keys.keys() {
185 validate_state_key(key)?;
186 if !key.starts_with(&ns) {
187 return Err(FaucetError::State(format!(
188 "state export key '{key}' is outside the namespace '{ns}' of pipeline '{}'",
189 self.pipeline
190 )));
191 }
192 }
193 Ok(())
194 }
195}
196
197pub async fn export_namespace(
199 store: &dyn StateStore,
200 pipeline: &str,
201) -> Result<StateExport, FaucetError> {
202 let mut export = StateExport::new(pipeline);
203 for key in store.list(&namespace_prefix(pipeline)).await? {
204 if let Some(value) = store.get(&key).await? {
205 export.keys.insert(key, value);
206 }
207 }
208 Ok(export)
209}
210
211#[derive(Debug, Clone, Default, PartialEq, serde::Serialize)]
213pub struct ImportReport {
214 pub written: Vec<String>,
216 pub deleted: Vec<String>,
219 pub atomic: bool,
221 #[serde(skip_serializing_if = "Option::is_none")]
224 pub error: Option<String>,
225}
226
227pub async fn import_namespace(
231 store: &dyn StateStore,
232 export: &StateExport,
233 replace: bool,
234) -> Result<ImportReport, FaucetError> {
235 export.validate()?;
236 let entries: Vec<(String, Value)> = export
237 .keys
238 .iter()
239 .map(|(k, v)| (k.clone(), v.clone()))
240 .collect();
241 let stale: Vec<String> = if replace {
242 store
243 .list(&namespace_prefix(&export.pipeline))
244 .await?
245 .into_iter()
246 .filter(|k| !export.keys.contains_key(k))
247 .collect()
248 } else {
249 Vec::new()
250 };
251 let mut report = ImportReport {
252 atomic: store.supports_atomic_batch(),
253 ..Default::default()
254 };
255 if report.atomic {
256 if let Err(e) = store.put_batch(&entries).await {
257 report.error = Some(e.to_string());
258 return Ok(report);
259 }
260 report.written = entries.into_iter().map(|(k, _)| k).collect();
261 } else {
262 for (key, value) in &entries {
263 if let Err(e) = store.put(key, value).await {
264 report.error = Some(format!("writing '{key}': {e}"));
265 return Ok(report);
266 }
267 report.written.push(key.clone());
268 }
269 }
270 for key in stale {
271 if let Err(e) = store.delete(&key).await {
272 report.error = Some(format!("deleting stale '{key}': {e}"));
273 return Ok(report);
274 }
275 report.deleted.push(key);
276 }
277 Ok(report)
278}
279
280pub const DOCTOR_SENTINEL_KEY: &str = "faucet_doctor_probe";
284
285pub fn doctor_sentinel_key() -> String {
289 format!("{DOCTOR_SENTINEL_KEY}.{}", uuid::Uuid::new_v4().simple())
290}
291
292pub fn validate_state_key(key: &str) -> Result<(), FaucetError> {
296 if key.is_empty() {
297 return Err(FaucetError::State("state key must not be empty".into()));
298 }
299 if key.len() > 256 {
300 return Err(FaucetError::State(format!(
301 "state key '{key}' exceeds 256 characters"
302 )));
303 }
304 for (i, c) in key.char_indices() {
305 let ok = c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | ':' | '.' | '/');
306 if !ok {
307 return Err(FaucetError::State(format!(
308 "state key '{key}' contains illegal character {c:?} at byte {i}"
309 )));
310 }
311 }
312 if key == "." || key == ".." || key.starts_with('.') {
313 return Err(FaucetError::State(format!(
314 "state key '{key}' must not begin with a dot"
315 )));
316 }
317 if key.contains('/')
321 && key
322 .split('/')
323 .any(|seg| seg.is_empty() || seg == "." || seg == ".." || seg.starts_with('.'))
324 {
325 return Err(FaucetError::State(format!(
326 "state key '{key}' has a path-like segment — `/` may only separate non-empty, non-dot segments"
327 )));
328 }
329 Ok(())
330}
331
332const LOCK_WAIT: std::time::Duration = std::time::Duration::from_secs(10);
334
335struct LockFile {
344 _file: std::fs::File,
345}
346
347impl LockFile {
348 fn remove(&self, path: &Path) -> Result<(), FaucetError> {
351 if cfg!(unix) {
352 match std::fs::remove_file(path) {
353 Ok(()) => {}
354 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
355 Err(e) => {
356 return Err(FaucetError::State(format!(
357 "failed to delete state lock {}: {e}",
358 path.display()
359 )));
360 }
361 }
362 }
363 Ok(())
364 }
365}
366
367#[cfg(unix)]
369fn names_open_file(file: &std::fs::File, path: &Path) -> Result<bool, FaucetError> {
370 use std::os::unix::fs::MetadataExt;
371 let open = file
372 .metadata()
373 .map_err(|e| FaucetError::State(format!("failed to stat state lock: {e}")))?;
374 match std::fs::metadata(path) {
375 Ok(m) => Ok(m.dev() == open.dev() && m.ino() == open.ino()),
376 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
377 Err(e) => Err(FaucetError::State(format!(
378 "failed to stat state lock {}: {e}",
379 path.display()
380 ))),
381 }
382}
383
384#[cfg(not(unix))]
385fn names_open_file(_file: &std::fs::File, _path: &Path) -> Result<bool, FaucetError> {
386 Ok(true)
387}
388
389async fn open_lock_file(path: &Path) -> Result<std::fs::File, FaucetError> {
390 Ok(tokio::fs::OpenOptions::new()
391 .create(true)
392 .truncate(false)
393 .write(true)
394 .open(path)
395 .await
396 .map_err(|e| {
397 FaucetError::State(format!(
398 "failed to create state lock {}: {e}",
399 path.display()
400 ))
401 })?
402 .into_std()
403 .await)
404}
405
406#[allow(clippy::disallowed_methods)] async fn acquire_lock_file(path: &Path) -> Result<LockFile, FaucetError> {
408 let started = tokio::time::Instant::now();
409 let mut file = open_lock_file(path).await?;
410 loop {
411 match file.try_lock() {
412 Ok(()) => {
413 if names_open_file(&file, path)? {
414 return Ok(LockFile { _file: file });
415 }
416 file = open_lock_file(path).await?;
417 }
418 Err(std::fs::TryLockError::WouldBlock) => {
419 if started.elapsed() > LOCK_WAIT {
420 return Err(FaucetError::State(format!(
421 "state lock {} is held by another writer",
422 path.display()
423 )));
424 }
425 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
426 }
427 Err(std::fs::TryLockError::Error(e)) => {
428 return Err(FaucetError::State(format!(
429 "failed to lock state lock {}: {e}",
430 path.display()
431 )));
432 }
433 }
434 }
435}
436
437#[derive(Default)]
441pub struct MemoryStateStore {
442 inner: Mutex<HashMap<String, Value>>,
443}
444
445impl MemoryStateStore {
446 pub fn new() -> Self {
448 Self::default()
449 }
450}
451
452#[async_trait]
453impl StateStore for MemoryStateStore {
454 async fn get(&self, key: &str) -> Result<Option<Value>, FaucetError> {
455 validate_state_key(key)?;
456 Ok(self.inner.lock().await.get(key).cloned())
457 }
458
459 async fn put(&self, key: &str, value: &Value) -> Result<(), FaucetError> {
460 validate_state_key(key)?;
461 self.inner
462 .lock()
463 .await
464 .insert(key.to_owned(), value.clone());
465 Ok(())
466 }
467
468 async fn delete(&self, key: &str) -> Result<(), FaucetError> {
469 validate_state_key(key)?;
470 self.inner.lock().await.remove(key);
471 Ok(())
472 }
473
474 fn supports_list(&self) -> bool {
475 true
476 }
477
478 async fn list(&self, prefix: &str) -> Result<Vec<String>, FaucetError> {
479 let mut keys: Vec<String> = self
480 .inner
481 .lock()
482 .await
483 .keys()
484 .filter(|k| k.starts_with(prefix))
485 .cloned()
486 .collect();
487 keys.sort();
488 Ok(keys)
489 }
490
491 fn supports_compare_and_put(&self) -> bool {
492 true
493 }
494
495 async fn compare_and_put(
496 &self,
497 key: &str,
498 expected: Option<&Value>,
499 value: &Value,
500 ) -> Result<bool, FaucetError> {
501 validate_state_key(key)?;
502 let mut map = self.inner.lock().await;
503 if map.get(key) != expected {
504 return Ok(false);
505 }
506 map.insert(key.to_owned(), value.clone());
507 Ok(true)
508 }
509
510 fn supports_atomic_batch(&self) -> bool {
511 true
512 }
513
514 async fn put_batch(&self, entries: &[(String, Value)]) -> Result<(), FaucetError> {
515 for (key, _) in entries {
516 validate_state_key(key)?;
517 }
518 let mut map = self.inner.lock().await;
519 for (key, value) in entries {
520 map.insert(key.clone(), value.clone());
521 }
522 Ok(())
523 }
524
525 async fn check(
526 &self,
527 _ctx: &crate::check::CheckContext,
528 ) -> Result<crate::check::CheckReport, FaucetError> {
529 Ok(crate::check::CheckReport::single(
531 crate::check::Probe::pass("sentinel", std::time::Duration::ZERO),
532 ))
533 }
534}
535
536const MAX_STEM: usize = 160;
543
544fn safe_filename(key: &str) -> String {
560 let mut out = String::with_capacity(key.len() + 8);
561 for c in key.chars() {
562 match c {
563 ':' => out.push_str("%3A"),
564 '/' => out.push_str("%2F"),
565 'A'..='Z' => out.push_str(&format!("%{:02X}", c as u32)),
566 other => out.push(other),
567 }
568 }
569 if out.len() <= MAX_STEM {
570 return out;
571 }
572 use sha2::Digest;
573 let digest = sha2::Sha256::digest(key.as_bytes());
574 let hex: String = digest.iter().map(|b| format!("{b:02x}")).collect();
575 let mut cut = MAX_STEM - hex.len() - 1;
576 while cut > 0
578 && out.as_bytes()[..cut]
579 .iter()
580 .rev()
581 .take(2)
582 .any(|b| *b == b'%')
583 {
584 cut -= 1;
585 }
586 format!("{}~{hex}", &out[..cut])
587}
588
589fn key_from_filename(stem: &str) -> Option<String> {
592 if stem.contains('~') {
593 return None;
594 }
595 let bytes = stem.as_bytes();
596 let mut key = String::with_capacity(stem.len());
597 let mut i = 0;
598 while i < bytes.len() {
599 if bytes[i] == b'%' {
600 let hex = stem.get(i + 1..i + 3)?;
601 let code = u8::from_str_radix(hex, 16).ok()?;
602 key.push(char::from(code));
603 i += 3;
604 } else {
605 key.push(char::from(bytes[i]));
606 i += 1;
607 }
608 }
609 validate_state_key(&key).ok().map(|()| key)
610}
611
612pub struct FileStateStore {
628 root: PathBuf,
629 write_lock: Mutex<()>,
630 migrated: tokio::sync::OnceCell<()>,
631 #[cfg(feature = "encryption")]
635 encryption: Option<crate::encryption::CompiledEncryption>,
636}
637
638impl FileStateStore {
639 pub fn new(root: impl Into<PathBuf>) -> Self {
641 Self {
642 root: root.into(),
643 write_lock: Mutex::new(()),
644 migrated: tokio::sync::OnceCell::new(),
645 #[cfg(feature = "encryption")]
646 encryption: None,
647 }
648 }
649
650 #[cfg(feature = "encryption")]
653 pub fn with_encryption(mut self, encryption: crate::encryption::CompiledEncryption) -> Self {
654 self.encryption = Some(encryption);
655 self
656 }
657
658 fn entry_path(&self, key: &str) -> PathBuf {
659 self.root.join(format!("{}.json", safe_filename(key)))
660 }
661
662 pub fn path_for(&self, key: &str) -> PathBuf {
665 self.entry_path(key)
666 }
667
668 fn temp_path(&self, key: &str) -> PathBuf {
669 use std::sync::OnceLock;
686 use std::sync::atomic::{AtomicU64, Ordering};
687 static PROC_TOKEN: OnceLock<String> = OnceLock::new();
688 static SEQ: AtomicU64 = AtomicU64::new(0);
689 let token = PROC_TOKEN.get_or_init(|| uuid::Uuid::new_v4().simple().to_string());
690 let seq = SEQ.fetch_add(1, Ordering::Relaxed);
691 self.root
692 .join(format!("{}.{}.{}.json.tmp", safe_filename(key), token, seq))
693 }
694
695 fn lock_path(&self, key: &str) -> PathBuf {
696 self.root.join(format!("{}.json.lock", safe_filename(key)))
697 }
698
699 async fn ensure_root(&self) -> Result<(), FaucetError> {
700 tokio::fs::create_dir_all(&self.root).await.map_err(|e| {
701 FaucetError::State(format!(
702 "failed to create state dir {}: {e}",
703 self.root.display()
704 ))
705 })
706 }
707
708 async fn read_entry(&self, key: &str, path: &Path) -> Result<Option<Value>, FaucetError> {
709 #[cfg(not(feature = "encryption"))]
710 let _ = key;
711 match tokio::fs::read(path).await {
712 Ok(bytes) => {
713 #[cfg(feature = "encryption")]
714 let bytes: Vec<u8> = if crate::encryption::is_encrypted(&bytes) {
715 match &self.encryption {
716 Some(enc) => enc.decrypt_bound(&bytes, key.as_bytes()).map_err(|e| {
717 FaucetError::State(format!(
721 "state file {} could not be decrypted: {e}",
722 path.display()
723 ))
724 })?,
725 None => {
726 return Err(FaucetError::State(format!(
727 "state file {} is encrypted but no `encryption` block is \
728 configured on the file state store — add \
729 `state.config.encryption` with the original key",
730 path.display()
731 )));
732 }
733 }
734 } else {
735 bytes
736 };
737 #[cfg(not(feature = "encryption"))]
738 let bytes = {
739 if bytes.starts_with(b"FCT1") {
742 return Err(FaucetError::State(format!(
743 "state file {} is encrypted but this build of faucet has no \
744 `encryption` feature",
745 path.display()
746 )));
747 }
748 bytes
749 };
750 let value: Value = serde_json::from_slice(&bytes).map_err(|e| {
751 FaucetError::State(format!(
752 "failed to parse state file {}: {e}",
753 path.display()
754 ))
755 })?;
756 Ok(Some(value))
757 }
758 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
759 Err(e) => Err(FaucetError::State(format!(
760 "failed to read state file {}: {e}",
761 path.display()
762 ))),
763 }
764 }
765
766 async fn migrate_legacy_names(&self) -> Result<(), FaucetError> {
772 self.migrated
773 .get_or_try_init(|| async {
774 let mut dir = match tokio::fs::read_dir(&self.root).await {
775 Ok(d) => d,
776 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
777 Err(e) => {
778 return Err(FaucetError::State(format!(
779 "failed to list state dir {}: {e}",
780 self.root.display()
781 )));
782 }
783 };
784 let mut renames = Vec::new();
785 while let Some(entry) = dir.next_entry().await.map_err(|e| {
786 FaucetError::State(format!(
787 "failed to list state dir {}: {e}",
788 self.root.display()
789 ))
790 })? {
791 let name = entry.file_name();
792 let Some(stem) = name.to_str().and_then(|n| n.strip_suffix(".json")) else {
793 continue;
794 };
795 if let Some(key) = key_from_filename(stem)
796 && safe_filename(&key) != stem
797 {
798 renames.push((key, entry.path()));
799 }
800 }
801 for (key, from) in renames {
802 if let Some(sidecar) = self.key_sidecar(&key) {
803 tokio::fs::write(&sidecar, key.as_bytes())
804 .await
805 .map_err(|e| {
806 FaucetError::State(format!(
807 "failed to write state key file {}: {e}",
808 sidecar.display()
809 ))
810 })?;
811 }
812 let to = self.entry_path(&key);
813 if tokio::fs::try_exists(&to).await.unwrap_or(false) {
814 let _ = tokio::fs::remove_file(&from).await;
815 continue;
816 }
817 match tokio::fs::rename(&from, &to).await {
818 Ok(()) => {}
819 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
820 Err(e) => {
821 return Err(FaucetError::State(format!(
822 "failed to rename legacy state file {} to {}: {e}",
823 from.display(),
824 to.display()
825 )));
826 }
827 }
828 }
829 Ok(())
830 })
831 .await
832 .map(|_| ())
833 }
834
835 fn key_sidecar(&self, key: &str) -> Option<PathBuf> {
836 let stem = safe_filename(key);
837 stem.contains('~')
838 .then(|| self.root.join(format!("{stem}.key")))
839 }
840
841 pub fn root(&self) -> &Path {
843 &self.root
844 }
845}
846
847#[async_trait]
848impl StateStore for FileStateStore {
849 async fn get(&self, key: &str) -> Result<Option<Value>, FaucetError> {
850 validate_state_key(key)?;
851 self.migrate_legacy_names().await?;
852 self.read_entry(key, &self.entry_path(key)).await
853 }
854
855 async fn put(&self, key: &str, value: &Value) -> Result<(), FaucetError> {
856 validate_state_key(key)?;
857 let _guard = self.write_lock.lock().await;
858 self.ensure_root().await?;
859 let bytes = serde_json::to_vec(value).map_err(|e| {
860 FaucetError::State(format!("failed to serialize state for key '{key}': {e}"))
861 })?;
862 #[cfg(feature = "encryption")]
863 let bytes = match &self.encryption {
864 Some(enc) => enc.encrypt_bound(&bytes, key.as_bytes()),
865 None => bytes,
866 };
867 self.migrate_legacy_names().await?;
868 let final_path = self.entry_path(key);
869 let tmp_path = self.temp_path(key);
870
871 if let Some(sidecar) = self.key_sidecar(key) {
872 tokio::fs::write(&sidecar, key.as_bytes())
873 .await
874 .map_err(|e| {
875 FaucetError::State(format!(
876 "failed to write state key file {}: {e}",
877 sidecar.display()
878 ))
879 })?;
880 }
881
882 let written: Result<(), FaucetError> = async {
889 let mut file = tokio::fs::File::create(&tmp_path).await.map_err(|e| {
890 FaucetError::State(format!(
891 "failed to create temp state file {}: {e}",
892 tmp_path.display()
893 ))
894 })?;
895 file.write_all(&bytes).await.map_err(|e| {
896 FaucetError::State(format!(
897 "failed to write temp state file {}: {e}",
898 tmp_path.display()
899 ))
900 })?;
901 file.sync_all().await.map_err(|e| {
902 FaucetError::State(format!(
903 "failed to fsync temp state file {}: {e}",
904 tmp_path.display()
905 ))
906 })?;
907 drop(file);
908 tokio::fs::rename(&tmp_path, &final_path)
909 .await
910 .map_err(|e| {
911 FaucetError::State(format!(
912 "failed to commit state file {}: {e}",
913 final_path.display()
914 ))
915 })
916 }
917 .await;
918 if let Err(e) = written {
919 let _ = tokio::fs::remove_file(&tmp_path).await;
920 return Err(e);
921 }
922
923 #[cfg(unix)]
929 {
930 let dir = tokio::fs::File::open(&self.root).await.map_err(|e| {
931 FaucetError::State(format!(
932 "failed to open state dir {} for fsync: {e}",
933 self.root.display()
934 ))
935 })?;
936 dir.sync_all().await.map_err(|e| {
937 FaucetError::State(format!(
938 "failed to fsync state dir {}: {e}",
939 self.root.display()
940 ))
941 })?;
942 }
943
944 tracing::debug!(
945 key,
946 path = %final_path.display(),
947 "state file written"
948 );
949 Ok(())
950 }
951
952 async fn delete(&self, key: &str) -> Result<(), FaucetError> {
953 validate_state_key(key)?;
954 self.migrate_legacy_names().await?;
955 let lock_path = self.lock_path(key);
956 let held = if tokio::fs::try_exists(&self.root).await.unwrap_or(false) {
957 Some(acquire_lock_file(&lock_path).await?)
958 } else {
959 None
960 };
961 let mut paths = vec![self.entry_path(key)];
962 paths.extend(self.key_sidecar(key));
963 for path in paths {
964 match tokio::fs::remove_file(&path).await {
965 Ok(()) => {}
966 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
967 Err(e) => {
968 return Err(FaucetError::State(format!(
969 "failed to delete state file {}: {e}",
970 path.display()
971 )));
972 }
973 }
974 }
975 if let Some(held) = held {
976 held.remove(&lock_path)?;
977 }
978 Ok(())
979 }
980
981 fn supports_compare_and_put(&self) -> bool {
982 true
983 }
984
985 async fn compare_and_put(
992 &self,
993 key: &str,
994 expected: Option<&Value>,
995 value: &Value,
996 ) -> Result<bool, FaucetError> {
997 validate_state_key(key)?;
998 self.ensure_root().await?;
999 let _held = acquire_lock_file(&self.lock_path(key)).await?;
1000 if self.get(key).await?.as_ref() != expected {
1001 return Ok(false);
1002 }
1003 self.put(key, value).await?;
1004 Ok(true)
1005 }
1006
1007 fn supports_list(&self) -> bool {
1008 true
1009 }
1010
1011 async fn list(&self, prefix: &str) -> Result<Vec<String>, FaucetError> {
1012 self.migrate_legacy_names().await?;
1013 let mut dir = match tokio::fs::read_dir(&self.root).await {
1014 Ok(d) => d,
1015 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
1016 Err(e) => {
1017 return Err(FaucetError::State(format!(
1018 "failed to list state dir {}: {e}",
1019 self.root.display()
1020 )));
1021 }
1022 };
1023 let mut keys = Vec::new();
1024 loop {
1025 let entry = dir.next_entry().await.map_err(|e| {
1026 FaucetError::State(format!(
1027 "failed to list state dir {}: {e}",
1028 self.root.display()
1029 ))
1030 })?;
1031 let Some(entry) = entry else { break };
1032 let name = entry.file_name();
1033 let Some(stem) = name.to_str().and_then(|n| n.strip_suffix(".json")) else {
1034 continue;
1035 };
1036 let key = if stem.contains('~') {
1037 tokio::fs::read_to_string(self.root.join(format!("{stem}.key")))
1038 .await
1039 .ok()
1040 .filter(|k| validate_state_key(k).is_ok())
1041 } else {
1042 key_from_filename(stem)
1043 };
1044 if let Some(key) = key
1045 && key.starts_with(prefix)
1046 {
1047 keys.push(key);
1048 }
1049 }
1050 keys.sort();
1051 keys.dedup();
1052 Ok(keys)
1053 }
1054
1055 async fn check(
1056 &self,
1057 _ctx: &crate::check::CheckContext,
1058 ) -> Result<crate::check::CheckReport, FaucetError> {
1059 use crate::check::{CheckReport, Probe};
1060 let start = std::time::Instant::now();
1064 let probe = match self.sentinel_roundtrip().await {
1065 Ok(()) => Probe::pass("sentinel", start.elapsed()),
1066 Err(e) => Probe::fail_hint(
1067 "sentinel",
1068 start.elapsed(),
1069 e.to_string(),
1070 format!("ensure {} exists and is writable", self.root.display()),
1071 ),
1072 };
1073 Ok(CheckReport::single(probe))
1074 }
1075}
1076
1077impl FileStateStore {
1078 async fn sentinel_roundtrip(&self) -> Result<(), FaucetError> {
1081 let probe = serde_json::json!({ "faucet_doctor": true });
1082 let key = doctor_sentinel_key();
1083 self.put(&key, &probe).await?;
1084 let got = self.get(&key).await?;
1085 let _ = self.delete(&key).await;
1087 match got {
1088 Some(v) if v == probe => Ok(()),
1089 _ => Err(FaucetError::State(
1090 "sentinel readback did not match what was written".into(),
1091 )),
1092 }
1093 }
1094}
1095
1096#[cfg(test)]
1097#[allow(clippy::disallowed_methods)] mod tests {
1099 use super::*;
1100 use serde_json::json;
1101 use std::sync::Arc;
1102 use tempfile::TempDir;
1103
1104 #[tokio::test]
1107 async fn a_lock_that_cannot_be_created_is_a_state_error() {
1108 let dir = TempDir::new().unwrap();
1109 let path = dir.path().join("missing-dir").join("k.lock");
1110 let err = acquire_lock_file(&path).await.err().unwrap().to_string();
1111 assert!(err.contains("failed to create state lock"), "{err}");
1112 }
1113
1114 #[test]
1115 fn rejects_empty_key() {
1116 let err = validate_state_key("").unwrap_err();
1117 assert!(matches!(err, FaucetError::State(_)));
1118 }
1119
1120 #[test]
1121 fn rejects_path_traversal_segments() {
1122 for k in [
1123 "../etc/passwd",
1124 "a/../b",
1125 "a/./b",
1126 "a//b",
1127 "/a",
1128 "a/",
1129 "a/.x",
1130 "a\\b",
1131 "..",
1132 ".",
1133 ] {
1134 assert!(validate_state_key(k).is_err(), "expected reject for {k:?}");
1135 }
1136 assert!(validate_state_key("acme/erp::invoices").is_ok());
1138 }
1139
1140 #[test]
1141 fn rejects_leading_dot() {
1142 assert!(validate_state_key(".hidden").is_err());
1143 }
1144
1145 #[test]
1146 fn rejects_over_long_key() {
1147 let k = "a".repeat(257);
1148 assert!(validate_state_key(&k).is_err());
1149 }
1150
1151 #[test]
1152 fn accepts_typical_keys() {
1153 for k in [
1154 "github_issues",
1155 "pipeline:rest:issues",
1156 "with.dot",
1157 "with-dash_and_underscore",
1158 "lower-Case_99",
1159 ] {
1160 validate_state_key(k).unwrap_or_else(|e| panic!("expected ok for {k:?}: {e}"));
1161 }
1162 }
1163
1164 #[tokio::test]
1167 async fn compare_and_put_writes_only_over_the_expected_value() {
1168 let mem = MemoryStateStore::new();
1169 assert!(mem.supports_compare_and_put());
1170 let a = serde_json::json!({"v": 1});
1171 let b = serde_json::json!({"v": 2});
1172 assert!(mem.compare_and_put("k", None, &a).await.unwrap());
1173 assert!(!mem.compare_and_put("k", None, &b).await.unwrap());
1174 assert!(!mem.compare_and_put("k", Some(&b), &b).await.unwrap());
1175 assert!(mem.compare_and_put("k", Some(&a), &b).await.unwrap());
1176 assert_eq!(mem.get("k").await.unwrap(), Some(b.clone()));
1177 assert!(mem.compare_and_put("bad key!", None, &a).await.is_err());
1178
1179 let dir = tempfile::tempdir().unwrap();
1180 let file = FileStateStore::new(dir.path());
1181 assert!(file.supports_compare_and_put());
1182 assert!(file.compare_and_put("k", None, &a).await.unwrap());
1183 assert!(!file.compare_and_put("k", Some(&b), &b).await.unwrap());
1184 assert!(file.compare_and_put("k", Some(&a), &b).await.unwrap());
1185 assert_eq!(file.get("k").await.unwrap(), Some(b));
1186 }
1187
1188 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1189 async fn file_compare_and_put_is_exclusive() {
1190 let dir = tempfile::tempdir().unwrap();
1191 let stores: Vec<std::sync::Arc<FileStateStore>> = (0..8)
1192 .map(|_| std::sync::Arc::new(FileStateStore::new(dir.path())))
1193 .collect();
1194 for round in 0..20 {
1195 let key = format!("lease{round}");
1196 let tasks: Vec<_> = stores
1197 .iter()
1198 .enumerate()
1199 .map(|(i, s)| {
1200 let (s, key) = (std::sync::Arc::clone(s), key.clone());
1201 tokio::spawn(async move {
1202 s.compare_and_put(&key, None, &json!({"by": i}))
1203 .await
1204 .unwrap()
1205 })
1206 })
1207 .collect();
1208 let mut wins = 0;
1209 for t in tasks {
1210 wins += usize::from(t.await.unwrap());
1211 }
1212 assert_eq!(wins, 1, "exactly one writer takes an absent key");
1213 }
1214 }
1215
1216 #[tokio::test]
1219 async fn file_compare_and_put_takes_a_lock_left_by_a_dead_writer() {
1220 let dir = tempfile::tempdir().unwrap();
1221 let store = FileStateStore::new(dir.path());
1222 std::fs::write(dir.path().join("lease.json.lock"), b"").unwrap();
1223 let started = tokio::time::Instant::now();
1224 assert!(
1225 store
1226 .compare_and_put("lease", None, &json!(1))
1227 .await
1228 .unwrap()
1229 );
1230 assert!(started.elapsed() < std::time::Duration::from_secs(1));
1231 assert!(
1232 store
1233 .compare_and_put("lease", Some(&json!(1)), &json!(2))
1234 .await
1235 .unwrap(),
1236 "the lock is released after each compare-and-put"
1237 );
1238 assert_eq!(store.list("").await.unwrap(), vec!["lease".to_string()]);
1239 }
1240
1241 #[cfg(unix)]
1242 #[tokio::test]
1243 async fn file_delete_removes_the_lock_file_and_the_key_stays_usable() {
1244 let dir = tempfile::tempdir().unwrap();
1245 let store = FileStateStore::new(dir.path());
1246 assert!(store.compare_and_put("k", None, &json!(1)).await.unwrap());
1247 assert!(dir.path().join("k.json.lock").exists());
1248 store.delete("k").await.unwrap();
1249 assert!(!dir.path().join("k.json.lock").exists());
1250 assert!(store.get("k").await.unwrap().is_none());
1251 assert!(store.compare_and_put("k", None, &json!(2)).await.unwrap());
1252 assert_eq!(store.get("k").await.unwrap(), Some(json!(2)));
1253 FileStateStore::new(dir.path().join("never-created"))
1254 .delete("k")
1255 .await
1256 .unwrap();
1257 }
1258
1259 #[cfg(unix)]
1262 #[tokio::test]
1263 async fn a_lock_won_on_an_unlinked_file_is_retaken_on_the_path() {
1264 use std::os::unix::fs::MetadataExt;
1265 let dir = tempfile::tempdir().unwrap();
1266 let path = dir.path().join("k.json.lock");
1267 let holder = std::fs::File::create(&path).unwrap();
1268 holder.lock().unwrap();
1269 let waiter = tokio::spawn({
1270 let path = path.clone();
1271 async move { acquire_lock_file(&path).await }
1272 });
1273 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1274 std::fs::remove_file(&path).unwrap();
1275 drop(holder);
1276 let held = waiter.await.unwrap().unwrap();
1277 let open = held._file.metadata().unwrap();
1278 let on_disk = std::fs::metadata(&path).unwrap();
1279 assert_eq!((open.dev(), open.ino()), (on_disk.dev(), on_disk.ino()));
1280 }
1281
1282 #[tokio::test(start_paused = true)]
1283 async fn file_compare_and_put_gives_up_on_a_live_lock() {
1284 let dir = tempfile::tempdir().unwrap();
1285 let store = FileStateStore::new(dir.path());
1286 let held = std::fs::File::create(dir.path().join("k.json.lock")).unwrap();
1287 held.lock().unwrap();
1288 let err = store
1289 .compare_and_put("k", None, &json!(1))
1290 .await
1291 .unwrap_err()
1292 .to_string();
1293 assert!(err.contains("held by another writer"), "{err}");
1294 drop(held);
1295 assert!(store.compare_and_put("k", None, &json!(1)).await.unwrap());
1296 }
1297
1298 #[tokio::test]
1299 async fn memory_get_returns_none_for_missing_key() {
1300 let s = MemoryStateStore::new();
1301 assert!(s.get("nope").await.unwrap().is_none());
1302 }
1303
1304 #[tokio::test]
1305 async fn memory_put_then_get_round_trips() {
1306 let s = MemoryStateStore::new();
1307 s.put("k", &json!({"cursor": "abc", "n": 7})).await.unwrap();
1308 let got = s.get("k").await.unwrap().unwrap();
1309 assert_eq!(got["cursor"], "abc");
1310 assert_eq!(got["n"], 7);
1311 }
1312
1313 #[tokio::test]
1314 async fn memory_put_overwrites_previous_value() {
1315 let s = MemoryStateStore::new();
1316 s.put("k", &json!(1)).await.unwrap();
1317 s.put("k", &json!(2)).await.unwrap();
1318 assert_eq!(s.get("k").await.unwrap().unwrap(), json!(2));
1319 }
1320
1321 #[tokio::test]
1322 async fn memory_delete_makes_get_return_none() {
1323 let s = MemoryStateStore::new();
1324 s.put("k", &json!("v")).await.unwrap();
1325 s.delete("k").await.unwrap();
1326 assert!(s.get("k").await.unwrap().is_none());
1327 }
1328
1329 #[tokio::test]
1330 async fn memory_delete_missing_key_is_ok() {
1331 let s = MemoryStateStore::new();
1332 s.delete("absent").await.unwrap();
1333 }
1334
1335 #[tokio::test]
1336 async fn memory_rejects_invalid_keys() {
1337 let s = MemoryStateStore::new();
1338 assert!(s.get("a b").await.is_err());
1339 assert!(s.put("a b", &json!(1)).await.is_err());
1340 assert!(s.delete("a b").await.is_err());
1341 }
1342
1343 #[tokio::test]
1346 async fn file_get_returns_none_for_missing_key() {
1347 let dir = TempDir::new().unwrap();
1348 let s = FileStateStore::new(dir.path());
1349 assert!(s.get("nope").await.unwrap().is_none());
1350 }
1351
1352 #[tokio::test]
1353 async fn file_put_creates_root_directory_lazily() {
1354 let dir = TempDir::new().unwrap();
1355 let root = dir.path().join("nested/state");
1356 let s = FileStateStore::new(&root);
1357 s.put("k", &json!("v")).await.unwrap();
1358 assert!(root.is_dir(), "root dir should be created on first put");
1359 }
1360
1361 #[tokio::test]
1362 async fn file_put_then_get_round_trips() {
1363 let dir = TempDir::new().unwrap();
1364 let s = FileStateStore::new(dir.path());
1365 let value = json!({"cursor": "abc", "n": 42, "nested": {"flag": true}});
1366 s.put("github_issues", &value).await.unwrap();
1367 let got = s.get("github_issues").await.unwrap().unwrap();
1368 assert_eq!(got, value);
1369 }
1370
1371 #[test]
1372 fn temp_path_is_unique_and_not_pid_derived() {
1373 let dir = TempDir::new().unwrap();
1379 let s = FileStateStore::new(dir.path());
1380
1381 let a = s.temp_path("k");
1382 let b = s.temp_path("k");
1383 assert_ne!(a, b);
1385
1386 let name_a = a.file_name().unwrap().to_str().unwrap();
1387 let pid = std::process::id().to_string();
1388 assert!(
1390 !name_a.split('.').any(|seg| seg == pid),
1391 "temp filename {name_a} must not embed the process id ({pid})"
1392 );
1393 assert!(name_a.ends_with(".json.tmp"));
1394 }
1395
1396 #[test]
1397 fn safe_filename_percent_encodes_colon() {
1398 assert_eq!(
1399 safe_filename("pipeline:rest:issues"),
1400 "pipeline%3Arest%3Aissues"
1401 );
1402 assert_eq!(safe_filename("plain_key-1.v2"), "plain_key-1.v2");
1403 assert_eq!(
1406 safe_filename("acme/erp::invoices"),
1407 "acme%2Ferp%3A%3Ainvoices"
1408 );
1409 assert!(validate_state_key("acme/erp::invoices").is_ok());
1410 assert!(validate_state_key("a b").is_err());
1411 }
1412
1413 #[tokio::test]
1414 async fn file_round_trips_colon_keys_with_safe_filename() {
1415 let dir = TempDir::new().unwrap();
1419 let s = FileStateStore::new(dir.path());
1420 let value = json!({"cursor": "z"});
1421 s.put("pipeline:rest:issues", &value).await.unwrap();
1422 assert_eq!(s.get("pipeline:rest:issues").await.unwrap().unwrap(), value);
1423 assert!(dir.path().join("pipeline%3Arest%3Aissues.json").exists());
1425 let mut has_colon = false;
1426 for entry in std::fs::read_dir(dir.path()).unwrap() {
1427 if entry.unwrap().file_name().to_string_lossy().contains(':') {
1428 has_colon = true;
1429 }
1430 }
1431 assert!(!has_colon, "no state filename may contain ':'");
1432 }
1433
1434 #[test]
1435 fn safe_filename_escapes_uppercase_and_round_trips() {
1436 assert_eq!(safe_filename("Users::x"), "%55sers%3A%3Ax");
1437 assert_ne!(
1438 safe_filename("Users::x").to_lowercase(),
1439 safe_filename("users::x").to_lowercase()
1440 );
1441 for k in ["Users::x", "a/B:c", "plain"] {
1442 assert_eq!(key_from_filename(&safe_filename(k)).as_deref(), Some(k));
1443 }
1444 assert_eq!(key_from_filename("Users%3Ax").as_deref(), Some("Users:x"));
1446 assert_eq!(key_from_filename("bad%Z"), None);
1447 assert_eq!(key_from_filename("a~b"), None);
1448 }
1449
1450 #[test]
1451 fn long_keys_hash_into_a_bounded_stem() {
1452 let key = format!("p::{}", "A:".repeat(120));
1453 let stem = safe_filename(&key);
1454 assert!(stem.len() <= MAX_STEM, "{}", stem.len());
1455 assert!(stem.contains('~'));
1456 let other = format!("p::{}B", "A:".repeat(119));
1457 assert_ne!(safe_filename(&other), stem);
1458 }
1459
1460 #[tokio::test]
1461 async fn keys_differing_only_in_case_keep_separate_files() {
1462 let dir = TempDir::new().unwrap();
1463 let s = FileStateStore::new(dir.path());
1464 s.put("Users::x", &json!(1)).await.unwrap();
1465 s.put("users::x", &json!(2)).await.unwrap();
1466 assert_eq!(s.get("Users::x").await.unwrap(), Some(json!(1)));
1467 assert_eq!(s.get("users::x").await.unwrap(), Some(json!(2)));
1468 assert_eq!(s.list("").await.unwrap(), vec!["Users::x", "users::x"]);
1469 }
1470
1471 #[tokio::test]
1472 async fn a_long_key_round_trips_lists_and_deletes() {
1473 let dir = TempDir::new().unwrap();
1474 let s = FileStateStore::new(dir.path());
1475 let key = format!("pipe::row::{}", "parent:".repeat(34));
1476 assert!(key.len() <= 256);
1477 s.put(&key, &json!({"c": 1})).await.unwrap();
1478 assert_eq!(s.get(&key).await.unwrap(), Some(json!({"c": 1})));
1479 assert_eq!(s.list("pipe").await.unwrap(), vec![key.clone()]);
1480 s.delete(&key).await.unwrap();
1481 assert_eq!(s.get(&key).await.unwrap(), None);
1482 assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0);
1483 }
1484
1485 #[tokio::test]
1486 async fn legacy_named_files_are_read_and_migrated() {
1487 let dir = TempDir::new().unwrap();
1488 std::fs::write(dir.path().join("Users%3Ax.json"), b"{\"c\":7}").unwrap();
1489 let s = FileStateStore::new(dir.path());
1490 assert_eq!(s.get("users:x").await.unwrap(), None);
1492 assert_eq!(s.get("Users:x").await.unwrap(), Some(json!({"c": 7})));
1493 s.put("Users:x", &json!({"c": 8})).await.unwrap();
1494 let names: Vec<String> = std::fs::read_dir(dir.path())
1495 .unwrap()
1496 .map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
1497 .collect();
1498 assert_eq!(names, vec!["%55sers%3Ax.json".to_string()]);
1499 assert_eq!(s.get("Users:x").await.unwrap(), Some(json!({"c": 8})));
1500 s.delete("Users:x").await.unwrap();
1501 assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0);
1502
1503 let dir = TempDir::new().unwrap();
1505 let key = format!("p::{}", "q".repeat(170));
1506 std::fs::write(
1507 dir.path().join(format!("p%3A%3A{}.json", "q".repeat(170))),
1508 b"3",
1509 )
1510 .unwrap();
1511 let s = FileStateStore::new(dir.path());
1512 assert_eq!(s.list("p").await.unwrap(), vec![key.clone()]);
1513 assert_eq!(s.get(&key).await.unwrap(), Some(json!(3)));
1514 }
1515
1516 #[tokio::test]
1517 async fn a_failed_put_leaves_no_temp_file() {
1518 let dir = TempDir::new().unwrap();
1519 let s = FileStateStore::new(dir.path());
1520 std::fs::create_dir(dir.path().join("k.json")).unwrap();
1522 std::fs::write(dir.path().join("k.json").join("x"), b"x").unwrap();
1523 assert!(s.put("k", &json!(1)).await.is_err());
1524 assert!(!has_tmp_residue(dir.path()));
1525 }
1526
1527 #[test]
1528 fn doctor_sentinel_keys_are_unique_and_valid() {
1529 let a = doctor_sentinel_key();
1530 assert_ne!(a, doctor_sentinel_key());
1531 assert!(a.starts_with(DOCTOR_SENTINEL_KEY));
1532 validate_state_key(&a).unwrap();
1533 }
1534
1535 fn has_tmp_residue(dir: &std::path::Path) -> bool {
1539 std::fs::read_dir(dir)
1540 .unwrap()
1541 .filter_map(|e| e.ok())
1542 .any(|e| e.file_name().to_string_lossy().ends_with(".json.tmp"))
1543 }
1544
1545 #[tokio::test]
1546 async fn file_put_overwrites_previous_value_atomically() {
1547 let dir = TempDir::new().unwrap();
1548 let s = FileStateStore::new(dir.path());
1549 s.put("k", &json!({"v": 1})).await.unwrap();
1550 s.put("k", &json!({"v": 2})).await.unwrap();
1551 assert_eq!(s.get("k").await.unwrap().unwrap(), json!({"v": 2}));
1552 assert!(!has_tmp_residue(dir.path()), "no temp residue after put");
1554 }
1555
1556 #[test]
1557 fn file_temp_paths_are_unique_per_write() {
1558 let dir = TempDir::new().unwrap();
1563 let s = FileStateStore::new(dir.path());
1564 let a = s.temp_path("k");
1565 let b = s.temp_path("k");
1566 assert_ne!(a, b, "each write must get a distinct temp path");
1567 assert_eq!(s.entry_path("k"), s.entry_path("k"));
1569 }
1570
1571 #[tokio::test]
1572 async fn file_put_writes_complete_durable_file_with_no_temp_residue() {
1573 let dir = TempDir::new().unwrap();
1580 let s = FileStateStore::new(dir.path());
1581 let big: Vec<Value> = (0..1_000)
1582 .map(|i| json!({"i": i, "s": "x".repeat(20)}))
1583 .collect();
1584 let value = json!({"cursor": "abc", "rows": big});
1585
1586 s.put("github_issues", &value).await.unwrap();
1587
1588 let raw = tokio::fs::read(dir.path().join("github_issues.json"))
1590 .await
1591 .expect("state file must exist after put");
1592 assert!(!raw.is_empty(), "state file must not be zero-length");
1593 let parsed: Value = serde_json::from_slice(&raw).expect("state file must be valid JSON");
1594 assert_eq!(parsed, value);
1595
1596 assert!(!has_tmp_residue(dir.path()), "no temp residue after put");
1598 }
1599
1600 #[tokio::test]
1601 async fn file_delete_removes_file() {
1602 let dir = TempDir::new().unwrap();
1603 let s = FileStateStore::new(dir.path());
1604 s.put("k", &json!("v")).await.unwrap();
1605 s.delete("k").await.unwrap();
1606 assert!(s.get("k").await.unwrap().is_none());
1607 assert!(!dir.path().join("k.json").exists());
1608 }
1609
1610 #[tokio::test]
1611 async fn file_delete_missing_key_is_ok() {
1612 let dir = TempDir::new().unwrap();
1613 let s = FileStateStore::new(dir.path());
1614 s.delete("absent").await.unwrap();
1615 }
1616
1617 #[tokio::test]
1618 async fn file_get_returns_error_for_corrupt_json() {
1619 let dir = TempDir::new().unwrap();
1620 let s = FileStateStore::new(dir.path());
1621 tokio::fs::create_dir_all(dir.path()).await.unwrap();
1622 tokio::fs::write(dir.path().join("bad.json"), b"not json")
1623 .await
1624 .unwrap();
1625 let err = s.get("bad").await.unwrap_err();
1626 match err {
1627 FaucetError::State(msg) => assert!(msg.contains("bad.json")),
1628 other => panic!("expected State error, got {other:?}"),
1629 }
1630 }
1631
1632 #[tokio::test]
1633 async fn file_concurrent_puts_do_not_corrupt_or_leak_temp() {
1634 let dir = TempDir::new().unwrap();
1635 let s = Arc::new(FileStateStore::new(dir.path()));
1636 let mut handles = vec![];
1637 for i in 0..50 {
1638 let s = Arc::clone(&s);
1639 handles.push(tokio::spawn(async move {
1640 s.put("k", &json!({"i": i})).await.unwrap();
1641 }));
1642 }
1643 for h in handles {
1644 h.await.unwrap();
1645 }
1646 let got = s.get("k").await.unwrap().unwrap();
1648 let i = got["i"].as_i64().unwrap();
1649 assert!((0..50).contains(&i));
1650 assert!(
1652 !has_tmp_residue(dir.path()),
1653 "no temp residue after concurrent puts"
1654 );
1655 }
1656
1657 #[tokio::test]
1658 async fn file_store_works_through_trait_object() {
1659 let dir = TempDir::new().unwrap();
1660 let s: Box<dyn StateStore> = Box::new(FileStateStore::new(dir.path()));
1661 s.put("k", &json!(1)).await.unwrap();
1662 assert_eq!(s.get("k").await.unwrap().unwrap(), json!(1));
1663 }
1664
1665 #[tokio::test]
1668 async fn memory_check_passes() {
1669 let s = MemoryStateStore::new();
1670 let report = s
1671 .check(&crate::check::CheckContext::default())
1672 .await
1673 .unwrap();
1674 assert_eq!(report.failed_count(), 0);
1675 assert!(
1676 report
1677 .probes
1678 .iter()
1679 .all(|p| matches!(p.status, crate::check::ProbeStatus::Pass))
1680 );
1681 }
1682
1683 #[tokio::test]
1684 async fn file_check_passes_for_writable_root() {
1685 let dir = TempDir::new().unwrap();
1686 let s = FileStateStore::new(dir.path());
1687 let report = s
1688 .check(&crate::check::CheckContext::default())
1689 .await
1690 .unwrap();
1691 assert_eq!(report.failed_count(), 0, "writable root should pass");
1692 let leftovers: Vec<_> = std::fs::read_dir(dir.path()).unwrap().collect();
1694 assert!(leftovers.is_empty(), "check() must not leave files behind");
1695 }
1696
1697 #[tokio::test]
1698 async fn file_store_root_returns_configured_directory() {
1699 let dir = TempDir::new().unwrap();
1700 let s = FileStateStore::new(dir.path());
1701 assert_eq!(s.root(), dir.path());
1702 }
1703
1704 #[tokio::test]
1705 async fn default_check_reports_not_implemented() {
1706 struct BareStore;
1709 #[async_trait]
1710 impl StateStore for BareStore {
1711 async fn get(&self, _key: &str) -> Result<Option<Value>, FaucetError> {
1712 Ok(None)
1713 }
1714 async fn put(&self, _key: &str, _value: &Value) -> Result<(), FaucetError> {
1715 Ok(())
1716 }
1717 async fn delete(&self, _key: &str) -> Result<(), FaucetError> {
1718 Ok(())
1719 }
1720 }
1721 let s = BareStore;
1722 let report = s
1723 .check(&crate::check::CheckContext::default())
1724 .await
1725 .unwrap();
1726 assert_eq!(report.failed_count(), 0);
1728 assert!(
1729 report
1730 .probes
1731 .iter()
1732 .any(|p| matches!(p.status, crate::check::ProbeStatus::Skip { .. })),
1733 "default check must surface a skipped (not-implemented) probe"
1734 );
1735 }
1736
1737 #[tokio::test]
1738 async fn file_check_fails_when_root_unusable() {
1739 let dir = TempDir::new().unwrap();
1741 let file = dir.path().join("not_a_dir");
1742 std::fs::write(&file, b"x").unwrap();
1743 let s = FileStateStore::new(file.join("state"));
1744 let report = s
1745 .check(&crate::check::CheckContext::default())
1746 .await
1747 .unwrap();
1748 assert_eq!(report.failed_count(), 1, "unusable root should fail");
1749 }
1750
1751 #[cfg(feature = "encryption")]
1752 mod encryption_at_rest {
1753 use super::*;
1754 use crate::encryption::{CompiledEncryption, EncryptionSpec, is_encrypted};
1755 use serde_json::json;
1756
1757 fn enc(key: &str) -> CompiledEncryption {
1758 CompiledEncryption::compile(&EncryptionSpec {
1759 key: key.into(),
1760 previous_keys: vec![],
1761 algorithm: Default::default(),
1762 })
1763 .unwrap()
1764 }
1765
1766 #[tokio::test]
1767 async fn encrypted_round_trip_and_ciphertext_on_disk() {
1768 let dir = tempfile::tempdir().unwrap();
1769 let store = FileStateStore::new(dir.path()).with_encryption(enc("k1"));
1770 store.put("bk", &json!({"lsn": 42})).await.unwrap();
1771 assert_eq!(store.get("bk").await.unwrap(), Some(json!({"lsn": 42})));
1772
1773 let raw = std::fs::read(dir.path().join("bk.json")).unwrap();
1775 assert!(is_encrypted(&raw));
1776 assert!(serde_json::from_slice::<Value>(&raw).is_err());
1777
1778 store.delete("bk").await.unwrap();
1779 assert_eq!(store.get("bk").await.unwrap(), None);
1780 }
1781
1782 #[tokio::test]
1783 async fn plaintext_file_stays_readable_and_is_sealed_on_next_write() {
1784 let dir = tempfile::tempdir().unwrap();
1785 let plain = FileStateStore::new(dir.path());
1787 plain.put("bk", &json!("legacy")).await.unwrap();
1788 let before = std::fs::read(dir.path().join("bk.json")).unwrap();
1789 assert!(!is_encrypted(&before));
1790
1791 let sealed = FileStateStore::new(dir.path()).with_encryption(enc("k1"));
1792 assert_eq!(sealed.get("bk").await.unwrap(), Some(json!("legacy")));
1793 sealed.put("bk", &json!("updated")).await.unwrap();
1794 let after = std::fs::read(dir.path().join("bk.json")).unwrap();
1795 assert!(is_encrypted(&after), "next write must seal the file");
1796 assert_eq!(sealed.get("bk").await.unwrap(), Some(json!("updated")));
1797 }
1798
1799 #[tokio::test]
1800 async fn wrong_key_is_a_typed_error_not_a_missing_bookmark() {
1801 let dir = tempfile::tempdir().unwrap();
1802 let a = FileStateStore::new(dir.path()).with_encryption(enc("right"));
1803 a.put("bk", &json!(1)).await.unwrap();
1804
1805 let b = FileStateStore::new(dir.path()).with_encryption(enc("wrong"));
1806 let err = b.get("bk").await.unwrap_err();
1807 assert!(matches!(err, FaucetError::State(_)));
1808 assert!(err.to_string().contains("could not be decrypted"), "{err}");
1809 }
1810
1811 #[tokio::test]
1812 async fn encrypted_file_with_unconfigured_store_is_a_typed_error() {
1813 let dir = tempfile::tempdir().unwrap();
1814 let sealed = FileStateStore::new(dir.path()).with_encryption(enc("k1"));
1815 sealed.put("bk", &json!(1)).await.unwrap();
1816
1817 let plain = FileStateStore::new(dir.path());
1818 let err = plain.get("bk").await.unwrap_err();
1819 assert!(err.to_string().contains("no `encryption` block"), "{err}");
1820 }
1821
1822 #[tokio::test]
1823 async fn rotation_reads_old_key_files() {
1824 let dir = tempfile::tempdir().unwrap();
1825 let old = FileStateStore::new(dir.path()).with_encryption(enc("old"));
1826 old.put("bk", &json!("v1")).await.unwrap();
1827
1828 let rotated = FileStateStore::new(dir.path()).with_encryption(
1829 CompiledEncryption::compile(&EncryptionSpec {
1830 key: "new".into(),
1831 previous_keys: vec!["old".into()],
1832 algorithm: Default::default(),
1833 })
1834 .unwrap(),
1835 );
1836 assert_eq!(rotated.get("bk").await.unwrap(), Some(json!("v1")));
1837 rotated.put("bk", &json!("v2")).await.unwrap();
1839 let new_only = FileStateStore::new(dir.path()).with_encryption(enc("new"));
1840 assert_eq!(new_only.get("bk").await.unwrap(), Some(json!("v2")));
1841 }
1842
1843 #[tokio::test]
1844 async fn no_temp_files_left_behind() {
1845 let dir = tempfile::tempdir().unwrap();
1846 let store = FileStateStore::new(dir.path()).with_encryption(enc("k1"));
1847 store.put("bk", &json!(1)).await.unwrap();
1848 let leftovers: Vec<_> = std::fs::read_dir(dir.path())
1849 .unwrap()
1850 .filter_map(Result::ok)
1851 .filter(|e| e.path().to_string_lossy().ends_with(".tmp"))
1852 .collect();
1853 assert!(
1854 leftovers.is_empty(),
1855 "atomic write must leave no temp files"
1856 );
1857 }
1858 }
1859}
1860
1861#[cfg(test)]
1862mod list_export_tests {
1863 use super::*;
1864 use serde_json::json;
1865
1866 struct Bare(MemoryStateStore);
1867
1868 #[async_trait]
1869 impl StateStore for Bare {
1870 async fn get(&self, key: &str) -> Result<Option<Value>, FaucetError> {
1871 self.0.get(key).await
1872 }
1873 async fn put(&self, key: &str, value: &Value) -> Result<(), FaucetError> {
1874 if key.ends_with("::boom") {
1875 return Err(FaucetError::State("disk full".into()));
1876 }
1877 self.0.put(key, value).await
1878 }
1879 async fn delete(&self, key: &str) -> Result<(), FaucetError> {
1880 if key.ends_with("::stuck") {
1881 return Err(FaucetError::State("locked".into()));
1882 }
1883 self.0.delete(key).await
1884 }
1885 }
1886
1887 #[tokio::test]
1888 async fn defaults_cannot_list_and_batch_sequentially() {
1889 let s = Bare(MemoryStateStore::new());
1890 assert!(!s.supports_list());
1891 assert!(!s.supports_atomic_batch());
1892 assert!(s.list("p::").await.is_err());
1893 s.put_batch(&[("p::a".into(), json!(1)), ("p::b".into(), json!(2))])
1894 .await
1895 .unwrap();
1896 assert_eq!(s.get("p::b").await.unwrap(), Some(json!(2)));
1897 let err = s
1898 .put_batch(&[("p::c".into(), json!(1)), ("p::boom".into(), json!(2))])
1899 .await
1900 .unwrap_err();
1901 assert!(err.to_string().contains("disk full"));
1902 assert_eq!(
1903 s.get("p::c").await.unwrap(),
1904 Some(json!(1)),
1905 "prefix landed"
1906 );
1907 }
1908
1909 #[tokio::test]
1910 async fn memory_lists_by_prefix_sorted_and_batches_atomically() {
1911 let s = MemoryStateStore::new();
1912 for k in ["p::b", "p::a", "q::a", "p::a::__sla__"] {
1913 s.put(k, &json!(k)).await.unwrap();
1914 }
1915 assert!(s.supports_list());
1916 assert_eq!(
1917 s.list("p::").await.unwrap(),
1918 vec!["p::a", "p::a::__sla__", "p::b"]
1919 );
1920 assert!(s.supports_atomic_batch());
1921 let err = s
1922 .put_batch(&[("p::x".into(), json!(1)), ("../bad".into(), json!(2))])
1923 .await
1924 .unwrap_err();
1925 assert!(matches!(err, FaucetError::State(_)));
1926 assert!(s.get("p::x").await.unwrap().is_none(), "nothing written");
1927 s.put_batch(&[("p::x".into(), json!(1))]).await.unwrap();
1928 assert_eq!(s.get("p::x").await.unwrap(), Some(json!(1)));
1929 }
1930
1931 #[tokio::test]
1932 async fn file_lists_decoded_keys_ignoring_temp_and_foreign_files() {
1933 let dir = tempfile::tempdir().unwrap();
1934 let s = FileStateStore::new(dir.path());
1935 assert!(s.list("p::").await.unwrap().is_empty(), "missing root");
1936 for k in ["p::a", "p::a::__sla__", "acme/p::x", "q::a"] {
1937 s.put(k, &json!(1)).await.unwrap();
1938 }
1939 std::fs::write(dir.path().join("p%3A%3Az.json.tmp"), b"{}").unwrap();
1940 std::fs::write(dir.path().join("notes.txt"), b"x").unwrap();
1941 std::fs::write(dir.path().join(".hidden.json"), b"{}").unwrap();
1942 assert!(s.supports_list());
1943 assert_eq!(s.list("p::").await.unwrap(), vec!["p::a", "p::a::__sla__"]);
1944 assert_eq!(s.list("acme/").await.unwrap(), vec!["acme/p::x"]);
1945 assert!(!s.supports_atomic_batch());
1946 }
1947
1948 #[cfg(unix)]
1949 #[tokio::test]
1950 async fn file_list_reports_unreadable_root() {
1951 let dir = tempfile::tempdir().unwrap();
1952 let file = dir.path().join("not-a-dir");
1953 std::fs::write(&file, b"x").unwrap();
1954 let s = FileStateStore::new(&file);
1955 let err = s.list("p::").await.unwrap_err();
1956 assert!(
1957 err.to_string().contains("failed to list state dir"),
1958 "{err}"
1959 );
1960 }
1961
1962 #[test]
1963 fn export_document_parses_and_validates() {
1964 let ok = json!({
1965 "version": 1,
1966 "pipeline": "orders",
1967 "exported_at": "2026-09-26T00:00:00Z",
1968 "keys": {"orders::a": {"id": 3}, "orders::a::__sla__": {}}
1969 });
1970 let e = StateExport::from_value(ok).unwrap();
1971 assert_eq!(e.keys.len(), 2);
1972 assert_eq!(namespace_prefix("orders"), "orders::");
1973
1974 let cases = [
1975 (json!({"pipeline": "o", "keys": {}}), "no `version`"),
1976 (
1977 json!({"version": "1", "pipeline": "o"}),
1978 "must be an integer",
1979 ),
1980 (json!({"version": 2, "pipeline": "o"}), "not supported"),
1981 (json!({"version": 0, "pipeline": "o"}), "not supported"),
1982 (json!({"version": 1}), "malformed"),
1983 (json!({"version": 1, "pipeline": "../o"}), "pipeline name"),
1984 (
1985 json!({"version": 1, "pipeline": "o", "keys": {"other::a": 1}}),
1986 "outside the namespace",
1987 ),
1988 (
1989 json!({"version": 1, "pipeline": "o", "keys": {"o::a b": 1}}),
1990 "illegal character",
1991 ),
1992 ];
1993 for (doc, needle) in cases {
1994 let err = StateExport::from_value(doc.clone())
1995 .unwrap_err()
1996 .to_string();
1997 assert!(err.contains(needle), "{doc} → {err}");
1998 }
1999 }
2000
2001 #[tokio::test]
2002 async fn export_then_import_round_trips_and_replace_drops_stale_keys() {
2003 let from = MemoryStateStore::new();
2004 from.put("o::a", &json!({"c": 1})).await.unwrap();
2005 from.put("o::b", &json!(2)).await.unwrap();
2006 from.put("other::a", &json!(9)).await.unwrap();
2007 let export = export_namespace(&from, "o").await.unwrap();
2008 assert_eq!(export.keys.len(), 2);
2009
2010 let to = MemoryStateStore::new();
2011 to.put("o::stale", &json!(0)).await.unwrap();
2012 let r = import_namespace(&to, &export, false).await.unwrap();
2013 assert!(r.atomic && r.error.is_none());
2014 assert_eq!(r.written, vec!["o::a", "o::b"]);
2015 assert!(
2016 to.get("o::stale").await.unwrap().is_some(),
2017 "kept without replace"
2018 );
2019
2020 let r = import_namespace(&to, &export, true).await.unwrap();
2021 assert_eq!(r.deleted, vec!["o::stale"]);
2022 assert!(to.get("o::stale").await.unwrap().is_none());
2023 assert_eq!(to.get("o::a").await.unwrap(), Some(json!({"c": 1})));
2024 }
2025
2026 #[tokio::test]
2027 async fn import_reports_partial_progress_on_a_non_atomic_store() {
2028 let mut export = StateExport::new("o");
2029 export.keys.insert("o::a".into(), json!(1));
2030 export.keys.insert("o::boom".into(), json!(2));
2031 let s = Bare(MemoryStateStore::new());
2032 let r = import_namespace(&s, &export, false).await.unwrap();
2033 assert!(!r.atomic);
2034 assert_eq!(r.written, vec!["o::a"]);
2035 assert!(r.error.unwrap().contains("o::boom"));
2036
2037 let mut bad = StateExport::new("o");
2038 bad.keys.insert("x::a".into(), json!(1));
2039 assert!(import_namespace(&s, &bad, false).await.is_err());
2040 }
2041
2042 #[tokio::test]
2043 async fn import_reports_batch_and_delete_failures() {
2044 struct Failing;
2045 #[async_trait]
2046 impl StateStore for Failing {
2047 async fn get(&self, _: &str) -> Result<Option<Value>, FaucetError> {
2048 Ok(None)
2049 }
2050 async fn put(&self, _: &str, _: &Value) -> Result<(), FaucetError> {
2051 Ok(())
2052 }
2053 async fn delete(&self, _: &str) -> Result<(), FaucetError> {
2054 Ok(())
2055 }
2056 fn supports_atomic_batch(&self) -> bool {
2057 true
2058 }
2059 async fn put_batch(&self, _: &[(String, Value)]) -> Result<(), FaucetError> {
2060 Err(FaucetError::State("tx aborted".into()))
2061 }
2062 }
2063 assert_eq!(Failing.get("o::a").await.unwrap(), None);
2064 Failing.put("o::a", &json!(1)).await.unwrap();
2065 Failing.delete("o::a").await.unwrap();
2066 let mut export = StateExport::new("o");
2067 export.keys.insert("o::a".into(), json!(1));
2068 let r = import_namespace(&Failing, &export, false).await.unwrap();
2069 assert!(r.written.is_empty());
2070 assert!(r.error.unwrap().contains("tx aborted"));
2071
2072 assert!(import_namespace(&Failing, &export, true).await.is_err());
2074
2075 let s = Bare(MemoryStateStore::new());
2076 struct Listing(Bare);
2077 #[async_trait]
2078 impl StateStore for Listing {
2079 async fn get(&self, k: &str) -> Result<Option<Value>, FaucetError> {
2080 self.0.get(k).await
2081 }
2082 async fn put(&self, k: &str, v: &Value) -> Result<(), FaucetError> {
2083 self.0.put(k, v).await
2084 }
2085 async fn delete(&self, k: &str) -> Result<(), FaucetError> {
2086 self.0.delete(k).await
2087 }
2088 async fn list(&self, p: &str) -> Result<Vec<String>, FaucetError> {
2089 self.0.0.list(p).await
2090 }
2091 }
2092 s.put("o::stuck", &json!(1)).await.unwrap();
2093 let listing = Listing(s);
2094 assert_eq!(listing.get("o::stuck").await.unwrap(), Some(json!(1)));
2095 let r = import_namespace(&listing, &export, true).await.unwrap();
2096 assert_eq!(r.written, vec!["o::a"]);
2097 assert!(r.error.unwrap().contains("deleting stale 'o::stuck'"));
2098 }
2099}