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 damage: Vec::new(),
160 },
161 ))
162}
163
164fn 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 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
250fn 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
256fn 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 crc: crc16_arc(payload),
288 crc32: 0,
289 offset: payload_start as u64,
290 }))
291}
292
293pub 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 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 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 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
378fn seal_header(hdr: &mut [u8; HEADER_LEN]) {
380 let crc = mar_crc32(&hdr[..124]);
381 put_u32(hdr, 124, crc);
382}
383
384fn 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
396fn 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 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 hdr[8] ^= 0x01;
452 assert!(!is_mar_header(&hdr));
453 }
454
455 #[test]
456 fn single_file_roundtrip() {
457 let data = vec![0xABu8; 5000]; 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 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 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 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 #[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 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}