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