Skip to main content

rskit_fs/
temp.rs

1//! Temporary file and path helpers.
2//!
3//! Temp file and directory creation, cloning, persistence, and file-writing
4//! helpers use blocking filesystem I/O. When calling them from async contexts,
5//! run them through `tokio::task::spawn_blocking` or an equivalent blocking
6//! executor boundary.
7#![allow(clippy::needless_pass_by_value)]
8
9use std::path::{Path, PathBuf};
10use std::sync::atomic::{AtomicU64, Ordering};
11use std::time::{SystemTime, UNIX_EPOCH};
12
13use rskit_errors::{AppError, AppResult, ErrorCode};
14
15use crate::path::parent_dir;
16
17static NEXT_TEMP_PATH: AtomicU64 = AtomicU64::new(1);
18
19/// Managed temporary file. Deleted when the inner handle is dropped.
20#[derive(Debug)]
21pub struct TempFile {
22    inner: tempfile::NamedTempFile,
23}
24
25impl TempFile {
26    /// Create a new temporary file in the system temp directory.
27    pub fn new() -> AppResult<Self> {
28        let inner = tempfile::NamedTempFile::new().map_err(create_temp_file_error)?;
29        Ok(Self { inner })
30    }
31
32    /// Create a temporary file with the given extension.
33    pub fn with_extension(ext: &str) -> AppResult<Self> {
34        let inner = tempfile::Builder::new()
35            .suffix(&format!(".{ext}"))
36            .tempfile()
37            .map_err(|error| create_temp_file_with_extension_error(ext, error))?;
38        Ok(Self { inner })
39    }
40
41    /// Create a temporary file in the given directory.
42    pub fn in_dir(dir: &Path) -> AppResult<Self> {
43        let inner = tempfile::NamedTempFile::new_in(dir)
44            .map_err(|error| create_temp_file_in_dir_error(dir, error))?;
45        Ok(Self { inner })
46    }
47
48    /// Create a temporary file in the given directory with the given extension.
49    pub fn in_dir_with_extension(dir: &Path, ext: &str) -> AppResult<Self> {
50        let inner = tempfile::Builder::new()
51            .suffix(&format!(".{ext}"))
52            .tempfile_in(dir)
53            .map_err(|error| create_temp_file_in_dir_with_extension_error(dir, ext, error))?;
54        Ok(Self { inner })
55    }
56
57    /// The path to this temporary file.
58    #[must_use]
59    pub fn path(&self) -> &Path {
60        self.inner.path()
61    }
62
63    /// Create an independent copy of this temporary file.
64    pub fn try_clone(&self) -> AppResult<Self> {
65        let new = Self::new()?;
66        std::fs::copy(self.path(), new.path())
67            .map_err(|error| AppError::internal(error).context("clone temp file"))?;
68        Ok(new)
69    }
70
71    /// Persist this temporary file to the given target path.
72    ///
73    /// The file will no longer be auto-deleted.
74    pub fn persist(self, target: impl AsRef<Path>) -> AppResult<PathBuf> {
75        let target = target.as_ref().to_path_buf();
76        self.inner.persist(&target).map_err(|error| {
77            AppError::new(
78                ErrorCode::Internal,
79                format!(
80                    "failed to persist temp file to {}: {error}",
81                    target.display()
82                ),
83            )
84        })?;
85        Ok(target)
86    }
87}
88
89/// Managed temporary directory. All contents are cleaned up on drop.
90pub struct TempDir {
91    inner: tempfile::TempDir,
92}
93
94impl TempDir {
95    /// Create a new temporary directory.
96    pub fn new() -> AppResult<Self> {
97        let inner = tempfile::TempDir::new().map_err(create_temp_dir_error)?;
98        Ok(Self { inner })
99    }
100
101    /// The path to this temporary directory.
102    #[must_use]
103    pub fn path(&self) -> &Path {
104        self.inner.path()
105    }
106
107    /// Create a child path within this temp directory.
108    pub fn child(&self, rel_path: impl AsRef<Path>) -> AppResult<PathBuf> {
109        crate::safe_join(self.path(), rel_path.as_ref())
110            .map_err(|error| AppError::new(ErrorCode::InvalidInput, error.to_string()))
111    }
112
113    /// Write a file at a relative path within this temp directory.
114    pub fn write_file(&self, rel_path: impl AsRef<Path>, content: &[u8]) -> AppResult<PathBuf> {
115        let path = self.child(rel_path)?;
116        let parent = parent_dir(&path).unwrap_or_else(|| self.path());
117        std::fs::create_dir_all(parent).map_err(create_parent_dirs_error)?;
118        std::fs::write(&path, content).map_err(|error| write_temp_dir_file_error(&path, error))?;
119        Ok(path)
120    }
121
122    /// Create a named file inside this temp directory.
123    pub fn create_file(&self, name: &str) -> AppResult<TempFile> {
124        let inner = tempfile::Builder::new()
125            .prefix(name)
126            .tempfile_in(self.path())
127            .map_err(|error| create_named_temp_dir_file_error(name, error))?;
128        Ok(TempFile { inner })
129    }
130
131    /// Create a file with the given extension inside this temp directory.
132    pub fn create_file_with_extension(&self, ext: &str) -> AppResult<TempFile> {
133        TempFile::in_dir_with_extension(self.path(), ext)
134    }
135}
136
137fn create_temp_file_error(error: std::io::Error) -> AppError {
138    AppError::new(
139        ErrorCode::Internal,
140        format!("failed to create temp file: {error}"),
141    )
142}
143
144fn create_temp_file_with_extension_error(ext: &str, error: std::io::Error) -> AppError {
145    AppError::new(
146        ErrorCode::Internal,
147        format!("failed to create temp file with extension .{ext}: {error}"),
148    )
149}
150
151fn create_temp_file_in_dir_error(dir: &Path, error: std::io::Error) -> AppError {
152    AppError::new(
153        ErrorCode::Internal,
154        format!("failed to create temp file in {}: {error}", dir.display()),
155    )
156}
157
158fn create_temp_file_in_dir_with_extension_error(
159    dir: &Path,
160    ext: &str,
161    error: std::io::Error,
162) -> AppError {
163    AppError::new(
164        ErrorCode::Internal,
165        format!(
166            "failed to create temp file in {} with extension .{ext}: {error}",
167            dir.display()
168        ),
169    )
170}
171
172fn create_temp_dir_error(error: std::io::Error) -> AppError {
173    AppError::new(
174        ErrorCode::Internal,
175        format!("failed to create temp dir: {error}"),
176    )
177}
178
179fn create_parent_dirs_error(error: std::io::Error) -> AppError {
180    AppError::new(
181        ErrorCode::Internal,
182        format!("failed to create parent dirs: {error}"),
183    )
184}
185
186fn write_temp_dir_file_error(path: &Path, error: std::io::Error) -> AppError {
187    AppError::new(
188        ErrorCode::Internal,
189        format!("failed to write file '{}': {error}", path.display()),
190    )
191}
192
193fn create_named_temp_dir_file_error(name: &str, error: std::io::Error) -> AppError {
194    AppError::new(
195        ErrorCode::Internal,
196        format!("failed to create file {name} in temp dir: {error}"),
197    )
198}
199
200impl std::fmt::Debug for TempDir {
201    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
202        f.debug_struct("TempDir")
203            .field("path", &self.inner.path())
204            .finish()
205    }
206}
207
208/// Build a collision-resistant temp path next to a destination path.
209///
210/// `prefix` and `suffix` are sanitized before interpolation so the generated
211/// file name remains a single path component under `dest`'s parent directory.
212///
213/// The function only constructs a path; callers still own creation mode,
214/// streaming writes, fsync/flush, and final rename/persist policy.
215#[must_use]
216pub fn sibling_temp_path(dest: &Path, prefix: &str, suffix: &str) -> PathBuf {
217    let parent = parent_dir(dest).unwrap_or_else(|| Path::new("."));
218    let prefix = sanitize_temp_prefix(prefix);
219    let suffix = sanitize_temp_suffix(suffix);
220    let sequence = NEXT_TEMP_PATH.fetch_add(1, Ordering::Relaxed);
221    let nanos = SystemTime::now()
222        .duration_since(UNIX_EPOCH)
223        .map_or(0, |duration| duration.as_nanos());
224    parent.join(format!(
225        ".{prefix}-{}-{nanos}-{sequence}{suffix}",
226        std::process::id()
227    ))
228}
229
230fn sanitize_temp_prefix(value: &str) -> String {
231    sanitize_temp_affix(value, false)
232}
233
234fn sanitize_temp_suffix(value: &str) -> String {
235    sanitize_temp_affix(value, true)
236}
237
238fn sanitize_temp_affix(value: &str, allow_dot: bool) -> String {
239    let mut sanitized = String::with_capacity(value.len());
240    let mut previous_dot = false;
241
242    for character in value.chars() {
243        let replacement = match character {
244            'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' => character,
245            '.' if allow_dot && !previous_dot => '.',
246            _ => '_',
247        };
248        previous_dot = replacement == '.';
249        sanitized.push(replacement);
250    }
251
252    sanitized
253}
254
255#[cfg(test)]
256mod tests {
257    use std::path::Path;
258
259    use super::{
260        TempDir, TempFile, create_named_temp_dir_file_error, create_parent_dirs_error,
261        create_temp_dir_error, create_temp_file_error, create_temp_file_in_dir_error,
262        create_temp_file_in_dir_with_extension_error, create_temp_file_with_extension_error,
263        sibling_temp_path, write_temp_dir_file_error,
264    };
265
266    #[test]
267    fn temp_file_constructors_create_files() {
268        let file = TempFile::new().unwrap();
269        assert!(file.path().exists());
270
271        let with_ext = TempFile::with_extension("txt").unwrap();
272        assert!(
273            with_ext
274                .path()
275                .ends_with(with_ext.path().file_name().unwrap())
276        );
277        assert!(with_ext.path().to_string_lossy().ends_with(".txt"));
278
279        let dir = TempDir::new().unwrap();
280        let in_dir = TempFile::in_dir(dir.path()).unwrap();
281        assert_eq!(in_dir.path().parent(), Some(dir.path()));
282
283        let in_dir_with_ext = TempFile::in_dir_with_extension(dir.path(), "log").unwrap();
284        assert_eq!(in_dir_with_ext.path().parent(), Some(dir.path()));
285        assert!(in_dir_with_ext.path().to_string_lossy().ends_with(".log"));
286    }
287
288    #[test]
289    fn temp_file_constructors_report_invalid_directories() {
290        let dir = TempDir::new().unwrap();
291        let file = dir.write_file("file.txt", b"hello").unwrap();
292
293        assert!(TempFile::in_dir(&file).is_err());
294        assert!(TempFile::in_dir_with_extension(&file, "txt").is_err());
295    }
296
297    #[test]
298    fn temp_error_builders_include_context() {
299        let dir = Path::new("dir");
300        let file = Path::new("dir/file.txt");
301        let err = || std::io::Error::other("boom");
302
303        assert!(
304            create_temp_file_error(err())
305                .to_string()
306                .contains("temp file")
307        );
308        assert!(
309            create_temp_file_with_extension_error("txt", err())
310                .to_string()
311                .contains(".txt")
312        );
313        assert!(
314            create_temp_file_in_dir_error(dir, err())
315                .to_string()
316                .contains("in dir")
317        );
318        assert!(
319            create_temp_file_in_dir_with_extension_error(dir, "txt", err())
320                .to_string()
321                .contains(".txt")
322        );
323        assert!(
324            create_temp_dir_error(err())
325                .to_string()
326                .contains("temp dir")
327        );
328        assert!(
329            create_parent_dirs_error(err())
330                .to_string()
331                .contains("parent dirs")
332        );
333        assert!(
334            write_temp_dir_file_error(file, err())
335                .to_string()
336                .contains("write file")
337        );
338        assert!(
339            create_named_temp_dir_file_error("name", err())
340                .to_string()
341                .contains("name")
342        );
343    }
344
345    #[test]
346    fn temp_file_persist_moves_file() {
347        let dir = TempDir::new().unwrap();
348        let file = TempFile::in_dir(dir.path()).unwrap();
349        std::fs::write(file.path(), b"persisted").unwrap();
350        let target = dir.child("persisted.txt").unwrap();
351
352        let persisted = file.persist(&target).unwrap();
353
354        assert_eq!(persisted, target);
355        assert_eq!(std::fs::read_to_string(persisted).unwrap(), "persisted");
356    }
357
358    #[test]
359    fn temp_file_persist_reports_errors() {
360        let dir = TempDir::new().unwrap();
361        let file = TempFile::in_dir(dir.path()).unwrap();
362        let target = dir.child("missing/target.txt").unwrap();
363
364        assert!(file.persist(target).is_err());
365    }
366
367    #[test]
368    fn sibling_temp_paths_are_unique_and_next_to_destination() {
369        let dest = Path::new("/tmp/output.txt");
370        let first = sibling_temp_path(dest, "download", ".tmp");
371        let second = sibling_temp_path(dest, "download", ".tmp");
372
373        assert_ne!(first, second);
374        assert_eq!(first.parent(), dest.parent());
375        assert!(
376            first
377                .file_name()
378                .unwrap()
379                .to_string_lossy()
380                .contains("download")
381        );
382    }
383
384    #[test]
385    fn sibling_temp_path_sanitizes_affixes() {
386        let dest = Path::new("/tmp/output.txt");
387        let path = sibling_temp_path(dest, "../escape", "/..\\payload");
388        let file_name = path.file_name().unwrap().to_string_lossy();
389
390        assert_eq!(path.parent(), dest.parent());
391        assert!(!file_name.contains('/'));
392        assert!(!file_name.contains('\\'));
393        assert!(!file_name.contains(".."));
394    }
395
396    #[test]
397    fn temp_dir_child_rejects_traversal() {
398        let dir = TempDir::new().unwrap();
399        assert!(dir.child("../escape").is_err());
400    }
401
402    #[test]
403    fn temp_dir_write_file_creates_parents() {
404        let dir = TempDir::new().unwrap();
405        let path = dir.write_file("a/b.txt", b"hello").unwrap();
406        assert_eq!(std::fs::read_to_string(path).unwrap(), "hello");
407    }
408
409    #[test]
410    fn temp_dir_write_file_reports_errors() {
411        let dir = TempDir::new().unwrap();
412        dir.write_file("file.txt", b"hello").unwrap();
413
414        assert!(dir.write_file("file.txt/child.txt", b"nope").is_err());
415    }
416
417    #[test]
418    fn temp_dir_create_file_helpers_create_files() {
419        let dir = TempDir::new().unwrap();
420        let named = dir.create_file("named").unwrap();
421        assert_eq!(named.path().parent(), Some(dir.path()));
422        assert!(
423            named
424                .path()
425                .file_name()
426                .unwrap()
427                .to_string_lossy()
428                .starts_with("named")
429        );
430
431        let with_extension = dir.create_file_with_extension("txt").unwrap();
432        assert_eq!(with_extension.path().parent(), Some(dir.path()));
433        assert!(with_extension.path().to_string_lossy().ends_with(".txt"));
434    }
435
436    #[test]
437    fn temp_dir_debug_includes_path() {
438        let dir = TempDir::new().unwrap();
439        let debug = format!("{dir:?}");
440
441        assert!(debug.contains("TempDir"));
442        assert!(debug.contains(&dir.path().display().to_string()));
443    }
444
445    #[test]
446    fn temp_file_can_be_cloned() {
447        let file = TempFile::new().unwrap();
448        std::fs::write(file.path(), b"data").unwrap();
449        let cloned = file.try_clone().unwrap();
450        assert_eq!(std::fs::read(cloned.path()).unwrap(), b"data");
451    }
452}