vhdx/backing.rs
1//! Pluggable, positioned backing store for one VHDX container.
2//!
3//! The reader historically held the **entire image** in a `Vec<u8>` — a 2 TB
4//! VHDX meant a 2 TB heap. [`Backing`] generalises that to four positioned-read
5//! backings WITHOUT a boxed trait, so the hot read path stays vtable-free (a
6//! `match` the compiler can inline, not a dynamic dispatch):
7//!
8//! - [`Backing::File`] — a loose container file (the bounded path), read with
9//! the OS positioned-read primitive: only the BAT-selected blocks ever touch
10//! RAM, so peak memory no longer scales with image size.
11//! - [`Backing::Sub`] — a contiguous sub-range of a larger file (a STORED, i.e.
12//! uncompressed, zip entry sits at a fixed offset inside the archive):
13//! `read_at(buf, off)` preads at `base + off`, clamped to `len`.
14//! - [`Backing::Mem`] — an in-RAM buffer (the legacy `from_bytes` path, or a
15//! DEFLATED zip entry inflated to memory once): `read_at` copies from the
16//! slice.
17//! - [`Backing::Reader`] — an arbitrary boxed [`ReadSeekSend`] reader (the
18//! forensic-vfs engine path): `read_at` locks a mutex, seeks to the requested
19//! offset, and reads — bridging the `Read + Seek` world to the positioned-read
20//! API. Keeps `forbid(unsafe)` (no mmap).
21//!
22//! All four expose the same cursor-free positioned-read API (`read_at` + `len`),
23//! so the open/parse pass reads small structures from their known offsets and the
24//! data path reads just the resolved block — never the whole file.
25
26use std::fs::File;
27use std::io::{self, Read, Seek, SeekFrom};
28use std::sync::{Arc, Mutex};
29
30/// A boxed `Read + Seek + Send` reader that can be used as a [`Backing::Reader`].
31///
32/// Any type implementing `Read + Seek + Send` satisfies this trait via the blanket
33/// impl below. This is the trait [`VhdxReader::open_reader`] accepts so the
34/// forensic-vfs engine can hand a `SourceCursor` (which is `Read + Seek + Send`)
35/// straight to the VHDX parser without forensic-vfs appearing in the production
36/// dependency tree.
37pub trait ReadSeekSend: Read + Seek + Send {}
38
39impl<T: Read + Seek + Send> ReadSeekSend for T {}
40
41/// Fill `buf` from `file` starting at `offset`, returning the bytes read (short
42/// only at end of file).
43///
44/// Uses the OS positioned-read primitive — `pread(2)` on Unix, `seek_read`
45/// (a `ReadFile` carrying its own `OVERLAPPED` offset) on Windows — so it takes
46/// `&File` and never touches a shared cursor. That makes it safe to call
47/// concurrently from many threads on one handle: each call carries its own
48/// offset, so there is no read/seek race. Keeps `forbid(unsafe)` (no mmap).
49fn pread(file: &File, buf: &mut [u8], offset: u64) -> io::Result<usize> {
50 #[cfg(unix)]
51 use std::os::unix::fs::FileExt;
52 #[cfg(windows)]
53 use std::os::windows::fs::FileExt;
54
55 let mut total = 0usize;
56 while total < buf.len() {
57 #[cfg(unix)]
58 let res = file.read_at(&mut buf[total..], offset + total as u64);
59 #[cfg(windows)]
60 let res = file.seek_read(&mut buf[total..], offset + total as u64);
61 #[cfg(not(any(unix, windows)))]
62 let res: io::Result<usize> = Err(io::Error::new(
63 io::ErrorKind::Unsupported,
64 "positioned reads unsupported on this platform",
65 ));
66 match res {
67 Ok(0) => break,
68 Ok(n) => total += n,
69 Err(ref e) if e.kind() == io::ErrorKind::Interrupted => {}
70 Err(e) => return Err(e),
71 }
72 }
73 Ok(total)
74}
75
76/// A positioned, cursor-free reader over one VHDX container's bytes.
77///
78/// `read_at(buf, offset)` fills `buf` from logical `offset` within the container
79/// and returns the byte count (short only at end of container) — never touching
80/// a shared cursor, so it is safe to call concurrently through `&self`.
81pub enum Backing {
82 /// A loose container file: positioned reads go straight to the OS handle.
83 File(File),
84 /// A contiguous sub-range `[base, base+len)` of a larger shared file.
85 Sub {
86 /// The backing file (shared; positioned reads carry their own offset).
87 file: Arc<File>,
88 /// Absolute file offset where this container's bytes begin.
89 base: u64,
90 /// Length of this container in bytes.
91 len: u64,
92 },
93 /// An in-RAM container (e.g. the legacy `from_bytes` path or an inflated
94 /// zip entry).
95 Mem(Arc<[u8]>),
96 /// An arbitrary seekable reader (e.g. a forensic-vfs `SourceCursor`).
97 ///
98 /// The inner reader is `Read + Seek + Send` but not cursor-free: `read_at`
99 /// locks the mutex, seeks to the requested offset, then reads. Because the
100 /// lock is held only for the duration of one `read_at` call, this is safe
101 /// under `&self` — the mutex serialises concurrent accesses.
102 Reader {
103 /// The seekable reader, guarded by a mutex to satisfy `&self` [`Backing::read_at`].
104 inner: Mutex<Box<dyn ReadSeekSend>>,
105 /// Total byte length of the container, measured at construction.
106 len: u64,
107 },
108}
109
110impl Backing {
111 /// Construct a [`Backing::Sub`] over `[base, base+len)` of `file`.
112 #[must_use]
113 pub fn sub(file: Arc<File>, base: u64, len: u64) -> Self {
114 Backing::Sub { file, base, len }
115 }
116
117 /// Construct an in-RAM [`Backing::Mem`] from owned bytes.
118 #[must_use]
119 pub fn from_bytes(bytes: impl Into<Arc<[u8]>>) -> Self {
120 Backing::Mem(bytes.into())
121 }
122
123 /// Total length of this container in bytes.
124 #[must_use]
125 pub fn len(&self) -> u64 {
126 match self {
127 Backing::File(f) => f.metadata().map_or(0, |m| m.len()),
128 Backing::Mem(b) => b.len() as u64,
129 Backing::Sub { len, .. } | Backing::Reader { len, .. } => *len,
130 }
131 }
132
133 /// Whether the container is empty.
134 #[must_use]
135 pub fn is_empty(&self) -> bool {
136 self.len() == 0
137 }
138
139 /// Fill `buf` from logical `offset` within this container, returning the
140 /// bytes read (short only at the container's end). Cursor-free and
141 /// thread-safe.
142 ///
143 /// # Errors
144 /// Propagates the underlying I/O error for [`Backing::File`] /
145 /// [`Backing::Sub`]; [`Backing::Mem`] never fails.
146 pub fn read_at(&self, buf: &mut [u8], offset: u64) -> io::Result<usize> {
147 match self {
148 Backing::File(f) => pread(f, buf, offset),
149 Backing::Sub { file, base, len } => {
150 // Clamp the request to this container's window so a Sub never
151 // reads past its end into a neighbouring entry. A read starting
152 // beyond the window yields 0 (clean EOF), mirroring File/Mem.
153 let avail = len.saturating_sub(offset);
154 if avail == 0 {
155 return Ok(0);
156 }
157 let want = (buf.len() as u64).min(avail) as usize;
158 pread(file, &mut buf[..want], base + offset)
159 }
160 Backing::Mem(bytes) => {
161 let off = offset.min(bytes.len() as u64) as usize;
162 let src = &bytes[off..];
163 let n = src.len().min(buf.len());
164 buf[..n].copy_from_slice(&src[..n]);
165 Ok(n)
166 }
167 Backing::Reader { inner, len } => {
168 // Clamp to container bounds first — a read starting at or past
169 // EOF returns 0 cleanly, mirroring the other variants.
170 let avail = len.saturating_sub(offset);
171 if avail == 0 {
172 return Ok(0);
173 }
174 let want = (buf.len() as u64).min(avail) as usize;
175 // Acquire the lock. A poisoned mutex means the reader is in an
176 // inconsistent state; surface that as an I/O error rather than
177 // panicking, so the caller can decide how to handle it.
178 let mut guard = inner
179 .lock()
180 .map_err(|_| io::Error::other("backing reader mutex poisoned"))?;
181 guard.seek(SeekFrom::Start(offset))?;
182 // Read in a loop to fill the buffer (mirroring pread semantics:
183 // short reads are only expected at EOF).
184 let dst = &mut buf[..want];
185 let mut total = 0;
186 while total < dst.len() {
187 match guard.read(&mut dst[total..])? {
188 0 => break, // EOF reached before filling the buffer.
189 n => total += n,
190 }
191 }
192 Ok(total)
193 }
194 }
195 }
196
197 /// Read exactly `len` bytes starting at `offset` into a fresh `Vec`.
198 ///
199 /// Used by the open/parse pass to pull a small, known-size structure
200 /// (a header slot, the region table, the metadata region, the BAT) into a
201 /// bounded buffer. The returned `Vec` is short only when the container ends
202 /// before `offset + len` (so the caller still range-checks the parse).
203 ///
204 /// # Errors
205 /// Propagates the underlying positioned-read error.
206 pub fn read_exact_at(&self, offset: u64, len: usize) -> io::Result<Vec<u8>> {
207 let mut buf = vec![0u8; len];
208 let n = self.read_at(&mut buf, offset)?;
209 buf.truncate(n);
210 Ok(buf)
211 }
212}
213
214impl std::fmt::Debug for Backing {
215 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
216 match self {
217 Backing::File(_) => f.debug_struct("File").field("len", &self.len()).finish(),
218 Backing::Sub { base, len, .. } => f
219 .debug_struct("Sub")
220 .field("base", base)
221 .field("len", len)
222 .finish(),
223 Backing::Mem(b) => f.debug_struct("Mem").field("len", &b.len()).finish(),
224 Backing::Reader { len, .. } => f.debug_struct("Reader").field("len", len).finish(),
225 }
226 }
227}
228
229#[cfg(test)]
230mod tests {
231 use std::io::Cursor;
232
233 use super::{Backing, ReadSeekSend};
234
235 /// Blanket impl: [`std::io::Cursor`]`<Vec<u8>>` satisfies [`ReadSeekSend`].
236 fn cursor_backing(data: Vec<u8>) -> Backing {
237 let len = data.len() as u64;
238 let inner = std::sync::Mutex::new(Box::new(Cursor::new(data)) as Box<dyn ReadSeekSend>);
239 Backing::Reader { inner, len }
240 }
241
242 #[test]
243 fn reader_backing_len_matches_construction() {
244 let b = cursor_backing(vec![1u8, 2, 3, 4, 5]);
245 assert_eq!(b.len(), 5);
246 assert!(!b.is_empty());
247 }
248
249 #[test]
250 fn reader_backing_read_at_fills_buf() {
251 let b = cursor_backing(vec![10, 20, 30, 40, 50]);
252 let mut buf = [0u8; 3];
253 let n = b.read_at(&mut buf, 1).unwrap();
254 assert_eq!(n, 3);
255 assert_eq!(buf, [20, 30, 40]);
256 }
257
258 #[test]
259 fn reader_backing_read_at_clamps_at_eof() {
260 let b = cursor_backing(vec![10, 20, 30]);
261 let mut buf = [0u8; 10];
262 let n = b.read_at(&mut buf, 1).unwrap();
263 assert_eq!(n, 2, "read past end should be clamped");
264 assert_eq!(&buf[..2], &[20, 30]);
265 }
266
267 #[test]
268 fn reader_backing_read_at_past_eof_returns_zero() {
269 let b = cursor_backing(vec![1, 2, 3]);
270 let mut buf = [0xFFu8; 4];
271 let n = b.read_at(&mut buf, 100).unwrap();
272 assert_eq!(n, 0);
273 }
274
275 #[test]
276 fn reader_backing_debug_does_not_panic() {
277 let b = cursor_backing(vec![0u8; 8]);
278 let s = format!("{b:?}");
279 assert!(s.contains("Reader"), "Debug output should name the variant");
280 }
281
282 #[test]
283 fn reader_backing_read_exact_at_works() {
284 let b = cursor_backing(vec![0, 1, 2, 3, 4, 5, 6, 7]);
285 let v = b.read_exact_at(2, 4).unwrap();
286 assert_eq!(v, vec![2, 3, 4, 5]);
287 }
288
289 #[test]
290 fn empty_reader_backing_is_empty() {
291 let b = cursor_backing(vec![]);
292 assert!(b.is_empty());
293 assert_eq!(b.len(), 0);
294 }
295}