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}