vtcode_core/tools/file_ops/
path_policy.rs1use 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 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 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 paths.push(self.workspace_root.join(path));
146
147 if !path.contains('/') && !path.contains('\\') {
149 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 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 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 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 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 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 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 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}