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