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}