Skip to main content

vtcode_commons/paths/
canonicalize.rs

1use anyhow::{Result, anyhow};
2use std::path::{Path, PathBuf};
3use tracing::warn;
4
5/// Canonicalize a filesystem path without the Windows `\\?\` verbatim prefix.
6///
7/// `std::fs::canonicalize` returns verbatim (`\\?\`) paths on Windows, which
8/// break path comparisons, round-tripping through user config, and some APIs
9/// that reject verbatim paths. `dunce::canonicalize` strips the prefix when it
10/// is safe to do so and is a perfect drop-in on non-Windows platforms.
11///
12/// This is the single canonical entry point for path canonicalization in the
13/// workspace. All call sites should use this (or `canonicalize_workspace`) rather
14/// than `std::fs::canonicalize` / `Path::canonicalize`, which are banned by
15/// `clippy.toml`'s `disallowed-methods`.
16pub fn canonicalize(path: impl AsRef<Path>) -> std::io::Result<PathBuf> {
17    dunce::canonicalize(path)
18}
19
20/// Async canonicalize via `dunce`, using `spawn_blocking` to avoid blocking the
21/// runtime. Equivalent to `tokio::fs::canonicalize` but without the Windows
22/// `\\?\` verbatim prefix. Use this in async contexts instead of the sync
23/// [`canonicalize`] when the path may require I/O.
24pub async fn canonicalize_async(path: impl AsRef<Path> + Send) -> std::io::Result<PathBuf> {
25    let path = path.as_ref().to_path_buf();
26    match tokio::task::spawn_blocking(move || dunce::canonicalize(&path)).await {
27        Ok(inner) => inner,
28        Err(join_error) => Err(std::io::Error::other(join_error)),
29    }
30}
31
32/// Canonicalize a path with fallback to the original path if canonicalization fails.
33pub fn canonicalize_workspace(workspace_root: &Path) -> PathBuf {
34    canonicalize(workspace_root).unwrap_or_else(|error| {
35        warn!(
36            path = %workspace_root.display(),
37            %error,
38            "Failed to canonicalize workspace root; falling back to provided path"
39        );
40        workspace_root.to_path_buf()
41    })
42}
43
44/// Canonicalize a path, walking up to find the nearest existing ancestor for new files.
45///
46/// This function handles paths to files that may not yet exist by finding the
47/// nearest existing parent directory, canonicalizing that, and then appending
48/// the remaining path components.
49///
50/// # Security
51/// This function is critical for security. It prevents symlink escapes by:
52/// 1. Finding the nearest existing ancestor directory
53/// 2. Canonicalizing that directory (resolves symlinks)
54/// 3. Appending the remaining path components
55///
56/// # Arguments
57/// * `normalized` - A normalized path (output from `normalize_path`)
58///
59/// # Returns
60/// The canonical path, or the normalized path if no parent exists. Existing
61/// symlinks must resolve, including symlinks whose targets do not yet exist.
62pub async fn canonicalize_allow_missing(normalized: &Path) -> Result<PathBuf> {
63    // Inspect the link itself: try_exists follows symlinks and reports a
64    // dangling link as absent, which would leave it unresolved in the result.
65    let mut current = normalized.to_path_buf();
66    loop {
67        match tokio::fs::symlink_metadata(&current).await {
68            Ok(_) => {
69                let canonical_parent = canonicalize_async(&current)
70                    .await
71                    .map_err(|e| anyhow!("Failed to resolve canonical path for '{}': {}", current.display(), e))?;
72
73                let remainder = normalized.strip_prefix(&current).unwrap_or_else(|_| Path::new(""));
74
75                // Return the canonical parent + remaining components
76                return if remainder.as_os_str().is_empty() {
77                    Ok(canonical_parent)
78                } else {
79                    Ok(canonical_parent.join(remainder))
80                };
81            }
82            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
83            Err(error) => return Err(anyhow!("Failed to inspect path '{}': {}", current.display(), error)),
84        }
85        let Some(parent) = current.parent() else { break };
86        current = parent.to_path_buf();
87    }
88
89    // No existing parent found, return normalized path as-is
90    Ok(normalized.to_path_buf())
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96
97    #[tokio::test]
98    async fn test_canonicalize_existing_file() {
99        // Create a temporary directory and file
100        let temp_dir = std::env::temp_dir();
101        let test_file = temp_dir.join("vtcode_test_existing.txt");
102        tokio::fs::write(&test_file, b"test").await.unwrap();
103
104        let canonical = canonicalize_allow_missing(&test_file).await.unwrap();
105
106        // Should get the canonical path
107        assert!(canonical.is_absolute());
108        assert!(canonical.exists());
109
110        // Cleanup
111        drop(tokio::fs::remove_file(&test_file).await);
112    }
113
114    #[tokio::test]
115    async fn test_canonicalize_missing_file() {
116        // Use a path that doesn't exist but has an existing parent
117        let temp_dir = std::env::temp_dir();
118        let missing_file = temp_dir.join("vtcode_test_missing_dir/missing_file.txt");
119
120        let canonical = canonicalize_allow_missing(&missing_file).await.unwrap();
121
122        // Should get canonical parent + missing components
123        assert!(canonical.is_absolute());
124        assert!(canonical.to_string_lossy().contains("missing_file.txt"));
125    }
126
127    #[tokio::test]
128    async fn test_canonicalize_deeply_missing_path() {
129        // Use a path with multiple missing parent directories
130        let temp_dir = std::env::temp_dir();
131        let deep_missing = temp_dir.join("vtcode_test_a/b/c/d/file.txt");
132
133        let canonical = canonicalize_allow_missing(&deep_missing).await.unwrap();
134
135        // Should get canonical temp_dir + missing components
136        assert!(canonical.is_absolute());
137        assert!(canonical.to_string_lossy().contains("vtcode_test_a"));
138    }
139
140    #[tokio::test]
141    async fn test_canonicalize_missing_file_with_existing_parent() {
142        // Create a parent directory
143        let temp_dir = std::env::temp_dir();
144        let test_dir = temp_dir.join("vtcode_test_parent");
145        tokio::fs::create_dir_all(&test_dir).await.unwrap();
146
147        let missing_file = test_dir.join("missing.txt");
148        let canonical = canonicalize_allow_missing(&missing_file).await.unwrap();
149
150        // Should get canonical parent + missing filename
151        assert!(canonical.is_absolute());
152        assert!(canonical.to_string_lossy().ends_with("missing.txt"));
153
154        // Cleanup
155        drop(tokio::fs::remove_dir(&test_dir).await);
156    }
157
158    #[cfg(unix)]
159    #[tokio::test]
160    async fn dangling_symlink_is_not_treated_as_a_missing_component() {
161        let workspace = tempfile::tempdir().unwrap();
162        let outside = tempfile::tempdir().unwrap();
163        let link = workspace.path().join("link");
164        std::os::unix::fs::symlink(outside.path().join("missing"), &link).unwrap();
165        assert!(canonicalize_allow_missing(&link).await.is_err());
166        assert!(canonicalize_allow_missing(&link.join("child")).await.is_err());
167        assert!(canonicalize_allow_missing(&workspace.path().join("new/child")).await.is_ok());
168        let valid_link = workspace.path().join("valid");
169        std::os::unix::fs::symlink(outside.path(), &valid_link).unwrap();
170        let resolved = canonicalize_allow_missing(&valid_link.join("new/child")).await.unwrap();
171        assert_eq!(resolved, canonicalize_async(outside.path()).await.unwrap().join("new/child"));
172    }
173}