1#[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
32const FIRST_USER_CNID: u32 = 16;
37
38const CATALOG_PACKING_OVERHEAD_NUM: u32 = 2;
47const CATALOG_PACKING_OVERHEAD_DEN: u32 = 1;
48const HFSPLUS_FOLDER_BODY_BYTES: usize = 88;
55const HFSPLUS_FILE_BODY_BYTES: usize = 248;
56const HFSPLUS_THREAD_BODY_FIXED_BYTES: usize = 10;
57
58fn 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 key_padded + body_bytes + 2 + 8
68}
69
70fn estimate_catalog_bytes(snapshot: &SourceCatalogSnapshot) -> u64 {
80 let mut record_bytes: u64 = 0;
81 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 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 let payload = (NODE_SIZE - 16) as u64;
109 let leaf_nodes = record_bytes.div_ceil(payload);
110 let total_nodes = leaf_nodes.saturating_mul(105) / 100 + 2;
113 total_nodes.saturating_mul(NODE_SIZE as u64)
114}
115
116#[derive(Debug, Clone, Copy)]
118pub struct ForkPlacement {
119 pub start_block: u32,
121 pub block_count: u32,
123 pub logical_size: u64,
126}
127
128#[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#[derive(Debug, Clone)]
142pub struct TargetLayoutPlan {
143 pub block_size: u32,
144 pub total_blocks: u32,
145 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 pub attributes_start: u32,
161 pub attributes_blocks: u32,
162 pub user_data_start: u32,
163 pub user_data_blocks_used: u32,
169 pub cnid_map: HashMap<u32, u32>,
173 pub file_placements: HashMap<u32, FilePlacement>,
177}
178
179#[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
194pub 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 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 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 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 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 let mut next_block = user_data_start;
307 let mut file_placements: HashMap<u32, FilePlacement> = HashMap::new();
308 let fork_start_block = |raw: &[u8; 80]| -> Option<u32> {
316 if BigEndian::read_u64(&raw[0..8]) == 0 {
317 None
318 } else {
319 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.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
393fn 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
416use 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 static PROGRESS_SINK: std::cell::RefCell<Option<ProgressSink>> =
440 const { std::cell::RefCell::new(None) };
441}
442
443pub type ProgressSink = Box<dyn FnMut(&str)>;
445
446pub fn set_progress_sink(cb: Option<ProgressSink>) {
451 PROGRESS_SINK.with(|cell| {
452 *cell.borrow_mut() = cb;
453 });
454}
455
456fn 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
552const KEY_COMPARE_CASE_FOLDING: u8 = 0xCF;
554const KEY_COMPARE_BINARY: u8 = 0xBC;
555
556const CATALOG_FOLDER: i16 = 1;
558const CATALOG_FILE: i16 = 2;
559const CATALOG_FOLDER_THREAD: i16 = 3;
560const CATALOG_FILE_THREAD: i16 = 4;
561
562const NODE_SIZE: usize = 4096;
565
566#[derive(Debug)]
570pub struct TargetMetadata {
571 pub block_zero: Vec<u8>,
575 pub bitmap: Vec<u8>,
577 pub catalog: Vec<u8>,
579 pub extents_overflow: Vec<u8>,
581 pub attributes: Option<Vec<u8>>,
584 pub vh_bytes: [u8; 512],
588}
589
590pub 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 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 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 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 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, 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 let thread = build_thread_record(CATALOG_FOLDER_THREAD, 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 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; }
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 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 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 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 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 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 patch_folder_valences(&mut catalog, &child_count, case_sensitive)?;
996
997 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 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 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#[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 pub bytes_emitted: u64,
1096}
1097
1098pub 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 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 writer.write_all(&meta.block_zero)?;
1143 debug_assert_eq!(writer.bytes_written, bs);
1144
1145 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 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 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 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 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
1321const HFSPLUS_FORK_DATA: u8 = 0x00;
1323const HFSPLUS_FORK_RESOURCE: u8 = 0xFF;
1324
1325struct 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
1353fn 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
1365fn 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
1380fn missing_cnid(kind: &str, cnid: u32) -> FilesystemError {
1385 FilesystemError::InvalidData(format!(
1386 "defrag plan is missing the {kind} CNID {cnid} mapping"
1387 ))
1388}
1389
1390fn 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 let map_nodes = hfs_common::map_nodes_required(total_nodes, NODE_SIZE);
1406 let used_nodes = 1 + map_nodes; 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 0,
1419 total_nodes,
1420 total_nodes - used_nodes,
1421 max_key_len,
1422 key_compare_type,
1423 );
1424
1425 if map_nodes > 0 {
1426 BigEndian::write_u32(&mut buf[0..4], 2);
1429 let bitmap_rec_offset = 248usize;
1434 let header_bitmap_len = NODE_SIZE - 256; 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 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
1455fn 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
1461fn 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#[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
1557fn 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
1568fn 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
1587fn 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 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 serialize_fork(data_fork, &mut rec[88..168]);
1610 serialize_fork(rsrc_fork, &mut rec[168..248]);
1611 rec
1612}
1613
1614fn 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]); 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
1634fn 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
1648fn 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
1660fn 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
1707fn 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 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
1794fn 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); BigEndian::write_u32(&mut out[8..12], 0); BigEndian::write_u32(&mut out[12..16], 0); 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); BigEndian::write_u32(&mut out[56..60], plan.block_size); BigEndian::write_u32(&mut out[60..64], plan.block_size); 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); BigEndian::write_u64(&mut out[72..80], 1); for i in 0..8 {
1828 BigEndian::write_u32(&mut out[80 + i * 4..84 + i * 4], finder_info[i]);
1829 }
1830
1831 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 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 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 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 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); 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#[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 #[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 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 let plan = plan_defrag_layout(&snap, 16 * 1024 * 1024).expect("plan should succeed");
1949
1950 assert_eq!(plan.block_size, 4096);
1952 assert_eq!(plan.total_blocks, (16 * 1024 * 1024) / 4096);
1953
1954 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 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 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 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 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 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 #[test]
2034 fn build_metadata_round_trip_dirs_and_files() {
2035 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 let bs = plan.block_size as usize;
2066 let mut img = vec![0u8; target_size as usize];
2067 img[..bs].copy_from_slice(&meta.block_zero);
2069 let bitmap_off = plan.bitmap_start as usize * bs;
2071 img[bitmap_off..bitmap_off + meta.bitmap.len()].copy_from_slice(&meta.bitmap);
2072 let catalog_off = plan.catalog_start as usize * bs;
2074 img[catalog_off..catalog_off + meta.catalog.len()].copy_from_slice(&meta.catalog);
2075 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 let alt = target_size as usize - 1024;
2083 img[alt..alt + 512].copy_from_slice(&meta.vh_bytes);
2084
2085 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 #[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 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 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 #[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 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 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 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 #[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 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 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 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 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 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 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 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 #[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 #[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]; 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 #[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 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 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 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 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 assert_eq!(report.files_copied, 1, "one non-inode file (dual)");
2709 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 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 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 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 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 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 #[test]
2796 fn stream_clone_of_multilevel_catalog_is_fsck_clean() {
2797 use crate::hfs_common::BTreeHeader;
2798
2799 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 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 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 if let Ok(path) = std::env::var("RB_EMIT_DEFRAG_IMAGE") {
2858 std::fs::write(&path, &out).unwrap();
2859 }
2860
2861 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 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 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}