reinhardt_utils/utils_core/
path_safety.rs1use std::path::{Component, Path, PathBuf};
7
8#[derive(Debug, thiserror::Error)]
10pub enum PathTraversalError {
11 #[error("Path traversal detected: input contains parent directory reference")]
12 ParentTraversal,
13 #[error("Absolute path not allowed in user input")]
14 AbsolutePath,
15 #[error("Path escapes base directory")]
16 EscapesBase,
17 #[error("Path contains null byte")]
18 NullByte,
19 #[error("IO error during path resolution: {0}")]
20 Io(#[from] std::io::Error),
21}
22
23pub fn safe_path_join(base: &Path, user_input: &str) -> Result<PathBuf, PathTraversalError> {
42 if user_input.contains('\0') {
46 return Err(PathTraversalError::NullByte);
47 }
48
49 if user_input.starts_with('/') || user_input.starts_with('\\') {
51 return Err(PathTraversalError::AbsolutePath);
52 }
53
54 if user_input.len() >= 2
56 && user_input.as_bytes()[0].is_ascii_alphabetic()
57 && user_input.as_bytes()[1] == b':'
58 {
59 return Err(PathTraversalError::AbsolutePath);
60 }
61
62 let input_path = Path::new(user_input);
64 for component in input_path.components() {
65 if matches!(component, Component::ParentDir) {
66 return Err(PathTraversalError::ParentTraversal);
67 }
68 }
69
70 if user_input.contains("..") {
72 return Err(PathTraversalError::ParentTraversal);
73 }
74
75 let joined = base.join(user_input);
77 let canonical_base = safe_canonicalize(base)?;
78 let canonical_joined = safe_canonicalize(&joined)?;
79
80 if !canonical_joined.starts_with(&canonical_base) {
82 return Err(PathTraversalError::EscapesBase);
83 }
84
85 Ok(canonical_joined)
86}
87
88fn safe_canonicalize(path: &Path) -> Result<PathBuf, PathTraversalError> {
91 if let Ok(canonical) = path.canonicalize() {
93 return Ok(canonical);
94 }
95
96 let mut remaining = Vec::new();
98 let mut current = path.to_path_buf();
99
100 let resolved = loop {
101 if current.exists() {
102 break current.canonicalize()?;
103 }
104 if let Some(file_name) = current.file_name() {
105 remaining.push(file_name.to_os_string());
106 if let Some(parent) = current.parent() {
107 current = parent.to_path_buf();
108 } else {
109 break current;
111 }
112 } else {
113 break current;
114 }
115 };
116
117 let mut result = resolved;
119 for component in remaining.into_iter().rev() {
120 result.push(component);
121 }
122
123 Ok(result)
124}
125
126pub fn is_safe_filename_component(input: &str) -> bool {
131 !input.is_empty()
132 && !input.contains('\0')
133 && !input.contains('/')
134 && !input.contains('\\')
135 && !input.contains("..")
136 && input
137 .chars()
138 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
139}
140
141#[cfg(test)]
142mod tests {
143 use super::*;
144 use rstest::rstest;
145 use std::fs;
146
147 fn create_test_dir() -> PathBuf {
149 let dir = PathBuf::from(format!(
150 "/tmp/reinhardt_path_safety_test_{}",
151 uuid::Uuid::new_v4()
152 ));
153 fs::create_dir_all(&dir).expect("Failed to create test directory");
154 dir
155 }
156
157 fn cleanup_test_dir(dir: &Path) {
159 if dir.exists() {
160 let _ = fs::remove_dir_all(dir);
161 }
162 }
163
164 #[rstest]
169 fn test_safe_path_join_normal_path() {
170 let base = create_test_dir();
172
173 let result = safe_path_join(&base, "subdir/file.txt");
175
176 assert!(result.is_ok());
178 let resolved = result.unwrap();
179 assert!(resolved.starts_with(base.canonicalize().unwrap()));
180 cleanup_test_dir(&base);
181 }
182
183 #[rstest]
184 fn test_safe_path_join_rejects_parent_traversal() {
185 let base = create_test_dir();
187
188 let result = safe_path_join(&base, "../../../etc/passwd");
190
191 assert!(matches!(result, Err(PathTraversalError::ParentTraversal)));
193 cleanup_test_dir(&base);
194 }
195
196 #[rstest]
197 fn test_safe_path_join_rejects_embedded_traversal() {
198 let base = create_test_dir();
200
201 let result = safe_path_join(&base, "foo/../../bar");
203
204 assert!(matches!(result, Err(PathTraversalError::ParentTraversal)));
206 cleanup_test_dir(&base);
207 }
208
209 #[rstest]
210 fn test_safe_path_join_rejects_absolute_path() {
211 let base = create_test_dir();
213
214 let result = safe_path_join(&base, "/etc/passwd");
216
217 assert!(matches!(result, Err(PathTraversalError::AbsolutePath)));
219 cleanup_test_dir(&base);
220 }
221
222 #[rstest]
223 fn test_safe_path_join_rejects_null_byte() {
224 let base = create_test_dir();
226
227 let result = safe_path_join(&base, "foo\0/../bar");
229
230 assert!(matches!(result, Err(PathTraversalError::NullByte)));
232 cleanup_test_dir(&base);
233 }
234
235 #[rstest]
236 fn test_safe_path_join_rejects_double_dot_in_component() {
237 let base = create_test_dir();
239
240 let result = safe_path_join(&base, "..hidden");
242
243 assert!(matches!(result, Err(PathTraversalError::ParentTraversal)));
245 cleanup_test_dir(&base);
246 }
247
248 #[rstest]
249 fn test_safe_path_join_allows_single_dot() {
250 let base = create_test_dir();
252
253 let result = safe_path_join(&base, "./file.txt");
255
256 assert!(result.is_ok());
258 cleanup_test_dir(&base);
259 }
260
261 #[rstest]
262 fn test_safe_path_join_allows_dotfiles() {
263 let base = create_test_dir();
265
266 let result = safe_path_join(&base, ".gitignore");
268
269 assert!(result.is_ok());
271 cleanup_test_dir(&base);
272 }
273
274 #[rstest]
275 fn test_safe_path_join_rejects_backslash_absolute() {
276 let base = create_test_dir();
278
279 let result = safe_path_join(&base, "\\etc\\passwd");
281
282 assert!(matches!(result, Err(PathTraversalError::AbsolutePath)));
284 cleanup_test_dir(&base);
285 }
286
287 #[rstest]
292 #[case("valid_filename", true)]
293 #[case("file.txt", true)]
294 #[case("my-file-123", true)]
295 #[case("../etc/passwd", false)]
296 #[case("/absolute", false)]
297 #[case("has space", false)]
298 #[case("", false)]
299 #[case("null\0byte", false)]
300 #[case("path/sep", false)]
301 #[case("back\\slash", false)]
302 #[case("..", false)]
303 fn test_is_safe_filename_component(#[case] input: &str, #[case] expected: bool) {
304 let result = is_safe_filename_component(input);
306
307 assert_eq!(result, expected, "Failed for input: {:?}", input);
309 }
310}