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 windows_sys::Win32::Storage::FileSystem::{
218        MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH,
219    };
220
221    let temp_wide = crate::windows_path::wide_maybe_verbatim(temp);
222    let destination_wide = crate::windows_path::wide_maybe_verbatim(destination);
223    let mut flags = MOVEFILE_REPLACE_EXISTING;
224    if durability == AtomicWriteDurability::Flush {
225        flags |= MOVEFILE_WRITE_THROUGH;
226    }
227
228    // Unlike POSIX `rename`, which atomically replaces a destination even while
229    // another process holds it open, `MoveFileExW` can transiently fail when a
230    // virus scanner, the Windows indexer, or a lagging handle close briefly
231    // holds the destination (ERROR_SHARING_VIOLATION) or its ACL check races
232    // (ERROR_ACCESS_DENIED). Those windows are short-lived, so retry with a
233    // small bounded backoff. This restores the rename tolerance the
234    // pre-consolidation snapshot writer had, WITHOUT reintroducing its
235    // destructive `remove_file(destination)` fallback (dropped deliberately so
236    // a crash mid-replace can never leave the destination missing).
237    const ERROR_ACCESS_DENIED: i32 = 5;
238    const ERROR_SHARING_VIOLATION: i32 = 32;
239    const MAX_ATTEMPTS: u32 = 10;
240    let mut backoff = std::time::Duration::from_millis(1);
241    for attempt in 1..=MAX_ATTEMPTS {
242        // SAFETY: both paths are NUL-terminated UTF-16 buffers that remain
243        // alive for the duration of the call.
244        if unsafe { MoveFileExW(temp_wide.as_ptr(), destination_wide.as_ptr(), flags) } != 0 {
245            return Ok(durability == AtomicWriteDurability::Flush);
246        }
247        let error = io::Error::last_os_error();
248        let retryable = matches!(
249            error.raw_os_error(),
250            Some(ERROR_SHARING_VIOLATION | ERROR_ACCESS_DENIED)
251        );
252        if !retryable || attempt == MAX_ATTEMPTS {
253            return Err(error);
254        }
255        std::thread::sleep(backoff);
256        backoff = (backoff * 2).min(std::time::Duration::from_millis(50));
257    }
258    unreachable!("the loop returns on the final attempt")
259}
260
261fn sync_parent_dir(path: &Path) -> bool {
262    if let Some(parent) = path.parent() {
263        if parent.as_os_str().is_empty() {
264            return false;
265        }
266        if let Ok(dir) = OpenOptions::new().read(true).open(parent) {
267            return dir.sync_all().is_ok();
268        }
269    }
270    false
271}
272
273/// Set `path`'s permission bits. Uses `set_permissions` rather than
274/// `OpenOptions::mode` so the umask cannot widen or narrow the request.
275#[cfg(unix)]
276fn apply_mode(path: &Path, mode: u32) -> io::Result<()> {
277    use std::os::unix::fs::PermissionsExt;
278    std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))
279}
280
281#[cfg(not(unix))]
282fn apply_mode(_path: &Path, _mode: u32) -> io::Result<()> {
283    Ok(())
284}
285
286/// Owns the temp file path + handle so callers can rely on RAII for
287/// cleanup if they bail out mid-write.
288struct TempFile {
289    path: PathBuf,
290    file: Option<File>,
291}
292
293/// Longest prefix of the target file name kept in the temp sibling's name.
294const TEMP_STEM_MAX: usize = 16;
295
296/// Build the name of the temp file written next to `file_name` before the
297/// atomic replace.
298///
299/// The temp sibling must not be meaningfully longer than the target it
300/// replaces: embedding the full target name (which can be a 64-char content
301/// hash) plus a hyphenated UUID made the temp path ~40 chars longer than the
302/// target, so a target that fits under Windows' legacy 260-char `MAX_PATH`
303/// could still produce a temp path that overflows it, failing `CreateFile`
304/// with `ERROR_PATH_NOT_FOUND` (os error 3). A short recognizable prefix plus a
305/// compact (unhyphenated) UUID keeps the temp co-located and unique while
306/// bounding its length to a small constant regardless of the target name.
307fn temp_sibling_name(file_name: &str) -> String {
308    let stem: String = file_name.chars().take(TEMP_STEM_MAX).collect();
309    format!(".{stem}.{}.tmp", uuid::Uuid::now_v7().simple())
310}
311
312impl TempFile {
313    fn create(target: &Path, mode: Option<u32>) -> io::Result<Self> {
314        let parent = target.parent().ok_or_else(|| {
315            io::Error::new(
316                io::ErrorKind::InvalidInput,
317                format!(
318                    "atomic_io: destination '{}' has no parent directory",
319                    target.display()
320                ),
321            )
322        })?;
323        if !parent.as_os_str().is_empty() {
324            std::fs::create_dir_all(parent)?;
325        }
326        let file_name = target
327            .file_name()
328            .and_then(|value| value.to_str())
329            .unwrap_or("file");
330        let tmp_name = temp_sibling_name(file_name);
331        let tmp_path = if parent.as_os_str().is_empty() {
332            PathBuf::from(tmp_name)
333        } else {
334            parent.join(tmp_name)
335        };
336        let file = OpenOptions::new()
337            .create_new(true)
338            .write(true)
339            .open(&tmp_path)?;
340        if let Some(mode) = mode {
341            if let Err(error) = apply_mode(&tmp_path, mode) {
342                drop(file);
343                let _ = std::fs::remove_file(&tmp_path);
344                return Err(error);
345            }
346        } else if let Ok(metadata) = std::fs::metadata(target) {
347            if let Err(error) = file.set_permissions(metadata.permissions()) {
348                drop(file);
349                let _ = std::fs::remove_file(&tmp_path);
350                return Err(error);
351            }
352        }
353        Ok(Self {
354            path: tmp_path,
355            file: Some(file),
356        })
357    }
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363
364    #[test]
365    fn temp_sibling_name_is_length_bounded_regardless_of_target_name() {
366        // A very long target name (e.g. a 64-char content hash, or longer) must
367        // not inflate the temp sibling past a small constant, so a target that
368        // fits under Windows' MAX_PATH can never produce an overflowing temp.
369        let bound = 1 + TEMP_STEM_MAX + 1 + 32 + 4; // ".{<=16}.{32-hex uuid}.tmp"
370        for name in ["s", "state.json", &"a".repeat(64), &"z".repeat(4096)] {
371            let temp = temp_sibling_name(name);
372            assert!(
373                temp.len() <= bound,
374                "temp name {:?} (len {}) exceeds bound {bound}",
375                temp,
376                temp.len()
377            );
378            assert!(temp.starts_with('.') && temp.ends_with(".tmp"));
379        }
380    }
381
382    #[test]
383    fn atomic_write_succeeds_for_a_long_target_file_name() {
384        // The temp sibling used to embed the full (long) target name, so this
385        // write overflowed MAX_PATH on Windows. It must succeed on every OS.
386        let dir = tempfile::tempdir().unwrap();
387        let path = dir.path().join("a".repeat(200));
388        atomic_write(&path, b"payload").unwrap();
389        assert_eq!(std::fs::read(&path).unwrap(), b"payload");
390    }
391
392    #[test]
393    fn writes_bytes_atomically() {
394        let dir = tempfile::tempdir().unwrap();
395        let path = dir.path().join("state.json");
396        atomic_write(&path, b"hello").unwrap();
397        assert_eq!(std::fs::read(&path).unwrap(), b"hello");
398    }
399
400    #[test]
401    fn overwrites_existing_file() {
402        let dir = tempfile::tempdir().unwrap();
403        let path = dir.path().join("state.json");
404        std::fs::write(&path, b"old").unwrap();
405        atomic_write(&path, b"new").unwrap();
406        assert_eq!(std::fs::read(&path).unwrap(), b"new");
407    }
408
409    #[test]
410    fn creates_missing_parent_dirs() {
411        let dir = tempfile::tempdir().unwrap();
412        let path = dir.path().join("a/b/c/state.json");
413        atomic_write(&path, b"deep").unwrap();
414        assert_eq!(std::fs::read(&path).unwrap(), b"deep");
415    }
416
417    #[test]
418    fn streaming_writer_finalizes_atomically() {
419        let dir = tempfile::tempdir().unwrap();
420        let path = dir.path().join("log.jsonl");
421        atomic_write_with(&path, |writer| {
422            writeln!(writer, "first")?;
423            writeln!(writer, "second")?;
424            Ok(())
425        })
426        .unwrap();
427        let read = std::fs::read_to_string(&path).unwrap();
428        assert_eq!(read, "first\nsecond\n");
429    }
430
431    #[test]
432    fn streaming_writer_cleans_up_on_error() {
433        let dir = tempfile::tempdir().unwrap();
434        let path = dir.path().join("state.json");
435        std::fs::write(&path, b"old").unwrap();
436        let err = atomic_write_with(&path, |writer| {
437            writer.write_all(b"partial")?;
438            Err(io::Error::other("nope"))
439        })
440        .unwrap_err();
441        assert_eq!(err.to_string(), "nope");
442        assert_eq!(std::fs::read(&path).unwrap(), b"old");
443        // No leftover .tmp siblings.
444        let leftover: Vec<_> = std::fs::read_dir(dir.path())
445            .unwrap()
446            .filter_map(Result::ok)
447            .filter(|entry| entry.file_name().to_string_lossy().ends_with(".tmp"))
448            .collect();
449        assert!(
450            leftover.is_empty(),
451            "tmp file should be cleaned up on error"
452        );
453    }
454
455    #[test]
456    fn flush_and_replace_failures_preserve_destination_and_clean_up() {
457        for stage in ["flush", "replace"] {
458            let dir = tempfile::tempdir().unwrap();
459            let path = dir.path().join("state.json");
460            std::fs::write(&path, b"old").unwrap();
461            TEST_FAILURE_STAGE.with(|value| value.set(Some(stage)));
462            let error = atomic_write(&path, b"new").unwrap_err();
463            TEST_FAILURE_STAGE.with(|value| value.set(None));
464
465            assert_eq!(error.to_string(), format!("injected {stage} failure"));
466            assert_eq!(std::fs::read(&path).unwrap(), b"old");
467            let leftovers: Vec<_> = std::fs::read_dir(dir.path())
468                .unwrap()
469                .filter_map(Result::ok)
470                .filter(|entry| entry.file_name().to_string_lossy().ends_with(".tmp"))
471                .collect();
472            assert!(leftovers.is_empty(), "{stage} left a temp file");
473        }
474    }
475
476    #[cfg(unix)]
477    #[test]
478    fn replacement_preserves_existing_permissions() {
479        use std::os::unix::fs::PermissionsExt;
480
481        let dir = tempfile::tempdir().unwrap();
482        let path = dir.path().join("state.json");
483        std::fs::write(&path, b"old").unwrap();
484        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o640)).unwrap();
485        atomic_write(&path, b"new").unwrap();
486        assert_eq!(
487            std::fs::metadata(path).unwrap().permissions().mode() & 0o777,
488            0o640
489        );
490    }
491
492    #[cfg(unix)]
493    #[test]
494    fn mode_is_applied_before_the_rename() {
495        use std::os::unix::fs::PermissionsExt;
496        let dir = tempfile::tempdir().unwrap();
497        let path = dir.path().join("credentials.json");
498        atomic_write_with_mode(&path, b"secret", 0o600).unwrap();
499        let mode = std::fs::metadata(&path).unwrap().permissions().mode();
500        assert_eq!(mode & 0o777, 0o600, "credentials must be owner-only");
501    }
502
503    #[cfg(unix)]
504    #[test]
505    fn mode_survives_overwriting_a_loose_destination() {
506        use std::os::unix::fs::PermissionsExt;
507        let dir = tempfile::tempdir().unwrap();
508        let path = dir.path().join("credentials.json");
509        std::fs::write(&path, b"old").unwrap();
510        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
511        atomic_write_with_mode(&path, b"secret", 0o600).unwrap();
512        let mode = std::fs::metadata(&path).unwrap().permissions().mode();
513        assert_eq!(mode & 0o777, 0o600);
514    }
515
516    #[test]
517    fn concurrent_writers_do_not_collide() {
518        let dir = tempfile::tempdir().unwrap();
519        let path = std::sync::Arc::new(dir.path().join("state.json"));
520        let mut handles = Vec::new();
521        for i in 0..16 {
522            let path = std::sync::Arc::clone(&path);
523            handles.push(std::thread::spawn(move || {
524                let payload = format!("writer-{i}");
525                atomic_write(&path, payload.as_bytes()).unwrap();
526            }));
527        }
528        for handle in handles {
529            handle.join().unwrap();
530        }
531        // The final contents must match exactly one of the writers — never a
532        // truncated or interleaved value.
533        let final_contents = std::fs::read_to_string(&*path).unwrap();
534        assert!(
535            final_contents.starts_with("writer-") && final_contents.len() <= "writer-15".len(),
536            "unexpected final contents: {final_contents:?}"
537        );
538    }
539}