aether/sandbox/
path_validator.rs1use std::collections::HashSet;
6use std::path::{Component, Path, PathBuf};
7
8#[derive(Debug, Clone, PartialEq)]
10pub enum PathValidationError {
11 OutsideRoot { path: PathBuf, root: PathBuf },
13 AbsolutePathNotAllowed(PathBuf),
15 ParentTraversalNotAllowed(PathBuf),
17 ExtensionNotAllowed { path: PathBuf, extension: String },
19 InvalidPath(PathBuf),
21}
22
23impl std::fmt::Display for PathValidationError {
24 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
25 match self {
26 PathValidationError::OutsideRoot { path, root } => {
27 write!(
28 f,
29 "Path '{}' is outside allowed root '{}'",
30 path.display(),
31 root.display()
32 )
33 }
34 PathValidationError::AbsolutePathNotAllowed(path) => {
35 write!(f, "Absolute path '{}' not allowed", path.display())
36 }
37 PathValidationError::ParentTraversalNotAllowed(path) => {
38 write!(
39 f,
40 "Parent traversal '..' not allowed in path '{}'",
41 path.display()
42 )
43 }
44 PathValidationError::ExtensionNotAllowed { path, extension } => {
45 write!(
46 f,
47 "File extension '{}' not allowed for path '{}'",
48 extension,
49 path.display()
50 )
51 }
52 PathValidationError::InvalidPath(path) => {
53 write!(f, "Invalid path '{}'", path.display())
54 }
55 }
56 }
57}
58
59impl std::error::Error for PathValidationError {}
60
61#[derive(Debug, Clone)]
63pub struct PathRestriction {
64 pub root_dir: PathBuf,
66 pub allow_absolute: bool,
68 pub allow_parent_traversal: bool,
70 pub allowed_extensions: Option<HashSet<String>>,
72}
73
74impl Default for PathRestriction {
75 fn default() -> Self {
76 Self {
77 root_dir: PathBuf::from("."),
78 allow_absolute: false,
79 allow_parent_traversal: false,
80 allowed_extensions: None,
81 }
82 }
83}
84
85#[derive(Clone)]
87pub struct PathValidator {
88 restriction: PathRestriction,
89}
90
91impl PathValidator {
92 pub fn new(restriction: PathRestriction) -> Self {
94 Self { restriction }
95 }
96
97 pub fn with_root_dir(root_dir: PathBuf) -> Self {
99 Self::new(PathRestriction {
100 root_dir,
101 allow_absolute: false,
102 allow_parent_traversal: false,
103 allowed_extensions: None,
104 })
105 }
106
107 pub fn validate_and_normalize(&self, path: &Path) -> Result<PathBuf, PathValidationError> {
111 if path.is_absolute() && !self.restriction.allow_absolute {
113 return Err(PathValidationError::AbsolutePathNotAllowed(
114 path.to_path_buf(),
115 ));
116 }
117
118 if !self.restriction.allow_parent_traversal {
120 let path_str = path.to_string_lossy();
121 if path_str.contains("..") {
122 return Err(PathValidationError::ParentTraversalNotAllowed(
123 path.to_path_buf(),
124 ));
125 }
126 }
127
128 let normalized = self.canonicalize_safe(path)?;
130
131 if let Ok(root) = self.restriction.root_dir.canonicalize()
133 && !normalized.starts_with(&root)
134 {
135 return Err(PathValidationError::OutsideRoot {
136 path: normalized.clone(),
137 root,
138 });
139 }
140
141 if let Some(allowed) = &self.restriction.allowed_extensions
143 && let Some(ext) = normalized.extension()
144 {
145 let ext_str = ext.to_string_lossy().to_lowercase();
146 if !allowed.contains(&ext_str) {
147 return Err(PathValidationError::ExtensionNotAllowed {
148 path: normalized.clone(),
149 extension: ext_str,
150 });
151 }
152 }
153
154 Ok(normalized)
155 }
156
157 fn canonicalize_safe(&self, path: &Path) -> Result<PathBuf, PathValidationError> {
159 let full_path = if path.is_absolute() {
161 path.to_path_buf()
162 } else {
163 self.restriction.root_dir.join(path)
164 };
165
166 match full_path.canonicalize() {
168 Ok(canon) => Ok(canon),
169 Err(_) => {
170 let mut result = PathBuf::new();
172 for component in full_path.components() {
173 match component {
174 Component::Prefix(_) | Component::RootDir | Component::Normal(_) => {
175 result.push(component);
176 }
177 Component::CurDir => {
178 }
180 Component::ParentDir => {
181 if !self.restriction.allow_parent_traversal {
182 return Err(PathValidationError::ParentTraversalNotAllowed(
183 full_path,
184 ));
185 }
186 if !result.pop() {
188 return Err(PathValidationError::InvalidPath(full_path));
189 }
190 }
191 }
192 }
193 Ok(result)
194 }
195 }
196 }
197
198 pub fn is_valid(&self, path: &Path) -> bool {
200 self.validate_and_normalize(path).is_ok()
201 }
202
203 pub fn restriction(&self) -> &PathRestriction {
205 &self.restriction
206 }
207}
208
209#[cfg(test)]
210mod tests {
211 use super::*;
212
213 #[test]
214 fn test_path_validator_blocks_parent_traversal() {
215 let restriction = PathRestriction {
216 root_dir: PathBuf::from("/safe"),
217 allow_absolute: false,
218 allow_parent_traversal: false,
219 allowed_extensions: None,
220 };
221
222 let validator = PathValidator::new(restriction);
223
224 assert!(
226 validator
227 .validate_and_normalize(Path::new("../etc/passwd"))
228 .is_err()
229 );
230 assert!(
231 validator
232 .validate_and_normalize(Path::new("safe/../../etc/passwd"))
233 .is_err()
234 );
235 }
236
237 #[test]
238 fn test_path_validator_blocks_absolute_paths() {
239 let restriction = PathRestriction {
240 root_dir: PathBuf::from("/safe"),
241 allow_absolute: false,
242 allow_parent_traversal: false,
243 allowed_extensions: None,
244 };
245
246 let validator = PathValidator::new(restriction);
247
248 assert!(
250 validator
251 .validate_and_normalize(Path::new("/etc/passwd"))
252 .is_err()
253 );
254 }
255
256 #[test]
257 fn test_path_validator_extension_whitelist() {
258 let mut allowed = HashSet::new();
259 allowed.insert("aether".to_string());
260 allowed.insert("txt".to_string());
261
262 let restriction = PathRestriction {
263 root_dir: PathBuf::from("/safe"),
264 allow_absolute: false,
265 allow_parent_traversal: false,
266 allowed_extensions: Some(allowed),
267 };
268
269 let _validator = PathValidator::new(restriction);
270
271 use std::fs;
273 let temp_dir = std::env::temp_dir();
274 let test_file = temp_dir.join("test.aether");
275 fs::write(&test_file, "test").unwrap();
276
277 }
280
281 #[test]
282 fn test_path_validator_with_root_dir() {
283 let validator = PathValidator::with_root_dir(PathBuf::from("/tmp/test"));
284
285 let result = validator.validate_and_normalize(Path::new("subdir/file.txt"));
287 assert!(result.is_ok() || result.is_err()); }
290
291 #[test]
292 fn test_path_error_display() {
293 let err = PathValidationError::OutsideRoot {
294 path: PathBuf::from("/etc/passwd"),
295 root: PathBuf::from("/safe"),
296 };
297 assert!(err.to_string().contains("outside allowed root"));
298
299 let err = PathValidationError::AbsolutePathNotAllowed(PathBuf::from("/etc/passwd"));
300 assert!(err.to_string().contains("not allowed"));
301
302 let err = PathValidationError::ParentTraversalNotAllowed(PathBuf::from("../file"));
303 assert!(err.to_string().contains("Parent traversal"));
304 }
305}