use crate::error::{Error, Result};
use std::io::Read;
use std::path::{Component, Path, PathBuf};
const MAX_UNCOMPRESSED: u64 = 512 * 1024 * 1024;
const S_IFMT: u32 = 0o170000;
const S_IFLNK: u32 = 0o120000;
fn entry_path(entry: &zip::read::ZipFile<'_>, dest: &Path) -> Result<PathBuf> {
#[cfg(unix)]
let candidate = {
use std::os::unix::ffi::OsStrExt as _;
PathBuf::from(std::ffi::OsStr::from_bytes(entry.name_raw()))
};
#[cfg(not(unix))]
let candidate = PathBuf::from(entry.name());
let ok = !candidate.as_os_str().is_empty()
&& !candidate.is_absolute()
&& candidate
.components()
.all(|c| matches!(c, Component::Normal(_) | Component::CurDir));
if !ok || entry.enclosed_name().is_none() {
return Err(Error::HostileArchive {
dest: dest.to_path_buf(),
reason: format!("invalid entry path: {:?}", entry.name()),
});
}
Ok(candidate)
}
fn zip_listing(
archive: &mut zip::ZipArchive<std::io::Cursor<&[u8]>>,
attrs: &std::collections::HashMap<Vec<u8>, (u8, u32)>,
dest: &Path,
) -> Result<Vec<(PathBuf, bool, bool)>> {
let mut out = Vec::with_capacity(archive.len());
for i in 0..archive.len() {
let entry = archive.by_index(i).map_err(Error::zip(dest))?;
let (host, ext) = attrs.get(entry.name_raw()).copied().unwrap_or_else(|| {
(
HOST_UNIX,
entry.unix_mode().map_or(0, |m| (m & 0o177777) << 16),
)
});
let is_symlink = host == HOST_UNIX && (ext >> 16) & S_IFMT == S_IFLNK;
out.push((entry_path(&entry, dest)?, entry.is_dir(), is_symlink));
}
Ok(out)
}
fn lone_top_level_symlink(listing: &[(PathBuf, bool, bool)]) -> Option<PathBuf> {
let mut only: Option<&(PathBuf, bool, bool)> = None;
for e in listing {
let mut comps =
e.0.components()
.filter(|c| matches!(c, Component::Normal(_)));
let Some(Component::Normal(first)) = comps.next() else {
continue;
};
if first == ".DS_Store" {
continue;
}
match only {
None => only = Some(e),
Some(seen) if std::ptr::eq(seen, e) => {}
Some(_) => return None,
}
}
let (path, _, is_symlink) = only?;
let depth = path
.components()
.filter(|c| matches!(c, Component::Normal(_)))
.count();
(*is_symlink && depth == 1).then(|| path.clone())
}
fn root_strip_of<'a>(
entries: impl Iterator<Item = (&'a Path, bool)>,
) -> Option<std::ffi::OsString> {
let mut top: std::collections::BTreeMap<std::ffi::OsString, bool> =
std::collections::BTreeMap::new();
for (raw, dir_entry) in entries {
let mut comps = raw
.components()
.filter(|c| matches!(c, Component::Normal(_)));
let Some(Component::Normal(first)) = comps.next() else {
continue;
};
if first == ".DS_Store" {
continue;
}
let is_dir = dir_entry || comps.next().is_some();
*top.entry(first.to_owned()).or_insert(false) |= is_dir;
}
let (name, is_dir) = top.iter().next()?;
(top.len() == 1 && *is_dir).then(|| name.clone())
}
fn central_attrs(zip_bytes: &[u8]) -> std::collections::HashMap<Vec<u8>, (u8, u32)> {
let mut out = std::collections::HashMap::new();
let mut i = 0usize;
while let Some(pos) = zip_bytes
.get(i..)
.and_then(|s| s.windows(4).position(|w| w == b"PK\x01\x02"))
.map(|p| i + p)
{
let field = |off: usize| -> Option<usize> {
let b = zip_bytes.get(pos + off..pos + off + 2)?;
Some(u16::from_le_bytes([b[0], b[1]]) as usize)
};
let (Some(name_len), Some(extra_len), Some(comment_len)) =
(field(28), field(30), field(32))
else {
break;
};
let Some(attrs) = zip_bytes
.get(pos + 38..pos + 42)
.map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]))
else {
break;
};
let Some(host) = zip_bytes.get(pos + 5).copied() else {
break;
};
if let Some(name) = zip_bytes.get(pos + 46..pos + 46 + name_len) {
out.insert(name.to_vec(), (host, attrs));
}
i = pos + 46 + name_len + extra_len + comment_len;
}
out
}
const HOST_UNIX: u8 = 3;
#[cfg_attr(not(unix), allow(dead_code))]
enum ModeRule {
Exact(u32),
NarrowDefault(u32),
Default,
}
fn unzip_mode(host: u8, attrs: u32) -> ModeRule {
let stored = (attrs >> 16) & 0o777;
if host == HOST_UNIX || stored != 0 {
return ModeRule::Exact(stored);
}
if attrs & 0x01 != 0 {
return ModeRule::NarrowDefault(0o444);
}
ModeRule::Default
}
#[cfg(unix)]
fn apply_mode(path: &Path, rule: &ModeRule) -> Result<()> {
use std::os::unix::fs::PermissionsExt as _;
let mode = match rule {
ModeRule::Exact(m) => *m,
ModeRule::NarrowDefault(m) => {
let cur = std::fs::symlink_metadata(path).map_err(Error::io(path))?;
cur.permissions().mode() & 0o777 & m
}
ModeRule::Default => return Ok(()),
};
std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).map_err(Error::io(path))
}
#[cfg(not(unix))]
fn apply_mode(_path: &Path, _rule: &ModeRule) -> Result<()> {
Ok(())
}
fn strip_root(raw: &Path, root: Option<&std::ffi::OsStr>) -> Option<PathBuf> {
let mut comps = raw
.components()
.filter(|c| matches!(c, Component::Normal(_)))
.peekable();
if let Some(root) = root {
match comps.next() {
Some(Component::Normal(first)) if first == root => {}
_ => return None,
}
}
let rest: PathBuf = comps.collect();
(!rest.as_os_str().is_empty()).then_some(rest)
}
fn ensure_dir(p: &Path, made: &mut std::collections::HashSet<PathBuf>) -> Result<()> {
if made.contains(p) {
return Ok(());
}
std::fs::create_dir_all(p).map_err(Error::io(p))?;
let mut cur = Some(p);
while let Some(c) = cur {
if !made.insert(c.to_path_buf()) {
break; }
cur = c.parent();
}
Ok(())
}
#[cfg(windows)]
fn windows_unzip_tool_present() -> bool {
let in_path = |exe: &str| {
std::env::var_os("PATH")
.is_some_and(|path| std::env::split_paths(&path).any(|dir| dir.join(exe).exists()))
};
in_path("7z.exe")
|| std::path::Path::new("C:\\Program Files\\7-Zip\\7z.exe").exists()
|| in_path("unzip.exe")
}
#[cfg(windows)]
const WINDOWS_RESERVED: [&str; 22] = [
"con", "prn", "aux", "nul", "com1", "com2", "com3", "com4", "com5", "com6", "com7", "com8",
"com9", "lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9",
];
#[cfg(windows)]
fn windows_safe_component(name: &str) -> String {
let stem = name.split('.').next().unwrap_or(name);
let reserved = WINDOWS_RESERVED
.iter()
.any(|r| stem.eq_ignore_ascii_case(r));
let mut out = String::with_capacity(name.len() + 1);
if reserved {
out.push('_');
}
out.push_str(name);
let kept = out.trim_end_matches(['.', ' ']).len();
let replaced = out.len() - kept;
out.truncate(kept);
for _ in 0..replaced {
out.push('_');
}
out
}
fn on_disk(rel: &Path) -> PathBuf {
#[cfg(not(windows))]
{
rel.to_path_buf()
}
#[cfg(windows)]
{
rel.components()
.map(|c| match c {
Component::Normal(n) => {
std::ffi::OsString::from(windows_safe_component(&n.to_string_lossy()))
}
other => other.as_os_str().to_owned(),
})
.collect()
}
}
fn rel_display(p: &Path) -> String {
p.components()
.filter_map(|c| match c {
Component::Normal(n) => Some(n.to_string_lossy()),
_ => None,
})
.collect::<Vec<_>>()
.join("/")
}
fn read_within(
reader: &mut impl Read,
allowance: &mut u64,
hint: u64,
) -> std::io::Result<Option<Vec<u8>>> {
let mut buf = Vec::with_capacity(hint.min(*allowance) as usize);
let read = reader.by_ref().take(*allowance + 1).read_to_end(&mut buf)? as u64;
if read > *allowance {
return Ok(None);
}
*allowance -= read;
Ok(Some(buf))
}
pub fn extract_zip(zip_bytes: &[u8], dest: &Path) -> Result<()> {
extract_zip_with_limit(zip_bytes, dest, MAX_UNCOMPRESSED)
}
fn extract_zip_with_limit(zip_bytes: &[u8], dest: &Path, limit: u64) -> Result<()> {
let mut archive =
zip::ZipArchive::new(std::io::Cursor::new(zip_bytes)).map_err(Error::zip(dest))?;
let mut made: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
ensure_dir(dest, &mut made)?;
let attrs = central_attrs(zip_bytes);
let listing = zip_listing(&mut archive, &attrs, dest)?;
if let Some(link) = lone_top_level_symlink(&listing) {
return Err(Error::HostileArchive {
dest: dest.to_path_buf(),
reason: format!(
"the archive's whole content is the symlink {}, which Composer would install as a link in place of the package directory",
rel_display(&link)
),
});
}
let strip = root_strip_of(listing.iter().map(|(p, d, _)| (p.as_path(), *d)));
let mut dir_modes: Vec<(PathBuf, ModeRule)> = Vec::new();
let mut symlinked: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
let mut written: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
let mut allowance = limit;
let over = || Error::HostileArchive {
dest: dest.to_path_buf(),
reason: format!("uncompressed size > {limit} bytes"),
};
for i in 0..archive.len() {
let mut entry = archive.by_index(i).map_err(Error::zip(dest))?;
let raw = entry_path(&entry, dest)?;
let Some(stripped) = strip_root(&raw, strip.as_deref()) else {
continue;
};
if let Some(link) = stripped
.ancestors()
.skip(1)
.find(|a| !a.as_os_str().is_empty() && symlinked.contains(*a))
{
return Err(Error::HostileArchive {
dest: dest.to_path_buf(),
reason: format!(
"entry {} would be written through the symlink {}",
rel_display(&stripped),
rel_display(link)
),
});
}
if let Some(file) = stripped
.ancestors()
.skip(1)
.find(|a| !a.as_os_str().is_empty() && written.contains(*a))
{
return Err(Error::HostileArchive {
dest: dest.to_path_buf(),
reason: format!(
"entry {} would be written under {}, which the archive wrote as a file",
rel_display(&stripped),
rel_display(file)
),
});
}
let disk = on_disk(&stripped);
if made.contains(&dest.join(&disk)) && !entry.is_dir() {
return Err(Error::HostileArchive {
dest: dest.to_path_buf(),
reason: format!(
"entry {} is a file where the archive already made a directory",
rel_display(&stripped)
),
});
}
let out = dest.join(&disk);
let (host, ext) = attrs.get(entry.name_raw()).copied().unwrap_or_else(|| {
(
HOST_UNIX,
entry.unix_mode().map_or(0, |m| (m & 0o177777) << 16),
)
});
let rule = unzip_mode(host, ext);
let is_symlink = host == HOST_UNIX && (ext >> 16) & S_IFMT == S_IFLNK;
if entry.is_dir() {
ensure_dir(&out, &mut made)?;
dir_modes.push((out.clone(), rule));
} else if is_symlink {
let hint = entry.size();
let bytes = read_within(&mut entry, &mut allowance, hint)
.map_err(Error::io(&out))?
.ok_or_else(over)?;
let target = String::from_utf8(bytes).map_err(|_| Error::HostileArchive {
dest: dest.to_path_buf(),
reason: format!("symlink {} has a non-UTF-8 target", rel_display(&stripped)),
})?;
check_symlink_target(&stripped, &target, dest, &symlinked)?;
if let Some(p) = out.parent() {
ensure_dir(p, &mut made)?;
}
let _ = std::fs::remove_file(&out);
symlinked.insert(stripped.clone());
#[cfg(unix)]
crate::clone::symlink_like_unzip(std::path::Path::new(&target), &out)?;
#[cfg(windows)]
{
let linked = windows_unzip_tool_present()
&& std::os::windows::fs::symlink_file(&target, &out).is_ok();
if !linked {
std::fs::write(&out, target.as_bytes()).map_err(Error::io(&out))?;
}
}
#[cfg(not(any(unix, windows)))]
std::fs::write(&out, target.as_bytes()).map_err(Error::io(&out))?;
} else {
if let Some(p) = out.parent() {
ensure_dir(p, &mut made)?;
}
let hint = entry.size();
let buf = read_within(&mut entry, &mut allowance, hint)
.map_err(Error::io(&out))?
.ok_or_else(over)?;
#[cfg(windows)]
let _ = std::fs::remove_file(&out);
std::fs::write(&out, &buf).map_err(Error::io(&out))?;
apply_mode(&out, &rule)?;
written.insert(stripped.clone());
}
}
for (path, rule) in dir_modes.iter().rev() {
apply_mode(path, rule)?;
}
Ok(())
}
pub fn extract_tar(tgz_bytes: &[u8], dest: &Path) -> Result<()> {
extract_tar_with_limit(tgz_bytes, dest, MAX_UNCOMPRESSED)
}
fn extract_tar_with_limit(tgz_bytes: &[u8], dest: &Path, limit: u64) -> Result<()> {
let hostile = |reason: String| Error::HostileArchive {
dest: dest.to_path_buf(),
reason,
};
let path_of = |bytes: &[u8]| -> Result<PathBuf> {
#[cfg(unix)]
let p = {
use std::os::unix::ffi::OsStrExt as _;
PathBuf::from(std::ffi::OsStr::from_bytes(bytes))
};
#[cfg(not(unix))]
let p = PathBuf::from(
std::str::from_utf8(bytes)
.map_err(|_| hostile("entry path is not UTF-8".to_owned()))?,
);
if p.as_os_str().is_empty()
|| p.is_absolute()
|| !p
.components()
.all(|c| matches!(c, Component::Normal(_) | Component::CurDir))
{
return Err(hostile(format!("invalid entry path: {p:?}")));
}
Ok(p)
};
struct Entry {
path: PathBuf,
kind: tar::EntryType,
#[cfg_attr(not(unix), allow(dead_code))]
mode: u32,
data: Vec<u8>,
}
let mut entries: Vec<Entry> = Vec::new();
let mut allowance = limit;
let decoder = flate2::read::GzDecoder::new(tgz_bytes);
let mut archive = tar::Archive::new(decoder);
let iter = archive.entries().map_err(Error::io(dest))?;
for entry in iter {
let mut entry = entry.map_err(Error::io(dest))?;
let kind = entry.header().entry_type();
let path = {
let raw = entry.path_bytes();
path_of(&raw)?
};
let mode = entry.header().mode().map_err(Error::io(dest))? & 0o777;
let data = match kind {
tar::EntryType::Regular | tar::EntryType::Continuous => {
let hint = entry.size();
read_within(&mut entry, &mut allowance, hint)
.map_err(Error::io(dest))?
.ok_or_else(|| hostile(format!("uncompressed size > {limit} bytes")))?
}
tar::EntryType::Directory | tar::EntryType::Symlink | tar::EntryType::Link => {
Vec::new()
}
other => {
return Err(hostile(format!(
"unsupported entry type {other:?} for {}",
path.display()
)))
}
};
entries.push(Entry {
path,
kind,
mode,
data,
});
}
let strip = root_strip_of(
entries
.iter()
.map(|e| (e.path.as_path(), e.kind == tar::EntryType::Directory)),
);
let mut made: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
ensure_dir(dest, &mut made)?;
for e in &entries {
let Some(stripped) = strip_root(&e.path, strip.as_deref()) else {
continue;
};
let out = dest.join(on_disk(&stripped));
if e.kind == tar::EntryType::Directory {
ensure_dir(&out, &mut made)?;
continue;
}
if let Some(p) = out.parent() {
ensure_dir(p, &mut made)?;
}
std::fs::write(&out, &e.data).map_err(Error::io(&out))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
std::fs::set_permissions(&out, std::fs::Permissions::from_mode(e.mode))
.map_err(Error::io(&out))?;
}
}
Ok(())
}
fn check_symlink_target(
link_rel: &Path,
target: &str,
dest: &Path,
symlinked: &std::collections::HashSet<PathBuf>,
) -> Result<()> {
let hostile = |reason: String| Error::HostileArchive {
dest: dest.to_path_buf(),
reason,
};
let target_path = Path::new(target);
if target_path.is_absolute() {
return Err(hostile(format!(
"absolute symlink: {link_rel:?} -> {target}"
)));
}
let mut parts: Vec<std::ffi::OsString> = link_rel
.components()
.filter_map(|c| match c {
Component::Normal(n) => Some(n.to_owned()),
_ => None,
})
.collect();
parts.pop(); let comps: Vec<Component<'_>> = target_path.components().collect();
for (i, c) in comps.iter().enumerate() {
match c {
Component::ParentDir => {
if parts.pop().is_none() {
return Err(hostile(format!(
"symlink escaping the archive: {link_rel:?} -> {target}"
)));
}
}
Component::Normal(n) => {
parts.push((*n).to_owned());
if i + 1 < comps.len() {
let crossed: PathBuf = parts.iter().collect();
if symlinked.contains(&crossed) {
return Err(hostile(format!(
"symlink {link_rel:?} -> {target} passes through the symlink {}",
crossed.display()
)));
}
}
}
Component::CurDir => {}
_ => {
return Err(hostile(format!(
"invalid symlink: {link_rel:?} -> {target}"
)))
}
}
}
Ok(())
}
#[cfg(test)]
mod tar_tests {
use super::*;
fn tgz(entries: &[(&str, tar::EntryType, u32, &[u8])]) -> Vec<u8> {
let mut b = tar::Builder::new(Vec::new());
for (path, kind, mode, data) in entries {
let mut h = tar::Header::new_gnu();
h.set_entry_type(*kind);
h.set_mode(*mode);
h.set_size(data.len() as u64);
h.set_mtime(0);
match kind {
tar::EntryType::Symlink | tar::EntryType::Link => {
b.append_link(&mut h, path, "target").expect("link")
}
_ if path.contains("..") || path.starts_with('/') => {
let gnu = h.as_gnu_mut().expect("gnu");
gnu.name[..path.len()].copy_from_slice(path.as_bytes());
h.set_cksum();
b.append(&h, *data).expect("raw")
}
_ => b.append_data(&mut h, path, *data).expect("data"),
}
}
let raw = b.into_inner().expect("tar");
let mut e = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
std::io::Write::write_all(&mut e, &raw).expect("gz");
e.finish().expect("gz")
}
#[test]
fn strips_single_root_keeps_modes_and_flattens_links() {
use tar::EntryType as T;
let d = tempfile::tempdir().expect("tmp");
let out = d.path().join("out");
extract_tar(
&tgz(&[
("package/a.txt", T::Regular, 0o664, b"a"),
("package/bin/x", T::Regular, 0o755, b"#!"),
("package/dir/", T::Directory, 0o700, b""),
("package/link", T::Symlink, 0o777, b""),
]),
&out,
)
.expect("extract");
assert_eq!(std::fs::read(out.join("a.txt")).expect("a"), b"a");
assert!(out.join("dir").is_dir());
let link = std::fs::symlink_metadata(out.join("link")).expect("link");
assert!(
link.is_file() && link.len() == 0,
"a link becomes an empty file"
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
let mode = |p: &str| {
std::fs::metadata(out.join(p))
.expect(p)
.permissions()
.mode()
& 0o7777
};
assert_eq!(mode("a.txt"), 0o664);
assert_eq!(mode("bin/x"), 0o755);
assert_eq!(mode("link"), 0o777);
assert_ne!(mode("dir"), 0o700, "directory modes are not applied");
}
}
#[test]
fn refuses_escapes_and_devices() {
use tar::EntryType as T;
let d = tempfile::tempdir().expect("tmp");
for (path, kind) in [
("package/../escape", T::Regular),
("/abs", T::Regular),
("package/dev", T::Char),
] {
let r = extract_tar(&tgz(&[(path, kind, 0o644, b"x")]), &d.path().join("o"));
assert!(
matches!(r, Err(Error::HostileArchive { .. })),
"{path} should be refused: {r:?}"
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write as _;
use zip::write::SimpleFileOptions;
fn build_zip(entries: &[(&str, &[u8], Option<u32>)]) -> Vec<u8> {
let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, content, mode) in entries {
let mut opts = SimpleFileOptions::default();
if let Some(m) = mode {
opts = opts.unix_permissions(*m);
}
if name.ends_with('/') {
w.add_directory(name.trim_end_matches('/'), opts)
.expect("dir");
} else {
w.start_file(*name, opts).expect("start");
w.write_all(content).expect("write");
}
}
w.finish().expect("finish").into_inner()
}
fn forge_declared_size(zip: &mut [u8], real: u32, forged: u32) {
let mut patched = 0;
for (sig, off) in [
(b"PK\x03\x04".as_slice(), 22usize),
(b"PK\x01\x02".as_slice(), 24),
] {
let mut i = 0;
while let Some(pos) = zip[i..].windows(4).position(|w| w == sig).map(|p| i + p) {
let cur = u32::from_le_bytes(zip[pos + off..pos + off + 4].try_into().expect("4"));
if cur == real {
zip[pos + off..pos + off + 4].copy_from_slice(&forged.to_le_bytes());
patched += 1;
}
i = pos + 4;
}
}
assert_eq!(patched, 2, "the two size fields of the entry");
}
#[test]
fn a_zip_that_lies_about_its_size_is_still_bounded() {
let payload = vec![b'z'; 4096];
let mut zip = build_zip(&[("pkg/big.bin", &payload, Some(0o644))]);
forge_declared_size(&mut zip, 4096, 1);
let d = tmpdir();
let err = extract_zip_with_limit(&zip, d.path(), 1024).expect_err("refused");
assert!(
format!("{err}").contains("uncompressed size"),
"unexpected error: {err}"
);
assert!(!d.path().join("big.bin").exists());
}
#[test]
fn a_zip_of_many_small_entries_shares_one_budget() {
let payload = vec![b'x'; 400];
let zip = build_zip(&[
("pkg/a", &payload, Some(0o644)),
("pkg/b", &payload, Some(0o644)),
("pkg/c", &payload, Some(0o644)),
("pkg/d", &payload, Some(0o644)),
]);
let d = tmpdir();
let err = extract_zip_with_limit(&zip, d.path(), 1024).expect_err("refused");
assert!(format!("{err}").contains("uncompressed size"), "{err}");
}
#[test]
fn an_honest_zip_just_under_the_budget_is_extracted() {
let payload = vec![b'y'; 1000];
let zip = build_zip(&[("pkg/ok.bin", &payload, Some(0o644))]);
let d = tmpdir();
extract_zip_with_limit(&zip, d.path(), 1024).expect("extracted");
assert_eq!(
std::fs::read(d.path().join("ok.bin")).expect("read").len(),
1000
);
}
#[test]
fn a_tar_that_lies_in_its_header_is_still_bounded() {
const REAL: usize = 4096;
let data = vec![b't'; REAL];
let mut builder = tar::Builder::new(Vec::new());
let body = format!("size={REAL}\n");
let mut total = body.len() + 3;
while total.to_string().len() + 1 + body.len() != total {
total += 1;
}
let record = format!("{total} {body}");
assert_eq!(record.len(), total, "a pax record counts its own length");
let mut px = tar::Header::new_ustar();
px.set_entry_type(tar::EntryType::XHeader);
px.set_mode(0o644);
px.set_mtime(0);
px.set_size(record.len() as u64);
px.set_path("PaxHeaders/big.bin").expect("pax path");
px.set_cksum();
builder.append(&px, record.as_bytes()).expect("pax header");
let mut h = tar::Header::new_ustar();
h.set_entry_type(tar::EntryType::Regular);
h.set_mode(0o644);
h.set_mtime(0);
h.set_size(0);
h.set_path("pkg/big.bin").expect("path");
h.set_cksum();
builder.append(&h, data.as_slice()).expect("entry");
let tar_bytes = builder.into_inner().expect("tar");
let mut enc = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
enc.write_all(&tar_bytes).expect("gzip");
let tgz = enc.finish().expect("gzip");
{
let mut archive = tar::Archive::new(flate2::read::GzDecoder::new(tgz.as_slice()));
let entry = archive
.entries()
.expect("entries")
.next()
.expect("one entry")
.expect("entry");
assert_eq!(entry.header().size().expect("header size"), 0);
assert_eq!(entry.size(), REAL as u64);
}
let d = tmpdir();
let err = extract_tar_with_limit(&tgz, d.path(), 1024).expect_err("refused");
assert!(format!("{err}").contains("uncompressed size"), "{err}");
}
#[test]
fn an_archive_that_is_only_a_symlink_is_refused() {
let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
w.add_symlink("pkg", "..", SimpleFileOptions::default())
.expect("symlink");
let zip = w.finish().expect("finish").into_inner();
let d = tmpdir();
let err = extract_zip(&zip, d.path()).expect_err("refused");
assert!(
format!("{err}").contains("whole content is the symlink"),
"{err}"
);
let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
w.start_file(".DS_Store", SimpleFileOptions::default())
.expect("start");
w.write_all(b"junk").expect("write");
w.add_symlink("pkg", ".", SimpleFileOptions::default())
.expect("symlink");
let zip = w.finish().expect("finish").into_inner();
let d = tmpdir();
assert!(extract_zip(&zip, d.path()).is_err());
let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
w.start_file("pkg/real.txt", SimpleFileOptions::default())
.expect("start");
w.write_all(b"x").expect("write");
w.add_symlink("pkg/alias.txt", "real.txt", SimpleFileOptions::default())
.expect("symlink");
let zip = w.finish().expect("finish").into_inner();
let d = tmpdir();
extract_zip(&zip, d.path()).expect("laid out");
let alias = d.path().join("alias.txt");
let meta = alias.symlink_metadata().expect("meta");
#[cfg(unix)]
assert!(meta.file_type().is_symlink());
#[cfg(windows)]
assert!(
meta.file_type().is_symlink() || std::fs::read(&alias).expect("read") == b"real.txt",
"neither a link nor the target's bytes"
);
}
#[test]
fn a_file_and_a_path_under_it_are_refused_both_ways() {
let zip = build_zip(&[
("pkg/a", b"i am a file", Some(0o600)),
("pkg/a/b", b"and i am under it", Some(0o644)),
]);
let d = tmpdir();
let err = extract_zip(&zip, d.path()).expect_err("refused");
assert!(
format!("{err}").contains("entry a/b would be written under a"),
"{err}"
);
let zip = build_zip(&[
("pkg/a/b", b"under first", Some(0o644)),
("pkg/a", b"then the file", Some(0o600)),
]);
let d = tmpdir();
let err = extract_zip(&zip, d.path()).expect_err("refused");
assert!(
format!("{err}")
.contains("entry a is a file where the archive already made a directory"),
"{err}"
);
let zip = build_zip(&[("pkg/a/b", b"x", Some(0o644)), ("pkg/a/", b"", Some(0o755))]);
let d = tmpdir();
extract_zip(&zip, d.path()).expect("laid out");
assert!(d.path().join("a").is_dir());
}
#[cfg(windows)]
fn composers_tool() -> Option<(std::path::PathBuf, bool)> {
let in_path = |exe: &str| {
std::env::var_os("PATH").and_then(|path| {
std::env::split_paths(&path)
.map(|dir| dir.join(exe))
.find(|p| p.exists())
})
};
in_path("7z.exe")
.or_else(|| {
let p = std::path::PathBuf::from("C:\\Program Files\\7-Zip\\7z.exe");
p.exists().then_some(p)
})
.map(|p| (p, true))
.or_else(|| in_path("unzip.exe").map(|p| (p, false)))
}
#[cfg(windows)]
fn run_tool(tool: &(std::path::PathBuf, bool), file: &Path, out: &Path) -> Option<i32> {
use std::process::{Command, Stdio};
std::fs::create_dir_all(out).expect("mkdir");
let (exe, is_7z) = tool;
let mut cmd = Command::new(exe);
if *is_7z {
cmd.args(["x", "-bb0", "-y"])
.arg(file)
.arg(format!("-o{}", out.display()));
} else {
cmd.arg("-qq").arg(file).arg("-d").arg(out);
}
cmd.stdin(Stdio::null())
.status()
.ok()
.and_then(|s| s.code())
}
#[test]
#[cfg(windows)]
fn a_symlink_entry_follows_whatever_tool_composer_would_use() {
let zip = {
let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
w.start_file("pkg/real.txt", SimpleFileOptions::default())
.expect("start");
w.write_all(b"real").expect("write");
w.add_symlink(
"pkg/link.txt",
"real.txt",
SimpleFileOptions::default().unix_permissions(0o777),
)
.expect("symlink");
w.finish().expect("finish").into_inner()
};
let work = tmpdir();
let file = work.path().join("dist.zip");
std::fs::write(&file, &zip).expect("write zip");
let tool = composers_tool();
let may_link = {
let probe = work.path().join("probe");
std::os::windows::fs::symlink_file("target", &probe).is_ok()
};
let tool_made_link = match &tool {
Some(t) => {
let (exe, is_7z) = t;
let out = work.path().join("tool");
let status = run_tool(t, &file, &out);
let made = out.join("pkg/link.txt");
let meta = std::fs::symlink_metadata(&made);
let answer = meta
.as_ref()
.map(|m| m.file_type().is_symlink())
.unwrap_or(false);
eprintln!(
"tool {} (7z={}) exited {status:?}: pkg/link.txt is {}",
exe.display(),
is_7z,
match meta {
Ok(m) if m.file_type().is_symlink() => "a symlink".to_owned(),
Ok(_) => format!(
"a regular file holding {:?}",
std::fs::read_to_string(&made).unwrap_or_default()
),
Err(e) => format!("absent ({e})"),
}
);
answer
}
None => {
eprintln!("no 7z and no unzip on this machine: Composer would use ZipArchive, which never makes a link");
false
}
};
eprintln!("this process may create links: {may_link}");
let ours = work.path().join("ours");
extract_zip(&zip, &ours).expect("extract_zip");
let link = ours.join("link.txt");
let meta = std::fs::symlink_metadata(&link).expect("meta");
if tool_made_link && may_link {
assert!(
meta.file_type().is_symlink(),
"the tool made a link and links are allowed, so we must make one"
);
assert_eq!(
std::fs::read_link(&link).expect("readlink"),
std::path::Path::new("real.txt")
);
} else {
assert!(
!meta.file_type().is_symlink(),
"no link was possible, so the entry must be a plain file"
);
assert_eq!(std::fs::read(&link).expect("read"), b"real.txt");
}
}
#[cfg(windows)]
fn tree_listing(root: &Path) -> Vec<String> {
fn walk(root: &Path, dir: &Path, out: &mut Vec<String>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for e in entries.flatten() {
let p = e.path();
out.push(
p.strip_prefix(root)
.unwrap_or(&p)
.to_string_lossy()
.replace('\\', "/"),
);
if p.is_dir() {
walk(root, &p, out);
}
}
}
let mut v = Vec::new();
walk(root, root, &mut v);
v.sort();
v
}
#[cfg(windows)]
fn trees_of(names: &[&str], work: &Path) -> Option<(Option<i32>, Vec<String>, Vec<String>)> {
let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for n in names {
w.start_file(
format!("pkg/{n}"),
SimpleFileOptions::default().unix_permissions(0o644),
)
.expect("start");
w.write_all(n.as_bytes()).expect("write");
}
let zip = w.finish().expect("finish").into_inner();
let file = work.join("dist.zip");
std::fs::write(&file, &zip).expect("write zip");
let tool = composers_tool()?;
let out = work.join("tool");
let code = run_tool(&tool, &file, &out);
let ours = work.join("ours");
let got = extract_zip(&zip, &ours);
eprintln!("tool exited {code:?}; extract_zip said {got:?}");
let (theirs, mine) = (tree_listing(&out.join("pkg")), tree_listing(&ours));
eprintln!(" tool laid out: {theirs:?}");
eprintln!(" vivacity laid out: {mine:?}");
if code != Some(0) {
assert!(
got.is_err(),
"the tool refused the archive, so the install must fail here too"
);
} else {
got.expect("the tool laid it out, so must we");
}
Some((code, theirs, mine))
}
#[test]
#[cfg(windows)]
fn windows_reserved_names_follow_the_tool() {
let work = tmpdir();
let Some((code, theirs, mine)) = trees_of(
&[
"CON",
"CON.txt",
"COM1",
"LPT1",
"PRN",
"AUX",
"NUL",
"trailing.",
"trailing ",
"two..",
"ok.txt",
],
work.path(),
) else {
eprintln!(
"no 7z and no unzip: Composer would use ZipArchive, out of this test's reach"
);
return;
};
assert_eq!(code, Some(0), "the tool is expected to rename, not refuse");
assert_eq!(theirs, mine, "the two trees differ");
}
#[test]
#[cfg(windows)]
fn windows_a_reserved_name_as_a_directory_follows_the_tool() {
let work = tmpdir();
let Some((code, _, _)) = trees_of(&["CON", "CON/inside.txt"], work.path()) else {
eprintln!("no 7z and no unzip: out of this test's reach");
return;
};
assert_ne!(
code,
Some(0),
"the tool is expected to hit its own checkdir error here"
);
}
#[test]
#[cfg(windows)]
fn windows_two_reserved_names_differing_only_in_case() {
let work = tmpdir();
let Some((_, theirs, mine)) = trees_of(&["CON", "con"], work.path()) else {
eprintln!("no 7z and no unzip: out of this test's reach");
return;
};
assert_eq!(theirs, mine, "the two trees differ");
}
#[test]
#[cfg(windows)]
fn a_path_longer_than_max_path_follows_the_tool() {
let deep = format!("pkg/{}/f.txt", vec!["d".repeat(40); 8].join("/"));
assert!(
deep.len() > 260,
"the path must exceed MAX_PATH ({})",
deep.len()
);
let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for name in ["pkg/ok.txt", deep.as_str()] {
w.start_file(name, SimpleFileOptions::default().unix_permissions(0o644))
.expect("start");
w.write_all(b"x").expect("write");
}
let zip = w.finish().expect("finish").into_inner();
let work = tmpdir();
let file = work.path().join("dist.zip");
std::fs::write(&file, &zip).expect("write zip");
let Some(tool) = composers_tool() else {
eprintln!("no 7z and no unzip: out of this test's reach");
return;
};
let out = work.path().join("tool");
let code = run_tool(&tool, &file, &out);
let ours = work.path().join("ours");
let got = extract_zip(&zip, &ours);
let theirs = out.join(&deep).exists();
eprintln!("tool exited {code:?} (deep entry laid out: {theirs}); extract_zip said {got:?}");
if code == Some(0) && theirs {
got.expect("the tool laid the long path out, so must we");
assert!(
ours.join(deep.trim_start_matches("pkg/")).exists(),
"the long path is missing on our side"
);
} else {
assert!(
got.is_err(),
"the tool could not lay the long path out, so the install must fail here too"
);
}
}
fn tmpdir() -> tempfile::TempDir {
tempfile::tempdir().expect("tmpdir")
}
fn build_mixed(entries: &[(&str, &str, bool)]) -> Vec<u8> {
let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, payload, link) in entries {
if *link {
w.add_symlink(*name, *payload, SimpleFileOptions::default())
.expect("symlink");
} else {
w.start_file(*name, SimpleFileOptions::default())
.expect("start");
w.write_all(payload.as_bytes()).expect("write");
}
}
w.finish().expect("finish").into_inner()
}
#[test]
fn a_chain_of_symlinks_cannot_escape() {
let d = tmpdir();
let dest = d.path().join("pkg");
let zip = build_mixed(&[
("pkg/a", ".", true),
("pkg/a/b", "..", true),
("pkg/a/b/pwned.txt", "ESCAPED", false),
]);
let err = extract_zip(&zip, &dest).expect_err("must be refused");
assert!(
format!("{err}").contains("through the symlink"),
"unexpected error: {err}"
);
assert!(
!d.path().join("pwned.txt").exists(),
"a file was written outside the extraction directory"
);
}
#[test]
fn a_symlink_target_may_not_cross_a_symlink_of_the_archive() {
let d = tmpdir();
let dest = d.path().join("pkg");
let zip = build_mixed(&[("pkg/a", ".", true), ("pkg/z", "a/../evil", true)]);
let err = extract_zip(&zip, &dest).expect_err("must be refused");
assert!(
format!("{err}").contains("passes through the symlink"),
"unexpected error: {err}"
);
}
#[test]
fn a_plain_symlink_inside_the_package_still_works() {
let d = tmpdir();
let dest = d.path().join("pkg");
let zip = build_mixed(&[
("pkg/src/Real.php", "<?php // real", false),
("pkg/src/Alias.php", "Real.php", true),
]);
extract_zip(&zip, &dest).expect("a legitimate link is extracted");
let alias = dest.join("src/Alias.php");
let meta = std::fs::symlink_metadata(&alias).expect("alias");
if meta.file_type().is_symlink() {
assert_eq!(
std::fs::read_to_string(&alias).expect("read through the link"),
"<?php // real"
);
} else {
assert_eq!(
std::fs::read_to_string(&alias).expect("read the file"),
"Real.php"
);
}
}
#[test]
fn strips_root_and_preserves_exec_bit() {
let zip = build_zip(&[
("root-abc/", b"", None),
("root-abc/src/a.php", b"<?php", None),
(
"root-abc/bin/tool",
b"#!/usr/bin/env php\n<?php",
Some(0o100755),
),
]);
let d = tmpdir();
extract_zip(&zip, d.path()).expect("extract");
assert!(d.path().join("src/a.php").is_file());
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
let mode = std::fs::metadata(d.path().join("bin/tool"))
.expect("meta")
.permissions()
.mode();
assert_eq!(mode & 0o111, 0o111, "executable bit lost");
}
}
#[test]
fn strips_only_a_single_top_level_directory() {
let single = build_zip(&[("pkg/a.txt", b"a", None), ("pkg/sub/b.txt", b"b", None)]);
let d = tmpdir();
extract_zip(&single, d.path()).expect("extract");
assert!(d.path().join("a.txt").is_file() && d.path().join("sub/b.txt").is_file());
let mixed = build_zip(&[("README", b"r", None), ("src/a.php", b"<?php", None)]);
let d = tmpdir();
extract_zip(&mixed, d.path()).expect("extract");
assert!(d.path().join("README").is_file(), "root file lost");
assert!(
d.path().join("src/a.php").is_file(),
"directory wrongly flattened"
);
let two = build_zip(&[("a/x", b"x", None), ("b/y", b"y", None)]);
let d = tmpdir();
extract_zip(&two, d.path()).expect("extract");
assert!(d.path().join("a/x").is_file() && d.path().join("b/y").is_file());
let file = build_zip(&[("only.txt", b"o", None)]);
let d = tmpdir();
extract_zip(&file, d.path()).expect("extract");
assert!(d.path().join("only.txt").is_file(), "single file lost");
let ds = build_zip(&[(".DS_Store", b"junk", None), ("pkg/a.txt", b"a", None)]);
let d = tmpdir();
extract_zip(&ds, d.path()).expect("extract");
assert!(d.path().join("a.txt").is_file());
assert!(!d.path().join(".DS_Store").exists());
let ds2 = build_zip(&[(".DS_Store", b"junk", None), ("a.txt", b"a", None)]);
let d = tmpdir();
extract_zip(&ds2, d.path()).expect("extract");
assert!(d.path().join("a.txt").is_file() && d.path().join(".DS_Store").is_file());
}
fn build_zip_with_symlink(link_name: &str, target: &str) -> Vec<u8> {
let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
w.add_directory("r", SimpleFileOptions::default())
.expect("dir");
w.start_file("r/real.txt", SimpleFileOptions::default())
.expect("start");
w.write_all(b"x").expect("write");
w.add_symlink(link_name, target, SimpleFileOptions::default())
.expect("symlink");
w.finish().expect("finish").into_inner()
}
#[test]
fn valid_symlink_is_recreated_hostile_ones_rejected() {
let ok = build_zip_with_symlink("r/sub/link", "../real.txt");
let d = tmpdir();
extract_zip(&ok, d.path()).expect("extract");
let meta = d.path().join("sub/link").symlink_metadata().expect("meta");
#[cfg(unix)]
assert!(meta.file_type().is_symlink());
#[cfg(not(unix))]
{
if meta.file_type().is_symlink() {
assert_eq!(
std::fs::read_link(d.path().join("sub/link")).expect("target"),
std::path::PathBuf::from("../real.txt")
);
} else {
assert!(meta.file_type().is_file());
assert_eq!(
std::fs::read(d.path().join("sub/link")).expect("read"),
b"../real.txt"
);
}
}
for target in ["../../etc/passwd", "/etc/passwd", "../../../x"] {
let bad = build_zip_with_symlink("r/link", target);
let d = tmpdir();
assert!(
extract_zip(&bad, d.path()).is_err(),
"hostile symlink accepted: {target}"
);
}
}
#[test]
fn zip_slip_paths_are_rejected() {
let benign = build_zip(&[("r/", b"", None), ("r/AA/evil.txt", b"x", None)]);
let patched: Vec<u8> = {
let needle = b"r/AA/evil.txt";
let replacement = b"r/../evil.txt";
let mut bytes = benign.clone();
let mut i = 0;
while i + needle.len() <= bytes.len() {
if &bytes[i..i + needle.len()] == needle {
bytes[i..i + needle.len()].copy_from_slice(replacement);
}
i += 1;
}
bytes
};
assert_ne!(benign, patched, "the patch replaced nothing");
let d = tmpdir();
assert!(
extract_zip(&patched, d.path()).is_err(),
"zip-slip accepted"
);
}
#[test]
fn bzip2_entry_decompresses_through_the_pure_rust_backend() {
let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let opts = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Bzip2);
w.start_file("pkg/src/a.php", opts).expect("start");
let body = "<?php // enough bytes for a real bzip2 block\n".repeat(200);
w.write_all(body.as_bytes()).expect("write");
let bytes = w.finish().expect("finish").into_inner();
let d = tmpdir();
extract_zip(&bytes, d.path()).expect("extract");
let out = std::fs::read_to_string(d.path().join("src/a.php")).expect("read");
assert_eq!(out, body);
}
}
pub fn read_zip_entry(zip_bytes: &[u8], rel: &str) -> Result<Option<Vec<u8>>> {
let dest = Path::new("<archive>");
let mut archive =
zip::ZipArchive::new(std::io::Cursor::new(zip_bytes)).map_err(Error::zip(dest))?;
let mut entries: Vec<(PathBuf, bool, u32)> = Vec::with_capacity(archive.len());
for i in 0..archive.len() {
let entry = archive.by_index(i).map_err(Error::zip(dest))?;
entries.push((
entry_path(&entry, dest)?,
entry.is_dir(),
entry.unix_mode().unwrap_or(0),
));
}
let strip = root_strip_of(entries.iter().map(|(p, d, _)| (p.as_path(), *d)));
let mut by_path: Vec<(Vec<std::ffi::OsString>, usize, bool)> = Vec::new();
for (i, (path, dir, mode)) in entries.iter().enumerate() {
if *dir {
continue;
}
let Some(comps) = stripped(path, strip.as_deref()) else {
continue;
};
let link = mode & S_IFMT == S_IFLNK;
if let Some(slot) = by_path.iter_mut().find(|(c, _, _)| *c == comps) {
*slot = (comps, i, link);
} else {
by_path.push((comps, i, link));
}
}
let find = |wanted: &[std::ffi::OsString]| -> Option<(usize, bool)> {
by_path
.iter()
.find(|(c, _, _)| c == wanted)
.or_else(|| {
by_path
.iter()
.find(|(c, _, _)| same_ignoring_case(c, wanted))
})
.map(|(_, i, link)| (*i, *link))
};
let mut wanted = components(Path::new(rel));
for _ in 0..8 {
let Some((index, link)) = find(&wanted) else {
return Ok(None);
};
let bytes = read_zip_index(&mut archive, index, dest, rel)?;
if !link {
return Ok(Some(bytes));
}
let Ok(target) = String::from_utf8(bytes) else {
return Ok(None);
};
let Some(next) = resolve_link(&wanted, &target) else {
return Ok(None);
};
wanted = next;
}
Ok(None)
}
fn read_zip_index(
archive: &mut zip::ZipArchive<std::io::Cursor<&[u8]>>,
index: usize,
dest: &Path,
rel: &str,
) -> Result<Vec<u8>> {
let mut entry = archive.by_index(index).map_err(Error::zip(dest))?;
let hint = entry.size();
let mut allowance = MAX_UNCOMPRESSED;
read_within(&mut entry, &mut allowance, hint)
.map_err(Error::io(dest))?
.ok_or_else(|| Error::HostileArchive {
dest: dest.to_path_buf(),
reason: format!("entry {rel} is absurdly large"),
})
}
fn resolve_link(link: &[std::ffi::OsString], target: &str) -> Option<Vec<std::ffi::OsString>> {
let target = Path::new(target);
if target.is_absolute() {
return None;
}
let mut out: Vec<std::ffi::OsString> = link[..link.len().saturating_sub(1)].to_vec();
for c in target.components() {
match c {
Component::Normal(n) => out.push(n.to_owned()),
Component::CurDir => {}
Component::ParentDir => {
out.pop()?;
}
_ => return None,
}
}
(!out.is_empty()).then_some(out)
}
pub fn read_tar_entry(tgz_bytes: &[u8], rel: &str) -> Result<Option<Vec<u8>>> {
let dest = Path::new("<archive>");
let wanted = components(Path::new(rel));
let mut paths: Vec<(PathBuf, bool)> = Vec::new();
let mut found: Vec<(PathBuf, Vec<u8>)> = Vec::new();
let decoder = flate2::read::GzDecoder::new(tgz_bytes);
let mut archive = tar::Archive::new(decoder);
let mut allowance = MAX_UNCOMPRESSED;
for entry in archive.entries().map_err(Error::io(dest))? {
let mut entry = entry.map_err(Error::io(dest))?;
let kind = entry.header().entry_type();
let path = {
let raw = entry.path_bytes();
let text = std::str::from_utf8(&raw).map_err(|_| Error::HostileArchive {
dest: dest.to_path_buf(),
reason: "entry path is not UTF-8".to_owned(),
})?;
let p = Path::new(text);
if p.is_absolute()
|| !p
.components()
.all(|c| matches!(c, Component::Normal(_) | Component::CurDir))
{
return Err(Error::HostileArchive {
dest: dest.to_path_buf(),
reason: format!("invalid entry path: {text:?}"),
});
}
p.to_path_buf()
};
paths.push((path.clone(), kind.is_dir()));
if !kind.is_file() {
continue;
}
if matches_stripped(&path, 0, &wanted) || matches_stripped(&path, 1, &wanted) {
let hint = entry.size();
let buf = read_within(&mut entry, &mut allowance, hint)
.map_err(Error::io(dest))?
.ok_or_else(|| Error::HostileArchive {
dest: dest.to_path_buf(),
reason: "decompressed size beyond the limit".to_owned(),
})?;
found.push((path, buf));
}
}
let strip = root_strip_of(paths.iter().map(|(p, d)| (p.as_path(), *d)));
let pick = |exact: bool| {
found.iter().rev().find(|(p, _)| {
stripped(p, strip.as_deref()).is_some_and(|c| {
if exact {
c == wanted
} else {
same_ignoring_case(&c, &wanted)
}
})
})
};
Ok(pick(true).or_else(|| pick(false)).map(|(_, b)| b.clone()))
}
fn stripped(path: &Path, root: Option<&std::ffi::OsStr>) -> Option<Vec<std::ffi::OsString>> {
let got = components(path);
match root {
None => (!got.is_empty()).then(|| got.clone()),
Some(root) => (got.len() > 1 && got[0] == root).then(|| got[1..].to_vec()),
}
}
fn components(p: &Path) -> Vec<std::ffi::OsString> {
p.components()
.filter_map(|c| match c {
Component::Normal(n) => Some(n.to_owned()),
_ => None,
})
.collect()
}
fn same_ignoring_case(a: &[std::ffi::OsString], b: &[std::ffi::OsString]) -> bool {
a.len() == b.len()
&& a.iter()
.zip(b)
.all(|(x, y)| match (x.to_str(), y.to_str()) {
(Some(x), Some(y)) => x.eq_ignore_ascii_case(y),
_ => x == y,
})
}
fn matches_stripped(path: &Path, drop: usize, wanted: &[std::ffi::OsString]) -> bool {
let got = components(path);
got.len() > drop && {
let c = &got[drop..];
c == wanted || same_ignoring_case(c, wanted)
}
}