Skip to main content

dmg/
lib.rs

1//! Pure-Rust forensic Apple Disk Image (DMG/UDIF) reader.
2//!
3//! A DMG file uses the UDIF (Universal Disk Image Format) container:
4//! - 512-byte **koly** trailer at the very end of the file (all big-endian)
5//! - XML plist at `xml_offset` containing partition block tables (`blkx` array)
6//! - Each blkx `Data` field is a base64-encoded **mish** block describing
7//!   how virtual sectors map to data in the file
8//!
9//! Supported block types: zero (`0x00`), raw (`0x01`), ignore (`0x02`), ADC
10//! (`0x80000004`), zlib/UDZO (`0x80000005`), bzip2/UDBZ (`0x80000006`),
11//! LZFSE/ULFO (`0x80000007`), and LZMA/ULMO (`0x80000008`) — every codec
12//! `hdiutil` emits. All decoders are pure Rust (no C dependencies).
13
14mod sparse;
15
16pub use sparse::{SparseBundleReader, SparseImageReader};
17
18use std::io::{self, Cursor, Read, Seek, SeekFrom, Write};
19
20use base64::Engine;
21use flate2::read::ZlibDecoder;
22use quick_xml::events::Event;
23use quick_xml::Reader;
24use thiserror::Error;
25
26const KOLY_MAGIC: u32 = 0x6B6F_6C79; // b"koly"
27const MISH_MAGIC: u32 = 0x6D69_7368; // b"mish"
28const KOLY_SIZE: u64 = 512;
29
30const BLK_ZERO: u32 = 0x0000_0000;
31const BLK_RAW: u32 = 0x0000_0001;
32const BLK_IGNORE: u32 = 0x0000_0002;
33const BLK_ADC: u32 = 0x8000_0004;
34const BLK_ZLIB: u32 = 0x8000_0005;
35const BLK_BZIP2: u32 = 0x8000_0006;
36const BLK_LZFSE: u32 = 0x8000_0007;
37const BLK_LZMA: u32 = 0x8000_0008;
38const BLK_COMMENT: u32 = 0x7FFF_FFFE;
39const BLK_TERM: u32 = 0xFFFF_FFFF;
40
41/// Hard cap on a single block's decompressed size. UDIF chunks are ~2 MiB
42/// (`decompressBufferRequested`); 64 MiB is generous headroom while bounding the
43/// allocation a malformed/oversized block can request (defends against memory-exhaustion and
44/// decompression bombs — see `decompress`).
45const MAX_RUN_BYTES: usize = 64 * 1024 * 1024;
46
47/// Errors returned by `DmgReader`.
48#[derive(Debug, Error)]
49pub enum DmgError {
50    #[error("I/O error: {0}")]
51    Io(#[from] io::Error),
52    #[error("not a DMG: missing koly magic")]
53    NotADmg,
54    #[error("file too small to contain koly trailer")]
55    FileTooSmall,
56    #[error("invalid mish block: {0}")]
57    BadMish(String),
58    #[error("invalid plist XML: {0}")]
59    BadPlist(String),
60    #[error("decompression error: {0}")]
61    Compression(String),
62    #[error("unsupported compression type: {0:#010x}")]
63    NotSupported(u32),
64    #[error("not a sparse image: bad sprs magic {0:#010x}")]
65    NotSparseImage(u32),
66    #[error("invalid sparse image header: {0}")]
67    BadSparseHeader(String),
68    #[error("sparsebundle Info.plist not found")]
69    MissingInfoPlist,
70    #[error("invalid sparsebundle Info.plist: {0}")]
71    BadInfoPlist(String),
72}
73
74/// One `BLKXRun` entry from a mish block.
75#[derive(Debug, Clone)]
76struct BlkxRun {
77    entry_type: u32,
78    sector_start: u64,
79    sector_count: u64,
80    /// Byte offset relative to the partition's `data_offset`.
81    data_offset: u64,
82    data_length: u64,
83}
84
85/// One partition (mish block) within the DMG.
86#[derive(Debug, Clone)]
87struct Partition {
88    /// Absolute byte offset in the file for this partition's data.
89    file_data_offset: u64,
90    /// First virtual sector of this partition.
91    sector_base: u64,
92    runs: Vec<BlkxRun>,
93}
94
95impl Partition {
96    /// True if this partition contains the given virtual sector.
97    fn total_sectors(&self) -> u64 {
98        self.runs
99            .iter()
100            .filter(|r| r.entry_type != BLK_COMMENT && r.entry_type != BLK_TERM)
101            .map(|r| r.sector_start.saturating_add(r.sector_count))
102            .max()
103            .unwrap_or(0)
104    }
105
106    fn contains_sector(&self, vsec: u64) -> bool {
107        if vsec < self.sector_base {
108            return false;
109        }
110        let local = vsec - self.sector_base;
111        local < self.total_sectors()
112    }
113
114    /// Find the run covering local sector `local_sec` (relative to `sector_base`).
115    fn run_for(&self, local_sec: u64) -> Option<&BlkxRun> {
116        self.runs.iter().find(|r| {
117            r.entry_type != BLK_TERM
118                && r.entry_type != BLK_COMMENT
119                && local_sec >= r.sector_start
120                && local_sec < r.sector_start.saturating_add(r.sector_count)
121        })
122    }
123}
124
125/// Read-only Apple DMG (UDIF) reader implementing `Read + Seek`.
126pub struct DmgReader<R: Read + Seek> {
127    inner: R,
128    sector_count: u64,
129    /// Total file size, used to reject out-of-bounds block references (a
130    /// malformed image cannot make us allocate or read past the file).
131    file_size: u64,
132    partitions: Vec<Partition>,
133    position: u64,
134}
135
136impl<R: Read + Seek> DmgReader<R> {
137    /// Open a DMG file, parsing the koly trailer and XML plist.
138    pub fn open(mut reader: R) -> Result<Self, DmgError> {
139        // Confirm the file is large enough to hold the koly trailer.
140        let file_size = reader.seek(SeekFrom::End(0))?;
141        if file_size < KOLY_SIZE {
142            return Err(DmgError::FileTooSmall);
143        }
144
145        // Read the 512-byte koly trailer.
146        reader.seek(SeekFrom::Start(file_size - KOLY_SIZE))?;
147        let mut koly = [0u8; 512];
148        reader.read_exact(&mut koly)?;
149
150        let magic = u32::from_be_bytes(koly[0..4].try_into().unwrap());
151        if magic != KOLY_MAGIC {
152            return Err(DmgError::NotADmg);
153        }
154
155        let xml_offset = u64::from_be_bytes(koly[216..224].try_into().unwrap());
156        let xml_length = u64::from_be_bytes(koly[224..232].try_into().unwrap());
157        let sector_count = u64::from_be_bytes(koly[492..500].try_into().unwrap());
158
159        // Reject an XML plist that claims to extend past the file — otherwise a
160        // malformed koly could request a multi-terabyte allocation.
161        if xml_offset
162            .checked_add(xml_length)
163            .is_none_or(|end| end > file_size)
164        {
165            return Err(DmgError::BadPlist("xml region out of file bounds".into()));
166        }
167
168        // Read the XML plist.
169        reader.seek(SeekFrom::Start(xml_offset))?;
170        let mut xml_bytes = vec![0u8; xml_length as usize];
171        reader.read_exact(&mut xml_bytes)?;
172        let xml = std::str::from_utf8(&xml_bytes).map_err(|e| DmgError::BadPlist(e.to_string()))?;
173
174        let partitions = parse_plist(xml)?;
175
176        Ok(Self {
177            inner: reader,
178            sector_count,
179            file_size,
180            partitions,
181            position: 0,
182        })
183    }
184
185    /// Total virtual disk size in bytes (`sector_count × 512`).
186    pub fn virtual_disk_size(&self) -> u64 {
187        self.sector_count.saturating_mul(512)
188    }
189}
190
191impl<R: Read + Seek> Read for DmgReader<R> {
192    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
193        if buf.is_empty() {
194            return Ok(0);
195        }
196        let disk_size = self.virtual_disk_size();
197        if self.position >= disk_size {
198            return Ok(0);
199        }
200
201        let vsec = self.position / 512;
202        let sec_offset = self.position % 512;
203
204        // Find the partition and run covering this sector.
205        let part = self
206            .partitions
207            .iter()
208            .find(|p| p.contains_sector(vsec))
209            .ok_or_else(|| io::Error::new(io::ErrorKind::UnexpectedEof, "no partition"))?;
210
211        let local_sec = vsec - part.sector_base;
212        let run = part
213            .run_for(local_sec)
214            .ok_or_else(|| io::Error::new(io::ErrorKind::UnexpectedEof, "no run"))?;
215
216        // Byte offset within this run (relative to the run's first sector).
217        // Saturating throughout: a malformed run must never panic on overflow.
218        let bytes_into_run = (local_sec - run.sector_start)
219            .saturating_mul(512)
220            .saturating_add(sec_offset);
221        let run_total_bytes = run.sector_count.saturating_mul(512);
222        let available_in_run = run_total_bytes.saturating_sub(bytes_into_run);
223        let to_read = buf.len().min(available_in_run as usize);
224
225        match run.entry_type {
226            BLK_ZERO | BLK_IGNORE => {
227                buf[..to_read].fill(0);
228            }
229            BLK_RAW => {
230                // Checked + file-bounded: a malformed offset must error, not
231                // overflow or read past the file.
232                let file_pos = part
233                    .file_data_offset
234                    .checked_add(run.data_offset)
235                    .and_then(|p| p.checked_add(bytes_into_run))
236                    .filter(|&p| p.saturating_add(to_read as u64) <= self.file_size)
237                    .ok_or_else(|| {
238                        io::Error::new(io::ErrorKind::InvalidData, "raw block out of file bounds")
239                    })?;
240                self.inner.seek(SeekFrom::Start(file_pos))?;
241                self.inner.read_exact(&mut buf[..to_read])?;
242            }
243            BLK_ADC | BLK_ZLIB | BLK_BZIP2 | BLK_LZFSE | BLK_LZMA => {
244                // Bound both sizes before allocating: the compressed region must
245                // lie within the file, and the decompressed run must fit the cap.
246                // A malformed image otherwise requests a multi-terabyte buffer.
247                let file_pos = part
248                    .file_data_offset
249                    .checked_add(run.data_offset)
250                    .ok_or_else(|| {
251                        io::Error::new(io::ErrorKind::InvalidData, "block offset overflow")
252                    })?;
253                let comp_ok = file_pos
254                    .checked_add(run.data_length)
255                    .is_some_and(|end| end <= self.file_size);
256                if !comp_ok {
257                    return Err(io::Error::new(
258                        io::ErrorKind::InvalidData,
259                        "compressed block extends past end of file",
260                    ));
261                }
262                let expected = (run.sector_count as usize).saturating_mul(512);
263                if expected > MAX_RUN_BYTES {
264                    return Err(io::Error::new(
265                        io::ErrorKind::InvalidData,
266                        "block decompressed size exceeds cap",
267                    ));
268                }
269                self.inner.seek(SeekFrom::Start(file_pos))?;
270                let mut compressed = vec![0u8; run.data_length as usize];
271                self.inner.read_exact(&mut compressed)?;
272                let decompressed = decompress(run.entry_type, &compressed, expected)
273                    .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
274                let start = bytes_into_run as usize;
275                if start >= decompressed.len() {
276                    return Err(io::Error::new(
277                        io::ErrorKind::UnexpectedEof,
278                        "decompressed run underrun",
279                    ));
280                }
281                let end = (start + to_read).min(decompressed.len());
282                buf[..end - start].copy_from_slice(&decompressed[start..end]);
283            }
284            t => {
285                return Err(io::Error::new(
286                    io::ErrorKind::Unsupported,
287                    format!("unsupported block type {t:#010x}"),
288                ));
289            }
290        }
291
292        self.position += to_read as u64;
293        Ok(to_read)
294    }
295}
296
297impl<R: Read + Seek> Seek for DmgReader<R> {
298    fn seek(&mut self, pos: SeekFrom) -> io::Result<u64> {
299        let disk_size = self.virtual_disk_size();
300        let new_pos = match pos {
301            SeekFrom::Start(n) => n,
302            SeekFrom::End(n) => {
303                if n >= 0 {
304                    disk_size.saturating_add(n as u64)
305                } else {
306                    disk_size.saturating_sub((-n) as u64)
307                }
308            }
309            SeekFrom::Current(n) => {
310                if n >= 0 {
311                    self.position.saturating_add(n as u64)
312                } else {
313                    self.position.saturating_sub((-n) as u64)
314                }
315            }
316        };
317        self.position = new_pos;
318        Ok(self.position)
319    }
320}
321
322// ── Block codecs (all pure Rust) ────────────────────────────────────────────
323
324/// Decompress one UDIF block's data with the codec named by `entry_type`.
325/// `expected_len` (the run's `sector_count × 512`) sizes the output buffer.
326fn decompress(
327    entry_type: u32,
328    compressed: &[u8],
329    expected_len: usize,
330) -> Result<Vec<u8>, DmgError> {
331    // `expected_len` is the caller-validated cap (<= MAX_RUN_BYTES). Every codec
332    // bounds its output to it so a decompression bomb cannot exhaust memory.
333    let mut out = Vec::with_capacity(expected_len);
334    let cap = expected_len as u64;
335    match entry_type {
336        BLK_ZLIB => {
337            ZlibDecoder::new(Cursor::new(compressed))
338                .take(cap)
339                .read_to_end(&mut out)
340                .map_err(|e| DmgError::Compression(e.to_string()))?;
341        }
342        BLK_BZIP2 => {
343            bzip2_rs::DecoderReader::new(Cursor::new(compressed))
344                .take(cap)
345                .read_to_end(&mut out)
346                .map_err(|e| DmgError::Compression(e.to_string()))?;
347        }
348        BLK_LZMA => {
349            // ULMO blocks are XZ-framed (stream magic FD 37 7A 58 5A 00), not
350            // raw LZMA1. A LimitWriter caps the output at the cap.
351            let mut input = Cursor::new(compressed);
352            let mut sink = LimitWriter {
353                buf: &mut out,
354                limit: expected_len,
355            };
356            lzma_rs::xz_decompress(&mut input, &mut sink)
357                .map_err(|e| DmgError::Compression(e.to_string()))?;
358        }
359        BLK_LZFSE => {
360            let mut decoder = lzfse_rust::LzfseRingDecoder::default();
361            decoder
362                .reader_bytes(compressed)
363                .take(cap)
364                .read_to_end(&mut out)
365                .map_err(|e| DmgError::Compression(e.to_string()))?;
366        }
367        BLK_ADC => out = adc_decompress(compressed, expected_len),
368        other => return Err(DmgError::NotSupported(other)),
369    }
370    Ok(out)
371}
372
373/// Decode an Apple Data Compression (ADC) block — the simple LZSS variant used
374/// by UDCO images. Three token forms: a literal run (high bit set), a 2-byte
375/// short match, and a 3-byte long match (both back-references into the output).
376fn adc_decompress(input: &[u8], expected_len: usize) -> Vec<u8> {
377    let mut out = Vec::with_capacity(expected_len);
378    let mut i = 0;
379    // Stop at the cap so a malformed stream of back-references can't grow the
380    // output without bound.
381    while i < input.len() && out.len() < expected_len {
382        let b = input[i];
383        i += 1;
384        if b & 0x80 != 0 {
385            // Literal run of (b & 0x7F) + 1 bytes.
386            let n = (b & 0x7F) as usize + 1;
387            let end = (i + n).min(input.len());
388            out.extend_from_slice(&input[i..end]);
389            i = end;
390        } else if b & 0x40 != 0 {
391            // 3-byte form: length (b & 0x3F) + 4, 16-bit back-offset.
392            if i + 1 >= input.len() {
393                break;
394            }
395            let len = (b & 0x3F) as usize + 4;
396            let offset = ((input[i] as usize) << 8) | input[i + 1] as usize;
397            i += 2;
398            copy_back(&mut out, offset, len);
399        } else {
400            // 2-byte form: length ((b >> 2) & 0x0F) + 3, 10-bit back-offset.
401            if i >= input.len() {
402                break;
403            }
404            let len = ((b >> 2) & 0x0F) as usize + 3;
405            let offset = (((b & 0x03) as usize) << 8) | input[i] as usize;
406            i += 1;
407            copy_back(&mut out, offset, len);
408        }
409    }
410    out
411}
412
413/// LZSS back-reference copy of `len` bytes from `offset + 1` behind the end of
414/// `out`, byte-by-byte so overlapping (run-length) copies work.
415fn copy_back(out: &mut Vec<u8>, offset: usize, len: usize) {
416    for _ in 0..len {
417        if out.len() <= offset {
418            break;
419        }
420        let byte = out[out.len() - 1 - offset];
421        out.push(byte);
422    }
423}
424
425/// A `Write` adapter that appends to a `Vec` but errors once `limit` bytes have
426/// been written — caps streaming decoders (XZ) so a decompression bomb cannot
427/// exhaust memory.
428struct LimitWriter<'a> {
429    buf: &'a mut Vec<u8>,
430    limit: usize,
431}
432
433impl Write for LimitWriter<'_> {
434    fn write(&mut self, data: &[u8]) -> io::Result<usize> {
435        if self.buf.len() + data.len() > self.limit {
436            return Err(io::Error::new(
437                io::ErrorKind::InvalidData,
438                "decompressed output exceeds cap",
439            ));
440        }
441        self.buf.extend_from_slice(data);
442        Ok(data.len())
443    }
444
445    fn flush(&mut self) -> io::Result<()> {
446        Ok(())
447    }
448}
449
450// ── XML plist parser ──────────────────────────────────────────────────────────
451
452/// Parse the XML plist and extract all mish (blkx) partitions.
453fn parse_plist(xml: &str) -> Result<Vec<Partition>, DmgError> {
454    let mut reader = Reader::from_str(xml);
455    reader.config_mut().trim_text(true);
456
457    let mut in_blkx = false;
458    let mut in_data = false;
459    let mut last_key = String::new();
460    let mut partitions = Vec::new();
461
462    loop {
463        match reader.read_event() {
464            Ok(Event::Start(e)) => match e.name().as_ref() {
465                b"array" if last_key == "blkx" => {
466                    in_blkx = true;
467                }
468                b"data" if in_blkx => {
469                    in_data = true;
470                }
471                _ => {}
472            },
473            Ok(Event::Text(e)) => {
474                let text = e
475                    .xml_content(quick_xml::XmlVersion::Implicit1_0)
476                    .unwrap_or_default();
477                let trimmed = text.trim();
478                if e.is_empty() || trimmed.is_empty() {
479                    continue;
480                }
481                // Check if this text is for a <key> element
482                if trimmed != "blkx" && !in_blkx {
483                    last_key = trimmed.to_string();
484                    continue;
485                }
486                if trimmed == "blkx" {
487                    last_key = "blkx".to_string();
488                    continue;
489                }
490                if in_data && in_blkx {
491                    // base64-encoded mish block
492                    let cleaned: String = trimmed.chars().filter(|c| !c.is_whitespace()).collect();
493                    let raw = base64::engine::general_purpose::STANDARD
494                        .decode(cleaned.as_bytes())
495                        .map_err(|e| DmgError::BadPlist(e.to_string()))?;
496                    let partition = parse_mish(&raw)?;
497                    partitions.push(partition);
498                    in_data = false;
499                }
500            }
501            Ok(Event::End(e)) => {
502                if e.name().as_ref() == b"array" {
503                    in_blkx = false;
504                }
505            }
506            Ok(Event::Eof) => break,
507            Err(e) => return Err(DmgError::BadPlist(e.to_string())),
508            _ => {}
509        }
510    }
511    Ok(partitions)
512}
513
514/// Parse a raw mish block into a `Partition`.
515///
516/// Real mish layout (all big-endian):
517///   0-3:    magic "mish"
518///   4-7:    version
519///   8-15:   firstSectorNumber
520///   16-23:  sectorCount
521///   24-31:  dataStart (byte offset into data fork)
522///   32-35:  decompressBufferRequested
523///   36-63:  reserved (28 bytes)
524///   64-67:  checksum.type
525///   68-71:  checksum.size (= 32 u32 words)
526///   72-199: checksum.data (128 bytes)
527///   200-203: blockDescriptorCount
528///   204+:   `BLKXRun` entries (40 bytes each)
529fn parse_mish(data: &[u8]) -> Result<Partition, DmgError> {
530    if data.len() < 204 {
531        return Err(DmgError::BadMish("too short".into()));
532    }
533    let magic = u32::from_be_bytes(data[0..4].try_into().unwrap());
534    if magic != MISH_MAGIC {
535        return Err(DmgError::BadMish(format!("bad magic {magic:#010x}")));
536    }
537    let sector_number = u64::from_be_bytes(data[8..16].try_into().unwrap());
538    let file_data_offset = u64::from_be_bytes(data[24..32].try_into().unwrap());
539    let block_descriptors = u32::from_be_bytes(data[200..204].try_into().unwrap()) as usize;
540
541    let runs_start = 204;
542    let run_size = 40;
543    if data.len() < runs_start + block_descriptors * run_size {
544        return Err(DmgError::BadMish("truncated run list".into()));
545    }
546
547    let mut runs = Vec::with_capacity(block_descriptors);
548    for i in 0..block_descriptors {
549        let o = runs_start + i * run_size;
550        let entry_type = u32::from_be_bytes(data[o..o + 4].try_into().unwrap());
551        let sector_start = u64::from_be_bytes(data[o + 8..o + 16].try_into().unwrap());
552        let sector_count = u64::from_be_bytes(data[o + 16..o + 24].try_into().unwrap());
553        let data_offset = u64::from_be_bytes(data[o + 24..o + 32].try_into().unwrap());
554        let data_length = u64::from_be_bytes(data[o + 32..o + 40].try_into().unwrap());
555        runs.push(BlkxRun {
556            entry_type,
557            sector_start,
558            sector_count,
559            data_offset,
560            data_length,
561        });
562        if entry_type == BLK_TERM {
563            break;
564        }
565    }
566
567    Ok(Partition {
568        file_data_offset,
569        sector_base: sector_number,
570        runs,
571    })
572}
573
574// ── Tests ─────────────────────────────────────────────────────────────────────
575
576#[cfg(test)]
577mod tests {
578    use super::*;
579    use std::io::Cursor;
580
581    // ── Synthetic DMG builder ─────────────────────────────────────────────────
582
583    /// One run entry for the test DMG builder.
584    struct RunDef {
585        entry_type: u32,
586        sector_start: u64,
587        sector_count: u64,
588        data: Vec<u8>, // raw or pre-compressed bytes; empty for zero/ignore
589    }
590
591    /// Build a minimal synthetic DMG in memory.
592    ///
593    /// Layout:
594    ///   [data bytes for all raw/compressed runs]
595    ///   [xml plist]
596    ///   [512-byte koly trailer]
597    #[allow(clippy::needless_pass_by_value)] // test helper; owned input is fine
598    fn make_dmg(sector_count: u64, runs: Vec<RunDef>) -> Vec<u8> {
599        let mut file: Vec<u8> = Vec::new();
600
601        // Phase 1: write all run data and track offsets.
602        let mish_data_offset = 0u64; // data fork starts at byte 0
603        let mut run_file_offsets: Vec<u64> = Vec::new();
604        for r in &runs {
605            run_file_offsets.push(file.len() as u64);
606            file.extend_from_slice(&r.data);
607        }
608
609        // Phase 2: build the mish block (binary, big-endian).
610        // Header is 204 bytes before the first run entry (see parse_mish layout comment).
611        let block_descriptors = runs.len() + 1; // +1 for BLK_TERM terminator
612        let total_data_written: u64 = run_file_offsets.last().map_or(0, |&off| {
613            let last = &runs[runs.len() - 1];
614            off + last.data.len() as u64
615        });
616        let mut mish: Vec<u8> = Vec::new();
617        mish.extend_from_slice(&MISH_MAGIC.to_be_bytes()); // 0-3
618        mish.extend_from_slice(&1u32.to_be_bytes()); // 4-7:  version
619        mish.extend_from_slice(&0u64.to_be_bytes()); // 8-15: sector_number
620        mish.extend_from_slice(&sector_count.to_be_bytes()); // 16-23: sector_count
621        mish.extend_from_slice(&mish_data_offset.to_be_bytes()); // 24-31: data_offset
622        mish.extend_from_slice(&0u32.to_be_bytes()); // 32-35: buffers_needed
623        mish.extend_from_slice(&[0u8; 28]); // 36-63: reserved
624                                            // Checksum at offset 64 (136 bytes: type + size + data[32 u32s])
625        mish.extend_from_slice(&2u32.to_be_bytes()); // 64-67: checksum.type (CRC32)
626        mish.extend_from_slice(&32u32.to_be_bytes()); // 68-71: checksum.size
627        mish.extend_from_slice(&[0u8; 128]); // 72-199: checksum.data (zeros)
628        mish.extend_from_slice(&(block_descriptors as u32).to_be_bytes()); // 200-203: count
629
630        // Runs at offset 204 (40 bytes each: type + reserved + sec_start + sec_count + d_off + d_len)
631        for (i, r) in runs.iter().enumerate() {
632            let data_off = run_file_offsets[i];
633            let data_len = r.data.len() as u64;
634            mish.extend_from_slice(&r.entry_type.to_be_bytes());
635            mish.extend_from_slice(&0u32.to_be_bytes()); // reserved
636            mish.extend_from_slice(&r.sector_start.to_be_bytes());
637            mish.extend_from_slice(&r.sector_count.to_be_bytes());
638            mish.extend_from_slice(&data_off.to_be_bytes());
639            mish.extend_from_slice(&data_len.to_be_bytes());
640        }
641        // Terminator run (BLK_TERM, 40 bytes)
642        mish.extend_from_slice(&BLK_TERM.to_be_bytes()); // type
643        mish.extend_from_slice(&0u32.to_be_bytes()); // reserved
644        mish.extend_from_slice(&sector_count.to_be_bytes()); // sector_start = end
645        mish.extend_from_slice(&0u64.to_be_bytes()); // sector_count = 0
646        mish.extend_from_slice(&total_data_written.to_be_bytes()); // data_offset
647        mish.extend_from_slice(&0u64.to_be_bytes()); // data_length = 0
648
649        // Phase 3: base64-encode the mish block.
650        let mish_b64 = base64::engine::general_purpose::STANDARD.encode(&mish);
651
652        // Phase 4: build the XML plist.
653        let xml = format!(
654            "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
655             <!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"\">\n\
656             <plist version=\"1.0\">\n\
657             <dict>\n  <key>resource-fork</key>\n  <dict>\n\
658             <key>blkx</key>\n<array>\n<dict>\n\
659             <key>Data</key><data>{mish_b64}</data>\n\
660             </dict>\n</array>\n  </dict>\n</dict>\n</plist>\n"
661        );
662
663        let xml_offset = file.len() as u64;
664        let xml_length = xml.len() as u64;
665        file.extend_from_slice(xml.as_bytes());
666
667        // Phase 5: build the 512-byte koly trailer.
668        let mut koly = [0u8; 512];
669        koly[0..4].copy_from_slice(&KOLY_MAGIC.to_be_bytes());
670        koly[4..8].copy_from_slice(&4u32.to_be_bytes()); // version
671        koly[8..12].copy_from_slice(&512u32.to_be_bytes()); // header_size
672        koly[216..224].copy_from_slice(&xml_offset.to_be_bytes());
673        koly[224..232].copy_from_slice(&xml_length.to_be_bytes());
674        koly[492..500].copy_from_slice(&sector_count.to_be_bytes());
675        file.extend_from_slice(&koly);
676        file
677    }
678
679    fn raw_run(sector_start: u64, data: Vec<u8>) -> RunDef {
680        assert!(data.len() % 512 == 0, "raw data must be sector-aligned");
681        RunDef {
682            entry_type: BLK_RAW,
683            sector_start,
684            sector_count: data.len() as u64 / 512,
685            data,
686        }
687    }
688
689    fn zero_run(sector_start: u64, sector_count: u64) -> RunDef {
690        RunDef {
691            entry_type: BLK_ZERO,
692            sector_start,
693            sector_count,
694            data: vec![],
695        }
696    }
697
698    fn zlib_run(sector_start: u64, uncompressed: &[u8]) -> RunDef {
699        use flate2::{write::ZlibEncoder, Compression};
700        use std::io::Write;
701        let mut enc = ZlibEncoder::new(Vec::new(), Compression::default());
702        enc.write_all(uncompressed).unwrap();
703        let compressed = enc.finish().unwrap();
704        RunDef {
705            entry_type: BLK_ZLIB,
706            sector_start,
707            sector_count: uncompressed.len() as u64 / 512,
708            data: compressed,
709        }
710    }
711
712    // ── Tests ─────────────────────────────────────────────────────────────────
713
714    #[test]
715    fn file_too_small_returns_err() {
716        let result = DmgReader::open(Cursor::new(b"tiny"));
717        assert!(matches!(result, Err(DmgError::FileTooSmall)));
718    }
719
720    #[test]
721    fn not_a_dmg_returns_err() {
722        // 512 bytes of zeros — no koly magic
723        let result = DmgReader::open(Cursor::new(vec![0u8; 512]));
724        assert!(matches!(result, Err(DmgError::NotADmg)));
725    }
726
727    #[test]
728    fn virtual_disk_size_is_512_times_sector_count() {
729        let payload = vec![0xBBu8; 512];
730        let dmg = make_dmg(1, vec![raw_run(0, payload)]);
731        let reader = DmgReader::open(Cursor::new(dmg)).expect("open");
732        assert_eq!(reader.virtual_disk_size(), 512);
733    }
734
735    #[test]
736    fn read_raw_block_returns_correct_bytes() {
737        let payload: Vec<u8> = (0u8..=255).cycle().take(512).collect();
738        let dmg = make_dmg(1, vec![raw_run(0, payload.clone())]);
739        let mut reader = DmgReader::open(Cursor::new(dmg)).expect("open");
740        let mut buf = vec![0u8; 512];
741        reader.read_exact(&mut buf).expect("read_exact");
742        assert_eq!(buf, payload);
743    }
744
745    #[test]
746    fn read_zeroed_block_returns_zeros() {
747        let dmg = make_dmg(2, vec![zero_run(0, 2)]);
748        let mut reader = DmgReader::open(Cursor::new(dmg)).expect("open");
749        let mut buf = vec![0xFFu8; 512];
750        reader.read_exact(&mut buf).expect("read_exact");
751        assert!(buf.iter().all(|&b| b == 0), "expected all zeros");
752    }
753
754    #[test]
755    fn seek_and_read_at_offset() {
756        let mut payload = vec![0u8; 512];
757        payload[100] = 0xAB;
758        payload[101] = 0xCD;
759        let dmg = make_dmg(1, vec![raw_run(0, payload)]);
760        let mut reader = DmgReader::open(Cursor::new(dmg)).expect("open");
761        reader.seek(SeekFrom::Start(100)).expect("seek");
762        let mut buf = [0u8; 2];
763        reader.read_exact(&mut buf).expect("read");
764        assert_eq!(buf, [0xAB, 0xCD]);
765    }
766
767    #[test]
768    fn read_across_run_boundary() {
769        let mut sector0 = vec![0xAAu8; 512];
770        sector0[511] = 0xBB;
771        let mut sector1 = vec![0xCCu8; 512];
772        sector1[0] = 0xDD;
773        let mut payload = sector0;
774        payload.extend_from_slice(&sector1);
775        let dmg = make_dmg(2, vec![raw_run(0, payload)]);
776        let mut reader = DmgReader::open(Cursor::new(dmg)).expect("open");
777        reader.seek(SeekFrom::Start(511)).expect("seek");
778        let mut buf = [0u8; 2];
779        reader.read_exact(&mut buf).expect("read");
780        // byte 511 = sector0[511] = 0xBB; byte 512 = sector1[0] = 0xDD
781        assert_eq!(buf, [0xBB, 0xDD]);
782    }
783
784    #[test]
785    fn zlib_block_decompressed_correctly() {
786        let uncompressed: Vec<u8> = (0u8..=255).cycle().take(512).collect();
787        let dmg = make_dmg(1, vec![zlib_run(0, &uncompressed)]);
788        let mut reader = DmgReader::open(Cursor::new(dmg)).expect("open");
789        let mut buf = vec![0u8; 512];
790        reader.read_exact(&mut buf).expect("read_exact");
791        assert_eq!(buf, uncompressed);
792    }
793
794    #[test]
795    fn multiple_partitions_both_readable() {
796        let p0 = vec![0xAAu8; 512];
797        let p1 = vec![0xBBu8; 512];
798        // Two separate runs at sector 0 and sector 1
799        let mut payload = p0.clone();
800        payload.extend_from_slice(&p1);
801        let dmg = make_dmg(2, vec![raw_run(0, payload)]);
802        let mut reader = DmgReader::open(Cursor::new(dmg)).expect("open");
803        let mut buf = [0u8; 512];
804        reader.read_exact(&mut buf).expect("read sector 0");
805        assert_eq!(&buf[..], &p0[..]);
806        reader.read_exact(&mut buf).expect("read sector 1");
807        assert_eq!(&buf[..], &p1[..]);
808    }
809}