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
57pub const DOCTOR_SENTINEL_KEY: &str = "faucet_doctor_probe";
60
61pub fn validate_state_key(key: &str) -> Result<(), FaucetError> {
65 if key.is_empty() {
66 return Err(FaucetError::State("state key must not be empty".into()));
67 }
68 if key.len() > 256 {
69 return Err(FaucetError::State(format!(
70 "state key '{key}' exceeds 256 characters"
71 )));
72 }
73 for (i, c) in key.char_indices() {
74 let ok = c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | ':' | '.');
75 if !ok {
76 return Err(FaucetError::State(format!(
77 "state key '{key}' contains illegal character {c:?} at byte {i}"
78 )));
79 }
80 }
81 if key == "." || key == ".." || key.starts_with('.') {
82 return Err(FaucetError::State(format!(
83 "state key '{key}' must not begin with a dot"
84 )));
85 }
86 Ok(())
87}
88
89#[derive(Default)]
93pub struct MemoryStateStore {
94 inner: Mutex<HashMap<String, Value>>,
95}
96
97impl MemoryStateStore {
98 pub fn new() -> Self {
100 Self::default()
101 }
102}
103
104#[async_trait]
105impl StateStore for MemoryStateStore {
106 async fn get(&self, key: &str) -> Result<Option<Value>, FaucetError> {
107 validate_state_key(key)?;
108 Ok(self.inner.lock().await.get(key).cloned())
109 }
110
111 async fn put(&self, key: &str, value: &Value) -> Result<(), FaucetError> {
112 validate_state_key(key)?;
113 self.inner
114 .lock()
115 .await
116 .insert(key.to_owned(), value.clone());
117 Ok(())
118 }
119
120 async fn delete(&self, key: &str) -> Result<(), FaucetError> {
121 validate_state_key(key)?;
122 self.inner.lock().await.remove(key);
123 Ok(())
124 }
125
126 async fn check(
127 &self,
128 _ctx: &crate::check::CheckContext,
129 ) -> Result<crate::check::CheckReport, FaucetError> {
130 Ok(crate::check::CheckReport::single(
132 crate::check::Probe::pass("sentinel", std::time::Duration::ZERO),
133 ))
134 }
135}
136
137fn safe_filename(key: &str) -> String {
145 key.replace(':', "%3A")
146}
147
148pub struct FileStateStore {
163 root: PathBuf,
164 write_lock: Mutex<()>,
165 #[cfg(feature = "encryption")]
169 encryption: Option<crate::encryption::CompiledEncryption>,
170}
171
172impl FileStateStore {
173 pub fn new(root: impl Into<PathBuf>) -> Self {
175 Self {
176 root: root.into(),
177 write_lock: Mutex::new(()),
178 #[cfg(feature = "encryption")]
179 encryption: None,
180 }
181 }
182
183 #[cfg(feature = "encryption")]
186 pub fn with_encryption(mut self, encryption: crate::encryption::CompiledEncryption) -> Self {
187 self.encryption = Some(encryption);
188 self
189 }
190
191 fn entry_path(&self, key: &str) -> PathBuf {
192 self.root.join(format!("{}.json", safe_filename(key)))
193 }
194
195 fn temp_path(&self, key: &str) -> PathBuf {
196 use std::sync::OnceLock;
213 use std::sync::atomic::{AtomicU64, Ordering};
214 static PROC_TOKEN: OnceLock<String> = OnceLock::new();
215 static SEQ: AtomicU64 = AtomicU64::new(0);
216 let token = PROC_TOKEN.get_or_init(|| uuid::Uuid::new_v4().simple().to_string());
217 let seq = SEQ.fetch_add(1, Ordering::Relaxed);
218 self.root
219 .join(format!("{}.{}.{}.json.tmp", safe_filename(key), token, seq))
220 }
221
222 async fn ensure_root(&self) -> Result<(), FaucetError> {
223 tokio::fs::create_dir_all(&self.root).await.map_err(|e| {
224 FaucetError::State(format!(
225 "failed to create state dir {}: {e}",
226 self.root.display()
227 ))
228 })
229 }
230
231 pub fn root(&self) -> &Path {
233 &self.root
234 }
235}
236
237#[async_trait]
238impl StateStore for FileStateStore {
239 async fn get(&self, key: &str) -> Result<Option<Value>, FaucetError> {
240 validate_state_key(key)?;
241 let path = self.entry_path(key);
242 match tokio::fs::read(&path).await {
243 Ok(bytes) => {
244 #[cfg(feature = "encryption")]
245 let bytes: Vec<u8> = if crate::encryption::is_encrypted(&bytes) {
246 match &self.encryption {
247 Some(enc) => enc.decrypt(&bytes).map_err(|e| {
248 FaucetError::State(format!(
252 "state file {} could not be decrypted: {e}",
253 path.display()
254 ))
255 })?,
256 None => {
257 return Err(FaucetError::State(format!(
258 "state file {} is encrypted but no `encryption` block is \
259 configured on the file state store — add \
260 `state.config.encryption` with the original key",
261 path.display()
262 )));
263 }
264 }
265 } else {
266 bytes
267 };
268 #[cfg(not(feature = "encryption"))]
269 let bytes = {
270 if bytes.starts_with(b"FCT1") {
273 return Err(FaucetError::State(format!(
274 "state file {} is encrypted but this build of faucet has no \
275 `encryption` feature",
276 path.display()
277 )));
278 }
279 bytes
280 };
281 let value: Value = serde_json::from_slice(&bytes).map_err(|e| {
282 FaucetError::State(format!(
283 "failed to parse state file {}: {e}",
284 path.display()
285 ))
286 })?;
287 Ok(Some(value))
288 }
289 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
290 Err(e) => Err(FaucetError::State(format!(
291 "failed to read state file {}: {e}",
292 path.display()
293 ))),
294 }
295 }
296
297 async fn put(&self, key: &str, value: &Value) -> Result<(), FaucetError> {
298 validate_state_key(key)?;
299 let _guard = self.write_lock.lock().await;
300 self.ensure_root().await?;
301 let bytes = serde_json::to_vec(value).map_err(|e| {
302 FaucetError::State(format!("failed to serialize state for key '{key}': {e}"))
303 })?;
304 #[cfg(feature = "encryption")]
305 let bytes = match &self.encryption {
306 Some(enc) => enc.encrypt(&bytes),
307 None => bytes,
308 };
309 let final_path = self.entry_path(key);
310 let tmp_path = self.temp_path(key);
311
312 {
318 let mut file = tokio::fs::File::create(&tmp_path).await.map_err(|e| {
319 FaucetError::State(format!(
320 "failed to create temp state file {}: {e}",
321 tmp_path.display()
322 ))
323 })?;
324 file.write_all(&bytes).await.map_err(|e| {
325 FaucetError::State(format!(
326 "failed to write temp state file {}: {e}",
327 tmp_path.display()
328 ))
329 })?;
330 file.sync_all().await.map_err(|e| {
331 FaucetError::State(format!(
332 "failed to fsync temp state file {}: {e}",
333 tmp_path.display()
334 ))
335 })?;
336 }
337
338 tokio::fs::rename(&tmp_path, &final_path)
339 .await
340 .map_err(|e| {
341 FaucetError::State(format!(
342 "failed to commit state file {}: {e}",
343 final_path.display()
344 ))
345 })?;
346
347 #[cfg(unix)]
353 {
354 let dir = tokio::fs::File::open(&self.root).await.map_err(|e| {
355 FaucetError::State(format!(
356 "failed to open state dir {} for fsync: {e}",
357 self.root.display()
358 ))
359 })?;
360 dir.sync_all().await.map_err(|e| {
361 FaucetError::State(format!(
362 "failed to fsync state dir {}: {e}",
363 self.root.display()
364 ))
365 })?;
366 }
367
368 tracing::debug!(
369 key,
370 path = %final_path.display(),
371 "state file written"
372 );
373 Ok(())
374 }
375
376 async fn delete(&self, key: &str) -> Result<(), FaucetError> {
377 validate_state_key(key)?;
378 let path = self.entry_path(key);
379 match tokio::fs::remove_file(&path).await {
380 Ok(()) => Ok(()),
381 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
382 Err(e) => Err(FaucetError::State(format!(
383 "failed to delete state file {}: {e}",
384 path.display()
385 ))),
386 }
387 }
388
389 async fn check(
390 &self,
391 _ctx: &crate::check::CheckContext,
392 ) -> Result<crate::check::CheckReport, FaucetError> {
393 use crate::check::{CheckReport, Probe};
394 let start = std::time::Instant::now();
398 let probe = match self.sentinel_roundtrip().await {
399 Ok(()) => Probe::pass("sentinel", start.elapsed()),
400 Err(e) => Probe::fail_hint(
401 "sentinel",
402 start.elapsed(),
403 e.to_string(),
404 format!("ensure {} exists and is writable", self.root.display()),
405 ),
406 };
407 Ok(CheckReport::single(probe))
408 }
409}
410
411impl FileStateStore {
412 async fn sentinel_roundtrip(&self) -> Result<(), FaucetError> {
415 let probe = serde_json::json!({ "faucet_doctor": true });
416 self.put(DOCTOR_SENTINEL_KEY, &probe).await?;
417 let got = self.get(DOCTOR_SENTINEL_KEY).await?;
418 let _ = self.delete(DOCTOR_SENTINEL_KEY).await;
420 match got {
421 Some(v) if v == probe => Ok(()),
422 _ => Err(FaucetError::State(
423 "sentinel readback did not match what was written".into(),
424 )),
425 }
426 }
427}
428
429#[cfg(test)]
430mod tests {
431 use super::*;
432 use serde_json::json;
433 use std::sync::Arc;
434 use tempfile::TempDir;
435
436 #[test]
439 fn rejects_empty_key() {
440 let err = validate_state_key("").unwrap_err();
441 assert!(matches!(err, FaucetError::State(_)));
442 }
443
444 #[test]
445 fn rejects_path_traversal_segments() {
446 for k in ["../etc/passwd", "a/b", "a\\b", "..", "."] {
447 assert!(validate_state_key(k).is_err(), "expected reject for {k:?}");
448 }
449 }
450
451 #[test]
452 fn rejects_leading_dot() {
453 assert!(validate_state_key(".hidden").is_err());
454 }
455
456 #[test]
457 fn rejects_over_long_key() {
458 let k = "a".repeat(257);
459 assert!(validate_state_key(&k).is_err());
460 }
461
462 #[test]
463 fn accepts_typical_keys() {
464 for k in [
465 "github_issues",
466 "pipeline:rest:issues",
467 "with.dot",
468 "with-dash_and_underscore",
469 "lower-Case_99",
470 ] {
471 validate_state_key(k).unwrap_or_else(|e| panic!("expected ok for {k:?}: {e}"));
472 }
473 }
474
475 #[tokio::test]
478 async fn memory_get_returns_none_for_missing_key() {
479 let s = MemoryStateStore::new();
480 assert!(s.get("nope").await.unwrap().is_none());
481 }
482
483 #[tokio::test]
484 async fn memory_put_then_get_round_trips() {
485 let s = MemoryStateStore::new();
486 s.put("k", &json!({"cursor": "abc", "n": 7})).await.unwrap();
487 let got = s.get("k").await.unwrap().unwrap();
488 assert_eq!(got["cursor"], "abc");
489 assert_eq!(got["n"], 7);
490 }
491
492 #[tokio::test]
493 async fn memory_put_overwrites_previous_value() {
494 let s = MemoryStateStore::new();
495 s.put("k", &json!(1)).await.unwrap();
496 s.put("k", &json!(2)).await.unwrap();
497 assert_eq!(s.get("k").await.unwrap().unwrap(), json!(2));
498 }
499
500 #[tokio::test]
501 async fn memory_delete_makes_get_return_none() {
502 let s = MemoryStateStore::new();
503 s.put("k", &json!("v")).await.unwrap();
504 s.delete("k").await.unwrap();
505 assert!(s.get("k").await.unwrap().is_none());
506 }
507
508 #[tokio::test]
509 async fn memory_delete_missing_key_is_ok() {
510 let s = MemoryStateStore::new();
511 s.delete("absent").await.unwrap();
512 }
513
514 #[tokio::test]
515 async fn memory_rejects_invalid_keys() {
516 let s = MemoryStateStore::new();
517 assert!(s.get("a/b").await.is_err());
518 assert!(s.put("a/b", &json!(1)).await.is_err());
519 assert!(s.delete("a/b").await.is_err());
520 }
521
522 #[tokio::test]
525 async fn file_get_returns_none_for_missing_key() {
526 let dir = TempDir::new().unwrap();
527 let s = FileStateStore::new(dir.path());
528 assert!(s.get("nope").await.unwrap().is_none());
529 }
530
531 #[tokio::test]
532 async fn file_put_creates_root_directory_lazily() {
533 let dir = TempDir::new().unwrap();
534 let root = dir.path().join("nested/state");
535 let s = FileStateStore::new(&root);
536 s.put("k", &json!("v")).await.unwrap();
537 assert!(root.is_dir(), "root dir should be created on first put");
538 }
539
540 #[tokio::test]
541 async fn file_put_then_get_round_trips() {
542 let dir = TempDir::new().unwrap();
543 let s = FileStateStore::new(dir.path());
544 let value = json!({"cursor": "abc", "n": 42, "nested": {"flag": true}});
545 s.put("github_issues", &value).await.unwrap();
546 let got = s.get("github_issues").await.unwrap().unwrap();
547 assert_eq!(got, value);
548 }
549
550 #[test]
551 fn temp_path_is_unique_and_not_pid_derived() {
552 let dir = TempDir::new().unwrap();
558 let s = FileStateStore::new(dir.path());
559
560 let a = s.temp_path("k");
561 let b = s.temp_path("k");
562 assert_ne!(a, b);
564
565 let name_a = a.file_name().unwrap().to_str().unwrap();
566 let pid = std::process::id().to_string();
567 assert!(
569 !name_a.split('.').any(|seg| seg == pid),
570 "temp filename {name_a} must not embed the process id ({pid})"
571 );
572 assert!(name_a.ends_with(".json.tmp"));
573 }
574
575 #[test]
576 fn safe_filename_percent_encodes_colon() {
577 assert_eq!(
578 safe_filename("pipeline:rest:issues"),
579 "pipeline%3Arest%3Aissues"
580 );
581 assert_eq!(safe_filename("plain_key-1.v2"), "plain_key-1.v2");
582 }
583
584 #[tokio::test]
585 async fn file_round_trips_colon_keys_with_safe_filename() {
586 let dir = TempDir::new().unwrap();
590 let s = FileStateStore::new(dir.path());
591 let value = json!({"cursor": "z"});
592 s.put("pipeline:rest:issues", &value).await.unwrap();
593 assert_eq!(s.get("pipeline:rest:issues").await.unwrap().unwrap(), value);
594 assert!(dir.path().join("pipeline%3Arest%3Aissues.json").exists());
596 let mut has_colon = false;
597 for entry in std::fs::read_dir(dir.path()).unwrap() {
598 if entry.unwrap().file_name().to_string_lossy().contains(':') {
599 has_colon = true;
600 }
601 }
602 assert!(!has_colon, "no state filename may contain ':'");
603 }
604
605 fn has_tmp_residue(dir: &std::path::Path) -> bool {
609 std::fs::read_dir(dir)
610 .unwrap()
611 .filter_map(|e| e.ok())
612 .any(|e| e.file_name().to_string_lossy().ends_with(".json.tmp"))
613 }
614
615 #[tokio::test]
616 async fn file_put_overwrites_previous_value_atomically() {
617 let dir = TempDir::new().unwrap();
618 let s = FileStateStore::new(dir.path());
619 s.put("k", &json!({"v": 1})).await.unwrap();
620 s.put("k", &json!({"v": 2})).await.unwrap();
621 assert_eq!(s.get("k").await.unwrap().unwrap(), json!({"v": 2}));
622 assert!(!has_tmp_residue(dir.path()), "no temp residue after put");
624 }
625
626 #[test]
627 fn file_temp_paths_are_unique_per_write() {
628 let dir = TempDir::new().unwrap();
633 let s = FileStateStore::new(dir.path());
634 let a = s.temp_path("k");
635 let b = s.temp_path("k");
636 assert_ne!(a, b, "each write must get a distinct temp path");
637 assert_eq!(s.entry_path("k"), s.entry_path("k"));
639 }
640
641 #[tokio::test]
642 async fn file_put_writes_complete_durable_file_with_no_temp_residue() {
643 let dir = TempDir::new().unwrap();
650 let s = FileStateStore::new(dir.path());
651 let big: Vec<Value> = (0..1_000)
652 .map(|i| json!({"i": i, "s": "x".repeat(20)}))
653 .collect();
654 let value = json!({"cursor": "abc", "rows": big});
655
656 s.put("github_issues", &value).await.unwrap();
657
658 let raw = tokio::fs::read(dir.path().join("github_issues.json"))
660 .await
661 .expect("state file must exist after put");
662 assert!(!raw.is_empty(), "state file must not be zero-length");
663 let parsed: Value = serde_json::from_slice(&raw).expect("state file must be valid JSON");
664 assert_eq!(parsed, value);
665
666 assert!(!has_tmp_residue(dir.path()), "no temp residue after put");
668 }
669
670 #[tokio::test]
671 async fn file_delete_removes_file() {
672 let dir = TempDir::new().unwrap();
673 let s = FileStateStore::new(dir.path());
674 s.put("k", &json!("v")).await.unwrap();
675 s.delete("k").await.unwrap();
676 assert!(s.get("k").await.unwrap().is_none());
677 assert!(!dir.path().join("k.json").exists());
678 }
679
680 #[tokio::test]
681 async fn file_delete_missing_key_is_ok() {
682 let dir = TempDir::new().unwrap();
683 let s = FileStateStore::new(dir.path());
684 s.delete("absent").await.unwrap();
685 }
686
687 #[tokio::test]
688 async fn file_get_returns_error_for_corrupt_json() {
689 let dir = TempDir::new().unwrap();
690 let s = FileStateStore::new(dir.path());
691 tokio::fs::create_dir_all(dir.path()).await.unwrap();
692 tokio::fs::write(dir.path().join("bad.json"), b"not json")
693 .await
694 .unwrap();
695 let err = s.get("bad").await.unwrap_err();
696 match err {
697 FaucetError::State(msg) => assert!(msg.contains("bad.json")),
698 other => panic!("expected State error, got {other:?}"),
699 }
700 }
701
702 #[tokio::test]
703 async fn file_concurrent_puts_do_not_corrupt_or_leak_temp() {
704 let dir = TempDir::new().unwrap();
705 let s = Arc::new(FileStateStore::new(dir.path()));
706 let mut handles = vec![];
707 for i in 0..50 {
708 let s = Arc::clone(&s);
709 handles.push(tokio::spawn(async move {
710 s.put("k", &json!({"i": i})).await.unwrap();
711 }));
712 }
713 for h in handles {
714 h.await.unwrap();
715 }
716 let got = s.get("k").await.unwrap().unwrap();
718 let i = got["i"].as_i64().unwrap();
719 assert!((0..50).contains(&i));
720 assert!(
722 !has_tmp_residue(dir.path()),
723 "no temp residue after concurrent puts"
724 );
725 }
726
727 #[tokio::test]
728 async fn file_store_works_through_trait_object() {
729 let dir = TempDir::new().unwrap();
730 let s: Box<dyn StateStore> = Box::new(FileStateStore::new(dir.path()));
731 s.put("k", &json!(1)).await.unwrap();
732 assert_eq!(s.get("k").await.unwrap().unwrap(), json!(1));
733 }
734
735 #[tokio::test]
738 async fn memory_check_passes() {
739 let s = MemoryStateStore::new();
740 let report = s
741 .check(&crate::check::CheckContext::default())
742 .await
743 .unwrap();
744 assert_eq!(report.failed_count(), 0);
745 assert!(
746 report
747 .probes
748 .iter()
749 .all(|p| matches!(p.status, crate::check::ProbeStatus::Pass))
750 );
751 }
752
753 #[tokio::test]
754 async fn file_check_passes_for_writable_root() {
755 let dir = TempDir::new().unwrap();
756 let s = FileStateStore::new(dir.path());
757 let report = s
758 .check(&crate::check::CheckContext::default())
759 .await
760 .unwrap();
761 assert_eq!(report.failed_count(), 0, "writable root should pass");
762 let leftovers: Vec<_> = std::fs::read_dir(dir.path()).unwrap().collect();
764 assert!(leftovers.is_empty(), "check() must not leave files behind");
765 }
766
767 #[tokio::test]
768 async fn file_store_root_returns_configured_directory() {
769 let dir = TempDir::new().unwrap();
770 let s = FileStateStore::new(dir.path());
771 assert_eq!(s.root(), dir.path());
772 }
773
774 #[tokio::test]
775 async fn default_check_reports_not_implemented() {
776 struct BareStore;
779 #[async_trait]
780 impl StateStore for BareStore {
781 async fn get(&self, _key: &str) -> Result<Option<Value>, FaucetError> {
782 Ok(None)
783 }
784 async fn put(&self, _key: &str, _value: &Value) -> Result<(), FaucetError> {
785 Ok(())
786 }
787 async fn delete(&self, _key: &str) -> Result<(), FaucetError> {
788 Ok(())
789 }
790 }
791 let s = BareStore;
792 let report = s
793 .check(&crate::check::CheckContext::default())
794 .await
795 .unwrap();
796 assert_eq!(report.failed_count(), 0);
798 assert!(
799 report
800 .probes
801 .iter()
802 .any(|p| matches!(p.status, crate::check::ProbeStatus::Skip { .. })),
803 "default check must surface a skipped (not-implemented) probe"
804 );
805 }
806
807 #[tokio::test]
808 async fn file_check_fails_when_root_unusable() {
809 let dir = TempDir::new().unwrap();
811 let file = dir.path().join("not_a_dir");
812 std::fs::write(&file, b"x").unwrap();
813 let s = FileStateStore::new(file.join("state"));
814 let report = s
815 .check(&crate::check::CheckContext::default())
816 .await
817 .unwrap();
818 assert_eq!(report.failed_count(), 1, "unusable root should fail");
819 }
820
821 #[cfg(feature = "encryption")]
822 mod encryption_at_rest {
823 use super::*;
824 use crate::encryption::{CompiledEncryption, EncryptionSpec, is_encrypted};
825 use serde_json::json;
826
827 fn enc(key: &str) -> CompiledEncryption {
828 CompiledEncryption::compile(&EncryptionSpec {
829 key: key.into(),
830 previous_keys: vec![],
831 algorithm: Default::default(),
832 })
833 .unwrap()
834 }
835
836 #[tokio::test]
837 async fn encrypted_round_trip_and_ciphertext_on_disk() {
838 let dir = tempfile::tempdir().unwrap();
839 let store = FileStateStore::new(dir.path()).with_encryption(enc("k1"));
840 store.put("bk", &json!({"lsn": 42})).await.unwrap();
841 assert_eq!(store.get("bk").await.unwrap(), Some(json!({"lsn": 42})));
842
843 let raw = std::fs::read(dir.path().join("bk.json")).unwrap();
845 assert!(is_encrypted(&raw));
846 assert!(serde_json::from_slice::<Value>(&raw).is_err());
847
848 store.delete("bk").await.unwrap();
849 assert_eq!(store.get("bk").await.unwrap(), None);
850 }
851
852 #[tokio::test]
853 async fn plaintext_file_stays_readable_and_is_sealed_on_next_write() {
854 let dir = tempfile::tempdir().unwrap();
855 let plain = FileStateStore::new(dir.path());
857 plain.put("bk", &json!("legacy")).await.unwrap();
858 let before = std::fs::read(dir.path().join("bk.json")).unwrap();
859 assert!(!is_encrypted(&before));
860
861 let sealed = FileStateStore::new(dir.path()).with_encryption(enc("k1"));
862 assert_eq!(sealed.get("bk").await.unwrap(), Some(json!("legacy")));
863 sealed.put("bk", &json!("updated")).await.unwrap();
864 let after = std::fs::read(dir.path().join("bk.json")).unwrap();
865 assert!(is_encrypted(&after), "next write must seal the file");
866 assert_eq!(sealed.get("bk").await.unwrap(), Some(json!("updated")));
867 }
868
869 #[tokio::test]
870 async fn wrong_key_is_a_typed_error_not_a_missing_bookmark() {
871 let dir = tempfile::tempdir().unwrap();
872 let a = FileStateStore::new(dir.path()).with_encryption(enc("right"));
873 a.put("bk", &json!(1)).await.unwrap();
874
875 let b = FileStateStore::new(dir.path()).with_encryption(enc("wrong"));
876 let err = b.get("bk").await.unwrap_err();
877 assert!(matches!(err, FaucetError::State(_)));
878 assert!(err.to_string().contains("could not be decrypted"), "{err}");
879 }
880
881 #[tokio::test]
882 async fn encrypted_file_with_unconfigured_store_is_a_typed_error() {
883 let dir = tempfile::tempdir().unwrap();
884 let sealed = FileStateStore::new(dir.path()).with_encryption(enc("k1"));
885 sealed.put("bk", &json!(1)).await.unwrap();
886
887 let plain = FileStateStore::new(dir.path());
888 let err = plain.get("bk").await.unwrap_err();
889 assert!(err.to_string().contains("no `encryption` block"), "{err}");
890 }
891
892 #[tokio::test]
893 async fn rotation_reads_old_key_files() {
894 let dir = tempfile::tempdir().unwrap();
895 let old = FileStateStore::new(dir.path()).with_encryption(enc("old"));
896 old.put("bk", &json!("v1")).await.unwrap();
897
898 let rotated = FileStateStore::new(dir.path()).with_encryption(
899 CompiledEncryption::compile(&EncryptionSpec {
900 key: "new".into(),
901 previous_keys: vec!["old".into()],
902 algorithm: Default::default(),
903 })
904 .unwrap(),
905 );
906 assert_eq!(rotated.get("bk").await.unwrap(), Some(json!("v1")));
907 rotated.put("bk", &json!("v2")).await.unwrap();
909 let new_only = FileStateStore::new(dir.path()).with_encryption(enc("new"));
910 assert_eq!(new_only.get("bk").await.unwrap(), Some(json!("v2")));
911 }
912
913 #[tokio::test]
914 async fn no_temp_files_left_behind() {
915 let dir = tempfile::tempdir().unwrap();
916 let store = FileStateStore::new(dir.path()).with_encryption(enc("k1"));
917 store.put("bk", &json!(1)).await.unwrap();
918 let leftovers: Vec<_> = std::fs::read_dir(dir.path())
919 .unwrap()
920 .filter_map(Result::ok)
921 .filter(|e| e.path().to_string_lossy().ends_with(".tmp"))
922 .collect();
923 assert!(
924 leftovers.is_empty(),
925 "atomic write must leave no temp files"
926 );
927 }
928 }
929}