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}