Skip to main content

leviath_core/
paths.rs

1//! Path containment, and the one definition of where Leviath's data lives.
2
3use std::path::{Path, PathBuf};
4
5/// The user's home directory, honoring the `LEVIATH_HOME` override.
6///
7/// `dirs::home_dir()` cannot be redirected by `$HOME` on macOS
8/// (`NSHomeDirectory()`) or `%USERPROFILE%` on Windows
9/// (`SHGetKnownFolderPath`), so `LEVIATH_HOME` is the single override every
10/// home-relative path honors - which is what lets a test (including one that
11/// spawns the real `lev` binary) redirect all of them at once.
12///
13/// `None` when neither resolves; callers decide how to report that rather than
14/// this silently picking a surprising fallback.
15pub fn home_dir() -> Option<PathBuf> {
16    if let Some(override_home) = std::env::var_os("LEVIATH_HOME") {
17        return Some(PathBuf::from(override_home));
18    }
19    dirs::home_dir()
20}
21
22/// Leviath's data root: `<home>/.leviath`.
23///
24/// Every persistent thing Leviath owns lives under here - `config.toml`,
25/// `mcp-auth.json`, `runs/`, `agents/`, `providers/`, `tools/`, the control
26/// socket. Having one function say so is the point: there were seven separate
27/// resolvers with **three incompatible readings of `LEVIATH_HOME`**, so with the
28/// override set the MCP OAuth token store landed in a different directory from
29/// the config that names those servers, and `lev serve` could not see agents
30/// `lev add` had just installed.
31pub fn data_dir() -> Option<PathBuf> {
32    home_dir().map(|home| home.join(".leviath"))
33}
34
35/// The directory scanned for global drop-in Rhai *tool* scripts.
36///
37/// `<home>/.leviath/tools`, alongside `providers/` and `agents/` - not
38/// `<home>/tools`, which is where three call sites landed by joining `"tools"`
39/// onto the *user home* rather than the data root. That mattered: every `.rhai`
40/// file found here is compiled and offered to **every** agent as an executable
41/// tool, and `$HOME/tools` is an ordinary, non-hidden directory a developer may
42/// well already have or that other software may write into.
43pub fn tools_dir() -> Option<PathBuf> {
44    data_dir().map(|d| d.join("tools"))
45}
46
47/// The directory holding drop-in Rhai *provider* scripts.
48pub fn providers_dir() -> Option<PathBuf> {
49    data_dir().map(|d| d.join("providers"))
50}
51
52/// The directory installed agents are unpacked into.
53pub fn agents_dir() -> Option<PathBuf> {
54    data_dir().map(|d| d.join("agents"))
55}
56
57/// Whether `name` is safe to use as a single path component.
58///
59/// Accepts `[A-Za-z0-9._-]+` and nothing else. Everything a caller supplies as
60/// "the name of a thing" - a blueprint name from a REST body, a run id from a
61/// URL segment - must pass this before it is `join`ed onto a directory, because
62/// `Path::join` does not normalize and does not resist an absolute path:
63///
64/// ```text
65/// agents_dir().join("../../../../tmp/x")   // escapes
66/// agents_dir().join("/etc/cron.d/x")       // replaces the base entirely
67/// ```
68///
69/// That was reachable from `POST /api/blueprints` (arbitrary directory creation
70/// and manifest write) and `DELETE /api/blueprints/{name}` (arbitrary recursive
71/// deletion, via a percent-encoded `..%2f` that decodes after segment matching).
72///
73/// A leading `.` is allowed (dotted names are ordinary) but `.` and `..`
74/// themselves are not, since both are traversal rather than names.
75pub fn is_safe_path_component(name: &str) -> bool {
76    !name.is_empty()
77        && name != "."
78        && name != ".."
79        && name
80            .chars()
81            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
82}
83
84/// Whether `path` still lands inside `root` once symlinks are followed.
85///
86/// A lexical `starts_with` check is not containment. It answers "does this
87/// string begin with that string", and a symlink at `<root>/link` pointing at
88/// `/` satisfies it while pointing anywhere on the filesystem. Every file tool
89/// in Leviath was relying on exactly that check.
90///
91/// `path` need not exist - a `write_file` creating a new file is the common
92/// case. The deepest *existing* ancestor is canonicalized (which is where any
93/// symlink lives) and the unresolved tail re-appended, so a not-yet-created file
94/// under a symlinked parent is still caught.
95///
96/// `root` is canonicalized here too rather than trusted: on macOS `/tmp` is a
97/// symlink to `/private/tmp`, so a root that was stored uncanonicalized would
98/// never prefix-match a canonicalized path and every access would be refused.
99///
100/// This is not TOCTOU-proof - a symlink planted between this call and the
101/// subsequent `open` still wins. Closing that needs `openat`/`O_NOFOLLOW`
102/// throughout. This stops the planted-symlink case, which is the one an agent
103/// can arrange for itself.
104pub fn resolves_within(path: &Path, root: &Path) -> bool {
105    let root = std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
106    match canonicalize_existing_prefix(path) {
107        Some(real) => real.starts_with(&root),
108        // Nothing along the path could be canonicalized at all (no existing
109        // ancestor, not even the filesystem root). Refuse: an unverifiable path
110        // is not a safe one.
111        None => false,
112    }
113}
114
115/// Canonicalize the deepest existing ancestor of `path` and re-append whatever
116/// tail did not exist, so a path to a not-yet-created file still has any
117/// symlinks in its parents resolved.
118fn canonicalize_existing_prefix(path: &Path) -> Option<PathBuf> {
119    let mut probe = path.to_path_buf();
120    let mut tail: Vec<std::ffi::OsString> = Vec::new();
121    loop {
122        if let Ok(real) = std::fs::canonicalize(&probe) {
123            let mut full = real;
124            // `tail` was pushed leaf-first while walking up, so replay it in
125            // reverse to rebuild the original order.
126            for component in tail.iter().rev() {
127                full.push(component);
128            }
129            return Some(full);
130        }
131        // Both in one step: after `file_name()` succeeds, `parent()` cannot
132        // fail (only a root has no parent, and a root has no file name either),
133        // so two separate `?`s would leave the second permanently uncovered.
134        let (Some(name), Some(parent)) = (probe.file_name(), probe.parent()) else {
135            return None;
136        };
137        let (name, parent) = (name.to_os_string(), parent.to_path_buf());
138        tail.push(name);
139        probe = parent;
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    /// Everything Leviath persists sits under one root, and `LEVIATH_HOME`
148    /// moves all of it together. Seven separate resolvers with three readings of
149    /// that variable is what this replaced - and the consequence was concrete: a
150    /// run that believed it was isolated wrote to the real
151    /// `~/.leviath/config.toml`.
152    #[test]
153    fn every_data_path_follows_leviath_home_together() {
154        temp_env::with_var("LEVIATH_HOME", Some("/tmp/lev-paths-test"), || {
155            let root = PathBuf::from("/tmp/lev-paths-test");
156            assert_eq!(home_dir(), Some(root.clone()));
157            let data = root.join(".leviath");
158            assert_eq!(data_dir(), Some(data.clone()));
159            assert_eq!(tools_dir(), Some(data.join("tools")));
160            assert_eq!(providers_dir(), Some(data.join("providers")));
161            assert_eq!(agents_dir(), Some(data.join("agents")));
162        });
163    }
164
165    /// Without the override, everything is under the real home's `.leviath`.
166    /// Asserted by shape: CI always has a home, but which one is not this
167    /// module's business.
168    #[test]
169    fn without_the_override_paths_sit_under_the_real_home() {
170        temp_env::with_var_unset("LEVIATH_HOME", || {
171            let home = home_dir().expect("a home directory resolves");
172            let data = data_dir().expect("so does the data dir");
173            assert_eq!(data, home.join(".leviath"));
174            // The global tool-scan directory in particular must sit inside
175            // Leviath's own directory, never a bare `$HOME/tools`: every
176            // `.rhai` file there becomes an executable tool for every agent.
177            assert_eq!(tools_dir(), Some(data.join("tools")));
178            assert!(tools_dir().expect("set").ends_with(".leviath/tools"));
179        });
180    }
181
182    #[test]
183    fn safe_components_are_ordinary_names() {
184        for name in ["coder", "my-agent", "agent_2", "v1.2.3", ".hidden", "a"] {
185            assert!(is_safe_path_component(name), "{name}");
186        }
187    }
188
189    #[test]
190    fn traversal_and_separators_are_refused() {
191        for name in [
192            "",
193            ".",
194            "..",
195            "../evil",
196            "../../../../tmp/x",
197            "/etc/passwd",
198            "a/b",
199            "a\\b",
200            // Percent-encoding decodes before this is called; the decoded form
201            // is what must be rejected.
202            "..%2fevil",
203            // NUL and other control characters truncate paths in C APIs.
204            "a\0b",
205            "a b",
206            "a;rm -rf",
207            // A drive-relative Windows path.
208            "C:evil",
209        ] {
210            assert!(!is_safe_path_component(name), "{name:?} should be refused");
211        }
212    }
213
214    #[test]
215    fn plain_paths_inside_the_root_are_contained() {
216        let dir = tempfile::tempdir().unwrap();
217        let root = dir.path();
218        std::fs::write(root.join("file.txt"), b"x").unwrap();
219        assert!(resolves_within(&root.join("file.txt"), root));
220        // A file that does not exist yet is still contained.
221        assert!(resolves_within(&root.join("new.txt"), root));
222        // ...including under directories that do not exist yet.
223        assert!(resolves_within(&root.join("a/b/c.txt"), root));
224    }
225
226    #[test]
227    fn a_path_outside_the_root_is_not_contained() {
228        let dir = tempfile::tempdir().unwrap();
229        let other = tempfile::tempdir().unwrap();
230        assert!(!resolves_within(other.path(), dir.path()));
231    }
232
233    /// The case a lexical `starts_with` check cannot see: the path is textually
234    /// inside the root the whole way, and still reads `/etc/passwd`.
235    #[cfg(unix)]
236    #[test]
237    fn a_symlink_out_of_the_root_is_not_contained() {
238        let dir = tempfile::tempdir().unwrap();
239        let root = dir.path();
240        let link = root.join("link");
241        std::os::unix::fs::symlink("/", &link).unwrap();
242
243        // Lexically this is impeccable - and that was the whole problem.
244        let target = link.join("etc/passwd");
245        assert!(
246            target.starts_with(root),
247            "precondition: textually contained"
248        );
249        assert!(!resolves_within(&target, root));
250    }
251
252    /// A symlink whose target is *inside* the root is fine - the check is about
253    /// where the path lands, not whether a symlink was involved.
254    #[cfg(unix)]
255    #[test]
256    fn a_symlink_within_the_root_is_contained() {
257        let dir = tempfile::tempdir().unwrap();
258        let root = dir.path();
259        std::fs::create_dir(root.join("real")).unwrap();
260        std::fs::write(root.join("real/file.txt"), b"x").unwrap();
261        std::os::unix::fs::symlink(root.join("real"), root.join("link")).unwrap();
262        assert!(resolves_within(&root.join("link/file.txt"), root));
263    }
264
265    /// A not-yet-created file *under* an escaping symlink is caught too: the
266    /// symlink is in the parent, which is where canonicalization starts.
267    #[cfg(unix)]
268    #[test]
269    fn a_new_file_under_an_escaping_symlink_is_not_contained() {
270        let dir = tempfile::tempdir().unwrap();
271        let outside = tempfile::tempdir().unwrap();
272        let root = dir.path();
273        std::os::unix::fs::symlink(outside.path(), root.join("link")).unwrap();
274        assert!(!resolves_within(&root.join("link/brand-new.txt"), root));
275    }
276
277    /// macOS puts temp dirs under a symlinked `/tmp`, so an uncanonicalized root
278    /// must still work - canonicalizing only the path and not the root would
279    /// refuse every access on that platform.
280    #[test]
281    fn an_uncanonicalized_root_still_matches() {
282        let dir = tempfile::tempdir().unwrap();
283        let root = dir.path();
284        std::fs::write(root.join("f.txt"), b"x").unwrap();
285        // `dir.path()` is already whatever the OS handed us; canonicalizing the
286        // path alone would diverge from it on macOS.
287        assert!(resolves_within(&root.join("f.txt"), root));
288    }
289
290    #[test]
291    fn a_relative_path_with_no_existing_ancestor_is_refused() {
292        // A bare relative name has no existing ancestor to canonicalize once the
293        // parent chain runs out, so it cannot be verified and is refused.
294        assert!(!resolves_within(
295            Path::new("nonexistent-relative"),
296            Path::new("/definitely/not/here")
297        ));
298    }
299}