gity_ipc/
validated_path.rs1use serde::{Deserialize, Serialize};
2use std::ops::Deref;
3use std::path::{Path, PathBuf};
4use thiserror::Error;
5
6const MAX_PATH_LENGTH: usize = 4096;
8
9#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
12pub struct ValidatedPath(PathBuf);
13
14impl ValidatedPath {
15 pub fn new(path: PathBuf) -> Result<Self, PathValidationError> {
17 if path.as_os_str().is_empty() {
19 return Err(PathValidationError::Empty);
20 }
21
22 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 let normalized = normalize_path(&path)?;
32
33 if normalized.as_os_str().to_string_lossy().contains('\0') {
35 return Err(PathValidationError::ContainsNull);
36 }
37
38 if contains_suspicious_patterns(&normalized) {
40 return Err(PathValidationError::SuspiciousPattern);
41 }
42
43 Ok(Self(normalized))
44 }
45
46 pub fn as_path(&self) -> &Path {
48 &self.0
49 }
50
51 #[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
72fn 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 std::path::Component::CurDir => {}
81 std::path::Component::ParentDir => {
83 if result.as_os_str().is_empty() || (has_root && result.as_os_str() == "/") {
85 return Err(PathValidationError::PathTraversal);
86 }
87 result.pop();
88 }
89 std::path::Component::RootDir => {
91 result.push("/");
92 }
93 component @ std::path::Component::Normal(_) => {
95 result.push(component);
96 }
97 component => result.push(component.as_os_str()),
99 }
100 }
101
102 Ok(result)
103}
104
105fn contains_suspicious_patterns(path: &Path) -> bool {
107 let s = path.as_os_str().to_string_lossy();
108
109 if s.contains("${") || s.contains('`') {
111 return true;
112 }
113
114 for c in s.chars() {
116 if c.is_control() {
117 return true;
118 }
119 }
120
121 false
122}
123
124#[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
151impl 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}