Skip to main content

fs_mac/
resource_fork.rs

1//! Resource fork serialization for HFS/HFS+ file extraction.
2//!
3//! Supports AppleDouble (._prefix sidecar), MacBinary III (.bin wrapper),
4//! and separate .rsrc sidecar files.
5
6use byteorder::{BigEndian, ByteOrder};
7
8/// How to handle resource forks during extraction.
9#[derive(Debug, Clone, Copy, PartialEq)]
10pub enum ResourceForkMode {
11    /// Write resource fork directly to the file's native resource fork
12    /// (macOS only — uses `filename/..namedfork/rsrc`).
13    Native,
14    /// AppleDouble format: ._prefix sidecar files
15    AppleDouble,
16    /// MacBinary III: single .bin file containing both forks
17    MacBinary,
18    /// BinHex 4.0: single .hqx text file containing both forks + Finder info
19    BinHex,
20    /// Separate .rsrc sidecar files
21    SeparateRsrc,
22    /// Skip resource forks entirely
23    DataForkOnly,
24}
25
26impl ResourceForkMode {
27    pub fn label(&self) -> &'static str {
28        match self {
29            ResourceForkMode::Native => "Native resource fork",
30            ResourceForkMode::AppleDouble => "AppleDouble (._file)",
31            ResourceForkMode::MacBinary => "MacBinary III (.bin)",
32            ResourceForkMode::BinHex => "BinHex 4.0 (.hqx)",
33            ResourceForkMode::SeparateRsrc => "Separate (.rsrc)",
34            ResourceForkMode::DataForkOnly => "Data fork only",
35        }
36    }
37
38    pub const ALL: [ResourceForkMode; 6] = [
39        ResourceForkMode::Native,
40        ResourceForkMode::AppleDouble,
41        ResourceForkMode::MacBinary,
42        ResourceForkMode::BinHex,
43        ResourceForkMode::SeparateRsrc,
44        ResourceForkMode::DataForkOnly,
45    ];
46}
47
48/// Mac file dates as raw seconds since the classic Mac epoch (1904-01-01) —
49/// the exact encoding MacBinary stores in its header and HFS/HFS+ record on
50/// disk. A field of `0` means "unknown": MacBinary writes a zero date and
51/// AppleDouble omits the File Dates Info entry, so callers with no dates get
52/// byte-identical output to the pre-dates behavior.
53#[derive(Debug, Clone, Copy, Default)]
54pub struct MacFileDates {
55    /// Creation time, seconds since 1904-01-01.
56    pub created: u32,
57    /// Modification time, seconds since 1904-01-01.
58    pub modified: u32,
59}
60
61/// Seconds between the classic Mac epoch (1904-01-01) and the Unix epoch.
62const MAC_EPOCH_UNIX_OFFSET: i64 = 2_082_844_800;
63/// Unix seconds at the AppleDouble "File Dates Info" epoch (2000-01-01 GMT).
64const APPLEDOUBLE_EPOCH_UNIX: i64 = 946_684_800;
65/// Apple's "unknown date" sentinel for AppleSingle/AppleDouble date fields.
66const APPLEDOUBLE_DATE_UNKNOWN: i32 = i32::MIN;
67
68/// Convert a Mac-epoch (1904) second count to the AppleDouble "File Dates Info"
69/// encoding: signed seconds relative to 2000-01-01 GMT. `0` (unknown) maps to
70/// Apple's `0x80000000` sentinel; out-of-range values clamp into `i32`.
71fn mac1904_to_appledouble(mac_secs: u32) -> i32 {
72    if mac_secs == 0 {
73        return APPLEDOUBLE_DATE_UNKNOWN;
74    }
75    let secs_2000 = mac_secs as i64 - MAC_EPOCH_UNIX_OFFSET - APPLEDOUBLE_EPOCH_UNIX;
76    // Keep off i32::MIN so a real date can't collide with the "unknown" sentinel.
77    secs_2000.clamp(i32::MIN as i64 + 1, i32::MAX as i64) as i32
78}
79
80/// Build an AppleDouble (version 2) sidecar file.
81///
82/// Contains Finder info (type/creator codes), file dates (when known), and
83/// resource fork data.
84/// Format: magic(4) + version(4) + filler(16) + num_entries(2) + entries...
85pub fn build_appledouble(
86    type_code: &[u8; 4],
87    creator_code: &[u8; 4],
88    dates: MacFileDates,
89    rsrc_data: &[u8],
90) -> Vec<u8> {
91    // Entries, in on-disk order: Finder Info (id 9, 32B), then — only when we
92    // have real dates — File Dates Info (id 8, 16B), then Resource Fork
93    // (id 2, variable). Omitting the dates entry when unknown keeps the output
94    // byte-identical to the pre-dates two-entry layout.
95    let has_dates = dates.created != 0 || dates.modified != 0;
96    let num_entries: u16 = if has_dates { 3 } else { 2 };
97    let header_len = 26 + num_entries as usize * 12; // 26-byte header + descriptors
98
99    let finder_offset = header_len as u32;
100    let finder_len: u32 = 32;
101    let dates_offset = finder_offset + finder_len; // meaningful only when has_dates
102    let dates_len: u32 = 16;
103    let rsrc_offset = if has_dates {
104        dates_offset + dates_len
105    } else {
106        finder_offset + finder_len
107    };
108    let rsrc_len: u32 = rsrc_data.len() as u32;
109
110    let total = rsrc_offset as usize + rsrc_data.len();
111    let mut buf = vec![0u8; total];
112
113    // Magic number
114    BigEndian::write_u32(&mut buf[0..4], APPLEDOUBLE_MAGIC);
115    // Version 2
116    BigEndian::write_u32(&mut buf[4..8], 0x00020000);
117    // Filler: 16 bytes of zeros (already zero)
118    // Number of entries
119    BigEndian::write_u16(&mut buf[24..26], num_entries);
120
121    // Entry descriptors: 12 bytes each (id, offset, length), starting at 26.
122    let mut d = 26;
123    let mut write_desc = |buf: &mut [u8], id: u32, off: u32, len: u32| {
124        BigEndian::write_u32(&mut buf[d..d + 4], id);
125        BigEndian::write_u32(&mut buf[d + 4..d + 8], off);
126        BigEndian::write_u32(&mut buf[d + 8..d + 12], len);
127        d += 12;
128    };
129    write_desc(&mut buf, 9, finder_offset, finder_len); // Finder Info
130    if has_dates {
131        write_desc(&mut buf, 8, dates_offset, dates_len); // File Dates Info
132    }
133    write_desc(&mut buf, 2, rsrc_offset, rsrc_len); // Resource Fork
134
135    // Finder Info data (32 bytes): fdType(4) + fdCreator(4) + fdFlags(2) + fdLocation(4) + fdFldr(2) + extended(16)
136    let fi_start = finder_offset as usize;
137    buf[fi_start..fi_start + 4].copy_from_slice(type_code);
138    buf[fi_start + 4..fi_start + 8].copy_from_slice(creator_code);
139    // Rest is zeros
140
141    // File Dates Info (16 bytes): create/modify/backup/access, signed seconds
142    // relative to 2000-01-01 GMT. We only know create/modify; the rest are the
143    // "unknown" sentinel.
144    if has_dates {
145        let di = dates_offset as usize;
146        BigEndian::write_i32(&mut buf[di..di + 4], mac1904_to_appledouble(dates.created));
147        BigEndian::write_i32(
148            &mut buf[di + 4..di + 8],
149            mac1904_to_appledouble(dates.modified),
150        );
151        BigEndian::write_i32(&mut buf[di + 8..di + 12], APPLEDOUBLE_DATE_UNKNOWN);
152        BigEndian::write_i32(&mut buf[di + 12..di + 16], APPLEDOUBLE_DATE_UNKNOWN);
153    }
154
155    // Resource fork data
156    buf[rsrc_offset as usize..].copy_from_slice(rsrc_data);
157
158    buf
159}
160
161/// Build a MacBinary III file containing both data and resource forks.
162///
163/// Format: 128-byte header + data fork (padded to 128) + resource fork (padded to 128).
164/// The .bin file replaces the separate data fork file.
165pub fn build_macbinary(
166    filename: &str,
167    type_code: &[u8; 4],
168    creator_code: &[u8; 4],
169    dates: MacFileDates,
170    data_fork: &[u8],
171    rsrc_data: &[u8],
172) -> Vec<u8> {
173    let data_padded = pad_to_128(data_fork.len());
174    let rsrc_padded = pad_to_128(rsrc_data.len());
175    let total = 128 + data_padded + rsrc_padded;
176    let mut buf = vec![0u8; total];
177
178    // Header byte 0: always 0
179    // Header byte 1: filename length (max 63)
180    let name_bytes = mac_name_bytes(filename);
181    let name_len = name_bytes.len();
182    buf[1] = name_len as u8;
183    buf[2..2 + name_len].copy_from_slice(&name_bytes);
184
185    // Type code at 65-68, creator at 69-72
186    buf[65..69].copy_from_slice(type_code);
187    buf[69..73].copy_from_slice(creator_code);
188
189    // Data fork length at 83-86 (big-endian)
190    BigEndian::write_u32(&mut buf[83..87], data_fork.len() as u32);
191    // Resource fork length at 87-90
192    BigEndian::write_u32(&mut buf[87..91], rsrc_data.len() as u32);
193
194    // Creation date at 91-94, modification date at 95-98: u32 secs since 1904,
195    // exactly what HFS/HFS+ record — so the raw on-disc timestamps drop in with
196    // no conversion. Both stay zero ("unknown") when `dates` is defaulted. These
197    // sit inside the CRC'd header region, so they must be written before the CRC.
198    BigEndian::write_u32(&mut buf[91..95], dates.created);
199    BigEndian::write_u32(&mut buf[95..99], dates.modified);
200
201    // MacBinary version: byte 122 = 130 (MacBinary III)
202    buf[122] = 130;
203    // Minimum version: byte 123 = 129 (MacBinary II)
204    buf[123] = 129;
205
206    // CRC-16 of header bytes 0-123 at bytes 124-125
207    let crc = macbinary_crc16(&buf[0..124]);
208    BigEndian::write_u16(&mut buf[124..126], crc);
209
210    // Data fork
211    buf[128..128 + data_fork.len()].copy_from_slice(data_fork);
212    // Resource fork
213    let rsrc_start = 128 + data_padded;
214    buf[rsrc_start..rsrc_start + rsrc_data.len()].copy_from_slice(rsrc_data);
215
216    buf
217}
218
219/// Sanitize a filename for the host OS via the shared legalizer (Windows rules on Windows).
220pub fn sanitize_filename(name: &str) -> String {
221    let check = disk_core::name_legalize::validate_host_name;
222    disk_core::name_legalize::legalize_name(&check, name).unwrap_or_else(|_| "_".to_string())
223}
224
225/// Round up to the next multiple of 128.
226fn pad_to_128(len: usize) -> usize {
227    (len + 127) & !127
228}
229
230/// CRC-16 used by MacBinary (CRC-16/XMODEM: CCITT polynomial 0x1021, init 0,
231/// no reflection, no final XOR). Computed over header bytes 0..124. Shared
232/// with `macarchive::macbinary`, the canonical full-fidelity parser.
233pub fn macbinary_crc16(data: &[u8]) -> u16 {
234    let mut crc: u16 = 0;
235    for &byte in data {
236        crc ^= (byte as u16) << 8;
237        for _ in 0..8 {
238            if crc & 0x8000 != 0 {
239                crc = (crc << 1) ^ 0x1021;
240            } else {
241                crc <<= 1;
242            }
243        }
244    }
245    crc
246}
247
248pub use disk_core::filesystem::ImportedResourceFork;
249
250/// Detect and read a resource fork associated with `host_path` by probing
251/// all supported formats in priority order:
252///
253/// 1. **Native** (macOS only): `host_path/..namedfork/rsrc`
254/// 2. **AppleDouble**: `._<filename>` sidecar next to the file
255/// 3. **MacBinary**: the file itself is a `.bin` MacBinary container
256/// 4. **AppleSingle**: the file itself is an AppleSingle container
257/// 5. **BinHex**: the file itself is a `.hqx` container
258/// 6. **Separate .rsrc**: `<filename>.rsrc` sidecar next to the file
259///
260/// Returns `None` if no resource fork is found.
261pub fn detect_resource_fork(host_path: &std::path::Path) -> Option<ImportedResourceFork> {
262    // 1. Native macOS resource fork
263    #[cfg(target_os = "macos")]
264    {
265        let rsrc_path = host_path.join("..namedfork/rsrc");
266        if let Ok(data) = std::fs::read(&rsrc_path) {
267            if !data.is_empty() {
268                let (type_code, creator_code) = read_finder_info_xattr(host_path);
269                return Some(ImportedResourceFork {
270                    data,
271                    data_fork: None,
272                    type_code,
273                    creator_code,
274                    name: None,
275                    finder_flags: None,
276                });
277            }
278        }
279    }
280
281    let file_name = host_path.file_name()?.to_str()?;
282    let parent = host_path.parent()?;
283
284    // 2. AppleDouble sidecar
285    let ad_path = parent.join(format!("._{file_name}"));
286    if ad_path.is_file() {
287        if let Ok(ad_data) = std::fs::read(&ad_path) {
288            if let Some(parsed) = parse_appledouble(&ad_data) {
289                return Some(parsed);
290            }
291        }
292    }
293
294    // 3. MacBinary — check if the file itself is a MacBinary container.
295    //
296    // Peek the 128-byte header before reading the whole thing. `host_path` is
297    // whatever the user named, and that is not always a regular file:
298    // `rb-cli inspect /dev/disk0` lands here, where an unguarded read pulls the
299    // entire disk into RAM. On a 64-bit host that is "merely" a 238 GiB read;
300    // on 32-bit PowerPC the Vec passes `isize::MAX` and the process aborts with
301    // "capacity overflow" after minutes of I/O. Even for ordinary files the old
302    // form read every byte of a multi-GB image only to reject it — an `inspect`
303    // of a 1 GiB image peaked at 1 GiB RSS.
304    if is_regular_file(host_path) {
305        if let Some(head) = read_header_128(host_path) {
306            if macbinary_header_ok(&head) {
307                if let Ok(mb_data) = std::fs::read(host_path) {
308                    if let Some(parsed) = parse_macbinary(&mb_data) {
309                        return Some(parsed);
310                    }
311                }
312            }
313        }
314    }
315
316    // 3b. AppleSingle: peek only the magic, since a small one is under 128 bytes.
317    if is_regular_file(host_path) {
318        if let Some(magic) = read_magic(host_path) {
319            if magic == APPLESINGLE_MAGIC {
320                if let Ok(bytes) = std::fs::read(host_path) {
321                    if let Some(parsed) = parse_applesingle(&bytes) {
322                        return Some(parsed);
323                    }
324                }
325            }
326        }
327    }
328
329    // 4. BinHex — the file itself is a `.hqx` container.
330    //
331    // Same header peek as MacBinary, for the same reason: never read an
332    // arbitrary path whole just to classify it.
333    if is_regular_file(host_path) {
334        if let Some(head) = read_header_128(host_path) {
335            if looks_like_binhex(&head) {
336                if let Ok(hqx) = std::fs::read(host_path) {
337                    if let Some(parsed) = parse_binhex_fork(&hqx) {
338                        return Some(parsed);
339                    }
340                }
341            }
342        }
343    }
344
345    // 5. Separate .rsrc sidecar
346    let rsrc_path = parent.join(format!("{file_name}.rsrc"));
347    if rsrc_path.is_file() {
348        if let Ok(data) = std::fs::read(&rsrc_path) {
349            if !data.is_empty() {
350                return Some(ImportedResourceFork {
351                    data,
352                    data_fork: None,
353                    type_code: None,
354                    creator_code: None,
355                    name: None,
356                    finder_flags: None,
357                });
358            }
359        }
360    }
361
362    None
363}
364
365/// Cheap gate for the BinHex banner, which `parse_binhex` searches for anyway.
366/// Only the first 128 bytes are inspected, so a header peek decides whether the
367/// whole file is worth reading.
368fn looks_like_binhex(head: &[u8]) -> bool {
369    head.windows(BINHEX_PREFIX.len())
370        .any(|w| w == BINHEX_PREFIX)
371}
372
373/// The stable part of the BinHex 4.0 banner; the version digits after it vary.
374const BINHEX_PREFIX: &[u8] = b"(This file must be converted with BinHex";
375
376/// Decode a BinHex 4.0 (`.hqx`) container into both forks plus Finder info.
377///
378/// Unlike the sidecar formats this is a whole-file wrapper, so `data_fork` and
379/// `name` are both carried and the caller writes those instead of the `.hqx`
380/// text. Returns `None` when the document does not decode or carries neither a
381/// resource fork nor Finder info, so a plain BinHexed text file still imports
382/// as its own contents rather than as an empty-forked stub.
383pub fn parse_binhex_fork(data: &[u8]) -> Option<ImportedResourceFork> {
384    let bh = crate::binhex::parse_binhex(data).ok()?;
385    let type_code = (bh.type_code != [0; 4]).then_some(bh.type_code);
386    let creator_code = (bh.creator_code != [0; 4]).then_some(bh.creator_code);
387    if bh.resource_fork.is_empty() && type_code.is_none() && creator_code.is_none() {
388        return None;
389    }
390    Some(ImportedResourceFork {
391        data: bh.resource_fork,
392        data_fork: Some(bh.data_fork),
393        type_code,
394        creator_code,
395        name: (!bh.name.is_empty()).then_some(bh.name),
396        finder_flags: Some(bh.flags),
397    })
398}
399
400/// Check if a host file is a resource fork sidecar that should be skipped
401/// during import (because it will be consumed as part of its primary file).
402pub fn is_resource_fork_sidecar(host_path: &std::path::Path) -> bool {
403    let file_name = match host_path.file_name().and_then(|n| n.to_str()) {
404        Some(n) => n,
405        None => return false,
406    };
407    let parent = match host_path.parent() {
408        Some(p) => p,
409        None => return false,
410    };
411
412    // AppleDouble sidecar: ._<name> where <name> exists as a regular file
413    if let Some(primary) = file_name.strip_prefix("._") {
414        if parent.join(primary).is_file() {
415            return true;
416        }
417    }
418
419    // Separate .rsrc sidecar: <name>.rsrc where <name> exists as a regular file
420    if let Some(stem) = file_name.strip_suffix(".rsrc") {
421        if !stem.is_empty() && parent.join(stem).is_file() {
422            return true;
423        }
424    }
425
426    false
427}
428
429/// Magic of an AppleDouble sidecar (forks and Finder info, no data fork).
430const APPLEDOUBLE_MAGIC: u32 = 0x00051607;
431/// Magic of an AppleSingle file (the same entry table, plus the data fork and name).
432const APPLESINGLE_MAGIC: u32 = 0x00051600;
433
434/// The entries an AppleDouble / AppleSingle header points at.
435#[derive(Default)]
436struct AppleEntries {
437    data_fork: Option<Vec<u8>>,
438    rsrc: Option<Vec<u8>>,
439    name: Option<String>,
440    type_code: Option<[u8; 4]>,
441    creator_code: Option<[u8; 4]>,
442    finder_flags: Option<u16>,
443}
444
445/// Walk the entry table shared by both formats; `None` when the magic is not `magic`.
446///
447/// Layout: `[0..4]` magic, `[4..8]` version, `[8..24]` filler, `[24..26]` entry count (u16 BE),
448/// then 12-byte descriptors (`id`, `offset`, `length`). Entry 1 is the data fork, 2 the
449/// resource fork, 3 the real name, 9 Finder info (type, creator, fdFlags).
450fn parse_apple_entries(data: &[u8], magic: u32) -> Option<AppleEntries> {
451    if data.len() < 26 || BigEndian::read_u32(&data[0..4]) != magic {
452        return None;
453    }
454    let num_entries = BigEndian::read_u16(&data[24..26]) as usize;
455    let mut out = AppleEntries::default();
456    for i in 0..num_entries {
457        let desc_off = 26 + i * 12;
458        if desc_off + 12 > data.len() {
459            break;
460        }
461        let entry_id = BigEndian::read_u32(&data[desc_off..desc_off + 4]);
462        let offset = BigEndian::read_u32(&data[desc_off + 4..desc_off + 8]) as usize;
463        let length = BigEndian::read_u32(&data[desc_off + 8..desc_off + 12]) as usize;
464        let Some(body) = offset
465            .checked_add(length)
466            .filter(|end| *end <= data.len())
467            .map(|end| &data[offset..end])
468        else {
469            continue;
470        };
471        match entry_id {
472            1 => out.data_fork = Some(body.to_vec()),
473            2 => out.rsrc = Some(body.to_vec()),
474            3 if !body.is_empty() => out.name = Some(crate::hfs::decode_mac_filename(body)),
475            9 if length >= 8 => {
476                let tc: [u8; 4] = body[0..4].try_into().ok()?;
477                let cc: [u8; 4] = body[4..8].try_into().ok()?;
478                out.type_code = (tc != [0; 4]).then_some(tc);
479                out.creator_code = (cc != [0; 4]).then_some(cc);
480                if length >= 10 {
481                    out.finder_flags = Some(BigEndian::read_u16(&body[8..10]));
482                }
483            }
484            _ => {}
485        }
486    }
487    Some(out)
488}
489
490/// Parse an AppleDouble sidecar into its resource fork and Finder info.
491///
492/// `None` unless it carries a resource fork or Finder info: files with type / creator but no
493/// fork (plain text, bookmark files) still need their FInfo to round-trip.
494pub fn parse_appledouble(data: &[u8]) -> Option<ImportedResourceFork> {
495    let e = parse_apple_entries(data, APPLEDOUBLE_MAGIC)?;
496    let rsrc = e.rsrc.unwrap_or_default();
497    if rsrc.is_empty() && e.type_code.is_none() && e.creator_code.is_none() {
498        return None;
499    }
500    Some(ImportedResourceFork {
501        data: rsrc,
502        data_fork: None,
503        type_code: e.type_code,
504        creator_code: e.creator_code,
505        name: None,
506        finder_flags: e.finder_flags,
507    })
508}
509
510/// Parse an AppleSingle file: both forks, the Finder info and the Mac name in one file.
511///
512/// Like MacBinary it is a whole-file wrapper, so `data_fork` is always set (empty when the
513/// file has none) and the caller writes that instead of the wrapper's bytes.
514pub fn parse_applesingle(data: &[u8]) -> Option<ImportedResourceFork> {
515    let e = parse_apple_entries(data, APPLESINGLE_MAGIC)?;
516    if e.data_fork.is_none() && e.rsrc.is_none() && e.type_code.is_none() {
517        return None;
518    }
519    Some(ImportedResourceFork {
520        data: e.rsrc.unwrap_or_default(),
521        data_fork: Some(e.data_fork.unwrap_or_default()),
522        type_code: e.type_code,
523        creator_code: e.creator_code,
524        name: e.name,
525        finder_flags: e.finder_flags,
526    })
527}
528
529/// Whether `path` is a regular file — i.e. something it is safe to read whole.
530/// A device node, FIFO or directory is not: reading one is either unbounded or
531/// meaningless, and [`detect_resource_fork`] is reached with raw device paths
532/// (`rb-cli inspect /dev/disk0`).
533fn is_regular_file(path: &std::path::Path) -> bool {
534    std::fs::metadata(path)
535        .map(|m| m.file_type().is_file())
536        .unwrap_or(false)
537}
538
539/// Read just the 128-byte container header. `None` if the file is shorter than
540/// that or unreadable — either way it cannot be a MacBinary container.
541fn read_header_128(path: &std::path::Path) -> Option<[u8; 128]> {
542    use std::io::Read as _;
543    let mut head = [0u8; 128];
544    let mut f = std::fs::File::open(path).ok()?;
545    f.read_exact(&mut head).ok()?;
546    Some(head)
547}
548
549/// The first four bytes as a big-endian `u32`, or `None` for a shorter file.
550fn read_magic(path: &std::path::Path) -> Option<u32> {
551    use std::io::Read as _;
552    let mut head = [0u8; 4];
553    std::fs::File::open(path).ok()?.read_exact(&mut head).ok()?;
554    Some(u32::from_be_bytes(head))
555}
556
557/// Header-only precondition for [`parse_macbinary`]: every check that function
558/// makes against the first 128 bytes, and nothing else.
559///
560/// Deliberately *not* rusty-backup's `macarchive::macbinary::is_macbinary`, which is a
561/// different predicate — it accepts a MacBinary I on an exact whole-file size
562/// match, which a header alone cannot establish, and it rejects some headers
563/// `parse_macbinary` would take. Gating on it would silently change which files
564/// yield a resource fork. Keep this in step with `parse_macbinary` if that
565/// function's header checks ever change.
566fn macbinary_header_ok(head: &[u8; 128]) -> bool {
567    if head[0] != 0 {
568        return false;
569    }
570    let name_len = head[1] as usize;
571    if name_len == 0 || name_len > 63 {
572        return false;
573    }
574    if head[122] != 129 && head[122] != 130 {
575        return false;
576    }
577    BigEndian::read_u16(&head[124..126]) == macbinary_crc16(&head[0..124])
578}
579
580/// Parse a MacBinary (II/III) file and extract both forks and Finder info.
581///
582/// MacBinary header (128 bytes):
583/// - `[0]` must be 0
584/// - `[1]` filename length (1-63)
585/// - `[65..69]` type code
586/// - `[69..73]` creator code
587/// - `[83..87]` data fork length (u32 BE)
588/// - `[87..91]` resource fork length (u32 BE)
589/// - `[122]` MacBinary version (129=II, 130=III)
590/// - `[124..126]` CRC-16 of bytes 0-123 (MacBinary II+)
591///
592/// Returns `None` if the file doesn't look like a valid MacBinary container.
593pub fn parse_macbinary(data: &[u8]) -> Option<ImportedResourceFork> {
594    if data.len() < 128 {
595        return None;
596    }
597    // Byte 0 must be 0
598    if data[0] != 0 {
599        return None;
600    }
601    // Filename length must be 1-63
602    let name_len = data[1] as usize;
603    if name_len == 0 || name_len > 63 {
604        return None;
605    }
606    // Check version byte — must be MacBinary II (129) or III (130)
607    let version = data[122];
608    if version != 129 && version != 130 {
609        return None;
610    }
611    // Verify CRC
612    let stored_crc = BigEndian::read_u16(&data[124..126]);
613    let computed_crc = macbinary_crc16(&data[0..124]);
614    if stored_crc != computed_crc {
615        return None;
616    }
617
618    let type_code: [u8; 4] = data[65..69].try_into().unwrap();
619    let creator_code: [u8; 4] = data[69..73].try_into().unwrap();
620
621    let data_len = BigEndian::read_u32(&data[83..87]) as usize;
622    let rsrc_len = BigEndian::read_u32(&data[87..91]) as usize;
623
624    let data_start = 128;
625    let data_padded = pad_to_128(data_len);
626    let rsrc_start = data_start + data_padded;
627
628    // A data-fork-only file is still MacBinary: type, creator and the Mac
629    // name ride in the header, and the 128 bytes are not file content.
630    if rsrc_start + rsrc_len > data.len() || data_start + data_len > data.len() {
631        return None;
632    }
633
634    Some(ImportedResourceFork {
635        data: data[rsrc_start..rsrc_start + rsrc_len].to_vec(),
636        data_fork: Some(data[data_start..data_start + data_len].to_vec()),
637        type_code: if type_code != [0; 4] {
638            Some(type_code)
639        } else {
640            None
641        },
642        creator_code: if creator_code != [0; 4] {
643            Some(creator_code)
644        } else {
645            None
646        },
647        name: {
648            let n = crate::hfs::decode_mac_filename(&data[2..2 + name_len]);
649            (!n.is_empty()).then_some(n)
650        },
651        // MacBinary II splits fdFlags: the high byte at 73, the low byte at 101.
652        finder_flags: Some(((data[73] as u16) << 8) | data[101] as u16),
653    })
654}
655
656/// Read the macOS `com.apple.FinderInfo` extended attribute and extract
657/// the 4-byte type and creator codes. Returns `(None, None)` if the xattr
658/// is missing, too short, or both codes are zero.
659#[cfg(target_os = "macos")]
660fn read_finder_info_xattr(path: &std::path::Path) -> (Option<[u8; 4]>, Option<[u8; 4]>) {
661    use std::ffi::CString;
662    use std::os::unix::ffi::OsStrExt;
663
664    let c_path = match CString::new(path.as_os_str().as_bytes()) {
665        Ok(p) => p,
666        Err(_) => return (None, None),
667    };
668    let c_name = match CString::new("com.apple.FinderInfo") {
669        Ok(n) => n,
670        Err(_) => return (None, None),
671    };
672
673    let mut buf = [0u8; 32];
674    // getxattr(path, name, value, size, position, options)
675    let n = unsafe {
676        libc::getxattr(
677            c_path.as_ptr(),
678            c_name.as_ptr(),
679            buf.as_mut_ptr() as *mut libc::c_void,
680            buf.len(),
681            0,
682            0,
683        )
684    };
685    if n < 8 {
686        return (None, None);
687    }
688
689    let mut tc = [0u8; 4];
690    let mut cc = [0u8; 4];
691    tc.copy_from_slice(&buf[0..4]);
692    cc.copy_from_slice(&buf[4..8]);
693
694    let tc = if tc != [0; 4] { Some(tc) } else { None };
695    let cc = if cc != [0; 4] { Some(cc) } else { None };
696    (tc, cc)
697}
698
699/// A Mac file name as the classic containers store it: Mac Roman, at most 63
700/// bytes. One byte per character, so the cut never splits a character.
701pub fn mac_name_bytes(name: &str) -> Vec<u8> {
702    let mut out = Vec::with_capacity(name.len());
703    for ch in name.chars() {
704        let mut one = [0u8; 4];
705        let bytes =
706            crate::hfs::utf8_to_mac_roman(ch.encode_utf8(&mut one)).unwrap_or_else(|_| vec![b'?']);
707        if out.len() + bytes.len() > 63 {
708            break;
709        }
710        out.extend_from_slice(&bytes);
711    }
712    out
713}
714
715#[cfg(test)]
716mod tests {
717    use super::*;
718
719    /// X13: names went out as UTF-8 and were cut at 63 bytes mid-character;
720    /// a classic Mac reads the header as Mac Roman.
721    /// H14: fdFlags rode in both containers and were dropped on import.
722    #[test]
723    fn container_parsers_carry_the_finder_flags() {
724        let mut mb = build_macbinary("Flag", b"APPL", b"ttxt", MacFileDates::default(), b"x", b"");
725        mb[73] = 0x01; // fdFlags high byte
726        mb[101] = 0x40; // fdFlags low byte
727        let crc = macbinary_crc16(&mb[0..124]);
728        BigEndian::write_u16(&mut mb[124..126], crc);
729        assert_eq!(parse_macbinary(&mb).unwrap().finder_flags, Some(0x0140));
730
731        let mut ad = build_appledouble(b"APPL", b"ttxt", MacFileDates::default(), b"RSRC");
732        // First entry descriptor at 26: id, offset, length; Finder Info is first.
733        assert_eq!(BigEndian::read_u32(&ad[26..30]), 9);
734        let off = BigEndian::read_u32(&ad[30..34]) as usize;
735        BigEndian::write_u16(&mut ad[off + 8..off + 10], 0x4000);
736        assert_eq!(parse_appledouble(&ad).unwrap().finder_flags, Some(0x4000));
737    }
738
739    #[test]
740    fn macbinary_names_are_mac_roman_and_cut_on_a_character() {
741        let mb = build_macbinary(
742            "Caf\u{e9}",
743            b"TEXT",
744            b"ttxt",
745            MacFileDates::default(),
746            b"x",
747            b"",
748        );
749        assert_eq!(mb[1], 4);
750        assert_eq!(&mb[2..6], &[b'C', b'a', b'f', 0x8E]);
751        let long = "\u{e9}".repeat(70);
752        let bytes = mac_name_bytes(&long);
753        assert_eq!(bytes.len(), 63);
754        assert!(bytes.iter().all(|&b| b == 0x8E));
755        assert_eq!(mac_name_bytes("\u{65e5}"), b"?");
756    }
757
758    #[test]
759    fn test_sanitize_filename() {
760        assert_eq!(sanitize_filename("hello:world"), "hello_world");
761        assert_eq!(sanitize_filename("file/name"), "file_name");
762        assert_eq!(sanitize_filename("normal.txt"), "normal.txt");
763        assert_eq!(sanitize_filename("a<b>c"), "a_b_c");
764        assert_eq!(sanitize_filename(".."), "_");
765        assert_eq!(sanitize_filename(""), "_");
766    }
767
768    #[test]
769    fn test_build_appledouble_header() {
770        let ad = build_appledouble(b"TEXT", b"ttxt", MacFileDates::default(), &[0xDE, 0xAD]);
771        // Magic
772        assert_eq!(BigEndian::read_u32(&ad[0..4]), 0x00051607);
773        // Version 2
774        assert_eq!(BigEndian::read_u32(&ad[4..8]), 0x00020000);
775        // 2 entries
776        assert_eq!(BigEndian::read_u16(&ad[24..26]), 2);
777        // Finder info entry has type/creator
778        let fi_offset = BigEndian::read_u32(&ad[30..34]) as usize;
779        assert_eq!(&ad[fi_offset..fi_offset + 4], b"TEXT");
780        assert_eq!(&ad[fi_offset + 4..fi_offset + 8], b"ttxt");
781        // Resource fork data at end
782        let rsrc_offset = BigEndian::read_u32(&ad[42..46]) as usize;
783        let rsrc_len = BigEndian::read_u32(&ad[46..50]) as usize;
784        assert_eq!(rsrc_len, 2);
785        assert_eq!(&ad[rsrc_offset..rsrc_offset + 2], &[0xDE, 0xAD]);
786    }
787
788    #[test]
789    fn test_build_macbinary() {
790        let mb = build_macbinary(
791            "test.txt",
792            b"TEXT",
793            b"ttxt",
794            MacFileDates::default(),
795            b"hello",
796            &[1, 2, 3],
797        );
798        // Filename length
799        assert_eq!(mb[1], 8);
800        assert_eq!(&mb[2..10], b"test.txt");
801        // Type/creator
802        assert_eq!(&mb[65..69], b"TEXT");
803        assert_eq!(&mb[69..73], b"ttxt");
804        // Data fork length
805        assert_eq!(BigEndian::read_u32(&mb[83..87]), 5);
806        // Resource fork length
807        assert_eq!(BigEndian::read_u32(&mb[87..91]), 3);
808        // MacBinary III version
809        assert_eq!(mb[122], 130);
810        // Data fork content
811        assert_eq!(&mb[128..133], b"hello");
812        // Resource fork content (after data padded to 128)
813        assert_eq!(&mb[256..259], &[1, 2, 3]);
814    }
815
816    #[test]
817    fn test_macbinary_crc() {
818        // Known test: CRC of all zeros should be 0
819        let zeros = [0u8; 124];
820        assert_eq!(macbinary_crc16(&zeros), 0);
821    }
822
823    #[test]
824    fn test_pad_to_128() {
825        assert_eq!(pad_to_128(0), 0);
826        assert_eq!(pad_to_128(1), 128);
827        assert_eq!(pad_to_128(128), 128);
828        assert_eq!(pad_to_128(129), 256);
829    }
830
831    #[test]
832    fn test_resource_fork_mode_labels() {
833        assert_eq!(
834            ResourceForkMode::AppleDouble.label(),
835            "AppleDouble (._file)"
836        );
837        assert_eq!(ResourceForkMode::DataForkOnly.label(), "Data fork only");
838    }
839
840    #[test]
841    fn test_parse_appledouble_roundtrip() {
842        let rsrc = vec![0xCA, 0xFE, 0xBA, 0xBE];
843        let ad = build_appledouble(b"TEXT", b"ttxt", MacFileDates::default(), &rsrc);
844        let parsed = parse_appledouble(&ad).expect("should parse");
845        assert_eq!(parsed.data, rsrc);
846        assert_eq!(parsed.type_code, Some(*b"TEXT"));
847        assert_eq!(parsed.creator_code, Some(*b"ttxt"));
848        assert!(parsed.data_fork.is_none());
849    }
850
851    #[test]
852    fn test_parse_appledouble_bad_magic() {
853        assert!(parse_appledouble(&[0; 30]).is_none());
854    }
855
856    #[test]
857    fn test_parse_macbinary_roundtrip() {
858        let data_fork = b"hello world";
859        let rsrc = vec![1, 2, 3, 4, 5];
860        let mb = build_macbinary(
861            "test.txt",
862            b"TEXT",
863            b"ttxt",
864            MacFileDates::default(),
865            data_fork,
866            &rsrc,
867        );
868        let parsed = parse_macbinary(&mb).expect("should parse");
869        assert_eq!(parsed.data, rsrc);
870        assert_eq!(parsed.data_fork.as_deref(), Some(data_fork.as_slice()));
871        assert_eq!(parsed.type_code, Some(*b"TEXT"));
872        assert_eq!(parsed.creator_code, Some(*b"ttxt"));
873    }
874
875    #[test]
876    fn test_parse_macbinary_bad_header() {
877        // Too short
878        assert!(parse_macbinary(&[0; 50]).is_none());
879        // Wrong byte 0
880        let mut bad = vec![0; 256];
881        bad[0] = 1;
882        assert!(parse_macbinary(&bad).is_none());
883    }
884
885    #[test]
886    fn test_parse_appledouble_finfo_only() {
887        // Sidecar built for a file with type/creator but no resource fork
888        // (e.g. a classic Mac bookmark or .txt file). Must round-trip.
889        let ad = build_appledouble(b"MOSS", b"sigl", MacFileDates::default(), &[]);
890        let parsed = parse_appledouble(&ad).expect("FInfo-only sidecar should parse");
891        assert!(parsed.data.is_empty());
892        assert_eq!(parsed.type_code, Some(*b"MOSS"));
893        assert_eq!(parsed.creator_code, Some(*b"sigl"));
894    }
895
896    #[test]
897    fn test_parse_appledouble_empty_finder_info_rejected() {
898        // Sidecar with no rsrc and no FInfo carries no information.
899        let ad = build_appledouble(&[0; 4], &[0; 4], MacFileDates::default(), &[]);
900        assert!(parse_appledouble(&ad).is_none());
901    }
902
903    /// Rejecting a data-fork-only MacBinary made `put` copy its 128-byte
904    /// header in as file content under the wrapper's host name.
905    #[test]
906    fn test_parse_macbinary_no_rsrc_still_unwraps() {
907        let mb = build_macbinary(
908            "test.txt",
909            b"TEXT",
910            b"ttxt",
911            MacFileDates::default(),
912            b"data",
913            &[],
914        );
915        let parsed = parse_macbinary(&mb).expect("a MacBinary with an empty resource fork");
916        assert!(parsed.data.is_empty());
917        assert_eq!(parsed.data_fork.as_deref(), Some(b"data".as_slice()));
918        assert_eq!(parsed.type_code, Some(*b"TEXT"));
919        assert_eq!(parsed.name.as_deref(), Some("test.txt"));
920    }
921
922    #[test]
923    fn test_build_macbinary_dates() {
924        let dates = MacFileDates {
925            created: 0x1234_5678,
926            modified: 0x2345_6789,
927        };
928        let mb = build_macbinary("f", b"TEXT", b"ttxt", dates, b"x", &[9]);
929        // Raw Mac-1904 seconds land at header offsets 91 (create) and 95 (modify).
930        assert_eq!(BigEndian::read_u32(&mb[91..95]), 0x1234_5678);
931        assert_eq!(BigEndian::read_u32(&mb[95..99]), 0x2345_6789);
932        // Dates are inside the CRC'd header region, so the container still parses.
933        assert!(parse_macbinary(&mb).is_some());
934    }
935
936    #[test]
937    fn test_build_appledouble_dates() {
938        // `created` maps to 100s after the 2000 epoch; `modified` stays unknown.
939        let created = (MAC_EPOCH_UNIX_OFFSET + APPLEDOUBLE_EPOCH_UNIX + 100) as u32;
940        let dates = MacFileDates {
941            created,
942            modified: 0,
943        };
944        let ad = build_appledouble(b"TEXT", b"ttxt", dates, &[0xAA]);
945        // Real dates add a third entry (Finder Info, File Dates Info, Resource Fork).
946        assert_eq!(BigEndian::read_u16(&ad[24..26]), 3);
947        // Second descriptor is the File Dates Info entry (id 8).
948        assert_eq!(BigEndian::read_u32(&ad[38..42]), 8);
949        let dates_off = BigEndian::read_u32(&ad[42..46]) as usize;
950        assert_eq!(BigEndian::read_i32(&ad[dates_off..dates_off + 4]), 100);
951        // Unknown modify/backup/access all become the sentinel.
952        assert_eq!(
953            BigEndian::read_i32(&ad[dates_off + 4..dates_off + 8]),
954            i32::MIN
955        );
956        // Finder info + resource fork still round-trip through the parser.
957        let parsed = parse_appledouble(&ad).expect("parse");
958        assert_eq!(parsed.type_code, Some(*b"TEXT"));
959        assert_eq!(parsed.data, vec![0xAA]);
960    }
961
962    #[test]
963    fn test_build_appledouble_no_dates_layout_unchanged() {
964        // Default (unknown) dates keep the classic two-entry layout so callers
965        // without timestamps emit byte-identical sidecars.
966        let ad = build_appledouble(b"TEXT", b"ttxt", MacFileDates::default(), &[0xAA]);
967        assert_eq!(BigEndian::read_u16(&ad[24..26]), 2);
968        // Second descriptor is the resource fork (id 2), not a dates entry.
969        assert_eq!(BigEndian::read_u32(&ad[38..42]), 2);
970    }
971
972    /// The 128-byte gate must accept exactly what `parse_macbinary` accepts —
973    /// it exists to avoid reading a whole file to reach the same verdict, not
974    /// to change it.
975    #[test]
976    fn macbinary_header_gate_agrees_with_the_parser() {
977        let mb = build_macbinary(
978            "note.txt",
979            b"TEXT",
980            b"ttxt",
981            MacFileDates::default(),
982            b"data",
983            &[0xAA, 0xBB],
984        );
985        let head: [u8; 128] = mb[..128].try_into().unwrap();
986        assert!(macbinary_header_ok(&head));
987        assert!(parse_macbinary(&mb).is_some());
988
989        // An APM Driver Descriptor Record — byte 0 is 'E' of "ER", the first
990        // thing `rb-cli inspect /dev/disk0` reads. Rejected on the header.
991        let mut ddr = [0u8; 128];
992        ddr[0] = b'E';
993        ddr[1] = b'R';
994        assert!(!macbinary_header_ok(&ddr));
995        assert!(parse_macbinary(&ddr).is_none());
996    }
997
998    /// `detect_resource_fork` probes whether the path *itself* is a MacBinary
999    /// container, and it is reached with paths the user typed — including raw
1000    /// devices (`rb-cli inspect /dev/disk0`). Reading one of those is
1001    /// unbounded: on 32-bit PowerPC it aborts with "capacity overflow" once the
1002    /// Vec passes `isize::MAX`, after minutes of I/O.
1003    ///
1004    /// A FIFO stands in for the device here: before the `is_regular_file` guard
1005    /// this call blocked forever on a reader with no writer.
1006    #[test]
1007    #[cfg(any(target_os = "linux", target_os = "macos"))]
1008    fn non_regular_paths_are_not_read_whole() {
1009        use std::ffi::CString;
1010        use std::os::unix::ffi::OsStrExt;
1011
1012        let dir = tempfile::tempdir().unwrap();
1013        let fifo = dir.path().join("pipe");
1014        let c_path = CString::new(fifo.as_os_str().as_bytes()).unwrap();
1015        assert_eq!(unsafe { libc::mkfifo(c_path.as_ptr(), 0o644) }, 0);
1016
1017        assert!(!is_regular_file(&fifo));
1018        assert!(detect_resource_fork(&fifo).is_none());
1019    }
1020
1021    /// An AppleSingle file the way `macutil` / A/UX write one: data, resource, name, FInfo.
1022    fn applesingle(data: &[u8], rsrc: &[u8], name: &[u8]) -> Vec<u8> {
1023        let mut finfo = [0u8; 16];
1024        finfo[0..4].copy_from_slice(b"TEXT");
1025        finfo[4..8].copy_from_slice(b"ttxt");
1026        finfo[8..10].copy_from_slice(&0x0100u16.to_be_bytes());
1027        let entries: [(u32, &[u8]); 4] = [(1, data), (2, rsrc), (3, name), (9, &finfo)];
1028        let mut out = Vec::new();
1029        out.extend_from_slice(&APPLESINGLE_MAGIC.to_be_bytes());
1030        out.extend_from_slice(&0x0002_0000u32.to_be_bytes());
1031        out.extend_from_slice(&[0u8; 16]);
1032        out.extend_from_slice(&(entries.len() as u16).to_be_bytes());
1033        let mut offset = 26 + 12 * entries.len();
1034        for (id, body) in entries {
1035            out.extend_from_slice(&id.to_be_bytes());
1036            out.extend_from_slice(&(offset as u32).to_be_bytes());
1037            out.extend_from_slice(&(body.len() as u32).to_be_bytes());
1038            offset += body.len();
1039        }
1040        for (_, body) in entries {
1041            out.extend_from_slice(body);
1042        }
1043        out
1044    }
1045
1046    #[test]
1047    fn applesingle_unwraps_to_both_forks_and_its_mac_name() {
1048        let bytes = applesingle(b"hello", b"RSRC", b"Read Me");
1049        let f = parse_applesingle(&bytes).expect("parses");
1050        assert_eq!(f.data_fork.as_deref(), Some(&b"hello"[..]));
1051        assert_eq!(f.data, b"RSRC");
1052        assert_eq!(f.name.as_deref(), Some("Read Me"));
1053        assert_eq!(
1054            (f.type_code, f.creator_code),
1055            (Some(*b"TEXT"), Some(*b"ttxt"))
1056        );
1057        assert_eq!(f.finder_flags, Some(0x0100));
1058        // The sidecar parser does not take a single file, nor the other way round.
1059        assert!(parse_appledouble(&bytes).is_none());
1060        let ad = build_appledouble(b"TEXT", b"ttxt", MacFileDates::default(), b"R");
1061        assert!(parse_applesingle(&ad).is_none());
1062    }
1063
1064    #[test]
1065    fn a_host_applesingle_file_is_detected_as_its_own_wrapper() {
1066        let dir = tempfile::tempdir().unwrap();
1067        let path = dir.path().join("readme.as");
1068        std::fs::write(&path, applesingle(b"hello", b"", b"Read Me")).unwrap();
1069        let f = detect_resource_fork(&path).expect("detected");
1070        assert_eq!(f.data_fork.as_deref(), Some(&b"hello"[..]));
1071        assert_eq!(f.name.as_deref(), Some("Read Me"));
1072        assert!(f.data.is_empty());
1073    }
1074}