Skip to main content

frontmatter_gen/
utils.rs

1// Copyright © 2024 Shokunin Static Site Generator. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! # Utility Module
5//!
6//! Provides common utilities for file system operations, logging, and other shared functionality.
7//!
8//! ## Features
9//!
10//! - Secure file system operations
11//! - Path validation and normalization
12//! - Temporary file management
13//! - Logging utilities
14//!
15//! ## Security
16//!
17//! All file system operations include checks for:
18//! - Path traversal attacks
19//! - Symlink attacks
20//! - Directory structure validation
21//! - Permission validation
22
23#[cfg(feature = "ssg")]
24use std::collections::HashSet;
25#[cfg(feature = "ssg")]
26use std::fs::File;
27
28use std::fs::create_dir_all;
29#[cfg(feature = "ssg")]
30use std::fs::remove_file;
31
32use std::io::{self};
33use std::path::Path;
34
35#[cfg(feature = "ssg")]
36use std::sync::Arc;
37
38use anyhow::{Context, Result};
39use thiserror::Error;
40
41#[cfg(feature = "ssg")]
42use tokio::sync::RwLock;
43
44#[cfg(feature = "ssg")]
45use uuid::Uuid;
46
47/// Errors that can occur during utility operations
48#[derive(Error, Debug)]
49pub enum UtilsError {
50    /// File system operation failed
51    #[error("File system error: {0}")]
52    FileSystem(#[from] io::Error),
53
54    /// Path validation failed
55    #[error("Invalid path '{path}': {details}")]
56    InvalidPath {
57        /// The path that was invalid
58        path: String,
59        /// Details about why the path was invalid
60        details: String,
61    },
62
63    /// Permission error
64    #[error("Permission denied: {0}")]
65    PermissionDenied(String),
66
67    /// Resource not found
68    #[error("Resource not found: {0}")]
69    NotFound(String),
70
71    /// Invalid operation
72    #[error("Invalid operation: {0}")]
73    InvalidOperation(String),
74}
75
76/// File system utilities module
77pub mod fs {
78    use super::*;
79    // Only the `ssg` surface below uses owned paths; without the gate
80    // this import is dead in a default build, which `[lints.rust]`
81    // makes an error. The fuzz and Miri jobs are the first to compile
82    // that configuration.
83    #[cfg(feature = "ssg")]
84    use std::path::PathBuf;
85
86    /// Tracks temporary files for cleanup
87    #[cfg(feature = "ssg")]
88    #[derive(Debug, Default)]
89    pub struct TempFileTracker {
90        files: Arc<RwLock<HashSet<PathBuf>>>,
91    }
92
93    #[cfg(feature = "ssg")]
94    impl TempFileTracker {
95        /// Creates a new temporary file tracker
96        pub fn new() -> Self {
97            Self {
98                files: Arc::new(RwLock::new(HashSet::new())),
99            }
100        }
101
102        /// Registers a temporary file for tracking
103        pub async fn register(&self, path: PathBuf) -> Result<()> {
104            let mut files = self.files.write().await;
105            let _ = files.insert(path);
106            Ok(())
107        }
108
109        /// Cleans up all tracked temporary files
110        pub async fn cleanup(&self) -> Result<()> {
111            let files = self.files.read().await;
112            for path in files.iter() {
113                if path.exists() {
114                    remove_file(path).with_context(|| {
115                        format!(
116                            "Failed to remove temporary file: {}",
117                            path.display()
118                        )
119                    })?;
120                }
121            }
122            Ok(())
123        }
124    }
125
126    /// Creates a new temporary file with the given prefix
127    #[cfg(feature = "ssg")]
128    pub async fn create_temp_file(
129        prefix: &str,
130    ) -> Result<(PathBuf, File), UtilsError> {
131        let temp_dir = std::env::temp_dir();
132        let file_name = format!("{}-{}", prefix, Uuid::new_v4());
133        let path = temp_dir.join(file_name);
134
135        let file =
136            File::create(&path).map_err(UtilsError::FileSystem)?;
137
138        Ok((path, file))
139    }
140
141    /// Validates that a path is safe to use
142    ///
143    /// # Arguments
144    ///
145    /// * `path` - Path to validate
146    ///
147    /// # Returns
148    ///
149    /// Returns Ok(()) if the path is safe, or an error if validation fails
150    ///
151    /// # Security
152    ///
153    /// Checks for:
154    /// - Path length limits
155    /// - Invalid characters
156    /// - Path traversal attempts
157    /// - Symlinks
158    /// - Reserved names
159    pub fn validate_path_safety(path: &Path) -> Result<()> {
160        let path_str = path.to_string_lossy();
161
162        // 1. Disallow backslashes for POSIX compatibility
163        if path_str.contains('\\') {
164            return Err(UtilsError::InvalidPath {
165                path: path_str.to_string(),
166                details: "Backslashes are not allowed in paths"
167                    .to_string(),
168            }
169            .into());
170        }
171
172        // 2. Check for null bytes and control characters
173        if path_str.contains('\0')
174            || path_str.chars().any(|c| c.is_control())
175        {
176            return Err(UtilsError::InvalidPath {
177                path: path_str.to_string(),
178                details: "Path contains invalid characters".to_string(),
179            }
180            .into());
181        }
182
183        // 3. Disallow path traversal using `..`
184        if path_str.contains("..") {
185            return Err(UtilsError::InvalidPath {
186                path: path_str.to_string(),
187                details: "Path traversal not allowed".to_string(),
188            }
189            .into());
190        }
191
192        // 4. Absolute paths are accepted.
193        //
194        // This function checks the *shape* of a path, not where it
195        // points: deciding which directory a caller may write to is the
196        // caller's job, and it has the root to compare against.
197        //
198        // It used to `return Ok(())` here, which silently skipped rules
199        // 5 and 6 below — so a symlink or a reserved name passed
200        // validation whenever it arrived as an absolute path, and only
201        // relative paths were fully checked. The early return is gone;
202        // every rule now applies to every path.
203
204        // 5. Check for symlinks
205        if let Ok(metadata) = path.symlink_metadata() {
206            if metadata.file_type().is_symlink() {
207                return Err(UtilsError::InvalidPath {
208                    path: path_str.to_string(),
209                    details: "Symlinks are not allowed".to_string(),
210                }
211                .into());
212            }
213        }
214
215        // 6. Prevent the use of reserved names (Windows compatibility)
216        let reserved_names =
217            ["con", "prn", "aux", "nul", "com1", "lpt1"];
218        if let Some(file_name) =
219            path.file_name().and_then(|n| n.to_str())
220        {
221            if reserved_names
222                .contains(&file_name.to_lowercase().as_str())
223            {
224                return Err(UtilsError::InvalidPath {
225                    path: path_str.to_string(),
226                    details: "Reserved file name not allowed"
227                        .to_string(),
228                }
229                .into());
230            }
231        }
232
233        Ok(())
234    }
235
236    /// Creates a directory and all parent directories
237    ///
238    /// # Arguments
239    ///
240    /// * `path` - Path to create
241    ///
242    /// # Returns
243    ///
244    /// Returns Ok(()) on success, or an error if creation fails
245    ///
246    /// # Security
247    ///
248    /// Validates path safety before creation
249    #[cfg(feature = "ssg")]
250    pub async fn create_directory(path: &Path) -> Result<()> {
251        validate_path_safety(path)?;
252
253        create_dir_all(path).with_context(|| {
254            format!("Failed to create directory: {}", path.display())
255        })?;
256
257        Ok(())
258    }
259
260    /// Copies a file from source to destination
261    ///
262    /// # Arguments
263    ///
264    /// * `src` - Source path
265    /// * `dst` - Destination path
266    ///
267    /// # Returns
268    ///
269    /// Returns Ok(()) on success, or an error if copy fails
270    ///
271    /// # Security
272    ///
273    /// Validates both paths and ensures proper permissions
274    pub async fn copy_file(src: &Path, dst: &Path) -> Result<()> {
275        validate_path_safety(src)?;
276        validate_path_safety(dst)?;
277
278        if let Some(parent) = dst.parent() {
279            create_dir_all(parent).with_context(|| {
280                format!(
281                    "Failed to create parent directory: {}",
282                    parent.display()
283                )
284            })?;
285        }
286
287        let _ = std::fs::copy(src, dst).with_context(|| {
288            format!(
289                "Failed to copy {} to {}",
290                src.display(),
291                dst.display()
292            )
293        })?;
294
295        Ok(())
296    }
297}
298
299/// Logging utilities module
300pub mod log {
301    #[cfg(feature = "ssg")]
302    use anyhow::{Context, Result};
303    #[cfg(feature = "ssg")]
304    use dtt::datetime::DateTime;
305    #[cfg(feature = "ssg")]
306    use log::{Level, Record};
307    #[cfg(feature = "ssg")]
308    use std::{
309        fs::{File, OpenOptions},
310        io::Write,
311        path::Path,
312    };
313
314    /// Log entry structure
315    #[cfg(feature = "ssg")]
316    #[derive(Debug)]
317    pub struct LogEntry {
318        /// Timestamp of the log entry
319        pub timestamp: DateTime,
320        /// Log level
321        pub level: Level,
322        /// Log message
323        pub message: String,
324        /// Optional error details
325        pub error: Option<String>,
326    }
327
328    #[cfg(feature = "ssg")]
329    impl LogEntry {
330        /// Creates a new log entry
331        pub fn new(record: &Record<'_>) -> Self {
332            Self {
333                timestamp: DateTime::new(),
334                level: record.level(),
335                message: record.args().to_string(),
336                error: None,
337            }
338        }
339
340        /// Formats the log entry as a string
341        pub fn format(&self) -> String {
342            let error_info = self
343                .error
344                .as_ref()
345                .map(|e| format!(" (Error: {})", e))
346                .unwrap_or_default();
347
348            format!(
349                "[{} {:>5}] {}{}",
350                self.timestamp, self.level, self.message, error_info
351            )
352        }
353    }
354
355    /// Log writer for handling log output
356    #[cfg(feature = "ssg")]
357    #[derive(Debug)]
358    pub struct LogWriter {
359        file: File,
360    }
361
362    #[cfg(feature = "ssg")]
363    impl LogWriter {
364        /// Creates a new log writer
365        pub fn new(path: &Path) -> Result<Self> {
366            let file = OpenOptions::new()
367                .create(true)
368                .append(true)
369                .open(path)
370                .with_context(|| {
371                    format!(
372                        "Failed to open log file: {}",
373                        path.display()
374                    )
375                })?;
376
377            Ok(Self { file })
378        }
379
380        /// Writes a log entry
381        pub fn write(&mut self, entry: &LogEntry) -> Result<()> {
382            writeln!(self.file, "{}", entry.format())
383                .context("Failed to write log entry")?;
384            Ok(())
385        }
386    }
387}
388
389impl From<anyhow::Error> for UtilsError {
390    fn from(err: anyhow::Error) -> Self {
391        UtilsError::InvalidOperation(err.to_string())
392    }
393}
394
395impl From<tokio::task::JoinError> for UtilsError {
396    fn from(err: tokio::task::JoinError) -> Self {
397        UtilsError::InvalidOperation(err.to_string())
398    }
399}
400
401#[cfg(all(test, feature = "ssg"))]
402mod tests {
403    use crate::utils::fs::copy_file;
404    use crate::utils::fs::create_directory;
405    use crate::utils::fs::create_temp_file;
406    use crate::utils::fs::validate_path_safety;
407    use crate::utils::fs::TempFileTracker;
408    use crate::utils::log::LogEntry;
409    use crate::utils::log::LogWriter;
410    use crate::utils::UtilsError;
411    use log::Level;
412    use log::Record;
413    use std::fs::read_to_string;
414    use std::fs::remove_file;
415    use std::path::Path;
416    use std::sync::Arc;
417
418    #[tokio::test]
419    async fn test_temp_file_creation_and_cleanup() -> anyhow::Result<()>
420    {
421        let tracker = TempFileTracker::new();
422        let (path, _file) = create_temp_file("test").await?;
423
424        tracker.register(path.clone()).await?;
425        assert!(path.exists());
426
427        tracker.cleanup().await?;
428        assert!(!path.exists());
429        Ok(())
430    }
431
432    #[tokio::test]
433    async fn test_temp_file_concurrent_access() -> Result<(), UtilsError>
434    {
435        use tokio::task;
436
437        let tracker = Arc::new(TempFileTracker::new());
438        let mut handles = Vec::new();
439
440        for i in 0..5 {
441            let tracker = Arc::clone(&tracker);
442            handles.push(task::spawn(async move {
443                let (path, _) =
444                    create_temp_file(&format!("test{}", i)).await?;
445                tracker.register(path).await
446            }));
447        }
448
449        for handle in handles {
450            handle.await??;
451        }
452
453        tracker.cleanup().await?;
454        Ok(())
455    }
456
457    #[tokio::test]
458    async fn test_create_directory_valid_path() -> anyhow::Result<()> {
459        let base = tempfile::tempdir()?;
460        let temp_dir = base.path().join("test_dir");
461
462        create_directory(&temp_dir).await?;
463        assert!(temp_dir.exists());
464        tokio::fs::remove_dir_all(temp_dir).await?;
465        Ok(())
466    }
467
468    #[tokio::test]
469    async fn test_copy_file_valid_paths() -> anyhow::Result<()> {
470        let src = std::env::temp_dir().join("src.txt");
471        let dst = std::env::temp_dir().join("dst.txt");
472
473        // Create the source file with content
474        tokio::fs::write(&src, "test content").await?;
475
476        copy_file(&src, &dst).await?;
477        assert_eq!(
478            tokio::fs::read_to_string(&dst).await?,
479            "test content"
480        );
481
482        tokio::fs::remove_file(src).await?;
483        tokio::fs::remove_file(dst).await?;
484        Ok(())
485    }
486
487    #[test]
488    fn test_validate_path_safety_valid_paths() {
489        assert!(
490            validate_path_safety(Path::new("content/file.txt")).is_ok()
491        );
492        assert!(
493            validate_path_safety(Path::new("templates/blog")).is_ok()
494        );
495    }
496
497    #[test]
498    fn test_validate_path_safety_invalid_paths() {
499        assert!(validate_path_safety(Path::new("../outside")).is_err());
500        assert!(
501            validate_path_safety(Path::new("content\0file")).is_err()
502        );
503        assert!(validate_path_safety(Path::new("CON")).is_err());
504    }
505
506    #[test]
507    fn test_validate_path_safety_edge_cases() {
508        // Test Unicode
509        assert!(validate_path_safety(Path::new("content/📚")).is_ok());
510
511        // Long paths
512        let long_name = "a".repeat(255);
513        assert!(validate_path_safety(Path::new(&long_name)).is_ok());
514
515        // Special characters
516        assert!(validate_path_safety(Path::new("content/#$@!")).is_ok());
517    }
518
519    #[test]
520    fn test_log_entry_format() {
521        let record = Record::builder()
522            .args(format_args!("Test log message"))
523            .level(Level::Info)
524            .target("test")
525            .module_path_static(Some("test"))
526            .file_static(Some("test.rs"))
527            .line(Some(42))
528            .build();
529
530        let entry = LogEntry::new(&record);
531        assert!(entry.format().contains("Test log message"));
532        assert!(entry.format().contains("INFO"));
533    }
534
535    #[test]
536    fn test_log_entry_with_error() {
537        let record = Record::builder()
538            .args(format_args!("Test error message"))
539            .level(Level::Error)
540            .target("test")
541            .module_path_static(Some("test"))
542            .file_static(Some("test.rs"))
543            .line(Some(42))
544            .build();
545
546        let mut entry = LogEntry::new(&record);
547        entry.error = Some("Error details".to_string());
548
549        let formatted = entry.format();
550        assert!(formatted.contains("Error details"));
551        assert!(formatted.contains("ERROR"));
552    }
553
554    #[test]
555    fn test_log_writer_creation() {
556        let temp_log_path = std::env::temp_dir().join("test_log.txt");
557        let writer = LogWriter::new(&temp_log_path).unwrap();
558
559        assert!(temp_log_path.exists());
560        drop(writer); // Ensure file is closed
561        remove_file(temp_log_path).unwrap();
562    }
563
564    #[test]
565    fn test_log_writer_write() {
566        let temp_log_path =
567            std::env::temp_dir().join("test_log_write.txt");
568        let mut writer = LogWriter::new(&temp_log_path).unwrap();
569
570        let record = Record::builder()
571            .args(format_args!("Write test message"))
572            .level(Level::Info)
573            .target("test")
574            .build();
575
576        let entry = LogEntry::new(&record);
577        writer.write(&entry).unwrap();
578
579        let content = read_to_string(&temp_log_path).unwrap();
580        assert!(content.contains("Write test message"));
581        remove_file(temp_log_path).unwrap();
582    }
583}
584
585// The `fs` and `log` modules this exercises are gated behind `ssg`;
586// without the same gate these tests do not compile under a default
587// build, which is how `cargo miri test --lib` runs them.
588#[cfg(all(test, feature = "ssg"))]
589mod exhaustive_utils_tests {
590    //! Every rejection rule in `validate_path_safety`, and the file
591    //! helpers built on it.
592    //!
593    //! Path validation is a security boundary: each rule below exists
594    //! because the path shape it rejects would otherwise escape the
595    //! directory the caller intended. A rule with no test is a rule
596    //! that can be deleted by accident.
597
598    use super::fs::*;
599    use super::log::*;
600    use super::UtilsError;
601    use std::path::{Path, PathBuf};
602
603    #[test]
604    fn rejects_backslashes() {
605        let err = validate_path_safety(Path::new("dir\\file.md"))
606            .expect_err(
607                "backslashes are rejected for POSIX compatibility",
608            );
609        assert!(err.to_string().contains("Backslash"), "{err}");
610    }
611
612    #[test]
613    fn rejects_null_bytes_and_control_characters() {
614        assert!(validate_path_safety(Path::new("a\0b")).is_err());
615        assert!(validate_path_safety(Path::new("a\u{7}b")).is_err());
616    }
617
618    #[test]
619    fn rejects_parent_directory_traversal() {
620        for candidate in ["../secret", "a/../../b", "..", "a/.."] {
621            assert!(
622                validate_path_safety(Path::new(candidate)).is_err(),
623                "{candidate} should be rejected"
624            );
625        }
626    }
627
628    #[test]
629    fn accepts_ordinary_relative_paths() {
630        for candidate in ["file.md", "dir/file.md", "a/b/c.txt", "."] {
631            assert!(
632                validate_path_safety(Path::new(candidate)).is_ok(),
633                "{candidate} should be accepted"
634            );
635        }
636    }
637
638    #[test]
639    fn absolute_paths_are_accepted_but_still_checked() {
640        // Absolute is not by itself unsafe: which directory a caller
641        // may write to is the caller's decision, and it has the root to
642        // compare against. What matters is that being absolute no
643        // longer skips the remaining rules — see `rejects_symlinks`,
644        // which passes an absolute path.
645        let inside =
646            std::env::temp_dir().join("frontmatter-gen-safe.md");
647        assert!(validate_path_safety(&inside).is_ok());
648        assert!(validate_path_safety(Path::new("/etc/passwd")).is_ok());
649        assert!(
650            validate_path_safety(Path::new("/tmp/con")).is_err(),
651            "an absolute path with a reserved name is still rejected"
652        );
653    }
654
655    #[test]
656    fn rejects_symlinks() {
657        let dir = tempfile::tempdir().expect("tempdir");
658        let target = dir.path().join("target.md");
659        std::fs::write(&target, b"x").expect("write");
660        let link = dir.path().join("link.md");
661
662        #[cfg(unix)]
663        std::os::unix::fs::symlink(&target, &link).expect("symlink");
664        #[cfg(not(unix))]
665        return;
666
667        let err = validate_path_safety(&link)
668            .expect_err("symlinks are rejected");
669        assert!(err.to_string().contains("Symlink"), "{err}");
670    }
671
672    #[test]
673    fn rejects_windows_reserved_names() {
674        for name in ["con", "PRN", "Aux", "nul", "com1", "LPT1"] {
675            assert!(
676                validate_path_safety(Path::new(name)).is_err(),
677                "{name} is reserved on Windows and must be rejected"
678            );
679        }
680        // A reserved stem with an extension is a different file name.
681        assert!(validate_path_safety(Path::new("console.md")).is_ok());
682    }
683
684    #[tokio::test]
685    async fn temp_file_tracker_registers_and_cleans_up() {
686        let tracker = TempFileTracker::new();
687        let dir = tempfile::tempdir().expect("tempdir");
688        let path = dir.path().join("tracked.txt");
689        std::fs::write(&path, b"data").expect("write");
690
691        tracker.register(path.clone()).await.expect("register");
692        assert!(path.exists());
693
694        tracker.cleanup().await.expect("cleanup");
695        assert!(!path.exists(), "cleanup removes registered files");
696
697        // Cleaning up twice is not an error: the file is already gone.
698        tracker.cleanup().await.expect("second cleanup");
699    }
700
701    #[tokio::test]
702    async fn copy_file_creates_the_destination_parent() {
703        let dir = tempfile::tempdir().expect("tempdir");
704        let src = dir.path().join("src.md");
705        std::fs::write(&src, b"content").expect("write");
706        let dst = dir.path().join("nested/deeper/dst.md");
707
708        copy_file(&src, &dst).await.expect("copy");
709        assert_eq!(
710            std::fs::read_to_string(&dst).expect("read"),
711            "content"
712        );
713    }
714
715    #[tokio::test]
716    async fn copy_file_rejects_an_unsafe_path_before_touching_the_disk()
717    {
718        let dir = tempfile::tempdir().expect("tempdir");
719        let src = dir.path().join("src.md");
720        std::fs::write(&src, b"content").expect("write");
721
722        let err = copy_file(&src, Path::new("../escape.md"))
723            .await
724            .expect_err("traversal in the destination is rejected");
725        assert!(!err.to_string().is_empty());
726        assert!(!PathBuf::from("../escape.md").exists());
727    }
728
729    fn entry(
730        level: log::Level,
731        message: &str,
732        error: Option<&str>,
733    ) -> LogEntry {
734        LogEntry {
735            timestamp: dtt::datetime::DateTime::new(),
736            level,
737            message: message.to_string(),
738            error: error.map(str::to_string),
739        }
740    }
741
742    #[tokio::test]
743    async fn create_directory_makes_nested_paths_and_refuses_unsafe_ones(
744    ) {
745        let dir = tempfile::tempdir().expect("tempdir");
746        let nested = dir.path().join("a/b/c");
747        create_directory(&nested).await.expect("create nested");
748        assert!(nested.is_dir());
749
750        // Already there: creating again is not an error.
751        create_directory(&nested).await.expect("idempotent");
752
753        assert!(create_directory(Path::new("../escape"))
754            .await
755            .is_err());
756    }
757
758    #[tokio::test]
759    async fn create_temp_file_returns_a_writable_handle_under_the_temp_dir(
760    ) {
761        use std::io::Write as _;
762
763        let (path, mut file) = create_temp_file("frontmatter-gen-test")
764            .await
765            .expect("create temp file");
766        assert!(path.starts_with(std::env::temp_dir()));
767        assert!(
768            path.file_name()
769                .and_then(|n| n.to_str())
770                .is_some_and(|n| n.starts_with("frontmatter-gen-test-")),
771            "the prefix names the file: {path:?}"
772        );
773
774        file.write_all(b"payload").expect("write");
775        drop(file);
776        assert_eq!(
777            std::fs::read(&path).expect("read back"),
778            b"payload"
779        );
780
781        // Handing it to the tracker is what makes it disposable.
782        let tracker = TempFileTracker::new();
783        tracker.register(path.clone()).await.expect("register");
784        tracker.cleanup().await.expect("cleanup");
785        assert!(!path.exists());
786    }
787
788    #[tokio::test]
789    async fn copy_file_reports_a_missing_source() {
790        let dir = tempfile::tempdir().expect("tempdir");
791        let missing = dir.path().join("absent.md");
792        let dst = dir.path().join("dst.md");
793        assert!(copy_file(&missing, &dst).await.is_err());
794    }
795
796    #[test]
797    fn log_entries_format_with_their_level_and_message() {
798        let formatted = entry(log::Level::Info, "hello", None).format();
799        assert!(formatted.contains("INFO"), "{formatted}");
800        assert!(formatted.contains("hello"), "{formatted}");
801        assert!(!formatted.contains("Error:"), "{formatted}");
802
803        // The optional error detail is appended when present.
804        let with_error =
805            entry(log::Level::Error, "failed", Some("disk full"))
806                .format();
807        assert!(with_error.contains("disk full"), "{with_error}");
808    }
809
810    #[test]
811    fn utils_error_converts_from_anyhow_and_join_errors() {
812        let err: UtilsError = anyhow::anyhow!("boom").into();
813        assert!(err.to_string().contains("boom"), "{err}");
814        assert!(matches!(err, UtilsError::InvalidOperation(_)));
815    }
816
817    #[tokio::test]
818    async fn utils_error_converts_from_a_join_error() {
819        let handle = tokio::spawn(async { panic!("task failed") });
820        let join_err = handle.await.expect_err("the task panicked");
821        let err: UtilsError = join_err.into();
822        assert!(matches!(err, UtilsError::InvalidOperation(_)));
823        assert!(!err.to_string().is_empty());
824    }
825
826    #[test]
827    fn log_writer_reports_a_path_it_cannot_open() {
828        // A directory is not a file: opening it for append fails.
829        let dir = tempfile::tempdir().expect("tempdir");
830        assert!(LogWriter::new(dir.path()).is_err());
831    }
832
833    #[tokio::test]
834    async fn cleanup_skips_files_that_are_already_gone() {
835        let tracker = TempFileTracker::new();
836        let dir = tempfile::tempdir().expect("tempdir");
837        let present = dir.path().join("present.txt");
838        let absent = dir.path().join("absent.txt");
839        std::fs::write(&present, b"x").expect("write");
840
841        tracker.register(present.clone()).await.expect("register");
842        tracker.register(absent.clone()).await.expect("register");
843
844        // The absent path is skipped rather than reported as an error.
845        tracker.cleanup().await.expect("cleanup");
846        assert!(!present.exists());
847    }
848
849    #[test]
850    fn log_writer_appends_entries_to_its_file() {
851        let dir = tempfile::tempdir().expect("tempdir");
852        let path = dir.path().join("log.txt");
853        let mut writer = LogWriter::new(&path).expect("open log");
854        writer
855            .write(&entry(log::Level::Warn, "careful", None))
856            .expect("write entry");
857        writer
858            .write(&entry(log::Level::Info, "second", None))
859            .expect("write another");
860        drop(writer);
861
862        let contents =
863            std::fs::read_to_string(&path).expect("read log");
864        assert!(contents.contains("careful"), "{contents}");
865        assert!(contents.contains("second"), "{contents}");
866    }
867
868    #[tokio::test]
869    async fn fs_error_paths_report_context() {
870        let dir = tempfile::tempdir().expect("tempdir");
871
872        // 1. TempFileTracker::cleanup fails when a registered path is a directory
873        let sub_dir = dir.path().join("a_dir");
874        std::fs::create_dir(&sub_dir).expect("mkdir");
875        let tracker = TempFileTracker::new();
876        tracker.register(sub_dir).await.expect("register");
877        assert!(tracker.cleanup().await.is_err());
878
879        // 2. create_directory fails when parent is a regular file
880        let regular_file = dir.path().join("regular.txt");
881        std::fs::write(&regular_file, b"hello").expect("write");
882        let bad_dir = regular_file.join("child_dir");
883        assert!(create_directory(&bad_dir).await.is_err());
884
885        // 3. copy_file fails when dst parent is under a regular file
886        let bad_dst = regular_file.join("parent").join("dest.txt");
887        assert!(copy_file(&regular_file, &bad_dst).await.is_err());
888
889        // 4. copy_file when dst has no parent (root path)
890        assert!(copy_file(&regular_file, Path::new("/"))
891            .await
892            .is_err());
893    }
894}