Skip to main content

scour_secrets/
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    /// `None` after `finish()` has consumed the writer (file closed before rename).
31    writer: Option<BufWriter<File>>,
32    /// Path to the temporary file.
33    tmp_path: PathBuf,
34    /// Final destination path.
35    dest_path: PathBuf,
36    /// Whether `finish()` has been called successfully.
37    finished: bool,
38}
39
40impl AtomicFileWriter {
41    /// Create a new atomic writer targeting `dest`.
42    ///
43    /// The temporary file is created with a random suffix in the same
44    /// directory as `dest`, using `O_CREAT | O_EXCL` to prevent
45    /// symlink-following attacks on shared filesystems.
46    ///
47    /// # Errors
48    ///
49    /// Returns an I/O error if the temporary file cannot be created.
50    pub fn new(dest: impl AsRef<Path>) -> io::Result<Self> {
51        Self::open(dest, false)
52    }
53
54    /// Like [`new`](Self::new), but restricts the temp file (and therefore
55    /// the renamed destination) to owner-read/write (0600) on Unix.
56    ///
57    /// Use this when writing files that contain sensitive material such as
58    /// plaintext secrets, so that the data is never world-readable — even
59    /// during the brief window between the initial `open` and the rename.
60    ///
61    /// # Errors
62    ///
63    /// Returns an error if the temporary file cannot be created or its
64    /// permissions cannot be set.
65    pub fn new_private(dest: impl AsRef<Path>) -> io::Result<Self> {
66        Self::open(dest, true)
67    }
68
69    #[cfg_attr(not(unix), allow(unused_variables))]
70    fn open(dest: impl AsRef<Path>, private: bool) -> io::Result<Self> {
71        let dest_path = dest.as_ref().to_path_buf();
72        let dir = dest_path.parent().unwrap_or(Path::new("."));
73        let base_name = dest_path
74            .file_name()
75            .and_then(|n| n.to_str())
76            .unwrap_or("out");
77
78        // Random suffix to prevent predictable temp file paths.
79        let random_suffix: u64 = rand::random();
80        let tmp_name = format!(".{}.{:016x}.tmp", base_name, random_suffix);
81        let tmp_path = dir.join(tmp_name);
82
83        // O_CREAT | O_EXCL: fails if the path already exists (no symlink following).
84        let file = OpenOptions::new()
85            .write(true)
86            .create_new(true)
87            .open(&tmp_path)?;
88
89        // Restrict permissions before any data is written so the file is
90        // never world-readable, even briefly.
91        #[cfg(unix)]
92        if private {
93            use std::os::unix::fs::PermissionsExt;
94            file.set_permissions(fs::Permissions::from_mode(0o600))?;
95        }
96
97        Ok(Self {
98            writer: Some(BufWriter::new(file)),
99            tmp_path,
100            dest_path,
101            finished: false,
102        })
103    }
104
105    /// Flush all buffers, fsync, and atomically rename to the final
106    /// destination.
107    ///
108    /// # Errors
109    ///
110    /// Returns an I/O error if flush, sync, or rename fails.  On
111    /// error, the temporary file is cleaned up on a best-effort basis.
112    pub fn finish(mut self) -> io::Result<()> {
113        // Unwrap the BufWriter, flushing any remaining buffer, then sync and
114        // explicitly close the file handle BEFORE the rename.  On Windows,
115        // keeping an open write handle across a rename can prevent subsequent
116        // readers from opening the destination path.
117        let mut writer = self
118            .writer
119            .take()
120            .expect("AtomicFileWriter::finish called after writer already consumed");
121        writer.flush()?;
122        let file = writer.into_inner().map_err(|e| e.into_error())?;
123        file.sync_all()?;
124        drop(file);
125
126        // Atomic rename.  On Windows, real-time AV (Defender), Search Indexer,
127        // or another process scanning the just-closed temp file can hold a
128        // transient lock that causes `MoveFileExW` to return ERROR_ACCESS_DENIED
129        // (or ERROR_SHARING_VIOLATION).  Retry with short backoff for up to
130        // ~1 second before giving up.
131        if let Err(e) = rename_with_retry(&self.tmp_path, &self.dest_path) {
132            // Cleanup the temp file on rename failure.
133            let _ = fs::remove_file(&self.tmp_path);
134            return Err(e);
135        }
136
137        self.finished = true;
138        Ok(())
139    }
140
141    /// Return the path of the temporary file (useful for cleanup on
142    /// signal).
143    #[must_use]
144    pub fn tmp_path(&self) -> &Path {
145        &self.tmp_path
146    }
147
148    /// Return the final destination path.
149    #[must_use]
150    pub fn dest_path(&self) -> &Path {
151        &self.dest_path
152    }
153}
154
155/// Rename `from` to `to`, retrying on Windows when the destination is
156/// transiently locked by another process (typically AV scanning the
157/// just-closed temp file).
158///
159/// `fs::rename` on Windows uses `MoveFileExW`, which can return
160/// `ERROR_ACCESS_DENIED` (5) or `ERROR_SHARING_VIOLATION` (32) when
161/// another process holds an open handle without `FILE_SHARE_DELETE`.
162/// Retry up to ~1 second with 50ms backoffs to ride out the typical
163/// AV scan window.
164///
165/// On non-Windows platforms this is a single `fs::rename` call.
166fn rename_with_retry(from: &Path, to: &Path) -> io::Result<()> {
167    #[cfg(not(windows))]
168    {
169        fs::rename(from, to)
170    }
171    #[cfg(windows)]
172    {
173        use std::thread::sleep;
174        use std::time::{Duration, Instant};
175
176        let deadline = Instant::now() + Duration::from_secs(1);
177        loop {
178            match fs::rename(from, to) {
179                Ok(()) => return Ok(()),
180                Err(e) => {
181                    let transient = matches!(
182                        e.raw_os_error(),
183                        Some(5)  /* ERROR_ACCESS_DENIED */
184                        | Some(32) /* ERROR_SHARING_VIOLATION */
185                    );
186                    if !transient || Instant::now() >= deadline {
187                        return Err(e);
188                    }
189                    sleep(Duration::from_millis(50));
190                }
191            }
192        }
193    }
194}
195
196impl Write for AtomicFileWriter {
197    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
198        self.writer
199            .as_mut()
200            .expect("write after AtomicFileWriter::finish")
201            .write(buf)
202    }
203
204    fn flush(&mut self) -> io::Result<()> {
205        self.writer
206            .as_mut()
207            .expect("flush after AtomicFileWriter::finish")
208            .flush()
209    }
210}
211
212impl io::Seek for AtomicFileWriter {
213    fn seek(&mut self, pos: io::SeekFrom) -> io::Result<u64> {
214        let writer = self
215            .writer
216            .as_mut()
217            .expect("seek after AtomicFileWriter::finish");
218        writer.flush()?;
219        writer.get_mut().seek(pos)
220    }
221}
222
223impl Drop for AtomicFileWriter {
224    fn drop(&mut self) {
225        if !self.finished {
226            // Close the file handle before removing the temp file so that
227            // on Windows the remove_file call is not blocked by an open handle.
228            drop(self.writer.take());
229            let _ = fs::remove_file(&self.tmp_path);
230        }
231    }
232}
233
234/// Write `data` to `dest` atomically.
235///
236/// Convenience wrapper around [`AtomicFileWriter`] for in-memory data.
237///
238/// # Errors
239///
240/// Returns [`std::io::Error`] if the file cannot be created, written,
241/// or renamed.
242pub fn atomic_write(dest: impl AsRef<Path>, data: &[u8]) -> io::Result<()> {
243    let mut writer = AtomicFileWriter::new(dest)?;
244    writer.write_all(data)?;
245    writer.finish()
246}
247
248/// Like [`atomic_write`] but creates the file with owner-only permissions
249/// (0600 on Unix).  Use for files containing plaintext secrets or other
250/// sensitive material.
251///
252/// # Errors
253///
254/// Returns an error if the file cannot be written or renamed into place.
255pub fn atomic_write_private(dest: impl AsRef<Path>, data: &[u8]) -> io::Result<()> {
256    let mut writer = AtomicFileWriter::new_private(dest)?;
257    writer.write_all(data)?;
258    writer.finish()
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264    use std::fs;
265
266    #[test]
267    fn atomic_write_creates_file() {
268        let dir = tempfile::tempdir().unwrap();
269        let dest = dir.path().join("output.txt");
270        atomic_write(&dest, b"hello world").unwrap();
271        assert_eq!(fs::read_to_string(&dest).unwrap(), "hello world");
272        // Temp file should not exist.
273        let tmp = dir.path().join("output.txt.tmp");
274        assert!(!tmp.exists());
275    }
276
277    #[test]
278    fn atomic_writer_drop_cleans_up() {
279        let dir = tempfile::tempdir().unwrap();
280        let dest = dir.path().join("output.txt");
281        {
282            let mut w = AtomicFileWriter::new(&dest).unwrap();
283            w.write_all(b"partial").unwrap();
284            // Drop without finish — should clean up temp.
285        }
286        assert!(!dest.exists(), "dest should not exist after aborted write");
287        let tmp = dir.path().join("output.txt.tmp");
288        assert!(!tmp.exists(), "temp file should be cleaned up");
289    }
290
291    #[test]
292    fn atomic_writer_streaming() {
293        let dir = tempfile::tempdir().unwrap();
294        let dest = dir.path().join("streamed.txt");
295        let mut w = AtomicFileWriter::new(&dest).unwrap();
296        for i in 0..100 {
297            writeln!(w, "line {}", i).unwrap();
298        }
299        w.finish().unwrap();
300        let content = fs::read_to_string(&dest).unwrap();
301        assert_eq!(content.lines().count(), 100);
302    }
303}