Skip to main content

arc_mac/
stuffit5.rs

1// SPDX-License-Identifier: LGPL-2.1-or-later
2// Derived from XADMaster, Copyright (c) 2017-present MacPaw Way Ltd.; see THIRD_PARTY_NOTICES.md.
3//! StuffIt 5 (`.sit` v5, magic "StuffIt (c)1997…") container reader.
4//!
5//! A different container from classic `SIT!`, but it reuses the same fork
6//! compression methods (0 none, 13 LZ+Huffman, 15 Arsenic), so once parsed the
7//! forks flow through [`super::stuffit::decompress_fork`]. Ported from XADMaster
8//! `XADStuffIt5Parser.m`.
9//!
10//! Common in the wild (e.g. Apple's own Mac OS 7.1 800K disk set). Encrypted
11//! archives are detected and rejected.
12
13use anyhow::{bail, Result};
14use std::collections::HashMap;
15
16use super::stuffit::{ForkCodec, ForkInfo, StuffItArchive, StuffItEntry};
17
18const SIT5_MAGIC: &[u8] = b"StuffIt (c)1997";
19const SIT5_ID: u32 = 0xA5A5_A5A5;
20
21const FLAG_DIRECTORY: u8 = 0x40;
22const FLAG_CRYPTED: u8 = 0x20;
23
24/// True if `data` begins with the StuffIt 5 signature.
25pub fn is_stuffit5(data: &[u8]) -> bool {
26    data.len() >= SIT5_MAGIC.len() && &data[..SIT5_MAGIC.len()] == SIT5_MAGIC
27}
28
29/// [`crate::stuffit::damage_hint`], falling back to naming the header's own line-ending damage.
30fn line_ending_hint(data: &[u8]) -> &'static str {
31    match crate::stuffit::damage_hint(data) {
32        "" => {
33            " - the file went through a text-mode (line-ending) conversion that rewrote the \
34             compressed data; it cannot be repaired, fetch a copy transferred in binary mode"
35        }
36        h => h,
37    }
38}
39
40/// A minimal big-endian cursor over the archive bytes.
41struct Cursor<'a> {
42    data: &'a [u8],
43    pos: usize,
44}
45
46impl<'a> Cursor<'a> {
47    fn need(&self, n: usize) -> Result<()> {
48        if self.pos + n > self.data.len() {
49            bail!("StuffIt 5: unexpected end of archive at {}", self.pos);
50        }
51        Ok(())
52    }
53    fn u8(&mut self) -> Result<u8> {
54        self.need(1)?;
55        let v = self.data[self.pos];
56        self.pos += 1;
57        Ok(v)
58    }
59    fn u16(&mut self) -> Result<u16> {
60        self.need(2)?;
61        let v = u16::from_be_bytes([self.data[self.pos], self.data[self.pos + 1]]);
62        self.pos += 2;
63        Ok(v)
64    }
65    fn u32(&mut self) -> Result<u32> {
66        self.need(4)?;
67        let v = u32::from_be_bytes([
68            self.data[self.pos],
69            self.data[self.pos + 1],
70            self.data[self.pos + 2],
71            self.data[self.pos + 3],
72        ]);
73        self.pos += 4;
74        Ok(v)
75    }
76    fn bytes(&mut self, n: usize) -> Result<&'a [u8]> {
77        self.need(n)?;
78        let s = &self.data[self.pos..self.pos + n];
79        self.pos += n;
80        Ok(s)
81    }
82    fn skip(&mut self, n: usize) -> Result<()> {
83        self.need(n)?;
84        self.pos += n;
85        Ok(())
86    }
87    fn seek(&mut self, abs: usize) {
88        self.pos = abs;
89    }
90}
91
92/// Parse a StuffIt 5 archive into the shared [`StuffItArchive`] representation.
93pub fn parse(data: &[u8]) -> Result<StuffItArchive> {
94    if !is_stuffit5(data) {
95        bail!("not a StuffIt 5 archive");
96    }
97    if data.len() < 100 {
98        bail!("StuffIt 5: truncated header");
99    }
100    // The signature text ends in CR LF at 78; a text-mode transfer collapses it (#102).
101    if &data[78..80] != b"\r\n" {
102        bail!(
103            "StuffIt 5: damaged archive header - the CR LF at offset 78 reads {:02x} {:02x}{}",
104            data[78],
105            data[79],
106            line_ending_hint(data)
107        );
108    }
109    let version = data[82];
110    if version != 5 {
111        bail!("StuffIt 5: unexpected archive version {version}");
112    }
113    // numfiles @ 92 (u16 BE), firstoffs @ 94 (u32 BE). firstoffs is the
114    // authoritative offset of the first top-level entry, so we don't need to
115    // walk the optional comment/hash header sections.
116    let numfiles = u16::from_be_bytes([data[92], data[93]]) as usize;
117    let firstoffs = u32::from_be_bytes([data[94], data[95], data[96], data[97]]) as usize;
118
119    let mut cur = Cursor {
120        data,
121        pos: firstoffs,
122    };
123    let mut entries = Vec::new();
124    let mut dirs: HashMap<u32, Vec<String>> = HashMap::new();
125
126    let mut total = numfiles;
127    let mut done = 0usize;
128    // Safety bound: never iterate more times than there are possible entries.
129    let max_iters = data.len() / 48 + numfiles + 16;
130    let mut iters = 0usize;
131
132    while done < total {
133        iters += 1;
134        if iters > max_iters {
135            bail!("StuffIt 5: entry walk did not terminate");
136        }
137        if cur.pos + 48 > data.len() {
138            break;
139        }
140        let offs = cur.pos as u32;
141
142        let headid = cur.u32()?;
143        if headid != SIT5_ID {
144            bail!(
145                "StuffIt 5: bad entry id {headid:#x} at {offs}{}",
146                crate::stuffit::damage_hint(data)
147            );
148        }
149        let entry_version = cur.u8()?;
150        cur.skip(1)?;
151        let headersize = cur.u16()? as usize;
152        let headerend = offs as usize + headersize;
153        cur.skip(1)?;
154        let flags = cur.u8()?;
155        let creation_date = cur.u32()?;
156        let modification_date = cur.u32()?;
157        let _prevoffs = cur.u32()?;
158        let _nextoffs = cur.u32()?;
159        let diroffs = cur.u32()?;
160        let namelength = cur.u16()? as usize;
161        let _headercrc = cur.u16()?;
162        let datalength = cur.u32()?;
163        let datacomplen = cur.u32()?;
164        let datacrc = cur.u16()?;
165        cur.skip(2)?;
166
167        let is_dir = flags & FLAG_DIRECTORY != 0;
168        let crypted = flags & FLAG_CRYPTED != 0;
169
170        let mut dir_numfiles = 0usize;
171        let mut datamethod = 0u8;
172        if is_dir {
173            dir_numfiles = cur.u16()? as usize;
174            if datalength == 0xffff_ffff {
175                // Phantom marker entry that follows each directory; skip it.
176                // (Header is exactly 48 bytes; cursor is already positioned.)
177                total += 1;
178                done += 1;
179                continue;
180            }
181        } else {
182            datamethod = cur.u8()?;
183            let passlen = cur.u8()? as usize;
184            if crypted && datalength != 0 {
185                bail!("StuffIt 5: encrypted archives are not supported");
186            } else if passlen != 0 {
187                bail!("StuffIt 5: unexpected password data");
188            }
189        }
190
191        let namedata = cur.bytes(namelength)?.to_vec();
192
193        if cur.pos < headerend {
194            let commentsize = cur.u16()? as usize;
195            cur.skip(2)?;
196            cur.skip(commentsize)?;
197        }
198
199        // Second block (present for both files and directories).
200        let something = cur.u16()?;
201        cur.skip(2)?;
202        let filetype = cur.bytes(4)?.try_into().unwrap();
203        let filecreator = cur.bytes(4)?.try_into().unwrap();
204        let finder_flags = cur.u16()?;
205        if entry_version == 1 {
206            cur.skip(22)?;
207        } else {
208            cur.skip(18)?;
209        }
210
211        let has_resource = something & 0x01 != 0;
212        let mut resourcecomplen = 0u32;
213        let mut resourcelength = 0u32;
214        let mut resourcecrc = 0u16;
215        let mut resourcemethod = 0u8;
216        if has_resource {
217            resourcelength = cur.u32()?;
218            resourcecomplen = cur.u32()?;
219            resourcecrc = cur.u16()?;
220            cur.skip(2)?;
221            resourcemethod = cur.u8()?;
222            let passlen = cur.u8()? as usize;
223            if crypted && resourcelength != 0 {
224                bail!("StuffIt 5: encrypted archives are not supported");
225            } else if passlen != 0 {
226                bail!("StuffIt 5: unexpected password data");
227            }
228        }
229
230        let datastart = cur.pos as u64;
231
232        let name = fs_mac::hfs::decode_mac_filename(&namedata);
233        let parent = dirs.get(&diroffs).cloned().unwrap_or_default();
234        let mut path = parent.clone();
235        path.push(name.clone());
236
237        if is_dir {
238            dirs.insert(offs, path.clone());
239            entries.push(StuffItEntry {
240                path,
241                name,
242                is_dir: true,
243                type_code: [0; 4],
244                creator_code: [0; 4],
245                finder_flags,
246                create_date: creation_date,
247                mod_date: modification_date,
248                data: None,
249                rsrc: None,
250            });
251            cur.seek(datastart as usize);
252            total += dir_numfiles;
253        } else {
254            let rsrc = if has_resource && (resourcelength > 0 || resourcecomplen > 0) {
255                Some(ForkInfo {
256                    method: resourcemethod,
257                    codec: ForkCodec::StuffIt,
258                    encrypted: false,
259                    uncompressed_len: resourcelength,
260                    compressed_len: resourcecomplen,
261                    crc: resourcecrc,
262                    crc32: 0,
263                    offset: datastart,
264                })
265            } else {
266                None
267            };
268            let data_fork = Some(ForkInfo {
269                method: datamethod,
270                codec: ForkCodec::StuffIt,
271                encrypted: false,
272                uncompressed_len: datalength,
273                compressed_len: datacomplen,
274                crc: datacrc,
275                crc32: 0,
276                offset: datastart + resourcecomplen as u64,
277            });
278            entries.push(StuffItEntry {
279                path,
280                name,
281                is_dir: false,
282                type_code: filetype,
283                creator_code: filecreator,
284                finder_flags,
285                create_date: creation_date,
286                mod_date: modification_date,
287                data: data_fork,
288                rsrc,
289            });
290            cur.seek(datastart as usize + resourcecomplen as usize + datacomplen as usize);
291        }
292        done += 1;
293    }
294
295    Ok(StuffItArchive {
296        entries,
297        truncated: None,
298        damage: Vec::new(),
299    })
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305
306    /// The 100-byte archive header plus 14 reserved bytes; the first entry goes at 114.
307    fn sit5_header() -> Vec<u8> {
308        let mut h =
309            b"StuffIt (c)1997-1998 Aladdin Systems, Inc., http://www.aladdinsys.com/StuffIt/\r\n"
310                .to_vec();
311        assert_eq!(h.len(), 80);
312        h.extend_from_slice(&[0x1a, 0x00, 5, 0x10]);
313        h.extend_from_slice(&[0, 0, 0, 0, 0, 0, 0, 0x72, 0, 1, 0, 0, 0, 0x72, 0, 0]);
314        h.extend_from_slice(b"\xa5\xa5Reserved\xa5\xa5\x00\xa5");
315        assert_eq!(h.len(), 114);
316        h
317    }
318
319    /// Filler that has every byte value except the ones in `missing`.
320    fn filler_without(missing: &[u8], len: usize) -> Vec<u8> {
321        (0..len)
322            .map(|i| (i % 256) as u8)
323            .filter(|b| !missing.contains(b))
324            .collect()
325    }
326
327    #[test]
328    fn line_ending_conversion_is_named() {
329        // Issue #102: CR LF -> LF at offset 78 shifts the flags byte (0x10) into the version slot.
330        let mut data = sit5_header();
331        data.remove(78);
332        data.extend(filler_without(&[0x0d], 8000));
333        let err = parse(&data).unwrap_err().to_string();
334        assert!(!err.contains("version 16"), "{err}");
335        assert!(err.contains("offset 78 reads 0a 1a"), "{err}");
336        assert!(err.contains("line-ending"), "{err}");
337    }
338
339    #[test]
340    fn utf8_conversion_is_named() {
341        // Issue #103: a UTF-8 round trip drops C0, C1 and F5-FF, so the A5A5A5A5 entry id is lost.
342        let mut data = sit5_header();
343        data.extend_from_slice(&[0x00, 0x01, 0x00, 0x00, 0x38, 0x00, 0x40, 0x14, 0x50, 0x00]);
344        let mut missing = vec![0xc0, 0xc1];
345        missing.extend(0xf5..=0xff);
346        data.extend(filler_without(&missing, 8000));
347        let err = parse(&data).unwrap_err().to_string();
348        assert!(err.contains("bad entry id"), "{err}");
349        assert!(err.contains("UTF-8"), "{err}");
350    }
351
352    #[test]
353    fn rejects_non_sit5() {
354        assert!(!is_stuffit5(b"SIT!......rLau"));
355        assert!(parse(b"nope").is_err());
356    }
357
358    /// End-to-end against a real StuffIt 5 archive if present (decoded from its
359    /// `.sit.hqx` on the fly). Validates the container parser + Arsenic codec +
360    /// per-fork CRCs (decompress_fork verifies CRCs internally).
361    #[test]
362    fn extract_real_sit5_if_present() {
363        let path = std::path::Path::new("/Users/dani/Downloads/Apple_Mac_OS_7.1_800k.sit__0.hqx");
364        if !path.is_file() {
365            return;
366        }
367        let raw = std::fs::read(path).unwrap();
368        let bh = fs_mac::binhex::parse_binhex(&raw).expect("binhex decode");
369        let archive = parse(&bh.data_fork).expect("sit5 parse");
370        let files: Vec<_> = archive.entries.iter().filter(|e| !e.is_dir).collect();
371        assert!(!files.is_empty());
372
373        let mut decompressed = 0;
374        for e in &files {
375            if let Some(f) = e.data.as_ref() {
376                if f.uncompressed_len == 0 {
377                    continue;
378                }
379                let out = crate::stuffit::decompress_fork(&bh.data_fork, f)
380                    .unwrap_or_else(|err| panic!("{}: {err}", e.name));
381                assert_eq!(out.len(), f.uncompressed_len as usize);
382                decompressed += 1;
383            }
384        }
385        eprintln!("SIT5: decompressed + CRC-verified {decompressed} data forks");
386        assert!(decompressed > 0);
387    }
388}