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// ── Tests ─────────────────────────────────────────────────────────────────────
1013
1014#[cfg(test)]
1015mod tests {
1016    use super::*;
1017    use std::io::Cursor;
1018    use testutil::{
1019        compressed_vmdk_with_oversized_marker, gd_at_end_stream_opt_vmdk, test_cowd_vmdk,
1020        test_sesparse_vmdk, test_sparse_vmdk, GRAIN_SIZE_BYTES,
1021    };
1022
1023    fn vmdk_header_bytes(capacity_sectors: u64, grain_size: u64, num_gtes_per_gt: u32) -> Vec<u8> {
1024        let mut h = vec![0u8; 512];
1025        h[0..4].copy_from_slice(&0x564D_444B_u32.to_le_bytes());
1026        h[4..8].copy_from_slice(&1u32.to_le_bytes());
1027        h[12..20].copy_from_slice(&capacity_sectors.to_le_bytes());
1028        h[20..28].copy_from_slice(&grain_size.to_le_bytes());
1029        h[44..48].copy_from_slice(&num_gtes_per_gt.to_le_bytes());
1030        h
1031    }
1032
1033    // ── Header version 2 (zeroed-grain feature) + ZERO extent type ───────────
1034
1035    #[test]
1036    fn header_version_2_zeroed_grain_opens() {
1037        // VMware images with the zeroed-grain feature carry version=2 + flag bit 2.
1038        // QEMU accepts any VMDK4-magic version; we must accept v2 too, not just 1/3.
1039        let mut vmdk = test_sparse_vmdk(&[0u8; 512]);
1040        vmdk[4..8].copy_from_slice(&2u32.to_le_bytes()); // version = 2
1041        vmdk[8..12].copy_from_slice(&0x0000_0004u32.to_le_bytes()); // VMDK4_FLAG_ZERO_GRAIN
1042        VmdkReader::open(Cursor::new(vmdk))
1043            .expect("version=2 (zeroed-grain) monolithicSparse must open");
1044    }
1045
1046    #[test]
1047    fn zero_extent_type_reads_as_zeros() {
1048        // A ZERO extent emulates a zero-filled region with NO backing file.
1049        // `RW <sectors> ZERO` — valid per the VMware descriptor spec.
1050        use std::io::Write as _;
1051        let dir = tempfile::tempdir().unwrap();
1052        let desc = "# Disk DescriptorFile\nversion=1\nCID=ffffffff\nparentCID=ffffffff\ncreateType=\"monolithicFlat\"\nRW 2048 ZERO\n";
1053        let desc_path = dir.path().join("zero.vmdk");
1054        std::fs::File::create(&desc_path)
1055            .unwrap()
1056            .write_all(desc.as_bytes())
1057            .unwrap();
1058        let mut reader =
1059            VmdkFileReader::open_path(&desc_path).expect("descriptor with a ZERO extent must open");
1060        assert_eq!(
1061            reader.virtual_disk_size(),
1062            2048 * 512,
1063            "ZERO extent contributes its sector count"
1064        );
1065        reader.seek(SeekFrom::Start(0)).unwrap();
1066        let mut buf = [0xFFu8; 512];
1067        reader.read_exact(&mut buf).expect("read");
1068        assert_eq!(buf, [0u8; 512], "ZERO extent must read as zeros");
1069    }
1070
1071    // ── custom + device-passthrough createTypes ──────────────────────────────
1072
1073    /// Write a descriptor + a flat extent file containing `byte0` at offset 0,
1074    /// then assert `open_path` reads it back through `create_type`/`extent_kw`.
1075    fn assert_flat_create_type_reads(create_type: &str, extent_kw: &str, byte0: u8) {
1076        use std::io::Write as _;
1077        let dir = tempfile::tempdir().unwrap();
1078        let mut extent = vec![0u8; 1024];
1079        extent[0] = byte0;
1080        let extent_path = dir.path().join("disk-flat.vmdk");
1081        std::fs::File::create(&extent_path)
1082            .unwrap()
1083            .write_all(&extent)
1084            .unwrap();
1085        let offset = if extent_kw == "FLAT" { " 0" } else { "" };
1086        let desc = format!(
1087            "# Disk DescriptorFile\nversion=1\nCID=ffffffff\nparentCID=ffffffff\n\
1088             createType=\"{create_type}\"\nRW 2 {extent_kw} \"disk-flat.vmdk\"{offset}\n"
1089        );
1090        let desc_path = dir.path().join("disk.vmdk");
1091        std::fs::write(&desc_path, desc.as_bytes()).unwrap();
1092        let mut reader = VmdkFileReader::open_path(&desc_path)
1093            .unwrap_or_else(|e| panic!("{create_type}/{extent_kw} must open: {e:?}"));
1094        let mut buf = [0u8; 1];
1095        reader.read_exact(&mut buf).expect("read");
1096        assert_eq!(
1097            buf[0], byte0,
1098            "{create_type}: must read the referenced extent"
1099        );
1100    }
1101
1102    #[test]
1103    fn custom_create_type_with_flat_extent_opens() {
1104        // createType="custom" is an arbitrary extent mix — route by extent composition.
1105        assert_flat_create_type_reads("custom", "FLAT", 0xC0);
1106    }
1107
1108    #[test]
1109    fn full_device_create_type_routes_to_flat() {
1110        // fullDevice / partitionedDevice map to a device path via a FLAT extent;
1111        // when the referenced path is present they read like any flat extent.
1112        assert_flat_create_type_reads("fullDevice", "FLAT", 0xFD);
1113        assert_flat_create_type_reads("partitionedDevice", "FLAT", 0xDE);
1114    }
1115
1116    #[test]
1117    fn vmfs_raw_rdm_create_types_route_to_flat() {
1118        // vmfsRaw / vmfsRawDeviceMap reference a raw LUN via a VMFSRAW/FLAT extent;
1119        // present-path reads must succeed (offline-absent yields a clear NotFound).
1120        assert_flat_create_type_reads("vmfsRaw", "VMFSRAW", 0x4A);
1121        assert_flat_create_type_reads("vmfsRawDeviceMap", "VMFSRAW", 0x4B);
1122    }
1123
1124    // ── extent_dependencies (companion-file discovery for evidence collection) ──
1125
1126    #[test]
1127    fn extent_dependencies_lists_flat_companion() {
1128        // A twoGbMaxExtentFlat descriptor must report its companion extent file so a
1129        // forensic examiner knows what to collect before the disk can be read.
1130        use std::io::Write as _;
1131        let dir = tempfile::tempdir().unwrap();
1132        let desc = "# Disk DescriptorFile\nversion=1\nCID=ffffffff\nparentCID=ffffffff\ncreateType=\"twoGbMaxExtentFlat\"\nRW 2048 FLAT \"disk-f001.vmdk\" 0\n";
1133        let desc_path = dir.path().join("disk.vmdk");
1134        std::fs::File::create(&desc_path)
1135            .unwrap()
1136            .write_all(desc.as_bytes())
1137            .unwrap();
1138        let deps = VmdkFileReader::extent_dependencies(&desc_path).expect("extent_dependencies");
1139        assert_eq!(deps.len(), 1, "one companion extent");
1140        assert_eq!(
1141            deps[0].file_name().unwrap().to_string_lossy(),
1142            "disk-f001.vmdk"
1143        );
1144        // Paths must be resolved relative to the descriptor's directory.
1145        assert_eq!(deps[0].parent().unwrap(), dir.path());
1146    }
1147
1148    #[test]
1149    fn extent_dependencies_lists_sparse_companions() {
1150        use std::io::Write as _;
1151        let dir = tempfile::tempdir().unwrap();
1152        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";
1153        let desc_path = dir.path().join("disk.vmdk");
1154        std::fs::File::create(&desc_path)
1155            .unwrap()
1156            .write_all(desc.as_bytes())
1157            .unwrap();
1158        let deps = VmdkFileReader::extent_dependencies(&desc_path).expect("deps");
1159        let names: Vec<String> = deps
1160            .iter()
1161            .map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
1162            .collect();
1163        assert_eq!(names, vec!["disk-s001.vmdk", "disk-s002.vmdk"]);
1164    }
1165
1166    #[test]
1167    fn extent_dependencies_empty_for_self_contained_binary() {
1168        // A binary single-file VMDK (no text descriptor) is self-contained → no deps.
1169        use std::io::Write as _;
1170        let dir = tempfile::tempdir().unwrap();
1171        let vmdk = test_sparse_vmdk(&[0u8; 512]);
1172        let path = dir.path().join("mono.vmdk");
1173        std::fs::File::create(&path)
1174            .unwrap()
1175            .write_all(&vmdk)
1176            .unwrap();
1177        let deps = VmdkFileReader::extent_dependencies(&path).expect("deps");
1178        assert!(
1179            deps.is_empty(),
1180            "self-contained binary VMDK has no companions"
1181        );
1182    }
1183
1184    #[test]
1185    fn extent_dependencies_excludes_zero_extents() {
1186        // ZERO extents have no backing file and must not appear as a dependency.
1187        use std::io::Write as _;
1188        let dir = tempfile::tempdir().unwrap();
1189        let desc = "# Disk DescriptorFile\nversion=1\nCID=ffffffff\nparentCID=ffffffff\ncreateType=\"monolithicFlat\"\nRW 2048 ZERO\nRW 2048 FLAT \"real-f001.vmdk\" 0\n";
1190        let desc_path = dir.path().join("disk.vmdk");
1191        std::fs::File::create(&desc_path)
1192            .unwrap()
1193            .write_all(desc.as_bytes())
1194            .unwrap();
1195        let deps = VmdkFileReader::extent_dependencies(&desc_path).expect("deps");
1196        let names: Vec<String> = deps
1197            .iter()
1198            .map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
1199            .collect();
1200        assert_eq!(
1201            names,
1202            vec!["real-f001.vmdk"],
1203            "ZERO extent contributes no file"
1204        );
1205    }
1206
1207    #[test]
1208    fn extent_dependencies_skips_empty_sparse_filename() {
1209        // A SPARSE extent with an empty filename is skipped (defensive guard).
1210        use std::io::Write as _;
1211        let dir = tempfile::tempdir().unwrap();
1212        let desc = "# Disk DescriptorFile\nversion=1\nCID=ffffffff\nparentCID=ffffffff\ncreateType=\"twoGbMaxExtentSparse\"\nRW 8 SPARSE \"\"\nRW 8 SPARSE \"real-s001.vmdk\"\n";
1213        let desc_path = dir.path().join("disk.vmdk");
1214        std::fs::File::create(&desc_path)
1215            .unwrap()
1216            .write_all(desc.as_bytes())
1217            .unwrap();
1218        let deps = VmdkFileReader::extent_dependencies(&desc_path).expect("deps");
1219        let names: Vec<String> = deps
1220            .iter()
1221            .map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
1222            .collect();
1223        assert_eq!(
1224            names,
1225            vec!["real-s001.vmdk"],
1226            "empty-filename sparse extent skipped"
1227        );
1228    }
1229
1230    // ── check_integrity (dangling-pointer / corruption detection) ─────────────
1231
1232    #[test]
1233    fn grain_size_zero_rejected() {
1234        let img = vmdk_header_bytes(8, 0, 512);
1235        assert!(VmdkReader::open(Cursor::new(img)).is_err());
1236    }
1237
1238    #[test]
1239    fn num_gtes_per_gt_zero_rejected() {
1240        let img = vmdk_header_bytes(8, 8, 0);
1241        assert!(VmdkReader::open(Cursor::new(img)).is_err());
1242    }
1243
1244    #[test]
1245    fn open_empty_file_returns_err() {
1246        assert!(VmdkReader::open(Cursor::new(vec![])).is_err());
1247    }
1248
1249    #[test]
1250    fn open_non_vmdk_file_returns_err() {
1251        assert!(VmdkReader::open(Cursor::new(b"this is not a vmdk file at all".to_vec())).is_err());
1252    }
1253
1254    #[test]
1255    fn sparse_vmdk_virtual_disk_size() {
1256        let vmdk = test_sparse_vmdk(&[0u8; 512]);
1257        let reader = VmdkReader::open(Cursor::new(vmdk)).expect("open");
1258        assert_eq!(reader.virtual_disk_size(), GRAIN_SIZE_BYTES as u64);
1259    }
1260
1261    #[test]
1262    fn sparse_vmdk_read_returns_sector_data() {
1263        let mut data = vec![0u8; 512];
1264        data[42] = 0xDE;
1265        data[43] = 0xAD;
1266        let vmdk = test_sparse_vmdk(&data);
1267        let mut reader = VmdkReader::open(Cursor::new(vmdk)).expect("open");
1268        let mut buf = vec![0u8; 512];
1269        reader.read_exact(&mut buf).expect("read");
1270        assert_eq!(buf[42], 0xDE);
1271        assert_eq!(buf[43], 0xAD);
1272    }
1273
1274    #[test]
1275    fn seek_and_read_at_offset() {
1276        let mut data = vec![0u8; GRAIN_SIZE_BYTES];
1277        data[100] = 0xBE;
1278        data[101] = 0xEF;
1279        let vmdk = test_sparse_vmdk(&data);
1280        let mut reader = VmdkReader::open(Cursor::new(vmdk)).expect("open");
1281        reader.seek(SeekFrom::Start(100)).expect("seek");
1282        let mut buf = [0u8; 2];
1283        reader.read_exact(&mut buf).expect("read");
1284        assert_eq!(buf, [0xBE, 0xEF]);
1285    }
1286
1287    #[test]
1288    fn vmdk_reader_is_send() {
1289        fn assert_send<T: Send>() {}
1290        assert_send::<VmdkReader<Cursor<Vec<u8>>>>();
1291    }
1292
1293    #[test]
1294    fn stream_opt_gd_at_end_opens_correctly() {
1295        let vmdk = gd_at_end_stream_opt_vmdk();
1296        let reader = VmdkReader::open(Cursor::new(vmdk))
1297            .expect("streamOptimized GD_AT_END must open via footer lookup");
1298        assert_eq!(reader.virtual_disk_size(), 1_048_576);
1299        assert_eq!(reader.disk_type(), "streamOptimized");
1300    }
1301
1302    #[test]
1303    fn stream_opt_gd_at_end_reads_zeros() {
1304        let vmdk = gd_at_end_stream_opt_vmdk();
1305        let mut reader = VmdkReader::open(Cursor::new(vmdk)).expect("open GD_AT_END vmdk");
1306        let mut buf = [0xFFu8; 512];
1307        reader.read_exact(&mut buf).expect("read sector 0");
1308        assert_eq!(buf, [0u8; 512]);
1309    }
1310
1311    proptest::proptest! {
1312        #[test]
1313        fn open_never_panics_on_arbitrary_bytes(
1314            bytes in proptest::collection::vec(proptest::prelude::any::<u8>(), 0..8192)
1315        ) {
1316            let _ = VmdkReader::open(Cursor::new(bytes));
1317        }
1318
1319        #[test]
1320        fn open_never_panics_on_valid_magic_plus_garbage(
1321            suffix in proptest::collection::vec(proptest::prelude::any::<u8>(), 0..8192)
1322        ) {
1323            let mut bytes = vec![0u8; 8];
1324            bytes[0..4].copy_from_slice(&0x564D_444B_u32.to_le_bytes());
1325            bytes[4..8].copy_from_slice(&1u32.to_le_bytes());
1326            bytes.extend_from_slice(&suffix);
1327            let _ = VmdkReader::open(Cursor::new(bytes));
1328        }
1329    }
1330
1331    // ── RGD validation ───────────────────────────────────────────────────────
1332
1333    // ── VMFS flat / ZERO extent descriptor parsing ───────────────────────────
1334
1335    #[test]
1336    fn vmfs_flat_extent_descriptor_opens_via_open_path() {
1337        // A vmfs descriptor with VMFS extent type (not FLAT) must open.
1338        // Currently returns Err(UnsupportedDiskType) because VMFS extent type is unrecognised.
1339        use std::io::Write as _;
1340        let dir = tempfile::tempdir().unwrap();
1341        let raw_path = dir.path().join("disk.vmdk");
1342        std::fs::File::create(&raw_path)
1343            .unwrap()
1344            .write_all(&vec![0u8; 512])
1345            .unwrap();
1346        let desc = format!(
1347            "# Disk DescriptorFile\nversion=1\nCID=ffffffff\nparentCID=ffffffff\ncreateType=\"vmfs\"\nRW 1 VMFS \"{}\"\n",
1348            raw_path.file_name().unwrap().to_string_lossy()
1349        );
1350        let desc_path = dir.path().join("disk_desc.vmdk");
1351        std::fs::write(&desc_path, desc.as_bytes()).unwrap();
1352        let result = VmdkFileReader::open_path(&desc_path);
1353        result.expect("vmfs descriptor with VMFS extent must open");
1354    }
1355
1356    #[test]
1357    fn vmfssparse_extent_descriptor_opens_as_cowd() {
1358        // vmfsSparse descriptor with VMFSSPARSE extent type referencing a COWD file.
1359        use std::io::Write as _;
1360        let dir = tempfile::tempdir().unwrap();
1361        let cowd_bytes = testutil::test_cowd_vmdk(&[0u8; 512]);
1362        let cowd_path = dir.path().join("disk-delta.vmdk");
1363        std::fs::File::create(&cowd_path)
1364            .unwrap()
1365            .write_all(&cowd_bytes)
1366            .unwrap();
1367        let desc = format!(
1368            "# Disk DescriptorFile\nversion=1\nCID=ffffffff\nparentCID=ffffffff\ncreateType=\"vmfsSparse\"\nRW 8 VMFSSPARSE \"{}\"\n",
1369            cowd_path.file_name().unwrap().to_string_lossy()
1370        );
1371        let desc_path = dir.path().join("desc.vmdk");
1372        std::fs::write(&desc_path, desc.as_bytes()).unwrap();
1373        let result = VmdkFileReader::open_path(&desc_path);
1374        result.expect("vmfsSparse/VMFSSPARSE descriptor must open");
1375    }
1376
1377    // ── seSparse format (vSphere 6.5+ VMFS6) ─────────────────────────────────
1378
1379    #[test]
1380    fn sesparse_vmdk_opens_successfully() {
1381        let se = test_sesparse_vmdk(&[0u8; 512]);
1382        VmdkReader::open(Cursor::new(se)).expect("seSparse VMDK must open");
1383    }
1384
1385    #[test]
1386    fn sesparse_vmdk_disk_type_is_sesparse() {
1387        let se = test_sesparse_vmdk(&[0u8; 512]);
1388        let reader = VmdkReader::open(Cursor::new(se)).expect("open");
1389        assert_eq!(reader.disk_type(), "seSparse");
1390    }
1391
1392    // ── qemu-img cross-validation (independent oracle) ───────────────────────
1393    //
1394    // COWD and seSparse cannot be generated by qemu-img (ESXi-only write formats),
1395    // but qemu-img *reads* them. These tests build a synthetic extent + descriptor,
1396    // then assert that `qemu-img convert -O raw` and our reader produce byte-identical
1397    // output. This is genuine independent validation: two unrelated parsers agreeing
1398    // on the same bytes confirms the fixture is format-correct and the reader is right.
1399    // Skipped automatically when qemu-img is not installed.
1400
1401    fn qemu_img_available() -> bool {
1402        std::process::Command::new("qemu-img")
1403            .arg("--version")
1404            .output()
1405            .is_ok_and(|o| o.status.success())
1406    }
1407
1408    /// Write `extent_bytes` + a descriptor of `create_type`/`extent_kw`, then compare
1409    /// `qemu-img convert -O raw` against `VmdkReader::open_path` byte-for-byte.
1410    fn assert_reader_matches_qemu(
1411        extent_bytes: &[u8],
1412        create_type: &str,
1413        extent_kw: &str,
1414        capacity_sectors: u64,
1415    ) {
1416        use std::io::Write as _;
1417        let dir = tempfile::tempdir().unwrap();
1418        let extent_path = dir.path().join("disk-extent.vmdk");
1419        std::fs::File::create(&extent_path)
1420            .unwrap()
1421            .write_all(extent_bytes)
1422            .unwrap();
1423        let desc = format!(
1424            "# Disk DescriptorFile\nversion=1\nCID=12345678\nparentCID=ffffffff\n\
1425             createType=\"{create_type}\"\nRW {capacity_sectors} {extent_kw} \"disk-extent.vmdk\"\n"
1426        );
1427        let desc_path = dir.path().join("disk.vmdk");
1428        std::fs::write(&desc_path, desc.as_bytes()).unwrap();
1429
1430        // qemu-img reference.
1431        let qemu_raw = dir.path().join("qemu.raw");
1432        let status = std::process::Command::new("qemu-img")
1433            .args(["convert", "-O", "raw"])
1434            .arg(&desc_path)
1435            .arg(&qemu_raw)
1436            .status()
1437            .expect("run qemu-img convert");
1438        assert!(
1439            status.success(),
1440            "qemu-img convert failed for {create_type}"
1441        );
1442        let qemu_bytes = std::fs::read(&qemu_raw).unwrap();
1443
1444        // Our reader.
1445        let mut reader = VmdkFileReader::open_path(&desc_path).expect("open_path");
1446        reader.seek(SeekFrom::Start(0)).unwrap();
1447        let mut mine = Vec::new();
1448        reader.read_to_end(&mut mine).unwrap();
1449
1450        assert_eq!(
1451            mine.len(),
1452            qemu_bytes.len(),
1453            "{create_type}: size mismatch (mine {} vs qemu {})",
1454            mine.len(),
1455            qemu_bytes.len()
1456        );
1457        assert!(
1458            mine == qemu_bytes,
1459            "{create_type}: byte mismatch vs qemu-img — reader disagrees with the independent oracle"
1460        );
1461    }
1462
1463    #[test]
1464    fn cowd_reader_matches_qemu_img() {
1465        if !qemu_img_available() {
1466            eprintln!("skipping: qemu-img not installed");
1467            return;
1468        }
1469        let pattern: Vec<u8> = (0..4096).map(|i| (i % 251) as u8).collect();
1470        let cowd = test_cowd_vmdk(&pattern);
1471        assert_reader_matches_qemu(&cowd, "vmfsSparse", "VMFSSPARSE", 8);
1472    }
1473
1474    #[test]
1475    fn sesparse_reader_matches_qemu_img() {
1476        if !qemu_img_available() {
1477            eprintln!("skipping: qemu-img not installed");
1478            return;
1479        }
1480        let pattern: Vec<u8> = (0..4096).map(|i| (i % 251) as u8).collect();
1481        let se = test_sesparse_vmdk(&pattern);
1482        assert_reader_matches_qemu(&se, "seSparse", "SESPARSE", 8);
1483    }
1484
1485    #[test]
1486    fn sesparse_vmdk_reads_grain_data() {
1487        let mut data = vec![0u8; 512];
1488        data[0] = 0x5E;
1489        data[1] = 0xA5;
1490        let se = test_sesparse_vmdk(&data);
1491        let mut reader = VmdkReader::open(Cursor::new(se)).expect("open seSparse");
1492        let mut buf = [0u8; 512];
1493        reader.read_exact(&mut buf).expect("read");
1494        assert_eq!(buf[0], 0x5E);
1495        assert_eq!(buf[1], 0xA5);
1496    }
1497
1498    #[test]
1499    fn sesparse_extent_descriptor_opens_via_open_path() {
1500        // seSparse descriptor (createType="seSparse", SESPARSE extent) must route
1501        // through open_path to the binary extent. This path was a gap until qemu-img
1502        // cross-validation exposed it (the bare-binary magic path worked, the
1503        // descriptor path did not).
1504        use std::io::Write as _;
1505        let dir = tempfile::tempdir().unwrap();
1506        let mut data = vec![0u8; 512];
1507        data[0] = 0x7E;
1508        let se_bytes = test_sesparse_vmdk(&data);
1509        let se_path = dir.path().join("disk-sesparse.vmdk");
1510        std::fs::File::create(&se_path)
1511            .unwrap()
1512            .write_all(&se_bytes)
1513            .unwrap();
1514        let desc = format!(
1515            "# Disk DescriptorFile\nversion=1\nCID=abcdef01\nparentCID=ffffffff\ncreateType=\"seSparse\"\nRW 8 SESPARSE \"{}\"\n",
1516            se_path.file_name().unwrap().to_string_lossy()
1517        );
1518        let desc_path = dir.path().join("disk.vmdk");
1519        std::fs::write(&desc_path, desc.as_bytes()).unwrap();
1520        let mut reader = VmdkFileReader::open_path(&desc_path)
1521            .expect("seSparse descriptor must open via open_path");
1522        assert_eq!(reader.disk_type(), "seSparse");
1523        let mut buf = [0u8; 1];
1524        reader.read_exact(&mut buf).expect("read grain 0");
1525        assert_eq!(
1526            buf[0], 0x7E,
1527            "must read seSparse grain data through the descriptor"
1528        );
1529    }
1530
1531    // ── COWD format (vmfsSparse / vmfsThin) ──────────────────────────────────
1532
1533    #[test]
1534    fn cowd_vmdk_opens_without_bad_magic_error() {
1535        let cowd = test_cowd_vmdk(&[0u8; 512]);
1536        let reader = VmdkReader::open(Cursor::new(cowd));
1537        reader.expect("COWD VMDK must open successfully");
1538    }
1539
1540    #[test]
1541    fn cowd_vmdk_reads_grain_data() {
1542        let mut data = vec![0u8; 512];
1543        data[0] = 0xC0;
1544        data[1] = 0xBE;
1545        let cowd = test_cowd_vmdk(&data);
1546        let mut reader = VmdkReader::open(Cursor::new(cowd)).expect("open COWD");
1547        let mut buf = [0u8; 512];
1548        reader.read_exact(&mut buf).expect("read");
1549        assert_eq!(buf[0], 0xC0, "COWD grain data byte 0");
1550        assert_eq!(buf[1], 0xBE, "COWD grain data byte 1");
1551    }
1552
1553    #[test]
1554    fn cowd_vmdk_virtual_disk_size() {
1555        let cowd = test_cowd_vmdk(&[0u8; 512]);
1556        let reader = VmdkReader::open(Cursor::new(cowd)).expect("open");
1557        // test_cowd_vmdk capacity = grain_size = 8 sectors = 4096 bytes
1558        assert_eq!(reader.virtual_disk_size(), 8 * 512);
1559    }
1560
1561    // ── VmdkHasher ───────────────────────────────────────────────────────────
1562
1563    #[test]
1564    fn hash_all_zeros_disk_produces_known_sha256() {
1565        // All-sparse VMDK reads as all zeros — SHA-256 of 1 MiB of zeros is a known constant.
1566        use std::io::Cursor;
1567        let vmdk = gd_at_end_stream_opt_vmdk();
1568        let mut reader = VmdkReader::open(Cursor::new(vmdk)).expect("open");
1569        reader.seek(SeekFrom::Start(0)).expect("seek");
1570        let digest = reader.hash().expect("hash");
1571        // SHA-256 of 1 MiB (1_048_576) zero bytes (computed independently):
1572        // echo -n | dd bs=1 count=0 | ... — computed via sha256sum
1573        assert_eq!(
1574            digest.sha256, "30e14955ebf1352266dc2ff8067e68104607e750abb9d3b36582b8af909fcb58",
1575            "SHA-256 of 1 MiB all-zeros"
1576        );
1577        assert_eq!(
1578            digest.md5, "b6d81b360a5672d80c27430f39153e2c",
1579            "MD5 of 1 MiB all-zeros (matches qemu-img reference)"
1580        );
1581    }
1582
1583    #[test]
1584    fn hash_produces_hex_strings_of_correct_length() {
1585        let vmdk = test_sparse_vmdk(&[0u8; 512]);
1586        let mut reader = VmdkReader::open(Cursor::new(vmdk)).expect("open");
1587        reader.seek(SeekFrom::Start(0)).expect("seek");
1588        let digest = reader.hash().expect("hash");
1589        assert_eq!(digest.sha256.len(), 64, "SHA-256 hex must be 64 chars");
1590        assert_eq!(digest.md5.len(), 32, "MD5 hex must be 32 chars");
1591    }
1592
1593    // ── serde feature ────────────────────────────────────────────────────────
1594
1595    #[cfg(feature = "serde")]
1596    #[test]
1597    fn vmdk_info_serializes_to_json() {
1598        let vmdk = test_sparse_vmdk(&[0u8; 512]);
1599        let reader = VmdkReader::open(Cursor::new(vmdk)).expect("open");
1600        let info = reader.info();
1601        let json = serde_json::to_string(&info).expect("serialize VmdkInfo to JSON");
1602        assert!(
1603            json.contains("\"disk_type\""),
1604            "JSON must contain disk_type field"
1605        );
1606        assert!(
1607            json.contains("monolithicSparse"),
1608            "JSON must contain createType value"
1609        );
1610        let info2: VmdkInfo = serde_json::from_str(&json).expect("deserialize VmdkInfo from JSON");
1611        assert_eq!(info2.disk_type, info.disk_type);
1612        assert_eq!(info2.virtual_disk_size, info.virtual_disk_size);
1613    }
1614
1615    #[cfg(feature = "serde")]
1616    #[test]
1617    fn allocated_grain_serializes_to_json() {
1618        let grain = AllocatedGrain {
1619            start_lba: 128,
1620            sector_count: 8,
1621        };
1622        let json = serde_json::to_string(&grain).expect("serialize AllocatedGrain");
1623        assert!(json.contains("\"start_lba\""));
1624        assert!(json.contains("128"));
1625        let grain2: AllocatedGrain = serde_json::from_str(&json).expect("deserialize");
1626        assert_eq!(grain2, grain);
1627    }
1628
1629    // ── GT cache ─────────────────────────────────────────────────────────────
1630
1631    #[test]
1632    fn gt_cache_grows_on_grain_read() {
1633        let vmdk = test_sparse_vmdk(&[0u8; 512]);
1634        let mut reader = VmdkReader::open(Cursor::new(vmdk)).expect("open");
1635        assert_eq!(reader.gt_cache_size(), 0, "cache starts empty");
1636        let mut buf = [0u8; 512];
1637        reader.read_exact(&mut buf).expect("read");
1638        assert_eq!(
1639            reader.gt_cache_size(),
1640            1,
1641            "one GT loaded after first grain read"
1642        );
1643    }
1644
1645    #[test]
1646    fn gt_cache_no_double_load_on_second_read_same_grain() {
1647        let vmdk = test_sparse_vmdk(&[0xABu8; 512]);
1648        let mut reader = VmdkReader::open(Cursor::new(vmdk)).expect("open");
1649        let mut buf = [0u8; 512];
1650        reader.read_exact(&mut buf).expect("first read");
1651        let after_first = reader.gt_cache_size();
1652        reader.seek(SeekFrom::Start(0)).expect("seek back");
1653        reader.read_exact(&mut buf).expect("second read");
1654        assert_eq!(
1655            reader.gt_cache_size(),
1656            after_first,
1657            "cache must not grow on second read of same GT"
1658        );
1659        assert_eq!(buf[0], 0xAB, "data must still be correct");
1660    }
1661
1662    // ── is_allocated / iter_allocated_grains ─────────────────────────────────
1663
1664    #[test]
1665    fn sparse_grain_is_not_allocated() {
1666        // test_sparse_vmdk has grain 0 allocated (sector data) and all other grains sparse.
1667        // Sectors beyond grain 0 should report not-allocated.
1668        let vmdk = test_sparse_vmdk(&[0u8; 512]);
1669        let mut reader = VmdkReader::open(Cursor::new(vmdk)).expect("open");
1670        // Grain 0 is allocated (GTE != 0).
1671        assert!(
1672            reader.is_allocated(0).expect("is_allocated lba=0"),
1673            "grain 0 must be allocated"
1674        );
1675        // Grain 1 and beyond: GTE == 0 (sparse).
1676        let grain_sectors = GRAIN_SIZE_BYTES as u64 / 512;
1677        assert!(
1678            !reader
1679                .is_allocated(grain_sectors)
1680                .expect("is_allocated lba=grain_sectors"),
1681            "grain 1 must be sparse"
1682        );
1683    }
1684
1685    #[test]
1686    fn lba_beyond_disk_is_not_allocated() {
1687        let vmdk = test_sparse_vmdk(&[0u8; 512]);
1688        let mut reader = VmdkReader::open(Cursor::new(vmdk)).expect("open");
1689        let beyond = reader.sector_count() + 1;
1690        assert!(
1691            !reader
1692                .is_allocated(beyond)
1693                .expect("is_allocated beyond end"),
1694            "LBA beyond virtual disk must be not-allocated"
1695        );
1696    }
1697
1698    #[test]
1699    fn iter_allocated_grains_yields_grain_zero() {
1700        let vmdk = test_sparse_vmdk(&[0u8; 512]);
1701        let mut reader = VmdkReader::open(Cursor::new(vmdk)).expect("open");
1702        let grains = reader
1703            .iter_allocated_grains()
1704            .expect("iter_allocated_grains");
1705        assert_eq!(grains.len(), 1, "only grain 0 is allocated");
1706        assert_eq!(grains[0].start_lba, 0);
1707        assert_eq!(grains[0].sector_count, GRAIN_SIZE_BYTES as u64 / 512);
1708    }
1709
1710    #[test]
1711    fn iter_allocated_grains_all_sparse_returns_empty() {
1712        let vmdk = gd_at_end_stream_opt_vmdk(); // all-sparse streamOptimized
1713        let mut reader = VmdkReader::open(Cursor::new(vmdk)).expect("open");
1714        let grains = reader
1715            .iter_allocated_grains()
1716            .expect("iter_allocated_grains");
1717        assert!(
1718            grains.is_empty(),
1719            "all-sparse VMDK must yield no allocated grains"
1720        );
1721    }
1722
1723    // ── VmdkInfo / metadata API ───────────────────────────────────────────────
1724
1725    #[test]
1726    fn sector_count_is_virtual_size_over_512() {
1727        let vmdk = test_sparse_vmdk(&[0u8; 512]);
1728        let reader = VmdkReader::open(Cursor::new(vmdk)).expect("open");
1729        assert_eq!(reader.sector_count() * 512, reader.virtual_disk_size());
1730    }
1731
1732    #[test]
1733    fn descriptor_text_contains_create_type() {
1734        let vmdk = test_sparse_vmdk(&[0u8; 512]);
1735        let reader = VmdkReader::open(Cursor::new(vmdk)).expect("open");
1736        let text = reader.descriptor_text();
1737        assert!(
1738            text.contains("monolithicSparse"),
1739            "descriptor_text must contain createType; got: {text:?}"
1740        );
1741    }
1742
1743    #[test]
1744    fn info_disk_type_matches_disk_type_method() {
1745        let vmdk = test_sparse_vmdk(&[0u8; 512]);
1746        let reader = VmdkReader::open(Cursor::new(vmdk)).expect("open");
1747        let info = reader.info();
1748        assert_eq!(info.disk_type, reader.disk_type());
1749    }
1750
1751    #[test]
1752    fn info_virtual_disk_size_and_sector_count_consistent() {
1753        let vmdk = test_sparse_vmdk(&[0u8; 512]);
1754        let reader = VmdkReader::open(Cursor::new(vmdk)).expect("open");
1755        let info = reader.info();
1756        assert_eq!(info.virtual_disk_size, reader.virtual_disk_size());
1757        assert_eq!(info.sector_count * 512, info.virtual_disk_size);
1758    }
1759
1760    #[test]
1761    fn info_grain_size_bytes_is_sectors_times_512() {
1762        let vmdk = test_sparse_vmdk(&[0u8; 512]);
1763        let reader = VmdkReader::open(Cursor::new(vmdk)).expect("open");
1764        let info = reader.info();
1765        assert_eq!(info.grain_size_bytes, info.grain_size_sectors * 512);
1766        assert!(
1767            info.grain_size_sectors >= 8,
1768            "grain_size_sectors must meet VDF 1.1 minimum"
1769        );
1770    }
1771
1772    #[test]
1773    fn info_cid_parsed_from_descriptor() {
1774        // testutil embeds CID=fffffffe in the descriptor.
1775        let vmdk = test_sparse_vmdk(&[0u8; 512]);
1776        let reader = VmdkReader::open(Cursor::new(vmdk)).expect("open");
1777        let info = reader.info();
1778        assert_eq!(
1779            info.cid, 0xffff_fffe,
1780            "CID must be parsed from embedded descriptor"
1781        );
1782        assert_eq!(
1783            info.parent_cid, 0xffff_ffff,
1784            "parentCID must be 0xffffffff (no parent) for a base image"
1785        );
1786    }
1787
1788    #[test]
1789    fn info_version_is_one_for_monolithic_sparse() {
1790        let vmdk = test_sparse_vmdk(&[0u8; 512]);
1791        let reader = VmdkReader::open(Cursor::new(vmdk)).expect("open");
1792        let info = reader.info();
1793        assert_eq!(info.version, 1);
1794        assert!(!info.compressed);
1795    }
1796
1797    // ── Fuzz / malicious-input defence ───────────────────────────────────────
1798
1799    #[test]
1800    fn compressed_grain_oversized_data_size_returns_invaliddata() {
1801        let vmdk = compressed_vmdk_with_oversized_marker(4 * 1024 * 1024);
1802        let mut reader = VmdkReader::open(Cursor::new(vmdk))
1803            .expect("VMDK with oversized marker must open — error only on read");
1804        let mut buf = [0u8; 512];
1805        let err = reader
1806            .read(&mut buf)
1807            .expect_err("oversized data_size must return Err");
1808        assert_eq!(
1809            err.kind(),
1810            io::ErrorKind::InvalidData,
1811            "must return InvalidData from cap check, not UnexpectedEof from allocation attempt"
1812        );
1813    }
1814
1815    #[test]
1816    fn grain_size_below_spec_minimum_is_rejected() {
1817        let mut hdr = vec![0u8; 512];
1818        hdr[0..4].copy_from_slice(&0x564D_444B_u32.to_le_bytes());
1819        hdr[4..8].copy_from_slice(&1u32.to_le_bytes());
1820        hdr[12..20].copy_from_slice(&128u64.to_le_bytes()); // capacity = 128 sectors
1821        hdr[20..28].copy_from_slice(&4u64.to_le_bytes()); // grain_size = 4 (below VDF 1.1 minimum of 8)
1822        hdr[44..48].copy_from_slice(&512u32.to_le_bytes()); // num_gtes_per_gt
1823        let result = VmdkReader::open(Cursor::new(hdr));
1824        assert!(
1825            result.is_err(),
1826            "grain_size=4 is below VDF 1.1 minimum of 8 sectors; open must return Err"
1827        );
1828    }
1829
1830    proptest::proptest! {
1831        #[test]
1832        fn open_never_panics_on_stream_opt_magic_plus_garbage(
1833            suffix in proptest::collection::vec(proptest::prelude::any::<u8>(), 0..8192)
1834        ) {
1835            let mut bytes = vec![0u8; 8];
1836            bytes[0..4].copy_from_slice(&0x564D_444B_u32.to_le_bytes());
1837            bytes[4..8].copy_from_slice(&3u32.to_le_bytes()); // version = 3 (streamOptimized path)
1838            bytes.extend_from_slice(&suffix);
1839            let _ = VmdkReader::open(Cursor::new(bytes));
1840        }
1841    }
1842
1843    /// Locate `qemu-img` portably (PATH-style common locations) for cross-validation
1844    /// tests; `None` (→ skip) only when it is genuinely not installed.
1845    fn qemu_img() -> Option<&'static str> {
1846        [
1847            "/opt/homebrew/bin/qemu-img",
1848            "/usr/bin/qemu-img",
1849            "/usr/local/bin/qemu-img",
1850        ]
1851        .into_iter()
1852        .find(|p| std::path::Path::new(p).exists())
1853    }
1854
1855    #[test]
1856    fn reads_match_qemu_raw_convert() {
1857        use std::fs::File;
1858        let Some(qemu_img) = qemu_img() else {
1859            return;
1860        };
1861        let tmp = tempfile::tempdir().expect("tempdir");
1862        let size: usize = 1 << 20;
1863        let raw_data: Vec<u8> = (0..size).map(|i| (i ^ (i >> 8)) as u8).collect();
1864        let raw_path = tmp.path().join("source.raw");
1865        std::fs::write(&raw_path, &raw_data).expect("write raw");
1866        let vmdk_path = tmp.path().join("test.vmdk");
1867        let status = std::process::Command::new(qemu_img)
1868            .args([
1869                "convert",
1870                "-O",
1871                "vmdk",
1872                raw_path.to_str().expect("UTF-8 path"),
1873                vmdk_path.to_str().expect("UTF-8 path"),
1874            ])
1875            .status()
1876            .expect("spawn qemu-img");
1877        assert!(status.success(), "qemu-img convert failed");
1878        let file = File::open(&vmdk_path).expect("open vmdk file");
1879        let mut reader = VmdkReader::open(file).expect("open");
1880        assert_eq!(reader.virtual_disk_size(), size as u64);
1881        let grain = 512 * 128;
1882        for &offset in &[0usize, 511, grain, grain + 512, size - 512] {
1883            let len = 512.min(size - offset);
1884            let mut buf = vec![0u8; len];
1885            reader.seek(SeekFrom::Start(offset as u64)).expect("seek");
1886            reader.read_exact(&mut buf).expect("read");
1887            assert_eq!(
1888                buf,
1889                raw_data[offset..offset + len],
1890                "byte mismatch at {offset:#x}"
1891            );
1892        }
1893    }
1894
1895    #[test]
1896    fn corpus_dfvfs_ext2_vmdk_reads_match_qemu_raw_convert() {
1897        use std::fs::File;
1898        let Some(qemu_img) = qemu_img() else {
1899            return;
1900        };
1901        let corpus =
1902            std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/data/dfvfs_ext2.vmdk");
1903        if !corpus.exists() {
1904            return;
1905        }
1906        let tmp = tempfile::tempdir().expect("tempdir");
1907        let raw_path = tmp.path().join("ext2.raw");
1908        let ok = std::process::Command::new(qemu_img)
1909            .args([
1910                "convert",
1911                "-O",
1912                "raw",
1913                corpus.to_str().expect("UTF-8 path"),
1914                raw_path.to_str().expect("UTF-8 path"),
1915            ])
1916            .status()
1917            .expect("spawn qemu-img")
1918            .success();
1919        assert!(ok, "qemu-img convert failed for dfvfs_ext2.vmdk");
1920        let ref_data = std::fs::read(&raw_path).expect("read reference raw");
1921        let file = File::open(&corpus).expect("open dfvfs_ext2.vmdk");
1922        let mut reader = VmdkReader::open(file).expect("open");
1923        assert_eq!(
1924            reader.virtual_disk_size(),
1925            ref_data.len() as u64,
1926            "virtual_disk_size must match qemu-img raw for dfvfs_ext2.vmdk"
1927        );
1928        let vsize = ref_data.len();
1929        let step = 4096usize;
1930        let mut offset = 0usize;
1931        while offset < vsize {
1932            let len = 512.min(vsize - offset);
1933            let mut buf = vec![0u8; len];
1934            reader.seek(SeekFrom::Start(offset as u64)).expect("seek");
1935            reader.read_exact(&mut buf).expect("read");
1936            assert_eq!(
1937                buf,
1938                ref_data[offset..offset + len],
1939                "byte mismatch at {offset:#x} in dfvfs_ext2.vmdk"
1940            );
1941            offset += step;
1942        }
1943    }
1944
1945    #[test]
1946    fn corpus_minimal_vmdk_reads_match_qemu_raw_convert() {
1947        use std::fs::File;
1948        let Some(qemu_img) = qemu_img() else {
1949            return;
1950        };
1951        let corpus =
1952            std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/data/minimal.vmdk");
1953        if !corpus.exists() {
1954            return;
1955        }
1956        let tmp = tempfile::tempdir().expect("tempdir");
1957        let raw_path = tmp.path().join("minimal.raw");
1958        let ok = std::process::Command::new(qemu_img)
1959            .args([
1960                "convert",
1961                "-O",
1962                "raw",
1963                corpus.to_str().expect("UTF-8 path"),
1964                raw_path.to_str().expect("UTF-8 path"),
1965            ])
1966            .status()
1967            .expect("spawn qemu-img")
1968            .success();
1969        assert!(ok, "qemu-img convert failed");
1970        let ref_data = std::fs::read(&raw_path).expect("read raw");
1971        let file = File::open(&corpus).expect("open corpus vmdk");
1972        let mut reader = VmdkReader::open(file).expect("open");
1973        assert_eq!(reader.virtual_disk_size(), ref_data.len() as u64);
1974        let vsize = ref_data.len();
1975        let grain = 65536usize;
1976        for &offset in &[0usize, 511, grain, grain + 512, vsize - 512] {
1977            let len = 512.min(vsize - offset);
1978            let mut buf = vec![0u8; len];
1979            reader.seek(SeekFrom::Start(offset as u64)).expect("seek");
1980            reader.read_exact(&mut buf).expect("read");
1981            assert_eq!(
1982                buf,
1983                ref_data[offset..offset + len],
1984                "byte mismatch at {offset:#x}"
1985            );
1986        }
1987    }
1988
1989    // ── Coverage: seSparse method branches (is_allocated / iter / integrity) ──
1990
1991    #[test]
1992    fn sesparse_is_allocated_and_iter() {
1993        let mut data = vec![0u8; 512];
1994        data[0] = 0x9A;
1995        let se = test_sesparse_vmdk(&data);
1996        let mut r = VmdkReader::open(Cursor::new(se)).expect("open");
1997        assert!(r.is_allocated(0).expect("grain 0 allocated"));
1998        assert!(!r
1999            .is_allocated(10_000)
2000            .expect("out-of-bounds lba is unallocated"));
2001        let grains = r.iter_allocated_grains().expect("iter");
2002        assert_eq!(grains.len(), 1);
2003        assert_eq!(grains[0].start_lba, 0);
2004    }
2005
2006    #[test]
2007    fn sesparse_invalid_gd_marker_errors_on_is_allocated() {
2008        // Corrupt GD[0] (sector 2) so its allocated nibble is wrong → se_read_gte errors.
2009        let mut se = test_sesparse_vmdk(&[0u8; 512]);
2010        let gd = 2 * 512;
2011        se[gd..gd + 8].copy_from_slice(&0x5000_0000_0000_0000u64.to_le_bytes());
2012        let mut r = VmdkReader::open(Cursor::new(se)).expect("open");
2013        let err = r.is_allocated(0).expect_err("invalid GD marker must error");
2014        assert_eq!(err.kind(), io::ErrorKind::InvalidData);
2015    }
2016
2017    #[test]
2018    fn sesparse_invalid_gd_marker_skipped_in_iter() {
2019        let mut se = test_sesparse_vmdk(&[0u8; 512]);
2020        let gd = 2 * 512;
2021        se[gd..gd + 8].copy_from_slice(&0x5000_0000_0000_0000u64.to_le_bytes());
2022        let mut r = VmdkReader::open(Cursor::new(se)).expect("open");
2023        assert!(r.iter_allocated_grains().expect("iter").is_empty());
2024    }
2025
2026    // ── Coverage: Flat reader is_allocated / iter_allocated_grains ────────────
2027
2028    fn open_flat_descriptor(dir: &std::path::Path, data: &[u8]) -> VmdkFileReader {
2029        use std::io::Write as _;
2030        let sectors = data.len().div_ceil(512).max(1);
2031        let mut ext = vec![0u8; sectors * 512];
2032        ext[..data.len()].copy_from_slice(data);
2033        std::fs::File::create(dir.join("disk-f001.vmdk"))
2034            .unwrap()
2035            .write_all(&ext)
2036            .unwrap();
2037        let desc = format!(
2038            "# Disk DescriptorFile\nversion=1\nCID=ffffffff\nparentCID=ffffffff\ncreateType=\"monolithicFlat\"\nRW {sectors} FLAT \"disk-f001.vmdk\" 0\n"
2039        );
2040        let desc_path = dir.join("disk.vmdk");
2041        std::fs::write(&desc_path, desc.as_bytes()).unwrap();
2042        VmdkFileReader::open_path(&desc_path).expect("open flat")
2043    }
2044
2045    #[test]
2046    fn flat_is_allocated_and_iter() {
2047        let dir = tempfile::tempdir().unwrap();
2048        let mut r = open_flat_descriptor(dir.path(), &[1u8; 1024]);
2049        // Every in-bounds sector of a flat extent is allocated.
2050        assert!(r.is_allocated(0).expect("flat lba 0 allocated"));
2051        assert!(r.is_allocated(1).expect("flat lba 1 allocated"));
2052        assert!(!r.is_allocated(10_000).expect("oob unallocated"));
2053        // iter yields the whole disk as one range.
2054        let grains = r.iter_allocated_grains().expect("iter");
2055        assert_eq!(grains.len(), 1);
2056        assert_eq!(grains[0].start_lba, 0);
2057        assert_eq!(grains[0].sector_count, 2);
2058    }
2059
2060    #[test]
2061    fn sesparse_sparse_grain_directory_entry_reads_zero() {
2062        // Widen capacity so a second, sparse (GD[1] == 0) grain-directory entry is
2063        // in-bounds — exercises the seSparse sparse-entry read / is_allocated / iter paths.
2064        let mut se = test_sesparse_vmdk(&[0xAB; 512]);
2065        let cap = (sesparse::SE_GTES_PER_GT + 1) * 8; // 4097 grains × 8 sectors
2066        se[16..24].copy_from_slice(&cap.to_le_bytes()); // seSparse capacity field
2067        let mut r = VmdkReader::open(Cursor::new(se)).expect("open");
2068        let lba = sesparse::SE_GTES_PER_GT * 8; // first LBA in the second GD entry
2069        assert!(!r.is_allocated(lba).expect("is_allocated"));
2070        assert_eq!(r.iter_allocated_grains().expect("iter").len(), 1);
2071        r.seek(SeekFrom::Start(lba * 512)).expect("seek");
2072        let mut buf = [0xFFu8; 512];
2073        r.read_exact(&mut buf).expect("read");
2074        assert_eq!(buf, [0u8; 512]);
2075    }
2076
2077    #[test]
2078    fn grain_location_and_grain_size_on_flat_reader() {
2079        let dir = tempfile::tempdir().unwrap();
2080        let mut r = open_flat_descriptor(dir.path(), &[1u8; 1024]);
2081        // grain_location is never called for Flat on the read path; calling it directly
2082        // exercises the "not reached" guard.
2083        assert!(matches!(
2084            r.grain_location(0).expect("loc"),
2085            crate::read::GrainLookup::Sparse
2086        ));
2087        assert_eq!(r.sparse_grain_size_bytes(), 0);
2088    }
2089
2090    // ── Coverage: accessors, format-specific branches, open_path arms ─────────
2091
2092    #[test]
2093    fn cid_and_parent_cid_accessors() {
2094        let vmdk = test_sparse_vmdk(&[0u8; 512]);
2095        let r = VmdkReader::open(Cursor::new(vmdk)).expect("open");
2096        assert_eq!(r.cid(), 0xffff_fffe); // testutil embeds CID=fffffffe
2097        assert_eq!(r.parent_cid(), 0xffff_ffff);
2098    }
2099
2100    #[test]
2101    fn disk_database_accessor_and_info() {
2102        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";
2103        let vmdk = testutil::test_sparse_vmdk_with_descriptor(&[0u8; 512], desc);
2104        let r = VmdkReader::open(Cursor::new(vmdk)).expect("open");
2105        let db = r.disk_database();
2106        assert_eq!(db.adapter_type.as_deref(), Some("lsilogic"));
2107        assert_eq!(db.virtual_hw_version.as_deref(), Some("13"));
2108        assert_eq!(db.thin_provisioned, Some(true));
2109        assert_eq!(db.geometry.unwrap().chs_sectors(), 1024 * 16 * 63);
2110        // Also surfaced through info().
2111        assert_eq!(r.info().disk_database, db);
2112    }
2113
2114    #[test]
2115    fn disk_database_empty_for_descriptorless_image() {
2116        let vmdk = test_sparse_vmdk(&[0u8; 512]); // descriptor has no ddb section
2117        let r = VmdkReader::open(Cursor::new(vmdk)).expect("open");
2118        assert!(r.disk_database().is_empty());
2119    }
2120
2121    #[test]
2122    fn change_track_path_reference() {
2123        let desc = "# Disk DescriptorFile\nversion=1\nCID=12345678\nparentCID=ffffffff\ncreateType=\"monolithicSparse\"\nchangeTrackPath=\"disk-ctk.vmdk\"\n";
2124        let vmdk = testutil::test_sparse_vmdk_with_descriptor(&[0u8; 512], desc);
2125        let r = VmdkReader::open(Cursor::new(vmdk)).expect("open");
2126        assert_eq!(r.change_track_path().as_deref(), Some("disk-ctk.vmdk"));
2127    }
2128
2129    #[test]
2130    fn change_track_path_absent() {
2131        let vmdk = test_sparse_vmdk(&[0u8; 512]);
2132        let r = VmdkReader::open(Cursor::new(vmdk)).expect("open");
2133        assert_eq!(r.change_track_path(), None);
2134    }
2135
2136    #[test]
2137    fn effective_content_id_uses_long_cid_on_sentinel() {
2138        // CID=fffffffe is the "use the long content identifier" sentinel.
2139        let desc = "# Disk DescriptorFile\nversion=1\nCID=fffffffe\nparentCID=ffffffff\ncreateType=\"monolithicSparse\"\nddb.longContentID = \"deadbeefcafef00d1122334455667788\"\n";
2140        let vmdk = testutil::test_sparse_vmdk_with_descriptor(&[0u8; 512], desc);
2141        let r = VmdkReader::open(Cursor::new(vmdk)).expect("open");
2142        assert_eq!(r.cid(), 0xffff_fffe);
2143        assert_eq!(r.effective_content_id(), "deadbeefcafef00d1122334455667788");
2144    }
2145
2146    #[test]
2147    fn effective_content_id_uses_short_cid_normally() {
2148        let desc = "# Disk DescriptorFile\nversion=1\nCID=12345678\nparentCID=ffffffff\ncreateType=\"monolithicSparse\"\n";
2149        let vmdk = testutil::test_sparse_vmdk_with_descriptor(&[0u8; 512], desc);
2150        let r = VmdkReader::open(Cursor::new(vmdk)).expect("open");
2151        assert_eq!(r.effective_content_id(), "12345678");
2152    }
2153
2154    #[test]
2155    fn rgd_fallback_recovers_grain_from_corrupt_primary_gd() {
2156        // Corrupt the primary GD entry (point it out of bounds) but leave the RGD and
2157        // the grain table it references intact. With RGD fallback enabled the grain is
2158        // still readable via the redundant directory — recovery qemu-img cannot do.
2159        let mut vmdk = test_sparse_vmdk(&[0xAB; 512]);
2160        let gd_byte = 21 * 512; // primary GD sector
2161        vmdk[gd_byte..gd_byte + 4].copy_from_slice(&0xFFFF_FFFFu32.to_le_bytes());
2162        let mut r = VmdkReader::open(Cursor::new(vmdk)).expect("open");
2163        r.enable_rgd_fallback();
2164        let mut buf = [0u8; 512];
2165        r.read_exact(&mut buf).expect("resilient read via RGD");
2166        assert_eq!(buf, [0xAB; 512], "grain recovered from redundant GD");
2167    }
2168
2169    #[test]
2170    fn corrupt_primary_gd_without_fallback_errors() {
2171        // Same corruption, but fallback is opt-in: without it the dangling primary
2172        // pointer makes the read fail (the safe, unsurprising default).
2173        let mut vmdk = test_sparse_vmdk(&[0xAB; 512]);
2174        let gd_byte = 21 * 512;
2175        vmdk[gd_byte..gd_byte + 4].copy_from_slice(&0xFFFF_FFFFu32.to_le_bytes());
2176        let mut r = VmdkReader::open(Cursor::new(vmdk)).expect("open");
2177        let mut buf = [0u8; 512];
2178        assert!(
2179            r.read_exact(&mut buf).is_err(),
2180            "dangling primary GD pointer must error without fallback"
2181        );
2182    }
2183
2184    /// Build a two-copy sparse VMDK where the *primary* grain table has its GTE[0]
2185    /// zeroed (a lost grain pointer) but the *redundant* grain table still holds the
2186    /// valid pointer. Layout (sectors): 0 header, 1..21 descriptor, 21 primary GD,
2187    /// 22 RGD, 23..27 primary GT (GTE[0]=0), 27..31 redundant GT (GTE[0]=31),
2188    /// 31..39 grain (0xAB).
2189    fn two_copy_vmdk_with_lost_primary_gte() -> Vec<u8> {
2190        const S: usize = 512;
2191        let mut hdr = vec![0u8; S];
2192        hdr[0..4].copy_from_slice(&header::MAGIC.to_le_bytes());
2193        hdr[4..8].copy_from_slice(&1u32.to_le_bytes());
2194        hdr[12..20].copy_from_slice(&8u64.to_le_bytes()); // capacity (1 grain)
2195        hdr[20..28].copy_from_slice(&8u64.to_le_bytes()); // grain_size
2196        hdr[28..36].copy_from_slice(&1u64.to_le_bytes()); // descriptor_offset
2197        hdr[36..44].copy_from_slice(&20u64.to_le_bytes()); // descriptor_size
2198        hdr[44..48].copy_from_slice(&512u32.to_le_bytes()); // num_gtes_per_gt
2199        hdr[48..56].copy_from_slice(&22u64.to_le_bytes()); // rgd_offset
2200        hdr[56..64].copy_from_slice(&21u64.to_le_bytes()); // gd_offset
2201        hdr[64..72].copy_from_slice(&31u64.to_le_bytes()); // overhead
2202        hdr[73..77].copy_from_slice(&[0x0A, 0x20, 0x0D, 0x0A]);
2203
2204        let mut desc = vec![0u8; 20 * S];
2205        let text = "# Disk DescriptorFile\nversion=1\nCID=12345678\nparentCID=ffffffff\ncreateType=\"monolithicSparse\"\n";
2206        desc[..text.len()].copy_from_slice(text.as_bytes());
2207
2208        let mut gd = vec![0u8; S];
2209        gd[0..4].copy_from_slice(&23u32.to_le_bytes()); // primary GT @ sector 23
2210        let mut rgd = vec![0u8; S];
2211        rgd[0..4].copy_from_slice(&27u32.to_le_bytes()); // redundant GT @ sector 27
2212
2213        let primary_gt = vec![0u8; 4 * S]; // GTE[0] = 0 — lost pointer
2214        let mut redundant_gt = vec![0u8; 4 * S];
2215        redundant_gt[0..4].copy_from_slice(&31u32.to_le_bytes()); // grain @ sector 31
2216
2217        let grain = vec![0xABu8; 8 * S];
2218
2219        let mut v = Vec::new();
2220        v.extend_from_slice(&hdr);
2221        v.extend_from_slice(&desc);
2222        v.extend_from_slice(&gd);
2223        v.extend_from_slice(&rgd);
2224        v.extend_from_slice(&primary_gt);
2225        v.extend_from_slice(&redundant_gt);
2226        v.extend_from_slice(&grain);
2227        v
2228    }
2229
2230    #[test]
2231    fn rgd_fallback_recovers_grain_from_lost_primary_gte() {
2232        let vmdk = two_copy_vmdk_with_lost_primary_gte();
2233        // Without fallback the lost primary GTE reads as sparse (zeros).
2234        let mut r = VmdkReader::open(Cursor::new(vmdk.clone())).expect("open");
2235        let mut buf = [0xFFu8; 512];
2236        r.read_exact(&mut buf).expect("read");
2237        assert_eq!(
2238            buf, [0u8; 512],
2239            "lost primary GTE reads sparse without recovery"
2240        );
2241        // With fallback the grain is recovered from the redundant grain table.
2242        let mut r = VmdkReader::open(Cursor::new(vmdk)).expect("open");
2243        r.enable_rgd_fallback();
2244        let mut buf = [0u8; 512];
2245        r.read_exact(&mut buf).expect("read");
2246        assert_eq!(buf, [0xAB; 512], "grain recovered from redundant GT entry");
2247    }
2248
2249    #[test]
2250    fn iter_allocated_grains_recovers_via_rgd() {
2251        // The allocation scan walks the grain directory directly; a damaged primary GD
2252        // pointer errors the scan, but RGD fallback recovers the map via the redundant GD.
2253        let mut vmdk = test_sparse_vmdk(&[0xAB; 512]);
2254        let gd_byte = 21 * 512;
2255        vmdk[gd_byte..gd_byte + 4].copy_from_slice(&0xFFFF_FFFFu32.to_le_bytes());
2256        {
2257            let mut r = VmdkReader::open(Cursor::new(vmdk.clone())).expect("open");
2258            assert!(
2259                r.iter_allocated_grains().is_err(),
2260                "dangling primary GD pointer errors the scan without fallback"
2261            );
2262        }
2263        let mut r = VmdkReader::open(Cursor::new(vmdk)).expect("open");
2264        r.enable_rgd_fallback();
2265        let grains = r
2266            .iter_allocated_grains()
2267            .expect("allocation map recovered via RGD");
2268        assert_eq!(grains.len(), 1);
2269        assert_eq!(grains[0].start_lba, 0);
2270    }
2271
2272    #[test]
2273    fn iter_allocated_grains_recovers_lost_primary_gte() {
2274        // A grain whose primary GT entry is lost should be listed by the allocation
2275        // scan under recovery (consistent with dump/hash --recover being able to read it).
2276        let vmdk = two_copy_vmdk_with_lost_primary_gte();
2277        {
2278            let mut r = VmdkReader::open(Cursor::new(vmdk.clone())).expect("open");
2279            assert_eq!(
2280                r.iter_allocated_grains().expect("scan").len(),
2281                0,
2282                "lost primary GTE is not listed without recovery"
2283            );
2284        }
2285        let mut r = VmdkReader::open(Cursor::new(vmdk)).expect("open");
2286        r.enable_rgd_fallback();
2287        let grains = r.iter_allocated_grains().expect("scan");
2288        assert_eq!(grains.len(), 1, "lost GTE recovered from redundant GT");
2289        assert_eq!(grains[0].start_lba, 0);
2290    }
2291
2292    #[test]
2293    fn rgd_recovery_count_tracks_pointer_recovery() {
2294        // Pointer-level recovery: a corrupt primary GD pointer counts one recovered grain.
2295        let mut vmdk = test_sparse_vmdk(&[0xAB; 512]);
2296        let gd_byte = 21 * 512;
2297        vmdk[gd_byte..gd_byte + 4].copy_from_slice(&0xFFFF_FFFFu32.to_le_bytes());
2298        let mut r = VmdkReader::open(Cursor::new(vmdk)).expect("open");
2299        r.enable_rgd_fallback();
2300        assert_eq!(r.rgd_recovery_count(), 0);
2301        let mut buf = [0u8; 512];
2302        r.read_exact(&mut buf).expect("read");
2303        assert_eq!(
2304            r.rgd_recovery_count(),
2305            1,
2306            "one grain recovered via RGD pointer"
2307        );
2308    }
2309
2310    #[test]
2311    fn rgd_recovery_count_tracks_entry_recovery() {
2312        // Content-level recovery: a lost primary GT entry counts one recovered grain.
2313        let vmdk = two_copy_vmdk_with_lost_primary_gte();
2314        let mut r = VmdkReader::open(Cursor::new(vmdk)).expect("open");
2315        r.enable_rgd_fallback();
2316        let mut buf = [0u8; 512];
2317        r.read_exact(&mut buf).expect("read");
2318        assert_eq!(
2319            r.rgd_recovery_count(),
2320            1,
2321            "one grain recovered via RGD entry"
2322        );
2323    }
2324
2325    #[test]
2326    fn rgd_recovery_count_zero_on_healthy_image() {
2327        let vmdk = test_sparse_vmdk(&[0xAB; 512]);
2328        let mut r = VmdkReader::open(Cursor::new(vmdk)).expect("open");
2329        r.enable_rgd_fallback();
2330        let mut buf = [0u8; 512];
2331        r.read_exact(&mut buf).expect("read");
2332        assert_eq!(
2333            r.rgd_recovery_count(),
2334            0,
2335            "healthy read uses the primary GD"
2336        );
2337    }
2338
2339    #[test]
2340    fn rgd_recovery_count_in_allocation_scan() {
2341        let vmdk = two_copy_vmdk_with_lost_primary_gte();
2342        let mut r = VmdkReader::open(Cursor::new(vmdk)).expect("open");
2343        r.enable_rgd_fallback();
2344        let _ = r.iter_allocated_grains().expect("scan");
2345        assert_eq!(r.rgd_recovery_count(), 1, "scan counts the recovered grain");
2346    }
2347
2348    #[test]
2349    fn open_rejects_capacity_overflow() {
2350        // capacity * 512 overflows u64 → GeometryOverflow rather than a panic.
2351        let mut vmdk = test_sparse_vmdk(&[0u8; 512]);
2352        vmdk[12..20].copy_from_slice(&u64::MAX.to_le_bytes());
2353        assert!(matches!(
2354            VmdkReader::open(Cursor::new(vmdk)),
2355            Err(VmdkError::GeometryOverflow { field: "capacity" })
2356        ));
2357    }
2358
2359    #[test]
2360    fn content_recovery_with_no_rgd_offset_reads_sparse() {
2361        // Primary GT entry lost + no RGD: content recovery finds nothing, stays sparse.
2362        // Exercises rgd_dir_entry (rgd_offset == 0) and rgd_gte (sector == 0) guards.
2363        let mut vmdk = test_sparse_vmdk(&[0xAB; 512]);
2364        vmdk[23 * 512..23 * 512 + 4].copy_from_slice(&0u32.to_le_bytes()); // primary GTE[0] = 0
2365        vmdk[48..56].copy_from_slice(&0u64.to_le_bytes()); // rgd_offset = 0
2366        let mut r = VmdkReader::open(Cursor::new(vmdk)).expect("open");
2367        r.enable_rgd_fallback();
2368        let mut buf = [0xFFu8; 512];
2369        r.read_exact(&mut buf).expect("read");
2370        assert_eq!(buf, [0u8; 512]);
2371    }
2372
2373    #[test]
2374    fn fallback_with_out_of_bounds_rgd_offset_is_safe() {
2375        // Corrupt primary GD + an rgd_offset that points past EOF: the RGD entry read is
2376        // bounds-checked (rgd_dir_entry / read_redundant_gt return 0/None), no panic.
2377        let mut vmdk = test_sparse_vmdk(&[0xAB; 512]);
2378        vmdk[21 * 512..21 * 512 + 4].copy_from_slice(&0xFFFF_FFFFu32.to_le_bytes());
2379        vmdk[48..56].copy_from_slice(&9_999_999u64.to_le_bytes());
2380        let mut r = VmdkReader::open(Cursor::new(vmdk)).expect("open");
2381        r.enable_rgd_fallback();
2382        let _ = r.iter_allocated_grains();
2383    }
2384
2385    #[test]
2386    fn fallback_scan_with_rgd_gt_past_eof_lists_primary() {
2387        // RGD entry points to a grain table past EOF: read_redundant_gt rejects it, but
2388        // the (valid) primary grain table is still scanned.
2389        let mut vmdk = test_sparse_vmdk(&[0xAB; 512]);
2390        vmdk[22 * 512..22 * 512 + 4].copy_from_slice(&9_999_999u32.to_le_bytes());
2391        let mut r = VmdkReader::open(Cursor::new(vmdk)).expect("open");
2392        r.enable_rgd_fallback();
2393        let grains = r.iter_allocated_grains().expect("scan");
2394        assert_eq!(grains.len(), 1);
2395    }
2396
2397    #[test]
2398    fn content_recovery_with_rgd_gt_past_eof_reads_sparse() {
2399        // Primary GT entry lost + the redundant GT pointer is past EOF: rgd_gte rejects
2400        // it and the grain stays sparse (no panic, no out-of-bounds read).
2401        let mut vmdk = test_sparse_vmdk(&[0xAB; 512]);
2402        vmdk[23 * 512..23 * 512 + 4].copy_from_slice(&0u32.to_le_bytes());
2403        vmdk[22 * 512..22 * 512 + 4].copy_from_slice(&9_999_999u32.to_le_bytes());
2404        let mut r = VmdkReader::open(Cursor::new(vmdk)).expect("open");
2405        r.enable_rgd_fallback();
2406        let mut buf = [0xFFu8; 512];
2407        r.read_exact(&mut buf).expect("read");
2408        assert_eq!(buf, [0u8; 512]);
2409    }
2410
2411    #[test]
2412    fn rgd_fallback_is_noop_on_healthy_image() {
2413        // Enabling fallback must not change reads on an intact image.
2414        let vmdk = test_sparse_vmdk(&[0xAB; 512]);
2415        let mut r = VmdkReader::open(Cursor::new(vmdk)).expect("open");
2416        r.enable_rgd_fallback();
2417        let mut buf = [0u8; 512];
2418        r.read_exact(&mut buf).expect("read healthy image");
2419        assert_eq!(buf, [0xAB; 512]);
2420    }
2421
2422    #[test]
2423    fn info_on_sesparse() {
2424        let se = test_sesparse_vmdk(&[0u8; 512]);
2425        let r = VmdkReader::open(Cursor::new(se)).expect("open");
2426        let info = r.info();
2427        assert_eq!(info.disk_type, "seSparse");
2428        assert_eq!(info.grain_size_bytes, 8 * 512);
2429    }
2430
2431    #[test]
2432    fn open_rejects_grain_directory_too_large() {
2433        // A monolithicSparse header with an enormous capacity → GD exceeds 16 MiB.
2434        let img = vmdk_header_bytes(1_000_000_000_000, 8, 512);
2435        assert!(matches!(
2436            VmdkReader::open(Cursor::new(img)),
2437            Err(VmdkError::FieldOutOfRange {
2438                field: "grain_directory",
2439                ..
2440            })
2441        ));
2442    }
2443
2444    /// Patch seSparse GTE[0] (grain table at sector 3, first entry) to `gte`.
2445    fn sesparse_with_gte0(gte: u64) -> Vec<u8> {
2446        let mut se = test_sesparse_vmdk(&[0xABu8; 512]);
2447        let gt = 3 * 512; // GT_OFFSET sector in testutil layout
2448        se[gt..gt + 8].copy_from_slice(&gte.to_le_bytes());
2449        se
2450    }
2451
2452    #[test]
2453    fn sesparse_zero_unmapped_and_empty_gtes_read_as_zeros() {
2454        for gte in [0u64, 0x1000_0000_0000_0000, 0x2000_0000_0000_0000] {
2455            let mut r = VmdkReader::open(Cursor::new(sesparse_with_gte0(gte))).expect("open");
2456            r.seek(SeekFrom::Start(0)).unwrap();
2457            let mut buf = [0xFFu8; 512];
2458            r.read_exact(&mut buf).expect("read");
2459            assert_eq!(buf, [0u8; 512], "gte {gte:#x} must read as zeros");
2460        }
2461    }
2462
2463    #[test]
2464    fn sesparse_unsupported_type_nibble_errors_on_read() {
2465        // Nibble 0x4 is not a defined seSparse grain type.
2466        let mut r =
2467            VmdkReader::open(Cursor::new(sesparse_with_gte0(0x4000_0000_0000_0000))).expect("open");
2468        let mut buf = [0u8; 512];
2469        let err = r.read(&mut buf).expect_err("unsupported nibble must error");
2470        assert_eq!(err.kind(), io::ErrorKind::InvalidData);
2471    }
2472
2473    #[test]
2474    fn custom_create_type_with_sparse_extent_opens() {
2475        use std::io::Write as _;
2476        let dir = tempfile::tempdir().unwrap();
2477        let ext = test_sparse_vmdk(&[0xC5u8; 512]);
2478        std::fs::File::create(dir.path().join("disk-s001.vmdk"))
2479            .unwrap()
2480            .write_all(&ext)
2481            .unwrap();
2482        let desc = "# Disk DescriptorFile\nversion=1\nCID=ffffffff\nparentCID=ffffffff\ncreateType=\"custom\"\nRW 8 SPARSE \"disk-s001.vmdk\"\n";
2483        let desc_path = dir.path().join("disk.vmdk");
2484        std::fs::write(&desc_path, desc.as_bytes()).unwrap();
2485        let mut r = VmdkFileReader::open_path(&desc_path).expect("custom+sparse opens");
2486        let mut buf = [0u8; 1];
2487        r.read_exact(&mut buf).expect("read");
2488        assert_eq!(buf[0], 0xC5);
2489    }
2490
2491    #[test]
2492    fn custom_create_type_with_no_extents_errors() {
2493        let dir = tempfile::tempdir().unwrap();
2494        let desc = "# Disk DescriptorFile\nversion=1\nCID=ffffffff\nparentCID=ffffffff\ncreateType=\"custom\"\n";
2495        let desc_path = dir.path().join("disk.vmdk");
2496        std::fs::write(&desc_path, desc.as_bytes()).unwrap();
2497        assert!(matches!(
2498            VmdkFileReader::open_path(&desc_path),
2499            Err(VmdkError::MalformedDescriptor(_))
2500        ));
2501    }
2502
2503    #[test]
2504    fn compressed_grain_decompressing_past_grain_size_is_refused() {
2505        // A streamOptimized grain whose zlib payload expands far beyond the grain
2506        // size is a decompression bomb; reading it must error rather than
2507        // materialize the full expansion in memory.
2508        use std::io::Read as _;
2509        let vmdk = crate::testutil::compressed_vmdk_with_bomb_grain(4 * 1024 * 1024);
2510        let mut r = VmdkReader::open(Cursor::new(vmdk)).expect("open");
2511        let mut buf = [0u8; 512];
2512        assert!(
2513            r.read(&mut buf).is_err(),
2514            "a grain that decompresses beyond its grain size must be refused"
2515        );
2516    }
2517
2518    #[test]
2519    fn descriptor_extent_path_cannot_escape_image_directory() {
2520        // A crafted descriptor must not be able to read files outside the image
2521        // directory via an absolute or `..`-climbing extent path.
2522        let outer = tempfile::tempdir().unwrap();
2523        std::fs::write(outer.path().join("secret.bin"), vec![0u8; 1024]).unwrap();
2524        let img = outer.path().join("img");
2525        std::fs::create_dir(&img).unwrap();
2526        let desc = "# Disk DescriptorFile\nversion=1\nCID=ffffffff\nparentCID=ffffffff\ncreateType=\"twoGbMaxExtentFlat\"\nRW 2 FLAT \"../secret.bin\" 0\n";
2527        let desc_path = img.join("disk.vmdk");
2528        std::fs::write(&desc_path, desc.as_bytes()).unwrap();
2529        // The extent path escapes the image directory — opening it must be refused.
2530        assert!(VmdkFileReader::open_path(&desc_path).is_err());
2531    }
2532
2533    #[test]
2534    fn custom_create_type_with_mixed_extents_errors() {
2535        // A `custom` descriptor listing BOTH a flat and a sparse extent must fail
2536        // loud rather than silently using only the flat extents and dropping the
2537        // sparse ones (silent wrong output / under-reported capacity).
2538        let dir = tempfile::tempdir().unwrap();
2539        let desc = "# Disk DescriptorFile\nversion=1\nCID=ffffffff\nparentCID=ffffffff\ncreateType=\"custom\"\nRW 2048 FLAT \"flat.bin\" 0\nRW 2048 SPARSE \"sparse.vmdk\"\n";
2540        let desc_path = dir.path().join("disk.vmdk");
2541        std::fs::write(&desc_path, desc.as_bytes()).unwrap();
2542        assert!(matches!(
2543            VmdkFileReader::open_path(&desc_path),
2544            Err(VmdkError::MalformedDescriptor(_))
2545        ));
2546    }
2547
2548    #[test]
2549    fn open_path_rejects_unknown_create_type() {
2550        let dir = tempfile::tempdir().unwrap();
2551        let desc = "# Disk DescriptorFile\nversion=1\nCID=ffffffff\nparentCID=ffffffff\ncreateType=\"someFutureFormat\"\n";
2552        let desc_path = dir.path().join("disk.vmdk");
2553        std::fs::write(&desc_path, desc.as_bytes()).unwrap();
2554        assert!(matches!(
2555            VmdkFileReader::open_path(&desc_path),
2556            Err(VmdkError::UnsupportedDiskType(_))
2557        ));
2558    }
2559
2560    /// A monolithicSparse VMDK with `num_gtes_per_gt` GTEs per GT and a zeroed
2561    /// second grain-directory entry, so grain index `num_gtes_per_gt` resolves to
2562    /// `gt_sector` == 0 (the "empty grain table" branch).
2563    fn sparse_with_zero_gd_entry() -> Vec<u8> {
2564        // capacity spans 2 grain-table groups (513 grains); GD has 2 entries.
2565        // GD[0] → a real grain table (grain 0 sparse), GD[1] = 0.
2566        const NGTE: u64 = 512;
2567        const GRAIN: u64 = 8;
2568        let capacity = (NGTE + 1) * GRAIN; // 513 grains
2569        let gd_sector = 1u64;
2570        let gt_sector = 2u64;
2571        let total_sectors = 10u64;
2572        let mut v = vec![0u8; total_sectors as usize * 512];
2573        v[0..4].copy_from_slice(&0x564D_444Bu32.to_le_bytes());
2574        v[4..8].copy_from_slice(&1u32.to_le_bytes());
2575        v[12..20].copy_from_slice(&capacity.to_le_bytes());
2576        v[20..28].copy_from_slice(&GRAIN.to_le_bytes());
2577        v[44..48].copy_from_slice(&(NGTE as u32).to_le_bytes());
2578        v[56..64].copy_from_slice(&gd_sector.to_le_bytes()); // gd_offset
2579                                                             // GD at sector 1: entry0 → gt_sector(2), entry1 → 0 (empty).
2580        let gd = gd_sector as usize * 512;
2581        v[gd..gd + 4].copy_from_slice(&(gt_sector as u32).to_le_bytes());
2582        // GD[1] stays 0. GT at sector 2 is all-zero → grain 0 sparse.
2583        v
2584    }
2585
2586    #[test]
2587    fn sesparse_descriptor_without_extent_errors() {
2588        use std::io::Write as _;
2589        let dir = tempfile::tempdir().unwrap();
2590        let desc = "# Disk DescriptorFile\nversion=1\nCID=ffffffff\nparentCID=ffffffff\ncreateType=\"seSparse\"\n";
2591        let p = dir.path().join("disk.vmdk");
2592        std::fs::File::create(&p)
2593            .unwrap()
2594            .write_all(desc.as_bytes())
2595            .unwrap();
2596        assert!(matches!(
2597            VmdkFileReader::open_path(&p),
2598            Err(VmdkError::MalformedDescriptor(_))
2599        ));
2600    }
2601
2602    #[test]
2603    fn sparse_empty_grain_table_entry_reads_zero_and_iterates_empty() {
2604        let vmdk = sparse_with_zero_gd_entry();
2605        let mut r = VmdkReader::open(Cursor::new(vmdk)).expect("open");
2606        // LBA in the second GD group (grain 512) → gt_sector == 0 branch.
2607        let lba = 512 * 8; // grain 512 start
2608        assert!(!r.is_allocated(lba).expect("is_allocated"));
2609        // Read there → zeros (grain_location gt_sector==0 → Sparse).
2610        r.seek(SeekFrom::Start(lba * 512)).unwrap();
2611        let mut buf = [0xFFu8; 512];
2612        r.read_exact(&mut buf).unwrap();
2613        assert_eq!(buf, [0u8; 512]);
2614        // iter_allocated_grains skips both the sparse GTE and the empty GD entry.
2615        assert!(r.iter_allocated_grains().expect("iter").is_empty());
2616    }
2617
2618    #[test]
2619    fn flat_zero_capacity_iter_is_empty() {
2620        // A ZERO-only flat descriptor with 0 sectors → empty virtual disk → no grains.
2621        use std::io::Write as _;
2622        let dir = tempfile::tempdir().unwrap();
2623        let desc = "# Disk DescriptorFile\nversion=1\nCID=ffffffff\nparentCID=ffffffff\ncreateType=\"monolithicFlat\"\nRW 0 ZERO\n";
2624        let p = dir.path().join("empty.vmdk");
2625        std::fs::File::create(&p)
2626            .unwrap()
2627            .write_all(desc.as_bytes())
2628            .unwrap();
2629        let mut r = VmdkFileReader::open_path(&p).expect("open empty flat");
2630        assert_eq!(r.virtual_disk_size(), 0);
2631        assert!(r.iter_allocated_grains().expect("iter").is_empty());
2632    }
2633}