diff --git a/src/extra_fields/extra_field.rs b/src/extra_fields/extra_field.rs index 9ef531424..569380ea7 100644 --- a/src/extra_fields/extra_field.rs +++ b/src/extra_fields/extra_field.rs @@ -117,20 +117,13 @@ impl ExtraField { let parsed_extra_field = match decoded_extra_field { // Zip64 extended information extra field Ok(UsedExtraField::Zip64ExtendedInfo) => { - let (new_uncomp, new_comp, new_head) = Zip64ExtendedInformation::parse( + ExtraField::Zip64ExtendedInformation(Zip64ExtendedInformation::parse( reader, len, file.get_uncompressed_size(), file.get_compressed_size(), file.get_header_start(), - )?; - ExtraField::Zip64ExtendedInformation(Zip64ExtendedInformation { - sizes: Some(Zip64Sizes { - uncompressed_size: new_uncomp, - compressed_size: new_comp, - }), - header_start: Some(new_head), - }) + )?) } Ok(UsedExtraField::Ntfs) => { // NTFS extra field diff --git a/src/extra_fields/zip64_extended_information.rs b/src/extra_fields/zip64_extended_information.rs index a59a3901d..6666a1585 100644 --- a/src/extra_fields/zip64_extended_information.rs +++ b/src/extra_fields/zip64_extended_information.rs @@ -140,72 +140,87 @@ impl Zip64ExtendedInformation { Ok(()) } + /// Reads the value for one field, and keeps it only if the entry asked for it. + /// + /// Whether the value is *read* is decided by the block's length, so that the reader stays in + /// step with writers that emit more fields than they need to. Whether it is *kept* is decided + /// by `is_zip64`, which says whether the matching field in the entry held the sentinel. #[inline] - pub(crate) fn parse( + fn read_field( reader: &mut R, len: u16, - uncompressed_size: u32, - compressed_size: u32, - header_start: Option, - ) -> ZipResult<(u64, u64, u64)> { - let mut consumed_len = 0; - let new_uncompressed_size = if len >= 24 || u64::from(uncompressed_size) == ZIP64_BYTES_THR - { - let new_uncompressed_size = match reader.read_u64_le() { - Ok(v) => v, - Err(e) if e.kind() == ErrorKind::UnexpectedEof => { - return Err(invalid!("ZIP64 extra field truncated")); - } - Err(e) => return Err(e.into()), - }; - consumed_len += mem::size_of::(); - new_uncompressed_size - } else { - uncompressed_size.into() + consumed_len: &mut usize, + is_zip64: bool, + ) -> ZipResult> { + if len < 24 && !is_zip64 { + return Ok(None); + } + let value = match reader.read_u64_le() { + Ok(value) => value, + Err(e) if e.kind() == ErrorKind::UnexpectedEof => { + return Err(invalid!("ZIP64 extra field truncated")); + } + Err(e) => return Err(e.into()), }; + *consumed_len += mem::size_of::(); + Ok(is_zip64.then_some(value)) + } - let new_compressed_size = if len >= 24 || u64::from(compressed_size) == ZIP64_BYTES_THR { - let new_compressed_size = match reader.read_u64_le() { - Ok(v) => v, - Err(e) if e.kind() == ErrorKind::UnexpectedEof => { - return Err(invalid!("ZIP64 extra field truncated")); - } - Err(e) => return Err(e.into()), - }; - consumed_len += mem::size_of::(); - new_compressed_size - } else { - compressed_size.into() - }; + /// Reads the block, keeping only the values the entry actually asked for. + /// + /// Per APPNOTE 4.5.3 a value belongs in this block only when the matching field in the entry + /// itself holds the 0xFFFFFFFF sentinel, which means "too large to store here, the real value + /// is in the ZIP64 block". Writers exist that emit a full length block anyway, and at least + /// one emits a malformed one, so a value that no sentinel asked for is dropped rather than + /// read over the entry's own perfectly good field. Such a value can only repeat what the entry + /// already said, so dropping it costs nothing when the block is well formed, and it is the + /// only way a malformed block can be told apart from a meaningful one. + /// + /// The `None` fields this leaves behind are the same `None` the writer uses for "this entry + /// has nothing to record here", so a block that was ignored on the way in is not written back + /// out on the way through. + /// + /// `entry_header_start` is `None` for a local header, which has no relative offset field for + /// the block to override in the first place. + #[inline] + pub(crate) fn parse( + reader: &mut R, + len: u16, + entry_uncompressed_size: u32, + entry_compressed_size: u32, + entry_header_start: Option, + ) -> ZipResult { + let mut consumed_len = 0; - let new_header_start = if len >= 24 { - let new_header_start = match reader.read_u64_le() { - Ok(v) => v, - Err(e) if e.kind() == ErrorKind::UnexpectedEof => { - return Err(invalid!("ZIP64 extra field truncated")); - } - Err(e) => return Err(e.into()), - }; - consumed_len += mem::size_of::(); - new_header_start + let uncompressed_size = Self::read_field( + reader, + len, + &mut consumed_len, + u64::from(entry_uncompressed_size) == ZIP64_BYTES_THR, + )?; + let compressed_size = Self::read_field( + reader, + len, + &mut consumed_len, + u64::from(entry_compressed_size) == ZIP64_BYTES_THR, + )?; + let header_start = Self::read_field( + reader, + len, + &mut consumed_len, + entry_header_start.is_some_and(|start| u64::from(start) == ZIP64_BYTES_THR), + )?; + + // The two sizes travel together, so one sentinel brings both along. The field that had no + // sentinel keeps the entry's own value, which is what it already held. + let sizes = if uncompressed_size.is_some() || compressed_size.is_some() { + Some(Zip64Sizes { + uncompressed_size: uncompressed_size + .unwrap_or_else(|| entry_uncompressed_size.into()), + compressed_size: compressed_size.unwrap_or_else(|| entry_compressed_size.into()), + }) } else { - if let Some(header_start) = header_start { - if u64::from(header_start) == ZIP64_BYTES_THR { - let new_header_start = match reader.read_u64_le() { - Ok(v) => v, - Err(e) if e.kind() == ErrorKind::UnexpectedEof => { - return Err(invalid!("ZIP64 extra field truncated")); - } - Err(e) => return Err(e.into()), - }; - consumed_len += mem::size_of::(); - new_header_start - } else { - header_start.into() - } - } else { - 0 - } + None }; let Some(leftover_len) = (len as usize).checked_sub(consumed_len) else { @@ -219,6 +234,9 @@ impl Zip64ExtendedInformation { return Err(e.into()); } - Ok((new_uncompressed_size, new_compressed_size, new_header_start)) + Ok(Self { + sizes, + header_start, + }) } } diff --git a/tests/zip64_extra_field_without_sentinel.rs b/tests/zip64_extra_field_without_sentinel.rs new file mode 100644 index 000000000..04878efe8 --- /dev/null +++ b/tests/zip64_extra_field_without_sentinel.rs @@ -0,0 +1,293 @@ +//! An entry whose sizes and offset all fit in 32 bits has nothing to say in a ZIP64 extended +//! information extra field, but writers exist that attach one regardless, and at least one +//! attaches a malformed one. Reading such a block over the entry's own perfectly good fields +//! turns an archive that every other tool accepts into an unreadable one, so a value from this +//! block may only replace a field holding the 0xFFFFFFFF sentinel that asked for it. + +use std::io::{Cursor, Read, Seek}; + +use zip::{ZipArchive, ZipWriter}; + +/// The first two entries of a real EPUB that this crate could not read, kept byte for byte: the +/// boilerplate `mimetype` and `META-INF/container.xml`, with a fresh end of central directory +/// record. Nothing of the book itself is in it. Every entry after `mimetype` in the original +/// carries the same malformed block as the one modelled below. +const REAL_WORLD_EPUB: [u8; 584] = [ + 0x50_u8, 0x4B_u8, 0x03_u8, 0x04_u8, 0x14_u8, 0x00_u8, 0x16_u8, 0x08_u8, 0x00_u8, 0x00_u8, + 0x0D_u8, 0x08_u8, 0xCC_u8, 0x50_u8, 0x6F_u8, 0x61_u8, 0xAB_u8, 0x2C_u8, 0x14_u8, 0x00_u8, + 0x00_u8, 0x00_u8, 0x14_u8, 0x00_u8, 0x00_u8, 0x00_u8, 0x08_u8, 0x00_u8, 0x00_u8, 0x00_u8, + 0x6D_u8, 0x69_u8, 0x6D_u8, 0x65_u8, 0x74_u8, 0x79_u8, 0x70_u8, 0x65_u8, 0x61_u8, 0x70_u8, + 0x70_u8, 0x6C_u8, 0x69_u8, 0x63_u8, 0x61_u8, 0x74_u8, 0x69_u8, 0x6F_u8, 0x6E_u8, 0x2F_u8, + 0x65_u8, 0x70_u8, 0x75_u8, 0x62_u8, 0x2B_u8, 0x7A_u8, 0x69_u8, 0x70_u8, 0x50_u8, 0x4B_u8, + 0x03_u8, 0x04_u8, 0x14_u8, 0x00_u8, 0x16_u8, 0x08_u8, 0x08_u8, 0x00_u8, 0x0E_u8, 0x08_u8, + 0xCC_u8, 0x50_u8, 0x3D_u8, 0x7F_u8, 0x99_u8, 0x05_u8, 0xBA_u8, 0x00_u8, 0x00_u8, 0x00_u8, + 0x0E_u8, 0x01_u8, 0x00_u8, 0x00_u8, 0x16_u8, 0x00_u8, 0x48_u8, 0x00_u8, 0x4D_u8, 0x45_u8, + 0x54_u8, 0x41_u8, 0x2D_u8, 0x49_u8, 0x4E_u8, 0x46_u8, 0x2F_u8, 0x63_u8, 0x6F_u8, 0x6E_u8, + 0x74_u8, 0x61_u8, 0x69_u8, 0x6E_u8, 0x65_u8, 0x72_u8, 0x2E_u8, 0x78_u8, 0x6D_u8, 0x6C_u8, + 0x01_u8, 0x00_u8, 0x20_u8, 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, 0x01_u8, 0x00_u8, + 0x18_u8, 0x00_u8, 0x0E_u8, 0x01_u8, 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, + 0xBA_u8, 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, 0x3A_u8, 0x00_u8, + 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, 0x0A_u8, 0x00_u8, 0x20_u8, 0x00_u8, + 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, 0x01_u8, 0x00_u8, 0x18_u8, 0x00_u8, 0x23_u8, 0x77_u8, + 0x1C_u8, 0x7B_u8, 0x4C_u8, 0x40_u8, 0xD6_u8, 0x01_u8, 0x23_u8, 0x77_u8, 0x1C_u8, 0x7B_u8, + 0x4C_u8, 0x40_u8, 0xD6_u8, 0x01_u8, 0x23_u8, 0x77_u8, 0x1C_u8, 0x7B_u8, 0x4C_u8, 0x40_u8, + 0xD6_u8, 0x01_u8, 0x5D_u8, 0x8E_u8, 0xC1_u8, 0x6A_u8, 0x02_u8, 0x41_u8, 0x10_u8, 0x44_u8, + 0xCF_u8, 0x09_u8, 0xE4_u8, 0x1F_u8, 0x86_u8, 0xBE_u8, 0x86_u8, 0x75_u8, 0xF4_u8, 0x26_u8, + 0xC3_u8, 0xAE_u8, 0x82_u8, 0xE0_u8, 0xD9_u8, 0x80_u8, 0x5F_u8, 0xD0_u8, 0x99_u8, 0xED_u8, + 0x35_u8, 0x83_u8, 0xB3_u8, 0xDD_u8, 0xC3_u8, 0x76_u8, 0xAF_u8, 0xC4_u8, 0xBF_u8, 0x77_u8, + 0xF4_u8, 0x60_u8, 0x82_u8, 0xC7_u8, 0x82_u8, 0x7A_u8, 0xAF_u8, 0xAA_u8, 0xDD_u8, 0xFE_u8, + 0x8E_u8, 0xD9_u8, 0x5D_u8, 0x68_u8, 0xD2_u8, 0x24_u8, 0xDC_u8, 0xC1_u8, 0x6A_u8, 0xB1_u8, + 0x04_u8, 0x47_u8, 0x1C_u8, 0xA5_u8, 0x4F_u8, 0x7C_u8, 0xEA_u8, 0x60_u8, 0xB6_u8, 0xA1_u8, + 0x59_u8, 0x83_u8, 0x53_u8, 0x43_u8, 0xEE_u8, 0x31_u8, 0x0B_u8, 0x53_u8, 0x07_u8, 0x57_u8, + 0x52_u8, 0xD8_u8, 0x6E_u8, 0x3E_u8, 0xDE_u8, 0xDB_u8, 0x28_u8, 0x6C_u8, 0x98_u8, 0x98_u8, + 0xA6_u8, 0x17_u8, 0xBA_u8, 0xFA_u8, 0x58_u8, 0x2B_u8, 0x3A_u8, 0x71_u8, 0x10_u8, 0xD4_u8, + 0xA4_u8, 0x81_u8, 0x71_u8, 0x24_u8, 0x0D_u8, 0x16_u8, 0x83_u8, 0x14_u8, 0xE2_u8, 0x5E_u8, + 0xE2_u8, 0x3C_u8, 0x12_u8, 0x5B_u8, 0x78_u8, 0xD4_u8, 0xC2_u8, 0x53_u8, 0x02_u8, 0x55_u8, + 0xF9_u8, 0xD6_u8, 0x4E_u8, 0x22_u8, 0x36_u8, 0xA4_u8, 0x4C_u8, 0x7A_u8, 0x4F_u8, 0x7F_u8, + 0xD1_u8, 0x0D_u8, 0x73_u8, 0xCE_u8, 0x4D_u8, 0x41_u8, 0xFB_u8, 0xE9_u8, 0xE0_u8, 0xB0_u8, + 0xDF_u8, 0x7D_u8, 0x1D_u8, 0xFD_u8, 0x9D_u8, 0xAB_u8, 0x96_u8, 0x85_u8, 0x94_u8, 0x01_u8, + 0xDC_u8, 0x48_u8, 0x7D_u8, 0xC2_u8, 0xC6_u8, 0xAE_u8, 0xA5_u8, 0xDE_u8, 0xC3_u8, 0x52_u8, + 0x72_u8, 0x8A_u8, 0x68_u8, 0xF5_u8, 0x8F_u8, 0x17_u8, 0xFA_u8, 0x2E_u8, 0x5A_u8, 0xB1_u8, + 0x78_u8, 0xC6_u8, 0x13_u8, 0x7D_u8, 0xD6_u8, 0x41_u8, 0x70_u8, 0xFE_u8, 0x31_u8, 0xE3_u8, + 0xFF_u8, 0xEF_u8, 0xB4_u8, 0xFE_u8, 0x79_u8, 0x62_u8, 0x73_u8, 0x03_u8, 0x50_u8, 0x4B_u8, + 0x01_u8, 0x02_u8, 0x17_u8, 0x0B_u8, 0x14_u8, 0x00_u8, 0x16_u8, 0x08_u8, 0x00_u8, 0x00_u8, + 0x0D_u8, 0x08_u8, 0xCC_u8, 0x50_u8, 0x6F_u8, 0x61_u8, 0xAB_u8, 0x2C_u8, 0x14_u8, 0x00_u8, + 0x00_u8, 0x00_u8, 0x14_u8, 0x00_u8, 0x00_u8, 0x00_u8, 0x08_u8, 0x00_u8, 0x00_u8, 0x00_u8, + 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, 0x81_u8, + 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, 0x6D_u8, 0x69_u8, 0x6D_u8, 0x65_u8, 0x74_u8, 0x79_u8, + 0x70_u8, 0x65_u8, 0x50_u8, 0x4B_u8, 0x01_u8, 0x02_u8, 0x17_u8, 0x0B_u8, 0x14_u8, 0x00_u8, + 0x16_u8, 0x08_u8, 0x08_u8, 0x00_u8, 0x0E_u8, 0x08_u8, 0xCC_u8, 0x50_u8, 0x3D_u8, 0x7F_u8, + 0x99_u8, 0x05_u8, 0xBA_u8, 0x00_u8, 0x00_u8, 0x00_u8, 0x0E_u8, 0x01_u8, 0x00_u8, 0x00_u8, + 0x16_u8, 0x00_u8, 0x48_u8, 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, + 0x00_u8, 0x00_u8, 0x00_u8, 0x81_u8, 0x3A_u8, 0x00_u8, 0x00_u8, 0x00_u8, 0x4D_u8, 0x45_u8, + 0x54_u8, 0x41_u8, 0x2D_u8, 0x49_u8, 0x4E_u8, 0x46_u8, 0x2F_u8, 0x63_u8, 0x6F_u8, 0x6E_u8, + 0x74_u8, 0x61_u8, 0x69_u8, 0x6E_u8, 0x65_u8, 0x72_u8, 0x2E_u8, 0x78_u8, 0x6D_u8, 0x6C_u8, + 0x01_u8, 0x00_u8, 0x20_u8, 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, 0x01_u8, 0x00_u8, + 0x18_u8, 0x00_u8, 0x0E_u8, 0x01_u8, 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, + 0xBA_u8, 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, 0x3A_u8, 0x00_u8, + 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, 0x0A_u8, 0x00_u8, 0x20_u8, 0x00_u8, + 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, 0x01_u8, 0x00_u8, 0x18_u8, 0x00_u8, 0x23_u8, 0x77_u8, + 0x1C_u8, 0x7B_u8, 0x4C_u8, 0x40_u8, 0xD6_u8, 0x01_u8, 0x23_u8, 0x77_u8, 0x1C_u8, 0x7B_u8, + 0x4C_u8, 0x40_u8, 0xD6_u8, 0x01_u8, 0x23_u8, 0x77_u8, 0x1C_u8, 0x7B_u8, 0x4C_u8, 0x40_u8, + 0xD6_u8, 0x01_u8, 0x50_u8, 0x4B_u8, 0x05_u8, 0x06_u8, 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, + 0x02_u8, 0x00_u8, 0x02_u8, 0x00_u8, 0xC2_u8, 0x00_u8, 0x00_u8, 0x00_u8, 0x70_u8, 0x01_u8, + 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, +]; + +const ZIP64_ID: u16 = 0x0001; + +/// Disable when only deflate-zopfli +#[cfg(not(all(feature = "deflate-zopfli", not(feature = "deflate-flate2"))))] +const CONTENT: &[u8] = b"the entry's own sizes and offset are the ones to trust"; + +/// Disable when only deflate-zopfli +#[cfg(not(all(feature = "deflate-zopfli", not(feature = "deflate-flate2"))))] +fn archive_with_malformed_zip64_block() -> Vec { + use std::io::Write; + + use zip::write::{ExtendedFileOptions, FileOptions}; + + // Header id the archive is built with, swapped for the ZIP64 id once it is finished: the + // writer rightly refuses to emit a custom field under 0x0001, and rewriting the two id bytes + // afterwards leaves every length and offset exactly where the writer put them. + const PLACEHOLDER_ID: u16 = 0x7a7a; + let payload = { + // The 32 byte payload of the malformed block, shaped like the one found in the wild. A correct + // block is `[uncompressed u64][compressed u64][header offset u64][disk u32]`; this one begins + // with eight bytes belonging to the NTFS block's header (a reserved u32, then tag 0x0001 and + // size 0x0018), so every value sits eight bytes to the right of where a reader looks for it. + // The values here are deliberately nothing like the entry's real ones: if any of them were + // applied, the archive would not read. + let mut payload = Vec::with_capacity(32); + payload.extend_from_slice(&[0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x18, 0x00]); + payload.extend_from_slice(&u64::to_le_bytes(0x0018_0100_0000_0000)); + payload.extend_from_slice(&u64::to_le_bytes(270)); + payload.extend_from_slice(&u64::to_le_bytes(186)); + payload + }; + let mut options: FileOptions = FileOptions::default(); + // Added to the local header, from where the writer mirrors it into the central directory, + // so both headers carry it exactly as the archives found in the wild do. + options + .add_extra_field(PLACEHOLDER_ID, &payload, false) + .expect("extra field should be accepted"); + + let mut writer = ZipWriter::new(Cursor::new(Vec::new())); + writer.start_file("hello.txt", options).unwrap(); + writer.write_all(CONTENT).unwrap(); + let mut archive = writer.finish().unwrap().into_inner(); + + let payload_len = u16::try_from(payload.len()).unwrap(); + { + // Rewrites every `PLACEHOLDER_ID` field header to `ZIP64_ID`, in place and in both the local + // and the central header, without moving a byte. + let mut needle = Vec::with_capacity(4); + needle.extend_from_slice(&PLACEHOLDER_ID.to_le_bytes()); + needle.extend_from_slice(&payload_len.to_le_bytes()); + let mut promoted = 0; + for start in 0..archive.len().saturating_sub(needle.len()) { + if &archive[start..start + needle.len()] == needle.as_slice() { + archive[start..start + 2].copy_from_slice(&ZIP64_ID.to_le_bytes()); + promoted += 1; + } + } + assert_eq!( + promoted, 2, + "expected the field in both the local and central header" + ); + } + archive +} + +/// Disable when only deflate-zopfli +#[test] +#[cfg(not(all(feature = "deflate-zopfli", not(feature = "deflate-flate2"))))] +fn a_zip64_block_no_field_asked_for_does_not_override_the_entry() { + let bytes = archive_with_malformed_zip64_block(); + let mut archive = ZipArchive::new(Cursor::new(bytes)).expect("archive should open"); + assert_eq!(archive.len(), 1); + + let mut entry = archive.by_name("hello.txt").expect("entry should be found"); + assert_eq!( + entry.size(), + CONTENT.len() as u64, + "the entry's own uncompressed size stands" + ); + let mut read_back = Vec::new(); + entry.read_to_end(&mut read_back).unwrap(); + assert_eq!(read_back, CONTENT); +} + +/// The same archive read by the streaming reader, which parses the local header rather than the +/// central directory and so reaches the block by a different path. +/// Disable when only deflate-zopfli +#[test] +#[cfg(not(all(feature = "deflate-zopfli", not(feature = "deflate-flate2"))))] +fn the_streaming_reader_also_ignores_such_a_block() { + let bytes = archive_with_malformed_zip64_block(); + let mut reader = Cursor::new(bytes); + let mut entry = zip::read::read_zipfile_from_stream(&mut reader) + .expect("local header should parse") + .expect("there should be an entry"); + assert_eq!(entry.name().unwrap(), "hello.txt"); + let mut read_back = Vec::new(); + entry.read_to_end(&mut read_back).unwrap(); + assert_eq!(read_back, CONTENT); +} + +/// The archive the tests above model, as it was actually found: an EPUB whose every entry carries +/// a 32 byte ZIP64 block, no sentinel anywhere, and whose reads used to fail with +/// `InvalidArchive("Invalid local file header")`. +#[test] +fn the_epub_this_came_from_reads() { + let mut archive = ZipArchive::new(Cursor::new(REAL_WORLD_EPUB)).expect("archive should open"); + assert_eq!(archive.len(), 2); + check_entries_line_up(&mut archive); + + // `mimetype` is stored, so it reads whatever features are on. + let mut mimetype = archive.by_name("mimetype").unwrap(); + let mut read_back = String::new(); + mimetype.read_to_string(&mut read_back).unwrap(); + assert_eq!(read_back, "application/epub+zip"); +} + +/// A value that was ignored on the way in must not be written back out on the way through. +/// +/// `Zip64ExtendedInformation` is the same type on both sides, and a `None` field means "this entry +/// has nothing to record here" to the writer. Keeping a value no sentinel asked for would make the +/// two sides disagree, and `ZipWriter::new_append` would then re-emit it: the entry's own 32 bit +/// relative offset field says one thing while the ZIP64 block it carries says another. +#[test] +fn a_value_no_sentinel_asked_for_is_not_written_back_out() { + let writer = ZipWriter::new_append(Cursor::new(REAL_WORLD_EPUB.to_vec())) + .expect("archive should open for append"); + let appended = writer.finish().unwrap().into_inner(); + + for (name, block) in central_directory_zip64_blocks(&appended) { + assert!( + block.len() <= 16, + "{name}: {} byte ZIP64 block written, so it carries a relative header offset that no sentinel asked for", + block.len() + ); + } + + let mut archive = ZipArchive::new(Cursor::new(appended)).expect("output should open"); + assert_eq!(archive.len(), 2); + check_entries_line_up(&mut archive); +} + +/// Every entry's local header is where the central directory says it is, and still declares the +/// sizes the central directory does. This is the check the bug used to fail: a relative offset +/// taken from a block no sentinel asked for pointed into the middle of the compressed data, and +/// the seek there found no `PK\x03\x04`. +/// +/// It goes through the raw reader so that it holds whatever compression features are enabled; +/// `META-INF/container.xml` is deflated, and this crate can be built without a deflate decoder. +fn check_entries_line_up(archive: &mut ZipArchive) { + for i in 0..archive.len() { + let entry = archive.by_index_raw(i).expect("entry should be found"); + assert!( + entry.size() > 0 && entry.compressed_size() > 0, + "{}: empty entry, fixture is not what the test expects", + entry.name().unwrap() + ); + } + + let container = archive + .by_index_raw(1) + .expect("META-INF/container.xml should be found"); + assert_eq!(container.name().unwrap(), "META-INF/container.xml"); + assert_eq!(container.size(), 270, "the entry's own size stands"); + assert_eq!( + container.compressed_size(), + 186, + "the entry's own compressed size stands" + ); +} + +/// The same fixture read all the way through, where there is a deflate decoder to do it with. +#[cfg(feature = "deflate-flate2")] +#[test] +fn the_epub_this_came_from_reads_its_contents() { + let mut archive = ZipArchive::new(Cursor::new(REAL_WORLD_EPUB)).expect("archive should open"); + let mut container = archive.by_name("META-INF/container.xml").unwrap(); + let mut read_back = String::new(); + container.read_to_string(&mut read_back).unwrap(); + assert!(read_back.contains("OEBPS/content.opf")); +} + +/// The payload of every ZIP64 extended information block in the central directory, by entry name. +fn central_directory_zip64_blocks(archive: &[u8]) -> Vec<(String, Vec)> { + let eocd = archive + .windows(4) + .rposition(|w| w == b"PK\x05\x06") + .expect("end of central directory record"); + let entries = u16::from_le_bytes(archive[eocd + 10..eocd + 12].try_into().unwrap()); + let mut at = u32::from_le_bytes(archive[eocd + 16..eocd + 20].try_into().unwrap()) as usize; + + let mut blocks = Vec::new(); + for _ in 0..entries { + assert_eq!( + &archive[at..at + 4], + b"PK\x01\x02", + "central directory entry" + ); + let field = |o: usize| u16::from_le_bytes(archive[at + o..at + o + 2].try_into().unwrap()); + let (name_len, extra_len, comment_len) = + (field(28) as usize, field(30) as usize, field(32) as usize); + let name = String::from_utf8_lossy(&archive[at + 46..at + 46 + name_len]).into_owned(); + let extra = &archive[at + 46 + name_len..at + 46 + name_len + extra_len]; + + let mut i = 0; + while i + 4 <= extra.len() { + let id = u16::from_le_bytes(extra[i..i + 2].try_into().unwrap()); + let len = u16::from_le_bytes(extra[i + 2..i + 4].try_into().unwrap()) as usize; + if id == ZIP64_ID { + blocks.push((name.clone(), extra[i + 4..i + 4 + len].to_vec())); + } + i += 4 + len; + } + at += 46 + name_len + extra_len + comment_len; + } + blocks +}