use std::{
collections::VecDeque,
ffi::{OsStr, OsString},
fs,
path::{Component, Path, PathBuf},
};
use typed_path::{Utf8WindowsComponent, Utf8WindowsPath};
use crate::{
read::extract::make_writable_dir_all,
result::{ZipResult, invalid},
};
pub(crate) fn simplified_components(input: &Path) -> Option<Vec<&OsStr>> {
let mut out = Vec::new();
let input_str = input.to_str()?;
for component in Utf8WindowsPath::new(input_str).components() {
match component {
Utf8WindowsComponent::Prefix(_) | Utf8WindowsComponent::RootDir => (),
Utf8WindowsComponent::ParentDir => {
out.pop()?;
}
Utf8WindowsComponent::Normal(s) => {
if s.contains(['/', '\\']) {
return None;
}
out.push(OsStr::new(s));
}
Utf8WindowsComponent::CurDir => (),
}
}
Some(out)
}
pub(crate) fn file_name_sanitized(no_null_filename: &str) -> PathBuf {
Utf8WindowsPath::new(no_null_filename)
.components()
.filter_map(|component| match component {
Utf8WindowsComponent::Normal(s) => Some(s),
_ => None,
})
.collect()
}
pub(crate) fn enclosed_name(file_name: &str) -> Option<PathBuf> {
let mut depth = 0usize;
let mut out_path = PathBuf::new();
for component in Utf8WindowsPath::new(file_name).components() {
match component {
Utf8WindowsComponent::Prefix(_) | Utf8WindowsComponent::RootDir => {
if depth > 0 {
return None;
}
}
Utf8WindowsComponent::ParentDir => {
depth = depth.checked_sub(1)?;
out_path.pop();
}
Utf8WindowsComponent::Normal(s) => {
if s.contains(['/', '\\']) {
return None;
}
depth += 1;
out_path.push(s);
}
Utf8WindowsComponent::CurDir => (),
}
}
Some(out_path)
}
pub(crate) fn strip_base_prefix<'p>(base: &Path, path: &'p Path) -> Option<&'p Path> {
if let Ok(rest) = path.strip_prefix(base) {
return Some(rest);
}
strip_equivalent_prefix(base, path)
}
#[cfg(windows)]
fn strip_equivalent_prefix<'p>(base: &Path, path: &'p Path) -> Option<&'p Path> {
use std::path::Prefix;
fn volume(prefix: Prefix<'_>) -> Prefix<'_> {
match prefix {
Prefix::Disk(disk) | Prefix::VerbatimDisk(disk) => {
Prefix::Disk(disk.to_ascii_uppercase())
}
Prefix::VerbatimUNC(server, share) => Prefix::UNC(server, share),
other => other,
}
}
let mut base_components = base.components();
let mut path_components = path.components();
match (base_components.next(), path_components.next()) {
(Some(Component::Prefix(base_prefix)), Some(Component::Prefix(path_prefix)))
if volume(base_prefix.kind()) == volume(path_prefix.kind()) => {}
_ => return None,
}
loop {
let rest = path_components.as_path();
match (base_components.next(), path_components.next()) {
(None, _) => return Some(rest),
(Some(base_component), Some(path_component)) if base_component == path_component => {}
_ => return None,
}
}
}
#[cfg(not(windows))]
fn strip_equivalent_prefix<'p>(_base: &Path, _path: &'p Path) -> Option<&'p Path> {
None
}
const MAX_SYMLINK_HOPS: usize = 40;
pub(crate) fn resolve_enclosed(
base: &Path,
start: &Path,
components: impl IntoIterator<Item = OsString>,
create_dirs: bool,
) -> ZipResult<PathBuf> {
debug_assert!(start.starts_with(base));
let mut current = start.to_path_buf();
let mut queue: VecDeque<OsString> = components.into_iter().collect();
let mut hops = 0usize;
let mut missing = false;
while let Some(component) = queue.pop_front() {
if component.is_empty() || component == OsStr::new(".") {
continue;
}
if component == OsStr::new("..") {
if missing {
return Err(invalid!(
"Path may escape the destination directory through a future symlink - '..' after a missing component"
));
}
if current == base {
return Err(invalid!(
"Path escapes the destination directory - component is '..'"
));
}
current.pop();
continue;
}
current.push(&component);
let meta = match fs::symlink_metadata(¤t) {
Ok(meta) => meta,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
if create_dirs && !queue.is_empty() {
make_writable_dir_all(¤t)?;
} else {
missing = true;
}
continue;
}
Err(e) => return Err(e.into()),
};
if !meta.is_symlink() {
continue;
}
hops += 1;
if hops > MAX_SYMLINK_HOPS {
return Err(invalid!("Extraction followed a symlink too deep"));
}
let target = fs::read_link(¤t)?;
current.pop();
let rest: PathBuf = if target.is_absolute() {
let Some(rest) = strip_base_prefix(base, &target) else {
return Err(invalid!("Symlink target escapes the destination directory"));
};
current = base.to_path_buf();
rest.to_path_buf()
} else {
target
};
for c in rest.components().rev() {
match c {
Component::Prefix(_) | Component::RootDir => {
return Err(invalid!("Invalid symlink target path"));
}
Component::CurDir => (),
Component::ParentDir => queue.push_front(OsString::from("..")),
Component::Normal(s) => queue.push_front(s.to_os_string()),
}
}
}
if !current.starts_with(base) {
return Err(invalid!(
"Path escapes the destination directory and not allowed outside"
));
}
Ok(current)
}
#[cfg(test)]
mod tests {
use super::{enclosed_name, simplified_components};
use std::path::{Path, PathBuf};
#[test]
fn test_simplified_components_relative_path() {
let path = Path::new("foo/bar/baz.txt");
let components = simplified_components(path).unwrap();
assert_eq!(components.len(), 3);
assert_eq!(components[0], "foo");
assert_eq!(components[1], "bar");
assert_eq!(components[2], "baz.txt");
}
#[test]
fn test_simplified_components_absolute_unix_path() {
let path = Path::new("/foo/bar/baz.txt");
let components = simplified_components(path).unwrap();
assert_eq!(components.len(), 3);
assert_eq!(components[0], "foo");
assert_eq!(components[1], "bar");
assert_eq!(components[2], "baz.txt");
}
#[test]
fn test_simplified_components_with_parent_dirs() {
let path = Path::new("foo/../bar/baz.txt");
let components = simplified_components(path).unwrap();
assert_eq!(components.len(), 2);
assert_eq!(components[0], "bar");
assert_eq!(components[1], "baz.txt");
}
#[test]
fn test_simplified_components_too_many_parent_dirs() {
let path = Path::new("foo/../../bar");
let result = simplified_components(path);
assert!(result.is_none()); }
#[test]
fn test_simplified_components_with_current_dir() {
let path = Path::new("foo/./bar/baz.txt");
let components = simplified_components(path).unwrap();
assert_eq!(components.len(), 3);
assert_eq!(components[0], "foo");
assert_eq!(components[1], "bar");
assert_eq!(components[2], "baz.txt");
}
#[test]
fn test_simplified_components_empty_path() {
let path = Path::new("");
let components = simplified_components(path).unwrap();
assert_eq!(components.len(), 0);
}
#[test]
fn test_simplified_components_root_only() {
let path = Path::new("/");
let components = simplified_components(path).unwrap();
assert_eq!(components.len(), 0);
}
#[test]
fn test_simplified_components_windows_absolute_path() {
let path = Path::new(r"C:\foo\bar\baz.txt");
let components = simplified_components(path).unwrap();
assert_eq!(components.len(), 3);
assert_eq!(components[0], "foo");
assert_eq!(components[1], "bar");
assert_eq!(components[2], "baz.txt");
}
#[test]
fn test_simplified_components_verbatim_prefix() {
for path in [
r"\\?\C:\foo\bar.txt",
r"\\?\UNC\server\share\foo\bar.txt",
r"\\.\C:\foo\bar.txt",
] {
let components = simplified_components(Path::new(path)).unwrap();
assert_eq!(components, ["foo", "bar.txt"], "{path}");
}
let components = simplified_components(Path::new(r"\\?\C:\foo\..\bar.txt")).unwrap();
assert_eq!(components, ["bar.txt"]);
let components = simplified_components(Path::new(r"\\?\C:\")).unwrap();
assert!(components.is_empty());
assert_eq!(
simplified_components(Path::new(r"\\?\C:\..\..\etc\passwd")),
None
);
}
#[test]
fn test_enclosed_name_verbatim_prefix() {
for name in [
r"\\?\C:\foo\bar.txt",
r"\\?\UNC\server\share\foo\bar.txt",
r"\\.\C:\foo\bar.txt",
] {
assert_eq!(
enclosed_name(name),
Some(PathBuf::from_iter(["foo", "bar.txt"])),
"{name}"
);
}
assert_eq!(enclosed_name(r"\\?\C:\..\..\etc\passwd"), None);
}
#[test]
fn test_enclosed_name_verbatim_prefix_with_a_separator_inside_a_component() {
for name in [
r"\\?\C:\../b",
r"\\?\C:\a/../../b",
r"\\?\UNC\server\share\../b",
r"\\?\C:\a\..\../b",
r"\\?\C:\..\b",
"../b",
] {
assert_eq!(enclosed_name(name), None, "{name}");
assert_eq!(simplified_components(Path::new(name)), None, "{name}");
}
}
#[cfg(windows)]
#[test]
fn test_strip_base_prefix_accepts_equivalent_windows_prefixes() {
use super::strip_base_prefix;
let base = Path::new(r"\\?\C:\dest");
for path in [r"C:\dest\sub\file.txt", r"c:\dest\sub\file.txt"] {
assert_eq!(
strip_base_prefix(base, Path::new(path)),
Some(Path::new(r"sub\file.txt")),
"{path} should be inside {}",
base.display()
);
}
assert_eq!(
strip_base_prefix(base, Path::new(r"C:\dest")),
Some(Path::new(""))
);
let unc_base = Path::new(r"\\?\UNC\server\share\dest");
assert_eq!(
strip_base_prefix(unc_base, Path::new(r"\\server\share\dest\file.txt")),
Some(Path::new("file.txt"))
);
}
#[test]
fn test_strip_base_prefix_rejects_other_windows_volumes() {
use super::strip_base_prefix;
let base = Path::new(r"\\?\C:\dest");
for path in [
r"D:\dest\file.txt",
r"\\attacker\share\dest\file.txt",
r"C:\dest-sibling\file.txt",
r"C:\other\file.txt",
] {
assert_eq!(
strip_base_prefix(base, Path::new(path)),
None,
"{path} should not be inside {}",
base.display()
);
}
}
#[test]
fn test_simplified_components_windows_unc_path() {
let path = Path::new(r"\\server\share\foo\bar.txt");
let components = simplified_components(path).unwrap();
assert_eq!(components.len(), 2);
assert_eq!(components[0], "foo");
assert_eq!(components[1], "bar.txt");
}
}