Skip to main content

lean_ctx/core/
pathjail.rs

1use std::path::{Path, PathBuf};
2
3fn allow_paths_from_env() -> Vec<PathBuf> {
4    let mut out = Vec::new();
5
6    if let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() {
7        out.push(canonicalize_or_self(&data_dir));
8    }
9
10    let v = std::env::var("LCTX_ALLOW_PATH")
11        .or_else(|_| std::env::var("LEAN_CTX_ALLOW_PATH"))
12        .unwrap_or_default();
13    if v.trim().is_empty() {
14        return out;
15    }
16    for p in std::env::split_paths(&v) {
17        if let Ok(canon) = std::fs::canonicalize(&p) {
18            out.push(canon);
19        } else {
20            out.push(p);
21        }
22    }
23    out
24}
25
26fn is_under_prefix(path: &Path, prefix: &Path) -> bool {
27    path.starts_with(prefix)
28}
29
30fn canonicalize_or_self(path: &Path) -> PathBuf {
31    std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
32}
33
34fn canonicalize_existing_ancestor(path: &Path) -> Option<(PathBuf, Vec<std::ffi::OsString>)> {
35    let mut cur = path.to_path_buf();
36    let mut remainder: Vec<std::ffi::OsString> = Vec::new();
37    loop {
38        if cur.exists() {
39            return Some((canonicalize_or_self(&cur), remainder));
40        }
41        let name = cur.file_name()?.to_os_string();
42        remainder.push(name);
43        if !cur.pop() {
44            return None;
45        }
46    }
47}
48
49pub fn jail_path(candidate: &Path, jail_root: &Path) -> Result<PathBuf, String> {
50    let root = canonicalize_or_self(jail_root);
51    let allow = allow_paths_from_env();
52
53    let (base, remainder) = canonicalize_existing_ancestor(candidate).ok_or_else(|| {
54        format!(
55            "path does not exist and has no existing ancestor: {}",
56            candidate.display()
57        )
58    })?;
59
60    let allowed = is_under_prefix(&base, &root) || allow.iter().any(|p| is_under_prefix(&base, p));
61
62    #[cfg(windows)]
63    let allowed = allowed || is_under_prefix_windows(&base, &root);
64
65    if !allowed {
66        return Err(format!(
67            "path escapes project root: {} (root: {})",
68            candidate.display(),
69            root.display()
70        ));
71    }
72
73    #[cfg(windows)]
74    reject_symlink_on_windows(candidate)?;
75
76    let mut out = base;
77    for part in remainder.iter().rev() {
78        out.push(part);
79    }
80    Ok(out)
81}
82
83#[cfg(windows)]
84fn is_under_prefix_windows(path: &Path, prefix: &Path) -> bool {
85    let path_str = path.to_string_lossy().to_lowercase().replace('/', "\\");
86    let prefix_str = prefix.to_string_lossy().to_lowercase().replace('/', "\\");
87    path_str.starts_with(&prefix_str)
88}
89
90#[cfg(windows)]
91fn reject_symlink_on_windows(path: &Path) -> Result<(), String> {
92    if let Ok(meta) = std::fs::symlink_metadata(path) {
93        if meta.is_symlink() {
94            return Err(format!(
95                "symlink not allowed in jailed path: {}",
96                path.display()
97            ));
98        }
99    }
100    Ok(())
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106
107    #[test]
108    fn rejects_path_outside_root() {
109        let tmp = tempfile::tempdir().unwrap();
110        let root = tmp.path().join("root");
111        let other = tmp.path().join("other");
112        std::fs::create_dir_all(&root).unwrap();
113        std::fs::create_dir_all(&other).unwrap();
114        std::fs::write(root.join("a.txt"), "ok").unwrap();
115        std::fs::write(other.join("b.txt"), "no").unwrap();
116
117        let ok = jail_path(&root.join("a.txt"), &root);
118        assert!(ok.is_ok());
119
120        let bad = jail_path(&other.join("b.txt"), &root);
121        assert!(bad.is_err());
122    }
123
124    #[test]
125    fn allows_nonexistent_child_under_root() {
126        let tmp = tempfile::tempdir().unwrap();
127        let root = tmp.path().join("root");
128        std::fs::create_dir_all(&root).unwrap();
129        std::fs::write(root.join("a.txt"), "ok").unwrap();
130
131        let p = root.join("new").join("file.txt");
132        let ok = jail_path(&p, &root).unwrap();
133        assert!(ok.to_string_lossy().contains("file.txt"));
134    }
135}