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/// The shared on-disk model-capability cache: `<home>/.leviath/model_capabilities.json`.
58///
59/// One path so every surface reads and writes the same file - the daemon writes
60/// it after priming, and a short-lived `lev models`, `lev validate` or serve
61/// handler fills a freshly built registry from it instead of each re-priming to
62/// its own conservative default. Read and written through
63/// `leviath_providers::CapabilityCache`.
64pub fn capability_cache_path() -> Option<PathBuf> {
65    data_dir().map(|d| d.join("model_capabilities.json"))
66}
67
68/// Whether `name` is safe to use as a single path component.
69///
70/// Accepts `[A-Za-z0-9._-]+` and nothing else. Everything a caller supplies as
71/// "the name of a thing" - a blueprint name from a REST body, a run id from a
72/// URL segment - must pass this before it is `join`ed onto a directory, because
73/// `Path::join` does not normalize and does not resist an absolute path:
74///
75/// ```text
76/// agents_dir().join("../../../../tmp/x")   // escapes
77/// agents_dir().join("/etc/cron.d/x")       // replaces the base entirely
78/// ```
79///
80/// That was reachable from `POST /api/blueprints` (arbitrary directory creation
81/// and manifest write) and `DELETE /api/blueprints/{name}` (arbitrary recursive
82/// deletion, via a percent-encoded `..%2f` that decodes after segment matching).
83///
84/// A leading `.` is allowed (dotted names are ordinary) but `.` and `..`
85/// themselves are not, since both are traversal rather than names.
86pub fn is_safe_path_component(name: &str) -> bool {
87    !name.is_empty()
88        && name != "."
89        && name != ".."
90        && name
91            .chars()
92            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
93}
94
95/// Whether `path` still lands inside `root` once symlinks are followed.
96///
97/// A lexical `starts_with` check is not containment. It answers "does this
98/// string begin with that string", and a symlink at `<root>/link` pointing at
99/// `/` satisfies it while pointing anywhere on the filesystem. Every file tool
100/// in Leviath was relying on exactly that check.
101///
102/// `path` need not exist - a `write_file` creating a new file is the common
103/// case. The deepest *existing* ancestor is canonicalized (which is where any
104/// symlink lives) and the unresolved tail re-appended, so a not-yet-created file
105/// under a symlinked parent is still caught.
106///
107/// `root` is canonicalized here too rather than trusted: on macOS `/tmp` is a
108/// symlink to `/private/tmp`, so a root that was stored uncanonicalized would
109/// never prefix-match a canonicalized path and every access would be refused.
110///
111/// This is not TOCTOU-proof - a symlink planted between this call and the
112/// subsequent `open` still wins. Closing that needs `openat`/`O_NOFOLLOW`
113/// throughout. This stops the planted-symlink case, which is the one an agent
114/// can arrange for itself.
115pub fn resolves_within(path: &Path, root: &Path) -> bool {
116    let root = std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
117    match canonicalize_existing_prefix(path) {
118        Some(real) => real.starts_with(&root),
119        // Nothing along the path could be canonicalized at all (no existing
120        // ancestor, not even the filesystem root). Refuse: an unverifiable path
121        // is not a safe one.
122        None => false,
123    }
124}
125
126/// Canonicalize a path for allowlist matching, failing closed.
127///
128/// `~` and `~/rest` against `home`; anything else as written. `None` for a
129/// `~` with no home to expand to, which names nothing checkable.
130pub fn expand_home(text: &str, home: Option<&Path>) -> Option<PathBuf> {
131    if text == "~" {
132        return home.map(Path::to_path_buf);
133    }
134    match text.strip_prefix("~/") {
135        Some(rest) => home.map(|h| h.join(rest)),
136        None => Some(PathBuf::from(text)),
137    }
138}
139
140/// Fold `.` and `..` lexically. `None` when a `..` would climb past the root
141/// or the start of a relative path, which is a path that names nothing a
142/// rule should vouch for.
143pub fn fold_dot_dot(path: &Path) -> Option<PathBuf> {
144    use std::path::Component;
145    let mut out = PathBuf::new();
146    for component in path.components() {
147        match component {
148            Component::CurDir => {}
149            Component::ParentDir => match out.components().next_back() {
150                Some(Component::Normal(_)) => {
151                    out.pop();
152                }
153                _ => return None,
154            },
155            other => out.push(other.as_os_str()),
156        }
157    }
158    Some(out)
159}
160
161/// The same machinery [`resolves_within`] uses, exposed for the `[read_paths]`
162/// resolver in `leviath-tools`: the deepest existing ancestor is
163/// canonicalized (which is where any symlink lives) and the unresolved tail
164/// re-appended. `None` means nothing along the path could be verified, and an
165/// unverifiable path must be refused, never matched.
166pub fn canonicalize_for_match(path: &Path) -> Option<PathBuf> {
167    canonicalize_existing_prefix(path)
168}
169
170/// Canonicalize the deepest existing ancestor of `path` and re-append whatever
171/// tail did not exist, so a path to a not-yet-created file still has any
172/// symlinks in its parents resolved.
173fn canonicalize_existing_prefix(path: &Path) -> Option<PathBuf> {
174    let mut probe = path.to_path_buf();
175    let mut tail: Vec<std::ffi::OsString> = Vec::new();
176    loop {
177        if let Ok(real) = std::fs::canonicalize(&probe) {
178            let mut full = real;
179            // `tail` was pushed leaf-first while walking up, so replay it in
180            // reverse to rebuild the original order.
181            for component in tail.iter().rev() {
182                full.push(component);
183            }
184            return Some(full);
185        }
186        // Both in one step: after `file_name()` succeeds, `parent()` cannot
187        // fail (only a root has no parent, and a root has no file name either),
188        // so two separate `?`s would leave the second permanently uncovered.
189        let (Some(name), Some(parent)) = (probe.file_name(), probe.parent()) else {
190            return None;
191        };
192        let (name, parent) = (name.to_os_string(), parent.to_path_buf());
193        tail.push(name);
194        probe = parent;
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201
202    #[test]
203    fn a_tilde_expands_against_home_or_names_nothing() {
204        let home = Path::new("/home/me");
205        assert_eq!(expand_home("~", Some(home)), Some(home.to_path_buf()));
206        assert_eq!(expand_home("~/x", Some(home)), Some(home.join("x")));
207        assert_eq!(expand_home("~/x", None), None);
208        assert_eq!(expand_home("~", None), None);
209        assert_eq!(expand_home("plain/x", None), Some(PathBuf::from("plain/x")));
210        assert_eq!(expand_home("~user/x", None), Some(PathBuf::from("~user/x")));
211    }
212
213    #[test]
214    fn dot_dot_folds_lexically_and_never_climbs_out() {
215        assert_eq!(
216            fold_dot_dot(Path::new("a/./b/../c")),
217            Some(PathBuf::from("a/c"))
218        );
219        assert_eq!(fold_dot_dot(Path::new("/a/..")), Some(PathBuf::from("/")));
220        // A leading `./` is the one `.` the component walk hands over.
221        assert_eq!(
222            fold_dot_dot(Path::new("./a/../b")),
223            Some(PathBuf::from("b"))
224        );
225        assert_eq!(fold_dot_dot(Path::new("../x")), None);
226        assert_eq!(fold_dot_dot(Path::new("/..")), None);
227        assert_eq!(
228            fold_dot_dot(Path::new("~/x/../y")),
229            Some(PathBuf::from("~/y"))
230        );
231    }
232
233    /// Everything Leviath persists sits under one root, and `LEVIATH_HOME`
234    /// moves all of it together. One resolver, because several with their own
235    /// readings of that variable let a run that believes it is isolated write
236    /// to the real `~/.leviath/config.toml`.
237    #[test]
238    fn every_data_path_follows_leviath_home_together() {
239        temp_env::with_var("LEVIATH_HOME", Some("/tmp/lev-paths-test"), || {
240            let root = PathBuf::from("/tmp/lev-paths-test");
241            assert_eq!(home_dir(), Some(root.clone()));
242            let data = root.join(".leviath");
243            assert_eq!(data_dir(), Some(data.clone()));
244            assert_eq!(tools_dir(), Some(data.join("tools")));
245            assert_eq!(providers_dir(), Some(data.join("providers")));
246            assert_eq!(agents_dir(), Some(data.join("agents")));
247            assert_eq!(
248                capability_cache_path(),
249                Some(data.join("model_capabilities.json"))
250            );
251        });
252    }
253
254    /// Without the override, everything is under the real home's `.leviath`.
255    /// Asserted by shape: CI always has a home, but which one is not this
256    /// module's business.
257    #[test]
258    fn without_the_override_paths_sit_under_the_real_home() {
259        temp_env::with_var_unset("LEVIATH_HOME", || {
260            let home = home_dir().expect("a home directory resolves");
261            let data = data_dir().expect("so does the data dir");
262            assert_eq!(data, home.join(".leviath"));
263            // The global tool-scan directory in particular must sit inside
264            // Leviath's own directory, never a bare `$HOME/tools`: every
265            // `.rhai` file there becomes an executable tool for every agent.
266            assert_eq!(tools_dir(), Some(data.join("tools")));
267            assert!(tools_dir().expect("set").ends_with(".leviath/tools"));
268        });
269    }
270
271    #[test]
272    fn safe_components_are_ordinary_names() {
273        for name in ["coder", "my-agent", "agent_2", "v1.2.3", ".hidden", "a"] {
274            assert!(is_safe_path_component(name), "{name}");
275        }
276    }
277
278    #[test]
279    fn traversal_and_separators_are_refused() {
280        for name in [
281            "",
282            ".",
283            "..",
284            "../evil",
285            "../../../../tmp/x",
286            "/etc/passwd",
287            "a/b",
288            "a\\b",
289            // Percent-encoding decodes before this is called; the decoded form
290            // is what must be rejected.
291            "..%2fevil",
292            // NUL and other control characters truncate paths in C APIs.
293            "a\0b",
294            "a b",
295            "a;rm -rf",
296            // A drive-relative Windows path.
297            "C:evil",
298        ] {
299            assert!(!is_safe_path_component(name), "{name:?} should be refused");
300        }
301    }
302
303    #[test]
304    fn plain_paths_inside_the_root_are_contained() {
305        let dir = tempfile::tempdir().unwrap();
306        let root = dir.path();
307        std::fs::write(root.join("file.txt"), b"x").unwrap();
308        assert!(resolves_within(&root.join("file.txt"), root));
309        // A file that does not exist yet is still contained.
310        assert!(resolves_within(&root.join("new.txt"), root));
311        // ...including under directories that do not exist yet.
312        assert!(resolves_within(&root.join("a/b/c.txt"), root));
313    }
314
315    #[test]
316    fn a_path_outside_the_root_is_not_contained() {
317        let dir = tempfile::tempdir().unwrap();
318        let other = tempfile::tempdir().unwrap();
319        assert!(!resolves_within(other.path(), dir.path()));
320    }
321
322    /// The case a lexical `starts_with` check cannot see: the path is textually
323    /// inside the root the whole way, and still reads `/etc/passwd`.
324    #[cfg(unix)]
325    #[test]
326    fn a_symlink_out_of_the_root_is_not_contained() {
327        let dir = tempfile::tempdir().unwrap();
328        let root = dir.path();
329        let link = root.join("link");
330        std::os::unix::fs::symlink("/", &link).unwrap();
331
332        // Lexically this is impeccable - and that was the whole problem.
333        let target = link.join("etc/passwd");
334        assert!(
335            target.starts_with(root),
336            "precondition: textually contained"
337        );
338        assert!(!resolves_within(&target, root));
339    }
340
341    /// A symlink whose target is *inside* the root is fine - the check is about
342    /// where the path lands, not whether a symlink was involved.
343    #[cfg(unix)]
344    #[test]
345    fn a_symlink_within_the_root_is_contained() {
346        let dir = tempfile::tempdir().unwrap();
347        let root = dir.path();
348        std::fs::create_dir(root.join("real")).unwrap();
349        std::fs::write(root.join("real/file.txt"), b"x").unwrap();
350        std::os::unix::fs::symlink(root.join("real"), root.join("link")).unwrap();
351        assert!(resolves_within(&root.join("link/file.txt"), root));
352    }
353
354    /// A not-yet-created file *under* an escaping symlink is caught too: the
355    /// symlink is in the parent, which is where canonicalization starts.
356    #[cfg(unix)]
357    #[test]
358    fn a_new_file_under_an_escaping_symlink_is_not_contained() {
359        let dir = tempfile::tempdir().unwrap();
360        let outside = tempfile::tempdir().unwrap();
361        let root = dir.path();
362        std::os::unix::fs::symlink(outside.path(), root.join("link")).unwrap();
363        assert!(!resolves_within(&root.join("link/brand-new.txt"), root));
364    }
365
366    /// macOS puts temp dirs under a symlinked `/tmp`, so an uncanonicalized root
367    /// must still work - canonicalizing only the path and not the root would
368    /// refuse every access on that platform.
369    #[test]
370    fn an_uncanonicalized_root_still_matches() {
371        let dir = tempfile::tempdir().unwrap();
372        let root = dir.path();
373        std::fs::write(root.join("f.txt"), b"x").unwrap();
374        // `dir.path()` is already whatever the OS handed us; canonicalizing the
375        // path alone would diverge from it on macOS.
376        assert!(resolves_within(&root.join("f.txt"), root));
377    }
378
379    /// The `[read_paths]` resolver's canonicalizer is the same machinery as
380    /// `resolves_within`, exposed fail-closed: a real path resolves (symlinks
381    /// and all), an unverifiable one is `None`.
382    #[test]
383    fn canonicalize_for_match_resolves_real_paths_and_refuses_unverifiable_ones() {
384        let dir = tempfile::tempdir().unwrap();
385        std::fs::write(dir.path().join("f.txt"), b"x").unwrap();
386        assert_eq!(
387            canonicalize_for_match(&dir.path().join("f.txt")),
388            Some(std::fs::canonicalize(dir.path().join("f.txt")).unwrap())
389        );
390        assert_eq!(
391            canonicalize_for_match(Path::new("no-such-relative-name")),
392            None
393        );
394    }
395
396    #[test]
397    fn a_relative_path_with_no_existing_ancestor_is_refused() {
398        // A bare relative name has no existing ancestor to canonicalize once the
399        // parent chain runs out, so it cannot be verified and is refused.
400        assert!(!resolves_within(
401            Path::new("nonexistent-relative"),
402            Path::new("/definitely/not/here")
403        ));
404    }
405}