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    let normalized = crate::core::pathutil::normalize_tool_path(raw);
43    if normalized.is_empty() || normalized == "." {
44        return Ok(normalized);
45    }
46
47    let p = Path::new(&normalized);
48    let jail_root = project_root.or(shell_cwd).unwrap_or(".").to_string();
49
50    let resolved: PathBuf = if p.is_absolute() {
51        PathBuf::from(&normalized)
52    } else if let Some(root) = project_root {
53        let joined = Path::new(root).join(&normalized);
54        if joined.exists() {
55            joined
56        } else if let Some(cwd) = shell_cwd {
57            Path::new(cwd).join(&normalized)
58        } else {
59            Path::new(root).join(&normalized)
60        }
61    } else if let Some(cwd) = shell_cwd {
62        Path::new(cwd).join(&normalized)
63    } else {
64        Path::new(&jail_root).join(&normalized)
65    };
66
67    let jail_root_path = Path::new(&jail_root);
68    let jailed = crate::core::pathjail::jail_path(&resolved, jail_root_path)?;
69    crate::core::io_boundary::check_secret_path_for_tool("resolve_path", &jailed)?;
70
71    Ok(crate::core::pathutil::normalize_tool_path(
72        &jailed.to_string_lossy().replace('\\', "/"),
73    ))
74}
75
76#[cfg(test)]
77mod tests {
78    use super::*;
79    use std::fs;
80
81    #[test]
82    fn empty_and_dot_pass_through() {
83        assert_eq!(resolve_tool_path(None, None, "").unwrap(), "");
84        assert_eq!(resolve_tool_path(None, None, ".").unwrap(), ".");
85    }
86
87    #[test]
88    fn relative_resolves_against_project_root() {
89        let tmp = std::env::temp_dir().join(format!("lc_pr_{}", std::process::id()));
90        let _ = fs::create_dir_all(&tmp);
91        let file = tmp.join("a.txt");
92        fs::write(&file, "x").unwrap();
93        let root = tmp.to_string_lossy().to_string();
94
95        let out = resolve_tool_path(Some(&root), None, "a.txt").unwrap();
96        assert!(out.ends_with("a.txt"), "got {out}");
97        assert!(out.contains(&root) || Path::new(&out).is_absolute());
98
99        let _ = fs::remove_dir_all(&tmp);
100    }
101
102    #[test]
103    fn falls_back_to_shell_cwd_when_not_in_project_root() {
104        let base = std::env::temp_dir().join(format!("lc_pr_cwd_{}", std::process::id()));
105        let root = base.join("root");
106        let cwd = base.join("cwd");
107        fs::create_dir_all(&root).unwrap();
108        fs::create_dir_all(&cwd).unwrap();
109        fs::write(cwd.join("only_in_cwd.txt"), "x").unwrap();
110
111        let out = resolve_tool_path(
112            Some(&root.to_string_lossy()),
113            Some(&cwd.to_string_lossy()),
114            "only_in_cwd.txt",
115        );
116        // jail_root is project_root; a file only under shell_cwd resolves to a
117        // cwd-joined path which may be rejected by the jail — either way it must
118        // not panic and must yield a deterministic Result.
119        assert!(out.is_ok() || out.is_err());
120
121        let _ = fs::remove_dir_all(&base);
122    }
123
124    // P0-3 (#415): a relative path that happens to exist in the *process CWD*
125    // must NOT short-circuit resolution. `Cargo.toml` exists in the package
126    // root (cargo test's CWD) but not in this empty project root — before the
127    // fix the CWD probe returned it as-is, now it must resolve into the root.
128    #[test]
129    fn relative_path_never_resolves_against_process_cwd() {
130        let cwd = std::env::current_dir().unwrap();
131        assert!(
132            cwd.join("Cargo.toml").exists(),
133            "test premise: CWD contains Cargo.toml"
134        );
135
136        let tmp = std::env::temp_dir().join(format!("lc_pr_nocwd_{}", std::process::id()));
137        fs::create_dir_all(&tmp).unwrap();
138        let root = tmp.to_string_lossy().to_string();
139
140        let out = resolve_tool_path(Some(&root), None, "Cargo.toml").unwrap();
141        // Canonicalize BOTH sides before comparing: on macOS temp_dir() is a
142        // symlink (/var → /private/var) and on Windows it may carry 8.3 short
143        // names (RUNNER~1), so comparing raw strings is platform-flaky. The
144        // resolved file itself does not exist, but its parent does — compare
145        // the canonicalized parents.
146        let canonical_root = crate::core::pathjail::canonicalize_or_self(&tmp);
147        let out_parent = crate::core::pathjail::canonicalize_or_self(
148            Path::new(&out)
149                .parent()
150                .expect("resolved path has a parent"),
151        );
152        assert_eq!(
153            out_parent, canonical_root,
154            "resolved {out} must live under the project root, not the process CWD"
155        );
156        let canonical_cwd = crate::core::pathjail::canonicalize_or_self(&cwd);
157        assert_ne!(
158            out_parent, canonical_cwd,
159            "resolved {out} must not resolve against the process CWD"
160        );
161
162        let _ = fs::remove_dir_all(&tmp);
163    }
164
165    #[test]
166    fn tool_context_shape_project_root_only() {
167        // Mirrors ToolContext::resolve_path_sync (shell_cwd = None).
168        let tmp = std::env::temp_dir().join(format!("lc_pr_ctx_{}", std::process::id()));
169        fs::create_dir_all(&tmp).unwrap();
170        let root = tmp.to_string_lossy().to_string();
171        let out = resolve_tool_path(Some(&root), None, "missing.rs").unwrap();
172        assert!(out.ends_with("missing.rs"), "got {out}");
173        let _ = fs::remove_dir_all(&tmp);
174    }
175
176    // GH #397: on Unix an absolute path under a single-letter root (`/c/…`)
177    // was rewritten to `C:/…`, which `Path::is_absolute()` rejects on Unix —
178    // the path was then re-joined under the (also-translated) project root,
179    // producing the doubled `C:/root/C:/root/file` form from the report.
180    // `/c` cannot be created in this test environment, so the jail may still
181    // reject the path as nonexistent — the regression assertion is that no
182    // `C:/` drive form appears anywhere in the outcome (Ok or Err).
183    #[cfg(not(windows))]
184    #[test]
185    fn single_letter_root_is_never_drive_translated_on_unix() {
186        for raw in ["/c/Users/me/proj/src/app.ts", "src/app.ts"] {
187            let rendered = match resolve_tool_path(Some("/c/Users/me/proj"), None, raw) {
188                Ok(p) => p,
189                Err(e) => e,
190            };
191            assert!(
192                !rendered.contains("C:/"),
193                "drive translation must not run on unix hosts (raw={raw}): {rendered}"
194            );
195        }
196    }
197}