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    /// Stretches of unexpected bytes the entry walk skipped; see [`SitDamage`].
138    pub damage: Vec<SitDamage>,
139}
140
141/// Bytes between two entries that no header accounts for, skipped by the entry walk (#101).
142/// See `docs/native_mac_archives.md` section 6a.
143#[derive(Debug, Clone, Copy, PartialEq, Eq)]
144pub struct SitDamage {
145    /// Offset from the `SIT!` signature where an entry header was expected.
146    pub offset: u64,
147    /// Bytes skipped before the next valid entry header.
148    pub skipped: u64,
149}
150
151impl SitDamage {
152    /// One-line ASCII summary for logs and the CLI.
153    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/// An archive whose header declares a longer stream than is present.
164///
165/// The entry headers sit early in the stream, so such an archive still *parses*
166/// and *lists* — but any entry whose compressed fork lies past the cut cannot be
167/// extracted, because those bytes are simply not in the file.
168///
169/// The usual cause is a StuffIt InstallerMaker installer, which fills disk 1 to
170/// capacity and continues the same byte stream on disk 2..N. Handed only disk 1,
171/// we see a complete, CRC-valid file carrying a partial archive. Recording this
172/// at parse time lets the extract path say so up front rather than emitting a
173/// per-entry "extends past end of archive" that reads like a decoder bug.
174#[derive(Debug, Clone, Copy, PartialEq, Eq)]
175pub struct SitTruncation {
176    /// Total archive length declared in the header at `[6..10]`.
177    pub declared: u64,
178    /// Bytes actually present from the archive base to end-of-buffer.
179    pub available: u64,
180}
181
182impl SitTruncation {
183    /// Bytes the header accounts for that the container does not hold.
184    pub fn missing(&self) -> u64 {
185        self.declared.saturating_sub(self.available)
186    }
187
188    /// One-line ASCII summary for logs and the CLI (no Unicode glyphs — the
189    /// egui font has no symbol coverage).
190    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
218/// CRC-16/ARC (reflected, poly 0xA001, init 0) — StuffIt's header and fork CRC.
219pub(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
234/// Detect whether `data` begins with a classic StuffIt archive.
235pub fn is_stuffit(data: &[u8]) -> bool {
236    data.len() >= 14 && &data[0..4] == b"SIT!" && &data[10..14] == b"rLau"
237}
238
239/// Locate the StuffIt archive inside a `.sea` (self-extracting archive). A
240/// StuffIt SEA is a Mac application whose payload is a plain `.sit` stream; we
241/// scan for the `SIT!`…`rLau` signature. Returns the offset of the archive.
242pub fn find_sea_archive(data: &[u8]) -> Option<usize> {
243    if is_stuffit(data) {
244        return Some(0);
245    }
246    // Scan for "SIT!" followed by "rLau" 10 bytes later.
247    data.windows(14)
248        .position(|w| &w[0..4] == b"SIT!" && &w[10..14] == b"rLau")
249}
250
251/// Parse a StuffIt archive (or the `.sit` stream embedded in a `.sea`).
252pub 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    // Clamp to what we actually have (some SEAs over-report) so the entry walk
262    // stays in bounds, but remember the shortfall: it's the difference between
263    // "this archive is complete" and "this is disk 1 of an InstallerMaker set".
264    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; // zero padding to the end
289            }
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            // currdir now includes this folder as its last component.
311            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; // folder marker carries no fork data
324            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        // Regular file.
334        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
396/// CRC-valid and not all zero (a zero block's CRC is also zero).
397fn 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
401/// Offset of the next plausible, CRC-valid entry header in `from..`, if any.
402fn 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
413/// A hint naming the damage when the file shows the marks of a text conversion.
414/// See `docs/native_mac_archives.md` section 6a for why each signature is conclusive.
415pub(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
436/// Decompress one fork from the archive bytes, verifying its CRC.
437///
438/// `archive` is the full file passed to [`parse`] (offsets in [`ForkInfo`] are
439/// absolute within it). Returns an error for encrypted forks and for
440/// compression methods not yet implemented.
441pub 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        // Not a bounds-check bug: `compressed_len` is the on-disk length, so
449        // this fires only when those bytes genuinely aren't in the container
450        // (a truncated / multi-disk archive). Quote the arithmetic so the next
451        // reader doesn't have to re-derive it.
452        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    // Arsenic (15) carries its own checksum; everything else uses CRC-16/ARC.
490    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// ---------------------------------------------------------------------------
504// Writer
505// ---------------------------------------------------------------------------
506
507/// Compression to use when writing a fork.
508#[derive(Debug, Clone, Copy, PartialEq, Eq)]
509pub enum WriteMethod {
510    /// Store uncompressed (method 0).
511    Store,
512    /// RLE90 (method 1), falling back to Store when it doesn't shrink the fork.
513    Rle,
514}
515
516/// A single file to place into a StuffIt archive.
517#[derive(Debug, Clone)]
518pub struct StuffItInput {
519    /// Mac filename (truncated to 31 bytes).
520    pub name: String,
521    pub type_code: [u8; 4],
522    pub creator_code: [u8; 4],
523    pub finder_flags: u16,
524    /// Mac 1904-epoch timestamps (0 is acceptable).
525    pub create_date: u32,
526    pub mod_date: u32,
527    pub data_fork: Vec<u8>,
528    pub resource_fork: Vec<u8>,
529}
530
531/// A node in a StuffIt input tree — either a file or a folder containing
532/// further nodes. Used by [`build_archive_tree`] to emit nested directory
533/// structures via the START_FOLDER (0x20) / END_FOLDER (0x21) marker
534/// entries that classic StuffIt uses for hierarchy.
535#[derive(Debug, Clone)]
536pub enum StuffItInputNode {
537    File(StuffItInput),
538    Folder {
539        /// Mac folder name (truncated to 31 bytes).
540        name: String,
541        /// Finder flags; usually 0 for ordinary folders.
542        finder_flags: u16,
543        /// Mac 1904-epoch timestamps; 0 is acceptable.
544        create_date: u32,
545        mod_date: u32,
546        /// Nested entries inside this folder, in archive order.
547        children: Vec<StuffItInputNode>,
548    },
549}
550
551/// Compress a fork for writing, returning `(method_byte, bytes)`.
552fn 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
566/// Build a classic StuffIt (`.sit`) archive from a flat list of files.
567///
568/// Produces correct 112-byte entry-header CRCs and per-fork CRC-16/ARC values
569/// (the checksums real readers verify), so the result round-trips through
570/// [`parse`] and is accepted by The Unarchiver. The 16-bit archive-header field
571/// at offset 20 (whose derivation is unknown and which XAD ignores) is written
572/// as zero.
573///
574/// Use [`build_archive_tree`] when you need to emit nested directory
575/// structures; this entry point keeps the older flat-file shape for callers
576/// that build a single top-level entry list.
577pub 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
582/// Build a classic StuffIt (`.sit`) archive from a node tree, emitting
583/// START_FOLDER (0x20) / END_FOLDER (0x21) marker entries for nested
584/// directories. Children of a `Folder` node appear between its start
585/// and end markers; the round-trip through [`parse`] reconstructs the
586/// directory path in each entry's `path` field.
587///
588/// The archive-header `num_files` field is written as the **top-level**
589/// entry count, matching what classic StuffIt produced. Readers
590/// (including ours) iterate entries until they hit `archiveLength`,
591/// so this field is informational.
592pub 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); // version
605    out.push(0); // reserved
606    out.extend_from_slice(&(ARCHIVE_HEADER_LEN as u32).to_be_bytes());
607    out.extend_from_slice(&[0, 0]); // archive-header field at [20]; XAD ignores it
608    debug_assert_eq!(out.len(), ARCHIVE_HEADER_LEN);
609    out.extend_from_slice(&body);
610    Ok(out)
611}
612
613/// Serialize a single node (file or folder) into `body`. For folders this
614/// emits the START marker, recurses into each child, and emits the END
615/// marker; for files it writes the standard 112-byte header + forks.
616fn 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    // [34..36] filename CRC (size byte + name); not verified by readers.
659    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    // [62..66] child offset: -1 marks a file entry (as real archives do).
673    h[62..66].copy_from_slice(&[0xff, 0xff, 0xff, 0xff]);
674    // Header CRC over the first 110 bytes.
675    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
683/// Emit a folder-start or folder-end marker. The parser keys off the
684/// method byte (`START_FOLDER` or `END_FOLDER`) after masking the
685/// encryption + "contains encrypted" bits; the rest of the 112-byte
686/// header follows the same layout as a file entry, with zeroed forks.
687fn 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    // Both fork-method bytes carry the marker. The parser accepts either
704    // position; we write both so the resulting archive is unambiguous.
705    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    // No type / creator codes on folder markers (the parser zeros them out).
714    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    // Fork lengths and compressed lengths stay zero — folders carry no data.
718    // [62..66] child offset is left at zero for folder markers.
719    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
725/// Encode a name to at most 31 Mac Roman bytes (lossy ASCII fallback).
726fn 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        // CRC-16/ARC of "123456789" is 0xBB3D (standard check value).
743        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        // Mirrors a StuffIt InstallerMaker disk 1: a well-formed prefix of an
758        // archive whose header still declares the full multi-disk length. Such a
759        // file parses and lists fine, so the truncation has to be reported
760        // explicitly or it surfaces only as a confusing per-entry skip.
761        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                // Incompressible-ish so Store really stores 40k.
780                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        // A complete archive must stay quiet.
787        assert!(parse(&full).unwrap().truncated.is_none());
788
789        // Chop mid-fork, the way filling a floppy does.
790        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        // The entry before the cut still extracts...
801        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        // ...and the one past it fails naming the real cause, not a bounds bug.
807        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    /// Splice `junk` in after the first entry's forks and fix the declared total
834    /// to cover it, the shape of the #101 samples.
835    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        // Issue #101: 149 stray bytes after the first entry. The rest must list and extract.
848        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        // Poser 1.0.sit: the gap opens with 112 zero bytes, whose header CRC is also zero.
870        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                // Long runs so RLE actually engages.
903                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            // decompress_fork verifies the per-fork CRC internally.
929            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        // An empty folder is two markers (start + end) and nothing in
968        // between. The parser should surface a single dir entry.
969        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        // 1 start marker + 2 files + (end marker is consumed silently).
993        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        // Verify fork data round-trips for files inside the folder.
1002        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        // Apps/
1009        //   Word/
1010        //     Templates/
1011        //       Letter.txt
1012        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        // 3 start markers + 1 file (ends are silent).
1026        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        // Mixing folders and top-level files exercises currdir popping
1038        // back to root after END_FOLDER.
1039        //   Folder1/
1040        //     Inner.txt
1041        //   TopLevel.txt
1042        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        // Folder start + Inner.txt + TopLevel.txt.
1050        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        // After the END_FOLDER, currdir is back at root.
1055        assert_eq!(archive.entries[2].path, vec!["TopLevel.txt"]);
1056    }
1057
1058    #[test]
1059    fn flat_build_archive_still_works_after_refactor() {
1060        // The original flat-file build_archive now delegates to
1061        // build_archive_tree; confirm the existing call shape stays valid.
1062        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    /// Parses real `.sit` samples if present (decoded from `.sit.hqx` on the
1080    /// fly). Validates the container parser + header CRCs end-to-end.
1081    #[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            // The .sit is the data fork of the BinHex file. Skip payloads that
1103            // aren't a classic StuffIt stream (e.g. the newer StuffIt 5 format,
1104            // which starts with "StuffIt (c)…" and is out of scope here).
1105            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            // Actually decompress every supported fork; decompress_fork
1118            // verifies the per-fork CRC-16/ARC, so success means the codec
1119            // (including method 13 / LZ+Huffman) round-trips correctly.
1120            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; // codec not implemented yet
1131                        }
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}