Skip to main content

arc_mac/
mar.rs

1// SPDX-License-Identifier: MIT
2//! MAR archive reader + writer (classic Mac, Hampa Hug's `mar` tool).
3//!
4//! A `.mar` archive stores a **single** Mac item — a file or a folder tree —
5//! with both forks, type/creator, Finder flags, and 1904-epoch dates. It is the
6//! native format of the `mar` MPW/Mac tool. Layout (all fields big-endian):
7//!
8//! ```text
9//! [128-byte record header] [data block] [rsrc block] [comment block] ...
10//! ```
11//!
12//! Each record header is 128 bytes:
13//!   0   u32  magic 'MAR\x80' (0x4d415280)
14//!   4   u32  id: 'file' (file) | 'fold' (folder start) | '----' (folder end)
15//!   8   pstr name (length byte + up to 63 Mac-Roman bytes, 64 bytes total)
16//!   72  u32  data fork size        76  u32  rsrc fork size
17//!   80  u32  comment size          84  u32  file flags (bit0 = locked)
18//!   88  u32  create date (Mac 1904)  92  u32  mod date
19//!   96  16   FInfo/DInfo: type@96 creator@100 finderFlags@104 ...
20//!   124 u32  header CRC (over bytes 0..124)
21//!
22//! A `file` header is followed by its data, resource, and comment blocks (each
23//! present only when its size > 0). A block is `[payload][u32 CRC][zero pad]`,
24//! padded so `payload + 4 + pad` is a multiple of 128. A `fold` header is
25//! followed by its children (more records) terminated by a `----` header.
26//!
27//! The whole byte stream may be wrapped in LZW (Unix `compress`, magic `1f 9d`)
28//! or an LZ4 frame (magic `04 22 4d 18`); an unwrapped archive starts directly
29//! with the first record's `MAR\x80`. We read/write the **stored** (unwrapped)
30//! form; a compressed archive is detected and reported (not silently mis-read).
31//!
32//! Both CRCs use a non-reflected CRC-32 with the Castagnoli polynomial
33//! 0x1edc6f41 (init 0, no xorout) — `mar`'s own checksum, distinct from the
34//! StuffIt/Compact-Pro fork checksums.
35//!
36//! Clean-room Rust from the format as described by the GPL-2.0 `mar-0.2.0`
37//! sources (Hampa Hug); no code is copied. Forks are decoded eagerly into a
38//! buffer and exposed through the shared [`StuffItArchive`] model (stored forks,
39//! offsets into the returned buffer) so the existing extract / browse / CLI path
40//! handles MAR with no special case — exactly like the loose-BinHex synthesis in
41//! [`super::extract`].
42
43use anyhow::{bail, Context, Result};
44
45use fs_mac::hfs::{decode_mac_filename, utf8_to_mac_roman};
46
47use super::stuffit::{
48    crc16_arc, ForkCodec, ForkInfo, StuffItArchive, StuffItEntry, StuffItInputNode,
49};
50
51const MAR_MAGIC: u32 = 0x4d41_5280; // 'MAR\x80'
52const MAR_FILE: u32 = 0x6669_6c65; // 'file'
53const MAR_FOLDER_START: u32 = 0x666f_6c64; // 'fold'
54const MAR_FOLDER_END: u32 = 0x2d2d_2d2d; // '----'
55const HEADER_LEN: usize = 128;
56const NAME_OFF: usize = 8;
57const NAME_MAX: usize = 63;
58
59/// LZW (`compress`) wrapper magic.
60const LZW_MAGIC: [u8; 2] = [0x1f, 0x9d];
61/// LZ4 frame wrapper magic.
62const LZ4_MAGIC: [u8; 4] = [0x04, 0x22, 0x4d, 0x18];
63
64// ---------------------------------------------------------------------------
65// CRC-32 (poly 0x1edc6f41, MSB-first, init 0) — mar's header + fork checksum.
66// ---------------------------------------------------------------------------
67
68const fn build_crc_table() -> [u32; 256] {
69    let mut tab = [0u32; 256];
70    let mut i = 0;
71    while i < 256 {
72        let mut reg = (i as u32) << 24;
73        let mut j = 0;
74        while j < 8 {
75            if reg & 0x8000_0000 != 0 {
76                reg = (reg << 1) ^ 0x1edc_6f41;
77            } else {
78                reg <<= 1;
79            }
80            j += 1;
81        }
82        tab[i] = reg;
83        i += 1;
84    }
85    tab
86}
87
88static CRC_TABLE: [u32; 256] = build_crc_table();
89
90fn mar_crc32(data: &[u8]) -> u32 {
91    let mut reg: u32 = 0;
92    for &b in data {
93        let idx = ((reg >> 24) ^ b as u32) & 0xff;
94        reg = (reg << 8) ^ CRC_TABLE[idx as usize];
95    }
96    reg
97}
98
99fn u16_be(buf: &[u8], i: usize) -> u16 {
100    u16::from_be_bytes([buf[i], buf[i + 1]])
101}
102
103fn u32_be(buf: &[u8], i: usize) -> u32 {
104    u32::from_be_bytes([buf[i], buf[i + 1], buf[i + 2], buf[i + 3]])
105}
106
107/// True if `buf` begins with a valid 128-byte MAR record header (magic + a
108/// header CRC that checks out). This is the unwrapped (stored) form.
109fn is_mar_header(buf: &[u8]) -> bool {
110    buf.len() >= HEADER_LEN
111        && u32_be(buf, 0) == MAR_MAGIC
112        && mar_crc32(&buf[..124]) == u32_be(buf, 124)
113}
114
115/// Detect a stored (unwrapped) MAR archive by its first record header.
116/// Compressed wrappers are intentionally **not** reported here — `parse`
117/// surfaces them with a clear "not yet supported" message rather than a
118/// detection false-positive on the generic LZW/LZ4 magics.
119pub fn is_mar(data: &[u8]) -> bool {
120    is_mar_header(data)
121}
122
123// ---------------------------------------------------------------------------
124// Reader
125// ---------------------------------------------------------------------------
126
127/// Parse a MAR archive. Returns the decoded byte buffer (fork offsets in the
128/// entries are absolute within it) plus the parsed directory — the same
129/// `(Vec<u8>, StuffItArchive)` shape every other Mac-archive parser returns, so
130/// `super::extract` handles it unchanged. Forks are modelled as **stored**
131/// (method 0) over the returned buffer; each fork's MAR CRC-32 is verified here.
132pub fn parse(data: &[u8]) -> Result<(Vec<u8>, StuffItArchive)> {
133    if !is_mar_header(data) {
134        if data.len() >= 2 && data[..2] == LZW_MAGIC {
135            bail!(
136                "MAR archive is LZW-compressed (Unix `compress`); only stored MAR is \
137                 supported so far. Decompress it first (e.g. `uncompress`) or open it \
138                 with the `mar` tool."
139            );
140        }
141        if data.len() >= 4 && data[..4] == LZ4_MAGIC {
142            bail!(
143                "MAR archive is LZ4-compressed; only stored MAR is supported so far. \
144                 Decompress the LZ4 frame first."
145            );
146        }
147        bail!("not a MAR archive (bad magic or header CRC)");
148    }
149
150    let mut entries: Vec<StuffItEntry> = Vec::new();
151    let mut pos = 0usize;
152    parse_record(data, &mut pos, &[], &mut entries).context("parsing MAR archive")?;
153
154    Ok((
155        data.to_vec(),
156        StuffItArchive {
157            entries,
158            truncated: None,
159            damage: Vec::new(),
160        },
161    ))
162}
163
164/// Parse one record at `*pos` (advancing it past the record and any fork
165/// blocks / nested children), appending the entry (and, for folders, its
166/// descendants) to `entries`.
167fn parse_record(
168    data: &[u8],
169    pos: &mut usize,
170    parent_path: &[String],
171    entries: &mut Vec<StuffItEntry>,
172) -> Result<()> {
173    if *pos + HEADER_LEN > data.len() {
174        bail!("truncated MAR record header");
175    }
176    let hdr = &data[*pos..*pos + HEADER_LEN];
177    if !is_mar_header(hdr) {
178        bail!("bad MAR record header (magic/CRC)");
179    }
180    let id = u32_be(hdr, 4);
181    *pos += HEADER_LEN;
182
183    let name = read_name(hdr);
184    let mut path = parent_path.to_vec();
185    path.push(name.clone());
186
187    let create_date = u32_be(hdr, 88);
188    let mod_date = u32_be(hdr, 92);
189    let type_code = [hdr[96], hdr[97], hdr[98], hdr[99]];
190    let creator_code = [hdr[100], hdr[101], hdr[102], hdr[103]];
191    let finder_flags = u16_be(hdr, 104);
192
193    match id {
194        MAR_FILE => {
195            let data_size = u32_be(hdr, 72);
196            let rsrc_size = u32_be(hdr, 76);
197            let comment_size = u32_be(hdr, 80);
198            let data_fork = read_block(data, pos, data_size, "data")?;
199            let rsrc_fork = read_block(data, pos, rsrc_size, "resource")?;
200            // Comments are decoded + CRC-verified for integrity but dropped
201            // (the archive model has nowhere to put a Finder comment).
202            let _ = read_block(data, pos, comment_size, "comment")?;
203            entries.push(StuffItEntry {
204                path,
205                name,
206                is_dir: false,
207                type_code,
208                creator_code,
209                finder_flags,
210                create_date,
211                mod_date,
212                data: data_fork,
213                rsrc: rsrc_fork,
214            });
215        }
216        MAR_FOLDER_START => {
217            entries.push(StuffItEntry {
218                path: path.clone(),
219                name,
220                is_dir: true,
221                type_code,
222                creator_code,
223                finder_flags,
224                create_date,
225                mod_date,
226                data: None,
227                rsrc: None,
228            });
229            loop {
230                if *pos + HEADER_LEN > data.len() {
231                    bail!("MAR folder is not terminated (missing '----' record)");
232                }
233                let child = &data[*pos..*pos + HEADER_LEN];
234                if !is_mar_header(child) {
235                    bail!("bad MAR record header inside folder");
236                }
237                if u32_be(child, 4) == MAR_FOLDER_END {
238                    *pos += HEADER_LEN;
239                    break;
240                }
241                parse_record(data, pos, &path, entries)?;
242            }
243        }
244        MAR_FOLDER_END => bail!("unexpected MAR '----' (folder end) record"),
245        other => bail!("unknown MAR record id {other:#010x}"),
246    }
247    Ok(())
248}
249
250/// Read the name P-string out of a record header (Mac-Roman → UTF-8).
251fn read_name(hdr: &[u8]) -> String {
252    let len = (hdr[NAME_OFF] as usize).min(NAME_MAX);
253    decode_mac_filename(&hdr[NAME_OFF + 1..NAME_OFF + 1 + len])
254}
255
256/// Read one fork/comment block: `size` payload bytes, a 4-byte big-endian
257/// MAR CRC-32 (verified), then zero padding to a 128-byte boundary. Advances
258/// `*pos` past the whole block. Returns a stored [`ForkInfo`] pointing at the
259/// payload, or `None` for an empty block.
260fn read_block(data: &[u8], pos: &mut usize, size: u32, what: &str) -> Result<Option<ForkInfo>> {
261    if size == 0 {
262        return Ok(None);
263    }
264    let size = size as usize;
265    let payload_start = *pos;
266    let crc_at = payload_start + size;
267    let pad = (128 - ((size + 4) & 127)) & 127;
268    let block_end = crc_at + 4 + pad;
269    if block_end > data.len() {
270        bail!("MAR {what} block extends past end of archive");
271    }
272    let payload = &data[payload_start..crc_at];
273    let want = u32_be(data, crc_at);
274    let got = mar_crc32(payload);
275    if got != want {
276        bail!("MAR {what} fork CRC mismatch (got {got:#010x}, expected {want:#010x})");
277    }
278    *pos = block_end;
279    Ok(Some(ForkInfo {
280        method: 0,
281        codec: ForkCodec::StuffIt,
282        encrypted: false,
283        uncompressed_len: size as u32,
284        compressed_len: size as u32,
285        // The shared store-path decoder (`stuffit::decompress_fork`) re-checks
286        // CRC-16/ARC, so seed it with the value of the verbatim payload.
287        crc: crc16_arc(payload),
288        crc32: 0,
289        offset: payload_start as u64,
290    }))
291}
292
293// ---------------------------------------------------------------------------
294// Writer (stored)
295// ---------------------------------------------------------------------------
296
297/// Build a stored MAR archive. A MAR holds a single root item: with one input
298/// node the node becomes the root; with several, they're wrapped in a folder
299/// named `root_name`. Forks are stored uncompressed (this tool never writes the
300/// optional LZW/LZ4 wrapper). The result round-trips through [`parse`] and is
301/// read by the `mar` tool.
302pub fn build_archive(root_name: &str, tree: &[StuffItInputNode]) -> Result<Vec<u8>> {
303    if tree.is_empty() {
304        bail!("cannot build an empty MAR archive");
305    }
306    let mut out = Vec::new();
307    if tree.len() == 1 {
308        emit_node(&mut out, &tree[0])?;
309    } else {
310        emit_folder(&mut out, root_name, 0, 0, 0, tree)?;
311    }
312    Ok(out)
313}
314
315fn emit_node(out: &mut Vec<u8>, node: &StuffItInputNode) -> Result<()> {
316    match node {
317        StuffItInputNode::File(f) => {
318            let mut hdr = [0u8; HEADER_LEN];
319            put_u32(&mut hdr, 0, MAR_MAGIC);
320            put_u32(&mut hdr, 4, MAR_FILE);
321            write_name(&mut hdr, &f.name)?;
322            put_u32(&mut hdr, 72, f.data_fork.len() as u32);
323            put_u32(&mut hdr, 76, f.resource_fork.len() as u32);
324            // comment_size (80) and file_flags (84) stay 0.
325            put_u32(&mut hdr, 88, f.create_date);
326            put_u32(&mut hdr, 92, f.mod_date);
327            hdr[96..100].copy_from_slice(&f.type_code);
328            hdr[100..104].copy_from_slice(&f.creator_code);
329            put_u16(&mut hdr, 104, f.finder_flags);
330            seal_header(&mut hdr);
331            out.extend_from_slice(&hdr);
332            write_block(out, &f.data_fork);
333            write_block(out, &f.resource_fork);
334            // No comment block (size 0 -> omitted, matching the mar encoder).
335            Ok(())
336        }
337        StuffItInputNode::Folder {
338            name,
339            finder_flags,
340            create_date,
341            mod_date,
342            children,
343        } => emit_folder(out, name, *finder_flags, *create_date, *mod_date, children),
344    }
345}
346
347fn emit_folder(
348    out: &mut Vec<u8>,
349    name: &str,
350    finder_flags: u16,
351    create_date: u32,
352    mod_date: u32,
353    children: &[StuffItInputNode],
354) -> Result<()> {
355    let mut hdr = [0u8; HEADER_LEN];
356    put_u32(&mut hdr, 0, MAR_MAGIC);
357    put_u32(&mut hdr, 4, MAR_FOLDER_START);
358    write_name(&mut hdr, name)?;
359    // sizes (72/76/80) and flags (84) stay 0 for a folder.
360    put_u32(&mut hdr, 88, create_date);
361    put_u32(&mut hdr, 92, mod_date);
362    put_u16(&mut hdr, 104, finder_flags);
363    seal_header(&mut hdr);
364    out.extend_from_slice(&hdr);
365
366    for child in children {
367        emit_node(out, child)?;
368    }
369
370    let mut end = [0u8; HEADER_LEN];
371    put_u32(&mut end, 0, MAR_MAGIC);
372    put_u32(&mut end, 4, MAR_FOLDER_END);
373    seal_header(&mut end);
374    out.extend_from_slice(&end);
375    Ok(())
376}
377
378/// Stamp a header's CRC-32 over bytes 0..124 into bytes 124..128.
379fn seal_header(hdr: &mut [u8; HEADER_LEN]) {
380    let crc = mar_crc32(&hdr[..124]);
381    put_u32(hdr, 124, crc);
382}
383
384/// Write a fork/comment block: payload, its CRC-32, then zero padding to a
385/// 128-byte boundary. An empty fork writes nothing (matching the reader).
386fn write_block(out: &mut Vec<u8>, payload: &[u8]) {
387    if payload.is_empty() {
388        return;
389    }
390    out.extend_from_slice(payload);
391    out.extend_from_slice(&mar_crc32(payload).to_be_bytes());
392    let pad = (128 - ((payload.len() + 4) & 127)) & 127;
393    out.resize(out.len() + pad, 0);
394}
395
396/// Write a Mac filename as a Pascal string into a header's name field
397/// (offset 8, length byte + up to 63 Mac-Roman bytes).
398fn write_name(hdr: &mut [u8; HEADER_LEN], name: &str) -> Result<()> {
399    let mut bytes = utf8_to_mac_roman(name).unwrap_or_else(|_| {
400        name.chars()
401            .map(|c| if c.is_ascii() { c as u8 } else { b'_' })
402            .collect()
403    });
404    bytes.truncate(NAME_MAX);
405    hdr[NAME_OFF] = bytes.len() as u8;
406    hdr[NAME_OFF + 1..NAME_OFF + 1 + bytes.len()].copy_from_slice(&bytes);
407    Ok(())
408}
409
410fn put_u16(buf: &mut [u8], i: usize, v: u16) {
411    buf[i..i + 2].copy_from_slice(&v.to_be_bytes());
412}
413
414fn put_u32(buf: &mut [u8], i: usize, v: u32) {
415    buf[i..i + 4].copy_from_slice(&v.to_be_bytes());
416}
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421    use crate::stuffit::{decompress_fork, StuffItInput};
422
423    fn file_node(
424        name: &str,
425        ty: &[u8; 4],
426        cr: &[u8; 4],
427        data: &[u8],
428        rsrc: &[u8],
429    ) -> StuffItInputNode {
430        StuffItInputNode::File(StuffItInput {
431            name: name.to_string(),
432            type_code: *ty,
433            creator_code: *cr,
434            finder_flags: 0,
435            create_date: 0xA7E1_1134,
436            mod_date: 0xE116_2A21,
437            data_fork: data.to_vec(),
438            resource_fork: rsrc.to_vec(),
439        })
440    }
441
442    #[test]
443    fn crc32_known_header_passes_roundtrip() {
444        // A sealed header's CRC must validate via the reader's check.
445        let mut hdr = [0u8; HEADER_LEN];
446        put_u32(&mut hdr, 0, MAR_MAGIC);
447        put_u32(&mut hdr, 4, MAR_FILE);
448        seal_header(&mut hdr);
449        assert!(is_mar_header(&hdr));
450        // Corrupting any covered byte breaks the header CRC.
451        hdr[8] ^= 0x01;
452        assert!(!is_mar_header(&hdr));
453    }
454
455    #[test]
456    fn single_file_roundtrip() {
457        let data = vec![0xABu8; 5000]; // multi-block, exercises padding
458        let rsrc = b"a resource fork".to_vec();
459        let node = file_node("Test File", b"APPL", b"TEST", &data, &rsrc);
460        let bytes = build_archive("ignored", &[node]).unwrap();
461
462        assert!(is_mar(&bytes), "writer output is detected as MAR");
463        let (buf, arc) = parse(&bytes).unwrap();
464        assert_eq!(arc.entries.len(), 1);
465        let e = &arc.entries[0];
466        assert_eq!(e.name, "Test File");
467        assert!(!e.is_dir);
468        assert_eq!(&e.type_code, b"APPL");
469        assert_eq!(&e.creator_code, b"TEST");
470        assert_eq!(e.create_date, 0xA7E1_1134);
471        let df = decompress_fork(&buf, e.data.as_ref().unwrap()).unwrap();
472        assert_eq!(df, data, "data fork round-trips byte-exact");
473        let rf = decompress_fork(&buf, e.rsrc.as_ref().unwrap()).unwrap();
474        assert_eq!(rf, rsrc, "resource fork round-trips byte-exact");
475    }
476
477    #[test]
478    fn data_only_and_rsrc_only_files() {
479        // A file with no resource fork, and one with no data fork.
480        let nodes = vec![
481            file_node("DataOnly", b"TEXT", b"ttxt", b"hello", &[]),
482            file_node("RsrcOnly", b"rsrc", b"RSED", &[], b"only rsrc"),
483        ];
484        let bytes = build_archive("Holder", &nodes).unwrap();
485        let (buf, arc) = parse(&bytes).unwrap();
486        // Two files wrapped in the synthetic "Holder" folder.
487        assert!(arc.entries.iter().any(|e| e.is_dir && e.name == "Holder"));
488        let d = arc.entries.iter().find(|e| e.name == "DataOnly").unwrap();
489        assert!(d.data.is_some() && d.rsrc.is_none());
490        assert_eq!(
491            decompress_fork(&buf, d.data.as_ref().unwrap()).unwrap(),
492            b"hello"
493        );
494        let r = arc.entries.iter().find(|e| e.name == "RsrcOnly").unwrap();
495        assert!(r.data.is_none() && r.rsrc.is_some());
496        assert_eq!(
497            decompress_fork(&buf, r.rsrc.as_ref().unwrap()).unwrap(),
498            b"only rsrc"
499        );
500    }
501
502    #[test]
503    fn nested_folder_roundtrip() {
504        let inner = StuffItInputNode::Folder {
505            name: "Inner".to_string(),
506            finder_flags: 0,
507            create_date: 0,
508            mod_date: 0,
509            children: vec![file_node("Leaf", b"TEXT", b"ttxt", b"leaf data", &[])],
510        };
511        let root = StuffItInputNode::Folder {
512            name: "Root".to_string(),
513            finder_flags: 0,
514            create_date: 0,
515            mod_date: 0,
516            children: vec![file_node("Top", b"TEXT", b"ttxt", b"top data", &[]), inner],
517        };
518        let bytes = build_archive("ignored", &[root]).unwrap();
519        let (_buf, arc) = parse(&bytes).unwrap();
520        let leaf = arc.entries.iter().find(|e| e.name == "Leaf").unwrap();
521        assert_eq!(
522            leaf.path,
523            vec!["Root", "Inner", "Leaf"],
524            "nested path reconstructed"
525        );
526        let top = arc.entries.iter().find(|e| e.name == "Top").unwrap();
527        assert_eq!(top.path, vec!["Root", "Top"]);
528    }
529
530    #[test]
531    fn rejects_non_mar() {
532        assert!(parse(b"not a mar at all, definitely not 128 bytes of header....").is_err());
533        // LZW/LZ4-wrapped MAR is recognized but reported as unsupported.
534        let lzw = parse(&[0x1f, 0x9d, 0x90, 0x00]).unwrap_err().to_string();
535        assert!(lzw.contains("LZW"), "LZW wrapper reported: {lzw}");
536        let lz4 = parse(&[0x04, 0x22, 0x4d, 0x18, 0x00])
537            .unwrap_err()
538            .to_string();
539        assert!(lz4.contains("LZ4"), "LZ4 wrapper reported: {lz4}");
540    }
541
542    /// Validate against a real `.mar` if one is present locally (not committed).
543    /// Mirrors `stuffit::parse_real_samples_if_present`. `parse` verifies every
544    /// fork's MAR CRC-32, so a clean parse proves byte-correct decoding.
545    #[test]
546    fn parse_real_mar_if_present() {
547        let path = std::path::Path::new("/Users/dani/Downloads/Bumpaint_1.0.mar");
548        let Ok(raw) = std::fs::read(path) else {
549            eprintln!("skipping: {} not present", path.display());
550            return;
551        };
552        assert!(is_mar(&raw), "real sample detected as MAR");
553        let (buf, arc) = parse(&raw).expect("real MAR parses (all fork CRCs verified)");
554        assert_eq!(arc.entries.len(), 1, "Bumpaint is a single application");
555        let e = &arc.entries[0];
556        assert_eq!(e.name, "Bumpaint 1.0");
557        assert_eq!(&e.type_code, b"APPL");
558        assert_eq!(&e.creator_code, b"BUMP");
559        let data = e.data.as_ref().expect("APPL has a data fork");
560        let rsrc = e.rsrc.as_ref().expect("APPL has a resource fork");
561        // 0x0002983a / 0x000143ee from the header (parse already CRC-checked).
562        assert_eq!(data.uncompressed_len, 170_042);
563        assert_eq!(rsrc.uncompressed_len, 82_926);
564        assert_eq!(decompress_fork(&buf, data).unwrap().len(), 170_042);
565        assert_eq!(decompress_fork(&buf, rsrc).unwrap().len(), 82_926);
566    }
567}