Skip to main content

arc_mac/
extract.rs

1// SPDX-License-Identifier: MIT
2//! Shared archive open + extraction logic used by both the CLI (`sit` verb)
3//! and the GUI archive-browse tab.
4
5use anyhow::{bail, Context, Result};
6use std::collections::HashSet;
7use std::path::{Path, PathBuf};
8
9use fs_mac::binhex;
10use fs_mac::resource_fork::{self, sanitize_filename};
11
12use super::stuffit::{self, StuffItArchive, StuffItEntry};
13use super::stuffit5;
14
15/// Container format for an extracted file's forks.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum ForkFormat {
18    /// One `.hqx` per file (both forks + Finder info).
19    BinHex,
20    /// One `.bin` MacBinary III per file.
21    MacBinary,
22    /// Data fork + `._name` AppleDouble sidecar.
23    AppleDouble,
24    /// Data fork + `name.rsrc` sidecar (resource fork only).
25    Raw,
26}
27
28impl ForkFormat {
29    pub const ALL: [ForkFormat; 4] = [
30        ForkFormat::BinHex,
31        ForkFormat::MacBinary,
32        ForkFormat::AppleDouble,
33        ForkFormat::Raw,
34    ];
35
36    pub fn label(&self) -> &'static str {
37        match self {
38            ForkFormat::BinHex => "BinHex (.hqx)",
39            ForkFormat::MacBinary => "MacBinary (.bin)",
40            ForkFormat::AppleDouble => "AppleDouble (._name)",
41            ForkFormat::Raw => "Raw + .rsrc",
42        }
43    }
44}
45
46/// Result of an extraction run.
47#[derive(Debug, Default, Clone, Copy)]
48pub struct ExtractStats {
49    pub files: usize,
50    pub skipped: usize,
51    /// Number of entry names that contained a character illegal in a host path
52    /// component (most often `/`, which is legal in a Mac filename) and were
53    /// rewritten by [`sanitize_filename`] during extraction.
54    pub names_sanitized: usize,
55}
56
57/// Read an archive file and parse it, transparently BinHex-decoding a
58/// `.sit.hqx` wrapper and routing classic StuffIt (`SIT!`), StuffIt 5, and
59/// `.sea` to the right parser. Returns the decoded archive bytes (offsets in
60/// the entries are relative to these) plus the parsed directory.
61pub fn open(path: &Path) -> Result<(Vec<u8>, StuffItArchive)> {
62    let raw = std::fs::read(path).with_context(|| format!("reading {}", path.display()))?;
63    open_bytes(raw)
64}
65
66/// Like [`open`] but from in-memory bytes (already-read file contents).
67/// Routes through [`super::detect::detect_mac_archive`] so detection is
68/// magic-driven rather than extension-driven: a loose `.hqx` carrying one
69/// Mac file is synthesized into a single-entry archive so the rest of the
70/// extract / browse path needs no special case.
71pub fn open_bytes(raw: Vec<u8>) -> Result<(Vec<u8>, StuffItArchive)> {
72    use super::detect::{detect_mac_archive, MacArchiveKind};
73
74    match detect_mac_archive(&raw) {
75        Some(MacArchiveKind::BinHexSingleFile) => {
76            // BinHex carrying one Mac file with no SIT/SEA inside.
77            // Synthesize a single-entry StuffIt archive that points at
78            // the decoded forks (method = 0 / store, fork bytes laid
79            // out back-to-back in the returned buffer).
80            let bh = binhex::parse_binhex(&raw)?;
81            Ok(synth_single_file_archive(bh))
82        }
83        Some(MacArchiveKind::BinHexOverSit) | Some(MacArchiveKind::BinHexOverSea) => {
84            // Peel the HQX, then parse the inner SIT / SIT5 / SEA.
85            let bh = binhex::parse_binhex(&raw)?;
86            parse_sit_family(bh.data_fork)
87        }
88        Some(MacArchiveKind::BinHexOverCompactPro) => {
89            // Peel the HQX, then parse the inner Compact Pro archive.
90            let bh = binhex::parse_binhex(&raw)?;
91            let archive = super::compactpro::parse(&bh.data_fork)?;
92            Ok((bh.data_fork, archive))
93        }
94        Some(MacArchiveKind::CompactPro) => {
95            let archive = super::compactpro::parse(&raw)?;
96            Ok((raw, archive))
97        }
98        Some(MacArchiveKind::Mar) => super::mar::parse(&raw),
99        Some(MacArchiveKind::MacZip) => super::maczip::parse(&raw),
100        Some(MacArchiveKind::MacBinary) => {
101            let mb = super::macbinary::parse(&raw)?;
102            // Peel: a MacBinary's data fork is frequently itself an archive we
103            // can go deeper on (e.g. a MacBinary III wrapping a StuffIt 5
104            // archive). Mirror the BinHex-over-SIT behavior so the entries the
105            // user sees are the inner archive's, not one opaque blob.
106            match detect_mac_archive(&mb.data_fork) {
107                Some(MacArchiveKind::Sit)
108                | Some(MacArchiveKind::Sit5)
109                | Some(MacArchiveKind::Sea) => parse_sit_family(mb.data_fork),
110                Some(MacArchiveKind::CompactPro) => {
111                    let archive = super::compactpro::parse(&mb.data_fork)?;
112                    Ok((mb.data_fork, archive))
113                }
114                // Plain Mac file (app, document, wrapped disk image): one entry.
115                // A wrapped disk image is surfaced via detect_mountable_image on
116                // the single entry's data fork, exactly like other archives.
117                _ => Ok(synth_from_macbinary(mb)),
118            }
119        }
120        Some(MacArchiveKind::Sit) | Some(MacArchiveKind::Sit5) | Some(MacArchiveKind::Sea) => {
121            parse_sit_family(raw)
122        }
123        None => {
124            if is_stuffitx(&raw) {
125                bail!(
126                    "StuffIt X (.sitx) recognized, but native extraction is not yet \
127                     implemented (its catalog + data streams use the Brimstone / \
128                     PPMd-variant-G codec). Use `unar` for now."
129                );
130            }
131            bail!("not a recognized Mac archive (BinHex / StuffIt / SEA)");
132        }
133    }
134}
135
136/// Pick the right SIT/SIT5/SEA parser for already-unwrapped bytes.
137fn parse_sit_family(bytes: Vec<u8>) -> Result<(Vec<u8>, StuffItArchive)> {
138    let archive = if stuffit5::is_stuffit5(&bytes) {
139        stuffit5::parse(&bytes)?
140    } else if stuffit::find_sea_archive(&bytes).is_some() {
141        stuffit::parse(&bytes)?
142    } else {
143        bail!("not a recognized StuffIt archive (classic SIT! / StuffIt 5)");
144    };
145    Ok((bytes, archive))
146}
147
148/// Manufacture a one-entry `StuffItArchive` from a BinHex file that
149/// doesn't wrap a SIT/SEA. The returned bytes hold the data fork
150/// followed by the resource fork; the entry's `ForkInfo` uses
151/// method = 0 (store) so [`stuffit::decompress_fork`] returns them
152/// verbatim. Keeps the Archives tab / Extract All path unchanged.
153fn synth_single_file_archive(bh: binhex::BinHexFile) -> (Vec<u8>, StuffItArchive) {
154    synth_single_entry(
155        bh.name,
156        bh.type_code,
157        bh.creator_code,
158        bh.flags,
159        0,
160        0,
161        &bh.data_fork,
162        &bh.resource_fork,
163    )
164}
165
166/// Manufacture a one-entry archive from a parsed MacBinary file. MacBinary
167/// holds exactly one Mac file's two forks plus Finder info, so it slots into
168/// the family the same way a single-file BinHex does — but it also carries
169/// real create/modify dates, which are preserved here.
170fn synth_from_macbinary(mb: super::macbinary::MacBinaryFile) -> (Vec<u8>, StuffItArchive) {
171    synth_single_entry(
172        mb.filename,
173        mb.type_code,
174        mb.creator_code,
175        mb.finder_flags,
176        mb.create_date,
177        mb.modify_date,
178        &mb.data_fork,
179        &mb.resource_fork,
180    )
181}
182
183/// Shared single-entry archive builder: lays the data fork then the resource
184/// fork back-to-back in the returned buffer and points a method-0 (store)
185/// entry at them, so [`stuffit::decompress_fork`] returns each verbatim. Used
186/// by the loose-BinHex and MacBinary single-file paths.
187#[allow(clippy::too_many_arguments)]
188fn synth_single_entry(
189    name: String,
190    type_code: [u8; 4],
191    creator_code: [u8; 4],
192    finder_flags: u16,
193    create_date: u32,
194    mod_date: u32,
195    data_fork: &[u8],
196    resource_fork: &[u8],
197) -> (Vec<u8>, StuffItArchive) {
198    let data_len = data_fork.len() as u32;
199    let rsrc_len = resource_fork.len() as u32;
200    let data_crc = stuffit::crc16_arc(data_fork);
201    let rsrc_crc = stuffit::crc16_arc(resource_fork);
202    let mut buf = Vec::with_capacity(data_fork.len() + resource_fork.len());
203    buf.extend_from_slice(data_fork);
204    buf.extend_from_slice(resource_fork);
205    let entry = stuffit::StuffItEntry {
206        path: vec![name.clone()],
207        name,
208        is_dir: false,
209        type_code,
210        creator_code,
211        finder_flags,
212        create_date,
213        mod_date,
214        data: Some(stuffit::ForkInfo {
215            method: 0,
216            codec: stuffit::ForkCodec::StuffIt,
217            encrypted: false,
218            uncompressed_len: data_len,
219            compressed_len: data_len,
220            crc: data_crc,
221            crc32: 0,
222            offset: 0,
223        }),
224        rsrc: if rsrc_len > 0 {
225            Some(stuffit::ForkInfo {
226                method: 0,
227                codec: stuffit::ForkCodec::StuffIt,
228                encrypted: false,
229                uncompressed_len: rsrc_len,
230                compressed_len: rsrc_len,
231                crc: rsrc_crc,
232                crc32: 0,
233                offset: data_len as u64,
234            })
235        } else {
236            None
237        },
238    };
239    (
240        buf,
241        StuffItArchive {
242            entries: vec![entry],
243            truncated: None,
244            damage: Vec::new(),
245        },
246    )
247}
248
249/// Detect the StuffIt X container ("StuffIt!" / "StuffIt?"). Distinct from
250/// StuffIt 5, whose 8th byte is a space ("StuffIt (c)1997…").
251pub fn is_stuffitx(data: &[u8]) -> bool {
252    data.len() >= 8 && &data[..7] == b"StuffIt" && (data[7] == b'!' || data[7] == b'?')
253}
254
255/// Extract every file in `archive` to `dest`, rebuilding the directory tree.
256/// `progress(done, total, name)` is called before each file entry.
257pub fn extract_all(
258    bytes: &[u8],
259    archive: &StuffItArchive,
260    dest: &Path,
261    format: ForkFormat,
262    progress: impl FnMut(usize, usize, &str),
263    log: impl FnMut(String),
264) -> Result<ExtractStats> {
265    extract_filtered(bytes, archive, dest, format, |_| true, progress, log)
266}
267
268/// Like [`extract_all`] but only writes entries for which `keep(idx)`
269/// returns true (where `idx` indexes into `archive.entries`). Folder
270/// markers are always written so the rebuilt tree is rooted correctly;
271/// file entries the predicate rejects increment neither `files` nor
272/// `skipped` and don't show up in the progress callback. Used by the
273/// GUI Archives tab's "Extract Selected..." button (Workflow D.3).
274pub fn extract_filtered(
275    bytes: &[u8],
276    archive: &StuffItArchive,
277    dest: &Path,
278    format: ForkFormat,
279    mut keep: impl FnMut(usize) -> bool,
280    mut progress: impl FnMut(usize, usize, &str),
281    mut log: impl FnMut(String),
282) -> Result<ExtractStats> {
283    // Say this once, before the per-entry skips: a truncated archive makes every
284    // entry past the cut fail, and the per-fork error alone reads like a decoder
285    // bug rather than a missing-disk problem.
286    if let Some(t) = archive.truncated {
287        log(t.describe());
288    }
289    for d in &archive.damage {
290        log(d.describe());
291    }
292
293    std::fs::create_dir_all(dest).with_context(|| format!("creating {}", dest.display()))?;
294
295    let total = archive
296        .entries
297        .iter()
298        .enumerate()
299        .filter(|(i, e)| !e.is_dir && keep(*i))
300        .count();
301
302    // Raw format only: an entry's resource fork is written to a `name.rsrc`
303    // sidecar, which collides when a *sibling* entry is literally named
304    // `name.rsrc` (e.g. classic THINK projects ship `Foo.pi` alongside a
305    // `Foo.pi.rsrc` resource file). Reserve every data-fork destination up
306    // front so sidecars dodge them — data-fork names always keep their
307    // natural path, sidecars yield to a non-colliding `.rsrc.N` variant.
308    let reserved_data_paths: HashSet<PathBuf> = if format == ForkFormat::Raw {
309        archive
310            .entries
311            .iter()
312            .enumerate()
313            .filter(|(i, e)| !e.is_dir && keep(*i))
314            .map(|(_, e)| entry_target(dest, e))
315            .collect()
316    } else {
317        HashSet::new()
318    };
319    let mut used_sidecars: HashSet<PathBuf> = HashSet::new();
320
321    let mut stats = ExtractStats::default();
322    let mut done = 0usize;
323
324    for (idx, e) in archive.entries.iter().enumerate() {
325        let target = entry_target(dest, e);
326
327        if e.is_dir {
328            // Always create directory markers — a subsequent kept file
329            // might land underneath them.
330            if let Some(s) = sanitized_if_changed(&e.name) {
331                log(format!(
332                    "Warning: \"{}\" -> \"{}\" (character invalid on this filesystem)",
333                    e.name, s
334                ));
335                stats.names_sanitized += 1;
336            }
337            std::fs::create_dir_all(&target)?;
338            continue;
339        }
340        if !keep(idx) {
341            continue;
342        }
343        if let Some(s) = sanitized_if_changed(&e.name) {
344            log(format!(
345                "Warning: \"{}\" -> \"{}\" (character invalid on this filesystem)",
346                e.name, s
347            ));
348            stats.names_sanitized += 1;
349        }
350        progress(done, total, &e.name);
351        done += 1;
352        if let Some(parent) = target.parent() {
353            std::fs::create_dir_all(parent)?;
354        }
355
356        let data = match decompress_named(bytes, e.data.as_ref(), e, &mut log) {
357            Ok(d) => d,
358            Err(()) => {
359                stats.skipped += 1;
360                continue;
361            }
362        };
363        let rsrc = match decompress_named(bytes, e.rsrc.as_ref(), e, &mut log) {
364            Ok(d) => d,
365            Err(()) => {
366                stats.skipped += 1;
367                continue;
368            }
369        };
370
371        // Compact Pro stores one CRC-32 per file over (resource fork ++ data
372        // fork); verify it now that both forks are decompressed. This is the
373        // format's only integrity check, so it covers single-fork, dual-fork,
374        // and empty entries alike.
375        if let Some(expected) = compactpro_entry_crc(e) {
376            if let Err(err) = super::compactpro::verify_entry_crc(&rsrc, &data, expected) {
377                log(format!("Skipped {}: {err}", e.display_path()));
378                stats.skipped += 1;
379                continue;
380            }
381        }
382
383        // Resolve the raw resource-fork sidecar path, dodging any sibling
384        // data fork that already owns `name.rsrc`. Other formats prefix the
385        // sidecar (`._name`) or change its extension (`.hqx` / `.bin`), so
386        // they never collide and pass `None`.
387        let raw_rsrc_path = if format == ForkFormat::Raw && !rsrc.is_empty() {
388            let natural = with_added_extension(&target, "rsrc");
389            let chosen = raw_rsrc_sidecar_path(&target, &reserved_data_paths, &mut used_sidecars);
390            if chosen != natural {
391                log(format!(
392                    "Note: resource fork of '{}' written as '{}' to avoid overwriting a sibling file named '{}'",
393                    e.display_path(),
394                    chosen.file_name().unwrap_or_default().to_string_lossy(),
395                    natural.file_name().unwrap_or_default().to_string_lossy(),
396                ));
397            }
398            Some(chosen)
399        } else {
400            None
401        };
402
403        write_entry(&target, e, &data, &rsrc, format, raw_rsrc_path.as_deref())?;
404        stats.files += 1;
405    }
406
407    if stats.names_sanitized > 0 {
408        log(format!(
409            "{} name(s) contain characters this filesystem can't store and were changed. \
410             To keep the original names, leave the archive compressed, or extract into an \
411             HFS/HFS+ disk image where these characters are legal.",
412            stats.names_sanitized
413        ));
414    }
415
416    Ok(stats)
417}
418
419/// The per-file CRC-32 for a Compact Pro entry (carried identically on both
420/// forks), or `None` for StuffIt entries which checksum each fork on decode.
421fn compactpro_entry_crc(e: &StuffItEntry) -> Option<u32> {
422    [e.rsrc.as_ref(), e.data.as_ref()]
423        .into_iter()
424        .flatten()
425        .find(|f| matches!(f.codec, stuffit::ForkCodec::CompactPro { .. }))
426        .map(|f| f.crc32)
427}
428
429fn decompress_named(
430    bytes: &[u8],
431    fork: Option<&stuffit::ForkInfo>,
432    e: &StuffItEntry,
433    log: &mut impl FnMut(String),
434) -> Result<Vec<u8>, ()> {
435    match fork {
436        Some(f) if f.uncompressed_len > 0 => {
437            let result = match f.codec {
438                stuffit::ForkCodec::StuffIt => stuffit::decompress_fork(bytes, f),
439                stuffit::ForkCodec::CompactPro { .. } => {
440                    super::compactpro::decompress_fork(bytes, f)
441                }
442            };
443            match result {
444                Ok(d) => Ok(d),
445                Err(err) => {
446                    log(format!("Skipped {}: {err}", e.display_path()));
447                    Err(())
448                }
449            }
450        }
451        _ => Ok(Vec::new()),
452    }
453}
454
455/// Write one extracted file's forks to the host in the chosen container format.
456///
457/// `raw_rsrc_path` overrides where a [`ForkFormat::Raw`] resource sidecar is
458/// written; the orchestrator passes a collision-free path here (see
459/// [`raw_rsrc_sidecar_path`]). It is ignored for every other format, and a
460/// `None` falls back to the natural `name.rsrc` next to `target`.
461pub fn write_entry(
462    target: &Path,
463    entry: &StuffItEntry,
464    data: &[u8],
465    rsrc: &[u8],
466    format: ForkFormat,
467    raw_rsrc_path: Option<&Path>,
468) -> Result<()> {
469    match format {
470        ForkFormat::BinHex => {
471            let bh = binhex::BinHexFile {
472                name: entry.name.clone(),
473                type_code: entry.type_code,
474                creator_code: entry.creator_code,
475                flags: entry.finder_flags,
476                data_fork: data.to_vec(),
477                resource_fork: rsrc.to_vec(),
478            };
479            std::fs::write(
480                with_added_extension(target, "hqx"),
481                binhex::build_binhex(&bh).as_bytes(),
482            )?;
483        }
484        ForkFormat::MacBinary => {
485            let mb = resource_fork::build_macbinary(
486                &entry.name,
487                &entry.type_code,
488                &entry.creator_code,
489                resource_fork::MacFileDates::default(),
490                data,
491                rsrc,
492            );
493            std::fs::write(with_added_extension(target, "bin"), mb)?;
494        }
495        ForkFormat::AppleDouble => {
496            std::fs::write(target, data)?;
497            if !rsrc.is_empty() || entry.type_code != [0; 4] || entry.creator_code != [0; 4] {
498                let ad = resource_fork::build_appledouble(
499                    &entry.type_code,
500                    &entry.creator_code,
501                    resource_fork::MacFileDates::default(),
502                    rsrc,
503                );
504                std::fs::write(sidecar_path(target, "._"), ad)?;
505            }
506        }
507        ForkFormat::Raw => {
508            std::fs::write(target, data)?;
509            if !rsrc.is_empty() {
510                let rsrc_dst = raw_rsrc_path
511                    .map(Path::to_path_buf)
512                    .unwrap_or_else(|| with_added_extension(target, "rsrc"));
513                std::fs::write(rsrc_dst, rsrc)?;
514            }
515        }
516    }
517    Ok(())
518}
519
520/// Destination path for an entry's data fork: `dest` joined with the entry's
521/// path components, each [`sanitize_filename`]d. Shared by the extraction loop
522/// and the up-front data-path reservation so both agree on every name.
523/// If `name` contains a character illegal in a host path component, returns its
524/// sanitized form; otherwise `None`. Lets the extractor warn the user that the
525/// on-disk name differs from the Mac original (e.g. a Mac filename with a `/`).
526fn sanitized_if_changed(name: &str) -> Option<String> {
527    let s = sanitize_filename(name);
528    (s != name).then_some(s)
529}
530
531fn entry_target(dest: &Path, entry: &StuffItEntry) -> PathBuf {
532    let mut rel = PathBuf::new();
533    for comp in &entry.path {
534        rel.push(sanitize_filename(comp));
535    }
536    dest.join(rel)
537}
538
539/// Pick a raw resource-fork sidecar path that won't clobber any data fork.
540/// The natural name is `<target>.rsrc`; if a different entry's data fork
541/// already owns that path (it's in `reserved`) or a previous sidecar took it
542/// (`used`), fall back to `<target>.rsrc.1`, `.2`, ... until free. Records the
543/// chosen path in `used` so two sidecars can't collide either.
544fn raw_rsrc_sidecar_path(
545    target: &Path,
546    reserved: &HashSet<PathBuf>,
547    used: &mut HashSet<PathBuf>,
548) -> PathBuf {
549    let base = with_added_extension(target, "rsrc");
550    let mut candidate = base.clone();
551    let mut n = 1u32;
552    while reserved.contains(&candidate) || used.contains(&candidate) {
553        candidate = with_added_extension(&base, &n.to_string());
554        n += 1;
555    }
556    used.insert(candidate.clone());
557    candidate
558}
559
560/// Append `.ext` to a path (keeping any existing extension).
561fn with_added_extension(path: &Path, ext: &str) -> PathBuf {
562    let mut name = path.file_name().unwrap_or_default().to_os_string();
563    name.push(".");
564    name.push(ext);
565    path.with_file_name(name)
566}
567
568/// Build a `._name`-style sidecar path next to `target`.
569fn sidecar_path(target: &Path, prefix: &str) -> PathBuf {
570    let name = target.file_name().unwrap_or_default().to_string_lossy();
571    target.with_file_name(format!("{prefix}{name}"))
572}
573
574#[cfg(test)]
575mod tests {
576    use super::*;
577
578    /// `open_bytes` on a loose `.hqx` (BinHex around plain Mac file
579    /// bytes, no SIT inside) synthesizes a one-entry archive that
580    /// decompresses to the original forks. Proves the Workflow D.1
581    /// "accept loose .hqx" path.
582    #[test]
583    fn open_bytes_synthesizes_single_entry_archive_for_loose_binhex() {
584        let bh = binhex::BinHexFile {
585            name: "Document.txt".into(),
586            type_code: *b"TEXT",
587            creator_code: *b"ttxt",
588            flags: 0x0042,
589            data_fork: b"hello mac world".to_vec(),
590            resource_fork: b"RSRC".to_vec(),
591        };
592        let hqx = binhex::build_binhex(&bh).into_bytes();
593
594        let (buf, archive) = open_bytes(hqx).expect("loose hqx should open");
595        assert_eq!(archive.entries.len(), 1, "synthesized archive is one entry");
596        let entry = &archive.entries[0];
597        assert_eq!(entry.name, "Document.txt");
598        assert_eq!(entry.type_code, *b"TEXT");
599        assert_eq!(entry.creator_code, *b"ttxt");
600        assert_eq!(entry.finder_flags, 0x0042);
601        assert!(!entry.is_dir);
602
603        let data = stuffit::decompress_fork(&buf, entry.data.as_ref().unwrap())
604            .expect("data fork round-trips through method=0");
605        assert_eq!(data, b"hello mac world");
606
607        let rsrc = stuffit::decompress_fork(&buf, entry.rsrc.as_ref().unwrap())
608            .expect("rsrc fork round-trips through method=0");
609        assert_eq!(rsrc, b"RSRC");
610    }
611
612    /// A loose `.hqx` with an empty resource fork should synthesize an
613    /// entry with `rsrc = None` (matching how real Mac files without
614    /// resource forks present in StuffIt archives).
615    #[test]
616    fn open_bytes_empty_rsrc_omits_fork() {
617        let bh = binhex::BinHexFile {
618            name: "data.bin".into(),
619            type_code: [0; 4],
620            creator_code: [0; 4],
621            flags: 0,
622            data_fork: vec![1, 2, 3, 4],
623            resource_fork: Vec::new(),
624        };
625        let hqx = binhex::build_binhex(&bh).into_bytes();
626        let (_buf, archive) = open_bytes(hqx).expect("open");
627        assert!(
628            archive.entries[0].rsrc.is_none(),
629            "empty rsrc folds to None"
630        );
631        assert!(archive.entries[0].data.is_some(), "data fork still present");
632    }
633
634    /// `extract_filtered` writes only the entries the predicate keeps;
635    /// folder markers always materialize so subsequent kept files have
636    /// somewhere to land. Proves the Workflow D.3 "Extract Selected"
637    /// data path.
638    #[test]
639    fn extract_filtered_writes_only_kept_entries() {
640        use crate::stuffit::{build_archive_tree, StuffItInput, StuffItInputNode, WriteMethod};
641        // Build a 3-file archive: file1 + file2 at root, file3 under
642        // subdir/. Then extract only file1 and file3.
643        let mk = |name: &str, body: &[u8]| StuffItInput {
644            name: name.into(),
645            type_code: [0; 4],
646            creator_code: [0; 4],
647            finder_flags: 0,
648            create_date: 0,
649            mod_date: 0,
650            data_fork: body.to_vec(),
651            resource_fork: Vec::new(),
652        };
653        let tree = vec![
654            StuffItInputNode::File(mk("file1.txt", b"AAA")),
655            StuffItInputNode::File(mk("file2.txt", b"BBB")),
656            StuffItInputNode::Folder {
657                name: "subdir".into(),
658                finder_flags: 0,
659                create_date: 0,
660                mod_date: 0,
661                children: vec![StuffItInputNode::File(mk("file3.txt", b"CCC"))],
662            },
663        ];
664        let arc_bytes = build_archive_tree(&tree, WriteMethod::Store).expect("build");
665        let (bytes, archive) = open_bytes(arc_bytes).expect("open");
666
667        // Find the indices of file1.txt and file3.txt in the parsed
668        // archive (folder markers + entry ordering can vary).
669        let keep_names: std::collections::HashSet<&str> =
670            ["file1.txt", "file3.txt"].into_iter().collect();
671        let keep_idx: std::collections::HashSet<usize> = archive
672            .entries
673            .iter()
674            .enumerate()
675            .filter(|(_, e)| keep_names.contains(e.name.as_str()))
676            .map(|(i, _)| i)
677            .collect();
678
679        let dir = tempfile::tempdir().expect("tempdir");
680        let stats = extract_filtered(
681            &bytes,
682            &archive,
683            dir.path(),
684            ForkFormat::Raw,
685            |i| keep_idx.contains(&i),
686            |_, _, _| {},
687            |_| {},
688        )
689        .expect("extract");
690        assert_eq!(stats.files, 2);
691        assert_eq!(stats.skipped, 0);
692
693        assert!(dir.path().join("file1.txt").exists());
694        assert!(
695            !dir.path().join("file2.txt").exists(),
696            "file2 should be skipped"
697        );
698        assert!(dir.path().join("subdir/file3.txt").exists());
699    }
700
701    /// A Mac filename containing `/` (legal on HFS, illegal as a host path
702    /// component) is sanitized to `_`, counted in `names_sanitized`, and a
703    /// per-file warning plus a closing recommendation are logged.
704    #[test]
705    fn extract_warns_and_counts_host_illegal_names() {
706        use crate::stuffit::{build_archive_tree, StuffItInput, StuffItInputNode, WriteMethod};
707        let tree = vec![StuffItInputNode::File(StuffItInput {
708            name: "Read/Write Me".into(),
709            type_code: *b"TEXT",
710            creator_code: *b"ttxt",
711            finder_flags: 0,
712            create_date: 0,
713            mod_date: 0,
714            data_fork: b"hi".to_vec(),
715            resource_fork: Vec::new(),
716        })];
717        let arc_bytes = build_archive_tree(&tree, WriteMethod::Store).expect("build");
718        let (bytes, archive) = open_bytes(arc_bytes).expect("open");
719
720        let dir = tempfile::tempdir().expect("tempdir");
721        let mut logs: Vec<String> = Vec::new();
722        let stats = extract_all(
723            &bytes,
724            &archive,
725            dir.path(),
726            ForkFormat::Raw,
727            |_, _, _| {},
728            |m| logs.push(m),
729        )
730        .expect("extract");
731
732        assert_eq!(stats.files, 1);
733        assert_eq!(stats.names_sanitized, 1);
734        assert!(dir.path().join("Read_Write Me").exists());
735        assert!(
736            logs.iter()
737                .any(|l| l.contains("Read/Write Me") && l.contains("Read_Write Me")),
738            "per-file warning logged"
739        );
740        assert!(
741            logs.iter().any(|l| l.contains("HFS/HFS+ disk image")),
742            "closing recommendation logged"
743        );
744    }
745
746    /// Raw extraction must not lose a resource fork when a sibling entry is
747    /// literally named `<name>.rsrc` and would otherwise overwrite the
748    /// sidecar. The data fork of the `.rsrc`-named file keeps its natural
749    /// path; the colliding resource fork is parked at `<name>.rsrc.1`.
750    /// (Reproduces the macppp `ppp.pi` / `ppp.pi.rsrc` data loss from the
751    /// real archive.)
752    #[test]
753    fn raw_extract_preserves_forks_on_dot_rsrc_name_collision() {
754        use crate::stuffit::{build_archive_tree, StuffItInputNode, WriteMethod};
755        let mk = |name: &str, data: &[u8], rsrc: &[u8]| crate::stuffit::StuffItInput {
756            name: name.into(),
757            type_code: [0; 4],
758            creator_code: [0; 4],
759            finder_flags: 0,
760            create_date: 0,
761            mod_date: 0,
762            data_fork: data.to_vec(),
763            resource_fork: rsrc.to_vec(),
764        };
765        // `Doc` carries a resource fork; `Doc.rsrc` is a *separate* file
766        // whose data fork would land on `Doc`'s sidecar path.
767        let tree = vec![
768            StuffItInputNode::File(mk("Doc", b"DOC-DATA", b"DOC-RSRC")),
769            StuffItInputNode::File(mk("Doc.rsrc", b"SIBLING-DATA", b"")),
770        ];
771        let arc_bytes = build_archive_tree(&tree, WriteMethod::Store).expect("build");
772        let (bytes, archive) = open_bytes(arc_bytes).expect("open");
773
774        let dir = tempfile::tempdir().expect("tempdir");
775        let stats = extract_all(
776            &bytes,
777            &archive,
778            dir.path(),
779            ForkFormat::Raw,
780            |_, _, _| {},
781            |_| {},
782        )
783        .expect("extract");
784        assert_eq!(stats.files, 2);
785        assert_eq!(stats.skipped, 0);
786
787        // The sibling file's data fork keeps `Doc.rsrc` intact.
788        assert_eq!(
789            std::fs::read(dir.path().join("Doc.rsrc")).unwrap(),
790            b"SIBLING-DATA",
791            "sibling data fork must not be clobbered"
792        );
793        // `Doc`'s data fork is untouched.
794        assert_eq!(std::fs::read(dir.path().join("Doc")).unwrap(), b"DOC-DATA");
795        // `Doc`'s resource fork survives at the de-conflicted sidecar.
796        assert_eq!(
797            std::fs::read(dir.path().join("Doc.rsrc.1")).unwrap(),
798            b"DOC-RSRC",
799            "Doc resource fork must be preserved at a non-colliding path"
800        );
801    }
802
803    /// Build a MacBinary II archive (valid CRC) for the open_bytes tests.
804    fn build_macbinary_ii(name: &[u8], flags: u16, data: &[u8], rsrc: &[u8]) -> Vec<u8> {
805        use byteorder::{BigEndian, ByteOrder};
806        let mut hdr = [0u8; 128];
807        hdr[1] = name.len() as u8;
808        hdr[2..2 + name.len()].copy_from_slice(name);
809        hdr[65..69].copy_from_slice(b"APPL");
810        hdr[69..73].copy_from_slice(b"Po.P");
811        hdr[73] = (flags >> 8) as u8;
812        hdr[101] = (flags & 0xFF) as u8;
813        BigEndian::write_u32(&mut hdr[83..87], data.len() as u32);
814        BigEndian::write_u32(&mut hdr[87..91], rsrc.len() as u32);
815        BigEndian::write_u32(&mut hdr[91..95], 0x1111_1111); // create
816        BigEndian::write_u32(&mut hdr[95..99], 0x2222_2222); // modify
817        hdr[122] = 129;
818        hdr[123] = 129;
819        let crc = fs_mac::resource_fork::macbinary_crc16(&hdr[0..124]);
820        BigEndian::write_u16(&mut hdr[124..126], crc);
821        let mut out = hdr.to_vec();
822        out.extend_from_slice(data);
823        while out.len() % 128 != 0 {
824            out.push(0);
825        }
826        out.extend_from_slice(rsrc);
827        while out.len() % 128 != 0 {
828            out.push(0);
829        }
830        out
831    }
832
833    /// A plain MacBinary file (no inner archive) synthesizes a one-entry
834    /// archive whose forks decompress verbatim and whose Finder flags + dates
835    /// are preserved. Proves Phase 2 of docs/native_mac_archives.md.
836    #[test]
837    fn open_bytes_synthesizes_single_entry_from_macbinary() {
838        let mb = build_macbinary_ii(b"Prince", 0x2000, b"data-fork-bytes", b"RSRCDATA");
839        let (buf, archive) = open_bytes(mb).expect("MacBinary should open");
840        assert_eq!(archive.entries.len(), 1);
841        let e = &archive.entries[0];
842        assert_eq!(e.name, "Prince");
843        assert_eq!(&e.type_code, b"APPL");
844        assert_eq!(&e.creator_code, b"Po.P");
845        assert_eq!(e.finder_flags, 0x2000, "hasBundle preserved");
846        assert_eq!(e.create_date, 0x1111_1111);
847        assert_eq!(e.mod_date, 0x2222_2222);
848        let data = stuffit::decompress_fork(&buf, e.data.as_ref().unwrap()).unwrap();
849        assert_eq!(data, b"data-fork-bytes");
850        let rsrc = stuffit::decompress_fork(&buf, e.rsrc.as_ref().unwrap()).unwrap();
851        assert_eq!(rsrc, b"RSRCDATA");
852    }
853
854    /// A MacBinary whose data fork is itself a StuffIt archive peels through to
855    /// the inner archive's entries (Phase 3 peel), rather than surfacing one
856    /// opaque blob.
857    #[test]
858    fn open_bytes_peels_macbinary_over_sit() {
859        use crate::stuffit::{build_archive_tree, StuffItInput, StuffItInputNode, WriteMethod};
860        let mk = |name: &str, body: &[u8]| StuffItInput {
861            name: name.into(),
862            type_code: [0; 4],
863            creator_code: [0; 4],
864            finder_flags: 0,
865            create_date: 0,
866            mod_date: 0,
867            data_fork: body.to_vec(),
868            resource_fork: Vec::new(),
869        };
870        let inner = build_archive_tree(
871            &[
872                StuffItInputNode::File(mk("inner1.txt", b"AAA")),
873                StuffItInputNode::File(mk("inner2.txt", b"BBB")),
874            ],
875            WriteMethod::Store,
876        )
877        .expect("build inner SIT");
878        let mb = build_macbinary_ii(b"Bundle.sit.bin", 0, &inner, b"");
879        let (_buf, archive) = open_bytes(mb).expect("MacBinary-over-SIT should open");
880        let names: std::collections::HashSet<&str> =
881            archive.entries.iter().map(|e| e.name.as_str()).collect();
882        assert!(
883            names.contains("inner1.txt") && names.contains("inner2.txt"),
884            "should expand to inner SIT entries, got {names:?}"
885        );
886    }
887
888    /// `open_bytes` on truly opaque bytes returns the new
889    /// magic-driven error message rather than the legacy SIT-specific one.
890    #[test]
891    fn open_bytes_rejects_non_archive_bytes_with_clear_message() {
892        let junk = b"this is not a Mac archive of any kind".to_vec();
893        let err = open_bytes(junk).expect_err("should reject");
894        let msg = format!("{err}");
895        assert!(
896            msg.contains("not a recognized Mac archive"),
897            "expected magic-driven error, got: {msg}"
898        );
899    }
900}