mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-14 22:42:46 +00:00
Add bounded XFL FLA package reader
This commit is contained in:
1 parent
b5b8a7750d
commit
b4d1e249d9
6 files changed
+1082
No files matched your search
Generated
+8
@@ -2584,6 +2584,14 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-xfl"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"miniz_oxide 0.8.9",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-xls"
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -138,6 +138,7 @@ craft-vcard = { path = "crates/vcard" }
|
||||
craft-vectorcraft-format = { path = "crates/vectorcraft-format" }
|
||||
craft-vectordrawable = { path = "crates/vectordrawable" }
|
||||
craft-xls = { path = "crates/xls" }
|
||||
craft-xfl = { path = "crates/xfl" }
|
||||
craft-wordmodel = { path = "crates/wordmodel" }
|
||||
craft-xlsx = { path = "crates/xlsx" }
|
||||
indd = { git = "https://github.com/storytold/indd-utils", rev = "7f3060e5bce78d21ea70e0d5eacb96edce15dfbe" }
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "craft-xfl"
|
||||
description = "Bounded reader for the ZIP package used by Animate XFL/FLA documents."
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
miniz_oxide = "0.8"
|
||||
thiserror = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
//! Reads the hostile ZIP package used by an Animate `.fla` (an archive of an XFL folder),
|
||||
//! including its XFL-specific path rules and resource limits.
|
||||
//!
|
||||
//! Implements the parts of the published ZIP format (PKWARE APPNOTE 6.3) that archive tools
|
||||
//! write: the end-of-central-directory record (and its ZIP64 form), central directory entries
|
||||
//! with ZIP64 sizes, local headers, data descriptors, "stored" and "deflate" entries (inflated
|
||||
//! by `miniz_oxide`), UTF-8 names (flag bit 11 or the Info-ZIP Unicode path field) and CP437
|
||||
//! names. Encryption, other compression methods and split archives are refused with a clear
|
||||
//! error.
|
||||
//!
|
||||
//! The archive is treated as hostile. Every offset and size is checked against the bytes it
|
||||
//! comes from; entries may not overlap each other or the central directory; a name that would
|
||||
//! leave the package (`..`, absolute or drive paths), is listed twice or is nested deeper than
|
||||
//! [`MAX_DEPTH`] refuses the archive; an entry may not claim more than deflate can produce from
|
||||
//! its bytes ([`MAX_RATIO`]), more than [`MAX_ENTRY`], or a size or checksum it doesn't have;
|
||||
//! and the archive hands out at most [`MAX_TOTAL`] bytes in all. Nothing is ever written to
|
||||
//! disk. Never panics.
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Largest single entry inflated (bytes).
|
||||
pub const MAX_ENTRY: usize = 256 * 1024 * 1024;
|
||||
/// Most bytes one archive hands out, over every read (the import's memory budget).
|
||||
pub const MAX_TOTAL: u64 = 1024 * 1024 * 1024;
|
||||
/// Most entries an archive may list (files and folders).
|
||||
pub const MAX_ENTRIES: usize = 100_000;
|
||||
/// Most folders a path may be inside (Animate's library nests a few deep).
|
||||
pub const MAX_DEPTH: usize = 32;
|
||||
/// Largest size-to-compressed-size ratio believed: deflate itself can't pass about 1032 : 1,
|
||||
/// so an entry claiming more is a zip bomb or a damaged size, refused before inflating.
|
||||
pub const MAX_RATIO: u64 = 1024;
|
||||
|
||||
#[derive(Debug, Clone, thiserror::Error, PartialEq)]
|
||||
pub enum ZipError {
|
||||
#[error("not a zip archive (no end-of-directory record)")]
|
||||
NotZip,
|
||||
#[error("the archive is damaged: {0}")]
|
||||
Damaged(&'static str),
|
||||
#[error("the archive is damaged: '{0}' {1}")]
|
||||
DamagedEntry(String, &'static str),
|
||||
#[error("the archive uses a zip feature AniCraft can't read ({0})")]
|
||||
UnsupportedArchive(&'static str),
|
||||
#[error("'{0}' uses a zip feature AniCraft can't read ({1})")]
|
||||
Unsupported(String, &'static str),
|
||||
#[error("'{0}' is too large to read")]
|
||||
TooLarge(String),
|
||||
#[error("the archive asks for more than {} MB in all", MAX_TOTAL / (1024 * 1024))]
|
||||
TooMuch,
|
||||
#[error("the archive lists more than {MAX_ENTRIES} files")]
|
||||
TooMany,
|
||||
#[error("'{0}' claims to expand more than compression allows (a zip bomb or a damaged archive)")]
|
||||
Bomb(String),
|
||||
#[error("'{0}' and '{1}' share bytes in the archive (a zip bomb or a damaged archive)")]
|
||||
Overlap(String, String),
|
||||
#[error("'{0}' points outside the document")]
|
||||
Unsafe(String),
|
||||
#[error("'{0}' is in the archive twice")]
|
||||
Duplicate(String),
|
||||
#[error("'{0}' is inside more than {MAX_DEPTH} folders")]
|
||||
TooDeep(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct Entry {
|
||||
method: u16,
|
||||
flags: u16,
|
||||
crc: u32,
|
||||
comp: usize,
|
||||
size: usize,
|
||||
/// Where its (compressed) bytes start.
|
||||
data: usize,
|
||||
}
|
||||
|
||||
pub struct Archive<'a> {
|
||||
data: &'a [u8],
|
||||
entries: HashMap<String, Entry>,
|
||||
folders: usize,
|
||||
/// Bytes still to hand out ([`MAX_TOTAL`] at first).
|
||||
budget: Cell<u64>,
|
||||
}
|
||||
|
||||
const END: u32 = 0x0605_4b50;
|
||||
const END64: u32 = 0x0606_4b50;
|
||||
const LOCATOR64: u32 = 0x0706_4b50;
|
||||
const CENTRAL: u32 = 0x0201_4b50;
|
||||
const LOCAL: u32 = 0x0403_4b50;
|
||||
|
||||
fn get(d: &[u8], at: usize, len: usize) -> Option<&[u8]> {
|
||||
d.get(at..at.checked_add(len)?)
|
||||
}
|
||||
fn u16_at(d: &[u8], i: usize) -> Option<u16> {
|
||||
Some(u16::from_le_bytes(get(d, i, 2)?.try_into().ok()?))
|
||||
}
|
||||
fn u32_at(d: &[u8], i: usize) -> Option<u32> {
|
||||
Some(u32::from_le_bytes(get(d, i, 4)?.try_into().ok()?))
|
||||
}
|
||||
fn u64_at(d: &[u8], i: usize) -> Option<u64> {
|
||||
Some(u64::from_le_bytes(get(d, i, 8)?.try_into().ok()?))
|
||||
}
|
||||
|
||||
/// The body of the extra field `id`, if `extra` has one.
|
||||
fn extra_field(mut extra: &[u8], id: u16) -> Option<&[u8]> {
|
||||
while extra.len() >= 4 {
|
||||
let (tag, len) = (u16_at(extra, 0)?, u16_at(extra, 2)? as usize);
|
||||
let body = get(extra, 4, len)?;
|
||||
if tag == id {
|
||||
return Some(body);
|
||||
}
|
||||
extra = extra.get(4 + len..)?;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// CRC-32 tables for eight bytes at a time ("slicing by 8"): `TABLES[0]` is the classic
|
||||
/// byte table, `TABLES[k][b]` the checksum of byte `b` followed by `k` zero bytes.
|
||||
const TABLES: [[u32; 256]; 8] = {
|
||||
let mut t = [[0u32; 256]; 8];
|
||||
let mut i = 0;
|
||||
while i < 256 {
|
||||
let mut c = i as u32;
|
||||
let mut k = 0;
|
||||
while k < 8 {
|
||||
c = if c & 1 != 0 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 };
|
||||
k += 1;
|
||||
}
|
||||
t[0][i] = c;
|
||||
i += 1;
|
||||
}
|
||||
let mut k = 1;
|
||||
while k < 8 {
|
||||
let mut i = 0;
|
||||
while i < 256 {
|
||||
let prev = t[k - 1][i];
|
||||
t[k][i] = (prev >> 8) ^ t[0][(prev & 0xff) as usize];
|
||||
i += 1;
|
||||
}
|
||||
k += 1;
|
||||
}
|
||||
t
|
||||
};
|
||||
|
||||
/// CRC-32 (ISO-HDLC, the zip checksum), eight bytes at a time.
|
||||
pub fn crc32(data: &[u8]) -> u32 {
|
||||
let t = &TABLES;
|
||||
let mut c = !0u32;
|
||||
let (blocks, rest) = data.as_chunks::<8>();
|
||||
for b in blocks {
|
||||
let lo = c ^ u32::from_le_bytes([b[0], b[1], b[2], b[3]]);
|
||||
c = t[7][(lo & 0xff) as usize]
|
||||
^ t[6][((lo >> 8) & 0xff) as usize]
|
||||
^ t[5][((lo >> 16) & 0xff) as usize]
|
||||
^ t[4][(lo >> 24) as usize]
|
||||
^ t[3][b[4] as usize]
|
||||
^ t[2][b[5] as usize]
|
||||
^ t[1][b[6] as usize]
|
||||
^ t[0][b[7] as usize];
|
||||
}
|
||||
for &b in rest {
|
||||
c = t[0][((c ^ b as u32) & 0xff) as usize] ^ (c >> 8);
|
||||
}
|
||||
!c
|
||||
}
|
||||
|
||||
/// Code page 437, bytes 0x80 to 0xFF: the zip format's name encoding without the UTF-8 flag.
|
||||
const CP437: [char; 128] = [
|
||||
'Ç', 'ü', 'é', 'â', 'ä', 'à', 'å', 'ç', 'ê', 'ë', 'è', 'ï', 'î', 'ì', 'Ä', 'Å', //
|
||||
'É', 'æ', 'Æ', 'ô', 'ö', 'ò', 'û', 'ù', 'ÿ', 'Ö', 'Ü', '¢', '£', '¥', '₧', 'ƒ', //
|
||||
'á', 'í', 'ó', 'ú', 'ñ', 'Ñ', 'ª', 'º', '¿', '⌐', '¬', '½', '¼', '¡', '«', '»', //
|
||||
'░', '▒', '▓', '│', '┤', '╡', '╢', '╖', '╕', '╣', '║', '╗', '╝', '╜', '╛', '┐', //
|
||||
'└', '┴', '┬', '├', '─', '┼', '╞', '╟', '╚', '╔', '╩', '╦', '╠', '═', '╬', '╧', //
|
||||
'╨', '╤', '╥', '╙', '╘', '╒', '╓', '╫', '╪', '┘', '┌', '█', '▄', '▌', '▐', '▀', //
|
||||
'α', 'ß', 'Γ', 'π', 'Σ', 'σ', 'µ', 'τ', 'Φ', 'Θ', 'Ω', 'δ', '∞', 'φ', 'ε', '∩', //
|
||||
'≡', '±', '≥', '≤', '⌠', '⌡', '÷', '≈', '°', '∙', '·', '√', 'ⁿ', '²', '■', '\u{a0}',
|
||||
];
|
||||
|
||||
/// An entry's name as text: the Info-ZIP Unicode path field when it matches the name, UTF-8
|
||||
/// when flag bit 11 says so, else CP437 as the format specifies. Many writers store UTF-8
|
||||
/// without setting the flag, so a name that is valid UTF-8 is read as UTF-8 (ASCII, which
|
||||
/// nearly every name is, reads the same either way).
|
||||
fn decode_name(raw: &[u8], utf8: bool, extra: &[u8]) -> Result<String, ZipError> {
|
||||
if let Some(up) = extra_field(extra, 0x7075)
|
||||
&& up.first() == Some(&1)
|
||||
&& u32_at(up, 1) == Some(crc32(raw))
|
||||
&& let Some(Ok(s)) = up.get(5..).map(std::str::from_utf8)
|
||||
{
|
||||
return Ok(s.to_string());
|
||||
}
|
||||
match std::str::from_utf8(raw) {
|
||||
Ok(s) => Ok(s.to_string()),
|
||||
Err(_) if utf8 => Err(ZipError::Damaged("an entry's name is not the UTF-8 it says it is")),
|
||||
Err(_) => Ok(raw.iter().map(|&b| if b < 0x80 { b as char } else { CP437.get((b - 0x80) as usize).copied().unwrap_or('\u{fffd}') }).collect()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A name as a plain path inside the package: `\` read as `/`, empty and `.` parts dropped.
|
||||
/// Names that would leave the package are refused, though nothing is ever extracted: every
|
||||
/// lookup stays unambiguous, as the folder form's reader also refuses them.
|
||||
fn clean_path(name: &str) -> Result<String, ZipError> {
|
||||
let unix = name.replace('\\', "/");
|
||||
let shown = || name.chars().take(200).collect::<String>();
|
||||
if unix.starts_with('/') || unix.contains('\0') {
|
||||
return Err(ZipError::Unsafe(shown()));
|
||||
}
|
||||
let mut parts = Vec::new();
|
||||
for (i, part) in unix.split('/').enumerate() {
|
||||
match part {
|
||||
"" | "." => {}
|
||||
".." => return Err(ZipError::Unsafe(shown())),
|
||||
// `C:`, `C:x`: a drive.
|
||||
p if i == 0 && p.contains(':') => return Err(ZipError::Unsafe(shown())),
|
||||
p => parts.push(p),
|
||||
}
|
||||
}
|
||||
if parts.len() > MAX_DEPTH + 1 {
|
||||
return Err(ZipError::TooDeep(shown()));
|
||||
}
|
||||
Ok(parts.join("/"))
|
||||
}
|
||||
|
||||
fn method_name(method: u16) -> &'static str {
|
||||
match method {
|
||||
9 => "Deflate64 compression",
|
||||
12 => "bzip2 compression",
|
||||
14 => "LZMA compression",
|
||||
93 => "Zstandard compression",
|
||||
95 => "XZ compression",
|
||||
99 => "AES encryption",
|
||||
_ => "an unknown compression method",
|
||||
}
|
||||
}
|
||||
|
||||
/// The end of the central directory: (entry count, its size, its offset, where it must end).
|
||||
fn find_end(data: &[u8]) -> Result<(u64, u64, u64, usize), ZipError> {
|
||||
// The end record is in the last 22 + 65535 bytes (it may carry a comment).
|
||||
let lo = data.len().saturating_sub(22 + 65_535);
|
||||
let eocd = (lo..data.len().saturating_sub(21)).rev().find(|&i| u32_at(data, i) == Some(END)).ok_or(ZipError::NotZip)?;
|
||||
let field16 = |o: usize| u16_at(data, eocd + o).ok_or(ZipError::Damaged("the end record is cut short"));
|
||||
let field32 = |o: usize| u32_at(data, eocd + o).ok_or(ZipError::Damaged("the end record is cut short"));
|
||||
let (disk, dir_disk, on_disk, count) = (field16(4)?, field16(6)?, field16(8)?, field16(10)?);
|
||||
let (size, offset) = (field32(12)?, field32(16)?);
|
||||
if count != 0xffff && size != 0xffff_ffff && offset != 0xffff_ffff {
|
||||
if disk != 0 || dir_disk != 0 || on_disk != count {
|
||||
return Err(ZipError::UnsupportedArchive("split across several files"));
|
||||
}
|
||||
return Ok((count.into(), size.into(), offset.into(), eocd));
|
||||
}
|
||||
// ZIP64: a locator just before the end record points at the ZIP64 end record.
|
||||
let locator = eocd.checked_sub(20).filter(|&l| u32_at(data, l) == Some(LOCATOR64)).ok_or(ZipError::Damaged("the ZIP64 end record is missing"))?;
|
||||
let missing = ZipError::Damaged("the ZIP64 end record is missing");
|
||||
let at = u64_at(data, locator + 8).and_then(|a| usize::try_from(a).ok()).filter(|&a| a < locator).ok_or(missing.clone())?;
|
||||
if u32_at(data, at) != Some(END64) {
|
||||
return Err(missing);
|
||||
}
|
||||
let cut = ZipError::Damaged("the ZIP64 end record is cut short");
|
||||
let (disk, dir_disk) = (u32_at(data, at + 16).ok_or(cut.clone())?, u32_at(data, at + 20).ok_or(cut.clone())?);
|
||||
let (on_disk, count) = (u64_at(data, at + 24).ok_or(cut.clone())?, u64_at(data, at + 32).ok_or(cut.clone())?);
|
||||
if disk != 0 || dir_disk != 0 || on_disk != count {
|
||||
return Err(ZipError::UnsupportedArchive("split across several files"));
|
||||
}
|
||||
Ok((count, u64_at(data, at + 40).ok_or(cut.clone())?, u64_at(data, at + 48).ok_or(cut)?, at))
|
||||
}
|
||||
|
||||
impl<'a> Archive<'a> {
|
||||
pub fn open(data: &'a [u8]) -> Result<Archive<'a>, ZipError> {
|
||||
let (count, dir_size, dir_at, dir_limit) = find_end(data)?;
|
||||
let past = ZipError::Damaged("the central directory is outside the archive");
|
||||
let dir_at = usize::try_from(dir_at).map_err(|_| past.clone())?;
|
||||
let dir_end = usize::try_from(dir_size).ok().and_then(|s| dir_at.checked_add(s)).filter(|&e| e <= dir_limit).ok_or(past.clone())?;
|
||||
let dir = data.get(dir_at..dir_end).ok_or(past)?;
|
||||
if count > MAX_ENTRIES as u64 {
|
||||
return Err(ZipError::TooMany);
|
||||
}
|
||||
let count = count as usize;
|
||||
// Every central entry is at least 46 bytes.
|
||||
let cut = ZipError::Damaged("the central directory is cut short");
|
||||
if count.saturating_mul(46) > dir.len() {
|
||||
return Err(cut);
|
||||
}
|
||||
let mut entries = HashMap::with_capacity(count);
|
||||
let mut folders = 0;
|
||||
// (start, end, name) of every entry's local header and data.
|
||||
let mut spans: Vec<(usize, usize, String)> = Vec::with_capacity(count);
|
||||
let mut i = 0usize;
|
||||
for _ in 0..count {
|
||||
if u32_at(dir, i) != Some(CENTRAL) {
|
||||
return Err(cut);
|
||||
}
|
||||
let short = |o: usize| u16_at(dir, i + o).ok_or(cut.clone());
|
||||
let long = |o: usize| u32_at(dir, i + o).ok_or(cut.clone());
|
||||
let (flags, method) = (short(8)?, short(10)?);
|
||||
let crc = long(16)?;
|
||||
let (comp32, size32, offset32) = (long(20)?, long(24)?, long(42)?);
|
||||
let (nlen, xlen, clen) = (short(28)? as usize, short(30)? as usize, short(32)? as usize);
|
||||
let raw_name = get(dir, i + 46, nlen).ok_or(cut.clone())?;
|
||||
let extra = get(dir, i + 46 + nlen, xlen).ok_or(cut.clone())?;
|
||||
get(dir, i + 46 + nlen + xlen, clen).ok_or(cut.clone())?;
|
||||
i += 46 + nlen + xlen + clen;
|
||||
|
||||
let name = decode_name(raw_name, flags & 0x0800 != 0, extra)?;
|
||||
let shown = || name.chars().take(200).collect::<String>();
|
||||
// ZIP64: each 32-bit field that overflowed is in the extra field, in this order.
|
||||
let (mut size, mut comp, mut offset) = (u64::from(size32), u64::from(comp32), u64::from(offset32));
|
||||
if size32 == u32::MAX || comp32 == u32::MAX || offset32 == u32::MAX {
|
||||
let z = extra_field(extra, 0x0001).ok_or_else(|| ZipError::DamagedEntry(shown(), "has no ZIP64 sizes"))?;
|
||||
let mut at = 0;
|
||||
for (wide, value) in [(size32, &mut size), (comp32, &mut comp), (offset32, &mut offset)] {
|
||||
if wide == u32::MAX {
|
||||
*value = u64_at(z, at).ok_or_else(|| ZipError::DamagedEntry(shown(), "has its ZIP64 sizes cut short"))?;
|
||||
at += 8;
|
||||
}
|
||||
}
|
||||
}
|
||||
let outside = || ZipError::DamagedEntry(shown(), "is outside the archive");
|
||||
let offset = usize::try_from(offset).map_err(|_| outside())?;
|
||||
let comp = usize::try_from(comp).map_err(|_| outside())?;
|
||||
// An entry's size is checked when it is read: one too large to read may sit unused.
|
||||
let size = usize::try_from(size).unwrap_or(usize::MAX);
|
||||
|
||||
// Its local header, then its data, all before the central directory.
|
||||
if u32_at(data, offset) != Some(LOCAL) {
|
||||
return Err(ZipError::DamagedEntry(shown(), "has no local header"));
|
||||
}
|
||||
let lnlen = u16_at(data, offset + 26).ok_or_else(outside)? as usize;
|
||||
let lxlen = u16_at(data, offset + 28).ok_or_else(outside)? as usize;
|
||||
let start = offset + 30 + lnlen + lxlen;
|
||||
let end = start.checked_add(comp).filter(|&e| e <= dir_at).ok_or_else(outside)?;
|
||||
spans.push((offset, end, shown()));
|
||||
|
||||
let folder = name.ends_with('/') || name.ends_with('\\');
|
||||
let path = clean_path(&name)?;
|
||||
if folder || path.is_empty() {
|
||||
if size != 0 || comp > 2 {
|
||||
return Err(ZipError::DamagedEntry(shown(), "is a folder with data"));
|
||||
}
|
||||
folders += 1;
|
||||
continue;
|
||||
}
|
||||
if method == 8 && size as u64 > (comp as u64).saturating_mul(MAX_RATIO).saturating_add(4096) {
|
||||
return Err(ZipError::Bomb(shown()));
|
||||
}
|
||||
if method == 0 && flags & 0x41 == 0 && size != comp {
|
||||
return Err(ZipError::DamagedEntry(shown(), "is stored with two different sizes"));
|
||||
}
|
||||
if entries.insert(path, Entry { method, flags, crc, comp, size, data: start }).is_some() {
|
||||
return Err(ZipError::Duplicate(shown()));
|
||||
}
|
||||
}
|
||||
// No two entries may share bytes (the overlapping-entries zip bomb).
|
||||
spans.sort_unstable_by_key(|s| s.0);
|
||||
if let Some(w) = spans.windows(2).find(|w| w[0].1 > w[1].0) {
|
||||
return Err(ZipError::Overlap(w[0].2.clone(), w[1].2.clone()));
|
||||
}
|
||||
Ok(Archive { data, entries, folders, budget: Cell::new(MAX_TOTAL) })
|
||||
}
|
||||
|
||||
/// The files' paths (`/`-separated, folders left out).
|
||||
pub fn names(&self) -> impl Iterator<Item = &str> {
|
||||
self.entries.keys().map(String::as_str)
|
||||
}
|
||||
|
||||
/// How many files and folder entries the archive lists.
|
||||
pub fn counts(&self) -> (usize, usize) {
|
||||
(self.entries.len(), self.folders)
|
||||
}
|
||||
|
||||
/// The bytes of entry `name` (path inside the archive, `/`-separated), checked against
|
||||
/// its size and checksum.
|
||||
pub fn read(&self, name: &str) -> Result<Option<Vec<u8>>, ZipError> {
|
||||
let Some(e) = self.entries.get(name) else { return Ok(None) };
|
||||
if e.flags & 0x41 != 0 {
|
||||
return Err(ZipError::Unsupported(name.into(), "encryption"));
|
||||
}
|
||||
if !matches!(e.method, 0 | 8) {
|
||||
return Err(ZipError::Unsupported(name.into(), method_name(e.method)));
|
||||
}
|
||||
if e.size > MAX_ENTRY || e.comp > MAX_ENTRY {
|
||||
return Err(ZipError::TooLarge(name.into()));
|
||||
}
|
||||
let left = self.budget.get();
|
||||
if e.size as u64 > left {
|
||||
return Err(ZipError::TooMuch);
|
||||
}
|
||||
let raw = get(self.data, e.data, e.comp).ok_or_else(|| ZipError::DamagedEntry(name.into(), "is outside the archive"))?;
|
||||
let out = match e.method {
|
||||
0 => raw.to_vec(),
|
||||
_ => miniz_oxide::inflate::decompress_to_vec_with_limit(raw, e.size.max(1)).map_err(|err| {
|
||||
let why = if err.status == miniz_oxide::inflate::TINFLStatus::HasMoreOutput {
|
||||
"expands past the size it gives"
|
||||
} else {
|
||||
"has damaged compressed data"
|
||||
};
|
||||
ZipError::DamagedEntry(name.into(), why)
|
||||
})?,
|
||||
};
|
||||
if out.len() != e.size {
|
||||
return Err(ZipError::DamagedEntry(name.into(), "is not the size it gives"));
|
||||
}
|
||||
if crc32(&out) != e.crc {
|
||||
return Err(ZipError::DamagedEntry(name.into(), "fails its checksum"));
|
||||
}
|
||||
self.budget.set(left - e.size as u64);
|
||||
Ok(Some(out))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../tests/support/fla.rs"]
|
||||
pub(crate) mod pack;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,297 @@
|
||||
//! The zip reader against the layouts writers produce and the archives an attacker would
|
||||
//! (ADR 0054). Archives are made by the independent writer in `tests/support/fla.rs`.
|
||||
|
||||
use super::pack::{self, Raw, write};
|
||||
use super::*;
|
||||
|
||||
fn zip(files: &[(&str, &[u8], bool)]) -> Vec<u8> {
|
||||
write(&files.iter().map(|(n, b, d)| Raw::new(n, b, *d)).collect::<Vec<_>>(), false)
|
||||
}
|
||||
|
||||
/// The error opening `z`, or reading every file in it.
|
||||
fn error(z: &[u8]) -> ZipError {
|
||||
match Archive::open(z) {
|
||||
Err(e) => e,
|
||||
Ok(a) => {
|
||||
let names: Vec<String> = a.names().map(str::to_string).collect();
|
||||
names.iter().find_map(|n| a.read(n).err()).expect("an error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_stored_and_deflated_entries() {
|
||||
let z = zip(&[("DOMDocument.xml", b"<DOMDocument/>", true), ("LIBRARY\\Ball.xml", b"<DOMSymbolItem/>", false)]);
|
||||
let a = Archive::open(&z).unwrap();
|
||||
assert_eq!(a.read("DOMDocument.xml").unwrap().unwrap(), b"<DOMDocument/>");
|
||||
assert_eq!(a.read("LIBRARY/Ball.xml").unwrap().unwrap(), b"<DOMSymbolItem/>", "backslashes normalised");
|
||||
assert_eq!(a.read("missing").unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_checksum_matches_the_published_check_value() {
|
||||
assert_eq!(crc32(b"123456789"), 0xCBF4_3926);
|
||||
assert_eq!(crc32(b""), 0);
|
||||
let bytes: Vec<u8> = (0..5000u32).map(|i| (i * 7 + i / 13) as u8).collect();
|
||||
assert_eq!(crc32(&bytes), pack::crc32(&bytes), "the table matches the bitwise form");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_layout_a_writer_uses_reads_the_same() {
|
||||
let files: std::collections::BTreeMap<String, Vec<u8>> =
|
||||
[("DOMDocument.xml", "<DOMDocument/>"), ("LIBRARY/Café/Ünïcödé ★.xml", "<DOMSymbolItem/>"), ("bin/M 1 1.dat", "data")]
|
||||
.iter()
|
||||
.map(|(p, b)| (p.to_string(), b.as_bytes().to_vec()))
|
||||
.collect();
|
||||
for v in pack::VARIANTS {
|
||||
let z = pack::pack(&files, v);
|
||||
let a = Archive::open(&z).unwrap_or_else(|e| panic!("{}: {e}", v.name));
|
||||
assert_eq!(a.counts().0, 3 + usize::from(v.mimetype), "{}", v.name);
|
||||
for (path, body) in &files {
|
||||
assert_eq!(a.read(path).unwrap_or_else(|e| panic!("{}: {e}", v.name)).as_deref(), Some(body.as_slice()), "{}: {path}", v.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_are_cp437_unless_flagged_or_utf8() {
|
||||
// "Café.xml" in CP437 (é is 0x82), not valid UTF-8, without the flag.
|
||||
let mut r = Raw::new("", b"x", false);
|
||||
(r.name, r.utf8) = (b"LIBRARY/Caf\x82.xml".to_vec(), false);
|
||||
let z = write(&[r.clone()], false);
|
||||
assert_eq!(Archive::open(&z).unwrap().names().collect::<Vec<_>>(), ["LIBRARY/Café.xml"]);
|
||||
assert_eq!(pack::cp437("LIBRARY/Café.xml").unwrap(), b"LIBRARY/Caf\x82.xml");
|
||||
// Flagged as UTF-8 but isn't: refused, not guessed.
|
||||
r.utf8 = true;
|
||||
assert!(matches!(Archive::open(&write(&[r], false)).err(), Some(ZipError::Damaged(_))));
|
||||
// A Unicode path field whose checksum doesn't match the name it stands in for is ignored.
|
||||
let mut r = Raw::new("plain.xml", b"x", false);
|
||||
r.utf8 = false;
|
||||
r.extra = [0x75, 0x70, 9, 0, 1, 0, 0, 0, 0, b'o', b't', b'h', b'r'].to_vec();
|
||||
assert_eq!(Archive::open(&write(&[r], false)).unwrap().names().collect::<Vec<_>>(), ["plain.xml"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damaged_archives_are_errors() {
|
||||
assert_eq!(Archive::open(b"not a zip").err(), Some(ZipError::NotZip));
|
||||
assert_eq!(Archive::open(b"").err(), Some(ZipError::NotZip));
|
||||
let mut z = zip(&[("a", b"hello", true)]);
|
||||
let n = z.len();
|
||||
// Point the directory past the end.
|
||||
z[n - 6] = 0xff;
|
||||
assert!(Archive::open(&z).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_truncated_central_directory_is_damaged() {
|
||||
let z = zip(&[("DOMDocument.xml", b"<DOMDocument/>", true), ("b.xml", b"<b/>", true), ("c.xml", b"<c/>", false)]);
|
||||
let dir_at = u32_at(&z, z.len() - 6).unwrap() as usize;
|
||||
let end = z.len() - 22;
|
||||
// Every cut inside the directory, keeping the end record (which still claims 3 entries).
|
||||
for cut in dir_at..end {
|
||||
let mut t = z[..cut].to_vec();
|
||||
t.extend_from_slice(&z[end..]);
|
||||
let e = Archive::open(&t).err().unwrap_or_else(|| panic!("cut at {cut} opened"));
|
||||
assert!(!e.to_string().is_empty());
|
||||
}
|
||||
// The end record says more entries than there are.
|
||||
let mut t = z.clone();
|
||||
t[end + 8] = 9;
|
||||
t[end + 10] = 9;
|
||||
assert_eq!(Archive::open(&t).err(), Some(ZipError::Damaged("the central directory is cut short")));
|
||||
// Cut short anywhere at all: an error, never a panic.
|
||||
for cut in 0..z.len() {
|
||||
let _ = Archive::open(&z[..cut]).map(|a| a.read("DOMDocument.xml"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlapping_entries_are_refused() {
|
||||
// Two names for one local header (the overlapping-file zip bomb).
|
||||
let mut b = Raw::new("b.xml", b"<a/>", true);
|
||||
b.share = Some(0);
|
||||
let z = write(&[Raw::new("a.xml", b"<a/>", true), b], false);
|
||||
assert!(matches!(Archive::open(&z).err(), Some(ZipError::Overlap(..))), "{:?}", Archive::open(&z).err());
|
||||
// An entry whose stated compressed size runs into the next one.
|
||||
let mut z = zip(&[("a.xml", b"aaaaaaaaaa", false), ("b.xml", b"bbbbbbbbbb", false)]);
|
||||
let dir_at = u32_at(&z, z.len() - 6).unwrap() as usize;
|
||||
z[dir_at + 20] = 20; // a's compressed size, in its central record
|
||||
z[dir_at + 24] = 20;
|
||||
assert!(matches!(Archive::open(&z).err(), Some(ZipError::Overlap(..))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entries_never_reach_past_their_data() {
|
||||
// An entry's data running into the central directory, or past the end.
|
||||
for size in [64u32, 0xffff_fff0] {
|
||||
let mut z = zip(&[("a.xml", b"aaaaaaaaaa", false)]);
|
||||
let dir_at = u32_at(&z, z.len() - 6).unwrap() as usize;
|
||||
z[dir_at + 20..dir_at + 24].copy_from_slice(&size.to_le_bytes());
|
||||
assert_eq!(Archive::open(&z).err(), Some(ZipError::DamagedEntry("a.xml".into(), "is outside the archive")));
|
||||
}
|
||||
// A local header offset past the end.
|
||||
let mut z = zip(&[("a.xml", b"aaaaaaaaaa", false)]);
|
||||
let dir_at = u32_at(&z, z.len() - 6).unwrap() as usize;
|
||||
z[dir_at + 42..dir_at + 46].copy_from_slice(&0x7fff_0000u32.to_le_bytes());
|
||||
assert_eq!(Archive::open(&z).err(), Some(ZipError::DamagedEntry("a.xml".into(), "has no local header")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lying_sizes_and_checksums_are_damaged() {
|
||||
// Says 100 bytes, inflates to 1000.
|
||||
let body = vec![b'x'; 1000];
|
||||
let mut r = Raw::new("a.xml", &body, true);
|
||||
r.size = Some(100);
|
||||
assert_eq!(error(&write(&[r], false)), ZipError::DamagedEntry("a.xml".into(), "expands past the size it gives"));
|
||||
// Says 2000, inflates to 1000.
|
||||
let mut r = Raw::new("a.xml", &body, true);
|
||||
r.size = Some(2000);
|
||||
assert_eq!(error(&write(&[r], false)), ZipError::DamagedEntry("a.xml".into(), "is not the size it gives"));
|
||||
// A stored entry with two sizes.
|
||||
let mut r = Raw::new("a.xml", &body, false);
|
||||
r.size = Some(10);
|
||||
assert_eq!(error(&write(&[r], false)), ZipError::DamagedEntry("a.xml".into(), "is stored with two different sizes"));
|
||||
// The wrong checksum.
|
||||
let mut r = Raw::new("a.xml", &body, true);
|
||||
r.crc = Some(12345);
|
||||
assert_eq!(error(&write(&[r], false)), ZipError::DamagedEntry("a.xml".into(), "fails its checksum"));
|
||||
// Garbage instead of deflate data.
|
||||
let mut r = Raw::new("a.xml", &body, true);
|
||||
r.compressed = Some(vec![0xff; 40]);
|
||||
assert_eq!(error(&write(&[r], false)), ZipError::DamagedEntry("a.xml".into(), "has damaged compressed data"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compression_bombs_are_refused() {
|
||||
// A real 64 MB of zeros deflates past the ratio cap: refused before inflating.
|
||||
let zeros = vec![0u8; 64 << 20];
|
||||
let z = zip(&[("bomb.xml", &zeros, true)]);
|
||||
assert!(z.len() < 100_000, "{}", z.len());
|
||||
assert_eq!(Archive::open(&z).err(), Some(ZipError::Bomb("bomb.xml".into())));
|
||||
// A size claim no deflate stream of that length could meet.
|
||||
let mut r = Raw::new("a.xml", &[7; 100], true);
|
||||
r.size = Some(200 << 20);
|
||||
assert_eq!(Archive::open(&write(&[r], false)).err(), Some(ZipError::Bomb("a.xml".into())));
|
||||
// One entry larger than an entry may be, at a believable ratio: refused before inflating.
|
||||
let mut r = Raw::new("big.dat", b"", true);
|
||||
r.compressed = Some(vec![0; 300 << 10]);
|
||||
r.size = Some(MAX_ENTRY as u64 + 1);
|
||||
let z = write(&[r], false);
|
||||
assert_eq!(error(&z), ZipError::TooLarge("big.dat".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_archive_hands_out_at_most_its_budget() {
|
||||
let body = vec![b'a'; 4096];
|
||||
let z = zip(&[("a.xml", &body, true)]);
|
||||
let a = Archive::open(&z).unwrap();
|
||||
a.budget.set(10_000);
|
||||
assert!(a.read("a.xml").is_ok() && a.read("a.xml").is_ok());
|
||||
assert_eq!(a.read("a.xml").err(), Some(ZipError::TooMuch), "a third read passes 10,000 bytes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsafe_paths_are_refused() {
|
||||
for name in [
|
||||
"../escape.xml",
|
||||
"LIBRARY/../../escape.xml",
|
||||
"/etc/passwd",
|
||||
"\\Windows\\win.ini",
|
||||
"C:\\Windows\\win.ini",
|
||||
"C:escape.xml",
|
||||
"LIBRARY\\..\\..\\x",
|
||||
"a\0b",
|
||||
] {
|
||||
let z = zip(&[("DOMDocument.xml", b"<DOMDocument/>", true), (name, b"x", false)]);
|
||||
assert!(matches!(Archive::open(&z).err(), Some(ZipError::Unsafe(_))), "{name:?}: {:?}", Archive::open(&z).err());
|
||||
}
|
||||
// Harmless odd names are tidied, not refused.
|
||||
let z = zip(&[("./LIBRARY//a.xml", b"x", false)]);
|
||||
assert_eq!(Archive::open(&z).unwrap().names().collect::<Vec<_>>(), ["LIBRARY/a.xml"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_names_are_refused() {
|
||||
let z = zip(&[("LIBRARY/a.xml", b"one", false), ("LIBRARY\\a.xml", b"two", false)]);
|
||||
assert_eq!(Archive::open(&z).err(), Some(ZipError::Duplicate("LIBRARY\\a.xml".into())));
|
||||
// Folders listed twice are harmless.
|
||||
let z = zip(&[("LIBRARY/", b"", false), ("LIBRARY\\", b"", false), ("LIBRARY/a.xml", b"one", false)]);
|
||||
assert_eq!(Archive::open(&z).unwrap().counts(), (1, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deeply_nested_folders_are_refused() {
|
||||
let ok = format!("{}a.xml", "f/".repeat(MAX_DEPTH));
|
||||
assert!(Archive::open(&zip(&[(&ok, b"x", false)])).is_ok());
|
||||
let deep = format!("{}a.xml", "f/".repeat(MAX_DEPTH + 1));
|
||||
assert!(matches!(Archive::open(&zip(&[(&deep, b"x", false)])).err(), Some(ZipError::TooDeep(_))));
|
||||
// As deep as a name can be: refused quickly, not recursed into.
|
||||
let deepest = "f/".repeat(30_000);
|
||||
assert!(matches!(Archive::open(&zip(&[(&deepest, b"", false)])).err(), Some(ZipError::TooDeep(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_features_say_what_they_are() {
|
||||
let mut r = Raw::new("a.xml", b"secret", false);
|
||||
r.flags = 1;
|
||||
assert_eq!(error(&write(&[r], false)), ZipError::Unsupported("a.xml".into(), "encryption"));
|
||||
let mut r = Raw::new("a.xml", b"x", false);
|
||||
r.method = Some(14);
|
||||
assert_eq!(error(&write(&[r], false)), ZipError::Unsupported("a.xml".into(), "LZMA compression"));
|
||||
// Split archives.
|
||||
let mut z = zip(&[("a.xml", b"x", false)]);
|
||||
let end = z.len() - 22;
|
||||
z[end + 4] = 1;
|
||||
assert_eq!(Archive::open(&z).err(), Some(ZipError::UnsupportedArchive("split across several files")));
|
||||
// Too many entries for the directory to hold, and more than the cap.
|
||||
let mut z = zip(&[("a.xml", b"x", false)]);
|
||||
let end = z.len() - 22;
|
||||
z[end + 8..end + 12].copy_from_slice(&[0xfe, 0xff, 0xfe, 0xff]);
|
||||
assert!(Archive::open(&z).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zip64_archives_open_and_their_damage_is_named() {
|
||||
let mut a = Raw::new("a.xml", b"<a/>", true);
|
||||
a.zip64 = true;
|
||||
let mut b = Raw::new("b.xml", b"<b/>", false);
|
||||
(b.zip64, b.descriptor) = (true, true);
|
||||
let z = write(&[a.clone(), b], true);
|
||||
let arch = Archive::open(&z).unwrap();
|
||||
assert_eq!(arch.read("b.xml").unwrap().unwrap(), b"<b/>");
|
||||
// The locator gone.
|
||||
let mut t = z.clone();
|
||||
let loc = t.len() - 22 - 20;
|
||||
t[loc] = 0;
|
||||
assert_eq!(Archive::open(&t).err(), Some(ZipError::Damaged("the ZIP64 end record is missing")));
|
||||
// An entry that says ZIP64 without the extra field.
|
||||
a.zip64 = false;
|
||||
let mut t = write(&[a], false);
|
||||
let dir_at = u32_at(&t, t.len() - 6).unwrap() as usize;
|
||||
t[dir_at + 24..dir_at + 28].copy_from_slice(&u32::MAX.to_le_bytes());
|
||||
assert_eq!(Archive::open(&t).err(), Some(ZipError::DamagedEntry("a.xml".into(), "has no ZIP64 sizes")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_read_as_sentences() {
|
||||
let all = [
|
||||
ZipError::NotZip,
|
||||
ZipError::Damaged("the central directory is cut short"),
|
||||
ZipError::DamagedEntry("a.xml".into(), "fails its checksum"),
|
||||
ZipError::UnsupportedArchive("split across several files"),
|
||||
ZipError::Unsupported("a.xml".into(), "encryption"),
|
||||
ZipError::TooLarge("a.xml".into()),
|
||||
ZipError::TooMuch,
|
||||
ZipError::TooMany,
|
||||
ZipError::Bomb("a.xml".into()),
|
||||
ZipError::Overlap("a.xml".into(), "b.xml".into()),
|
||||
ZipError::Unsafe("../a.xml".into()),
|
||||
ZipError::Duplicate("a.xml".into()),
|
||||
ZipError::TooDeep("f/f/a.xml".into()),
|
||||
];
|
||||
for e in all {
|
||||
let s = e.to_string();
|
||||
assert!(s.len() > 12 && !s.contains("{") && s.chars().next().is_some_and(|c| !c.is_uppercase() || c == 'A'), "{s}");
|
||||
}
|
||||
assert_eq!(ZipError::TooMuch.to_string(), "the archive asks for more than 1024 MB in all");
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
//! Test support: packs an XFL package into `.fla` (zip) archives in memory, in each of the ways
|
||||
//! zip writers lay one out (ADR 0054), and writes hand-made entries for hostile archives.
|
||||
//!
|
||||
//! Written from the published format (PKWARE APPNOTE 6.3), independently of the reader in
|
||||
//! `src/zip.rs` (it has its own checksum), so a misreading in one is caught by the other.
|
||||
//! Shared by the xfl crate's unit and corpus tests and the engine's golden tests (`#[path]`).
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// How a variant compresses its entries.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Compression {
|
||||
Deflate,
|
||||
Stored,
|
||||
/// XML deflated, images stored (as most writers do), the rest alternating.
|
||||
Mixed,
|
||||
}
|
||||
|
||||
/// How a variant writes entry names.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Names {
|
||||
/// UTF-8 with flag bit 11 set.
|
||||
Utf8Flag,
|
||||
/// UTF-8 without the flag (common, though the format says CP437).
|
||||
Utf8Unflagged,
|
||||
/// CP437 bytes without the flag, as the format specifies (names CP437 can't spell are
|
||||
/// written as UTF-8 with the flag).
|
||||
Cp437,
|
||||
/// An ASCII stand-in name with the real one in the Info-ZIP Unicode path field.
|
||||
UnicodeExtra,
|
||||
}
|
||||
|
||||
/// One way of packing a package.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Variant {
|
||||
pub name: &'static str,
|
||||
pub compression: Compression,
|
||||
/// An entry for every folder, before the files in it.
|
||||
pub folders: bool,
|
||||
/// The `mimetype` entry first, stored, as Animate writes it.
|
||||
pub mimetype: bool,
|
||||
/// `\` between folders, as some Windows tools write.
|
||||
pub backslashes: bool,
|
||||
pub names: Names,
|
||||
/// Sizes and checksums after the data (bit 3), as streaming writers do.
|
||||
pub descriptors: bool,
|
||||
/// ZIP64 sizes on every entry and a ZIP64 end record.
|
||||
pub zip64: bool,
|
||||
}
|
||||
|
||||
const BASE: Variant = Variant {
|
||||
name: "",
|
||||
compression: Compression::Deflate,
|
||||
folders: false,
|
||||
mimetype: false,
|
||||
backslashes: false,
|
||||
names: Names::Utf8Flag,
|
||||
descriptors: false,
|
||||
zip64: false,
|
||||
};
|
||||
|
||||
/// Every variant each corpus package is checked in.
|
||||
pub const VARIANTS: &[Variant] = &[
|
||||
Variant { name: "as Animate writes it (mimetype first, deflated)", mimetype: true, ..BASE },
|
||||
Variant { name: "stored", compression: Compression::Stored, ..BASE },
|
||||
Variant { name: "mixed, with folder entries", compression: Compression::Mixed, folders: true, mimetype: true, ..BASE },
|
||||
Variant { name: "Windows separators, unflagged UTF-8 names", backslashes: true, folders: true, names: Names::Utf8Unflagged, ..BASE },
|
||||
Variant { name: "CP437 names", names: Names::Cp437, compression: Compression::Mixed, ..BASE },
|
||||
Variant { name: "Unicode path fields", names: Names::UnicodeExtra, ..BASE },
|
||||
Variant { name: "data descriptors", descriptors: true, compression: Compression::Mixed, ..BASE },
|
||||
Variant { name: "ZIP64", zip64: true, compression: Compression::Mixed, ..BASE },
|
||||
Variant {
|
||||
name: "everything at once",
|
||||
compression: Compression::Mixed,
|
||||
folders: true,
|
||||
mimetype: true,
|
||||
backslashes: true,
|
||||
names: Names::Cp437,
|
||||
descriptors: true,
|
||||
zip64: true,
|
||||
},
|
||||
];
|
||||
|
||||
/// One entry as written. Fields a hostile test may change are public.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Raw {
|
||||
/// The name's bytes as stored.
|
||||
pub name: Vec<u8>,
|
||||
/// The uncompressed bytes.
|
||||
pub body: Vec<u8>,
|
||||
pub deflate: bool,
|
||||
/// General purpose flag bits besides 3 and 11 (bit 0: encrypted).
|
||||
pub flags: u16,
|
||||
pub utf8: bool,
|
||||
pub descriptor: bool,
|
||||
pub zip64: bool,
|
||||
/// Overrides: the method, the uncompressed size and the checksum the headers give.
|
||||
pub method: Option<u16>,
|
||||
pub size: Option<u64>,
|
||||
pub crc: Option<u32>,
|
||||
/// Extra field bytes in the central directory (besides ZIP64's).
|
||||
pub extra: Vec<u8>,
|
||||
/// Points this entry's central record at entry `n`'s local header instead of its own.
|
||||
pub share: Option<usize>,
|
||||
/// Stored as compressed bytes: these instead of compressing `body`.
|
||||
pub compressed: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl Raw {
|
||||
pub fn new(name: &str, body: &[u8], deflate: bool) -> Raw {
|
||||
Raw { name: name.as_bytes().to_vec(), body: body.to_vec(), deflate, utf8: true, ..Raw::default() }
|
||||
}
|
||||
}
|
||||
|
||||
/// CRC-32 (ISO-HDLC), bit by bit.
|
||||
pub fn crc32(data: &[u8]) -> u32 {
|
||||
let mut c = !0u32;
|
||||
for &b in data {
|
||||
c ^= u32::from(b);
|
||||
for _ in 0..8 {
|
||||
c = if c & 1 != 0 { (c >> 1) ^ 0xEDB8_8320 } else { c >> 1 };
|
||||
}
|
||||
}
|
||||
!c
|
||||
}
|
||||
|
||||
/// `s` in CP437, if every character is in it.
|
||||
pub fn cp437(s: &str) -> Option<Vec<u8>> {
|
||||
const HIGH: &str =
|
||||
"ÇüéâäàåçêëèïîìÄÅÉæÆôöòûùÿÖÜ¢£¥₧ƒáíóúñѪº¿⌐¬½¼¡«»░▒▓│┤╡╢╖╕╣║╗╝╜╛┐└┴┬├─┼╞╟╚╔╩╦╠═╬╧╨╤╥╙╘╒╓╫╪┘┌█▄▌▐▀αßΓπΣσµτΦΘΩδ∞φε∩≡±≥≤⌠⌡÷≈°∙·√ⁿ²■\u{a0}";
|
||||
s.chars().map(|c| if c.is_ascii() { Some(c as u8) } else { HIGH.chars().position(|h| h == c).map(|i| 0x80 + i as u8) }).collect()
|
||||
}
|
||||
|
||||
fn le16(out: &mut Vec<u8>, v: u16) {
|
||||
out.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
fn le32(out: &mut Vec<u8>, v: u32) {
|
||||
out.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
fn le64(out: &mut Vec<u8>, v: u64) {
|
||||
out.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
|
||||
/// Writes `entries` as a zip archive, with a ZIP64 end record if `zip64_end`.
|
||||
pub fn write(entries: &[Raw], zip64_end: bool) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
let mut central = Vec::new();
|
||||
let mut offsets = Vec::new();
|
||||
// (time, date): 2025-11-11 12:00.
|
||||
let (time, date) = (0x6000u16, 0x5B6Bu16);
|
||||
for e in entries {
|
||||
let data = match &e.compressed {
|
||||
Some(c) => c.clone(),
|
||||
None if e.deflate => miniz_oxide::deflate::compress_to_vec(&e.body, 6),
|
||||
None => e.body.clone(),
|
||||
};
|
||||
let method = e.method.unwrap_or(if e.deflate { 8 } else { 0 });
|
||||
let crc = e.crc.unwrap_or_else(|| crc32(&e.body));
|
||||
let size = e.size.unwrap_or(e.body.len() as u64);
|
||||
let comp = data.len() as u64;
|
||||
let flags = e.flags | if e.descriptor { 8 } else { 0 } | if e.utf8 { 0x800 } else { 0 };
|
||||
let version: u16 = if e.zip64 { 45 } else { 20 };
|
||||
let offset = out.len() as u64;
|
||||
offsets.push(offset);
|
||||
// The local header.
|
||||
le32(&mut out, 0x0403_4b50);
|
||||
le16(&mut out, version);
|
||||
le16(&mut out, flags);
|
||||
le16(&mut out, method);
|
||||
le16(&mut out, time);
|
||||
le16(&mut out, date);
|
||||
let local_extra = if e.zip64 {
|
||||
let mut x = Vec::new();
|
||||
le16(&mut x, 1);
|
||||
le16(&mut x, 16);
|
||||
le64(&mut x, if e.descriptor { 0 } else { size });
|
||||
le64(&mut x, if e.descriptor { 0 } else { comp });
|
||||
x
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
if e.descriptor {
|
||||
out.extend_from_slice(&[0; 12]);
|
||||
} else {
|
||||
le32(&mut out, crc);
|
||||
le32(&mut out, if e.zip64 { u32::MAX } else { comp as u32 });
|
||||
le32(&mut out, if e.zip64 { u32::MAX } else { size as u32 });
|
||||
}
|
||||
le16(&mut out, e.name.len() as u16);
|
||||
le16(&mut out, local_extra.len() as u16);
|
||||
out.extend_from_slice(&e.name);
|
||||
out.extend_from_slice(&local_extra);
|
||||
out.extend_from_slice(&data);
|
||||
if e.descriptor {
|
||||
le32(&mut out, 0x0807_4b50);
|
||||
le32(&mut out, crc);
|
||||
if e.zip64 {
|
||||
le64(&mut out, comp);
|
||||
le64(&mut out, size);
|
||||
} else {
|
||||
le32(&mut out, comp as u32);
|
||||
le32(&mut out, size as u32);
|
||||
}
|
||||
}
|
||||
// Its central record.
|
||||
let at = e.share.and_then(|n| offsets.get(n).copied()).unwrap_or(offset);
|
||||
let mut extra = Vec::new();
|
||||
if e.zip64 {
|
||||
le16(&mut extra, 1);
|
||||
le16(&mut extra, 24);
|
||||
le64(&mut extra, size);
|
||||
le64(&mut extra, comp);
|
||||
le64(&mut extra, at);
|
||||
}
|
||||
extra.extend_from_slice(&e.extra);
|
||||
le32(&mut central, 0x0201_4b50);
|
||||
le16(&mut central, version);
|
||||
le16(&mut central, version);
|
||||
le16(&mut central, flags);
|
||||
le16(&mut central, method);
|
||||
le16(&mut central, time);
|
||||
le16(&mut central, date);
|
||||
le32(&mut central, crc);
|
||||
le32(&mut central, if e.zip64 { u32::MAX } else { comp as u32 });
|
||||
le32(&mut central, if e.zip64 { u32::MAX } else { size as u32 });
|
||||
le16(&mut central, e.name.len() as u16);
|
||||
le16(&mut central, extra.len() as u16);
|
||||
le16(&mut central, 0); // comment
|
||||
le16(&mut central, 0); // disk
|
||||
le16(&mut central, 0); // internal attributes
|
||||
let folder = e.name.last().is_some_and(|&c| c == b'/' || c == b'\\');
|
||||
le32(&mut central, if folder { 0x10 } else { 0 });
|
||||
le32(&mut central, if e.zip64 { u32::MAX } else { at as u32 });
|
||||
central.extend_from_slice(&e.name);
|
||||
central.extend_from_slice(&extra);
|
||||
}
|
||||
let dir_at = out.len() as u64;
|
||||
out.extend_from_slice(¢ral);
|
||||
let n = entries.len() as u64;
|
||||
if zip64_end {
|
||||
let record = out.len() as u64;
|
||||
le32(&mut out, 0x0606_4b50);
|
||||
le64(&mut out, 44);
|
||||
le16(&mut out, 45);
|
||||
le16(&mut out, 45);
|
||||
le32(&mut out, 0);
|
||||
le32(&mut out, 0);
|
||||
le64(&mut out, n);
|
||||
le64(&mut out, n);
|
||||
le64(&mut out, central.len() as u64);
|
||||
le64(&mut out, dir_at);
|
||||
le32(&mut out, 0x0706_4b50);
|
||||
le32(&mut out, 0);
|
||||
le64(&mut out, record);
|
||||
le32(&mut out, 1);
|
||||
}
|
||||
le32(&mut out, 0x0605_4b50);
|
||||
le32(&mut out, 0); // disks
|
||||
let (count, size, at) = if zip64_end { (u16::MAX, u32::MAX, u32::MAX) } else { (n as u16, central.len() as u32, dir_at as u32) };
|
||||
le16(&mut out, count);
|
||||
le16(&mut out, count);
|
||||
le32(&mut out, size);
|
||||
le32(&mut out, at);
|
||||
le16(&mut out, 0);
|
||||
out
|
||||
}
|
||||
|
||||
/// The entries `files` (paths inside the package) become in `v`.
|
||||
pub fn entries(files: &BTreeMap<String, Vec<u8>>, v: &Variant) -> Vec<Raw> {
|
||||
let mut out = Vec::new();
|
||||
let named = |path: &str, body: &[u8], deflate: bool| {
|
||||
let shown = if v.backslashes { path.replace('/', "\\") } else { path.to_string() };
|
||||
let mut r = Raw::new(&shown, body, deflate);
|
||||
r.descriptor = v.descriptors;
|
||||
r.zip64 = v.zip64;
|
||||
match v.names {
|
||||
Names::Utf8Flag => {}
|
||||
Names::Utf8Unflagged => r.utf8 = false,
|
||||
Names::Cp437 => {
|
||||
if let Some(b) = cp437(&shown) {
|
||||
(r.name, r.utf8) = (b, false);
|
||||
}
|
||||
}
|
||||
Names::UnicodeExtra => {
|
||||
let stand_in: Vec<u8> = shown.chars().map(|c| if c.is_ascii() { c as u8 } else { b'_' }).collect();
|
||||
le16(&mut r.extra, 0x7075);
|
||||
le16(&mut r.extra, (5 + shown.len()) as u16);
|
||||
r.extra.push(1);
|
||||
le32(&mut r.extra, crc32(&stand_in));
|
||||
r.extra.extend_from_slice(shown.as_bytes());
|
||||
(r.name, r.utf8) = (stand_in, false);
|
||||
}
|
||||
}
|
||||
r
|
||||
};
|
||||
if v.mimetype {
|
||||
out.push(named("mimetype", b"application/vnd.adobe.xfl", false));
|
||||
}
|
||||
let mut made = std::collections::BTreeSet::new();
|
||||
for (i, (path, body)) in files.iter().enumerate() {
|
||||
if v.folders {
|
||||
let parts: Vec<&str> = path.split('/').collect();
|
||||
for k in 1..parts.len() {
|
||||
let folder = format!("{}/", parts[..k].join("/"));
|
||||
if made.insert(folder.clone()) {
|
||||
out.push(named(&folder, b"", false));
|
||||
}
|
||||
}
|
||||
}
|
||||
let image = [".png", ".jpg", ".jpeg", ".gif"].iter().any(|x| path.to_ascii_lowercase().ends_with(x));
|
||||
let deflate = match v.compression {
|
||||
Compression::Deflate => true,
|
||||
Compression::Stored => false,
|
||||
Compression::Mixed => path.ends_with(".xml") || (!image && i % 2 == 0),
|
||||
};
|
||||
out.push(named(path, body, deflate));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `files` packed as a `.fla` in variant `v`.
|
||||
pub fn pack(files: &BTreeMap<String, Vec<u8>>, v: &Variant) -> Vec<u8> {
|
||||
write(&entries(files, v), v.zip64)
|
||||
}
|
||||
|
||||
/// A package folder's files by their path inside it, leaving out its `golden/` renders.
|
||||
pub fn package(dir: &std::path::Path) -> BTreeMap<String, Vec<u8>> {
|
||||
fn walk(dir: &std::path::Path, prefix: &str, out: &mut BTreeMap<String, Vec<u8>>) {
|
||||
let mut list: Vec<_> = std::fs::read_dir(dir).map(|r| r.flatten().collect()).unwrap_or_default();
|
||||
list.sort_by_key(|e| e.file_name());
|
||||
for e in list {
|
||||
let name = e.file_name().to_string_lossy().into_owned();
|
||||
let path = if prefix.is_empty() { name.clone() } else { format!("{prefix}/{name}") };
|
||||
if e.path().is_dir() {
|
||||
if path != "golden" {
|
||||
walk(&e.path(), &path, out);
|
||||
}
|
||||
} else if let Ok(b) = std::fs::read(e.path()) {
|
||||
out.insert(path, b);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = BTreeMap::new();
|
||||
walk(dir, "", &mut out);
|
||||
out
|
||||
}
|
||||
Reference in new issue
Block a user