Skip to main content

lean_ctx/core/
path_resolve.rs

1//! Shared path-resolution for tool handlers.
2//!
3//! Previously two near-identical `resolve_path_sync` implementations lived in
4//! `tools/registered/mod.rs` (SessionState-based) and `server/tool_trait.rs`
5//! (ToolContext-based), plus several copies of the project-marker test. This
6//! module is the single source of truth: [`resolve_tool_path`] for jailed path
7//! resolution and a re-export of [`has_project_marker`] for marker detection.
8
9use std::path::{Path, PathBuf};
10
11/// Single canonical project-marker test (`.git`, `Cargo.toml`, …).
12///
13/// Re-exported from [`crate::core::pathutil`] so callers that think in terms of
14/// path resolution have a local, discoverable handle.
15pub use crate::core::pathutil::has_project_marker;
16
17/// Resolve a (possibly relative) tool path to a normalized, jail-checked,
18/// secret-screened absolute path.
19///
20/// Resolution order for relative inputs:
21/// 1. absolute path → used as-is
22/// 2. `<project_root>/<path>` if it exists
23/// 3. `<shell_cwd>/<path>` if a shell cwd is known
24/// 4. `<jail_root>/<path>` as a last resort
25///
26/// Relative inputs are NEVER resolved against the process CWD: the daemon's
27/// CWD is not the project, so a CWD `exists()` probe made resolution
28/// nondeterministic across MCP/daemon/CLI contexts (and could pick a
29/// same-named file outside the project).
30///
31/// `jail_root` is `project_root`, else `shell_cwd`, else `"."`. The result is
32/// confined to the jail root via [`crate::core::pathjail::jail_path`] and
33/// screened by the secret-path I/O boundary.
34///
35/// Performs blocking filesystem `exists()` checks; callers on async runtimes
36/// must wrap this in `tokio::task::block_in_place`.
37pub fn resolve_tool_path(
38    project_root: Option<&str>,
39    shell_cwd: Option<&str>,
40    raw: &str,
41) -> Result<String, String> {
42    resolve_tool_path_with_roots(project_root, shell_cwd, raw, &[])
43}
44
45/// Like [`resolve_tool_path`], but also permits paths under any of
46/// `extra_roots` (session-scoped trusted roots from `session.extra_roots`).
47///
48/// An empty `extra_roots` is identical to [`resolve_tool_path`]; this is how
49/// sync tool handlers honor MCP `roots/list` / config `extra_roots` for an
50/// explicit path without widening the global jail (#403).
51pub fn resolve_tool_path_with_roots(
52    project_root: Option<&str>,
53    shell_cwd: Option<&str>,
54    raw: &str,
55    extra_roots: &[String],
56) -> Result<String, String> {
57    let normalized = crate::core::pathutil::normalize_tool_path(raw);
58    if normalized.is_empty() || normalized == "." {
59        return Ok(normalized);
60    }
61
62    let p = Path::new(&normalized);
63    let jail_root = project_root.or(shell_cwd).unwrap_or(".").to_string();
64
65    let resolved: PathBuf = if p.is_absolute() {
66        PathBuf::from(&normalized)
67    } else if let Some(root) = project_root {
68        let joined = Path::new(root).join(&normalized);
69        if joined.exists() {
70            joined
71        } else if let Some(cwd) = shell_cwd {
72            Path::new(cwd).join(&normalized)
73        } else {
74            Path::new(root).join(&normalized)
75        }
76    } else if let Some(cwd) = shell_cwd {
77        Path::new(cwd).join(&normalized)
78    } else {
79        Path::new(&jail_root).join(&normalized)
80    };
81
82    let jail_root_path = Path::new(&jail_root);
83    let jailed =
84        crate::core::pathjail::jail_path_with_roots(&resolved, jail_root_path, extra_roots)?;
85    crate::core::io_boundary::check_secret_path_for_tool("resolve_path", &jailed)?;
86
87    Ok(crate::core::pathutil::normalize_tool_path(
88        &jailed.to_string_lossy().replace('\\', "/"),
89    ))
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95    use std::fs;
96
97    #[test]
98    fn empty_and_dot_pass_through() {
99        assert_eq!(resolve_tool_path(None, None, "").unwrap(), "");
100        assert_eq!(resolve_tool_path(None, None, ".").unwrap(), ".");
101    }
102
103    #[test]
104    fn relative_resolves_against_project_root() {
105        let tmp = std::env::temp_dir().join(format!("lc_pr_{}", std::process::id()));
106        let _ = fs::create_dir_all(&tmp);
107        let file = tmp.join("a.txt");
108        fs::write(&file, "x").unwrap();
109        let root = tmp.to_string_lossy().to_string();
110
111        let out = resolve_tool_path(Some(&root), None, "a.txt").unwrap();
112        assert!(out.ends_with("a.txt"), "got {out}");
113        assert!(out.contains(&root) || Path::new(&out).is_absolute());
114
115        let _ = fs::remove_dir_all(&tmp);
116    }
117
118    #[test]
119    fn falls_back_to_shell_cwd_when_not_in_project_root() {
120        let base = std::env::temp_dir().join(format!("lc_pr_cwd_{}", std::process::id()));
121        let root = base.join("root");
122        let cwd = base.join("cwd");
123        fs::create_dir_all(&root).unwrap();
124        fs::create_dir_all(&cwd).unwrap();
125        fs::write(cwd.join("only_in_cwd.txt"), "x").unwrap();
126
127        let out = resolve_tool_path(
128            Some(&root.to_string_lossy()),
129            Some(&cwd.to_string_lossy()),
130            "only_in_cwd.txt",
131        );
132        // jail_root is project_root; a file only under shell_cwd resolves to a
133        // cwd-joined path which may be rejected by the jail — either way it must
134        // not panic and must yield a deterministic Result.
135        assert!(out.is_ok() || out.is_err());
136
137        let _ = fs::remove_dir_all(&base);
138    }
139
140    // P0-3 (#415): a relative path that happens to exist in the *process CWD*
141    // must NOT short-circuit resolution. `Cargo.toml` exists in the package
142    // root (cargo test's CWD) but not in this empty project root — before the
143    // fix the CWD probe returned it as-is, now it must resolve into the root.
144    #[test]
145    fn relative_path_never_resolves_against_process_cwd() {
146        let cwd = std::env::current_dir().unwrap();
147        assert!(
148            cwd.join("Cargo.toml").exists(),
149            "test premise: CWD contains Cargo.toml"
150        );
151
152        let tmp = std::env::temp_dir().join(format!("lc_pr_nocwd_{}", std::process::id()));
153        fs::create_dir_all(&tmp).unwrap();
154        let root = tmp.to_string_lossy().to_string();
155
156        let out = resolve_tool_path(Some(&root), None, "Cargo.toml").unwrap();
157        // Canonicalize BOTH sides before comparing: on macOS temp_dir() is a
158        // symlink (/var → /private/var) and on Windows it may carry 8.3 short
159        // names (RUNNER~1), so comparing raw strings is platform-flaky. The
160        // resolved file itself does not exist, but its parent does — compare
161        // the canonicalized parents.
162        let canonical_root = crate::core::pathjail::canonicalize_or_self(&tmp);
163        let out_parent = crate::core::pathjail::canonicalize_or_self(
164            Path::new(&out)
165                .parent()
166                .expect("resolved path has a parent"),
167        );
168        assert_eq!(
169            out_parent, canonical_root,
170            "resolved {out} must live under the project root, not the process CWD"
171        );
172        let canonical_cwd = crate::core::pathjail::canonicalize_or_self(&cwd);
173        assert_ne!(
174            out_parent, canonical_cwd,
175            "resolved {out} must not resolve against the process CWD"
176        );
177
178        let _ = fs::remove_dir_all(&tmp);
179    }
180
181    // #403: session-scoped extra_roots must thread through to the jail so an
182    // explicit path under a worktree resolves where the bare resolver rejects
183    // it. Asserts only the Ok case (robust against parallel env mutation): with
184    // the jail on, success here is only possible because extra_roots were honored.
185    #[cfg(not(feature = "no-jail"))]
186    #[test]
187    fn extra_roots_thread_through_resolve_tool_path() {
188        let base = std::env::temp_dir().join(format!("lc_pr_extra_{}", std::process::id()));
189        let root = base.join("root");
190        let worktree = base.join("worktree");
191        fs::create_dir_all(&root).unwrap();
192        fs::create_dir_all(&worktree).unwrap();
193        let file = worktree.join("a.txt");
194        fs::write(&file, "x").unwrap();
195
196        let root_s = root.to_string_lossy().to_string();
197        let file_abs = file.to_string_lossy().to_string();
198        let extra = vec![worktree.to_string_lossy().to_string()];
199
200        let out = resolve_tool_path_with_roots(Some(&root_s), None, &file_abs, &extra);
201        assert!(
202            out.is_ok(),
203            "extra_roots must thread through the resolver: {out:?}"
204        );
205
206        let _ = fs::remove_dir_all(&base);
207    }
208
209    #[test]
210    fn tool_context_shape_project_root_only() {
211        // Mirrors ToolContext::resolve_path_sync (shell_cwd = None).
212        let tmp = std::env::temp_dir().join(format!("lc_pr_ctx_{}", std::process::id()));
213        fs::create_dir_all(&tmp).unwrap();
214        let root = tmp.to_string_lossy().to_string();
215        let out = resolve_tool_path(Some(&root), None, "missing.rs").unwrap();
216        assert!(out.ends_with("missing.rs"), "got {out}");
217        let _ = fs::remove_dir_all(&tmp);
218    }
219
220    // GH #397: on Unix an absolute path under a single-letter root (`/c/…`)
221    // was rewritten to `C:/…`, which `Path::is_absolute()` rejects on Unix —
222    // the path was then re-joined under the (also-translated) project root,
223    // producing the doubled `C:/root/C:/root/file` form from the report.
224    // `/c` cannot be created in this test environment, so the jail may still
225    // reject the path as nonexistent — the regression assertion is that no
226    // `C:/` drive form appears anywhere in the outcome (Ok or Err).
227    #[cfg(not(windows))]
228    #[test]
229    fn single_letter_root_is_never_drive_translated_on_unix() {
230        for raw in ["/c/Users/me/proj/src/app.ts", "src/app.ts"] {
231            let rendered = match resolve_tool_path(Some("/c/Users/me/proj"), None, raw) {
232                Ok(p) => p,
233                Err(e) => e,
234            };
235            assert!(
236                !rendered.contains("C:/"),
237                "drive translation must not run on unix hosts (raw={raw}): {rendered}"
238            );
239        }
240    }
241}