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 pub damage: Vec<SitDamage>,
139}
140
141#[derive(Debug, Clone, Copy, PartialEq, Eq)]
144pub struct SitDamage {
145 pub offset: u64,
147 pub skipped: u64,
149}
150
151impl SitDamage {
152 pub fn describe(&self) -> String {
154 format!(
155 "Warning: damaged StuffIt archive - {} unexpected bytes at offset {} where an entry \
156 header should be; skipped to the next valid header. The entry before the gap is \
157 likely damaged too and may fail its CRC.",
158 self.skipped, self.offset
159 )
160 }
161}
162
163#[derive(Debug, Clone, Copy, PartialEq, Eq)]
175pub struct SitTruncation {
176 pub declared: u64,
178 pub available: u64,
180}
181
182impl SitTruncation {
183 pub fn missing(&self) -> u64 {
185 self.declared.saturating_sub(self.available)
186 }
187
188 pub fn describe(&self) -> String {
191 format!(
192 "Warning: truncated StuffIt archive - header declares {} bytes, {} present ({} missing). \
193 Any entry whose data lies past the cut will be skipped. StuffIt InstallerMaker splits \
194 large installers across several disks, so this is likely the first disk of a set.",
195 self.declared,
196 self.available,
197 self.missing()
198 )
199 }
200}
201
202fn method_name(method: u8) -> &'static str {
203 match method & 0x0f {
204 0 => "None",
205 1 => "RLE",
206 2 => "Compress (LZW)",
207 3 => "Huffman",
208 5 => "LZAH",
209 6 => "Fixed Huffman",
210 8 => "MW",
211 13 => "LZ+Huffman",
212 14 => "Installer",
213 15 => "Arsenic",
214 _ => "Unknown",
215 }
216}
217
218pub(crate) fn crc16_arc(data: &[u8]) -> u16 {
220 let mut crc: u16 = 0;
221 for &b in data {
222 crc ^= b as u16;
223 for _ in 0..8 {
224 if crc & 1 != 0 {
225 crc = (crc >> 1) ^ 0xA001;
226 } else {
227 crc >>= 1;
228 }
229 }
230 }
231 crc
232}
233
234pub fn is_stuffit(data: &[u8]) -> bool {
236 data.len() >= 14 && &data[0..4] == b"SIT!" && &data[10..14] == b"rLau"
237}
238
239pub fn find_sea_archive(data: &[u8]) -> Option<usize> {
243 if is_stuffit(data) {
244 return Some(0);
245 }
246 data.windows(14)
248 .position(|w| &w[0..4] == b"SIT!" && &w[10..14] == b"rLau")
249}
250
251pub fn parse(data: &[u8]) -> Result<StuffItArchive> {
253 let base = find_sea_archive(data)
254 .ok_or_else(|| anyhow::anyhow!("not a StuffIt archive (no SIT!/rLau signature)"))?;
255 let archive = &data[base..];
256 if archive.len() < ARCHIVE_HEADER_LEN {
257 bail!("StuffIt: truncated archive header");
258 }
259
260 let declared_total = BigEndian::read_u32(&archive[6..10]) as usize;
261 let total_size = declared_total.min(archive.len());
265 let truncated = (declared_total > archive.len()).then_some(SitTruncation {
266 declared: declared_total as u64,
267 available: archive.len() as u64,
268 });
269
270 let mut entries = Vec::new();
271 let mut damage = Vec::new();
272 let mut currdir: Vec<String> = Vec::new();
273 let mut pos = ARCHIVE_HEADER_LEN;
274
275 while pos + ENTRY_HEADER_LEN <= total_size {
276 let h = &archive[pos..pos + ENTRY_HEADER_LEN];
277
278 if !entry_header_valid(h) {
279 if let Some(next) = find_next_entry_header(archive, pos + 1, total_size) {
280 damage.push(SitDamage {
281 offset: pos as u64,
282 skipped: (next - pos) as u64,
283 });
284 pos = next;
285 continue;
286 }
287 if h[..110].iter().all(|&b| b == 0) {
288 break; }
290 let stored_hdr_crc = BigEndian::read_u16(&h[110..112]);
291 let computed = crc16_arc(&h[0..110]);
292 bail!(
293 "StuffIt: entry header CRC mismatch at offset {pos} (got {computed:#06x}, expected {stored_hdr_crc:#06x}){}",
294 damage_hint(data)
295 );
296 }
297
298 let rmethod = h[0];
299 let dmethod = h[1];
300 let mut namelen = h[2] as usize;
301 if namelen > 31 {
302 namelen = 31;
303 }
304 let name = fs_mac::hfs::decode_mac_filename(&h[3..3 + namelen]);
305
306 let data_start = (base + pos + ENTRY_HEADER_LEN) as u64;
307
308 if (dmethod & FOLDER_MASK) == START_FOLDER || (rmethod & FOLDER_MASK) == START_FOLDER {
309 currdir.push(name.clone());
310 entries.push(StuffItEntry {
312 path: currdir.clone(),
313 name,
314 is_dir: true,
315 type_code: [0; 4],
316 creator_code: [0; 4],
317 finder_flags: BigEndian::read_u16(&h[74..76]),
318 create_date: BigEndian::read_u32(&h[76..80]),
319 mod_date: BigEndian::read_u32(&h[80..84]),
320 data: None,
321 rsrc: None,
322 });
323 pos += ENTRY_HEADER_LEN; continue;
325 }
326
327 if (dmethod & FOLDER_MASK) == END_FOLDER || (rmethod & FOLDER_MASK) == END_FOLDER {
328 currdir.pop();
329 pos += ENTRY_HEADER_LEN;
330 continue;
331 }
332
333 let rsrc_len = BigEndian::read_u32(&h[84..88]);
335 let data_len = BigEndian::read_u32(&h[88..92]);
336 let rsrc_clen = BigEndian::read_u32(&h[92..96]);
337 let data_clen = BigEndian::read_u32(&h[96..100]);
338 let rsrc_crc = BigEndian::read_u16(&h[100..102]);
339 let data_crc = BigEndian::read_u16(&h[102..104]);
340
341 let mut type_code = [0u8; 4];
342 type_code.copy_from_slice(&h[66..70]);
343 let mut creator_code = [0u8; 4];
344 creator_code.copy_from_slice(&h[70..74]);
345
346 let rsrc = if rsrc_len > 0 || rsrc_clen > 0 {
347 Some(ForkInfo {
348 method: rmethod & !ENCRYPTED_FLAG,
349 codec: ForkCodec::StuffIt,
350 encrypted: rmethod & ENCRYPTED_FLAG != 0,
351 uncompressed_len: rsrc_len,
352 compressed_len: rsrc_clen,
353 crc: rsrc_crc,
354 crc32: 0,
355 offset: data_start,
356 })
357 } else {
358 None
359 };
360 let data = Some(ForkInfo {
361 method: dmethod & !ENCRYPTED_FLAG,
362 codec: ForkCodec::StuffIt,
363 encrypted: dmethod & ENCRYPTED_FLAG != 0,
364 uncompressed_len: data_len,
365 compressed_len: data_clen,
366 crc: data_crc,
367 crc32: 0,
368 offset: data_start + rsrc_clen as u64,
369 });
370
371 let mut path = currdir.clone();
372 path.push(name.clone());
373 entries.push(StuffItEntry {
374 path,
375 name,
376 is_dir: false,
377 type_code,
378 creator_code,
379 finder_flags: BigEndian::read_u16(&h[74..76]),
380 create_date: BigEndian::read_u32(&h[76..80]),
381 mod_date: BigEndian::read_u32(&h[80..84]),
382 data,
383 rsrc,
384 });
385
386 pos += ENTRY_HEADER_LEN + rsrc_clen as usize + data_clen as usize;
387 }
388
389 Ok(StuffItArchive {
390 entries,
391 truncated,
392 damage,
393 })
394}
395
396fn entry_header_valid(h: &[u8]) -> bool {
398 BigEndian::read_u16(&h[110..112]) == crc16_arc(&h[0..110]) && h[..110].iter().any(|&b| b != 0)
399}
400
401fn find_next_entry_header(archive: &[u8], from: usize, total_size: usize) -> Option<usize> {
403 let method_ok = |m: u8| {
404 let f = m & FOLDER_MASK;
405 f == START_FOLDER || f == END_FOLDER || (m & !ENCRYPTED_FLAG) <= 0x0f
406 };
407 (from..(total_size + 1).saturating_sub(ENTRY_HEADER_LEN)).find(|&q| {
408 let h = &archive[q..q + ENTRY_HEADER_LEN];
409 h[2] <= 31 && method_ok(h[0]) && method_ok(h[1]) && entry_header_valid(h)
410 })
411}
412
413pub(crate) fn damage_hint(data: &[u8]) -> &'static str {
416 if data.len() < 4096 {
417 return "";
418 }
419 let mut seen = [false; 256];
420 for &b in data {
421 seen[b as usize] = true;
422 }
423 if !seen[0x0d] {
424 return " - the file holds no CR (0x0d) bytes at all, so it went through a text-mode \
425 (line-ending) conversion that rewrote the compressed data; it cannot be repaired, \
426 fetch a copy transferred in binary mode";
427 }
428 if !seen[0xc0] && !seen[0xc1] && !seen[0xf5..].contains(&true) {
429 return " - the file holds none of the bytes C0, C1, F5-FF (the ones UTF-8 never uses), \
430 so it went through a text-encoding conversion that rewrote the compressed data; it \
431 cannot be repaired, fetch a copy transferred in binary mode";
432 }
433 ""
434}
435
436pub fn decompress_fork(archive: &[u8], fork: &ForkInfo) -> Result<Vec<u8>> {
442 if fork.encrypted {
443 bail!("StuffIt: encrypted forks are not supported");
444 }
445 let start = fork.offset as usize;
446 let end = start + fork.compressed_len as usize;
447 if end > archive.len() {
448 bail!(
453 "StuffIt: fork data extends past end of archive (needs {} compressed bytes at offset \
454 {start}, archive holds {}; {} missing - the archive is truncated)",
455 fork.compressed_len,
456 archive.len(),
457 end - archive.len()
458 );
459 }
460 let comp = &archive[start..end];
461 let want = fork.uncompressed_len as usize;
462
463 let out = match fork.method & 0x0f {
464 0 => comp.to_vec(),
465 1 => binhex::rle90_decode(comp),
466 2 => super::stuffit_lzw::decompress(comp, want, 0x8e)?,
467 3 => super::stuffit_huffman::decompress(comp, want)?,
468 5 => super::stuffit_lzah::decompress(comp, want)?,
469 13 => super::stuffit_lzh::decompress(comp, want)?,
470 15 => super::stuffit_arsenic::decompress(comp, want)?,
471 m => bail!(
472 "StuffIt: compression method {m} ({}) not yet supported",
473 method_name(fork.method)
474 ),
475 };
476
477 let out = if out.len() > want {
478 out[..want].to_vec()
479 } else {
480 out
481 };
482 if out.len() != want {
483 bail!(
484 "StuffIt: decompressed length {} != expected {want}",
485 out.len()
486 );
487 }
488
489 if fork.method & 0x0f != 15 {
491 let got = crc16_arc(&out);
492 if got != fork.crc {
493 bail!(
494 "StuffIt: fork CRC mismatch (got {got:#06x}, expected {:#06x})",
495 fork.crc
496 );
497 }
498 }
499
500 Ok(out)
501}
502
503#[derive(Debug, Clone, Copy, PartialEq, Eq)]
509pub enum WriteMethod {
510 Store,
512 Rle,
514}
515
516#[derive(Debug, Clone)]
518pub struct StuffItInput {
519 pub name: String,
521 pub type_code: [u8; 4],
522 pub creator_code: [u8; 4],
523 pub finder_flags: u16,
524 pub create_date: u32,
526 pub mod_date: u32,
527 pub data_fork: Vec<u8>,
528 pub resource_fork: Vec<u8>,
529}
530
531#[derive(Debug, Clone)]
536pub enum StuffItInputNode {
537 File(StuffItInput),
538 Folder {
539 name: String,
541 finder_flags: u16,
543 create_date: u32,
545 mod_date: u32,
546 children: Vec<StuffItInputNode>,
548 },
549}
550
551fn compress_fork(data: &[u8], method: WriteMethod) -> (u8, Vec<u8>) {
553 match method {
554 WriteMethod::Store => (0, data.to_vec()),
555 WriteMethod::Rle => {
556 let encoded = binhex::rle90_encode(data);
557 if encoded.len() < data.len() {
558 (1, encoded)
559 } else {
560 (0, data.to_vec())
561 }
562 }
563 }
564}
565
566pub fn build_archive(files: &[StuffItInput], method: WriteMethod) -> Result<Vec<u8>> {
578 let nodes: Vec<StuffItInputNode> = files.iter().cloned().map(StuffItInputNode::File).collect();
579 build_archive_tree(&nodes, method)
580}
581
582pub fn build_archive_tree(tree: &[StuffItInputNode], method: WriteMethod) -> Result<Vec<u8>> {
593 let mut body = Vec::new();
594 for node in tree {
595 emit_node(&mut body, node, method);
596 }
597
598 let total = (ARCHIVE_HEADER_LEN + body.len()) as u32;
599 let mut out = Vec::with_capacity(total as usize);
600 out.extend_from_slice(b"SIT!");
601 out.extend_from_slice(&(tree.len() as u16).to_be_bytes());
602 out.extend_from_slice(&total.to_be_bytes());
603 out.extend_from_slice(b"rLau");
604 out.push(1); out.push(0); out.extend_from_slice(&(ARCHIVE_HEADER_LEN as u32).to_be_bytes());
607 out.extend_from_slice(&[0, 0]); debug_assert_eq!(out.len(), ARCHIVE_HEADER_LEN);
609 out.extend_from_slice(&body);
610 Ok(out)
611}
612
613fn emit_node(body: &mut Vec<u8>, node: &StuffItInputNode, method: WriteMethod) {
617 match node {
618 StuffItInputNode::File(f) => emit_file_entry(body, f, method),
619 StuffItInputNode::Folder {
620 name,
621 finder_flags,
622 create_date,
623 mod_date,
624 children,
625 } => {
626 emit_folder_marker(
627 body,
628 name,
629 *finder_flags,
630 *create_date,
631 *mod_date,
632 START_FOLDER,
633 );
634 for child in children {
635 emit_node(body, child, method);
636 }
637 emit_folder_marker(body, "", 0, 0, 0, END_FOLDER);
638 }
639 }
640}
641
642fn emit_file_entry(body: &mut Vec<u8>, f: &StuffItInput, method: WriteMethod) {
643 let name_bytes = mac_name_bytes(&f.name);
644 let namelen = name_bytes.len();
645
646 let (rmethod, rcomp) = if f.resource_fork.is_empty() {
647 (0u8, Vec::new())
648 } else {
649 compress_fork(&f.resource_fork, method)
650 };
651 let (dmethod, dcomp) = compress_fork(&f.data_fork, method);
652
653 let mut h = [0u8; ENTRY_HEADER_LEN];
654 h[0] = rmethod;
655 h[1] = dmethod;
656 h[2] = namelen as u8;
657 h[3..3 + namelen].copy_from_slice(&name_bytes);
658 let fname_crc = crc16_arc(&h[2..3 + namelen]);
660 BigEndian::write_u16(&mut h[34..36], fname_crc);
661 h[66..70].copy_from_slice(&f.type_code);
662 h[70..74].copy_from_slice(&f.creator_code);
663 BigEndian::write_u16(&mut h[74..76], f.finder_flags);
664 BigEndian::write_u32(&mut h[76..80], f.create_date);
665 BigEndian::write_u32(&mut h[80..84], f.mod_date);
666 BigEndian::write_u32(&mut h[84..88], f.resource_fork.len() as u32);
667 BigEndian::write_u32(&mut h[88..92], f.data_fork.len() as u32);
668 BigEndian::write_u32(&mut h[92..96], rcomp.len() as u32);
669 BigEndian::write_u32(&mut h[96..100], dcomp.len() as u32);
670 BigEndian::write_u16(&mut h[100..102], crc16_arc(&f.resource_fork));
671 BigEndian::write_u16(&mut h[102..104], crc16_arc(&f.data_fork));
672 h[62..66].copy_from_slice(&[0xff, 0xff, 0xff, 0xff]);
674 let hdr_crc = crc16_arc(&h[0..110]);
676 BigEndian::write_u16(&mut h[110..112], hdr_crc);
677
678 body.extend_from_slice(&h);
679 body.extend_from_slice(&rcomp);
680 body.extend_from_slice(&dcomp);
681}
682
683fn emit_folder_marker(
688 body: &mut Vec<u8>,
689 name: &str,
690 finder_flags: u16,
691 create_date: u32,
692 mod_date: u32,
693 marker: u8,
694) {
695 let name_bytes = if marker == START_FOLDER {
696 mac_name_bytes(name)
697 } else {
698 Vec::new()
699 };
700 let namelen = name_bytes.len();
701
702 let mut h = [0u8; ENTRY_HEADER_LEN];
703 h[0] = marker;
706 h[1] = marker;
707 h[2] = namelen as u8;
708 if namelen > 0 {
709 h[3..3 + namelen].copy_from_slice(&name_bytes);
710 let fname_crc = crc16_arc(&h[2..3 + namelen]);
711 BigEndian::write_u16(&mut h[34..36], fname_crc);
712 }
713 BigEndian::write_u16(&mut h[74..76], finder_flags);
715 BigEndian::write_u32(&mut h[76..80], create_date);
716 BigEndian::write_u32(&mut h[80..84], mod_date);
717 let hdr_crc = crc16_arc(&h[0..110]);
720 BigEndian::write_u16(&mut h[110..112], hdr_crc);
721
722 body.extend_from_slice(&h);
723}
724
725fn mac_name_bytes(name: &str) -> Vec<u8> {
727 let mut bytes = fs_mac::hfs::utf8_to_mac_roman(name).unwrap_or_else(|_| {
728 name.chars()
729 .map(|c| if c.is_ascii() { c as u8 } else { b'_' })
730 .collect()
731 });
732 bytes.truncate(31);
733 bytes
734}
735
736#[cfg(test)]
737mod tests {
738 use super::*;
739
740 #[test]
741 fn crc16_arc_known() {
742 assert_eq!(crc16_arc(b"123456789"), 0xBB3D);
744 }
745
746 #[test]
747 fn detects_signature() {
748 let mut buf = vec![0u8; 22];
749 buf[0..4].copy_from_slice(b"SIT!");
750 buf[10..14].copy_from_slice(b"rLau");
751 assert!(is_stuffit(&buf));
752 assert!(!is_stuffit(b"not a sit file"));
753 }
754
755 #[test]
756 fn truncated_archive_is_reported_not_silently_clamped() {
757 let files = vec![
762 StuffItInput {
763 name: "Small".to_string(),
764 type_code: *b"TEXT",
765 creator_code: *b"ttxt",
766 finder_flags: 0,
767 create_date: 0,
768 mod_date: 0,
769 data_fork: b"fits before the cut".to_vec(),
770 resource_fork: Vec::new(),
771 },
772 StuffItInput {
773 name: "Big".to_string(),
774 type_code: *b"APPL",
775 creator_code: *b"Spe2",
776 finder_flags: 0,
777 create_date: 0,
778 mod_date: 0,
779 data_fork: (0..40_000u32).map(|i| (i % 251) as u8).collect(),
781 resource_fork: Vec::new(),
782 },
783 ];
784 let full = build_archive(&files, WriteMethod::Store).unwrap();
785
786 assert!(parse(&full).unwrap().truncated.is_none());
788
789 let cut = full.len() - 10_000;
791 let disk1 = full[..cut].to_vec();
792
793 let archive = parse(&disk1).expect("disk 1 must still parse and list");
794 assert_eq!(archive.entries.len(), 2, "both headers precede the cut");
795 let t = archive.truncated.expect("truncation must be recorded");
796 assert_eq!(t.declared, full.len() as u64);
797 assert_eq!(t.available, cut as u64);
798 assert_eq!(t.missing(), 10_000);
799
800 let e0 = &archive.entries[0];
802 assert_eq!(
803 decompress_fork(&disk1, e0.data.as_ref().unwrap()).unwrap(),
804 files[0].data_fork
805 );
806 let err = decompress_fork(&disk1, archive.entries[1].data.as_ref().unwrap())
808 .unwrap_err()
809 .to_string();
810 assert!(err.contains("truncated"), "unhelpful message: {err}");
811 assert!(err.contains("10000 missing"), "no shortfall in: {err}");
812 }
813
814 fn two_file_archive() -> (Vec<StuffItInput>, Vec<u8>) {
815 let mk = |name: &str, body: Vec<u8>| StuffItInput {
816 name: name.to_string(),
817 type_code: *b"BINA",
818 creator_code: *b"????",
819 finder_flags: 0,
820 create_date: 0,
821 mod_date: 0,
822 data_fork: body,
823 resource_fork: Vec::new(),
824 };
825 let files = vec![
826 mk("First", (0..3000u32).map(|i| (i % 253) as u8).collect()),
827 mk("Second", b"after the gap".to_vec()),
828 ];
829 let bytes = build_archive(&files, WriteMethod::Store).unwrap();
830 (files, bytes)
831 }
832
833 fn splice_after_first(bytes: &[u8], junk: &[u8]) -> (Vec<u8>, usize) {
836 let gap_at = ARCHIVE_HEADER_LEN + ENTRY_HEADER_LEN + 3000;
837 let mut out = bytes[..gap_at].to_vec();
838 out.extend_from_slice(junk);
839 out.extend_from_slice(&bytes[gap_at..]);
840 let total = out.len() as u32;
841 BigEndian::write_u32(&mut out[6..10], total);
842 (out, gap_at)
843 }
844
845 #[test]
846 fn bytes_between_entries_are_skipped_and_reported() {
847 let (files, bytes) = two_file_archive();
849 let junk: Vec<u8> = (0..149u32).map(|i| (i * 7 + 3) as u8).collect();
850 let (damaged, gap_at) = splice_after_first(&bytes, &junk);
851
852 let archive = parse(&damaged).expect("damaged archive must still parse");
853 assert_eq!(archive.entries.len(), 2);
854 assert_eq!(archive.entries[1].name, "Second");
855 assert_eq!(
856 archive.damage,
857 vec![SitDamage {
858 offset: gap_at as u64,
859 skipped: 149
860 }]
861 );
862 assert!(archive.damage[0].describe().contains("149"));
863 let second = decompress_fork(&damaged, archive.entries[1].data.as_ref().unwrap()).unwrap();
864 assert_eq!(second, files[1].data_fork);
865 }
866
867 #[test]
868 fn zero_block_is_not_an_entry() {
869 let (_, bytes) = two_file_archive();
871 let mut junk = vec![0u8; ENTRY_HEADER_LEN];
872 junk.extend_from_slice(&[0x80, 0xff, 0x97, 0x05, 0x7f, 0xff, 0x3f, 0x00]);
873 let (damaged, gap_at) = splice_after_first(&bytes, &junk);
874
875 let archive = parse(&damaged).expect("damaged archive must still parse");
876 let names: Vec<&str> = archive.entries.iter().map(|e| e.name.as_str()).collect();
877 assert_eq!(names, ["First", "Second"]);
878 assert_eq!(archive.damage.len(), 1);
879 assert_eq!(archive.damage[0].offset, gap_at as u64);
880 assert_eq!(archive.damage[0].skipped, junk.len() as u64);
881 }
882
883 #[test]
884 fn bad_header_with_nothing_after_it_still_errors() {
885 let (_, mut bytes) = two_file_archive();
886 let second_hdr = ARCHIVE_HEADER_LEN + ENTRY_HEADER_LEN + 3000;
887 bytes[second_hdr + 5] ^= 0xff;
888 let err = parse(&bytes).unwrap_err().to_string();
889 assert!(err.contains("CRC mismatch"), "{err}");
890 }
891
892 #[test]
893 fn write_then_read_roundtrip() {
894 let files = vec![
895 StuffItInput {
896 name: "Read Me".to_string(),
897 type_code: *b"TEXT",
898 creator_code: *b"ttxt",
899 finder_flags: 0,
900 create_date: 0xAABBCCDD,
901 mod_date: 0x11223344,
902 data_fork: b"Hello\r\rAAAAAAAAAAAAAAAAAAAA end.".to_vec(),
904 resource_fork: vec![0u8; 200],
905 },
906 StuffItInput {
907 name: "Empty Rsrc".to_string(),
908 type_code: *b"BINA",
909 creator_code: *b"????",
910 finder_flags: 0x4000,
911 create_date: 0,
912 mod_date: 0,
913 data_fork: b"just a data fork".to_vec(),
914 resource_fork: Vec::new(),
915 },
916 ];
917
918 for method in [WriteMethod::Store, WriteMethod::Rle] {
919 let bytes = build_archive(&files, method).unwrap();
920 assert!(is_stuffit(&bytes));
921 let archive = parse(&bytes).expect("our writer must parse");
922 assert_eq!(archive.entries.len(), 2);
923
924 let e0 = &archive.entries[0];
925 assert_eq!(e0.name, "Read Me");
926 assert_eq!(&e0.type_code, b"TEXT");
927 assert_eq!(e0.create_date, 0xAABBCCDD);
928 let data0 = decompress_fork(&bytes, e0.data.as_ref().unwrap()).unwrap();
930 assert_eq!(data0, files[0].data_fork);
931 let rsrc0 = decompress_fork(&bytes, e0.rsrc.as_ref().unwrap()).unwrap();
932 assert_eq!(rsrc0, files[0].resource_fork);
933
934 let e1 = &archive.entries[1];
935 assert_eq!(e1.finder_flags, 0x4000);
936 assert!(e1.rsrc.is_none());
937 let data1 = decompress_fork(&bytes, e1.data.as_ref().unwrap()).unwrap();
938 assert_eq!(data1, files[1].data_fork);
939 }
940 }
941
942 fn make_file(name: &str, body: &[u8]) -> StuffItInputNode {
943 StuffItInputNode::File(StuffItInput {
944 name: name.to_string(),
945 type_code: *b"TEXT",
946 creator_code: *b"ttxt",
947 finder_flags: 0,
948 create_date: 0,
949 mod_date: 0,
950 data_fork: body.to_vec(),
951 resource_fork: Vec::new(),
952 })
953 }
954
955 fn make_folder(name: &str, children: Vec<StuffItInputNode>) -> StuffItInputNode {
956 StuffItInputNode::Folder {
957 name: name.to_string(),
958 finder_flags: 0,
959 create_date: 0,
960 mod_date: 0,
961 children,
962 }
963 }
964
965 #[test]
966 fn empty_folder_round_trips() {
967 let tree = vec![make_folder("Apps", vec![])];
970 let bytes = build_archive_tree(&tree, WriteMethod::Store).unwrap();
971 let archive = parse(&bytes).expect("parse empty folder");
972
973 assert_eq!(archive.entries.len(), 1);
974 let only = &archive.entries[0];
975 assert!(only.is_dir, "empty folder marker should be is_dir");
976 assert_eq!(only.name, "Apps");
977 assert_eq!(only.path, vec!["Apps".to_string()]);
978 }
979
980 #[test]
981 fn folder_with_two_files_round_trips() {
982 let tree = vec![make_folder(
983 "Docs",
984 vec![
985 make_file("ReadMe", b"hello"),
986 make_file("Notes", b"more text"),
987 ],
988 )];
989 let bytes = build_archive_tree(&tree, WriteMethod::Store).unwrap();
990 let archive = parse(&bytes).expect("parse folder with files");
991
992 assert_eq!(archive.entries.len(), 3);
994 assert!(archive.entries[0].is_dir);
995 assert_eq!(archive.entries[0].name, "Docs");
996 assert_eq!(archive.entries[1].name, "ReadMe");
997 assert_eq!(archive.entries[1].path, vec!["Docs", "ReadMe"]);
998 assert_eq!(archive.entries[2].name, "Notes");
999 assert_eq!(archive.entries[2].path, vec!["Docs", "Notes"]);
1000
1001 let data = decompress_fork(&bytes, archive.entries[1].data.as_ref().unwrap()).unwrap();
1003 assert_eq!(data, b"hello");
1004 }
1005
1006 #[test]
1007 fn deeply_nested_folders_preserve_paths() {
1008 let tree = vec![make_folder(
1013 "Apps",
1014 vec![make_folder(
1015 "Word",
1016 vec![make_folder(
1017 "Templates",
1018 vec![make_file("Letter.txt", b"Dear ___,")],
1019 )],
1020 )],
1021 )];
1022 let bytes = build_archive_tree(&tree, WriteMethod::Store).unwrap();
1023 let archive = parse(&bytes).expect("parse nested folders");
1024
1025 assert_eq!(archive.entries.len(), 4);
1027 assert_eq!(
1028 archive.entries[3].path,
1029 vec!["Apps", "Word", "Templates", "Letter.txt"]
1030 );
1031 let body = decompress_fork(&bytes, archive.entries[3].data.as_ref().unwrap()).unwrap();
1032 assert_eq!(body, b"Dear ___,");
1033 }
1034
1035 #[test]
1036 fn top_level_files_after_folder_end_back_at_root() {
1037 let tree = vec![
1043 make_folder("Folder1", vec![make_file("Inner.txt", b"inside")]),
1044 make_file("TopLevel.txt", b"outside"),
1045 ];
1046 let bytes = build_archive_tree(&tree, WriteMethod::Store).unwrap();
1047 let archive = parse(&bytes).expect("parse mixed tree");
1048
1049 assert_eq!(archive.entries.len(), 3);
1051 assert!(archive.entries[0].is_dir);
1052 assert_eq!(archive.entries[0].path, vec!["Folder1"]);
1053 assert_eq!(archive.entries[1].path, vec!["Folder1", "Inner.txt"]);
1054 assert_eq!(archive.entries[2].path, vec!["TopLevel.txt"]);
1056 }
1057
1058 #[test]
1059 fn flat_build_archive_still_works_after_refactor() {
1060 let files = vec![StuffItInput {
1063 name: "Sole".to_string(),
1064 type_code: *b"TEXT",
1065 creator_code: *b"ttxt",
1066 finder_flags: 0,
1067 create_date: 0,
1068 mod_date: 0,
1069 data_fork: b"just one file".to_vec(),
1070 resource_fork: Vec::new(),
1071 }];
1072 let bytes = build_archive(&files, WriteMethod::Store).unwrap();
1073 let archive = parse(&bytes).expect("parse flat");
1074 assert_eq!(archive.entries.len(), 1);
1075 assert!(!archive.entries[0].is_dir);
1076 assert_eq!(archive.entries[0].path, vec!["Sole"]);
1077 }
1078
1079 #[test]
1082 fn parse_real_samples_if_present() {
1083 let dir = std::path::Path::new("/Users/dani/Downloads");
1084 if !dir.is_dir() {
1085 return;
1086 }
1087 let mut checked = 0;
1088 for entry in std::fs::read_dir(dir).unwrap().flatten() {
1089 let path = entry.path();
1090 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
1091 if !name.to_ascii_lowercase().ends_with(".sit.hqx") {
1092 continue;
1093 }
1094 let bytes = match std::fs::read(&path) {
1095 Ok(b) => b,
1096 Err(_) => continue,
1097 };
1098 let bh = match binhex::parse_binhex(&bytes) {
1099 Ok(b) => b,
1100 Err(_) => continue,
1101 };
1102 if find_sea_archive(&bh.data_fork).is_none() {
1106 eprintln!("{name}: skipped (not classic SIT!; likely StuffIt 5)");
1107 continue;
1108 }
1109 let archive = match parse(&bh.data_fork) {
1110 Ok(a) => a,
1111 Err(e) => panic!("parse failed for {name}: {e}"),
1112 };
1113 assert!(!archive.entries.is_empty(), "no entries parsed from {name}");
1114 for e in &archive.entries {
1115 assert!(!e.name.is_empty() || e.is_dir, "empty entry name in {name}");
1116 }
1117 let mut ok = 0;
1121 let mut skipped = 0;
1122 for e in &archive.entries {
1123 for fork in [e.data.as_ref(), e.rsrc.as_ref()].into_iter().flatten() {
1124 if fork.uncompressed_len == 0 {
1125 continue;
1126 }
1127 match decompress_fork(&bh.data_fork, fork) {
1128 Ok(_) => ok += 1,
1129 Err(_) if matches!(fork.method & 0x0f, 2 | 3 | 5 | 6 | 8 | 14 | 15) => {
1130 skipped += 1; }
1132 Err(e) => panic!("{name}: decompress failed: {e}"),
1133 }
1134 }
1135 }
1136 eprintln!(
1137 "{name}: {} entries, {ok} forks decompressed + CRC-verified, {skipped} unsupported-codec",
1138 archive.entries.len()
1139 );
1140 checked += 1;
1141 if checked >= 6 {
1142 break;
1143 }
1144 }
1145 eprintln!("validated {checked} real .sit samples");
1146 }
1147}