Skip to main content

hayson_engine/atomic/
temp_file.rs

1use std::fmt;
2use std::fs::{self, File, OpenOptions};
3use std::io::{self, Write};
4use std::path::{Path, PathBuf};
5
6#[derive(Debug)]
7pub struct AtomicTemporaryFileError {
8    pub path: PathBuf,
9    pub reason: String,
10    source: io::Error,
11}
12
13impl fmt::Display for AtomicTemporaryFileError {
14    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
15        write!(f, "temporary file \"{}\" {}", self.path.display(), self.reason)
16    }
17}
18
19impl std::error::Error for AtomicTemporaryFileError {
20    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
21        Some(&self.source)
22    }
23}
24
25impl AtomicTemporaryFileError {
26    /// Recovers the domain error from an `io::Error` returned by [`open_temporary`] or
27    /// [`write_atomic`], if that is what it wraps.
28    pub fn downcast(error: &io::Error) -> Option<&AtomicTemporaryFileError> {
29        error.get_ref().and_then(|inner| inner.downcast_ref())
30    }
31
32    fn into_io_error(self) -> io::Error {
33        io::Error::new(io::ErrorKind::AlreadyExists, self)
34    }
35}
36
37// NOTE: off unix this is a no-op — O_EXCL alone still refuses a planted symlink, per POSIX.
38#[cfg(unix)]
39fn apply_unix_extras(options: &mut OpenOptions, mode: Option<u32>) {
40    use std::os::unix::fs::OpenOptionsExt;
41    options.custom_flags(libc::O_NOFOLLOW);
42    if let Some(mode) = mode {
43        options.mode(mode);
44    }
45}
46
47#[cfg(not(unix))]
48fn apply_unix_extras(_options: &mut OpenOptions, _mode: Option<u32>) {}
49
50fn open_exclusive(path: &Path, mode: Option<u32>) -> io::Result<File> {
51    let mut options = OpenOptions::new();
52    options.write(true).create_new(true);
53    apply_unix_extras(&mut options, mode);
54    options.open(path)
55}
56
57// wasm32-wasip1's capability model refuses to resolve a path through a symlink that would leave
58// the module's preopened sandbox *before* it ever gets to an existence check — where native POSIX
59// answers `EEXIST` for `O_CREAT|O_EXCL` against a planted symlink regardless of its target, WASI
60// answers `ENOTCAPABLE` instead. Same case (something already sits there and must not be written
61// through), different errno — this is the WASI half of that same refusal.
62#[cfg(target_os = "wasi")]
63fn is_planted_symlink_refusal(error: &io::Error) -> bool {
64    error.raw_os_error() == Some(libc::ENOTCAPABLE)
65}
66
67#[cfg(not(target_os = "wasi"))]
68fn is_planted_symlink_refusal(_error: &io::Error) -> bool {
69    false
70}
71
72/// Opens `path` for writing on the condition that nothing already sits there — a pre-existing
73/// file, and a symlink regardless of its target, both fail with `AlreadyExists` per POSIX
74/// `O_CREAT|O_EXCL` semantics (on `wasm32-wasip1`, a symlink leaving the sandbox instead fails
75/// with `ENOTCAPABLE` — see [`is_planted_symlink_refusal`]), so the call never writes through a
76/// planted link. A first residue is cleared and the create is retried once; a second refusal —
77/// the residue could not be removed, or the path was claimed again in the instant after clearing
78/// it — surfaces as an [`AtomicTemporaryFileError`] wrapped in the returned `io::Error` (see
79/// [`AtomicTemporaryFileError::downcast`]).
80pub fn open_temporary(path: &Path, mode: Option<u32>) -> io::Result<File> {
81    match open_exclusive(path, mode) {
82        Ok(file) => Ok(file),
83        Err(error) if error.kind() == io::ErrorKind::AlreadyExists || is_planted_symlink_refusal(&error) => {
84            if let Err(cause) = fs::remove_file(path) {
85                return Err(AtomicTemporaryFileError {
86                    path: path.to_path_buf(),
87                    reason: "is in the way and could not be cleared, so nothing on disk was changed — remove whatever holds that path and retry".to_string(),
88                    source: cause,
89                }
90                .into_io_error());
91            }
92            open_exclusive(path, mode).map_err(|cause| {
93                let reason = if cause.kind() == io::ErrorKind::AlreadyExists || is_planted_symlink_refusal(&cause) {
94                    "was planted again in the instant after the residue in its place was cleared, so it was refused rather than written through, and nothing on disk was changed — another process is racing for that path"
95                } else {
96                    "could not be created after the residue in its place was cleared, so nothing on disk was changed"
97                };
98                AtomicTemporaryFileError {
99                    path: path.to_path_buf(),
100                    reason: reason.to_string(),
101                    source: cause,
102                }
103                .into_io_error()
104            })
105        }
106        Err(cause) => Err(cause),
107    }
108}
109
110fn temp_path_for(target: &Path) -> PathBuf {
111    let mut name = target.as_os_str().to_owned();
112    name.push(".tmp");
113    PathBuf::from(name)
114}
115
116/// Writes `contents` to `target` by creating `<target>.tmp` on exclusive terms (see
117/// [`open_temporary`]), `fsync`ing it, and renaming it over `target` — the rename is atomic, so a
118/// reader of `target` never observes a partially written file. Leaves `<target>.tmp` behind on a
119/// write/fsync failure; the next call clears it as ordinary residue.
120pub fn write_atomic(target: &Path, contents: &[u8], mode: Option<u32>) -> io::Result<()> {
121    let temp_path = temp_path_for(target);
122    let mut file = open_temporary(&temp_path, mode)?;
123    file.write_all(contents)?;
124    file.sync_all()?;
125    drop(file);
126    fs::rename(&temp_path, target)
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132    use std::io::Read;
133    use std::os::unix::fs::{symlink, PermissionsExt};
134
135    fn temp_dir(prefix: &str) -> PathBuf {
136        let dir = std::env::temp_dir().join(format!(
137            "{prefix}{}-{}",
138            std::process::id(),
139            std::time::SystemTime::now()
140                .duration_since(std::time::UNIX_EPOCH)
141                .unwrap()
142                .as_nanos()
143        ));
144        fs::create_dir_all(&dir).unwrap();
145        dir
146    }
147
148    #[test]
149    fn creates_and_writes_a_fresh_temporary_file() {
150        let dir = temp_dir("hayson-atomic-fresh-");
151        let path = dir.join("payload.tmp");
152
153        let mut file = open_temporary(&path, None).unwrap();
154        file.write_all(b"fresh").unwrap();
155        drop(file);
156
157        let mut contents = String::new();
158        File::open(&path).unwrap().read_to_string(&mut contents).unwrap();
159        assert_eq!(contents, "fresh");
160    }
161
162    #[test]
163    fn replaces_plain_residue_left_by_a_killed_process() {
164        let dir = temp_dir("hayson-atomic-residue-");
165        let path = dir.join("payload.tmp");
166        fs::write(&path, "stale-residue-from-a-killed-process").unwrap();
167
168        let mut file = open_temporary(&path, None).unwrap();
169        file.write_all(b"fresh").unwrap();
170        drop(file);
171
172        let contents = fs::read_to_string(&path).unwrap();
173        assert_eq!(contents, "fresh");
174    }
175
176    #[test]
177    fn creates_with_the_given_mode_without_a_later_chmod() {
178        let dir = temp_dir("hayson-atomic-mode-");
179        let path = dir.join("payload.tmp");
180
181        open_temporary(&path, Some(0o600)).unwrap();
182
183        let mode = fs::metadata(&path).unwrap().permissions().mode();
184        assert_eq!(mode & 0o777, 0o600);
185    }
186
187    #[test]
188    fn refuses_a_planted_symlink_and_leaves_the_victim_untouched() {
189        let dir = temp_dir("hayson-atomic-symlink-");
190        let victim_dir = temp_dir("hayson-atomic-victim-");
191        let victim = victim_dir.join("precious.txt");
192        fs::write(&victim, "PRECIOUS\n").unwrap();
193        let victim_mtime = fs::metadata(&victim).unwrap().modified().unwrap();
194
195        let path = dir.join("payload.tmp");
196        symlink(&victim, &path).unwrap();
197
198        let mut file = open_temporary(&path, None).unwrap();
199        file.write_all(b"written-through-nothing").unwrap();
200        drop(file);
201
202        assert_eq!(fs::read_to_string(&victim).unwrap(), "PRECIOUS\n");
203        assert_eq!(fs::metadata(&victim).unwrap().modified().unwrap(), victim_mtime);
204        assert!(!fs::symlink_metadata(&path).unwrap().file_type().is_symlink());
205        assert_eq!(fs::read_to_string(&path).unwrap(), "written-through-nothing");
206    }
207
208    #[test]
209    fn a_residue_that_cannot_be_cleared_surfaces_as_the_domain_error() {
210        let dir = temp_dir("hayson-atomic-unclearable-");
211        let path = dir.join("payload.tmp");
212        // A non-empty directory in the way: remove_file refuses it, so the retry path never runs.
213        fs::create_dir(&path).unwrap();
214        fs::write(path.join("occupant"), "x").unwrap();
215
216        let error = open_temporary(&path, None).unwrap_err();
217        let domain = AtomicTemporaryFileError::downcast(&error).expect("domain error");
218        assert_eq!(domain.path, path);
219        assert!(domain.reason.contains("could not be cleared"));
220    }
221
222    #[test]
223    fn write_atomic_creates_the_target_and_leaves_no_temporary_behind() {
224        let dir = temp_dir("hayson-atomic-write-");
225        let target = dir.join("manifest.json");
226
227        write_atomic(&target, b"{\"hayson\":1}", None).unwrap();
228
229        assert_eq!(fs::read_to_string(&target).unwrap(), "{\"hayson\":1}");
230        assert!(!temp_path_for(&target).exists());
231    }
232
233    #[test]
234    fn write_atomic_is_not_written_through_a_planted_symlink_on_the_temporary_path() {
235        let dir = temp_dir("hayson-atomic-write-symlink-");
236        let victim_dir = temp_dir("hayson-atomic-write-victim-");
237        let victim = victim_dir.join("precious.txt");
238        fs::write(&victim, "PRECIOUS\n").unwrap();
239        let victim_mtime = fs::metadata(&victim).unwrap().modified().unwrap();
240
241        let target = dir.join("manifest.json");
242        symlink(&victim, temp_path_for(&target)).unwrap();
243
244        write_atomic(&target, b"{\"hayson\":1}", None).unwrap();
245
246        assert_eq!(fs::read_to_string(&victim).unwrap(), "PRECIOUS\n");
247        assert_eq!(fs::metadata(&victim).unwrap().modified().unwrap(), victim_mtime);
248        assert_eq!(fs::read_to_string(&target).unwrap(), "{\"hayson\":1}");
249    }
250}