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 or already-existing 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/// `jail_root` is `project_root`, else `shell_cwd`, else `"."`. The result is
27/// confined to the jail root via [`crate::core::pathjail::jail_path`] and
28/// screened by the secret-path I/O boundary.
29///
30/// Performs blocking filesystem `exists()` checks; callers on async runtimes
31/// must wrap this in `tokio::task::block_in_place`.
32pub fn resolve_tool_path(
33    project_root: Option<&str>,
34    shell_cwd: Option<&str>,
35    raw: &str,
36) -> Result<String, String> {
37    let normalized = crate::core::pathutil::normalize_tool_path(raw);
38    if normalized.is_empty() || normalized == "." {
39        return Ok(normalized);
40    }
41
42    let p = Path::new(&normalized);
43    let jail_root = project_root.or(shell_cwd).unwrap_or(".").to_string();
44
45    let resolved: PathBuf = if p.is_absolute() || p.exists() {
46        PathBuf::from(&normalized)
47    } else if let Some(root) = project_root {
48        let joined = Path::new(root).join(&normalized);
49        if joined.exists() {
50            joined
51        } else if let Some(cwd) = shell_cwd {
52            Path::new(cwd).join(&normalized)
53        } else {
54            Path::new(root).join(&normalized)
55        }
56    } else if let Some(cwd) = shell_cwd {
57        Path::new(cwd).join(&normalized)
58    } else {
59        Path::new(&jail_root).join(&normalized)
60    };
61
62    let jail_root_path = Path::new(&jail_root);
63    let jailed = crate::core::pathjail::jail_path(&resolved, jail_root_path)?;
64    crate::core::io_boundary::check_secret_path_for_tool("resolve_path", &jailed)?;
65
66    Ok(crate::core::pathutil::normalize_tool_path(
67        &jailed.to_string_lossy().replace('\\', "/"),
68    ))
69}
70
71#[cfg(test)]
72mod tests {
73    use super::*;
74    use std::fs;
75
76    #[test]
77    fn empty_and_dot_pass_through() {
78        assert_eq!(resolve_tool_path(None, None, "").unwrap(), "");
79        assert_eq!(resolve_tool_path(None, None, ".").unwrap(), ".");
80    }
81
82    #[test]
83    fn relative_resolves_against_project_root() {
84        let tmp = std::env::temp_dir().join(format!("lc_pr_{}", std::process::id()));
85        let _ = fs::create_dir_all(&tmp);
86        let file = tmp.join("a.txt");
87        fs::write(&file, "x").unwrap();
88        let root = tmp.to_string_lossy().to_string();
89
90        let out = resolve_tool_path(Some(&root), None, "a.txt").unwrap();
91        assert!(out.ends_with("a.txt"), "got {out}");
92        assert!(out.contains(&root) || Path::new(&out).is_absolute());
93
94        let _ = fs::remove_dir_all(&tmp);
95    }
96
97    #[test]
98    fn falls_back_to_shell_cwd_when_not_in_project_root() {
99        let base = std::env::temp_dir().join(format!("lc_pr_cwd_{}", std::process::id()));
100        let root = base.join("root");
101        let cwd = base.join("cwd");
102        fs::create_dir_all(&root).unwrap();
103        fs::create_dir_all(&cwd).unwrap();
104        fs::write(cwd.join("only_in_cwd.txt"), "x").unwrap();
105
106        let out = resolve_tool_path(
107            Some(&root.to_string_lossy()),
108            Some(&cwd.to_string_lossy()),
109            "only_in_cwd.txt",
110        );
111        // jail_root is project_root; a file only under shell_cwd resolves to a
112        // cwd-joined path which may be rejected by the jail — either way it must
113        // not panic and must yield a deterministic Result.
114        assert!(out.is_ok() || out.is_err());
115
116        let _ = fs::remove_dir_all(&base);
117    }
118
119    #[test]
120    fn tool_context_shape_project_root_only() {
121        // Mirrors ToolContext::resolve_path_sync (shell_cwd = None).
122        let tmp = std::env::temp_dir().join(format!("lc_pr_ctx_{}", std::process::id()));
123        fs::create_dir_all(&tmp).unwrap();
124        let root = tmp.to_string_lossy().to_string();
125        let out = resolve_tool_path(Some(&root), None, "missing.rs").unwrap();
126        assert!(out.ends_with("missing.rs"), "got {out}");
127        let _ = fs::remove_dir_all(&tmp);
128    }
129}