hayson_engine/atomic/
temp_file.rs1use 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 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#[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#[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
72pub 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
116pub 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 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}