From 558c0ecec079c5e60e5a8ec0a8aa848b3a54e82f Mon Sep 17 00:00:00 2001 From: jujuforce Date: Mon, 20 Apr 2026 11:37:53 +0200 Subject: [PATCH 1/3] fix: parse exFAT UtcOffset per spec, tolerate bad timestamps --- src/main.rs | 69 ++++++++++++++++++++++++++++++++++------------------- 1 file changed, 44 insertions(+), 25 deletions(-) diff --git a/src/main.rs b/src/main.rs index ccc7513..6696dd7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -30,22 +30,36 @@ fn exfat_timestamp_to_system_time( ) -> Result { let exfat_date = timestamp.date(); let exfat_time = timestamp.time(); - // exFAT UTC offset is in 15-minute intervals, so 1 = UTC+00:15, 2 = UTC+00:30, etc. - let exfat_utc_offset = timestamp.utc_offset() as i32 * 15 * 60; - let chrono_date_time = FixedOffset::east_opt(exfat_utc_offset) - .ok_or_else(|| anyhow!("invaid utc offset: {}", timestamp.utc_offset()))? - .with_ymd_and_hms( - exfat_date.year as i32, - exfat_date.month as u32, - exfat_date.day as u32, - exfat_time.hour as u32, - exfat_time.minute as u32, - exfat_time.second as u32, - ) - .unwrap(); - return Ok(SystemTime::UNIX_EPOCH - + Duration::from_micros(chrono_date_time.timestamp_micros().try_into()?)); + // The exFAT UtcOffset byte packs an OffsetValid flag (bit 7) with a 7-bit + // two's-complement OffsetFromUtc in 15-minute units. When OffsetValid is 0 + // the timestamp has no timezone info and the offset bits must be ignored. + let raw = timestamp.utc_offset() as u8; + let offset_seconds = if raw & 0x80 == 0 { + 0 + } else { + let offset_quarters = (((raw & 0x7F) << 1) as i8) >> 1; + offset_quarters as i32 * 15 * 60 + }; + let fixed_offset = FixedOffset::east_opt(offset_seconds).unwrap_or_else(|| FixedOffset::east_opt(0).unwrap()); + + let chrono_date_time = match fixed_offset.with_ymd_and_hms( + exfat_date.year as i32, + exfat_date.month as u32, + exfat_date.day as u32, + exfat_time.hour as u32, + exfat_time.minute as u32, + exfat_time.second as u32, + ) { + chrono::LocalResult::Single(dt) => dt, + _ => return Ok(SystemTime::UNIX_EPOCH), + }; + + let micros: u64 = chrono_date_time + .timestamp_micros() + .try_into() + .unwrap_or(0); + Ok(SystemTime::UNIX_EPOCH + Duration::from_micros(micros)) } fn extract_exfat_contents(exfat_path: &Path) -> Result<()> { @@ -96,17 +110,22 @@ fn extract_exfat_elements( match element { FsElement::F(ref mut file) => { let dest_path = output_dir.join(file.name()); - let mut dest = File::create(dest_path)?; + let mut dest = File::create(&dest_path)?; - dest.set_times( - FileTimes::new() - .set_accessed(exfat_timestamp_to_system_time( - file.timestamps().accessed(), - )?) - .set_modified(exfat_timestamp_to_system_time( - file.timestamps().modified(), - )?), - )?; + let accessed = exfat_timestamp_to_system_time(file.timestamps().accessed()); + let modified = exfat_timestamp_to_system_time(file.timestamps().modified()); + if let (Ok(accessed), Ok(modified)) = (accessed, modified) { + if let Err(e) = dest.set_times( + FileTimes::new() + .set_accessed(accessed) + .set_modified(modified), + ) { + println!( + "WARNING: Failed to set times on {}: {e}", + dest_path.display() + ); + } + } let mut writer = BufWriter::with_capacity(256 * 1024, &mut dest); From d719125ea38870d070e6899230fb224e65f53dfa Mon Sep 17 00:00:00 2001 From: jujuforce Date: Mon, 20 Apr 2026 14:28:12 +0200 Subject: [PATCH 2/3] fix: set file times after writing to preserve source mtime --- src/main.rs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/main.rs b/src/main.rs index 6696dd7..4ad4339 100644 --- a/src/main.rs +++ b/src/main.rs @@ -112,6 +112,15 @@ fn extract_exfat_elements( let dest_path = output_dir.join(file.name()); let mut dest = File::create(&dest_path)?; + { + let mut writer = BufWriter::with_capacity(256 * 1024, &mut dest); + std::io::copy(file, &mut writer)?; + writer.flush()?; + } + pb.inc(file.len()); + + // set_times must run after writes — otherwise the kernel + // updates mtime/atime back to "now" when bytes are flushed. let accessed = exfat_timestamp_to_system_time(file.timestamps().accessed()); let modified = exfat_timestamp_to_system_time(file.timestamps().modified()); if let (Ok(accessed), Ok(modified)) = (accessed, modified) { @@ -126,12 +135,6 @@ fn extract_exfat_elements( ); } } - - let mut writer = BufWriter::with_capacity(256 * 1024, &mut dest); - - std::io::copy(file, &mut writer)?; - writer.flush()?; - pb.inc(file.len()); } FsElement::D(directory) => { let dest_path = output_dir.join(directory.name()); From 2ef6c992f99f770ec73ff041f44360f45b056311 Mon Sep 17 00:00:00 2001 From: jujuforce Date: Mon, 20 Apr 2026 20:44:56 +0200 Subject: [PATCH 3/3] fix: resolve VHD chains via parent/own GUIDs --- src/main.rs | 89 ++++++++++-- src/vhd.rs | 402 +++++++++++++++++++++++++++++----------------------- 2 files changed, 302 insertions(+), 189 deletions(-) diff --git a/src/main.rs b/src/main.rs index 4ad4339..1d2d676 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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(()) +} diff --git a/src/vhd.rs b/src/vhd.rs index 57d0806..554ca2b 100644 --- a/src/vhd.rs +++ b/src/vhd.rs @@ -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, + /// 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 { + 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 Seek for VhdReader { } // --------------------------------------------------------------------------- -// 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 { - 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 { + 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 { + layers: Vec>, ntfs_offset: u64, virtual_size: u64, pos: u64, } -impl MergedVhdReader { - pub fn new(mut base: R, mut delta: R) -> Result { - // 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 ChainedVhdReader { + /// Build a chain reader. `readers` must be ordered base-first, top-most delta last. + pub fn new(readers: Vec) -> Result { + 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> = 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 { - 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 MergedVhdReader { } } -impl Read for MergedVhdReader { +/// 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( + layer: &mut VhdLayer, + virt_off: u64, + buf: &mut [u8], +) -> io::Result> { + 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( + layers: &mut [VhdLayer], + vsize: u64, + virt_off: u64, + buf: &mut [u8], +) -> io::Result { + 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 Read for ChainedVhdReader { fn read(&mut self, buf: &mut [u8]) -> io::Result { 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 Seek for MergedVhdReader { +impl Seek for ChainedVhdReader { fn seek(&mut self, pos: SeekFrom) -> io::Result { let target = match pos { SeekFrom::Start(o) => o as i64, @@ -440,66 +509,28 @@ fn find_ntfs_offset( 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( - base: &mut R, - base_layout: &VhdLayout, - delta: &mut R, - delta_layout: &VhdLayout, - vsize: u64, - virt_off: u64, - buf: &mut [u8], -) -> io::Result { - 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( - 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( + layers: &mut [VhdLayer], vsize: u64, ) -> Result { - 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], 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( 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( } 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::>() + .join(" + "); + println!("Extracting chained VHD: {paths_disp}"); + + let readers: Vec = chain + .iter() + .map(|p| File::open(p)) + .collect::>()?; + 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(()) }