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