Skip to main content

fs_mac/
hfsplus_clone.rs

1//! Source-side snapshot types for the HFS+ reformat-and-copy pipeline.
2//!
3//! Step 10 of `docs/hfsplus_enhancements.md`. Parallel to `hfs_clone.rs`,
4//! but for HFS+/HFSX. Nothing here mutates the source volume; these structs
5//! capture every byte of metadata that must survive a clone-shrink so later
6//! steps can replay it onto a freshly-formatted target.
7//!
8//! Step 14 wired extended-attribute capture into both `SourceFileSnapshot`
9//! and `SourceDirSnapshot`. Step 17 added file-hardlink support: stubs
10//! routed into a separate `SourceHardlinkSnapshot` list and inodes
11//! (files under `\0\0\0\0HFS+ Private Data` named `iNode<N>`) flagged
12//! with `is_inode = true`. Step 18 (this revision) does the same for
13//! directory hardlinks: file rows with FInfo `fdrp`/`MACS` route into
14//! `SourceDirHardlinkSnapshot`, and folders under
15//! `.HFS+ Private Directory Data\r` named `dir_<N>` are flagged with
16//! `is_dir_inode = true`. Replay (Step 21) emits each directory inode
17//! once and points each link at it.
18
19#[cfg(feature = "rust173-polyfill")]
20use disk_core::rust173::IntIsMultipleOf as _;
21use std::io::{Read, Seek, Write};
22
23use byteorder::{BigEndian, ByteOrder};
24
25use crate::hfs_common::walk_leaf_records;
26use crate::hfsplus::{HfsPlusFilesystem, RecordMetadata, XattrKind, XattrRecord};
27use disk_core::entry::FileEntry;
28use disk_core::filesystem::{
29    CreateDirectoryOptions, CreateFileOptions, EditableFilesystem, Filesystem, FilesystemError,
30};
31use std::collections::{HashMap, HashSet, VecDeque};
32
33/// Fork-type byte used in extents-overflow keys (mirrors the constant in
34/// `hfsplus`).
35const HFSPLUS_FORK_DATA: u8 = 0x00;
36const HFSPLUS_FORK_RESOURCE: u8 = 0xFF;
37
38/// Catalog record types.
39const CATALOG_FOLDER: i16 = 1;
40const CATALOG_FILE: i16 = 2;
41
42/// FInfo magic that flags an HFS+ hardlink. File hardlinks carry
43/// `fdType='hlnk' fdCreator='hfs+'`; directory hardlinks (10.5+) carry
44/// `fdType='fdrp' fdCreator='MACS'`. Both are unsupported until Phase 7.
45const HARDLINK_FILE_TYPE: &[u8; 4] = b"hlnk";
46const HARDLINK_FILE_CREATOR: &[u8; 4] = b"hfs+";
47const HARDLINK_DIR_TYPE: &[u8; 4] = b"fdrp";
48const HARDLINK_DIR_CREATOR: &[u8; 4] = b"MACS";
49
50/// Volume-level metadata captured from the source.
51#[derive(Debug, Clone)]
52pub struct SourceVolumeSnapshot {
53    /// Volume label (decoded UTF-8 of the root folder key name).
54    pub label: String,
55    /// 0x482B (HFS+) or 0x4858 (HFSX). Preserved so the target can be
56    /// re-formatted with the same case-sensitivity behavior.
57    pub signature: u16,
58    /// VH attributes word — clone needs this for the journaled bit, the
59    /// case-sensitivity bit (HFSX), and a few low-level flags.
60    pub attributes: u32,
61    pub create_date: u32,
62    pub modify_date: u32,
63    pub backup_date: u32,
64    pub checked_date: u32,
65    /// 8 × u32 Finder info. `finder_info[0]` is the blessed System Folder
66    /// CNID; other slots are copied verbatim with any CNID remap applied
67    /// by the caller in Step 21.
68    pub finder_info: [u32; 8],
69    /// Source allocation block size (informational — clone may pick a
70    /// different block size on the target).
71    pub block_size: u32,
72    /// Source total allocation blocks (informational).
73    pub total_blocks: u32,
74    pub file_count: u32,
75    pub folder_count: u32,
76    /// First 1024 bytes of the partition. Always zero on macOS-formatted
77    /// volumes; copied verbatim by clone for round-trip parity.
78    pub boot_blocks: [u8; 1024],
79    /// Source `vh.catalog_file.total_blocks`. The streamed defrag planner
80    /// uses this (with a safety margin) to size the target's catalog
81    /// B-tree so leaf splits don't run out of free nodes mid-emit.
82    pub catalog_total_blocks: u32,
83    /// Source `vh.extents_file.total_blocks`. Inherited as a ceiling for
84    /// the target's extents-overflow file size; the streamed target packs
85    /// files contiguously and typically needs less.
86    pub extents_total_blocks: u32,
87    /// Source `vh.attributes_file.total_blocks`. Zero on volumes without
88    /// xattrs; non-zero values flow through to the target's attributes
89    /// B-tree allocation.
90    pub attributes_total_blocks: u32,
91}
92
93/// Per-directory metadata captured from a `kHFSPlusFolderRecord`.
94#[derive(Debug, Clone)]
95pub struct SourceDirSnapshot {
96    pub name: String,
97    /// Original UTF-16BE name code units. Kept so the target writer can
98    /// re-key the catalog with byte-identical names (UTF-8 round-tripping
99    /// can lose detail on some Unicode forms).
100    pub name_utf16: Vec<u16>,
101    /// Source CNID (`folderID`). Targets get fresh CNIDs so this is for
102    /// building the source→target CNID map only.
103    pub cnid: u32,
104    pub parent_cnid: u32,
105    /// Number of immediate children (directly from the source record).
106    pub valence: u32,
107    /// `flags` field (offset 2 of the folder record).
108    pub flags: u16,
109    /// `userInfo` — 16 bytes of FolderInfo (`DInfo`).
110    pub finder_info: [u8; 16],
111    /// `finderInfo` — 16 bytes of ExtendedFolderInfo (`DXInfo`).
112    pub extended_finder_info: [u8; 16],
113    /// `permissions` — 16 bytes of `HFSPlusBSDInfo` (zeros on most volumes).
114    pub bsd_info: [u8; 16],
115    pub create_date: u32,
116    pub content_mod_date: u32,
117    pub attribute_mod_date: u32,
118    pub access_date: u32,
119    pub backup_date: u32,
120    pub text_encoding: u32,
121    /// Extended attributes attached to this CNID. `XattrRecord::name` holds
122    /// the key (e.g. `com.apple.FinderInfo`); `XattrRecord::kind` is the
123    /// inline / fork / extents payload. Empty vector when the volume has no
124    /// attributes file or no records for this CNID. `pub(crate)` because
125    /// `XattrRecord` is itself crate-private; promote both together if a
126    /// non-test external consumer ever needs to read them.
127    pub(crate) xattrs: Vec<XattrRecord>,
128    /// `true` for folders that live under the source's
129    /// `.HFS+ Private Directory Data\r` directory whose name parses as
130    /// `dir_<N>` — i.e. directory-hardlink inodes. Replay must emit them
131    /// through `create_dir_hardlink_inode` rather than as ordinary
132    /// directories in the user-visible tree.
133    pub is_dir_inode: bool,
134    /// Source iNodeNum (decoded from the `dir_<N>` folder name) for
135    /// directory-inode rows; `None` for ordinary folders.
136    pub inode_num: Option<u32>,
137}
138
139/// Per-file metadata captured from a `kHFSPlusFileRecord`.
140#[derive(Debug, Clone)]
141pub struct SourceFileSnapshot {
142    pub name: String,
143    pub name_utf16: Vec<u16>,
144    /// Source CNID (`fileID`).
145    pub cnid: u32,
146    pub parent_cnid: u32,
147    pub flags: u16,
148    /// `userInfo` — 16 bytes of FileInfo (`FInfo`). `fdType` at byte 0 and
149    /// `fdCreator` at byte 4 are the type/creator codes; `fdFlags` at
150    /// byte 8 is the Finder flags word (0x8000 = `kIsAlias`).
151    pub finder_info: [u8; 16],
152    /// `finderInfo` — 16 bytes of ExtendedFileInfo (`FXInfo`).
153    pub extended_finder_info: [u8; 16],
154    /// `permissions` — 16 bytes of `HFSPlusBSDInfo` (zeros on most volumes).
155    pub bsd_info: [u8; 16],
156    pub create_date: u32,
157    pub content_mod_date: u32,
158    pub attribute_mod_date: u32,
159    pub access_date: u32,
160    pub backup_date: u32,
161    pub text_encoding: u32,
162    /// Raw 80-byte `HFSPlusForkData` for the data fork (8 inline extents +
163    /// logical/total/clump). Resolved into a streaming reader via
164    /// `HfsPlusFilesystem::fork_stream_reader` at clone time so the bytes
165    /// never have to materialise in RAM.
166    pub data_fork_raw: [u8; 80],
167    /// Raw 80-byte `HFSPlusForkData` for the resource fork.
168    pub rsrc_fork_raw: [u8; 80],
169    /// Logical data-fork size in bytes (mirror of bytes 0..8 of
170    /// `data_fork_raw`, broken out for convenience).
171    pub data_fork_size: u64,
172    /// Logical resource-fork size in bytes.
173    pub rsrc_fork_size: u64,
174    /// Extended attributes attached to this CNID. See
175    /// [`SourceDirSnapshot::xattrs`] for layout details.
176    pub(crate) xattrs: Vec<XattrRecord>,
177    /// `true` for files that live under the source's `HFS+ Private Data`
178    /// directory whose name parses as `iNode<N>` — i.e. file-hardlink
179    /// inodes. Replay must emit them through `create_hardlink_inode`
180    /// rather than as ordinary files in the user-visible tree, and the
181    /// hardlink stubs that reference them carry the source CNID stored
182    /// here. Always `false` until [`SourceCatalogSnapshot::capture`]
183    /// resolves the private dir.
184    pub is_inode: bool,
185    /// Source iNodeNum (decoded from the `iNode<N>` filename) for inode
186    /// rows; `None` for ordinary files. Carried so the replay path can
187    /// emit each hardlink stub with the same iNodeNum the inode itself
188    /// will be created under on the target.
189    pub inode_num: Option<u32>,
190}
191
192/// One file-hardlink stub captured from the source catalog. Replay creates
193/// `inode_count` inodes once and N stub rows pointing at them.
194#[derive(Debug, Clone)]
195pub struct SourceHardlinkSnapshot {
196    /// Stub name visible in the user directory.
197    pub name: String,
198    pub name_utf16: Vec<u16>,
199    /// Source CNID of the stub catalog row (not the inode).
200    pub cnid: u32,
201    /// User-visible parent CNID.
202    pub parent_cnid: u32,
203    /// Source CNID of the inode this stub references. The inode appears
204    /// in `SourceCatalogSnapshot::files` with `is_inode = true` and the
205    /// matching `cnid`.
206    pub inode_source_cnid: u32,
207    /// Original iNodeNum (`bsdInfo.special` u32). Replay can re-use this
208    /// number directly since target CNIDs differ from source CNIDs.
209    pub inode_num: u32,
210    pub flags: u16,
211    pub finder_info: [u8; 16],
212    pub extended_finder_info: [u8; 16],
213    pub bsd_info: [u8; 16],
214    pub create_date: u32,
215    pub content_mod_date: u32,
216    pub attribute_mod_date: u32,
217    pub access_date: u32,
218    pub backup_date: u32,
219    pub text_encoding: u32,
220    pub(crate) xattrs: Vec<XattrRecord>,
221}
222
223impl SourceFileSnapshot {
224    /// Decoded type code (FInfo bytes 0..4) as a 4-character string.
225    pub fn type_code(&self) -> String {
226        decode_fourcc(&self.finder_info[0..4])
227    }
228
229    /// Decoded creator code (FInfo bytes 4..8) as a 4-character string.
230    pub fn creator_code(&self) -> String {
231        decode_fourcc(&self.finder_info[4..8])
232    }
233}
234
235/// One directory-hardlink stub captured from the source catalog. The stub
236/// is encoded as a FILE record on disk; replay re-emits it the same way
237/// (FInfo `fdrp`/`MACS` plus `bsdInfo.special = inode_num`).
238#[derive(Debug, Clone)]
239pub struct SourceDirHardlinkSnapshot {
240    pub name: String,
241    pub name_utf16: Vec<u16>,
242    pub cnid: u32,
243    pub parent_cnid: u32,
244    /// Source CNID of the directory inode this stub references. The
245    /// inode appears in `SourceCatalogSnapshot::dirs` with
246    /// `is_dir_inode = true` and the matching `cnid`.
247    pub inode_dir_source_cnid: u32,
248    pub inode_num: u32,
249    pub flags: u16,
250    pub finder_info: [u8; 16],
251    pub extended_finder_info: [u8; 16],
252    pub bsd_info: [u8; 16],
253    pub create_date: u32,
254    pub content_mod_date: u32,
255    pub attribute_mod_date: u32,
256    pub access_date: u32,
257    pub backup_date: u32,
258    pub text_encoding: u32,
259    pub(crate) xattrs: Vec<XattrRecord>,
260}
261
262/// Full capture of a source HFS+/HFSX volume: volume metadata plus every
263/// directory and file record. Catalog *thread* records are not captured —
264/// the target writer recreates them from scratch.
265#[derive(Debug, Clone)]
266pub struct SourceCatalogSnapshot {
267    pub volume: SourceVolumeSnapshot,
268    pub dirs: Vec<SourceDirSnapshot>,
269    /// Every captured file row, both user-visible files and the per-inode
270    /// `iNode<N>` rows under `HFS+ Private Data`. Inodes carry
271    /// `is_inode = true` and an `inode_num`; replay must emit them through
272    /// the dedicated hardlink-inode helper rather than as user-tree files.
273    pub files: Vec<SourceFileSnapshot>,
274    /// Hardlink stub rows. Each references one `is_inode=true` entry in
275    /// `files` by source CNID.
276    pub hardlinks: Vec<SourceHardlinkSnapshot>,
277    /// Directory-hardlink stub rows. Each references one
278    /// `is_dir_inode=true` entry in `dirs` by source CNID.
279    pub dir_hardlinks: Vec<SourceDirHardlinkSnapshot>,
280}
281
282impl SourceVolumeSnapshot {
283    /// Capture volume-level metadata from an opened source filesystem. The
284    /// source is read but never mutated.
285    pub fn capture<R: Read + Seek>(fs: &mut HfsPlusFilesystem<R>) -> Result<Self, FilesystemError> {
286        let boot_blocks = fs.read_boot_blocks()?;
287        Ok(SourceVolumeSnapshot {
288            label: fs.label().to_string(),
289            signature: fs.signature(),
290            attributes: fs.vh_attributes(),
291            create_date: fs.vh_create_date(),
292            modify_date: fs.vh_modify_date(),
293            backup_date: fs.vh_backup_date(),
294            checked_date: fs.vh_checked_date(),
295            finder_info: fs.vh_finder_info(),
296            block_size: fs.block_size(),
297            total_blocks: fs.total_blocks(),
298            file_count: fs.vh_file_count(),
299            folder_count: fs.vh_folder_count(),
300            boot_blocks,
301            catalog_total_blocks: fs.vh_catalog_total_blocks(),
302            extents_total_blocks: fs.vh_extents_total_blocks(),
303            attributes_total_blocks: fs.vh_attributes_total_blocks(),
304        })
305    }
306}
307
308impl SourceCatalogSnapshot {
309    /// Capture volume + every directory + every file record from the source
310    /// catalog. Walks the leaf-node chain of the catalog B-tree (skipping
311    /// thread records) and reads each file's data + resource fork into the
312    /// snapshot. Index nodes are ignored.
313    ///
314    /// File hardlinks (Step 17) are captured into a separate
315    /// [`SourceHardlinkSnapshot`] list and the inode rows under
316    /// `\0\0\0\0HFS+ Private Data` are flagged with `is_inode = true`.
317    /// Directory hardlinks (Step 18) still trip the `Unsupported` guard.
318    pub fn capture<R: Read + Seek>(fs: &mut HfsPlusFilesystem<R>) -> Result<Self, FilesystemError> {
319        let volume = SourceVolumeSnapshot::capture(fs)?;
320
321        let mut dirs: Vec<SourceDirSnapshot> = Vec::new();
322        let mut files: Vec<SourceFileSnapshot> = Vec::new();
323        let mut pending_forks: Vec<([u8; 80], [u8; 80])> = Vec::new();
324        let mut pending_links: Vec<PendingHardlink> = Vec::new();
325        let mut pending_dir_links: Vec<PendingDirHardlink> = Vec::new();
326        let mut walk_err: Option<FilesystemError> = None;
327        {
328            let catalog = fs.catalog_data();
329            let node_size = fs.catalog_node_size();
330            let first_leaf = fs.catalog_first_leaf();
331            walk_leaf_records(
332                catalog,
333                first_leaf,
334                node_size,
335                |_node_idx, _rec_idx, _abs_off, rec| -> Option<()> {
336                    match parse_catalog_record(rec) {
337                        Ok(ParsedRecord::Folder(d)) => {
338                            dirs.push(d);
339                            None
340                        }
341                        Ok(ParsedRecord::File {
342                            file,
343                            data_fork,
344                            rsrc_fork,
345                        }) => {
346                            files.push(file);
347                            pending_forks.push((data_fork, rsrc_fork));
348                            None
349                        }
350                        Ok(ParsedRecord::Hardlink(p)) => {
351                            pending_links.push(p);
352                            None
353                        }
354                        Ok(ParsedRecord::DirHardlink(p)) => {
355                            pending_dir_links.push(p);
356                            None
357                        }
358                        Ok(ParsedRecord::Thread) | Ok(ParsedRecord::Skip) => None,
359                        Err(e) => {
360                            walk_err = Some(e);
361                            Some(())
362                        }
363                    }
364                },
365            );
366        }
367        if let Some(e) = walk_err {
368            return Err(e);
369        }
370
371        // Resolve the source's HFS+ Private Data dir CNID and flag every
372        // captured file whose parent matches and whose name is `iNode<N>`
373        // as an inode. Build the iNodeNum -> source CNID map for the
374        // hardlink resolution pass below.
375        let private_name = hfsplus_private_dir_name();
376        let dir_private_name = hfsplus_dir_private_dir_name();
377        let private_cnid = dirs.iter().find_map(|d| {
378            if d.name == private_name {
379                Some(d.cnid)
380            } else {
381                None
382            }
383        });
384        let dir_private_cnid = dirs.iter().find_map(|d| {
385            if d.name == dir_private_name {
386                Some(d.cnid)
387            } else {
388                None
389            }
390        });
391        let mut inode_num_to_source_cnid: std::collections::HashMap<u32, u32> =
392            std::collections::HashMap::new();
393        let mut dir_inode_num_to_source_cnid: std::collections::HashMap<u32, u32> =
394            std::collections::HashMap::new();
395        if let Some(pcnid) = dir_private_cnid {
396            for d in dirs.iter_mut() {
397                if d.parent_cnid != pcnid {
398                    continue;
399                }
400                let Some(rest) = d.name.strip_prefix("dir_") else {
401                    continue;
402                };
403                let Ok(num) = rest.parse::<u32>() else {
404                    continue;
405                };
406                d.is_dir_inode = true;
407                d.inode_num = Some(num);
408                dir_inode_num_to_source_cnid.insert(num, d.cnid);
409            }
410        }
411        if let Some(pcnid) = private_cnid {
412            for f in files.iter_mut() {
413                if f.parent_cnid != pcnid {
414                    continue;
415                }
416                let Some(rest) = f.name.strip_prefix("iNode") else {
417                    continue;
418                };
419                let Ok(num) = rest.parse::<u32>() else {
420                    continue;
421                };
422                f.is_inode = true;
423                f.inode_num = Some(num);
424                inode_num_to_source_cnid.insert(num, f.cnid);
425            }
426        }
427
428        // Resolve pending hardlink stubs against the inode map. Stubs whose
429        // iNode<N> is missing aren't necessarily corruption: some pre-Time-
430        // Machine HFS+ volumes have files tagged with the hardlink FInfo
431        // signature (`hlnk`/`hfs+`) without the corresponding `HFS+ Private
432        // Data` iNode entry — typically a leftover after a tooling round-
433        // trip. Demote them to ordinary files (using the captured fork
434        // bytes) so the clone preserves the source verbatim.
435        let mut hardlinks: Vec<SourceHardlinkSnapshot> = Vec::with_capacity(pending_links.len());
436        for p in pending_links {
437            let Some(inode_source_cnid) = inode_num_to_source_cnid.get(&p.inode_num).copied()
438            else {
439                files.push(SourceFileSnapshot {
440                    name: p.name,
441                    name_utf16: p.name_utf16,
442                    cnid: p.cnid,
443                    parent_cnid: p.parent_cnid,
444                    flags: p.flags,
445                    finder_info: p.finder_info,
446                    extended_finder_info: p.extended_finder_info,
447                    bsd_info: p.bsd_info,
448                    create_date: p.create_date,
449                    content_mod_date: p.content_mod_date,
450                    attribute_mod_date: p.attribute_mod_date,
451                    access_date: p.access_date,
452                    backup_date: p.backup_date,
453                    text_encoding: p.text_encoding,
454                    data_fork_raw: p.data_fork_raw,
455                    rsrc_fork_raw: p.rsrc_fork_raw,
456                    data_fork_size: p.data_fork_size,
457                    rsrc_fork_size: p.rsrc_fork_size,
458                    xattrs: Vec::new(),
459                    is_inode: false,
460                    inode_num: None,
461                });
462                continue;
463            };
464            hardlinks.push(SourceHardlinkSnapshot {
465                name: p.name,
466                name_utf16: p.name_utf16,
467                cnid: p.cnid,
468                parent_cnid: p.parent_cnid,
469                inode_source_cnid,
470                inode_num: p.inode_num,
471                flags: p.flags,
472                finder_info: p.finder_info,
473                extended_finder_info: p.extended_finder_info,
474                bsd_info: p.bsd_info,
475                create_date: p.create_date,
476                content_mod_date: p.content_mod_date,
477                attribute_mod_date: p.attribute_mod_date,
478                access_date: p.access_date,
479                backup_date: p.backup_date,
480                text_encoding: p.text_encoding,
481                xattrs: Vec::new(),
482            });
483        }
484
485        let mut dir_hardlinks: Vec<SourceDirHardlinkSnapshot> =
486            Vec::with_capacity(pending_dir_links.len());
487        for p in pending_dir_links {
488            let Some(inode_dir_source_cnid) =
489                dir_inode_num_to_source_cnid.get(&p.inode_num).copied()
490            else {
491                // No matching dir_<N> in .HFS+ Private Directory Data\r —
492                // this isn't a real HFS+ directory hardlink, just an
493                // ordinary Mac OS Finder folder alias that happens to use
494                // the same FInfo type/creator (`fdrp`/`MACS`). Demote back
495                // into the regular file list, preserving the captured
496                // fork bytes verbatim so the alias survives the clone
497                // intact.
498                files.push(SourceFileSnapshot {
499                    name: p.name,
500                    name_utf16: p.name_utf16,
501                    cnid: p.cnid,
502                    parent_cnid: p.parent_cnid,
503                    flags: p.flags,
504                    finder_info: p.finder_info,
505                    extended_finder_info: p.extended_finder_info,
506                    bsd_info: p.bsd_info,
507                    create_date: p.create_date,
508                    content_mod_date: p.content_mod_date,
509                    attribute_mod_date: p.attribute_mod_date,
510                    access_date: p.access_date,
511                    backup_date: p.backup_date,
512                    text_encoding: p.text_encoding,
513                    data_fork_raw: p.data_fork_raw,
514                    rsrc_fork_raw: p.rsrc_fork_raw,
515                    data_fork_size: p.data_fork_size,
516                    rsrc_fork_size: p.rsrc_fork_size,
517                    xattrs: Vec::new(),
518                    is_inode: false,
519                    inode_num: None,
520                });
521                continue;
522            };
523            dir_hardlinks.push(SourceDirHardlinkSnapshot {
524                name: p.name,
525                name_utf16: p.name_utf16,
526                cnid: p.cnid,
527                parent_cnid: p.parent_cnid,
528                inode_dir_source_cnid,
529                inode_num: p.inode_num,
530                flags: p.flags,
531                finder_info: p.finder_info,
532                extended_finder_info: p.extended_finder_info,
533                bsd_info: p.bsd_info,
534                create_date: p.create_date,
535                content_mod_date: p.content_mod_date,
536                attribute_mod_date: p.attribute_mod_date,
537                access_date: p.access_date,
538                backup_date: p.backup_date,
539                text_encoding: p.text_encoding,
540                xattrs: Vec::new(),
541            });
542        }
543
544        // Stash the raw fork records on each file snapshot so the clone
545        // pipeline can stream forks block-by-block instead of buffering
546        // them. Then load xattrs (small, bounded by node size).
547        for (file, (data_fork, rsrc_fork)) in files.iter_mut().zip(pending_forks) {
548            file.data_fork_size = BigEndian::read_u64(&data_fork[0..8]);
549            file.rsrc_fork_size = BigEndian::read_u64(&rsrc_fork[0..8]);
550            file.data_fork_raw = data_fork;
551            file.rsrc_fork_raw = rsrc_fork;
552            file.xattrs = fs.list_xattrs(file.cnid)?;
553        }
554        for dir in dirs.iter_mut() {
555            dir.xattrs = fs.list_xattrs(dir.cnid)?;
556        }
557        for link in hardlinks.iter_mut() {
558            link.xattrs = fs.list_xattrs(link.cnid)?;
559        }
560        for link in dir_hardlinks.iter_mut() {
561            link.xattrs = fs.list_xattrs(link.cnid)?;
562        }
563
564        Ok(SourceCatalogSnapshot {
565            volume,
566            dirs,
567            files,
568            hardlinks,
569            dir_hardlinks,
570        })
571    }
572}
573
574/// Outcome of parsing a single catalog leaf record. `Skip` covers index /
575/// malformed records that we silently drop; `Thread` is the (parent, "")
576/// or (parent, name) thread record we don't capture (target rebuilds them).
577enum ParsedRecord {
578    Folder(SourceDirSnapshot),
579    File {
580        file: SourceFileSnapshot,
581        data_fork: [u8; 80],
582        rsrc_fork: [u8; 80],
583    },
584    Hardlink(PendingHardlink),
585    DirHardlink(PendingDirHardlink),
586    Thread,
587    Skip,
588}
589
590/// Catalog file row identified as a hardlink stub during the first pass.
591/// Held until [`SourceCatalogSnapshot::capture`] resolves the private dir
592/// and links each stub to its captured inode.
593struct PendingHardlink {
594    name: String,
595    name_utf16: Vec<u16>,
596    cnid: u32,
597    parent_cnid: u32,
598    inode_num: u32,
599    flags: u16,
600    finder_info: [u8; 16],
601    extended_finder_info: [u8; 16],
602    bsd_info: [u8; 16],
603    create_date: u32,
604    content_mod_date: u32,
605    attribute_mod_date: u32,
606    access_date: u32,
607    backup_date: u32,
608    text_encoding: u32,
609    /// Raw forks captured from the catalog record. Mirrors
610    /// `PendingDirHardlink`'s field — used to demote stubs back to
611    /// regular files when no matching `iNode<N>` exists in the source's
612    /// `HFS+ Private Data` directory.
613    data_fork_raw: [u8; 80],
614    rsrc_fork_raw: [u8; 80],
615    data_fork_size: u64,
616    rsrc_fork_size: u64,
617}
618
619/// Pending directory-hardlink stub. Same shape as `PendingHardlink` but
620/// resolved against the dir-inode map instead of the file-inode map.
621struct PendingDirHardlink {
622    name: String,
623    name_utf16: Vec<u16>,
624    cnid: u32,
625    parent_cnid: u32,
626    inode_num: u32,
627    flags: u16,
628    finder_info: [u8; 16],
629    extended_finder_info: [u8; 16],
630    bsd_info: [u8; 16],
631    create_date: u32,
632    content_mod_date: u32,
633    attribute_mod_date: u32,
634    access_date: u32,
635    backup_date: u32,
636    text_encoding: u32,
637    /// Raw forks captured from the catalog record. Only used when the
638    /// pending stub turns out to be a normal Mac OS Finder folder alias
639    /// (FInfo `fdrp`/`MACS`) rather than a true HFS+ directory hardlink —
640    /// in that case the resolver demotes this entry back into a regular
641    /// `SourceFileSnapshot` and needs the fork bytes verbatim. For real
642    /// dir-hardlink stubs the forks are conventionally zero and ignored.
643    data_fork_raw: [u8; 80],
644    rsrc_fork_raw: [u8; 80],
645    data_fork_size: u64,
646    rsrc_fork_size: u64,
647}
648
649/// Build the source's `.HFS+ Private Directory Data\r` directory name.
650fn hfsplus_dir_private_dir_name() -> String {
651    let mut s = String::with_capacity(28 + 1);
652    s.push_str(".HFS+ Private Directory Data");
653    s.push('\u{D}');
654    s
655}
656
657/// Build the source's `\0\0\0\0HFS+ Private Data` directory name.
658/// Mirrors the helper in `hfsplus.rs` (which is module-private there).
659fn hfsplus_private_dir_name() -> String {
660    let mut s = String::with_capacity(4 + 17);
661    for _ in 0..4 {
662        s.push('\u{0}');
663    }
664    s.push_str("HFS+ Private Data");
665    s
666}
667
668fn parse_catalog_record(rec: &[u8]) -> Result<ParsedRecord, FilesystemError> {
669    if rec.len() < 4 {
670        return Ok(ParsedRecord::Skip);
671    }
672    let key_len = BigEndian::read_u16(&rec[0..2]) as usize;
673    if key_len < 6 || 2 + key_len > rec.len() {
674        return Ok(ParsedRecord::Skip);
675    }
676    let key = &rec[2..2 + key_len];
677    let parent_cnid = BigEndian::read_u32(&key[0..4]);
678    let name_length = BigEndian::read_u16(&key[4..6]) as usize;
679    if 6 + name_length * 2 > key.len() {
680        return Ok(ParsedRecord::Skip);
681    }
682    let name_bytes = &key[6..6 + name_length * 2];
683    let name_utf16: Vec<u16> = (0..name_length)
684        .map(|i| BigEndian::read_u16(&name_bytes[i * 2..i * 2 + 2]))
685        .collect();
686    let name = String::from_utf16(&name_utf16).unwrap_or_else(|_| {
687        name_utf16
688            .iter()
689            .map(|&u| char::from_u32(u as u32).unwrap_or('?'))
690            .collect()
691    });
692
693    // Record body follows the key, padded to even offset.
694    let mut body_start = 2 + key_len;
695    if !body_start.is_multiple_of(2) {
696        body_start += 1;
697    }
698    if body_start + 2 > rec.len() {
699        return Ok(ParsedRecord::Skip);
700    }
701    let body = &rec[body_start..];
702    let record_type = BigEndian::read_i16(&body[0..2]);
703
704    match record_type {
705        CATALOG_FOLDER => {
706            if body.len() < 88 {
707                return Ok(ParsedRecord::Skip);
708            }
709            let flags = BigEndian::read_u16(&body[2..4]);
710            let valence = BigEndian::read_u32(&body[4..8]);
711            let cnid = BigEndian::read_u32(&body[8..12]);
712            let create_date = BigEndian::read_u32(&body[12..16]);
713            let content_mod_date = BigEndian::read_u32(&body[16..20]);
714            let attribute_mod_date = BigEndian::read_u32(&body[20..24]);
715            let access_date = BigEndian::read_u32(&body[24..28]);
716            let backup_date = BigEndian::read_u32(&body[28..32]);
717            let mut bsd_info = [0u8; 16];
718            bsd_info.copy_from_slice(&body[32..48]);
719            let mut finder_info = [0u8; 16];
720            finder_info.copy_from_slice(&body[48..64]);
721            let mut extended_finder_info = [0u8; 16];
722            extended_finder_info.copy_from_slice(&body[64..80]);
723            let text_encoding = BigEndian::read_u32(&body[80..84]);
724
725            // Directory hardlinks are encoded as FILE records (handled in
726            // the CATALOG_FILE branch below), not as FOLDER records — so
727            // there is no fdrp/MACS check here. The folder-side flag
728            // `is_dir_inode` is set in capture's second pass for folders
729            // living under `.HFS+ Private Directory Data\r`.
730            Ok(ParsedRecord::Folder(SourceDirSnapshot {
731                name,
732                name_utf16,
733                cnid,
734                parent_cnid,
735                valence,
736                flags,
737                finder_info,
738                extended_finder_info,
739                bsd_info,
740                create_date,
741                content_mod_date,
742                attribute_mod_date,
743                access_date,
744                backup_date,
745                text_encoding,
746                xattrs: Vec::new(),
747                is_dir_inode: false,
748                inode_num: None,
749            }))
750        }
751        CATALOG_FILE => {
752            if body.len() < 248 {
753                return Ok(ParsedRecord::Skip);
754            }
755            let flags = BigEndian::read_u16(&body[2..4]);
756            // body[4..8] = reserved1
757            let cnid = BigEndian::read_u32(&body[8..12]);
758            let create_date = BigEndian::read_u32(&body[12..16]);
759            let content_mod_date = BigEndian::read_u32(&body[16..20]);
760            let attribute_mod_date = BigEndian::read_u32(&body[20..24]);
761            let access_date = BigEndian::read_u32(&body[24..28]);
762            let backup_date = BigEndian::read_u32(&body[28..32]);
763            let mut bsd_info = [0u8; 16];
764            bsd_info.copy_from_slice(&body[32..48]);
765            let mut finder_info = [0u8; 16];
766            finder_info.copy_from_slice(&body[48..64]);
767            let mut extended_finder_info = [0u8; 16];
768            extended_finder_info.copy_from_slice(&body[64..80]);
769            let text_encoding = BigEndian::read_u32(&body[80..84]);
770            // body[84..88] = reserved2
771            let mut data_fork = [0u8; 80];
772            data_fork.copy_from_slice(&body[88..168]);
773            let mut rsrc_fork = [0u8; 80];
774            rsrc_fork.copy_from_slice(&body[168..248]);
775
776            // File hardlink stub: FInfo `hlnk`/`hfs+`. Route into the
777            // pending-hardlinks list so the second pass can resolve the
778            // iNodeNum (`bsdInfo.special`, body offset 44..48) against the
779            // captured iNode<N> rows under HFS+ Private Data.
780            if &finder_info[0..4] == HARDLINK_FILE_TYPE
781                && &finder_info[4..8] == HARDLINK_FILE_CREATOR
782            {
783                let inode_num = BigEndian::read_u32(&body[44..48]);
784                let data_fork_size = BigEndian::read_u64(&data_fork[0..8]);
785                let rsrc_fork_size = BigEndian::read_u64(&rsrc_fork[0..8]);
786                return Ok(ParsedRecord::Hardlink(PendingHardlink {
787                    name,
788                    name_utf16,
789                    cnid,
790                    parent_cnid,
791                    inode_num,
792                    flags,
793                    finder_info,
794                    extended_finder_info,
795                    bsd_info,
796                    create_date,
797                    content_mod_date,
798                    attribute_mod_date,
799                    access_date,
800                    backup_date,
801                    text_encoding,
802                    data_fork_raw: data_fork,
803                    rsrc_fork_raw: rsrc_fork,
804                    data_fork_size,
805                    rsrc_fork_size,
806                }));
807            }
808
809            // Directory hardlink stub: FILE record with FInfo `fdrp`/`MACS`.
810            // The `dir_<N>` directory inode is captured via the FOLDER
811            // branch and flagged with `is_dir_inode=true` in capture's
812            // second pass.
813            //
814            // Caveat: `fdrp`/`MACS` is also the FInfo signature for
815            // ordinary Mac OS Finder folder *aliases*. Pre-Time-Machine
816            // HFS+ volumes (e.g. wrapped Mac OS 9 disks) use this
817            // type/creator for plain Finder aliases without ever creating
818            // `.HFS+ Private Directory Data\r`. Routing such files into
819            // the dir-hardlink resolver would later bail with "no matching
820            // entry exists in .HFS+ Private Directory Data". The resolver
821            // checks for the private-dir + a matching `dir_<N>` and
822            // demotes any stubs that don't resolve back into
823            // `SourceFileSnapshot`s — keep the raw forks here so that
824            // demotion is lossless.
825            if &finder_info[0..4] == HARDLINK_DIR_TYPE && &finder_info[4..8] == HARDLINK_DIR_CREATOR
826            {
827                let inode_num = BigEndian::read_u32(&body[44..48]);
828                let data_fork_size = BigEndian::read_u64(&data_fork[0..8]);
829                let rsrc_fork_size = BigEndian::read_u64(&rsrc_fork[0..8]);
830                return Ok(ParsedRecord::DirHardlink(PendingDirHardlink {
831                    name,
832                    name_utf16,
833                    cnid,
834                    parent_cnid,
835                    inode_num,
836                    flags,
837                    finder_info,
838                    extended_finder_info,
839                    bsd_info,
840                    create_date,
841                    content_mod_date,
842                    attribute_mod_date,
843                    access_date,
844                    backup_date,
845                    text_encoding,
846                    data_fork_raw: data_fork,
847                    rsrc_fork_raw: rsrc_fork,
848                    data_fork_size,
849                    rsrc_fork_size,
850                }));
851            }
852
853            Ok(ParsedRecord::File {
854                file: SourceFileSnapshot {
855                    name,
856                    name_utf16,
857                    cnid,
858                    parent_cnid,
859                    flags,
860                    finder_info,
861                    extended_finder_info,
862                    bsd_info,
863                    create_date,
864                    content_mod_date,
865                    attribute_mod_date,
866                    access_date,
867                    backup_date,
868                    text_encoding,
869                    data_fork_raw: [0u8; 80],
870                    rsrc_fork_raw: [0u8; 80],
871                    data_fork_size: 0,
872                    rsrc_fork_size: 0,
873                    xattrs: Vec::new(),
874                    is_inode: false,
875                    inode_num: None,
876                },
877                data_fork,
878                rsrc_fork,
879            })
880        }
881        // 3 = folder thread, 4 = file thread; recreated by target writer.
882        3 | 4 => Ok(ParsedRecord::Thread),
883        _ => Ok(ParsedRecord::Skip),
884    }
885}
886
887/// Decode a 4-byte Mac OS type/creator code to a printable string. Mirrors
888/// the helper in `hfsplus.rs` (kept private there).
889fn decode_fourcc(data: &[u8]) -> String {
890    data.iter()
891        .map(|&b| {
892            if b.is_ascii_graphic() || b == b' ' {
893                b as char
894            } else {
895                '.'
896            }
897        })
898        .collect()
899}
900
901/// Outcome of [`clone_hfsplus_volume`]. Counts mirror the source side; any
902/// non-fatal per-entry skips end up in `skipped` rather than the result.
903#[derive(Debug, Default, Clone)]
904pub struct HfsPlusCloneReport {
905    pub files_copied: u64,
906    pub dirs_copied: u64,
907    pub data_bytes_copied: u64,
908    pub rsrc_bytes_copied: u64,
909    pub xattrs_copied: u64,
910    pub hardlinks_copied: u64,
911    pub dir_hardlinks_copied: u64,
912    /// (path, reason) for entries the cloner deliberately skipped — e.g.
913    /// fork-style xattrs that the target's xattr writer can't yet emit.
914    pub skipped: Vec<(String, String)>,
915}
916
917/// BFS-replay every directory, file, hardlink, and xattr from `source` onto
918/// `target`. Step 21 of `docs/hfsplus_enhancements.md`.
919///
920/// Pre-conditions:
921/// - `target` is freshly built by [`crate::hfsplus::create_blank_hfsplus`]
922///   (case-sensitivity matches `source.is_hfsx()`) and is large enough to
923///   hold every used block in `source`.
924/// - `target.prepare_for_edit()` has been called so the xattr / hardlink
925///   caches are primed.
926/// - `source` is opened read-only; this function never mutates it.
927///
928/// Post-conditions on success:
929/// - Every dir/file from `source` exists at the same path on `target`.
930/// - File and directory hardlinks are reproduced as N catalog stubs over
931///   one inode (not N independent files / N independent directory copies).
932/// - Inline extended attributes round-trip byte-for-byte. Fork-style and
933///   extents-style xattrs are listed in `report.skipped` because the
934///   target's xattr writer rejects non-inline payloads.
935/// - Volume create / modify / backup / checked dates, boot blocks, and
936///   `vh.finder_info` (with CNID remap applied to slots that match a known
937///   source CNID) are mirrored from the source.
938/// - `target.sync_metadata()` has been called.
939pub fn clone_hfsplus_volume<RS, RT>(
940    source: &mut HfsPlusFilesystem<RS>,
941    target: &mut HfsPlusFilesystem<RT>,
942) -> Result<HfsPlusCloneReport, FilesystemError>
943where
944    RS: Read + Seek + Send,
945    RT: Read + Write + Seek + Send,
946{
947    let snapshot = SourceCatalogSnapshot::capture(source)?;
948    let mut report = HfsPlusCloneReport::default();
949
950    // Volume-level metadata staged first; sync_metadata at the end pushes
951    // it to disk along with everything else. modify_date is bumped to
952    // hfs_now() during sync regardless — this stages the source value as
953    // a fallback.
954    target.set_volume_dates(
955        snapshot.volume.create_date,
956        snapshot.volume.modify_date,
957        snapshot.volume.backup_date,
958        snapshot.volume.checked_date,
959    );
960    target.set_boot_blocks(&snapshot.volume.boot_blocks);
961
962    // Index source dirs / files / hardlinks by parent CNID. Inodes (under
963    // the private dirs) are emitted via dedicated helpers, not as user-
964    // visible children, so they bypass this map.
965    let mut dirs_by_parent: HashMap<u32, Vec<&SourceDirSnapshot>> = HashMap::new();
966    for d in &snapshot.dirs {
967        if d.cnid == 2 || d.is_dir_inode {
968            continue;
969        }
970        // Skip the source's private dirs themselves — the target recreates
971        // them on demand inside `create_hardlink_inode` /
972        // `create_dir_hardlink_inode`. Replaying them here would create
973        // duplicates with different CNIDs.
974        if d.parent_cnid == 2
975            && (d.name == hfsplus_private_dir_name() || d.name == hfsplus_dir_private_dir_name())
976        {
977            continue;
978        }
979        dirs_by_parent.entry(d.parent_cnid).or_default().push(d);
980    }
981    let mut files_by_parent: HashMap<u32, Vec<&SourceFileSnapshot>> = HashMap::new();
982    for f in &snapshot.files {
983        if f.is_inode {
984            continue;
985        }
986        files_by_parent.entry(f.parent_cnid).or_default().push(f);
987    }
988    let mut hardlinks_by_parent: HashMap<u32, Vec<&SourceHardlinkSnapshot>> = HashMap::new();
989    for h in &snapshot.hardlinks {
990        hardlinks_by_parent
991            .entry(h.parent_cnid)
992            .or_default()
993            .push(h);
994    }
995    let mut dir_hardlinks_by_parent: HashMap<u32, Vec<&SourceDirHardlinkSnapshot>> = HashMap::new();
996    for h in &snapshot.dir_hardlinks {
997        dir_hardlinks_by_parent
998            .entry(h.parent_cnid)
999            .or_default()
1000            .push(h);
1001    }
1002
1003    // First: emit every file inode and dir inode under the target's
1004    // private dirs so subsequent hardlink stubs find them. We emit each
1005    // inode at its source iNodeNum so the stub's `bsdInfo.special` value
1006    // (inode_num, copied verbatim) keeps pointing at the right thing.
1007    for f in snapshot.files.iter().filter(|f| f.is_inode) {
1008        let inode_num = f.inode_num.expect("inode without inode_num");
1009        let inode_entry = {
1010            let mut data =
1011                source.fork_stream_reader(f.cnid, HFSPLUS_FORK_DATA, &f.data_fork_raw)?;
1012            target.create_hardlink_inode(
1013                inode_num,
1014                &mut data,
1015                f.data_fork_size,
1016                &CreateFileOptions::default(),
1017            )?
1018        };
1019        if f.rsrc_fork_size > 0 {
1020            let mut rsrc =
1021                source.fork_stream_reader(f.cnid, HFSPLUS_FORK_RESOURCE, &f.rsrc_fork_raw)?;
1022            target.write_resource_fork(&inode_entry, &mut rsrc, f.rsrc_fork_size)?;
1023        }
1024        report.data_bytes_copied += f.data_fork_size;
1025        report.rsrc_bytes_copied += f.rsrc_fork_size;
1026        stamp_file_metadata(target, &inode_entry, f, &mut report)?;
1027    }
1028    for d in snapshot.dirs.iter().filter(|d| d.is_dir_inode) {
1029        let inode_num = d.inode_num.expect("dir-inode without inode_num");
1030        let inode_entry =
1031            target.create_dir_hardlink_inode(inode_num, &CreateDirectoryOptions::default())?;
1032        stamp_dir_metadata(target, &inode_entry, d, &mut report)?;
1033    }
1034
1035    // BFS over user-visible directories. Source CNIDs map to the target's
1036    // freshly-assigned CNIDs via `cnid_map`; `entry_map` keeps the
1037    // FileEntry for each so children can be emitted under the right
1038    // parent without re-walking the catalog.
1039    let mut cnid_map: HashMap<u32, u32> = HashMap::new();
1040    cnid_map.insert(2, 2);
1041    let target_root = target.root()?;
1042    let mut entry_map: HashMap<u32, FileEntry> = HashMap::new();
1043    entry_map.insert(2, target_root.clone());
1044
1045    if let Some(root_src) = snapshot.dirs.iter().find(|d| d.cnid == 2) {
1046        stamp_dir_metadata(target, &target_root, root_src, &mut report)?;
1047    }
1048
1049    let mut queue: VecDeque<u32> = VecDeque::new();
1050    queue.push_back(2);
1051
1052    while let Some(src_parent_cnid) = queue.pop_front() {
1053        let parent_entry = entry_map.get(&src_parent_cnid).cloned().ok_or_else(|| {
1054            FilesystemError::InvalidData(format!(
1055                "clone: missing parent entry for source CNID {src_parent_cnid}"
1056            ))
1057        })?;
1058
1059        if let Some(children) = dirs_by_parent.remove(&src_parent_cnid) {
1060            for d in children {
1061                let new_dir = target.create_directory(
1062                    &parent_entry,
1063                    &d.name,
1064                    &CreateDirectoryOptions::default(),
1065                )?;
1066                stamp_dir_metadata(target, &new_dir, d, &mut report)?;
1067                cnid_map.insert(d.cnid, new_dir.location as u32);
1068                entry_map.insert(d.cnid, new_dir);
1069                report.dirs_copied += 1;
1070                queue.push_back(d.cnid);
1071            }
1072        }
1073
1074        if let Some(children) = files_by_parent.remove(&src_parent_cnid) {
1075            for f in children {
1076                let new_file = {
1077                    let mut data =
1078                        source.fork_stream_reader(f.cnid, HFSPLUS_FORK_DATA, &f.data_fork_raw)?;
1079                    target.create_file(
1080                        &parent_entry,
1081                        &f.name,
1082                        &mut data,
1083                        f.data_fork_size,
1084                        &CreateFileOptions {
1085                            type_code: Some(f.type_code()),
1086                            creator_code: Some(f.creator_code()),
1087                            ..Default::default()
1088                        },
1089                    )?
1090                };
1091                report.data_bytes_copied += f.data_fork_size;
1092                if f.rsrc_fork_size > 0 {
1093                    let mut rsrc = source.fork_stream_reader(
1094                        f.cnid,
1095                        HFSPLUS_FORK_RESOURCE,
1096                        &f.rsrc_fork_raw,
1097                    )?;
1098                    target.write_resource_fork(&new_file, &mut rsrc, f.rsrc_fork_size)?;
1099                    report.rsrc_bytes_copied += f.rsrc_fork_size;
1100                }
1101                stamp_file_metadata(target, &new_file, f, &mut report)?;
1102                cnid_map.insert(f.cnid, new_file.location as u32);
1103                report.files_copied += 1;
1104            }
1105        }
1106
1107        if let Some(children) = hardlinks_by_parent.remove(&src_parent_cnid) {
1108            for h in children {
1109                let stub = target.create_hardlink(&parent_entry, &h.name, h.inode_num)?;
1110                replay_xattrs(
1111                    target,
1112                    stub.location as u32,
1113                    &h.xattrs,
1114                    &h.name,
1115                    &mut report,
1116                )?;
1117                report.hardlinks_copied += 1;
1118            }
1119        }
1120
1121        if let Some(children) = dir_hardlinks_by_parent.remove(&src_parent_cnid) {
1122            for h in children {
1123                let stub = target.create_dir_hardlink(&parent_entry, &h.name, h.inode_num)?;
1124                replay_xattrs(
1125                    target,
1126                    stub.location as u32,
1127                    &h.xattrs,
1128                    &h.name,
1129                    &mut report,
1130                )?;
1131                report.dir_hardlinks_copied += 1;
1132            }
1133        }
1134    }
1135
1136    // Volume Finder Info: copy verbatim, remapping any slot whose value
1137    // matches a known source CNID. Slots 6/7 form the volume's Finder ID
1138    // (cross-mount disk identifier) — leave them zeroed so Mac OS treats
1139    // the clone as a fresh volume rather than running Disk First Aid on
1140    // it. Same convention as `clone_hfs_volume`.
1141    let known_source_cnids: HashSet<u32> = cnid_map.keys().copied().collect();
1142    let mut new_finder_info = [0u32; 8];
1143    for (i, slot) in new_finder_info.iter_mut().enumerate() {
1144        if i == 6 || i == 7 {
1145            continue;
1146        }
1147        let src_val = snapshot.volume.finder_info[i];
1148        *slot = if known_source_cnids.contains(&src_val) {
1149            *cnid_map.get(&src_val).unwrap()
1150        } else {
1151            src_val
1152        };
1153    }
1154    target.set_volume_finder_info(new_finder_info);
1155
1156    target.sync_metadata()?;
1157
1158    // Final verification: fsck must be clean. The clone path is the only
1159    // place where a bug in fragmented allocation / B-tree splits is going
1160    // to silently corrupt user data, so it's worth the extra walk.
1161    if let Some(fsck) = target.fsck() {
1162        let result = fsck?;
1163        if !result.errors.is_empty() {
1164            let summary: Vec<String> = result
1165                .errors
1166                .iter()
1167                .take(5)
1168                .map(|i| i.code.clone())
1169                .collect();
1170            return Err(FilesystemError::InvalidData(format!(
1171                "clone target failed fsck: {} error(s); first: [{}]",
1172                result.errors.len(),
1173                summary.join(", "),
1174            )));
1175        }
1176    }
1177
1178    Ok(report)
1179}
1180
1181/// Build a [`RecordMetadata`] from a captured file snapshot.
1182fn metadata_from_file(f: &SourceFileSnapshot) -> RecordMetadata {
1183    RecordMetadata {
1184        create_date: f.create_date,
1185        content_mod_date: f.content_mod_date,
1186        attribute_mod_date: f.attribute_mod_date,
1187        access_date: f.access_date,
1188        backup_date: f.backup_date,
1189        bsd_info: f.bsd_info,
1190        finder_info: f.finder_info,
1191        extended_finder_info: f.extended_finder_info,
1192        text_encoding: f.text_encoding,
1193    }
1194}
1195
1196/// Build a [`RecordMetadata`] from a captured directory snapshot.
1197fn metadata_from_dir(d: &SourceDirSnapshot) -> RecordMetadata {
1198    RecordMetadata {
1199        create_date: d.create_date,
1200        content_mod_date: d.content_mod_date,
1201        attribute_mod_date: d.attribute_mod_date,
1202        access_date: d.access_date,
1203        backup_date: d.backup_date,
1204        bsd_info: d.bsd_info,
1205        finder_info: d.finder_info,
1206        extended_finder_info: d.extended_finder_info,
1207        text_encoding: d.text_encoding,
1208    }
1209}
1210
1211fn stamp_file_metadata<RT>(
1212    target: &mut HfsPlusFilesystem<RT>,
1213    entry: &FileEntry,
1214    f: &SourceFileSnapshot,
1215    report: &mut HfsPlusCloneReport,
1216) -> Result<(), FilesystemError>
1217where
1218    RT: Read + Write + Seek + Send,
1219{
1220    let cnid = entry.location as u32;
1221    target.set_record_metadata(cnid, &metadata_from_file(f))?;
1222    replay_xattrs(target, cnid, &f.xattrs, &f.name, report)?;
1223    Ok(())
1224}
1225
1226fn stamp_dir_metadata<RT>(
1227    target: &mut HfsPlusFilesystem<RT>,
1228    entry: &FileEntry,
1229    d: &SourceDirSnapshot,
1230    report: &mut HfsPlusCloneReport,
1231) -> Result<(), FilesystemError>
1232where
1233    RT: Read + Write + Seek + Send,
1234{
1235    let cnid = entry.location as u32;
1236    target.set_record_metadata(cnid, &metadata_from_dir(d))?;
1237    replay_xattrs(target, cnid, &d.xattrs, &d.name, report)?;
1238    Ok(())
1239}
1240
1241/// Replay a captured CNID's xattrs onto the target. Inline records map
1242/// directly to `set_xattr`; fork-style and extents-continuation records
1243/// are skipped with a `report.skipped` entry until the target's xattr
1244/// writer grows fork-style support.
1245fn replay_xattrs<RT>(
1246    target: &mut HfsPlusFilesystem<RT>,
1247    cnid: u32,
1248    xattrs: &[XattrRecord],
1249    label: &str,
1250    report: &mut HfsPlusCloneReport,
1251) -> Result<(), FilesystemError>
1252where
1253    RT: Read + Write + Seek + Send,
1254{
1255    for rec in xattrs {
1256        match &rec.kind {
1257            XattrKind::Inline(value) => {
1258                target.set_xattr(cnid, &rec.name, value)?;
1259                report.xattrs_copied += 1;
1260            }
1261            XattrKind::Fork(_) | XattrKind::Extents(_) => {
1262                report.skipped.push((
1263                    format!("{label}/xattr:{}", rec.name),
1264                    "fork-style xattr — target only supports inline payloads".into(),
1265                ));
1266            }
1267        }
1268    }
1269    Ok(())
1270}
1271
1272#[cfg(test)]
1273mod tests {
1274    use std::io::Cursor;
1275
1276    use super::*;
1277    use crate::hfsplus::{create_blank_hfsplus, HfsPlusFilesystem};
1278
1279    #[test]
1280    fn capture_blank_volume_only_has_root_dir() {
1281        let mut img = create_blank_hfsplus(32 * 1024 * 1024, 4096, "Empty", false);
1282        let cursor = Cursor::new(&mut img);
1283        let mut fs = HfsPlusFilesystem::open(cursor, 0).expect("open blank");
1284        let snap = SourceCatalogSnapshot::capture(&mut fs).expect("capture");
1285        assert_eq!(snap.volume.label, "Empty");
1286        assert_eq!(snap.volume.block_size, 4096);
1287        // The catalog walk always visits the root folder record
1288        // (parent=1, cnid=2). User-level dirs filter on parent_cnid != 1.
1289        let root = snap
1290            .dirs
1291            .iter()
1292            .find(|d| d.parent_cnid == 1)
1293            .expect("root dir");
1294        assert_eq!(root.cnid, 2);
1295        assert_eq!(root.name, "Empty");
1296        let user_dirs: Vec<_> = snap.dirs.iter().filter(|d| d.parent_cnid != 1).collect();
1297        assert!(
1298            user_dirs.is_empty(),
1299            "blank should have no user dirs, got: {:?}",
1300            user_dirs.iter().map(|d| &d.name).collect::<Vec<_>>()
1301        );
1302    }
1303
1304    #[test]
1305    fn capture_walks_dirs_and_files_and_streams_forks() {
1306        let mut img = create_blank_hfsplus(32 * 1024 * 1024, 4096, "Work", false);
1307        {
1308            let cursor = Cursor::new(&mut img);
1309            let mut fs = HfsPlusFilesystem::open(cursor, 0).expect("open blank");
1310            fs.prepare_for_edit().expect("prepare_for_edit");
1311            let root = fs.root().expect("root entry");
1312            let _docs = fs
1313                .create_directory(&root, "Docs", &CreateDirectoryOptions::default())
1314                .expect("create dir");
1315            let payload: &[u8] = b"hello world\n";
1316            let mut data = std::io::Cursor::new(payload);
1317            fs.create_file(
1318                &root,
1319                "note.txt",
1320                &mut data,
1321                payload.len() as u64,
1322                &CreateFileOptions::default(),
1323            )
1324            .expect("create file");
1325            fs.sync_metadata().expect("sync");
1326        }
1327
1328        let cursor = Cursor::new(&mut img);
1329        let mut fs = HfsPlusFilesystem::open(cursor, 0).expect("re-open");
1330        let snap = SourceCatalogSnapshot::capture(&mut fs).expect("capture");
1331
1332        assert_eq!(snap.volume.label, "Work");
1333        // Root + "Docs". User-only dirs filter on parent_cnid != 1.
1334        let user_dirs: Vec<&SourceDirSnapshot> =
1335            snap.dirs.iter().filter(|d| d.parent_cnid != 1).collect();
1336        assert_eq!(user_dirs.len(), 1, "expected one user directory");
1337        let docs = user_dirs[0];
1338        assert_eq!(docs.name, "Docs");
1339        assert_eq!(docs.parent_cnid, 2);
1340
1341        assert_eq!(snap.files.len(), 1, "expected one user file");
1342        let note = &snap.files[0];
1343        assert_eq!(note.name, "note.txt");
1344        assert_eq!(note.parent_cnid, 2);
1345        assert_eq!(note.data_fork_size, b"hello world\n".len() as u64);
1346        assert_eq!(note.rsrc_fork_size, 0);
1347        // Stream-read the data fork off the source and verify byte-equal.
1348        let cnid = note.cnid;
1349        let raw = note.data_fork_raw;
1350        let mut buf = Vec::new();
1351        fs.fork_stream_reader(cnid, 0x00, &raw)
1352            .expect("fork reader")
1353            .read_to_end(&mut buf)
1354            .expect("read fork");
1355        assert_eq!(buf, b"hello world\n");
1356    }
1357
1358    // The xattr round-trip capture test (Step 14) lives in hfsplus.rs's test
1359    // module since it relies on `make_editable_hfsplus_image_with_inline_xattr`
1360    // to seed an attributes B-tree (a freshly built blank from
1361    // `create_blank_hfsplus` has `attributes_file.logical_size == 0`, so
1362    // `set_xattr` on it would be rejected by `insert_xattr_record`).
1363
1364    #[test]
1365    fn capture_no_xattrs_yields_empty_vec() {
1366        let mut img = create_blank_hfsplus(32 * 1024 * 1024, 4096, "Plain", false);
1367        {
1368            let cursor = Cursor::new(&mut img);
1369            let mut fs = HfsPlusFilesystem::open(cursor, 0).expect("open");
1370            fs.prepare_for_edit().expect("prepare_for_edit");
1371            let root = fs.root().expect("root");
1372            let mut data = std::io::Cursor::new(&b"x"[..]);
1373            fs.create_file(&root, "x.txt", &mut data, 1, &CreateFileOptions::default())
1374                .expect("create");
1375            fs.sync_metadata().expect("sync");
1376        }
1377        let cursor = Cursor::new(&mut img);
1378        let mut fs = HfsPlusFilesystem::open(cursor, 0).expect("re-open");
1379        let snap = SourceCatalogSnapshot::capture(&mut fs).expect("capture");
1380        for f in &snap.files {
1381            assert!(f.xattrs.is_empty(), "file {} has xattrs", f.name);
1382        }
1383        for d in &snap.dirs {
1384            assert!(d.xattrs.is_empty(), "dir {} has xattrs", d.name);
1385        }
1386    }
1387
1388    /// Build a small source HFS+ volume with a directory tree, a file
1389    /// with both forks, and a couple of inline xattrs, then clone it onto
1390    /// a freshly-built target. Verifies catalog metadata, fork bytes,
1391    /// xattrs, and that the report counts match.
1392    #[test]
1393    fn clone_round_trip_basic_tree() {
1394        // Source.
1395        let mut src_img = create_blank_hfsplus(32 * 1024 * 1024, 4096, "Source", false);
1396        let payload: &[u8] = b"contents of note.txt\n";
1397        let rsrc: &[u8] = b"resource fork bytes";
1398        let xval = b"xattr-payload";
1399        {
1400            let cursor = Cursor::new(&mut src_img);
1401            let mut fs = HfsPlusFilesystem::open(cursor, 0).expect("open src");
1402            fs.prepare_for_edit().expect("prepare src");
1403            let root = fs.root().expect("root");
1404            let docs = fs
1405                .create_directory(&root, "Docs", &CreateDirectoryOptions::default())
1406                .expect("mkdir Docs");
1407            let mut data = Cursor::new(payload);
1408            let note = fs
1409                .create_file(
1410                    &docs,
1411                    "note.txt",
1412                    &mut data,
1413                    payload.len() as u64,
1414                    &CreateFileOptions::default(),
1415                )
1416                .expect("create note");
1417            let mut rcur = Cursor::new(rsrc);
1418            fs.write_resource_fork(&note, &mut rcur, rsrc.len() as u64)
1419                .expect("write rsrc");
1420            // The empty blank has attributes_file.logical_size == 0, so
1421            // set_xattr would be rejected. Skip xattr round-trip in this
1422            // basic test; it's covered separately by clone_round_trip_xattrs
1423            // once the attributes file has been seeded.
1424            let _ = xval;
1425            fs.sync_metadata().expect("sync src");
1426        }
1427
1428        // Target.
1429        let mut tgt_img = create_blank_hfsplus(32 * 1024 * 1024, 4096, "Target", false);
1430
1431        // Clone.
1432        let report = {
1433            let src_cur = Cursor::new(&mut src_img);
1434            let mut src_fs = HfsPlusFilesystem::open(src_cur, 0).expect("open src for clone");
1435            let tgt_cur = Cursor::new(&mut tgt_img);
1436            let mut tgt_fs = HfsPlusFilesystem::open(tgt_cur, 0).expect("open tgt");
1437            tgt_fs.prepare_for_edit().expect("prepare tgt");
1438            clone_hfsplus_volume(&mut src_fs, &mut tgt_fs).expect("clone")
1439        };
1440        assert_eq!(report.dirs_copied, 1, "expected 1 dir copied");
1441        assert_eq!(report.files_copied, 1, "expected 1 file copied");
1442        assert_eq!(report.data_bytes_copied, payload.len() as u64);
1443        assert_eq!(report.rsrc_bytes_copied, rsrc.len() as u64);
1444
1445        // Verify the cloned tree.
1446        let cursor = Cursor::new(&mut tgt_img);
1447        let mut fs = HfsPlusFilesystem::open(cursor, 0).expect("re-open tgt");
1448        let root = fs.root().expect("tgt root");
1449        let children = fs.list_directory(&root).expect("list root");
1450        let docs = children
1451            .iter()
1452            .find(|e| e.name == "Docs" && e.is_directory())
1453            .expect("Docs in tgt");
1454        let docs_children = fs.list_directory(docs).expect("list Docs");
1455        let note = docs_children
1456            .iter()
1457            .find(|e| e.name == "note.txt")
1458            .expect("note in tgt");
1459        let read_data = fs.read_file(note, payload.len()).expect("read data");
1460        assert_eq!(read_data, payload);
1461        let mut rsrc_buf: Vec<u8> = Vec::new();
1462        fs.write_resource_fork_to(note, &mut rsrc_buf)
1463            .expect("read rsrc");
1464        assert_eq!(rsrc_buf, rsrc);
1465
1466        // fsck should be clean — clone runs it internally, but re-run
1467        // here too so the test fails loudly if internal verification ever
1468        // gets relaxed.
1469        let result = fs.fsck().expect("fsck supported").expect("fsck ok");
1470        let codes: Vec<&str> = result.errors.iter().map(|e| e.code.as_str()).collect();
1471        assert!(result.is_clean(), "tgt fsck dirty: {codes:?}");
1472    }
1473}