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 meta.file_type().is_symlink() || is_reparse_point(meta)
497}
498
499#[cfg(windows)]
501fn is_reparse_point(meta: &std::fs::Metadata) -> bool {
502 use std::os::windows::fs::MetadataExt;
503 const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0400;
504 meta.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
505}
506
507#[cfg(not(windows))]
508fn is_reparse_point(_meta: &std::fs::Metadata) -> bool {
509 false
510}
511
512pub fn is_tcc_sensitive_home_dir(dir: &Path) -> bool {
521 let Some(home) = dirs::home_dir() else {
522 return false;
523 };
524 if dir == home {
525 return true;
526 }
527 if dir.parent() != Some(home.as_path()) {
528 return false;
529 }
530 matches!(
531 dir.file_name().and_then(|n| n.to_str()),
532 Some("Documents" | "Desktop" | "Downloads")
533 )
534}
535
536pub fn is_under_tcc_protected_dir(path: &Path) -> bool {
544 if !cfg!(target_os = "macos") {
545 return false;
546 }
547 let Some(home) = dirs::home_dir() else {
548 return false;
549 };
550 ["Documents", "Desktop", "Downloads"]
551 .iter()
552 .any(|magic| path.starts_with(home.join(magic)))
553}
554
555pub fn process_is_tcc_standalone() -> bool {
569 #[cfg(target_os = "macos")]
570 {
571 if let Ok(v) = std::env::var("LEAN_CTX_TCC_STANDALONE") {
575 match v.trim() {
576 "1" | "true" => return true,
577 "0" | "false" => return false,
578 _ => {}
579 }
580 }
581 if std::env::var_os(crate::core::tcc_guard_sandbox::SEATBELT_SENTINEL).is_some() {
589 return true;
590 }
591 (unsafe { libc::getppid() }) == 1
593 }
594 #[cfg(not(target_os = "macos"))]
595 {
596 false
597 }
598}
599
600pub fn may_probe_path(path: &Path) -> bool {
608 !(process_is_tcc_standalone() && is_under_tcc_protected_dir(path))
609}
610
611pub fn has_multi_repo_children(dir: &Path) -> bool {
614 if is_tcc_sensitive_home_dir(dir) || !may_probe_path(dir) {
620 return false;
621 }
622 let Ok(entries) = std::fs::read_dir(dir) else {
623 return false;
624 };
625 let count = entries
626 .filter_map(Result::ok)
627 .filter(|e| e.file_type().is_ok_and(|ft| ft.is_dir()))
628 .filter(|e| has_project_marker(&e.path()))
629 .take(2)
630 .count();
631 count >= 2
632}
633
634pub fn is_data_dir_collision(project_root: &Path) -> bool {
638 if is_broad_or_unsafe_root(project_root) {
639 return true;
640 }
641 if let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() {
642 let project_lean_ctx = project_root.join(".lean-ctx");
643 if project_lean_ctx == data_dir || data_dir.starts_with(&project_lean_ctx) {
644 return true;
645 }
646 }
647 false
648}
649
650pub fn safe_project_data_dir(project_root: &Path) -> Result<PathBuf, String> {
653 if is_data_dir_collision(project_root) {
654 return Err(format!(
655 "project root {} collides with global data directory; \
656 skipping project-scoped write",
657 project_root.display()
658 ));
659 }
660 Ok(project_root.join(".lean-ctx"))
661}
662
663pub fn find_on_path(name: &str) -> Option<PathBuf> {
672 find_all_on_path(name).into_iter().next()
673}
674
675pub fn find_all_on_path(name: &str) -> Vec<PathBuf> {
678 if name.is_empty() {
679 return Vec::new();
680 }
681 let Some(path_var) = std::env::var_os("PATH") else {
682 return Vec::new();
683 };
684 let candidates = path_candidates(name);
685 std::env::split_paths(&path_var)
686 .filter(|dir| !dir.as_os_str().is_empty())
687 .flat_map(|dir| candidates.iter().map(move |c| dir.join(c)))
688 .filter(|p| is_executable_file(p))
689 .collect()
690}
691
692#[cfg(windows)]
693fn path_candidates(name: &str) -> Vec<String> {
694 let mut out = Vec::new();
695 if Path::new(name).extension().is_none() {
696 let exts = std::env::var("PATHEXT").unwrap_or_default();
697 let exts = if exts.trim().is_empty() {
698 ".COM;.EXE;.BAT;.CMD".to_string()
699 } else {
700 exts
701 };
702 out.extend(
703 exts.split(';')
704 .map(str::trim)
705 .filter(|e| !e.is_empty())
706 .map(|e| format!("{name}{e}")),
707 );
708 }
709 out.push(name.to_string());
710 out
711}
712
713#[cfg(not(windows))]
714fn path_candidates(name: &str) -> Vec<String> {
715 vec![name.to_string()]
716}
717
718#[cfg(unix)]
719fn is_executable_file(p: &Path) -> bool {
720 use std::os::unix::fs::PermissionsExt;
721 std::fs::metadata(p).is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
722}
723
724#[cfg(not(unix))]
725fn is_executable_file(p: &Path) -> bool {
726 p.is_file()
727}
728
729#[cfg(test)]
730mod tests {
731 use super::*;
732
733 #[test]
734 fn find_on_path_resolves_extensionless_name_and_misses_unknown() {
735 let _env_lock = crate::core::data_dir::test_env_lock();
736 let tmp = tempfile::tempdir().unwrap();
737 #[cfg(windows)]
738 let file_name = "leanctx-fop-probe.cmd";
739 #[cfg(not(windows))]
740 let file_name = "leanctx-fop-probe";
741 let file = tmp.path().join(file_name);
742 std::fs::write(&file, "x").unwrap();
743 #[cfg(windows)]
746 std::fs::write(tmp.path().join("leanctx-fop-probe"), "#!/bin/sh").unwrap();
747 #[cfg(unix)]
748 {
749 use std::os::unix::fs::PermissionsExt;
750 std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o755)).unwrap();
751 }
752
753 let old = std::env::var_os("PATH");
754 let mut dirs = vec![tmp.path().to_path_buf()];
755 dirs.extend(old.iter().flat_map(std::env::split_paths));
756 unsafe { std::env::set_var("PATH", std::env::join_paths(dirs).unwrap()) };
758 let found = find_on_path("leanctx-fop-probe");
759 let missing = find_on_path("leanctx-fop-definitely-missing");
760 match old {
761 Some(v) => unsafe { std::env::set_var("PATH", v) },
763 None => unsafe { std::env::remove_var("PATH") },
765 }
766
767 let found = found.expect("probe on PATH");
768 assert!(
769 found
770 .to_string_lossy()
771 .eq_ignore_ascii_case(&file.to_string_lossy()),
772 "{found:?} vs {file:?}"
773 );
774 assert!(missing.is_none());
775 }
776
777 #[cfg(unix)]
781 #[test]
782 fn same_project_ignores_spelling_but_not_identity() {
783 let tmp = tempfile::tempdir().unwrap();
784 let real = tmp.path().join("repo");
785 let other = tmp.path().join("other");
786 std::fs::create_dir_all(&real).unwrap();
787 std::fs::create_dir_all(&other).unwrap();
788 let link = tmp.path().join("link");
789 std::os::unix::fs::symlink(&real, &link).unwrap();
790 let (real, link, other) = (
791 real.to_string_lossy().to_string(),
792 link.to_string_lossy().to_string(),
793 other.to_string_lossy().to_string(),
794 );
795
796 assert!(same_project(&real, &link));
797 assert!(same_project(&real, &format!("{real}/")));
798 assert!(same_project(r"C:\work\repo", "C:/work/repo/"));
799 assert!(!same_project(&real, &other));
800 }
801
802 #[test]
803 fn lean_ctx_stores_are_never_a_project_root() {
804 let iso = crate::core::data_dir::isolated_data_dir();
805 let project = tempfile::tempdir().unwrap();
806 assert!(is_broad_or_unsafe_root(iso.path()));
808 assert!(!is_broad_or_unsafe_root(&iso.path().join("proj")));
810 assert!(!is_broad_or_unsafe_root(project.path()));
811 }
812
813 #[cfg(unix)]
814 #[test]
815 fn a_store_under_another_spelling_is_still_refused() {
816 let iso = crate::core::data_dir::isolated_data_dir();
817 let links = tempfile::tempdir().unwrap();
818 let alias = links.path().join("data-alias");
819 std::os::unix::fs::symlink(iso.path(), &alias).unwrap();
820 assert!(is_broad_or_unsafe_root(&alias));
821 let inner = iso.path().join("proj");
823 std::fs::create_dir_all(&inner).unwrap();
824 let inner_alias = links.path().join("proj-alias");
825 std::os::unix::fs::symlink(&inner, &inner_alias).unwrap();
826 assert!(!is_broad_or_unsafe_root(&inner_alias));
827 }
828
829 #[test]
830 fn strip_regular_verbatim() {
831 let p = PathBuf::from(r"\\?\C:\Users\dev\project");
832 let result = strip_verbatim(p);
833 assert_eq!(result, PathBuf::from("C:/Users/dev/project"));
834 }
835
836 #[test]
837 fn tcc_sensitive_home_dir_matches_home_and_magic_dirs() {
838 let _env_lock = crate::core::data_dir::test_env_lock();
841 let Some(home) = dirs::home_dir() else {
842 return;
843 };
844 assert!(is_tcc_sensitive_home_dir(&home));
846 assert!(is_tcc_sensitive_home_dir(&home.join("Documents")));
847 assert!(is_tcc_sensitive_home_dir(&home.join("Desktop")));
848 assert!(is_tcc_sensitive_home_dir(&home.join("Downloads")));
849 }
850
851 #[test]
852 fn tcc_sensitive_home_dir_allows_real_projects() {
853 let _env_lock = crate::core::data_dir::test_env_lock();
854 let Some(home) = dirs::home_dir() else {
855 return;
856 };
857 assert!(!is_tcc_sensitive_home_dir(
860 &home.join("Documents").join("my-project")
861 ));
862 assert!(!is_tcc_sensitive_home_dir(&home.join("code")));
863 assert!(!is_tcc_sensitive_home_dir(&home.join("Projects")));
864 }
865
866 #[test]
867 #[cfg(target_os = "macos")]
868 fn under_tcc_protected_dir_matches_nested_paths() {
869 let _env_lock = crate::core::data_dir::test_env_lock();
870 let Some(home) = dirs::home_dir() else {
871 return;
872 };
873 assert!(is_under_tcc_protected_dir(&home.join("Documents")));
875 assert!(is_under_tcc_protected_dir(
876 &home.join("Documents/deep/nested/project")
877 ));
878 assert!(is_under_tcc_protected_dir(&home.join("Desktop/scratch")));
879 assert!(is_under_tcc_protected_dir(&home.join("Downloads/x.zip")));
880 assert!(!is_under_tcc_protected_dir(&home));
882 assert!(!is_under_tcc_protected_dir(&home.join("code/project")));
883 assert!(!is_under_tcc_protected_dir(Path::new("/tmp/Documents")));
884 }
885
886 #[test]
887 #[cfg(target_os = "macos")]
888 #[serial_test::serial]
889 fn tcc_standalone_blocks_probes_under_protected_dirs() {
890 let _env_lock = crate::core::data_dir::test_env_lock();
896 let Some(home) = dirs::home_dir() else {
897 return;
898 };
899 let doc_proj = home.join("Documents/some-project");
900
901 crate::test_env::set_var("LEAN_CTX_TCC_STANDALONE", "1");
902 assert!(process_is_tcc_standalone());
903 assert!(!may_probe_path(&doc_proj));
904 assert!(may_probe_path(Path::new("/tmp/some-project")));
906 assert!(!has_project_marker(&doc_proj));
908
909 crate::test_env::set_var("LEAN_CTX_TCC_STANDALONE", "0");
910 assert!(!process_is_tcc_standalone());
911 assert!(may_probe_path(&doc_proj));
912 crate::test_env::remove_var("LEAN_CTX_TCC_STANDALONE");
913 }
914
915 #[test]
916 #[cfg(target_os = "macos")]
917 #[serial_test::serial]
918 fn tcc_standalone_detected_via_seatbelt_sentinel() {
919 let _env_lock = crate::core::data_dir::test_env_lock();
920 let Some(home) = dirs::home_dir() else {
921 return;
922 };
923 let doc_proj = home.join("Documents/some-project");
924
925 crate::test_env::remove_var("LEAN_CTX_TCC_STANDALONE");
930 crate::test_env::set_var(crate::core::tcc_guard_sandbox::SEATBELT_SENTINEL, "1");
931 assert!(process_is_tcc_standalone());
932 assert!(!may_probe_path(&doc_proj));
933 crate::test_env::remove_var(crate::core::tcc_guard_sandbox::SEATBELT_SENTINEL);
934
935 assert!(!process_is_tcc_standalone());
938 }
939
940 #[test]
941 #[cfg(target_os = "macos")]
942 #[serial_test::serial]
943 fn tcc_standalone_skips_canonicalize_under_protected_dirs() {
944 let _env_lock = crate::core::data_dir::test_env_lock();
945 let Some(home) = dirs::home_dir() else {
946 return;
947 };
948 let missing = home.join("Documents/lean-ctx-tcc-test-does-not-exist-xyzzy");
954
955 crate::test_env::set_var("LEAN_CTX_TCC_STANDALONE", "1");
956 let guarded = safe_canonicalize(&missing);
957 assert!(
958 guarded.is_ok(),
959 "standalone safe_canonicalize must short-circuit (no stat) under ~/Documents"
960 );
961 assert_eq!(guarded.unwrap(), missing);
962 assert_eq!(safe_canonicalize_or_self(&missing), missing);
963
964 let tmp_missing = Path::new("/tmp/lean-ctx-tcc-test-does-not-exist-xyzzy");
966 assert!(safe_canonicalize(tmp_missing).is_err());
967
968 crate::test_env::set_var("LEAN_CTX_TCC_STANDALONE", "0");
971 assert!(safe_canonicalize(&missing).is_err());
972
973 crate::test_env::remove_var("LEAN_CTX_TCC_STANDALONE");
974 }
975
976 #[test]
977 #[cfg(target_os = "macos")]
978 #[serial_test::serial]
979 fn canonicalize_secure_bypasses_tcc_guard_for_pathjail() {
980 let _env_lock = crate::core::data_dir::test_env_lock();
981 let Some(home) = dirs::home_dir() else {
988 return;
989 };
990 let missing = home.join("Documents/lean-ctx-secure-canon-does-not-exist-xyzzy");
991
992 crate::test_env::set_var("LEAN_CTX_TCC_STANDALONE", "1");
993 assert_eq!(safe_canonicalize(&missing).unwrap(), missing);
995 assert!(
999 canonicalize_secure(&missing).is_err(),
1000 "canonicalize_secure must bypass the TCC guard and touch the filesystem"
1001 );
1002 assert_eq!(canonicalize_secure_or_self(&missing), missing);
1003 crate::test_env::remove_var("LEAN_CTX_TCC_STANDALONE");
1004 }
1005
1006 #[test]
1007 fn strip_unc_verbatim() {
1008 let p = PathBuf::from(r"\\?\UNC\server\share\dir");
1009 let result = strip_verbatim(p);
1010 assert_eq!(result, PathBuf::from("//server/share/dir"));
1011 }
1012
1013 #[test]
1014 fn no_prefix_unchanged() {
1015 let p = PathBuf::from("/home/user/project");
1016 let result = strip_verbatim(p.clone());
1017 assert_eq!(result, p);
1018 }
1019
1020 #[test]
1021 fn windows_drive_unchanged() {
1022 let p = PathBuf::from("C:/Users/dev");
1023 let result = strip_verbatim(p.clone());
1024 assert_eq!(result, p);
1025 }
1026
1027 #[test]
1028 fn strip_str_regular() {
1029 assert_eq!(
1030 strip_verbatim_str(r"\\?\E:\code\lean-ctx"),
1031 Some("E:/code/lean-ctx".to_string())
1032 );
1033 }
1034
1035 #[test]
1036 fn strip_str_unc() {
1037 assert_eq!(
1038 strip_verbatim_str(r"\\?\UNC\myserver\data"),
1039 Some("//myserver/data".to_string())
1040 );
1041 }
1042
1043 #[test]
1044 fn strip_str_forward_slash_variant() {
1045 assert_eq!(
1046 strip_verbatim_str("//?/C:/Users/dev"),
1047 Some("C:/Users/dev".to_string())
1048 );
1049 }
1050
1051 #[test]
1052 fn strip_str_no_prefix() {
1053 assert_eq!(strip_verbatim_str("/home/user"), None);
1054 }
1055
1056 #[test]
1057 fn safe_canonicalize_or_self_nonexistent() {
1058 let p = Path::new("/this/path/should/not/exist/xyzzy");
1059 let result = safe_canonicalize_or_self(p);
1060 assert_eq!(result, p.to_path_buf());
1061 }
1062
1063 #[test]
1066 fn msys_drive_prefix_translation() {
1067 assert_eq!(
1068 translate_msys_drive_prefix("/c/Users/ABC").as_deref(),
1069 Some("C:/Users/ABC")
1070 );
1071 assert_eq!(
1072 translate_msys_drive_prefix("/D/Program Files").as_deref(),
1073 Some("D:/Program Files")
1074 );
1075 assert_eq!(translate_msys_drive_prefix("/usr/local/bin"), None);
1076 assert_eq!(translate_msys_drive_prefix("/c"), None);
1077 assert_eq!(translate_msys_drive_prefix("c/Users"), None);
1078 }
1079
1080 #[cfg(windows)]
1081 #[test]
1082 fn normalize_msys_path_to_native() {
1083 assert_eq!(
1084 normalize_tool_path("/c/Users/ABC/AppData/lean-ctx"),
1085 "C:/Users/ABC/AppData/lean-ctx"
1086 );
1087 assert_eq!(
1088 normalize_tool_path("/D/Program Files/lean-ctx.exe"),
1089 "D:/Program Files/lean-ctx.exe"
1090 );
1091 }
1092
1093 #[cfg(not(windows))]
1096 #[test]
1097 fn normalize_single_letter_unix_path_untouched() {
1098 assert_eq!(
1099 normalize_tool_path_lexical("/c/Users/me/proj"),
1100 "/c/Users/me/proj"
1101 );
1102 assert_eq!(
1103 normalize_tool_path_lexical("/x/projects/app/src"),
1104 "/x/projects/app/src"
1105 );
1106 }
1107
1108 #[test]
1109 fn normalize_native_windows_path_unchanged() {
1110 assert_eq!(
1111 normalize_tool_path("C:/Users/ABC/lean-ctx.exe"),
1112 "C:/Users/ABC/lean-ctx.exe"
1113 );
1114 }
1115
1116 #[test]
1117 fn normalize_backslash_windows_path() {
1118 assert_eq!(
1119 normalize_tool_path(r"C:\Users\ABC\lean-ctx.exe"),
1120 "C:/Users/ABC/lean-ctx.exe"
1121 );
1122 }
1123
1124 #[test]
1125 fn normalize_unix_path_unchanged() {
1126 assert_eq!(
1127 normalize_tool_path("/usr/local/bin/lean-ctx"),
1128 "/usr/local/bin/lean-ctx"
1129 );
1130 }
1131
1132 #[cfg(not(windows))]
1133 #[test]
1134 fn normalize_unescapes_shell_quoting_on_unix() {
1135 assert_eq!(
1136 normalize_tool_path_lexical(r"/Users/me/Pfadi\ Scouting/app/main.ts"),
1137 "/Users/me/Pfadi Scouting/app/main.ts",
1138 "an escaped space must not become a separator"
1139 );
1140 assert_eq!(
1141 normalize_tool_path_lexical(r"/srv/\(group\)/a\&b.txt"),
1142 "/srv/(group)/a&b.txt"
1143 );
1144 assert_eq!(
1145 normalize_tool_path_lexical("'/Users/me/My Project/src'"),
1146 "/Users/me/My Project/src"
1147 );
1148 assert_eq!(normalize_tool_path_lexical("\"/tmp/a b\""), "/tmp/a b");
1149 assert_eq!(
1150 normalize_tool_path_lexical("'"),
1151 "'",
1152 "a lone quote is a name"
1153 );
1154 }
1155
1156 #[test]
1157 fn normalize_windows_path_with_spaces_and_backslashes() {
1158 assert_eq!(
1162 normalize_tool_path(r"C:\Users\My Name\My Project\src\main.rs"),
1163 "C:/Users/My Name/My Project/src/main.rs"
1164 );
1165 assert_eq!(
1166 normalize_tool_path(r"C:\Program Files\app\config.toml"),
1167 "C:/Program Files/app/config.toml"
1168 );
1169 }
1170
1171 #[test]
1172 fn normalize_double_slashes() {
1173 assert_eq!(
1174 normalize_tool_path("C:/Users//ABC//lean-ctx"),
1175 "C:/Users/ABC/lean-ctx"
1176 );
1177 }
1178
1179 #[test]
1180 fn normalize_trailing_slash_removed() {
1181 assert_eq!(normalize_tool_path("C:/Users/ABC/"), "C:/Users/ABC");
1182 assert_eq!(
1183 normalize_tool_path_lexical("/tmp/nonexistent-dir-xyzzy/"),
1184 "/tmp/nonexistent-dir-xyzzy"
1185 );
1186 }
1187
1188 #[test]
1189 fn normalize_root_slash_preserved() {
1190 assert_eq!(normalize_tool_path("/"), "/");
1191 }
1192
1193 #[test]
1194 fn normalize_drive_root_preserved() {
1195 assert_eq!(normalize_tool_path("C:/"), "C:/");
1196 }
1197
1198 #[test]
1199 fn normalize_verbatim_with_msys() {
1200 assert_eq!(normalize_tool_path(r"\\?\C:\Users\dev"), "C:/Users/dev");
1201 }
1202
1203 #[test]
1204 fn broad_root_rejects_home() {
1205 let _home = crate::core::data_dir::test_env_lock();
1206 if let Some(home) = dirs::home_dir() {
1207 assert!(is_broad_or_unsafe_root(&home));
1208 }
1209 }
1210
1211 #[test]
1212 fn broad_root_rejects_wsl_windows_user_profile() {
1213 assert!(is_broad_or_unsafe_root(Path::new("/mnt/c/Users/dev")));
1214 assert!(!is_broad_or_unsafe_root(Path::new(
1215 "/mnt/c/Users/dev/projects/my-app"
1216 )));
1217 }
1218
1219 #[test]
1220 fn broad_root_rejects_filesystem_root() {
1221 assert!(is_broad_or_unsafe_root(Path::new("/")));
1222 }
1223
1224 #[test]
1225 fn broad_root_rejects_dot() {
1226 assert!(is_broad_or_unsafe_root(Path::new(".")));
1227 }
1228
1229 #[test]
1230 fn broad_root_rejects_agent_dirs() {
1231 assert!(is_broad_or_unsafe_root(Path::new("/home/user/.claude")));
1232 assert!(is_broad_or_unsafe_root(Path::new("/home/user/.codex")));
1233 }
1234
1235 #[test]
1236 fn broad_root_rejects_copilot_and_friends() {
1237 assert!(is_broad_or_unsafe_root(Path::new("/home/user/.copilot")));
1240 assert!(is_broad_or_unsafe_root(Path::new("/home/user/.cursor")));
1241 assert!(is_broad_or_unsafe_root(Path::new("/home/user/.windsurf")));
1242 assert!(is_broad_or_unsafe_root(Path::new("/home/user/.gemini")));
1243 assert!(is_broad_or_unsafe_root(Path::new("/home/user/.lmstudio")));
1244 }
1245
1246 #[test]
1247 fn agent_config_dir_matches_every_known_client() {
1248 for name in AGENT_CONFIG_DIRS {
1249 let leaf = format!("/home/user/{name}");
1250 assert!(is_agent_config_dir(Path::new(&leaf)), "{leaf}");
1251 let nested = format!("/home/user/{name}/mcp");
1252 assert!(is_agent_config_dir(Path::new(&nested)), "{nested}");
1253 }
1254 }
1255
1256 #[test]
1257 fn claude_code_worktree_checkout_is_not_agent_config() {
1258 for p in [
1261 "/repo/.claude/worktrees/fix-x",
1262 "/repo/.claude/worktrees/fix-x/rust/src",
1263 r"C:\repo\.claude\worktrees\fix-x",
1264 ] {
1265 assert!(!is_agent_config_dir(Path::new(p)), "{p}");
1266 assert!(!is_broad_or_unsafe_root(Path::new(p)), "{p}");
1267 }
1268 for p in [
1269 "/repo/.claude/worktrees",
1270 "/repo/.claude/worktrees/",
1271 "/repo/.claude/settings",
1272 "/repo/.claude/worktrees/fix-x/.claude",
1273 "/repo/.claude/worktrees/fix-x/.codex/mcp",
1274 "/home/user/.claude/worktreesx/a",
1275 ] {
1276 assert!(is_agent_config_dir(Path::new(p)), "{p}");
1277 }
1278 assert!(!is_agent_config_dir(Path::new("/repo/.claudex/a")));
1279 }
1280
1281 #[test]
1282 fn agent_config_dir_matches_windows_backslash() {
1283 assert!(is_agent_config_dir(Path::new(r"C:\Users\me\.copilot")));
1285 assert!(is_agent_config_dir(Path::new(r"C:\Users\me\.copilot\mcp")));
1286 }
1287
1288 #[test]
1289 fn agent_config_dir_ignores_real_projects() {
1290 assert!(!is_agent_config_dir(Path::new("/home/user/code/lean-ctx")));
1291 assert!(!is_agent_config_dir(Path::new(r"C:\src\app")));
1292 }
1293
1294 #[test]
1295 fn broad_root_allows_project_subdir() {
1296 let tmp = tempfile::tempdir().unwrap();
1297 let subdir = tmp.path().join("my-project");
1298 std::fs::create_dir_all(&subdir).unwrap();
1299 assert!(!is_broad_or_unsafe_root(&subdir));
1300 }
1301
1302 #[test]
1303 fn broad_root_allows_home_subdirs() {
1304 let _home = crate::core::data_dir::test_env_lock();
1305 if let Some(home) = dirs::home_dir() {
1306 let subdir = home.join("projects").join("my-app");
1307 assert!(!is_broad_or_unsafe_root(&subdir));
1308 }
1309 }
1310
1311 #[test]
1312 fn data_dir_collision_rejects_home() {
1313 let _home = crate::core::data_dir::test_env_lock();
1314 if let Some(home) = dirs::home_dir() {
1315 assert!(is_data_dir_collision(&home));
1316 }
1317 }
1318
1319 #[test]
1320 fn data_dir_collision_allows_normal_project() {
1321 let tmp = tempfile::tempdir().unwrap();
1322 let project = tmp.path().join("my-project");
1323 std::fs::create_dir_all(&project).unwrap();
1324 assert!(!is_data_dir_collision(&project));
1325 }
1326
1327 #[test]
1328 fn has_project_marker_detects_git() {
1329 let tmp = tempfile::tempdir().unwrap();
1330 let root = tmp.path().join("repo");
1331 std::fs::create_dir_all(&root).unwrap();
1332 assert!(!has_project_marker(&root));
1333 std::fs::create_dir(root.join(".git")).unwrap();
1334 assert!(has_project_marker(&root));
1335 }
1336
1337 #[test]
1338 fn has_project_marker_detects_cargo_toml() {
1339 let tmp = tempfile::tempdir().unwrap();
1340 let root = tmp.path().join("rust-project");
1341 std::fs::create_dir_all(&root).unwrap();
1342 std::fs::write(root.join("Cargo.toml"), "[package]").unwrap();
1343 assert!(has_project_marker(&root));
1344 }
1345
1346 #[test]
1347 fn has_project_marker_detects_godot_project() {
1348 let tmp = tempfile::tempdir().unwrap();
1349 let root = tmp.path().join("godot-game");
1350 std::fs::create_dir_all(&root).unwrap();
1351 std::fs::write(root.join("project.godot"), "config_version=5\n").unwrap();
1352 assert!(has_project_marker(&root));
1353 }
1354
1355 #[test]
1356 fn multi_repo_children_needs_two() {
1357 let tmp = tempfile::tempdir().unwrap();
1358 let parent = tmp.path().join("code");
1359 std::fs::create_dir_all(&parent).unwrap();
1360
1361 assert!(!has_multi_repo_children(&parent));
1363
1364 let repo1 = parent.join("repo1");
1366 std::fs::create_dir_all(repo1.join(".git")).unwrap();
1367 assert!(!has_multi_repo_children(&parent));
1368
1369 let repo2 = parent.join("repo2");
1371 std::fs::create_dir_all(repo2.join(".git")).unwrap();
1372 assert!(has_multi_repo_children(&parent));
1373 }
1374
1375 #[test]
1376 fn multi_repo_children_ignores_files() {
1377 let tmp = tempfile::tempdir().unwrap();
1378 let parent = tmp.path().join("mixed");
1379 std::fs::create_dir_all(&parent).unwrap();
1380
1381 let repo1 = parent.join("repo1");
1383 std::fs::create_dir_all(repo1.join(".git")).unwrap();
1384 std::fs::write(parent.join("not-a-repo"), "file").unwrap();
1385 assert!(!has_multi_repo_children(&parent));
1386
1387 let repo2 = parent.join("repo2");
1389 std::fs::create_dir_all(&repo2).unwrap();
1390 std::fs::write(repo2.join("package.json"), "{}").unwrap();
1391 assert!(has_multi_repo_children(&parent));
1392 }
1393
1394 #[test]
1395 fn multi_repo_children_nonexistent_dir() {
1396 assert!(!has_multi_repo_children(Path::new("/nonexistent/path/xyz")));
1397 }
1398
1399 #[test]
1400 fn regular_file_is_not_symlink_or_reparse() {
1401 let tmp = tempfile::tempdir().unwrap();
1402 let file = tmp.path().join("plain.txt");
1403 std::fs::write(&file, "x").unwrap();
1404 let meta = std::fs::symlink_metadata(&file).unwrap();
1405 assert!(!is_symlink_or_reparse(&meta));
1406 }
1407
1408 #[cfg(unix)]
1409 #[test]
1410 fn unix_symlink_is_detected() {
1411 let tmp = tempfile::tempdir().unwrap();
1412 let target = tmp.path().join("target.txt");
1413 std::fs::write(&target, "x").unwrap();
1414 let link = tmp.path().join("link.txt");
1415 std::os::unix::fs::symlink(&target, &link).unwrap();
1416 let meta = std::fs::symlink_metadata(&link).unwrap();
1417 assert!(is_symlink_or_reparse(&meta));
1418 }
1419
1420 #[cfg(windows)]
1423 #[test]
1424 fn windows_symlink_is_detected() {
1425 let tmp = tempfile::tempdir().unwrap();
1426 let target = tmp.path().join("target.txt");
1427 std::fs::write(&target, "x").unwrap();
1428 let link = tmp.path().join("link.txt");
1429 if std::os::windows::fs::symlink_file(&target, &link).is_err() {
1430 eprintln!("skipping: symlink creation not permitted on this runner");
1431 return;
1432 }
1433 let meta = std::fs::symlink_metadata(&link).unwrap();
1434 assert!(is_symlink_or_reparse(&meta));
1435 }
1436}