use std::path::{Path, PathBuf};
pub fn safe_canonicalize(path: &Path) -> std::io::Result<PathBuf> {
let canon = std::fs::canonicalize(path)?;
Ok(strip_verbatim(canon))
}
pub fn safe_canonicalize_or_self(path: &Path) -> PathBuf {
safe_canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
}
pub fn safe_canonicalize_bounded(path: &Path, timeout_ms: u64) -> PathBuf {
use super::io_health;
let path_str = path.to_string_lossy();
if io_health::is_slow_mount(&path_str) && io_health::recent_freeze_count() > 0 {
return safe_canonicalize_or_self(path);
}
let effective_timeout =
io_health::adaptive_timeout(std::time::Duration::from_millis(timeout_ms));
let path_owned = path.to_path_buf();
let (tx, rx) = std::sync::mpsc::channel();
let _ = std::thread::Builder::new()
.name("canonicalize-bounded".into())
.spawn(move || {
let result = safe_canonicalize(&path_owned).unwrap_or(path_owned);
let _ = tx.send(result);
});
if let Ok(canonical) = rx.recv_timeout(effective_timeout) {
canonical
} else {
io_health::record_freeze();
tracing::warn!(
"[SECURITY] canonicalize timed out ({}ms) for {}; PathJail checks on \
uncanonicalized paths may be less reliable",
effective_timeout.as_millis(),
path.display()
);
path.to_path_buf()
}
}
pub fn strip_verbatim(path: PathBuf) -> PathBuf {
let s = path.to_string_lossy();
if let Some(stripped) = strip_verbatim_str(&s) {
PathBuf::from(stripped)
} else {
path
}
}
pub fn strip_verbatim_str(path: &str) -> Option<String> {
let normalized = path.replace('\\', "/");
if let Some(rest) = normalized.strip_prefix("//?/UNC/") {
Some(format!("//{rest}"))
} else {
normalized
.strip_prefix("//?/")
.map(std::string::ToString::to_string)
}
}
fn translate_msys_drive_prefix(p: &str) -> Option<String> {
if p.len() >= 3
&& p.starts_with('/')
&& p.as_bytes()[1].is_ascii_alphabetic()
&& p.as_bytes()[2] == b'/'
{
let drive = p.as_bytes()[1].to_ascii_uppercase() as char;
Some(format!("{drive}:{}", &p[2..]))
} else {
None
}
}
pub fn normalize_tool_path_lexical(path: &str) -> String {
let mut p = match strip_verbatim_str(path) {
Some(stripped) => stripped,
None => path.to_string(),
};
if cfg!(windows) {
if let Some(translated) = translate_msys_drive_prefix(&p) {
p = translated;
}
}
p = p.replace('\\', "/");
while p.contains("//") && !p.starts_with("//") {
p = p.replace("//", "/");
}
if p.len() > 1 && p.ends_with('/') && !p.ends_with(":/") {
p.pop();
}
p
}
pub fn normalize_tool_path(path: &str) -> String {
let mut p = normalize_tool_path_lexical(path);
let is_absolute = p.starts_with('/') || (p.len() >= 3 && p.as_bytes()[1] == b':');
let is_root_only = p == "/" || (p.len() <= 3 && p.ends_with('/') && is_absolute);
if is_absolute
&& !is_root_only
&& !crate::core::io_health::is_slow_mount(&p)
&& may_probe_path(Path::new(&*p))
{
if let Ok(canonical) = safe_canonicalize(Path::new(&*p)) {
let canonical_str = canonical.to_string_lossy().replace('\\', "/");
if !canonical_str.is_empty() {
p = canonical_str;
}
}
}
p
}
pub fn is_broad_or_unsafe_root(dir: &Path) -> bool {
if let Some(home) = dirs::home_dir() {
if dir == home {
return true;
}
}
let s = dir.to_string_lossy();
if s == "/" || s == "\\" || s == "." {
return true;
}
s.ends_with("/.claude")
|| s.ends_with("/.codex")
|| s.contains("/.claude/")
|| s.contains("/.codex/")
}
pub const PROJECT_MARKERS: &[&str] = &[
".git",
"Cargo.toml",
"package.json",
"go.mod",
"pyproject.toml",
"setup.py",
"pom.xml",
"build.gradle",
"Makefile",
"project.godot",
".lean-ctx.toml",
".planning",
];
pub fn has_project_marker(dir: &Path) -> bool {
if !may_probe_path(dir) {
return false;
}
PROJECT_MARKERS.iter().any(|m| dir.join(m).exists())
}
pub fn is_symlink_or_reparse(meta: &std::fs::Metadata) -> bool {
if meta.file_type().is_symlink() {
return true;
}
#[cfg(windows)]
{
use std::os::windows::fs::MetadataExt;
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0400;
return meta.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0;
}
#[cfg(not(windows))]
false
}
pub fn is_tcc_sensitive_home_dir(dir: &Path) -> bool {
let Some(home) = dirs::home_dir() else {
return false;
};
if dir == home {
return true;
}
if dir.parent() != Some(home.as_path()) {
return false;
}
matches!(
dir.file_name().and_then(|n| n.to_str()),
Some("Documents" | "Desktop" | "Downloads")
)
}
pub fn is_under_tcc_protected_dir(path: &Path) -> bool {
if !cfg!(target_os = "macos") {
return false;
}
let Some(home) = dirs::home_dir() else {
return false;
};
["Documents", "Desktop", "Downloads"]
.iter()
.any(|magic| path.starts_with(home.join(magic)))
}
pub fn process_is_tcc_standalone() -> bool {
#[cfg(target_os = "macos")]
{
if let Ok(v) = std::env::var("LEAN_CTX_TCC_STANDALONE") {
match v.trim() {
"1" | "true" => return true,
"0" | "false" => return false,
_ => {}
}
}
(unsafe { libc::getppid() }) == 1
}
#[cfg(not(target_os = "macos"))]
{
false
}
}
pub fn may_probe_path(path: &Path) -> bool {
!(process_is_tcc_standalone() && is_under_tcc_protected_dir(path))
}
pub fn has_multi_repo_children(dir: &Path) -> bool {
if is_tcc_sensitive_home_dir(dir) {
return false;
}
let Ok(entries) = std::fs::read_dir(dir) else {
return false;
};
let count = entries
.filter_map(Result::ok)
.filter(|e| e.file_type().is_ok_and(|ft| ft.is_dir()))
.filter(|e| has_project_marker(&e.path()))
.take(2)
.count();
count >= 2
}
pub fn is_data_dir_collision(project_root: &Path) -> bool {
if is_broad_or_unsafe_root(project_root) {
return true;
}
if let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() {
let project_lean_ctx = project_root.join(".lean-ctx");
if project_lean_ctx == data_dir || data_dir.starts_with(&project_lean_ctx) {
return true;
}
}
false
}
pub fn safe_project_data_dir(project_root: &Path) -> Result<PathBuf, String> {
if is_data_dir_collision(project_root) {
return Err(format!(
"project root {} collides with global data directory; \
skipping project-scoped write",
project_root.display()
));
}
Ok(project_root.join(".lean-ctx"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strip_regular_verbatim() {
let p = PathBuf::from(r"\\?\C:\Users\dev\project");
let result = strip_verbatim(p);
assert_eq!(result, PathBuf::from("C:/Users/dev/project"));
}
#[test]
fn tcc_sensitive_home_dir_matches_home_and_magic_dirs() {
let Some(home) = dirs::home_dir() else {
return;
};
assert!(is_tcc_sensitive_home_dir(&home));
assert!(is_tcc_sensitive_home_dir(&home.join("Documents")));
assert!(is_tcc_sensitive_home_dir(&home.join("Desktop")));
assert!(is_tcc_sensitive_home_dir(&home.join("Downloads")));
}
#[test]
fn tcc_sensitive_home_dir_allows_real_projects() {
let Some(home) = dirs::home_dir() else {
return;
};
assert!(!is_tcc_sensitive_home_dir(
&home.join("Documents").join("my-project")
));
assert!(!is_tcc_sensitive_home_dir(&home.join("code")));
assert!(!is_tcc_sensitive_home_dir(&home.join("Projects")));
}
#[test]
#[cfg(target_os = "macos")]
fn under_tcc_protected_dir_matches_nested_paths() {
let Some(home) = dirs::home_dir() else {
return;
};
assert!(is_under_tcc_protected_dir(&home.join("Documents")));
assert!(is_under_tcc_protected_dir(
&home.join("Documents/deep/nested/project")
));
assert!(is_under_tcc_protected_dir(&home.join("Desktop/scratch")));
assert!(is_under_tcc_protected_dir(&home.join("Downloads/x.zip")));
assert!(!is_under_tcc_protected_dir(&home));
assert!(!is_under_tcc_protected_dir(&home.join("code/project")));
assert!(!is_under_tcc_protected_dir(Path::new("/tmp/Documents")));
}
#[test]
#[cfg(target_os = "macos")]
#[serial_test::serial]
fn tcc_standalone_blocks_probes_under_protected_dirs() {
let Some(home) = dirs::home_dir() else {
return;
};
let doc_proj = home.join("Documents/some-project");
std::env::set_var("LEAN_CTX_TCC_STANDALONE", "1");
assert!(process_is_tcc_standalone());
assert!(!may_probe_path(&doc_proj));
assert!(may_probe_path(Path::new("/tmp/some-project")));
assert!(!has_project_marker(&doc_proj));
std::env::set_var("LEAN_CTX_TCC_STANDALONE", "0");
assert!(!process_is_tcc_standalone());
assert!(may_probe_path(&doc_proj));
std::env::remove_var("LEAN_CTX_TCC_STANDALONE");
}
#[test]
fn strip_unc_verbatim() {
let p = PathBuf::from(r"\\?\UNC\server\share\dir");
let result = strip_verbatim(p);
assert_eq!(result, PathBuf::from("//server/share/dir"));
}
#[test]
fn no_prefix_unchanged() {
let p = PathBuf::from("/home/user/project");
let result = strip_verbatim(p.clone());
assert_eq!(result, p);
}
#[test]
fn windows_drive_unchanged() {
let p = PathBuf::from("C:/Users/dev");
let result = strip_verbatim(p.clone());
assert_eq!(result, p);
}
#[test]
fn strip_str_regular() {
assert_eq!(
strip_verbatim_str(r"\\?\E:\code\lean-ctx"),
Some("E:/code/lean-ctx".to_string())
);
}
#[test]
fn strip_str_unc() {
assert_eq!(
strip_verbatim_str(r"\\?\UNC\myserver\data"),
Some("//myserver/data".to_string())
);
}
#[test]
fn strip_str_forward_slash_variant() {
assert_eq!(
strip_verbatim_str("//?/C:/Users/dev"),
Some("C:/Users/dev".to_string())
);
}
#[test]
fn strip_str_no_prefix() {
assert_eq!(strip_verbatim_str("/home/user"), None);
}
#[test]
fn safe_canonicalize_or_self_nonexistent() {
let p = Path::new("/this/path/should/not/exist/xyzzy");
let result = safe_canonicalize_or_self(p);
assert_eq!(result, p.to_path_buf());
}
#[test]
fn msys_drive_prefix_translation() {
assert_eq!(
translate_msys_drive_prefix("/c/Users/ABC").as_deref(),
Some("C:/Users/ABC")
);
assert_eq!(
translate_msys_drive_prefix("/D/Program Files").as_deref(),
Some("D:/Program Files")
);
assert_eq!(translate_msys_drive_prefix("/usr/local/bin"), None);
assert_eq!(translate_msys_drive_prefix("/c"), None);
assert_eq!(translate_msys_drive_prefix("c/Users"), None);
}
#[cfg(windows)]
#[test]
fn normalize_msys_path_to_native() {
assert_eq!(
normalize_tool_path("/c/Users/ABC/AppData/lean-ctx"),
"C:/Users/ABC/AppData/lean-ctx"
);
assert_eq!(
normalize_tool_path("/D/Program Files/lean-ctx.exe"),
"D:/Program Files/lean-ctx.exe"
);
}
#[cfg(not(windows))]
#[test]
fn normalize_single_letter_unix_path_untouched() {
assert_eq!(
normalize_tool_path_lexical("/c/Users/me/proj"),
"/c/Users/me/proj"
);
assert_eq!(
normalize_tool_path_lexical("/x/projects/app/src"),
"/x/projects/app/src"
);
}
#[test]
fn normalize_native_windows_path_unchanged() {
assert_eq!(
normalize_tool_path("C:/Users/ABC/lean-ctx.exe"),
"C:/Users/ABC/lean-ctx.exe"
);
}
#[test]
fn normalize_backslash_windows_path() {
assert_eq!(
normalize_tool_path(r"C:\Users\ABC\lean-ctx.exe"),
"C:/Users/ABC/lean-ctx.exe"
);
}
#[test]
fn normalize_unix_path_unchanged() {
assert_eq!(
normalize_tool_path("/usr/local/bin/lean-ctx"),
"/usr/local/bin/lean-ctx"
);
}
#[test]
fn normalize_windows_path_with_spaces_and_backslashes() {
assert_eq!(
normalize_tool_path(r"C:\Users\My Name\My Project\src\main.rs"),
"C:/Users/My Name/My Project/src/main.rs"
);
assert_eq!(
normalize_tool_path(r"C:\Program Files\app\config.toml"),
"C:/Program Files/app/config.toml"
);
}
#[test]
fn normalize_double_slashes() {
assert_eq!(
normalize_tool_path("C:/Users//ABC//lean-ctx"),
"C:/Users/ABC/lean-ctx"
);
}
#[test]
fn normalize_trailing_slash_removed() {
assert_eq!(normalize_tool_path("C:/Users/ABC/"), "C:/Users/ABC");
assert_eq!(
normalize_tool_path_lexical("/tmp/nonexistent-dir-xyzzy/"),
"/tmp/nonexistent-dir-xyzzy"
);
}
#[test]
fn normalize_root_slash_preserved() {
assert_eq!(normalize_tool_path("/"), "/");
}
#[test]
fn normalize_drive_root_preserved() {
assert_eq!(normalize_tool_path("C:/"), "C:/");
}
#[test]
fn normalize_verbatim_with_msys() {
assert_eq!(normalize_tool_path(r"\\?\C:\Users\dev"), "C:/Users/dev");
}
#[test]
fn broad_root_rejects_home() {
if let Some(home) = dirs::home_dir() {
assert!(is_broad_or_unsafe_root(&home));
}
}
#[test]
fn broad_root_rejects_filesystem_root() {
assert!(is_broad_or_unsafe_root(Path::new("/")));
}
#[test]
fn broad_root_rejects_dot() {
assert!(is_broad_or_unsafe_root(Path::new(".")));
}
#[test]
fn broad_root_rejects_agent_dirs() {
assert!(is_broad_or_unsafe_root(Path::new("/home/user/.claude")));
assert!(is_broad_or_unsafe_root(Path::new("/home/user/.codex")));
}
#[test]
fn broad_root_allows_project_subdir() {
let tmp = tempfile::tempdir().unwrap();
let subdir = tmp.path().join("my-project");
std::fs::create_dir_all(&subdir).unwrap();
assert!(!is_broad_or_unsafe_root(&subdir));
}
#[test]
fn broad_root_allows_home_subdirs() {
if let Some(home) = dirs::home_dir() {
let subdir = home.join("projects").join("my-app");
assert!(!is_broad_or_unsafe_root(&subdir));
}
}
#[test]
fn data_dir_collision_rejects_home() {
if let Some(home) = dirs::home_dir() {
assert!(is_data_dir_collision(&home));
}
}
#[test]
fn data_dir_collision_allows_normal_project() {
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path().join("my-project");
std::fs::create_dir_all(&project).unwrap();
assert!(!is_data_dir_collision(&project));
}
#[test]
fn has_project_marker_detects_git() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("repo");
std::fs::create_dir_all(&root).unwrap();
assert!(!has_project_marker(&root));
std::fs::create_dir(root.join(".git")).unwrap();
assert!(has_project_marker(&root));
}
#[test]
fn has_project_marker_detects_cargo_toml() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("rust-project");
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("Cargo.toml"), "[package]").unwrap();
assert!(has_project_marker(&root));
}
#[test]
fn has_project_marker_detects_godot_project() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("godot-game");
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("project.godot"), "config_version=5\n").unwrap();
assert!(has_project_marker(&root));
}
#[test]
fn multi_repo_children_needs_two() {
let tmp = tempfile::tempdir().unwrap();
let parent = tmp.path().join("code");
std::fs::create_dir_all(&parent).unwrap();
assert!(!has_multi_repo_children(&parent));
let repo1 = parent.join("repo1");
std::fs::create_dir_all(repo1.join(".git")).unwrap();
assert!(!has_multi_repo_children(&parent));
let repo2 = parent.join("repo2");
std::fs::create_dir_all(repo2.join(".git")).unwrap();
assert!(has_multi_repo_children(&parent));
}
#[test]
fn multi_repo_children_ignores_files() {
let tmp = tempfile::tempdir().unwrap();
let parent = tmp.path().join("mixed");
std::fs::create_dir_all(&parent).unwrap();
let repo1 = parent.join("repo1");
std::fs::create_dir_all(repo1.join(".git")).unwrap();
std::fs::write(parent.join("not-a-repo"), "file").unwrap();
assert!(!has_multi_repo_children(&parent));
let repo2 = parent.join("repo2");
std::fs::create_dir_all(&repo2).unwrap();
std::fs::write(repo2.join("package.json"), "{}").unwrap();
assert!(has_multi_repo_children(&parent));
}
#[test]
fn multi_repo_children_nonexistent_dir() {
assert!(!has_multi_repo_children(Path::new("/nonexistent/path/xyz")));
}
#[test]
fn regular_file_is_not_symlink_or_reparse() {
let tmp = tempfile::tempdir().unwrap();
let file = tmp.path().join("plain.txt");
std::fs::write(&file, "x").unwrap();
let meta = std::fs::symlink_metadata(&file).unwrap();
assert!(!is_symlink_or_reparse(&meta));
}
#[cfg(unix)]
#[test]
fn unix_symlink_is_detected() {
let tmp = tempfile::tempdir().unwrap();
let target = tmp.path().join("target.txt");
std::fs::write(&target, "x").unwrap();
let link = tmp.path().join("link.txt");
std::os::unix::fs::symlink(&target, &link).unwrap();
let meta = std::fs::symlink_metadata(&link).unwrap();
assert!(is_symlink_or_reparse(&meta));
}
#[cfg(windows)]
#[test]
fn windows_symlink_is_detected() {
let tmp = tempfile::tempdir().unwrap();
let target = tmp.path().join("target.txt");
std::fs::write(&target, "x").unwrap();
let link = tmp.path().join("link.txt");
if std::os::windows::fs::symlink_file(&target, &link).is_err() {
eprintln!("skipping: symlink creation not permitted on this runner");
return;
}
let meta = std::fs::symlink_metadata(&link).unwrap();
assert!(is_symlink_or_reparse(&meta));
}
}