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 if let Ok(canon) = std::fs::canonicalize(&p) {
44 out.push(canon);
45 } else {
46 out.push(p);
47 }
48 }
49 out
50}
51
52fn is_under_prefix(path: &Path, prefix: &Path) -> bool {
53 path.starts_with(prefix)
54}
55
56fn canonicalize_or_self(path: &Path) -> PathBuf {
57 std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
58}
59
60fn canonicalize_existing_ancestor(path: &Path) -> Option<(PathBuf, Vec<std::ffi::OsString>)> {
61 let mut cur = path.to_path_buf();
62 let mut remainder: Vec<std::ffi::OsString> = Vec::new();
63 loop {
64 if cur.exists() {
65 return Some((canonicalize_or_self(&cur), remainder));
66 }
67 let name = cur.file_name()?.to_os_string();
68 remainder.push(name);
69 if !cur.pop() {
70 return None;
71 }
72 }
73}
74
75pub fn jail_path(candidate: &Path, jail_root: &Path) -> Result<PathBuf, String> {
76 let root = canonicalize_or_self(jail_root);
77 let allow = allow_paths_from_env();
78
79 let (base, remainder) = canonicalize_existing_ancestor(candidate).ok_or_else(|| {
80 format!(
81 "path does not exist and has no existing ancestor: {}",
82 candidate.display()
83 )
84 })?;
85
86 let allowed = is_under_prefix(&base, &root) || allow.iter().any(|p| is_under_prefix(&base, p));
87
88 #[cfg(windows)]
89 let allowed = allowed || is_under_prefix_windows(&base, &root);
90
91 if !allowed {
92 return Err(format!(
93 "path escapes project root: {} (root: {})",
94 candidate.display(),
95 root.display()
96 ));
97 }
98
99 #[cfg(windows)]
100 reject_symlink_on_windows(candidate)?;
101
102 let mut out = base;
103 for part in remainder.iter().rev() {
104 out.push(part);
105 }
106 Ok(out)
107}
108
109#[cfg(windows)]
110fn is_under_prefix_windows(path: &Path, prefix: &Path) -> bool {
111 let path_str = path.to_string_lossy().to_lowercase().replace('/', "\\");
112 let prefix_str = prefix.to_string_lossy().to_lowercase().replace('/', "\\");
113 path_str.starts_with(&prefix_str)
114}
115
116#[cfg(windows)]
117fn reject_symlink_on_windows(path: &Path) -> Result<(), String> {
118 if let Ok(meta) = std::fs::symlink_metadata(path) {
119 if meta.is_symlink() {
120 return Err(format!(
121 "symlink not allowed in jailed path: {}",
122 path.display()
123 ));
124 }
125 }
126 Ok(())
127}
128
129#[cfg(test)]
130mod tests {
131 use super::*;
132
133 #[test]
134 fn rejects_path_outside_root() {
135 let tmp = tempfile::tempdir().unwrap();
136 let root = tmp.path().join("root");
137 let other = tmp.path().join("other");
138 std::fs::create_dir_all(&root).unwrap();
139 std::fs::create_dir_all(&other).unwrap();
140 std::fs::write(root.join("a.txt"), "ok").unwrap();
141 std::fs::write(other.join("b.txt"), "no").unwrap();
142
143 let ok = jail_path(&root.join("a.txt"), &root);
144 assert!(ok.is_ok());
145
146 let bad = jail_path(&other.join("b.txt"), &root);
147 assert!(bad.is_err());
148 }
149
150 #[test]
151 fn allows_nonexistent_child_under_root() {
152 let tmp = tempfile::tempdir().unwrap();
153 let root = tmp.path().join("root");
154 std::fs::create_dir_all(&root).unwrap();
155 std::fs::write(root.join("a.txt"), "ok").unwrap();
156
157 let p = root.join("new").join("file.txt");
158 let ok = jail_path(&p, &root).unwrap();
159 assert!(ok.to_string_lossy().contains("file.txt"));
160 }
161
162 #[test]
163 fn ide_config_dirs_list_is_not_empty() {
164 assert!(IDE_CONFIG_DIRS.len() >= 10);
165 assert!(IDE_CONFIG_DIRS.contains(&".codex"));
166 assert!(IDE_CONFIG_DIRS.contains(&".cursor"));
167 assert!(IDE_CONFIG_DIRS.contains(&".claude"));
168 assert!(IDE_CONFIG_DIRS.contains(&".gemini"));
169 }
170}