Skip to main content

beam_core/
persist.rs

1//! Common persistence helpers: atomic JSON file writes with tmp + rename.
2//! Sensitive files can opt-in to 0600 permissions on Unix.
3
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use anyhow::{Context, Result};
8use serde::Serialize;
9use uuid::Uuid;
10
11/// Atomically write a serializable value to `path` using tmp + rename.
12/// Creates parent directories if needed.
13pub fn atomic_write_json(path: &Path, value: &impl Serialize) -> Result<()> {
14    atomic_write_json_with_perms(path, value, false)
15}
16
17/// Atomically write a serializable value to `path` using tmp + rename.
18/// If `restrict_perms` is true, sets file permissions to 0o600 on Unix.
19pub fn atomic_write_json_with_perms(
20    path: &Path,
21    value: &impl Serialize,
22    restrict_perms: bool,
23) -> Result<()> {
24    if let Some(parent) = path.parent() {
25        fs::create_dir_all(parent)?;
26    }
27    let tmp = path.with_extension(format!("{}.tmp", Uuid::new_v4()));
28    let payload = serde_json::to_vec_pretty(value)?;
29    fs::write(&tmp, &payload)?;
30    if restrict_perms {
31        #[cfg(unix)]
32        {
33            use std::os::unix::fs::PermissionsExt;
34            let perms = fs::Permissions::from_mode(0o600);
35            let _ = fs::set_permissions(&tmp, perms);
36        }
37    }
38    fs::rename(&tmp, path)
39        .with_context(|| format!("failed to atomically write {}", path.display()))?;
40    Ok(())
41}
42
43/// Read and deserialize JSON from `path`. Returns `Ok(None)` if the file does not exist.
44pub fn read_json<T: serde::de::DeserializeOwned>(path: &Path) -> Result<Option<T>> {
45    match fs::read_to_string(path) {
46        Ok(raw) => {
47            if raw.trim().is_empty() {
48                return Ok(None);
49            }
50            let value = serde_json::from_str(&raw)?;
51            Ok(Some(value))
52        }
53        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
54        Err(err) => Err(err.into()),
55    }
56}
57
58/// Remove a file if it exists. No error if not found.
59pub fn remove_file_if_exists(path: &Path) -> Result<()> {
60    match fs::remove_file(path) {
61        Ok(()) => Ok(()),
62        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
63        Err(err) => Err(err.into()),
64    }
65}
66
67/// Helper: build a tmp path for a given file path.
68pub fn tmp_path_for(path: &Path) -> PathBuf {
69    path.with_extension(format!("{}.tmp", Uuid::new_v4()))
70}
71
72#[cfg(test)]
73mod tests {
74    use super::*;
75    use serde::Deserialize;
76
77    #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
78    struct TestData {
79        key: String,
80        value: i32,
81    }
82
83    fn temp_path(label: &str) -> PathBuf {
84        let nanos = std::time::SystemTime::now()
85            .duration_since(std::time::UNIX_EPOCH)
86            .unwrap_or_default()
87            .as_nanos();
88        std::env::temp_dir().join(format!(
89            "beam-persist-test-{}-{}-{}",
90            label,
91            nanos,
92            std::process::id()
93        ))
94    }
95
96    #[test]
97    fn atomic_write_and_read_roundtrip() {
98        let path = temp_path("roundtrip");
99        let data = TestData {
100            key: "hello".to_string(),
101            value: 42,
102        };
103        atomic_write_json(&path, &data).unwrap();
104        let loaded: Option<TestData> = read_json(&path).unwrap();
105        assert_eq!(loaded, Some(data));
106        let _ = fs::remove_file(&path);
107    }
108
109    #[test]
110    fn read_json_returns_none_for_missing_file() {
111        let path = temp_path("missing");
112        let result: Option<TestData> = read_json(&path).unwrap();
113        assert!(result.is_none());
114    }
115
116    #[test]
117    fn atomic_write_with_restrict_perms() {
118        let path = temp_path("perms");
119        let data = TestData {
120            key: "secret".to_string(),
121            value: 99,
122        };
123        atomic_write_json_with_perms(&path, &data, true).unwrap();
124        let loaded: Option<TestData> = read_json(&path).unwrap();
125        assert_eq!(loaded, Some(data));
126        #[cfg(unix)]
127        {
128            use std::os::unix::fs::PermissionsExt;
129            let metadata = fs::metadata(&path).unwrap();
130            let mode = metadata.permissions().mode() & 0o777;
131            assert_eq!(mode, 0o600, "expected 0600 permissions, got {:o}", mode);
132        }
133        let _ = fs::remove_file(&path);
134    }
135
136    #[test]
137    fn remove_file_if_exists_ok() {
138        let path = temp_path("rm");
139        fs::write(&path, "test").unwrap();
140        remove_file_if_exists(&path).unwrap();
141        assert!(!path.exists());
142        // Removing again is fine
143        remove_file_if_exists(&path).unwrap();
144    }
145}