use anyhow::{bail, Result};
use byteorder::{BigEndian, ByteOrder};
use fs_mac::binhex;
const ARCHIVE_HEADER_LEN: usize = 22;
const ENTRY_HEADER_LEN: usize = 112;
const ENCRYPTED_FLAG: u8 = 0x80;
const FOLDER_CONTAINS_ENCRYPTED: u8 = 0x10;
const START_FOLDER: u8 = 0x20;
const END_FOLDER: u8 = 0x21;
const FOLDER_MASK: u8 = !(ENCRYPTED_FLAG | FOLDER_CONTAINS_ENCRYPTED);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ForkCodec {
StuffIt,
CompactPro { lzh: bool },
}
#[derive(Debug, Clone)]
pub struct ForkInfo {
pub method: u8,
pub codec: ForkCodec,
pub encrypted: bool,
pub uncompressed_len: u32,
pub compressed_len: u32,
pub crc: u16,
pub crc32: u32,
pub offset: u64,
}
impl ForkInfo {
pub fn method_name(&self) -> &'static str {
match self.codec {
ForkCodec::StuffIt => method_name(self.method),
ForkCodec::CompactPro { lzh: true } => "Compact Pro (LZH+RLE)",
ForkCodec::CompactPro { lzh: false } => "Compact Pro (RLE)",
}
}
}
#[derive(Debug, Clone)]
pub struct StuffItEntry {
pub path: Vec<String>,
pub name: String,
pub is_dir: bool,
pub type_code: [u8; 4],
pub creator_code: [u8; 4],
pub finder_flags: u16,
pub create_date: u32,
pub mod_date: u32,
pub data: Option<ForkInfo>,
pub rsrc: Option<ForkInfo>,
}
impl StuffItEntry {
pub fn display_path(&self) -> String {
self.path.join("/")
}
}
#[derive(Debug, Clone)]
pub struct StuffItArchive {
pub entries: Vec<StuffItEntry>,
pub truncated: Option<SitTruncation>,
pub damage: Vec<SitDamage>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SitDamage {
pub offset: u64,
pub skipped: u64,
}
impl SitDamage {
pub fn describe(&self) -> String {
format!(
"Warning: damaged StuffIt archive - {} unexpected bytes at offset {} where an entry \
header should be; skipped to the next valid header. The entry before the gap is \
likely damaged too and may fail its CRC.",
self.skipped, self.offset
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SitTruncation {
pub declared: u64,
pub available: u64,
}
impl SitTruncation {
pub fn missing(&self) -> u64 {
self.declared.saturating_sub(self.available)
}
pub fn describe(&self) -> String {
format!(
"Warning: truncated StuffIt archive - header declares {} bytes, {} present ({} missing). \
Any entry whose data lies past the cut will be skipped. StuffIt InstallerMaker splits \
large installers across several disks, so this is likely the first disk of a set.",
self.declared,
self.available,
self.missing()
)
}
}
fn method_name(method: u8) -> &'static str {
match method & 0x0f {
0 => "None",
1 => "RLE",
2 => "Compress (LZW)",
3 => "Huffman",
5 => "LZAH",
6 => "Fixed Huffman",
8 => "MW",
13 => "LZ+Huffman",
14 => "Installer",
15 => "Arsenic",
_ => "Unknown",
}
}
pub(crate) fn crc16_arc(data: &[u8]) -> u16 {
let mut crc: u16 = 0;
for &b in data {
crc ^= b as u16;
for _ in 0..8 {
if crc & 1 != 0 {
crc = (crc >> 1) ^ 0xA001;
} else {
crc >>= 1;
}
}
}
crc
}
pub fn is_stuffit(data: &[u8]) -> bool {
data.len() >= 14 && &data[0..4] == b"SIT!" && &data[10..14] == b"rLau"
}
pub fn find_sea_archive(data: &[u8]) -> Option<usize> {
if is_stuffit(data) {
return Some(0);
}
data.windows(14)
.position(|w| &w[0..4] == b"SIT!" && &w[10..14] == b"rLau")
}
pub fn parse(data: &[u8]) -> Result<StuffItArchive> {
let base = find_sea_archive(data)
.ok_or_else(|| anyhow::anyhow!("not a StuffIt archive (no SIT!/rLau signature)"))?;
let archive = &data[base..];
if archive.len() < ARCHIVE_HEADER_LEN {
bail!("StuffIt: truncated archive header");
}
let declared_total = BigEndian::read_u32(&archive[6..10]) as usize;
let total_size = declared_total.min(archive.len());
let truncated = (declared_total > archive.len()).then_some(SitTruncation {
declared: declared_total as u64,
available: archive.len() as u64,
});
let mut entries = Vec::new();
let mut damage = Vec::new();
let mut currdir: Vec<String> = Vec::new();
let mut pos = ARCHIVE_HEADER_LEN;
while pos + ENTRY_HEADER_LEN <= total_size {
let h = &archive[pos..pos + ENTRY_HEADER_LEN];
if !entry_header_valid(h) {
if let Some(next) = find_next_entry_header(archive, pos + 1, total_size) {
damage.push(SitDamage {
offset: pos as u64,
skipped: (next - pos) as u64,
});
pos = next;
continue;
}
if h[..110].iter().all(|&b| b == 0) {
break; }
let stored_hdr_crc = BigEndian::read_u16(&h[110..112]);
let computed = crc16_arc(&h[0..110]);
bail!(
"StuffIt: entry header CRC mismatch at offset {pos} (got {computed:#06x}, expected {stored_hdr_crc:#06x}){}",
damage_hint(data)
);
}
let rmethod = h[0];
let dmethod = h[1];
let mut namelen = h[2] as usize;
if namelen > 31 {
namelen = 31;
}
let name = fs_mac::hfs::decode_mac_filename(&h[3..3 + namelen]);
let data_start = (base + pos + ENTRY_HEADER_LEN) as u64;
if (dmethod & FOLDER_MASK) == START_FOLDER || (rmethod & FOLDER_MASK) == START_FOLDER {
currdir.push(name.clone());
entries.push(StuffItEntry {
path: currdir.clone(),
name,
is_dir: true,
type_code: [0; 4],
creator_code: [0; 4],
finder_flags: BigEndian::read_u16(&h[74..76]),
create_date: BigEndian::read_u32(&h[76..80]),
mod_date: BigEndian::read_u32(&h[80..84]),
data: None,
rsrc: None,
});
pos += ENTRY_HEADER_LEN; continue;
}
if (dmethod & FOLDER_MASK) == END_FOLDER || (rmethod & FOLDER_MASK) == END_FOLDER {
currdir.pop();
pos += ENTRY_HEADER_LEN;
continue;
}
let rsrc_len = BigEndian::read_u32(&h[84..88]);
let data_len = BigEndian::read_u32(&h[88..92]);
let rsrc_clen = BigEndian::read_u32(&h[92..96]);
let data_clen = BigEndian::read_u32(&h[96..100]);
let rsrc_crc = BigEndian::read_u16(&h[100..102]);
let data_crc = BigEndian::read_u16(&h[102..104]);
let mut type_code = [0u8; 4];
type_code.copy_from_slice(&h[66..70]);
let mut creator_code = [0u8; 4];
creator_code.copy_from_slice(&h[70..74]);
let rsrc = if rsrc_len > 0 || rsrc_clen > 0 {
Some(ForkInfo {
method: rmethod & !ENCRYPTED_FLAG,
codec: ForkCodec::StuffIt,
encrypted: rmethod & ENCRYPTED_FLAG != 0,
uncompressed_len: rsrc_len,
compressed_len: rsrc_clen,
crc: rsrc_crc,
crc32: 0,
offset: data_start,
})
} else {
None
};
let data = Some(ForkInfo {
method: dmethod & !ENCRYPTED_FLAG,
codec: ForkCodec::StuffIt,
encrypted: dmethod & ENCRYPTED_FLAG != 0,
uncompressed_len: data_len,
compressed_len: data_clen,
crc: data_crc,
crc32: 0,
offset: data_start + rsrc_clen as u64,
});
let mut path = currdir.clone();
path.push(name.clone());
entries.push(StuffItEntry {
path,
name,
is_dir: false,
type_code,
creator_code,
finder_flags: BigEndian::read_u16(&h[74..76]),
create_date: BigEndian::read_u32(&h[76..80]),
mod_date: BigEndian::read_u32(&h[80..84]),
data,
rsrc,
});
pos += ENTRY_HEADER_LEN + rsrc_clen as usize + data_clen as usize;
}
Ok(StuffItArchive {
entries,
truncated,
damage,
})
}
fn entry_header_valid(h: &[u8]) -> bool {
BigEndian::read_u16(&h[110..112]) == crc16_arc(&h[0..110]) && h[..110].iter().any(|&b| b != 0)
}
fn find_next_entry_header(archive: &[u8], from: usize, total_size: usize) -> Option<usize> {
let method_ok = |m: u8| {
let f = m & FOLDER_MASK;
f == START_FOLDER || f == END_FOLDER || (m & !ENCRYPTED_FLAG) <= 0x0f
};
(from..(total_size + 1).saturating_sub(ENTRY_HEADER_LEN)).find(|&q| {
let h = &archive[q..q + ENTRY_HEADER_LEN];
h[2] <= 31 && method_ok(h[0]) && method_ok(h[1]) && entry_header_valid(h)
})
}
pub(crate) fn damage_hint(data: &[u8]) -> &'static str {
if data.len() < 4096 {
return "";
}
let mut seen = [false; 256];
for &b in data {
seen[b as usize] = true;
}
if !seen[0x0d] {
return " - the file holds no CR (0x0d) bytes at all, so it went through a text-mode \
(line-ending) conversion that rewrote the compressed data; it cannot be repaired, \
fetch a copy transferred in binary mode";
}
if !seen[0xc0] && !seen[0xc1] && !seen[0xf5..].contains(&true) {
return " - the file holds none of the bytes C0, C1, F5-FF (the ones UTF-8 never uses), \
so it went through a text-encoding conversion that rewrote the compressed data; it \
cannot be repaired, fetch a copy transferred in binary mode";
}
""
}
pub fn decompress_fork(archive: &[u8], fork: &ForkInfo) -> Result<Vec<u8>> {
if fork.encrypted {
bail!("StuffIt: encrypted forks are not supported");
}
let start = fork.offset as usize;
let end = start + fork.compressed_len as usize;
if end > archive.len() {
bail!(
"StuffIt: fork data extends past end of archive (needs {} compressed bytes at offset \
{start}, archive holds {}; {} missing - the archive is truncated)",
fork.compressed_len,
archive.len(),
end - archive.len()
);
}
let comp = &archive[start..end];
let want = fork.uncompressed_len as usize;
let out = match fork.method & 0x0f {
0 => comp.to_vec(),
1 => binhex::rle90_decode(comp),
2 => super::stuffit_lzw::decompress(comp, want, 0x8e)?,
3 => super::stuffit_huffman::decompress(comp, want)?,
5 => super::stuffit_lzah::decompress(comp, want)?,
13 => super::stuffit_lzh::decompress(comp, want)?,
15 => super::stuffit_arsenic::decompress(comp, want)?,
m => bail!(
"StuffIt: compression method {m} ({}) not yet supported",
method_name(fork.method)
),
};
let out = if out.len() > want {
out[..want].to_vec()
} else {
out
};
if out.len() != want {
bail!(
"StuffIt: decompressed length {} != expected {want}",
out.len()
);
}
if fork.method & 0x0f != 15 {
let got = crc16_arc(&out);
if got != fork.crc {
bail!(
"StuffIt: fork CRC mismatch (got {got:#06x}, expected {:#06x})",
fork.crc
);
}
}
Ok(out)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WriteMethod {
Store,
Rle,
}
#[derive(Debug, Clone)]
pub struct StuffItInput {
pub name: String,
pub type_code: [u8; 4],
pub creator_code: [u8; 4],
pub finder_flags: u16,
pub create_date: u32,
pub mod_date: u32,
pub data_fork: Vec<u8>,
pub resource_fork: Vec<u8>,
}
#[derive(Debug, Clone)]
pub enum StuffItInputNode {
File(StuffItInput),
Folder {
name: String,
finder_flags: u16,
create_date: u32,
mod_date: u32,
children: Vec<StuffItInputNode>,
},
}
fn compress_fork(data: &[u8], method: WriteMethod) -> (u8, Vec<u8>) {
match method {
WriteMethod::Store => (0, data.to_vec()),
WriteMethod::Rle => {
let encoded = binhex::rle90_encode(data);
if encoded.len() < data.len() {
(1, encoded)
} else {
(0, data.to_vec())
}
}
}
}
pub fn build_archive(files: &[StuffItInput], method: WriteMethod) -> Result<Vec<u8>> {
let nodes: Vec<StuffItInputNode> = files.iter().cloned().map(StuffItInputNode::File).collect();
build_archive_tree(&nodes, method)
}
pub fn build_archive_tree(tree: &[StuffItInputNode], method: WriteMethod) -> Result<Vec<u8>> {
let mut body = Vec::new();
for node in tree {
emit_node(&mut body, node, method);
}
let total = (ARCHIVE_HEADER_LEN + body.len()) as u32;
let mut out = Vec::with_capacity(total as usize);
out.extend_from_slice(b"SIT!");
out.extend_from_slice(&(tree.len() as u16).to_be_bytes());
out.extend_from_slice(&total.to_be_bytes());
out.extend_from_slice(b"rLau");
out.push(1); out.push(0); out.extend_from_slice(&(ARCHIVE_HEADER_LEN as u32).to_be_bytes());
out.extend_from_slice(&[0, 0]); debug_assert_eq!(out.len(), ARCHIVE_HEADER_LEN);
out.extend_from_slice(&body);
Ok(out)
}
fn emit_node(body: &mut Vec<u8>, node: &StuffItInputNode, method: WriteMethod) {
match node {
StuffItInputNode::File(f) => emit_file_entry(body, f, method),
StuffItInputNode::Folder {
name,
finder_flags,
create_date,
mod_date,
children,
} => {
emit_folder_marker(
body,
name,
*finder_flags,
*create_date,
*mod_date,
START_FOLDER,
);
for child in children {
emit_node(body, child, method);
}
emit_folder_marker(body, "", 0, 0, 0, END_FOLDER);
}
}
}
fn emit_file_entry(body: &mut Vec<u8>, f: &StuffItInput, method: WriteMethod) {
let name_bytes = mac_name_bytes(&f.name);
let namelen = name_bytes.len();
let (rmethod, rcomp) = if f.resource_fork.is_empty() {
(0u8, Vec::new())
} else {
compress_fork(&f.resource_fork, method)
};
let (dmethod, dcomp) = compress_fork(&f.data_fork, method);
let mut h = [0u8; ENTRY_HEADER_LEN];
h[0] = rmethod;
h[1] = dmethod;
h[2] = namelen as u8;
h[3..3 + namelen].copy_from_slice(&name_bytes);
let fname_crc = crc16_arc(&h[2..3 + namelen]);
BigEndian::write_u16(&mut h[34..36], fname_crc);
h[66..70].copy_from_slice(&f.type_code);
h[70..74].copy_from_slice(&f.creator_code);
BigEndian::write_u16(&mut h[74..76], f.finder_flags);
BigEndian::write_u32(&mut h[76..80], f.create_date);
BigEndian::write_u32(&mut h[80..84], f.mod_date);
BigEndian::write_u32(&mut h[84..88], f.resource_fork.len() as u32);
BigEndian::write_u32(&mut h[88..92], f.data_fork.len() as u32);
BigEndian::write_u32(&mut h[92..96], rcomp.len() as u32);
BigEndian::write_u32(&mut h[96..100], dcomp.len() as u32);
BigEndian::write_u16(&mut h[100..102], crc16_arc(&f.resource_fork));
BigEndian::write_u16(&mut h[102..104], crc16_arc(&f.data_fork));
h[62..66].copy_from_slice(&[0xff, 0xff, 0xff, 0xff]);
let hdr_crc = crc16_arc(&h[0..110]);
BigEndian::write_u16(&mut h[110..112], hdr_crc);
body.extend_from_slice(&h);
body.extend_from_slice(&rcomp);
body.extend_from_slice(&dcomp);
}
fn emit_folder_marker(
body: &mut Vec<u8>,
name: &str,
finder_flags: u16,
create_date: u32,
mod_date: u32,
marker: u8,
) {
let name_bytes = if marker == START_FOLDER {
mac_name_bytes(name)
} else {
Vec::new()
};
let namelen = name_bytes.len();
let mut h = [0u8; ENTRY_HEADER_LEN];
h[0] = marker;
h[1] = marker;
h[2] = namelen as u8;
if namelen > 0 {
h[3..3 + namelen].copy_from_slice(&name_bytes);
let fname_crc = crc16_arc(&h[2..3 + namelen]);
BigEndian::write_u16(&mut h[34..36], fname_crc);
}
BigEndian::write_u16(&mut h[74..76], finder_flags);
BigEndian::write_u32(&mut h[76..80], create_date);
BigEndian::write_u32(&mut h[80..84], mod_date);
let hdr_crc = crc16_arc(&h[0..110]);
BigEndian::write_u16(&mut h[110..112], hdr_crc);
body.extend_from_slice(&h);
}
fn mac_name_bytes(name: &str) -> Vec<u8> {
let mut bytes = fs_mac::hfs::utf8_to_mac_roman(name).unwrap_or_else(|_| {
name.chars()
.map(|c| if c.is_ascii() { c as u8 } else { b'_' })
.collect()
});
bytes.truncate(31);
bytes
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn crc16_arc_known() {
assert_eq!(crc16_arc(b"123456789"), 0xBB3D);
}
#[test]
fn detects_signature() {
let mut buf = vec![0u8; 22];
buf[0..4].copy_from_slice(b"SIT!");
buf[10..14].copy_from_slice(b"rLau");
assert!(is_stuffit(&buf));
assert!(!is_stuffit(b"not a sit file"));
}
#[test]
fn truncated_archive_is_reported_not_silently_clamped() {
let files = vec![
StuffItInput {
name: "Small".to_string(),
type_code: *b"TEXT",
creator_code: *b"ttxt",
finder_flags: 0,
create_date: 0,
mod_date: 0,
data_fork: b"fits before the cut".to_vec(),
resource_fork: Vec::new(),
},
StuffItInput {
name: "Big".to_string(),
type_code: *b"APPL",
creator_code: *b"Spe2",
finder_flags: 0,
create_date: 0,
mod_date: 0,
data_fork: (0..40_000u32).map(|i| (i % 251) as u8).collect(),
resource_fork: Vec::new(),
},
];
let full = build_archive(&files, WriteMethod::Store).unwrap();
assert!(parse(&full).unwrap().truncated.is_none());
let cut = full.len() - 10_000;
let disk1 = full[..cut].to_vec();
let archive = parse(&disk1).expect("disk 1 must still parse and list");
assert_eq!(archive.entries.len(), 2, "both headers precede the cut");
let t = archive.truncated.expect("truncation must be recorded");
assert_eq!(t.declared, full.len() as u64);
assert_eq!(t.available, cut as u64);
assert_eq!(t.missing(), 10_000);
let e0 = &archive.entries[0];
assert_eq!(
decompress_fork(&disk1, e0.data.as_ref().unwrap()).unwrap(),
files[0].data_fork
);
let err = decompress_fork(&disk1, archive.entries[1].data.as_ref().unwrap())
.unwrap_err()
.to_string();
assert!(err.contains("truncated"), "unhelpful message: {err}");
assert!(err.contains("10000 missing"), "no shortfall in: {err}");
}
fn two_file_archive() -> (Vec<StuffItInput>, Vec<u8>) {
let mk = |name: &str, body: Vec<u8>| StuffItInput {
name: name.to_string(),
type_code: *b"BINA",
creator_code: *b"????",
finder_flags: 0,
create_date: 0,
mod_date: 0,
data_fork: body,
resource_fork: Vec::new(),
};
let files = vec![
mk("First", (0..3000u32).map(|i| (i % 253) as u8).collect()),
mk("Second", b"after the gap".to_vec()),
];
let bytes = build_archive(&files, WriteMethod::Store).unwrap();
(files, bytes)
}
fn splice_after_first(bytes: &[u8], junk: &[u8]) -> (Vec<u8>, usize) {
let gap_at = ARCHIVE_HEADER_LEN + ENTRY_HEADER_LEN + 3000;
let mut out = bytes[..gap_at].to_vec();
out.extend_from_slice(junk);
out.extend_from_slice(&bytes[gap_at..]);
let total = out.len() as u32;
BigEndian::write_u32(&mut out[6..10], total);
(out, gap_at)
}
#[test]
fn bytes_between_entries_are_skipped_and_reported() {
let (files, bytes) = two_file_archive();
let junk: Vec<u8> = (0..149u32).map(|i| (i * 7 + 3) as u8).collect();
let (damaged, gap_at) = splice_after_first(&bytes, &junk);
let archive = parse(&damaged).expect("damaged archive must still parse");
assert_eq!(archive.entries.len(), 2);
assert_eq!(archive.entries[1].name, "Second");
assert_eq!(
archive.damage,
vec![SitDamage {
offset: gap_at as u64,
skipped: 149
}]
);
assert!(archive.damage[0].describe().contains("149"));
let second = decompress_fork(&damaged, archive.entries[1].data.as_ref().unwrap()).unwrap();
assert_eq!(second, files[1].data_fork);
}
#[test]
fn zero_block_is_not_an_entry() {
let (_, bytes) = two_file_archive();
let mut junk = vec![0u8; ENTRY_HEADER_LEN];
junk.extend_from_slice(&[0x80, 0xff, 0x97, 0x05, 0x7f, 0xff, 0x3f, 0x00]);
let (damaged, gap_at) = splice_after_first(&bytes, &junk);
let archive = parse(&damaged).expect("damaged archive must still parse");
let names: Vec<&str> = archive.entries.iter().map(|e| e.name.as_str()).collect();
assert_eq!(names, ["First", "Second"]);
assert_eq!(archive.damage.len(), 1);
assert_eq!(archive.damage[0].offset, gap_at as u64);
assert_eq!(archive.damage[0].skipped, junk.len() as u64);
}
#[test]
fn bad_header_with_nothing_after_it_still_errors() {
let (_, mut bytes) = two_file_archive();
let second_hdr = ARCHIVE_HEADER_LEN + ENTRY_HEADER_LEN + 3000;
bytes[second_hdr + 5] ^= 0xff;
let err = parse(&bytes).unwrap_err().to_string();
assert!(err.contains("CRC mismatch"), "{err}");
}
#[test]
fn write_then_read_roundtrip() {
let files = vec![
StuffItInput {
name: "Read Me".to_string(),
type_code: *b"TEXT",
creator_code: *b"ttxt",
finder_flags: 0,
create_date: 0xAABBCCDD,
mod_date: 0x11223344,
data_fork: b"Hello\r\rAAAAAAAAAAAAAAAAAAAA end.".to_vec(),
resource_fork: vec![0u8; 200],
},
StuffItInput {
name: "Empty Rsrc".to_string(),
type_code: *b"BINA",
creator_code: *b"????",
finder_flags: 0x4000,
create_date: 0,
mod_date: 0,
data_fork: b"just a data fork".to_vec(),
resource_fork: Vec::new(),
},
];
for method in [WriteMethod::Store, WriteMethod::Rle] {
let bytes = build_archive(&files, method).unwrap();
assert!(is_stuffit(&bytes));
let archive = parse(&bytes).expect("our writer must parse");
assert_eq!(archive.entries.len(), 2);
let e0 = &archive.entries[0];
assert_eq!(e0.name, "Read Me");
assert_eq!(&e0.type_code, b"TEXT");
assert_eq!(e0.create_date, 0xAABBCCDD);
let data0 = decompress_fork(&bytes, e0.data.as_ref().unwrap()).unwrap();
assert_eq!(data0, files[0].data_fork);
let rsrc0 = decompress_fork(&bytes, e0.rsrc.as_ref().unwrap()).unwrap();
assert_eq!(rsrc0, files[0].resource_fork);
let e1 = &archive.entries[1];
assert_eq!(e1.finder_flags, 0x4000);
assert!(e1.rsrc.is_none());
let data1 = decompress_fork(&bytes, e1.data.as_ref().unwrap()).unwrap();
assert_eq!(data1, files[1].data_fork);
}
}
fn make_file(name: &str, body: &[u8]) -> StuffItInputNode {
StuffItInputNode::File(StuffItInput {
name: name.to_string(),
type_code: *b"TEXT",
creator_code: *b"ttxt",
finder_flags: 0,
create_date: 0,
mod_date: 0,
data_fork: body.to_vec(),
resource_fork: Vec::new(),
})
}
fn make_folder(name: &str, children: Vec<StuffItInputNode>) -> StuffItInputNode {
StuffItInputNode::Folder {
name: name.to_string(),
finder_flags: 0,
create_date: 0,
mod_date: 0,
children,
}
}
#[test]
fn empty_folder_round_trips() {
let tree = vec![make_folder("Apps", vec![])];
let bytes = build_archive_tree(&tree, WriteMethod::Store).unwrap();
let archive = parse(&bytes).expect("parse empty folder");
assert_eq!(archive.entries.len(), 1);
let only = &archive.entries[0];
assert!(only.is_dir, "empty folder marker should be is_dir");
assert_eq!(only.name, "Apps");
assert_eq!(only.path, vec!["Apps".to_string()]);
}
#[test]
fn folder_with_two_files_round_trips() {
let tree = vec![make_folder(
"Docs",
vec![
make_file("ReadMe", b"hello"),
make_file("Notes", b"more text"),
],
)];
let bytes = build_archive_tree(&tree, WriteMethod::Store).unwrap();
let archive = parse(&bytes).expect("parse folder with files");
assert_eq!(archive.entries.len(), 3);
assert!(archive.entries[0].is_dir);
assert_eq!(archive.entries[0].name, "Docs");
assert_eq!(archive.entries[1].name, "ReadMe");
assert_eq!(archive.entries[1].path, vec!["Docs", "ReadMe"]);
assert_eq!(archive.entries[2].name, "Notes");
assert_eq!(archive.entries[2].path, vec!["Docs", "Notes"]);
let data = decompress_fork(&bytes, archive.entries[1].data.as_ref().unwrap()).unwrap();
assert_eq!(data, b"hello");
}
#[test]
fn deeply_nested_folders_preserve_paths() {
let tree = vec![make_folder(
"Apps",
vec![make_folder(
"Word",
vec![make_folder(
"Templates",
vec![make_file("Letter.txt", b"Dear ___,")],
)],
)],
)];
let bytes = build_archive_tree(&tree, WriteMethod::Store).unwrap();
let archive = parse(&bytes).expect("parse nested folders");
assert_eq!(archive.entries.len(), 4);
assert_eq!(
archive.entries[3].path,
vec!["Apps", "Word", "Templates", "Letter.txt"]
);
let body = decompress_fork(&bytes, archive.entries[3].data.as_ref().unwrap()).unwrap();
assert_eq!(body, b"Dear ___,");
}
#[test]
fn top_level_files_after_folder_end_back_at_root() {
let tree = vec![
make_folder("Folder1", vec![make_file("Inner.txt", b"inside")]),
make_file("TopLevel.txt", b"outside"),
];
let bytes = build_archive_tree(&tree, WriteMethod::Store).unwrap();
let archive = parse(&bytes).expect("parse mixed tree");
assert_eq!(archive.entries.len(), 3);
assert!(archive.entries[0].is_dir);
assert_eq!(archive.entries[0].path, vec!["Folder1"]);
assert_eq!(archive.entries[1].path, vec!["Folder1", "Inner.txt"]);
assert_eq!(archive.entries[2].path, vec!["TopLevel.txt"]);
}
#[test]
fn flat_build_archive_still_works_after_refactor() {
let files = vec![StuffItInput {
name: "Sole".to_string(),
type_code: *b"TEXT",
creator_code: *b"ttxt",
finder_flags: 0,
create_date: 0,
mod_date: 0,
data_fork: b"just one file".to_vec(),
resource_fork: Vec::new(),
}];
let bytes = build_archive(&files, WriteMethod::Store).unwrap();
let archive = parse(&bytes).expect("parse flat");
assert_eq!(archive.entries.len(), 1);
assert!(!archive.entries[0].is_dir);
assert_eq!(archive.entries[0].path, vec!["Sole"]);
}
#[test]
fn parse_real_samples_if_present() {
let dir = std::path::Path::new("/Users/dani/Downloads");
if !dir.is_dir() {
return;
}
let mut checked = 0;
for entry in std::fs::read_dir(dir).unwrap().flatten() {
let path = entry.path();
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if !name.to_ascii_lowercase().ends_with(".sit.hqx") {
continue;
}
let bytes = match std::fs::read(&path) {
Ok(b) => b,
Err(_) => continue,
};
let bh = match binhex::parse_binhex(&bytes) {
Ok(b) => b,
Err(_) => continue,
};
if find_sea_archive(&bh.data_fork).is_none() {
eprintln!("{name}: skipped (not classic SIT!; likely StuffIt 5)");
continue;
}
let archive = match parse(&bh.data_fork) {
Ok(a) => a,
Err(e) => panic!("parse failed for {name}: {e}"),
};
assert!(!archive.entries.is_empty(), "no entries parsed from {name}");
for e in &archive.entries {
assert!(!e.name.is_empty() || e.is_dir, "empty entry name in {name}");
}
let mut ok = 0;
let mut skipped = 0;
for e in &archive.entries {
for fork in [e.data.as_ref(), e.rsrc.as_ref()].into_iter().flatten() {
if fork.uncompressed_len == 0 {
continue;
}
match decompress_fork(&bh.data_fork, fork) {
Ok(_) => ok += 1,
Err(_) if matches!(fork.method & 0x0f, 2 | 3 | 5 | 6 | 8 | 14 | 15) => {
skipped += 1; }
Err(e) => panic!("{name}: decompress failed: {e}"),
}
}
}
eprintln!(
"{name}: {} entries, {ok} forks decompressed + CRC-verified, {skipped} unsupported-codec",
archive.entries.len()
);
checked += 1;
if checked >= 6 {
break;
}
}
eprintln!("validated {checked} real .sit samples");
}
}