Skip to main content

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}