mod_tempdir/named_file.rs
1//! Temporary file management. See [`NamedTempFile`].
2//!
3//! Companion module to the crate root, which owns [`crate::TempDir`].
4//! Both types share the internal [`crate::unique_name`] generator so
5//! the `mod-rand` feature controls naming for files and directories
6//! in lockstep.
7
8use std::io;
9use std::path::{Path, PathBuf};
10
11use crate::unique_name;
12
13/// A temporary file that auto-deletes when dropped.
14///
15/// Companion to [`crate::TempDir`]. Where `TempDir` manages a
16/// directory, `NamedTempFile` manages a single zero-byte file at a
17/// fresh path under the OS temp location. The caller reopens the
18/// path with [`std::fs::OpenOptions`] (or any other API) when ready
19/// to write or read.
20///
21/// The default basename is `.tmpfile-{pid}-{name12}`, intentionally
22/// distinct from [`TempDir`](crate::TempDir)'s `.tmp-{pid}-{name12}`
23/// so an operator inspecting the OS temp location can tell the two
24/// apart at a glance. The 12 trailing characters use the same
25/// Crockford base32 generator as `TempDir`, so the optional
26/// `mod-rand` feature controls both types in lockstep. The embedded
27/// PID lets [`cleanup_orphans`](crate::cleanup_orphans) identify
28/// files left behind by crashed processes.
29///
30/// # Example
31///
32/// ```no_run
33/// use mod_tempdir::NamedTempFile;
34/// use std::io::Write;
35///
36/// let f = NamedTempFile::new().unwrap();
37/// let mut handle = std::fs::OpenOptions::new()
38/// .write(true)
39/// .open(f.path())
40/// .unwrap();
41/// handle.write_all(b"hello").unwrap();
42/// drop(handle);
43/// // `f` is deleted automatically when it goes out of scope.
44/// ```
45///
46/// # Cleanup semantics
47///
48/// Drop calls [`std::fs::remove_file`] best-effort. A failure (file
49/// already gone, permission denied, or a still-open handle on
50/// Windows) is intentionally swallowed: a `Drop` impl must not
51/// panic. Use [`NamedTempFile::persist`] to keep the file alive past
52/// drop.
53///
54/// # Windows handle-lock caveat
55///
56/// On Windows, [`std::fs::remove_file`] returns
57/// `ERROR_SHARING_VIOLATION` (surfaced in Rust as
58/// [`std::io::ErrorKind::PermissionDenied`]) if any process still
59/// holds an open handle to the file at the moment of Drop. The
60/// library does not retry. Drop must not block, and retries cannot
61/// force-close a caller-owned handle. The file is left on disk in
62/// that case. Close any handles you open against
63/// [`NamedTempFile::path`] before the `NamedTempFile` drops to
64/// guarantee cleanup.
65#[derive(Debug)]
66pub struct NamedTempFile {
67 path: PathBuf,
68 cleanup_on_drop: bool,
69}
70
71impl NamedTempFile {
72 /// Create a new temporary file in the system's temp location
73 /// (`/tmp` on Linux/macOS, `%TEMP%` on Windows).
74 ///
75 /// The basename is `.tmpfile-{pid}-{name12}` where `{pid}` is
76 /// the current process ID (used by
77 /// [`cleanup_orphans`](crate::cleanup_orphans) to identify
78 /// entries left behind by crashed processes) and `{name12}` is a
79 /// 12-character Crockford base32 string from the shared name
80 /// generator. The file is materialized via
81 /// [`std::fs::File::create`]; the returned `File` handle is
82 /// closed before this function returns, so the caller starts
83 /// from a clean slate.
84 ///
85 /// With the `mod-rand` feature enabled, the name fragment comes
86 /// from `mod_rand::tier2::unique_name`. Without it, from the
87 /// same internal process-unique mixer as
88 /// [`TempDir::new`](crate::TempDir::new).
89 ///
90 /// # Errors
91 ///
92 /// Returns the underlying [`io::Error`] from
93 /// [`std::fs::File::create`] if the file cannot be created.
94 ///
95 /// # Example
96 ///
97 /// ```no_run
98 /// use mod_tempdir::NamedTempFile;
99 ///
100 /// let f = NamedTempFile::new().unwrap();
101 /// assert!(f.path().is_file());
102 /// ```
103 pub fn new() -> io::Result<Self> {
104 let name = unique_name(12);
105 let pid = std::process::id();
106 let path = std::env::temp_dir().join(format!(".tmpfile-{pid}-{name}"));
107 std::fs::File::create(&path)?;
108 Ok(Self {
109 path,
110 cleanup_on_drop: true,
111 })
112 }
113
114 /// Create a new temporary file with the given prefix.
115 ///
116 /// The final basename is `{prefix}-{12-char-name}`. The prefix
117 /// is joined verbatim and is the caller's responsibility to
118 /// sanitize.
119 ///
120 /// # Errors
121 ///
122 /// Returns the underlying [`io::Error`] from
123 /// [`std::fs::File::create`] if the file cannot be created.
124 ///
125 /// # Example
126 ///
127 /// ```no_run
128 /// use mod_tempdir::NamedTempFile;
129 ///
130 /// let f = NamedTempFile::with_prefix("my-fixture").unwrap();
131 /// assert!(f
132 /// .path()
133 /// .file_name()
134 /// .unwrap()
135 /// .to_string_lossy()
136 /// .starts_with("my-fixture-"));
137 /// ```
138 pub fn with_prefix(prefix: &str) -> io::Result<Self> {
139 let name = unique_name(12);
140 let path = std::env::temp_dir().join(format!("{prefix}-{name}"));
141 std::fs::File::create(&path)?;
142 Ok(Self {
143 path,
144 cleanup_on_drop: true,
145 })
146 }
147
148 /// Return the path of this temporary file.
149 ///
150 /// # Example
151 ///
152 /// ```no_run
153 /// use mod_tempdir::NamedTempFile;
154 ///
155 /// let f = NamedTempFile::new().unwrap();
156 /// let mut handle = std::fs::OpenOptions::new()
157 /// .write(true)
158 /// .open(f.path())
159 /// .unwrap();
160 /// # let _ = handle;
161 /// ```
162 pub fn path(&self) -> &Path {
163 &self.path
164 }
165
166 /// Consume this `NamedTempFile` and return the path, disabling
167 /// cleanup on drop. The file will persist.
168 ///
169 /// Use this when you want to inspect contents after a test
170 /// fails.
171 ///
172 /// # Example
173 ///
174 /// ```no_run
175 /// use mod_tempdir::NamedTempFile;
176 ///
177 /// let f = NamedTempFile::new().unwrap();
178 /// let kept = f.persist();
179 /// // `kept` survives past the original `f` going out of scope.
180 /// # std::fs::remove_file(&kept).unwrap();
181 /// ```
182 pub fn persist(mut self) -> PathBuf {
183 self.cleanup_on_drop = false;
184 self.path.clone()
185 }
186
187 /// Return `true` if the file will be deleted on drop.
188 ///
189 /// # Example
190 ///
191 /// ```no_run
192 /// use mod_tempdir::NamedTempFile;
193 ///
194 /// let f = NamedTempFile::new().unwrap();
195 /// assert!(f.cleanup_on_drop());
196 /// ```
197 pub fn cleanup_on_drop(&self) -> bool {
198 self.cleanup_on_drop
199 }
200
201 /// Atomically move this file to `target` with crash-safety
202 /// guarantees, then disable cleanup on drop.
203 ///
204 /// Performs the canonical "atomic durable write" sequence:
205 ///
206 /// 1. `fsync` the temp file contents to disk
207 /// ([`std::fs::File::sync_all`]).
208 /// 2. Atomically rename the temp file onto `target` via
209 /// [`std::fs::rename`]. On Unix this is `rename(2)`; on
210 /// Windows it is `MoveFileExW` with `MOVEFILE_REPLACE_EXISTING`.
211 /// Both are atomic within a single filesystem.
212 /// 3. Best-effort `fsync` of the target's parent directory so
213 /// the rename itself survives a crash. Failures here are
214 /// silent, matching the rest of the crate's durability story.
215 ///
216 /// On success, the temp file no longer exists at
217 /// [`path`](Self::path); the data lives at `target`. Cleanup on
218 /// drop is disabled and the consumed `self` does not attempt
219 /// removal.
220 ///
221 /// # Errors
222 ///
223 /// On any failure (fsync, rename, etc.), the temp file is
224 /// **preserved** on disk and returned to the caller via
225 /// [`PersistAtomicError::file`]. The caller can inspect the
226 /// underlying [`io::Error`], optionally fix the cause (e.g.,
227 /// create the missing parent directory), and retry. This is
228 /// the standard `tempfile`-crate pattern and matches the
229 /// data-integrity guarantee that a failed atomic-persist must
230 /// never lose the source.
231 ///
232 /// Common error causes:
233 /// - Target's parent directory does not exist.
234 /// - Target's parent is on a different filesystem (`EXDEV` on
235 /// Unix, `ERROR_NOT_SAME_DEVICE` on Windows).
236 /// - Permission denied at the target location.
237 /// - Source temp file already removed (race with cleanup).
238 ///
239 /// # Cross-filesystem behaviour
240 ///
241 /// `rename` is atomic only within a single filesystem. If
242 /// `target` is on a different mount than the temp directory,
243 /// `rename` will return `EXDEV` on Unix or the equivalent on
244 /// Windows. Callers wanting cross-filesystem persistence must
245 /// either pick a `target` on the same filesystem as
246 /// [`std::env::temp_dir`] or do their own copy-and-delete.
247 ///
248 /// # Example
249 ///
250 /// ```no_run
251 /// use mod_tempdir::NamedTempFile;
252 /// use std::io::Write;
253 ///
254 /// let f = NamedTempFile::new().unwrap();
255 /// {
256 /// let mut h = std::fs::OpenOptions::new()
257 /// .write(true)
258 /// .open(f.path())
259 /// .unwrap();
260 /// h.write_all(b"finalized payload").unwrap();
261 /// }
262 ///
263 /// let target = std::env::temp_dir().join("finalized.bin");
264 /// let landed = f.persist_atomic(&target).unwrap();
265 /// assert_eq!(landed, target);
266 /// # std::fs::remove_file(&landed).unwrap();
267 /// ```
268 ///
269 /// Retry pattern on recoverable error:
270 ///
271 /// ```no_run
272 /// use mod_tempdir::NamedTempFile;
273 ///
274 /// let mut f = NamedTempFile::new().unwrap();
275 /// let target = std::env::temp_dir().join("retry-target");
276 /// loop {
277 /// match f.persist_atomic(&target) {
278 /// Ok(_landed) => break,
279 /// Err(e) => {
280 /// eprintln!("persist failed: {}", e.error);
281 /// // ... fix the underlying issue ...
282 /// f = e.file; // recover the temp file and try again
283 /// # break;
284 /// }
285 /// }
286 /// }
287 /// # std::fs::remove_file(&target).ok();
288 /// ```
289 pub fn persist_atomic(
290 mut self,
291 target: impl AsRef<Path>,
292 ) -> Result<PathBuf, PersistAtomicError> {
293 let target = target.as_ref();
294
295 // Step 1: fsync the source. A writable handle is needed for
296 // `sync_all` semantics on every platform we support. If
297 // either the open or the fsync fails, return `self` to the
298 // caller so the temp file is preserved.
299 match std::fs::OpenOptions::new().write(true).open(&self.path) {
300 Ok(handle) => {
301 if let Err(error) = handle.sync_all() {
302 return Err(PersistAtomicError { error, file: self });
303 }
304 }
305 Err(error) => return Err(PersistAtomicError { error, file: self }),
306 }
307
308 // Step 2: atomic rename. `std::fs::rename` is POSIX
309 // `rename(2)` on Unix and `MoveFileExW` with
310 // `MOVEFILE_REPLACE_EXISTING` on Windows. Both are atomic
311 // within a single filesystem.
312 if let Err(error) = std::fs::rename(&self.path, target) {
313 return Err(PersistAtomicError { error, file: self });
314 }
315
316 // Step 3: best-effort fsync of the target's parent directory
317 // so the rename itself is durable across a crash. Failures
318 // are intentionally silent, matching the Drop philosophy.
319 if let Some(parent) = target.parent() {
320 let _ = sync_directory(parent);
321 }
322
323 // The temp file no longer exists at `self.path`. Disable
324 // cleanup explicitly so Drop does not attempt a no-op
325 // `remove_file` against a path that has moved.
326 self.cleanup_on_drop = false;
327
328 Ok(target.to_path_buf())
329 }
330}
331
332/// Error returned by [`NamedTempFile::persist_atomic`] when the
333/// atomic-persist sequence fails partway through.
334///
335/// The underlying [`io::Error`] is in [`PersistAtomicError::error`]
336/// and the original [`NamedTempFile`] is in
337/// [`PersistAtomicError::file`], preserved so the caller can retry
338/// or fall back to other cleanup logic without losing the source.
339///
340/// # Example
341///
342/// ```no_run
343/// use mod_tempdir::{NamedTempFile, PersistAtomicError};
344///
345/// fn persist_or_log(f: NamedTempFile, target: &str) {
346/// match f.persist_atomic(target) {
347/// Ok(landed) => println!("persisted to {}", landed.display()),
348/// Err(PersistAtomicError { error, file }) => {
349/// eprintln!("persist failed: {error}");
350/// // `file` is the original NamedTempFile, intact.
351/// // It will be cleaned up on Drop, or you can retry.
352/// drop(file);
353/// }
354/// }
355/// }
356/// ```
357#[derive(Debug)]
358pub struct PersistAtomicError {
359 /// The underlying I/O error that aborted the atomic persist.
360 pub error: io::Error,
361 /// The `NamedTempFile` that would have been moved, returned
362 /// intact so the caller can retry or drop it.
363 pub file: NamedTempFile,
364}
365
366impl std::fmt::Display for PersistAtomicError {
367 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
368 write!(f, "atomic persist failed: {}", self.error)
369 }
370}
371
372impl std::error::Error for PersistAtomicError {
373 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
374 Some(&self.error)
375 }
376}
377
378impl From<PersistAtomicError> for io::Error {
379 fn from(e: PersistAtomicError) -> Self {
380 e.error
381 }
382}
383
384/// Best-effort fsync of a directory. Used by
385/// [`NamedTempFile::persist_atomic`] to make the rename durable.
386///
387/// Linux / macOS: open the directory and call `sync_all` (`fsync` on
388/// the directory fd).
389///
390/// Windows: open with `FILE_FLAG_BACKUP_SEMANTICS` (required to get
391/// a directory handle) and call `sync_all`. Directory fsync semantics
392/// on NTFS are less load-bearing than on Unix; this is still
393/// best-effort.
394#[cfg(unix)]
395fn sync_directory(path: &Path) -> io::Result<()> {
396 let dir = std::fs::File::open(path)?;
397 dir.sync_all()
398}
399
400#[cfg(windows)]
401fn sync_directory(path: &Path) -> io::Result<()> {
402 use std::os::windows::fs::OpenOptionsExt;
403 const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000;
404 let dir = std::fs::OpenOptions::new()
405 .write(true)
406 .custom_flags(FILE_FLAG_BACKUP_SEMANTICS)
407 .open(path)?;
408 dir.sync_all()
409}
410
411#[cfg(not(any(unix, windows)))]
412fn sync_directory(_path: &Path) -> io::Result<()> {
413 // No portable directory fsync primitive available on this
414 // platform; rename atomicity is the only durability guarantee.
415 Ok(())
416}
417
418impl Drop for NamedTempFile {
419 fn drop(&mut self) {
420 if self.cleanup_on_drop {
421 // Cleanup is best-effort and must not panic in Drop.
422 // Filesystem errors (file in use on Windows, permission
423 // denied, file already gone) are intentionally swallowed
424 // per REPS section 5.
425 let _ = std::fs::remove_file(&self.path);
426 }
427 }
428}
429
430#[cfg(test)]
431mod tests {
432 use super::*;
433
434 #[test]
435 fn creates_file() {
436 let f = NamedTempFile::new().unwrap();
437 assert!(f.path().exists());
438 assert!(f.path().is_file());
439 }
440
441 #[test]
442 fn auto_cleanup() {
443 let path = {
444 let f = NamedTempFile::new().unwrap();
445 f.path().to_path_buf()
446 };
447 assert!(!path.exists());
448 }
449
450 #[test]
451 fn persist_disables_cleanup() {
452 let f = NamedTempFile::new().unwrap();
453 let path = f.persist();
454 assert!(path.exists());
455 std::fs::remove_file(&path).unwrap();
456 }
457
458 #[test]
459 fn with_prefix_works() {
460 let f = NamedTempFile::with_prefix("named").unwrap();
461 let name = f.path().file_name().unwrap().to_string_lossy();
462 assert!(name.starts_with("named-"));
463 }
464
465 #[test]
466 fn two_files_unique() {
467 let a = NamedTempFile::new().unwrap();
468 let b = NamedTempFile::new().unwrap();
469 assert_ne!(a.path(), b.path());
470 }
471}