Skip to main content

dmg/
sparse.rs

1//! Apple sparse-image readers: `.sparseimage` (single `sprs` file) and
2//! `.sparsebundle` (a bundle directory of band files).
3//!
4//! Both expose a virtual disk over a set of fixed-size *bands*. Unallocated
5//! bands (`.sparseimage` table entry 0 / a missing `.sparsebundle` band file)
6//! read back as zeros, so the reader materialises a flat image identical to
7//! `hdiutil convert … -format UDTO`.
8
9use std::collections::HashMap;
10use std::io::{self, Read, Seek, SeekFrom};
11use std::path::{Path, PathBuf};
12
13use quick_xml::events::Event;
14use quick_xml::Reader;
15
16use crate::DmgError;
17
18const SPRS_MAGIC: u32 = 0x7370_7273; // b"sprs"
19const SPARSE_HEADER_SIZE: u64 = 4096;
20const SECTOR: u64 = 512;
21/// Byte offset of the band table within the 4096-byte header.
22const BAND_TABLE_OFFSET: usize = 0x40;
23
24/// Bounds-checked big-endian `u32` read: yields 0 (never panics) when `off`
25/// is out of range, so a truncated/hostile header cannot crash the reader.
26fn be_u32(data: &[u8], off: usize) -> u32 {
27    let mut b = [0u8; 4];
28    // `get(off..).get(..4)` never overflows and yields None (→ 0) when the
29    // 4-byte window falls outside the header.
30    if let Some(s) = data.get(off..).and_then(|s| s.get(..4)) {
31        b.copy_from_slice(s);
32    }
33    u32::from_be_bytes(b)
34}
35
36/// Reader for a single-file `.sparseimage` (`sprs` magic).
37///
38/// The 4096-byte header carries a band table *indexed by physical slot* (0-based
39/// position of a stored band after the header); each entry is `virtual_band + 1`,
40/// or 0 for an unused slot. Inverting it gives virtual band → physical slot; a
41/// virtual band with no slot is an unallocated hole that reads back as zeros.
42/// (This inverse-map layout was confirmed byte-identical to `hdiutil`'s UDTO raw
43/// oracle — see `tests/sparse_images.rs`.)
44pub struct SparseImageReader<R: Read + Seek> {
45    inner: R,
46    band_size: u64,
47    virtual_size: u64,
48    /// Total file size, used to zero-fill reads into a truncated band.
49    file_size: u64,
50    /// Virtual band → physical slot (0-based position after the header).
51    band_slot: HashMap<u64, u64>,
52    position: u64,
53}
54
55impl<R: Read + Seek> SparseImageReader<R> {
56    /// Open a `.sparseimage`, parsing and validating the 4096-byte `sprs` header.
57    pub fn open(mut reader: R) -> Result<Self, DmgError> {
58        let file_size = reader.seek(SeekFrom::End(0))?;
59        if file_size < SPARSE_HEADER_SIZE {
60            return Err(DmgError::BadSparseHeader(format!(
61                "file too small for 4096-byte sparse header: {file_size} bytes"
62            )));
63        }
64        reader.seek(SeekFrom::Start(0))?;
65        let mut header = [0u8; SPARSE_HEADER_SIZE as usize];
66        reader.read_exact(&mut header)?;
67
68        let magic = be_u32(&header, 0x00);
69        if magic != SPRS_MAGIC {
70            return Err(DmgError::NotSparseImage(magic));
71        }
72
73        let sectors_per_band = u64::from(be_u32(&header, 0x08));
74        if sectors_per_band == 0 {
75            return Err(DmgError::BadSparseHeader(
76                "sectors_per_band is 0 (would divide by zero)".into(),
77            ));
78        }
79        let band_size = sectors_per_band.saturating_mul(SECTOR);
80        let total_sectors = u64::from(be_u32(&header, 0x10));
81        let virtual_size = total_sectors.saturating_mul(SECTOR);
82
83        // Physical band slots stored after the header. `ceil` so a truncated
84        // trailing band is still mapped; `band_size >= 512` rules out div-by-0.
85        let phys_region = file_size.saturating_sub(SPARSE_HEADER_SIZE);
86        let nphys = phys_region.div_ceil(band_size);
87
88        // The slot-indexed table lives inside the 4096-byte header at 0x40. More
89        // slots than fit there would need a multi-node table we do not support —
90        // reject loudly rather than silently drop bands. This is also the
91        // allocation-bomb guard (the map holds at most 1008 entries).
92        let max_slots = (SPARSE_HEADER_SIZE as usize - BAND_TABLE_OFFSET) / 4;
93        if nphys > max_slots as u64 {
94            return Err(DmgError::BadSparseHeader(format!(
95                "{nphys} physical bands exceed the single 4096-byte band table (max {max_slots}); multi-node tables unsupported"
96            )));
97        }
98
99        // Invert the slot → (virtual_band + 1) table into virtual band → slot.
100        let mut band_slot = HashMap::new();
101        for slot in 0..nphys {
102            let v = be_u32(&header, BAND_TABLE_OFFSET + slot as usize * 4);
103            if v != 0 {
104                band_slot.insert(u64::from(v) - 1, slot);
105            }
106        }
107
108        Ok(Self {
109            inner: reader,
110            band_size,
111            virtual_size,
112            file_size,
113            band_slot,
114            position: 0,
115        })
116    }
117
118    /// Total virtual disk size in bytes (`total_sectors × 512`).
119    pub fn virtual_disk_size(&self) -> u64 {
120        self.virtual_size
121    }
122}
123
124impl<R: Read + Seek> Read for SparseImageReader<R> {
125    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
126        if buf.is_empty() || self.position >= self.virtual_size {
127            return Ok(0);
128        }
129        let vband = self.position / self.band_size;
130        let off = self.position % self.band_size;
131        let band_remaining = self.band_size - off;
132        let disk_remaining = self.virtual_size - self.position;
133        let to_read = (buf.len() as u64).min(band_remaining).min(disk_remaining) as usize;
134
135        match self.band_slot.get(&vband) {
136            // No physical slot for this virtual band → unallocated hole.
137            None => buf[..to_read].fill(0),
138            Some(&slot) => {
139                // slot < 1008 and band_size <= u32::MAX*512, so the offset stays
140                // well within u64; saturate anyway to stay panic-free.
141                let file_off = SPARSE_HEADER_SIZE
142                    .saturating_add(slot.saturating_mul(self.band_size))
143                    .saturating_add(off);
144                if file_off >= self.file_size {
145                    buf[..to_read].fill(0);
146                } else {
147                    let avail = (self.file_size - file_off).min(to_read as u64) as usize;
148                    self.inner.seek(SeekFrom::Start(file_off))?;
149                    self.inner.read_exact(&mut buf[..avail])?;
150                    if avail < to_read {
151                        buf[avail..to_read].fill(0);
152                    }
153                }
154            }
155        }
156
157        self.position += to_read as u64;
158        Ok(to_read)
159    }
160}
161
162impl<R: Read + Seek> Seek for SparseImageReader<R> {
163    fn seek(&mut self, pos: SeekFrom) -> io::Result<u64> {
164        self.position = seek_within(self.position, self.virtual_size, pos);
165        Ok(self.position)
166    }
167}
168
169/// Saturating seek shared by both readers: a malformed offset clamps, never panics.
170fn seek_within(current: u64, size: u64, pos: SeekFrom) -> u64 {
171    match pos {
172        SeekFrom::Start(n) => n,
173        SeekFrom::End(n) => {
174            if n >= 0 {
175                size.saturating_add(n as u64)
176            } else {
177                size.saturating_sub(n.unsigned_abs())
178            }
179        }
180        SeekFrom::Current(n) => {
181            if n >= 0 {
182                current.saturating_add(n as u64)
183            } else {
184                current.saturating_sub(n.unsigned_abs())
185            }
186        }
187    }
188}
189
190const SPARSEBUNDLE_TYPE: &str = "com.apple.diskimage.sparsebundle";
191
192/// Reader for a `.sparsebundle` directory.
193///
194/// `Info.plist` gives the band size and total virtual size; the `bands/`
195/// directory holds band files named by lowercase-hex band index. A missing
196/// band file (or bytes past a short band file's end) reads back as zeros.
197pub struct SparseBundleReader {
198    bands_dir: PathBuf,
199    band_size: u64,
200    virtual_size: u64,
201    position: u64,
202}
203
204impl SparseBundleReader {
205    /// Open a `.sparsebundle` directory, parsing and validating `Info.plist`.
206    pub fn open(dir: &Path) -> Result<Self, DmgError> {
207        let info_path = dir.join("Info.plist");
208        let xml = match std::fs::read_to_string(&info_path) {
209            Ok(s) => s,
210            Err(e) if e.kind() == io::ErrorKind::NotFound => {
211                return Err(DmgError::MissingInfoPlist)
212            }
213            Err(e) => return Err(DmgError::Io(e)),
214        };
215
216        let info = parse_info_plist(&xml)?;
217
218        match info.bundle_type.as_deref() {
219            Some(SPARSEBUNDLE_TYPE) => {}
220            other => {
221                return Err(DmgError::BadInfoPlist(format!(
222                    "diskimage-bundle-type is {other:?}, expected {SPARSEBUNDLE_TYPE:?}"
223                )))
224            }
225        }
226
227        let band_size = info
228            .band_size
229            .ok_or_else(|| DmgError::BadInfoPlist("missing band-size key".into()))?;
230        if band_size == 0 {
231            return Err(DmgError::BadInfoPlist(
232                "band-size is 0 (would divide by zero)".into(),
233            ));
234        }
235        let virtual_size = info
236            .size
237            .ok_or_else(|| DmgError::BadInfoPlist("missing size key".into()))?;
238
239        Ok(Self {
240            bands_dir: dir.join("bands"),
241            band_size,
242            virtual_size,
243            position: 0,
244        })
245    }
246
247    /// Total virtual disk size in bytes (the plist `size` key).
248    pub fn virtual_disk_size(&self) -> u64 {
249        self.virtual_size
250    }
251}
252
253impl Read for SparseBundleReader {
254    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
255        if buf.is_empty() || self.position >= self.virtual_size {
256            return Ok(0);
257        }
258        let band = self.position / self.band_size;
259        let off = self.position % self.band_size;
260        let band_remaining = self.band_size - off;
261        let disk_remaining = self.virtual_size - self.position;
262        let to_read = (buf.len() as u64).min(band_remaining).min(disk_remaining) as usize;
263
264        let path = self.bands_dir.join(format!("{band:x}"));
265        match std::fs::File::open(&path) {
266            Ok(mut f) => {
267                let flen = f.seek(SeekFrom::End(0))?;
268                if off >= flen {
269                    buf[..to_read].fill(0);
270                } else {
271                    let avail = (flen - off).min(to_read as u64) as usize;
272                    f.seek(SeekFrom::Start(off))?;
273                    f.read_exact(&mut buf[..avail])?;
274                    if avail < to_read {
275                        buf[avail..to_read].fill(0);
276                    }
277                }
278            }
279            Err(e) if e.kind() == io::ErrorKind::NotFound => buf[..to_read].fill(0),
280            Err(e) => return Err(e),
281        }
282
283        self.position += to_read as u64;
284        Ok(to_read)
285    }
286}
287
288impl Seek for SparseBundleReader {
289    fn seek(&mut self, pos: SeekFrom) -> io::Result<u64> {
290        self.position = seek_within(self.position, self.virtual_size, pos);
291        Ok(self.position)
292    }
293}
294
295/// The three `Info.plist` fields the reader needs.
296struct BundleInfo {
297    band_size: Option<u64>,
298    size: Option<u64>,
299    bundle_type: Option<String>,
300}
301
302/// Parse the `.sparsebundle` `Info.plist`, extracting `band-size`, `size`, and
303/// `diskimage-bundle-type`. Any non-integer value under a size key is a loud error.
304fn parse_info_plist(xml: &str) -> Result<BundleInfo, DmgError> {
305    let mut reader = Reader::from_str(xml);
306    reader.config_mut().trim_text(true);
307
308    let mut band_size = None;
309    let mut size = None;
310    let mut bundle_type = None;
311    let mut current_key: Option<String> = None;
312    let mut elem: Option<Vec<u8>> = None;
313
314    loop {
315        match reader.read_event() {
316            Ok(Event::Start(e)) => elem = Some(e.name().as_ref().to_vec()),
317            Ok(Event::Text(e)) => {
318                let text = e
319                    .xml_content(quick_xml::XmlVersion::Implicit1_0)
320                    .unwrap_or_default();
321                let t = text.trim();
322                if t.is_empty() {
323                    continue;
324                }
325                match elem.as_deref() {
326                    Some(b"key") => current_key = Some(t.to_string()),
327                    Some(b"integer") => {
328                        let key = current_key.as_deref();
329                        if key == Some("band-size") || key == Some("size") {
330                            let v: u64 = t.parse().map_err(|_| {
331                                DmgError::BadInfoPlist(format!(
332                                    "non-integer value {t:?} for key {key:?}"
333                                ))
334                            })?;
335                            if key == Some("band-size") {
336                                band_size = Some(v);
337                            } else {
338                                size = Some(v);
339                            }
340                        }
341                    }
342                    Some(b"string") if current_key.as_deref() == Some("diskimage-bundle-type") => {
343                        bundle_type = Some(t.to_string());
344                    }
345                    _ => {}
346                }
347            }
348            Ok(Event::End(_)) => elem = None,
349            Ok(Event::Eof) => break,
350            Err(e) => return Err(DmgError::BadInfoPlist(e.to_string())),
351            _ => {}
352        }
353    }
354
355    Ok(BundleInfo {
356        band_size,
357        size,
358        bundle_type,
359    })
360}
361
362#[cfg(test)]
363mod sparseimage_tests {
364    use super::SparseImageReader;
365    use crate::DmgError;
366    use std::io::{Cursor, Read, Seek, SeekFrom};
367
368    const HDR: usize = 4096;
369
370    /// Build a synthetic `.sparseimage` in memory.
371    ///
372    /// `spb` = sectors per band. `bands[v]` is virtual band `v`'s content
373    /// (`None` = unallocated hole). Allocated bands are stored in ascending
374    /// virtual order after the 4096-byte header; the band table at 0x40 is
375    /// indexed by physical slot and holds `virtual_band + 1` for each slot
376    /// (0 = unused) — the layout `hdiutil` writes, confirmed against its UDTO
377    /// raw oracle in `tests/sparse_images.rs`.
378    fn build(spb: u32, bands: &[Option<Vec<u8>>]) -> Vec<u8> {
379        let band_size = spb as usize * 512;
380        let total_sectors = (bands.len() * spb as usize) as u32;
381        let mut file = vec![0u8; HDR];
382        file[0..4].copy_from_slice(&0x7370_7273u32.to_be_bytes()); // "sprs"
383        file[4..8].copy_from_slice(&3u32.to_be_bytes()); // version
384        file[8..12].copy_from_slice(&spb.to_be_bytes()); // sectors_per_band
385        file[16..20].copy_from_slice(&total_sectors.to_be_bytes()); // total_sectors
386        let mut slot = 0usize;
387        for (v, b) in bands.iter().enumerate() {
388            if let Some(content) = b {
389                assert_eq!(content.len(), band_size, "band must be band_size");
390                let o = 0x40 + slot * 4;
391                file[o..o + 4].copy_from_slice(&((v as u32) + 1).to_be_bytes());
392                file.extend_from_slice(content);
393                slot += 1;
394            }
395        }
396        file
397    }
398
399    /// `band_size` = 2×512 = 1024; 3 virtual bands; virtual band 1 is a hole.
400    /// v0 starts with the HFS+ `H+` magic, v2 is 0xCC filled.
401    fn sample() -> Vec<u8> {
402        let mut b0 = vec![0xAAu8; 1024];
403        b0[0..4].copy_from_slice(&[0x48, 0x2b, 0x00, 0x04]);
404        let b2 = vec![0xCCu8; 1024];
405        build(2, &[Some(b0), None, Some(b2)])
406    }
407
408    #[test]
409    fn bad_magic_is_not_sparse_image() {
410        let mut f = sample();
411        f[0] = 0;
412        assert!(matches!(
413            SparseImageReader::open(Cursor::new(f)),
414            Err(DmgError::NotSparseImage(_))
415        ));
416    }
417
418    #[test]
419    fn zero_sectors_per_band_is_bad_header() {
420        let mut f = sample();
421        f[8..12].copy_from_slice(&0u32.to_be_bytes());
422        assert!(matches!(
423            SparseImageReader::open(Cursor::new(f)),
424            Err(DmgError::BadSparseHeader(_))
425        ));
426    }
427
428    #[test]
429    fn file_too_small_is_bad_header() {
430        assert!(matches!(
431            SparseImageReader::open(Cursor::new(vec![0u8; 100])),
432            Err(DmgError::BadSparseHeader(_))
433        ));
434    }
435
436    #[test]
437    fn too_many_physical_bands_is_bad_header() {
438        // band_size=512 (spb=1); 1009 physical bands overrun the (4096-64)/4 =
439        // 1008-entry single-header band table → loud error, not silent truncation.
440        let mut f = vec![0u8; HDR + 1009 * 512];
441        f[0..4].copy_from_slice(&0x7370_7273u32.to_be_bytes());
442        f[8..12].copy_from_slice(&1u32.to_be_bytes()); // sectors_per_band = 1
443        f[16..20].copy_from_slice(&1009u32.to_be_bytes());
444        assert!(matches!(
445            SparseImageReader::open(Cursor::new(f)),
446            Err(DmgError::BadSparseHeader(_))
447        ));
448    }
449
450    #[test]
451    fn virtual_disk_size_is_total_sectors_times_512() {
452        let r = SparseImageReader::open(Cursor::new(sample())).unwrap();
453        assert_eq!(r.virtual_disk_size(), 6 * 512);
454    }
455
456    #[test]
457    fn reads_allocated_band_magic() {
458        let mut r = SparseImageReader::open(Cursor::new(sample())).unwrap();
459        let mut buf = [0u8; 4];
460        r.read_exact(&mut buf).unwrap();
461        assert_eq!(buf, [0x48, 0x2b, 0x00, 0x04]);
462    }
463
464    #[test]
465    fn hole_band_reads_zeros() {
466        let mut r = SparseImageReader::open(Cursor::new(sample())).unwrap();
467        r.seek(SeekFrom::Start(1024)).unwrap();
468        let mut buf = [0xFFu8; 1024];
469        r.read_exact(&mut buf).unwrap();
470        assert!(buf.iter().all(|&b| b == 0));
471    }
472
473    #[test]
474    fn second_allocated_band_reads_content() {
475        let mut r = SparseImageReader::open(Cursor::new(sample())).unwrap();
476        r.seek(SeekFrom::Start(2048)).unwrap();
477        let mut buf = [0u8; 4];
478        r.read_exact(&mut buf).unwrap();
479        assert_eq!(buf, [0xCC, 0xCC, 0xCC, 0xCC]);
480    }
481
482    #[test]
483    fn read_across_band_boundary() {
484        let mut r = SparseImageReader::open(Cursor::new(sample())).unwrap();
485        r.seek(SeekFrom::Start(1023)).unwrap();
486        let mut buf = [0xEEu8; 2];
487        r.read_exact(&mut buf).unwrap();
488        assert_eq!(buf, [0xAA, 0x00]);
489    }
490
491    #[test]
492    fn seek_within_band_reads_offset() {
493        let mut r = SparseImageReader::open(Cursor::new(sample())).unwrap();
494        r.seek(SeekFrom::Start(10)).unwrap();
495        let mut buf = [0u8; 1];
496        r.read_exact(&mut buf).unwrap();
497        assert_eq!(buf[0], 0xAA);
498    }
499
500    #[test]
501    fn seek_from_end_and_current() {
502        let mut r = SparseImageReader::open(Cursor::new(sample())).unwrap();
503        assert_eq!(r.seek(SeekFrom::End(0)).unwrap(), 6 * 512);
504        assert_eq!(r.seek(SeekFrom::End(-2048)).unwrap(), 1024);
505        assert_eq!(r.seek(SeekFrom::Current(1024)).unwrap(), 2048);
506        assert_eq!(r.seek(SeekFrom::Current(-2048)).unwrap(), 0);
507    }
508
509    #[test]
510    fn read_past_eof_returns_zero() {
511        let mut r = SparseImageReader::open(Cursor::new(sample())).unwrap();
512        r.seek(SeekFrom::Start(6 * 512)).unwrap();
513        let mut buf = [0u8; 16];
514        assert_eq!(r.read(&mut buf).unwrap(), 0);
515    }
516
517    #[test]
518    fn empty_buffer_reads_zero() {
519        let mut r = SparseImageReader::open(Cursor::new(sample())).unwrap();
520        assert_eq!(r.read(&mut []).unwrap(), 0);
521    }
522
523    #[test]
524    fn unmapped_virtual_band_reads_zeros() {
525        // Only virtual band 0 is allocated; bands 1 and 2 have no slot → zeros.
526        let f = build(2, &[Some(vec![0xAAu8; 1024]), None, None]);
527        let mut r = SparseImageReader::open(Cursor::new(f)).unwrap();
528        r.seek(SeekFrom::Start(2048)).unwrap();
529        let mut buf = [0xFFu8; 512];
530        r.read_exact(&mut buf).unwrap();
531        assert!(buf.iter().all(|&b| b == 0));
532    }
533
534    #[test]
535    fn truncated_physical_band_tail_reads_zeros() {
536        // One allocated band, but the file is truncated mid-band: the present
537        // half reads real bytes, the missing tail reads zeros.
538        let mut f = build(2, &[Some(vec![0xAAu8; 1024])]);
539        f.truncate(HDR + 512);
540        let mut r = SparseImageReader::open(Cursor::new(f)).unwrap();
541        let mut buf = [0u8; 1024];
542        r.read_exact(&mut buf).unwrap();
543        assert!(buf[..512].iter().all(|&b| b == 0xAA));
544        assert!(buf[512..].iter().all(|&b| b == 0));
545    }
546
547    #[test]
548    fn read_starting_in_truncated_region_reads_zeros() {
549        // Seek past the present bytes of a truncated band: the band's file offset
550        // is wholly past EOF → zeros (never a short-read panic).
551        let mut f = build(2, &[Some(vec![0xAAu8; 1024])]);
552        f.truncate(HDR + 512);
553        let mut r = SparseImageReader::open(Cursor::new(f)).unwrap();
554        r.seek(SeekFrom::Start(600)).unwrap();
555        let mut buf = [0xFFu8; 8];
556        r.read_exact(&mut buf).unwrap();
557        assert!(buf.iter().all(|&b| b == 0));
558    }
559}
560
561#[cfg(test)]
562mod sparsebundle_tests {
563    use super::SparseBundleReader;
564    use crate::DmgError;
565    use std::fs;
566    use std::io::{Read, Seek, SeekFrom, Write};
567    use tempfile::TempDir;
568
569    /// Assemble a `<name>.sparsebundle` directory with the given Info.plist body
570    /// and band files. `bands` are `(index, bytes)`; omitted indices are holes.
571    fn bundle(plist: &str, bands: &[(u64, Vec<u8>)]) -> TempDir {
572        let dir = TempDir::new().unwrap();
573        fs::write(dir.path().join("Info.plist"), plist).unwrap();
574        let bands_dir = dir.path().join("bands");
575        fs::create_dir(&bands_dir).unwrap();
576        for (idx, bytes) in bands {
577            let mut f = fs::File::create(bands_dir.join(format!("{idx:x}"))).unwrap();
578            f.write_all(bytes).unwrap();
579        }
580        dir
581    }
582
583    fn plist(band_size: Option<u64>, size: Option<u64>, bundle_type: Option<&str>) -> String {
584        use std::fmt::Write as _;
585        let mut body = String::from(
586            "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<plist version=\"1.0\">\n<dict>\n",
587        );
588        if let Some(t) = bundle_type {
589            let _ = writeln!(
590                body,
591                "  <key>diskimage-bundle-type</key><string>{t}</string>"
592            );
593        }
594        if let Some(b) = band_size {
595            let _ = writeln!(body, "  <key>band-size</key><integer>{b}</integer>");
596        }
597        if let Some(s) = size {
598            let _ = writeln!(body, "  <key>size</key><integer>{s}</integer>");
599        }
600        body.push_str("</dict>\n</plist>\n");
601        body
602    }
603
604    /// A realistic Info.plist, including keys the reader ignores (a non-target
605    /// `<string>` and `<integer>`) plus an escaped whitespace-only value that is
606    /// skipped — mirrors what `hdiutil` writes.
607    fn valid_plist() -> String {
608        "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<plist version=\"1.0\">\n<dict>\n\
609         \t<key>CFBundleInfoDictionaryVersion</key><string>6.0</string>\n\
610         \t<key>band-size</key><integer>1024</integer>\n\
611         \t<key>bundle-backingstore-version</key><integer>1</integer>\n\
612         \t<key>diskimage-bundle-type</key><string>com.apple.diskimage.sparsebundle</string>\n\
613         \t<key>pad</key><string>&#32;</string>\n\
614         \t<key>size</key><integer>3072</integer>\n\
615         </dict>\n</plist>\n"
616            .to_string()
617    }
618
619    /// band-size 1024, size 3072 (3 bands): band 0 present (0xAA, `H+` magic),
620    /// band 1 a hole (missing file), band 2 present but half-length (0xCC).
621    fn valid_bundle() -> TempDir {
622        let mut b0 = vec![0xAAu8; 1024];
623        b0[0..4].copy_from_slice(&[0x48, 0x2b, 0x00, 0x04]);
624        let b2 = vec![0xCCu8; 512];
625        bundle(&valid_plist(), &[(0, b0), (2, b2)])
626    }
627
628    #[test]
629    fn missing_info_plist_errors() {
630        let dir = TempDir::new().unwrap();
631        assert!(matches!(
632            SparseBundleReader::open(dir.path()),
633            Err(DmgError::MissingInfoPlist)
634        ));
635    }
636
637    #[test]
638    fn malformed_plist_errors() {
639        let dir = bundle("<plist><dict><key>oops", &[]);
640        assert!(matches!(
641            SparseBundleReader::open(dir.path()),
642            Err(DmgError::BadInfoPlist(_))
643        ));
644    }
645
646    #[test]
647    fn wrong_bundle_type_errors() {
648        let dir = bundle(
649            &plist(Some(1024), Some(3072), Some("com.apple.diskimage.sparse")),
650            &[],
651        );
652        assert!(matches!(
653            SparseBundleReader::open(dir.path()),
654            Err(DmgError::BadInfoPlist(_))
655        ));
656    }
657
658    #[test]
659    fn missing_band_size_errors() {
660        let dir = bundle(
661            &plist(None, Some(3072), Some("com.apple.diskimage.sparsebundle")),
662            &[],
663        );
664        assert!(matches!(
665            SparseBundleReader::open(dir.path()),
666            Err(DmgError::BadInfoPlist(_))
667        ));
668    }
669
670    #[test]
671    fn zero_band_size_errors() {
672        let dir = bundle(
673            &plist(
674                Some(0),
675                Some(3072),
676                Some("com.apple.diskimage.sparsebundle"),
677            ),
678            &[],
679        );
680        assert!(matches!(
681            SparseBundleReader::open(dir.path()),
682            Err(DmgError::BadInfoPlist(_))
683        ));
684    }
685
686    #[test]
687    fn missing_size_errors() {
688        let dir = bundle(
689            &plist(Some(1024), None, Some("com.apple.diskimage.sparsebundle")),
690            &[],
691        );
692        assert!(matches!(
693            SparseBundleReader::open(dir.path()),
694            Err(DmgError::BadInfoPlist(_))
695        ));
696    }
697
698    #[test]
699    fn non_integer_band_size_errors() {
700        let xml = "<plist version=\"1.0\">\n<dict>\n\
701                   <key>diskimage-bundle-type</key><string>com.apple.diskimage.sparsebundle</string>\n\
702                   <key>band-size</key><integer>notanumber</integer>\n\
703                   <key>size</key><integer>3072</integer>\n</dict>\n</plist>\n";
704        let dir = bundle(xml, &[]);
705        assert!(matches!(
706            SparseBundleReader::open(dir.path()),
707            Err(DmgError::BadInfoPlist(_))
708        ));
709    }
710
711    #[test]
712    fn virtual_disk_size_matches_plist_size() {
713        let dir = valid_bundle();
714        let r = SparseBundleReader::open(dir.path()).unwrap();
715        assert_eq!(r.virtual_disk_size(), 3072);
716    }
717
718    #[test]
719    fn reads_present_band_magic() {
720        let dir = valid_bundle();
721        let mut r = SparseBundleReader::open(dir.path()).unwrap();
722        let mut buf = [0u8; 4];
723        r.read_exact(&mut buf).unwrap();
724        assert_eq!(buf, [0x48, 0x2b, 0x00, 0x04]);
725    }
726
727    #[test]
728    fn missing_band_file_reads_zeros() {
729        let dir = valid_bundle();
730        let mut r = SparseBundleReader::open(dir.path()).unwrap();
731        r.seek(SeekFrom::Start(1024)).unwrap(); // band 1 = hole
732        let mut buf = [0xFFu8; 1024];
733        r.read_exact(&mut buf).unwrap();
734        assert!(buf.iter().all(|&b| b == 0));
735    }
736
737    #[test]
738    fn partial_band_tail_reads_zeros() {
739        let dir = valid_bundle();
740        let mut r = SparseBundleReader::open(dir.path()).unwrap();
741        r.seek(SeekFrom::Start(2048)).unwrap(); // band 2 = 512 real bytes + 512 zeros
742        let mut buf = [0u8; 1024];
743        r.read_exact(&mut buf).unwrap();
744        assert!(buf[..512].iter().all(|&b| b == 0xCC));
745        assert!(buf[512..].iter().all(|&b| b == 0));
746    }
747
748    #[test]
749    fn read_across_band_boundary() {
750        let dir = valid_bundle();
751        let mut r = SparseBundleReader::open(dir.path()).unwrap();
752        r.seek(SeekFrom::Start(1023)).unwrap();
753        let mut buf = [0xEEu8; 2];
754        r.read_exact(&mut buf).unwrap(); // band0 tail 0xAA, then band1 hole 0x00
755        assert_eq!(buf, [0xAA, 0x00]);
756    }
757
758    #[test]
759    fn seek_within_band_reads_offset() {
760        let dir = valid_bundle();
761        let mut r = SparseBundleReader::open(dir.path()).unwrap();
762        r.seek(SeekFrom::Start(500)).unwrap();
763        let mut buf = [0u8; 1];
764        r.read_exact(&mut buf).unwrap();
765        assert_eq!(buf[0], 0xAA);
766    }
767
768    #[test]
769    fn seek_from_end_and_current() {
770        let dir = valid_bundle();
771        let mut r = SparseBundleReader::open(dir.path()).unwrap();
772        assert_eq!(r.seek(SeekFrom::End(0)).unwrap(), 3072);
773        assert_eq!(r.seek(SeekFrom::End(-1024)).unwrap(), 2048);
774        assert_eq!(r.seek(SeekFrom::Current(-2048)).unwrap(), 0);
775    }
776
777    #[test]
778    fn read_past_eof_returns_zero() {
779        let dir = valid_bundle();
780        let mut r = SparseBundleReader::open(dir.path()).unwrap();
781        r.seek(SeekFrom::Start(3072)).unwrap();
782        let mut buf = [0u8; 16];
783        assert_eq!(r.read(&mut buf).unwrap(), 0);
784    }
785
786    #[test]
787    fn empty_buffer_reads_zero() {
788        let dir = valid_bundle();
789        let mut r = SparseBundleReader::open(dir.path()).unwrap();
790        assert_eq!(r.read(&mut []).unwrap(), 0);
791    }
792
793    #[test]
794    fn read_wholly_past_short_band_reads_zeros() {
795        // band 2 is 512 bytes; read starting at offset 512 within it (virtual
796        // 2560) is entirely past the file's end → zeros.
797        let dir = valid_bundle();
798        let mut r = SparseBundleReader::open(dir.path()).unwrap();
799        r.seek(SeekFrom::Start(2560)).unwrap();
800        let mut buf = [0xFFu8; 512];
801        r.read_exact(&mut buf).unwrap();
802        assert!(buf.iter().all(|&b| b == 0));
803    }
804
805    #[test]
806    fn bands_path_that_is_a_file_errors_on_read() {
807        // `bands` is a regular file, so opening `bands/0` fails with a
808        // not-NotFound error (ENOTDIR) — surfaced loudly, not swallowed to zeros.
809        let dir = TempDir::new().unwrap();
810        fs::write(dir.path().join("Info.plist"), valid_plist()).unwrap();
811        fs::write(dir.path().join("bands"), b"not a directory").unwrap();
812        let mut r = SparseBundleReader::open(dir.path()).unwrap();
813        let mut buf = [0u8; 16];
814        assert!(r.read(&mut buf).is_err());
815    }
816
817    #[test]
818    fn info_plist_that_is_a_directory_is_io_error() {
819        // Info.plist exists but is a directory → a read error that is not
820        // NotFound → surfaced as DmgError::Io, distinct from MissingInfoPlist.
821        let dir = TempDir::new().unwrap();
822        fs::create_dir(dir.path().join("Info.plist")).unwrap();
823        assert!(matches!(
824            SparseBundleReader::open(dir.path()),
825            Err(DmgError::Io(_))
826        ));
827    }
828
829    #[test]
830    fn mismatched_xml_tags_error() {
831        // quick-xml's end-name check rejects the mismatched close tag.
832        let dir = bundle("<plist version=\"1.0\">\n<dict></nope>\n</plist>\n", &[]);
833        assert!(matches!(
834            SparseBundleReader::open(dir.path()),
835            Err(DmgError::BadInfoPlist(_))
836        ));
837    }
838}