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
660pub fn find_on_path(name: &str) -> Option<PathBuf> {
669 find_all_on_path(name).into_iter().next()
670}
671
672pub fn find_all_on_path(name: &str) -> Vec<PathBuf> {
675 if name.is_empty() {
676 return Vec::new();
677 }
678 let Some(path_var) = std::env::var_os("PATH") else {
679 return Vec::new();
680 };
681 let candidates = path_candidates(name);
682 std::env::split_paths(&path_var)
683 .filter(|dir| !dir.as_os_str().is_empty())
684 .flat_map(|dir| candidates.iter().map(move |c| dir.join(c)))
685 .filter(|p| is_executable_file(p))
686 .collect()
687}
688
689#[cfg(windows)]
690fn path_candidates(name: &str) -> Vec<String> {
691 let mut out = Vec::new();
692 if Path::new(name).extension().is_none() {
693 let exts = std::env::var("PATHEXT").unwrap_or_default();
694 let exts = if exts.trim().is_empty() {
695 ".COM;.EXE;.BAT;.CMD".to_string()
696 } else {
697 exts
698 };
699 out.extend(
700 exts.split(';')
701 .map(str::trim)
702 .filter(|e| !e.is_empty())
703 .map(|e| format!("{name}{e}")),
704 );
705 }
706 out.push(name.to_string());
707 out
708}
709
710#[cfg(not(windows))]
711fn path_candidates(name: &str) -> Vec<String> {
712 vec![name.to_string()]
713}
714
715#[cfg(unix)]
716fn is_executable_file(p: &Path) -> bool {
717 use std::os::unix::fs::PermissionsExt;
718 std::fs::metadata(p).is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
719}
720
721#[cfg(not(unix))]
722fn is_executable_file(p: &Path) -> bool {
723 p.is_file()
724}
725
726#[cfg(test)]
727mod tests {
728 use super::*;
729
730 #[test]
731 fn find_on_path_resolves_extensionless_name_and_misses_unknown() {
732 let _env_lock = crate::core::data_dir::test_env_lock();
733 let tmp = tempfile::tempdir().unwrap();
734 #[cfg(windows)]
735 let file_name = "leanctx-fop-probe.cmd";
736 #[cfg(not(windows))]
737 let file_name = "leanctx-fop-probe";
738 let file = tmp.path().join(file_name);
739 std::fs::write(&file, "x").unwrap();
740 #[cfg(windows)]
743 std::fs::write(tmp.path().join("leanctx-fop-probe"), "#!/bin/sh").unwrap();
744 #[cfg(unix)]
745 {
746 use std::os::unix::fs::PermissionsExt;
747 std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o755)).unwrap();
748 }
749
750 let old = std::env::var_os("PATH");
751 let mut dirs = vec![tmp.path().to_path_buf()];
752 dirs.extend(old.iter().flat_map(std::env::split_paths));
753 unsafe { std::env::set_var("PATH", std::env::join_paths(dirs).unwrap()) };
755 let found = find_on_path("leanctx-fop-probe");
756 let missing = find_on_path("leanctx-fop-definitely-missing");
757 match old {
758 Some(v) => unsafe { std::env::set_var("PATH", v) },
760 None => unsafe { std::env::remove_var("PATH") },
762 }
763
764 let found = found.expect("probe on PATH");
765 assert!(
766 found
767 .to_string_lossy()
768 .eq_ignore_ascii_case(&file.to_string_lossy()),
769 "{found:?} vs {file:?}"
770 );
771 assert!(missing.is_none());
772 }
773
774 #[cfg(unix)]
778 #[test]
779 fn same_project_ignores_spelling_but_not_identity() {
780 let tmp = tempfile::tempdir().unwrap();
781 let real = tmp.path().join("repo");
782 let other = tmp.path().join("other");
783 std::fs::create_dir_all(&real).unwrap();
784 std::fs::create_dir_all(&other).unwrap();
785 let link = tmp.path().join("link");
786 std::os::unix::fs::symlink(&real, &link).unwrap();
787 let (real, link, other) = (
788 real.to_string_lossy().to_string(),
789 link.to_string_lossy().to_string(),
790 other.to_string_lossy().to_string(),
791 );
792
793 assert!(same_project(&real, &link));
794 assert!(same_project(&real, &format!("{real}/")));
795 assert!(same_project(r"C:\work\repo", "C:/work/repo/"));
796 assert!(!same_project(&real, &other));
797 }
798
799 #[test]
800 fn lean_ctx_stores_are_never_a_project_root() {
801 let iso = crate::core::data_dir::isolated_data_dir();
802 let project = tempfile::tempdir().unwrap();
803 assert!(is_broad_or_unsafe_root(iso.path()));
805 assert!(!is_broad_or_unsafe_root(&iso.path().join("proj")));
807 assert!(!is_broad_or_unsafe_root(project.path()));
808 }
809
810 #[cfg(unix)]
811 #[test]
812 fn a_store_under_another_spelling_is_still_refused() {
813 let iso = crate::core::data_dir::isolated_data_dir();
814 let links = tempfile::tempdir().unwrap();
815 let alias = links.path().join("data-alias");
816 std::os::unix::fs::symlink(iso.path(), &alias).unwrap();
817 assert!(is_broad_or_unsafe_root(&alias));
818 let inner = iso.path().join("proj");
820 std::fs::create_dir_all(&inner).unwrap();
821 let inner_alias = links.path().join("proj-alias");
822 std::os::unix::fs::symlink(&inner, &inner_alias).unwrap();
823 assert!(!is_broad_or_unsafe_root(&inner_alias));
824 }
825
826 #[test]
827 fn strip_regular_verbatim() {
828 let p = PathBuf::from(r"\\?\C:\Users\dev\project");
829 let result = strip_verbatim(p);
830 assert_eq!(result, PathBuf::from("C:/Users/dev/project"));
831 }
832
833 #[test]
834 fn tcc_sensitive_home_dir_matches_home_and_magic_dirs() {
835 let _env_lock = crate::core::data_dir::test_env_lock();
838 let Some(home) = dirs::home_dir() else {
839 return;
840 };
841 assert!(is_tcc_sensitive_home_dir(&home));
843 assert!(is_tcc_sensitive_home_dir(&home.join("Documents")));
844 assert!(is_tcc_sensitive_home_dir(&home.join("Desktop")));
845 assert!(is_tcc_sensitive_home_dir(&home.join("Downloads")));
846 }
847
848 #[test]
849 fn tcc_sensitive_home_dir_allows_real_projects() {
850 let _env_lock = crate::core::data_dir::test_env_lock();
851 let Some(home) = dirs::home_dir() else {
852 return;
853 };
854 assert!(!is_tcc_sensitive_home_dir(
857 &home.join("Documents").join("my-project")
858 ));
859 assert!(!is_tcc_sensitive_home_dir(&home.join("code")));
860 assert!(!is_tcc_sensitive_home_dir(&home.join("Projects")));
861 }
862
863 #[test]
864 #[cfg(target_os = "macos")]
865 fn under_tcc_protected_dir_matches_nested_paths() {
866 let _env_lock = crate::core::data_dir::test_env_lock();
867 let Some(home) = dirs::home_dir() else {
868 return;
869 };
870 assert!(is_under_tcc_protected_dir(&home.join("Documents")));
872 assert!(is_under_tcc_protected_dir(
873 &home.join("Documents/deep/nested/project")
874 ));
875 assert!(is_under_tcc_protected_dir(&home.join("Desktop/scratch")));
876 assert!(is_under_tcc_protected_dir(&home.join("Downloads/x.zip")));
877 assert!(!is_under_tcc_protected_dir(&home));
879 assert!(!is_under_tcc_protected_dir(&home.join("code/project")));
880 assert!(!is_under_tcc_protected_dir(Path::new("/tmp/Documents")));
881 }
882
883 #[test]
884 #[cfg(target_os = "macos")]
885 #[serial_test::serial]
886 fn tcc_standalone_blocks_probes_under_protected_dirs() {
887 let _env_lock = crate::core::data_dir::test_env_lock();
893 let Some(home) = dirs::home_dir() else {
894 return;
895 };
896 let doc_proj = home.join("Documents/some-project");
897
898 crate::test_env::set_var("LEAN_CTX_TCC_STANDALONE", "1");
899 assert!(process_is_tcc_standalone());
900 assert!(!may_probe_path(&doc_proj));
901 assert!(may_probe_path(Path::new("/tmp/some-project")));
903 assert!(!has_project_marker(&doc_proj));
905
906 crate::test_env::set_var("LEAN_CTX_TCC_STANDALONE", "0");
907 assert!(!process_is_tcc_standalone());
908 assert!(may_probe_path(&doc_proj));
909 crate::test_env::remove_var("LEAN_CTX_TCC_STANDALONE");
910 }
911
912 #[test]
913 #[cfg(target_os = "macos")]
914 #[serial_test::serial]
915 fn tcc_standalone_detected_via_seatbelt_sentinel() {
916 let _env_lock = crate::core::data_dir::test_env_lock();
917 let Some(home) = dirs::home_dir() else {
918 return;
919 };
920 let doc_proj = home.join("Documents/some-project");
921
922 crate::test_env::remove_var("LEAN_CTX_TCC_STANDALONE");
927 crate::test_env::set_var(crate::core::tcc_guard_sandbox::SEATBELT_SENTINEL, "1");
928 assert!(process_is_tcc_standalone());
929 assert!(!may_probe_path(&doc_proj));
930 crate::test_env::remove_var(crate::core::tcc_guard_sandbox::SEATBELT_SENTINEL);
931
932 assert!(!process_is_tcc_standalone());
935 }
936
937 #[test]
938 #[cfg(target_os = "macos")]
939 #[serial_test::serial]
940 fn tcc_standalone_skips_canonicalize_under_protected_dirs() {
941 let _env_lock = crate::core::data_dir::test_env_lock();
942 let Some(home) = dirs::home_dir() else {
943 return;
944 };
945 let missing = home.join("Documents/lean-ctx-tcc-test-does-not-exist-xyzzy");
951
952 crate::test_env::set_var("LEAN_CTX_TCC_STANDALONE", "1");
953 let guarded = safe_canonicalize(&missing);
954 assert!(
955 guarded.is_ok(),
956 "standalone safe_canonicalize must short-circuit (no stat) under ~/Documents"
957 );
958 assert_eq!(guarded.unwrap(), missing);
959 assert_eq!(safe_canonicalize_or_self(&missing), missing);
960
961 let tmp_missing = Path::new("/tmp/lean-ctx-tcc-test-does-not-exist-xyzzy");
963 assert!(safe_canonicalize(tmp_missing).is_err());
964
965 crate::test_env::set_var("LEAN_CTX_TCC_STANDALONE", "0");
968 assert!(safe_canonicalize(&missing).is_err());
969
970 crate::test_env::remove_var("LEAN_CTX_TCC_STANDALONE");
971 }
972
973 #[test]
974 #[cfg(target_os = "macos")]
975 #[serial_test::serial]
976 fn canonicalize_secure_bypasses_tcc_guard_for_pathjail() {
977 let _env_lock = crate::core::data_dir::test_env_lock();
978 let Some(home) = dirs::home_dir() else {
985 return;
986 };
987 let missing = home.join("Documents/lean-ctx-secure-canon-does-not-exist-xyzzy");
988
989 crate::test_env::set_var("LEAN_CTX_TCC_STANDALONE", "1");
990 assert_eq!(safe_canonicalize(&missing).unwrap(), missing);
992 assert!(
996 canonicalize_secure(&missing).is_err(),
997 "canonicalize_secure must bypass the TCC guard and touch the filesystem"
998 );
999 assert_eq!(canonicalize_secure_or_self(&missing), missing);
1000 crate::test_env::remove_var("LEAN_CTX_TCC_STANDALONE");
1001 }
1002
1003 #[test]
1004 fn strip_unc_verbatim() {
1005 let p = PathBuf::from(r"\\?\UNC\server\share\dir");
1006 let result = strip_verbatim(p);
1007 assert_eq!(result, PathBuf::from("//server/share/dir"));
1008 }
1009
1010 #[test]
1011 fn no_prefix_unchanged() {
1012 let p = PathBuf::from("/home/user/project");
1013 let result = strip_verbatim(p.clone());
1014 assert_eq!(result, p);
1015 }
1016
1017 #[test]
1018 fn windows_drive_unchanged() {
1019 let p = PathBuf::from("C:/Users/dev");
1020 let result = strip_verbatim(p.clone());
1021 assert_eq!(result, p);
1022 }
1023
1024 #[test]
1025 fn strip_str_regular() {
1026 assert_eq!(
1027 strip_verbatim_str(r"\\?\E:\code\lean-ctx"),
1028 Some("E:/code/lean-ctx".to_string())
1029 );
1030 }
1031
1032 #[test]
1033 fn strip_str_unc() {
1034 assert_eq!(
1035 strip_verbatim_str(r"\\?\UNC\myserver\data"),
1036 Some("//myserver/data".to_string())
1037 );
1038 }
1039
1040 #[test]
1041 fn strip_str_forward_slash_variant() {
1042 assert_eq!(
1043 strip_verbatim_str("//?/C:/Users/dev"),
1044 Some("C:/Users/dev".to_string())
1045 );
1046 }
1047
1048 #[test]
1049 fn strip_str_no_prefix() {
1050 assert_eq!(strip_verbatim_str("/home/user"), None);
1051 }
1052
1053 #[test]
1054 fn safe_canonicalize_or_self_nonexistent() {
1055 let p = Path::new("/this/path/should/not/exist/xyzzy");
1056 let result = safe_canonicalize_or_self(p);
1057 assert_eq!(result, p.to_path_buf());
1058 }
1059
1060 #[test]
1063 fn msys_drive_prefix_translation() {
1064 assert_eq!(
1065 translate_msys_drive_prefix("/c/Users/ABC").as_deref(),
1066 Some("C:/Users/ABC")
1067 );
1068 assert_eq!(
1069 translate_msys_drive_prefix("/D/Program Files").as_deref(),
1070 Some("D:/Program Files")
1071 );
1072 assert_eq!(translate_msys_drive_prefix("/usr/local/bin"), None);
1073 assert_eq!(translate_msys_drive_prefix("/c"), None);
1074 assert_eq!(translate_msys_drive_prefix("c/Users"), None);
1075 }
1076
1077 #[cfg(windows)]
1078 #[test]
1079 fn normalize_msys_path_to_native() {
1080 assert_eq!(
1081 normalize_tool_path("/c/Users/ABC/AppData/lean-ctx"),
1082 "C:/Users/ABC/AppData/lean-ctx"
1083 );
1084 assert_eq!(
1085 normalize_tool_path("/D/Program Files/lean-ctx.exe"),
1086 "D:/Program Files/lean-ctx.exe"
1087 );
1088 }
1089
1090 #[cfg(not(windows))]
1093 #[test]
1094 fn normalize_single_letter_unix_path_untouched() {
1095 assert_eq!(
1096 normalize_tool_path_lexical("/c/Users/me/proj"),
1097 "/c/Users/me/proj"
1098 );
1099 assert_eq!(
1100 normalize_tool_path_lexical("/x/projects/app/src"),
1101 "/x/projects/app/src"
1102 );
1103 }
1104
1105 #[test]
1106 fn normalize_native_windows_path_unchanged() {
1107 assert_eq!(
1108 normalize_tool_path("C:/Users/ABC/lean-ctx.exe"),
1109 "C:/Users/ABC/lean-ctx.exe"
1110 );
1111 }
1112
1113 #[test]
1114 fn normalize_backslash_windows_path() {
1115 assert_eq!(
1116 normalize_tool_path(r"C:\Users\ABC\lean-ctx.exe"),
1117 "C:/Users/ABC/lean-ctx.exe"
1118 );
1119 }
1120
1121 #[test]
1122 fn normalize_unix_path_unchanged() {
1123 assert_eq!(
1124 normalize_tool_path("/usr/local/bin/lean-ctx"),
1125 "/usr/local/bin/lean-ctx"
1126 );
1127 }
1128
1129 #[cfg(not(windows))]
1130 #[test]
1131 fn normalize_unescapes_shell_quoting_on_unix() {
1132 assert_eq!(
1133 normalize_tool_path_lexical(r"/Users/me/Pfadi\ Scouting/app/main.ts"),
1134 "/Users/me/Pfadi Scouting/app/main.ts",
1135 "an escaped space must not become a separator"
1136 );
1137 assert_eq!(
1138 normalize_tool_path_lexical(r"/srv/\(group\)/a\&b.txt"),
1139 "/srv/(group)/a&b.txt"
1140 );
1141 assert_eq!(
1142 normalize_tool_path_lexical("'/Users/me/My Project/src'"),
1143 "/Users/me/My Project/src"
1144 );
1145 assert_eq!(normalize_tool_path_lexical("\"/tmp/a b\""), "/tmp/a b");
1146 assert_eq!(
1147 normalize_tool_path_lexical("'"),
1148 "'",
1149 "a lone quote is a name"
1150 );
1151 }
1152
1153 #[test]
1154 fn normalize_windows_path_with_spaces_and_backslashes() {
1155 assert_eq!(
1159 normalize_tool_path(r"C:\Users\My Name\My Project\src\main.rs"),
1160 "C:/Users/My Name/My Project/src/main.rs"
1161 );
1162 assert_eq!(
1163 normalize_tool_path(r"C:\Program Files\app\config.toml"),
1164 "C:/Program Files/app/config.toml"
1165 );
1166 }
1167
1168 #[test]
1169 fn normalize_double_slashes() {
1170 assert_eq!(
1171 normalize_tool_path("C:/Users//ABC//lean-ctx"),
1172 "C:/Users/ABC/lean-ctx"
1173 );
1174 }
1175
1176 #[test]
1177 fn normalize_trailing_slash_removed() {
1178 assert_eq!(normalize_tool_path("C:/Users/ABC/"), "C:/Users/ABC");
1179 assert_eq!(
1180 normalize_tool_path_lexical("/tmp/nonexistent-dir-xyzzy/"),
1181 "/tmp/nonexistent-dir-xyzzy"
1182 );
1183 }
1184
1185 #[test]
1186 fn normalize_root_slash_preserved() {
1187 assert_eq!(normalize_tool_path("/"), "/");
1188 }
1189
1190 #[test]
1191 fn normalize_drive_root_preserved() {
1192 assert_eq!(normalize_tool_path("C:/"), "C:/");
1193 }
1194
1195 #[test]
1196 fn normalize_verbatim_with_msys() {
1197 assert_eq!(normalize_tool_path(r"\\?\C:\Users\dev"), "C:/Users/dev");
1198 }
1199
1200 #[test]
1201 fn broad_root_rejects_home() {
1202 let _home = crate::core::data_dir::test_env_lock();
1203 if let Some(home) = dirs::home_dir() {
1204 assert!(is_broad_or_unsafe_root(&home));
1205 }
1206 }
1207
1208 #[test]
1209 fn broad_root_rejects_wsl_windows_user_profile() {
1210 assert!(is_broad_or_unsafe_root(Path::new("/mnt/c/Users/dev")));
1211 assert!(!is_broad_or_unsafe_root(Path::new(
1212 "/mnt/c/Users/dev/projects/my-app"
1213 )));
1214 }
1215
1216 #[test]
1217 fn broad_root_rejects_filesystem_root() {
1218 assert!(is_broad_or_unsafe_root(Path::new("/")));
1219 }
1220
1221 #[test]
1222 fn broad_root_rejects_dot() {
1223 assert!(is_broad_or_unsafe_root(Path::new(".")));
1224 }
1225
1226 #[test]
1227 fn broad_root_rejects_agent_dirs() {
1228 assert!(is_broad_or_unsafe_root(Path::new("/home/user/.claude")));
1229 assert!(is_broad_or_unsafe_root(Path::new("/home/user/.codex")));
1230 }
1231
1232 #[test]
1233 fn broad_root_rejects_copilot_and_friends() {
1234 assert!(is_broad_or_unsafe_root(Path::new("/home/user/.copilot")));
1237 assert!(is_broad_or_unsafe_root(Path::new("/home/user/.cursor")));
1238 assert!(is_broad_or_unsafe_root(Path::new("/home/user/.windsurf")));
1239 assert!(is_broad_or_unsafe_root(Path::new("/home/user/.gemini")));
1240 assert!(is_broad_or_unsafe_root(Path::new("/home/user/.lmstudio")));
1241 }
1242
1243 #[test]
1244 fn agent_config_dir_matches_every_known_client() {
1245 for name in AGENT_CONFIG_DIRS {
1246 let leaf = format!("/home/user/{name}");
1247 assert!(is_agent_config_dir(Path::new(&leaf)), "{leaf}");
1248 let nested = format!("/home/user/{name}/mcp");
1249 assert!(is_agent_config_dir(Path::new(&nested)), "{nested}");
1250 }
1251 }
1252
1253 #[test]
1254 fn claude_code_worktree_checkout_is_not_agent_config() {
1255 for p in [
1258 "/repo/.claude/worktrees/fix-x",
1259 "/repo/.claude/worktrees/fix-x/rust/src",
1260 r"C:\repo\.claude\worktrees\fix-x",
1261 ] {
1262 assert!(!is_agent_config_dir(Path::new(p)), "{p}");
1263 assert!(!is_broad_or_unsafe_root(Path::new(p)), "{p}");
1264 }
1265 for p in [
1266 "/repo/.claude/worktrees",
1267 "/repo/.claude/worktrees/",
1268 "/repo/.claude/settings",
1269 "/repo/.claude/worktrees/fix-x/.claude",
1270 "/repo/.claude/worktrees/fix-x/.codex/mcp",
1271 "/home/user/.claude/worktreesx/a",
1272 ] {
1273 assert!(is_agent_config_dir(Path::new(p)), "{p}");
1274 }
1275 assert!(!is_agent_config_dir(Path::new("/repo/.claudex/a")));
1276 }
1277
1278 #[test]
1279 fn agent_config_dir_matches_windows_backslash() {
1280 assert!(is_agent_config_dir(Path::new(r"C:\Users\me\.copilot")));
1282 assert!(is_agent_config_dir(Path::new(r"C:\Users\me\.copilot\mcp")));
1283 }
1284
1285 #[test]
1286 fn agent_config_dir_ignores_real_projects() {
1287 assert!(!is_agent_config_dir(Path::new("/home/user/code/lean-ctx")));
1288 assert!(!is_agent_config_dir(Path::new(r"C:\src\app")));
1289 }
1290
1291 #[test]
1292 fn broad_root_allows_project_subdir() {
1293 let tmp = tempfile::tempdir().unwrap();
1294 let subdir = tmp.path().join("my-project");
1295 std::fs::create_dir_all(&subdir).unwrap();
1296 assert!(!is_broad_or_unsafe_root(&subdir));
1297 }
1298
1299 #[test]
1300 fn broad_root_allows_home_subdirs() {
1301 let _home = crate::core::data_dir::test_env_lock();
1302 if let Some(home) = dirs::home_dir() {
1303 let subdir = home.join("projects").join("my-app");
1304 assert!(!is_broad_or_unsafe_root(&subdir));
1305 }
1306 }
1307
1308 #[test]
1309 fn data_dir_collision_rejects_home() {
1310 let _home = crate::core::data_dir::test_env_lock();
1311 if let Some(home) = dirs::home_dir() {
1312 assert!(is_data_dir_collision(&home));
1313 }
1314 }
1315
1316 #[test]
1317 fn data_dir_collision_allows_normal_project() {
1318 let tmp = tempfile::tempdir().unwrap();
1319 let project = tmp.path().join("my-project");
1320 std::fs::create_dir_all(&project).unwrap();
1321 assert!(!is_data_dir_collision(&project));
1322 }
1323
1324 #[test]
1325 fn has_project_marker_detects_git() {
1326 let tmp = tempfile::tempdir().unwrap();
1327 let root = tmp.path().join("repo");
1328 std::fs::create_dir_all(&root).unwrap();
1329 assert!(!has_project_marker(&root));
1330 std::fs::create_dir(root.join(".git")).unwrap();
1331 assert!(has_project_marker(&root));
1332 }
1333
1334 #[test]
1335 fn has_project_marker_detects_cargo_toml() {
1336 let tmp = tempfile::tempdir().unwrap();
1337 let root = tmp.path().join("rust-project");
1338 std::fs::create_dir_all(&root).unwrap();
1339 std::fs::write(root.join("Cargo.toml"), "[package]").unwrap();
1340 assert!(has_project_marker(&root));
1341 }
1342
1343 #[test]
1344 fn has_project_marker_detects_godot_project() {
1345 let tmp = tempfile::tempdir().unwrap();
1346 let root = tmp.path().join("godot-game");
1347 std::fs::create_dir_all(&root).unwrap();
1348 std::fs::write(root.join("project.godot"), "config_version=5\n").unwrap();
1349 assert!(has_project_marker(&root));
1350 }
1351
1352 #[test]
1353 fn multi_repo_children_needs_two() {
1354 let tmp = tempfile::tempdir().unwrap();
1355 let parent = tmp.path().join("code");
1356 std::fs::create_dir_all(&parent).unwrap();
1357
1358 assert!(!has_multi_repo_children(&parent));
1360
1361 let repo1 = parent.join("repo1");
1363 std::fs::create_dir_all(repo1.join(".git")).unwrap();
1364 assert!(!has_multi_repo_children(&parent));
1365
1366 let repo2 = parent.join("repo2");
1368 std::fs::create_dir_all(repo2.join(".git")).unwrap();
1369 assert!(has_multi_repo_children(&parent));
1370 }
1371
1372 #[test]
1373 fn multi_repo_children_ignores_files() {
1374 let tmp = tempfile::tempdir().unwrap();
1375 let parent = tmp.path().join("mixed");
1376 std::fs::create_dir_all(&parent).unwrap();
1377
1378 let repo1 = parent.join("repo1");
1380 std::fs::create_dir_all(repo1.join(".git")).unwrap();
1381 std::fs::write(parent.join("not-a-repo"), "file").unwrap();
1382 assert!(!has_multi_repo_children(&parent));
1383
1384 let repo2 = parent.join("repo2");
1386 std::fs::create_dir_all(&repo2).unwrap();
1387 std::fs::write(repo2.join("package.json"), "{}").unwrap();
1388 assert!(has_multi_repo_children(&parent));
1389 }
1390
1391 #[test]
1392 fn multi_repo_children_nonexistent_dir() {
1393 assert!(!has_multi_repo_children(Path::new("/nonexistent/path/xyz")));
1394 }
1395
1396 #[test]
1397 fn regular_file_is_not_symlink_or_reparse() {
1398 let tmp = tempfile::tempdir().unwrap();
1399 let file = tmp.path().join("plain.txt");
1400 std::fs::write(&file, "x").unwrap();
1401 let meta = std::fs::symlink_metadata(&file).unwrap();
1402 assert!(!is_symlink_or_reparse(&meta));
1403 }
1404
1405 #[cfg(unix)]
1406 #[test]
1407 fn unix_symlink_is_detected() {
1408 let tmp = tempfile::tempdir().unwrap();
1409 let target = tmp.path().join("target.txt");
1410 std::fs::write(&target, "x").unwrap();
1411 let link = tmp.path().join("link.txt");
1412 std::os::unix::fs::symlink(&target, &link).unwrap();
1413 let meta = std::fs::symlink_metadata(&link).unwrap();
1414 assert!(is_symlink_or_reparse(&meta));
1415 }
1416
1417 #[cfg(windows)]
1420 #[test]
1421 fn windows_symlink_is_detected() {
1422 let tmp = tempfile::tempdir().unwrap();
1423 let target = tmp.path().join("target.txt");
1424 std::fs::write(&target, "x").unwrap();
1425 let link = tmp.path().join("link.txt");
1426 if std::os::windows::fs::symlink_file(&target, &link).is_err() {
1427 eprintln!("skipping: symlink creation not permitted on this runner");
1428 return;
1429 }
1430 let meta = std::fs::symlink_metadata(&link).unwrap();
1431 assert!(is_symlink_or_reparse(&meta));
1432 }
1433}