Skip to main content

arc_mac/
detect.rs

1// SPDX-License-Identifier: MIT
2//! Magic-sniff classifier for Mac archive bytes. Returns a single
3//! [`MacArchiveKind`] regardless of filename extension, so the GUI
4//! workflows in OPEN-WORK §6.1 can trigger off the actual file content
5//! rather than a possibly-misleading name.
6//!
7//! Detection order:
8//!  1. BinHex 4.0 (textual banner). If it decodes, re-sniff the inner
9//!     data fork: a SIT/SIT5 payload yields [`BinHexOverSit`], a SEA
10//!     payload yields [`BinHexOverSea`], anything else yields
11//!     [`BinHexSingleFile`] (one Mac file's worth of forks).
12//!  2. Standalone classic StuffIt `SIT!`.
13//!  3. Standalone StuffIt 5.
14//!  4. Standalone SEA (Mac app data fork with a SIT! signature inside).
15//!
16//! Anything else: `None`. The caller treats it as opaque bytes.
17//!
18//! [`BinHexOverSit`]: MacArchiveKind::BinHexOverSit
19//! [`BinHexOverSea`]: MacArchiveKind::BinHexOverSea
20//! [`BinHexSingleFile`]: MacArchiveKind::BinHexSingleFile
21
22use std::io::Cursor;
23
24use dsk_apple::dc42;
25use fs_mac::binhex;
26
27use super::compactpro::is_compactpro;
28use super::extract::is_stuffitx;
29use super::stuffit::{find_sea_archive, is_stuffit};
30use super::stuffit5::is_stuffit5;
31
32/// What kind of Mac archive a byte buffer is, if any.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum MacArchiveKind {
35    /// A `.hqx` whose decoded payload is one Mac file (not a SIT/SEA).
36    /// Carries data fork + resource fork + Finder info (type, creator,
37    /// flags). The Workflow A modal asks the user whether to "Convert
38    /// to binary" (decode the HQX and add the inner file) or add as-is.
39    BinHexSingleFile,
40    /// A `.sit.hqx`: BinHex-wrapped classic StuffIt or StuffIt 5
41    /// archive. The Workflow A modal offers three actions (convert and
42    /// expand / convert only / add as-is).
43    BinHexOverSit,
44    /// A `.sea.hqx`: BinHex-wrapped self-extracting StuffIt archive.
45    /// Same three-action shape as [`BinHexOverSit`].
46    BinHexOverSea,
47    /// A classic StuffIt archive (`SIT!` magic).
48    Sit,
49    /// A StuffIt 5 archive.
50    Sit5,
51    /// A self-extracting StuffIt archive: a Mac application carrying a
52    /// SIT! signature inside its data fork.
53    Sea,
54    /// A Compact Pro archive (`.cpt`, or a `.sea` self-extracting one). The
55    /// `0x01` marker plus a CRC-validated index identify it.
56    CompactPro,
57    /// A `.cpt.hqx` / `.sea.hqx`: BinHex-wrapped Compact Pro archive.
58    BinHexOverCompactPro,
59    /// A `mar` archive (`.mar`, `MAR\x80` magic) — a single Mac file or folder
60    /// tree. Only the stored (unwrapped) form is recognized here.
61    Mar,
62    /// A MacBinary I/II/III file (`.bin`): a transport wrapper holding one Mac
63    /// file's two forks plus Finder info. Content-detected (it has no reliable
64    /// extension), so a raw `.bin` disk image stays a disk image.
65    MacBinary,
66    /// A MacZip archive (`.zip`): Info-ZIP's Macintosh port stores each Mac
67    /// file's data fork as a normal ZIP entry and its resource fork under a
68    /// `XtraStuf.mac/` component, with Finder info in a `Mac3` extra field.
69    /// Content-detected (presence of a `Mac3` field), so a plain
70    /// disk-image-in-a-zip stays a disk image.
71    MacZip,
72}
73
74impl MacArchiveKind {
75    /// Short label suitable for log messages and dialog titles
76    /// ("BinHex 4.0", "StuffIt", "StuffIt 5", "SEA", "StuffIt-over-BinHex",
77    /// "SEA-over-BinHex").
78    pub fn label(self) -> &'static str {
79        match self {
80            MacArchiveKind::BinHexSingleFile => "BinHex 4.0",
81            MacArchiveKind::BinHexOverSit => "StuffIt-over-BinHex",
82            MacArchiveKind::BinHexOverSea => "SEA-over-BinHex",
83            MacArchiveKind::Sit => "StuffIt",
84            MacArchiveKind::Sit5 => "StuffIt 5",
85            MacArchiveKind::Sea => "SEA",
86            MacArchiveKind::CompactPro => "Compact Pro",
87            MacArchiveKind::BinHexOverCompactPro => "Compact-Pro-over-BinHex",
88            MacArchiveKind::Mar => "MAR",
89            MacArchiveKind::MacBinary => "MacBinary",
90            MacArchiveKind::MacZip => "MacZip",
91        }
92    }
93
94    /// True iff the kind has a BinHex outer wrapper (i.e. would benefit
95    /// from a "Convert" action that strips the wrapper).
96    pub fn is_binhex_wrapped(self) -> bool {
97        matches!(
98            self,
99            MacArchiveKind::BinHexSingleFile
100                | MacArchiveKind::BinHexOverSit
101                | MacArchiveKind::BinHexOverSea
102                | MacArchiveKind::BinHexOverCompactPro
103        )
104    }
105
106    /// True iff the kind has a multi-file archive payload (SIT / SIT5 /
107    /// SEA, possibly wrapped in BinHex). The opposite case is a
108    /// `BinHexSingleFile`, which only ever holds one file.
109    pub fn is_multi_file(self) -> bool {
110        !matches!(
111            self,
112            MacArchiveKind::BinHexSingleFile | MacArchiveKind::MacBinary
113        )
114    }
115}
116
117/// Classify `bytes` as a Mac archive if possible. Filename extension
118/// is irrelevant — detection runs purely on content magic. Returns
119/// `None` for anything that doesn't match a known archive shape.
120///
121/// Performance: when BinHex is suspected, the full
122/// [`binhex::parse_binhex`] runs (it has to, because we need the
123/// decoded payload to disambiguate single-file vs SIT-wrapped vs
124/// SEA-wrapped). For a multi-MB HQX this is the bulk of the cost.
125/// Non-BinHex inputs are O(small constant) — just magic comparisons.
126pub fn detect_mac_archive(bytes: &[u8]) -> Option<MacArchiveKind> {
127    // BinHex first. The banner is text so a quick header peek would
128    // suffice as a gate, but parse_binhex already searches for it
129    // with bounded work; calling it directly keeps one code path.
130    if let Ok(bh) = binhex::parse_binhex(bytes) {
131        let inner = bh.data_fork.as_slice();
132        if is_stuffit(inner) || is_stuffit5(inner) {
133            return Some(MacArchiveKind::BinHexOverSit);
134        }
135        if is_compactpro(inner) {
136            return Some(MacArchiveKind::BinHexOverCompactPro);
137        }
138        if find_sea_archive(inner).is_some() {
139            return Some(MacArchiveKind::BinHexOverSea);
140        }
141        return Some(MacArchiveKind::BinHexSingleFile);
142    }
143    // Standalone SIT / SIT5 / SEA. is_stuffitx is checked too so the
144    // "looks like StuffIt but is actually StuffIt X" case doesn't
145    // falsely match SEA's broader signature scan.
146    if is_stuffit(bytes) {
147        return Some(MacArchiveKind::Sit);
148    }
149    if is_stuffit5(bytes) {
150        return Some(MacArchiveKind::Sit5);
151    }
152    if is_stuffitx(bytes) {
153        // StuffIt X is a recognized container but the extractor doesn't
154        // speak it yet; report None so the caller doesn't pop a "we can
155        // do this" modal it can't honor. The user's still free to add
156        // the file as-is; the existing extractor bails with a clearer
157        // message when they try to expand it.
158        return None;
159    }
160    if is_compactpro(bytes) {
161        return Some(MacArchiveKind::CompactPro);
162    }
163    if super::mar::is_mar(bytes) {
164        return Some(MacArchiveKind::Mar);
165    }
166    // MacBinary after the strong-magic formats (it has no magic of its own and
167    // uses a confidence heuristic), but before the broad SEA signature scan so
168    // a MacBinary wrapping a SEA app still classifies as MacBinary. The
169    // heuristic is conservative enough that a raw `.bin` disk image is not
170    // matched (see is_macbinary's floor checks).
171    if super::macbinary::is_macbinary(bytes).is_some() {
172        return Some(MacArchiveKind::MacBinary);
173    }
174    // MacZip is a ZIP carrying Mac3 extra fields. Cheap PK-magic gate inside
175    // is_maczip means a non-zip input bails immediately; a plain
176    // disk-image-in-a-zip has no Mac3 field and is not matched.
177    if super::maczip::is_maczip(bytes) {
178        return Some(MacArchiveKind::MacZip);
179    }
180    if find_sea_archive(bytes).is_some() {
181        return Some(MacArchiveKind::Sea);
182    }
183    None
184}
185
186/// A disk-image payload an archive's single entry can resolve to.
187/// Returned by [`detect_mountable_image`] — the Workflow A and D.2
188/// auto-unwrap hint uses this to surface "Mount in new Inspect tab"
189/// when the user picks an archive whose contents are themselves a
190/// disk image.
191#[derive(Debug, Clone, Copy, PartialEq, Eq)]
192pub enum MountableImageKind {
193    /// DiskCopy 4.2 floppy image (`.dsk` style).
194    DiskCopy42,
195    /// A raw HFS volume — MDB signature `0x4244` at byte 1024.
196    RawHfs,
197    /// A raw HFS+ / HFSX volume — signature `0x482B` / `0x4858`.
198    RawHfsPlus,
199}
200
201impl MountableImageKind {
202    /// Short label for log messages / dialog buttons.
203    pub fn label(self) -> &'static str {
204        match self {
205            MountableImageKind::DiskCopy42 => "DiskCopy 4.2",
206            MountableImageKind::RawHfs => "raw HFS",
207            MountableImageKind::RawHfsPlus => "raw HFS+",
208        }
209    }
210}
211
212/// Sniff a decompressed payload for "is this a disk image the Inspect
213/// tab can mount and partition-walk?" Tries DiskCopy 4.2 first (header
214/// at offset 0 plus size match), then raw HFS / HFS+ (MDB signature at
215/// byte 1024 — the BasiliskII HFV layout and the Apple_HFS partition
216/// layout share this offset).
217pub fn detect_mountable_image(bytes: &[u8]) -> Option<MountableImageKind> {
218    // DiskCopy 4.2 — header validates against the buffer length, so a
219    // random buffer that happens to start with header-shaped bytes
220    // gets rejected by the size match.
221    if dc42::detect_dc42(&mut Cursor::new(bytes), bytes.len() as u64).is_some() {
222        return Some(MountableImageKind::DiskCopy42);
223    }
224    // Raw HFS / HFS+ — signature at byte 1024 (HFS_SIGNATURE = 0x4244,
225    // HFSPlus = 0x482B, HFSX = 0x4858). Matches the superfloppy probe
226    // in partition::detect_superfloppy.
227    if bytes.len() >= 1026 {
228        let sig = u16::from_be_bytes([bytes[1024], bytes[1025]]);
229        return match sig {
230            0x4244 => Some(MountableImageKind::RawHfs),
231            0x482B | 0x4858 => Some(MountableImageKind::RawHfsPlus),
232            _ => None,
233        };
234    }
235    None
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241
242    /// A minimum-viable SIT header that satisfies `is_stuffit` (just
243    /// the magic — no real entries needed to drive the classifier).
244    fn synth_sit_header() -> Vec<u8> {
245        let mut out = vec![0u8; 14];
246        out[0..4].copy_from_slice(b"SIT!");
247        out[10..14].copy_from_slice(b"rLau");
248        out
249    }
250
251    /// A minimum SIT5 header (just the magic string).
252    fn synth_sit5_header() -> Vec<u8> {
253        b"StuffIt (c)1997-2002 by Aladdin Systems, Inc.".to_vec()
254    }
255
256    /// A SEA-shaped buffer: arbitrary "Mac app code" prefix with a SIT
257    /// signature buried inside. find_sea_archive scans for the magic.
258    fn synth_sea_blob() -> Vec<u8> {
259        let mut out = vec![0u8; 256];
260        // Plant a SIT! / rLau signature at offset 100 — anywhere
261        // non-zero counts as a SEA (offset 0 would classify as plain
262        // Sit instead).
263        out[100..104].copy_from_slice(b"SIT!");
264        out[110..114].copy_from_slice(b"rLau");
265        out
266    }
267
268    /// Wrap arbitrary bytes in a BinHex envelope so they round-trip
269    /// through parse_binhex. Picks a benign Mac filename, zero
270    /// type/creator, and the given bytes as the data fork.
271    fn wrap_binhex(name: &str, data_fork: Vec<u8>) -> Vec<u8> {
272        let bh = fs_mac::binhex::BinHexFile {
273            name: name.to_string(),
274            type_code: [0; 4],
275            creator_code: [0; 4],
276            flags: 0,
277            data_fork,
278            resource_fork: Vec::new(),
279        };
280        fs_mac::binhex::build_binhex(&bh).into_bytes()
281    }
282
283    #[test]
284    fn detects_mac_archive_kinds() {
285        // (built input bytes, expected classification). Covers standalone
286        // SIT/SIT5/SEA plus BinHex wrapping each payload type.
287        let cases: Vec<(Vec<u8>, MacArchiveKind)> = vec![
288            (synth_sit_header(), MacArchiveKind::Sit),
289            (synth_sit5_header(), MacArchiveKind::Sit5),
290            // SEA: SIT! planted mid-buffer (offset 100), not at 0, so it
291            // routes through the Sea branch rather than plain Sit.
292            (synth_sea_blob(), MacArchiveKind::Sea),
293            // BinHex, plain data fork (no SIT/SEA inside).
294            (
295                wrap_binhex("Document.txt", b"hello world".to_vec()),
296                MacArchiveKind::BinHexSingleFile,
297            ),
298            (
299                wrap_binhex("Bundle.sit", synth_sit_header()),
300                MacArchiveKind::BinHexOverSit,
301            ),
302            // SIT5-inside still classifies as BinHexOverSit (same modal flow).
303            (
304                wrap_binhex("Bundle.sit", synth_sit5_header()),
305                MacArchiveKind::BinHexOverSit,
306            ),
307            (
308                wrap_binhex("Installer.sea", synth_sea_blob()),
309                MacArchiveKind::BinHexOverSea,
310            ),
311        ];
312        for (input, expected) in cases {
313            assert_eq!(detect_mac_archive(&input), Some(expected));
314        }
315    }
316
317    #[test]
318    fn returns_none_for_random_bytes() {
319        let junk = (0..200u8).collect::<Vec<u8>>();
320        assert_eq!(detect_mac_archive(&junk), None);
321    }
322
323    #[test]
324    fn returns_none_for_empty() {
325        assert_eq!(detect_mac_archive(&[]), None);
326    }
327
328    #[test]
329    fn returns_none_for_stuffitx() {
330        // StuffIt X is recognized but we don't support extraction, so
331        // the classifier returns None — the caller falls through to the
332        // existing add-as-binary path instead of popping a misleading
333        // "we can expand this" modal.
334        let mut sx = b"StuffIt!".to_vec();
335        sx.extend_from_slice(&[0u8; 32]);
336        assert_eq!(detect_mac_archive(&sx), None);
337    }
338
339    #[test]
340    fn label_is_human_readable() {
341        // Spot-check: a few labels must come back as plain ASCII so
342        // they survive the egui font (CLAUDE.md rule).
343        assert!(MacArchiveKind::Sit.label().is_ascii());
344        assert!(MacArchiveKind::BinHexOverSit.label().is_ascii());
345        assert!(MacArchiveKind::Sea.label().is_ascii());
346    }
347
348    #[test]
349    fn is_binhex_wrapped_covers_the_three_hqx_kinds() {
350        for k in [
351            MacArchiveKind::BinHexSingleFile,
352            MacArchiveKind::BinHexOverSit,
353            MacArchiveKind::BinHexOverSea,
354        ] {
355            assert!(k.is_binhex_wrapped(), "{k:?}");
356        }
357        for k in [
358            MacArchiveKind::Sit,
359            MacArchiveKind::Sit5,
360            MacArchiveKind::Sea,
361        ] {
362            assert!(!k.is_binhex_wrapped(), "{k:?}");
363        }
364    }
365
366    #[test]
367    fn mountable_image_detects_raw_hfs_at_byte_1024() {
368        // Buffer big enough to host the MDB; signature 0x4244 = "BD" at
369        // byte 1024 marks classic HFS.
370        let mut buf = vec![0u8; 2048];
371        buf[1024] = 0x42;
372        buf[1025] = 0x44;
373        assert_eq!(
374            detect_mountable_image(&buf),
375            Some(MountableImageKind::RawHfs)
376        );
377    }
378
379    #[test]
380    fn mountable_image_detects_hfs_plus_signatures() {
381        for sig in [0x482Bu16, 0x4858u16] {
382            let mut buf = vec![0u8; 2048];
383            buf[1024..1026].copy_from_slice(&sig.to_be_bytes());
384            assert_eq!(
385                detect_mountable_image(&buf),
386                Some(MountableImageKind::RawHfsPlus),
387                "sig 0x{sig:04X}",
388            );
389        }
390    }
391
392    #[test]
393    fn mountable_image_returns_none_for_random_short_buffer() {
394        assert_eq!(detect_mountable_image(&[0u8; 64]), None);
395        // Even big buffers with non-magic at byte 1024 return None.
396        assert_eq!(detect_mountable_image(&vec![0u8; 4096]), None);
397    }
398
399    #[test]
400    fn mountable_image_label_is_ascii() {
401        // egui font (CLAUDE.md rule) — every label that appears in the
402        // GUI must survive without unicode glyphs.
403        for kind in [
404            MountableImageKind::DiskCopy42,
405            MountableImageKind::RawHfs,
406            MountableImageKind::RawHfsPlus,
407        ] {
408            assert!(kind.label().is_ascii(), "{kind:?}");
409        }
410    }
411
412    #[test]
413    fn is_multi_file_excludes_binhex_single_file() {
414        assert!(!MacArchiveKind::BinHexSingleFile.is_multi_file());
415        for k in [
416            MacArchiveKind::BinHexOverSit,
417            MacArchiveKind::BinHexOverSea,
418            MacArchiveKind::Sit,
419            MacArchiveKind::Sit5,
420            MacArchiveKind::Sea,
421            MacArchiveKind::Mar,
422        ] {
423            assert!(k.is_multi_file(), "{k:?}");
424        }
425    }
426
427    #[test]
428    fn detects_macbinary_and_not_raw_disk_image() {
429        use byteorder::{BigEndian, ByteOrder};
430        // A CRC-valid MacBinary II classifies as MacBinary (single-file).
431        let mut hdr = [0u8; 128];
432        hdr[1] = 1;
433        hdr[2] = b'x';
434        hdr[65..69].copy_from_slice(b"TEXT");
435        hdr[69..73].copy_from_slice(b"ttxt");
436        BigEndian::write_u32(&mut hdr[83..87], 4); // data fork len
437        hdr[122] = 129;
438        hdr[123] = 129;
439        let crc = fs_mac::resource_fork::macbinary_crc16(&hdr[0..124]);
440        BigEndian::write_u16(&mut hdr[124..126], crc);
441        let mut mb = hdr.to_vec();
442        mb.extend_from_slice(b"DATA");
443        while mb.len() % 128 != 0 {
444            mb.push(0);
445        }
446        assert_eq!(detect_mac_archive(&mb), Some(MacArchiveKind::MacBinary));
447        assert!(!MacArchiveKind::MacBinary.is_multi_file());
448
449        // A raw HFS volume (MDB sig at byte 1024) must NOT be a Mac archive —
450        // the .bin overload stays a disk image.
451        let mut hfs = vec![0u8; 2048];
452        hfs[0] = 0x4C;
453        hfs[1] = 0x4B;
454        hfs[1024] = 0x42;
455        hfs[1025] = 0x44;
456        assert_eq!(detect_mac_archive(&hfs), None);
457    }
458
459    #[test]
460    fn detects_mar() {
461        // A stored MAR built by our own writer classifies as Mar (magic +
462        // header CRC), is not BinHex-wrapped, and counts as multi-file.
463        let mar = crate::mar::build_archive(
464            "x",
465            &[crate::stuffit::StuffItInputNode::File(
466                crate::stuffit::StuffItInput {
467                    name: "f".into(),
468                    type_code: *b"TEXT",
469                    creator_code: *b"ttxt",
470                    finder_flags: 0,
471                    create_date: 0,
472                    mod_date: 0,
473                    data_fork: b"hi".to_vec(),
474                    resource_fork: Vec::new(),
475                },
476            )],
477        )
478        .unwrap();
479        assert_eq!(detect_mac_archive(&mar), Some(MacArchiveKind::Mar));
480        assert!(!MacArchiveKind::Mar.is_binhex_wrapped());
481        assert_eq!(MacArchiveKind::Mar.label(), "MAR");
482    }
483}