1use std::io;
2use std::path::{Path, PathBuf};
3
4#[derive(Debug)]
6pub enum PathSecurityError {
7 PathTraversal { path: PathBuf, workspace: PathBuf },
9 SymlinkEscape { path: PathBuf, target: PathBuf, workspace: PathBuf },
11 CanonicalizationFailed { path: PathBuf, error: io::Error },
13}
14
15impl std::fmt::Display for PathSecurityError {
16 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
17 match self {
18 PathSecurityError::PathTraversal { path, workspace } => {
19 write!(
20 f,
21 "Path traversal detected: '{}' escapes workspace '{}'",
22 path.display(),
23 workspace.display()
24 )
25 }
26 PathSecurityError::SymlinkEscape { path, target, workspace } => {
27 write!(
28 f,
29 "Symlink escape detected: '{}' points to '{}' outside workspace '{}'",
30 path.display(),
31 target.display(),
32 workspace.display()
33 )
34 }
35 PathSecurityError::CanonicalizationFailed { path, error } => {
36 write!(f, "Failed to resolve path '{}': {}", path.display(), error)
37 }
38 }
39 }
40}
41
42impl std::error::Error for PathSecurityError {}
43
44pub fn validate_path_in_workspace(
52 path: &Path,
53 workspace_root: &Path,
54) -> Result<PathBuf, PathSecurityError> {
55 let canonical_workspace = workspace_root.canonicalize().map_err(|e| {
58 PathSecurityError::CanonicalizationFailed { path: workspace_root.to_path_buf(), error: e }
59 })?;
60
61 if let Ok(metadata) = std::fs::symlink_metadata(path) {
65 if metadata.file_type().is_symlink() {
66 let target = std::fs::read_link(path).map_err(|e| {
67 PathSecurityError::CanonicalizationFailed { path: path.to_path_buf(), error: e }
68 })?;
69 let absolute_target = if target.is_absolute() {
70 target
71 } else {
72 path.parent().unwrap_or(Path::new("/")).join(&target)
73 };
74 let canonical_target = absolute_target.canonicalize().map_err(|e| {
75 PathSecurityError::CanonicalizationFailed {
76 path: absolute_target.clone(),
77 error: e,
78 }
79 })?;
80 if !canonical_target.starts_with(&canonical_workspace) {
81 return Err(PathSecurityError::SymlinkEscape {
82 path: path.to_path_buf(),
83 target: canonical_target,
84 workspace: canonical_workspace,
85 });
86 }
87 }
88 }
89
90 match path.canonicalize() {
94 Ok(canonical_path) => {
95 if canonical_path.starts_with(&canonical_workspace) {
96 Ok(canonical_path)
97 } else {
98 Err(PathSecurityError::PathTraversal {
99 path: path.to_path_buf(),
100 workspace: canonical_workspace,
101 })
102 }
103 }
104 Err(e) if e.kind() == io::ErrorKind::NotFound => {
105 resolve_new_path(path, &canonical_workspace)
106 }
107 Err(e) => {
108 Err(PathSecurityError::CanonicalizationFailed { path: path.to_path_buf(), error: e })
109 }
110 }
111}
112
113fn resolve_new_path(path: &Path, canonical_workspace: &Path) -> Result<PathBuf, PathSecurityError> {
130 let traversal = || PathSecurityError::PathTraversal {
131 path: path.to_path_buf(),
132 workspace: canonical_workspace.to_path_buf(),
133 };
134
135 let mut existing = path;
137 loop {
138 if std::fs::symlink_metadata(existing).is_ok() {
139 break;
140 }
141 match existing.parent() {
142 Some(parent) => existing = parent,
143 None => break,
144 }
145 }
146
147 let canonical_base = existing.canonicalize().map_err(|e| {
150 PathSecurityError::CanonicalizationFailed { path: existing.to_path_buf(), error: e }
151 })?;
152
153 let remainder = path.strip_prefix(existing).map_err(|_| traversal())?;
156 let mut resolved = canonical_base;
157 for component in remainder.components() {
158 match component {
159 std::path::Component::Normal(c) => resolved.push(c),
160 std::path::Component::ParentDir => {
161 resolved.pop();
162 }
163 std::path::Component::CurDir => {}
164 _ => return Err(traversal()),
166 }
167 }
168
169 if resolved.starts_with(canonical_workspace) {
170 Ok(resolved)
171 } else {
172 Err(traversal())
173 }
174}
175
176pub fn is_symlink(path: &Path) -> bool {
178 std::fs::symlink_metadata(path).is_ok_and(|m| m.file_type().is_symlink())
179}
180
181pub fn expand_user(path: &str) -> String {
183 if path.starts_with('~') {
184 if let Some(home_str) = home::home_dir().as_deref().and_then(Path::to_str) {
189 return path.replacen('~', home_str, 1);
190 }
191 }
192 path.to_string()
193}
194
195pub fn ensure_directory_exists(path: &Path) -> std::io::Result<()> {
197 if !path.exists() {
198 std::fs::create_dir_all(path)?;
199 }
200 Ok(())
201}
202
203pub fn resolve_in_workspace(
207 path: &str,
208 cwd: &Path,
209 workspace_root: &Path,
210) -> Result<PathBuf, PathSecurityError> {
211 if path.trim().is_empty() {
212 return validate_path_in_workspace(workspace_root, workspace_root);
213 }
214 let expanded = expand_user(path);
215 let candidate = if Path::new(&expanded).is_absolute() {
216 PathBuf::from(expanded)
217 } else {
218 cwd.join(expanded)
219 };
220 validate_path_in_workspace(&candidate, workspace_root)
221}
222
223pub fn glob_matches(pattern: &glob::Pattern, relative: &Path) -> bool {
228 let opts = glob::MatchOptions { require_literal_separator: true, ..glob::MatchOptions::new() };
229 if pattern.matches_path_with(relative, opts) {
230 return true;
231 }
232 relative
233 .file_name()
234 .is_some_and(|name| pattern.matches_with(name.to_string_lossy().as_ref(), opts))
235}
236
237#[cfg(test)]
238mod tests {
239 #![allow(clippy::unwrap_used)]
240 use super::*;
241 use proptest::prelude::*;
242 use std::fs;
243 use tempfile::TempDir;
244
245 proptest! {
246 #![proptest_config(ProptestConfig::with_cases(256))]
247
248 #[test]
254 fn resolve_in_workspace_ok_implies_contained(
255 segments in prop::collection::vec(
256 prop_oneof![Just("..".to_string()), Just(".".to_string()), "[a-zA-Z0-9_]{1,5}"],
257 0..10,
258 )
259 ) {
260 let ws = std::env::temp_dir().canonicalize().unwrap();
261 let rel = segments.join("/");
262 if let Ok(resolved) = resolve_in_workspace(&rel, &ws, &ws) {
263 prop_assert!(
264 resolved.starts_with(&ws),
265 "resolve_in_workspace({rel:?}) escaped to {resolved:?}"
266 );
267 }
268 }
269
270 #[test]
274 fn validate_ok_implies_contained_any_input(s in ".*") {
275 let ws = std::env::temp_dir().canonicalize().unwrap();
276 if let Ok(p) = validate_path_in_workspace(Path::new(&s), &ws) {
277 prop_assert!(p.starts_with(&ws), "accepted escaping path {p:?} from input {s:?}");
278 }
279 }
280 }
281
282 #[test]
283 fn expand_user_leaves_non_tilde_paths_untouched() {
284 assert_eq!(expand_user("/abs/path"), "/abs/path");
285 assert_eq!(expand_user("rel/path"), "rel/path");
286 }
287
288 #[test]
289 fn expand_user_never_maps_tilde_to_root() {
290 let out = expand_user("~/sub");
294 assert_ne!(out, "/sub", "~/sub must not become the filesystem root");
295 if let Some(home) = home::home_dir().and_then(|h| h.to_str().map(String::from)) {
296 assert_eq!(out, format!("{home}/sub"));
297 }
298 }
299
300 #[test]
301 fn allows_existing_file_in_workspace() {
302 let ws = TempDir::new().unwrap();
303 let f = ws.path().join("a.txt");
304 fs::write(&f, "x").unwrap();
305 let v = validate_path_in_workspace(&f, ws.path()).unwrap();
306 assert!(v.starts_with(ws.path().canonicalize().unwrap()));
307 }
308
309 #[test]
310 fn allows_new_file_in_nested_nonexistent_dir() {
311 let ws = TempDir::new().unwrap();
314 let f = ws.path().join("new/deep/dir/file.txt");
315 let v = validate_path_in_workspace(&f, ws.path()).unwrap();
316 assert!(v.starts_with(ws.path().canonicalize().unwrap()));
317 assert!(v.ends_with("new/deep/dir/file.txt"));
318 }
319
320 #[test]
321 fn rejects_traversal_via_dotdot_in_new_path() {
322 let ws = TempDir::new().unwrap();
323 let f = ws.path().join("nope/../../etc/passwd");
324 assert!(matches!(
325 validate_path_in_workspace(&f, ws.path()),
326 Err(PathSecurityError::PathTraversal { .. })
327 ));
328 }
329
330 #[test]
331 fn rejects_existing_path_outside_workspace() {
332 let ws = TempDir::new().unwrap();
333 let outside = TempDir::new().unwrap();
334 let f = outside.path().join("secret.txt");
335 fs::write(&f, "s").unwrap();
336 assert!(matches!(
337 validate_path_in_workspace(&f, ws.path()),
338 Err(PathSecurityError::PathTraversal { .. })
339 ));
340 }
341
342 #[cfg(unix)]
343 #[test]
344 fn rejects_symlink_escaping_workspace() {
345 use std::os::unix::fs::symlink;
346 let ws = TempDir::new().unwrap();
347 let outside = TempDir::new().unwrap();
348 let secret = outside.path().join("secret.txt");
349 fs::write(&secret, "s").unwrap();
350 let link = ws.path().join("link.txt");
351 symlink(&secret, &link).unwrap();
352 assert!(matches!(
353 validate_path_in_workspace(&link, ws.path()),
354 Err(PathSecurityError::SymlinkEscape { .. })
355 ));
356 }
357
358 #[cfg(unix)]
359 #[test]
360 fn rejects_new_file_through_dangling_symlink_dir() {
361 use std::os::unix::fs::symlink;
366 let ws = TempDir::new().unwrap();
367 let outside = TempDir::new().unwrap();
368 let link = ws.path().join("evil");
369 symlink(outside.path().join("nonexistent"), &link).unwrap();
370 let f = link.join("file.txt");
371 assert!(validate_path_in_workspace(&f, ws.path()).is_err());
372 }
373
374 #[cfg(unix)]
375 #[test]
376 fn allows_new_file_through_internal_symlink_dir() {
377 use std::os::unix::fs::symlink;
380 let ws = TempDir::new().unwrap();
381 let real = ws.path().join("real");
382 fs::create_dir(&real).unwrap();
383 let link = ws.path().join("link");
384 symlink(&real, &link).unwrap();
385 let f = link.join("file.txt");
386 let v = validate_path_in_workspace(&f, ws.path()).unwrap();
387 assert!(v.starts_with(ws.path().canonicalize().unwrap()));
388 }
389}