Skip to main content

lean_ctx/core/
pathutil.rs

1use std::path::{Path, PathBuf};
2
3/// Canonicalize a path and strip the Windows verbatim/extended-length prefix (`\\?\`)
4/// that `std::fs::canonicalize` adds on Windows. This prefix breaks many tools and
5/// string-based path comparisons.
6///
7/// On non-Windows platforms this is equivalent to `std::fs::canonicalize`.
8pub fn safe_canonicalize(path: &Path) -> std::io::Result<PathBuf> {
9    let canon = std::fs::canonicalize(path)?;
10    Ok(strip_verbatim(canon))
11}
12
13/// Like `safe_canonicalize` but returns the original path on failure.
14pub fn safe_canonicalize_or_self(path: &Path) -> PathBuf {
15    safe_canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
16}
17
18/// Canonicalize with a timeout guard. Protects against hangs on WSL2 DrvFS,
19/// Windows reparse points, NFS, FUSE, sshfs, and other slow filesystems.
20/// Falls back to the original path if canonicalize doesn't complete within the timeout.
21/// Self-healing: after a timeout, subsequent calls to slow mounts skip the thread entirely.
22pub fn safe_canonicalize_bounded(path: &Path, timeout_ms: u64) -> PathBuf {
23    use super::io_health;
24
25    let path_str = path.to_string_lossy();
26    if io_health::is_slow_mount(&path_str) && io_health::recent_freeze_count() > 0 {
27        return safe_canonicalize_or_self(path);
28    }
29
30    let effective_timeout =
31        io_health::adaptive_timeout(std::time::Duration::from_millis(timeout_ms));
32
33    let path_owned = path.to_path_buf();
34    let (tx, rx) = std::sync::mpsc::channel();
35    let _ = std::thread::Builder::new()
36        .name("canonicalize-bounded".into())
37        .spawn(move || {
38            let result = safe_canonicalize(&path_owned).unwrap_or(path_owned);
39            let _ = tx.send(result);
40        });
41    if let Ok(canonical) = rx.recv_timeout(effective_timeout) {
42        canonical
43    } else {
44        io_health::record_freeze();
45        tracing::warn!(
46            "[SECURITY] canonicalize timed out ({}ms) for {}; PathJail checks on \
47             uncanonicalized paths may be less reliable",
48            effective_timeout.as_millis(),
49            path.display()
50        );
51        path.to_path_buf()
52    }
53}
54
55/// Remove the `\\?\` / `//?/` verbatim prefix from a `PathBuf`.
56/// Handles both regular verbatim (`\\?\C:\...`) and UNC verbatim (`\\?\UNC\...`).
57pub fn strip_verbatim(path: PathBuf) -> PathBuf {
58    let s = path.to_string_lossy();
59    if let Some(stripped) = strip_verbatim_str(&s) {
60        PathBuf::from(stripped)
61    } else {
62        path
63    }
64}
65
66/// Remove the `\\?\` / `//?/` verbatim prefix from a path string.
67/// Returns `Some(cleaned)` if a prefix was found, `None` otherwise.
68pub fn strip_verbatim_str(path: &str) -> Option<String> {
69    let normalized = path.replace('\\', "/");
70
71    if let Some(rest) = normalized.strip_prefix("//?/UNC/") {
72        Some(format!("//{rest}"))
73    } else {
74        normalized
75            .strip_prefix("//?/")
76            .map(std::string::ToString::to_string)
77    }
78}
79
80/// Normalize paths from any client format to a consistent OS-native form.
81/// Handles MSYS2/Git Bash (`/c/Users/...` -> `C:/Users/...`), mixed separators,
82/// double slashes, and trailing slashes. Uses forward slashes for consistency.
83pub fn normalize_tool_path(path: &str) -> String {
84    let mut p = match strip_verbatim_str(path) {
85        Some(stripped) => stripped,
86        None => path.to_string(),
87    };
88
89    // MSYS2/Git Bash: /c/Users/... -> C:/Users/...
90    if p.len() >= 3
91        && p.starts_with('/')
92        && p.as_bytes()[1].is_ascii_alphabetic()
93        && p.as_bytes()[2] == b'/'
94    {
95        let drive = p.as_bytes()[1].to_ascii_uppercase() as char;
96        p = format!("{drive}:{}", &p[2..]);
97    }
98
99    p = p.replace('\\', "/");
100
101    // Collapse double slashes (preserve UNC paths starting with //)
102    while p.contains("//") && !p.starts_with("//") {
103        p = p.replace("//", "/");
104    }
105
106    // Remove trailing slash (unless root like "/" or "C:/")
107    if p.len() > 1 && p.ends_with('/') && !p.ends_with(":/") {
108        p.pop();
109    }
110
111    p
112}
113
114/// Returns `true` if the directory is too broad to be a valid project root.
115/// Rejects home directory, filesystem root, `.` (bare CWD), and agent sandbox
116/// directories (`.claude`, `.codex`). Used to prevent writing project-scoped
117/// data (overlays, policies) into the global `~/.lean-ctx/` data directory.
118pub fn is_broad_or_unsafe_root(dir: &Path) -> bool {
119    if let Some(home) = dirs::home_dir() {
120        if dir == home {
121            return true;
122        }
123    }
124    let s = dir.to_string_lossy();
125    if s == "/" || s == "\\" || s == "." {
126        return true;
127    }
128    s.ends_with("/.claude")
129        || s.ends_with("/.codex")
130        || s.contains("/.claude/")
131        || s.contains("/.codex/")
132}
133
134/// Returns `true` if `project_root` collides with the lean-ctx data directory.
135/// This prevents project-scoped files (overlays.json, policies.json) from being
136/// written into `~/.lean-ctx/` or `~/.config/lean-ctx/`.
137pub fn is_data_dir_collision(project_root: &Path) -> bool {
138    if is_broad_or_unsafe_root(project_root) {
139        return true;
140    }
141    if let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() {
142        let project_lean_ctx = project_root.join(".lean-ctx");
143        if project_lean_ctx == data_dir || data_dir.starts_with(&project_lean_ctx) {
144            return true;
145        }
146    }
147    false
148}
149
150/// Returns the project-scoped `.lean-ctx/` directory if the project root is safe.
151/// Returns `Err` if the project root collides with the global data directory.
152pub fn safe_project_data_dir(project_root: &Path) -> Result<PathBuf, String> {
153    if is_data_dir_collision(project_root) {
154        return Err(format!(
155            "project root {} collides with global data directory; \
156             skipping project-scoped write",
157            project_root.display()
158        ));
159    }
160    Ok(project_root.join(".lean-ctx"))
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166
167    #[test]
168    fn strip_regular_verbatim() {
169        let p = PathBuf::from(r"\\?\C:\Users\dev\project");
170        let result = strip_verbatim(p);
171        assert_eq!(result, PathBuf::from("C:/Users/dev/project"));
172    }
173
174    #[test]
175    fn strip_unc_verbatim() {
176        let p = PathBuf::from(r"\\?\UNC\server\share\dir");
177        let result = strip_verbatim(p);
178        assert_eq!(result, PathBuf::from("//server/share/dir"));
179    }
180
181    #[test]
182    fn no_prefix_unchanged() {
183        let p = PathBuf::from("/home/user/project");
184        let result = strip_verbatim(p.clone());
185        assert_eq!(result, p);
186    }
187
188    #[test]
189    fn windows_drive_unchanged() {
190        let p = PathBuf::from("C:/Users/dev");
191        let result = strip_verbatim(p.clone());
192        assert_eq!(result, p);
193    }
194
195    #[test]
196    fn strip_str_regular() {
197        assert_eq!(
198            strip_verbatim_str(r"\\?\E:\code\lean-ctx"),
199            Some("E:/code/lean-ctx".to_string())
200        );
201    }
202
203    #[test]
204    fn strip_str_unc() {
205        assert_eq!(
206            strip_verbatim_str(r"\\?\UNC\myserver\data"),
207            Some("//myserver/data".to_string())
208        );
209    }
210
211    #[test]
212    fn strip_str_forward_slash_variant() {
213        assert_eq!(
214            strip_verbatim_str("//?/C:/Users/dev"),
215            Some("C:/Users/dev".to_string())
216        );
217    }
218
219    #[test]
220    fn strip_str_no_prefix() {
221        assert_eq!(strip_verbatim_str("/home/user"), None);
222    }
223
224    #[test]
225    fn safe_canonicalize_or_self_nonexistent() {
226        let p = Path::new("/this/path/should/not/exist/xyzzy");
227        let result = safe_canonicalize_or_self(p);
228        assert_eq!(result, p.to_path_buf());
229    }
230
231    #[test]
232    fn normalize_msys_path_to_native() {
233        assert_eq!(
234            normalize_tool_path("/c/Users/ABC/AppData/lean-ctx"),
235            "C:/Users/ABC/AppData/lean-ctx"
236        );
237    }
238
239    #[test]
240    fn normalize_msys_uppercase_drive() {
241        assert_eq!(
242            normalize_tool_path("/D/Program Files/lean-ctx.exe"),
243            "D:/Program Files/lean-ctx.exe"
244        );
245    }
246
247    #[test]
248    fn normalize_native_windows_path_unchanged() {
249        assert_eq!(
250            normalize_tool_path("C:/Users/ABC/lean-ctx.exe"),
251            "C:/Users/ABC/lean-ctx.exe"
252        );
253    }
254
255    #[test]
256    fn normalize_backslash_windows_path() {
257        assert_eq!(
258            normalize_tool_path(r"C:\Users\ABC\lean-ctx.exe"),
259            "C:/Users/ABC/lean-ctx.exe"
260        );
261    }
262
263    #[test]
264    fn normalize_unix_path_unchanged() {
265        assert_eq!(
266            normalize_tool_path("/usr/local/bin/lean-ctx"),
267            "/usr/local/bin/lean-ctx"
268        );
269    }
270
271    #[test]
272    fn normalize_double_slashes() {
273        assert_eq!(
274            normalize_tool_path("C:/Users//ABC//lean-ctx"),
275            "C:/Users/ABC/lean-ctx"
276        );
277    }
278
279    #[test]
280    fn normalize_trailing_slash_removed() {
281        assert_eq!(normalize_tool_path("/c/Users/ABC/"), "C:/Users/ABC");
282    }
283
284    #[test]
285    fn normalize_root_slash_preserved() {
286        assert_eq!(normalize_tool_path("/"), "/");
287    }
288
289    #[test]
290    fn normalize_drive_root_preserved() {
291        assert_eq!(normalize_tool_path("C:/"), "C:/");
292    }
293
294    #[test]
295    fn normalize_verbatim_with_msys() {
296        assert_eq!(normalize_tool_path(r"\\?\C:\Users\dev"), "C:/Users/dev");
297    }
298
299    #[test]
300    fn broad_root_rejects_home() {
301        if let Some(home) = dirs::home_dir() {
302            assert!(is_broad_or_unsafe_root(&home));
303        }
304    }
305
306    #[test]
307    fn broad_root_rejects_filesystem_root() {
308        assert!(is_broad_or_unsafe_root(Path::new("/")));
309    }
310
311    #[test]
312    fn broad_root_rejects_dot() {
313        assert!(is_broad_or_unsafe_root(Path::new(".")));
314    }
315
316    #[test]
317    fn broad_root_rejects_agent_dirs() {
318        assert!(is_broad_or_unsafe_root(Path::new("/home/user/.claude")));
319        assert!(is_broad_or_unsafe_root(Path::new("/home/user/.codex")));
320    }
321
322    #[test]
323    fn broad_root_allows_project_subdir() {
324        let tmp = tempfile::tempdir().unwrap();
325        let subdir = tmp.path().join("my-project");
326        std::fs::create_dir_all(&subdir).unwrap();
327        assert!(!is_broad_or_unsafe_root(&subdir));
328    }
329
330    #[test]
331    fn broad_root_allows_home_subdirs() {
332        if let Some(home) = dirs::home_dir() {
333            let subdir = home.join("projects").join("my-app");
334            assert!(!is_broad_or_unsafe_root(&subdir));
335        }
336    }
337
338    #[test]
339    fn data_dir_collision_rejects_home() {
340        if let Some(home) = dirs::home_dir() {
341            assert!(is_data_dir_collision(&home));
342        }
343    }
344
345    #[test]
346    fn data_dir_collision_allows_normal_project() {
347        let tmp = tempfile::tempdir().unwrap();
348        let project = tmp.path().join("my-project");
349        std::fs::create_dir_all(&project).unwrap();
350        assert!(!is_data_dir_collision(&project));
351    }
352}