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