Skip to main content

fs_mac/
fork_export.rs

1//! Fork-preserving single-file export, shared by the browse view's "Extract"
2//! and Commander's copy/export-to-host.
3//!
4//! Reads a file's data fork (`read_file`) and resource fork
5//! (`write_resource_fork_to`) off any [`Filesystem`] and writes it into a host
6//! directory in the chosen [`ResourceForkMode`]:
7//! - **MacBinary** — single `.bin` carrying both forks (only when a resource
8//!   fork is present; otherwise falls through to a plain data-fork write).
9//! - **BinHex** — single `.hqx` text file (always applicable; carries Finder
10//!   info even with no resource fork).
11//! - **AppleDouble** — data fork under the plain name + a `._name` sidecar
12//!   (emitted whenever there's a resource fork *or* type/creator to preserve).
13//! - **Native** — data fork + the macOS `..namedfork/rsrc` write (macOS only).
14//! - **SeparateRsrc** — data fork + a `name.rsrc` sidecar.
15//! - **DataForkOnly** — just the data fork.
16//!
17//! This mirrors the browse view's `extract_entry` per-file logic in one place
18//! so Commander gets identical output, and it works over the remote wire too
19//! (`RemoteFilesystem` implements `write_resource_fork_to` via the daemon's
20//! resource-fork read op).
21
22use std::path::Path;
23
24use anyhow::{Context, Result};
25
26use crate::binhex;
27use crate::resource_fork::{self, MacFileDates, ResourceForkMode};
28use disk_core::entry::FileEntry;
29use disk_core::filesystem::Filesystem;
30
31/// The default sanitized host filename for `entry` (no ProDOS type suffix).
32/// Callers with no special naming needs pass this as `out_name`.
33pub fn safe_name(entry: &FileEntry) -> String {
34    resource_fork::sanitize_filename(&entry.name)
35}
36
37/// The host path [`export_file_with_fork`] writes `entry`'s data to under
38/// `mode`, so a caller can see a collision before anything is overwritten.
39pub fn primary_output_path(
40    entry: &FileEntry,
41    dest_dir: &Path,
42    out_name: &str,
43    mode: ResourceForkMode,
44) -> std::path::PathBuf {
45    let has_rsrc = entry.resource_fork_size.map(|s| s > 0).unwrap_or(false);
46    match mode {
47        ResourceForkMode::MacBinary if has_rsrc => dest_dir.join(format!("{out_name}.bin")),
48        ResourceForkMode::BinHex => dest_dir.join(format!("{out_name}.hqx")),
49        _ => dest_dir.join(out_name),
50    }
51}
52
53/// Export one FILE `entry` into `dest_dir` under the base name `out_name`
54/// (already sanitized / suffixed by the caller — e.g. the browse view appends a
55/// ProDOS `#TTAAAA` suffix), using `mode`. Returns the number of bytes written
56/// (data + resource) for progress accounting. The caller handles directories
57/// (create + recurse). Use [`safe_name`] for the default sanitized name.
58pub fn export_file_with_fork(
59    fs: &mut dyn Filesystem,
60    entry: &FileEntry,
61    dest_dir: &Path,
62    out_name: &str,
63    mode: ResourceForkMode,
64) -> Result<u64> {
65    let safe = out_name;
66    let has_rsrc = entry.resource_fork_size.map(|s| s > 0).unwrap_or(false);
67    let type_code = entry.type_code.unwrap_or([0; 4]);
68    let creator_code = entry.creator_code.unwrap_or([0; 4]);
69    // HFS/HFS+ catalog dates are already raw Mac-1904 seconds — the exact
70    // MacBinary / AppleDouble encoding — so carry them straight through.
71    let dates = entry
72        .mac_dates
73        .map(|(created, modified, _backup)| MacFileDates { created, modified })
74        .unwrap_or_default();
75
76    let read_data = |fs: &mut dyn Filesystem| -> Result<Vec<u8>> {
77        fs.read_file(entry, usize::MAX)
78            .with_context(|| format!("reading '{}'", entry.name))
79    };
80    let read_rsrc = |fs: &mut dyn Filesystem| -> Result<Vec<u8>> {
81        let mut buf = Vec::new();
82        fs.write_resource_fork_to(entry, &mut buf)
83            .with_context(|| format!("reading resource fork of '{}'", entry.name))?;
84        Ok(buf)
85    };
86
87    if mode == ResourceForkMode::MacBinary && has_rsrc {
88        let data = read_data(fs)?;
89        let rsrc = read_rsrc(fs)?;
90        let mb =
91            resource_fork::build_macbinary(safe, &type_code, &creator_code, dates, &data, &rsrc);
92        let out = dest_dir.join(format!("{safe}.bin"));
93        std::fs::write(&out, &mb).with_context(|| format!("writing {}", out.display()))?;
94        return Ok(data.len() as u64 + rsrc.len() as u64);
95    }
96
97    if mode == ResourceForkMode::BinHex {
98        let data = read_data(fs)?;
99        let rsrc = if has_rsrc { read_rsrc(fs)? } else { Vec::new() };
100        let bh = binhex::BinHexFile {
101            name: entry.name.clone(),
102            type_code,
103            creator_code,
104            flags: 0,
105            data_fork: data,
106            resource_fork: rsrc,
107        };
108        let text = binhex::build_binhex(&bh);
109        let out = dest_dir.join(format!("{safe}.hqx"));
110        std::fs::write(&out, text.as_bytes())
111            .with_context(|| format!("writing {}", out.display()))?;
112        return Ok(bh.data_fork.len() as u64 + bh.resource_fork.len() as u64);
113    }
114
115    // Data fork to the plain name...
116    let out = dest_dir.join(safe);
117    let mut f =
118        std::fs::File::create(&out).with_context(|| format!("creating {}", out.display()))?;
119    let written = fs
120        .write_file_to(entry, &mut f)
121        .with_context(|| format!("extracting '{}'", entry.name))?;
122    drop(f);
123    // Preserve the source's mtime on the extracted host file when the
124    // source recorded one — a plain image->host extract now round-trips
125    // the date the same way tar's `-p` flag does. Best-effort: a
126    // filesystem that doesn't record dates leaves this None and the OS
127    // stamps the current time as before. Errors are non-fatal (some
128    // network filesystems / SMB shares reject setting mtime).
129    apply_host_mtime(&out, entry);
130
131    // ...plus a resource-fork sidecar for the fork-carrying "beside the data
132    // fork" modes. MacBinary-without-rsrc and DataForkOnly land here as a plain
133    // data-fork write with no sidecar.
134    let mut extra = 0u64;
135    let has_finfo = type_code != [0; 4] || creator_code != [0; 4];
136    match mode {
137        ResourceForkMode::Native if has_rsrc => {
138            let rsrc = read_rsrc(fs)?;
139            let p = out.join("..namedfork/rsrc");
140            std::fs::write(&p, &rsrc)
141                .with_context(|| format!("writing native resource fork of '{}'", entry.name))?;
142            extra = rsrc.len() as u64;
143        }
144        ResourceForkMode::AppleDouble if has_rsrc || has_finfo => {
145            let rsrc = if has_rsrc { read_rsrc(fs)? } else { Vec::new() };
146            let ad = resource_fork::build_appledouble(&type_code, &creator_code, dates, &rsrc);
147            let p = dest_dir.join(format!("._{safe}"));
148            std::fs::write(&p, &ad)
149                .with_context(|| format!("writing AppleDouble sidecar of '{}'", entry.name))?;
150            extra = rsrc.len() as u64;
151        }
152        ResourceForkMode::SeparateRsrc if has_rsrc => {
153            let rsrc = read_rsrc(fs)?;
154            let p = dest_dir.join(format!("{safe}.rsrc"));
155            std::fs::write(&p, &rsrc)
156                .with_context(|| format!("writing .rsrc sidecar of '{}'", entry.name))?;
157            extra = rsrc.len() as u64;
158        }
159        _ => {}
160    }
161
162    Ok(written + extra)
163}
164
165/// Stamp the extracted host file's mtime/atime from `entry.modified_unix`.
166/// A best-effort call: filesystems that can't set times (network mounts,
167/// some FUSE-backed FSes) just leave the OS default, they don't fail the
168/// export.
169fn apply_host_mtime(path: &Path, entry: &FileEntry) {
170    let Some(secs) = entry.modified_unix else {
171        return;
172    };
173    let ft = filetime::FileTime::from_unix_time(secs as i64, 0);
174    let _ = filetime::set_file_times(path, ft, ft);
175}