Skip to main content

arc_mac/
macbinary.rs

1// SPDX-License-Identifier: MIT
2//! MacBinary I / II / III — the canonical full-fidelity parser for the family.
3//!
4//! MacBinary is a *transport wrapper*: it serializes exactly one Mac file's
5//! data fork + resource fork + Finder metadata into a single flat stream. It
6//! is not a multi-file archive, so it joins the `macarchive` family as a
7//! single-entry [`StuffItArchive`](super::stuffit::StuffItArchive) (see
8//! [`super::extract::open_bytes`]).
9//!
10//! This module replaces the full-fidelity parser that used to live buried in
11//! the `put-macbinary` CLI verb. The lean `resource_fork::parse_macbinary`
12//! remains a separate, deliberately conservative host-sidecar *import*
13//! detector (it returns an `ImportedResourceFork` and only accepts
14//! CRC-validated II/III with a non-empty resource fork). The two have distinct
15//! roles; this one is the format reader.
16//!
17//! Format reference: `docs/native_mac_archives.md` §2 (embedded spec).
18//!
19//! ## Two entry points
20//! - [`is_macbinary`] — the confidence-scored §2.4 detection heuristic, used by
21//!   the content classifier. Strict on purpose: MacBinary I has no magic and
22//!   lazy II writers zero the CRC, so a false positive would hijack an
23//!   unrelated `.bin`.
24//! - [`parse`] — lenient structural parse for a buffer already believed to be
25//!   MacBinary (e.g. a user-supplied `put-macbinary` input). Does not require a
26//!   valid CRC, and **accepts an empty resource fork** (data-fork-only Mac
27//!   files — a wrapped disk image, a text file — are legitimate MacBinary; the
28//!   old `resource_fork` parser wrongly rejected them).
29
30use anyhow::{bail, Result};
31use byteorder::{BigEndian, ByteOrder};
32
33use fs_mac::resource_fork::macbinary_crc16;
34
35/// Which MacBinary revision a stream identifies as.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum MacBinaryVersion {
38    /// MacBinary I — no signature, no CRC. Identified only by heuristics.
39    One,
40    /// MacBinary II — version byte `$7A == 0x81`, CRC-16/XMODEM at `$7C`.
41    Two,
42    /// MacBinary III — `'mBIN'` at `$66` and version byte `$7A == 0x82`.
43    Three,
44}
45
46impl MacBinaryVersion {
47    /// Human-readable label ("MacBinary I" / "II" / "III"). Plain ASCII.
48    pub fn label(self) -> &'static str {
49        match self {
50            MacBinaryVersion::One => "MacBinary I",
51            MacBinaryVersion::Two => "MacBinary II",
52            MacBinaryVersion::Three => "MacBinary III",
53        }
54    }
55}
56
57/// One Mac file recovered from a MacBinary stream: both forks plus the full
58/// Finder metadata the header carries.
59#[derive(Debug, Clone)]
60pub struct MacBinaryFile {
61    /// Filename (Mac Roman → UTF-8).
62    pub filename: String,
63    /// 4-byte Finder type code (`fdType`); may be all-zero.
64    pub type_code: [u8; 4],
65    /// 4-byte Finder creator code (`fdCreator`); may be all-zero.
66    pub creator_code: [u8; 4],
67    /// Full 16-bit Finder flags: high byte from `$49`, low byte from `$65`
68    /// (II/III) or `$4A` (I).
69    pub finder_flags: u16,
70    /// `fdLocation` `(v, h)` — Pascal Point convention (vertical first).
71    pub finder_location: (i16, i16),
72    /// `fdFldr` — the window/folder ID the Finder displays the file in.
73    pub finder_folder: i16,
74    /// Creation date, Mac epoch (seconds since 1904-01-01, local time).
75    pub create_date: u32,
76    /// Modification date, Mac epoch.
77    pub modify_date: u32,
78    /// Which revision this stream identifies as.
79    pub version: MacBinaryVersion,
80    /// Data fork bytes (may be empty).
81    pub data_fork: Vec<u8>,
82    /// Resource fork bytes (may be empty).
83    pub resource_fork: Vec<u8>,
84}
85
86/// Round up to the next 128-byte boundary. MacBinary pads every section
87/// (secondary header, data fork, resource fork, comment).
88const fn pad128(n: usize) -> usize {
89    n.div_ceil(128) * 128
90}
91
92/// Maximum legal fork length per the standard (`0..=0x7FFFFF`).
93const MAX_FORK_LEN: u32 = 0x007F_FFFF;
94
95/// Label the version from the "version the writer targets" byte at `$7A`
96/// (`0x82` = III, `0x81` = II, else I). The `'mBIN'` signature at `$66` is an
97/// *additional* III marker used by [`is_macbinary`] for high-confidence
98/// detection, but our own `build_macbinary` (and some real writers) set `0x82`
99/// without it, so the version byte is the authoritative label.
100fn version_of(bytes: &[u8]) -> MacBinaryVersion {
101    match bytes[122] {
102        0x82 => MacBinaryVersion::Three,
103        0x81 => MacBinaryVersion::Two,
104        _ => MacBinaryVersion::One,
105    }
106}
107
108/// Combine the split Finder-flags bytes into the full 16-bit `fdFlags`.
109fn finder_flags_of(bytes: &[u8], version: MacBinaryVersion) -> u16 {
110    let hi = bytes[73] as u16;
111    let lo = match version {
112        // II/III moved the low byte to $65 and made $4A reserved.
113        MacBinaryVersion::Two | MacBinaryVersion::Three => bytes[101] as u16,
114        // MacBinary I kept fdFlags as a 2-byte field at $49..$4B.
115        MacBinaryVersion::One => bytes[74] as u16,
116    };
117    (hi << 8) | lo
118}
119
120/// Classify `bytes` as MacBinary using the confidence-scored §2.4 heuristic.
121/// Returns the identified version, or `None`.
122///
123/// The floor (all required): byte `$00`/`$52`/the 8 reserved at `$6C` are zero;
124/// filename length `1..=63` with printable Mac-Roman bytes; fork lengths each
125/// `<= 0x7FFFFF` with at least one nonzero; type/creator each four printable
126/// bytes or all-zero. Then escalate: `'mBIN'` → III (certain); valid CRC → II
127/// (certain); otherwise accept as I only when the padded size matches exactly
128/// (conservative, to avoid hijacking an unrelated `.bin`).
129pub fn is_macbinary(bytes: &[u8]) -> Option<MacBinaryVersion> {
130    if bytes.len() < 128 {
131        return None;
132    }
133    // --- Floor checks -----------------------------------------------------
134    // Reserved bytes that must be zero in every MacBinary revision.
135    if bytes[0] != 0 || bytes[82] != 0 || bytes[108..116].iter().any(|&b| b != 0) {
136        return None;
137    }
138    let name_len = bytes[1] as usize;
139    if name_len == 0 || name_len > 63 {
140        return None;
141    }
142    // Filename bytes must be printable Mac Roman (>= 0x20) and contain no
143    // colon (the classic-Mac path separator).
144    if bytes[2..2 + name_len]
145        .iter()
146        .any(|&b| b < 0x20 || b == b':')
147    {
148        return None;
149    }
150    let data_len = BigEndian::read_u32(&bytes[83..87]);
151    let rsrc_len = BigEndian::read_u32(&bytes[87..91]);
152    if data_len > MAX_FORK_LEN || rsrc_len > MAX_FORK_LEN || (data_len == 0 && rsrc_len == 0) {
153        return None;
154    }
155    if !fourcc_plausible(&bytes[65..69]) || !fourcc_plausible(&bytes[69..73]) {
156        return None;
157    }
158
159    // --- Confidence escalation -------------------------------------------
160    // Strong MacBinary III signature → certain.
161    if bytes[122] == 0x82 && &bytes[102..106] == b"mBIN" {
162        return Some(MacBinaryVersion::Three);
163    }
164    // CRC-validated → certain (a II, or a III whose writer set $7A=0x82 but
165    // omitted the 'mBIN' signature — our build_macbinary does exactly this).
166    let stored_crc = BigEndian::read_u16(&bytes[124..126]);
167    if stored_crc != 0 && stored_crc == macbinary_crc16(&bytes[0..124]) {
168        return Some(version_of(bytes));
169    }
170    // MacBinary I (or a CRC-lazy II): require an exact size match — the file is
171    // the header plus both 128-padded forks, allowing the final block to be
172    // left unpadded by some writers. Be conservative to avoid hijacking an
173    // unrelated `.bin`.
174    let min = 128usize + data_len as usize + rsrc_len as usize;
175    let max = 128usize + pad128(data_len as usize) + pad128(rsrc_len as usize);
176    if (min..=max).contains(&bytes.len()) {
177        Some(version_of(bytes))
178    } else {
179        None
180    }
181}
182
183/// A 4-byte Finder code is plausible if it's all-zero or every byte is a
184/// printable (`>= 0x20`) character.
185fn fourcc_plausible(b: &[u8]) -> bool {
186    b == [0u8; 4] || b.iter().all(|&c| c >= 0x20)
187}
188
189/// Parse a buffer believed to be MacBinary into a [`MacBinaryFile`]. Lenient:
190/// no CRC enforcement (some legacy II archives carry garbage there) and an
191/// empty resource fork is accepted. Use [`is_macbinary`] first for
192/// content-driven detection.
193pub fn parse(bytes: &[u8]) -> Result<MacBinaryFile> {
194    if bytes.len() < 128 {
195        bail!("MacBinary: file too short ({} bytes)", bytes.len());
196    }
197    // byte 0 ("old version") and byte 82 (reserved) must be 0 in all revisions.
198    if bytes[0] != 0 || bytes[82] != 0 {
199        bail!(
200            "MacBinary: header sanity bytes wrong (b0={:#x}, b82={:#x})",
201            bytes[0],
202            bytes[82]
203        );
204    }
205    let name_len = bytes[1] as usize;
206    if name_len == 0 || name_len > 63 {
207        bail!("MacBinary: bad filename length {name_len}");
208    }
209    let filename = fs_mac::hfs::decode_mac_filename(&bytes[2..2 + name_len]);
210
211    let version = version_of(bytes);
212
213    let mut type_code = [0u8; 4];
214    type_code.copy_from_slice(&bytes[65..69]);
215    let mut creator_code = [0u8; 4];
216    creator_code.copy_from_slice(&bytes[69..73]);
217
218    let finder_flags = finder_flags_of(bytes, version);
219    let finder_location = (
220        BigEndian::read_i16(&bytes[75..77]),
221        BigEndian::read_i16(&bytes[77..79]),
222    );
223    let finder_folder = BigEndian::read_i16(&bytes[79..81]);
224    let data_len = BigEndian::read_u32(&bytes[83..87]) as usize;
225    let rsrc_len = BigEndian::read_u32(&bytes[87..91]) as usize;
226    let create_date = BigEndian::read_u32(&bytes[91..95]);
227    let modify_date = BigEndian::read_u32(&bytes[95..99]);
228
229    // Sections follow the header, each padded to 128: [secondary header]
230    // [data fork] [resource fork] [optional comment].
231    let sec_hdr_len = BigEndian::read_u16(&bytes[120..122]) as usize;
232    let data_start = 128 + pad128(sec_hdr_len);
233    let data_end = data_start + data_len;
234    if data_end > bytes.len() {
235        bail!(
236            "MacBinary: data fork extends past file ({data_end} > {})",
237            bytes.len()
238        );
239    }
240    let data_fork = bytes[data_start..data_end].to_vec();
241
242    let rsrc_start = data_start + pad128(data_len);
243    let rsrc_end = rsrc_start + rsrc_len;
244    if rsrc_end > bytes.len() {
245        bail!(
246            "MacBinary: resource fork extends past file ({rsrc_end} > {})",
247            bytes.len()
248        );
249    }
250    let resource_fork = bytes[rsrc_start..rsrc_end].to_vec();
251
252    Ok(MacBinaryFile {
253        filename,
254        type_code,
255        creator_code,
256        finder_flags,
257        finder_location,
258        finder_folder,
259        create_date,
260        modify_date,
261        version,
262        data_fork,
263        resource_fork,
264    })
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270
271    /// Build a MacBinary II archive (valid CRC) for parser/detection tests.
272    fn build_mb2(
273        filename: &[u8],
274        type_code: [u8; 4],
275        creator: [u8; 4],
276        finder_flags: u16,
277        data: &[u8],
278        rsrc: &[u8],
279    ) -> Vec<u8> {
280        let mut hdr = [0u8; 128];
281        hdr[1] = filename.len() as u8;
282        hdr[2..2 + filename.len()].copy_from_slice(filename);
283        hdr[65..69].copy_from_slice(&type_code);
284        hdr[69..73].copy_from_slice(&creator);
285        hdr[73] = (finder_flags >> 8) as u8;
286        hdr[101] = (finder_flags & 0xFF) as u8;
287        BigEndian::write_i16(&mut hdr[75..77], 100); // fdLocation.v
288        BigEndian::write_i16(&mut hdr[77..79], 200); // fdLocation.h
289        BigEndian::write_i16(&mut hdr[79..81], -1); // fdFldr
290        BigEndian::write_u32(&mut hdr[83..87], data.len() as u32);
291        BigEndian::write_u32(&mut hdr[87..91], rsrc.len() as u32);
292        BigEndian::write_u32(&mut hdr[91..95], 0xAAAA_AAAA); // create
293        BigEndian::write_u32(&mut hdr[95..99], 0xBBBB_BBBB); // modify
294        hdr[122] = 129; // MacBinary II
295        hdr[123] = 129;
296        let crc = macbinary_crc16(&hdr[0..124]);
297        BigEndian::write_u16(&mut hdr[124..126], crc);
298
299        let mut out = hdr.to_vec();
300        out.extend_from_slice(data);
301        while out.len() % 128 != 0 {
302            out.push(0);
303        }
304        out.extend_from_slice(rsrc);
305        while out.len() % 128 != 0 {
306            out.push(0);
307        }
308        out
309    }
310
311    #[test]
312    fn parse_extracts_all_fields() {
313        let data = b"hello mac";
314        let rsrc = b"RSRC";
315        let mb = build_mb2(b"Greet", *b"TEXT", *b"ttxt", 0x2000, data, rsrc);
316        let f = parse(&mb).unwrap();
317        assert_eq!(f.filename, "Greet");
318        assert_eq!(&f.type_code, b"TEXT");
319        assert_eq!(&f.creator_code, b"ttxt");
320        assert_eq!(f.finder_flags, 0x2000);
321        assert_eq!(f.finder_location, (100, 200));
322        assert_eq!(f.finder_folder, -1);
323        assert_eq!(f.create_date, 0xAAAA_AAAA);
324        assert_eq!(f.modify_date, 0xBBBB_BBBB);
325        assert_eq!(f.version, MacBinaryVersion::Two);
326        assert_eq!(f.data_fork, data);
327        assert_eq!(f.resource_fork, rsrc);
328    }
329
330    #[test]
331    fn is_macbinary_accepts_crc_validated_ii() {
332        let mb = build_mb2(b"x", *b"TEXT", *b"ttxt", 0, b"data", b"rs");
333        assert_eq!(is_macbinary(&mb), Some(MacBinaryVersion::Two));
334    }
335
336    #[test]
337    fn is_macbinary_detects_iii_signature() {
338        let mut mb = build_mb2(b"x", *b"APPL", *b"Po.P", 0x2000, b"data", b"");
339        mb[122] = 0x82; // MacBinary III version byte
340        mb[102..106].copy_from_slice(b"mBIN");
341        // CRC is now stale, but the 'mBIN' signature is authoritative.
342        assert_eq!(is_macbinary(&mb), Some(MacBinaryVersion::Three));
343    }
344
345    #[test]
346    fn parse_accepts_empty_resource_fork() {
347        // Data-fork-only Mac file (e.g. a wrapped disk image). The old
348        // resource_fork parser wrongly rejected this.
349        let mb = build_mb2(b"img", *b"????", *b"????", 0, b"disk-image-bytes", b"");
350        let f = parse(&mb).unwrap();
351        assert!(f.resource_fork.is_empty());
352        assert_eq!(f.data_fork, b"disk-image-bytes");
353    }
354
355    #[test]
356    fn is_macbinary_rejects_raw_disk_image() {
357        // A raw HFS volume (MDB sig at byte 1024) must not be misread as
358        // MacBinary — byte 0 is nonzero / reserved bytes dirty / no CRC.
359        let mut buf = vec![0u8; 2048];
360        buf[0] = 0x4C; // typical first byte of an HFS boot block ('LK')
361        buf[1] = 0x4B;
362        buf[1024] = 0x42;
363        buf[1025] = 0x44;
364        assert_eq!(is_macbinary(&buf), None);
365    }
366
367    #[test]
368    fn is_macbinary_rejects_random_and_short() {
369        assert_eq!(is_macbinary(&[0u8; 64]), None);
370        let junk: Vec<u8> = (0..200u8).collect();
371        assert_eq!(is_macbinary(&junk), None);
372    }
373
374    #[test]
375    fn parse_rejects_bad_reserved_byte() {
376        let mut mb = build_mb2(b"x", *b"TEXT", *b"ttxt", 0, b"d", b"");
377        mb[82] = 0x55; // reserved byte must be 0
378        assert!(parse(&mb).is_err());
379    }
380}