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        },
160    ))
161}
162
163/// Parse one record at `*pos` (advancing it past the record and any fork
164/// blocks / nested children), appending the entry (and, for folders, its
165/// descendants) to `entries`.
166fn parse_record(
167    data: &[u8],
168    pos: &mut usize,
169    parent_path: &[String],
170    entries: &mut Vec<StuffItEntry>,
171) -> Result<()> {
172    if *pos + HEADER_LEN > data.len() {
173        bail!("truncated MAR record header");
174    }
175    let hdr = &data[*pos..*pos + HEADER_LEN];
176    if !is_mar_header(hdr) {
177        bail!("bad MAR record header (magic/CRC)");
178    }
179    let id = u32_be(hdr, 4);
180    *pos += HEADER_LEN;
181
182    let name = read_name(hdr);
183    let mut path = parent_path.to_vec();
184    path.push(name.clone());
185
186    let create_date = u32_be(hdr, 88);
187    let mod_date = u32_be(hdr, 92);
188    let type_code = [hdr[96], hdr[97], hdr[98], hdr[99]];
189    let creator_code = [hdr[100], hdr[101], hdr[102], hdr[103]];
190    let finder_flags = u16_be(hdr, 104);
191
192    match id {
193        MAR_FILE => {
194            let data_size = u32_be(hdr, 72);
195            let rsrc_size = u32_be(hdr, 76);
196            let comment_size = u32_be(hdr, 80);
197            let data_fork = read_block(data, pos, data_size, "data")?;
198            let rsrc_fork = read_block(data, pos, rsrc_size, "resource")?;
199            // Comments are decoded + CRC-verified for integrity but dropped
200            // (the archive model has nowhere to put a Finder comment).
201            let _ = read_block(data, pos, comment_size, "comment")?;
202            entries.push(StuffItEntry {
203                path,
204                name,
205                is_dir: false,
206                type_code,
207                creator_code,
208                finder_flags,
209                create_date,
210                mod_date,
211                data: data_fork,
212                rsrc: rsrc_fork,
213            });
214        }
215        MAR_FOLDER_START => {
216            entries.push(StuffItEntry {
217                path: path.clone(),
218                name,
219                is_dir: true,
220                type_code,
221                creator_code,
222                finder_flags,
223                create_date,
224                mod_date,
225                data: None,
226                rsrc: None,
227            });
228            loop {
229                if *pos + HEADER_LEN > data.len() {
230                    bail!("MAR folder is not terminated (missing '----' record)");
231                }
232                let child = &data[*pos..*pos + HEADER_LEN];
233                if !is_mar_header(child) {
234                    bail!("bad MAR record header inside folder");
235                }
236                if u32_be(child, 4) == MAR_FOLDER_END {
237                    *pos += HEADER_LEN;
238                    break;
239                }
240                parse_record(data, pos, &path, entries)?;
241            }
242        }
243        MAR_FOLDER_END => bail!("unexpected MAR '----' (folder end) record"),
244        other => bail!("unknown MAR record id {other:#010x}"),
245    }
246    Ok(())
247}
248
249/// Read the name P-string out of a record header (Mac-Roman → UTF-8).
250fn read_name(hdr: &[u8]) -> String {
251    let len = (hdr[NAME_OFF] as usize).min(NAME_MAX);
252    decode_mac_filename(&hdr[NAME_OFF + 1..NAME_OFF + 1 + len])
253}
254
255/// Read one fork/comment block: `size` payload bytes, a 4-byte big-endian
256/// MAR CRC-32 (verified), then zero padding to a 128-byte boundary. Advances
257/// `*pos` past the whole block. Returns a stored [`ForkInfo`] pointing at the
258/// payload, or `None` for an empty block.
259fn read_block(data: &[u8], pos: &mut usize, size: u32, what: &str) -> Result<Option<ForkInfo>> {
260    if size == 0 {
261        return Ok(None);
262    }
263    let size = size as usize;
264    let payload_start = *pos;
265    let crc_at = payload_start + size;
266    let pad = (128 - ((size + 4) & 127)) & 127;
267    let block_end = crc_at + 4 + pad;
268    if block_end > data.len() {
269        bail!("MAR {what} block extends past end of archive");
270    }
271    let payload = &data[payload_start..crc_at];
272    let want = u32_be(data, crc_at);
273    let got = mar_crc32(payload);
274    if got != want {
275        bail!("MAR {what} fork CRC mismatch (got {got:#010x}, expected {want:#010x})");
276    }
277    *pos = block_end;
278    Ok(Some(ForkInfo {
279        method: 0,
280        codec: ForkCodec::StuffIt,
281        encrypted: false,
282        uncompressed_len: size as u32,
283        compressed_len: size as u32,
284        // The shared store-path decoder (`stuffit::decompress_fork`) re-checks
285        // CRC-16/ARC, so seed it with the value of the verbatim payload.
286        crc: crc16_arc(payload),
287        crc32: 0,
288        offset: payload_start as u64,
289    }))
290}
291
292// ---------------------------------------------------------------------------
293// Writer (stored)
294// ---------------------------------------------------------------------------
295
296/// Build a stored MAR archive. A MAR holds a single root item: with one input
297/// node the node becomes the root; with several, they're wrapped in a folder
298/// named `root_name`. Forks are stored uncompressed (this tool never writes the
299/// optional LZW/LZ4 wrapper). The result round-trips through [`parse`] and is
300/// read by the `mar` tool.
301pub fn build_archive(root_name: &str, tree: &[StuffItInputNode]) -> Result<Vec<u8>> {
302    if tree.is_empty() {
303        bail!("cannot build an empty MAR archive");
304    }
305    let mut out = Vec::new();
306    if tree.len() == 1 {
307        emit_node(&mut out, &tree[0])?;
308    } else {
309        emit_folder(&mut out, root_name, 0, 0, 0, tree)?;
310    }
311    Ok(out)
312}
313
314fn emit_node(out: &mut Vec<u8>, node: &StuffItInputNode) -> Result<()> {
315    match node {
316        StuffItInputNode::File(f) => {
317            let mut hdr = [0u8; HEADER_LEN];
318            put_u32(&mut hdr, 0, MAR_MAGIC);
319            put_u32(&mut hdr, 4, MAR_FILE);
320            write_name(&mut hdr, &f.name)?;
321            put_u32(&mut hdr, 72, f.data_fork.len() as u32);
322            put_u32(&mut hdr, 76, f.resource_fork.len() as u32);
323            // comment_size (80) and file_flags (84) stay 0.
324            put_u32(&mut hdr, 88, f.create_date);
325            put_u32(&mut hdr, 92, f.mod_date);
326            hdr[96..100].copy_from_slice(&f.type_code);
327            hdr[100..104].copy_from_slice(&f.creator_code);
328            put_u16(&mut hdr, 104, f.finder_flags);
329            seal_header(&mut hdr);
330            out.extend_from_slice(&hdr);
331            write_block(out, &f.data_fork);
332            write_block(out, &f.resource_fork);
333            // No comment block (size 0 -> omitted, matching the mar encoder).
334            Ok(())
335        }
336        StuffItInputNode::Folder {
337            name,
338            finder_flags,
339            create_date,
340            mod_date,
341            children,
342        } => emit_folder(out, name, *finder_flags, *create_date, *mod_date, children),
343    }
344}
345
346fn emit_folder(
347    out: &mut Vec<u8>,
348    name: &str,
349    finder_flags: u16,
350    create_date: u32,
351    mod_date: u32,
352    children: &[StuffItInputNode],
353) -> Result<()> {
354    let mut hdr = [0u8; HEADER_LEN];
355    put_u32(&mut hdr, 0, MAR_MAGIC);
356    put_u32(&mut hdr, 4, MAR_FOLDER_START);
357    write_name(&mut hdr, name)?;
358    // sizes (72/76/80) and flags (84) stay 0 for a folder.
359    put_u32(&mut hdr, 88, create_date);
360    put_u32(&mut hdr, 92, mod_date);
361    put_u16(&mut hdr, 104, finder_flags);
362    seal_header(&mut hdr);
363    out.extend_from_slice(&hdr);
364
365    for child in children {
366        emit_node(out, child)?;
367    }
368
369    let mut end = [0u8; HEADER_LEN];
370    put_u32(&mut end, 0, MAR_MAGIC);
371    put_u32(&mut end, 4, MAR_FOLDER_END);
372    seal_header(&mut end);
373    out.extend_from_slice(&end);
374    Ok(())
375}
376
377/// Stamp a header's CRC-32 over bytes 0..124 into bytes 124..128.
378fn seal_header(hdr: &mut [u8; HEADER_LEN]) {
379    let crc = mar_crc32(&hdr[..124]);
380    put_u32(hdr, 124, crc);
381}
382
383/// Write a fork/comment block: payload, its CRC-32, then zero padding to a
384/// 128-byte boundary. An empty fork writes nothing (matching the reader).
385fn write_block(out: &mut Vec<u8>, payload: &[u8]) {
386    if payload.is_empty() {
387        return;
388    }
389    out.extend_from_slice(payload);
390    out.extend_from_slice(&mar_crc32(payload).to_be_bytes());
391    let pad = (128 - ((payload.len() + 4) & 127)) & 127;
392    out.resize(out.len() + pad, 0);
393}
394
395/// Write a Mac filename as a Pascal string into a header's name field
396/// (offset 8, length byte + up to 63 Mac-Roman bytes).
397fn write_name(hdr: &mut [u8; HEADER_LEN], name: &str) -> Result<()> {
398    let mut bytes = utf8_to_mac_roman(name).unwrap_or_else(|_| {
399        name.chars()
400            .map(|c| if c.is_ascii() { c as u8 } else { b'_' })
401            .collect()
402    });
403    bytes.truncate(NAME_MAX);
404    hdr[NAME_OFF] = bytes.len() as u8;
405    hdr[NAME_OFF + 1..NAME_OFF + 1 + bytes.len()].copy_from_slice(&bytes);
406    Ok(())
407}
408
409fn put_u16(buf: &mut [u8], i: usize, v: u16) {
410    buf[i..i + 2].copy_from_slice(&v.to_be_bytes());
411}
412
413fn put_u32(buf: &mut [u8], i: usize, v: u32) {
414    buf[i..i + 4].copy_from_slice(&v.to_be_bytes());
415}
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420    use crate::stuffit::{decompress_fork, StuffItInput};
421
422    fn file_node(
423        name: &str,
424        ty: &[u8; 4],
425        cr: &[u8; 4],
426        data: &[u8],
427        rsrc: &[u8],
428    ) -> StuffItInputNode {
429        StuffItInputNode::File(StuffItInput {
430            name: name.to_string(),
431            type_code: *ty,
432            creator_code: *cr,
433            finder_flags: 0,
434            create_date: 0xA7E1_1134,
435            mod_date: 0xE116_2A21,
436            data_fork: data.to_vec(),
437            resource_fork: rsrc.to_vec(),
438        })
439    }
440
441    #[test]
442    fn crc32_known_header_passes_roundtrip() {
443        // A sealed header's CRC must validate via the reader's check.
444        let mut hdr = [0u8; HEADER_LEN];
445        put_u32(&mut hdr, 0, MAR_MAGIC);
446        put_u32(&mut hdr, 4, MAR_FILE);
447        seal_header(&mut hdr);
448        assert!(is_mar_header(&hdr));
449        // Corrupting any covered byte breaks the header CRC.
450        hdr[8] ^= 0x01;
451        assert!(!is_mar_header(&hdr));
452    }
453
454    #[test]
455    fn single_file_roundtrip() {
456        let data = vec![0xABu8; 5000]; // multi-block, exercises padding
457        let rsrc = b"a resource fork".to_vec();
458        let node = file_node("Test File", b"APPL", b"TEST", &data, &rsrc);
459        let bytes = build_archive("ignored", &[node]).unwrap();
460
461        assert!(is_mar(&bytes), "writer output is detected as MAR");
462        let (buf, arc) = parse(&bytes).unwrap();
463        assert_eq!(arc.entries.len(), 1);
464        let e = &arc.entries[0];
465        assert_eq!(e.name, "Test File");
466        assert!(!e.is_dir);
467        assert_eq!(&e.type_code, b"APPL");
468        assert_eq!(&e.creator_code, b"TEST");
469        assert_eq!(e.create_date, 0xA7E1_1134);
470        let df = decompress_fork(&buf, e.data.as_ref().unwrap()).unwrap();
471        assert_eq!(df, data, "data fork round-trips byte-exact");
472        let rf = decompress_fork(&buf, e.rsrc.as_ref().unwrap()).unwrap();
473        assert_eq!(rf, rsrc, "resource fork round-trips byte-exact");
474    }
475
476    #[test]
477    fn data_only_and_rsrc_only_files() {
478        // A file with no resource fork, and one with no data fork.
479        let nodes = vec![
480            file_node("DataOnly", b"TEXT", b"ttxt", b"hello", &[]),
481            file_node("RsrcOnly", b"rsrc", b"RSED", &[], b"only rsrc"),
482        ];
483        let bytes = build_archive("Holder", &nodes).unwrap();
484        let (buf, arc) = parse(&bytes).unwrap();
485        // Two files wrapped in the synthetic "Holder" folder.
486        assert!(arc.entries.iter().any(|e| e.is_dir && e.name == "Holder"));
487        let d = arc.entries.iter().find(|e| e.name == "DataOnly").unwrap();
488        assert!(d.data.is_some() && d.rsrc.is_none());
489        assert_eq!(
490            decompress_fork(&buf, d.data.as_ref().unwrap()).unwrap(),
491            b"hello"
492        );
493        let r = arc.entries.iter().find(|e| e.name == "RsrcOnly").unwrap();
494        assert!(r.data.is_none() && r.rsrc.is_some());
495        assert_eq!(
496            decompress_fork(&buf, r.rsrc.as_ref().unwrap()).unwrap(),
497            b"only rsrc"
498        );
499    }
500
501    #[test]
502    fn nested_folder_roundtrip() {
503        let inner = StuffItInputNode::Folder {
504            name: "Inner".to_string(),
505            finder_flags: 0,
506            create_date: 0,
507            mod_date: 0,
508            children: vec![file_node("Leaf", b"TEXT", b"ttxt", b"leaf data", &[])],
509        };
510        let root = StuffItInputNode::Folder {
511            name: "Root".to_string(),
512            finder_flags: 0,
513            create_date: 0,
514            mod_date: 0,
515            children: vec![file_node("Top", b"TEXT", b"ttxt", b"top data", &[]), inner],
516        };
517        let bytes = build_archive("ignored", &[root]).unwrap();
518        let (_buf, arc) = parse(&bytes).unwrap();
519        let leaf = arc.entries.iter().find(|e| e.name == "Leaf").unwrap();
520        assert_eq!(
521            leaf.path,
522            vec!["Root", "Inner", "Leaf"],
523            "nested path reconstructed"
524        );
525        let top = arc.entries.iter().find(|e| e.name == "Top").unwrap();
526        assert_eq!(top.path, vec!["Root", "Top"]);
527    }
528
529    #[test]
530    fn rejects_non_mar() {
531        assert!(parse(b"not a mar at all, definitely not 128 bytes of header....").is_err());
532        // LZW/LZ4-wrapped MAR is recognized but reported as unsupported.
533        let lzw = parse(&[0x1f, 0x9d, 0x90, 0x00]).unwrap_err().to_string();
534        assert!(lzw.contains("LZW"), "LZW wrapper reported: {lzw}");
535        let lz4 = parse(&[0x04, 0x22, 0x4d, 0x18, 0x00])
536            .unwrap_err()
537            .to_string();
538        assert!(lz4.contains("LZ4"), "LZ4 wrapper reported: {lz4}");
539    }
540
541    /// Validate against a real `.mar` if one is present locally (not committed).
542    /// Mirrors `stuffit::parse_real_samples_if_present`. `parse` verifies every
543    /// fork's MAR CRC-32, so a clean parse proves byte-correct decoding.
544    #[test]
545    fn parse_real_mar_if_present() {
546        let path = std::path::Path::new("/Users/dani/Downloads/Bumpaint_1.0.mar");
547        let Ok(raw) = std::fs::read(path) else {
548            eprintln!("skipping: {} not present", path.display());
549            return;
550        };
551        assert!(is_mar(&raw), "real sample detected as MAR");
552        let (buf, arc) = parse(&raw).expect("real MAR parses (all fork CRCs verified)");
553        assert_eq!(arc.entries.len(), 1, "Bumpaint is a single application");
554        let e = &arc.entries[0];
555        assert_eq!(e.name, "Bumpaint 1.0");
556        assert_eq!(&e.type_code, b"APPL");
557        assert_eq!(&e.creator_code, b"BUMP");
558        let data = e.data.as_ref().expect("APPL has a data fork");
559        let rsrc = e.rsrc.as_ref().expect("APPL has a resource fork");
560        // 0x0002983a / 0x000143ee from the header (parse already CRC-checked).
561        assert_eq!(data.uncompressed_len, 170_042);
562        assert_eq!(rsrc.uncompressed_len, 82_926);
563        assert_eq!(decompress_fork(&buf, data).unwrap().len(), 170_042);
564        assert_eq!(decompress_fork(&buf, rsrc).unwrap().len(), 82_926);
565    }
566}