fob_graph/analysis/walker/
validation.rs

1//! Path validation and security checks for graph traversal.
2//!
3//! This module provides security checks to prevent path traversal attacks
4//! and ensure that all resolved paths stay within the intended directory.
5
6use std::path::{Path, PathBuf};
7use thiserror::Error;
8
9/// Error indicating a path traversal attempt was detected.
10#[derive(Debug, Error)]
11#[error("Path traversal detected: path '{path}' escapes from cwd '{cwd}'")]
12pub struct PathTraversalError {
13    /// The path that attempted to escape
14    pub path: PathBuf,
15    /// The current working directory that was escaped from
16    pub cwd: PathBuf,
17}
18
19/// Validate that a normalized path stays within the current working directory.
20///
21/// This prevents path traversal attacks where malicious paths like `../../../etc/passwd`
22/// could escape the intended directory.
23///
24/// # Arguments
25///
26/// * `normalized_path` - The normalized absolute path to validate
27/// * `cwd` - The current working directory that serves as the root
28///
29/// # Returns
30///
31/// Ok(()) if the path is safe, or PathTraversalError if it escapes the cwd
32pub fn validate_path_within_cwd(
33    normalized_path: &Path,
34    cwd: &Path,
35) -> Result<(), PathTraversalError> {
36    // First, try with canonicalized paths (most accurate)
37    if let (Ok(normalized), Ok(cwd_normalized)) =
38        (normalized_path.canonicalize(), cwd.canonicalize())
39    {
40        if normalized.starts_with(&cwd_normalized) {
41            return Ok(());
42        }
43    }
44
45    // Fallback: check with non-canonicalized paths
46    // This handles cases where paths don't exist yet or canonicalization fails
47    // We use a more lenient check here, but still validate the path structure
48    if normalized_path.starts_with(cwd) {
49        // Additional check: ensure the path doesn't contain ".." that would escape
50        // This is a safety check for the fallback case
51        // Count how many directory levels we're going up from cwd
52        let relative = normalized_path
53            .strip_prefix(cwd)
54            .ok()
55            .and_then(|p| p.to_str());
56
57        if let Some(rel) = relative {
58            // Check if the relative path contains excessive ".."
59            let dot_dot_count = rel.matches("../").count();
60            if dot_dot_count == 0 {
61                // No ".." components, safe
62                return Ok(());
63            }
64        }
65
66        // If we have ".." components, be more strict
67        // Only allow if canonicalization succeeded and passed the starts_with check above
68        // Otherwise, reject to be safe
69        return Err(PathTraversalError {
70            path: normalized_path.to_path_buf(),
71            cwd: cwd.to_path_buf(),
72        });
73    }
74
75    // Path doesn't start with cwd, definitely a traversal attempt
76    Err(PathTraversalError {
77        path: normalized_path.to_path_buf(),
78        cwd: cwd.to_path_buf(),
79    })
80}
81
82/// Normalize a path and validate it stays within the cwd.
83///
84/// This is a secure version of path normalization that:
85/// 1. Converts relative paths to absolute paths
86/// 2. Cleans the path (removes `.` and `..` components)
87/// 3. Validates the path doesn't escape the cwd
88///
89/// # Arguments
90///
91/// * `path` - The path to normalize (can be relative or absolute)
92/// * `cwd` - The current working directory
93///
94/// # Returns
95///
96/// The normalized absolute path, or an error if path traversal is detected
97pub fn normalize_and_validate_path(path: &Path, cwd: &Path) -> Result<PathBuf, PathTraversalError> {
98    use path_clean::PathClean;
99
100    let normalized = if path.is_absolute() {
101        path.to_path_buf()
102    } else {
103        cwd.join(path)
104    };
105
106    let cleaned = normalized.clean();
107
108    // Validate the cleaned path stays within cwd
109    validate_path_within_cwd(&cleaned, cwd)?;
110
111    Ok(cleaned)
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117    use std::fs;
118    use tempfile::TempDir;
119
120    #[test]
121    fn test_validate_path_within_cwd_valid() {
122        let temp_dir = TempDir::new().unwrap();
123        let cwd = temp_dir.path();
124        let valid_path = cwd.join("src").join("index.ts");
125
126        // Create the directory structure
127        fs::create_dir_all(valid_path.parent().unwrap()).unwrap();
128        fs::write(&valid_path, "").unwrap();
129
130        assert!(validate_path_within_cwd(&valid_path, cwd).is_ok());
131    }
132
133    #[test]
134    fn test_validate_path_within_cwd_traversal() {
135        let temp_dir = TempDir::new().unwrap();
136        let cwd = temp_dir.path();
137        let traversal_path = cwd.join("..").join("etc").join("passwd");
138
139        let result = validate_path_within_cwd(&traversal_path, cwd);
140        assert!(result.is_err());
141        if let Err(PathTraversalError { path, cwd: _ }) = result {
142            assert_eq!(path, traversal_path);
143        }
144    }
145
146    #[test]
147    fn test_normalize_and_validate_relative_path() {
148        let temp_dir = TempDir::new().unwrap();
149        let cwd = temp_dir.path();
150        let relative_path = Path::new("src/index.ts");
151        let expected = cwd.join("src").join("index.ts");
152
153        let result = normalize_and_validate_path(relative_path, cwd).unwrap();
154        assert_eq!(result, expected);
155    }
156
157    #[test]
158    fn test_normalize_and_validate_traversal_attempt() {
159        let temp_dir = TempDir::new().unwrap();
160        let cwd = temp_dir.path();
161        let traversal_path = Path::new("../../../etc/passwd");
162
163        let result = normalize_and_validate_path(traversal_path, cwd);
164        assert!(result.is_err());
165    }
166
167    #[test]
168    fn test_normalize_and_validate_with_dot_components() {
169        let temp_dir = TempDir::new().unwrap();
170        let cwd = temp_dir.path();
171        let path_with_dots = Path::new("./src/../src/./index.ts");
172        let expected = cwd.join("src").join("index.ts");
173
174        let result = normalize_and_validate_path(path_with_dots, cwd).unwrap();
175        assert_eq!(result, expected);
176    }
177}