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 fn is_broad_or_unsafe_root(dir: &Path) -> bool {
224 if let Some(home) = dirs::home_dir()
225 && dir == home
226 {
227 return true;
228 }
229 let s = dir.to_string_lossy();
230 if s == "/" || s == "\\" || s == "." {
231 return true;
232 }
233 s.ends_with("/.claude")
234 || s.ends_with("/.codex")
235 || s.ends_with("/.codebuddy")
236 || s.contains("/.claude/")
237 || s.contains("/.codex/")
238 || s.contains("/.codebuddy/")
239}
240
241pub const PROJECT_MARKERS: &[&str] = &[
243 ".git",
244 "Cargo.toml",
245 "package.json",
246 "go.mod",
247 "pyproject.toml",
248 "setup.py",
249 "pom.xml",
250 "build.gradle",
251 "Makefile",
252 "project.godot",
253 ".lean-ctx.toml",
254 ".planning",
255];
256
257pub fn has_project_marker(dir: &Path) -> bool {
264 if !may_probe_path(dir) {
265 return false;
266 }
267 PROJECT_MARKERS.iter().any(|m| dir.join(m).exists())
268}
269
270pub fn is_symlink_or_reparse(meta: &std::fs::Metadata) -> bool {
278 if meta.file_type().is_symlink() {
279 return true;
280 }
281 #[cfg(windows)]
282 {
283 use std::os::windows::fs::MetadataExt;
284 const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0400;
285 return meta.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0;
286 }
287 #[cfg(not(windows))]
288 false
289}
290
291pub fn is_tcc_sensitive_home_dir(dir: &Path) -> bool {
300 let Some(home) = dirs::home_dir() else {
301 return false;
302 };
303 if dir == home {
304 return true;
305 }
306 if dir.parent() != Some(home.as_path()) {
307 return false;
308 }
309 matches!(
310 dir.file_name().and_then(|n| n.to_str()),
311 Some("Documents" | "Desktop" | "Downloads")
312 )
313}
314
315pub fn is_under_tcc_protected_dir(path: &Path) -> bool {
323 if !cfg!(target_os = "macos") {
324 return false;
325 }
326 let Some(home) = dirs::home_dir() else {
327 return false;
328 };
329 ["Documents", "Desktop", "Downloads"]
330 .iter()
331 .any(|magic| path.starts_with(home.join(magic)))
332}
333
334pub fn process_is_tcc_standalone() -> bool {
348 #[cfg(target_os = "macos")]
349 {
350 if let Ok(v) = std::env::var("LEAN_CTX_TCC_STANDALONE") {
354 match v.trim() {
355 "1" | "true" => return true,
356 "0" | "false" => return false,
357 _ => {}
358 }
359 }
360 if std::env::var_os(crate::core::tcc_guard_sandbox::SEATBELT_SENTINEL).is_some() {
368 return true;
369 }
370 (unsafe { libc::getppid() }) == 1
372 }
373 #[cfg(not(target_os = "macos"))]
374 {
375 false
376 }
377}
378
379pub fn may_probe_path(path: &Path) -> bool {
387 !(process_is_tcc_standalone() && is_under_tcc_protected_dir(path))
388}
389
390pub fn has_multi_repo_children(dir: &Path) -> bool {
393 if is_tcc_sensitive_home_dir(dir) || !may_probe_path(dir) {
399 return false;
400 }
401 let Ok(entries) = std::fs::read_dir(dir) else {
402 return false;
403 };
404 let count = entries
405 .filter_map(Result::ok)
406 .filter(|e| e.file_type().is_ok_and(|ft| ft.is_dir()))
407 .filter(|e| has_project_marker(&e.path()))
408 .take(2)
409 .count();
410 count >= 2
411}
412
413pub fn is_data_dir_collision(project_root: &Path) -> bool {
417 if is_broad_or_unsafe_root(project_root) {
418 return true;
419 }
420 if let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() {
421 let project_lean_ctx = project_root.join(".lean-ctx");
422 if project_lean_ctx == data_dir || data_dir.starts_with(&project_lean_ctx) {
423 return true;
424 }
425 }
426 false
427}
428
429pub fn safe_project_data_dir(project_root: &Path) -> Result<PathBuf, String> {
432 if is_data_dir_collision(project_root) {
433 return Err(format!(
434 "project root {} collides with global data directory; \
435 skipping project-scoped write",
436 project_root.display()
437 ));
438 }
439 Ok(project_root.join(".lean-ctx"))
440}
441
442#[cfg(test)]
443mod tests {
444 use super::*;
445
446 #[test]
447 fn strip_regular_verbatim() {
448 let p = PathBuf::from(r"\\?\C:\Users\dev\project");
449 let result = strip_verbatim(p);
450 assert_eq!(result, PathBuf::from("C:/Users/dev/project"));
451 }
452
453 #[test]
454 fn tcc_sensitive_home_dir_matches_home_and_magic_dirs() {
455 let Some(home) = dirs::home_dir() else {
456 return;
457 };
458 assert!(is_tcc_sensitive_home_dir(&home));
460 assert!(is_tcc_sensitive_home_dir(&home.join("Documents")));
461 assert!(is_tcc_sensitive_home_dir(&home.join("Desktop")));
462 assert!(is_tcc_sensitive_home_dir(&home.join("Downloads")));
463 }
464
465 #[test]
466 fn tcc_sensitive_home_dir_allows_real_projects() {
467 let Some(home) = dirs::home_dir() else {
468 return;
469 };
470 assert!(!is_tcc_sensitive_home_dir(
473 &home.join("Documents").join("my-project")
474 ));
475 assert!(!is_tcc_sensitive_home_dir(&home.join("code")));
476 assert!(!is_tcc_sensitive_home_dir(&home.join("Projects")));
477 }
478
479 #[test]
480 #[cfg(target_os = "macos")]
481 fn under_tcc_protected_dir_matches_nested_paths() {
482 let Some(home) = dirs::home_dir() else {
483 return;
484 };
485 assert!(is_under_tcc_protected_dir(&home.join("Documents")));
487 assert!(is_under_tcc_protected_dir(
488 &home.join("Documents/deep/nested/project")
489 ));
490 assert!(is_under_tcc_protected_dir(&home.join("Desktop/scratch")));
491 assert!(is_under_tcc_protected_dir(&home.join("Downloads/x.zip")));
492 assert!(!is_under_tcc_protected_dir(&home));
494 assert!(!is_under_tcc_protected_dir(&home.join("code/project")));
495 assert!(!is_under_tcc_protected_dir(Path::new("/tmp/Documents")));
496 }
497
498 #[test]
499 #[cfg(target_os = "macos")]
500 #[serial_test::serial]
501 fn tcc_standalone_blocks_probes_under_protected_dirs() {
502 let Some(home) = dirs::home_dir() else {
503 return;
504 };
505 let doc_proj = home.join("Documents/some-project");
506
507 crate::test_env::set_var("LEAN_CTX_TCC_STANDALONE", "1");
508 assert!(process_is_tcc_standalone());
509 assert!(!may_probe_path(&doc_proj));
510 assert!(may_probe_path(Path::new("/tmp/some-project")));
512 assert!(!has_project_marker(&doc_proj));
514
515 crate::test_env::set_var("LEAN_CTX_TCC_STANDALONE", "0");
516 assert!(!process_is_tcc_standalone());
517 assert!(may_probe_path(&doc_proj));
518 crate::test_env::remove_var("LEAN_CTX_TCC_STANDALONE");
519 }
520
521 #[test]
522 #[cfg(target_os = "macos")]
523 #[serial_test::serial]
524 fn tcc_standalone_detected_via_seatbelt_sentinel() {
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::remove_var("LEAN_CTX_TCC_STANDALONE");
535 crate::test_env::set_var(crate::core::tcc_guard_sandbox::SEATBELT_SENTINEL, "1");
536 assert!(process_is_tcc_standalone());
537 assert!(!may_probe_path(&doc_proj));
538 crate::test_env::remove_var(crate::core::tcc_guard_sandbox::SEATBELT_SENTINEL);
539
540 assert!(!process_is_tcc_standalone());
543 }
544
545 #[test]
546 #[cfg(target_os = "macos")]
547 #[serial_test::serial]
548 fn tcc_standalone_skips_canonicalize_under_protected_dirs() {
549 let Some(home) = dirs::home_dir() else {
550 return;
551 };
552 let missing = home.join("Documents/lean-ctx-tcc-test-does-not-exist-xyzzy");
558
559 crate::test_env::set_var("LEAN_CTX_TCC_STANDALONE", "1");
560 let guarded = safe_canonicalize(&missing);
561 assert!(
562 guarded.is_ok(),
563 "standalone safe_canonicalize must short-circuit (no stat) under ~/Documents"
564 );
565 assert_eq!(guarded.unwrap(), missing);
566 assert_eq!(safe_canonicalize_or_self(&missing), missing);
567
568 let tmp_missing = Path::new("/tmp/lean-ctx-tcc-test-does-not-exist-xyzzy");
570 assert!(safe_canonicalize(tmp_missing).is_err());
571
572 crate::test_env::set_var("LEAN_CTX_TCC_STANDALONE", "0");
575 assert!(safe_canonicalize(&missing).is_err());
576
577 crate::test_env::remove_var("LEAN_CTX_TCC_STANDALONE");
578 }
579
580 #[test]
581 #[cfg(target_os = "macos")]
582 #[serial_test::serial]
583 fn canonicalize_secure_bypasses_tcc_guard_for_pathjail() {
584 let Some(home) = dirs::home_dir() else {
591 return;
592 };
593 let missing = home.join("Documents/lean-ctx-secure-canon-does-not-exist-xyzzy");
594
595 crate::test_env::set_var("LEAN_CTX_TCC_STANDALONE", "1");
596 assert_eq!(safe_canonicalize(&missing).unwrap(), missing);
598 assert!(
602 canonicalize_secure(&missing).is_err(),
603 "canonicalize_secure must bypass the TCC guard and touch the filesystem"
604 );
605 assert_eq!(canonicalize_secure_or_self(&missing), missing);
606 crate::test_env::remove_var("LEAN_CTX_TCC_STANDALONE");
607 }
608
609 #[test]
610 fn strip_unc_verbatim() {
611 let p = PathBuf::from(r"\\?\UNC\server\share\dir");
612 let result = strip_verbatim(p);
613 assert_eq!(result, PathBuf::from("//server/share/dir"));
614 }
615
616 #[test]
617 fn no_prefix_unchanged() {
618 let p = PathBuf::from("/home/user/project");
619 let result = strip_verbatim(p.clone());
620 assert_eq!(result, p);
621 }
622
623 #[test]
624 fn windows_drive_unchanged() {
625 let p = PathBuf::from("C:/Users/dev");
626 let result = strip_verbatim(p.clone());
627 assert_eq!(result, p);
628 }
629
630 #[test]
631 fn strip_str_regular() {
632 assert_eq!(
633 strip_verbatim_str(r"\\?\E:\code\lean-ctx"),
634 Some("E:/code/lean-ctx".to_string())
635 );
636 }
637
638 #[test]
639 fn strip_str_unc() {
640 assert_eq!(
641 strip_verbatim_str(r"\\?\UNC\myserver\data"),
642 Some("//myserver/data".to_string())
643 );
644 }
645
646 #[test]
647 fn strip_str_forward_slash_variant() {
648 assert_eq!(
649 strip_verbatim_str("//?/C:/Users/dev"),
650 Some("C:/Users/dev".to_string())
651 );
652 }
653
654 #[test]
655 fn strip_str_no_prefix() {
656 assert_eq!(strip_verbatim_str("/home/user"), None);
657 }
658
659 #[test]
660 fn safe_canonicalize_or_self_nonexistent() {
661 let p = Path::new("/this/path/should/not/exist/xyzzy");
662 let result = safe_canonicalize_or_self(p);
663 assert_eq!(result, p.to_path_buf());
664 }
665
666 #[test]
669 fn msys_drive_prefix_translation() {
670 assert_eq!(
671 translate_msys_drive_prefix("/c/Users/ABC").as_deref(),
672 Some("C:/Users/ABC")
673 );
674 assert_eq!(
675 translate_msys_drive_prefix("/D/Program Files").as_deref(),
676 Some("D:/Program Files")
677 );
678 assert_eq!(translate_msys_drive_prefix("/usr/local/bin"), None);
679 assert_eq!(translate_msys_drive_prefix("/c"), None);
680 assert_eq!(translate_msys_drive_prefix("c/Users"), None);
681 }
682
683 #[cfg(windows)]
684 #[test]
685 fn normalize_msys_path_to_native() {
686 assert_eq!(
687 normalize_tool_path("/c/Users/ABC/AppData/lean-ctx"),
688 "C:/Users/ABC/AppData/lean-ctx"
689 );
690 assert_eq!(
691 normalize_tool_path("/D/Program Files/lean-ctx.exe"),
692 "D:/Program Files/lean-ctx.exe"
693 );
694 }
695
696 #[cfg(not(windows))]
699 #[test]
700 fn normalize_single_letter_unix_path_untouched() {
701 assert_eq!(
702 normalize_tool_path_lexical("/c/Users/me/proj"),
703 "/c/Users/me/proj"
704 );
705 assert_eq!(
706 normalize_tool_path_lexical("/x/projects/app/src"),
707 "/x/projects/app/src"
708 );
709 }
710
711 #[test]
712 fn normalize_native_windows_path_unchanged() {
713 assert_eq!(
714 normalize_tool_path("C:/Users/ABC/lean-ctx.exe"),
715 "C:/Users/ABC/lean-ctx.exe"
716 );
717 }
718
719 #[test]
720 fn normalize_backslash_windows_path() {
721 assert_eq!(
722 normalize_tool_path(r"C:\Users\ABC\lean-ctx.exe"),
723 "C:/Users/ABC/lean-ctx.exe"
724 );
725 }
726
727 #[test]
728 fn normalize_unix_path_unchanged() {
729 assert_eq!(
730 normalize_tool_path("/usr/local/bin/lean-ctx"),
731 "/usr/local/bin/lean-ctx"
732 );
733 }
734
735 #[test]
736 fn normalize_windows_path_with_spaces_and_backslashes() {
737 assert_eq!(
741 normalize_tool_path(r"C:\Users\My Name\My Project\src\main.rs"),
742 "C:/Users/My Name/My Project/src/main.rs"
743 );
744 assert_eq!(
745 normalize_tool_path(r"C:\Program Files\app\config.toml"),
746 "C:/Program Files/app/config.toml"
747 );
748 }
749
750 #[test]
751 fn normalize_double_slashes() {
752 assert_eq!(
753 normalize_tool_path("C:/Users//ABC//lean-ctx"),
754 "C:/Users/ABC/lean-ctx"
755 );
756 }
757
758 #[test]
759 fn normalize_trailing_slash_removed() {
760 assert_eq!(normalize_tool_path("C:/Users/ABC/"), "C:/Users/ABC");
761 assert_eq!(
762 normalize_tool_path_lexical("/tmp/nonexistent-dir-xyzzy/"),
763 "/tmp/nonexistent-dir-xyzzy"
764 );
765 }
766
767 #[test]
768 fn normalize_root_slash_preserved() {
769 assert_eq!(normalize_tool_path("/"), "/");
770 }
771
772 #[test]
773 fn normalize_drive_root_preserved() {
774 assert_eq!(normalize_tool_path("C:/"), "C:/");
775 }
776
777 #[test]
778 fn normalize_verbatim_with_msys() {
779 assert_eq!(normalize_tool_path(r"\\?\C:\Users\dev"), "C:/Users/dev");
780 }
781
782 #[test]
783 fn broad_root_rejects_home() {
784 if let Some(home) = dirs::home_dir() {
785 assert!(is_broad_or_unsafe_root(&home));
786 }
787 }
788
789 #[test]
790 fn broad_root_rejects_filesystem_root() {
791 assert!(is_broad_or_unsafe_root(Path::new("/")));
792 }
793
794 #[test]
795 fn broad_root_rejects_dot() {
796 assert!(is_broad_or_unsafe_root(Path::new(".")));
797 }
798
799 #[test]
800 fn broad_root_rejects_agent_dirs() {
801 assert!(is_broad_or_unsafe_root(Path::new("/home/user/.claude")));
802 assert!(is_broad_or_unsafe_root(Path::new("/home/user/.codex")));
803 }
804
805 #[test]
806 fn broad_root_allows_project_subdir() {
807 let tmp = tempfile::tempdir().unwrap();
808 let subdir = tmp.path().join("my-project");
809 std::fs::create_dir_all(&subdir).unwrap();
810 assert!(!is_broad_or_unsafe_root(&subdir));
811 }
812
813 #[test]
814 fn broad_root_allows_home_subdirs() {
815 if let Some(home) = dirs::home_dir() {
816 let subdir = home.join("projects").join("my-app");
817 assert!(!is_broad_or_unsafe_root(&subdir));
818 }
819 }
820
821 #[test]
822 fn data_dir_collision_rejects_home() {
823 if let Some(home) = dirs::home_dir() {
824 assert!(is_data_dir_collision(&home));
825 }
826 }
827
828 #[test]
829 fn data_dir_collision_allows_normal_project() {
830 let tmp = tempfile::tempdir().unwrap();
831 let project = tmp.path().join("my-project");
832 std::fs::create_dir_all(&project).unwrap();
833 assert!(!is_data_dir_collision(&project));
834 }
835
836 #[test]
837 fn has_project_marker_detects_git() {
838 let tmp = tempfile::tempdir().unwrap();
839 let root = tmp.path().join("repo");
840 std::fs::create_dir_all(&root).unwrap();
841 assert!(!has_project_marker(&root));
842 std::fs::create_dir(root.join(".git")).unwrap();
843 assert!(has_project_marker(&root));
844 }
845
846 #[test]
847 fn has_project_marker_detects_cargo_toml() {
848 let tmp = tempfile::tempdir().unwrap();
849 let root = tmp.path().join("rust-project");
850 std::fs::create_dir_all(&root).unwrap();
851 std::fs::write(root.join("Cargo.toml"), "[package]").unwrap();
852 assert!(has_project_marker(&root));
853 }
854
855 #[test]
856 fn has_project_marker_detects_godot_project() {
857 let tmp = tempfile::tempdir().unwrap();
858 let root = tmp.path().join("godot-game");
859 std::fs::create_dir_all(&root).unwrap();
860 std::fs::write(root.join("project.godot"), "config_version=5\n").unwrap();
861 assert!(has_project_marker(&root));
862 }
863
864 #[test]
865 fn multi_repo_children_needs_two() {
866 let tmp = tempfile::tempdir().unwrap();
867 let parent = tmp.path().join("code");
868 std::fs::create_dir_all(&parent).unwrap();
869
870 assert!(!has_multi_repo_children(&parent));
872
873 let repo1 = parent.join("repo1");
875 std::fs::create_dir_all(repo1.join(".git")).unwrap();
876 assert!(!has_multi_repo_children(&parent));
877
878 let repo2 = parent.join("repo2");
880 std::fs::create_dir_all(repo2.join(".git")).unwrap();
881 assert!(has_multi_repo_children(&parent));
882 }
883
884 #[test]
885 fn multi_repo_children_ignores_files() {
886 let tmp = tempfile::tempdir().unwrap();
887 let parent = tmp.path().join("mixed");
888 std::fs::create_dir_all(&parent).unwrap();
889
890 let repo1 = parent.join("repo1");
892 std::fs::create_dir_all(repo1.join(".git")).unwrap();
893 std::fs::write(parent.join("not-a-repo"), "file").unwrap();
894 assert!(!has_multi_repo_children(&parent));
895
896 let repo2 = parent.join("repo2");
898 std::fs::create_dir_all(&repo2).unwrap();
899 std::fs::write(repo2.join("package.json"), "{}").unwrap();
900 assert!(has_multi_repo_children(&parent));
901 }
902
903 #[test]
904 fn multi_repo_children_nonexistent_dir() {
905 assert!(!has_multi_repo_children(Path::new("/nonexistent/path/xyz")));
906 }
907
908 #[test]
909 fn regular_file_is_not_symlink_or_reparse() {
910 let tmp = tempfile::tempdir().unwrap();
911 let file = tmp.path().join("plain.txt");
912 std::fs::write(&file, "x").unwrap();
913 let meta = std::fs::symlink_metadata(&file).unwrap();
914 assert!(!is_symlink_or_reparse(&meta));
915 }
916
917 #[cfg(unix)]
918 #[test]
919 fn unix_symlink_is_detected() {
920 let tmp = tempfile::tempdir().unwrap();
921 let target = tmp.path().join("target.txt");
922 std::fs::write(&target, "x").unwrap();
923 let link = tmp.path().join("link.txt");
924 std::os::unix::fs::symlink(&target, &link).unwrap();
925 let meta = std::fs::symlink_metadata(&link).unwrap();
926 assert!(is_symlink_or_reparse(&meta));
927 }
928
929 #[cfg(windows)]
932 #[test]
933 fn windows_symlink_is_detected() {
934 let tmp = tempfile::tempdir().unwrap();
935 let target = tmp.path().join("target.txt");
936 std::fs::write(&target, "x").unwrap();
937 let link = tmp.path().join("link.txt");
938 if std::os::windows::fs::symlink_file(&target, &link).is_err() {
939 eprintln!("skipping: symlink creation not permitted on this runner");
940 return;
941 }
942 let meta = std::fs::symlink_metadata(&link).unwrap();
943 assert!(is_symlink_or_reparse(&meta));
944 }
945}