Skip to main content

vtcode_commons/
paths.rs

1#![expect(
2    unused_results,
3    reason = "Path normalization intentionally ignores the boolean returned by pop after a validated component check."
4)]
5
6use anyhow::{Context, Result, anyhow, bail};
7use std::path::{Component, Path, PathBuf};
8use tracing::warn;
9
10/// Normalize a path by resolving `.` and `..` components lexically.
11pub fn normalize_path(path: &Path) -> PathBuf {
12    let mut normalized = PathBuf::new();
13    for component in path.components() {
14        match component {
15            Component::ParentDir => {
16                normalized.pop();
17            }
18            Component::CurDir => {}
19            Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
20            Component::RootDir => normalized.push(component.as_os_str()),
21            Component::Normal(part) => normalized.push(part),
22        }
23    }
24    normalized
25}
26
27/// Expand a leading `~` or `~/` to the user's home directory. The function is
28/// intentionally forgiving: paths that don't start with `~` are returned as-is,
29/// and when the home directory cannot be determined the original path is
30/// preserved so callers can surface a downstream error rather than panicking.
31///
32/// This is the canonical implementation used by the tool registry and the
33/// sandbox runtime; both call sites previously carried near-identical copies.
34pub fn expand_tilde(path: &str) -> PathBuf {
35    if path == "~" {
36        return dirs::home_dir().unwrap_or_else(|| PathBuf::from(path));
37    }
38    if let Some(rest) = path.strip_prefix("~/")
39        && let Some(home) = dirs::home_dir()
40    {
41        return home.join(rest);
42    }
43    PathBuf::from(path)
44}
45
46/// Canonicalize a filesystem path without the Windows `\\?\` verbatim prefix.
47///
48/// `std::fs::canonicalize` returns verbatim (`\\?\`) paths on Windows, which
49/// break path comparisons, round-tripping through user config, and some APIs
50/// that reject verbatim paths. `dunce::canonicalize` strips the prefix when it
51/// is safe to do so and is a perfect drop-in on non-Windows platforms.
52///
53/// This is the single canonical entry point for path canonicalization in the
54/// workspace. All call sites should use this (or `canonicalize_workspace`) rather
55/// than `std::fs::canonicalize` / `Path::canonicalize`, which are banned by
56/// `clippy.toml`'s `disallowed-methods`.
57pub fn canonicalize(path: impl AsRef<Path>) -> std::io::Result<PathBuf> {
58    dunce::canonicalize(path)
59}
60
61/// Async canonicalize via `dunce`, using `spawn_blocking` to avoid blocking the
62/// runtime. Equivalent to `tokio::fs::canonicalize` but without the Windows
63/// `\\?\` verbatim prefix. Use this in async contexts instead of the sync
64/// [`canonicalize`] when the path may require I/O.
65pub async fn canonicalize_async(path: impl AsRef<Path> + Send) -> std::io::Result<PathBuf> {
66    let path = path.as_ref().to_path_buf();
67    match tokio::task::spawn_blocking(move || dunce::canonicalize(&path)).await {
68        Ok(inner) => inner,
69        Err(join_error) => Err(std::io::Error::other(join_error)),
70    }
71}
72
73/// Canonicalize a path with fallback to the original path if canonicalization fails.
74pub fn canonicalize_workspace(workspace_root: &Path) -> PathBuf {
75    canonicalize(workspace_root).unwrap_or_else(|error| {
76        warn!(
77            path = %workspace_root.display(),
78            %error,
79            "Failed to canonicalize workspace root; falling back to provided path"
80        );
81        workspace_root.to_path_buf()
82    })
83}
84
85/// Render a path relative to the workspace when it belongs to that workspace.
86///
87/// Paths outside the workspace remain absolute (or otherwise unchanged) so a
88/// diagnostic never hides that it refers to an external location. The
89/// filesystem-resolved comparison handles workspace aliases and symlink
90/// escapes without requiring callers to canonicalize their candidate first.
91pub fn workspace_relative_display(workspace_root: &Path, path: &Path) -> String {
92    let candidate = if path.is_absolute() {
93        path.to_path_buf()
94    } else {
95        workspace_root.join(path)
96    };
97
98    if let Ok(canonical_workspace) = canonicalize(workspace_root) {
99        // A successful canonicalization is authoritative: a path that
100        // resolves outside the workspace must not fall back to lexical
101        // containment through an escaping symlink.
102        match canonicalize_for_display(&candidate) {
103            DisplayResolution::Resolved(canonical_candidate) => {
104                return canonical_candidate
105                    .strip_prefix(&canonical_workspace)
106                    .map(|relative| relative.to_string_lossy().into_owned())
107                    .unwrap_or_else(|_| path.to_string_lossy().into_owned());
108            }
109            DisplayResolution::Unresolved => return path.to_string_lossy().into_owned(),
110        }
111    }
112
113    // If the candidate cannot be resolved yet (for example, a new file with a
114    // missing tail), retain the cheap lexical behavior for paths that are
115    // clearly under the workspace.
116    let normalized_candidate = normalize_path(&candidate);
117    let normalized_workspace = normalize_path(workspace_root);
118    if let Ok(relative) = normalized_candidate.strip_prefix(normalized_workspace) {
119        return relative.to_string_lossy().into_owned();
120    }
121    path.to_string_lossy().into_owned()
122}
123
124enum DisplayResolution {
125    Resolved(PathBuf),
126    Unresolved,
127}
128
129fn canonicalize_for_display(path: &Path) -> DisplayResolution {
130    if let Ok(canonical) = canonicalize(path) {
131        return DisplayResolution::Resolved(canonical);
132    }
133
134    let mut missing_tail = Vec::new();
135    let mut existing_prefix = path;
136    loop {
137        match std::fs::symlink_metadata(existing_prefix) {
138            Ok(_) => break,
139            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
140                let Some(file_name) = existing_prefix.file_name() else {
141                    return DisplayResolution::Unresolved;
142                };
143                let Some(parent) = existing_prefix.parent() else {
144                    return DisplayResolution::Unresolved;
145                };
146                missing_tail.push(PathBuf::from(file_name));
147                existing_prefix = parent;
148            }
149            Err(_) => return DisplayResolution::Unresolved,
150        }
151    }
152
153    let Ok(mut canonical) = canonicalize(existing_prefix) else {
154        // This includes dangling symlinks and inaccessible existing paths.
155        // Do not fall back to lexical containment for either case.
156        return DisplayResolution::Unresolved;
157    };
158    for component in missing_tail.into_iter().rev() {
159        canonical.push(component);
160    }
161    DisplayResolution::Resolved(canonical)
162}
163
164/// Resolve a path relative to a workspace root and ensure it stays within it.
165pub fn resolve_workspace_path(workspace_root: &Path, user_path: &Path) -> Result<PathBuf> {
166    let candidate = if user_path.is_absolute() {
167        user_path.to_path_buf()
168    } else {
169        workspace_root.join(user_path)
170    };
171
172    let canonical =
173        canonicalize(&candidate).with_context(|| format!("Failed to canonicalize path {}", candidate.display()))?;
174
175    let workspace_canonical = canonicalize(workspace_root)
176        .with_context(|| format!("Failed to canonicalize workspace root {}", workspace_root.display()))?;
177
178    if !canonical.starts_with(&workspace_canonical) {
179        return Err(anyhow!("Path {} escapes workspace root {}", canonical.display(), workspace_canonical.display()));
180    }
181
182    Ok(canonical)
183}
184
185/// Return a canonicalised absolute path that is guaranteed to reside inside the
186/// provided `workspace_root`.  If the path is outside the workspace an error is
187/// returned.
188pub fn secure_path(workspace_root: &Path, user_path: &Path) -> Result<PathBuf> {
189    // Resolve relative paths against the workspace root.
190    resolve_workspace_path(workspace_root, user_path)
191}
192
193/// Ensure a candidate path is inside the workspace root after lexical
194/// normalization.
195///
196/// This is the cheap, filesystem-free tier of workspace containment: it
197/// resolves `.`/`..` components lexically but does not follow symlinks. Use
198/// [`ensure_path_within_workspace_resolved`] when the candidate may traverse
199/// symlinks that point outside the workspace.
200///
201/// Returns the normalized candidate path on success.
202pub fn ensure_path_within_workspace(candidate: &Path, workspace_root: &Path) -> Result<PathBuf> {
203    let normalized_candidate = normalize_path(candidate);
204    let normalized_workspace = normalize_path(workspace_root);
205
206    if !normalized_candidate.starts_with(&normalized_workspace) {
207        bail!("Path '{}' escapes workspace '{}'", candidate.display(), workspace_root.display());
208    }
209
210    Ok(normalized_candidate)
211}
212
213/// Ensure a candidate path is inside the workspace root, resolving symlinks
214/// component by component.
215///
216/// This is the strict, filesystem-aware tier of workspace containment. On top
217/// of the lexical check performed by [`ensure_path_within_workspace`], it
218/// walks each component of the candidate below the workspace root and:
219///
220/// - canonicalizes every existing component and verifies the resolved path
221///   still starts with the canonical workspace root (catches symlinks that
222///   point outside the workspace);
223/// - tolerates nonexistent tail components (paths about to be created);
224/// - rejects traversal through a file component (e.g. `file.txt/child`).
225///
226/// The candidate must already be lexically inside `workspace_root` (both
227/// sides are normalized before comparison).
228///
229/// Returns the normalized candidate path on success.
230pub async fn ensure_path_within_workspace_resolved(candidate: &Path, workspace_root: &Path) -> Result<PathBuf> {
231    let normalized_root = normalize_path(workspace_root);
232    let normalized_candidate = normalize_path(candidate);
233
234    let canonical_root = match canonicalize_async(&normalized_root).await {
235        Ok(resolved) => resolved,
236        Err(error) => {
237            warn!(
238                path = %normalized_root.display(),
239                %error,
240                "Failed to canonicalize workspace root; falling back to provided path"
241            );
242            normalized_root.clone()
243        }
244    };
245
246    if normalized_root == normalized_candidate {
247        return Ok(normalized_candidate);
248    }
249
250    let relative = normalized_candidate
251        .strip_prefix(&normalized_root)
252        .map_err(|_error| anyhow!("path '{}' escapes the workspace root", candidate.display()))?
253        .to_path_buf();
254
255    let mut prefix = normalized_root.clone();
256    let mut components = relative.components().peekable();
257
258    while let Some(component) = components.next() {
259        prefix.push(component.as_os_str());
260
261        let metadata = match tokio::fs::symlink_metadata(&prefix).await {
262            Ok(metadata) => metadata,
263            Err(error) => {
264                if error.kind() == std::io::ErrorKind::NotFound {
265                    break;
266                }
267                return Err(error).with_context(|| format!("failed to inspect path component '{}'", prefix.display()));
268            }
269        };
270
271        let resolved = canonicalize_async(&prefix)
272            .await
273            .with_context(|| format!("failed to canonicalize path component '{}'", prefix.display()))?;
274
275        if metadata.file_type().is_symlink() {
276            if !resolved.starts_with(&canonical_root) {
277                return Err(anyhow!(
278                    "path '{}' escapes the workspace root via symlink '{}'",
279                    candidate.display(),
280                    prefix.display()
281                ));
282            }
283        } else {
284            if !resolved.starts_with(&canonical_root) {
285                return Err(anyhow!(
286                    "path '{}' escapes the workspace root via component '{}'",
287                    candidate.display(),
288                    prefix.display()
289                ));
290            }
291
292            if metadata.is_file() && components.peek().is_some() {
293                return Err(anyhow!(
294                    "path '{}' traverses through file component '{}'",
295                    candidate.display(),
296                    prefix.display()
297                ));
298            }
299        }
300    }
301
302    Ok(normalized_candidate)
303}
304
305/// Normalize identifiers to ASCII alphanumerics with lowercase output.
306pub fn normalize_ascii_identifier(value: &str) -> String {
307    let mut normalized = String::with_capacity(value.len());
308    for ch in value.chars() {
309        if ch.is_ascii_alphanumeric() {
310            normalized.push(ch.to_ascii_lowercase());
311        }
312    }
313    normalized
314}
315
316/// Check if a path string is a safe relative path (no traversal, no absolute).
317pub fn is_safe_relative_path(path: &str) -> bool {
318    let path = path.trim();
319    if path.is_empty() {
320        return false;
321    }
322
323    // Check for path traversal attempts
324    if path.contains("..") {
325        return false;
326    }
327
328    // Block absolute paths for security
329    if path.starts_with('/') || path.contains(':') {
330        return false;
331    }
332
333    true
334}
335
336/// Validates that a path is safe to use.
337/// Preventing traversal, absolute system paths, and dangerous characters.
338///
339/// Optimization: Uses early returns and byte-level checks for common patterns
340pub fn validate_path_safety(path: &str) -> Result<()> {
341    // Optimization: Fast path for empty or very short paths
342    if path.is_empty() {
343        return Ok(());
344    }
345
346    // Reject path traversal attempts
347    // Optimization: Use contains on bytes for simple patterns
348    if path.contains("..") {
349        bail!("Path traversal attempt detected ('..')");
350    }
351
352    // Additional traversal patterns
353    if path.contains("~/../") || path.contains("/.../") {
354        bail!("Advanced path traversal detected");
355    }
356
357    // Optimization: Only check Unix critical paths if path starts with '/'
358    if path.starts_with('/') {
359        // Reject absolute paths outside workspace
360        // Note: We can't strictly block all absolute paths as the agent might need to access
361        // explicitly allowed directories, but we can block obvious system critical paths.
362        static UNIX_CRITICAL: &[&str] = &["/etc", "/usr", "/bin", "/sbin", "/var", "/boot", "/root", "/dev"];
363        for prefix in UNIX_CRITICAL {
364            let is_var_temp_exception = *prefix == "/var"
365                && (path.starts_with("/var/folders/")
366                    || path == "/var/folders"
367                    || path.starts_with("/var/tmp/")
368                    || path == "/var/tmp");
369
370            if !is_var_temp_exception && matches_critical_prefix(path, prefix) {
371                bail!("Access to system directory denied: {prefix}");
372            }
373        }
374    }
375
376    // Windows critical paths
377    #[cfg(windows)]
378    {
379        let path_lower = path.to_lowercase();
380        static WIN_CRITICAL: &[&str] = &["c:\\windows", "c:\\program files", "c:\\system32"];
381        for prefix in WIN_CRITICAL {
382            if path_lower.starts_with(prefix) {
383                bail!("Access to Windows system directory denied");
384            }
385        }
386    }
387
388    // Reject dangerous shell characters in paths (including null byte)
389    // Optimization: Check bytes directly for faster character detection
390    static DANGEROUS_CHARS: &[u8] = b"$`|;&\n\r><\0";
391    for &c in path.as_bytes() {
392        if DANGEROUS_CHARS.contains(&c) {
393            bail!("Path contains dangerous shell characters");
394        }
395    }
396
397    Ok(())
398}
399
400fn matches_critical_prefix(path: &str, prefix: &str) -> bool {
401    path == prefix || path.strip_prefix(prefix).is_some_and(|rest| rest.starts_with('/'))
402}
403
404/// Extract the filename from a path, with fallback to the full path.
405pub fn file_name_from_path(path: &str) -> String {
406    Path::new(path)
407        .file_name()
408        .and_then(|name| name.to_str())
409        .map(|s| s.to_string())
410        .unwrap_or_else(|| path.to_string())
411}
412
413/// Canonicalize a path, walking up to find the nearest existing ancestor for new files.
414///
415/// This function handles paths to files that may not yet exist by finding the
416/// nearest existing parent directory, canonicalizing that, and then appending
417/// the remaining path components.
418///
419/// # Security
420/// This function is critical for security. It prevents symlink escapes by:
421/// 1. Finding the nearest existing ancestor directory
422/// 2. Canonicalizing that directory (resolves symlinks)
423/// 3. Appending the remaining path components
424///
425/// # Arguments
426/// * `normalized` - A normalized path (output from `normalize_path`)
427///
428/// # Returns
429/// The canonical path, or the normalized path if no parent exists
430pub async fn canonicalize_allow_missing(normalized: &Path) -> Result<PathBuf> {
431    // If the path exists, canonicalize it directly
432    if tokio::fs::try_exists(normalized).await.unwrap_or(false) {
433        return canonicalize_async(normalized)
434            .await
435            .map_err(|e| anyhow!("Failed to resolve canonical path for '{}': {}", normalized.display(), e));
436    }
437
438    // Walk up the directory tree to find the nearest existing ancestor
439    let mut current = normalized.to_path_buf();
440    while let Some(parent) = current.parent() {
441        if tokio::fs::try_exists(parent).await.unwrap_or(false) {
442            // Canonicalize the existing parent
443            let canonical_parent = canonicalize_async(parent)
444                .await
445                .map_err(|e| anyhow!("Failed to resolve canonical path for '{}': {}", parent.display(), e))?;
446
447            // Get the remaining path components
448            let remainder = normalized.strip_prefix(parent).unwrap_or_else(|_| Path::new(""));
449
450            // Return the canonical parent + remaining components
451            return if remainder.as_os_str().is_empty() {
452                Ok(canonical_parent)
453            } else {
454                Ok(canonical_parent.join(remainder))
455            };
456        }
457        current = parent.to_path_buf();
458    }
459
460    // No existing parent found, return normalized path as-is
461    Ok(normalized.to_path_buf())
462}
463
464/// Provides the root directories an application uses to store data.
465pub trait WorkspacePaths: Send + Sync {
466    /// Absolute path to the application's workspace root.
467    fn workspace_root(&self) -> &Path;
468
469    /// Returns the directory where configuration files should be stored.
470    fn config_dir(&self) -> PathBuf;
471
472    /// Returns an optional cache directory for transient data.
473    fn cache_dir(&self) -> Option<PathBuf> {
474        None
475    }
476
477    /// Returns an optional directory for telemetry or log artifacts.
478    fn telemetry_dir(&self) -> Option<PathBuf> {
479        None
480    }
481
482    /// Determine the [`PathScope`] for a given path based on workspace directories.
483    ///
484    /// Returns the most specific scope matching the path:
485    /// - `Workspace` if under `workspace_root()`
486    /// - `Config` if under `config_dir()`
487    /// - `Cache` if under `cache_dir()`
488    /// - `Telemetry` if under `telemetry_dir()`
489    /// - Falls back to `Cache` if no match
490    fn scope_for_path(&self, path: &Path) -> PathScope {
491        if path.starts_with(self.workspace_root()) {
492            return PathScope::Workspace;
493        }
494
495        let config_dir = self.config_dir();
496        if path.starts_with(&config_dir) {
497            return PathScope::Config;
498        }
499
500        if let Some(cache_dir) = self.cache_dir()
501            && path.starts_with(&cache_dir)
502        {
503            return PathScope::Cache;
504        }
505
506        if let Some(telemetry_dir) = self.telemetry_dir()
507            && path.starts_with(&telemetry_dir)
508        {
509            return PathScope::Telemetry;
510        }
511
512        PathScope::Cache
513    }
514}
515
516/// Helper trait that adds path resolution helpers on top of [`WorkspacePaths`].
517pub trait PathResolver: WorkspacePaths {
518    /// Resolve a path relative to the workspace root.
519    fn resolve<P>(&self, relative: P) -> PathBuf
520    where
521        P: AsRef<Path>,
522    {
523        self.workspace_root().join(relative)
524    }
525
526    /// Resolve a path within the configuration directory.
527    fn resolve_config<P>(&self, relative: P) -> PathBuf
528    where
529        P: AsRef<Path>,
530    {
531        self.config_dir().join(relative)
532    }
533}
534
535impl<T> PathResolver for T where T: WorkspacePaths + ?Sized {}
536
537/// Enumeration describing the conceptual scope of a file path.
538#[derive(Debug, Clone, Copy, PartialEq, Eq)]
539pub enum PathScope {
540    Workspace,
541    Config,
542    Cache,
543    Telemetry,
544}
545
546impl PathScope {
547    /// Returns a human-readable description used in error messages.
548    pub fn description(self) -> &'static str {
549        match self {
550            Self::Workspace => "workspace",
551            Self::Config => "configuration",
552            Self::Cache => "cache",
553            Self::Telemetry => "telemetry",
554        }
555    }
556}
557
558// ============================================================================
559// Extension Traits (Pattern 3: Extension Traits)
560// ============================================================================
561
562/// Extension trait that adds path normalization and safety methods to `Path`.
563///
564/// Delegates to the existing free functions in this module, providing a more
565/// ergonomic call-site syntax:
566///
567/// ```rust
568/// use vtcode_commons::paths::PathExt;
569/// use std::path::Path;
570///
571/// let normalized = Path::new("/tmp/project/src/../src/lib.rs").normalize();
572/// ```
573pub trait PathExt {
574    /// Normalize a path by resolving `.` and `..` components lexically.
575    fn normalize(&self) -> PathBuf;
576
577    /// Canonicalize with fallback to the original path if canonicalization fails.
578    fn canonicalize_or_self(&self) -> PathBuf;
579
580    /// Extract the filename from a path as a `String`, with fallback to the
581    /// full path when no filename component exists.
582    ///
583    /// Unlike [`Path::file_name`] which returns `Option<&OsStr>`, this method
584    /// always returns a `String` and falls back gracefully.
585    fn file_name_str(&self) -> String;
586}
587
588impl PathExt for Path {
589    fn normalize(&self) -> PathBuf {
590        normalize_path(self)
591    }
592
593    fn canonicalize_or_self(&self) -> PathBuf {
594        canonicalize_workspace(self)
595    }
596
597    fn file_name_str(&self) -> String {
598        self.file_name()
599            .and_then(|name| name.to_str())
600            .map(|s| s.to_string())
601            .unwrap_or_else(|| self.to_string_lossy().into_owned())
602    }
603}
604
605/// Extension trait that adds path-related methods to `str`.
606///
607/// Provides ergonomic access to tilde expansion and path safety checks:
608///
609/// ```rust
610/// use vtcode_commons::paths::StrPathExt;
611///
612/// let expanded = "~/projects/vtcode".expand_tilde();
613/// assert!(StrPathExt::is_safe_path("src/main.rs"));
614/// ```
615pub trait StrPathExt {
616    /// Expand a leading `~` or `~/` to the user's home directory.
617    fn expand_tilde(&self) -> PathBuf;
618
619    /// Check if this path string is a safe relative path (no traversal, no absolute).
620    fn is_safe_path(&self) -> bool;
621
622    /// Validate that this path is safe to use (no traversal, no dangerous characters).
623    fn validate_safety(&self) -> Result<()>;
624
625    /// Extract the filename from this path string.
626    fn file_name_str(&self) -> String;
627}
628
629impl StrPathExt for str {
630    fn expand_tilde(&self) -> PathBuf {
631        expand_tilde(self)
632    }
633
634    fn is_safe_path(&self) -> bool {
635        is_safe_relative_path(self)
636    }
637
638    fn validate_safety(&self) -> Result<()> {
639        validate_path_safety(self)
640    }
641
642    fn file_name_str(&self) -> String {
643        file_name_from_path(self)
644    }
645}
646
647#[cfg(test)]
648mod tests {
649    use super::*;
650    use std::path::{Path, PathBuf};
651
652    struct StaticPaths {
653        root: PathBuf,
654        config: PathBuf,
655    }
656
657    impl WorkspacePaths for StaticPaths {
658        fn workspace_root(&self) -> &Path {
659            &self.root
660        }
661
662        fn config_dir(&self) -> PathBuf {
663            self.config.clone()
664        }
665
666        fn cache_dir(&self) -> Option<PathBuf> {
667            Some(self.root.join("cache"))
668        }
669    }
670
671    #[test]
672    fn resolves_relative_paths() {
673        let paths = StaticPaths {
674            root: PathBuf::from("/tmp/project"),
675            config: PathBuf::from("/tmp/project/config"),
676        };
677
678        assert_eq!(PathResolver::resolve(&paths, "subdir/file.txt"), PathBuf::from("/tmp/project/subdir/file.txt"));
679        assert_eq!(
680            PathResolver::resolve_config(&paths, "settings.toml"),
681            PathBuf::from("/tmp/project/config/settings.toml")
682        );
683        assert_eq!(paths.cache_dir(), Some(PathBuf::from("/tmp/project/cache")));
684    }
685
686    #[test]
687    fn workspace_relative_display_uses_workspace_relative_paths() {
688        let workspace = Path::new("/workspace");
689        let path = Path::new("/workspace/src/main.rs");
690
691        assert_eq!(workspace_relative_display(workspace, path), "src/main.rs");
692    }
693
694    #[test]
695    fn workspace_relative_display_preserves_external_paths() {
696        let workspace = Path::new("/workspace");
697        let path = Path::new("/tmp/external.txt");
698
699        assert_eq!(workspace_relative_display(workspace, path), "/tmp/external.txt");
700    }
701
702    #[test]
703    fn workspace_relative_display_handles_canonical_workspace_paths() {
704        let workspace = tempfile::tempdir().unwrap();
705        let workspace_path = canonicalize(workspace.path()).unwrap();
706        let path = workspace_path.join("docs").join("guide.md");
707
708        assert_eq!(workspace_relative_display(workspace.path(), &path), "docs/guide.md");
709    }
710
711    #[cfg(unix)]
712    #[test]
713    fn workspace_relative_display_resolves_workspace_aliases() {
714        use std::os::unix::fs::symlink;
715
716        let temp = tempfile::tempdir().unwrap();
717        let workspace = temp.path().join("workspace");
718        std::fs::create_dir_all(workspace.join("src")).unwrap();
719        let alias = temp.path().join("workspace-alias");
720        symlink(&workspace, &alias).unwrap();
721
722        let path = alias.join("src").join("main.rs");
723        assert_eq!(workspace_relative_display(&workspace, &path), "src/main.rs");
724    }
725
726    #[cfg(unix)]
727    #[test]
728    fn workspace_relative_display_preserves_symlink_escape() {
729        use std::os::unix::fs::symlink;
730
731        let temp = tempfile::tempdir().unwrap();
732        let workspace = temp.path().join("workspace");
733        let outside = temp.path().join("outside");
734        std::fs::create_dir_all(&workspace).unwrap();
735        std::fs::create_dir_all(&outside).unwrap();
736        std::fs::write(outside.join("secret.txt"), "secret").unwrap();
737        symlink(&outside, workspace.join("linked-outside")).unwrap();
738
739        let path = workspace.join("linked-outside").join("secret.txt");
740        assert_eq!(workspace_relative_display(&workspace, &path), path.to_string_lossy());
741    }
742
743    #[cfg(unix)]
744    #[test]
745    fn workspace_relative_display_fails_closed_for_dangling_symlink() {
746        use std::os::unix::fs::symlink;
747
748        let temp = tempfile::tempdir().unwrap();
749        let workspace = temp.path().join("workspace");
750        std::fs::create_dir_all(&workspace).unwrap();
751        let path = workspace.join("dangling-link");
752        symlink(temp.path().join("missing-target"), &path).unwrap();
753
754        assert_eq!(workspace_relative_display(&workspace, &path), path.to_string_lossy());
755    }
756
757    #[test]
758    fn ensures_path_within_workspace_accepts_nested_path() {
759        let workspace = Path::new("/tmp/project");
760        let candidate = Path::new("/tmp/project/src/../src/lib.rs");
761        let normalized = ensure_path_within_workspace(candidate, workspace).unwrap();
762        assert_eq!(normalized, PathBuf::from("/tmp/project/src/lib.rs"));
763    }
764
765    #[test]
766    fn ensures_path_within_workspace_rejects_escape() {
767        let workspace = Path::new("/tmp/project");
768        let candidate = Path::new("/tmp/project/../../etc/passwd");
769        assert!(ensure_path_within_workspace(candidate, workspace).is_err());
770    }
771
772    #[tokio::test]
773    async fn resolved_check_accepts_nested_existing_path() {
774        let workspace = tempfile::tempdir().unwrap();
775        let root = canonicalize(workspace.path()).unwrap();
776        let nested = root.join("src");
777        tokio::fs::create_dir_all(&nested).await.unwrap();
778        let file = nested.join("lib.rs");
779        tokio::fs::write(&file, b"test").await.unwrap();
780
781        let result = ensure_path_within_workspace_resolved(&file, &root).await;
782        assert_eq!(result.unwrap(), file);
783    }
784
785    #[tokio::test]
786    async fn resolved_check_accepts_missing_tail_components() {
787        let workspace = tempfile::tempdir().unwrap();
788        let root = canonicalize(workspace.path()).unwrap();
789        let missing = root.join("new_dir/new_file.txt");
790
791        let result = ensure_path_within_workspace_resolved(&missing, &root).await;
792        assert_eq!(result.unwrap(), missing);
793    }
794
795    #[tokio::test]
796    async fn resolved_check_rejects_lexical_escape() {
797        let workspace = tempfile::tempdir().unwrap();
798        let root = canonicalize(workspace.path()).unwrap();
799        let escape = root.join("../outside.txt");
800
801        assert!(ensure_path_within_workspace_resolved(&escape, &root).await.is_err());
802    }
803
804    #[cfg(unix)]
805    #[tokio::test]
806    async fn resolved_check_rejects_symlink_escape() {
807        let workspace = tempfile::tempdir().unwrap();
808        let outside = tempfile::tempdir().unwrap();
809        let root = canonicalize(workspace.path()).unwrap();
810        let outside_dir = canonicalize(outside.path()).unwrap();
811
812        let link = root.join("escape");
813        tokio::fs::symlink(&outside_dir, &link).await.unwrap();
814
815        let candidate = link.join("secret.txt");
816        assert!(ensure_path_within_workspace_resolved(&candidate, &root).await.is_err());
817    }
818
819    #[cfg(unix)]
820    #[tokio::test]
821    async fn resolved_check_accepts_symlink_within_workspace() {
822        let workspace = tempfile::tempdir().unwrap();
823        let root = canonicalize(workspace.path()).unwrap();
824        let target = root.join("real");
825        tokio::fs::create_dir_all(&target).await.unwrap();
826        let link = root.join("alias");
827        tokio::fs::symlink(&target, &link).await.unwrap();
828
829        let candidate = link.join("file.txt");
830        assert!(ensure_path_within_workspace_resolved(&candidate, &root).await.is_ok());
831    }
832
833    #[tokio::test]
834    async fn resolved_check_rejects_traversal_through_file() {
835        let workspace = tempfile::tempdir().unwrap();
836        let root = canonicalize(workspace.path()).unwrap();
837        let file = root.join("data.txt");
838        tokio::fs::write(&file, b"test").await.unwrap();
839
840        let candidate = file.join("child.txt");
841        assert!(ensure_path_within_workspace_resolved(&candidate, &root).await.is_err());
842    }
843
844    #[tokio::test]
845    async fn test_canonicalize_existing_file() {
846        // Create a temporary directory and file
847        let temp_dir = std::env::temp_dir();
848        let test_file = temp_dir.join("vtcode_test_existing.txt");
849        tokio::fs::write(&test_file, b"test").await.unwrap();
850
851        let canonical = canonicalize_allow_missing(&test_file).await.unwrap();
852
853        // Should get the canonical path
854        assert!(canonical.is_absolute());
855        assert!(canonical.exists());
856
857        // Cleanup
858        tokio::fs::remove_file(&test_file).await.ok();
859    }
860
861    #[tokio::test]
862    async fn test_canonicalize_missing_file() {
863        // Use a path that doesn't exist but has an existing parent
864        let temp_dir = std::env::temp_dir();
865        let missing_file = temp_dir.join("vtcode_test_missing_dir/missing_file.txt");
866
867        let canonical = canonicalize_allow_missing(&missing_file).await.unwrap();
868
869        // Should get canonical parent + missing components
870        assert!(canonical.is_absolute());
871        assert!(canonical.to_string_lossy().contains("missing_file.txt"));
872    }
873
874    #[tokio::test]
875    async fn test_canonicalize_deeply_missing_path() {
876        // Use a path with multiple missing parent directories
877        let temp_dir = std::env::temp_dir();
878        let deep_missing = temp_dir.join("vtcode_test_a/b/c/d/file.txt");
879
880        let canonical = canonicalize_allow_missing(&deep_missing).await.unwrap();
881
882        // Should get canonical temp_dir + missing components
883        assert!(canonical.is_absolute());
884        assert!(canonical.to_string_lossy().contains("vtcode_test_a"));
885    }
886
887    #[tokio::test]
888    async fn test_canonicalize_missing_file_with_existing_parent() {
889        // Create a parent directory
890        let temp_dir = std::env::temp_dir();
891        let test_dir = temp_dir.join("vtcode_test_parent");
892        tokio::fs::create_dir_all(&test_dir).await.unwrap();
893
894        let missing_file = test_dir.join("missing.txt");
895        let canonical = canonicalize_allow_missing(&missing_file).await.unwrap();
896
897        // Should get canonical parent + missing filename
898        assert!(canonical.is_absolute());
899        assert!(canonical.to_string_lossy().ends_with("missing.txt"));
900
901        // Cleanup
902        tokio::fs::remove_dir(&test_dir).await.ok();
903    }
904
905    #[test]
906    fn expand_tilde_passes_through_absolute_paths() {
907        let absolute = "/etc/hosts";
908        assert_eq!(expand_tilde(absolute), PathBuf::from(absolute));
909    }
910
911    #[test]
912    fn expand_tilde_passes_through_relative_paths() {
913        let relative = "src/main.rs";
914        assert_eq!(expand_tilde(relative), PathBuf::from(relative));
915    }
916
917    #[test]
918    fn expand_tilde_resolves_bare_tilde_to_home() {
919        if let Some(home) = dirs::home_dir() {
920            assert_eq!(expand_tilde("~"), home);
921        }
922    }
923
924    #[test]
925    fn expand_tilde_resolves_tilde_slash_prefix() {
926        if let Some(home) = dirs::home_dir() {
927            let resolved = expand_tilde("~/projects/vtcode");
928            assert_eq!(resolved, home.join("projects/vtcode"));
929        }
930    }
931}