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".to_string(),
});
}
if entry_name.contains('\0') {
return Err(Error::UnsafePath {
path: entry_name.to_string(),
reason: "entry name contains a NUL byte".to_string(),
});
}
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: reason.to_string(),
}
})?;
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".to_string(),
})
} 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(crate) const EMPTY_SYMLINK_TARGET: &str = "symlink target is empty";
pub(crate) const CLIMBS_AFTER_A_NAME: &str =
"symlink target uses `..` after a name, which can climb out through another link";
pub(crate) fn climbs_out_of_dest(target: &str) -> String {
format!(
"symlink target `{}` climbs out of the destination",
crate::display::fmt_name(target)
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum SymlinkVerdict {
Allowed,
Skip(&'static str),
}
pub fn check_symlink_target(dest: &Path, link_path: &Path, target: &str) -> Result<()> {
match classify_symlink_target(dest, link_path, target, target)? {
SymlinkVerdict::Allowed => Ok(()),
SymlinkVerdict::Skip(reason) => Err(Error::UnsafePath {
path: target.to_string(),
reason: reason.to_string(),
}),
}
}
pub fn classify_symlink_target(
dest: &Path,
link_path: &Path,
entry: &str,
target: &str,
) -> Result<SymlinkVerdict> {
let refuse = |reason: String| Error::UnsafePath {
path: entry.to_string(),
reason,
};
let target_path = Path::new(target);
if target.contains('\0') {
return Err(refuse(format!(
"symlink target `{}` contains a NUL byte",
crate::display::fmt_name(target)
)));
}
if target.is_empty() {
return Ok(SymlinkVerdict::Skip(EMPTY_SYMLINK_TARGET));
}
if target_path.is_absolute() {
return Err(refuse(format!(
"symlink target `{}` is absolute",
crate::display::fmt_name(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(|_| refuse(climbs_out_of_dest(target)))?;
walk_within(&mut out, &mut pushed, target_path)
.map_err(|_| refuse(climbs_out_of_dest(target)))?;
let mut named = false;
for comp in target_path.components() {
match comp {
Component::Normal(_) => named = true,
Component::ParentDir if named => {
return Ok(SymlinkVerdict::Skip(CLIMBS_AFTER_A_NAME));
}
_ => {}
}
}
Ok(SymlinkVerdict::Allowed)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum NameRefusal {
TooLong,
InvalidOnThisFilesystem,
}
#[must_use]
pub fn name_refused_by_filesystem(e: &std::io::Error) -> Option<NameRefusal> {
#[cfg(any(target_os = "macos", target_os = "ios", target_os = "freebsd"))]
const ENAMETOOLONG: i32 = 63;
#[cfg(not(any(target_os = "macos", target_os = "ios", target_os = "freebsd")))]
const ENAMETOOLONG: i32 = 36;
#[cfg(any(target_os = "macos", target_os = "ios"))]
const EILSEQ: Option<i32> = Some(92);
#[cfg(target_os = "linux")]
const EILSEQ: Option<i32> = Some(84);
#[cfg(not(any(target_os = "macos", target_os = "ios", target_os = "linux")))]
const EILSEQ: Option<i32> = None;
if e.kind() == std::io::ErrorKind::InvalidFilename || e.raw_os_error() == Some(ENAMETOOLONG) {
Some(NameRefusal::TooLong)
} else if EILSEQ.is_some() && e.raw_os_error() == EILSEQ {
Some(NameRefusal::InvalidOnThisFilesystem)
} else {
None
}
}
#[cfg(test)]
mod tests {
#[test]
fn the_filesystem_refusing_a_name_is_classified() {
use super::{NameRefusal, name_refused_by_filesystem};
use std::io::{Error as IoError, ErrorKind};
#[cfg(target_os = "macos")]
const EILSEQ: i32 = 92;
#[cfg(target_os = "linux")]
const EILSEQ: i32 = 84;
#[cfg(any(target_os = "macos", target_os = "linux"))]
assert_eq!(
name_refused_by_filesystem(&IoError::from_raw_os_error(EILSEQ)),
Some(NameRefusal::InvalidOnThisFilesystem)
);
assert_eq!(
name_refused_by_filesystem(&IoError::from(ErrorKind::InvalidFilename)),
Some(NameRefusal::TooLong)
);
assert_eq!(
name_refused_by_filesystem(&IoError::from(ErrorKind::PermissionDenied)),
None
);
}
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 a_nul_bearing_name_or_symlink_target_is_refused_before_the_filesystem_sees_it() {
let dest = Path::new("/tmp/out");
for name in ["safe.txt\0..", "a\0/../../etc/passwd", "zt\0ee\0a.txt"] {
match super::safe_join(dest, name) {
Err(Error::UnsafePath { path, reason }) => {
assert_eq!(path, name);
assert!(
reason == "entry name contains a NUL byte"
|| reason == "path traversal above the destination",
"unexpected reason for {name:?}: {reason}"
);
}
other => panic!("expected UnsafePath for {name:?}, got {other:?}"),
}
}
match super::check_symlink_target(dest, &dest.join("sub/link"), "tar\0get") {
Err(Error::UnsafePath { path, reason }) => {
assert_eq!(path, "tar\0get");
assert_eq!(reason, "symlink target `tar\\0get` contains a NUL byte");
assert!(!reason.contains('\0'), "raw NUL leaked");
}
other => panic!("expected UnsafePath, got {other:?}"),
}
}
#[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("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 a_symlink_target_climbing_after_a_name_is_skipped() {
let dest = Path::new("/tmp/out");
for (link, target) in [
(dest.join("s1"), "x/s2/.."),
(dest.join("s1"), "a/../b"),
(dest.join("s1"), "./a/.."),
(dest.join("sub/top"), "../sub/.."),
(dest.join("a/b/l"), "../../x/y/../z"),
(dest.join("s1"), "a/./.."),
(dest.join("pkg-1.0/bin/foo"), "pkg-1.0/../lib/foo"),
] {
assert_eq!(
super::classify_symlink_target(dest, &link, "e", target).unwrap(),
super::SymlinkVerdict::Skip(super::CLIMBS_AFTER_A_NAME),
"{target} from {link:?}"
);
match super::check_symlink_target(dest, &link, target) {
Err(Error::UnsafePath { path, reason }) => {
assert_eq!(path, target);
assert_eq!(reason, super::CLIMBS_AFTER_A_NAME, "{target}");
}
other => panic!("{target} from {link:?} must be refused, got {other:?}"),
}
}
}
#[test]
fn an_escape_is_refused_naming_the_entry_even_when_its_shape_would_skip() {
let dest = Path::new("/tmp/out");
for (link, target, reason) in [
(
dest.join("l"),
"a/../../..",
super::climbs_out_of_dest("a/../../.."),
),
(
dest.join("sub/l"),
"../sub/../..",
super::climbs_out_of_dest("../sub/../.."),
),
(
dest.join("sub/l"),
"../..",
super::climbs_out_of_dest("../.."),
),
(
dest.join("sub/l"),
"../../x/../y",
super::climbs_out_of_dest("../../x/../y"),
),
(
dest.join("l"),
"/etc/passwd",
"symlink target `/etc/passwd` is absolute".to_string(),
),
(
dest.join("l"),
"a\0/..",
"symlink target `a\\0/..` contains a NUL byte".to_string(),
),
] {
match super::classify_symlink_target(dest, &link, "the/entry", target) {
Err(Error::UnsafePath { path, reason: r }) => {
assert_eq!(path, "the/entry", "{target:?}");
assert_eq!(r, reason, "{target:?}");
}
other => panic!("{target:?} from {link:?} must be refused, got {other:?}"),
}
}
}
#[test]
fn a_quoted_target_is_escaped() {
let dest = Path::new("/tmp/out");
let target = "../\x1b`x/../..";
match super::classify_symlink_target(dest, &dest.join("l"), "e", target) {
Err(Error::UnsafePath { reason, .. }) => {
assert_eq!(
reason,
"symlink target `../\\x1b`x/../..` climbs out of the destination"
);
assert!(!reason.contains('\x1b'));
}
other => panic!("expected UnsafePath, got {other:?}"),
}
}
#[test]
fn an_empty_symlink_target_is_skipped() {
let dest = Path::new("/tmp/out");
assert_eq!(
super::classify_symlink_target(dest, &dest.join("sub/l"), "sub/l", "").unwrap(),
super::SymlinkVerdict::Skip(super::EMPTY_SYMLINK_TARGET)
);
match super::check_symlink_target(dest, &dest.join("sub/l"), "") {
Err(Error::UnsafePath { path, reason }) => {
assert_eq!(path, "");
assert_eq!(reason, super::EMPTY_SYMLINK_TARGET);
}
other => panic!("the wrapper must still refuse it, got {other:?}"),
}
}
#[test]
fn a_leading_parent_run_and_plain_names_are_still_accepted() {
let dest = Path::new("/tmp/out");
for (link, target) in [
(dest.join("sub/top"), ".."),
(dest.join("sub/l"), "../a"),
(dest.join("a/b/l"), "../../a/b"),
(dest.join("a/b/l"), "./../.."),
(dest.join("l"), "a/b"),
(dest.join("l"), "./a"),
(dest.join("l"), "a/./b"),
(dest.join("l"), "a/b/."),
(dest.join("l"), "."),
(dest.join("l"), "..foo/...b"),
] {
assert!(
super::check_symlink_target(dest, &link, target).is_ok(),
"{target} from {link:?} must be accepted"
);
assert_eq!(
super::classify_symlink_target(dest, &link, "e", target).unwrap(),
super::SymlinkVerdict::Allowed,
"{target} from {link:?}"
);
}
}
#[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());
}
}