Skip to main content

fs_mac/
hfs_clone.rs

1//! Source-side snapshot types for the classic-HFS reformat-and-copy pipeline.
2//!
3//! Step 2 of docs/hfs_expand_block_size.md. Nothing here mutates the source
4//! volume; these structs exist to capture every byte of metadata that must
5//! survive the copy so later steps can replay it onto a freshly-formatted
6//! target volume.
7
8#[cfg(feature = "rust173-polyfill")]
9use disk_core::rust173::IntIsMultipleOf as _;
10use std::collections::{HashMap, HashSet, VecDeque};
11use std::io::{Read, Seek, Write};
12
13use byteorder::{BigEndian, ByteOrder};
14
15use crate::hfs::{HfsExtDescriptor, HfsFilesystem};
16use disk_core::entry::FileEntry;
17use disk_core::filesystem::FilesystemError;
18use disk_core::filesystem::{
19    CreateDirectoryOptions, CreateFileOptions, EditableFilesystem, Filesystem,
20};
21
22/// Classic-HFS catalog record type byte.
23const CATALOG_DIR: i8 = 1;
24const CATALOG_FILE: i8 = 2;
25
26/// Volume-level metadata captured from the source.
27#[derive(Debug, Clone)]
28pub struct SourceVolumeSnapshot {
29    /// UTF-8 decoding of the volume name (Pascal string in Mac Roman on disk).
30    pub volume_name: String,
31    /// Raw Mac Roman bytes of the volume name (up to 27 bytes).
32    pub volume_name_raw: Vec<u8>,
33    /// drCrDate — volume create date (HFS epoch seconds).
34    pub create_date: u32,
35    /// drLsMod — volume modify date.
36    pub modify_date: u32,
37    /// drVolBkUp — volume backup date.
38    pub backup_date: u32,
39    /// drFndrInfo — 8 × u32 Finder info. `finder_info[0]` is the blessed
40    /// System Folder CNID; other slots are also copied verbatim with any
41    /// CNID remap applied by the caller.
42    pub finder_info: [u32; 8],
43    /// Sectors 0..1 of the partition, 1024 bytes verbatim.
44    pub boot_blocks: [u8; 1024],
45    /// Source allocation block size (drAlBlkSiz) — informational only.
46    pub block_size: u32,
47    /// Source total allocation blocks (drNmAlBlks) — informational only.
48    pub total_blocks: u16,
49}
50
51/// Per-file metadata captured from a classic-HFS catalog file record.
52#[derive(Debug, Clone)]
53pub struct SourceFileSnapshot {
54    pub name: String,
55    pub name_raw: Vec<u8>,
56    /// Source CNID (filFlNum). Targets get fresh CNIDs so this is for
57    /// building the source→target CNID map only.
58    pub cnid: u32,
59    pub parent_cnid: u32,
60    /// filFlags byte (offset 2) — bit 0x01 = locked.
61    pub flags: u8,
62    /// 16-byte FInfo (fdType, fdCreator, fdFlags, fdLocation, fdFldr).
63    pub finfo: [u8; 16],
64    /// 16-byte FXInfo (fdIconID, fdScript, fdXFlags, fdComment, fdPutAway).
65    pub fxinfo: [u8; 16],
66    pub create_date: u32,
67    pub modify_date: u32,
68    pub backup_date: u32,
69    pub data_size: u32,
70    pub data_extents: [HfsExtDescriptor; 3],
71    pub rsrc_size: u32,
72    pub rsrc_extents: [HfsExtDescriptor; 3],
73}
74
75impl SourceFileSnapshot {
76    /// True if the file's locked bit is set (filFlags bit 0x01).
77    pub fn is_locked(&self) -> bool {
78        self.flags & 0x01 != 0
79    }
80}
81
82/// Per-directory metadata captured from a classic-HFS catalog dir record.
83#[derive(Debug, Clone)]
84pub struct SourceDirSnapshot {
85    pub name: String,
86    pub name_raw: Vec<u8>,
87    /// Source CNID (dirDirID).
88    pub cnid: u32,
89    pub parent_cnid: u32,
90    /// 16-byte DInfo (frRect, frFlags, frLocation, frView).
91    pub dinfo: [u8; 16],
92    /// 16-byte DXInfo (frScroll, frOpenChain, frScript, frXFlags, frComment, frPutAway).
93    pub dxinfo: [u8; 16],
94    pub create_date: u32,
95    pub modify_date: u32,
96    pub backup_date: u32,
97}
98
99/// Full capture of a source HFS volume: volume metadata plus every directory
100/// and file record in the catalog.
101///
102/// Catalog *thread* records are not captured — the target writer recreates
103/// threads from scratch.
104#[derive(Debug, Clone)]
105pub struct SourceCatalogSnapshot {
106    pub volume: SourceVolumeSnapshot,
107    pub dirs: Vec<SourceDirSnapshot>,
108    pub files: Vec<SourceFileSnapshot>,
109}
110
111impl SourceVolumeSnapshot {
112    /// Capture volume-level metadata from an opened source filesystem. The
113    /// source is only read, never mutated.
114    pub fn capture<R: Read + Seek>(fs: &mut HfsFilesystem<R>) -> Result<Self, FilesystemError> {
115        let boot_blocks = fs.read_boot_blocks()?;
116        let mdb = fs.mdb();
117        // drVolBkUp lives at offset 64 of the MDB sector — not parsed into
118        // the struct, so read it from the preserved raw sector.
119        let backup_date = BigEndian::read_u32(&mdb.raw_sector[64..68]);
120        Ok(SourceVolumeSnapshot {
121            volume_name: mdb.volume_name.clone(),
122            volume_name_raw: mdb.volume_name_raw.clone(),
123            create_date: mdb.create_date,
124            modify_date: mdb.modify_date,
125            backup_date,
126            finder_info: mdb.finder_info,
127            boot_blocks,
128            block_size: mdb.block_size,
129            total_blocks: mdb.total_blocks,
130        })
131    }
132}
133
134impl SourceCatalogSnapshot {
135    /// Capture volume + all directory + all file records. Uses only the
136    /// primary catalog B-tree; index nodes are ignored and only leaf records
137    /// are visited. Unknown record types (threads, corrupt) are skipped.
138    pub fn capture<R: Read + Seek>(fs: &mut HfsFilesystem<R>) -> Result<Self, FilesystemError> {
139        let volume = SourceVolumeSnapshot::capture(fs)?;
140        let (dirs, files) = walk_catalog(fs.catalog_data());
141        Ok(SourceCatalogSnapshot {
142            volume,
143            dirs,
144            files,
145        })
146    }
147}
148
149/// Walk leaf records of the classic-HFS catalog B-tree and split them into
150/// dir and file snapshots. Thread records and malformed records are skipped.
151fn walk_catalog(catalog: &[u8]) -> (Vec<SourceDirSnapshot>, Vec<SourceFileSnapshot>) {
152    let mut dirs = Vec::new();
153    let mut files = Vec::new();
154
155    if catalog.len() < 512 {
156        return (dirs, files);
157    }
158    let node_size = BigEndian::read_u16(&catalog[32..34]) as usize;
159    if node_size == 0 || catalog.len() < node_size {
160        return (dirs, files);
161    }
162    // BTHeaderRec.firstLeafNode is at offset 14 within the header record,
163    // which starts 14 bytes into node 0 → absolute offset 24..28.
164    let mut node_idx = BigEndian::read_u32(&catalog[24..28]);
165
166    while node_idx != 0 {
167        let offset = node_idx as usize * node_size;
168        if offset + node_size > catalog.len() {
169            break;
170        }
171        let node = &catalog[offset..offset + node_size];
172        let next_node = BigEndian::read_u32(&node[0..4]);
173        let kind = node[8] as i8;
174        if kind != -1 {
175            break;
176        }
177        let num_records = BigEndian::read_u16(&node[10..12]) as usize;
178
179        for i in 0..num_records {
180            let offset_pos = node_size - 2 * (i + 1);
181            if offset_pos + 2 > node.len() {
182                break;
183            }
184            let rec_offset = BigEndian::read_u16(&node[offset_pos..offset_pos + 2]) as usize;
185            if rec_offset + 7 > node.len() {
186                continue;
187            }
188            let key_len = node[rec_offset] as usize;
189            if key_len < 6 || rec_offset + 1 + key_len > node.len() {
190                continue;
191            }
192            let parent_id = BigEndian::read_u32(&node[rec_offset + 2..rec_offset + 6]);
193            let name_len = node[rec_offset + 6] as usize;
194            let name_end = rec_offset + 7 + name_len;
195            if name_end > node.len() {
196                continue;
197            }
198            let name_raw = node[rec_offset + 7..name_end].to_vec();
199
200            let mut rec_data_offset = rec_offset + 1 + key_len;
201            if !rec_data_offset.is_multiple_of(2) {
202                rec_data_offset += 1;
203            }
204            if rec_data_offset + 2 > node.len() {
205                continue;
206            }
207            let rec_type = node[rec_data_offset] as i8;
208
209            match rec_type {
210                CATALOG_DIR => {
211                    if rec_data_offset + 70 > node.len() {
212                        continue;
213                    }
214                    let rec = &node[rec_data_offset..rec_data_offset + 70];
215                    let cnid = BigEndian::read_u32(&rec[6..10]);
216                    let create_date = BigEndian::read_u32(&rec[10..14]);
217                    let modify_date = BigEndian::read_u32(&rec[14..18]);
218                    let backup_date = BigEndian::read_u32(&rec[18..22]);
219                    let mut dinfo = [0u8; 16];
220                    dinfo.copy_from_slice(&rec[22..38]);
221                    let mut dxinfo = [0u8; 16];
222                    dxinfo.copy_from_slice(&rec[38..54]);
223                    dirs.push(SourceDirSnapshot {
224                        name: crate::hfs::mac_roman_to_utf8(&name_raw),
225                        name_raw,
226                        cnid,
227                        parent_cnid: parent_id,
228                        dinfo,
229                        dxinfo,
230                        create_date,
231                        modify_date,
232                        backup_date,
233                    });
234                }
235                CATALOG_FILE => {
236                    if rec_data_offset + 102 > node.len() {
237                        continue;
238                    }
239                    let rec = &node[rec_data_offset..rec_data_offset + 102];
240                    let flags = rec[2];
241                    let mut finfo = [0u8; 16];
242                    finfo.copy_from_slice(&rec[4..20]);
243                    let cnid = BigEndian::read_u32(&rec[20..24]);
244                    let data_size = BigEndian::read_u32(&rec[26..30]);
245                    let rsrc_size = BigEndian::read_u32(&rec[36..40]);
246                    let create_date = BigEndian::read_u32(&rec[44..48]);
247                    let modify_date = BigEndian::read_u32(&rec[48..52]);
248                    let backup_date = BigEndian::read_u32(&rec[52..56]);
249                    let mut fxinfo = [0u8; 16];
250                    fxinfo.copy_from_slice(&rec[56..72]);
251                    let mut data_extents = [HfsExtDescriptor {
252                        start_block: 0,
253                        block_count: 0,
254                    }; 3];
255                    for j in 0..3 {
256                        data_extents[j] = HfsExtDescriptor::parse(&rec[74 + j * 4..78 + j * 4]);
257                    }
258                    let mut rsrc_extents = [HfsExtDescriptor {
259                        start_block: 0,
260                        block_count: 0,
261                    }; 3];
262                    for j in 0..3 {
263                        rsrc_extents[j] = HfsExtDescriptor::parse(&rec[86 + j * 4..90 + j * 4]);
264                    }
265                    files.push(SourceFileSnapshot {
266                        name: crate::hfs::mac_roman_to_utf8(&name_raw),
267                        name_raw,
268                        cnid,
269                        parent_cnid: parent_id,
270                        flags,
271                        finfo,
272                        fxinfo,
273                        create_date,
274                        modify_date,
275                        backup_date,
276                        data_size,
277                        data_extents,
278                        rsrc_size,
279                        rsrc_extents,
280                    });
281                }
282                _ => {}
283            }
284        }
285
286        node_idx = next_node;
287    }
288
289    (dirs, files)
290}
291
292/// Summary of a `clone_hfs_volume` run. `errors` and `skipped` are populated
293/// only for non-fatal per-entry issues; fatal failures bubble up through the
294/// `Result` instead.
295#[derive(Debug, Default)]
296pub struct CloneReport {
297    pub files_copied: u64,
298    pub dirs_copied: u64,
299    pub data_bytes_copied: u64,
300    pub rsrc_bytes_copied: u64,
301    /// (path, reason) for entries the cloner deliberately skipped.
302    pub skipped: Vec<(String, String)>,
303    /// (path, reason) for non-fatal errors that allowed the clone to continue.
304    pub errors: Vec<(String, String)>,
305}
306
307/// Walk every directory and file in `source` and replicate them onto `target`,
308/// preserving Finder metadata, dates, locked state, resource forks, and the
309/// blessed-system-folder pointer.
310///
311/// Pre-conditions:
312/// - `target` is freshly built (empty catalog except for the root directory)
313///   and large enough to hold every file in `source`.
314/// - `source` is opened read-only; this function never mutates it.
315///
316/// Post-conditions on success:
317/// - Every dir/file from `source` exists at the same path on `target`.
318/// - Volume name was set when `target` was built; volume dates, boot blocks,
319///   and `drFndrInfo` (with CNID remap applied to slots that look like CNIDs)
320///   are copied from `source`.
321/// - `target.sync_metadata()` has been called.
322pub fn clone_hfs_volume<RS, RT>(
323    source: &mut HfsFilesystem<RS>,
324    target: &mut HfsFilesystem<RT>,
325) -> Result<CloneReport, FilesystemError>
326where
327    RS: Read + Seek + Send,
328    RT: Read + Write + Seek + Send,
329{
330    let snapshot = SourceCatalogSnapshot::capture(source)?;
331    let mut report = CloneReport::default();
332
333    // Volume-level metadata. The volume name was supplied when `target` was
334    // created; everything else is staged here and flushed by sync_metadata.
335    target.set_volume_dates(
336        snapshot.volume.create_date,
337        snapshot.volume.modify_date,
338        snapshot.volume.backup_date,
339    );
340    target.set_boot_blocks(&snapshot.volume.boot_blocks);
341
342    // Index source dirs/files by parent CNID so a BFS from the root visits
343    // parents before children regardless of catalog ordering.
344    let mut dirs_by_parent: HashMap<u32, Vec<&SourceDirSnapshot>> = HashMap::new();
345    for d in &snapshot.dirs {
346        if d.cnid == 2 {
347            continue; // root handled separately
348        }
349        dirs_by_parent.entry(d.parent_cnid).or_default().push(d);
350    }
351    let mut files_by_parent: HashMap<u32, Vec<&SourceFileSnapshot>> = HashMap::new();
352    for f in &snapshot.files {
353        files_by_parent.entry(f.parent_cnid).or_default().push(f);
354    }
355
356    // Map source CNID → target CNID. Both volumes use CNID 2 for the root.
357    let mut cnid_map: HashMap<u32, u32> = HashMap::new();
358    cnid_map.insert(2, 2);
359
360    // Map source CNID → target FileEntry. Used as the parent argument when
361    // creating children.
362    let target_root = target.root()?;
363    let mut entry_map: HashMap<u32, FileEntry> = HashMap::new();
364    entry_map.insert(2, target_root.clone());
365
366    // Apply the source root's DInfo / dates to the target's pre-existing root.
367    if let Some(root_src) = snapshot.dirs.iter().find(|d| d.cnid == 2) {
368        target.set_directory_finder_info(&target_root, root_src.dinfo, root_src.dxinfo)?;
369        target.set_dates(
370            &target_root,
371            root_src.create_date,
372            root_src.modify_date,
373            root_src.backup_date,
374        )?;
375    }
376
377    let mut queue: VecDeque<u32> = VecDeque::new();
378    queue.push_back(2);
379
380    while let Some(src_parent_cnid) = queue.pop_front() {
381        let parent_entry = entry_map.get(&src_parent_cnid).cloned().ok_or_else(|| {
382            FilesystemError::InvalidData(format!(
383                "clone: missing parent entry for source CNID {src_parent_cnid}"
384            ))
385        })?;
386
387        // Directories first so files can land in them, but ordering doesn't
388        // actually matter — they share the same parent.
389        if let Some(children) = dirs_by_parent.remove(&src_parent_cnid) {
390            for d in children {
391                let new_dir = target.create_directory(
392                    &parent_entry,
393                    &d.name,
394                    &CreateDirectoryOptions::default(),
395                )?;
396                target.set_directory_finder_info(&new_dir, d.dinfo, d.dxinfo)?;
397                target.set_dates(&new_dir, d.create_date, d.modify_date, d.backup_date)?;
398                cnid_map.insert(d.cnid, new_dir.location as u32);
399                entry_map.insert(d.cnid, new_dir);
400                report.dirs_copied += 1;
401                queue.push_back(d.cnid);
402            }
403        }
404
405        if let Some(children) = files_by_parent.remove(&src_parent_cnid) {
406            for f in children {
407                clone_one_file(source, target, &parent_entry, f, &mut cnid_map, &mut report)?;
408            }
409        }
410    }
411
412    // Volume Finder info: copy all 8 ints, remapping any value that matches
413    // a known source CNID. drFndrInfo[0] is the blessed System Folder, [3] is
414    // typically the OS folder, [5] the macOS X System file. We don't know
415    // which slots hold CNIDs vs flags, so the "matches a known source CNID"
416    // heuristic catches the right ones without disturbing flag fields.
417    //
418    // Slots 6 and 7 form the volume's "Finder ID" (`fndrInfo[6..8]` packed as
419    // a u64) — Finder uses it to recognise a previously-seen disk across
420    // mounts. Carrying the source's ID forward makes Mac OS treat the clone
421    // as "the source disk, modified outside our knowledge" and run Disk
422    // First Aid on first mount. Zeroing them lets Mac OS generate a fresh
423    // ID on first mount, so the clone presents as a new disk and skips the
424    // dirty-volume check.
425    let known_source_cnids: HashSet<u32> = cnid_map.keys().copied().collect();
426    let mut new_finder_info = [0u8; 32];
427    for i in 0..8 {
428        if i == 6 || i == 7 {
429            continue; // leave Finder volume ID zeroed
430        }
431        let src_val = snapshot.volume.finder_info[i];
432        let mapped = if known_source_cnids.contains(&src_val) {
433            *cnid_map.get(&src_val).unwrap()
434        } else {
435            src_val
436        };
437        BigEndian::write_u32(&mut new_finder_info[i * 4..(i + 1) * 4], mapped);
438    }
439    target.set_volume_finder_info(&new_finder_info);
440
441    target.sync_metadata()?;
442
443    Ok(report)
444}
445
446/// Read one file's data + resource forks from source and write them to target,
447/// then stamp Finder metadata, dates, and the locked bit.
448fn clone_one_file<RS, RT>(
449    source: &mut HfsFilesystem<RS>,
450    target: &mut HfsFilesystem<RT>,
451    parent_entry: &FileEntry,
452    f: &SourceFileSnapshot,
453    cnid_map: &mut HashMap<u32, u32>,
454    report: &mut CloneReport,
455) -> Result<(), FilesystemError>
456where
457    RS: Read + Seek + Send,
458    RT: Read + Write + Seek + Send,
459{
460    let mut source_entry = FileEntry::new_file(
461        f.name.clone(),
462        String::new(),
463        f.data_size as u64,
464        f.cnid as u64,
465    );
466    if f.rsrc_size > 0 {
467        source_entry.resource_fork_size = Some(f.rsrc_size as u64);
468    }
469
470    // Piped, not buffered: an HFS data fork can reach 2 GiB.
471    let new_file = if f.data_size > 0 {
472        disk_core::stream_pipe::with_file_reader(source, &source_entry, |r| {
473            target.create_file(
474                parent_entry,
475                &f.name,
476                r,
477                f.data_size as u64,
478                &CreateFileOptions::default(),
479            )
480        })?
481    } else {
482        target.create_file(
483            parent_entry,
484            &f.name,
485            &mut std::io::empty(),
486            0,
487            &CreateFileOptions::default(),
488        )?
489    };
490    report.data_bytes_copied += f.data_size as u64;
491
492    if f.rsrc_size > 0 {
493        let mut rsrc = Vec::with_capacity(f.rsrc_size as usize);
494        source.write_resource_fork_to(&source_entry, &mut rsrc)?;
495        let mut rsrc_cursor = std::io::Cursor::new(&rsrc);
496        target.write_resource_fork(&new_file, &mut rsrc_cursor, f.rsrc_size as u64)?;
497        report.rsrc_bytes_copied += f.rsrc_size as u64;
498    }
499
500    target.set_finder_info(&new_file, f.finfo, f.fxinfo)?;
501    target.set_dates(&new_file, f.create_date, f.modify_date, f.backup_date)?;
502    if f.is_locked() {
503        target.set_file_locked(&new_file, true)?;
504    }
505
506    cnid_map.insert(f.cnid, new_file.location as u32);
507    report.files_copied += 1;
508    Ok(())
509}
510
511#[cfg(test)]
512mod tests {
513    use super::*;
514    use crate::hfs::HfsFilesystem;
515    use byteorder::{BigEndian, ByteOrder};
516    use disk_core::filesystem::{CreateFileOptions, EditableFilesystem, Filesystem};
517    use std::io::Cursor;
518
519    /// Build a minimal classic-HFS image with a volume name, known dates,
520    /// and a blessed folder CNID. Fields not exercised by the cloner are
521    /// left at defaults.
522    fn make_test_image() -> Vec<u8> {
523        {
524            let block_size = 4096u32;
525            let total_blocks: u16 = 128;
526            let first_alloc_block: u16 = 5;
527            let alloc_start = first_alloc_block as usize * 512;
528            let image_size = alloc_start + total_blocks as usize * block_size as usize;
529            let mut img = vec![0u8; image_size];
530
531            // Boot blocks (sectors 0..1, 1024 bytes): write a recognisable
532            // pattern so capture() can round-trip them.
533            for (i, slot) in img[..1024].iter_mut().enumerate() {
534                *slot = (i as u8).wrapping_mul(7).wrapping_add(3);
535            }
536
537            // Bitmap sector 3, mark blocks 0-3 allocated (catalog)
538            img[3 * 512] = 0b11110000;
539
540            // Build a catalog B-tree with just the root dir + its thread
541            let node_size = 4096usize;
542            let catalog_offset = alloc_start;
543            img[catalog_offset + 8] = 1; // header node
544            BigEndian::write_u16(&mut img[catalog_offset + 10..catalog_offset + 12], 3);
545            let hr = catalog_offset + 14;
546            BigEndian::write_u16(&mut img[hr..hr + 2], 1);
547            BigEndian::write_u32(&mut img[hr + 2..hr + 6], 1);
548            BigEndian::write_u32(&mut img[hr + 6..hr + 10], 2);
549            BigEndian::write_u32(&mut img[hr + 10..hr + 14], 1);
550            BigEndian::write_u32(&mut img[hr + 14..hr + 18], 1);
551            BigEndian::write_u16(&mut img[hr + 18..hr + 20], node_size as u16);
552            BigEndian::write_u16(&mut img[hr + 20..hr + 22], 37);
553            BigEndian::write_u32(&mut img[hr + 22..hr + 26], 4);
554            BigEndian::write_u32(&mut img[hr + 26..hr + 30], 2);
555            let ot = catalog_offset + node_size;
556            BigEndian::write_u16(&mut img[ot - 2..ot], 14);
557            BigEndian::write_u16(&mut img[ot - 4..ot - 2], 142);
558            BigEndian::write_u16(&mut img[ot - 6..ot - 4], 270);
559            BigEndian::write_u16(&mut img[ot - 8..ot - 6], 526);
560            img[catalog_offset + 270] = 0b11000000;
561
562            let leaf_off = catalog_offset + node_size;
563            img[leaf_off + 8] = 0xFF; // leaf
564            img[leaf_off + 9] = 1;
565            BigEndian::write_u16(&mut img[leaf_off + 10..leaf_off + 12], 2);
566
567            let r0_off = leaf_off + 14;
568            img[r0_off] = 6;
569            img[r0_off + 1] = 0;
570            BigEndian::write_u32(&mut img[r0_off + 2..r0_off + 6], 1);
571            img[r0_off + 6] = 0;
572            img[r0_off + 7] = 0;
573            let r0_data = r0_off + 8;
574            img[r0_data] = 1; // CATALOG_DIR
575            BigEndian::write_u32(&mut img[r0_data + 6..r0_data + 10], 2);
576            // Give the root dir some recognisable dates + DInfo
577            BigEndian::write_u32(&mut img[r0_data + 10..r0_data + 14], 0x11111111);
578            BigEndian::write_u32(&mut img[r0_data + 14..r0_data + 18], 0x22222222);
579            BigEndian::write_u32(&mut img[r0_data + 18..r0_data + 22], 0x33333333);
580            img[r0_data + 22..r0_data + 38].copy_from_slice(b"DINFO_ROOT______");
581            img[r0_data + 38..r0_data + 54].copy_from_slice(b"DXINFO_ROOT_____");
582
583            let r1_off = r0_data + 70;
584            img[r1_off] = 6;
585            img[r1_off + 1] = 0;
586            BigEndian::write_u32(&mut img[r1_off + 2..r1_off + 6], 2);
587            img[r1_off + 6] = 0;
588            img[r1_off + 7] = 0;
589            let r1_data = r1_off + 8;
590            img[r1_data] = 3; // CATALOG_DIR_THREAD
591            BigEndian::write_u32(&mut img[r1_data + 10..r1_data + 14], 1);
592            img[r1_data + 14] = 0;
593
594            let lot = leaf_off + node_size;
595            BigEndian::write_u16(&mut img[lot - 2..lot], 14);
596            let r1_rel = (r1_off - leaf_off) as u16;
597            BigEndian::write_u16(&mut img[lot - 4..lot - 2], r1_rel);
598            let free_rel = (r1_data + 46 - leaf_off) as u16;
599            BigEndian::write_u16(&mut img[lot - 6..lot - 4], free_rel);
600
601            // MDB at byte 1024 — write AFTER the boot-block pattern above
602            // so the pattern doesn't stomp on it.
603            let mdb_off = 1024;
604            for b in img[mdb_off..mdb_off + 512].iter_mut() {
605                *b = 0;
606            }
607            let mut mdb = [0u8; 512];
608            BigEndian::write_u16(&mut mdb[0..2], 0x4244); // 'BD' HFS signature
609            BigEndian::write_u32(&mut mdb[2..6], 0xAABBCCDD); // create date
610            BigEndian::write_u32(&mut mdb[6..10], 0x11223344); // modify date
611            BigEndian::write_u16(&mut mdb[14..16], 3); // volume bitmap sector
612            BigEndian::write_u16(&mut mdb[18..20], total_blocks);
613            BigEndian::write_u32(&mut mdb[20..24], block_size);
614            BigEndian::write_u16(&mut mdb[28..30], first_alloc_block);
615            BigEndian::write_u32(&mut mdb[30..34], 16);
616            BigEndian::write_u16(&mut mdb[34..36], total_blocks - 4);
617            mdb[36] = 7;
618            mdb[37..44].copy_from_slice(b"TestVol");
619            // drVolBkUp at offset 64
620            BigEndian::write_u32(&mut mdb[64..68], 0xDEADBEEF);
621            // drFndrInfo[0] = 99 (blessed folder CNID)
622            BigEndian::write_u32(&mut mdb[92..96], 99);
623            // drFndrInfo[3] = 77 (simulate OS-folder fingerprint)
624            BigEndian::write_u32(&mut mdb[92 + 12..92 + 16], 77);
625            BigEndian::write_u32(&mut mdb[146..150], 4 * block_size);
626            BigEndian::write_u16(&mut mdb[150..152], 0);
627            BigEndian::write_u16(&mut mdb[152..154], 4);
628            img[mdb_off..mdb_off + 512].copy_from_slice(&mdb);
629
630            img
631        }
632    }
633
634    #[test]
635    fn volume_snapshot_roundtrip() {
636        let img = make_test_image();
637        let mut fs = HfsFilesystem::open(Cursor::new(img.clone()), 0).unwrap();
638        let snap = SourceVolumeSnapshot::capture(&mut fs).unwrap();
639
640        assert_eq!(snap.volume_name, "TestVol");
641        assert_eq!(snap.volume_name_raw, b"TestVol");
642        assert_eq!(snap.create_date, 0xAABBCCDD);
643        assert_eq!(snap.modify_date, 0x11223344);
644        assert_eq!(snap.backup_date, 0xDEADBEEF);
645        assert_eq!(snap.finder_info[0], 99);
646        assert_eq!(snap.finder_info[3], 77);
647        assert_eq!(snap.block_size, 4096);
648        assert_eq!(snap.total_blocks, 128);
649
650        // Boot blocks round-trip: reproduce the pattern the generator wrote
651        // into sectors 0..1.
652        let mut expected = [0u8; 1024];
653        for (i, slot) in expected.iter_mut().enumerate() {
654            *slot = (i as u8).wrapping_mul(7).wrapping_add(3);
655        }
656        assert_eq!(snap.boot_blocks, expected);
657    }
658
659    #[test]
660    fn dir_snapshot_captures_dinfo_and_dates() {
661        let img = make_test_image();
662        let mut fs = HfsFilesystem::open(Cursor::new(img.clone()), 0).unwrap();
663        let snap = SourceCatalogSnapshot::capture(&mut fs).unwrap();
664
665        // Root directory should be captured (CNID 2, parent 1) with the
666        // dates and DInfo we stamped.
667        let root = snap
668            .dirs
669            .iter()
670            .find(|d| d.cnid == 2)
671            .expect("root dir captured");
672        assert_eq!(root.parent_cnid, 1);
673        assert_eq!(root.create_date, 0x11111111);
674        assert_eq!(root.modify_date, 0x22222222);
675        assert_eq!(root.backup_date, 0x33333333);
676        assert_eq!(&root.dinfo, b"DINFO_ROOT______");
677        assert_eq!(&root.dxinfo, b"DXINFO_ROOT_____");
678        assert!(snap.files.is_empty());
679    }
680
681    #[test]
682    fn file_snapshot_captures_finfo_dates_and_extents() {
683        // Start from the test image, add a file via the editable API so
684        // FInfo type/creator + dates get written by the real code path,
685        // then capture and verify the snapshot round-trips those fields.
686        let mut backing: Vec<u8> = make_test_image();
687        {
688            let mut fs = HfsFilesystem::open(Cursor::new(&mut backing), 0).unwrap();
689            let root = fs.root().unwrap();
690            let opts = CreateFileOptions {
691                os_type: Some(*b"TEXT"),
692                os_creator: Some(*b"ttxt"),
693                ..Default::default()
694            };
695            let payload = b"hello world".to_vec();
696            let mut src = payload.as_slice();
697            fs.create_file(&root, "greet", &mut src, payload.len() as u64, &opts)
698                .unwrap();
699            fs.sync_metadata().unwrap();
700        }
701
702        let mut fs = HfsFilesystem::open(Cursor::new(&mut backing), 0).unwrap();
703        let snap = SourceCatalogSnapshot::capture(&mut fs).unwrap();
704        let f = snap
705            .files
706            .iter()
707            .find(|f| f.name == "greet")
708            .expect("greet captured");
709        assert_eq!(f.parent_cnid, 2);
710        assert_eq!(&f.finfo[0..4], b"TEXT");
711        assert_eq!(&f.finfo[4..8], b"ttxt");
712        assert_eq!(f.data_size, 11);
713        assert!(f.data_extents[0].block_count >= 1);
714        assert!(f.create_date != 0);
715        assert!(f.modify_date != 0);
716        assert!(!f.is_locked());
717    }
718
719    /// Build a 1 MiB blank HFS at 4K blocks, populate it via the editable
720    /// API with a directory tree containing files (incl. a resource fork)
721    /// and a blessed System Folder, then return the backing bytes.
722    fn build_populated_source() -> Vec<u8> {
723        use crate::hfs::create_blank_hfs;
724        use disk_core::filesystem::{CreateDirectoryOptions, ResourceForkSource};
725
726        let mut backing = create_blank_hfs(1024 * 1024, 4096, "Source").unwrap();
727        {
728            let mut fs = HfsFilesystem::open(Cursor::new(&mut backing), 0).unwrap();
729            let root = fs.root().unwrap();
730
731            let sysf = fs
732                .create_directory(&root, "System Folder", &CreateDirectoryOptions::default())
733                .unwrap();
734            let apps = fs
735                .create_directory(&root, "Apps", &CreateDirectoryOptions::default())
736                .unwrap();
737            let sub = fs
738                .create_directory(&apps, "Sub", &CreateDirectoryOptions::default())
739                .unwrap();
740
741            // Stamp recognisable DInfo / dates on Apps so we can verify
742            // directory metadata flowed through.
743            let mut dinfo = [0u8; 16];
744            dinfo[..4].copy_from_slice(&[0x12, 0x34, 0x56, 0x78]);
745            let mut dxinfo = [0u8; 16];
746            dxinfo[..4].copy_from_slice(&[0xAA, 0xBB, 0xCC, 0xDD]);
747            fs.set_directory_finder_info(&apps, dinfo, dxinfo).unwrap();
748            fs.set_dates(&apps, 0x10000001, 0x10000002, 0x10000003)
749                .unwrap();
750
751            // Plain text file in Apps.
752            let payload = b"hello world".to_vec();
753            let mut data = payload.as_slice();
754            let opts = CreateFileOptions {
755                os_type: Some(*b"TEXT"),
756                os_creator: Some(*b"ttxt"),
757                ..Default::default()
758            };
759            let note = fs
760                .create_file(&apps, "note.txt", &mut data, payload.len() as u64, &opts)
761                .unwrap();
762            fs.set_dates(&note, 0x20000001, 0x20000002, 0x20000003)
763                .unwrap();
764
765            // Bigger file in Apps/Sub to exercise multi-block reads.
766            let big = vec![0x42u8; 6000];
767            let mut data = big.as_slice();
768            let opts = CreateFileOptions {
769                os_type: Some(*b"BINA"),
770                os_creator: Some(*b"hexd"),
771                ..Default::default()
772            };
773            fs.create_file(&sub, "data.bin", &mut data, big.len() as u64, &opts)
774                .unwrap();
775
776            // File with a resource fork in the root.
777            let rsrc = b"RESOURCE_FORK_BYTES".to_vec();
778            let payload = b"with rsrc".to_vec();
779            let mut data = payload.as_slice();
780            let opts = CreateFileOptions {
781                os_type: Some(*b"APPL"),
782                os_creator: Some(*b"MACS"),
783                resource_fork: Some(ResourceForkSource::Data(rsrc.clone())),
784                ..Default::default()
785            };
786            let _ = fs
787                .create_file(&root, "App", &mut data, payload.len() as u64, &opts)
788                .unwrap();
789
790            // Locked file.
791            let payload = b"locked content".to_vec();
792            let mut data = payload.as_slice();
793            let locked_file = fs
794                .create_file(
795                    &root,
796                    "ReadOnly",
797                    &mut data,
798                    payload.len() as u64,
799                    &CreateFileOptions::default(),
800                )
801                .unwrap();
802            fs.set_file_locked(&locked_file, true).unwrap();
803
804            fs.set_blessed_folder(&sysf).unwrap();
805
806            // Stamp deterministic volume dates so we can assert on them.
807            fs.set_volume_dates(0x30000001, 0x30000002, 0x30000003);
808
809            fs.sync_metadata().unwrap();
810        }
811        backing
812    }
813
814    fn find_child<R: Read + Seek + Send>(
815        fs: &mut HfsFilesystem<R>,
816        parent: &FileEntry,
817        name: &str,
818    ) -> FileEntry {
819        let kids = fs.list_directory(parent).unwrap();
820        kids.into_iter()
821            .find(|e| e.name == name)
822            .unwrap_or_else(|| panic!("entry '{name}' not found under {}", parent.path))
823    }
824
825    #[test]
826    fn clone_round_trip_basic() {
827        let mut source_bytes = build_populated_source();
828        // Target uses a different (larger) block size to exercise the
829        // reformat aspect of the clone.
830        let mut target_bytes =
831            crate::hfs::create_blank_hfs(2 * 1024 * 1024, 8192, "Target").unwrap();
832
833        {
834            let mut source = HfsFilesystem::open(Cursor::new(&mut source_bytes), 0).unwrap();
835            let mut target = HfsFilesystem::open(Cursor::new(&mut target_bytes), 0).unwrap();
836            let report = clone_hfs_volume(&mut source, &mut target).unwrap();
837            assert_eq!(report.files_copied, 4);
838            assert_eq!(report.dirs_copied, 3);
839            assert!(report.data_bytes_copied >= 6000 + 11);
840            assert_eq!(
841                report.rsrc_bytes_copied,
842                b"RESOURCE_FORK_BYTES".len() as u64
843            );
844        }
845
846        // Re-open target read-only and verify the catalog matches.
847        let mut fs = HfsFilesystem::open(Cursor::new(&target_bytes), 0).unwrap();
848        let root = fs.root().unwrap();
849        let kids: Vec<String> = fs
850            .list_directory(&root)
851            .unwrap()
852            .into_iter()
853            .map(|e| e.name)
854            .collect();
855        assert!(kids.contains(&"System Folder".to_string()));
856        assert!(kids.contains(&"Apps".to_string()));
857        assert!(kids.contains(&"App".to_string()));
858        assert!(kids.contains(&"ReadOnly".to_string()));
859
860        // Apps dir metadata round-trip.
861        let apps = find_child(&mut fs, &root, "Apps");
862        let apps_kids: Vec<String> = fs
863            .list_directory(&apps)
864            .unwrap()
865            .into_iter()
866            .map(|e| e.name)
867            .collect();
868        assert!(apps_kids.contains(&"note.txt".to_string()));
869        assert!(apps_kids.contains(&"Sub".to_string()));
870
871        // Sub/data.bin contents.
872        let sub = find_child(&mut fs, &apps, "Sub");
873        let data_bin = find_child(&mut fs, &sub, "data.bin");
874        assert_eq!(data_bin.size, 6000);
875        let bytes = fs.read_file(&data_bin, 6000).unwrap();
876        assert_eq!(bytes, vec![0x42u8; 6000]);
877        assert_eq!(data_bin.type_code, Some(*b"BINA"));
878        assert_eq!(data_bin.creator_code, Some(*b"hexd"));
879
880        // note.txt contents and type/creator.
881        let note = find_child(&mut fs, &apps, "note.txt");
882        let bytes = fs.read_file(&note, 32).unwrap();
883        assert_eq!(bytes, b"hello world");
884        assert_eq!(note.type_code, Some(*b"TEXT"));
885        assert_eq!(note.creator_code, Some(*b"ttxt"));
886
887        // Resource-forked file.
888        let app = find_child(&mut fs, &root, "App");
889        assert_eq!(app.resource_fork_size, Some(19));
890        let mut buf = Vec::new();
891        fs.write_resource_fork_to(&app, &mut buf).unwrap();
892        assert_eq!(buf, b"RESOURCE_FORK_BYTES");
893
894        // Blessed folder pointer remapped to target's "System Folder" CNID.
895        let blessed = fs.blessed_system_folder().expect("blessed folder set");
896        let sys_folder = find_child(&mut fs, &root, "System Folder");
897        assert_eq!(blessed.0, sys_folder.location);
898        assert_eq!(blessed.1, "System Folder");
899
900        // Volume name + dates carried.
901        assert_eq!(fs.volume_label(), Some("Target"));
902
903        // fsck reports no errors on the cloned volume.
904        let fsck = fs.fsck().expect("HFS supports fsck");
905        assert!(
906            fsck.is_clean(),
907            "fsck errors on cloned volume: {:?}",
908            fsck.errors
909                .iter()
910                .map(|e| e.code.as_str())
911                .collect::<Vec<_>>()
912        );
913    }
914
915    #[test]
916    fn clone_preserves_locked_flag_and_dates() {
917        let mut source_bytes = build_populated_source();
918        let mut target_bytes =
919            crate::hfs::create_blank_hfs(2 * 1024 * 1024, 8192, "Target").unwrap();
920        {
921            let mut source = HfsFilesystem::open(Cursor::new(&mut source_bytes), 0).unwrap();
922            let mut target = HfsFilesystem::open(Cursor::new(&mut target_bytes), 0).unwrap();
923            clone_hfs_volume(&mut source, &mut target).unwrap();
924        }
925
926        // Capture target catalog and inspect raw flags / dates.
927        let mut fs = HfsFilesystem::open(Cursor::new(&target_bytes), 0).unwrap();
928        let snap = SourceCatalogSnapshot::capture(&mut fs).unwrap();
929        let locked = snap
930            .files
931            .iter()
932            .find(|f| f.name == "ReadOnly")
933            .expect("ReadOnly file present");
934        assert!(locked.is_locked(), "locked flag must survive clone");
935
936        let note = snap
937            .files
938            .iter()
939            .find(|f| f.name == "note.txt")
940            .expect("note.txt present");
941        assert_eq!(note.create_date, 0x20000001);
942        assert_eq!(note.modify_date, 0x20000002);
943        assert_eq!(note.backup_date, 0x20000003);
944
945        let apps = snap
946            .dirs
947            .iter()
948            .find(|d| d.name == "Apps")
949            .expect("Apps dir present");
950        assert_eq!(apps.create_date, 0x10000001);
951        assert_eq!(apps.modify_date, 0x10000002);
952        assert_eq!(apps.backup_date, 0x10000003);
953        assert_eq!(&apps.dinfo[..4], &[0x12, 0x34, 0x56, 0x78]);
954        assert_eq!(&apps.dxinfo[..4], &[0xAA, 0xBB, 0xCC, 0xDD]);
955
956        // Volume dates copied.
957        assert_eq!(snap.volume.create_date, 0x30000001);
958        assert_eq!(snap.volume.modify_date, 0x30000002);
959        assert_eq!(snap.volume.backup_date, 0x30000003);
960    }
961}