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
20pub fn 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 for p in &cfg.extra_roots {
42 let pb = PathBuf::from(p);
43 out.push(canonicalize_or_self(&pb));
44 }
45
46 let v = std::env::var("LCTX_ALLOW_PATH")
47 .or_else(|_| std::env::var("LEAN_CTX_ALLOW_PATH"))
48 .unwrap_or_default();
49 if !v.trim().is_empty() {
50 for p in std::env::split_paths(&v) {
51 out.push(canonicalize_or_self(&p));
52 }
53 }
54
55 let extra = std::env::var("LEAN_CTX_EXTRA_ROOTS").unwrap_or_default();
56 if !extra.trim().is_empty() {
57 for p in std::env::split_paths(&extra) {
58 out.push(canonicalize_or_self(&p));
59 }
60 }
61
62 out
63}
64
65fn is_under_prefix(path: &Path, prefix: &Path) -> bool {
66 path.starts_with(prefix)
67}
68
69pub fn canonicalize_or_self(path: &Path) -> PathBuf {
70 super::pathutil::safe_canonicalize_bounded(path, 2000)
71}
72
73fn canonicalize_existing_ancestor(path: &Path) -> Option<(PathBuf, Vec<std::ffi::OsString>)> {
74 let mut cur = path.to_path_buf();
75 let mut remainder: Vec<std::ffi::OsString> = Vec::new();
76 loop {
77 if cur.exists() {
78 return Some((canonicalize_or_self(&cur), remainder));
79 }
80 let name = cur.file_name()?.to_os_string();
81 remainder.push(name);
82 if !cur.pop() {
83 return None;
84 }
85 }
86}
87
88pub fn jail_path(candidate: &Path, jail_root: &Path) -> Result<PathBuf, String> {
89 if candidate.to_string_lossy().as_bytes().contains(&0) {
90 return Err("path contains null byte".to_string());
91 }
92
93 #[cfg(feature = "no-jail")]
94 {
95 let _ = jail_root;
96 return Ok(canonicalize_or_self(candidate));
97 }
98
99 #[allow(unreachable_code)]
100 {
101 let cfg = crate::core::config::Config::load();
102 if cfg.path_jail == Some(false) {
103 return Ok(canonicalize_or_self(candidate));
104 }
105
106 let root = canonicalize_or_self(jail_root);
107
108 let resolved: PathBuf;
113 let candidate: &Path = if candidate.is_absolute() {
114 candidate
115 } else {
116 resolved = root.join(candidate);
117 resolved.as_path()
118 };
119
120 let allow = allow_paths_from_env_and_config();
121
122 let (base, remainder) = canonicalize_existing_ancestor(candidate).ok_or_else(|| {
123 format!(
124 "path does not exist and has no existing ancestor: {}",
125 candidate.display()
126 )
127 })?;
128
129 let allowed =
130 is_under_prefix(&base, &root) || allow.iter().any(|p| is_under_prefix(&base, p));
131
132 #[cfg(windows)]
133 let allowed = allowed || is_under_prefix_windows(&base, &root);
134
135 if !allowed {
136 let base_msg = format!(
137 "path escapes project root: {} (root: {})",
138 candidate.display(),
139 root.display(),
140 );
141 let hint = if crate::core::protocol::meta_visible() {
142 format!(
143 ". Hint: set LEAN_CTX_ALLOW_PATH={} or add it to allow_paths in ~/.lean-ctx/config.toml",
144 candidate.parent().unwrap_or(candidate).display()
145 )
146 } else {
147 String::new()
148 };
149 return Err(format!("{base_msg}{hint}"));
150 }
151
152 #[cfg(windows)]
153 reject_symlink_on_windows(candidate)?;
154
155 let mut out = base;
156 for part in remainder.iter().rev() {
157 out.push(part);
158 }
159
160 if out.exists() {
163 let final_canon = canonicalize_or_self(&out);
164 let final_ok = is_under_prefix(&final_canon, &root)
165 || allow.iter().any(|p| is_under_prefix(&final_canon, p));
166 #[cfg(windows)]
167 let final_ok = final_ok || is_under_prefix_windows(&final_canon, &root);
168 if !final_ok {
169 return Err(format!(
170 "post-canonicalize jail escape detected: {} resolves to {}",
171 candidate.display(),
172 final_canon.display()
173 ));
174 }
175 }
176
177 Ok(out)
178 }
179}
180
181#[cfg(windows)]
182fn is_under_prefix_windows(path: &Path, prefix: &Path) -> bool {
183 let path_str = normalize_windows_path(&path.to_string_lossy());
184 let prefix_str = normalize_windows_path(&prefix.to_string_lossy());
185 path_str.starts_with(&prefix_str)
186}
187
188#[cfg(windows)]
189fn normalize_windows_path(s: &str) -> String {
190 let stripped = super::pathutil::strip_verbatim_str(s).unwrap_or_else(|| s.to_string());
191 stripped.to_lowercase().replace('/', "\\")
192}
193
194#[cfg(windows)]
195fn reject_symlink_on_windows(path: &Path) -> Result<(), String> {
196 if let Ok(meta) = std::fs::symlink_metadata(path) {
197 if meta.is_symlink() {
198 return Err(format!(
199 "symlink not allowed in jailed path: {}",
200 path.display()
201 ));
202 }
203 }
204 Ok(())
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210
211 #[cfg(not(feature = "no-jail"))]
212 #[test]
213 fn rejects_path_outside_root() {
214 let tmp = tempfile::tempdir().unwrap();
215 let root = tmp.path().join("root");
216 let other = tmp.path().join("other");
217 std::fs::create_dir_all(&root).unwrap();
218 std::fs::create_dir_all(&other).unwrap();
219 std::fs::write(root.join("a.txt"), "ok").unwrap();
220 std::fs::write(other.join("b.txt"), "no").unwrap();
221
222 let ok = jail_path(&root.join("a.txt"), &root);
223 assert!(ok.is_ok());
224
225 let bad = jail_path(&other.join("b.txt"), &root);
226 assert!(bad.is_err());
227 }
228
229 #[test]
230 fn allows_nonexistent_child_under_root() {
231 let tmp = tempfile::tempdir().unwrap();
232 let root = tmp.path().join("root");
233 std::fs::create_dir_all(&root).unwrap();
234 std::fs::write(root.join("a.txt"), "ok").unwrap();
235
236 let p = root.join("new").join("file.txt");
237 let ok = jail_path(&p, &root).unwrap();
238 assert!(ok.to_string_lossy().contains("file.txt"));
239 }
240
241 #[cfg(not(feature = "no-jail"))]
242 #[test]
243 fn relative_candidate_resolves_against_root_not_cwd() {
244 let tmp = tempfile::tempdir().unwrap();
247 let root = tmp.path().join("project");
248 std::fs::create_dir_all(root.join("sub")).unwrap();
249 std::fs::write(root.join("sub").join("file.rs"), "ok").unwrap();
250
251 let jailed = jail_path(Path::new("sub/file.rs"), &root)
252 .expect("relative candidate should resolve under the jail root");
253 assert!(jailed.ends_with("sub/file.rs"));
254 assert!(
255 is_under_prefix(&canonicalize_or_self(&jailed), &canonicalize_or_self(&root)),
256 "resolved path must live under the jail root: {jailed:?}"
257 );
258 }
259
260 #[test]
261 fn ide_config_dirs_list_is_not_empty() {
262 assert!(IDE_CONFIG_DIRS.len() >= 10);
263 assert!(IDE_CONFIG_DIRS.contains(&".codex"));
264 assert!(IDE_CONFIG_DIRS.contains(&".cursor"));
265 assert!(IDE_CONFIG_DIRS.contains(&".claude"));
266 assert!(IDE_CONFIG_DIRS.contains(&".gemini"));
267 }
268
269 #[test]
270 fn canonicalize_or_self_strips_verbatim() {
271 let tmp = tempfile::tempdir().unwrap();
272 let dir = tmp.path().join("project");
273 std::fs::create_dir_all(&dir).unwrap();
274
275 let result = canonicalize_or_self(&dir);
276 let s = result.to_string_lossy();
277 assert!(
278 !s.starts_with(r"\\?\"),
279 "canonicalize_or_self should strip verbatim prefix, got: {s}"
280 );
281 }
282
283 #[test]
284 fn jail_path_accepts_same_dir_different_format() {
285 let tmp = tempfile::tempdir().unwrap();
286 let root = tmp.path().join("project");
287 std::fs::create_dir_all(&root).unwrap();
288 std::fs::write(root.join("file.rs"), "ok").unwrap();
289
290 let result = jail_path(&root.join("file.rs"), &root);
291 assert!(result.is_ok(), "same dir should be accepted: {result:?}");
292 }
293
294 #[cfg(not(feature = "no-jail"))]
295 #[test]
296 fn error_message_contains_escape_info() {
297 let tmp = tempfile::tempdir().unwrap();
298 let root = tmp.path().join("root");
299 let other = tmp.path().join("other");
300 std::fs::create_dir_all(&root).unwrap();
301 std::fs::create_dir_all(&other).unwrap();
302 std::fs::write(other.join("b.txt"), "no").unwrap();
303
304 let err = jail_path(&other.join("b.txt"), &root).unwrap_err();
305 assert!(
306 err.contains("path escapes project root"),
307 "error should mention escape: {err}"
308 );
309 }
310
311 #[test]
312 fn allow_path_env_permits_outside_root() {
313 let tmp = tempfile::tempdir().unwrap();
314 let root = tmp.path().join("root");
315 let other = tmp.path().join("other");
316 std::fs::create_dir_all(&root).unwrap();
317 std::fs::create_dir_all(&other).unwrap();
318 std::fs::write(other.join("b.txt"), "allowed").unwrap();
319
320 let canon = canonicalize_or_self(&other);
321 std::env::set_var("LEAN_CTX_ALLOW_PATH", canon.to_string_lossy().as_ref());
322 let result = jail_path(&other.join("b.txt"), &root);
323 std::env::remove_var("LEAN_CTX_ALLOW_PATH");
324
325 assert!(
326 result.is_ok(),
327 "LEAN_CTX_ALLOW_PATH should permit access: {result:?}"
328 );
329 }
330
331 #[cfg(all(unix, not(feature = "no-jail")))]
332 #[test]
333 fn rejects_symlink_escape_on_unix() {
334 use std::os::unix::fs::symlink;
335
336 let tmp = tempfile::tempdir().unwrap();
337 let root = tmp.path().join("root");
338 let other = tmp.path().join("other");
339 std::fs::create_dir_all(&root).unwrap();
340 std::fs::create_dir_all(&other).unwrap();
341 std::fs::write(other.join("secret.txt"), "no").unwrap();
342
343 let link = root.join("link.txt");
344 symlink(other.join("secret.txt"), &link).unwrap();
345
346 let bad = jail_path(&link, &root);
347 assert!(bad.is_err(), "symlink escape must be rejected: {bad:?}");
348 }
349
350 #[test]
351 fn rejects_null_byte_in_path() {
352 let tmp = tempfile::tempdir().unwrap();
353 let root = tmp.path().join("root");
354 std::fs::create_dir_all(&root).unwrap();
355
356 let bad_path = PathBuf::from("file\0.txt");
357 let result = jail_path(&bad_path, &root);
358 assert!(result.is_err(), "null byte in path must be rejected");
359 assert!(
360 result.unwrap_err().contains("null byte"),
361 "error must mention null byte"
362 );
363 }
364}