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!("Failed to canonicalize workspace root {}", workspace_root.display())
66 })?;
67
68 if !canonical.starts_with(&workspace_canonical) {
69 return Err(anyhow!(
70 "Path {} escapes workspace root {}",
71 canonical.display(),
72 workspace_canonical.display()
73 ));
74 }
75
76 Ok(canonical)
77}
78
79pub fn secure_path(workspace_root: &Path, user_path: &Path) -> Result<PathBuf> {
83 resolve_workspace_path(workspace_root, user_path)
85}
86
87pub fn ensure_path_within_workspace(candidate: &Path, workspace_root: &Path) -> Result<PathBuf> {
97 let normalized_candidate = normalize_path(candidate);
98 let normalized_workspace = normalize_path(workspace_root);
99
100 if !normalized_candidate.starts_with(&normalized_workspace) {
101 bail!("Path '{}' escapes workspace '{}'", candidate.display(), workspace_root.display());
102 }
103
104 Ok(normalized_candidate)
105}
106
107pub async fn ensure_path_within_workspace_resolved(
125 candidate: &Path,
126 workspace_root: &Path,
127) -> Result<PathBuf> {
128 let normalized_root = normalize_path(workspace_root);
129 let normalized_candidate = normalize_path(candidate);
130
131 let canonical_root = match tokio::fs::canonicalize(&normalized_root).await {
132 Ok(resolved) => resolved,
133 Err(error) => {
134 warn!(
135 path = %normalized_root.display(),
136 %error,
137 "Failed to canonicalize workspace root; falling back to provided path"
138 );
139 normalized_root.clone()
140 }
141 };
142
143 if normalized_root == normalized_candidate {
144 return Ok(normalized_candidate);
145 }
146
147 let relative = normalized_candidate
148 .strip_prefix(&normalized_root)
149 .map_err(|_error| anyhow!("path '{}' escapes the workspace root", candidate.display()))?
150 .to_path_buf();
151
152 let mut prefix = normalized_root.clone();
153 let mut components = relative.components().peekable();
154
155 while let Some(component) = components.next() {
156 prefix.push(component.as_os_str());
157
158 let metadata = match tokio::fs::symlink_metadata(&prefix).await {
159 Ok(metadata) => metadata,
160 Err(error) => {
161 if error.kind() == std::io::ErrorKind::NotFound {
162 break;
163 }
164 return Err(error).with_context(|| {
165 format!("failed to inspect path component '{}'", prefix.display())
166 });
167 }
168 };
169
170 let resolved = tokio::fs::canonicalize(&prefix).await.with_context(|| {
171 format!("failed to canonicalize path component '{}'", prefix.display())
172 })?;
173
174 if metadata.file_type().is_symlink() {
175 if !resolved.starts_with(&canonical_root) {
176 return Err(anyhow!(
177 "path '{}' escapes the workspace root via symlink '{}'",
178 candidate.display(),
179 prefix.display()
180 ));
181 }
182 } else {
183 if !resolved.starts_with(&canonical_root) {
184 return Err(anyhow!(
185 "path '{}' escapes the workspace root via component '{}'",
186 candidate.display(),
187 prefix.display()
188 ));
189 }
190
191 if metadata.is_file() && components.peek().is_some() {
192 return Err(anyhow!(
193 "path '{}' traverses through file component '{}'",
194 candidate.display(),
195 prefix.display()
196 ));
197 }
198 }
199 }
200
201 Ok(normalized_candidate)
202}
203
204pub fn normalize_ascii_identifier(value: &str) -> String {
206 let mut normalized = String::new();
207 for ch in value.chars() {
208 if ch.is_ascii_alphanumeric() {
209 normalized.push(ch.to_ascii_lowercase());
210 }
211 }
212 normalized
213}
214
215pub fn is_safe_relative_path(path: &str) -> bool {
217 let path = path.trim();
218 if path.is_empty() {
219 return false;
220 }
221
222 if path.contains("..") {
224 return false;
225 }
226
227 if path.starts_with('/') || path.contains(':') {
229 return false;
230 }
231
232 true
233}
234
235pub fn validate_path_safety(path: &str) -> Result<()> {
240 if path.is_empty() {
242 return Ok(());
243 }
244
245 if path.contains("..") {
248 bail!("Path traversal attempt detected ('..')");
249 }
250
251 if path.contains("~/../") || path.contains("/.../") {
253 bail!("Advanced path traversal detected");
254 }
255
256 if path.starts_with('/') {
258 static UNIX_CRITICAL: &[&str] = &[
262 "/etc", "/usr", "/bin", "/sbin", "/var", "/boot", "/root", "/dev",
263 ];
264 for prefix in UNIX_CRITICAL {
265 let is_var_temp_exception = *prefix == "/var"
266 && (path.starts_with("/var/folders/")
267 || path == "/var/folders"
268 || path.starts_with("/var/tmp/")
269 || path == "/var/tmp");
270
271 if !is_var_temp_exception && matches_critical_prefix(path, prefix) {
272 bail!("Access to system directory denied: {prefix}");
273 }
274 }
275 }
276
277 #[cfg(windows)]
279 {
280 let path_lower = path.to_lowercase();
281 static WIN_CRITICAL: &[&str] = &["c:\\windows", "c:\\program files", "c:\\system32"];
282 for prefix in WIN_CRITICAL {
283 if path_lower.starts_with(prefix) {
284 bail!("Access to Windows system directory denied");
285 }
286 }
287 }
288
289 static DANGEROUS_CHARS: &[u8] = b"$`|;&\n\r><\0";
292 for &c in path.as_bytes() {
293 if DANGEROUS_CHARS.contains(&c) {
294 bail!("Path contains dangerous shell characters");
295 }
296 }
297
298 Ok(())
299}
300
301fn matches_critical_prefix(path: &str, prefix: &str) -> bool {
302 path == prefix || path.strip_prefix(prefix).is_some_and(|rest| rest.starts_with('/'))
303}
304
305pub fn file_name_from_path(path: &str) -> String {
307 Path::new(path)
308 .file_name()
309 .and_then(|name| name.to_str())
310 .map(|s| s.to_string())
311 .unwrap_or_else(|| path.to_string())
312}
313
314pub async fn canonicalize_allow_missing(normalized: &Path) -> Result<PathBuf> {
332 if tokio::fs::try_exists(normalized).await.unwrap_or(false) {
334 return tokio::fs::canonicalize(normalized).await.map_err(|e| {
335 anyhow!("Failed to resolve canonical path for '{}': {}", normalized.display(), e)
336 });
337 }
338
339 let mut current = normalized.to_path_buf();
341 while let Some(parent) = current.parent() {
342 if tokio::fs::try_exists(parent).await.unwrap_or(false) {
343 let canonical_parent = tokio::fs::canonicalize(parent).await.map_err(|e| {
345 anyhow!("Failed to resolve canonical path for '{}': {}", parent.display(), e)
346 })?;
347
348 let remainder = normalized.strip_prefix(parent).unwrap_or_else(|_| Path::new(""));
350
351 return if remainder.as_os_str().is_empty() {
353 Ok(canonical_parent)
354 } else {
355 Ok(canonical_parent.join(remainder))
356 };
357 }
358 current = parent.to_path_buf();
359 }
360
361 Ok(normalized.to_path_buf())
363}
364
365pub trait WorkspacePaths: Send + Sync {
367 fn workspace_root(&self) -> &Path;
369
370 fn config_dir(&self) -> PathBuf;
372
373 fn cache_dir(&self) -> Option<PathBuf> {
375 None
376 }
377
378 fn telemetry_dir(&self) -> Option<PathBuf> {
380 None
381 }
382
383 fn scope_for_path(&self, path: &Path) -> PathScope {
392 if path.starts_with(self.workspace_root()) {
393 return PathScope::Workspace;
394 }
395
396 let config_dir = self.config_dir();
397 if path.starts_with(&config_dir) {
398 return PathScope::Config;
399 }
400
401 if let Some(cache_dir) = self.cache_dir()
402 && path.starts_with(&cache_dir)
403 {
404 return PathScope::Cache;
405 }
406
407 if let Some(telemetry_dir) = self.telemetry_dir()
408 && path.starts_with(&telemetry_dir)
409 {
410 return PathScope::Telemetry;
411 }
412
413 PathScope::Cache
414 }
415}
416
417pub trait PathResolver: WorkspacePaths {
419 fn resolve<P>(&self, relative: P) -> PathBuf
421 where
422 P: AsRef<Path>,
423 {
424 self.workspace_root().join(relative)
425 }
426
427 fn resolve_config<P>(&self, relative: P) -> PathBuf
429 where
430 P: AsRef<Path>,
431 {
432 self.config_dir().join(relative)
433 }
434}
435
436impl<T> PathResolver for T where T: WorkspacePaths + ?Sized {}
437
438#[derive(Debug, Clone, Copy, PartialEq, Eq)]
440pub enum PathScope {
441 Workspace,
442 Config,
443 Cache,
444 Telemetry,
445}
446
447impl PathScope {
448 pub fn description(self) -> &'static str {
450 match self {
451 Self::Workspace => "workspace",
452 Self::Config => "configuration",
453 Self::Cache => "cache",
454 Self::Telemetry => "telemetry",
455 }
456 }
457}
458
459pub trait PathExt {
475 fn normalize(&self) -> PathBuf;
477
478 fn canonicalize_or_self(&self) -> PathBuf;
480
481 fn file_name_str(&self) -> String;
487}
488
489impl PathExt for Path {
490 fn normalize(&self) -> PathBuf {
491 normalize_path(self)
492 }
493
494 fn canonicalize_or_self(&self) -> PathBuf {
495 canonicalize_workspace(self)
496 }
497
498 fn file_name_str(&self) -> String {
499 self.file_name()
500 .and_then(|name| name.to_str())
501 .map(|s| s.to_string())
502 .unwrap_or_else(|| self.to_string_lossy().into_owned())
503 }
504}
505
506pub trait StrPathExt {
517 fn expand_tilde(&self) -> PathBuf;
519
520 fn is_safe_path(&self) -> bool;
522
523 fn validate_safety(&self) -> Result<()>;
525
526 fn file_name_str(&self) -> String;
528}
529
530impl StrPathExt for str {
531 fn expand_tilde(&self) -> PathBuf {
532 expand_tilde(self)
533 }
534
535 fn is_safe_path(&self) -> bool {
536 is_safe_relative_path(self)
537 }
538
539 fn validate_safety(&self) -> Result<()> {
540 validate_path_safety(self)
541 }
542
543 fn file_name_str(&self) -> String {
544 file_name_from_path(self)
545 }
546}
547
548#[cfg(test)]
549mod tests {
550 use super::*;
551 use std::path::{Path, PathBuf};
552
553 struct StaticPaths {
554 root: PathBuf,
555 config: PathBuf,
556 }
557
558 impl WorkspacePaths for StaticPaths {
559 fn workspace_root(&self) -> &Path {
560 &self.root
561 }
562
563 fn config_dir(&self) -> PathBuf {
564 self.config.clone()
565 }
566
567 fn cache_dir(&self) -> Option<PathBuf> {
568 Some(self.root.join("cache"))
569 }
570 }
571
572 #[test]
573 fn resolves_relative_paths() {
574 let paths = StaticPaths {
575 root: PathBuf::from("/tmp/project"),
576 config: PathBuf::from("/tmp/project/config"),
577 };
578
579 assert_eq!(
580 PathResolver::resolve(&paths, "subdir/file.txt"),
581 PathBuf::from("/tmp/project/subdir/file.txt")
582 );
583 assert_eq!(
584 PathResolver::resolve_config(&paths, "settings.toml"),
585 PathBuf::from("/tmp/project/config/settings.toml")
586 );
587 assert_eq!(paths.cache_dir(), Some(PathBuf::from("/tmp/project/cache")));
588 }
589
590 #[test]
591 fn ensures_path_within_workspace_accepts_nested_path() {
592 let workspace = Path::new("/tmp/project");
593 let candidate = Path::new("/tmp/project/src/../src/lib.rs");
594 let normalized = ensure_path_within_workspace(candidate, workspace).unwrap();
595 assert_eq!(normalized, PathBuf::from("/tmp/project/src/lib.rs"));
596 }
597
598 #[test]
599 fn ensures_path_within_workspace_rejects_escape() {
600 let workspace = Path::new("/tmp/project");
601 let candidate = Path::new("/tmp/project/../../etc/passwd");
602 assert!(ensure_path_within_workspace(candidate, workspace).is_err());
603 }
604
605 #[tokio::test]
606 async fn resolved_check_accepts_nested_existing_path() {
607 let workspace = tempfile::tempdir().unwrap();
608 let root = workspace.path().canonicalize().unwrap();
609 let nested = root.join("src");
610 tokio::fs::create_dir_all(&nested).await.unwrap();
611 let file = nested.join("lib.rs");
612 tokio::fs::write(&file, b"test").await.unwrap();
613
614 let result = ensure_path_within_workspace_resolved(&file, &root).await;
615 assert_eq!(result.unwrap(), file);
616 }
617
618 #[tokio::test]
619 async fn resolved_check_accepts_missing_tail_components() {
620 let workspace = tempfile::tempdir().unwrap();
621 let root = workspace.path().canonicalize().unwrap();
622 let missing = root.join("new_dir/new_file.txt");
623
624 let result = ensure_path_within_workspace_resolved(&missing, &root).await;
625 assert_eq!(result.unwrap(), missing);
626 }
627
628 #[tokio::test]
629 async fn resolved_check_rejects_lexical_escape() {
630 let workspace = tempfile::tempdir().unwrap();
631 let root = workspace.path().canonicalize().unwrap();
632 let escape = root.join("../outside.txt");
633
634 assert!(ensure_path_within_workspace_resolved(&escape, &root).await.is_err());
635 }
636
637 #[cfg(unix)]
638 #[tokio::test]
639 async fn resolved_check_rejects_symlink_escape() {
640 let workspace = tempfile::tempdir().unwrap();
641 let outside = tempfile::tempdir().unwrap();
642 let root = workspace.path().canonicalize().unwrap();
643 let outside_dir = outside.path().canonicalize().unwrap();
644
645 let link = root.join("escape");
646 tokio::fs::symlink(&outside_dir, &link).await.unwrap();
647
648 let candidate = link.join("secret.txt");
649 assert!(ensure_path_within_workspace_resolved(&candidate, &root).await.is_err());
650 }
651
652 #[cfg(unix)]
653 #[tokio::test]
654 async fn resolved_check_accepts_symlink_within_workspace() {
655 let workspace = tempfile::tempdir().unwrap();
656 let root = workspace.path().canonicalize().unwrap();
657 let target = root.join("real");
658 tokio::fs::create_dir_all(&target).await.unwrap();
659 let link = root.join("alias");
660 tokio::fs::symlink(&target, &link).await.unwrap();
661
662 let candidate = link.join("file.txt");
663 assert!(ensure_path_within_workspace_resolved(&candidate, &root).await.is_ok());
664 }
665
666 #[tokio::test]
667 async fn resolved_check_rejects_traversal_through_file() {
668 let workspace = tempfile::tempdir().unwrap();
669 let root = workspace.path().canonicalize().unwrap();
670 let file = root.join("data.txt");
671 tokio::fs::write(&file, b"test").await.unwrap();
672
673 let candidate = file.join("child.txt");
674 assert!(ensure_path_within_workspace_resolved(&candidate, &root).await.is_err());
675 }
676
677 #[tokio::test]
678 async fn test_canonicalize_existing_file() {
679 let temp_dir = std::env::temp_dir();
681 let test_file = temp_dir.join("vtcode_test_existing.txt");
682 tokio::fs::write(&test_file, b"test").await.unwrap();
683
684 let canonical = canonicalize_allow_missing(&test_file).await.unwrap();
685
686 assert!(canonical.is_absolute());
688 assert!(canonical.exists());
689
690 tokio::fs::remove_file(&test_file).await.ok();
692 }
693
694 #[tokio::test]
695 async fn test_canonicalize_missing_file() {
696 let temp_dir = std::env::temp_dir();
698 let missing_file = temp_dir.join("vtcode_test_missing_dir/missing_file.txt");
699
700 let canonical = canonicalize_allow_missing(&missing_file).await.unwrap();
701
702 assert!(canonical.is_absolute());
704 assert!(canonical.to_string_lossy().contains("missing_file.txt"));
705 }
706
707 #[tokio::test]
708 async fn test_canonicalize_deeply_missing_path() {
709 let temp_dir = std::env::temp_dir();
711 let deep_missing = temp_dir.join("vtcode_test_a/b/c/d/file.txt");
712
713 let canonical = canonicalize_allow_missing(&deep_missing).await.unwrap();
714
715 assert!(canonical.is_absolute());
717 assert!(canonical.to_string_lossy().contains("vtcode_test_a"));
718 }
719
720 #[tokio::test]
721 async fn test_canonicalize_missing_file_with_existing_parent() {
722 let temp_dir = std::env::temp_dir();
724 let test_dir = temp_dir.join("vtcode_test_parent");
725 tokio::fs::create_dir_all(&test_dir).await.unwrap();
726
727 let missing_file = test_dir.join("missing.txt");
728 let canonical = canonicalize_allow_missing(&missing_file).await.unwrap();
729
730 assert!(canonical.is_absolute());
732 assert!(canonical.to_string_lossy().ends_with("missing.txt"));
733
734 tokio::fs::remove_dir(&test_dir).await.ok();
736 }
737
738 #[test]
739 fn expand_tilde_passes_through_absolute_paths() {
740 let absolute = "/etc/hosts";
741 assert_eq!(expand_tilde(absolute), PathBuf::from(absolute));
742 }
743
744 #[test]
745 fn expand_tilde_passes_through_relative_paths() {
746 let relative = "src/main.rs";
747 assert_eq!(expand_tilde(relative), PathBuf::from(relative));
748 }
749
750 #[test]
751 fn expand_tilde_resolves_bare_tilde_to_home() {
752 if let Some(home) = dirs::home_dir() {
753 assert_eq!(expand_tilde("~"), home);
754 }
755 }
756
757 #[test]
758 fn expand_tilde_resolves_tilde_slash_prefix() {
759 if let Some(home) = dirs::home_dir() {
760 let resolved = expand_tilde("~/projects/vtcode");
761 assert_eq!(resolved, home.join("projects/vtcode"));
762 }
763 }
764}