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
10pub 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
27pub 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
46pub fn canonicalize(path: impl AsRef<Path>) -> std::io::Result<PathBuf> {
58 dunce::canonicalize(path)
59}
60
61pub 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
73pub 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
85pub fn resolve_workspace_path(workspace_root: &Path, user_path: &Path) -> Result<PathBuf> {
87 let candidate = if user_path.is_absolute() {
88 user_path.to_path_buf()
89 } else {
90 workspace_root.join(user_path)
91 };
92
93 let canonical =
94 canonicalize(&candidate).with_context(|| format!("Failed to canonicalize path {}", candidate.display()))?;
95
96 let workspace_canonical = canonicalize(workspace_root)
97 .with_context(|| format!("Failed to canonicalize workspace root {}", workspace_root.display()))?;
98
99 if !canonical.starts_with(&workspace_canonical) {
100 return Err(anyhow!("Path {} escapes workspace root {}", canonical.display(), workspace_canonical.display()));
101 }
102
103 Ok(canonical)
104}
105
106pub fn secure_path(workspace_root: &Path, user_path: &Path) -> Result<PathBuf> {
110 resolve_workspace_path(workspace_root, user_path)
112}
113
114pub fn ensure_path_within_workspace(candidate: &Path, workspace_root: &Path) -> Result<PathBuf> {
124 let normalized_candidate = normalize_path(candidate);
125 let normalized_workspace = normalize_path(workspace_root);
126
127 if !normalized_candidate.starts_with(&normalized_workspace) {
128 bail!("Path '{}' escapes workspace '{}'", candidate.display(), workspace_root.display());
129 }
130
131 Ok(normalized_candidate)
132}
133
134pub async fn ensure_path_within_workspace_resolved(candidate: &Path, workspace_root: &Path) -> Result<PathBuf> {
152 let normalized_root = normalize_path(workspace_root);
153 let normalized_candidate = normalize_path(candidate);
154
155 let canonical_root = match canonicalize_async(&normalized_root).await {
156 Ok(resolved) => resolved,
157 Err(error) => {
158 warn!(
159 path = %normalized_root.display(),
160 %error,
161 "Failed to canonicalize workspace root; falling back to provided path"
162 );
163 normalized_root.clone()
164 }
165 };
166
167 if normalized_root == normalized_candidate {
168 return Ok(normalized_candidate);
169 }
170
171 let relative = normalized_candidate
172 .strip_prefix(&normalized_root)
173 .map_err(|_error| anyhow!("path '{}' escapes the workspace root", candidate.display()))?
174 .to_path_buf();
175
176 let mut prefix = normalized_root.clone();
177 let mut components = relative.components().peekable();
178
179 while let Some(component) = components.next() {
180 prefix.push(component.as_os_str());
181
182 let metadata = match tokio::fs::symlink_metadata(&prefix).await {
183 Ok(metadata) => metadata,
184 Err(error) => {
185 if error.kind() == std::io::ErrorKind::NotFound {
186 break;
187 }
188 return Err(error).with_context(|| format!("failed to inspect path component '{}'", prefix.display()));
189 }
190 };
191
192 let resolved = canonicalize_async(&prefix)
193 .await
194 .with_context(|| format!("failed to canonicalize path component '{}'", prefix.display()))?;
195
196 if metadata.file_type().is_symlink() {
197 if !resolved.starts_with(&canonical_root) {
198 return Err(anyhow!(
199 "path '{}' escapes the workspace root via symlink '{}'",
200 candidate.display(),
201 prefix.display()
202 ));
203 }
204 } else {
205 if !resolved.starts_with(&canonical_root) {
206 return Err(anyhow!(
207 "path '{}' escapes the workspace root via component '{}'",
208 candidate.display(),
209 prefix.display()
210 ));
211 }
212
213 if metadata.is_file() && components.peek().is_some() {
214 return Err(anyhow!(
215 "path '{}' traverses through file component '{}'",
216 candidate.display(),
217 prefix.display()
218 ));
219 }
220 }
221 }
222
223 Ok(normalized_candidate)
224}
225
226pub fn normalize_ascii_identifier(value: &str) -> String {
228 let mut normalized = String::with_capacity(value.len());
229 for ch in value.chars() {
230 if ch.is_ascii_alphanumeric() {
231 normalized.push(ch.to_ascii_lowercase());
232 }
233 }
234 normalized
235}
236
237pub fn is_safe_relative_path(path: &str) -> bool {
239 let path = path.trim();
240 if path.is_empty() {
241 return false;
242 }
243
244 if path.contains("..") {
246 return false;
247 }
248
249 if path.starts_with('/') || path.contains(':') {
251 return false;
252 }
253
254 true
255}
256
257pub fn validate_path_safety(path: &str) -> Result<()> {
262 if path.is_empty() {
264 return Ok(());
265 }
266
267 if path.contains("..") {
270 bail!("Path traversal attempt detected ('..')");
271 }
272
273 if path.contains("~/../") || path.contains("/.../") {
275 bail!("Advanced path traversal detected");
276 }
277
278 if path.starts_with('/') {
280 static UNIX_CRITICAL: &[&str] = &["/etc", "/usr", "/bin", "/sbin", "/var", "/boot", "/root", "/dev"];
284 for prefix in UNIX_CRITICAL {
285 let is_var_temp_exception = *prefix == "/var"
286 && (path.starts_with("/var/folders/")
287 || path == "/var/folders"
288 || path.starts_with("/var/tmp/")
289 || path == "/var/tmp");
290
291 if !is_var_temp_exception && matches_critical_prefix(path, prefix) {
292 bail!("Access to system directory denied: {prefix}");
293 }
294 }
295 }
296
297 #[cfg(windows)]
299 {
300 let path_lower = path.to_lowercase();
301 static WIN_CRITICAL: &[&str] = &["c:\\windows", "c:\\program files", "c:\\system32"];
302 for prefix in WIN_CRITICAL {
303 if path_lower.starts_with(prefix) {
304 bail!("Access to Windows system directory denied");
305 }
306 }
307 }
308
309 static DANGEROUS_CHARS: &[u8] = b"$`|;&\n\r><\0";
312 for &c in path.as_bytes() {
313 if DANGEROUS_CHARS.contains(&c) {
314 bail!("Path contains dangerous shell characters");
315 }
316 }
317
318 Ok(())
319}
320
321fn matches_critical_prefix(path: &str, prefix: &str) -> bool {
322 path == prefix || path.strip_prefix(prefix).is_some_and(|rest| rest.starts_with('/'))
323}
324
325pub fn file_name_from_path(path: &str) -> String {
327 Path::new(path)
328 .file_name()
329 .and_then(|name| name.to_str())
330 .map(|s| s.to_string())
331 .unwrap_or_else(|| path.to_string())
332}
333
334pub async fn canonicalize_allow_missing(normalized: &Path) -> Result<PathBuf> {
352 if tokio::fs::try_exists(normalized).await.unwrap_or(false) {
354 return canonicalize_async(normalized)
355 .await
356 .map_err(|e| anyhow!("Failed to resolve canonical path for '{}': {}", normalized.display(), e));
357 }
358
359 let mut current = normalized.to_path_buf();
361 while let Some(parent) = current.parent() {
362 if tokio::fs::try_exists(parent).await.unwrap_or(false) {
363 let canonical_parent = canonicalize_async(parent)
365 .await
366 .map_err(|e| anyhow!("Failed to resolve canonical path for '{}': {}", parent.display(), e))?;
367
368 let remainder = normalized.strip_prefix(parent).unwrap_or_else(|_| Path::new(""));
370
371 return if remainder.as_os_str().is_empty() {
373 Ok(canonical_parent)
374 } else {
375 Ok(canonical_parent.join(remainder))
376 };
377 }
378 current = parent.to_path_buf();
379 }
380
381 Ok(normalized.to_path_buf())
383}
384
385pub trait WorkspacePaths: Send + Sync {
387 fn workspace_root(&self) -> &Path;
389
390 fn config_dir(&self) -> PathBuf;
392
393 fn cache_dir(&self) -> Option<PathBuf> {
395 None
396 }
397
398 fn telemetry_dir(&self) -> Option<PathBuf> {
400 None
401 }
402
403 fn scope_for_path(&self, path: &Path) -> PathScope {
412 if path.starts_with(self.workspace_root()) {
413 return PathScope::Workspace;
414 }
415
416 let config_dir = self.config_dir();
417 if path.starts_with(&config_dir) {
418 return PathScope::Config;
419 }
420
421 if let Some(cache_dir) = self.cache_dir()
422 && path.starts_with(&cache_dir)
423 {
424 return PathScope::Cache;
425 }
426
427 if let Some(telemetry_dir) = self.telemetry_dir()
428 && path.starts_with(&telemetry_dir)
429 {
430 return PathScope::Telemetry;
431 }
432
433 PathScope::Cache
434 }
435}
436
437pub trait PathResolver: WorkspacePaths {
439 fn resolve<P>(&self, relative: P) -> PathBuf
441 where
442 P: AsRef<Path>,
443 {
444 self.workspace_root().join(relative)
445 }
446
447 fn resolve_config<P>(&self, relative: P) -> PathBuf
449 where
450 P: AsRef<Path>,
451 {
452 self.config_dir().join(relative)
453 }
454}
455
456impl<T> PathResolver for T where T: WorkspacePaths + ?Sized {}
457
458#[derive(Debug, Clone, Copy, PartialEq, Eq)]
460pub enum PathScope {
461 Workspace,
462 Config,
463 Cache,
464 Telemetry,
465}
466
467impl PathScope {
468 pub fn description(self) -> &'static str {
470 match self {
471 Self::Workspace => "workspace",
472 Self::Config => "configuration",
473 Self::Cache => "cache",
474 Self::Telemetry => "telemetry",
475 }
476 }
477}
478
479pub trait PathExt {
495 fn normalize(&self) -> PathBuf;
497
498 fn canonicalize_or_self(&self) -> PathBuf;
500
501 fn file_name_str(&self) -> String;
507}
508
509impl PathExt for Path {
510 fn normalize(&self) -> PathBuf {
511 normalize_path(self)
512 }
513
514 fn canonicalize_or_self(&self) -> PathBuf {
515 canonicalize_workspace(self)
516 }
517
518 fn file_name_str(&self) -> String {
519 self.file_name()
520 .and_then(|name| name.to_str())
521 .map(|s| s.to_string())
522 .unwrap_or_else(|| self.to_string_lossy().into_owned())
523 }
524}
525
526pub trait StrPathExt {
537 fn expand_tilde(&self) -> PathBuf;
539
540 fn is_safe_path(&self) -> bool;
542
543 fn validate_safety(&self) -> Result<()>;
545
546 fn file_name_str(&self) -> String;
548}
549
550impl StrPathExt for str {
551 fn expand_tilde(&self) -> PathBuf {
552 expand_tilde(self)
553 }
554
555 fn is_safe_path(&self) -> bool {
556 is_safe_relative_path(self)
557 }
558
559 fn validate_safety(&self) -> Result<()> {
560 validate_path_safety(self)
561 }
562
563 fn file_name_str(&self) -> String {
564 file_name_from_path(self)
565 }
566}
567
568#[cfg(test)]
569mod tests {
570 use super::*;
571 use std::path::{Path, PathBuf};
572
573 struct StaticPaths {
574 root: PathBuf,
575 config: PathBuf,
576 }
577
578 impl WorkspacePaths for StaticPaths {
579 fn workspace_root(&self) -> &Path {
580 &self.root
581 }
582
583 fn config_dir(&self) -> PathBuf {
584 self.config.clone()
585 }
586
587 fn cache_dir(&self) -> Option<PathBuf> {
588 Some(self.root.join("cache"))
589 }
590 }
591
592 #[test]
593 fn resolves_relative_paths() {
594 let paths = StaticPaths {
595 root: PathBuf::from("/tmp/project"),
596 config: PathBuf::from("/tmp/project/config"),
597 };
598
599 assert_eq!(PathResolver::resolve(&paths, "subdir/file.txt"), PathBuf::from("/tmp/project/subdir/file.txt"));
600 assert_eq!(
601 PathResolver::resolve_config(&paths, "settings.toml"),
602 PathBuf::from("/tmp/project/config/settings.toml")
603 );
604 assert_eq!(paths.cache_dir(), Some(PathBuf::from("/tmp/project/cache")));
605 }
606
607 #[test]
608 fn ensures_path_within_workspace_accepts_nested_path() {
609 let workspace = Path::new("/tmp/project");
610 let candidate = Path::new("/tmp/project/src/../src/lib.rs");
611 let normalized = ensure_path_within_workspace(candidate, workspace).unwrap();
612 assert_eq!(normalized, PathBuf::from("/tmp/project/src/lib.rs"));
613 }
614
615 #[test]
616 fn ensures_path_within_workspace_rejects_escape() {
617 let workspace = Path::new("/tmp/project");
618 let candidate = Path::new("/tmp/project/../../etc/passwd");
619 assert!(ensure_path_within_workspace(candidate, workspace).is_err());
620 }
621
622 #[tokio::test]
623 async fn resolved_check_accepts_nested_existing_path() {
624 let workspace = tempfile::tempdir().unwrap();
625 let root = canonicalize(workspace.path()).unwrap();
626 let nested = root.join("src");
627 tokio::fs::create_dir_all(&nested).await.unwrap();
628 let file = nested.join("lib.rs");
629 tokio::fs::write(&file, b"test").await.unwrap();
630
631 let result = ensure_path_within_workspace_resolved(&file, &root).await;
632 assert_eq!(result.unwrap(), file);
633 }
634
635 #[tokio::test]
636 async fn resolved_check_accepts_missing_tail_components() {
637 let workspace = tempfile::tempdir().unwrap();
638 let root = canonicalize(workspace.path()).unwrap();
639 let missing = root.join("new_dir/new_file.txt");
640
641 let result = ensure_path_within_workspace_resolved(&missing, &root).await;
642 assert_eq!(result.unwrap(), missing);
643 }
644
645 #[tokio::test]
646 async fn resolved_check_rejects_lexical_escape() {
647 let workspace = tempfile::tempdir().unwrap();
648 let root = canonicalize(workspace.path()).unwrap();
649 let escape = root.join("../outside.txt");
650
651 assert!(ensure_path_within_workspace_resolved(&escape, &root).await.is_err());
652 }
653
654 #[cfg(unix)]
655 #[tokio::test]
656 async fn resolved_check_rejects_symlink_escape() {
657 let workspace = tempfile::tempdir().unwrap();
658 let outside = tempfile::tempdir().unwrap();
659 let root = canonicalize(workspace.path()).unwrap();
660 let outside_dir = canonicalize(outside.path()).unwrap();
661
662 let link = root.join("escape");
663 tokio::fs::symlink(&outside_dir, &link).await.unwrap();
664
665 let candidate = link.join("secret.txt");
666 assert!(ensure_path_within_workspace_resolved(&candidate, &root).await.is_err());
667 }
668
669 #[cfg(unix)]
670 #[tokio::test]
671 async fn resolved_check_accepts_symlink_within_workspace() {
672 let workspace = tempfile::tempdir().unwrap();
673 let root = canonicalize(workspace.path()).unwrap();
674 let target = root.join("real");
675 tokio::fs::create_dir_all(&target).await.unwrap();
676 let link = root.join("alias");
677 tokio::fs::symlink(&target, &link).await.unwrap();
678
679 let candidate = link.join("file.txt");
680 assert!(ensure_path_within_workspace_resolved(&candidate, &root).await.is_ok());
681 }
682
683 #[tokio::test]
684 async fn resolved_check_rejects_traversal_through_file() {
685 let workspace = tempfile::tempdir().unwrap();
686 let root = canonicalize(workspace.path()).unwrap();
687 let file = root.join("data.txt");
688 tokio::fs::write(&file, b"test").await.unwrap();
689
690 let candidate = file.join("child.txt");
691 assert!(ensure_path_within_workspace_resolved(&candidate, &root).await.is_err());
692 }
693
694 #[tokio::test]
695 async fn test_canonicalize_existing_file() {
696 let temp_dir = std::env::temp_dir();
698 let test_file = temp_dir.join("vtcode_test_existing.txt");
699 tokio::fs::write(&test_file, b"test").await.unwrap();
700
701 let canonical = canonicalize_allow_missing(&test_file).await.unwrap();
702
703 assert!(canonical.is_absolute());
705 assert!(canonical.exists());
706
707 tokio::fs::remove_file(&test_file).await.ok();
709 }
710
711 #[tokio::test]
712 async fn test_canonicalize_missing_file() {
713 let temp_dir = std::env::temp_dir();
715 let missing_file = temp_dir.join("vtcode_test_missing_dir/missing_file.txt");
716
717 let canonical = canonicalize_allow_missing(&missing_file).await.unwrap();
718
719 assert!(canonical.is_absolute());
721 assert!(canonical.to_string_lossy().contains("missing_file.txt"));
722 }
723
724 #[tokio::test]
725 async fn test_canonicalize_deeply_missing_path() {
726 let temp_dir = std::env::temp_dir();
728 let deep_missing = temp_dir.join("vtcode_test_a/b/c/d/file.txt");
729
730 let canonical = canonicalize_allow_missing(&deep_missing).await.unwrap();
731
732 assert!(canonical.is_absolute());
734 assert!(canonical.to_string_lossy().contains("vtcode_test_a"));
735 }
736
737 #[tokio::test]
738 async fn test_canonicalize_missing_file_with_existing_parent() {
739 let temp_dir = std::env::temp_dir();
741 let test_dir = temp_dir.join("vtcode_test_parent");
742 tokio::fs::create_dir_all(&test_dir).await.unwrap();
743
744 let missing_file = test_dir.join("missing.txt");
745 let canonical = canonicalize_allow_missing(&missing_file).await.unwrap();
746
747 assert!(canonical.is_absolute());
749 assert!(canonical.to_string_lossy().ends_with("missing.txt"));
750
751 tokio::fs::remove_dir(&test_dir).await.ok();
753 }
754
755 #[test]
756 fn expand_tilde_passes_through_absolute_paths() {
757 let absolute = "/etc/hosts";
758 assert_eq!(expand_tilde(absolute), PathBuf::from(absolute));
759 }
760
761 #[test]
762 fn expand_tilde_passes_through_relative_paths() {
763 let relative = "src/main.rs";
764 assert_eq!(expand_tilde(relative), PathBuf::from(relative));
765 }
766
767 #[test]
768 fn expand_tilde_resolves_bare_tilde_to_home() {
769 if let Some(home) = dirs::home_dir() {
770 assert_eq!(expand_tilde("~"), home);
771 }
772 }
773
774 #[test]
775 fn expand_tilde_resolves_tilde_slash_prefix() {
776 if let Some(home) = dirs::home_dir() {
777 let resolved = expand_tilde("~/projects/vtcode");
778 assert_eq!(resolved, home.join("projects/vtcode"));
779 }
780 }
781}