use std::path::PathBuf;
use super::{ControllerStore, StoreError};
#[derive(Debug, Clone)]
pub struct FileStore {
path: PathBuf,
}
impl FileStore {
pub fn new(path: impl Into<PathBuf>) -> Self {
Self { path: path.into() }
}
}
impl ControllerStore for FileStore {
fn load(&self) -> Result<Option<Vec<u8>>, StoreError> {
match std::fs::read(&self.path) {
Ok(bytes) => Ok(Some(bytes)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(StoreError::Io(e)),
}
}
fn save(&self, snapshot: &[u8]) -> Result<(), StoreError> {
use std::io::Write;
use std::sync::atomic::{AtomicU64, Ordering};
static SAVE_SEQ: AtomicU64 = AtomicU64::new(0);
let seq = SAVE_SEQ.fetch_add(1, Ordering::Relaxed);
let tmp = self
.path
.with_extension(format!("tmp.{}.{seq}", std::process::id()));
{
let mut f = std::fs::File::create(&tmp)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
f.set_permissions(std::fs::Permissions::from_mode(0o600))?;
}
f.write_all(snapshot)?;
f.sync_all()?;
}
if let Err(e) = std::fs::rename(&tmp, &self.path) {
let _ = std::fs::remove_file(&tmp);
return Err(StoreError::Io(e));
}
Ok(())
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)] mod tests {
use super::*;
fn temp_path(name: &str) -> PathBuf {
use std::sync::atomic::{AtomicU32, Ordering};
static COUNTER: AtomicU32 = AtomicU32::new(0);
let uniq = COUNTER.fetch_add(1, Ordering::Relaxed);
let mut p = std::env::temp_dir();
p.push(format!(
"matter-controller-test-{name}-{}-{uniq}",
std::process::id()
));
let _ = std::fs::remove_file(&p);
let _ = std::fs::remove_file(p.with_extension("tmp"));
p
}
#[test]
fn load_missing_returns_none() {
let store = FileStore::new(temp_path("missing"));
assert!(store.load().expect("load ok").is_none());
}
#[test]
fn save_then_load_round_trips() {
let path = temp_path("roundtrip");
let store = FileStore::new(&path);
store.save(b"hello snapshot").expect("save ok");
assert_eq!(
store.load().expect("load ok"),
Some(b"hello snapshot".to_vec())
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn save_overwrites_atomically() {
let path = temp_path("overwrite");
let store = FileStore::new(&path);
store.save(b"first").expect("save 1");
store.save(b"second value longer").expect("save 2");
assert_eq!(store.load().expect("load").unwrap(), b"second value longer");
let stem = path.file_name().unwrap().to_string_lossy().into_owned();
let stray = std::fs::read_dir(path.parent().unwrap())
.unwrap()
.filter_map(Result::ok)
.any(|e| {
let n = e.file_name().to_string_lossy().into_owned();
n.starts_with(&stem) && n.contains(".tmp")
});
assert!(!stray, "stray temp file left behind");
let _ = std::fs::remove_file(&path);
}
#[test]
fn concurrent_saves_all_succeed() {
let path = temp_path("concurrent");
let mut handles = Vec::new();
for i in 0..8u8 {
let store = FileStore::new(&path);
handles.push(std::thread::spawn(move || {
for _ in 0..25 {
store.save(&[i; 64]).expect("concurrent save must not race");
}
}));
}
for h in handles {
h.join().expect("saver thread");
}
let survivor = FileStore::new(&path).load().expect("load").unwrap();
assert_eq!(survivor.len(), 64, "no torn write");
assert!(survivor.iter().all(|b| *b == survivor[0]), "intact value");
let _ = std::fs::remove_file(&path);
}
#[cfg(unix)]
#[test]
fn saved_file_is_0600() {
use std::os::unix::fs::PermissionsExt;
let path = temp_path("perms");
let store = FileStore::new(&path);
store.save(b"secret").expect("save");
let mode = std::fs::metadata(&path).expect("meta").permissions().mode();
assert_eq!(mode & 0o777, 0o600);
let _ = std::fs::remove_file(&path);
}
}