Skip to main content

capsule_core/wasm/utilities/
path_validator.rs

1use std::error::Error;
2use std::fmt;
3use std::path::{Path, PathBuf};
4
5#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
6pub enum FileAccessMode {
7    ReadOnly,
8
9    #[default]
10    ReadWrite,
11}
12
13#[derive(Debug)]
14pub struct ParsedPath {
15    pub path: PathBuf,
16    pub guest_path: String,
17    pub mode: FileAccessMode,
18}
19
20#[derive(Debug)]
21pub enum PathValidationError {
22    AbsolutePathNotAllowed(String),
23    EscapesProjectDirectory(String),
24    PathNotFound(String),
25    InvalidMode(String),
26}
27
28impl fmt::Display for PathValidationError {
29    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
30        match self {
31            PathValidationError::AbsolutePathNotAllowed(path) => {
32                write!(f, "Absolute paths are not allowed: {}", path)
33            }
34            PathValidationError::EscapesProjectDirectory(path) => {
35                write!(f, "Path escapes project directory: {}", path)
36            }
37            PathValidationError::PathNotFound(path) => {
38                write!(f, "Path does not exist: {}", path)
39            }
40            PathValidationError::InvalidMode(mode) => {
41                write!(
42                    f,
43                    "Invalid access mode '{}'. Use :ro (read-only) or :rw (read-write)",
44                    mode
45                )
46            }
47        }
48    }
49}
50
51impl Error for PathValidationError {}
52
53fn parse_path_with_mode(path_spec: &str) -> (String, FileAccessMode) {
54    if let Some(pos) = path_spec.rfind(':') {
55        let (path, mode_str) = path_spec.split_at(pos);
56        let mode = &mode_str[1..];
57
58        match mode {
59            "ro" => (path.to_string(), FileAccessMode::ReadOnly),
60            "rw" => (path.to_string(), FileAccessMode::ReadWrite),
61            _ => (path_spec.to_string(), FileAccessMode::default()),
62        }
63    } else {
64        (path_spec.to_string(), FileAccessMode::default())
65    }
66}
67
68pub fn validate_path(
69    path_spec: &str,
70    project_root: &Path,
71) -> Result<ParsedPath, PathValidationError> {
72    let (path_str, mode) = parse_path_with_mode(path_spec);
73    let p = Path::new(&path_str);
74
75    if p.is_absolute() {
76        return Err(PathValidationError::AbsolutePathNotAllowed(path_str));
77    }
78
79    let joined = project_root.join(p);
80    let resolved = joined
81        .canonicalize()
82        .map_err(|_| PathValidationError::PathNotFound(path_str.clone()))?;
83
84    let canonical_root = project_root
85        .canonicalize()
86        .map_err(|_| PathValidationError::EscapesProjectDirectory(path_str.clone()))?;
87
88    if !resolved.starts_with(&canonical_root) {
89        return Err(PathValidationError::EscapesProjectDirectory(path_str));
90    }
91
92    Ok(ParsedPath {
93        path: resolved,
94        guest_path: path_str,
95        mode,
96    })
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102    use std::fs;
103
104    #[test]
105    fn test_absolute_path_rejected() {
106        let temp = std::env::temp_dir();
107
108        let result = validate_path("/absolute/path", &temp);
109        assert!(matches!(
110            result,
111            Err(PathValidationError::AbsolutePathNotAllowed(_))
112        ));
113    }
114
115    #[test]
116    fn test_relative_path_works() {
117        let current = std::env::current_dir().unwrap();
118
119        let test_dir = current.join(".capsule_test");
120        let _ = fs::create_dir(&test_dir);
121
122        let result = validate_path("./.capsule_test", &current);
123
124        let _ = fs::remove_dir(&test_dir);
125
126        assert!(result.is_ok());
127        let parsed = result.unwrap();
128        assert_eq!(parsed.guest_path, "./.capsule_test");
129    }
130
131    #[test]
132    fn test_non_existent_path_fails() {
133        let current = std::env::current_dir().unwrap();
134
135        let result = validate_path("./nonexistent_dir", &current);
136        assert!(matches!(result, Err(PathValidationError::PathNotFound(_))));
137    }
138
139    #[test]
140    fn test_escape_project_root_rejected() {
141        let temp = std::env::temp_dir();
142        let subdir = temp.join("test_subdir");
143        let _ = fs::create_dir(&subdir);
144
145        let result = validate_path("../", &subdir);
146
147        let _ = fs::remove_dir(&subdir);
148
149        assert!(matches!(
150            result,
151            Err(PathValidationError::EscapesProjectDirectory(_))
152        ));
153    }
154
155    #[test]
156    fn test_parse_mode_readonly() {
157        let (path, mode) = parse_path_with_mode("./data:ro");
158        assert_eq!(path, "./data");
159        assert_eq!(mode, FileAccessMode::ReadOnly);
160    }
161
162    #[test]
163    fn test_parse_mode_readwrite() {
164        let (path, mode) = parse_path_with_mode("./output:rw");
165        assert_eq!(path, "./output");
166        assert_eq!(mode, FileAccessMode::ReadWrite);
167    }
168
169    #[test]
170    fn test_parse_mode_default() {
171        let (path, mode) = parse_path_with_mode("./data");
172        assert_eq!(path, "./data");
173        assert_eq!(mode, FileAccessMode::ReadWrite);
174    }
175}