Skip to main content

tokenless_core/
safe_path.rs

1use std::path::{Path, PathBuf};
2
3/// A validated relative path that is safe against path traversal attacks.
4///
5/// Rejects absolute paths, `..` components, null bytes, and other dangerous
6/// constructs at construction time.
7#[derive(Debug, Clone, PartialEq, Eq, Hash)]
8pub struct SafePath(PathBuf);
9
10impl SafePath {
11    /// Create a new `SafePath` from a string.
12    ///
13    /// # Errors
14    ///
15    /// Returns [`crate::CoreError::Path`] if the path:
16    /// - is absolute
17    /// - contains `..` components
18    /// - contains null bytes
19    /// - has components longer than 255 bytes
20    /// - contains Unicode bidirectional control characters
21    pub fn new(path: impl AsRef<Path>) -> crate::Result<Self> {
22        let path = path.as_ref();
23
24        if path.is_absolute() {
25            return Err(crate::CoreError::Path(
26                "absolute paths are not allowed".into(),
27            ));
28        }
29
30        for component in path.components() {
31            use std::path::Component;
32            match component {
33                Component::ParentDir => {
34                    return Err(crate::CoreError::Path(
35                        "'..' components are not allowed".into(),
36                    ));
37                }
38                Component::Normal(os_str) => {
39                    let s = os_str.to_string_lossy();
40                    if s.contains('\0') {
41                        return Err(crate::CoreError::Path("null bytes are not allowed".into()));
42                    }
43                    if os_str.len() > 255 {
44                        return Err(crate::CoreError::Path(format!(
45                            "path component exceeds 255 bytes: '{s}'"
46                        )));
47                    }
48                    // Reject Unicode bidirectional control characters
49                    if s.contains('\u{202A}')
50                        || s.contains('\u{202B}')
51                        || s.contains('\u{202C}')
52                        || s.contains('\u{202D}')
53                        || s.contains('\u{202E}')
54                        || s.contains('\u{2066}')
55                        || s.contains('\u{2067}')
56                        || s.contains('\u{2068}')
57                        || s.contains('\u{2069}')
58                    {
59                        return Err(crate::CoreError::Path(
60                            "Unicode bidirectional control characters are not allowed".into(),
61                        ));
62                    }
63                }
64                _ => {}
65            }
66        }
67
68        Ok(Self(path.to_path_buf()))
69    }
70
71    /// Return the inner path.
72    #[must_use]
73    pub fn as_path(&self) -> &Path {
74        &self.0
75    }
76}
77
78impl AsRef<Path> for SafePath {
79    fn as_ref(&self) -> &Path {
80        &self.0
81    }
82}
83
84impl std::fmt::Display for SafePath {
85    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86        write!(f, "{}", self.0.display())
87    }
88}