1use anyhow::{bail, Context, Result};
6use std::collections::HashSet;
7use std::path::{Path, PathBuf};
8
9use fs_mac::binhex;
10use fs_mac::resource_fork::{self, sanitize_filename};
11
12use super::stuffit::{self, StuffItArchive, StuffItEntry};
13use super::stuffit5;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum ForkFormat {
18 BinHex,
20 MacBinary,
22 AppleDouble,
24 Raw,
26}
27
28impl ForkFormat {
29 pub const ALL: [ForkFormat; 4] = [
30 ForkFormat::BinHex,
31 ForkFormat::MacBinary,
32 ForkFormat::AppleDouble,
33 ForkFormat::Raw,
34 ];
35
36 pub fn label(&self) -> &'static str {
37 match self {
38 ForkFormat::BinHex => "BinHex (.hqx)",
39 ForkFormat::MacBinary => "MacBinary (.bin)",
40 ForkFormat::AppleDouble => "AppleDouble (._name)",
41 ForkFormat::Raw => "Raw + .rsrc",
42 }
43 }
44}
45
46#[derive(Debug, Default, Clone, Copy)]
48pub struct ExtractStats {
49 pub files: usize,
50 pub skipped: usize,
51 pub names_sanitized: usize,
55}
56
57pub fn open(path: &Path) -> Result<(Vec<u8>, StuffItArchive)> {
62 let raw = std::fs::read(path).with_context(|| format!("reading {}", path.display()))?;
63 open_bytes(raw)
64}
65
66pub fn open_bytes(raw: Vec<u8>) -> Result<(Vec<u8>, StuffItArchive)> {
72 use super::detect::{detect_mac_archive, MacArchiveKind};
73
74 match detect_mac_archive(&raw) {
75 Some(MacArchiveKind::BinHexSingleFile) => {
76 let bh = binhex::parse_binhex(&raw)?;
81 Ok(synth_single_file_archive(bh))
82 }
83 Some(MacArchiveKind::BinHexOverSit) | Some(MacArchiveKind::BinHexOverSea) => {
84 let bh = binhex::parse_binhex(&raw)?;
86 parse_sit_family(bh.data_fork)
87 }
88 Some(MacArchiveKind::BinHexOverCompactPro) => {
89 let bh = binhex::parse_binhex(&raw)?;
91 let archive = super::compactpro::parse(&bh.data_fork)?;
92 Ok((bh.data_fork, archive))
93 }
94 Some(MacArchiveKind::CompactPro) => {
95 let archive = super::compactpro::parse(&raw)?;
96 Ok((raw, archive))
97 }
98 Some(MacArchiveKind::Mar) => super::mar::parse(&raw),
99 Some(MacArchiveKind::MacZip) => super::maczip::parse(&raw),
100 Some(MacArchiveKind::MacBinary) => {
101 let mb = super::macbinary::parse(&raw)?;
102 match detect_mac_archive(&mb.data_fork) {
107 Some(MacArchiveKind::Sit)
108 | Some(MacArchiveKind::Sit5)
109 | Some(MacArchiveKind::Sea) => parse_sit_family(mb.data_fork),
110 Some(MacArchiveKind::CompactPro) => {
111 let archive = super::compactpro::parse(&mb.data_fork)?;
112 Ok((mb.data_fork, archive))
113 }
114 _ => Ok(synth_from_macbinary(mb)),
118 }
119 }
120 Some(MacArchiveKind::Sit) | Some(MacArchiveKind::Sit5) | Some(MacArchiveKind::Sea) => {
121 parse_sit_family(raw)
122 }
123 None => {
124 if is_stuffitx(&raw) {
125 bail!(
126 "StuffIt X (.sitx) recognized, but native extraction is not yet \
127 implemented (its catalog + data streams use the Brimstone / \
128 PPMd-variant-G codec). Use `unar` for now."
129 );
130 }
131 bail!("not a recognized Mac archive (BinHex / StuffIt / SEA)");
132 }
133 }
134}
135
136fn parse_sit_family(bytes: Vec<u8>) -> Result<(Vec<u8>, StuffItArchive)> {
138 let archive = if stuffit5::is_stuffit5(&bytes) {
139 stuffit5::parse(&bytes)?
140 } else if stuffit::find_sea_archive(&bytes).is_some() {
141 stuffit::parse(&bytes)?
142 } else {
143 bail!("not a recognized StuffIt archive (classic SIT! / StuffIt 5)");
144 };
145 Ok((bytes, archive))
146}
147
148fn synth_single_file_archive(bh: binhex::BinHexFile) -> (Vec<u8>, StuffItArchive) {
154 synth_single_entry(
155 bh.name,
156 bh.type_code,
157 bh.creator_code,
158 bh.flags,
159 0,
160 0,
161 &bh.data_fork,
162 &bh.resource_fork,
163 )
164}
165
166fn synth_from_macbinary(mb: super::macbinary::MacBinaryFile) -> (Vec<u8>, StuffItArchive) {
171 synth_single_entry(
172 mb.filename,
173 mb.type_code,
174 mb.creator_code,
175 mb.finder_flags,
176 mb.create_date,
177 mb.modify_date,
178 &mb.data_fork,
179 &mb.resource_fork,
180 )
181}
182
183#[allow(clippy::too_many_arguments)]
188fn synth_single_entry(
189 name: String,
190 type_code: [u8; 4],
191 creator_code: [u8; 4],
192 finder_flags: u16,
193 create_date: u32,
194 mod_date: u32,
195 data_fork: &[u8],
196 resource_fork: &[u8],
197) -> (Vec<u8>, StuffItArchive) {
198 let data_len = data_fork.len() as u32;
199 let rsrc_len = resource_fork.len() as u32;
200 let data_crc = stuffit::crc16_arc(data_fork);
201 let rsrc_crc = stuffit::crc16_arc(resource_fork);
202 let mut buf = Vec::with_capacity(data_fork.len() + resource_fork.len());
203 buf.extend_from_slice(data_fork);
204 buf.extend_from_slice(resource_fork);
205 let entry = stuffit::StuffItEntry {
206 path: vec![name.clone()],
207 name,
208 is_dir: false,
209 type_code,
210 creator_code,
211 finder_flags,
212 create_date,
213 mod_date,
214 data: Some(stuffit::ForkInfo {
215 method: 0,
216 codec: stuffit::ForkCodec::StuffIt,
217 encrypted: false,
218 uncompressed_len: data_len,
219 compressed_len: data_len,
220 crc: data_crc,
221 crc32: 0,
222 offset: 0,
223 }),
224 rsrc: if rsrc_len > 0 {
225 Some(stuffit::ForkInfo {
226 method: 0,
227 codec: stuffit::ForkCodec::StuffIt,
228 encrypted: false,
229 uncompressed_len: rsrc_len,
230 compressed_len: rsrc_len,
231 crc: rsrc_crc,
232 crc32: 0,
233 offset: data_len as u64,
234 })
235 } else {
236 None
237 },
238 };
239 (
240 buf,
241 StuffItArchive {
242 entries: vec![entry],
243 truncated: None,
244 damage: Vec::new(),
245 },
246 )
247}
248
249pub fn is_stuffitx(data: &[u8]) -> bool {
252 data.len() >= 8 && &data[..7] == b"StuffIt" && (data[7] == b'!' || data[7] == b'?')
253}
254
255pub fn extract_all(
258 bytes: &[u8],
259 archive: &StuffItArchive,
260 dest: &Path,
261 format: ForkFormat,
262 progress: impl FnMut(usize, usize, &str),
263 log: impl FnMut(String),
264) -> Result<ExtractStats> {
265 extract_filtered(bytes, archive, dest, format, |_| true, progress, log)
266}
267
268pub fn extract_filtered(
275 bytes: &[u8],
276 archive: &StuffItArchive,
277 dest: &Path,
278 format: ForkFormat,
279 mut keep: impl FnMut(usize) -> bool,
280 mut progress: impl FnMut(usize, usize, &str),
281 mut log: impl FnMut(String),
282) -> Result<ExtractStats> {
283 if let Some(t) = archive.truncated {
287 log(t.describe());
288 }
289 for d in &archive.damage {
290 log(d.describe());
291 }
292
293 std::fs::create_dir_all(dest).with_context(|| format!("creating {}", dest.display()))?;
294
295 let total = archive
296 .entries
297 .iter()
298 .enumerate()
299 .filter(|(i, e)| !e.is_dir && keep(*i))
300 .count();
301
302 let reserved_data_paths: HashSet<PathBuf> = if format == ForkFormat::Raw {
309 archive
310 .entries
311 .iter()
312 .enumerate()
313 .filter(|(i, e)| !e.is_dir && keep(*i))
314 .map(|(_, e)| entry_target(dest, e))
315 .collect()
316 } else {
317 HashSet::new()
318 };
319 let mut used_sidecars: HashSet<PathBuf> = HashSet::new();
320
321 let mut stats = ExtractStats::default();
322 let mut done = 0usize;
323
324 for (idx, e) in archive.entries.iter().enumerate() {
325 let target = entry_target(dest, e);
326
327 if e.is_dir {
328 if let Some(s) = sanitized_if_changed(&e.name) {
331 log(format!(
332 "Warning: \"{}\" -> \"{}\" (character invalid on this filesystem)",
333 e.name, s
334 ));
335 stats.names_sanitized += 1;
336 }
337 std::fs::create_dir_all(&target)?;
338 continue;
339 }
340 if !keep(idx) {
341 continue;
342 }
343 if let Some(s) = sanitized_if_changed(&e.name) {
344 log(format!(
345 "Warning: \"{}\" -> \"{}\" (character invalid on this filesystem)",
346 e.name, s
347 ));
348 stats.names_sanitized += 1;
349 }
350 progress(done, total, &e.name);
351 done += 1;
352 if let Some(parent) = target.parent() {
353 std::fs::create_dir_all(parent)?;
354 }
355
356 let data = match decompress_named(bytes, e.data.as_ref(), e, &mut log) {
357 Ok(d) => d,
358 Err(()) => {
359 stats.skipped += 1;
360 continue;
361 }
362 };
363 let rsrc = match decompress_named(bytes, e.rsrc.as_ref(), e, &mut log) {
364 Ok(d) => d,
365 Err(()) => {
366 stats.skipped += 1;
367 continue;
368 }
369 };
370
371 if let Some(expected) = compactpro_entry_crc(e) {
376 if let Err(err) = super::compactpro::verify_entry_crc(&rsrc, &data, expected) {
377 log(format!("Skipped {}: {err}", e.display_path()));
378 stats.skipped += 1;
379 continue;
380 }
381 }
382
383 let raw_rsrc_path = if format == ForkFormat::Raw && !rsrc.is_empty() {
388 let natural = with_added_extension(&target, "rsrc");
389 let chosen = raw_rsrc_sidecar_path(&target, &reserved_data_paths, &mut used_sidecars);
390 if chosen != natural {
391 log(format!(
392 "Note: resource fork of '{}' written as '{}' to avoid overwriting a sibling file named '{}'",
393 e.display_path(),
394 chosen.file_name().unwrap_or_default().to_string_lossy(),
395 natural.file_name().unwrap_or_default().to_string_lossy(),
396 ));
397 }
398 Some(chosen)
399 } else {
400 None
401 };
402
403 write_entry(&target, e, &data, &rsrc, format, raw_rsrc_path.as_deref())?;
404 stats.files += 1;
405 }
406
407 if stats.names_sanitized > 0 {
408 log(format!(
409 "{} name(s) contain characters this filesystem can't store and were changed. \
410 To keep the original names, leave the archive compressed, or extract into an \
411 HFS/HFS+ disk image where these characters are legal.",
412 stats.names_sanitized
413 ));
414 }
415
416 Ok(stats)
417}
418
419fn compactpro_entry_crc(e: &StuffItEntry) -> Option<u32> {
422 [e.rsrc.as_ref(), e.data.as_ref()]
423 .into_iter()
424 .flatten()
425 .find(|f| matches!(f.codec, stuffit::ForkCodec::CompactPro { .. }))
426 .map(|f| f.crc32)
427}
428
429fn decompress_named(
430 bytes: &[u8],
431 fork: Option<&stuffit::ForkInfo>,
432 e: &StuffItEntry,
433 log: &mut impl FnMut(String),
434) -> Result<Vec<u8>, ()> {
435 match fork {
436 Some(f) if f.uncompressed_len > 0 => {
437 let result = match f.codec {
438 stuffit::ForkCodec::StuffIt => stuffit::decompress_fork(bytes, f),
439 stuffit::ForkCodec::CompactPro { .. } => {
440 super::compactpro::decompress_fork(bytes, f)
441 }
442 };
443 match result {
444 Ok(d) => Ok(d),
445 Err(err) => {
446 log(format!("Skipped {}: {err}", e.display_path()));
447 Err(())
448 }
449 }
450 }
451 _ => Ok(Vec::new()),
452 }
453}
454
455pub fn write_entry(
462 target: &Path,
463 entry: &StuffItEntry,
464 data: &[u8],
465 rsrc: &[u8],
466 format: ForkFormat,
467 raw_rsrc_path: Option<&Path>,
468) -> Result<()> {
469 match format {
470 ForkFormat::BinHex => {
471 let bh = binhex::BinHexFile {
472 name: entry.name.clone(),
473 type_code: entry.type_code,
474 creator_code: entry.creator_code,
475 flags: entry.finder_flags,
476 data_fork: data.to_vec(),
477 resource_fork: rsrc.to_vec(),
478 };
479 std::fs::write(
480 with_added_extension(target, "hqx"),
481 binhex::build_binhex(&bh).as_bytes(),
482 )?;
483 }
484 ForkFormat::MacBinary => {
485 let mb = resource_fork::build_macbinary(
486 &entry.name,
487 &entry.type_code,
488 &entry.creator_code,
489 resource_fork::MacFileDates::default(),
490 data,
491 rsrc,
492 );
493 std::fs::write(with_added_extension(target, "bin"), mb)?;
494 }
495 ForkFormat::AppleDouble => {
496 std::fs::write(target, data)?;
497 if !rsrc.is_empty() || entry.type_code != [0; 4] || entry.creator_code != [0; 4] {
498 let ad = resource_fork::build_appledouble(
499 &entry.type_code,
500 &entry.creator_code,
501 resource_fork::MacFileDates::default(),
502 rsrc,
503 );
504 std::fs::write(sidecar_path(target, "._"), ad)?;
505 }
506 }
507 ForkFormat::Raw => {
508 std::fs::write(target, data)?;
509 if !rsrc.is_empty() {
510 let rsrc_dst = raw_rsrc_path
511 .map(Path::to_path_buf)
512 .unwrap_or_else(|| with_added_extension(target, "rsrc"));
513 std::fs::write(rsrc_dst, rsrc)?;
514 }
515 }
516 }
517 Ok(())
518}
519
520fn sanitized_if_changed(name: &str) -> Option<String> {
527 let s = sanitize_filename(name);
528 (s != name).then_some(s)
529}
530
531fn entry_target(dest: &Path, entry: &StuffItEntry) -> PathBuf {
532 let mut rel = PathBuf::new();
533 for comp in &entry.path {
534 rel.push(sanitize_filename(comp));
535 }
536 dest.join(rel)
537}
538
539fn raw_rsrc_sidecar_path(
545 target: &Path,
546 reserved: &HashSet<PathBuf>,
547 used: &mut HashSet<PathBuf>,
548) -> PathBuf {
549 let base = with_added_extension(target, "rsrc");
550 let mut candidate = base.clone();
551 let mut n = 1u32;
552 while reserved.contains(&candidate) || used.contains(&candidate) {
553 candidate = with_added_extension(&base, &n.to_string());
554 n += 1;
555 }
556 used.insert(candidate.clone());
557 candidate
558}
559
560fn with_added_extension(path: &Path, ext: &str) -> PathBuf {
562 let mut name = path.file_name().unwrap_or_default().to_os_string();
563 name.push(".");
564 name.push(ext);
565 path.with_file_name(name)
566}
567
568fn sidecar_path(target: &Path, prefix: &str) -> PathBuf {
570 let name = target.file_name().unwrap_or_default().to_string_lossy();
571 target.with_file_name(format!("{prefix}{name}"))
572}
573
574#[cfg(test)]
575mod tests {
576 use super::*;
577
578 #[test]
583 fn open_bytes_synthesizes_single_entry_archive_for_loose_binhex() {
584 let bh = binhex::BinHexFile {
585 name: "Document.txt".into(),
586 type_code: *b"TEXT",
587 creator_code: *b"ttxt",
588 flags: 0x0042,
589 data_fork: b"hello mac world".to_vec(),
590 resource_fork: b"RSRC".to_vec(),
591 };
592 let hqx = binhex::build_binhex(&bh).into_bytes();
593
594 let (buf, archive) = open_bytes(hqx).expect("loose hqx should open");
595 assert_eq!(archive.entries.len(), 1, "synthesized archive is one entry");
596 let entry = &archive.entries[0];
597 assert_eq!(entry.name, "Document.txt");
598 assert_eq!(entry.type_code, *b"TEXT");
599 assert_eq!(entry.creator_code, *b"ttxt");
600 assert_eq!(entry.finder_flags, 0x0042);
601 assert!(!entry.is_dir);
602
603 let data = stuffit::decompress_fork(&buf, entry.data.as_ref().unwrap())
604 .expect("data fork round-trips through method=0");
605 assert_eq!(data, b"hello mac world");
606
607 let rsrc = stuffit::decompress_fork(&buf, entry.rsrc.as_ref().unwrap())
608 .expect("rsrc fork round-trips through method=0");
609 assert_eq!(rsrc, b"RSRC");
610 }
611
612 #[test]
616 fn open_bytes_empty_rsrc_omits_fork() {
617 let bh = binhex::BinHexFile {
618 name: "data.bin".into(),
619 type_code: [0; 4],
620 creator_code: [0; 4],
621 flags: 0,
622 data_fork: vec![1, 2, 3, 4],
623 resource_fork: Vec::new(),
624 };
625 let hqx = binhex::build_binhex(&bh).into_bytes();
626 let (_buf, archive) = open_bytes(hqx).expect("open");
627 assert!(
628 archive.entries[0].rsrc.is_none(),
629 "empty rsrc folds to None"
630 );
631 assert!(archive.entries[0].data.is_some(), "data fork still present");
632 }
633
634 #[test]
639 fn extract_filtered_writes_only_kept_entries() {
640 use crate::stuffit::{build_archive_tree, StuffItInput, StuffItInputNode, WriteMethod};
641 let mk = |name: &str, body: &[u8]| StuffItInput {
644 name: name.into(),
645 type_code: [0; 4],
646 creator_code: [0; 4],
647 finder_flags: 0,
648 create_date: 0,
649 mod_date: 0,
650 data_fork: body.to_vec(),
651 resource_fork: Vec::new(),
652 };
653 let tree = vec![
654 StuffItInputNode::File(mk("file1.txt", b"AAA")),
655 StuffItInputNode::File(mk("file2.txt", b"BBB")),
656 StuffItInputNode::Folder {
657 name: "subdir".into(),
658 finder_flags: 0,
659 create_date: 0,
660 mod_date: 0,
661 children: vec![StuffItInputNode::File(mk("file3.txt", b"CCC"))],
662 },
663 ];
664 let arc_bytes = build_archive_tree(&tree, WriteMethod::Store).expect("build");
665 let (bytes, archive) = open_bytes(arc_bytes).expect("open");
666
667 let keep_names: std::collections::HashSet<&str> =
670 ["file1.txt", "file3.txt"].into_iter().collect();
671 let keep_idx: std::collections::HashSet<usize> = archive
672 .entries
673 .iter()
674 .enumerate()
675 .filter(|(_, e)| keep_names.contains(e.name.as_str()))
676 .map(|(i, _)| i)
677 .collect();
678
679 let dir = tempfile::tempdir().expect("tempdir");
680 let stats = extract_filtered(
681 &bytes,
682 &archive,
683 dir.path(),
684 ForkFormat::Raw,
685 |i| keep_idx.contains(&i),
686 |_, _, _| {},
687 |_| {},
688 )
689 .expect("extract");
690 assert_eq!(stats.files, 2);
691 assert_eq!(stats.skipped, 0);
692
693 assert!(dir.path().join("file1.txt").exists());
694 assert!(
695 !dir.path().join("file2.txt").exists(),
696 "file2 should be skipped"
697 );
698 assert!(dir.path().join("subdir/file3.txt").exists());
699 }
700
701 #[test]
705 fn extract_warns_and_counts_host_illegal_names() {
706 use crate::stuffit::{build_archive_tree, StuffItInput, StuffItInputNode, WriteMethod};
707 let tree = vec![StuffItInputNode::File(StuffItInput {
708 name: "Read/Write Me".into(),
709 type_code: *b"TEXT",
710 creator_code: *b"ttxt",
711 finder_flags: 0,
712 create_date: 0,
713 mod_date: 0,
714 data_fork: b"hi".to_vec(),
715 resource_fork: Vec::new(),
716 })];
717 let arc_bytes = build_archive_tree(&tree, WriteMethod::Store).expect("build");
718 let (bytes, archive) = open_bytes(arc_bytes).expect("open");
719
720 let dir = tempfile::tempdir().expect("tempdir");
721 let mut logs: Vec<String> = Vec::new();
722 let stats = extract_all(
723 &bytes,
724 &archive,
725 dir.path(),
726 ForkFormat::Raw,
727 |_, _, _| {},
728 |m| logs.push(m),
729 )
730 .expect("extract");
731
732 assert_eq!(stats.files, 1);
733 assert_eq!(stats.names_sanitized, 1);
734 assert!(dir.path().join("Read_Write Me").exists());
735 assert!(
736 logs.iter()
737 .any(|l| l.contains("Read/Write Me") && l.contains("Read_Write Me")),
738 "per-file warning logged"
739 );
740 assert!(
741 logs.iter().any(|l| l.contains("HFS/HFS+ disk image")),
742 "closing recommendation logged"
743 );
744 }
745
746 #[test]
753 fn raw_extract_preserves_forks_on_dot_rsrc_name_collision() {
754 use crate::stuffit::{build_archive_tree, StuffItInputNode, WriteMethod};
755 let mk = |name: &str, data: &[u8], rsrc: &[u8]| crate::stuffit::StuffItInput {
756 name: name.into(),
757 type_code: [0; 4],
758 creator_code: [0; 4],
759 finder_flags: 0,
760 create_date: 0,
761 mod_date: 0,
762 data_fork: data.to_vec(),
763 resource_fork: rsrc.to_vec(),
764 };
765 let tree = vec![
768 StuffItInputNode::File(mk("Doc", b"DOC-DATA", b"DOC-RSRC")),
769 StuffItInputNode::File(mk("Doc.rsrc", b"SIBLING-DATA", b"")),
770 ];
771 let arc_bytes = build_archive_tree(&tree, WriteMethod::Store).expect("build");
772 let (bytes, archive) = open_bytes(arc_bytes).expect("open");
773
774 let dir = tempfile::tempdir().expect("tempdir");
775 let stats = extract_all(
776 &bytes,
777 &archive,
778 dir.path(),
779 ForkFormat::Raw,
780 |_, _, _| {},
781 |_| {},
782 )
783 .expect("extract");
784 assert_eq!(stats.files, 2);
785 assert_eq!(stats.skipped, 0);
786
787 assert_eq!(
789 std::fs::read(dir.path().join("Doc.rsrc")).unwrap(),
790 b"SIBLING-DATA",
791 "sibling data fork must not be clobbered"
792 );
793 assert_eq!(std::fs::read(dir.path().join("Doc")).unwrap(), b"DOC-DATA");
795 assert_eq!(
797 std::fs::read(dir.path().join("Doc.rsrc.1")).unwrap(),
798 b"DOC-RSRC",
799 "Doc resource fork must be preserved at a non-colliding path"
800 );
801 }
802
803 fn build_macbinary_ii(name: &[u8], flags: u16, data: &[u8], rsrc: &[u8]) -> Vec<u8> {
805 use byteorder::{BigEndian, ByteOrder};
806 let mut hdr = [0u8; 128];
807 hdr[1] = name.len() as u8;
808 hdr[2..2 + name.len()].copy_from_slice(name);
809 hdr[65..69].copy_from_slice(b"APPL");
810 hdr[69..73].copy_from_slice(b"Po.P");
811 hdr[73] = (flags >> 8) as u8;
812 hdr[101] = (flags & 0xFF) as u8;
813 BigEndian::write_u32(&mut hdr[83..87], data.len() as u32);
814 BigEndian::write_u32(&mut hdr[87..91], rsrc.len() as u32);
815 BigEndian::write_u32(&mut hdr[91..95], 0x1111_1111); BigEndian::write_u32(&mut hdr[95..99], 0x2222_2222); hdr[122] = 129;
818 hdr[123] = 129;
819 let crc = fs_mac::resource_fork::macbinary_crc16(&hdr[0..124]);
820 BigEndian::write_u16(&mut hdr[124..126], crc);
821 let mut out = hdr.to_vec();
822 out.extend_from_slice(data);
823 while out.len() % 128 != 0 {
824 out.push(0);
825 }
826 out.extend_from_slice(rsrc);
827 while out.len() % 128 != 0 {
828 out.push(0);
829 }
830 out
831 }
832
833 #[test]
837 fn open_bytes_synthesizes_single_entry_from_macbinary() {
838 let mb = build_macbinary_ii(b"Prince", 0x2000, b"data-fork-bytes", b"RSRCDATA");
839 let (buf, archive) = open_bytes(mb).expect("MacBinary should open");
840 assert_eq!(archive.entries.len(), 1);
841 let e = &archive.entries[0];
842 assert_eq!(e.name, "Prince");
843 assert_eq!(&e.type_code, b"APPL");
844 assert_eq!(&e.creator_code, b"Po.P");
845 assert_eq!(e.finder_flags, 0x2000, "hasBundle preserved");
846 assert_eq!(e.create_date, 0x1111_1111);
847 assert_eq!(e.mod_date, 0x2222_2222);
848 let data = stuffit::decompress_fork(&buf, e.data.as_ref().unwrap()).unwrap();
849 assert_eq!(data, b"data-fork-bytes");
850 let rsrc = stuffit::decompress_fork(&buf, e.rsrc.as_ref().unwrap()).unwrap();
851 assert_eq!(rsrc, b"RSRCDATA");
852 }
853
854 #[test]
858 fn open_bytes_peels_macbinary_over_sit() {
859 use crate::stuffit::{build_archive_tree, StuffItInput, StuffItInputNode, WriteMethod};
860 let mk = |name: &str, body: &[u8]| StuffItInput {
861 name: name.into(),
862 type_code: [0; 4],
863 creator_code: [0; 4],
864 finder_flags: 0,
865 create_date: 0,
866 mod_date: 0,
867 data_fork: body.to_vec(),
868 resource_fork: Vec::new(),
869 };
870 let inner = build_archive_tree(
871 &[
872 StuffItInputNode::File(mk("inner1.txt", b"AAA")),
873 StuffItInputNode::File(mk("inner2.txt", b"BBB")),
874 ],
875 WriteMethod::Store,
876 )
877 .expect("build inner SIT");
878 let mb = build_macbinary_ii(b"Bundle.sit.bin", 0, &inner, b"");
879 let (_buf, archive) = open_bytes(mb).expect("MacBinary-over-SIT should open");
880 let names: std::collections::HashSet<&str> =
881 archive.entries.iter().map(|e| e.name.as_str()).collect();
882 assert!(
883 names.contains("inner1.txt") && names.contains("inner2.txt"),
884 "should expand to inner SIT entries, got {names:?}"
885 );
886 }
887
888 #[test]
891 fn open_bytes_rejects_non_archive_bytes_with_clear_message() {
892 let junk = b"this is not a Mac archive of any kind".to_vec();
893 let err = open_bytes(junk).expect_err("should reject");
894 let msg = format!("{err}");
895 assert!(
896 msg.contains("not a recognized Mac archive"),
897 "expected magic-driven error, got: {msg}"
898 );
899 }
900}