1use 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; const MAR_FILE: u32 = 0x6669_6c65; const MAR_FOLDER_START: u32 = 0x666f_6c64; const MAR_FOLDER_END: u32 = 0x2d2d_2d2d; const HEADER_LEN: usize = 128;
56const NAME_OFF: usize = 8;
57const NAME_MAX: usize = 63;
58
59const LZW_MAGIC: [u8; 2] = [0x1f, 0x9d];
61const LZ4_MAGIC: [u8; 4] = [0x04, 0x22, 0x4d, 0x18];
63
64const 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
107fn 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
115pub fn is_mar(data: &[u8]) -> bool {
120 is_mar_header(data)
121}
122
123pub 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
163fn 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 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
249fn 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
255fn 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 crc: crc16_arc(payload),
287 crc32: 0,
288 offset: payload_start as u64,
289 }))
290}
291
292pub 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 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 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 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
377fn seal_header(hdr: &mut [u8; HEADER_LEN]) {
379 let crc = mar_crc32(&hdr[..124]);
380 put_u32(hdr, 124, crc);
381}
382
383fn 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
395fn 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 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 hdr[8] ^= 0x01;
451 assert!(!is_mar_header(&hdr));
452 }
453
454 #[test]
455 fn single_file_roundtrip() {
456 let data = vec![0xABu8; 5000]; 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 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 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 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 #[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 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}