Skip to main content

vtcode_commons/paths/
containment.rs

1use super::canonicalize::{canonicalize, canonicalize_async};
2use super::normalize::normalize_path;
3use anyhow::{Context, Result, anyhow, bail};
4use std::path::{Component, Path, PathBuf};
5use tracing::warn;
6
7/// Validate every existing path component and reject escaping symlinks.
8///
9/// Platform aliases such as macOS `/tmp -> /private/tmp` are accepted when a
10/// symlink resolves beneath its containing directory. Missing trailing
11/// components are allowed when `allow_missing_tail` is set so callers can
12/// create them safely afterwards. A symlinked leaf always fails: callers
13/// must decide explicitly whether to follow, replace, or reject it.
14///
15/// # Errors
16///
17/// Returns an error when the path contains `..`, when any existing
18/// component is a symlink escaping its containing directory (including a
19/// symlinked leaf), when a non-leaf component is not a directory, or when
20/// a component is missing and `allow_missing_tail` is false.
21pub fn validate_no_escaping_symlink_ancestors(path: &Path, allow_missing_tail: bool) -> std::io::Result<()> {
22    use std::fs;
23
24    let components = path.components().collect::<Vec<_>>();
25    let mut current = PathBuf::new();
26    for (index, component) in components.iter().enumerate() {
27        if matches!(component, Component::ParentDir) {
28            return Err(std::io::Error::other(format!("path contains traversal: {}", path.display())));
29        }
30        if matches!(component, Component::CurDir) {
31            continue;
32        }
33        current.push(component.as_os_str());
34        let is_leaf = index + 1 == components.len();
35        let metadata = match fs::symlink_metadata(&current) {
36            Ok(metadata) => metadata,
37            Err(error) if allow_missing_tail && error.kind() == std::io::ErrorKind::NotFound => break,
38            Err(error) => return Err(error),
39        };
40        if metadata.file_type().is_symlink() {
41            if is_leaf {
42                return Err(std::io::Error::other(format!("refusing symlink path {}", current.display())));
43            }
44            let parent = current
45                .parent()
46                .filter(|parent| !parent.as_os_str().is_empty())
47                .unwrap_or_else(|| Path::new("."));
48            let canonical_parent = canonicalize(parent)?;
49            let canonical_target = canonicalize(&current)?;
50            if !canonical_target.starts_with(&canonical_parent) {
51                return Err(std::io::Error::other(format!(
52                    "path component {} escapes its containing directory",
53                    current.display()
54                )));
55            }
56            if !fs::metadata(&current)?.is_dir() {
57                return Err(std::io::Error::other(format!("path component {} is not a directory", current.display())));
58            }
59        } else if !is_leaf && !metadata.is_dir() {
60            return Err(std::io::Error::other(format!("path component {} is not a directory", current.display())));
61        }
62    }
63    Ok(())
64}
65
66/// Render a path relative to the workspace when it belongs to that workspace.
67///
68/// Paths outside the workspace remain absolute (or otherwise unchanged) so a
69/// diagnostic never hides that it refers to an external location. The
70/// filesystem-resolved comparison handles workspace aliases and symlink
71/// escapes without requiring callers to canonicalize their candidate first.
72pub fn workspace_relative_display(workspace_root: &Path, path: &Path) -> String {
73    let candidate = if path.is_absolute() {
74        path.to_path_buf()
75    } else {
76        workspace_root.join(path)
77    };
78
79    if let Ok(canonical_workspace) = canonicalize(workspace_root) {
80        // A successful canonicalization is authoritative: a path that
81        // resolves outside the workspace must not fall back to lexical
82        // containment through an escaping symlink.
83        match canonicalize_for_display(&candidate) {
84            DisplayResolution::Resolved(canonical_candidate) => {
85                return canonical_candidate
86                    .strip_prefix(&canonical_workspace)
87                    .map(|relative| relative.to_string_lossy().into_owned())
88                    .unwrap_or_else(|_| path.to_string_lossy().into_owned());
89            }
90            DisplayResolution::Unresolved => return path.to_string_lossy().into_owned(),
91        }
92    }
93
94    // If the candidate cannot be resolved yet (for example, a new file with a
95    // missing tail), retain the cheap lexical behavior for paths that are
96    // clearly under the workspace.
97    let normalized_candidate = normalize_path(&candidate);
98    let normalized_workspace = normalize_path(workspace_root);
99    if let Ok(relative) = normalized_candidate.strip_prefix(normalized_workspace) {
100        return relative.to_string_lossy().into_owned();
101    }
102    path.to_string_lossy().into_owned()
103}
104
105enum DisplayResolution {
106    Resolved(PathBuf),
107    Unresolved,
108}
109
110fn canonicalize_for_display(path: &Path) -> DisplayResolution {
111    if let Ok(canonical) = canonicalize(path) {
112        return DisplayResolution::Resolved(canonical);
113    }
114
115    let mut missing_tail = Vec::new();
116    let mut existing_prefix = path;
117    loop {
118        match std::fs::symlink_metadata(existing_prefix) {
119            Ok(_) => break,
120            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
121                let Some(file_name) = existing_prefix.file_name() else {
122                    return DisplayResolution::Unresolved;
123                };
124                let Some(parent) = existing_prefix.parent() else {
125                    return DisplayResolution::Unresolved;
126                };
127                missing_tail.push(PathBuf::from(file_name));
128                existing_prefix = parent;
129            }
130            Err(_) => return DisplayResolution::Unresolved,
131        }
132    }
133
134    let Ok(mut canonical) = canonicalize(existing_prefix) else {
135        // This includes dangling symlinks and inaccessible existing paths.
136        // Do not fall back to lexical containment for either case.
137        return DisplayResolution::Unresolved;
138    };
139    for component in missing_tail.into_iter().rev() {
140        canonical.push(component);
141    }
142    DisplayResolution::Resolved(canonical)
143}
144
145/// Resolve a path relative to a workspace root and ensure it stays within it.
146pub fn resolve_workspace_path(workspace_root: &Path, user_path: &Path) -> Result<PathBuf> {
147    let candidate = if user_path.is_absolute() {
148        user_path.to_path_buf()
149    } else {
150        workspace_root.join(user_path)
151    };
152
153    let canonical =
154        canonicalize(&candidate).with_context(|| format!("Failed to canonicalize path {}", candidate.display()))?;
155
156    let workspace_canonical = canonicalize(workspace_root)
157        .with_context(|| format!("Failed to canonicalize workspace root {}", workspace_root.display()))?;
158
159    if !canonical.starts_with(&workspace_canonical) {
160        return Err(anyhow!("Path {} escapes workspace root {}", canonical.display(), workspace_canonical.display()));
161    }
162
163    Ok(canonical)
164}
165
166/// Return a canonicalised absolute path that is guaranteed to reside inside the
167/// provided `workspace_root`.  If the path is outside the workspace an error is
168/// returned.
169pub fn secure_path(workspace_root: &Path, user_path: &Path) -> Result<PathBuf> {
170    // Resolve relative paths against the workspace root.
171    resolve_workspace_path(workspace_root, user_path)
172}
173
174/// Ensure a candidate path is inside the workspace root after lexical
175/// normalization.
176///
177/// This is the cheap, filesystem-free tier of workspace containment: it
178/// resolves `.`/`..` components lexically but does not follow symlinks. Use
179/// [`ensure_path_within_workspace_resolved`] when the candidate may traverse
180/// symlinks that point outside the workspace.
181///
182/// Returns the normalized candidate path on success.
183pub fn ensure_path_within_workspace(candidate: &Path, workspace_root: &Path) -> Result<PathBuf> {
184    let normalized_candidate = normalize_path(candidate);
185    let normalized_workspace = normalize_path(workspace_root);
186
187    if !normalized_candidate.starts_with(&normalized_workspace) {
188        bail!("Path '{}' escapes workspace '{}'", candidate.display(), workspace_root.display());
189    }
190
191    Ok(normalized_candidate)
192}
193
194/// Ensure a candidate path is inside the workspace root, resolving symlinks
195/// component by component.
196///
197/// This is the strict, filesystem-aware tier of workspace containment. On top
198/// of the lexical check performed by [`ensure_path_within_workspace`], it
199/// walks each component of the candidate below the workspace root and:
200///
201/// - canonicalizes every existing component and verifies the resolved path
202///   still starts with the canonical workspace root (catches symlinks that
203///   point outside the workspace);
204/// - tolerates nonexistent tail components (paths about to be created);
205/// - rejects traversal through a file component (e.g. `file.txt/child`).
206///
207/// The candidate must already be lexically inside `workspace_root` (both
208/// sides are normalized before comparison).
209///
210/// Returns the normalized candidate path on success.
211pub async fn ensure_path_within_workspace_resolved(candidate: &Path, workspace_root: &Path) -> Result<PathBuf> {
212    let normalized_root = normalize_path(workspace_root);
213    let normalized_candidate = normalize_path(candidate);
214
215    let canonical_root = match canonicalize_async(&normalized_root).await {
216        Ok(resolved) => resolved,
217        Err(error) => {
218            warn!(
219                path = %normalized_root.display(),
220                %error,
221                "Failed to canonicalize workspace root; falling back to provided path"
222            );
223            normalized_root.clone()
224        }
225    };
226
227    if normalized_root == normalized_candidate {
228        return Ok(normalized_candidate);
229    }
230
231    let relative = normalized_candidate
232        .strip_prefix(&normalized_root)
233        .map_err(|_error| anyhow!("path '{}' escapes the workspace root", candidate.display()))?
234        .to_path_buf();
235
236    let mut prefix = normalized_root.clone();
237    let mut components = relative.components().peekable();
238
239    while let Some(component) = components.next() {
240        prefix.push(component.as_os_str());
241
242        let metadata = match tokio::fs::symlink_metadata(&prefix).await {
243            Ok(metadata) => metadata,
244            Err(error) => {
245                if error.kind() == std::io::ErrorKind::NotFound {
246                    break;
247                }
248                return Err(error).with_context(|| format!("failed to inspect path component '{}'", prefix.display()));
249            }
250        };
251
252        let resolved = canonicalize_async(&prefix)
253            .await
254            .with_context(|| format!("failed to canonicalize path component '{}'", prefix.display()))?;
255
256        if metadata.file_type().is_symlink() {
257            if !resolved.starts_with(&canonical_root) {
258                return Err(anyhow!(
259                    "path '{}' escapes the workspace root via symlink '{}'",
260                    candidate.display(),
261                    prefix.display()
262                ));
263            }
264        } else {
265            if !resolved.starts_with(&canonical_root) {
266                return Err(anyhow!(
267                    "path '{}' escapes the workspace root via component '{}'",
268                    candidate.display(),
269                    prefix.display()
270                ));
271            }
272
273            if metadata.is_file() && components.peek().is_some() {
274                return Err(anyhow!(
275                    "path '{}' traverses through file component '{}'",
276                    candidate.display(),
277                    prefix.display()
278                ));
279            }
280        }
281    }
282
283    Ok(normalized_candidate)
284}
285
286#[cfg(test)]
287mod tests {
288    use super::super::canonicalize::canonicalize;
289    use super::*;
290    use std::path::{Path, PathBuf};
291
292    #[test]
293    fn workspace_relative_display_uses_workspace_relative_paths() {
294        let workspace = Path::new("/workspace");
295        let path = Path::new("/workspace/src/main.rs");
296
297        assert_eq!(workspace_relative_display(workspace, path), "src/main.rs");
298    }
299
300    #[test]
301    fn workspace_relative_display_preserves_external_paths() {
302        let workspace = Path::new("/workspace");
303        let path = Path::new("/tmp/external.txt");
304
305        assert_eq!(workspace_relative_display(workspace, path), "/tmp/external.txt");
306    }
307
308    #[test]
309    fn workspace_relative_display_handles_canonical_workspace_paths() {
310        let workspace = tempfile::tempdir().unwrap();
311        let workspace_path = canonicalize(workspace.path()).unwrap();
312        let path = workspace_path.join("docs").join("guide.md");
313
314        assert_eq!(workspace_relative_display(workspace.path(), &path), "docs/guide.md");
315    }
316
317    #[cfg(unix)]
318    #[test]
319    fn workspace_relative_display_resolves_workspace_aliases() {
320        use std::os::unix::fs::symlink;
321
322        let temp = tempfile::tempdir().unwrap();
323        let workspace = temp.path().join("workspace");
324        std::fs::create_dir_all(workspace.join("src")).unwrap();
325        let alias = temp.path().join("workspace-alias");
326        symlink(&workspace, &alias).unwrap();
327
328        let path = alias.join("src").join("main.rs");
329        assert_eq!(workspace_relative_display(&workspace, &path), "src/main.rs");
330    }
331
332    #[cfg(unix)]
333    #[test]
334    fn workspace_relative_display_preserves_symlink_escape() {
335        use std::os::unix::fs::symlink;
336
337        let temp = tempfile::tempdir().unwrap();
338        let workspace = temp.path().join("workspace");
339        let outside = temp.path().join("outside");
340        std::fs::create_dir_all(&workspace).unwrap();
341        std::fs::create_dir_all(&outside).unwrap();
342        std::fs::write(outside.join("secret.txt"), "secret").unwrap();
343        symlink(&outside, workspace.join("linked-outside")).unwrap();
344
345        let path = workspace.join("linked-outside").join("secret.txt");
346        assert_eq!(workspace_relative_display(&workspace, &path), path.to_string_lossy());
347    }
348
349    #[cfg(unix)]
350    #[test]
351    fn workspace_relative_display_fails_closed_for_dangling_symlink() {
352        use std::os::unix::fs::symlink;
353
354        let temp = tempfile::tempdir().unwrap();
355        let workspace = temp.path().join("workspace");
356        std::fs::create_dir_all(&workspace).unwrap();
357        let path = workspace.join("dangling-link");
358        symlink(temp.path().join("missing-target"), &path).unwrap();
359
360        assert_eq!(workspace_relative_display(&workspace, &path), path.to_string_lossy());
361    }
362
363    #[test]
364    fn ensures_path_within_workspace_accepts_nested_path() {
365        let workspace = Path::new("/tmp/project");
366        let candidate = Path::new("/tmp/project/src/../src/lib.rs");
367        let normalized = ensure_path_within_workspace(candidate, workspace).unwrap();
368        assert_eq!(normalized, PathBuf::from("/tmp/project/src/lib.rs"));
369    }
370
371    #[test]
372    fn ensures_path_within_workspace_rejects_escape() {
373        let workspace = Path::new("/tmp/project");
374        let candidate = Path::new("/tmp/project/../../etc/passwd");
375        assert!(ensure_path_within_workspace(candidate, workspace).is_err());
376    }
377
378    #[tokio::test]
379    async fn resolved_check_accepts_nested_existing_path() {
380        let workspace = tempfile::tempdir().unwrap();
381        let root = canonicalize(workspace.path()).unwrap();
382        let nested = root.join("src");
383        tokio::fs::create_dir_all(&nested).await.unwrap();
384        let file = nested.join("lib.rs");
385        tokio::fs::write(&file, b"test").await.unwrap();
386
387        let result = ensure_path_within_workspace_resolved(&file, &root).await;
388        assert_eq!(result.unwrap(), file);
389    }
390
391    #[tokio::test]
392    async fn resolved_check_accepts_missing_tail_components() {
393        let workspace = tempfile::tempdir().unwrap();
394        let root = canonicalize(workspace.path()).unwrap();
395        let missing = root.join("new_dir/new_file.txt");
396
397        let result = ensure_path_within_workspace_resolved(&missing, &root).await;
398        assert_eq!(result.unwrap(), missing);
399    }
400
401    #[tokio::test]
402    async fn resolved_check_rejects_lexical_escape() {
403        let workspace = tempfile::tempdir().unwrap();
404        let root = canonicalize(workspace.path()).unwrap();
405        let escape = root.join("../outside.txt");
406
407        assert!(ensure_path_within_workspace_resolved(&escape, &root).await.is_err());
408    }
409
410    #[cfg(unix)]
411    #[tokio::test]
412    async fn resolved_check_rejects_symlink_escape() {
413        let workspace = tempfile::tempdir().unwrap();
414        let outside = tempfile::tempdir().unwrap();
415        let root = canonicalize(workspace.path()).unwrap();
416        let outside_dir = canonicalize(outside.path()).unwrap();
417
418        let link = root.join("escape");
419        tokio::fs::symlink(&outside_dir, &link).await.unwrap();
420
421        let candidate = link.join("secret.txt");
422        assert!(ensure_path_within_workspace_resolved(&candidate, &root).await.is_err());
423    }
424
425    #[cfg(unix)]
426    #[tokio::test]
427    async fn resolved_check_accepts_symlink_within_workspace() {
428        let workspace = tempfile::tempdir().unwrap();
429        let root = canonicalize(workspace.path()).unwrap();
430        let target = root.join("real");
431        tokio::fs::create_dir_all(&target).await.unwrap();
432        let link = root.join("alias");
433        tokio::fs::symlink(&target, &link).await.unwrap();
434
435        let candidate = link.join("file.txt");
436        assert!(ensure_path_within_workspace_resolved(&candidate, &root).await.is_ok());
437    }
438
439    #[tokio::test]
440    async fn resolved_check_rejects_traversal_through_file() {
441        let workspace = tempfile::tempdir().unwrap();
442        let root = canonicalize(workspace.path()).unwrap();
443        let file = root.join("data.txt");
444        tokio::fs::write(&file, b"test").await.unwrap();
445
446        let candidate = file.join("child.txt");
447        assert!(ensure_path_within_workspace_resolved(&candidate, &root).await.is_err());
448    }
449
450    #[test]
451    fn symlink_ancestor_validation_rejects_traversal_and_missing_tail() {
452        let temp = tempfile::tempdir().expect("tempdir");
453        let missing = temp.path().join("absent").join("child");
454        assert!(validate_no_escaping_symlink_ancestors(&missing, true).is_ok());
455        assert!(validate_no_escaping_symlink_ancestors(&missing, false).is_err());
456        assert!(validate_no_escaping_symlink_ancestors(Path::new("a/../b"), false).is_err());
457        assert!(validate_no_escaping_symlink_ancestors(temp.path(), false).is_ok());
458    }
459
460    #[cfg(unix)]
461    #[test]
462    fn symlink_ancestor_validation_rejects_escaping_links_but_allows_in_parent_aliases() {
463        use std::os::unix::fs::symlink;
464
465        let temp = tempfile::tempdir().expect("tempdir");
466        let outside = tempfile::tempdir().expect("outside tempdir");
467        let escape = temp.path().join("escape");
468        symlink(outside.path(), &escape).expect("symlink");
469        let error = validate_no_escaping_symlink_ancestors(&escape.join("child"), false)
470            .expect_err("escaping ancestor must fail");
471        assert!(error.to_string().contains("escapes its containing directory"), "{error}");
472        let leaf_error = validate_no_escaping_symlink_ancestors(&escape, false).expect_err("symlinked leaf must fail");
473        assert!(leaf_error.to_string().contains("refusing symlink path"), "{leaf_error}");
474
475        let inner = temp.path().join("inner");
476        std::fs::create_dir(&inner).expect("mkdir");
477        symlink(&inner, temp.path().join("alias")).expect("symlink");
478        // An in-parent alias as a non-leaf ancestor is tolerated (this is
479        // how platform aliases like /tmp survive validation); as a leaf it
480        // still fails closed.
481        assert!(validate_no_escaping_symlink_ancestors(&temp.path().join("alias").join("child"), true).is_ok());
482        assert!(validate_no_escaping_symlink_ancestors(&temp.path().join("alias"), false).is_err());
483    }
484}