feat: extract VHD contents to folder and delete VHD

After decryption and merging, VHDs are now mounted via Hyper-V,
their contents copied to a same-named folder (excluding system
dirs), then dismounted and deleted. Handles partitions without
drive letters by assigning one temporarily. Uses a temp .ps1
script to avoid nested PowerShell quoting issues.

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 6b06bc4e92
commit 96bb605a17
+116 -2
View File
@@ -234,6 +234,98 @@ 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:
@@ -392,7 +484,7 @@ fn main() -> Result<()> {
}
}
// Auto-merge delta VHDs with their base (Windows only)
// Post-extraction: merge deltas and extract VHD contents (Windows only)
#[cfg(windows)]
if !cli.no_extract && !extracted_vhds.is_empty() {
// Group by game_id
@@ -401,11 +493,18 @@ fn main() -> Result<()> {
by_game.entry(vhd.game_id.clone()).or_default().push(vhd);
}
// Track which VHDs to extract contents from at the end
let mut vhds_to_extract: Vec<PathBuf> = Vec::new();
for (game_id, vhds) in &by_game {
let base = vhds.iter().find(|v| v.sequence_number == 0);
let deltas: Vec<_> = vhds.iter().filter(|v| v.sequence_number > 0).collect();
if deltas.is_empty() {
// Standalone base VHD, just extract its contents
if let Some(base) = base {
vhds_to_extract.push(base.vhd_path.clone());
}
continue;
}
@@ -454,7 +553,6 @@ fn main() -> Result<()> {
"Found base APP in same directory, extracting: {}",
app_path.display()
);
// Read the bootid to get the sequence number
let base_file = FscryptDecryptor::new(File::open(&app_path)?)
.map_err(|e| anyhow!(e))?;
let base_seq = base_file.bootid.sequence_number;
@@ -492,6 +590,7 @@ fn main() -> Result<()> {
}
};
let mut merge_ok = true;
for delta in deltas {
if let Err(e) = merge_vhd(&base.vhd_path, &delta.vhd_path) {
println!(
@@ -499,6 +598,21 @@ fn main() -> Result<()> {
delta.vhd_path.display(),
base.vhd_path.display()
);
merge_ok = false;
}
}
// Extract the final merged VHD
if merge_ok {
vhds_to_extract.push(base.vhd_path.clone());
}
}
// 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) {
println!("WARNING: Failed to extract VHD contents: {e:#?}");
}
}
}