Skip to main content

magellan/
validation.rs

1//! Path validation and canonicalization utilities.
2//!
3//! Provides security-critical path validation to prevent directory traversal attacks.
4//! All file access operations MUST validate paths before accessing filesystem resources.
5
6use anyhow::Result;
7use camino::Utf8Path;
8use std::path::{Path, PathBuf};
9
10/// Error types for path validation.
11#[derive(Debug, thiserror::Error)]
12pub enum PathValidationError {
13    /// Path cannot be canonicalized (doesn't exist or permission denied)
14    #[error("cannot canonicalize path: {0}")]
15    CannotCanonicalize(String),
16
17    /// Resolved path escapes the project root
18    #[error("path escapes project root: {0} (root: {1})")]
19    OutsideRoot(String, String),
20
21    /// Path contains suspicious traversal patterns
22    #[error("path contains suspicious traversal patterns: {0}")]
23    SuspiciousTraversal(String),
24
25    /// Symlink points outside project root
26    #[error("symlink escapes project root: {0} -> {1}")]
27    SymlinkEscape(String, String),
28}
29
30/// Canonicalize a path using std::fs::canonicalize.
31///
32/// This resolves all symlinks, `..`, and `.` components to produce an absolute path.
33/// Returns an error if the path doesn't exist or cannot be accessed.
34///
35/// # Arguments
36/// * `path` - Path to canonicalize
37///
38/// # Returns
39/// Canonicalized absolute path, or error if path cannot be canonicalized
40pub fn canonicalize_path(path: &Path) -> Result<PathBuf, PathValidationError> {
41    std::fs::canonicalize(path)
42        .map_err(|_| PathValidationError::CannotCanonicalize(path.to_string_lossy().to_string()))
43}
44
45/// Normalize a path to a consistent format.
46///
47/// This function provides lenient path normalization that works for both
48/// existing and non-existing paths (important for watcher delete events).
49///
50/// # Behavior
51/// - If path exists: canonicalize it (resolves symlinks, `..`, `.`)
52/// - If path doesn't exist: strip `./` prefix and return as-is
53/// - Returns a String for easier database storage
54///
55/// # Arguments
56/// * `path` - Path to normalize
57///
58/// # Returns
59/// Normalized path string
60///
61/// # Examples
62/// ```
63/// # use magellan::validation::normalize_path;
64/// # use std::path::Path;
65/// // Existing file: returns canonical path
66/// // Non-existing: strips ./ prefix
67/// let normalized = normalize_path(Path::new("./src/lib.rs")).unwrap();
68/// assert!(normalized.contains("src/lib.rs"));
69/// assert!(!normalized.starts_with("./"));
70/// ```
71pub fn normalize_path(path: &Path) -> Result<String> {
72    // Try canonicalize first (works for existing files)
73    if let Ok(canonical) = std::fs::canonicalize(path) {
74        return Ok(canonical.to_string_lossy().to_string());
75    }
76
77    // Fallback for non-existent paths: strip ./ prefix manually
78    // This is important for watcher delete events where the file is already gone
79    let path_str = path.to_string_lossy().to_string();
80    let normalized = if let Some(stripped) = path_str.strip_prefix("./") {
81        stripped.to_string()
82    } else {
83        path_str
84    };
85
86    Ok(normalized)
87}
88
89/// Validate that a path is within the given root directory.
90///
91/// This function:
92/// 1. Canonicalizes the input path (resolves symlinks, ., ..)
93/// 2. Checks that the canonicalized path starts with the canonicalized root
94/// 3. Returns the validated canonical path on success
95///
96/// # Arguments
97/// * `path` - Path to validate
98/// * `root` - Project root directory
99///
100/// # Returns
101/// Canonicalized path if valid, error if path escapes root
102///
103/// # Security
104/// This is the PRIMARY defense against directory traversal attacks.
105/// All file access MUST go through this validation.
106pub fn validate_path_within_root(path: &Path, root: &Path) -> Result<PathBuf, PathValidationError> {
107    // First, check for obvious traversal patterns before canonicalization
108    // This catches attacks like "../../../etc/passwd" even if some ancestor
109    // doesn't exist (which would cause canonicalize to fail)
110    let path_str = path.to_string_lossy();
111    if has_suspicious_traversal(&path_str) {
112        return Err(PathValidationError::SuspiciousTraversal(
113            path_str.to_string(),
114        ));
115    }
116
117    // Canonicalize both paths to absolute form
118    let canonical_path = canonicalize_path(path)?;
119    let canonical_root = canonicalize_path(root)
120        .map_err(|_| PathValidationError::CannotCanonicalize(root.to_string_lossy().to_string()))?;
121
122    // Check if canonical path starts with canonical root
123    if !canonical_path.starts_with(&canonical_root) {
124        return Err(PathValidationError::OutsideRoot(
125            canonical_path.to_string_lossy().to_string(),
126            canonical_root.to_string_lossy().to_string(),
127        ));
128    }
129
130    Ok(canonical_path)
131}
132
133/// Check for suspicious path traversal patterns.
134///
135/// This is a pre-check to catch obvious attacks even when canonicalization
136/// might fail (e.g., if intermediate directories don't exist).
137///
138/// The threshold is >=3 parent directory patterns - legitimate use cases
139/// may use 1-2 levels of parent traversal, but bare parent references
140/// (like `../config`) are still flagged as suspicious.
141pub fn has_suspicious_traversal(path: &str) -> bool {
142    // Check for parent directory patterns
143    // Must handle both Unix (../) and Windows (..\\) patterns
144    let path_normalized = path.replace('\\', "/");
145
146    // Count "../" occurrences - 3 or more is highly suspicious
147    // (legitimate use cases rarely go up more than a couple levels)
148    let parent_count = path_normalized.matches("../").count();
149    if parent_count >= 3 {
150        return true;
151    }
152
153    // Check for bare parent references (paths starting with ../ that look like attacks)
154    // Only flag single-parent references like ../config or ../config/file
155    // Multi-parent paths like ../../dir or ../parent/sub are allowed
156    if path_normalized.starts_with("../") && !path_normalized.starts_with("../../") {
157        // Single parent: flag if it looks like an attack (few subdirectories)
158        let depth = path_normalized.matches('/').count();
159        if depth <= 2 {
160            return true;
161        }
162    }
163
164    // Windows-specific: check for ..\ at start
165    // Only flag single-parent references
166    let path_win = path.replace('/', "\\");
167    if path_win.starts_with("..\\") && !path_win.starts_with("..\\..\\") {
168        let depth = path_win.matches('\\').count();
169        if depth <= 2 {
170            return true;
171        }
172    }
173
174    // Check for mixed traversal patterns like "./subdir/../../etc"
175    // These combine forward navigation with parent traversal to hide intent
176    // This is suspicious even with just 2 parents because it obfuscates the attack
177    // We need to check for "./" followed by "../" (not "../../" which is just parents)
178    let parts: Vec<&str> = path_normalized.split('/').collect();
179    for (i, part) in parts.iter().enumerate() {
180        if *part == "." && i < parts.len() - 1 {
181            // Found "./", check if any later part is ".."
182            if parts[i + 1..].contains(&"..") {
183                return true;
184            }
185        }
186    }
187
188    // Windows-specific mixed pattern: ".\" followed by "..\"
189    let parts_win: Vec<&str> = path_win.split('\\').collect();
190    for (i, part) in parts_win.iter().enumerate() {
191        if *part == "." && i < parts_win.len() - 1 && parts_win[i + 1..].contains(&"..") {
192            return true;
193        }
194    }
195
196    false
197}
198
199/// Check if a symlink is safe (doesn't escape project root).
200///
201/// This function resolves the symlink target and validates it's within root.
202///
203/// # Arguments
204/// * `symlink_path` - Path to the symlink itself
205/// * `root` - Project root directory
206///
207/// # Returns
208/// Ok if symlink is safe, Err if symlink target escapes root
209pub fn is_safe_symlink(symlink_path: &Path, root: &Path) -> Result<bool, PathValidationError> {
210    // Read the symlink target
211    let target = std::fs::read_link(symlink_path).map_err(|_| {
212        PathValidationError::CannotCanonicalize(symlink_path.to_string_lossy().to_string())
213    })?;
214
215    // If target is absolute, validate it directly
216    if target.is_absolute() {
217        match validate_path_within_root(&target, root) {
218            Ok(_) => return Ok(true),
219            Err(PathValidationError::OutsideRoot(_, _)) => {
220                return Err(PathValidationError::SymlinkEscape(
221                    symlink_path.to_string_lossy().to_string(),
222                    target.to_string_lossy().to_string(),
223                ))
224            }
225            Err(e) => return Err(e),
226        }
227    }
228
229    // If relative, resolve relative to parent directory
230    let parent = symlink_path.parent().unwrap_or(symlink_path);
231    let resolved = parent.join(&target);
232
233    match validate_path_within_root(&resolved, root) {
234        Ok(_) => Ok(true),
235        Err(PathValidationError::OutsideRoot(_, _)) => Err(PathValidationError::SymlinkEscape(
236            symlink_path.to_string_lossy().to_string(),
237            target.to_string_lossy().to_string(),
238        )),
239        Err(e) => Err(e),
240    }
241}
242
243/// Validate a UTF-8 path using camino's Utf8Path.
244///
245/// This is a convenience wrapper for UTF-8 path handling.
246pub fn validate_utf8_path(
247    utf8_path: &Utf8Path,
248    root: &Path,
249) -> Result<PathBuf, PathValidationError> {
250    let path = Path::new(utf8_path.as_str());
251    validate_path_within_root(path, root)
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257    use std::fs;
258    use tempfile::TempDir;
259
260    #[test]
261    fn test_normalize_path_relative_prefix() {
262        let temp_dir = TempDir::new().unwrap();
263
264        // Test with ./ prefix on non-existing path (in temp dir so it doesn't exist)
265        let nonexist = temp_dir.path().join("./src/lib.rs");
266        let result = normalize_path(&nonexist).unwrap();
267        assert!(result.contains("src/lib.rs"));
268        assert!(!result.starts_with("./"));
269
270        // Test without ./ prefix
271        let nonexist2 = temp_dir.path().join("src/main.rs");
272        let result = normalize_path(&nonexist2).unwrap();
273        assert!(result.contains("src/main.rs"));
274
275        // Test absolute path returns canonical (if exists)
276        let test_file = temp_dir.path().join("test.rs");
277        std::fs::write(&test_file, b"fn test() {}").unwrap();
278        let result = normalize_path(&test_file).unwrap();
279        assert!(result.contains("test.rs"));
280
281        // Test ./ prefix on existing file still canonicalizes
282        let relative_to_temp = temp_dir.path().join("./test.rs");
283        let result = normalize_path(&relative_to_temp).unwrap();
284        assert!(result.contains("test.rs"));
285        assert!(!result.starts_with("./"));
286    }
287
288    #[test]
289    fn test_normalize_path_absolute() {
290        let temp_dir = TempDir::new().unwrap();
291
292        // Create a test file
293        let test_file = temp_dir.path().join("absolute.rs");
294        std::fs::write(&test_file, b"fn abs() {}").unwrap();
295
296        // Absolute path should canonicalize
297        let result = normalize_path(&test_file).unwrap();
298        assert!(result.contains("absolute.rs"));
299        // On most systems, canonicalized paths are absolute
300        assert!(!result.starts_with("./"));
301    }
302
303    #[test]
304    fn test_normalize_path_non_existing() {
305        // Non-existing path should strip ./ but not fail
306        let result = normalize_path(Path::new("./does/not/exist.rs")).unwrap();
307        assert_eq!(result, "does/not/exist.rs");
308
309        // Non-existing without ./ should be unchanged
310        let result = normalize_path(Path::new("nonexistent/path.rs")).unwrap();
311        assert_eq!(result, "nonexistent/path.rs");
312    }
313
314    #[test]
315    fn test_normalize_path_redundant_dots() {
316        let temp_dir = TempDir::new().unwrap();
317
318        // Create nested structure
319        let subdir = temp_dir.path().join("a/b");
320        std::fs::create_dir_all(&subdir).unwrap();
321        let test_file = subdir.join("test.rs");
322        std::fs::write(&test_file, b"fn test() {}").unwrap();
323
324        // Canonicalize should resolve the path completely
325        let result = normalize_path(&test_file).unwrap();
326        assert!(result.contains("test.rs"));
327        // Should not contain .. or . components
328        assert!(!result.contains(".."));
329    }
330
331    #[test]
332    fn test_has_suspicious_traversal_parent_patterns() {
333        assert!(has_suspicious_traversal("../../../etc/passwd"));
334        assert!(has_suspicious_traversal(
335            "..\\\\..\\\\..\\\\windows\\\\system32"
336        ));
337        assert!(has_suspicious_traversal("../config"));
338        assert!(has_suspicious_traversal("..\\config"));
339    }
340
341    #[test]
342    fn test_has_suspicious_traversal_mixed_patterns() {
343        assert!(has_suspicious_traversal("./subdir/../../etc"));
344        assert!(has_suspicious_traversal(".\\subdir\\..\\..\\etc"));
345    }
346
347    #[test]
348    fn test_has_suspicious_traversal_normal_paths() {
349        assert!(!has_suspicious_traversal("src/main.rs"));
350        assert!(!has_suspicious_traversal("./src/lib.rs"));
351        assert!(!has_suspicious_traversal("../parent/src/lib.rs")); // Only 1 parent
352        assert!(!has_suspicious_traversal("../../normal")); // Only 2 parents
353    }
354
355    #[test]
356    fn test_validate_path_within_root_valid() {
357        let temp_dir = TempDir::new().unwrap();
358        let root = temp_dir.path();
359
360        // Create a file inside root
361        let file_path = root.join("test.rs");
362        fs::write(&file_path, b"fn test() {}").unwrap();
363
364        let result = validate_path_within_root(&file_path, root);
365        assert!(result.is_ok());
366        assert!(result.unwrap().starts_with(root));
367    }
368
369    #[test]
370    fn test_validate_path_within_root_traversal_rejected() {
371        let temp_dir = TempDir::new().unwrap();
372        let root = temp_dir.path();
373
374        // Try to access file outside root using parent traversal
375        let outside = root.join("../../../etc/passwd");
376
377        let result = validate_path_within_root(&outside, root);
378        assert!(result.is_err());
379        assert!(matches!(
380            result.unwrap_err(),
381            PathValidationError::SuspiciousTraversal(_)
382        ));
383    }
384
385    #[test]
386    fn test_validate_path_within_root_absolute_outside() {
387        let temp_dir = TempDir::new().unwrap();
388        let root = temp_dir.path();
389
390        // Try to access absolute path outside root
391        let outside = Path::new("/etc/passwd");
392
393        let result = validate_path_within_root(outside, root);
394        assert!(result.is_err());
395
396        // Either SuspiciousTraversal or OutsideRoot depending on whether path exists
397        match result.unwrap_err() {
398            PathValidationError::SuspiciousTraversal(_) => {}
399            PathValidationError::OutsideRoot(_, _) => {}
400            _ => panic!("Expected traversal or outside error"),
401        }
402    }
403
404    #[test]
405    fn test_is_safe_symlink_inside_root() {
406        let temp_dir = TempDir::new().unwrap();
407        let root = temp_dir.path();
408
409        // Create a target file
410        let target = root.join("target.rs");
411        fs::write(&target, b"fn target() {}").unwrap();
412
413        // Create symlink pointing to target
414        let symlink = root.join("link.rs");
415        #[cfg(unix)]
416        std::os::unix::fs::symlink(&target, &symlink).unwrap();
417
418        #[cfg(windows)]
419        std::os::windows::fs::symlink_file(&target, &symlink).unwrap();
420
421        // On supported platforms, verify symlink is safe
422        #[cfg(any(unix, windows))]
423        {
424            let result = is_safe_symlink(&symlink, root);
425            assert!(result.is_ok());
426            assert!(result.unwrap());
427        }
428    }
429
430    #[test]
431    fn test_is_safe_symlink_outside_root() {
432        let temp_dir = TempDir::new().unwrap();
433        let root = temp_dir.path();
434
435        // Create a target file outside root
436        let outside_dir = TempDir::new().unwrap();
437        let target = outside_dir.path().join("outside.rs");
438        fs::write(&target, b"fn outside() {}").unwrap();
439
440        // Create symlink inside root pointing outside
441        let symlink = root.join("link.rs");
442        #[cfg(unix)]
443        std::os::unix::fs::symlink(&target, &symlink).unwrap();
444
445        #[cfg(windows)]
446        std::os::windows::fs::symlink_file(&target, &symlink).unwrap();
447
448        // On supported platforms, verify symlink is detected as unsafe
449        #[cfg(any(unix, windows))]
450        {
451            let result = is_safe_symlink(&symlink, root);
452            assert!(result.is_err());
453            // Absolute symlinks pointing outside root should produce SymlinkEscape
454            match result.unwrap_err() {
455                PathValidationError::SymlinkEscape(_, _) => {}
456                PathValidationError::CannotCanonicalize(_) => {
457                    // Broken symlinks are also unsafe
458                }
459                other => panic!(
460                    "Expected SymlinkEscape or CannotCanonicalize, got: {:?}",
461                    other
462                ),
463            }
464        }
465    }
466
467    #[test]
468    fn test_cross_platform_path_handling() {
469        let temp_dir = TempDir::new().unwrap();
470        let root = temp_dir.path();
471
472        // Create a file with subdirectory
473        let subdir = root.join("src");
474        fs::create_dir(&subdir).unwrap();
475        let file_path = subdir.join("main.rs");
476        fs::write(&file_path, b"fn main() {}").unwrap();
477
478        // Test with forward slash path (Unix-style)
479        let path_str = file_path.to_string_lossy().replace('\\', "/");
480        let result = validate_path_within_root(Path::new(&path_str), root);
481        assert!(result.is_ok());
482
483        // Test with backslash path (Windows-style) - this may not work on Unix
484        // but the canonicalization should handle it if the OS supports it
485        if cfg!(windows) {
486            let path_str_win = file_path.to_string_lossy().replace('/', "\\");
487            let result_win = validate_path_within_root(Path::new(&path_str_win), root);
488            assert!(result_win.is_ok());
489        }
490    }
491}