Skip to main content

winx_code_agent/utils/
path.rs

1use std::io;
2use std::path::{Path, PathBuf};
3
4/// Security error for path validation
5#[derive(Debug)]
6pub enum PathSecurityError {
7    /// Path escapes the workspace root (path traversal attempt)
8    PathTraversal { path: PathBuf, workspace: PathBuf },
9    /// Path is a symlink pointing outside workspace
10    SymlinkEscape { path: PathBuf, target: PathBuf, workspace: PathBuf },
11    /// Failed to canonicalize path
12    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
44/// Validates that a path is within the workspace root.
45/// Returns the canonicalized path if valid.
46///
47/// # Security
48/// - Prevents path traversal attacks (../)
49/// - Detects symlinks pointing outside workspace
50/// - Canonicalizes path before comparison
51pub fn validate_path_in_workspace(
52    path: &Path,
53    workspace_root: &Path,
54) -> Result<PathBuf, PathSecurityError> {
55    // Resolve the workspace boundary once, up front; everything is checked
56    // against this. Fail closed if the workspace itself can't be canonicalized.
57    let canonical_workspace = workspace_root.canonicalize().map_err(|e| {
58        PathSecurityError::CanonicalizationFailed { path: workspace_root.to_path_buf(), error: e }
59    })?;
60
61    // If `path` itself is a symlink, resolve its target and reject if it escapes.
62    // Fail CLOSED: a target we can't resolve (e.g. dangling) is refused, not
63    // waved through (the old code silently passed on any resolution failure).
64    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    // Resolve `path`. If it exists, canonicalize() collapses `..`, resolves
91    // symlinks, etc. If it doesn't (creating a new file/dir), fall back to a
92    // lexical resolution of the not-yet-existing tail.
93    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
113/// Resolve a not-yet-existing path for containment checking (supports creating
114/// files in directories that don't exist yet — i.e. `mkdir -p` semantics).
115///
116/// Strategy: walk up to the first ancestor that exists *as a filesystem entry*,
117/// canonicalize it (resolving any real symlinks in the existing prefix), then
118/// re-apply the remaining (non-existent) components purely lexically — `..`
119/// pops, `.` is skipped — and verify the result is inside `canonical_workspace`.
120///
121/// Two subtleties make this safe:
122/// - We stop the walk-up on `symlink_metadata` (entry exists), NOT `exists()`
123///   (which follows symlinks). A dangling symlink in an intermediate component
124///   therefore becomes the canonicalize target and fails closed, instead of
125///   being treated as a fresh lexical component that a later `create_dir_all`
126///   would follow out of the workspace.
127/// - The lexical pass resolves `..` before the containment check, so
128///   `workspace/new/../../etc/passwd` is rejected.
129fn 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    // Walk up to the first ancestor that exists as a filesystem entry.
136    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    // The deepest existing ancestor must resolve (a dangling symlink here fails
148    // closed) and anchor the resolution.
149    let canonical_base = existing.canonicalize().map_err(|e| {
150        PathSecurityError::CanonicalizationFailed { path: existing.to_path_buf(), error: e }
151    })?;
152
153    // Apply the components after `existing` lexically (they don't exist yet, so
154    // there are no symlinks among them to follow).
155    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            // RootDir / Prefix must not appear in a relative remainder.
165            _ => return Err(traversal()),
166        }
167    }
168
169    if resolved.starts_with(canonical_workspace) {
170        Ok(resolved)
171    } else {
172        Err(traversal())
173    }
174}
175
176/// Check if a path is a symlink without following it
177pub fn is_symlink(path: &Path) -> bool {
178    std::fs::symlink_metadata(path).is_ok_and(|m| m.file_type().is_symlink())
179}
180
181/// Expands a path that starts with ~ to the user's home directory
182pub fn expand_user(path: &str) -> String {
183    if path.starts_with('~') {
184        // Only expand when home is known AND valid UTF-8. The old `to_str()
185        // .unwrap_or("")` mapped `~/x` to `/x` (the filesystem root!) on a
186        // non-UTF-8 $HOME — silently pointing at the wrong place. Leaving the
187        // literal `~` is the safe failure.
188        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
195/// Ensures a directory exists, creating it if necessary
196pub 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
203/// Resolve a user-supplied path string (possibly empty, relative, or `~`) into a
204/// workspace-confined absolute path. Empty input resolves to the workspace root.
205/// Used by the read-only search/glob tools to scope and confine their root.
206pub 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
223/// Match a glob against a workspace-relative path. `*`/`?` do NOT cross `/`
224/// (so `src/*.ts` matches only direct children — `**` is the recursive form,
225/// matching `find`/`bash` semantics); a bare pattern (e.g. `*.rs`) also matches
226/// the file name at any depth, giving the intuitive "all .rs files".
227pub 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        /// The containment invariant the whole file-safety story rests on: no
249        /// relative path string — however many `..`/`.`/odd segments — can make
250        /// resolve_in_workspace return Ok pointing OUTSIDE the workspace. Either it
251        /// errors, or the result is contained. (Uses the real /tmp as a stand-in
252        /// workspace so each case is read-only and cheap.)
253        #[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        /// Same invariant at the lower layer, for ANY input string (incl. absolute
271        /// paths, traversal, junk): validate_path_in_workspace must never accept a
272        /// path that escapes, and must never panic.
273        #[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        // Regression: a non-UTF-8 (or unknown) $HOME used to turn `~/sub` into
291        // `/sub` (the filesystem root). It must expand to a home-prefixed path or
292        // stay literal — never collapse to root.
293        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        // mkdir -p semantics: a deep, not-yet-existing path resolves and stays
312        // contained (validation happens before the dirs are created).
313        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        // An intermediate component is a dangling symlink pointing outside the
362        // workspace. The walk-up must stop on it (symlink_metadata) and fail
363        // closed, NOT treat it as a fresh lexical component that a later
364        // create_dir_all would follow out of the workspace.
365        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        // A symlink to a directory INSIDE the workspace is fine; the resolved
378        // path stays contained.
379        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}