Skip to main content

ggen_utils/
safe_path.rs

1//! Safe path handling with validation to prevent path traversal attacks
2//!
3//! This module provides the `SafePath` newtype that wraps `PathBuf` with validation
4//! to prevent common path traversal vulnerabilities. All paths are validated on
5//! construction to ensure they:
6//!
7//! - Do not contain parent directory references (..)
8//! - Are relative paths or absolute paths within an allowed root
9//! - Do not exceed maximum depth of 20 levels
10//! - Do not contain symlinks outside the workspace (when resolved)
11//!
12//! ## Examples
13//!
14//! ```rust
15//! use ggen_utils::safe_path::SafePath;
16//!
17//! // Valid paths
18//! let safe = SafePath::new("src/generated").unwrap();
19//! let joined = safe.join("output.rs").unwrap();
20//! assert_eq!(joined.as_path().to_str().unwrap(), "src/generated/output.rs");
21//!
22//! // Invalid paths
23//! assert!(SafePath::new("../etc/passwd").is_err());
24//! assert!(SafePath::new("src/../../etc/passwd").is_err());
25//! ```
26
27use crate::error::{Error, Result};
28use std::convert::TryFrom;
29use std::fmt;
30use std::path::{Path, PathBuf};
31
32/// Maximum allowed path depth to prevent deeply nested directory attacks
33const MAX_PATH_DEPTH: usize = 20;
34
35/// A validated path that prevents path traversal attacks
36///
37/// This newtype wrapper around `PathBuf` ensures that all paths are validated
38/// on construction and cannot be modified to violate safety constraints.
39#[derive(Debug, Clone, PartialEq, Eq, Hash)]
40pub struct SafePath {
41    /// Inner path - private to prevent direct mutation
42    inner: PathBuf,
43}
44
45impl SafePath {
46    /// Create a new SafePath from a string-like type
47    ///
48    /// # Validation Rules
49    ///
50    /// - No parent directory references (..)
51    /// - Must be relative or within allowed root (if absolute)
52    /// - Maximum depth of 20 levels
53    /// - Path components must not be empty or contain only whitespace
54    ///
55    /// # Examples
56    ///
57    /// ```rust
58    /// use ggen_utils::safe_path::SafePath;
59    ///
60    /// // Valid paths
61    /// assert!(SafePath::new("src/generated").is_ok());
62    /// assert!(SafePath::new("./output").is_ok());
63    /// assert!(SafePath::new("test").is_ok());
64    ///
65    /// // Invalid paths
66    /// assert!(SafePath::new("../etc").is_err());
67    /// assert!(SafePath::new("src/../../../etc").is_err());
68    /// assert!(SafePath::new("").is_err());
69    /// ```
70    pub fn new<P: AsRef<Path>>(path: P) -> Result<Self> {
71        let path = path.as_ref();
72        Self::validate_path(path)?;
73        Ok(Self {
74            inner: Self::normalize_path(path),
75        })
76    }
77
78    /// Join this path with another path component
79    ///
80    /// The joined path is validated to ensure safety constraints are maintained.
81    ///
82    /// # Examples
83    ///
84    /// ```rust
85    /// use ggen_utils::safe_path::SafePath;
86    ///
87    /// let base = SafePath::new("src").unwrap();
88    /// let joined = base.join("generated").unwrap();
89    /// assert_eq!(joined.as_path().to_str().unwrap(), "src/generated");
90    ///
91    /// // Cannot join with parent references
92    /// assert!(base.join("../etc").is_err());
93    /// ```
94    pub fn join<P: AsRef<Path>>(&self, path: P) -> Result<Self> {
95        let joined = self.inner.join(path.as_ref());
96        Self::validate_path(&joined)?;
97        Ok(Self {
98            inner: Self::normalize_path(&joined),
99        })
100    }
101
102    /// Get a reference to the inner path
103    ///
104    /// # Examples
105    ///
106    /// ```rust
107    /// use ggen_utils::safe_path::SafePath;
108    ///
109    /// let safe = SafePath::new("src/generated").unwrap();
110    /// assert_eq!(safe.as_path().to_str().unwrap(), "src/generated");
111    /// ```
112    #[must_use]
113    pub fn as_path(&self) -> &Path {
114        &self.inner
115    }
116
117    /// Convert into the inner PathBuf
118    ///
119    /// # Examples
120    ///
121    /// ```rust
122    /// use ggen_utils::safe_path::SafePath;
123    ///
124    /// let safe = SafePath::new("src/generated").unwrap();
125    /// let path_buf = safe.into_path_buf();
126    /// assert_eq!(path_buf.to_str().unwrap(), "src/generated");
127    /// ```
128    #[must_use]
129    pub fn into_path_buf(self) -> PathBuf {
130        self.inner
131    }
132
133    /// Get the PathBuf (clone)
134    ///
135    /// # Examples
136    ///
137    /// ```rust
138    /// use ggen_utils::safe_path::SafePath;
139    ///
140    /// let safe = SafePath::new("src/generated").unwrap();
141    /// let path_buf = safe.as_path_buf();
142    /// assert_eq!(path_buf.to_str().unwrap(), "src/generated");
143    /// ```
144    #[must_use]
145    pub fn as_path_buf(&self) -> PathBuf {
146        self.inner.clone()
147    }
148
149    /// Create a SafePath allowing absolute paths (for system paths like config files)
150    ///
151    /// # Examples
152    ///
153    /// ```rust
154    /// use ggen_utils::safe_path::SafePath;
155    ///
156    /// let safe = SafePath::new_absolute("/tmp/config.toml").unwrap();
157    /// assert!(safe.as_path().is_absolute());
158    /// ```
159    pub fn new_absolute<P: AsRef<Path>>(path: P) -> Result<Self> {
160        let path = path.as_ref();
161        // For absolute paths, we skip the relative path validation
162        // but still check for parent dir components and other security issues
163
164        // Check for empty path
165        if path.as_os_str().is_empty() {
166            return Err(Error::invalid_input("Path cannot be empty"));
167        }
168
169        // Check for parent directory references in original path
170        for component in path.components() {
171            if let std::path::Component::ParentDir = component {
172                return Err(Error::invalid_input(format!(
173                    "Path contains parent directory reference (..): {}",
174                    path.display()
175                )));
176            }
177        }
178
179        // No normalization for absolute paths - use as-is
180        Ok(Self {
181            inner: path.to_path_buf(),
182        })
183    }
184
185    /// Get current working directory as SafePath
186    ///
187    /// # Examples
188    ///
189    /// ```rust
190    /// use ggen_utils::safe_path::SafePath;
191    ///
192    /// let cwd = SafePath::current_dir().unwrap();
193    /// assert!(cwd.as_path().is_absolute());
194    /// ```
195    pub fn current_dir() -> Result<Self> {
196        let cwd = std::env::current_dir()
197            .map_err(|e| Error::new(&format!("Failed to get current directory: {}", e)))?;
198        Self::new_absolute(cwd)
199    }
200
201    /// Get the parent directory
202    ///
203    /// # Examples
204    ///
205    /// ```rust
206    /// use ggen_utils::safe_path::SafePath;
207    ///
208    /// let path = SafePath::new("src/generated").unwrap();
209    /// let parent = path.parent().unwrap();
210    /// assert_eq!(parent.as_path().to_str().unwrap(), "src");
211    /// ```
212    pub fn parent(&self) -> Result<Self> {
213        let parent_path = self
214            .inner
215            .parent()
216            .ok_or_else(|| Error::invalid_input("Path has no parent"))?;
217        // For parent, we need to check if it's absolute or relative
218        if parent_path.is_absolute() {
219            Self::new_absolute(parent_path)
220        } else {
221            Self::new(parent_path)
222        }
223    }
224
225    /// Check if path exists on filesystem
226    ///
227    /// # Examples
228    ///
229    /// ```rust
230    /// use ggen_utils::safe_path::SafePath;
231    ///
232    /// let cwd = SafePath::current_dir().unwrap();
233    /// assert!(cwd.exists());
234    /// ```
235    #[must_use]
236    pub fn exists(&self) -> bool {
237        self.inner.exists()
238    }
239
240    /// Validate a path against security constraints
241    ///
242    /// This performs the following checks:
243    /// - Path is not empty
244    /// - No parent directory references (..)
245    /// - Path depth does not exceed MAX_PATH_DEPTH
246    /// - Path components are not empty or whitespace-only
247    fn validate_path(path: &Path) -> Result<()> {
248        // Check for empty path
249        if path.as_os_str().is_empty() {
250            return Err(Error::invalid_input("Path cannot be empty"));
251        }
252
253        // Normalize and check for parent references in the final path
254        let normalized = Self::normalize_path(path);
255
256        // Check each component
257        let components: Vec<_> = normalized.components().collect();
258
259        // Check depth
260        if components.len() > MAX_PATH_DEPTH {
261            return Err(Error::invalid_input(format!(
262                "Path depth {} exceeds maximum allowed depth of {}",
263                components.len(),
264                MAX_PATH_DEPTH
265            )));
266        }
267
268        // Check for parent directory references in original path
269        for component in path.components() {
270            if let std::path::Component::ParentDir = component {
271                return Err(Error::invalid_input(format!(
272                    "Path contains parent directory reference (..): {}",
273                    path.display()
274                )));
275            }
276        }
277
278        // Check for empty or whitespace-only components
279        for component in &components {
280            if let std::path::Component::Normal(os_str) = component {
281                if let Some(s) = os_str.to_str() {
282                    if s.trim().is_empty() {
283                        return Err(Error::invalid_input(
284                            "Path components cannot be empty or whitespace-only",
285                        ));
286                    }
287                }
288            }
289        }
290
291        Ok(())
292    }
293
294    /// Normalize a path by removing redundant separators and current directory references
295    ///
296    /// This does NOT resolve symlinks or make the path absolute - it only performs
297    /// basic normalization of the path string representation.
298    fn normalize_path(path: &Path) -> PathBuf {
299        let mut normalized = PathBuf::new();
300
301        for component in path.components() {
302            match component {
303                std::path::Component::CurDir => {
304                    // Skip current directory references
305                }
306                std::path::Component::Normal(_) => {
307                    normalized.push(component);
308                }
309                _ => {
310                    // Keep other components (RootDir, Prefix, ParentDir)
311                    // Note: ParentDir will be caught by validation
312                    normalized.push(component);
313                }
314            }
315        }
316
317        normalized
318    }
319}
320
321impl TryFrom<&str> for SafePath {
322    type Error = Error;
323
324    fn try_from(value: &str) -> Result<Self> {
325        Self::new(value)
326    }
327}
328
329impl TryFrom<String> for SafePath {
330    type Error = Error;
331
332    fn try_from(value: String) -> Result<Self> {
333        Self::new(value)
334    }
335}
336
337impl TryFrom<&String> for SafePath {
338    type Error = Error;
339
340    fn try_from(value: &String) -> Result<Self> {
341        Self::new(value)
342    }
343}
344
345impl TryFrom<PathBuf> for SafePath {
346    type Error = Error;
347
348    fn try_from(value: PathBuf) -> Result<Self> {
349        Self::new(value)
350    }
351}
352
353impl TryFrom<&Path> for SafePath {
354    type Error = Error;
355
356    fn try_from(value: &Path) -> Result<Self> {
357        Self::new(value)
358    }
359}
360
361impl fmt::Display for SafePath {
362    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
363        write!(f, "{}", self.inner.display())
364    }
365}
366
367impl AsRef<Path> for SafePath {
368    fn as_ref(&self) -> &Path {
369        &self.inner
370    }
371}
372
373impl AsRef<PathBuf> for SafePath {
374    fn as_ref(&self) -> &PathBuf {
375        &self.inner
376    }
377}
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382
383    // Test Category: Basic Construction
384
385    #[test]
386    fn test_new_simple_path() {
387        // Arrange
388        let path = "src/generated";
389
390        // Act
391        let result = SafePath::new(path);
392
393        // Assert
394        assert!(result.is_ok());
395        let safe_path = result.unwrap();
396        assert_eq!(safe_path.as_path().to_str().unwrap(), "src/generated");
397    }
398
399    #[test]
400    fn test_new_single_component() {
401        // Arrange
402        let path = "test";
403
404        // Act
405        let result = SafePath::new(path);
406
407        // Assert
408        assert!(result.is_ok());
409        assert_eq!(result.unwrap().as_path().to_str().unwrap(), "test");
410    }
411
412    #[test]
413    fn test_new_with_current_dir_prefix() {
414        // Arrange
415        let path = "./output";
416
417        // Act
418        let result = SafePath::new(path);
419
420        // Assert
421        assert!(result.is_ok());
422        // Current directory reference should be normalized away
423        assert_eq!(result.unwrap().as_path().to_str().unwrap(), "output");
424    }
425
426    #[test]
427    fn test_new_with_multiple_current_dirs() {
428        // Arrange
429        let path = "./src/./generated/./output";
430
431        // Act
432        let result = SafePath::new(path);
433
434        // Assert
435        assert!(result.is_ok());
436        assert_eq!(
437            result.unwrap().as_path().to_str().unwrap(),
438            "src/generated/output"
439        );
440    }
441
442    // Test Category: Parent Directory Attacks
443
444    #[test]
445    fn test_new_parent_dir_fails() {
446        // Arrange
447        let path = "../etc/passwd";
448
449        // Act
450        let result = SafePath::new(path);
451
452        // Assert
453        assert!(result.is_err());
454        let err = result.unwrap_err();
455        assert!(err.to_string().contains("parent directory reference"));
456    }
457
458    #[test]
459    fn test_new_parent_dir_in_middle_fails() {
460        // Arrange
461        let path = "src/../../../etc/passwd";
462
463        // Act
464        let result = SafePath::new(path);
465
466        // Assert
467        assert!(result.is_err());
468        assert!(result.unwrap_err().to_string().contains("parent directory"));
469    }
470
471    #[test]
472    fn test_new_parent_dir_at_end_fails() {
473        // Arrange
474        let path = "src/generated/..";
475
476        // Act
477        let result = SafePath::new(path);
478
479        // Assert
480        assert!(result.is_err());
481        assert!(result.unwrap_err().to_string().contains("parent directory"));
482    }
483
484    #[test]
485    fn test_new_multiple_parent_dirs_fails() {
486        // Arrange
487        let path = "../../../../../../etc/passwd";
488
489        // Act
490        let result = SafePath::new(path);
491
492        // Assert
493        assert!(result.is_err());
494        assert!(result.unwrap_err().to_string().contains("parent directory"));
495    }
496
497    // Test Category: Empty and Invalid Paths
498
499    #[test]
500    fn test_new_empty_path_fails() {
501        // Arrange
502        let path = "";
503
504        // Act
505        let result = SafePath::new(path);
506
507        // Assert
508        assert!(result.is_err());
509        assert!(result.unwrap_err().to_string().contains("cannot be empty"));
510    }
511
512    #[test]
513    fn test_new_whitespace_component_fails() {
514        // Arrange
515        let path = "src/   /generated";
516
517        // Act
518        let result = SafePath::new(path);
519
520        // Assert
521        assert!(result.is_err());
522        assert!(result.unwrap_err().to_string().contains("whitespace-only"));
523    }
524
525    // Test Category: Depth Validation
526
527    #[test]
528    fn test_new_max_depth_allowed() {
529        // Arrange - Create path at exact MAX_PATH_DEPTH (20)
530        let components: Vec<String> = (0..MAX_PATH_DEPTH).map(|i| format!("level{}", i)).collect();
531        let path = components.join("/");
532
533        // Act
534        let result = SafePath::new(&path);
535
536        // Assert
537        assert!(result.is_ok());
538    }
539
540    #[test]
541    fn test_new_exceeds_max_depth_fails() {
542        // Arrange - Create path with 21 levels (exceeds MAX_PATH_DEPTH)
543        let components: Vec<String> = (0..=MAX_PATH_DEPTH)
544            .map(|i| format!("level{}", i))
545            .collect();
546        let path = components.join("/");
547
548        // Act
549        let result = SafePath::new(&path);
550
551        // Assert
552        assert!(result.is_err());
553        let err = result.unwrap_err();
554        assert!(err.to_string().contains("exceeds maximum allowed depth"));
555        assert!(err.to_string().contains("20"));
556    }
557
558    // Test Category: Join Operations
559
560    #[test]
561    fn test_join_simple() {
562        // Arrange
563        let base = SafePath::new("src").unwrap();
564
565        // Act
566        let result = base.join("generated");
567
568        // Assert
569        assert!(result.is_ok());
570        assert_eq!(result.unwrap().as_path().to_str().unwrap(), "src/generated");
571    }
572
573    #[test]
574    fn test_join_multiple_times() {
575        // Arrange
576        let base = SafePath::new("src").unwrap();
577
578        // Act
579        let step1 = base.join("generated").unwrap();
580        let step2 = step1.join("output").unwrap();
581        let result = step2.join("file.rs");
582
583        // Assert
584        assert!(result.is_ok());
585        assert_eq!(
586            result.unwrap().as_path().to_str().unwrap(),
587            "src/generated/output/file.rs"
588        );
589    }
590
591    #[test]
592    fn test_join_with_parent_dir_fails() {
593        // Arrange
594        let base = SafePath::new("src").unwrap();
595
596        // Act
597        let result = base.join("../etc");
598
599        // Assert
600        assert!(result.is_err());
601        assert!(result.unwrap_err().to_string().contains("parent directory"));
602    }
603
604    #[test]
605    fn test_join_exceeding_depth_fails() {
606        // Arrange - Create base path at 18 levels
607        let components: Vec<String> = (0..18).map(|i| format!("level{}", i)).collect();
608        let base = SafePath::new(components.join("/")).unwrap();
609
610        // Act - Try to join 3 more levels (total 21, exceeds 20)
611        let result = base.join("level18/level19/level20");
612
613        // Assert
614        assert!(result.is_err());
615        assert!(result
616            .unwrap_err()
617            .to_string()
618            .contains("exceeds maximum allowed depth"));
619    }
620
621    // Test Category: Type Conversions
622
623    #[test]
624    fn test_try_from_str() {
625        // Arrange
626        let path = "src/generated";
627
628        // Act
629        let result = SafePath::try_from(path);
630
631        // Assert
632        assert!(result.is_ok());
633        assert_eq!(result.unwrap().as_path().to_str().unwrap(), "src/generated");
634    }
635
636    #[test]
637    fn test_try_from_string() {
638        // Arrange
639        let path = String::from("src/generated");
640
641        // Act
642        let result = SafePath::try_from(path);
643
644        // Assert
645        assert!(result.is_ok());
646        assert_eq!(result.unwrap().as_path().to_str().unwrap(), "src/generated");
647    }
648
649    #[test]
650    fn test_try_from_string_ref() {
651        // Arrange
652        let path = String::from("src/generated");
653
654        // Act
655        let result = SafePath::try_from(&path);
656
657        // Assert
658        assert!(result.is_ok());
659        assert_eq!(result.unwrap().as_path().to_str().unwrap(), "src/generated");
660    }
661
662    #[test]
663    fn test_try_from_path_buf() {
664        // Arrange
665        let path = PathBuf::from("src/generated");
666
667        // Act
668        let result = SafePath::try_from(path);
669
670        // Assert
671        assert!(result.is_ok());
672        assert_eq!(result.unwrap().as_path().to_str().unwrap(), "src/generated");
673    }
674
675    #[test]
676    fn test_try_from_path() {
677        // Arrange
678        let path = Path::new("src/generated");
679
680        // Act
681        let result = SafePath::try_from(path);
682
683        // Assert
684        assert!(result.is_ok());
685        assert_eq!(result.unwrap().as_path().to_str().unwrap(), "src/generated");
686    }
687
688    #[test]
689    fn test_try_from_invalid_str_fails() {
690        // Arrange
691        let path = "../etc/passwd";
692
693        // Act
694        let result = SafePath::try_from(path);
695
696        // Assert
697        assert!(result.is_err());
698        assert!(result.unwrap_err().to_string().contains("parent directory"));
699    }
700
701    // Test Category: Display and AsRef Traits
702
703    #[test]
704    fn test_display_trait() {
705        // Arrange
706        let safe = SafePath::new("src/generated").unwrap();
707
708        // Act
709        let display_string = format!("{}", safe);
710
711        // Assert
712        assert_eq!(display_string, "src/generated");
713    }
714
715    #[test]
716    fn test_debug_trait() {
717        // Arrange
718        let safe = SafePath::new("src/generated").unwrap();
719
720        // Act
721        let debug_string = format!("{:?}", safe);
722
723        // Assert
724        assert!(debug_string.contains("SafePath"));
725        assert!(debug_string.contains("src/generated"));
726    }
727
728    #[test]
729    fn test_as_ref_path() {
730        // Arrange
731        let safe = SafePath::new("src/generated").unwrap();
732
733        // Act
734        let path_ref: &Path = safe.as_ref();
735
736        // Assert
737        assert_eq!(path_ref.to_str().unwrap(), "src/generated");
738    }
739
740    #[test]
741    fn test_as_ref_path_buf() {
742        // Arrange
743        let safe = SafePath::new("src/generated").unwrap();
744
745        // Act
746        let path_buf_ref: &PathBuf = safe.as_ref();
747
748        // Assert
749        assert_eq!(path_buf_ref.to_str().unwrap(), "src/generated");
750    }
751
752    // Test Category: Equality and Cloning
753
754    #[test]
755    fn test_equality() {
756        // Arrange
757        let safe1 = SafePath::new("src/generated").unwrap();
758        let safe2 = SafePath::new("src/generated").unwrap();
759        let safe3 = SafePath::new("src/output").unwrap();
760
761        // Act & Assert
762        assert_eq!(safe1, safe2);
763        assert_ne!(safe1, safe3);
764    }
765
766    #[test]
767    fn test_clone() {
768        // Arrange
769        let safe = SafePath::new("src/generated").unwrap();
770
771        // Act
772        let cloned = safe.clone();
773
774        // Assert
775        assert_eq!(safe, cloned);
776        assert_eq!(safe.as_path(), cloned.as_path());
777    }
778
779    // Test Category: Path Normalization
780
781    #[test]
782    fn test_normalization_removes_current_dir() {
783        // Arrange
784        let path = "./src/./generated/./output";
785
786        // Act
787        let safe = SafePath::new(path).unwrap();
788
789        // Assert
790        assert_eq!(safe.as_path().to_str().unwrap(), "src/generated/output");
791    }
792
793    #[test]
794    fn test_into_path_buf() {
795        // Arrange
796        let safe = SafePath::new("src/generated").unwrap();
797
798        // Act
799        let path_buf = safe.into_path_buf();
800
801        // Assert
802        assert_eq!(path_buf.to_str().unwrap(), "src/generated");
803    }
804
805    // Test Category: Edge Cases
806
807    #[test]
808    fn test_path_with_file_extension() {
809        // Arrange
810        let path = "src/generated/output.rs";
811
812        // Act
813        let result = SafePath::new(path);
814
815        // Assert
816        assert!(result.is_ok());
817        assert_eq!(
818            result.unwrap().as_path().to_str().unwrap(),
819            "src/generated/output.rs"
820        );
821    }
822
823    #[test]
824    fn test_path_with_special_chars_in_name() {
825        // Arrange
826        let path = "src/my-project_v2.0/output";
827
828        // Act
829        let result = SafePath::new(path);
830
831        // Assert
832        assert!(result.is_ok());
833        assert_eq!(
834            result.unwrap().as_path().to_str().unwrap(),
835            "src/my-project_v2.0/output"
836        );
837    }
838}