lean_ctx/core/
pathutil.rs1use std::path::{Path, PathBuf};
2
3pub fn safe_canonicalize(path: &Path) -> std::io::Result<PathBuf> {
9 let canon = std::fs::canonicalize(path)?;
10 Ok(strip_verbatim(canon))
11}
12
13pub fn safe_canonicalize_or_self(path: &Path) -> PathBuf {
15 safe_canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
16}
17
18pub fn safe_canonicalize_bounded(path: &Path, timeout_ms: u64) -> PathBuf {
23 use super::io_health;
24
25 let path_str = path.to_string_lossy();
26 if io_health::is_slow_mount(&path_str) && io_health::recent_freeze_count() > 0 {
27 return safe_canonicalize_or_self(path);
28 }
29
30 let effective_timeout =
31 io_health::adaptive_timeout(std::time::Duration::from_millis(timeout_ms));
32
33 let path_owned = path.to_path_buf();
34 let (tx, rx) = std::sync::mpsc::channel();
35 let _ = std::thread::Builder::new()
36 .name("canonicalize-bounded".into())
37 .spawn(move || {
38 let result = safe_canonicalize(&path_owned).unwrap_or(path_owned);
39 let _ = tx.send(result);
40 });
41 if let Ok(canonical) = rx.recv_timeout(effective_timeout) {
42 canonical
43 } else {
44 io_health::record_freeze();
45 tracing::warn!(
46 "[SECURITY] canonicalize timed out ({}ms) for {}; PathJail checks on \
47 uncanonicalized paths may be less reliable",
48 effective_timeout.as_millis(),
49 path.display()
50 );
51 path.to_path_buf()
52 }
53}
54
55pub fn strip_verbatim(path: PathBuf) -> PathBuf {
58 let s = path.to_string_lossy();
59 if let Some(stripped) = strip_verbatim_str(&s) {
60 PathBuf::from(stripped)
61 } else {
62 path
63 }
64}
65
66pub fn strip_verbatim_str(path: &str) -> Option<String> {
69 let normalized = path.replace('\\', "/");
70
71 if let Some(rest) = normalized.strip_prefix("//?/UNC/") {
72 Some(format!("//{rest}"))
73 } else {
74 normalized
75 .strip_prefix("//?/")
76 .map(std::string::ToString::to_string)
77 }
78}
79
80pub fn normalize_tool_path(path: &str) -> String {
84 let mut p = match strip_verbatim_str(path) {
85 Some(stripped) => stripped,
86 None => path.to_string(),
87 };
88
89 if p.len() >= 3
91 && p.starts_with('/')
92 && p.as_bytes()[1].is_ascii_alphabetic()
93 && p.as_bytes()[2] == b'/'
94 {
95 let drive = p.as_bytes()[1].to_ascii_uppercase() as char;
96 p = format!("{drive}:{}", &p[2..]);
97 }
98
99 p = p.replace('\\', "/");
100
101 while p.contains("//") && !p.starts_with("//") {
103 p = p.replace("//", "/");
104 }
105
106 if p.len() > 1 && p.ends_with('/') && !p.ends_with(":/") {
108 p.pop();
109 }
110
111 p
112}
113
114pub fn is_broad_or_unsafe_root(dir: &Path) -> bool {
119 if let Some(home) = dirs::home_dir() {
120 if dir == home {
121 return true;
122 }
123 }
124 let s = dir.to_string_lossy();
125 if s == "/" || s == "\\" || s == "." {
126 return true;
127 }
128 s.ends_with("/.claude")
129 || s.ends_with("/.codex")
130 || s.contains("/.claude/")
131 || s.contains("/.codex/")
132}
133
134pub fn is_data_dir_collision(project_root: &Path) -> bool {
138 if is_broad_or_unsafe_root(project_root) {
139 return true;
140 }
141 if let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() {
142 let project_lean_ctx = project_root.join(".lean-ctx");
143 if project_lean_ctx == data_dir || data_dir.starts_with(&project_lean_ctx) {
144 return true;
145 }
146 }
147 false
148}
149
150pub fn safe_project_data_dir(project_root: &Path) -> Result<PathBuf, String> {
153 if is_data_dir_collision(project_root) {
154 return Err(format!(
155 "project root {} collides with global data directory; \
156 skipping project-scoped write",
157 project_root.display()
158 ));
159 }
160 Ok(project_root.join(".lean-ctx"))
161}
162
163#[cfg(test)]
164mod tests {
165 use super::*;
166
167 #[test]
168 fn strip_regular_verbatim() {
169 let p = PathBuf::from(r"\\?\C:\Users\dev\project");
170 let result = strip_verbatim(p);
171 assert_eq!(result, PathBuf::from("C:/Users/dev/project"));
172 }
173
174 #[test]
175 fn strip_unc_verbatim() {
176 let p = PathBuf::from(r"\\?\UNC\server\share\dir");
177 let result = strip_verbatim(p);
178 assert_eq!(result, PathBuf::from("//server/share/dir"));
179 }
180
181 #[test]
182 fn no_prefix_unchanged() {
183 let p = PathBuf::from("/home/user/project");
184 let result = strip_verbatim(p.clone());
185 assert_eq!(result, p);
186 }
187
188 #[test]
189 fn windows_drive_unchanged() {
190 let p = PathBuf::from("C:/Users/dev");
191 let result = strip_verbatim(p.clone());
192 assert_eq!(result, p);
193 }
194
195 #[test]
196 fn strip_str_regular() {
197 assert_eq!(
198 strip_verbatim_str(r"\\?\E:\code\lean-ctx"),
199 Some("E:/code/lean-ctx".to_string())
200 );
201 }
202
203 #[test]
204 fn strip_str_unc() {
205 assert_eq!(
206 strip_verbatim_str(r"\\?\UNC\myserver\data"),
207 Some("//myserver/data".to_string())
208 );
209 }
210
211 #[test]
212 fn strip_str_forward_slash_variant() {
213 assert_eq!(
214 strip_verbatim_str("//?/C:/Users/dev"),
215 Some("C:/Users/dev".to_string())
216 );
217 }
218
219 #[test]
220 fn strip_str_no_prefix() {
221 assert_eq!(strip_verbatim_str("/home/user"), None);
222 }
223
224 #[test]
225 fn safe_canonicalize_or_self_nonexistent() {
226 let p = Path::new("/this/path/should/not/exist/xyzzy");
227 let result = safe_canonicalize_or_self(p);
228 assert_eq!(result, p.to_path_buf());
229 }
230
231 #[test]
232 fn normalize_msys_path_to_native() {
233 assert_eq!(
234 normalize_tool_path("/c/Users/ABC/AppData/lean-ctx"),
235 "C:/Users/ABC/AppData/lean-ctx"
236 );
237 }
238
239 #[test]
240 fn normalize_msys_uppercase_drive() {
241 assert_eq!(
242 normalize_tool_path("/D/Program Files/lean-ctx.exe"),
243 "D:/Program Files/lean-ctx.exe"
244 );
245 }
246
247 #[test]
248 fn normalize_native_windows_path_unchanged() {
249 assert_eq!(
250 normalize_tool_path("C:/Users/ABC/lean-ctx.exe"),
251 "C:/Users/ABC/lean-ctx.exe"
252 );
253 }
254
255 #[test]
256 fn normalize_backslash_windows_path() {
257 assert_eq!(
258 normalize_tool_path(r"C:\Users\ABC\lean-ctx.exe"),
259 "C:/Users/ABC/lean-ctx.exe"
260 );
261 }
262
263 #[test]
264 fn normalize_unix_path_unchanged() {
265 assert_eq!(
266 normalize_tool_path("/usr/local/bin/lean-ctx"),
267 "/usr/local/bin/lean-ctx"
268 );
269 }
270
271 #[test]
272 fn normalize_double_slashes() {
273 assert_eq!(
274 normalize_tool_path("C:/Users//ABC//lean-ctx"),
275 "C:/Users/ABC/lean-ctx"
276 );
277 }
278
279 #[test]
280 fn normalize_trailing_slash_removed() {
281 assert_eq!(normalize_tool_path("/c/Users/ABC/"), "C:/Users/ABC");
282 }
283
284 #[test]
285 fn normalize_root_slash_preserved() {
286 assert_eq!(normalize_tool_path("/"), "/");
287 }
288
289 #[test]
290 fn normalize_drive_root_preserved() {
291 assert_eq!(normalize_tool_path("C:/"), "C:/");
292 }
293
294 #[test]
295 fn normalize_verbatim_with_msys() {
296 assert_eq!(normalize_tool_path(r"\\?\C:\Users\dev"), "C:/Users/dev");
297 }
298
299 #[test]
300 fn broad_root_rejects_home() {
301 if let Some(home) = dirs::home_dir() {
302 assert!(is_broad_or_unsafe_root(&home));
303 }
304 }
305
306 #[test]
307 fn broad_root_rejects_filesystem_root() {
308 assert!(is_broad_or_unsafe_root(Path::new("/")));
309 }
310
311 #[test]
312 fn broad_root_rejects_dot() {
313 assert!(is_broad_or_unsafe_root(Path::new(".")));
314 }
315
316 #[test]
317 fn broad_root_rejects_agent_dirs() {
318 assert!(is_broad_or_unsafe_root(Path::new("/home/user/.claude")));
319 assert!(is_broad_or_unsafe_root(Path::new("/home/user/.codex")));
320 }
321
322 #[test]
323 fn broad_root_allows_project_subdir() {
324 let tmp = tempfile::tempdir().unwrap();
325 let subdir = tmp.path().join("my-project");
326 std::fs::create_dir_all(&subdir).unwrap();
327 assert!(!is_broad_or_unsafe_root(&subdir));
328 }
329
330 #[test]
331 fn broad_root_allows_home_subdirs() {
332 if let Some(home) = dirs::home_dir() {
333 let subdir = home.join("projects").join("my-app");
334 assert!(!is_broad_or_unsafe_root(&subdir));
335 }
336 }
337
338 #[test]
339 fn data_dir_collision_rejects_home() {
340 if let Some(home) = dirs::home_dir() {
341 assert!(is_data_dir_collision(&home));
342 }
343 }
344
345 #[test]
346 fn data_dir_collision_allows_normal_project() {
347 let tmp = tempfile::tempdir().unwrap();
348 let project = tmp.path().join("my-project");
349 std::fs::create_dir_all(&project).unwrap();
350 assert!(!is_data_dir_collision(&project));
351 }
352}