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 },
245 )
246}
247
248pub fn is_stuffitx(data: &[u8]) -> bool {
251 data.len() >= 8 && &data[..7] == b"StuffIt" && (data[7] == b'!' || data[7] == b'?')
252}
253
254pub fn extract_all(
257 bytes: &[u8],
258 archive: &StuffItArchive,
259 dest: &Path,
260 format: ForkFormat,
261 progress: impl FnMut(usize, usize, &str),
262 log: impl FnMut(String),
263) -> Result<ExtractStats> {
264 extract_filtered(bytes, archive, dest, format, |_| true, progress, log)
265}
266
267pub fn extract_filtered(
274 bytes: &[u8],
275 archive: &StuffItArchive,
276 dest: &Path,
277 format: ForkFormat,
278 mut keep: impl FnMut(usize) -> bool,
279 mut progress: impl FnMut(usize, usize, &str),
280 mut log: impl FnMut(String),
281) -> Result<ExtractStats> {
282 if let Some(t) = archive.truncated {
286 log(t.describe());
287 }
288
289 std::fs::create_dir_all(dest).with_context(|| format!("creating {}", dest.display()))?;
290
291 let total = archive
292 .entries
293 .iter()
294 .enumerate()
295 .filter(|(i, e)| !e.is_dir && keep(*i))
296 .count();
297
298 let reserved_data_paths: HashSet<PathBuf> = if format == ForkFormat::Raw {
305 archive
306 .entries
307 .iter()
308 .enumerate()
309 .filter(|(i, e)| !e.is_dir && keep(*i))
310 .map(|(_, e)| entry_target(dest, e))
311 .collect()
312 } else {
313 HashSet::new()
314 };
315 let mut used_sidecars: HashSet<PathBuf> = HashSet::new();
316
317 let mut stats = ExtractStats::default();
318 let mut done = 0usize;
319
320 for (idx, e) in archive.entries.iter().enumerate() {
321 let target = entry_target(dest, e);
322
323 if e.is_dir {
324 if let Some(s) = sanitized_if_changed(&e.name) {
327 log(format!(
328 "Warning: \"{}\" -> \"{}\" (character invalid on this filesystem)",
329 e.name, s
330 ));
331 stats.names_sanitized += 1;
332 }
333 std::fs::create_dir_all(&target)?;
334 continue;
335 }
336 if !keep(idx) {
337 continue;
338 }
339 if let Some(s) = sanitized_if_changed(&e.name) {
340 log(format!(
341 "Warning: \"{}\" -> \"{}\" (character invalid on this filesystem)",
342 e.name, s
343 ));
344 stats.names_sanitized += 1;
345 }
346 progress(done, total, &e.name);
347 done += 1;
348 if let Some(parent) = target.parent() {
349 std::fs::create_dir_all(parent)?;
350 }
351
352 let data = match decompress_named(bytes, e.data.as_ref(), e, &mut log) {
353 Ok(d) => d,
354 Err(()) => {
355 stats.skipped += 1;
356 continue;
357 }
358 };
359 let rsrc = match decompress_named(bytes, e.rsrc.as_ref(), e, &mut log) {
360 Ok(d) => d,
361 Err(()) => {
362 stats.skipped += 1;
363 continue;
364 }
365 };
366
367 if let Some(expected) = compactpro_entry_crc(e) {
372 if let Err(err) = super::compactpro::verify_entry_crc(&rsrc, &data, expected) {
373 log(format!("Skipped {}: {err}", e.display_path()));
374 stats.skipped += 1;
375 continue;
376 }
377 }
378
379 let raw_rsrc_path = if format == ForkFormat::Raw && !rsrc.is_empty() {
384 let natural = with_added_extension(&target, "rsrc");
385 let chosen = raw_rsrc_sidecar_path(&target, &reserved_data_paths, &mut used_sidecars);
386 if chosen != natural {
387 log(format!(
388 "Note: resource fork of '{}' written as '{}' to avoid overwriting a sibling file named '{}'",
389 e.display_path(),
390 chosen.file_name().unwrap_or_default().to_string_lossy(),
391 natural.file_name().unwrap_or_default().to_string_lossy(),
392 ));
393 }
394 Some(chosen)
395 } else {
396 None
397 };
398
399 write_entry(&target, e, &data, &rsrc, format, raw_rsrc_path.as_deref())?;
400 stats.files += 1;
401 }
402
403 if stats.names_sanitized > 0 {
404 log(format!(
405 "{} name(s) contain characters this filesystem can't store and were changed. \
406 To keep the original names, leave the archive compressed, or extract into an \
407 HFS/HFS+ disk image where these characters are legal.",
408 stats.names_sanitized
409 ));
410 }
411
412 Ok(stats)
413}
414
415fn compactpro_entry_crc(e: &StuffItEntry) -> Option<u32> {
418 [e.rsrc.as_ref(), e.data.as_ref()]
419 .into_iter()
420 .flatten()
421 .find(|f| matches!(f.codec, stuffit::ForkCodec::CompactPro { .. }))
422 .map(|f| f.crc32)
423}
424
425fn decompress_named(
426 bytes: &[u8],
427 fork: Option<&stuffit::ForkInfo>,
428 e: &StuffItEntry,
429 log: &mut impl FnMut(String),
430) -> Result<Vec<u8>, ()> {
431 match fork {
432 Some(f) if f.uncompressed_len > 0 => {
433 let result = match f.codec {
434 stuffit::ForkCodec::StuffIt => stuffit::decompress_fork(bytes, f),
435 stuffit::ForkCodec::CompactPro { .. } => {
436 super::compactpro::decompress_fork(bytes, f)
437 }
438 };
439 match result {
440 Ok(d) => Ok(d),
441 Err(err) => {
442 log(format!("Skipped {}: {err}", e.display_path()));
443 Err(())
444 }
445 }
446 }
447 _ => Ok(Vec::new()),
448 }
449}
450
451pub fn write_entry(
458 target: &Path,
459 entry: &StuffItEntry,
460 data: &[u8],
461 rsrc: &[u8],
462 format: ForkFormat,
463 raw_rsrc_path: Option<&Path>,
464) -> Result<()> {
465 match format {
466 ForkFormat::BinHex => {
467 let bh = binhex::BinHexFile {
468 name: entry.name.clone(),
469 type_code: entry.type_code,
470 creator_code: entry.creator_code,
471 flags: entry.finder_flags,
472 data_fork: data.to_vec(),
473 resource_fork: rsrc.to_vec(),
474 };
475 std::fs::write(
476 with_added_extension(target, "hqx"),
477 binhex::build_binhex(&bh).as_bytes(),
478 )?;
479 }
480 ForkFormat::MacBinary => {
481 let mb = resource_fork::build_macbinary(
482 &entry.name,
483 &entry.type_code,
484 &entry.creator_code,
485 resource_fork::MacFileDates::default(),
486 data,
487 rsrc,
488 );
489 std::fs::write(with_added_extension(target, "bin"), mb)?;
490 }
491 ForkFormat::AppleDouble => {
492 std::fs::write(target, data)?;
493 if !rsrc.is_empty() || entry.type_code != [0; 4] || entry.creator_code != [0; 4] {
494 let ad = resource_fork::build_appledouble(
495 &entry.type_code,
496 &entry.creator_code,
497 resource_fork::MacFileDates::default(),
498 rsrc,
499 );
500 std::fs::write(sidecar_path(target, "._"), ad)?;
501 }
502 }
503 ForkFormat::Raw => {
504 std::fs::write(target, data)?;
505 if !rsrc.is_empty() {
506 let rsrc_dst = raw_rsrc_path
507 .map(Path::to_path_buf)
508 .unwrap_or_else(|| with_added_extension(target, "rsrc"));
509 std::fs::write(rsrc_dst, rsrc)?;
510 }
511 }
512 }
513 Ok(())
514}
515
516fn sanitized_if_changed(name: &str) -> Option<String> {
523 let s = sanitize_filename(name);
524 (s != name).then_some(s)
525}
526
527fn entry_target(dest: &Path, entry: &StuffItEntry) -> PathBuf {
528 let mut rel = PathBuf::new();
529 for comp in &entry.path {
530 rel.push(sanitize_filename(comp));
531 }
532 dest.join(rel)
533}
534
535fn raw_rsrc_sidecar_path(
541 target: &Path,
542 reserved: &HashSet<PathBuf>,
543 used: &mut HashSet<PathBuf>,
544) -> PathBuf {
545 let base = with_added_extension(target, "rsrc");
546 let mut candidate = base.clone();
547 let mut n = 1u32;
548 while reserved.contains(&candidate) || used.contains(&candidate) {
549 candidate = with_added_extension(&base, &n.to_string());
550 n += 1;
551 }
552 used.insert(candidate.clone());
553 candidate
554}
555
556fn with_added_extension(path: &Path, ext: &str) -> PathBuf {
558 let mut name = path.file_name().unwrap_or_default().to_os_string();
559 name.push(".");
560 name.push(ext);
561 path.with_file_name(name)
562}
563
564fn sidecar_path(target: &Path, prefix: &str) -> PathBuf {
566 let name = target.file_name().unwrap_or_default().to_string_lossy();
567 target.with_file_name(format!("{prefix}{name}"))
568}
569
570#[cfg(test)]
571mod tests {
572 use super::*;
573
574 #[test]
579 fn open_bytes_synthesizes_single_entry_archive_for_loose_binhex() {
580 let bh = binhex::BinHexFile {
581 name: "Document.txt".into(),
582 type_code: *b"TEXT",
583 creator_code: *b"ttxt",
584 flags: 0x0042,
585 data_fork: b"hello mac world".to_vec(),
586 resource_fork: b"RSRC".to_vec(),
587 };
588 let hqx = binhex::build_binhex(&bh).into_bytes();
589
590 let (buf, archive) = open_bytes(hqx).expect("loose hqx should open");
591 assert_eq!(archive.entries.len(), 1, "synthesized archive is one entry");
592 let entry = &archive.entries[0];
593 assert_eq!(entry.name, "Document.txt");
594 assert_eq!(entry.type_code, *b"TEXT");
595 assert_eq!(entry.creator_code, *b"ttxt");
596 assert_eq!(entry.finder_flags, 0x0042);
597 assert!(!entry.is_dir);
598
599 let data = stuffit::decompress_fork(&buf, entry.data.as_ref().unwrap())
600 .expect("data fork round-trips through method=0");
601 assert_eq!(data, b"hello mac world");
602
603 let rsrc = stuffit::decompress_fork(&buf, entry.rsrc.as_ref().unwrap())
604 .expect("rsrc fork round-trips through method=0");
605 assert_eq!(rsrc, b"RSRC");
606 }
607
608 #[test]
612 fn open_bytes_empty_rsrc_omits_fork() {
613 let bh = binhex::BinHexFile {
614 name: "data.bin".into(),
615 type_code: [0; 4],
616 creator_code: [0; 4],
617 flags: 0,
618 data_fork: vec![1, 2, 3, 4],
619 resource_fork: Vec::new(),
620 };
621 let hqx = binhex::build_binhex(&bh).into_bytes();
622 let (_buf, archive) = open_bytes(hqx).expect("open");
623 assert!(
624 archive.entries[0].rsrc.is_none(),
625 "empty rsrc folds to None"
626 );
627 assert!(archive.entries[0].data.is_some(), "data fork still present");
628 }
629
630 #[test]
635 fn extract_filtered_writes_only_kept_entries() {
636 use crate::stuffit::{build_archive_tree, StuffItInput, StuffItInputNode, WriteMethod};
637 let mk = |name: &str, body: &[u8]| StuffItInput {
640 name: name.into(),
641 type_code: [0; 4],
642 creator_code: [0; 4],
643 finder_flags: 0,
644 create_date: 0,
645 mod_date: 0,
646 data_fork: body.to_vec(),
647 resource_fork: Vec::new(),
648 };
649 let tree = vec![
650 StuffItInputNode::File(mk("file1.txt", b"AAA")),
651 StuffItInputNode::File(mk("file2.txt", b"BBB")),
652 StuffItInputNode::Folder {
653 name: "subdir".into(),
654 finder_flags: 0,
655 create_date: 0,
656 mod_date: 0,
657 children: vec![StuffItInputNode::File(mk("file3.txt", b"CCC"))],
658 },
659 ];
660 let arc_bytes = build_archive_tree(&tree, WriteMethod::Store).expect("build");
661 let (bytes, archive) = open_bytes(arc_bytes).expect("open");
662
663 let keep_names: std::collections::HashSet<&str> =
666 ["file1.txt", "file3.txt"].into_iter().collect();
667 let keep_idx: std::collections::HashSet<usize> = archive
668 .entries
669 .iter()
670 .enumerate()
671 .filter(|(_, e)| keep_names.contains(e.name.as_str()))
672 .map(|(i, _)| i)
673 .collect();
674
675 let dir = tempfile::tempdir().expect("tempdir");
676 let stats = extract_filtered(
677 &bytes,
678 &archive,
679 dir.path(),
680 ForkFormat::Raw,
681 |i| keep_idx.contains(&i),
682 |_, _, _| {},
683 |_| {},
684 )
685 .expect("extract");
686 assert_eq!(stats.files, 2);
687 assert_eq!(stats.skipped, 0);
688
689 assert!(dir.path().join("file1.txt").exists());
690 assert!(
691 !dir.path().join("file2.txt").exists(),
692 "file2 should be skipped"
693 );
694 assert!(dir.path().join("subdir/file3.txt").exists());
695 }
696
697 #[test]
701 fn extract_warns_and_counts_host_illegal_names() {
702 use crate::stuffit::{build_archive_tree, StuffItInput, StuffItInputNode, WriteMethod};
703 let tree = vec![StuffItInputNode::File(StuffItInput {
704 name: "Read/Write Me".into(),
705 type_code: *b"TEXT",
706 creator_code: *b"ttxt",
707 finder_flags: 0,
708 create_date: 0,
709 mod_date: 0,
710 data_fork: b"hi".to_vec(),
711 resource_fork: Vec::new(),
712 })];
713 let arc_bytes = build_archive_tree(&tree, WriteMethod::Store).expect("build");
714 let (bytes, archive) = open_bytes(arc_bytes).expect("open");
715
716 let dir = tempfile::tempdir().expect("tempdir");
717 let mut logs: Vec<String> = Vec::new();
718 let stats = extract_all(
719 &bytes,
720 &archive,
721 dir.path(),
722 ForkFormat::Raw,
723 |_, _, _| {},
724 |m| logs.push(m),
725 )
726 .expect("extract");
727
728 assert_eq!(stats.files, 1);
729 assert_eq!(stats.names_sanitized, 1);
730 assert!(dir.path().join("Read_Write Me").exists());
731 assert!(
732 logs.iter()
733 .any(|l| l.contains("Read/Write Me") && l.contains("Read_Write Me")),
734 "per-file warning logged"
735 );
736 assert!(
737 logs.iter().any(|l| l.contains("HFS/HFS+ disk image")),
738 "closing recommendation logged"
739 );
740 }
741
742 #[test]
749 fn raw_extract_preserves_forks_on_dot_rsrc_name_collision() {
750 use crate::stuffit::{build_archive_tree, StuffItInputNode, WriteMethod};
751 let mk = |name: &str, data: &[u8], rsrc: &[u8]| crate::stuffit::StuffItInput {
752 name: name.into(),
753 type_code: [0; 4],
754 creator_code: [0; 4],
755 finder_flags: 0,
756 create_date: 0,
757 mod_date: 0,
758 data_fork: data.to_vec(),
759 resource_fork: rsrc.to_vec(),
760 };
761 let tree = vec![
764 StuffItInputNode::File(mk("Doc", b"DOC-DATA", b"DOC-RSRC")),
765 StuffItInputNode::File(mk("Doc.rsrc", b"SIBLING-DATA", b"")),
766 ];
767 let arc_bytes = build_archive_tree(&tree, WriteMethod::Store).expect("build");
768 let (bytes, archive) = open_bytes(arc_bytes).expect("open");
769
770 let dir = tempfile::tempdir().expect("tempdir");
771 let stats = extract_all(
772 &bytes,
773 &archive,
774 dir.path(),
775 ForkFormat::Raw,
776 |_, _, _| {},
777 |_| {},
778 )
779 .expect("extract");
780 assert_eq!(stats.files, 2);
781 assert_eq!(stats.skipped, 0);
782
783 assert_eq!(
785 std::fs::read(dir.path().join("Doc.rsrc")).unwrap(),
786 b"SIBLING-DATA",
787 "sibling data fork must not be clobbered"
788 );
789 assert_eq!(std::fs::read(dir.path().join("Doc")).unwrap(), b"DOC-DATA");
791 assert_eq!(
793 std::fs::read(dir.path().join("Doc.rsrc.1")).unwrap(),
794 b"DOC-RSRC",
795 "Doc resource fork must be preserved at a non-colliding path"
796 );
797 }
798
799 fn build_macbinary_ii(name: &[u8], flags: u16, data: &[u8], rsrc: &[u8]) -> Vec<u8> {
801 use byteorder::{BigEndian, ByteOrder};
802 let mut hdr = [0u8; 128];
803 hdr[1] = name.len() as u8;
804 hdr[2..2 + name.len()].copy_from_slice(name);
805 hdr[65..69].copy_from_slice(b"APPL");
806 hdr[69..73].copy_from_slice(b"Po.P");
807 hdr[73] = (flags >> 8) as u8;
808 hdr[101] = (flags & 0xFF) as u8;
809 BigEndian::write_u32(&mut hdr[83..87], data.len() as u32);
810 BigEndian::write_u32(&mut hdr[87..91], rsrc.len() as u32);
811 BigEndian::write_u32(&mut hdr[91..95], 0x1111_1111); BigEndian::write_u32(&mut hdr[95..99], 0x2222_2222); hdr[122] = 129;
814 hdr[123] = 129;
815 let crc = fs_mac::resource_fork::macbinary_crc16(&hdr[0..124]);
816 BigEndian::write_u16(&mut hdr[124..126], crc);
817 let mut out = hdr.to_vec();
818 out.extend_from_slice(data);
819 while out.len() % 128 != 0 {
820 out.push(0);
821 }
822 out.extend_from_slice(rsrc);
823 while out.len() % 128 != 0 {
824 out.push(0);
825 }
826 out
827 }
828
829 #[test]
833 fn open_bytes_synthesizes_single_entry_from_macbinary() {
834 let mb = build_macbinary_ii(b"Prince", 0x2000, b"data-fork-bytes", b"RSRCDATA");
835 let (buf, archive) = open_bytes(mb).expect("MacBinary should open");
836 assert_eq!(archive.entries.len(), 1);
837 let e = &archive.entries[0];
838 assert_eq!(e.name, "Prince");
839 assert_eq!(&e.type_code, b"APPL");
840 assert_eq!(&e.creator_code, b"Po.P");
841 assert_eq!(e.finder_flags, 0x2000, "hasBundle preserved");
842 assert_eq!(e.create_date, 0x1111_1111);
843 assert_eq!(e.mod_date, 0x2222_2222);
844 let data = stuffit::decompress_fork(&buf, e.data.as_ref().unwrap()).unwrap();
845 assert_eq!(data, b"data-fork-bytes");
846 let rsrc = stuffit::decompress_fork(&buf, e.rsrc.as_ref().unwrap()).unwrap();
847 assert_eq!(rsrc, b"RSRCDATA");
848 }
849
850 #[test]
854 fn open_bytes_peels_macbinary_over_sit() {
855 use crate::stuffit::{build_archive_tree, StuffItInput, StuffItInputNode, WriteMethod};
856 let mk = |name: &str, body: &[u8]| StuffItInput {
857 name: name.into(),
858 type_code: [0; 4],
859 creator_code: [0; 4],
860 finder_flags: 0,
861 create_date: 0,
862 mod_date: 0,
863 data_fork: body.to_vec(),
864 resource_fork: Vec::new(),
865 };
866 let inner = build_archive_tree(
867 &[
868 StuffItInputNode::File(mk("inner1.txt", b"AAA")),
869 StuffItInputNode::File(mk("inner2.txt", b"BBB")),
870 ],
871 WriteMethod::Store,
872 )
873 .expect("build inner SIT");
874 let mb = build_macbinary_ii(b"Bundle.sit.bin", 0, &inner, b"");
875 let (_buf, archive) = open_bytes(mb).expect("MacBinary-over-SIT should open");
876 let names: std::collections::HashSet<&str> =
877 archive.entries.iter().map(|e| e.name.as_str()).collect();
878 assert!(
879 names.contains("inner1.txt") && names.contains("inner2.txt"),
880 "should expand to inner SIT entries, got {names:?}"
881 );
882 }
883
884 #[test]
887 fn open_bytes_rejects_non_archive_bytes_with_clear_message() {
888 let junk = b"this is not a Mac archive of any kind".to_vec();
889 let err = open_bytes(junk).expect_err("should reject");
890 let msg = format!("{err}");
891 assert!(
892 msg.contains("not a recognized Mac archive"),
893 "expected magic-driven error, got: {msg}"
894 );
895 }
896}