Skip to main content

fs_mac/
binhex.rs

1//! BinHex 4.0 (`.hqx`) encode + decode.
2//!
3//! BinHex 4.0 is the classic Macintosh 7-bit-safe encoding that preserves a
4//! file's **data fork, resource fork, and Finder info** (type/creator/flags) in
5//! a single ASCII text file. Layout of the decoded byte stream:
6//!
7//! ```text
8//! nameLen(1) name(nameLen) version(1=0) type(4) creator(4) flags(2)
9//! dataLen(4) rsrcLen(4) headerCRC(2)
10//! <data fork bytes> dataCRC(2)
11//! <resource fork bytes> rsrcCRC(2)
12//! ```
13//!
14//! That whole byte stream is RLE90-compressed (`0x90` run marker) and then
15//! 6-bit encoded into the BinHex alphabet, wrapped between `:` markers under the
16//! `(This file must be converted with BinHex 4.0)` banner.
17//!
18//! CRCs are CRC-16-CCITT (poly `0x1021`, init 0) in **augmented** form: the
19//! running CRC is finalized by feeding two trailing zero bytes. This differs
20//! from the MacBinary CRC in `resource_fork.rs` (which stores the CRC directly).
21//! Reference: hfsutils `src/common/crc.c` `crc_binh` + `src/binhex/binhex.c`,
22//! and XADMaster `XADBinHexParser.m`.
23
24use anyhow::{bail, Result};
25use byteorder::{BigEndian, ByteOrder};
26
27/// The 64-character BinHex 4.0 alphabet (note the gaps: no `7 O W g n o`).
28const ALPHABET: &[u8; 64] = b"!\"#$%&'()*+,-012345689@ABCDEFGHIJKLMNPQRSTUVXYZ[`abcdefhijklmpqr";
29
30const BANNER: &str = "(This file must be converted with BinHex 4.0)";
31
32/// A file decoded from / to be encoded into BinHex 4.0.
33#[derive(Debug, Clone, PartialEq)]
34pub struct BinHexFile {
35    /// Macintosh filename (≤ 63 bytes).
36    pub name: String,
37    /// 4-byte Finder type code (e.g. `TEXT`).
38    pub type_code: [u8; 4],
39    /// 4-byte Finder creator code (e.g. `ttxt`).
40    pub creator_code: [u8; 4],
41    /// 16-bit Finder flags.
42    pub flags: u16,
43    /// Data fork bytes.
44    pub data_fork: Vec<u8>,
45    /// Resource fork bytes.
46    pub resource_fork: Vec<u8>,
47}
48
49impl BinHexFile {
50    /// True when the decoded payload looks like a wrapped archive/disk image
51    /// (by filename extension) — useful for the auto-unwrap dispatch.
52    pub fn looks_like_archive(&self) -> bool {
53        let lower = self.name.to_ascii_lowercase();
54        [
55            ".sit", ".sitx", ".cpt", ".sea", ".bin", ".img", ".image", ".dsk",
56        ]
57        .iter()
58        .any(|ext| lower.ends_with(ext))
59    }
60}
61
62/// BinHex CRC-16-CCITT (augmented: data followed by two zero bytes).
63fn binhex_crc16(data: &[u8]) -> u16 {
64    let mut crc: u16 = 0;
65    let mut feed = |byte: u8| {
66        for i in (0..8).rev() {
67            let bit = ((byte >> i) & 1) as u16;
68            let msb = (crc >> 15) & 1;
69            crc = (crc << 1) | bit;
70            if msb == 1 {
71                crc ^= 0x1021;
72            }
73        }
74    };
75    for &b in data {
76        feed(b);
77    }
78    // Augment: finalize by clocking two zero bytes through the register.
79    feed(0);
80    feed(0);
81    crc
82}
83
84/// RLE90 compress: a run of `b` repeated `n` (3..=255) times becomes
85/// `b 0x90 n`; a literal `0x90` becomes `0x90 0x00`. Shared with the StuffIt
86/// writer (compression method 1).
87pub fn rle90_encode(data: &[u8]) -> Vec<u8> {
88    let mut out = Vec::with_capacity(data.len());
89    let mut i = 0;
90    while i < data.len() {
91        let b = data[i];
92        let mut run = 1usize;
93        while i + run < data.len() && data[i + run] == b && run < 255 {
94            run += 1;
95        }
96        if b == 0x90 {
97            // Emit one literal 0x90, then run-length the rest if needed.
98            out.push(0x90);
99            out.push(0x00);
100            if run > 1 {
101                out.push(0x90);
102                out.push(run as u8);
103            }
104        } else if run >= 3 {
105            out.push(b);
106            out.push(0x90);
107            out.push(run as u8);
108        } else {
109            for _ in 0..run {
110                out.push(b);
111            }
112        }
113        i += run;
114    }
115    out
116}
117
118/// RLE90 decompress (inverse of [`rle90_encode`]). Shared with the StuffIt
119/// reader, which uses the same 0x90 run encoding for compression method 1.
120pub fn rle90_decode(input: &[u8]) -> Vec<u8> {
121    let mut out = Vec::with_capacity(input.len() * 2);
122    let mut i = 0;
123    while i < input.len() {
124        let b = input[i];
125        i += 1;
126        if b != 0x90 {
127            out.push(b);
128            continue;
129        }
130        if i >= input.len() {
131            break; // dangling marker (padding); stop
132        }
133        let count = input[i];
134        i += 1;
135        if count == 0 {
136            out.push(0x90);
137        } else {
138            let last = *out.last().unwrap_or(&0);
139            for _ in 0..count.saturating_sub(1) {
140                out.push(last);
141            }
142        }
143    }
144    out
145}
146
147/// 6-bit encode into the BinHex alphabet (no line wrapping).
148fn sixbit_encode(data: &[u8]) -> String {
149    let mut out = String::with_capacity(data.len() * 4 / 3 + 4);
150    let mut acc: u32 = 0;
151    let mut bits = 0u32;
152    for &b in data {
153        acc = (acc << 8) | b as u32;
154        bits += 8;
155        while bits >= 6 {
156            bits -= 6;
157            out.push(ALPHABET[((acc >> bits) & 0x3f) as usize] as char);
158        }
159    }
160    if bits > 0 {
161        let idx = ((acc << (6 - bits)) & 0x3f) as usize;
162        out.push(ALPHABET[idx] as char);
163    }
164    out
165}
166
167/// Encode a [`BinHexFile`] into a complete `.hqx` text document.
168pub fn build_binhex(file: &BinHexFile) -> String {
169    let name_bytes = crate::resource_fork::mac_name_bytes(&file.name);
170    let name_len = name_bytes.len();
171
172    // Header: nameLen, name, version(0), type, creator, flags, dataLen, rsrcLen.
173    let mut header = Vec::with_capacity(name_len + 20);
174    header.push(name_len as u8);
175    header.extend_from_slice(&name_bytes);
176    header.push(0); // version
177    header.extend_from_slice(&file.type_code);
178    header.extend_from_slice(&file.creator_code);
179    let mut tmp = [0u8; 4];
180    BigEndian::write_u16(&mut tmp[..2], file.flags);
181    header.extend_from_slice(&tmp[..2]);
182    BigEndian::write_u32(&mut tmp, file.data_fork.len() as u32);
183    header.extend_from_slice(&tmp);
184    BigEndian::write_u32(&mut tmp, file.resource_fork.len() as u32);
185    header.extend_from_slice(&tmp);
186
187    // Assemble the full pre-RLE byte stream with all three CRCs.
188    let mut raw = header.clone();
189    push_u16(&mut raw, binhex_crc16(&header));
190    raw.extend_from_slice(&file.data_fork);
191    push_u16(&mut raw, binhex_crc16(&file.data_fork));
192    raw.extend_from_slice(&file.resource_fork);
193    push_u16(&mut raw, binhex_crc16(&file.resource_fork));
194
195    let encoded = sixbit_encode(&rle90_encode(&raw));
196
197    // Wrap: banner, blank line, ':' + 64-char lines + ':'.
198    let mut out = String::with_capacity(encoded.len() + encoded.len() / 64 + BANNER.len() + 8);
199    out.push_str(BANNER);
200    out.push('\n');
201    out.push('\n');
202    out.push(':');
203    for (i, ch) in encoded.chars().enumerate() {
204        if i > 0 && i % 64 == 0 {
205            out.push('\n');
206        }
207        out.push(ch);
208    }
209    out.push(':');
210    out.push('\n');
211    out
212}
213
214/// Decode a `.hqx` document into a [`BinHexFile`], verifying all CRCs.
215pub fn parse_binhex(input: &[u8]) -> Result<BinHexFile> {
216    // Inverse alphabet table (-1 == not a data character).
217    let mut inv = [-1i8; 256];
218    for (i, &c) in ALPHABET.iter().enumerate() {
219        inv[c as usize] = i as i8;
220    }
221
222    let marker = BANNER.as_bytes();
223    // Match on the stable prefix in case the version digits differ slightly.
224    let prefix = b"(This file must be converted with BinHex";
225    let pos = find_subslice(input, prefix)
226        .or_else(|| find_subslice(input, marker))
227        .ok_or_else(|| anyhow::anyhow!("BinHex: banner not found"))?;
228
229    // Skip to the end of the banner line, then to the opening ':'.
230    let mut i = pos + prefix.len();
231    while i < input.len() && input[i] != b'\n' && input[i] != b'\r' {
232        i += 1;
233    }
234    while i < input.len() && input[i] != b':' {
235        i += 1;
236    }
237    if i >= input.len() {
238        bail!("BinHex: opening ':' not found");
239    }
240    i += 1; // skip opening ':'
241
242    // 6-bit decode until the closing ':'.
243    let mut rle = Vec::new();
244    let mut acc: u32 = 0;
245    let mut bits = 0u32;
246    while i < input.len() {
247        let c = input[i];
248        i += 1;
249        if c == b':' {
250            break;
251        }
252        let v = inv[c as usize];
253        if v < 0 {
254            continue; // whitespace / line breaks
255        }
256        acc = (acc << 6) | v as u32;
257        bits += 6;
258        if bits >= 8 {
259            bits -= 8;
260            rle.push((acc >> bits) as u8);
261        }
262    }
263
264    let raw = rle90_decode(&rle);
265    parse_decoded(&raw)
266}
267
268fn parse_decoded(raw: &[u8]) -> Result<BinHexFile> {
269    if raw.is_empty() {
270        bail!("BinHex: empty payload");
271    }
272    let name_len = raw[0] as usize;
273    if name_len == 0 || name_len > 63 {
274        bail!("BinHex: bad name length {name_len}");
275    }
276    // nameLen(1) name version(1) type(4) creator(4) flags(2) dataLen(4) rsrcLen(4) crc(2)
277    let header_len = 1 + name_len + 1 + 4 + 4 + 2 + 4 + 4;
278    let need = header_len + 2;
279    if raw.len() < need {
280        bail!("BinHex: truncated header");
281    }
282
283    let name = crate::hfs::decode_mac_filename(&raw[1..1 + name_len]);
284    let mut p = 1 + name_len + 1; // skip version byte
285    let type_code: [u8; 4] = raw[p..p + 4].try_into().unwrap();
286    p += 4;
287    let creator_code: [u8; 4] = raw[p..p + 4].try_into().unwrap();
288    p += 4;
289    let flags = BigEndian::read_u16(&raw[p..p + 2]);
290    p += 2;
291    let data_len = BigEndian::read_u32(&raw[p..p + 4]) as usize;
292    p += 4;
293    let rsrc_len = BigEndian::read_u32(&raw[p..p + 4]) as usize;
294    p += 4;
295    let header_crc = BigEndian::read_u16(&raw[p..p + 2]);
296
297    let computed = binhex_crc16(&raw[0..header_len]);
298    if computed != header_crc {
299        bail!("BinHex: header CRC mismatch (got {computed:#06x}, expected {header_crc:#06x})");
300    }
301
302    // data fork + its CRC
303    let data_start = header_len + 2;
304    let data_end = data_start + data_len;
305    if raw.len() < data_end + 2 {
306        bail!("BinHex: truncated data fork");
307    }
308    let data_fork = raw[data_start..data_end].to_vec();
309    let data_crc = BigEndian::read_u16(&raw[data_end..data_end + 2]);
310    let computed = binhex_crc16(&data_fork);
311    if computed != data_crc {
312        bail!("BinHex: data fork CRC mismatch (got {computed:#06x}, expected {data_crc:#06x})");
313    }
314
315    // resource fork + its CRC
316    let rsrc_start = data_end + 2;
317    let rsrc_end = rsrc_start + rsrc_len;
318    if raw.len() < rsrc_end + 2 {
319        bail!("BinHex: truncated resource fork");
320    }
321    let resource_fork = raw[rsrc_start..rsrc_end].to_vec();
322    let rsrc_crc = BigEndian::read_u16(&raw[rsrc_end..rsrc_end + 2]);
323    let computed = binhex_crc16(&resource_fork);
324    if computed != rsrc_crc {
325        bail!("BinHex: resource fork CRC mismatch (got {computed:#06x}, expected {rsrc_crc:#06x})");
326    }
327
328    Ok(BinHexFile {
329        name,
330        type_code,
331        creator_code,
332        flags,
333        data_fork,
334        resource_fork,
335    })
336}
337
338fn push_u16(buf: &mut Vec<u8>, v: u16) {
339    buf.push((v >> 8) as u8);
340    buf.push((v & 0xff) as u8);
341}
342
343fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> {
344    if needle.is_empty() || haystack.len() < needle.len() {
345        return None;
346    }
347    haystack.windows(needle.len()).position(|w| w == needle)
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353
354    /// X13: the header name is Mac Roman on both sides of the wire.
355    #[test]
356    fn binhex_names_round_trip_through_mac_roman() {
357        let file = BinHexFile {
358            name: "Caf\u{e9} \u{2122}".to_string(),
359            type_code: *b"TEXT",
360            creator_code: *b"ttxt",
361            flags: 0,
362            data_fork: b"hello".to_vec(),
363            resource_fork: Vec::new(),
364        };
365        let text = build_binhex(&file);
366        let back = parse_binhex(text.as_bytes()).unwrap();
367        assert_eq!(back.name, "Caf\u{e9} \u{2122}");
368        assert_eq!(back.data_fork, b"hello");
369    }
370
371    #[test]
372    fn crc_known_vector() {
373        // BinHex CRC of empty input (just the two augment bytes) is 0.
374        assert_eq!(binhex_crc16(&[]), 0);
375        // Single 0x00 byte -> still 0 through the register.
376        assert_eq!(binhex_crc16(&[0]), 0);
377    }
378
379    #[test]
380    fn rle90_roundtrip() {
381        let cases: Vec<Vec<u8>> = vec![
382            vec![],
383            vec![1, 2, 3],
384            vec![5, 5, 5, 5, 5],
385            vec![0x90],
386            vec![0x90, 0x90, 0x90],
387            vec![0, 0, 0, 0x90, 1, 1, 1, 1, 0x90, 0x90],
388            (0..=255u8).collect(),
389            vec![7u8; 300], // exceeds single-byte run cap
390        ];
391        for c in cases {
392            let enc = rle90_encode(&c);
393            let dec = rle90_decode(&enc);
394            assert_eq!(dec, c, "rle90 roundtrip failed for {c:?}");
395        }
396    }
397
398    #[test]
399    fn sixbit_roundtrip_via_full() {
400        // Exercised more fully by binhex_roundtrip; this checks alphabet sanity.
401        assert_eq!(ALPHABET.len(), 64);
402        let mut seen = std::collections::HashSet::new();
403        for &c in ALPHABET.iter() {
404            assert!(seen.insert(c), "duplicate alphabet char {c}");
405        }
406    }
407
408    #[test]
409    fn binhex_roundtrip() {
410        let file = BinHexFile {
411            name: "Test File".to_string(),
412            type_code: *b"TEXT",
413            creator_code: *b"ttxt",
414            flags: 0x0000,
415            data_fork: b"Hello, classic Mac world!\r\rThis has \x90\x90 markers.".to_vec(),
416            resource_fork: vec![0xCA, 0xFE, 0xBA, 0xBE, 0, 0, 0, 1, 2, 3],
417        };
418        let text = build_binhex(&file);
419        assert!(text.contains(BANNER));
420        let parsed = parse_binhex(text.as_bytes()).expect("should parse");
421        assert_eq!(parsed, file);
422    }
423
424    #[test]
425    fn binhex_empty_forks() {
426        let file = BinHexFile {
427            name: "Empty".to_string(),
428            type_code: *b"TEXT",
429            creator_code: *b"ttxt",
430            flags: 0,
431            data_fork: vec![],
432            resource_fork: vec![],
433        };
434        let text = build_binhex(&file);
435        let parsed = parse_binhex(text.as_bytes()).expect("should parse");
436        assert_eq!(parsed, file);
437    }
438
439    #[test]
440    fn rejects_non_binhex() {
441        assert!(parse_binhex(b"not a binhex file at all").is_err());
442    }
443
444    /// Validates against real `.hqx` samples if present locally (not in CI).
445    /// Confirms the CRC + RLE + alphabet match real-world encoders.
446    #[test]
447    fn parse_real_samples_if_present() {
448        let dir = std::path::Path::new("/Users/dani/Downloads");
449        if !dir.is_dir() {
450            return;
451        }
452        let mut checked = 0;
453        for entry in std::fs::read_dir(dir).unwrap().flatten() {
454            let path = entry.path();
455            if path.extension().and_then(|e| e.to_str()) != Some("hqx") {
456                continue;
457            }
458            let bytes = match std::fs::read(&path) {
459                Ok(b) => b,
460                Err(_) => continue,
461            };
462            let parsed = parse_binhex(&bytes).unwrap_or_else(|e| panic!("failed on {path:?}: {e}"));
463            assert!(!parsed.name.is_empty(), "empty name in {path:?}");
464            checked += 1;
465            if checked >= 5 {
466                break;
467            }
468        }
469        eprintln!("validated {checked} real .hqx samples");
470    }
471}