1#![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#[derive(Debug)]
21pub struct TempFile {
22 inner: tempfile::NamedTempFile,
23}
24
25impl TempFile {
26 pub fn new() -> AppResult<Self> {
28 let inner = tempfile::NamedTempFile::new().map_err(create_temp_file_error)?;
29 Ok(Self { inner })
30 }
31
32 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 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 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 #[must_use]
59 pub fn path(&self) -> &Path {
60 self.inner.path()
61 }
62
63 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 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
89pub struct TempDir {
91 inner: tempfile::TempDir,
92}
93
94impl TempDir {
95 pub fn new() -> AppResult<Self> {
97 let inner = tempfile::TempDir::new().map_err(create_temp_dir_error)?;
98 Ok(Self { inner })
99 }
100
101 #[must_use]
103 pub fn path(&self) -> &Path {
104 self.inner.path()
105 }
106
107 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 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 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 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#[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}