sandbox_runtime/utils/
path.rs

1//! Path normalization utilities.
2
3use std::path::{Path, PathBuf};
4
5/// Normalize a path for sandbox use.
6/// - Expands ~ to home directory
7/// - Resolves to canonical path if possible
8/// - Returns the normalized path string
9pub fn normalize_path_for_sandbox(path: &str) -> String {
10    let expanded = expand_home(path);
11
12    // Try to canonicalize (resolves symlinks)
13    match std::fs::canonicalize(&expanded) {
14        Ok(canonical) => canonical.display().to_string(),
15        Err(_) => expanded,
16    }
17}
18
19/// Expand ~ to the home directory.
20pub fn expand_home(path: &str) -> String {
21    if path.starts_with("~/") {
22        if let Some(home) = dirs::home_dir() {
23            return format!("{}{}", home.display(), &path[1..]);
24        }
25    } else if path == "~" {
26        if let Some(home) = dirs::home_dir() {
27            return home.display().to_string();
28        }
29    }
30    path.to_string()
31}
32
33/// Normalize case for path comparison on case-insensitive filesystems.
34pub fn normalize_case_for_comparison(path: &str) -> String {
35    #[cfg(target_os = "macos")]
36    {
37        path.to_lowercase()
38    }
39    #[cfg(not(target_os = "macos"))]
40    {
41        path.to_string()
42    }
43}
44
45/// Check if a path contains glob characters.
46pub fn contains_glob_chars(path: &str) -> bool {
47    path.contains('*') || path.contains('?') || path.contains('[') || path.contains('{')
48}
49
50/// Remove trailing glob suffix (e.g., /** or /*)
51pub fn remove_trailing_glob_suffix(path: &str) -> String {
52    let mut result = path.to_string();
53
54    // Remove trailing /**
55    while result.ends_with("/**") {
56        result = result[..result.len() - 3].to_string();
57    }
58
59    // Remove trailing /*
60    while result.ends_with("/*") {
61        result = result[..result.len() - 2].to_string();
62    }
63
64    result
65}
66
67/// Check if a resolved symlink path is outside the original path boundary.
68/// This prevents escaping the sandbox via symlinks.
69pub fn is_symlink_outside_boundary(original: &Path, resolved: &Path) -> bool {
70    // If the resolved path is an ancestor of or equal to root, it's outside
71    if resolved == Path::new("/") {
72        return true;
73    }
74
75    // Check if resolved is an ancestor of original
76    if original.starts_with(resolved) && original != resolved {
77        return true;
78    }
79
80    false
81}
82
83/// Get the parent directory path, handling root correctly.
84pub fn get_parent_path(path: &Path) -> Option<&Path> {
85    let parent = path.parent()?;
86    if parent.as_os_str().is_empty() {
87        None
88    } else {
89        Some(parent)
90    }
91}
92
93/// Join paths, handling absolute paths correctly.
94pub fn join_paths<P: AsRef<Path>>(base: &Path, path: P) -> PathBuf {
95    let path = path.as_ref();
96    if path.is_absolute() {
97        path.to_path_buf()
98    } else {
99        base.join(path)
100    }
101}
102
103/// Check if a path is a symlink.
104pub fn is_symlink(path: &Path) -> bool {
105    path.symlink_metadata()
106        .map(|m| m.file_type().is_symlink())
107        .unwrap_or(false)
108}
109
110/// Resolve a symlink to its target, if it is one.
111pub fn resolve_symlink(path: &Path) -> std::io::Result<PathBuf> {
112    std::fs::read_link(path)
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118
119    #[test]
120    fn test_expand_home() {
121        let home = dirs::home_dir().unwrap();
122
123        assert_eq!(expand_home("~"), home.display().to_string());
124        assert_eq!(
125            expand_home("~/Documents"),
126            format!("{}/Documents", home.display())
127        );
128        assert_eq!(expand_home("/absolute/path"), "/absolute/path");
129        assert_eq!(expand_home("relative/path"), "relative/path");
130    }
131
132    #[test]
133    fn test_contains_glob_chars() {
134        assert!(contains_glob_chars("*.txt"));
135        assert!(contains_glob_chars("src/**/*.rs"));
136        assert!(contains_glob_chars("file?.txt"));
137        assert!(contains_glob_chars("file[0-9].txt"));
138        assert!(contains_glob_chars("file{a,b}.txt"));
139        assert!(!contains_glob_chars("/plain/path"));
140    }
141
142    #[test]
143    fn test_remove_trailing_glob_suffix() {
144        assert_eq!(remove_trailing_glob_suffix("/path/**"), "/path");
145        assert_eq!(remove_trailing_glob_suffix("/path/*"), "/path");
146        assert_eq!(remove_trailing_glob_suffix("/path/**/**"), "/path");
147        assert_eq!(remove_trailing_glob_suffix("/path"), "/path");
148    }
149}