matter_controller/store/
file.rs1use std::path::PathBuf;
4
5use super::{ControllerStore, StoreError};
6
7#[derive(Debug, Clone)]
14pub struct FileStore {
15 path: PathBuf,
16}
17
18impl FileStore {
19 pub fn new(path: impl Into<PathBuf>) -> Self {
21 Self { path: path.into() }
22 }
23}
24
25impl ControllerStore for FileStore {
26 fn load(&self) -> Result<Option<Vec<u8>>, StoreError> {
27 match std::fs::read(&self.path) {
28 Ok(bytes) => Ok(Some(bytes)),
29 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
30 Err(e) => Err(StoreError::Io(e)),
31 }
32 }
33
34 fn save(&self, snapshot: &[u8]) -> Result<(), StoreError> {
35 use std::io::Write;
36 use std::sync::atomic::{AtomicU64, Ordering};
37
38 static SAVE_SEQ: AtomicU64 = AtomicU64::new(0);
46 let seq = SAVE_SEQ.fetch_add(1, Ordering::Relaxed);
47 let tmp = self
48 .path
49 .with_extension(format!("tmp.{}.{seq}", std::process::id()));
50 {
51 let mut f = std::fs::File::create(&tmp)?;
52 #[cfg(unix)]
53 {
54 use std::os::unix::fs::PermissionsExt;
55 f.set_permissions(std::fs::Permissions::from_mode(0o600))?;
56 }
57 f.write_all(snapshot)?;
58 f.sync_all()?;
59 }
60 if let Err(e) = std::fs::rename(&tmp, &self.path) {
61 let _ = std::fs::remove_file(&tmp);
63 return Err(StoreError::Io(e));
64 }
65 Ok(())
66 }
67}
68
69#[cfg(test)]
70#[allow(clippy::unwrap_used, clippy::expect_used)] mod tests {
72 use super::*;
73
74 fn temp_path(name: &str) -> PathBuf {
75 use std::sync::atomic::{AtomicU32, Ordering};
76 static COUNTER: AtomicU32 = AtomicU32::new(0);
79 let uniq = COUNTER.fetch_add(1, Ordering::Relaxed);
80 let mut p = std::env::temp_dir();
81 p.push(format!(
82 "matter-controller-test-{name}-{}-{uniq}",
83 std::process::id()
84 ));
85 let _ = std::fs::remove_file(&p);
86 let _ = std::fs::remove_file(p.with_extension("tmp"));
87 p
88 }
89
90 #[test]
91 fn load_missing_returns_none() {
92 let store = FileStore::new(temp_path("missing"));
93 assert!(store.load().expect("load ok").is_none());
94 }
95
96 #[test]
97 fn save_then_load_round_trips() {
98 let path = temp_path("roundtrip");
99 let store = FileStore::new(&path);
100 store.save(b"hello snapshot").expect("save ok");
101 assert_eq!(
102 store.load().expect("load ok"),
103 Some(b"hello snapshot".to_vec())
104 );
105 let _ = std::fs::remove_file(&path);
106 }
107
108 #[test]
109 fn save_overwrites_atomically() {
110 let path = temp_path("overwrite");
111 let store = FileStore::new(&path);
112 store.save(b"first").expect("save 1");
113 store.save(b"second value longer").expect("save 2");
114 assert_eq!(store.load().expect("load").unwrap(), b"second value longer");
115 let stem = path.file_name().unwrap().to_string_lossy().into_owned();
117 let stray = std::fs::read_dir(path.parent().unwrap())
118 .unwrap()
119 .filter_map(Result::ok)
120 .any(|e| {
121 let n = e.file_name().to_string_lossy().into_owned();
122 n.starts_with(&stem) && n.contains(".tmp")
123 });
124 assert!(!stray, "stray temp file left behind");
125 let _ = std::fs::remove_file(&path);
126 }
127
128 #[test]
133 fn concurrent_saves_all_succeed() {
134 let path = temp_path("concurrent");
135 let mut handles = Vec::new();
136 for i in 0..8u8 {
137 let store = FileStore::new(&path);
138 handles.push(std::thread::spawn(move || {
139 for _ in 0..25 {
140 store.save(&[i; 64]).expect("concurrent save must not race");
141 }
142 }));
143 }
144 for h in handles {
145 h.join().expect("saver thread");
146 }
147 let survivor = FileStore::new(&path).load().expect("load").unwrap();
148 assert_eq!(survivor.len(), 64, "no torn write");
149 assert!(survivor.iter().all(|b| *b == survivor[0]), "intact value");
150 let _ = std::fs::remove_file(&path);
151 }
152
153 #[cfg(unix)]
154 #[test]
155 fn saved_file_is_0600() {
156 use std::os::unix::fs::PermissionsExt;
157 let path = temp_path("perms");
158 let store = FileStore::new(&path);
159 store.save(b"secret").expect("save");
160 let mode = std::fs::metadata(&path).expect("meta").permissions().mode();
161 assert_eq!(mode & 0o777, 0o600);
162 let _ = std::fs::remove_file(&path);
163 }
164}