hygg_shared/
lib.rs

1//! # Hygg Shared Utilities
2//!
3//! This crate contains shared utilities used across the hygg project,
4//! including cross-platform path handling and common error types.
5
6use std::path::{Path, PathBuf};
7
8/// Error type for path-related operations
9#[derive(Debug)]
10pub enum PathError {
11  /// The file was not found or could not be resolved
12  FileNotFound(String),
13  /// The path contains invalid characters
14  InvalidPath(String),
15  /// The path is not a regular file
16  NotAFile(String),
17  /// General I/O error occurred
18  IoError(String),
19}
20
21impl std::fmt::Display for PathError {
22  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
23    match self {
24      PathError::FileNotFound(msg) => write!(f, "File not found: {}", msg),
25      PathError::InvalidPath(msg) => write!(f, "Invalid path: {}", msg),
26      PathError::NotAFile(msg) => write!(f, "Not a file: {}", msg),
27      PathError::IoError(msg) => write!(f, "I/O error: {}", msg),
28    }
29  }
30}
31
32impl std::error::Error for PathError {}
33
34/// Normalizes and validates a file path for cross-platform compatibility
35///
36/// This function handles:
37/// - Resolving relative paths (like `./file.txt` or `.\file.txt` on Windows)
38/// - Converting paths to absolute canonical form
39/// - Validating that the path points to a regular file
40/// - Cross-platform path separator handling
41///
42/// # Arguments
43/// * `file_path` - The file path to normalize (can be relative or absolute)
44///
45/// # Returns
46/// * `Ok(PathBuf)` - The normalized canonical path
47/// * `Err(PathError)` - If the path is invalid, doesn't exist, or isn't a file
48///
49/// # Examples
50/// ```rust,no_run
51/// use hygg_shared::normalize_file_path;
52///
53/// // Works with relative paths
54/// let path = normalize_file_path("./test.txt")?;
55///
56/// // Works with absolute paths
57/// let path = normalize_file_path("/home/user/document.pdf")?;
58///
59/// // Works with Windows-style paths
60/// let path = normalize_file_path(r".\document.docx")?;
61/// # Ok::<(), hygg_shared::PathError>(())
62/// ```
63pub fn normalize_file_path(file_path: &str) -> Result<PathBuf, PathError> {
64  // Check for null bytes
65  if file_path.contains('\0') {
66    return Err(PathError::InvalidPath(
67      "Null bytes not allowed in file path".to_string(),
68    ));
69  }
70
71  // Check for dangerous characters that could be used for command injection
72  // Note: Backslash is valid on Windows, parentheses are common in filenames
73  let dangerous_chars = ['|', '&', ';', '`', '$', '<', '>', '\n', '\r'];
74
75  if file_path.chars().any(|c| dangerous_chars.contains(&c)) {
76    return Err(PathError::InvalidPath(
77      "File path contains dangerous characters".to_string(),
78    ));
79  }
80
81  // Normalize the path to handle different path separators and resolve relative
82  // paths
83  let path = Path::new(file_path);
84
85  // Canonicalize the path to resolve . and .. components and convert to
86  // absolute path
87  let canonical_path = path.canonicalize().map_err(|e| {
88    PathError::FileNotFound(format!(
89      "Failed to resolve path '{}': {}",
90      file_path, e
91    ))
92  })?;
93
94  // Ensure the file is a regular file
95  if !canonical_path.is_file() {
96    return Err(PathError::NotAFile("Path is not a regular file".to_string()));
97  }
98
99  Ok(canonical_path)
100}
101
102#[cfg(test)]
103mod tests {
104  use super::*;
105  use std::fs::File;
106  use std::io::Write;
107
108  #[test]
109  fn test_normalize_file_path_with_null_bytes() {
110    let result = normalize_file_path("test\0file.txt");
111    assert!(matches!(result, Err(PathError::InvalidPath(_))));
112  }
113
114  #[test]
115  fn test_normalize_file_path_with_dangerous_chars() {
116    let dangerous_paths = [
117      "test|file.txt",
118      "test&file.txt",
119      "test;file.txt",
120      "test`file.txt",
121      "test$file.txt",
122      "test<file>.txt",
123    ];
124
125    for dangerous_path in dangerous_paths {
126      let result = normalize_file_path(dangerous_path);
127      assert!(
128        matches!(result, Err(PathError::InvalidPath(_))),
129        "Should reject dangerous path: {}",
130        dangerous_path
131      );
132    }
133  }
134
135  #[test]
136  fn test_normalize_file_path_nonexistent_file() {
137    let result = normalize_file_path("definitely_nonexistent_file.txt");
138    assert!(matches!(result, Err(PathError::FileNotFound(_))));
139  }
140
141  #[test]
142  fn test_normalize_file_path_directory() {
143    let result = normalize_file_path(".");
144    assert!(matches!(result, Err(PathError::NotAFile(_))));
145  }
146
147  #[test]
148  fn test_normalize_file_path_success() -> Result<(), Box<dyn std::error::Error>>
149  {
150    // Create a temporary file for testing
151    let temp_file = std::env::temp_dir().join("hygg_test_file.txt");
152    {
153      let mut file = File::create(&temp_file)?;
154      file.write_all(b"test content")?;
155    }
156
157    // Test with the temporary file
158    let result = normalize_file_path(temp_file.to_str().unwrap());
159    assert!(result.is_ok());
160
161    // Clean up
162    std::fs::remove_file(&temp_file)?;
163    Ok(())
164  }
165
166  #[test]
167  fn test_path_error_display() {
168    let file_error = PathError::FileNotFound("test.txt".to_string());
169    assert_eq!(format!("{}", file_error), "File not found: test.txt");
170
171    let invalid_error = PathError::InvalidPath("Bad path".to_string());
172    assert_eq!(format!("{}", invalid_error), "Invalid path: Bad path");
173
174    let not_file_error = PathError::NotAFile("is directory".to_string());
175    assert_eq!(format!("{}", not_file_error), "Not a file: is directory");
176
177    let io_error = PathError::IoError("permission denied".to_string());
178    assert_eq!(format!("{}", io_error), "I/O error: permission denied");
179  }
180}