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 format!("{pipeline}::")
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 STALE_LOCK: std::time::Duration = std::time::Duration::from_secs(30);
334const LOCK_WAIT: std::time::Duration = std::time::Duration::from_secs(10);
336
337struct LockFile(PathBuf);
339
340impl Drop for LockFile {
341 fn drop(&mut self) {
342 let _ = std::fs::remove_file(&self.0);
343 }
344}
345
346async fn acquire_lock_file(path: &Path) -> Result<LockFile, FaucetError> {
347 let started = tokio::time::Instant::now();
348 loop {
349 match tokio::fs::OpenOptions::new()
350 .write(true)
351 .create_new(true)
352 .open(path)
353 .await
354 {
355 Ok(_) => return Ok(LockFile(path.to_path_buf())),
356 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
357 let stale = tokio::fs::metadata(path)
358 .await
359 .ok()
360 .and_then(|m| m.modified().ok())
361 .and_then(|t| t.elapsed().ok())
362 .is_some_and(|age| age > STALE_LOCK);
363 if stale {
364 let _ = tokio::fs::remove_file(path).await;
365 continue;
366 }
367 if started.elapsed() > LOCK_WAIT {
368 return Err(FaucetError::State(format!(
369 "state lock {} is held by another writer",
370 path.display()
371 )));
372 }
373 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
374 }
375 Err(e) => {
376 return Err(FaucetError::State(format!(
377 "failed to create state lock {}: {e}",
378 path.display()
379 )));
380 }
381 }
382 }
383}
384
385#[derive(Default)]
389pub struct MemoryStateStore {
390 inner: Mutex<HashMap<String, Value>>,
391}
392
393impl MemoryStateStore {
394 pub fn new() -> Self {
396 Self::default()
397 }
398}
399
400#[async_trait]
401impl StateStore for MemoryStateStore {
402 async fn get(&self, key: &str) -> Result<Option<Value>, FaucetError> {
403 validate_state_key(key)?;
404 Ok(self.inner.lock().await.get(key).cloned())
405 }
406
407 async fn put(&self, key: &str, value: &Value) -> Result<(), FaucetError> {
408 validate_state_key(key)?;
409 self.inner
410 .lock()
411 .await
412 .insert(key.to_owned(), value.clone());
413 Ok(())
414 }
415
416 async fn delete(&self, key: &str) -> Result<(), FaucetError> {
417 validate_state_key(key)?;
418 self.inner.lock().await.remove(key);
419 Ok(())
420 }
421
422 fn supports_list(&self) -> bool {
423 true
424 }
425
426 async fn list(&self, prefix: &str) -> Result<Vec<String>, FaucetError> {
427 let mut keys: Vec<String> = self
428 .inner
429 .lock()
430 .await
431 .keys()
432 .filter(|k| k.starts_with(prefix))
433 .cloned()
434 .collect();
435 keys.sort();
436 Ok(keys)
437 }
438
439 fn supports_compare_and_put(&self) -> bool {
440 true
441 }
442
443 async fn compare_and_put(
444 &self,
445 key: &str,
446 expected: Option<&Value>,
447 value: &Value,
448 ) -> Result<bool, FaucetError> {
449 validate_state_key(key)?;
450 let mut map = self.inner.lock().await;
451 if map.get(key) != expected {
452 return Ok(false);
453 }
454 map.insert(key.to_owned(), value.clone());
455 Ok(true)
456 }
457
458 fn supports_atomic_batch(&self) -> bool {
459 true
460 }
461
462 async fn put_batch(&self, entries: &[(String, Value)]) -> Result<(), FaucetError> {
463 for (key, _) in entries {
464 validate_state_key(key)?;
465 }
466 let mut map = self.inner.lock().await;
467 for (key, value) in entries {
468 map.insert(key.clone(), value.clone());
469 }
470 Ok(())
471 }
472
473 async fn check(
474 &self,
475 _ctx: &crate::check::CheckContext,
476 ) -> Result<crate::check::CheckReport, FaucetError> {
477 Ok(crate::check::CheckReport::single(
479 crate::check::Probe::pass("sentinel", std::time::Duration::ZERO),
480 ))
481 }
482}
483
484const MAX_STEM: usize = 160;
491
492fn safe_filename(key: &str) -> String {
508 let mut out = String::with_capacity(key.len() + 8);
509 for c in key.chars() {
510 match c {
511 ':' => out.push_str("%3A"),
512 '/' => out.push_str("%2F"),
513 'A'..='Z' => out.push_str(&format!("%{:02X}", c as u32)),
514 other => out.push(other),
515 }
516 }
517 if out.len() <= MAX_STEM {
518 return out;
519 }
520 use sha2::Digest;
521 let digest = sha2::Sha256::digest(key.as_bytes());
522 let hex: String = digest.iter().map(|b| format!("{b:02x}")).collect();
523 let mut cut = MAX_STEM - hex.len() - 1;
524 while cut > 0
526 && out.as_bytes()[..cut]
527 .iter()
528 .rev()
529 .take(2)
530 .any(|b| *b == b'%')
531 {
532 cut -= 1;
533 }
534 format!("{}~{hex}", &out[..cut])
535}
536
537fn key_from_filename(stem: &str) -> Option<String> {
540 if stem.contains('~') {
541 return None;
542 }
543 let bytes = stem.as_bytes();
544 let mut key = String::with_capacity(stem.len());
545 let mut i = 0;
546 while i < bytes.len() {
547 if bytes[i] == b'%' {
548 let hex = stem.get(i + 1..i + 3)?;
549 let code = u8::from_str_radix(hex, 16).ok()?;
550 key.push(char::from(code));
551 i += 3;
552 } else {
553 key.push(char::from(bytes[i]));
554 i += 1;
555 }
556 }
557 validate_state_key(&key).ok().map(|()| key)
558}
559
560pub struct FileStateStore {
576 root: PathBuf,
577 write_lock: Mutex<()>,
578 migrated: tokio::sync::OnceCell<()>,
579 #[cfg(feature = "encryption")]
583 encryption: Option<crate::encryption::CompiledEncryption>,
584}
585
586impl FileStateStore {
587 pub fn new(root: impl Into<PathBuf>) -> Self {
589 Self {
590 root: root.into(),
591 write_lock: Mutex::new(()),
592 migrated: tokio::sync::OnceCell::new(),
593 #[cfg(feature = "encryption")]
594 encryption: None,
595 }
596 }
597
598 #[cfg(feature = "encryption")]
601 pub fn with_encryption(mut self, encryption: crate::encryption::CompiledEncryption) -> Self {
602 self.encryption = Some(encryption);
603 self
604 }
605
606 fn entry_path(&self, key: &str) -> PathBuf {
607 self.root.join(format!("{}.json", safe_filename(key)))
608 }
609
610 fn temp_path(&self, key: &str) -> PathBuf {
611 use std::sync::OnceLock;
628 use std::sync::atomic::{AtomicU64, Ordering};
629 static PROC_TOKEN: OnceLock<String> = OnceLock::new();
630 static SEQ: AtomicU64 = AtomicU64::new(0);
631 let token = PROC_TOKEN.get_or_init(|| uuid::Uuid::new_v4().simple().to_string());
632 let seq = SEQ.fetch_add(1, Ordering::Relaxed);
633 self.root
634 .join(format!("{}.{}.{}.json.tmp", safe_filename(key), token, seq))
635 }
636
637 async fn ensure_root(&self) -> Result<(), FaucetError> {
638 tokio::fs::create_dir_all(&self.root).await.map_err(|e| {
639 FaucetError::State(format!(
640 "failed to create state dir {}: {e}",
641 self.root.display()
642 ))
643 })
644 }
645
646 async fn read_entry(&self, key: &str, path: &Path) -> Result<Option<Value>, FaucetError> {
647 #[cfg(not(feature = "encryption"))]
648 let _ = key;
649 match tokio::fs::read(path).await {
650 Ok(bytes) => {
651 #[cfg(feature = "encryption")]
652 let bytes: Vec<u8> = if crate::encryption::is_encrypted(&bytes) {
653 match &self.encryption {
654 Some(enc) => enc.decrypt_bound(&bytes, key.as_bytes()).map_err(|e| {
655 FaucetError::State(format!(
659 "state file {} could not be decrypted: {e}",
660 path.display()
661 ))
662 })?,
663 None => {
664 return Err(FaucetError::State(format!(
665 "state file {} is encrypted but no `encryption` block is \
666 configured on the file state store — add \
667 `state.config.encryption` with the original key",
668 path.display()
669 )));
670 }
671 }
672 } else {
673 bytes
674 };
675 #[cfg(not(feature = "encryption"))]
676 let bytes = {
677 if bytes.starts_with(b"FCT1") {
680 return Err(FaucetError::State(format!(
681 "state file {} is encrypted but this build of faucet has no \
682 `encryption` feature",
683 path.display()
684 )));
685 }
686 bytes
687 };
688 let value: Value = serde_json::from_slice(&bytes).map_err(|e| {
689 FaucetError::State(format!(
690 "failed to parse state file {}: {e}",
691 path.display()
692 ))
693 })?;
694 Ok(Some(value))
695 }
696 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
697 Err(e) => Err(FaucetError::State(format!(
698 "failed to read state file {}: {e}",
699 path.display()
700 ))),
701 }
702 }
703
704 async fn migrate_legacy_names(&self) -> Result<(), FaucetError> {
710 self.migrated
711 .get_or_try_init(|| async {
712 let mut dir = match tokio::fs::read_dir(&self.root).await {
713 Ok(d) => d,
714 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
715 Err(e) => {
716 return Err(FaucetError::State(format!(
717 "failed to list state dir {}: {e}",
718 self.root.display()
719 )));
720 }
721 };
722 let mut renames = Vec::new();
723 while let Some(entry) = dir.next_entry().await.map_err(|e| {
724 FaucetError::State(format!(
725 "failed to list state dir {}: {e}",
726 self.root.display()
727 ))
728 })? {
729 let name = entry.file_name();
730 let Some(stem) = name.to_str().and_then(|n| n.strip_suffix(".json")) else {
731 continue;
732 };
733 if let Some(key) = key_from_filename(stem)
734 && safe_filename(&key) != stem
735 {
736 renames.push((key, entry.path()));
737 }
738 }
739 for (key, from) in renames {
740 if let Some(sidecar) = self.key_sidecar(&key) {
741 tokio::fs::write(&sidecar, key.as_bytes())
742 .await
743 .map_err(|e| {
744 FaucetError::State(format!(
745 "failed to write state key file {}: {e}",
746 sidecar.display()
747 ))
748 })?;
749 }
750 let to = self.entry_path(&key);
751 if tokio::fs::try_exists(&to).await.unwrap_or(false) {
752 let _ = tokio::fs::remove_file(&from).await;
753 continue;
754 }
755 match tokio::fs::rename(&from, &to).await {
756 Ok(()) => {}
757 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
758 Err(e) => {
759 return Err(FaucetError::State(format!(
760 "failed to rename legacy state file {} to {}: {e}",
761 from.display(),
762 to.display()
763 )));
764 }
765 }
766 }
767 Ok(())
768 })
769 .await
770 .map(|_| ())
771 }
772
773 fn key_sidecar(&self, key: &str) -> Option<PathBuf> {
774 let stem = safe_filename(key);
775 stem.contains('~')
776 .then(|| self.root.join(format!("{stem}.key")))
777 }
778
779 pub fn root(&self) -> &Path {
781 &self.root
782 }
783}
784
785#[async_trait]
786impl StateStore for FileStateStore {
787 async fn get(&self, key: &str) -> Result<Option<Value>, FaucetError> {
788 validate_state_key(key)?;
789 self.migrate_legacy_names().await?;
790 self.read_entry(key, &self.entry_path(key)).await
791 }
792
793 async fn put(&self, key: &str, value: &Value) -> Result<(), FaucetError> {
794 validate_state_key(key)?;
795 let _guard = self.write_lock.lock().await;
796 self.ensure_root().await?;
797 let bytes = serde_json::to_vec(value).map_err(|e| {
798 FaucetError::State(format!("failed to serialize state for key '{key}': {e}"))
799 })?;
800 #[cfg(feature = "encryption")]
801 let bytes = match &self.encryption {
802 Some(enc) => enc.encrypt_bound(&bytes, key.as_bytes()),
803 None => bytes,
804 };
805 self.migrate_legacy_names().await?;
806 let final_path = self.entry_path(key);
807 let tmp_path = self.temp_path(key);
808
809 if let Some(sidecar) = self.key_sidecar(key) {
810 tokio::fs::write(&sidecar, key.as_bytes())
811 .await
812 .map_err(|e| {
813 FaucetError::State(format!(
814 "failed to write state key file {}: {e}",
815 sidecar.display()
816 ))
817 })?;
818 }
819
820 let written: Result<(), FaucetError> = async {
827 let mut file = tokio::fs::File::create(&tmp_path).await.map_err(|e| {
828 FaucetError::State(format!(
829 "failed to create temp state file {}: {e}",
830 tmp_path.display()
831 ))
832 })?;
833 file.write_all(&bytes).await.map_err(|e| {
834 FaucetError::State(format!(
835 "failed to write temp state file {}: {e}",
836 tmp_path.display()
837 ))
838 })?;
839 file.sync_all().await.map_err(|e| {
840 FaucetError::State(format!(
841 "failed to fsync temp state file {}: {e}",
842 tmp_path.display()
843 ))
844 })?;
845 drop(file);
846 tokio::fs::rename(&tmp_path, &final_path)
847 .await
848 .map_err(|e| {
849 FaucetError::State(format!(
850 "failed to commit state file {}: {e}",
851 final_path.display()
852 ))
853 })
854 }
855 .await;
856 if let Err(e) = written {
857 let _ = tokio::fs::remove_file(&tmp_path).await;
858 return Err(e);
859 }
860
861 #[cfg(unix)]
867 {
868 let dir = tokio::fs::File::open(&self.root).await.map_err(|e| {
869 FaucetError::State(format!(
870 "failed to open state dir {} for fsync: {e}",
871 self.root.display()
872 ))
873 })?;
874 dir.sync_all().await.map_err(|e| {
875 FaucetError::State(format!(
876 "failed to fsync state dir {}: {e}",
877 self.root.display()
878 ))
879 })?;
880 }
881
882 tracing::debug!(
883 key,
884 path = %final_path.display(),
885 "state file written"
886 );
887 Ok(())
888 }
889
890 async fn delete(&self, key: &str) -> Result<(), FaucetError> {
891 validate_state_key(key)?;
892 self.migrate_legacy_names().await?;
893 let mut paths = vec![self.entry_path(key)];
894 paths.extend(self.key_sidecar(key));
895 for path in paths {
896 match tokio::fs::remove_file(&path).await {
897 Ok(()) => {}
898 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
899 Err(e) => {
900 return Err(FaucetError::State(format!(
901 "failed to delete state file {}: {e}",
902 path.display()
903 )));
904 }
905 }
906 }
907 Ok(())
908 }
909
910 fn supports_compare_and_put(&self) -> bool {
911 true
912 }
913
914 async fn compare_and_put(
918 &self,
919 key: &str,
920 expected: Option<&Value>,
921 value: &Value,
922 ) -> Result<bool, FaucetError> {
923 validate_state_key(key)?;
924 self.ensure_root().await?;
925 let lock = self.root.join(format!("{}.json.lock", safe_filename(key)));
926 let _held = acquire_lock_file(&lock).await?;
927 if self.get(key).await?.as_ref() != expected {
928 return Ok(false);
929 }
930 self.put(key, value).await?;
931 Ok(true)
932 }
933
934 fn supports_list(&self) -> bool {
935 true
936 }
937
938 async fn list(&self, prefix: &str) -> Result<Vec<String>, FaucetError> {
939 self.migrate_legacy_names().await?;
940 let mut dir = match tokio::fs::read_dir(&self.root).await {
941 Ok(d) => d,
942 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
943 Err(e) => {
944 return Err(FaucetError::State(format!(
945 "failed to list state dir {}: {e}",
946 self.root.display()
947 )));
948 }
949 };
950 let mut keys = Vec::new();
951 loop {
952 let entry = dir.next_entry().await.map_err(|e| {
953 FaucetError::State(format!(
954 "failed to list state dir {}: {e}",
955 self.root.display()
956 ))
957 })?;
958 let Some(entry) = entry else { break };
959 let name = entry.file_name();
960 let Some(stem) = name.to_str().and_then(|n| n.strip_suffix(".json")) else {
961 continue;
962 };
963 let key = if stem.contains('~') {
964 tokio::fs::read_to_string(self.root.join(format!("{stem}.key")))
965 .await
966 .ok()
967 .filter(|k| validate_state_key(k).is_ok())
968 } else {
969 key_from_filename(stem)
970 };
971 if let Some(key) = key
972 && key.starts_with(prefix)
973 {
974 keys.push(key);
975 }
976 }
977 keys.sort();
978 keys.dedup();
979 Ok(keys)
980 }
981
982 async fn check(
983 &self,
984 _ctx: &crate::check::CheckContext,
985 ) -> Result<crate::check::CheckReport, FaucetError> {
986 use crate::check::{CheckReport, Probe};
987 let start = std::time::Instant::now();
991 let probe = match self.sentinel_roundtrip().await {
992 Ok(()) => Probe::pass("sentinel", start.elapsed()),
993 Err(e) => Probe::fail_hint(
994 "sentinel",
995 start.elapsed(),
996 e.to_string(),
997 format!("ensure {} exists and is writable", self.root.display()),
998 ),
999 };
1000 Ok(CheckReport::single(probe))
1001 }
1002}
1003
1004impl FileStateStore {
1005 async fn sentinel_roundtrip(&self) -> Result<(), FaucetError> {
1008 let probe = serde_json::json!({ "faucet_doctor": true });
1009 let key = doctor_sentinel_key();
1010 self.put(&key, &probe).await?;
1011 let got = self.get(&key).await?;
1012 let _ = self.delete(&key).await;
1014 match got {
1015 Some(v) if v == probe => Ok(()),
1016 _ => Err(FaucetError::State(
1017 "sentinel readback did not match what was written".into(),
1018 )),
1019 }
1020 }
1021}
1022
1023#[cfg(test)]
1024mod tests {
1025 use super::*;
1026 use serde_json::json;
1027 use std::sync::Arc;
1028 use tempfile::TempDir;
1029
1030 #[tokio::test]
1033 async fn a_lock_that_cannot_be_created_is_a_state_error() {
1034 let dir = TempDir::new().unwrap();
1035 let path = dir.path().join("missing-dir").join("k.lock");
1036 let err = acquire_lock_file(&path).await.err().unwrap().to_string();
1037 assert!(err.contains("failed to create state lock"), "{err}");
1038 }
1039
1040 #[test]
1041 fn rejects_empty_key() {
1042 let err = validate_state_key("").unwrap_err();
1043 assert!(matches!(err, FaucetError::State(_)));
1044 }
1045
1046 #[test]
1047 fn rejects_path_traversal_segments() {
1048 for k in [
1049 "../etc/passwd",
1050 "a/../b",
1051 "a/./b",
1052 "a//b",
1053 "/a",
1054 "a/",
1055 "a/.x",
1056 "a\\b",
1057 "..",
1058 ".",
1059 ] {
1060 assert!(validate_state_key(k).is_err(), "expected reject for {k:?}");
1061 }
1062 assert!(validate_state_key("acme/erp::invoices").is_ok());
1064 }
1065
1066 #[test]
1067 fn rejects_leading_dot() {
1068 assert!(validate_state_key(".hidden").is_err());
1069 }
1070
1071 #[test]
1072 fn rejects_over_long_key() {
1073 let k = "a".repeat(257);
1074 assert!(validate_state_key(&k).is_err());
1075 }
1076
1077 #[test]
1078 fn accepts_typical_keys() {
1079 for k in [
1080 "github_issues",
1081 "pipeline:rest:issues",
1082 "with.dot",
1083 "with-dash_and_underscore",
1084 "lower-Case_99",
1085 ] {
1086 validate_state_key(k).unwrap_or_else(|e| panic!("expected ok for {k:?}: {e}"));
1087 }
1088 }
1089
1090 #[tokio::test]
1093 async fn compare_and_put_writes_only_over_the_expected_value() {
1094 let mem = MemoryStateStore::new();
1095 assert!(mem.supports_compare_and_put());
1096 let a = serde_json::json!({"v": 1});
1097 let b = serde_json::json!({"v": 2});
1098 assert!(mem.compare_and_put("k", None, &a).await.unwrap());
1099 assert!(!mem.compare_and_put("k", None, &b).await.unwrap());
1100 assert!(!mem.compare_and_put("k", Some(&b), &b).await.unwrap());
1101 assert!(mem.compare_and_put("k", Some(&a), &b).await.unwrap());
1102 assert_eq!(mem.get("k").await.unwrap(), Some(b.clone()));
1103 assert!(mem.compare_and_put("bad key!", None, &a).await.is_err());
1104
1105 let dir = tempfile::tempdir().unwrap();
1106 let file = FileStateStore::new(dir.path());
1107 assert!(file.supports_compare_and_put());
1108 assert!(file.compare_and_put("k", None, &a).await.unwrap());
1109 assert!(!file.compare_and_put("k", Some(&b), &b).await.unwrap());
1110 assert!(file.compare_and_put("k", Some(&a), &b).await.unwrap());
1111 assert_eq!(file.get("k").await.unwrap(), Some(b));
1112 }
1113
1114 #[tokio::test]
1115 async fn file_compare_and_put_is_exclusive_and_breaks_stale_locks() {
1116 let dir = tempfile::tempdir().unwrap();
1117 let stores: Vec<std::sync::Arc<FileStateStore>> = (0..8)
1118 .map(|_| std::sync::Arc::new(FileStateStore::new(dir.path())))
1119 .collect();
1120 let tasks: Vec<_> = stores
1121 .iter()
1122 .enumerate()
1123 .map(|(i, s)| {
1124 let s = std::sync::Arc::clone(s);
1125 tokio::spawn(async move {
1126 s.compare_and_put("lease", None, &json!({"by": i}))
1127 .await
1128 .unwrap()
1129 })
1130 })
1131 .collect();
1132 let mut wins = 0;
1133 for t in tasks {
1134 wins += usize::from(t.await.unwrap());
1135 }
1136 assert_eq!(wins, 1, "exactly one writer takes an absent key");
1137 let lock = dir.path().join("lease.json.lock");
1138 assert!(!lock.exists(), "the lock is released");
1139
1140 let f = std::fs::File::create(&lock).unwrap();
1141 f.set_modified(std::time::SystemTime::now() - STALE_LOCK * 2)
1142 .unwrap();
1143 let held = stores[0].get("lease").await.unwrap();
1144 assert!(
1145 stores[0]
1146 .compare_and_put("lease", held.as_ref(), &json!({"by": "late"}))
1147 .await
1148 .unwrap()
1149 );
1150 assert_eq!(stores[0].list("").await.unwrap(), vec!["lease".to_string()]);
1151 }
1152
1153 #[tokio::test(start_paused = true)]
1154 async fn file_compare_and_put_gives_up_on_a_live_lock() {
1155 let dir = tempfile::tempdir().unwrap();
1156 let store = FileStateStore::new(dir.path());
1157 std::fs::write(dir.path().join("k.json.lock"), b"").unwrap();
1158 let err = store
1159 .compare_and_put("k", None, &json!(1))
1160 .await
1161 .unwrap_err()
1162 .to_string();
1163 assert!(err.contains("held by another writer"), "{err}");
1164 }
1165
1166 #[tokio::test]
1167 async fn memory_get_returns_none_for_missing_key() {
1168 let s = MemoryStateStore::new();
1169 assert!(s.get("nope").await.unwrap().is_none());
1170 }
1171
1172 #[tokio::test]
1173 async fn memory_put_then_get_round_trips() {
1174 let s = MemoryStateStore::new();
1175 s.put("k", &json!({"cursor": "abc", "n": 7})).await.unwrap();
1176 let got = s.get("k").await.unwrap().unwrap();
1177 assert_eq!(got["cursor"], "abc");
1178 assert_eq!(got["n"], 7);
1179 }
1180
1181 #[tokio::test]
1182 async fn memory_put_overwrites_previous_value() {
1183 let s = MemoryStateStore::new();
1184 s.put("k", &json!(1)).await.unwrap();
1185 s.put("k", &json!(2)).await.unwrap();
1186 assert_eq!(s.get("k").await.unwrap().unwrap(), json!(2));
1187 }
1188
1189 #[tokio::test]
1190 async fn memory_delete_makes_get_return_none() {
1191 let s = MemoryStateStore::new();
1192 s.put("k", &json!("v")).await.unwrap();
1193 s.delete("k").await.unwrap();
1194 assert!(s.get("k").await.unwrap().is_none());
1195 }
1196
1197 #[tokio::test]
1198 async fn memory_delete_missing_key_is_ok() {
1199 let s = MemoryStateStore::new();
1200 s.delete("absent").await.unwrap();
1201 }
1202
1203 #[tokio::test]
1204 async fn memory_rejects_invalid_keys() {
1205 let s = MemoryStateStore::new();
1206 assert!(s.get("a b").await.is_err());
1207 assert!(s.put("a b", &json!(1)).await.is_err());
1208 assert!(s.delete("a b").await.is_err());
1209 }
1210
1211 #[tokio::test]
1214 async fn file_get_returns_none_for_missing_key() {
1215 let dir = TempDir::new().unwrap();
1216 let s = FileStateStore::new(dir.path());
1217 assert!(s.get("nope").await.unwrap().is_none());
1218 }
1219
1220 #[tokio::test]
1221 async fn file_put_creates_root_directory_lazily() {
1222 let dir = TempDir::new().unwrap();
1223 let root = dir.path().join("nested/state");
1224 let s = FileStateStore::new(&root);
1225 s.put("k", &json!("v")).await.unwrap();
1226 assert!(root.is_dir(), "root dir should be created on first put");
1227 }
1228
1229 #[tokio::test]
1230 async fn file_put_then_get_round_trips() {
1231 let dir = TempDir::new().unwrap();
1232 let s = FileStateStore::new(dir.path());
1233 let value = json!({"cursor": "abc", "n": 42, "nested": {"flag": true}});
1234 s.put("github_issues", &value).await.unwrap();
1235 let got = s.get("github_issues").await.unwrap().unwrap();
1236 assert_eq!(got, value);
1237 }
1238
1239 #[test]
1240 fn temp_path_is_unique_and_not_pid_derived() {
1241 let dir = TempDir::new().unwrap();
1247 let s = FileStateStore::new(dir.path());
1248
1249 let a = s.temp_path("k");
1250 let b = s.temp_path("k");
1251 assert_ne!(a, b);
1253
1254 let name_a = a.file_name().unwrap().to_str().unwrap();
1255 let pid = std::process::id().to_string();
1256 assert!(
1258 !name_a.split('.').any(|seg| seg == pid),
1259 "temp filename {name_a} must not embed the process id ({pid})"
1260 );
1261 assert!(name_a.ends_with(".json.tmp"));
1262 }
1263
1264 #[test]
1265 fn safe_filename_percent_encodes_colon() {
1266 assert_eq!(
1267 safe_filename("pipeline:rest:issues"),
1268 "pipeline%3Arest%3Aissues"
1269 );
1270 assert_eq!(safe_filename("plain_key-1.v2"), "plain_key-1.v2");
1271 assert_eq!(
1274 safe_filename("acme/erp::invoices"),
1275 "acme%2Ferp%3A%3Ainvoices"
1276 );
1277 assert!(validate_state_key("acme/erp::invoices").is_ok());
1278 assert!(validate_state_key("a b").is_err());
1279 }
1280
1281 #[tokio::test]
1282 async fn file_round_trips_colon_keys_with_safe_filename() {
1283 let dir = TempDir::new().unwrap();
1287 let s = FileStateStore::new(dir.path());
1288 let value = json!({"cursor": "z"});
1289 s.put("pipeline:rest:issues", &value).await.unwrap();
1290 assert_eq!(s.get("pipeline:rest:issues").await.unwrap().unwrap(), value);
1291 assert!(dir.path().join("pipeline%3Arest%3Aissues.json").exists());
1293 let mut has_colon = false;
1294 for entry in std::fs::read_dir(dir.path()).unwrap() {
1295 if entry.unwrap().file_name().to_string_lossy().contains(':') {
1296 has_colon = true;
1297 }
1298 }
1299 assert!(!has_colon, "no state filename may contain ':'");
1300 }
1301
1302 #[test]
1303 fn safe_filename_escapes_uppercase_and_round_trips() {
1304 assert_eq!(safe_filename("Users::x"), "%55sers%3A%3Ax");
1305 assert_ne!(
1306 safe_filename("Users::x").to_lowercase(),
1307 safe_filename("users::x").to_lowercase()
1308 );
1309 for k in ["Users::x", "a/B:c", "plain"] {
1310 assert_eq!(key_from_filename(&safe_filename(k)).as_deref(), Some(k));
1311 }
1312 assert_eq!(key_from_filename("Users%3Ax").as_deref(), Some("Users:x"));
1314 assert_eq!(key_from_filename("bad%Z"), None);
1315 assert_eq!(key_from_filename("a~b"), None);
1316 }
1317
1318 #[test]
1319 fn long_keys_hash_into_a_bounded_stem() {
1320 let key = format!("p::{}", "A:".repeat(120));
1321 let stem = safe_filename(&key);
1322 assert!(stem.len() <= MAX_STEM, "{}", stem.len());
1323 assert!(stem.contains('~'));
1324 let other = format!("p::{}B", "A:".repeat(119));
1325 assert_ne!(safe_filename(&other), stem);
1326 }
1327
1328 #[tokio::test]
1329 async fn keys_differing_only_in_case_keep_separate_files() {
1330 let dir = TempDir::new().unwrap();
1331 let s = FileStateStore::new(dir.path());
1332 s.put("Users::x", &json!(1)).await.unwrap();
1333 s.put("users::x", &json!(2)).await.unwrap();
1334 assert_eq!(s.get("Users::x").await.unwrap(), Some(json!(1)));
1335 assert_eq!(s.get("users::x").await.unwrap(), Some(json!(2)));
1336 assert_eq!(s.list("").await.unwrap(), vec!["Users::x", "users::x"]);
1337 }
1338
1339 #[tokio::test]
1340 async fn a_long_key_round_trips_lists_and_deletes() {
1341 let dir = TempDir::new().unwrap();
1342 let s = FileStateStore::new(dir.path());
1343 let key = format!("pipe::row::{}", "parent:".repeat(34));
1344 assert!(key.len() <= 256);
1345 s.put(&key, &json!({"c": 1})).await.unwrap();
1346 assert_eq!(s.get(&key).await.unwrap(), Some(json!({"c": 1})));
1347 assert_eq!(s.list("pipe").await.unwrap(), vec![key.clone()]);
1348 s.delete(&key).await.unwrap();
1349 assert_eq!(s.get(&key).await.unwrap(), None);
1350 assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0);
1351 }
1352
1353 #[tokio::test]
1354 async fn legacy_named_files_are_read_and_migrated() {
1355 let dir = TempDir::new().unwrap();
1356 std::fs::write(dir.path().join("Users%3Ax.json"), b"{\"c\":7}").unwrap();
1357 let s = FileStateStore::new(dir.path());
1358 assert_eq!(s.get("users:x").await.unwrap(), None);
1360 assert_eq!(s.get("Users:x").await.unwrap(), Some(json!({"c": 7})));
1361 s.put("Users:x", &json!({"c": 8})).await.unwrap();
1362 let names: Vec<String> = std::fs::read_dir(dir.path())
1363 .unwrap()
1364 .map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
1365 .collect();
1366 assert_eq!(names, vec!["%55sers%3Ax.json".to_string()]);
1367 assert_eq!(s.get("Users:x").await.unwrap(), Some(json!({"c": 8})));
1368 s.delete("Users:x").await.unwrap();
1369 assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0);
1370
1371 let dir = TempDir::new().unwrap();
1373 let key = format!("p::{}", "q".repeat(170));
1374 std::fs::write(
1375 dir.path().join(format!("p%3A%3A{}.json", "q".repeat(170))),
1376 b"3",
1377 )
1378 .unwrap();
1379 let s = FileStateStore::new(dir.path());
1380 assert_eq!(s.list("p").await.unwrap(), vec![key.clone()]);
1381 assert_eq!(s.get(&key).await.unwrap(), Some(json!(3)));
1382 }
1383
1384 #[tokio::test]
1385 async fn a_failed_put_leaves_no_temp_file() {
1386 let dir = TempDir::new().unwrap();
1387 let s = FileStateStore::new(dir.path());
1388 std::fs::create_dir(dir.path().join("k.json")).unwrap();
1390 std::fs::write(dir.path().join("k.json").join("x"), b"x").unwrap();
1391 assert!(s.put("k", &json!(1)).await.is_err());
1392 assert!(!has_tmp_residue(dir.path()));
1393 }
1394
1395 #[test]
1396 fn doctor_sentinel_keys_are_unique_and_valid() {
1397 let a = doctor_sentinel_key();
1398 assert_ne!(a, doctor_sentinel_key());
1399 assert!(a.starts_with(DOCTOR_SENTINEL_KEY));
1400 validate_state_key(&a).unwrap();
1401 }
1402
1403 fn has_tmp_residue(dir: &std::path::Path) -> bool {
1407 std::fs::read_dir(dir)
1408 .unwrap()
1409 .filter_map(|e| e.ok())
1410 .any(|e| e.file_name().to_string_lossy().ends_with(".json.tmp"))
1411 }
1412
1413 #[tokio::test]
1414 async fn file_put_overwrites_previous_value_atomically() {
1415 let dir = TempDir::new().unwrap();
1416 let s = FileStateStore::new(dir.path());
1417 s.put("k", &json!({"v": 1})).await.unwrap();
1418 s.put("k", &json!({"v": 2})).await.unwrap();
1419 assert_eq!(s.get("k").await.unwrap().unwrap(), json!({"v": 2}));
1420 assert!(!has_tmp_residue(dir.path()), "no temp residue after put");
1422 }
1423
1424 #[test]
1425 fn file_temp_paths_are_unique_per_write() {
1426 let dir = TempDir::new().unwrap();
1431 let s = FileStateStore::new(dir.path());
1432 let a = s.temp_path("k");
1433 let b = s.temp_path("k");
1434 assert_ne!(a, b, "each write must get a distinct temp path");
1435 assert_eq!(s.entry_path("k"), s.entry_path("k"));
1437 }
1438
1439 #[tokio::test]
1440 async fn file_put_writes_complete_durable_file_with_no_temp_residue() {
1441 let dir = TempDir::new().unwrap();
1448 let s = FileStateStore::new(dir.path());
1449 let big: Vec<Value> = (0..1_000)
1450 .map(|i| json!({"i": i, "s": "x".repeat(20)}))
1451 .collect();
1452 let value = json!({"cursor": "abc", "rows": big});
1453
1454 s.put("github_issues", &value).await.unwrap();
1455
1456 let raw = tokio::fs::read(dir.path().join("github_issues.json"))
1458 .await
1459 .expect("state file must exist after put");
1460 assert!(!raw.is_empty(), "state file must not be zero-length");
1461 let parsed: Value = serde_json::from_slice(&raw).expect("state file must be valid JSON");
1462 assert_eq!(parsed, value);
1463
1464 assert!(!has_tmp_residue(dir.path()), "no temp residue after put");
1466 }
1467
1468 #[tokio::test]
1469 async fn file_delete_removes_file() {
1470 let dir = TempDir::new().unwrap();
1471 let s = FileStateStore::new(dir.path());
1472 s.put("k", &json!("v")).await.unwrap();
1473 s.delete("k").await.unwrap();
1474 assert!(s.get("k").await.unwrap().is_none());
1475 assert!(!dir.path().join("k.json").exists());
1476 }
1477
1478 #[tokio::test]
1479 async fn file_delete_missing_key_is_ok() {
1480 let dir = TempDir::new().unwrap();
1481 let s = FileStateStore::new(dir.path());
1482 s.delete("absent").await.unwrap();
1483 }
1484
1485 #[tokio::test]
1486 async fn file_get_returns_error_for_corrupt_json() {
1487 let dir = TempDir::new().unwrap();
1488 let s = FileStateStore::new(dir.path());
1489 tokio::fs::create_dir_all(dir.path()).await.unwrap();
1490 tokio::fs::write(dir.path().join("bad.json"), b"not json")
1491 .await
1492 .unwrap();
1493 let err = s.get("bad").await.unwrap_err();
1494 match err {
1495 FaucetError::State(msg) => assert!(msg.contains("bad.json")),
1496 other => panic!("expected State error, got {other:?}"),
1497 }
1498 }
1499
1500 #[tokio::test]
1501 async fn file_concurrent_puts_do_not_corrupt_or_leak_temp() {
1502 let dir = TempDir::new().unwrap();
1503 let s = Arc::new(FileStateStore::new(dir.path()));
1504 let mut handles = vec![];
1505 for i in 0..50 {
1506 let s = Arc::clone(&s);
1507 handles.push(tokio::spawn(async move {
1508 s.put("k", &json!({"i": i})).await.unwrap();
1509 }));
1510 }
1511 for h in handles {
1512 h.await.unwrap();
1513 }
1514 let got = s.get("k").await.unwrap().unwrap();
1516 let i = got["i"].as_i64().unwrap();
1517 assert!((0..50).contains(&i));
1518 assert!(
1520 !has_tmp_residue(dir.path()),
1521 "no temp residue after concurrent puts"
1522 );
1523 }
1524
1525 #[tokio::test]
1526 async fn file_store_works_through_trait_object() {
1527 let dir = TempDir::new().unwrap();
1528 let s: Box<dyn StateStore> = Box::new(FileStateStore::new(dir.path()));
1529 s.put("k", &json!(1)).await.unwrap();
1530 assert_eq!(s.get("k").await.unwrap().unwrap(), json!(1));
1531 }
1532
1533 #[tokio::test]
1536 async fn memory_check_passes() {
1537 let s = MemoryStateStore::new();
1538 let report = s
1539 .check(&crate::check::CheckContext::default())
1540 .await
1541 .unwrap();
1542 assert_eq!(report.failed_count(), 0);
1543 assert!(
1544 report
1545 .probes
1546 .iter()
1547 .all(|p| matches!(p.status, crate::check::ProbeStatus::Pass))
1548 );
1549 }
1550
1551 #[tokio::test]
1552 async fn file_check_passes_for_writable_root() {
1553 let dir = TempDir::new().unwrap();
1554 let s = FileStateStore::new(dir.path());
1555 let report = s
1556 .check(&crate::check::CheckContext::default())
1557 .await
1558 .unwrap();
1559 assert_eq!(report.failed_count(), 0, "writable root should pass");
1560 let leftovers: Vec<_> = std::fs::read_dir(dir.path()).unwrap().collect();
1562 assert!(leftovers.is_empty(), "check() must not leave files behind");
1563 }
1564
1565 #[tokio::test]
1566 async fn file_store_root_returns_configured_directory() {
1567 let dir = TempDir::new().unwrap();
1568 let s = FileStateStore::new(dir.path());
1569 assert_eq!(s.root(), dir.path());
1570 }
1571
1572 #[tokio::test]
1573 async fn default_check_reports_not_implemented() {
1574 struct BareStore;
1577 #[async_trait]
1578 impl StateStore for BareStore {
1579 async fn get(&self, _key: &str) -> Result<Option<Value>, FaucetError> {
1580 Ok(None)
1581 }
1582 async fn put(&self, _key: &str, _value: &Value) -> Result<(), FaucetError> {
1583 Ok(())
1584 }
1585 async fn delete(&self, _key: &str) -> Result<(), FaucetError> {
1586 Ok(())
1587 }
1588 }
1589 let s = BareStore;
1590 let report = s
1591 .check(&crate::check::CheckContext::default())
1592 .await
1593 .unwrap();
1594 assert_eq!(report.failed_count(), 0);
1596 assert!(
1597 report
1598 .probes
1599 .iter()
1600 .any(|p| matches!(p.status, crate::check::ProbeStatus::Skip { .. })),
1601 "default check must surface a skipped (not-implemented) probe"
1602 );
1603 }
1604
1605 #[tokio::test]
1606 async fn file_check_fails_when_root_unusable() {
1607 let dir = TempDir::new().unwrap();
1609 let file = dir.path().join("not_a_dir");
1610 std::fs::write(&file, b"x").unwrap();
1611 let s = FileStateStore::new(file.join("state"));
1612 let report = s
1613 .check(&crate::check::CheckContext::default())
1614 .await
1615 .unwrap();
1616 assert_eq!(report.failed_count(), 1, "unusable root should fail");
1617 }
1618
1619 #[cfg(feature = "encryption")]
1620 mod encryption_at_rest {
1621 use super::*;
1622 use crate::encryption::{CompiledEncryption, EncryptionSpec, is_encrypted};
1623 use serde_json::json;
1624
1625 fn enc(key: &str) -> CompiledEncryption {
1626 CompiledEncryption::compile(&EncryptionSpec {
1627 key: key.into(),
1628 previous_keys: vec![],
1629 algorithm: Default::default(),
1630 })
1631 .unwrap()
1632 }
1633
1634 #[tokio::test]
1635 async fn encrypted_round_trip_and_ciphertext_on_disk() {
1636 let dir = tempfile::tempdir().unwrap();
1637 let store = FileStateStore::new(dir.path()).with_encryption(enc("k1"));
1638 store.put("bk", &json!({"lsn": 42})).await.unwrap();
1639 assert_eq!(store.get("bk").await.unwrap(), Some(json!({"lsn": 42})));
1640
1641 let raw = std::fs::read(dir.path().join("bk.json")).unwrap();
1643 assert!(is_encrypted(&raw));
1644 assert!(serde_json::from_slice::<Value>(&raw).is_err());
1645
1646 store.delete("bk").await.unwrap();
1647 assert_eq!(store.get("bk").await.unwrap(), None);
1648 }
1649
1650 #[tokio::test]
1651 async fn plaintext_file_stays_readable_and_is_sealed_on_next_write() {
1652 let dir = tempfile::tempdir().unwrap();
1653 let plain = FileStateStore::new(dir.path());
1655 plain.put("bk", &json!("legacy")).await.unwrap();
1656 let before = std::fs::read(dir.path().join("bk.json")).unwrap();
1657 assert!(!is_encrypted(&before));
1658
1659 let sealed = FileStateStore::new(dir.path()).with_encryption(enc("k1"));
1660 assert_eq!(sealed.get("bk").await.unwrap(), Some(json!("legacy")));
1661 sealed.put("bk", &json!("updated")).await.unwrap();
1662 let after = std::fs::read(dir.path().join("bk.json")).unwrap();
1663 assert!(is_encrypted(&after), "next write must seal the file");
1664 assert_eq!(sealed.get("bk").await.unwrap(), Some(json!("updated")));
1665 }
1666
1667 #[tokio::test]
1668 async fn wrong_key_is_a_typed_error_not_a_missing_bookmark() {
1669 let dir = tempfile::tempdir().unwrap();
1670 let a = FileStateStore::new(dir.path()).with_encryption(enc("right"));
1671 a.put("bk", &json!(1)).await.unwrap();
1672
1673 let b = FileStateStore::new(dir.path()).with_encryption(enc("wrong"));
1674 let err = b.get("bk").await.unwrap_err();
1675 assert!(matches!(err, FaucetError::State(_)));
1676 assert!(err.to_string().contains("could not be decrypted"), "{err}");
1677 }
1678
1679 #[tokio::test]
1680 async fn encrypted_file_with_unconfigured_store_is_a_typed_error() {
1681 let dir = tempfile::tempdir().unwrap();
1682 let sealed = FileStateStore::new(dir.path()).with_encryption(enc("k1"));
1683 sealed.put("bk", &json!(1)).await.unwrap();
1684
1685 let plain = FileStateStore::new(dir.path());
1686 let err = plain.get("bk").await.unwrap_err();
1687 assert!(err.to_string().contains("no `encryption` block"), "{err}");
1688 }
1689
1690 #[tokio::test]
1691 async fn rotation_reads_old_key_files() {
1692 let dir = tempfile::tempdir().unwrap();
1693 let old = FileStateStore::new(dir.path()).with_encryption(enc("old"));
1694 old.put("bk", &json!("v1")).await.unwrap();
1695
1696 let rotated = FileStateStore::new(dir.path()).with_encryption(
1697 CompiledEncryption::compile(&EncryptionSpec {
1698 key: "new".into(),
1699 previous_keys: vec!["old".into()],
1700 algorithm: Default::default(),
1701 })
1702 .unwrap(),
1703 );
1704 assert_eq!(rotated.get("bk").await.unwrap(), Some(json!("v1")));
1705 rotated.put("bk", &json!("v2")).await.unwrap();
1707 let new_only = FileStateStore::new(dir.path()).with_encryption(enc("new"));
1708 assert_eq!(new_only.get("bk").await.unwrap(), Some(json!("v2")));
1709 }
1710
1711 #[tokio::test]
1712 async fn no_temp_files_left_behind() {
1713 let dir = tempfile::tempdir().unwrap();
1714 let store = FileStateStore::new(dir.path()).with_encryption(enc("k1"));
1715 store.put("bk", &json!(1)).await.unwrap();
1716 let leftovers: Vec<_> = std::fs::read_dir(dir.path())
1717 .unwrap()
1718 .filter_map(Result::ok)
1719 .filter(|e| e.path().to_string_lossy().ends_with(".tmp"))
1720 .collect();
1721 assert!(
1722 leftovers.is_empty(),
1723 "atomic write must leave no temp files"
1724 );
1725 }
1726 }
1727}
1728
1729#[cfg(test)]
1730mod list_export_tests {
1731 use super::*;
1732 use serde_json::json;
1733
1734 struct Bare(MemoryStateStore);
1735
1736 #[async_trait]
1737 impl StateStore for Bare {
1738 async fn get(&self, key: &str) -> Result<Option<Value>, FaucetError> {
1739 self.0.get(key).await
1740 }
1741 async fn put(&self, key: &str, value: &Value) -> Result<(), FaucetError> {
1742 if key.ends_with("::boom") {
1743 return Err(FaucetError::State("disk full".into()));
1744 }
1745 self.0.put(key, value).await
1746 }
1747 async fn delete(&self, key: &str) -> Result<(), FaucetError> {
1748 if key.ends_with("::stuck") {
1749 return Err(FaucetError::State("locked".into()));
1750 }
1751 self.0.delete(key).await
1752 }
1753 }
1754
1755 #[tokio::test]
1756 async fn defaults_cannot_list_and_batch_sequentially() {
1757 let s = Bare(MemoryStateStore::new());
1758 assert!(!s.supports_list());
1759 assert!(!s.supports_atomic_batch());
1760 assert!(s.list("p::").await.is_err());
1761 s.put_batch(&[("p::a".into(), json!(1)), ("p::b".into(), json!(2))])
1762 .await
1763 .unwrap();
1764 assert_eq!(s.get("p::b").await.unwrap(), Some(json!(2)));
1765 let err = s
1766 .put_batch(&[("p::c".into(), json!(1)), ("p::boom".into(), json!(2))])
1767 .await
1768 .unwrap_err();
1769 assert!(err.to_string().contains("disk full"));
1770 assert_eq!(
1771 s.get("p::c").await.unwrap(),
1772 Some(json!(1)),
1773 "prefix landed"
1774 );
1775 }
1776
1777 #[tokio::test]
1778 async fn memory_lists_by_prefix_sorted_and_batches_atomically() {
1779 let s = MemoryStateStore::new();
1780 for k in ["p::b", "p::a", "q::a", "p::a::__sla__"] {
1781 s.put(k, &json!(k)).await.unwrap();
1782 }
1783 assert!(s.supports_list());
1784 assert_eq!(
1785 s.list("p::").await.unwrap(),
1786 vec!["p::a", "p::a::__sla__", "p::b"]
1787 );
1788 assert!(s.supports_atomic_batch());
1789 let err = s
1790 .put_batch(&[("p::x".into(), json!(1)), ("../bad".into(), json!(2))])
1791 .await
1792 .unwrap_err();
1793 assert!(matches!(err, FaucetError::State(_)));
1794 assert!(s.get("p::x").await.unwrap().is_none(), "nothing written");
1795 s.put_batch(&[("p::x".into(), json!(1))]).await.unwrap();
1796 assert_eq!(s.get("p::x").await.unwrap(), Some(json!(1)));
1797 }
1798
1799 #[tokio::test]
1800 async fn file_lists_decoded_keys_ignoring_temp_and_foreign_files() {
1801 let dir = tempfile::tempdir().unwrap();
1802 let s = FileStateStore::new(dir.path());
1803 assert!(s.list("p::").await.unwrap().is_empty(), "missing root");
1804 for k in ["p::a", "p::a::__sla__", "acme/p::x", "q::a"] {
1805 s.put(k, &json!(1)).await.unwrap();
1806 }
1807 std::fs::write(dir.path().join("p%3A%3Az.json.tmp"), b"{}").unwrap();
1808 std::fs::write(dir.path().join("notes.txt"), b"x").unwrap();
1809 std::fs::write(dir.path().join(".hidden.json"), b"{}").unwrap();
1810 assert!(s.supports_list());
1811 assert_eq!(s.list("p::").await.unwrap(), vec!["p::a", "p::a::__sla__"]);
1812 assert_eq!(s.list("acme/").await.unwrap(), vec!["acme/p::x"]);
1813 assert!(!s.supports_atomic_batch());
1814 }
1815
1816 #[cfg(unix)]
1817 #[tokio::test]
1818 async fn file_list_reports_unreadable_root() {
1819 let dir = tempfile::tempdir().unwrap();
1820 let file = dir.path().join("not-a-dir");
1821 std::fs::write(&file, b"x").unwrap();
1822 let s = FileStateStore::new(&file);
1823 let err = s.list("p::").await.unwrap_err();
1824 assert!(
1825 err.to_string().contains("failed to list state dir"),
1826 "{err}"
1827 );
1828 }
1829
1830 #[test]
1831 fn export_document_parses_and_validates() {
1832 let ok = json!({
1833 "version": 1,
1834 "pipeline": "orders",
1835 "exported_at": "2026-09-26T00:00:00Z",
1836 "keys": {"orders::a": {"id": 3}, "orders::a::__sla__": {}}
1837 });
1838 let e = StateExport::from_value(ok).unwrap();
1839 assert_eq!(e.keys.len(), 2);
1840 assert_eq!(namespace_prefix("orders"), "orders::");
1841
1842 let cases = [
1843 (json!({"pipeline": "o", "keys": {}}), "no `version`"),
1844 (
1845 json!({"version": "1", "pipeline": "o"}),
1846 "must be an integer",
1847 ),
1848 (json!({"version": 2, "pipeline": "o"}), "not supported"),
1849 (json!({"version": 0, "pipeline": "o"}), "not supported"),
1850 (json!({"version": 1}), "malformed"),
1851 (json!({"version": 1, "pipeline": "../o"}), "pipeline name"),
1852 (
1853 json!({"version": 1, "pipeline": "o", "keys": {"other::a": 1}}),
1854 "outside the namespace",
1855 ),
1856 (
1857 json!({"version": 1, "pipeline": "o", "keys": {"o::a b": 1}}),
1858 "illegal character",
1859 ),
1860 ];
1861 for (doc, needle) in cases {
1862 let err = StateExport::from_value(doc.clone())
1863 .unwrap_err()
1864 .to_string();
1865 assert!(err.contains(needle), "{doc} → {err}");
1866 }
1867 }
1868
1869 #[tokio::test]
1870 async fn export_then_import_round_trips_and_replace_drops_stale_keys() {
1871 let from = MemoryStateStore::new();
1872 from.put("o::a", &json!({"c": 1})).await.unwrap();
1873 from.put("o::b", &json!(2)).await.unwrap();
1874 from.put("other::a", &json!(9)).await.unwrap();
1875 let export = export_namespace(&from, "o").await.unwrap();
1876 assert_eq!(export.keys.len(), 2);
1877
1878 let to = MemoryStateStore::new();
1879 to.put("o::stale", &json!(0)).await.unwrap();
1880 let r = import_namespace(&to, &export, false).await.unwrap();
1881 assert!(r.atomic && r.error.is_none());
1882 assert_eq!(r.written, vec!["o::a", "o::b"]);
1883 assert!(
1884 to.get("o::stale").await.unwrap().is_some(),
1885 "kept without replace"
1886 );
1887
1888 let r = import_namespace(&to, &export, true).await.unwrap();
1889 assert_eq!(r.deleted, vec!["o::stale"]);
1890 assert!(to.get("o::stale").await.unwrap().is_none());
1891 assert_eq!(to.get("o::a").await.unwrap(), Some(json!({"c": 1})));
1892 }
1893
1894 #[tokio::test]
1895 async fn import_reports_partial_progress_on_a_non_atomic_store() {
1896 let mut export = StateExport::new("o");
1897 export.keys.insert("o::a".into(), json!(1));
1898 export.keys.insert("o::boom".into(), json!(2));
1899 let s = Bare(MemoryStateStore::new());
1900 let r = import_namespace(&s, &export, false).await.unwrap();
1901 assert!(!r.atomic);
1902 assert_eq!(r.written, vec!["o::a"]);
1903 assert!(r.error.unwrap().contains("o::boom"));
1904
1905 let mut bad = StateExport::new("o");
1906 bad.keys.insert("x::a".into(), json!(1));
1907 assert!(import_namespace(&s, &bad, false).await.is_err());
1908 }
1909
1910 #[tokio::test]
1911 async fn import_reports_batch_and_delete_failures() {
1912 struct Failing;
1913 #[async_trait]
1914 impl StateStore for Failing {
1915 async fn get(&self, _: &str) -> Result<Option<Value>, FaucetError> {
1916 Ok(None)
1917 }
1918 async fn put(&self, _: &str, _: &Value) -> Result<(), FaucetError> {
1919 Ok(())
1920 }
1921 async fn delete(&self, _: &str) -> Result<(), FaucetError> {
1922 Ok(())
1923 }
1924 fn supports_atomic_batch(&self) -> bool {
1925 true
1926 }
1927 async fn put_batch(&self, _: &[(String, Value)]) -> Result<(), FaucetError> {
1928 Err(FaucetError::State("tx aborted".into()))
1929 }
1930 }
1931 assert_eq!(Failing.get("o::a").await.unwrap(), None);
1932 Failing.put("o::a", &json!(1)).await.unwrap();
1933 Failing.delete("o::a").await.unwrap();
1934 let mut export = StateExport::new("o");
1935 export.keys.insert("o::a".into(), json!(1));
1936 let r = import_namespace(&Failing, &export, false).await.unwrap();
1937 assert!(r.written.is_empty());
1938 assert!(r.error.unwrap().contains("tx aborted"));
1939
1940 assert!(import_namespace(&Failing, &export, true).await.is_err());
1942
1943 let s = Bare(MemoryStateStore::new());
1944 struct Listing(Bare);
1945 #[async_trait]
1946 impl StateStore for Listing {
1947 async fn get(&self, k: &str) -> Result<Option<Value>, FaucetError> {
1948 self.0.get(k).await
1949 }
1950 async fn put(&self, k: &str, v: &Value) -> Result<(), FaucetError> {
1951 self.0.put(k, v).await
1952 }
1953 async fn delete(&self, k: &str) -> Result<(), FaucetError> {
1954 self.0.delete(k).await
1955 }
1956 async fn list(&self, p: &str) -> Result<Vec<String>, FaucetError> {
1957 self.0.0.list(p).await
1958 }
1959 }
1960 s.put("o::stuck", &json!(1)).await.unwrap();
1961 let listing = Listing(s);
1962 assert_eq!(listing.get("o::stuck").await.unwrap(), Some(json!(1)));
1963 let r = import_namespace(&listing, &export, true).await.unwrap();
1964 assert_eq!(r.written, vec!["o::a"]);
1965 assert!(r.error.unwrap().contains("deleting stale 'o::stuck'"));
1966 }
1967}