Skip to main content

fs_mac/
hfsplus_defrag.rs

1//! Streamed defragmenting clone for HFS+/HFSX (Step 22 of
2//! `docs/hfsplus_enhancements.md`).
3//!
4//! Two-pass model:
5//!
6//! 1. **Plan** ([`plan_defrag_layout`]) — given a [`SourceCatalogSnapshot`]
7//!    plus a target byte size, decide every block placement on the
8//!    target volume: reserved regions (bitmap, B-tree files), per-file
9//!    contiguous fork extents in the user-data area, and a
10//!    source→target CNID remap.
11//! 2. **Build + emit** (steps 22c/22d) — produce the in-memory metadata
12//!    image (catalog B-tree, extents-overflow B-tree, attributes B-tree,
13//!    bitmap, VH) reflecting the plan, then forward-only emit the full
14//!    image into a `Write` sink with user-fork bytes streamed straight
15//!    from source via `HfsPlusFilesystem::fork_stream_reader`.
16//!
17//! Steps 22a (pre-flight refusal), 22b (planner), 22c (in-memory
18//! metadata builder including hardlinks + xattrs), and 22d (forward-only
19//! emit engine) are wired up; the round-trip integration test lands in
20//! 22e.
21
22#[cfg(feature = "rust173-polyfill")]
23use disk_core::rust173::IntIsMultipleOf as _;
24use std::collections::HashMap;
25use std::io::{Read, Seek, Write};
26
27use crate::hfsplus_clone::{
28    SourceCatalogSnapshot, SourceDirHardlinkSnapshot, SourceDirSnapshot, SourceFileSnapshot,
29    SourceHardlinkSnapshot,
30};
31
32/// First user-assignable CNID. CNIDs 1..=15 are reserved by HFS+
33/// (root-parent, root-folder, extents/catalog/attributes/allocation file
34/// IDs, etc.). The streamed builder issues fresh target CNIDs starting
35/// here, in source-CNID order, so the remap is stable and reproducible.
36const FIRST_USER_CNID: u32 = 16;
37
38/// Catalog growth multiplier applied AFTER computing the exact per-record
39/// byte cost from the snapshot. The exact estimate covers the densely-
40/// packed final state (~95% leaf utilisation); the multiplier accounts for
41/// split-induced fragmentation during incremental insertion (records
42/// arrive in source-CNID order, not sorted by catalog key, so splits leave
43/// nodes ~50% full until later inserts fill them back). 2× the exact
44/// estimate is a comfortable overprovision; the unused tail is zeros and
45/// compresses to nothing in CHD.
46const CATALOG_PACKING_OVERHEAD_NUM: u32 = 2;
47const CATALOG_PACKING_OVERHEAD_DEN: u32 = 1;
48/// Approximate body sizes (excluding the key portion) of each HFS+ catalog
49/// record kind, used by [`estimate_catalog_bytes`] to compute the exact
50/// catalog footprint for a given snapshot. Real records are slightly
51/// larger (variable name length in thread records, possible alignment
52/// padding); the per-record `+8` slop and the
53/// `CATALOG_PACKING_OVERHEAD_*` multiplier together absorb the variance.
54const HFSPLUS_FOLDER_BODY_BYTES: usize = 88;
55const HFSPLUS_FILE_BODY_BYTES: usize = 248;
56const HFSPLUS_THREAD_BODY_FIXED_BYTES: usize = 10;
57
58/// Compute the byte cost of a single catalog record (key + body + offset
59/// table slot). HFS+ catalog keys are `key_len(2) + parentID(4) +
60/// name_len(2) + name(UTF-16BE NFD)`, even-aligned.
61fn catalog_record_cost(name_utf16_len: usize, body_bytes: usize) -> usize {
62    let key_bytes = 6 + name_utf16_len * 2;
63    let key_padded = (key_bytes + 1) & !1;
64    // +2 for the per-record offset table slot the leaf stores at the tail.
65    // +8 slop covers per-record variance (thread `recordType` padding,
66    // round-ups inside `build_*_body`, etc.). Cheap insurance.
67    key_padded + body_bytes + 2 + 8
68}
69
70/// Walk a [`SourceCatalogSnapshot`] and return the exact catalog byte
71/// requirement for the target B-tree, including:
72///   - dir record + dir thread per directory
73///   - file record + file thread per file (incl. inodes)
74///   - hardlink-stub record + thread per file hardlink and dir hardlink
75///   - header + map placeholder + ~5% index overhead
76///
77/// The caller multiplies by [`CATALOG_PACKING_OVERHEAD_NUM`] /
78/// [`CATALOG_PACKING_OVERHEAP_DEN`] to absorb split-induced fragmentation.
79fn estimate_catalog_bytes(snapshot: &SourceCatalogSnapshot) -> u64 {
80    let mut record_bytes: u64 = 0;
81    // Helper to add (record cost + thread cost) where the thread carries
82    // the same UTF-16 name in its body. The thread record itself uses an
83    // empty key (`parent=cnid, name=""`).
84    let mut accumulate = |name: &str, body_bytes: usize| {
85        let name_utf16: usize = name.encode_utf16().count();
86        let main_cost = catalog_record_cost(name_utf16, body_bytes);
87        // Thread record: key has empty name (so name_utf16=0), body is
88        // recordType(2) + reserved(2) + parentID(4) + name_len(2) + name.
89        let thread_body = HFSPLUS_THREAD_BODY_FIXED_BYTES + name_utf16 * 2;
90        let thread_cost = catalog_record_cost(0, thread_body);
91        record_bytes = record_bytes.saturating_add((main_cost + thread_cost) as u64);
92    };
93    for d in &snapshot.dirs {
94        accumulate(&d.name, HFSPLUS_FOLDER_BODY_BYTES);
95    }
96    for f in &snapshot.files {
97        accumulate(&f.name, HFSPLUS_FILE_BODY_BYTES);
98    }
99    for h in &snapshot.hardlinks {
100        accumulate(&h.name, HFSPLUS_FILE_BODY_BYTES);
101    }
102    for h in &snapshot.dir_hardlinks {
103        accumulate(&h.name, HFSPLUS_FILE_BODY_BYTES);
104    }
105    // Convert byte sum to leaf nodes. Each leaf has `NODE_SIZE - 14
106    // (descriptor) - 2 (free-space slot) = NODE_SIZE - 16` bytes of
107    // payload. Round up.
108    let payload = (NODE_SIZE - 16) as u64;
109    let leaf_nodes = record_bytes.div_ceil(payload);
110    // Add 5% for index nodes + 2 for header + map placeholder. Big
111    // catalogs amortise to ~2-3% index overhead; 5% is a safe upper bound.
112    let total_nodes = leaf_nodes.saturating_mul(105) / 100 + 2;
113    total_nodes.saturating_mul(NODE_SIZE as u64)
114}
115
116/// Where one user fork lives on the target volume.
117#[derive(Debug, Clone, Copy)]
118pub struct ForkPlacement {
119    /// First allocation block of the contiguous extent.
120    pub start_block: u32,
121    /// Number of allocation blocks (`ceil(logical_size / block_size)`).
122    pub block_count: u32,
123    /// Logical fork size in bytes — preserved from source so the target
124    /// catalog record can carry it byte-for-byte.
125    pub logical_size: u64,
126}
127
128/// Per-file (or per-inode) target placement: assigned CNID + fork
129/// extents. Hardlink stub rows don't appear here — they share the
130/// inode's target CNID via `bsdInfo.special` and carry no forks of
131/// their own.
132#[derive(Debug, Clone)]
133pub struct FilePlacement {
134    pub target_cnid: u32,
135    pub data_fork: Option<ForkPlacement>,
136    pub rsrc_fork: Option<ForkPlacement>,
137}
138
139/// Result of [`plan_defrag_layout`]. Pure planning data — no I/O has
140/// happened, no bytes have been written.
141#[derive(Debug, Clone)]
142pub struct TargetLayoutPlan {
143    pub block_size: u32,
144    pub total_blocks: u32,
145    /// Allocation blocks reserved for the volume's structural region:
146    /// `1 (boot+VH)` + `bitmap_blocks` + `catalog_blocks` +
147    /// `extents_blocks` + `attributes_blocks`. Everything from
148    /// `user_data_start` to `total_blocks - 1` (less the alt-VH region)
149    /// is available for user file forks.
150    pub reserved_blocks: u32,
151    pub bitmap_start: u32,
152    pub bitmap_blocks: u32,
153    pub catalog_start: u32,
154    pub catalog_blocks: u32,
155    pub extents_start: u32,
156    pub extents_blocks: u32,
157    /// `attributes_blocks == 0` indicates the source had no xattrs and
158    /// the target's attributes file is left unallocated (vh field
159    /// `ForkData::empty()` at build time).
160    pub attributes_start: u32,
161    pub attributes_blocks: u32,
162    pub user_data_start: u32,
163    /// First user-data block past the end of every allocated fork. The
164    /// builder uses this when computing `vh.next_allocation`, and the
165    /// emit phase uses `total_blocks - user_data_blocks_used` to compute
166    /// the zero-padding region between the last user fork and the alt
167    /// VH.
168    pub user_data_blocks_used: u32,
169    /// Source CNID → target CNID. Includes every dir, file, inode, and
170    /// hardlink stub captured in the snapshot. Root (CNID 2) maps to
171    /// root.
172    pub cnid_map: HashMap<u32, u32>,
173    /// Per-file fork placements, keyed by *source* CNID. Includes
174    /// inodes (under HFS+ Private Data) but excludes hardlink stubs
175    /// (they have no forks of their own).
176    pub file_placements: HashMap<u32, FilePlacement>,
177}
178
179/// Errors the planner can return. Keeping these distinct from
180/// `FilesystemError` since the planner doesn't do any I/O — every
181/// failure is an arithmetic / sizing issue surfaced as a string.
182#[derive(Debug, Clone, thiserror::Error)]
183pub enum PlanError {
184    #[error("target_size {target} bytes is smaller than the reserved metadata region ({reserved} bytes); pick a larger target")]
185    TargetTooSmallForMetadata { target: u64, reserved: u64 },
186    #[error("target_size {target} bytes is too small to hold {user_bytes} bytes of user data plus metadata + alt-VH region")]
187    TargetTooSmallForUserData { target: u64, user_bytes: u64 },
188    #[error("source block size {0} is not a power of two in [512, 4096]; defrag-clone is unsupported on this volume")]
189    UnsupportedBlockSize(u32),
190    #[error("target size {0} is not a multiple of source block size; round it up before planning")]
191    TargetNotBlockAligned(u64),
192}
193
194/// Build a [`TargetLayoutPlan`] for streaming a defragmented copy of
195/// `snapshot` into a target volume of exactly `target_size` bytes.
196///
197/// The target adopts the source's block size verbatim — keeping the
198/// HFS+ attributes-file machinery happy and avoiding the need to
199/// re-encode existing fork extents at a different granularity.
200///
201/// Reserved-region sizing:
202/// - **Catalog**: `source_blocks * 3/2` (1.5× safety margin for leaf
203///   splits during emit).
204/// - **Extents-overflow**: matches source verbatim. The streamed target
205///   packs every fork contiguously, so the overflow file is mostly
206///   empty in practice — but matching the source size guarantees we'll
207///   never refuse to emit a record.
208/// - **Attributes**: matches source verbatim (or zero when the source
209///   has no xattrs). Same reasoning.
210/// - **Bitmap**: `ceil(total_blocks / 8 / block_size)` — exactly what
211///   `create_blank_hfsplus` computes.
212///
213/// User-data placement:
214/// - Iterates files in source-CNID order so the remap is reproducible.
215/// - Inodes (files with `is_inode = true`) are placed alongside ordinary
216///   files; the planner doesn't distinguish at this layer.
217/// - Hardlink stubs are remapped (CNIDs assigned) but not given any
218///   user-data extent.
219pub fn plan_defrag_layout(
220    snapshot: &SourceCatalogSnapshot,
221    target_size: u64,
222) -> Result<TargetLayoutPlan, PlanError> {
223    let block_size = snapshot.volume.block_size;
224    if !block_size.is_power_of_two() || !(512..=4096).contains(&block_size) {
225        return Err(PlanError::UnsupportedBlockSize(block_size));
226    }
227    let bs = block_size as u64;
228    if !target_size.is_multiple_of(bs) {
229        return Err(PlanError::TargetNotBlockAligned(target_size));
230    }
231    let total_blocks: u32 = (target_size / bs)
232        .try_into()
233        .map_err(|_| PlanError::TargetNotBlockAligned(target_size))?;
234
235    let bitmap_bytes = total_blocks.div_ceil(8) as u64;
236    let bitmap_blocks = bitmap_bytes.div_ceil(bs) as u32;
237
238    // Exact catalog sizing: walk the snapshot, sum per-record byte costs,
239    // convert to leaf nodes, add 5% index overhead, and multiply by the
240    // packing-overhead factor (2× by default) to absorb split-induced
241    // fragmentation. This is precise and deterministic — no more
242    // heuristic multiples-of-source guesses. The unused tail of the
243    // catalog file is zeros and compresses to ~nothing in CHD output.
244    let exact_catalog_bytes = estimate_catalog_bytes(snapshot)
245        .saturating_mul(CATALOG_PACKING_OVERHEAD_NUM as u64)
246        / CATALOG_PACKING_OVERHEAD_DEN as u64;
247    let catalog_bytes_needed = exact_catalog_bytes.div_ceil(bs).saturating_mul(bs);
248    let catalog_blocks_exact: u32 = (catalog_bytes_needed / bs).try_into().unwrap_or(u32::MAX);
249    // Floor at the source value so degenerate sources (extremely small
250    // catalogs, e.g. fresh-formatted volumes) still get sensible space.
251    let catalog_blocks = catalog_blocks_exact.max(snapshot.volume.catalog_total_blocks);
252
253    let extents_blocks = snapshot.volume.extents_total_blocks;
254    let attributes_blocks = snapshot.volume.attributes_total_blocks;
255
256    let bitmap_start: u32 = 1;
257    let catalog_start = bitmap_start + bitmap_blocks;
258    let extents_start = catalog_start + catalog_blocks;
259    let attributes_start = extents_start + extents_blocks;
260    let user_data_start = attributes_start + attributes_blocks;
261    let reserved_blocks = user_data_start;
262
263    // Alt-VH lives in the last 1024 bytes of the volume. Reserve at
264    // least one block for it so user data doesn't collide with the alt
265    // VH region — `create_blank_hfsplus` reserves 1024 bytes (= 2× 512
266    // sectors); for block sizes ≤ 1024 we need 1 block, for ≤ 4096 we
267    // still only need 1 block since alt VH is 512 bytes inside that
268    // trailing 1024-byte region.
269    let alt_vh_reserve_blocks: u32 = 1024u32.div_ceil(block_size).max(1);
270
271    if (reserved_blocks as u64 + alt_vh_reserve_blocks as u64) >= total_blocks as u64 {
272        return Err(PlanError::TargetTooSmallForMetadata {
273            target: target_size,
274            reserved: (reserved_blocks as u64 + alt_vh_reserve_blocks as u64) * bs,
275        });
276    }
277
278    // CNID remap: root (CNID 2) maps to root; everything else gets a
279    // fresh CNID issued sequentially from FIRST_USER_CNID. We iterate in
280    // source-CNID order so remaps are deterministic across runs.
281    let mut cnid_map: HashMap<u32, u32> = HashMap::new();
282    cnid_map.insert(2, 2);
283
284    let mut all_source_cnids: Vec<u32> = Vec::new();
285    for d in &snapshot.dirs {
286        if d.cnid != 2 {
287            all_source_cnids.push(d.cnid);
288        }
289    }
290    for f in &snapshot.files {
291        all_source_cnids.push(f.cnid);
292    }
293    for h in &snapshot.hardlinks {
294        all_source_cnids.push(h.cnid);
295    }
296    for h in &snapshot.dir_hardlinks {
297        all_source_cnids.push(h.cnid);
298    }
299    all_source_cnids.sort_unstable();
300    all_source_cnids.dedup();
301    for (next_cnid, cnid) in (FIRST_USER_CNID..).zip(all_source_cnids) {
302        cnid_map.insert(cnid, next_cnid);
303    }
304
305    // Pack user file forks contiguously starting at user_data_start.
306    let mut next_block = user_data_start;
307    let mut file_placements: HashMap<u32, FilePlacement> = HashMap::new();
308    // Sort files by the first extent's source start_block — this matches
309    // the target-placement order to source disk order so the emit phase
310    // reads the source sequentially. Earlier this sorted by CNID, which
311    // produced a randomish source-read pattern even on a 99.9%-contiguous
312    // disk and starved the encoder when the source is slow (SD cards).
313    // Files with no data fork (resource-only) fall back to their resource
314    // fork's start block; files with neither sort by CNID at the tail.
315    let fork_start_block = |raw: &[u8; 80]| -> Option<u32> {
316        if BigEndian::read_u64(&raw[0..8]) == 0 {
317            None
318        } else {
319            // First extent descriptor: start_block at offset 16, count at 20.
320            // count==0 means "no extent here" (defensive — a 0-byte fork
321            // would have been filtered by logical_size check above, but
322            // some captures may carry zero-count slots after the live data).
323            let count = BigEndian::read_u32(&raw[20..24]);
324            if count == 0 {
325                None
326            } else {
327                Some(BigEndian::read_u32(&raw[16..20]))
328            }
329        }
330    };
331    let mut files_sorted: Vec<&SourceFileSnapshot> = snapshot.files.iter().collect();
332    files_sorted.sort_by_key(|f| {
333        let data_first = fork_start_block(&f.data_fork_raw);
334        let rsrc_first = fork_start_block(&f.rsrc_fork_raw);
335        let primary = data_first.or(rsrc_first);
336        // (primary_or_u32max, cnid) — files without any fork sort to
337        // the end stably by CNID; everything with a fork sorts by
338        // source-disk position.
339        (primary.unwrap_or(u32::MAX), f.cnid)
340    });
341    for f in files_sorted {
342        let target_cnid = *cnid_map.get(&f.cnid).expect("file CNID must be in map");
343        let data_fork = allocate_fork(&mut next_block, f.data_fork_size, bs);
344        let rsrc_fork = allocate_fork(&mut next_block, f.rsrc_fork_size, bs);
345        file_placements.insert(
346            f.cnid,
347            FilePlacement {
348                target_cnid,
349                data_fork,
350                rsrc_fork,
351            },
352        );
353    }
354
355    let user_data_blocks_used = next_block - user_data_start;
356    if (next_block as u64 + alt_vh_reserve_blocks as u64) > total_blocks as u64 {
357        let user_bytes: u64 = file_placements
358            .values()
359            .map(|p| {
360                p.data_fork.map(|f| f.logical_size).unwrap_or(0)
361                    + p.rsrc_fork.map(|f| f.logical_size).unwrap_or(0)
362            })
363            .sum();
364        return Err(PlanError::TargetTooSmallForUserData {
365            target: target_size,
366            user_bytes,
367        });
368    }
369
370    Ok(TargetLayoutPlan {
371        block_size,
372        total_blocks,
373        reserved_blocks,
374        bitmap_start,
375        bitmap_blocks,
376        catalog_start,
377        catalog_blocks,
378        extents_start,
379        extents_blocks,
380        attributes_start: if attributes_blocks > 0 {
381            attributes_start
382        } else {
383            0
384        },
385        attributes_blocks,
386        user_data_start,
387        user_data_blocks_used,
388        cnid_map,
389        file_placements,
390    })
391}
392
393/// Reserve `ceil(logical_size / block_size)` blocks for one fork. Empty
394/// forks (`logical_size == 0`) consume no blocks and return `None`.
395fn allocate_fork(
396    next_block: &mut u32,
397    logical_size: u64,
398    block_size: u64,
399) -> Option<ForkPlacement> {
400    if logical_size == 0 {
401        return None;
402    }
403    let block_count_u64 = logical_size.div_ceil(block_size);
404    let block_count: u32 = block_count_u64
405        .try_into()
406        .expect("fork too large for u32 block count");
407    let start = *next_block;
408    *next_block += block_count;
409    Some(ForkPlacement {
410        start_block: start,
411        block_count,
412        logical_size,
413    })
414}
415
416// =============================================================================
417// Step 22c — In-memory metadata builder.
418// =============================================================================
419
420use byteorder::{BigEndian, ByteOrder};
421use std::cmp::Ordering;
422
423use crate::hfs_common;
424use crate::hfsplus::{
425    write_blank_btree_header_node, ExtentDescriptor, ForkData, HfsPlusFilesystem, XattrKind,
426    XattrRecord, HFSPLUS_VOLUME_UNMOUNTED_BIT,
427};
428use disk_core::filesystem::FilesystemError;
429
430thread_local! {
431    /// Thread-local progress sink for the defrag pipeline. Callers
432    /// (`run_with_resize` etc.) `set_progress_sink(Some(cb))` before
433    /// invoking [`stream_defragmented_hfsplus`] /
434    /// [`build_target_metadata`] / wrappers, and clear it on the way out.
435    /// Tests and other callers leave it `None`; the defrag code emits to
436    /// stderr only as before. Avoids threading a `log_cb` through 8+
437    /// signatures while still piping the per-50k-records ticks into the
438    /// GUI log queue.
439    static PROGRESS_SINK: std::cell::RefCell<Option<ProgressSink>> =
440        const { std::cell::RefCell::new(None) };
441}
442
443/// Thread-local progress callback used by [`set_progress_sink`].
444pub type ProgressSink = Box<dyn FnMut(&str)>;
445
446/// Install or clear a progress sink for the current thread. Pass `Some(cb)`
447/// to receive log lines (one per 50k catalog inserts during build, plus
448/// phase boundaries); pass `None` to suppress GUI logging and rely on
449/// stderr only. Safe to call from anywhere; the previous value is dropped.
450pub fn set_progress_sink(cb: Option<ProgressSink>) {
451    PROGRESS_SINK.with(|cell| {
452        *cell.borrow_mut() = cb;
453    });
454}
455
456/// Internal helper — emit a progress message to both stderr (for the
457/// `tee /tmp/rusty-backup-run.log` capture) and, if set, the thread-local
458/// progress sink (for the in-GUI log).
459/// Print a fragmentation summary for the captured HFS+ snapshot — number
460/// of files with each extent-count bucket, plus how many forks overflow
461/// the 8 inline extent descriptors into the extents-overflow B-tree.
462/// Diagnostic-only; doesn't change the defrag pipeline. Output goes to
463/// both stderr and the GUI progress sink so it lands in /tmp logs and
464/// the in-app log panel.
465fn emit_fragmentation_report(snapshot: &SourceCatalogSnapshot) {
466    let mut files_with_data: u64 = 0;
467    let mut total_data_bytes: u64 = 0;
468    let mut total_data_inline_extents: u64 = 0;
469    let mut bucket_one: u64 = 0;
470    let mut bucket_two_three: u64 = 0;
471    let mut bucket_four_eight: u64 = 0;
472    let mut files_with_overflow: u64 = 0;
473    let mut max_inline_extents: u32 = 0;
474
475    let mut analyze_fork = |fork_raw: &[u8; 80]| {
476        let logical = BigEndian::read_u64(&fork_raw[0..8]);
477        if logical == 0 {
478            return;
479        }
480        let total_blocks = BigEndian::read_u32(&fork_raw[12..16]);
481        let mut inline_extents: u32 = 0;
482        let mut inline_block_total: u32 = 0;
483        for i in 0..8 {
484            let off = 16 + i * 8;
485            let start = BigEndian::read_u32(&fork_raw[off..off + 4]);
486            let count = BigEndian::read_u32(&fork_raw[off + 4..off + 8]);
487            if count > 0 {
488                inline_extents += 1;
489                inline_block_total = inline_block_total.saturating_add(count);
490                let _ = start;
491            }
492        }
493        files_with_data += 1;
494        total_data_bytes = total_data_bytes.saturating_add(logical);
495        total_data_inline_extents += inline_extents as u64;
496        max_inline_extents = max_inline_extents.max(inline_extents);
497        match inline_extents {
498            0 | 1 => bucket_one += 1,
499            2..=3 => bucket_two_three += 1,
500            _ => bucket_four_eight += 1,
501        }
502        if total_blocks > inline_block_total {
503            files_with_overflow += 1;
504        }
505    };
506
507    for f in &snapshot.files {
508        analyze_fork(&f.data_fork_raw);
509    }
510
511    let avg = if files_with_data > 0 {
512        total_data_inline_extents as f64 / files_with_data as f64
513    } else {
514        0.0
515    };
516    let pct = |n: u64| -> f64 {
517        if files_with_data == 0 {
518            0.0
519        } else {
520            100.0 * n as f64 / files_with_data as f64
521        }
522    };
523    emit_progress(&format!(
524        "[fragmentation] {} files with data fork ({} MiB total); \
525         1 extent: {} ({:.1}%), 2-3 extents: {} ({:.1}%), \
526         4-8 extents: {} ({:.1}%); >8 extents (overflow B-tree): {} ({:.1}%); \
527         avg inline extents/file: {:.2}; max inline extents seen: {}",
528        files_with_data,
529        total_data_bytes / (1024 * 1024),
530        bucket_one,
531        pct(bucket_one),
532        bucket_two_three,
533        pct(bucket_two_three),
534        bucket_four_eight,
535        pct(bucket_four_eight),
536        files_with_overflow,
537        pct(files_with_overflow),
538        avg,
539        max_inline_extents,
540    ));
541}
542
543fn emit_progress(msg: &str) {
544    eprintln!("{msg}");
545    PROGRESS_SINK.with(|cell| {
546        if let Some(cb) = cell.borrow_mut().as_mut() {
547            cb(msg);
548        }
549    });
550}
551
552/// HFS+ B-tree key compare types (`BTHeaderRec` offset 37).
553const KEY_COMPARE_CASE_FOLDING: u8 = 0xCF;
554const KEY_COMPARE_BINARY: u8 = 0xBC;
555
556/// Catalog record types (i16 at body offset 0).
557const CATALOG_FOLDER: i16 = 1;
558const CATALOG_FILE: i16 = 2;
559const CATALOG_FOLDER_THREAD: i16 = 3;
560const CATALOG_FILE_THREAD: i16 = 4;
561
562/// HFS+ B-tree node size — fixed at 4096 (Apple's default; matches every
563/// blank-builder helper in `hfsplus.rs`).
564const NODE_SIZE: usize = 4096;
565
566/// Output of [`build_target_metadata`]: the in-memory metadata image
567/// for a streamed defrag target. Step 22d emits these byte buffers
568/// directly into the compressor before streaming user-fork bytes.
569#[derive(Debug)]
570pub struct TargetMetadata {
571    /// The block-0 region: 1024 bytes of boot blocks (copied from
572    /// source) followed by the 512-byte primary VH at offset 1024 and
573    /// `block_size - 1024 - 512` bytes of trailing zero pad.
574    pub block_zero: Vec<u8>,
575    /// Allocation bitmap, sized to `bitmap_blocks * block_size`.
576    pub bitmap: Vec<u8>,
577    /// Catalog B-tree bytes, sized to `catalog_blocks * block_size`.
578    pub catalog: Vec<u8>,
579    /// Extents-overflow B-tree bytes, sized to `extents_blocks * block_size`.
580    pub extents_overflow: Vec<u8>,
581    /// Attributes B-tree bytes — only present when the source had
582    /// xattrs. Sized to `attributes_blocks * block_size`.
583    pub attributes: Option<Vec<u8>>,
584    /// 512-byte primary VH bytes. Already embedded inside `block_zero`,
585    /// surfaced here separately so 22d can stamp the alt VH without
586    /// re-serialising.
587    pub vh_bytes: [u8; 512],
588}
589
590/// Build the in-memory metadata image for a streamed defrag target.
591///
592/// **Scope (22c, basic):** directories and ordinary files only. Source
593/// hardlinks (file or directory) and xattrs are not yet emitted —
594/// they currently return `Err(Unsupported)`. Follow-up sub-steps will
595/// add them; the planner already issues them target CNIDs so plugging
596/// them in is a localised change.
597pub fn build_target_metadata(
598    plan: &TargetLayoutPlan,
599    snapshot: &SourceCatalogSnapshot,
600) -> Result<TargetMetadata, FilesystemError> {
601    let block_size = plan.block_size;
602    let block_size_u = block_size as usize;
603    let case_sensitive = snapshot.volume.signature == 0x4858;
604
605    // Initialise empty B-tree files. The B-tree files are themselves
606    // sized in allocation blocks; node count is derived from the byte
607    // size since `node_size = 4096`.
608    let catalog_bytes = plan.catalog_blocks as usize * block_size_u;
609    let extents_bytes = plan.extents_blocks as usize * block_size_u;
610    let mut catalog = init_btree_buffer(
611        catalog_bytes,
612        516,
613        if case_sensitive {
614            KEY_COMPARE_BINARY
615        } else {
616            KEY_COMPARE_CASE_FOLDING
617        },
618    );
619    let extents_overflow = init_btree_buffer(extents_bytes, 10, 0);
620
621    // Attributes B-tree: only allocate when the source had xattrs. The
622    // planner sized `attributes_blocks` from `vh.attributes_file.total_blocks`
623    // so capacity matches. `max_key_len = 266` covers the worst-case
624    // attribute key: 4 fileID + 4 startBlock + 2 nameLen + 127*2 name.
625    let mut attributes: Option<Vec<u8>> = if plan.attributes_blocks > 0 {
626        let attr_bytes = plan.attributes_blocks as usize * block_size_u;
627        Some(init_btree_buffer(attr_bytes, 266, 0))
628    } else {
629        None
630    };
631
632    // Bitmap: total_blocks bits, packed BE. All-zero start.
633    let bitmap_bytes = (plan.total_blocks as usize).div_ceil(8);
634    let bitmap_alloc = plan.bitmap_blocks as usize * block_size_u;
635    let mut bitmap = vec![0u8; bitmap_alloc.max(bitmap_bytes)];
636
637    // ----------------------------------------------------------------
638    // Insert the root folder record + thread record. The root folder
639    // (CNID 2) has no "parent" entry of its own — it lives at
640    // (parent=1, name=<volume>). The thread record sits at
641    // (parent=2, name="") with `parentID=1` in its data and the
642    // volume name in its `nodeName`.
643    // ----------------------------------------------------------------
644    let cmp = |a: &[u8], b: &[u8]| catalog_compare(a, b, case_sensitive);
645    let label = &snapshot.volume.label;
646    {
647        let folder_meta = root_folder_metadata(snapshot);
648        let key = build_catalog_key(1, label);
649        let record = build_folder_body(2, /* valence */ 0, &folder_meta);
650        let mut key_record = key.clone();
651        if !key_record.len().is_multiple_of(2) {
652            key_record.push(0);
653        }
654        key_record.extend_from_slice(&record);
655        hfs_common::btree_insert_full(
656            &mut catalog,
657            &key_record,
658            &hfs_common::BTreeKeyFormat::HFSPLUS_CATALOG,
659            &cmp,
660        )?;
661
662        // Root thread record.
663        let thread = build_thread_record(CATALOG_FOLDER_THREAD, /* parent=1 */ 1, label);
664        let mut tk = build_catalog_key(2, "");
665        if !tk.len().is_multiple_of(2) {
666            tk.push(0);
667        }
668        tk.extend_from_slice(&thread);
669        hfs_common::btree_insert_full(
670            &mut catalog,
671            &tk,
672            &hfs_common::BTreeKeyFormat::HFSPLUS_CATALOG,
673            &cmp,
674        )?;
675        let _ = (folder_meta, record);
676    }
677
678    // ----------------------------------------------------------------
679    // Insert directories in BFS order from root so parent valence
680    // updates work without lookups (we count children locally).
681    // ----------------------------------------------------------------
682    use std::collections::HashMap;
683    let mut child_count: HashMap<u32, u32> = HashMap::new();
684    let mut dirs_by_parent: HashMap<u32, Vec<&SourceDirSnapshot>> = HashMap::new();
685    for d in &snapshot.dirs {
686        if d.cnid == 2 {
687            continue; // root handled separately
688        }
689        dirs_by_parent.entry(d.parent_cnid).or_default().push(d);
690    }
691
692    let mut queue: std::collections::VecDeque<u32> = std::collections::VecDeque::new();
693    queue.push_back(2);
694    // Diagnostic: stderr trace every N catalog inserts so a multi-million-
695    // record clone shows progress (vs. looking like a hang). Captures
696    // wall-clock between ticks so the user can compare slow-but-progressing
697    // against truly stuck.
698    let mut insert_count: u64 = 0;
699    let tick = 50_000u64;
700    let start = std::time::Instant::now();
701    let mut last_tick = start;
702    let log_progress = |insert_count: u64, phase: &str, last_tick: &mut std::time::Instant| {
703        let now = std::time::Instant::now();
704        let dt = now.duration_since(*last_tick).as_secs_f64();
705        let total = now.duration_since(start).as_secs_f64();
706        let msg = format!(
707            "[defrag-build] {phase}: {insert_count} catalog inserts \
708             (+{tick} in {dt:.2}s, total {total:.1}s)"
709        );
710        emit_progress(&msg);
711        *last_tick = now;
712    };
713    while let Some(src_parent) = queue.pop_front() {
714        if let Some(children) = dirs_by_parent.remove(&src_parent) {
715            for d in children {
716                let target_cnid = *plan
717                    .cnid_map
718                    .get(&d.cnid)
719                    .ok_or_else(|| missing_cnid("dir", d.cnid))?;
720                let target_parent = *plan
721                    .cnid_map
722                    .get(&d.parent_cnid)
723                    .ok_or_else(|| missing_cnid("parent", d.parent_cnid))?;
724
725                let meta = dir_metadata(d);
726                let key = build_catalog_key(target_parent, &d.name);
727                let body = build_folder_body(target_cnid, d.valence, &meta);
728                let mut key_record = key.clone();
729                if !key_record.len().is_multiple_of(2) {
730                    key_record.push(0);
731                }
732                key_record.extend_from_slice(&body);
733                hfs_common::btree_insert_full(
734                    &mut catalog,
735                    &key_record,
736                    &hfs_common::BTreeKeyFormat::HFSPLUS_CATALOG,
737                    &cmp,
738                )?;
739
740                let thread = build_thread_record(CATALOG_FOLDER_THREAD, target_parent, &d.name);
741                let mut tk = build_catalog_key(target_cnid, "");
742                if !tk.len().is_multiple_of(2) {
743                    tk.push(0);
744                }
745                tk.extend_from_slice(&thread);
746                hfs_common::btree_insert_full(
747                    &mut catalog,
748                    &tk,
749                    &hfs_common::BTreeKeyFormat::HFSPLUS_CATALOG,
750                    &cmp,
751                )?;
752
753                *child_count.entry(target_parent).or_default() += 1;
754                queue.push_back(d.cnid);
755
756                insert_count += 2;
757                if insert_count.is_multiple_of(tick) {
758                    log_progress(insert_count, "dirs", &mut last_tick);
759                }
760            }
761        }
762    }
763    log_progress(insert_count, "dirs done", &mut last_tick);
764
765    // ----------------------------------------------------------------
766    // Insert files. Forks come from the planner; no allocation here.
767    // ----------------------------------------------------------------
768    for f in &snapshot.files {
769        let target_cnid = *plan
770            .cnid_map
771            .get(&f.cnid)
772            .ok_or_else(|| missing_cnid("file", f.cnid))?;
773        let target_parent = *plan
774            .cnid_map
775            .get(&f.parent_cnid)
776            .ok_or_else(|| missing_cnid("file parent", f.parent_cnid))?;
777
778        let placement = plan
779            .file_placements
780            .get(&f.cnid)
781            .ok_or_else(|| missing_cnid("placement", f.cnid))?;
782        let data_fork = make_fork_data(placement.data_fork);
783        let rsrc_fork = make_fork_data(placement.rsrc_fork);
784
785        let meta = file_metadata(f);
786        let key = build_catalog_key(target_parent, &f.name);
787        let body = build_file_body(target_cnid, &data_fork, &rsrc_fork, &meta);
788        let mut key_record = key.clone();
789        if !key_record.len().is_multiple_of(2) {
790            key_record.push(0);
791        }
792        key_record.extend_from_slice(&body);
793        hfs_common::btree_insert_full(
794            &mut catalog,
795            &key_record,
796            &hfs_common::BTreeKeyFormat::HFSPLUS_CATALOG,
797            &cmp,
798        )?;
799
800        let thread = build_thread_record(CATALOG_FILE_THREAD, target_parent, &f.name);
801        let mut tk = build_catalog_key(target_cnid, "");
802        if !tk.len().is_multiple_of(2) {
803            tk.push(0);
804        }
805        tk.extend_from_slice(&thread);
806        hfs_common::btree_insert_full(
807            &mut catalog,
808            &tk,
809            &hfs_common::BTreeKeyFormat::HFSPLUS_CATALOG,
810            &cmp,
811        )?;
812
813        *child_count.entry(target_parent).or_default() += 1;
814
815        insert_count += 2;
816        if insert_count.is_multiple_of(tick) {
817            log_progress(insert_count, "files", &mut last_tick);
818        }
819    }
820    log_progress(insert_count, "files done", &mut last_tick);
821
822    // ----------------------------------------------------------------
823    // Hardlink stubs (Step 22c-i). Each stub is a FILE catalog record —
824    // even directory hardlinks ride the FILE shape on disk per TN1150.
825    // The source already encoded `FInfo = ('hlnk','hfs+')` /
826    // `('fdrp','MACS')` and `bsdInfo.special = inode_num` on the stub
827    // row, so faithful replay is just "emit a file record with the
828    // captured metadata and empty forks." The matching inode (a regular
829    // file or directory under the target's HFS+ Private Data /
830    // .HFS+ Private Directory Data\r dir) was already laid down by the
831    // dir / file loops above; we just patch its `bsdInfo.special` to
832    // hold the live link count once we know how many stubs reference it.
833    // ----------------------------------------------------------------
834    use std::collections::HashMap as _HashMap;
835    let mut file_link_counts: _HashMap<u32, u32> = _HashMap::new();
836    let mut dir_link_counts: _HashMap<u32, u32> = _HashMap::new();
837
838    for h in &snapshot.hardlinks {
839        let target_cnid = *plan
840            .cnid_map
841            .get(&h.cnid)
842            .ok_or_else(|| missing_cnid("hardlink stub", h.cnid))?;
843        let target_parent = *plan
844            .cnid_map
845            .get(&h.parent_cnid)
846            .ok_or_else(|| missing_cnid("hardlink parent", h.parent_cnid))?;
847        let meta = file_hardlink_metadata(h);
848        let key = build_catalog_key(target_parent, &h.name);
849        let empty_data = ForkData::empty();
850        let empty_rsrc = ForkData::empty();
851        let body = build_file_body(target_cnid, &empty_data, &empty_rsrc, &meta);
852        let mut key_record = key.clone();
853        if !key_record.len().is_multiple_of(2) {
854            key_record.push(0);
855        }
856        key_record.extend_from_slice(&body);
857        hfs_common::btree_insert_full(
858            &mut catalog,
859            &key_record,
860            &hfs_common::BTreeKeyFormat::HFSPLUS_CATALOG,
861            &cmp,
862        )?;
863
864        let thread = build_thread_record(CATALOG_FILE_THREAD, target_parent, &h.name);
865        let mut tk = build_catalog_key(target_cnid, "");
866        if !tk.len().is_multiple_of(2) {
867            tk.push(0);
868        }
869        tk.extend_from_slice(&thread);
870        hfs_common::btree_insert_full(
871            &mut catalog,
872            &tk,
873            &hfs_common::BTreeKeyFormat::HFSPLUS_CATALOG,
874            &cmp,
875        )?;
876
877        *child_count.entry(target_parent).or_default() += 1;
878        *file_link_counts.entry(h.inode_num).or_default() += 1;
879    }
880
881    for h in &snapshot.dir_hardlinks {
882        let target_cnid = *plan
883            .cnid_map
884            .get(&h.cnid)
885            .ok_or_else(|| missing_cnid("dir hardlink stub", h.cnid))?;
886        let target_parent = *plan
887            .cnid_map
888            .get(&h.parent_cnid)
889            .ok_or_else(|| missing_cnid("dir hardlink parent", h.parent_cnid))?;
890        let meta = dir_hardlink_metadata(h);
891        let key = build_catalog_key(target_parent, &h.name);
892        let empty_data = ForkData::empty();
893        let empty_rsrc = ForkData::empty();
894        let body = build_file_body(target_cnid, &empty_data, &empty_rsrc, &meta);
895        let mut key_record = key.clone();
896        if !key_record.len().is_multiple_of(2) {
897            key_record.push(0);
898        }
899        key_record.extend_from_slice(&body);
900        hfs_common::btree_insert_full(
901            &mut catalog,
902            &key_record,
903            &hfs_common::BTreeKeyFormat::HFSPLUS_CATALOG,
904            &cmp,
905        )?;
906
907        let thread = build_thread_record(CATALOG_FILE_THREAD, target_parent, &h.name);
908        let mut tk = build_catalog_key(target_cnid, "");
909        if !tk.len().is_multiple_of(2) {
910            tk.push(0);
911        }
912        tk.extend_from_slice(&thread);
913        hfs_common::btree_insert_full(
914            &mut catalog,
915            &tk,
916            &hfs_common::BTreeKeyFormat::HFSPLUS_CATALOG,
917            &cmp,
918        )?;
919
920        *child_count.entry(target_parent).or_default() += 1;
921        *dir_link_counts.entry(h.inode_num).or_default() += 1;
922    }
923
924    // Patch each inode's bsdInfo.special with its live link count. The
925    // file/dir record carried the source's count through verbatim during
926    // its emission above; recomputing from actually-emitted stubs keeps
927    // us self-consistent even if the source lied (corrupted volume).
928    for f in snapshot.files.iter().filter(|f| f.is_inode) {
929        let target_cnid = *plan
930            .cnid_map
931            .get(&f.cnid)
932            .ok_or_else(|| missing_cnid("file inode", f.cnid))?;
933        let count = f
934            .inode_num
935            .and_then(|n| file_link_counts.get(&n).copied())
936            .unwrap_or(0);
937        patch_record_bsd_special(&mut catalog, target_cnid, count)?;
938    }
939    for d in snapshot.dirs.iter().filter(|d| d.is_dir_inode) {
940        let target_cnid = *plan
941            .cnid_map
942            .get(&d.cnid)
943            .ok_or_else(|| missing_cnid("dir inode", d.cnid))?;
944        let count = d
945            .inode_num
946            .and_then(|n| dir_link_counts.get(&n).copied())
947            .unwrap_or(0);
948        patch_record_bsd_special(&mut catalog, target_cnid, count)?;
949    }
950
951    // ----------------------------------------------------------------
952    // Extended attributes (Step 22c-ii). Inline xattrs replay against
953    // each remapped CNID; fork-style and extents-style xattrs are
954    // intentionally rejected (mirrors Step 13's inline-only limitation
955    // on the editing side, until fork-style xattr writes ship). The
956    // attributes buffer was pre-sized from source.vh.attributes_file
957    // so all source records fit in capacity.
958    // ----------------------------------------------------------------
959    if let Some(ref mut attr_buf) = attributes {
960        for f in &snapshot.files {
961            let target_cnid = *plan
962                .cnid_map
963                .get(&f.cnid)
964                .ok_or_else(|| missing_cnid("file xattr", f.cnid))?;
965            emit_xattrs_for_cnid(attr_buf, target_cnid, &f.xattrs, &f.name)?;
966        }
967        for d in &snapshot.dirs {
968            let target_cnid = *plan
969                .cnid_map
970                .get(&d.cnid)
971                .ok_or_else(|| missing_cnid("dir xattr", d.cnid))?;
972            emit_xattrs_for_cnid(attr_buf, target_cnid, &d.xattrs, &d.name)?;
973        }
974        for h in &snapshot.hardlinks {
975            let target_cnid = *plan
976                .cnid_map
977                .get(&h.cnid)
978                .ok_or_else(|| missing_cnid("hardlink xattr", h.cnid))?;
979            emit_xattrs_for_cnid(attr_buf, target_cnid, &h.xattrs, &h.name)?;
980        }
981        for h in &snapshot.dir_hardlinks {
982            let target_cnid = *plan
983                .cnid_map
984                .get(&h.cnid)
985                .ok_or_else(|| missing_cnid("dir hardlink xattr", h.cnid))?;
986            emit_xattrs_for_cnid(attr_buf, target_cnid, &h.xattrs, &h.name)?;
987        }
988    }
989
990    // ----------------------------------------------------------------
991    // Patch parent valences. The root's valence comes straight from
992    // the snapshot's root SourceDirSnapshot if present; otherwise we
993    // accept whatever we counted.
994    // ----------------------------------------------------------------
995    patch_folder_valences(&mut catalog, &child_count, case_sensitive)?;
996
997    // ----------------------------------------------------------------
998    // Mark allocation bitmap. Reserved blocks at front + every
999    // planned user-fork extent + alt-VH region (last block, plus
1000    // possibly the previous block to cover the 1024-byte alt-VH region).
1001    // ----------------------------------------------------------------
1002    for blk in 0..plan.reserved_blocks {
1003        hfs_common::bitmap_set_bit_be(&mut bitmap, blk);
1004    }
1005    for placement in plan.file_placements.values() {
1006        if let Some(f) = placement.data_fork {
1007            for i in 0..f.block_count {
1008                hfs_common::bitmap_set_bit_be(&mut bitmap, f.start_block + i);
1009            }
1010        }
1011        if let Some(f) = placement.rsrc_fork {
1012            for i in 0..f.block_count {
1013                hfs_common::bitmap_set_bit_be(&mut bitmap, f.start_block + i);
1014            }
1015        }
1016    }
1017    let alt_vh_blocks: u32 = 1024u32.div_ceil(block_size).max(1);
1018    for i in 0..alt_vh_blocks {
1019        hfs_common::bitmap_set_bit_be(&mut bitmap, plan.total_blocks - 1 - i);
1020    }
1021
1022    // ----------------------------------------------------------------
1023    // Volume header. Counts come from snapshot (one folder added for
1024    // root automatically — drFndrInfo[0]=blessed-folder etc. carries
1025    // through with CNID remap). free_blocks = total - allocated.
1026    // ----------------------------------------------------------------
1027    let mut allocated_blocks: u32 = plan.reserved_blocks + alt_vh_blocks;
1028    for placement in plan.file_placements.values() {
1029        if let Some(f) = placement.data_fork {
1030            allocated_blocks += f.block_count;
1031        }
1032        if let Some(f) = placement.rsrc_fork {
1033            allocated_blocks += f.block_count;
1034        }
1035    }
1036    let free_blocks = plan.total_blocks.saturating_sub(allocated_blocks);
1037
1038    let mut new_finder_info = [0u32; 8];
1039    let known: std::collections::HashSet<u32> = plan.cnid_map.keys().copied().collect();
1040    for (i, slot) in new_finder_info.iter_mut().enumerate() {
1041        if i == 6 || i == 7 {
1042            continue;
1043        }
1044        let src_val = snapshot.volume.finder_info[i];
1045        *slot = if known.contains(&src_val) {
1046            *plan.cnid_map.get(&src_val).unwrap()
1047        } else {
1048            src_val
1049        };
1050    }
1051
1052    let vh_bytes = build_volume_header(
1053        snapshot,
1054        plan,
1055        new_finder_info,
1056        snapshot.volume.file_count,
1057        snapshot.volume.folder_count,
1058        free_blocks,
1059    );
1060
1061    // Block-0 region: boot blocks (1024 from source) + primary VH (512)
1062    // + zero pad up to block_size.
1063    let mut block_zero = vec![0u8; block_size_u];
1064    block_zero[..1024].copy_from_slice(&snapshot.volume.boot_blocks);
1065    block_zero[1024..1536].copy_from_slice(&vh_bytes);
1066
1067    Ok(TargetMetadata {
1068        block_zero,
1069        bitmap,
1070        catalog,
1071        extents_overflow,
1072        attributes,
1073        vh_bytes,
1074    })
1075}
1076
1077// ----------------------------------------------------------------------
1078// Step 22d — streaming emit engine.
1079// ----------------------------------------------------------------------
1080
1081/// Outcome of [`stream_defragmented_hfsplus`]. Counts mirror the source
1082/// snapshot — every captured directory, file, hardlink stub, and xattr
1083/// is reflected in the emitted target.
1084#[derive(Debug, Default, Clone)]
1085pub struct DefragReport {
1086    pub files_copied: u64,
1087    pub dirs_copied: u64,
1088    pub data_bytes_copied: u64,
1089    pub rsrc_bytes_copied: u64,
1090    pub xattrs_copied: u64,
1091    pub hardlinks_copied: u64,
1092    pub dir_hardlinks_copied: u64,
1093    /// Total bytes emitted into the writer. Always equals `target_size`
1094    /// (the function refuses to short-write).
1095    pub bytes_emitted: u64,
1096}
1097
1098/// Stream a defragmented copy of `source` into `dst` as exactly
1099/// `target_size` bytes. Combines the planner (22b), the in-memory
1100/// metadata builder (22c), and a forward-only emit pass that interleaves
1101/// the metadata regions with user-fork bytes streamed from `source`.
1102///
1103/// The output is a flat HFS+ image suitable for opening with
1104/// [`HfsPlusFilesystem::open`] (no APM wrapper is added — that's the
1105/// caller's job, e.g. via [`emit_apm_disk_with_hfs`]).
1106///
1107/// Pre-conditions:
1108/// - `source` is opened read-only; the function never mutates it.
1109/// - `target_size` is a multiple of `source`'s block size and large
1110///   enough to hold every captured fork (validated inside the planner).
1111///
1112/// `dst` only needs `Write` — emission is single-pass and never seeks.
1113pub fn stream_defragmented_hfsplus<R, W>(
1114    source: &mut HfsPlusFilesystem<R>,
1115    target_size: u64,
1116    dst: &mut W,
1117    before_emit: Option<&dyn Fn()>,
1118) -> Result<DefragReport, FilesystemError>
1119where
1120    R: Read + Seek + Send,
1121    W: Write,
1122{
1123    let snapshot = SourceCatalogSnapshot::capture(source)?;
1124    emit_fragmentation_report(&snapshot);
1125    let plan = plan_defrag_layout(&snapshot, target_size)
1126        .map_err(|e| FilesystemError::InvalidData(format!("defrag plan failed: {e}")))?;
1127    let meta = build_target_metadata(&plan, &snapshot)?;
1128    // Random-access phases (catalog walk in `capture`, B-tree population
1129    // in `build_target_metadata`) are now done; what follows is a long,
1130    // mostly-sequential drain of fork bytes from the source. Callers can
1131    // use this hook to flip a wrapping `BulkBufReader` into bulk fill
1132    // mode so the next phase collapses thousands of small reads into a
1133    // handful of multi-hundred-MiB syscalls.
1134    if let Some(cb) = before_emit {
1135        cb();
1136    }
1137
1138    let bs = plan.block_size as u64;
1139    let mut writer = CountingWriter::new(dst);
1140
1141    // Block 0 — boot blocks + primary VH (already padded to block_size).
1142    writer.write_all(&meta.block_zero)?;
1143    debug_assert_eq!(writer.bytes_written, bs);
1144
1145    // Bitmap, catalog, extents-overflow, attributes — back-to-back per
1146    // the planner's region layout (no gaps).
1147    assert_region(
1148        writer.bytes_written,
1149        plan.bitmap_start as u64 * bs,
1150        "bitmap_start",
1151    )?;
1152    writer.write_all(&meta.bitmap)?;
1153    assert_region(
1154        writer.bytes_written,
1155        plan.catalog_start as u64 * bs,
1156        "catalog_start",
1157    )?;
1158    writer.write_all(&meta.catalog)?;
1159    assert_region(
1160        writer.bytes_written,
1161        plan.extents_start as u64 * bs,
1162        "extents_start",
1163    )?;
1164    writer.write_all(&meta.extents_overflow)?;
1165    // `plan.attributes_start` is a sentinel zero when the source had no
1166    // xattrs (planner convention) — only assert the boundary when the
1167    // attributes file actually exists.
1168    if plan.attributes_blocks > 0 {
1169        assert_region(
1170            writer.bytes_written,
1171            plan.attributes_start as u64 * bs,
1172            "attributes_start",
1173        )?;
1174    }
1175    if let Some(attrs) = meta.attributes.as_ref() {
1176        writer.write_all(attrs)?;
1177    }
1178    assert_region(
1179        writer.bytes_written,
1180        plan.user_data_start as u64 * bs,
1181        "user_data_start",
1182    )?;
1183
1184    // Build an emission schedule for the user-data area: every fork the
1185    // planner placed, sorted by start_block. Forks pack contiguously
1186    // from `user_data_start`, but we still defensively zero-fill any
1187    // gap between the previous fork's end and the next fork's start.
1188    struct ForkEmit<'a> {
1189        start_block: u32,
1190        block_count: u32,
1191        logical_size: u64,
1192        source_cnid: u32,
1193        fork_kind: u8,
1194        fork_raw: &'a [u8; 80],
1195    }
1196
1197    let mut emits: Vec<ForkEmit<'_>> = Vec::new();
1198    for f in &snapshot.files {
1199        let placement = plan
1200            .file_placements
1201            .get(&f.cnid)
1202            .ok_or_else(|| missing_cnid("file", f.cnid))?;
1203        if let Some(d) = placement.data_fork {
1204            emits.push(ForkEmit {
1205                start_block: d.start_block,
1206                block_count: d.block_count,
1207                logical_size: d.logical_size,
1208                source_cnid: f.cnid,
1209                fork_kind: HFSPLUS_FORK_DATA,
1210                fork_raw: &f.data_fork_raw,
1211            });
1212        }
1213        if let Some(r) = placement.rsrc_fork {
1214            emits.push(ForkEmit {
1215                start_block: r.start_block,
1216                block_count: r.block_count,
1217                logical_size: r.logical_size,
1218                source_cnid: f.cnid,
1219                fork_kind: HFSPLUS_FORK_RESOURCE,
1220                fork_raw: &f.rsrc_fork_raw,
1221            });
1222        }
1223    }
1224    emits.sort_by_key(|e| e.start_block);
1225
1226    let mut report = DefragReport {
1227        files_copied: snapshot.files.iter().filter(|f| !f.is_inode).count() as u64,
1228        dirs_copied: snapshot
1229            .dirs
1230            .iter()
1231            .filter(|d| d.cnid != 2 && !d.is_dir_inode)
1232            .count() as u64,
1233        hardlinks_copied: snapshot.hardlinks.len() as u64,
1234        dir_hardlinks_copied: snapshot.dir_hardlinks.len() as u64,
1235        xattrs_copied: snapshot
1236            .files
1237            .iter()
1238            .map(|f| f.xattrs.len() as u64)
1239            .sum::<u64>()
1240            + snapshot
1241                .dirs
1242                .iter()
1243                .map(|d| d.xattrs.len() as u64)
1244                .sum::<u64>()
1245            + snapshot
1246                .hardlinks
1247                .iter()
1248                .map(|h| h.xattrs.len() as u64)
1249                .sum::<u64>()
1250            + snapshot
1251                .dir_hardlinks
1252                .iter()
1253                .map(|h| h.xattrs.len() as u64)
1254                .sum::<u64>(),
1255        ..DefragReport::default()
1256    };
1257
1258    for emit in &emits {
1259        let target_offset = emit.start_block as u64 * bs;
1260        let cursor = writer.bytes_written;
1261        if target_offset > cursor {
1262            zero_fill(&mut writer, target_offset - cursor)?;
1263        } else if target_offset < cursor {
1264            return Err(FilesystemError::InvalidData(format!(
1265                "defrag emit overlap: fork at block {} starts before cursor at byte {}",
1266                emit.start_block, cursor
1267            )));
1268        }
1269
1270        // Stream `logical_size` bytes from source, then zero-pad to
1271        // `block_count * bs` so the next fork lands on its planned block.
1272        let mut reader =
1273            source.fork_stream_reader(emit.source_cnid, emit.fork_kind, emit.fork_raw)?;
1274        let copied = std::io::copy(&mut reader, &mut writer)?;
1275        if copied != emit.logical_size {
1276            return Err(FilesystemError::InvalidData(format!(
1277                "defrag emit short read: file {} fork {:#x}: expected {} bytes, got {}",
1278                emit.source_cnid, emit.fork_kind, emit.logical_size, copied
1279            )));
1280        }
1281        let extent_bytes = emit.block_count as u64 * bs;
1282        if copied > extent_bytes {
1283            return Err(FilesystemError::InvalidData(format!(
1284                "defrag emit overflow: file {} fork {:#x}: {} bytes copied exceeds extent {}",
1285                emit.source_cnid, emit.fork_kind, copied, extent_bytes
1286            )));
1287        }
1288        zero_fill(&mut writer, extent_bytes - copied)?;
1289
1290        match emit.fork_kind {
1291            HFSPLUS_FORK_DATA => report.data_bytes_copied += copied,
1292            HFSPLUS_FORK_RESOURCE => report.rsrc_bytes_copied += copied,
1293            _ => {}
1294        }
1295    }
1296
1297    // Zero-pad from the end of the last user fork up to the alt-VH
1298    // region (last 1024 bytes of the volume).
1299    let alt_vh_offset = target_size.saturating_sub(1024);
1300    let cursor = writer.bytes_written;
1301    if cursor > alt_vh_offset {
1302        return Err(FilesystemError::InvalidData(format!(
1303            "defrag emit overran into alt-VH region: cursor {cursor} > alt offset {alt_vh_offset}"
1304        )));
1305    }
1306    zero_fill(&mut writer, alt_vh_offset - cursor)?;
1307    writer.write_all(&meta.vh_bytes)?;
1308    let trailing = target_size - writer.bytes_written;
1309    zero_fill(&mut writer, trailing)?;
1310
1311    if writer.bytes_written != target_size {
1312        return Err(FilesystemError::InvalidData(format!(
1313            "defrag emit byte count mismatch: wrote {}, expected {}",
1314            writer.bytes_written, target_size
1315        )));
1316    }
1317    report.bytes_emitted = writer.bytes_written;
1318    Ok(report)
1319}
1320
1321/// HFS+ fork-type bytes (mirrors the constants in `hfsplus_clone.rs`).
1322const HFSPLUS_FORK_DATA: u8 = 0x00;
1323const HFSPLUS_FORK_RESOURCE: u8 = 0xFF;
1324
1325/// Wraps a `Write` and tracks total bytes emitted. The streaming emit
1326/// engine relies on the running count for region-boundary debug
1327/// assertions and for computing zero-fill spans.
1328struct CountingWriter<'a, W: Write> {
1329    inner: &'a mut W,
1330    bytes_written: u64,
1331}
1332
1333impl<'a, W: Write> CountingWriter<'a, W> {
1334    fn new(inner: &'a mut W) -> Self {
1335        Self {
1336            inner,
1337            bytes_written: 0,
1338        }
1339    }
1340}
1341
1342impl<'a, W: Write> Write for CountingWriter<'a, W> {
1343    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
1344        let n = self.inner.write(buf)?;
1345        self.bytes_written += n as u64;
1346        Ok(n)
1347    }
1348    fn flush(&mut self) -> std::io::Result<()> {
1349        self.inner.flush()
1350    }
1351}
1352
1353/// Verify that the streaming cursor lands exactly on the planned region
1354/// boundary. Catches metadata-buffer length drift between the planner
1355/// and the builder before we emit a malformed image.
1356fn assert_region(actual: u64, expected: u64, name: &str) -> Result<(), FilesystemError> {
1357    if actual != expected {
1358        return Err(FilesystemError::InvalidData(format!(
1359            "defrag emit cursor at {actual} but planner placed {name} at {expected}"
1360        )));
1361    }
1362    Ok(())
1363}
1364
1365/// Emit `n` zero bytes to `writer` using a 64 KiB scratch buffer.
1366fn zero_fill<W: Write>(writer: &mut W, n: u64) -> std::io::Result<()> {
1367    if n == 0 {
1368        return Ok(());
1369    }
1370    static BUF: [u8; 65536] = [0u8; 65536];
1371    let mut left = n;
1372    while left > 0 {
1373        let chunk = left.min(BUF.len() as u64) as usize;
1374        writer.write_all(&BUF[..chunk])?;
1375        left -= chunk as u64;
1376    }
1377    Ok(())
1378}
1379
1380// ----------------------------------------------------------------------
1381// Helpers (private to this module).
1382// ----------------------------------------------------------------------
1383
1384fn missing_cnid(kind: &str, cnid: u32) -> FilesystemError {
1385    FilesystemError::InvalidData(format!(
1386        "defrag plan is missing the {kind} CNID {cnid} mapping"
1387    ))
1388}
1389
1390/// Initialise a B-tree buffer (`total_bytes` long) with one header
1391/// node + one empty leaf node. The remaining nodes are free.
1392fn init_btree_buffer(total_bytes: usize, max_key_len: u16, key_compare_type: u8) -> Vec<u8> {
1393    assert!(
1394        total_bytes.is_multiple_of(NODE_SIZE),
1395        "btree size must be node-aligned"
1396    );
1397    let total_nodes = (total_bytes / NODE_SIZE) as u32;
1398    // Layout: 0 = header, 2..2+map_nodes = map nodes (BTNodeKind=2), then free
1399    // nodes. Node 1 is left free; the first `btree_insert_full` bootstraps it
1400    // into the root leaf (matching `write_blank_btree_header_node`'s canonical
1401    // empty-tree shape: depth 0 / root 0, no leaf node until the first insert).
1402    // Map nodes are required whenever the catalog exceeds the header bitmap's
1403    // capacity (~30,720 nodes at NODE_SIZE=4096, i.e. ~120 MiB catalog file);
1404    // OS 9 volumes with 100k+ catalog records routinely need them.
1405    let map_nodes = hfs_common::map_nodes_required(total_nodes, NODE_SIZE);
1406    let used_nodes = 1 + map_nodes; // header + map nodes; node 1 is free
1407    assert!(
1408        total_nodes >= used_nodes + 2,
1409        "need at least {} nodes (header + {} map nodes + 2 free), got {}",
1410        used_nodes + 2,
1411        map_nodes,
1412        total_nodes,
1413    );
1414    let mut buf = vec![0u8; total_bytes];
1415    write_blank_btree_header_node(
1416        &mut buf[..NODE_SIZE],
1417        NODE_SIZE,
1418        /* leaf_records= */ 0,
1419        total_nodes,
1420        /* free_nodes= */ total_nodes - used_nodes,
1421        max_key_len,
1422        key_compare_type,
1423    );
1424
1425    if map_nodes > 0 {
1426        // Header.fLink points at the first map node so `btree_bitmap_segments`
1427        // walks the chain when allocating from the global node space.
1428        BigEndian::write_u32(&mut buf[0..4], 2);
1429        // Mark each map node as allocated in the header's bitmap (record 2,
1430        // at offset 248 — matches `write_blank_btree_header_node`'s canonical
1431        // TN1150 layout). With NODE_SIZE=4096 the header bitmap covers ~30,720
1432        // bits; map nodes sit at indices 2..2+map_nodes, comfortably inside.
1433        let bitmap_rec_offset = 248usize;
1434        let header_bitmap_len = NODE_SIZE - 256; // = 3840 bytes
1435        for i in 0..map_nodes {
1436            let bit = 2 + i;
1437            let byte_idx = (bit / 8) as usize;
1438            let bit_in_byte = 7 - (bit % 8) as u8;
1439            if byte_idx < header_bitmap_len {
1440                buf[bitmap_rec_offset + byte_idx] |= 1u8 << bit_in_byte;
1441            }
1442        }
1443        // Initialise each map node with the prev/next chain pointers.
1444        for i in 0..map_nodes {
1445            let node_idx = 2 + i;
1446            let next_link = if i + 1 < map_nodes { node_idx + 1 } else { 0 };
1447            let prev_link = if i == 0 { 0 } else { node_idx - 1 };
1448            hfs_common::init_map_node(&mut buf, NODE_SIZE, node_idx, prev_link, next_link);
1449        }
1450    }
1451
1452    buf
1453}
1454
1455/// Build an HFS+ catalog key (`key_len(2) + parent_id(4) + name_length(2) + name(UTF-16BE NFD)`).
1456/// Mirrors `HfsPlusFilesystem::build_catalog_key` (which is `pub(crate)`).
1457fn build_catalog_key(parent_cnid: u32, name: &str) -> Vec<u8> {
1458    HfsPlusFilesystem::<std::io::Cursor<Vec<u8>>>::build_catalog_key(parent_cnid, name)
1459}
1460
1461/// HFS+ catalog key compare. Mirrors `HfsPlusFilesystem::catalog_compare`.
1462fn catalog_compare(a: &[u8], b: &[u8], case_sensitive: bool) -> Ordering {
1463    if a.len() < 8 || b.len() < 8 {
1464        return a.len().cmp(&b.len());
1465    }
1466    let parent_a = BigEndian::read_u32(&a[2..6]);
1467    let parent_b = BigEndian::read_u32(&b[2..6]);
1468    let name_len_a = BigEndian::read_u16(&a[6..8]) as usize;
1469    let name_len_b = BigEndian::read_u16(&b[6..8]) as usize;
1470    let name_a: Vec<u16> = a[8..8 + name_len_a.min((a.len() - 8) / 2) * 2]
1471        .chunks_exact(2)
1472        .map(BigEndian::read_u16)
1473        .collect();
1474    let name_b: Vec<u16> = b[8..8 + name_len_b.min((b.len() - 8) / 2) * 2]
1475        .chunks_exact(2)
1476        .map(BigEndian::read_u16)
1477        .collect();
1478    hfs_common::compare_hfsplus_keys(parent_a, &name_a, parent_b, &name_b, case_sensitive)
1479}
1480
1481/// Per-record metadata shared by file/folder bodies (dates + 16-byte
1482/// FInfo/FXInfo + 16-byte BSDInfo + 4-byte textEncoding).
1483#[derive(Default, Clone, Copy)]
1484struct CatalogRecordMeta {
1485    flags: u16,
1486    create_date: u32,
1487    content_mod_date: u32,
1488    attribute_mod_date: u32,
1489    access_date: u32,
1490    backup_date: u32,
1491    bsd_info: [u8; 16],
1492    finder_info: [u8; 16],
1493    extended_finder_info: [u8; 16],
1494    text_encoding: u32,
1495}
1496
1497fn file_metadata(f: &SourceFileSnapshot) -> CatalogRecordMeta {
1498    CatalogRecordMeta {
1499        flags: f.flags,
1500        create_date: f.create_date,
1501        content_mod_date: f.content_mod_date,
1502        attribute_mod_date: f.attribute_mod_date,
1503        access_date: f.access_date,
1504        backup_date: f.backup_date,
1505        bsd_info: f.bsd_info,
1506        finder_info: f.finder_info,
1507        extended_finder_info: f.extended_finder_info,
1508        text_encoding: f.text_encoding,
1509    }
1510}
1511
1512fn file_hardlink_metadata(h: &SourceHardlinkSnapshot) -> CatalogRecordMeta {
1513    CatalogRecordMeta {
1514        flags: h.flags,
1515        create_date: h.create_date,
1516        content_mod_date: h.content_mod_date,
1517        attribute_mod_date: h.attribute_mod_date,
1518        access_date: h.access_date,
1519        backup_date: h.backup_date,
1520        bsd_info: h.bsd_info,
1521        finder_info: h.finder_info,
1522        extended_finder_info: h.extended_finder_info,
1523        text_encoding: h.text_encoding,
1524    }
1525}
1526
1527fn dir_hardlink_metadata(h: &SourceDirHardlinkSnapshot) -> CatalogRecordMeta {
1528    CatalogRecordMeta {
1529        flags: h.flags,
1530        create_date: h.create_date,
1531        content_mod_date: h.content_mod_date,
1532        attribute_mod_date: h.attribute_mod_date,
1533        access_date: h.access_date,
1534        backup_date: h.backup_date,
1535        bsd_info: h.bsd_info,
1536        finder_info: h.finder_info,
1537        extended_finder_info: h.extended_finder_info,
1538        text_encoding: h.text_encoding,
1539    }
1540}
1541
1542fn dir_metadata(d: &SourceDirSnapshot) -> CatalogRecordMeta {
1543    CatalogRecordMeta {
1544        flags: d.flags,
1545        create_date: d.create_date,
1546        content_mod_date: d.content_mod_date,
1547        attribute_mod_date: d.attribute_mod_date,
1548        access_date: d.access_date,
1549        backup_date: d.backup_date,
1550        bsd_info: d.bsd_info,
1551        finder_info: d.finder_info,
1552        extended_finder_info: d.extended_finder_info,
1553        text_encoding: d.text_encoding,
1554    }
1555}
1556
1557/// Find the source's root-dir snapshot (parent=1, cnid=2) and lift its
1558/// metadata onto the target's root.
1559fn root_folder_metadata(snapshot: &SourceCatalogSnapshot) -> CatalogRecordMeta {
1560    snapshot
1561        .dirs
1562        .iter()
1563        .find(|d| d.cnid == 2)
1564        .map(dir_metadata)
1565        .unwrap_or_default()
1566}
1567
1568/// Build an 88-byte HFS+ folder record body.
1569fn build_folder_body(folder_id: u32, valence: u32, meta: &CatalogRecordMeta) -> [u8; 88] {
1570    let mut rec = [0u8; 88];
1571    BigEndian::write_i16(&mut rec[0..2], CATALOG_FOLDER);
1572    BigEndian::write_u16(&mut rec[2..4], meta.flags);
1573    BigEndian::write_u32(&mut rec[4..8], valence);
1574    BigEndian::write_u32(&mut rec[8..12], folder_id);
1575    BigEndian::write_u32(&mut rec[12..16], meta.create_date);
1576    BigEndian::write_u32(&mut rec[16..20], meta.content_mod_date);
1577    BigEndian::write_u32(&mut rec[20..24], meta.attribute_mod_date);
1578    BigEndian::write_u32(&mut rec[24..28], meta.access_date);
1579    BigEndian::write_u32(&mut rec[28..32], meta.backup_date);
1580    rec[32..48].copy_from_slice(&meta.bsd_info);
1581    rec[48..64].copy_from_slice(&meta.finder_info);
1582    rec[64..80].copy_from_slice(&meta.extended_finder_info);
1583    BigEndian::write_u32(&mut rec[80..84], meta.text_encoding);
1584    rec
1585}
1586
1587/// Build a 248-byte HFS+ file record body.
1588fn build_file_body(
1589    file_id: u32,
1590    data_fork: &ForkData,
1591    rsrc_fork: &ForkData,
1592    meta: &CatalogRecordMeta,
1593) -> [u8; 248] {
1594    let mut rec = [0u8; 248];
1595    BigEndian::write_i16(&mut rec[0..2], CATALOG_FILE);
1596    BigEndian::write_u16(&mut rec[2..4], meta.flags);
1597    // body[4..8] reserved1
1598    BigEndian::write_u32(&mut rec[8..12], file_id);
1599    BigEndian::write_u32(&mut rec[12..16], meta.create_date);
1600    BigEndian::write_u32(&mut rec[16..20], meta.content_mod_date);
1601    BigEndian::write_u32(&mut rec[20..24], meta.attribute_mod_date);
1602    BigEndian::write_u32(&mut rec[24..28], meta.access_date);
1603    BigEndian::write_u32(&mut rec[28..32], meta.backup_date);
1604    rec[32..48].copy_from_slice(&meta.bsd_info);
1605    rec[48..64].copy_from_slice(&meta.finder_info);
1606    rec[64..80].copy_from_slice(&meta.extended_finder_info);
1607    BigEndian::write_u32(&mut rec[80..84], meta.text_encoding);
1608    // body[84..88] reserved2
1609    serialize_fork(data_fork, &mut rec[88..168]);
1610    serialize_fork(rsrc_fork, &mut rec[168..248]);
1611    rec
1612}
1613
1614/// Build a thread record (`type(2)+reserved(2)+parentID(4)+name`).
1615fn build_thread_record(record_type: i16, parent_cnid: u32, name: &str) -> Vec<u8> {
1616    let utf16: Vec<u16> = fs_mac_unicode::hfs_unicode::decompose_str(name);
1617    let mut rec = Vec::with_capacity(10 + utf16.len() * 2);
1618    let mut b2 = [0u8; 2];
1619    let mut b4 = [0u8; 4];
1620    BigEndian::write_i16(&mut b2, record_type);
1621    rec.extend_from_slice(&b2);
1622    rec.extend_from_slice(&[0, 0]); // reserved
1623    BigEndian::write_u32(&mut b4, parent_cnid);
1624    rec.extend_from_slice(&b4);
1625    BigEndian::write_u16(&mut b2, utf16.len() as u16);
1626    rec.extend_from_slice(&b2);
1627    for &ch in &utf16 {
1628        BigEndian::write_u16(&mut b2, ch);
1629        rec.extend_from_slice(&b2);
1630    }
1631    rec
1632}
1633
1634/// Translate a planned [`ForkPlacement`] into an HFS+ [`ForkData`].
1635fn make_fork_data(placement: Option<ForkPlacement>) -> ForkData {
1636    let mut fork = ForkData::empty();
1637    if let Some(p) = placement {
1638        fork.logical_size = p.logical_size;
1639        fork.total_blocks = p.block_count;
1640        fork.extents[0] = ExtentDescriptor {
1641            start_block: p.start_block,
1642            block_count: p.block_count,
1643        };
1644    }
1645    fork
1646}
1647
1648/// Serialize a `ForkData` into the 80-byte on-disk layout.
1649fn serialize_fork(fork: &ForkData, out: &mut [u8]) {
1650    BigEndian::write_u64(&mut out[0..8], fork.logical_size);
1651    BigEndian::write_u32(&mut out[8..12], fork.clump_size);
1652    BigEndian::write_u32(&mut out[12..16], fork.total_blocks);
1653    for (i, ext) in fork.extents.iter().enumerate() {
1654        let off = 16 + i * 8;
1655        BigEndian::write_u32(&mut out[off..off + 4], ext.start_block);
1656        BigEndian::write_u32(&mut out[off + 4..off + 8], ext.block_count);
1657    }
1658}
1659
1660/// Walk the catalog after every record's been inserted and stamp each
1661/// folder record's `valence` with the count of inserted children for
1662/// that folder. The records were inserted with `valence=0` initially
1663/// (root) or the source's valence (other dirs); for streamed defrag
1664/// we recompute from the actually-inserted children to stay self-
1665/// consistent.
1666/// Emit one CNID's worth of xattrs into the target attributes B-tree.
1667/// Inline records flow through `HfsPlusFilesystem::build_inline_attr_record`
1668/// and `HfsPlusFilesystem::attr_compare` — same builders the editing
1669/// path uses, so on-disk layout matches byte-for-byte. Fork-style
1670/// (`Fork(...)`) and extents-continuation (`Extents(...)`) records
1671/// surface a controlled error mirroring Step 13's inline-only
1672/// limitation; landing fork-style xattr writes is a separate feature.
1673fn emit_xattrs_for_cnid(
1674    attr_buf: &mut [u8],
1675    target_cnid: u32,
1676    xattrs: &[XattrRecord],
1677    label: &str,
1678) -> Result<(), FilesystemError> {
1679    for x in xattrs {
1680        match &x.kind {
1681            XattrKind::Inline(value) => {
1682                let rec = HfsPlusFilesystem::<std::io::Cursor<Vec<u8>>>::build_inline_attr_record(
1683                    target_cnid,
1684                    &x.name,
1685                    value,
1686                );
1687                hfs_common::btree_insert_full(
1688                    attr_buf,
1689                    &rec,
1690                    &hfs_common::BTreeKeyFormat::HFSPLUS_ATTRIBUTES,
1691                    &HfsPlusFilesystem::<std::io::Cursor<Vec<u8>>>::attr_compare,
1692                )?;
1693            }
1694            XattrKind::Fork(_) | XattrKind::Extents(_) => {
1695                return Err(FilesystemError::Unsupported(format!(
1696                    "{label}: fork-style extended attribute '{}' is not supported by \
1697                     the streamed defrag emit path (inline xattrs only — same limitation \
1698                     as Step 13's editing path)",
1699                    x.name
1700                )));
1701            }
1702        }
1703    }
1704    Ok(())
1705}
1706
1707/// Locate the catalog record for `target_cnid` (file or folder, not a
1708/// thread) and stamp its `bsdInfo.special` u32 (record body offset
1709/// 32+12 = 44) with `value`. Used by 22c-i to write live link counts
1710/// onto inode rows after every stub has been emitted.
1711fn patch_record_bsd_special(
1712    catalog: &mut [u8],
1713    target_cnid: u32,
1714    value: u32,
1715) -> Result<(), FilesystemError> {
1716    let header = hfs_common::BTreeHeader::read(catalog);
1717    let node_size = header.node_size as usize;
1718    let mut node_idx = header.first_leaf_node;
1719    while node_idx != 0 {
1720        let off = node_idx as usize * node_size;
1721        let next_node = BigEndian::read_u32(&catalog[off..off + 4]);
1722        let num_records = BigEndian::read_u16(&catalog[off + 10..off + 12]) as usize;
1723        for rec_idx in 0..num_records {
1724            let (rec_start, _) =
1725                hfs_common::btree_record_range(&catalog[off..off + node_size], node_size, rec_idx);
1726            let abs = off + rec_start;
1727            let key_len = BigEndian::read_u16(&catalog[abs..abs + 2]) as usize;
1728            let mut body = abs + 2 + key_len;
1729            if !body.is_multiple_of(2) {
1730                body += 1;
1731            }
1732            if body + 48 > catalog.len() {
1733                continue;
1734            }
1735            let record_type = BigEndian::read_i16(&catalog[body..body + 2]);
1736            // Both folder (88B) and file (248B) records carry CNID at
1737            // body+8 and bsdInfo at body+32; thread records (3/4) don't
1738            // carry bsdInfo and are skipped.
1739            if record_type != CATALOG_FOLDER && record_type != CATALOG_FILE {
1740                continue;
1741            }
1742            let cnid = BigEndian::read_u32(&catalog[body + 8..body + 12]);
1743            if cnid == target_cnid {
1744                BigEndian::write_u32(&mut catalog[body + 44..body + 48], value);
1745                return Ok(());
1746            }
1747        }
1748        node_idx = next_node;
1749    }
1750    Err(FilesystemError::NotFound(format!(
1751        "catalog record for inode CNID {target_cnid} not found while patching link count"
1752    )))
1753}
1754
1755fn patch_folder_valences(
1756    catalog: &mut [u8],
1757    counts: &std::collections::HashMap<u32, u32>,
1758    case_sensitive: bool,
1759) -> Result<(), FilesystemError> {
1760    let header = hfs_common::BTreeHeader::read(catalog);
1761    let node_size = header.node_size as usize;
1762    let first_leaf = header.first_leaf_node;
1763    let mut node_idx = first_leaf;
1764    while node_idx != 0 {
1765        let off = node_idx as usize * node_size;
1766        let next_node = BigEndian::read_u32(&catalog[off..off + 4]);
1767        let num_records = BigEndian::read_u16(&catalog[off + 10..off + 12]) as usize;
1768        for rec_idx in 0..num_records {
1769            let (rec_start, _rec_end) =
1770                hfs_common::btree_record_range(&catalog[off..off + node_size], node_size, rec_idx);
1771            let abs = off + rec_start;
1772            let key_len = BigEndian::read_u16(&catalog[abs..abs + 2]) as usize;
1773            let mut body = abs + 2 + key_len;
1774            if !body.is_multiple_of(2) {
1775                body += 1;
1776            }
1777            if body + 12 > catalog.len() {
1778                continue;
1779            }
1780            let record_type = BigEndian::read_i16(&catalog[body..body + 2]);
1781            if record_type != CATALOG_FOLDER {
1782                continue;
1783            }
1784            let cnid = BigEndian::read_u32(&catalog[body + 8..body + 12]);
1785            let count = counts.get(&cnid).copied().unwrap_or(0);
1786            BigEndian::write_u32(&mut catalog[body + 4..body + 8], count);
1787        }
1788        node_idx = next_node;
1789    }
1790    let _ = case_sensitive;
1791    Ok(())
1792}
1793
1794/// Build the 512-byte HFS+ volume header.
1795fn build_volume_header(
1796    snapshot: &SourceCatalogSnapshot,
1797    plan: &TargetLayoutPlan,
1798    finder_info: [u32; 8],
1799    file_count: u32,
1800    folder_count: u32,
1801    free_blocks: u32,
1802) -> [u8; 512] {
1803    let mut out = [0u8; 512];
1804    let signature = snapshot.volume.signature;
1805    let version: u16 = if signature == 0x4858 { 5 } else { 4 };
1806    BigEndian::write_u16(&mut out[0..2], signature);
1807    BigEndian::write_u16(&mut out[2..4], version);
1808    BigEndian::write_u32(&mut out[4..8], HFSPLUS_VOLUME_UNMOUNTED_BIT); // attributes
1809    BigEndian::write_u32(&mut out[8..12], 0); // last_mounted_version
1810    BigEndian::write_u32(&mut out[12..16], 0); // journal_info_block (no journal)
1811    BigEndian::write_u32(&mut out[16..20], snapshot.volume.create_date);
1812    BigEndian::write_u32(&mut out[20..24], snapshot.volume.modify_date);
1813    BigEndian::write_u32(&mut out[24..28], snapshot.volume.backup_date);
1814    BigEndian::write_u32(&mut out[28..32], snapshot.volume.checked_date);
1815    BigEndian::write_u32(&mut out[32..36], file_count);
1816    BigEndian::write_u32(&mut out[36..40], folder_count);
1817    BigEndian::write_u32(&mut out[40..44], plan.block_size);
1818    BigEndian::write_u32(&mut out[44..48], plan.total_blocks);
1819    BigEndian::write_u32(&mut out[48..52], free_blocks);
1820    BigEndian::write_u32(&mut out[52..56], plan.user_data_start); // next_allocation
1821    BigEndian::write_u32(&mut out[56..60], plan.block_size); // rsrc_clump
1822    BigEndian::write_u32(&mut out[60..64], plan.block_size); // data_clump
1823    let max_target_cnid = plan.cnid_map.values().copied().max().unwrap_or(15);
1824    BigEndian::write_u32(&mut out[64..68], max_target_cnid + 1);
1825    BigEndian::write_u32(&mut out[68..72], 0); // write_count
1826    BigEndian::write_u64(&mut out[72..80], 1); // encodings_bitmap (MacRoman)
1827    for i in 0..8 {
1828        BigEndian::write_u32(&mut out[80 + i * 4..84 + i * 4], finder_info[i]);
1829    }
1830
1831    // allocation_file at bitmap_start..+bitmap_blocks.
1832    let bitmap_logical = plan.bitmap_blocks as u64 * plan.block_size as u64;
1833    write_fork_descriptor(
1834        &mut out[112..192],
1835        bitmap_logical,
1836        plan.bitmap_blocks,
1837        plan.bitmap_start,
1838        plan.bitmap_blocks,
1839    );
1840    // extents_file
1841    let extents_logical = plan.extents_blocks as u64 * plan.block_size as u64;
1842    write_fork_descriptor(
1843        &mut out[192..272],
1844        extents_logical,
1845        plan.extents_blocks,
1846        plan.extents_start,
1847        plan.extents_blocks,
1848    );
1849    // catalog_file
1850    let catalog_logical = plan.catalog_blocks as u64 * plan.block_size as u64;
1851    write_fork_descriptor(
1852        &mut out[272..352],
1853        catalog_logical,
1854        plan.catalog_blocks,
1855        plan.catalog_start,
1856        plan.catalog_blocks,
1857    );
1858    // attributes_file (zero/empty when no xattrs).
1859    let attr_logical = plan.attributes_blocks as u64 * plan.block_size as u64;
1860    write_fork_descriptor(
1861        &mut out[352..432],
1862        attr_logical,
1863        plan.attributes_blocks,
1864        plan.attributes_start,
1865        plan.attributes_blocks,
1866    );
1867    // startup_file: zero.
1868    out
1869}
1870
1871fn write_fork_descriptor(
1872    out: &mut [u8],
1873    logical_size: u64,
1874    total_blocks: u32,
1875    start_block: u32,
1876    block_count: u32,
1877) {
1878    BigEndian::write_u64(&mut out[0..8], logical_size);
1879    BigEndian::write_u32(&mut out[8..12], 0); // clump
1880    BigEndian::write_u32(&mut out[12..16], total_blocks);
1881    if block_count > 0 {
1882        BigEndian::write_u32(&mut out[16..20], start_block);
1883        BigEndian::write_u32(&mut out[20..24], block_count);
1884    }
1885}
1886
1887// Suppress unused warnings on the SourceHardlinkSnapshot / SourceDirHardlinkSnapshot
1888// types until 22c follow-ups (file + dir hardlinks) wire them in.
1889#[allow(dead_code)]
1890fn _touch(_: &SourceHardlinkSnapshot, _: &SourceDirHardlinkSnapshot) {}
1891
1892#[cfg(test)]
1893mod tests {
1894    use std::io::Cursor;
1895
1896    use super::*;
1897    use crate::hfsplus::{create_blank_hfsplus, HfsPlusFilesystem};
1898    use crate::hfsplus_clone::SourceCatalogSnapshot;
1899    use disk_core::filesystem::{
1900        CreateDirectoryOptions, CreateFileOptions, EditableFilesystem, Filesystem,
1901    };
1902
1903    /// Build a small synthetic HFS+ source with two files (different
1904    /// fork sizes) and one directory, capture it, and exercise the
1905    /// planner. Asserts CNID-remap is sequential, fork extents are
1906    /// contiguous, and reserved-region sizes are sane.
1907    #[test]
1908    fn plan_assigns_contiguous_packed_extents() {
1909        let mut img = create_blank_hfsplus(32 * 1024 * 1024, 4096, "Source", false);
1910        {
1911            let cur = Cursor::new(&mut img);
1912            let mut fs = HfsPlusFilesystem::open(cur, 0).unwrap();
1913            fs.prepare_for_edit().unwrap();
1914            let root = fs.root().unwrap();
1915            let _ = fs
1916                .create_directory(&root, "Docs", &CreateDirectoryOptions::default())
1917                .unwrap();
1918            let small_payload = b"small";
1919            let mut sd = Cursor::new(small_payload);
1920            fs.create_file(
1921                &root,
1922                "small.txt",
1923                &mut sd,
1924                small_payload.len() as u64,
1925                &CreateFileOptions::default(),
1926            )
1927            .unwrap();
1928            // Big enough to take 2 blocks (>4096 bytes).
1929            let big_payload = vec![0xABu8; 5000];
1930            let mut bd = Cursor::new(&big_payload);
1931            fs.create_file(
1932                &root,
1933                "big.bin",
1934                &mut bd,
1935                big_payload.len() as u64,
1936                &CreateFileOptions::default(),
1937            )
1938            .unwrap();
1939            fs.sync_metadata().unwrap();
1940        }
1941
1942        let cur = Cursor::new(&mut img);
1943        let mut fs = HfsPlusFilesystem::open(cur, 0).unwrap();
1944        let snap = SourceCatalogSnapshot::capture(&mut fs).unwrap();
1945
1946        // Plan a 16 MiB target — comfortably larger than the small
1947        // user-data footprint, smaller than the source.
1948        let plan = plan_defrag_layout(&snap, 16 * 1024 * 1024).expect("plan should succeed");
1949
1950        // Block size matches source.
1951        assert_eq!(plan.block_size, 4096);
1952        assert_eq!(plan.total_blocks, (16 * 1024 * 1024) / 4096);
1953
1954        // Reserved region is at the front and non-empty.
1955        assert_eq!(plan.bitmap_start, 1);
1956        assert!(plan.bitmap_blocks >= 1);
1957        assert!(plan.catalog_start > plan.bitmap_start);
1958        assert!(plan.catalog_blocks >= 1);
1959        assert!(plan.extents_start >= plan.catalog_start + plan.catalog_blocks);
1960        assert!(plan.user_data_start >= plan.extents_start + plan.extents_blocks);
1961        assert_eq!(plan.reserved_blocks, plan.user_data_start);
1962
1963        // CNID map: root maps to root; user dirs / files get sequential
1964        // CNIDs starting at 16.
1965        assert_eq!(plan.cnid_map.get(&2).copied(), Some(2));
1966        let mut targets: Vec<u32> = plan
1967            .cnid_map
1968            .iter()
1969            .filter(|(&src, _)| src != 2)
1970            .map(|(_, &t)| t)
1971            .collect();
1972        targets.sort_unstable();
1973        let want: Vec<u32> = (FIRST_USER_CNID..FIRST_USER_CNID + targets.len() as u32).collect();
1974        assert_eq!(
1975            targets, want,
1976            "non-root CNIDs must be sequential from {FIRST_USER_CNID}"
1977        );
1978
1979        // File placements: small (1 block), big (2 blocks). Both packed
1980        // back-to-back starting at user_data_start.
1981        let small = snap
1982            .files
1983            .iter()
1984            .find(|f| f.name == "small.txt")
1985            .expect("small file");
1986        let big = snap
1987            .files
1988            .iter()
1989            .find(|f| f.name == "big.bin")
1990            .expect("big");
1991        let small_p = plan.file_placements.get(&small.cnid).expect("small placed");
1992        let big_p = plan.file_placements.get(&big.cnid).expect("big placed");
1993        let small_data = small_p.data_fork.expect("small data fork");
1994        let big_data = big_p.data_fork.expect("big data fork");
1995        assert_eq!(small_data.block_count, 1);
1996        assert_eq!(big_data.block_count, 2);
1997        // Contiguity: order depends on source CNID; whichever comes
1998        // first starts at user_data_start, the next directly after.
1999        let mut starts = [
2000            (small.cnid, small_data.start_block, small_data.block_count),
2001            (big.cnid, big_data.start_block, big_data.block_count),
2002        ];
2003        starts.sort_by_key(|&(cnid, _, _)| cnid);
2004        assert_eq!(starts[0].1, plan.user_data_start);
2005        assert_eq!(starts[1].1, starts[0].1 + starts[0].2);
2006
2007        // Resource forks are absent — empty fork → None.
2008        assert!(small_p.rsrc_fork.is_none());
2009        assert!(big_p.rsrc_fork.is_none());
2010    }
2011
2012    #[test]
2013    fn plan_refuses_target_smaller_than_metadata() {
2014        // A blank source's reserved region is roughly 13 blocks
2015        // (1 boot+VH + 1 bitmap + 6 catalog (4×1.5) + 4 extents + 0
2016        // attributes + 1 alt-VH). A 32 KiB target = 8 blocks won't fit.
2017        let mut img = create_blank_hfsplus(32 * 1024 * 1024, 4096, "Tiny", false);
2018        let cur = Cursor::new(&mut img);
2019        let mut fs = HfsPlusFilesystem::open(cur, 0).unwrap();
2020        let snap = SourceCatalogSnapshot::capture(&mut fs).unwrap();
2021        let err = plan_defrag_layout(&snap, 32 * 1024).expect_err("32 KiB target must be refused");
2022        match err {
2023            PlanError::TargetTooSmallForMetadata { .. } => {}
2024            other => panic!("unexpected error: {other:?}"),
2025        }
2026    }
2027
2028    /// Round-trip: build a small synthetic source, plan + build target
2029    /// metadata, stitch into a sparse target image, open as
2030    /// HfsPlusFilesystem, walk the catalog. Verifies dirs and files
2031    /// land at the right CNIDs with byte-correct fork descriptors,
2032    /// and that fsck reports clean.
2033    #[test]
2034    fn build_metadata_round_trip_dirs_and_files() {
2035        // Source: 32 MiB blank with one dir + two files (no xattrs,
2036        // no hardlinks — those lock 22c out of the basic path).
2037        let mut src_img = create_blank_hfsplus(32 * 1024 * 1024, 4096, "Source", false);
2038        {
2039            let cur = Cursor::new(&mut src_img);
2040            let mut fs = HfsPlusFilesystem::open(cur, 0).unwrap();
2041            fs.prepare_for_edit().unwrap();
2042            let root = fs.root().unwrap();
2043            let _docs = fs
2044                .create_directory(&root, "Docs", &CreateDirectoryOptions::default())
2045                .unwrap();
2046            let mut a = Cursor::new(b"hello\n".as_ref());
2047            fs.create_file(&root, "a.txt", &mut a, 6, &CreateFileOptions::default())
2048                .unwrap();
2049            let mut b = Cursor::new(b"world\n".as_ref());
2050            fs.create_file(&root, "b.txt", &mut b, 6, &CreateFileOptions::default())
2051                .unwrap();
2052            fs.sync_metadata().unwrap();
2053        }
2054
2055        let cur = Cursor::new(&mut src_img);
2056        let mut src_fs = HfsPlusFilesystem::open(cur, 0).unwrap();
2057        let snap = SourceCatalogSnapshot::capture(&mut src_fs).unwrap();
2058
2059        let target_size: u64 = 8 * 1024 * 1024;
2060        let plan = plan_defrag_layout(&snap, target_size).unwrap();
2061        let meta = build_target_metadata(&plan, &snap).expect("build metadata");
2062
2063        // Stitch together a target image: block_zero + zero gap +
2064        // bitmap + catalog + extents + zero user-data area + alt VH.
2065        let bs = plan.block_size as usize;
2066        let mut img = vec![0u8; target_size as usize];
2067        // Block 0.
2068        img[..bs].copy_from_slice(&meta.block_zero);
2069        // Bitmap.
2070        let bitmap_off = plan.bitmap_start as usize * bs;
2071        img[bitmap_off..bitmap_off + meta.bitmap.len()].copy_from_slice(&meta.bitmap);
2072        // Catalog.
2073        let catalog_off = plan.catalog_start as usize * bs;
2074        img[catalog_off..catalog_off + meta.catalog.len()].copy_from_slice(&meta.catalog);
2075        // Extents-overflow.
2076        let extents_off = plan.extents_start as usize * bs;
2077        img[extents_off..extents_off + meta.extents_overflow.len()]
2078            .copy_from_slice(&meta.extents_overflow);
2079        // User data area: zeros are fine since we don't validate fork
2080        // contents in this test (22d will stream the real bytes).
2081        // Alt VH.
2082        let alt = target_size as usize - 1024;
2083        img[alt..alt + 512].copy_from_slice(&meta.vh_bytes);
2084
2085        // Open the produced image and walk the catalog.
2086        let cur = Cursor::new(img);
2087        let mut tgt = HfsPlusFilesystem::open(cur, 0).expect("target opens");
2088        let root = tgt.root().expect("root");
2089        let kids = tgt.list_directory(&root).expect("list root");
2090        let names: Vec<&str> = kids.iter().map(|e| e.name.as_str()).collect();
2091        assert!(names.contains(&"Docs"), "Docs missing: {names:?}");
2092        assert!(names.contains(&"a.txt"), "a.txt missing: {names:?}");
2093        assert!(names.contains(&"b.txt"), "b.txt missing: {names:?}");
2094    }
2095
2096    /// Build a source with one file inode plus 3 hardlink stub rows.
2097    /// Plan and build target metadata, stitch into a target image,
2098    /// open as `HfsPlusFilesystem`, walk the catalog. All 3 stubs and
2099    /// the inode row must be present; the inode's `bsdInfo.special`
2100    /// (link count) must equal 3.
2101    #[test]
2102    fn build_metadata_emits_file_hardlinks_with_link_count() {
2103        const PAYLOAD: &[u8] = b"shared\n";
2104        const I_NODE_NUM: u32 = 42;
2105
2106        let mut src_img = create_blank_hfsplus(32 * 1024 * 1024, 4096, "Src", false);
2107        {
2108            let cursor = Cursor::new(&mut src_img);
2109            let mut fs = HfsPlusFilesystem::open(cursor, 0).unwrap();
2110            fs.prepare_for_edit().unwrap();
2111            let mut data = Cursor::new(PAYLOAD);
2112            fs.create_hardlink_inode(
2113                I_NODE_NUM,
2114                &mut data,
2115                PAYLOAD.len() as u64,
2116                &CreateFileOptions::default(),
2117            )
2118            .unwrap();
2119            let root = fs.root().unwrap();
2120            for name in ["a.txt", "b.txt", "c.txt"] {
2121                fs.create_hardlink(&root, name, I_NODE_NUM).unwrap();
2122            }
2123            fs.sync_metadata().unwrap();
2124        }
2125
2126        let cursor = Cursor::new(&mut src_img);
2127        let mut src_fs = HfsPlusFilesystem::open(cursor, 0).unwrap();
2128        let snap = SourceCatalogSnapshot::capture(&mut src_fs).unwrap();
2129        assert_eq!(snap.hardlinks.len(), 3, "expected 3 hardlink stubs");
2130        assert_eq!(
2131            snap.files.iter().filter(|f| f.is_inode).count(),
2132            1,
2133            "expected 1 inode"
2134        );
2135
2136        let target_size: u64 = 8 * 1024 * 1024;
2137        let plan = plan_defrag_layout(&snap, target_size).unwrap();
2138        let meta = build_target_metadata(&plan, &snap).expect("build metadata");
2139
2140        // Stitch a target image (no fork bytes streamed — those land in
2141        // 22d; this test only exercises catalog correctness).
2142        let bs = plan.block_size as usize;
2143        let mut img = vec![0u8; target_size as usize];
2144        img[..bs].copy_from_slice(&meta.block_zero);
2145        let bitmap_off = plan.bitmap_start as usize * bs;
2146        img[bitmap_off..bitmap_off + meta.bitmap.len()].copy_from_slice(&meta.bitmap);
2147        let catalog_off = plan.catalog_start as usize * bs;
2148        img[catalog_off..catalog_off + meta.catalog.len()].copy_from_slice(&meta.catalog);
2149        let extents_off = plan.extents_start as usize * bs;
2150        img[extents_off..extents_off + meta.extents_overflow.len()]
2151            .copy_from_slice(&meta.extents_overflow);
2152        let alt = target_size as usize - 1024;
2153        img[alt..alt + 512].copy_from_slice(&meta.vh_bytes);
2154
2155        let cursor = Cursor::new(img);
2156        let mut tgt = HfsPlusFilesystem::open(cursor, 0).expect("target opens");
2157        let root = tgt.root().expect("root");
2158        let kids = tgt.list_directory(&root).expect("list root");
2159        let stub_names: Vec<&str> = kids
2160            .iter()
2161            .filter(|e| ["a.txt", "b.txt", "c.txt"].contains(&e.name.as_str()))
2162            .map(|e| e.name.as_str())
2163            .collect();
2164        assert_eq!(stub_names.len(), 3, "expected 3 stubs in root: {kids:?}");
2165
2166        // Verify the inode's link count was patched. We have to walk the
2167        // catalog looking for the inode CNID's record body and reading
2168        // bsdInfo.special.
2169        let inode_target_cnid = {
2170            let inode = snap.files.iter().find(|f| f.is_inode).unwrap();
2171            *plan.cnid_map.get(&inode.cnid).unwrap()
2172        };
2173        let link_count =
2174            read_inode_link_count(&meta.catalog, inode_target_cnid).expect("inode catalog record");
2175        assert_eq!(link_count, 3, "inode should carry linkCount=3");
2176    }
2177
2178    /// Directory hardlinks: 2 stub rows + 1 directory inode under
2179    /// `.HFS+ Private Directory Data\r`. Build target metadata, open as
2180    /// HfsPlusFilesystem, verify both stubs are present at root and the
2181    /// inode dir is reachable under the dir-private dir.
2182    #[test]
2183    fn build_metadata_emits_dir_hardlinks_with_link_count() {
2184        const D_INODE_NUM: u32 = 7;
2185
2186        let mut src_img = create_blank_hfsplus(32 * 1024 * 1024, 4096, "DhSrc", false);
2187        {
2188            let cursor = Cursor::new(&mut src_img);
2189            let mut fs = HfsPlusFilesystem::open(cursor, 0).unwrap();
2190            fs.prepare_for_edit().unwrap();
2191            fs.create_dir_hardlink_inode(D_INODE_NUM, &CreateDirectoryOptions::default())
2192                .unwrap();
2193            let root = fs.root().unwrap();
2194            for name in ["alpha", "beta"] {
2195                fs.create_dir_hardlink(&root, name, D_INODE_NUM).unwrap();
2196            }
2197            fs.sync_metadata().unwrap();
2198        }
2199
2200        let cursor = Cursor::new(&mut src_img);
2201        let mut src_fs = HfsPlusFilesystem::open(cursor, 0).unwrap();
2202        let snap = SourceCatalogSnapshot::capture(&mut src_fs).unwrap();
2203        assert_eq!(snap.dir_hardlinks.len(), 2);
2204        assert_eq!(snap.dirs.iter().filter(|d| d.is_dir_inode).count(), 1);
2205
2206        let target_size: u64 = 8 * 1024 * 1024;
2207        let plan = plan_defrag_layout(&snap, target_size).unwrap();
2208        let meta = build_target_metadata(&plan, &snap).expect("build metadata");
2209
2210        // Patch the target image and reopen.
2211        let bs = plan.block_size as usize;
2212        let mut img = vec![0u8; target_size as usize];
2213        img[..bs].copy_from_slice(&meta.block_zero);
2214        let bo = plan.bitmap_start as usize * bs;
2215        img[bo..bo + meta.bitmap.len()].copy_from_slice(&meta.bitmap);
2216        let co = plan.catalog_start as usize * bs;
2217        img[co..co + meta.catalog.len()].copy_from_slice(&meta.catalog);
2218        let eo = plan.extents_start as usize * bs;
2219        img[eo..eo + meta.extents_overflow.len()].copy_from_slice(&meta.extents_overflow);
2220        let alt = target_size as usize - 1024;
2221        img[alt..alt + 512].copy_from_slice(&meta.vh_bytes);
2222
2223        let cursor = Cursor::new(img);
2224        let mut tgt = HfsPlusFilesystem::open(cursor, 0).expect("target opens");
2225        let root = tgt.root().expect("root");
2226        let kids = tgt.list_directory(&root).expect("list root");
2227        let stub_names: Vec<&str> = kids
2228            .iter()
2229            .filter(|e| ["alpha", "beta"].contains(&e.name.as_str()))
2230            .map(|e| e.name.as_str())
2231            .collect();
2232        assert_eq!(stub_names.len(), 2, "expected 2 dir-hardlink stubs");
2233
2234        // Verify the dir inode's link count.
2235        let inode_target_cnid = {
2236            let inode = snap.dirs.iter().find(|d| d.is_dir_inode).unwrap();
2237            *plan.cnid_map.get(&inode.cnid).unwrap()
2238        };
2239        let link_count = read_inode_link_count(&meta.catalog, inode_target_cnid)
2240            .expect("dir inode catalog record");
2241        assert_eq!(link_count, 2, "dir inode should carry linkCount=2");
2242    }
2243
2244    /// Walk a catalog buffer, find the file/folder record for `cnid`,
2245    /// return its `bsdInfo.special` (record body offset 44, u32 BE).
2246    fn read_inode_link_count(catalog: &[u8], cnid: u32) -> Option<u32> {
2247        let header = crate::hfs_common::BTreeHeader::read(catalog);
2248        let node_size = header.node_size as usize;
2249        let mut node_idx = header.first_leaf_node;
2250        while node_idx != 0 {
2251            let off = node_idx as usize * node_size;
2252            let next = BigEndian::read_u32(&catalog[off..off + 4]);
2253            let num = BigEndian::read_u16(&catalog[off + 10..off + 12]) as usize;
2254            for rec_idx in 0..num {
2255                let (rec_start, _) = crate::hfs_common::btree_record_range(
2256                    &catalog[off..off + node_size],
2257                    node_size,
2258                    rec_idx,
2259                );
2260                let abs = off + rec_start;
2261                let key_len = BigEndian::read_u16(&catalog[abs..abs + 2]) as usize;
2262                let mut body = abs + 2 + key_len;
2263                if !body.is_multiple_of(2) {
2264                    body += 1;
2265                }
2266                if body + 48 > catalog.len() {
2267                    continue;
2268                }
2269                let rt = BigEndian::read_i16(&catalog[body..body + 2]);
2270                if rt != CATALOG_FOLDER && rt != CATALOG_FILE {
2271                    continue;
2272                }
2273                let rcnid = BigEndian::read_u32(&catalog[body + 8..body + 12]);
2274                if rcnid == cnid {
2275                    return Some(BigEndian::read_u32(&catalog[body + 44..body + 48]));
2276                }
2277            }
2278            node_idx = next;
2279        }
2280        None
2281    }
2282
2283    /// Plain blank — no xattrs, no hardlinks. With 22c-i shipped the
2284    /// only remaining `Unsupported` guard is for xattr-bearing volumes;
2285    /// a blank source flows through the basic dirs+files path cleanly.
2286    /// Round-trip an inline xattr: synthesize a minimal source snapshot
2287    /// carrying one file with `com.apple.FinderInfo` inline xattr,
2288    /// build target metadata, parse the resulting attributes B-tree
2289    /// back, verify the record's CNID is the planner-remapped target
2290    /// CNID and the value bytes round-trip byte-equal.
2291    #[test]
2292    fn build_metadata_emits_inline_xattr() {
2293        use crate::hfsplus::{XattrKind, XattrRecord};
2294        use crate::hfsplus_clone::{
2295            SourceCatalogSnapshot, SourceDirSnapshot, SourceFileSnapshot, SourceVolumeSnapshot,
2296        };
2297
2298        let label = "Tgt".to_string();
2299        let label_utf16: Vec<u16> = label.encode_utf16().collect();
2300
2301        // Root dir. CNID 2, parent 1, no xattrs.
2302        let root = SourceDirSnapshot {
2303            name: label.clone(),
2304            name_utf16: label_utf16.clone(),
2305            cnid: 2,
2306            parent_cnid: 1,
2307            valence: 1,
2308            flags: 0,
2309            finder_info: [0u8; 16],
2310            extended_finder_info: [0u8; 16],
2311            bsd_info: [0u8; 16],
2312            create_date: 0,
2313            content_mod_date: 0,
2314            attribute_mod_date: 0,
2315            access_date: 0,
2316            backup_date: 0,
2317            text_encoding: 0,
2318            xattrs: Vec::new(),
2319            is_dir_inode: false,
2320            inode_num: None,
2321        };
2322        // One file, source CNID = 16, with one inline xattr.
2323        let xattr_value = b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f\
2324                            \x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f"
2325            .to_vec();
2326        let xattr = XattrRecord {
2327            name: "com.apple.FinderInfo".to_string(),
2328            start_block: 0,
2329            kind: XattrKind::Inline(xattr_value.clone()),
2330        };
2331        let f_name = "note.txt".to_string();
2332        let f_name_utf16: Vec<u16> = f_name.encode_utf16().collect();
2333        let mut data_fork_raw = [0u8; 80];
2334        // logical_size = 0 → no data fork allocation. Planner skips it.
2335        BigEndian::write_u64(&mut data_fork_raw[0..8], 0);
2336        let file = SourceFileSnapshot {
2337            name: f_name,
2338            name_utf16: f_name_utf16,
2339            cnid: 100,
2340            parent_cnid: 2,
2341            flags: 0,
2342            finder_info: [0u8; 16],
2343            extended_finder_info: [0u8; 16],
2344            bsd_info: [0u8; 16],
2345            create_date: 0,
2346            content_mod_date: 0,
2347            attribute_mod_date: 0,
2348            access_date: 0,
2349            backup_date: 0,
2350            text_encoding: 0,
2351            data_fork_raw,
2352            rsrc_fork_raw: [0u8; 80],
2353            data_fork_size: 0,
2354            rsrc_fork_size: 0,
2355            xattrs: vec![xattr],
2356            is_inode: false,
2357            inode_num: None,
2358        };
2359
2360        let snap = SourceCatalogSnapshot {
2361            volume: SourceVolumeSnapshot {
2362                label: label.clone(),
2363                signature: 0x482B,
2364                attributes: 0x100,
2365                create_date: 0,
2366                modify_date: 0,
2367                backup_date: 0,
2368                checked_date: 0,
2369                finder_info: [0u32; 8],
2370                block_size: 4096,
2371                total_blocks: 0,
2372                file_count: 1,
2373                folder_count: 0,
2374                boot_blocks: [0u8; 1024],
2375                catalog_total_blocks: 4,
2376                extents_total_blocks: 4,
2377                // Force the planner to size an attributes B-tree.
2378                attributes_total_blocks: 4,
2379            },
2380            dirs: vec![root],
2381            files: vec![file],
2382            hardlinks: Vec::new(),
2383            dir_hardlinks: Vec::new(),
2384        };
2385
2386        let target_size: u64 = 8 * 1024 * 1024;
2387        let plan = plan_defrag_layout(&snap, target_size).expect("plan");
2388        assert!(
2389            plan.attributes_blocks >= 4,
2390            "planner must size attributes B-tree from source"
2391        );
2392        let meta = build_target_metadata(&plan, &snap).expect("build with xattr");
2393        let attr_buf = meta.attributes.as_ref().expect("attributes buffer present");
2394
2395        // Parse the leaf, find our record, verify CNID + value.
2396        let target_cnid = *plan.cnid_map.get(&100).expect("file CNID remapped");
2397        let (found_cnid, found_value) =
2398            read_first_inline_xattr(attr_buf).expect("at least one xattr record");
2399        assert_eq!(
2400            found_cnid, target_cnid,
2401            "xattr record must reference remapped target CNID"
2402        );
2403        assert_eq!(found_value, xattr_value, "xattr value must round-trip");
2404    }
2405
2406    /// Walk the leaf of an attributes B-tree, return the first inline
2407    /// xattr record's `(fileID, valueBytes)`. Test-only.
2408    fn read_first_inline_xattr(attr_buf: &[u8]) -> Option<(u32, Vec<u8>)> {
2409        let header = crate::hfs_common::BTreeHeader::read(attr_buf);
2410        let node_size = header.node_size as usize;
2411        let leaf_off = header.first_leaf_node as usize * node_size;
2412        let num = BigEndian::read_u16(&attr_buf[leaf_off + 10..leaf_off + 12]) as usize;
2413        if num == 0 {
2414            return None;
2415        }
2416        let (rec_start, _) = crate::hfs_common::btree_record_range(
2417            &attr_buf[leaf_off..leaf_off + node_size],
2418            node_size,
2419            0,
2420        );
2421        let abs = leaf_off + rec_start;
2422        let key_len = BigEndian::read_u16(&attr_buf[abs..abs + 2]) as usize;
2423        let cnid = BigEndian::read_u32(&attr_buf[abs + 4..abs + 8]);
2424        let name_len = BigEndian::read_u16(&attr_buf[abs + 12..abs + 14]) as usize;
2425        let data_off = abs + 2 + key_len;
2426        // Inline-data record body: recordType(4) + reserved(8) + attrSize(4) + attrData.
2427        let attr_size = BigEndian::read_u32(&attr_buf[data_off + 12..data_off + 16]) as usize;
2428        let value = attr_buf[data_off + 16..data_off + 16 + attr_size].to_vec();
2429        let _ = name_len;
2430        Some((cnid, value))
2431    }
2432
2433    /// Fork-style xattrs trip a clear `Unsupported` error rather than
2434    /// silently dropping. Mirrors Step 13's editing-side limitation.
2435    #[test]
2436    fn build_metadata_refuses_fork_style_xattr() {
2437        use crate::hfsplus::{ExtentDescriptor, ForkData, XattrKind, XattrRecord};
2438        use crate::hfsplus_clone::{
2439            SourceCatalogSnapshot, SourceDirSnapshot, SourceFileSnapshot, SourceVolumeSnapshot,
2440        };
2441
2442        let mut data_fork_raw = [0u8; 80];
2443        BigEndian::write_u64(&mut data_fork_raw[0..8], 0);
2444        let f = SourceFileSnapshot {
2445            name: "f".into(),
2446            name_utf16: "f".encode_utf16().collect(),
2447            cnid: 100,
2448            parent_cnid: 2,
2449            flags: 0,
2450            finder_info: [0u8; 16],
2451            extended_finder_info: [0u8; 16],
2452            bsd_info: [0u8; 16],
2453            create_date: 0,
2454            content_mod_date: 0,
2455            attribute_mod_date: 0,
2456            access_date: 0,
2457            backup_date: 0,
2458            text_encoding: 0,
2459            data_fork_raw,
2460            rsrc_fork_raw: [0u8; 80],
2461            data_fork_size: 0,
2462            rsrc_fork_size: 0,
2463            xattrs: vec![XattrRecord {
2464                name: "com.apple.bigxattr".into(),
2465                start_block: 0,
2466                kind: XattrKind::Fork(ForkData {
2467                    logical_size: 16384,
2468                    clump_size: 0,
2469                    total_blocks: 4,
2470                    extents: [ExtentDescriptor {
2471                        start_block: 0,
2472                        block_count: 0,
2473                    }; 8],
2474                }),
2475            }],
2476            is_inode: false,
2477            inode_num: None,
2478        };
2479        let root = SourceDirSnapshot {
2480            name: "T".into(),
2481            name_utf16: "T".encode_utf16().collect(),
2482            cnid: 2,
2483            parent_cnid: 1,
2484            valence: 1,
2485            flags: 0,
2486            finder_info: [0u8; 16],
2487            extended_finder_info: [0u8; 16],
2488            bsd_info: [0u8; 16],
2489            create_date: 0,
2490            content_mod_date: 0,
2491            attribute_mod_date: 0,
2492            access_date: 0,
2493            backup_date: 0,
2494            text_encoding: 0,
2495            xattrs: Vec::new(),
2496            is_dir_inode: false,
2497            inode_num: None,
2498        };
2499        let snap = SourceCatalogSnapshot {
2500            volume: SourceVolumeSnapshot {
2501                label: "T".into(),
2502                signature: 0x482B,
2503                attributes: 0x100,
2504                create_date: 0,
2505                modify_date: 0,
2506                backup_date: 0,
2507                checked_date: 0,
2508                finder_info: [0u32; 8],
2509                block_size: 4096,
2510                total_blocks: 0,
2511                file_count: 1,
2512                folder_count: 0,
2513                boot_blocks: [0u8; 1024],
2514                catalog_total_blocks: 4,
2515                extents_total_blocks: 4,
2516                attributes_total_blocks: 4,
2517            },
2518            dirs: vec![root],
2519            files: vec![f],
2520            hardlinks: Vec::new(),
2521            dir_hardlinks: Vec::new(),
2522        };
2523        let plan = plan_defrag_layout(&snap, 8 * 1024 * 1024).unwrap();
2524        let err = build_target_metadata(&plan, &snap)
2525            .expect_err("fork-style xattr must trip Unsupported");
2526        match err {
2527            FilesystemError::Unsupported(msg) => {
2528                assert!(
2529                    msg.contains("fork-style") || msg.contains("inline"),
2530                    "unexpected error: {msg}"
2531                );
2532            }
2533            other => panic!("unexpected error: {other:?}"),
2534        }
2535    }
2536
2537    #[test]
2538    fn build_metadata_handles_plain_blank() {
2539        let mut img = create_blank_hfsplus(32 * 1024 * 1024, 4096, "Plain", false);
2540        let cur = Cursor::new(&mut img);
2541        let mut fs = HfsPlusFilesystem::open(cur, 0).unwrap();
2542        let snap = SourceCatalogSnapshot::capture(&mut fs).unwrap();
2543        let plan = plan_defrag_layout(&snap, 8 * 1024 * 1024).unwrap();
2544        build_target_metadata(&plan, &snap).expect("plain blank should build");
2545    }
2546
2547    #[test]
2548    fn plan_refuses_unaligned_target() {
2549        let mut img = create_blank_hfsplus(32 * 1024 * 1024, 4096, "U", false);
2550        let cur = Cursor::new(&mut img);
2551        let mut fs = HfsPlusFilesystem::open(cur, 0).unwrap();
2552        let snap = SourceCatalogSnapshot::capture(&mut fs).unwrap();
2553        let err = plan_defrag_layout(&snap, 16 * 1024 * 1024 + 1)
2554            .expect_err("non-block-aligned target must be refused");
2555        match err {
2556            PlanError::TargetNotBlockAligned(_) => {}
2557            other => panic!("unexpected error: {other:?}"),
2558        }
2559    }
2560
2561    /// Smoke test for [`stream_defragmented_hfsplus`]: emit a forward-only
2562    /// image into a `Vec<u8>`, reopen it as `HfsPlusFilesystem`, and
2563    /// verify dirs, files, and fork bytes round-trip. The full fixture
2564    /// (xattrs + hardlinks + fragmented forks + fsck) lands in 22e.
2565    #[test]
2566    fn stream_emit_round_trips_dirs_and_files() {
2567        let mut src_img = create_blank_hfsplus(32 * 1024 * 1024, 4096, "Stream", false);
2568        {
2569            let cur = Cursor::new(&mut src_img);
2570            let mut fs = HfsPlusFilesystem::open(cur, 0).unwrap();
2571            fs.prepare_for_edit().unwrap();
2572            let root = fs.root().unwrap();
2573            let _ = fs
2574                .create_directory(&root, "Docs", &CreateDirectoryOptions::default())
2575                .unwrap();
2576            let payload_a = b"hello\n";
2577            let mut a = Cursor::new(payload_a.as_ref());
2578            fs.create_file(
2579                &root,
2580                "a.txt",
2581                &mut a,
2582                payload_a.len() as u64,
2583                &CreateFileOptions::default(),
2584            )
2585            .unwrap();
2586            let payload_b = vec![0xCDu8; 5000]; // > 1 block, exercises padding
2587            let mut b = Cursor::new(&payload_b);
2588            fs.create_file(
2589                &root,
2590                "b.bin",
2591                &mut b,
2592                payload_b.len() as u64,
2593                &CreateFileOptions::default(),
2594            )
2595            .unwrap();
2596            fs.sync_metadata().unwrap();
2597        }
2598
2599        let cur = Cursor::new(&mut src_img);
2600        let mut src_fs = HfsPlusFilesystem::open(cur, 0).unwrap();
2601
2602        let target_size: u64 = 8 * 1024 * 1024;
2603        let mut out: Vec<u8> = Vec::with_capacity(target_size as usize);
2604        let report = stream_defragmented_hfsplus(&mut src_fs, target_size, &mut out, None)
2605            .expect("stream emit should succeed");
2606        assert_eq!(out.len() as u64, target_size, "emit size mismatch");
2607        assert_eq!(report.bytes_emitted, target_size);
2608        assert_eq!(report.files_copied, 2);
2609        assert_eq!(report.dirs_copied, 1);
2610        assert_eq!(report.data_bytes_copied, 6 + 5000);
2611
2612        let cur = Cursor::new(out);
2613        let mut tgt = HfsPlusFilesystem::open(cur, 0).expect("target opens");
2614        let root = tgt.root().expect("root");
2615        let kids = tgt.list_directory(&root).expect("list root");
2616        let names: Vec<&str> = kids.iter().map(|e| e.name.as_str()).collect();
2617        assert!(names.contains(&"Docs"));
2618        assert!(names.contains(&"a.txt"));
2619        assert!(names.contains(&"b.bin"));
2620
2621        let a = kids.iter().find(|e| e.name == "a.txt").unwrap().clone();
2622        let bytes = tgt.read_file(&a, usize::MAX).expect("read a.txt");
2623        assert_eq!(bytes, b"hello\n");
2624        let b = kids.iter().find(|e| e.name == "b.bin").unwrap().clone();
2625        let bytes = tgt.read_file(&b, usize::MAX).expect("read b.bin");
2626        assert_eq!(bytes.len(), 5000);
2627        assert!(bytes.iter().all(|&x| x == 0xCD));
2628    }
2629
2630    /// Step 22e — full end-to-end engine fixture. Build a synthetic HFS+
2631    /// source that exercises the clone-relevant features the blank
2632    /// builder can seed: a nested directory tree, a file with both data
2633    /// and resource forks, and a file hardlink pair. Stream into a
2634    /// `Vec<u8>`, reopen as `HfsPlusFilesystem`, walk the catalog, and
2635    /// verify byte-equal data forks and resource fork, preserved
2636    /// hardlink topology (both link rows resolve to identical bytes),
2637    /// and `fsck` clean on the target. Xattr emit is exercised
2638    /// separately by `build_metadata_emits_inline_xattr` —
2639    /// `create_blank_hfsplus` doesn't seed an attributes B-tree, so
2640    /// live `set_xattr` against the blank source is a separate plumbing
2641    /// step.
2642    #[test]
2643    fn stream_emit_round_trips_full_fixture() {
2644        use disk_core::filesystem::ResourceForkSource;
2645
2646        const DATA_PAYLOAD: &[u8] = b"the quick brown fox\n";
2647        const RSRC_PAYLOAD: &[u8] = b"resource-fork-bytes-12345\n";
2648        const SHARED_PAYLOAD: &[u8] = b"shared via hardlink\n";
2649        const I_NODE_NUM: u32 = 91;
2650
2651        let mut src_img = create_blank_hfsplus(32 * 1024 * 1024, 4096, "Full", false);
2652        {
2653            let cur = Cursor::new(&mut src_img);
2654            let mut fs = HfsPlusFilesystem::open(cur, 0).unwrap();
2655            fs.prepare_for_edit().unwrap();
2656            let root = fs.root().unwrap();
2657
2658            // Nested directory tree.
2659            let docs = fs
2660                .create_directory(&root, "Docs", &CreateDirectoryOptions::default())
2661                .unwrap();
2662            let _nested = fs
2663                .create_directory(&docs, "Nested", &CreateDirectoryOptions::default())
2664                .unwrap();
2665
2666            // File with both forks.
2667            let mut data = Cursor::new(DATA_PAYLOAD);
2668            let opts = CreateFileOptions {
2669                resource_fork: Some(ResourceForkSource::Data(RSRC_PAYLOAD.to_vec())),
2670                ..CreateFileOptions::default()
2671            };
2672            fs.create_file(
2673                &docs,
2674                "with_forks.bin",
2675                &mut data,
2676                DATA_PAYLOAD.len() as u64,
2677                &opts,
2678            )
2679            .unwrap();
2680
2681            // Hardlink pair: one inode + two stub rows in the root.
2682            let mut shared = Cursor::new(SHARED_PAYLOAD);
2683            fs.create_hardlink_inode(
2684                I_NODE_NUM,
2685                &mut shared,
2686                SHARED_PAYLOAD.len() as u64,
2687                &CreateFileOptions::default(),
2688            )
2689            .unwrap();
2690            fs.create_hardlink(&root, "alpha.txt", I_NODE_NUM).unwrap();
2691            fs.create_hardlink(&root, "beta.txt", I_NODE_NUM).unwrap();
2692
2693            fs.sync_metadata().unwrap();
2694        }
2695
2696        // Stream the source into a fresh Vec<u8>.
2697        let cur = Cursor::new(&mut src_img);
2698        let mut src_fs = HfsPlusFilesystem::open(cur, 0).unwrap();
2699        let target_size: u64 = 8 * 1024 * 1024;
2700        let mut out: Vec<u8> = Vec::with_capacity(target_size as usize);
2701        let report = stream_defragmented_hfsplus(&mut src_fs, target_size, &mut out, None)
2702            .expect("stream emit should succeed");
2703        assert_eq!(out.len() as u64, target_size, "emit size mismatch");
2704        assert_eq!(report.bytes_emitted, target_size);
2705        // `files_copied` excludes inodes (per DefragReport's filter), so just
2706        // the dual-fork file. The inode's bytes still flow into
2707        // `data_bytes_copied` below.
2708        assert_eq!(report.files_copied, 1, "one non-inode file (dual)");
2709        // Docs + Nested + the synthesized "HFS+ Private Data" dir; root and
2710        // dir-inode dirs are excluded by the report filter.
2711        assert_eq!(report.dirs_copied, 3);
2712        assert_eq!(report.hardlinks_copied, 2);
2713        assert_eq!(report.xattrs_copied, 0);
2714        assert_eq!(
2715            report.data_bytes_copied,
2716            DATA_PAYLOAD.len() as u64 + SHARED_PAYLOAD.len() as u64
2717        );
2718        assert_eq!(report.rsrc_bytes_copied, RSRC_PAYLOAD.len() as u64);
2719
2720        // Reopen and verify catalog contents.
2721        let cur = Cursor::new(out);
2722        let mut tgt = HfsPlusFilesystem::open(cur, 0).expect("target opens");
2723        let root = tgt.root().expect("root");
2724        let kids = tgt.list_directory(&root).expect("list root");
2725        let names: Vec<&str> = kids.iter().map(|e| e.name.as_str()).collect();
2726        assert!(names.contains(&"Docs"), "Docs missing: {names:?}");
2727        assert!(names.contains(&"alpha.txt"), "alpha missing: {names:?}");
2728        assert!(names.contains(&"beta.txt"), "beta missing: {names:?}");
2729
2730        // Both hardlink rows must resolve to identical bytes.
2731        let alpha = kids.iter().find(|e| e.name == "alpha.txt").unwrap().clone();
2732        let beta = kids.iter().find(|e| e.name == "beta.txt").unwrap().clone();
2733        assert!(
2734            alpha.link_target_cnid.is_some(),
2735            "alpha must carry a link_target_cnid"
2736        );
2737        assert_eq!(
2738            alpha.link_target_cnid, beta.link_target_cnid,
2739            "both stubs should resolve to the same inode"
2740        );
2741        let alpha_bytes = tgt.read_file(&alpha, usize::MAX).expect("read alpha");
2742        let beta_bytes = tgt.read_file(&beta, usize::MAX).expect("read beta");
2743        assert_eq!(alpha_bytes, SHARED_PAYLOAD);
2744        assert_eq!(beta_bytes, SHARED_PAYLOAD);
2745
2746        // Data fork on the dual-fork file under Docs/.
2747        let docs = kids.iter().find(|e| e.name == "Docs").unwrap().clone();
2748        let docs_kids = tgt.list_directory(&docs).expect("list Docs");
2749        let with_forks = docs_kids
2750            .iter()
2751            .find(|e| e.name == "with_forks.bin")
2752            .expect("dual-fork file present")
2753            .clone();
2754        assert!(
2755            docs_kids.iter().any(|e| e.name == "Nested"),
2756            "Nested dir missing"
2757        );
2758        let data_bytes = tgt.read_file(&with_forks, usize::MAX).expect("read data");
2759        assert_eq!(data_bytes, DATA_PAYLOAD);
2760
2761        // Resource fork: write_resource_fork_to streams it into a buffer.
2762        let mut rsrc_buf: Vec<u8> = Vec::new();
2763        let written = tgt
2764            .write_resource_fork_to(&with_forks, &mut rsrc_buf)
2765            .expect("read rsrc");
2766        assert_eq!(written, RSRC_PAYLOAD.len() as u64);
2767        assert_eq!(rsrc_buf, RSRC_PAYLOAD);
2768        assert_eq!(
2769            tgt.resource_fork_size(&with_forks),
2770            RSRC_PAYLOAD.len() as u64
2771        );
2772
2773        // fsck clean on the target.
2774        let fsck = tgt
2775            .fsck()
2776            .expect("HFS+ supports fsck")
2777            .expect("fsck runs without error");
2778        assert!(
2779            fsck.is_clean(),
2780            "target should be fsck-clean: errors={:?}",
2781            fsck.errors
2782                .iter()
2783                .map(|e| (&e.code, &e.message))
2784                .collect::<Vec<_>>()
2785        );
2786    }
2787
2788    /// P4 (docs/hfsplus_btree_growth_plan.md): the streamed defrag builder builds
2789    /// its target catalog with `hfs_common::btree_insert_full` +
2790    /// `BTreeKeyFormat::HFSPLUS_CATALOG` (wired in P1). Confirm that on a source
2791    /// large enough for a *multi-level* catalog the clone re-builds an equally
2792    /// multi-level catalog that is fsck-clean and round-trips — i.e. the
2793    /// variable-length index keys behave correctly through the clone path, not
2794    /// just the live-edit path.
2795    #[test]
2796    fn stream_clone_of_multilevel_catalog_is_fsck_clean() {
2797        use crate::hfs_common::BTreeHeader;
2798
2799        // 64 MiB source (auto-sized catalog) with 300 files across 10 dirs —
2800        // ~620 catalog records, comfortably past a single leaf node.
2801        const DIRS: usize = 10;
2802        const PER_DIR: usize = 30;
2803        let mut src_img = create_blank_hfsplus(64 * 1024 * 1024, 4096, "Big", false);
2804        {
2805            let cur = Cursor::new(&mut src_img);
2806            let mut fs = HfsPlusFilesystem::open(cur, 0).unwrap();
2807            fs.prepare_for_edit().unwrap();
2808            let root = fs.root().unwrap();
2809            for d in 0..DIRS {
2810                let dir = fs
2811                    .create_directory(
2812                        &root,
2813                        &format!("d{d:02}"),
2814                        &CreateDirectoryOptions::default(),
2815                    )
2816                    .unwrap();
2817                for f in 0..PER_DIR {
2818                    let body = format!("contents of d{d:02}/f{f:03}\n");
2819                    let mut data = Cursor::new(body.into_bytes());
2820                    let len = data.get_ref().len() as u64;
2821                    fs.create_file(
2822                        &dir,
2823                        &format!("f{f:03}.txt"),
2824                        &mut data,
2825                        len,
2826                        &CreateFileOptions::default(),
2827                    )
2828                    .unwrap();
2829                }
2830            }
2831            fs.sync_metadata().unwrap();
2832            // Sanity: the source catalog really is multi-level.
2833            let depth = BTreeHeader::read(fs.catalog_data()).depth;
2834            assert!(
2835                depth >= 2,
2836                "source catalog depth {depth} < 2 — test needs a deeper tree"
2837            );
2838        }
2839
2840        // Clone via the streamed defrag path.
2841        let cur = Cursor::new(&mut src_img);
2842        let mut src_fs = HfsPlusFilesystem::open(cur, 0).unwrap();
2843        let target_size: u64 = 32 * 1024 * 1024;
2844        let mut out: Vec<u8> = Vec::with_capacity(target_size as usize);
2845        let report = stream_defragmented_hfsplus(&mut src_fs, target_size, &mut out, None)
2846            .expect("multi-level clone should succeed");
2847        assert_eq!(
2848            report.files_copied as usize,
2849            DIRS * PER_DIR,
2850            "all files copied"
2851        );
2852        assert_eq!(report.dirs_copied as usize, DIRS, "all dirs copied");
2853
2854        // Emit the defrag-clone output so a Mac can fsck_hfs it — the canonical
2855        // B-tree header layout fix makes this production path fsck_hfs-clean,
2856        // not just clean per our own checker.
2857        if let Ok(path) = std::env::var("RB_EMIT_DEFRAG_IMAGE") {
2858            std::fs::write(&path, &out).unwrap();
2859        }
2860
2861        // The defrag-built target catalog must itself be multi-level...
2862        let cur = Cursor::new(out);
2863        let mut tgt = HfsPlusFilesystem::open(cur, 0).expect("target opens");
2864        let tgt_depth = BTreeHeader::read(tgt.catalog_data()).depth;
2865        assert!(
2866            tgt_depth >= 2,
2867            "cloned catalog collapsed to depth {tgt_depth} — multi-level rebuild failed"
2868        );
2869
2870        // ...fsck-clean...
2871        let fsck = tgt.fsck().expect("HFS+ fsck").expect("fsck runs");
2872        assert!(
2873            fsck.is_clean(),
2874            "cloned multi-level catalog not fsck-clean: {:?}",
2875            fsck.errors
2876                .iter()
2877                .take(8)
2878                .map(|e| (&e.code, &e.message))
2879                .collect::<Vec<_>>()
2880        );
2881
2882        // ...and round-trips: every dir/file present, contents intact (spot-check).
2883        let root = tgt.root().unwrap();
2884        let dirs = tgt.list_directory(&root).unwrap();
2885        assert_eq!(
2886            dirs.iter().filter(|e| e.name.starts_with('d')).count(),
2887            DIRS,
2888            "directory count drifted after clone"
2889        );
2890        let d05 = dirs.iter().find(|e| e.name == "d05").unwrap().clone();
2891        let d05_kids = tgt.list_directory(&d05).unwrap();
2892        assert_eq!(d05_kids.len(), PER_DIR, "d05 child count drifted");
2893        let f017 = d05_kids
2894            .iter()
2895            .find(|e| e.name == "f017.txt")
2896            .unwrap()
2897            .clone();
2898        let bytes = tgt.read_file(&f017, usize::MAX).unwrap();
2899        assert_eq!(
2900            bytes, b"contents of d05/f017\n",
2901            "cloned file contents differ"
2902        );
2903    }
2904}