agpm_cli/utils/
path_validation.rs

1//! Path validation and security utilities for AGPM.
2//!
3//! This module provides utilities for safe path handling, validation,
4//! and security checks to prevent path traversal attacks.
5
6use anyhow::{Context, Result, anyhow};
7use std::path::{Component, Path, PathBuf};
8
9/// Validates that a path is safe and within project boundaries.
10///
11/// # Arguments
12/// * `path` - The path to validate
13/// * `project_dir` - The project root directory
14///
15/// # Returns
16/// The canonicalized path if valid
17///
18/// # Errors
19/// Returns an error if:
20/// - The path doesn't exist
21/// - The path escapes the project directory
22/// - The path cannot be canonicalized
23pub fn validate_project_path(path: &Path, project_dir: &Path) -> Result<PathBuf> {
24    let canonical = safe_canonicalize(path)?;
25    let project_canonical = safe_canonicalize(project_dir)?;
26
27    if !canonical.starts_with(&project_canonical) {
28        return Err(anyhow!("Path '{}' escapes project directory", path.display()));
29    }
30
31    Ok(canonical)
32}
33
34/// Safely canonicalizes a path, handling various edge cases.
35///
36/// # Arguments
37/// * `path` - The path to canonicalize
38///
39/// # Returns
40/// The canonicalized path
41///
42/// # Errors
43/// Returns an error if the path cannot be canonicalized
44pub fn safe_canonicalize(path: &Path) -> Result<PathBuf> {
45    // First check if the path exists
46    if !path.exists() {
47        // If it doesn't exist, try to canonicalize the parent
48        if let Some(parent) = path.parent()
49            && parent.exists()
50        {
51            let canonical_parent = parent.canonicalize().with_context(|| {
52                format!("Failed to canonicalize parent of '{}'", path.display())
53            })?;
54
55            if let Some(file_name) = path.file_name() {
56                return Ok(canonical_parent.join(file_name));
57            }
58        }
59        return Err(anyhow!("Path does not exist: {}", path.display()));
60    }
61
62    path.canonicalize().with_context(|| format!("Failed to canonicalize path: {}", path.display()))
63}
64
65/// Ensures a path is within a specific directory boundary.
66///
67/// # Arguments
68/// * `path` - The path to check
69/// * `boundary` - The boundary directory
70///
71/// # Returns
72/// `true` if the path is within the boundary
73pub fn ensure_within_directory(path: &Path, boundary: &Path) -> Result<bool> {
74    let canonical_path = safe_canonicalize(path)?;
75    let canonical_boundary = safe_canonicalize(boundary)?;
76
77    Ok(canonical_path.starts_with(&canonical_boundary))
78}
79
80/// Validates that a path doesn't contain dangerous components.
81///
82/// # Arguments
83/// * `path` - The path to validate
84///
85/// # Returns
86/// `Ok(())` if the path is safe
87///
88/// # Errors
89/// Returns an error if the path contains dangerous components like:
90/// - Parent directory references (..)
91pub fn validate_no_traversal(path: &Path) -> Result<()> {
92    for component in path.components() {
93        match component {
94            Component::ParentDir => {
95                return Err(anyhow!(
96                    "Path contains parent directory reference (..): {}",
97                    path.display()
98                ));
99            }
100            // Allow RootDir for absolute paths or paths that start with /
101            // On Windows, /path is not absolute but is still valid within a project
102            Component::RootDir => {
103                // RootDir is OK - it just means the path starts with /
104                // This is valid for both absolute paths and project-relative paths
105            }
106            _ => {}
107        }
108    }
109    Ok(())
110}
111
112/// Creates a safe relative path from a base directory.
113///
114/// # Arguments
115/// * `base` - The base directory
116/// * `target` - The target path
117///
118/// # Returns
119/// A relative path from base to target, or None if not possible
120pub fn safe_relative_path(base: &Path, target: &Path) -> Result<PathBuf> {
121    let base_canonical = safe_canonicalize(base)?;
122    let target_canonical = safe_canonicalize(target)?;
123
124    target_canonical.strip_prefix(&base_canonical).map(std::path::Path::to_path_buf).map_err(|_| {
125        anyhow!("Cannot create relative path from {} to {}", base.display(), target.display())
126    })
127}
128
129/// Ensures a directory exists, creating it if necessary.
130///
131/// # Arguments
132/// * `dir` - The directory path
133///
134/// # Returns
135/// The canonical path to the directory
136///
137/// # Errors
138/// Returns an error if the directory cannot be created
139pub fn ensure_directory_exists(dir: &Path) -> Result<PathBuf> {
140    if !dir.exists() {
141        std::fs::create_dir_all(dir)
142            .with_context(|| format!("Failed to create directory: {}", dir.display()))?;
143    }
144
145    safe_canonicalize(dir)
146}
147
148/// Validates and normalizes a file path for a specific resource type.
149///
150/// # Arguments
151/// * `path` - The path to validate
152/// * `resource_type` - The type of resource (e.g., "agent", "snippet")
153/// * `project_dir` - The project directory
154///
155/// # Returns
156/// The validated and normalized path
157pub fn validate_resource_path(
158    path: &Path,
159    resource_type: &str,
160    project_dir: &Path,
161) -> Result<PathBuf> {
162    // Ensure no traversal attempts
163    validate_no_traversal(path)?;
164
165    // Build the full path
166    let full_path = if path.is_absolute() {
167        path.to_path_buf()
168    } else {
169        project_dir.join(path)
170    };
171
172    // For new files that don't exist yet, validate the parent directory
173    let canonical_project = safe_canonicalize(project_dir)?;
174
175    if full_path.exists() {
176        // If file exists, validate it's within project
177        validate_project_path(&full_path, project_dir)?;
178    } else {
179        // For non-existent files, check parent directory
180        if let Some(parent) = full_path.parent()
181            && parent.exists()
182        {
183            let canonical_parent = safe_canonicalize(parent)?;
184            if !canonical_parent.starts_with(&canonical_project) {
185                return Err(anyhow!("Path '{}' escapes project directory", full_path.display()));
186            }
187        }
188    }
189
190    // Check file extension for resource files
191    if resource_type != "directory" && full_path.extension().is_none_or(|ext| ext != "md") {
192        return Err(anyhow!(
193            "Invalid {} file: expected .md extension, got {}",
194            resource_type,
195            full_path.display()
196        ));
197    }
198
199    Ok(full_path)
200}
201
202/// Sanitizes a file name to remove potentially dangerous characters.
203///
204/// # Arguments
205/// * `name` - The file name to sanitize
206///
207/// # Returns
208/// A sanitized version of the file name
209pub fn sanitize_file_name(name: &str) -> String {
210    name.chars().filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_' || *c == '.').collect()
211}
212
213/// Gets the project root directory from a path.
214///
215/// Searches upward from the given path to find a directory containing agpm.toml
216///
217/// # Arguments
218/// * `start_path` - The path to start searching from
219///
220/// # Returns
221/// The project root directory if found
222pub fn find_project_root(start_path: &Path) -> Result<PathBuf> {
223    let mut current = if start_path.is_file() {
224        start_path.parent().ok_or_else(|| anyhow!("Invalid start path"))?
225    } else {
226        start_path
227    };
228
229    loop {
230        if current.join("agpm.toml").exists() {
231            return safe_canonicalize(current);
232        }
233
234        match current.parent() {
235            Some(parent) => current = parent,
236            None => {
237                return Err(anyhow!(
238                    "No agpm.toml found in any parent directory of {}",
239                    start_path.display()
240                ));
241            }
242        }
243    }
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249    use std::fs;
250    use tempfile::tempdir;
251
252    #[test]
253    fn test_validate_no_traversal() {
254        // Valid paths
255        assert!(validate_no_traversal(Path::new("foo/bar")).is_ok());
256        assert!(validate_no_traversal(Path::new("/absolute/path")).is_ok());
257        assert!(validate_no_traversal(Path::new("./relative")).is_ok());
258
259        // Invalid paths
260        assert!(validate_no_traversal(Path::new("../parent")).is_err());
261        assert!(validate_no_traversal(Path::new("foo/../bar")).is_err());
262        assert!(validate_no_traversal(Path::new("../../escape")).is_err());
263    }
264
265    #[test]
266    fn test_sanitize_file_name() {
267        assert_eq!(sanitize_file_name("valid-name_123.md"), "valid-name_123.md");
268        assert_eq!(sanitize_file_name("bad/\\name<>:|?*"), "badname");
269        assert_eq!(sanitize_file_name("spaces are removed"), "spacesareremoved");
270    }
271
272    #[test]
273    fn test_validate_project_path() -> Result<()> {
274        let temp_dir = tempdir()?;
275        let project_dir = temp_dir.path();
276
277        // Create a test file
278        let test_file = project_dir.join("test.md");
279        fs::write(&test_file, "test")?;
280
281        // Valid path within project
282        let result = validate_project_path(&test_file, project_dir)?;
283        let canonical_project = project_dir.canonicalize()?;
284        assert!(result.starts_with(&canonical_project));
285
286        // Path outside project should fail
287        let outside_path = temp_dir.path().parent().unwrap().join("outside.md");
288        assert!(validate_project_path(&outside_path, project_dir).is_err());
289
290        Ok(())
291    }
292
293    #[test]
294    fn test_ensure_directory_exists() -> Result<()> {
295        let temp_dir = tempdir()?;
296        let new_dir = temp_dir.path().join("new").join("nested").join("dir");
297
298        assert!(!new_dir.exists());
299
300        let result = ensure_directory_exists(&new_dir)?;
301        assert!(result.exists());
302        assert!(result.is_dir());
303
304        Ok(())
305    }
306
307    #[test]
308    fn test_find_project_root() -> Result<()> {
309        let temp_dir = tempdir()?;
310        let project_dir = temp_dir.path();
311
312        // Create agpm.toml
313        fs::write(project_dir.join("agpm.toml"), "[project]")?;
314
315        // Create nested directory
316        let nested = project_dir.join("src").join("nested");
317        fs::create_dir_all(&nested)?;
318
319        // Should find root from nested directory
320        let found = find_project_root(&nested)?;
321        assert_eq!(found, project_dir.canonicalize()?);
322
323        // Should find root from file in nested directory
324        let file_path = nested.join("file.rs");
325        fs::write(&file_path, "// test")?;
326        let found = find_project_root(&file_path)?;
327        assert_eq!(found, project_dir.canonicalize()?);
328
329        Ok(())
330    }
331
332    #[test]
333    fn test_safe_relative_path() -> Result<()> {
334        let temp_dir = tempdir()?;
335        let base = temp_dir.path();
336        let target = base.join("subdir").join("file.md");
337
338        // Create the target directory
339        fs::create_dir_all(target.parent().unwrap())?;
340        fs::write(&target, "test")?;
341
342        let relative = safe_relative_path(base, &target)?;
343        assert_eq!(relative, Path::new("subdir").join("file.md"));
344
345        Ok(())
346    }
347
348    #[test]
349    fn test_validate_resource_path() -> Result<()> {
350        let temp_dir = tempdir()?;
351        let project_dir = temp_dir.path();
352
353        // Valid agent path
354        let agent_path = Path::new("agents/my-agent.md");
355        validate_resource_path(agent_path, "agent", project_dir)?;
356
357        // Invalid extension
358        let wrong_ext = Path::new("agents/my-agent.txt");
359        let result = validate_resource_path(wrong_ext, "agent", project_dir);
360        assert!(result.is_err());
361
362        // Path with traversal
363        let traversal = Path::new("../outside/agent.md");
364        let result = validate_resource_path(traversal, "agent", project_dir);
365        assert!(result.is_err());
366
367        Ok(())
368    }
369}