use std::path::{Path, PathBuf};
pub(crate) fn resolve_path_safe(workdir: &Path, raw: &str) -> Result<PathBuf, String> {
let (resolved, within) = resolve_path_within(workdir, raw)?;
if within {
Ok(resolved)
} else {
Err(format!(
"path '{}' is outside the project directory '{}'",
raw,
workdir.display()
))
}
}
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) 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");
assert!(resolve_path_safe(&root, "..").is_err());
assert!(resolve_path_safe(&root, "sub").is_ok());
let _ = std::fs::remove_dir_all(&root);
}
#[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());
}
}