Skip to main content

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        Self::open(dest, false)
51    }
52
53    /// Like [`new`](Self::new), but restricts the temp file (and therefore
54    /// the renamed destination) to owner-read/write (0600) on Unix.
55    ///
56    /// Use this when writing files that contain sensitive material such as
57    /// plaintext secrets, so that the data is never world-readable — even
58    /// during the brief window between the initial `open` and the rename.
59    ///
60    /// # Errors
61    ///
62    /// Returns an error if the temporary file cannot be created or its
63    /// permissions cannot be set.
64    pub fn new_private(dest: impl AsRef<Path>) -> io::Result<Self> {
65        Self::open(dest, true)
66    }
67
68    #[cfg_attr(not(unix), allow(unused_variables))]
69    fn open(dest: impl AsRef<Path>, private: bool) -> io::Result<Self> {
70        let dest_path = dest.as_ref().to_path_buf();
71        let dir = dest_path.parent().unwrap_or(Path::new("."));
72        let base_name = dest_path
73            .file_name()
74            .and_then(|n| n.to_str())
75            .unwrap_or("out");
76
77        // Random suffix to prevent predictable temp file paths.
78        let random_suffix: u64 = rand::random();
79        let tmp_name = format!(".{}.{:016x}.tmp", base_name, random_suffix);
80        let tmp_path = dir.join(tmp_name);
81
82        // O_CREAT | O_EXCL: fails if the path already exists (no symlink following).
83        let file = OpenOptions::new()
84            .write(true)
85            .create_new(true)
86            .open(&tmp_path)?;
87
88        // Restrict permissions before any data is written so the file is
89        // never world-readable, even briefly.
90        #[cfg(unix)]
91        if private {
92            use std::os::unix::fs::PermissionsExt;
93            file.set_permissions(fs::Permissions::from_mode(0o600))?;
94        }
95
96        Ok(Self {
97            writer: BufWriter::new(file),
98            tmp_path,
99            dest_path,
100            finished: false,
101        })
102    }
103
104    /// Flush all buffers, fsync, and atomically rename to the final
105    /// destination.
106    ///
107    /// # Errors
108    ///
109    /// Returns an I/O error if flush, sync, or rename fails.  On
110    /// error, the temporary file is cleaned up on a best-effort basis.
111    pub fn finish(mut self) -> io::Result<()> {
112        // Flush the BufWriter.
113        self.writer.flush()?;
114
115        // Fsync the underlying file.
116        self.writer.get_ref().sync_all()?;
117
118        // Atomic rename.
119        if let Err(e) = fs::rename(&self.tmp_path, &self.dest_path) {
120            // Cleanup the temp file on rename failure.
121            let _ = fs::remove_file(&self.tmp_path);
122            return Err(e);
123        }
124
125        self.finished = true;
126        Ok(())
127    }
128
129    /// Return the path of the temporary file (useful for cleanup on
130    /// signal).
131    #[must_use]
132    pub fn tmp_path(&self) -> &Path {
133        &self.tmp_path
134    }
135
136    /// Return the final destination path.
137    #[must_use]
138    pub fn dest_path(&self) -> &Path {
139        &self.dest_path
140    }
141}
142
143impl Write for AtomicFileWriter {
144    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
145        self.writer.write(buf)
146    }
147
148    fn flush(&mut self) -> io::Result<()> {
149        self.writer.flush()
150    }
151}
152
153impl io::Seek for AtomicFileWriter {
154    fn seek(&mut self, pos: io::SeekFrom) -> io::Result<u64> {
155        self.writer.flush()?;
156        self.writer.get_mut().seek(pos)
157    }
158}
159
160impl Drop for AtomicFileWriter {
161    fn drop(&mut self) {
162        if !self.finished {
163            // Best-effort cleanup: remove the temporary file.
164            let _ = fs::remove_file(&self.tmp_path);
165        }
166    }
167}
168
169/// Write `data` to `dest` atomically.
170///
171/// Convenience wrapper around [`AtomicFileWriter`] for in-memory data.
172///
173/// # Errors
174///
175/// Returns [`std::io::Error`] if the file cannot be created, written,
176/// or renamed.
177pub fn atomic_write(dest: impl AsRef<Path>, data: &[u8]) -> io::Result<()> {
178    let mut writer = AtomicFileWriter::new(dest)?;
179    writer.write_all(data)?;
180    writer.finish()
181}
182
183/// Like [`atomic_write`] but creates the file with owner-only permissions
184/// (0600 on Unix).  Use for files containing plaintext secrets or other
185/// sensitive material.
186///
187/// # Errors
188///
189/// Returns an error if the file cannot be written or renamed into place.
190pub fn atomic_write_private(dest: impl AsRef<Path>, data: &[u8]) -> io::Result<()> {
191    let mut writer = AtomicFileWriter::new_private(dest)?;
192    writer.write_all(data)?;
193    writer.finish()
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199    use std::fs;
200
201    #[test]
202    fn atomic_write_creates_file() {
203        let dir = tempfile::tempdir().unwrap();
204        let dest = dir.path().join("output.txt");
205        atomic_write(&dest, b"hello world").unwrap();
206        assert_eq!(fs::read_to_string(&dest).unwrap(), "hello world");
207        // Temp file should not exist.
208        let tmp = dir.path().join("output.txt.tmp");
209        assert!(!tmp.exists());
210    }
211
212    #[test]
213    fn atomic_writer_drop_cleans_up() {
214        let dir = tempfile::tempdir().unwrap();
215        let dest = dir.path().join("output.txt");
216        {
217            let mut w = AtomicFileWriter::new(&dest).unwrap();
218            w.write_all(b"partial").unwrap();
219            // Drop without finish — should clean up temp.
220        }
221        assert!(!dest.exists(), "dest should not exist after aborted write");
222        let tmp = dir.path().join("output.txt.tmp");
223        assert!(!tmp.exists(), "temp file should be cleaned up");
224    }
225
226    #[test]
227    fn atomic_writer_streaming() {
228        let dir = tempfile::tempdir().unwrap();
229        let dest = dir.path().join("streamed.txt");
230        let mut w = AtomicFileWriter::new(&dest).unwrap();
231        for i in 0..100 {
232            writeln!(w, "line {}", i).unwrap();
233        }
234        w.finish().unwrap();
235        let content = fs::read_to_string(&dest).unwrap();
236        assert_eq!(content.lines().count(), 100);
237    }
238}