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        },
245    )
246}
247
248/// Detect the StuffIt X container ("StuffIt!" / "StuffIt?"). Distinct from
249/// StuffIt 5, whose 8th byte is a space ("StuffIt (c)1997…").
250pub fn is_stuffitx(data: &[u8]) -> bool {
251    data.len() >= 8 && &data[..7] == b"StuffIt" && (data[7] == b'!' || data[7] == b'?')
252}
253
254/// Extract every file in `archive` to `dest`, rebuilding the directory tree.
255/// `progress(done, total, name)` is called before each file entry.
256pub fn extract_all(
257    bytes: &[u8],
258    archive: &StuffItArchive,
259    dest: &Path,
260    format: ForkFormat,
261    progress: impl FnMut(usize, usize, &str),
262    log: impl FnMut(String),
263) -> Result<ExtractStats> {
264    extract_filtered(bytes, archive, dest, format, |_| true, progress, log)
265}
266
267/// Like [`extract_all`] but only writes entries for which `keep(idx)`
268/// returns true (where `idx` indexes into `archive.entries`). Folder
269/// markers are always written so the rebuilt tree is rooted correctly;
270/// file entries the predicate rejects increment neither `files` nor
271/// `skipped` and don't show up in the progress callback. Used by the
272/// GUI Archives tab's "Extract Selected..." button (Workflow D.3).
273pub fn extract_filtered(
274    bytes: &[u8],
275    archive: &StuffItArchive,
276    dest: &Path,
277    format: ForkFormat,
278    mut keep: impl FnMut(usize) -> bool,
279    mut progress: impl FnMut(usize, usize, &str),
280    mut log: impl FnMut(String),
281) -> Result<ExtractStats> {
282    // Say this once, before the per-entry skips: a truncated archive makes every
283    // entry past the cut fail, and the per-fork error alone reads like a decoder
284    // bug rather than a missing-disk problem.
285    if let Some(t) = archive.truncated {
286        log(t.describe());
287    }
288
289    std::fs::create_dir_all(dest).with_context(|| format!("creating {}", dest.display()))?;
290
291    let total = archive
292        .entries
293        .iter()
294        .enumerate()
295        .filter(|(i, e)| !e.is_dir && keep(*i))
296        .count();
297
298    // Raw format only: an entry's resource fork is written to a `name.rsrc`
299    // sidecar, which collides when a *sibling* entry is literally named
300    // `name.rsrc` (e.g. classic THINK projects ship `Foo.pi` alongside a
301    // `Foo.pi.rsrc` resource file). Reserve every data-fork destination up
302    // front so sidecars dodge them — data-fork names always keep their
303    // natural path, sidecars yield to a non-colliding `.rsrc.N` variant.
304    let reserved_data_paths: HashSet<PathBuf> = if format == ForkFormat::Raw {
305        archive
306            .entries
307            .iter()
308            .enumerate()
309            .filter(|(i, e)| !e.is_dir && keep(*i))
310            .map(|(_, e)| entry_target(dest, e))
311            .collect()
312    } else {
313        HashSet::new()
314    };
315    let mut used_sidecars: HashSet<PathBuf> = HashSet::new();
316
317    let mut stats = ExtractStats::default();
318    let mut done = 0usize;
319
320    for (idx, e) in archive.entries.iter().enumerate() {
321        let target = entry_target(dest, e);
322
323        if e.is_dir {
324            // Always create directory markers — a subsequent kept file
325            // might land underneath them.
326            if let Some(s) = sanitized_if_changed(&e.name) {
327                log(format!(
328                    "Warning: \"{}\" -> \"{}\" (character invalid on this filesystem)",
329                    e.name, s
330                ));
331                stats.names_sanitized += 1;
332            }
333            std::fs::create_dir_all(&target)?;
334            continue;
335        }
336        if !keep(idx) {
337            continue;
338        }
339        if let Some(s) = sanitized_if_changed(&e.name) {
340            log(format!(
341                "Warning: \"{}\" -> \"{}\" (character invalid on this filesystem)",
342                e.name, s
343            ));
344            stats.names_sanitized += 1;
345        }
346        progress(done, total, &e.name);
347        done += 1;
348        if let Some(parent) = target.parent() {
349            std::fs::create_dir_all(parent)?;
350        }
351
352        let data = match decompress_named(bytes, e.data.as_ref(), e, &mut log) {
353            Ok(d) => d,
354            Err(()) => {
355                stats.skipped += 1;
356                continue;
357            }
358        };
359        let rsrc = match decompress_named(bytes, e.rsrc.as_ref(), e, &mut log) {
360            Ok(d) => d,
361            Err(()) => {
362                stats.skipped += 1;
363                continue;
364            }
365        };
366
367        // Compact Pro stores one CRC-32 per file over (resource fork ++ data
368        // fork); verify it now that both forks are decompressed. This is the
369        // format's only integrity check, so it covers single-fork, dual-fork,
370        // and empty entries alike.
371        if let Some(expected) = compactpro_entry_crc(e) {
372            if let Err(err) = super::compactpro::verify_entry_crc(&rsrc, &data, expected) {
373                log(format!("Skipped {}: {err}", e.display_path()));
374                stats.skipped += 1;
375                continue;
376            }
377        }
378
379        // Resolve the raw resource-fork sidecar path, dodging any sibling
380        // data fork that already owns `name.rsrc`. Other formats prefix the
381        // sidecar (`._name`) or change its extension (`.hqx` / `.bin`), so
382        // they never collide and pass `None`.
383        let raw_rsrc_path = if format == ForkFormat::Raw && !rsrc.is_empty() {
384            let natural = with_added_extension(&target, "rsrc");
385            let chosen = raw_rsrc_sidecar_path(&target, &reserved_data_paths, &mut used_sidecars);
386            if chosen != natural {
387                log(format!(
388                    "Note: resource fork of '{}' written as '{}' to avoid overwriting a sibling file named '{}'",
389                    e.display_path(),
390                    chosen.file_name().unwrap_or_default().to_string_lossy(),
391                    natural.file_name().unwrap_or_default().to_string_lossy(),
392                ));
393            }
394            Some(chosen)
395        } else {
396            None
397        };
398
399        write_entry(&target, e, &data, &rsrc, format, raw_rsrc_path.as_deref())?;
400        stats.files += 1;
401    }
402
403    if stats.names_sanitized > 0 {
404        log(format!(
405            "{} name(s) contain characters this filesystem can't store and were changed. \
406             To keep the original names, leave the archive compressed, or extract into an \
407             HFS/HFS+ disk image where these characters are legal.",
408            stats.names_sanitized
409        ));
410    }
411
412    Ok(stats)
413}
414
415/// The per-file CRC-32 for a Compact Pro entry (carried identically on both
416/// forks), or `None` for StuffIt entries which checksum each fork on decode.
417fn compactpro_entry_crc(e: &StuffItEntry) -> Option<u32> {
418    [e.rsrc.as_ref(), e.data.as_ref()]
419        .into_iter()
420        .flatten()
421        .find(|f| matches!(f.codec, stuffit::ForkCodec::CompactPro { .. }))
422        .map(|f| f.crc32)
423}
424
425fn decompress_named(
426    bytes: &[u8],
427    fork: Option<&stuffit::ForkInfo>,
428    e: &StuffItEntry,
429    log: &mut impl FnMut(String),
430) -> Result<Vec<u8>, ()> {
431    match fork {
432        Some(f) if f.uncompressed_len > 0 => {
433            let result = match f.codec {
434                stuffit::ForkCodec::StuffIt => stuffit::decompress_fork(bytes, f),
435                stuffit::ForkCodec::CompactPro { .. } => {
436                    super::compactpro::decompress_fork(bytes, f)
437                }
438            };
439            match result {
440                Ok(d) => Ok(d),
441                Err(err) => {
442                    log(format!("Skipped {}: {err}", e.display_path()));
443                    Err(())
444                }
445            }
446        }
447        _ => Ok(Vec::new()),
448    }
449}
450
451/// Write one extracted file's forks to the host in the chosen container format.
452///
453/// `raw_rsrc_path` overrides where a [`ForkFormat::Raw`] resource sidecar is
454/// written; the orchestrator passes a collision-free path here (see
455/// [`raw_rsrc_sidecar_path`]). It is ignored for every other format, and a
456/// `None` falls back to the natural `name.rsrc` next to `target`.
457pub fn write_entry(
458    target: &Path,
459    entry: &StuffItEntry,
460    data: &[u8],
461    rsrc: &[u8],
462    format: ForkFormat,
463    raw_rsrc_path: Option<&Path>,
464) -> Result<()> {
465    match format {
466        ForkFormat::BinHex => {
467            let bh = binhex::BinHexFile {
468                name: entry.name.clone(),
469                type_code: entry.type_code,
470                creator_code: entry.creator_code,
471                flags: entry.finder_flags,
472                data_fork: data.to_vec(),
473                resource_fork: rsrc.to_vec(),
474            };
475            std::fs::write(
476                with_added_extension(target, "hqx"),
477                binhex::build_binhex(&bh).as_bytes(),
478            )?;
479        }
480        ForkFormat::MacBinary => {
481            let mb = resource_fork::build_macbinary(
482                &entry.name,
483                &entry.type_code,
484                &entry.creator_code,
485                resource_fork::MacFileDates::default(),
486                data,
487                rsrc,
488            );
489            std::fs::write(with_added_extension(target, "bin"), mb)?;
490        }
491        ForkFormat::AppleDouble => {
492            std::fs::write(target, data)?;
493            if !rsrc.is_empty() || entry.type_code != [0; 4] || entry.creator_code != [0; 4] {
494                let ad = resource_fork::build_appledouble(
495                    &entry.type_code,
496                    &entry.creator_code,
497                    resource_fork::MacFileDates::default(),
498                    rsrc,
499                );
500                std::fs::write(sidecar_path(target, "._"), ad)?;
501            }
502        }
503        ForkFormat::Raw => {
504            std::fs::write(target, data)?;
505            if !rsrc.is_empty() {
506                let rsrc_dst = raw_rsrc_path
507                    .map(Path::to_path_buf)
508                    .unwrap_or_else(|| with_added_extension(target, "rsrc"));
509                std::fs::write(rsrc_dst, rsrc)?;
510            }
511        }
512    }
513    Ok(())
514}
515
516/// Destination path for an entry's data fork: `dest` joined with the entry's
517/// path components, each [`sanitize_filename`]d. Shared by the extraction loop
518/// and the up-front data-path reservation so both agree on every name.
519/// If `name` contains a character illegal in a host path component, returns its
520/// sanitized form; otherwise `None`. Lets the extractor warn the user that the
521/// on-disk name differs from the Mac original (e.g. a Mac filename with a `/`).
522fn sanitized_if_changed(name: &str) -> Option<String> {
523    let s = sanitize_filename(name);
524    (s != name).then_some(s)
525}
526
527fn entry_target(dest: &Path, entry: &StuffItEntry) -> PathBuf {
528    let mut rel = PathBuf::new();
529    for comp in &entry.path {
530        rel.push(sanitize_filename(comp));
531    }
532    dest.join(rel)
533}
534
535/// Pick a raw resource-fork sidecar path that won't clobber any data fork.
536/// The natural name is `<target>.rsrc`; if a different entry's data fork
537/// already owns that path (it's in `reserved`) or a previous sidecar took it
538/// (`used`), fall back to `<target>.rsrc.1`, `.2`, ... until free. Records the
539/// chosen path in `used` so two sidecars can't collide either.
540fn raw_rsrc_sidecar_path(
541    target: &Path,
542    reserved: &HashSet<PathBuf>,
543    used: &mut HashSet<PathBuf>,
544) -> PathBuf {
545    let base = with_added_extension(target, "rsrc");
546    let mut candidate = base.clone();
547    let mut n = 1u32;
548    while reserved.contains(&candidate) || used.contains(&candidate) {
549        candidate = with_added_extension(&base, &n.to_string());
550        n += 1;
551    }
552    used.insert(candidate.clone());
553    candidate
554}
555
556/// Append `.ext` to a path (keeping any existing extension).
557fn with_added_extension(path: &Path, ext: &str) -> PathBuf {
558    let mut name = path.file_name().unwrap_or_default().to_os_string();
559    name.push(".");
560    name.push(ext);
561    path.with_file_name(name)
562}
563
564/// Build a `._name`-style sidecar path next to `target`.
565fn sidecar_path(target: &Path, prefix: &str) -> PathBuf {
566    let name = target.file_name().unwrap_or_default().to_string_lossy();
567    target.with_file_name(format!("{prefix}{name}"))
568}
569
570#[cfg(test)]
571mod tests {
572    use super::*;
573
574    /// `open_bytes` on a loose `.hqx` (BinHex around plain Mac file
575    /// bytes, no SIT inside) synthesizes a one-entry archive that
576    /// decompresses to the original forks. Proves the Workflow D.1
577    /// "accept loose .hqx" path.
578    #[test]
579    fn open_bytes_synthesizes_single_entry_archive_for_loose_binhex() {
580        let bh = binhex::BinHexFile {
581            name: "Document.txt".into(),
582            type_code: *b"TEXT",
583            creator_code: *b"ttxt",
584            flags: 0x0042,
585            data_fork: b"hello mac world".to_vec(),
586            resource_fork: b"RSRC".to_vec(),
587        };
588        let hqx = binhex::build_binhex(&bh).into_bytes();
589
590        let (buf, archive) = open_bytes(hqx).expect("loose hqx should open");
591        assert_eq!(archive.entries.len(), 1, "synthesized archive is one entry");
592        let entry = &archive.entries[0];
593        assert_eq!(entry.name, "Document.txt");
594        assert_eq!(entry.type_code, *b"TEXT");
595        assert_eq!(entry.creator_code, *b"ttxt");
596        assert_eq!(entry.finder_flags, 0x0042);
597        assert!(!entry.is_dir);
598
599        let data = stuffit::decompress_fork(&buf, entry.data.as_ref().unwrap())
600            .expect("data fork round-trips through method=0");
601        assert_eq!(data, b"hello mac world");
602
603        let rsrc = stuffit::decompress_fork(&buf, entry.rsrc.as_ref().unwrap())
604            .expect("rsrc fork round-trips through method=0");
605        assert_eq!(rsrc, b"RSRC");
606    }
607
608    /// A loose `.hqx` with an empty resource fork should synthesize an
609    /// entry with `rsrc = None` (matching how real Mac files without
610    /// resource forks present in StuffIt archives).
611    #[test]
612    fn open_bytes_empty_rsrc_omits_fork() {
613        let bh = binhex::BinHexFile {
614            name: "data.bin".into(),
615            type_code: [0; 4],
616            creator_code: [0; 4],
617            flags: 0,
618            data_fork: vec![1, 2, 3, 4],
619            resource_fork: Vec::new(),
620        };
621        let hqx = binhex::build_binhex(&bh).into_bytes();
622        let (_buf, archive) = open_bytes(hqx).expect("open");
623        assert!(
624            archive.entries[0].rsrc.is_none(),
625            "empty rsrc folds to None"
626        );
627        assert!(archive.entries[0].data.is_some(), "data fork still present");
628    }
629
630    /// `extract_filtered` writes only the entries the predicate keeps;
631    /// folder markers always materialize so subsequent kept files have
632    /// somewhere to land. Proves the Workflow D.3 "Extract Selected"
633    /// data path.
634    #[test]
635    fn extract_filtered_writes_only_kept_entries() {
636        use crate::stuffit::{build_archive_tree, StuffItInput, StuffItInputNode, WriteMethod};
637        // Build a 3-file archive: file1 + file2 at root, file3 under
638        // subdir/. Then extract only file1 and file3.
639        let mk = |name: &str, body: &[u8]| StuffItInput {
640            name: name.into(),
641            type_code: [0; 4],
642            creator_code: [0; 4],
643            finder_flags: 0,
644            create_date: 0,
645            mod_date: 0,
646            data_fork: body.to_vec(),
647            resource_fork: Vec::new(),
648        };
649        let tree = vec![
650            StuffItInputNode::File(mk("file1.txt", b"AAA")),
651            StuffItInputNode::File(mk("file2.txt", b"BBB")),
652            StuffItInputNode::Folder {
653                name: "subdir".into(),
654                finder_flags: 0,
655                create_date: 0,
656                mod_date: 0,
657                children: vec![StuffItInputNode::File(mk("file3.txt", b"CCC"))],
658            },
659        ];
660        let arc_bytes = build_archive_tree(&tree, WriteMethod::Store).expect("build");
661        let (bytes, archive) = open_bytes(arc_bytes).expect("open");
662
663        // Find the indices of file1.txt and file3.txt in the parsed
664        // archive (folder markers + entry ordering can vary).
665        let keep_names: std::collections::HashSet<&str> =
666            ["file1.txt", "file3.txt"].into_iter().collect();
667        let keep_idx: std::collections::HashSet<usize> = archive
668            .entries
669            .iter()
670            .enumerate()
671            .filter(|(_, e)| keep_names.contains(e.name.as_str()))
672            .map(|(i, _)| i)
673            .collect();
674
675        let dir = tempfile::tempdir().expect("tempdir");
676        let stats = extract_filtered(
677            &bytes,
678            &archive,
679            dir.path(),
680            ForkFormat::Raw,
681            |i| keep_idx.contains(&i),
682            |_, _, _| {},
683            |_| {},
684        )
685        .expect("extract");
686        assert_eq!(stats.files, 2);
687        assert_eq!(stats.skipped, 0);
688
689        assert!(dir.path().join("file1.txt").exists());
690        assert!(
691            !dir.path().join("file2.txt").exists(),
692            "file2 should be skipped"
693        );
694        assert!(dir.path().join("subdir/file3.txt").exists());
695    }
696
697    /// A Mac filename containing `/` (legal on HFS, illegal as a host path
698    /// component) is sanitized to `_`, counted in `names_sanitized`, and a
699    /// per-file warning plus a closing recommendation are logged.
700    #[test]
701    fn extract_warns_and_counts_host_illegal_names() {
702        use crate::stuffit::{build_archive_tree, StuffItInput, StuffItInputNode, WriteMethod};
703        let tree = vec![StuffItInputNode::File(StuffItInput {
704            name: "Read/Write Me".into(),
705            type_code: *b"TEXT",
706            creator_code: *b"ttxt",
707            finder_flags: 0,
708            create_date: 0,
709            mod_date: 0,
710            data_fork: b"hi".to_vec(),
711            resource_fork: Vec::new(),
712        })];
713        let arc_bytes = build_archive_tree(&tree, WriteMethod::Store).expect("build");
714        let (bytes, archive) = open_bytes(arc_bytes).expect("open");
715
716        let dir = tempfile::tempdir().expect("tempdir");
717        let mut logs: Vec<String> = Vec::new();
718        let stats = extract_all(
719            &bytes,
720            &archive,
721            dir.path(),
722            ForkFormat::Raw,
723            |_, _, _| {},
724            |m| logs.push(m),
725        )
726        .expect("extract");
727
728        assert_eq!(stats.files, 1);
729        assert_eq!(stats.names_sanitized, 1);
730        assert!(dir.path().join("Read_Write Me").exists());
731        assert!(
732            logs.iter()
733                .any(|l| l.contains("Read/Write Me") && l.contains("Read_Write Me")),
734            "per-file warning logged"
735        );
736        assert!(
737            logs.iter().any(|l| l.contains("HFS/HFS+ disk image")),
738            "closing recommendation logged"
739        );
740    }
741
742    /// Raw extraction must not lose a resource fork when a sibling entry is
743    /// literally named `<name>.rsrc` and would otherwise overwrite the
744    /// sidecar. The data fork of the `.rsrc`-named file keeps its natural
745    /// path; the colliding resource fork is parked at `<name>.rsrc.1`.
746    /// (Reproduces the macppp `ppp.pi` / `ppp.pi.rsrc` data loss from the
747    /// real archive.)
748    #[test]
749    fn raw_extract_preserves_forks_on_dot_rsrc_name_collision() {
750        use crate::stuffit::{build_archive_tree, StuffItInputNode, WriteMethod};
751        let mk = |name: &str, data: &[u8], rsrc: &[u8]| crate::stuffit::StuffItInput {
752            name: name.into(),
753            type_code: [0; 4],
754            creator_code: [0; 4],
755            finder_flags: 0,
756            create_date: 0,
757            mod_date: 0,
758            data_fork: data.to_vec(),
759            resource_fork: rsrc.to_vec(),
760        };
761        // `Doc` carries a resource fork; `Doc.rsrc` is a *separate* file
762        // whose data fork would land on `Doc`'s sidecar path.
763        let tree = vec![
764            StuffItInputNode::File(mk("Doc", b"DOC-DATA", b"DOC-RSRC")),
765            StuffItInputNode::File(mk("Doc.rsrc", b"SIBLING-DATA", b"")),
766        ];
767        let arc_bytes = build_archive_tree(&tree, WriteMethod::Store).expect("build");
768        let (bytes, archive) = open_bytes(arc_bytes).expect("open");
769
770        let dir = tempfile::tempdir().expect("tempdir");
771        let stats = extract_all(
772            &bytes,
773            &archive,
774            dir.path(),
775            ForkFormat::Raw,
776            |_, _, _| {},
777            |_| {},
778        )
779        .expect("extract");
780        assert_eq!(stats.files, 2);
781        assert_eq!(stats.skipped, 0);
782
783        // The sibling file's data fork keeps `Doc.rsrc` intact.
784        assert_eq!(
785            std::fs::read(dir.path().join("Doc.rsrc")).unwrap(),
786            b"SIBLING-DATA",
787            "sibling data fork must not be clobbered"
788        );
789        // `Doc`'s data fork is untouched.
790        assert_eq!(std::fs::read(dir.path().join("Doc")).unwrap(), b"DOC-DATA");
791        // `Doc`'s resource fork survives at the de-conflicted sidecar.
792        assert_eq!(
793            std::fs::read(dir.path().join("Doc.rsrc.1")).unwrap(),
794            b"DOC-RSRC",
795            "Doc resource fork must be preserved at a non-colliding path"
796        );
797    }
798
799    /// Build a MacBinary II archive (valid CRC) for the open_bytes tests.
800    fn build_macbinary_ii(name: &[u8], flags: u16, data: &[u8], rsrc: &[u8]) -> Vec<u8> {
801        use byteorder::{BigEndian, ByteOrder};
802        let mut hdr = [0u8; 128];
803        hdr[1] = name.len() as u8;
804        hdr[2..2 + name.len()].copy_from_slice(name);
805        hdr[65..69].copy_from_slice(b"APPL");
806        hdr[69..73].copy_from_slice(b"Po.P");
807        hdr[73] = (flags >> 8) as u8;
808        hdr[101] = (flags & 0xFF) as u8;
809        BigEndian::write_u32(&mut hdr[83..87], data.len() as u32);
810        BigEndian::write_u32(&mut hdr[87..91], rsrc.len() as u32);
811        BigEndian::write_u32(&mut hdr[91..95], 0x1111_1111); // create
812        BigEndian::write_u32(&mut hdr[95..99], 0x2222_2222); // modify
813        hdr[122] = 129;
814        hdr[123] = 129;
815        let crc = fs_mac::resource_fork::macbinary_crc16(&hdr[0..124]);
816        BigEndian::write_u16(&mut hdr[124..126], crc);
817        let mut out = hdr.to_vec();
818        out.extend_from_slice(data);
819        while out.len() % 128 != 0 {
820            out.push(0);
821        }
822        out.extend_from_slice(rsrc);
823        while out.len() % 128 != 0 {
824            out.push(0);
825        }
826        out
827    }
828
829    /// A plain MacBinary file (no inner archive) synthesizes a one-entry
830    /// archive whose forks decompress verbatim and whose Finder flags + dates
831    /// are preserved. Proves Phase 2 of docs/native_mac_archives.md.
832    #[test]
833    fn open_bytes_synthesizes_single_entry_from_macbinary() {
834        let mb = build_macbinary_ii(b"Prince", 0x2000, b"data-fork-bytes", b"RSRCDATA");
835        let (buf, archive) = open_bytes(mb).expect("MacBinary should open");
836        assert_eq!(archive.entries.len(), 1);
837        let e = &archive.entries[0];
838        assert_eq!(e.name, "Prince");
839        assert_eq!(&e.type_code, b"APPL");
840        assert_eq!(&e.creator_code, b"Po.P");
841        assert_eq!(e.finder_flags, 0x2000, "hasBundle preserved");
842        assert_eq!(e.create_date, 0x1111_1111);
843        assert_eq!(e.mod_date, 0x2222_2222);
844        let data = stuffit::decompress_fork(&buf, e.data.as_ref().unwrap()).unwrap();
845        assert_eq!(data, b"data-fork-bytes");
846        let rsrc = stuffit::decompress_fork(&buf, e.rsrc.as_ref().unwrap()).unwrap();
847        assert_eq!(rsrc, b"RSRCDATA");
848    }
849
850    /// A MacBinary whose data fork is itself a StuffIt archive peels through to
851    /// the inner archive's entries (Phase 3 peel), rather than surfacing one
852    /// opaque blob.
853    #[test]
854    fn open_bytes_peels_macbinary_over_sit() {
855        use crate::stuffit::{build_archive_tree, StuffItInput, StuffItInputNode, WriteMethod};
856        let mk = |name: &str, body: &[u8]| StuffItInput {
857            name: name.into(),
858            type_code: [0; 4],
859            creator_code: [0; 4],
860            finder_flags: 0,
861            create_date: 0,
862            mod_date: 0,
863            data_fork: body.to_vec(),
864            resource_fork: Vec::new(),
865        };
866        let inner = build_archive_tree(
867            &[
868                StuffItInputNode::File(mk("inner1.txt", b"AAA")),
869                StuffItInputNode::File(mk("inner2.txt", b"BBB")),
870            ],
871            WriteMethod::Store,
872        )
873        .expect("build inner SIT");
874        let mb = build_macbinary_ii(b"Bundle.sit.bin", 0, &inner, b"");
875        let (_buf, archive) = open_bytes(mb).expect("MacBinary-over-SIT should open");
876        let names: std::collections::HashSet<&str> =
877            archive.entries.iter().map(|e| e.name.as_str()).collect();
878        assert!(
879            names.contains("inner1.txt") && names.contains("inner2.txt"),
880            "should expand to inner SIT entries, got {names:?}"
881        );
882    }
883
884    /// `open_bytes` on truly opaque bytes returns the new
885    /// magic-driven error message rather than the legacy SIT-specific one.
886    #[test]
887    fn open_bytes_rejects_non_archive_bytes_with_clear_message() {
888        let junk = b"this is not a Mac archive of any kind".to_vec();
889        let err = open_bytes(junk).expect_err("should reject");
890        let msg = format!("{err}");
891        assert!(
892            msg.contains("not a recognized Mac archive"),
893            "expected magic-driven error, got: {msg}"
894        );
895    }
896}