1use anyhow::{bail, Result};
37use byteorder::{BigEndian, ByteOrder};
38
39use fs_mac::binhex;
40
41const ARCHIVE_HEADER_LEN: usize = 22;
42const ENTRY_HEADER_LEN: usize = 112;
43
44const ENCRYPTED_FLAG: u8 = 0x80;
45const FOLDER_CONTAINS_ENCRYPTED: u8 = 0x10;
46const START_FOLDER: u8 = 0x20;
47const END_FOLDER: u8 = 0x21;
48const FOLDER_MASK: u8 = !(ENCRYPTED_FLAG | FOLDER_CONTAINS_ENCRYPTED);
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum ForkCodec {
58 StuffIt,
61 CompactPro { lzh: bool },
66}
67
68#[derive(Debug, Clone)]
70pub struct ForkInfo {
71 pub method: u8,
74 pub codec: ForkCodec,
76 pub encrypted: bool,
78 pub uncompressed_len: u32,
80 pub compressed_len: u32,
82 pub crc: u16,
84 pub crc32: u32,
86 pub offset: u64,
88}
89
90impl ForkInfo {
91 pub fn method_name(&self) -> &'static str {
93 match self.codec {
94 ForkCodec::StuffIt => method_name(self.method),
95 ForkCodec::CompactPro { lzh: true } => "Compact Pro (LZH+RLE)",
96 ForkCodec::CompactPro { lzh: false } => "Compact Pro (RLE)",
97 }
98 }
99}
100
101#[derive(Debug, Clone)]
103pub struct StuffItEntry {
104 pub path: Vec<String>,
106 pub name: String,
108 pub is_dir: bool,
110 pub type_code: [u8; 4],
111 pub creator_code: [u8; 4],
112 pub finder_flags: u16,
113 pub create_date: u32,
115 pub mod_date: u32,
116 pub data: Option<ForkInfo>,
118 pub rsrc: Option<ForkInfo>,
120}
121
122impl StuffItEntry {
123 pub fn display_path(&self) -> String {
125 self.path.join("/")
126 }
127}
128
129#[derive(Debug, Clone)]
132pub struct StuffItArchive {
133 pub entries: Vec<StuffItEntry>,
134 pub truncated: Option<SitTruncation>,
137}
138
139#[derive(Debug, Clone, Copy, PartialEq, Eq)]
151pub struct SitTruncation {
152 pub declared: u64,
154 pub available: u64,
156}
157
158impl SitTruncation {
159 pub fn missing(&self) -> u64 {
161 self.declared.saturating_sub(self.available)
162 }
163
164 pub fn describe(&self) -> String {
167 format!(
168 "Warning: truncated StuffIt archive - header declares {} bytes, {} present ({} missing). \
169 Any entry whose data lies past the cut will be skipped. StuffIt InstallerMaker splits \
170 large installers across several disks, so this is likely the first disk of a set.",
171 self.declared,
172 self.available,
173 self.missing()
174 )
175 }
176}
177
178fn method_name(method: u8) -> &'static str {
179 match method & 0x0f {
180 0 => "None",
181 1 => "RLE",
182 2 => "Compress (LZW)",
183 3 => "Huffman",
184 5 => "LZAH",
185 6 => "Fixed Huffman",
186 8 => "MW",
187 13 => "LZ+Huffman",
188 14 => "Installer",
189 15 => "Arsenic",
190 _ => "Unknown",
191 }
192}
193
194pub(crate) fn crc16_arc(data: &[u8]) -> u16 {
196 let mut crc: u16 = 0;
197 for &b in data {
198 crc ^= b as u16;
199 for _ in 0..8 {
200 if crc & 1 != 0 {
201 crc = (crc >> 1) ^ 0xA001;
202 } else {
203 crc >>= 1;
204 }
205 }
206 }
207 crc
208}
209
210pub fn is_stuffit(data: &[u8]) -> bool {
212 data.len() >= 14 && &data[0..4] == b"SIT!" && &data[10..14] == b"rLau"
213}
214
215pub fn find_sea_archive(data: &[u8]) -> Option<usize> {
219 if is_stuffit(data) {
220 return Some(0);
221 }
222 data.windows(14)
224 .position(|w| &w[0..4] == b"SIT!" && &w[10..14] == b"rLau")
225}
226
227pub fn parse(data: &[u8]) -> Result<StuffItArchive> {
229 let base = find_sea_archive(data)
230 .ok_or_else(|| anyhow::anyhow!("not a StuffIt archive (no SIT!/rLau signature)"))?;
231 let archive = &data[base..];
232 if archive.len() < ARCHIVE_HEADER_LEN {
233 bail!("StuffIt: truncated archive header");
234 }
235
236 let declared_total = BigEndian::read_u32(&archive[6..10]) as usize;
237 let total_size = declared_total.min(archive.len());
241 let truncated = (declared_total > archive.len()).then_some(SitTruncation {
242 declared: declared_total as u64,
243 available: archive.len() as u64,
244 });
245
246 let mut entries = Vec::new();
247 let mut currdir: Vec<String> = Vec::new();
248 let mut pos = ARCHIVE_HEADER_LEN;
249
250 while pos + ENTRY_HEADER_LEN <= total_size {
251 let h = &archive[pos..pos + ENTRY_HEADER_LEN];
252
253 let stored_hdr_crc = BigEndian::read_u16(&h[110..112]);
254 let computed = crc16_arc(&h[0..110]);
255 if stored_hdr_crc != computed {
256 bail!(
257 "StuffIt: entry header CRC mismatch at offset {pos} (got {computed:#06x}, expected {stored_hdr_crc:#06x})"
258 );
259 }
260
261 let rmethod = h[0];
262 let dmethod = h[1];
263 let mut namelen = h[2] as usize;
264 if namelen > 31 {
265 namelen = 31;
266 }
267 let name = fs_mac::hfs::decode_mac_filename(&h[3..3 + namelen]);
268
269 let data_start = (base + pos + ENTRY_HEADER_LEN) as u64;
270
271 if (dmethod & FOLDER_MASK) == START_FOLDER || (rmethod & FOLDER_MASK) == START_FOLDER {
272 currdir.push(name.clone());
273 entries.push(StuffItEntry {
275 path: currdir.clone(),
276 name,
277 is_dir: true,
278 type_code: [0; 4],
279 creator_code: [0; 4],
280 finder_flags: BigEndian::read_u16(&h[74..76]),
281 create_date: BigEndian::read_u32(&h[76..80]),
282 mod_date: BigEndian::read_u32(&h[80..84]),
283 data: None,
284 rsrc: None,
285 });
286 pos += ENTRY_HEADER_LEN; continue;
288 }
289
290 if (dmethod & FOLDER_MASK) == END_FOLDER || (rmethod & FOLDER_MASK) == END_FOLDER {
291 currdir.pop();
292 pos += ENTRY_HEADER_LEN;
293 continue;
294 }
295
296 let rsrc_len = BigEndian::read_u32(&h[84..88]);
298 let data_len = BigEndian::read_u32(&h[88..92]);
299 let rsrc_clen = BigEndian::read_u32(&h[92..96]);
300 let data_clen = BigEndian::read_u32(&h[96..100]);
301 let rsrc_crc = BigEndian::read_u16(&h[100..102]);
302 let data_crc = BigEndian::read_u16(&h[102..104]);
303
304 let mut type_code = [0u8; 4];
305 type_code.copy_from_slice(&h[66..70]);
306 let mut creator_code = [0u8; 4];
307 creator_code.copy_from_slice(&h[70..74]);
308
309 let rsrc = if rsrc_len > 0 || rsrc_clen > 0 {
310 Some(ForkInfo {
311 method: rmethod & !ENCRYPTED_FLAG,
312 codec: ForkCodec::StuffIt,
313 encrypted: rmethod & ENCRYPTED_FLAG != 0,
314 uncompressed_len: rsrc_len,
315 compressed_len: rsrc_clen,
316 crc: rsrc_crc,
317 crc32: 0,
318 offset: data_start,
319 })
320 } else {
321 None
322 };
323 let data = Some(ForkInfo {
324 method: dmethod & !ENCRYPTED_FLAG,
325 codec: ForkCodec::StuffIt,
326 encrypted: dmethod & ENCRYPTED_FLAG != 0,
327 uncompressed_len: data_len,
328 compressed_len: data_clen,
329 crc: data_crc,
330 crc32: 0,
331 offset: data_start + rsrc_clen as u64,
332 });
333
334 let mut path = currdir.clone();
335 path.push(name.clone());
336 entries.push(StuffItEntry {
337 path,
338 name,
339 is_dir: false,
340 type_code,
341 creator_code,
342 finder_flags: BigEndian::read_u16(&h[74..76]),
343 create_date: BigEndian::read_u32(&h[76..80]),
344 mod_date: BigEndian::read_u32(&h[80..84]),
345 data,
346 rsrc,
347 });
348
349 pos += ENTRY_HEADER_LEN + rsrc_clen as usize + data_clen as usize;
350 }
351
352 Ok(StuffItArchive { entries, truncated })
353}
354
355pub fn decompress_fork(archive: &[u8], fork: &ForkInfo) -> Result<Vec<u8>> {
361 if fork.encrypted {
362 bail!("StuffIt: encrypted forks are not supported");
363 }
364 let start = fork.offset as usize;
365 let end = start + fork.compressed_len as usize;
366 if end > archive.len() {
367 bail!(
372 "StuffIt: fork data extends past end of archive (needs {} compressed bytes at offset \
373 {start}, archive holds {}; {} missing - the archive is truncated)",
374 fork.compressed_len,
375 archive.len(),
376 end - archive.len()
377 );
378 }
379 let comp = &archive[start..end];
380 let want = fork.uncompressed_len as usize;
381
382 let out = match fork.method & 0x0f {
383 0 => comp.to_vec(),
384 1 => binhex::rle90_decode(comp),
385 2 => super::stuffit_lzw::decompress(comp, want, 0x8e)?,
386 3 => super::stuffit_huffman::decompress(comp, want)?,
387 5 => super::stuffit_lzah::decompress(comp, want)?,
388 13 => super::stuffit_lzh::decompress(comp, want)?,
389 15 => super::stuffit_arsenic::decompress(comp, want)?,
390 m => bail!(
391 "StuffIt: compression method {m} ({}) not yet supported",
392 method_name(fork.method)
393 ),
394 };
395
396 let out = if out.len() > want {
397 out[..want].to_vec()
398 } else {
399 out
400 };
401 if out.len() != want {
402 bail!(
403 "StuffIt: decompressed length {} != expected {want}",
404 out.len()
405 );
406 }
407
408 if fork.method & 0x0f != 15 {
410 let got = crc16_arc(&out);
411 if got != fork.crc {
412 bail!(
413 "StuffIt: fork CRC mismatch (got {got:#06x}, expected {:#06x})",
414 fork.crc
415 );
416 }
417 }
418
419 Ok(out)
420}
421
422#[derive(Debug, Clone, Copy, PartialEq, Eq)]
428pub enum WriteMethod {
429 Store,
431 Rle,
433}
434
435#[derive(Debug, Clone)]
437pub struct StuffItInput {
438 pub name: String,
440 pub type_code: [u8; 4],
441 pub creator_code: [u8; 4],
442 pub finder_flags: u16,
443 pub create_date: u32,
445 pub mod_date: u32,
446 pub data_fork: Vec<u8>,
447 pub resource_fork: Vec<u8>,
448}
449
450#[derive(Debug, Clone)]
455pub enum StuffItInputNode {
456 File(StuffItInput),
457 Folder {
458 name: String,
460 finder_flags: u16,
462 create_date: u32,
464 mod_date: u32,
465 children: Vec<StuffItInputNode>,
467 },
468}
469
470fn compress_fork(data: &[u8], method: WriteMethod) -> (u8, Vec<u8>) {
472 match method {
473 WriteMethod::Store => (0, data.to_vec()),
474 WriteMethod::Rle => {
475 let encoded = binhex::rle90_encode(data);
476 if encoded.len() < data.len() {
477 (1, encoded)
478 } else {
479 (0, data.to_vec())
480 }
481 }
482 }
483}
484
485pub fn build_archive(files: &[StuffItInput], method: WriteMethod) -> Result<Vec<u8>> {
497 let nodes: Vec<StuffItInputNode> = files.iter().cloned().map(StuffItInputNode::File).collect();
498 build_archive_tree(&nodes, method)
499}
500
501pub fn build_archive_tree(tree: &[StuffItInputNode], method: WriteMethod) -> Result<Vec<u8>> {
512 let mut body = Vec::new();
513 for node in tree {
514 emit_node(&mut body, node, method);
515 }
516
517 let total = (ARCHIVE_HEADER_LEN + body.len()) as u32;
518 let mut out = Vec::with_capacity(total as usize);
519 out.extend_from_slice(b"SIT!");
520 out.extend_from_slice(&(tree.len() as u16).to_be_bytes());
521 out.extend_from_slice(&total.to_be_bytes());
522 out.extend_from_slice(b"rLau");
523 out.push(1); out.push(0); out.extend_from_slice(&(ARCHIVE_HEADER_LEN as u32).to_be_bytes());
526 out.extend_from_slice(&[0, 0]); debug_assert_eq!(out.len(), ARCHIVE_HEADER_LEN);
528 out.extend_from_slice(&body);
529 Ok(out)
530}
531
532fn emit_node(body: &mut Vec<u8>, node: &StuffItInputNode, method: WriteMethod) {
536 match node {
537 StuffItInputNode::File(f) => emit_file_entry(body, f, method),
538 StuffItInputNode::Folder {
539 name,
540 finder_flags,
541 create_date,
542 mod_date,
543 children,
544 } => {
545 emit_folder_marker(
546 body,
547 name,
548 *finder_flags,
549 *create_date,
550 *mod_date,
551 START_FOLDER,
552 );
553 for child in children {
554 emit_node(body, child, method);
555 }
556 emit_folder_marker(body, "", 0, 0, 0, END_FOLDER);
557 }
558 }
559}
560
561fn emit_file_entry(body: &mut Vec<u8>, f: &StuffItInput, method: WriteMethod) {
562 let name_bytes = mac_name_bytes(&f.name);
563 let namelen = name_bytes.len();
564
565 let (rmethod, rcomp) = if f.resource_fork.is_empty() {
566 (0u8, Vec::new())
567 } else {
568 compress_fork(&f.resource_fork, method)
569 };
570 let (dmethod, dcomp) = compress_fork(&f.data_fork, method);
571
572 let mut h = [0u8; ENTRY_HEADER_LEN];
573 h[0] = rmethod;
574 h[1] = dmethod;
575 h[2] = namelen as u8;
576 h[3..3 + namelen].copy_from_slice(&name_bytes);
577 let fname_crc = crc16_arc(&h[2..3 + namelen]);
579 BigEndian::write_u16(&mut h[34..36], fname_crc);
580 h[66..70].copy_from_slice(&f.type_code);
581 h[70..74].copy_from_slice(&f.creator_code);
582 BigEndian::write_u16(&mut h[74..76], f.finder_flags);
583 BigEndian::write_u32(&mut h[76..80], f.create_date);
584 BigEndian::write_u32(&mut h[80..84], f.mod_date);
585 BigEndian::write_u32(&mut h[84..88], f.resource_fork.len() as u32);
586 BigEndian::write_u32(&mut h[88..92], f.data_fork.len() as u32);
587 BigEndian::write_u32(&mut h[92..96], rcomp.len() as u32);
588 BigEndian::write_u32(&mut h[96..100], dcomp.len() as u32);
589 BigEndian::write_u16(&mut h[100..102], crc16_arc(&f.resource_fork));
590 BigEndian::write_u16(&mut h[102..104], crc16_arc(&f.data_fork));
591 h[62..66].copy_from_slice(&[0xff, 0xff, 0xff, 0xff]);
593 let hdr_crc = crc16_arc(&h[0..110]);
595 BigEndian::write_u16(&mut h[110..112], hdr_crc);
596
597 body.extend_from_slice(&h);
598 body.extend_from_slice(&rcomp);
599 body.extend_from_slice(&dcomp);
600}
601
602fn emit_folder_marker(
607 body: &mut Vec<u8>,
608 name: &str,
609 finder_flags: u16,
610 create_date: u32,
611 mod_date: u32,
612 marker: u8,
613) {
614 let name_bytes = if marker == START_FOLDER {
615 mac_name_bytes(name)
616 } else {
617 Vec::new()
618 };
619 let namelen = name_bytes.len();
620
621 let mut h = [0u8; ENTRY_HEADER_LEN];
622 h[0] = marker;
625 h[1] = marker;
626 h[2] = namelen as u8;
627 if namelen > 0 {
628 h[3..3 + namelen].copy_from_slice(&name_bytes);
629 let fname_crc = crc16_arc(&h[2..3 + namelen]);
630 BigEndian::write_u16(&mut h[34..36], fname_crc);
631 }
632 BigEndian::write_u16(&mut h[74..76], finder_flags);
634 BigEndian::write_u32(&mut h[76..80], create_date);
635 BigEndian::write_u32(&mut h[80..84], mod_date);
636 let hdr_crc = crc16_arc(&h[0..110]);
639 BigEndian::write_u16(&mut h[110..112], hdr_crc);
640
641 body.extend_from_slice(&h);
642}
643
644fn mac_name_bytes(name: &str) -> Vec<u8> {
646 let mut bytes = fs_mac::hfs::utf8_to_mac_roman(name).unwrap_or_else(|_| {
647 name.chars()
648 .map(|c| if c.is_ascii() { c as u8 } else { b'_' })
649 .collect()
650 });
651 bytes.truncate(31);
652 bytes
653}
654
655#[cfg(test)]
656mod tests {
657 use super::*;
658
659 #[test]
660 fn crc16_arc_known() {
661 assert_eq!(crc16_arc(b"123456789"), 0xBB3D);
663 }
664
665 #[test]
666 fn detects_signature() {
667 let mut buf = vec![0u8; 22];
668 buf[0..4].copy_from_slice(b"SIT!");
669 buf[10..14].copy_from_slice(b"rLau");
670 assert!(is_stuffit(&buf));
671 assert!(!is_stuffit(b"not a sit file"));
672 }
673
674 #[test]
675 fn truncated_archive_is_reported_not_silently_clamped() {
676 let files = vec![
681 StuffItInput {
682 name: "Small".to_string(),
683 type_code: *b"TEXT",
684 creator_code: *b"ttxt",
685 finder_flags: 0,
686 create_date: 0,
687 mod_date: 0,
688 data_fork: b"fits before the cut".to_vec(),
689 resource_fork: Vec::new(),
690 },
691 StuffItInput {
692 name: "Big".to_string(),
693 type_code: *b"APPL",
694 creator_code: *b"Spe2",
695 finder_flags: 0,
696 create_date: 0,
697 mod_date: 0,
698 data_fork: (0..40_000u32).map(|i| (i % 251) as u8).collect(),
700 resource_fork: Vec::new(),
701 },
702 ];
703 let full = build_archive(&files, WriteMethod::Store).unwrap();
704
705 assert!(parse(&full).unwrap().truncated.is_none());
707
708 let cut = full.len() - 10_000;
710 let disk1 = full[..cut].to_vec();
711
712 let archive = parse(&disk1).expect("disk 1 must still parse and list");
713 assert_eq!(archive.entries.len(), 2, "both headers precede the cut");
714 let t = archive.truncated.expect("truncation must be recorded");
715 assert_eq!(t.declared, full.len() as u64);
716 assert_eq!(t.available, cut as u64);
717 assert_eq!(t.missing(), 10_000);
718
719 let e0 = &archive.entries[0];
721 assert_eq!(
722 decompress_fork(&disk1, e0.data.as_ref().unwrap()).unwrap(),
723 files[0].data_fork
724 );
725 let err = decompress_fork(&disk1, archive.entries[1].data.as_ref().unwrap())
727 .unwrap_err()
728 .to_string();
729 assert!(err.contains("truncated"), "unhelpful message: {err}");
730 assert!(err.contains("10000 missing"), "no shortfall in: {err}");
731 }
732
733 #[test]
734 fn write_then_read_roundtrip() {
735 let files = vec![
736 StuffItInput {
737 name: "Read Me".to_string(),
738 type_code: *b"TEXT",
739 creator_code: *b"ttxt",
740 finder_flags: 0,
741 create_date: 0xAABBCCDD,
742 mod_date: 0x11223344,
743 data_fork: b"Hello\r\rAAAAAAAAAAAAAAAAAAAA end.".to_vec(),
745 resource_fork: vec![0u8; 200],
746 },
747 StuffItInput {
748 name: "Empty Rsrc".to_string(),
749 type_code: *b"BINA",
750 creator_code: *b"????",
751 finder_flags: 0x4000,
752 create_date: 0,
753 mod_date: 0,
754 data_fork: b"just a data fork".to_vec(),
755 resource_fork: Vec::new(),
756 },
757 ];
758
759 for method in [WriteMethod::Store, WriteMethod::Rle] {
760 let bytes = build_archive(&files, method).unwrap();
761 assert!(is_stuffit(&bytes));
762 let archive = parse(&bytes).expect("our writer must parse");
763 assert_eq!(archive.entries.len(), 2);
764
765 let e0 = &archive.entries[0];
766 assert_eq!(e0.name, "Read Me");
767 assert_eq!(&e0.type_code, b"TEXT");
768 assert_eq!(e0.create_date, 0xAABBCCDD);
769 let data0 = decompress_fork(&bytes, e0.data.as_ref().unwrap()).unwrap();
771 assert_eq!(data0, files[0].data_fork);
772 let rsrc0 = decompress_fork(&bytes, e0.rsrc.as_ref().unwrap()).unwrap();
773 assert_eq!(rsrc0, files[0].resource_fork);
774
775 let e1 = &archive.entries[1];
776 assert_eq!(e1.finder_flags, 0x4000);
777 assert!(e1.rsrc.is_none());
778 let data1 = decompress_fork(&bytes, e1.data.as_ref().unwrap()).unwrap();
779 assert_eq!(data1, files[1].data_fork);
780 }
781 }
782
783 fn make_file(name: &str, body: &[u8]) -> StuffItInputNode {
784 StuffItInputNode::File(StuffItInput {
785 name: name.to_string(),
786 type_code: *b"TEXT",
787 creator_code: *b"ttxt",
788 finder_flags: 0,
789 create_date: 0,
790 mod_date: 0,
791 data_fork: body.to_vec(),
792 resource_fork: Vec::new(),
793 })
794 }
795
796 fn make_folder(name: &str, children: Vec<StuffItInputNode>) -> StuffItInputNode {
797 StuffItInputNode::Folder {
798 name: name.to_string(),
799 finder_flags: 0,
800 create_date: 0,
801 mod_date: 0,
802 children,
803 }
804 }
805
806 #[test]
807 fn empty_folder_round_trips() {
808 let tree = vec![make_folder("Apps", vec![])];
811 let bytes = build_archive_tree(&tree, WriteMethod::Store).unwrap();
812 let archive = parse(&bytes).expect("parse empty folder");
813
814 assert_eq!(archive.entries.len(), 1);
815 let only = &archive.entries[0];
816 assert!(only.is_dir, "empty folder marker should be is_dir");
817 assert_eq!(only.name, "Apps");
818 assert_eq!(only.path, vec!["Apps".to_string()]);
819 }
820
821 #[test]
822 fn folder_with_two_files_round_trips() {
823 let tree = vec![make_folder(
824 "Docs",
825 vec![
826 make_file("ReadMe", b"hello"),
827 make_file("Notes", b"more text"),
828 ],
829 )];
830 let bytes = build_archive_tree(&tree, WriteMethod::Store).unwrap();
831 let archive = parse(&bytes).expect("parse folder with files");
832
833 assert_eq!(archive.entries.len(), 3);
835 assert!(archive.entries[0].is_dir);
836 assert_eq!(archive.entries[0].name, "Docs");
837 assert_eq!(archive.entries[1].name, "ReadMe");
838 assert_eq!(archive.entries[1].path, vec!["Docs", "ReadMe"]);
839 assert_eq!(archive.entries[2].name, "Notes");
840 assert_eq!(archive.entries[2].path, vec!["Docs", "Notes"]);
841
842 let data = decompress_fork(&bytes, archive.entries[1].data.as_ref().unwrap()).unwrap();
844 assert_eq!(data, b"hello");
845 }
846
847 #[test]
848 fn deeply_nested_folders_preserve_paths() {
849 let tree = vec![make_folder(
854 "Apps",
855 vec![make_folder(
856 "Word",
857 vec![make_folder(
858 "Templates",
859 vec![make_file("Letter.txt", b"Dear ___,")],
860 )],
861 )],
862 )];
863 let bytes = build_archive_tree(&tree, WriteMethod::Store).unwrap();
864 let archive = parse(&bytes).expect("parse nested folders");
865
866 assert_eq!(archive.entries.len(), 4);
868 assert_eq!(
869 archive.entries[3].path,
870 vec!["Apps", "Word", "Templates", "Letter.txt"]
871 );
872 let body = decompress_fork(&bytes, archive.entries[3].data.as_ref().unwrap()).unwrap();
873 assert_eq!(body, b"Dear ___,");
874 }
875
876 #[test]
877 fn top_level_files_after_folder_end_back_at_root() {
878 let tree = vec![
884 make_folder("Folder1", vec![make_file("Inner.txt", b"inside")]),
885 make_file("TopLevel.txt", b"outside"),
886 ];
887 let bytes = build_archive_tree(&tree, WriteMethod::Store).unwrap();
888 let archive = parse(&bytes).expect("parse mixed tree");
889
890 assert_eq!(archive.entries.len(), 3);
892 assert!(archive.entries[0].is_dir);
893 assert_eq!(archive.entries[0].path, vec!["Folder1"]);
894 assert_eq!(archive.entries[1].path, vec!["Folder1", "Inner.txt"]);
895 assert_eq!(archive.entries[2].path, vec!["TopLevel.txt"]);
897 }
898
899 #[test]
900 fn flat_build_archive_still_works_after_refactor() {
901 let files = vec![StuffItInput {
904 name: "Sole".to_string(),
905 type_code: *b"TEXT",
906 creator_code: *b"ttxt",
907 finder_flags: 0,
908 create_date: 0,
909 mod_date: 0,
910 data_fork: b"just one file".to_vec(),
911 resource_fork: Vec::new(),
912 }];
913 let bytes = build_archive(&files, WriteMethod::Store).unwrap();
914 let archive = parse(&bytes).expect("parse flat");
915 assert_eq!(archive.entries.len(), 1);
916 assert!(!archive.entries[0].is_dir);
917 assert_eq!(archive.entries[0].path, vec!["Sole"]);
918 }
919
920 #[test]
923 fn parse_real_samples_if_present() {
924 let dir = std::path::Path::new("/Users/dani/Downloads");
925 if !dir.is_dir() {
926 return;
927 }
928 let mut checked = 0;
929 for entry in std::fs::read_dir(dir).unwrap().flatten() {
930 let path = entry.path();
931 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
932 if !name.to_ascii_lowercase().ends_with(".sit.hqx") {
933 continue;
934 }
935 let bytes = match std::fs::read(&path) {
936 Ok(b) => b,
937 Err(_) => continue,
938 };
939 let bh = match binhex::parse_binhex(&bytes) {
940 Ok(b) => b,
941 Err(_) => continue,
942 };
943 if find_sea_archive(&bh.data_fork).is_none() {
947 eprintln!("{name}: skipped (not classic SIT!; likely StuffIt 5)");
948 continue;
949 }
950 let archive = match parse(&bh.data_fork) {
951 Ok(a) => a,
952 Err(e) => panic!("parse failed for {name}: {e}"),
953 };
954 assert!(!archive.entries.is_empty(), "no entries parsed from {name}");
955 for e in &archive.entries {
956 assert!(!e.name.is_empty() || e.is_dir, "empty entry name in {name}");
957 }
958 let mut ok = 0;
962 let mut skipped = 0;
963 for e in &archive.entries {
964 for fork in [e.data.as_ref(), e.rsrc.as_ref()].into_iter().flatten() {
965 if fork.uncompressed_len == 0 {
966 continue;
967 }
968 match decompress_fork(&bh.data_fork, fork) {
969 Ok(_) => ok += 1,
970 Err(_) if matches!(fork.method & 0x0f, 2 | 3 | 5 | 6 | 8 | 14 | 15) => {
971 skipped += 1; }
973 Err(e) => panic!("{name}: decompress failed: {e}"),
974 }
975 }
976 }
977 eprintln!(
978 "{name}: {} entries, {ok} forks decompressed + CRC-verified, {skipped} unsupported-codec",
979 archive.entries.len()
980 );
981 checked += 1;
982 if checked >= 6 {
983 break;
984 }
985 }
986 eprintln!("validated {checked} real .sit samples");
987 }
988}