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 is_windows_absolute_path(path: &str) -> bool {
160 let bytes = path.as_bytes();
161 if bytes.len() >= 3
162 && bytes[0].is_ascii_alphabetic()
163 && bytes[1] == b':'
164 && matches!(bytes[2], b'\\' | b'/')
165 {
166 return true;
167 }
168
169 path.starts_with("\\\\") || path.starts_with("//")
170}
171
172pub fn normalize_tool_path_lexical(path: &str) -> String {
178 let mut p = match strip_verbatim_str(path) {
179 Some(stripped) => stripped,
180 None => path.to_string(),
181 };
182
183 if cfg!(windows)
184 && let Some(translated) = translate_msys_drive_prefix(&p)
185 {
186 p = translated;
187 } else if !cfg!(windows) {
188 p = unescape_shell_path(&p);
189 }
190
191 p = p.replace('\\', "/");
192
193 while p.contains("//") && !p.starts_with("//") {
195 p = p.replace("//", "/");
196 }
197
198 if p.len() > 1 && p.ends_with('/') && !p.ends_with(":/") {
200 p.pop();
201 }
202
203 p
204}
205
206fn unescape_shell_path(path: &str) -> String {
214 let trimmed = path.trim();
215 let unquoted = [('\'', '\''), ('"', '"')]
216 .iter()
217 .find_map(|&(open, close)| {
218 trimmed
219 .strip_prefix(open)
220 .and_then(|rest| rest.strip_suffix(close))
221 .filter(|inner| !inner.is_empty())
222 })
223 .unwrap_or(path);
224 if !unquoted.contains('\\') {
225 return unquoted.to_string();
226 }
227 let mut out = String::with_capacity(unquoted.len());
228 let mut chars = unquoted.chars().peekable();
229 while let Some(c) = chars.next() {
230 if c == '\\'
231 && let Some(&next) = chars.peek()
232 && matches!(
233 next,
234 ' ' | '('
235 | ')'
236 | '['
237 | ']'
238 | '{'
239 | '}'
240 | '&'
241 | '\''
242 | '"'
243 | '$'
244 | '!'
245 | '#'
246 | ';'
247 | '*'
248 | '?'
249 | '<'
250 | '>'
251 | '|'
252 | '~'
253 | '`'
254 | '='
255 )
256 {
257 out.push(next);
258 chars.next();
259 continue;
260 }
261 out.push(c);
262 }
263 out
264}
265
266pub fn same_project(a: &str, b: &str) -> bool {
276 let lexical = |path: &str| {
277 let path = path.trim().replace('\\', "/");
278 let trimmed = path.trim_end_matches('/');
279 if trimmed.is_empty() {
280 path
281 } else {
282 trimmed.to_string()
283 }
284 };
285 a == b || lexical(a) == lexical(b) || normalize_tool_path(a) == normalize_tool_path(b)
286}
287
288pub fn normalize_tool_path(path: &str) -> String {
289 let mut p = normalize_tool_path_lexical(path);
290
291 let is_absolute = p.starts_with('/') || (p.len() >= 3 && p.as_bytes()[1] == b':');
297 let is_root_only = p == "/" || (p.len() <= 3 && p.ends_with('/') && is_absolute);
298 if is_absolute
299 && !is_root_only
300 && !crate::core::io_health::is_slow_mount(&p)
301 && may_probe_path(Path::new(&*p))
302 && let Ok(canonical) = safe_canonicalize(Path::new(&*p))
303 {
304 let canonical_str = canonical.to_string_lossy().replace('\\', "/");
305 if !canonical_str.is_empty() {
306 p = canonical_str;
307 }
308 }
309
310 p
311}
312
313pub const AGENT_CONFIG_DIRS: &[&str] = &[
320 ".claude",
321 ".codex",
322 ".codebuddy",
323 ".codewhale",
324 ".copilot",
325 ".cursor",
326 ".windsurf",
327 ".gemini",
328 ".lmstudio",
329];
330
331pub fn is_agent_config_dir(dir: &Path) -> bool {
339 let s = dir.to_string_lossy().replace('\\', "/");
340 AGENT_CONFIG_DIRS.iter().any(|name| {
341 let segment = format!("/{name}");
342 s.match_indices(&segment).any(|(at, _)| {
343 let tail = &s[at + segment.len()..];
344 (tail.is_empty() || tail.starts_with('/')) && !is_worktree_checkout_tail(tail)
345 })
346 })
347}
348
349fn is_worktree_checkout_tail(tail: &str) -> bool {
351 tail.strip_prefix("/worktrees/")
352 .and_then(|rest| rest.split('/').next())
353 .is_some_and(|name| !name.is_empty())
354}
355
356fn is_wsl_windows_user_profile(dir: &Path) -> bool {
357 let normalized = dir.to_string_lossy();
358 if !normalized.starts_with("/mnt/") {
359 return false;
360 }
361
362 let mut components = normalized
363 .split('/')
364 .filter(|component| !component.is_empty());
365 let (Some(mount), Some(drive), Some(users), Some(username)) = (
366 components.next(),
367 components.next(),
368 components.next(),
369 components.next(),
370 ) else {
371 return false;
372 };
373
374 mount.eq_ignore_ascii_case("mnt")
375 && drive.len() == 1
376 && drive.as_bytes()[0].is_ascii_alphabetic()
377 && users.eq_ignore_ascii_case("users")
378 && !username.is_empty()
379 && components.next().is_none()
380}
381
382pub fn home_dirs() -> Vec<PathBuf> {
388 let mut homes: Vec<PathBuf> = Vec::with_capacity(2);
389 for home in [dirs::home_dir(), crate::core::home::resolve_home_dir()]
390 .into_iter()
391 .flatten()
392 {
393 if !homes.contains(&home) {
394 homes.push(home);
395 }
396 }
397 homes
398}
399
400pub fn is_suspicious_root(dir: &Path) -> bool {
403 is_broad_or_unsafe_root(dir) || is_agent_config_dir(dir)
404}
405
406pub fn is_broad_or_unsafe_root(dir: &Path) -> bool {
413 let plain = strip_verbatim(dir.to_path_buf());
417 if home_dirs()
418 .into_iter()
419 .any(|home| plain == strip_verbatim(home))
420 {
421 return true;
422 }
423 if is_wsl_windows_user_profile(dir) {
424 return true;
425 }
426 let s = dir.to_string_lossy();
427 if s == "/" || s == "\\" || s == "." {
428 return true;
429 }
430 is_agent_config_dir(dir) || is_lean_ctx_store_dir(dir)
431}
432
433pub fn is_lean_ctx_store_dir(dir: &Path) -> bool {
441 let real_dir = dir.canonicalize().ok();
442 [
443 crate::core::data_dir::resolve_data_dir(),
444 crate::core::paths::state_dir_read_only(),
445 crate::core::paths::config_dir_read_only(),
446 crate::core::paths::cache_dir_read_only(),
447 ]
448 .into_iter()
449 .flatten()
450 .filter(|store| store.is_absolute())
451 .any(|store| {
452 let real_store = store.canonicalize().ok();
453 dir == store
454 || real_store.as_deref() == Some(dir)
455 || (real_dir.is_some() && real_dir == real_store)
456 })
457}
458
459pub const PROJECT_MARKERS: &[&str] = &[
461 ".git",
462 "Cargo.toml",
463 "package.json",
464 "go.mod",
465 "pyproject.toml",
466 "setup.py",
467 "pom.xml",
468 "build.gradle",
469 "Makefile",
470 "project.godot",
471 ".lean-ctx.toml",
472 ".planning",
473];
474
475pub fn has_project_marker(dir: &Path) -> bool {
482 if !may_probe_path(dir) {
483 return false;
484 }
485 PROJECT_MARKERS.iter().any(|m| dir.join(m).exists())
486}
487
488pub fn is_symlink_or_reparse(meta: &std::fs::Metadata) -> bool {
496 if meta.file_type().is_symlink() {
497 return true;
498 }
499 #[cfg(windows)]
500 {
501 use std::os::windows::fs::MetadataExt;
502 const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0400;
503 meta.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
504 }
505 #[cfg(not(windows))]
506 false
507}
508
509pub fn is_tcc_sensitive_home_dir(dir: &Path) -> bool {
518 let Some(home) = dirs::home_dir() else {
519 return false;
520 };
521 if dir == home {
522 return true;
523 }
524 if dir.parent() != Some(home.as_path()) {
525 return false;
526 }
527 matches!(
528 dir.file_name().and_then(|n| n.to_str()),
529 Some("Documents" | "Desktop" | "Downloads")
530 )
531}
532
533pub fn is_under_tcc_protected_dir(path: &Path) -> bool {
541 if !cfg!(target_os = "macos") {
542 return false;
543 }
544 let Some(home) = dirs::home_dir() else {
545 return false;
546 };
547 ["Documents", "Desktop", "Downloads"]
548 .iter()
549 .any(|magic| path.starts_with(home.join(magic)))
550}
551
552pub fn process_is_tcc_standalone() -> bool {
566 #[cfg(target_os = "macos")]
567 {
568 if let Ok(v) = std::env::var("LEAN_CTX_TCC_STANDALONE") {
572 match v.trim() {
573 "1" | "true" => return true,
574 "0" | "false" => return false,
575 _ => {}
576 }
577 }
578 if std::env::var_os(crate::core::tcc_guard_sandbox::SEATBELT_SENTINEL).is_some() {
586 return true;
587 }
588 (unsafe { libc::getppid() }) == 1
590 }
591 #[cfg(not(target_os = "macos"))]
592 {
593 false
594 }
595}
596
597pub fn may_probe_path(path: &Path) -> bool {
605 !(process_is_tcc_standalone() && is_under_tcc_protected_dir(path))
606}
607
608pub fn has_multi_repo_children(dir: &Path) -> bool {
611 if is_tcc_sensitive_home_dir(dir) || !may_probe_path(dir) {
617 return false;
618 }
619 let Ok(entries) = std::fs::read_dir(dir) else {
620 return false;
621 };
622 let count = entries
623 .filter_map(Result::ok)
624 .filter(|e| e.file_type().is_ok_and(|ft| ft.is_dir()))
625 .filter(|e| has_project_marker(&e.path()))
626 .take(2)
627 .count();
628 count >= 2
629}
630
631pub fn is_data_dir_collision(project_root: &Path) -> bool {
635 if is_broad_or_unsafe_root(project_root) {
636 return true;
637 }
638 if let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() {
639 let project_lean_ctx = project_root.join(".lean-ctx");
640 if project_lean_ctx == data_dir || data_dir.starts_with(&project_lean_ctx) {
641 return true;
642 }
643 }
644 false
645}
646
647pub fn safe_project_data_dir(project_root: &Path) -> Result<PathBuf, String> {
650 if is_data_dir_collision(project_root) {
651 return Err(format!(
652 "project root {} collides with global data directory; \
653 skipping project-scoped write",
654 project_root.display()
655 ));
656 }
657 Ok(project_root.join(".lean-ctx"))
658}
659
660#[cfg(test)]
661mod tests {
662 use super::*;
663
664 #[cfg(unix)]
668 #[test]
669 fn same_project_ignores_spelling_but_not_identity() {
670 let tmp = tempfile::tempdir().unwrap();
671 let real = tmp.path().join("repo");
672 let other = tmp.path().join("other");
673 std::fs::create_dir_all(&real).unwrap();
674 std::fs::create_dir_all(&other).unwrap();
675 let link = tmp.path().join("link");
676 std::os::unix::fs::symlink(&real, &link).unwrap();
677 let (real, link, other) = (
678 real.to_string_lossy().to_string(),
679 link.to_string_lossy().to_string(),
680 other.to_string_lossy().to_string(),
681 );
682
683 assert!(same_project(&real, &link));
684 assert!(same_project(&real, &format!("{real}/")));
685 assert!(same_project(r"C:\work\repo", "C:/work/repo/"));
686 assert!(!same_project(&real, &other));
687 }
688
689 #[test]
690 fn lean_ctx_stores_are_never_a_project_root() {
691 let iso = crate::core::data_dir::isolated_data_dir();
692 let project = tempfile::tempdir().unwrap();
693 assert!(is_broad_or_unsafe_root(iso.path()));
695 assert!(!is_broad_or_unsafe_root(&iso.path().join("proj")));
697 assert!(!is_broad_or_unsafe_root(project.path()));
698 }
699
700 #[cfg(unix)]
701 #[test]
702 fn a_store_under_another_spelling_is_still_refused() {
703 let iso = crate::core::data_dir::isolated_data_dir();
704 let links = tempfile::tempdir().unwrap();
705 let alias = links.path().join("data-alias");
706 std::os::unix::fs::symlink(iso.path(), &alias).unwrap();
707 assert!(is_broad_or_unsafe_root(&alias));
708 let inner = iso.path().join("proj");
710 std::fs::create_dir_all(&inner).unwrap();
711 let inner_alias = links.path().join("proj-alias");
712 std::os::unix::fs::symlink(&inner, &inner_alias).unwrap();
713 assert!(!is_broad_or_unsafe_root(&inner_alias));
714 }
715
716 #[test]
717 fn strip_regular_verbatim() {
718 let p = PathBuf::from(r"\\?\C:\Users\dev\project");
719 let result = strip_verbatim(p);
720 assert_eq!(result, PathBuf::from("C:/Users/dev/project"));
721 }
722
723 #[test]
724 fn tcc_sensitive_home_dir_matches_home_and_magic_dirs() {
725 let _env_lock = crate::core::data_dir::test_env_lock();
728 let Some(home) = dirs::home_dir() else {
729 return;
730 };
731 assert!(is_tcc_sensitive_home_dir(&home));
733 assert!(is_tcc_sensitive_home_dir(&home.join("Documents")));
734 assert!(is_tcc_sensitive_home_dir(&home.join("Desktop")));
735 assert!(is_tcc_sensitive_home_dir(&home.join("Downloads")));
736 }
737
738 #[test]
739 fn tcc_sensitive_home_dir_allows_real_projects() {
740 let _env_lock = crate::core::data_dir::test_env_lock();
741 let Some(home) = dirs::home_dir() else {
742 return;
743 };
744 assert!(!is_tcc_sensitive_home_dir(
747 &home.join("Documents").join("my-project")
748 ));
749 assert!(!is_tcc_sensitive_home_dir(&home.join("code")));
750 assert!(!is_tcc_sensitive_home_dir(&home.join("Projects")));
751 }
752
753 #[test]
754 #[cfg(target_os = "macos")]
755 fn under_tcc_protected_dir_matches_nested_paths() {
756 let _env_lock = crate::core::data_dir::test_env_lock();
757 let Some(home) = dirs::home_dir() else {
758 return;
759 };
760 assert!(is_under_tcc_protected_dir(&home.join("Documents")));
762 assert!(is_under_tcc_protected_dir(
763 &home.join("Documents/deep/nested/project")
764 ));
765 assert!(is_under_tcc_protected_dir(&home.join("Desktop/scratch")));
766 assert!(is_under_tcc_protected_dir(&home.join("Downloads/x.zip")));
767 assert!(!is_under_tcc_protected_dir(&home));
769 assert!(!is_under_tcc_protected_dir(&home.join("code/project")));
770 assert!(!is_under_tcc_protected_dir(Path::new("/tmp/Documents")));
771 }
772
773 #[test]
774 #[cfg(target_os = "macos")]
775 #[serial_test::serial]
776 fn tcc_standalone_blocks_probes_under_protected_dirs() {
777 let _env_lock = crate::core::data_dir::test_env_lock();
783 let Some(home) = dirs::home_dir() else {
784 return;
785 };
786 let doc_proj = home.join("Documents/some-project");
787
788 crate::test_env::set_var("LEAN_CTX_TCC_STANDALONE", "1");
789 assert!(process_is_tcc_standalone());
790 assert!(!may_probe_path(&doc_proj));
791 assert!(may_probe_path(Path::new("/tmp/some-project")));
793 assert!(!has_project_marker(&doc_proj));
795
796 crate::test_env::set_var("LEAN_CTX_TCC_STANDALONE", "0");
797 assert!(!process_is_tcc_standalone());
798 assert!(may_probe_path(&doc_proj));
799 crate::test_env::remove_var("LEAN_CTX_TCC_STANDALONE");
800 }
801
802 #[test]
803 #[cfg(target_os = "macos")]
804 #[serial_test::serial]
805 fn tcc_standalone_detected_via_seatbelt_sentinel() {
806 let _env_lock = crate::core::data_dir::test_env_lock();
807 let Some(home) = dirs::home_dir() else {
808 return;
809 };
810 let doc_proj = home.join("Documents/some-project");
811
812 crate::test_env::remove_var("LEAN_CTX_TCC_STANDALONE");
817 crate::test_env::set_var(crate::core::tcc_guard_sandbox::SEATBELT_SENTINEL, "1");
818 assert!(process_is_tcc_standalone());
819 assert!(!may_probe_path(&doc_proj));
820 crate::test_env::remove_var(crate::core::tcc_guard_sandbox::SEATBELT_SENTINEL);
821
822 assert!(!process_is_tcc_standalone());
825 }
826
827 #[test]
828 #[cfg(target_os = "macos")]
829 #[serial_test::serial]
830 fn tcc_standalone_skips_canonicalize_under_protected_dirs() {
831 let _env_lock = crate::core::data_dir::test_env_lock();
832 let Some(home) = dirs::home_dir() else {
833 return;
834 };
835 let missing = home.join("Documents/lean-ctx-tcc-test-does-not-exist-xyzzy");
841
842 crate::test_env::set_var("LEAN_CTX_TCC_STANDALONE", "1");
843 let guarded = safe_canonicalize(&missing);
844 assert!(
845 guarded.is_ok(),
846 "standalone safe_canonicalize must short-circuit (no stat) under ~/Documents"
847 );
848 assert_eq!(guarded.unwrap(), missing);
849 assert_eq!(safe_canonicalize_or_self(&missing), missing);
850
851 let tmp_missing = Path::new("/tmp/lean-ctx-tcc-test-does-not-exist-xyzzy");
853 assert!(safe_canonicalize(tmp_missing).is_err());
854
855 crate::test_env::set_var("LEAN_CTX_TCC_STANDALONE", "0");
858 assert!(safe_canonicalize(&missing).is_err());
859
860 crate::test_env::remove_var("LEAN_CTX_TCC_STANDALONE");
861 }
862
863 #[test]
864 #[cfg(target_os = "macos")]
865 #[serial_test::serial]
866 fn canonicalize_secure_bypasses_tcc_guard_for_pathjail() {
867 let _env_lock = crate::core::data_dir::test_env_lock();
868 let Some(home) = dirs::home_dir() else {
875 return;
876 };
877 let missing = home.join("Documents/lean-ctx-secure-canon-does-not-exist-xyzzy");
878
879 crate::test_env::set_var("LEAN_CTX_TCC_STANDALONE", "1");
880 assert_eq!(safe_canonicalize(&missing).unwrap(), missing);
882 assert!(
886 canonicalize_secure(&missing).is_err(),
887 "canonicalize_secure must bypass the TCC guard and touch the filesystem"
888 );
889 assert_eq!(canonicalize_secure_or_self(&missing), missing);
890 crate::test_env::remove_var("LEAN_CTX_TCC_STANDALONE");
891 }
892
893 #[test]
894 fn strip_unc_verbatim() {
895 let p = PathBuf::from(r"\\?\UNC\server\share\dir");
896 let result = strip_verbatim(p);
897 assert_eq!(result, PathBuf::from("//server/share/dir"));
898 }
899
900 #[test]
901 fn no_prefix_unchanged() {
902 let p = PathBuf::from("/home/user/project");
903 let result = strip_verbatim(p.clone());
904 assert_eq!(result, p);
905 }
906
907 #[test]
908 fn windows_drive_unchanged() {
909 let p = PathBuf::from("C:/Users/dev");
910 let result = strip_verbatim(p.clone());
911 assert_eq!(result, p);
912 }
913
914 #[test]
915 fn strip_str_regular() {
916 assert_eq!(
917 strip_verbatim_str(r"\\?\E:\code\lean-ctx"),
918 Some("E:/code/lean-ctx".to_string())
919 );
920 }
921
922 #[test]
923 fn strip_str_unc() {
924 assert_eq!(
925 strip_verbatim_str(r"\\?\UNC\myserver\data"),
926 Some("//myserver/data".to_string())
927 );
928 }
929
930 #[test]
931 fn strip_str_forward_slash_variant() {
932 assert_eq!(
933 strip_verbatim_str("//?/C:/Users/dev"),
934 Some("C:/Users/dev".to_string())
935 );
936 }
937
938 #[test]
939 fn strip_str_no_prefix() {
940 assert_eq!(strip_verbatim_str("/home/user"), None);
941 }
942
943 #[test]
944 fn safe_canonicalize_or_self_nonexistent() {
945 let p = Path::new("/this/path/should/not/exist/xyzzy");
946 let result = safe_canonicalize_or_self(p);
947 assert_eq!(result, p.to_path_buf());
948 }
949
950 #[test]
953 fn msys_drive_prefix_translation() {
954 assert_eq!(
955 translate_msys_drive_prefix("/c/Users/ABC").as_deref(),
956 Some("C:/Users/ABC")
957 );
958 assert_eq!(
959 translate_msys_drive_prefix("/D/Program Files").as_deref(),
960 Some("D:/Program Files")
961 );
962 assert_eq!(translate_msys_drive_prefix("/usr/local/bin"), None);
963 assert_eq!(translate_msys_drive_prefix("/c"), None);
964 assert_eq!(translate_msys_drive_prefix("c/Users"), None);
965 }
966
967 #[cfg(windows)]
968 #[test]
969 fn normalize_msys_path_to_native() {
970 assert_eq!(
971 normalize_tool_path("/c/Users/ABC/AppData/lean-ctx"),
972 "C:/Users/ABC/AppData/lean-ctx"
973 );
974 assert_eq!(
975 normalize_tool_path("/D/Program Files/lean-ctx.exe"),
976 "D:/Program Files/lean-ctx.exe"
977 );
978 }
979
980 #[cfg(not(windows))]
983 #[test]
984 fn normalize_single_letter_unix_path_untouched() {
985 assert_eq!(
986 normalize_tool_path_lexical("/c/Users/me/proj"),
987 "/c/Users/me/proj"
988 );
989 assert_eq!(
990 normalize_tool_path_lexical("/x/projects/app/src"),
991 "/x/projects/app/src"
992 );
993 }
994
995 #[test]
996 fn normalize_native_windows_path_unchanged() {
997 assert_eq!(
998 normalize_tool_path("C:/Users/ABC/lean-ctx.exe"),
999 "C:/Users/ABC/lean-ctx.exe"
1000 );
1001 }
1002
1003 #[test]
1004 fn normalize_backslash_windows_path() {
1005 assert_eq!(
1006 normalize_tool_path(r"C:\Users\ABC\lean-ctx.exe"),
1007 "C:/Users/ABC/lean-ctx.exe"
1008 );
1009 }
1010
1011 #[test]
1012 fn normalize_unix_path_unchanged() {
1013 assert_eq!(
1014 normalize_tool_path("/usr/local/bin/lean-ctx"),
1015 "/usr/local/bin/lean-ctx"
1016 );
1017 }
1018
1019 #[cfg(not(windows))]
1020 #[test]
1021 fn normalize_unescapes_shell_quoting_on_unix() {
1022 assert_eq!(
1023 normalize_tool_path_lexical(r"/Users/me/Pfadi\ Scouting/app/main.ts"),
1024 "/Users/me/Pfadi Scouting/app/main.ts",
1025 "an escaped space must not become a separator"
1026 );
1027 assert_eq!(
1028 normalize_tool_path_lexical(r"/srv/\(group\)/a\&b.txt"),
1029 "/srv/(group)/a&b.txt"
1030 );
1031 assert_eq!(
1032 normalize_tool_path_lexical("'/Users/me/My Project/src'"),
1033 "/Users/me/My Project/src"
1034 );
1035 assert_eq!(normalize_tool_path_lexical("\"/tmp/a b\""), "/tmp/a b");
1036 assert_eq!(
1037 normalize_tool_path_lexical("'"),
1038 "'",
1039 "a lone quote is a name"
1040 );
1041 }
1042
1043 #[test]
1044 fn normalize_windows_path_with_spaces_and_backslashes() {
1045 assert_eq!(
1049 normalize_tool_path(r"C:\Users\My Name\My Project\src\main.rs"),
1050 "C:/Users/My Name/My Project/src/main.rs"
1051 );
1052 assert_eq!(
1053 normalize_tool_path(r"C:\Program Files\app\config.toml"),
1054 "C:/Program Files/app/config.toml"
1055 );
1056 }
1057
1058 #[test]
1059 fn normalize_double_slashes() {
1060 assert_eq!(
1061 normalize_tool_path("C:/Users//ABC//lean-ctx"),
1062 "C:/Users/ABC/lean-ctx"
1063 );
1064 }
1065
1066 #[test]
1067 fn normalize_trailing_slash_removed() {
1068 assert_eq!(normalize_tool_path("C:/Users/ABC/"), "C:/Users/ABC");
1069 assert_eq!(
1070 normalize_tool_path_lexical("/tmp/nonexistent-dir-xyzzy/"),
1071 "/tmp/nonexistent-dir-xyzzy"
1072 );
1073 }
1074
1075 #[test]
1076 fn normalize_root_slash_preserved() {
1077 assert_eq!(normalize_tool_path("/"), "/");
1078 }
1079
1080 #[test]
1081 fn normalize_drive_root_preserved() {
1082 assert_eq!(normalize_tool_path("C:/"), "C:/");
1083 }
1084
1085 #[test]
1086 fn normalize_verbatim_with_msys() {
1087 assert_eq!(normalize_tool_path(r"\\?\C:\Users\dev"), "C:/Users/dev");
1088 }
1089
1090 #[test]
1091 fn broad_root_rejects_home() {
1092 let _home = crate::core::data_dir::test_env_lock();
1093 if let Some(home) = dirs::home_dir() {
1094 assert!(is_broad_or_unsafe_root(&home));
1095 }
1096 }
1097
1098 #[test]
1099 fn broad_root_rejects_wsl_windows_user_profile() {
1100 assert!(is_broad_or_unsafe_root(Path::new("/mnt/c/Users/dev")));
1101 assert!(!is_broad_or_unsafe_root(Path::new(
1102 "/mnt/c/Users/dev/projects/my-app"
1103 )));
1104 }
1105
1106 #[test]
1107 fn broad_root_rejects_filesystem_root() {
1108 assert!(is_broad_or_unsafe_root(Path::new("/")));
1109 }
1110
1111 #[test]
1112 fn broad_root_rejects_dot() {
1113 assert!(is_broad_or_unsafe_root(Path::new(".")));
1114 }
1115
1116 #[test]
1117 fn broad_root_rejects_agent_dirs() {
1118 assert!(is_broad_or_unsafe_root(Path::new("/home/user/.claude")));
1119 assert!(is_broad_or_unsafe_root(Path::new("/home/user/.codex")));
1120 }
1121
1122 #[test]
1123 fn broad_root_rejects_copilot_and_friends() {
1124 assert!(is_broad_or_unsafe_root(Path::new("/home/user/.copilot")));
1127 assert!(is_broad_or_unsafe_root(Path::new("/home/user/.cursor")));
1128 assert!(is_broad_or_unsafe_root(Path::new("/home/user/.windsurf")));
1129 assert!(is_broad_or_unsafe_root(Path::new("/home/user/.gemini")));
1130 assert!(is_broad_or_unsafe_root(Path::new("/home/user/.lmstudio")));
1131 }
1132
1133 #[test]
1134 fn agent_config_dir_matches_every_known_client() {
1135 for name in AGENT_CONFIG_DIRS {
1136 let leaf = format!("/home/user/{name}");
1137 assert!(is_agent_config_dir(Path::new(&leaf)), "{leaf}");
1138 let nested = format!("/home/user/{name}/mcp");
1139 assert!(is_agent_config_dir(Path::new(&nested)), "{nested}");
1140 }
1141 }
1142
1143 #[test]
1144 fn claude_code_worktree_checkout_is_not_agent_config() {
1145 for p in [
1148 "/repo/.claude/worktrees/fix-x",
1149 "/repo/.claude/worktrees/fix-x/rust/src",
1150 r"C:\repo\.claude\worktrees\fix-x",
1151 ] {
1152 assert!(!is_agent_config_dir(Path::new(p)), "{p}");
1153 assert!(!is_broad_or_unsafe_root(Path::new(p)), "{p}");
1154 }
1155 for p in [
1156 "/repo/.claude/worktrees",
1157 "/repo/.claude/worktrees/",
1158 "/repo/.claude/settings",
1159 "/repo/.claude/worktrees/fix-x/.claude",
1160 "/repo/.claude/worktrees/fix-x/.codex/mcp",
1161 "/home/user/.claude/worktreesx/a",
1162 ] {
1163 assert!(is_agent_config_dir(Path::new(p)), "{p}");
1164 }
1165 assert!(!is_agent_config_dir(Path::new("/repo/.claudex/a")));
1166 }
1167
1168 #[test]
1169 fn agent_config_dir_matches_windows_backslash() {
1170 assert!(is_agent_config_dir(Path::new(r"C:\Users\me\.copilot")));
1172 assert!(is_agent_config_dir(Path::new(r"C:\Users\me\.copilot\mcp")));
1173 }
1174
1175 #[test]
1176 fn agent_config_dir_ignores_real_projects() {
1177 assert!(!is_agent_config_dir(Path::new("/home/user/code/lean-ctx")));
1178 assert!(!is_agent_config_dir(Path::new(r"C:\src\app")));
1179 }
1180
1181 #[test]
1182 fn broad_root_allows_project_subdir() {
1183 let tmp = tempfile::tempdir().unwrap();
1184 let subdir = tmp.path().join("my-project");
1185 std::fs::create_dir_all(&subdir).unwrap();
1186 assert!(!is_broad_or_unsafe_root(&subdir));
1187 }
1188
1189 #[test]
1190 fn broad_root_allows_home_subdirs() {
1191 let _home = crate::core::data_dir::test_env_lock();
1192 if let Some(home) = dirs::home_dir() {
1193 let subdir = home.join("projects").join("my-app");
1194 assert!(!is_broad_or_unsafe_root(&subdir));
1195 }
1196 }
1197
1198 #[test]
1199 fn data_dir_collision_rejects_home() {
1200 let _home = crate::core::data_dir::test_env_lock();
1201 if let Some(home) = dirs::home_dir() {
1202 assert!(is_data_dir_collision(&home));
1203 }
1204 }
1205
1206 #[test]
1207 fn data_dir_collision_allows_normal_project() {
1208 let tmp = tempfile::tempdir().unwrap();
1209 let project = tmp.path().join("my-project");
1210 std::fs::create_dir_all(&project).unwrap();
1211 assert!(!is_data_dir_collision(&project));
1212 }
1213
1214 #[test]
1215 fn has_project_marker_detects_git() {
1216 let tmp = tempfile::tempdir().unwrap();
1217 let root = tmp.path().join("repo");
1218 std::fs::create_dir_all(&root).unwrap();
1219 assert!(!has_project_marker(&root));
1220 std::fs::create_dir(root.join(".git")).unwrap();
1221 assert!(has_project_marker(&root));
1222 }
1223
1224 #[test]
1225 fn has_project_marker_detects_cargo_toml() {
1226 let tmp = tempfile::tempdir().unwrap();
1227 let root = tmp.path().join("rust-project");
1228 std::fs::create_dir_all(&root).unwrap();
1229 std::fs::write(root.join("Cargo.toml"), "[package]").unwrap();
1230 assert!(has_project_marker(&root));
1231 }
1232
1233 #[test]
1234 fn has_project_marker_detects_godot_project() {
1235 let tmp = tempfile::tempdir().unwrap();
1236 let root = tmp.path().join("godot-game");
1237 std::fs::create_dir_all(&root).unwrap();
1238 std::fs::write(root.join("project.godot"), "config_version=5\n").unwrap();
1239 assert!(has_project_marker(&root));
1240 }
1241
1242 #[test]
1243 fn multi_repo_children_needs_two() {
1244 let tmp = tempfile::tempdir().unwrap();
1245 let parent = tmp.path().join("code");
1246 std::fs::create_dir_all(&parent).unwrap();
1247
1248 assert!(!has_multi_repo_children(&parent));
1250
1251 let repo1 = parent.join("repo1");
1253 std::fs::create_dir_all(repo1.join(".git")).unwrap();
1254 assert!(!has_multi_repo_children(&parent));
1255
1256 let repo2 = parent.join("repo2");
1258 std::fs::create_dir_all(repo2.join(".git")).unwrap();
1259 assert!(has_multi_repo_children(&parent));
1260 }
1261
1262 #[test]
1263 fn multi_repo_children_ignores_files() {
1264 let tmp = tempfile::tempdir().unwrap();
1265 let parent = tmp.path().join("mixed");
1266 std::fs::create_dir_all(&parent).unwrap();
1267
1268 let repo1 = parent.join("repo1");
1270 std::fs::create_dir_all(repo1.join(".git")).unwrap();
1271 std::fs::write(parent.join("not-a-repo"), "file").unwrap();
1272 assert!(!has_multi_repo_children(&parent));
1273
1274 let repo2 = parent.join("repo2");
1276 std::fs::create_dir_all(&repo2).unwrap();
1277 std::fs::write(repo2.join("package.json"), "{}").unwrap();
1278 assert!(has_multi_repo_children(&parent));
1279 }
1280
1281 #[test]
1282 fn multi_repo_children_nonexistent_dir() {
1283 assert!(!has_multi_repo_children(Path::new("/nonexistent/path/xyz")));
1284 }
1285
1286 #[test]
1287 fn regular_file_is_not_symlink_or_reparse() {
1288 let tmp = tempfile::tempdir().unwrap();
1289 let file = tmp.path().join("plain.txt");
1290 std::fs::write(&file, "x").unwrap();
1291 let meta = std::fs::symlink_metadata(&file).unwrap();
1292 assert!(!is_symlink_or_reparse(&meta));
1293 }
1294
1295 #[cfg(unix)]
1296 #[test]
1297 fn unix_symlink_is_detected() {
1298 let tmp = tempfile::tempdir().unwrap();
1299 let target = tmp.path().join("target.txt");
1300 std::fs::write(&target, "x").unwrap();
1301 let link = tmp.path().join("link.txt");
1302 std::os::unix::fs::symlink(&target, &link).unwrap();
1303 let meta = std::fs::symlink_metadata(&link).unwrap();
1304 assert!(is_symlink_or_reparse(&meta));
1305 }
1306
1307 #[cfg(windows)]
1310 #[test]
1311 fn windows_symlink_is_detected() {
1312 let tmp = tempfile::tempdir().unwrap();
1313 let target = tmp.path().join("target.txt");
1314 std::fs::write(&target, "x").unwrap();
1315 let link = tmp.path().join("link.txt");
1316 if std::os::windows::fs::symlink_file(&target, &link).is_err() {
1317 eprintln!("skipping: symlink creation not permitted on this runner");
1318 return;
1319 }
1320 let meta = std::fs::symlink_metadata(&link).unwrap();
1321 assert!(is_symlink_or_reparse(&meta));
1322 }
1323}