1use crate::error::{Error, Result};
34use std::collections::HashSet;
35use std::path::{Path, PathBuf};
36
37#[derive(Debug, Clone, PartialEq, Eq)]
43pub enum PathValidationError {
44 PathTraversal { path: String },
46 AbsolutePath { path: String },
48 DepthExceeded {
50 path: String,
51 depth: usize,
52 max: usize,
53 },
54 NullByte { path: String },
56 InvalidExtension { path: String, expected: Vec<String> },
58 SymlinkEscape { link: String, target: String },
60 WorkspaceEscape { path: String, workspace: String },
62 EmptyPath,
64 InvalidUtf8 { path: String },
66 UnicodeNormalization { path: String, reason: String },
68}
69
70impl std::fmt::Display for PathValidationError {
71 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72 match self {
73 Self::PathTraversal { path } => {
74 write!(f, "Path traversal detected: {path} contains '..'")
75 }
76 Self::AbsolutePath { path } => {
77 write!(f, "Absolute path not allowed: {path}")
78 }
79 Self::DepthExceeded { path, depth, max } => {
80 write!(f, "Path depth {depth} exceeds maximum {max}: {path}")
81 }
82 Self::NullByte { path } => {
83 write!(f, "Path contains null byte: {path}")
84 }
85 Self::InvalidExtension { path, expected } => {
86 write!(
87 f,
88 "Invalid extension for {path}, expected one of: {}",
89 expected.join(", ")
90 )
91 }
92 Self::SymlinkEscape { link, target } => {
93 write!(f, "Symlink {link} points outside workspace: {target}")
94 }
95 Self::WorkspaceEscape { path, workspace } => {
96 write!(f, "Path {path} escapes workspace {workspace}")
97 }
98 Self::EmptyPath => {
99 write!(f, "Path cannot be empty")
100 }
101 Self::InvalidUtf8 { path } => {
102 write!(f, "Path contains invalid UTF-8: {path}")
103 }
104 Self::UnicodeNormalization { path, reason } => {
105 write!(f, "Unicode normalization issue in {path}: {reason}")
106 }
107 }
108 }
109}
110
111impl std::error::Error for PathValidationError {}
112
113impl From<PathValidationError> for Error {
114 fn from(err: PathValidationError) -> Self {
115 Error::new(&err.to_string())
116 }
117}
118
119#[derive(Debug, Clone, PartialEq, Eq, Hash)]
133pub struct SafePath {
134 inner: PathBuf,
136 absolute: PathBuf,
138}
139
140impl SafePath {
141 #[must_use]
143 pub fn as_path(&self) -> &Path {
144 &self.inner
145 }
146
147 #[must_use]
149 pub fn absolute(&self) -> &Path {
150 &self.absolute
151 }
152
153 #[must_use]
155 pub fn to_path_buf(&self) -> PathBuf {
156 self.inner.clone()
157 }
158
159 #[must_use]
161 pub fn extension(&self) -> Option<&str> {
162 self.inner.extension().and_then(|s| s.to_str())
163 }
164
165 #[must_use]
167 pub fn file_name(&self) -> Option<&str> {
168 self.inner.file_name().and_then(|s| s.to_str())
169 }
170}
171
172impl AsRef<Path> for SafePath {
173 fn as_ref(&self) -> &Path {
174 &self.inner
175 }
176}
177
178impl std::fmt::Display for SafePath {
179 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
180 write!(f, "{}", self.inner.display())
181 }
182}
183
184#[derive(Debug, Clone)]
198pub struct PathValidator {
199 workspace_root: PathBuf,
201 max_depth: Option<usize>,
203 allowed_extensions: HashSet<String>,
205 allow_absolute: bool,
207 follow_symlinks: bool,
209}
210
211impl PathValidator {
212 #[must_use]
223 pub fn new(workspace_root: &Path) -> Self {
224 Self {
225 workspace_root: workspace_root.to_path_buf(),
226 max_depth: None,
227 allowed_extensions: HashSet::new(),
228 allow_absolute: false,
229 follow_symlinks: true,
230 }
231 }
232
233 #[must_use]
235 pub fn with_max_depth(mut self, max_depth: usize) -> Self {
236 self.max_depth = Some(max_depth);
237 self
238 }
239
240 #[must_use]
242 pub fn with_allowed_extensions(mut self, extensions: Vec<&str>) -> Self {
243 self.allowed_extensions = extensions.into_iter().map(|s| s.to_string()).collect();
244 self
245 }
246
247 #[must_use]
249 pub fn with_absolute_paths(mut self, allow: bool) -> Self {
250 self.allow_absolute = allow;
251 self
252 }
253
254 #[must_use]
256 pub fn with_follow_symlinks(mut self, follow: bool) -> Self {
257 self.follow_symlinks = follow;
258 self
259 }
260
261 pub fn validate(&self, path: impl AsRef<Path>) -> Result<SafePath> {
274 let path = path.as_ref();
275
276 if path.as_os_str().is_empty() {
278 return Err(PathValidationError::EmptyPath.into());
279 }
280
281 let path_str = path
283 .to_str()
284 .ok_or_else(|| PathValidationError::InvalidUtf8 {
285 path: path.display().to_string(),
286 })?;
287
288 if path_str.contains('\0') {
290 return Err(PathValidationError::NullByte {
291 path: path_str.to_string(),
292 }
293 .into());
294 }
295
296 self.check_path_traversal(path)?;
298
299 if path.is_absolute() && !self.allow_absolute {
301 return Err(PathValidationError::AbsolutePath {
302 path: path_str.to_string(),
303 }
304 .into());
305 }
306
307 if let Some(max_depth) = self.max_depth {
309 let depth = path.components().count();
310 if depth > max_depth {
311 return Err(PathValidationError::DepthExceeded {
312 path: path_str.to_string(),
313 depth,
314 max: max_depth,
315 }
316 .into());
317 }
318 }
319
320 if !self.allowed_extensions.is_empty() {
322 self.check_extension(path)?;
323 }
324
325 let absolute = self.resolve_safely(path)?;
327
328 self.check_workspace_bounds(&absolute)?;
330
331 if self.follow_symlinks {
333 self.check_symlink(&absolute)?;
334 }
335
336 Ok(SafePath {
337 inner: path.to_path_buf(),
338 absolute,
339 })
340 }
341
342 fn check_path_traversal(&self, path: &Path) -> Result<()> {
344 for component in path.components() {
345 if let std::path::Component::ParentDir = component {
346 return Err(PathValidationError::PathTraversal {
347 path: path.display().to_string(),
348 }
349 .into());
350 }
351 }
352 Ok(())
353 }
354
355 fn check_extension(&self, path: &Path) -> Result<()> {
357 let ext = path.extension().and_then(|s| s.to_str()).ok_or_else(|| {
358 PathValidationError::InvalidExtension {
359 path: path.display().to_string(),
360 expected: self.allowed_extensions.iter().cloned().collect(),
361 }
362 })?;
363
364 if !self.allowed_extensions.contains(ext) {
365 return Err(PathValidationError::InvalidExtension {
366 path: path.display().to_string(),
367 expected: self.allowed_extensions.iter().cloned().collect(),
368 }
369 .into());
370 }
371
372 Ok(())
373 }
374
375 fn resolve_safely(&self, path: &Path) -> Result<PathBuf> {
377 let base = if path.is_absolute() {
378 path.to_path_buf()
379 } else {
380 self.workspace_root.join(path)
381 };
382
383 if base.exists() {
385 base.canonicalize().map_err(|e| {
386 Error::new(&format!(
387 "Failed to canonicalize path {}: {}",
388 base.display(),
389 e
390 ))
391 })
392 } else {
393 let mut absolute = self.workspace_root.clone();
395 for component in path.components() {
396 match component {
397 std::path::Component::Normal(c) => {
398 absolute.push(c);
399 }
400 std::path::Component::RootDir if self.allow_absolute => {
401 absolute = PathBuf::from("/");
402 }
403 std::path::Component::RootDir => {}
404 std::path::Component::ParentDir => {
405 return Err(PathValidationError::PathTraversal {
407 path: path.display().to_string(),
408 }
409 .into());
410 }
411 _ => {}
412 }
413 }
414 Ok(absolute)
415 }
416 }
417
418 fn check_workspace_bounds(&self, absolute: &Path) -> Result<()> {
420 let workspace_canonical = self.workspace_root.canonicalize().map_err(|e| {
422 Error::new(&format!(
423 "Failed to canonicalize workspace root {}: {}",
424 self.workspace_root.display(),
425 e
426 ))
427 })?;
428
429 if !absolute.starts_with(&workspace_canonical) {
431 return Err(PathValidationError::WorkspaceEscape {
432 path: absolute.display().to_string(),
433 workspace: workspace_canonical.display().to_string(),
434 }
435 .into());
436 }
437
438 Ok(())
439 }
440
441 fn check_symlink(&self, path: &Path) -> Result<()> {
443 if path.is_symlink() {
445 let target = std::fs::read_link(path).map_err(|e| {
446 Error::new(&format!("Failed to read symlink {}: {}", path.display(), e))
447 })?;
448
449 let target_absolute = if target.is_absolute() {
451 target
452 } else {
453 path.parent()
454 .ok_or_else(|| Error::new("Symlink has no parent"))?
455 .join(target)
456 };
457
458 self.check_workspace_bounds(&target_absolute)?;
460 }
461
462 Ok(())
463 }
464
465 pub fn validate_relative(&self, path: impl AsRef<Path>) -> Result<SafePath> {
469 let path = path.as_ref();
470
471 if path.is_absolute() {
472 return Err(PathValidationError::AbsolutePath {
473 path: path.display().to_string(),
474 }
475 .into());
476 }
477
478 self.validate(path)
479 }
480
481 pub fn validate_batch(&self, paths: &[impl AsRef<Path>]) -> Result<Vec<SafePath>> {
483 paths.iter().map(|p| self.validate(p)).collect()
484 }
485}
486
487#[cfg(test)]
488mod tests {
489 use super::*;
490 use tempfile::tempdir;
491
492 #[test]
495 fn test_valid_relative_path() {
496 let workspace = tempdir().expect("Failed to create temp dir");
498 let validator = PathValidator::new(workspace.path());
499
500 let result = validator.validate("templates/example.tera");
502
503 assert!(result.is_ok());
505 let safe_path = result.expect("Should validate");
506 assert_eq!(safe_path.extension(), Some("tera"));
507 }
508
509 #[test]
510 fn test_path_traversal_blocked() {
511 let workspace = tempdir().expect("Failed to create temp dir");
513 let validator = PathValidator::new(workspace.path());
514
515 let result = validator.validate("../../../etc/passwd");
517
518 assert!(result.is_err());
520 let err = result.unwrap_err();
521 assert!(err.to_string().contains("Path traversal"));
522 }
523
524 #[test]
525 fn test_null_byte_blocked() {
526 let workspace = tempdir().expect("Failed to create temp dir");
528 let validator = PathValidator::new(workspace.path());
529
530 let result = validator.validate("file\0.txt");
532
533 assert!(result.is_err());
535 let err = result.unwrap_err();
536 assert!(err.to_string().contains("null byte"));
537 }
538
539 #[test]
540 fn test_absolute_path_blocked_by_default() {
541 let workspace = tempdir().expect("Failed to create temp dir");
543 let validator = PathValidator::new(workspace.path());
544
545 let result = validator.validate("/etc/passwd");
547
548 assert!(result.is_err());
550 let err = result.unwrap_err();
551 assert!(err.to_string().contains("Absolute path"));
552 }
553
554 #[test]
555 fn test_absolute_path_allowed_when_configured() {
556 let workspace = tempdir().expect("Failed to create temp dir");
558 let validator = PathValidator::new(workspace.path()).with_absolute_paths(true);
559
560 let test_file = workspace.path().join("test.txt");
562 std::fs::write(&test_file, "test").expect("Failed to create test file");
563 let result = validator.validate(&test_file);
564
565 assert!(result.is_ok());
567 }
568
569 #[test]
570 fn test_depth_limit_enforced() {
571 let workspace = tempdir().expect("Failed to create temp dir");
573 let validator = PathValidator::new(workspace.path()).with_max_depth(3);
574
575 let deep_path = "a/b/c/d/e/f/g.txt";
577 let result = validator.validate(deep_path);
578
579 assert!(result.is_err());
581 let err = result.unwrap_err();
582 assert!(err.to_string().contains("depth"));
583 }
584
585 #[test]
586 fn test_extension_validation() {
587 let workspace = tempdir().expect("Failed to create temp dir");
589 let validator =
590 PathValidator::new(workspace.path()).with_allowed_extensions(vec!["tera", "tmpl"]);
591
592 let result_valid = validator.validate("template.tera");
594 assert!(result_valid.is_ok());
595
596 let result_invalid = validator.validate("script.sh");
598
599 assert!(result_invalid.is_err());
601 let err = result_invalid.unwrap_err();
602 assert!(err.to_string().contains("Invalid extension"));
603 }
604
605 #[test]
606 fn test_empty_path_blocked() {
607 let workspace = tempdir().expect("Failed to create temp dir");
609 let validator = PathValidator::new(workspace.path());
610
611 let result = validator.validate("");
613
614 assert!(result.is_err());
616 let err = result.unwrap_err();
617 assert!(err.to_string().contains("empty"));
618 }
619
620 #[test]
621 fn test_workspace_escape_blocked() {
622 let workspace = tempdir().expect("Failed to create temp dir");
624 let validator = PathValidator::new(workspace.path()).with_absolute_paths(true);
625
626 let result = validator.validate("/etc/passwd");
628
629 assert!(result.is_err());
631 let err = result.unwrap_err();
632 assert!(err.to_string().contains("workspace"));
633 }
634
635 #[test]
636 fn test_symlink_validation() {
637 let workspace = tempdir().expect("Failed to create temp dir");
639 let validator = PathValidator::new(workspace.path());
640
641 let target = workspace.path().join("target.txt");
643 std::fs::write(&target, "content").expect("Failed to create target file");
644
645 let link = workspace.path().join("link.txt");
646 #[cfg(unix)]
647 std::os::unix::fs::symlink(&target, &link).expect("Failed to create symlink");
648
649 #[cfg(unix)]
651 let result = validator.validate("link.txt");
652
653 #[cfg(unix)]
655 assert!(result.is_ok());
656 }
657
658 #[test]
659 fn test_batch_validation() {
660 let workspace = tempdir().expect("Failed to create temp dir");
662 let validator = PathValidator::new(workspace.path());
663
664 let paths = vec!["file1.txt", "file2.txt", "templates/example.tera"];
665
666 let result = validator.validate_batch(&paths);
668
669 assert!(result.is_ok());
671 let safe_paths = result.expect("Should validate all");
672 assert_eq!(safe_paths.len(), 3);
673 }
674
675 #[test]
676 fn test_safe_path_accessors() {
677 let workspace = tempdir().expect("Failed to create temp dir");
679 let validator = PathValidator::new(workspace.path());
680
681 let safe_path = validator
683 .validate("templates/example.tera")
684 .expect("Should validate");
685
686 assert_eq!(safe_path.extension(), Some("tera"));
688 assert_eq!(safe_path.file_name(), Some("example.tera"));
689 assert_eq!(safe_path.as_path(), Path::new("templates/example.tera"));
690 }
691
692 #[test]
693 fn test_unicode_path_handling() {
694 let workspace = tempdir().expect("Failed to create temp dir");
696 let validator = PathValidator::new(workspace.path());
697
698 let result = validator.validate("templates/例え.tera");
700
701 assert!(result.is_ok());
703 }
704
705 #[test]
706 fn test_validate_relative_rejects_absolute() {
707 let workspace = tempdir().expect("Failed to create temp dir");
709 let validator = PathValidator::new(workspace.path());
710
711 let result = validator.validate_relative("/etc/passwd");
713
714 assert!(result.is_err());
716 let err = result.unwrap_err();
717 assert!(err.to_string().contains("Absolute path"));
718 }
719}