lean_ctx/core/
pathjail.rs1use std::path::{Path, PathBuf};
2
3const IDE_CONFIG_DIRS: &[&str] = &[
4 ".lean-ctx",
5 ".cursor",
6 ".claude",
7 ".codex",
8 ".codeium",
9 ".gemini",
10 ".qwen",
11 ".trae",
12 ".kiro",
13 ".verdent",
14 ".pi",
15 ".amp",
16 ".aider",
17 ".continue",
18];
19
20fn allow_paths_from_env_and_config() -> Vec<PathBuf> {
21 let mut out = Vec::new();
22
23 if let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() {
24 out.push(canonicalize_or_self(&data_dir));
25 }
26
27 if let Some(home) = dirs::home_dir() {
28 for dir in IDE_CONFIG_DIRS {
29 let p = home.join(dir);
30 if p.exists() {
31 out.push(canonicalize_or_self(&p));
32 }
33 }
34 }
35
36 let cfg = crate::core::config::Config::load();
37 for p in &cfg.allow_paths {
38 let pb = PathBuf::from(p);
39 out.push(canonicalize_or_self(&pb));
40 }
41
42 let v = std::env::var("LCTX_ALLOW_PATH")
43 .or_else(|_| std::env::var("LEAN_CTX_ALLOW_PATH"))
44 .unwrap_or_default();
45 if v.trim().is_empty() {
46 return out;
47 }
48 for p in std::env::split_paths(&v) {
49 out.push(canonicalize_or_self(&p));
50 }
51 out
52}
53
54fn is_under_prefix(path: &Path, prefix: &Path) -> bool {
55 path.starts_with(prefix)
56}
57
58fn canonicalize_or_self(path: &Path) -> PathBuf {
59 super::pathutil::safe_canonicalize_or_self(path)
60}
61
62fn canonicalize_existing_ancestor(path: &Path) -> Option<(PathBuf, Vec<std::ffi::OsString>)> {
63 let mut cur = path.to_path_buf();
64 let mut remainder: Vec<std::ffi::OsString> = Vec::new();
65 loop {
66 if cur.exists() {
67 return Some((canonicalize_or_self(&cur), remainder));
68 }
69 let name = cur.file_name()?.to_os_string();
70 remainder.push(name);
71 if !cur.pop() {
72 return None;
73 }
74 }
75}
76
77pub fn jail_path(candidate: &Path, jail_root: &Path) -> Result<PathBuf, String> {
78 let root = canonicalize_or_self(jail_root);
79 let allow = allow_paths_from_env_and_config();
80
81 let (base, remainder) = canonicalize_existing_ancestor(candidate).ok_or_else(|| {
82 format!(
83 "path does not exist and has no existing ancestor: {}",
84 candidate.display()
85 )
86 })?;
87
88 let allowed = is_under_prefix(&base, &root) || allow.iter().any(|p| is_under_prefix(&base, p));
89
90 #[cfg(windows)]
91 let allowed = allowed || is_under_prefix_windows(&base, &root);
92
93 if !allowed {
94 return Err(format!(
95 "path escapes project root: {} (root: {}). \
96 Hint: set LEAN_CTX_ALLOW_PATH={} or add it to allow_paths in ~/.lean-ctx/config.toml",
97 candidate.display(),
98 root.display(),
99 candidate.parent().unwrap_or(candidate).display()
100 ));
101 }
102
103 #[cfg(windows)]
104 reject_symlink_on_windows(candidate)?;
105
106 let mut out = base;
107 for part in remainder.iter().rev() {
108 out.push(part);
109 }
110 Ok(out)
111}
112
113#[cfg(windows)]
114fn is_under_prefix_windows(path: &Path, prefix: &Path) -> bool {
115 let path_str = normalize_windows_path(&path.to_string_lossy());
116 let prefix_str = normalize_windows_path(&prefix.to_string_lossy());
117 path_str.starts_with(&prefix_str)
118}
119
120#[cfg(windows)]
121fn normalize_windows_path(s: &str) -> String {
122 let stripped = super::pathutil::strip_verbatim_str(s).unwrap_or_else(|| s.to_string());
123 stripped.to_lowercase().replace('/', "\\")
124}
125
126#[cfg(windows)]
127fn reject_symlink_on_windows(path: &Path) -> Result<(), String> {
128 if let Ok(meta) = std::fs::symlink_metadata(path) {
129 if meta.is_symlink() {
130 return Err(format!(
131 "symlink not allowed in jailed path: {}",
132 path.display()
133 ));
134 }
135 }
136 Ok(())
137}
138
139#[cfg(test)]
140mod tests {
141 use super::*;
142
143 #[test]
144 fn rejects_path_outside_root() {
145 let tmp = tempfile::tempdir().unwrap();
146 let root = tmp.path().join("root");
147 let other = tmp.path().join("other");
148 std::fs::create_dir_all(&root).unwrap();
149 std::fs::create_dir_all(&other).unwrap();
150 std::fs::write(root.join("a.txt"), "ok").unwrap();
151 std::fs::write(other.join("b.txt"), "no").unwrap();
152
153 let ok = jail_path(&root.join("a.txt"), &root);
154 assert!(ok.is_ok());
155
156 let bad = jail_path(&other.join("b.txt"), &root);
157 assert!(bad.is_err());
158 }
159
160 #[test]
161 fn allows_nonexistent_child_under_root() {
162 let tmp = tempfile::tempdir().unwrap();
163 let root = tmp.path().join("root");
164 std::fs::create_dir_all(&root).unwrap();
165 std::fs::write(root.join("a.txt"), "ok").unwrap();
166
167 let p = root.join("new").join("file.txt");
168 let ok = jail_path(&p, &root).unwrap();
169 assert!(ok.to_string_lossy().contains("file.txt"));
170 }
171
172 #[test]
173 fn ide_config_dirs_list_is_not_empty() {
174 assert!(IDE_CONFIG_DIRS.len() >= 10);
175 assert!(IDE_CONFIG_DIRS.contains(&".codex"));
176 assert!(IDE_CONFIG_DIRS.contains(&".cursor"));
177 assert!(IDE_CONFIG_DIRS.contains(&".claude"));
178 assert!(IDE_CONFIG_DIRS.contains(&".gemini"));
179 }
180
181 #[test]
182 fn canonicalize_or_self_strips_verbatim() {
183 let tmp = tempfile::tempdir().unwrap();
184 let dir = tmp.path().join("project");
185 std::fs::create_dir_all(&dir).unwrap();
186
187 let result = canonicalize_or_self(&dir);
188 let s = result.to_string_lossy();
189 assert!(
190 !s.starts_with(r"\\?\"),
191 "canonicalize_or_self should strip verbatim prefix, got: {s}"
192 );
193 }
194
195 #[test]
196 fn jail_path_accepts_same_dir_different_format() {
197 let tmp = tempfile::tempdir().unwrap();
198 let root = tmp.path().join("project");
199 std::fs::create_dir_all(&root).unwrap();
200 std::fs::write(root.join("file.rs"), "ok").unwrap();
201
202 let result = jail_path(&root.join("file.rs"), &root);
203 assert!(result.is_ok(), "same dir should be accepted: {result:?}");
204 }
205
206 #[test]
207 fn error_message_contains_allow_path_hint() {
208 let tmp = tempfile::tempdir().unwrap();
209 let root = tmp.path().join("root");
210 let other = tmp.path().join("other");
211 std::fs::create_dir_all(&root).unwrap();
212 std::fs::create_dir_all(&other).unwrap();
213 std::fs::write(other.join("b.txt"), "no").unwrap();
214
215 let err = jail_path(&other.join("b.txt"), &root).unwrap_err();
216 assert!(
217 err.contains("LEAN_CTX_ALLOW_PATH"),
218 "error should hint at LEAN_CTX_ALLOW_PATH: {err}"
219 );
220 assert!(
221 err.contains("allow_paths"),
222 "error should hint at config allow_paths: {err}"
223 );
224 }
225
226 #[test]
227 fn allow_path_env_permits_outside_root() {
228 let tmp = tempfile::tempdir().unwrap();
229 let root = tmp.path().join("root");
230 let other = tmp.path().join("other");
231 std::fs::create_dir_all(&root).unwrap();
232 std::fs::create_dir_all(&other).unwrap();
233 std::fs::write(other.join("b.txt"), "allowed").unwrap();
234
235 let canon = canonicalize_or_self(&other);
236 std::env::set_var("LEAN_CTX_ALLOW_PATH", canon.to_string_lossy().as_ref());
237 let result = jail_path(&other.join("b.txt"), &root);
238 std::env::remove_var("LEAN_CTX_ALLOW_PATH");
239
240 assert!(
241 result.is_ok(),
242 "LEAN_CTX_ALLOW_PATH should permit access: {result:?}"
243 );
244 }
245}