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 (unsafe { libc::getppid() }) == 1
362 }
363 #[cfg(not(target_os = "macos"))]
364 {
365 false
366 }
367}
368
369pub fn may_probe_path(path: &Path) -> bool {
377 !(process_is_tcc_standalone() && is_under_tcc_protected_dir(path))
378}
379
380pub fn has_multi_repo_children(dir: &Path) -> bool {
383 if is_tcc_sensitive_home_dir(dir) || !may_probe_path(dir) {
389 return false;
390 }
391 let Ok(entries) = std::fs::read_dir(dir) else {
392 return false;
393 };
394 let count = entries
395 .filter_map(Result::ok)
396 .filter(|e| e.file_type().is_ok_and(|ft| ft.is_dir()))
397 .filter(|e| has_project_marker(&e.path()))
398 .take(2)
399 .count();
400 count >= 2
401}
402
403pub fn is_data_dir_collision(project_root: &Path) -> bool {
407 if is_broad_or_unsafe_root(project_root) {
408 return true;
409 }
410 if let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() {
411 let project_lean_ctx = project_root.join(".lean-ctx");
412 if project_lean_ctx == data_dir || data_dir.starts_with(&project_lean_ctx) {
413 return true;
414 }
415 }
416 false
417}
418
419pub fn safe_project_data_dir(project_root: &Path) -> Result<PathBuf, String> {
422 if is_data_dir_collision(project_root) {
423 return Err(format!(
424 "project root {} collides with global data directory; \
425 skipping project-scoped write",
426 project_root.display()
427 ));
428 }
429 Ok(project_root.join(".lean-ctx"))
430}
431
432#[cfg(test)]
433mod tests {
434 use super::*;
435
436 #[test]
437 fn strip_regular_verbatim() {
438 let p = PathBuf::from(r"\\?\C:\Users\dev\project");
439 let result = strip_verbatim(p);
440 assert_eq!(result, PathBuf::from("C:/Users/dev/project"));
441 }
442
443 #[test]
444 fn tcc_sensitive_home_dir_matches_home_and_magic_dirs() {
445 let Some(home) = dirs::home_dir() else {
446 return;
447 };
448 assert!(is_tcc_sensitive_home_dir(&home));
450 assert!(is_tcc_sensitive_home_dir(&home.join("Documents")));
451 assert!(is_tcc_sensitive_home_dir(&home.join("Desktop")));
452 assert!(is_tcc_sensitive_home_dir(&home.join("Downloads")));
453 }
454
455 #[test]
456 fn tcc_sensitive_home_dir_allows_real_projects() {
457 let Some(home) = dirs::home_dir() else {
458 return;
459 };
460 assert!(!is_tcc_sensitive_home_dir(
463 &home.join("Documents").join("my-project")
464 ));
465 assert!(!is_tcc_sensitive_home_dir(&home.join("code")));
466 assert!(!is_tcc_sensitive_home_dir(&home.join("Projects")));
467 }
468
469 #[test]
470 #[cfg(target_os = "macos")]
471 fn under_tcc_protected_dir_matches_nested_paths() {
472 let Some(home) = dirs::home_dir() else {
473 return;
474 };
475 assert!(is_under_tcc_protected_dir(&home.join("Documents")));
477 assert!(is_under_tcc_protected_dir(
478 &home.join("Documents/deep/nested/project")
479 ));
480 assert!(is_under_tcc_protected_dir(&home.join("Desktop/scratch")));
481 assert!(is_under_tcc_protected_dir(&home.join("Downloads/x.zip")));
482 assert!(!is_under_tcc_protected_dir(&home));
484 assert!(!is_under_tcc_protected_dir(&home.join("code/project")));
485 assert!(!is_under_tcc_protected_dir(Path::new("/tmp/Documents")));
486 }
487
488 #[test]
489 #[cfg(target_os = "macos")]
490 #[serial_test::serial]
491 fn tcc_standalone_blocks_probes_under_protected_dirs() {
492 let Some(home) = dirs::home_dir() else {
493 return;
494 };
495 let doc_proj = home.join("Documents/some-project");
496
497 crate::test_env::set_var("LEAN_CTX_TCC_STANDALONE", "1");
498 assert!(process_is_tcc_standalone());
499 assert!(!may_probe_path(&doc_proj));
500 assert!(may_probe_path(Path::new("/tmp/some-project")));
502 assert!(!has_project_marker(&doc_proj));
504
505 crate::test_env::set_var("LEAN_CTX_TCC_STANDALONE", "0");
506 assert!(!process_is_tcc_standalone());
507 assert!(may_probe_path(&doc_proj));
508 crate::test_env::remove_var("LEAN_CTX_TCC_STANDALONE");
509 }
510
511 #[test]
512 #[cfg(target_os = "macos")]
513 #[serial_test::serial]
514 fn tcc_standalone_skips_canonicalize_under_protected_dirs() {
515 let Some(home) = dirs::home_dir() else {
516 return;
517 };
518 let missing = home.join("Documents/lean-ctx-tcc-test-does-not-exist-xyzzy");
524
525 crate::test_env::set_var("LEAN_CTX_TCC_STANDALONE", "1");
526 let guarded = safe_canonicalize(&missing);
527 assert!(
528 guarded.is_ok(),
529 "standalone safe_canonicalize must short-circuit (no stat) under ~/Documents"
530 );
531 assert_eq!(guarded.unwrap(), missing);
532 assert_eq!(safe_canonicalize_or_self(&missing), missing);
533
534 let tmp_missing = Path::new("/tmp/lean-ctx-tcc-test-does-not-exist-xyzzy");
536 assert!(safe_canonicalize(tmp_missing).is_err());
537
538 crate::test_env::set_var("LEAN_CTX_TCC_STANDALONE", "0");
541 assert!(safe_canonicalize(&missing).is_err());
542
543 crate::test_env::remove_var("LEAN_CTX_TCC_STANDALONE");
544 }
545
546 #[test]
547 #[cfg(target_os = "macos")]
548 #[serial_test::serial]
549 fn canonicalize_secure_bypasses_tcc_guard_for_pathjail() {
550 let Some(home) = dirs::home_dir() else {
557 return;
558 };
559 let missing = home.join("Documents/lean-ctx-secure-canon-does-not-exist-xyzzy");
560
561 crate::test_env::set_var("LEAN_CTX_TCC_STANDALONE", "1");
562 assert_eq!(safe_canonicalize(&missing).unwrap(), missing);
564 assert!(
568 canonicalize_secure(&missing).is_err(),
569 "canonicalize_secure must bypass the TCC guard and touch the filesystem"
570 );
571 assert_eq!(canonicalize_secure_or_self(&missing), missing);
572 crate::test_env::remove_var("LEAN_CTX_TCC_STANDALONE");
573 }
574
575 #[test]
576 fn strip_unc_verbatim() {
577 let p = PathBuf::from(r"\\?\UNC\server\share\dir");
578 let result = strip_verbatim(p);
579 assert_eq!(result, PathBuf::from("//server/share/dir"));
580 }
581
582 #[test]
583 fn no_prefix_unchanged() {
584 let p = PathBuf::from("/home/user/project");
585 let result = strip_verbatim(p.clone());
586 assert_eq!(result, p);
587 }
588
589 #[test]
590 fn windows_drive_unchanged() {
591 let p = PathBuf::from("C:/Users/dev");
592 let result = strip_verbatim(p.clone());
593 assert_eq!(result, p);
594 }
595
596 #[test]
597 fn strip_str_regular() {
598 assert_eq!(
599 strip_verbatim_str(r"\\?\E:\code\lean-ctx"),
600 Some("E:/code/lean-ctx".to_string())
601 );
602 }
603
604 #[test]
605 fn strip_str_unc() {
606 assert_eq!(
607 strip_verbatim_str(r"\\?\UNC\myserver\data"),
608 Some("//myserver/data".to_string())
609 );
610 }
611
612 #[test]
613 fn strip_str_forward_slash_variant() {
614 assert_eq!(
615 strip_verbatim_str("//?/C:/Users/dev"),
616 Some("C:/Users/dev".to_string())
617 );
618 }
619
620 #[test]
621 fn strip_str_no_prefix() {
622 assert_eq!(strip_verbatim_str("/home/user"), None);
623 }
624
625 #[test]
626 fn safe_canonicalize_or_self_nonexistent() {
627 let p = Path::new("/this/path/should/not/exist/xyzzy");
628 let result = safe_canonicalize_or_self(p);
629 assert_eq!(result, p.to_path_buf());
630 }
631
632 #[test]
635 fn msys_drive_prefix_translation() {
636 assert_eq!(
637 translate_msys_drive_prefix("/c/Users/ABC").as_deref(),
638 Some("C:/Users/ABC")
639 );
640 assert_eq!(
641 translate_msys_drive_prefix("/D/Program Files").as_deref(),
642 Some("D:/Program Files")
643 );
644 assert_eq!(translate_msys_drive_prefix("/usr/local/bin"), None);
645 assert_eq!(translate_msys_drive_prefix("/c"), None);
646 assert_eq!(translate_msys_drive_prefix("c/Users"), None);
647 }
648
649 #[cfg(windows)]
650 #[test]
651 fn normalize_msys_path_to_native() {
652 assert_eq!(
653 normalize_tool_path("/c/Users/ABC/AppData/lean-ctx"),
654 "C:/Users/ABC/AppData/lean-ctx"
655 );
656 assert_eq!(
657 normalize_tool_path("/D/Program Files/lean-ctx.exe"),
658 "D:/Program Files/lean-ctx.exe"
659 );
660 }
661
662 #[cfg(not(windows))]
665 #[test]
666 fn normalize_single_letter_unix_path_untouched() {
667 assert_eq!(
668 normalize_tool_path_lexical("/c/Users/me/proj"),
669 "/c/Users/me/proj"
670 );
671 assert_eq!(
672 normalize_tool_path_lexical("/x/projects/app/src"),
673 "/x/projects/app/src"
674 );
675 }
676
677 #[test]
678 fn normalize_native_windows_path_unchanged() {
679 assert_eq!(
680 normalize_tool_path("C:/Users/ABC/lean-ctx.exe"),
681 "C:/Users/ABC/lean-ctx.exe"
682 );
683 }
684
685 #[test]
686 fn normalize_backslash_windows_path() {
687 assert_eq!(
688 normalize_tool_path(r"C:\Users\ABC\lean-ctx.exe"),
689 "C:/Users/ABC/lean-ctx.exe"
690 );
691 }
692
693 #[test]
694 fn normalize_unix_path_unchanged() {
695 assert_eq!(
696 normalize_tool_path("/usr/local/bin/lean-ctx"),
697 "/usr/local/bin/lean-ctx"
698 );
699 }
700
701 #[test]
702 fn normalize_windows_path_with_spaces_and_backslashes() {
703 assert_eq!(
707 normalize_tool_path(r"C:\Users\My Name\My Project\src\main.rs"),
708 "C:/Users/My Name/My Project/src/main.rs"
709 );
710 assert_eq!(
711 normalize_tool_path(r"C:\Program Files\app\config.toml"),
712 "C:/Program Files/app/config.toml"
713 );
714 }
715
716 #[test]
717 fn normalize_double_slashes() {
718 assert_eq!(
719 normalize_tool_path("C:/Users//ABC//lean-ctx"),
720 "C:/Users/ABC/lean-ctx"
721 );
722 }
723
724 #[test]
725 fn normalize_trailing_slash_removed() {
726 assert_eq!(normalize_tool_path("C:/Users/ABC/"), "C:/Users/ABC");
727 assert_eq!(
728 normalize_tool_path_lexical("/tmp/nonexistent-dir-xyzzy/"),
729 "/tmp/nonexistent-dir-xyzzy"
730 );
731 }
732
733 #[test]
734 fn normalize_root_slash_preserved() {
735 assert_eq!(normalize_tool_path("/"), "/");
736 }
737
738 #[test]
739 fn normalize_drive_root_preserved() {
740 assert_eq!(normalize_tool_path("C:/"), "C:/");
741 }
742
743 #[test]
744 fn normalize_verbatim_with_msys() {
745 assert_eq!(normalize_tool_path(r"\\?\C:\Users\dev"), "C:/Users/dev");
746 }
747
748 #[test]
749 fn broad_root_rejects_home() {
750 if let Some(home) = dirs::home_dir() {
751 assert!(is_broad_or_unsafe_root(&home));
752 }
753 }
754
755 #[test]
756 fn broad_root_rejects_filesystem_root() {
757 assert!(is_broad_or_unsafe_root(Path::new("/")));
758 }
759
760 #[test]
761 fn broad_root_rejects_dot() {
762 assert!(is_broad_or_unsafe_root(Path::new(".")));
763 }
764
765 #[test]
766 fn broad_root_rejects_agent_dirs() {
767 assert!(is_broad_or_unsafe_root(Path::new("/home/user/.claude")));
768 assert!(is_broad_or_unsafe_root(Path::new("/home/user/.codex")));
769 }
770
771 #[test]
772 fn broad_root_allows_project_subdir() {
773 let tmp = tempfile::tempdir().unwrap();
774 let subdir = tmp.path().join("my-project");
775 std::fs::create_dir_all(&subdir).unwrap();
776 assert!(!is_broad_or_unsafe_root(&subdir));
777 }
778
779 #[test]
780 fn broad_root_allows_home_subdirs() {
781 if let Some(home) = dirs::home_dir() {
782 let subdir = home.join("projects").join("my-app");
783 assert!(!is_broad_or_unsafe_root(&subdir));
784 }
785 }
786
787 #[test]
788 fn data_dir_collision_rejects_home() {
789 if let Some(home) = dirs::home_dir() {
790 assert!(is_data_dir_collision(&home));
791 }
792 }
793
794 #[test]
795 fn data_dir_collision_allows_normal_project() {
796 let tmp = tempfile::tempdir().unwrap();
797 let project = tmp.path().join("my-project");
798 std::fs::create_dir_all(&project).unwrap();
799 assert!(!is_data_dir_collision(&project));
800 }
801
802 #[test]
803 fn has_project_marker_detects_git() {
804 let tmp = tempfile::tempdir().unwrap();
805 let root = tmp.path().join("repo");
806 std::fs::create_dir_all(&root).unwrap();
807 assert!(!has_project_marker(&root));
808 std::fs::create_dir(root.join(".git")).unwrap();
809 assert!(has_project_marker(&root));
810 }
811
812 #[test]
813 fn has_project_marker_detects_cargo_toml() {
814 let tmp = tempfile::tempdir().unwrap();
815 let root = tmp.path().join("rust-project");
816 std::fs::create_dir_all(&root).unwrap();
817 std::fs::write(root.join("Cargo.toml"), "[package]").unwrap();
818 assert!(has_project_marker(&root));
819 }
820
821 #[test]
822 fn has_project_marker_detects_godot_project() {
823 let tmp = tempfile::tempdir().unwrap();
824 let root = tmp.path().join("godot-game");
825 std::fs::create_dir_all(&root).unwrap();
826 std::fs::write(root.join("project.godot"), "config_version=5\n").unwrap();
827 assert!(has_project_marker(&root));
828 }
829
830 #[test]
831 fn multi_repo_children_needs_two() {
832 let tmp = tempfile::tempdir().unwrap();
833 let parent = tmp.path().join("code");
834 std::fs::create_dir_all(&parent).unwrap();
835
836 assert!(!has_multi_repo_children(&parent));
838
839 let repo1 = parent.join("repo1");
841 std::fs::create_dir_all(repo1.join(".git")).unwrap();
842 assert!(!has_multi_repo_children(&parent));
843
844 let repo2 = parent.join("repo2");
846 std::fs::create_dir_all(repo2.join(".git")).unwrap();
847 assert!(has_multi_repo_children(&parent));
848 }
849
850 #[test]
851 fn multi_repo_children_ignores_files() {
852 let tmp = tempfile::tempdir().unwrap();
853 let parent = tmp.path().join("mixed");
854 std::fs::create_dir_all(&parent).unwrap();
855
856 let repo1 = parent.join("repo1");
858 std::fs::create_dir_all(repo1.join(".git")).unwrap();
859 std::fs::write(parent.join("not-a-repo"), "file").unwrap();
860 assert!(!has_multi_repo_children(&parent));
861
862 let repo2 = parent.join("repo2");
864 std::fs::create_dir_all(&repo2).unwrap();
865 std::fs::write(repo2.join("package.json"), "{}").unwrap();
866 assert!(has_multi_repo_children(&parent));
867 }
868
869 #[test]
870 fn multi_repo_children_nonexistent_dir() {
871 assert!(!has_multi_repo_children(Path::new("/nonexistent/path/xyz")));
872 }
873
874 #[test]
875 fn regular_file_is_not_symlink_or_reparse() {
876 let tmp = tempfile::tempdir().unwrap();
877 let file = tmp.path().join("plain.txt");
878 std::fs::write(&file, "x").unwrap();
879 let meta = std::fs::symlink_metadata(&file).unwrap();
880 assert!(!is_symlink_or_reparse(&meta));
881 }
882
883 #[cfg(unix)]
884 #[test]
885 fn unix_symlink_is_detected() {
886 let tmp = tempfile::tempdir().unwrap();
887 let target = tmp.path().join("target.txt");
888 std::fs::write(&target, "x").unwrap();
889 let link = tmp.path().join("link.txt");
890 std::os::unix::fs::symlink(&target, &link).unwrap();
891 let meta = std::fs::symlink_metadata(&link).unwrap();
892 assert!(is_symlink_or_reparse(&meta));
893 }
894
895 #[cfg(windows)]
898 #[test]
899 fn windows_symlink_is_detected() {
900 let tmp = tempfile::tempdir().unwrap();
901 let target = tmp.path().join("target.txt");
902 std::fs::write(&target, "x").unwrap();
903 let link = tmp.path().join("link.txt");
904 if std::os::windows::fs::symlink_file(&target, &link).is_err() {
905 eprintln!("skipping: symlink creation not permitted on this runner");
906 return;
907 }
908 let meta = std::fs::symlink_metadata(&link).unwrap();
909 assert!(is_symlink_or_reparse(&meta));
910 }
911}