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        if let Some(home_dir) = home::home_dir() {
185            return path.replacen('~', home_dir.to_str().unwrap_or(""), 1);
186        }
187    }
188    path.to_string()
189}
190
191/// Ensures a directory exists, creating it if necessary
192pub fn ensure_directory_exists(path: &Path) -> std::io::Result<()> {
193    if !path.exists() {
194        std::fs::create_dir_all(path)?;
195    }
196    Ok(())
197}
198
199/// Resolve a user-supplied path string (possibly empty, relative, or `~`) into a
200/// workspace-confined absolute path. Empty input resolves to the workspace root.
201/// Used by the read-only search/glob tools to scope and confine their root.
202pub fn resolve_in_workspace(
203    path: &str,
204    cwd: &Path,
205    workspace_root: &Path,
206) -> Result<PathBuf, PathSecurityError> {
207    if path.trim().is_empty() {
208        return validate_path_in_workspace(workspace_root, workspace_root);
209    }
210    let expanded = expand_user(path);
211    let candidate = if Path::new(&expanded).is_absolute() {
212        PathBuf::from(expanded)
213    } else {
214        cwd.join(expanded)
215    };
216    validate_path_in_workspace(&candidate, workspace_root)
217}
218
219/// Match a glob against a workspace-relative path. `*`/`?` do NOT cross `/`
220/// (so `src/*.ts` matches only direct children — `**` is the recursive form,
221/// matching `find`/`bash` semantics); a bare pattern (e.g. `*.rs`) also matches
222/// the file name at any depth, giving the intuitive "all .rs files".
223pub fn glob_matches(pattern: &glob::Pattern, relative: &Path) -> bool {
224    let opts = glob::MatchOptions { require_literal_separator: true, ..glob::MatchOptions::new() };
225    if pattern.matches_path_with(relative, opts) {
226        return true;
227    }
228    relative
229        .file_name()
230        .is_some_and(|name| pattern.matches_with(name.to_string_lossy().as_ref(), opts))
231}
232
233#[cfg(test)]
234mod tests {
235    #![allow(clippy::unwrap_used)]
236    use super::*;
237    use std::fs;
238    use tempfile::TempDir;
239
240    #[test]
241    fn allows_existing_file_in_workspace() {
242        let ws = TempDir::new().unwrap();
243        let f = ws.path().join("a.txt");
244        fs::write(&f, "x").unwrap();
245        let v = validate_path_in_workspace(&f, ws.path()).unwrap();
246        assert!(v.starts_with(ws.path().canonicalize().unwrap()));
247    }
248
249    #[test]
250    fn allows_new_file_in_nested_nonexistent_dir() {
251        // mkdir -p semantics: a deep, not-yet-existing path resolves and stays
252        // contained (validation happens before the dirs are created).
253        let ws = TempDir::new().unwrap();
254        let f = ws.path().join("new/deep/dir/file.txt");
255        let v = validate_path_in_workspace(&f, ws.path()).unwrap();
256        assert!(v.starts_with(ws.path().canonicalize().unwrap()));
257        assert!(v.ends_with("new/deep/dir/file.txt"));
258    }
259
260    #[test]
261    fn rejects_traversal_via_dotdot_in_new_path() {
262        let ws = TempDir::new().unwrap();
263        let f = ws.path().join("nope/../../etc/passwd");
264        assert!(matches!(
265            validate_path_in_workspace(&f, ws.path()),
266            Err(PathSecurityError::PathTraversal { .. })
267        ));
268    }
269
270    #[test]
271    fn rejects_existing_path_outside_workspace() {
272        let ws = TempDir::new().unwrap();
273        let outside = TempDir::new().unwrap();
274        let f = outside.path().join("secret.txt");
275        fs::write(&f, "s").unwrap();
276        assert!(matches!(
277            validate_path_in_workspace(&f, ws.path()),
278            Err(PathSecurityError::PathTraversal { .. })
279        ));
280    }
281
282    #[cfg(unix)]
283    #[test]
284    fn rejects_symlink_escaping_workspace() {
285        use std::os::unix::fs::symlink;
286        let ws = TempDir::new().unwrap();
287        let outside = TempDir::new().unwrap();
288        let secret = outside.path().join("secret.txt");
289        fs::write(&secret, "s").unwrap();
290        let link = ws.path().join("link.txt");
291        symlink(&secret, &link).unwrap();
292        assert!(matches!(
293            validate_path_in_workspace(&link, ws.path()),
294            Err(PathSecurityError::SymlinkEscape { .. })
295        ));
296    }
297
298    #[cfg(unix)]
299    #[test]
300    fn rejects_new_file_through_dangling_symlink_dir() {
301        // An intermediate component is a dangling symlink pointing outside the
302        // workspace. The walk-up must stop on it (symlink_metadata) and fail
303        // closed, NOT treat it as a fresh lexical component that a later
304        // create_dir_all would follow out of the workspace.
305        use std::os::unix::fs::symlink;
306        let ws = TempDir::new().unwrap();
307        let outside = TempDir::new().unwrap();
308        let link = ws.path().join("evil");
309        symlink(outside.path().join("nonexistent"), &link).unwrap();
310        let f = link.join("file.txt");
311        assert!(validate_path_in_workspace(&f, ws.path()).is_err());
312    }
313
314    #[cfg(unix)]
315    #[test]
316    fn allows_new_file_through_internal_symlink_dir() {
317        // A symlink to a directory INSIDE the workspace is fine; the resolved
318        // path stays contained.
319        use std::os::unix::fs::symlink;
320        let ws = TempDir::new().unwrap();
321        let real = ws.path().join("real");
322        fs::create_dir(&real).unwrap();
323        let link = ws.path().join("link");
324        symlink(&real, &link).unwrap();
325        let f = link.join("file.txt");
326        let v = validate_path_in_workspace(&f, ws.path()).unwrap();
327        assert!(v.starts_with(ws.path().canonicalize().unwrap()));
328    }
329}