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