use std::path::{Component, Path, PathBuf};
fn unsafe_path(relative: &str) -> Box<dyn std::error::Error + Send + Sync> {
format!("unsafe path {relative:?}: refuses to escape the destination").into()
}
pub fn ensure_within(relative: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
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 + Send + Sync>> {
ensure_within(relative)?;
Ok(base.join(relative))
}
pub fn contained_target(
root: &Path,
out: &Path,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
let parent = out
.parent()
.ok_or_else(|| -> Box<dyn std::error::Error + Send + Sync> {
"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 + Send + Sync> {
"path has no file name".into()
})?;
let target = real_parent.join(name);
if let Ok(meta) = std::fs::symlink_metadata(&target) {
if meta.file_type().is_symlink() {
return Err(format!(
"unsafe path {out:?}: destination already exists as a symlink \
(refuses to write through it)"
)
.into());
}
}
Ok(target)
}
#[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());
}
#[test]
#[cfg(unix)]
fn contained_target_refuses_a_preexisting_leaf_symlink() {
use std::os::unix::fs::symlink;
let tmp = tempfile::tempdir().unwrap();
let dest = tmp.path().join("dest");
let outside = tmp.path().join("outside.txt");
std::fs::create_dir_all(&dest).unwrap();
std::fs::write(&outside, b"original").unwrap();
let root = dest.canonicalize().unwrap();
symlink(&outside, dest.join("leaf")).unwrap();
assert!(
contained_target(&root, &dest.join("leaf")).is_err(),
"a pre-existing leaf symlink must be refused"
);
}
}