use std::path::{Component, Path, PathBuf};
fn unsafe_path(relative: &str) -> Box<dyn std::error::Error> {
format!("unsafe path {relative:?}: refuses to escape the destination").into()
}
pub fn ensure_within(relative: &str) -> Result<(), Box<dyn std::error::Error>> {
if relative.is_empty() {
return Err(unsafe_path(relative));
}
for component in Path::new(relative).components() {
match component {
Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
return Err(unsafe_path(relative));
}
Component::Normal(segment) if segment.to_string_lossy().contains('\\') => {
return Err(unsafe_path(relative));
}
_ => {}
}
}
Ok(())
}
pub fn safe_join(base: &Path, relative: &str) -> Result<PathBuf, Box<dyn std::error::Error>> {
ensure_within(relative)?;
Ok(base.join(relative))
}
pub fn contained_target(root: &Path, out: &Path) -> Result<PathBuf, Box<dyn std::error::Error>> {
let parent = out
.parent()
.ok_or_else(|| -> Box<dyn std::error::Error> { "path has no parent".into() })?;
std::fs::create_dir_all(parent)?;
let real_parent = parent.canonicalize()?;
if !real_parent.starts_with(root) {
return Err(format!(
"unsafe path {out:?}: parent resolves outside the destination (symlink traversal?)"
)
.into());
}
let name = out
.file_name()
.ok_or_else(|| -> Box<dyn std::error::Error> { "path has no file name".into() })?;
Ok(real_parent.join(name))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ensure_within_rejects_traversal() {
for bad in [
"../flag.txt",
"./../flag.txt",
"a/../../flag.txt",
"/etc/passwd", "..", "", "..\\flag.txt", "a/..\\..\\b", ] {
assert!(ensure_within(bad).is_err(), "{bad:?} must be rejected");
}
}
#[test]
fn ensure_within_allows_legal_but_unusual_names() {
for ok in [
"flag.txt",
"a/b/c.js",
"@scope/pkg/index.js",
".../flag.txt", "~/flag.txt", "file:///tmp/flag.txt", "a..b/c", "./flag.txt", ] {
assert!(
ensure_within(ok).is_ok(),
"{ok:?} is a normal name, must be contained"
);
}
}
#[test]
fn safe_join_stays_under_base() {
let base = Path::new("/srv/node_modules");
assert_eq!(
safe_join(base, "@scope/pkg/index.js").unwrap(),
base.join("@scope/pkg/index.js")
);
assert!(safe_join(base, "../escape").is_err());
assert!(safe_join(base, "a/../b").is_err());
assert!(safe_join(base, "/abs").is_err());
assert!(safe_join(base, "").is_err());
}
#[test]
fn contained_target_refuses_the_root_itself() {
let tmp = tempfile::tempdir().unwrap();
let dest = tmp.path().join("pkg");
std::fs::create_dir_all(&dest).unwrap();
let root = dest.canonicalize().unwrap();
assert!(contained_target(&root, &dest).is_err());
assert!(contained_target(&root, &dest.join("file.js")).is_ok());
}
}