Skip to main content

locode_host/
path.rs

1//! The path jail: resolve a model-supplied path under the workspace root, or reject it.
2
3use std::io::ErrorKind;
4use std::path::{Component, Path, PathBuf};
5
6use crate::{Host, PathPolicy};
7
8/// A path-jail failure.
9#[derive(Debug, thiserror::Error)]
10pub enum PathError {
11    /// The path resolves outside the workspace root.
12    #[error("path escapes the workspace root: {0}")]
13    Escape(String),
14    /// The workspace root itself is invalid (does not exist / cannot canonicalize).
15    #[error("workspace root is invalid: {0}")]
16    InvalidRoot(String),
17    /// An IO error while resolving.
18    #[error("io error resolving {path}: {source}")]
19    Io {
20        /// The path being resolved.
21        path: String,
22        /// The underlying IO error.
23        source: std::io::Error,
24    },
25}
26
27impl Host {
28    /// Resolve `candidate` (absolute, `~`-prefixed, or relative to `cwd`) to a concrete
29    /// absolute path.
30    ///
31    /// A leading `~` or `~/` is expanded against `$HOME` **before** anything else, so a
32    /// model-supplied `~/.config/x` names the real home path instead of a literal `~`
33    /// directory under `cwd`. Only `~` and `~/…` expand; `~user` is left alone, and with
34    /// no `$HOME` the path is unchanged. Expansion is not a permission:
35    /// the expanded path still faces the jail below, and under [`PathPolicy::Jailed`] a
36    /// home path outside the workspace is rejected as an escape — the point is that the
37    /// rejection now names the path the caller actually meant.
38    ///
39    /// Under [`PathPolicy::Jailed`] the result is guaranteed to live under the workspace
40    /// root — `..`, absolute, and **symlink** escapes are rejected — while paths whose
41    /// leaf does not yet exist (create-new-file) are still allowed. Under
42    /// [`PathPolicy::Unrestricted`] the path is only made absolute; nothing is rejected.
43    ///
44    /// # Errors
45    /// [`PathError::Escape`] if a jailed path escapes the root; [`PathError::Io`] if an
46    /// ancestor cannot be canonicalized.
47    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        // (1) Lexical prefix check — catches `../etc/passwd`, `/etc/passwd`, `a/../../b`.
65        //     A path may sit under the workspace root OR any `--add-dir` root; both
66        //     lists are already canonical, and both checks below use the same set so a
67        //     symlink cannot enter through one root and escape via another.
68        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        // (2) Symlink check — canonicalize the deepest *existing* ancestor and confirm it
72        // is still under the (already-canonical) root. Catches an in-jail symlink that
73        // points out, while still allowing a not-yet-existing leaf.
74        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    /// Whether `path` sits under the workspace root or any `--add-dir` root.
88    ///
89    /// Roots are canonical by construction ([`Host::new`]), so this is a pure
90    /// prefix test. Extra roots widen the jail additively: they never relax the
91    /// checks applied to the primary root, and a path outside every root is still
92    /// an escape.
93    fn is_under_a_root(&self, path: &Path) -> bool {
94        if path.starts_with(&self.workspace_root) {
95            return true;
96        }
97        // The guard is taken and dropped inside this call — never held across
98        // the `await` in `resolve_in_jail`.
99        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    /// The lexical pre-check's second form: roots as the caller wrote them.
107    ///
108    /// Only ever *widens* step (1), which is a cheap filter — step (2) then
109    /// canonicalizes and re-checks against the canonical roots, so a symlink
110    /// that actually leaves every root is still rejected.
111    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
120/// The user's home directory (`$HOME`), or `None` when it is unset or empty.
121///
122/// Deliberately `$HOME` and **not** `$LOCODE_HOME`: `~` is the OS-level home in every
123/// shell and every surveyed harness, while `$LOCODE_HOME` (`crate::locode_home`) only
124/// relocates *our* dotfolder. Conflating them would make `~/x` mean different files
125/// depending on an unrelated override.
126fn home_dir() -> Option<PathBuf> {
127    std::env::var_os("HOME")
128        .filter(|h| !h.is_empty())
129        .map(PathBuf::from)
130}
131
132/// Expand a leading `~` / `~/…` against `home`, borrowing when there is nothing to do.
133///
134/// Only the bare `~` and the `~/` prefix are recognized — `~user` is left untouched, as
135/// in both reference harnesses (Claude Code's `expandPath`, `src/utils/path.ts:57-64`,
136/// and grok's `shellexpand::tilde`), because resolving another user's home needs a
137/// passwd lookup we have no reason to perform. With no `$HOME` the path is returned
138/// unchanged rather than guessed at.
139fn expand_home_prefix<'a>(path: &'a Path, home: Option<&Path>) -> std::borrow::Cow<'a, Path> {
140    use std::borrow::Cow;
141
142    // Operate on the raw bytes/str form: `Path::components` would already have split
143    // `~/foo` into `~` + `foo`, and a bare `~` must map to the home dir itself.
144    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        // `~/` alone (and `~//x`) would `join("")`/absolutize oddly; trim leading slashes
155        // so the result is always `<home>/<rest>`.
156        Some(rest) => Cow::Owned(home.join(rest.trim_start_matches('/'))),
157        None => Cow::Borrowed(path),
158    }
159}
160
161/// Crate-internal alias so `Host::new` can normalize roots before canonicalizing.
162pub(crate) fn normalize_lexical_pub(path: &Path) -> PathBuf {
163    normalize_lexical(path)
164}
165
166/// Syntactically resolve `.`/`..` without touching the filesystem.
167fn 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
181/// Canonicalize the deepest ancestor of `path` that actually exists (symlink-resolving).
182async 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    /// `--add-dir` widens the jail additively: a path under an extra root
203    /// resolves, while anything outside *every* root is still an escape.
204    #[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        // The primary root still works.
216        host.resolve_in_jail(&cwd, Path::new("in_main.txt"))
217            .await
218            .expect("primary root still resolves");
219
220        // The added root is reachable by absolute path, including a leaf that
221        // does not exist yet (create-new-file must keep working there).
222        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        // Everything else is still an escape — widening is not disabling.
228        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    /// `/add-dir` widens a **running** host: a path rejected a moment ago
242    /// resolves once its root is added, without rebuilding anything.
243    #[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        // Idempotent: repeating the command must not error or duplicate.
263        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        // A clone shares the list — tools already hold their own `Arc<Host>`.
270        let clone = host.clone();
271        clone
272            .resolve_in_jail(&cwd, &target)
273            .await
274            .expect("clones see the widened jail");
275
276        // Widening one root does not open others.
277        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    /// A bad path leaves the jail untouched.
286    #[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    /// A non-existent `--add-dir` fails at construction, naming the directory —
297    /// never a silently narrower jail.
298    #[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        // `~user` needs a passwd lookup we do not do; `~` mid-path is an ordinary name;
326        // and a file literally called `~x` must not become a home path.
327        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        // The reported bug: `~/…` took the relative branch and became `<cwd>/~/…`, a
349        // path that sits *inside* the jail, so both checks passed and the read failed
350        // with a bogus "not found" for a directory literally named `~`.
351        let Some(home) = home_dir() else {
352            return; // no $HOME in this environment; nothing to assert
353        };
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        // Expansion is not a permission: outside the workspace, `~/…` is still an
369        // escape — but the error now names the home path the caller meant.
370        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        // Relative resolves against cwd.
421        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    // `std::os::unix::fs::symlink` — does not even compile off unix.
431    #[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        // A symlink inside the jail pointing outside it.
440        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    // `/etc/hostname` and rootless absolute-path semantics are unix-only.
449    #[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}