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}