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 = dirs::home_dir()
242 .expect("home dir")
243 .to_string_lossy()
244 .to_string();
245 let error =
246 resolve_tool_path(None, Some(&home), "src/no_such_file_lc_7f3a.rs").unwrap_err();
247 assert!(
248 error.contains("has no project to resolve against"),
249 "{error}"
250 );
251 assert!(error.contains("absolute path"), "{error}");
252
253 let dir = tempfile::tempdir().unwrap();
255 let cwd = dir.path().to_string_lossy().to_string();
256 let outcome = resolve_tool_path(None, Some(&cwd), "src/no_such_file_lc.rs");
257 assert!(
258 outcome
259 .as_ref()
260 .err()
261 .is_none_or(|e| !e.contains("no project to resolve against")),
262 "{outcome:?}"
263 );
264 }
265
266 #[test]
267 fn relative_resolves_against_project_root() {
268 let _environment = crate::core::data_dir::test_env_lock();
269 let tmp = std::env::temp_dir().join(format!("lc_pr_{}", std::process::id()));
270 let _ = fs::create_dir_all(&tmp);
271 let file = tmp.join("a.txt");
272 fs::write(&file, "x").unwrap();
273 let root = tmp.to_string_lossy().to_string();
274
275 let out = resolve_tool_path(Some(&root), None, "a.txt").unwrap();
276 assert!(out.ends_with("a.txt"), "got {out}");
277 assert!(out.contains(&root) || Path::new(&out).is_absolute());
278
279 let _ = fs::remove_dir_all(&tmp);
280 }
281
282 #[test]
283 fn falls_back_to_shell_cwd_when_not_in_project_root() {
284 let _environment = crate::core::data_dir::test_env_lock();
285 let base = std::env::temp_dir().join(format!("lc_pr_cwd_{}", std::process::id()));
286 let root = base.join("root");
287 let cwd = base.join("cwd");
288 fs::create_dir_all(&root).unwrap();
289 fs::create_dir_all(&cwd).unwrap();
290 fs::write(cwd.join("only_in_cwd.txt"), "x").unwrap();
291
292 let out = resolve_tool_path(
293 Some(&root.to_string_lossy()),
294 Some(&cwd.to_string_lossy()),
295 "only_in_cwd.txt",
296 );
297 assert!(out.is_ok() || out.is_err());
301
302 let _ = fs::remove_dir_all(&base);
303 }
304
305 #[test]
310 fn relative_path_never_resolves_against_process_cwd() {
311 let _environment = crate::core::data_dir::test_env_lock();
312 let cwd = std::env::current_dir().unwrap();
313 assert!(
314 cwd.join("Cargo.toml").exists(),
315 "test premise: CWD contains Cargo.toml"
316 );
317
318 let tmp = std::env::temp_dir().join(format!("lc_pr_nocwd_{}", std::process::id()));
319 fs::create_dir_all(&tmp).unwrap();
320 let root = tmp.to_string_lossy().to_string();
321
322 let out = resolve_tool_path(Some(&root), None, "Cargo.toml").unwrap();
323 let canonical_root = crate::core::pathjail::canonicalize_or_self(&tmp);
329 let out_parent = crate::core::pathjail::canonicalize_or_self(
330 Path::new(&out)
331 .parent()
332 .expect("resolved path has a parent"),
333 );
334 assert_eq!(
335 out_parent, canonical_root,
336 "resolved {out} must live under the project root, not the process CWD"
337 );
338 let canonical_cwd = crate::core::pathjail::canonicalize_or_self(&cwd);
339 assert_ne!(
340 out_parent, canonical_cwd,
341 "resolved {out} must not resolve against the process CWD"
342 );
343
344 let _ = fs::remove_dir_all(&tmp);
345 }
346
347 #[cfg(not(feature = "no-jail"))]
352 #[test]
353 fn extra_roots_thread_through_resolve_tool_path() {
354 let _environment = crate::core::data_dir::test_env_lock();
355 let base = std::env::temp_dir().join(format!("lc_pr_extra_{}", std::process::id()));
356 let root = base.join("root");
357 let worktree = base.join("worktree");
358 fs::create_dir_all(&root).unwrap();
359 fs::create_dir_all(&worktree).unwrap();
360 let file = worktree.join("a.txt");
361 fs::write(&file, "x").unwrap();
362
363 let root_s = root.to_string_lossy().to_string();
364 let file_abs = file.to_string_lossy().to_string();
365 let extra = vec![worktree.to_string_lossy().to_string()];
366
367 let out = resolve_tool_path_with_roots(Some(&root_s), None, &file_abs, &extra);
368 assert!(
369 out.is_ok(),
370 "extra_roots must thread through the resolver: {out:?}"
371 );
372
373 let _ = fs::remove_dir_all(&base);
374 }
375
376 #[test]
381 fn worktree_shell_cwd_outranks_stale_project_root_copy() {
382 let _environment = crate::core::data_dir::test_env_lock();
385 let base = std::env::temp_dir().join(format!("lc_707_nested_{}", std::process::id()));
386 let repo = base.join("repo");
387 let wt = repo.join(".claude").join("worktrees").join("fix-x");
388 fs::create_dir_all(repo.join("src")).unwrap();
389 fs::create_dir_all(repo.join(".git")).unwrap(); fs::create_dir_all(wt.join("src")).unwrap();
391 fs::write(wt.join(".git"), "gitdir: ../../.git/worktrees/fix-x\n").unwrap(); fs::write(repo.join("src/scoring.rs"), "stale").unwrap();
393 fs::write(wt.join("src/scoring.rs"), "fresh").unwrap();
394
395 let out = resolve_tool_path(
396 Some(&repo.to_string_lossy()),
397 Some(&wt.to_string_lossy()),
398 "src/scoring.rs",
399 )
400 .unwrap();
401 assert_eq!(
402 fs::read_to_string(&out).unwrap(),
403 "fresh",
404 "must resolve into the worktree, not the stale root: {out}"
405 );
406
407 let new = resolve_tool_path(
410 Some(&repo.to_string_lossy()),
411 Some(&wt.to_string_lossy()),
412 "src/new_file.rs",
413 )
414 .unwrap();
415 assert!(
416 new.contains("worktrees"),
417 "write target must land in the worktree: {new}"
418 );
419
420 let _ = fs::remove_dir_all(&base);
421 }
422
423 #[test]
427 fn monorepo_subdir_shell_cwd_is_not_a_divergent_checkout() {
428 let _environment = crate::core::data_dir::test_env_lock();
429 let base = std::env::temp_dir().join(format!("lc_707_mono_{}", std::process::id()));
430 let repo = base.join("repo");
431 fs::create_dir_all(repo.join("rust").join("src")).unwrap();
432 fs::create_dir_all(repo.join(".git")).unwrap();
433 fs::write(repo.join("rust/Cargo.toml"), "[package]").unwrap();
434 fs::write(repo.join("rust/src/main.rs"), "root copy").unwrap();
435
436 assert!(!shell_cwd_is_divergent_checkout(
437 &repo.to_string_lossy(),
438 &repo.join("rust").to_string_lossy(),
439 ));
440
441 let out = resolve_tool_path(
442 Some(&repo.to_string_lossy()),
443 Some(&repo.join("rust").to_string_lossy()),
444 "rust/src/main.rs",
445 )
446 .unwrap();
447 assert_eq!(
448 fs::read_to_string(&out).unwrap(),
449 "root copy",
450 "same-checkout cwd must not divert resolution: {out}"
451 );
452
453 let _ = fs::remove_dir_all(&base);
454 }
455
456 #[test]
459 fn gitless_shell_cwd_gives_no_divergence_signal() {
460 let base = std::env::temp_dir().join(format!("lc_707_gitless_{}", std::process::id()));
461 let repo = base.join("repo");
462 let scratch = base.join("scratch");
463 fs::create_dir_all(repo.join(".git")).unwrap();
464 fs::create_dir_all(&scratch).unwrap();
465 fs::write(repo.join("a.txt"), "root").unwrap();
466
467 assert!(!shell_cwd_is_divergent_checkout(
468 &repo.to_string_lossy(),
469 &scratch.to_string_lossy(),
470 ));
471
472 let _ = fs::remove_dir_all(&base);
473 }
474
475 #[test]
476 fn tool_context_shape_project_root_only() {
477 let _environment = crate::core::data_dir::test_env_lock();
478 let tmp = std::env::temp_dir().join(format!("lc_pr_ctx_{}", std::process::id()));
480 fs::create_dir_all(&tmp).unwrap();
481 let root = tmp.to_string_lossy().to_string();
482 let out = resolve_tool_path(Some(&root), None, "missing.rs").unwrap();
483 assert!(out.ends_with("missing.rs"), "got {out}");
484 let _ = fs::remove_dir_all(&tmp);
485 }
486
487 #[cfg(not(windows))]
495 #[test]
496 fn single_letter_root_is_never_drive_translated_on_unix() {
497 let _environment = crate::core::data_dir::test_env_lock();
498 for raw in ["/c/Users/me/proj/src/app.ts", "src/app.ts"] {
499 let rendered = match resolve_tool_path(Some("/c/Users/me/proj"), None, raw) {
500 Ok(p) => p,
501 Err(e) => e,
502 };
503 assert!(
504 !rendered.contains("C:/"),
505 "drive translation must not run on unix hosts (raw={raw}): {rendered}"
506 );
507 }
508 }
509
510 #[test]
513 fn a_refusal_names_the_boundary_that_refused() {
514 use crate::core::security_events::RefusalClass;
515 let _environment = crate::core::data_dir::test_env_lock();
516 let tmp = tempfile::tempdir().unwrap();
517 let root = tmp.path().canonicalize().unwrap();
518 fs::write(root.join(".env"), "TOKEN=x").unwrap();
519 let root = root.to_string_lossy().into_owned();
520 let prev = std::env::var("LEAN_CTX_IO_BOUNDARY_MODE").ok();
521 crate::test_env::set_var("LEAN_CTX_IO_BOUNDARY_MODE", "enforce");
522 let secret = resolve_tool_path_refusing(Some(&root), None, ".env", &[]);
523 let outside =
524 resolve_tool_path_refusing(Some(&root), None, &format!("{root}/../outside.txt"), &[]);
525 match prev {
526 Some(v) => crate::test_env::set_var("LEAN_CTX_IO_BOUNDARY_MODE", v),
527 None => crate::test_env::remove_var("LEAN_CTX_IO_BOUNDARY_MODE"),
528 }
529 let secret = secret.expect_err("enforce mode refuses a secret path");
530 assert_eq!(secret.class, Some(RefusalClass::SecretPath));
531 assert!(
532 secret.message.contains("Secret-like path"),
533 "{}",
534 secret.message
535 );
536 if !cfg!(feature = "no-jail") {
537 let outside = outside.expect_err("the jail refuses a path outside the root");
538 assert_eq!(outside.class, Some(RefusalClass::OutsideRoot));
539 }
540 let unbound = resolve_tool_path_refusing(None, None, "src/no_such_file_lc_9c1.rs", &[]);
541 if let Err(refusal) = unbound {
542 assert_eq!(refusal.class, None, "{}", refusal.message);
543 }
544 }
545}