From 96bb605a1701d035a545418fc063d56d88c3beba Mon Sep 17 00:00:00 2001 From: Jujuforce Date: Sun, 22 Mar 2026 15:31:09 +0100 Subject: [PATCH] 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) --- src/main.rs | 118 +++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 116 insertions(+), 2 deletions(-) diff --git a/src/main.rs b/src/main.rs index efbabea..a85fb4f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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 = 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:#?}"); } } }