feat: pure Rust VHD extraction, no admin/Hyper-V needed

Replace PowerShell-based VHD mounting (Set-VHD/Mount-VHD/Copy-Item)
with a pure Rust implementation. New src/vhd.rs module:
- VhdReader<R> parses fixed and dynamic VHD formats
- Handles MBR partition tables and NTFS offset detection
- Dynamic VHD support with BAT (Block Allocation Table) parsing
- Recursive NTFS directory extraction with timestamp preservation
- No elevation, no Hyper-V, works cross-platform

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jujuforce
2026-03-22 16:40:18 +01:00
co-authored by Claude Opus 4.6
parent 96bb605a17
commit f612c44f19
2 changed files with 521 additions and 93 deletions
+2 -93
View File
@@ -23,6 +23,7 @@ use crate::stream::FscryptDecryptor;
mod bootid;
mod crypto;
mod stream;
mod vhd;
/// Info collected from each input file for sorting and post-extraction merge.
struct InputFile {
@@ -234,98 +235,6 @@ fn strip_extended_path_prefix(path: PathBuf) -> PathBuf {
}
}
/// Mount a VHD, extract all contents to a folder with the same name, dismount, and delete the VHD.
/// Requires elevation (triggers UAC prompt).
#[cfg(windows)]
fn extract_vhd_contents(vhd_path: &Path) -> Result<()> {
let vhd_abs = strip_extended_path_prefix(std::fs::canonicalize(vhd_path)?);
let output_dir = vhd_path.with_extension("");
let output_dir_abs = strip_extended_path_prefix(std::path::absolute(&output_dir)?);
println!("Extracting VHD contents...");
println!(" VHD: {}", vhd_abs.display());
println!(" Output: {}", output_dir_abs.display());
// Write a PowerShell script to a temp file to avoid nested quoting hell.
// Mount VHD read-only, copy contents, dismount.
let script_path = vhd_path.with_extension("extract.ps1");
let script_abs = strip_extended_path_prefix(std::path::absolute(&script_path)?);
let error_log = vhd_path.with_extension("extract_error.txt");
let error_log_abs = strip_extended_path_prefix(std::path::absolute(&error_log)?);
let script = format!(
r#"try {{
$vhd = Mount-VHD -Path '{vhd}' -ReadOnly -Passthru
$disk = $vhd | Get-Disk
$part = $disk | Get-Partition | Where-Object {{ $_.Type -ne 'Reserved' }} | Select-Object -First 1
# Try to get existing drive letter
$dl = ($part | Get-Volume).DriveLetter
# If no drive letter, assign one temporarily
$assignedLetter = $false
if (-not $dl) {{
$usedLetters = (Get-Volume).DriveLetter
$dl = [char[]](90..68) | Where-Object {{ $_ -notin $usedLetters }} | Select-Object -First 1
if (-not $dl) {{
Dismount-VHD -Path '{vhd}'
throw 'No available drive letter'
}}
$part | Set-Partition -NewDriveLetter $dl
$assignedLetter = $true
}}
$src = "$($dl):\"
New-Item -ItemType Directory -Force -Path '{out}' | Out-Null
Get-ChildItem -Path $src -Force | Where-Object {{ $_.Name -ne 'System Volume Information' -and $_.Name -ne '$Recycle.Bin' }} | Copy-Item -Destination '{out}' -Recurse -Force
# Remove assigned letter before dismounting
if ($assignedLetter) {{
$part | Remove-PartitionAccessPath -AccessPath "$($dl):\" -ErrorAction SilentlyContinue
}}
Dismount-VHD -Path '{vhd}'
}} catch {{
$_ | Out-File '{err}' -Encoding UTF8
try {{ Dismount-VHD -Path '{vhd}' -ErrorAction SilentlyContinue }} catch {{}}
throw
}}"#,
vhd = vhd_abs.display(),
out = output_dir_abs.display(),
err = error_log_abs.display(),
);
std::fs::write(&script_path, &script)?;
let status = std::process::Command::new("powershell")
.args([
"-Command",
&format!(
"Start-Process powershell -Verb RunAs -Wait -ArgumentList '-ExecutionPolicy', 'Bypass', '-File', '{}'",
script_abs.display()
),
])
.status()?;
// Clean up the script
std::fs::remove_file(&script_path).ok();
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 extraction failed: {}", error_text.trim());
} else if status.success() && output_dir.exists() {
println!("Extracted to: {}", output_dir_abs.display());
// Delete the VHD now that contents are extracted
if let Err(e) = std::fs::remove_file(vhd_path) {
println!("WARNING: Could not delete VHD: {e}");
}
} else {
println!("WARNING: VHD extraction may have failed. Check UAC was accepted.");
}
Ok(())
}
/// Merge a differencing VHD into its parent using Hyper-V PowerShell cmdlets.
///
/// Steps:
@@ -611,7 +520,7 @@ fn main() -> Result<()> {
// Extract all VHD contents to folders, then delete the VHDs
for vhd_path in &vhds_to_extract {
if vhd_path.exists() {
if let Err(e) = extract_vhd_contents(vhd_path) {
if let Err(e) = vhd::extract_vhd(vhd_path) {
println!("WARNING: Failed to extract VHD contents: {e:#?}");
}
}
+519
View File
@@ -0,0 +1,519 @@
use std::{
fs::{create_dir_all, File, FileTimes, OpenOptions},
io::{self, BufRead, BufReader, Read, Seek, SeekFrom, Write},
path::Path,
time::{Duration, SystemTime},
};
use anyhow::{anyhow, Result};
use indicatif::{ProgressBar, ProgressStyle};
use ntfs::{structured_values::NtfsStandardInformation, Ntfs, NtfsAttributeType, NtfsTime};
// ---------------------------------------------------------------------------
// VHD constants
// ---------------------------------------------------------------------------
const VHD_FOOTER_SIZE: u64 = 512;
const VHD_COOKIE: &[u8; 8] = b"conectix";
const VHD_TYPE_FIXED: u32 = 2;
const VHD_TYPE_DYNAMIC: u32 = 3;
const DYNAMIC_HEADER_COOKIE: &[u8; 8] = b"cxsparse";
const BAT_ENTRY_UNUSED: u32 = 0xFFFFFFFF;
/// Sectors per bitmap: each data block is preceded by a sector-aligned bitmap.
const BITMAP_SECTORS: u64 = 1;
const MBR_SIGNATURE: [u8; 2] = [0x55, 0xAA];
const MBR_PARTITION_TABLE_OFFSET: usize = 0x1BE;
const MBR_PARTITION_ENTRY_SIZE: usize = 16;
const NTFS_PARTITION_TYPE: u8 = 0x07;
const NTFS_PROBE_OFFSETS: &[u64] = &[0, 32_256, 1_048_576, 512];
const NTFS_MAGIC: [u8; 4] = [0xEB, 0x52, 0x90, 0x4E];
// ---------------------------------------------------------------------------
// VHD error type
// ---------------------------------------------------------------------------
#[derive(Debug, thiserror::Error)]
pub enum VhdError {
#[error(transparent)]
Io(#[from] io::Error),
#[error("Not a valid VHD file (bad cookie)")]
InvalidCookie,
#[error("Unsupported VHD type {0} (only fixed=2 and dynamic=3 are supported)")]
UnsupportedType(u32),
#[error("Invalid dynamic VHD header")]
InvalidDynamicHeader,
#[error("No NTFS partition found in VHD")]
NoNtfsPartition,
}
// ---------------------------------------------------------------------------
// VHD reader
// ---------------------------------------------------------------------------
enum VhdLayout {
/// Fixed VHD: data is contiguous from offset 0 to (file_size - 512).
Fixed,
/// Dynamic VHD: data is in blocks addressed via a Block Allocation Table.
Dynamic {
bat: Vec<u32>,
block_size: u64,
},
}
/// A reader that transparently presents the NTFS partition within a VHD file.
pub struct VhdReader<R> {
inner: R,
layout: VhdLayout,
/// Byte offset where the NTFS partition starts within the virtual disk.
ntfs_offset: u64,
/// Total virtual disk size.
virtual_disk_size: u64,
/// Current virtual position (relative to NTFS start).
pos: u64,
}
impl<R: Read + Seek> VhdReader<R> {
pub fn new(mut inner: R) -> Result<Self, VhdError> {
let file_size = inner.seek(SeekFrom::End(0))?;
if file_size < VHD_FOOTER_SIZE {
return Err(VhdError::InvalidCookie);
}
// Read footer (last 512 bytes)
inner.seek(SeekFrom::Start(file_size - VHD_FOOTER_SIZE))?;
let mut footer = [0u8; VHD_FOOTER_SIZE as usize];
inner.read_exact(&mut footer)?;
if &footer[0..8] != VHD_COOKIE {
return Err(VhdError::InvalidCookie);
}
let disk_type =
u32::from_be_bytes([footer[0x3C], footer[0x3D], footer[0x3E], footer[0x3F]]);
let (layout, virtual_disk_size) = match disk_type {
VHD_TYPE_FIXED => {
let vds = file_size - VHD_FOOTER_SIZE;
(VhdLayout::Fixed, vds)
}
VHD_TYPE_DYNAMIC => {
let (layout, vds) = Self::parse_dynamic(&mut inner, &footer)?;
(layout, vds)
}
other => return Err(VhdError::UnsupportedType(other)),
};
// Detect NTFS partition offset within the virtual disk
let ntfs_offset =
Self::detect_ntfs_offset_virtual(&mut inner, &layout, virtual_disk_size)?;
Ok(Self {
inner,
layout,
ntfs_offset,
virtual_disk_size,
pos: 0,
})
}
fn parse_dynamic(inner: &mut R, footer: &[u8]) -> Result<(VhdLayout, u64), VhdError> {
// data_offset in footer (big-endian u64 at 0x10) points to dynamic header
let data_offset = u64::from_be_bytes([
footer[0x10],
footer[0x11],
footer[0x12],
footer[0x13],
footer[0x14],
footer[0x15],
footer[0x16],
footer[0x17],
]);
// Read dynamic disk header (1024 bytes)
inner.seek(SeekFrom::Start(data_offset))?;
let mut hdr = [0u8; 1024];
inner.read_exact(&mut hdr)?;
if &hdr[0..8] != DYNAMIC_HEADER_COOKIE {
return Err(VhdError::InvalidDynamicHeader);
}
// BAT offset (big-endian u64 at 0x10 in header)
let bat_offset = u64::from_be_bytes([
hdr[0x10], hdr[0x11], hdr[0x12], hdr[0x13], hdr[0x14], hdr[0x15], hdr[0x16],
hdr[0x17],
]);
// Max table entries (big-endian u32 at 0x18)
let max_entries =
u32::from_be_bytes([hdr[0x18], hdr[0x19], hdr[0x1A], hdr[0x1B]]) as usize;
// Block size (big-endian u32 at 0x20)
let block_size = u32::from_be_bytes([hdr[0x20], hdr[0x21], hdr[0x22], hdr[0x23]]) as u64;
// Read BAT
inner.seek(SeekFrom::Start(bat_offset))?;
let mut bat_bytes = vec![0u8; max_entries * 4];
inner.read_exact(&mut bat_bytes)?;
let bat: Vec<u32> = (0..max_entries)
.map(|i| {
u32::from_be_bytes([
bat_bytes[i * 4],
bat_bytes[i * 4 + 1],
bat_bytes[i * 4 + 2],
bat_bytes[i * 4 + 3],
])
})
.collect();
let virtual_disk_size = max_entries as u64 * block_size;
Ok((VhdLayout::Dynamic { bat, block_size }, virtual_disk_size))
}
/// Read from a virtual disk offset, handling both fixed and dynamic layouts.
fn read_virtual(&mut self, virtual_offset: u64, buf: &mut [u8]) -> io::Result<usize> {
if virtual_offset >= self.virtual_disk_size {
return Ok(0);
}
let remaining = self.virtual_disk_size - virtual_offset;
let to_read = std::cmp::min(buf.len() as u64, remaining) as usize;
match &self.layout {
VhdLayout::Fixed => {
self.inner.seek(SeekFrom::Start(virtual_offset))?;
self.inner.read(&mut buf[..to_read])
}
VhdLayout::Dynamic { bat, block_size } => {
let block_index = (virtual_offset / block_size) as usize;
let offset_in_block = virtual_offset % block_size;
let max_in_block = (*block_size - offset_in_block) as usize;
let to_read = std::cmp::min(to_read, max_in_block);
if block_index >= bat.len() || bat[block_index] == BAT_ENTRY_UNUSED {
// Unallocated block: return zeros
buf[..to_read].fill(0);
Ok(to_read)
} else {
// Block starts at sector bat[block_index], skip bitmap sector(s)
let block_file_offset = bat[block_index] as u64 * 512
+ BITMAP_SECTORS * 512
+ offset_in_block;
self.inner.seek(SeekFrom::Start(block_file_offset))?;
self.inner.read(&mut buf[..to_read])
}
}
}
}
/// Detect NTFS partition offset by reading virtual disk data.
fn detect_ntfs_offset_virtual(
inner: &mut R,
layout: &VhdLayout,
virtual_disk_size: u64,
) -> Result<u64, VhdError> {
// Helper to read 4 bytes from a virtual offset
let read_magic = |inner: &mut R, layout: &VhdLayout, offset: u64| -> io::Result<[u8; 4]> {
let mut magic = [0u8; 4];
match layout {
VhdLayout::Fixed => {
inner.seek(SeekFrom::Start(offset))?;
inner.read_exact(&mut magic)?;
}
VhdLayout::Dynamic { bat, block_size } => {
let bi = (offset / block_size) as usize;
let bo = offset % block_size;
if bi < bat.len() && bat[bi] != BAT_ENTRY_UNUSED {
let file_off = bat[bi] as u64 * 512 + BITMAP_SECTORS * 512 + bo;
inner.seek(SeekFrom::Start(file_off))?;
inner.read_exact(&mut magic)?;
}
}
}
Ok(magic)
};
// Stage 1: Try MBR
if virtual_disk_size >= 512 {
let mut mbr = [0u8; 512];
match layout {
VhdLayout::Fixed => {
inner.seek(SeekFrom::Start(0))?;
inner.read_exact(&mut mbr)?;
}
VhdLayout::Dynamic { bat, block_size: _ } => {
if !bat.is_empty() && bat[0] != BAT_ENTRY_UNUSED {
let file_off = bat[0] as u64 * 512 + BITMAP_SECTORS * 512;
inner.seek(SeekFrom::Start(file_off))?;
inner.read_exact(&mut mbr)?;
}
}
}
if mbr[510..512] == MBR_SIGNATURE {
for i in 0..4 {
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],
mbr[eo + 9],
mbr[eo + 10],
mbr[eo + 11],
]);
let offset = lba as u64 * 512;
if offset + 4 <= virtual_disk_size {
if read_magic(inner, layout, offset)? == NTFS_MAGIC {
return Ok(offset);
}
}
}
}
}
}
// Stage 2: Probe known offsets
for &offset in NTFS_PROBE_OFFSETS {
if offset + 4 > virtual_disk_size {
continue;
}
if read_magic(inner, layout, offset)? == NTFS_MAGIC {
return Ok(offset);
}
}
Err(VhdError::NoNtfsPartition)
}
pub fn ntfs_size(&self) -> u64 {
self.virtual_disk_size - self.ntfs_offset
}
}
impl<R: Read + Seek> Read for VhdReader<R> {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
let remaining = self.ntfs_size() - self.pos;
if remaining == 0 {
return Ok(0);
}
let virtual_offset = self.ntfs_offset + self.pos;
let max_read = std::cmp::min(buf.len() as u64, remaining) as usize;
let n = self.read_virtual(virtual_offset, &mut buf[..max_read])?;
self.pos += n as u64;
Ok(n)
}
}
impl<R: Read + Seek> Seek for VhdReader<R> {
fn seek(&mut self, pos: SeekFrom) -> io::Result<u64> {
let new_pos = match pos {
SeekFrom::Start(offset) => offset,
SeekFrom::Current(offset) => {
let target = self.pos as i64 + offset;
if target < 0 {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"cannot seek before start",
));
}
target as u64
}
SeekFrom::End(offset) => {
let target = self.ntfs_size() as i64 + offset;
if target < 0 {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"cannot seek before start",
));
}
target as u64
}
};
self.pos = new_pos;
Ok(new_pos)
}
}
// ---------------------------------------------------------------------------
// NTFS extraction from VHD
// ---------------------------------------------------------------------------
fn ntfs_time_to_system_time(ntfs_time: NtfsTime) -> SystemTime {
let intervals_since_windows_epoch = ntfs_time.nt_timestamp();
let intervals_since_unix_epoch = intervals_since_windows_epoch - 116_444_736_000_000_000;
let nanos_since_unix_epoch = intervals_since_unix_epoch * 100;
SystemTime::UNIX_EPOCH + Duration::from_nanos(nanos_since_unix_epoch)
}
fn set_ntfs_timestamps<T: Read + Seek>(
fs: &mut T,
file: &ntfs::NtfsFile,
path: &Path,
) -> Result<()> {
let mut attrs = file.attributes();
while let Some(attr) = attrs.next(fs) {
let attr = attr?;
let attr = attr.to_attribute()?;
if let Ok(NtfsAttributeType::StandardInformation) = attr.ty() {
let info = attr.resident_structured_value::<NtfsStandardInformation>()?;
let handle = OpenOptions::new().write(true).open(path)?;
handle.set_times(
FileTimes::new()
.set_accessed(ntfs_time_to_system_time(info.access_time()))
.set_modified(ntfs_time_to_system_time(info.modification_time())),
)?;
break;
}
}
Ok(())
}
fn extract_ntfs_dir<T: Read + Seek>(
ntfs: &Ntfs,
fs: &mut T,
dir: &ntfs::NtfsFile,
output_dir: &Path,
pb: &ProgressBar,
) -> Result<()> {
let index = dir.directory_index(fs)?;
let mut iter = index.entries();
while let Some(entry) = iter.next(fs) {
let entry = entry?;
let file_name = entry
.key()
.ok_or_else(|| anyhow!("missing index entry key"))?;
let file_name = file_name?;
let name = file_name.name().to_string_lossy();
if name.starts_with('$')
|| name == "."
|| name == ".."
|| name == "System Volume Information"
{
continue;
}
let file = entry.to_file(ntfs, fs)?;
let dest_path = output_dir.join(&*name);
if file_name.is_directory() {
create_dir_all(&dest_path)?;
extract_ntfs_dir(ntfs, fs, &file, &dest_path, pb)?;
set_ntfs_timestamps(fs, &file, &dest_path).ok();
} else if let Some(data_item) = file.data(fs, "") {
let data_item = data_item?;
let data_attribute = data_item.to_attribute()?;
let mut data_value =
BufReader::with_capacity(256 * 1024, data_attribute.value(fs)?.attach(fs));
let mut output_file = File::create(&dest_path)?;
loop {
let buffer = data_value.fill_buf()?;
let length = buffer.len();
if length == 0 {
break;
}
output_file.write_all(buffer)?;
data_value.consume(length);
pb.inc(length as u64);
}
output_file.flush()?;
drop(data_value);
set_ntfs_timestamps(fs, &file, &dest_path).ok();
}
}
Ok(())
}
fn calculate_ntfs_size<T: Read + Seek>(
ntfs: &Ntfs,
fs: &mut T,
dir: &ntfs::NtfsFile,
) -> Result<u64> {
let mut total: u64 = 0;
let index = dir.directory_index(fs)?;
let mut iter = index.entries();
while let Some(entry) = iter.next(fs) {
let entry = entry?;
let file_name = entry
.key()
.ok_or_else(|| anyhow!("missing index entry key"))?;
let file_name = file_name?;
let name = file_name.name().to_string_lossy();
if name.starts_with('$')
|| name == "."
|| name == ".."
|| name == "System Volume Information"
{
continue;
}
let file = entry.to_file(ntfs, fs)?;
if file_name.is_directory() {
total += calculate_ntfs_size(ntfs, fs, &file)?;
} else if let Some(data_item) = file.data(fs, "") {
let data_item = data_item?;
let attr = data_item.to_attribute()?;
total += attr.value_length();
}
}
Ok(total)
}
/// Extract all files from a VHD's NTFS filesystem into a folder, then delete the VHD.
pub fn extract_vhd(vhd_path: &Path) -> Result<()> {
let output_dir = vhd_path.with_extension("");
println!("Extracting VHD contents...");
println!(" VHD: {}", vhd_path.display());
println!(" Output: {}", output_dir.display());
let file = File::open(vhd_path)?;
let mut vhd = VhdReader::new(file).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_size = calculate_ntfs_size(&ntfs, &mut vhd, &root)?;
let pb = ProgressBar::new(total_size).with_style(
ProgressStyle::default_bar().template(
"{prefix} [{bar:20!.bright.yellow/dim.white}] {bytes:>8} [{elapsed}<{eta}, {bytes_per_sec}]",
)?,
);
pb.set_prefix(format!(
"Extracting {}",
vhd_path.file_name().unwrap_or_default().to_string_lossy()
));
create_dir_all(&output_dir)?;
let root = ntfs.root_directory(&mut vhd)?;
extract_ntfs_dir(&ntfs, &mut vhd, &root, &output_dir, &pb)?;
pb.finish();
println!("Extracted to: {}", output_dir.display());
drop(vhd);
if let Err(e) = std::fs::remove_file(vhd_path) {
println!("WARNING: Could not delete VHD: {e}");
}
Ok(())
}