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 if let Some(home) = dirs::home_dir() {
1093 assert!(is_broad_or_unsafe_root(&home));
1094 }
1095 }
1096
1097 #[test]
1098 fn broad_root_rejects_wsl_windows_user_profile() {
1099 assert!(is_broad_or_unsafe_root(Path::new("/mnt/c/Users/dev")));
1100 assert!(!is_broad_or_unsafe_root(Path::new(
1101 "/mnt/c/Users/dev/projects/my-app"
1102 )));
1103 }
1104
1105 #[test]
1106 fn broad_root_rejects_filesystem_root() {
1107 assert!(is_broad_or_unsafe_root(Path::new("/")));
1108 }
1109
1110 #[test]
1111 fn broad_root_rejects_dot() {
1112 assert!(is_broad_or_unsafe_root(Path::new(".")));
1113 }
1114
1115 #[test]
1116 fn broad_root_rejects_agent_dirs() {
1117 assert!(is_broad_or_unsafe_root(Path::new("/home/user/.claude")));
1118 assert!(is_broad_or_unsafe_root(Path::new("/home/user/.codex")));
1119 }
1120
1121 #[test]
1122 fn broad_root_rejects_copilot_and_friends() {
1123 assert!(is_broad_or_unsafe_root(Path::new("/home/user/.copilot")));
1126 assert!(is_broad_or_unsafe_root(Path::new("/home/user/.cursor")));
1127 assert!(is_broad_or_unsafe_root(Path::new("/home/user/.windsurf")));
1128 assert!(is_broad_or_unsafe_root(Path::new("/home/user/.gemini")));
1129 assert!(is_broad_or_unsafe_root(Path::new("/home/user/.lmstudio")));
1130 }
1131
1132 #[test]
1133 fn agent_config_dir_matches_every_known_client() {
1134 for name in AGENT_CONFIG_DIRS {
1135 let leaf = format!("/home/user/{name}");
1136 assert!(is_agent_config_dir(Path::new(&leaf)), "{leaf}");
1137 let nested = format!("/home/user/{name}/mcp");
1138 assert!(is_agent_config_dir(Path::new(&nested)), "{nested}");
1139 }
1140 }
1141
1142 #[test]
1143 fn claude_code_worktree_checkout_is_not_agent_config() {
1144 for p in [
1147 "/repo/.claude/worktrees/fix-x",
1148 "/repo/.claude/worktrees/fix-x/rust/src",
1149 r"C:\repo\.claude\worktrees\fix-x",
1150 ] {
1151 assert!(!is_agent_config_dir(Path::new(p)), "{p}");
1152 assert!(!is_broad_or_unsafe_root(Path::new(p)), "{p}");
1153 }
1154 for p in [
1155 "/repo/.claude/worktrees",
1156 "/repo/.claude/worktrees/",
1157 "/repo/.claude/settings",
1158 "/repo/.claude/worktrees/fix-x/.claude",
1159 "/repo/.claude/worktrees/fix-x/.codex/mcp",
1160 "/home/user/.claude/worktreesx/a",
1161 ] {
1162 assert!(is_agent_config_dir(Path::new(p)), "{p}");
1163 }
1164 assert!(!is_agent_config_dir(Path::new("/repo/.claudex/a")));
1165 }
1166
1167 #[test]
1168 fn agent_config_dir_matches_windows_backslash() {
1169 assert!(is_agent_config_dir(Path::new(r"C:\Users\me\.copilot")));
1171 assert!(is_agent_config_dir(Path::new(r"C:\Users\me\.copilot\mcp")));
1172 }
1173
1174 #[test]
1175 fn agent_config_dir_ignores_real_projects() {
1176 assert!(!is_agent_config_dir(Path::new("/home/user/code/lean-ctx")));
1177 assert!(!is_agent_config_dir(Path::new(r"C:\src\app")));
1178 }
1179
1180 #[test]
1181 fn broad_root_allows_project_subdir() {
1182 let tmp = tempfile::tempdir().unwrap();
1183 let subdir = tmp.path().join("my-project");
1184 std::fs::create_dir_all(&subdir).unwrap();
1185 assert!(!is_broad_or_unsafe_root(&subdir));
1186 }
1187
1188 #[test]
1189 fn broad_root_allows_home_subdirs() {
1190 if let Some(home) = dirs::home_dir() {
1191 let subdir = home.join("projects").join("my-app");
1192 assert!(!is_broad_or_unsafe_root(&subdir));
1193 }
1194 }
1195
1196 #[test]
1197 fn data_dir_collision_rejects_home() {
1198 if let Some(home) = dirs::home_dir() {
1199 assert!(is_data_dir_collision(&home));
1200 }
1201 }
1202
1203 #[test]
1204 fn data_dir_collision_allows_normal_project() {
1205 let tmp = tempfile::tempdir().unwrap();
1206 let project = tmp.path().join("my-project");
1207 std::fs::create_dir_all(&project).unwrap();
1208 assert!(!is_data_dir_collision(&project));
1209 }
1210
1211 #[test]
1212 fn has_project_marker_detects_git() {
1213 let tmp = tempfile::tempdir().unwrap();
1214 let root = tmp.path().join("repo");
1215 std::fs::create_dir_all(&root).unwrap();
1216 assert!(!has_project_marker(&root));
1217 std::fs::create_dir(root.join(".git")).unwrap();
1218 assert!(has_project_marker(&root));
1219 }
1220
1221 #[test]
1222 fn has_project_marker_detects_cargo_toml() {
1223 let tmp = tempfile::tempdir().unwrap();
1224 let root = tmp.path().join("rust-project");
1225 std::fs::create_dir_all(&root).unwrap();
1226 std::fs::write(root.join("Cargo.toml"), "[package]").unwrap();
1227 assert!(has_project_marker(&root));
1228 }
1229
1230 #[test]
1231 fn has_project_marker_detects_godot_project() {
1232 let tmp = tempfile::tempdir().unwrap();
1233 let root = tmp.path().join("godot-game");
1234 std::fs::create_dir_all(&root).unwrap();
1235 std::fs::write(root.join("project.godot"), "config_version=5\n").unwrap();
1236 assert!(has_project_marker(&root));
1237 }
1238
1239 #[test]
1240 fn multi_repo_children_needs_two() {
1241 let tmp = tempfile::tempdir().unwrap();
1242 let parent = tmp.path().join("code");
1243 std::fs::create_dir_all(&parent).unwrap();
1244
1245 assert!(!has_multi_repo_children(&parent));
1247
1248 let repo1 = parent.join("repo1");
1250 std::fs::create_dir_all(repo1.join(".git")).unwrap();
1251 assert!(!has_multi_repo_children(&parent));
1252
1253 let repo2 = parent.join("repo2");
1255 std::fs::create_dir_all(repo2.join(".git")).unwrap();
1256 assert!(has_multi_repo_children(&parent));
1257 }
1258
1259 #[test]
1260 fn multi_repo_children_ignores_files() {
1261 let tmp = tempfile::tempdir().unwrap();
1262 let parent = tmp.path().join("mixed");
1263 std::fs::create_dir_all(&parent).unwrap();
1264
1265 let repo1 = parent.join("repo1");
1267 std::fs::create_dir_all(repo1.join(".git")).unwrap();
1268 std::fs::write(parent.join("not-a-repo"), "file").unwrap();
1269 assert!(!has_multi_repo_children(&parent));
1270
1271 let repo2 = parent.join("repo2");
1273 std::fs::create_dir_all(&repo2).unwrap();
1274 std::fs::write(repo2.join("package.json"), "{}").unwrap();
1275 assert!(has_multi_repo_children(&parent));
1276 }
1277
1278 #[test]
1279 fn multi_repo_children_nonexistent_dir() {
1280 assert!(!has_multi_repo_children(Path::new("/nonexistent/path/xyz")));
1281 }
1282
1283 #[test]
1284 fn regular_file_is_not_symlink_or_reparse() {
1285 let tmp = tempfile::tempdir().unwrap();
1286 let file = tmp.path().join("plain.txt");
1287 std::fs::write(&file, "x").unwrap();
1288 let meta = std::fs::symlink_metadata(&file).unwrap();
1289 assert!(!is_symlink_or_reparse(&meta));
1290 }
1291
1292 #[cfg(unix)]
1293 #[test]
1294 fn unix_symlink_is_detected() {
1295 let tmp = tempfile::tempdir().unwrap();
1296 let target = tmp.path().join("target.txt");
1297 std::fs::write(&target, "x").unwrap();
1298 let link = tmp.path().join("link.txt");
1299 std::os::unix::fs::symlink(&target, &link).unwrap();
1300 let meta = std::fs::symlink_metadata(&link).unwrap();
1301 assert!(is_symlink_or_reparse(&meta));
1302 }
1303
1304 #[cfg(windows)]
1307 #[test]
1308 fn windows_symlink_is_detected() {
1309 let tmp = tempfile::tempdir().unwrap();
1310 let target = tmp.path().join("target.txt");
1311 std::fs::write(&target, "x").unwrap();
1312 let link = tmp.path().join("link.txt");
1313 if std::os::windows::fs::symlink_file(&target, &link).is_err() {
1314 eprintln!("skipping: symlink creation not permitted on this runner");
1315 return;
1316 }
1317 let meta = std::fs::symlink_metadata(&link).unwrap();
1318 assert!(is_symlink_or_reparse(&meta));
1319 }
1320}