1use std::path::{Path, PathBuf};
2
3pub fn safe_canonicalize(path: &Path) -> std::io::Result<PathBuf> {
9 if !may_probe_path(path) {
22 return Ok(path.to_path_buf());
23 }
24 canonicalize_raw(path)
25}
26
27fn canonicalize_raw(path: &Path) -> std::io::Result<PathBuf> {
31 let canon = std::fs::canonicalize(path)?;
32 Ok(strip_verbatim(canon))
33}
34
35pub fn safe_canonicalize_or_self(path: &Path) -> PathBuf {
37 safe_canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
38}
39
40pub fn canonicalize_secure(path: &Path) -> std::io::Result<PathBuf> {
47 canonicalize_raw(path)
48}
49
50pub fn canonicalize_secure_or_self(path: &Path) -> PathBuf {
52 canonicalize_secure(path).unwrap_or_else(|_| path.to_path_buf())
53}
54
55pub fn safe_canonicalize_bounded(path: &Path, timeout_ms: u64) -> PathBuf {
62 canonicalize_bounded_with(path, timeout_ms, safe_canonicalize_or_self)
63}
64
65pub fn canonicalize_secure_bounded(path: &Path, timeout_ms: u64) -> PathBuf {
69 canonicalize_bounded_with(path, timeout_ms, canonicalize_secure_or_self)
70}
71
72fn canonicalize_bounded_with(
76 path: &Path,
77 timeout_ms: u64,
78 resolve: fn(&Path) -> PathBuf,
79) -> PathBuf {
80 use super::io_health;
81
82 let path_str = path.to_string_lossy();
83 if io_health::is_slow_mount(&path_str) && io_health::recent_freeze_count() > 0 {
84 return resolve(path);
85 }
86
87 let effective_timeout =
88 io_health::adaptive_timeout(std::time::Duration::from_millis(timeout_ms));
89
90 let path_owned = path.to_path_buf();
91 let (tx, rx) = std::sync::mpsc::channel();
92 let _ = std::thread::Builder::new()
93 .name("canonicalize-bounded".into())
94 .spawn(move || {
95 let _ = tx.send(resolve(&path_owned));
96 });
97 if let Ok(canonical) = rx.recv_timeout(effective_timeout) {
98 canonical
99 } else {
100 io_health::record_freeze();
101 tracing::warn!(
102 "[SECURITY] canonicalize timed out ({}ms) for {}; PathJail checks on \
103 uncanonicalized paths may be less reliable",
104 effective_timeout.as_millis(),
105 path.display()
106 );
107 path.to_path_buf()
108 }
109}
110
111pub fn strip_verbatim(path: PathBuf) -> PathBuf {
114 let s = path.to_string_lossy();
115 if let Some(stripped) = strip_verbatim_str(&s) {
116 PathBuf::from(stripped)
117 } else {
118 path
119 }
120}
121
122pub fn strip_verbatim_str(path: &str) -> Option<String> {
125 let normalized = path.replace('\\', "/");
126
127 if let Some(rest) = normalized.strip_prefix("//?/UNC/") {
128 Some(format!("//{rest}"))
129 } else {
130 normalized
131 .strip_prefix("//?/")
132 .map(std::string::ToString::to_string)
133 }
134}
135
136fn translate_msys_drive_prefix(p: &str) -> Option<String> {
145 if p.len() >= 3
146 && p.starts_with('/')
147 && p.as_bytes()[1].is_ascii_alphabetic()
148 && p.as_bytes()[2] == b'/'
149 {
150 let drive = p.as_bytes()[1].to_ascii_uppercase() as char;
151 Some(format!("{drive}:{}", &p[2..]))
152 } else {
153 None
154 }
155}
156
157pub fn normalize_tool_path_lexical(path: &str) -> String {
163 let mut p = match strip_verbatim_str(path) {
164 Some(stripped) => stripped,
165 None => path.to_string(),
166 };
167
168 if cfg!(windows)
169 && let Some(translated) = translate_msys_drive_prefix(&p)
170 {
171 p = translated;
172 }
173
174 p = p.replace('\\', "/");
175
176 while p.contains("//") && !p.starts_with("//") {
178 p = p.replace("//", "/");
179 }
180
181 if p.len() > 1 && p.ends_with('/') && !p.ends_with(":/") {
183 p.pop();
184 }
185
186 p
187}
188
189pub fn normalize_tool_path(path: &str) -> String {
195 let mut p = normalize_tool_path_lexical(path);
196
197 let is_absolute = p.starts_with('/') || (p.len() >= 3 && p.as_bytes()[1] == b':');
203 let is_root_only = p == "/" || (p.len() <= 3 && p.ends_with('/') && is_absolute);
204 if is_absolute
205 && !is_root_only
206 && !crate::core::io_health::is_slow_mount(&p)
207 && may_probe_path(Path::new(&*p))
208 && let Ok(canonical) = safe_canonicalize(Path::new(&*p))
209 {
210 let canonical_str = canonical.to_string_lossy().replace('\\', "/");
211 if !canonical_str.is_empty() {
212 p = canonical_str;
213 }
214 }
215
216 p
217}
218
219pub const AGENT_CONFIG_DIRS: &[&str] = &[
226 ".claude",
227 ".codex",
228 ".codebuddy",
229 ".copilot",
230 ".cursor",
231 ".windsurf",
232 ".gemini",
233 ".lmstudio",
234];
235
236pub fn is_agent_config_dir(dir: &Path) -> bool {
240 let s = dir.to_string_lossy().replace('\\', "/");
241 AGENT_CONFIG_DIRS
242 .iter()
243 .any(|name| s.ends_with(&format!("/{name}")) || s.contains(&format!("/{name}/")))
244}
245
246pub fn is_broad_or_unsafe_root(dir: &Path) -> bool {
252 if let Some(home) = dirs::home_dir()
253 && dir == home
254 {
255 return true;
256 }
257 let s = dir.to_string_lossy();
258 if s == "/" || s == "\\" || s == "." {
259 return true;
260 }
261 is_agent_config_dir(dir)
262}
263
264pub const PROJECT_MARKERS: &[&str] = &[
266 ".git",
267 "Cargo.toml",
268 "package.json",
269 "go.mod",
270 "pyproject.toml",
271 "setup.py",
272 "pom.xml",
273 "build.gradle",
274 "Makefile",
275 "project.godot",
276 ".lean-ctx.toml",
277 ".planning",
278];
279
280pub fn has_project_marker(dir: &Path) -> bool {
287 if !may_probe_path(dir) {
288 return false;
289 }
290 PROJECT_MARKERS.iter().any(|m| dir.join(m).exists())
291}
292
293pub fn is_symlink_or_reparse(meta: &std::fs::Metadata) -> bool {
301 if meta.file_type().is_symlink() {
302 return true;
303 }
304 #[cfg(windows)]
305 {
306 use std::os::windows::fs::MetadataExt;
307 const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0400;
308 return meta.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0;
309 }
310 #[cfg(not(windows))]
311 false
312}
313
314pub fn is_tcc_sensitive_home_dir(dir: &Path) -> bool {
323 let Some(home) = dirs::home_dir() else {
324 return false;
325 };
326 if dir == home {
327 return true;
328 }
329 if dir.parent() != Some(home.as_path()) {
330 return false;
331 }
332 matches!(
333 dir.file_name().and_then(|n| n.to_str()),
334 Some("Documents" | "Desktop" | "Downloads")
335 )
336}
337
338pub fn is_under_tcc_protected_dir(path: &Path) -> bool {
346 if !cfg!(target_os = "macos") {
347 return false;
348 }
349 let Some(home) = dirs::home_dir() else {
350 return false;
351 };
352 ["Documents", "Desktop", "Downloads"]
353 .iter()
354 .any(|magic| path.starts_with(home.join(magic)))
355}
356
357pub fn process_is_tcc_standalone() -> bool {
371 #[cfg(target_os = "macos")]
372 {
373 if let Ok(v) = std::env::var("LEAN_CTX_TCC_STANDALONE") {
377 match v.trim() {
378 "1" | "true" => return true,
379 "0" | "false" => return false,
380 _ => {}
381 }
382 }
383 if std::env::var_os(crate::core::tcc_guard_sandbox::SEATBELT_SENTINEL).is_some() {
391 return true;
392 }
393 (unsafe { libc::getppid() }) == 1
395 }
396 #[cfg(not(target_os = "macos"))]
397 {
398 false
399 }
400}
401
402pub fn may_probe_path(path: &Path) -> bool {
410 !(process_is_tcc_standalone() && is_under_tcc_protected_dir(path))
411}
412
413pub fn has_multi_repo_children(dir: &Path) -> bool {
416 if is_tcc_sensitive_home_dir(dir) || !may_probe_path(dir) {
422 return false;
423 }
424 let Ok(entries) = std::fs::read_dir(dir) else {
425 return false;
426 };
427 let count = entries
428 .filter_map(Result::ok)
429 .filter(|e| e.file_type().is_ok_and(|ft| ft.is_dir()))
430 .filter(|e| has_project_marker(&e.path()))
431 .take(2)
432 .count();
433 count >= 2
434}
435
436pub fn is_data_dir_collision(project_root: &Path) -> bool {
440 if is_broad_or_unsafe_root(project_root) {
441 return true;
442 }
443 if let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() {
444 let project_lean_ctx = project_root.join(".lean-ctx");
445 if project_lean_ctx == data_dir || data_dir.starts_with(&project_lean_ctx) {
446 return true;
447 }
448 }
449 false
450}
451
452pub fn safe_project_data_dir(project_root: &Path) -> Result<PathBuf, String> {
455 if is_data_dir_collision(project_root) {
456 return Err(format!(
457 "project root {} collides with global data directory; \
458 skipping project-scoped write",
459 project_root.display()
460 ));
461 }
462 Ok(project_root.join(".lean-ctx"))
463}
464
465#[cfg(test)]
466mod tests {
467 use super::*;
468
469 #[test]
470 fn strip_regular_verbatim() {
471 let p = PathBuf::from(r"\\?\C:\Users\dev\project");
472 let result = strip_verbatim(p);
473 assert_eq!(result, PathBuf::from("C:/Users/dev/project"));
474 }
475
476 #[test]
477 fn tcc_sensitive_home_dir_matches_home_and_magic_dirs() {
478 let Some(home) = dirs::home_dir() else {
479 return;
480 };
481 assert!(is_tcc_sensitive_home_dir(&home));
483 assert!(is_tcc_sensitive_home_dir(&home.join("Documents")));
484 assert!(is_tcc_sensitive_home_dir(&home.join("Desktop")));
485 assert!(is_tcc_sensitive_home_dir(&home.join("Downloads")));
486 }
487
488 #[test]
489 fn tcc_sensitive_home_dir_allows_real_projects() {
490 let Some(home) = dirs::home_dir() else {
491 return;
492 };
493 assert!(!is_tcc_sensitive_home_dir(
496 &home.join("Documents").join("my-project")
497 ));
498 assert!(!is_tcc_sensitive_home_dir(&home.join("code")));
499 assert!(!is_tcc_sensitive_home_dir(&home.join("Projects")));
500 }
501
502 #[test]
503 #[cfg(target_os = "macos")]
504 fn under_tcc_protected_dir_matches_nested_paths() {
505 let Some(home) = dirs::home_dir() else {
506 return;
507 };
508 assert!(is_under_tcc_protected_dir(&home.join("Documents")));
510 assert!(is_under_tcc_protected_dir(
511 &home.join("Documents/deep/nested/project")
512 ));
513 assert!(is_under_tcc_protected_dir(&home.join("Desktop/scratch")));
514 assert!(is_under_tcc_protected_dir(&home.join("Downloads/x.zip")));
515 assert!(!is_under_tcc_protected_dir(&home));
517 assert!(!is_under_tcc_protected_dir(&home.join("code/project")));
518 assert!(!is_under_tcc_protected_dir(Path::new("/tmp/Documents")));
519 }
520
521 #[test]
522 #[cfg(target_os = "macos")]
523 #[serial_test::serial]
524 fn tcc_standalone_blocks_probes_under_protected_dirs() {
525 let Some(home) = dirs::home_dir() else {
526 return;
527 };
528 let doc_proj = home.join("Documents/some-project");
529
530 crate::test_env::set_var("LEAN_CTX_TCC_STANDALONE", "1");
531 assert!(process_is_tcc_standalone());
532 assert!(!may_probe_path(&doc_proj));
533 assert!(may_probe_path(Path::new("/tmp/some-project")));
535 assert!(!has_project_marker(&doc_proj));
537
538 crate::test_env::set_var("LEAN_CTX_TCC_STANDALONE", "0");
539 assert!(!process_is_tcc_standalone());
540 assert!(may_probe_path(&doc_proj));
541 crate::test_env::remove_var("LEAN_CTX_TCC_STANDALONE");
542 }
543
544 #[test]
545 #[cfg(target_os = "macos")]
546 #[serial_test::serial]
547 fn tcc_standalone_detected_via_seatbelt_sentinel() {
548 let Some(home) = dirs::home_dir() else {
549 return;
550 };
551 let doc_proj = home.join("Documents/some-project");
552
553 crate::test_env::remove_var("LEAN_CTX_TCC_STANDALONE");
558 crate::test_env::set_var(crate::core::tcc_guard_sandbox::SEATBELT_SENTINEL, "1");
559 assert!(process_is_tcc_standalone());
560 assert!(!may_probe_path(&doc_proj));
561 crate::test_env::remove_var(crate::core::tcc_guard_sandbox::SEATBELT_SENTINEL);
562
563 assert!(!process_is_tcc_standalone());
566 }
567
568 #[test]
569 #[cfg(target_os = "macos")]
570 #[serial_test::serial]
571 fn tcc_standalone_skips_canonicalize_under_protected_dirs() {
572 let Some(home) = dirs::home_dir() else {
573 return;
574 };
575 let missing = home.join("Documents/lean-ctx-tcc-test-does-not-exist-xyzzy");
581
582 crate::test_env::set_var("LEAN_CTX_TCC_STANDALONE", "1");
583 let guarded = safe_canonicalize(&missing);
584 assert!(
585 guarded.is_ok(),
586 "standalone safe_canonicalize must short-circuit (no stat) under ~/Documents"
587 );
588 assert_eq!(guarded.unwrap(), missing);
589 assert_eq!(safe_canonicalize_or_self(&missing), missing);
590
591 let tmp_missing = Path::new("/tmp/lean-ctx-tcc-test-does-not-exist-xyzzy");
593 assert!(safe_canonicalize(tmp_missing).is_err());
594
595 crate::test_env::set_var("LEAN_CTX_TCC_STANDALONE", "0");
598 assert!(safe_canonicalize(&missing).is_err());
599
600 crate::test_env::remove_var("LEAN_CTX_TCC_STANDALONE");
601 }
602
603 #[test]
604 #[cfg(target_os = "macos")]
605 #[serial_test::serial]
606 fn canonicalize_secure_bypasses_tcc_guard_for_pathjail() {
607 let Some(home) = dirs::home_dir() else {
614 return;
615 };
616 let missing = home.join("Documents/lean-ctx-secure-canon-does-not-exist-xyzzy");
617
618 crate::test_env::set_var("LEAN_CTX_TCC_STANDALONE", "1");
619 assert_eq!(safe_canonicalize(&missing).unwrap(), missing);
621 assert!(
625 canonicalize_secure(&missing).is_err(),
626 "canonicalize_secure must bypass the TCC guard and touch the filesystem"
627 );
628 assert_eq!(canonicalize_secure_or_self(&missing), missing);
629 crate::test_env::remove_var("LEAN_CTX_TCC_STANDALONE");
630 }
631
632 #[test]
633 fn strip_unc_verbatim() {
634 let p = PathBuf::from(r"\\?\UNC\server\share\dir");
635 let result = strip_verbatim(p);
636 assert_eq!(result, PathBuf::from("//server/share/dir"));
637 }
638
639 #[test]
640 fn no_prefix_unchanged() {
641 let p = PathBuf::from("/home/user/project");
642 let result = strip_verbatim(p.clone());
643 assert_eq!(result, p);
644 }
645
646 #[test]
647 fn windows_drive_unchanged() {
648 let p = PathBuf::from("C:/Users/dev");
649 let result = strip_verbatim(p.clone());
650 assert_eq!(result, p);
651 }
652
653 #[test]
654 fn strip_str_regular() {
655 assert_eq!(
656 strip_verbatim_str(r"\\?\E:\code\lean-ctx"),
657 Some("E:/code/lean-ctx".to_string())
658 );
659 }
660
661 #[test]
662 fn strip_str_unc() {
663 assert_eq!(
664 strip_verbatim_str(r"\\?\UNC\myserver\data"),
665 Some("//myserver/data".to_string())
666 );
667 }
668
669 #[test]
670 fn strip_str_forward_slash_variant() {
671 assert_eq!(
672 strip_verbatim_str("//?/C:/Users/dev"),
673 Some("C:/Users/dev".to_string())
674 );
675 }
676
677 #[test]
678 fn strip_str_no_prefix() {
679 assert_eq!(strip_verbatim_str("/home/user"), None);
680 }
681
682 #[test]
683 fn safe_canonicalize_or_self_nonexistent() {
684 let p = Path::new("/this/path/should/not/exist/xyzzy");
685 let result = safe_canonicalize_or_self(p);
686 assert_eq!(result, p.to_path_buf());
687 }
688
689 #[test]
692 fn msys_drive_prefix_translation() {
693 assert_eq!(
694 translate_msys_drive_prefix("/c/Users/ABC").as_deref(),
695 Some("C:/Users/ABC")
696 );
697 assert_eq!(
698 translate_msys_drive_prefix("/D/Program Files").as_deref(),
699 Some("D:/Program Files")
700 );
701 assert_eq!(translate_msys_drive_prefix("/usr/local/bin"), None);
702 assert_eq!(translate_msys_drive_prefix("/c"), None);
703 assert_eq!(translate_msys_drive_prefix("c/Users"), None);
704 }
705
706 #[cfg(windows)]
707 #[test]
708 fn normalize_msys_path_to_native() {
709 assert_eq!(
710 normalize_tool_path("/c/Users/ABC/AppData/lean-ctx"),
711 "C:/Users/ABC/AppData/lean-ctx"
712 );
713 assert_eq!(
714 normalize_tool_path("/D/Program Files/lean-ctx.exe"),
715 "D:/Program Files/lean-ctx.exe"
716 );
717 }
718
719 #[cfg(not(windows))]
722 #[test]
723 fn normalize_single_letter_unix_path_untouched() {
724 assert_eq!(
725 normalize_tool_path_lexical("/c/Users/me/proj"),
726 "/c/Users/me/proj"
727 );
728 assert_eq!(
729 normalize_tool_path_lexical("/x/projects/app/src"),
730 "/x/projects/app/src"
731 );
732 }
733
734 #[test]
735 fn normalize_native_windows_path_unchanged() {
736 assert_eq!(
737 normalize_tool_path("C:/Users/ABC/lean-ctx.exe"),
738 "C:/Users/ABC/lean-ctx.exe"
739 );
740 }
741
742 #[test]
743 fn normalize_backslash_windows_path() {
744 assert_eq!(
745 normalize_tool_path(r"C:\Users\ABC\lean-ctx.exe"),
746 "C:/Users/ABC/lean-ctx.exe"
747 );
748 }
749
750 #[test]
751 fn normalize_unix_path_unchanged() {
752 assert_eq!(
753 normalize_tool_path("/usr/local/bin/lean-ctx"),
754 "/usr/local/bin/lean-ctx"
755 );
756 }
757
758 #[test]
759 fn normalize_windows_path_with_spaces_and_backslashes() {
760 assert_eq!(
764 normalize_tool_path(r"C:\Users\My Name\My Project\src\main.rs"),
765 "C:/Users/My Name/My Project/src/main.rs"
766 );
767 assert_eq!(
768 normalize_tool_path(r"C:\Program Files\app\config.toml"),
769 "C:/Program Files/app/config.toml"
770 );
771 }
772
773 #[test]
774 fn normalize_double_slashes() {
775 assert_eq!(
776 normalize_tool_path("C:/Users//ABC//lean-ctx"),
777 "C:/Users/ABC/lean-ctx"
778 );
779 }
780
781 #[test]
782 fn normalize_trailing_slash_removed() {
783 assert_eq!(normalize_tool_path("C:/Users/ABC/"), "C:/Users/ABC");
784 assert_eq!(
785 normalize_tool_path_lexical("/tmp/nonexistent-dir-xyzzy/"),
786 "/tmp/nonexistent-dir-xyzzy"
787 );
788 }
789
790 #[test]
791 fn normalize_root_slash_preserved() {
792 assert_eq!(normalize_tool_path("/"), "/");
793 }
794
795 #[test]
796 fn normalize_drive_root_preserved() {
797 assert_eq!(normalize_tool_path("C:/"), "C:/");
798 }
799
800 #[test]
801 fn normalize_verbatim_with_msys() {
802 assert_eq!(normalize_tool_path(r"\\?\C:\Users\dev"), "C:/Users/dev");
803 }
804
805 #[test]
806 fn broad_root_rejects_home() {
807 if let Some(home) = dirs::home_dir() {
808 assert!(is_broad_or_unsafe_root(&home));
809 }
810 }
811
812 #[test]
813 fn broad_root_rejects_filesystem_root() {
814 assert!(is_broad_or_unsafe_root(Path::new("/")));
815 }
816
817 #[test]
818 fn broad_root_rejects_dot() {
819 assert!(is_broad_or_unsafe_root(Path::new(".")));
820 }
821
822 #[test]
823 fn broad_root_rejects_agent_dirs() {
824 assert!(is_broad_or_unsafe_root(Path::new("/home/user/.claude")));
825 assert!(is_broad_or_unsafe_root(Path::new("/home/user/.codex")));
826 }
827
828 #[test]
829 fn broad_root_rejects_copilot_and_friends() {
830 assert!(is_broad_or_unsafe_root(Path::new("/home/user/.copilot")));
833 assert!(is_broad_or_unsafe_root(Path::new("/home/user/.cursor")));
834 assert!(is_broad_or_unsafe_root(Path::new("/home/user/.windsurf")));
835 assert!(is_broad_or_unsafe_root(Path::new("/home/user/.gemini")));
836 assert!(is_broad_or_unsafe_root(Path::new("/home/user/.lmstudio")));
837 }
838
839 #[test]
840 fn agent_config_dir_matches_every_known_client() {
841 for name in AGENT_CONFIG_DIRS {
842 let leaf = format!("/home/user/{name}");
843 assert!(is_agent_config_dir(Path::new(&leaf)), "{leaf}");
844 let nested = format!("/home/user/{name}/mcp");
845 assert!(is_agent_config_dir(Path::new(&nested)), "{nested}");
846 }
847 }
848
849 #[test]
850 fn agent_config_dir_matches_windows_backslash() {
851 assert!(is_agent_config_dir(Path::new(r"C:\Users\me\.copilot")));
853 assert!(is_agent_config_dir(Path::new(r"C:\Users\me\.copilot\mcp")));
854 }
855
856 #[test]
857 fn agent_config_dir_ignores_real_projects() {
858 assert!(!is_agent_config_dir(Path::new("/home/user/code/lean-ctx")));
859 assert!(!is_agent_config_dir(Path::new(r"C:\src\app")));
860 }
861
862 #[test]
863 fn broad_root_allows_project_subdir() {
864 let tmp = tempfile::tempdir().unwrap();
865 let subdir = tmp.path().join("my-project");
866 std::fs::create_dir_all(&subdir).unwrap();
867 assert!(!is_broad_or_unsafe_root(&subdir));
868 }
869
870 #[test]
871 fn broad_root_allows_home_subdirs() {
872 if let Some(home) = dirs::home_dir() {
873 let subdir = home.join("projects").join("my-app");
874 assert!(!is_broad_or_unsafe_root(&subdir));
875 }
876 }
877
878 #[test]
879 fn data_dir_collision_rejects_home() {
880 if let Some(home) = dirs::home_dir() {
881 assert!(is_data_dir_collision(&home));
882 }
883 }
884
885 #[test]
886 fn data_dir_collision_allows_normal_project() {
887 let tmp = tempfile::tempdir().unwrap();
888 let project = tmp.path().join("my-project");
889 std::fs::create_dir_all(&project).unwrap();
890 assert!(!is_data_dir_collision(&project));
891 }
892
893 #[test]
894 fn has_project_marker_detects_git() {
895 let tmp = tempfile::tempdir().unwrap();
896 let root = tmp.path().join("repo");
897 std::fs::create_dir_all(&root).unwrap();
898 assert!(!has_project_marker(&root));
899 std::fs::create_dir(root.join(".git")).unwrap();
900 assert!(has_project_marker(&root));
901 }
902
903 #[test]
904 fn has_project_marker_detects_cargo_toml() {
905 let tmp = tempfile::tempdir().unwrap();
906 let root = tmp.path().join("rust-project");
907 std::fs::create_dir_all(&root).unwrap();
908 std::fs::write(root.join("Cargo.toml"), "[package]").unwrap();
909 assert!(has_project_marker(&root));
910 }
911
912 #[test]
913 fn has_project_marker_detects_godot_project() {
914 let tmp = tempfile::tempdir().unwrap();
915 let root = tmp.path().join("godot-game");
916 std::fs::create_dir_all(&root).unwrap();
917 std::fs::write(root.join("project.godot"), "config_version=5\n").unwrap();
918 assert!(has_project_marker(&root));
919 }
920
921 #[test]
922 fn multi_repo_children_needs_two() {
923 let tmp = tempfile::tempdir().unwrap();
924 let parent = tmp.path().join("code");
925 std::fs::create_dir_all(&parent).unwrap();
926
927 assert!(!has_multi_repo_children(&parent));
929
930 let repo1 = parent.join("repo1");
932 std::fs::create_dir_all(repo1.join(".git")).unwrap();
933 assert!(!has_multi_repo_children(&parent));
934
935 let repo2 = parent.join("repo2");
937 std::fs::create_dir_all(repo2.join(".git")).unwrap();
938 assert!(has_multi_repo_children(&parent));
939 }
940
941 #[test]
942 fn multi_repo_children_ignores_files() {
943 let tmp = tempfile::tempdir().unwrap();
944 let parent = tmp.path().join("mixed");
945 std::fs::create_dir_all(&parent).unwrap();
946
947 let repo1 = parent.join("repo1");
949 std::fs::create_dir_all(repo1.join(".git")).unwrap();
950 std::fs::write(parent.join("not-a-repo"), "file").unwrap();
951 assert!(!has_multi_repo_children(&parent));
952
953 let repo2 = parent.join("repo2");
955 std::fs::create_dir_all(&repo2).unwrap();
956 std::fs::write(repo2.join("package.json"), "{}").unwrap();
957 assert!(has_multi_repo_children(&parent));
958 }
959
960 #[test]
961 fn multi_repo_children_nonexistent_dir() {
962 assert!(!has_multi_repo_children(Path::new("/nonexistent/path/xyz")));
963 }
964
965 #[test]
966 fn regular_file_is_not_symlink_or_reparse() {
967 let tmp = tempfile::tempdir().unwrap();
968 let file = tmp.path().join("plain.txt");
969 std::fs::write(&file, "x").unwrap();
970 let meta = std::fs::symlink_metadata(&file).unwrap();
971 assert!(!is_symlink_or_reparse(&meta));
972 }
973
974 #[cfg(unix)]
975 #[test]
976 fn unix_symlink_is_detected() {
977 let tmp = tempfile::tempdir().unwrap();
978 let target = tmp.path().join("target.txt");
979 std::fs::write(&target, "x").unwrap();
980 let link = tmp.path().join("link.txt");
981 std::os::unix::fs::symlink(&target, &link).unwrap();
982 let meta = std::fs::symlink_metadata(&link).unwrap();
983 assert!(is_symlink_or_reparse(&meta));
984 }
985
986 #[cfg(windows)]
989 #[test]
990 fn windows_symlink_is_detected() {
991 let tmp = tempfile::tempdir().unwrap();
992 let target = tmp.path().join("target.txt");
993 std::fs::write(&target, "x").unwrap();
994 let link = tmp.path().join("link.txt");
995 if std::os::windows::fs::symlink_file(&target, &link).is_err() {
996 eprintln!("skipping: symlink creation not permitted on this runner");
997 return;
998 }
999 let meta = std::fs::symlink_metadata(&link).unwrap();
1000 assert!(is_symlink_or_reparse(&meta));
1001 }
1002}