Skip to main content

matter_controller/store/
file.rs

1//! Default filesystem-backed [`ControllerStore`].
2
3use std::path::PathBuf;
4
5use super::{ControllerStore, StoreError};
6
7/// Stores the snapshot blob as a single file.
8///
9/// Writes are atomic (temp file + rename) and, on Unix, the file is
10/// created with `0600` permissions. The blob holds private keys in the
11/// clear: protect the containing directory, or supply a custom
12/// [`ControllerStore`] backed by an encrypted store.
13#[derive(Debug, Clone)]
14pub struct FileStore {
15    path: PathBuf,
16}
17
18impl FileStore {
19    /// Create a store backed by `path`. The file need not exist yet.
20    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        // Unique temp file per save: the controller runs best-effort persists
39        // (address hints, resumption records) on detached blocking threads
40        // that can overlap a durable save. With a SHARED temp path, writer B
41        // truncates writer A's temp mid-write and whoever renames second gets
42        // ENOENT — the intermittent "store-persist ENOENT" seen in the
43        // integration sweep. Unique names keep each rename atomic and
44        // last-writer-wins.
45        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            // Don't leave key material lying around in a stray temp file.
62            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)] // Test code: CLAUDE.md allows unwrap/expect with justification.
71mod tests {
72    use super::*;
73
74    fn temp_path(name: &str) -> PathBuf {
75        use std::sync::atomic::{AtomicU32, Ordering};
76        // Unique per (process, call): a fixed shared path races when two test
77        // processes (or a re-run overlapping a prior run) touch the same file.
78        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        // no temp file may linger (unique names: check the parent dir)
116        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    /// Regression for the shared-temp-path race: concurrent saves from
129    /// multiple threads (the controller's overlapping best-effort + durable
130    /// persist shape) must ALL succeed, and the surviving file must be one
131    /// of the written values, intact.
132    #[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}