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