#![allow(clippy::indexing_slicing)]
#![allow(clippy::arithmetic_side_effects)]
#![allow(clippy::as_conversions)]
#![allow(clippy::cast_possible_truncation)]
#![allow(clippy::cast_possible_wrap)]
#![allow(clippy::cast_sign_loss)]
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[0..4]).into_owned();
let num_entries = u16::from(data[4]);
let archive_size = u32::from_be_bytes([data[5], data[6], data[7], data[8]]);
let version = data[9];
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 offset < data.len() {
let m = data[offset];
offset += 1;
m
} else {
0
};
let data_method = if offset < data.len() {
let m = data[offset];
offset += 1;
m
} else {
0
};
let is_encrypted = if offset < data.len() {
let e = data[offset] & 0x10 != 0;
offset += 1;
e
} else {
false
};
let file_type = if offset + 4 <= data.len() {
let ft = String::from_utf8_lossy(&data[offset..offset + 4]).into_owned();
offset += 4;
ft
} else {
"????".into()
};
let creator_code = if offset + 4 <= data.len() {
let cc = String::from_utf8_lossy(&data[offset..offset + 4]).into_owned();
offset += 4;
cc
} else {
"????".into()
};
let name_len = if offset < data.len() {
let l = data[offset] as usize;
offset += 1;
l
} else {
0
};
let name = if offset + name_len <= data.len() {
let n = String::from_utf8_lossy(&data[offset..offset + name_len]).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[0..7]).into_owned();
let version = data[7];
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) -> Vec<StuffItEntry> {
let mut entries = Vec::new();
let mut offset = start;
while offset + 50 < data.len() && entries.len() < 1000 {
let name_len = if offset + 50 < data.len() {
data[offset + 49] as usize
} else {
break;
};
if name_len == 0 || name_len > 255 || offset + 50 + name_len > data.len() {
break;
}
let name = String::from_utf8_lossy(&data[offset + 50..offset + 50 + name_len]).into_owned();
if name.chars().any(|c| c.is_control() && c != '\t') {
break;
}
let is_directory = data[offset] & 0x40 != 0;
let compression_method = data[offset + 3];
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[offset + 4] & 0x10 != 0;
let file_type = if offset + 28 <= data.len() {
String::from_utf8_lossy(&data[offset + 24..offset + 28]).into_owned()
} else {
"????".into()
};
let creator_code = if offset + 32 <= data.len() {
String::from_utf8_lossy(&data[offset + 28..offset + 32]).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;
}
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) -> u32 {
if offset + 4 <= data.len() {
u32::from_be_bytes([
data[offset],
data[offset + 1],
data[offset + 2],
data[offset + 3],
])
} else {
0
}
}
fn read_u16_be(data: &[u8], offset: usize) -> u16 {
if offset + 2 <= data.len() {
u16::from_be_bytes([data[offset], data[offset + 1]])
} else {
0
}
}
#[cfg(test)]
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"));
}
}