1#[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#[derive(Error, Debug)]
49pub enum UtilsError {
50 #[error("File system error: {0}")]
52 FileSystem(#[from] io::Error),
53
54 #[error("Invalid path '{path}': {details}")]
56 InvalidPath {
57 path: String,
59 details: String,
61 },
62
63 #[error("Permission denied: {0}")]
65 PermissionDenied(String),
66
67 #[error("Resource not found: {0}")]
69 NotFound(String),
70
71 #[error("Invalid operation: {0}")]
73 InvalidOperation(String),
74}
75
76pub mod fs {
78 use super::*;
79 #[cfg(feature = "ssg")]
84 use std::path::PathBuf;
85
86 #[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 pub fn new() -> Self {
97 Self {
98 files: Arc::new(RwLock::new(HashSet::new())),
99 }
100 }
101
102 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 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 #[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 pub fn validate_path_safety(path: &Path) -> Result<()> {
160 let path_str = path.to_string_lossy();
161
162 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 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 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 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 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 #[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 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
299pub 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 #[cfg(feature = "ssg")]
316 #[derive(Debug)]
317 pub struct LogEntry {
318 pub timestamp: DateTime,
320 pub level: Level,
322 pub message: String,
324 pub error: Option<String>,
326 }
327
328 #[cfg(feature = "ssg")]
329 impl LogEntry {
330 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 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 #[cfg(feature = "ssg")]
357 #[derive(Debug)]
358 pub struct LogWriter {
359 file: File,
360 }
361
362 #[cfg(feature = "ssg")]
363 impl LogWriter {
364 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 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 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 assert!(validate_path_safety(Path::new("content/📚")).is_ok());
510
511 let long_name = "a".repeat(255);
513 assert!(validate_path_safety(Path::new(&long_name)).is_ok());
514
515 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); 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#[cfg(all(test, feature = "ssg"))]
589mod exhaustive_utils_tests {
590 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 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 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 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 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 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 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 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 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 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 let regular_file = dir.path().join("regular.txt");
881 std::fs::write(®ular_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 let bad_dst = regular_file.join("parent").join("dest.txt");
887 assert!(copy_file(®ular_file, &bad_dst).await.is_err());
888
889 assert!(copy_file(®ular_file, Path::new("/"))
891 .await
892 .is_err());
893 }
894}