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
80fn translate_msys_drive_prefix(p: &str) -> Option<String> {
89 if p.len() >= 3
90 && p.starts_with('/')
91 && p.as_bytes()[1].is_ascii_alphabetic()
92 && p.as_bytes()[2] == b'/'
93 {
94 let drive = p.as_bytes()[1].to_ascii_uppercase() as char;
95 Some(format!("{drive}:{}", &p[2..]))
96 } else {
97 None
98 }
99}
100
101pub fn normalize_tool_path_lexical(path: &str) -> String {
107 let mut p = match strip_verbatim_str(path) {
108 Some(stripped) => stripped,
109 None => path.to_string(),
110 };
111
112 if cfg!(windows) {
113 if let Some(translated) = translate_msys_drive_prefix(&p) {
114 p = translated;
115 }
116 }
117
118 p = p.replace('\\', "/");
119
120 while p.contains("//") && !p.starts_with("//") {
122 p = p.replace("//", "/");
123 }
124
125 if p.len() > 1 && p.ends_with('/') && !p.ends_with(":/") {
127 p.pop();
128 }
129
130 p
131}
132
133pub fn normalize_tool_path(path: &str) -> String {
139 let mut p = normalize_tool_path_lexical(path);
140
141 let is_absolute = p.starts_with('/') || (p.len() >= 3 && p.as_bytes()[1] == b':');
147 let is_root_only = p == "/" || (p.len() <= 3 && p.ends_with('/') && is_absolute);
148 if is_absolute
149 && !is_root_only
150 && !crate::core::io_health::is_slow_mount(&p)
151 && may_probe_path(Path::new(&*p))
152 {
153 if let Ok(canonical) = safe_canonicalize(Path::new(&*p)) {
154 let canonical_str = canonical.to_string_lossy().replace('\\', "/");
155 if !canonical_str.is_empty() {
156 p = canonical_str;
157 }
158 }
159 }
160
161 p
162}
163
164pub fn is_broad_or_unsafe_root(dir: &Path) -> bool {
169 if let Some(home) = dirs::home_dir() {
170 if dir == home {
171 return true;
172 }
173 }
174 let s = dir.to_string_lossy();
175 if s == "/" || s == "\\" || s == "." {
176 return true;
177 }
178 s.ends_with("/.claude")
179 || s.ends_with("/.codex")
180 || s.ends_with("/.codebuddy")
181 || s.contains("/.claude/")
182 || s.contains("/.codex/")
183 || s.contains("/.codebuddy/")
184}
185
186pub const PROJECT_MARKERS: &[&str] = &[
188 ".git",
189 "Cargo.toml",
190 "package.json",
191 "go.mod",
192 "pyproject.toml",
193 "setup.py",
194 "pom.xml",
195 "build.gradle",
196 "Makefile",
197 "project.godot",
198 ".lean-ctx.toml",
199 ".planning",
200];
201
202pub fn has_project_marker(dir: &Path) -> bool {
209 if !may_probe_path(dir) {
210 return false;
211 }
212 PROJECT_MARKERS.iter().any(|m| dir.join(m).exists())
213}
214
215pub fn is_symlink_or_reparse(meta: &std::fs::Metadata) -> bool {
223 if meta.file_type().is_symlink() {
224 return true;
225 }
226 #[cfg(windows)]
227 {
228 use std::os::windows::fs::MetadataExt;
229 const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0400;
230 return meta.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0;
231 }
232 #[cfg(not(windows))]
233 false
234}
235
236pub fn is_tcc_sensitive_home_dir(dir: &Path) -> bool {
245 let Some(home) = dirs::home_dir() else {
246 return false;
247 };
248 if dir == home {
249 return true;
250 }
251 if dir.parent() != Some(home.as_path()) {
252 return false;
253 }
254 matches!(
255 dir.file_name().and_then(|n| n.to_str()),
256 Some("Documents" | "Desktop" | "Downloads")
257 )
258}
259
260pub fn is_under_tcc_protected_dir(path: &Path) -> bool {
268 if !cfg!(target_os = "macos") {
269 return false;
270 }
271 let Some(home) = dirs::home_dir() else {
272 return false;
273 };
274 ["Documents", "Desktop", "Downloads"]
275 .iter()
276 .any(|magic| path.starts_with(home.join(magic)))
277}
278
279pub fn process_is_tcc_standalone() -> bool {
293 #[cfg(target_os = "macos")]
294 {
295 if let Ok(v) = std::env::var("LEAN_CTX_TCC_STANDALONE") {
299 match v.trim() {
300 "1" | "true" => return true,
301 "0" | "false" => return false,
302 _ => {}
303 }
304 }
305 (unsafe { libc::getppid() }) == 1
307 }
308 #[cfg(not(target_os = "macos"))]
309 {
310 false
311 }
312}
313
314pub fn may_probe_path(path: &Path) -> bool {
322 !(process_is_tcc_standalone() && is_under_tcc_protected_dir(path))
323}
324
325pub fn has_multi_repo_children(dir: &Path) -> bool {
328 if is_tcc_sensitive_home_dir(dir) {
331 return false;
332 }
333 let Ok(entries) = std::fs::read_dir(dir) else {
334 return false;
335 };
336 let count = entries
337 .filter_map(Result::ok)
338 .filter(|e| e.file_type().is_ok_and(|ft| ft.is_dir()))
339 .filter(|e| has_project_marker(&e.path()))
340 .take(2)
341 .count();
342 count >= 2
343}
344
345pub fn is_data_dir_collision(project_root: &Path) -> bool {
349 if is_broad_or_unsafe_root(project_root) {
350 return true;
351 }
352 if let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() {
353 let project_lean_ctx = project_root.join(".lean-ctx");
354 if project_lean_ctx == data_dir || data_dir.starts_with(&project_lean_ctx) {
355 return true;
356 }
357 }
358 false
359}
360
361pub fn safe_project_data_dir(project_root: &Path) -> Result<PathBuf, String> {
364 if is_data_dir_collision(project_root) {
365 return Err(format!(
366 "project root {} collides with global data directory; \
367 skipping project-scoped write",
368 project_root.display()
369 ));
370 }
371 Ok(project_root.join(".lean-ctx"))
372}
373
374#[cfg(test)]
375mod tests {
376 use super::*;
377
378 #[test]
379 fn strip_regular_verbatim() {
380 let p = PathBuf::from(r"\\?\C:\Users\dev\project");
381 let result = strip_verbatim(p);
382 assert_eq!(result, PathBuf::from("C:/Users/dev/project"));
383 }
384
385 #[test]
386 fn tcc_sensitive_home_dir_matches_home_and_magic_dirs() {
387 let Some(home) = dirs::home_dir() else {
388 return;
389 };
390 assert!(is_tcc_sensitive_home_dir(&home));
392 assert!(is_tcc_sensitive_home_dir(&home.join("Documents")));
393 assert!(is_tcc_sensitive_home_dir(&home.join("Desktop")));
394 assert!(is_tcc_sensitive_home_dir(&home.join("Downloads")));
395 }
396
397 #[test]
398 fn tcc_sensitive_home_dir_allows_real_projects() {
399 let Some(home) = dirs::home_dir() else {
400 return;
401 };
402 assert!(!is_tcc_sensitive_home_dir(
405 &home.join("Documents").join("my-project")
406 ));
407 assert!(!is_tcc_sensitive_home_dir(&home.join("code")));
408 assert!(!is_tcc_sensitive_home_dir(&home.join("Projects")));
409 }
410
411 #[test]
412 #[cfg(target_os = "macos")]
413 fn under_tcc_protected_dir_matches_nested_paths() {
414 let Some(home) = dirs::home_dir() else {
415 return;
416 };
417 assert!(is_under_tcc_protected_dir(&home.join("Documents")));
419 assert!(is_under_tcc_protected_dir(
420 &home.join("Documents/deep/nested/project")
421 ));
422 assert!(is_under_tcc_protected_dir(&home.join("Desktop/scratch")));
423 assert!(is_under_tcc_protected_dir(&home.join("Downloads/x.zip")));
424 assert!(!is_under_tcc_protected_dir(&home));
426 assert!(!is_under_tcc_protected_dir(&home.join("code/project")));
427 assert!(!is_under_tcc_protected_dir(Path::new("/tmp/Documents")));
428 }
429
430 #[test]
431 #[cfg(target_os = "macos")]
432 #[serial_test::serial]
433 fn tcc_standalone_blocks_probes_under_protected_dirs() {
434 let Some(home) = dirs::home_dir() else {
435 return;
436 };
437 let doc_proj = home.join("Documents/some-project");
438
439 std::env::set_var("LEAN_CTX_TCC_STANDALONE", "1");
440 assert!(process_is_tcc_standalone());
441 assert!(!may_probe_path(&doc_proj));
442 assert!(may_probe_path(Path::new("/tmp/some-project")));
444 assert!(!has_project_marker(&doc_proj));
446
447 std::env::set_var("LEAN_CTX_TCC_STANDALONE", "0");
448 assert!(!process_is_tcc_standalone());
449 assert!(may_probe_path(&doc_proj));
450 std::env::remove_var("LEAN_CTX_TCC_STANDALONE");
451 }
452
453 #[test]
454 fn strip_unc_verbatim() {
455 let p = PathBuf::from(r"\\?\UNC\server\share\dir");
456 let result = strip_verbatim(p);
457 assert_eq!(result, PathBuf::from("//server/share/dir"));
458 }
459
460 #[test]
461 fn no_prefix_unchanged() {
462 let p = PathBuf::from("/home/user/project");
463 let result = strip_verbatim(p.clone());
464 assert_eq!(result, p);
465 }
466
467 #[test]
468 fn windows_drive_unchanged() {
469 let p = PathBuf::from("C:/Users/dev");
470 let result = strip_verbatim(p.clone());
471 assert_eq!(result, p);
472 }
473
474 #[test]
475 fn strip_str_regular() {
476 assert_eq!(
477 strip_verbatim_str(r"\\?\E:\code\lean-ctx"),
478 Some("E:/code/lean-ctx".to_string())
479 );
480 }
481
482 #[test]
483 fn strip_str_unc() {
484 assert_eq!(
485 strip_verbatim_str(r"\\?\UNC\myserver\data"),
486 Some("//myserver/data".to_string())
487 );
488 }
489
490 #[test]
491 fn strip_str_forward_slash_variant() {
492 assert_eq!(
493 strip_verbatim_str("//?/C:/Users/dev"),
494 Some("C:/Users/dev".to_string())
495 );
496 }
497
498 #[test]
499 fn strip_str_no_prefix() {
500 assert_eq!(strip_verbatim_str("/home/user"), None);
501 }
502
503 #[test]
504 fn safe_canonicalize_or_self_nonexistent() {
505 let p = Path::new("/this/path/should/not/exist/xyzzy");
506 let result = safe_canonicalize_or_self(p);
507 assert_eq!(result, p.to_path_buf());
508 }
509
510 #[test]
513 fn msys_drive_prefix_translation() {
514 assert_eq!(
515 translate_msys_drive_prefix("/c/Users/ABC").as_deref(),
516 Some("C:/Users/ABC")
517 );
518 assert_eq!(
519 translate_msys_drive_prefix("/D/Program Files").as_deref(),
520 Some("D:/Program Files")
521 );
522 assert_eq!(translate_msys_drive_prefix("/usr/local/bin"), None);
523 assert_eq!(translate_msys_drive_prefix("/c"), None);
524 assert_eq!(translate_msys_drive_prefix("c/Users"), None);
525 }
526
527 #[cfg(windows)]
528 #[test]
529 fn normalize_msys_path_to_native() {
530 assert_eq!(
531 normalize_tool_path("/c/Users/ABC/AppData/lean-ctx"),
532 "C:/Users/ABC/AppData/lean-ctx"
533 );
534 assert_eq!(
535 normalize_tool_path("/D/Program Files/lean-ctx.exe"),
536 "D:/Program Files/lean-ctx.exe"
537 );
538 }
539
540 #[cfg(not(windows))]
543 #[test]
544 fn normalize_single_letter_unix_path_untouched() {
545 assert_eq!(
546 normalize_tool_path_lexical("/c/Users/me/proj"),
547 "/c/Users/me/proj"
548 );
549 assert_eq!(
550 normalize_tool_path_lexical("/x/projects/app/src"),
551 "/x/projects/app/src"
552 );
553 }
554
555 #[test]
556 fn normalize_native_windows_path_unchanged() {
557 assert_eq!(
558 normalize_tool_path("C:/Users/ABC/lean-ctx.exe"),
559 "C:/Users/ABC/lean-ctx.exe"
560 );
561 }
562
563 #[test]
564 fn normalize_backslash_windows_path() {
565 assert_eq!(
566 normalize_tool_path(r"C:\Users\ABC\lean-ctx.exe"),
567 "C:/Users/ABC/lean-ctx.exe"
568 );
569 }
570
571 #[test]
572 fn normalize_unix_path_unchanged() {
573 assert_eq!(
574 normalize_tool_path("/usr/local/bin/lean-ctx"),
575 "/usr/local/bin/lean-ctx"
576 );
577 }
578
579 #[test]
580 fn normalize_windows_path_with_spaces_and_backslashes() {
581 assert_eq!(
585 normalize_tool_path(r"C:\Users\My Name\My Project\src\main.rs"),
586 "C:/Users/My Name/My Project/src/main.rs"
587 );
588 assert_eq!(
589 normalize_tool_path(r"C:\Program Files\app\config.toml"),
590 "C:/Program Files/app/config.toml"
591 );
592 }
593
594 #[test]
595 fn normalize_double_slashes() {
596 assert_eq!(
597 normalize_tool_path("C:/Users//ABC//lean-ctx"),
598 "C:/Users/ABC/lean-ctx"
599 );
600 }
601
602 #[test]
603 fn normalize_trailing_slash_removed() {
604 assert_eq!(normalize_tool_path("C:/Users/ABC/"), "C:/Users/ABC");
605 assert_eq!(
606 normalize_tool_path_lexical("/tmp/nonexistent-dir-xyzzy/"),
607 "/tmp/nonexistent-dir-xyzzy"
608 );
609 }
610
611 #[test]
612 fn normalize_root_slash_preserved() {
613 assert_eq!(normalize_tool_path("/"), "/");
614 }
615
616 #[test]
617 fn normalize_drive_root_preserved() {
618 assert_eq!(normalize_tool_path("C:/"), "C:/");
619 }
620
621 #[test]
622 fn normalize_verbatim_with_msys() {
623 assert_eq!(normalize_tool_path(r"\\?\C:\Users\dev"), "C:/Users/dev");
624 }
625
626 #[test]
627 fn broad_root_rejects_home() {
628 if let Some(home) = dirs::home_dir() {
629 assert!(is_broad_or_unsafe_root(&home));
630 }
631 }
632
633 #[test]
634 fn broad_root_rejects_filesystem_root() {
635 assert!(is_broad_or_unsafe_root(Path::new("/")));
636 }
637
638 #[test]
639 fn broad_root_rejects_dot() {
640 assert!(is_broad_or_unsafe_root(Path::new(".")));
641 }
642
643 #[test]
644 fn broad_root_rejects_agent_dirs() {
645 assert!(is_broad_or_unsafe_root(Path::new("/home/user/.claude")));
646 assert!(is_broad_or_unsafe_root(Path::new("/home/user/.codex")));
647 }
648
649 #[test]
650 fn broad_root_allows_project_subdir() {
651 let tmp = tempfile::tempdir().unwrap();
652 let subdir = tmp.path().join("my-project");
653 std::fs::create_dir_all(&subdir).unwrap();
654 assert!(!is_broad_or_unsafe_root(&subdir));
655 }
656
657 #[test]
658 fn broad_root_allows_home_subdirs() {
659 if let Some(home) = dirs::home_dir() {
660 let subdir = home.join("projects").join("my-app");
661 assert!(!is_broad_or_unsafe_root(&subdir));
662 }
663 }
664
665 #[test]
666 fn data_dir_collision_rejects_home() {
667 if let Some(home) = dirs::home_dir() {
668 assert!(is_data_dir_collision(&home));
669 }
670 }
671
672 #[test]
673 fn data_dir_collision_allows_normal_project() {
674 let tmp = tempfile::tempdir().unwrap();
675 let project = tmp.path().join("my-project");
676 std::fs::create_dir_all(&project).unwrap();
677 assert!(!is_data_dir_collision(&project));
678 }
679
680 #[test]
681 fn has_project_marker_detects_git() {
682 let tmp = tempfile::tempdir().unwrap();
683 let root = tmp.path().join("repo");
684 std::fs::create_dir_all(&root).unwrap();
685 assert!(!has_project_marker(&root));
686 std::fs::create_dir(root.join(".git")).unwrap();
687 assert!(has_project_marker(&root));
688 }
689
690 #[test]
691 fn has_project_marker_detects_cargo_toml() {
692 let tmp = tempfile::tempdir().unwrap();
693 let root = tmp.path().join("rust-project");
694 std::fs::create_dir_all(&root).unwrap();
695 std::fs::write(root.join("Cargo.toml"), "[package]").unwrap();
696 assert!(has_project_marker(&root));
697 }
698
699 #[test]
700 fn has_project_marker_detects_godot_project() {
701 let tmp = tempfile::tempdir().unwrap();
702 let root = tmp.path().join("godot-game");
703 std::fs::create_dir_all(&root).unwrap();
704 std::fs::write(root.join("project.godot"), "config_version=5\n").unwrap();
705 assert!(has_project_marker(&root));
706 }
707
708 #[test]
709 fn multi_repo_children_needs_two() {
710 let tmp = tempfile::tempdir().unwrap();
711 let parent = tmp.path().join("code");
712 std::fs::create_dir_all(&parent).unwrap();
713
714 assert!(!has_multi_repo_children(&parent));
716
717 let repo1 = parent.join("repo1");
719 std::fs::create_dir_all(repo1.join(".git")).unwrap();
720 assert!(!has_multi_repo_children(&parent));
721
722 let repo2 = parent.join("repo2");
724 std::fs::create_dir_all(repo2.join(".git")).unwrap();
725 assert!(has_multi_repo_children(&parent));
726 }
727
728 #[test]
729 fn multi_repo_children_ignores_files() {
730 let tmp = tempfile::tempdir().unwrap();
731 let parent = tmp.path().join("mixed");
732 std::fs::create_dir_all(&parent).unwrap();
733
734 let repo1 = parent.join("repo1");
736 std::fs::create_dir_all(repo1.join(".git")).unwrap();
737 std::fs::write(parent.join("not-a-repo"), "file").unwrap();
738 assert!(!has_multi_repo_children(&parent));
739
740 let repo2 = parent.join("repo2");
742 std::fs::create_dir_all(&repo2).unwrap();
743 std::fs::write(repo2.join("package.json"), "{}").unwrap();
744 assert!(has_multi_repo_children(&parent));
745 }
746
747 #[test]
748 fn multi_repo_children_nonexistent_dir() {
749 assert!(!has_multi_repo_children(Path::new("/nonexistent/path/xyz")));
750 }
751
752 #[test]
753 fn regular_file_is_not_symlink_or_reparse() {
754 let tmp = tempfile::tempdir().unwrap();
755 let file = tmp.path().join("plain.txt");
756 std::fs::write(&file, "x").unwrap();
757 let meta = std::fs::symlink_metadata(&file).unwrap();
758 assert!(!is_symlink_or_reparse(&meta));
759 }
760
761 #[cfg(unix)]
762 #[test]
763 fn unix_symlink_is_detected() {
764 let tmp = tempfile::tempdir().unwrap();
765 let target = tmp.path().join("target.txt");
766 std::fs::write(&target, "x").unwrap();
767 let link = tmp.path().join("link.txt");
768 std::os::unix::fs::symlink(&target, &link).unwrap();
769 let meta = std::fs::symlink_metadata(&link).unwrap();
770 assert!(is_symlink_or_reparse(&meta));
771 }
772
773 #[cfg(windows)]
776 #[test]
777 fn windows_symlink_is_detected() {
778 let tmp = tempfile::tempdir().unwrap();
779 let target = tmp.path().join("target.txt");
780 std::fs::write(&target, "x").unwrap();
781 let link = tmp.path().join("link.txt");
782 if std::os::windows::fs::symlink_file(&target, &link).is_err() {
783 eprintln!("skipping: symlink creation not permitted on this runner");
784 return;
785 }
786 let meta = std::fs::symlink_metadata(&link).unwrap();
787 assert!(is_symlink_or_reparse(&meta));
788 }
789}