1use 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
24pub fn is_stuffit5(data: &[u8]) -> bool {
26 data.len() >= SIT5_MAGIC.len() && &data[..SIT5_MAGIC.len()] == SIT5_MAGIC
27}
28
29struct 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
81pub 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 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 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 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 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 #[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}