Skip to main content

vhd/
lib.rs

1//! Pure-Rust read-only legacy VHD disk image reader.
2//!
3//! Implements the MS-VHD specification (Virtual PC / Virtual Server / Hyper-V
4//! Generation-1 format). Supports Fixed and Dynamic disk types. Differencing
5//! disks are rejected (parent locator resolution is out of scope).
6//!
7//! # Format overview
8//! Every VHD ends with a 512-byte footer (`cookie = "conectix"`).
9//! - **Fixed**: the footer immediately follows the raw sector data.
10//! - **Dynamic**: footer → dynamic header → Block Allocation Table (BAT)
11//!   → data blocks, with a copy of the footer at byte 0.
12
13#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
14
15use std::io::{Read, Seek, SeekFrom};
16use std::path::Path;
17
18mod dynamic;
19mod error;
20mod read;
21
22#[cfg(feature = "test-helpers")]
23pub mod footer;
24#[cfg(not(feature = "test-helpers"))]
25mod footer;
26
27#[cfg(feature = "vfs")]
28pub mod vfs;
29
30pub use error::VhdError;
31pub use footer::{DiskType, VhdFooter};
32
33/// A seekable, thread-safe byte source the reader can sit on: a `File`, an
34/// in-RAM `Cursor`, or a positioned sub-range of a `.zip`. Lets a caller open a
35/// VHD straight out of an archive (no temp-file extraction) via
36/// [`VhdReader::open_reader`], while [`VhdReader::open`] keeps the file-path
37/// convenience.
38pub trait ReadSeekSend: Read + Seek + Send + Sync {}
39impl<T: Read + Seek + Send + Sync> ReadSeekSend for T {}
40
41/// Read-only VHD container reader.
42///
43/// Implements `Read + Seek` over the virtual sector stream.
44pub struct VhdReader {
45    inner: VhdInner,
46    pos: u64,
47    virtual_disk_size: u64,
48    original_size: u64,
49}
50
51enum VhdInner {
52    Fixed {
53        file: Box<dyn ReadSeekSend>,
54    },
55    Dynamic {
56        file: Box<dyn ReadSeekSend>,
57        bat: dynamic::BlockAllocationTable,
58        block_size: u32,
59    },
60}
61
62impl VhdReader {
63    /// Open a VHD disk image.
64    ///
65    /// Returns [`VhdError`] if the file is not a valid VHD, or if it is a
66    /// Differencing disk (parent resolution is not supported).
67    pub fn open(path: &Path) -> Result<Self, VhdError> {
68        Self::open_reader(Box::new(std::fs::File::open(path)?))
69    }
70
71    /// Open a VHD image from any seekable byte source (a `Cursor` over inflated
72    /// bytes, a positioned sub-range of a `.zip`, …) rather than a file path —
73    /// so an image stored inside an archive can be read without extracting it to
74    /// a temp file first.
75    pub fn open_reader(mut backing: Box<dyn ReadSeekSend>) -> Result<Self, VhdError> {
76        // The footer (last 512 B) + dynamic header + BAT parsers take a whole-file
77        // slice, so materialize the backing once (the file-path `open` did the
78        // same via `std::fs::read`). The backing is then kept for block reads.
79        let mut data = Vec::new();
80        backing.read_to_end(&mut data)?;
81        let footer = footer::VhdFooter::parse(&data)?;
82
83        let (inner, virtual_disk_size) = match footer.disk_type {
84            footer::DiskType::Fixed => (VhdInner::Fixed { file: backing }, footer.current_size),
85            footer::DiskType::Dynamic => {
86                let dyn_hdr = dynamic::DynamicHeader::parse(&data, footer.data_offset)?;
87                let bat = dynamic::BlockAllocationTable::parse(&data, &dyn_hdr)?;
88                (
89                    VhdInner::Dynamic {
90                        file: backing,
91                        bat,
92                        block_size: dyn_hdr.block_size,
93                    },
94                    footer.current_size,
95                )
96            }
97        };
98
99        Ok(VhdReader {
100            inner,
101            pos: 0,
102            virtual_disk_size,
103            original_size: footer.original_size,
104        })
105    }
106
107    /// Virtual disk size in bytes — the current, readable capacity
108    /// (footer CurrentSize, offset 48). This is what qemu-img reports.
109    pub fn virtual_disk_size(&self) -> u64 {
110        self.virtual_disk_size
111    }
112
113    /// Original (creation-time) size in bytes (footer OriginalSize, offset 40).
114    /// Equals `virtual_disk_size` for an un-resized disk and differs after a
115    /// resize (a forensic signal). This is the value libvhdi reports as media size.
116    pub fn original_size(&self) -> u64 {
117        self.original_size
118    }
119
120    /// Disk type (Fixed or Dynamic).
121    pub fn disk_type(&self) -> DiskType {
122        match &self.inner {
123            VhdInner::Fixed { .. } => DiskType::Fixed,
124            VhdInner::Dynamic { .. } => DiskType::Dynamic,
125        }
126    }
127}
128
129impl Read for VhdReader {
130    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
131        if self.pos >= self.virtual_disk_size || buf.is_empty() {
132            return Ok(0);
133        }
134        let remaining = (self.virtual_disk_size - self.pos) as usize;
135        let to_read = buf.len().min(remaining);
136
137        match &mut self.inner {
138            VhdInner::Fixed { file } => {
139                file.seek(SeekFrom::Start(self.pos))?;
140                let n = file.read(&mut buf[..to_read])?;
141                self.pos += n as u64;
142                Ok(n)
143            }
144            VhdInner::Dynamic {
145                file,
146                bat,
147                block_size,
148            } => {
149                let block_size_u64 = u64::from(*block_size);
150                let block_end = ((self.pos / block_size_u64) + 1) * block_size_u64;
151                let chunk = to_read.min((block_end - self.pos) as usize);
152
153                if let Some(file_off) = bat
154                    .file_offset_for_byte(self.pos)
155                    .map_err(|e| std::io::Error::other(e.to_string()))?
156                {
157                    file.seek(SeekFrom::Start(file_off))?;
158                    let n = file.read(&mut buf[..chunk])?;
159                    self.pos += n as u64;
160                    Ok(n)
161                } else {
162                    // Sparse block — return zeroes.
163                    buf[..chunk].fill(0);
164                    self.pos += chunk as u64;
165                    Ok(chunk)
166                }
167            }
168        }
169    }
170}
171
172impl Seek for VhdReader {
173    fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
174        let new_pos = match pos {
175            SeekFrom::Start(n) => n as i64,
176            SeekFrom::Current(n) => self.pos as i64 + n,
177            SeekFrom::End(n) => self.virtual_disk_size as i64 + n,
178        };
179        if new_pos < 0 {
180            return Err(std::io::Error::new(
181                std::io::ErrorKind::InvalidInput,
182                "seek before start",
183            ));
184        }
185        self.pos = new_pos as u64;
186        Ok(self.pos)
187    }
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193
194    // Minimal valid Fixed VHD: 512 bytes of sector data + 512-byte footer.
195    fn fixed_vhd_bytes(sector_data: &[u8]) -> Vec<u8> {
196        let mut buf = sector_data.to_vec();
197        buf.extend_from_slice(&footer::test_fixed_footer(sector_data.len() as u64));
198        buf
199    }
200
201    fn write_tmp(data: &[u8]) -> tempfile::NamedTempFile {
202        use std::io::Write;
203        let mut f = tempfile::NamedTempFile::new().unwrap();
204        f.write_all(data).unwrap();
205        f
206    }
207
208    #[test]
209    fn seek_current_and_end_and_before_start() {
210        let image = fixed_vhd_bytes(&[0u8; 1024]);
211        let tmp = write_tmp(&image);
212        let mut r = VhdReader::open(tmp.path()).unwrap();
213        r.seek(SeekFrom::Start(200)).unwrap();
214        assert_eq!(r.seek(SeekFrom::Current(100)).unwrap(), 300); // relative seek
215        assert_eq!(
216            r.seek(SeekFrom::End(-10)).unwrap(),
217            r.virtual_disk_size() - 10
218        );
219        // Seeking before the start is an error, not a panic.
220        assert!(r.seek(SeekFrom::Current(-100_000)).is_err());
221    }
222
223    #[test]
224    fn open_reader_over_cursor_matches_open_path() {
225        use std::io::Cursor;
226        let sector: Vec<u8> = (0u8..=255).cycle().take(1024).collect();
227        let image = fixed_vhd_bytes(&sector);
228
229        // Oracle: open(path) and read the whole virtual disk.
230        let tmp = write_tmp(&image);
231        let mut via_path = VhdReader::open(tmp.path()).expect("open path");
232        let mut want = Vec::new();
233        via_path.read_to_end(&mut want).expect("read path");
234
235        // Under test: open_reader over an in-RAM Cursor of the SAME bytes — the
236        // zip-direct backing path.
237        let mut via_reader =
238            VhdReader::open_reader(Box::new(Cursor::new(image.clone()))).expect("open_reader");
239        let mut got = Vec::new();
240        via_reader.read_to_end(&mut got).expect("read reader");
241
242        assert_eq!(
243            got, want,
244            "open_reader must read byte-identically to open(path)"
245        );
246        assert_eq!(via_reader.virtual_disk_size(), via_path.virtual_disk_size());
247    }
248
249    #[test]
250    fn open_nonexistent_returns_err() {
251        assert!(VhdReader::open(Path::new("/tmp/no_such.vhd")).is_err());
252    }
253
254    #[test]
255    fn open_empty_file_returns_err() {
256        let f = write_tmp(&[]);
257        assert!(VhdReader::open(f.path()).is_err());
258    }
259
260    #[test]
261    fn open_non_vhd_file_returns_err() {
262        let f = write_tmp(b"this is not a vhd file at all, no footer here");
263        assert!(VhdReader::open(f.path()).is_err());
264    }
265
266    #[test]
267    fn fixed_vhd_size_matches_footer() {
268        let sector = vec![0u8; 512];
269        let vhd = fixed_vhd_bytes(&sector);
270        let f = write_tmp(&vhd);
271        let reader = VhdReader::open(f.path()).expect("open fixed vhd");
272        assert_eq!(reader.virtual_disk_size(), 512);
273    }
274
275    #[test]
276    fn fixed_vhd_disk_type_is_fixed() {
277        let sector = vec![0u8; 512];
278        let vhd = fixed_vhd_bytes(&sector);
279        let f = write_tmp(&vhd);
280        let reader = VhdReader::open(f.path()).expect("open fixed vhd");
281        assert_eq!(reader.disk_type(), DiskType::Fixed);
282    }
283
284    #[test]
285    fn fixed_vhd_read_returns_sector_data() {
286        let mut sector = vec![0u8; 512];
287        sector[42] = 0xDE;
288        sector[43] = 0xAD;
289        let vhd = fixed_vhd_bytes(&sector);
290        let f = write_tmp(&vhd);
291        let mut reader = VhdReader::open(f.path()).expect("open");
292        let mut buf = vec![0u8; 512];
293        reader.read_exact(&mut buf).expect("read");
294        assert_eq!(buf[42], 0xDE);
295        assert_eq!(buf[43], 0xAD);
296    }
297
298    #[test]
299    fn seek_and_read_at_offset() {
300        let mut sector = vec![0u8; 512];
301        sector[100] = 0xBE;
302        sector[101] = 0xEF;
303        let vhd = fixed_vhd_bytes(&sector);
304        let f = write_tmp(&vhd);
305        let mut reader = VhdReader::open(f.path()).expect("open");
306        reader.seek(SeekFrom::Start(100)).unwrap();
307        let mut buf = [0u8; 2];
308        reader.read_exact(&mut buf).unwrap();
309        assert_eq!(buf, [0xBE, 0xEF]);
310    }
311
312    #[test]
313    fn differencing_disk_returns_err() {
314        // A footer with disk_type=4 (Differencing) must be rejected.
315        let mut footer_bytes = footer::test_fixed_footer(512);
316        // Disk type field is at offset 60 in the footer; set to 4.
317        footer_bytes[60] = 0;
318        footer_bytes[61] = 0;
319        footer_bytes[62] = 0;
320        footer_bytes[63] = 4;
321        let mut vhd = vec![0u8; 512];
322        vhd.extend_from_slice(&footer_bytes);
323        let f = write_tmp(&vhd);
324        assert!(VhdReader::open(f.path()).is_err());
325    }
326
327    #[test]
328    fn vhd_reader_is_send() {
329        fn assert_send<T: Send>() {}
330        assert_send::<VhdReader>();
331    }
332
333    // ── block_size=0 in dynamic header must be rejected, not panic ────────────
334    //
335    // A crafted dynamic VHD with block_size=0 causes div-by-zero in:
336    //   file_offset_for_byte: virtual_byte / block_size
337    //   Read::read: self.pos / block_size_u64
338    // open() must return Err before reaching those sites.
339    #[test]
340    fn dynamic_vhd_block_size_zero_rejected() {
341        use std::io::Write;
342
343        const BLOCK_SIZE: u64 = 0; // deliberately invalid
344        let mut file = vec![0u8; 4096];
345
346        let footer = {
347            let mut f = vec![0u8; 512];
348            f[0..8].copy_from_slice(b"conectix");
349            f[8..12].copy_from_slice(&0x0000_0002u32.to_be_bytes());
350            f[12..16].copy_from_slice(&0x0001_0000u32.to_be_bytes());
351            f[16..24].copy_from_slice(&512u64.to_be_bytes());
352            f[40..48].copy_from_slice(&(512u64).to_be_bytes()); // original_size (offset 40)
353            f[48..56].copy_from_slice(&(512u64).to_be_bytes()); // current_size (offset 48)
354            f[60..64].copy_from_slice(&3u32.to_be_bytes()); // Dynamic
355            let mut s: u32 = 0;
356            for (i, &b) in f.iter().enumerate() {
357                if !(64..68).contains(&i) {
358                    s = s.wrapping_add(u32::from(b));
359                }
360            }
361            f[64..68].copy_from_slice(&(!s).to_be_bytes());
362            f
363        };
364
365        file[0..512].copy_from_slice(&footer);
366        file[3584..4096].copy_from_slice(&footer);
367        file[512..520].copy_from_slice(b"cxsparse");
368        file[512 + 16..512 + 24].copy_from_slice(&1536u64.to_be_bytes());
369        file[512 + 28..512 + 32].copy_from_slice(&1u32.to_be_bytes());
370        file[512 + 32..512 + 36].copy_from_slice(&(BLOCK_SIZE as u32).to_be_bytes()); // 0!
371
372        let mut tmp = tempfile::NamedTempFile::new().unwrap();
373        tmp.write_all(&file).unwrap();
374        assert!(
375            VhdReader::open(tmp.path()).is_err(),
376            "block_size=0 must be rejected at open() to prevent div-by-zero"
377        );
378    }
379
380    // ── BITMAP_SECTORS must be computed from block_size, not hardcoded to 1 ────
381    //
382    // MS-VHD spec §2.3: each dynamic block is preceded by a sector bitmap whose
383    // size (in sectors) = ((block_size / (8 * 512) + 511) & ~511) / 512.
384    // For the standard 2 MiB block_size this is 1 sector; for 4 MiB it is 2.
385    // Hardcoding BITMAP_SECTORS = 1 causes 4 MiB blocks to be mis-read by exactly
386    // 512 bytes — returning the second bitmap sector instead of the first data sector.
387    #[test]
388    fn bitmap_sectors_computed_for_4mib_block_size() {
389        use std::io::Write;
390
391        // Build a minimal dynamic VHD with block_size = 4 MiB.
392        //
393        // File layout (all offsets in bytes):
394        //   [0..512)   : footer copy (dynamic, data_offset=512, virtual_size=4MiB)
395        //   [512..1536): dynamic header (bat_offset=1536, block_size=4MiB, max_bat_entries=1)
396        //   [1536..2048): BAT (entry 0 = sector 4, padded)
397        //   [2048..2560): block 0 bitmap sector 1 (0xFF — all sectors present)
398        //   [2560..3072): block 0 bitmap sector 2 (0xFF)
399        //   [3072..3584): block 0 data sector 0 (0xAB pattern — the "right" answer)
400        //   [3584..4096): real footer (same as copy)
401
402        const BLOCK_SIZE: u64 = 4 * 1024 * 1024;
403        let mut file = vec![0u8; 4096];
404
405        // Footer builder (dynamic disk type).
406        let footer = {
407            let mut f = vec![0u8; 512];
408            f[0..8].copy_from_slice(b"conectix");
409            f[8..12].copy_from_slice(&0x0000_0002u32.to_be_bytes()); // features
410            f[12..16].copy_from_slice(&0x0001_0000u32.to_be_bytes()); // file format version
411            f[16..24].copy_from_slice(&512u64.to_be_bytes()); // data_offset → dynamic header
412            f[40..48].copy_from_slice(&BLOCK_SIZE.to_be_bytes()); // original_size (offset 40)
413            f[48..56].copy_from_slice(&BLOCK_SIZE.to_be_bytes()); // current_size (offset 48)
414            f[60..64].copy_from_slice(&3u32.to_be_bytes()); // disk_type = Dynamic
415                                                            // One's-complement checksum (bytes 64-67 zeroed during computation).
416            let mut s: u32 = 0;
417            for (i, &b) in f.iter().enumerate() {
418                if !(64..68).contains(&i) {
419                    s = s.wrapping_add(u32::from(b));
420                }
421            }
422            f[64..68].copy_from_slice(&(!s).to_be_bytes());
423            f
424        };
425
426        file[0..512].copy_from_slice(&footer); // footer copy
427        file[3584..4096].copy_from_slice(&footer); // real footer at end
428
429        // Dynamic header (bat_offset=1536, block_size=4MiB, max_bat_entries=1).
430        file[512..520].copy_from_slice(b"cxsparse");
431        file[512 + 16..512 + 24].copy_from_slice(&1536u64.to_be_bytes()); // bat_offset
432        file[512 + 28..512 + 32].copy_from_slice(&1u32.to_be_bytes()); // max_bat_entries
433        file[512 + 32..512 + 36].copy_from_slice(&(BLOCK_SIZE as u32).to_be_bytes()); // block_size
434
435        // BAT: entry 0 = sector 4 (byte 2048 = start of block 0).
436        file[1536..1540].copy_from_slice(&4u32.to_be_bytes());
437
438        // Block 0 bitmap: 2 sectors × 0xFF (all 512-byte sectors present).
439        file[2048..2560].fill(0xFF); // bitmap sector 1
440        file[2560..3072].fill(0xFF); // bitmap sector 2
441
442        // Block 0 data: known sentinel. The test asserts this is what we read.
443        // With BITMAP_SECTORS=1 (bug), the reader skips only 1 sector and reads
444        // bytes [2560..3072) which are 0xFF (bitmap) — mismatch.
445        file[3072..3584].fill(0xAB);
446
447        let mut tmp = tempfile::NamedTempFile::new().unwrap();
448        tmp.write_all(&file).unwrap();
449
450        let mut reader = VhdReader::open(tmp.path()).expect("open synthetic 4MiB-block vhd");
451        let mut buf = [0u8; 512];
452        reader.seek(SeekFrom::Start(0)).unwrap();
453        reader
454            .read_exact(&mut buf)
455            .expect("read block 0 data sector 0");
456        assert_eq!(
457            buf, [0xABu8; 512],
458            "with 4 MiB block_size, bitmap is 2 sectors (1024 bytes); \
459             BITMAP_SECTORS must not be hardcoded to 1"
460        );
461    }
462
463    // ── Differential test: bytes must match qemu-img convert -O raw output ────
464    //
465}