1use std::io::ErrorKind;
4use std::path::{Component, Path, PathBuf};
5
6use crate::{Host, PathPolicy};
7
8#[derive(Debug, thiserror::Error)]
10pub enum PathError {
11 #[error("path escapes the workspace root: {0}")]
13 Escape(String),
14 #[error("workspace root is invalid: {0}")]
16 InvalidRoot(String),
17 #[error("io error resolving {path}: {source}")]
19 Io {
20 path: String,
22 source: std::io::Error,
24 },
25}
26
27impl Host {
28 pub async fn resolve_in_jail(
48 &self,
49 cwd: &Path,
50 candidate: &Path,
51 ) -> Result<PathBuf, PathError> {
52 let candidate = expand_home_prefix(candidate, home_dir().as_deref());
53 let absolute = if candidate.is_absolute() {
54 candidate.to_path_buf()
55 } else {
56 cwd.join(&*candidate)
57 };
58 let normalized = normalize_lexical(&absolute);
59
60 if self.path_policy == PathPolicy::Unrestricted {
61 return Ok(normalized);
62 }
63
64 if !self.is_under_a_root(&normalized) && !self.is_under_a_root_as_given(&normalized) {
69 return Err(PathError::Escape(normalized.display().to_string()));
70 }
71 let canonical_ancestor =
75 canonicalize_existing_ancestor(&normalized)
76 .await
77 .map_err(|source| PathError::Io {
78 path: normalized.display().to_string(),
79 source,
80 })?;
81 if !self.is_under_a_root(&canonical_ancestor) {
82 return Err(PathError::Escape(normalized.display().to_string()));
83 }
84 Ok(normalized)
85 }
86
87 fn is_under_a_root(&self, path: &Path) -> bool {
94 if path.starts_with(&self.workspace_root) {
95 return true;
96 }
97 let roots = self
100 .extra_roots
101 .read()
102 .unwrap_or_else(std::sync::PoisonError::into_inner);
103 roots.iter().any(|root| path.starts_with(root))
104 }
105
106 fn is_under_a_root_as_given(&self, path: &Path) -> bool {
112 let roots = self
113 .roots_as_given
114 .read()
115 .unwrap_or_else(std::sync::PoisonError::into_inner);
116 roots.iter().any(|root| path.starts_with(root))
117 }
118}
119
120fn home_dir() -> Option<PathBuf> {
127 std::env::var_os("HOME")
128 .filter(|h| !h.is_empty())
129 .map(PathBuf::from)
130}
131
132fn expand_home_prefix<'a>(path: &'a Path, home: Option<&Path>) -> std::borrow::Cow<'a, Path> {
140 use std::borrow::Cow;
141
142 let Some(raw) = path.to_str() else {
145 return Cow::Borrowed(path);
146 };
147 let Some(home) = home else {
148 return Cow::Borrowed(path);
149 };
150 if raw == "~" {
151 return Cow::Owned(home.to_path_buf());
152 }
153 match raw.strip_prefix("~/") {
154 Some(rest) => Cow::Owned(home.join(rest.trim_start_matches('/'))),
157 None => Cow::Borrowed(path),
158 }
159}
160
161pub(crate) fn normalize_lexical_pub(path: &Path) -> PathBuf {
163 normalize_lexical(path)
164}
165
166fn normalize_lexical(path: &Path) -> PathBuf {
168 let mut out = PathBuf::new();
169 for component in path.components() {
170 match component {
171 Component::ParentDir => {
172 out.pop();
173 }
174 Component::CurDir => {}
175 other => out.push(other.as_os_str()),
176 }
177 }
178 out
179}
180
181async fn canonicalize_existing_ancestor(path: &Path) -> std::io::Result<PathBuf> {
183 let mut current = path;
184 loop {
185 match tokio::fs::canonicalize(current).await {
186 Ok(canonical) => return Ok(canonical),
187 Err(e) if e.kind() == ErrorKind::NotFound => match current.parent() {
188 Some(parent) => current = parent,
189 None => return Err(e),
190 },
191 Err(e) => return Err(e),
192 }
193 }
194}
195
196#[cfg(test)]
197mod tests {
198 use super::*;
199 use crate::{PathPolicy, test_host};
200 use tempfile::tempdir;
201
202 #[tokio::test]
205 async fn extra_roots_widen_the_jail_without_opening_it() {
206 let main = tempdir().expect("main root");
207 let extra = tempdir().expect("extra root");
208 let outside = tempdir().expect("unrelated dir");
209
210 let mut config = crate::HostConfig::new(main.path());
211 config.extra_roots = vec![extra.path().to_path_buf()];
212 let host = crate::Host::new(config).expect("host with an extra root");
213 let cwd = host.workspace_root().to_path_buf();
214
215 host.resolve_in_jail(&cwd, Path::new("in_main.txt"))
217 .await
218 .expect("primary root still resolves");
219
220 let added_file = extra.path().join("subdir/new.txt");
223 host.resolve_in_jail(&cwd, &added_file)
224 .await
225 .expect("a path under an --add-dir root resolves");
226
227 let err = host
229 .resolve_in_jail(&cwd, &outside.path().join("secret.txt"))
230 .await
231 .expect_err("an unrelated directory is still out of bounds");
232 assert!(matches!(err, PathError::Escape(_)), "{err:?}");
233
234 let err = host
235 .resolve_in_jail(&cwd, Path::new("/etc/passwd"))
236 .await
237 .expect_err("absolute escapes are unaffected by extra roots");
238 assert!(matches!(err, PathError::Escape(_)), "{err:?}");
239 }
240
241 #[tokio::test]
244 async fn add_root_widens_a_live_host() {
245 let main = tempdir().expect("main root");
246 let later = tempdir().expect("root added later");
247 let host = test_host(main.path(), PathPolicy::Jailed, false);
248 let cwd = host.workspace_root().to_path_buf();
249 let target = later.path().join("file.txt");
250
251 let err = host
252 .resolve_in_jail(&cwd, &target)
253 .await
254 .expect_err("out of bounds before the root is added");
255 assert!(matches!(err, PathError::Escape(_)), "{err:?}");
256
257 host.add_root(later.path()).expect("root added");
258 host.resolve_in_jail(&cwd, &target)
259 .await
260 .expect("reachable once added");
261
262 host.add_root(later.path())
264 .expect("adding twice is a no-op");
265 host.resolve_in_jail(&cwd, &target)
266 .await
267 .expect("still fine");
268
269 let clone = host.clone();
271 clone
272 .resolve_in_jail(&cwd, &target)
273 .await
274 .expect("clones see the widened jail");
275
276 let elsewhere = tempdir().expect("unrelated");
278 let err = host
279 .resolve_in_jail(&cwd, &elsewhere.path().join("x"))
280 .await
281 .expect_err("unrelated dirs stay out");
282 assert!(matches!(err, PathError::Escape(_)), "{err:?}");
283 }
284
285 #[tokio::test]
287 async fn add_root_rejects_a_missing_directory() {
288 let main = tempdir().expect("main root");
289 let host = test_host(main.path(), PathPolicy::Jailed, false);
290 let err = host
291 .add_root(&main.path().join("nope"))
292 .expect_err("missing directory");
293 assert!(matches!(err, PathError::InvalidRoot(msg) if msg.contains("nope")));
294 }
295
296 #[test]
299 fn a_missing_extra_root_is_a_construction_error() {
300 let main = tempdir().expect("main root");
301 let mut config = crate::HostConfig::new(main.path());
302 config.extra_roots = vec![main.path().join("does-not-exist")];
303 let err = crate::Host::new(config).expect_err("must not silently drop the root");
304 assert!(matches!(err, PathError::InvalidRoot(msg) if msg.contains("does-not-exist")));
305 }
306
307 #[test]
308 fn expands_bare_tilde_and_tilde_slash() {
309 let home = Path::new("/home/u");
310 let cases = [
311 ("~", "/home/u"),
312 ("~/", "/home/u"),
313 ("~/.locode/settings.json", "/home/u/.locode/settings.json"),
314 ("~//nested", "/home/u/nested"),
315 ];
316 for (raw, want) in cases {
317 let got = expand_home_prefix(Path::new(raw), Some(home));
318 assert_eq!(normalize_lexical(&got), Path::new(want), "{raw}");
319 }
320 }
321
322 #[test]
323 fn leaves_non_home_prefixes_alone() {
324 let home = Path::new("/home/u");
325 for raw in [
328 "~root/x",
329 "~x",
330 "a/~/b",
331 "./~",
332 "relative/path",
333 "/abs/path",
334 ] {
335 let got = expand_home_prefix(Path::new(raw), Some(home));
336 assert_eq!(got.as_ref(), Path::new(raw), "{raw} must be untouched");
337 }
338 }
339
340 #[test]
341 fn without_home_the_path_is_unchanged() {
342 let got = expand_home_prefix(Path::new("~/x"), None);
343 assert_eq!(got.as_ref(), Path::new("~/x"));
344 }
345
346 #[tokio::test]
347 async fn tilde_resolves_to_the_real_home_not_a_cwd_subdir() {
348 let Some(home) = home_dir() else {
352 return; };
354 let dir = tempdir().unwrap();
355 let host = test_host(dir.path(), PathPolicy::Unrestricted, false);
356 let cwd = host.workspace_root().to_path_buf();
357
358 let resolved = host
359 .resolve_in_jail(&cwd, Path::new("~/.locode/settings.json"))
360 .await
361 .expect("unrestricted resolve");
362 assert_eq!(resolved, home.join(".locode/settings.json"));
363 assert!(!resolved.starts_with(&cwd), "must not land under cwd");
364 }
365
366 #[tokio::test]
367 async fn jailed_tilde_escape_names_the_expanded_path() {
368 let Some(home) = home_dir() else {
371 return;
372 };
373 let dir = tempdir().unwrap();
374 let host = test_host(dir.path(), PathPolicy::Jailed, false);
375 let cwd = host.workspace_root().to_path_buf();
376
377 let err = host
378 .resolve_in_jail(&cwd, Path::new("~/.locode/settings.json"))
379 .await
380 .expect_err("home is outside the workspace root");
381 match err {
382 PathError::Escape(shown) => {
383 assert!(shown.starts_with(&home.display().to_string()), "{shown}");
384 assert!(
385 !shown.contains('~'),
386 "the literal tilde must be gone: {shown}"
387 );
388 }
389 other => panic!("expected Escape, got {other:?}"),
390 }
391 }
392
393 #[tokio::test]
394 async fn rejects_parent_and_absolute_escapes() {
395 let dir = tempdir().unwrap();
396 let host = test_host(dir.path(), PathPolicy::Jailed, false);
397 let root = host.workspace_root().to_path_buf();
398
399 for bad in ["../etc/passwd", "/etc/passwd", "a/../../b"] {
400 let err = host
401 .resolve_in_jail(&root, Path::new(bad))
402 .await
403 .expect_err(bad);
404 assert!(matches!(err, PathError::Escape(_)), "{bad} should escape");
405 }
406 }
407
408 #[tokio::test]
409 async fn allows_paths_in_jail_including_nonexistent_leaf() {
410 let dir = tempdir().unwrap();
411 let host = test_host(dir.path(), PathPolicy::Jailed, false);
412 let root = host.workspace_root().to_path_buf();
413
414 let resolved = host
415 .resolve_in_jail(&root, Path::new("newdir/new.txt"))
416 .await
417 .expect("nonexistent leaf under root is allowed");
418 assert!(resolved.starts_with(&root));
419
420 let sub = root.join("sub");
422 let resolved = host
423 .resolve_in_jail(&sub, Path::new("f.txt"))
424 .await
425 .expect("relative under cwd");
426 assert_eq!(resolved, sub.join("f.txt"));
427 }
428
429 #[cfg(unix)]
430 #[cfg(unix)]
432 #[tokio::test]
433 async fn rejects_symlink_escape() {
434 let dir = tempdir().unwrap();
435 let outside = tempdir().unwrap();
436 let host = test_host(dir.path(), PathPolicy::Jailed, false);
437 let root = host.workspace_root().to_path_buf();
438
439 std::os::unix::fs::symlink(outside.path(), root.join("link")).unwrap();
441 let err = host
442 .resolve_in_jail(&root, Path::new("link/secret.txt"))
443 .await
444 .expect_err("symlink escape");
445 assert!(matches!(err, PathError::Escape(_)));
446 }
447
448 #[cfg(unix)]
450 #[tokio::test]
451 async fn unrestricted_allows_escapes() {
452 let dir = tempdir().unwrap();
453 let host = test_host(dir.path(), PathPolicy::Unrestricted, false);
454 let root = host.workspace_root().to_path_buf();
455
456 let resolved = host
457 .resolve_in_jail(&root, Path::new("/etc/hostname"))
458 .await
459 .expect("unrestricted allows absolute out-of-root");
460 assert_eq!(resolved, Path::new("/etc/hostname"));
461 }
462}