Skip to main content

arc_mac/
maczip.rs

1// SPDX-License-Identifier: MIT
2//! MacZip — Info-ZIP's Macintosh port (D. Haase), the "new" `Mac3` format.
3//!
4//! MacZip writes a standard PKZIP archive where each Mac file becomes a normal
5//! ZIP entry (the data fork) plus a second entry for the resource fork, stored
6//! under a `XtraStuf.mac/` directory component (the `ResourceMark`,
7//! `"XtraStuf.mac:"` on a real Mac). Per-file Finder metadata rides in a
8//! `Mac3` (`0x334D`) extra field present on both the local and central header:
9//!
10//! ```text
11//! Mac3 extra block (id 'M','3' = 0x334D, little-endian):
12//!   +0  u16  TSize        block data size = 14 + compsize (local) / 14 (central)
13//!   --- block data (TSize bytes) ---
14//!   +0  u32  attrsize     uncompressed size of the attribute blob
15//!   +4  u16  info flags   EB_M3_FL_* (bit0 DATFRK, bit2 UNCMPR, ...)
16//!   +6  u32  fdType       4 chars, native (big-endian) order
17//!   +10 u32  fdCreator    4 chars
18//!   +14 ..   attribute blob (LOCAL header only; absent in central)
19//! ```
20//!
21//! Type/creator live uncompressed in the fixed part. The attribute blob (only
22//! in the local header) carries fdFlags + dates + folder settings + comment,
23//! and is itself `memcompress`ed unless the `UNCMPR` (0x04) bit is set:
24//! `[u16 method][u32 crc][raw-deflate stream]`. Within the decompressed blob,
25//! `fdFlags` is the first `u16` and the create/modify dates are at +26/+30
26//! (Mac 1904-epoch seconds), matching the StuffIt/MacBinary date convention.
27//!
28//! Detection is content-driven: a plain disk-image-in-a-zip carries no `Mac3`
29//! field, so [`is_maczip`] returns false for it (see the `.zip` overload note
30//! in `docs/maczip_plus_fixes.md`).
31//!
32//! Format reference: the MacZip 1.06 source (`zip/macos/source/extrafld.c`
33//! writer + `unzip/macos/source/macos.c` reader).
34
35use std::io::Read;
36
37use anyhow::{bail, Context, Result};
38
39use super::stuffit::{crc16_arc, ForkCodec, ForkInfo, StuffItArchive, StuffItEntry};
40
41/// The path component MacZip prepends to a resource fork's entry name
42/// (`ResourceMark` is `"XtraStuf.mac:"`; in ZIP paths the separator is `/`).
43const RESOURCE_MARK: &str = "XtraStuf.mac";
44
45/// Info-ZIP `Mac3` extra-field header id (`'M','3'`, little-endian).
46const EB_ID_MAC3: u16 = 0x334D;
47/// Johnny Lee's old Macintosh extra-field id (`0x07c8`).
48const EB_ID_JLEE: u16 = 0x07C8;
49/// Attribute blob is stored uncompressed (`EB_M3_FL_UNCMPR`).
50const EB_M3_FL_UNCMPR: u16 = 0x04;
51/// Fixed part of a `Mac3` block before the (local-only) attribute blob.
52const MAC3_FIXED_LEN: usize = 14;
53
54// --- little-endian readers (bounds-checked) --------------------------------
55
56fn le16(b: &[u8], off: usize) -> Option<u16> {
57    b.get(off..off + 2)
58        .map(|s| u16::from_le_bytes([s[0], s[1]]))
59}
60
61fn le32(b: &[u8], off: usize) -> Option<u32> {
62    b.get(off..off + 4)
63        .map(|s| u32::from_le_bytes([s[0], s[1], s[2], s[3]]))
64}
65
66/// One central-directory record (no file data, no inflation).
67struct CentralEntry {
68    name: String,
69    method: u16,
70    comp_size: u32,
71    uncomp_size: u32,
72    local_offset: u32,
73    /// Central-header extra field (carries the `Mac3` fixed part; no blob).
74    extra: Vec<u8>,
75    is_dir: bool,
76}
77
78/// Find the End Of Central Directory record and return `(cd_offset, count)`.
79fn find_eocd(bytes: &[u8]) -> Option<(usize, usize)> {
80    const EOCD_SIG: &[u8; 4] = b"PK\x05\x06";
81    if bytes.len() < 22 {
82        return None;
83    }
84    // The EOCD is within the last 22 + 65535 bytes (max comment length).
85    let scan_start = bytes.len().saturating_sub(22 + 0xFFFF);
86    let mut i = bytes.len() - 22;
87    loop {
88        if &bytes[i..i + 4] == EOCD_SIG {
89            let count = le16(bytes, i + 10)? as usize;
90            let cd_offset = le32(bytes, i + 16)? as usize;
91            if cd_offset <= bytes.len() {
92                return Some((cd_offset, count));
93            }
94        }
95        if i == scan_start {
96            return None;
97        }
98        i -= 1;
99    }
100}
101
102/// Walk the central directory into a list of records. Errors if `bytes` is not
103/// a well-formed ZIP.
104fn read_central_directory(bytes: &[u8]) -> Result<Vec<CentralEntry>> {
105    let (cd_offset, count) = find_eocd(bytes).context("not a ZIP archive (no EOCD record)")?;
106    const CEN_SIG: &[u8; 4] = b"PK\x01\x02";
107    let mut entries = Vec::with_capacity(count.min(4096));
108    let mut p = cd_offset;
109    while p + 46 <= bytes.len() && &bytes[p..p + 4] == CEN_SIG {
110        let method = le16(bytes, p + 10).context("central header truncated")?;
111        let comp_size = le32(bytes, p + 20).context("central header truncated")?;
112        let uncomp_size = le32(bytes, p + 24).context("central header truncated")?;
113        let name_len = le16(bytes, p + 28).context("central header truncated")? as usize;
114        let extra_len = le16(bytes, p + 30).context("central header truncated")? as usize;
115        let comment_len = le16(bytes, p + 32).context("central header truncated")? as usize;
116        let local_offset = le32(bytes, p + 42).context("central header truncated")?;
117        let name_start = p + 46;
118        let extra_start = name_start + name_len;
119        let comment_start = extra_start + extra_len;
120        let next = comment_start + comment_len;
121        if next > bytes.len() {
122            bail!("central directory entry extends past end of file");
123        }
124        let name_raw = &bytes[name_start..extra_start];
125        let name = decode_zip_name(name_raw);
126        let extra = bytes[extra_start..comment_start].to_vec();
127        let is_dir = name.ends_with('/');
128        entries.push(CentralEntry {
129            name,
130            method,
131            comp_size,
132            uncomp_size,
133            local_offset,
134            extra,
135            is_dir,
136        });
137        p = next;
138    }
139    Ok(entries)
140}
141
142/// Decode a ZIP entry name. MacZip stores Mac Roman → ISO-8859-1 in the public
143/// name; we accept UTF-8 when valid (covers ASCII and modern writers) and fall
144/// back to Latin-1 (lossless byte→char) otherwise.
145fn decode_zip_name(raw: &[u8]) -> String {
146    match std::str::from_utf8(raw) {
147        Ok(s) => s.to_string(),
148        Err(_) => raw.iter().map(|&b| b as char).collect(),
149    }
150}
151
152/// Parsed `Mac3` extra-field contents for one entry.
153struct Mac3 {
154    info_flags: u16,
155    type_code: [u8; 4],
156    creator_code: [u8; 4],
157    /// Attribute blob (present in local headers, empty in central headers).
158    blob: Vec<u8>,
159}
160
161/// Find and parse a `Mac3` (`0x334D`) extra block within an extra-field buffer.
162fn find_mac3(extra: &[u8]) -> Option<Mac3> {
163    let mut i = 0usize;
164    while i + 4 <= extra.len() {
165        let id = u16::from_le_bytes([extra[i], extra[i + 1]]);
166        let sz = u16::from_le_bytes([extra[i + 2], extra[i + 3]]) as usize;
167        let data_start = i + 4;
168        let data_end = data_start.checked_add(sz)?;
169        if data_end > extra.len() {
170            break;
171        }
172        if id == EB_ID_MAC3 && sz >= MAC3_FIXED_LEN {
173            let data = &extra[data_start..data_end];
174            let info_flags = u16::from_le_bytes([data[4], data[5]]);
175            let mut type_code = [0u8; 4];
176            type_code.copy_from_slice(&data[6..10]);
177            let mut creator_code = [0u8; 4];
178            creator_code.copy_from_slice(&data[10..14]);
179            return Some(Mac3 {
180                info_flags,
181                type_code,
182                creator_code,
183                blob: data[MAC3_FIXED_LEN..].to_vec(),
184            });
185        }
186        i = data_end;
187    }
188    None
189}
190
191/// Johnny Lee's old Macintosh extra field (`0x07c8`): `'JLEE'` then
192/// `fdType(4) fdCreator(4) fdFlags(2 BE)`. Best-effort fallback for archives
193/// from Info-ZIP's first Mac port.
194fn find_jlee(extra: &[u8]) -> Option<([u8; 4], [u8; 4], u16)> {
195    let mut i = 0usize;
196    while i + 4 <= extra.len() {
197        let id = u16::from_le_bytes([extra[i], extra[i + 1]]);
198        let sz = u16::from_le_bytes([extra[i + 2], extra[i + 3]]) as usize;
199        let data_start = i + 4;
200        let data_end = data_start.checked_add(sz)?;
201        if data_end > extra.len() {
202            break;
203        }
204        if id == EB_ID_JLEE && sz >= 14 && &extra[data_start..data_start + 4] == b"JLEE" {
205            let data = &extra[data_start..data_end];
206            let mut type_code = [0u8; 4];
207            type_code.copy_from_slice(&data[4..8]);
208            let mut creator_code = [0u8; 4];
209            creator_code.copy_from_slice(&data[8..12]);
210            let flags = u16::from_be_bytes([data[12], data[13]]);
211            return Some((type_code, creator_code, flags));
212        }
213        i = data_end;
214    }
215    None
216}
217
218/// Decompress a `Mac3` attribute blob to its raw form. When the `UNCMPR` bit is
219/// set the blob is already raw; otherwise it's Info-ZIP `memcompress` output:
220/// `[u16 method][u32 crc][stream]` (method 8 = raw deflate, 0 = stored).
221fn decode_mac3_blob(blob: &[u8], info_flags: u16) -> Option<Vec<u8>> {
222    if info_flags & EB_M3_FL_UNCMPR != 0 {
223        return Some(blob.to_vec());
224    }
225    if blob.len() < 6 {
226        return None;
227    }
228    let method = u16::from_le_bytes([blob[0], blob[1]]);
229    let stream = &blob[6..]; // skip method(2) + crc(4)
230    match method {
231        0 => Some(stream.to_vec()),
232        8 => inflate(stream).ok(),
233        _ => None,
234    }
235}
236
237/// Raw-deflate inflate (ZIP method 8 — no zlib/gzip wrapper).
238fn inflate(stream: &[u8]) -> Result<Vec<u8>> {
239    let mut out = Vec::new();
240    flate2::read::DeflateDecoder::new(stream)
241        .read_to_end(&mut out)
242        .context("inflating deflate stream")?;
243    Ok(out)
244}
245
246/// Finder metadata pulled from a `Mac3` blob: fdFlags + create/modify dates.
247struct Mac3Meta {
248    finder_flags: u16,
249    create_date: u32,
250    mod_date: u32,
251}
252
253/// Read the fixed fields we surface from a decompressed `Mac3` attribute blob:
254/// `fdFlags` at +0, `ioFlCrDat` at +26, `ioFlMdDat` at +30 (all little-endian).
255fn mac3_meta(blob: &[u8]) -> Mac3Meta {
256    Mac3Meta {
257        finder_flags: le16(blob, 0).unwrap_or(0),
258        create_date: le32(blob, 26).unwrap_or(0),
259        mod_date: le32(blob, 30).unwrap_or(0),
260    }
261}
262
263/// Quick content check: is this a MacZip archive (vs. a plain disk-image zip)?
264/// True iff it's a ZIP whose central directory carries at least one `Mac3` (or
265/// old `JLEE`) extra field — the signal a plain zip never has.
266pub fn is_maczip(bytes: &[u8]) -> bool {
267    // Cheap gate: a ZIP starts with a local-file-header or (empty) EOCD magic.
268    if !(bytes.starts_with(b"PK\x03\x04") || bytes.starts_with(b"PK\x05\x06")) {
269        return false;
270    }
271    match read_central_directory(bytes) {
272        Ok(entries) => entries
273            .iter()
274            .any(|e| find_mac3(&e.extra).is_some() || find_jlee(&e.extra).is_some()),
275        Err(_) => false,
276    }
277}
278
279/// Per-file accumulator while pairing data + resource forks.
280struct Merged {
281    components: Vec<String>,
282    name: String,
283    is_dir: bool,
284    type_code: [u8; 4],
285    creator_code: [u8; 4],
286    finder_flags: u16,
287    create_date: u32,
288    mod_date: u32,
289    data: Option<Vec<u8>>,
290    rsrc: Option<Vec<u8>>,
291    /// True once metadata has been taken from the data-fork entry (which wins
292    /// over the resource-fork entry's copy if both carry a `Mac3`).
293    meta_from_data: bool,
294}
295
296/// Read and inflate one entry's fork bytes using the central-directory sizes
297/// (authoritative even when the local header zeroes them for a data
298/// descriptor), plus the local-header extra field (which carries the `Mac3`
299/// attribute blob).
300fn read_entry_payload<'a>(bytes: &'a [u8], e: &CentralEntry) -> Result<(Vec<u8>, &'a [u8])> {
301    let lo = e.local_offset as usize;
302    if lo + 30 > bytes.len() || &bytes[lo..lo + 4] != b"PK\x03\x04" {
303        bail!("bad local header for {}", e.name);
304    }
305    let name_len = le16(bytes, lo + 26).context("local header truncated")? as usize;
306    let extra_len = le16(bytes, lo + 28).context("local header truncated")? as usize;
307    let extra_start = lo + 30 + name_len;
308    let data_start = extra_start + extra_len;
309    let data_end = data_start
310        .checked_add(e.comp_size as usize)
311        .context("fork length overflow")?;
312    if data_end > bytes.len() || extra_start > bytes.len() {
313        bail!("fork data extends past end of file for {}", e.name);
314    }
315    let local_extra = &bytes[extra_start..data_start];
316    let raw = &bytes[data_start..data_end];
317    let data = match e.method {
318        0 => raw.to_vec(),
319        8 => inflate(raw).with_context(|| format!("inflating {}", e.name))?,
320        m => bail!("unsupported ZIP compression method {m} for {}", e.name),
321    };
322    if data.len() as u32 != e.uncomp_size {
323        bail!(
324            "size mismatch for {} ({} != {})",
325            e.name,
326            data.len(),
327            e.uncomp_size
328        );
329    }
330    Ok((data, local_extra))
331}
332
333/// Parse a MacZip archive into the family's [`StuffItArchive`] model. Returns
334/// the decompressed-fork buffer (entries reference it with method-0/store
335/// `ForkInfo`) plus the parsed directory, so the whole extract/browse path is
336/// reused unchanged.
337pub fn parse(bytes: &[u8]) -> Result<(Vec<u8>, StuffItArchive)> {
338    let entries = read_central_directory(bytes)?;
339
340    let mut merged: Vec<Merged> = Vec::new();
341    let mut index: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
342
343    for e in &entries {
344        let comps: Vec<String> = e
345            .name
346            .split('/')
347            .filter(|c| !c.is_empty())
348            .map(|c| c.to_string())
349            .collect();
350        if comps.is_empty() {
351            continue;
352        }
353        let is_rsrc = comps.iter().any(|c| c == RESOURCE_MARK);
354        let logical: Vec<String> = comps.into_iter().filter(|c| c != RESOURCE_MARK).collect();
355        if logical.is_empty() {
356            continue; // the bare "XtraStuf.mac/" container directory
357        }
358        let key = logical.join("/");
359
360        if e.is_dir {
361            // Emit a folder marker only for real directories (skip the
362            // resource-fork container dirs, already filtered above).
363            if is_rsrc {
364                continue;
365            }
366            index.entry(key.clone()).or_insert_with(|| {
367                merged.push(Merged {
368                    name: logical.last().cloned().unwrap_or_default(),
369                    components: logical.clone(),
370                    is_dir: true,
371                    type_code: [0; 4],
372                    creator_code: [0; 4],
373                    finder_flags: 0,
374                    create_date: 0,
375                    mod_date: 0,
376                    data: None,
377                    rsrc: None,
378                    meta_from_data: false,
379                });
380                merged.len() - 1
381            });
382            continue;
383        }
384
385        let (payload, local_extra) = match read_entry_payload(bytes, e) {
386            Ok(v) => v,
387            Err(_) => continue, // skip an unreadable fork; keep the rest
388        };
389        let mac3 = find_mac3(local_extra);
390        let jlee = mac3.is_none().then(|| find_jlee(local_extra)).flatten();
391
392        let idx = *index.entry(key.clone()).or_insert_with(|| {
393            merged.push(Merged {
394                name: logical.last().cloned().unwrap_or_default(),
395                components: logical.clone(),
396                is_dir: false,
397                type_code: [0; 4],
398                creator_code: [0; 4],
399                finder_flags: 0,
400                create_date: 0,
401                mod_date: 0,
402                data: None,
403                rsrc: None,
404                meta_from_data: false,
405            });
406            merged.len() - 1
407        });
408        let m = &mut merged[idx];
409
410        // Apply Finder metadata. The data fork wins if both forks carry it.
411        if !m.meta_from_data {
412            if let Some(ref info) = mac3 {
413                m.type_code = info.type_code;
414                m.creator_code = info.creator_code;
415                if let Some(blob) = decode_mac3_blob(&info.blob, info.info_flags) {
416                    let meta = mac3_meta(&blob);
417                    m.finder_flags = meta.finder_flags;
418                    m.create_date = meta.create_date;
419                    m.mod_date = meta.mod_date;
420                }
421                if !is_rsrc {
422                    m.meta_from_data = true;
423                }
424            } else if let Some((tc, cc, flags)) = jlee {
425                m.type_code = tc;
426                m.creator_code = cc;
427                m.finder_flags = flags;
428                if !is_rsrc {
429                    m.meta_from_data = true;
430                }
431            }
432        }
433
434        if is_rsrc {
435            m.rsrc = Some(payload);
436        } else {
437            m.data = Some(payload);
438        }
439    }
440
441    // Lay every fork into one buffer; entries reference it with method-0
442    // (store) ForkInfo so stuffit::decompress_fork returns them verbatim.
443    let mut buf: Vec<u8> = Vec::new();
444    let mut out_entries: Vec<StuffItEntry> = Vec::with_capacity(merged.len());
445    for m in merged {
446        if m.is_dir {
447            out_entries.push(StuffItEntry {
448                path: m.components,
449                name: m.name,
450                is_dir: true,
451                type_code: [0; 4],
452                creator_code: [0; 4],
453                finder_flags: 0,
454                create_date: 0,
455                mod_date: 0,
456                data: None,
457                rsrc: None,
458            });
459            continue;
460        }
461        let data = m.data.unwrap_or_default();
462        let rsrc = m.rsrc.unwrap_or_default();
463        let data_off = buf.len() as u64;
464        buf.extend_from_slice(&data);
465        let rsrc_off = buf.len() as u64;
466        buf.extend_from_slice(&rsrc);
467        out_entries.push(StuffItEntry {
468            path: m.components,
469            name: m.name,
470            is_dir: false,
471            type_code: m.type_code,
472            creator_code: m.creator_code,
473            finder_flags: m.finder_flags,
474            create_date: m.create_date,
475            mod_date: m.mod_date,
476            data: Some(store_fork(&data, data_off, crc16_arc(&data))),
477            rsrc: (!rsrc.is_empty()).then(|| store_fork(&rsrc, rsrc_off, crc16_arc(&rsrc))),
478        });
479    }
480
481    if out_entries.iter().all(|e| e.is_dir) {
482        bail!("ZIP carries no MacZip file entries");
483    }
484
485    Ok((
486        buf,
487        StuffItArchive {
488            entries: out_entries,
489            truncated: None,
490        },
491    ))
492}
493
494/// A method-0 (store) [`ForkInfo`] pointing at already-decompressed bytes.
495fn store_fork(data: &[u8], offset: u64, crc: u16) -> ForkInfo {
496    ForkInfo {
497        method: 0,
498        codec: ForkCodec::StuffIt,
499        encrypted: false,
500        uncompressed_len: data.len() as u32,
501        compressed_len: data.len() as u32,
502        crc,
503        crc32: 0,
504        offset,
505    }
506}
507
508#[cfg(test)]
509mod tests {
510    use super::*;
511    use crate::stuffit::decompress_fork;
512
513    /// Build a one-entry ZIP (stored) with an optional Mac3 extra field on both
514    /// headers, used to exercise the parser without the `zip` crate's writer.
515    struct ZipBuilder {
516        local: Vec<u8>,
517        central: Vec<u8>,
518        count: u16,
519    }
520    impl ZipBuilder {
521        fn new() -> Self {
522            Self {
523                local: Vec::new(),
524                central: Vec::new(),
525                count: 0,
526            }
527        }
528        /// Add a stored entry. `extra_local` is the local-header extra field
529        /// (carrying the Mac3 blob); `extra_central` the central one.
530        fn add(&mut self, name: &str, data: &[u8], extra_local: &[u8], extra_central: &[u8]) {
531            let crc = crc32(data);
532            let off = self.local.len() as u32;
533            // Local file header.
534            self.local.extend_from_slice(b"PK\x03\x04");
535            self.local.extend_from_slice(&[20, 0]); // version needed
536            self.local.extend_from_slice(&[0, 0]); // flags
537            self.local.extend_from_slice(&[0, 0]); // method 0 (store)
538            self.local.extend_from_slice(&[0, 0, 0, 0]); // time/date
539            self.local.extend_from_slice(&crc.to_le_bytes());
540            self.local
541                .extend_from_slice(&(data.len() as u32).to_le_bytes()); // comp
542            self.local
543                .extend_from_slice(&(data.len() as u32).to_le_bytes()); // uncomp
544            self.local
545                .extend_from_slice(&(name.len() as u16).to_le_bytes());
546            self.local
547                .extend_from_slice(&(extra_local.len() as u16).to_le_bytes());
548            self.local.extend_from_slice(name.as_bytes());
549            self.local.extend_from_slice(extra_local);
550            self.local.extend_from_slice(data);
551            // Central directory header.
552            self.central.extend_from_slice(b"PK\x01\x02");
553            self.central.extend_from_slice(&[20, 0, 20, 0]); // versions
554            self.central.extend_from_slice(&[0, 0]); // flags
555            self.central.extend_from_slice(&[0, 0]); // method
556            self.central.extend_from_slice(&[0, 0, 0, 0]); // time/date
557            self.central.extend_from_slice(&crc.to_le_bytes());
558            self.central
559                .extend_from_slice(&(data.len() as u32).to_le_bytes());
560            self.central
561                .extend_from_slice(&(data.len() as u32).to_le_bytes());
562            self.central
563                .extend_from_slice(&(name.len() as u16).to_le_bytes());
564            self.central
565                .extend_from_slice(&(extra_central.len() as u16).to_le_bytes());
566            self.central.extend_from_slice(&[0, 0]); // comment len
567            self.central.extend_from_slice(&[0, 0]); // disk start
568            self.central.extend_from_slice(&[0, 0]); // internal attrs
569            self.central.extend_from_slice(&[0, 0, 0, 0]); // external attrs
570            self.central.extend_from_slice(&off.to_le_bytes());
571            self.central.extend_from_slice(name.as_bytes());
572            self.central.extend_from_slice(extra_central);
573            self.count += 1;
574        }
575        fn finish(self) -> Vec<u8> {
576            let mut out = self.local;
577            let cd_off = out.len() as u32;
578            out.extend_from_slice(&self.central);
579            let cd_size = out.len() as u32 - cd_off;
580            out.extend_from_slice(b"PK\x05\x06");
581            out.extend_from_slice(&[0, 0, 0, 0]); // disk numbers
582            out.extend_from_slice(&self.count.to_le_bytes());
583            out.extend_from_slice(&self.count.to_le_bytes());
584            out.extend_from_slice(&cd_size.to_le_bytes());
585            out.extend_from_slice(&cd_off.to_le_bytes());
586            out.extend_from_slice(&[0, 0]); // comment len
587            out
588        }
589    }
590
591    fn crc32(data: &[u8]) -> u32 {
592        // Minimal CRC-32 (IEEE) for the test ZIP headers.
593        let mut crc = 0xFFFF_FFFFu32;
594        for &b in data {
595            crc ^= b as u32;
596            for _ in 0..8 {
597                crc = if crc & 1 != 0 {
598                    (crc >> 1) ^ 0xEDB8_8320
599                } else {
600                    crc >> 1
601                };
602            }
603        }
604        !crc
605    }
606
607    /// Build a Mac3 extra field. `blob` (uncompressed, UNCMPR set) is included
608    /// only when `local` is true (central headers carry just the fixed part).
609    fn mac3_field(type_code: &[u8; 4], creator: &[u8; 4], blob: &[u8], local: bool) -> Vec<u8> {
610        let mut data = Vec::new();
611        let attrsize = if local { blob.len() as u32 } else { 0 };
612        data.extend_from_slice(&attrsize.to_le_bytes()); // attrsize
613        data.extend_from_slice(&EB_M3_FL_UNCMPR.to_le_bytes()); // info flags: uncompressed
614        data.extend_from_slice(type_code);
615        data.extend_from_slice(creator);
616        if local {
617            data.extend_from_slice(blob);
618        }
619        let mut out = Vec::new();
620        out.extend_from_slice(&EB_ID_MAC3.to_le_bytes());
621        out.extend_from_slice(&(data.len() as u16).to_le_bytes());
622        out.extend_from_slice(&data);
623        out
624    }
625
626    /// A Mac3 attribute blob with fdFlags + create/modify dates at the fixed
627    /// offsets the reader pulls (others zero).
628    fn mac3_blob(fd_flags: u16, create: u32, modify: u32) -> Vec<u8> {
629        let mut b = vec![0u8; 40];
630        b[0..2].copy_from_slice(&fd_flags.to_le_bytes());
631        b[26..30].copy_from_slice(&create.to_le_bytes());
632        b[30..34].copy_from_slice(&modify.to_le_bytes());
633        b
634    }
635
636    #[test]
637    fn pairs_forks_and_reads_finder_metadata() {
638        let blob = mac3_blob(0x2000, 0x1111_1111, 0x2222_2222);
639        let mut zb = ZipBuilder::new();
640        // Data fork.
641        zb.add(
642            "Apps/Game",
643            b"DATA-FORK",
644            &mac3_field(b"APPL", b"Po.P", &blob, true),
645            &mac3_field(b"APPL", b"Po.P", &[], false),
646        );
647        // Resource fork under XtraStuf.mac/.
648        zb.add(
649            "Apps/XtraStuf.mac/Game",
650            b"RSRC-FORK",
651            &mac3_field(b"APPL", b"Po.P", &[], true),
652            &mac3_field(b"APPL", b"Po.P", &[], false),
653        );
654        let zip = zb.finish();
655
656        assert!(is_maczip(&zip), "should detect MacZip via Mac3 field");
657        let (buf, archive) = parse(&zip).expect("parse MacZip");
658        // One merged file entry (forks paired), no separate XtraStuf entry.
659        let files: Vec<_> = archive.entries.iter().filter(|e| !e.is_dir).collect();
660        assert_eq!(files.len(), 1, "data + rsrc forks must merge to one entry");
661        let f = files[0];
662        assert_eq!(f.name, "Game");
663        assert_eq!(f.path, vec!["Apps".to_string(), "Game".to_string()]);
664        assert_eq!(&f.type_code, b"APPL");
665        assert_eq!(&f.creator_code, b"Po.P");
666        assert_eq!(f.finder_flags, 0x2000, "hasBundle from Mac3 blob");
667        assert_eq!(f.create_date, 0x1111_1111);
668        assert_eq!(f.mod_date, 0x2222_2222);
669        let data = decompress_fork(&buf, f.data.as_ref().unwrap()).unwrap();
670        assert_eq!(data, b"DATA-FORK");
671        let rsrc = decompress_fork(&buf, f.rsrc.as_ref().unwrap()).unwrap();
672        assert_eq!(rsrc, b"RSRC-FORK");
673    }
674
675    #[test]
676    fn plain_disk_image_zip_is_not_maczip() {
677        // A normal zip (no Mac3 extra field) must NOT be classified as MacZip,
678        // so a raw disk image inside a .zip stays a disk image.
679        let mut zb = ZipBuilder::new();
680        zb.add("backup.img", b"\x00\x01\x02 not a mac file", &[], &[]);
681        let zip = zb.finish();
682        assert!(!is_maczip(&zip), "plain disk-image zip must not be MacZip");
683    }
684
685    #[test]
686    fn deflated_mac3_blob_round_trips() {
687        // memcompress-style blob: [method=8][crc][raw deflate].
688        use flate2::{write::DeflateEncoder, Compression};
689        use std::io::Write;
690        let raw_blob = mac3_blob(0x0400, 7, 9); // hasCustomIcon
691        let mut enc = DeflateEncoder::new(Vec::new(), Compression::default());
692        enc.write_all(&raw_blob).unwrap();
693        let deflated = enc.finish().unwrap();
694        let mut packed = Vec::new();
695        packed.extend_from_slice(&8u16.to_le_bytes()); // method 8
696        packed.extend_from_slice(&0u32.to_le_bytes()); // crc (unchecked)
697        packed.extend_from_slice(&deflated);
698
699        let decoded = decode_mac3_blob(&packed, 0).expect("inflate memcompress blob");
700        let meta = mac3_meta(&decoded);
701        assert_eq!(meta.finder_flags, 0x0400);
702        assert_eq!(meta.create_date, 7);
703        assert_eq!(meta.mod_date, 9);
704    }
705}