1use std::path::{Path, PathBuf};
10
11pub use crate::core::pathutil::has_project_marker;
16
17fn nearest_git_boundary(start: &Path) -> Option<PathBuf> {
26 let start = crate::core::pathutil::safe_canonicalize_or_self(start);
27 let mut cur: Option<&Path> = Some(start.as_path());
28 while let Some(dir) = cur {
29 if dir.join(".git").exists() {
30 return Some(dir.to_path_buf());
31 }
32 cur = dir.parent();
33 }
34 None
35}
36
37pub fn shell_cwd_is_divergent_checkout(project_root: &str, shell_cwd: &str) -> bool {
46 if shell_cwd == project_root {
47 return false;
48 }
49 match (
50 nearest_git_boundary(Path::new(shell_cwd)),
51 nearest_git_boundary(Path::new(project_root)),
52 ) {
53 (Some(cwd_git), Some(root_git)) => cwd_git != root_git,
54 _ => false,
55 }
56}
57
58pub fn resolve_tool_path(
79 project_root: Option<&str>,
80 shell_cwd: Option<&str>,
81 raw: &str,
82) -> Result<String, String> {
83 resolve_tool_path_with_roots(project_root, shell_cwd, raw, &[])
84}
85
86pub fn resolve_tool_path_with_roots(
93 project_root: Option<&str>,
94 shell_cwd: Option<&str>,
95 raw: &str,
96 extra_roots: &[String],
97) -> Result<String, String> {
98 resolve_tool_path_refusing(project_root, shell_cwd, raw, extra_roots)
99 .map_err(|refusal| refusal.message)
100}
101
102#[derive(Debug)]
106pub struct PathRefusal {
107 pub message: String,
108 pub class: Option<crate::core::security_events::RefusalClass>,
109}
110
111impl From<String> for PathRefusal {
112 fn from(message: String) -> Self {
113 Self {
114 message,
115 class: None,
116 }
117 }
118}
119
120impl PathRefusal {
121 pub fn secret_path(message: String) -> Self {
123 Self {
124 message,
125 class: Some(crate::core::security_events::RefusalClass::SecretPath),
126 }
127 }
128
129 pub fn jail(error: &crate::core::error::PathJailError) -> Self {
132 Self {
133 message: error.to_string(),
134 class: crate::core::security_events::RefusalClass::of_jail_error(error),
135 }
136 }
137}
138
139pub fn resolve_tool_path_refusing(
141 project_root: Option<&str>,
142 shell_cwd: Option<&str>,
143 raw: &str,
144 extra_roots: &[String],
145) -> Result<String, PathRefusal> {
146 let normalized = crate::core::pathutil::normalize_tool_path(raw);
147 if normalized.is_empty() || normalized == "." {
148 return Ok(crate::core::workspace_authority::project_dot(
149 &normalized,
150 project_root,
151 )?);
152 }
153
154 let p = Path::new(&normalized);
155 let jail_root = project_root.or(shell_cwd).unwrap_or(".").to_string();
156
157 let resolved: PathBuf = if p.is_absolute() {
158 PathBuf::from(&normalized)
159 } else if let Some(root) = project_root {
160 if let Some(cwd) = shell_cwd
165 && shell_cwd_is_divergent_checkout(root, cwd)
166 {
167 Path::new(cwd).join(&normalized)
168 } else {
169 let joined = Path::new(root).join(&normalized);
170 if joined.exists() {
171 joined
172 } else if let Some(cwd) = shell_cwd {
173 Path::new(cwd).join(&normalized)
174 } else {
175 joined
176 }
177 }
178 } else if let Some(cwd) = shell_cwd {
179 Path::new(cwd).join(&normalized)
180 } else {
181 Path::new(&jail_root).join(&normalized)
182 };
183
184 if project_root.is_none()
185 && let Some(error) = unbound_relative_path(raw, &resolved, shell_cwd.unwrap_or(&jail_root))
186 {
187 return Err(error.into());
188 }
189
190 let jail_root_path = Path::new(&jail_root);
191 let jailed =
192 crate::core::pathjail::jail_path_auto_admitting(&resolved, jail_root_path, extra_roots)
193 .map_err(|e| PathRefusal::jail(&e))?;
194 crate::core::io_boundary::check_secret_path_for_tool("resolve_path", &jailed)
195 .map_err(PathRefusal::secret_path)?;
196
197 Ok(crate::core::pathutil::normalize_tool_path(
198 &jailed.to_string_lossy().replace('\\', "/"),
199 ))
200}
201
202pub fn unbound_relative_path(raw: &str, resolved: &Path, base: &str) -> Option<String> {
208 let normalized = crate::core::pathutil::normalize_tool_path(raw);
209 if Path::new(&normalized).is_absolute() || resolved.exists() {
210 return None;
211 }
212 let base = std::path::absolute(base).unwrap_or_else(|_| PathBuf::from(base));
213 if !crate::core::pathutil::is_broad_or_unsafe_root(&base) {
214 return None;
215 }
216 Some(format!(
217 "relative path '{raw}' has no project to resolve against: lean-ctx has no project for \
218 this session yet (the host started it in {}, outside a project). Pass the absolute \
219 path instead, e.g. /home/me/code/my-app/{normalized}; that project then stays the \
220 session project.",
221 base.display()
222 ))
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228 use std::fs;
229
230 #[test]
231 fn empty_and_dot_pass_through() {
232 assert_eq!(resolve_tool_path(None, None, "").unwrap(), "");
233 assert_eq!(resolve_tool_path(None, None, ".").unwrap(), ".");
234 }
235
236 #[test]
237 fn missing_relative_path_without_a_project_says_what_is_missing() {
238 let _home = crate::core::data_dir::test_env_lock();
239 let home = dirs::home_dir()
243 .expect("home dir")
244 .to_string_lossy()
245 .to_string();
246 let error =
247 resolve_tool_path(None, Some(&home), "src/no_such_file_lc_7f3a.rs").unwrap_err();
248 assert!(
249 error.contains("has no project to resolve against"),
250 "{error}"
251 );
252 assert!(error.contains("absolute path"), "{error}");
253
254 let dir = tempfile::tempdir().unwrap();
256 let cwd = dir.path().to_string_lossy().to_string();
257 let outcome = resolve_tool_path(None, Some(&cwd), "src/no_such_file_lc.rs");
258 assert!(
259 outcome
260 .as_ref()
261 .err()
262 .is_none_or(|e| !e.contains("no project to resolve against")),
263 "{outcome:?}"
264 );
265 }
266
267 #[test]
268 fn relative_resolves_against_project_root() {
269 let _environment = crate::core::data_dir::test_env_lock();
270 let tmp = std::env::temp_dir().join(format!("lc_pr_{}", std::process::id()));
271 let _ = fs::create_dir_all(&tmp);
272 let file = tmp.join("a.txt");
273 fs::write(&file, "x").unwrap();
274 let root = tmp.to_string_lossy().to_string();
275
276 let out = resolve_tool_path(Some(&root), None, "a.txt").unwrap();
277 assert!(out.ends_with("a.txt"), "got {out}");
278 assert!(out.contains(&root) || Path::new(&out).is_absolute());
279
280 let _ = fs::remove_dir_all(&tmp);
281 }
282
283 #[test]
284 fn falls_back_to_shell_cwd_when_not_in_project_root() {
285 let _environment = crate::core::data_dir::test_env_lock();
286 let base = std::env::temp_dir().join(format!("lc_pr_cwd_{}", std::process::id()));
287 let root = base.join("root");
288 let cwd = base.join("cwd");
289 fs::create_dir_all(&root).unwrap();
290 fs::create_dir_all(&cwd).unwrap();
291 fs::write(cwd.join("only_in_cwd.txt"), "x").unwrap();
292
293 let out = resolve_tool_path(
294 Some(&root.to_string_lossy()),
295 Some(&cwd.to_string_lossy()),
296 "only_in_cwd.txt",
297 );
298 assert!(out.is_ok() || out.is_err());
302
303 let _ = fs::remove_dir_all(&base);
304 }
305
306 #[test]
311 fn relative_path_never_resolves_against_process_cwd() {
312 let _environment = crate::core::data_dir::test_env_lock();
313 let cwd = std::env::current_dir().unwrap();
314 assert!(
315 cwd.join("Cargo.toml").exists(),
316 "test premise: CWD contains Cargo.toml"
317 );
318
319 let tmp = std::env::temp_dir().join(format!("lc_pr_nocwd_{}", std::process::id()));
320 fs::create_dir_all(&tmp).unwrap();
321 let root = tmp.to_string_lossy().to_string();
322
323 let out = resolve_tool_path(Some(&root), None, "Cargo.toml").unwrap();
324 let canonical_root = crate::core::pathjail::canonicalize_or_self(&tmp);
330 let out_parent = crate::core::pathjail::canonicalize_or_self(
331 Path::new(&out)
332 .parent()
333 .expect("resolved path has a parent"),
334 );
335 assert_eq!(
336 out_parent, canonical_root,
337 "resolved {out} must live under the project root, not the process CWD"
338 );
339 let canonical_cwd = crate::core::pathjail::canonicalize_or_self(&cwd);
340 assert_ne!(
341 out_parent, canonical_cwd,
342 "resolved {out} must not resolve against the process CWD"
343 );
344
345 let _ = fs::remove_dir_all(&tmp);
346 }
347
348 #[cfg(not(feature = "no-jail"))]
353 #[test]
354 fn extra_roots_thread_through_resolve_tool_path() {
355 let _environment = crate::core::data_dir::test_env_lock();
356 let base = std::env::temp_dir().join(format!("lc_pr_extra_{}", std::process::id()));
357 let root = base.join("root");
358 let worktree = base.join("worktree");
359 fs::create_dir_all(&root).unwrap();
360 fs::create_dir_all(&worktree).unwrap();
361 let file = worktree.join("a.txt");
362 fs::write(&file, "x").unwrap();
363
364 let root_s = root.to_string_lossy().to_string();
365 let file_abs = file.to_string_lossy().to_string();
366 let extra = vec![worktree.to_string_lossy().to_string()];
367
368 let out = resolve_tool_path_with_roots(Some(&root_s), None, &file_abs, &extra);
369 assert!(
370 out.is_ok(),
371 "extra_roots must thread through the resolver: {out:?}"
372 );
373
374 let _ = fs::remove_dir_all(&base);
375 }
376
377 #[test]
382 fn worktree_shell_cwd_outranks_stale_project_root_copy() {
383 let _environment = crate::core::data_dir::test_env_lock();
386 let base = std::env::temp_dir().join(format!("lc_707_nested_{}", std::process::id()));
387 let repo = base.join("repo");
388 let wt = repo.join(".claude").join("worktrees").join("fix-x");
389 fs::create_dir_all(repo.join("src")).unwrap();
390 fs::create_dir_all(repo.join(".git")).unwrap(); fs::create_dir_all(wt.join("src")).unwrap();
392 fs::write(wt.join(".git"), "gitdir: ../../.git/worktrees/fix-x\n").unwrap(); fs::write(repo.join("src/scoring.rs"), "stale").unwrap();
394 fs::write(wt.join("src/scoring.rs"), "fresh").unwrap();
395
396 let out = resolve_tool_path(
397 Some(&repo.to_string_lossy()),
398 Some(&wt.to_string_lossy()),
399 "src/scoring.rs",
400 )
401 .unwrap();
402 assert_eq!(
403 fs::read_to_string(&out).unwrap(),
404 "fresh",
405 "must resolve into the worktree, not the stale root: {out}"
406 );
407
408 let new = resolve_tool_path(
411 Some(&repo.to_string_lossy()),
412 Some(&wt.to_string_lossy()),
413 "src/new_file.rs",
414 )
415 .unwrap();
416 assert!(
417 new.contains("worktrees"),
418 "write target must land in the worktree: {new}"
419 );
420
421 let _ = fs::remove_dir_all(&base);
422 }
423
424 #[test]
428 fn monorepo_subdir_shell_cwd_is_not_a_divergent_checkout() {
429 let _environment = crate::core::data_dir::test_env_lock();
430 let base = std::env::temp_dir().join(format!("lc_707_mono_{}", std::process::id()));
431 let repo = base.join("repo");
432 fs::create_dir_all(repo.join("rust").join("src")).unwrap();
433 fs::create_dir_all(repo.join(".git")).unwrap();
434 fs::write(repo.join("rust/Cargo.toml"), "[package]").unwrap();
435 fs::write(repo.join("rust/src/main.rs"), "root copy").unwrap();
436
437 assert!(!shell_cwd_is_divergent_checkout(
438 &repo.to_string_lossy(),
439 &repo.join("rust").to_string_lossy(),
440 ));
441
442 let out = resolve_tool_path(
443 Some(&repo.to_string_lossy()),
444 Some(&repo.join("rust").to_string_lossy()),
445 "rust/src/main.rs",
446 )
447 .unwrap();
448 assert_eq!(
449 fs::read_to_string(&out).unwrap(),
450 "root copy",
451 "same-checkout cwd must not divert resolution: {out}"
452 );
453
454 let _ = fs::remove_dir_all(&base);
455 }
456
457 #[test]
460 fn gitless_shell_cwd_gives_no_divergence_signal() {
461 let base = std::env::temp_dir().join(format!("lc_707_gitless_{}", std::process::id()));
462 let repo = base.join("repo");
463 let scratch = base.join("scratch");
464 fs::create_dir_all(repo.join(".git")).unwrap();
465 fs::create_dir_all(&scratch).unwrap();
466 fs::write(repo.join("a.txt"), "root").unwrap();
467
468 assert!(!shell_cwd_is_divergent_checkout(
469 &repo.to_string_lossy(),
470 &scratch.to_string_lossy(),
471 ));
472
473 let _ = fs::remove_dir_all(&base);
474 }
475
476 #[test]
477 fn tool_context_shape_project_root_only() {
478 let _environment = crate::core::data_dir::test_env_lock();
479 let tmp = std::env::temp_dir().join(format!("lc_pr_ctx_{}", std::process::id()));
481 fs::create_dir_all(&tmp).unwrap();
482 let root = tmp.to_string_lossy().to_string();
483 let out = resolve_tool_path(Some(&root), None, "missing.rs").unwrap();
484 assert!(out.ends_with("missing.rs"), "got {out}");
485 let _ = fs::remove_dir_all(&tmp);
486 }
487
488 #[cfg(not(windows))]
496 #[test]
497 fn single_letter_root_is_never_drive_translated_on_unix() {
498 let _environment = crate::core::data_dir::test_env_lock();
499 for raw in ["/c/Users/me/proj/src/app.ts", "src/app.ts"] {
500 let rendered = match resolve_tool_path(Some("/c/Users/me/proj"), None, raw) {
501 Ok(p) => p,
502 Err(e) => e,
503 };
504 assert!(
505 !rendered.contains("C:/"),
506 "drive translation must not run on unix hosts (raw={raw}): {rendered}"
507 );
508 }
509 }
510
511 #[test]
514 fn a_refusal_names_the_boundary_that_refused() {
515 use crate::core::security_events::RefusalClass;
516 let _environment = crate::core::data_dir::test_env_lock();
517 let tmp = tempfile::tempdir().unwrap();
518 let root = tmp.path().canonicalize().unwrap();
519 fs::write(root.join(".env"), "TOKEN=x").unwrap();
520 let root = root.to_string_lossy().into_owned();
521 let prev = std::env::var("LEAN_CTX_IO_BOUNDARY_MODE").ok();
522 crate::test_env::set_var("LEAN_CTX_IO_BOUNDARY_MODE", "enforce");
523 let secret = resolve_tool_path_refusing(Some(&root), None, ".env", &[]);
524 let outside =
525 resolve_tool_path_refusing(Some(&root), None, &format!("{root}/../outside.txt"), &[]);
526 match prev {
527 Some(v) => crate::test_env::set_var("LEAN_CTX_IO_BOUNDARY_MODE", v),
528 None => crate::test_env::remove_var("LEAN_CTX_IO_BOUNDARY_MODE"),
529 }
530 let secret = secret.expect_err("enforce mode refuses a secret path");
531 assert_eq!(secret.class, Some(RefusalClass::SecretPath));
532 assert!(
533 secret.message.contains("Secret-like path"),
534 "{}",
535 secret.message
536 );
537 if !cfg!(feature = "no-jail") {
538 let outside = outside.expect_err("the jail refuses a path outside the root");
539 assert_eq!(outside.class, Some(RefusalClass::OutsideRoot));
540 }
541 let unbound = resolve_tool_path_refusing(None, None, "src/no_such_file_lc_9c1.rs", &[]);
542 if let Err(refusal) = unbound {
543 assert_eq!(refusal.class, None, "{}", refusal.message);
544 }
545 }
546}