Skip to main content

apollo/tools/
sandbox.rs

1use std::path::{Component, Path, PathBuf};
2
3fn workspace_canonical(workspace: &Path) -> PathBuf {
4    workspace
5        .canonicalize()
6        .unwrap_or_else(|_| workspace.to_path_buf())
7}
8
9fn validate_within_workspace(
10    workspace: &Path,
11    resolved: &Path,
12    original: &str,
13) -> anyhow::Result<PathBuf> {
14    let canonical_workspace = workspace_canonical(workspace);
15    if !resolved.starts_with(&canonical_workspace) {
16        anyhow::bail!(
17            "Access denied: path '{}' is outside the workspace.",
18            original
19        );
20    }
21    Ok(resolved.to_path_buf())
22}
23
24fn resolve_write_candidate(requested: &Path) -> PathBuf {
25    let mut prefix = if requested.is_absolute() {
26        PathBuf::from(std::path::MAIN_SEPARATOR.to_string())
27    } else {
28        PathBuf::new()
29    };
30    let mut canonical_prefix = prefix.clone();
31    let mut remainder = PathBuf::new();
32    let mut saw_missing_component = false;
33
34    for component in requested.components() {
35        match component {
36            Component::RootDir | Component::Prefix(_) => {}
37            Component::CurDir => {}
38            Component::ParentDir => {
39                if saw_missing_component {
40                    remainder.pop();
41                } else {
42                    prefix.pop();
43                    if prefix.exists() {
44                        canonical_prefix = prefix.canonicalize().unwrap_or_else(|_| prefix.clone());
45                    }
46                }
47            }
48            Component::Normal(part) => {
49                if saw_missing_component {
50                    remainder.push(part);
51                    continue;
52                }
53
54                prefix.push(part);
55                if prefix.exists() {
56                    canonical_prefix = prefix.canonicalize().unwrap_or_else(|_| prefix.clone());
57                } else {
58                    saw_missing_component = true;
59                    remainder.push(part);
60                }
61            }
62        }
63    }
64
65    if saw_missing_component {
66        canonical_prefix.join(remainder)
67    } else {
68        canonical_prefix
69    }
70}
71
72pub fn resolve_workspace_existing_path(
73    workspace: &Path,
74    raw_path: &str,
75) -> anyhow::Result<PathBuf> {
76    let path = raw_path.trim();
77    if path.is_empty() {
78        anyhow::bail!("Path is required");
79    }
80    if path.starts_with('~') {
81        anyhow::bail!("Home directory expansion (~) is disabled for security.");
82    }
83
84    let requested = if Path::new(path).is_absolute() {
85        PathBuf::from(path)
86    } else {
87        workspace.join(path)
88    };
89    let resolved = requested
90        .canonicalize()
91        .map_err(|e| anyhow::anyhow!("Cannot resolve '{}': {}", raw_path, e))?;
92
93    validate_within_workspace(workspace, &resolved, raw_path)
94}
95
96pub fn resolve_workspace_write_path(workspace: &Path, raw_path: &str) -> anyhow::Result<PathBuf> {
97    let path = raw_path.trim();
98    if path.is_empty() {
99        anyhow::bail!("Path is required");
100    }
101    if path.starts_with('~') {
102        anyhow::bail!("Home directory expansion (~) is disabled for security.");
103    }
104
105    let requested = if Path::new(path).is_absolute() {
106        PathBuf::from(path)
107    } else {
108        workspace.join(path)
109    };
110    let resolved = resolve_write_candidate(&requested);
111    validate_within_workspace(workspace, &resolved, raw_path)
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117
118    #[test]
119    fn rejects_absolute_escape() {
120        let tmp = tempfile::tempdir().unwrap();
121        let ws = tmp.path().join("workspace");
122        std::fs::create_dir_all(&ws).unwrap();
123        let err = resolve_workspace_write_path(&ws, "/etc/passwd").unwrap_err();
124        assert!(err.to_string().contains("outside the workspace"));
125    }
126
127    #[test]
128    fn rejects_relative_traversal() {
129        let tmp = tempfile::tempdir().unwrap();
130        let ws = tmp.path().join("workspace");
131        std::fs::create_dir_all(&ws).unwrap();
132        let outside = tmp.path().join("secrets.txt");
133        std::fs::write(&outside, "secret").unwrap();
134        let err = resolve_workspace_existing_path(&ws, "../secrets.txt").unwrap_err();
135        assert!(err.to_string().contains("outside the workspace"));
136    }
137}