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)]
pub(crate) struct ResolvedInRoot {
pub abs: PathBuf,
pub rel: PathBuf,
pub root: PathBuf,
pub in_scratchpad: bool,
}
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(),
in_scratchpad: false,
});
}
if Path::new(raw).is_absolute()
&& let Some(scratch) = roots.scratchpad
&& let Ok((abs, true)) = resolve_path_within(scratch, raw)
&& let Ok(rel) = relative_within(scratch, raw)
{
return Ok(ResolvedInRoot {
abs,
rel,
root: scratch.to_path_buf(),
in_scratchpad: true,
});
}
match (project, roots.scratchpad) {
(Err(e), _) => Err(e),
(Ok(_), None) => Err(format!(
"path '{}' is outside the project directory '{}'",
raw,
roots.workdir.display()
)),
(Ok(_), Some(scratch)) => Err(format!(
"path '{}' is outside the project directory '{}' and the session scratchpad '{}'",
raw,
roots.workdir.display(),
scratch.display()
)),
}
}
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 = lexical_normalize(&candidate);
let root = lexical_normalize(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_via_existing_ancestor(candidate: &Path) -> Result<PathBuf, String> {
let normalized = lexical_normalize(candidate);
let mut ancestor = normalized.as_path();
let mut tail: Vec<std::ffi::OsString> = Vec::new();
loop {
if let Ok(real) = std::fs::canonicalize(ancestor) {
let mut out = real;
for comp in tail.iter().rev() {
out.push(comp);
}
return Ok(out);
}
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()
));
},
}
}
}
fn lexical_normalize(p: &Path) -> PathBuf {
use std::path::Component;
let mut out = PathBuf::new();
for comp in p.components() {
match comp {
Component::ParentDir => {
if !out.pop() {
out.push("..");
}
},
Component::CurDir => {},
other => out.push(other.as_os_str()),
}
}
out
}
#[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_err());
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!(!r.in_scratchpad);
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!(r.in_scratchpad);
assert_eq!(r.rel, PathBuf::from("nested/notes.txt"));
assert_eq!(r.root, scratch);
let outside = base.join("elsewhere/file.txt");
let err = resolve_in_roots(&roots, outside.to_str().unwrap()).unwrap_err();
assert!(err.contains("outside the project"), "got: {err}");
assert!(err.contains("scratchpad"), "got: {err}");
assert!(resolve_in_roots(&roots, "../scratch/sneaky.txt").is_err());
let no_scratch = AllowedRoots::new(&project, None);
let err = resolve_in_roots(&no_scratch, raw.to_str().unwrap()).unwrap_err();
assert!(err.contains("outside the project directory"), "got: {err}");
assert!(!err.contains("scratchpad"), "got: {err}");
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());
}
}