Skip to main content

harn_vm/
atomic_io.rs

1//! Atomic file write helpers.
2//!
3//! All persistent on-disk state in Harn (workflow mailboxes, run records,
4//! event logs, lockfiles, package manifests, ...) should use these helpers
5//! rather than `std::fs::write` so that concurrent readers and abrupt
6//! process termination cannot observe a half-written file.
7//!
8//! The pattern is:
9//!
10//! 1. Create the parent directory if needed.
11//! 2. Write to a sibling `.<name>.<uuid>.tmp` file.
12//! 3. Flush userspace buffers and, when requested, `fsync` the temp file.
13//! 4. Replace the destination atomically (`rename` on POSIX and
14//!    `MoveFileExW(REPLACE_EXISTING)` on Windows).
15//! 5. When requested, best-effort `fsync` the parent directory so the rename
16//!    survives a power loss on filesystems that decouple the dirent from the
17//!    inode.
18//!
19//! On any failure between (2) and (4), the temp file is removed so that
20//! repeated retries don't leak `.tmp` siblings.
21
22use std::fs::{File, OpenOptions};
23use std::io::{self, BufWriter, Write};
24use std::path::{Path, PathBuf};
25
26#[cfg(test)]
27thread_local! {
28    static TEST_FAILURE_STAGE: std::cell::Cell<Option<&'static str>> = const { std::cell::Cell::new(None) };
29}
30
31#[cfg(test)]
32fn fail_test_stage(stage: &'static str) -> io::Result<()> {
33    if TEST_FAILURE_STAGE.with(|value| value.get()) == Some(stage) {
34        return Err(io::Error::other(format!("injected {stage} failure")));
35    }
36    Ok(())
37}
38
39#[cfg(not(test))]
40#[inline]
41fn fail_test_stage(_stage: &'static str) -> io::Result<()> {
42    Ok(())
43}
44
45/// Durability requested for an atomic namespace replacement.
46#[derive(Clone, Copy, Debug, Eq, PartialEq)]
47pub enum AtomicWriteDurability {
48    /// Readers never observe a partial payload. No storage flush is promised.
49    Namespace,
50    /// Flush the payload before replacement and request persistence of the
51    /// namespace update. Filesystems and hardware may still have weaker
52    /// guarantees than the operating-system call reports.
53    Flush,
54}
55
56/// Storage-flush work completed by an atomic write.
57#[derive(Clone, Copy, Debug, Eq, PartialEq)]
58pub struct AtomicWriteReceipt {
59    /// The complete payload was flushed before replacement.
60    pub file_synced: bool,
61    /// Persistence of the namespace replacement was confirmed.
62    pub namespace_synced: bool,
63}
64
65/// Atomically write `bytes` to `path`.
66pub fn atomic_write(path: &Path, bytes: &[u8]) -> io::Result<()> {
67    atomic_write_with(path, |writer| writer.write_all(bytes))
68}
69
70/// Atomically write `bytes` to `path`, giving the file the Unix permission
71/// bits `mode` (e.g. `0o600`).
72///
73/// The mode is applied to the temp file *before* the rename, so the bytes are
74/// never observable at the process umask's default permissions — not even for
75/// the width of the write. That ordering is the whole point of this variant:
76/// writing first and `chmod`ing the destination afterwards leaves a window in
77/// which a secret is world-readable. On non-Unix targets `mode` is ignored.
78pub fn atomic_write_with_mode(path: &Path, bytes: &[u8], mode: u32) -> io::Result<()> {
79    atomic_write_stream_with_durability_and_mode(
80        path,
81        AtomicWriteDurability::Flush,
82        Some(mode),
83        |writer| writer.write_all(bytes),
84    )
85    .map(|_| ())
86}
87
88/// Atomically write `bytes` with an explicit durability request.
89pub fn atomic_write_with_durability(
90    path: &Path,
91    bytes: &[u8],
92    durability: AtomicWriteDurability,
93) -> io::Result<AtomicWriteReceipt> {
94    atomic_write_stream_with_durability_and_mode(path, durability, None, |writer| {
95        writer.write_all(bytes)
96    })
97}
98
99pub(crate) fn atomic_write_with_durability_unlocked(
100    path: &Path,
101    bytes: &[u8],
102    durability: AtomicWriteDurability,
103) -> io::Result<AtomicWriteReceipt> {
104    atomic_write_stream_with_durability_and_mode_unlocked(path, durability, None, |writer| {
105        writer.write_all(bytes)
106    })
107}
108
109/// Atomically write the destination at `path` by streaming through a
110/// `BufWriter`. The closure runs against a buffered writer over a sibling
111/// temp file. On success, the buffer is flushed, the file is `fsync`'d, and
112/// the temp file is renamed over `path`.
113///
114/// Use this for line-by-line or chunked writes (e.g. JSONL compaction).
115/// For a one-shot byte write, prefer [`atomic_write`].
116pub fn atomic_write_with<F>(path: &Path, write_fn: F) -> io::Result<()>
117where
118    F: FnOnce(&mut BufWriter<File>) -> io::Result<()>,
119{
120    atomic_write_stream_with_durability_and_mode(path, AtomicWriteDurability::Flush, None, write_fn)
121        .map(|_| ())
122}
123
124fn atomic_write_stream_with_durability_and_mode<F>(
125    path: &Path,
126    durability: AtomicWriteDurability,
127    mode: Option<u32>,
128    write_fn: F,
129) -> io::Result<AtomicWriteReceipt>
130where
131    F: FnOnce(&mut BufWriter<File>) -> io::Result<()>,
132{
133    // Windows refuses a replace while another writer is replacing the same
134    // destination. Use the same canonical, cross-process lock as conditional
135    // replacement instead of retrying on a timing-dependent access error.
136    #[cfg(windows)]
137    let _lock = crate::conditional_replace::acquire_lock(path)?;
138
139    atomic_write_stream_with_durability_and_mode_unlocked(path, durability, mode, write_fn)
140}
141
142fn atomic_write_stream_with_durability_and_mode_unlocked<F>(
143    path: &Path,
144    durability: AtomicWriteDurability,
145    mode: Option<u32>,
146    write_fn: F,
147) -> io::Result<AtomicWriteReceipt>
148where
149    F: FnOnce(&mut BufWriter<File>) -> io::Result<()>,
150{
151    let mut tmp = TempFile::create(path, mode)?;
152    let result = write_and_finalize(&mut tmp, durability, write_fn);
153    if let Err(err) = result {
154        let _ = std::fs::remove_file(&tmp.path);
155        return Err(err);
156    }
157    if let Err(err) = fail_test_stage("replace") {
158        let _ = std::fs::remove_file(&tmp.path);
159        return Err(err);
160    }
161    let replace_synced = match replace_temp_file(&tmp.path, path, durability) {
162        Ok(synced) => synced,
163        Err(err) => {
164            let _ = std::fs::remove_file(&tmp.path);
165            return Err(err);
166        }
167    };
168    let namespace_synced = match durability {
169        AtomicWriteDurability::Namespace => false,
170        AtomicWriteDurability::Flush => replace_synced || sync_parent_dir(path),
171    };
172    Ok(AtomicWriteReceipt {
173        file_synced: durability == AtomicWriteDurability::Flush,
174        namespace_synced,
175    })
176}
177
178fn write_and_finalize<F>(
179    tmp: &mut TempFile,
180    durability: AtomicWriteDurability,
181    write_fn: F,
182) -> io::Result<()>
183where
184    F: FnOnce(&mut BufWriter<File>) -> io::Result<()>,
185{
186    let file = tmp
187        .file
188        .take()
189        .ok_or_else(|| io::Error::other("atomic_io: temporary file handle was already consumed"))?;
190    let mut buf = BufWriter::new(file);
191    write_fn(&mut buf)?;
192    fail_test_stage("flush")?;
193    buf.flush()?;
194    let inner = buf.into_inner().map_err(|err| err.into_error())?;
195    if durability == AtomicWriteDurability::Flush {
196        inner.sync_all()?;
197    }
198    Ok(())
199}
200
201#[cfg(not(windows))]
202fn replace_temp_file(
203    temp: &Path,
204    destination: &Path,
205    _durability: AtomicWriteDurability,
206) -> io::Result<bool> {
207    std::fs::rename(temp, destination)?;
208    Ok(false)
209}
210
211#[cfg(windows)]
212fn replace_temp_file(
213    temp: &Path,
214    destination: &Path,
215    durability: AtomicWriteDurability,
216) -> io::Result<bool> {
217    use std::os::windows::ffi::OsStrExt;
218    use windows_sys::Win32::Storage::FileSystem::{
219        MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH,
220    };
221
222    let mut temp_wide: Vec<u16> = temp.as_os_str().encode_wide().collect();
223    temp_wide.push(0);
224    let mut destination_wide: Vec<u16> = destination.as_os_str().encode_wide().collect();
225    destination_wide.push(0);
226    let mut flags = MOVEFILE_REPLACE_EXISTING;
227    if durability == AtomicWriteDurability::Flush {
228        flags |= MOVEFILE_WRITE_THROUGH;
229    }
230    // SAFETY: both paths are NUL-terminated UTF-16 buffers that remain alive
231    // for the duration of the call.
232    if unsafe { MoveFileExW(temp_wide.as_ptr(), destination_wide.as_ptr(), flags) } == 0 {
233        return Err(io::Error::last_os_error());
234    }
235    Ok(durability == AtomicWriteDurability::Flush)
236}
237
238fn sync_parent_dir(path: &Path) -> bool {
239    if let Some(parent) = path.parent() {
240        if parent.as_os_str().is_empty() {
241            return false;
242        }
243        if let Ok(dir) = OpenOptions::new().read(true).open(parent) {
244            return dir.sync_all().is_ok();
245        }
246    }
247    false
248}
249
250/// Set `path`'s permission bits. Uses `set_permissions` rather than
251/// `OpenOptions::mode` so the umask cannot widen or narrow the request.
252#[cfg(unix)]
253fn apply_mode(path: &Path, mode: u32) -> io::Result<()> {
254    use std::os::unix::fs::PermissionsExt;
255    std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))
256}
257
258#[cfg(not(unix))]
259fn apply_mode(_path: &Path, _mode: u32) -> io::Result<()> {
260    Ok(())
261}
262
263/// Owns the temp file path + handle so callers can rely on RAII for
264/// cleanup if they bail out mid-write.
265struct TempFile {
266    path: PathBuf,
267    file: Option<File>,
268}
269
270impl TempFile {
271    fn create(target: &Path, mode: Option<u32>) -> io::Result<Self> {
272        let parent = target.parent().ok_or_else(|| {
273            io::Error::new(
274                io::ErrorKind::InvalidInput,
275                format!(
276                    "atomic_io: destination '{}' has no parent directory",
277                    target.display()
278                ),
279            )
280        })?;
281        if !parent.as_os_str().is_empty() {
282            std::fs::create_dir_all(parent)?;
283        }
284        let file_name = target
285            .file_name()
286            .and_then(|value| value.to_str())
287            .unwrap_or("file");
288        let tmp_path = if parent.as_os_str().is_empty() {
289            PathBuf::from(format!(".{file_name}.{}.tmp", uuid::Uuid::now_v7()))
290        } else {
291            parent.join(format!(".{file_name}.{}.tmp", uuid::Uuid::now_v7()))
292        };
293        let file = OpenOptions::new()
294            .create_new(true)
295            .write(true)
296            .open(&tmp_path)?;
297        if let Some(mode) = mode {
298            if let Err(error) = apply_mode(&tmp_path, mode) {
299                drop(file);
300                let _ = std::fs::remove_file(&tmp_path);
301                return Err(error);
302            }
303        } else if let Ok(metadata) = std::fs::metadata(target) {
304            if let Err(error) = file.set_permissions(metadata.permissions()) {
305                drop(file);
306                let _ = std::fs::remove_file(&tmp_path);
307                return Err(error);
308            }
309        }
310        Ok(Self {
311            path: tmp_path,
312            file: Some(file),
313        })
314    }
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320
321    #[test]
322    fn writes_bytes_atomically() {
323        let dir = tempfile::tempdir().unwrap();
324        let path = dir.path().join("state.json");
325        atomic_write(&path, b"hello").unwrap();
326        assert_eq!(std::fs::read(&path).unwrap(), b"hello");
327    }
328
329    #[test]
330    fn overwrites_existing_file() {
331        let dir = tempfile::tempdir().unwrap();
332        let path = dir.path().join("state.json");
333        std::fs::write(&path, b"old").unwrap();
334        atomic_write(&path, b"new").unwrap();
335        assert_eq!(std::fs::read(&path).unwrap(), b"new");
336    }
337
338    #[test]
339    fn creates_missing_parent_dirs() {
340        let dir = tempfile::tempdir().unwrap();
341        let path = dir.path().join("a/b/c/state.json");
342        atomic_write(&path, b"deep").unwrap();
343        assert_eq!(std::fs::read(&path).unwrap(), b"deep");
344    }
345
346    #[test]
347    fn streaming_writer_finalizes_atomically() {
348        let dir = tempfile::tempdir().unwrap();
349        let path = dir.path().join("log.jsonl");
350        atomic_write_with(&path, |writer| {
351            writeln!(writer, "first")?;
352            writeln!(writer, "second")?;
353            Ok(())
354        })
355        .unwrap();
356        let read = std::fs::read_to_string(&path).unwrap();
357        assert_eq!(read, "first\nsecond\n");
358    }
359
360    #[test]
361    fn streaming_writer_cleans_up_on_error() {
362        let dir = tempfile::tempdir().unwrap();
363        let path = dir.path().join("state.json");
364        std::fs::write(&path, b"old").unwrap();
365        let err = atomic_write_with(&path, |writer| {
366            writer.write_all(b"partial")?;
367            Err(io::Error::other("nope"))
368        })
369        .unwrap_err();
370        assert_eq!(err.to_string(), "nope");
371        assert_eq!(std::fs::read(&path).unwrap(), b"old");
372        // No leftover .tmp siblings.
373        let leftover: Vec<_> = std::fs::read_dir(dir.path())
374            .unwrap()
375            .filter_map(Result::ok)
376            .filter(|entry| entry.file_name().to_string_lossy().ends_with(".tmp"))
377            .collect();
378        assert!(
379            leftover.is_empty(),
380            "tmp file should be cleaned up on error"
381        );
382    }
383
384    #[test]
385    fn flush_and_replace_failures_preserve_destination_and_clean_up() {
386        for stage in ["flush", "replace"] {
387            let dir = tempfile::tempdir().unwrap();
388            let path = dir.path().join("state.json");
389            std::fs::write(&path, b"old").unwrap();
390            TEST_FAILURE_STAGE.with(|value| value.set(Some(stage)));
391            let error = atomic_write(&path, b"new").unwrap_err();
392            TEST_FAILURE_STAGE.with(|value| value.set(None));
393
394            assert_eq!(error.to_string(), format!("injected {stage} failure"));
395            assert_eq!(std::fs::read(&path).unwrap(), b"old");
396            let leftovers: Vec<_> = std::fs::read_dir(dir.path())
397                .unwrap()
398                .filter_map(Result::ok)
399                .filter(|entry| entry.file_name().to_string_lossy().ends_with(".tmp"))
400                .collect();
401            assert!(leftovers.is_empty(), "{stage} left a temp file");
402        }
403    }
404
405    #[cfg(unix)]
406    #[test]
407    fn replacement_preserves_existing_permissions() {
408        use std::os::unix::fs::PermissionsExt;
409
410        let dir = tempfile::tempdir().unwrap();
411        let path = dir.path().join("state.json");
412        std::fs::write(&path, b"old").unwrap();
413        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o640)).unwrap();
414        atomic_write(&path, b"new").unwrap();
415        assert_eq!(
416            std::fs::metadata(path).unwrap().permissions().mode() & 0o777,
417            0o640
418        );
419    }
420
421    #[cfg(unix)]
422    #[test]
423    fn mode_is_applied_before_the_rename() {
424        use std::os::unix::fs::PermissionsExt;
425        let dir = tempfile::tempdir().unwrap();
426        let path = dir.path().join("credentials.json");
427        atomic_write_with_mode(&path, b"secret", 0o600).unwrap();
428        let mode = std::fs::metadata(&path).unwrap().permissions().mode();
429        assert_eq!(mode & 0o777, 0o600, "credentials must be owner-only");
430    }
431
432    #[cfg(unix)]
433    #[test]
434    fn mode_survives_overwriting_a_loose_destination() {
435        use std::os::unix::fs::PermissionsExt;
436        let dir = tempfile::tempdir().unwrap();
437        let path = dir.path().join("credentials.json");
438        std::fs::write(&path, b"old").unwrap();
439        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
440        atomic_write_with_mode(&path, b"secret", 0o600).unwrap();
441        let mode = std::fs::metadata(&path).unwrap().permissions().mode();
442        assert_eq!(mode & 0o777, 0o600);
443    }
444
445    #[test]
446    fn concurrent_writers_do_not_collide() {
447        let dir = tempfile::tempdir().unwrap();
448        let path = std::sync::Arc::new(dir.path().join("state.json"));
449        let mut handles = Vec::new();
450        for i in 0..16 {
451            let path = std::sync::Arc::clone(&path);
452            handles.push(std::thread::spawn(move || {
453                let payload = format!("writer-{i}");
454                atomic_write(&path, payload.as_bytes()).unwrap();
455            }));
456        }
457        for handle in handles {
458            handle.join().unwrap();
459        }
460        // The final contents must match exactly one of the writers — never a
461        // truncated or interleaved value.
462        let final_contents = std::fs::read_to_string(&*path).unwrap();
463        assert!(
464            final_contents.starts_with("writer-") && final_contents.len() <= "writer-15".len(),
465            "unexpected final contents: {final_contents:?}"
466        );
467    }
468}