use anyhow::{bail, Result};
use byteorder::{BigEndian, ByteOrder};
use std::sync::OnceLock;
use super::stuffit::{ForkCodec, ForkInfo, StuffItArchive, StuffItEntry};
use fs_mac::hfs::decode_mac_filename;
const FILE_METADATA_LEN: usize = 45;
pub fn is_compactpro(bytes: &[u8]) -> bool {
validate_index(bytes).unwrap_or(false)
}
fn validate_index(bytes: &[u8]) -> Option<bool> {
if bytes.len() < 8 || bytes[0] != 1 {
return Some(false);
}
let offset = BigEndian::read_u32(bytes.get(4..8)?) as usize;
let correct = BigEndian::read_u32(bytes.get(offset..offset + 4)?);
let mut p = offset + 4;
let head = bytes.get(p..p + 3)?;
let mut crc = crc32_update(0xffff_ffff, head);
let numentries = BigEndian::read_u16(&head[0..2]) as usize;
let commentsize = head[2] as usize;
p += 3;
let comment = bytes.get(p..p + commentsize)?;
crc = crc32_update(crc, comment);
p += commentsize;
for _ in 0..numentries {
let namelen = *bytes.get(p)?;
crc = crc32_update(crc, &[namelen]);
p += 1;
let nl = (namelen & 0x7f) as usize;
let namebytes = bytes.get(p..p + nl)?;
crc = crc32_update(crc, namebytes);
p += nl;
let metadatasize = if namelen & 0x80 != 0 {
2
} else {
FILE_METADATA_LEN
};
let meta = bytes.get(p..p + metadatasize)?;
crc = crc32_update(crc, meta);
p += metadatasize;
}
Some(crc == correct)
}
pub fn parse(bytes: &[u8]) -> Result<StuffItArchive> {
if bytes.len() < 8 || bytes[0] != 1 {
bail!("not a Compact Pro archive");
}
let index_offset = BigEndian::read_u32(&bytes[4..8]) as usize;
let mut pos = index_offset;
if pos + 7 > bytes.len() {
bail!("Compact Pro: truncated archive index");
}
pos += 4; let numentries = BigEndian::read_u16(&bytes[pos..pos + 2]) as usize;
pos += 2;
let commentlen = bytes[pos] as usize;
pos += 1;
pos = pos
.checked_add(commentlen)
.filter(|&p| p <= bytes.len())
.ok_or_else(|| anyhow::anyhow!("Compact Pro: comment runs past end of archive"))?;
let mut entries = Vec::new();
parse_directory(bytes, &mut pos, &[], numentries, &mut entries)?;
Ok(StuffItArchive {
entries,
truncated: None,
})
}
fn parse_directory(
bytes: &[u8],
pos: &mut usize,
parent: &[String],
mut numentries: usize,
entries: &mut Vec<StuffItEntry>,
) -> Result<()> {
while numentries > 0 {
let namelen = *bytes
.get(*pos)
.ok_or_else(|| anyhow::anyhow!("Compact Pro: index truncated mid-entry"))?;
*pos += 1;
let nl = (namelen & 0x7f) as usize;
let namebytes = bytes
.get(*pos..*pos + nl)
.ok_or_else(|| anyhow::anyhow!("Compact Pro: name runs past end of archive"))?;
let name = decode_mac_filename(namebytes);
*pos += nl;
let mut path = parent.to_vec();
path.push(name.clone());
if namelen & 0x80 != 0 {
let numdir = read_u16(bytes, pos)? as usize;
entries.push(StuffItEntry {
path: path.clone(),
name,
is_dir: true,
type_code: [0; 4],
creator_code: [0; 4],
finder_flags: 0,
create_date: 0,
mod_date: 0,
data: None,
rsrc: None,
});
parse_directory(bytes, pos, &path, numdir, entries)?;
numentries = numentries
.checked_sub(numdir + 1)
.ok_or_else(|| anyhow::anyhow!("Compact Pro: inconsistent directory count"))?;
} else {
let meta = bytes
.get(*pos..*pos + FILE_METADATA_LEN)
.ok_or_else(|| anyhow::anyhow!("Compact Pro: file metadata past end of archive"))?;
*pos += FILE_METADATA_LEN;
let fileoffs = BigEndian::read_u32(&meta[1..5]) as u64;
let mut type_code = [0u8; 4];
type_code.copy_from_slice(&meta[5..9]);
let mut creator_code = [0u8; 4];
creator_code.copy_from_slice(&meta[9..13]);
let create_date = BigEndian::read_u32(&meta[13..17]);
let mod_date = BigEndian::read_u32(&meta[17..21]);
let finder_flags = BigEndian::read_u16(&meta[21..23]);
let crc = BigEndian::read_u32(&meta[23..27]);
let flags = BigEndian::read_u16(&meta[27..29]);
let resourcelength = BigEndian::read_u32(&meta[29..33]);
let datalength = BigEndian::read_u32(&meta[33..37]);
let resourcecomplen = BigEndian::read_u32(&meta[37..41]);
let datacomplen = BigEndian::read_u32(&meta[41..45]);
let encrypted = flags & 1 != 0;
let rsrc = if resourcelength > 0 {
Some(ForkInfo {
method: 0,
codec: ForkCodec::CompactPro {
lzh: flags & 2 != 0,
},
encrypted,
uncompressed_len: resourcelength,
compressed_len: resourcecomplen,
crc: 0,
crc32: crc,
offset: fileoffs,
})
} else {
None
};
let data = if datalength > 0 || resourcelength == 0 {
Some(ForkInfo {
method: 0,
codec: ForkCodec::CompactPro {
lzh: flags & 4 != 0,
},
encrypted,
uncompressed_len: datalength,
compressed_len: datacomplen,
crc: 0,
crc32: crc,
offset: fileoffs + resourcecomplen as u64,
})
} else {
None
};
entries.push(StuffItEntry {
path,
name,
is_dir: false,
type_code,
creator_code,
finder_flags,
create_date,
mod_date,
data,
rsrc,
});
numentries -= 1;
}
}
Ok(())
}
fn read_u16(bytes: &[u8], pos: &mut usize) -> Result<u16> {
let v = bytes
.get(*pos..*pos + 2)
.ok_or_else(|| anyhow::anyhow!("Compact Pro: index truncated"))?;
*pos += 2;
Ok(BigEndian::read_u16(v))
}
pub fn decompress_fork(archive: &[u8], fork: &ForkInfo) -> Result<Vec<u8>> {
let lzh = match fork.codec {
ForkCodec::CompactPro { lzh } => lzh,
ForkCodec::StuffIt => bail!("compactpro::decompress_fork called on a StuffIt fork"),
};
if fork.encrypted {
bail!("Compact Pro: encrypted entries are not supported");
}
let start = fork.offset as usize;
let end = start
.checked_add(fork.compressed_len as usize)
.ok_or_else(|| anyhow::anyhow!("Compact Pro: fork length overflow"))?;
if end > archive.len() {
bail!("Compact Pro: fork data extends past end of archive");
}
let comp = &archive[start..end];
let out_len = fork.uncompressed_len as usize;
if lzh {
let mut src = CpLzh::new(comp);
rle_decode(&mut src, out_len)
} else {
let mut src = SliceSource::new(comp);
rle_decode(&mut src, out_len)
}
}
pub fn verify_entry_crc(rsrc: &[u8], data: &[u8], expected: u32) -> Result<()> {
let got = crc32_update(crc32_update(0xffff_ffff, rsrc), data);
if got != expected {
bail!("Compact Pro: file CRC mismatch (got {got:#010x}, expected {expected:#010x})");
}
Ok(())
}
trait ByteSource {
fn next_byte(&mut self) -> Option<u8>;
}
struct SliceSource<'a> {
data: &'a [u8],
pos: usize,
}
impl<'a> SliceSource<'a> {
fn new(data: &'a [u8]) -> Self {
SliceSource { data, pos: 0 }
}
}
impl ByteSource for SliceSource<'_> {
fn next_byte(&mut self) -> Option<u8> {
let v = self.data.get(self.pos).copied();
if v.is_some() {
self.pos += 1;
}
v
}
}
#[derive(Default)]
struct RleState {
saved: u8,
repeat: i64,
halfescaped: bool,
}
fn rle_next(st: &mut RleState, src: &mut dyn ByteSource) -> Option<u8> {
if st.repeat != 0 {
st.repeat -= 1;
return Some(st.saved);
}
let byte = if st.halfescaped {
st.halfescaped = false;
0x81
} else {
src.next_byte()?
};
if byte != 0x81 {
st.saved = byte;
return Some(byte);
}
let b2 = src.next_byte()?;
if b2 == 0x82 {
let count = src.next_byte()?;
if count != 0 {
st.repeat = count as i64 - 2;
Some(st.saved)
} else {
st.repeat = 1;
st.saved = 0x82;
Some(0x81)
}
} else if b2 == 0x81 {
st.halfescaped = true;
st.saved = 0x81;
Some(0x81)
} else {
st.repeat = 1;
st.saved = b2;
Some(0x81)
}
}
fn rle_decode(src: &mut dyn ByteSource, out_len: usize) -> Result<Vec<u8>> {
let mut out = Vec::with_capacity(out_len);
let mut st = RleState::default();
while out.len() < out_len {
match rle_next(&mut st, src) {
Some(b) => out.push(b),
None => bail!(
"Compact Pro: unexpected end of compressed data ({} of {} bytes)",
out.len(),
out_len
),
}
}
Ok(out)
}
const LZH_BLOCKSIZE: i64 = 0x1fff0;
const LZH_WINDOW_MASK: u64 = 0x1fff;
enum Token {
Literal(u8),
Match { offset: u32, length: i32 },
}
struct CpLzh<'a> {
input: CpInput<'a>,
window: Vec<u8>,
pos: u64,
matchlength: i32,
matchoffset: u64,
blockcount: i64,
blockstart: i64,
literalcode: Option<Huff>,
lengthcode: Option<Huff>,
offsetcode: Option<Huff>,
ended: bool,
}
impl<'a> CpLzh<'a> {
fn new(data: &'a [u8]) -> Self {
CpLzh {
input: CpInput::new(data),
window: vec![0u8; (LZH_WINDOW_MASK + 1) as usize],
pos: 0,
matchlength: 0,
matchoffset: 0,
blockcount: LZH_BLOCKSIZE, blockstart: 0,
literalcode: None,
lengthcode: None,
offsetcode: None,
ended: false,
}
}
fn produce(&mut self) -> Option<u8> {
if self.ended {
return None;
}
if self.matchlength == 0 {
match self.next_token() {
Some(Token::Literal(v)) => {
self.window[(self.pos & LZH_WINDOW_MASK) as usize] = v;
self.pos = self.pos.wrapping_add(1);
return Some(v);
}
Some(Token::Match { offset, length }) => {
self.matchlength = length;
self.matchoffset = self.pos.wrapping_sub(offset as u64);
}
None => {
self.ended = true;
return None;
}
}
}
self.matchlength -= 1;
let byte = self.window[(self.matchoffset & LZH_WINDOW_MASK) as usize];
self.matchoffset = self.matchoffset.wrapping_add(1);
self.window[(self.pos & LZH_WINDOW_MASK) as usize] = byte;
self.pos = self.pos.wrapping_add(1);
Some(byte)
}
fn next_token(&mut self) -> Option<Token> {
if self.blockcount >= LZH_BLOCKSIZE {
if self.blockstart != 0 {
self.input.skip_to_byte_boundary();
if (self.input.buffer_offset() - self.blockstart) & 1 != 0 {
self.input.skip_bytes(3);
} else {
self.input.skip_bytes(2);
}
}
self.literalcode = Some(self.parse_code(256)?);
self.lengthcode = Some(self.parse_code(64)?);
self.offsetcode = Some(self.parse_code(128)?);
self.blockcount = 0;
self.blockstart = self.input.buffer_offset();
}
if self.input.next_bit() != 0 {
self.blockcount += 2;
let sym = Huff::decode(self.literalcode.as_ref()?, &mut self.input)?;
Some(Token::Literal(sym as u8))
} else {
self.blockcount += 3;
let length = Huff::decode(self.lengthcode.as_ref()?, &mut self.input)?;
let osym = Huff::decode(self.offsetcode.as_ref()?, &mut self.input)?;
let offset = ((osym as u32) << 6) | self.input.next_bits(6);
Some(Token::Match { offset, length })
}
}
fn parse_code(&mut self, size: usize) -> Option<Huff> {
let numbytes = self.input.next_byte()? as usize;
if numbytes * 2 > size {
return None;
}
let mut lengths = vec![0i32; size];
for i in 0..numbytes {
let val = self.input.next_byte()? as i32;
lengths[2 * i] = val >> 4;
lengths[2 * i + 1] = val & 0x0f;
}
Huff::build(&lengths)
}
}
impl ByteSource for CpLzh<'_> {
fn next_byte(&mut self) -> Option<u8> {
self.produce()
}
}
const HUFF_MAX_BITS: usize = 15;
struct Huff {
counts: [u16; HUFF_MAX_BITS + 1],
symbols: Vec<i32>,
}
impl Huff {
fn build(lengths: &[i32]) -> Option<Huff> {
let mut counts = [0u16; HUFF_MAX_BITS + 1];
for &l in lengths {
if !(0..=HUFF_MAX_BITS as i32).contains(&l) {
return None;
}
counts[l as usize] += 1;
}
let mut left: i64 = 1;
for &count in &counts[1..=HUFF_MAX_BITS] {
left = (left << 1) - count as i64;
if left < 0 {
return None;
}
}
let mut offs = [0u16; HUFF_MAX_BITS + 2];
for len in 1..=HUFF_MAX_BITS {
offs[len + 1] = offs[len] + counts[len];
}
let total = offs[HUFF_MAX_BITS + 1] as usize;
let mut symbols = vec![0i32; total];
for (sym, &len) in lengths.iter().enumerate() {
if len != 0 {
symbols[offs[len as usize] as usize] = sym as i32;
offs[len as usize] += 1;
}
}
Some(Huff { counts, symbols })
}
fn decode(&self, input: &mut CpInput) -> Option<i32> {
let mut code: i32 = 0;
let mut first: i32 = 0;
let mut index: i32 = 0;
for len in 1..=HUFF_MAX_BITS {
code |= input.next_bit() as i32;
let count = self.counts[len] as i32;
if code - count < first {
return self.symbols.get((index + (code - first)) as usize).copied();
}
index += count;
first += count;
first <<= 1;
code <<= 1;
}
None
}
}
struct CpInput<'a> {
data: &'a [u8],
currbyte: usize,
bits: u32,
numbits: u32,
}
impl<'a> CpInput<'a> {
fn new(data: &'a [u8]) -> Self {
CpInput {
data,
currbyte: 0,
bits: 0,
numbits: 0,
}
}
fn peek_byte(&self, offs: usize) -> u32 {
match self.data.get(self.currbyte + offs) {
Some(&b) => b as u32,
None => 0,
}
}
fn fill_bits(&mut self) {
let mut numbytes = ((32 - self.numbits) >> 3) as usize;
let left = self.data.len().saturating_sub(self.currbyte);
if numbytes > left {
numbytes = left;
}
let s = (self.numbits >> 3) as usize;
match numbytes {
4 => {
self.bits = (self.peek_byte(s) << 24)
| (self.peek_byte(s + 1) << 16)
| (self.peek_byte(s + 2) << 8)
| self.peek_byte(s + 3);
}
3 => {
self.bits |= ((self.peek_byte(s) << 16)
| (self.peek_byte(s + 1) << 8)
| self.peek_byte(s + 2))
<< (8 - self.numbits);
}
2 => {
self.bits |=
((self.peek_byte(s) << 8) | self.peek_byte(s + 1)) << (16 - self.numbits);
}
1 => {
self.bits |= self.peek_byte(s) << (24 - self.numbits);
}
_ => {}
}
self.numbits += (numbytes as u32) * 8;
}
fn peek_bits(&mut self, n: u32) -> u32 {
if n == 0 {
return 0;
}
if n > self.numbits {
self.fill_bits();
}
self.bits >> (32 - n)
}
fn skip_peeked(&mut self, n: u32) {
let numbytes = ((n as i64) - ((self.numbits & 7) as i64) + 7) >> 3;
self.currbyte += numbytes.max(0) as usize;
self.bits = if n >= 32 { 0 } else { self.bits << n };
self.numbits = self.numbits.saturating_sub(n);
}
fn next_bit(&mut self) -> u32 {
let b = self.peek_bits(1);
self.skip_peeked(1);
b
}
fn next_bits(&mut self, n: u32) -> u32 {
if n == 0 {
return 0;
}
let b = self.peek_bits(n);
self.skip_peeked(n);
b
}
fn next_byte(&mut self) -> Option<u8> {
let v = self.data.get(self.currbyte).copied();
if v.is_some() {
self.currbyte += 1;
}
v
}
fn skip_to_byte_boundary(&mut self) {
self.bits = 0;
self.numbits = 0;
}
fn skip_bytes(&mut self, n: usize) {
self.currbyte += n;
}
fn buffer_offset(&self) -> i64 {
self.currbyte as i64
}
}
fn crc_table() -> &'static [u32; 256] {
static TABLE: OnceLock<[u32; 256]> = OnceLock::new();
TABLE.get_or_init(|| {
let mut t = [0u32; 256];
let mut n = 0usize;
while n < 256 {
let mut c = n as u32;
let mut k = 0;
while k < 8 {
c = if c & 1 != 0 {
0xedb8_8320 ^ (c >> 1)
} else {
c >> 1
};
k += 1;
}
t[n] = c;
n += 1;
}
t
})
}
fn crc32_update(mut crc: u32, data: &[u8]) -> u32 {
let t = crc_table();
for &b in data {
crc = t[((crc ^ b as u32) & 0xff) as usize] ^ (crc >> 8);
}
crc
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rle_passthrough_without_escapes() {
let mut src = SliceSource::new(&[1, 2, 3, 4, 5]);
let out = rle_decode(&mut src, 5).unwrap();
assert_eq!(out, vec![1, 2, 3, 4, 5]);
}
#[test]
fn rle_run_repeats_previous_byte() {
let mut src = SliceSource::new(&[b'A', 0x81, 0x82, 0x04]);
let out = rle_decode(&mut src, 4).unwrap();
assert_eq!(out, vec![b'A', b'A', b'A', b'A']);
}
#[test]
fn rle_literal_escape_pair() {
let mut src = SliceSource::new(&[0x81, 0x82, 0x00]);
let out = rle_decode(&mut src, 2).unwrap();
assert_eq!(out, vec![0x81, 0x82]);
}
#[test]
fn crc32_empty_is_seed() {
assert_eq!(crc32_update(0xffff_ffff, &[]), 0xffff_ffff);
}
#[test]
fn crc32_check_value() {
let stored = crc32_update(0xffff_ffff, b"123456789");
assert_eq!(stored ^ 0xffff_ffff, 0xCBF4_3926);
}
#[test]
fn extract_real_compactpro_if_present() {
let path = std::path::Path::new("/Users/dani/Downloads/Dunjin44.cpt.hqx");
if !path.is_file() {
return;
}
let raw = std::fs::read(path).unwrap();
let bh = fs_mac::binhex::parse_binhex(&raw).expect("binhex decode");
assert!(is_compactpro(&bh.data_fork), "should detect Compact Pro");
let archive = parse(&bh.data_fork).expect("compactpro parse");
let files: Vec<_> = archive.entries.iter().filter(|e| !e.is_dir).collect();
assert!(!files.is_empty());
let mut verified = 0;
let mut saw_multiblock_fork = false;
for e in &files {
let decode = |f: &ForkInfo| {
decompress_fork(&bh.data_fork, f).unwrap_or_else(|err| panic!("{}: {err}", e.name))
};
let data = e.data.as_ref().map(&decode).unwrap_or_default();
let rsrc = e.rsrc.as_ref().map(&decode).unwrap_or_default();
if data.len() >= 150_000 || rsrc.len() >= 150_000 {
saw_multiblock_fork = true;
}
if let Some(crc) = e.data.as_ref().or(e.rsrc.as_ref()).map(|f| f.crc32) {
verify_entry_crc(&rsrc, &data, crc)
.unwrap_or_else(|err| panic!("{}: {err}", e.name));
verified += 1;
}
}
assert!(
saw_multiblock_fork,
"expected the 150 KB multi-block Dunjin Text fork"
);
assert!(verified > 0);
eprintln!("Compact Pro: CRC-verified {verified} entries (incl. multi-block)");
}
#[test]
fn cpinput_reads_bits_msb_first() {
let mut inp = CpInput::new(&[0b1011_0010]);
let bits: Vec<u32> = (0..8).map(|_| inp.next_bit()).collect();
assert_eq!(bits, vec![1, 0, 1, 1, 0, 0, 1, 0]);
let mut inp2 = CpInput::new(&[0b1100_1010, 0b1111_0000]);
assert_eq!(inp2.next_bits(4), 0b1100);
assert_eq!(inp2.next_bits(4), 0b1010);
assert_eq!(inp2.next_bits(8), 0b1111_0000);
}
#[test]
fn cpinput_byte_offset_is_ceil_of_consumed_bits() {
let data = [0xAC, 0x35, 0xFF, 0x00, 0x42];
let mut inp = CpInput::new(&data);
for k in 1..=(data.len() as u32 * 8) {
let _ = inp.next_bit();
assert_eq!(
inp.buffer_offset(),
k.div_ceil(8) as i64,
"after consuming {k} bits"
);
}
}
#[test]
fn cpinput_skip_to_byte_boundary_then_byte_read_aligns() {
let mut inp = CpInput::new(&[0xAB, 0xCD, 0xEF, 0x12]);
for _ in 0..3 {
inp.next_bit();
}
assert_eq!(inp.buffer_offset(), 1, "3 bits -> ceil = byte 1");
inp.skip_to_byte_boundary();
assert_eq!(inp.buffer_offset(), 1, "boundary holds at byte 1");
assert_eq!(inp.next_byte(), Some(0xCD), "byte read resumes at byte 1");
assert_eq!(inp.buffer_offset(), 2);
inp.skip_bytes(1);
assert_eq!(inp.next_byte(), Some(0x12));
}
#[test]
fn huff_single_symbol() {
let mut lengths = vec![0i32; 4];
lengths[2] = 1; let huff = Huff::build(&lengths).expect("valid code");
let mut input = CpInput::new(&[0x00]);
assert_eq!(huff.decode(&mut input), Some(2));
}
#[test]
fn verify_entry_crc_covers_rsrc_then_data() {
assert!(verify_entry_crc(&[], &[], 0xffff_ffff).is_ok());
let rsrc = b"RSRC-BYTES";
let data = b"DATA-BYTES";
let expected = crc32_update(crc32_update(0xffff_ffff, rsrc), data);
assert!(verify_entry_crc(rsrc, data, expected).is_ok());
assert!(verify_entry_crc(data, rsrc, expected).is_err());
assert!(verify_entry_crc(rsrc, data, expected ^ 1).is_err());
}
#[test]
fn huff_rejects_oversubscribed() {
let lengths = vec![1, 1, 1];
assert!(Huff::build(&lengths).is_none());
}
}