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
29fn 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
40struct 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
92pub 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 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 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 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 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 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 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 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 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 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 #[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}