1use std::fs::{File, OpenOptions};
23use std::io::{self, BufWriter, Write};
24use std::path::{Path, PathBuf};
25
26#[cfg(test)]
27thread_local! {
28 static TEST_FAILURE_STAGE: std::cell::Cell<Option<&'static str>> = const { std::cell::Cell::new(None) };
29}
30
31#[cfg(test)]
32fn fail_test_stage(stage: &'static str) -> io::Result<()> {
33 if TEST_FAILURE_STAGE.with(|value| value.get()) == Some(stage) {
34 return Err(io::Error::other(format!("injected {stage} failure")));
35 }
36 Ok(())
37}
38
39#[cfg(not(test))]
40#[inline]
41fn fail_test_stage(_stage: &'static str) -> io::Result<()> {
42 Ok(())
43}
44
45#[derive(Clone, Copy, Debug, Eq, PartialEq)]
47pub enum AtomicWriteDurability {
48 Namespace,
50 Flush,
54}
55
56#[derive(Clone, Copy, Debug, Eq, PartialEq)]
58pub struct AtomicWriteReceipt {
59 pub file_synced: bool,
61 pub namespace_synced: bool,
63}
64
65pub fn atomic_write(path: &Path, bytes: &[u8]) -> io::Result<()> {
67 atomic_write_with(path, |writer| writer.write_all(bytes))
68}
69
70pub fn atomic_write_with_mode(path: &Path, bytes: &[u8], mode: u32) -> io::Result<()> {
79 atomic_write_stream_with_durability_and_mode(
80 path,
81 AtomicWriteDurability::Flush,
82 Some(mode),
83 |writer| writer.write_all(bytes),
84 )
85 .map(|_| ())
86}
87
88pub fn atomic_write_with_durability(
90 path: &Path,
91 bytes: &[u8],
92 durability: AtomicWriteDurability,
93) -> io::Result<AtomicWriteReceipt> {
94 atomic_write_stream_with_durability_and_mode(path, durability, None, |writer| {
95 writer.write_all(bytes)
96 })
97}
98
99pub(crate) fn atomic_write_with_durability_unlocked(
100 path: &Path,
101 bytes: &[u8],
102 durability: AtomicWriteDurability,
103) -> io::Result<AtomicWriteReceipt> {
104 atomic_write_stream_with_durability_and_mode_unlocked(path, durability, None, |writer| {
105 writer.write_all(bytes)
106 })
107}
108
109pub fn atomic_write_with<F>(path: &Path, write_fn: F) -> io::Result<()>
117where
118 F: FnOnce(&mut BufWriter<File>) -> io::Result<()>,
119{
120 atomic_write_stream_with_durability_and_mode(path, AtomicWriteDurability::Flush, None, write_fn)
121 .map(|_| ())
122}
123
124fn atomic_write_stream_with_durability_and_mode<F>(
125 path: &Path,
126 durability: AtomicWriteDurability,
127 mode: Option<u32>,
128 write_fn: F,
129) -> io::Result<AtomicWriteReceipt>
130where
131 F: FnOnce(&mut BufWriter<File>) -> io::Result<()>,
132{
133 #[cfg(windows)]
137 let _lock = crate::conditional_replace::acquire_lock(path)?;
138
139 atomic_write_stream_with_durability_and_mode_unlocked(path, durability, mode, write_fn)
140}
141
142fn atomic_write_stream_with_durability_and_mode_unlocked<F>(
143 path: &Path,
144 durability: AtomicWriteDurability,
145 mode: Option<u32>,
146 write_fn: F,
147) -> io::Result<AtomicWriteReceipt>
148where
149 F: FnOnce(&mut BufWriter<File>) -> io::Result<()>,
150{
151 let mut tmp = TempFile::create(path, mode)?;
152 let result = write_and_finalize(&mut tmp, durability, write_fn);
153 if let Err(err) = result {
154 let _ = std::fs::remove_file(&tmp.path);
155 return Err(err);
156 }
157 if let Err(err) = fail_test_stage("replace") {
158 let _ = std::fs::remove_file(&tmp.path);
159 return Err(err);
160 }
161 let replace_synced = match replace_temp_file(&tmp.path, path, durability) {
162 Ok(synced) => synced,
163 Err(err) => {
164 let _ = std::fs::remove_file(&tmp.path);
165 return Err(err);
166 }
167 };
168 let namespace_synced = match durability {
169 AtomicWriteDurability::Namespace => false,
170 AtomicWriteDurability::Flush => replace_synced || sync_parent_dir(path),
171 };
172 Ok(AtomicWriteReceipt {
173 file_synced: durability == AtomicWriteDurability::Flush,
174 namespace_synced,
175 })
176}
177
178fn write_and_finalize<F>(
179 tmp: &mut TempFile,
180 durability: AtomicWriteDurability,
181 write_fn: F,
182) -> io::Result<()>
183where
184 F: FnOnce(&mut BufWriter<File>) -> io::Result<()>,
185{
186 let file = tmp
187 .file
188 .take()
189 .ok_or_else(|| io::Error::other("atomic_io: temporary file handle was already consumed"))?;
190 let mut buf = BufWriter::new(file);
191 write_fn(&mut buf)?;
192 fail_test_stage("flush")?;
193 buf.flush()?;
194 let inner = buf.into_inner().map_err(|err| err.into_error())?;
195 if durability == AtomicWriteDurability::Flush {
196 inner.sync_all()?;
197 }
198 Ok(())
199}
200
201#[cfg(not(windows))]
202fn replace_temp_file(
203 temp: &Path,
204 destination: &Path,
205 _durability: AtomicWriteDurability,
206) -> io::Result<bool> {
207 std::fs::rename(temp, destination)?;
208 Ok(false)
209}
210
211#[cfg(windows)]
212fn replace_temp_file(
213 temp: &Path,
214 destination: &Path,
215 durability: AtomicWriteDurability,
216) -> io::Result<bool> {
217 use std::os::windows::ffi::OsStrExt;
218 use windows_sys::Win32::Storage::FileSystem::{
219 MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH,
220 };
221
222 let mut temp_wide: Vec<u16> = temp.as_os_str().encode_wide().collect();
223 temp_wide.push(0);
224 let mut destination_wide: Vec<u16> = destination.as_os_str().encode_wide().collect();
225 destination_wide.push(0);
226 let mut flags = MOVEFILE_REPLACE_EXISTING;
227 if durability == AtomicWriteDurability::Flush {
228 flags |= MOVEFILE_WRITE_THROUGH;
229 }
230 if unsafe { MoveFileExW(temp_wide.as_ptr(), destination_wide.as_ptr(), flags) } == 0 {
233 return Err(io::Error::last_os_error());
234 }
235 Ok(durability == AtomicWriteDurability::Flush)
236}
237
238fn sync_parent_dir(path: &Path) -> bool {
239 if let Some(parent) = path.parent() {
240 if parent.as_os_str().is_empty() {
241 return false;
242 }
243 if let Ok(dir) = OpenOptions::new().read(true).open(parent) {
244 return dir.sync_all().is_ok();
245 }
246 }
247 false
248}
249
250#[cfg(unix)]
253fn apply_mode(path: &Path, mode: u32) -> io::Result<()> {
254 use std::os::unix::fs::PermissionsExt;
255 std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))
256}
257
258#[cfg(not(unix))]
259fn apply_mode(_path: &Path, _mode: u32) -> io::Result<()> {
260 Ok(())
261}
262
263struct TempFile {
266 path: PathBuf,
267 file: Option<File>,
268}
269
270impl TempFile {
271 fn create(target: &Path, mode: Option<u32>) -> io::Result<Self> {
272 let parent = target.parent().ok_or_else(|| {
273 io::Error::new(
274 io::ErrorKind::InvalidInput,
275 format!(
276 "atomic_io: destination '{}' has no parent directory",
277 target.display()
278 ),
279 )
280 })?;
281 if !parent.as_os_str().is_empty() {
282 std::fs::create_dir_all(parent)?;
283 }
284 let file_name = target
285 .file_name()
286 .and_then(|value| value.to_str())
287 .unwrap_or("file");
288 let tmp_path = if parent.as_os_str().is_empty() {
289 PathBuf::from(format!(".{file_name}.{}.tmp", uuid::Uuid::now_v7()))
290 } else {
291 parent.join(format!(".{file_name}.{}.tmp", uuid::Uuid::now_v7()))
292 };
293 let file = OpenOptions::new()
294 .create_new(true)
295 .write(true)
296 .open(&tmp_path)?;
297 if let Some(mode) = mode {
298 if let Err(error) = apply_mode(&tmp_path, mode) {
299 drop(file);
300 let _ = std::fs::remove_file(&tmp_path);
301 return Err(error);
302 }
303 } else if let Ok(metadata) = std::fs::metadata(target) {
304 if let Err(error) = file.set_permissions(metadata.permissions()) {
305 drop(file);
306 let _ = std::fs::remove_file(&tmp_path);
307 return Err(error);
308 }
309 }
310 Ok(Self {
311 path: tmp_path,
312 file: Some(file),
313 })
314 }
315}
316
317#[cfg(test)]
318mod tests {
319 use super::*;
320
321 #[test]
322 fn writes_bytes_atomically() {
323 let dir = tempfile::tempdir().unwrap();
324 let path = dir.path().join("state.json");
325 atomic_write(&path, b"hello").unwrap();
326 assert_eq!(std::fs::read(&path).unwrap(), b"hello");
327 }
328
329 #[test]
330 fn overwrites_existing_file() {
331 let dir = tempfile::tempdir().unwrap();
332 let path = dir.path().join("state.json");
333 std::fs::write(&path, b"old").unwrap();
334 atomic_write(&path, b"new").unwrap();
335 assert_eq!(std::fs::read(&path).unwrap(), b"new");
336 }
337
338 #[test]
339 fn creates_missing_parent_dirs() {
340 let dir = tempfile::tempdir().unwrap();
341 let path = dir.path().join("a/b/c/state.json");
342 atomic_write(&path, b"deep").unwrap();
343 assert_eq!(std::fs::read(&path).unwrap(), b"deep");
344 }
345
346 #[test]
347 fn streaming_writer_finalizes_atomically() {
348 let dir = tempfile::tempdir().unwrap();
349 let path = dir.path().join("log.jsonl");
350 atomic_write_with(&path, |writer| {
351 writeln!(writer, "first")?;
352 writeln!(writer, "second")?;
353 Ok(())
354 })
355 .unwrap();
356 let read = std::fs::read_to_string(&path).unwrap();
357 assert_eq!(read, "first\nsecond\n");
358 }
359
360 #[test]
361 fn streaming_writer_cleans_up_on_error() {
362 let dir = tempfile::tempdir().unwrap();
363 let path = dir.path().join("state.json");
364 std::fs::write(&path, b"old").unwrap();
365 let err = atomic_write_with(&path, |writer| {
366 writer.write_all(b"partial")?;
367 Err(io::Error::other("nope"))
368 })
369 .unwrap_err();
370 assert_eq!(err.to_string(), "nope");
371 assert_eq!(std::fs::read(&path).unwrap(), b"old");
372 let leftover: Vec<_> = std::fs::read_dir(dir.path())
374 .unwrap()
375 .filter_map(Result::ok)
376 .filter(|entry| entry.file_name().to_string_lossy().ends_with(".tmp"))
377 .collect();
378 assert!(
379 leftover.is_empty(),
380 "tmp file should be cleaned up on error"
381 );
382 }
383
384 #[test]
385 fn flush_and_replace_failures_preserve_destination_and_clean_up() {
386 for stage in ["flush", "replace"] {
387 let dir = tempfile::tempdir().unwrap();
388 let path = dir.path().join("state.json");
389 std::fs::write(&path, b"old").unwrap();
390 TEST_FAILURE_STAGE.with(|value| value.set(Some(stage)));
391 let error = atomic_write(&path, b"new").unwrap_err();
392 TEST_FAILURE_STAGE.with(|value| value.set(None));
393
394 assert_eq!(error.to_string(), format!("injected {stage} failure"));
395 assert_eq!(std::fs::read(&path).unwrap(), b"old");
396 let leftovers: Vec<_> = std::fs::read_dir(dir.path())
397 .unwrap()
398 .filter_map(Result::ok)
399 .filter(|entry| entry.file_name().to_string_lossy().ends_with(".tmp"))
400 .collect();
401 assert!(leftovers.is_empty(), "{stage} left a temp file");
402 }
403 }
404
405 #[cfg(unix)]
406 #[test]
407 fn replacement_preserves_existing_permissions() {
408 use std::os::unix::fs::PermissionsExt;
409
410 let dir = tempfile::tempdir().unwrap();
411 let path = dir.path().join("state.json");
412 std::fs::write(&path, b"old").unwrap();
413 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o640)).unwrap();
414 atomic_write(&path, b"new").unwrap();
415 assert_eq!(
416 std::fs::metadata(path).unwrap().permissions().mode() & 0o777,
417 0o640
418 );
419 }
420
421 #[cfg(unix)]
422 #[test]
423 fn mode_is_applied_before_the_rename() {
424 use std::os::unix::fs::PermissionsExt;
425 let dir = tempfile::tempdir().unwrap();
426 let path = dir.path().join("credentials.json");
427 atomic_write_with_mode(&path, b"secret", 0o600).unwrap();
428 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
429 assert_eq!(mode & 0o777, 0o600, "credentials must be owner-only");
430 }
431
432 #[cfg(unix)]
433 #[test]
434 fn mode_survives_overwriting_a_loose_destination() {
435 use std::os::unix::fs::PermissionsExt;
436 let dir = tempfile::tempdir().unwrap();
437 let path = dir.path().join("credentials.json");
438 std::fs::write(&path, b"old").unwrap();
439 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
440 atomic_write_with_mode(&path, b"secret", 0o600).unwrap();
441 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
442 assert_eq!(mode & 0o777, 0o600);
443 }
444
445 #[test]
446 fn concurrent_writers_do_not_collide() {
447 let dir = tempfile::tempdir().unwrap();
448 let path = std::sync::Arc::new(dir.path().join("state.json"));
449 let mut handles = Vec::new();
450 for i in 0..16 {
451 let path = std::sync::Arc::clone(&path);
452 handles.push(std::thread::spawn(move || {
453 let payload = format!("writer-{i}");
454 atomic_write(&path, payload.as_bytes()).unwrap();
455 }));
456 }
457 for handle in handles {
458 handle.join().unwrap();
459 }
460 let final_contents = std::fs::read_to_string(&*path).unwrap();
463 assert!(
464 final_contents.starts_with("writer-") && final_contents.len() <= "writer-15".len(),
465 "unexpected final contents: {final_contents:?}"
466 );
467 }
468}