vtcode_commons/paths/
safety.rs1use anyhow::{Result, bail};
2use std::path::{Component, Path, PathBuf};
3
4pub fn is_safe_relative_path(path: &str) -> bool {
6 let path = path.trim();
7 if path.is_empty() {
8 return false;
9 }
10
11 if path.contains("..") {
13 return false;
14 }
15
16 if path.starts_with('/') || path.contains(':') {
18 return false;
19 }
20
21 true
22}
23
24pub fn validate_path_safety(path: &str) -> Result<()> {
29 if path.is_empty() {
31 return Ok(());
32 }
33
34 if path.contains("..") {
37 bail!("Path traversal attempt detected ('..')");
38 }
39
40 if path.contains("~/../") || path.contains("/.../") {
42 bail!("Advanced path traversal detected");
43 }
44
45 if path.starts_with('/') {
47 static UNIX_CRITICAL: &[&str] = &["/etc", "/usr", "/bin", "/sbin", "/var", "/boot", "/root", "/dev"];
51 for prefix in UNIX_CRITICAL {
52 let is_var_temp_exception = *prefix == "/var"
53 && (path.starts_with("/var/folders/")
54 || path == "/var/folders"
55 || path.starts_with("/var/tmp/")
56 || path == "/var/tmp");
57
58 if !is_var_temp_exception && matches_critical_prefix(path, prefix) {
59 bail!("Access to system directory denied: {prefix}");
60 }
61 }
62 }
63
64 #[cfg(windows)]
66 {
67 let path_lower = path.to_lowercase();
68 static WIN_CRITICAL: &[&str] = &["c:\\windows", "c:\\program files", "c:\\system32"];
69 for prefix in WIN_CRITICAL {
70 if path_lower.starts_with(prefix) {
71 bail!("Access to Windows system directory denied");
72 }
73 }
74 }
75
76 static DANGEROUS_CHARS: &[u8] = b"$`|;&\n\r><\0";
79 for &c in path.as_bytes() {
80 if DANGEROUS_CHARS.contains(&c) {
81 bail!("Path contains dangerous shell characters");
82 }
83 }
84
85 Ok(())
86}
87
88fn matches_critical_prefix(path: &str, prefix: &str) -> bool {
89 path == prefix || path.strip_prefix(prefix).is_some_and(|rest| rest.starts_with('/'))
90}
91
92pub fn safe_archive_relative_path(name: &Path) -> Result<PathBuf> {
106 let mut safe = PathBuf::new();
107 for component in name.components() {
108 match component {
109 Component::Normal(part) => safe.push(part),
110 Component::CurDir => {}
111 Component::ParentDir => {
112 bail!("unsafe archive path ('..' escapes destination): {}", name.display());
113 }
114 Component::RootDir | Component::Prefix(_) => {
115 bail!("unsafe archive path (absolute): {}", name.display());
116 }
117 }
118 }
119 Ok(safe)
120}
121
122#[cfg(test)]
123mod tests {
124 use super::*;
125
126 #[test]
127 fn accepts_nested_relative_names() {
128 assert_eq!(safe_archive_relative_path(Path::new("a/b/c.txt")).expect("safe"), PathBuf::from("a/b/c.txt"));
129 }
130
131 #[test]
132 fn collapses_current_dir_components() {
133 assert_eq!(
134 safe_archive_relative_path(Path::new("./nested/./file.txt")).expect("safe"),
135 PathBuf::from("nested/file.txt")
136 );
137 }
138
139 #[test]
140 fn accepts_dot_dot_inside_a_single_component() {
141 assert_eq!(
144 safe_archive_relative_path(Path::new("v1..2/README.md")).expect("safe"),
145 PathBuf::from("v1..2/README.md")
146 );
147 }
148
149 #[test]
150 fn rejects_parent_components() {
151 let err = safe_archive_relative_path(Path::new("../../etc/passwd")).expect_err("traversal");
152 assert!(err.to_string().contains("unsafe archive path"), "unexpected error: {err}");
153 }
154
155 #[test]
156 fn rejects_absolute_names() {
157 let err = safe_archive_relative_path(Path::new("/abs.txt")).expect_err("absolute");
158 assert!(err.to_string().contains("unsafe archive path"), "unexpected error: {err}");
159 }
160
161 #[test]
162 fn resolves_bare_current_dir_to_empty() {
163 assert!(safe_archive_relative_path(Path::new(".")).expect("safe").as_os_str().is_empty());
164 }
165
166 #[cfg(windows)]
167 #[test]
168 fn rejects_windows_prefixes() {
169 let err = safe_archive_relative_path(Path::new("C:\\evil.txt")).expect_err("prefix");
170 assert!(err.to_string().contains("unsafe archive path"), "unexpected error: {err}");
171 }
172}