//! A minimal zip reader: enough for a PCSX2 savestate, nothing more. //! //! PCSX2 writes savestates with libzip: a sequence of local file headers //! followed by a central directory. Entries are stored, deflated or //! zstd-compressed, so this reader takes the entry's compression method from //! its local header and decodes with it. Only what the format needs is //! implemented, and what is not needed is rejected rather than guessed at: //! //! * **no streamed entries** — libzip writes the compressed size into the //! local header, and a `data descriptor` (flag bit 3) is an error. Making //! that an error rather than a guess matters: misparsed sizes produce a //! plausible but wrong read of the next entry. //! * **CRC-32 is checked.** A savestate that was truncated or partially //! overwritten would otherwise read as a plausible wrong buffer, and this //! harness exists to compare against real bytes. //! //! Zip64 is not needed for a 32 MiB entry, and is not supported. use std::io::Read; use anyhow::{anyhow, Context, Result}; /// One entry in a zip archive. #[derive(Debug, Clone)] pub struct Entry { pub name: String, /// 0 = stored, 8 = deflate, 93 = zstd. pub method: u16, /// Byte range of the compressed payload within the archive. pub data: std::ops::Range, /// Expected CRC-32 of the uncompressed payload. pub crc32: u32, /// Uncompressed size in bytes. pub uncompressed_size: usize, } /// A parsed zip archive over a borrowed byte range. #[derive(Debug, Clone)] pub struct Zip { entries: Vec, } pub const LOCAL_HEADER: u32 = 0x0403_4b50; const METHOD_STORED: u16 = 0; const METHOD_DEFLATE: u16 = 8; const METHOD_ZSTD: u16 = 93; impl Zip { /// Walk the local file headers from the start of the archive. pub fn parse(bytes: &[u8]) -> Result { let mut entries = Vec::new(); let mut pos = 0usize; while pos + 4 <= bytes.len() { let sig = u32::from_le_bytes(bytes[pos..pos + 4].try_into().unwrap()); if sig != LOCAL_HEADER { if pos == 0 { return Err(anyhow!("not a zip archive")); } // The central directory (or an end-of-central-directory // record) ends the local-header run. break; } let h = LocalHeader::parse(bytes, pos)?; if h.flags & 0x08 != 0 { return Err(anyhow!( "entry {:?} uses a data descriptor; this reader needs sizes in the local header", h.name )); } let data_start = pos + 30 + h.name_len + h.extra_len; let data_end = data_start.checked_add(h.compressed_size).ok_or_else(|| { anyhow!("entry {:?} compressed size overflows the archive", h.name) })?; if data_end > bytes.len() { return Err(anyhow!( "entry {:?} at +{data_start:#x} runs {} bytes past the end of the archive", h.name, data_end - bytes.len() )); } entries.push(Entry { name: h.name, method: h.method, data: data_start..data_end, crc32: h.crc32, uncompressed_size: h.uncompressed_size, }); pos = data_end; } if entries.is_empty() { return Err(anyhow!("zip archive holds no entries")); } Ok(Zip { entries }) } pub fn entries(&self) -> &[Entry] { &self.entries } pub fn find(&self, name: &str) -> Option<&Entry> { self.entries.iter().find(|e| e.name == name) } /// Read and decompress one entry. pub fn read(&self, bytes: &[u8], name: &str) -> Result> { let e = self .find(name) .ok_or_else(|| anyhow!("zip archive has no entry {name:?}"))?; let raw = &bytes[e.data.clone()]; let out = match e.method { METHOD_STORED => raw.to_vec(), METHOD_DEFLATE => { let mut d = flate2::read::DeflateDecoder::new(raw); let mut out = Vec::with_capacity(e.uncompressed_size); d.read_to_end(&mut out).context("deflate entry")?; out } METHOD_ZSTD => { let d = ruzstd::decoding::StreamingDecoder::new(raw).context("zstd entry header")?; let mut out = Vec::with_capacity(e.uncompressed_size); d.take(e.uncompressed_size.max(1) as u64) .read_to_end(&mut out) .context("zstd entry")?; out } other => return Err(anyhow!("entry {name:?} uses unsupported method {other}")), }; if out.len() != e.uncompressed_size { return Err(anyhow!( "entry {name:?} decompressed to {} bytes, header says {}", out.len(), e.uncompressed_size )); } if crc32(&out) != e.crc32 { return Err(anyhow!("entry {name:?} failed its CRC-32 check")); } Ok(out) } } struct LocalHeader { name: String, method: u16, flags: u16, crc32: u32, compressed_size: usize, uncompressed_size: usize, name_len: usize, extra_len: usize, } impl LocalHeader { fn parse(bytes: &[u8], pos: usize) -> Result { let fixed = bytes .get(pos..pos + 30) .ok_or_else(|| anyhow!("local header at +{pos:#x} is truncated"))?; let flags = le16(fixed, 6); let method = le16(fixed, 8); let crc32 = le32(fixed, 14); let compressed_size = le32(fixed, 18) as usize; let uncompressed_size = le32(fixed, 22) as usize; let name_len = le16(fixed, 26) as usize; let extra_len = le16(fixed, 28) as usize; let name_start = pos + 30; let name_end = name_start + name_len; let name = bytes .get(name_start..name_end) .ok_or_else(|| anyhow!("entry name at +{pos:#x} runs past the archive"))?; let name = String::from_utf8(name.to_vec()) .map_err(|_| anyhow!("entry name at +{pos:#x} is not UTF-8"))?; Ok(LocalHeader { name, method, flags, crc32, compressed_size, uncompressed_size, name_len, extra_len, }) } } fn le16(b: &[u8], at: usize) -> u16 { u16::from_le_bytes(b[at..at + 2].try_into().unwrap()) } fn le32(b: &[u8], at: usize) -> u32 { u32::from_le_bytes(b[at..at + 4].try_into().unwrap()) } /// CRC-32 (IEEE 802.3), the one zip uses. Written out rather than pulled from /// a crate because it is fifteen lines and keeps the lockfile unchanged. pub fn crc32(data: &[u8]) -> u32 { let mut table = [0u32; 256]; for (i, slot) in table.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 }; } *slot = c; } let mut c = 0xFFFF_FFFFu32; for &b in data { c = table[((c ^ b as u32) & 0xff) as usize] ^ (c >> 8); } c ^ 0xFFFF_FFFF } #[cfg(test)] mod tests { use super::*; /// A stored entry, written by hand so the test does not need an encoder. fn stored_zip(name: &str, payload: &[u8]) -> Vec { let mut out = Vec::new(); out.extend_from_slice(&LOCAL_HEADER.to_le_bytes()); out.extend_from_slice(&20u16.to_le_bytes()); // version needed out.extend_from_slice(&0u16.to_le_bytes()); // flags out.extend_from_slice(&METHOD_STORED.to_le_bytes()); out.extend_from_slice(&0u16.to_le_bytes()); // time out.extend_from_slice(&0u16.to_le_bytes()); // date out.extend_from_slice(&crc32(payload).to_le_bytes()); out.extend_from_slice(&(payload.len() as u32).to_le_bytes()); out.extend_from_slice(&(payload.len() as u32).to_le_bytes()); out.extend_from_slice(&(name.len() as u16).to_le_bytes()); out.extend_from_slice(&0u16.to_le_bytes()); // extra len out.extend_from_slice(name.as_bytes()); out.extend_from_slice(payload); // A minimal end-of-central-directory record; the reader stops at it. out.extend_from_slice(&0x0605_4b50u32.to_le_bytes()); out.extend_from_slice(&[0u8; 18]); out } #[test] fn reads_a_stored_entry_and_checks_its_crc() { let z = stored_zip("eeMemory.bin", b"0123456789"); let zip = Zip::parse(&z).expect("should parse"); assert_eq!(zip.entries().len(), 1); assert_eq!(zip.entries()[0].name, "eeMemory.bin"); assert_eq!(zip.read(&z, "eeMemory.bin").unwrap(), b"0123456789"); } #[test] fn a_corrupted_entry_is_an_error_not_a_wrong_read() { let mut z = stored_zip("v.bin", b"0123456789"); let last = z.len() - 19 - 5; z[last] ^= 0xff; let zip = Zip::parse(&z).expect("header parsing survives"); let err = zip.read(&z, "v.bin").unwrap_err().to_string(); assert!(err.contains("CRC-32"), "got {err}"); } #[test] fn a_data_descriptor_entry_is_rejected() { let mut z = stored_zip("v.bin", b"payload"); // Rewrite the flags field (offset 6) to set bit 3. z[6] = 0x08; let err = Zip::parse(&z).unwrap_err().to_string(); assert!(err.contains("data descriptor"), "got {err}"); } #[test] fn a_truncated_entry_is_rejected() { let mut z = stored_zip("v.bin", b"0123456789"); // The header declares ten payload bytes; leave one of them missing. // Truncating the end instead would only chew into the central // directory, which this reader stops at before it gets there. let declared = 30 + "v.bin".len() + 10; z.truncate(declared - 1); let err = Zip::parse(&z).unwrap_err().to_string(); assert!(err.contains("past the end"), "got {err}"); } #[test] fn crc32_matches_the_known_value() { // The standard check value for CRC-32/ISO-HDLC. assert_eq!(crc32(b"123456789"), 0xCBF4_3926); } /// A deflate entry. The stream is the deflate encoding of `b"AB"` produced /// by an independent encoder (Python's zlib, raw wbits), so this checks /// against something this code did not make. #[test] fn reads_a_deflate_entry() { let deflated: &[u8] = &[115, 116, 2, 0]; let mut out = Vec::new(); out.extend_from_slice(&LOCAL_HEADER.to_le_bytes()); out.extend_from_slice(&20u16.to_le_bytes()); out.extend_from_slice(&0u16.to_le_bytes()); out.extend_from_slice(&METHOD_DEFLATE.to_le_bytes()); out.extend_from_slice(&0u16.to_le_bytes()); out.extend_from_slice(&0u16.to_le_bytes()); out.extend_from_slice(&crc32(b"AB").to_le_bytes()); out.extend_from_slice(&(deflated.len() as u32).to_le_bytes()); out.extend_from_slice(&2u32.to_le_bytes()); out.extend_from_slice(&("v.bin".len() as u16).to_le_bytes()); out.extend_from_slice(&0u16.to_le_bytes()); out.extend_from_slice(b"v.bin"); out.extend_from_slice(deflated); let zip = Zip::parse(&out).expect("should parse"); assert_eq!(zip.read(&out, "v.bin").unwrap(), b"AB"); } /// A zstd entry, the way PCSX2 writes them. The frame is the zstd encoding /// of `b"AB"` from an independent encoder (Python 3.14's zipfile), and it /// uses raw blocks the way a seven-byte payload needs. #[test] fn reads_a_zstd_entry() { let zstd_frame: &[u8] = &[40, 181, 47, 253, 0, 88, 17, 0, 0, 65, 66]; let mut out = Vec::new(); out.extend_from_slice(&LOCAL_HEADER.to_le_bytes()); out.extend_from_slice(&20u16.to_le_bytes()); out.extend_from_slice(&0u16.to_le_bytes()); out.extend_from_slice(&93u16.to_le_bytes()); out.extend_from_slice(&0u16.to_le_bytes()); out.extend_from_slice(&0u16.to_le_bytes()); out.extend_from_slice(&crc32(b"AB").to_le_bytes()); out.extend_from_slice(&(zstd_frame.len() as u32).to_le_bytes()); out.extend_from_slice(&2u32.to_le_bytes()); out.extend_from_slice(&("v.bin".len() as u16).to_le_bytes()); out.extend_from_slice(&0u16.to_le_bytes()); out.extend_from_slice(b"v.bin"); out.extend_from_slice(zstd_frame); let zip = Zip::parse(&out).expect("should parse"); assert_eq!(zip.entries()[0].method, 93); assert_eq!(zip.read(&out, "v.bin").unwrap(), b"AB"); } }