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
27pub(crate) fn 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
246fn is_wsl_windows_user_profile(dir: &Path) -> bool {
247 let normalized = dir.to_string_lossy();
248 if !normalized.starts_with("/mnt/") {
249 return false;
250 }
251
252 let mut components = normalized
253 .split('/')
254 .filter(|component| !component.is_empty());
255 let (Some(mount), Some(drive), Some(users), Some(username)) = (
256 components.next(),
257 components.next(),
258 components.next(),
259 components.next(),
260 ) else {
261 return false;
262 };
263
264 mount.eq_ignore_ascii_case("mnt")
265 && drive.len() == 1
266 && drive.as_bytes()[0].is_ascii_alphabetic()
267 && users.eq_ignore_ascii_case("users")
268 && !username.is_empty()
269 && components.next().is_none()
270}
271
272pub fn is_broad_or_unsafe_root(dir: &Path) -> bool {
278 if let Some(home) = dirs::home_dir()
279 && dir == home
280 {
281 return true;
282 }
283 if is_wsl_windows_user_profile(dir) {
284 return true;
285 }
286 let s = dir.to_string_lossy();
287 if s == "/" || s == "\\" || s == "." {
288 return true;
289 }
290 is_agent_config_dir(dir)
291}
292
293pub const PROJECT_MARKERS: &[&str] = &[
295 ".git",
296 "Cargo.toml",
297 "package.json",
298 "go.mod",
299 "pyproject.toml",
300 "setup.py",
301 "pom.xml",
302 "build.gradle",
303 "Makefile",
304 "project.godot",
305 ".lean-ctx.toml",
306 ".planning",
307];
308
309pub fn has_project_marker(dir: &Path) -> bool {
316 if !may_probe_path(dir) {
317 return false;
318 }
319 PROJECT_MARKERS.iter().any(|m| dir.join(m).exists())
320}
321
322pub fn is_symlink_or_reparse(meta: &std::fs::Metadata) -> bool {
330 if meta.file_type().is_symlink() {
331 return true;
332 }
333 #[cfg(windows)]
334 {
335 use std::os::windows::fs::MetadataExt;
336 const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0400;
337 return meta.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0;
338 }
339 #[cfg(not(windows))]
340 false
341}
342
343pub fn is_tcc_sensitive_home_dir(dir: &Path) -> bool {
352 let Some(home) = dirs::home_dir() else {
353 return false;
354 };
355 if dir == home {
356 return true;
357 }
358 if dir.parent() != Some(home.as_path()) {
359 return false;
360 }
361 matches!(
362 dir.file_name().and_then(|n| n.to_str()),
363 Some("Documents" | "Desktop" | "Downloads")
364 )
365}
366
367pub fn is_under_tcc_protected_dir(path: &Path) -> bool {
375 if !cfg!(target_os = "macos") {
376 return false;
377 }
378 let Some(home) = dirs::home_dir() else {
379 return false;
380 };
381 ["Documents", "Desktop", "Downloads"]
382 .iter()
383 .any(|magic| path.starts_with(home.join(magic)))
384}
385
386pub fn process_is_tcc_standalone() -> bool {
400 #[cfg(target_os = "macos")]
401 {
402 if let Ok(v) = std::env::var("LEAN_CTX_TCC_STANDALONE") {
406 match v.trim() {
407 "1" | "true" => return true,
408 "0" | "false" => return false,
409 _ => {}
410 }
411 }
412 if std::env::var_os(crate::core::tcc_guard_sandbox::SEATBELT_SENTINEL).is_some() {
420 return true;
421 }
422 (unsafe { libc::getppid() }) == 1
424 }
425 #[cfg(not(target_os = "macos"))]
426 {
427 false
428 }
429}
430
431pub fn may_probe_path(path: &Path) -> bool {
439 !(process_is_tcc_standalone() && is_under_tcc_protected_dir(path))
440}
441
442pub fn has_multi_repo_children(dir: &Path) -> bool {
445 if is_tcc_sensitive_home_dir(dir) || !may_probe_path(dir) {
451 return false;
452 }
453 let Ok(entries) = std::fs::read_dir(dir) else {
454 return false;
455 };
456 let count = entries
457 .filter_map(Result::ok)
458 .filter(|e| e.file_type().is_ok_and(|ft| ft.is_dir()))
459 .filter(|e| has_project_marker(&e.path()))
460 .take(2)
461 .count();
462 count >= 2
463}
464
465pub fn is_data_dir_collision(project_root: &Path) -> bool {
469 if is_broad_or_unsafe_root(project_root) {
470 return true;
471 }
472 if let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() {
473 let project_lean_ctx = project_root.join(".lean-ctx");
474 if project_lean_ctx == data_dir || data_dir.starts_with(&project_lean_ctx) {
475 return true;
476 }
477 }
478 false
479}
480
481pub fn safe_project_data_dir(project_root: &Path) -> Result<PathBuf, String> {
484 if is_data_dir_collision(project_root) {
485 return Err(format!(
486 "project root {} collides with global data directory; \
487 skipping project-scoped write",
488 project_root.display()
489 ));
490 }
491 Ok(project_root.join(".lean-ctx"))
492}
493
494#[cfg(test)]
495mod tests {
496 use super::*;
497
498 #[test]
499 fn strip_regular_verbatim() {
500 let p = PathBuf::from(r"\\?\C:\Users\dev\project");
501 let result = strip_verbatim(p);
502 assert_eq!(result, PathBuf::from("C:/Users/dev/project"));
503 }
504
505 #[test]
506 fn tcc_sensitive_home_dir_matches_home_and_magic_dirs() {
507 let Some(home) = dirs::home_dir() else {
508 return;
509 };
510 assert!(is_tcc_sensitive_home_dir(&home));
512 assert!(is_tcc_sensitive_home_dir(&home.join("Documents")));
513 assert!(is_tcc_sensitive_home_dir(&home.join("Desktop")));
514 assert!(is_tcc_sensitive_home_dir(&home.join("Downloads")));
515 }
516
517 #[test]
518 fn tcc_sensitive_home_dir_allows_real_projects() {
519 let Some(home) = dirs::home_dir() else {
520 return;
521 };
522 assert!(!is_tcc_sensitive_home_dir(
525 &home.join("Documents").join("my-project")
526 ));
527 assert!(!is_tcc_sensitive_home_dir(&home.join("code")));
528 assert!(!is_tcc_sensitive_home_dir(&home.join("Projects")));
529 }
530
531 #[test]
532 #[cfg(target_os = "macos")]
533 fn under_tcc_protected_dir_matches_nested_paths() {
534 let Some(home) = dirs::home_dir() else {
535 return;
536 };
537 assert!(is_under_tcc_protected_dir(&home.join("Documents")));
539 assert!(is_under_tcc_protected_dir(
540 &home.join("Documents/deep/nested/project")
541 ));
542 assert!(is_under_tcc_protected_dir(&home.join("Desktop/scratch")));
543 assert!(is_under_tcc_protected_dir(&home.join("Downloads/x.zip")));
544 assert!(!is_under_tcc_protected_dir(&home));
546 assert!(!is_under_tcc_protected_dir(&home.join("code/project")));
547 assert!(!is_under_tcc_protected_dir(Path::new("/tmp/Documents")));
548 }
549
550 #[test]
551 #[cfg(target_os = "macos")]
552 #[serial_test::serial]
553 fn tcc_standalone_blocks_probes_under_protected_dirs() {
554 let _env_lock = crate::core::data_dir::test_env_lock();
560 let Some(home) = dirs::home_dir() else {
561 return;
562 };
563 let doc_proj = home.join("Documents/some-project");
564
565 crate::test_env::set_var("LEAN_CTX_TCC_STANDALONE", "1");
566 assert!(process_is_tcc_standalone());
567 assert!(!may_probe_path(&doc_proj));
568 assert!(may_probe_path(Path::new("/tmp/some-project")));
570 assert!(!has_project_marker(&doc_proj));
572
573 crate::test_env::set_var("LEAN_CTX_TCC_STANDALONE", "0");
574 assert!(!process_is_tcc_standalone());
575 assert!(may_probe_path(&doc_proj));
576 crate::test_env::remove_var("LEAN_CTX_TCC_STANDALONE");
577 }
578
579 #[test]
580 #[cfg(target_os = "macos")]
581 #[serial_test::serial]
582 fn tcc_standalone_detected_via_seatbelt_sentinel() {
583 let _env_lock = crate::core::data_dir::test_env_lock();
584 let Some(home) = dirs::home_dir() else {
585 return;
586 };
587 let doc_proj = home.join("Documents/some-project");
588
589 crate::test_env::remove_var("LEAN_CTX_TCC_STANDALONE");
594 crate::test_env::set_var(crate::core::tcc_guard_sandbox::SEATBELT_SENTINEL, "1");
595 assert!(process_is_tcc_standalone());
596 assert!(!may_probe_path(&doc_proj));
597 crate::test_env::remove_var(crate::core::tcc_guard_sandbox::SEATBELT_SENTINEL);
598
599 assert!(!process_is_tcc_standalone());
602 }
603
604 #[test]
605 #[cfg(target_os = "macos")]
606 #[serial_test::serial]
607 fn tcc_standalone_skips_canonicalize_under_protected_dirs() {
608 let _env_lock = crate::core::data_dir::test_env_lock();
609 let Some(home) = dirs::home_dir() else {
610 return;
611 };
612 let missing = home.join("Documents/lean-ctx-tcc-test-does-not-exist-xyzzy");
618
619 crate::test_env::set_var("LEAN_CTX_TCC_STANDALONE", "1");
620 let guarded = safe_canonicalize(&missing);
621 assert!(
622 guarded.is_ok(),
623 "standalone safe_canonicalize must short-circuit (no stat) under ~/Documents"
624 );
625 assert_eq!(guarded.unwrap(), missing);
626 assert_eq!(safe_canonicalize_or_self(&missing), missing);
627
628 let tmp_missing = Path::new("/tmp/lean-ctx-tcc-test-does-not-exist-xyzzy");
630 assert!(safe_canonicalize(tmp_missing).is_err());
631
632 crate::test_env::set_var("LEAN_CTX_TCC_STANDALONE", "0");
635 assert!(safe_canonicalize(&missing).is_err());
636
637 crate::test_env::remove_var("LEAN_CTX_TCC_STANDALONE");
638 }
639
640 #[test]
641 #[cfg(target_os = "macos")]
642 #[serial_test::serial]
643 fn canonicalize_secure_bypasses_tcc_guard_for_pathjail() {
644 let _env_lock = crate::core::data_dir::test_env_lock();
645 let Some(home) = dirs::home_dir() else {
652 return;
653 };
654 let missing = home.join("Documents/lean-ctx-secure-canon-does-not-exist-xyzzy");
655
656 crate::test_env::set_var("LEAN_CTX_TCC_STANDALONE", "1");
657 assert_eq!(safe_canonicalize(&missing).unwrap(), missing);
659 assert!(
663 canonicalize_secure(&missing).is_err(),
664 "canonicalize_secure must bypass the TCC guard and touch the filesystem"
665 );
666 assert_eq!(canonicalize_secure_or_self(&missing), missing);
667 crate::test_env::remove_var("LEAN_CTX_TCC_STANDALONE");
668 }
669
670 #[test]
671 fn strip_unc_verbatim() {
672 let p = PathBuf::from(r"\\?\UNC\server\share\dir");
673 let result = strip_verbatim(p);
674 assert_eq!(result, PathBuf::from("//server/share/dir"));
675 }
676
677 #[test]
678 fn no_prefix_unchanged() {
679 let p = PathBuf::from("/home/user/project");
680 let result = strip_verbatim(p.clone());
681 assert_eq!(result, p);
682 }
683
684 #[test]
685 fn windows_drive_unchanged() {
686 let p = PathBuf::from("C:/Users/dev");
687 let result = strip_verbatim(p.clone());
688 assert_eq!(result, p);
689 }
690
691 #[test]
692 fn strip_str_regular() {
693 assert_eq!(
694 strip_verbatim_str(r"\\?\E:\code\lean-ctx"),
695 Some("E:/code/lean-ctx".to_string())
696 );
697 }
698
699 #[test]
700 fn strip_str_unc() {
701 assert_eq!(
702 strip_verbatim_str(r"\\?\UNC\myserver\data"),
703 Some("//myserver/data".to_string())
704 );
705 }
706
707 #[test]
708 fn strip_str_forward_slash_variant() {
709 assert_eq!(
710 strip_verbatim_str("//?/C:/Users/dev"),
711 Some("C:/Users/dev".to_string())
712 );
713 }
714
715 #[test]
716 fn strip_str_no_prefix() {
717 assert_eq!(strip_verbatim_str("/home/user"), None);
718 }
719
720 #[test]
721 fn safe_canonicalize_or_self_nonexistent() {
722 let p = Path::new("/this/path/should/not/exist/xyzzy");
723 let result = safe_canonicalize_or_self(p);
724 assert_eq!(result, p.to_path_buf());
725 }
726
727 #[test]
730 fn msys_drive_prefix_translation() {
731 assert_eq!(
732 translate_msys_drive_prefix("/c/Users/ABC").as_deref(),
733 Some("C:/Users/ABC")
734 );
735 assert_eq!(
736 translate_msys_drive_prefix("/D/Program Files").as_deref(),
737 Some("D:/Program Files")
738 );
739 assert_eq!(translate_msys_drive_prefix("/usr/local/bin"), None);
740 assert_eq!(translate_msys_drive_prefix("/c"), None);
741 assert_eq!(translate_msys_drive_prefix("c/Users"), None);
742 }
743
744 #[cfg(windows)]
745 #[test]
746 fn normalize_msys_path_to_native() {
747 assert_eq!(
748 normalize_tool_path("/c/Users/ABC/AppData/lean-ctx"),
749 "C:/Users/ABC/AppData/lean-ctx"
750 );
751 assert_eq!(
752 normalize_tool_path("/D/Program Files/lean-ctx.exe"),
753 "D:/Program Files/lean-ctx.exe"
754 );
755 }
756
757 #[cfg(not(windows))]
760 #[test]
761 fn normalize_single_letter_unix_path_untouched() {
762 assert_eq!(
763 normalize_tool_path_lexical("/c/Users/me/proj"),
764 "/c/Users/me/proj"
765 );
766 assert_eq!(
767 normalize_tool_path_lexical("/x/projects/app/src"),
768 "/x/projects/app/src"
769 );
770 }
771
772 #[test]
773 fn normalize_native_windows_path_unchanged() {
774 assert_eq!(
775 normalize_tool_path("C:/Users/ABC/lean-ctx.exe"),
776 "C:/Users/ABC/lean-ctx.exe"
777 );
778 }
779
780 #[test]
781 fn normalize_backslash_windows_path() {
782 assert_eq!(
783 normalize_tool_path(r"C:\Users\ABC\lean-ctx.exe"),
784 "C:/Users/ABC/lean-ctx.exe"
785 );
786 }
787
788 #[test]
789 fn normalize_unix_path_unchanged() {
790 assert_eq!(
791 normalize_tool_path("/usr/local/bin/lean-ctx"),
792 "/usr/local/bin/lean-ctx"
793 );
794 }
795
796 #[test]
797 fn normalize_windows_path_with_spaces_and_backslashes() {
798 assert_eq!(
802 normalize_tool_path(r"C:\Users\My Name\My Project\src\main.rs"),
803 "C:/Users/My Name/My Project/src/main.rs"
804 );
805 assert_eq!(
806 normalize_tool_path(r"C:\Program Files\app\config.toml"),
807 "C:/Program Files/app/config.toml"
808 );
809 }
810
811 #[test]
812 fn normalize_double_slashes() {
813 assert_eq!(
814 normalize_tool_path("C:/Users//ABC//lean-ctx"),
815 "C:/Users/ABC/lean-ctx"
816 );
817 }
818
819 #[test]
820 fn normalize_trailing_slash_removed() {
821 assert_eq!(normalize_tool_path("C:/Users/ABC/"), "C:/Users/ABC");
822 assert_eq!(
823 normalize_tool_path_lexical("/tmp/nonexistent-dir-xyzzy/"),
824 "/tmp/nonexistent-dir-xyzzy"
825 );
826 }
827
828 #[test]
829 fn normalize_root_slash_preserved() {
830 assert_eq!(normalize_tool_path("/"), "/");
831 }
832
833 #[test]
834 fn normalize_drive_root_preserved() {
835 assert_eq!(normalize_tool_path("C:/"), "C:/");
836 }
837
838 #[test]
839 fn normalize_verbatim_with_msys() {
840 assert_eq!(normalize_tool_path(r"\\?\C:\Users\dev"), "C:/Users/dev");
841 }
842
843 #[test]
844 fn broad_root_rejects_home() {
845 if let Some(home) = dirs::home_dir() {
846 assert!(is_broad_or_unsafe_root(&home));
847 }
848 }
849
850 #[test]
851 fn broad_root_rejects_wsl_windows_user_profile() {
852 assert!(is_broad_or_unsafe_root(Path::new("/mnt/c/Users/dev")));
853 assert!(!is_broad_or_unsafe_root(Path::new(
854 "/mnt/c/Users/dev/projects/my-app"
855 )));
856 }
857
858 #[test]
859 fn broad_root_rejects_filesystem_root() {
860 assert!(is_broad_or_unsafe_root(Path::new("/")));
861 }
862
863 #[test]
864 fn broad_root_rejects_dot() {
865 assert!(is_broad_or_unsafe_root(Path::new(".")));
866 }
867
868 #[test]
869 fn broad_root_rejects_agent_dirs() {
870 assert!(is_broad_or_unsafe_root(Path::new("/home/user/.claude")));
871 assert!(is_broad_or_unsafe_root(Path::new("/home/user/.codex")));
872 }
873
874 #[test]
875 fn broad_root_rejects_copilot_and_friends() {
876 assert!(is_broad_or_unsafe_root(Path::new("/home/user/.copilot")));
879 assert!(is_broad_or_unsafe_root(Path::new("/home/user/.cursor")));
880 assert!(is_broad_or_unsafe_root(Path::new("/home/user/.windsurf")));
881 assert!(is_broad_or_unsafe_root(Path::new("/home/user/.gemini")));
882 assert!(is_broad_or_unsafe_root(Path::new("/home/user/.lmstudio")));
883 }
884
885 #[test]
886 fn agent_config_dir_matches_every_known_client() {
887 for name in AGENT_CONFIG_DIRS {
888 let leaf = format!("/home/user/{name}");
889 assert!(is_agent_config_dir(Path::new(&leaf)), "{leaf}");
890 let nested = format!("/home/user/{name}/mcp");
891 assert!(is_agent_config_dir(Path::new(&nested)), "{nested}");
892 }
893 }
894
895 #[test]
896 fn agent_config_dir_matches_windows_backslash() {
897 assert!(is_agent_config_dir(Path::new(r"C:\Users\me\.copilot")));
899 assert!(is_agent_config_dir(Path::new(r"C:\Users\me\.copilot\mcp")));
900 }
901
902 #[test]
903 fn agent_config_dir_ignores_real_projects() {
904 assert!(!is_agent_config_dir(Path::new("/home/user/code/lean-ctx")));
905 assert!(!is_agent_config_dir(Path::new(r"C:\src\app")));
906 }
907
908 #[test]
909 fn broad_root_allows_project_subdir() {
910 let tmp = tempfile::tempdir().unwrap();
911 let subdir = tmp.path().join("my-project");
912 std::fs::create_dir_all(&subdir).unwrap();
913 assert!(!is_broad_or_unsafe_root(&subdir));
914 }
915
916 #[test]
917 fn broad_root_allows_home_subdirs() {
918 if let Some(home) = dirs::home_dir() {
919 let subdir = home.join("projects").join("my-app");
920 assert!(!is_broad_or_unsafe_root(&subdir));
921 }
922 }
923
924 #[test]
925 fn data_dir_collision_rejects_home() {
926 if let Some(home) = dirs::home_dir() {
927 assert!(is_data_dir_collision(&home));
928 }
929 }
930
931 #[test]
932 fn data_dir_collision_allows_normal_project() {
933 let tmp = tempfile::tempdir().unwrap();
934 let project = tmp.path().join("my-project");
935 std::fs::create_dir_all(&project).unwrap();
936 assert!(!is_data_dir_collision(&project));
937 }
938
939 #[test]
940 fn has_project_marker_detects_git() {
941 let tmp = tempfile::tempdir().unwrap();
942 let root = tmp.path().join("repo");
943 std::fs::create_dir_all(&root).unwrap();
944 assert!(!has_project_marker(&root));
945 std::fs::create_dir(root.join(".git")).unwrap();
946 assert!(has_project_marker(&root));
947 }
948
949 #[test]
950 fn has_project_marker_detects_cargo_toml() {
951 let tmp = tempfile::tempdir().unwrap();
952 let root = tmp.path().join("rust-project");
953 std::fs::create_dir_all(&root).unwrap();
954 std::fs::write(root.join("Cargo.toml"), "[package]").unwrap();
955 assert!(has_project_marker(&root));
956 }
957
958 #[test]
959 fn has_project_marker_detects_godot_project() {
960 let tmp = tempfile::tempdir().unwrap();
961 let root = tmp.path().join("godot-game");
962 std::fs::create_dir_all(&root).unwrap();
963 std::fs::write(root.join("project.godot"), "config_version=5\n").unwrap();
964 assert!(has_project_marker(&root));
965 }
966
967 #[test]
968 fn multi_repo_children_needs_two() {
969 let tmp = tempfile::tempdir().unwrap();
970 let parent = tmp.path().join("code");
971 std::fs::create_dir_all(&parent).unwrap();
972
973 assert!(!has_multi_repo_children(&parent));
975
976 let repo1 = parent.join("repo1");
978 std::fs::create_dir_all(repo1.join(".git")).unwrap();
979 assert!(!has_multi_repo_children(&parent));
980
981 let repo2 = parent.join("repo2");
983 std::fs::create_dir_all(repo2.join(".git")).unwrap();
984 assert!(has_multi_repo_children(&parent));
985 }
986
987 #[test]
988 fn multi_repo_children_ignores_files() {
989 let tmp = tempfile::tempdir().unwrap();
990 let parent = tmp.path().join("mixed");
991 std::fs::create_dir_all(&parent).unwrap();
992
993 let repo1 = parent.join("repo1");
995 std::fs::create_dir_all(repo1.join(".git")).unwrap();
996 std::fs::write(parent.join("not-a-repo"), "file").unwrap();
997 assert!(!has_multi_repo_children(&parent));
998
999 let repo2 = parent.join("repo2");
1001 std::fs::create_dir_all(&repo2).unwrap();
1002 std::fs::write(repo2.join("package.json"), "{}").unwrap();
1003 assert!(has_multi_repo_children(&parent));
1004 }
1005
1006 #[test]
1007 fn multi_repo_children_nonexistent_dir() {
1008 assert!(!has_multi_repo_children(Path::new("/nonexistent/path/xyz")));
1009 }
1010
1011 #[test]
1012 fn regular_file_is_not_symlink_or_reparse() {
1013 let tmp = tempfile::tempdir().unwrap();
1014 let file = tmp.path().join("plain.txt");
1015 std::fs::write(&file, "x").unwrap();
1016 let meta = std::fs::symlink_metadata(&file).unwrap();
1017 assert!(!is_symlink_or_reparse(&meta));
1018 }
1019
1020 #[cfg(unix)]
1021 #[test]
1022 fn unix_symlink_is_detected() {
1023 let tmp = tempfile::tempdir().unwrap();
1024 let target = tmp.path().join("target.txt");
1025 std::fs::write(&target, "x").unwrap();
1026 let link = tmp.path().join("link.txt");
1027 std::os::unix::fs::symlink(&target, &link).unwrap();
1028 let meta = std::fs::symlink_metadata(&link).unwrap();
1029 assert!(is_symlink_or_reparse(&meta));
1030 }
1031
1032 #[cfg(windows)]
1035 #[test]
1036 fn windows_symlink_is_detected() {
1037 let tmp = tempfile::tempdir().unwrap();
1038 let target = tmp.path().join("target.txt");
1039 std::fs::write(&target, "x").unwrap();
1040 let link = tmp.path().join("link.txt");
1041 if std::os::windows::fs::symlink_file(&target, &link).is_err() {
1042 eprintln!("skipping: symlink creation not permitted on this runner");
1043 return;
1044 }
1045 let meta = std::fs::symlink_metadata(&link).unwrap();
1046 assert!(is_symlink_or_reparse(&meta));
1047 }
1048}