Skip to main content

grex_core/fs/
atomic.rs

1//! Atomic file replacement via temp-file + rename.
2//!
3//! The temp file is always created in the **same directory** as the target so
4//! the final `rename` stays on the same filesystem (required for atomicity on
5//! POSIX; `MoveFileExW` handles same-volume atomic replace on Windows).
6//!
7//! # Symlink handling
8//!
9//! * **Unix**: if `path` is a symlink, we resolve it via `fs::canonicalize`
10//!   and write to the resolved pointee. The symlink itself is preserved; its
11//!   target file is replaced atomically. This matches what most tools expect
12//!   when writing to a path that happens to be a link.
13//! * **Windows**: symlinks are uncommon and require elevated privileges by
14//!   default. Current behavior is preserved: `rename` replaces the link with
15//!   a regular file. A `tracing::warn!` is emitted when this happens so the
16//!   caller can notice.
17//!
18//! # Concurrent writers
19//!
20//! The temp path is uniquified per writer using pid + monotonic nanos so two
21//! processes/threads writing to the same target cannot step on each other's
22//! temp file. Each writer gets its own `<path>.tmp.<pid>.<nanos>`; the final
23//! rename still wins atomically.
24//!
25//! # Crash safety
26//!
27//! * If a crash happens before `rename`, the original file (if any) is
28//!   untouched. A partially written `.tmp.<pid>.<nanos>` may remain — callers
29//!   may choose to clean it on the next open, but leaving it is safe.
30//! * `rename` on an existing target is atomic on all supported platforms
31//!   (Linux/macOS/Windows). Readers either see the old or new contents,
32//!   never a mix.
33
34use std::fs;
35use std::io;
36use std::path::{Path, PathBuf};
37use std::sync::atomic::{AtomicU64, Ordering};
38use std::time::{SystemTime, UNIX_EPOCH};
39
40/// Process-local monotonic tiebreaker for the temp-path suffix.
41///
42/// System clock resolution is coarse on some platforms (Windows in particular,
43/// where `SystemTime::now()` may advance in ~15 ms steps). Two temp paths
44/// minted inside the same tick would collide; this counter breaks the tie.
45static TMP_COUNTER: AtomicU64 = AtomicU64::new(0);
46
47/// Atomically replace `path` with `bytes`.
48///
49/// Writes to a uniquified sibling temp file then renames into place. The
50/// parent directory must exist; it will **not** be created.
51///
52/// On Unix, if `path` is a symlink, its pointee is resolved and replaced —
53/// the symlink itself is preserved. On Windows the symlink is replaced with
54/// a regular file (with a `tracing::warn!`).
55///
56/// # Errors
57///
58/// Returns [`io::Error`] if the write or rename fails.
59pub fn atomic_write(path: &Path, bytes: &[u8]) -> io::Result<()> {
60    let target = resolve_target(path);
61    let tmp = tmp_path(&target);
62    // Write to the uniquified temp — prior-crash leftovers from OTHER
63    // writers have different suffixes, so we don't touch them.
64    fs::write(&tmp, bytes)?;
65    // `fs::rename` replaces the target atomically on same-volume paths.
66    match fs::rename(&tmp, &target) {
67        Ok(()) => Ok(()),
68        Err(e) => {
69            // Rename failed — don't leave garbage around.
70            let _ = fs::remove_file(&tmp);
71            Err(e)
72        }
73    }
74}
75
76/// Resolve the target path to write.
77///
78/// On Unix, a symlink is canonicalized so the pointee is replaced, not the
79/// link. On Windows or if canonicalization fails (e.g. the target does not
80/// exist yet), `path` is used as-is.
81#[cfg(unix)]
82fn resolve_target(path: &Path) -> PathBuf {
83    match fs::symlink_metadata(path) {
84        Ok(meta) if meta.file_type().is_symlink() => match fs::canonicalize(path) {
85            Ok(resolved) => resolved,
86            Err(e) => {
87                tracing::warn!(
88                    path = %path.display(),
89                    error = %e,
90                    "atomic_write: failed to canonicalize symlink; replacing link with regular file"
91                );
92                path.to_path_buf()
93            }
94        },
95        _ => path.to_path_buf(),
96    }
97}
98
99#[cfg(windows)]
100fn resolve_target(path: &Path) -> PathBuf {
101    if let Ok(meta) = fs::symlink_metadata(path) {
102        if meta.file_type().is_symlink() {
103            tracing::warn!(
104                path = %path.display(),
105                "atomic_write on Windows replaces symlink with a regular file; pointee untouched"
106            );
107        }
108    }
109    path.to_path_buf()
110}
111
112/// Build a uniquified sibling temp path `<path>.tmp.<pid>.<nanos>.<ctr>`.
113///
114/// `pid` separates processes; `nanos` + `ctr` separate writers within one
115/// process even when the system clock has coarse resolution.
116fn tmp_path(path: &Path) -> PathBuf {
117    let pid = std::process::id();
118    let nanos = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0);
119    let ctr = TMP_COUNTER.fetch_add(1, Ordering::Relaxed);
120    let mut s = path.as_os_str().to_owned();
121    s.push(format!(".tmp.{pid}.{nanos:x}.{ctr:x}"));
122    PathBuf::from(s)
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128    use tempfile::tempdir;
129
130    #[test]
131    fn write_succeeds() {
132        let dir = tempdir().unwrap();
133        let p = dir.path().join("a.txt");
134        atomic_write(&p, b"hello").unwrap();
135        assert_eq!(fs::read(&p).unwrap(), b"hello");
136    }
137
138    #[test]
139    fn existing_file_overwritten() {
140        let dir = tempdir().unwrap();
141        let p = dir.path().join("a.txt");
142        fs::write(&p, b"old").unwrap();
143        atomic_write(&p, b"new").unwrap();
144        assert_eq!(fs::read(&p).unwrap(), b"new");
145    }
146
147    #[test]
148    fn temp_file_cleaned_on_success() {
149        // No specific tmp name is predictable anymore (pid/nanos/ctr suffix),
150        // but no tmp file from OUR writer should remain after a successful
151        // atomic_write — only the final target should exist in the parent.
152        let dir = tempdir().unwrap();
153        let p = dir.path().join("a.txt");
154        atomic_write(&p, b"x").unwrap();
155        let entries: Vec<_> = fs::read_dir(dir.path())
156            .unwrap()
157            .filter_map(|e| e.ok())
158            .map(|e| e.file_name())
159            .collect();
160        assert_eq!(entries, vec![std::ffi::OsString::from("a.txt")]);
161    }
162
163    #[test]
164    fn stale_temp_from_prior_crash_does_not_block_write() {
165        // A prior-crash leftover has a different pid/nanos suffix than the
166        // current writer, so atomic_write must not care about it: the new
167        // write uses a fresh suffix and succeeds regardless.
168        let dir = tempdir().unwrap();
169        let p = dir.path().join("a.txt");
170        // Hand-construct a plausible prior-crash temp path.
171        let mut stale = p.as_os_str().to_owned();
172        stale.push(".tmp.99999.deadbeef.0");
173        let stale = PathBuf::from(stale);
174        fs::write(&stale, b"garbage").unwrap();
175        atomic_write(&p, b"fresh").unwrap();
176        assert_eq!(fs::read(&p).unwrap(), b"fresh");
177        // The unrelated stale file is left alone — that's the explicit
178        // design trade-off for crash safety with concurrent writers.
179        assert!(stale.exists(), "stale temp from a foreign writer is left untouched");
180    }
181
182    #[test]
183    fn tmp_paths_are_unique_per_call() {
184        let dir = tempdir().unwrap();
185        let p = dir.path().join("a.txt");
186        let t1 = tmp_path(&p);
187        let t2 = tmp_path(&p);
188        assert_ne!(t1, t2, "consecutive tmp_path calls must differ");
189    }
190}