Skip to main content

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}