mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-14 06:22:24 +00:00
228 lines
9.7 KiB
Rust
228 lines
9.7 KiB
Rust
//! A ZIP reader for `.pcraft` bundles: STORE and DEFLATE entries, found through the central
|
|
//! directory, CRC-checked. No ZIP64, no encryption, no archives split across disks.
|
|
|
|
#[cfg(feature = "pcraft-pixels")]
|
|
use std::collections::HashMap;
|
|
use std::io::Read;
|
|
use std::ops::Range;
|
|
|
|
use crate::{Budget, Error, malformed};
|
|
|
|
#[cfg(test)]
|
|
thread_local! {
|
|
/// How many times this thread has walked a central directory with [`Zip::find`] or
|
|
/// [`Zip::index`].
|
|
pub(crate) static SCANS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
|
}
|
|
|
|
const LOCAL_SIG: u32 = 0x0403_4b50;
|
|
const CENTRAL_SIG: u32 = 0x0201_4b50;
|
|
const EOCD_SIG: u32 = 0x0605_4b50;
|
|
const ZIP64_LOCATOR_SIG: u32 = 0x0706_4b50;
|
|
const EOCD_LEN: usize = 22;
|
|
const CENTRAL_LEN: usize = 46;
|
|
const LOCAL_LEN: usize = 30;
|
|
|
|
fn u16_at(b: &[u8], at: usize) -> Option<u16> {
|
|
let s = b.get(at..at.checked_add(2)?)?;
|
|
Some(u16::from_le_bytes([*s.first()?, *s.get(1)?]))
|
|
}
|
|
|
|
fn u32_at(b: &[u8], at: usize) -> Option<u32> {
|
|
let s = b.get(at..at.checked_add(4)?)?;
|
|
Some(u32::from_le_bytes([*s.first()?, *s.get(1)?, *s.get(2)?, *s.get(3)?]))
|
|
}
|
|
|
|
fn truncated() -> Error {
|
|
malformed("truncated ZIP archive")
|
|
}
|
|
|
|
/// One entry of the central directory.
|
|
pub(crate) struct Entry {
|
|
name: Range<usize>,
|
|
flags: u16,
|
|
method: u16,
|
|
crc: u32,
|
|
compressed: usize,
|
|
size: u64,
|
|
/// Where the entry's data starts.
|
|
data: usize,
|
|
}
|
|
|
|
pub(crate) struct Zip<'a> {
|
|
bytes: &'a [u8],
|
|
entries: Vec<Entry>,
|
|
}
|
|
|
|
impl<'a> Zip<'a> {
|
|
pub(crate) fn open(bytes: &'a [u8], control: Option<&crate::cancel::Control<'_>>) -> Result<Self, Error> {
|
|
crate::cancel::check(control)?;
|
|
let last = bytes.len().checked_sub(EOCD_LEN).ok_or_else(|| malformed("not a ZIP archive (too short)"))?;
|
|
// The comment may itself hold the signature: accept only a record whose comment ends the
|
|
// file and whose central directory ends where the record starts.
|
|
let eocd = (last.saturating_sub(65535)..=last)
|
|
.rev()
|
|
.find(|&i| {
|
|
let comment_end = u16_at(bytes, i + 20).and_then(|n| i.checked_add(EOCD_LEN)?.checked_add(usize::from(n)));
|
|
let dir_end = u32_at(bytes, i + 16)
|
|
.zip(u32_at(bytes, i + 12))
|
|
.and_then(|(off, size)| usize::try_from(off).ok()?.checked_add(usize::try_from(size).ok()?));
|
|
u32_at(bytes, i) == Some(EOCD_SIG) && comment_end == Some(bytes.len()) && dir_end == Some(i)
|
|
})
|
|
.ok_or_else(|| {
|
|
let zip64 = (last.saturating_sub(65535 + 20)..=last.saturating_sub(20)).any(|i| u32_at(bytes, i) == Some(ZIP64_LOCATOR_SIG));
|
|
if zip64 {
|
|
Error::Unsupported("ZIP64 archives are not supported".into())
|
|
} else {
|
|
malformed("ZIP end of central directory not found")
|
|
}
|
|
})?;
|
|
let disk = u16_at(bytes, eocd + 4).ok_or_else(truncated)?;
|
|
let dir_disk = u16_at(bytes, eocd + 6).ok_or_else(truncated)?;
|
|
let count = usize::from(u16_at(bytes, eocd + 10).ok_or_else(truncated)?);
|
|
if disk != 0 || dir_disk != 0 {
|
|
return Err(Error::Unsupported("ZIP archives split across disks are not supported".into()));
|
|
}
|
|
let dir_size = u32_at(bytes, eocd + 12).ok_or_else(truncated)? as usize;
|
|
let dir = eocd - dir_size;
|
|
let mut entries = Vec::with_capacity(count.min(dir_size / CENTRAL_LEN));
|
|
let mut p = dir;
|
|
for _ in 0..count {
|
|
crate::cancel::check(control)?;
|
|
if u32_at(bytes, p) != Some(CENTRAL_SIG) || p + CENTRAL_LEN > eocd {
|
|
return Err(malformed("bad ZIP central directory entry"));
|
|
}
|
|
let field16 = |o: usize| u16_at(bytes, p + o).ok_or_else(truncated);
|
|
let field32 = |o: usize| u32_at(bytes, p + o).ok_or_else(truncated);
|
|
let (flags, method, crc) = (field16(8)?, field16(10)?, field32(16)?);
|
|
let (compressed, size) = (field32(20)?, field32(24)?);
|
|
let (name_len, extra_len, comment_len) = (usize::from(field16(28)?), usize::from(field16(30)?), usize::from(field16(32)?));
|
|
let local = field32(42)? as usize;
|
|
if compressed == u32::MAX || size == u32::MAX || local == u32::MAX as usize {
|
|
return Err(Error::Unsupported("ZIP64 entries are not supported".into()));
|
|
}
|
|
let name_start = p + CENTRAL_LEN;
|
|
let name_end = name_start + name_len;
|
|
let next = name_end + extra_len + comment_len;
|
|
if next > eocd {
|
|
return Err(malformed("ZIP central directory entry out of range"));
|
|
}
|
|
if u32_at(bytes, local) != Some(LOCAL_SIG) {
|
|
return Err(malformed("bad ZIP local header"));
|
|
}
|
|
let local_name = usize::from(u16_at(bytes, local + 26).ok_or_else(truncated)?);
|
|
let local_extra = usize::from(u16_at(bytes, local + 28).ok_or_else(truncated)?);
|
|
let data = local.checked_add(LOCAL_LEN + local_name + local_extra).ok_or_else(truncated)?;
|
|
let compressed = compressed as usize;
|
|
if data.checked_add(compressed).is_none_or(|end| end > bytes.len()) {
|
|
return Err(malformed("ZIP entry data out of range"));
|
|
}
|
|
entries.push(Entry { name: name_start..name_end, flags, method, crc, compressed, size: u64::from(size), data });
|
|
p = next;
|
|
}
|
|
Ok(Zip { bytes, entries })
|
|
}
|
|
|
|
/// The first entry named `name`. Each call walks the central directory; for many lookups,
|
|
/// use [`Zip::index`].
|
|
pub(crate) fn find(&self, name: &str, control: Option<&crate::cancel::Control<'_>>) -> Result<Option<&Entry>, Error> {
|
|
#[cfg(test)]
|
|
SCANS.with(|n| n.set(n.get() + 1));
|
|
for e in &self.entries {
|
|
crate::cancel::check(control)?;
|
|
if self.bytes.get(e.name.clone()) == Some(name.as_bytes()) {
|
|
return Ok(Some(e));
|
|
}
|
|
}
|
|
Ok(None)
|
|
}
|
|
|
|
/// Every entry by name, the first of each name as [`Zip::find`] gives it, built in one walk of
|
|
/// the central directory.
|
|
#[cfg(feature = "pcraft-pixels")]
|
|
pub(crate) fn index(&self) -> HashMap<&'a [u8], &Entry> {
|
|
#[cfg(test)]
|
|
SCANS.with(|n| n.set(n.get() + 1));
|
|
let mut index = HashMap::with_capacity(self.entries.len());
|
|
for e in &self.entries {
|
|
if let Some(name) = self.bytes.get(e.name.clone()) {
|
|
index.entry(name).or_insert(e);
|
|
}
|
|
}
|
|
index
|
|
}
|
|
|
|
/// The entry's bytes, decompressed and CRC-checked; their size is checked against `max` and
|
|
/// counted against `budget` first.
|
|
pub(crate) fn read(&self, e: &Entry, max: u64, budget: &mut Budget, what: &str) -> Result<Vec<u8>, Error> {
|
|
if e.flags & 1 != 0 {
|
|
return Err(Error::Unsupported(format!("{what} is encrypted")));
|
|
}
|
|
if !matches!(e.method, 0 | 8) {
|
|
return Err(Error::Unsupported(format!("{what} uses ZIP compression method {}", e.method)));
|
|
}
|
|
if e.size > max {
|
|
return Err(Error::LimitExceeded(format!("{what} is {} bytes, more than the limit ({max})", e.size)));
|
|
}
|
|
budget.take(e.size, what)?;
|
|
let raw = self.bytes.get(e.data..e.data + e.compressed).ok_or_else(truncated)?;
|
|
let size = usize::try_from(e.size).map_err(|_| Error::LimitExceeded(format!("{what} does not fit in memory")))?;
|
|
let data = if e.method == 0 {
|
|
if e.compressed != size {
|
|
return Err(malformed(format!("{what} has the wrong size")));
|
|
}
|
|
budget.copy(raw)?
|
|
} else {
|
|
// DEFLATE expands at most 1032 times.
|
|
let mut out = Vec::with_capacity(size.min(raw.len().saturating_mul(1032)));
|
|
let input = crate::cancel::Reader::new(raw, budget.control);
|
|
let decoder = flate2::read::DeflateDecoder::new(input);
|
|
crate::cancel::Reader::new(decoder, budget.control)
|
|
.take(e.size + 1)
|
|
.read_to_end(&mut out)
|
|
.map_err(|err| malformed(format!("{what}: {err}")))?;
|
|
if out.len() != size {
|
|
return Err(malformed(format!("{what} has the wrong size")));
|
|
}
|
|
out
|
|
};
|
|
let mut crc = flate2::Crc::new();
|
|
for chunk in data.chunks(crate::cancel::CHUNK) {
|
|
budget.check_cancelled()?;
|
|
crc.update(chunk);
|
|
}
|
|
if crc.sum() != e.crc {
|
|
return Err(malformed(format!("{what} failed its CRC check")));
|
|
}
|
|
Ok(data)
|
|
}
|
|
}
|
|
|
|
/// Does a local header in `head` name `name`? Walks the local headers from the start, for a
|
|
/// head too short to reach the central directory; stops at an entry whose size is only in a
|
|
/// data descriptor.
|
|
pub(crate) fn local_names_include(head: &[u8], name: &str, control: Option<&crate::cancel::Control<'_>>) -> Result<bool, Error> {
|
|
let mut p = 0usize;
|
|
while u32_at(head, p) == Some(LOCAL_SIG) {
|
|
crate::cancel::check(control)?;
|
|
let (Some(flags), Some(compressed), Some(name_len), Some(extra_len)) =
|
|
(u16_at(head, p + 6), u32_at(head, p + 18), u16_at(head, p + 26), u16_at(head, p + 28))
|
|
else {
|
|
return Ok(false);
|
|
};
|
|
let start = p + LOCAL_LEN;
|
|
let end = start + usize::from(name_len);
|
|
if head.get(start..end) == Some(name.as_bytes()) {
|
|
return Ok(true);
|
|
}
|
|
if flags & 8 != 0 {
|
|
return Ok(false);
|
|
}
|
|
p = match end.checked_add(usize::from(extra_len)).and_then(|q| q.checked_add(compressed as usize)) {
|
|
Some(q) => q,
|
|
None => return Ok(false),
|
|
};
|
|
}
|
|
Ok(false)
|
|
}
|