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() -> 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 v = std::env::var("LCTX_ALLOW_PATH")
37 .or_else(|_| std::env::var("LEAN_CTX_ALLOW_PATH"))
38 .unwrap_or_default();
39 if v.trim().is_empty() {
40 return out;
41 }
42 for p in std::env::split_paths(&v) {
43 out.push(canonicalize_or_self(&p));
44 }
45 out
46}
47
48fn is_under_prefix(path: &Path, prefix: &Path) -> bool {
49 path.starts_with(prefix)
50}
51
52fn canonicalize_or_self(path: &Path) -> PathBuf {
53 super::pathutil::safe_canonicalize_or_self(path)
54}
55
56fn canonicalize_existing_ancestor(path: &Path) -> Option<(PathBuf, Vec<std::ffi::OsString>)> {
57 let mut cur = path.to_path_buf();
58 let mut remainder: Vec<std::ffi::OsString> = Vec::new();
59 loop {
60 if cur.exists() {
61 return Some((canonicalize_or_self(&cur), remainder));
62 }
63 let name = cur.file_name()?.to_os_string();
64 remainder.push(name);
65 if !cur.pop() {
66 return None;
67 }
68 }
69}
70
71pub fn jail_path(candidate: &Path, jail_root: &Path) -> Result<PathBuf, String> {
72 let root = canonicalize_or_self(jail_root);
73 let allow = allow_paths_from_env();
74
75 let (base, remainder) = canonicalize_existing_ancestor(candidate).ok_or_else(|| {
76 format!(
77 "path does not exist and has no existing ancestor: {}",
78 candidate.display()
79 )
80 })?;
81
82 let allowed = is_under_prefix(&base, &root) || allow.iter().any(|p| is_under_prefix(&base, p));
83
84 #[cfg(windows)]
85 let allowed = allowed || is_under_prefix_windows(&base, &root);
86
87 if !allowed {
88 return Err(format!(
89 "path escapes project root: {} (root: {})",
90 candidate.display(),
91 root.display()
92 ));
93 }
94
95 #[cfg(windows)]
96 reject_symlink_on_windows(candidate)?;
97
98 let mut out = base;
99 for part in remainder.iter().rev() {
100 out.push(part);
101 }
102 Ok(out)
103}
104
105#[cfg(windows)]
106fn is_under_prefix_windows(path: &Path, prefix: &Path) -> bool {
107 let path_str = normalize_windows_path(&path.to_string_lossy());
108 let prefix_str = normalize_windows_path(&prefix.to_string_lossy());
109 path_str.starts_with(&prefix_str)
110}
111
112#[cfg(windows)]
113fn normalize_windows_path(s: &str) -> String {
114 let stripped = super::pathutil::strip_verbatim_str(s).unwrap_or_else(|| s.to_string());
115 stripped.to_lowercase().replace('/', "\\")
116}
117
118#[cfg(windows)]
119fn reject_symlink_on_windows(path: &Path) -> Result<(), String> {
120 if let Ok(meta) = std::fs::symlink_metadata(path) {
121 if meta.is_symlink() {
122 return Err(format!(
123 "symlink not allowed in jailed path: {}",
124 path.display()
125 ));
126 }
127 }
128 Ok(())
129}
130
131#[cfg(test)]
132mod tests {
133 use super::*;
134
135 #[test]
136 fn rejects_path_outside_root() {
137 let tmp = tempfile::tempdir().unwrap();
138 let root = tmp.path().join("root");
139 let other = tmp.path().join("other");
140 std::fs::create_dir_all(&root).unwrap();
141 std::fs::create_dir_all(&other).unwrap();
142 std::fs::write(root.join("a.txt"), "ok").unwrap();
143 std::fs::write(other.join("b.txt"), "no").unwrap();
144
145 let ok = jail_path(&root.join("a.txt"), &root);
146 assert!(ok.is_ok());
147
148 let bad = jail_path(&other.join("b.txt"), &root);
149 assert!(bad.is_err());
150 }
151
152 #[test]
153 fn allows_nonexistent_child_under_root() {
154 let tmp = tempfile::tempdir().unwrap();
155 let root = tmp.path().join("root");
156 std::fs::create_dir_all(&root).unwrap();
157 std::fs::write(root.join("a.txt"), "ok").unwrap();
158
159 let p = root.join("new").join("file.txt");
160 let ok = jail_path(&p, &root).unwrap();
161 assert!(ok.to_string_lossy().contains("file.txt"));
162 }
163
164 #[test]
165 fn ide_config_dirs_list_is_not_empty() {
166 assert!(IDE_CONFIG_DIRS.len() >= 10);
167 assert!(IDE_CONFIG_DIRS.contains(&".codex"));
168 assert!(IDE_CONFIG_DIRS.contains(&".cursor"));
169 assert!(IDE_CONFIG_DIRS.contains(&".claude"));
170 assert!(IDE_CONFIG_DIRS.contains(&".gemini"));
171 }
172
173 #[test]
174 fn canonicalize_or_self_strips_verbatim() {
175 let tmp = tempfile::tempdir().unwrap();
176 let dir = tmp.path().join("project");
177 std::fs::create_dir_all(&dir).unwrap();
178
179 let result = canonicalize_or_self(&dir);
180 let s = result.to_string_lossy();
181 assert!(
182 !s.starts_with(r"\\?\"),
183 "canonicalize_or_self should strip verbatim prefix, got: {s}"
184 );
185 }
186
187 #[test]
188 fn jail_path_accepts_same_dir_different_format() {
189 let tmp = tempfile::tempdir().unwrap();
190 let root = tmp.path().join("project");
191 std::fs::create_dir_all(&root).unwrap();
192 std::fs::write(root.join("file.rs"), "ok").unwrap();
193
194 let result = jail_path(&root.join("file.rs"), &root);
195 assert!(result.is_ok(), "same dir should be accepted: {result:?}");
196 }
197}