Skip to main content

vmdk/
header.rs

1//! Sparse extent header (Virtual Disk Format 1.1, §4.1).
2
3use crate::error::{Result, VmdkError};
4
5pub const MAGIC: u32 = 0x564D_444B;
6pub const VERSION: u32 = 1;
7/// Version 2 enables the zeroed-grain feature (GTE == 1 → explicit zero grain).
8pub const VERSION_ZEROED_GRAIN: u32 = 2;
9pub const VERSION_STREAM_OPT: u32 = 3;
10pub const SECTOR_SIZE: u64 = 512;
11
12/// Maximum grain-table entries per grain table (VDF 1.1 §4.1: `numGTEsPerGT` = 512).
13///
14/// QEMU's `vmdk_open_vmdk4` rejects any larger value. The read path allocates
15/// `num_gtes_per_gt * 4` bytes per grain table, so this bound caps that allocation
16/// at 2 KiB and prevents a crafted header from forcing a huge allocation.
17pub const MAX_NUM_GTES_PER_GT: u32 = 512;
18
19/// Upper bound on `grainSize` (in sectors). Real VMDK grains are powers of two and
20/// tiny — 128 sectors (64 KiB) is the VMware default and ESXi/VMFS tops out around
21/// 2048 sectors (1 MiB). This cap (32 MiB) is orders of magnitude above any genuine
22/// file, yet bounds the compressed-grain read allocation (`vec![0u8; data_size]`,
23/// where `data_size <= grain_size_bytes + 64 KiB`) so a crafted header cannot force
24/// a multi-gigabyte allocation. Mirrors the `MAX_NUM_GTES_PER_GT` allocation cap.
25pub const MAX_GRAIN_SIZE_SECTORS: u64 = 0x10000;
26
27/// Sentinel `gdOffset` in the *primary* header of a `streamOptimized` extent.
28///
29/// When `gdOffset == GD_AT_END` the real GD location is in the *footer* header
30/// appended to the end of the file: `SparseExtentHeader` at `file_end − 1024`,
31/// followed by an EOS marker at `file_end − 512` (VDF 1.1 §4.6).
32pub const GD_AT_END: u64 = 0xffff_ffff_ffff_ffff;
33
34/// Parsed fields from the 512-byte `SparseExtentHeader`.
35pub struct SparseExtentHeader {
36    pub version: u32,           // 1 = monolithicSparse, 3 = streamOptimized
37    pub capacity: u64,          // virtual disk size in sectors
38    pub grain_size: u64,        // grain size in sectors
39    pub descriptor_offset: u64, // in sectors
40    pub descriptor_size: u64,   // in sectors
41    pub num_gtes_per_gt: u32,
42    pub rgd_offset: u64, // redundant grain directory offset in sectors (0 if absent)
43    pub gd_offset: u64,  // grain directory offset in sectors
44    /// `true` when `compress_algorithm == 1` (stream-optimised / DEFLATE).
45    pub compressed: bool,
46}
47
48impl SparseExtentHeader {
49    pub fn parse(data: &[u8]) -> Result<Self> {
50        if data.len() < 512 {
51            return Err(VmdkError::FileTooSmall);
52        }
53
54        let magic = u32::from_le_bytes(data[0..4].try_into().expect("4 bytes"));
55        if magic != MAGIC {
56            return Err(VmdkError::BadMagic);
57        }
58
59        let version = u32::from_le_bytes(data[4..8].try_into().expect("4 bytes"));
60        // Accept v1 (base), v2 (zeroed-grain feature) and v3 (streamOptimized).
61        // QEMU accepts any VMDK4-magic version; we cap at the three defined values.
62        if version != VERSION && version != VERSION_ZEROED_GRAIN && version != VERSION_STREAM_OPT {
63            return Err(VmdkError::UnsupportedVersion(version));
64        }
65
66        let capacity = u64::from_le_bytes(data[12..20].try_into().expect("8 bytes"));
67        let grain_size = u64::from_le_bytes(data[20..28].try_into().expect("8 bytes"));
68        let descriptor_offset = u64::from_le_bytes(data[28..36].try_into().expect("8 bytes"));
69        let descriptor_size = u64::from_le_bytes(data[36..44].try_into().expect("8 bytes"));
70        let num_gtes_per_gt = u32::from_le_bytes(data[44..48].try_into().expect("4 bytes"));
71        let rgd_offset = u64::from_le_bytes(data[48..56].try_into().expect("8 bytes"));
72        let gd_offset = u64::from_le_bytes(data[56..64].try_into().expect("8 bytes"));
73        let compress_algorithm = u16::from_le_bytes(data[77..79].try_into().expect("2 bytes"));
74
75        // v1: compression must be absent; v3 (streamOptimized): deflate (1) is expected.
76        // Spec note (VDF 1.1 §4.4): COMPRESSION_DEFLATE is described as RFC 1951 (raw
77        // DEFLATE), but both VMware tooling and QEMU actually produce RFC 1950 payloads
78        // (2-byte zlib header + DEFLATE stream + Adler-32 trailer).  Use ZlibDecoder,
79        // not DeflateDecoder — the spec has a documentation error.
80        match (version, compress_algorithm) {
81            (VERSION | VERSION_ZEROED_GRAIN, 0) | (VERSION_STREAM_OPT, 1) => {}
82            _ => return Err(VmdkError::CompressedNotSupported),
83        }
84
85        // Validate geometry before these values feed division arithmetic in the reader.
86        // VDF 1.1 §4.1: minimum grain size is 8 sectors (4 KiB).
87        if grain_size < 8 {
88            return Err(VmdkError::FieldOutOfRange {
89                field: "grain_size",
90                value: grain_size,
91                reason: "must be >= 8 sectors (VDF 1.1 §4.1)",
92            });
93        }
94        // Upper bound: caps the compressed-grain read allocation so a crafted header
95        // cannot drive a multi-gigabyte allocation (fuzz_recover oom-2763835523).
96        if grain_size > MAX_GRAIN_SIZE_SECTORS {
97            return Err(VmdkError::FieldOutOfRange {
98                field: "grain_size",
99                value: grain_size,
100                reason: "exceeds the maximum supported grain size (32 MiB)",
101            });
102        }
103        if num_gtes_per_gt == 0 {
104            return Err(VmdkError::FieldOutOfRange {
105                field: "num_gtes_per_gt",
106                value: u64::from(num_gtes_per_gt),
107                reason: "must be > 0",
108            });
109        }
110        // VDF 1.1 defines numGTEsPerGT as 512; QEMU's vmdk_open_vmdk4 rejects any
111        // larger value. Enforcing it here bounds the read path's grain-table
112        // allocation (`vec![0u8; num_gtes_per_gt * 4]`) at parse time, so no caller
113        // can be driven into a multi-gigabyte allocation by a crafted header.
114        if num_gtes_per_gt > MAX_NUM_GTES_PER_GT {
115            return Err(VmdkError::FieldOutOfRange {
116                field: "num_gtes_per_gt",
117                value: u64::from(num_gtes_per_gt),
118                reason: "exceeds the spec maximum of 512",
119            });
120        }
121
122        Ok(SparseExtentHeader {
123            version,
124            capacity,
125            grain_size,
126            descriptor_offset,
127            descriptor_size,
128            num_gtes_per_gt,
129            rgd_offset,
130            gd_offset,
131            compressed: compress_algorithm != 0,
132        })
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139
140    fn valid_header() -> Vec<u8> {
141        let mut h = vec![0u8; 512];
142        h[0..4].copy_from_slice(&MAGIC.to_le_bytes());
143        h[4..8].copy_from_slice(&VERSION.to_le_bytes());
144        h[12..20].copy_from_slice(&8u64.to_le_bytes()); // capacity
145        h[20..28].copy_from_slice(&8u64.to_le_bytes()); // grain_size
146        h[44..48].copy_from_slice(&512u32.to_le_bytes()); // num_gtes_per_gt
147        h
148    }
149
150    #[test]
151    fn parse_rejects_short_buffer() {
152        assert!(matches!(
153            SparseExtentHeader::parse(&[0u8; 100]),
154            Err(VmdkError::FileTooSmall)
155        ));
156    }
157
158    #[test]
159    fn parse_rejects_bad_magic() {
160        let h = vec![0u8; 512];
161        assert!(matches!(
162            SparseExtentHeader::parse(&h),
163            Err(VmdkError::BadMagic)
164        ));
165    }
166
167    #[test]
168    fn parse_rejects_unsupported_version() {
169        let mut h = valid_header();
170        h[4..8].copy_from_slice(&4u32.to_le_bytes()); // version 4 is undefined
171        assert!(matches!(
172            SparseExtentHeader::parse(&h),
173            Err(VmdkError::UnsupportedVersion(4))
174        ));
175    }
176
177    #[test]
178    fn parse_accepts_version_2() {
179        let mut h = valid_header();
180        h[4..8].copy_from_slice(&VERSION_ZEROED_GRAIN.to_le_bytes());
181        let hdr = SparseExtentHeader::parse(&h).expect("v2 parses");
182        assert_eq!(hdr.version, 2);
183    }
184
185    #[test]
186    fn parse_rejects_grain_size_below_minimum() {
187        let mut h = valid_header();
188        h[20..28].copy_from_slice(&4u64.to_le_bytes()); // < 8
189        assert!(matches!(
190            SparseExtentHeader::parse(&h),
191            Err(VmdkError::FieldOutOfRange {
192                field: "grain_size",
193                value: 4,
194                ..
195            })
196        ));
197    }
198
199    #[test]
200    fn parse_rejects_grain_size_above_maximum() {
201        // A huge grainSize would size the compressed-grain read allocation into the
202        // gigabytes (fuzz_recover oom-2763835523); it must be rejected at parse time.
203        let mut h = valid_header();
204        let oversized = MAX_GRAIN_SIZE_SECTORS + 1;
205        h[20..28].copy_from_slice(&oversized.to_le_bytes());
206        assert!(matches!(
207            SparseExtentHeader::parse(&h),
208            Err(VmdkError::FieldOutOfRange {
209                field: "grain_size",
210                ..
211            })
212        ));
213    }
214
215    #[test]
216    fn parse_rejects_zero_num_gtes() {
217        let mut h = valid_header();
218        h[44..48].copy_from_slice(&0u32.to_le_bytes());
219        assert!(matches!(
220            SparseExtentHeader::parse(&h),
221            Err(VmdkError::FieldOutOfRange {
222                field: "num_gtes_per_gt",
223                value: 0,
224                ..
225            })
226        ));
227    }
228
229    #[test]
230    fn parse_rejects_num_gtes_above_spec_max() {
231        // VDF 1.1 defines numGTEsPerGT as 512; QEMU rejects anything larger.
232        // Without this bound a crafted header drives an unguarded
233        // `vec![0u8; num_gtes_per_gt * 4]` in the read path — e.g. 0xFFFFFFFF
234        // yields a ~17 GiB allocation (allocation-amplification DoS).
235        let mut h = valid_header();
236        h[44..48].copy_from_slice(&513u32.to_le_bytes());
237        assert!(matches!(
238            SparseExtentHeader::parse(&h),
239            Err(VmdkError::FieldOutOfRange {
240                field: "num_gtes_per_gt",
241                value: 513,
242                ..
243            })
244        ));
245
246        // The extreme crafted value must also be rejected, not allocated.
247        let mut h = valid_header();
248        h[44..48].copy_from_slice(&0xFFFF_FFFFu32.to_le_bytes());
249        assert!(matches!(
250            SparseExtentHeader::parse(&h),
251            Err(VmdkError::FieldOutOfRange {
252                field: "num_gtes_per_gt",
253                value: 0xFFFF_FFFF,
254                ..
255            })
256        ));
257    }
258
259    #[test]
260    fn parse_accepts_num_gtes_at_spec_max() {
261        // Exactly 512 is the canonical value and must remain valid.
262        let mut h = valid_header();
263        h[44..48].copy_from_slice(&512u32.to_le_bytes());
264        assert!(SparseExtentHeader::parse(&h).is_ok());
265    }
266
267    #[test]
268    fn parse_rejects_compressed_flag_on_v1() {
269        let mut h = valid_header();
270        h[77..79].copy_from_slice(&1u16.to_le_bytes()); // compress on v1 is invalid
271        assert!(matches!(
272            SparseExtentHeader::parse(&h),
273            Err(VmdkError::CompressedNotSupported)
274        ));
275    }
276}