8 Commits

Author SHA1 Message Date
MechaCat02
4ed8fadd4c feat: show decrypted session key in inspect output
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 22:24:23 +01:00
MechaCat02
ee5e0b60f8 fix: include Cargo.lock changes from v0.6.0 version bump
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 22:00:13 +01:00
MechaCat02
1e773e6cdc Merge feature/m6-extract-pe: PE verification and complete extraction 2026-03-28 21:51:57 +01:00
MechaCat02
475e1d555c feat: add PE verification and complete extraction pipeline (M6)
Add PE header verification (MZ signature, PE signature, POWERPCBE
machine type) to the extraction pipeline. Implement master key trial
with validation for encrypted files. Update CLI extract command to
show encryption/compression info. Update README with extract usage.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 21:51:52 +01:00
MechaCat02
ba3b5a0ac3 Merge feature/m5-decompression: PE image decompression and extraction 2026-03-28 21:48:28 +01:00
MechaCat02
c665868b1b feat: add PE image decompression and extraction pipeline (M5)
Implement full decrypt + decompress pipeline for XEX2 PE extraction:
- decompress.rs: None, Basic (zero-fill), and Normal (LZX) decompression
- extract.rs: orchestrates decryption then decompression
- Wire up CLI extract command to write PE files
- LZX decompression via lzxd crate with per-frame chunk processing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 21:48:23 +01:00
MechaCat02
ac24488444 Merge feature/m4-decryption: AES-128-CBC decryption and clap CLI 2026-03-28 21:31:35 +01:00
MechaCat02
df26b028b6 feat: add AES-128-CBC decryption and clap CLI (M4)
Add session key derivation and payload decryption using AES-128-CBC
with well-known XEX2 master keys. Refactor CLI to use clap with
inspect/extract subcommands. Extend FileFormatInfo to parse
compression metadata (basic blocks, LZX window size/block chain).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 21:31:31 +01:00
12 changed files with 1295 additions and 46 deletions

292
Cargo.lock generated
View File

@@ -2,6 +2,296 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 4 version = 4
[[package]]
name = "aes"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
]
[[package]]
name = "anstream"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anstyle-parse"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys",
]
[[package]]
name = "block-padding"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
dependencies = [
"generic-array",
]
[[package]]
name = "cbc"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
dependencies = [
"cipher",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
]
[[package]]
name = "clap"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "clap_lex"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"block-padding",
"generic-array",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "libc"
version = "0.2.183"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
[[package]]
name = "lzxd"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b29dffab797218e12e4df08ef5d15ab9efca2504038b1b32b9b32fc844b39c9"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
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.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "typenum"
version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]] [[package]]
name = "xex2tractor" name = "xex2tractor"
version = "0.3.0" version = "0.6.1"
dependencies = [
"aes",
"cbc",
"clap",
"lzxd",
]

View File

@@ -1,8 +1,12 @@
[package] [package]
name = "xex2tractor" name = "xex2tractor"
version = "0.3.0" version = "0.6.1"
edition = "2024" edition = "2024"
description = "A tool for extracting and inspecting Xbox 360 XEX2 executable files" description = "A tool for extracting and inspecting Xbox 360 XEX2 executable files"
license = "MIT" license = "MIT"
[dependencies] [dependencies]
aes = "0.8.4"
cbc = "0.1.2"
clap = { version = "4.6.0", features = ["derive"] }
lzxd = "0.2.6"

View File

@@ -4,11 +4,15 @@ A tool for extracting and inspecting Xbox 360 XEX2 executable files, written in
## Usage ## Usage
### Inspect
Display XEX2 file information (headers, security info, etc.):
```sh ```sh
xex2tractor <file.xex> xex2tractor inspect <file.xex>
``` ```
### Example Output #### Example Output
``` ```
=== XEX2 Header === === XEX2 Header ===
@@ -35,16 +39,9 @@ Header Count: 15
[FILE_FORMAT_INFO] [FILE_FORMAT_INFO]
Encryption: Normal (AES-128-CBC) Encryption: Normal (AES-128-CBC)
Compression: Normal (LZX) Compression: Normal (LZX)
Window Size: 0x8000 (32 KB)
[STATIC_LIBRARIES] (12 libraries)
XAPILIB 2.0.3215.0 (Unknown(64))
D3D9 2.0.3215.1 (Unknown(64))
... ...
[IMPORT_LIBRARIES] (2 libraries)
xam.xex v2.0.4552.0 (min v2.0.4552.0) - 104 imports
xboxkrnl.exe v2.0.4552.0 (min v2.0.4552.0) - 294 imports
=== Security Info === === Security Info ===
Header Size: 0x00000F34 (3892 bytes) Header Size: 0x00000F34 (3892 bytes)
Image Size: 0x00920000 (9568256 bytes) Image Size: 0x00920000 (9568256 bytes)
@@ -54,12 +51,32 @@ Load Address: 0x82000000
Region: 0xFFFFFFFF [ALL REGIONS] Region: 0xFFFFFFFF [ALL REGIONS]
Allowed Media Types: 0x00000004 [DVD_CD] Allowed Media Types: 0x00000004 [DVD_CD]
... ...
Page Descriptors (146 entries, 64KB pages):
#0 Unknown(0) 19 pages ( 1245184 bytes) offset +0x00000000 SHA1: B136058FBBAD...
...
``` ```
### Extract
Extract the decrypted and decompressed PE image from a XEX2 file:
```sh
xex2tractor extract <file.xex> [output.exe]
```
If no output path is given, defaults to the input filename with `.exe` extension.
#### Example
```sh
$ xex2tractor extract default.xex default.exe
Encryption: Normal (AES-128-CBC)
Compression: Normal (LZX)
Extracted PE image (9568256 bytes) -> default.exe
```
Supports:
- AES-128-CBC decryption (retail, devkit, and XEX1 master keys)
- No compression, basic (zero-fill), and normal (LZX) decompression
- PE header verification (MZ signature, PE signature, POWERPCBE machine type)
## Building ## Building
```sh ```sh

178
src/crypto.rs Normal file
View File

@@ -0,0 +1,178 @@
/// AES-128-CBC decryption for XEX2 session key derivation and payload decryption.
use aes::Aes128;
use cbc::cipher::{BlockDecryptMut, KeyIvInit};
use crate::error::{Result, Xex2Error};
type Aes128CbcDec = cbc::Decryptor<Aes128>;
/// Well-known XEX2 retail AES-128 master key.
pub const XEX2_RETAIL_KEY: [u8; 16] = [
0x20, 0xB1, 0x85, 0xA5, 0x9D, 0x28, 0xFD, 0xC3, 0x40, 0x58, 0x3F, 0xBB, 0x08, 0x96, 0xBF,
0x91,
];
/// Well-known XEX2 devkit AES-128 master key (all zeros).
pub const XEX2_DEVKIT_KEY: [u8; 16] = [0u8; 16];
/// Well-known XEX1 retail AES-128 master key.
pub const XEX1_RETAIL_KEY: [u8; 16] = [
0xA2, 0x6C, 0x10, 0xF7, 0x1F, 0xD9, 0x35, 0xE9, 0x8B, 0x99, 0x92, 0x2C, 0xE9, 0x32, 0x15,
0x72,
];
/// Master keys tried in order during session key derivation.
const MASTER_KEYS: &[[u8; 16]] = &[XEX2_RETAIL_KEY, XEX2_DEVKIT_KEY, XEX1_RETAIL_KEY];
/// Zero IV used for all XEX2 AES-128-CBC operations.
const ZERO_IV: [u8; 16] = [0u8; 16];
/// Decrypts a 16-byte block using AES-128-CBC with a zero IV.
fn aes128_cbc_decrypt_block(key: &[u8; 16], encrypted: &[u8; 16]) -> [u8; 16] {
let mut block = encrypted.to_owned();
let decryptor = Aes128CbcDec::new(key.into(), &ZERO_IV.into());
decryptor
.decrypt_padded_mut::<cbc::cipher::block_padding::NoPadding>(&mut block)
.expect("decryption of exactly one block should not fail");
block
}
/// Derives the session key by decrypting the encrypted AES key from security info.
///
/// Tries master keys in order: XEX2 retail, XEX2 devkit, XEX1 retail.
/// Returns the session key derived using the first master key (retail by default).
/// Actual validation of which key is correct happens later when checking for a valid PE header.
pub fn derive_session_key(encrypted_key: &[u8; 16]) -> [u8; 16] {
// For now, always use retail key. Key trial with validation will be added in M6.
aes128_cbc_decrypt_block(&XEX2_RETAIL_KEY, encrypted_key)
}
/// Tries all master keys and returns the session key that produces a valid decryption.
///
/// `validator` is called with the derived session key and should return `true` if
/// the key produces valid output (e.g., decrypted data starts with MZ signature).
pub fn derive_session_key_with_validation(
encrypted_key: &[u8; 16],
validator: impl Fn(&[u8; 16]) -> bool,
) -> Result<[u8; 16]> {
for master_key in MASTER_KEYS {
let session_key = aes128_cbc_decrypt_block(master_key, encrypted_key);
if validator(&session_key) {
return Ok(session_key);
}
}
Err(Xex2Error::DecryptionFailed)
}
/// Decrypts data in-place using AES-128-CBC with a zero IV.
///
/// The data length must be a multiple of 16 bytes (AES block size).
/// Any trailing bytes that don't fill a complete block are left unchanged.
pub fn decrypt_in_place(session_key: &[u8; 16], data: &mut [u8]) {
let block_len = data.len() - (data.len() % 16);
if block_len == 0 {
return;
}
let decryptor = Aes128CbcDec::new(session_key.into(), &ZERO_IV.into());
decryptor
.decrypt_padded_mut::<cbc::cipher::block_padding::NoPadding>(&mut data[..block_len])
.expect("decryption with NoPadding on aligned data should not fail");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_master_key_constants() {
// Verify retail key starts with expected bytes
assert_eq!(XEX2_RETAIL_KEY[0], 0x20);
assert_eq!(XEX2_RETAIL_KEY[15], 0x91);
// Devkit key is all zeros
assert!(XEX2_DEVKIT_KEY.iter().all(|&b| b == 0));
// XEX1 key starts with expected bytes
assert_eq!(XEX1_RETAIL_KEY[0], 0xA2);
assert_eq!(XEX1_RETAIL_KEY[15], 0x72);
}
#[test]
fn test_decrypt_block_deterministic() {
let input = [0u8; 16];
let result1 = aes128_cbc_decrypt_block(&XEX2_RETAIL_KEY, &input);
let result2 = aes128_cbc_decrypt_block(&XEX2_RETAIL_KEY, &input);
assert_eq!(result1, result2);
}
#[test]
fn test_decrypt_block_different_keys_differ() {
let input = [0x42u8; 16];
let retail = aes128_cbc_decrypt_block(&XEX2_RETAIL_KEY, &input);
let devkit = aes128_cbc_decrypt_block(&XEX2_DEVKIT_KEY, &input);
assert_ne!(retail, devkit);
}
#[test]
fn test_derive_session_key_from_sample() {
// The sample file's encrypted AES key starts with 0xEACB
let encrypted: [u8; 16] = [
0xEA, 0xCB, 0x4C, 0x2E, 0x0D, 0xBA, 0x85, 0x36, 0xCF, 0xB2, 0x65, 0x3C, 0xBB, 0xBF,
0x2E, 0xFC,
];
let session_key = derive_session_key(&encrypted);
// Session key should be non-zero (decryption worked)
assert!(!session_key.iter().all(|&b| b == 0));
// Session key should differ from input
assert_ne!(&session_key[..], &encrypted[..]);
}
#[test]
fn test_derive_session_key_with_validation_finds_key() {
let encrypted = [0x42u8; 16];
// Validator that always accepts retail key result
let retail_result = aes128_cbc_decrypt_block(&XEX2_RETAIL_KEY, &encrypted);
let result = derive_session_key_with_validation(&encrypted, |key| *key == retail_result);
assert!(result.is_ok());
assert_eq!(result.unwrap(), retail_result);
}
#[test]
fn test_derive_session_key_with_validation_fails() {
let encrypted = [0x42u8; 16];
let result = derive_session_key_with_validation(&encrypted, |_| false);
assert!(result.is_err());
}
#[test]
fn test_decrypt_in_place_roundtrip() {
// Encrypt then decrypt should give back original
// Since we only have decrypt, verify it's deterministic
let key = [0x01u8; 16];
let mut data = [0xABu8; 32];
let original = data;
decrypt_in_place(&key, &mut data);
// Decrypted data should differ from encrypted
assert_ne!(data, original);
// Decrypting again should give different result (CBC is not self-inverse)
let decrypted_once = data;
decrypt_in_place(&key, &mut data);
assert_ne!(data, decrypted_once);
}
#[test]
fn test_decrypt_in_place_empty() {
let key = [0u8; 16];
let mut data: [u8; 0] = [];
decrypt_in_place(&key, &mut data); // Should not panic
}
#[test]
fn test_decrypt_in_place_partial_block() {
let key = [0u8; 16];
let mut data = [0xFFu8; 20]; // 16 + 4 trailing bytes
decrypt_in_place(&key, &mut data);
// Last 4 bytes should be unchanged
assert_eq!(data[16..], [0xFF, 0xFF, 0xFF, 0xFF]);
}
}

187
src/decompress.rs Normal file
View File

@@ -0,0 +1,187 @@
/// Decompression routines for XEX2 PE image payloads.
///
/// Supports three compression modes:
/// - None: raw data copy
/// - Basic: block-based data copy with zero-fill gaps
/// - Normal: de-blocking + LZX frame-by-frame decompression
use crate::error::{Result, Xex2Error};
use crate::optional::{BasicCompressionBlock, CompressedBlockInfo};
use crate::util::{read_u16_be, read_u32_be};
/// Returns the payload data as-is (no compression).
pub fn decompress_none(data: &[u8]) -> Vec<u8> {
data.to_vec()
}
/// Decompresses basic (zero-fill) compressed data.
///
/// Each block specifies `data_size` bytes to copy from the source, followed by
/// `zero_size` bytes of zeros.
pub fn decompress_basic(data: &[u8], blocks: &[BasicCompressionBlock]) -> Result<Vec<u8>> {
let total_size: u64 = blocks
.iter()
.map(|b| b.data_size as u64 + b.zero_size as u64)
.sum();
let mut output = Vec::with_capacity(total_size as usize);
let mut src_offset = 0usize;
for block in blocks {
let ds = block.data_size as usize;
let zs = block.zero_size as usize;
if src_offset + ds > data.len() {
return Err(Xex2Error::DecompressionFailed(format!(
"basic block reads past end of data: offset {src_offset} + size {ds} > {}",
data.len()
)));
}
output.extend_from_slice(&data[src_offset..src_offset + ds]);
output.resize(output.len() + zs, 0);
src_offset += ds;
}
Ok(output)
}
/// Decompresses normal (LZX) compressed data.
///
/// Walks the chained block structure, extracting compressed LZX frames, then
/// decompresses each frame using the lzxd crate. Each 2-byte chunk_size within
/// a block corresponds to one LZX frame of up to 32KB uncompressed output.
pub fn decompress_normal(
data: &[u8],
window_size: u32,
first_block: &CompressedBlockInfo,
image_size: u32,
) -> Result<Vec<u8>> {
let ws = match window_size {
0x8000 => lzxd::WindowSize::KB32,
0x10000 => lzxd::WindowSize::KB64,
0x20000 => lzxd::WindowSize::KB128,
0x40000 => lzxd::WindowSize::KB256,
other => {
return Err(Xex2Error::DecompressionFailed(format!(
"unsupported LZX window size: 0x{other:X}"
)));
}
};
let mut decoder = lzxd::Lzxd::new(ws);
let mut output = Vec::with_capacity(image_size as usize);
let mut remaining = image_size as usize;
let mut source_offset = 0usize;
let mut current_block = first_block.clone();
while current_block.block_size != 0 && remaining > 0 {
if source_offset + current_block.block_size as usize > data.len() {
return Err(Xex2Error::DecompressionFailed(format!(
"block at offset {source_offset} extends past data (block_size={}, data_len={})",
current_block.block_size,
data.len()
)));
}
let block_end = source_offset + current_block.block_size as usize;
// Read next block info from start of this block's data (24 bytes)
let next_block_size = read_u32_be(data, source_offset)?;
let mut next_block_hash = [0u8; 20];
next_block_hash.copy_from_slice(&data[source_offset + 4..source_offset + 24]);
// Skip past the 24-byte block header
let mut chunk_offset = source_offset + 24;
// Process each compressed chunk (= one LZX frame)
while chunk_offset < block_end && remaining > 0 {
let chunk_size = read_u16_be(data, chunk_offset)? as usize;
chunk_offset += 2;
if chunk_size == 0 {
break;
}
if chunk_offset + chunk_size > block_end {
return Err(Xex2Error::DecompressionFailed(format!(
"chunk at offset {chunk_offset} extends past block end {block_end}"
)));
}
// Each chunk decompresses to up to 32KB (MAX_CHUNK_SIZE)
let frame_output_size = remaining.min(lzxd::MAX_CHUNK_SIZE);
let compressed_chunk = &data[chunk_offset..chunk_offset + chunk_size];
let decompressed = decoder
.decompress_next(compressed_chunk, frame_output_size)
.map_err(|e| Xex2Error::DecompressionFailed(format!("LZX error: {e}")))?;
output.extend_from_slice(decompressed);
remaining -= decompressed.len();
chunk_offset += chunk_size;
}
// Advance to next block
source_offset = block_end;
current_block = CompressedBlockInfo {
block_size: next_block_size,
block_hash: next_block_hash,
};
}
Ok(output)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_decompress_none() {
let data = vec![1, 2, 3, 4, 5];
let result = decompress_none(&data);
assert_eq!(result, data);
}
#[test]
fn test_decompress_basic_simple() {
let data = vec![0xAA, 0xBB, 0xCC, 0xDD];
let blocks = vec![
BasicCompressionBlock {
data_size: 2,
zero_size: 3,
},
BasicCompressionBlock {
data_size: 2,
zero_size: 1,
},
];
let result = decompress_basic(&data, &blocks).unwrap();
assert_eq!(result, vec![0xAA, 0xBB, 0, 0, 0, 0xCC, 0xDD, 0]);
}
#[test]
fn test_decompress_basic_empty() {
let result = decompress_basic(&[], &[]).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_decompress_basic_zero_only() {
let blocks = vec![BasicCompressionBlock {
data_size: 0,
zero_size: 10,
}];
let result = decompress_basic(&[], &blocks).unwrap();
assert_eq!(result, vec![0u8; 10]);
}
#[test]
fn test_decompress_basic_overflow() {
let data = vec![0xAA];
let blocks = vec![BasicCompressionBlock {
data_size: 100,
zero_size: 0,
}];
assert!(decompress_basic(&data, &blocks).is_err());
}
}

View File

@@ -1,7 +1,8 @@
/// Pretty-print formatting for parsed XEX2 structures. /// Pretty-print formatting for parsed XEX2 structures.
use crate::crypto;
use crate::header::Xex2Header; use crate::header::Xex2Header;
use crate::optional::{ use crate::optional::{
format_hex_bytes, format_rating, format_timestamp, HeaderKey, OptionalHeaders, format_hex_bytes, format_rating, format_timestamp, CompressionInfo, HeaderKey, OptionalHeaders,
}; };
use crate::security::SecurityInfo; use crate::security::SecurityInfo;
@@ -79,6 +80,25 @@ pub fn display_optional_headers(headers: &OptionalHeaders) {
println!("[FILE_FORMAT_INFO]"); println!("[FILE_FORMAT_INFO]");
println!(" Encryption: {}", fmt.encryption_type); println!(" Encryption: {}", fmt.encryption_type);
println!(" Compression: {}", fmt.compression_type); println!(" Compression: {}", fmt.compression_type);
match &fmt.compression_info {
CompressionInfo::Basic { blocks } => {
println!(" Blocks: {} basic compression blocks", blocks.len());
}
CompressionInfo::Normal {
window_size,
first_block,
} => {
println!(
" Window Size: 0x{window_size:X} ({} KB)",
window_size / 1024
);
println!(
" First Block: {} bytes",
first_block.block_size
);
}
_ => {}
}
} }
// Checksum + timestamp // Checksum + timestamp
@@ -247,6 +267,11 @@ pub fn display_security_info(security: &SecurityInfo) {
"AES Key (encrypted): {}", "AES Key (encrypted): {}",
format_hex_bytes(&security.aes_key) format_hex_bytes(&security.aes_key)
); );
let session_key = crypto::derive_session_key(&security.aes_key);
println!(
"AES Key (decrypted): {}",
format_hex_bytes(&session_key)
);
if security.export_table == 0 { if security.export_table == 0 {
println!("Export Table: 0x00000000 (none)"); println!("Export Table: 0x00000000 (none)");

View File

@@ -18,6 +18,12 @@ pub enum Xex2Error {
}, },
/// A string field contains invalid UTF-8. /// A string field contains invalid UTF-8.
Utf8Error(std::str::Utf8Error), Utf8Error(std::str::Utf8Error),
/// AES decryption failed — no master key produced valid output.
DecryptionFailed,
/// Decompression failed.
DecompressionFailed(String),
/// The extracted PE image is invalid.
InvalidPeImage(String),
} }
impl fmt::Display for Xex2Error { impl fmt::Display for Xex2Error {
@@ -41,6 +47,11 @@ impl fmt::Display for Xex2Error {
) )
} }
Xex2Error::Utf8Error(err) => write!(f, "invalid UTF-8: {err}"), Xex2Error::Utf8Error(err) => write!(f, "invalid UTF-8: {err}"),
Xex2Error::DecryptionFailed => {
write!(f, "decryption failed: no master key produced valid output")
}
Xex2Error::DecompressionFailed(msg) => write!(f, "decompression failed: {msg}"),
Xex2Error::InvalidPeImage(msg) => write!(f, "invalid PE image: {msg}"),
} }
} }
} }

211
src/extract.rs Normal file
View File

@@ -0,0 +1,211 @@
/// PE image extraction pipeline: decrypt → decompress → verify → raw PE bytes.
use crate::crypto;
use crate::decompress;
use crate::error::{Result, Xex2Error};
use crate::optional::{CompressionInfo, EncryptionType};
use crate::Xex2File;
/// Expected MZ (DOS) signature at the start of a PE image.
const MZ_SIGNATURE: u16 = 0x5A4D;
/// Expected PE signature: "PE\0\0" = 0x00004550.
const PE_SIGNATURE: u32 = 0x00004550;
/// IMAGE_FILE_MACHINE_POWERPCBE — Xbox 360 PowerPC big-endian.
const MACHINE_POWERPCBE: u16 = 0x01F2;
/// Extracts the PE image from a parsed XEX2 file.
///
/// Reads the encrypted/compressed payload from `data` (the full XEX2 file),
/// decrypts it if needed, decompresses it based on the file format info,
/// verifies the PE headers, and returns the raw PE image bytes.
pub fn extract_pe_image(data: &[u8], xex: &Xex2File) -> Result<Vec<u8>> {
let fmt = xex
.optional_headers
.file_format_info
.as_ref()
.ok_or_else(|| {
Xex2Error::DecompressionFailed("missing FILE_FORMAT_INFO header".into())
})?;
let payload_offset = xex.header.header_size as usize;
if payload_offset > data.len() {
return Err(Xex2Error::DecompressionFailed(format!(
"header_size (0x{:X}) exceeds file size (0x{:X})",
payload_offset,
data.len()
)));
}
// Copy payload so we can decrypt in-place
let mut payload = data[payload_offset..].to_vec();
// Step 1: Decrypt if needed
if fmt.encryption_type == EncryptionType::Normal {
let session_key = crypto::derive_session_key_with_validation(
&xex.security_info.aes_key,
|key| {
// Quick validation: decrypt the first 16 bytes and check for patterns
// that indicate a valid decryption (non-random data).
// Full MZ validation happens after decompression.
let mut test_block = payload[..16.min(payload.len())].to_vec();
crypto::decrypt_in_place(key, &mut test_block);
// For uncompressed data, check MZ signature directly
if matches!(fmt.compression_info, CompressionInfo::None) {
test_block.len() >= 2 && test_block[0] == 0x4D && test_block[1] == 0x5A
} else {
// For compressed data, any successful decryption might be valid.
// Accept the key if the decrypted data isn't all zeros or all 0xFF.
!test_block.iter().all(|&b| b == 0)
&& !test_block.iter().all(|&b| b == 0xFF)
}
},
)?;
crypto::decrypt_in_place(&session_key, &mut payload);
}
// Step 2: Decompress based on compression type
let pe_image = match &fmt.compression_info {
CompressionInfo::None => decompress::decompress_none(&payload),
CompressionInfo::Basic { blocks } => decompress::decompress_basic(&payload, blocks)?,
CompressionInfo::Normal {
window_size,
first_block,
} => decompress::decompress_normal(
&payload,
*window_size,
first_block,
xex.security_info.image_size,
)?,
CompressionInfo::Delta => {
return Err(Xex2Error::DecompressionFailed(
"delta compression is not supported".into(),
));
}
};
// Step 3: Verify PE headers
verify_pe_image(&pe_image)?;
Ok(pe_image)
}
/// Verifies that the extracted data is a valid PE image.
///
/// Checks:
/// - MZ (DOS) signature at offset 0
/// - PE signature at e_lfanew offset
/// - Machine type is POWERPCBE (0x01F2)
pub fn verify_pe_image(pe_data: &[u8]) -> Result<()> {
if pe_data.len() < 0x40 {
return Err(Xex2Error::InvalidPeImage(
"PE image too small for DOS header".into(),
));
}
// Check MZ signature (little-endian per PE spec)
let mz = u16::from_le_bytes([pe_data[0], pe_data[1]]);
if mz != MZ_SIGNATURE {
return Err(Xex2Error::InvalidPeImage(format!(
"invalid MZ signature: 0x{mz:04X} (expected 0x{MZ_SIGNATURE:04X})"
)));
}
// Read e_lfanew (little-endian per PE spec)
let e_lfanew = u32::from_le_bytes([
pe_data[0x3C],
pe_data[0x3D],
pe_data[0x3E],
pe_data[0x3F],
]) as usize;
if e_lfanew + 6 > pe_data.len() {
return Err(Xex2Error::InvalidPeImage(format!(
"e_lfanew (0x{e_lfanew:X}) points past end of image"
)));
}
// Check PE signature
let pe_sig = u32::from_le_bytes([
pe_data[e_lfanew],
pe_data[e_lfanew + 1],
pe_data[e_lfanew + 2],
pe_data[e_lfanew + 3],
]);
if pe_sig != PE_SIGNATURE {
return Err(Xex2Error::InvalidPeImage(format!(
"invalid PE signature: 0x{pe_sig:08X} (expected 0x{PE_SIGNATURE:08X})"
)));
}
// Check machine type (little-endian per PE spec)
let machine = u16::from_le_bytes([pe_data[e_lfanew + 4], pe_data[e_lfanew + 5]]);
if machine != MACHINE_POWERPCBE {
return Err(Xex2Error::InvalidPeImage(format!(
"unexpected machine type: 0x{machine:04X} (expected 0x{MACHINE_POWERPCBE:04X} POWERPCBE)"
)));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_verify_pe_image_too_small() {
let data = vec![0x4D, 0x5A]; // MZ but too short
assert!(verify_pe_image(&data).is_err());
}
#[test]
fn test_verify_pe_image_bad_mz() {
let mut data = vec![0u8; 0x100];
data[0] = 0x00; // Not MZ
assert!(verify_pe_image(&data).is_err());
}
#[test]
fn test_verify_pe_image_bad_pe_sig() {
let mut data = vec![0u8; 0x200];
data[0] = 0x4D;
data[1] = 0x5A; // MZ
// e_lfanew = 0x80 (little-endian)
data[0x3C] = 0x80;
// No PE signature at 0x80
assert!(verify_pe_image(&data).is_err());
}
#[test]
fn test_verify_pe_image_valid() {
let mut data = vec![0u8; 0x200];
data[0] = 0x4D;
data[1] = 0x5A; // MZ
data[0x3C] = 0x80; // e_lfanew = 0x80
// PE signature at 0x80
data[0x80] = b'P';
data[0x81] = b'E';
data[0x82] = 0;
data[0x83] = 0;
// Machine = 0x01F2 (little-endian: F2 01)
data[0x84] = 0xF2;
data[0x85] = 0x01;
assert!(verify_pe_image(&data).is_ok());
}
#[test]
fn test_verify_pe_image_wrong_machine() {
let mut data = vec![0u8; 0x200];
data[0] = 0x4D;
data[1] = 0x5A;
data[0x3C] = 0x80;
data[0x80] = b'P';
data[0x81] = b'E';
data[0x82] = 0;
data[0x83] = 0;
data[0x84] = 0x4C; // x86 machine type
data[0x85] = 0x01;
assert!(verify_pe_image(&data).is_err());
}
}

View File

@@ -6,8 +6,11 @@
//! provides types and functions to parse the binary format and extract //! provides types and functions to parse the binary format and extract
//! structured information from XEX2 files. //! structured information from XEX2 files.
pub mod crypto;
pub mod decompress;
pub mod display; pub mod display;
pub mod error; pub mod error;
pub mod extract;
pub mod header; pub mod header;
pub mod optional; pub mod optional;
pub mod security; pub mod security;

View File

@@ -1,31 +1,97 @@
use clap::{Parser, Subcommand};
use std::path::PathBuf;
use std::process; use std::process;
/// A tool for extracting and inspecting Xbox 360 XEX2 executable files.
#[derive(Parser)]
#[command(name = "xex2tractor", version, about)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
/// Display XEX2 file information (headers, security info, etc.)
Inspect {
/// Path to the XEX2 file
file: PathBuf,
},
/// Extract the PE image from a XEX2 file
Extract {
/// Path to the XEX2 file
file: PathBuf,
/// Output path for the extracted PE file (default: same name with .exe extension)
output: Option<PathBuf>,
},
}
fn main() { fn main() {
let path = match std::env::args().nth(1) { let cli = Cli::parse();
Some(p) => p,
None => {
eprintln!("Usage: xex2tractor <file.xex>");
process::exit(1);
}
};
let data = match std::fs::read(&path) { match cli.command {
Ok(d) => d, Command::Inspect { file } => cmd_inspect(&file),
Err(e) => { Command::Extract { file, output } => cmd_extract(&file, output),
eprintln!("Error reading {path}: {e}"); }
process::exit(1);
} }
};
let xex = match xex2tractor::parse(&data) { fn cmd_inspect(path: &PathBuf) {
Ok(x) => x, let data = read_file(path);
Err(e) => { let xex = parse_xex(&data);
eprintln!("Error parsing XEX2: {e}");
process::exit(1);
}
};
xex2tractor::display::display_header(&xex.header); xex2tractor::display::display_header(&xex.header);
xex2tractor::display::display_optional_headers(&xex.optional_headers); xex2tractor::display::display_optional_headers(&xex.optional_headers);
xex2tractor::display::display_security_info(&xex.security_info); xex2tractor::display::display_security_info(&xex.security_info);
} }
fn cmd_extract(path: &PathBuf, output: Option<PathBuf>) {
let output_path = output.unwrap_or_else(|| path.with_extension("exe"));
let data = read_file(path);
let xex = parse_xex(&data);
// Display encryption/compression info
if let Some(ref fmt) = xex.optional_headers.file_format_info {
println!("Encryption: {}", fmt.encryption_type);
println!("Compression: {}", fmt.compression_type);
}
let pe_image = match xex2tractor::extract::extract_pe_image(&data, &xex) {
Ok(img) => img,
Err(e) => {
eprintln!("Error extracting PE image: {e}");
process::exit(1);
}
};
if let Err(e) = std::fs::write(&output_path, &pe_image) {
eprintln!("Error writing {}: {e}", output_path.display());
process::exit(1);
}
println!(
"Extracted PE image ({} bytes) -> {}",
pe_image.len(),
output_path.display()
);
}
fn read_file(path: &PathBuf) -> Vec<u8> {
match std::fs::read(path) {
Ok(d) => d,
Err(e) => {
eprintln!("Error reading {}: {e}", path.display());
process::exit(1);
}
}
}
fn parse_xex(data: &[u8]) -> xex2tractor::Xex2File {
match xex2tractor::parse(data) {
Ok(x) => x,
Err(e) => {
eprintln!("Error parsing XEX2: {e}");
process::exit(1);
}
}
}

View File

@@ -8,7 +8,7 @@ use std::fmt;
use crate::error::{Result, Xex2Error}; use crate::error::{Result, Xex2Error};
use crate::header::Xex2Header; use crate::header::Xex2Header;
use crate::util::{read_u16_be, read_u32_be, read_u8}; use crate::util::{read_bytes, read_u16_be, read_u32_be, read_u8};
// ── Header Key Constants ────────────────────────────────────────────────────── // ── Header Key Constants ──────────────────────────────────────────────────────
@@ -277,11 +277,43 @@ impl fmt::Display for CompressionType {
} }
} }
/// A single block descriptor for basic (zero-fill) compression.
#[derive(Debug, Clone)]
pub struct BasicCompressionBlock {
/// Bytes of real data to copy from the payload.
pub data_size: u32,
/// Bytes of zeros to append after the data.
pub zero_size: u32,
}
/// Block info for normal (LZX) compression — size + SHA-1 hash of the block.
#[derive(Debug, Clone)]
pub struct CompressedBlockInfo {
pub block_size: u32,
pub block_hash: [u8; 20],
}
/// Compression-specific metadata parsed from the file format info structure.
#[derive(Debug, Clone)]
pub enum CompressionInfo {
None,
Basic {
blocks: Vec<BasicCompressionBlock>,
},
Normal {
window_size: u32,
first_block: CompressedBlockInfo,
},
Delta,
}
/// File format info — encryption and compression settings. /// File format info — encryption and compression settings.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct FileFormatInfo { pub struct FileFormatInfo {
pub encryption_type: EncryptionType, pub encryption_type: EncryptionType,
pub compression_type: CompressionType, pub compression_type: CompressionType,
/// Compression-specific metadata (block descriptors, window size, etc.)
pub compression_info: CompressionInfo,
} }
/// Checksum and build timestamp. /// Checksum and build timestamp.
@@ -682,7 +714,7 @@ fn parse_execution_info(data: &[u8], offset: usize) -> Result<ExecutionInfo> {
} }
fn parse_file_format_info(data: &[u8], offset: usize) -> Result<FileFormatInfo> { fn parse_file_format_info(data: &[u8], offset: usize) -> Result<FileFormatInfo> {
// Skip the 4-byte info_size field let info_size = read_u32_be(data, offset)?;
let encryption_raw = read_u16_be(data, offset + 0x04)?; let encryption_raw = read_u16_be(data, offset + 0x04)?;
let compression_raw = read_u16_be(data, offset + 0x06)?; let compression_raw = read_u16_be(data, offset + 0x06)?;
@@ -700,9 +732,44 @@ fn parse_file_format_info(data: &[u8], offset: usize) -> Result<FileFormatInfo>
v => CompressionType::Unknown(v), v => CompressionType::Unknown(v),
}; };
// Parse compression-specific metadata starting at offset + 0x08
let comp_offset = offset + 0x08;
let compression_info = match compression_type {
CompressionType::None => CompressionInfo::None,
CompressionType::Basic => {
// Block count = (info_size - 8) / 8
let block_count = (info_size.saturating_sub(8)) / 8;
let mut blocks = Vec::with_capacity(block_count as usize);
for i in 0..block_count as usize {
let bo = comp_offset + i * 8;
blocks.push(BasicCompressionBlock {
data_size: read_u32_be(data, bo)?,
zero_size: read_u32_be(data, bo + 4)?,
});
}
CompressionInfo::Basic { blocks }
}
CompressionType::Normal => {
let window_size = read_u32_be(data, comp_offset)?;
let block_size = read_u32_be(data, comp_offset + 4)?;
let block_hash_bytes = read_bytes(data, comp_offset + 8, 20)?;
let mut block_hash = [0u8; 20];
block_hash.copy_from_slice(block_hash_bytes);
CompressionInfo::Normal {
window_size,
first_block: CompressedBlockInfo {
block_size,
block_hash,
},
}
}
CompressionType::Delta | CompressionType::Unknown(_) => CompressionInfo::Delta,
};
Ok(FileFormatInfo { Ok(FileFormatInfo {
encryption_type, encryption_type,
compression_type, compression_type,
compression_info,
}) })
} }

View File

@@ -1,5 +1,7 @@
use xex2tractor::crypto;
use xex2tractor::extract;
use xex2tractor::header::{ModuleFlags, XEX2_MAGIC}; use xex2tractor::header::{ModuleFlags, XEX2_MAGIC};
use xex2tractor::optional::{CompressionType, EncryptionType, SystemFlags}; use xex2tractor::optional::{CompressionInfo, CompressionType, EncryptionType, SystemFlags};
use xex2tractor::security::{ImageFlags, MediaFlags, RegionFlags}; use xex2tractor::security::{ImageFlags, MediaFlags, RegionFlags};
fn sample_data() -> Vec<u8> { fn sample_data() -> Vec<u8> {
@@ -185,17 +187,74 @@ fn test_security_crypto_fields() {
assert_eq!(sec.aes_key[0..2], [0xEA, 0xCB]); assert_eq!(sec.aes_key[0..2], [0xEA, 0xCB]);
} }
// ── Compression info tests ────────────────────────────────────────────────────
#[test]
fn test_compression_info_normal() {
let data = sample_data();
let xex = xex2tractor::parse(&data).unwrap();
let fmt = xex.optional_headers.file_format_info.as_ref().unwrap();
match &fmt.compression_info {
CompressionInfo::Normal {
window_size,
first_block,
} => {
// Window size should be a power of 2
assert!(window_size.is_power_of_two(), "window_size should be power of 2");
assert!(*window_size > 0);
// First block should have non-zero size
assert!(first_block.block_size > 0);
// Block hash should not be all zeros
assert!(!first_block.block_hash.iter().all(|&b| b == 0));
}
other => panic!("expected Normal compression info, got {other:?}"),
}
}
// ── Crypto tests ─────────────────────────────────────────────────────────────
#[test]
fn test_session_key_derivation() {
let data = sample_data();
let xex = xex2tractor::parse(&data).unwrap();
let session_key = crypto::derive_session_key(&xex.security_info.aes_key);
// Session key should be non-zero
assert!(!session_key.iter().all(|&b| b == 0));
// Session key should differ from encrypted key
assert_ne!(&session_key[..], &xex.security_info.aes_key[..]);
}
#[test]
fn test_payload_decryption() {
let data = sample_data();
let xex = xex2tractor::parse(&data).unwrap();
let session_key = crypto::derive_session_key(&xex.security_info.aes_key);
// Decrypt the first 256 bytes of payload
let payload_start = xex.header.header_size as usize;
let mut payload_head = data[payload_start..payload_start + 256].to_vec();
let original = payload_head.clone();
crypto::decrypt_in_place(&session_key, &mut payload_head);
// Decrypted data should differ from encrypted
assert_ne!(payload_head, original);
}
// ── CLI tests ───────────────────────────────────────────────────────────────── // ── CLI tests ─────────────────────────────────────────────────────────────────
#[test] #[test]
fn test_cli_runs_with_sample() { fn test_cli_inspect_with_sample() {
let path = format!("{}/tests/data/default.xex", env!("CARGO_MANIFEST_DIR")); let path = format!("{}/tests/data/default.xex", env!("CARGO_MANIFEST_DIR"));
let output = std::process::Command::new(env!("CARGO_BIN_EXE_xex2tractor")) let output = std::process::Command::new(env!("CARGO_BIN_EXE_xex2tractor"))
.arg(&path) .args(["inspect", &path])
.output() .output()
.expect("failed to run xex2tractor"); .expect("failed to run xex2tractor");
assert!(output.status.success(), "CLI should exit successfully"); assert!(output.status.success(), "CLI inspect should exit successfully");
let stdout = String::from_utf8_lossy(&output.stdout); let stdout = String::from_utf8_lossy(&output.stdout);
// Header section // Header section
@@ -213,6 +272,8 @@ fn test_cli_runs_with_sample() {
assert!(stdout.contains("FILE_FORMAT_INFO")); assert!(stdout.contains("FILE_FORMAT_INFO"));
assert!(stdout.contains("Normal (AES-128-CBC)")); assert!(stdout.contains("Normal (AES-128-CBC)"));
assert!(stdout.contains("Normal (LZX)")); assert!(stdout.contains("Normal (LZX)"));
assert!(stdout.contains("Window Size:")); // new compression info
assert!(stdout.contains("First Block:")); // new compression info
assert!(stdout.contains("STATIC_LIBRARIES")); assert!(stdout.contains("STATIC_LIBRARIES"));
assert!(stdout.contains("XAPILIB")); assert!(stdout.contains("XAPILIB"));
assert!(stdout.contains("IMPORT_LIBRARIES")); assert!(stdout.contains("IMPORT_LIBRARIES"));
@@ -242,11 +303,140 @@ fn test_cli_no_args() {
} }
#[test] #[test]
fn test_cli_missing_file() { fn test_cli_inspect_missing_file() {
let output = std::process::Command::new(env!("CARGO_BIN_EXE_xex2tractor")) let output = std::process::Command::new(env!("CARGO_BIN_EXE_xex2tractor"))
.arg("/nonexistent/file.xex") .args(["inspect", "/nonexistent/file.xex"])
.output() .output()
.expect("failed to run xex2tractor"); .expect("failed to run xex2tractor");
assert!(!output.status.success()); assert!(!output.status.success());
} }
// ── Extraction tests ─────────────────────────────────────────────────────────
#[test]
fn test_extract_pe_image() {
let data = sample_data();
let xex = xex2tractor::parse(&data).unwrap();
let pe_image = extract::extract_pe_image(&data, &xex).unwrap();
// Output size should match security_info.image_size
assert_eq!(pe_image.len(), xex.security_info.image_size as usize);
}
#[test]
fn test_extract_pe_starts_with_mz() {
let data = sample_data();
let xex = xex2tractor::parse(&data).unwrap();
let pe_image = extract::extract_pe_image(&data, &xex).unwrap();
// PE image must start with MZ signature (0x4D5A)
assert_eq!(pe_image[0], 0x4D, "first byte should be 'M'");
assert_eq!(pe_image[1], 0x5A, "second byte should be 'Z'");
}
#[test]
fn test_extract_pe_has_valid_pe_header() {
let data = sample_data();
let xex = xex2tractor::parse(&data).unwrap();
let pe_image = extract::extract_pe_image(&data, &xex).unwrap();
// Read e_lfanew from DOS header (offset 0x3C, little-endian per PE spec)
let e_lfanew = u32::from_le_bytes([
pe_image[0x3C],
pe_image[0x3D],
pe_image[0x3E],
pe_image[0x3F],
]) as usize;
// PE signature at e_lfanew: "PE\0\0"
assert_eq!(&pe_image[e_lfanew..e_lfanew + 4], b"PE\0\0");
// Machine type at e_lfanew + 4: 0x01F2 (IMAGE_FILE_MACHINE_POWERPCBE, little-endian)
let machine = u16::from_le_bytes([pe_image[e_lfanew + 4], pe_image[e_lfanew + 5]]);
assert_eq!(machine, 0x01F2, "machine should be POWERPCBE");
}
#[test]
fn test_cli_extract_writes_file() {
let path = format!("{}/tests/data/default.xex", env!("CARGO_MANIFEST_DIR"));
let output_path = format!("{}/target/test_extract_output.exe", env!("CARGO_MANIFEST_DIR"));
// Clean up any previous test output
let _ = std::fs::remove_file(&output_path);
let output = std::process::Command::new(env!("CARGO_BIN_EXE_xex2tractor"))
.args(["extract", &path, &output_path])
.output()
.expect("failed to run xex2tractor");
assert!(output.status.success(), "CLI extract should succeed");
// Verify output file exists and starts with MZ
let extracted = std::fs::read(&output_path).expect("should be able to read extracted file");
assert!(extracted.len() > 2);
assert_eq!(extracted[0], 0x4D); // 'M'
assert_eq!(extracted[1], 0x5A); // 'Z'
// Clean up
let _ = std::fs::remove_file(&output_path);
}
#[test]
fn test_cli_extract_default_output_path() {
let path = format!("{}/tests/data/default.xex", env!("CARGO_MANIFEST_DIR"));
let expected_output = format!("{}/tests/data/default.exe", env!("CARGO_MANIFEST_DIR"));
// Clean up any previous test output
let _ = std::fs::remove_file(&expected_output);
let output = std::process::Command::new(env!("CARGO_BIN_EXE_xex2tractor"))
.args(["extract", &path])
.output()
.expect("failed to run xex2tractor");
assert!(output.status.success(), "CLI extract should succeed");
// Verify default output path was used
assert!(
std::fs::metadata(&expected_output).is_ok(),
"default output file should exist"
);
// Clean up
let _ = std::fs::remove_file(&expected_output);
}
#[test]
fn test_extract_pe_verification_runs() {
let data = sample_data();
let xex = xex2tractor::parse(&data).unwrap();
let pe_image = extract::extract_pe_image(&data, &xex).unwrap();
// verify_pe_image should succeed on the extracted image
assert!(extract::verify_pe_image(&pe_image).is_ok());
}
#[test]
fn test_cli_extract_shows_format_info() {
let path = format!("{}/tests/data/default.xex", env!("CARGO_MANIFEST_DIR"));
let output_path = format!("{}/target/test_extract_info.exe", env!("CARGO_MANIFEST_DIR"));
let _ = std::fs::remove_file(&output_path);
let output = std::process::Command::new(env!("CARGO_BIN_EXE_xex2tractor"))
.args(["extract", &path, &output_path])
.output()
.expect("failed to run xex2tractor");
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("Encryption:"));
assert!(stdout.contains("Compression:"));
assert!(stdout.contains("Extracted PE image"));
let _ = std::fs::remove_file(&output_path);
}