use std::borrow::Cow;
use crate::bytes::Reader;
use crate::error::{MalformedDetail, ResourceLimit};
use crate::formats::ParseLimits;
const CENTRAL_HEADER_SIGNATURE: [u8; 4] = [b'P', b'K', 0x01, 0x02];
const LOCAL_HEADER_SIGNATURE: [u8; 4] = [b'P', b'K', 0x03, 0x04];
const EOCD_SIGNATURE: [u8; 4] = [b'P', b'K', 0x05, 0x06];
const ZIP64_EOCD_SIGNATURE: [u8; 4] = [b'P', b'K', 0x06, 0x06];
const ZIP64_LOCATOR_SIGNATURE: [u8; 4] = [b'P', b'K', 0x06, 0x07];
const EOCD_FIXED_BYTES: usize = 22;
const ZIP64_LOCATOR_BYTES: usize = 20;
const MAX_EOCD_SEARCH: usize = EOCD_FIXED_BYTES.saturating_add(u16::MAX as usize);
const FLAG_ENCRYPTED: u16 = 1 << 0;
const FLAG_DATA_DESCRIPTOR: u16 = 1 << 3;
const ZIP64_EXTRA_ID: u16 = 0x0001;
const ZIP64_SENTINEL_32: u32 = 0xFFFF_FFFF;
const ZIP64_SENTINEL_16: u16 = 0xFFFF;
const DOS_EPOCH_DATE: u16 = 0x0021;
const DOS_EPOCH_TIME: u16 = 0x0000;
const VERSION_NEEDED: u16 = 20;
const MAX_EXPANSION_RATIO: u64 = 200;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Method {
Stored,
Deflate,
}
impl Method {
const fn code(self) -> u16 {
match self {
Self::Stored => 0,
Self::Deflate => 8,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ZipError {
Malformed {
detail: MalformedDetail,
offset: Option<u64>,
},
Limit(ResourceLimit),
}
impl ZipError {
fn at(detail: MalformedDetail, offset: usize) -> Self {
Self::Malformed {
detail,
offset: u64::try_from(offset).ok(),
}
}
const fn anywhere(detail: MalformedDetail) -> Self {
Self::Malformed {
detail,
offset: None,
}
}
pub(crate) fn into_strypt(self, format: crate::detect::Format) -> crate::error::StryptError {
match self {
Self::Malformed { detail, offset } => crate::error::StryptError::Malformed {
format,
offset,
detail,
},
Self::Limit(limit) => crate::error::StryptError::LimitExceeded { format, limit },
}
}
}
type ZipResult<T> = std::result::Result<T, ZipError>;
#[derive(Debug, Clone)]
pub(crate) struct Entry<'a> {
pub(crate) name: &'a [u8],
pub(crate) method: Method,
pub(crate) crc32: u32,
pub(crate) compressed: &'a [u8],
pub(crate) uncompressed_size: u64,
pub(crate) flags: u16,
pub(crate) modified: u32,
pub(crate) extra: &'a [u8],
}
impl<'a> Entry<'a> {
pub(crate) fn name_str(&self) -> Option<&'a str> {
std::str::from_utf8(self.name).ok()
}
pub(crate) fn is_directory(&self) -> bool {
self.name.last() == Some(&b'/')
}
pub(crate) fn contents(&self, budget: u64) -> ZipResult<Cow<'a, [u8]>> {
if self.uncompressed_size > budget {
return Err(ZipError::Limit(ResourceLimit::ExpandedSize));
}
let data: Cow<'a, [u8]> = match self.method {
Method::Stored => {
if u64::try_from(self.compressed.len()) != Ok(self.uncompressed_size) {
return Err(ZipError::anywhere(MalformedDetail::LengthOutOfRange));
}
Cow::Borrowed(self.compressed)
}
Method::Deflate => Cow::Owned(inflate_bounded(
self.compressed,
self.uncompressed_size,
budget,
)?),
};
if crc32fast::hash(data.as_ref()) != self.crc32 {
return Err(ZipError::anywhere(MalformedDetail::BrokenIndex));
}
Ok(data)
}
}
fn inflate_bounded(input: &[u8], expected: u64, budget: u64) -> ZipResult<Vec<u8>> {
let ratio_ceiling = u64::try_from(input.len())
.ok()
.and_then(|n| n.checked_mul(MAX_EXPANSION_RATIO))
.map_or(u64::MAX, |n| n.max(1024));
if expected > ratio_ceiling || expected > budget {
return Err(ZipError::Limit(ResourceLimit::ExpandedSize));
}
let capacity =
usize::try_from(expected).map_err(|_| ZipError::Limit(ResourceLimit::ExpandedSize))?;
let mut out: Vec<u8> = Vec::with_capacity(capacity);
let mut decompress = flate2::Decompress::new(false);
let status = decompress
.decompress_vec(input, &mut out, flate2::FlushDecompress::Finish)
.map_err(|_| ZipError::anywhere(MalformedDetail::Truncated))?;
if status != flate2::Status::StreamEnd {
return Err(ZipError::Limit(ResourceLimit::ExpandedSize));
}
if u64::try_from(out.len()) != Ok(expected) {
return Err(ZipError::anywhere(MalformedDetail::LengthOutOfRange));
}
Ok(out)
}
pub(crate) fn read<'a>(data: &'a [u8], limits: &ParseLimits) -> ZipResult<Vec<Entry<'a>>> {
let eocd = find_eocd(data)?;
let directory = locate_directory(data, &eocd)?;
if directory.entries > u64::from(limits.max_items) {
return Err(ZipError::Limit(ResourceLimit::ItemCount));
}
let count = usize::try_from(directory.entries)
.map_err(|_| ZipError::Limit(ResourceLimit::ItemCount))?;
let start = usize::try_from(directory.offset)
.map_err(|_| ZipError::at(MalformedDetail::LengthOutOfRange, 0))?;
let mut r = Reader::new(data);
r.seek(start)
.ok_or_else(|| ZipError::at(MalformedDetail::LengthOutOfRange, start))?;
let mut entries = Vec::new();
for _ in 0..count {
entries.push(read_central_header(data, &mut r)?);
}
Ok(entries)
}
struct Eocd {
offset: usize,
entries: u64,
directory_offset: u64,
}
struct Directory {
entries: u64,
offset: u64,
}
fn find_eocd(data: &[u8]) -> ZipResult<Eocd> {
let window_start = data.len().saturating_sub(MAX_EOCD_SEARCH);
let mut candidate = data.len().saturating_sub(EOCD_FIXED_BYTES);
loop {
if data.get(candidate..candidate.saturating_add(EOCD_SIGNATURE.len()))
== Some(EOCD_SIGNATURE.as_slice())
&& let Some(eocd) = parse_eocd(data, candidate)
{
return Ok(eocd);
}
if candidate <= window_start {
return Err(ZipError::anywhere(MalformedDetail::MissingMarker));
}
candidate = candidate.saturating_sub(1);
}
}
fn parse_eocd(data: &[u8], offset: usize) -> Option<Eocd> {
let mut r = Reader::new(data);
r.seek(offset)?;
r.skip(EOCD_SIGNATURE.len())?;
let this_disk = r.u16_le()?;
let directory_disk = r.u16_le()?;
let entries_here = r.u16_le()?;
let entries_total = r.u16_le()?;
let _directory_size = r.u32_le()?;
let directory_offset = r.u32_le()?;
let comment_len = r.u16_le()?;
if this_disk != 0 || directory_disk != 0 || entries_here != entries_total {
return None;
}
if usize::from(comment_len) != r.remaining() {
return None;
}
Some(Eocd {
offset,
entries: u64::from(entries_total),
directory_offset: u64::from(directory_offset),
})
}
fn locate_directory(data: &[u8], eocd: &Eocd) -> ZipResult<Directory> {
let needs_zip64 = eocd.entries == u64::from(ZIP64_SENTINEL_16)
|| eocd.directory_offset == u64::from(ZIP64_SENTINEL_32);
if !needs_zip64 {
return Ok(Directory {
entries: eocd.entries,
offset: eocd.directory_offset,
});
}
let locator_at = eocd
.offset
.checked_sub(ZIP64_LOCATOR_BYTES)
.ok_or_else(|| ZipError::at(MalformedDetail::MissingMarker, eocd.offset))?;
let mut r = Reader::new(data);
r.seek(locator_at)
.ok_or_else(|| ZipError::at(MalformedDetail::MissingMarker, locator_at))?;
if r.take(ZIP64_LOCATOR_SIGNATURE.len()) != Some(ZIP64_LOCATOR_SIGNATURE.as_slice()) {
return Err(ZipError::at(MalformedDetail::MissingMarker, locator_at));
}
r.skip(4)
.ok_or_else(|| ZipError::at(MalformedDetail::Truncated, locator_at))?;
let record_at =
read_u64_le(&mut r).ok_or_else(|| ZipError::at(MalformedDetail::Truncated, locator_at))?;
let record_at = usize::try_from(record_at)
.map_err(|_| ZipError::at(MalformedDetail::LengthOutOfRange, locator_at))?;
r.seek(record_at)
.ok_or_else(|| ZipError::at(MalformedDetail::LengthOutOfRange, locator_at))?;
if r.take(ZIP64_EOCD_SIGNATURE.len()) != Some(ZIP64_EOCD_SIGNATURE.as_slice()) {
return Err(ZipError::at(MalformedDetail::MissingMarker, record_at));
}
r.skip(8)
.and_then(|()| r.skip(4))
.and_then(|()| r.skip(8))
.ok_or_else(|| ZipError::at(MalformedDetail::Truncated, record_at))?;
let entries_here =
read_u64_le(&mut r).ok_or_else(|| ZipError::at(MalformedDetail::Truncated, record_at))?;
let entries_total =
read_u64_le(&mut r).ok_or_else(|| ZipError::at(MalformedDetail::Truncated, record_at))?;
let _directory_size =
read_u64_le(&mut r).ok_or_else(|| ZipError::at(MalformedDetail::Truncated, record_at))?;
let directory_offset =
read_u64_le(&mut r).ok_or_else(|| ZipError::at(MalformedDetail::Truncated, record_at))?;
if entries_here != entries_total {
return Err(ZipError::at(MalformedDetail::UnsupportedFeature, record_at));
}
Ok(Directory {
entries: entries_total,
offset: directory_offset,
})
}
fn read_central_header<'a>(data: &'a [u8], r: &mut Reader<'a>) -> ZipResult<Entry<'a>> {
let start = r.position();
if r.take(CENTRAL_HEADER_SIGNATURE.len()) != Some(CENTRAL_HEADER_SIGNATURE.as_slice()) {
return Err(ZipError::at(MalformedDetail::MissingMarker, start));
}
r.skip(4)
.ok_or_else(|| ZipError::at(MalformedDetail::Truncated, start))?;
let flags = r
.u16_le()
.ok_or_else(|| ZipError::at(MalformedDetail::Truncated, start))?;
if flags & FLAG_ENCRYPTED != 0 {
return Err(ZipError::at(MalformedDetail::UnsupportedFeature, start));
}
let method_code = r
.u16_le()
.ok_or_else(|| ZipError::at(MalformedDetail::Truncated, start))?;
let method = match method_code {
0 => Method::Stored,
8 => Method::Deflate,
_ => return Err(ZipError::at(MalformedDetail::UnsupportedFeature, start)),
};
let modified = r
.u32_le()
.ok_or_else(|| ZipError::at(MalformedDetail::Truncated, start))?;
let crc32 = r
.u32_le()
.ok_or_else(|| ZipError::at(MalformedDetail::Truncated, start))?;
let compressed_size = r
.u32_le()
.ok_or_else(|| ZipError::at(MalformedDetail::Truncated, start))?;
let uncompressed_size = r
.u32_le()
.ok_or_else(|| ZipError::at(MalformedDetail::Truncated, start))?;
let name_len = r
.u16_le()
.ok_or_else(|| ZipError::at(MalformedDetail::Truncated, start))?;
let extra_len = r
.u16_le()
.ok_or_else(|| ZipError::at(MalformedDetail::Truncated, start))?;
let comment_len = r
.u16_le()
.ok_or_else(|| ZipError::at(MalformedDetail::Truncated, start))?;
let disk = r
.u16_le()
.ok_or_else(|| ZipError::at(MalformedDetail::Truncated, start))?;
r.skip(6)
.ok_or_else(|| ZipError::at(MalformedDetail::Truncated, start))?;
let local_offset = r
.u32_le()
.ok_or_else(|| ZipError::at(MalformedDetail::Truncated, start))?;
let name = r
.take(usize::from(name_len))
.ok_or_else(|| ZipError::at(MalformedDetail::Truncated, start))?;
let extra = r
.take(usize::from(extra_len))
.ok_or_else(|| ZipError::at(MalformedDetail::Truncated, start))?;
r.skip(usize::from(comment_len))
.ok_or_else(|| ZipError::at(MalformedDetail::Truncated, start))?;
if disk != 0 && disk != ZIP64_SENTINEL_16 {
return Err(ZipError::at(MalformedDetail::UnsupportedFeature, start));
}
if !name_is_safe(name) {
return Err(ZipError::at(MalformedDetail::UnexpectedMarker, start));
}
let sizes = resolve_zip64_sizes(
extra,
compressed_size,
uncompressed_size,
local_offset,
start,
)?;
let compressed = slice_entry_data(data, &sizes, start)?;
Ok(Entry {
name,
method,
crc32,
compressed,
uncompressed_size: sizes.uncompressed,
flags,
modified,
extra,
})
}
pub(crate) const NORMALISED_DOS_DATETIME: u32 =
((DOS_EPOCH_DATE as u32) << 16) | (DOS_EPOCH_TIME as u32);
const HOST_METADATA_EXTRA_TAGS: [u16; 4] = [
0x5455, 0x000A, 0x7875, 0x5855,
];
pub(crate) fn extra_names_the_host(extra: &[u8]) -> bool {
let mut r = Reader::new(extra);
while r.remaining() >= 4 {
let Some(id) = r.u16_le() else { return false };
let Some(len) = r.u16_le() else { return false };
if r.skip(usize::from(len)).is_none() {
return false;
}
if HOST_METADATA_EXTRA_TAGS.contains(&id) {
return true;
}
}
false
}
struct Sizes {
compressed: u64,
uncompressed: u64,
local_offset: u64,
}
fn resolve_zip64_sizes(
extra: &[u8],
compressed: u32,
uncompressed: u32,
local_offset: u32,
start: usize,
) -> ZipResult<Sizes> {
let mut sizes = Sizes {
compressed: u64::from(compressed),
uncompressed: u64::from(uncompressed),
local_offset: u64::from(local_offset),
};
let needs = compressed == ZIP64_SENTINEL_32
|| uncompressed == ZIP64_SENTINEL_32
|| local_offset == ZIP64_SENTINEL_32;
if !needs {
return Ok(sizes);
}
let mut r = Reader::new(extra);
while r.remaining() >= 4 {
let id = r
.u16_le()
.ok_or_else(|| ZipError::at(MalformedDetail::Truncated, start))?;
let len = r
.u16_le()
.ok_or_else(|| ZipError::at(MalformedDetail::Truncated, start))?;
let block = r
.take(usize::from(len))
.ok_or_else(|| ZipError::at(MalformedDetail::LengthOutOfRange, start))?;
if id != ZIP64_EXTRA_ID {
continue;
}
let mut b = Reader::new(block);
if uncompressed == ZIP64_SENTINEL_32 {
sizes.uncompressed = read_u64_le(&mut b)
.ok_or_else(|| ZipError::at(MalformedDetail::Truncated, start))?;
}
if compressed == ZIP64_SENTINEL_32 {
sizes.compressed = read_u64_le(&mut b)
.ok_or_else(|| ZipError::at(MalformedDetail::Truncated, start))?;
}
if local_offset == ZIP64_SENTINEL_32 {
sizes.local_offset = read_u64_le(&mut b)
.ok_or_else(|| ZipError::at(MalformedDetail::Truncated, start))?;
}
return Ok(sizes);
}
Err(ZipError::at(MalformedDetail::BrokenIndex, start))
}
fn slice_entry_data<'a>(data: &'a [u8], sizes: &Sizes, start: usize) -> ZipResult<&'a [u8]> {
let local_at = usize::try_from(sizes.local_offset)
.map_err(|_| ZipError::at(MalformedDetail::LengthOutOfRange, start))?;
let mut r = Reader::new(data);
r.seek(local_at)
.ok_or_else(|| ZipError::at(MalformedDetail::LengthOutOfRange, start))?;
if r.take(LOCAL_HEADER_SIGNATURE.len()) != Some(LOCAL_HEADER_SIGNATURE.as_slice()) {
return Err(ZipError::at(MalformedDetail::BrokenIndex, local_at));
}
r.skip(22)
.ok_or_else(|| ZipError::at(MalformedDetail::Truncated, local_at))?;
let name_len = r
.u16_le()
.ok_or_else(|| ZipError::at(MalformedDetail::Truncated, local_at))?;
let extra_len = r
.u16_le()
.ok_or_else(|| ZipError::at(MalformedDetail::Truncated, local_at))?;
r.skip(usize::from(name_len))
.and_then(|()| r.skip(usize::from(extra_len)))
.ok_or_else(|| ZipError::at(MalformedDetail::Truncated, local_at))?;
let data_at = r.position();
let length = usize::try_from(sizes.compressed)
.map_err(|_| ZipError::at(MalformedDetail::LengthOutOfRange, start))?;
let end = data_at
.checked_add(length)
.ok_or_else(|| ZipError::at(MalformedDetail::LengthOutOfRange, data_at))?;
data.get(data_at..end)
.ok_or_else(|| ZipError::at(MalformedDetail::LengthOutOfRange, data_at))
}
fn name_is_safe(name: &[u8]) -> bool {
if name.is_empty() || name.len() > u16::MAX as usize {
return false;
}
if name.first() == Some(&b'/') {
return false;
}
if name.contains(&b'\\') {
return false;
}
if matches!(name.get(1), Some(&b':')) {
return false;
}
!name.split(|b| *b == b'/').any(|part| part == b"..")
}
fn read_u64_le(r: &mut Reader<'_>) -> Option<u64> {
let low = u64::from(r.u32_le()?);
let high = u64::from(r.u32_le()?);
high.checked_shl(32)?.checked_add(low)
}
struct CentralRecord<'a> {
offset: u32,
name: &'a [u8],
method: Method,
crc: u32,
compressed_len: u32,
uncompressed_len: u32,
flags: u16,
}
pub(crate) enum Output<'a> {
Copied(Entry<'a>),
Rewritten {
name: Vec<u8>,
data: Vec<u8>,
flags: u16,
},
}
#[expect(
clippy::too_many_lines,
reason = "the local headers, the central directory, and the end record are three views of \
one set of fields written in the order the specification lays them out; splitting \
them apart would put the three out of step, and an archive whose three copies of \
a length disagree is exactly what this module refuses on the way in"
)]
pub(crate) fn write(entries: &[Output<'_>]) -> ZipResult<Vec<u8>> {
let mut out: Vec<u8> = Vec::new();
let mut directory: Vec<CentralRecord<'_>> = Vec::with_capacity(entries.len());
for entry in entries {
let offset = u32::try_from(out.len())
.map_err(|_| ZipError::anywhere(MalformedDetail::LengthOutOfRange))?;
let (name, method, crc, payload, uncompressed, flags): (&[u8], _, _, &[u8], usize, u16) =
match entry {
Output::Copied(e) => {
let uncompressed = usize::try_from(e.uncompressed_size)
.map_err(|_| ZipError::anywhere(MalformedDetail::LengthOutOfRange))?;
(
e.name,
e.method,
e.crc32,
e.compressed,
uncompressed,
e.flags & !FLAG_DATA_DESCRIPTOR,
)
}
Output::Rewritten { name, data, flags } => (
name.as_slice(),
Method::Stored,
crc32fast::hash(data),
data.as_slice(),
data.len(),
*flags & !FLAG_DATA_DESCRIPTOR,
),
};
let name_len = u16::try_from(name.len())
.map_err(|_| ZipError::anywhere(MalformedDetail::LengthOutOfRange))?;
let compressed_len = u32::try_from(payload.len())
.map_err(|_| ZipError::anywhere(MalformedDetail::LengthOutOfRange))?;
let uncompressed_len = u32::try_from(uncompressed)
.map_err(|_| ZipError::anywhere(MalformedDetail::LengthOutOfRange))?;
out.extend_from_slice(&LOCAL_HEADER_SIGNATURE);
out.extend_from_slice(&VERSION_NEEDED.to_le_bytes());
out.extend_from_slice(&flags.to_le_bytes());
out.extend_from_slice(&method.code().to_le_bytes());
out.extend_from_slice(&DOS_EPOCH_TIME.to_le_bytes());
out.extend_from_slice(&DOS_EPOCH_DATE.to_le_bytes());
out.extend_from_slice(&crc.to_le_bytes());
out.extend_from_slice(&compressed_len.to_le_bytes());
out.extend_from_slice(&uncompressed_len.to_le_bytes());
out.extend_from_slice(&name_len.to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes());
out.extend_from_slice(name);
out.extend_from_slice(payload);
directory.push(CentralRecord {
offset,
name,
method,
crc,
compressed_len,
uncompressed_len,
flags,
});
}
let directory_offset = u32::try_from(out.len())
.map_err(|_| ZipError::anywhere(MalformedDetail::LengthOutOfRange))?;
for record in &directory {
let CentralRecord {
offset,
name,
method,
crc,
compressed_len,
uncompressed_len,
flags,
} = record;
let name_len = u16::try_from(name.len())
.map_err(|_| ZipError::anywhere(MalformedDetail::LengthOutOfRange))?;
out.extend_from_slice(&CENTRAL_HEADER_SIGNATURE);
out.extend_from_slice(&VERSION_NEEDED.to_le_bytes());
out.extend_from_slice(&VERSION_NEEDED.to_le_bytes());
out.extend_from_slice(&flags.to_le_bytes());
out.extend_from_slice(&method.code().to_le_bytes());
out.extend_from_slice(&DOS_EPOCH_TIME.to_le_bytes());
out.extend_from_slice(&DOS_EPOCH_DATE.to_le_bytes());
out.extend_from_slice(&crc.to_le_bytes());
out.extend_from_slice(&compressed_len.to_le_bytes());
out.extend_from_slice(&uncompressed_len.to_le_bytes());
out.extend_from_slice(&name_len.to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes());
out.extend_from_slice(&0u32.to_le_bytes());
out.extend_from_slice(&offset.to_le_bytes());
out.extend_from_slice(name);
}
let directory_size = u32::try_from(out.len().saturating_sub(directory_offset as usize))
.map_err(|_| ZipError::anywhere(MalformedDetail::LengthOutOfRange))?;
let count = u16::try_from(directory.len())
.map_err(|_| ZipError::anywhere(MalformedDetail::LengthOutOfRange))?;
out.extend_from_slice(&EOCD_SIGNATURE);
out.extend_from_slice(&0u16.to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes());
out.extend_from_slice(&count.to_le_bytes());
out.extend_from_slice(&count.to_le_bytes());
out.extend_from_slice(&directory_size.to_le_bytes());
out.extend_from_slice(&directory_offset.to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes());
Ok(out)
}
pub(crate) fn spend(budget: &mut u64, spent: u64) -> ZipResult<()> {
*budget = budget
.checked_sub(spent)
.ok_or(ZipError::Limit(ResourceLimit::ExpandedSize))?;
Ok(())
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::indexing_slicing)]
use super::*;
fn archive(parts: &[(&str, &[u8])]) -> Vec<u8> {
let outputs: Vec<Output<'_>> = parts
.iter()
.map(|(name, data)| Output::Rewritten {
name: name.as_bytes().to_vec(),
data: (*data).to_vec(),
flags: 0,
})
.collect();
write(&outputs).unwrap()
}
#[test]
fn a_written_archive_reads_back_with_the_same_entries() {
let bytes = archive(&[("a.xml", b"<a/>"), ("dir/b.bin", &[0u8; 32])]);
let entries = read(&bytes, &ParseLimits::default()).unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].name_str(), Some("a.xml"));
assert_eq!(
entries[0].contents(u64::MAX).unwrap().as_ref(),
b"<a/>".as_slice()
);
assert_eq!(entries[1].name_str(), Some("dir/b.bin"));
assert_eq!(entries[1].contents(u64::MAX).unwrap().len(), 32);
}
#[test]
fn writing_is_deterministic() {
let a = archive(&[("x", b"1"), ("y", b"2")]);
let b = archive(&[("x", b"1"), ("y", b"2")]);
assert_eq!(a, b);
}
#[test]
fn an_entry_timestamp_is_normalised_rather_than_preserved() {
let bytes = archive(&[("a", b"x")]);
assert_eq!(&bytes[10..12], DOS_EPOCH_TIME.to_le_bytes());
assert_eq!(&bytes[12..14], DOS_EPOCH_DATE.to_le_bytes());
}
#[test]
fn an_encrypted_entry_is_refused_not_skipped() {
let mut bytes = archive(&[("secret.xml", b"<a/>")]);
let dir = bytes
.windows(4)
.position(|w| w == CENTRAL_HEADER_SIGNATURE)
.unwrap();
bytes[dir + 8] |= 1;
assert!(matches!(
read(&bytes, &ParseLimits::default()),
Err(ZipError::Malformed {
detail: MalformedDetail::UnsupportedFeature,
..
})
));
}
#[test]
fn an_unsupported_compression_method_is_refused() {
let mut bytes = archive(&[("a.xml", b"<a/>")]);
let dir = bytes
.windows(4)
.position(|w| w == CENTRAL_HEADER_SIGNATURE)
.unwrap();
bytes[dir + 10..dir + 12].copy_from_slice(&93u16.to_le_bytes());
assert!(matches!(
read(&bytes, &ParseLimits::default()),
Err(ZipError::Malformed {
detail: MalformedDetail::UnsupportedFeature,
..
})
));
}
#[test]
fn traversal_and_absolute_names_are_refused() {
for name in ["../escape.xml", "/etc/passwd", "a\\b.xml", "C:/x.xml"] {
assert!(
!name_is_safe(name.as_bytes()),
"{name} should have been refused"
);
}
for name in ["word/media/image1.png", "[Content_Types].xml", "a..b/c"] {
assert!(name_is_safe(name.as_bytes()), "{name} is an ordinary name");
}
}
#[test]
fn a_file_with_no_end_record_is_refused() {
assert!(matches!(
read(b"PK\x03\x04 not really an archive", &ParseLimits::default()),
Err(ZipError::Malformed {
detail: MalformedDetail::MissingMarker,
..
})
));
}
#[test]
fn an_entry_count_beyond_the_ceiling_is_refused_before_allocating() {
let mut bytes = archive(&[("a", b"x")]);
let eocd = bytes.windows(4).rposition(|w| w == EOCD_SIGNATURE).unwrap();
bytes[eocd + 8..eocd + 10].copy_from_slice(&500u16.to_le_bytes());
bytes[eocd + 10..eocd + 12].copy_from_slice(&500u16.to_le_bytes());
let limits = ParseLimits {
max_items: 10,
..ParseLimits::default()
};
assert!(matches!(
read(&bytes, &limits),
Err(ZipError::Limit(ResourceLimit::ItemCount))
));
}
#[test]
fn a_crc_that_does_not_match_the_data_is_refused() {
let mut bytes = archive(&[("a.xml", b"<a/>")]);
let dir = bytes
.windows(4)
.position(|w| w == CENTRAL_HEADER_SIGNATURE)
.unwrap();
bytes[dir + 16..dir + 20].copy_from_slice(&0xDEAD_BEEFu32.to_le_bytes());
let entries = read(&bytes, &ParseLimits::default()).unwrap();
assert!(entries[0].contents(u64::MAX).is_err());
}
#[test]
fn a_stored_entry_whose_sizes_disagree_is_refused() {
let mut bytes = archive(&[("a.xml", b"<a/>")]);
let dir = bytes
.windows(4)
.position(|w| w == CENTRAL_HEADER_SIGNATURE)
.unwrap();
bytes[dir + 24..dir + 28].copy_from_slice(&99u32.to_le_bytes());
let entries = read(&bytes, &ParseLimits::default()).unwrap();
assert!(entries[0].contents(u64::MAX).is_err());
}
#[test]
fn a_declared_expansion_beyond_the_budget_is_refused_before_inflating() {
let entry = Entry {
name: b"bomb.xml",
method: Method::Deflate,
crc32: 0,
compressed: &[0x00; 64],
uncompressed_size: 1_000_000_000,
flags: 0,
modified: NORMALISED_DOS_DATETIME,
extra: &[],
};
assert!(matches!(
entry.contents(4096),
Err(ZipError::Limit(ResourceLimit::ExpandedSize))
));
}
#[test]
fn the_expansion_ratio_bounds_a_small_archive_with_a_large_budget() {
let entry = Entry {
name: b"bomb.xml",
method: Method::Deflate,
crc32: 0,
compressed: &[0x00; 64],
uncompressed_size: 64 * MAX_EXPANSION_RATIO + 1,
flags: 0,
modified: NORMALISED_DOS_DATETIME,
extra: &[],
};
assert!(matches!(
entry.contents(u64::MAX),
Err(ZipError::Limit(ResourceLimit::ExpandedSize))
));
}
#[test]
fn the_budget_refuses_rather_than_wrapping() {
let mut budget = 10u64;
assert!(spend(&mut budget, 4).is_ok());
assert_eq!(budget, 6);
assert!(matches!(
spend(&mut budget, 7),
Err(ZipError::Limit(ResourceLimit::ExpandedSize))
));
}
#[test]
fn truncating_a_valid_archive_anywhere_does_not_panic() {
let bytes = archive(&[("a.xml", b"<a/>"), ("b/c.bin", &[7u8; 100])]);
for n in 0..bytes.len() {
let prefix = bytes.get(0..n).unwrap_or_default();
if let Ok(entries) = read(prefix, &ParseLimits::default()) {
for entry in &entries {
let _ = entry.contents(1 << 20);
}
}
}
}
#[test]
fn a_directory_offset_pointing_outside_the_file_is_refused() {
let mut bytes = archive(&[("a", b"x")]);
let eocd = bytes.windows(4).rposition(|w| w == EOCD_SIGNATURE).unwrap();
bytes[eocd + 16..eocd + 20].copy_from_slice(&0x7FFF_0000u32.to_le_bytes());
assert!(read(&bytes, &ParseLimits::default()).is_err());
}
#[test]
fn a_stray_end_record_signature_in_the_data_does_not_derail_the_parse() {
let mut payload = EOCD_SIGNATURE.to_vec();
payload.extend_from_slice(&[0u8; 40]);
let bytes = archive(&[("a.bin", &payload)]);
let entries = read(&bytes, &ParseLimits::default()).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].name_str(), Some("a.bin"));
}
}