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 let is_absolute = p.starts_with('/') || (p.len() >= 3 && p.as_bytes()[1] == b':');
116 let is_root_only = p == "/" || (p.len() <= 3 && p.ends_with('/') && is_absolute);
117 if is_absolute && !is_root_only && !crate::core::io_health::is_slow_mount(&p) {
118 if let Ok(canonical) = safe_canonicalize(Path::new(&*p)) {
119 let canonical_str = canonical.to_string_lossy().replace('\\', "/");
120 if !canonical_str.is_empty() {
121 p = canonical_str;
122 }
123 }
124 }
125
126 p
127}
128
129pub fn is_broad_or_unsafe_root(dir: &Path) -> bool {
134 if let Some(home) = dirs::home_dir() {
135 if dir == home {
136 return true;
137 }
138 }
139 let s = dir.to_string_lossy();
140 if s == "/" || s == "\\" || s == "." {
141 return true;
142 }
143 s.ends_with("/.claude")
144 || s.ends_with("/.codex")
145 || s.contains("/.claude/")
146 || s.contains("/.codex/")
147}
148
149pub const PROJECT_MARKERS: &[&str] = &[
151 ".git",
152 "Cargo.toml",
153 "package.json",
154 "go.mod",
155 "pyproject.toml",
156 "setup.py",
157 "pom.xml",
158 "build.gradle",
159 "Makefile",
160 "project.godot",
161 ".lean-ctx.toml",
162 ".planning",
163];
164
165pub fn has_project_marker(dir: &Path) -> bool {
167 PROJECT_MARKERS.iter().any(|m| dir.join(m).exists())
168}
169
170pub fn is_symlink_or_reparse(meta: &std::fs::Metadata) -> bool {
178 if meta.file_type().is_symlink() {
179 return true;
180 }
181 #[cfg(windows)]
182 {
183 use std::os::windows::fs::MetadataExt;
184 const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0400;
185 return meta.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0;
186 }
187 #[cfg(not(windows))]
188 false
189}
190
191pub fn is_tcc_sensitive_home_dir(dir: &Path) -> bool {
200 let Some(home) = dirs::home_dir() else {
201 return false;
202 };
203 if dir == home {
204 return true;
205 }
206 if dir.parent() != Some(home.as_path()) {
207 return false;
208 }
209 matches!(
210 dir.file_name().and_then(|n| n.to_str()),
211 Some("Documents" | "Desktop" | "Downloads")
212 )
213}
214
215pub fn has_multi_repo_children(dir: &Path) -> bool {
218 if is_tcc_sensitive_home_dir(dir) {
221 return false;
222 }
223 let Ok(entries) = std::fs::read_dir(dir) else {
224 return false;
225 };
226 let count = entries
227 .filter_map(Result::ok)
228 .filter(|e| e.file_type().is_ok_and(|ft| ft.is_dir()))
229 .filter(|e| has_project_marker(&e.path()))
230 .take(2)
231 .count();
232 count >= 2
233}
234
235pub fn is_data_dir_collision(project_root: &Path) -> bool {
239 if is_broad_or_unsafe_root(project_root) {
240 return true;
241 }
242 if let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() {
243 let project_lean_ctx = project_root.join(".lean-ctx");
244 if project_lean_ctx == data_dir || data_dir.starts_with(&project_lean_ctx) {
245 return true;
246 }
247 }
248 false
249}
250
251pub fn safe_project_data_dir(project_root: &Path) -> Result<PathBuf, String> {
254 if is_data_dir_collision(project_root) {
255 return Err(format!(
256 "project root {} collides with global data directory; \
257 skipping project-scoped write",
258 project_root.display()
259 ));
260 }
261 Ok(project_root.join(".lean-ctx"))
262}
263
264#[cfg(test)]
265mod tests {
266 use super::*;
267
268 #[test]
269 fn strip_regular_verbatim() {
270 let p = PathBuf::from(r"\\?\C:\Users\dev\project");
271 let result = strip_verbatim(p);
272 assert_eq!(result, PathBuf::from("C:/Users/dev/project"));
273 }
274
275 #[test]
276 fn tcc_sensitive_home_dir_matches_home_and_magic_dirs() {
277 let Some(home) = dirs::home_dir() else {
278 return;
279 };
280 assert!(is_tcc_sensitive_home_dir(&home));
282 assert!(is_tcc_sensitive_home_dir(&home.join("Documents")));
283 assert!(is_tcc_sensitive_home_dir(&home.join("Desktop")));
284 assert!(is_tcc_sensitive_home_dir(&home.join("Downloads")));
285 }
286
287 #[test]
288 fn tcc_sensitive_home_dir_allows_real_projects() {
289 let Some(home) = dirs::home_dir() else {
290 return;
291 };
292 assert!(!is_tcc_sensitive_home_dir(
295 &home.join("Documents").join("my-project")
296 ));
297 assert!(!is_tcc_sensitive_home_dir(&home.join("code")));
298 assert!(!is_tcc_sensitive_home_dir(&home.join("Projects")));
299 }
300
301 #[test]
302 fn strip_unc_verbatim() {
303 let p = PathBuf::from(r"\\?\UNC\server\share\dir");
304 let result = strip_verbatim(p);
305 assert_eq!(result, PathBuf::from("//server/share/dir"));
306 }
307
308 #[test]
309 fn no_prefix_unchanged() {
310 let p = PathBuf::from("/home/user/project");
311 let result = strip_verbatim(p.clone());
312 assert_eq!(result, p);
313 }
314
315 #[test]
316 fn windows_drive_unchanged() {
317 let p = PathBuf::from("C:/Users/dev");
318 let result = strip_verbatim(p.clone());
319 assert_eq!(result, p);
320 }
321
322 #[test]
323 fn strip_str_regular() {
324 assert_eq!(
325 strip_verbatim_str(r"\\?\E:\code\lean-ctx"),
326 Some("E:/code/lean-ctx".to_string())
327 );
328 }
329
330 #[test]
331 fn strip_str_unc() {
332 assert_eq!(
333 strip_verbatim_str(r"\\?\UNC\myserver\data"),
334 Some("//myserver/data".to_string())
335 );
336 }
337
338 #[test]
339 fn strip_str_forward_slash_variant() {
340 assert_eq!(
341 strip_verbatim_str("//?/C:/Users/dev"),
342 Some("C:/Users/dev".to_string())
343 );
344 }
345
346 #[test]
347 fn strip_str_no_prefix() {
348 assert_eq!(strip_verbatim_str("/home/user"), None);
349 }
350
351 #[test]
352 fn safe_canonicalize_or_self_nonexistent() {
353 let p = Path::new("/this/path/should/not/exist/xyzzy");
354 let result = safe_canonicalize_or_self(p);
355 assert_eq!(result, p.to_path_buf());
356 }
357
358 #[test]
359 fn normalize_msys_path_to_native() {
360 assert_eq!(
361 normalize_tool_path("/c/Users/ABC/AppData/lean-ctx"),
362 "C:/Users/ABC/AppData/lean-ctx"
363 );
364 }
365
366 #[test]
367 fn normalize_msys_uppercase_drive() {
368 assert_eq!(
369 normalize_tool_path("/D/Program Files/lean-ctx.exe"),
370 "D:/Program Files/lean-ctx.exe"
371 );
372 }
373
374 #[test]
375 fn normalize_native_windows_path_unchanged() {
376 assert_eq!(
377 normalize_tool_path("C:/Users/ABC/lean-ctx.exe"),
378 "C:/Users/ABC/lean-ctx.exe"
379 );
380 }
381
382 #[test]
383 fn normalize_backslash_windows_path() {
384 assert_eq!(
385 normalize_tool_path(r"C:\Users\ABC\lean-ctx.exe"),
386 "C:/Users/ABC/lean-ctx.exe"
387 );
388 }
389
390 #[test]
391 fn normalize_unix_path_unchanged() {
392 assert_eq!(
393 normalize_tool_path("/usr/local/bin/lean-ctx"),
394 "/usr/local/bin/lean-ctx"
395 );
396 }
397
398 #[test]
399 fn normalize_windows_path_with_spaces_and_backslashes() {
400 assert_eq!(
404 normalize_tool_path(r"C:\Users\My Name\My Project\src\main.rs"),
405 "C:/Users/My Name/My Project/src/main.rs"
406 );
407 assert_eq!(
408 normalize_tool_path(r"C:\Program Files\app\config.toml"),
409 "C:/Program Files/app/config.toml"
410 );
411 }
412
413 #[test]
414 fn normalize_double_slashes() {
415 assert_eq!(
416 normalize_tool_path("C:/Users//ABC//lean-ctx"),
417 "C:/Users/ABC/lean-ctx"
418 );
419 }
420
421 #[test]
422 fn normalize_trailing_slash_removed() {
423 assert_eq!(normalize_tool_path("/c/Users/ABC/"), "C:/Users/ABC");
424 }
425
426 #[test]
427 fn normalize_root_slash_preserved() {
428 assert_eq!(normalize_tool_path("/"), "/");
429 }
430
431 #[test]
432 fn normalize_drive_root_preserved() {
433 assert_eq!(normalize_tool_path("C:/"), "C:/");
434 }
435
436 #[test]
437 fn normalize_verbatim_with_msys() {
438 assert_eq!(normalize_tool_path(r"\\?\C:\Users\dev"), "C:/Users/dev");
439 }
440
441 #[test]
442 fn broad_root_rejects_home() {
443 if let Some(home) = dirs::home_dir() {
444 assert!(is_broad_or_unsafe_root(&home));
445 }
446 }
447
448 #[test]
449 fn broad_root_rejects_filesystem_root() {
450 assert!(is_broad_or_unsafe_root(Path::new("/")));
451 }
452
453 #[test]
454 fn broad_root_rejects_dot() {
455 assert!(is_broad_or_unsafe_root(Path::new(".")));
456 }
457
458 #[test]
459 fn broad_root_rejects_agent_dirs() {
460 assert!(is_broad_or_unsafe_root(Path::new("/home/user/.claude")));
461 assert!(is_broad_or_unsafe_root(Path::new("/home/user/.codex")));
462 }
463
464 #[test]
465 fn broad_root_allows_project_subdir() {
466 let tmp = tempfile::tempdir().unwrap();
467 let subdir = tmp.path().join("my-project");
468 std::fs::create_dir_all(&subdir).unwrap();
469 assert!(!is_broad_or_unsafe_root(&subdir));
470 }
471
472 #[test]
473 fn broad_root_allows_home_subdirs() {
474 if let Some(home) = dirs::home_dir() {
475 let subdir = home.join("projects").join("my-app");
476 assert!(!is_broad_or_unsafe_root(&subdir));
477 }
478 }
479
480 #[test]
481 fn data_dir_collision_rejects_home() {
482 if let Some(home) = dirs::home_dir() {
483 assert!(is_data_dir_collision(&home));
484 }
485 }
486
487 #[test]
488 fn data_dir_collision_allows_normal_project() {
489 let tmp = tempfile::tempdir().unwrap();
490 let project = tmp.path().join("my-project");
491 std::fs::create_dir_all(&project).unwrap();
492 assert!(!is_data_dir_collision(&project));
493 }
494
495 #[test]
496 fn has_project_marker_detects_git() {
497 let tmp = tempfile::tempdir().unwrap();
498 let root = tmp.path().join("repo");
499 std::fs::create_dir_all(&root).unwrap();
500 assert!(!has_project_marker(&root));
501 std::fs::create_dir(root.join(".git")).unwrap();
502 assert!(has_project_marker(&root));
503 }
504
505 #[test]
506 fn has_project_marker_detects_cargo_toml() {
507 let tmp = tempfile::tempdir().unwrap();
508 let root = tmp.path().join("rust-project");
509 std::fs::create_dir_all(&root).unwrap();
510 std::fs::write(root.join("Cargo.toml"), "[package]").unwrap();
511 assert!(has_project_marker(&root));
512 }
513
514 #[test]
515 fn has_project_marker_detects_godot_project() {
516 let tmp = tempfile::tempdir().unwrap();
517 let root = tmp.path().join("godot-game");
518 std::fs::create_dir_all(&root).unwrap();
519 std::fs::write(root.join("project.godot"), "config_version=5\n").unwrap();
520 assert!(has_project_marker(&root));
521 }
522
523 #[test]
524 fn multi_repo_children_needs_two() {
525 let tmp = tempfile::tempdir().unwrap();
526 let parent = tmp.path().join("code");
527 std::fs::create_dir_all(&parent).unwrap();
528
529 assert!(!has_multi_repo_children(&parent));
531
532 let repo1 = parent.join("repo1");
534 std::fs::create_dir_all(repo1.join(".git")).unwrap();
535 assert!(!has_multi_repo_children(&parent));
536
537 let repo2 = parent.join("repo2");
539 std::fs::create_dir_all(repo2.join(".git")).unwrap();
540 assert!(has_multi_repo_children(&parent));
541 }
542
543 #[test]
544 fn multi_repo_children_ignores_files() {
545 let tmp = tempfile::tempdir().unwrap();
546 let parent = tmp.path().join("mixed");
547 std::fs::create_dir_all(&parent).unwrap();
548
549 let repo1 = parent.join("repo1");
551 std::fs::create_dir_all(repo1.join(".git")).unwrap();
552 std::fs::write(parent.join("not-a-repo"), "file").unwrap();
553 assert!(!has_multi_repo_children(&parent));
554
555 let repo2 = parent.join("repo2");
557 std::fs::create_dir_all(&repo2).unwrap();
558 std::fs::write(repo2.join("package.json"), "{}").unwrap();
559 assert!(has_multi_repo_children(&parent));
560 }
561
562 #[test]
563 fn multi_repo_children_nonexistent_dir() {
564 assert!(!has_multi_repo_children(Path::new("/nonexistent/path/xyz")));
565 }
566
567 #[test]
568 fn regular_file_is_not_symlink_or_reparse() {
569 let tmp = tempfile::tempdir().unwrap();
570 let file = tmp.path().join("plain.txt");
571 std::fs::write(&file, "x").unwrap();
572 let meta = std::fs::symlink_metadata(&file).unwrap();
573 assert!(!is_symlink_or_reparse(&meta));
574 }
575
576 #[cfg(unix)]
577 #[test]
578 fn unix_symlink_is_detected() {
579 let tmp = tempfile::tempdir().unwrap();
580 let target = tmp.path().join("target.txt");
581 std::fs::write(&target, "x").unwrap();
582 let link = tmp.path().join("link.txt");
583 std::os::unix::fs::symlink(&target, &link).unwrap();
584 let meta = std::fs::symlink_metadata(&link).unwrap();
585 assert!(is_symlink_or_reparse(&meta));
586 }
587
588 #[cfg(windows)]
591 #[test]
592 fn windows_symlink_is_detected() {
593 let tmp = tempfile::tempdir().unwrap();
594 let target = tmp.path().join("target.txt");
595 std::fs::write(&target, "x").unwrap();
596 let link = tmp.path().join("link.txt");
597 if std::os::windows::fs::symlink_file(&target, &link).is_err() {
598 eprintln!("skipping: symlink creation not permitted on this runner");
599 return;
600 }
601 let meta = std::fs::symlink_metadata(&link).unwrap();
602 assert!(is_symlink_or_reparse(&meta));
603 }
604}