Skip to main content

vmdk/
lib.rs

1//! Pure-Rust read-only VMDK disk image reader.
2//!
3//! Supports monolithic sparse (`monolithicSparse`), stream-optimised
4//! (`streamOptimized`, including allocated compressed grains), flat-extent
5//! VMDKs (`twoGbMaxExtentFlat`, `monolithicFlat`), and multi-file sparse
6//! extents (`twoGbMaxExtentSparse`).
7
8use std::collections::HashMap;
9use std::fs::File;
10use std::io::{self, BufReader, Read, Seek, SeekFrom};
11use std::path::Path;
12
13mod bytes;
14mod chain;
15mod cowd;
16mod ddb;
17mod descriptor;
18mod diag;
19pub(crate) mod error;
20mod flat;
21pub mod header;
22mod read;
23mod recovery;
24pub mod sesparse;
25mod sparse_multi;
26
27pub use chain::VmdkChainReader;
28pub use ddb::{DiskDatabase, DiskGeometry};
29
30pub use error::VmdkError;
31
32use descriptor::parse_text_descriptor;
33use flat::MultiExtentReader;
34use header::{SparseExtentHeader, GD_AT_END, SECTOR_SIZE};
35use sparse_multi::MultiSparseReader;
36
37// ── Public API types ──────────────────────────────────────────────────────────
38
39/// Object-safe combination of [`Read`] and [`Seek`].
40///
41/// Automatically implemented for all `T: Read + Seek`.  Used as the inner
42/// reader type for [`VmdkFileReader`].
43pub trait ReadSeek: Read + Seek {}
44impl<T: Read + Seek> ReadSeek for T {}
45
46/// A VMDK reader opened from a file-system path, with an erased inner type.
47///
48/// Returned by [`VmdkReader::open_path`]; supports all formats including
49/// multi-file flat extents that cannot be opened from a single stream.
50pub type VmdkFileReader = VmdkReader<Box<dyn ReadSeek + Send>>;
51
52/// SHA-256 and MD5 hash of the full virtual disk contents.
53///
54/// Produced by [`VmdkReader::hash`]. Both digests are computed in a single
55/// sequential pass over the virtual disk.
56#[derive(Debug, Clone, PartialEq, Eq)]
57#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
58pub struct VmdkDigest {
59    /// SHA-256 digest (32 bytes), hex-encoded.
60    pub sha256: String,
61    /// MD5 digest (16 bytes), hex-encoded.
62    pub md5: String,
63}
64
65/// A contiguous range of allocated (non-sparse) sectors in a VMDK virtual disk.
66///
67/// Returned by [`VmdkReader::iter_allocated_grains`].
68#[derive(Debug, Clone, PartialEq, Eq)]
69#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
70pub struct AllocatedGrain {
71    /// First LBA (512-byte sector number) of this allocated range.
72    pub start_lba: u64,
73    /// Number of sectors in this range (always a multiple of `grain_size_sectors`).
74    pub sector_count: u64,
75}
76
77/// Structured metadata for a VMDK virtual disk.
78///
79/// Returned by [`VmdkReader::info`].  All fields are `Clone`-able so callers
80/// can store or serialise the snapshot independently of the reader.
81#[derive(Debug, Clone)]
82#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
83pub struct VmdkInfo {
84    /// `createType` from the embedded descriptor (e.g. `"monolithicSparse"`).
85    pub disk_type: String,
86    /// Header format version: 1 for `monolithicSparse`; 3 for `streamOptimized`; 0 for flat.
87    pub version: u32,
88    /// Content ID (CID) from the descriptor, or `0xffff_ffff` if absent.
89    pub cid: u32,
90    /// Parent content ID; `0xffff_ffff` means no parent (not a delta/snapshot).
91    pub parent_cid: u32,
92    /// Grain size in sectors (0 for flat/raw extents).
93    pub grain_size_sectors: u64,
94    /// Grain size in bytes (0 for flat/raw extents).
95    pub grain_size_bytes: u64,
96    /// Total virtual disk size in bytes.
97    pub virtual_disk_size: u64,
98    /// Total virtual disk size in 512-byte sectors.
99    pub sector_count: u64,
100    /// `true` for `streamOptimized` VMDKs whose allocated grains are zlib-compressed.
101    pub compressed: bool,
102    /// Raw embedded descriptor text; empty when no embedded descriptor is present.
103    pub descriptor_text: String,
104    /// Parsed `ddb.*` disk database (geometry, adapter type, versions, UUID, …).
105    pub disk_database: DiskDatabase,
106}
107
108// ── Internal format dispatch ──────────────────────────────────────────────────
109
110pub(crate) enum FormatState {
111    Sparse {
112        grain_dir: Vec<u32>,
113        grain_size_bytes: u64,
114        num_gtes_per_gt: u64,
115        /// `true` for stream-optimised VMDKs: allocated grains carry a zlib-wrapped payload.
116        compressed: bool,
117    },
118    /// seSparse (vSphere 6.5+, VMFS6): nibble-typed, bit-rotated 8-byte grain entries.
119    SeSparse {
120        /// Raw L1 (grain directory) entries — high nibble 0x1 = allocated, low 32 bits = GT index.
121        grain_dir: Vec<u64>,
122        grain_size_bytes: u64,
123        /// First sector of the grain-table region (`grain_tables_offset`).
124        gt_offset_sectors: u64,
125        /// First sector of the grain-data region (`grains_offset`).
126        grains_offset_sectors: u64,
127    },
128    /// Raw flat extents — reads pass through directly to the inner reader.
129    Flat,
130}
131
132// ── VmdkReader ────────────────────────────────────────────────────────────────
133
134/// Read-only VMDK container reader, generic over any `Read + Seek` source.
135///
136/// Implements `Read + Seek` over the virtual sector stream.
137///
138/// # Examples
139///
140/// ```no_run
141/// use std::fs::File;
142/// use vmdk::VmdkReader;
143///
144/// let file = File::open("disk.vmdk").unwrap();
145/// let mut reader = VmdkReader::open(file).unwrap();
146/// println!("virtual disk size: {} bytes", reader.virtual_disk_size());
147/// ```
148pub struct VmdkReader<R: Read + Seek> {
149    pub(crate) inner: R,
150    pub(crate) fmt: FormatState,
151    pub(crate) virtual_disk_size: u64,
152    disk_type: Box<str>,
153    pub(crate) pos: u64,
154    version: u32,
155    cid: u32,
156    parent_cid: u32,
157    descriptor_text: Box<str>,
158    /// RGD (redundant grain directory) sector offset; 0 when absent.
159    pub(crate) rgd_offset: u64,
160    /// Number of GD entries — stored for RGD validation without re-deriving.
161    pub(crate) gd_entry_count: usize,
162    /// Cache of grain tables: maps GT sector number → Vec of GTE values.
163    /// Avoids redundant seeks for repeated grain reads within the same GT.
164    pub(crate) gt_cache: HashMap<u32, Vec<u32>>,
165    /// When `true`, a read whose primary grain-table pointer is unusable (out of
166    /// bounds) falls back to the redundant grain directory. Opt-in recovery mode.
167    pub(crate) rgd_fallback: bool,
168    /// Count of grains resolved via the redundant grain directory in this reader's
169    /// lifetime (pointer- or entry-level recovery). Read with `rgd_recovery_count()`.
170    pub(crate) rgd_recovery_count: u64,
171}
172
173/// Maximum bytes read from an embedded descriptor (guards against crafted images).
174const MAX_DESCRIPTOR_BYTES: u64 = 64 * 1024;
175
176/// Read the embedded text descriptor from a binary VMDK and parse it.
177///
178/// Returns a `TextDescriptor` with all metadata fields populated.
179/// When no embedded descriptor is present (`descriptor_offset=0` or `descriptor_size=0`),
180/// returns a descriptor with empty `create_type` and sentinel values for CID fields.
181fn read_descriptor<R: Read + Seek>(
182    reader: &mut R,
183    hdr: &SparseExtentHeader,
184) -> io::Result<descriptor::TextDescriptor> {
185    if hdr.descriptor_offset == 0 || hdr.descriptor_size == 0 {
186        return descriptor::parse_text_descriptor("")
187            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()));
188    }
189    let byte_offset = hdr
190        .descriptor_offset
191        .checked_mul(SECTOR_SIZE)
192        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "descriptor_offset overflow"))?;
193    let byte_len = hdr
194        .descriptor_size
195        .checked_mul(SECTOR_SIZE)
196        .unwrap_or(MAX_DESCRIPTOR_BYTES)
197        .min(MAX_DESCRIPTOR_BYTES);
198    reader.seek(SeekFrom::Start(byte_offset))?;
199    let mut buf = vec![0u8; byte_len as usize];
200    reader.read_exact(&mut buf)?;
201
202    let text = descriptor::decode_descriptor(&buf);
203    descriptor::parse_text_descriptor(&text)
204        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))
205}
206
207impl<R: Read + Seek> VmdkReader<R> {
208    /// Open a binary VMDK (monolithic sparse or stream-optimised) from any
209    /// `Read + Seek` source.
210    ///
211    /// For multi-file flat VMDKs (text descriptor + extent files) use
212    /// [`VmdkReader::open_path`] instead.
213    pub fn open(mut reader: R) -> Result<Self, VmdkError> {
214        let mut hdr_bytes = [0u8; 512];
215        reader.read_exact(&mut hdr_bytes)?;
216
217        // Detect COWD magic ("COWD", big-endian) before attempting VMDK4 parse.
218        let magic_be = u32::from_be_bytes(hdr_bytes[0..4].try_into().expect("4 bytes"));
219        if magic_be == cowd::COWD_MAGIC {
220            return Self::open_cowd(reader, &hdr_bytes);
221        }
222        // Detect seSparse magic (0x0000_0000_CAFE_BABE, u64 little-endian at offset 0).
223        if hdr_bytes.len() >= 8 {
224            let se_magic = u64::from_le_bytes(hdr_bytes[0..8].try_into().expect("8 bytes"));
225            if se_magic == sesparse::SE_CONST_MAGIC {
226                return Self::open_sesparse(reader, &hdr_bytes);
227            }
228        }
229
230        let hdr = SparseExtentHeader::parse(&hdr_bytes)?;
231
232        let grain_size_bytes =
233            hdr.grain_size
234                .checked_mul(SECTOR_SIZE)
235                .ok_or(VmdkError::GeometryOverflow {
236                    field: "grain_size",
237                })?;
238        let virtual_disk_size = hdr
239            .capacity
240            .checked_mul(SECTOR_SIZE)
241            .ok_or(VmdkError::GeometryOverflow { field: "capacity" })?;
242
243        let desc = read_descriptor(&mut reader, &hdr)?;
244
245        let num_grains = hdr
246            .capacity
247            .checked_add(hdr.grain_size - 1)
248            .ok_or(VmdkError::GeometryOverflow { field: "capacity" })?
249            / hdr.grain_size;
250        let num_gts = num_grains
251            .checked_add(u64::from(hdr.num_gtes_per_gt) - 1)
252            .ok_or(VmdkError::GeometryOverflow {
253                field: "num_grains",
254            })?
255            / u64::from(hdr.num_gtes_per_gt);
256        let gd_byte_len = num_gts.checked_mul(4).ok_or(VmdkError::GeometryOverflow {
257            field: "gd_byte_len",
258        })?;
259
260        const MAX_GD_BYTES: u64 = 16 * 1024 * 1024;
261        if gd_byte_len > MAX_GD_BYTES {
262            return Err(VmdkError::FieldOutOfRange {
263                field: "grain_directory",
264                value: gd_byte_len,
265                reason: "exceeds the 16 MiB cap",
266            });
267        }
268        // For streamOptimized, the primary header carries GD_AT_END as a sentinel;
269        // the real GD offset is in the footer header at file_end − 1024 (VDF 1.1 §4.6).
270        let gd_offset = if hdr.gd_offset == GD_AT_END {
271            reader.seek(SeekFrom::End(-1024))?;
272            let mut footer_bytes = [0u8; 512];
273            reader.read_exact(&mut footer_bytes)?;
274            SparseExtentHeader::parse(&footer_bytes)?.gd_offset
275        } else {
276            hdr.gd_offset
277        };
278
279        let gd_sector_offset = gd_offset
280            .checked_mul(SECTOR_SIZE)
281            .ok_or(VmdkError::GeometryOverflow { field: "gd_offset" })?;
282        reader.seek(SeekFrom::Start(gd_sector_offset))?;
283        let mut gd_bytes = vec![0u8; gd_byte_len as usize];
284        reader.read_exact(&mut gd_bytes)?;
285
286        let grain_dir = bytes::le_u32_table(&gd_bytes);
287
288        diag::opened(
289            desc.create_type.as_ref(),
290            hdr.version,
291            virtual_disk_size,
292            grain_size_bytes,
293            hdr.compressed,
294        );
295        Ok(VmdkReader {
296            inner: reader,
297            fmt: FormatState::Sparse {
298                grain_dir,
299                grain_size_bytes,
300                num_gtes_per_gt: u64::from(hdr.num_gtes_per_gt),
301                compressed: hdr.compressed,
302            },
303            virtual_disk_size,
304            disk_type: desc.create_type,
305            pos: 0,
306            version: hdr.version,
307            cid: desc.cid,
308            parent_cid: desc.parent_cid,
309            descriptor_text: desc.raw_text,
310            rgd_offset: hdr.rgd_offset,
311            gd_entry_count: num_gts as usize,
312            gt_cache: HashMap::new(),
313            rgd_fallback: false,
314            rgd_recovery_count: 0,
315        })
316    }
317
318    /// Virtual disk size in bytes.
319    pub fn virtual_disk_size(&self) -> u64 {
320        self.virtual_disk_size
321    }
322
323    /// Seek to `offset` and read exactly `buf.len()` bytes — one home for the
324    /// pervasive seek-then-read idiom.
325    pub(crate) fn read_exact_at(&mut self, offset: u64, buf: &mut [u8]) -> io::Result<()> {
326        self.inner.seek(SeekFrom::Start(offset))?;
327        self.inner.read_exact(buf)
328    }
329
330    /// `createType` from the embedded text descriptor (e.g. `"monolithicSparse"`).
331    ///
332    /// Returns an empty string when no embedded descriptor is present.
333    pub fn disk_type(&self) -> &str {
334        &self.disk_type
335    }
336
337    /// CID from the embedded descriptor; `0xffff_ffff` when absent.
338    pub fn cid(&self) -> u32 {
339        self.cid
340    }
341
342    /// Parent CID; `0xffff_ffff` means this is a base image (no parent).
343    pub fn parent_cid(&self) -> u32 {
344        self.parent_cid
345    }
346
347    /// Virtual disk size in 512-byte sectors.
348    pub fn sector_count(&self) -> u64 {
349        self.virtual_disk_size / SECTOR_SIZE
350    }
351
352    /// Raw embedded descriptor text; empty when no embedded descriptor is present.
353    pub fn descriptor_text(&self) -> &str {
354        &self.descriptor_text
355    }
356
357    /// Parsed `ddb.*` disk database (geometry, adapter type, VM hardware / tools
358    /// versions, UUID, long content ID, thin-provisioning, encoding).
359    ///
360    /// Empty when the descriptor carries no disk database (e.g. a snapshot delta).
361    pub fn disk_database(&self) -> DiskDatabase {
362        DiskDatabase::parse(&self.descriptor_text)
363    }
364
365    /// The descriptor's `changeTrackPath` — the Change Block Tracking (`-ctk.vmdk`)
366    /// file, if this disk has CBT enabled. The `-ctk` file maps which blocks changed
367    /// between snapshots and is the basis for incremental forensic acquisition.
368    pub fn change_track_path(&self) -> Option<String> {
369        for line in self.descriptor_text.lines() {
370            if let Some(rest) = line.trim().strip_prefix("changeTrackPath") {
371                let v = rest.trim_start().trim_start_matches('=').trim();
372                let v = v.trim_matches('"');
373                if !v.is_empty() {
374                    return Some(v.to_owned());
375                }
376            }
377        }
378        None
379    }
380
381    /// The disk's effective content identifier as a hex string.
382    ///
383    /// When `CID == 0xFFFFFFFE` (the "use the long content identifier" sentinel),
384    /// returns `ddb.longContentID`; otherwise the 8-hex-digit short CID.
385    pub fn effective_content_id(&self) -> String {
386        if self.cid == 0xffff_fffe {
387            if let Some(long) = self.disk_database().long_content_id {
388                return long;
389            }
390        }
391        format!("{:08x}", self.cid)
392    }
393
394    /// Structured snapshot of all metadata for this image.
395    pub fn info(&self) -> VmdkInfo {
396        let (grain_size_sectors, grain_size_bytes, compressed) = match &self.fmt {
397            FormatState::Sparse {
398                grain_size_bytes,
399                compressed,
400                ..
401            } => (
402                *grain_size_bytes / SECTOR_SIZE,
403                *grain_size_bytes,
404                *compressed,
405            ),
406            FormatState::SeSparse {
407                grain_size_bytes, ..
408            } => (*grain_size_bytes / SECTOR_SIZE, *grain_size_bytes, false),
409            FormatState::Flat => (0, 0, false),
410        };
411        VmdkInfo {
412            disk_type: self.disk_type.to_string(),
413            version: self.version,
414            cid: self.cid,
415            parent_cid: self.parent_cid,
416            grain_size_sectors,
417            grain_size_bytes,
418            virtual_disk_size: self.virtual_disk_size,
419            sector_count: self.virtual_disk_size / SECTOR_SIZE,
420            compressed,
421            descriptor_text: self.descriptor_text.to_string(),
422            disk_database: DiskDatabase::parse(&self.descriptor_text),
423        }
424    }
425
426    /// Open a seSparse extent file (vSphere 6.5+ VMFS6 snapshots).
427    ///
428    /// Called from `open()` when seSparse constant-header magic is detected.
429    fn open_sesparse(mut reader: R, hdr_bytes: &[u8]) -> Result<Self, VmdkError> {
430        use sesparse::open_sesparse;
431        reader.seek(SeekFrom::Start(0))?;
432        let (grain_dir, grain_size_bytes, grains_offset_sectors) = open_sesparse(&mut reader)?;
433
434        let se_hdr = sesparse::SeConstHeader::parse(hdr_bytes)?;
435        let virtual_disk_size = se_hdr
436            .capacity
437            .checked_mul(SECTOR_SIZE)
438            .ok_or(VmdkError::GeometryOverflow { field: "capacity" })?;
439
440        Ok(VmdkReader {
441            inner: reader,
442            fmt: FormatState::SeSparse {
443                grain_dir,
444                grain_size_bytes,
445                gt_offset_sectors: se_hdr.gt_offset,
446                grains_offset_sectors,
447            },
448            virtual_disk_size,
449            disk_type: Box::from("seSparse"),
450            pos: 0,
451            version: 0,
452            cid: 0xffff_ffff,
453            parent_cid: 0xffff_ffff,
454            descriptor_text: Box::from(""),
455            rgd_offset: 0,
456            gd_entry_count: 0,
457            gt_cache: HashMap::new(),
458            rgd_fallback: false,
459            rgd_recovery_count: 0,
460        })
461    }
462
463    /// Open a COWD extent file (vmfsSparse / vmfsThin).
464    ///
465    /// Called from `open()` when COWD magic is detected.
466    fn open_cowd(mut reader: R, hdr_bytes: &[u8]) -> Result<Self, VmdkError> {
467        use cowd::{open_cowd, COWD_GTES_PER_GT};
468
469        // Reader is positioned after the 512-byte header; seek back to start so
470        // open_cowd() can re-read the header for its own parsing.
471        reader.seek(SeekFrom::Start(0))?;
472        let (grain_dir, grain_size_bytes) = open_cowd(&mut reader)?;
473
474        // COWD capacity is 32-bit sectors; derive virtual_disk_size.
475        let cowd_hdr = cowd::CowdHeader::parse(hdr_bytes)?;
476        let virtual_disk_size = u64::from(cowd_hdr.capacity)
477            .checked_mul(SECTOR_SIZE)
478            .ok_or(VmdkError::GeometryOverflow { field: "capacity" })?;
479
480        Ok(VmdkReader {
481            inner: reader,
482            fmt: FormatState::Sparse {
483                grain_dir,
484                grain_size_bytes,
485                num_gtes_per_gt: COWD_GTES_PER_GT as u64,
486                compressed: false,
487            },
488            virtual_disk_size,
489            disk_type: Box::from("vmfsSparse"),
490            pos: 0,
491            version: 1,
492            cid: 0xffff_ffff,
493            parent_cid: 0xffff_ffff,
494            descriptor_text: Box::from(""),
495            rgd_offset: 0,
496            gd_entry_count: 0,
497            gt_cache: HashMap::new(),
498            rgd_fallback: false,
499            rgd_recovery_count: 0,
500        })
501    }
502
503    /// Returns `true` if the 512-byte sector at `lba` is allocated (non-sparse).
504    ///
505    /// An `lba` beyond the virtual disk boundary always returns `false`.
506    /// For flat/raw-extent VMDKs every sector is implicitly allocated; returns `true` for
507    /// any in-bounds LBA.
508    pub fn is_allocated(&mut self, lba: u64) -> io::Result<bool> {
509        if lba >= self.virtual_disk_size / SECTOR_SIZE {
510            return Ok(false);
511        }
512        // Extract all values from self.fmt before any mutable borrow of self.inner.
513        let virtual_offset = lba * SECTOR_SIZE;
514        match &self.fmt {
515            FormatState::Flat => Ok(true),
516            FormatState::Sparse {
517                grain_dir,
518                grain_size_bytes,
519                num_gtes_per_gt,
520                ..
521            } => {
522                let grain_idx = virtual_offset / grain_size_bytes;
523                let gd_idx = (grain_idx / num_gtes_per_gt) as usize;
524                let gte_idx = grain_idx % num_gtes_per_gt;
525                let gt_sector = grain_dir.get(gd_idx).copied().unwrap_or(0);
526                let () = ();
527                if gt_sector == 0 {
528                    return Ok(false);
529                }
530                let gte_pos = u64::from(gt_sector) * SECTOR_SIZE + gte_idx * 4;
531                let mut b = [0u8; 4];
532                self.read_exact_at(gte_pos, &mut b)?;
533                Ok(u32::from_le_bytes(b) > 1)
534            }
535            FormatState::SeSparse {
536                grain_dir,
537                grain_size_bytes,
538                gt_offset_sectors,
539                ..
540            } => {
541                let gd_entry = {
542                    let grain_idx = virtual_offset / grain_size_bytes;
543                    let gd_idx = (grain_idx / sesparse::SE_GTES_PER_GT) as usize;
544                    grain_dir.get(gd_idx).copied().unwrap_or(0)
545                };
546                let grain_idx = virtual_offset / grain_size_bytes;
547                let gte_idx = grain_idx % sesparse::SE_GTES_PER_GT;
548                let gt_off = *gt_offset_sectors;
549                let Some(gte) = self.se_read_gte(gd_entry, gt_off, gte_idx)? else {
550                    return Ok(false);
551                };
552                // Allocated only when the GTE type nibble is "allocated" (0x3).
553                Ok(gte & sesparse::SE_GTE_TYPE_MASK == sesparse::SE_GTE_TYPE_ALLOCATED)
554            }
555        }
556    }
557
558    /// Read a seSparse L2 (grain-table) entry given its L1 (GD) entry.
559    ///
560    /// Returns `Ok(None)` if the GD entry is unallocated, `Ok(Some(gte))` otherwise.
561    /// Validates the GD allocated-marker nibble per the seSparse encoding.
562    pub(crate) fn se_read_gte(
563        &mut self,
564        gd_entry: u64,
565        gt_offset_sectors: u64,
566        gte_idx: u64,
567    ) -> io::Result<Option<u64>> {
568        if gd_entry == 0 {
569            return Ok(None);
570        }
571        if gd_entry & sesparse::SE_GD_ALLOC_MASK != sesparse::SE_GD_ALLOC_FLAG {
572            return Err(io::Error::new(
573                io::ErrorKind::InvalidData,
574                "seSparse GD entry has invalid allocated marker",
575            ));
576        }
577        let gt_table_idx = gd_entry & sesparse::SE_GD_INDEX_MASK;
578        let gt_sector = gt_offset_sectors + gt_table_idx * sesparse::SE_GT_SECTORS;
579        let gte_pos = gt_sector * SECTOR_SIZE + gte_idx * 8;
580        let mut b = [0u8; 8];
581        self.read_exact_at(gte_pos, &mut b)?;
582        Ok(Some(u64::from_le_bytes(b)))
583    }
584
585    /// Iterate over all allocated (non-sparse) grain ranges in LBA order.
586    ///
587    /// Each yielded [`AllocatedGrain`] covers exactly one grain; contiguous allocated
588    /// grains are not coalesced so the caller can apply its own merging if desired.
589    /// The iterator is eager — it collects all GTE reads upfront to avoid borrow issues.
590    pub fn iter_allocated_grains(&mut self) -> io::Result<Vec<AllocatedGrain>> {
591        let (grain_dir, grain_size_bytes, num_gtes_per_gt) = match &self.fmt {
592            FormatState::Flat => {
593                // All sectors allocated; yield the entire virtual disk as one grain.
594                let sector_count = self.virtual_disk_size / SECTOR_SIZE;
595                return Ok(if sector_count == 0 {
596                    vec![]
597                } else {
598                    vec![AllocatedGrain {
599                        start_lba: 0,
600                        sector_count,
601                    }]
602                });
603            }
604            FormatState::Sparse {
605                grain_dir,
606                grain_size_bytes,
607                num_gtes_per_gt,
608                ..
609            } => (grain_dir.clone(), *grain_size_bytes, *num_gtes_per_gt),
610            FormatState::SeSparse {
611                grain_dir,
612                grain_size_bytes,
613                gt_offset_sectors,
614                ..
615            } => {
616                let (gd, gsz, goff) = (grain_dir.clone(), *grain_size_bytes, *gt_offset_sectors);
617                let grain_sectors = gsz / SECTOR_SIZE;
618                let max_lba = self.virtual_disk_size / SECTOR_SIZE;
619                let mut result = Vec::new();
620                for (gd_idx, &gd_entry) in gd.iter().enumerate() {
621                    // Skip unallocated GD slots; require the allocated-marker nibble.
622                    if gd_entry == 0 {
623                        continue;
624                    }
625                    if gd_entry & sesparse::SE_GD_ALLOC_MASK != sesparse::SE_GD_ALLOC_FLAG {
626                        continue; // malformed GD entry — skip rather than abort the scan
627                    }
628                    let gt_table_idx = gd_entry & sesparse::SE_GD_INDEX_MASK;
629                    let gt_sector = goff + gt_table_idx * sesparse::SE_GT_SECTORS;
630                    let gt_bytes_len = sesparse::SE_GTES_PER_GT as usize * 8;
631                    let mut gt_bytes = vec![0u8; gt_bytes_len];
632                    self.read_exact_at(gt_sector * SECTOR_SIZE, &mut gt_bytes)?;
633                    for gte_idx in 0..sesparse::SE_GTES_PER_GT as usize {
634                        let gte = u64::from_le_bytes(
635                            gt_bytes[gte_idx * 8..gte_idx * 8 + 8]
636                                .try_into()
637                                .expect("8 bytes"),
638                        );
639                        // Only "allocated" (0x3) grains hold real data; zero/unmapped are sparse.
640                        if gte & sesparse::SE_GTE_TYPE_MASK == sesparse::SE_GTE_TYPE_ALLOCATED {
641                            let grain_idx =
642                                gd_idx as u64 * sesparse::SE_GTES_PER_GT + gte_idx as u64;
643                            let start_lba = grain_idx * grain_sectors;
644                            if start_lba < max_lba {
645                                result.push(AllocatedGrain {
646                                    start_lba,
647                                    sector_count: grain_sectors,
648                                });
649                            }
650                        }
651                    }
652                }
653                return Ok(result);
654            }
655        };
656        let grain_sectors = grain_size_bytes / SECTOR_SIZE;
657        let mut result = Vec::new();
658
659        for (gd_idx, &primary_gt_sector) in grain_dir.iter().enumerate() {
660            // Recovery mode: resolve a damaged primary pointer through the RGD, and load
661            // the redundant grain table once so individually lost primary entries can be
662            // recovered from it.
663            let gt_sector = if self.rgd_fallback {
664                self.resilient_gt_sector(gd_idx, primary_gt_sector, num_gtes_per_gt)?
665            } else {
666                primary_gt_sector
667            };
668            let redundant_gt = if self.rgd_fallback {
669                self.read_redundant_gt(gd_idx, num_gtes_per_gt)?
670            } else {
671                None
672            };
673            if gt_sector == 0 {
674                continue;
675            }
676            let gt_size = num_gtes_per_gt as usize * 4;
677            let gt_bytes = {
678                let gt_byte_offset = u64::from(gt_sector) * SECTOR_SIZE;
679                let mut b = vec![0u8; gt_size];
680                self.read_exact_at(gt_byte_offset, &mut b)?;
681                b
682            };
683
684            // The whole grain table was recovered when fallback swapped in an RGD pointer.
685            let pointer_recovered =
686                self.rgd_fallback && gt_sector != primary_gt_sector && gt_sector != 0;
687            for gte_idx in 0..num_gtes_per_gt as usize {
688                let mut gte = u32::from_le_bytes(
689                    gt_bytes[gte_idx * 4..gte_idx * 4 + 4]
690                        .try_into()
691                        .expect("4 bytes"),
692                );
693                // Recover a lost primary entry from the redundant grain table.
694                let mut entry_recovered = false;
695                if gte <= 1 {
696                    if let Some(rgt) = &redundant_gt {
697                        let rgte = u32::from_le_bytes(
698                            rgt[gte_idx * 4..gte_idx * 4 + 4]
699                                .try_into()
700                                .expect("4 bytes"),
701                        );
702                        if rgte > 1 {
703                            gte = rgte;
704                            entry_recovered = true;
705                        }
706                    }
707                }
708                if gte > 1 {
709                    if pointer_recovered || entry_recovered {
710                        self.rgd_recovery_count += 1;
711                    }
712                    let grain_idx = gd_idx as u64 * num_gtes_per_gt + gte_idx as u64;
713                    let start_lba = grain_idx * grain_sectors;
714                    if start_lba < self.virtual_disk_size / SECTOR_SIZE {
715                        result.push(AllocatedGrain {
716                            start_lba,
717                            sector_count: grain_sectors,
718                        });
719                    }
720                }
721            }
722        }
723        Ok(result)
724    }
725
726    /// Compute SHA-256 and MD5 digests of the full virtual disk in one sequential pass.
727    ///
728    /// Reads from the current seek position (normally the caller should seek to 0 first).
729    /// Uses a 64 KiB streaming buffer to avoid loading the whole disk into memory.
730    pub fn hash(&mut self) -> io::Result<VmdkDigest> {
731        use md5::Md5;
732        use sha2::{Digest as _, Sha256};
733
734        let mut sha = Sha256::new();
735        let mut md = Md5::new();
736        let mut buf = vec![0u8; 65536];
737        loop {
738            let n = self.read(&mut buf)?;
739            if n == 0 {
740                break;
741            }
742            sha.update(&buf[..n]);
743            md.update(&buf[..n]);
744        }
745        let sha_bytes = sha.finalize();
746        let md_bytes = md.finalize();
747        Ok(VmdkDigest {
748            sha256: sha_bytes
749                .iter()
750                .fold(String::with_capacity(64), |mut s, b| {
751                    use std::fmt::Write as _;
752                    let _ = write!(s, "{b:02x}");
753                    s
754                }),
755            md5: md_bytes.iter().fold(String::with_capacity(32), |mut s, b| {
756                use std::fmt::Write as _;
757                let _ = write!(s, "{b:02x}");
758                s
759            }),
760        })
761    }
762
763    /// Number of grain tables currently held in the GT cache.
764    ///
765    /// Exposed for testing; not part of the stable public API.
766    #[doc(hidden)]
767    pub fn gt_cache_size(&self) -> usize {
768        self.gt_cache.len()
769    }
770}
771
772// ── open_path (path-aware, all formats) ──────────────────────────────────────
773
774impl VmdkFileReader {
775    /// List the companion extent files this VMDK depends on, resolved relative to
776    /// the descriptor's directory.
777    ///
778    /// For a self-contained binary VMDK (`monolithicSparse`, `streamOptimized`, …)
779    /// this is empty — the single file holds everything. For multi-file formats
780    /// (`twoGbMaxExtent*`, `monolithicFlat`, `vmfsSparse`, `seSparse`, `custom`, …)
781    /// it returns every backing extent file in descriptor order. `ZERO`/`NOACCESS`
782    /// extents carry no file and are excluded.
783    ///
784    /// Forensic use: enumerate what must be collected *before* the disk can be read,
785    /// without opening (or even possessing) the extents themselves.
786    pub fn extent_dependencies(path: &Path) -> Result<Vec<std::path::PathBuf>, VmdkError> {
787        // Peek the first byte: binary VMDKs (non-`#`) are self-contained.
788        let first_byte = {
789            let mut buf = [0u8; 1];
790            File::open(path)?.read_exact(&mut buf)?;
791            buf[0]
792        };
793        if first_byte != b'#' {
794            return Ok(Vec::new());
795        }
796        let text = std::fs::read_to_string(path)?;
797        let desc = parse_text_descriptor(&text)?;
798        let dir = path.parent().unwrap_or(Path::new("."));
799
800        let mut deps = Vec::new();
801        // Flat extents (FLAT/VMFS/VMFSRAW); ZERO/NOACCESS have no backing file.
802        for ext in &desc.extents {
803            if ext.is_zero || ext.filename.is_empty() {
804                continue;
805            }
806            deps.push(dir.join(ext.filename.as_ref()));
807        }
808        // Sparse extents (SPARSE/VMFSSPARSE/SESPARSE) always have a backing file.
809        for ext in &desc.sparse_extents {
810            if ext.filename.is_empty() {
811                continue;
812            }
813            deps.push(dir.join(ext.filename.as_ref()));
814        }
815        Ok(deps)
816    }
817
818    /// Open any VMDK format from a file-system path.
819    ///
820    /// Unlike [`VmdkReader::open`], this constructor handles text-descriptor
821    /// VMDKs (`twoGbMaxExtentFlat`) that reference external extent files, as
822    /// well as binary VMDKs that can be opened from a single stream.
823    pub fn open_path(path: &Path) -> Result<Self, VmdkError> {
824        // Peek at the first byte to distinguish text descriptors from binary VMDKs.
825        let first_byte = {
826            let mut buf = [0u8; 1];
827            File::open(path)?.read_exact(&mut buf)?;
828            buf[0]
829        };
830
831        if first_byte == b'#' {
832            // Text descriptor: parse extents and route by createType. Decoded via
833            // the declared encoding (read raw, not read_to_string, so a non-UTF-8
834            // descriptor is decoded rather than rejected outright).
835            let text = descriptor::decode_descriptor(&std::fs::read(path)?);
836            let desc = parse_text_descriptor(&text)?;
837            let dir = path.parent().unwrap_or(Path::new("."));
838
839            match desc.create_type.as_ref() {
840                // Flat / device-passthrough formats — FLAT/VMFS/VMFSRAW/ZERO extents read
841                // as raw bytes. Device maps (fullDevice/partitionedDevice/vmfsRaw/RDM)
842                // reference a device path; present paths read, absent ones yield NotFound.
843                "vmfs"
844                | "vmfsPreallocated"
845                | "vmfsEagerZeroedThick"
846                | "vmfsRDM"
847                | "vmfsRaw"
848                | "vmfsRawDeviceMap"
849                | "vmfsPassthroughRawDeviceMap"
850                | "fullDevice"
851                | "partitionedDevice"
852                | "twoGbMaxExtentFlat"
853                | "monolithicFlat" => {
854                    let multi = MultiExtentReader::open(dir, &desc.extents)?;
855                    let virtual_disk_size = desc
856                        .capacity_sectors
857                        .checked_mul(SECTOR_SIZE)
858                        .ok_or(VmdkError::GeometryOverflow { field: "capacity" })?;
859                    Ok(VmdkReader {
860                        inner: Box::new(multi) as Box<dyn ReadSeek + Send>,
861                        fmt: FormatState::Flat,
862                        virtual_disk_size,
863                        disk_type: desc.create_type,
864                        pos: 0,
865                        version: 0,
866                        cid: desc.cid,
867                        parent_cid: desc.parent_cid,
868                        descriptor_text: desc.raw_text,
869                        rgd_offset: 0,
870                        gd_entry_count: 0,
871                        gt_cache: HashMap::new(),
872                        rgd_fallback: false,
873                        rgd_recovery_count: 0,
874                    })
875                }
876                // ESXi sparse formats: SPARSE/VMFSSPARSE extent type — binary VMDK4 or COWD.
877                "vmfsSparse" | "vmfsThin" | "twoGbMaxExtentSparse" => {
878                    let multi = MultiSparseReader::open(dir, &desc.sparse_extents)?;
879                    let virtual_disk_size =
880                        desc.sparse_capacity_sectors
881                            .checked_mul(SECTOR_SIZE)
882                            .ok_or(VmdkError::GeometryOverflow { field: "capacity" })?;
883                    Ok(VmdkReader {
884                        inner: Box::new(multi) as Box<dyn ReadSeek + Send>,
885                        fmt: FormatState::Flat,
886                        virtual_disk_size,
887                        disk_type: desc.create_type,
888                        pos: 0,
889                        version: 0,
890                        cid: desc.cid,
891                        parent_cid: desc.parent_cid,
892                        descriptor_text: desc.raw_text,
893                        rgd_offset: 0,
894                        gd_entry_count: 0,
895                        gt_cache: HashMap::new(),
896                        rgd_fallback: false,
897                        rgd_recovery_count: 0,
898                    })
899                }
900                // seSparse: a single binary extent whose CAFEBABE magic selects the reader.
901                "seSparse" => {
902                    let entry =
903                        desc.sparse_extents
904                            .first()
905                            .ok_or(VmdkError::MalformedDescriptor(
906                                "seSparse createType without a SESPARSE extent",
907                            ))?;
908                    let extent_path = dir.join(entry.filename.as_ref());
909                    let file = BufReader::new(File::open(&extent_path)?);
910                    Ok(VmdkReader::open(file)?.into_file_reader())
911                }
912                // custom: an arbitrary extent mix — route by which extents are present.
913                "custom" => {
914                    if !desc.extents.is_empty() && !desc.sparse_extents.is_empty() {
915                        // Mixed flat+sparse under one custom createType is not composed;
916                        // fail loud rather than silently dropping the sparse extents.
917                        Err(VmdkError::MalformedDescriptor(
918                            "custom createType mixes flat and sparse extents, which is not supported",
919                        ))
920                    } else if !desc.extents.is_empty() {
921                        let multi = MultiExtentReader::open(dir, &desc.extents)?;
922                        let virtual_disk_size = desc
923                            .capacity_sectors
924                            .checked_mul(SECTOR_SIZE)
925                            .ok_or(VmdkError::GeometryOverflow { field: "capacity" })?;
926                        Ok(VmdkReader {
927                            inner: Box::new(multi) as Box<dyn ReadSeek + Send>,
928                            fmt: FormatState::Flat,
929                            virtual_disk_size,
930                            disk_type: desc.create_type,
931                            pos: 0,
932                            version: 0,
933                            cid: desc.cid,
934                            parent_cid: desc.parent_cid,
935                            descriptor_text: desc.raw_text,
936                            rgd_offset: 0,
937                            gd_entry_count: 0,
938                            gt_cache: HashMap::new(),
939                            rgd_fallback: false,
940                            rgd_recovery_count: 0,
941                        })
942                    } else if !desc.sparse_extents.is_empty() {
943                        let multi = MultiSparseReader::open(dir, &desc.sparse_extents)?;
944                        let virtual_disk_size = desc
945                            .sparse_capacity_sectors
946                            .checked_mul(SECTOR_SIZE)
947                            .ok_or(VmdkError::GeometryOverflow { field: "capacity" })?;
948                        Ok(VmdkReader {
949                            inner: Box::new(multi) as Box<dyn ReadSeek + Send>,
950                            fmt: FormatState::Flat,
951                            virtual_disk_size,
952                            disk_type: desc.create_type,
953                            pos: 0,
954                            version: 0,
955                            cid: desc.cid,
956                            parent_cid: desc.parent_cid,
957                            descriptor_text: desc.raw_text,
958                            rgd_offset: 0,
959                            gd_entry_count: 0,
960                            gt_cache: HashMap::new(),
961                            rgd_fallback: false,
962                            rgd_recovery_count: 0,
963                        })
964                    } else {
965                        Err(VmdkError::MalformedDescriptor(
966                            "custom createType without recognised extents",
967                        ))
968                    }
969                }
970                _ => Err(VmdkError::UnsupportedDiskType(
971                    desc.create_type.into_string(),
972                )),
973            }
974        } else {
975            // Binary VMDK — parse normally then erase the reader type.
976            let file = BufReader::new(File::open(path)?);
977            Ok(VmdkReader::open(file)?.into_file_reader())
978        }
979    }
980}
981
982impl<R: Read + Seek + Send + 'static> VmdkReader<R> {
983    fn into_file_reader(self) -> VmdkFileReader {
984        VmdkFileReader {
985            inner: Box::new(self.inner),
986            fmt: self.fmt,
987            virtual_disk_size: self.virtual_disk_size,
988            disk_type: self.disk_type,
989            pos: self.pos,
990            version: self.version,
991            cid: self.cid,
992            parent_cid: self.parent_cid,
993            descriptor_text: self.descriptor_text,
994            rgd_offset: self.rgd_offset,
995            gd_entry_count: self.gd_entry_count,
996            gt_cache: self.gt_cache,
997            rgd_fallback: self.rgd_fallback,
998            rgd_recovery_count: self.rgd_recovery_count,
999        }
1000    }
1001}
1002
1003// ── Read + Seek impls ─────────────────────────────────────────────────────────
1004
1005// ── Test helpers ──────────────────────────────────────────────────────────────
1006
1007#[cfg(feature = "test-helpers")]
1008pub mod testutil;
1009#[cfg(not(feature = "test-helpers"))]
1010mod testutil;
1011
1012// ── forensic-vfs integration ──────────────────────────────────────────────────
1013
1014#[cfg(feature = "vfs")]
1015mod vfs;
1016#[cfg(feature = "vfs")]
1017pub use vfs::VmdkSource;
1018
1019// ── Tests ─────────────────────────────────────────────────────────────────────
1020
1021#[cfg(test)]
1022mod tests {
1023    use super::*;
1024    use std::io::Cursor;
1025    use testutil::{
1026        compressed_vmdk_with_oversized_marker, gd_at_end_stream_opt_vmdk, test_cowd_vmdk,
1027        test_sesparse_vmdk, test_sparse_vmdk, GRAIN_SIZE_BYTES,
1028    };
1029
1030    fn vmdk_header_bytes(capacity_sectors: u64, grain_size: u64, num_gtes_per_gt: u32) -> Vec<u8> {
1031        let mut h = vec![0u8; 512];
1032        h[0..4].copy_from_slice(&0x564D_444B_u32.to_le_bytes());
1033        h[4..8].copy_from_slice(&1u32.to_le_bytes());
1034        h[12..20].copy_from_slice(&capacity_sectors.to_le_bytes());
1035        h[20..28].copy_from_slice(&grain_size.to_le_bytes());
1036        h[44..48].copy_from_slice(&num_gtes_per_gt.to_le_bytes());
1037        h
1038    }
1039
1040    // ── Header version 2 (zeroed-grain feature) + ZERO extent type ───────────
1041
1042    #[test]
1043    fn header_version_2_zeroed_grain_opens() {
1044        // VMware images with the zeroed-grain feature carry version=2 + flag bit 2.
1045        // QEMU accepts any VMDK4-magic version; we must accept v2 too, not just 1/3.
1046        let mut vmdk = test_sparse_vmdk(&[0u8; 512]);
1047        vmdk[4..8].copy_from_slice(&2u32.to_le_bytes()); // version = 2
1048        vmdk[8..12].copy_from_slice(&0x0000_0004u32.to_le_bytes()); // VMDK4_FLAG_ZERO_GRAIN
1049        VmdkReader::open(Cursor::new(vmdk))
1050            .expect("version=2 (zeroed-grain) monolithicSparse must open");
1051    }
1052
1053    #[test]
1054    fn zero_extent_type_reads_as_zeros() {
1055        // A ZERO extent emulates a zero-filled region with NO backing file.
1056        // `RW <sectors> ZERO` — valid per the VMware descriptor spec.
1057        use std::io::Write as _;
1058        let dir = tempfile::tempdir().unwrap();
1059        let desc = "# Disk DescriptorFile\nversion=1\nCID=ffffffff\nparentCID=ffffffff\ncreateType=\"monolithicFlat\"\nRW 2048 ZERO\n";
1060        let desc_path = dir.path().join("zero.vmdk");
1061        std::fs::File::create(&desc_path)
1062            .unwrap()
1063            .write_all(desc.as_bytes())
1064            .unwrap();
1065        let mut reader =
1066            VmdkFileReader::open_path(&desc_path).expect("descriptor with a ZERO extent must open");
1067        assert_eq!(
1068            reader.virtual_disk_size(),
1069            2048 * 512,
1070            "ZERO extent contributes its sector count"
1071        );
1072        reader.seek(SeekFrom::Start(0)).unwrap();
1073        let mut buf = [0xFFu8; 512];
1074        reader.read_exact(&mut buf).expect("read");
1075        assert_eq!(buf, [0u8; 512], "ZERO extent must read as zeros");
1076    }
1077
1078    // ── custom + device-passthrough createTypes ──────────────────────────────
1079
1080    /// Write a descriptor + a flat extent file containing `byte0` at offset 0,
1081    /// then assert `open_path` reads it back through `create_type`/`extent_kw`.
1082    fn assert_flat_create_type_reads(create_type: &str, extent_kw: &str, byte0: u8) {
1083        use std::io::Write as _;
1084        let dir = tempfile::tempdir().unwrap();
1085        let mut extent = vec![0u8; 1024];
1086        extent[0] = byte0;
1087        let extent_path = dir.path().join("disk-flat.vmdk");
1088        std::fs::File::create(&extent_path)
1089            .unwrap()
1090            .write_all(&extent)
1091            .unwrap();
1092        let offset = if extent_kw == "FLAT" { " 0" } else { "" };
1093        let desc = format!(
1094            "# Disk DescriptorFile\nversion=1\nCID=ffffffff\nparentCID=ffffffff\n\
1095             createType=\"{create_type}\"\nRW 2 {extent_kw} \"disk-flat.vmdk\"{offset}\n"
1096        );
1097        let desc_path = dir.path().join("disk.vmdk");
1098        std::fs::write(&desc_path, desc.as_bytes()).unwrap();
1099        let mut reader = VmdkFileReader::open_path(&desc_path)
1100            .unwrap_or_else(|e| panic!("{create_type}/{extent_kw} must open: {e:?}"));
1101        let mut buf = [0u8; 1];
1102        reader.read_exact(&mut buf).expect("read");
1103        assert_eq!(
1104            buf[0], byte0,
1105            "{create_type}: must read the referenced extent"
1106        );
1107    }
1108
1109    #[test]
1110    fn custom_create_type_with_flat_extent_opens() {
1111        // createType="custom" is an arbitrary extent mix — route by extent composition.
1112        assert_flat_create_type_reads("custom", "FLAT", 0xC0);
1113    }
1114
1115    #[test]
1116    fn full_device_create_type_routes_to_flat() {
1117        // fullDevice / partitionedDevice map to a device path via a FLAT extent;
1118        // when the referenced path is present they read like any flat extent.
1119        assert_flat_create_type_reads("fullDevice", "FLAT", 0xFD);
1120        assert_flat_create_type_reads("partitionedDevice", "FLAT", 0xDE);
1121    }
1122
1123    #[test]
1124    fn vmfs_raw_rdm_create_types_route_to_flat() {
1125        // vmfsRaw / vmfsRawDeviceMap reference a raw LUN via a VMFSRAW/FLAT extent;
1126        // present-path reads must succeed (offline-absent yields a clear NotFound).
1127        assert_flat_create_type_reads("vmfsRaw", "VMFSRAW", 0x4A);
1128        assert_flat_create_type_reads("vmfsRawDeviceMap", "VMFSRAW", 0x4B);
1129    }
1130
1131    // ── extent_dependencies (companion-file discovery for evidence collection) ──
1132
1133    #[test]
1134    fn extent_dependencies_lists_flat_companion() {
1135        // A twoGbMaxExtentFlat descriptor must report its companion extent file so a
1136        // forensic examiner knows what to collect before the disk can be read.
1137        use std::io::Write as _;
1138        let dir = tempfile::tempdir().unwrap();
1139        let desc = "# Disk DescriptorFile\nversion=1\nCID=ffffffff\nparentCID=ffffffff\ncreateType=\"twoGbMaxExtentFlat\"\nRW 2048 FLAT \"disk-f001.vmdk\" 0\n";
1140        let desc_path = dir.path().join("disk.vmdk");
1141        std::fs::File::create(&desc_path)
1142            .unwrap()
1143            .write_all(desc.as_bytes())
1144            .unwrap();
1145        let deps = VmdkFileReader::extent_dependencies(&desc_path).expect("extent_dependencies");
1146        assert_eq!(deps.len(), 1, "one companion extent");
1147        assert_eq!(
1148            deps[0].file_name().unwrap().to_string_lossy(),
1149            "disk-f001.vmdk"
1150        );
1151        // Paths must be resolved relative to the descriptor's directory.
1152        assert_eq!(deps[0].parent().unwrap(), dir.path());
1153    }
1154
1155    #[test]
1156    fn extent_dependencies_lists_sparse_companions() {
1157        use std::io::Write as _;
1158        let dir = tempfile::tempdir().unwrap();
1159        let desc = "# Disk DescriptorFile\nversion=1\nCID=ffffffff\nparentCID=ffffffff\ncreateType=\"twoGbMaxExtentSparse\"\nRW 4194304 SPARSE \"disk-s001.vmdk\"\nRW 4194304 SPARSE \"disk-s002.vmdk\"\n";
1160        let desc_path = dir.path().join("disk.vmdk");
1161        std::fs::File::create(&desc_path)
1162            .unwrap()
1163            .write_all(desc.as_bytes())
1164            .unwrap();
1165        let deps = VmdkFileReader::extent_dependencies(&desc_path).expect("deps");
1166        let names: Vec<String> = deps
1167            .iter()
1168            .map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
1169            .collect();
1170        assert_eq!(names, vec!["disk-s001.vmdk", "disk-s002.vmdk"]);
1171    }
1172
1173    #[test]
1174    fn extent_dependencies_empty_for_self_contained_binary() {
1175        // A binary single-file VMDK (no text descriptor) is self-contained → no deps.
1176        use std::io::Write as _;
1177        let dir = tempfile::tempdir().unwrap();
1178        let vmdk = test_sparse_vmdk(&[0u8; 512]);
1179        let path = dir.path().join("mono.vmdk");
1180        std::fs::File::create(&path)
1181            .unwrap()
1182            .write_all(&vmdk)
1183            .unwrap();
1184        let deps = VmdkFileReader::extent_dependencies(&path).expect("deps");
1185        assert!(
1186            deps.is_empty(),
1187            "self-contained binary VMDK has no companions"
1188        );
1189    }
1190
1191    #[test]
1192    fn extent_dependencies_excludes_zero_extents() {
1193        // ZERO extents have no backing file and must not appear as a dependency.
1194        use std::io::Write as _;
1195        let dir = tempfile::tempdir().unwrap();
1196        let desc = "# Disk DescriptorFile\nversion=1\nCID=ffffffff\nparentCID=ffffffff\ncreateType=\"monolithicFlat\"\nRW 2048 ZERO\nRW 2048 FLAT \"real-f001.vmdk\" 0\n";
1197        let desc_path = dir.path().join("disk.vmdk");
1198        std::fs::File::create(&desc_path)
1199            .unwrap()
1200            .write_all(desc.as_bytes())
1201            .unwrap();
1202        let deps = VmdkFileReader::extent_dependencies(&desc_path).expect("deps");
1203        let names: Vec<String> = deps
1204            .iter()
1205            .map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
1206            .collect();
1207        assert_eq!(
1208            names,
1209            vec!["real-f001.vmdk"],
1210            "ZERO extent contributes no file"
1211        );
1212    }
1213
1214    #[test]
1215    fn extent_dependencies_skips_empty_sparse_filename() {
1216        // A SPARSE extent with an empty filename is skipped (defensive guard).
1217        use std::io::Write as _;
1218        let dir = tempfile::tempdir().unwrap();
1219        let desc = "# Disk DescriptorFile\nversion=1\nCID=ffffffff\nparentCID=ffffffff\ncreateType=\"twoGbMaxExtentSparse\"\nRW 8 SPARSE \"\"\nRW 8 SPARSE \"real-s001.vmdk\"\n";
1220        let desc_path = dir.path().join("disk.vmdk");
1221        std::fs::File::create(&desc_path)
1222            .unwrap()
1223            .write_all(desc.as_bytes())
1224            .unwrap();
1225        let deps = VmdkFileReader::extent_dependencies(&desc_path).expect("deps");
1226        let names: Vec<String> = deps
1227            .iter()
1228            .map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
1229            .collect();
1230        assert_eq!(
1231            names,
1232            vec!["real-s001.vmdk"],
1233            "empty-filename sparse extent skipped"
1234        );
1235    }
1236
1237    // ── check_integrity (dangling-pointer / corruption detection) ─────────────
1238
1239    #[test]
1240    fn grain_size_zero_rejected() {
1241        let img = vmdk_header_bytes(8, 0, 512);
1242        assert!(VmdkReader::open(Cursor::new(img)).is_err());
1243    }
1244
1245    #[test]
1246    fn num_gtes_per_gt_zero_rejected() {
1247        let img = vmdk_header_bytes(8, 8, 0);
1248        assert!(VmdkReader::open(Cursor::new(img)).is_err());
1249    }
1250
1251    #[test]
1252    fn open_empty_file_returns_err() {
1253        assert!(VmdkReader::open(Cursor::new(vec![])).is_err());
1254    }
1255
1256    #[test]
1257    fn open_non_vmdk_file_returns_err() {
1258        assert!(VmdkReader::open(Cursor::new(b"this is not a vmdk file at all".to_vec())).is_err());
1259    }
1260
1261    #[test]
1262    fn sparse_vmdk_virtual_disk_size() {
1263        let vmdk = test_sparse_vmdk(&[0u8; 512]);
1264        let reader = VmdkReader::open(Cursor::new(vmdk)).expect("open");
1265        assert_eq!(reader.virtual_disk_size(), GRAIN_SIZE_BYTES as u64);
1266    }
1267
1268    #[test]
1269    fn sparse_vmdk_read_returns_sector_data() {
1270        let mut data = vec![0u8; 512];
1271        data[42] = 0xDE;
1272        data[43] = 0xAD;
1273        let vmdk = test_sparse_vmdk(&data);
1274        let mut reader = VmdkReader::open(Cursor::new(vmdk)).expect("open");
1275        let mut buf = vec![0u8; 512];
1276        reader.read_exact(&mut buf).expect("read");
1277        assert_eq!(buf[42], 0xDE);
1278        assert_eq!(buf[43], 0xAD);
1279    }
1280
1281    #[test]
1282    fn seek_and_read_at_offset() {
1283        let mut data = vec![0u8; GRAIN_SIZE_BYTES];
1284        data[100] = 0xBE;
1285        data[101] = 0xEF;
1286        let vmdk = test_sparse_vmdk(&data);
1287        let mut reader = VmdkReader::open(Cursor::new(vmdk)).expect("open");
1288        reader.seek(SeekFrom::Start(100)).expect("seek");
1289        let mut buf = [0u8; 2];
1290        reader.read_exact(&mut buf).expect("read");
1291        assert_eq!(buf, [0xBE, 0xEF]);
1292    }
1293
1294    #[test]
1295    fn vmdk_reader_is_send() {
1296        fn assert_send<T: Send>() {}
1297        assert_send::<VmdkReader<Cursor<Vec<u8>>>>();
1298    }
1299
1300    #[test]
1301    fn stream_opt_gd_at_end_opens_correctly() {
1302        let vmdk = gd_at_end_stream_opt_vmdk();
1303        let reader = VmdkReader::open(Cursor::new(vmdk))
1304            .expect("streamOptimized GD_AT_END must open via footer lookup");
1305        assert_eq!(reader.virtual_disk_size(), 1_048_576);
1306        assert_eq!(reader.disk_type(), "streamOptimized");
1307    }
1308
1309    #[test]
1310    fn stream_opt_gd_at_end_reads_zeros() {
1311        let vmdk = gd_at_end_stream_opt_vmdk();
1312        let mut reader = VmdkReader::open(Cursor::new(vmdk)).expect("open GD_AT_END vmdk");
1313        let mut buf = [0xFFu8; 512];
1314        reader.read_exact(&mut buf).expect("read sector 0");
1315        assert_eq!(buf, [0u8; 512]);
1316    }
1317
1318    proptest::proptest! {
1319        #[test]
1320        fn open_never_panics_on_arbitrary_bytes(
1321            bytes in proptest::collection::vec(proptest::prelude::any::<u8>(), 0..8192)
1322        ) {
1323            let _ = VmdkReader::open(Cursor::new(bytes));
1324        }
1325
1326        #[test]
1327        fn open_never_panics_on_valid_magic_plus_garbage(
1328            suffix in proptest::collection::vec(proptest::prelude::any::<u8>(), 0..8192)
1329        ) {
1330            let mut bytes = vec![0u8; 8];
1331            bytes[0..4].copy_from_slice(&0x564D_444B_u32.to_le_bytes());
1332            bytes[4..8].copy_from_slice(&1u32.to_le_bytes());
1333            bytes.extend_from_slice(&suffix);
1334            let _ = VmdkReader::open(Cursor::new(bytes));
1335        }
1336    }
1337
1338    // ── RGD validation ───────────────────────────────────────────────────────
1339
1340    // ── VMFS flat / ZERO extent descriptor parsing ───────────────────────────
1341
1342    #[test]
1343    fn vmfs_flat_extent_descriptor_opens_via_open_path() {
1344        // A vmfs descriptor with VMFS extent type (not FLAT) must open.
1345        // Currently returns Err(UnsupportedDiskType) because VMFS extent type is unrecognised.
1346        use std::io::Write as _;
1347        let dir = tempfile::tempdir().unwrap();
1348        let raw_path = dir.path().join("disk.vmdk");
1349        std::fs::File::create(&raw_path)
1350            .unwrap()
1351            .write_all(&vec![0u8; 512])
1352            .unwrap();
1353        let desc = format!(
1354            "# Disk DescriptorFile\nversion=1\nCID=ffffffff\nparentCID=ffffffff\ncreateType=\"vmfs\"\nRW 1 VMFS \"{}\"\n",
1355            raw_path.file_name().unwrap().to_string_lossy()
1356        );
1357        let desc_path = dir.path().join("disk_desc.vmdk");
1358        std::fs::write(&desc_path, desc.as_bytes()).unwrap();
1359        let result = VmdkFileReader::open_path(&desc_path);
1360        result.expect("vmfs descriptor with VMFS extent must open");
1361    }
1362
1363    #[test]
1364    fn vmfssparse_extent_descriptor_opens_as_cowd() {
1365        // vmfsSparse descriptor with VMFSSPARSE extent type referencing a COWD file.
1366        use std::io::Write as _;
1367        let dir = tempfile::tempdir().unwrap();
1368        let cowd_bytes = testutil::test_cowd_vmdk(&[0u8; 512]);
1369        let cowd_path = dir.path().join("disk-delta.vmdk");
1370        std::fs::File::create(&cowd_path)
1371            .unwrap()
1372            .write_all(&cowd_bytes)
1373            .unwrap();
1374        let desc = format!(
1375            "# Disk DescriptorFile\nversion=1\nCID=ffffffff\nparentCID=ffffffff\ncreateType=\"vmfsSparse\"\nRW 8 VMFSSPARSE \"{}\"\n",
1376            cowd_path.file_name().unwrap().to_string_lossy()
1377        );
1378        let desc_path = dir.path().join("desc.vmdk");
1379        std::fs::write(&desc_path, desc.as_bytes()).unwrap();
1380        let result = VmdkFileReader::open_path(&desc_path);
1381        result.expect("vmfsSparse/VMFSSPARSE descriptor must open");
1382    }
1383
1384    // ── seSparse format (vSphere 6.5+ VMFS6) ─────────────────────────────────
1385
1386    #[test]
1387    fn sesparse_vmdk_opens_successfully() {
1388        let se = test_sesparse_vmdk(&[0u8; 512]);
1389        VmdkReader::open(Cursor::new(se)).expect("seSparse VMDK must open");
1390    }
1391
1392    #[test]
1393    fn sesparse_vmdk_disk_type_is_sesparse() {
1394        let se = test_sesparse_vmdk(&[0u8; 512]);
1395        let reader = VmdkReader::open(Cursor::new(se)).expect("open");
1396        assert_eq!(reader.disk_type(), "seSparse");
1397    }
1398
1399    // ── qemu-img cross-validation (independent oracle) ───────────────────────
1400    //
1401    // COWD and seSparse cannot be generated by qemu-img (ESXi-only write formats),
1402    // but qemu-img *reads* them. These tests build a synthetic extent + descriptor,
1403    // then assert that `qemu-img convert -O raw` and our reader produce byte-identical
1404    // output. This is genuine independent validation: two unrelated parsers agreeing
1405    // on the same bytes confirms the fixture is format-correct and the reader is right.
1406    // Skipped automatically when qemu-img is not installed.
1407
1408    fn qemu_img_available() -> bool {
1409        std::process::Command::new("qemu-img")
1410            .arg("--version")
1411            .output()
1412            .is_ok_and(|o| o.status.success())
1413    }
1414
1415    /// Write `extent_bytes` + a descriptor of `create_type`/`extent_kw`, then compare
1416    /// `qemu-img convert -O raw` against `VmdkReader::open_path` byte-for-byte.
1417    fn assert_reader_matches_qemu(
1418        extent_bytes: &[u8],
1419        create_type: &str,
1420        extent_kw: &str,
1421        capacity_sectors: u64,
1422    ) {
1423        use std::io::Write as _;
1424        let dir = tempfile::tempdir().unwrap();
1425        let extent_path = dir.path().join("disk-extent.vmdk");
1426        std::fs::File::create(&extent_path)
1427            .unwrap()
1428            .write_all(extent_bytes)
1429            .unwrap();
1430        let desc = format!(
1431            "# Disk DescriptorFile\nversion=1\nCID=12345678\nparentCID=ffffffff\n\
1432             createType=\"{create_type}\"\nRW {capacity_sectors} {extent_kw} \"disk-extent.vmdk\"\n"
1433        );
1434        let desc_path = dir.path().join("disk.vmdk");
1435        std::fs::write(&desc_path, desc.as_bytes()).unwrap();
1436
1437        // qemu-img reference.
1438        let qemu_raw = dir.path().join("qemu.raw");
1439        let status = std::process::Command::new("qemu-img")
1440            .args(["convert", "-O", "raw"])
1441            .arg(&desc_path)
1442            .arg(&qemu_raw)
1443            .status()
1444            .expect("run qemu-img convert");
1445        assert!(
1446            status.success(),
1447            "qemu-img convert failed for {create_type}"
1448        );
1449        let qemu_bytes = std::fs::read(&qemu_raw).unwrap();
1450
1451        // Our reader.
1452        let mut reader = VmdkFileReader::open_path(&desc_path).expect("open_path");
1453        reader.seek(SeekFrom::Start(0)).unwrap();
1454        let mut mine = Vec::new();
1455        reader.read_to_end(&mut mine).unwrap();
1456
1457        assert_eq!(
1458            mine.len(),
1459            qemu_bytes.len(),
1460            "{create_type}: size mismatch (mine {} vs qemu {})",
1461            mine.len(),
1462            qemu_bytes.len()
1463        );
1464        assert!(
1465            mine == qemu_bytes,
1466            "{create_type}: byte mismatch vs qemu-img — reader disagrees with the independent oracle"
1467        );
1468    }
1469
1470    #[test]
1471    fn cowd_reader_matches_qemu_img() {
1472        if !qemu_img_available() {
1473            eprintln!("skipping: qemu-img not installed");
1474            return;
1475        }
1476        let pattern: Vec<u8> = (0..4096).map(|i| (i % 251) as u8).collect();
1477        let cowd = test_cowd_vmdk(&pattern);
1478        assert_reader_matches_qemu(&cowd, "vmfsSparse", "VMFSSPARSE", 8);
1479    }
1480
1481    #[test]
1482    fn sesparse_reader_matches_qemu_img() {
1483        if !qemu_img_available() {
1484            eprintln!("skipping: qemu-img not installed");
1485            return;
1486        }
1487        let pattern: Vec<u8> = (0..4096).map(|i| (i % 251) as u8).collect();
1488        let se = test_sesparse_vmdk(&pattern);
1489        assert_reader_matches_qemu(&se, "seSparse", "SESPARSE", 8);
1490    }
1491
1492    #[test]
1493    fn sesparse_vmdk_reads_grain_data() {
1494        let mut data = vec![0u8; 512];
1495        data[0] = 0x5E;
1496        data[1] = 0xA5;
1497        let se = test_sesparse_vmdk(&data);
1498        let mut reader = VmdkReader::open(Cursor::new(se)).expect("open seSparse");
1499        let mut buf = [0u8; 512];
1500        reader.read_exact(&mut buf).expect("read");
1501        assert_eq!(buf[0], 0x5E);
1502        assert_eq!(buf[1], 0xA5);
1503    }
1504
1505    #[test]
1506    fn sesparse_extent_descriptor_opens_via_open_path() {
1507        // seSparse descriptor (createType="seSparse", SESPARSE extent) must route
1508        // through open_path to the binary extent. This path was a gap until qemu-img
1509        // cross-validation exposed it (the bare-binary magic path worked, the
1510        // descriptor path did not).
1511        use std::io::Write as _;
1512        let dir = tempfile::tempdir().unwrap();
1513        let mut data = vec![0u8; 512];
1514        data[0] = 0x7E;
1515        let se_bytes = test_sesparse_vmdk(&data);
1516        let se_path = dir.path().join("disk-sesparse.vmdk");
1517        std::fs::File::create(&se_path)
1518            .unwrap()
1519            .write_all(&se_bytes)
1520            .unwrap();
1521        let desc = format!(
1522            "# Disk DescriptorFile\nversion=1\nCID=abcdef01\nparentCID=ffffffff\ncreateType=\"seSparse\"\nRW 8 SESPARSE \"{}\"\n",
1523            se_path.file_name().unwrap().to_string_lossy()
1524        );
1525        let desc_path = dir.path().join("disk.vmdk");
1526        std::fs::write(&desc_path, desc.as_bytes()).unwrap();
1527        let mut reader = VmdkFileReader::open_path(&desc_path)
1528            .expect("seSparse descriptor must open via open_path");
1529        assert_eq!(reader.disk_type(), "seSparse");
1530        let mut buf = [0u8; 1];
1531        reader.read_exact(&mut buf).expect("read grain 0");
1532        assert_eq!(
1533            buf[0], 0x7E,
1534            "must read seSparse grain data through the descriptor"
1535        );
1536    }
1537
1538    // ── COWD format (vmfsSparse / vmfsThin) ──────────────────────────────────
1539
1540    #[test]
1541    fn cowd_vmdk_opens_without_bad_magic_error() {
1542        let cowd = test_cowd_vmdk(&[0u8; 512]);
1543        let reader = VmdkReader::open(Cursor::new(cowd));
1544        reader.expect("COWD VMDK must open successfully");
1545    }
1546
1547    #[test]
1548    fn cowd_vmdk_reads_grain_data() {
1549        let mut data = vec![0u8; 512];
1550        data[0] = 0xC0;
1551        data[1] = 0xBE;
1552        let cowd = test_cowd_vmdk(&data);
1553        let mut reader = VmdkReader::open(Cursor::new(cowd)).expect("open COWD");
1554        let mut buf = [0u8; 512];
1555        reader.read_exact(&mut buf).expect("read");
1556        assert_eq!(buf[0], 0xC0, "COWD grain data byte 0");
1557        assert_eq!(buf[1], 0xBE, "COWD grain data byte 1");
1558    }
1559
1560    #[test]
1561    fn cowd_vmdk_virtual_disk_size() {
1562        let cowd = test_cowd_vmdk(&[0u8; 512]);
1563        let reader = VmdkReader::open(Cursor::new(cowd)).expect("open");
1564        // test_cowd_vmdk capacity = grain_size = 8 sectors = 4096 bytes
1565        assert_eq!(reader.virtual_disk_size(), 8 * 512);
1566    }
1567
1568    // ── VmdkHasher ───────────────────────────────────────────────────────────
1569
1570    #[test]
1571    fn hash_all_zeros_disk_produces_known_sha256() {
1572        // All-sparse VMDK reads as all zeros — SHA-256 of 1 MiB of zeros is a known constant.
1573        use std::io::Cursor;
1574        let vmdk = gd_at_end_stream_opt_vmdk();
1575        let mut reader = VmdkReader::open(Cursor::new(vmdk)).expect("open");
1576        reader.seek(SeekFrom::Start(0)).expect("seek");
1577        let digest = reader.hash().expect("hash");
1578        // SHA-256 of 1 MiB (1_048_576) zero bytes (computed independently):
1579        // echo -n | dd bs=1 count=0 | ... — computed via sha256sum
1580        assert_eq!(
1581            digest.sha256, "30e14955ebf1352266dc2ff8067e68104607e750abb9d3b36582b8af909fcb58",
1582            "SHA-256 of 1 MiB all-zeros"
1583        );
1584        assert_eq!(
1585            digest.md5, "b6d81b360a5672d80c27430f39153e2c",
1586            "MD5 of 1 MiB all-zeros (matches qemu-img reference)"
1587        );
1588    }
1589
1590    #[test]
1591    fn hash_produces_hex_strings_of_correct_length() {
1592        let vmdk = test_sparse_vmdk(&[0u8; 512]);
1593        let mut reader = VmdkReader::open(Cursor::new(vmdk)).expect("open");
1594        reader.seek(SeekFrom::Start(0)).expect("seek");
1595        let digest = reader.hash().expect("hash");
1596        assert_eq!(digest.sha256.len(), 64, "SHA-256 hex must be 64 chars");
1597        assert_eq!(digest.md5.len(), 32, "MD5 hex must be 32 chars");
1598    }
1599
1600    // ── serde feature ────────────────────────────────────────────────────────
1601
1602    #[cfg(feature = "serde")]
1603    #[test]
1604    fn vmdk_info_serializes_to_json() {
1605        let vmdk = test_sparse_vmdk(&[0u8; 512]);
1606        let reader = VmdkReader::open(Cursor::new(vmdk)).expect("open");
1607        let info = reader.info();
1608        let json = serde_json::to_string(&info).expect("serialize VmdkInfo to JSON");
1609        assert!(
1610            json.contains("\"disk_type\""),
1611            "JSON must contain disk_type field"
1612        );
1613        assert!(
1614            json.contains("monolithicSparse"),
1615            "JSON must contain createType value"
1616        );
1617        let info2: VmdkInfo = serde_json::from_str(&json).expect("deserialize VmdkInfo from JSON");
1618        assert_eq!(info2.disk_type, info.disk_type);
1619        assert_eq!(info2.virtual_disk_size, info.virtual_disk_size);
1620    }
1621
1622    #[cfg(feature = "serde")]
1623    #[test]
1624    fn allocated_grain_serializes_to_json() {
1625        let grain = AllocatedGrain {
1626            start_lba: 128,
1627            sector_count: 8,
1628        };
1629        let json = serde_json::to_string(&grain).expect("serialize AllocatedGrain");
1630        assert!(json.contains("\"start_lba\""));
1631        assert!(json.contains("128"));
1632        let grain2: AllocatedGrain = serde_json::from_str(&json).expect("deserialize");
1633        assert_eq!(grain2, grain);
1634    }
1635
1636    // ── GT cache ─────────────────────────────────────────────────────────────
1637
1638    #[test]
1639    fn gt_cache_grows_on_grain_read() {
1640        let vmdk = test_sparse_vmdk(&[0u8; 512]);
1641        let mut reader = VmdkReader::open(Cursor::new(vmdk)).expect("open");
1642        assert_eq!(reader.gt_cache_size(), 0, "cache starts empty");
1643        let mut buf = [0u8; 512];
1644        reader.read_exact(&mut buf).expect("read");
1645        assert_eq!(
1646            reader.gt_cache_size(),
1647            1,
1648            "one GT loaded after first grain read"
1649        );
1650    }
1651
1652    #[test]
1653    fn gt_cache_no_double_load_on_second_read_same_grain() {
1654        let vmdk = test_sparse_vmdk(&[0xABu8; 512]);
1655        let mut reader = VmdkReader::open(Cursor::new(vmdk)).expect("open");
1656        let mut buf = [0u8; 512];
1657        reader.read_exact(&mut buf).expect("first read");
1658        let after_first = reader.gt_cache_size();
1659        reader.seek(SeekFrom::Start(0)).expect("seek back");
1660        reader.read_exact(&mut buf).expect("second read");
1661        assert_eq!(
1662            reader.gt_cache_size(),
1663            after_first,
1664            "cache must not grow on second read of same GT"
1665        );
1666        assert_eq!(buf[0], 0xAB, "data must still be correct");
1667    }
1668
1669    // ── is_allocated / iter_allocated_grains ─────────────────────────────────
1670
1671    #[test]
1672    fn sparse_grain_is_not_allocated() {
1673        // test_sparse_vmdk has grain 0 allocated (sector data) and all other grains sparse.
1674        // Sectors beyond grain 0 should report not-allocated.
1675        let vmdk = test_sparse_vmdk(&[0u8; 512]);
1676        let mut reader = VmdkReader::open(Cursor::new(vmdk)).expect("open");
1677        // Grain 0 is allocated (GTE != 0).
1678        assert!(
1679            reader.is_allocated(0).expect("is_allocated lba=0"),
1680            "grain 0 must be allocated"
1681        );
1682        // Grain 1 and beyond: GTE == 0 (sparse).
1683        let grain_sectors = GRAIN_SIZE_BYTES as u64 / 512;
1684        assert!(
1685            !reader
1686                .is_allocated(grain_sectors)
1687                .expect("is_allocated lba=grain_sectors"),
1688            "grain 1 must be sparse"
1689        );
1690    }
1691
1692    #[test]
1693    fn lba_beyond_disk_is_not_allocated() {
1694        let vmdk = test_sparse_vmdk(&[0u8; 512]);
1695        let mut reader = VmdkReader::open(Cursor::new(vmdk)).expect("open");
1696        let beyond = reader.sector_count() + 1;
1697        assert!(
1698            !reader
1699                .is_allocated(beyond)
1700                .expect("is_allocated beyond end"),
1701            "LBA beyond virtual disk must be not-allocated"
1702        );
1703    }
1704
1705    #[test]
1706    fn iter_allocated_grains_yields_grain_zero() {
1707        let vmdk = test_sparse_vmdk(&[0u8; 512]);
1708        let mut reader = VmdkReader::open(Cursor::new(vmdk)).expect("open");
1709        let grains = reader
1710            .iter_allocated_grains()
1711            .expect("iter_allocated_grains");
1712        assert_eq!(grains.len(), 1, "only grain 0 is allocated");
1713        assert_eq!(grains[0].start_lba, 0);
1714        assert_eq!(grains[0].sector_count, GRAIN_SIZE_BYTES as u64 / 512);
1715    }
1716
1717    #[test]
1718    fn iter_allocated_grains_all_sparse_returns_empty() {
1719        let vmdk = gd_at_end_stream_opt_vmdk(); // all-sparse streamOptimized
1720        let mut reader = VmdkReader::open(Cursor::new(vmdk)).expect("open");
1721        let grains = reader
1722            .iter_allocated_grains()
1723            .expect("iter_allocated_grains");
1724        assert!(
1725            grains.is_empty(),
1726            "all-sparse VMDK must yield no allocated grains"
1727        );
1728    }
1729
1730    // ── VmdkInfo / metadata API ───────────────────────────────────────────────
1731
1732    #[test]
1733    fn sector_count_is_virtual_size_over_512() {
1734        let vmdk = test_sparse_vmdk(&[0u8; 512]);
1735        let reader = VmdkReader::open(Cursor::new(vmdk)).expect("open");
1736        assert_eq!(reader.sector_count() * 512, reader.virtual_disk_size());
1737    }
1738
1739    #[test]
1740    fn descriptor_text_contains_create_type() {
1741        let vmdk = test_sparse_vmdk(&[0u8; 512]);
1742        let reader = VmdkReader::open(Cursor::new(vmdk)).expect("open");
1743        let text = reader.descriptor_text();
1744        assert!(
1745            text.contains("monolithicSparse"),
1746            "descriptor_text must contain createType; got: {text:?}"
1747        );
1748    }
1749
1750    #[test]
1751    fn info_disk_type_matches_disk_type_method() {
1752        let vmdk = test_sparse_vmdk(&[0u8; 512]);
1753        let reader = VmdkReader::open(Cursor::new(vmdk)).expect("open");
1754        let info = reader.info();
1755        assert_eq!(info.disk_type, reader.disk_type());
1756    }
1757
1758    #[test]
1759    fn info_virtual_disk_size_and_sector_count_consistent() {
1760        let vmdk = test_sparse_vmdk(&[0u8; 512]);
1761        let reader = VmdkReader::open(Cursor::new(vmdk)).expect("open");
1762        let info = reader.info();
1763        assert_eq!(info.virtual_disk_size, reader.virtual_disk_size());
1764        assert_eq!(info.sector_count * 512, info.virtual_disk_size);
1765    }
1766
1767    #[test]
1768    fn info_grain_size_bytes_is_sectors_times_512() {
1769        let vmdk = test_sparse_vmdk(&[0u8; 512]);
1770        let reader = VmdkReader::open(Cursor::new(vmdk)).expect("open");
1771        let info = reader.info();
1772        assert_eq!(info.grain_size_bytes, info.grain_size_sectors * 512);
1773        assert!(
1774            info.grain_size_sectors >= 8,
1775            "grain_size_sectors must meet VDF 1.1 minimum"
1776        );
1777    }
1778
1779    #[test]
1780    fn info_cid_parsed_from_descriptor() {
1781        // testutil embeds CID=fffffffe in the descriptor.
1782        let vmdk = test_sparse_vmdk(&[0u8; 512]);
1783        let reader = VmdkReader::open(Cursor::new(vmdk)).expect("open");
1784        let info = reader.info();
1785        assert_eq!(
1786            info.cid, 0xffff_fffe,
1787            "CID must be parsed from embedded descriptor"
1788        );
1789        assert_eq!(
1790            info.parent_cid, 0xffff_ffff,
1791            "parentCID must be 0xffffffff (no parent) for a base image"
1792        );
1793    }
1794
1795    #[test]
1796    fn info_version_is_one_for_monolithic_sparse() {
1797        let vmdk = test_sparse_vmdk(&[0u8; 512]);
1798        let reader = VmdkReader::open(Cursor::new(vmdk)).expect("open");
1799        let info = reader.info();
1800        assert_eq!(info.version, 1);
1801        assert!(!info.compressed);
1802    }
1803
1804    // ── Fuzz / malicious-input defence ───────────────────────────────────────
1805
1806    #[test]
1807    fn compressed_grain_oversized_data_size_returns_invaliddata() {
1808        let vmdk = compressed_vmdk_with_oversized_marker(4 * 1024 * 1024);
1809        let mut reader = VmdkReader::open(Cursor::new(vmdk))
1810            .expect("VMDK with oversized marker must open — error only on read");
1811        let mut buf = [0u8; 512];
1812        let err = reader
1813            .read(&mut buf)
1814            .expect_err("oversized data_size must return Err");
1815        assert_eq!(
1816            err.kind(),
1817            io::ErrorKind::InvalidData,
1818            "must return InvalidData from cap check, not UnexpectedEof from allocation attempt"
1819        );
1820    }
1821
1822    #[test]
1823    fn grain_size_below_spec_minimum_is_rejected() {
1824        let mut hdr = vec![0u8; 512];
1825        hdr[0..4].copy_from_slice(&0x564D_444B_u32.to_le_bytes());
1826        hdr[4..8].copy_from_slice(&1u32.to_le_bytes());
1827        hdr[12..20].copy_from_slice(&128u64.to_le_bytes()); // capacity = 128 sectors
1828        hdr[20..28].copy_from_slice(&4u64.to_le_bytes()); // grain_size = 4 (below VDF 1.1 minimum of 8)
1829        hdr[44..48].copy_from_slice(&512u32.to_le_bytes()); // num_gtes_per_gt
1830        let result = VmdkReader::open(Cursor::new(hdr));
1831        assert!(
1832            result.is_err(),
1833            "grain_size=4 is below VDF 1.1 minimum of 8 sectors; open must return Err"
1834        );
1835    }
1836
1837    proptest::proptest! {
1838        #[test]
1839        fn open_never_panics_on_stream_opt_magic_plus_garbage(
1840            suffix in proptest::collection::vec(proptest::prelude::any::<u8>(), 0..8192)
1841        ) {
1842            let mut bytes = vec![0u8; 8];
1843            bytes[0..4].copy_from_slice(&0x564D_444B_u32.to_le_bytes());
1844            bytes[4..8].copy_from_slice(&3u32.to_le_bytes()); // version = 3 (streamOptimized path)
1845            bytes.extend_from_slice(&suffix);
1846            let _ = VmdkReader::open(Cursor::new(bytes));
1847        }
1848    }
1849
1850    /// Locate `qemu-img` portably (PATH-style common locations) for cross-validation
1851    /// tests; `None` (→ skip) only when it is genuinely not installed.
1852    fn qemu_img() -> Option<&'static str> {
1853        [
1854            "/opt/homebrew/bin/qemu-img",
1855            "/usr/bin/qemu-img",
1856            "/usr/local/bin/qemu-img",
1857        ]
1858        .into_iter()
1859        .find(|p| std::path::Path::new(p).exists())
1860    }
1861
1862    #[test]
1863    fn reads_match_qemu_raw_convert() {
1864        use std::fs::File;
1865        let Some(qemu_img) = qemu_img() else {
1866            return;
1867        };
1868        let tmp = tempfile::tempdir().expect("tempdir");
1869        let size: usize = 1 << 20;
1870        let raw_data: Vec<u8> = (0..size).map(|i| (i ^ (i >> 8)) as u8).collect();
1871        let raw_path = tmp.path().join("source.raw");
1872        std::fs::write(&raw_path, &raw_data).expect("write raw");
1873        let vmdk_path = tmp.path().join("test.vmdk");
1874        let status = std::process::Command::new(qemu_img)
1875            .args([
1876                "convert",
1877                "-O",
1878                "vmdk",
1879                raw_path.to_str().expect("UTF-8 path"),
1880                vmdk_path.to_str().expect("UTF-8 path"),
1881            ])
1882            .status()
1883            .expect("spawn qemu-img");
1884        assert!(status.success(), "qemu-img convert failed");
1885        let file = File::open(&vmdk_path).expect("open vmdk file");
1886        let mut reader = VmdkReader::open(file).expect("open");
1887        assert_eq!(reader.virtual_disk_size(), size as u64);
1888        let grain = 512 * 128;
1889        for &offset in &[0usize, 511, grain, grain + 512, size - 512] {
1890            let len = 512.min(size - offset);
1891            let mut buf = vec![0u8; len];
1892            reader.seek(SeekFrom::Start(offset as u64)).expect("seek");
1893            reader.read_exact(&mut buf).expect("read");
1894            assert_eq!(
1895                buf,
1896                raw_data[offset..offset + len],
1897                "byte mismatch at {offset:#x}"
1898            );
1899        }
1900    }
1901
1902    #[test]
1903    fn corpus_dfvfs_ext2_vmdk_reads_match_qemu_raw_convert() {
1904        use std::fs::File;
1905        let Some(qemu_img) = qemu_img() else {
1906            return;
1907        };
1908        let corpus =
1909            std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../tests/data/dfvfs_ext2.vmdk");
1910        if !corpus.exists() {
1911            return;
1912        }
1913        let tmp = tempfile::tempdir().expect("tempdir");
1914        let raw_path = tmp.path().join("ext2.raw");
1915        let ok = std::process::Command::new(qemu_img)
1916            .args([
1917                "convert",
1918                "-O",
1919                "raw",
1920                corpus.to_str().expect("UTF-8 path"),
1921                raw_path.to_str().expect("UTF-8 path"),
1922            ])
1923            .status()
1924            .expect("spawn qemu-img")
1925            .success();
1926        assert!(ok, "qemu-img convert failed for dfvfs_ext2.vmdk");
1927        let ref_data = std::fs::read(&raw_path).expect("read reference raw");
1928        let file = File::open(&corpus).expect("open dfvfs_ext2.vmdk");
1929        let mut reader = VmdkReader::open(file).expect("open");
1930        assert_eq!(
1931            reader.virtual_disk_size(),
1932            ref_data.len() as u64,
1933            "virtual_disk_size must match qemu-img raw for dfvfs_ext2.vmdk"
1934        );
1935        let vsize = ref_data.len();
1936        let step = 4096usize;
1937        let mut offset = 0usize;
1938        while offset < vsize {
1939            let len = 512.min(vsize - offset);
1940            let mut buf = vec![0u8; len];
1941            reader.seek(SeekFrom::Start(offset as u64)).expect("seek");
1942            reader.read_exact(&mut buf).expect("read");
1943            assert_eq!(
1944                buf,
1945                ref_data[offset..offset + len],
1946                "byte mismatch at {offset:#x} in dfvfs_ext2.vmdk"
1947            );
1948            offset += step;
1949        }
1950    }
1951
1952    #[test]
1953    fn corpus_minimal_vmdk_reads_match_qemu_raw_convert() {
1954        use std::fs::File;
1955        let Some(qemu_img) = qemu_img() else {
1956            return;
1957        };
1958        let corpus =
1959            std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../tests/data/minimal.vmdk");
1960        if !corpus.exists() {
1961            return;
1962        }
1963        let tmp = tempfile::tempdir().expect("tempdir");
1964        let raw_path = tmp.path().join("minimal.raw");
1965        let ok = std::process::Command::new(qemu_img)
1966            .args([
1967                "convert",
1968                "-O",
1969                "raw",
1970                corpus.to_str().expect("UTF-8 path"),
1971                raw_path.to_str().expect("UTF-8 path"),
1972            ])
1973            .status()
1974            .expect("spawn qemu-img")
1975            .success();
1976        assert!(ok, "qemu-img convert failed");
1977        let ref_data = std::fs::read(&raw_path).expect("read raw");
1978        let file = File::open(&corpus).expect("open corpus vmdk");
1979        let mut reader = VmdkReader::open(file).expect("open");
1980        assert_eq!(reader.virtual_disk_size(), ref_data.len() as u64);
1981        let vsize = ref_data.len();
1982        let grain = 65536usize;
1983        for &offset in &[0usize, 511, grain, grain + 512, vsize - 512] {
1984            let len = 512.min(vsize - offset);
1985            let mut buf = vec![0u8; len];
1986            reader.seek(SeekFrom::Start(offset as u64)).expect("seek");
1987            reader.read_exact(&mut buf).expect("read");
1988            assert_eq!(
1989                buf,
1990                ref_data[offset..offset + len],
1991                "byte mismatch at {offset:#x}"
1992            );
1993        }
1994    }
1995
1996    // ── Coverage: seSparse method branches (is_allocated / iter / integrity) ──
1997
1998    #[test]
1999    fn sesparse_is_allocated_and_iter() {
2000        let mut data = vec![0u8; 512];
2001        data[0] = 0x9A;
2002        let se = test_sesparse_vmdk(&data);
2003        let mut r = VmdkReader::open(Cursor::new(se)).expect("open");
2004        assert!(r.is_allocated(0).expect("grain 0 allocated"));
2005        assert!(!r
2006            .is_allocated(10_000)
2007            .expect("out-of-bounds lba is unallocated"));
2008        let grains = r.iter_allocated_grains().expect("iter");
2009        assert_eq!(grains.len(), 1);
2010        assert_eq!(grains[0].start_lba, 0);
2011    }
2012
2013    #[test]
2014    fn sesparse_invalid_gd_marker_errors_on_is_allocated() {
2015        // Corrupt GD[0] (sector 2) so its allocated nibble is wrong → se_read_gte errors.
2016        let mut se = test_sesparse_vmdk(&[0u8; 512]);
2017        let gd = 2 * 512;
2018        se[gd..gd + 8].copy_from_slice(&0x5000_0000_0000_0000u64.to_le_bytes());
2019        let mut r = VmdkReader::open(Cursor::new(se)).expect("open");
2020        let err = r.is_allocated(0).expect_err("invalid GD marker must error");
2021        assert_eq!(err.kind(), io::ErrorKind::InvalidData);
2022    }
2023
2024    #[test]
2025    fn sesparse_invalid_gd_marker_skipped_in_iter() {
2026        let mut se = test_sesparse_vmdk(&[0u8; 512]);
2027        let gd = 2 * 512;
2028        se[gd..gd + 8].copy_from_slice(&0x5000_0000_0000_0000u64.to_le_bytes());
2029        let mut r = VmdkReader::open(Cursor::new(se)).expect("open");
2030        assert!(r.iter_allocated_grains().expect("iter").is_empty());
2031    }
2032
2033    // ── Coverage: Flat reader is_allocated / iter_allocated_grains ────────────
2034
2035    fn open_flat_descriptor(dir: &std::path::Path, data: &[u8]) -> VmdkFileReader {
2036        use std::io::Write as _;
2037        let sectors = data.len().div_ceil(512).max(1);
2038        let mut ext = vec![0u8; sectors * 512];
2039        ext[..data.len()].copy_from_slice(data);
2040        std::fs::File::create(dir.join("disk-f001.vmdk"))
2041            .unwrap()
2042            .write_all(&ext)
2043            .unwrap();
2044        let desc = format!(
2045            "# Disk DescriptorFile\nversion=1\nCID=ffffffff\nparentCID=ffffffff\ncreateType=\"monolithicFlat\"\nRW {sectors} FLAT \"disk-f001.vmdk\" 0\n"
2046        );
2047        let desc_path = dir.join("disk.vmdk");
2048        std::fs::write(&desc_path, desc.as_bytes()).unwrap();
2049        VmdkFileReader::open_path(&desc_path).expect("open flat")
2050    }
2051
2052    #[test]
2053    fn flat_is_allocated_and_iter() {
2054        let dir = tempfile::tempdir().unwrap();
2055        let mut r = open_flat_descriptor(dir.path(), &[1u8; 1024]);
2056        // Every in-bounds sector of a flat extent is allocated.
2057        assert!(r.is_allocated(0).expect("flat lba 0 allocated"));
2058        assert!(r.is_allocated(1).expect("flat lba 1 allocated"));
2059        assert!(!r.is_allocated(10_000).expect("oob unallocated"));
2060        // iter yields the whole disk as one range.
2061        let grains = r.iter_allocated_grains().expect("iter");
2062        assert_eq!(grains.len(), 1);
2063        assert_eq!(grains[0].start_lba, 0);
2064        assert_eq!(grains[0].sector_count, 2);
2065    }
2066
2067    #[test]
2068    fn sesparse_sparse_grain_directory_entry_reads_zero() {
2069        // Widen capacity so a second, sparse (GD[1] == 0) grain-directory entry is
2070        // in-bounds — exercises the seSparse sparse-entry read / is_allocated / iter paths.
2071        let mut se = test_sesparse_vmdk(&[0xAB; 512]);
2072        let cap = (sesparse::SE_GTES_PER_GT + 1) * 8; // 4097 grains × 8 sectors
2073        se[16..24].copy_from_slice(&cap.to_le_bytes()); // seSparse capacity field
2074        let mut r = VmdkReader::open(Cursor::new(se)).expect("open");
2075        let lba = sesparse::SE_GTES_PER_GT * 8; // first LBA in the second GD entry
2076        assert!(!r.is_allocated(lba).expect("is_allocated"));
2077        assert_eq!(r.iter_allocated_grains().expect("iter").len(), 1);
2078        r.seek(SeekFrom::Start(lba * 512)).expect("seek");
2079        let mut buf = [0xFFu8; 512];
2080        r.read_exact(&mut buf).expect("read");
2081        assert_eq!(buf, [0u8; 512]);
2082    }
2083
2084    #[test]
2085    fn grain_location_and_grain_size_on_flat_reader() {
2086        let dir = tempfile::tempdir().unwrap();
2087        let mut r = open_flat_descriptor(dir.path(), &[1u8; 1024]);
2088        // grain_location is never called for Flat on the read path; calling it directly
2089        // exercises the "not reached" guard.
2090        assert!(matches!(
2091            r.grain_location(0).expect("loc"),
2092            crate::read::GrainLookup::Sparse
2093        ));
2094        assert_eq!(r.sparse_grain_size_bytes(), 0);
2095    }
2096
2097    // ── Coverage: accessors, format-specific branches, open_path arms ─────────
2098
2099    #[test]
2100    fn cid_and_parent_cid_accessors() {
2101        let vmdk = test_sparse_vmdk(&[0u8; 512]);
2102        let r = VmdkReader::open(Cursor::new(vmdk)).expect("open");
2103        assert_eq!(r.cid(), 0xffff_fffe); // testutil embeds CID=fffffffe
2104        assert_eq!(r.parent_cid(), 0xffff_ffff);
2105    }
2106
2107    #[test]
2108    fn disk_database_accessor_and_info() {
2109        let desc = "# Disk DescriptorFile\nversion=1\nCID=12345678\nparentCID=ffffffff\ncreateType=\"monolithicSparse\"\nddb.adapterType = \"lsilogic\"\nddb.geometry.cylinders = \"1024\"\nddb.geometry.heads = \"16\"\nddb.geometry.sectors = \"63\"\nddb.virtualHWVersion = \"13\"\nddb.thinProvisioned = \"1\"\n";
2110        let vmdk = testutil::test_sparse_vmdk_with_descriptor(&[0u8; 512], desc);
2111        let r = VmdkReader::open(Cursor::new(vmdk)).expect("open");
2112        let db = r.disk_database();
2113        assert_eq!(db.adapter_type.as_deref(), Some("lsilogic"));
2114        assert_eq!(db.virtual_hw_version.as_deref(), Some("13"));
2115        assert_eq!(db.thin_provisioned, Some(true));
2116        assert_eq!(db.geometry.unwrap().chs_sectors(), 1024 * 16 * 63);
2117        // Also surfaced through info().
2118        assert_eq!(r.info().disk_database, db);
2119    }
2120
2121    #[test]
2122    fn disk_database_empty_for_descriptorless_image() {
2123        let vmdk = test_sparse_vmdk(&[0u8; 512]); // descriptor has no ddb section
2124        let r = VmdkReader::open(Cursor::new(vmdk)).expect("open");
2125        assert!(r.disk_database().is_empty());
2126    }
2127
2128    #[test]
2129    fn change_track_path_reference() {
2130        let desc = "# Disk DescriptorFile\nversion=1\nCID=12345678\nparentCID=ffffffff\ncreateType=\"monolithicSparse\"\nchangeTrackPath=\"disk-ctk.vmdk\"\n";
2131        let vmdk = testutil::test_sparse_vmdk_with_descriptor(&[0u8; 512], desc);
2132        let r = VmdkReader::open(Cursor::new(vmdk)).expect("open");
2133        assert_eq!(r.change_track_path().as_deref(), Some("disk-ctk.vmdk"));
2134    }
2135
2136    #[test]
2137    fn change_track_path_absent() {
2138        let vmdk = test_sparse_vmdk(&[0u8; 512]);
2139        let r = VmdkReader::open(Cursor::new(vmdk)).expect("open");
2140        assert_eq!(r.change_track_path(), None);
2141    }
2142
2143    #[test]
2144    fn effective_content_id_uses_long_cid_on_sentinel() {
2145        // CID=fffffffe is the "use the long content identifier" sentinel.
2146        let desc = "# Disk DescriptorFile\nversion=1\nCID=fffffffe\nparentCID=ffffffff\ncreateType=\"monolithicSparse\"\nddb.longContentID = \"deadbeefcafef00d1122334455667788\"\n";
2147        let vmdk = testutil::test_sparse_vmdk_with_descriptor(&[0u8; 512], desc);
2148        let r = VmdkReader::open(Cursor::new(vmdk)).expect("open");
2149        assert_eq!(r.cid(), 0xffff_fffe);
2150        assert_eq!(r.effective_content_id(), "deadbeefcafef00d1122334455667788");
2151    }
2152
2153    #[test]
2154    fn effective_content_id_uses_short_cid_normally() {
2155        let desc = "# Disk DescriptorFile\nversion=1\nCID=12345678\nparentCID=ffffffff\ncreateType=\"monolithicSparse\"\n";
2156        let vmdk = testutil::test_sparse_vmdk_with_descriptor(&[0u8; 512], desc);
2157        let r = VmdkReader::open(Cursor::new(vmdk)).expect("open");
2158        assert_eq!(r.effective_content_id(), "12345678");
2159    }
2160
2161    #[test]
2162    fn rgd_fallback_recovers_grain_from_corrupt_primary_gd() {
2163        // Corrupt the primary GD entry (point it out of bounds) but leave the RGD and
2164        // the grain table it references intact. With RGD fallback enabled the grain is
2165        // still readable via the redundant directory — recovery qemu-img cannot do.
2166        let mut vmdk = test_sparse_vmdk(&[0xAB; 512]);
2167        let gd_byte = 21 * 512; // primary GD sector
2168        vmdk[gd_byte..gd_byte + 4].copy_from_slice(&0xFFFF_FFFFu32.to_le_bytes());
2169        let mut r = VmdkReader::open(Cursor::new(vmdk)).expect("open");
2170        r.enable_rgd_fallback();
2171        let mut buf = [0u8; 512];
2172        r.read_exact(&mut buf).expect("resilient read via RGD");
2173        assert_eq!(buf, [0xAB; 512], "grain recovered from redundant GD");
2174    }
2175
2176    #[test]
2177    fn corrupt_primary_gd_without_fallback_errors() {
2178        // Same corruption, but fallback is opt-in: without it the dangling primary
2179        // pointer makes the read fail (the safe, unsurprising default).
2180        let mut vmdk = test_sparse_vmdk(&[0xAB; 512]);
2181        let gd_byte = 21 * 512;
2182        vmdk[gd_byte..gd_byte + 4].copy_from_slice(&0xFFFF_FFFFu32.to_le_bytes());
2183        let mut r = VmdkReader::open(Cursor::new(vmdk)).expect("open");
2184        let mut buf = [0u8; 512];
2185        assert!(
2186            r.read_exact(&mut buf).is_err(),
2187            "dangling primary GD pointer must error without fallback"
2188        );
2189    }
2190
2191    /// Build a two-copy sparse VMDK where the *primary* grain table has its GTE[0]
2192    /// zeroed (a lost grain pointer) but the *redundant* grain table still holds the
2193    /// valid pointer. Layout (sectors): 0 header, 1..21 descriptor, 21 primary GD,
2194    /// 22 RGD, 23..27 primary GT (GTE[0]=0), 27..31 redundant GT (GTE[0]=31),
2195    /// 31..39 grain (0xAB).
2196    fn two_copy_vmdk_with_lost_primary_gte() -> Vec<u8> {
2197        const S: usize = 512;
2198        let mut hdr = vec![0u8; S];
2199        hdr[0..4].copy_from_slice(&header::MAGIC.to_le_bytes());
2200        hdr[4..8].copy_from_slice(&1u32.to_le_bytes());
2201        hdr[12..20].copy_from_slice(&8u64.to_le_bytes()); // capacity (1 grain)
2202        hdr[20..28].copy_from_slice(&8u64.to_le_bytes()); // grain_size
2203        hdr[28..36].copy_from_slice(&1u64.to_le_bytes()); // descriptor_offset
2204        hdr[36..44].copy_from_slice(&20u64.to_le_bytes()); // descriptor_size
2205        hdr[44..48].copy_from_slice(&512u32.to_le_bytes()); // num_gtes_per_gt
2206        hdr[48..56].copy_from_slice(&22u64.to_le_bytes()); // rgd_offset
2207        hdr[56..64].copy_from_slice(&21u64.to_le_bytes()); // gd_offset
2208        hdr[64..72].copy_from_slice(&31u64.to_le_bytes()); // overhead
2209        hdr[73..77].copy_from_slice(&[0x0A, 0x20, 0x0D, 0x0A]);
2210
2211        let mut desc = vec![0u8; 20 * S];
2212        let text = "# Disk DescriptorFile\nversion=1\nCID=12345678\nparentCID=ffffffff\ncreateType=\"monolithicSparse\"\n";
2213        desc[..text.len()].copy_from_slice(text.as_bytes());
2214
2215        let mut gd = vec![0u8; S];
2216        gd[0..4].copy_from_slice(&23u32.to_le_bytes()); // primary GT @ sector 23
2217        let mut rgd = vec![0u8; S];
2218        rgd[0..4].copy_from_slice(&27u32.to_le_bytes()); // redundant GT @ sector 27
2219
2220        let primary_gt = vec![0u8; 4 * S]; // GTE[0] = 0 — lost pointer
2221        let mut redundant_gt = vec![0u8; 4 * S];
2222        redundant_gt[0..4].copy_from_slice(&31u32.to_le_bytes()); // grain @ sector 31
2223
2224        let grain = vec![0xABu8; 8 * S];
2225
2226        let mut v = Vec::new();
2227        v.extend_from_slice(&hdr);
2228        v.extend_from_slice(&desc);
2229        v.extend_from_slice(&gd);
2230        v.extend_from_slice(&rgd);
2231        v.extend_from_slice(&primary_gt);
2232        v.extend_from_slice(&redundant_gt);
2233        v.extend_from_slice(&grain);
2234        v
2235    }
2236
2237    #[test]
2238    fn rgd_fallback_recovers_grain_from_lost_primary_gte() {
2239        let vmdk = two_copy_vmdk_with_lost_primary_gte();
2240        // Without fallback the lost primary GTE reads as sparse (zeros).
2241        let mut r = VmdkReader::open(Cursor::new(vmdk.clone())).expect("open");
2242        let mut buf = [0xFFu8; 512];
2243        r.read_exact(&mut buf).expect("read");
2244        assert_eq!(
2245            buf, [0u8; 512],
2246            "lost primary GTE reads sparse without recovery"
2247        );
2248        // With fallback the grain is recovered from the redundant grain table.
2249        let mut r = VmdkReader::open(Cursor::new(vmdk)).expect("open");
2250        r.enable_rgd_fallback();
2251        let mut buf = [0u8; 512];
2252        r.read_exact(&mut buf).expect("read");
2253        assert_eq!(buf, [0xAB; 512], "grain recovered from redundant GT entry");
2254    }
2255
2256    #[test]
2257    fn iter_allocated_grains_recovers_via_rgd() {
2258        // The allocation scan walks the grain directory directly; a damaged primary GD
2259        // pointer errors the scan, but RGD fallback recovers the map via the redundant GD.
2260        let mut vmdk = test_sparse_vmdk(&[0xAB; 512]);
2261        let gd_byte = 21 * 512;
2262        vmdk[gd_byte..gd_byte + 4].copy_from_slice(&0xFFFF_FFFFu32.to_le_bytes());
2263        {
2264            let mut r = VmdkReader::open(Cursor::new(vmdk.clone())).expect("open");
2265            assert!(
2266                r.iter_allocated_grains().is_err(),
2267                "dangling primary GD pointer errors the scan without fallback"
2268            );
2269        }
2270        let mut r = VmdkReader::open(Cursor::new(vmdk)).expect("open");
2271        r.enable_rgd_fallback();
2272        let grains = r
2273            .iter_allocated_grains()
2274            .expect("allocation map recovered via RGD");
2275        assert_eq!(grains.len(), 1);
2276        assert_eq!(grains[0].start_lba, 0);
2277    }
2278
2279    #[test]
2280    fn iter_allocated_grains_recovers_lost_primary_gte() {
2281        // A grain whose primary GT entry is lost should be listed by the allocation
2282        // scan under recovery (consistent with dump/hash --recover being able to read it).
2283        let vmdk = two_copy_vmdk_with_lost_primary_gte();
2284        {
2285            let mut r = VmdkReader::open(Cursor::new(vmdk.clone())).expect("open");
2286            assert_eq!(
2287                r.iter_allocated_grains().expect("scan").len(),
2288                0,
2289                "lost primary GTE is not listed without recovery"
2290            );
2291        }
2292        let mut r = VmdkReader::open(Cursor::new(vmdk)).expect("open");
2293        r.enable_rgd_fallback();
2294        let grains = r.iter_allocated_grains().expect("scan");
2295        assert_eq!(grains.len(), 1, "lost GTE recovered from redundant GT");
2296        assert_eq!(grains[0].start_lba, 0);
2297    }
2298
2299    #[test]
2300    fn rgd_recovery_count_tracks_pointer_recovery() {
2301        // Pointer-level recovery: a corrupt primary GD pointer counts one recovered grain.
2302        let mut vmdk = test_sparse_vmdk(&[0xAB; 512]);
2303        let gd_byte = 21 * 512;
2304        vmdk[gd_byte..gd_byte + 4].copy_from_slice(&0xFFFF_FFFFu32.to_le_bytes());
2305        let mut r = VmdkReader::open(Cursor::new(vmdk)).expect("open");
2306        r.enable_rgd_fallback();
2307        assert_eq!(r.rgd_recovery_count(), 0);
2308        let mut buf = [0u8; 512];
2309        r.read_exact(&mut buf).expect("read");
2310        assert_eq!(
2311            r.rgd_recovery_count(),
2312            1,
2313            "one grain recovered via RGD pointer"
2314        );
2315    }
2316
2317    #[test]
2318    fn rgd_recovery_count_tracks_entry_recovery() {
2319        // Content-level recovery: a lost primary GT entry counts one recovered grain.
2320        let vmdk = two_copy_vmdk_with_lost_primary_gte();
2321        let mut r = VmdkReader::open(Cursor::new(vmdk)).expect("open");
2322        r.enable_rgd_fallback();
2323        let mut buf = [0u8; 512];
2324        r.read_exact(&mut buf).expect("read");
2325        assert_eq!(
2326            r.rgd_recovery_count(),
2327            1,
2328            "one grain recovered via RGD entry"
2329        );
2330    }
2331
2332    #[test]
2333    fn rgd_recovery_count_zero_on_healthy_image() {
2334        let vmdk = test_sparse_vmdk(&[0xAB; 512]);
2335        let mut r = VmdkReader::open(Cursor::new(vmdk)).expect("open");
2336        r.enable_rgd_fallback();
2337        let mut buf = [0u8; 512];
2338        r.read_exact(&mut buf).expect("read");
2339        assert_eq!(
2340            r.rgd_recovery_count(),
2341            0,
2342            "healthy read uses the primary GD"
2343        );
2344    }
2345
2346    #[test]
2347    fn rgd_recovery_count_in_allocation_scan() {
2348        let vmdk = two_copy_vmdk_with_lost_primary_gte();
2349        let mut r = VmdkReader::open(Cursor::new(vmdk)).expect("open");
2350        r.enable_rgd_fallback();
2351        let _ = r.iter_allocated_grains().expect("scan");
2352        assert_eq!(r.rgd_recovery_count(), 1, "scan counts the recovered grain");
2353    }
2354
2355    #[test]
2356    fn open_rejects_capacity_overflow() {
2357        // capacity * 512 overflows u64 → GeometryOverflow rather than a panic.
2358        let mut vmdk = test_sparse_vmdk(&[0u8; 512]);
2359        vmdk[12..20].copy_from_slice(&u64::MAX.to_le_bytes());
2360        assert!(matches!(
2361            VmdkReader::open(Cursor::new(vmdk)),
2362            Err(VmdkError::GeometryOverflow { field: "capacity" })
2363        ));
2364    }
2365
2366    #[test]
2367    fn content_recovery_with_no_rgd_offset_reads_sparse() {
2368        // Primary GT entry lost + no RGD: content recovery finds nothing, stays sparse.
2369        // Exercises rgd_dir_entry (rgd_offset == 0) and rgd_gte (sector == 0) guards.
2370        let mut vmdk = test_sparse_vmdk(&[0xAB; 512]);
2371        vmdk[23 * 512..23 * 512 + 4].copy_from_slice(&0u32.to_le_bytes()); // primary GTE[0] = 0
2372        vmdk[48..56].copy_from_slice(&0u64.to_le_bytes()); // rgd_offset = 0
2373        let mut r = VmdkReader::open(Cursor::new(vmdk)).expect("open");
2374        r.enable_rgd_fallback();
2375        let mut buf = [0xFFu8; 512];
2376        r.read_exact(&mut buf).expect("read");
2377        assert_eq!(buf, [0u8; 512]);
2378    }
2379
2380    #[test]
2381    fn fallback_with_out_of_bounds_rgd_offset_is_safe() {
2382        // Corrupt primary GD + an rgd_offset that points past EOF: the RGD entry read is
2383        // bounds-checked (rgd_dir_entry / read_redundant_gt return 0/None), no panic.
2384        let mut vmdk = test_sparse_vmdk(&[0xAB; 512]);
2385        vmdk[21 * 512..21 * 512 + 4].copy_from_slice(&0xFFFF_FFFFu32.to_le_bytes());
2386        vmdk[48..56].copy_from_slice(&9_999_999u64.to_le_bytes());
2387        let mut r = VmdkReader::open(Cursor::new(vmdk)).expect("open");
2388        r.enable_rgd_fallback();
2389        let _ = r.iter_allocated_grains();
2390    }
2391
2392    #[test]
2393    fn fallback_scan_with_rgd_gt_past_eof_lists_primary() {
2394        // RGD entry points to a grain table past EOF: read_redundant_gt rejects it, but
2395        // the (valid) primary grain table is still scanned.
2396        let mut vmdk = test_sparse_vmdk(&[0xAB; 512]);
2397        vmdk[22 * 512..22 * 512 + 4].copy_from_slice(&9_999_999u32.to_le_bytes());
2398        let mut r = VmdkReader::open(Cursor::new(vmdk)).expect("open");
2399        r.enable_rgd_fallback();
2400        let grains = r.iter_allocated_grains().expect("scan");
2401        assert_eq!(grains.len(), 1);
2402    }
2403
2404    #[test]
2405    fn content_recovery_with_rgd_gt_past_eof_reads_sparse() {
2406        // Primary GT entry lost + the redundant GT pointer is past EOF: rgd_gte rejects
2407        // it and the grain stays sparse (no panic, no out-of-bounds read).
2408        let mut vmdk = test_sparse_vmdk(&[0xAB; 512]);
2409        vmdk[23 * 512..23 * 512 + 4].copy_from_slice(&0u32.to_le_bytes());
2410        vmdk[22 * 512..22 * 512 + 4].copy_from_slice(&9_999_999u32.to_le_bytes());
2411        let mut r = VmdkReader::open(Cursor::new(vmdk)).expect("open");
2412        r.enable_rgd_fallback();
2413        let mut buf = [0xFFu8; 512];
2414        r.read_exact(&mut buf).expect("read");
2415        assert_eq!(buf, [0u8; 512]);
2416    }
2417
2418    #[test]
2419    fn rgd_fallback_is_noop_on_healthy_image() {
2420        // Enabling fallback must not change reads on an intact image.
2421        let vmdk = test_sparse_vmdk(&[0xAB; 512]);
2422        let mut r = VmdkReader::open(Cursor::new(vmdk)).expect("open");
2423        r.enable_rgd_fallback();
2424        let mut buf = [0u8; 512];
2425        r.read_exact(&mut buf).expect("read healthy image");
2426        assert_eq!(buf, [0xAB; 512]);
2427    }
2428
2429    #[test]
2430    fn info_on_sesparse() {
2431        let se = test_sesparse_vmdk(&[0u8; 512]);
2432        let r = VmdkReader::open(Cursor::new(se)).expect("open");
2433        let info = r.info();
2434        assert_eq!(info.disk_type, "seSparse");
2435        assert_eq!(info.grain_size_bytes, 8 * 512);
2436    }
2437
2438    #[test]
2439    fn open_rejects_grain_directory_too_large() {
2440        // A monolithicSparse header with an enormous capacity → GD exceeds 16 MiB.
2441        let img = vmdk_header_bytes(1_000_000_000_000, 8, 512);
2442        assert!(matches!(
2443            VmdkReader::open(Cursor::new(img)),
2444            Err(VmdkError::FieldOutOfRange {
2445                field: "grain_directory",
2446                ..
2447            })
2448        ));
2449    }
2450
2451    /// Patch seSparse GTE[0] (grain table at sector 3, first entry) to `gte`.
2452    fn sesparse_with_gte0(gte: u64) -> Vec<u8> {
2453        let mut se = test_sesparse_vmdk(&[0xABu8; 512]);
2454        let gt = 3 * 512; // GT_OFFSET sector in testutil layout
2455        se[gt..gt + 8].copy_from_slice(&gte.to_le_bytes());
2456        se
2457    }
2458
2459    #[test]
2460    fn sesparse_zero_unmapped_and_empty_gtes_read_as_zeros() {
2461        for gte in [0u64, 0x1000_0000_0000_0000, 0x2000_0000_0000_0000] {
2462            let mut r = VmdkReader::open(Cursor::new(sesparse_with_gte0(gte))).expect("open");
2463            r.seek(SeekFrom::Start(0)).unwrap();
2464            let mut buf = [0xFFu8; 512];
2465            r.read_exact(&mut buf).expect("read");
2466            assert_eq!(buf, [0u8; 512], "gte {gte:#x} must read as zeros");
2467        }
2468    }
2469
2470    #[test]
2471    fn sesparse_unsupported_type_nibble_errors_on_read() {
2472        // Nibble 0x4 is not a defined seSparse grain type.
2473        let mut r =
2474            VmdkReader::open(Cursor::new(sesparse_with_gte0(0x4000_0000_0000_0000))).expect("open");
2475        let mut buf = [0u8; 512];
2476        let err = r.read(&mut buf).expect_err("unsupported nibble must error");
2477        assert_eq!(err.kind(), io::ErrorKind::InvalidData);
2478    }
2479
2480    #[test]
2481    fn custom_create_type_with_sparse_extent_opens() {
2482        use std::io::Write as _;
2483        let dir = tempfile::tempdir().unwrap();
2484        let ext = test_sparse_vmdk(&[0xC5u8; 512]);
2485        std::fs::File::create(dir.path().join("disk-s001.vmdk"))
2486            .unwrap()
2487            .write_all(&ext)
2488            .unwrap();
2489        let desc = "# Disk DescriptorFile\nversion=1\nCID=ffffffff\nparentCID=ffffffff\ncreateType=\"custom\"\nRW 8 SPARSE \"disk-s001.vmdk\"\n";
2490        let desc_path = dir.path().join("disk.vmdk");
2491        std::fs::write(&desc_path, desc.as_bytes()).unwrap();
2492        let mut r = VmdkFileReader::open_path(&desc_path).expect("custom+sparse opens");
2493        let mut buf = [0u8; 1];
2494        r.read_exact(&mut buf).expect("read");
2495        assert_eq!(buf[0], 0xC5);
2496    }
2497
2498    #[test]
2499    fn custom_create_type_with_no_extents_errors() {
2500        let dir = tempfile::tempdir().unwrap();
2501        let desc = "# Disk DescriptorFile\nversion=1\nCID=ffffffff\nparentCID=ffffffff\ncreateType=\"custom\"\n";
2502        let desc_path = dir.path().join("disk.vmdk");
2503        std::fs::write(&desc_path, desc.as_bytes()).unwrap();
2504        assert!(matches!(
2505            VmdkFileReader::open_path(&desc_path),
2506            Err(VmdkError::MalformedDescriptor(_))
2507        ));
2508    }
2509
2510    #[test]
2511    fn compressed_grain_decompressing_past_grain_size_is_refused() {
2512        // A streamOptimized grain whose zlib payload expands far beyond the grain
2513        // size is a decompression bomb; reading it must error rather than
2514        // materialize the full expansion in memory.
2515        use std::io::Read as _;
2516        let vmdk = crate::testutil::compressed_vmdk_with_bomb_grain(4 * 1024 * 1024);
2517        let mut r = VmdkReader::open(Cursor::new(vmdk)).expect("open");
2518        let mut buf = [0u8; 512];
2519        assert!(
2520            r.read(&mut buf).is_err(),
2521            "a grain that decompresses beyond its grain size must be refused"
2522        );
2523    }
2524
2525    #[test]
2526    fn descriptor_extent_path_cannot_escape_image_directory() {
2527        // A crafted descriptor must not be able to read files outside the image
2528        // directory via an absolute or `..`-climbing extent path.
2529        let outer = tempfile::tempdir().unwrap();
2530        std::fs::write(outer.path().join("secret.bin"), vec![0u8; 1024]).unwrap();
2531        let img = outer.path().join("img");
2532        std::fs::create_dir(&img).unwrap();
2533        let desc = "# Disk DescriptorFile\nversion=1\nCID=ffffffff\nparentCID=ffffffff\ncreateType=\"twoGbMaxExtentFlat\"\nRW 2 FLAT \"../secret.bin\" 0\n";
2534        let desc_path = img.join("disk.vmdk");
2535        std::fs::write(&desc_path, desc.as_bytes()).unwrap();
2536        // The extent path escapes the image directory — opening it must be refused.
2537        assert!(VmdkFileReader::open_path(&desc_path).is_err());
2538    }
2539
2540    #[test]
2541    fn custom_create_type_with_mixed_extents_errors() {
2542        // A `custom` descriptor listing BOTH a flat and a sparse extent must fail
2543        // loud rather than silently using only the flat extents and dropping the
2544        // sparse ones (silent wrong output / under-reported capacity).
2545        let dir = tempfile::tempdir().unwrap();
2546        let desc = "# Disk DescriptorFile\nversion=1\nCID=ffffffff\nparentCID=ffffffff\ncreateType=\"custom\"\nRW 2048 FLAT \"flat.bin\" 0\nRW 2048 SPARSE \"sparse.vmdk\"\n";
2547        let desc_path = dir.path().join("disk.vmdk");
2548        std::fs::write(&desc_path, desc.as_bytes()).unwrap();
2549        assert!(matches!(
2550            VmdkFileReader::open_path(&desc_path),
2551            Err(VmdkError::MalformedDescriptor(_))
2552        ));
2553    }
2554
2555    #[test]
2556    fn open_path_rejects_unknown_create_type() {
2557        let dir = tempfile::tempdir().unwrap();
2558        let desc = "# Disk DescriptorFile\nversion=1\nCID=ffffffff\nparentCID=ffffffff\ncreateType=\"someFutureFormat\"\n";
2559        let desc_path = dir.path().join("disk.vmdk");
2560        std::fs::write(&desc_path, desc.as_bytes()).unwrap();
2561        assert!(matches!(
2562            VmdkFileReader::open_path(&desc_path),
2563            Err(VmdkError::UnsupportedDiskType(_))
2564        ));
2565    }
2566
2567    /// A monolithicSparse VMDK with `num_gtes_per_gt` GTEs per GT and a zeroed
2568    /// second grain-directory entry, so grain index `num_gtes_per_gt` resolves to
2569    /// `gt_sector` == 0 (the "empty grain table" branch).
2570    fn sparse_with_zero_gd_entry() -> Vec<u8> {
2571        // capacity spans 2 grain-table groups (513 grains); GD has 2 entries.
2572        // GD[0] → a real grain table (grain 0 sparse), GD[1] = 0.
2573        const NGTE: u64 = 512;
2574        const GRAIN: u64 = 8;
2575        let capacity = (NGTE + 1) * GRAIN; // 513 grains
2576        let gd_sector = 1u64;
2577        let gt_sector = 2u64;
2578        let total_sectors = 10u64;
2579        let mut v = vec![0u8; total_sectors as usize * 512];
2580        v[0..4].copy_from_slice(&0x564D_444Bu32.to_le_bytes());
2581        v[4..8].copy_from_slice(&1u32.to_le_bytes());
2582        v[12..20].copy_from_slice(&capacity.to_le_bytes());
2583        v[20..28].copy_from_slice(&GRAIN.to_le_bytes());
2584        v[44..48].copy_from_slice(&(NGTE as u32).to_le_bytes());
2585        v[56..64].copy_from_slice(&gd_sector.to_le_bytes()); // gd_offset
2586                                                             // GD at sector 1: entry0 → gt_sector(2), entry1 → 0 (empty).
2587        let gd = gd_sector as usize * 512;
2588        v[gd..gd + 4].copy_from_slice(&(gt_sector as u32).to_le_bytes());
2589        // GD[1] stays 0. GT at sector 2 is all-zero → grain 0 sparse.
2590        v
2591    }
2592
2593    #[test]
2594    fn sesparse_descriptor_without_extent_errors() {
2595        use std::io::Write as _;
2596        let dir = tempfile::tempdir().unwrap();
2597        let desc = "# Disk DescriptorFile\nversion=1\nCID=ffffffff\nparentCID=ffffffff\ncreateType=\"seSparse\"\n";
2598        let p = dir.path().join("disk.vmdk");
2599        std::fs::File::create(&p)
2600            .unwrap()
2601            .write_all(desc.as_bytes())
2602            .unwrap();
2603        assert!(matches!(
2604            VmdkFileReader::open_path(&p),
2605            Err(VmdkError::MalformedDescriptor(_))
2606        ));
2607    }
2608
2609    #[test]
2610    fn sparse_empty_grain_table_entry_reads_zero_and_iterates_empty() {
2611        let vmdk = sparse_with_zero_gd_entry();
2612        let mut r = VmdkReader::open(Cursor::new(vmdk)).expect("open");
2613        // LBA in the second GD group (grain 512) → gt_sector == 0 branch.
2614        let lba = 512 * 8; // grain 512 start
2615        assert!(!r.is_allocated(lba).expect("is_allocated"));
2616        // Read there → zeros (grain_location gt_sector==0 → Sparse).
2617        r.seek(SeekFrom::Start(lba * 512)).unwrap();
2618        let mut buf = [0xFFu8; 512];
2619        r.read_exact(&mut buf).unwrap();
2620        assert_eq!(buf, [0u8; 512]);
2621        // iter_allocated_grains skips both the sparse GTE and the empty GD entry.
2622        assert!(r.iter_allocated_grains().expect("iter").is_empty());
2623    }
2624
2625    #[test]
2626    fn flat_zero_capacity_iter_is_empty() {
2627        // A ZERO-only flat descriptor with 0 sectors → empty virtual disk → no grains.
2628        use std::io::Write as _;
2629        let dir = tempfile::tempdir().unwrap();
2630        let desc = "# Disk DescriptorFile\nversion=1\nCID=ffffffff\nparentCID=ffffffff\ncreateType=\"monolithicFlat\"\nRW 0 ZERO\n";
2631        let p = dir.path().join("empty.vmdk");
2632        std::fs::File::create(&p)
2633            .unwrap()
2634            .write_all(desc.as_bytes())
2635            .unwrap();
2636        let mut r = VmdkFileReader::open_path(&p).expect("open empty flat");
2637        assert_eq!(r.virtual_disk_size(), 0);
2638        assert!(r.iter_allocated_grains().expect("iter").is_empty());
2639    }
2640}