use std::collections::HashSet;
use std::io::BufReader;
use std::io::Read;
use std::io::Seek;
use std::io::SeekFrom;
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::MutexGuard;
use std::sync::PoisonError;
use zip::CompressionMethod;
use zip::ZipArchive as ZipReader;
use zip::ZipReadOptions;
use zip::result::ZipError;
use super::chunk_search::ChunkEnd;
use super::chunk_search::ChunkPlan;
use super::chunk_search::ChunkSource;
use super::chunk_search::Flow;
use super::chunk_search::search_chunks;
use super::common;
use super::zip::admit_zip_entry;
use super::zip::is_password_required;
use super::zip_tail::judge_first_signature;
use super::zip_tail::scan;
use crate::ArchiveError;
use crate::Result;
use crate::error::CentralRecordOrdinal;
use crate::error::EndRecordKind;
use crate::error::HiddenRegion;
use crate::error::ZipEntryIndex;
use crate::error::ZipHeaderField;
use crate::error::ZipInconsistency;
use crate::error::ZipUncomparable;
use crate::security::zipbomb::DeclaredCompressedBudget;
use resume::DESCRIPTOR_LOOKAHEAD;
use resume::DescriptorExit;
use resume::DescriptorWidth;
use resume::LocalFacts;
use resume::streaming_resume;
use xl_type::EntryKind;
use xl_type::HostSystem;
use xl_type::XlScan;
use xl_type::reader_kind;
const CDFH_SIGNATURE: [u8; 4] = *b"PK\x01\x02";
pub(super) const LFH_SIGNATURE: [u8; 4] = *b"PK\x03\x04";
pub(super) const DATA_DESCRIPTOR_SIGNATURE: [u8; 4] = *b"PK\x07\x08";
const EOCD_SIGNATURE: [u8; 4] = *b"PK\x05\x06";
const CDFH_FIXED_LEN: usize = 46;
pub(super) const LFH_FIXED_LEN: usize = 30;
const EOCD_FIXED_LEN: usize = 22;
const EOCD_SEARCH_WINDOW: u64 = EOCD_FIXED_LEN as u64 + 4 + 65_536;
const ZIP64_LOCATOR_SIGNATURE: [u8; 4] = *b"PK\x06\x07";
const ZIP64_LOCATOR_LEN: usize = 20;
const ZIP64_END_SIGNATURE: [u8; 4] = *b"PK\x06\x06";
const ZIP64_END_FIXED_LEN: usize = 56;
const ZIP64_END_DIRECTORY_AT: usize = 48;
const ZIP64_EXTRA_ID: u16 = 0x0001;
const HEADER_INCONSISTENCY_PREFIX: &str = "header inconsistency detected: ";
const FIELD_MESSAGES: [(&str, ZipHeaderField); 9] = [
(
"Local file header filename mismatch",
ZipHeaderField::FileName,
),
(
"UnicodePath (0x7075) extra field mismatch",
ZipHeaderField::UnicodePath,
),
(
"compression method mismatch",
ZipHeaderField::CompressionMethod,
),
("encryption flag mismatch", ZipHeaderField::EncryptionFlag),
(
"data descriptor flag mismatch",
ZipHeaderField::DataDescriptorFlag,
),
("last modified time mismatch", ZipHeaderField::ModifiedTime),
("CRC32 checksum mismatch", ZipHeaderField::Crc32),
("compressed size mismatch", ZipHeaderField::CompressedSize),
(
"uncompressed size mismatch",
ZipHeaderField::UncompressedSize,
),
];
macro_rules! enum_with_all {
($(#[$meta:meta])* enum $name:ident { $($variant:ident),+ $(,)? }) => {
$(#[$meta])*
enum $name {
$($variant),+
}
impl $name {
const ALL: [Self; [$(Self::$variant),+].len()] = [$(Self::$variant),+];
}
};
}
enum_with_all! {
#[derive(Debug, Clone, Copy)]
enum Slot {
Mismatch,
Duplicate,
Hidden,
StrayCentral,
StrayEnd,
DescriptorOverrun,
XlType,
Overlap,
Uncounted,
IgnoredEnd,
}
}
const _: () = {
let mut i = 0;
while i < Slot::ALL.len() {
assert!(Slot::ALL[i].index() == i);
i += 1;
}
};
impl Slot {
const fn limit(self) -> usize {
match self {
Self::Mismatch => 24,
Self::Duplicate
| Self::Hidden
| Self::StrayCentral
| Self::StrayEnd
| Self::DescriptorOverrun
| Self::XlType => 16,
Self::Overlap | Self::Uncounted | Self::IgnoredEnd => 1,
}
}
const fn index(self) -> usize {
self as usize
}
}
#[cfg(test)]
pub const MAX_RETAINED_FINDINGS: usize = {
let mut total = 0;
let mut i = 0;
while i < Slot::ALL.len() {
total += Slot::ALL[i].limit();
i += 1;
}
total
};
#[derive(Debug, Default)]
pub struct Findings {
items: Vec<ZipInconsistency>,
kept: [usize; Slot::ALL.len()],
omitted: usize,
}
impl Findings {
fn try_push(&mut self, slot: Slot, make: impl FnOnce() -> ZipInconsistency) -> bool {
if self.kept[slot.index()] < slot.limit() {
self.kept[slot.index()] += 1;
self.items.push(make());
true
} else {
self.omitted = self.omitted.saturating_add(1);
false
}
}
fn push_with(&mut self, slot: Slot, make: impl FnOnce() -> ZipInconsistency) {
self.try_push(slot, make);
}
fn extend(&mut self, other: &Self) {
self.omitted = self.omitted.saturating_add(other.omitted);
for (slot, kept) in Slot::ALL.into_iter().zip(other.kept) {
let items = other
.items
.iter()
.filter(|f| slot_of(f).index() == slot.index());
for item in items.take(kept) {
self.push_with(slot, || item.clone());
}
}
}
}
fn slot_of(finding: &ZipInconsistency) -> Slot {
match finding {
ZipInconsistency::HeaderMismatch { .. } | ZipInconsistency::HeaderUncomparable { .. } => {
Slot::Mismatch
}
ZipInconsistency::DuplicateName { .. } => Slot::Duplicate,
ZipInconsistency::HiddenLocalHeader { .. } => Slot::Hidden,
ZipInconsistency::StrayCentralRecord { .. } => Slot::StrayCentral,
ZipInconsistency::StrayEndRecord { .. } => Slot::StrayEnd,
ZipInconsistency::DescriptorOverrun { .. } => Slot::DescriptorOverrun,
ZipInconsistency::XlTypeDivergence { .. } => Slot::XlType,
ZipInconsistency::OverlappingEntries => Slot::Overlap,
ZipInconsistency::UncountedCentralRecords { .. } => Slot::Uncounted,
ZipInconsistency::IgnoredEndRecord { .. } => Slot::IgnoredEnd,
}
}
#[derive(Debug)]
pub struct Shared<R> {
reader: Arc<Mutex<Windowed<R>>>,
fault: Arc<Mutex<Option<(std::io::ErrorKind, String)>>>,
}
impl<R> Shared<R> {
pub fn new(reader: R) -> Self {
Self {
reader: Arc::new(Mutex::new(Windowed::new(reader))),
fault: Arc::new(Mutex::new(None)),
}
}
fn lock(&self) -> MutexGuard<'_, Windowed<R>> {
self.reader.lock().unwrap_or_else(PoisonError::into_inner)
}
fn note(&self, error: std::io::Error) -> std::io::Error {
let mut fault = self.fault.lock().unwrap_or_else(PoisonError::into_inner);
fault.get_or_insert_with(|| (error.kind(), error.to_string()));
error
}
fn take_fault(&self) -> Option<std::io::Error> {
let mut fault = self.fault.lock().unwrap_or_else(PoisonError::into_inner);
fault
.take()
.map(|(kind, message)| std::io::Error::new(kind, message))
}
}
impl<R> Clone for Shared<R> {
fn clone(&self) -> Self {
Self {
reader: Arc::clone(&self.reader),
fault: Arc::clone(&self.fault),
}
}
}
const WINDOW: usize = 32 * 1024;
const WINDOW_BYPASS: usize = 4 * 1024;
#[derive(Debug)]
struct Windowed<R> {
inner: R,
window: Vec<u8>,
window_start: u64,
cursor: u64,
inner_position: Option<u64>,
}
impl<R> Windowed<R> {
const fn new(inner: R) -> Self {
Self {
inner,
window: Vec::new(),
window_start: 0,
cursor: 0,
inner_position: None,
}
}
fn cached(&self) -> &[u8] {
let offset = self.cursor.wrapping_sub(self.window_start);
usize::try_from(offset)
.ok()
.and_then(|offset| self.window.get(offset..))
.unwrap_or(&[])
}
}
impl<R: Read + Seek> Windowed<R> {
fn position_inner(&mut self) -> std::io::Result<()> {
if self.inner_position != Some(self.cursor) {
self.inner_position = None;
self.inner.seek(SeekFrom::Start(self.cursor))?;
self.inner_position = Some(self.cursor);
}
Ok(())
}
fn advance(&mut self, read: usize) {
self.cursor += read as u64;
self.inner_position = self.inner_position.map(|p| p + read as u64);
}
}
impl<R: Read + Seek> Read for Windowed<R> {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
let cached = self.cached();
if !cached.is_empty() {
let n = cached.len().min(buf.len());
buf[..n].copy_from_slice(&cached[..n]);
self.cursor += n as u64;
return Ok(n);
}
self.position_inner()?;
if buf.len() >= WINDOW_BYPASS {
let n = self
.inner
.read(buf)
.inspect_err(|_| self.inner_position = None)?;
self.advance(n);
return Ok(n);
}
self.window.clear();
self.window.resize(WINDOW, 0);
self.window_start = self.cursor;
let filled = self.inner.read(&mut self.window).inspect_err(|_| {
self.window.clear();
self.inner_position = None;
})?;
self.window.truncate(filled);
let n = filled.min(buf.len());
buf[..n].copy_from_slice(&self.window[..n]);
self.advance(n);
self.inner_position = self.inner_position.map(|p| p + (filled - n) as u64);
Ok(n)
}
}
impl<R: Read + Seek> Seek for Windowed<R> {
fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
let target = match pos {
SeekFrom::Start(offset) => Some(offset),
SeekFrom::Current(delta) => self.cursor.checked_add_signed(delta),
SeekFrom::End(_) => {
self.inner_position = None;
let end = self.inner.seek(pos)?;
self.inner_position = Some(end);
Some(end)
}
};
self.cursor = target.ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"invalid seek to a negative or overflowing position",
)
})?;
Ok(self.cursor)
}
}
impl<R: Read + Seek> Read for Shared<R> {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
let read = self.lock().read(buf);
read.map_err(|e| self.note(e))
}
}
impl<R: Read + Seek> Seek for Shared<R> {
fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
let sought = self.lock().seek(pos);
sought.map_err(|e| self.note(e))
}
}
#[derive(Debug)]
pub struct ZipStructure {
central_entries: usize,
findings: Findings,
#[cfg(test)]
gap_reads: usize,
}
impl ZipStructure {
#[cfg(test)]
pub(crate) fn unchecked(central_entries: usize) -> Self {
Self {
central_entries,
findings: Findings::default(),
gap_reads: 0,
}
}
pub(crate) const fn central_entries(&self) -> usize {
self.central_entries
}
pub(crate) fn findings(&self) -> &[ZipInconsistency] {
&self.findings.items
}
pub(crate) const fn omitted_findings(&self) -> usize {
self.findings.omitted
}
#[cfg(test)]
pub(crate) const fn gap_reads(&self) -> usize {
self.gap_reads
}
}
#[derive(Debug, Clone, Copy)]
struct EntryLayout {
entry: ZipEntryIndex,
header_start: u64,
data_start: u64,
end: u64,
descriptor: Option<DescriptorExit>,
method: CompressionMethod,
compressed: u64,
size: u64,
kind: EntryKind,
}
#[derive(Debug)]
struct DirectoryMap {
directory_start: u64,
archive_offset: u64,
central_starts: Vec<u64>,
comment: Vec<u8>,
}
impl DirectoryMap {
fn of<R: Read + Seek>(archive: &ZipReader<R>) -> Result<Self> {
let central_starts = (0..archive.len())
.map(|index| {
archive
.by_index_data(index)
.map(|entry| entry.central_header_start())
.map_err(|e| {
ArchiveError::InvalidArchive(format!("failed to read entry {index}: {e}"))
})
})
.collect::<Result<_>>()?;
Ok(Self {
directory_start: archive.central_directory_start(),
archive_offset: archive.offset(),
central_starts,
comment: archive.comment().to_vec(),
})
}
}
pub fn open<R: Read + Seek>(reader: R) -> Result<(ZipReader<Shared<R>>, ZipStructure)> {
open_chunked(reader, ChunkSizes::PRODUCTION)
}
fn open_chunked<R: Read + Seek>(
reader: R,
chunks: ChunkSizes,
) -> Result<(ZipReader<Shared<R>>, ZipStructure)> {
let shared = Shared::new(reader);
let mut archive = open_reader(shared.clone()).map_err(|e| reader_fault(&shared, e))?;
let scan = scan_entries(&mut archive, &shared).map_err(|e| reader_fault(&shared, e))?;
let map = DirectoryMap::of(&archive).map_err(|e| reader_fault(&shared, e))?;
let mut census_reader = shared.clone();
let census = Census::run(&mut census_reader, &map, &scan, chunks)
.map_err(|e| reader_fault(&shared, e))?;
if let Some(fault) = shared.take_fault() {
return Err(ArchiveError::Io(fault));
}
let mut findings = scan.findings;
findings.extend(&census.findings);
Ok((
archive,
ZipStructure {
central_entries: census.central_entries,
findings,
#[cfg(test)]
gap_reads: census.gap_reads,
},
))
}
fn reader_fault<R>(shared: &Shared<R>, error: ArchiveError) -> ArchiveError {
shared.take_fault().map_or(error, ArchiveError::Io)
}
fn open_reader<R: Read + Seek>(reader: R) -> Result<ZipReader<R>> {
ZipReader::new(reader)
.map_err(|e| ArchiveError::InvalidArchive(format!("failed to open ZIP archive: {e}")))
}
fn census_disagreement() -> ArchiveError {
ArchiveError::InvalidArchive("ZIP central directory census disagrees with reader".into())
}
#[derive(Debug, Default)]
struct Scan {
layouts: Vec<EntryLayout>,
findings: Findings,
crate_duplicates: bool,
}
fn scan_entries<R: Read + Seek>(
archive: &mut ZipReader<Shared<R>>,
shared: &Shared<R>,
) -> Result<Scan> {
let mut budget = DeclaredCompressedBudget::new(archive.central_directory_start());
let mut scan = Scan {
crate_duplicates: archive.has_duplicate_names(),
..Scan::default()
};
let consistent = archive.validate_header_consistency().is_ok();
for index in 0..archive.len() {
if consistent {
let file = archive.by_index_raw(index).map_err(|e| {
ArchiveError::InvalidArchive(format!("failed to read entry {index}: {e}"))
})?;
admit_zip_entry(&file, &mut budget)?;
scan.layouts.push(layout_of(&file, index)?);
continue;
}
let mut mismatch = None;
if !scan.crate_duplicates {
let options = ZipReadOptions::new().enforce_header_consistency(true);
match archive.by_index_with_options(index, options) {
Ok(file) => {
admit_zip_entry(&file, &mut budget)?;
scan.layouts.push(layout_of(&file, index)?);
continue;
}
Err(e) => {
if let Some(fault) = shared.take_fault() {
return Err(ArchiveError::Io(fault));
}
mismatch = Some(e);
}
}
}
let file = archive.by_index(index).map_err(|e| {
if is_password_required(&e) {
common::encrypted_archive_error()
} else {
ArchiveError::InvalidArchive(format!("failed to read entry {index}: {e}"))
}
})?;
admit_zip_entry(&file, &mut budget)?;
scan.layouts.push(layout_of(&file, index)?);
if let Some(error) = mismatch {
scan.findings.push_with(Slot::Mismatch, || {
classify(&error).finding(
ZipEntryIndex::new(index),
String::from_utf8_lossy(file.name_raw()).into_owned(),
)
});
}
}
if overlaps(&scan.layouts, archive.central_directory_start()) {
scan.findings
.push_with(Slot::Overlap, || ZipInconsistency::OverlappingEntries);
}
Ok(scan)
}
fn layout_of<R: Read>(file: &zip::read::ZipFile<'_, R>, index: usize) -> Result<EntryLayout> {
let invalid =
|| ArchiveError::InvalidArchive("ZIP entry data range is not representable".into());
let data_start = file.data_start().ok_or_else(invalid)?;
Ok(EntryLayout {
entry: ZipEntryIndex::new(index),
header_start: file.header_start(),
data_start,
end: data_start
.checked_add(file.compressed_size())
.ok_or_else(invalid)?,
descriptor: file
.flags()
.is_using_data_descriptor()
.then(|| DescriptorExit::of(file.compression())),
method: file.compression(),
compressed: file.compressed_size(),
size: file.size(),
kind: EntryKind::of_central(file.is_dir(), file.unix_mode()),
})
}
fn overlaps(layouts: &[EntryLayout], directory_start: u64) -> bool {
let mut ranges: Vec<_> = layouts.iter().map(|l| (l.header_start, l.end)).collect();
ranges.sort_unstable();
ranges.iter().any(|&(_, end)| end > directory_start)
|| ranges.array_windows().any(|[a, b]| a.1 > b.0)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HeaderFault {
Mismatch(ZipHeaderField),
Uncomparable(ZipUncomparable),
}
impl HeaderFault {
fn finding(self, entry: ZipEntryIndex, name: String) -> ZipInconsistency {
match self {
Self::Mismatch(field) => ZipInconsistency::HeaderMismatch { entry, field, name },
Self::Uncomparable(reason) => ZipInconsistency::HeaderUncomparable {
entry,
name,
reason,
},
}
}
}
pub fn classify(error: &ZipError) -> HeaderFault {
let ZipError::InvalidArchive(msg) = error else {
return HeaderFault::Uncomparable(ZipUncomparable::Unparseable);
};
let Some(rest) = msg.strip_prefix(HEADER_INCONSISTENCY_PREFIX) else {
return HeaderFault::Uncomparable(ZipUncomparable::Unparseable);
};
FIELD_MESSAGES
.iter()
.find(|(text, _)| rest.starts_with(text))
.map_or(
HeaderFault::Uncomparable(ZipUncomparable::Unrecognized),
|&(_, field)| HeaderFault::Mismatch(field),
)
}
#[derive(Debug)]
struct Census {
central_entries: usize,
findings: Findings,
#[cfg(test)]
gap_reads: usize,
}
struct CentralRecord {
name_len: u16,
extra_len: u16,
comment_len: u16,
local_offset: u32,
}
impl CentralRecord {
fn read(reader: &mut impl Read) -> Option<Self> {
let mut header = [0u8; CDFH_FIXED_LEN];
if reader.read_exact(&mut header).is_err() || header[..4] != CDFH_SIGNATURE {
return None;
}
Some(Self {
name_len: le16::<28, CDFH_FIXED_LEN>(&header),
extra_len: le16::<30, CDFH_FIXED_LEN>(&header),
comment_len: le16::<32, CDFH_FIXED_LEN>(&header),
local_offset: le32::<42, CDFH_FIXED_LEN>(&header),
})
}
fn variable_len(&self) -> u64 {
u64::from(self.name_len) + u64::from(self.extra_len) + u64::from(self.comment_len)
}
}
impl Census {
fn run<R: Read + Seek>(
reader: &mut R,
map: &DirectoryMap,
scan: &Scan,
chunks: ChunkSizes,
) -> Result<Self> {
let file_len = reader.seek(SeekFrom::End(0))?;
let mut findings = Findings::default();
let mut walker = BufReader::new(&mut *reader);
walker.seek(SeekFrom::Start(map.directory_start))?;
let walk = walk_directory(&mut walker, map, &mut findings)?;
drop(walker);
if walk.crate_duplicates() != scan.crate_duplicates {
return Err(census_disagreement());
}
if walk.uncounted > 0 {
findings.push_with(Slot::Uncounted, || {
ZipInconsistency::UncountedCentralRecords {
counted: walk.counted,
found: walk.counted.saturating_add(walk.uncounted),
}
});
}
let mut gaps = GapChecker::new(reader, map, &scan.layouts, &walk, file_len, chunks);
gaps.run(&mut findings)?;
#[cfg(test)]
let reads = gaps.reads;
drop(gaps);
find_ignored_end_records(reader, file_len, map, &mut findings)?;
Ok(Self {
central_entries: walk.counted.saturating_add(walk.uncounted),
findings,
#[cfg(test)]
gap_reads: reads,
})
}
}
struct Walk {
counted: usize,
shadowed: usize,
shadowed_headers: HashSet<u64>,
uncounted: usize,
}
impl Walk {
const fn crate_duplicates(&self) -> bool {
self.shadowed > 0
}
}
fn walk_directory<R: Read + Seek>(
reader: &mut BufReader<&mut R>,
map: &DirectoryMap,
findings: &mut Findings,
) -> Result<Walk> {
let mapped: HashSet<u64> = map.central_starts.iter().copied().collect();
if mapped.len() != map.central_starts.len() {
return Err(census_disagreement());
}
let last_mapped = map.central_starts.iter().copied().max();
let mut position = map.directory_start;
let mut walk = Walk {
counted: 0,
shadowed: 0,
shadowed_headers: HashSet::new(),
uncounted: 0,
};
let mut visited = 0;
while last_mapped.is_some_and(|last| position <= last) {
let record = CentralRecord::read(reader).ok_or_else(census_disagreement)?;
if mapped.contains(&position) {
visited += 1;
skip(reader, record.variable_len())?;
} else {
let ordinal = CentralRecordOrdinal::new(walk.counted);
let mut name = vec![0u8; usize::from(record.name_len)];
reader.read_exact(&mut name)?;
findings.push_with(Slot::Duplicate, || ZipInconsistency::DuplicateName {
record: ordinal,
name: String::from_utf8_lossy(&name).into_owned(),
});
walk.shadowed += 1;
walk.shadowed_headers
.insert(u64::from(record.local_offset).saturating_add(map.archive_offset));
skip(
reader,
u64::from(record.extra_len) + u64::from(record.comment_len),
)?;
}
walk.counted += 1;
position = position
.checked_add(CDFH_FIXED_LEN as u64 + record.variable_len())
.ok_or_else(census_disagreement)?;
}
if visited != mapped.len() {
return Err(census_disagreement());
}
while let Some(record) = CentralRecord::read(reader) {
skip(reader, record.variable_len())?;
walk.uncounted += 1;
}
Ok(walk)
}
fn skip<R: Read + Seek>(reader: &mut BufReader<&mut R>, len: u64) -> std::io::Result<()> {
let len =
i64::try_from(len).map_err(|_| std::io::Error::from(std::io::ErrorKind::InvalidInput))?;
reader.seek_relative(len)
}
const GAP_CHUNK: usize = 64 * 1024;
const PREFIX_CHUNK: usize = 128 * 1024;
const PREFIX_LOOKAHEAD: usize = LFH_FIXED_LEN + u16::MAX as usize;
#[derive(Debug, Clone, Copy)]
struct ChunkSizes {
gap: usize,
prefix: usize,
}
impl ChunkSizes {
const PRODUCTION: Self = Self {
gap: GAP_CHUNK,
prefix: PREFIX_CHUNK,
};
}
const MAX_VERSION_NEEDED: u16 = 63;
const RESERVED_FLAG_BITS: u16 = 0xD780;
const DATA_DESCRIPTOR_FLAG: u16 = 0x0008;
const KNOWN_METHODS: [u16; 9] = [0, 8, 9, 12, 14, 93, 95, 98, 99];
impl<R: Read + Seek> ChunkSource for GapChecker<'_, R> {
fn read_chunk(&mut self, position: u64, buf: &mut [u8]) -> Result<usize> {
self.read_at(position, buf)?;
Ok(buf.len())
}
}
struct GapChecker<'a, R> {
reader: &'a mut R,
map: &'a DirectoryMap,
layouts: &'a [EntryLayout],
known_headers: HashSet<u64>,
search_gaps: bool,
file_len: u64,
chunks: ChunkSizes,
next_unjudged: u64,
exhausted: bool,
#[cfg(test)]
reads: usize,
}
impl<'a, R: Read + Seek> GapChecker<'a, R> {
fn new(
reader: &'a mut R,
map: &'a DirectoryMap,
layouts: &'a [EntryLayout],
walk: &Walk,
file_len: u64,
chunks: ChunkSizes,
) -> Self {
Self {
reader,
map,
layouts,
known_headers: layouts
.iter()
.map(|l| l.header_start)
.chain(walk.shadowed_headers.iter().copied())
.collect(),
search_gaps: !walk.crate_duplicates(),
file_len,
chunks,
next_unjudged: 0,
exhausted: false,
#[cfg(test)]
reads: 0,
}
}
fn is_known(&self, position: u64) -> bool {
position == self.map.directory_start || self.known_headers.contains(&position)
}
fn run(&mut self, findings: &mut Findings) -> Result<()> {
let mut order = self.layouts.to_vec();
order.sort_unstable_by_key(|l| l.header_start);
self.check_xl_types(&order, findings)?;
let prefix_end = if self.map.archive_offset > 0 {
self.map.archive_offset
} else {
order
.first()
.map_or(self.map.directory_start, |l| l.header_start)
};
let prefix_followed = if order.is_empty() {
Followed::ByDirectory
} else {
Followed::ByEntry
};
self.scan_prefix(prefix_end, prefix_followed, findings)?;
let mut starts: Vec<u64> = order.iter().map(|l| l.header_start).collect();
starts.push(self.map.directory_start);
starts.dedup();
let mut cursor = prefix_end;
for layout in &order {
if self.exhausted {
return Ok(());
}
self.gap(
cursor,
layout.header_start,
Followed::ByEntry,
HiddenRegion::Gap,
findings,
)?;
if layout.end > self.map.directory_start {
continue;
}
cursor = cursor.max(layout.end);
if let Some(exit) = layout.descriptor {
let next = starts
.get(starts.partition_point(|&s| s <= layout.header_start))
.copied()
.unwrap_or(self.map.directory_start);
if layout.method == CompressionMethod::Stored {
self.check_stored_resume(layout, findings)?;
}
self.check_descriptor(layout, exit, next, findings)?;
}
}
let decoy_start = cursor.max(
self.map
.directory_start
.saturating_sub(self.map.archive_offset),
);
self.gap(
cursor,
decoy_start,
Followed::ByDirectory,
HiddenRegion::Gap,
findings,
)?;
self.gap(
decoy_start,
self.map.directory_start,
Followed::ByEntry,
HiddenRegion::Gap,
findings,
)
}
fn scan_prefix(&mut self, end: u64, followed: Followed, findings: &mut Findings) -> Result<()> {
if end == 0 {
return Ok(());
}
if end < LFH_FIXED_LEN as u64 {
self.probe(0, HiddenRegion::Prefix, findings)?;
}
if self.exhausted {
return Ok(());
}
let mut start = [0u8; 2];
let lenient = end >= 4 && {
self.read_uncounted(0, &mut start)?;
start == *b"PK"
};
let plan = ChunkPlan {
range: 0..end,
chunk: self.chunks.prefix,
signature_len: 4,
max_lookahead: PREFIX_LOOKAHEAD,
};
search_chunks(self, &plan, find_signature, |this, hit| {
let found = hit.offset;
let flow = match hit.kind {
Signature::Stray(stray) => {
if lenient {
this.stray_record(found, followed, stray, findings);
}
Flow::Next
}
Signature::LocalHeader if lenient || found == 0 => {
match hit.bytes.first_chunk::<LFH_FIXED_LEN>() {
Some(header) => {
this.judge(found, header, HiddenRegion::Prefix, findings);
Flow::Next
}
None if hit.end == ChunkEnd::Last => Flow::Next,
None => Flow::NeedMore,
}
}
Signature::LocalHeader => {
let name_len = le_u16(hit.bytes, 26)
.map(usize::from)
.filter(|n| LFH_FIXED_LEN + n <= hit.bytes.len());
let Some(name_len) = name_len else {
return Ok(if hit.end == ChunkEnd::Last {
Flow::Next
} else {
Flow::NeedMore
});
};
let candidate = &hit.bytes[..LFH_FIXED_LEN + name_len];
if is_plausible_prefix_header(candidate, end - found)
&& !findings.try_push(Slot::Hidden, || {
ZipInconsistency::HiddenLocalHeader {
offset: found,
region: HiddenRegion::Prefix,
}
})
{
this.exhausted = true;
return Ok(Flow::Stop);
}
Flow::Next
}
};
Ok(match flow {
Flow::Next if this.exhausted => Flow::Finish,
flow => flow,
})
})
}
fn gap(
&mut self,
from: u64,
to: u64,
followed: Followed,
region: HiddenRegion,
findings: &mut Findings,
) -> Result<()> {
if self.exhausted || from >= to || self.is_known(from) {
return Ok(());
}
if !self.search_gaps {
return self.probe(from, region, findings);
}
let plan = ChunkPlan {
range: from..to,
chunk: self.chunks.gap,
signature_len: 4,
max_lookahead: LFH_FIXED_LEN,
};
search_chunks(self, &plan, find_signature, |this, hit| {
match hit.kind {
Signature::Stray(stray) => this.stray_record(hit.offset, followed, stray, findings),
Signature::LocalHeader => match hit.bytes.first_chunk::<LFH_FIXED_LEN>() {
Some(header) => this.judge(hit.offset, header, region, findings),
None if hit.end == ChunkEnd::Last => {
this.probe(hit.offset, region, findings)?;
}
None => return Ok(Flow::NeedMore),
},
}
Ok(if this.exhausted {
Flow::Stop
} else {
Flow::Next
})
})
}
fn check_descriptor(
&mut self,
layout: &EntryLayout,
exit: DescriptorExit,
next: u64,
findings: &mut Findings,
) -> Result<()> {
if !self.search_gaps {
for width in DescriptorWidth::ALL {
self.probe(
layout.end.saturating_add(width.bytes()),
HiddenRegion::Gap,
findings,
)?;
}
}
let local = self.local_facts(layout.header_start)?;
let mut at_end = [0u8; DESCRIPTOR_LOOKAHEAD];
let available = self.file_len.saturating_sub(layout.end);
let len = usize::try_from(available).map_or(at_end.len(), |n| n.min(at_end.len()));
self.read_at(layout.end, &mut at_end[..len])?;
let resume = streaming_resume(layout, exit, local, &at_end[..len]);
if resume.position(layout.end).is_none_or(|p| p > next) {
findings.push_with(Slot::DescriptorOverrun, || {
ZipInconsistency::DescriptorOverrun {
entry: layout.entry,
offset: layout.end,
}
});
}
Ok(())
}
fn check_stored_resume(&mut self, layout: &EntryLayout, findings: &mut Findings) -> Result<()> {
if layout.compressed < DATA_DESCRIPTOR_SIGNATURE.len() as u64 {
return Ok(());
}
self.reader.seek(SeekFrom::Start(layout.data_start))?;
let mut data = Read::take(&mut *self.reader, layout.compressed);
let found = scan(
&mut data,
layout.compressed,
DATA_DESCRIPTOR_SIGNATURE,
judge_first_signature,
)?;
let Some(at) = found else {
return Ok(());
};
let signature = layout.data_start.saturating_add(at);
if !self.search_gaps {
for width in DescriptorWidth::ALL {
self.probe(
signature.saturating_add(width.bytes()),
HiddenRegion::DescriptorTail,
findings,
)?;
}
}
self.gap(
signature,
layout.end,
Followed::ByEntry,
HiddenRegion::DescriptorTail,
findings,
)
}
fn probe(
&mut self,
position: u64,
region: HiddenRegion,
findings: &mut Findings,
) -> Result<()> {
if self.is_known(position) || position < self.next_unjudged {
return Ok(());
}
let mut header = [0u8; LFH_FIXED_LEN];
if position.saturating_add(LFH_FIXED_LEN as u64) > self.file_len {
return Ok(());
}
self.read_at(position, &mut header)?;
if header[..4] == LFH_SIGNATURE {
self.judge(position, &header, region, findings);
}
Ok(())
}
fn stray_record(
&mut self,
position: u64,
followed: Followed,
kind: StrayKind,
findings: &mut Findings,
) {
if followed == Followed::ByDirectory || position < self.next_unjudged {
return;
}
self.next_unjudged = position + 1;
if !findings.try_push(kind.slot(), || kind.finding(position)) {
self.exhausted = true;
}
}
fn judge(
&mut self,
position: u64,
header: &[u8; LFH_FIXED_LEN],
region: HiddenRegion,
findings: &mut Findings,
) {
if position < self.next_unjudged || self.is_known(position) {
return;
}
self.next_unjudged = position + 1;
let name_len = u64::from(le16::<26, LFH_FIXED_LEN>(header));
let extra_len = u64::from(le16::<28, LFH_FIXED_LEN>(header));
let end = position + LFH_FIXED_LEN as u64 + name_len + extra_len;
if (name_len > 0 || le32::<18, LFH_FIXED_LEN>(header) > 0)
&& end <= self.map.directory_start
&& !findings.try_push(Slot::Hidden, || ZipInconsistency::HiddenLocalHeader {
offset: position,
region,
})
{
self.exhausted = true;
}
}
fn read_at(&mut self, position: u64, buf: &mut [u8]) -> Result<()> {
#[cfg(test)]
{
self.reads += 1;
}
self.read_uncounted(position, buf)
}
fn read_uncounted(&mut self, position: u64, buf: &mut [u8]) -> Result<()> {
self.reader.seek(SeekFrom::Start(position))?;
self.reader.read_exact(buf)?;
Ok(())
}
fn check_xl_types(&mut self, order: &[EntryLayout], findings: &mut Findings) -> Result<()> {
let central = self.central_xl_scans()?;
let mut examined: Option<(u64, Option<LocalXl>)> = None;
for layout in order {
if examined
.as_ref()
.is_none_or(|(at, _)| *at != layout.header_start)
{
examined = Some((
layout.header_start,
self.read_local_xl(layout.header_start)?,
));
}
let Some((_, Some(local))) = &mut examined else {
continue;
};
let central_scan = central.get(layout.entry.get()).copied().flatten();
self.check_xl_type(
layout,
local,
central_scan.unwrap_or(XlScan::ABSENT),
findings,
)?;
}
Ok(())
}
fn central_xl_scans(&mut self) -> Result<Vec<Option<XlScan>>> {
let starts = &self.map.central_starts;
let mut order: Vec<usize> = (0..starts.len()).collect();
order.sort_unstable_by_key(|&index| starts[index]);
let mut scans = vec![None; starts.len()];
for index in order {
let header =
self.read_header::<CDFH_FIXED_LEN>(starts[index], CDFH_LENGTHS, Reads::Uncounted)?;
scans[index] = header.and_then(|h| XlScan::parse(h.host(), &h.extra));
}
Ok(scans)
}
fn read_local_xl(&mut self, header_start: u64) -> Result<Option<LocalXl>> {
let header =
self.read_header::<LFH_FIXED_LEN>(header_start, LFH_LENGTHS, Reads::Uncounted)?;
Ok(header.and_then(|h| {
Some(LocalXl {
scan: XlScan::parse(h.host(), &h.extra)?,
name_start: h.name_start,
name_len: h.name_len,
name: None,
extra: h.extra,
})
}))
}
fn check_xl_type(
&mut self,
layout: &EntryLayout,
local: &mut LocalXl,
central_scan: XlScan,
findings: &mut Findings,
) -> Result<()> {
if local.scan.file_type.is_none() && central_scan.file_type.is_none() {
return Ok(());
}
if local.name.is_none() {
let mut name = vec![0u8; local.name_len];
self.read_uncounted(local.name_start, &mut name)?;
local.name = Some(name);
}
let name = local.name.as_deref().unwrap_or_default();
let derived = reader_kind(local.scan, central_scan, name, &local.extra);
if derived.is_some_and(|kind| kind != layout.kind) {
findings.push_with(Slot::XlType, || ZipInconsistency::XlTypeDivergence {
entry: layout.entry,
offset: layout.header_start,
});
}
Ok(())
}
fn read_header<const FIXED: usize>(
&mut self,
start: u64,
lengths: LengthFields,
reads: Reads,
) -> Result<Option<RawHeader<FIXED>>> {
if start.saturating_add(FIXED as u64) > self.file_len {
return Ok(None);
}
let mut fixed = [0u8; FIXED];
self.read_header_bytes(start, &mut fixed, reads)?;
let (Some(name_len), Some(extra_len)) = (
le_u16(&fixed, lengths.name).map(usize::from),
le_u16(&fixed, lengths.extra).map(usize::from),
) else {
return Ok(None);
};
let name_start = start + FIXED as u64;
let extra_start = name_start + name_len as u64;
if extra_start.saturating_add(extra_len as u64) > self.file_len {
return Ok(None);
}
let mut extra = Vec::new();
if extra_len >= 4 {
extra.resize(extra_len, 0);
self.read_header_bytes(extra_start, &mut extra, reads)?;
}
Ok(Some(RawHeader {
fixed,
name_start,
name_len,
extra,
}))
}
fn read_header_bytes(&mut self, position: u64, buf: &mut [u8], reads: Reads) -> Result<()> {
match reads {
Reads::Counted => self.read_at(position, buf),
Reads::Uncounted => self.read_uncounted(position, buf),
}
}
fn local_facts(&mut self, header_start: u64) -> Result<LocalFacts> {
let header =
self.read_header::<LFH_FIXED_LEN>(header_start, LFH_LENGTHS, Reads::Counted)?;
Ok(header.map_or_else(LocalFacts::default, |h| {
LocalFacts::parse(&h.fixed, &h.extra)
}))
}
}
#[derive(Debug, Clone, Copy)]
struct LengthFields {
name: usize,
extra: usize,
}
const LFH_LENGTHS: LengthFields = LengthFields {
name: 26,
extra: 28,
};
const CDFH_LENGTHS: LengthFields = LengthFields {
name: 28,
extra: 30,
};
const HEADER_HOST_AT: usize = 5;
#[derive(Debug, Clone, Copy)]
enum Reads {
Counted,
Uncounted,
}
struct LocalXl {
scan: XlScan,
extra: Vec<u8>,
name_start: u64,
name_len: usize,
name: Option<Vec<u8>>,
}
struct RawHeader<const FIXED: usize> {
fixed: [u8; FIXED],
name_start: u64,
name_len: usize,
extra: Vec<u8>,
}
impl<const FIXED: usize> RawHeader<FIXED> {
fn host(&self) -> HostSystem {
HostSystem::from_byte(self.fixed[HEADER_HOST_AT])
}
}
fn is_plausible_prefix_header(candidate: &[u8], room: u64) -> bool {
let Some(header) = candidate.first_chunk::<LFH_FIXED_LEN>() else {
return false;
};
let name_len = usize::from(le16::<26, LFH_FIXED_LEN>(header));
let flags = le16::<6, LFH_FIXED_LEN>(header);
if name_len == 0
|| le16::<4, LFH_FIXED_LEN>(header) > MAX_VERSION_NEEDED
|| flags & RESERVED_FLAG_BITS != 0
|| !KNOWN_METHODS.contains(&le16::<8, LFH_FIXED_LEN>(header))
{
return false;
}
let mut needed =
(LFH_FIXED_LEN + name_len) as u64 + u64::from(le16::<28, LFH_FIXED_LEN>(header));
if flags & DATA_DESCRIPTOR_FLAG == 0 {
let compressed = le32::<18, LFH_FIXED_LEN>(header);
if compressed != u32::MAX {
needed += u64::from(compressed);
}
}
needed <= room
&& candidate
.get(LFH_FIXED_LEN..LFH_FIXED_LEN + name_len)
.is_some_and(|name| !name.contains(&0))
}
fn find_ignored_end_records<R: Read + Seek>(
reader: &mut R,
file_len: u64,
map: &DirectoryMap,
findings: &mut Findings,
) -> Result<()> {
let start = file_len.saturating_sub(EOCD_SEARCH_WINDOW);
let mut window = vec![0u8; usize::try_from(file_len - start).unwrap_or(0)];
reader.seek(SeekFrom::Start(start))?;
reader.read_exact(&mut window)?;
let mut candidates = Vec::new();
for at in (0..window.len().saturating_sub(EOCD_FIXED_LEN - 1)).rev() {
if window[at..at + 4] != EOCD_SIGNATURE {
continue;
}
let Some(comment_len) = le_u16(&window, at + 20).map(usize::from) else {
continue;
};
let Some(comment) = window.get(at + EOCD_FIXED_LEN..at + EOCD_FIXED_LEN + comment_len)
else {
continue;
};
if comment == map.comment && is_used_record(reader, &window, at, file_len, map)? {
for offset in candidates {
findings.push_with(Slot::IgnoredEnd, || ZipInconsistency::IgnoredEndRecord {
offset,
});
}
return Ok(());
}
candidates.push(start + at as u64);
}
if candidates.len() > 1 {
findings.push_with(Slot::IgnoredEnd, || ZipInconsistency::IgnoredEndRecord {
offset: candidates[0],
});
}
Ok(())
}
fn is_used_record<R: Read + Seek>(
reader: &mut R,
window: &[u8],
at: usize,
file_len: u64,
map: &DirectoryMap,
) -> Result<bool> {
let Some(directory) = le_u32(window, at + 16) else {
return Ok(false);
};
if directory != u32::MAX {
return Ok(u64::from(directory).saturating_add(map.archive_offset) == map.directory_start);
}
let Some(locator) = at
.checked_sub(ZIP64_LOCATOR_LEN)
.and_then(|from| window.get(from..at))
else {
return Ok(false);
};
if locator[..4] != ZIP64_LOCATOR_SIGNATURE {
return Ok(false);
}
let Some(end_record) = le_u64(locator, 8) else {
return Ok(false);
};
for position in [end_record.saturating_add(map.archive_offset), end_record] {
if position.saturating_add(ZIP64_END_FIXED_LEN as u64) > file_len {
continue;
}
let mut record = [0u8; ZIP64_END_FIXED_LEN];
reader.seek(SeekFrom::Start(position))?;
reader.read_exact(&mut record)?;
if record[..4] != ZIP64_END_SIGNATURE {
continue;
}
let directory = le64::<ZIP64_END_DIRECTORY_AT, ZIP64_END_FIXED_LEN>(&record);
return Ok(directory.saturating_add(map.archive_offset) == map.directory_start);
}
Ok(false)
}
pub(super) fn le_u16(bytes: &[u8], at: usize) -> Option<u16> {
bytes
.get(at..)?
.first_chunk()
.copied()
.map(u16::from_le_bytes)
}
pub(super) fn le_u32(bytes: &[u8], at: usize) -> Option<u32> {
bytes
.get(at..)?
.first_chunk()
.copied()
.map(u32::from_le_bytes)
}
pub(super) fn le_u64(bytes: &[u8], at: usize) -> Option<u64> {
bytes
.get(at..)?
.first_chunk()
.copied()
.map(u64::from_le_bytes)
}
pub(super) const fn le16<const AT: usize, const N: usize>(bytes: &[u8; N]) -> u16 {
const { assert!(AT + 2 <= N) };
u16::from_le_bytes([bytes[AT], bytes[AT + 1]])
}
pub(super) const fn le32<const AT: usize, const N: usize>(bytes: &[u8; N]) -> u32 {
const { assert!(AT + 4 <= N) };
u32::from_le_bytes([bytes[AT], bytes[AT + 1], bytes[AT + 2], bytes[AT + 3]])
}
pub(super) const fn le64<const AT: usize, const N: usize>(bytes: &[u8; N]) -> u64 {
const { assert!(AT + 8 <= N) };
u64::from_le_bytes([
bytes[AT],
bytes[AT + 1],
bytes[AT + 2],
bytes[AT + 3],
bytes[AT + 4],
bytes[AT + 5],
bytes[AT + 6],
bytes[AT + 7],
])
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Overrun;
fn extra_fields(block: &[u8]) -> impl Iterator<Item = std::result::Result<(u16, &[u8]), Overrun>> {
let mut rest = block;
std::iter::from_fn(move || {
let (head, tail) = rest.split_first_chunk::<4>()?;
let (id, len) = (le16::<0, 4>(head), usize::from(le16::<2, 4>(head)));
if let Some((body, after)) = tail.split_at_checked(len) {
rest = after;
Some(Ok((id, body)))
} else {
rest = &[];
Some(Err(Overrun))
}
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum StrayKind {
Central,
End(EndRecordKind),
}
impl StrayKind {
const fn slot(self) -> Slot {
match self {
Self::Central => Slot::StrayCentral,
Self::End(_) => Slot::StrayEnd,
}
}
const fn finding(self, offset: u64) -> ZipInconsistency {
match self {
Self::Central => ZipInconsistency::StrayCentralRecord { offset },
Self::End(kind) => ZipInconsistency::StrayEndRecord { offset, kind },
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Signature {
LocalHeader,
Stray(StrayKind),
}
fn find_signature(bytes: &[u8]) -> Option<(usize, Signature)> {
bytes
.array_windows::<4>()
.enumerate()
.find_map(|(index, w)| {
let kind = if *w == LFH_SIGNATURE {
Signature::LocalHeader
} else if *w == CDFH_SIGNATURE {
Signature::Stray(StrayKind::Central)
} else if *w == EOCD_SIGNATURE {
Signature::Stray(StrayKind::End(EndRecordKind::Classic))
} else if *w == ZIP64_END_SIGNATURE {
Signature::Stray(StrayKind::End(EndRecordKind::Zip64))
} else {
return None;
};
Some((index, kind))
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Followed {
ByEntry,
ByDirectory,
}
mod resume;
mod xl_type;
#[cfg(test)]
mod chunk_boundaries;
#[cfg(test)]
mod tests;