Merge pull request #1223 from storytold/codex/anicraft-xfl-format

Add bounded XFL FLA package reader
This commit is contained in:
Dylan M. Taylor authored and GitHub committed 2026-11-13 22:01:31 -05:00
commit 427fa15899
7 files changed
+1329

No files matched your search

Generated
+8
View File
@@ -2626,6 +2626,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"
+1
View File
@@ -142,6 +142,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" }
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "craft-xfl"
description = "Bounded XFL/FLA package reader and native shape-edge parser."
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 }
+152
View File
@@ -0,0 +1,152 @@
//! XFL's compact shape-edge attribute grammar.
//!
//! An edge's `edges` attribute uses twips (1/20 pixel): `!x y` moves, `|x y` and `/x y` draw a
//! line, `[cx cy x y` and `]cx cy x y` draw a quadratic curve, and `Sn` marks selection state.
//! Coordinates may be decimal or signed 24.8 fixed-point hexadecimal (`#A.8` is 10.5).
//!
//! This module returns neutral points and segments so consumers can map them to their own
//! geometry model. Like Animate files in the wild, a damaged suffix ends parsing while keeping
//! the valid prefix. The segment cap bounds output allocation.
/// Most segments parsed from one edge attribute.
pub const MAX_EDGE_SEGMENTS: usize = 2_000_000;
/// A point in XFL's edge geometry, expressed in pixels after twips conversion.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Point {
pub x: f32,
pub y: f32,
}
/// One line (`control` is absent) or quadratic curve segment.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Segment {
pub from: Point,
pub control: Option<Point>,
pub to: Point,
}
const TWIPS_PER_PIXEL: f32 = 20.0;
fn number(input: &[u8], at: &mut usize) -> Option<f32> {
while input.get(*at).is_some_and(|b| b.is_ascii_whitespace()) {
*at += 1;
}
let start = *at;
if input.get(*at) == Some(&b'#') {
*at += 1;
let integer_start = *at;
while input.get(*at).is_some_and(u8::is_ascii_hexdigit) {
*at += 1;
}
let integer = std::str::from_utf8(input.get(integer_start..*at)?).ok()?;
let mut fraction = 0_u32;
if input.get(*at) == Some(&b'.') {
*at += 1;
let fraction_start = *at;
while input.get(*at).is_some_and(u8::is_ascii_hexdigit) && *at - fraction_start < 2 {
*at += 1;
}
let digits = std::str::from_utf8(input.get(fraction_start..*at)?).ok()?;
// A single hex digit is the high nibble of the 8-bit fractional part.
fraction = u32::from_str_radix(digits, 16).ok()? << if digits.len() == 1 { 4 } else { 0 };
}
let whole = u32::from_str_radix(if integer.is_empty() { "0" } else { integer }, 16).ok()?;
let fixed = whole.wrapping_shl(8) | fraction;
return Some((fixed as i32) as f32 / 256.0);
}
if matches!(input.get(*at), Some(b'-' | b'+')) {
*at += 1;
}
while input.get(*at).is_some_and(|b| b.is_ascii_digit() || matches!(b, b'.' | b'e' | b'E')) {
*at += 1;
}
std::str::from_utf8(input.get(start..*at)?).ok()?.parse::<f32>().ok().filter(|v| v.is_finite())
}
fn point(input: &[u8], at: &mut usize) -> Option<Point> {
Some(Point { x: number(input, at)? / TWIPS_PER_PIXEL, y: number(input, at)? / TWIPS_PER_PIXEL })
}
/// Parses an XFL `<Edge edges="…">` attribute into pixel-coordinate line and quadratic segments.
///
/// Stops at the first unknown or malformed command, preserving the valid prefix. The current
/// point starts at the origin. Move and selection commands do not produce segments.
pub fn parse_edge_segments(source: &str) -> Vec<Segment> {
let input = source.as_bytes();
let mut at = 0;
let mut current = Point::default();
let mut segments = Vec::new();
while let Some(&command) = input.get(at) {
at += 1;
match command {
b'!' => match point(input, &mut at) {
Some(p) => current = p,
None => break,
},
b'|' | b'/' => match point(input, &mut at) {
Some(to) => {
segments.push(Segment { from: current, control: None, to });
current = to;
}
None => break,
},
b'[' | b']' => match (point(input, &mut at), point(input, &mut at)) {
(Some(control), Some(to)) => {
segments.push(Segment { from: current, control: Some(control), to });
current = to;
}
_ => break,
},
b'S' => {
while input.get(at).is_some_and(u8::is_ascii_digit) {
at += 1;
}
}
b if b.is_ascii_whitespace() => {}
_ => break,
}
if segments.len() >= MAX_EDGE_SEGMENTS {
break;
}
}
segments
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_line_quadratic_and_twips() {
let segments = parse_edge_segments("!0 0|200 0[400 0 400 200S2/0 200");
assert_eq!(segments.len(), 3);
assert_eq!(segments[0].to, Point { x: 10.0, y: 0.0 });
assert_eq!(segments[1].control, Some(Point { x: 20.0, y: 0.0 }));
assert_eq!(segments[2], Segment { from: Point { x: 20.0, y: 10.0 }, control: None, to: Point { x: 0.0, y: 10.0 } });
}
#[test]
fn parses_signed_24_8_fixed_hex_coordinates() {
let segments = parse_edge_segments("!#A.8 -20|#FFFFFF.80 0");
assert_eq!(segments.len(), 1);
assert_eq!(segments[0].from, Point { x: 0.525, y: -1.0 });
assert_eq!(segments[0].to, Point { x: -0.025, y: 0.0 });
}
#[test]
fn malformed_suffix_keeps_only_valid_prefix() {
let segments = parse_edge_segments("!0 0|200 0[400 0 nope 200");
assert_eq!(segments, [Segment { from: Point::default(), control: None, to: Point { x: 10.0, y: 0.0 } }]);
assert!(parse_edge_segments("!1 2|x").is_empty());
assert!(parse_edge_segments("!NaN 2|5 5").is_empty());
}
#[test]
fn oversized_coordinate_numbers_and_segment_lists_are_bounded() {
assert!(parse_edge_segments("!999999999999999999999999999999999999999999999999 0|1 1").is_empty());
let source = "|0 0".repeat(MAX_EDGE_SEGMENTS + 1);
let segments = parse_edge_segments(&source);
assert_eq!(segments.len(), MAX_EDGE_SEGMENTS);
}
}
+461
View File
@@ -0,0 +1,461 @@
//! 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.
//!
//! [`edges`] decodes native shape-edge attributes into neutral points and line/quadratic
//! segments for consumers to map into their own geometry models.
use std::cell::Cell;
use std::collections::HashMap;
/// XFL's edge-attribute grammar, independent of any consuming application's geometry types.
pub mod edges;
/// 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 this XFL reader can't read ({0})")]
UnsupportedArchive(&'static str),
#[error("'{0}' uses a zip feature this XFL reader 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 part in unix.split('/') {
match part {
"" | "." => {}
".." => return Err(ZipError::Unsafe(shown())),
// `C:`, `C:x`: a drive.
p if parts.is_empty() && 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 mut eocd = None;
let mut fallback = None;
for candidate in (lo..data.len().saturating_sub(21)).rev() {
if u32_at(data, candidate) != Some(END)
|| !u16_at(data, candidate + 20)
.is_some_and(|comment| candidate.checked_add(22 + usize::from(comment)).is_some_and(|end| end <= data.len()))
{
continue;
}
// Keep the earliest bounds-valid candidate's eventual parse error if no structurally
// consistent candidate is found. This preserves useful damaged-ZIP diagnostics.
fallback = Some(candidate);
let size = u32_at(data, candidate + 12).unwrap_or(u32::MAX);
let offset = u32_at(data, candidate + 16).unwrap_or(u32::MAX);
if size == u32::MAX || offset == u32::MAX {
// ZIP64's directory must end exactly where the ZIP64 end record starts.
let locator = candidate.checked_sub(20).filter(|&at| u32_at(data, at) == Some(LOCATOR64));
let valid = locator
.and_then(|at| u64_at(data, at + 8))
.and_then(|at| usize::try_from(at).ok())
.filter(|&at| u32_at(data, at) == Some(END64))
.is_some_and(|at| {
let dir_size = u64_at(data, at + 40).and_then(|n| usize::try_from(n).ok());
let dir_offset = u64_at(data, at + 48).and_then(|n| usize::try_from(n).ok());
dir_size.zip(dir_offset).is_some_and(|(size, offset)| offset.checked_add(size) == Some(at))
});
if !valid {
continue;
}
} else {
let end = usize::try_from(offset).ok().and_then(|offset| usize::try_from(size).ok().and_then(|size| offset.checked_add(size)));
if end != Some(candidate) {
continue;
}
}
eocd = Some(candidate);
break;
}
let eocd = eocd.or(fallback).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()));
}
}
if i != dir.len() {
return Err(ZipError::Damaged("the central directory has trailing data"));
}
// 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;
+344
View File
@@ -0,0 +1,344 @@
//! 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 drive_prefix_after_normalized_dot_segments_is_refused() {
for name in ["./C:/LIBRARY/a.xml", "./C:\\LIBRARY\\a.xml"] {
assert!(matches!(Archive::open(&zip(&[(name, b"x", false)])).err(), Some(ZipError::Unsafe(_))));
}
assert!(Archive::open(&zip(&[("./LIBRARY/a.xml", b"x", false)])).is_ok());
}
#[test]
fn invalid_eocd_comment_candidate_does_not_mask_archive_end() {
let mut z = zip(&[("a.xml", b"x", false)]);
let end = z.len() - 22;
z[end + 20..end + 22].copy_from_slice(&22u16.to_le_bytes());
let fake = z.len();
z.extend_from_slice(&END.to_le_bytes());
z.extend_from_slice(&[0; 16]);
z.extend_from_slice(&u16::MAX.to_le_bytes());
assert!(Archive::open(&z).is_ok());
assert_eq!(fake + 22, z.len());
}
#[test]
fn eocd_signature_inside_comment_is_not_an_empty_archive() {
let mut z = zip(&[("a.xml", b"x", false)]);
let end = z.len() - 22;
z[end + 20..end + 22].copy_from_slice(&22u16.to_le_bytes());
z.extend_from_slice(&END.to_le_bytes());
z.extend_from_slice(&[0; 18]);
let archive = Archive::open(&z).unwrap();
assert_eq!(archive.names().collect::<Vec<_>>(), ["a.xml"]);
}
#[test]
fn central_directory_must_be_consumed_exactly() {
let mut z = zip(&[("a.xml", b"x", false)]);
let end = z.len() - 22;
let size = u32::from_le_bytes(z[end + 12..end + 16].try_into().unwrap());
z.insert(end, 0);
z[end + 12 + 1..end + 16 + 1].copy_from_slice(&(size + 1).to_le_bytes());
assert_eq!(Archive::open(&z).err(), Some(ZipError::Damaged("the central directory has trailing data")));
}
#[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() {
assert_eq!(
ZipError::UnsupportedArchive("split across several files").to_string(),
"the archive uses a zip feature this XFL reader can't read (split across several files)"
);
assert_eq!(ZipError::Unsupported("a.xml".into(), "encryption").to_string(), "'a.xml' uses a zip feature this XFL reader can't read (encryption)");
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");
}
+347
View File
@@ -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(&central);
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
}