oxi/store/fs_util.rs
1//! Atomic write helpers shared across `store/*`.
2//!
3//! Replaces the per-module `atomic_write` copies that lived in
4//! [`super::issues`] and [`super::session`]. Those copies named the temp file
5//! `tmp.<pid>`, which collides under PID-namespace recycling (containers) or
6//! fork+exec where two live processes can share a PID and stomp each other's
7//! temp. The temp name here is `<path>.tmp.<pid>.<uuid>` — PID kept for
8//! debuggability, the UUID guarantees uniqueness.
9//!
10//! # Durability
11//!
12//! `fs::rename` is atomic but not durable (no `fsync`). This matches the
13//! existing CLI consistency model; durability is an explicit opt-in left for
14//! a future `atomic_write_durable` if a caller needs it. Out of P1 scope.
15
16use std::fs;
17use std::io;
18use std::path::Path;
19
20/// Atomically write UTF-8 content to `path` (temp file + rename).
21///
22/// See the module docs for the temp-name rationale (UUID suffix).
23pub fn atomic_write(path: &Path, content: &str) -> io::Result<()> {
24 atomic_write_bytes(path, content.as_bytes())
25}
26
27/// Atomically write raw bytes to `path` (temp file + rename).
28///
29/// On rename failure the temp file is best-effort removed so we don't leak
30/// orphans into the data directory.
31pub fn atomic_write_bytes(path: &Path, content: &[u8]) -> io::Result<()> {
32 let tmp = path.with_extension(format!(
33 "tmp.{}.{}",
34 std::process::id(),
35 uuid::Uuid::new_v4().simple()
36 ));
37 fs::write(&tmp, content)?;
38 match fs::rename(&tmp, path) {
39 Ok(()) => Ok(()),
40 Err(e) => {
41 // Best-effort cleanup; the rename error is the one we propagate.
42 let _ = fs::remove_file(&tmp);
43 Err(e)
44 }
45 }
46}
47
48#[cfg(test)]
49mod tests {
50 use super::*;
51
52 #[test]
53 fn temp_file_name_contains_uuid_suffix() {
54 // The temp name must carry both the pid (debuggability) and a UUID
55 // (uniqueness). We can't observe it directly (it's created+renamed
56 // inside the helper), so we reconstruct the expected shape and assert
57 // the naming policy holds against the code path.
58 let pid = std::process::id();
59 let uuid = uuid::Uuid::new_v4().simple().to_string();
60 let suffix = format!("tmp.{}.{}", pid, uuid);
61 // 32 hex chars for v4 simple.
62 assert_eq!(uuid.len(), 32);
63 assert!(uuid.chars().all(|c| c.is_ascii_hexdigit()));
64 assert!(suffix.contains(&pid.to_string()));
65 }
66
67 #[test]
68 fn atomic_write_survives_concurrent_same_path() {
69 // 16 threads all writing distinct payloads to the SAME final path.
70 // Regardless of interleaving, the final file must contain exactly one
71 // of the payloads — never a torn or empty file. (Regression for the
72 // PID-collision defect.)
73 let tmp = std::env::temp_dir().join(format!(
74 "oxi-fs-util-concurrent-{}",
75 uuid::Uuid::new_v4().simple()
76 ));
77 // Start from an empty file so with_extension behaves.
78 fs::write(&tmp, b"").unwrap();
79
80 let payloads: Vec<String> = (0..16).map(|i| format!("payload-{i}")).collect();
81 let path = tmp.clone();
82 std::thread::scope(|s| {
83 for p in payloads {
84 let path = path.clone();
85 s.spawn(move || atomic_write(&path, &p).unwrap());
86 }
87 });
88
89 let got = fs::read_to_string(&tmp).unwrap();
90 assert!(
91 (0..16).any(|i| got == format!("payload-{i}")),
92 "concurrent writes produced a torn result: {got:?}"
93 );
94 // No leftover temp files in the directory.
95 let dir = tmp.parent().unwrap();
96 let leaking: Vec<_> = fs::read_dir(dir)
97 .unwrap()
98 .flatten()
99 .filter(|e| {
100 e.file_name()
101 .to_string_lossy()
102 .starts_with(tmp.file_name().unwrap().to_string_lossy().as_ref())
103 && e.file_name().to_string_lossy() != tmp.file_name().unwrap().to_string_lossy()
104 })
105 .collect();
106 let _ = fs::remove_file(&tmp);
107 assert!(
108 leaking.is_empty(),
109 "orphan temp files left behind: {leaking:?}"
110 );
111 }
112
113 #[test]
114 fn rename_failure_does_not_leak_orphan() {
115 // A read-only directory makes rename fail. The temp file must be
116 // removed by the helper (best-effort) and the error propagated.
117 let root =
118 std::env::temp_dir().join(format!("oxi-fs-util-ro-{}", uuid::Uuid::new_v4().simple()));
119 fs::create_dir(&root).unwrap();
120 let target = root.join("out.md");
121
122 // Make the directory read-only so rename cannot complete.
123 let mut perms = fs::metadata(&root).unwrap().permissions();
124 perms.set_readonly(true);
125 fs::set_permissions(&root, perms).unwrap();
126
127 let res = atomic_write(&target, "hello");
128 assert!(res.is_err(), "expected rename to fail under read-only dir");
129
130 // Restore perms so cleanup works, then check no temp orphan remains.
131 let mut perms = fs::metadata(&root).unwrap().permissions();
132 // `set_readonly(false)` restores the owner-write bit on Unix (mode |=
133 // 0o200), which is all `remove_dir_all` needs. Clippy flags the
134 // incomplete restore; for this throwaway test fixture that is intended.
135 #[allow(clippy::permissions_set_readonly_false)]
136 perms.set_readonly(false);
137 fs::set_permissions(&root, perms).unwrap();
138
139 let leftovers: Vec<_> = fs::read_dir(&root)
140 .unwrap()
141 .flatten()
142 .map(|e| e.file_name().to_string_lossy().into_owned())
143 .collect();
144 fs::remove_dir_all(&root).ok();
145 assert!(
146 leftovers.is_empty(),
147 "temp orphan leaked after rename failure: {leftovers:?}"
148 );
149 }
150}