1use 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 expand_user_path(raw: &str) -> PathBuf {
28 let mut s = raw.to_string();
29
30 if s == "~" || s.starts_with("~/") {
31 if let Some(home) = dirs::home_dir() {
32 s = format!("{}{}", home.to_string_lossy(), &s[1..]);
33 }
34 }
35
36 while let Some(start) = s.find('$') {
37 let rest = &s[start + 1..];
38 let (name, token_len) = if let Some(stripped) = rest.strip_prefix('{') {
39 match stripped.find('}') {
40 Some(end) => (stripped[..end].to_string(), end + 3),
41 None => break,
42 }
43 } else {
44 let end = rest
45 .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
46 .unwrap_or(rest.len());
47 (rest[..end].to_string(), end + 1)
48 };
49 if name.is_empty() {
50 break;
51 }
52 if let Ok(val) = std::env::var(&name) {
53 s.replace_range(start..start + token_len, &val);
54 } else {
55 tracing::warn!(
56 "allow_paths/extra_roots entry '{raw}' references unset variable ${name} — entry will never match"
57 );
58 break;
59 }
60 }
61
62 PathBuf::from(s)
63}
64
65pub fn allow_paths_from_env_and_config() -> Vec<PathBuf> {
66 let mut out = Vec::new();
67 let cfg = crate::core::config::Config::load();
68
69 if let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() {
70 out.push(canonicalize_or_self(&data_dir));
71 }
72
73 if let Some(home) = dirs::home_dir() {
74 let ide_dirs_allowed = cfg.allow_ide_config_dirs
75 || std::env::var("LEAN_CTX_ALLOW_IDE_DIRS").is_ok_and(|v| v == "1");
76 out.extend(home_allow_dirs(&home, ide_dirs_allowed));
77 }
78
79 for p in &cfg.allow_paths {
80 out.push(canonicalize_or_self(&expand_user_path(p)));
81 }
82 for p in &cfg.extra_roots {
83 out.push(canonicalize_or_self(&expand_user_path(p)));
84 }
85
86 let v = std::env::var("LCTX_ALLOW_PATH")
89 .or_else(|_| std::env::var("LEAN_CTX_ALLOW_PATH"))
90 .unwrap_or_default();
91 if !v.trim().is_empty() {
92 for p in std::env::split_paths(&v) {
93 out.push(canonicalize_or_self(&expand_user_path(
94 &p.to_string_lossy(),
95 )));
96 }
97 }
98
99 let extra = std::env::var("LEAN_CTX_EXTRA_ROOTS").unwrap_or_default();
100 if !extra.trim().is_empty() {
101 for p in std::env::split_paths(&extra) {
102 out.push(canonicalize_or_self(&expand_user_path(
103 &p.to_string_lossy(),
104 )));
105 }
106 }
107
108 out
109}
110
111fn home_allow_dirs(home: &Path, ide_dirs_allowed: bool) -> Vec<PathBuf> {
117 let mut out = Vec::new();
118 for dir in IDE_CONFIG_DIRS {
119 if *dir != ".lean-ctx" && !ide_dirs_allowed {
120 continue;
121 }
122 let p = home.join(dir);
123 if p.exists() {
124 out.push(canonicalize_or_self(&p));
125 }
126 }
127 out
128}
129
130fn is_under_prefix(path: &Path, prefix: &Path) -> bool {
131 path.starts_with(prefix)
132}
133
134pub fn canonicalize_or_self(path: &Path) -> PathBuf {
135 super::pathutil::safe_canonicalize_bounded(path, 2000)
136}
137
138fn canonicalize_existing_ancestor(path: &Path) -> Option<(PathBuf, Vec<std::ffi::OsString>)> {
139 let mut cur = path.to_path_buf();
140 let mut remainder: Vec<std::ffi::OsString> = Vec::new();
141 loop {
142 if cur.exists() {
143 return Some((canonicalize_or_self(&cur), remainder));
144 }
145 let name = cur.file_name()?.to_os_string();
146 remainder.push(name);
147 if !cur.pop() {
148 return None;
149 }
150 }
151}
152
153pub fn jail_path(candidate: &Path, jail_root: &Path) -> Result<PathBuf, String> {
154 if candidate.to_string_lossy().as_bytes().contains(&0) {
155 return Err("path contains null byte".to_string());
156 }
157
158 #[cfg(feature = "no-jail")]
159 {
160 let _ = jail_root;
161 return Ok(canonicalize_or_self(candidate));
162 }
163
164 #[allow(unreachable_code)]
165 {
166 let cfg = crate::core::config::Config::load();
167 if cfg.path_jail == Some(false) {
168 return Ok(canonicalize_or_self(candidate));
169 }
170
171 let root = canonicalize_or_self(jail_root);
172
173 let resolved: PathBuf;
178 let candidate: &Path = if candidate.is_absolute() {
179 candidate
180 } else {
181 resolved = root.join(candidate);
182 resolved.as_path()
183 };
184
185 let allow = allow_paths_from_env_and_config();
186
187 let (base, remainder) = canonicalize_existing_ancestor(candidate).ok_or_else(|| {
188 format!(
189 "path does not exist and has no existing ancestor: {}",
190 candidate.display()
191 )
192 })?;
193
194 let allowed =
195 is_under_prefix(&base, &root) || allow.iter().any(|p| is_under_prefix(&base, p));
196
197 #[cfg(windows)]
198 let allowed = allowed || is_under_prefix_windows(&base, &root);
199
200 if !allowed {
201 let base_msg = format!(
202 "path escapes project root: {} (root: {})",
203 candidate.display(),
204 root.display(),
205 );
206 let hint = if crate::core::protocol::meta_visible() {
207 format!(
208 ". Hint: set LEAN_CTX_ALLOW_PATH={} or add it to allow_paths in ~/.lean-ctx/config.toml",
209 candidate.parent().unwrap_or(candidate).display()
210 )
211 } else {
212 String::new()
213 };
214 return Err(format!("{base_msg}{hint}"));
215 }
216
217 #[cfg(windows)]
218 reject_symlink_on_windows(candidate)?;
219
220 let mut out = base;
221 for part in remainder.iter().rev() {
222 out.push(part);
223 }
224
225 if out.exists() {
228 let final_canon = canonicalize_or_self(&out);
229 let final_ok = is_under_prefix(&final_canon, &root)
230 || allow.iter().any(|p| is_under_prefix(&final_canon, p));
231 #[cfg(windows)]
232 let final_ok = final_ok || is_under_prefix_windows(&final_canon, &root);
233 if !final_ok {
234 return Err(format!(
235 "post-canonicalize jail escape detected: {} resolves to {}",
236 candidate.display(),
237 final_canon.display()
238 ));
239 }
240 }
241
242 Ok(out)
243 }
244}
245
246#[cfg(windows)]
247fn is_under_prefix_windows(path: &Path, prefix: &Path) -> bool {
248 let path_str = normalize_windows_path(&path.to_string_lossy());
249 let prefix_str = normalize_windows_path(&prefix.to_string_lossy());
250 path_str.starts_with(&prefix_str)
251}
252
253#[cfg(windows)]
254fn normalize_windows_path(s: &str) -> String {
255 let stripped = super::pathutil::strip_verbatim_str(s).unwrap_or_else(|| s.to_string());
256 stripped.to_lowercase().replace('/', "\\")
257}
258
259#[cfg(windows)]
260fn reject_symlink_on_windows(path: &Path) -> Result<(), String> {
261 if let Ok(meta) = std::fs::symlink_metadata(path) {
262 if super::pathutil::is_symlink_or_reparse(&meta) {
265 return Err(format!(
266 "symlink not allowed in jailed path: {}",
267 path.display()
268 ));
269 }
270 }
271 Ok(())
272}
273
274#[cfg(test)]
275mod tests {
276 use super::*;
277
278 #[cfg(not(feature = "no-jail"))]
279 #[test]
280 fn rejects_path_outside_root() {
281 let tmp = tempfile::tempdir().unwrap();
282 let root = tmp.path().join("root");
283 let other = tmp.path().join("other");
284 std::fs::create_dir_all(&root).unwrap();
285 std::fs::create_dir_all(&other).unwrap();
286 std::fs::write(root.join("a.txt"), "ok").unwrap();
287 std::fs::write(other.join("b.txt"), "no").unwrap();
288
289 let ok = jail_path(&root.join("a.txt"), &root);
290 assert!(ok.is_ok());
291
292 let bad = jail_path(&other.join("b.txt"), &root);
293 assert!(bad.is_err());
294 }
295
296 #[test]
297 fn allows_nonexistent_child_under_root() {
298 let tmp = tempfile::tempdir().unwrap();
299 let root = tmp.path().join("root");
300 std::fs::create_dir_all(&root).unwrap();
301 std::fs::write(root.join("a.txt"), "ok").unwrap();
302
303 let p = root.join("new").join("file.txt");
304 let ok = jail_path(&p, &root).unwrap();
305 assert!(ok.to_string_lossy().contains("file.txt"));
306 }
307
308 #[cfg(not(feature = "no-jail"))]
309 #[test]
310 fn relative_candidate_resolves_against_root_not_cwd() {
311 let tmp = tempfile::tempdir().unwrap();
314 let root = tmp.path().join("project");
315 std::fs::create_dir_all(root.join("sub")).unwrap();
316 std::fs::write(root.join("sub").join("file.rs"), "ok").unwrap();
317
318 let jailed = jail_path(Path::new("sub/file.rs"), &root)
319 .expect("relative candidate should resolve under the jail root");
320 assert!(jailed.ends_with("sub/file.rs"));
321 assert!(
322 is_under_prefix(&canonicalize_or_self(&jailed), &canonicalize_or_self(&root)),
323 "resolved path must live under the jail root: {jailed:?}"
324 );
325 }
326
327 #[test]
328 fn ide_config_dirs_list_is_not_empty() {
329 assert!(IDE_CONFIG_DIRS.len() >= 10);
330 assert!(IDE_CONFIG_DIRS.contains(&".codex"));
331 assert!(IDE_CONFIG_DIRS.contains(&".cursor"));
332 assert!(IDE_CONFIG_DIRS.contains(&".claude"));
333 assert!(IDE_CONFIG_DIRS.contains(&".gemini"));
334 }
335
336 #[test]
339 fn ide_config_dirs_are_excluded_by_default() {
340 let home = tempfile::tempdir().unwrap();
341 for d in [".lean-ctx", ".cursor", ".claude", ".codex"] {
342 std::fs::create_dir_all(home.path().join(d)).unwrap();
343 }
344
345 let denied = home_allow_dirs(home.path(), false);
346 assert_eq!(
347 denied.len(),
348 1,
349 "only ~/.lean-ctx may be allowed: {denied:?}"
350 );
351 assert!(denied[0].ends_with(".lean-ctx"));
352
353 let allowed = home_allow_dirs(home.path(), true);
354 assert_eq!(allowed.len(), 4, "opt-in must allow all existing IDE dirs");
355 }
356
357 #[test]
358 fn canonicalize_or_self_strips_verbatim() {
359 let tmp = tempfile::tempdir().unwrap();
360 let dir = tmp.path().join("project");
361 std::fs::create_dir_all(&dir).unwrap();
362
363 let result = canonicalize_or_self(&dir);
364 let s = result.to_string_lossy();
365 assert!(
366 !s.starts_with(r"\\?\"),
367 "canonicalize_or_self should strip verbatim prefix, got: {s}"
368 );
369 }
370
371 #[test]
372 fn jail_path_accepts_same_dir_different_format() {
373 let tmp = tempfile::tempdir().unwrap();
374 let root = tmp.path().join("project");
375 std::fs::create_dir_all(&root).unwrap();
376 std::fs::write(root.join("file.rs"), "ok").unwrap();
377
378 let result = jail_path(&root.join("file.rs"), &root);
379 assert!(result.is_ok(), "same dir should be accepted: {result:?}");
380 }
381
382 #[cfg(not(feature = "no-jail"))]
383 #[test]
384 fn error_message_contains_escape_info() {
385 let tmp = tempfile::tempdir().unwrap();
386 let root = tmp.path().join("root");
387 let other = tmp.path().join("other");
388 std::fs::create_dir_all(&root).unwrap();
389 std::fs::create_dir_all(&other).unwrap();
390 std::fs::write(other.join("b.txt"), "no").unwrap();
391
392 let err = jail_path(&other.join("b.txt"), &root).unwrap_err();
393 assert!(
394 err.contains("path escapes project root"),
395 "error should mention escape: {err}"
396 );
397 }
398
399 #[test]
402 fn expand_user_path_expands_tilde_and_vars() {
403 let home = dirs::home_dir().expect("home dir");
404 let home_s = home.to_string_lossy().to_string();
405
406 assert_eq!(expand_user_path("~"), home);
407 assert_eq!(expand_user_path("~/code"), home.join("code"));
408 assert_eq!(expand_user_path("$HOME/code"), home.join("code"));
409 assert_eq!(expand_user_path("${HOME}/code"), home.join("code"));
410 std::env::set_var("LEAN_CTX_TEST_SUB", "sub");
412 assert_eq!(
413 expand_user_path("$HOME/$LEAN_CTX_TEST_SUB/x"),
414 PathBuf::from(format!("{home_s}/sub/x"))
415 );
416 std::env::remove_var("LEAN_CTX_TEST_SUB");
417 assert_eq!(expand_user_path("/etc"), PathBuf::from("/etc"));
419 }
420
421 #[test]
422 fn expand_user_path_leaves_unset_vars_verbatim() {
423 std::env::remove_var("LEAN_CTX_TEST_UNSET_VAR");
424 let p = expand_user_path("$LEAN_CTX_TEST_UNSET_VAR/code");
425 assert_eq!(p, PathBuf::from("$LEAN_CTX_TEST_UNSET_VAR/code"));
426 }
427
428 static ALLOW_PATH_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
431
432 #[cfg(unix)]
435 #[test]
436 fn allow_path_root_slash_permits_everything() {
437 let _guard = ALLOW_PATH_ENV_LOCK.lock().unwrap();
438 let tmp = tempfile::tempdir().unwrap();
439 let root = tmp.path().join("root");
440 let other = tmp.path().join("other");
441 std::fs::create_dir_all(&root).unwrap();
442 std::fs::create_dir_all(&other).unwrap();
443 std::fs::write(other.join("b.txt"), "allowed").unwrap();
444
445 std::env::set_var("LEAN_CTX_ALLOW_PATH", "/");
446 let result = jail_path(&other.join("b.txt"), &root);
447 std::env::remove_var("LEAN_CTX_ALLOW_PATH");
448
449 assert!(result.is_ok(), "allow path '/' must permit all: {result:?}");
450 }
451
452 #[test]
453 fn allow_path_env_permits_outside_root() {
454 let _guard = ALLOW_PATH_ENV_LOCK.lock().unwrap();
455 let tmp = tempfile::tempdir().unwrap();
456 let root = tmp.path().join("root");
457 let other = tmp.path().join("other");
458 std::fs::create_dir_all(&root).unwrap();
459 std::fs::create_dir_all(&other).unwrap();
460 std::fs::write(other.join("b.txt"), "allowed").unwrap();
461
462 let canon = canonicalize_or_self(&other);
463 std::env::set_var("LEAN_CTX_ALLOW_PATH", canon.to_string_lossy().as_ref());
464 let result = jail_path(&other.join("b.txt"), &root);
465 std::env::remove_var("LEAN_CTX_ALLOW_PATH");
466
467 assert!(
468 result.is_ok(),
469 "LEAN_CTX_ALLOW_PATH should permit access: {result:?}"
470 );
471 }
472
473 #[cfg(all(unix, not(feature = "no-jail")))]
474 #[test]
475 fn rejects_symlink_escape_on_unix() {
476 use std::os::unix::fs::symlink;
477
478 let tmp = tempfile::tempdir().unwrap();
479 let root = tmp.path().join("root");
480 let other = tmp.path().join("other");
481 std::fs::create_dir_all(&root).unwrap();
482 std::fs::create_dir_all(&other).unwrap();
483 std::fs::write(other.join("secret.txt"), "no").unwrap();
484
485 let link = root.join("link.txt");
486 symlink(other.join("secret.txt"), &link).unwrap();
487
488 let bad = jail_path(&link, &root);
489 assert!(bad.is_err(), "symlink escape must be rejected: {bad:?}");
490 }
491
492 #[test]
493 fn rejects_null_byte_in_path() {
494 let tmp = tempfile::tempdir().unwrap();
495 let root = tmp.path().join("root");
496 std::fs::create_dir_all(&root).unwrap();
497
498 let bad_path = PathBuf::from("file\0.txt");
499 let result = jail_path(&bad_path, &root);
500 assert!(result.is_err(), "null byte in path must be rejected");
501 assert!(
502 result.unwrap_err().contains("null byte"),
503 "error must mention null byte"
504 );
505 }
506}