use std::path::{Path, PathBuf};
fn path_is_within_dir(dir: &Path, file: &Path) -> bool {
match (dir.canonicalize(), file.canonicalize()) {
(Ok(canon_dir), Ok(canon_file)) => canon_file.starts_with(&canon_dir),
_ => false,
}
}
pub(crate) fn script_search_paths(run: &str, search_roots: &[&Path]) -> Vec<(PathBuf, PathBuf)> {
search_roots
.iter()
.map(|root| (root.to_path_buf(), root.join(run)))
.collect()
}
pub fn resolve_script_path(run: &str, search_roots: &[&Path]) -> Option<PathBuf> {
if Path::new(run).is_absolute() {
return None;
}
if run.contains("..") {
return None;
}
let pairs = script_search_paths(run, search_roots);
for (root, candidate) in &pairs {
if candidate.exists() && path_is_within_dir(root, candidate) {
return Some(candidate.clone());
}
}
None
}
pub fn default_skills_dir() -> Option<PathBuf> {
std::env::var_os("HOME").map(|h| PathBuf::from(&h).join(".claude/skills"))
}
pub fn make_script_resolver(
search_roots: Vec<PathBuf>,
) -> impl Fn(&str) -> Result<PathBuf, String> {
move |run| {
let root_refs: Vec<&Path> = search_roots.iter().map(|p| p.as_path()).collect();
resolve_script_path(run, &root_refs).ok_or_else(|| {
if Path::new(run).is_absolute() {
format!(
"absolute paths are not allowed in `run:` (got '{run}'); use a path relative to the configured search roots"
)
} else {
let pairs = script_search_paths(run, &root_refs);
let searched: Vec<String> =
pairs.iter().map(|(_, c)| c.display().to_string()).collect();
searched.join(", ")
}
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolve_script_path_rejects_absolute_path_even_if_it_exists() {
let tmp = tempfile::tempdir().expect("tempdir");
let wd = tmp.path();
assert_eq!(resolve_script_path("/bin/sh", &[wd]), None);
}
#[test]
fn resolve_script_path_rejects_traversal_back_into_search_root() {
let tmp = tempfile::tempdir().expect("tempdir");
let wd = tmp.path();
assert_eq!(resolve_script_path("../foo.sh", &[wd]), None);
}
#[test]
fn make_script_resolver_returns_explicit_error_for_absolute_path() {
let resolver = make_script_resolver(vec![PathBuf::from("/tmp")]);
let err = resolver("/etc/shadow").expect_err("absolute path must error");
assert!(
err.contains("absolute paths are not allowed"),
"error should explain why absolute paths fail; got: {err}"
);
}
}