1use anyhow::Result;
7use camino::Utf8Path;
8use std::path::{Path, PathBuf};
9
10#[derive(Debug, thiserror::Error)]
12pub enum PathValidationError {
13 #[error("cannot canonicalize path: {0}")]
15 CannotCanonicalize(String),
16
17 #[error("path escapes project root: {0} (root: {1})")]
19 OutsideRoot(String, String),
20
21 #[error("path contains suspicious traversal patterns: {0}")]
23 SuspiciousTraversal(String),
24
25 #[error("symlink escapes project root: {0} -> {1}")]
27 SymlinkEscape(String, String),
28}
29
30pub fn canonicalize_path(path: &Path) -> Result<PathBuf, PathValidationError> {
41 std::fs::canonicalize(path)
42 .map_err(|_| PathValidationError::CannotCanonicalize(path.to_string_lossy().to_string()))
43}
44
45pub fn normalize_path(path: &Path) -> Result<String> {
72 if let Ok(canonical) = std::fs::canonicalize(path) {
74 return Ok(canonical.to_string_lossy().to_string());
75 }
76
77 let path_str = path.to_string_lossy().to_string();
80 let normalized = if let Some(stripped) = path_str.strip_prefix("./") {
81 stripped.to_string()
82 } else {
83 path_str
84 };
85
86 Ok(normalized)
87}
88
89pub fn validate_path_within_root(path: &Path, root: &Path) -> Result<PathBuf, PathValidationError> {
107 let path_str = path.to_string_lossy();
111 if has_suspicious_traversal(&path_str) {
112 return Err(PathValidationError::SuspiciousTraversal(
113 path_str.to_string(),
114 ));
115 }
116
117 let canonical_path = canonicalize_path(path)?;
119 let canonical_root = canonicalize_path(root)
120 .map_err(|_| PathValidationError::CannotCanonicalize(root.to_string_lossy().to_string()))?;
121
122 if !canonical_path.starts_with(&canonical_root) {
124 return Err(PathValidationError::OutsideRoot(
125 canonical_path.to_string_lossy().to_string(),
126 canonical_root.to_string_lossy().to_string(),
127 ));
128 }
129
130 Ok(canonical_path)
131}
132
133pub fn has_suspicious_traversal(path: &str) -> bool {
142 let path_normalized = path.replace('\\', "/");
145
146 let parent_count = path_normalized.matches("../").count();
149 if parent_count >= 3 {
150 return true;
151 }
152
153 if path_normalized.starts_with("../") && !path_normalized.starts_with("../../") {
157 let depth = path_normalized.matches('/').count();
159 if depth <= 2 {
160 return true;
161 }
162 }
163
164 let path_win = path.replace('/', "\\");
167 if path_win.starts_with("..\\") && !path_win.starts_with("..\\..\\") {
168 let depth = path_win.matches('\\').count();
169 if depth <= 2 {
170 return true;
171 }
172 }
173
174 let parts: Vec<&str> = path_normalized.split('/').collect();
179 for (i, part) in parts.iter().enumerate() {
180 if *part == "." && i < parts.len() - 1 {
181 if parts[i + 1..].contains(&"..") {
183 return true;
184 }
185 }
186 }
187
188 let parts_win: Vec<&str> = path_win.split('\\').collect();
190 for (i, part) in parts_win.iter().enumerate() {
191 if *part == "." && i < parts_win.len() - 1 && parts_win[i + 1..].contains(&"..") {
192 return true;
193 }
194 }
195
196 false
197}
198
199pub fn is_safe_symlink(symlink_path: &Path, root: &Path) -> Result<bool, PathValidationError> {
210 let target = std::fs::read_link(symlink_path).map_err(|_| {
212 PathValidationError::CannotCanonicalize(symlink_path.to_string_lossy().to_string())
213 })?;
214
215 if target.is_absolute() {
217 match validate_path_within_root(&target, root) {
218 Ok(_) => return Ok(true),
219 Err(PathValidationError::OutsideRoot(_, _)) => {
220 return Err(PathValidationError::SymlinkEscape(
221 symlink_path.to_string_lossy().to_string(),
222 target.to_string_lossy().to_string(),
223 ))
224 }
225 Err(e) => return Err(e),
226 }
227 }
228
229 let parent = symlink_path.parent().unwrap_or(symlink_path);
231 let resolved = parent.join(&target);
232
233 match validate_path_within_root(&resolved, root) {
234 Ok(_) => Ok(true),
235 Err(PathValidationError::OutsideRoot(_, _)) => Err(PathValidationError::SymlinkEscape(
236 symlink_path.to_string_lossy().to_string(),
237 target.to_string_lossy().to_string(),
238 )),
239 Err(e) => Err(e),
240 }
241}
242
243pub fn validate_utf8_path(
247 utf8_path: &Utf8Path,
248 root: &Path,
249) -> Result<PathBuf, PathValidationError> {
250 let path = Path::new(utf8_path.as_str());
251 validate_path_within_root(path, root)
252}
253
254#[cfg(test)]
255mod tests {
256 use super::*;
257 use std::fs;
258 use tempfile::TempDir;
259
260 #[test]
261 fn test_normalize_path_relative_prefix() {
262 let temp_dir = TempDir::new().unwrap();
263
264 let nonexist = temp_dir.path().join("./src/lib.rs");
266 let result = normalize_path(&nonexist).unwrap();
267 assert!(result.contains("src/lib.rs"));
268 assert!(!result.starts_with("./"));
269
270 let nonexist2 = temp_dir.path().join("src/main.rs");
272 let result = normalize_path(&nonexist2).unwrap();
273 assert!(result.contains("src/main.rs"));
274
275 let test_file = temp_dir.path().join("test.rs");
277 std::fs::write(&test_file, b"fn test() {}").unwrap();
278 let result = normalize_path(&test_file).unwrap();
279 assert!(result.contains("test.rs"));
280
281 let relative_to_temp = temp_dir.path().join("./test.rs");
283 let result = normalize_path(&relative_to_temp).unwrap();
284 assert!(result.contains("test.rs"));
285 assert!(!result.starts_with("./"));
286 }
287
288 #[test]
289 fn test_normalize_path_absolute() {
290 let temp_dir = TempDir::new().unwrap();
291
292 let test_file = temp_dir.path().join("absolute.rs");
294 std::fs::write(&test_file, b"fn abs() {}").unwrap();
295
296 let result = normalize_path(&test_file).unwrap();
298 assert!(result.contains("absolute.rs"));
299 assert!(!result.starts_with("./"));
301 }
302
303 #[test]
304 fn test_normalize_path_non_existing() {
305 let result = normalize_path(Path::new("./does/not/exist.rs")).unwrap();
307 assert_eq!(result, "does/not/exist.rs");
308
309 let result = normalize_path(Path::new("nonexistent/path.rs")).unwrap();
311 assert_eq!(result, "nonexistent/path.rs");
312 }
313
314 #[test]
315 fn test_normalize_path_redundant_dots() {
316 let temp_dir = TempDir::new().unwrap();
317
318 let subdir = temp_dir.path().join("a/b");
320 std::fs::create_dir_all(&subdir).unwrap();
321 let test_file = subdir.join("test.rs");
322 std::fs::write(&test_file, b"fn test() {}").unwrap();
323
324 let result = normalize_path(&test_file).unwrap();
326 assert!(result.contains("test.rs"));
327 assert!(!result.contains(".."));
329 }
330
331 #[test]
332 fn test_has_suspicious_traversal_parent_patterns() {
333 assert!(has_suspicious_traversal("../../../etc/passwd"));
334 assert!(has_suspicious_traversal(
335 "..\\\\..\\\\..\\\\windows\\\\system32"
336 ));
337 assert!(has_suspicious_traversal("../config"));
338 assert!(has_suspicious_traversal("..\\config"));
339 }
340
341 #[test]
342 fn test_has_suspicious_traversal_mixed_patterns() {
343 assert!(has_suspicious_traversal("./subdir/../../etc"));
344 assert!(has_suspicious_traversal(".\\subdir\\..\\..\\etc"));
345 }
346
347 #[test]
348 fn test_has_suspicious_traversal_normal_paths() {
349 assert!(!has_suspicious_traversal("src/main.rs"));
350 assert!(!has_suspicious_traversal("./src/lib.rs"));
351 assert!(!has_suspicious_traversal("../parent/src/lib.rs")); assert!(!has_suspicious_traversal("../../normal")); }
354
355 #[test]
356 fn test_validate_path_within_root_valid() {
357 let temp_dir = TempDir::new().unwrap();
358 let root = temp_dir.path();
359
360 let file_path = root.join("test.rs");
362 fs::write(&file_path, b"fn test() {}").unwrap();
363
364 let result = validate_path_within_root(&file_path, root);
365 assert!(result.is_ok());
366 assert!(result.unwrap().starts_with(root));
367 }
368
369 #[test]
370 fn test_validate_path_within_root_traversal_rejected() {
371 let temp_dir = TempDir::new().unwrap();
372 let root = temp_dir.path();
373
374 let outside = root.join("../../../etc/passwd");
376
377 let result = validate_path_within_root(&outside, root);
378 assert!(result.is_err());
379 assert!(matches!(
380 result.unwrap_err(),
381 PathValidationError::SuspiciousTraversal(_)
382 ));
383 }
384
385 #[test]
386 fn test_validate_path_within_root_absolute_outside() {
387 let temp_dir = TempDir::new().unwrap();
388 let root = temp_dir.path();
389
390 let outside = Path::new("/etc/passwd");
392
393 let result = validate_path_within_root(outside, root);
394 assert!(result.is_err());
395
396 match result.unwrap_err() {
398 PathValidationError::SuspiciousTraversal(_) => {}
399 PathValidationError::OutsideRoot(_, _) => {}
400 _ => panic!("Expected traversal or outside error"),
401 }
402 }
403
404 #[test]
405 fn test_is_safe_symlink_inside_root() {
406 let temp_dir = TempDir::new().unwrap();
407 let root = temp_dir.path();
408
409 let target = root.join("target.rs");
411 fs::write(&target, b"fn target() {}").unwrap();
412
413 let symlink = root.join("link.rs");
415 #[cfg(unix)]
416 std::os::unix::fs::symlink(&target, &symlink).unwrap();
417
418 #[cfg(windows)]
419 std::os::windows::fs::symlink_file(&target, &symlink).unwrap();
420
421 #[cfg(any(unix, windows))]
423 {
424 let result = is_safe_symlink(&symlink, root);
425 assert!(result.is_ok());
426 assert!(result.unwrap());
427 }
428 }
429
430 #[test]
431 fn test_is_safe_symlink_outside_root() {
432 let temp_dir = TempDir::new().unwrap();
433 let root = temp_dir.path();
434
435 let outside_dir = TempDir::new().unwrap();
437 let target = outside_dir.path().join("outside.rs");
438 fs::write(&target, b"fn outside() {}").unwrap();
439
440 let symlink = root.join("link.rs");
442 #[cfg(unix)]
443 std::os::unix::fs::symlink(&target, &symlink).unwrap();
444
445 #[cfg(windows)]
446 std::os::windows::fs::symlink_file(&target, &symlink).unwrap();
447
448 #[cfg(any(unix, windows))]
450 {
451 let result = is_safe_symlink(&symlink, root);
452 assert!(result.is_err());
453 match result.unwrap_err() {
455 PathValidationError::SymlinkEscape(_, _) => {}
456 PathValidationError::CannotCanonicalize(_) => {
457 }
459 other => panic!(
460 "Expected SymlinkEscape or CannotCanonicalize, got: {:?}",
461 other
462 ),
463 }
464 }
465 }
466
467 #[test]
468 fn test_cross_platform_path_handling() {
469 let temp_dir = TempDir::new().unwrap();
470 let root = temp_dir.path();
471
472 let subdir = root.join("src");
474 fs::create_dir(&subdir).unwrap();
475 let file_path = subdir.join("main.rs");
476 fs::write(&file_path, b"fn main() {}").unwrap();
477
478 let path_str = file_path.to_string_lossy().replace('\\', "/");
480 let result = validate_path_within_root(Path::new(&path_str), root);
481 assert!(result.is_ok());
482
483 if cfg!(windows) {
486 let path_str_win = file_path.to_string_lossy().replace('/', "\\");
487 let result_win = validate_path_within_root(Path::new(&path_str_win), root);
488 assert!(result_win.is_ok());
489 }
490 }
491}