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 windows_sys::Win32::Storage::FileSystem::{
218 MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH,
219 };
220
221 let temp_wide = crate::windows_path::wide_maybe_verbatim(temp);
222 let destination_wide = crate::windows_path::wide_maybe_verbatim(destination);
223 let mut flags = MOVEFILE_REPLACE_EXISTING;
224 if durability == AtomicWriteDurability::Flush {
225 flags |= MOVEFILE_WRITE_THROUGH;
226 }
227
228 const ERROR_ACCESS_DENIED: i32 = 5;
238 const ERROR_SHARING_VIOLATION: i32 = 32;
239 const MAX_ATTEMPTS: u32 = 10;
240 let mut backoff = std::time::Duration::from_millis(1);
241 for attempt in 1..=MAX_ATTEMPTS {
242 if unsafe { MoveFileExW(temp_wide.as_ptr(), destination_wide.as_ptr(), flags) } != 0 {
245 return Ok(durability == AtomicWriteDurability::Flush);
246 }
247 let error = io::Error::last_os_error();
248 let retryable = matches!(
249 error.raw_os_error(),
250 Some(ERROR_SHARING_VIOLATION | ERROR_ACCESS_DENIED)
251 );
252 if !retryable || attempt == MAX_ATTEMPTS {
253 return Err(error);
254 }
255 std::thread::sleep(backoff);
256 backoff = (backoff * 2).min(std::time::Duration::from_millis(50));
257 }
258 unreachable!("the loop returns on the final attempt")
259}
260
261fn sync_parent_dir(path: &Path) -> bool {
262 if let Some(parent) = path.parent() {
263 if parent.as_os_str().is_empty() {
264 return false;
265 }
266 if let Ok(dir) = OpenOptions::new().read(true).open(parent) {
267 return dir.sync_all().is_ok();
268 }
269 }
270 false
271}
272
273#[cfg(unix)]
276fn apply_mode(path: &Path, mode: u32) -> io::Result<()> {
277 use std::os::unix::fs::PermissionsExt;
278 std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))
279}
280
281#[cfg(not(unix))]
282fn apply_mode(_path: &Path, _mode: u32) -> io::Result<()> {
283 Ok(())
284}
285
286struct TempFile {
289 path: PathBuf,
290 file: Option<File>,
291}
292
293const TEMP_STEM_MAX: usize = 16;
295
296fn temp_sibling_name(file_name: &str) -> String {
308 let stem: String = file_name.chars().take(TEMP_STEM_MAX).collect();
309 format!(".{stem}.{}.tmp", uuid::Uuid::now_v7().simple())
310}
311
312impl TempFile {
313 fn create(target: &Path, mode: Option<u32>) -> io::Result<Self> {
314 let parent = target.parent().ok_or_else(|| {
315 io::Error::new(
316 io::ErrorKind::InvalidInput,
317 format!(
318 "atomic_io: destination '{}' has no parent directory",
319 target.display()
320 ),
321 )
322 })?;
323 if !parent.as_os_str().is_empty() {
324 std::fs::create_dir_all(parent)?;
325 }
326 let file_name = target
327 .file_name()
328 .and_then(|value| value.to_str())
329 .unwrap_or("file");
330 let tmp_name = temp_sibling_name(file_name);
331 let tmp_path = if parent.as_os_str().is_empty() {
332 PathBuf::from(tmp_name)
333 } else {
334 parent.join(tmp_name)
335 };
336 let file = OpenOptions::new()
337 .create_new(true)
338 .write(true)
339 .open(&tmp_path)?;
340 if let Some(mode) = mode {
341 if let Err(error) = apply_mode(&tmp_path, mode) {
342 drop(file);
343 let _ = std::fs::remove_file(&tmp_path);
344 return Err(error);
345 }
346 } else if let Ok(metadata) = std::fs::metadata(target) {
347 if let Err(error) = file.set_permissions(metadata.permissions()) {
348 drop(file);
349 let _ = std::fs::remove_file(&tmp_path);
350 return Err(error);
351 }
352 }
353 Ok(Self {
354 path: tmp_path,
355 file: Some(file),
356 })
357 }
358}
359
360#[cfg(test)]
361mod tests {
362 use super::*;
363
364 #[test]
365 fn temp_sibling_name_is_length_bounded_regardless_of_target_name() {
366 let bound = 1 + TEMP_STEM_MAX + 1 + 32 + 4; for name in ["s", "state.json", &"a".repeat(64), &"z".repeat(4096)] {
371 let temp = temp_sibling_name(name);
372 assert!(
373 temp.len() <= bound,
374 "temp name {:?} (len {}) exceeds bound {bound}",
375 temp,
376 temp.len()
377 );
378 assert!(temp.starts_with('.') && temp.ends_with(".tmp"));
379 }
380 }
381
382 #[test]
383 fn atomic_write_succeeds_for_a_long_target_file_name() {
384 let dir = tempfile::tempdir().unwrap();
387 let path = dir.path().join("a".repeat(200));
388 atomic_write(&path, b"payload").unwrap();
389 assert_eq!(std::fs::read(&path).unwrap(), b"payload");
390 }
391
392 #[test]
393 fn writes_bytes_atomically() {
394 let dir = tempfile::tempdir().unwrap();
395 let path = dir.path().join("state.json");
396 atomic_write(&path, b"hello").unwrap();
397 assert_eq!(std::fs::read(&path).unwrap(), b"hello");
398 }
399
400 #[test]
401 fn overwrites_existing_file() {
402 let dir = tempfile::tempdir().unwrap();
403 let path = dir.path().join("state.json");
404 std::fs::write(&path, b"old").unwrap();
405 atomic_write(&path, b"new").unwrap();
406 assert_eq!(std::fs::read(&path).unwrap(), b"new");
407 }
408
409 #[test]
410 fn creates_missing_parent_dirs() {
411 let dir = tempfile::tempdir().unwrap();
412 let path = dir.path().join("a/b/c/state.json");
413 atomic_write(&path, b"deep").unwrap();
414 assert_eq!(std::fs::read(&path).unwrap(), b"deep");
415 }
416
417 #[test]
418 fn streaming_writer_finalizes_atomically() {
419 let dir = tempfile::tempdir().unwrap();
420 let path = dir.path().join("log.jsonl");
421 atomic_write_with(&path, |writer| {
422 writeln!(writer, "first")?;
423 writeln!(writer, "second")?;
424 Ok(())
425 })
426 .unwrap();
427 let read = std::fs::read_to_string(&path).unwrap();
428 assert_eq!(read, "first\nsecond\n");
429 }
430
431 #[test]
432 fn streaming_writer_cleans_up_on_error() {
433 let dir = tempfile::tempdir().unwrap();
434 let path = dir.path().join("state.json");
435 std::fs::write(&path, b"old").unwrap();
436 let err = atomic_write_with(&path, |writer| {
437 writer.write_all(b"partial")?;
438 Err(io::Error::other("nope"))
439 })
440 .unwrap_err();
441 assert_eq!(err.to_string(), "nope");
442 assert_eq!(std::fs::read(&path).unwrap(), b"old");
443 let leftover: Vec<_> = std::fs::read_dir(dir.path())
445 .unwrap()
446 .filter_map(Result::ok)
447 .filter(|entry| entry.file_name().to_string_lossy().ends_with(".tmp"))
448 .collect();
449 assert!(
450 leftover.is_empty(),
451 "tmp file should be cleaned up on error"
452 );
453 }
454
455 #[test]
456 fn flush_and_replace_failures_preserve_destination_and_clean_up() {
457 for stage in ["flush", "replace"] {
458 let dir = tempfile::tempdir().unwrap();
459 let path = dir.path().join("state.json");
460 std::fs::write(&path, b"old").unwrap();
461 TEST_FAILURE_STAGE.with(|value| value.set(Some(stage)));
462 let error = atomic_write(&path, b"new").unwrap_err();
463 TEST_FAILURE_STAGE.with(|value| value.set(None));
464
465 assert_eq!(error.to_string(), format!("injected {stage} failure"));
466 assert_eq!(std::fs::read(&path).unwrap(), b"old");
467 let leftovers: Vec<_> = std::fs::read_dir(dir.path())
468 .unwrap()
469 .filter_map(Result::ok)
470 .filter(|entry| entry.file_name().to_string_lossy().ends_with(".tmp"))
471 .collect();
472 assert!(leftovers.is_empty(), "{stage} left a temp file");
473 }
474 }
475
476 #[cfg(unix)]
477 #[test]
478 fn replacement_preserves_existing_permissions() {
479 use std::os::unix::fs::PermissionsExt;
480
481 let dir = tempfile::tempdir().unwrap();
482 let path = dir.path().join("state.json");
483 std::fs::write(&path, b"old").unwrap();
484 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o640)).unwrap();
485 atomic_write(&path, b"new").unwrap();
486 assert_eq!(
487 std::fs::metadata(path).unwrap().permissions().mode() & 0o777,
488 0o640
489 );
490 }
491
492 #[cfg(unix)]
493 #[test]
494 fn mode_is_applied_before_the_rename() {
495 use std::os::unix::fs::PermissionsExt;
496 let dir = tempfile::tempdir().unwrap();
497 let path = dir.path().join("credentials.json");
498 atomic_write_with_mode(&path, b"secret", 0o600).unwrap();
499 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
500 assert_eq!(mode & 0o777, 0o600, "credentials must be owner-only");
501 }
502
503 #[cfg(unix)]
504 #[test]
505 fn mode_survives_overwriting_a_loose_destination() {
506 use std::os::unix::fs::PermissionsExt;
507 let dir = tempfile::tempdir().unwrap();
508 let path = dir.path().join("credentials.json");
509 std::fs::write(&path, b"old").unwrap();
510 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
511 atomic_write_with_mode(&path, b"secret", 0o600).unwrap();
512 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
513 assert_eq!(mode & 0o777, 0o600);
514 }
515
516 #[test]
517 fn concurrent_writers_do_not_collide() {
518 let dir = tempfile::tempdir().unwrap();
519 let path = std::sync::Arc::new(dir.path().join("state.json"));
520 let mut handles = Vec::new();
521 for i in 0..16 {
522 let path = std::sync::Arc::clone(&path);
523 handles.push(std::thread::spawn(move || {
524 let payload = format!("writer-{i}");
525 atomic_write(&path, payload.as_bytes()).unwrap();
526 }));
527 }
528 for handle in handles {
529 handle.join().unwrap();
530 }
531 let final_contents = std::fs::read_to_string(&*path).unwrap();
534 assert!(
535 final_contents.starts_with("writer-") && final_contents.len() <= "writer-15".len(),
536 "unexpected final contents: {final_contents:?}"
537 );
538 }
539}