use crate::error::{Error, Result};
#[derive(Debug, Clone, serde::Serialize)]
pub struct StuffItHeader {
pub signature: String,
pub version: u8,
pub num_entries: u16,
pub archive_size: u32,
pub is_stuffit5: bool,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct StuffItEntry {
pub name: String,
pub is_directory: bool,
pub compression_method: u8,
pub compression_method_name: String,
pub data_compressed_size: u32,
pub data_uncompressed_size: u32,
pub rsrc_compressed_size: u32,
pub rsrc_uncompressed_size: u32,
pub file_type: String,
pub creator_code: String,
pub is_encrypted: bool,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct StuffItAnalysis {
pub header: StuffItHeader,
pub entries: Vec<StuffItEntry>,
}
const SIT_MAGIC: &[u8; 4] = b"SIT!";
const SIT5_MAGIC: &[u8; 7] = b"StuffIt";
const SIT_HEADER_SIZE: usize = 22;
#[must_use]
pub fn probe(data: &[u8]) -> bool {
if data.len() < 4 {
return false;
}
data.starts_with(SIT_MAGIC) || (data.len() >= 7 && data.starts_with(SIT5_MAGIC))
}
pub fn analyze(data: &[u8]) -> Result<StuffItAnalysis> {
if !probe(data) {
return Err(Error::InvalidStuffIt("not a StuffIt archive".into()));
}
let is_stuffit5 = data.len() >= 7 && data.starts_with(SIT5_MAGIC);
if is_stuffit5 {
analyze_stuffit5(data)
} else {
analyze_classic(data)
}
}
fn analyze_classic(data: &[u8]) -> Result<StuffItAnalysis> {
if data.len() < SIT_HEADER_SIZE {
return Err(Error::InvalidStuffIt("header too short".into()));
}
let signature = String::from_utf8_lossy(
data.get(0..4)
.ok_or_else(|| Error::InvalidStuffIt("data truncated".into()))?,
)
.into_owned();
let num_entries = u16::from(
data.get(4)
.copied()
.ok_or_else(|| Error::InvalidStuffIt("data truncated".into()))?,
);
let archive_size = read_u32_be(data, 5)?;
let version = data
.get(9)
.copied()
.ok_or_else(|| Error::InvalidStuffIt("data truncated".into()))?;
let header = StuffItHeader {
signature,
version,
num_entries,
archive_size,
is_stuffit5: false,
};
let entries = parse_classic_entries(data, SIT_HEADER_SIZE, num_entries)?;
Ok(StuffItAnalysis { header, entries })
}
fn parse_classic_entries(data: &[u8], start: usize, count: u16) -> Result<Vec<StuffItEntry>> {
let mut entries = Vec::new();
let mut offset = start;
for _ in 0..count {
if offset + 4 >= data.len() {
break;
}
let rsrc_compressed = read_u32_be(data, offset)?;
offset += 4;
let rsrc_uncompressed = read_u32_be(data, offset)?;
offset += 4;
let data_compressed = read_u32_be(data, offset)?;
offset += 4;
let data_uncompressed = read_u32_be(data, offset)?;
offset += 4;
let rsrc_method = if let Some(&m) = data.get(offset) {
offset += 1;
m
} else {
0
};
let data_method = if let Some(&m) = data.get(offset) {
offset += 1;
m
} else {
0
};
let is_encrypted = if let Some(&e) = data.get(offset) {
offset += 1;
e & 0x10 != 0
} else {
false
};
let file_type = if let Some(ft_slice) = data.get(offset..offset + 4) {
let ft = String::from_utf8_lossy(ft_slice).into_owned();
offset += 4;
ft
} else {
"????".into()
};
let creator_code = if let Some(cc_slice) = data.get(offset..offset + 4) {
let cc = String::from_utf8_lossy(cc_slice).into_owned();
offset += 4;
cc
} else {
"????".into()
};
let name_len = if let Some(&l) = data.get(offset) {
offset += 1;
l as usize
} else {
0
};
let name = if let Some(n_slice) = data.get(offset..offset + name_len) {
let n = String::from_utf8_lossy(n_slice).into_owned();
offset += name_len;
n
} else {
format!("entry_{}", entries.len())
};
let method = data_method.max(rsrc_method);
entries.push(StuffItEntry {
name,
is_directory: false,
compression_method: method,
compression_method_name: sit_method_name(method),
data_compressed_size: data_compressed,
data_uncompressed_size: data_uncompressed,
rsrc_compressed_size: rsrc_compressed,
rsrc_uncompressed_size: rsrc_uncompressed,
file_type,
creator_code,
is_encrypted,
});
}
Ok(entries)
}
fn analyze_stuffit5(data: &[u8]) -> Result<StuffItAnalysis> {
if data.len() < 100 {
return Err(Error::InvalidStuffIt("StuffIt 5 header too short".into()));
}
let signature = String::from_utf8_lossy(
data.get(0..7)
.ok_or_else(|| Error::InvalidStuffIt("data truncated".into()))?,
)
.into_owned();
let version = data
.get(7)
.copied()
.ok_or_else(|| Error::InvalidStuffIt("data truncated".into()))?;
let header_size = read_u32_be(data, 14)? as usize;
let archive_size = if data.len() >= 86 {
read_u32_be(data, 82)?
} else {
data.len() as u32
};
let num_entries = if data.len() >= 90 {
read_u16_be(data, 88)?
} else {
0
};
let header = StuffItHeader {
signature,
version,
num_entries,
archive_size,
is_stuffit5: true,
};
let entries = if header_size < data.len() {
parse_stuffit5_entries(data, header_size, num_entries)?
} else {
Vec::new()
};
Ok(StuffItAnalysis { header, entries })
}
fn parse_stuffit5_entries(data: &[u8], start: usize, _count: u16) -> Result<Vec<StuffItEntry>> {
let mut entries = Vec::new();
let mut offset = start;
while offset + 50 < data.len() && entries.len() < 1000 {
let name_len = data.get(offset + 49).copied().map_or(0, usize::from);
if name_len == 0 || name_len > 255 || offset + 50 + name_len > data.len() {
break;
}
let name_slice = data
.get(offset + 50..offset + 50 + name_len)
.ok_or_else(|| Error::InvalidStuffIt("data truncated".into()))?;
let name = String::from_utf8_lossy(name_slice).into_owned();
if name.chars().any(|c| c.is_control() && c != '\t') {
break;
}
let is_directory = data
.get(offset)
.copied()
.ok_or_else(|| Error::InvalidStuffIt("data truncated".into()))?
& 0x40
!= 0;
let compression_method = data
.get(offset + 3)
.copied()
.ok_or_else(|| Error::InvalidStuffIt("data truncated".into()))?;
let data_compressed = read_u32_be(data, offset + 8)?;
let data_uncompressed = read_u32_be(data, offset + 12)?;
let rsrc_compressed = read_u32_be(data, offset + 16)?;
let rsrc_uncompressed = read_u32_be(data, offset + 20)?;
let is_encrypted = data
.get(offset + 4)
.copied()
.ok_or_else(|| Error::InvalidStuffIt("data truncated".into()))?
& 0x10
!= 0;
let file_type = if let Some(ft_slice) = data.get(offset + 24..offset + 28) {
String::from_utf8_lossy(ft_slice).into_owned()
} else {
"????".into()
};
let creator_code = if let Some(cc_slice) = data.get(offset + 28..offset + 32) {
String::from_utf8_lossy(cc_slice).into_owned()
} else {
"????".into()
};
entries.push(StuffItEntry {
name,
is_directory,
compression_method,
compression_method_name: sit_method_name(compression_method),
data_compressed_size: data_compressed,
data_uncompressed_size: data_uncompressed,
rsrc_compressed_size: rsrc_compressed,
rsrc_uncompressed_size: rsrc_uncompressed,
file_type,
creator_code,
is_encrypted,
});
offset += 50 + name_len;
}
Ok(entries)
}
fn sit_method_name(method: u8) -> String {
match method {
0 => "none (stored)".into(),
1 => "RLE".into(),
2 => "LZC (Lempel-Ziv)".into(),
3 => "Huffman".into(),
5 => "LZAH".into(),
6 => "fixedHuffman".into(),
8 => "MW (Miller-Wegman)".into(),
13 => "LZ+Huffman (method 13)".into(),
14 => "Installer".into(),
15 => "Arsenic".into(),
_ => format!("unknown ({method})"),
}
}
fn read_u32_be(data: &[u8], offset: usize) -> Result<u32> {
let bytes: [u8; 4] = data
.get(offset..offset + 4)
.ok_or_else(|| Error::InvalidStuffIt("data truncated".into()))?
.try_into()
.map_err(|_| Error::InvalidStuffIt("data truncated".into()))?;
Ok(u32::from_be_bytes(bytes))
}
fn read_u16_be(data: &[u8], offset: usize) -> Result<u16> {
let bytes: [u8; 2] = data
.get(offset..offset + 2)
.ok_or_else(|| Error::InvalidStuffIt("data truncated".into()))?
.try_into()
.map_err(|_| Error::InvalidStuffIt("data truncated".into()))?;
Ok(u16::from_be_bytes(bytes))
}
#[cfg(test)]
#[allow(clippy::missing_panics_doc)]
mod tests {
use super::*;
#[test]
fn test_probe_classic() {
let data = b"SIT!\x05\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00";
assert!(probe(data));
}
#[test]
fn test_probe_stuffit5() {
let mut data = vec![0u8; 100];
data[..7].copy_from_slice(b"StuffIt");
assert!(probe(&data));
}
#[test]
fn test_probe_not_stuffit() {
let data = b"PK\x03\x04extra";
assert!(!probe(data));
}
#[test]
fn test_analyze_classic_header() {
let mut data = vec![0u8; 100];
data[0..4].copy_from_slice(b"SIT!");
data[4] = 2; data[5..9].copy_from_slice(&100u32.to_be_bytes()); data[9] = 1;
let result = analyze(&data);
assert!(result.is_ok());
let analysis = result.expect("analysis should succeed");
assert_eq!(analysis.header.signature, "SIT!");
assert_eq!(analysis.header.num_entries, 2);
assert!(!analysis.header.is_stuffit5);
}
#[test]
fn test_analyze_not_stuffit() {
let data = b"NOT_A_SIT_ARCHIVE";
assert!(analyze(data).is_err());
}
#[test]
fn test_method_names() {
assert_eq!(sit_method_name(0), "none (stored)");
assert_eq!(sit_method_name(1), "RLE");
assert_eq!(sit_method_name(15), "Arsenic");
assert!(sit_method_name(99).contains("unknown"));
}
}