Skip to main content

vhdx/
reader.rs

1use std::fs::File;
2use std::io::{self, Read, Seek, SeekFrom};
3use std::path::Path;
4
5use crate::backing::{Backing, ReadSeekSend};
6use crate::bat::Bat;
7use crate::error::{Result, VhdxError};
8use crate::header::{parse_active_header, REGION_TABLE1_OFFSET, REGION_TABLE2_OFFSET};
9use crate::log::LogOverlay;
10use crate::metadata::{parse_metadata, VhdxMetadata};
11use crate::region::parse_region_table;
12use crate::FILE_MAGIC;
13
14/// Read-only VHDX container reader.
15///
16/// Implements `Read + Seek` over the virtual sector stream.
17///
18/// # Bounded memory
19/// The reader holds the BAT (a small `Vec<u64>`), the parsed metadata, and a
20/// log overlay (empty for a clean image, ~log-sized for a dirty one) — never the
21/// whole container. Block bytes are fetched on demand from a positioned
22/// [`Backing`], so peak RSS does not scale with image size. [`VhdxReader::open`]
23/// takes the bounded [`Backing::File`] path; [`VhdxReader::from_bytes`] keeps the
24/// legacy in-RAM path (backed by [`Backing::Mem`]).
25#[derive(Debug)]
26pub struct VhdxReader {
27    backing: Backing,
28    overlay: LogOverlay,
29    bat: Bat,
30    meta: VhdxMetadata,
31    pos: u64,
32    parent: Option<Box<VhdxReader>>,
33}
34
35/// Minimum container size: covers magic, both headers, and both region tables.
36const MIN_CONTAINER_SIZE: u64 = 0x0025_0000;
37
38impl VhdxReader {
39    /// Open a VHDX container from a path with **bounded** memory.
40    ///
41    /// Reads only the small fixed structures (headers, region table, metadata,
42    /// BAT, and — for a dirty image — the log region) at construction; the
43    /// payload blocks are read on demand. A 2 TB image no longer means a 2 TB
44    /// heap.
45    pub fn open(path: &Path) -> Result<Self> {
46        let file = File::open(path)?;
47        Self::from_backing(Backing::File(file), None)
48    }
49
50    /// Construct a reader over an arbitrary positioned [`Backing`].
51    ///
52    /// The escape hatch behind [`open`](Self::open) (and the issen zip bridge):
53    /// a [`Backing::Sub`] reads a STORED zip entry in place, a [`Backing::Mem`]
54    /// reads inflated/owned bytes. Parsing and reads are identical across all
55    /// three backings.
56    pub fn from_backing(backing: Backing, parent: Option<Box<VhdxReader>>) -> Result<Self> {
57        Self::parse_backing(backing, parent)
58    }
59
60    /// Open a VHDX container from owned bytes (legacy in-RAM path).
61    ///
62    /// The whole buffer is held in RAM (via [`Backing::Mem`]) and the dirty log,
63    /// if any, is replayed in place before parsing — byte-identical to the
64    /// historical behaviour. Prefer [`open`](Self::open) for files on disk.
65    pub fn from_bytes(data: Vec<u8>) -> Result<Self> {
66        Self::parse_bytes(data, None)
67    }
68
69    /// Open a differencing (child) disk with its parent chain (in-RAM path).
70    ///
71    /// Reads absent blocks from `parent` instead of returning zeros.
72    pub fn from_bytes_with_parent(data: Vec<u8>, parent: VhdxReader) -> Result<Self> {
73        Self::parse_bytes(data, Some(Box::new(parent)))
74    }
75
76    /// Open a differencing (child) disk over an arbitrary backing with its
77    /// parent chain.
78    pub fn from_backing_with_parent(backing: Backing, parent: VhdxReader) -> Result<Self> {
79        Self::parse_backing(backing, Some(Box::new(parent)))
80    }
81
82    /// Open a VHDX container from any boxed `Read + Seek + Send` reader.
83    ///
84    /// This is the entry point used by the forensic-vfs engine, which hands a
85    /// `SourceCursor` (a `Read + Seek + Send`) to the reader. The `len` is
86    /// determined once at construction by seeking to the end of the reader;
87    /// subsequent `read_at` calls lock a mutex, seek, and read — no mmap,
88    /// no unsafe, fully `forbid(unsafe)` compliant.
89    ///
90    /// # Errors
91    /// Returns the same errors as [`open`](Self::open): [`VhdxError::BadMagic`]
92    /// if the stream does not start with the VHDX file magic, and
93    /// [`VhdxError::ContainerTooSmall`] if the stream is shorter than the
94    /// minimum VHDX container size.
95    pub fn open_reader(mut reader: Box<dyn ReadSeekSend>) -> Result<Self> {
96        // Measure the length once up front by seeking to the end. A seek error
97        // here is surfaced as an I/O error wrapped in VhdxError.
98        let len = reader
99            .seek(std::io::SeekFrom::End(0))
100            .map_err(VhdxError::Io)?;
101        reader
102            .seek(std::io::SeekFrom::Start(0))
103            .map_err(VhdxError::Io)?;
104        let inner = std::sync::Mutex::new(reader);
105        Self::from_backing(Backing::Reader { inner, len }, None)
106    }
107
108    /// In-RAM parse: replay the log in place, then parse from the buffer and
109    /// keep it as the [`Backing::Mem`].
110    fn parse_bytes(mut data: Vec<u8>, parent: Option<Box<VhdxReader>>) -> Result<Self> {
111        if data.len() < 8 || &data[0..8] != FILE_MAGIC {
112            return Err(VhdxError::BadMagic);
113        }
114        if (data.len() as u64) < MIN_CONTAINER_SIZE {
115            return Err(VhdxError::ContainerTooSmall(MIN_CONTAINER_SIZE));
116        }
117        crate::log::apply(&mut data)?;
118        // The log is already folded into `data`, so the overlay is empty and
119        // the backing serves the committed bytes directly.
120        let backing = Backing::from_bytes(data);
121        Self::assemble(backing, LogOverlay::default(), parent)
122    }
123
124    /// Bounded parse: read the small fixed structures via positioned reads, then
125    /// build the log overlay (empty for a clean image).
126    fn parse_backing(backing: Backing, parent: Option<Box<VhdxReader>>) -> Result<Self> {
127        let mut magic = [0u8; 8];
128        let n = backing.read_at(&mut magic, 0)?;
129        if n < 8 || &magic != FILE_MAGIC {
130            return Err(VhdxError::BadMagic);
131        }
132        if backing.len() < MIN_CONTAINER_SIZE {
133            return Err(VhdxError::ContainerTooSmall(MIN_CONTAINER_SIZE));
134        }
135        let overlay = {
136            // The header lives in the first 1 MB; read that slice to select the
137            // active header and (if dirty) drive the log overlay.
138            let head = backing.read_exact_at(0, MIN_CONTAINER_SIZE as usize)?;
139            let header = parse_active_header(&head)?;
140            crate::log::build_overlay(&backing, &header)?
141        };
142        Self::assemble(backing, overlay, parent)
143    }
144
145    /// Shared tail: parse region table → metadata → BAT from small positioned
146    /// reads (the overlay patches any log-dirtied structure bytes), validate,
147    /// and assemble the reader.
148    fn assemble(
149        backing: Backing,
150        overlay: LogOverlay,
151        parent: Option<Box<VhdxReader>>,
152    ) -> Result<Self> {
153        // Region tables sit at fixed offsets within the first 1 MB; read that
154        // prefix once (small, bounded) and parse the region table from it,
155        // applying the log overlay so a log-patched region table is honoured.
156        let mut head = backing.read_exact_at(0, MIN_CONTAINER_SIZE as usize)?;
157        overlay.patch(&mut head, 0);
158
159        let container_len = backing.len();
160        let regions = parse_region_table(&head, REGION_TABLE1_OFFSET as usize, container_len)
161            .or_else(|_| parse_region_table(&head, REGION_TABLE2_OFFSET as usize, container_len))?;
162
163        // Metadata + BAT can live past the first 1 MB; read each region by its
164        // own offset/length and patch with the overlay.
165        let meta_region = read_region(
166            &backing,
167            &overlay,
168            regions.metadata.file_offset,
169            regions.metadata.length,
170        )?;
171        let meta = parse_metadata(&meta_region, 0, regions.metadata.length)?;
172        meta.validate()?;
173        if meta.has_parent && parent.is_none() {
174            return Err(VhdxError::DifferencingNotSupported);
175        }
176
177        let bat_region = read_region(
178            &backing,
179            &overlay,
180            regions.bat.file_offset,
181            regions.bat.length,
182        )?;
183        let bat = Bat::parse(&bat_region, 0, regions.bat.length, meta.clone())?;
184
185        Ok(Self {
186            backing,
187            overlay,
188            bat,
189            meta,
190            pos: 0,
191            parent,
192        })
193    }
194
195    pub fn virtual_disk_size(&self) -> u64 {
196        self.meta.virtual_disk_size
197    }
198
199    pub fn logical_sector_size(&self) -> u32 {
200        self.meta.logical_sector_size
201    }
202}
203
204/// Read a region `[file_offset, file_offset+length)` from the backing into a
205/// fresh buffer and apply the log overlay, so region/metadata/BAT parsing sees
206/// the committed state — exactly as the in-place `apply` path would.
207///
208/// The returned buffer is offset-zero based, so callers parse with offset `0`.
209fn read_region(
210    backing: &Backing,
211    overlay: &LogOverlay,
212    file_offset: u64,
213    length: u32,
214) -> Result<Vec<u8>> {
215    let end = file_offset.saturating_add(u64::from(length));
216    if backing.len() < end {
217        return Err(VhdxError::OffsetOutOfBounds);
218    }
219    let mut buf = backing.read_exact_at(file_offset, length as usize)?;
220    if buf.len() < length as usize {
221        return Err(VhdxError::OffsetOutOfBounds);
222    }
223    overlay.patch(&mut buf, file_offset);
224    Ok(buf)
225}
226
227impl Read for VhdxReader {
228    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
229        if self.pos >= self.meta.virtual_disk_size {
230            return Ok(0);
231        }
232        let remaining = self.meta.virtual_disk_size - self.pos;
233        let to_read = buf.len().min(remaining as usize);
234        let block_size = u64::from(self.meta.block_size);
235        let mut written = 0;
236
237        while written < to_read {
238            let virtual_byte = self.pos + written as u64;
239            let block_end = ((virtual_byte / block_size) + 1) * block_size;
240            let this_chunk = (to_read - written).min((block_end - virtual_byte) as usize);
241
242            match self.bat.file_offset_for_byte(virtual_byte) {
243                Ok(file_off) => {
244                    let dst = &mut buf[written..written + this_chunk];
245                    let n = self.backing.read_at(dst, file_off)?;
246                    if n < this_chunk {
247                        return Err(io::Error::new(
248                            io::ErrorKind::UnexpectedEof,
249                            "VHDX data truncated",
250                        ));
251                    }
252                    // Fold any committed-log sectors over the on-disk block.
253                    self.overlay.patch(dst, file_off);
254                }
255                Err(VhdxError::BlockNotPresent(_)) => {
256                    if let Some(ref mut p) = self.parent {
257                        p.seek(SeekFrom::Start(virtual_byte))
258                            .map_err(io::Error::other)?;
259                        p.read_exact(&mut buf[written..written + this_chunk])?;
260                    } else {
261                        buf[written..written + this_chunk].fill(0);
262                    }
263                }
264                Err(e) => return Err(io::Error::other(e.to_string())),
265            }
266            written += this_chunk;
267        }
268
269        self.pos += written as u64;
270        Ok(written)
271    }
272}
273
274impl Seek for VhdxReader {
275    fn seek(&mut self, pos: SeekFrom) -> io::Result<u64> {
276        let new_pos = match pos {
277            SeekFrom::Start(n) => n as i64,
278            SeekFrom::Current(n) => self.pos as i64 + n,
279            SeekFrom::End(n) => self.meta.virtual_disk_size as i64 + n,
280        };
281        if new_pos < 0 {
282            return Err(io::Error::new(
283                io::ErrorKind::InvalidInput,
284                "seek before start",
285            ));
286        }
287        self.pos = new_pos as u64;
288        Ok(self.pos)
289    }
290}