fastskill_core/security/
path.rs1use std::path::{Component, Path, PathBuf};
4use thiserror::Error;
5
6#[derive(Debug, Error)]
7pub enum PathSecurityError {
8 #[error("Path traversal attempt detected: {0}")]
9 TraversalAttempt(String),
10
11 #[error("Invalid path component: {0}")]
12 InvalidComponent(String),
13
14 #[error("Path canonicalization failed: {0}")]
15 CanonicalizationFailed(String),
16
17 #[error("Path escapes root directory: {0}")]
18 EscapesRoot(String),
19}
20
21pub fn sanitize_path_component(component: &str) -> String {
24 component
25 .chars()
26 .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_' || *c == '.')
27 .collect()
28}
29
30pub fn normalize_path(path: &Path) -> PathBuf {
33 let mut result = PathBuf::new();
34 for component in path.components() {
35 match component {
36 Component::Prefix(_) | Component::RootDir => {
37 result.push(component);
38 }
39 Component::CurDir => {
40 }
42 Component::ParentDir => {
43 if !result.pop() {
45 return PathBuf::new();
47 }
48 }
49 Component::Normal(s) => {
50 result.push(s);
51 }
52 }
53 }
54 result
55}
56
57pub fn validate_path_within_root(path: &Path, root: &Path) -> Result<PathBuf, PathSecurityError> {
60 let abs_root = root.canonicalize().map_err(|e| {
62 PathSecurityError::CanonicalizationFailed(format!("Failed to canonicalize root: {}", e))
63 })?;
64
65 let abs_path = if path.exists() {
67 path.canonicalize().map_err(|e| {
68 PathSecurityError::CanonicalizationFailed(format!("Failed to canonicalize path: {}", e))
69 })?
70 } else {
71 let parent = path.parent().unwrap_or(Path::new("."));
73 let abs_parent = if parent.as_os_str().is_empty() {
74 std::env::current_dir().map_err(|e| {
75 PathSecurityError::CanonicalizationFailed(format!(
76 "Failed to get current directory: {}",
77 e
78 ))
79 })?
80 } else if parent.exists() {
81 parent.canonicalize().map_err(|e| {
82 PathSecurityError::CanonicalizationFailed(format!(
83 "Failed to canonicalize parent: {}",
84 e
85 ))
86 })?
87 } else {
88 let relative_to_root = path.strip_prefix(root).unwrap_or(path);
91 let normalized_relative = normalize_path(relative_to_root);
92 if normalized_relative.as_os_str().is_empty() {
93 return Err(PathSecurityError::EscapesRoot(format!(
95 "Path '{}' attempts to escape root directory '{}'",
96 path.display(),
97 abs_root.display()
98 )));
99 }
100 let normalized_path = abs_root.join(&normalized_relative);
101 if !normalized_path.starts_with(&abs_root) {
103 return Err(PathSecurityError::EscapesRoot(format!(
104 "Path '{}' attempts to escape root directory '{}'",
105 path.display(),
106 abs_root.display()
107 )));
108 }
109 return Ok(normalized_path);
110 };
111
112 if let Some(filename) = path.file_name() {
113 abs_parent.join(filename)
114 } else {
115 abs_parent
116 }
117 };
118
119 if !abs_path.starts_with(&abs_root) {
121 return Err(PathSecurityError::EscapesRoot(format!(
122 "Path '{}' attempts to escape root directory '{}'",
123 abs_path.display(),
124 abs_root.display()
125 )));
126 }
127
128 Ok(abs_path)
129}
130
131pub fn validate_path_component(component: &str) -> Result<String, PathSecurityError> {
134 if component.contains("..") || component.contains('/') || component.contains('\\') {
136 return Err(PathSecurityError::TraversalAttempt(format!(
137 "Path component '{}' contains directory traversal characters",
138 component
139 )));
140 }
141
142 if component.starts_with('/') || (cfg!(windows) && component.contains(':')) {
144 return Err(PathSecurityError::InvalidComponent(format!(
145 "Path component '{}' appears to be an absolute path",
146 component
147 )));
148 }
149
150 Ok(component.to_string())
151}
152
153pub fn safe_join(root: &Path, user_path: &str) -> Result<PathBuf, PathSecurityError> {
156 let components: Vec<&str> = user_path.split('/').collect();
158 for component in components {
159 validate_path_component(component)?;
160 }
161
162 let joined = root.join(user_path);
164
165 if !joined.exists() {
168 return Ok(joined);
169 }
170
171 validate_path_within_root(&joined, root)
172}
173
174#[cfg(test)]
175#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
176mod tests {
177 use super::*;
178 use std::fs;
179 use tempfile::TempDir;
180
181 #[test]
182 fn test_sanitize_path_component() {
183 assert_eq!(sanitize_path_component("valid-name_123"), "valid-name_123");
184 assert_eq!(sanitize_path_component("../etc/passwd"), "..etcpasswd");
185 assert_eq!(sanitize_path_component("../../"), "...."); assert_eq!(
187 sanitize_path_component("file with spaces"),
188 "filewithspaces"
189 );
190 }
191
192 #[test]
193 fn test_validate_path_component() {
194 assert!(validate_path_component("valid-name").is_ok());
195 assert!(validate_path_component("valid_name_123").is_ok());
196 assert!(validate_path_component("..").is_err());
197 assert!(validate_path_component("../etc").is_err());
198 assert!(validate_path_component("/etc/passwd").is_err());
199 assert!(validate_path_component("path/to/file").is_err());
200 }
201
202 #[test]
203 fn test_validate_path_within_root() {
204 let temp_dir = TempDir::new().unwrap();
205 let root = temp_dir.path();
206
207 let valid_file = root.join("valid.txt");
209 fs::write(&valid_file, "test").unwrap();
210
211 assert!(validate_path_within_root(&valid_file, root).is_ok());
213
214 let outside_path = temp_dir.path().parent().unwrap().join("outside.txt");
216 if outside_path.exists() {
217 assert!(validate_path_within_root(&outside_path, root).is_err());
218 }
219 }
220
221 #[test]
222 fn test_safe_join() {
223 let temp_dir = TempDir::new().unwrap();
224 let root = temp_dir.path();
225
226 assert!(safe_join(root, "subdir/file.txt").is_ok());
228
229 assert!(safe_join(root, "../etc/passwd").is_err());
231 assert!(safe_join(root, "subdir/../../etc").is_err());
232 }
233
234 #[test]
235 fn test_validated_return_value_is_safe() {
236 let temp_dir = TempDir::new().unwrap();
237 let root = temp_dir.path();
238
239 let safe_component = validate_path_component("valid-name").unwrap();
241
242 let path = root.join(&safe_component);
244
245 let canonical_path = path.canonicalize().unwrap_or(path);
247 let canonical_root = root.canonicalize().unwrap_or(root.to_path_buf());
248 assert!(canonical_path.starts_with(&canonical_root));
249
250 assert!(!safe_component.contains(".."));
252 assert!(!safe_component.contains('/'));
253 assert!(!safe_component.contains('\\'));
254 }
255
256 #[test]
257 fn test_validate_path_within_root_nonexistent_traversal_rejected() {
258 let temp_dir = TempDir::new().unwrap();
259 let root = temp_dir.path();
260
261 let escape_path = root.join("subdir/../../escape");
263 let result = validate_path_within_root(&escape_path, root);
264
265 assert!(matches!(result, Err(PathSecurityError::EscapesRoot(_))));
266 }
267}