use std::path::{Path, PathBuf};
pub(crate) fn resolve_path_within(workdir: &Path, raw: &str) -> Result<(PathBuf, bool), String> {
let p = PathBuf::from(raw);
let candidate = if p.is_absolute() { p } else { workdir.join(&p) };
let root = std::fs::canonicalize(workdir).map_err(|e| {
format!(
"cannot canonicalize project dir '{}': {}",
workdir.display(),
e
)
})?;
let resolved = match std::fs::canonicalize(&candidate) {
Ok(real) => real,
Err(_) => resolve_via_existing_ancestor(&candidate)?,
};
let within = resolved.starts_with(&root);
Ok((resolved, within))
}
pub(crate) struct AllowedRoots<'a> {
pub workdir: &'a Path,
pub scratchpad: Option<&'a Path>,
pub additional: &'a [PathBuf],
}
impl<'a> AllowedRoots<'a> {
pub(crate) fn new(workdir: &'a Path, scratchpad: Option<&'a Path>) -> Self {
Self {
workdir,
scratchpad,
additional: &[],
}
}
pub(crate) fn of(ctx: &'a crate::providers::ctx::ExecContext) -> Self {
Self::new(&ctx.workdir, ctx.scratchpad.as_deref()).with_additional(&ctx.additional_dirs)
}
pub(crate) fn with_additional(mut self, additional: &'a [PathBuf]) -> Self {
self.additional = additional;
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum PathContainment {
Project,
AddedDir,
Scratchpad,
External,
}
#[derive(Debug)]
pub(crate) struct ResolvedInRoot {
pub abs: PathBuf,
pub rel: PathBuf,
pub root: PathBuf,
pub containment: PathContainment,
}
pub(crate) fn resolve_in_roots(
roots: &AllowedRoots<'_>,
raw: &str,
) -> Result<ResolvedInRoot, String> {
let project = resolve_path_within(roots.workdir, raw);
if let Ok((abs, true)) = &project {
return Ok(ResolvedInRoot {
abs: abs.clone(),
rel: relative_within(roots.workdir, raw)?,
root: roots.workdir.to_path_buf(),
containment: PathContainment::Project,
});
}
if let Some(scratch) = roots.scratchpad
&& Path::new(raw).is_absolute()
&& relative_within(scratch, raw).is_ok()
{
let (abs, within) = resolve_path_within(scratch, raw)?;
if within {
return Ok(ResolvedInRoot {
abs,
rel: relative_within(scratch, raw)?,
root: scratch.to_path_buf(),
containment: PathContainment::Scratchpad,
});
}
return Err(format!(
"path '{raw}' is inside scratchpad but symlinks outside it"
));
}
let p = PathBuf::from(raw);
let candidate = if p.is_absolute() {
p
} else {
roots.workdir.join(&p)
};
if let Some(resolved) = resolve_in_additional(roots.additional, &candidate) {
return Ok(resolved);
}
let (root, rel, abs) = resolve_external_target(&candidate)?;
Ok(ResolvedInRoot {
abs,
rel,
root,
containment: PathContainment::External,
})
}
fn resolve_in_additional(additional: &[PathBuf], candidate: &Path) -> Option<ResolvedInRoot> {
let raw = candidate.to_str()?;
additional.iter().find_map(|root| {
let (abs, within) = resolve_path_within(root, raw).ok()?;
let rel = relative_within(root, raw).ok()?;
within.then(|| ResolvedInRoot {
abs,
rel,
root: root.clone(),
containment: PathContainment::AddedDir,
})
})
}
pub(crate) fn relative_within(workdir: &Path, raw: &str) -> Result<PathBuf, String> {
let p = PathBuf::from(raw);
let candidate = if p.is_absolute() { p } else { workdir.join(&p) };
let normalized = mermaid_runtime::normalize_lexical(&candidate);
let root = mermaid_runtime::normalize_lexical(workdir);
match normalized.strip_prefix(&root) {
Ok(rel) => Ok(rel.to_path_buf()),
Err(_) => Err(format!(
"path '{}' is outside the project directory '{}'",
raw,
workdir.display()
)),
}
}
fn resolve_external_target(candidate: &Path) -> Result<(PathBuf, PathBuf, PathBuf), String> {
let normalized = mermaid_runtime::normalize_lexical(candidate);
let mut ancestor = normalized.as_path();
let mut tail: Vec<std::ffi::OsString> = Vec::new();
let (real_root, tail) = loop {
if let Ok(real) = std::fs::canonicalize(ancestor) {
break (real, tail);
}
let Some(file) = ancestor.file_name() else {
return Err(format!(
"cannot resolve path '{}': no existing ancestor directory",
candidate.display()
));
};
tail.push(file.to_os_string());
match ancestor.parent() {
Some(parent) => ancestor = parent,
None => {
return Err(format!(
"cannot resolve path '{}': no existing ancestor directory",
candidate.display()
));
},
}
};
let mut abs = real_root.clone();
let mut rel = PathBuf::new();
for comp in tail.iter().rev() {
abs.push(comp);
rel.push(comp);
}
let (root, rel) = if rel.as_os_str().is_empty() {
if let Some(parent) = real_root.parent() {
let file = real_root
.file_name()
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."));
(parent.to_path_buf(), file)
} else {
(real_root, PathBuf::from("."))
}
} else {
(real_root, rel)
};
Ok((root, rel, abs))
}
fn resolve_via_existing_ancestor(candidate: &Path) -> Result<PathBuf, String> {
resolve_external_target(candidate).map(|(_, _, abs)| abs)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolve_path_within_reports_containment() {
let root = std::env::temp_dir().join(format!("mermaid_pw_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("sub")).unwrap();
let (p, within) = resolve_path_within(&root, "sub").unwrap();
assert!(within, "subdir should be within the project");
assert!(p.ends_with("sub"));
let (_p, within) = resolve_path_within(&root, "..").unwrap();
assert!(!within, "parent dir should be outside the project");
let roots = AllowedRoots::new(&root, None);
assert!(resolve_in_roots(&roots, "..").is_ok());
assert!(resolve_in_roots(&roots, "sub").is_ok());
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn resolve_in_roots_dual_root_table() {
let base = std::env::temp_dir().join(format!("mermaid_rir_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let project = base.join("project");
let scratch = base.join("scratch");
std::fs::create_dir_all(project.join("sub")).unwrap();
std::fs::create_dir_all(&scratch).unwrap();
let roots = AllowedRoots::new(&project, Some(&scratch));
let r = resolve_in_roots(&roots, "sub/file.txt").unwrap();
assert_eq!(r.containment, PathContainment::Project);
assert_eq!(r.rel, PathBuf::from("sub/file.txt"));
assert_eq!(r.root, project);
let raw = scratch.join("nested/notes.txt");
let r = resolve_in_roots(&roots, raw.to_str().unwrap()).unwrap();
assert_eq!(r.containment, PathContainment::Scratchpad);
assert_eq!(r.rel, PathBuf::from("nested/notes.txt"));
assert_eq!(r.root, scratch);
let outside = base.join("elsewhere/file.txt");
let r = resolve_in_roots(&roots, outside.to_str().unwrap()).unwrap();
assert_eq!(r.containment, PathContainment::External);
assert!(r.abs.ends_with(Path::new("elsewhere").join("file.txt")));
let r = resolve_in_roots(&roots, "../scratch/sneaky.txt").unwrap();
assert_eq!(r.containment, PathContainment::External);
let _ = std::fs::remove_dir_all(&base);
}
#[cfg(unix)]
#[test]
fn resolve_in_roots_rejects_a_symlink_inside_the_scratchpad_that_escapes() {
let base = std::env::temp_dir().join(format!("mermaid_sym_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let project = base.join("project");
let scratch = base.join("scratch");
let outside = base.join("outside");
std::fs::create_dir_all(project.join("secrets")).unwrap();
std::fs::create_dir_all(&scratch).unwrap();
std::fs::create_dir_all(outside.join("home")).unwrap();
let roots = AllowedRoots::new(&project, Some(&scratch));
std::os::unix::fs::symlink(&project, scratch.join("to_project")).unwrap();
std::os::unix::fs::symlink(&outside, scratch.join("to_outside")).unwrap();
for tail in ["to_project/secrets/leak.txt", "to_outside/home/leak.txt"] {
let raw = scratch.join(tail);
let res = resolve_in_roots(&roots, raw.to_str().unwrap());
assert!(
res.is_err(),
"symlink escape {tail:?} must be rejected, got {res:?}"
);
}
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn resolve_in_roots_claims_paths_inside_an_added_root() {
let base = std::env::temp_dir().join(format!("mermaid_rad_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&base).unwrap();
let base = std::fs::canonicalize(&base).unwrap();
let project = base.join("project");
std::fs::create_dir_all(&project).unwrap();
std::fs::create_dir_all(base.join("added/sub")).unwrap();
std::fs::create_dir_all(base.join("elsewhere")).unwrap();
let added = std::fs::canonicalize(base.join("added")).unwrap();
let additional = [added.clone()];
let roots = AllowedRoots::new(&project, None).with_additional(&additional);
let raw = added.join("sub/new.txt");
let r = resolve_in_roots(&roots, raw.to_str().unwrap()).unwrap();
assert_eq!(r.containment, PathContainment::AddedDir);
assert_eq!(r.root, added);
assert_eq!(r.rel, PathBuf::from("sub/new.txt"));
let r = resolve_in_roots(&roots, "../added/sub").unwrap();
assert_eq!(r.containment, PathContainment::AddedDir);
assert_eq!(r.rel, PathBuf::from("sub"));
assert_eq!(
resolve_in_roots(&roots, "file.txt").unwrap().containment,
PathContainment::Project
);
let outside = base.join("elsewhere/file.txt");
assert_eq!(
resolve_in_roots(&roots, outside.to_str().unwrap())
.unwrap()
.containment,
PathContainment::External
);
assert_eq!(
resolve_in_roots(&roots, base.to_str().unwrap())
.unwrap()
.containment,
PathContainment::External
);
let bare = AllowedRoots::new(&project, None);
assert_eq!(
resolve_in_roots(&bare, raw.to_str().unwrap())
.unwrap()
.containment,
PathContainment::External
);
let _ = std::fs::remove_dir_all(&base);
}
#[cfg(unix)]
#[test]
fn resolve_in_roots_does_not_let_a_symlink_cross_an_added_root() {
let base = std::env::temp_dir().join(format!("mermaid_ras_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&base).unwrap();
let base = std::fs::canonicalize(&base).unwrap();
let project = base.join("project");
let secret = base.join("secret");
std::fs::create_dir_all(&project).unwrap();
std::fs::create_dir_all(base.join("added")).unwrap();
std::fs::create_dir_all(&secret).unwrap();
std::fs::write(secret.join("key"), "k").unwrap();
let added = std::fs::canonicalize(base.join("added")).unwrap();
std::os::unix::fs::symlink(&secret, added.join("escape")).unwrap();
std::os::unix::fs::symlink(&added, base.join("into_added")).unwrap();
let additional = [added.clone()];
let roots = AllowedRoots::new(&project, None).with_additional(&additional);
for raw in [
added.join("escape/key"),
added.join("escape/new.txt"),
base.join("into_added/file.txt"),
] {
let r = resolve_in_roots(&roots, raw.to_str().unwrap()).unwrap();
assert_eq!(
r.containment,
PathContainment::External,
"{} must not count as inside the added root",
raw.display()
);
}
let r = resolve_in_roots(&roots, added.join("escape/key").to_str().unwrap()).unwrap();
assert_eq!(r.abs, std::fs::canonicalize(secret.join("key")).unwrap());
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn relative_within_names_path_relative_to_root() {
let root = std::env::temp_dir().join(format!("mermaid_rel_{}", std::process::id()));
assert_eq!(
relative_within(&root, "sub/file.txt").unwrap(),
PathBuf::from("sub/file.txt")
);
assert_eq!(
relative_within(&root, "a/../b.txt").unwrap(),
PathBuf::from("b.txt")
);
assert_eq!(
relative_within(&root, root.join("c.txt").to_str().unwrap()).unwrap(),
PathBuf::from("c.txt")
);
assert!(relative_within(&root, "../escape").is_err());
#[cfg(unix)]
assert!(relative_within(&root, "/etc/passwd").is_err());
}
}