diff --git a/Cargo.lock b/Cargo.lock index e0968ff2c7..2f716ff214 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1349,6 +1349,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "craft-zip" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=7bb6c9007#7bb6c90076a0177d01cc4c482e7fd8dc9f31a8bf" +dependencies = [ + "flate2", + "thiserror 2.0.21", + "zip", +] + [[package]] name = "crc32fast" version = "1.5.2" @@ -4502,6 +4512,7 @@ dependencies = [ name = "pdfcraft-export" version = "0.21.1" dependencies = [ + "craft-zip", "flate2", ] @@ -6931,7 +6942,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix 1.1.6", "windows-sys 0.52.0", diff --git a/Cargo.toml b/Cargo.toml index 18fa9ed37b..6cf41104e1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,7 @@ craft-i18n = { git = "https://github.com/storytold/craft-libs", version = "0.1.0 craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "60ede99985ef1e5df87a2aca66ed538eb23dec99" } # ICC transforms for app-managed print colour; at craft-print's commit, so one craft-libs checkout. craft-cms = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "60ede99985ef1e5df87a2aca66ed538eb23dec99" } +craft-zip = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "7bb6c9007" } # Whether a path stored in a document may be opened (GoToR link targets). craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" } # craft-update: app-specific pretend versions apply to startup and manual checks. diff --git a/crates/export/Cargo.toml b/crates/export/Cargo.toml index f318d95301..dc0998c036 100644 --- a/crates/export/Cargo.toml +++ b/crates/export/Cargo.toml @@ -11,4 +11,7 @@ repository.workspace = true workspace = true [dependencies] +craft-zip.workspace = true + +[dev-dependencies] flate2.workspace = true diff --git a/crates/export/src/zip.rs b/crates/export/src/zip.rs index e15966c119..99cf872d04 100644 --- a/crates/export/src/zip.rs +++ b/crates/export/src/zip.rs @@ -1,348 +1,25 @@ -//! A minimal zip writer (deflate or store) for Office files. Archives that outgrow the classic -//! end-of-central-directory record's 16-bit entry count or 32-bit sizes are written with the -//! ZIP64 records (APPNOTE 4.4), which every reader understands; small ones are unchanged. +//! Zip writer for Office files using `craft-zip`. -use std::io::Write; +use craft_zip::{Compression, Writer}; -fn crc32(data: &[u8]) -> u32 { - static TABLE: std::sync::OnceLock<[u32; 256]> = std::sync::OnceLock::new(); - let table = TABLE.get_or_init(|| { - let mut t = [0u32; 256]; - for (i, e) in t.iter_mut().enumerate() { - let mut c = i as u32; - for _ in 0..8 { - c = if c & 1 != 0 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 }; - } - *e = c; - } - t - }); - let mut c = 0xFFFF_FFFFu32; - for b in data { - c = table[((c ^ *b as u32) & 0xFF) as usize] ^ (c >> 8); - } - c ^ 0xFFFF_FFFF -} - -/// The largest entry count the classic end-of-central-directory record holds (APPNOTE 4.3.12). -const MAX_CLASSIC_COUNT: u64 = u16::MAX as u64; -/// The largest size or offset the classic records hold: past it the field is a sentinel and the -/// ZIP64 record carries the real value (APPNOTE 4.4.1). -const MAX_CLASSIC_U32: u64 = u32::MAX as u64; - -/// The ZIP64 extended-information extra field (APPNOTE 4.5.3) holding `fields` — the values that -/// no longer fit their classic fields — in specification order. Empty when everything fits. -fn zip64_extra(fields: &[u64]) -> Vec { - let mut e = Vec::with_capacity(4 + 8 * fields.len()); - if !fields.is_empty() { - e.extend(0x0001u16.to_le_bytes()); - e.extend((8 * fields.len() as u16).to_le_bytes()); - for &f in fields { - e.extend(f.to_le_bytes()); - } - } - e -} - -/// The classic 32-bit field value for `value`: the value itself, or the 0xFFFFFFFF sentinel when it -/// no longer fits — including when it equals the sentinel, which a reader cannot tell from a real -/// value — in which case the ZIP64 extra field carries the real value (APPNOTE 4.4.3). -fn classic_u32(value: u64) -> u32 { - if value >= MAX_CLASSIC_U32 { u32::MAX } else { value as u32 } -} - -/// Builds one member's local file header and central directory entry. The sizes and `offset` are -/// parameters, not read from the body, so tests can inject values past the classic fields without -/// allocating the data. -fn entry_headers(name: &str, crc: u32, method: u16, compressed: u64, uncompressed: u64, offset: u64) -> (Vec, Vec) { - // A value at or above the sentinel cannot be held by a classic field, so it is offloaded. - let size_is_zip64 = uncompressed >= MAX_CLASSIC_U32 || compressed >= MAX_CLASSIC_U32; - // A local header that carries ZIP64 sizes carries both, original then compressed, even when only - // one overflowed (APPNOTE 4.5.3). A central entry carries one value for each field it set to its - // sentinel, and only those, so a reader walking the fields by their classic ones stays in step. - let local_extra = if size_is_zip64 { zip64_extra(&[uncompressed, compressed]) } else { Vec::new() }; - let mut central_fields = Vec::new(); - if uncompressed >= MAX_CLASSIC_U32 { - central_fields.push(uncompressed); - } - if compressed >= MAX_CLASSIC_U32 { - central_fields.push(compressed); - } - if offset >= MAX_CLASSIC_U32 { - central_fields.push(offset); - } - let central_extra = zip64_extra(¢ral_fields); - // An entry that carries ZIP64 fields needs version 4.5 to extract (APPNOTE 4.4.3); the version - // in the local header and the central entry agree. - let version_needed = if size_is_zip64 || offset >= MAX_CLASSIC_U32 { 45u16 } else { 20u16 }; - let header = |sig: u32, central: bool| { - let mut h = Vec::new(); - h.extend(sig.to_le_bytes()); - if central { - h.extend(20u16.to_le_bytes()); // made by - } - h.extend(version_needed.to_le_bytes()); // version needed - h.extend(0x0800u16.to_le_bytes()); // UTF-8 names - h.extend(method.to_le_bytes()); - h.extend([0, 0, 0x21, 0]); // 00:00, 1980-01-01 (a zero date is not a valid DOS date) - h.extend(crc.to_le_bytes()); - // A local ZIP64 extra carries both sizes, so both corresponding classic fields are - // sentinels even when only one size needs offloading (APPNOTE 4.5.3). Central entries - // remain selective and use a sentinel only for fields represented in their extra. - let (compressed_field, uncompressed_field) = - if !central && size_is_zip64 { (u32::MAX, u32::MAX) } else { (classic_u32(compressed), classic_u32(uncompressed)) }; - h.extend(compressed_field.to_le_bytes()); - h.extend(uncompressed_field.to_le_bytes()); - h.extend((name.len() as u16).to_le_bytes()); - let extra = if central { ¢ral_extra } else { &local_extra }; - h.extend((extra.len() as u16).to_le_bytes()); // extra - if central { - h.extend(0u16.to_le_bytes()); // comment - h.extend(0u16.to_le_bytes()); // disk - h.extend(0u16.to_le_bytes()); // internal attributes - h.extend(0u32.to_le_bytes()); // external attributes - h.extend(classic_u32(offset).to_le_bytes()); - } - h.extend(name.as_bytes()); - h.extend(extra); - h - }; - (header(0x0403_4b50, false), header(0x0201_4b50, true)) -} - -/// Builds a zip archive in memory. An archive past the classic record's 16-bit entry count or -/// 32-bit sizes and offsets is written with a ZIP64 end-of-central-directory record: a -/// PowerPoint export writes two entries per page plus ten fixed parts, so a document with -/// 32 763 pages already passes 65 535 entries, and without the ZIP64 record the count wraps -/// and readers see a central directory that does not describe the archive. +/// Zip archive writer wrapping [`Writer`]. #[derive(Default)] pub struct Zip { - out: Vec, - central: Vec, - count: usize, + writer: Writer, } impl Zip { /// Add a file. Already-compressed data (images) is stored; the rest is deflated. pub fn add(&mut self, name: &str, data: &[u8], compress: bool) { - let crc = crc32(data); - let (method, body) = if compress { - let mut e = flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default()); - let _ = e.write_all(data); - (8u16, e.finish().unwrap_or_default()) + let compression = if compress { + Compression::Deflated } else { - (0u16, data.to_vec()) + Compression::Stored }; - let offset = self.out.len() as u64; - let (local, central) = entry_headers(name, crc, method, body.len() as u64, data.len() as u64, offset); - self.out.extend(local); - self.out.extend(&body); - self.central.extend(central); - self.count += 1; + let _ = self.writer.add(name, data, compression); } - pub fn finish(mut self) -> Vec { - let start = self.out.len() as u64; - let size = self.central.len() as u64; - let count = self.count as u64; - self.out.append(&mut self.central); - let classic_count = if count > MAX_CLASSIC_COUNT { u16::MAX } else { count as u16 }; - if count > MAX_CLASSIC_COUNT || size >= MAX_CLASSIC_U32 || start >= MAX_CLASSIC_U32 { - let eocd64_at = self.out.len() as u64; - self.out.extend(0x0606_4b50u32.to_le_bytes()); // ZIP64 end of central directory record - self.out.extend(44u64.to_le_bytes()); // size of the rest of this record - self.out.extend(45u16.to_le_bytes()); // version made by (4.5, DOS) - self.out.extend(45u16.to_le_bytes()); // version needed to extract - self.out.extend(0u32.to_le_bytes()); // number of this disk - self.out.extend(0u32.to_le_bytes()); // disk with the start of the central directory - self.out.extend(count.to_le_bytes()); // entries on this disk - self.out.extend(count.to_le_bytes()); // entries in total - self.out.extend(size.to_le_bytes()); // size of the central directory - self.out.extend(start.to_le_bytes()); // offset of the central directory - self.out.extend(0x0706_4b50u32.to_le_bytes()); // ZIP64 end of central directory locator - self.out.extend(0u32.to_le_bytes()); // disk with the ZIP64 record - self.out.extend(eocd64_at.to_le_bytes()); // offset of the ZIP64 record - self.out.extend(1u32.to_le_bytes()); // total number of disks - } - self.out.extend(0x0605_4b50u32.to_le_bytes()); - self.out.extend([0u8; 4]); // number of this disk, disk with the central directory - self.out.extend(classic_count.to_le_bytes()); // entries on this disk - self.out.extend(classic_count.to_le_bytes()); // entries in total - self.out.extend(classic_u32(size).to_le_bytes()); - self.out.extend(classic_u32(start).to_le_bytes()); - self.out.extend(0u16.to_le_bytes()); // comment length - self.out - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn crc_matches_the_reference() { - assert_eq!(crc32(b"123456789"), 0xCBF4_3926); - } - - #[test] - fn archive_has_both_directories() { - let mut z = Zip::default(); - z.add("a.txt", b"hello hello hello", true); - z.add("b.png", b"\x89PNG", false); - let b = z.finish(); - assert!(b.starts_with(b"PK\x03\x04")); - assert_eq!(b.windows(4).filter(|w| *w == b"PK\x01\x02").count(), 2); - assert_eq!(&b[b.len() - 22..b.len() - 18], b"PK\x05\x06"); - } - - #[test] - fn more_entries_than_the_classic_record_holds_writes_zip64() { - // A PowerPoint export writes two entries per page plus ten fixed parts, so a - // document with 32 763 pages already passes the classic record's 16-bit entry - // count. The count must not wrap: it overflows in a debug build, and in a release - // one it silently writes a central directory readers cannot match to the archive. - let mut z = Zip::default(); - for i in 0..u16::MAX as usize + 1 { - z.add(&format!("ppt/slides/slide{i}.xml"), b"x", true); - z.add(&format!("ppt/slides/_rels/slide{i}.xml.rels"), b"y", true); - } - let b = z.finish(); - let entries = 2 * (u16::MAX as usize + 1); - // The classic end-of-central-directory record holds sentinels… - let eocd = &b[b.len() - 22..]; - assert_eq!(&eocd[..4], &0x0605_4b50u32.to_le_bytes()); - assert_eq!(&eocd[8..10], &u16::MAX.to_le_bytes(), "entries on this disk"); - assert_eq!(&eocd[10..12], &u16::MAX.to_le_bytes(), "entries in total"); - // …and the ZIP64 locator and record carry the truth. - let locator = &b[b.len() - 42..b.len() - 22]; - assert_eq!(&locator[..4], &0x0706_4b50u32.to_le_bytes()); - let at = u64::from_le_bytes(locator[8..16].try_into().unwrap_or([0; 8])) as usize; - assert_eq!(&b[at..at + 4], &0x0606_4b50u32.to_le_bytes()); - assert_eq!(u64::from_le_bytes(b[at + 24..at + 32].try_into().unwrap_or([0; 8])), entries as u64, "entries on this disk"); - assert_eq!(u64::from_le_bytes(b[at + 32..at + 40].try_into().unwrap_or([0; 8])), entries as u64, "entries in total"); - } - - /// The extra field of a local file header (APPNOTE 4.3.7), which follows the file name. - fn local_extra(h: &[u8]) -> &[u8] { - let le = |i: usize, n: usize| h[i..i + n].iter().rev().fold(0usize, |v, b| v << 8 | *b as usize); - let (name_len, extra_len) = (le(26, 2), le(28, 2)); - &h[30 + name_len..30 + name_len + extra_len] - } - - /// The extra field of a central directory entry (APPNOTE 4.3.12), which follows the file name. - fn central_extra(h: &[u8]) -> &[u8] { - let le = |i: usize, n: usize| h[i..i + n].iter().rev().fold(0usize, |v, b| v << 8 | *b as usize); - let (name_len, extra_len) = (le(28, 2), le(30, 2)); - &h[46 + name_len..46 + name_len + extra_len] - } - - /// The classic compressed and original size fields of a local file header (`at` 18) or a - /// central directory entry (`at` 20). - fn classic_sizes(h: &[u8], at: usize) -> (u32, u32) { - let field = |i: usize| u32::from_le_bytes(h[i..i + 4].try_into().unwrap_or([0; 4])); - (field(at), field(at + 4)) - } - - /// Asserts the local header carries both sizes whenever either needs ZIP64, with both classic - /// size fields set to the sentinel (APPNOTE 4.5.3: a ZIP64 value appears only for a field set to - /// its sentinel, and the local entry must carry both sizes). The central entry stays selective: - /// only the fields that themselves overflow get a value, in specification order. - fn assert_local_pair_and_central_selectivity(compressed: u64, uncompressed: u64) { - let at_limit = |v: u64| v >= u32::MAX as u64; - let (local, central) = entry_headers("a", 0, 0, compressed, uncompressed, 0); - assert_eq!(classic_sizes(&local, 18), (u32::MAX, u32::MAX), "both local size fields are sentinels for ({compressed}, {uncompressed})"); - let extra = local_extra(&local); - assert_eq!( - u16::from_le_bytes(extra[2..4].try_into().unwrap_or([0; 2])), - 16, - "both local sizes are present for ({compressed}, {uncompressed})" - ); - assert_eq!(u64::from_le_bytes(extra[4..12].try_into().unwrap_or([0; 8])), uncompressed, "original size first"); - assert_eq!(u64::from_le_bytes(extra[12..20].try_into().unwrap_or([0; 8])), compressed, "compressed size second"); - let expected = - (if at_limit(compressed) { u32::MAX } else { compressed as u32 }, if at_limit(uncompressed) { u32::MAX } else { uncompressed as u32 }); - assert_eq!( - classic_sizes(¢ral, 20), - expected, - "the central entry offloads only the fields that overflow for ({compressed}, {uncompressed})" - ); - let fields = usize::from(at_limit(uncompressed)) + usize::from(at_limit(compressed)); - let extra = central_extra(¢ral); - assert_eq!( - u16::from_le_bytes(extra[2..4].try_into().unwrap_or([0; 2])), - 8 * fields as u16, - "one central value per sentinel field for ({compressed}, {uncompressed})" - ); - if at_limit(uncompressed) { - assert_eq!(u64::from_le_bytes(extra[4..12].try_into().unwrap_or([0; 8])), uncompressed, "original size first"); - } - if at_limit(compressed) { - let at = if at_limit(uncompressed) { 12 } else { 4 }; - assert_eq!(u64::from_le_bytes(extra[at..at + 8].try_into().unwrap_or([0; 8])), compressed, "compressed size in specification order"); - } - } - - #[test] - fn both_local_sizes_are_sentinels_when_only_one_needs_zip64() { - // A local ZIP64 extra must carry both sizes, and its values correspond to classic fields set - // to their sentinel — so a small classic size next to an overflowing one is wrong even - // though the extra holds the truth. Both orders of which size overflows. - assert_local_pair_and_central_selectivity(100, u32::MAX as u64 + 1); - assert_local_pair_and_central_selectivity(u32::MAX as u64 + 1, 100); - } - - #[test] - fn both_local_sizes_are_sentinels_when_one_equals_the_sentinel() { - // A size of exactly 0xFFFFFFFF is indistinguishable from the sentinel, so it needs ZIP64 - // just like a larger one; the other local size must then become a sentinel too. Both orders. - assert_local_pair_and_central_selectivity(100, u32::MAX as u64); - assert_local_pair_and_central_selectivity(u32::MAX as u64, 100); - } - - #[test] - fn local_zip64_extra_carries_both_sizes() { - // When only one size overflows the u32 limit, the local header still carries both sizes — - // original then compressed — because a local ZIP64 extra that carries either must carry - // both (APPNOTE 4.5.3). Either size may be the one past the limit. - for (compressed, uncompressed) in [(100u64, u32::MAX as u64 + 1), (u32::MAX as u64 + 1, 100)] { - let (local, _) = entry_headers("a", 0, 0, compressed, uncompressed, 0); - let extra = local_extra(&local); - assert_eq!(u16::from_le_bytes(extra[..2].try_into().unwrap_or([0; 2])), 0x0001, "ZIP64 extra id"); - assert_eq!(u16::from_le_bytes(extra[2..4].try_into().unwrap_or([0; 2])), 16, "both sizes are present"); - assert_eq!(u64::from_le_bytes(extra[4..12].try_into().unwrap_or([0; 8])), uncompressed, "original size first"); - assert_eq!(u64::from_le_bytes(extra[12..20].try_into().unwrap_or([0; 8])), compressed, "compressed size second"); - } - } - - #[test] - fn zip64_entries_need_version_4_5() { - // An entry that carries ZIP64 fields must advertise version 4.5 to extract, in both the - // local file header and the central directory entry (APPNOTE 4.4.3). An entry that fits - // every classic field stays at 2.0, so small archives are unchanged. - let (local, central) = entry_headers("a", 0, 0, u32::MAX as u64 + 1, u32::MAX as u64 + 1, 0); - assert_eq!(u16::from_le_bytes(local[4..6].try_into().unwrap_or([0; 2])), 45, "local version needed"); - assert_eq!(u16::from_le_bytes(central[6..8].try_into().unwrap_or([0; 2])), 45, "central version needed"); - let (local, central) = entry_headers("a", 0, 0, 4, 8, 0); - assert_eq!(u16::from_le_bytes(local[4..6].try_into().unwrap_or([0; 2])), 20, "local version needed"); - assert_eq!(u16::from_le_bytes(central[6..8].try_into().unwrap_or([0; 2])), 20, "central version needed"); - } - - #[test] - fn size_equal_to_the_sentinel_is_offloaded() { - // A real size of exactly u32::MAX is indistinguishable from the 0xFFFFFFFF sentinel, so it - // too must be offloaded: every classic field set to the sentinel needs its 8-byte value in - // the central extra, or a reader such as Python's zipfile reports "Compress size not - // found". Both orders of which size is the MAX+1 one must work. - for (compressed, uncompressed) in [(u32::MAX as u64, u32::MAX as u64 + 1), (u32::MAX as u64 + 1, u32::MAX as u64)] { - let (local, central) = entry_headers("a", 0, 0, compressed, uncompressed, 0); - assert_eq!(u32::from_le_bytes(local[18..22].try_into().unwrap_or([0; 4])), u32::MAX, "local compressed sentinel"); - assert_eq!(u32::from_le_bytes(local[22..26].try_into().unwrap_or([0; 4])), u32::MAX, "local original sentinel"); - assert_eq!(u32::from_le_bytes(central[20..24].try_into().unwrap_or([0; 4])), u32::MAX, "central compressed sentinel"); - assert_eq!(u32::from_le_bytes(central[24..28].try_into().unwrap_or([0; 4])), u32::MAX, "central original sentinel"); - let extra = central_extra(¢ral); - assert_eq!(u16::from_le_bytes(extra[2..4].try_into().unwrap_or([0; 2])), 16, "a value for each sentinel field"); - assert_eq!(u64::from_le_bytes(extra[4..12].try_into().unwrap_or([0; 8])), uncompressed, "original size first"); - assert_eq!(u64::from_le_bytes(extra[12..20].try_into().unwrap_or([0; 8])), compressed, "compressed size second"); - } + pub fn finish(self) -> Vec { + self.writer.finish().unwrap_or_default() } }