Skip to main content

arc_mac/
stuffit.rs

1// SPDX-License-Identifier: MIT
2//! Classic StuffIt (`.sit`) + StuffIt self-extracting (`.sea`) reader.
3//!
4//! Container layout (reverse-engineered; see XADMaster `XADStuffItParser.m`,
5//! mirrored in `docs/native_mac_archives.md`):
6//!
7//! ```text
8//! Archive header (22 bytes):
9//!   [0..4]  "SIT!" magic
10//!   [4..6]  number of entries (u16 BE)        (informational; we walk to EOF)
11//!   [6..10] total archive length (u32 BE)
12//!   [10..14] "rLau" secondary signature
13//!   [14..22] version + reserved + header CRC
14//!
15//! Per-entry header (112 bytes):
16//!   [0]   resource-fork compression method
17//!   [1]   data-fork compression method
18//!   [2]   filename length (<= 31)
19//!   [3..34] filename (Mac Roman)
20//!   [66..70] file type        [70..74] creator
21//!   [74..76] Finder flags
22//!   [76..80] creation date    [80..84] modification date  (Mac 1904 epoch)
23//!   [84..88] resource fork uncompressed length
24//!   [88..92] data fork uncompressed length
25//!   [92..96] resource fork compressed length
26//!   [96..100] data fork compressed length
27//!   [100..102] resource CRC   [102..104] data CRC  (CRC-16/ARC)
28//!   [110..112] header CRC (CRC-16/ARC over [0..110])
29//! Then: <compressed resource fork><compressed data fork>
30//! ```
31//!
32//! A method byte's high bit (`0x80`) marks an encrypted fork; `0x20`/`0x21`
33//! (after masking the encryption + folder-contains-encrypted bits) mark the
34//! start/end of a folder. The low nibble is the compression method.
35
36use 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;
48/// Mask that strips the encryption + "contains encrypted" bits, leaving the
49/// folder marker (or the raw method) behind.
50const FOLDER_MASK: u8 = !(ENCRYPTED_FLAG | FOLDER_CONTAINS_ENCRYPTED);
51
52/// Which decompressor a [`ForkInfo`] needs. StuffIt and Compact Pro share the
53/// same entry/fork plumbing (the directory tree in [`StuffItEntry`] and every
54/// output format in `extract`) but use entirely different fork codecs and
55/// checksums, so the codec tag picks the right decompressor at extract time.
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum ForkCodec {
58    /// StuffIt method lives in [`ForkInfo::method`]; checksum is the
59    /// CRC-16/ARC in [`ForkInfo::crc`].
60    StuffIt,
61    /// Compact Pro (`super::compactpro`): byte-level RLE, optionally preceded
62    /// by an LZH (LZSS + Huffman) layer. Compact Pro stores one CRC-32 per
63    /// file (in [`ForkInfo::crc32`]) over the resource fork followed by the
64    /// data fork, so it's verified once per entry rather than per fork.
65    CompactPro { lzh: bool },
66}
67
68/// One fork (data or resource) of a StuffIt or Compact Pro entry.
69#[derive(Debug, Clone)]
70pub struct ForkInfo {
71    /// StuffIt compression method (low nibble: 0=none, 1=RLE90, 3=Huffman,
72    /// 13=LZ+Huffman, 15=Arsenic, …). Ignored for [`ForkCodec::CompactPro`].
73    pub method: u8,
74    /// Which codec decompresses this fork.
75    pub codec: ForkCodec,
76    /// True when the fork is password-encrypted (not supported for extraction).
77    pub encrypted: bool,
78    /// Decompressed length in bytes.
79    pub uncompressed_len: u32,
80    /// Compressed length in bytes (as stored in the archive).
81    pub compressed_len: u32,
82    /// Stored CRC-16/ARC of the decompressed fork (StuffIt forks only).
83    pub crc: u16,
84    /// Stored CRC-32 of the decompressed fork (Compact Pro forks only).
85    pub crc32: u32,
86    /// Absolute byte offset of the compressed data within the archive.
87    pub offset: u64,
88}
89
90impl ForkInfo {
91    /// Human-readable compression method name, codec-aware.
92    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/// One file or folder in a StuffIt archive.
102#[derive(Debug, Clone)]
103pub struct StuffItEntry {
104    /// Path components from the archive root (last element is the name).
105    pub path: Vec<String>,
106    /// Leaf name (Mac Roman → UTF-8).
107    pub name: String,
108    /// True for folder markers (no fork data).
109    pub is_dir: bool,
110    pub type_code: [u8; 4],
111    pub creator_code: [u8; 4],
112    pub finder_flags: u16,
113    /// Mac 1904-epoch timestamps.
114    pub create_date: u32,
115    pub mod_date: u32,
116    /// Data fork (absent for folders).
117    pub data: Option<ForkInfo>,
118    /// Resource fork (absent for folders).
119    pub rsrc: Option<ForkInfo>,
120}
121
122impl StuffItEntry {
123    /// Display path joined with `/`.
124    pub fn display_path(&self) -> String {
125        self.path.join("/")
126    }
127}
128
129/// A parsed StuffIt archive (entries in archive order; directory structure is
130/// reflected in each entry's `path`).
131#[derive(Debug, Clone)]
132pub struct StuffItArchive {
133    pub entries: Vec<StuffItEntry>,
134    /// `Some` when the archive header declares more bytes than the container
135    /// actually holds — see [`SitTruncation`].
136    pub truncated: Option<SitTruncation>,
137}
138
139/// An archive whose header declares a longer stream than is present.
140///
141/// The entry headers sit early in the stream, so such an archive still *parses*
142/// and *lists* — but any entry whose compressed fork lies past the cut cannot be
143/// extracted, because those bytes are simply not in the file.
144///
145/// The usual cause is a StuffIt InstallerMaker installer, which fills disk 1 to
146/// capacity and continues the same byte stream on disk 2..N. Handed only disk 1,
147/// we see a complete, CRC-valid file carrying a partial archive. Recording this
148/// at parse time lets the extract path say so up front rather than emitting a
149/// per-entry "extends past end of archive" that reads like a decoder bug.
150#[derive(Debug, Clone, Copy, PartialEq, Eq)]
151pub struct SitTruncation {
152    /// Total archive length declared in the header at `[6..10]`.
153    pub declared: u64,
154    /// Bytes actually present from the archive base to end-of-buffer.
155    pub available: u64,
156}
157
158impl SitTruncation {
159    /// Bytes the header accounts for that the container does not hold.
160    pub fn missing(&self) -> u64 {
161        self.declared.saturating_sub(self.available)
162    }
163
164    /// One-line ASCII summary for logs and the CLI (no Unicode glyphs — the
165    /// egui font has no symbol coverage).
166    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
194/// CRC-16/ARC (reflected, poly 0xA001, init 0) — StuffIt's header and fork CRC.
195pub(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
210/// Detect whether `data` begins with a classic StuffIt archive.
211pub fn is_stuffit(data: &[u8]) -> bool {
212    data.len() >= 14 && &data[0..4] == b"SIT!" && &data[10..14] == b"rLau"
213}
214
215/// Locate the StuffIt archive inside a `.sea` (self-extracting archive). A
216/// StuffIt SEA is a Mac application whose payload is a plain `.sit` stream; we
217/// scan for the `SIT!`…`rLau` signature. Returns the offset of the archive.
218pub fn find_sea_archive(data: &[u8]) -> Option<usize> {
219    if is_stuffit(data) {
220        return Some(0);
221    }
222    // Scan for "SIT!" followed by "rLau" 10 bytes later.
223    data.windows(14)
224        .position(|w| &w[0..4] == b"SIT!" && &w[10..14] == b"rLau")
225}
226
227/// Parse a StuffIt archive (or the `.sit` stream embedded in a `.sea`).
228pub 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    // Clamp to what we actually have (some SEAs over-report) so the entry walk
238    // stays in bounds, but remember the shortfall: it's the difference between
239    // "this archive is complete" and "this is disk 1 of an InstallerMaker set".
240    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            // currdir now includes this folder as its last component.
274            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; // folder marker carries no fork data
287            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        // Regular file.
297        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
355/// Decompress one fork from the archive bytes, verifying its CRC.
356///
357/// `archive` is the full file passed to [`parse`] (offsets in [`ForkInfo`] are
358/// absolute within it). Returns an error for encrypted forks and for
359/// compression methods not yet implemented.
360pub 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        // Not a bounds-check bug: `compressed_len` is the on-disk length, so
368        // this fires only when those bytes genuinely aren't in the container
369        // (a truncated / multi-disk archive). Quote the arithmetic so the next
370        // reader doesn't have to re-derive it.
371        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    // Arsenic (15) carries its own checksum; everything else uses CRC-16/ARC.
409    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// ---------------------------------------------------------------------------
423// Writer
424// ---------------------------------------------------------------------------
425
426/// Compression to use when writing a fork.
427#[derive(Debug, Clone, Copy, PartialEq, Eq)]
428pub enum WriteMethod {
429    /// Store uncompressed (method 0).
430    Store,
431    /// RLE90 (method 1), falling back to Store when it doesn't shrink the fork.
432    Rle,
433}
434
435/// A single file to place into a StuffIt archive.
436#[derive(Debug, Clone)]
437pub struct StuffItInput {
438    /// Mac filename (truncated to 31 bytes).
439    pub name: String,
440    pub type_code: [u8; 4],
441    pub creator_code: [u8; 4],
442    pub finder_flags: u16,
443    /// Mac 1904-epoch timestamps (0 is acceptable).
444    pub create_date: u32,
445    pub mod_date: u32,
446    pub data_fork: Vec<u8>,
447    pub resource_fork: Vec<u8>,
448}
449
450/// A node in a StuffIt input tree — either a file or a folder containing
451/// further nodes. Used by [`build_archive_tree`] to emit nested directory
452/// structures via the START_FOLDER (0x20) / END_FOLDER (0x21) marker
453/// entries that classic StuffIt uses for hierarchy.
454#[derive(Debug, Clone)]
455pub enum StuffItInputNode {
456    File(StuffItInput),
457    Folder {
458        /// Mac folder name (truncated to 31 bytes).
459        name: String,
460        /// Finder flags; usually 0 for ordinary folders.
461        finder_flags: u16,
462        /// Mac 1904-epoch timestamps; 0 is acceptable.
463        create_date: u32,
464        mod_date: u32,
465        /// Nested entries inside this folder, in archive order.
466        children: Vec<StuffItInputNode>,
467    },
468}
469
470/// Compress a fork for writing, returning `(method_byte, bytes)`.
471fn 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
485/// Build a classic StuffIt (`.sit`) archive from a flat list of files.
486///
487/// Produces correct 112-byte entry-header CRCs and per-fork CRC-16/ARC values
488/// (the checksums real readers verify), so the result round-trips through
489/// [`parse`] and is accepted by The Unarchiver. The 16-bit archive-header field
490/// at offset 20 (whose derivation is unknown and which XAD ignores) is written
491/// as zero.
492///
493/// Use [`build_archive_tree`] when you need to emit nested directory
494/// structures; this entry point keeps the older flat-file shape for callers
495/// that build a single top-level entry list.
496pub 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
501/// Build a classic StuffIt (`.sit`) archive from a node tree, emitting
502/// START_FOLDER (0x20) / END_FOLDER (0x21) marker entries for nested
503/// directories. Children of a `Folder` node appear between its start
504/// and end markers; the round-trip through [`parse`] reconstructs the
505/// directory path in each entry's `path` field.
506///
507/// The archive-header `num_files` field is written as the **top-level**
508/// entry count, matching what classic StuffIt produced. Readers
509/// (including ours) iterate entries until they hit `archiveLength`,
510/// so this field is informational.
511pub 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); // version
524    out.push(0); // reserved
525    out.extend_from_slice(&(ARCHIVE_HEADER_LEN as u32).to_be_bytes());
526    out.extend_from_slice(&[0, 0]); // archive-header field at [20]; XAD ignores it
527    debug_assert_eq!(out.len(), ARCHIVE_HEADER_LEN);
528    out.extend_from_slice(&body);
529    Ok(out)
530}
531
532/// Serialize a single node (file or folder) into `body`. For folders this
533/// emits the START marker, recurses into each child, and emits the END
534/// marker; for files it writes the standard 112-byte header + forks.
535fn 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    // [34..36] filename CRC (size byte + name); not verified by readers.
578    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    // [62..66] child offset: -1 marks a file entry (as real archives do).
592    h[62..66].copy_from_slice(&[0xff, 0xff, 0xff, 0xff]);
593    // Header CRC over the first 110 bytes.
594    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
602/// Emit a folder-start or folder-end marker. The parser keys off the
603/// method byte (`START_FOLDER` or `END_FOLDER`) after masking the
604/// encryption + "contains encrypted" bits; the rest of the 112-byte
605/// header follows the same layout as a file entry, with zeroed forks.
606fn 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    // Both fork-method bytes carry the marker. The parser accepts either
623    // position; we write both so the resulting archive is unambiguous.
624    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    // No type / creator codes on folder markers (the parser zeros them out).
633    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    // Fork lengths and compressed lengths stay zero — folders carry no data.
637    // [62..66] child offset is left at zero for folder markers.
638    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
644/// Encode a name to at most 31 Mac Roman bytes (lossy ASCII fallback).
645fn 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        // CRC-16/ARC of "123456789" is 0xBB3D (standard check value).
662        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        // Mirrors a StuffIt InstallerMaker disk 1: a well-formed prefix of an
677        // archive whose header still declares the full multi-disk length. Such a
678        // file parses and lists fine, so the truncation has to be reported
679        // explicitly or it surfaces only as a confusing per-entry skip.
680        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                // Incompressible-ish so Store really stores 40k.
699                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        // A complete archive must stay quiet.
706        assert!(parse(&full).unwrap().truncated.is_none());
707
708        // Chop mid-fork, the way filling a floppy does.
709        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        // The entry before the cut still extracts...
720        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        // ...and the one past it fails naming the real cause, not a bounds bug.
726        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                // Long runs so RLE actually engages.
744                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            // decompress_fork verifies the per-fork CRC internally.
770            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        // An empty folder is two markers (start + end) and nothing in
809        // between. The parser should surface a single dir entry.
810        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        // 1 start marker + 2 files + (end marker is consumed silently).
834        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        // Verify fork data round-trips for files inside the folder.
843        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        // Apps/
850        //   Word/
851        //     Templates/
852        //       Letter.txt
853        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        // 3 start markers + 1 file (ends are silent).
867        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        // Mixing folders and top-level files exercises currdir popping
879        // back to root after END_FOLDER.
880        //   Folder1/
881        //     Inner.txt
882        //   TopLevel.txt
883        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        // Folder start + Inner.txt + TopLevel.txt.
891        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        // After the END_FOLDER, currdir is back at root.
896        assert_eq!(archive.entries[2].path, vec!["TopLevel.txt"]);
897    }
898
899    #[test]
900    fn flat_build_archive_still_works_after_refactor() {
901        // The original flat-file build_archive now delegates to
902        // build_archive_tree; confirm the existing call shape stays valid.
903        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    /// Parses real `.sit` samples if present (decoded from `.sit.hqx` on the
921    /// fly). Validates the container parser + header CRCs end-to-end.
922    #[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            // The .sit is the data fork of the BinHex file. Skip payloads that
944            // aren't a classic StuffIt stream (e.g. the newer StuffIt 5 format,
945            // which starts with "StuffIt (c)…" and is out of scope here).
946            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            // Actually decompress every supported fork; decompress_fork
959            // verifies the per-fork CRC-16/ARC, so success means the codec
960            // (including method 13 / LZ+Huffman) round-trips correctly.
961            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; // codec not implemented yet
972                        }
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}