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
24const DENIED_FILE_NAMES: &[&str] = &[".env", "apollo.json"];
28
29const DENIED_FILE_PREFIXES: &[&str] = &[".env."];
31
32const DENIED_HOME_DIRS: &[&str] = &[".apollo"];
35
36const DENIED_WORKSPACE_WRITE_DIRS: &[&str] = &[".apollo"];
45
46fn ensure_write_not_denied(
47 workspace: &Path,
48 resolved: &Path,
49 original: &str,
50) -> anyhow::Result<()> {
51 let canonical_workspace = workspace_canonical(workspace);
52 for dir in DENIED_WORKSPACE_WRITE_DIRS {
53 if resolved.starts_with(canonical_workspace.join(dir)) {
54 anyhow::bail!(
55 "Access denied: '{}' is inside the workspace's {} directory, which holds \
56 apollo's own skills, plugins and state.",
57 original,
58 dir
59 );
60 }
61 }
62 Ok(())
63}
64
65fn ensure_not_denied(resolved: &Path, original: &str) -> anyhow::Result<()> {
66 if let Some(name) = resolved.file_name().and_then(|n| n.to_str()) {
67 let denied = DENIED_FILE_NAMES.contains(&name)
68 || DENIED_FILE_PREFIXES
69 .iter()
70 .any(|prefix| name.starts_with(prefix));
71 if denied {
72 anyhow::bail!(
73 "Access denied: '{}' holds apollo's own credentials or configuration.",
74 original
75 );
76 }
77 }
78
79 if let Some(home) = dirs::home_dir() {
80 for dir in DENIED_HOME_DIRS {
81 let denied_root = home.join(dir);
82 if resolved.starts_with(&denied_root) {
83 anyhow::bail!(
84 "Access denied: '{}' is inside {}, which holds apollo's own credentials.",
85 original,
86 denied_root.display()
87 );
88 }
89 }
90 }
91
92 Ok(())
93}
94
95fn resolve_write_candidate(requested: &Path) -> PathBuf {
96 let mut prefix = if requested.is_absolute() {
97 PathBuf::from(std::path::MAIN_SEPARATOR.to_string())
98 } else {
99 PathBuf::new()
100 };
101 let mut canonical_prefix = prefix.clone();
102 let mut remainder = PathBuf::new();
103 let mut saw_missing_component = false;
104
105 for component in requested.components() {
106 match component {
107 Component::RootDir | Component::Prefix(_) => {}
108 Component::CurDir => {}
109 Component::ParentDir => {
110 if saw_missing_component {
111 remainder.pop();
112 } else {
113 prefix.pop();
114 if prefix.exists() {
115 canonical_prefix = prefix.canonicalize().unwrap_or_else(|_| prefix.clone());
116 }
117 }
118 }
119 Component::Normal(part) => {
120 if saw_missing_component {
121 remainder.push(part);
122 continue;
123 }
124
125 prefix.push(part);
126 if prefix.exists() {
127 canonical_prefix = prefix.canonicalize().unwrap_or_else(|_| prefix.clone());
128 } else {
129 saw_missing_component = true;
130 remainder.push(part);
131 }
132 }
133 }
134 }
135
136 if saw_missing_component {
137 canonical_prefix.join(remainder)
138 } else {
139 canonical_prefix
140 }
141}
142
143pub fn resolve_workspace_existing_path(
144 workspace: &Path,
145 raw_path: &str,
146) -> anyhow::Result<PathBuf> {
147 let path = raw_path.trim();
148 if path.is_empty() {
149 anyhow::bail!("Path is required");
150 }
151 if path.starts_with('~') {
152 anyhow::bail!("Home directory expansion (~) is disabled for security.");
153 }
154
155 let requested = if Path::new(path).is_absolute() {
156 PathBuf::from(path)
157 } else {
158 workspace.join(path)
159 };
160 let resolved = requested
161 .canonicalize()
162 .map_err(|e| anyhow::anyhow!("Cannot resolve '{}': {}", raw_path, e))?;
163
164 ensure_not_denied(&resolved, raw_path)?;
165 validate_within_workspace(workspace, &resolved, raw_path)
166}
167
168pub fn resolve_workspace_write_path(workspace: &Path, raw_path: &str) -> anyhow::Result<PathBuf> {
169 let path = raw_path.trim();
170 if path.is_empty() {
171 anyhow::bail!("Path is required");
172 }
173 if path.starts_with('~') {
174 anyhow::bail!("Home directory expansion (~) is disabled for security.");
175 }
176
177 let requested = if Path::new(path).is_absolute() {
178 PathBuf::from(path)
179 } else {
180 workspace.join(path)
181 };
182 let resolved = resolve_write_candidate(&requested);
183 ensure_not_denied(&resolved, raw_path)?;
184 ensure_write_not_denied(workspace, &resolved, raw_path)?;
185 validate_within_workspace(workspace, &resolved, raw_path)
186}
187
188#[cfg(test)]
189mod tests {
190 use super::*;
191
192 #[test]
193 fn rejects_absolute_escape() {
194 let tmp = tempfile::tempdir().unwrap();
195 let ws = tmp.path().join("workspace");
196 std::fs::create_dir_all(&ws).unwrap();
197 let err = resolve_workspace_write_path(&ws, "/etc/passwd").unwrap_err();
198 assert!(err.to_string().contains("outside the workspace"));
199 }
200
201 #[test]
202 fn refuses_to_read_workspace_secrets() {
203 let tmp = tempfile::tempdir().unwrap();
204 let ws = tmp.path().join("workspace");
205 std::fs::create_dir_all(&ws).unwrap();
206 for name in [".env", ".env.local", "apollo.json"] {
207 std::fs::write(ws.join(name), "secret").unwrap();
208 let err = resolve_workspace_existing_path(&ws, name).unwrap_err();
209 assert!(err.to_string().contains("Access denied"), "{name}: {err}");
210 }
211 }
212
213 #[test]
214 fn refuses_to_write_workspace_secrets() {
215 let tmp = tempfile::tempdir().unwrap();
216 let ws = tmp.path().join("workspace");
217 std::fs::create_dir_all(&ws).unwrap();
218 for name in [".env", ".env.production", "apollo.json", "sub/.env"] {
219 let err = resolve_workspace_write_path(&ws, name).unwrap_err();
220 assert!(err.to_string().contains("Access denied"), "{name}: {err}");
221 }
222 }
223
224 #[test]
225 fn allows_ordinary_workspace_files() {
226 let tmp = tempfile::tempdir().unwrap();
227 let ws = tmp.path().join("workspace");
228 std::fs::create_dir_all(&ws).unwrap();
229 std::fs::write(ws.join("notes.md"), "hello").unwrap();
230 resolve_workspace_existing_path(&ws, "notes.md").unwrap();
231 resolve_workspace_write_path(&ws, "sub/new.txt").unwrap();
232 resolve_workspace_write_path(&ws, "environment.json").unwrap();
233 }
234
235 #[test]
236 fn refuses_to_write_anywhere_under_the_workspace_apollo_dir() {
237 let tmp = tempfile::tempdir().unwrap();
238 let ws = tmp.path().join("workspace");
239 std::fs::create_dir_all(&ws).unwrap();
240 for path in [
241 ".apollo/skills/evil/SKILL.md",
242 ".apollo/plugins/manifest.json",
243 ".apollo/state.surreal",
244 ".apollo/graph_memory.json",
245 "./.apollo/skills/x/SKILL.md",
246 "sub/../.apollo/skills/x/SKILL.md",
247 ] {
248 let err = resolve_workspace_write_path(&ws, path).unwrap_err();
249 assert!(err.to_string().contains("Access denied"), "{path}: {err}");
250 }
251 let absolute = ws.join(".apollo/skills/evil/SKILL.md");
252 let err = resolve_workspace_write_path(&ws, absolute.to_str().unwrap()).unwrap_err();
253 assert!(err.to_string().contains("Access denied"), "{err}");
254 }
255
256 #[test]
259 fn apollo_deny_does_not_swallow_neighbouring_names() {
260 let tmp = tempfile::tempdir().unwrap();
261 let ws = tmp.path().join("workspace");
262 std::fs::create_dir_all(&ws).unwrap();
263 resolve_workspace_write_path(&ws, ".apollo-notes.md").unwrap();
264 resolve_workspace_write_path(&ws, "src/apollo/main.rs").unwrap();
265 resolve_workspace_write_path(&ws, "docs/.apollos/x.md").unwrap();
266 }
267
268 #[test]
271 fn reads_under_workspace_apollo_still_work() {
272 let tmp = tempfile::tempdir().unwrap();
273 let ws = tmp.path().join("workspace");
274 std::fs::create_dir_all(ws.join(".apollo/skills/demo")).unwrap();
275 std::fs::write(ws.join(".apollo/skills/demo/SKILL.md"), "# demo").unwrap();
276 resolve_workspace_existing_path(&ws, ".apollo/skills/demo/SKILL.md").unwrap();
277 }
278
279 #[test]
280 fn rejects_relative_traversal() {
281 let tmp = tempfile::tempdir().unwrap();
282 let ws = tmp.path().join("workspace");
283 std::fs::create_dir_all(&ws).unwrap();
284 let outside = tmp.path().join("secrets.txt");
285 std::fs::write(&outside, "secret").unwrap();
286 let err = resolve_workspace_existing_path(&ws, "../secrets.txt").unwrap_err();
287 assert!(err.to_string().contains("outside the workspace"));
288 }
289}