1use anyhow::{bail, Result};
25use byteorder::{BigEndian, ByteOrder};
26
27const ALPHABET: &[u8; 64] = b"!\"#$%&'()*+,-012345689@ABCDEFGHIJKLMNPQRSTUVXYZ[`abcdefhijklmpqr";
29
30const BANNER: &str = "(This file must be converted with BinHex 4.0)";
31
32#[derive(Debug, Clone, PartialEq)]
34pub struct BinHexFile {
35 pub name: String,
37 pub type_code: [u8; 4],
39 pub creator_code: [u8; 4],
41 pub flags: u16,
43 pub data_fork: Vec<u8>,
45 pub resource_fork: Vec<u8>,
47}
48
49impl BinHexFile {
50 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
62fn 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 feed(0);
80 feed(0);
81 crc
82}
83
84pub 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 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
118pub 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; }
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
147fn 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
167pub 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 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); 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 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 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
214pub fn parse_binhex(input: &[u8]) -> Result<BinHexFile> {
216 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 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 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; 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; }
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 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; 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 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 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 #[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 assert_eq!(binhex_crc16(&[]), 0);
375 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], ];
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 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 #[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}