From 53c389ba05798b4b663d8c0515ad820332c1495e Mon Sep 17 00:00:00 2001 From: jujuforce Date: Wed, 16 Jul 2025 15:12:32 +0200 Subject: [PATCH 01/17] feat: exfat content extraction --- Cargo.lock | 241 +++++++++++++++++++++++++++++++++++++++++++++++++++- Cargo.toml | 1 + src/main.rs | 54 +++++++++++- 3 files changed, 293 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4824116..6942941 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "aes" @@ -19,6 +19,21 @@ version = "1.0.86" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da" +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bitflags" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" +dependencies = [ + "bytemuck", +] + [[package]] name = "block-padding" version = "0.3.3" @@ -28,6 +43,26 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bytemuck" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c76a5792e44e4abe34d3abf15636779261d45a7450612059293d1d2cfc63422" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ecc273b49b3205b83d648f0690daa588925572cc5063745bfe547fe7ec8e1a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "cbc" version = "0.1.2" @@ -43,6 +78,15 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "checked_num" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "deb5a345c406bf67f62bd14f284c9cb041ce5107725c22e665a163c871a4fb7b" +dependencies = [ + "num-traits", +] + [[package]] name = "cipher" version = "0.4.4" @@ -94,12 +138,130 @@ dependencies = [ "typenum", ] +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn", +] + [[package]] name = "encode_unicode" version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f" +[[package]] +name = "endify" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "412c0c5048548a61a2ed94233a7305d2dfa4d1d3acc69039da77efa84e51df21" +dependencies = [ + "endify-derive", +] + +[[package]] +name = "endify-derive" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a204d4aa704d0a74ee3bebccc65f24bca0cdef2588f08eaafda467cd4d3c8a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "enumeric" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f2f9bd649bd8c884aa518a798861fd111699dfc7d7dcc193bc2b658c8b9188d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "exfat-fs" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cbdfa8ecf36ab2341032974d3644a56f1e40c9da3b643118492cf97f357f3c5" +dependencies = [ + "bitflags", + "bytemuck", + "checked_num", + "derive_builder", + "endify", + "enumeric", + "thiserror", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "fsdecrypt" version = "0.1.1" @@ -108,6 +270,7 @@ dependencies = [ "anyhow", "cbc", "crc32fast", + "exfat-fs", "hex-literal", "indicatif", ] @@ -128,6 +291,12 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6fe2267d4ed49bc07b63801559be28c718ea06c4738b7a03c94df7386d2cde46" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "indicatif" version = "0.17.8" @@ -172,6 +341,15 @@ version = "0.2.155" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "number_prefix" version = "0.4.0" @@ -184,12 +362,73 @@ version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da544ee218f0d287a911e9c99a39a8c9bc8fcad3cb8db5959940044ecfc67265" +[[package]] +name = "proc-macro2" +version = "1.0.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "typenum" version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + [[package]] name = "unicode-width" version = "0.1.13" diff --git a/Cargo.toml b/Cargo.toml index fd5fd16..f8966b5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,3 +10,4 @@ cbc = "0.1.2" crc32fast = "1.4.2" hex-literal = "0.4.1" indicatif = "0.17.8" +exfat-fs = "0.1.3" diff --git a/src/main.rs b/src/main.rs index 09bf4ff..4becbe1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,9 +1,11 @@ use std::{ - fs::File, + fs::{File, create_dir_all}, io::{BufReader, BufWriter, Read, Seek, SeekFrom, Write}, path::Path, }; +use exfat_fs::dir::{Root, entry::fs::FsElement}; + use aes::{ cipher::{block_padding::NoPadding, BlockDecryptMut, InnerIvInit, KeyInit, KeyIvInit}, Aes128Dec, @@ -21,6 +23,44 @@ mod crypto; const PAGE_SIZE: u64 = 4096; +fn extract_exfat_contents(exfat_path: &Path) -> Result<()> { + println!("Extracting contents of {}", exfat_path.display()); + + let file = File::open(exfat_path)?; + let mut root = Root::open(file)?; + + // Create output directory with same name as exfat file (without extension) + let output_dir = exfat_path.with_extension(""); + create_dir_all(&output_dir)?; + + extract_fs_elements(root.items(), &output_dir)?; + Ok(()) +} + +fn extract_fs_elements(elements: &mut [FsElement], output_dir: &Path) -> Result<()> { + let item_count = elements.len(); + + for i in 0..item_count { + if let Some(FsElement::F(ref mut file_ref)) = elements.get_mut(i) { + // Extract file + let filename = file_ref.name().to_owned(); + let file_path = output_dir.join(&filename); + let mut buffer = Vec::new(); + file_ref.read_to_end(&mut buffer)?; + std::fs::write(file_path, buffer)?; + } else if let Some(FsElement::D(ref dir_ref)) = elements.get(i) { + // Handle directory + let dirname = dir_ref.name(); + let dir_path = output_dir.join(dirname); + create_dir_all(&dir_path)?; + let mut children = dir_ref.open()?; + // Extract children recursively + extract_fs_elements(&mut children, &dir_path)?; + } + } + Ok(()) +} + fn main() -> Result<()> { let args = std::env::args().collect::>(); @@ -160,7 +200,7 @@ fn main() -> Result<()> { .template("{prefix} [{bar:20!.bright.yellow/dim.white}] {bytes:>8} [{elapsed}<{eta}, {bytes_per_sec}]")? ); - pb.set_prefix(output_filename); + pb.set_prefix(output_filename.clone()); reader.seek(SeekFrom::Start(data_offset))?; for _ in 0..output_size / PAGE_SIZE { @@ -184,6 +224,16 @@ fn main() -> Result<()> { writer.flush()?; pb.finish(); + // Extract exfat contents if this is an exfat file + if bootid.container_type == ContainerType::OPTION && output_path.extension().unwrap_or_default() == "exfat" { + if let Err(e) = extract_exfat_contents(&output_path) { + println!("WARNING: Failed to extract exfat contents: {e}"); + } + println!("Extracted exfat contents: {}", output_path.display()); + println!("Deleting exfat file: {}", output_path.display()); + std::fs::remove_file(output_path)?; + } + page.clear(); page_iv.fill(0); bootid_bytes.fill(0); From 6cdf6a8e52c992da171483f82da753a3e7ac416c Mon Sep 17 00:00:00 2001 From: beerpsi Date: Wed, 16 Jul 2025 15:47:53 +0000 Subject: [PATCH 02/17] use more idiomatic rust --- src/main.rs | 63 ++++++++++++++++++++++++++--------------------------- 1 file changed, 31 insertions(+), 32 deletions(-) diff --git a/src/main.rs b/src/main.rs index 4becbe1..62381a9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,10 +1,10 @@ use std::{ - fs::{File, create_dir_all}, + fs::{create_dir_all, File}, io::{BufReader, BufWriter, Read, Seek, SeekFrom, Write}, path::Path, }; -use exfat_fs::dir::{Root, entry::fs::FsElement}; +use exfat_fs::dir::{entry::fs::FsElement, Root}; use aes::{ cipher::{block_padding::NoPadding, BlockDecryptMut, InnerIvInit, KeyInit, KeyIvInit}, @@ -25,39 +25,38 @@ const PAGE_SIZE: u64 = 4096; fn extract_exfat_contents(exfat_path: &Path) -> Result<()> { println!("Extracting contents of {}", exfat_path.display()); - + let file = File::open(exfat_path)?; let mut root = Root::open(file)?; - + // Create output directory with same name as exfat file (without extension) let output_dir = exfat_path.with_extension(""); + create_dir_all(&output_dir)?; - extract_fs_elements(root.items(), &output_dir)?; + Ok(()) } fn extract_fs_elements(elements: &mut [FsElement], output_dir: &Path) -> Result<()> { - let item_count = elements.len(); + for element in elements { + match element { + FsElement::F(ref mut file) => { + let dest_path = output_dir.join(file.name()); + let mut dest = File::create(dest_path)?; - for i in 0..item_count { - if let Some(FsElement::F(ref mut file_ref)) = elements.get_mut(i) { - // Extract file - let filename = file_ref.name().to_owned(); - let file_path = output_dir.join(&filename); - let mut buffer = Vec::new(); - file_ref.read_to_end(&mut buffer)?; - std::fs::write(file_path, buffer)?; - } else if let Some(FsElement::D(ref dir_ref)) = elements.get(i) { - // Handle directory - let dirname = dir_ref.name(); - let dir_path = output_dir.join(dirname); - create_dir_all(&dir_path)?; - let mut children = dir_ref.open()?; - // Extract children recursively - extract_fs_elements(&mut children, &dir_path)?; + std::io::copy(file, &mut dest)?; + } + FsElement::D(directory) => { + let dest_path = output_dir.join(directory.name()); + create_dir_all(&dest_path)?; + + let mut children = directory.open()?; + extract_fs_elements(&mut children, &dest_path)?; + } } } + Ok(()) } @@ -123,11 +122,7 @@ fn main() -> Result<()> { let data_offset = bootid.header_block_count * bootid.block_size; let key = keys.key; - let iv = if bootid.use_custom_iv { - None - } else { - keys.iv - }; + let iv = if bootid.use_custom_iv { None } else { keys.iv }; let iv = match iv { Some(iv) => iv, None => { @@ -225,13 +220,17 @@ fn main() -> Result<()> { pb.finish(); // Extract exfat contents if this is an exfat file - if bootid.container_type == ContainerType::OPTION && output_path.extension().unwrap_or_default() == "exfat" { + if bootid.container_type == ContainerType::OPTION + && output_path.extension().unwrap_or_default() == "exfat" + { if let Err(e) = extract_exfat_contents(&output_path) { - println!("WARNING: Failed to extract exfat contents: {e}"); + println!("WARNING: Failed to extract exfat contents: {e:#?}"); + } else { + println!("Extracted exfat contents: {:?}", output_path); + println!("Deleting exfat file: {:?}", output_path); + + std::fs::remove_file(output_path)?; } - println!("Extracted exfat contents: {}", output_path.display()); - println!("Deleting exfat file: {}", output_path.display()); - std::fs::remove_file(output_path)?; } page.clear(); From d4c4ba8d8d6f189ee6613eabaa4ea390838443bf Mon Sep 17 00:00:00 2001 From: Jujuforce Date: Sun, 22 Mar 2026 13:53:52 +0100 Subject: [PATCH 03/17] docs: add README Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 103 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..53b7217 --- /dev/null +++ b/README.md @@ -0,0 +1,103 @@ +# fsdecrypt + +Decryptor and extractor for SEGA arcade filesystem containers (fscrypt format). + +Handles AES-128-CBC encrypted container images used on SEGA Nu/ALLS arcade hardware, automatically parsing the embedded BootID header to identify the game and container type, then decrypting and extracting the contents. + +## Features + +- Decrypts OS, APP, and OPTION (DLC) containers +- Extracts NTFS-based containers (OS/APP) including internal VHD images +- Extracts ExFAT-based containers (OPTION/DLC packs) +- Preserves file timestamps during extraction +- Built-in key database for 70+ game titles +- Supports external key files for unlisted games +- Progress bars with transfer speed and ETA + +## Installation + +Requires [Rust](https://www.rust-lang.org/tools/install) 1.56+ (edition 2021). + +```bash +cargo build --release +``` + +The binary will be at `target/release/fsdecrypt` (or `fsdecrypt.exe` on Windows). + +## Usage + +``` +fsdecrypt [OPTIONS] ... +``` + +### Arguments + +- `...` - One or more encrypted container files (`.app`, `.opt`, etc.) + +### Options + +- `--no-extract` - Decrypt the container to a raw image file without extracting its contents +- `-h, --help` - Print help +- `-V, --version` - Print version + +### Examples + +```bash +# Decrypt and extract an APP container +fsdecrypt SDXX_1.00.00_20240101120000_0.app + +# Decrypt and extract an OPTION container +fsdecrypt SDXX_A001_20240101120000_0.opt + +# Process multiple containers at once +fsdecrypt game_v1.app game_v2.app extras.opt + +# Decrypt only, skip extraction +fsdecrypt --no-extract SDXX_1.00.00_20240101120000_0.app +``` + +### Output + +By default, the tool extracts container contents directly: + +| Type | Extracted contents | +|--------|--------------------| +| OS | `internal_{seq}.vhd` | +| APP | `internal_{seq}.vhd` | +| OPTION | Directory with all DLC files | + +With `--no-extract`, a raw decrypted image is written instead: + +| Type | Output filename | +|--------|-----------------| +| OS | `{os_id}_{version}_{timestamp}_{seq}.ntfs` | +| APP | `{game_id}_{version}_{timestamp}_{seq}.ntfs` | +| OPTION | `{game_id}_{option}_{timestamp}_{seq}.exfat` | + +## External Key Files + +For games not in the built-in database, place a key file named `{GAME_ID}.bin` in the working directory: + +- **16 bytes**: AES-128 key only (IV will be derived automatically) +- **32 bytes**: AES-128 key (first 16 bytes) + IV (last 16 bytes) + +## How It Works + +1. The first 128 bytes of the container are decrypted using a hardcoded master key to obtain the **BootID** header +2. The BootID contains metadata: game ID, container type, block layout, and an IV mode flag +3. The game-specific AES-128 key is looked up from the built-in database (or read from an external `.bin` file) +4. If no IV is hardcoded or the container uses a custom IV, the IV is derived by trial-decrypting the first data page against the expected filesystem header (NTFS or ExFAT magic bytes) +5. Each 4096-byte page is decrypted independently using AES-128-CBC, with a per-page IV computed by XORing the file IV with the page's file offset +6. The decrypted stream is parsed as NTFS or ExFAT depending on container type, and contents are extracted + +## Container Types + +| Type | ID | Filesystem | Description | +|------|----|-----------|-------------| +| OS | `0x00` | NTFS | Operating system image | +| APP | `0x01` | NTFS | Game application and assets | +| OPTION | `0x02` | ExFAT | Downloadable content / option packs | + +## License + +[BSD Zero Clause License](LICENSE) (0BSD) From 4461e5169e8bd71af7c2707487353d27087aff5f Mon Sep 17 00:00:00 2001 From: Jujuforce Date: Sun, 22 Mar 2026 16:36:50 +0100 Subject: [PATCH 04/17] chore: add .gitignore Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 65a7303..0ab9dbf 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ *.ntfs *.exfat flamegraph.svg +CLAUDE.md From c896f362bab9193da3c112f83df562d1dc27fd8d Mon Sep 17 00:00:00 2001 From: Jujuforce Date: Sun, 22 Mar 2026 15:17:36 +0100 Subject: [PATCH 05/17] feat: auto-merge delta VHD updates for APP containers When multiple APP files are provided (base seq=0 + delta seq>0), fsdecrypt now automatically links and merges the differencing VHD into the parent using Set-VHD + Merge-VHD (Windows/Hyper-V only). Triggers a single UAC prompt for elevation. Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 26 ++++++++-- src/main.rs | 146 +++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 167 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 53b7217..a9a90af 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ Handles AES-128-CBC encrypted container images used on SEGA Nu/ALLS arcade hardw - Decrypts OS, APP, and OPTION (DLC) containers - Extracts NTFS-based containers (OS/APP) including internal VHD images - Extracts ExFAT-based containers (OPTION/DLC packs) +- **Auto-merges delta update VHDs** with their base on Windows (Hyper-V) - Preserves file timestamps during extraction - Built-in key database for 70+ game titles - Supports external key files for unlisted games @@ -44,18 +45,35 @@ fsdecrypt [OPTIONS] ... ```bash # Decrypt and extract an APP container -fsdecrypt SDXX_1.00.00_20240101120000_0.app +fsdecrypt ABCD_1.00.00_20240101120000_0.app # Decrypt and extract an OPTION container -fsdecrypt SDXX_A001_20240101120000_0.opt +fsdecrypt ABCD_A001_20240101120000_0.opt # Process multiple containers at once fsdecrypt game_v1.app game_v2.app extras.opt # Decrypt only, skip extraction -fsdecrypt --no-extract SDXX_1.00.00_20240101120000_0.app +fsdecrypt --no-extract ABCD_1.00.00_20240101120000_0.app ``` +### Delta Updates + +When a game ships incremental updates, you get a base `.app` (seq=0) and one or more delta `.app` files (seq>0). Pass them all together and fsdecrypt handles the rest: + +```bash +# Base + delta update: fsdecrypt extracts both VHDs, then auto-merges +fsdecrypt ABCD_1.00.00_20240101120000_0.app ABCD_1.01.00_20240215143000_1_1.00.00.app +``` + +The merge workflow (Windows only, requires Hyper-V): +1. Both containers are decrypted and their internal VHDs extracted +2. `Set-VHD` links the delta (differencing) VHD to its parent +3. `Merge-VHD` merges the delta into the base VHD +4. A UAC prompt will appear since these cmdlets require elevation + +If you only provide the delta without its base, fsdecrypt will extract the VHD and print a warning with instructions. + ### Output By default, the tool extracts container contents directly: @@ -63,7 +81,7 @@ By default, the tool extracts container contents directly: | Type | Extracted contents | |--------|--------------------| | OS | `internal_{seq}.vhd` | -| APP | `internal_{seq}.vhd` | +| APP | `internal_{seq}.vhd` (auto-merged if delta + base provided) | | OPTION | Directory with all DLC files | With `--no-extract`, a raw decrypted image is written instead: diff --git a/src/main.rs b/src/main.rs index fded235..9c3e00e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,5 @@ use std::{ + collections::HashMap, fs::{create_dir_all, File, FileTimes}, io::{BufRead, BufReader, BufWriter, Write}, path::{Path, PathBuf}, @@ -23,6 +24,19 @@ mod bootid; mod crypto; mod stream; +/// Info collected from each input file for sorting and post-extraction merge. +struct InputFile { + path: PathBuf, + sequence_number: u8, +} + +/// Info about an extracted VHD, used for delta merge. +struct ExtractedVhd { + vhd_path: PathBuf, + sequence_number: u8, + game_id: String, +} + fn exfat_timestamp_to_system_time( timestamp: &exfat_fs::timestamp::Timestamp, ) -> Result { @@ -208,6 +222,74 @@ fn extract_internal_vhd(image_path: &Path, sequence_number: u8) -> Result PathBuf { + let s = path.to_string_lossy(); + if let Some(stripped) = s.strip_prefix(r"\\?\") { + PathBuf::from(stripped) + } else { + path + } +} + +/// Merge a differencing VHD into its parent using Hyper-V PowerShell cmdlets. +/// +/// Steps: +/// 1. `Set-VHD` links the delta VHD to its parent +/// 2. `Merge-VHD` merges the delta's changes into the parent (modifies parent in-place) +/// +/// Requires elevation (triggers UAC prompt). +#[cfg(windows)] +fn merge_vhd(base_vhd: &Path, delta_vhd: &Path) -> Result<()> { + let base_abs = strip_extended_path_prefix(std::fs::canonicalize(base_vhd)?); + let delta_abs = strip_extended_path_prefix(std::fs::canonicalize(delta_vhd)?); + + println!("Merging VHDs..."); + println!(" Base (parent): {}", base_abs.display()); + println!(" Delta (child): {}", delta_abs.display()); + + // Set-VHD links the differencing disk to its parent, then + // Merge-VHD (without -DestinationPath) merges the child into its immediate parent. + 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_extended_path_prefix(std::path::absolute(&error_log)?); + + // Wrap in try/catch to capture errors from the elevated process + 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 delta into base VHD: {}", base_abs.display()); + } else { + println!("WARNING: PowerShell exited with: {status}. Check UAC was accepted."); + } + + Ok(()) +} + #[derive(Parser)] #[command(version, about = "decryptor for some SEGA containers", long_about = None)] struct Cli { @@ -221,7 +303,24 @@ struct Cli { fn main() -> Result<()> { let cli = Cli::parse(); + // Pre-read bootids for all files to sort by sequence number (base first) + let mut inputs: Vec = Vec::new(); for path in &cli.files { + let file = FscryptDecryptor::new(File::open(path)?).map_err(|e| anyhow!(e))?; + inputs.push(InputFile { + path: path.clone(), + sequence_number: file.bootid.sequence_number, + }); + } + + // Sort so seq=0 (base) is processed before seq>0 (deltas) + inputs.sort_by_key(|f| f.sequence_number); + + // Track extracted VHDs for post-extraction merge + let mut extracted_vhds: Vec = Vec::new(); + + for input in &inputs { + let path = &input.path; let file = FscryptDecryptor::new(File::open(path)?).map_err(|e| anyhow!(e))?; let bootid = file.bootid.clone(); let output_filename = file.filename()?; @@ -263,7 +362,15 @@ fn main() -> Result<()> { match bootid.container_type { ContainerType::OS | ContainerType::APP => { match extract_internal_vhd(&path, bootid.sequence_number) { - Ok(_) => {} + Ok(vhd_path) => { + let game_id = + std::str::from_utf8(&bootid.game_id)?.trim_end().to_string(); + extracted_vhds.push(ExtractedVhd { + vhd_path, + sequence_number: bootid.sequence_number, + game_id, + }); + } Err(e) => { println!("WARNING: Failed to extract internal VHD: {e:#?}"); } @@ -282,5 +389,42 @@ fn main() -> Result<()> { } } + // Auto-merge delta VHDs with their base (Windows only) + #[cfg(windows)] + if !cli.no_extract && !extracted_vhds.is_empty() { + // Group by game_id + let mut by_game: HashMap> = HashMap::new(); + for vhd in &extracted_vhds { + by_game.entry(vhd.game_id.clone()).or_default().push(vhd); + } + + 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() { + continue; + } + + let Some(base) = base else { + println!( + "WARNING: Delta VHD(s) found for {game_id} but no base (seq=0) VHD was extracted." + ); + println!(" Provide the base .app file to enable automatic merging."); + continue; + }; + + for delta in deltas { + if let Err(e) = merge_vhd(&base.vhd_path, &delta.vhd_path) { + println!( + "WARNING: Failed to merge {} with {}: {e:#?}", + delta.vhd_path.display(), + base.vhd_path.display() + ); + } + } + } + } + Ok(()) } From 371abf4e32c34d1a473ad4ffeb205807ff502d30 Mon Sep 17 00:00:00 2001 From: Jujuforce Date: Sun, 22 Mar 2026 15:20:15 +0100 Subject: [PATCH 06/17] feat: search for existing base VHD in same directory When only a delta .app is provided without the base, fsdecrypt now scans the same directory for an existing base VHD matching the game ID pattern ({game_id}_*_0.vhd) before giving up. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/main.rs | 48 ++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 42 insertions(+), 6 deletions(-) diff --git a/src/main.rs b/src/main.rs index 9c3e00e..4b52c68 100644 --- a/src/main.rs +++ b/src/main.rs @@ -406,12 +406,48 @@ fn main() -> Result<()> { continue; } - let Some(base) = base else { - println!( - "WARNING: Delta VHD(s) found for {game_id} but no base (seq=0) VHD was extracted." - ); - println!(" Provide the base .app file to enable automatic merging."); - continue; + // If no base was extracted in this run, search the same directory for an existing one + let found_base_path; + let base = match base { + Some(b) => b, + None => { + // Look for a VHD matching "{game_id}_*_0.vhd" in the same directory as the delta + let delta_dir = deltas[0] + .vhd_path + .parent() + .unwrap_or_else(|| Path::new(".")); + let pattern_prefix = format!("{game_id}_"); + let pattern_suffix = "_0.vhd"; + + found_base_path = std::fs::read_dir(delta_dir)? + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .find(|p| { + let name = p.file_name().unwrap_or_default().to_string_lossy(); + name.starts_with(&pattern_prefix) && name.ends_with(pattern_suffix) + }); + + match &found_base_path { + Some(path) => { + println!("Found existing base VHD: {}", path.display()); + // Create a temporary ExtractedVhd to reference + &ExtractedVhd { + vhd_path: path.clone(), + sequence_number: 0, + game_id: game_id.clone(), + } + } + None => { + println!( + "WARNING: Delta VHD(s) found for {game_id} but no base (seq=0) VHD found." + ); + println!( + " Provide the base .app file or place the base VHD in the same directory." + ); + continue; + } + } + } }; for delta in deltas { From 6b06bc4e92e0434cae24cfe745236831a4e72a94 Mon Sep 17 00:00:00 2001 From: Jujuforce Date: Sun, 22 Mar 2026 15:22:55 +0100 Subject: [PATCH 07/17] feat: auto-extract base .app when only delta is provided When no base VHD is found, fsdecrypt now also searches for the base .app file ({game_id}_*_0.app) in the same directory, extracts its VHD, and then merges the delta into it automatically. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/main.rs | 90 +++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 66 insertions(+), 24 deletions(-) diff --git a/src/main.rs b/src/main.rs index 4b52c68..efbabea 100644 --- a/src/main.rs +++ b/src/main.rs @@ -33,6 +33,8 @@ struct InputFile { /// Info about an extracted VHD, used for delta merge. struct ExtractedVhd { vhd_path: PathBuf, + /// Original input .app file path (for resolving sibling files) + input_path: PathBuf, sequence_number: u8, game_id: String, } @@ -367,6 +369,7 @@ fn main() -> Result<()> { std::str::from_utf8(&bootid.game_id)?.trim_end().to_string(); extracted_vhds.push(ExtractedVhd { vhd_path, + input_path: path.clone(), sequence_number: bootid.sequence_number, game_id, }); @@ -406,45 +409,84 @@ fn main() -> Result<()> { continue; } - // If no base was extracted in this run, search the same directory for an existing one - let found_base_path; + // If no base was extracted in this run, search the same directory + let found_base; let base = match base { Some(b) => b, None => { - // Look for a VHD matching "{game_id}_*_0.vhd" in the same directory as the delta let delta_dir = deltas[0] - .vhd_path + .input_path .parent() .unwrap_or_else(|| Path::new(".")); let pattern_prefix = format!("{game_id}_"); - let pattern_suffix = "_0.vhd"; - found_base_path = std::fs::read_dir(delta_dir)? + // First, look for an existing base VHD ({game_id}_*_0.vhd) + let existing_vhd = std::fs::read_dir(delta_dir)? .filter_map(|e| e.ok()) .map(|e| e.path()) .find(|p| { let name = p.file_name().unwrap_or_default().to_string_lossy(); - name.starts_with(&pattern_prefix) && name.ends_with(pattern_suffix) + name.starts_with(&pattern_prefix) && name.ends_with("_0.vhd") }); - match &found_base_path { - Some(path) => { - println!("Found existing base VHD: {}", path.display()); - // Create a temporary ExtractedVhd to reference - &ExtractedVhd { - vhd_path: path.clone(), - sequence_number: 0, - game_id: game_id.clone(), + if let Some(vhd_path) = existing_vhd { + println!("Found existing base VHD: {}", vhd_path.display()); + found_base = ExtractedVhd { + vhd_path, + input_path: PathBuf::new(), + sequence_number: 0, + game_id: game_id.clone(), + }; + &found_base + } else { + // Look for a base .app file ({game_id}_*_0.app) and extract it + let base_app = std::fs::read_dir(delta_dir)? + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .find(|p| { + let name = p.file_name().unwrap_or_default().to_string_lossy(); + name.starts_with(&pattern_prefix) && name.ends_with("_0.app") + }); + + match base_app { + Some(app_path) => { + println!( + "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; + drop(base_file); + + match extract_internal_vhd(&app_path, base_seq) { + Ok(vhd_path) => { + found_base = ExtractedVhd { + vhd_path, + input_path: app_path, + sequence_number: 0, + game_id: game_id.clone(), + }; + &found_base + } + Err(e) => { + println!( + "WARNING: Failed to extract base VHD: {e:#?}" + ); + continue; + } + } + } + None => { + println!( + "WARNING: Delta VHD(s) found for {game_id} but no base (seq=0) found." + ); + println!( + " Place the base .app or .vhd in the same directory." + ); + continue; } - } - None => { - println!( - "WARNING: Delta VHD(s) found for {game_id} but no base (seq=0) VHD found." - ); - println!( - " Provide the base .app file or place the base VHD in the same directory." - ); - continue; } } } From 96bb605a1701d035a545418fc063d56d88c3beba Mon Sep 17 00:00:00 2001 From: Jujuforce Date: Sun, 22 Mar 2026 15:31:09 +0100 Subject: [PATCH 08/17] 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:#?}"); } } } From f612c44f1900c07cab0cb7f72b8ecf7dd7a0a386 Mon Sep 17 00:00:00 2001 From: Jujuforce Date: Sun, 22 Mar 2026 15:44:49 +0100 Subject: [PATCH 09/17] 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 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) --- src/main.rs | 95 +--------- src/vhd.rs | 519 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 521 insertions(+), 93 deletions(-) create mode 100644 src/vhd.rs diff --git a/src/main.rs b/src/main.rs index a85fb4f..52f9df0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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:#?}"); } } diff --git a/src/vhd.rs b/src/vhd.rs new file mode 100644 index 0000000..704aed4 --- /dev/null +++ b/src/vhd.rs @@ -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, + block_size: u64, + }, +} + +/// A reader that transparently presents the NTFS partition within a VHD file. +pub struct VhdReader { + 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 VhdReader { + pub fn new(mut inner: R) -> Result { + 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 = (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 { + 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 { + // 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 Read for VhdReader { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + 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 Seek for VhdReader { + fn seek(&mut self, pos: SeekFrom) -> io::Result { + 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( + 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::()?; + 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( + 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( + ntfs: &Ntfs, + fs: &mut T, + dir: &ntfs::NtfsFile, +) -> Result { + 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(()) +} From 65b070adbd29b8fda9e8c98bd8862942ca61fe15 Mon Sep 17 00:00:00 2001 From: Jujuforce Date: Sun, 22 Mar 2026 15:57:05 +0100 Subject: [PATCH 10/17] refactor: clean up code, use constants, separate concerns - Move VHD parsing and NTFS extraction to src/vhd.rs - Replace all hardcoded magic values with named constants - Extract shared progress bar style to constant - Simplify main.rs merge/resolve logic with helper functions - Condense formatting without changing behavior Co-Authored-By: Claude Opus 4.6 (1M context) --- src/main.rs | 240 ++++++++++--------------- src/vhd.rs | 509 +++++++++++++++++++++------------------------------- 2 files changed, 302 insertions(+), 447 deletions(-) diff --git a/src/main.rs b/src/main.rs index 52f9df0..512daba 100644 --- a/src/main.rs +++ b/src/main.rs @@ -25,21 +25,6 @@ mod crypto; mod stream; mod vhd; -/// Info collected from each input file for sorting and post-extraction merge. -struct InputFile { - path: PathBuf, - sequence_number: u8, -} - -/// Info about an extracted VHD, used for delta merge. -struct ExtractedVhd { - vhd_path: PathBuf, - /// Original input .app file path (for resolving sibling files) - input_path: PathBuf, - sequence_number: u8, - game_id: String, -} - fn exfat_timestamp_to_system_time( timestamp: &exfat_fs::timestamp::Timestamp, ) -> Result { @@ -225,34 +210,44 @@ fn extract_internal_vhd(image_path: &Path, sequence_number: u8) -> Result PathBuf { +// --------------------------------------------------------------------------- +// Delta merge support +// --------------------------------------------------------------------------- + +/// Info about an extracted VHD, used for delta merge. +struct ExtractedVhd { + vhd_path: PathBuf, + input_path: PathBuf, + sequence_number: u8, + game_id: String, +} + +/// Strip the `\\?\` prefix that `canonicalize` adds on Windows. +fn strip_unc_prefix(path: PathBuf) -> PathBuf { let s = path.to_string_lossy(); - if let Some(stripped) = s.strip_prefix(r"\\?\") { - PathBuf::from(stripped) - } else { - path - } + 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()? + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .find(|p| { + let name = p.file_name().unwrap_or_default().to_string_lossy(); + name.starts_with(prefix) && name.ends_with(suffix) + }) } /// Merge a differencing VHD into its parent using Hyper-V PowerShell cmdlets. -/// -/// Steps: -/// 1. `Set-VHD` links the delta VHD to its parent -/// 2. `Merge-VHD` merges the delta's changes into the parent (modifies parent in-place) -/// /// Requires elevation (triggers UAC prompt). #[cfg(windows)] fn merge_vhd(base_vhd: &Path, delta_vhd: &Path) -> Result<()> { - let base_abs = strip_extended_path_prefix(std::fs::canonicalize(base_vhd)?); - let delta_abs = strip_extended_path_prefix(std::fs::canonicalize(delta_vhd)?); + 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..."); - println!(" Base (parent): {}", base_abs.display()); - println!(" Delta (child): {}", delta_abs.display()); + println!("Merging VHDs: {} <- {}", base_abs.display(), delta_abs.display()); - // Set-VHD links the differencing disk to its parent, then - // Merge-VHD (without -DestinationPath) merges the child into its immediate parent. let ps_commands = format!( "Set-VHD -Path '{}' -ParentPath '{}'; Merge-VHD -Path '{}' -Force", delta_abs.display(), @@ -261,9 +256,8 @@ fn merge_vhd(base_vhd: &Path, delta_vhd: &Path) -> Result<()> { ); let error_log = delta_vhd.with_extension("merge_error.txt"); - let error_log_abs = strip_extended_path_prefix(std::path::absolute(&error_log)?); + let error_log_abs = strip_unc_prefix(std::path::absolute(&error_log)?); - // Wrap in try/catch to capture errors from the elevated process let wrapped = format!( "try {{ {} }} catch {{ $_ | Out-File '{}' -Encoding UTF8; throw }}", ps_commands, @@ -285,14 +279,67 @@ fn merge_vhd(base_vhd: &Path, delta_vhd: &Path) -> Result<()> { std::fs::remove_file(&error_log).ok(); println!("WARNING: VHD merge failed: {}", error_text.trim()); } else if status.success() { - println!("Merged delta into base VHD: {}", base_abs.display()); + println!("Merged into: {}", base_abs.display()); } else { - println!("WARNING: PowerShell exited with: {status}. Check UAC was accepted."); + 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, + delta_dir: &Path, + out: &'a mut Option, +) -> Option<&'a ExtractedVhd> { + if let Some(b) = base { + return Some(b); + } + + let prefix = format!("{game_id}_"); + + // Look for existing base VHD + if let Some(vhd_path) = find_sibling(delta_dir, &prefix, "_0.vhd") { + println!("Found existing base VHD: {}", vhd_path.display()); + *out = Some(ExtractedVhd { + vhd_path, input_path: PathBuf::new(), + sequence_number: 0, game_id: game_id.into(), + }); + return out.as_ref(); + } + + // Look for base .app and extract + if let Some(app_path) = find_sibling(delta_dir, &prefix, "_0.app") { + println!("Found base APP, extracting: {}", app_path.display()); + if let Ok(f) = File::open(&app_path) + .and_then(|f| FscryptDecryptor::new(f).map_err(|e| std::io::Error::other(e))) + { + let seq = f.bootid.sequence_number; + drop(f); + if let Ok(vhd_path) = extract_internal_vhd(&app_path, seq) { + *out = Some(ExtractedVhd { + vhd_path, input_path: app_path, + sequence_number: 0, game_id: game_id.into(), + }); + return out.as_ref(); + } + } + println!("WARNING: Failed to extract base VHD from {}", app_path.display()); + } + + println!("WARNING: No base (seq=0) found for {game_id}."); + println!(" Place the base .app or .vhd in the same directory."); + None +} + +// --------------------------------------------------------------------------- +// CLI & main +// --------------------------------------------------------------------------- + #[derive(Parser)] #[command(version, about = "decryptor for some SEGA containers", long_about = None)] struct Cli { @@ -306,24 +353,18 @@ struct Cli { fn main() -> Result<()> { let cli = Cli::parse(); - // Pre-read bootids for all files to sort by sequence number (base first) - let mut inputs: Vec = Vec::new(); + // Pre-read bootids to sort by sequence number (base first) + let mut inputs: Vec<(PathBuf, u8)> = Vec::new(); for path in &cli.files { let file = FscryptDecryptor::new(File::open(path)?).map_err(|e| anyhow!(e))?; - inputs.push(InputFile { - path: path.clone(), - sequence_number: file.bootid.sequence_number, - }); + inputs.push((path.clone(), file.bootid.sequence_number)); } - - // Sort so seq=0 (base) is processed before seq>0 (deltas) - inputs.sort_by_key(|f| f.sequence_number); + inputs.sort_by_key(|(_, seq)| *seq); // Track extracted VHDs for post-extraction merge let mut extracted_vhds: Vec = Vec::new(); - for input in &inputs { - let path = &input.path; + for (path, _) in &inputs { let file = FscryptDecryptor::new(File::open(path)?).map_err(|e| anyhow!(e))?; let bootid = file.bootid.clone(); let output_filename = file.filename()?; @@ -396,132 +437,47 @@ fn main() -> Result<()> { // Post-extraction: merge deltas and extract VHD contents (Windows only) #[cfg(windows)] if !cli.no_extract && !extracted_vhds.is_empty() { - // Group by game_id let mut by_game: HashMap> = HashMap::new(); for vhd in &extracted_vhds { 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 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 its contents if let Some(base) = base { vhds_to_extract.push(base.vhd_path.clone()); } continue; } - // If no base was extracted in this run, search the same directory - let found_base; - let base = match base { - Some(b) => b, - None => { - let delta_dir = deltas[0] - .input_path - .parent() - .unwrap_or_else(|| Path::new(".")); - let pattern_prefix = format!("{game_id}_"); - - // First, look for an existing base VHD ({game_id}_*_0.vhd) - let existing_vhd = std::fs::read_dir(delta_dir)? - .filter_map(|e| e.ok()) - .map(|e| e.path()) - .find(|p| { - let name = p.file_name().unwrap_or_default().to_string_lossy(); - name.starts_with(&pattern_prefix) && name.ends_with("_0.vhd") - }); - - if let Some(vhd_path) = existing_vhd { - println!("Found existing base VHD: {}", vhd_path.display()); - found_base = ExtractedVhd { - vhd_path, - input_path: PathBuf::new(), - sequence_number: 0, - game_id: game_id.clone(), - }; - &found_base - } else { - // Look for a base .app file ({game_id}_*_0.app) and extract it - let base_app = std::fs::read_dir(delta_dir)? - .filter_map(|e| e.ok()) - .map(|e| e.path()) - .find(|p| { - let name = p.file_name().unwrap_or_default().to_string_lossy(); - name.starts_with(&pattern_prefix) && name.ends_with("_0.app") - }); - - match base_app { - Some(app_path) => { - println!( - "Found base APP in same directory, extracting: {}", - app_path.display() - ); - let base_file = FscryptDecryptor::new(File::open(&app_path)?) - .map_err(|e| anyhow!(e))?; - let base_seq = base_file.bootid.sequence_number; - drop(base_file); - - match extract_internal_vhd(&app_path, base_seq) { - Ok(vhd_path) => { - found_base = ExtractedVhd { - vhd_path, - input_path: app_path, - sequence_number: 0, - game_id: game_id.clone(), - }; - &found_base - } - Err(e) => { - println!( - "WARNING: Failed to extract base VHD: {e:#?}" - ); - continue; - } - } - } - None => { - println!( - "WARNING: Delta VHD(s) found for {game_id} but no base (seq=0) found." - ); - println!( - " Place the base .app or .vhd in the same directory." - ); - continue; - } - } - } - } + let delta_dir = deltas[0].input_path.parent().unwrap_or(Path::new(".")); + let mut resolved = None; + let Some(base) = resolve_base_vhd(base, game_id, delta_dir, &mut resolved) else { + continue; }; let mut merge_ok = true; - for delta in deltas { + for delta in &deltas { if let Err(e) = merge_vhd(&base.vhd_path, &delta.vhd_path) { - println!( - "WARNING: Failed to merge {} with {}: {e:#?}", - delta.vhd_path.display(), - base.vhd_path.display() - ); + println!("WARNING: Failed to merge: {e:#}"); 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) = vhd::extract_vhd(vhd_path) { - println!("WARNING: Failed to extract VHD contents: {e:#?}"); + println!("WARNING: VHD extraction failed: {e:#}"); } } } diff --git a/src/vhd.rs b/src/vhd.rs index 704aed4..f236393 100644 --- a/src/vhd.rs +++ b/src/vhd.rs @@ -10,27 +10,45 @@ use indicatif::{ProgressBar, ProgressStyle}; use ntfs::{structured_values::NtfsStandardInformation, Ntfs, NtfsAttributeType, NtfsTime}; // --------------------------------------------------------------------------- -// VHD constants +// Constants // --------------------------------------------------------------------------- -const VHD_FOOTER_SIZE: u64 = 512; +const SECTOR_SIZE: u64 = 512; +const BUF_SIZE: usize = 256 * 1024; + +// VHD format const VHD_COOKIE: &[u8; 8] = b"conectix"; const VHD_TYPE_FIXED: u32 = 2; const VHD_TYPE_DYNAMIC: u32 = 3; +const VHD_FOOTER_DISK_TYPE_OFFSET: usize = 0x3C; +const VHD_FOOTER_DATA_OFFSET: usize = 0x10; +// Dynamic VHD header 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 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; +const BAT_UNUSED: u32 = 0xFFFFFFFF; +// MBR const MBR_SIGNATURE: [u8; 2] = [0x55, 0xAA]; const MBR_PARTITION_TABLE_OFFSET: usize = 0x1BE; const MBR_PARTITION_ENTRY_SIZE: usize = 16; +const MBR_MAX_PARTITIONS: usize = 4; const NTFS_PARTITION_TYPE: u8 = 0x07; -const NTFS_PROBE_OFFSETS: &[u64] = &[0, 32_256, 1_048_576, 512]; +// NTFS boot sector magic const NTFS_MAGIC: [u8; 4] = [0xEB, 0x52, 0x90, 0x4E]; +/// Common virtual offsets where NTFS boot sector might start. +const NTFS_PROBE_OFFSETS: [u64; 4] = [0, 32_256, 1_048_576, 512]; + +// Progress bar +const PROGRESS_STYLE: &str = + "{prefix} [{bar:20!.bright.yellow/dim.white}] {bytes:>8} [{elapsed}<{eta}, {bytes_per_sec}]"; + +// Windows epoch -> Unix epoch offset (100ns intervals) +const WINDOWS_EPOCH_OFFSET: u64 = 116_444_736_000_000_000; // --------------------------------------------------------------------------- // VHD error type @@ -40,16 +58,12 @@ const NTFS_MAGIC: [u8; 4] = [0xEB, 0x52, 0x90, 0x4E]; pub enum VhdError { #[error(transparent)] Io(#[from] io::Error), - - #[error("Not a valid VHD file (bad cookie)")] + #[error("Not a valid VHD file")] InvalidCookie, - - #[error("Unsupported VHD type {0} (only fixed=2 and dynamic=3 are supported)")] + #[error("Unsupported VHD type {0} (only fixed and dynamic are supported)")] UnsupportedType(u32), - #[error("Invalid dynamic VHD header")] InvalidDynamicHeader, - #[error("No NTFS partition found in VHD")] NoNtfsPartition, } @@ -59,234 +73,161 @@ pub enum VhdError { // --------------------------------------------------------------------------- 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, - block_size: u64, - }, + Dynamic { bat: Vec, block_size: u64 }, } -/// A reader that transparently presents the NTFS partition within a VHD file. +/// Transparently presents the NTFS partition within a fixed or dynamic VHD. pub struct VhdReader { 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). + virtual_size: u64, pos: u64, } impl VhdReader { pub fn new(mut inner: R) -> Result { let file_size = inner.seek(SeekFrom::End(0))?; - if file_size < VHD_FOOTER_SIZE { + if file_size < SECTOR_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.seek(SeekFrom::Start(file_size - SECTOR_SIZE))?; + let mut footer = [0u8; SECTOR_SIZE as usize]; inner.read_exact(&mut footer)?; - - if &footer[0..8] != VHD_COOKIE { + if &footer[..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)), + 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)?, + t => return Err(VhdError::UnsupportedType(t)), }; - // 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, - }) + let ntfs_offset = Self::find_ntfs(&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> { - // 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], - ]); + let header_offset = read_be_u64(footer, VHD_FOOTER_DATA_OFFSET); - // Read dynamic disk header (1024 bytes) - inner.seek(SeekFrom::Start(data_offset))?; - let mut hdr = [0u8; 1024]; + inner.seek(SeekFrom::Start(header_offset))?; + let mut hdr = [0u8; DYNAMIC_HEADER_SIZE]; inner.read_exact(&mut hdr)?; - - if &hdr[0..8] != DYNAMIC_HEADER_COOKIE { + if &hdr[..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], - ]); + 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; - // 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 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(); - let bat: Vec = (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)) + Ok((VhdLayout::Dynamic { bat, block_size }, max_entries as u64 * block_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 { - if virtual_offset >= self.virtual_disk_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 remaining = self.virtual_disk_size - virtual_offset; - let to_read = std::cmp::min(buf.len() as u64, remaining) as usize; + 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(virtual_offset))?; - self.inner.read(&mut buf[..to_read]) + self.inner.seek(SeekFrom::Start(virt_off))?; + self.inner.read(&mut buf[..cap]) } 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); + 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 block_index >= bat.len() || bat[block_index] == BAT_ENTRY_UNUSED { - // Unallocated block: return zeros - buf[..to_read].fill(0); - Ok(to_read) + if bi >= bat.len() || bat[bi] == BAT_UNUSED { + buf[..n].fill(0); + Ok(n) } 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]) + // 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]) } } } } - /// Detect NTFS partition offset by reading virtual disk data. - fn detect_ntfs_offset_virtual( - inner: &mut R, - layout: &VhdLayout, - virtual_disk_size: u64, - ) -> Result { - // 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)?; - } + /// 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(magic) - }; + } + Ok(buf) + } - // 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)?; - } + /// 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..4 { + 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], - 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); - } + 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); } } } } } - // 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 { + // 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); } } @@ -294,20 +235,19 @@ impl VhdReader { Err(VhdError::NoNtfsPartition) } - pub fn ntfs_size(&self) -> u64 { - self.virtual_disk_size - self.ntfs_offset + fn ntfs_size(&self) -> u64 { + self.virtual_size - self.ntfs_offset } } impl Read for VhdReader { fn read(&mut self, buf: &mut [u8]) -> io::Result { - let remaining = self.ntfs_size() - self.pos; + let remaining = self.ntfs_size().saturating_sub(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])?; + 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])?; self.pos += n as u64; Ok(n) } @@ -315,73 +255,57 @@ impl Read for VhdReader { impl Seek for VhdReader { fn seek(&mut self, pos: SeekFrom) -> io::Result { - 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 - } + 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, }; - self.pos = new_pos; - Ok(new_pos) + if target < 0 { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "seek before start")); + } + self.pos = target as u64; + Ok(self.pos) } } // --------------------------------------------------------------------------- -// NTFS extraction from VHD +// NTFS extraction // --------------------------------------------------------------------------- -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 is_ntfs_system_entry(name: &str) -> bool { + name.starts_with('$') || name == "." || name == ".." || name == "System Volume Information" } -fn set_ntfs_timestamps( - fs: &mut T, - file: &ntfs::NtfsFile, - path: &Path, -) -> Result<()> { +fn ntfs_time_to_system_time(t: NtfsTime) -> SystemTime { + let nanos = (t.nt_timestamp() - WINDOWS_EPOCH_OFFSET) * 100; + SystemTime::UNIX_EPOCH + Duration::from_nanos(nanos) +} + +fn set_ntfs_timestamps(fs: &mut T, file: &ntfs::NtfsFile, path: &Path) { 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::()?; - 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; + while let Some(Ok(attr)) = attrs.next(fs) { + if let Ok(attr) = attr.to_attribute() { + if let Ok(NtfsAttributeType::StandardInformation) = attr.ty() { + if let Ok(info) = attr.resident_structured_value::() { + let _ = OpenOptions::new().write(true).open(path).and_then(|h| { + h.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( ntfs: &Ntfs, fs: &mut T, dir: &ntfs::NtfsFile, - output_dir: &Path, + out: &Path, pb: &ProgressBar, ) -> Result<()> { let index = dir.directory_index(fs)?; @@ -389,52 +313,40 @@ fn extract_ntfs_dir( 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" - { + let key = entry.key().ok_or_else(|| anyhow!("missing key"))??; + let name = key.name().to_string_lossy(); + if is_ntfs_system_entry(&name) { continue; } let file = entry.to_file(ntfs, fs)?; - let dest_path = output_dir.join(&*name); + let dest = out.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)?; + if key.is_directory() { + create_dir_all(&dest)?; + extract_ntfs_dir(ntfs, fs, &file, &dest, pb)?; + set_ntfs_timestamps(fs, &file, &dest); + } else if let Some(data) = file.data(fs, "") { + let data_item = data?; + let attr = data_item.to_attribute()?; + let mut reader = BufReader::with_capacity(BUF_SIZE, attr.value(fs)?.attach(fs)); + let mut out_file = File::create(&dest)?; loop { - let buffer = data_value.fill_buf()?; - let length = buffer.len(); - if length == 0 { + let buf = reader.fill_buf()?; + if buf.is_empty() { break; } - output_file.write_all(buffer)?; - data_value.consume(length); - pb.inc(length as u64); + out_file.write_all(buf)?; + let n = buf.len(); + reader.consume(n); + pb.inc(n as u64); } - output_file.flush()?; - drop(data_value); - - set_ntfs_timestamps(fs, &file, &dest_path).ok(); + out_file.flush()?; + drop(reader); + set_ntfs_timestamps(fs, &file, &dest); } } - Ok(()) } @@ -443,77 +355,64 @@ fn calculate_ntfs_size( fs: &mut T, dir: &ntfs::NtfsFile, ) -> Result { - let mut total: u64 = 0; + let mut total = 0u64; 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" - { + let key = entry.key().ok_or_else(|| anyhow!("missing key"))??; + if is_ntfs_system_entry(&key.name().to_string_lossy().as_ref()) { continue; } - let file = entry.to_file(ntfs, fs)?; - if file_name.is_directory() { + if key.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(); + } else if let Some(data) = file.data(fs, "") { + total += data?.to_attribute()?.value_length(); } } - Ok(total) } -/// Extract all files from a VHD's NTFS filesystem into a folder, then delete the VHD. +/// Extract all files from a 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()); - 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 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_size = calculate_ntfs_size(&ntfs, &mut vhd, &root)?; + let total = 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() - )); + 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(); + 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(()) } + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn read_be_u32(buf: &[u8], offset: usize) -> u32 { + u32::from_be_bytes(buf[offset..offset + 4].try_into().unwrap()) +} + +fn read_be_u64(buf: &[u8], offset: usize) -> u64 { + u64::from_be_bytes(buf[offset..offset + 8].try_into().unwrap()) +} From 2121b436990e5cae2910e9ca81ae8286ea4fbe1b Mon Sep 17 00:00:00 2001 From: Jujuforce Date: Sun, 22 Mar 2026 16:15:58 +0100 Subject: [PATCH 11/17] 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 // --------------------------------------------------------------------------- From 068badad7efb642a8419767af1e3afef346a1ad4 Mon Sep 17 00:00:00 2001 From: Jujuforce Date: Sun, 22 Mar 2026 16:29:49 +0100 Subject: [PATCH 12/17] feat: name output folder after the input .app file Output directory is now derived from the input .app path (without extension) instead of the intermediate VHD filename. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/main.rs | 6 ++++-- src/vhd.rs | 16 ++++++++-------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/main.rs b/src/main.rs index d61e613..ccc7513 100644 --- a/src/main.rs +++ b/src/main.rs @@ -393,7 +393,8 @@ fn main() -> Result<()> { if deltas.is_empty() { // Standalone base VHD, just extract if let Some(base) = base { - if let Err(e) = vhd::extract_vhd(&base.vhd_path) { + let output_dir = base.input_path.with_extension(""); + if let Err(e) = vhd::extract_vhd(&base.vhd_path, &output_dir) { println!("WARNING: VHD extraction failed: {e:#}"); } } @@ -408,7 +409,8 @@ fn main() -> Result<()> { // Merge + extract each delta with the base (pure Rust, no admin needed) for delta in &deltas { - if let Err(e) = vhd::extract_merged_vhd(&base.vhd_path, &delta.vhd_path) { + 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:#}"); } } diff --git a/src/vhd.rs b/src/vhd.rs index fc05c28..57d0806 100644 --- a/src/vhd.rs +++ b/src/vhd.rs @@ -656,13 +656,13 @@ fn extract_ntfs_to_dir(fs: &mut T, output_dir: &Path, prefix: &s // --------------------------------------------------------------------------- /// 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(""); +/// The `output_dir` is where files are extracted to. +pub fn extract_vhd(vhd_path: &Path, output_dir: &Path) -> Result<()> { println!("Extracting VHD: {}", vhd_path.display()); let mut vhd = VhdReader::new(File::open(vhd_path)?).map_err(|e| anyhow!(e))?; - let prefix = vhd_path.file_name().unwrap_or_default().to_string_lossy().to_string(); - extract_ntfs_to_dir(&mut vhd, &output_dir, &prefix)?; + let prefix = output_dir.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()); @@ -675,16 +675,16 @@ pub fn extract_vhd(vhd_path: &Path) -> Result<()> { /// 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(""); +/// 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()); 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)?; + let prefix = output_dir.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()); From b0e97f8b002baa254bce7415953cd1f7a05d9b82 Mon Sep 17 00:00:00 2001 From: Jujuforce Date: Sun, 22 Mar 2026 16:35:15 +0100 Subject: [PATCH 13/17] docs: simplify README, reflect pure Rust extraction Remove technical implementation details, keep it user-friendly. Document delta update auto-detection, output folder naming, and cross-platform support. Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 97 +++++++++++-------------------------------------------- 1 file changed, 19 insertions(+), 78 deletions(-) diff --git a/README.md b/README.md index a9a90af..9dae751 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,10 @@ # fsdecrypt -Decryptor and extractor for SEGA arcade filesystem containers (fscrypt format). - -Handles AES-128-CBC encrypted container images used on SEGA Nu/ALLS arcade hardware, automatically parsing the embedded BootID header to identify the game and container type, then decrypting and extracting the contents. - -## Features - -- Decrypts OS, APP, and OPTION (DLC) containers -- Extracts NTFS-based containers (OS/APP) including internal VHD images -- Extracts ExFAT-based containers (OPTION/DLC packs) -- **Auto-merges delta update VHDs** with their base on Windows (Hyper-V) -- Preserves file timestamps during extraction -- Built-in key database for 70+ game titles -- Supports external key files for unlisted games -- Progress bars with transfer speed and ETA +Decryptor and extractor for SEGA arcade filesystem containers (fscrypt format). Works on Windows and Linux, no admin privileges required. ## Installation -Requires [Rust](https://www.rust-lang.org/tools/install) 1.56+ (edition 2021). +Requires [Rust](https://www.rust-lang.org/tools/install). ```bash cargo build --release @@ -27,94 +14,48 @@ The binary will be at `target/release/fsdecrypt` (or `fsdecrypt.exe` on Windows) ## Usage +```bash +fsdecrypt ... ``` -fsdecrypt [OPTIONS] ... -``` - -### Arguments - -- `...` - One or more encrypted container files (`.app`, `.opt`, etc.) - -### Options - -- `--no-extract` - Decrypt the container to a raw image file without extracting its contents -- `-h, --help` - Print help -- `-V, --version` - Print version ### Examples ```bash -# Decrypt and extract an APP container +# Extract a game APP container fsdecrypt ABCD_1.00.00_20240101120000_0.app -# Decrypt and extract an OPTION container +# Extract an OPTION/DLC container fsdecrypt ABCD_A001_20240101120000_0.opt -# Process multiple containers at once +# Extract multiple files at once fsdecrypt game_v1.app game_v2.app extras.opt -# Decrypt only, skip extraction +# Decrypt only (outputs raw .ntfs/.exfat image, no extraction) fsdecrypt --no-extract ABCD_1.00.00_20240101120000_0.app ``` ### Delta Updates -When a game ships incremental updates, you get a base `.app` (seq=0) and one or more delta `.app` files (seq>0). Pass them all together and fsdecrypt handles the rest: +Games often ship incremental updates as a separate `.app` file. Just pass the update file — fsdecrypt will automatically find the base in the same folder and merge them: + +```bash +fsdecrypt ABCD_1.01.00_20240215143000_1_1.00.00.app +``` + +You can also pass both explicitly: ```bash -# Base + delta update: fsdecrypt extracts both VHDs, then auto-merges fsdecrypt ABCD_1.00.00_20240101120000_0.app ABCD_1.01.00_20240215143000_1_1.00.00.app ``` -The merge workflow (Windows only, requires Hyper-V): -1. Both containers are decrypted and their internal VHDs extracted -2. `Set-VHD` links the delta (differencing) VHD to its parent -3. `Merge-VHD` merges the delta into the base VHD -4. A UAC prompt will appear since these cmdlets require elevation - -If you only provide the delta without its base, fsdecrypt will extract the VHD and print a warning with instructions. - -### Output - -By default, the tool extracts container contents directly: - -| Type | Extracted contents | -|--------|--------------------| -| OS | `internal_{seq}.vhd` | -| APP | `internal_{seq}.vhd` (auto-merged if delta + base provided) | -| OPTION | Directory with all DLC files | - -With `--no-extract`, a raw decrypted image is written instead: - -| Type | Output filename | -|--------|-----------------| -| OS | `{os_id}_{version}_{timestamp}_{seq}.ntfs` | -| APP | `{game_id}_{version}_{timestamp}_{seq}.ntfs` | -| OPTION | `{game_id}_{option}_{timestamp}_{seq}.exfat` | +The output folder is named after the input file (e.g. `ABCD_1.01.00_20240215143000_1_1.00.00/`). ## External Key Files -For games not in the built-in database, place a key file named `{GAME_ID}.bin` in the working directory: +For games not in the built-in key database, place a file named `{GAME_ID}.bin` in the working directory: -- **16 bytes**: AES-128 key only (IV will be derived automatically) -- **32 bytes**: AES-128 key (first 16 bytes) + IV (last 16 bytes) - -## How It Works - -1. The first 128 bytes of the container are decrypted using a hardcoded master key to obtain the **BootID** header -2. The BootID contains metadata: game ID, container type, block layout, and an IV mode flag -3. The game-specific AES-128 key is looked up from the built-in database (or read from an external `.bin` file) -4. If no IV is hardcoded or the container uses a custom IV, the IV is derived by trial-decrypting the first data page against the expected filesystem header (NTFS or ExFAT magic bytes) -5. Each 4096-byte page is decrypted independently using AES-128-CBC, with a per-page IV computed by XORing the file IV with the page's file offset -6. The decrypted stream is parsed as NTFS or ExFAT depending on container type, and contents are extracted - -## Container Types - -| Type | ID | Filesystem | Description | -|------|----|-----------|-------------| -| OS | `0x00` | NTFS | Operating system image | -| APP | `0x01` | NTFS | Game application and assets | -| OPTION | `0x02` | ExFAT | Downloadable content / option packs | +- **16 bytes** for key only (IV derived automatically) +- **32 bytes** for key + IV ## License From 558c0ecec079c5e60e5a8ec0a8aa848b3a54e82f Mon Sep 17 00:00:00 2001 From: jujuforce Date: Mon, 20 Apr 2026 11:37:53 +0200 Subject: [PATCH 14/17] 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 15/17] 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 16/17] 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(()) } From 01e1437a1e47fd09f823e9ccaabd8f1a9137dabc Mon Sep 17 00:00:00 2001 From: Jujuforce Date: Tue, 21 Apr 2026 12:41:09 +0200 Subject: [PATCH 17/17] chore: edited gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 0ab9dbf..a3c3401 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ *.exfat flamegraph.svg CLAUDE.md +memory/