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], 0x00051607);
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. **Separate .rsrc**: `<filename>.rsrc` sidecar next to the file
257///
258/// Returns `None` if no resource fork is found.
259pub fn detect_resource_fork(host_path: &std::path::Path) -> Option<ImportedResourceFork> {
260    // 1. Native macOS resource fork
261    #[cfg(target_os = "macos")]
262    {
263        let rsrc_path = host_path.join("..namedfork/rsrc");
264        if let Ok(data) = std::fs::read(&rsrc_path) {
265            if !data.is_empty() {
266                let (type_code, creator_code) = read_finder_info_xattr(host_path);
267                return Some(ImportedResourceFork {
268                    data,
269                    data_fork: None,
270                    type_code,
271                    creator_code,
272                    name: None,
273                    finder_flags: None,
274                });
275            }
276        }
277    }
278
279    let file_name = host_path.file_name()?.to_str()?;
280    let parent = host_path.parent()?;
281
282    // 2. AppleDouble sidecar
283    let ad_path = parent.join(format!("._{file_name}"));
284    if ad_path.is_file() {
285        if let Ok(ad_data) = std::fs::read(&ad_path) {
286            if let Some(parsed) = parse_appledouble(&ad_data) {
287                return Some(parsed);
288            }
289        }
290    }
291
292    // 3. MacBinary — check if the file itself is a MacBinary container.
293    //
294    // Peek the 128-byte header before reading the whole thing. `host_path` is
295    // whatever the user named, and that is not always a regular file:
296    // `rb-cli inspect /dev/disk0` lands here, where an unguarded read pulls the
297    // entire disk into RAM. On a 64-bit host that is "merely" a 238 GiB read;
298    // on 32-bit PowerPC the Vec passes `isize::MAX` and the process aborts with
299    // "capacity overflow" after minutes of I/O. Even for ordinary files the old
300    // form read every byte of a multi-GB image only to reject it — an `inspect`
301    // of a 1 GiB image peaked at 1 GiB RSS.
302    if is_regular_file(host_path) {
303        if let Some(head) = read_header_128(host_path) {
304            if macbinary_header_ok(&head) {
305                if let Ok(mb_data) = std::fs::read(host_path) {
306                    if let Some(parsed) = parse_macbinary(&mb_data) {
307                        return Some(parsed);
308                    }
309                }
310            }
311        }
312    }
313
314    // 4. BinHex — the file itself is a `.hqx` container.
315    //
316    // Same header peek as MacBinary, for the same reason: never read an
317    // arbitrary path whole just to classify it.
318    if is_regular_file(host_path) {
319        if let Some(head) = read_header_128(host_path) {
320            if looks_like_binhex(&head) {
321                if let Ok(hqx) = std::fs::read(host_path) {
322                    if let Some(parsed) = parse_binhex_fork(&hqx) {
323                        return Some(parsed);
324                    }
325                }
326            }
327        }
328    }
329
330    // 5. Separate .rsrc sidecar
331    let rsrc_path = parent.join(format!("{file_name}.rsrc"));
332    if rsrc_path.is_file() {
333        if let Ok(data) = std::fs::read(&rsrc_path) {
334            if !data.is_empty() {
335                return Some(ImportedResourceFork {
336                    data,
337                    data_fork: None,
338                    type_code: None,
339                    creator_code: None,
340                    name: None,
341                    finder_flags: None,
342                });
343            }
344        }
345    }
346
347    None
348}
349
350/// Cheap gate for the BinHex banner, which `parse_binhex` searches for anyway.
351/// Only the first 128 bytes are inspected, so a header peek decides whether the
352/// whole file is worth reading.
353fn looks_like_binhex(head: &[u8]) -> bool {
354    head.windows(BINHEX_PREFIX.len())
355        .any(|w| w == BINHEX_PREFIX)
356}
357
358/// The stable part of the BinHex 4.0 banner; the version digits after it vary.
359const BINHEX_PREFIX: &[u8] = b"(This file must be converted with BinHex";
360
361/// Decode a BinHex 4.0 (`.hqx`) container into both forks plus Finder info.
362///
363/// Unlike the sidecar formats this is a whole-file wrapper, so `data_fork` and
364/// `name` are both carried and the caller writes those instead of the `.hqx`
365/// text. Returns `None` when the document does not decode or carries neither a
366/// resource fork nor Finder info, so a plain BinHexed text file still imports
367/// as its own contents rather than as an empty-forked stub.
368pub fn parse_binhex_fork(data: &[u8]) -> Option<ImportedResourceFork> {
369    let bh = crate::binhex::parse_binhex(data).ok()?;
370    let type_code = (bh.type_code != [0; 4]).then_some(bh.type_code);
371    let creator_code = (bh.creator_code != [0; 4]).then_some(bh.creator_code);
372    if bh.resource_fork.is_empty() && type_code.is_none() && creator_code.is_none() {
373        return None;
374    }
375    Some(ImportedResourceFork {
376        data: bh.resource_fork,
377        data_fork: Some(bh.data_fork),
378        type_code,
379        creator_code,
380        name: (!bh.name.is_empty()).then_some(bh.name),
381        finder_flags: Some(bh.flags),
382    })
383}
384
385/// Check if a host file is a resource fork sidecar that should be skipped
386/// during import (because it will be consumed as part of its primary file).
387pub fn is_resource_fork_sidecar(host_path: &std::path::Path) -> bool {
388    let file_name = match host_path.file_name().and_then(|n| n.to_str()) {
389        Some(n) => n,
390        None => return false,
391    };
392    let parent = match host_path.parent() {
393        Some(p) => p,
394        None => return false,
395    };
396
397    // AppleDouble sidecar: ._<name> where <name> exists as a regular file
398    if let Some(primary) = file_name.strip_prefix("._") {
399        if parent.join(primary).is_file() {
400            return true;
401        }
402    }
403
404    // Separate .rsrc sidecar: <name>.rsrc where <name> exists as a regular file
405    if let Some(stem) = file_name.strip_suffix(".rsrc") {
406        if !stem.is_empty() && parent.join(stem).is_file() {
407            return true;
408        }
409    }
410
411    false
412}
413
414/// Parse an AppleDouble file and extract the resource fork and Finder info.
415///
416/// AppleDouble layout:
417/// - `[0..4]` magic `0x00051607`
418/// - `[4..8]` version `0x00020000`
419/// - `[8..24]` filler (16 bytes)
420/// - `[24..26]` number of entries (u16 BE)
421/// - For each entry: `[+0..4]` entry ID, `[+4..8]` offset, `[+8..12]` length
422///   - Entry ID 2 = resource fork
423///   - Entry ID 9 = Finder info (type/creator at bytes 0-3 / 4-7)
424pub fn parse_appledouble(data: &[u8]) -> Option<ImportedResourceFork> {
425    if data.len() < 26 {
426        return None;
427    }
428    // Check magic
429    if BigEndian::read_u32(&data[0..4]) != 0x00051607 {
430        return None;
431    }
432
433    let num_entries = BigEndian::read_u16(&data[24..26]) as usize;
434    let mut rsrc_data: Option<Vec<u8>> = None;
435    let mut finder_flags: Option<u16> = None;
436    let mut type_code: Option<[u8; 4]> = None;
437    let mut creator_code: Option<[u8; 4]> = None;
438
439    for i in 0..num_entries {
440        let desc_off = 26 + i * 12;
441        if desc_off + 12 > data.len() {
442            break;
443        }
444        let entry_id = BigEndian::read_u32(&data[desc_off..desc_off + 4]);
445        let offset = BigEndian::read_u32(&data[desc_off + 4..desc_off + 8]) as usize;
446        let length = BigEndian::read_u32(&data[desc_off + 8..desc_off + 12]) as usize;
447
448        if offset + length > data.len() {
449            continue;
450        }
451
452        match entry_id {
453            2 => {
454                // Resource fork
455                rsrc_data = Some(data[offset..offset + length].to_vec());
456            }
457            9
458                // Finder info: type at +0, creator at +4, fdFlags at +8
459                if length >= 8 => {
460                    let mut tc = [0u8; 4];
461                    let mut cc = [0u8; 4];
462                    tc.copy_from_slice(&data[offset..offset + 4]);
463                    cc.copy_from_slice(&data[offset + 4..offset + 8]);
464                    if tc != [0; 4] {
465                        type_code = Some(tc);
466                    }
467                    if cc != [0; 4] {
468                        creator_code = Some(cc);
469                    }
470                    if length >= 10 {
471                        finder_flags =
472                            Some(BigEndian::read_u16(&data[offset + 8..offset + 10]));
473                    }
474                }
475            _ => {}
476        }
477    }
478
479    // Accept the sidecar if it carries either a resource fork OR Finder info.
480    // Files with type/creator but no rsrc (e.g. plain text, bookmark files)
481    // still need to round-trip their FInfo.
482    let rsrc = rsrc_data.unwrap_or_default();
483    if rsrc.is_empty() && type_code.is_none() && creator_code.is_none() {
484        return None;
485    }
486
487    Some(ImportedResourceFork {
488        data: rsrc,
489        data_fork: None,
490        type_code,
491        creator_code,
492        name: None,
493        finder_flags,
494    })
495}
496
497/// Whether `path` is a regular file — i.e. something it is safe to read whole.
498/// A device node, FIFO or directory is not: reading one is either unbounded or
499/// meaningless, and [`detect_resource_fork`] is reached with raw device paths
500/// (`rb-cli inspect /dev/disk0`).
501fn is_regular_file(path: &std::path::Path) -> bool {
502    std::fs::metadata(path)
503        .map(|m| m.file_type().is_file())
504        .unwrap_or(false)
505}
506
507/// Read just the 128-byte container header. `None` if the file is shorter than
508/// that or unreadable — either way it cannot be a MacBinary container.
509fn read_header_128(path: &std::path::Path) -> Option<[u8; 128]> {
510    use std::io::Read as _;
511    let mut head = [0u8; 128];
512    let mut f = std::fs::File::open(path).ok()?;
513    f.read_exact(&mut head).ok()?;
514    Some(head)
515}
516
517/// Header-only precondition for [`parse_macbinary`]: every check that function
518/// makes against the first 128 bytes, and nothing else.
519///
520/// Deliberately *not* rusty-backup's `macarchive::macbinary::is_macbinary`, which is a
521/// different predicate — it accepts a MacBinary I on an exact whole-file size
522/// match, which a header alone cannot establish, and it rejects some headers
523/// `parse_macbinary` would take. Gating on it would silently change which files
524/// yield a resource fork. Keep this in step with `parse_macbinary` if that
525/// function's header checks ever change.
526fn macbinary_header_ok(head: &[u8; 128]) -> bool {
527    if head[0] != 0 {
528        return false;
529    }
530    let name_len = head[1] as usize;
531    if name_len == 0 || name_len > 63 {
532        return false;
533    }
534    if head[122] != 129 && head[122] != 130 {
535        return false;
536    }
537    BigEndian::read_u16(&head[124..126]) == macbinary_crc16(&head[0..124])
538}
539
540/// Parse a MacBinary (II/III) file and extract both forks and Finder info.
541///
542/// MacBinary header (128 bytes):
543/// - `[0]` must be 0
544/// - `[1]` filename length (1-63)
545/// - `[65..69]` type code
546/// - `[69..73]` creator code
547/// - `[83..87]` data fork length (u32 BE)
548/// - `[87..91]` resource fork length (u32 BE)
549/// - `[122]` MacBinary version (129=II, 130=III)
550/// - `[124..126]` CRC-16 of bytes 0-123 (MacBinary II+)
551///
552/// Returns `None` if the file doesn't look like a valid MacBinary container.
553pub fn parse_macbinary(data: &[u8]) -> Option<ImportedResourceFork> {
554    if data.len() < 128 {
555        return None;
556    }
557    // Byte 0 must be 0
558    if data[0] != 0 {
559        return None;
560    }
561    // Filename length must be 1-63
562    let name_len = data[1] as usize;
563    if name_len == 0 || name_len > 63 {
564        return None;
565    }
566    // Check version byte — must be MacBinary II (129) or III (130)
567    let version = data[122];
568    if version != 129 && version != 130 {
569        return None;
570    }
571    // Verify CRC
572    let stored_crc = BigEndian::read_u16(&data[124..126]);
573    let computed_crc = macbinary_crc16(&data[0..124]);
574    if stored_crc != computed_crc {
575        return None;
576    }
577
578    let type_code: [u8; 4] = data[65..69].try_into().unwrap();
579    let creator_code: [u8; 4] = data[69..73].try_into().unwrap();
580
581    let data_len = BigEndian::read_u32(&data[83..87]) as usize;
582    let rsrc_len = BigEndian::read_u32(&data[87..91]) as usize;
583
584    let data_start = 128;
585    let data_padded = pad_to_128(data_len);
586    let rsrc_start = data_start + data_padded;
587
588    // A data-fork-only file is still MacBinary: type, creator and the Mac
589    // name ride in the header, and the 128 bytes are not file content.
590    if rsrc_start + rsrc_len > data.len() || data_start + data_len > data.len() {
591        return None;
592    }
593
594    Some(ImportedResourceFork {
595        data: data[rsrc_start..rsrc_start + rsrc_len].to_vec(),
596        data_fork: Some(data[data_start..data_start + data_len].to_vec()),
597        type_code: if type_code != [0; 4] {
598            Some(type_code)
599        } else {
600            None
601        },
602        creator_code: if creator_code != [0; 4] {
603            Some(creator_code)
604        } else {
605            None
606        },
607        name: {
608            let n = crate::hfs::decode_mac_filename(&data[2..2 + name_len]);
609            (!n.is_empty()).then_some(n)
610        },
611        // MacBinary II splits fdFlags: the high byte at 73, the low byte at 101.
612        finder_flags: Some(((data[73] as u16) << 8) | data[101] as u16),
613    })
614}
615
616/// Read the macOS `com.apple.FinderInfo` extended attribute and extract
617/// the 4-byte type and creator codes. Returns `(None, None)` if the xattr
618/// is missing, too short, or both codes are zero.
619#[cfg(target_os = "macos")]
620fn read_finder_info_xattr(path: &std::path::Path) -> (Option<[u8; 4]>, Option<[u8; 4]>) {
621    use std::ffi::CString;
622    use std::os::unix::ffi::OsStrExt;
623
624    let c_path = match CString::new(path.as_os_str().as_bytes()) {
625        Ok(p) => p,
626        Err(_) => return (None, None),
627    };
628    let c_name = match CString::new("com.apple.FinderInfo") {
629        Ok(n) => n,
630        Err(_) => return (None, None),
631    };
632
633    let mut buf = [0u8; 32];
634    // getxattr(path, name, value, size, position, options)
635    let n = unsafe {
636        libc::getxattr(
637            c_path.as_ptr(),
638            c_name.as_ptr(),
639            buf.as_mut_ptr() as *mut libc::c_void,
640            buf.len(),
641            0,
642            0,
643        )
644    };
645    if n < 8 {
646        return (None, None);
647    }
648
649    let mut tc = [0u8; 4];
650    let mut cc = [0u8; 4];
651    tc.copy_from_slice(&buf[0..4]);
652    cc.copy_from_slice(&buf[4..8]);
653
654    let tc = if tc != [0; 4] { Some(tc) } else { None };
655    let cc = if cc != [0; 4] { Some(cc) } else { None };
656    (tc, cc)
657}
658
659/// A Mac file name as the classic containers store it: Mac Roman, at most 63
660/// bytes. One byte per character, so the cut never splits a character.
661pub fn mac_name_bytes(name: &str) -> Vec<u8> {
662    let mut out = Vec::with_capacity(name.len());
663    for ch in name.chars() {
664        let mut one = [0u8; 4];
665        let bytes =
666            crate::hfs::utf8_to_mac_roman(ch.encode_utf8(&mut one)).unwrap_or_else(|_| vec![b'?']);
667        if out.len() + bytes.len() > 63 {
668            break;
669        }
670        out.extend_from_slice(&bytes);
671    }
672    out
673}
674
675#[cfg(test)]
676mod tests {
677    use super::*;
678
679    /// X13: names went out as UTF-8 and were cut at 63 bytes mid-character;
680    /// a classic Mac reads the header as Mac Roman.
681    /// H14: fdFlags rode in both containers and were dropped on import.
682    #[test]
683    fn container_parsers_carry_the_finder_flags() {
684        let mut mb = build_macbinary("Flag", b"APPL", b"ttxt", MacFileDates::default(), b"x", b"");
685        mb[73] = 0x01; // fdFlags high byte
686        mb[101] = 0x40; // fdFlags low byte
687        let crc = macbinary_crc16(&mb[0..124]);
688        BigEndian::write_u16(&mut mb[124..126], crc);
689        assert_eq!(parse_macbinary(&mb).unwrap().finder_flags, Some(0x0140));
690
691        let mut ad = build_appledouble(b"APPL", b"ttxt", MacFileDates::default(), b"RSRC");
692        // First entry descriptor at 26: id, offset, length; Finder Info is first.
693        assert_eq!(BigEndian::read_u32(&ad[26..30]), 9);
694        let off = BigEndian::read_u32(&ad[30..34]) as usize;
695        BigEndian::write_u16(&mut ad[off + 8..off + 10], 0x4000);
696        assert_eq!(parse_appledouble(&ad).unwrap().finder_flags, Some(0x4000));
697    }
698
699    #[test]
700    fn macbinary_names_are_mac_roman_and_cut_on_a_character() {
701        let mb = build_macbinary(
702            "Caf\u{e9}",
703            b"TEXT",
704            b"ttxt",
705            MacFileDates::default(),
706            b"x",
707            b"",
708        );
709        assert_eq!(mb[1], 4);
710        assert_eq!(&mb[2..6], &[b'C', b'a', b'f', 0x8E]);
711        let long = "\u{e9}".repeat(70);
712        let bytes = mac_name_bytes(&long);
713        assert_eq!(bytes.len(), 63);
714        assert!(bytes.iter().all(|&b| b == 0x8E));
715        assert_eq!(mac_name_bytes("\u{65e5}"), b"?");
716    }
717
718    #[test]
719    fn test_sanitize_filename() {
720        assert_eq!(sanitize_filename("hello:world"), "hello_world");
721        assert_eq!(sanitize_filename("file/name"), "file_name");
722        assert_eq!(sanitize_filename("normal.txt"), "normal.txt");
723        assert_eq!(sanitize_filename("a<b>c"), "a_b_c");
724        assert_eq!(sanitize_filename(".."), "_");
725        assert_eq!(sanitize_filename(""), "_");
726    }
727
728    #[test]
729    fn test_build_appledouble_header() {
730        let ad = build_appledouble(b"TEXT", b"ttxt", MacFileDates::default(), &[0xDE, 0xAD]);
731        // Magic
732        assert_eq!(BigEndian::read_u32(&ad[0..4]), 0x00051607);
733        // Version 2
734        assert_eq!(BigEndian::read_u32(&ad[4..8]), 0x00020000);
735        // 2 entries
736        assert_eq!(BigEndian::read_u16(&ad[24..26]), 2);
737        // Finder info entry has type/creator
738        let fi_offset = BigEndian::read_u32(&ad[30..34]) as usize;
739        assert_eq!(&ad[fi_offset..fi_offset + 4], b"TEXT");
740        assert_eq!(&ad[fi_offset + 4..fi_offset + 8], b"ttxt");
741        // Resource fork data at end
742        let rsrc_offset = BigEndian::read_u32(&ad[42..46]) as usize;
743        let rsrc_len = BigEndian::read_u32(&ad[46..50]) as usize;
744        assert_eq!(rsrc_len, 2);
745        assert_eq!(&ad[rsrc_offset..rsrc_offset + 2], &[0xDE, 0xAD]);
746    }
747
748    #[test]
749    fn test_build_macbinary() {
750        let mb = build_macbinary(
751            "test.txt",
752            b"TEXT",
753            b"ttxt",
754            MacFileDates::default(),
755            b"hello",
756            &[1, 2, 3],
757        );
758        // Filename length
759        assert_eq!(mb[1], 8);
760        assert_eq!(&mb[2..10], b"test.txt");
761        // Type/creator
762        assert_eq!(&mb[65..69], b"TEXT");
763        assert_eq!(&mb[69..73], b"ttxt");
764        // Data fork length
765        assert_eq!(BigEndian::read_u32(&mb[83..87]), 5);
766        // Resource fork length
767        assert_eq!(BigEndian::read_u32(&mb[87..91]), 3);
768        // MacBinary III version
769        assert_eq!(mb[122], 130);
770        // Data fork content
771        assert_eq!(&mb[128..133], b"hello");
772        // Resource fork content (after data padded to 128)
773        assert_eq!(&mb[256..259], &[1, 2, 3]);
774    }
775
776    #[test]
777    fn test_macbinary_crc() {
778        // Known test: CRC of all zeros should be 0
779        let zeros = [0u8; 124];
780        assert_eq!(macbinary_crc16(&zeros), 0);
781    }
782
783    #[test]
784    fn test_pad_to_128() {
785        assert_eq!(pad_to_128(0), 0);
786        assert_eq!(pad_to_128(1), 128);
787        assert_eq!(pad_to_128(128), 128);
788        assert_eq!(pad_to_128(129), 256);
789    }
790
791    #[test]
792    fn test_resource_fork_mode_labels() {
793        assert_eq!(
794            ResourceForkMode::AppleDouble.label(),
795            "AppleDouble (._file)"
796        );
797        assert_eq!(ResourceForkMode::DataForkOnly.label(), "Data fork only");
798    }
799
800    #[test]
801    fn test_parse_appledouble_roundtrip() {
802        let rsrc = vec![0xCA, 0xFE, 0xBA, 0xBE];
803        let ad = build_appledouble(b"TEXT", b"ttxt", MacFileDates::default(), &rsrc);
804        let parsed = parse_appledouble(&ad).expect("should parse");
805        assert_eq!(parsed.data, rsrc);
806        assert_eq!(parsed.type_code, Some(*b"TEXT"));
807        assert_eq!(parsed.creator_code, Some(*b"ttxt"));
808        assert!(parsed.data_fork.is_none());
809    }
810
811    #[test]
812    fn test_parse_appledouble_bad_magic() {
813        assert!(parse_appledouble(&[0; 30]).is_none());
814    }
815
816    #[test]
817    fn test_parse_macbinary_roundtrip() {
818        let data_fork = b"hello world";
819        let rsrc = vec![1, 2, 3, 4, 5];
820        let mb = build_macbinary(
821            "test.txt",
822            b"TEXT",
823            b"ttxt",
824            MacFileDates::default(),
825            data_fork,
826            &rsrc,
827        );
828        let parsed = parse_macbinary(&mb).expect("should parse");
829        assert_eq!(parsed.data, rsrc);
830        assert_eq!(parsed.data_fork.as_deref(), Some(data_fork.as_slice()));
831        assert_eq!(parsed.type_code, Some(*b"TEXT"));
832        assert_eq!(parsed.creator_code, Some(*b"ttxt"));
833    }
834
835    #[test]
836    fn test_parse_macbinary_bad_header() {
837        // Too short
838        assert!(parse_macbinary(&[0; 50]).is_none());
839        // Wrong byte 0
840        let mut bad = vec![0; 256];
841        bad[0] = 1;
842        assert!(parse_macbinary(&bad).is_none());
843    }
844
845    #[test]
846    fn test_parse_appledouble_finfo_only() {
847        // Sidecar built for a file with type/creator but no resource fork
848        // (e.g. a classic Mac bookmark or .txt file). Must round-trip.
849        let ad = build_appledouble(b"MOSS", b"sigl", MacFileDates::default(), &[]);
850        let parsed = parse_appledouble(&ad).expect("FInfo-only sidecar should parse");
851        assert!(parsed.data.is_empty());
852        assert_eq!(parsed.type_code, Some(*b"MOSS"));
853        assert_eq!(parsed.creator_code, Some(*b"sigl"));
854    }
855
856    #[test]
857    fn test_parse_appledouble_empty_finder_info_rejected() {
858        // Sidecar with no rsrc and no FInfo carries no information.
859        let ad = build_appledouble(&[0; 4], &[0; 4], MacFileDates::default(), &[]);
860        assert!(parse_appledouble(&ad).is_none());
861    }
862
863    /// Rejecting a data-fork-only MacBinary made `put` copy its 128-byte
864    /// header in as file content under the wrapper's host name.
865    #[test]
866    fn test_parse_macbinary_no_rsrc_still_unwraps() {
867        let mb = build_macbinary(
868            "test.txt",
869            b"TEXT",
870            b"ttxt",
871            MacFileDates::default(),
872            b"data",
873            &[],
874        );
875        let parsed = parse_macbinary(&mb).expect("a MacBinary with an empty resource fork");
876        assert!(parsed.data.is_empty());
877        assert_eq!(parsed.data_fork.as_deref(), Some(b"data".as_slice()));
878        assert_eq!(parsed.type_code, Some(*b"TEXT"));
879        assert_eq!(parsed.name.as_deref(), Some("test.txt"));
880    }
881
882    #[test]
883    fn test_build_macbinary_dates() {
884        let dates = MacFileDates {
885            created: 0x1234_5678,
886            modified: 0x2345_6789,
887        };
888        let mb = build_macbinary("f", b"TEXT", b"ttxt", dates, b"x", &[9]);
889        // Raw Mac-1904 seconds land at header offsets 91 (create) and 95 (modify).
890        assert_eq!(BigEndian::read_u32(&mb[91..95]), 0x1234_5678);
891        assert_eq!(BigEndian::read_u32(&mb[95..99]), 0x2345_6789);
892        // Dates are inside the CRC'd header region, so the container still parses.
893        assert!(parse_macbinary(&mb).is_some());
894    }
895
896    #[test]
897    fn test_build_appledouble_dates() {
898        // `created` maps to 100s after the 2000 epoch; `modified` stays unknown.
899        let created = (MAC_EPOCH_UNIX_OFFSET + APPLEDOUBLE_EPOCH_UNIX + 100) as u32;
900        let dates = MacFileDates {
901            created,
902            modified: 0,
903        };
904        let ad = build_appledouble(b"TEXT", b"ttxt", dates, &[0xAA]);
905        // Real dates add a third entry (Finder Info, File Dates Info, Resource Fork).
906        assert_eq!(BigEndian::read_u16(&ad[24..26]), 3);
907        // Second descriptor is the File Dates Info entry (id 8).
908        assert_eq!(BigEndian::read_u32(&ad[38..42]), 8);
909        let dates_off = BigEndian::read_u32(&ad[42..46]) as usize;
910        assert_eq!(BigEndian::read_i32(&ad[dates_off..dates_off + 4]), 100);
911        // Unknown modify/backup/access all become the sentinel.
912        assert_eq!(
913            BigEndian::read_i32(&ad[dates_off + 4..dates_off + 8]),
914            i32::MIN
915        );
916        // Finder info + resource fork still round-trip through the parser.
917        let parsed = parse_appledouble(&ad).expect("parse");
918        assert_eq!(parsed.type_code, Some(*b"TEXT"));
919        assert_eq!(parsed.data, vec![0xAA]);
920    }
921
922    #[test]
923    fn test_build_appledouble_no_dates_layout_unchanged() {
924        // Default (unknown) dates keep the classic two-entry layout so callers
925        // without timestamps emit byte-identical sidecars.
926        let ad = build_appledouble(b"TEXT", b"ttxt", MacFileDates::default(), &[0xAA]);
927        assert_eq!(BigEndian::read_u16(&ad[24..26]), 2);
928        // Second descriptor is the resource fork (id 2), not a dates entry.
929        assert_eq!(BigEndian::read_u32(&ad[38..42]), 2);
930    }
931
932    /// The 128-byte gate must accept exactly what `parse_macbinary` accepts —
933    /// it exists to avoid reading a whole file to reach the same verdict, not
934    /// to change it.
935    #[test]
936    fn macbinary_header_gate_agrees_with_the_parser() {
937        let mb = build_macbinary(
938            "note.txt",
939            b"TEXT",
940            b"ttxt",
941            MacFileDates::default(),
942            b"data",
943            &[0xAA, 0xBB],
944        );
945        let head: [u8; 128] = mb[..128].try_into().unwrap();
946        assert!(macbinary_header_ok(&head));
947        assert!(parse_macbinary(&mb).is_some());
948
949        // An APM Driver Descriptor Record — byte 0 is 'E' of "ER", the first
950        // thing `rb-cli inspect /dev/disk0` reads. Rejected on the header.
951        let mut ddr = [0u8; 128];
952        ddr[0] = b'E';
953        ddr[1] = b'R';
954        assert!(!macbinary_header_ok(&ddr));
955        assert!(parse_macbinary(&ddr).is_none());
956    }
957
958    /// `detect_resource_fork` probes whether the path *itself* is a MacBinary
959    /// container, and it is reached with paths the user typed — including raw
960    /// devices (`rb-cli inspect /dev/disk0`). Reading one of those is
961    /// unbounded: on 32-bit PowerPC it aborts with "capacity overflow" once the
962    /// Vec passes `isize::MAX`, after minutes of I/O.
963    ///
964    /// A FIFO stands in for the device here: before the `is_regular_file` guard
965    /// this call blocked forever on a reader with no writer.
966    #[test]
967    #[cfg(any(target_os = "linux", target_os = "macos"))]
968    fn non_regular_paths_are_not_read_whole() {
969        use std::ffi::CString;
970        use std::os::unix::ffi::OsStrExt;
971
972        let dir = tempfile::tempdir().unwrap();
973        let fifo = dir.path().join("pipe");
974        let c_path = CString::new(fifo.as_os_str().as_bytes()).unwrap();
975        assert_eq!(unsafe { libc::mkfifo(c_path.as_ptr(), 0o644) }, 0);
976
977        assert!(!is_regular_file(&fifo));
978        assert!(detect_resource_fork(&fifo).is_none());
979    }
980}