Skip to main content

arc_mac/
compactpro.rs

1// SPDX-License-Identifier: LGPL-2.1-or-later
2// Derived from XADMaster, Copyright (c) 2017-present MacPaw Way Ltd.; see THIRD_PARTY_NOTICES.md.
3//! Compact Pro (`.cpt`) archive reader — Bill Goodman's Compactor / Compact
4//! Pro format, common for vintage Mac software and frequently distributed as a
5//! BinHex-wrapped self-extracting archive (`.sea.hqx`).
6//!
7//! Two-stage compression: a byte-level RLE layer, optionally fed by an LZH
8//! (LZSS + canonical Huffman) layer. Each file carries a data and/or resource
9//! fork plus Finder info, mapping cleanly onto the shared [`StuffItEntry`] /
10//! [`StuffItArchive`] plumbing the rest of `macarchive` already uses, so the
11//! directory tree and all four output formats in `extract` come for free.
12//!
13//! Ported from XADMaster's `XADCompactProParser.m`,
14//! `XADCompactProRLEHandle.m`, and `XADCompactProLZHHandle.m` (which build on
15//! `XADLZSSHandle`, `XADPrefixCode`, and `CSInputBuffer`, all under
16//! `~/repos/XADMaster`). The block-padding quirk in the LZH layer and the
17//! RLE escape sequence are faithful reproductions of that reference.
18
19use anyhow::{bail, Result};
20use byteorder::{BigEndian, ByteOrder};
21use std::sync::OnceLock;
22
23use super::stuffit::{ForkCodec, ForkInfo, StuffItArchive, StuffItEntry};
24use fs_mac::hfs::decode_mac_filename;
25
26/// Bytes of file metadata that follow a file entry's name (volume, offset,
27/// type/creator, two dates, finder flags, crc, flags, four lengths).
28const FILE_METADATA_LEN: usize = 45;
29
30// ---------------------------------------------------------------------------
31// Detection
32// ---------------------------------------------------------------------------
33
34/// True iff `bytes` is a Compact Pro archive. Mirrors XADMaster's recognizer:
35/// the leading `0x01` marker is far too weak on its own, so we walk the index
36/// at the end of the file and confirm the stored CRC-32 over the directory.
37pub fn is_compactpro(bytes: &[u8]) -> bool {
38    validate_index(bytes).unwrap_or(false)
39}
40
41/// Walk the index and check its CRC; `Some(true)` only when everything lines
42/// up. Returns `Ok(false)` / `None`-equivalent for any structural problem so
43/// the detector never panics on hostile input.
44fn validate_index(bytes: &[u8]) -> Option<bool> {
45    if bytes.len() < 8 || bytes[0] != 1 {
46        return Some(false);
47    }
48    let offset = BigEndian::read_u32(bytes.get(4..8)?) as usize;
49    // headcrc(4) + numentries(2) + commentlen(1)
50    let correct = BigEndian::read_u32(bytes.get(offset..offset + 4)?);
51    let mut p = offset + 4;
52
53    let head = bytes.get(p..p + 3)?;
54    let mut crc = crc32_update(0xffff_ffff, head);
55    let numentries = BigEndian::read_u16(&head[0..2]) as usize;
56    let commentsize = head[2] as usize;
57    p += 3;
58
59    let comment = bytes.get(p..p + commentsize)?;
60    crc = crc32_update(crc, comment);
61    p += commentsize;
62
63    for _ in 0..numentries {
64        let namelen = *bytes.get(p)?;
65        crc = crc32_update(crc, &[namelen]);
66        p += 1;
67        let nl = (namelen & 0x7f) as usize;
68        let namebytes = bytes.get(p..p + nl)?;
69        crc = crc32_update(crc, namebytes);
70        p += nl;
71        let metadatasize = if namelen & 0x80 != 0 {
72            2
73        } else {
74            FILE_METADATA_LEN
75        };
76        let meta = bytes.get(p..p + metadatasize)?;
77        crc = crc32_update(crc, meta);
78        p += metadatasize;
79    }
80
81    Some(crc == correct)
82}
83
84// ---------------------------------------------------------------------------
85// Directory parsing
86// ---------------------------------------------------------------------------
87
88/// Parse a Compact Pro archive's directory into the shared entry tree. Fork
89/// data offsets are absolute within `bytes`, exactly like the StuffIt parsers.
90pub fn parse(bytes: &[u8]) -> Result<StuffItArchive> {
91    if bytes.len() < 8 || bytes[0] != 1 {
92        bail!("not a Compact Pro archive");
93    }
94    let index_offset = BigEndian::read_u32(&bytes[4..8]) as usize;
95    let mut pos = index_offset;
96
97    // headcrc(4) numentries(2) commentlen(1)
98    if pos + 7 > bytes.len() {
99        bail!("Compact Pro: truncated archive index");
100    }
101    pos += 4; // skip stored header CRC (validated in is_compactpro)
102    let numentries = BigEndian::read_u16(&bytes[pos..pos + 2]) as usize;
103    pos += 2;
104    let commentlen = bytes[pos] as usize;
105    pos += 1;
106    pos = pos
107        .checked_add(commentlen)
108        .filter(|&p| p <= bytes.len())
109        .ok_or_else(|| anyhow::anyhow!("Compact Pro: comment runs past end of archive"))?;
110
111    let mut entries = Vec::new();
112    parse_directory(bytes, &mut pos, &[], numentries, &mut entries)?;
113    Ok(StuffItArchive {
114        entries,
115        truncated: None,
116    })
117}
118
119fn parse_directory(
120    bytes: &[u8],
121    pos: &mut usize,
122    parent: &[String],
123    mut numentries: usize,
124    entries: &mut Vec<StuffItEntry>,
125) -> Result<()> {
126    while numentries > 0 {
127        let namelen = *bytes
128            .get(*pos)
129            .ok_or_else(|| anyhow::anyhow!("Compact Pro: index truncated mid-entry"))?;
130        *pos += 1;
131        let nl = (namelen & 0x7f) as usize;
132        let namebytes = bytes
133            .get(*pos..*pos + nl)
134            .ok_or_else(|| anyhow::anyhow!("Compact Pro: name runs past end of archive"))?;
135        let name = decode_mac_filename(namebytes);
136        *pos += nl;
137
138        let mut path = parent.to_vec();
139        path.push(name.clone());
140
141        if namelen & 0x80 != 0 {
142            // Directory: 2-byte (recursive) descendant count.
143            let numdir = read_u16(bytes, pos)? as usize;
144            entries.push(StuffItEntry {
145                path: path.clone(),
146                name,
147                is_dir: true,
148                type_code: [0; 4],
149                creator_code: [0; 4],
150                finder_flags: 0,
151                create_date: 0,
152                mod_date: 0,
153                data: None,
154                rsrc: None,
155            });
156            parse_directory(bytes, pos, &path, numdir, entries)?;
157            numentries = numentries
158                .checked_sub(numdir + 1)
159                .ok_or_else(|| anyhow::anyhow!("Compact Pro: inconsistent directory count"))?;
160        } else {
161            // File: 45 bytes of metadata.
162            let meta = bytes
163                .get(*pos..*pos + FILE_METADATA_LEN)
164                .ok_or_else(|| anyhow::anyhow!("Compact Pro: file metadata past end of archive"))?;
165            *pos += FILE_METADATA_LEN;
166
167            // volume@0, fileoffs@1, type@5, creator@9, cdate@13, mdate@17,
168            // finderflags@21, crc@23, flags@27, rsrclen@29, datalen@33,
169            // rsrccomplen@37, datacomplen@41.
170            let fileoffs = BigEndian::read_u32(&meta[1..5]) as u64;
171            let mut type_code = [0u8; 4];
172            type_code.copy_from_slice(&meta[5..9]);
173            let mut creator_code = [0u8; 4];
174            creator_code.copy_from_slice(&meta[9..13]);
175            let create_date = BigEndian::read_u32(&meta[13..17]);
176            let mod_date = BigEndian::read_u32(&meta[17..21]);
177            let finder_flags = BigEndian::read_u16(&meta[21..23]);
178            let crc = BigEndian::read_u32(&meta[23..27]);
179            let flags = BigEndian::read_u16(&meta[27..29]);
180            let resourcelength = BigEndian::read_u32(&meta[29..33]);
181            let datalength = BigEndian::read_u32(&meta[33..37]);
182            let resourcecomplen = BigEndian::read_u32(&meta[37..41]);
183            let datacomplen = BigEndian::read_u32(&meta[41..45]);
184
185            let encrypted = flags & 1 != 0;
186
187            // Resource fork present when it has uncompressed bytes; its LZH bit
188            // is flags&2. The single per-file CRC-32 is carried on both forks
189            // and verified once per entry (see [`verify_entry_crc`]).
190            let rsrc = if resourcelength > 0 {
191                Some(ForkInfo {
192                    method: 0,
193                    codec: ForkCodec::CompactPro {
194                        lzh: flags & 2 != 0,
195                    },
196                    encrypted,
197                    uncompressed_len: resourcelength,
198                    compressed_len: resourcecomplen,
199                    crc: 0,
200                    crc32: crc,
201                    offset: fileoffs,
202                })
203            } else {
204                None
205            };
206            // Data fork present when it has bytes, or when the file has neither
207            // fork (a zero-length data fork). Its LZH bit is flags&4.
208            let data = if datalength > 0 || resourcelength == 0 {
209                Some(ForkInfo {
210                    method: 0,
211                    codec: ForkCodec::CompactPro {
212                        lzh: flags & 4 != 0,
213                    },
214                    encrypted,
215                    uncompressed_len: datalength,
216                    compressed_len: datacomplen,
217                    crc: 0,
218                    crc32: crc,
219                    offset: fileoffs + resourcecomplen as u64,
220                })
221            } else {
222                None
223            };
224
225            entries.push(StuffItEntry {
226                path,
227                name,
228                is_dir: false,
229                type_code,
230                creator_code,
231                finder_flags,
232                create_date,
233                mod_date,
234                data,
235                rsrc,
236            });
237            numentries -= 1;
238        }
239    }
240    Ok(())
241}
242
243fn read_u16(bytes: &[u8], pos: &mut usize) -> Result<u16> {
244    let v = bytes
245        .get(*pos..*pos + 2)
246        .ok_or_else(|| anyhow::anyhow!("Compact Pro: index truncated"))?;
247    *pos += 2;
248    Ok(BigEndian::read_u16(v))
249}
250
251// ---------------------------------------------------------------------------
252// Fork decompression
253// ---------------------------------------------------------------------------
254
255/// Decompress one Compact Pro fork (RLE, optionally LZH-fed). The CRC is not
256/// checked here — Compact Pro stores one checksum per *file* covering the
257/// resource fork followed by the data fork, so the caller verifies it once
258/// both forks are in hand via [`verify_entry_crc`].
259pub fn decompress_fork(archive: &[u8], fork: &ForkInfo) -> Result<Vec<u8>> {
260    let lzh = match fork.codec {
261        ForkCodec::CompactPro { lzh } => lzh,
262        ForkCodec::StuffIt => bail!("compactpro::decompress_fork called on a StuffIt fork"),
263    };
264    if fork.encrypted {
265        bail!("Compact Pro: encrypted entries are not supported");
266    }
267
268    let start = fork.offset as usize;
269    let end = start
270        .checked_add(fork.compressed_len as usize)
271        .ok_or_else(|| anyhow::anyhow!("Compact Pro: fork length overflow"))?;
272    if end > archive.len() {
273        bail!("Compact Pro: fork data extends past end of archive");
274    }
275    let comp = &archive[start..end];
276    let out_len = fork.uncompressed_len as usize;
277
278    if lzh {
279        let mut src = CpLzh::new(comp);
280        rle_decode(&mut src, out_len)
281    } else {
282        let mut src = SliceSource::new(comp);
283        rle_decode(&mut src, out_len)
284    }
285}
286
287/// Verify a Compact Pro file's CRC-32. The stored checksum covers the
288/// decompressed resource fork immediately followed by the decompressed data
289/// fork (either may be empty), as a single reflected CRC-32 with no final
290/// complement. This is the only integrity check for the format, so it runs
291/// for every entry — single-fork, dual-fork, and empty alike.
292pub fn verify_entry_crc(rsrc: &[u8], data: &[u8], expected: u32) -> Result<()> {
293    let got = crc32_update(crc32_update(0xffff_ffff, rsrc), data);
294    if got != expected {
295        bail!("Compact Pro: file CRC mismatch (got {got:#010x}, expected {expected:#010x})");
296    }
297    Ok(())
298}
299
300// ---------------------------------------------------------------------------
301// RLE layer (XADCompactProRLEHandle)
302// ---------------------------------------------------------------------------
303
304/// A byte source the RLE layer pulls from: either the LZH decoder or, for
305/// RLE-only forks, the raw compressed bytes.
306trait ByteSource {
307    fn next_byte(&mut self) -> Option<u8>;
308}
309
310struct SliceSource<'a> {
311    data: &'a [u8],
312    pos: usize,
313}
314
315impl<'a> SliceSource<'a> {
316    fn new(data: &'a [u8]) -> Self {
317        SliceSource { data, pos: 0 }
318    }
319}
320
321impl ByteSource for SliceSource<'_> {
322    fn next_byte(&mut self) -> Option<u8> {
323        let v = self.data.get(self.pos).copied();
324        if v.is_some() {
325            self.pos += 1;
326        }
327        v
328    }
329}
330
331#[derive(Default)]
332struct RleState {
333    saved: u8,
334    /// Remaining repeats of `saved`. Signed so a malformed short run count
335    /// can't wrap into a huge positive value (matches XAD's `int repeat`).
336    repeat: i64,
337    halfescaped: bool,
338}
339
340/// Produce one decompressed byte, mirroring `produceByteAtOffset`. `0x81` is
341/// the escape; `0x81 0x82 n` repeats the previous byte, `0x81 0x82 0` emits a
342/// literal `0x81 0x82`, `0x81 0x81` emits a literal `0x81`, and `0x81 x`
343/// emits `0x81 x`.
344fn rle_next(st: &mut RleState, src: &mut dyn ByteSource) -> Option<u8> {
345    if st.repeat != 0 {
346        st.repeat -= 1;
347        return Some(st.saved);
348    }
349
350    let byte = if st.halfescaped {
351        st.halfescaped = false;
352        0x81
353    } else {
354        src.next_byte()?
355    };
356
357    if byte != 0x81 {
358        st.saved = byte;
359        return Some(byte);
360    }
361
362    let b2 = src.next_byte()?;
363    if b2 == 0x82 {
364        let count = src.next_byte()?;
365        if count != 0 {
366            st.repeat = count as i64 - 2;
367            Some(st.saved)
368        } else {
369            st.repeat = 1;
370            st.saved = 0x82;
371            Some(0x81)
372        }
373    } else if b2 == 0x81 {
374        st.halfescaped = true;
375        st.saved = 0x81;
376        Some(0x81)
377    } else {
378        st.repeat = 1;
379        st.saved = b2;
380        Some(0x81)
381    }
382}
383
384fn rle_decode(src: &mut dyn ByteSource, out_len: usize) -> Result<Vec<u8>> {
385    let mut out = Vec::with_capacity(out_len);
386    let mut st = RleState::default();
387    while out.len() < out_len {
388        match rle_next(&mut st, src) {
389            Some(b) => out.push(b),
390            None => bail!(
391                "Compact Pro: unexpected end of compressed data ({} of {} bytes)",
392                out.len(),
393                out_len
394            ),
395        }
396    }
397    Ok(out)
398}
399
400// ---------------------------------------------------------------------------
401// LZH layer (XADCompactProLZHHandle over XADLZSSHandle)
402// ---------------------------------------------------------------------------
403
404const LZH_BLOCKSIZE: i64 = 0x1fff0;
405const LZH_WINDOW_MASK: u64 = 0x1fff; // 8192-byte window
406
407enum Token {
408    Literal(u8),
409    Match { offset: u32, length: i32 },
410}
411
412struct CpLzh<'a> {
413    input: CpInput<'a>,
414    window: Vec<u8>,
415    pos: u64,
416    matchlength: i32,
417    matchoffset: u64,
418    blockcount: i64,
419    blockstart: i64,
420    literalcode: Option<Huff>,
421    lengthcode: Option<Huff>,
422    offsetcode: Option<Huff>,
423    ended: bool,
424}
425
426impl<'a> CpLzh<'a> {
427    fn new(data: &'a [u8]) -> Self {
428        CpLzh {
429            input: CpInput::new(data),
430            window: vec![0u8; (LZH_WINDOW_MASK + 1) as usize],
431            pos: 0,
432            matchlength: 0,
433            matchoffset: 0,
434            blockcount: LZH_BLOCKSIZE, // force a block header on first token
435            blockstart: 0,
436            literalcode: None,
437            lengthcode: None,
438            offsetcode: None,
439            ended: false,
440        }
441    }
442
443    fn produce(&mut self) -> Option<u8> {
444        if self.ended {
445            return None;
446        }
447        if self.matchlength == 0 {
448            match self.next_token() {
449                Some(Token::Literal(v)) => {
450                    self.window[(self.pos & LZH_WINDOW_MASK) as usize] = v;
451                    self.pos = self.pos.wrapping_add(1);
452                    return Some(v);
453                }
454                Some(Token::Match { offset, length }) => {
455                    self.matchlength = length;
456                    self.matchoffset = self.pos.wrapping_sub(offset as u64);
457                    // fall through to copy the first matched byte
458                }
459                None => {
460                    self.ended = true;
461                    return None;
462                }
463            }
464        }
465
466        self.matchlength -= 1;
467        let byte = self.window[(self.matchoffset & LZH_WINDOW_MASK) as usize];
468        self.matchoffset = self.matchoffset.wrapping_add(1);
469        self.window[(self.pos & LZH_WINDOW_MASK) as usize] = byte;
470        self.pos = self.pos.wrapping_add(1);
471        Some(byte)
472    }
473
474    fn next_token(&mut self) -> Option<Token> {
475        if self.blockcount >= LZH_BLOCKSIZE {
476            if self.blockstart != 0 {
477                // Skip the inter-block padding. (XAD's verbatim comment: "Don't
478                // let your bad implementations leak into your file formats,
479                // people!") Pad to an even offset relative to the block start.
480                self.input.skip_to_byte_boundary();
481                if (self.input.buffer_offset() - self.blockstart) & 1 != 0 {
482                    self.input.skip_bytes(3);
483                } else {
484                    self.input.skip_bytes(2);
485                }
486            }
487            self.literalcode = Some(self.parse_code(256)?);
488            self.lengthcode = Some(self.parse_code(64)?);
489            self.offsetcode = Some(self.parse_code(128)?);
490            self.blockcount = 0;
491            self.blockstart = self.input.buffer_offset();
492        }
493
494        if self.input.next_bit() != 0 {
495            self.blockcount += 2;
496            let sym = Huff::decode(self.literalcode.as_ref()?, &mut self.input)?;
497            Some(Token::Literal(sym as u8))
498        } else {
499            self.blockcount += 3;
500            let length = Huff::decode(self.lengthcode.as_ref()?, &mut self.input)?;
501            let osym = Huff::decode(self.offsetcode.as_ref()?, &mut self.input)?;
502            let offset = ((osym as u32) << 6) | self.input.next_bits(6);
503            Some(Token::Match { offset, length })
504        }
505    }
506
507    /// Parse one Huffman sub-table: a byte count, then that many bytes of two
508    /// 4-bit code lengths each; the remaining symbols are absent (length 0).
509    fn parse_code(&mut self, size: usize) -> Option<Huff> {
510        let numbytes = self.input.next_byte()? as usize;
511        if numbytes * 2 > size {
512            return None;
513        }
514        let mut lengths = vec![0i32; size];
515        for i in 0..numbytes {
516            let val = self.input.next_byte()? as i32;
517            lengths[2 * i] = val >> 4;
518            lengths[2 * i + 1] = val & 0x0f;
519        }
520        Huff::build(&lengths)
521    }
522}
523
524impl ByteSource for CpLzh<'_> {
525    fn next_byte(&mut self) -> Option<u8> {
526        self.produce()
527    }
528}
529
530// ---------------------------------------------------------------------------
531// Canonical Huffman (XADPrefixCode, shortestCodeIsZeros, high-bit-first)
532// ---------------------------------------------------------------------------
533
534const HUFF_MAX_BITS: usize = 15;
535
536/// Canonical prefix code decoded high-bit-first, matching XADPrefixCode with
537/// `shortestCodeIsZeros:YES`. Built from per-symbol bit lengths (0 = absent).
538struct Huff {
539    /// `counts[len]` = number of symbols assigned a code of that length.
540    counts: [u16; HUFF_MAX_BITS + 1],
541    /// Symbols ordered by (length, symbol value) — the canonical order.
542    symbols: Vec<i32>,
543}
544
545impl Huff {
546    fn build(lengths: &[i32]) -> Option<Huff> {
547        let mut counts = [0u16; HUFF_MAX_BITS + 1];
548        for &l in lengths {
549            if !(0..=HUFF_MAX_BITS as i32).contains(&l) {
550                return None;
551            }
552            counts[l as usize] += 1;
553        }
554        // An all-absent table is valid but decodes nothing; reject over-
555        // subscribed code spaces (incomplete ones are allowed: a single symbol).
556        let mut left: i64 = 1;
557        for &count in &counts[1..=HUFF_MAX_BITS] {
558            left = (left << 1) - count as i64;
559            if left < 0 {
560                return None;
561            }
562        }
563
564        // Offsets into the symbol table, sorted by symbol within each length.
565        let mut offs = [0u16; HUFF_MAX_BITS + 2];
566        for len in 1..=HUFF_MAX_BITS {
567            offs[len + 1] = offs[len] + counts[len];
568        }
569        let total = offs[HUFF_MAX_BITS + 1] as usize;
570        let mut symbols = vec![0i32; total];
571        for (sym, &len) in lengths.iter().enumerate() {
572            if len != 0 {
573                symbols[offs[len as usize] as usize] = sym as i32;
574                offs[len as usize] += 1;
575            }
576        }
577
578        Some(Huff { counts, symbols })
579    }
580
581    /// Decode one symbol, consuming bits high-bit-first (puff.c's algorithm,
582    /// the inverse of the canonical assignment). Returns `None` on an invalid
583    /// or truncated code.
584    fn decode(&self, input: &mut CpInput) -> Option<i32> {
585        let mut code: i32 = 0;
586        let mut first: i32 = 0;
587        let mut index: i32 = 0;
588        for len in 1..=HUFF_MAX_BITS {
589            code |= input.next_bit() as i32;
590            let count = self.counts[len] as i32;
591            if code - count < first {
592                return self.symbols.get((index + (code - first)) as usize).copied();
593            }
594            index += count;
595            first += count;
596            first <<= 1;
597            code <<= 1;
598        }
599        None
600    }
601}
602
603// ---------------------------------------------------------------------------
604// Bit reader (CSInputBuffer, non-LE path) over an in-memory slice
605// ---------------------------------------------------------------------------
606
607/// MSB-first bit reader replicating XADMaster's `CSInputBuffer` byte/bit
608/// accounting closely enough that `buffer_offset()` (its `currbyte`) tracks
609/// `ceil(consumed_bits / 8)` — the LZH block-padding skip depends on it. Reads
610/// past end-of-input yield zero bits / `None` bytes rather than panicking.
611struct CpInput<'a> {
612    data: &'a [u8],
613    currbyte: usize,
614    bits: u32,
615    numbits: u32,
616}
617
618impl<'a> CpInput<'a> {
619    fn new(data: &'a [u8]) -> Self {
620        CpInput {
621            data,
622            currbyte: 0,
623            bits: 0,
624            numbits: 0,
625        }
626    }
627
628    fn peek_byte(&self, offs: usize) -> u32 {
629        match self.data.get(self.currbyte + offs) {
630            Some(&b) => b as u32,
631            None => 0,
632        }
633    }
634
635    fn fill_bits(&mut self) {
636        let mut numbytes = ((32 - self.numbits) >> 3) as usize;
637        let left = self.data.len().saturating_sub(self.currbyte);
638        if numbytes > left {
639            numbytes = left;
640        }
641        let s = (self.numbits >> 3) as usize;
642        match numbytes {
643            4 => {
644                self.bits = (self.peek_byte(s) << 24)
645                    | (self.peek_byte(s + 1) << 16)
646                    | (self.peek_byte(s + 2) << 8)
647                    | self.peek_byte(s + 3);
648            }
649            3 => {
650                self.bits |= ((self.peek_byte(s) << 16)
651                    | (self.peek_byte(s + 1) << 8)
652                    | self.peek_byte(s + 2))
653                    << (8 - self.numbits);
654            }
655            2 => {
656                self.bits |=
657                    ((self.peek_byte(s) << 8) | self.peek_byte(s + 1)) << (16 - self.numbits);
658            }
659            1 => {
660                self.bits |= self.peek_byte(s) << (24 - self.numbits);
661            }
662            _ => {}
663        }
664        self.numbits += (numbytes as u32) * 8;
665    }
666
667    fn peek_bits(&mut self, n: u32) -> u32 {
668        if n == 0 {
669            return 0;
670        }
671        if n > self.numbits {
672            self.fill_bits();
673        }
674        self.bits >> (32 - n)
675    }
676
677    fn skip_peeked(&mut self, n: u32) {
678        let numbytes = ((n as i64) - ((self.numbits & 7) as i64) + 7) >> 3;
679        self.currbyte += numbytes.max(0) as usize;
680        self.bits = if n >= 32 { 0 } else { self.bits << n };
681        self.numbits = self.numbits.saturating_sub(n);
682    }
683
684    fn next_bit(&mut self) -> u32 {
685        let b = self.peek_bits(1);
686        self.skip_peeked(1);
687        b
688    }
689
690    fn next_bits(&mut self, n: u32) -> u32 {
691        if n == 0 {
692            return 0;
693        }
694        let b = self.peek_bits(n);
695        self.skip_peeked(n);
696        b
697    }
698
699    /// Byte-aligned read (Compact Pro only uses this at block boundaries, where
700    /// the stream is on a byte boundary). `None` past end of input.
701    fn next_byte(&mut self) -> Option<u8> {
702        let v = self.data.get(self.currbyte).copied();
703        if v.is_some() {
704            self.currbyte += 1;
705        }
706        v
707    }
708
709    fn skip_to_byte_boundary(&mut self) {
710        self.bits = 0;
711        self.numbits = 0;
712    }
713
714    fn skip_bytes(&mut self, n: usize) {
715        self.currbyte += n;
716    }
717
718    fn buffer_offset(&self) -> i64 {
719        self.currbyte as i64
720    }
721}
722
723// ---------------------------------------------------------------------------
724// CRC-32 (reflected, polynomial 0xedb88320, no final XOR — Compact Pro stores
725// the running value directly)
726// ---------------------------------------------------------------------------
727
728fn crc_table() -> &'static [u32; 256] {
729    static TABLE: OnceLock<[u32; 256]> = OnceLock::new();
730    TABLE.get_or_init(|| {
731        let mut t = [0u32; 256];
732        let mut n = 0usize;
733        while n < 256 {
734            let mut c = n as u32;
735            let mut k = 0;
736            while k < 8 {
737                c = if c & 1 != 0 {
738                    0xedb8_8320 ^ (c >> 1)
739                } else {
740                    c >> 1
741                };
742                k += 1;
743            }
744            t[n] = c;
745            n += 1;
746        }
747        t
748    })
749}
750
751fn crc32_update(mut crc: u32, data: &[u8]) -> u32 {
752    let t = crc_table();
753    for &b in data {
754        crc = t[((crc ^ b as u32) & 0xff) as usize] ^ (crc >> 8);
755    }
756    crc
757}
758
759#[cfg(test)]
760mod tests {
761    use super::*;
762
763    /// RLE: a plain byte stream with no `0x81` escapes round-trips verbatim.
764    #[test]
765    fn rle_passthrough_without_escapes() {
766        let mut src = SliceSource::new(&[1, 2, 3, 4, 5]);
767        let out = rle_decode(&mut src, 5).unwrap();
768        assert_eq!(out, vec![1, 2, 3, 4, 5]);
769    }
770
771    /// RLE run: literal `A` then `0x81 0x82 0x04` repeats it to four total.
772    #[test]
773    fn rle_run_repeats_previous_byte() {
774        let mut src = SliceSource::new(&[b'A', 0x81, 0x82, 0x04]);
775        let out = rle_decode(&mut src, 4).unwrap();
776        assert_eq!(out, vec![b'A', b'A', b'A', b'A']);
777    }
778
779    /// RLE escape for a literal `0x81 0x82` pair: `0x81 0x82 0x00`.
780    #[test]
781    fn rle_literal_escape_pair() {
782        let mut src = SliceSource::new(&[0x81, 0x82, 0x00]);
783        let out = rle_decode(&mut src, 2).unwrap();
784        assert_eq!(out, vec![0x81, 0x82]);
785    }
786
787    /// CRC-32 stored form is the running value with no final complement; an
788    /// empty buffer leaves the seed untouched.
789    #[test]
790    fn crc32_empty_is_seed() {
791        assert_eq!(crc32_update(0xffff_ffff, &[]), 0xffff_ffff);
792    }
793
794    /// Reflected CRC-32 over "123456789": standard check value 0xCBF43926
795    /// after final XOR, i.e. the stored (pre-XOR) value is its complement.
796    #[test]
797    fn crc32_check_value() {
798        let stored = crc32_update(0xffff_ffff, b"123456789");
799        assert_eq!(stored ^ 0xffff_ffff, 0xCBF4_3926);
800    }
801
802    /// End-to-end against a real **multi-block** Compact Pro archive (the
803    /// 150 KB "Dunjin Text" data fork spans three LZH blocks, so this exercises
804    /// the inter-block padding skip). Freely available from the IF Archive at
805    /// `if-archive/games/mac/Dunjin44.cpt.hqx`. No-op when the file is absent.
806    /// Every entry's per-file CRC-32 is checked, which is the format's own
807    /// integrity guarantee — a decode error anywhere would fail it.
808    #[test]
809    fn extract_real_compactpro_if_present() {
810        let path = std::path::Path::new("/Users/dani/Downloads/Dunjin44.cpt.hqx");
811        if !path.is_file() {
812            return;
813        }
814        let raw = std::fs::read(path).unwrap();
815        let bh = fs_mac::binhex::parse_binhex(&raw).expect("binhex decode");
816        assert!(is_compactpro(&bh.data_fork), "should detect Compact Pro");
817        let archive = parse(&bh.data_fork).expect("compactpro parse");
818        let files: Vec<_> = archive.entries.iter().filter(|e| !e.is_dir).collect();
819        assert!(!files.is_empty());
820
821        let mut verified = 0;
822        let mut saw_multiblock_fork = false;
823        for e in &files {
824            let decode = |f: &ForkInfo| {
825                decompress_fork(&bh.data_fork, f).unwrap_or_else(|err| panic!("{}: {err}", e.name))
826            };
827            let data = e.data.as_ref().map(&decode).unwrap_or_default();
828            let rsrc = e.rsrc.as_ref().map(&decode).unwrap_or_default();
829            // > ~128 KB of fork data guarantees the LZH stream crossed a block.
830            if data.len() >= 150_000 || rsrc.len() >= 150_000 {
831                saw_multiblock_fork = true;
832            }
833            // The per-file CRC-32 rides on whichever fork(s) the file has.
834            if let Some(crc) = e.data.as_ref().or(e.rsrc.as_ref()).map(|f| f.crc32) {
835                verify_entry_crc(&rsrc, &data, crc)
836                    .unwrap_or_else(|err| panic!("{}: {err}", e.name));
837                verified += 1;
838            }
839        }
840        assert!(
841            saw_multiblock_fork,
842            "expected the 150 KB multi-block Dunjin Text fork"
843        );
844        assert!(verified > 0);
845        eprintln!("Compact Pro: CRC-verified {verified} entries (incl. multi-block)");
846    }
847
848    /// Bits are consumed MSB-first, matching XADMaster's `CSInputNextBit` /
849    /// `CSInputNextBitString`.
850    #[test]
851    fn cpinput_reads_bits_msb_first() {
852        let mut inp = CpInput::new(&[0b1011_0010]);
853        let bits: Vec<u32> = (0..8).map(|_| inp.next_bit()).collect();
854        assert_eq!(bits, vec![1, 0, 1, 1, 0, 0, 1, 0]);
855
856        let mut inp2 = CpInput::new(&[0b1100_1010, 0b1111_0000]);
857        assert_eq!(inp2.next_bits(4), 0b1100);
858        assert_eq!(inp2.next_bits(4), 0b1010);
859        assert_eq!(inp2.next_bits(8), 0b1111_0000);
860    }
861
862    /// `buffer_offset()` (XAD's `currbyte`) must equal `ceil(consumed_bits/8)`
863    /// at every step — the LZH inter-block padding skip depends on this exact
864    /// accounting. Validated independently of any real archive.
865    #[test]
866    fn cpinput_byte_offset_is_ceil_of_consumed_bits() {
867        let data = [0xAC, 0x35, 0xFF, 0x00, 0x42];
868        let mut inp = CpInput::new(&data);
869        for k in 1..=(data.len() as u32 * 8) {
870            let _ = inp.next_bit();
871            assert_eq!(
872                inp.buffer_offset(),
873                k.div_ceil(8) as i64,
874                "after consuming {k} bits"
875            );
876        }
877    }
878
879    /// Skipping to the byte boundary rounds the position up to the next byte,
880    /// and a subsequent byte read lands on that byte — the exact sequence the
881    /// block-transition code performs before parsing a new block header.
882    #[test]
883    fn cpinput_skip_to_byte_boundary_then_byte_read_aligns() {
884        let mut inp = CpInput::new(&[0xAB, 0xCD, 0xEF, 0x12]);
885        for _ in 0..3 {
886            inp.next_bit();
887        }
888        assert_eq!(inp.buffer_offset(), 1, "3 bits -> ceil = byte 1");
889        inp.skip_to_byte_boundary();
890        assert_eq!(inp.buffer_offset(), 1, "boundary holds at byte 1");
891        assert_eq!(inp.next_byte(), Some(0xCD), "byte read resumes at byte 1");
892        assert_eq!(inp.buffer_offset(), 2);
893        // skip_bytes advances exactly, matching the +2/+3 padding skip.
894        inp.skip_bytes(1);
895        assert_eq!(inp.next_byte(), Some(0x12));
896    }
897
898    /// A single-symbol Huffman code (one length-1 symbol) decodes that symbol.
899    #[test]
900    fn huff_single_symbol() {
901        let mut lengths = vec![0i32; 4];
902        lengths[2] = 1; // symbol 2, code length 1
903        let huff = Huff::build(&lengths).expect("valid code");
904        let mut input = CpInput::new(&[0x00]);
905        assert_eq!(huff.decode(&mut input), Some(2));
906    }
907
908    /// The per-file CRC covers `resource ++ data`; an empty file checksums to
909    /// the bare seed, and order matters (rsrc before data).
910    #[test]
911    fn verify_entry_crc_covers_rsrc_then_data() {
912        // Empty entry: seed value, as seen on Compact Pro's `Icon` placeholder.
913        assert!(verify_entry_crc(&[], &[], 0xffff_ffff).is_ok());
914
915        let rsrc = b"RSRC-BYTES";
916        let data = b"DATA-BYTES";
917        let expected = crc32_update(crc32_update(0xffff_ffff, rsrc), data);
918        assert!(verify_entry_crc(rsrc, data, expected).is_ok());
919        // Swapped order must not validate.
920        assert!(verify_entry_crc(data, rsrc, expected).is_err());
921        // A bit flip is caught.
922        assert!(verify_entry_crc(rsrc, data, expected ^ 1).is_err());
923    }
924
925    /// Over-subscribed code lengths are rejected rather than producing a
926    /// corrupt table.
927    #[test]
928    fn huff_rejects_oversubscribed() {
929        // Three length-1 codes cannot coexist (only two 1-bit codes exist).
930        let lengths = vec![1, 1, 1];
931        assert!(Huff::build(&lengths).is_none());
932    }
933}