sanitize_engine/atomic.rs
1//! Atomic file writes for crash-safe output.
2//!
3//! All output files are written via a temporary file alongside the final
4//! destination, flushed and fsynced, then atomically renamed into place.
5//! This guarantees that the final path either contains the complete, valid
6//! output or does not exist at all — partial or corrupt files are never
7//! left behind even if the process crashes or is interrupted.
8//!
9//! # Platform Notes
10//!
11//! - On POSIX systems, `std::fs::rename` is atomic within the same
12//! filesystem. The temporary file is created in the same directory as
13//! the destination to ensure they share a mount point.
14//! - `File::sync_all()` is called before rename to flush OS and
15//! hardware buffers.
16//! - On rename failure, the temporary file is cleaned up on a
17//! best-effort basis.
18
19use std::fs::{self, File, OpenOptions};
20use std::io::{self, BufWriter, Write};
21use std::path::{Path, PathBuf};
22
23/// An atomic file writer that writes to a temporary file and renames
24/// on completion.
25///
26/// If the writer is dropped without calling [`finish()`](Self::finish),
27/// the temporary file is removed (best-effort cleanup).
28pub struct AtomicFileWriter {
29 /// Buffered writer around the temporary file.
30 writer: BufWriter<File>,
31 /// Path to the temporary file.
32 tmp_path: PathBuf,
33 /// Final destination path.
34 dest_path: PathBuf,
35 /// Whether `finish()` has been called successfully.
36 finished: bool,
37}
38
39impl AtomicFileWriter {
40 /// Create a new atomic writer targeting `dest`.
41 ///
42 /// The temporary file is created with a random suffix in the same
43 /// directory as `dest`, using `O_CREAT | O_EXCL` to prevent
44 /// symlink-following attacks on shared filesystems.
45 ///
46 /// # Errors
47 ///
48 /// Returns an I/O error if the temporary file cannot be created.
49 pub fn new(dest: impl AsRef<Path>) -> io::Result<Self> {
50 let dest_path = dest.as_ref().to_path_buf();
51 let dir = dest_path.parent().unwrap_or(Path::new("."));
52 let base_name = dest_path
53 .file_name()
54 .and_then(|n| n.to_str())
55 .unwrap_or("out");
56
57 // Random suffix to prevent predictable temp file paths.
58 let random_suffix: u64 = rand::random();
59 let tmp_name = format!(".{}.{:016x}.tmp", base_name, random_suffix);
60 let tmp_path = dir.join(tmp_name);
61
62 // O_CREAT | O_EXCL: fails if the path already exists (no symlink following).
63 let file = OpenOptions::new()
64 .write(true)
65 .create_new(true)
66 .open(&tmp_path)?;
67 Ok(Self {
68 writer: BufWriter::new(file),
69 tmp_path,
70 dest_path,
71 finished: false,
72 })
73 }
74
75 /// Flush all buffers, fsync, and atomically rename to the final
76 /// destination.
77 ///
78 /// # Errors
79 ///
80 /// Returns an I/O error if flush, sync, or rename fails. On
81 /// error, the temporary file is cleaned up on a best-effort basis.
82 pub fn finish(mut self) -> io::Result<()> {
83 // Flush the BufWriter.
84 self.writer.flush()?;
85
86 // Fsync the underlying file.
87 self.writer.get_ref().sync_all()?;
88
89 // Atomic rename.
90 if let Err(e) = fs::rename(&self.tmp_path, &self.dest_path) {
91 // Cleanup the temp file on rename failure.
92 let _ = fs::remove_file(&self.tmp_path);
93 return Err(e);
94 }
95
96 self.finished = true;
97 Ok(())
98 }
99
100 /// Return the path of the temporary file (useful for cleanup on
101 /// signal).
102 #[must_use]
103 pub fn tmp_path(&self) -> &Path {
104 &self.tmp_path
105 }
106
107 /// Return the final destination path.
108 #[must_use]
109 pub fn dest_path(&self) -> &Path {
110 &self.dest_path
111 }
112}
113
114impl Write for AtomicFileWriter {
115 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
116 self.writer.write(buf)
117 }
118
119 fn flush(&mut self) -> io::Result<()> {
120 self.writer.flush()
121 }
122}
123
124impl io::Seek for AtomicFileWriter {
125 fn seek(&mut self, pos: io::SeekFrom) -> io::Result<u64> {
126 self.writer.flush()?;
127 self.writer.get_mut().seek(pos)
128 }
129}
130
131impl Drop for AtomicFileWriter {
132 fn drop(&mut self) {
133 if !self.finished {
134 // Best-effort cleanup: remove the temporary file.
135 let _ = fs::remove_file(&self.tmp_path);
136 }
137 }
138}
139
140/// Write `data` to `dest` atomically.
141///
142/// Convenience wrapper around [`AtomicFileWriter`] for in-memory data.
143///
144/// # Errors
145///
146/// Returns [`std::io::Error`] if the file cannot be created, written,
147/// or renamed.
148pub fn atomic_write(dest: impl AsRef<Path>, data: &[u8]) -> io::Result<()> {
149 let mut writer = AtomicFileWriter::new(dest)?;
150 writer.write_all(data)?;
151 writer.finish()
152}
153
154#[cfg(test)]
155mod tests {
156 use super::*;
157 use std::fs;
158
159 #[test]
160 fn atomic_write_creates_file() {
161 let dir = tempfile::tempdir().unwrap();
162 let dest = dir.path().join("output.txt");
163 atomic_write(&dest, b"hello world").unwrap();
164 assert_eq!(fs::read_to_string(&dest).unwrap(), "hello world");
165 // Temp file should not exist.
166 let tmp = dir.path().join("output.txt.tmp");
167 assert!(!tmp.exists());
168 }
169
170 #[test]
171 fn atomic_writer_drop_cleans_up() {
172 let dir = tempfile::tempdir().unwrap();
173 let dest = dir.path().join("output.txt");
174 {
175 let mut w = AtomicFileWriter::new(&dest).unwrap();
176 w.write_all(b"partial").unwrap();
177 // Drop without finish — should clean up temp.
178 }
179 assert!(!dest.exists(), "dest should not exist after aborted write");
180 let tmp = dir.path().join("output.txt.tmp");
181 assert!(!tmp.exists(), "temp file should be cleaned up");
182 }
183
184 #[test]
185 fn atomic_writer_streaming() {
186 let dir = tempfile::tempdir().unwrap();
187 let dest = dir.path().join("streamed.txt");
188 let mut w = AtomicFileWriter::new(&dest).unwrap();
189 for i in 0..100 {
190 writeln!(w, "line {}", i).unwrap();
191 }
192 w.finish().unwrap();
193 let content = fs::read_to_string(&dest).unwrap();
194 assert_eq!(content.lines().count(), 100);
195 }
196}