mermaid_runtime/
atomic.rs1use 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
19const STALE_TEMP_SECS: u64 = 3600;
25
26pub fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
30 write_atomic_inner(path, bytes, None)
31}
32
33pub fn write_atomic_with_mode(path: &Path, bytes: &[u8], mode: u32) -> std::io::Result<()> {
38 write_atomic_inner(path, bytes, Some(mode))
39}
40
41fn write_atomic_inner(path: &Path, bytes: &[u8], mode: Option<u32>) -> std::io::Result<()> {
42 let parent = path.parent().unwrap_or_else(|| Path::new("."));
43 fs::create_dir_all(parent)?;
44
45 let stem = path.file_name().and_then(|n| n.to_str()).unwrap_or("tmp");
46
47 sweep_stale_temps(parent, stem, Duration::from_secs(STALE_TEMP_SECS));
54
55 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
56 let tmp = parent.join(format!(".{}.{}.{}.tmp", stem, std::process::id(), n));
57
58 {
59 let mut f = create_temp(&tmp, mode)?;
60 f.write_all(bytes)?;
61 f.sync_all()?;
62 }
63
64 if let Err(e) = fs::rename(&tmp, path) {
65 let _ = fs::remove_file(&tmp);
66 return Err(e);
67 }
68
69 if let Ok(dir) = File::open(parent) {
72 let _ = dir.sync_all();
73 }
74 Ok(())
75}
76
77#[cfg(unix)]
80fn create_temp(tmp: &Path, mode: Option<u32>) -> std::io::Result<File> {
81 match mode {
82 Some(mode) => {
83 use std::os::unix::fs::OpenOptionsExt;
84 std::fs::OpenOptions::new()
85 .write(true)
86 .create(true)
87 .truncate(true)
88 .mode(mode)
89 .open(tmp)
90 },
91 None => File::create(tmp),
92 }
93}
94
95#[cfg(not(unix))]
96fn create_temp(tmp: &Path, _mode: Option<u32>) -> std::io::Result<File> {
97 File::create(tmp)
98}
99
100fn sweep_stale_temps(parent: &Path, stem: &str, max_age: Duration) {
111 let prefix = format!(".{stem}.");
112 let Ok(entries) = fs::read_dir(parent) else {
113 return;
114 };
115 let now = SystemTime::now();
116 for entry in entries.flatten() {
117 let name = entry.file_name();
118 let Some(name) = name.to_str() else {
119 continue;
120 };
121 if !name.starts_with(&prefix) || !name.ends_with(".tmp") {
122 continue;
123 }
124 let stale = entry
125 .metadata()
126 .and_then(|m| m.modified())
127 .ok()
128 .and_then(|mtime| now.duration_since(mtime).ok())
129 .map(|age| age >= max_age)
130 .unwrap_or(false);
131 if stale {
132 let _ = fs::remove_file(entry.path());
133 }
134 }
135}
136
137#[cfg(test)]
138mod tests {
139 use super::*;
140
141 #[test]
142 fn atomic_write_replaces_existing_and_no_temp_left() {
143 let dir = std::env::temp_dir().join(format!("mermaid_atomic_{}", std::process::id()));
144 let _ = fs::create_dir_all(&dir);
145 let target = dir.join("conv.json");
146 write_atomic(&target, b"first").unwrap();
147 assert_eq!(fs::read_to_string(&target).unwrap(), "first");
148 write_atomic(&target, b"second").unwrap();
149 assert_eq!(fs::read_to_string(&target).unwrap(), "second");
150 let leftovers = fs::read_dir(&dir)
152 .unwrap()
153 .flatten()
154 .filter(|e| e.file_name().to_string_lossy().ends_with(".tmp"))
155 .count();
156 assert_eq!(leftovers, 0);
157 let _ = fs::remove_dir_all(&dir);
158 }
159
160 #[test]
161 fn sweep_removes_only_matching_stale_temps() {
162 let dir = std::env::temp_dir().join(format!("mermaid_atomic_sweep_{}", std::process::id()));
163 let _ = fs::remove_dir_all(&dir);
164 let _ = fs::create_dir_all(&dir);
165
166 let target = dir.join("conv.json");
167 write_atomic(&target, b"live").unwrap();
168
169 let orphan = dir.join(".conv.json.99999.0.tmp");
171 fs::write(&orphan, b"half-written").unwrap();
172 let other = dir.join(".other.json.99999.0.tmp");
174 fs::write(&other, b"someone else").unwrap();
175 let unrelated = dir.join("notes.txt");
177 fs::write(&unrelated, b"keep me").unwrap();
178
179 sweep_stale_temps(&dir, "conv.json", Duration::ZERO);
181
182 assert!(!orphan.exists(), "matching stale temp must be swept");
183 assert!(other.exists(), "a different target's temp must survive");
184 assert!(unrelated.exists(), "unrelated files must survive");
185 assert!(target.exists(), "the destination must never be swept");
186 assert_eq!(fs::read_to_string(&target).unwrap(), "live");
187
188 let _ = fs::remove_dir_all(&dir);
189 }
190
191 #[test]
192 fn sweep_preserves_fresh_in_flight_temps() {
193 let dir = std::env::temp_dir().join(format!("mermaid_atomic_fresh_{}", std::process::id()));
194 let _ = fs::remove_dir_all(&dir);
195 let _ = fs::create_dir_all(&dir);
196
197 let fresh = dir.join(".conv.json.12345.7.tmp");
199 fs::write(&fresh, b"being written").unwrap();
200
201 sweep_stale_temps(&dir, "conv.json", Duration::from_secs(STALE_TEMP_SECS));
203
204 assert!(fresh.exists(), "a fresh/in-flight temp must not be swept");
205 let _ = fs::remove_dir_all(&dir);
206 }
207
208 #[test]
209 #[cfg(unix)]
210 fn write_atomic_with_mode_creates_0600_and_leaves_no_temp() {
211 use std::os::unix::fs::PermissionsExt;
212 let dir = std::env::temp_dir().join(format!("mermaid_atomic_mode_{}", std::process::id()));
213 let _ = fs::remove_dir_all(&dir);
214 let _ = fs::create_dir_all(&dir);
215 let target = dir.join("config.toml");
216
217 write_atomic_with_mode(&target, b"secret = true", 0o600).unwrap();
218 assert_eq!(fs::read_to_string(&target).unwrap(), "secret = true");
219 let mode = fs::metadata(&target).unwrap().permissions().mode() & 0o777;
220 assert_eq!(mode, 0o600, "config must be created 0600, not at umask");
221
222 fs::set_permissions(&target, fs::Permissions::from_mode(0o644)).unwrap();
225 write_atomic_with_mode(&target, b"secret = false", 0o600).unwrap();
226 let mode = fs::metadata(&target).unwrap().permissions().mode() & 0o777;
227 assert_eq!(
228 mode, 0o600,
229 "an overwrite must not leave the file world-readable"
230 );
231
232 let leftovers = fs::read_dir(&dir)
233 .unwrap()
234 .flatten()
235 .filter(|e| e.file_name().to_string_lossy().ends_with(".tmp"))
236 .count();
237 assert_eq!(leftovers, 0, "no temp left behind");
238
239 let _ = fs::remove_dir_all(&dir);
240 }
241}