fix: resolve VHD chains via parent/own GUIDs

This commit is contained in:
jujuforce
2026-04-20 20:44:56 +02:00
parent d719125ea3
commit 2ef6c992f9
2 changed files with 302 additions and 189 deletions
+81 -8
View File
@@ -410,10 +410,14 @@ fn main() -> Result<()> {
for (game_id, vhds) in &by_game {
let base = vhds.iter().find(|v| v.sequence_number == 0).copied();
let deltas: Vec<_> = vhds.iter().filter(|v| v.sequence_number > 0).collect();
let deltas: Vec<_> = vhds
.iter()
.filter(|v| v.sequence_number > 0)
.copied()
.collect();
if deltas.is_empty() {
// Standalone base VHD, just extract
// Standalone base VHD, just extract.
if let Some(base) = base {
let output_dir = base.input_path.with_extension("");
if let Err(e) = vhd::extract_vhd(&base.vhd_path, &output_dir) {
@@ -429,15 +433,84 @@ fn main() -> Result<()> {
continue;
};
// Merge + extract each delta with the base (pure Rust, no admin needed)
for delta in &deltas {
let output_dir = delta.input_path.with_extension("");
if let Err(e) = vhd::extract_merged_vhd(&base.vhd_path, &delta.vhd_path, &output_dir) {
println!("WARNING: Merged VHD extraction failed: {e:#}");
}
if let Err(e) = process_chain(base, &deltas) {
println!("WARNING: VHD chain processing failed for {game_id}: {e:#}");
}
}
}
Ok(())
}
/// Order deltas into a single parent→child chain by matching each delta's
/// Parent Unique ID to the previous VHD's own Unique Id, then extract each
/// patch level and finally delete the intermediate .vhd files.
fn process_chain(base: &ExtractedVhd, deltas: &[&ExtractedVhd]) -> Result<()> {
// Collect GUID info for base + all deltas up front (cheap — reads at most
// ~1.5 KiB per VHD). Track each VHD by its own GUID.
let base_info = vhd::read_vhd_guid_info(&base.vhd_path)
.map_err(|e| anyhow!("reading base {}: {e}", base.vhd_path.display()))?;
let mut remaining: Vec<(vhd::VhdGuidInfo, &ExtractedVhd)> = Vec::with_capacity(deltas.len());
for d in deltas {
match vhd::read_vhd_guid_info(&d.vhd_path) {
Ok(info) => remaining.push((info, *d)),
Err(e) => {
println!(
"WARNING: could not read VHD metadata for {}: {e} — skipping this delta",
d.vhd_path.display()
);
}
}
}
// Walk the chain: repeatedly look for the delta whose parent_id matches
// the last VHD's own_id. Stop if the link breaks so we can warn clearly.
let mut chain: Vec<&ExtractedVhd> = vec![base];
let mut last_own_id = base_info.own_id;
while !remaining.is_empty() {
let pos = remaining
.iter()
.position(|(info, _)| info.parent_id == Some(last_own_id));
let Some(pos) = pos else { break };
let (info, vhd) = remaining.remove(pos);
chain.push(vhd);
last_own_id = info.own_id;
}
if !remaining.is_empty() {
println!(
"WARNING: {} delta(s) could not be linked into the chain (missing parent VHD). \
Make sure every intermediate patch is provided.",
remaining.len()
);
for (_, v) in &remaining {
println!(" orphan: {}", v.input_path.display());
}
}
// chain[0] is the base; chain[i>=1] is a differencing VHD whose correct
// merged view is `chain[0..=i]`. Extract each patch level against its
// full parent chain.
for i in 1..chain.len() {
let layers: Vec<&Path> = chain[..=i].iter().map(|v| v.vhd_path.as_path()).collect();
let output_dir = chain[i].input_path.with_extension("");
if let Err(e) = vhd::extract_chained_vhd(&layers, &output_dir) {
println!(
"WARNING: chained VHD extraction failed for {}: {e:#}",
chain[i].input_path.display()
);
}
}
// All extractions done — now safe to delete the intermediate .vhd files.
// Includes the base (matches the previous auto-merge behavior of consuming
// the extracted VHD once done with it).
for v in &chain {
if let Err(e) = std::fs::remove_file(&v.vhd_path) {
println!("WARNING: Could not delete {}: {e}", v.vhd_path.display());
}
}
Ok(())
}
+221 -181
View File
@@ -23,6 +23,8 @@ const VHD_TYPE_DYNAMIC: u32 = 3;
const VHD_TYPE_DIFFERENCING: u32 = 4;
const VHD_FOOTER_DISK_TYPE_OFFSET: usize = 0x3C;
const VHD_FOOTER_DATA_OFFSET: usize = 0x10;
/// VHD footer Unique Id (GUID), 16 bytes at offset 68 (0x44) — identifies this VHD.
const VHD_FOOTER_UNIQUE_ID_OFFSET: usize = 0x44;
// Dynamic/differencing VHD header
const DYNAMIC_HEADER_COOKIE: &[u8; 8] = b"cxsparse";
@@ -30,8 +32,64 @@ const DYNAMIC_HEADER_SIZE: usize = 1024;
const DYNAMIC_BAT_OFFSET_FIELD: usize = 0x10;
const DYNAMIC_MAX_ENTRIES_FIELD: usize = 0x18;
const DYNAMIC_BLOCK_SIZE_FIELD: usize = 0x20;
/// Dynamic header Parent Unique ID (GUID), 16 bytes at offset 40 (0x28) — only
/// meaningful for differencing VHDs; points at the parent VHD's footer Unique Id.
const DYNAMIC_PARENT_UNIQUE_ID_OFFSET: usize = 0x28;
const BAT_UNUSED: u32 = 0xFFFFFFFF;
pub type VhdGuid = [u8; 16];
/// Chain-linking info read from a VHD. `parent_id` is `Some` only for differencing
/// disks (type 4), and points at the parent VHD's `own_id`.
#[derive(Debug, Clone)]
pub struct VhdGuidInfo {
pub own_id: VhdGuid,
pub parent_id: Option<VhdGuid>,
/// Kept for diagnostics / future validation; not every caller inspects it.
#[allow(dead_code)]
pub disk_type: u32,
}
/// Read a VHD's Unique Id and (for differencing VHDs) its Parent Unique ID.
/// Cheap — only reads the 512-byte footer plus, if differencing, the 1024-byte
/// dynamic header. Used to build chains by matching child.parent_id -> parent.own_id.
pub fn read_vhd_guid_info(path: &Path) -> Result<VhdGuidInfo, VhdError> {
let mut f = File::open(path)?;
let size = f.seek(SeekFrom::End(0))?;
if size < SECTOR_SIZE {
return Err(VhdError::InvalidCookie);
}
f.seek(SeekFrom::Start(size - SECTOR_SIZE))?;
let mut footer = [0u8; SECTOR_SIZE as usize];
f.read_exact(&mut footer)?;
if &footer[..8] != VHD_COOKIE {
return Err(VhdError::InvalidCookie);
}
let own_id: VhdGuid = footer[VHD_FOOTER_UNIQUE_ID_OFFSET..VHD_FOOTER_UNIQUE_ID_OFFSET + 16]
.try_into()
.unwrap();
let disk_type = read_be_u32(&footer, VHD_FOOTER_DISK_TYPE_OFFSET);
let parent_id = if disk_type == VHD_TYPE_DIFFERENCING {
let header_offset = read_be_u64(&footer, VHD_FOOTER_DATA_OFFSET);
f.seek(SeekFrom::Start(header_offset))?;
let mut hdr = [0u8; DYNAMIC_HEADER_SIZE];
f.read_exact(&mut hdr)?;
if &hdr[..8] != DYNAMIC_HEADER_COOKIE {
return Err(VhdError::InvalidDynamicHeader);
}
let guid: VhdGuid = hdr
[DYNAMIC_PARENT_UNIQUE_ID_OFFSET..DYNAMIC_PARENT_UNIQUE_ID_OFFSET + 16]
.try_into()
.unwrap();
Some(guid)
} else {
None
};
Ok(VhdGuidInfo { own_id, parent_id, disk_type })
}
// MBR
const MBR_SIGNATURE: [u8; 2] = [0x55, 0xAA];
const MBR_PARTITION_TABLE_OFFSET: usize = 0x1BE;
@@ -242,130 +300,68 @@ impl<R: Read + Seek> Seek for VhdReader<R> {
}
// ---------------------------------------------------------------------------
// Merged VHD reader (base + delta overlay, no disk merge needed)
// Chained VHD reader (base + N deltas overlaid, no on-disk merge needed)
// ---------------------------------------------------------------------------
/// Reads from a differencing VHD overlaid on a base VHD.
/// For each block, reads from delta if allocated, otherwise from base.
pub struct MergedVhdReader<R> {
base: R,
base_layout: VhdLayout,
delta: R,
delta_layout: VhdLayout,
/// One layer of a VHD chain: a file handle plus its parsed layout.
struct VhdLayer<R> {
inner: R,
layout: VhdLayout,
}
/// Reads from a chain of VHDs where `layers[0]` is the base (dynamic/fixed)
/// and `layers[1..]` are differencing VHDs in parent→child order.
///
/// For each read, walks layers from top delta down to base. At each layer,
/// if the sector is present-and-modified (BAT allocated + bitmap bit set),
/// that layer's bytes win; otherwise the read falls through to the layer
/// below. The base layer's own `read_at` handles zero-fill for unallocated
/// dynamic blocks.
pub struct ChainedVhdReader<R> {
layers: Vec<VhdLayer<R>>,
ntfs_offset: u64,
virtual_size: u64,
pos: u64,
}
impl<R: Read + Seek> MergedVhdReader<R> {
pub fn new(mut base: R, mut delta: R) -> Result<Self, VhdError> {
// Parse base
let base_file_size = base.seek(SeekFrom::End(0))?;
if base_file_size < SECTOR_SIZE {
return Err(VhdError::InvalidCookie);
}
base.seek(SeekFrom::Start(base_file_size - SECTOR_SIZE))?;
let mut base_footer = [0u8; SECTOR_SIZE as usize];
base.read_exact(&mut base_footer)?;
if &base_footer[..8] != VHD_COOKIE {
impl<R: Read + Seek> ChainedVhdReader<R> {
/// Build a chain reader. `readers` must be ordered base-first, top-most delta last.
pub fn new(readers: Vec<R>) -> Result<Self, VhdError> {
if readers.is_empty() {
return Err(VhdError::InvalidCookie);
}
let base_type = read_be_u32(&base_footer, VHD_FOOTER_DISK_TYPE_OFFSET);
let (base_layout, base_vsize) = match base_type {
VHD_TYPE_FIXED => (VhdLayout::Fixed, base_file_size - SECTOR_SIZE),
VHD_TYPE_DYNAMIC | VHD_TYPE_DIFFERENCING => {
VhdLayout::parse_sparse(&mut base, &base_footer)?
let mut layers: Vec<VhdLayer<R>> = Vec::with_capacity(readers.len());
let mut virtual_size = 0u64;
for (idx, mut r) in readers.into_iter().enumerate() {
let file_size = r.seek(SeekFrom::End(0))?;
if file_size < SECTOR_SIZE {
return Err(VhdError::InvalidCookie);
}
t => return Err(VhdError::UnsupportedType(t)),
};
// Parse delta
let delta_file_size = delta.seek(SeekFrom::End(0))?;
if delta_file_size < SECTOR_SIZE {
return Err(VhdError::InvalidCookie);
}
delta.seek(SeekFrom::Start(delta_file_size - SECTOR_SIZE))?;
let mut delta_footer = [0u8; SECTOR_SIZE as usize];
delta.read_exact(&mut delta_footer)?;
if &delta_footer[..8] != VHD_COOKIE {
return Err(VhdError::InvalidCookie);
}
let delta_type = read_be_u32(&delta_footer, VHD_FOOTER_DISK_TYPE_OFFSET);
let (delta_layout, _) = match delta_type {
VHD_TYPE_DYNAMIC | VHD_TYPE_DIFFERENCING => {
VhdLayout::parse_sparse(&mut delta, &delta_footer)?
r.seek(SeekFrom::Start(file_size - SECTOR_SIZE))?;
let mut footer = [0u8; SECTOR_SIZE as usize];
r.read_exact(&mut footer)?;
if &footer[..8] != VHD_COOKIE {
return Err(VhdError::InvalidCookie);
}
t => return Err(VhdError::UnsupportedType(t)),
};
// Use base's virtual size as the canonical disk size
let virtual_size = base_vsize;
// Find NTFS using the merged view
let ntfs_offset = find_ntfs_offset_merged(
&mut base, &base_layout, &mut delta, &delta_layout, virtual_size,
)?;
Ok(Self {
base, base_layout,
delta, delta_layout,
ntfs_offset, virtual_size, pos: 0,
})
}
/// Read from the merged view: for each sector, check the delta's bitmap
/// to decide whether to read from delta or base.
fn read_merged(&mut self, virt_off: u64, buf: &mut [u8]) -> io::Result<usize> {
if virt_off >= self.virtual_size {
return Ok(0);
}
let cap = std::cmp::min(buf.len() as u64, self.virtual_size - virt_off) as usize;
match &self.delta_layout {
VhdLayout::Fixed => {
// Shouldn't happen for a delta, but fall through to base
self.base_layout.read_at(&mut self.base, virt_off, self.virtual_size, &mut buf[..cap])
}
VhdLayout::Sparse { bat, block_size } => {
let bi = (virt_off / block_size) as usize;
let bo = virt_off % block_size;
let n = std::cmp::min(cap, (*block_size - bo) as usize);
if bi >= bat.len() || bat[bi] == BAT_UNUSED {
// Block not in delta, read from base
return self.base_layout.read_at(
&mut self.base, virt_off, self.virtual_size, &mut buf[..n],
);
}
let block_file_offset = bat[bi] as u64 * SECTOR_SIZE;
// Read the bitmap sector for this block
self.delta.seek(SeekFrom::Start(block_file_offset))?;
let mut bitmap = [0u8; SECTOR_SIZE as usize];
self.delta.read_exact(&mut bitmap)?;
// Check if the sector containing our offset has been modified
let sector_in_block = (bo / SECTOR_SIZE) as usize;
let bitmap_byte = bitmap[sector_in_block / 8];
let bitmap_bit = 7 - (sector_in_block % 8); // MSB first
let sector_modified = (bitmap_byte >> bitmap_bit) & 1 == 1;
if sector_modified {
// Read from delta (skip bitmap sector)
let file_off = block_file_offset + SECTOR_SIZE + bo;
self.delta.seek(SeekFrom::Start(file_off))?;
self.delta.read(&mut buf[..n])
} else {
// Sector not modified in delta, read from base
self.base_layout.read_at(
&mut self.base, virt_off, self.virtual_size, &mut buf[..n],
)
let disk_type = read_be_u32(&footer, VHD_FOOTER_DISK_TYPE_OFFSET);
let (layout, vsize) = match disk_type {
VHD_TYPE_FIXED => (VhdLayout::Fixed, file_size - SECTOR_SIZE),
VHD_TYPE_DYNAMIC | VHD_TYPE_DIFFERENCING => {
VhdLayout::parse_sparse(&mut r, &footer)?
}
t => return Err(VhdError::UnsupportedType(t)),
};
if idx == 0 {
virtual_size = vsize;
}
layers.push(VhdLayer { inner: r, layout });
}
let ntfs_offset = find_ntfs_offset_chain(&mut layers, virtual_size)?;
Ok(Self { layers, ntfs_offset, virtual_size, pos: 0 })
}
fn ntfs_size(&self) -> u64 {
@@ -373,20 +369,93 @@ impl<R: Read + Seek> MergedVhdReader<R> {
}
}
impl<R: Read + Seek> Read for MergedVhdReader<R> {
/// If `layer` has the sector for `virt_off` present AND marked modified in
/// its bitmap, read from it and return `Some(bytes_read)`. Otherwise `None`
/// signals "fall through to the layer below".
///
/// Reads are capped at the current sector boundary. The VHD bitmap is
/// per-sector: a single block can have a mixed 1/0 pattern, so a larger read
/// might cross a sector that belongs to a different layer. The Read
/// implementation loops until `buf` is filled, amortising the extra calls.
fn try_read_from_layer<R: Read + Seek>(
layer: &mut VhdLayer<R>,
virt_off: u64,
buf: &mut [u8],
) -> io::Result<Option<usize>> {
match &layer.layout {
VhdLayout::Fixed => Ok(None), // Fixed deltas make no sense; fall through.
VhdLayout::Sparse { bat, block_size } => {
let bi = (virt_off / block_size) as usize;
let bo = virt_off % block_size;
if bi >= bat.len() || bat[bi] == BAT_UNUSED {
return Ok(None);
}
// Cap at the current sector to honour per-sector bitmap semantics.
let sector_remaining = (SECTOR_SIZE - (virt_off % SECTOR_SIZE)) as usize;
let n = std::cmp::min(buf.len(), sector_remaining);
let block_file_offset = bat[bi] as u64 * SECTOR_SIZE;
// Read the block's bitmap sector.
layer.inner.seek(SeekFrom::Start(block_file_offset))?;
let mut bitmap = [0u8; SECTOR_SIZE as usize];
layer.inner.read_exact(&mut bitmap)?;
let sector_in_block = (bo / SECTOR_SIZE) as usize;
let bitmap_byte = bitmap[sector_in_block / 8];
let bitmap_bit = 7 - (sector_in_block % 8); // MSB first
if (bitmap_byte >> bitmap_bit) & 1 == 0 {
return Ok(None);
}
let file_off = block_file_offset + SECTOR_SIZE + bo;
layer.inner.seek(SeekFrom::Start(file_off))?;
let got = layer.inner.read(&mut buf[..n])?;
Ok(Some(got))
}
}
}
/// Walk layers top-to-bottom; first layer that owns the sector wins.
/// The base layer (index 0) always answers (possibly with zeros for
/// unallocated dynamic blocks).
fn read_chain<R: Read + Seek>(
layers: &mut [VhdLayer<R>],
vsize: u64,
virt_off: u64,
buf: &mut [u8],
) -> io::Result<usize> {
if virt_off >= vsize {
return Ok(0);
}
let cap = std::cmp::min(buf.len() as u64, vsize - virt_off) as usize;
// Try deltas from top (last) down to just above base (index 1).
for i in (1..layers.len()).rev() {
if let Some(n) = try_read_from_layer(&mut layers[i], virt_off, &mut buf[..cap])? {
return Ok(n);
}
}
// Fall through to base.
let base = &mut layers[0];
base.layout.read_at(&mut base.inner, virt_off, vsize, &mut buf[..cap])
}
impl<R: Read + Seek> Read for ChainedVhdReader<R> {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
let remaining = self.ntfs_size().saturating_sub(self.pos);
if remaining == 0 {
return Ok(0);
}
let cap = std::cmp::min(buf.len() as u64, remaining) as usize;
let n = self.read_merged(self.ntfs_offset + self.pos, &mut buf[..cap])?;
let virt_off = self.ntfs_offset + self.pos;
let n = read_chain(&mut self.layers, self.virtual_size, virt_off, &mut buf[..cap])?;
self.pos += n as u64;
Ok(n)
}
}
impl<R: Read + Seek> Seek for MergedVhdReader<R> {
impl<R: Read + Seek> Seek for ChainedVhdReader<R> {
fn seek(&mut self, pos: SeekFrom) -> io::Result<u64> {
let target = match pos {
SeekFrom::Start(o) => o as i64,
@@ -440,66 +509,28 @@ fn find_ntfs_offset<R: Read + Seek>(
Err(VhdError::NoNtfsPartition)
}
/// Read from merged view with bitmap awareness: if the delta has the block
/// allocated but the specific sector's bitmap bit is 0, read from base instead.
fn read_merged_sector<R: Read + Seek>(
base: &mut R,
base_layout: &VhdLayout,
delta: &mut R,
delta_layout: &VhdLayout,
vsize: u64,
virt_off: u64,
buf: &mut [u8],
) -> io::Result<usize> {
match delta_layout {
VhdLayout::Fixed => base_layout.read_at(base, virt_off, vsize, buf),
VhdLayout::Sparse { bat, block_size } => {
let bi = (virt_off / block_size) as usize;
if bi >= bat.len() || bat[bi] == BAT_UNUSED {
return base_layout.read_at(base, virt_off, vsize, buf);
}
let bo = virt_off % block_size;
let block_file_offset = bat[bi] as u64 * SECTOR_SIZE;
// Read bitmap
delta.seek(SeekFrom::Start(block_file_offset))?;
let mut bitmap = [0u8; SECTOR_SIZE as usize];
delta.read_exact(&mut bitmap)?;
let sector_in_block = (bo / SECTOR_SIZE) as usize;
let bitmap_byte = bitmap[sector_in_block / 8];
let bitmap_bit = 7 - (sector_in_block % 8);
let modified = (bitmap_byte >> bitmap_bit) & 1 == 1;
if modified {
let file_off = block_file_offset + SECTOR_SIZE + bo;
delta.seek(SeekFrom::Start(file_off))?;
delta.read(buf)
} else {
base_layout.read_at(base, virt_off, vsize, buf)
}
}
}
}
/// Find NTFS offset in a merged (base + delta) view.
fn find_ntfs_offset_merged<R: Read + Seek>(
base: &mut R,
base_layout: &VhdLayout,
delta: &mut R,
delta_layout: &VhdLayout,
/// Find NTFS offset in a chained view (base + N deltas).
fn find_ntfs_offset_chain<R: Read + Seek>(
layers: &mut [VhdLayer<R>],
vsize: u64,
) -> Result<u64, VhdError> {
let read_magic = |base: &mut R, delta: &mut R, offset: u64| -> io::Result<[u8; 4]> {
// If the chain has only a base, defer to the single-VHD finder — it's simpler
// and avoids the bitmap machinery for a pure dynamic/fixed disk.
if layers.len() == 1 {
let base = &mut layers[0];
return find_ntfs_offset(&mut base.inner, &base.layout, vsize);
}
let read_magic = |layers: &mut [VhdLayer<R>], offset: u64| -> io::Result<[u8; 4]> {
let mut buf = [0u8; 4];
read_merged_sector(base, base_layout, delta, delta_layout, vsize, offset, &mut buf)?;
read_chain(layers, vsize, offset, &mut buf)?;
Ok(buf)
};
// Try MBR from merged view
// Try MBR from merged view.
if vsize >= SECTOR_SIZE {
let mut mbr = [0u8; SECTOR_SIZE as usize];
read_merged_sector(base, base_layout, delta, delta_layout, vsize, 0, &mut mbr)?;
read_chain(layers, vsize, 0, &mut mbr)?;
if mbr[510..512] == MBR_SIGNATURE {
for i in 0..MBR_MAX_PARTITIONS {
@@ -507,7 +538,7 @@ fn find_ntfs_offset_merged<R: Read + Seek>(
if mbr[eo + 4] == NTFS_PARTITION_TYPE {
let lba = u32::from_le_bytes(mbr[eo + 8..eo + 12].try_into().unwrap());
let offset = lba as u64 * SECTOR_SIZE;
if offset + 4 <= vsize && read_magic(base, delta, offset)? == NTFS_MAGIC {
if offset + 4 <= vsize && read_magic(layers, offset)? == NTFS_MAGIC {
return Ok(offset);
}
}
@@ -516,7 +547,7 @@ fn find_ntfs_offset_merged<R: Read + Seek>(
}
for offset in NTFS_PROBE_OFFSETS {
if offset + 4 <= vsize && read_magic(base, delta, offset)? == NTFS_MAGIC {
if offset + 4 <= vsize && read_magic(layers, offset)? == NTFS_MAGIC {
return Ok(offset);
}
}
@@ -673,27 +704,36 @@ pub fn extract_vhd(vhd_path: &Path, output_dir: &Path) -> Result<()> {
Ok(())
}
/// Extract files from a merged view of base + delta VHDs (pure Rust, no admin needed).
/// Reads delta blocks where available, falls back to base. Deletes both VHDs after.
/// The `output_dir` is where files are extracted to.
pub fn extract_merged_vhd(base_path: &Path, delta_path: &Path, output_dir: &Path) -> Result<()> {
println!("Extracting merged VHD: {} + {}", base_path.display(), delta_path.display());
/// Extract files from a chained view of a base + N differencing VHDs.
///
/// `chain` must be ordered base-first, top-most delta last. A chain of length 1
/// is equivalent to extracting just the base. Unlike [`extract_vhd`], this does
/// **not** delete the inputs — the caller is responsible, since a single VHD
/// in a chain is typically consumed by multiple extractions (one per patch
/// level) and must not be removed until all of them have completed.
pub fn extract_chained_vhd(chain: &[&Path], output_dir: &Path) -> Result<()> {
if chain.is_empty() {
return Err(anyhow!("extract_chained_vhd: empty chain"));
}
let base = File::open(base_path)?;
let delta = File::open(delta_path)?;
let mut merged = MergedVhdReader::new(base, delta).map_err(|e| anyhow!(e))?;
let paths_disp = chain
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join(" + ");
println!("Extracting chained VHD: {paths_disp}");
let readers: Vec<File> = chain
.iter()
.map(|p| File::open(p))
.collect::<io::Result<_>>()?;
let mut reader = ChainedVhdReader::new(readers).map_err(|e| anyhow!(e))?;
let prefix = output_dir.file_name().unwrap_or_default().to_string_lossy().to_string();
extract_ntfs_to_dir(&mut merged, output_dir, &prefix)?;
extract_ntfs_to_dir(&mut reader, output_dir, &prefix)?;
println!("Extracted to: {}", output_dir.display());
drop(merged);
for path in [base_path, delta_path] {
if let Err(e) = std::fs::remove_file(path) {
println!("WARNING: Could not delete {}: {e}", path.display());
}
}
Ok(())
}