Skip to main content

pakx_core/
atomic_write.rs

1//! Crash-safe write helper used by `agents.lock`, `agents.yml`, and the
2//! federated-registry response cache.
3//!
4//! The pattern is: write the body to `<path>.tmp` and `rename` it into
5//! place. POSIX `rename(2)` is atomic within the same filesystem, and on
6//! Windows `std::fs::rename` lowers to `MoveFileExW(MOVEFILE_REPLACE_EXISTING)`
7//! which is also atomic for files. Either way: a crash mid-write leaves
8//! the destination either untouched or fully written, never half.
9//!
10//! Why bother. The previous `fs::write(path, body)` path could leave a
11//! corrupt `agents.lock` on disk if the process died after `open` but
12//! before the last byte hit. A corrupt lockfile fails the *next* `pakx
13//! install` / `pakx test` hard rather than self-healing — exactly the
14//! scenario the user least wants to debug.
15//!
16//! Permission bits are NOT set here. The `~/.pakx/credentials.json`
17//! writer needs `0600` and handles that itself via `OpenOptions::mode`
18//! at the `open` call (see `credentials::Credentials::write_to`) —
19//! mode-at-open is the only atomic way to get sensitive bits onto disk.
20//! For everything else (lockfile, manifest, cache) the default umask is
21//! the right call: cache entries are public registry responses, the
22//! lockfile is meant to be committed to source control, and manifests
23//! are user-authored config.
24
25use std::path::{Path, PathBuf};
26
27/// Write `bytes` to `path` atomically.
28///
29/// Implementation: write to `<path>.tmp`, then `rename` into place. If
30/// the rename fails the orphan `.tmp` is unlinked so we don't leak temp
31/// files across crashes. The caller is responsible for ensuring the
32/// parent directory exists.
33///
34/// # Errors
35///
36/// Returns the underlying `std::io::Error` from either the temp-file
37/// write or the final rename. On rename failure the temp file is removed
38/// best-effort before returning — the original error is surfaced
39/// regardless, since the cleanup outcome is rarely actionable.
40pub fn atomic_write(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
41    let tmp = tmp_path_for(path);
42
43    // Write the body. On failure, leave the tmp file alone — `fs::write`
44    // already removed any partial bytes on close, and the tmp path is
45    // deterministic so the next successful run reuses it.
46    std::fs::write(&tmp, bytes)?;
47
48    // Rename into place. If the rename fails (cross-device, permission,
49    // etc.), unlink the orphan tmp so we don't litter the filesystem
50    // with stale `.tmp` files across failed runs. Ignore cleanup errors;
51    // surfacing the original rename failure is more useful.
52    if let Err(e) = std::fs::rename(&tmp, path) {
53        let _ = std::fs::remove_file(&tmp);
54        return Err(e);
55    }
56    Ok(())
57}
58
59/// Compute the temp path used by [`atomic_write`]. Splitting this out
60/// lets callers reason about the rename target shape (and unit-test the
61/// orphan-cleanup path) without going through the filesystem.
62#[must_use]
63pub fn tmp_path_for(path: &Path) -> PathBuf {
64    // `path.with_extension("...tmp")` strips any existing extension,
65    // which would collapse `agents.lock` → `agents.tmp` and collide
66    // with `agents.yml`'s tmp. Appending `.tmp` to the OS string keeps
67    // every original byte intact: `agents.lock` → `agents.lock.tmp`.
68    let mut s = path.as_os_str().to_owned();
69    s.push(".tmp");
70    PathBuf::from(s)
71}
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76
77    #[test]
78    fn writes_bytes_to_path() {
79        let dir = tempfile::tempdir().unwrap();
80        let path = dir.path().join("data.bin");
81        atomic_write(&path, b"hello").unwrap();
82        assert_eq!(std::fs::read(&path).unwrap(), b"hello");
83    }
84
85    #[test]
86    fn no_tmp_file_lingers_after_successful_write() {
87        let dir = tempfile::tempdir().unwrap();
88        let path = dir.path().join("data.bin");
89        atomic_write(&path, b"hello").unwrap();
90
91        let tmp = tmp_path_for(&path);
92        assert!(
93            !tmp.exists(),
94            "tmp file {} should not exist after successful rename",
95            tmp.display(),
96        );
97    }
98
99    #[test]
100    fn overwrites_existing_destination() {
101        let dir = tempfile::tempdir().unwrap();
102        let path = dir.path().join("data.bin");
103        atomic_write(&path, b"first").unwrap();
104        atomic_write(&path, b"second").unwrap();
105        assert_eq!(std::fs::read(&path).unwrap(), b"second");
106    }
107
108    #[test]
109    fn tmp_path_preserves_full_filename() {
110        // `agents.lock` must become `agents.lock.tmp`, not `agents.tmp`
111        // — collapsing extensions would let `agents.yml`'s tmp collide
112        // with `agents.lock`'s tmp in the same directory.
113        let p = Path::new("/some/dir/agents.lock");
114        assert_eq!(tmp_path_for(p), Path::new("/some/dir/agents.lock.tmp"));
115        let p = Path::new("/some/dir/agents.yml");
116        assert_eq!(tmp_path_for(p), Path::new("/some/dir/agents.yml.tmp"));
117    }
118
119    #[test]
120    fn tmp_path_handles_no_extension() {
121        let p = Path::new("/some/dir/file");
122        assert_eq!(tmp_path_for(p), Path::new("/some/dir/file.tmp"));
123    }
124
125    /// Unix-only: a read-only parent dir makes the temp-file create
126    /// (and any subsequent rename) fail. Verify the orphan `.tmp` is
127    /// cleaned up — or never created — after the failure so we don't
128    /// leak temp files. Windows ACLs make this hard to set up the same
129    /// way; skipping there keeps the test portable.
130    ///
131    /// The test silently passes through as a no-op when run as `root`
132    /// (root bypasses unix DAC and would make the writes succeed). The
133    /// `nix::geteuid` check would pull in another dep just for this
134    /// path; we approximate by attempting the write and returning early
135    /// if it unexpectedly succeeds — which only happens under root.
136    #[cfg(unix)]
137    #[test]
138    fn cleans_up_tmp_on_failure() {
139        use std::os::unix::fs::PermissionsExt;
140
141        let dir = tempfile::tempdir().unwrap();
142        let subdir = dir.path().join("ro");
143        std::fs::create_dir(&subdir).unwrap();
144        let path = subdir.join("data.bin");
145
146        // Drop write perms on the parent dir → both the `fs::write` of
147        // `<path>.tmp` and any subsequent `rename` fail with EACCES.
148        let mut perms = std::fs::metadata(&subdir).unwrap().permissions();
149        perms.set_mode(0o555);
150        std::fs::set_permissions(&subdir, perms).unwrap();
151
152        let outcome = atomic_write(&path, b"new");
153
154        // Restore perms before any assertion so a failing assertion
155        // doesn't leak a non-deletable tempdir.
156        let mut perms = std::fs::metadata(&subdir).unwrap().permissions();
157        perms.set_mode(0o755);
158        std::fs::set_permissions(&subdir, perms).unwrap();
159
160        // Running as root bypasses DAC; the write succeeds. Skip the
161        // assertion in that case rather than failing CI in
162        // root-containers.
163        if outcome.is_ok() {
164            return;
165        }
166
167        let tmp = tmp_path_for(&path);
168        assert!(
169            !tmp.exists(),
170            "tmp file {} should not linger after failed write",
171            tmp.display(),
172        );
173    }
174}