Skip to main content

gity_ipc/
validated_path.rs

1use serde::{Deserialize, Serialize};
2use std::ops::Deref;
3use std::path::{Path, PathBuf};
4use thiserror::Error;
5
6/// Maximum allowed path length (4096 bytes - reasonable for most filesystems)
7const MAX_PATH_LENGTH: usize = 4096;
8
9/// Validated repository path that has been sanitized and checked.
10/// This type ensures all paths used in IPC communication are safe.
11#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
12pub struct ValidatedPath(PathBuf);
13
14impl ValidatedPath {
15    /// Validates and converts a PathBuf into a ValidatedPath.
16    pub fn new(path: PathBuf) -> Result<Self, PathValidationError> {
17        // Check for empty path
18        if path.as_os_str().is_empty() {
19            return Err(PathValidationError::Empty);
20        }
21
22        // Check for overly long paths
23        if path.as_os_str().len() > MAX_PATH_LENGTH {
24            return Err(PathValidationError::TooLong {
25                len: path.as_os_str().len(),
26                max: MAX_PATH_LENGTH,
27            });
28        }
29
30        // Normalize the path (resolve . and .. as much as possible without following symlinks)
31        let normalized = normalize_path(&path)?;
32
33        // Check for null bytes (invalid in paths)
34        if normalized.as_os_str().to_string_lossy().contains('\0') {
35            return Err(PathValidationError::ContainsNull);
36        }
37
38        // Check for suspicious escape sequences
39        if contains_suspicious_patterns(&normalized) {
40            return Err(PathValidationError::SuspiciousPattern);
41        }
42
43        Ok(Self(normalized))
44    }
45
46    /// Returns a reference to the underlying path.
47    pub fn as_path(&self) -> &Path {
48        &self.0
49    }
50
51    /// Consumes the ValidatedPath and returns the inner PathBuf.
52    #[inline]
53    pub fn into_inner(self) -> PathBuf {
54        self.0
55    }
56}
57
58impl AsRef<Path> for ValidatedPath {
59    fn as_ref(&self) -> &Path {
60        &self.0
61    }
62}
63
64impl Deref for ValidatedPath {
65    type Target = Path;
66
67    fn deref(&self) -> &Path {
68        &self.0
69    }
70}
71
72/// Normalizes a path without following symlinks
73fn normalize_path(path: &Path) -> Result<PathBuf, PathValidationError> {
74    let mut result = PathBuf::new();
75    let has_root = path.has_root() || path.is_absolute();
76
77    for component in path.components() {
78        match component {
79            // Skip current directory references
80            std::path::Component::CurDir => {}
81            // Handle parent directory references
82            std::path::Component::ParentDir => {
83                // Can't go above the root - check if we're at root
84                if result.as_os_str().is_empty() || (has_root && result.as_os_str() == "/") {
85                    return Err(PathValidationError::PathTraversal);
86                }
87                result.pop();
88            }
89            // Root directory - just mark that we have a root
90            std::path::Component::RootDir => {
91                result.push("/");
92            }
93            // Normalize/normal components
94            component @ std::path::Component::Normal(_) => {
95                result.push(component);
96            }
97            // Prefix components (like drive letters on Windows) - pass through
98            component => result.push(component.as_os_str()),
99        }
100    }
101
102    Ok(result)
103}
104
105/// Checks for suspicious patterns in paths
106fn contains_suspicious_patterns(path: &Path) -> bool {
107    let s = path.as_os_str().to_string_lossy();
108
109    // Check for escape sequences that could be used for injection
110    if s.contains("${") || s.contains('`') {
111        return true;
112    }
113
114    // Check for control characters
115    for c in s.chars() {
116        if c.is_control() {
117            return true;
118        }
119    }
120
121    false
122}
123
124/// Errors that can occur during path validation.
125#[derive(Debug, Error, PartialEq, Eq)]
126pub enum PathValidationError {
127    #[error("path is empty")]
128    Empty,
129    #[error("path too long: {len} bytes exceeds maximum of {max} bytes")]
130    TooLong { len: usize, max: usize },
131    #[error("path contains null byte")]
132    ContainsNull,
133    #[error("path contains suspicious pattern (escape sequences or control characters)")]
134    SuspiciousPattern,
135    #[error("path traversal outside repository root")]
136    PathTraversal,
137}
138
139impl std::fmt::Display for ValidatedPath {
140    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
141        write!(f, "{}", self.0.display())
142    }
143}
144
145impl From<ValidatedPath> for PathBuf {
146    fn from(val: ValidatedPath) -> Self {
147        val.0
148    }
149}
150
151// Make ValidatedPath serializable/deserializable by delegating to PathBuf
152impl Serialize for ValidatedPath {
153    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
154    where
155        S: serde::Serializer,
156    {
157        self.0.serialize(serializer)
158    }
159}
160
161impl<'de> Deserialize<'de> for ValidatedPath {
162    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
163    where
164        D: serde::Deserializer<'de>,
165    {
166        let path = PathBuf::deserialize(deserializer)?;
167        ValidatedPath::new(path).map_err(serde::de::Error::custom)
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174    use std::path::PathBuf;
175
176    #[test]
177    fn normal_path_accepted() {
178        let path = PathBuf::from("/home/user/repo");
179        assert!(ValidatedPath::new(path).is_ok());
180    }
181
182    #[test]
183    fn empty_path_rejected() {
184        let path = PathBuf::new();
185        assert!(matches!(
186            ValidatedPath::new(path),
187            Err(PathValidationError::Empty)
188        ));
189    }
190
191    #[test]
192    fn path_with_null_byte_rejected() {
193        let mut path = PathBuf::from("/home/user/repo");
194        path.push("\0");
195        assert!(matches!(
196            ValidatedPath::new(path),
197            Err(PathValidationError::ContainsNull)
198        ));
199    }
200
201    #[test]
202    fn path_traversal_rejected() {
203        let path = PathBuf::from("/home/user/../../../etc");
204        assert!(matches!(
205            ValidatedPath::new(path),
206            Err(PathValidationError::PathTraversal)
207        ));
208    }
209
210    #[test]
211    fn control_characters_rejected() {
212        let mut path = PathBuf::from("/home/user/repo");
213        path.push("\x01");
214        assert!(matches!(
215            ValidatedPath::new(path),
216            Err(PathValidationError::SuspiciousPattern)
217        ));
218    }
219
220    #[test]
221    fn path_with_dot_normalized() {
222        let path = PathBuf::from("/home/user/./repo");
223        let validated = ValidatedPath::new(path).unwrap();
224        assert_eq!(validated.as_path(), Path::new("/home/user/repo"));
225    }
226
227    #[test]
228    fn path_with_parent_normalized() {
229        let path = PathBuf::from("/home/user/../user/repo");
230        let validated = ValidatedPath::new(path).unwrap();
231        assert_eq!(validated.as_path(), Path::new("/home/user/repo"));
232    }
233
234    #[test]
235    fn escape_sequence_rejected() {
236        let path = PathBuf::from("/home/user/${VAR}");
237        assert!(matches!(
238            ValidatedPath::new(path),
239            Err(PathValidationError::SuspiciousPattern)
240        ));
241    }
242
243    #[test]
244    fn backtick_rejected() {
245        let path = PathBuf::from("/home/user/`command`");
246        assert!(matches!(
247            ValidatedPath::new(path),
248            Err(PathValidationError::SuspiciousPattern)
249        ));
250    }
251
252    #[test]
253    fn display_trait_works() {
254        let path = PathBuf::from("/home/user/repo");
255        let validated = ValidatedPath::new(path).unwrap();
256        assert_eq!(format!("{}", validated), "/home/user/repo");
257    }
258
259    #[test]
260    fn serde_roundtrip() {
261        let path = PathBuf::from("/home/user/repo");
262        let validated = ValidatedPath::new(path).unwrap();
263        let bytes = bincode::serialize(&validated).unwrap();
264        let decoded: ValidatedPath = bincode::deserialize(&bytes).unwrap();
265        assert_eq!(validated.as_path(), decoded.as_path());
266    }
267}