diff --git a/Cargo.lock b/Cargo.lock index e3503e90f..3349803c7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 033e961b3..e915965bd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" } diff --git a/crates/xfl/Cargo.toml b/crates/xfl/Cargo.toml new file mode 100644 index 000000000..7ed5ec340 --- /dev/null +++ b/crates/xfl/Cargo.toml @@ -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 } + diff --git a/crates/xfl/src/edges.rs b/crates/xfl/src/edges.rs new file mode 100644 index 000000000..e58602e9d --- /dev/null +++ b/crates/xfl/src/edges.rs @@ -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, + pub to: Point, +} + +const TWIPS_PER_PIXEL: f32 = 20.0; + +fn number(input: &[u8], at: &mut usize) -> Option { + 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::().ok().filter(|v| v.is_finite()) +} + +fn point(input: &[u8], at: &mut usize) -> Option { + Some(Point { x: number(input, at)? / TWIPS_PER_PIXEL, y: number(input, at)? / TWIPS_PER_PIXEL }) +} + +/// Parses an XFL `` 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 { + 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); + } +} diff --git a/crates/xfl/src/lib.rs b/crates/xfl/src/lib.rs new file mode 100644 index 000000000..6fd34d0be --- /dev/null +++ b/crates/xfl/src/lib.rs @@ -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, + folders: usize, + /// Bytes still to hand out ([`MAX_TOTAL`] at first). + budget: Cell, +} + +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 { + Some(u16::from_le_bytes(get(d, i, 2)?.try_into().ok()?)) +} +fn u32_at(d: &[u8], i: usize) -> Option { + Some(u32::from_le_bytes(get(d, i, 4)?.try_into().ok()?)) +} +fn u64_at(d: &[u8], i: usize) -> Option { + 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 { + 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 { + let unix = name.replace('\\', "/"); + let shown = || name.chars().take(200).collect::(); + 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, 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::(); + // 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 { + 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>, 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; diff --git a/crates/xfl/src/tests.rs b/crates/xfl/src/tests.rs new file mode 100644 index 000000000..9f9d5a13e --- /dev/null +++ b/crates/xfl/src/tests.rs @@ -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 { + write(&files.iter().map(|(n, b, d)| Raw::new(n, b, *d)).collect::>(), 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 = 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"", true), ("LIBRARY\\Ball.xml", b"", false)]); + let a = Archive::open(&z).unwrap(); + assert_eq!(a.read("DOMDocument.xml").unwrap().unwrap(), b""); + assert_eq!(a.read("LIBRARY/Ball.xml").unwrap().unwrap(), b"", "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 = (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> = + [("DOMDocument.xml", ""), ("LIBRARY/Café/Ünïcödé ★.xml", ""), ("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::>(), ["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::>(), ["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"", true), ("b.xml", b"", true), ("c.xml", b"", 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"", true); + b.share = Some(0); + let z = write(&[Raw::new("a.xml", b"", 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"", 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::>(), ["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::>(), ["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"", true); + a.zip64 = true; + let mut b = Raw::new("b.xml", 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""); + // 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"); +} diff --git a/crates/xfl/tests/support/fla.rs b/crates/xfl/tests/support/fla.rs new file mode 100644 index 000000000..a3aee2952 --- /dev/null +++ b/crates/xfl/tests/support/fla.rs @@ -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, + /// The uncompressed bytes. + pub body: Vec, + 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, + pub size: Option, + pub crc: Option, + /// Extra field bytes in the central directory (besides ZIP64's). + pub extra: Vec, + /// Points this entry's central record at entry `n`'s local header instead of its own. + pub share: Option, + /// Stored as compressed bytes: these instead of compressing `body`. + pub compressed: Option>, +} + +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> { + 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, v: u16) { + out.extend_from_slice(&v.to_le_bytes()); +} +fn le32(out: &mut Vec, v: u32) { + out.extend_from_slice(&v.to_le_bytes()); +} +fn le64(out: &mut Vec, 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 { + 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>, v: &Variant) -> Vec { + 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 = 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>, v: &Variant) -> Vec { + 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> { + fn walk(dir: &std::path::Path, prefix: &str, out: &mut BTreeMap>) { + 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 +}