Skip to main content

frazaro_core/sheet/
zip.rs

1//! A zip container with stored entries and no clock (PORT.7, slice 7a).
2//!
3//! New ground, said plainly: the reference saves through Excel and never
4//! writes a zip. Every entry here is STORED (method 0), so the bytes of a
5//! part are the bytes in the file and the only arithmetic the format asks for
6//! is its CRC-32. Every entry carries the zip epoch, 1980-01-01 00:00:00, as
7//! its modification stamp, the one value that reads as "no clock"; entries go
8//! in the order given; no extra field, no comment, no zip64. Two calls over
9//! the same parts give the same bytes, which is what the build golden
10//! (`scripts/build/*_golden.xlsx`) and `frazaro rebuild` stand on, and what
11//! `tools/check_build_golden.ps1` reads off the artifact.
12//!
13//! Sizes are the format's 32-bit ones. The writer answers `None` when a part,
14//! an offset or the count of parts would not fit, and the build turns that
15//! into the catalogue's refusal; nothing here panics. The reader at the foot
16//! walks an archive's central directory back, for the tests and for
17//! `rebuild`, which reads only archives this writer made.
18
19const LOCAL_HEADER: u32 = 0x0403_4B50;
20const CENTRAL_HEADER: u32 = 0x0201_4B50;
21const END_OF_CENTRAL_DIRECTORY: u32 = 0x0605_4B50;
22/// "Version needed" and "version made by": 2.0, plain MS-DOS attributes.
23const VERSION: u16 = 20;
24/// The compression method of every entry: none.
25pub const STORED: u16 = 0;
26/// The modification stamp of every entry, in MS-DOS form: 00:00:00 on
27/// 1980-01-01 (day 1 of month 1 of year 1980, the format's year zero).
28pub const EPOCH_TIME: u16 = 0;
29pub const EPOCH_DATE: u16 = 0x0021;
30
31const fn crc_table() -> [u32; 256] {
32    let mut table = [0u32; 256];
33    let mut n = 0;
34    while n < 256 {
35        let mut c = n as u32;
36        let mut k = 0;
37        while k < 8 {
38            c = if c & 1 != 0 {
39                0xEDB8_8320 ^ (c >> 1)
40            } else {
41                c >> 1
42            };
43            k += 1;
44        }
45        table[n] = c;
46        n += 1;
47    }
48    table
49}
50const CRC_TABLE: [u32; 256] = crc_table();
51
52/// The IEEE CRC-32 (reflected polynomial EDB88320) as the zip format uses
53/// it; the check value for "123456789" is CBF43926.
54pub fn crc32(data: &[u8]) -> u32 {
55    let mut c = 0xFFFF_FFFFu32;
56    for &b in data {
57        c = CRC_TABLE[((c ^ u32::from(b)) & 0xFF) as usize] ^ (c >> 8);
58    }
59    c ^ 0xFFFF_FFFF
60}
61
62fn put_u16(out: &mut Vec<u8>, v: u16) {
63    out.extend_from_slice(&v.to_le_bytes());
64}
65
66fn put_u32(out: &mut Vec<u8>, v: u32) {
67    out.extend_from_slice(&v.to_le_bytes());
68}
69
70/// An entry written as given (slice 7d): `bytes` go into the file under
71/// `method`, and `crc` and `size` describe the uncompressed content. A
72/// stored entry's bytes are its content; an entry copied from another
73/// archive keeps that archive's compressed bytes, method, CRC and size, so
74/// the copy is byte-exact without decoding.
75pub struct RawEntry<'a> {
76    pub name: &'a str,
77    pub method: u16,
78    pub crc: u32,
79    pub size: u32,
80    pub bytes: &'a [u8],
81}
82
83/// The archive holding `parts`, each stored, in the order given; `None`
84/// when a size would not fit the format's fields.
85pub fn write_stored(parts: &[(String, Vec<u8>)]) -> Option<Vec<u8>> {
86    let mut raws = Vec::with_capacity(parts.len());
87    for (name, data) in parts {
88        raws.push(RawEntry {
89            name,
90            method: STORED,
91            crc: crc32(data),
92            size: u32::try_from(data.len()).ok()?,
93            bytes: data,
94        });
95    }
96    write_entries(&raws)
97}
98
99/// The archive holding `entries` as given, in the order given, every one
100/// stamped with the epoch; `None` when a size would not fit the format's
101/// fields.
102pub fn write_entries(entries: &[RawEntry]) -> Option<Vec<u8>> {
103    let count = u16::try_from(entries.len()).ok()?;
104    let mut out: Vec<u8> = Vec::new();
105    let mut central: Vec<u8> = Vec::new();
106    for e in entries {
107        let name_len = u16::try_from(e.name.len()).ok()?;
108        let compressed = u32::try_from(e.bytes.len()).ok()?;
109        let offset = u32::try_from(out.len()).ok()?;
110        // The local file header, then the entry's bytes as they are.
111        put_u32(&mut out, LOCAL_HEADER);
112        put_u16(&mut out, VERSION);
113        put_u16(&mut out, 0); // general purpose flags
114        put_u16(&mut out, e.method);
115        put_u16(&mut out, EPOCH_TIME);
116        put_u16(&mut out, EPOCH_DATE);
117        put_u32(&mut out, e.crc);
118        put_u32(&mut out, compressed);
119        put_u32(&mut out, e.size);
120        put_u16(&mut out, name_len);
121        put_u16(&mut out, 0); // extra field length
122        out.extend_from_slice(e.name.as_bytes());
123        out.extend_from_slice(e.bytes);
124        // The central directory's record of it.
125        put_u32(&mut central, CENTRAL_HEADER);
126        put_u16(&mut central, VERSION); // version made by
127        put_u16(&mut central, VERSION); // version needed
128        put_u16(&mut central, 0);
129        put_u16(&mut central, e.method);
130        put_u16(&mut central, EPOCH_TIME);
131        put_u16(&mut central, EPOCH_DATE);
132        put_u32(&mut central, e.crc);
133        put_u32(&mut central, compressed);
134        put_u32(&mut central, e.size);
135        put_u16(&mut central, name_len);
136        put_u16(&mut central, 0); // extra field length
137        put_u16(&mut central, 0); // comment length
138        put_u16(&mut central, 0); // disk number start
139        put_u16(&mut central, 0); // internal attributes
140        put_u32(&mut central, 0); // external attributes
141        put_u32(&mut central, offset);
142        central.extend_from_slice(e.name.as_bytes());
143    }
144    let cd_offset = u32::try_from(out.len()).ok()?;
145    let cd_size = u32::try_from(central.len()).ok()?;
146    out.extend_from_slice(&central);
147    put_u32(&mut out, END_OF_CENTRAL_DIRECTORY);
148    put_u16(&mut out, 0); // this disk
149    put_u16(&mut out, 0); // the disk the directory starts on
150    put_u16(&mut out, count);
151    put_u16(&mut out, count);
152    put_u32(&mut out, cd_size);
153    put_u32(&mut out, cd_offset);
154    put_u16(&mut out, 0); // comment length
155    Some(out)
156}
157
158/// One entry of an archive's central directory, with where its data starts.
159#[derive(Clone, Debug, PartialEq, Eq)]
160pub struct Entry {
161    pub name: String,
162    pub method: u16,
163    pub time: u16,
164    pub date: u16,
165    pub crc: u32,
166    pub compressed_size: u32,
167    pub size: u32,
168    /// The offset of the entry's bytes, behind its local header.
169    pub data_offset: usize,
170}
171
172fn read_u16(b: &[u8], at: usize) -> Option<u16> {
173    Some(u16::from_le_bytes([*b.get(at)?, *b.get(at + 1)?]))
174}
175
176fn read_u32(b: &[u8], at: usize) -> Option<u32> {
177    Some(u32::from_le_bytes([
178        *b.get(at)?,
179        *b.get(at + 1)?,
180        *b.get(at + 2)?,
181        *b.get(at + 3)?,
182    ]))
183}
184
185const ZIP64_LOCATOR: u32 = 0x0706_4B50;
186
187/// Where the end-of-central-directory record starts: the last 22 bytes
188/// when the archive has no comment, and up to 65,535 bytes earlier when it
189/// has one (the record's comment length must then reach the end).
190fn find_eocd(bytes: &[u8]) -> Option<usize> {
191    if bytes.len() < 22 {
192        return None;
193    }
194    let last = bytes.len() - 22;
195    let first = last.saturating_sub(65_535);
196    (first..=last).rev().find(|&i| {
197        read_u32(bytes, i) == Some(END_OF_CENTRAL_DIRECTORY)
198            && read_u16(bytes, i + 20).map(usize::from) == Some(bytes.len() - 22 - i)
199    })
200}
201
202/// Why `entries` would answer `None` for these bytes, in a few words, or
203/// `None` when it would not.
204pub fn why_not_an_archive(bytes: &[u8]) -> Option<&'static str> {
205    if bytes.starts_with(&[0xD0, 0xCF, 0x11, 0xE0]) {
206        return Some("it is an older .xls or an encrypted workbook, not an .xlsx package");
207    }
208    let Some(eocd) = find_eocd(bytes) else {
209        return Some("it has no end-of-central-directory record, so it is not a zip archive");
210    };
211    if read_u16(bytes, eocd + 10) == Some(0xFFFF)
212        || read_u32(bytes, eocd + 16) == Some(0xFFFF_FFFF)
213        || (eocd >= 20 && read_u32(bytes, eocd - 20) == Some(ZIP64_LOCATOR))
214    {
215        return Some("it is a zip64 archive, which this version does not read");
216    }
217    if entries(bytes).is_none() {
218        return Some("its central directory does not read whole");
219    }
220    None
221}
222
223/// The central directory of an archive, in directory order; `None` when
224/// the bytes are not an archive this reader knows (zip64 included). A
225/// comment at the end is allowed; a data descriptor after an entry is
226/// harmless, since the sizes come from the directory. Each entry's data
227/// offset is read from its own local header, as a reader must.
228pub fn entries(bytes: &[u8]) -> Option<Vec<Entry>> {
229    let eocd = find_eocd(bytes)?;
230    let count = usize::from(read_u16(bytes, eocd + 10)?);
231    let cd_size = read_u32(bytes, eocd + 12)? as usize;
232    let cd_offset = read_u32(bytes, eocd + 16)? as usize;
233    if count == 0xFFFF || cd_offset == 0xFFFF_FFFF {
234        return None; // zip64
235    }
236    if cd_offset.checked_add(cd_size)? > eocd {
237        return None;
238    }
239    let mut pos = cd_offset;
240    let mut list = Vec::with_capacity(count);
241    for _ in 0..count {
242        if read_u32(bytes, pos)? != CENTRAL_HEADER {
243            return None;
244        }
245        let method = read_u16(bytes, pos + 10)?;
246        let time = read_u16(bytes, pos + 12)?;
247        let date = read_u16(bytes, pos + 14)?;
248        let crc = read_u32(bytes, pos + 16)?;
249        let compressed_size = read_u32(bytes, pos + 20)?;
250        let size = read_u32(bytes, pos + 24)?;
251        let name_len = usize::from(read_u16(bytes, pos + 28)?);
252        let extra_len = usize::from(read_u16(bytes, pos + 30)?);
253        let comment_len = usize::from(read_u16(bytes, pos + 32)?);
254        let local = read_u32(bytes, pos + 42)? as usize;
255        let name = std::str::from_utf8(bytes.get(pos + 46..pos + 46 + name_len)?)
256            .ok()?
257            .to_string();
258        if read_u32(bytes, local)? != LOCAL_HEADER {
259            return None;
260        }
261        let local_name_len = usize::from(read_u16(bytes, local + 26)?);
262        let local_extra_len = usize::from(read_u16(bytes, local + 28)?);
263        let data_offset = local + 30 + local_name_len + local_extra_len;
264        if data_offset.checked_add(compressed_size as usize)? > bytes.len() {
265            return None;
266        }
267        list.push(Entry {
268            name,
269            method,
270            time,
271            date,
272            crc,
273            compressed_size,
274            size,
275            data_offset,
276        });
277        pos += 46 + name_len + extra_len + comment_len;
278    }
279    Some(list)
280}
281
282/// A stored entry's bytes; `None` for an entry that is not stored.
283pub fn stored_data<'a>(bytes: &'a [u8], e: &Entry) -> Option<&'a [u8]> {
284    if e.method != STORED {
285        return None;
286    }
287    bytes.get(e.data_offset..e.data_offset + e.size as usize)
288}
289
290/// An entry's bytes as the archive holds them, compressed or not, for a
291/// byte-exact copy into another archive.
292pub fn raw_data<'a>(bytes: &'a [u8], e: &Entry) -> Option<&'a [u8]> {
293    bytes.get(e.data_offset..e.data_offset + e.compressed_size as usize)
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299
300    #[test]
301    fn the_crc_check_value() {
302        assert_eq!(crc32(b"123456789"), 0xCBF4_3926);
303        assert_eq!(crc32(b""), 0);
304    }
305
306    fn two_parts() -> Vec<(String, Vec<u8>)> {
307        vec![
308            ("[Content_Types].xml".to_string(), b"<Types/>".to_vec()),
309            ("xl/workbook.xml".to_string(), b"<workbook/>".to_vec()),
310        ]
311    }
312
313    #[test]
314    fn the_archive_walks_back_through_its_own_directory() {
315        let bytes = write_stored(&two_parts()).expect("two small parts fit");
316        assert_eq!(&bytes[..4], &[0x50, 0x4B, 0x03, 0x04]);
317        let list = entries(&bytes).expect("a central directory");
318        assert_eq!(list.len(), 2);
319        assert_eq!(list[0].name, "[Content_Types].xml");
320        assert_eq!(list[1].name, "xl/workbook.xml");
321        for (e, (_, data)) in list.iter().zip(two_parts().iter()) {
322            assert_eq!(e.method, STORED);
323            assert_eq!((e.time, e.date), (EPOCH_TIME, EPOCH_DATE));
324            assert_eq!(e.size, data.len() as u32);
325            assert_eq!(e.compressed_size, data.len() as u32);
326            assert_eq!(e.crc, crc32(data));
327            assert_eq!(stored_data(&bytes, e).unwrap(), data.as_slice());
328        }
329    }
330
331    #[test]
332    fn the_same_parts_give_the_same_bytes() {
333        assert_eq!(write_stored(&two_parts()), write_stored(&two_parts()));
334    }
335
336    #[test]
337    fn an_entry_copies_as_it_is_and_a_comment_or_zip64_is_told_apart() {
338        // A "deflated" entry carried as given: the bytes are not decoded,
339        // the method, CRC and size are what the source said.
340        let raw = RawEntry {
341            name: "xl/styles.xml",
342            method: 8,
343            crc: 0x1234_5678,
344            size: 999,
345            bytes: &[0x4b, 0x4c, 0x4a, 0x06, 0x00],
346        };
347        let own = RawEntry {
348            name: "own.xml",
349            method: STORED,
350            crc: crc32(b"<a/>"),
351            size: 4,
352            bytes: b"<a/>",
353        };
354        let bytes = write_entries(&[raw, own]).unwrap();
355        let list = entries(&bytes).unwrap();
356        assert_eq!(list[0].method, 8);
357        assert_eq!(list[0].crc, 0x1234_5678);
358        assert_eq!(list[0].size, 999);
359        assert_eq!(list[0].compressed_size, 5);
360        assert_eq!(
361            raw_data(&bytes, &list[0]).unwrap(),
362            &[0x4b, 0x4c, 0x4a, 0x06, 0x00]
363        );
364        assert_eq!(stored_data(&bytes, &list[0]), None);
365        assert_eq!(stored_data(&bytes, &list[1]).unwrap(), b"<a/>");
366        assert_eq!(why_not_an_archive(&bytes), None);
367        // An archive comment after the record is allowed.
368        let mut commented = bytes.clone();
369        let n = commented.len();
370        commented[n - 2] = 5;
371        commented.extend_from_slice(b"hello");
372        assert_eq!(entries(&commented).unwrap().len(), 2);
373        // What is not an archive is named.
374        assert_eq!(
375            why_not_an_archive(b"not a zip"),
376            Some("it has no end-of-central-directory record, so it is not a zip archive")
377        );
378        assert_eq!(
379            why_not_an_archive(&[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]),
380            Some("it is an older .xls or an encrypted workbook, not an .xlsx package")
381        );
382        let mut zip64 = bytes.clone();
383        let n = zip64.len();
384        zip64[n - 12] = 0xFF;
385        zip64[n - 11] = 0xFF;
386        zip64[n - 10] = 0xFF;
387        zip64[n - 9] = 0xFF;
388        assert_eq!(
389            why_not_an_archive(&zip64),
390            Some("it is a zip64 archive, which this version does not read")
391        );
392        assert!(entries(&zip64).is_none());
393    }
394
395    #[test]
396    fn what_does_not_fit_is_refused_not_truncated() {
397        let many: Vec<(String, Vec<u8>)> =
398            (0..65_536).map(|_| ("a".to_string(), Vec::new())).collect();
399        assert!(write_stored(&many).is_none());
400        assert!(entries(b"not a zip at all, not even twenty-two").is_none());
401        assert!(entries(b"").is_none());
402    }
403}