use std::path::{Component, Path, PathBuf};
use crate::error::{Error, Result};
pub fn safe_join(dest: &Path, entry_name: &str) -> Result<PathBuf> {
if entry_name.is_empty() {
return Err(Error::UnsafePath {
path: entry_name.to_string(),
reason: "empty entry name",
});
}
let mut out = PathBuf::new();
let mut pushed_a_component = false;
walk_within(&mut out, &mut pushed_a_component, Path::new(entry_name)).map_err(|reason| {
Error::UnsafePath {
path: entry_name.to_string(),
reason,
}
})?;
if out.as_os_str().is_empty() {
return if pushed_a_component {
Err(Error::UnsafePath {
path: entry_name.to_string(),
reason: "path traversal nets back to the destination",
})
} else {
Ok(dest.to_path_buf())
};
}
Ok(dest.join(out))
}
fn walk_within(
out: &mut PathBuf,
pushed_a_component: &mut bool,
path: &Path,
) -> std::result::Result<(), &'static str> {
for comp in path.components() {
match comp {
Component::RootDir | Component::Prefix(_) => return Err("absolute path"),
Component::CurDir => {}
Component::ParentDir => {
if !out.pop() {
return Err("path traversal above the destination");
}
}
Component::Normal(part) => {
out.push(part);
*pushed_a_component = true;
}
}
}
Ok(())
}
pub fn check_symlink_target(dest: &Path, link_path: &Path, target: &str) -> Result<()> {
let target_path = Path::new(target);
if target_path.is_absolute() {
return Err(Error::UnsafePath {
path: target.to_string(),
reason: "absolute symlink target",
});
}
let parent = link_path.parent().unwrap_or(dest);
let rel = parent.strip_prefix(dest).unwrap_or(Path::new(""));
let mut out = PathBuf::new();
let mut pushed = false;
walk_within(&mut out, &mut pushed, rel).map_err(|reason| Error::UnsafePath {
path: target.to_string(),
reason,
})?;
walk_within(&mut out, &mut pushed, target_path).map_err(|reason| Error::UnsafePath {
path: target.to_string(),
reason,
})?;
Ok(())
}
#[cfg(test)]
mod tests {
use std::path::Path;
use crate::error::Error;
#[test]
fn absolute_traversal_and_escaping_symlinks_are_all_refused() {
let dest = Path::new("/tmp/out");
for (name, why) in [
("../../etc/passwd", "traversal"),
("a/../../b", "traversal"),
("/etc/passwd", "absolute"),
("", "empty"),
] {
let err = super::safe_join(dest, name).expect_err("{name} must be refused");
match err {
Error::UnsafePath { path, .. } => assert_eq!(path, name),
other => panic!("expected UnsafePath for {name} ({why}), got {other:?}"),
}
}
}
#[test]
fn ordinary_nested_names_are_accepted_unchanged() {
let dest = Path::new("/tmp/out");
assert_eq!(
super::safe_join(dest, "a/b/c.txt").unwrap(),
dest.join("a/b/c.txt")
);
assert_eq!(
super::safe_join(dest, "./a.txt").unwrap(),
dest.join("a.txt")
);
}
#[test]
fn refusal_never_rewrites_the_name_it_refuses() {
let dest = Path::new("/tmp/out");
assert!(super::safe_join(dest, "../x").is_err());
assert!(super::safe_join(dest, "/x").is_err());
}
#[test]
fn a_symlink_whose_target_escapes_the_destination_is_refused() {
let dest = Path::new("/tmp/out");
assert!(super::check_symlink_target(dest, &dest.join("link"), "../../etc/passwd").is_err());
assert!(super::check_symlink_target(dest, &dest.join("link"), "/etc/passwd").is_err());
assert!(super::check_symlink_target(dest, &dest.join("a/link"), "b.txt").is_ok());
}
#[test]
fn dot_only_and_dot_prefixed_names_are_ordinary_filenames_not_traversal() {
let dest = Path::new("/tmp/out");
assert_eq!(super::safe_join(dest, "...").unwrap(), dest.join("..."));
assert_eq!(
super::safe_join(dest, "a/....b").unwrap(),
dest.join("a/....b")
);
assert_eq!(super::safe_join(dest, "..foo").unwrap(), dest.join("..foo"));
}
#[test]
fn backslash_heavy_names_stay_a_single_contained_component() {
let dest = Path::new("/tmp/out");
assert_eq!(
super::safe_join(dest, "..\\..\\etc\\passwd").unwrap(),
dest.join("..\\..\\etc\\passwd")
);
}
#[test]
fn embedded_nul_does_not_disguise_a_traversal_component() {
let dest = Path::new("/tmp/out");
assert_eq!(
super::safe_join(dest, "safe.txt\0..").unwrap(),
dest.join("safe.txt\0..")
);
assert!(super::safe_join(dest, "a\0/../../etc/passwd").is_err());
}
#[test]
fn a_symlink_target_that_resolves_to_the_destination_itself_is_contained() {
let dest = Path::new("/tmp/out");
assert!(super::check_symlink_target(dest, &dest.join("sub/top"), "..").is_ok());
assert!(super::check_symlink_target(dest, &dest.join("a/b/top"), "../..").is_ok());
assert!(super::check_symlink_target(dest, &dest.join("sub/top"), "../sub/..").is_ok());
assert!(super::check_symlink_target(dest, &dest.join("here"), ".").is_ok());
assert!(super::check_symlink_target(dest, &dest.join("sub/self"), "../sub").is_ok());
}
#[test]
fn a_symlink_target_one_step_past_the_destination_is_still_refused() {
let dest = Path::new("/tmp/out");
for (link, target) in [
(dest.join("sub/top"), "../.."),
(dest.join("top"), ".."),
(dest.join("a/b/top"), "../../.."),
(dest.join("sub/top"), "../../etc/passwd"),
(dest.join("sub/top"), "../sub/../.."),
(dest.join("sub/top"), "/etc/passwd"),
] {
let err = super::check_symlink_target(dest, &link, target)
.expect_err("{target} from {link:?} must be refused");
match err {
Error::UnsafePath { path, .. } => assert_eq!(
path, target,
"the refusal must name the archive's own target string"
),
other => panic!("expected UnsafePath for {target}, got {other:?}"),
}
}
}
#[test]
fn an_entry_name_that_nets_to_the_destination_is_still_refused_after_the_symlink_fix() {
let dest = Path::new("/tmp/out");
assert!(super::safe_join(dest, "sub/..").is_err());
assert!(super::safe_join(dest, "a/b/../..").is_err());
assert!(super::check_symlink_target(dest, &dest.join("sub/top"), "..").is_ok());
}
#[test]
fn a_chain_of_parent_dirs_through_a_nested_symlink_is_refused() {
let dest = Path::new("/tmp/out");
assert!(super::check_symlink_target(dest, &dest.join("a/b/link"), "../../b.txt").is_ok());
assert!(
super::check_symlink_target(dest, &dest.join("a/b/link"), "../../../etc/passwd")
.is_err()
);
}
#[test]
fn dot_and_dot_slash_name_the_destination_itself() {
let dest = Path::new("/tmp/out");
assert_eq!(super::safe_join(dest, ".").unwrap(), dest);
assert_eq!(super::safe_join(dest, "./").unwrap(), dest);
}
#[test]
fn a_trailing_dot_resolves_within_its_parent_not_to_the_destination() {
let dest = Path::new("/tmp/out");
assert_eq!(super::safe_join(dest, "sub/.").unwrap(), dest.join("sub"));
}
#[test]
fn empty_name_is_still_refused_even_though_dot_is_now_accepted() {
let dest = Path::new("/tmp/out");
assert!(super::safe_join(dest, "").is_err());
}
#[test]
fn traversal_that_nets_to_empty_is_still_refused_not_confused_with_naming_the_destination() {
let dest = Path::new("/tmp/out");
assert!(super::safe_join(dest, "..").is_err());
assert!(super::safe_join(dest, "./..").is_err());
assert!(super::safe_join(dest, "a/../..").is_err());
assert!(super::safe_join(dest, "a/..").is_err());
}
}