Skip to main content

ggen_utils/
path_validator.rs

1//! Comprehensive Path Validation for Security-Critical File Operations
2//!
3//! This module provides enterprise-grade path validation to prevent:
4//! - Path traversal attacks (../, ../../etc/passwd)
5//! - Symlink attacks (following links outside workspace)
6//! - Null byte injection
7//! - Unicode normalization attacks
8//! - Absolute path escapes
9//! - Depth limit violations
10//! - Extension mismatch attacks
11//!
12//! ## Usage
13//!
14//! ```rust
15//! use ggen_utils::path_validator::{PathValidator, SafePath};
16//! use std::path::Path;
17//!
18//! # fn main() -> ggen_utils::error::Result<()> {
19//! // Create validator with workspace root
20//! let validator = PathValidator::new(Path::new("/workspace"))
21//!     .with_max_depth(10)
22//!     .with_allowed_extensions(vec!["tmpl", "tera", "ttl", "rdf"]);
23//!
24//! // Validate a path
25//! let safe_path = validator.validate("templates/example.tera")?;
26//!
27//! // Use safe path for file operations
28//! let content = std::fs::read_to_string(safe_path.as_path())?;
29//! # Ok(())
30//! # }
31//! ```
32
33use crate::error::{Error, Result};
34use std::collections::HashSet;
35use std::path::{Path, PathBuf};
36
37// ============================================================================
38// Error Types
39// ============================================================================
40
41/// Path validation error types
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub enum PathValidationError {
44    /// Path contains ".." components (path traversal attempt)
45    PathTraversal { path: String },
46    /// Path is absolute when relative was expected
47    AbsolutePath { path: String },
48    /// Path exceeds maximum allowed depth
49    DepthExceeded {
50        path: String,
51        depth: usize,
52        max: usize,
53    },
54    /// Path contains null bytes
55    NullByte { path: String },
56    /// Path has invalid extension
57    InvalidExtension { path: String, expected: Vec<String> },
58    /// Symlink points outside allowed workspace
59    SymlinkEscape { link: String, target: String },
60    /// Path escapes workspace root
61    WorkspaceEscape { path: String, workspace: String },
62    /// Path is empty
63    EmptyPath,
64    /// Invalid UTF-8 in path
65    InvalidUtf8 { path: String },
66    /// Unicode normalization issue
67    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// ============================================================================
120// SafePath - Type-safe validated path
121// ============================================================================
122
123/// A path that has been validated and is safe to use for file operations
124///
125/// This type guarantees:
126/// - No path traversal components (..)
127/// - Within workspace bounds
128/// - Valid UTF-8
129/// - No null bytes
130/// - Extension matches expectations (if configured)
131/// - Depth within limits (if configured)
132#[derive(Debug, Clone, PartialEq, Eq, Hash)]
133pub struct SafePath {
134    /// The validated path (relative to workspace root)
135    inner: PathBuf,
136    /// Absolute path (resolved and validated)
137    absolute: PathBuf,
138}
139
140impl SafePath {
141    /// Get the relative path
142    #[must_use]
143    pub fn as_path(&self) -> &Path {
144        &self.inner
145    }
146
147    /// Get the absolute path
148    #[must_use]
149    pub fn absolute(&self) -> &Path {
150        &self.absolute
151    }
152
153    /// Convert to PathBuf
154    #[must_use]
155    pub fn to_path_buf(&self) -> PathBuf {
156        self.inner.clone()
157    }
158
159    /// Get file extension
160    #[must_use]
161    pub fn extension(&self) -> Option<&str> {
162        self.inner.extension().and_then(|s| s.to_str())
163    }
164
165    /// Get file name
166    #[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// ============================================================================
185// PathValidator
186// ============================================================================
187
188/// Validates paths for security-critical file operations
189///
190/// Prevents common attack vectors:
191/// - Path traversal (../)
192/// - Symlink escapes
193/// - Null byte injection
194/// - Unicode normalization attacks
195/// - Absolute path escapes
196/// - Depth limit violations
197#[derive(Debug, Clone)]
198pub struct PathValidator {
199    /// Workspace root (all paths must be within this)
200    workspace_root: PathBuf,
201    /// Maximum path depth (number of components)
202    max_depth: Option<usize>,
203    /// Allowed file extensions (empty means all)
204    allowed_extensions: HashSet<String>,
205    /// Allow absolute paths
206    allow_absolute: bool,
207    /// Follow symlinks (with validation)
208    follow_symlinks: bool,
209}
210
211impl PathValidator {
212    /// Create a new validator with workspace root
213    ///
214    /// # Example
215    ///
216    /// ```rust
217    /// use ggen_utils::path_validator::PathValidator;
218    /// use std::path::Path;
219    ///
220    /// let validator = PathValidator::new(Path::new("/workspace"));
221    /// ```
222    #[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    /// Set maximum path depth
234    #[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    /// Set allowed file extensions
241    #[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    /// Allow absolute paths
248    #[must_use]
249    pub fn with_absolute_paths(mut self, allow: bool) -> Self {
250        self.allow_absolute = allow;
251        self
252    }
253
254    /// Set whether to follow symlinks
255    #[must_use]
256    pub fn with_follow_symlinks(mut self, follow: bool) -> Self {
257        self.follow_symlinks = follow;
258        self
259    }
260
261    /// Validate a path and return a SafePath
262    ///
263    /// # Errors
264    ///
265    /// Returns error if path:
266    /// - Contains null bytes
267    /// - Contains ".." components
268    /// - Is absolute (unless allowed)
269    /// - Exceeds max depth
270    /// - Has invalid extension
271    /// - Escapes workspace
272    /// - Is a symlink pointing outside workspace
273    pub fn validate(&self, path: impl AsRef<Path>) -> Result<SafePath> {
274        let path = path.as_ref();
275
276        // Check for empty path
277        if path.as_os_str().is_empty() {
278            return Err(PathValidationError::EmptyPath.into());
279        }
280
281        // Validate UTF-8
282        let path_str = path
283            .to_str()
284            .ok_or_else(|| PathValidationError::InvalidUtf8 {
285                path: path.display().to_string(),
286            })?;
287
288        // Check for null bytes
289        if path_str.contains('\0') {
290            return Err(PathValidationError::NullByte {
291                path: path_str.to_string(),
292            }
293            .into());
294        }
295
296        // Check for path traversal
297        self.check_path_traversal(path)?;
298
299        // Check if absolute
300        if path.is_absolute() && !self.allow_absolute {
301            return Err(PathValidationError::AbsolutePath {
302                path: path_str.to_string(),
303            }
304            .into());
305        }
306
307        // Check depth
308        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        // Check extension
321        if !self.allowed_extensions.is_empty() {
322            self.check_extension(path)?;
323        }
324
325        // Resolve to absolute path
326        let absolute = self.resolve_safely(path)?;
327
328        // Check workspace bounds
329        self.check_workspace_bounds(&absolute)?;
330
331        // Check symlinks
332        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    /// Check for path traversal components
343    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    /// Check file extension
356    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    /// Resolve path safely to absolute path
376    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        // Canonicalize if exists, otherwise construct absolute path
384        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            // For non-existent paths, manually construct absolute path
394            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                        // Already checked, should not reach here
406                        return Err(PathValidationError::PathTraversal {
407                            path: path.display().to_string(),
408                        }
409                        .into());
410                    }
411                    _ => {}
412                }
413            }
414            Ok(absolute)
415        }
416    }
417
418    /// Check if path is within workspace bounds
419    fn check_workspace_bounds(&self, absolute: &Path) -> Result<()> {
420        // Canonicalize workspace root
421        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        // Check if absolute path starts with workspace root
430        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    /// Check symlink target
442    fn check_symlink(&self, path: &Path) -> Result<()> {
443        // If path is a symlink, check where it points
444        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            // Resolve target to absolute path
450            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            // Check if target is within workspace
459            self.check_workspace_bounds(&target_absolute)?;
460        }
461
462        Ok(())
463    }
464
465    /// Validate a relative path doesn't escape workspace
466    ///
467    /// This is a convenience method for validating paths that should be relative.
468    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    /// Batch validate multiple paths
482    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    // Arrange-Act-Assert pattern (Chicago TDD)
493
494    #[test]
495    fn test_valid_relative_path() {
496        // Arrange
497        let workspace = tempdir().expect("Failed to create temp dir");
498        let validator = PathValidator::new(workspace.path());
499
500        // Act
501        let result = validator.validate("templates/example.tera");
502
503        // Assert
504        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        // Arrange
512        let workspace = tempdir().expect("Failed to create temp dir");
513        let validator = PathValidator::new(workspace.path());
514
515        // Act
516        let result = validator.validate("../../../etc/passwd");
517
518        // Assert
519        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        // Arrange
527        let workspace = tempdir().expect("Failed to create temp dir");
528        let validator = PathValidator::new(workspace.path());
529
530        // Act
531        let result = validator.validate("file\0.txt");
532
533        // Assert
534        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        // Arrange
542        let workspace = tempdir().expect("Failed to create temp dir");
543        let validator = PathValidator::new(workspace.path());
544
545        // Act
546        let result = validator.validate("/etc/passwd");
547
548        // Assert
549        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        // Arrange
557        let workspace = tempdir().expect("Failed to create temp dir");
558        let validator = PathValidator::new(workspace.path()).with_absolute_paths(true);
559
560        // Act
561        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
566        assert!(result.is_ok());
567    }
568
569    #[test]
570    fn test_depth_limit_enforced() {
571        // Arrange
572        let workspace = tempdir().expect("Failed to create temp dir");
573        let validator = PathValidator::new(workspace.path()).with_max_depth(3);
574
575        // Act
576        let deep_path = "a/b/c/d/e/f/g.txt";
577        let result = validator.validate(deep_path);
578
579        // Assert
580        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        // Arrange
588        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        // Act - valid extension
593        let result_valid = validator.validate("template.tera");
594        assert!(result_valid.is_ok());
595
596        // Act - invalid extension
597        let result_invalid = validator.validate("script.sh");
598
599        // Assert
600        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        // Arrange
608        let workspace = tempdir().expect("Failed to create temp dir");
609        let validator = PathValidator::new(workspace.path());
610
611        // Act
612        let result = validator.validate("");
613
614        // Assert
615        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        // Arrange
623        let workspace = tempdir().expect("Failed to create temp dir");
624        let validator = PathValidator::new(workspace.path()).with_absolute_paths(true);
625
626        // Act - try to access outside workspace
627        let result = validator.validate("/etc/passwd");
628
629        // Assert
630        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        // Arrange
638        let workspace = tempdir().expect("Failed to create temp dir");
639        let validator = PathValidator::new(workspace.path());
640
641        // Create a file and symlink inside workspace
642        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        // Act
650        #[cfg(unix)]
651        let result = validator.validate("link.txt");
652
653        // Assert
654        #[cfg(unix)]
655        assert!(result.is_ok());
656    }
657
658    #[test]
659    fn test_batch_validation() {
660        // Arrange
661        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        // Act
667        let result = validator.validate_batch(&paths);
668
669        // Assert
670        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        // Arrange
678        let workspace = tempdir().expect("Failed to create temp dir");
679        let validator = PathValidator::new(workspace.path());
680
681        // Act
682        let safe_path = validator
683            .validate("templates/example.tera")
684            .expect("Should validate");
685
686        // Assert
687        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        // Arrange
695        let workspace = tempdir().expect("Failed to create temp dir");
696        let validator = PathValidator::new(workspace.path());
697
698        // Act - Unicode characters in path
699        let result = validator.validate("templates/例え.tera");
700
701        // Assert
702        assert!(result.is_ok());
703    }
704
705    #[test]
706    fn test_validate_relative_rejects_absolute() {
707        // Arrange
708        let workspace = tempdir().expect("Failed to create temp dir");
709        let validator = PathValidator::new(workspace.path());
710
711        // Act
712        let result = validator.validate_relative("/etc/passwd");
713
714        // Assert
715        assert!(result.is_err());
716        let err = result.unwrap_err();
717        assert!(err.to_string().contains("Absolute path"));
718    }
719}