Skip to main content

graphify_security/
path_validator.rs

1//! Path traversal prevention and graph file validation.
2
3use std::path::{Path, PathBuf};
4
5use crate::SecurityError;
6
7/// Ensure a path stays within an allowed directory (no `../` traversal).
8///
9/// Both `path` and `allowed_root` are canonicalized before comparison, so
10/// symlinks and relative components are resolved.
11pub fn safe_path(path: &Path, allowed_root: &Path) -> Result<PathBuf, SecurityError> {
12    let canonical = path
13        .canonicalize()
14        .map_err(|_| SecurityError::PathTraversal(path.to_string_lossy().to_string()))?;
15    let root = allowed_root
16        .canonicalize()
17        .map_err(|_| SecurityError::PathTraversal(allowed_root.to_string_lossy().to_string()))?;
18
19    if canonical.starts_with(&root) {
20        Ok(canonical)
21    } else {
22        Err(SecurityError::PathTraversal(
23            path.to_string_lossy().to_string(),
24        ))
25    }
26}
27
28/// Validate a graph file path: must have a `.json` extension.
29pub fn validate_graph_path(path: &str) -> Result<PathBuf, SecurityError> {
30    let p = PathBuf::from(path);
31    if p.extension().and_then(|e| e.to_str()) != Some("json") {
32        return Err(SecurityError::InvalidPath(
33            "graph file must be .json".into(),
34        ));
35    }
36    Ok(p)
37}
38
39#[cfg(test)]
40mod tests {
41    use super::*;
42    use std::fs;
43
44    #[test]
45    fn test_safe_path_within_root() {
46        let dir = std::env::temp_dir().join("graphify_security_test_safe");
47        let _ = fs::create_dir_all(&dir);
48        let file = dir.join("test.json");
49        fs::write(&file, "{}").unwrap();
50
51        let result = safe_path(&file, &dir);
52        assert!(result.is_ok());
53
54        let _ = fs::remove_file(&file);
55        let _ = fs::remove_dir(&dir);
56    }
57
58    #[test]
59    fn test_safe_path_traversal_blocked() {
60        // Try to escape from a subdirectory to its parent
61        let dir = std::env::temp_dir().join("graphify_security_test_traversal");
62        let sub = dir.join("sub");
63        let _ = fs::create_dir_all(&sub);
64        // Create a file in the parent dir
65        let file = dir.join("secret.txt");
66        fs::write(&file, "secret").unwrap();
67
68        // Attempt traversal: sub/../secret.txt should be blocked when root is sub/
69        let traversal = sub.join("../secret.txt");
70        let result = safe_path(&traversal, &sub);
71        assert!(matches!(result, Err(SecurityError::PathTraversal(_))));
72
73        let _ = fs::remove_file(&file);
74        let _ = fs::remove_dir(&sub);
75        let _ = fs::remove_dir(&dir);
76    }
77
78    #[test]
79    fn test_safe_path_nonexistent_file() {
80        let result = safe_path(Path::new("/nonexistent/path/file.txt"), Path::new("/tmp"));
81        assert!(matches!(result, Err(SecurityError::PathTraversal(_))));
82    }
83
84    #[test]
85    fn test_validate_graph_path_json() {
86        let result = validate_graph_path("output/graph.json");
87        assert!(result.is_ok());
88        assert_eq!(result.unwrap(), PathBuf::from("output/graph.json"));
89    }
90
91    #[test]
92    fn test_validate_graph_path_non_json() {
93        let result = validate_graph_path("output/graph.xml");
94        assert!(matches!(result, Err(SecurityError::InvalidPath(_))));
95    }
96
97    #[test]
98    fn test_validate_graph_path_no_extension() {
99        let result = validate_graph_path("output/graph");
100        assert!(matches!(result, Err(SecurityError::InvalidPath(_))));
101    }
102
103    #[test]
104    fn test_validate_graph_path_dot_json_in_middle() {
105        // "foo.json.bak" should fail — extension is "bak"
106        let result = validate_graph_path("foo.json.bak");
107        assert!(matches!(result, Err(SecurityError::InvalidPath(_))));
108    }
109}