Skip to main content

carta_core/container/
zip.rs

1//! A deterministic ZIP archive builder and reader.
2//!
3//! The builder writes entries with a fixed modification timestamp and no extra fields, so identical
4//! inputs always produce byte-identical archives — a hard requirement for reproducible output. An
5//! entry is either stored uncompressed ([`ZipArchive::store`]) or DEFLATE-compressed
6//! ([`ZipArchive::deflate`], falling back to stored when compression would not shrink it). The
7//! reader ([`read`]) parses such archives back into their entries, so a round trip is exact.
8
9use crate::{Error, Result};
10
11/// DEFLATE level used by [`ZipArchive::deflate`]. Fixed so output stays reproducible.
12const DEFLATE_LEVEL: u8 = 9;
13
14const METHOD_STORE: u16 = 0;
15const METHOD_DEFLATE: u16 = 8;
16
17/// Modification date/time stamped on every entry: 1980-01-01 00:00:00, the ZIP epoch. A constant
18/// keeps archives reproducible regardless of the wall clock.
19const DOS_TIME: u16 = 0;
20const DOS_DATE: u16 = 0x0021;
21
22const VERSION_MADE_BY: u16 = 20;
23const VERSION_STORE: u16 = 10;
24const VERSION_DEFLATE: u16 = 20;
25
26const SIG_LOCAL: u32 = 0x0403_4b50;
27const SIG_CENTRAL: u32 = 0x0201_4b50;
28const SIG_EOCD: u32 = 0x0605_4b50;
29
30/// General-purpose bit 11: the file name is UTF-8. Set only when a name has non-ASCII bytes.
31const GPBF_UTF8: u16 = 1 << 11;
32
33const LOCAL_HEADER_LEN: usize = 30;
34const CENTRAL_HEADER_LEN: usize = 46;
35const EOCD_LEN: usize = 22;
36
37/// A builder that accumulates entries and serializes them into a ZIP archive.
38#[derive(Debug, Default)]
39pub struct ZipArchive {
40    body: Vec<u8>,
41    entries: Vec<CentralEntry>,
42}
43
44#[derive(Debug)]
45struct CentralEntry {
46    name: String,
47    method: u16,
48    version_needed: u16,
49    crc: u32,
50    compressed_size: u32,
51    uncompressed_size: u32,
52    local_offset: u32,
53    utf8: bool,
54}
55
56impl ZipArchive {
57    /// An empty archive.
58    #[must_use]
59    pub fn new() -> Self {
60        Self::default()
61    }
62
63    /// Adds `data` under `name`, stored uncompressed. Required for an entry that must not be
64    /// compressed, such as an e-book package's signature `mimetype`.
65    pub fn store(&mut self, name: &str, data: &[u8]) -> Result<()> {
66        self.write_entry(name, data, METHOD_STORE, data, VERSION_STORE)
67    }
68
69    /// Adds `data` under `name`, DEFLATE-compressed — unless compression fails to shrink it, in
70    /// which case the entry is stored (so an already-compressed image never grows).
71    pub fn deflate(&mut self, name: &str, data: &[u8]) -> Result<()> {
72        let compressed = miniz_oxide::deflate::compress_to_vec(data, DEFLATE_LEVEL);
73        if compressed.len() < data.len() {
74            self.write_entry(name, data, METHOD_DEFLATE, &compressed, VERSION_DEFLATE)
75        } else {
76            self.store(name, data)
77        }
78    }
79
80    fn write_entry(
81        &mut self,
82        name: &str,
83        uncompressed: &[u8],
84        method: u16,
85        payload: &[u8],
86        version_needed: u16,
87    ) -> Result<()> {
88        let uncompressed_size =
89            u32::try_from(uncompressed.len()).map_err(|_| entry_too_large(name))?;
90        let compressed_size = u32::try_from(payload.len()).map_err(|_| entry_too_large(name))?;
91        let local_offset = u32::try_from(self.body.len()).map_err(|_| entry_too_large(name))?;
92        let name_bytes = name.as_bytes();
93        let name_len = u16::try_from(name_bytes.len()).map_err(|_| entry_too_large(name))?;
94        let utf8 = !name.is_ascii();
95        let flags = if utf8 { GPBF_UTF8 } else { 0 };
96        let crc = crc32(uncompressed);
97
98        self.body.extend_from_slice(&SIG_LOCAL.to_le_bytes());
99        self.body.extend_from_slice(&version_needed.to_le_bytes());
100        self.body.extend_from_slice(&flags.to_le_bytes());
101        self.body.extend_from_slice(&method.to_le_bytes());
102        self.body.extend_from_slice(&DOS_TIME.to_le_bytes());
103        self.body.extend_from_slice(&DOS_DATE.to_le_bytes());
104        self.body.extend_from_slice(&crc.to_le_bytes());
105        self.body.extend_from_slice(&compressed_size.to_le_bytes());
106        self.body
107            .extend_from_slice(&uncompressed_size.to_le_bytes());
108        self.body.extend_from_slice(&name_len.to_le_bytes());
109        self.body.extend_from_slice(&0u16.to_le_bytes());
110        self.body.extend_from_slice(name_bytes);
111        self.body.extend_from_slice(payload);
112
113        self.entries.push(CentralEntry {
114            name: name.to_owned(),
115            method,
116            version_needed,
117            crc,
118            compressed_size,
119            uncompressed_size,
120            local_offset,
121            utf8,
122        });
123        Ok(())
124    }
125
126    /// Serializes the archive: the accumulated entry bodies, then the central directory, then the
127    /// end-of-central-directory record.
128    pub fn finish(self) -> Result<Vec<u8>> {
129        let mut out = self.body;
130        let central_offset = u32::try_from(out.len()).map_err(|_| archive_too_large())?;
131
132        for entry in &self.entries {
133            let name_bytes = entry.name.as_bytes();
134            let name_len = u16::try_from(name_bytes.len()).map_err(|_| archive_too_large())?;
135            let flags = if entry.utf8 { GPBF_UTF8 } else { 0 };
136            out.extend_from_slice(&SIG_CENTRAL.to_le_bytes());
137            out.extend_from_slice(&VERSION_MADE_BY.to_le_bytes());
138            out.extend_from_slice(&entry.version_needed.to_le_bytes());
139            out.extend_from_slice(&flags.to_le_bytes());
140            out.extend_from_slice(&entry.method.to_le_bytes());
141            out.extend_from_slice(&DOS_TIME.to_le_bytes());
142            out.extend_from_slice(&DOS_DATE.to_le_bytes());
143            out.extend_from_slice(&entry.crc.to_le_bytes());
144            out.extend_from_slice(&entry.compressed_size.to_le_bytes());
145            out.extend_from_slice(&entry.uncompressed_size.to_le_bytes());
146            out.extend_from_slice(&name_len.to_le_bytes());
147            out.extend_from_slice(&0u16.to_le_bytes()); // extra field length
148            out.extend_from_slice(&0u16.to_le_bytes()); // comment length
149            out.extend_from_slice(&0u16.to_le_bytes()); // disk number start
150            out.extend_from_slice(&0u16.to_le_bytes()); // internal attributes
151            out.extend_from_slice(&0u32.to_le_bytes()); // external attributes
152            out.extend_from_slice(&entry.local_offset.to_le_bytes());
153            out.extend_from_slice(name_bytes);
154        }
155
156        let central_end = u32::try_from(out.len()).map_err(|_| archive_too_large())?;
157        let central_size = central_end - central_offset;
158        let count = u16::try_from(self.entries.len()).map_err(|_| archive_too_large())?;
159
160        out.extend_from_slice(&SIG_EOCD.to_le_bytes());
161        out.extend_from_slice(&0u16.to_le_bytes()); // this disk number
162        out.extend_from_slice(&0u16.to_le_bytes()); // disk with central directory
163        out.extend_from_slice(&count.to_le_bytes());
164        out.extend_from_slice(&count.to_le_bytes());
165        out.extend_from_slice(&central_size.to_le_bytes());
166        out.extend_from_slice(&central_offset.to_le_bytes());
167        out.extend_from_slice(&0u16.to_le_bytes()); // comment length
168        Ok(out)
169    }
170}
171
172/// One entry recovered from an archive by [`read`].
173#[derive(Debug, Clone, PartialEq, Eq)]
174pub struct ZipEntry {
175    /// The entry's name (its path within the archive).
176    pub name: String,
177    /// The entry's uncompressed bytes.
178    pub data: Vec<u8>,
179}
180
181/// Parses `archive` into its entries, in central-directory order, decompressing DEFLATE entries.
182///
183/// # Errors
184/// Returns [`Error::Container`] if the archive is truncated, uses an unsupported compression method,
185/// carries a non-UTF-8 name, or fails to decompress.
186pub fn read(archive: &[u8]) -> Result<Vec<ZipEntry>> {
187    let eocd =
188        find_eocd(archive).ok_or_else(|| corrupt("missing end-of-central-directory record"))?;
189    let count = u16_at(archive, eocd + 10)?;
190    let mut pos =
191        usize::try_from(u32_at(archive, eocd + 16)?).map_err(|_| corrupt("offset too large"))?;
192
193    let mut entries = Vec::with_capacity(usize::from(count));
194    for _ in 0..count {
195        if u32_at(archive, pos)? != SIG_CENTRAL {
196            return Err(corrupt("bad central-directory signature"));
197        }
198        let method = u16_at(archive, pos + 10)?;
199        let compressed_size =
200            usize::try_from(u32_at(archive, pos + 20)?).map_err(|_| corrupt("size too large"))?;
201        let uncompressed_size =
202            usize::try_from(u32_at(archive, pos + 24)?).map_err(|_| corrupt("size too large"))?;
203        let name_len = usize::from(u16_at(archive, pos + 28)?);
204        let extra_len = usize::from(u16_at(archive, pos + 30)?);
205        let comment_len = usize::from(u16_at(archive, pos + 32)?);
206        let local_offset =
207            usize::try_from(u32_at(archive, pos + 42)?).map_err(|_| corrupt("offset too large"))?;
208
209        let name_start = add(pos, CENTRAL_HEADER_LEN)?;
210        let name_bytes = archive
211            .get(name_start..add(name_start, name_len)?)
212            .ok_or_else(|| corrupt("truncated central-directory name"))?;
213        let name =
214            String::from_utf8(name_bytes.to_vec()).map_err(|_| corrupt("non-UTF-8 entry name"))?;
215
216        let local_name_len = usize::from(u16_at(archive, add(local_offset, 26)?)?);
217        let local_extra_len = usize::from(u16_at(archive, add(local_offset, 28)?)?);
218        let data_start = add(
219            add(add(local_offset, LOCAL_HEADER_LEN)?, local_name_len)?,
220            local_extra_len,
221        )?;
222        let raw = archive
223            .get(data_start..add(data_start, compressed_size)?)
224            .ok_or_else(|| corrupt("truncated entry data"))?;
225
226        let data = match method {
227            METHOD_STORE => raw.to_vec(),
228            // Decompress no further than the central directory's declared uncompressed size, so a
229            // crafted entry whose tiny payload would otherwise inflate without bound cannot exhaust
230            // memory; a result that disagrees with the declared size marks a corrupt archive.
231            METHOD_DEFLATE => {
232                let out =
233                    miniz_oxide::inflate::decompress_to_vec_with_limit(raw, uncompressed_size)
234                        .map_err(|_| corrupt("DEFLATE decode failed"))?;
235                if out.len() != uncompressed_size {
236                    return Err(corrupt("DEFLATE size disagrees with declared size"));
237                }
238                out
239            }
240            other => return Err(corrupt(&format!("unsupported compression method {other}"))),
241        };
242        entries.push(ZipEntry { name, data });
243        pos = add(
244            add(add(add(pos, CENTRAL_HEADER_LEN)?, name_len)?, extra_len)?,
245            comment_len,
246        )?;
247    }
248    Ok(entries)
249}
250
251/// Add two archive offsets, mapping overflow to a corrupt-archive error. An offset and a length
252/// taken from a hostile archive can sum past the address space; without this the addition would wrap
253/// in release (yielding an in-range slice of the wrong bytes) or panic in debug.
254fn add(a: usize, b: usize) -> Result<usize> {
255    a.checked_add(b)
256        .ok_or_else(|| corrupt("archive offset out of range"))
257}
258
259fn find_eocd(buf: &[u8]) -> Option<usize> {
260    if buf.len() < EOCD_LEN {
261        return None;
262    }
263    let signature = SIG_EOCD.to_le_bytes();
264    let earliest = buf.len().saturating_sub(EOCD_LEN + usize::from(u16::MAX));
265    let mut at = buf.len() - EOCD_LEN;
266    loop {
267        if buf.get(at..at + 4) == Some(&signature[..]) {
268            return Some(at);
269        }
270        if at == earliest {
271            return None;
272        }
273        at -= 1;
274    }
275}
276
277fn u16_at(buf: &[u8], at: usize) -> Result<u16> {
278    let bytes = buf
279        .get(at..)
280        .and_then(|rest| rest.get(..2))
281        .ok_or_else(|| corrupt("truncated field"))?;
282    let array = <[u8; 2]>::try_from(bytes).map_err(|_| corrupt("truncated field"))?;
283    Ok(u16::from_le_bytes(array))
284}
285
286fn u32_at(buf: &[u8], at: usize) -> Result<u32> {
287    let bytes = buf
288        .get(at..)
289        .and_then(|rest| rest.get(..4))
290        .ok_or_else(|| corrupt("truncated field"))?;
291    let array = <[u8; 4]>::try_from(bytes).map_err(|_| corrupt("truncated field"))?;
292    Ok(u32::from_le_bytes(array))
293}
294
295/// The CRC-32 (IEEE 802.3) of `data`, computed bitwise so no lookup table is needed.
296fn crc32(data: &[u8]) -> u32 {
297    let mut crc: u32 = 0xFFFF_FFFF;
298    for &byte in data {
299        crc ^= u32::from(byte);
300        let mut bit = 0;
301        while bit < 8 {
302            let mask = (crc & 1).wrapping_neg();
303            crc = (crc >> 1) ^ (0xEDB8_8320 & mask);
304            bit += 1;
305        }
306    }
307    !crc
308}
309
310fn entry_too_large(name: &str) -> Error {
311    Error::Container(format!(
312        "zip entry '{name}' exceeds 4 GiB (ZIP64 is unsupported)"
313    ))
314}
315
316fn archive_too_large() -> Error {
317    Error::Container("zip archive exceeds 4 GiB (ZIP64 is unsupported)".to_owned())
318}
319
320fn corrupt(detail: &str) -> Error {
321    Error::Container(format!("corrupt zip archive: {detail}"))
322}
323
324#[cfg(test)]
325mod tests {
326    use super::{ZipArchive, ZipEntry, crc32, read};
327
328    #[test]
329    fn crc32_matches_known_vector() {
330        // The canonical CRC-32 check value for the ASCII string "123456789".
331        assert_eq!(crc32(b"123456789"), 0xCBF4_3926);
332        assert_eq!(crc32(b""), 0);
333    }
334
335    #[test]
336    fn stored_and_deflated_entries_round_trip() {
337        let mut archive = ZipArchive::new();
338        let text = b"hello, world; ".repeat(64);
339        archive
340            .store("mimetype", b"application/epub+zip")
341            .expect("store");
342        archive.deflate("EPUB/content.opf", &text).expect("deflate");
343        archive.deflate("small", b"x").expect("deflate small");
344        let bytes = archive.finish().expect("finish");
345
346        let entries = read(&bytes).expect("read");
347        assert_eq!(
348            entries,
349            vec![
350                ZipEntry {
351                    name: "mimetype".to_owned(),
352                    data: b"application/epub+zip".to_vec()
353                },
354                ZipEntry {
355                    name: "EPUB/content.opf".to_owned(),
356                    data: text
357                },
358                ZipEntry {
359                    name: "small".to_owned(),
360                    data: b"x".to_vec()
361                },
362            ]
363        );
364    }
365
366    #[test]
367    fn incompressible_data_falls_back_to_stored() {
368        // A single byte cannot be shrunk by DEFLATE, so the entry must be stored and still read back.
369        let mut archive = ZipArchive::new();
370        archive.deflate("one", b"Z").expect("deflate");
371        let bytes = archive.finish().expect("finish");
372        let entries = read(&bytes).expect("read");
373        assert_eq!(entries.first().map(|e| e.data.clone()), Some(b"Z".to_vec()));
374    }
375
376    #[test]
377    fn output_is_reproducible() {
378        let build = || {
379            let mut archive = ZipArchive::new();
380            archive
381                .store("mimetype", b"application/epub+zip")
382                .expect("store");
383            archive
384                .deflate("a.txt", b"repeatable content ".repeat(16).as_slice())
385                .expect("deflate");
386            archive.finish().expect("finish")
387        };
388        assert_eq!(build(), build());
389    }
390
391    #[test]
392    fn read_rejects_truncated_archive() {
393        assert!(read(b"not a zip").is_err());
394        assert!(read(b"").is_err());
395    }
396
397    #[test]
398    fn read_rejects_deflate_exceeding_declared_size() {
399        // A deflated entry whose central-directory record understates the uncompressed size is
400        // rejected rather than inflated past that bound — the guard against a decompression bomb
401        // that would otherwise expand a tiny payload into an unbounded allocation.
402        let mut archive = ZipArchive::new();
403        archive
404            .deflate("big", b"AAAAAAAAAAAAAAAA".repeat(64).as_slice())
405            .expect("deflate");
406        let mut bytes = archive.finish().expect("finish");
407        let central = bytes
408            .windows(4)
409            .position(|window| window == [0x50, 0x4b, 0x01, 0x02])
410            .expect("central-directory header");
411        bytes
412            .get_mut(central + 24..central + 28)
413            .expect("uncompressed-size field")
414            .copy_from_slice(&4u32.to_le_bytes());
415        assert!(read(&bytes).is_err());
416    }
417}