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>,
}
impl<'a> AllowedRoots<'a> {
pub(crate) fn new(workdir: &'a Path, scratchpad: Option<&'a Path>) -> Self {
Self {
workdir,
scratchpad,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum PathContainment {
Project,
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)
};
let (root, rel, abs) = resolve_external_target(&candidate)?;
Ok(ResolvedInRoot {
abs,
rel,
root,
containment: PathContainment::External,
})
}
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 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());
}
}