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/// A minimal big-endian cursor over the archive bytes.
30struct Cursor<'a> {
31    data: &'a [u8],
32    pos: usize,
33}
34
35impl<'a> Cursor<'a> {
36    fn need(&self, n: usize) -> Result<()> {
37        if self.pos + n > self.data.len() {
38            bail!("StuffIt 5: unexpected end of archive at {}", self.pos);
39        }
40        Ok(())
41    }
42    fn u8(&mut self) -> Result<u8> {
43        self.need(1)?;
44        let v = self.data[self.pos];
45        self.pos += 1;
46        Ok(v)
47    }
48    fn u16(&mut self) -> Result<u16> {
49        self.need(2)?;
50        let v = u16::from_be_bytes([self.data[self.pos], self.data[self.pos + 1]]);
51        self.pos += 2;
52        Ok(v)
53    }
54    fn u32(&mut self) -> Result<u32> {
55        self.need(4)?;
56        let v = u32::from_be_bytes([
57            self.data[self.pos],
58            self.data[self.pos + 1],
59            self.data[self.pos + 2],
60            self.data[self.pos + 3],
61        ]);
62        self.pos += 4;
63        Ok(v)
64    }
65    fn bytes(&mut self, n: usize) -> Result<&'a [u8]> {
66        self.need(n)?;
67        let s = &self.data[self.pos..self.pos + n];
68        self.pos += n;
69        Ok(s)
70    }
71    fn skip(&mut self, n: usize) -> Result<()> {
72        self.need(n)?;
73        self.pos += n;
74        Ok(())
75    }
76    fn seek(&mut self, abs: usize) {
77        self.pos = abs;
78    }
79}
80
81/// Parse a StuffIt 5 archive into the shared [`StuffItArchive`] representation.
82pub fn parse(data: &[u8]) -> Result<StuffItArchive> {
83    if !is_stuffit5(data) {
84        bail!("not a StuffIt 5 archive");
85    }
86    if data.len() < 100 {
87        bail!("StuffIt 5: truncated header");
88    }
89    let version = data[82];
90    if version != 5 {
91        bail!("StuffIt 5: unexpected archive version {version}");
92    }
93    // numfiles @ 92 (u16 BE), firstoffs @ 94 (u32 BE). firstoffs is the
94    // authoritative offset of the first top-level entry, so we don't need to
95    // walk the optional comment/hash header sections.
96    let numfiles = u16::from_be_bytes([data[92], data[93]]) as usize;
97    let firstoffs = u32::from_be_bytes([data[94], data[95], data[96], data[97]]) as usize;
98
99    let mut cur = Cursor {
100        data,
101        pos: firstoffs,
102    };
103    let mut entries = Vec::new();
104    let mut dirs: HashMap<u32, Vec<String>> = HashMap::new();
105
106    let mut total = numfiles;
107    let mut done = 0usize;
108    // Safety bound: never iterate more times than there are possible entries.
109    let max_iters = data.len() / 48 + numfiles + 16;
110    let mut iters = 0usize;
111
112    while done < total {
113        iters += 1;
114        if iters > max_iters {
115            bail!("StuffIt 5: entry walk did not terminate");
116        }
117        if cur.pos + 48 > data.len() {
118            break;
119        }
120        let offs = cur.pos as u32;
121
122        let headid = cur.u32()?;
123        if headid != SIT5_ID {
124            bail!("StuffIt 5: bad entry id {headid:#x} at {offs}");
125        }
126        let entry_version = cur.u8()?;
127        cur.skip(1)?;
128        let headersize = cur.u16()? as usize;
129        let headerend = offs as usize + headersize;
130        cur.skip(1)?;
131        let flags = cur.u8()?;
132        let creation_date = cur.u32()?;
133        let modification_date = cur.u32()?;
134        let _prevoffs = cur.u32()?;
135        let _nextoffs = cur.u32()?;
136        let diroffs = cur.u32()?;
137        let namelength = cur.u16()? as usize;
138        let _headercrc = cur.u16()?;
139        let datalength = cur.u32()?;
140        let datacomplen = cur.u32()?;
141        let datacrc = cur.u16()?;
142        cur.skip(2)?;
143
144        let is_dir = flags & FLAG_DIRECTORY != 0;
145        let crypted = flags & FLAG_CRYPTED != 0;
146
147        let mut dir_numfiles = 0usize;
148        let mut datamethod = 0u8;
149        if is_dir {
150            dir_numfiles = cur.u16()? as usize;
151            if datalength == 0xffff_ffff {
152                // Phantom marker entry that follows each directory; skip it.
153                // (Header is exactly 48 bytes; cursor is already positioned.)
154                total += 1;
155                done += 1;
156                continue;
157            }
158        } else {
159            datamethod = cur.u8()?;
160            let passlen = cur.u8()? as usize;
161            if crypted && datalength != 0 {
162                bail!("StuffIt 5: encrypted archives are not supported");
163            } else if passlen != 0 {
164                bail!("StuffIt 5: unexpected password data");
165            }
166        }
167
168        let namedata = cur.bytes(namelength)?.to_vec();
169
170        if cur.pos < headerend {
171            let commentsize = cur.u16()? as usize;
172            cur.skip(2)?;
173            cur.skip(commentsize)?;
174        }
175
176        // Second block (present for both files and directories).
177        let something = cur.u16()?;
178        cur.skip(2)?;
179        let filetype = cur.bytes(4)?.try_into().unwrap();
180        let filecreator = cur.bytes(4)?.try_into().unwrap();
181        let finder_flags = cur.u16()?;
182        if entry_version == 1 {
183            cur.skip(22)?;
184        } else {
185            cur.skip(18)?;
186        }
187
188        let has_resource = something & 0x01 != 0;
189        let mut resourcecomplen = 0u32;
190        let mut resourcelength = 0u32;
191        let mut resourcecrc = 0u16;
192        let mut resourcemethod = 0u8;
193        if has_resource {
194            resourcelength = cur.u32()?;
195            resourcecomplen = cur.u32()?;
196            resourcecrc = cur.u16()?;
197            cur.skip(2)?;
198            resourcemethod = cur.u8()?;
199            let passlen = cur.u8()? as usize;
200            if crypted && resourcelength != 0 {
201                bail!("StuffIt 5: encrypted archives are not supported");
202            } else if passlen != 0 {
203                bail!("StuffIt 5: unexpected password data");
204            }
205        }
206
207        let datastart = cur.pos as u64;
208
209        let name = fs_mac::hfs::decode_mac_filename(&namedata);
210        let parent = dirs.get(&diroffs).cloned().unwrap_or_default();
211        let mut path = parent.clone();
212        path.push(name.clone());
213
214        if is_dir {
215            dirs.insert(offs, path.clone());
216            entries.push(StuffItEntry {
217                path,
218                name,
219                is_dir: true,
220                type_code: [0; 4],
221                creator_code: [0; 4],
222                finder_flags,
223                create_date: creation_date,
224                mod_date: modification_date,
225                data: None,
226                rsrc: None,
227            });
228            cur.seek(datastart as usize);
229            total += dir_numfiles;
230        } else {
231            let rsrc = if has_resource && (resourcelength > 0 || resourcecomplen > 0) {
232                Some(ForkInfo {
233                    method: resourcemethod,
234                    codec: ForkCodec::StuffIt,
235                    encrypted: false,
236                    uncompressed_len: resourcelength,
237                    compressed_len: resourcecomplen,
238                    crc: resourcecrc,
239                    crc32: 0,
240                    offset: datastart,
241                })
242            } else {
243                None
244            };
245            let data_fork = Some(ForkInfo {
246                method: datamethod,
247                codec: ForkCodec::StuffIt,
248                encrypted: false,
249                uncompressed_len: datalength,
250                compressed_len: datacomplen,
251                crc: datacrc,
252                crc32: 0,
253                offset: datastart + resourcecomplen as u64,
254            });
255            entries.push(StuffItEntry {
256                path,
257                name,
258                is_dir: false,
259                type_code: filetype,
260                creator_code: filecreator,
261                finder_flags,
262                create_date: creation_date,
263                mod_date: modification_date,
264                data: data_fork,
265                rsrc,
266            });
267            cur.seek(datastart as usize + resourcecomplen as usize + datacomplen as usize);
268        }
269        done += 1;
270    }
271
272    Ok(StuffItArchive {
273        entries,
274        truncated: None,
275    })
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281
282    #[test]
283    fn rejects_non_sit5() {
284        assert!(!is_stuffit5(b"SIT!......rLau"));
285        assert!(parse(b"nope").is_err());
286    }
287
288    /// End-to-end against a real StuffIt 5 archive if present (decoded from its
289    /// `.sit.hqx` on the fly). Validates the container parser + Arsenic codec +
290    /// per-fork CRCs (decompress_fork verifies CRCs internally).
291    #[test]
292    fn extract_real_sit5_if_present() {
293        let path = std::path::Path::new("/Users/dani/Downloads/Apple_Mac_OS_7.1_800k.sit__0.hqx");
294        if !path.is_file() {
295            return;
296        }
297        let raw = std::fs::read(path).unwrap();
298        let bh = fs_mac::binhex::parse_binhex(&raw).expect("binhex decode");
299        let archive = parse(&bh.data_fork).expect("sit5 parse");
300        let files: Vec<_> = archive.entries.iter().filter(|e| !e.is_dir).collect();
301        assert!(!files.is_empty());
302
303        let mut decompressed = 0;
304        for e in &files {
305            if let Some(f) = e.data.as_ref() {
306                if f.uncompressed_len == 0 {
307                    continue;
308                }
309                let out = crate::stuffit::decompress_fork(&bh.data_fork, f)
310                    .unwrap_or_else(|err| panic!("{}: {err}", e.name));
311                assert_eq!(out.len(), f.uncompressed_len as usize);
312                decompressed += 1;
313            }
314        }
315        eprintln!("SIT5: decompressed + CRC-verified {decompressed} data forks");
316        assert!(decompressed > 0);
317    }
318}