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::fs::FileType;
5use std::path::{Component, Path, PathBuf};
6use tracing::warn;
7
8/// Validate every existing path component and reject escaping symlinks.
9///
10/// Platform aliases such as macOS `/tmp -> /private/tmp` are accepted when a
11/// symlink resolves beneath its containing directory. Missing trailing
12/// components are allowed when `allow_missing_tail` is set so callers can
13/// create them safely afterwards. A symlinked leaf always fails: callers
14/// must decide explicitly whether to follow, replace, or reject it.
15///
16/// # Errors
17///
18/// Returns an error when the path contains `..`, when any existing
19/// component is a symlink escaping its containing directory (including a
20/// symlinked leaf), when a non-leaf component is not a directory, or when
21/// a component is missing and `allow_missing_tail` is false.
22pub fn validate_no_escaping_symlink_ancestors(path: &Path, allow_missing_tail: bool) -> std::io::Result<()> {
23    use std::fs;
24
25    let components = path.components().collect::<Vec<_>>();
26    let mut current = PathBuf::new();
27    for (index, component) in components.iter().enumerate() {
28        if matches!(component, Component::ParentDir) {
29            return Err(std::io::Error::other(format!("path contains traversal: {}", path.display())));
30        }
31        if matches!(component, Component::CurDir) {
32            continue;
33        }
34        current.push(component.as_os_str());
35        let is_leaf = index + 1 == components.len();
36        let metadata = match fs::symlink_metadata(&current) {
37            Ok(metadata) => metadata,
38            Err(error) if allow_missing_tail && error.kind() == std::io::ErrorKind::NotFound => break,
39            Err(error) => return Err(error),
40        };
41        if metadata.file_type().is_symlink() {
42            if is_leaf {
43                return Err(std::io::Error::other(format!("refusing symlink path {}", current.display())));
44            }
45            let parent = current
46                .parent()
47                .filter(|parent| !parent.as_os_str().is_empty())
48                .unwrap_or_else(|| Path::new("."));
49            let canonical_parent = canonicalize(parent)?;
50            let canonical_target = canonicalize(&current)?;
51            if !canonical_target.starts_with(&canonical_parent) {
52                return Err(std::io::Error::other(format!(
53                    "path component {} escapes its containing directory",
54                    current.display()
55                )));
56            }
57            if !fs::metadata(&current)?.is_dir() {
58                return Err(std::io::Error::other(format!("path component {} is not a directory", current.display())));
59            }
60        } else if !is_leaf && !metadata.is_dir() {
61            return Err(std::io::Error::other(format!("path component {} is not a directory", current.display())));
62        }
63    }
64    Ok(())
65}
66
67/// Render a path relative to the workspace when it belongs to that workspace.
68///
69/// Paths outside the workspace remain absolute (or otherwise unchanged) so a
70/// diagnostic never hides that it refers to an external location. The
71/// filesystem-resolved comparison handles workspace aliases and symlink
72/// escapes without requiring callers to canonicalize their candidate first.
73///
74/// This is the one-shot form. Loops that render many entries should build a
75/// [`WorkspacePathDisplay`] once instead, so the workspace root is
76/// canonicalized a single time rather than once per entry.
77pub fn workspace_relative_display(workspace_root: &Path, path: &Path) -> String {
78    WorkspacePathDisplay::new(workspace_root).render(path)
79}
80
81/// Render a working directory for tool metadata: workspace-relative, `/`-separated.
82///
83/// Session backends report the directory they run in, and the shape is a user-visible
84/// contract shared by the PTY and pipe executors: paths inside the workspace are
85/// relative, the workspace root itself is `"."`, separators are always `/` so the
86/// value is stable across platforms, and paths outside the workspace stay absolute.
87pub fn workspace_relative_slash(workspace_root: &Path, path: &Path) -> String {
88    match path.strip_prefix(workspace_root) {
89        Ok(relative) if relative.as_os_str().is_empty() => ".".into(),
90        Ok(relative) => relative.to_string_lossy().replace('\\', "/"),
91        Err(_) => path.to_string_lossy().into_owned(),
92    }
93}
94
95/// Renders workspace-relative display paths for many entries under one root.
96///
97/// [`workspace_relative_display`] canonicalizes the workspace root on every
98/// call. That is invisible for a single diagnostic, but directory listings and
99/// tree walks render one path per entry, so the repeated `realpath` dominates
100/// the loop while always producing the same answer. This type resolves the
101/// root once and reuses it.
102#[derive(Clone)]
103pub struct WorkspacePathDisplay {
104    workspace_root: PathBuf,
105    /// Workspace root as resolved on the filesystem, when it exists.
106    canonical_root: Option<PathBuf>,
107}
108
109impl WorkspacePathDisplay {
110    /// Resolve the workspace root once, reusing it for every later [`Self::render`].
111    pub fn new(workspace_root: &Path) -> Self {
112        let canonical_root = canonicalize(workspace_root).ok();
113        Self {
114            workspace_root: workspace_root.to_path_buf(),
115            canonical_root,
116        }
117    }
118
119    /// Render `path` relative to the workspace, matching
120    /// [`workspace_relative_display`] exactly.
121    pub fn render(&self, path: &Path) -> String {
122        let candidate = if path.is_absolute() {
123            path.to_path_buf()
124        } else {
125            self.workspace_root.join(path)
126        };
127
128        if let Some(canonical_workspace) = self.canonical_root.as_ref() {
129            // A successful canonicalization is authoritative: a path that
130            // resolves outside the workspace must not fall back to lexical
131            // containment through an escaping symlink.
132            match canonicalize_for_display(&candidate) {
133                DisplayResolution::Resolved(canonical_candidate) => {
134                    return canonical_candidate
135                        .strip_prefix(canonical_workspace)
136                        .map(|relative| relative.to_string_lossy().into_owned())
137                        .unwrap_or_else(|_| path.to_string_lossy().into_owned());
138                }
139                DisplayResolution::Unresolved => return path.to_string_lossy().into_owned(),
140            }
141        }
142
143        // If the candidate cannot be resolved yet (for example, a new file with a
144        // missing tail), retain the cheap lexical behavior for paths that are
145        // clearly under the workspace.
146        let normalized_candidate = normalize_path(&candidate);
147        let normalized_workspace = normalize_path(&self.workspace_root);
148        if let Ok(relative) = normalized_candidate.strip_prefix(normalized_workspace) {
149            return relative.to_string_lossy().into_owned();
150        }
151        path.to_string_lossy().into_owned()
152    }
153
154    /// Render a path produced by a traversal that cannot cross a symlink.
155    ///
156    /// # Preconditions
157    ///
158    /// The caller must know both of the following, which hold for entries from
159    /// [`build_default_walker`](crate::walk::build_default_walker) (it does not
160    /// follow links) and for direct children of an already canonical directory:
161    ///
162    /// - `file_type` is the entry's own type, or `None` when unknown;
163    /// - no ancestor between the workspace root and `path` is a symlink.
164    ///
165    /// Under those conditions the resolved path equals the lexically stripped
166    /// path, so the per-entry `realpath` is redundant. Anything else — an
167    /// unknown type, or a symlinked leaf that may point outside the workspace —
168    /// falls back to [`Self::render`], which stays authoritative.
169    pub fn render_entry(&self, path: &Path, file_type: Option<FileType>) -> String {
170        if file_type.is_some_and(|file_type| !file_type.is_symlink())
171            && let Some(relative) = self.lexical_relative(path)
172        {
173            return relative;
174        }
175        self.render(path)
176    }
177
178    /// Strip whichever known root `path` lexically sits under, without any syscall.
179    ///
180    /// This is [`Self::render_entry`]'s core, exposed for callers that must
181    /// build *keys* rather than display strings (for example indexing children
182    /// by parent directory). It carries the same preconditions documented on
183    /// [`Self::render_entry`]: the caller must know the path came from a
184    /// traversal that cannot cross a symlink.
185    ///
186    /// Returns `None` for relative paths and for any `.`/`..` component, where
187    /// lexical stripping would disagree with the resolved form; callers fall
188    /// back to [`Self::render`].
189    pub fn relative_lexical(&self, path: &Path) -> Option<String> {
190        self.lexical_relative(path)
191    }
192
193    /// Strip whichever of the two known roots the path lexically sits under.
194    ///
195    /// Rejects relative paths and any `.`/`..` component: stripping is lexical
196    /// while [`Self::render`] resolves those components, so a path containing
197    /// them can strip to a different answer than the resolved form.
198    fn lexical_relative(&self, path: &Path) -> Option<String> {
199        if !path.is_absolute()
200            || path
201                .components()
202                .any(|component| matches!(component, Component::CurDir | Component::ParentDir))
203        {
204            return None;
205        }
206        let candidate = path;
207
208        self.canonical_root
209            .iter()
210            .chain(std::iter::once(&self.workspace_root))
211            .find_map(|root| {
212                candidate
213                    .strip_prefix(normalize_path(root))
214                    .ok()
215                    .map(|relative| relative.to_string_lossy().into_owned())
216            })
217    }
218}
219
220enum DisplayResolution {
221    Resolved(PathBuf),
222    Unresolved,
223}
224
225fn canonicalize_for_display(path: &Path) -> DisplayResolution {
226    if let Ok(canonical) = canonicalize(path) {
227        return DisplayResolution::Resolved(canonical);
228    }
229
230    let mut missing_tail = Vec::new();
231    let mut existing_prefix = path;
232    loop {
233        match std::fs::symlink_metadata(existing_prefix) {
234            Ok(_) => break,
235            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
236                let Some(file_name) = existing_prefix.file_name() else {
237                    return DisplayResolution::Unresolved;
238                };
239                let Some(parent) = existing_prefix.parent() else {
240                    return DisplayResolution::Unresolved;
241                };
242                missing_tail.push(PathBuf::from(file_name));
243                existing_prefix = parent;
244            }
245            Err(_) => return DisplayResolution::Unresolved,
246        }
247    }
248
249    let Ok(mut canonical) = canonicalize(existing_prefix) else {
250        // This includes dangling symlinks and inaccessible existing paths.
251        // Do not fall back to lexical containment for either case.
252        return DisplayResolution::Unresolved;
253    };
254    for component in missing_tail.into_iter().rev() {
255        canonical.push(component);
256    }
257    DisplayResolution::Resolved(canonical)
258}
259
260/// Resolve a path relative to a workspace root and ensure it stays within it.
261pub fn resolve_workspace_path(workspace_root: &Path, user_path: &Path) -> Result<PathBuf> {
262    let candidate = if user_path.is_absolute() {
263        user_path.to_path_buf()
264    } else {
265        workspace_root.join(user_path)
266    };
267
268    let canonical =
269        canonicalize(&candidate).with_context(|| format!("Failed to canonicalize path {}", candidate.display()))?;
270
271    let workspace_canonical = canonicalize(workspace_root)
272        .with_context(|| format!("Failed to canonicalize workspace root {}", workspace_root.display()))?;
273
274    if !canonical.starts_with(&workspace_canonical) {
275        return Err(anyhow!("Path {} escapes workspace root {}", canonical.display(), workspace_canonical.display()));
276    }
277
278    Ok(canonical)
279}
280
281/// Return a canonicalised absolute path that is guaranteed to reside inside the
282/// provided `workspace_root`.  If the path is outside the workspace an error is
283/// returned.
284pub fn secure_path(workspace_root: &Path, user_path: &Path) -> Result<PathBuf> {
285    // Resolve relative paths against the workspace root.
286    resolve_workspace_path(workspace_root, user_path)
287}
288
289/// Ensure a candidate path is inside the workspace root after lexical
290/// normalization.
291///
292/// This is the cheap, filesystem-free tier of workspace containment: it
293/// resolves `.`/`..` components lexically but does not follow symlinks. Use
294/// [`ensure_path_within_workspace_resolved`] when the candidate may traverse
295/// symlinks that point outside the workspace.
296///
297/// Returns the normalized candidate path on success.
298pub fn ensure_path_within_workspace(candidate: &Path, workspace_root: &Path) -> Result<PathBuf> {
299    let normalized_candidate = normalize_path(candidate);
300    let normalized_workspace = normalize_path(workspace_root);
301
302    if !normalized_candidate.starts_with(&normalized_workspace) {
303        bail!("Path '{}' escapes workspace '{}'", candidate.display(), workspace_root.display());
304    }
305
306    Ok(normalized_candidate)
307}
308
309/// Ensure a candidate path is inside the workspace root, resolving symlinks
310/// component by component.
311///
312/// This is the strict, filesystem-aware tier of workspace containment. On top
313/// of the lexical check performed by [`ensure_path_within_workspace`], it
314/// walks each component of the candidate below the workspace root and:
315///
316/// - canonicalizes every existing component and verifies the resolved path
317///   still starts with the canonical workspace root (catches symlinks that
318///   point outside the workspace);
319/// - tolerates nonexistent tail components (paths about to be created);
320/// - rejects traversal through a file component (e.g. `file.txt/child`).
321///
322/// The candidate must already be lexically inside `workspace_root` (both
323/// sides are normalized before comparison).
324///
325/// Returns the normalized candidate path on success.
326pub async fn ensure_path_within_workspace_resolved(candidate: &Path, workspace_root: &Path) -> Result<PathBuf> {
327    let normalized_root = normalize_path(workspace_root);
328    let normalized_candidate = normalize_path(candidate);
329
330    let canonical_root = match canonicalize_async(&normalized_root).await {
331        Ok(resolved) => resolved,
332        Err(error) => {
333            warn!(
334                path = %normalized_root.display(),
335                %error,
336                "Failed to canonicalize workspace root; falling back to provided path"
337            );
338            normalized_root.clone()
339        }
340    };
341
342    if normalized_root == normalized_candidate {
343        return Ok(normalized_candidate);
344    }
345
346    let relative = normalized_candidate
347        .strip_prefix(&normalized_root)
348        .map_err(|_error| anyhow!("path '{}' escapes the workspace root", candidate.display()))?
349        .to_path_buf();
350
351    let mut prefix = normalized_root.clone();
352    let mut components = relative.components().peekable();
353
354    while let Some(component) = components.next() {
355        prefix.push(component.as_os_str());
356
357        let metadata = match tokio::fs::symlink_metadata(&prefix).await {
358            Ok(metadata) => metadata,
359            Err(error) => {
360                if error.kind() == std::io::ErrorKind::NotFound {
361                    break;
362                }
363                return Err(error).with_context(|| format!("failed to inspect path component '{}'", prefix.display()));
364            }
365        };
366
367        let resolved = canonicalize_async(&prefix)
368            .await
369            .with_context(|| format!("failed to canonicalize path component '{}'", prefix.display()))?;
370
371        if metadata.file_type().is_symlink() {
372            if !resolved.starts_with(&canonical_root) {
373                return Err(anyhow!(
374                    "path '{}' escapes the workspace root via symlink '{}'",
375                    candidate.display(),
376                    prefix.display()
377                ));
378            }
379        } else {
380            if !resolved.starts_with(&canonical_root) {
381                return Err(anyhow!(
382                    "path '{}' escapes the workspace root via component '{}'",
383                    candidate.display(),
384                    prefix.display()
385                ));
386            }
387
388            if metadata.is_file() && components.peek().is_some() {
389                return Err(anyhow!(
390                    "path '{}' traverses through file component '{}'",
391                    candidate.display(),
392                    prefix.display()
393                ));
394            }
395        }
396    }
397
398    Ok(normalized_candidate)
399}
400
401#[cfg(test)]
402mod tests {
403    use super::super::canonicalize::canonicalize;
404    use super::*;
405    use std::path::{Path, PathBuf};
406
407    #[test]
408    fn workspace_relative_display_uses_workspace_relative_paths() {
409        let workspace = Path::new("/workspace");
410        let path = Path::new("/workspace/src/main.rs");
411
412        assert_eq!(workspace_relative_display(workspace, path), "src/main.rs");
413    }
414
415    #[test]
416    fn workspace_relative_display_preserves_external_paths() {
417        let workspace = Path::new("/workspace");
418        let path = Path::new("/tmp/external.txt");
419
420        assert_eq!(workspace_relative_display(workspace, path), "/tmp/external.txt");
421    }
422
423    #[test]
424    fn workspace_relative_display_handles_canonical_workspace_paths() {
425        let workspace = tempfile::tempdir().unwrap();
426        let workspace_path = canonicalize(workspace.path()).unwrap();
427        let path = workspace_path.join("docs").join("guide.md");
428
429        assert_eq!(workspace_relative_display(workspace.path(), &path), "docs/guide.md");
430    }
431
432    #[cfg(unix)]
433    #[test]
434    fn workspace_relative_display_resolves_workspace_aliases() {
435        use std::os::unix::fs::symlink;
436
437        let temp = tempfile::tempdir().unwrap();
438        let workspace = temp.path().join("workspace");
439        std::fs::create_dir_all(workspace.join("src")).unwrap();
440        let alias = temp.path().join("workspace-alias");
441        symlink(&workspace, &alias).unwrap();
442
443        let path = alias.join("src").join("main.rs");
444        assert_eq!(workspace_relative_display(&workspace, &path), "src/main.rs");
445    }
446
447    #[cfg(unix)]
448    #[test]
449    fn workspace_relative_display_preserves_symlink_escape() {
450        use std::os::unix::fs::symlink;
451
452        let temp = tempfile::tempdir().unwrap();
453        let workspace = temp.path().join("workspace");
454        let outside = temp.path().join("outside");
455        std::fs::create_dir_all(&workspace).unwrap();
456        std::fs::create_dir_all(&outside).unwrap();
457        std::fs::write(outside.join("secret.txt"), "secret").unwrap();
458        symlink(&outside, workspace.join("linked-outside")).unwrap();
459
460        let path = workspace.join("linked-outside").join("secret.txt");
461        assert_eq!(workspace_relative_display(&workspace, &path), path.to_string_lossy());
462    }
463
464    #[cfg(unix)]
465    #[test]
466    fn workspace_relative_display_fails_closed_for_dangling_symlink() {
467        use std::os::unix::fs::symlink;
468
469        let temp = tempfile::tempdir().unwrap();
470        let workspace = temp.path().join("workspace");
471        std::fs::create_dir_all(&workspace).unwrap();
472        let path = workspace.join("dangling-link");
473        symlink(temp.path().join("missing-target"), &path).unwrap();
474
475        assert_eq!(workspace_relative_display(&workspace, &path), path.to_string_lossy());
476    }
477
478    #[test]
479    fn path_display_matches_one_shot_render_for_many_entries() {
480        let workspace = tempfile::tempdir().unwrap();
481        let root = canonicalize(workspace.path()).unwrap();
482        std::fs::create_dir_all(root.join("src/nested")).unwrap();
483        std::fs::write(root.join("src/nested/lib.rs"), "x").unwrap();
484
485        let display = WorkspacePathDisplay::new(workspace.path());
486        for relative in ["src", "src/nested", "src/nested/lib.rs"] {
487            let path = root.join(relative);
488            assert_eq!(display.render(&path), workspace_relative_display(workspace.path(), &path));
489            assert_eq!(display.render(&path), relative);
490        }
491    }
492
493    #[test]
494    fn path_display_entry_fast_path_agrees_with_render() {
495        let workspace = tempfile::tempdir().unwrap();
496        let root = canonicalize(workspace.path()).unwrap();
497        std::fs::create_dir_all(root.join("src")).unwrap();
498        std::fs::write(root.join("src/lib.rs"), "x").unwrap();
499
500        let display = WorkspacePathDisplay::new(workspace.path());
501        let file_type = std::fs::symlink_metadata(root.join("src/lib.rs")).unwrap().file_type();
502
503        assert_eq!(display.render_entry(&root.join("src/lib.rs"), Some(file_type)), "src/lib.rs");
504        // Identity: the fast path must never disagree with the authoritative form.
505        assert_eq!(
506            display.render_entry(&root.join("src/lib.rs"), Some(file_type)),
507            display.render(&root.join("src/lib.rs"))
508        );
509        // Unknown type must fall back rather than guess.
510        assert_eq!(display.render_entry(&root.join("src/lib.rs"), None), display.render(&root.join("src/lib.rs")));
511    }
512
513    #[test]
514    fn path_display_entry_fast_path_rejects_dot_components() {
515        let workspace = tempfile::tempdir().unwrap();
516        let root = canonicalize(workspace.path()).unwrap();
517        std::fs::create_dir_all(root.join("src")).unwrap();
518
519        let display = WorkspacePathDisplay::new(workspace.path());
520        let dotted = root.join("src").join("..").join("src").join("lib.rs");
521        let file_type = std::fs::symlink_metadata(root.join("src")).unwrap().file_type();
522
523        // Stripping is lexical but `render` resolves, so the fast path must
524        // defer instead of emitting `src/../src/lib.rs`.
525        assert_eq!(display.render_entry(&dotted, Some(file_type)), display.render(&dotted));
526    }
527
528    #[cfg(unix)]
529    #[test]
530    fn path_display_entry_fast_path_defers_for_symlinks() {
531        use std::os::unix::fs::symlink;
532
533        let temp = tempfile::tempdir().unwrap();
534        let workspace = temp.path().join("workspace");
535        let outside = temp.path().join("outside");
536        std::fs::create_dir_all(&workspace).unwrap();
537        std::fs::create_dir_all(&outside).unwrap();
538        std::fs::write(outside.join("secret.txt"), "secret").unwrap();
539        let link = workspace.join("linked-outside");
540        symlink(&outside, &link).unwrap();
541
542        let display = WorkspacePathDisplay::new(&workspace);
543        let link_type = std::fs::symlink_metadata(&link).unwrap().file_type();
544        assert!(link_type.is_symlink());
545
546        // A symlinked leaf can leave the workspace, so it must not take the
547        // lexical fast path even though it lexically starts with the root.
548        assert_eq!(display.render_entry(&link, Some(link_type)), display.render(&link));
549        assert_eq!(display.render_entry(&link, Some(link_type)), link.to_string_lossy());
550    }
551
552    #[test]
553    fn ensures_path_within_workspace_accepts_nested_path() {
554        let workspace = Path::new("/tmp/project");
555        let candidate = Path::new("/tmp/project/src/../src/lib.rs");
556        let normalized = ensure_path_within_workspace(candidate, workspace).unwrap();
557        assert_eq!(normalized, PathBuf::from("/tmp/project/src/lib.rs"));
558    }
559
560    #[test]
561    fn ensures_path_within_workspace_rejects_escape() {
562        let workspace = Path::new("/tmp/project");
563        let candidate = Path::new("/tmp/project/../../etc/passwd");
564        assert!(ensure_path_within_workspace(candidate, workspace).is_err());
565    }
566
567    #[tokio::test]
568    async fn resolved_check_accepts_nested_existing_path() {
569        let workspace = tempfile::tempdir().unwrap();
570        let root = canonicalize(workspace.path()).unwrap();
571        let nested = root.join("src");
572        tokio::fs::create_dir_all(&nested).await.unwrap();
573        let file = nested.join("lib.rs");
574        tokio::fs::write(&file, b"test").await.unwrap();
575
576        let result = ensure_path_within_workspace_resolved(&file, &root).await;
577        assert_eq!(result.unwrap(), file);
578    }
579
580    #[tokio::test]
581    async fn resolved_check_accepts_missing_tail_components() {
582        let workspace = tempfile::tempdir().unwrap();
583        let root = canonicalize(workspace.path()).unwrap();
584        let missing = root.join("new_dir/new_file.txt");
585
586        let result = ensure_path_within_workspace_resolved(&missing, &root).await;
587        assert_eq!(result.unwrap(), missing);
588    }
589
590    #[tokio::test]
591    async fn resolved_check_rejects_lexical_escape() {
592        let workspace = tempfile::tempdir().unwrap();
593        let root = canonicalize(workspace.path()).unwrap();
594        let escape = root.join("../outside.txt");
595
596        assert!(ensure_path_within_workspace_resolved(&escape, &root).await.is_err());
597    }
598
599    #[cfg(unix)]
600    #[tokio::test]
601    async fn resolved_check_rejects_symlink_escape() {
602        let workspace = tempfile::tempdir().unwrap();
603        let outside = tempfile::tempdir().unwrap();
604        let root = canonicalize(workspace.path()).unwrap();
605        let outside_dir = canonicalize(outside.path()).unwrap();
606
607        let link = root.join("escape");
608        tokio::fs::symlink(&outside_dir, &link).await.unwrap();
609
610        let candidate = link.join("secret.txt");
611        assert!(ensure_path_within_workspace_resolved(&candidate, &root).await.is_err());
612    }
613
614    #[cfg(unix)]
615    #[tokio::test]
616    async fn resolved_check_accepts_symlink_within_workspace() {
617        let workspace = tempfile::tempdir().unwrap();
618        let root = canonicalize(workspace.path()).unwrap();
619        let target = root.join("real");
620        tokio::fs::create_dir_all(&target).await.unwrap();
621        let link = root.join("alias");
622        tokio::fs::symlink(&target, &link).await.unwrap();
623
624        let candidate = link.join("file.txt");
625        assert!(ensure_path_within_workspace_resolved(&candidate, &root).await.is_ok());
626    }
627
628    #[tokio::test]
629    async fn resolved_check_rejects_traversal_through_file() {
630        let workspace = tempfile::tempdir().unwrap();
631        let root = canonicalize(workspace.path()).unwrap();
632        let file = root.join("data.txt");
633        tokio::fs::write(&file, b"test").await.unwrap();
634
635        let candidate = file.join("child.txt");
636        assert!(ensure_path_within_workspace_resolved(&candidate, &root).await.is_err());
637    }
638
639    #[test]
640    fn symlink_ancestor_validation_rejects_traversal_and_missing_tail() {
641        let temp = tempfile::tempdir().expect("tempdir");
642        let missing = temp.path().join("absent").join("child");
643        assert!(validate_no_escaping_symlink_ancestors(&missing, true).is_ok());
644        assert!(validate_no_escaping_symlink_ancestors(&missing, false).is_err());
645        assert!(validate_no_escaping_symlink_ancestors(Path::new("a/../b"), false).is_err());
646        assert!(validate_no_escaping_symlink_ancestors(temp.path(), false).is_ok());
647    }
648
649    #[cfg(unix)]
650    #[test]
651    fn symlink_ancestor_validation_rejects_escaping_links_but_allows_in_parent_aliases() {
652        use std::os::unix::fs::symlink;
653
654        let temp = tempfile::tempdir().expect("tempdir");
655        let outside = tempfile::tempdir().expect("outside tempdir");
656        let escape = temp.path().join("escape");
657        symlink(outside.path(), &escape).expect("symlink");
658        let error = validate_no_escaping_symlink_ancestors(&escape.join("child"), false)
659            .expect_err("escaping ancestor must fail");
660        assert!(error.to_string().contains("escapes its containing directory"), "{error}");
661        let leaf_error = validate_no_escaping_symlink_ancestors(&escape, false).expect_err("symlinked leaf must fail");
662        assert!(leaf_error.to_string().contains("refusing symlink path"), "{leaf_error}");
663
664        let inner = temp.path().join("inner");
665        std::fs::create_dir(&inner).expect("mkdir");
666        symlink(&inner, temp.path().join("alias")).expect("symlink");
667        // An in-parent alias as a non-leaf ancestor is tolerated (this is
668        // how platform aliases like /tmp survive validation); as a leaf it
669        // still fails closed.
670        assert!(validate_no_escaping_symlink_ancestors(&temp.path().join("alias").join("child"), true).is_ok());
671        assert!(validate_no_escaping_symlink_ancestors(&temp.path().join("alias"), false).is_err());
672    }
673}