Skip to main content

vtcode_core/tools/file_ops/
path_policy.rs

1use super::FileOpsTool;
2use crate::tools::jaro_winkler_similarity;
3use anyhow::{Context, Result, anyhow};
4use ignore::DirEntry;
5use std::cmp::Ordering;
6use std::future::Future;
7use std::path::{Path, PathBuf};
8use vtcode_commons::walk::{build_default_walker, is_excluded_dir};
9use vtcode_commons::workspace_relative_display;
10
11const MAX_PATH_SUGGESTIONS: usize = 3;
12const MAX_PATH_SUGGESTION_SCAN: usize = 20_000;
13const MIN_PATH_SUGGESTION_SCORE: f32 = 0.78;
14
15#[derive(Clone, Copy, Debug, PartialEq, Eq)]
16pub(super) enum PathSuggestionKind {
17    Any,
18    File,
19}
20
21impl PathSuggestionKind {
22    fn matches(self, entry: &DirEntry) -> bool {
23        match self {
24            Self::Any => true,
25            Self::File => entry.file_type().is_some_and(|ft| ft.is_file()),
26        }
27    }
28}
29
30fn normalize_path_for_suggestion(path: &str) -> String {
31    path.replace('\\', "/").trim_matches('/').to_ascii_lowercase()
32}
33
34fn suggestion_basename(path: &str) -> &str {
35    path.rsplit('/').next().unwrap_or(path)
36}
37
38fn suggestion_score(requested_path: &str, candidate_path: &str) -> f32 {
39    let requested_name = suggestion_basename(requested_path);
40    let candidate_name = suggestion_basename(candidate_path);
41
42    let full_score = jaro_winkler_similarity(requested_path, candidate_path);
43    let name_score = if requested_name.is_empty() || candidate_name.is_empty() {
44        0.0
45    } else {
46        jaro_winkler_similarity(requested_name, candidate_name)
47    };
48
49    let mut score = full_score.max(name_score * 0.85);
50
51    if !requested_name.is_empty() && requested_name == candidate_name {
52        score += 0.20;
53    } else if !requested_name.is_empty()
54        && (candidate_name.contains(requested_name) || requested_name.contains(candidate_name))
55    {
56        score += 0.06;
57    }
58
59    if candidate_path.ends_with(requested_path) || requested_path.ends_with(candidate_path) {
60        score += 0.12;
61    }
62
63    score.min(1.0)
64}
65
66impl FileOpsTool {
67    pub(super) fn canonical_workspace_root(&self) -> &PathBuf {
68        &self.canonical_workspace_root
69    }
70
71    pub(super) fn workspace_relative_display(&self, path: &Path) -> String {
72        workspace_relative_display(&self.workspace_root, path)
73    }
74
75    fn absolute_candidate(&self, path: &Path) -> PathBuf {
76        if path.is_absolute() {
77            path.to_path_buf()
78        } else {
79            self.workspace_root.join(path)
80        }
81    }
82
83    pub(super) async fn normalize_and_validate_user_path(&self, path: &str) -> Result<PathBuf> {
84        self.normalize_and_validate_candidate(Path::new(path), path).await
85    }
86
87    pub(super) async fn normalize_and_validate_candidate(
88        &self,
89        path: &Path,
90        original_display: &str,
91    ) -> Result<PathBuf> {
92        use crate::utils::path::normalize_path;
93        let absolute = self.absolute_candidate(path);
94        let normalized = normalize_path(&absolute);
95        let normalized_root = normalize_path(&self.workspace_root);
96        let canonical_root = normalize_path(self.canonical_workspace_root());
97
98        let lexical_in_workspace = normalized.starts_with(&normalized_root);
99        let lexical_in_canonical_workspace = normalized.starts_with(&canonical_root);
100
101        // Callers may retain a path through an equivalent filesystem alias
102        // (for example `/var` versus macOS's `/private/var`) after the
103        // registry has canonicalized its workspace root. Resolve that alias
104        // before applying containment; an outside path still fails the
105        // canonical-root check below.
106        if !lexical_in_workspace && !lexical_in_canonical_workspace {
107            let canonical = self.canonicalize_allow_missing(&normalized).await?;
108            if !canonical.starts_with(&canonical_root) {
109                return Err(anyhow!("Error: Path '{original_display}' resolves outside the workspace."));
110            }
111            return Ok(canonical);
112        }
113
114        // Symlink-aware containment: validate every path component so a
115        // symlink committed inside the workspace cannot resolve outside it.
116        // The lexical tier above gives precise error messages for plain
117        // traversal; this tier closes the escape-by-symlink case.
118        if lexical_in_workspace {
119            vtcode_commons::paths::ensure_path_within_workspace_resolved(&normalized, &self.workspace_root)
120                .await
121                .with_context(|| format!("Error: Path '{original_display}' is not accessible inside the workspace"))?;
122        }
123
124        let canonical = self.canonicalize_allow_missing(&normalized).await?;
125        if !canonical.starts_with(&canonical_root) {
126            return Err(anyhow!("Error: Path '{original_display}' resolves outside the workspace."));
127        }
128        Ok(canonical)
129    }
130
131    fn canonicalize_allow_missing<'a>(&'a self, normalized: &'a Path) -> impl Future<Output = Result<PathBuf>> + 'a {
132        crate::utils::path::canonicalize_allow_missing(normalized)
133    }
134
135    pub(super) async fn resolve_file_path(&self, path: &str) -> Result<Vec<PathBuf>> {
136        let mut paths = Vec::new();
137        let requested = PathBuf::from(path);
138
139        if requested.is_absolute() {
140            paths.push(requested);
141            return Ok(paths);
142        }
143
144        // Try exact path first
145        paths.push(self.workspace_root.join(path));
146
147        // If it's just a filename, try common directories that exist in most projects
148        if !path.contains('/') && !path.contains('\\') {
149            // Generic source directories found in most projects
150            paths.push(self.workspace_root.join("src").join(path));
151            paths.push(self.workspace_root.join("lib").join(path));
152            paths.push(self.workspace_root.join("bin").join(path));
153            paths.push(self.workspace_root.join("app").join(path));
154            paths.push(self.workspace_root.join("source").join(path));
155            paths.push(self.workspace_root.join("sources").join(path));
156            paths.push(self.workspace_root.join("include").join(path));
157            paths.push(self.workspace_root.join("docs").join(path));
158            paths.push(self.workspace_root.join("doc").join(path));
159            paths.push(self.workspace_root.join("examples").join(path));
160            paths.push(self.workspace_root.join("example").join(path));
161            paths.push(self.workspace_root.join("tests").join(path));
162            paths.push(self.workspace_root.join("test").join(path));
163        }
164
165        // Try case-insensitive variants for filenames
166        if !path.contains('/') && !path.contains('\\') {
167            let path_lower = path.to_lowercase();
168            if let Ok(mut entries) = tokio::fs::read_dir(&self.workspace_root).await {
169                loop {
170                    let entry: tokio::fs::DirEntry = match entries.next_entry().await {
171                        Ok(Some(e)) => e,
172                        _ => break,
173                    };
174                    let name = entry.file_name();
175                    if let Ok(name_str) = name.into_string() {
176                        if name_str.to_lowercase() == path_lower {
177                            paths.push(entry.path());
178                        }
179                    }
180                }
181            }
182        }
183
184        Ok(paths)
185    }
186
187    pub(super) async fn missing_path_suggestion_suffix(
188        &self,
189        requested_path: &str,
190        kind: PathSuggestionKind,
191    ) -> String {
192        let suggestions = self.suggest_workspace_paths(requested_path, kind).await;
193        if suggestions.is_empty() {
194            String::new()
195        } else {
196            format!(" Did you mean: {}?", suggestions.join(", "))
197        }
198    }
199
200    async fn suggest_workspace_paths(&self, requested_path: &str, kind: PathSuggestionKind) -> Vec<String> {
201        let requested_path = normalize_path_for_suggestion(requested_path);
202        if requested_path.is_empty() || requested_path == "." {
203            return Vec::new();
204        }
205
206        let mut scored_paths = Vec::with_capacity(MAX_PATH_SUGGESTIONS * 2);
207        let mut scanned = 0usize;
208
209        let walker = build_default_walker(&self.workspace_root)
210            .filter_entry(|entry| !is_excluded_dir(entry))
211            .build();
212
213        for entry in walker {
214            let Ok(entry) = entry else {
215                continue;
216            };
217            if entry.depth() == 0 || !kind.matches(&entry) {
218                continue;
219            }
220
221            scanned += 1;
222            if scanned > MAX_PATH_SUGGESTION_SCAN {
223                break;
224            }
225
226            let display_path = self.workspace_relative_display(entry.path());
227            let normalized_candidate = normalize_path_for_suggestion(&display_path);
228            if normalized_candidate.is_empty() || normalized_candidate == requested_path {
229                continue;
230            }
231
232            let score = suggestion_score(&requested_path, &normalized_candidate);
233            if score < MIN_PATH_SUGGESTION_SCORE {
234                continue;
235            }
236
237            scored_paths.push((score, display_path));
238        }
239
240        scored_paths.sort_by(|left, right| {
241            right
242                .0
243                .partial_cmp(&left.0)
244                .unwrap_or(Ordering::Equal)
245                .then_with(|| left.1.cmp(&right.1))
246        });
247        scored_paths.dedup_by(|left, right| left.1 == right.1);
248
249        scored_paths
250            .into_iter()
251            .take(MAX_PATH_SUGGESTIONS)
252            .map(|(_, path)| path)
253            .collect()
254    }
255
256    /// Public helper to normalize and validate a user-provided path against the workspace root.
257    /// Inline-delegating wrapper that returns the inner future directly to
258    /// avoid an extra coroutine state machine (audit section 16).
259    pub fn normalize_user_path<'a>(&'a self, path: &'a str) -> impl Future<Output = Result<PathBuf>> + 'a {
260        self.normalize_and_validate_user_path(path)
261    }
262}
263
264#[cfg(test)]
265mod tests {
266    use super::super::FileOpsTool;
267    use crate::tools::grep_file::GrepSearchManager;
268    use std::fs;
269    use std::path::PathBuf;
270    use std::sync::Arc;
271    use tempfile::TempDir;
272
273    fn make_tool(workspace: &TempDir) -> FileOpsTool {
274        let grep_manager = Arc::new(GrepSearchManager::new(workspace.path().to_path_buf()));
275        FileOpsTool::new(workspace.path().to_path_buf(), grep_manager)
276    }
277
278    fn canonical_root(workspace: &TempDir) -> PathBuf {
279        dunce::canonicalize(workspace.path()).expect("canonicalize workspace root")
280    }
281
282    #[cfg(unix)]
283    #[tokio::test]
284    async fn symlink_inside_workspace_pointing_outside_is_rejected() {
285        let temp_dir = TempDir::new().expect("workspace tempdir");
286        let outside = TempDir::new().expect("outside tempdir");
287        fs::create_dir_all(temp_dir.path().join("sub")).expect("create sub");
288        fs::write(outside.path().join("secret.txt"), "top secret").expect("write outside");
289
290        std::os::unix::fs::symlink(outside.path(), temp_dir.path().join("sub/link")).expect("create symlink");
291
292        let file_ops = make_tool(&temp_dir);
293
294        let result = file_ops.normalize_user_path("sub/link/secret.txt").await;
295        assert!(result.is_err(), "symlink escape must be rejected, got {result:?}");
296    }
297
298    #[tokio::test]
299    async fn plain_paths_inside_workspace_still_validate() {
300        let temp_dir = TempDir::new().expect("workspace tempdir");
301        fs::create_dir_all(temp_dir.path().join("sub")).expect("create sub");
302        fs::write(temp_dir.path().join("sub/file.txt"), "ok").expect("write file");
303        let root = canonical_root(&temp_dir);
304
305        let file_ops = make_tool(&temp_dir);
306
307        let resolved = file_ops
308            .normalize_user_path("sub/file.txt")
309            .await
310            .expect("existing in-workspace path must validate");
311        assert!(resolved.starts_with(&root));
312
313        // Missing files inside the workspace remain allowed (create flows).
314        let created = file_ops
315            .normalize_user_path("sub/new-file.txt")
316            .await
317            .expect("missing in-workspace path must validate");
318        assert!(created.starts_with(&root));
319
320        // Plain traversal stays rejected.
321        assert!(file_ops.normalize_user_path("../outside.txt").await.is_err());
322    }
323
324    #[cfg(unix)]
325    #[tokio::test]
326    async fn canonical_alias_of_workspace_root_is_accepted() {
327        let temp_dir = TempDir::new().expect("workspace tempdir");
328        let canonical_root = canonical_root(&temp_dir);
329        if canonical_root == temp_dir.path() {
330            // No alias on this platform layout; nothing to test.
331            return;
332        }
333        fs::create_dir_all(temp_dir.path().join("sub")).expect("create sub");
334        fs::write(temp_dir.path().join("sub/file.txt"), "ok").expect("write file");
335
336        let file_ops = make_tool(&temp_dir);
337
338        // An absolute path written through the canonical (resolved) alias of
339        // the workspace root must be accepted: it resolves inside the
340        // workspace even though it does not start with the raw root.
341        let alias_path = canonical_root.join("sub/file.txt");
342        let resolved = file_ops
343            .normalize_user_path(&alias_path.to_string_lossy())
344            .await
345            .expect("canonical alias path must validate");
346        assert!(resolved.starts_with(&canonical_root));
347
348        // A missing file through the alias stays allowed (create flows).
349        let created = file_ops
350            .normalize_user_path(&canonical_root.join("sub/new.txt").to_string_lossy())
351            .await
352            .expect("missing path via alias must validate");
353        assert!(created.starts_with(&canonical_root));
354    }
355
356    #[cfg(unix)]
357    #[tokio::test]
358    async fn canonical_alias_escape_is_still_rejected() {
359        let temp_dir = TempDir::new().expect("workspace tempdir");
360        let outside = TempDir::new().expect("outside tempdir");
361        let canonical_root = canonical_root(&temp_dir);
362        if canonical_root == temp_dir.path() {
363            return;
364        }
365
366        let file_ops = make_tool(&temp_dir);
367        let outside_path = outside.path().join("secret.txt");
368        fs::write(&outside_path, "top secret").expect("write outside");
369
370        let result = file_ops.normalize_user_path(&outside_path.to_string_lossy()).await;
371        assert!(result.is_err(), "outside canonical path must be rejected");
372    }
373}