1use anyhow::{Context, Result, anyhow, bail};
2use std::path::{Component, Path, PathBuf};
3use tracing::warn;
4
5pub fn normalize_path(path: &Path) -> PathBuf {
7 let mut normalized = PathBuf::new();
8 for component in path.components() {
9 match component {
10 Component::ParentDir => {
11 normalized.pop();
12 }
13 Component::CurDir => {}
14 Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
15 Component::RootDir => normalized.push(component.as_os_str()),
16 Component::Normal(part) => normalized.push(part),
17 }
18 }
19 normalized
20}
21
22pub fn expand_tilde(path: &str) -> PathBuf {
30 if path == "~" {
31 return dirs::home_dir().unwrap_or_else(|| PathBuf::from(path));
32 }
33 if let Some(rest) = path.strip_prefix("~/")
34 && let Some(home) = dirs::home_dir()
35 {
36 return home.join(rest);
37 }
38 PathBuf::from(path)
39}
40
41pub fn canonicalize_workspace(workspace_root: &Path) -> PathBuf {
43 std::fs::canonicalize(workspace_root).unwrap_or_else(|error| {
44 warn!(
45 path = %workspace_root.display(),
46 %error,
47 "Failed to canonicalize workspace root; falling back to provided path"
48 );
49 workspace_root.to_path_buf()
50 })
51}
52
53pub fn resolve_workspace_path(workspace_root: &Path, user_path: &Path) -> Result<PathBuf> {
55 let candidate = if user_path.is_absolute() {
56 user_path.to_path_buf()
57 } else {
58 workspace_root.join(user_path)
59 };
60
61 let canonical = std::fs::canonicalize(&candidate)
62 .with_context(|| format!("Failed to canonicalize path {}", candidate.display()))?;
63
64 let workspace_canonical = std::fs::canonicalize(workspace_root).with_context(|| {
65 format!(
66 "Failed to canonicalize workspace root {}",
67 workspace_root.display()
68 )
69 })?;
70
71 if !canonical.starts_with(&workspace_canonical) {
72 return Err(anyhow!(
73 "Path {} escapes workspace root {}",
74 canonical.display(),
75 workspace_canonical.display()
76 ));
77 }
78
79 Ok(canonical)
80}
81
82pub fn secure_path(workspace_root: &Path, user_path: &Path) -> Result<PathBuf> {
86 resolve_workspace_path(workspace_root, user_path)
88}
89
90pub fn ensure_path_within_workspace(candidate: &Path, workspace_root: &Path) -> Result<PathBuf> {
100 let normalized_candidate = normalize_path(candidate);
101 let normalized_workspace = normalize_path(workspace_root);
102
103 if !normalized_candidate.starts_with(&normalized_workspace) {
104 bail!(
105 "Path '{}' escapes workspace '{}'",
106 candidate.display(),
107 workspace_root.display()
108 );
109 }
110
111 Ok(normalized_candidate)
112}
113
114pub async fn ensure_path_within_workspace_resolved(
132 candidate: &Path,
133 workspace_root: &Path,
134) -> Result<PathBuf> {
135 let normalized_root = normalize_path(workspace_root);
136 let normalized_candidate = normalize_path(candidate);
137
138 let canonical_root = match tokio::fs::canonicalize(&normalized_root).await {
139 Ok(resolved) => resolved,
140 Err(error) => {
141 warn!(
142 path = %normalized_root.display(),
143 %error,
144 "Failed to canonicalize workspace root; falling back to provided path"
145 );
146 normalized_root.clone()
147 }
148 };
149
150 if normalized_root == normalized_candidate {
151 return Ok(normalized_candidate);
152 }
153
154 let relative = normalized_candidate
155 .strip_prefix(&normalized_root)
156 .map_err(|_error| anyhow!("path '{}' escapes the workspace root", candidate.display()))?
157 .to_path_buf();
158
159 let mut prefix = normalized_root.clone();
160 let mut components = relative.components().peekable();
161
162 while let Some(component) = components.next() {
163 prefix.push(component.as_os_str());
164
165 let metadata = match tokio::fs::symlink_metadata(&prefix).await {
166 Ok(metadata) => metadata,
167 Err(error) => {
168 if error.kind() == std::io::ErrorKind::NotFound {
169 break;
170 }
171 return Err(error).with_context(|| {
172 format!("failed to inspect path component '{}'", prefix.display())
173 });
174 }
175 };
176
177 let resolved = tokio::fs::canonicalize(&prefix).await.with_context(|| {
178 format!(
179 "failed to canonicalize path component '{}'",
180 prefix.display()
181 )
182 })?;
183
184 if metadata.file_type().is_symlink() {
185 if !resolved.starts_with(&canonical_root) {
186 return Err(anyhow!(
187 "path '{}' escapes the workspace root via symlink '{}'",
188 candidate.display(),
189 prefix.display()
190 ));
191 }
192 } else {
193 if !resolved.starts_with(&canonical_root) {
194 return Err(anyhow!(
195 "path '{}' escapes the workspace root via component '{}'",
196 candidate.display(),
197 prefix.display()
198 ));
199 }
200
201 if metadata.is_file() && components.peek().is_some() {
202 return Err(anyhow!(
203 "path '{}' traverses through file component '{}'",
204 candidate.display(),
205 prefix.display()
206 ));
207 }
208 }
209 }
210
211 Ok(normalized_candidate)
212}
213
214pub fn normalize_ascii_identifier(value: &str) -> String {
216 let mut normalized = String::new();
217 for ch in value.chars() {
218 if ch.is_ascii_alphanumeric() {
219 normalized.push(ch.to_ascii_lowercase());
220 }
221 }
222 normalized
223}
224
225pub fn is_safe_relative_path(path: &str) -> bool {
227 let path = path.trim();
228 if path.is_empty() {
229 return false;
230 }
231
232 if path.contains("..") {
234 return false;
235 }
236
237 if path.starts_with('/') || path.contains(':') {
239 return false;
240 }
241
242 true
243}
244
245pub fn validate_path_safety(path: &str) -> Result<()> {
250 if path.is_empty() {
252 return Ok(());
253 }
254
255 if path.contains("..") {
258 bail!("Path traversal attempt detected ('..')");
259 }
260
261 if path.contains("~/../") || path.contains("/.../") {
263 bail!("Advanced path traversal detected");
264 }
265
266 if path.starts_with('/') {
268 static UNIX_CRITICAL: &[&str] = &[
272 "/etc", "/usr", "/bin", "/sbin", "/var", "/boot", "/root", "/dev",
273 ];
274 for prefix in UNIX_CRITICAL {
275 let is_var_temp_exception = *prefix == "/var"
276 && (path.starts_with("/var/folders/")
277 || path == "/var/folders"
278 || path.starts_with("/var/tmp/")
279 || path == "/var/tmp");
280
281 if !is_var_temp_exception && matches_critical_prefix(path, prefix) {
282 bail!("Access to system directory denied: {prefix}");
283 }
284 }
285 }
286
287 #[cfg(windows)]
289 {
290 let path_lower = path.to_lowercase();
291 static WIN_CRITICAL: &[&str] = &["c:\\windows", "c:\\program files", "c:\\system32"];
292 for prefix in WIN_CRITICAL {
293 if path_lower.starts_with(prefix) {
294 bail!("Access to Windows system directory denied");
295 }
296 }
297 }
298
299 static DANGEROUS_CHARS: &[u8] = b"$`|;&\n\r><\0";
302 for &c in path.as_bytes() {
303 if DANGEROUS_CHARS.contains(&c) {
304 bail!("Path contains dangerous shell characters");
305 }
306 }
307
308 Ok(())
309}
310
311fn matches_critical_prefix(path: &str, prefix: &str) -> bool {
312 path == prefix
313 || path
314 .strip_prefix(prefix)
315 .is_some_and(|rest| rest.starts_with('/'))
316}
317
318pub fn file_name_from_path(path: &str) -> String {
320 Path::new(path)
321 .file_name()
322 .and_then(|name| name.to_str())
323 .map(|s| s.to_string())
324 .unwrap_or_else(|| path.to_string())
325}
326
327pub async fn canonicalize_allow_missing(normalized: &Path) -> Result<PathBuf> {
345 if tokio::fs::try_exists(normalized).await.unwrap_or(false) {
347 return tokio::fs::canonicalize(normalized).await.map_err(|e| {
348 anyhow!(
349 "Failed to resolve canonical path for '{}': {}",
350 normalized.display(),
351 e
352 )
353 });
354 }
355
356 let mut current = normalized.to_path_buf();
358 while let Some(parent) = current.parent() {
359 if tokio::fs::try_exists(parent).await.unwrap_or(false) {
360 let canonical_parent = tokio::fs::canonicalize(parent).await.map_err(|e| {
362 anyhow!(
363 "Failed to resolve canonical path for '{}': {}",
364 parent.display(),
365 e
366 )
367 })?;
368
369 let remainder = normalized
371 .strip_prefix(parent)
372 .unwrap_or_else(|_| Path::new(""));
373
374 return if remainder.as_os_str().is_empty() {
376 Ok(canonical_parent)
377 } else {
378 Ok(canonical_parent.join(remainder))
379 };
380 }
381 current = parent.to_path_buf();
382 }
383
384 Ok(normalized.to_path_buf())
386}
387
388pub trait WorkspacePaths: Send + Sync {
390 fn workspace_root(&self) -> &Path;
392
393 fn config_dir(&self) -> PathBuf;
395
396 fn cache_dir(&self) -> Option<PathBuf> {
398 None
399 }
400
401 fn telemetry_dir(&self) -> Option<PathBuf> {
403 None
404 }
405
406 fn scope_for_path(&self, path: &Path) -> PathScope {
415 if path.starts_with(self.workspace_root()) {
416 return PathScope::Workspace;
417 }
418
419 let config_dir = self.config_dir();
420 if path.starts_with(&config_dir) {
421 return PathScope::Config;
422 }
423
424 if let Some(cache_dir) = self.cache_dir()
425 && path.starts_with(&cache_dir)
426 {
427 return PathScope::Cache;
428 }
429
430 if let Some(telemetry_dir) = self.telemetry_dir()
431 && path.starts_with(&telemetry_dir)
432 {
433 return PathScope::Telemetry;
434 }
435
436 PathScope::Cache
437 }
438}
439
440pub trait PathResolver: WorkspacePaths {
442 fn resolve<P>(&self, relative: P) -> PathBuf
444 where
445 P: AsRef<Path>,
446 {
447 self.workspace_root().join(relative)
448 }
449
450 fn resolve_config<P>(&self, relative: P) -> PathBuf
452 where
453 P: AsRef<Path>,
454 {
455 self.config_dir().join(relative)
456 }
457}
458
459impl<T> PathResolver for T where T: WorkspacePaths + ?Sized {}
460
461#[derive(Debug, Clone, Copy, PartialEq, Eq)]
463pub enum PathScope {
464 Workspace,
465 Config,
466 Cache,
467 Telemetry,
468}
469
470impl PathScope {
471 pub fn description(self) -> &'static str {
473 match self {
474 Self::Workspace => "workspace",
475 Self::Config => "configuration",
476 Self::Cache => "cache",
477 Self::Telemetry => "telemetry",
478 }
479 }
480}
481
482pub trait PathExt {
498 fn normalize(&self) -> PathBuf;
500
501 fn canonicalize_or_self(&self) -> PathBuf;
503
504 fn file_name_str(&self) -> String;
510}
511
512impl PathExt for Path {
513 fn normalize(&self) -> PathBuf {
514 normalize_path(self)
515 }
516
517 fn canonicalize_or_self(&self) -> PathBuf {
518 canonicalize_workspace(self)
519 }
520
521 fn file_name_str(&self) -> String {
522 self.file_name()
523 .and_then(|name| name.to_str())
524 .map(|s| s.to_string())
525 .unwrap_or_else(|| self.to_string_lossy().into_owned())
526 }
527}
528
529pub trait StrPathExt {
540 fn expand_tilde(&self) -> PathBuf;
542
543 fn is_safe_path(&self) -> bool;
545
546 fn validate_safety(&self) -> Result<()>;
548
549 fn file_name_str(&self) -> String;
551}
552
553impl StrPathExt for str {
554 fn expand_tilde(&self) -> PathBuf {
555 expand_tilde(self)
556 }
557
558 fn is_safe_path(&self) -> bool {
559 is_safe_relative_path(self)
560 }
561
562 fn validate_safety(&self) -> Result<()> {
563 validate_path_safety(self)
564 }
565
566 fn file_name_str(&self) -> String {
567 file_name_from_path(self)
568 }
569}
570
571#[cfg(test)]
572mod tests {
573 use super::*;
574 use std::path::{Path, PathBuf};
575
576 struct StaticPaths {
577 root: PathBuf,
578 config: PathBuf,
579 }
580
581 impl WorkspacePaths for StaticPaths {
582 fn workspace_root(&self) -> &Path {
583 &self.root
584 }
585
586 fn config_dir(&self) -> PathBuf {
587 self.config.clone()
588 }
589
590 fn cache_dir(&self) -> Option<PathBuf> {
591 Some(self.root.join("cache"))
592 }
593 }
594
595 #[test]
596 fn resolves_relative_paths() {
597 let paths = StaticPaths {
598 root: PathBuf::from("/tmp/project"),
599 config: PathBuf::from("/tmp/project/config"),
600 };
601
602 assert_eq!(
603 PathResolver::resolve(&paths, "subdir/file.txt"),
604 PathBuf::from("/tmp/project/subdir/file.txt")
605 );
606 assert_eq!(
607 PathResolver::resolve_config(&paths, "settings.toml"),
608 PathBuf::from("/tmp/project/config/settings.toml")
609 );
610 assert_eq!(paths.cache_dir(), Some(PathBuf::from("/tmp/project/cache")));
611 }
612
613 #[test]
614 fn ensures_path_within_workspace_accepts_nested_path() {
615 let workspace = Path::new("/tmp/project");
616 let candidate = Path::new("/tmp/project/src/../src/lib.rs");
617 let normalized = ensure_path_within_workspace(candidate, workspace).unwrap();
618 assert_eq!(normalized, PathBuf::from("/tmp/project/src/lib.rs"));
619 }
620
621 #[test]
622 fn ensures_path_within_workspace_rejects_escape() {
623 let workspace = Path::new("/tmp/project");
624 let candidate = Path::new("/tmp/project/../../etc/passwd");
625 assert!(ensure_path_within_workspace(candidate, workspace).is_err());
626 }
627
628 #[tokio::test]
629 async fn resolved_check_accepts_nested_existing_path() {
630 let workspace = tempfile::tempdir().unwrap();
631 let root = workspace.path().canonicalize().unwrap();
632 let nested = root.join("src");
633 tokio::fs::create_dir_all(&nested).await.unwrap();
634 let file = nested.join("lib.rs");
635 tokio::fs::write(&file, b"test").await.unwrap();
636
637 let result = ensure_path_within_workspace_resolved(&file, &root).await;
638 assert_eq!(result.unwrap(), file);
639 }
640
641 #[tokio::test]
642 async fn resolved_check_accepts_missing_tail_components() {
643 let workspace = tempfile::tempdir().unwrap();
644 let root = workspace.path().canonicalize().unwrap();
645 let missing = root.join("new_dir/new_file.txt");
646
647 let result = ensure_path_within_workspace_resolved(&missing, &root).await;
648 assert_eq!(result.unwrap(), missing);
649 }
650
651 #[tokio::test]
652 async fn resolved_check_rejects_lexical_escape() {
653 let workspace = tempfile::tempdir().unwrap();
654 let root = workspace.path().canonicalize().unwrap();
655 let escape = root.join("../outside.txt");
656
657 assert!(
658 ensure_path_within_workspace_resolved(&escape, &root)
659 .await
660 .is_err()
661 );
662 }
663
664 #[cfg(unix)]
665 #[tokio::test]
666 async fn resolved_check_rejects_symlink_escape() {
667 let workspace = tempfile::tempdir().unwrap();
668 let outside = tempfile::tempdir().unwrap();
669 let root = workspace.path().canonicalize().unwrap();
670 let outside_dir = outside.path().canonicalize().unwrap();
671
672 let link = root.join("escape");
673 tokio::fs::symlink(&outside_dir, &link).await.unwrap();
674
675 let candidate = link.join("secret.txt");
676 assert!(
677 ensure_path_within_workspace_resolved(&candidate, &root)
678 .await
679 .is_err()
680 );
681 }
682
683 #[cfg(unix)]
684 #[tokio::test]
685 async fn resolved_check_accepts_symlink_within_workspace() {
686 let workspace = tempfile::tempdir().unwrap();
687 let root = workspace.path().canonicalize().unwrap();
688 let target = root.join("real");
689 tokio::fs::create_dir_all(&target).await.unwrap();
690 let link = root.join("alias");
691 tokio::fs::symlink(&target, &link).await.unwrap();
692
693 let candidate = link.join("file.txt");
694 assert!(
695 ensure_path_within_workspace_resolved(&candidate, &root)
696 .await
697 .is_ok()
698 );
699 }
700
701 #[tokio::test]
702 async fn resolved_check_rejects_traversal_through_file() {
703 let workspace = tempfile::tempdir().unwrap();
704 let root = workspace.path().canonicalize().unwrap();
705 let file = root.join("data.txt");
706 tokio::fs::write(&file, b"test").await.unwrap();
707
708 let candidate = file.join("child.txt");
709 assert!(
710 ensure_path_within_workspace_resolved(&candidate, &root)
711 .await
712 .is_err()
713 );
714 }
715
716 #[tokio::test]
717 async fn test_canonicalize_existing_file() {
718 let temp_dir = std::env::temp_dir();
720 let test_file = temp_dir.join("vtcode_test_existing.txt");
721 tokio::fs::write(&test_file, b"test").await.unwrap();
722
723 let canonical = canonicalize_allow_missing(&test_file).await.unwrap();
724
725 assert!(canonical.is_absolute());
727 assert!(canonical.exists());
728
729 tokio::fs::remove_file(&test_file).await.ok();
731 }
732
733 #[tokio::test]
734 async fn test_canonicalize_missing_file() {
735 let temp_dir = std::env::temp_dir();
737 let missing_file = temp_dir.join("vtcode_test_missing_dir/missing_file.txt");
738
739 let canonical = canonicalize_allow_missing(&missing_file).await.unwrap();
740
741 assert!(canonical.is_absolute());
743 assert!(canonical.to_string_lossy().contains("missing_file.txt"));
744 }
745
746 #[tokio::test]
747 async fn test_canonicalize_deeply_missing_path() {
748 let temp_dir = std::env::temp_dir();
750 let deep_missing = temp_dir.join("vtcode_test_a/b/c/d/file.txt");
751
752 let canonical = canonicalize_allow_missing(&deep_missing).await.unwrap();
753
754 assert!(canonical.is_absolute());
756 assert!(canonical.to_string_lossy().contains("vtcode_test_a"));
757 }
758
759 #[tokio::test]
760 async fn test_canonicalize_missing_file_with_existing_parent() {
761 let temp_dir = std::env::temp_dir();
763 let test_dir = temp_dir.join("vtcode_test_parent");
764 tokio::fs::create_dir_all(&test_dir).await.unwrap();
765
766 let missing_file = test_dir.join("missing.txt");
767 let canonical = canonicalize_allow_missing(&missing_file).await.unwrap();
768
769 assert!(canonical.is_absolute());
771 assert!(canonical.to_string_lossy().ends_with("missing.txt"));
772
773 tokio::fs::remove_dir(&test_dir).await.ok();
775 }
776
777 #[test]
778 fn expand_tilde_passes_through_absolute_paths() {
779 let absolute = "/etc/hosts";
780 assert_eq!(expand_tilde(absolute), PathBuf::from(absolute));
781 }
782
783 #[test]
784 fn expand_tilde_passes_through_relative_paths() {
785 let relative = "src/main.rs";
786 assert_eq!(expand_tilde(relative), PathBuf::from(relative));
787 }
788
789 #[test]
790 fn expand_tilde_resolves_bare_tilde_to_home() {
791 if let Some(home) = dirs::home_dir() {
792 assert_eq!(expand_tilde("~"), home);
793 }
794 }
795
796 #[test]
797 fn expand_tilde_resolves_tilde_slash_prefix() {
798 if let Some(home) = dirs::home_dir() {
799 let resolved = expand_tilde("~/projects/vtcode");
800 assert_eq!(resolved, home.join("projects/vtcode"));
801 }
802 }
803}