Skip to main content

reinhardt_utils/utils_core/
path_safety.rs

1//! Path safety utilities for preventing directory traversal attacks
2//!
3//! Provides functions to safely join user-controlled path components
4//! with base directories, preventing path traversal vulnerabilities.
5
6use std::path::{Component, Path, PathBuf};
7
8/// Errors that can occur during safe path operations
9#[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
23/// Safely join a base directory with user-provided input, preventing path traversal.
24///
25/// This function implements a 3-stage defense:
26/// 1. Reject `..` components, absolute paths, and null bytes
27/// 2. Canonicalize both base and joined paths
28/// 3. Verify the result is contained within the base directory
29///
30/// For non-existent paths, component-by-component resolution is used
31/// to canonicalize the existing ancestor and append remaining components.
32///
33/// # Errors
34///
35/// Returns `PathTraversalError` if:
36/// - The user input contains `..` path components
37/// - The user input is an absolute path
38/// - The user input contains null bytes
39/// - The resolved path escapes the base directory
40/// - An IO error occurs during canonicalization
41pub fn safe_path_join(base: &Path, user_input: &str) -> Result<PathBuf, PathTraversalError> {
42	// Stage 1: Input validation
43
44	// Reject null bytes
45	if user_input.contains('\0') {
46		return Err(PathTraversalError::NullByte);
47	}
48
49	// Reject absolute paths
50	if user_input.starts_with('/') || user_input.starts_with('\\') {
51		return Err(PathTraversalError::AbsolutePath);
52	}
53
54	// On Windows, reject drive-letter absolute paths
55	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	// Reject any component that is `..` using std::path::Component analysis
63	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	// Also catch encoded or obfuscated `..` that Component might normalize away
71	if user_input.contains("..") {
72		return Err(PathTraversalError::ParentTraversal);
73	}
74
75	// Stage 2: Join and canonicalize
76	let joined = base.join(user_input);
77	let canonical_base = safe_canonicalize(base)?;
78	let canonical_joined = safe_canonicalize(&joined)?;
79
80	// Stage 3: Containment verification
81	if !canonical_joined.starts_with(&canonical_base) {
82		return Err(PathTraversalError::EscapesBase);
83	}
84
85	Ok(canonical_joined)
86}
87
88/// Canonicalize a path, handling non-existent paths by resolving existing ancestors
89/// and appending remaining (non-existent) components.
90fn safe_canonicalize(path: &Path) -> Result<PathBuf, PathTraversalError> {
91	// Try direct canonicalization first (works for existing paths)
92	if let Ok(canonical) = path.canonicalize() {
93		return Ok(canonical);
94	}
95
96	// For non-existent paths, find the deepest existing ancestor
97	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				// Reached root without finding existing ancestor, use path as-is
110				break current;
111			}
112		} else {
113			break current;
114		}
115	};
116
117	// Append non-existent components (in original order)
118	let mut result = resolved;
119	for component in remaining.into_iter().rev() {
120		result.push(component);
121	}
122
123	Ok(result)
124}
125
126/// Validate that a string contains only safe characters for use as a filename component.
127///
128/// Allows only alphanumeric characters, hyphens, underscores, and dots.
129/// Rejects path separators, null bytes, and any other special characters.
130pub 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	/// Create a temporary test directory and return its path
148	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	/// Cleanup test directory
158	fn cleanup_test_dir(dir: &Path) {
159		if dir.exists() {
160			let _ = fs::remove_dir_all(dir);
161		}
162	}
163
164	// ===================================================================
165	// safe_path_join tests
166	// ===================================================================
167
168	#[rstest]
169	fn test_safe_path_join_normal_path() {
170		// Arrange
171		let base = create_test_dir();
172
173		// Act
174		let result = safe_path_join(&base, "subdir/file.txt");
175
176		// Assert
177		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		// Arrange
186		let base = create_test_dir();
187
188		// Act
189		let result = safe_path_join(&base, "../../../etc/passwd");
190
191		// Assert
192		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		// Arrange
199		let base = create_test_dir();
200
201		// Act
202		let result = safe_path_join(&base, "foo/../../bar");
203
204		// Assert
205		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		// Arrange
212		let base = create_test_dir();
213
214		// Act
215		let result = safe_path_join(&base, "/etc/passwd");
216
217		// Assert
218		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		// Arrange
225		let base = create_test_dir();
226
227		// Act
228		let result = safe_path_join(&base, "foo\0/../bar");
229
230		// Assert
231		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		// Arrange
238		let base = create_test_dir();
239
240		// Act
241		let result = safe_path_join(&base, "..hidden");
242
243		// Assert
244		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		// Arrange
251		let base = create_test_dir();
252
253		// Act
254		let result = safe_path_join(&base, "./file.txt");
255
256		// Assert
257		assert!(result.is_ok());
258		cleanup_test_dir(&base);
259	}
260
261	#[rstest]
262	fn test_safe_path_join_allows_dotfiles() {
263		// Arrange
264		let base = create_test_dir();
265
266		// Act
267		let result = safe_path_join(&base, ".gitignore");
268
269		// Assert
270		assert!(result.is_ok());
271		cleanup_test_dir(&base);
272	}
273
274	#[rstest]
275	fn test_safe_path_join_rejects_backslash_absolute() {
276		// Arrange
277		let base = create_test_dir();
278
279		// Act
280		let result = safe_path_join(&base, "\\etc\\passwd");
281
282		// Assert
283		assert!(matches!(result, Err(PathTraversalError::AbsolutePath)));
284		cleanup_test_dir(&base);
285	}
286
287	// ===================================================================
288	// is_safe_filename_component tests
289	// ===================================================================
290
291	#[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		// Act
305		let result = is_safe_filename_component(input);
306
307		// Assert
308		assert_eq!(result, expected, "Failed for input: {:?}", input);
309	}
310}