Skip to main content

mermaid_runtime/
atomic.rs

1//! Atomic file writes.
2//!
3//! Plain `fs::write` truncates the target to zero length and then writes the
4//! new contents in place. A crash / kill / disk-full between those two steps
5//! leaves the file empty or half-written — catastrophic for session,
6//! checkpoint, and plugin-lockfile state that is rewritten in full on every
7//! save. [`write_atomic`] writes to a temp sibling, fsyncs it, then renames over
8//! the target, so a reader always sees either the old complete file or the new
9//! complete file.
10
11use std::fs::{self, File};
12use std::io::Write;
13use std::path::Path;
14use std::sync::atomic::{AtomicU64, Ordering};
15use std::time::{Duration, SystemTime};
16
17static COUNTER: AtomicU64 = AtomicU64::new(0);
18
19/// A sibling temp file untouched for at least this long is treated as orphaned by
20/// a crashed writer and swept on the next write to the same target. The window is
21/// deliberately generous: `write_atomic` rewrites small session/checkpoint/
22/// lockfile state in a single pass, so a legitimate in-flight temp (ours or
23/// another live writer's) is always far younger than this and is never collected.
24const STALE_TEMP_SECS: u64 = 3600;
25
26/// Write `bytes` to `path` atomically: temp file in the same directory →
27/// `sync_all` → rename over the destination. The rename is atomic on the same
28/// filesystem (and replaces an existing target on both Unix and Windows).
29///
30/// # Errors
31///
32/// Creating the parent directory, creating or writing the temp sibling,
33/// `sync_all`, and the rename. On a rename failure the temp file is removed
34/// and the destination is left exactly as it was — a failed call never leaves
35/// a truncated target, which is the whole point of the helper.
36pub fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
37    write_atomic_inner(path, bytes, None)
38}
39
40/// Like [`write_atomic`], but create the temp file with the given Unix
41/// permission `mode` (e.g. `0o600`) so the renamed destination is never even
42/// briefly world-readable. `mode` is ignored on non-Unix, where directory ACLs
43/// scope the file. Use this for secret-bearing files such as the config.
44///
45/// # Errors
46///
47/// Exactly [`write_atomic`]'s, plus the `mode` being rejected when the temp
48/// file is created on Unix.
49pub fn write_atomic_with_mode(path: &Path, bytes: &[u8], mode: u32) -> std::io::Result<()> {
50    write_atomic_inner(path, bytes, Some(mode))
51}
52
53fn write_atomic_inner(path: &Path, bytes: &[u8], mode: Option<u32>) -> std::io::Result<()> {
54    let parent = path.parent().unwrap_or_else(|| Path::new("."));
55    fs::create_dir_all(parent)?;
56
57    let stem = path.file_name().and_then(|n| n.to_str()).unwrap_or("tmp");
58
59    // Best-effort: clear temp siblings stranded by a previous crashed write to
60    // this same target. A crash between the temp create and `rename` below leaves
61    // `.<stem>.<pid>.<n>.tmp` behind forever (cleanup otherwise runs only on
62    // rename-error or success), so without this repeated crashes would litter the
63    // directory. The sweep only removes clearly abandoned (stale) temps and never
64    // the destination or a fresh/in-flight temp.
65    sweep_stale_temps(parent, stem, Duration::from_secs(STALE_TEMP_SECS));
66
67    let n = COUNTER.fetch_add(1, Ordering::Relaxed);
68    let tmp = parent.join(format!(".{}.{}.{}.tmp", stem, std::process::id(), n));
69
70    {
71        let mut f = create_temp(&tmp, mode)?;
72        f.write_all(bytes)?;
73        f.sync_all()?;
74    }
75
76    if let Err(e) = fs::rename(&tmp, path) {
77        let _ = fs::remove_file(&tmp);
78        return Err(e);
79    }
80
81    // Best-effort durability of the rename itself. Opening a directory as a
82    // File is not supported on Windows, so this is a silent no-op there.
83    if let Ok(dir) = File::open(parent) {
84        let _ = dir.sync_all();
85    }
86    Ok(())
87}
88
89/// Create the temp file, honoring an explicit Unix `mode` when given so a secret
90/// file is written 0600 from the start rather than at the process umask.
91#[cfg(unix)]
92fn create_temp(tmp: &Path, mode: Option<u32>) -> std::io::Result<File> {
93    match mode {
94        Some(mode) => {
95            use std::os::unix::fs::OpenOptionsExt;
96            std::fs::OpenOptions::new()
97                .write(true)
98                .create(true)
99                .truncate(true)
100                .mode(mode)
101                .open(tmp)
102        },
103        None => File::create(tmp),
104    }
105}
106
107#[cfg(not(unix))]
108fn create_temp(tmp: &Path, _mode: Option<u32>) -> std::io::Result<File> {
109    File::create(tmp)
110}
111
112/// Best-effort sweep of orphaned temp siblings for the target named `stem` in
113/// `parent`, left behind when a writer crashed between creating the temp and
114/// renaming it over the destination. Only files matching THIS target's temp
115/// pattern (`.{stem}.{pid}.{n}.tmp`) and older than `max_age` are removed.
116///
117/// Safety: the destination is named exactly `stem`, which can never start with
118/// the dotted `.{stem}.` prefix, so it is structurally unmatched; and a live,
119/// in-flight temp (ours or another concurrent writer's) is younger than
120/// `max_age` and so is never collected. Every error is swallowed — a sweep
121/// failure must not fail the write.
122fn sweep_stale_temps(parent: &Path, stem: &str, max_age: Duration) {
123    let prefix = format!(".{stem}.");
124    let Ok(entries) = fs::read_dir(parent) else {
125        return;
126    };
127    let now = SystemTime::now();
128    for entry in entries.flatten() {
129        let name = entry.file_name();
130        let Some(name) = name.to_str() else {
131            continue;
132        };
133        if !name.starts_with(&prefix) || !name.ends_with(".tmp") {
134            continue;
135        }
136        let stale = entry
137            .metadata()
138            .and_then(|m| m.modified())
139            .ok()
140            .and_then(|mtime| now.duration_since(mtime).ok())
141            .map(|age| age >= max_age)
142            .unwrap_or(false);
143        if stale {
144            let _ = fs::remove_file(entry.path());
145        }
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    #[test]
154    fn atomic_write_replaces_existing_and_no_temp_left() {
155        let dir = std::env::temp_dir().join(format!("mermaid_atomic_{}", std::process::id()));
156        let _ = fs::create_dir_all(&dir);
157        let target = dir.join("conv.json");
158        write_atomic(&target, b"first").unwrap();
159        assert_eq!(fs::read_to_string(&target).unwrap(), "first");
160        write_atomic(&target, b"second").unwrap();
161        assert_eq!(fs::read_to_string(&target).unwrap(), "second");
162        // No leftover temp files.
163        let leftovers = fs::read_dir(&dir)
164            .unwrap()
165            .flatten()
166            .filter(|e| e.file_name().to_string_lossy().ends_with(".tmp"))
167            .count();
168        assert_eq!(leftovers, 0);
169        let _ = fs::remove_dir_all(&dir);
170    }
171
172    #[test]
173    fn sweep_removes_only_matching_stale_temps() {
174        let dir = std::env::temp_dir().join(format!("mermaid_atomic_sweep_{}", std::process::id()));
175        let _ = fs::remove_dir_all(&dir);
176        let _ = fs::create_dir_all(&dir);
177
178        let target = dir.join("conv.json");
179        write_atomic(&target, b"live").unwrap();
180
181        // An orphaned temp for THIS target (a crashed prior write).
182        let orphan = dir.join(".conv.json.99999.0.tmp");
183        fs::write(&orphan, b"half-written").unwrap();
184        // A temp for a DIFFERENT target — must be left alone.
185        let other = dir.join(".other.json.99999.0.tmp");
186        fs::write(&other, b"someone else").unwrap();
187        // An unrelated regular file — must be left alone.
188        let unrelated = dir.join("notes.txt");
189        fs::write(&unrelated, b"keep me").unwrap();
190
191        // max_age = ZERO ⇒ any matching temp qualifies as stale.
192        sweep_stale_temps(&dir, "conv.json", Duration::ZERO);
193
194        assert!(!orphan.exists(), "matching stale temp must be swept");
195        assert!(other.exists(), "a different target's temp must survive");
196        assert!(unrelated.exists(), "unrelated files must survive");
197        assert!(target.exists(), "the destination must never be swept");
198        assert_eq!(fs::read_to_string(&target).unwrap(), "live");
199
200        let _ = fs::remove_dir_all(&dir);
201    }
202
203    #[test]
204    fn sweep_preserves_fresh_in_flight_temps() {
205        let dir = std::env::temp_dir().join(format!("mermaid_atomic_fresh_{}", std::process::id()));
206        let _ = fs::remove_dir_all(&dir);
207        let _ = fs::create_dir_all(&dir);
208
209        // A freshly created temp stands in for a concurrent, in-flight write.
210        let fresh = dir.join(".conv.json.12345.7.tmp");
211        fs::write(&fresh, b"being written").unwrap();
212
213        // A long window must never collect a just-created temp.
214        sweep_stale_temps(&dir, "conv.json", Duration::from_secs(STALE_TEMP_SECS));
215
216        assert!(fresh.exists(), "a fresh/in-flight temp must not be swept");
217        let _ = fs::remove_dir_all(&dir);
218    }
219
220    #[test]
221    #[cfg(unix)]
222    fn write_atomic_with_mode_creates_0600_and_leaves_no_temp() {
223        use std::os::unix::fs::PermissionsExt;
224        let dir = std::env::temp_dir().join(format!("mermaid_atomic_mode_{}", std::process::id()));
225        let _ = fs::remove_dir_all(&dir);
226        let _ = fs::create_dir_all(&dir);
227        let target = dir.join("config.toml");
228
229        write_atomic_with_mode(&target, b"secret = true", 0o600).unwrap();
230        assert_eq!(fs::read_to_string(&target).unwrap(), "secret = true");
231        let mode = fs::metadata(&target).unwrap().permissions().mode() & 0o777;
232        assert_eq!(mode, 0o600, "config must be created 0600, not at umask");
233
234        // Overwriting a world-readable pre-existing file re-creates it 0600 (the
235        // renamed 0600 temp replaces the old file).
236        fs::set_permissions(&target, fs::Permissions::from_mode(0o644)).unwrap();
237        write_atomic_with_mode(&target, b"secret = false", 0o600).unwrap();
238        let mode = fs::metadata(&target).unwrap().permissions().mode() & 0o777;
239        assert_eq!(
240            mode, 0o600,
241            "an overwrite must not leave the file world-readable"
242        );
243
244        let leftovers = fs::read_dir(&dir)
245            .unwrap()
246            .flatten()
247            .filter(|e| e.file_name().to_string_lossy().ends_with(".tmp"))
248            .count();
249        assert_eq!(leftovers, 0, "no temp left behind");
250
251        let _ = fs::remove_dir_all(&dir);
252    }
253}