From 2121b436990e5cae2910e9ca81ae8286ea4fbe1b Mon Sep 17 00:00:00 2001 From: Jujuforce Date: Sun, 22 Mar 2026 16:15:58 +0100 Subject: [PATCH] feat: pure Rust VHD merge, remove PowerShell dependency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace Set-VHD + Merge-VHD PowerShell cmdlets with a pure Rust MergedVhdReader that overlays delta blocks on the base VHD using the sector bitmap to decide which sectors come from which disk. No actual merge to disk needed — reads directly from both files. - No admin privileges or UAC prompts required - No Hyper-V or PowerShell dependency - Cross-platform (works on Windows and Unix) - Remove #[cfg(windows)] from merge/extract path Co-Authored-By: Claude Opus 4.6 (1M context) --- src/main.rs | 84 +------- src/vhd.rs | 586 +++++++++++++++++++++++++++++++++++++++------------- 2 files changed, 447 insertions(+), 223 deletions(-) diff --git a/src/main.rs b/src/main.rs index 512daba..d61e613 100644 --- a/src/main.rs +++ b/src/main.rs @@ -222,12 +222,6 @@ struct ExtractedVhd { game_id: String, } -/// Strip the `\\?\` prefix that `canonicalize` adds on Windows. -fn strip_unc_prefix(path: PathBuf) -> PathBuf { - let s = path.to_string_lossy(); - s.strip_prefix(r"\\?\").map(PathBuf::from).unwrap_or(path) -} - /// Search a directory for a file matching `{prefix}*{suffix}`. fn find_sibling(dir: &Path, prefix: &str, suffix: &str) -> Option { std::fs::read_dir(dir).ok()? @@ -239,57 +233,8 @@ fn find_sibling(dir: &Path, prefix: &str, suffix: &str) -> Option { }) } -/// Merge a differencing VHD into its parent using Hyper-V PowerShell cmdlets. -/// Requires elevation (triggers UAC prompt). -#[cfg(windows)] -fn merge_vhd(base_vhd: &Path, delta_vhd: &Path) -> Result<()> { - let base_abs = strip_unc_prefix(std::fs::canonicalize(base_vhd)?); - let delta_abs = strip_unc_prefix(std::fs::canonicalize(delta_vhd)?); - - println!("Merging VHDs: {} <- {}", base_abs.display(), delta_abs.display()); - - let ps_commands = format!( - "Set-VHD -Path '{}' -ParentPath '{}'; Merge-VHD -Path '{}' -Force", - delta_abs.display(), - base_abs.display(), - delta_abs.display(), - ); - - let error_log = delta_vhd.with_extension("merge_error.txt"); - let error_log_abs = strip_unc_prefix(std::path::absolute(&error_log)?); - - let wrapped = format!( - "try {{ {} }} catch {{ $_ | Out-File '{}' -Encoding UTF8; throw }}", - ps_commands, - error_log_abs.display(), - ); - - let status = std::process::Command::new("powershell") - .args([ - "-Command", - &format!( - "Start-Process powershell -Verb RunAs -Wait -ArgumentList '-Command', '{}'", - wrapped.replace('\'', "''") - ), - ]) - .status()?; - - if error_log.exists() { - let error_text = std::fs::read_to_string(&error_log).unwrap_or_default(); - std::fs::remove_file(&error_log).ok(); - println!("WARNING: VHD merge failed: {}", error_text.trim()); - } else if status.success() { - println!("Merged into: {}", base_abs.display()); - } else { - println!("WARNING: Merge failed (exit: {status}). Check UAC was accepted."); - } - - Ok(()) -} - /// Resolve the base VHD for a delta: check extracted VHDs, then look for /// an existing .vhd or .app in the same directory. -#[cfg(windows)] fn resolve_base_vhd<'a>( base: Option<&'a ExtractedVhd>, game_id: &str, @@ -434,23 +379,23 @@ fn main() -> Result<()> { } } - // Post-extraction: merge deltas and extract VHD contents (Windows only) - #[cfg(windows)] + // Post-extraction: merge deltas and extract VHD contents if !cli.no_extract && !extracted_vhds.is_empty() { let mut by_game: HashMap> = HashMap::new(); for vhd in &extracted_vhds { by_game.entry(vhd.game_id.clone()).or_default().push(vhd); } - let mut vhds_to_extract: Vec = Vec::new(); - 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(); if deltas.is_empty() { + // Standalone base VHD, just extract if let Some(base) = base { - vhds_to_extract.push(base.vhd_path.clone()); + if let Err(e) = vhd::extract_vhd(&base.vhd_path) { + println!("WARNING: VHD extraction failed: {e:#}"); + } } continue; } @@ -461,23 +406,10 @@ fn main() -> Result<()> { continue; }; - let mut merge_ok = true; + // Merge + extract each delta with the base (pure Rust, no admin needed) for delta in &deltas { - if let Err(e) = merge_vhd(&base.vhd_path, &delta.vhd_path) { - println!("WARNING: Failed to merge: {e:#}"); - merge_ok = false; - } - } - - if merge_ok { - vhds_to_extract.push(base.vhd_path.clone()); - } - } - - for vhd_path in &vhds_to_extract { - if vhd_path.exists() { - if let Err(e) = vhd::extract_vhd(vhd_path) { - println!("WARNING: VHD extraction failed: {e:#}"); + if let Err(e) = vhd::extract_merged_vhd(&base.vhd_path, &delta.vhd_path) { + println!("WARNING: Merged VHD extraction failed: {e:#}"); } } } diff --git a/src/vhd.rs b/src/vhd.rs index f236393..fc05c28 100644 --- a/src/vhd.rs +++ b/src/vhd.rs @@ -20,10 +20,11 @@ const BUF_SIZE: usize = 256 * 1024; const VHD_COOKIE: &[u8; 8] = b"conectix"; const VHD_TYPE_FIXED: u32 = 2; 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; -// Dynamic VHD header +// Dynamic/differencing VHD header const DYNAMIC_HEADER_COOKIE: &[u8; 8] = b"cxsparse"; const DYNAMIC_HEADER_SIZE: usize = 1024; const DYNAMIC_BAT_OFFSET_FIELD: usize = 0x10; @@ -60,7 +61,7 @@ pub enum VhdError { Io(#[from] io::Error), #[error("Not a valid VHD file")] InvalidCookie, - #[error("Unsupported VHD type {0} (only fixed and dynamic are supported)")] + #[error("Unsupported VHD type {0}")] UnsupportedType(u32), #[error("Invalid dynamic VHD header")] InvalidDynamicHeader, @@ -69,14 +70,106 @@ pub enum VhdError { } // --------------------------------------------------------------------------- -// VHD reader +// VHD layout: how to map virtual offsets to file offsets // --------------------------------------------------------------------------- enum VhdLayout { + /// Data is contiguous from offset 0 to (file_size - 512). Fixed, - Dynamic { bat: Vec, block_size: u64 }, + /// Data is in blocks addressed via a Block Allocation Table. + /// Used for both dynamic (type 3) and differencing (type 4) VHDs. + Sparse { bat: Vec, block_size: u64 }, } +impl VhdLayout { + /// Parse the dynamic/differencing header and BAT. + fn parse_sparse(inner: &mut R, footer: &[u8]) -> Result<(Self, u64), VhdError> { + let header_offset = read_be_u64(footer, VHD_FOOTER_DATA_OFFSET); + + inner.seek(SeekFrom::Start(header_offset))?; + let mut hdr = [0u8; DYNAMIC_HEADER_SIZE]; + inner.read_exact(&mut hdr)?; + if &hdr[..8] != DYNAMIC_HEADER_COOKIE { + return Err(VhdError::InvalidDynamicHeader); + } + + let bat_offset = read_be_u64(&hdr, DYNAMIC_BAT_OFFSET_FIELD); + let max_entries = read_be_u32(&hdr, DYNAMIC_MAX_ENTRIES_FIELD) as usize; + let block_size = read_be_u32(&hdr, DYNAMIC_BLOCK_SIZE_FIELD) as u64; + + inner.seek(SeekFrom::Start(bat_offset))?; + let mut raw = vec![0u8; max_entries * 4]; + inner.read_exact(&mut raw)?; + let bat: Vec = (0..max_entries).map(|i| read_be_u32(&raw, i * 4)).collect(); + + Ok((VhdLayout::Sparse { bat, block_size }, max_entries as u64 * block_size)) + } + + /// Read bytes from a virtual offset according to this layout. + fn read_at( + &self, + inner: &mut R, + virt_off: u64, + virtual_size: u64, + buf: &mut [u8], + ) -> io::Result { + if virt_off >= virtual_size { + return Ok(0); + } + let cap = std::cmp::min(buf.len() as u64, virtual_size - virt_off) as usize; + + match self { + VhdLayout::Fixed => { + inner.seek(SeekFrom::Start(virt_off))?; + inner.read(&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 { + buf[..n].fill(0); + Ok(n) + } else { + // Each block: bitmap sector + data. Skip bitmap. + let file_off = bat[bi] as u64 * SECTOR_SIZE + SECTOR_SIZE + bo; + inner.seek(SeekFrom::Start(file_off))?; + inner.read(&mut buf[..n]) + } + } + } + } + + /// Read 4 bytes from a virtual offset (for magic-byte probing). + fn read_magic( + &self, + inner: &mut R, + offset: u64, + ) -> io::Result<[u8; 4]> { + let mut buf = [0u8; 4]; + match self { + VhdLayout::Fixed => { + inner.seek(SeekFrom::Start(offset))?; + inner.read_exact(&mut buf)?; + } + VhdLayout::Sparse { bat, block_size } => { + let bi = (offset / block_size) as usize; + if bi < bat.len() && bat[bi] != BAT_UNUSED { + let file_off = bat[bi] as u64 * SECTOR_SIZE + SECTOR_SIZE + offset % block_size; + inner.seek(SeekFrom::Start(file_off))?; + inner.read_exact(&mut buf)?; + } + } + } + Ok(buf) + } +} + +// --------------------------------------------------------------------------- +// VHD reader (single VHD) +// --------------------------------------------------------------------------- + /// Transparently presents the NTFS partition within a fixed or dynamic VHD. pub struct VhdReader { inner: R, @@ -103,138 +196,16 @@ impl VhdReader { let disk_type = read_be_u32(&footer, VHD_FOOTER_DISK_TYPE_OFFSET); let (layout, virtual_size) = match disk_type { VHD_TYPE_FIXED => (VhdLayout::Fixed, file_size - SECTOR_SIZE), - VHD_TYPE_DYNAMIC => Self::parse_dynamic(&mut inner, &footer)?, + VHD_TYPE_DYNAMIC | VHD_TYPE_DIFFERENCING => { + VhdLayout::parse_sparse(&mut inner, &footer)? + } t => return Err(VhdError::UnsupportedType(t)), }; - let ntfs_offset = Self::find_ntfs(&mut inner, &layout, virtual_size)?; + let ntfs_offset = find_ntfs_offset(&mut inner, &layout, virtual_size)?; Ok(Self { inner, layout, ntfs_offset, virtual_size, pos: 0 }) } - fn parse_dynamic(inner: &mut R, footer: &[u8]) -> Result<(VhdLayout, u64), VhdError> { - let header_offset = read_be_u64(footer, VHD_FOOTER_DATA_OFFSET); - - inner.seek(SeekFrom::Start(header_offset))?; - let mut hdr = [0u8; DYNAMIC_HEADER_SIZE]; - inner.read_exact(&mut hdr)?; - if &hdr[..8] != DYNAMIC_HEADER_COOKIE { - return Err(VhdError::InvalidDynamicHeader); - } - - let bat_offset = read_be_u64(&hdr, DYNAMIC_BAT_OFFSET_FIELD); - let max_entries = read_be_u32(&hdr, DYNAMIC_MAX_ENTRIES_FIELD) as usize; - let block_size = read_be_u32(&hdr, DYNAMIC_BLOCK_SIZE_FIELD) as u64; - - inner.seek(SeekFrom::Start(bat_offset))?; - let mut raw = vec![0u8; max_entries * 4]; - inner.read_exact(&mut raw)?; - let bat: Vec = (0..max_entries).map(|i| read_be_u32(&raw, i * 4)).collect(); - - Ok((VhdLayout::Dynamic { bat, block_size }, max_entries as u64 * block_size)) - } - - /// Translate a virtual disk offset to a file read. - fn read_virtual(&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.layout { - VhdLayout::Fixed => { - self.inner.seek(SeekFrom::Start(virt_off))?; - self.inner.read(&mut buf[..cap]) - } - VhdLayout::Dynamic { 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 { - buf[..n].fill(0); - Ok(n) - } else { - // Each block: bitmap sector + data. Skip bitmap. - let file_off = bat[bi] as u64 * SECTOR_SIZE + SECTOR_SIZE + bo; - self.inner.seek(SeekFrom::Start(file_off))?; - self.inner.read(&mut buf[..n]) - } - } - } - } - - /// Read 4 bytes from a virtual offset (for magic-byte probing). - fn read_virtual_u32(inner: &mut R, layout: &VhdLayout, offset: u64) -> io::Result<[u8; 4]> { - let mut buf = [0u8; 4]; - match layout { - VhdLayout::Fixed => { - inner.seek(SeekFrom::Start(offset))?; - inner.read_exact(&mut buf)?; - } - VhdLayout::Dynamic { bat, block_size } => { - let bi = (offset / block_size) as usize; - if bi < bat.len() && bat[bi] != BAT_UNUSED { - let file_off = bat[bi] as u64 * SECTOR_SIZE + SECTOR_SIZE + offset % block_size; - inner.seek(SeekFrom::Start(file_off))?; - inner.read_exact(&mut buf)?; - } - } - } - Ok(buf) - } - - /// Read a full sector from virtual offset 0. - fn read_first_sector(inner: &mut R, layout: &VhdLayout) -> io::Result<[u8; SECTOR_SIZE as usize]> { - let mut sector = [0u8; SECTOR_SIZE as usize]; - match layout { - VhdLayout::Fixed => { - inner.seek(SeekFrom::Start(0))?; - inner.read_exact(&mut sector)?; - } - VhdLayout::Dynamic { bat, .. } => { - if !bat.is_empty() && bat[0] != BAT_UNUSED { - inner.seek(SeekFrom::Start(bat[0] as u64 * SECTOR_SIZE + SECTOR_SIZE))?; - inner.read_exact(&mut sector)?; - } - } - } - Ok(sector) - } - - /// Find the byte offset of the NTFS partition within the virtual disk. - fn find_ntfs(inner: &mut R, layout: &VhdLayout, vsize: u64) -> Result { - // Try MBR partition table first - if vsize >= SECTOR_SIZE { - let mbr = Self::read_first_sector(inner, layout)?; - - if mbr[510..512] == MBR_SIGNATURE { - for i in 0..MBR_MAX_PARTITIONS { - let eo = MBR_PARTITION_TABLE_OFFSET + i * MBR_PARTITION_ENTRY_SIZE; - 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 - && Self::read_virtual_u32(inner, layout, offset)? == NTFS_MAGIC - { - return Ok(offset); - } - } - } - } - } - - // Probe common offsets - for offset in NTFS_PROBE_OFFSETS { - if offset + 4 <= vsize - && Self::read_virtual_u32(inner, layout, offset)? == NTFS_MAGIC - { - return Ok(offset); - } - } - - Err(VhdError::NoNtfsPartition) - } - fn ntfs_size(&self) -> u64 { self.virtual_size - self.ntfs_offset } @@ -247,7 +218,9 @@ impl Read for VhdReader { return Ok(0); } let cap = std::cmp::min(buf.len() as u64, remaining) as usize; - let n = self.read_virtual(self.ntfs_offset + self.pos, &mut buf[..cap])?; + let n = self.layout.read_at( + &mut self.inner, self.ntfs_offset + self.pos, self.virtual_size, &mut buf[..cap], + )?; self.pos += n as u64; Ok(n) } @@ -269,7 +242,290 @@ impl Seek for VhdReader { } // --------------------------------------------------------------------------- -// NTFS extraction +// Merged VHD reader (base + delta overlay, no 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, + 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 { + 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)? + } + 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)? + } + 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], + ) + } + } + } + } + + fn ntfs_size(&self) -> u64 { + self.virtual_size - self.ntfs_offset + } +} + +impl Read for MergedVhdReader { + 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])?; + self.pos += n as u64; + Ok(n) + } +} + +impl Seek for MergedVhdReader { + fn seek(&mut self, pos: SeekFrom) -> io::Result { + let target = match pos { + SeekFrom::Start(o) => o as i64, + SeekFrom::Current(o) => self.pos as i64 + o, + SeekFrom::End(o) => self.ntfs_size() as i64 + o, + }; + if target < 0 { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "seek before start")); + } + self.pos = target as u64; + Ok(self.pos) + } +} + +// --------------------------------------------------------------------------- +// NTFS partition detection (shared between single and merged readers) +// --------------------------------------------------------------------------- + +/// Find NTFS offset in a single VHD. +fn find_ntfs_offset( + inner: &mut R, + layout: &VhdLayout, + vsize: u64, +) -> Result { + // Try MBR + if vsize >= SECTOR_SIZE { + let mut mbr = [0u8; SECTOR_SIZE as usize]; + let _ = layout.read_at(inner, 0, vsize, &mut mbr); + + if mbr[510..512] == MBR_SIGNATURE { + for i in 0..MBR_MAX_PARTITIONS { + let eo = MBR_PARTITION_TABLE_OFFSET + i * MBR_PARTITION_ENTRY_SIZE; + 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 && layout.read_magic(inner, offset)? == NTFS_MAGIC { + return Ok(offset); + } + } + } + } + } + + // Probe common offsets + for offset in NTFS_PROBE_OFFSETS { + if offset + 4 <= vsize && layout.read_magic(inner, offset)? == NTFS_MAGIC { + return Ok(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, + vsize: u64, +) -> Result { + let read_magic = |base: &mut R, delta: &mut 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)?; + Ok(buf) + }; + + // 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)?; + + if mbr[510..512] == MBR_SIGNATURE { + for i in 0..MBR_MAX_PARTITIONS { + let eo = MBR_PARTITION_TABLE_OFFSET + i * MBR_PARTITION_ENTRY_SIZE; + 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 { + return Ok(offset); + } + } + } + } + } + + for offset in NTFS_PROBE_OFFSETS { + if offset + 4 <= vsize && read_magic(base, delta, offset)? == NTFS_MAGIC { + return Ok(offset); + } + } + + Err(VhdError::NoNtfsPartition) +} + +// --------------------------------------------------------------------------- +// NTFS extraction (shared logic) // --------------------------------------------------------------------------- fn is_ntfs_system_entry(name: &str) -> bool { @@ -375,26 +631,38 @@ fn calculate_ntfs_size( Ok(total) } -/// Extract all files from a VHD's NTFS filesystem, then delete the VHD. +/// Shared extraction logic: given an NTFS-bearing Read+Seek, extract to output_dir. +fn extract_ntfs_to_dir(fs: &mut T, output_dir: &Path, prefix: &str) -> Result<()> { + let mut ntfs = Ntfs::new(fs)?; + ntfs.read_upcase_table(fs)?; + + let root = ntfs.root_directory(fs)?; + let total = calculate_ntfs_size(&ntfs, fs, &root)?; + + let pb = ProgressBar::new(total) + .with_style(ProgressStyle::default_bar().template(PROGRESS_STYLE)?); + pb.set_prefix(prefix.to_string()); + + create_dir_all(output_dir)?; + let root = ntfs.root_directory(fs)?; + extract_ntfs_dir(&ntfs, fs, &root, output_dir, &pb)?; + pb.finish(); + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/// Extract all files from a single VHD's NTFS filesystem, then delete the VHD. pub fn extract_vhd(vhd_path: &Path) -> Result<()> { let output_dir = vhd_path.with_extension(""); println!("Extracting VHD: {}", vhd_path.display()); let mut vhd = VhdReader::new(File::open(vhd_path)?).map_err(|e| anyhow!(e))?; - let mut ntfs = Ntfs::new(&mut vhd)?; - ntfs.read_upcase_table(&mut vhd)?; - - let root = ntfs.root_directory(&mut vhd)?; - let total = calculate_ntfs_size(&ntfs, &mut vhd, &root)?; - - let pb = ProgressBar::new(total) - .with_style(ProgressStyle::default_bar().template(PROGRESS_STYLE)?); - pb.set_prefix(vhd_path.file_name().unwrap_or_default().to_string_lossy().to_string()); - - create_dir_all(&output_dir)?; - let root = ntfs.root_directory(&mut vhd)?; - extract_ntfs_dir(&ntfs, &mut vhd, &root, &output_dir, &pb)?; - pb.finish(); + let prefix = vhd_path.file_name().unwrap_or_default().to_string_lossy().to_string(); + extract_ntfs_to_dir(&mut vhd, &output_dir, &prefix)?; println!("Extracted to: {}", output_dir.display()); @@ -405,6 +673,30 @@ pub fn extract_vhd(vhd_path: &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. +pub fn extract_merged_vhd(base_path: &Path, delta_path: &Path) -> Result<()> { + let output_dir = base_path.with_extension(""); + println!("Extracting merged VHD: {} + {}", base_path.display(), delta_path.display()); + + 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 prefix = base_path.file_name().unwrap_or_default().to_string_lossy().to_string(); + extract_ntfs_to_dir(&mut merged, &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(()) +} + // --------------------------------------------------------------------------- // Helpers // ---------------------------------------------------------------------------