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        if !normalized.starts_with(&self.workspace_root) {
66            return Err(PathError::Escape(normalized.display().to_string()));
67        }
68        // (2) Symlink check — canonicalize the deepest *existing* ancestor and confirm it
69        // is still under the (already-canonical) root. Catches an in-jail symlink that
70        // points out, while still allowing a not-yet-existing leaf.
71        let canonical_ancestor =
72            canonicalize_existing_ancestor(&normalized)
73                .await
74                .map_err(|source| PathError::Io {
75                    path: normalized.display().to_string(),
76                    source,
77                })?;
78        if !canonical_ancestor.starts_with(&self.workspace_root) {
79            return Err(PathError::Escape(normalized.display().to_string()));
80        }
81        Ok(normalized)
82    }
83}
84
85/// The user's home directory (`$HOME`), or `None` when it is unset or empty.
86///
87/// Deliberately `$HOME` and **not** `$LOCODE_HOME`: `~` is the OS-level home in every
88/// shell and every surveyed harness, while `$LOCODE_HOME` (`crate::locode_home`) only
89/// relocates *our* dotfolder. Conflating them would make `~/x` mean different files
90/// depending on an unrelated override.
91fn home_dir() -> Option<PathBuf> {
92    std::env::var_os("HOME")
93        .filter(|h| !h.is_empty())
94        .map(PathBuf::from)
95}
96
97/// Expand a leading `~` / `~/…` against `home`, borrowing when there is nothing to do.
98///
99/// Only the bare `~` and the `~/` prefix are recognized — `~user` is left untouched, as
100/// in both reference harnesses (Claude Code's `expandPath`, `src/utils/path.ts:57-64`,
101/// and grok's `shellexpand::tilde`), because resolving another user's home needs a
102/// passwd lookup we have no reason to perform. With no `$HOME` the path is returned
103/// unchanged rather than guessed at.
104fn expand_home_prefix<'a>(path: &'a Path, home: Option<&Path>) -> std::borrow::Cow<'a, Path> {
105    use std::borrow::Cow;
106
107    // Operate on the raw bytes/str form: `Path::components` would already have split
108    // `~/foo` into `~` + `foo`, and a bare `~` must map to the home dir itself.
109    let Some(raw) = path.to_str() else {
110        return Cow::Borrowed(path);
111    };
112    let Some(home) = home else {
113        return Cow::Borrowed(path);
114    };
115    if raw == "~" {
116        return Cow::Owned(home.to_path_buf());
117    }
118    match raw.strip_prefix("~/") {
119        // `~/` alone (and `~//x`) would `join("")`/absolutize oddly; trim leading slashes
120        // so the result is always `<home>/<rest>`.
121        Some(rest) => Cow::Owned(home.join(rest.trim_start_matches('/'))),
122        None => Cow::Borrowed(path),
123    }
124}
125
126/// Syntactically resolve `.`/`..` without touching the filesystem.
127fn normalize_lexical(path: &Path) -> PathBuf {
128    let mut out = PathBuf::new();
129    for component in path.components() {
130        match component {
131            Component::ParentDir => {
132                out.pop();
133            }
134            Component::CurDir => {}
135            other => out.push(other.as_os_str()),
136        }
137    }
138    out
139}
140
141/// Canonicalize the deepest ancestor of `path` that actually exists (symlink-resolving).
142async fn canonicalize_existing_ancestor(path: &Path) -> std::io::Result<PathBuf> {
143    let mut current = path;
144    loop {
145        match tokio::fs::canonicalize(current).await {
146            Ok(canonical) => return Ok(canonical),
147            Err(e) if e.kind() == ErrorKind::NotFound => match current.parent() {
148                Some(parent) => current = parent,
149                None => return Err(e),
150            },
151            Err(e) => return Err(e),
152        }
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159    use crate::{PathPolicy, test_host};
160    use tempfile::tempdir;
161
162    #[test]
163    fn expands_bare_tilde_and_tilde_slash() {
164        let home = Path::new("/home/u");
165        let cases = [
166            ("~", "/home/u"),
167            ("~/", "/home/u"),
168            ("~/.locode/settings.json", "/home/u/.locode/settings.json"),
169            ("~//nested", "/home/u/nested"),
170        ];
171        for (raw, want) in cases {
172            let got = expand_home_prefix(Path::new(raw), Some(home));
173            assert_eq!(normalize_lexical(&got), Path::new(want), "{raw}");
174        }
175    }
176
177    #[test]
178    fn leaves_non_home_prefixes_alone() {
179        let home = Path::new("/home/u");
180        // `~user` needs a passwd lookup we do not do; `~` mid-path is an ordinary name;
181        // and a file literally called `~x` must not become a home path.
182        for raw in [
183            "~root/x",
184            "~x",
185            "a/~/b",
186            "./~",
187            "relative/path",
188            "/abs/path",
189        ] {
190            let got = expand_home_prefix(Path::new(raw), Some(home));
191            assert_eq!(got.as_ref(), Path::new(raw), "{raw} must be untouched");
192        }
193    }
194
195    #[test]
196    fn without_home_the_path_is_unchanged() {
197        let got = expand_home_prefix(Path::new("~/x"), None);
198        assert_eq!(got.as_ref(), Path::new("~/x"));
199    }
200
201    #[tokio::test]
202    async fn tilde_resolves_to_the_real_home_not_a_cwd_subdir() {
203        // The reported bug: `~/…` took the relative branch and became `<cwd>/~/…`, a
204        // path that sits *inside* the jail, so both checks passed and the read failed
205        // with a bogus "not found" for a directory literally named `~`.
206        let Some(home) = home_dir() else {
207            return; // no $HOME in this environment; nothing to assert
208        };
209        let dir = tempdir().unwrap();
210        let host = test_host(dir.path(), PathPolicy::Unrestricted, false);
211        let cwd = host.workspace_root().to_path_buf();
212
213        let resolved = host
214            .resolve_in_jail(&cwd, Path::new("~/.locode/settings.json"))
215            .await
216            .expect("unrestricted resolve");
217        assert_eq!(resolved, home.join(".locode/settings.json"));
218        assert!(!resolved.starts_with(&cwd), "must not land under cwd");
219    }
220
221    #[tokio::test]
222    async fn jailed_tilde_escape_names_the_expanded_path() {
223        // Expansion is not a permission: outside the workspace, `~/…` is still an
224        // escape — but the error now names the home path the caller meant.
225        let Some(home) = home_dir() else {
226            return;
227        };
228        let dir = tempdir().unwrap();
229        let host = test_host(dir.path(), PathPolicy::Jailed, false);
230        let cwd = host.workspace_root().to_path_buf();
231
232        let err = host
233            .resolve_in_jail(&cwd, Path::new("~/.locode/settings.json"))
234            .await
235            .expect_err("home is outside the workspace root");
236        match err {
237            PathError::Escape(shown) => {
238                assert!(shown.starts_with(&home.display().to_string()), "{shown}");
239                assert!(
240                    !shown.contains('~'),
241                    "the literal tilde must be gone: {shown}"
242                );
243            }
244            other => panic!("expected Escape, got {other:?}"),
245        }
246    }
247
248    #[tokio::test]
249    async fn rejects_parent_and_absolute_escapes() {
250        let dir = tempdir().unwrap();
251        let host = test_host(dir.path(), PathPolicy::Jailed, false);
252        let root = host.workspace_root().to_path_buf();
253
254        for bad in ["../etc/passwd", "/etc/passwd", "a/../../b"] {
255            let err = host
256                .resolve_in_jail(&root, Path::new(bad))
257                .await
258                .expect_err(bad);
259            assert!(matches!(err, PathError::Escape(_)), "{bad} should escape");
260        }
261    }
262
263    #[tokio::test]
264    async fn allows_paths_in_jail_including_nonexistent_leaf() {
265        let dir = tempdir().unwrap();
266        let host = test_host(dir.path(), PathPolicy::Jailed, false);
267        let root = host.workspace_root().to_path_buf();
268
269        let resolved = host
270            .resolve_in_jail(&root, Path::new("newdir/new.txt"))
271            .await
272            .expect("nonexistent leaf under root is allowed");
273        assert!(resolved.starts_with(&root));
274
275        // Relative resolves against cwd.
276        let sub = root.join("sub");
277        let resolved = host
278            .resolve_in_jail(&sub, Path::new("f.txt"))
279            .await
280            .expect("relative under cwd");
281        assert_eq!(resolved, sub.join("f.txt"));
282    }
283
284    #[cfg(unix)]
285    // `std::os::unix::fs::symlink` — does not even compile off unix.
286    #[cfg(unix)]
287    #[tokio::test]
288    async fn rejects_symlink_escape() {
289        let dir = tempdir().unwrap();
290        let outside = tempdir().unwrap();
291        let host = test_host(dir.path(), PathPolicy::Jailed, false);
292        let root = host.workspace_root().to_path_buf();
293
294        // A symlink inside the jail pointing outside it.
295        std::os::unix::fs::symlink(outside.path(), root.join("link")).unwrap();
296        let err = host
297            .resolve_in_jail(&root, Path::new("link/secret.txt"))
298            .await
299            .expect_err("symlink escape");
300        assert!(matches!(err, PathError::Escape(_)));
301    }
302
303    // `/etc/hostname` and rootless absolute-path semantics are unix-only.
304    #[cfg(unix)]
305    #[tokio::test]
306    async fn unrestricted_allows_escapes() {
307        let dir = tempdir().unwrap();
308        let host = test_host(dir.path(), PathPolicy::Unrestricted, false);
309        let root = host.workspace_root().to_path_buf();
310
311        let resolved = host
312            .resolve_in_jail(&root, Path::new("/etc/hostname"))
313            .await
314            .expect("unrestricted allows absolute out-of-root");
315        assert_eq!(resolved, Path::new("/etc/hostname"));
316    }
317}