Skip to main content

vtcode_commons/paths/
safety.rs

1use anyhow::{Result, bail};
2use std::path::{Component, Path, PathBuf};
3
4/// Check if a path string is a safe relative path (no traversal, no absolute).
5pub fn is_safe_relative_path(path: &str) -> bool {
6    let path = path.trim();
7    if path.is_empty() {
8        return false;
9    }
10
11    // Check for path traversal attempts
12    if path.contains("..") {
13        return false;
14    }
15
16    // Block absolute paths for security
17    if path.starts_with('/') || path.contains(':') {
18        return false;
19    }
20
21    true
22}
23
24/// Validates that a path is safe to use.
25/// Preventing traversal, absolute system paths, and dangerous characters.
26///
27/// Optimization: Uses early returns and byte-level checks for common patterns
28pub fn validate_path_safety(path: &str) -> Result<()> {
29    // Optimization: Fast path for empty or very short paths
30    if path.is_empty() {
31        return Ok(());
32    }
33
34    // Reject path traversal attempts
35    // Optimization: Use contains on bytes for simple patterns
36    if path.contains("..") {
37        bail!("Path traversal attempt detected ('..')");
38    }
39
40    // Additional traversal patterns
41    if path.contains("~/../") || path.contains("/.../") {
42        bail!("Advanced path traversal detected");
43    }
44
45    // Optimization: Only check Unix critical paths if path starts with '/'
46    if path.starts_with('/') {
47        // Reject absolute paths outside workspace
48        // Note: We can't strictly block all absolute paths as the agent might need to access
49        // explicitly allowed directories, but we can block obvious system critical paths.
50        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    // Windows critical paths
65    #[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    // Reject dangerous shell characters in paths (including null byte)
77    // Optimization: Check bytes directly for faster character detection
78    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
92/// Validate an archive entry name and return it as a destination-relative path.
93///
94/// Lexical only (no filesystem access): rejects absolute paths, Windows
95/// prefixes, and parent components (`..`), and collapses `.` components.
96/// Unlike a substring check for `..`, legitimate names such as
97/// `v1..2/README.md` are accepted. An empty result means the name resolves to
98/// the extraction destination itself (e.g. `.`); callers decide whether to
99/// skip or reject that.
100///
101/// # Errors
102///
103/// Returns an error when the name contains a component that would escape the
104/// extraction destination.
105pub 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        // Substring `..` checks reject this legitimate name; component
142        // analysis must accept it.
143        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}