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