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