lean_ctx/core/
pathjail.rs1use 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}