Skip to main content

fs_mac/
hfsplus_wrapper_clone.rs

1//! Wrapper-aware HFS+ defrag clone.
2//!
3//! Classic Mac OS 8.x/9.x volumes store an HFS+ volume **embedded inside an
4//! HFS wrapper**: the outer Apple_HFS partition is itself a classic HFS
5//! volume whose MDB has `drEmbedSigWord == 0x482B`/`0x4858`, with
6//! `drEmbedExtent` pointing at a contiguous range of outer allocation
7//! blocks that hold a real HFS+ volume. Mac OS 8.1+ reads the MDB, jumps
8//! straight to the inner HFS+, and mounts it; pre-8.1 systems mount the
9//! outer HFS and see a "Where_have_all_my_files_gone?" read-me file.
10//!
11//! [`stream_defragmented_hfsplus`](crate::hfsplus_defrag::stream_defragmented_hfsplus)
12//! only handles **flat** HFS+ (no wrapper). [`stream_wrapped_defragmented_hfsplus`]
13//! in this module wraps that with the surrounding HFS shell:
14//!
15//! ```text
16//! +---- new wrapper partition ----+
17//! | boot blocks (verbatim)        |
18//! | primary MDB (patched)         |
19//! | volume bitmap (patched)       |
20//! | wrapper alloc blocks 0..S-1   |  (verbatim from source — holds the
21//! |   ("read-me" file + B-trees)  |   wrapper's catalog/extents, untouched)
22//! | embedded extent (S..S+N-1)    |  ← stream_defragmented_hfsplus output
23//! |   = new flat HFS+ volume      |
24//! | alt MDB (patched mirror)      |
25//! | reserved last sector          |
26//! +-------------------------------+
27//! ```
28//!
29//! where `S = drEmbedExtent.startBlock` (unchanged from source) and `N =
30//! ceil(target_inner_size / drAlBlkSiz)` (smaller than the source's
31//! embedded extent). `drNmAlBlks` shrinks to `S + N` and the wrapper's
32//! internal catalog/extents B-trees are preserved byte-for-byte because
33//! their allocation blocks all lie in `[0..S)` and stay at the same
34//! source byte offsets.
35
36#[cfg(feature = "rust173-polyfill")]
37use disk_core::rust173::IntIsMultipleOf as _;
38use std::io::{self, BufReader, Read, Seek, SeekFrom, Write};
39
40use byteorder::{BigEndian, ByteOrder};
41
42use crate::hfsplus::HfsPlusFilesystem;
43use crate::hfsplus_defrag::{stream_defragmented_hfsplus, DefragReport};
44use disk_core::filesystem::FilesystemError;
45
46/// Fields parsed from the outer HFS wrapper MDB that the clone pipeline
47/// needs to rebuild the wrapper around a shrunken inner volume.
48#[derive(Debug, Clone)]
49pub struct WrappedHfsPlusInfo {
50    /// Byte offset of the wrapper inside the source disk image.
51    pub partition_offset: u64,
52    /// Size in bytes of the source wrapper partition extent.
53    pub source_partition_size: u64,
54    /// `drAlBlkSiz` — outer HFS allocation block size (bytes). Always a
55    /// multiple of 512, but unlike HFS+ this need not be a power of two.
56    pub al_block_size: u32,
57    /// `drAlBlSt` — sector index (512-byte units, relative to partition
58    /// start) where the wrapper's allocation block area begins.
59    pub al_block_start_sector: u16,
60    /// `drNmAlBlks` — number of allocation blocks in the source wrapper.
61    pub source_total_blocks: u16,
62    /// `drVBMSt` — sector index (relative to partition start) of the
63    /// volume bitmap.
64    pub bitmap_start_sector: u16,
65    /// `drEmbedSigWord` — `0x482B` (H+) or `0x4858` (HX).
66    pub embed_sig: u16,
67    /// `drEmbedExtent.startBlock` — first wrapper allocation block of the
68    /// embedded HFS+ volume.
69    pub embed_start_block: u16,
70    /// `drEmbedExtent.blockCount` — number of wrapper allocation blocks
71    /// occupied by the embedded HFS+ volume.
72    pub embed_block_count: u16,
73    /// Byte offset of the embedded HFS+ inside the source disk image.
74    pub inner_offset: u64,
75    /// Size in bytes of the embedded HFS+ volume in the source.
76    pub inner_size: u64,
77}
78
79/// Probe the wrapper MDB at `partition_offset + 1024` and return the
80/// parsed wrapper info iff it's a classic HFS volume with an embedded
81/// HFS+/HFSX. Returns `None` for any other layout (flat HFS+, native HFS,
82/// non-HFS, parse errors).
83pub fn detect_wrapped_hfsplus<R: Read + Seek>(
84    reader: &mut R,
85    partition_offset: u64,
86    partition_size: u64,
87) -> Option<WrappedHfsPlusInfo> {
88    reader.seek(SeekFrom::Start(partition_offset + 1024)).ok()?;
89    let mut mdb = [0u8; 512];
90    reader.read_exact(&mut mdb).ok()?;
91    parse_wrapper_mdb(&mdb, partition_offset, partition_size)
92}
93
94/// Same as [`detect_wrapped_hfsplus`] but reads the MDB sector via
95/// positioned I/O ([`std::os::unix::fs::FileExt::read_at`] on Unix,
96/// [`std::os::windows::fs::FileExt::seek_read`] on Windows). Use this
97/// from any context that shares the underlying file descriptor across
98/// threads — `try_clone()` on Unix uses `dup()`, which shares the
99/// kernel-level seek offset, so concurrent `seek + read` callers can
100/// clobber each other and return the wrong partition's MDB. Positioned
101/// reads carry the offset in the syscall itself and don't touch the
102/// shared cursor, so they're safe under any amount of fd sharing.
103pub fn detect_wrapped_hfsplus_at(
104    file: &std::fs::File,
105    partition_offset: u64,
106    partition_size: u64,
107) -> Option<WrappedHfsPlusInfo> {
108    let mut mdb = [0u8; 512];
109    read_exact_at(file, partition_offset + 1024, &mut mdb).ok()?;
110    parse_wrapper_mdb(&mdb, partition_offset, partition_size)
111}
112
113/// Read exactly `buf.len()` bytes from `file` at byte offset `offset`
114/// without touching the file's seek cursor. Uses `pread`/`seek_read`
115/// under the hood. Loops on short reads.
116fn read_exact_at(file: &std::fs::File, mut offset: u64, mut buf: &mut [u8]) -> io::Result<()> {
117    #[cfg(unix)]
118    use std::os::unix::fs::FileExt;
119    #[cfg(windows)]
120    use std::os::windows::fs::FileExt;
121    while !buf.is_empty() {
122        #[cfg(unix)]
123        let n = file.read_at(buf, offset)?;
124        #[cfg(windows)]
125        let n = file.seek_read(buf, offset)?;
126        if n == 0 {
127            return Err(io::Error::new(
128                io::ErrorKind::UnexpectedEof,
129                "unexpected EOF in positioned read",
130            ));
131        }
132        offset += n as u64;
133        let tmp = buf;
134        buf = &mut tmp[n..];
135    }
136    Ok(())
137}
138
139/// Parse a 512-byte HFS MDB sector and decide whether it carries a
140/// wrapped HFS+ volume. Pure function — no I/O. Both
141/// [`detect_wrapped_hfsplus`] (legacy `Read + Seek` API) and
142/// [`detect_wrapped_hfsplus_at`] (race-safe positioned-read API) call
143/// this so the validation logic lives in one place.
144fn parse_wrapper_mdb(
145    mdb: &[u8; 512],
146    partition_offset: u64,
147    partition_size: u64,
148) -> Option<WrappedHfsPlusInfo> {
149    let sig = BigEndian::read_u16(&mdb[0..2]);
150    if sig != 0x4244 {
151        return None;
152    }
153    let embed_sig = BigEndian::read_u16(&mdb[124..126]);
154    if embed_sig != 0x482B && embed_sig != 0x4858 {
155        return None;
156    }
157    let al_block_size = BigEndian::read_u32(&mdb[20..24]);
158    let bitmap_start_sector = BigEndian::read_u16(&mdb[14..16]);
159    let total_blocks = BigEndian::read_u16(&mdb[18..20]);
160    let al_block_start_sector = BigEndian::read_u16(&mdb[28..30]);
161    let embed_start_block = BigEndian::read_u16(&mdb[126..128]);
162    let embed_block_count = BigEndian::read_u16(&mdb[128..130]);
163
164    if al_block_size == 0 || al_block_size % 512 != 0 {
165        return None;
166    }
167    if al_block_start_sector == 0 || bitmap_start_sector == 0 {
168        return None;
169    }
170    if embed_block_count == 0 {
171        return None;
172    }
173
174    let inner_offset = partition_offset
175        + (al_block_start_sector as u64) * 512
176        + (embed_start_block as u64) * (al_block_size as u64);
177    let inner_size = (embed_block_count as u64) * (al_block_size as u64);
178
179    // Sanity: inner extent must fit inside the partition. Use saturating
180    // arithmetic so a caller passing `u64::MAX` for the partition size (when
181    // the real bound isn't known yet — e.g. the GUI's pre-flight) doesn't
182    // overflow.
183    let part_end = partition_offset.saturating_add(partition_size);
184    let inner_end = inner_offset.saturating_add(inner_size);
185    if inner_end > part_end {
186        return None;
187    }
188
189    Some(WrappedHfsPlusInfo {
190        partition_offset,
191        source_partition_size: partition_size,
192        al_block_size,
193        al_block_start_sector,
194        source_total_blocks: total_blocks,
195        bitmap_start_sector,
196        embed_sig,
197        embed_start_block,
198        embed_block_count,
199        inner_offset,
200        inner_size,
201    })
202}
203
204/// Plan for emitting a shrunken wrapped HFS+ volume. All sizes are bytes
205/// unless noted.
206#[derive(Debug, Clone)]
207pub struct WrappedClonePlan {
208    pub info: WrappedHfsPlusInfo,
209    /// Inner HFS+ size after clone, in bytes. Multiple of both the inner
210    /// HFS+ block size and the outer `al_block_size`.
211    pub new_inner_size: u64,
212    /// Inner extent measured in outer wrapper allocation blocks.
213    pub new_embed_block_count: u16,
214    /// New `drNmAlBlks` (= `embed_start_block + new_embed_block_count`).
215    pub new_total_blocks: u16,
216    /// New partition extent size in bytes (multiple of 512).
217    pub new_partition_size: u64,
218}
219
220#[derive(Debug, thiserror::Error)]
221pub enum WrappedCloneError {
222    #[error("inner HFS+ block size {0} is not a divisor of the wrapper allocation block size")]
223    InnerOuterBlockMismatch(u32),
224    #[error("requested inner target {requested} exceeds source extent {available}")]
225    TargetExceedsSource { requested: u64, available: u64 },
226    #[error("new wrapper allocation block count {count} exceeds the 16-bit HFS field limit")]
227    BlockCountOverflow { count: u64 },
228    #[error("io: {0}")]
229    Io(#[from] io::Error),
230    #[error("filesystem: {0}")]
231    Filesystem(#[from] FilesystemError),
232}
233
234/// Build a [`WrappedClonePlan`] sized for a shrunken inner HFS+ volume.
235///
236/// `target_inner_size_hint` is the caller's *desired* inner size (typically
237/// the inner volume's `defragmented_minimum_size()`). It will be rounded
238/// up to the next outer `al_block_size` multiple, and validated against
239/// the source's embedded extent.
240pub fn plan_wrapped_clone(
241    info: &WrappedHfsPlusInfo,
242    target_inner_size_hint: u64,
243) -> Result<WrappedClonePlan, WrappedCloneError> {
244    let outer_bs = info.al_block_size as u64;
245    // Round up to outer allocation block boundary. The wrapper packs the
246    // inner extent in units of `al_block_size`, so anything finer would
247    // break the embedded-extent invariants.
248    let new_inner_size = target_inner_size_hint.div_ceil(outer_bs) * outer_bs;
249    if new_inner_size > info.inner_size {
250        return Err(WrappedCloneError::TargetExceedsSource {
251            requested: new_inner_size,
252            available: info.inner_size,
253        });
254    }
255    let new_count_u64 = new_inner_size / outer_bs;
256    let new_embed_block_count: u16 =
257        new_count_u64
258            .try_into()
259            .map_err(|_| WrappedCloneError::BlockCountOverflow {
260                count: new_count_u64,
261            })?;
262    let new_total_blocks_u32 = info.embed_start_block as u32 + new_embed_block_count as u32;
263    let new_total_blocks: u16 =
264        new_total_blocks_u32
265            .try_into()
266            .map_err(|_| WrappedCloneError::BlockCountOverflow {
267                count: new_total_blocks_u32 as u64,
268            })?;
269    // Wrapper layout: alloc-block region + 1024 trailing bytes (alt MDB +
270    // reserved last sector). Per IM:Files alt MDB is at sector -2 from
271    // partition end, last sector reserved.
272    let new_partition_size =
273        (info.al_block_start_sector as u64) * 512 + (new_total_blocks as u64) * outer_bs + 1024;
274
275    Ok(WrappedClonePlan {
276        info: info.clone(),
277        new_inner_size,
278        new_embed_block_count,
279        new_total_blocks,
280        new_partition_size,
281    })
282}
283
284/// Stream a shrunken wrapped HFS+ volume to `dst`. Single-pass; no seeks
285/// required on the writer.
286///
287/// `reader` must be a `Read + Seek` view of the *source disk* (not the
288/// inner volume): wrapper bytes are pulled from the source partition extent
289/// at byte offsets computed from `plan.info.partition_offset` etc.
290pub fn stream_wrapped_defragmented_hfsplus<R, W>(
291    reader: &mut R,
292    plan: &WrappedClonePlan,
293    dst: &mut W,
294    before_emit: Option<&dyn Fn()>,
295) -> Result<DefragReport, WrappedCloneError>
296where
297    R: Read + Seek + Send,
298    W: Write,
299{
300    let info = &plan.info;
301    let mut written: u64 = 0;
302    let outer_bs = info.al_block_size as u64;
303
304    // ---------- Boot blocks: bytes [0, 1024) — verbatim from source ----------
305    copy_from_source(reader, info.partition_offset, dst, 1024)?;
306    written += 1024;
307
308    // ---------- Primary MDB at sector 2 (bytes 1024..1536) ----------
309    let mut mdb_buf = [0u8; 512];
310    {
311        reader.seek(SeekFrom::Start(info.partition_offset + 1024))?;
312        reader.read_exact(&mut mdb_buf)?;
313    }
314    patch_mdb_in_place(&mut mdb_buf, plan);
315    dst.write_all(&mdb_buf)?;
316    written += 512;
317
318    // ---------- Zero/source bytes from MDB end up to drVBMSt sector ----------
319    let bitmap_start = info.bitmap_start_sector as u64 * 512;
320    if bitmap_start < 1536 {
321        // Pathological wrapper — drVBMSt must be ≥ 3 per HFS spec.
322        return Err(WrappedCloneError::Filesystem(FilesystemError::InvalidData(
323            format!("wrapper drVBMSt {} too low", info.bitmap_start_sector),
324        )));
325    }
326    if bitmap_start > 1536 {
327        copy_from_source(
328            reader,
329            info.partition_offset + 1536,
330            dst,
331            bitmap_start - 1536,
332        )?;
333        written += bitmap_start - 1536;
334    }
335
336    // ---------- Volume bitmap [drVBMSt, drAlBlSt) — source bytes, last bits cleared ----------
337    let alloc_start = info.al_block_start_sector as u64 * 512;
338    if alloc_start <= bitmap_start {
339        return Err(WrappedCloneError::Filesystem(FilesystemError::InvalidData(
340            format!(
341                "wrapper drAlBlSt {} not greater than drVBMSt {}",
342                info.al_block_start_sector, info.bitmap_start_sector,
343            ),
344        )));
345    }
346    let bitmap_len = alloc_start - bitmap_start;
347    let mut bitmap = vec![0u8; bitmap_len as usize];
348    {
349        reader.seek(SeekFrom::Start(info.partition_offset + bitmap_start))?;
350        reader.read_exact(&mut bitmap)?;
351    }
352    clear_bitmap_tail(&mut bitmap, plan.new_total_blocks);
353    dst.write_all(&bitmap)?;
354    written += bitmap_len;
355
356    // ---------- Wrapper alloc blocks [0, embed_start) — verbatim ----------
357    let pre_embed_bytes = (info.embed_start_block as u64) * outer_bs;
358    if pre_embed_bytes > 0 {
359        copy_from_source(
360            reader,
361            info.partition_offset + alloc_start,
362            dst,
363            pre_embed_bytes,
364        )?;
365        written += pre_embed_bytes;
366    }
367
368    // ---------- Embedded HFS+ — clone via flat-HFS+ streamer ----------
369    //
370    // Reopen the inner volume as its own HfsPlusFilesystem at the source's
371    // inner_offset. The clone targets `plan.new_inner_size`, which we
372    // already validated as a multiple of the outer alloc-block size (≥
373    // the inner block size in every real-world wrapper).
374    let inner_clone_reader = clone_reader(reader)?;
375    let mut inner_fs =
376        HfsPlusFilesystem::open(BufReader::new(inner_clone_reader), info.inner_offset)
377            .map_err(WrappedCloneError::Filesystem)?;
378
379    // The planner aligns `new_inner_size` to the outer `al_block_size`,
380    // which is normally a clean multiple of the inner HFS+ block size
381    // (4 KiB). Validate it explicitly so a corrupt source surfaces here
382    // instead of mid-stream.
383    let inner_bs = inner_fs.block_size();
384    if !plan.new_inner_size.is_multiple_of(inner_bs as u64) {
385        return Err(WrappedCloneError::InnerOuterBlockMismatch(inner_bs));
386    }
387
388    let report = stream_defragmented_hfsplus(&mut inner_fs, plan.new_inner_size, dst, before_emit)
389        .map_err(WrappedCloneError::Filesystem)?;
390    written += plan.new_inner_size;
391
392    // ---------- Tail: zero pad to (new_partition_size - 1024) ----------
393    let alt_mdb_offset = plan.new_partition_size - 1024;
394    if written > alt_mdb_offset {
395        return Err(WrappedCloneError::Filesystem(FilesystemError::InvalidData(
396            format!(
397                "wrapper clone over-emitted: wrote {written} bytes but alt MDB sits at {alt_mdb_offset}"
398            ),
399        )));
400    }
401    write_zeros(dst, alt_mdb_offset - written)?;
402    written = alt_mdb_offset;
403
404    // ---------- Alternate MDB (sector -2 from partition end) ----------
405    dst.write_all(&mdb_buf)?;
406    written += 512;
407
408    // ---------- Reserved last sector ----------
409    write_zeros(dst, 512)?;
410    written += 512;
411
412    debug_assert_eq!(written, plan.new_partition_size);
413    Ok(report)
414}
415
416fn patch_mdb_in_place(mdb: &mut [u8; 512], plan: &WrappedClonePlan) {
417    // drNmAlBlks @ 18 (u16 BE).
418    BigEndian::write_u16(&mut mdb[18..20], plan.new_total_blocks);
419    // drFreeBks @ 34 (u16 BE). The wrapper is fully allocated: its own
420    // bookkeeping files fill `[0..embed_start)` and the embedded extent
421    // fills `[embed_start..new_total_blocks)`. Nothing free.
422    BigEndian::write_u16(&mut mdb[34..36], 0);
423    // drEmbedExtent.blockCount @ 128 (u16 BE).
424    BigEndian::write_u16(&mut mdb[128..130], plan.new_embed_block_count);
425    // drEmbedExtent.startBlock @ 126 stays unchanged so the inner
426    // landing offset within the wrapper doesn't move.
427}
428
429fn clear_bitmap_tail(bitmap: &mut [u8], new_total_blocks: u16) {
430    let n = new_total_blocks as usize;
431    let byte_idx = n / 8;
432    let bit_idx = n % 8;
433    if byte_idx >= bitmap.len() {
434        return;
435    }
436    // Within the byte that straddles the boundary: keep the low `bit_idx`
437    // MSB-counted bits (HFS bitmap is big-endian: bit 0 = MSB of byte 0
438    // covers alloc block 0).
439    if bit_idx > 0 {
440        // Mask keeping the top `bit_idx` bits.
441        let keep_mask: u8 = !((1u8 << (8 - bit_idx)) - 1);
442        bitmap[byte_idx] &= keep_mask;
443    } else {
444        bitmap[byte_idx] = 0;
445    }
446    for byte in bitmap[byte_idx + 1..].iter_mut() {
447        *byte = 0;
448    }
449}
450
451const ZEROS: &[u8] = &[0u8; 65536];
452
453fn write_zeros<W: Write>(dst: &mut W, mut remaining: u64) -> io::Result<()> {
454    while remaining > 0 {
455        let n = remaining.min(ZEROS.len() as u64) as usize;
456        dst.write_all(&ZEROS[..n])?;
457        remaining -= n as u64;
458    }
459    Ok(())
460}
461
462fn copy_from_source<R: Read + Seek, W: Write>(
463    reader: &mut R,
464    src_offset: u64,
465    dst: &mut W,
466    len: u64,
467) -> io::Result<()> {
468    reader.seek(SeekFrom::Start(src_offset))?;
469    let mut buf = [0u8; 65536];
470    let mut remaining = len;
471    while remaining > 0 {
472        let n = remaining.min(buf.len() as u64) as usize;
473        reader.read_exact(&mut buf[..n])?;
474        dst.write_all(&buf[..n])?;
475        remaining -= n as u64;
476    }
477    Ok(())
478}
479
480/// Build a fresh `Read + Seek` view of the same backing source so the
481/// inner `HfsPlusFilesystem` can be opened without interfering with the
482/// outer reader's cursor. Falls back to a wrapped clone via `try_clone`
483/// for `File`-backed sources; in-memory test sources implement a
484/// no-op clone trick documented in the test helpers.
485fn clone_reader<R: Read + Seek>(reader: &mut R) -> Result<ReaderClone<'_, R>, WrappedCloneError> {
486    // The trait-object reader can't be Cloned generically. We need a
487    // separate Read+Seek over the same byte stream. The simplest portable
488    // path: dump nothing — just return a positional wrapper that re-uses
489    // `reader` via a shared cursor. Since `stream_wrapped_defragmented_hfsplus`
490    // is single-pass on `dst` and the inner clone consumes the reader
491    // strictly via Seek+Read (no concurrent use), interleaving is fine.
492    Ok(ReaderClone {
493        // Safety: we move the &mut R into a wrapper that's only used for
494        // the duration of the inner clone. The outer streamer doesn't
495        // re-issue reads on `reader` until the inner clone returns.
496        inner: reader,
497    })
498}
499
500/// Adapter that re-borrows the source reader for the inner HFS+ open.
501struct ReaderClone<'a, R> {
502    inner: &'a mut R,
503}
504
505impl<'a, R: Read> Read for ReaderClone<'a, R> {
506    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
507        self.inner.read(buf)
508    }
509}
510
511impl<'a, R: Seek> Seek for ReaderClone<'a, R> {
512    fn seek(&mut self, pos: SeekFrom) -> io::Result<u64> {
513        self.inner.seek(pos)
514    }
515}
516
517#[cfg(test)]
518mod tests {
519    use super::*;
520    use std::io::Cursor;
521
522    use crate::hfsplus::create_blank_hfsplus;
523
524    /// Build a minimal wrapped HFS+ image:
525    ///   - boot blocks (1024 bytes, zeros)
526    ///   - primary MDB at sector 2 with drEmbedSigWord/drEmbedExtent set
527    ///   - drVBMSt = 3, bitmap covers `drNmAlBlks` bits then zero pad
528    ///   - drAlBlSt = `bitmap_sectors + 3`
529    ///   - Wrapper alloc block 0 = zeros (no real wrapper B-trees — the
530    ///     clone path treats this region opaquely as "wrapper bookkeeping")
531    ///   - Embedded HFS+ at alloc block `embed_start`, sized to fit
532    ///     `inner_image_size_bytes`
533    ///   - Alt MDB at partition_size - 1024
534    fn build_wrapped_test_image(
535        inner_block_size: u32,
536        inner_size: u64,
537        outer_al_block_size: u32,
538        embed_start_block: u16,
539    ) -> (Vec<u8>, /*partition_size*/ u64) {
540        assert_eq!(inner_size % outer_al_block_size as u64, 0);
541        let inner = create_blank_hfsplus(inner_size, inner_block_size, "Inner", false);
542        assert_eq!(inner.len() as u64, inner_size);
543
544        let outer_bs = outer_al_block_size as u64;
545        let embed_block_count = (inner_size / outer_bs) as u16;
546        let total_blocks: u16 = embed_start_block + embed_block_count;
547        let bitmap_bits = total_blocks as u64;
548        let bitmap_bytes = bitmap_bits.div_ceil(8);
549        let bitmap_sectors = bitmap_bytes.div_ceil(512) as u16;
550        let drvbmst: u16 = 3;
551        let dralblst: u16 = drvbmst + bitmap_sectors;
552
553        let alloc_start_bytes = (dralblst as u64) * 512;
554        let inner_offset = alloc_start_bytes + (embed_start_block as u64) * outer_bs;
555        let partition_size = alloc_start_bytes + (total_blocks as u64) * outer_bs + 1024;
556
557        let mut img = vec![0u8; partition_size as usize];
558
559        // MDB at sector 2.
560        let mdb_off = 1024;
561        let mdb = &mut img[mdb_off..mdb_off + 512];
562        BigEndian::write_u16(&mut mdb[0..2], 0x4244);
563        BigEndian::write_u16(&mut mdb[14..16], drvbmst);
564        BigEndian::write_u16(&mut mdb[18..20], total_blocks); // drNmAlBlks
565        BigEndian::write_u32(&mut mdb[20..24], outer_al_block_size); // drAlBlkSiz
566        BigEndian::write_u16(&mut mdb[28..30], dralblst); // drAlBlSt
567        BigEndian::write_u16(&mut mdb[34..36], 0); // drFreeBks
568        BigEndian::write_u16(&mut mdb[124..126], 0x482B); // drEmbedSigWord
569        BigEndian::write_u16(&mut mdb[126..128], embed_start_block);
570        BigEndian::write_u16(&mut mdb[128..130], embed_block_count);
571
572        // Bitmap: first `total_blocks` bits set.
573        let bitmap_off = (drvbmst as usize) * 512;
574        for bit in 0..(total_blocks as usize) {
575            img[bitmap_off + bit / 8] |= 0x80 >> (bit % 8);
576        }
577
578        // Inner HFS+ payload at the embedded extent.
579        img[inner_offset as usize..(inner_offset + inner_size) as usize].copy_from_slice(&inner);
580
581        // Alt MDB mirror at -1024.
582        let alt_off = partition_size as usize - 1024;
583        let mdb_copy = img[mdb_off..mdb_off + 512].to_vec();
584        img[alt_off..alt_off + 512].copy_from_slice(&mdb_copy);
585
586        (img, partition_size)
587    }
588
589    #[test]
590    fn detect_recognises_synthesised_wrapper() {
591        let (img, partition_size) = build_wrapped_test_image(4096, 4 * 1024 * 1024, 4096, 1);
592        let mut cur = Cursor::new(&img);
593        let info =
594            detect_wrapped_hfsplus(&mut cur, 0, partition_size).expect("should detect wrapper");
595        assert_eq!(info.embed_sig, 0x482B);
596        assert_eq!(info.al_block_size, 4096);
597        assert_eq!(info.embed_start_block, 1);
598        assert_eq!(info.embed_block_count as u32, (4 * 1024 * 1024) / 4096);
599        assert_eq!(info.inner_size, 4 * 1024 * 1024);
600    }
601
602    #[test]
603    fn round_trip_clones_at_source_size() {
604        // 512 KiB inner, 4 KiB outer alloc blocks.
605        let (img, partition_size) = build_wrapped_test_image(4096, 4 * 1024 * 1024, 4096, 1);
606        let mut cur = Cursor::new(img.clone());
607        let info = detect_wrapped_hfsplus(&mut cur, 0, partition_size).unwrap();
608        // Clone at the same inner size — no shrink, just round-trip.
609        let plan = plan_wrapped_clone(&info, info.inner_size).unwrap();
610        assert_eq!(plan.new_inner_size, info.inner_size);
611        assert_eq!(plan.new_partition_size, partition_size);
612
613        let mut out: Vec<u8> = Vec::with_capacity(partition_size as usize);
614        let mut src_cur = Cursor::new(img.as_slice());
615        let report = stream_wrapped_defragmented_hfsplus(&mut src_cur, &plan, &mut out, None)
616            .expect("clone should succeed");
617        // Empty inner volume — no files copied, but stream completed.
618        assert_eq!(report.files_copied, 0);
619        assert_eq!(out.len(), partition_size as usize);
620
621        // Verify wrapper survived: signature + embed pointer intact.
622        let mut out_cur = Cursor::new(out.as_slice());
623        let info2 = detect_wrapped_hfsplus(&mut out_cur, 0, partition_size)
624            .expect("output must still parse as wrapper");
625        assert_eq!(info2.embed_sig, info.embed_sig);
626        assert_eq!(info2.embed_start_block, info.embed_start_block);
627        assert_eq!(info2.embed_block_count, info.embed_block_count);
628
629        // Inner HFS+ must be openable at the resolved offset.
630        let mut inner_cur = Cursor::new(out.as_slice());
631        let _hfs = crate::hfsplus::HfsPlusFilesystem::open(&mut inner_cur, info2.inner_offset)
632            .expect("inner HFS+ must be valid after wrapper round-trip");
633    }
634
635    #[test]
636    fn shrink_keeps_wrapper_and_inner_valid() {
637        // Build an 8 MiB inner; clone targeting 1 MiB. The blank HFS+
638        // body fits well under 1 MiB, so the planner should accept it.
639        let (img, partition_size) = build_wrapped_test_image(4096, 8 * 1024 * 1024, 4096, 1);
640        let mut cur = Cursor::new(img.clone());
641        let info = detect_wrapped_hfsplus(&mut cur, 0, partition_size).unwrap();
642
643        let target_inner = 1024 * 1024u64;
644        let plan = plan_wrapped_clone(&info, target_inner).unwrap();
645        assert_eq!(plan.new_inner_size, target_inner);
646        assert!(plan.new_partition_size < partition_size);
647
648        let mut out: Vec<u8> = Vec::with_capacity(plan.new_partition_size as usize);
649        let mut src_cur = Cursor::new(img.as_slice());
650        stream_wrapped_defragmented_hfsplus(&mut src_cur, &plan, &mut out, None)
651            .expect("shrink clone should succeed");
652        assert_eq!(out.len(), plan.new_partition_size as usize);
653
654        // Output: wrapper still parses and inner HFS+ opens at resolved offset.
655        let mut out_cur = Cursor::new(out.as_slice());
656        let info2 = detect_wrapped_hfsplus(&mut out_cur, 0, plan.new_partition_size).unwrap();
657        assert_eq!(
658            info2.embed_block_count, plan.new_embed_block_count,
659            "wrapper must reflect the new embedded extent size"
660        );
661        let mut inner_cur = Cursor::new(out.as_slice());
662        let _hfs = crate::hfsplus::HfsPlusFilesystem::open(&mut inner_cur, info2.inner_offset)
663            .expect("inner HFS+ must be valid after wrapper shrink");
664    }
665
666    #[test]
667    fn clear_bitmap_tail_zeros_past_boundary() {
668        // 24 blocks worth of bitmap, all 1s.
669        let mut bm = vec![0xFFu8; 4];
670        clear_bitmap_tail(&mut bm, 10);
671        // Blocks 0..10 set, 10..32 cleared. Bit 0 = MSB.
672        assert_eq!(bm[0], 0xFF);
673        assert_eq!(bm[1], 0b1100_0000);
674        assert_eq!(bm[2], 0x00);
675        assert_eq!(bm[3], 0x00);
676    }
677
678    #[test]
679    fn clear_bitmap_tail_byte_boundary() {
680        let mut bm = vec![0xFFu8; 4];
681        clear_bitmap_tail(&mut bm, 16);
682        assert_eq!(bm[0], 0xFF);
683        assert_eq!(bm[1], 0xFF);
684        assert_eq!(bm[2], 0x00);
685        assert_eq!(bm[3], 0x00);
686    }
687
688    #[test]
689    fn plan_rounds_inner_to_outer_block() {
690        let info = WrappedHfsPlusInfo {
691            partition_offset: 0,
692            source_partition_size: 1024 * 1024 * 1024, // 1 GiB
693            al_block_size: 32 * 1024,                  // 32 KiB outer blocks
694            al_block_start_sector: 19,
695            source_total_blocks: 32000,
696            bitmap_start_sector: 3,
697            embed_sig: 0x482B,
698            embed_start_block: 8,
699            embed_block_count: 30000,
700            inner_offset: 19 * 512 + 8 * 32 * 1024,
701            inner_size: 30000u64 * 32 * 1024,
702        };
703        // Ask for ~5 MiB; expect rounding up to a multiple of 32 KiB.
704        let hint = 5 * 1024 * 1024 + 17; // not aligned
705        let plan = plan_wrapped_clone(&info, hint).unwrap();
706        assert_eq!(plan.new_inner_size % (32 * 1024), 0);
707        assert!(plan.new_inner_size >= hint);
708        // new_total_blocks = embed_start + new_count
709        assert_eq!(
710            plan.new_total_blocks as u64,
711            info.embed_start_block as u64 + plan.new_embed_block_count as u64
712        );
713        // partition_size = alloc_start*512 + new_total*outer_bs + 1024
714        let expected_size = 19u64 * 512 + (plan.new_total_blocks as u64) * 32 * 1024 + 1024;
715        assert_eq!(plan.new_partition_size, expected_size);
716    }
717}