9 Commits

Author SHA1 Message Date
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
MechaCat02
38d1cc1b6d Merge feature/m3-security-info: parse and display security info 2026-03-28 19:04:48 +01:00
MechaCat02
66e078363c feat: parse and display security info (M3)
Implement security info parsing including RSA signature, encrypted AES
key, image/region/media flags, load address, SHA-1 digests, and page
descriptors with section type classification. Add comprehensive unit
and integration tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 19:04:41 +01:00
13 changed files with 1969 additions and 32 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.2.0" version = "0.6.0"
dependencies = [
"aes",
"cbc",
"clap",
"lzxd",
]

View File

@@ -1,8 +1,12 @@
[package] [package]
name = "xex2tractor" name = "xex2tractor"
version = "0.2.0" version = "0.6.0"
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,17 +39,44 @@ 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) === Security Info ===
xam.xex v2.0.4552.0 (min v2.0.4552.0) - 104 imports Header Size: 0x00000F34 (3892 bytes)
xboxkrnl.exe v2.0.4552.0 (min v2.0.4552.0) - 294 imports Image Size: 0x00920000 (9568256 bytes)
RSA Signature: 2C94EBE6...11A6E8AA (256 bytes)
Image Flags: 0x00000008 [XGD2_MEDIA_ONLY]
Load Address: 0x82000000
Region: 0xFFFFFFFF [ALL REGIONS]
Allowed Media Types: 0x00000004 [DVD_CD]
...
``` ```
### 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,8 +1,9 @@
/// Pretty-print formatting for parsed XEX2 structures. /// Pretty-print formatting for parsed XEX2 structures.
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;
/// Prints the XEX2 main header in a human-readable format. /// Prints the XEX2 main header in a human-readable format.
pub fn display_header(header: &Xex2Header) { pub fn display_header(header: &Xex2Header) {
@@ -78,6 +79,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
@@ -200,3 +220,91 @@ pub fn display_optional_headers(headers: &OptionalHeaders) {
} }
} }
} }
/// Prints the security info in a human-readable format.
pub fn display_security_info(security: &SecurityInfo) {
println!();
println!("=== Security Info ===");
println!(
"Header Size: 0x{:08X} ({} bytes)",
security.header_size, security.header_size
);
println!(
"Image Size: 0x{:08X} ({} bytes)",
security.image_size, security.image_size
);
// RSA signature — show first 8 and last 8 bytes
let sig = &security.rsa_signature;
println!(
"RSA Signature: {}...{} (256 bytes)",
sig[..4].iter().map(|b| format!("{b:02X}")).collect::<String>(),
sig[252..].iter().map(|b| format!("{b:02X}")).collect::<String>()
);
println!("Unknown (0x108): 0x{:08X}", security.unk_108);
println!("Image Flags: {}", security.image_flags);
println!("Load Address: 0x{:08X}", security.load_address);
println!(
"Section Digest: {}",
format_hex_bytes(&security.section_digest)
);
println!("Import Table Count: {}", security.import_table_count);
println!(
"Import Table Digest: {}",
format_hex_bytes(&security.import_table_digest)
);
println!(
"XGD2 Media ID: {}",
security
.xgd2_media_id
.iter()
.map(|b| format!("{b:02X}"))
.collect::<String>()
);
println!(
"AES Key (encrypted): {}",
format_hex_bytes(&security.aes_key)
);
if security.export_table == 0 {
println!("Export Table: 0x00000000 (none)");
} else {
println!("Export Table: 0x{:08X}", security.export_table);
}
println!(
"Header Digest: {}",
format_hex_bytes(&security.header_digest)
);
println!("Region: {}", security.region);
println!("Allowed Media Types: {}", security.allowed_media_types);
// Page descriptors
println!();
let page_size = security.image_flags.page_size();
let page_size_label = if page_size == 0x1000 { "4KB" } else { "64KB" };
println!(
"Page Descriptors ({} entries, {} pages):",
security.page_descriptor_count, page_size_label
);
let mut address_offset: u64 = 0;
for (i, desc) in security.page_descriptors.iter().enumerate() {
let digest_preview: String = desc.data_digest[..6]
.iter()
.map(|b| format!("{b:02X}"))
.collect();
let size = desc.page_count as u64 * page_size as u64;
println!(
" #{i:<4} {:<10} {:<4} pages ({:>8} bytes) offset +0x{address_offset:08X} SHA1: {digest_preview}...",
desc.section_type.to_string(),
desc.page_count,
size
);
address_offset += size;
}
println!(
" Total mapped size: 0x{address_offset:X} ({address_offset} bytes)"
);
}

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,15 +6,20 @@
//! 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 util; pub mod util;
use error::Result; use error::Result;
use header::Xex2Header; use header::Xex2Header;
use optional::OptionalHeaders; use optional::OptionalHeaders;
use security::SecurityInfo;
/// A parsed XEX2 file containing all extracted structures. /// A parsed XEX2 file containing all extracted structures.
#[derive(Debug)] #[derive(Debug)]
@@ -23,6 +28,8 @@ pub struct Xex2File {
pub header: Xex2Header, pub header: Xex2Header,
/// All parsed optional headers. /// All parsed optional headers.
pub optional_headers: OptionalHeaders, pub optional_headers: OptionalHeaders,
/// Security info (signatures, keys, page descriptors).
pub security_info: SecurityInfo,
} }
/// Parses an XEX2 file from a byte slice. /// Parses an XEX2 file from a byte slice.
@@ -32,9 +39,11 @@ pub struct Xex2File {
pub fn parse(data: &[u8]) -> Result<Xex2File> { pub fn parse(data: &[u8]) -> Result<Xex2File> {
let header = header::parse_header(data)?; let header = header::parse_header(data)?;
let optional_headers = optional::parse_optional_headers(data, &header)?; let optional_headers = optional::parse_optional_headers(data, &header)?;
let security_info = security::parse_security_info(data, header.security_offset)?;
Ok(Xex2File { Ok(Xex2File {
header, header,
optional_headers, optional_headers,
security_info,
}) })
} }

View File

@@ -1,30 +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 => { match cli.command {
eprintln!("Usage: xex2tractor <file.xex>"); Command::Inspect { file } => cmd_inspect(&file),
Command::Extract { file, output } => cmd_extract(&file, output),
}
}
fn cmd_inspect(path: &PathBuf) {
let data = read_file(path);
let xex = parse_xex(&data);
xex2tractor::display::display_header(&xex.header);
xex2tractor::display::display_optional_headers(&xex.optional_headers);
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); process::exit(1);
} }
}; };
let data = match std::fs::read(&path) { 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, Ok(d) => d,
Err(e) => { Err(e) => {
eprintln!("Error reading {path}: {e}"); eprintln!("Error reading {}: {e}", path.display());
process::exit(1); process::exit(1);
} }
}; }
}
let xex = match xex2tractor::parse(&data) { fn parse_xex(data: &[u8]) -> xex2tractor::Xex2File {
match xex2tractor::parse(data) {
Ok(x) => x, Ok(x) => x,
Err(e) => { Err(e) => {
eprintln!("Error parsing XEX2: {e}"); eprintln!("Error parsing XEX2: {e}");
process::exit(1); process::exit(1);
} }
}; }
xex2tractor::display::display_header(&xex.header);
xex2tractor::display::display_optional_headers(&xex.optional_headers);
} }

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,
}) })
} }

510
src/security.rs Normal file
View File

@@ -0,0 +1,510 @@
/// XEX2 security info parsing.
///
/// The security info structure is located at the file offset specified by
/// `xex2_header.security_offset` and contains cryptographic signatures,
/// encryption keys, memory layout information, and per-page integrity digests.
use std::fmt;
use crate::error::{Result, Xex2Error};
use crate::util::{read_bytes, read_u32_be};
// ── Security Info ─────────────────────────────────────────────────────────────
/// The parsed XEX2 security info structure.
#[derive(Debug, Clone)]
pub struct SecurityInfo {
/// Size of this security info structure in bytes.
pub header_size: u32,
/// Size of the decompressed PE image in bytes.
pub image_size: u32,
/// RSA-2048 signature over the header (256 bytes).
pub rsa_signature: [u8; 256],
/// Unknown field at offset 0x108 (often a length value).
pub unk_108: u32,
/// Image flags bitmask.
pub image_flags: ImageFlags,
/// Virtual memory address where the PE image is loaded.
pub load_address: u32,
/// SHA-1 digest of section data (20 bytes).
pub section_digest: [u8; 20],
/// Number of import table entries.
pub import_table_count: u32,
/// SHA-1 digest of the import table (20 bytes).
pub import_table_digest: [u8; 20],
/// XGD2 media identifier (16 bytes).
pub xgd2_media_id: [u8; 16],
/// Encrypted AES-128 session key (16 bytes).
pub aes_key: [u8; 16],
/// Memory address of the XEX export table (0 if none).
pub export_table: u32,
/// SHA-1 digest of header data (20 bytes).
pub header_digest: [u8; 20],
/// Allowed regions bitmask.
pub region: RegionFlags,
/// Allowed media types bitmask.
pub allowed_media_types: MediaFlags,
/// Number of page descriptors following.
pub page_descriptor_count: u32,
/// Per-page descriptors with section types and integrity digests.
pub page_descriptors: Vec<PageDescriptor>,
}
/// Parses the security info structure from `data` at the given file `offset`.
pub fn parse_security_info(data: &[u8], offset: u32) -> Result<SecurityInfo> {
let off = offset as usize;
// Minimum size: fixed fields up to page_descriptor_count (0x184 bytes)
let min_size = off + 0x184;
if min_size > data.len() {
return Err(Xex2Error::InvalidOffset {
name: "security_info",
offset,
file_size: data.len(),
});
}
let header_size = read_u32_be(data, off)?;
let image_size = read_u32_be(data, off + 0x004)?;
let mut rsa_signature = [0u8; 256];
rsa_signature.copy_from_slice(read_bytes(data, off + 0x008, 256)?);
let unk_108 = read_u32_be(data, off + 0x108)?;
let image_flags = ImageFlags(read_u32_be(data, off + 0x10C)?);
let load_address = read_u32_be(data, off + 0x110)?;
let mut section_digest = [0u8; 20];
section_digest.copy_from_slice(read_bytes(data, off + 0x114, 20)?);
let import_table_count = read_u32_be(data, off + 0x128)?;
let mut import_table_digest = [0u8; 20];
import_table_digest.copy_from_slice(read_bytes(data, off + 0x12C, 20)?);
let mut xgd2_media_id = [0u8; 16];
xgd2_media_id.copy_from_slice(read_bytes(data, off + 0x140, 16)?);
let mut aes_key = [0u8; 16];
aes_key.copy_from_slice(read_bytes(data, off + 0x150, 16)?);
let export_table = read_u32_be(data, off + 0x160)?;
let mut header_digest = [0u8; 20];
header_digest.copy_from_slice(read_bytes(data, off + 0x164, 20)?);
let region = RegionFlags(read_u32_be(data, off + 0x178)?);
let allowed_media_types = MediaFlags(read_u32_be(data, off + 0x17C)?);
let page_descriptor_count = read_u32_be(data, off + 0x180)?;
// Parse page descriptors (24 bytes each, starting at offset + 0x184)
let desc_start = off + 0x184;
let desc_total = page_descriptor_count as usize * 24;
if desc_start + desc_total > data.len() {
return Err(Xex2Error::FileTooSmall {
expected: desc_start + desc_total,
actual: data.len(),
});
}
let mut page_descriptors = Vec::with_capacity(page_descriptor_count as usize);
for i in 0..page_descriptor_count as usize {
let base = desc_start + i * 24;
let value = read_u32_be(data, base)?;
let info = ((value >> 28) & 0xF) as u8;
let page_count = value & 0x0FFFFFFF;
let mut data_digest = [0u8; 20];
data_digest.copy_from_slice(read_bytes(data, base + 4, 20)?);
page_descriptors.push(PageDescriptor {
section_type: SectionType::from_raw(info),
page_count,
data_digest,
});
}
Ok(SecurityInfo {
header_size,
image_size,
rsa_signature,
unk_108,
image_flags,
load_address,
section_digest,
import_table_count,
import_table_digest,
xgd2_media_id,
aes_key,
export_table,
header_digest,
region,
allowed_media_types,
page_descriptor_count,
page_descriptors,
})
}
// ── Image Flags ───────────────────────────────────────────────────────────────
/// Image flags bitmask from the security info.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ImageFlags(pub u32);
impl ImageFlags {
const FLAGS: &[(u32, &str)] = &[
(0x00000002, "MANUFACTURING_UTILITY"),
(0x00000004, "MANUFACTURING_SUPPORT_TOOLS"),
(0x00000008, "XGD2_MEDIA_ONLY"),
(0x00000100, "CARDEA_KEY"),
(0x00000200, "XEIKA_KEY"),
(0x00000400, "USERMODE_TITLE"),
(0x00000800, "USERMODE_SYSTEM"),
(0x10000000, "PAGE_SIZE_4KB"),
(0x20000000, "REGION_FREE"),
(0x40000000, "REVOCATION_CHECK_OPTIONAL"),
(0x80000000, "REVOCATION_CHECK_REQUIRED"),
];
/// Returns a list of human-readable flag names that are set.
pub fn flag_names(self) -> Vec<&'static str> {
Self::FLAGS
.iter()
.filter(|(bit, _)| self.0 & bit != 0)
.map(|(_, name)| *name)
.collect()
}
/// Returns `true` if the 4KB page size flag is set (otherwise 64KB).
pub fn is_4kb_pages(self) -> bool {
self.0 & 0x10000000 != 0
}
/// Returns the page size in bytes based on the page size flag.
pub fn page_size(self) -> u32 {
if self.is_4kb_pages() {
0x1000
} else {
0x10000
}
}
}
impl fmt::Display for ImageFlags {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let names = self.flag_names();
if names.is_empty() {
write!(f, "0x{:08X}", self.0)
} else {
write!(f, "0x{:08X} [{}]", self.0, names.join(", "))
}
}
}
// ── Region Flags ──────────────────────────────────────────────────────────────
/// Allowed region flags bitmask.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RegionFlags(pub u32);
impl RegionFlags {
const NTSCU: u32 = 0x000000FF;
const NTSCJ: u32 = 0x0000FF00;
const NTSCJ_JAPAN: u32 = 0x00000100;
const NTSCJ_CHINA: u32 = 0x00000200;
const PAL: u32 = 0x00FF0000;
const PAL_AU_NZ: u32 = 0x00010000;
const OTHER: u32 = 0xFF000000;
const ALL: u32 = 0xFFFFFFFF;
/// Returns a human-readable description of the active region flags.
pub fn description(self) -> String {
if self.0 == Self::ALL {
return "ALL REGIONS".to_string();
}
if self.0 == 0 {
return "NONE".to_string();
}
let mut regions = Vec::new();
if self.0 & Self::NTSCU != 0 {
regions.push("NTSC/U");
}
if self.0 & Self::NTSCJ_JAPAN != 0 {
regions.push("NTSC/J-Japan");
}
if self.0 & Self::NTSCJ_CHINA != 0 {
regions.push("NTSC/J-China");
}
// Only show generic NTSC/J if specific bits aren't set but region is
if self.0 & Self::NTSCJ != 0
&& self.0 & Self::NTSCJ_JAPAN == 0
&& self.0 & Self::NTSCJ_CHINA == 0
{
regions.push("NTSC/J");
}
if self.0 & Self::PAL_AU_NZ != 0 {
regions.push("PAL-AU/NZ");
}
// Only show generic PAL if specific bits aren't set but region is
if self.0 & Self::PAL != 0 && self.0 & Self::PAL_AU_NZ == 0 {
regions.push("PAL");
}
if self.0 & Self::OTHER != 0 {
regions.push("Other");
}
regions.join(", ")
}
}
impl fmt::Display for RegionFlags {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "0x{:08X} [{}]", self.0, self.description())
}
}
// ── Media Flags ───────────────────────────────────────────────────────────────
/// Allowed media types bitmask.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MediaFlags(pub u32);
impl MediaFlags {
const FLAGS: &[(u32, &str)] = &[
(0x00000001, "HARD_DISK"),
(0x00000002, "DVD_X2"),
(0x00000004, "DVD_CD"),
(0x00000008, "DVD_5"),
(0x00000010, "DVD_9"),
(0x00000020, "SYSTEM_FLASH"),
(0x00000080, "MEMORY_UNIT"),
(0x00000100, "USB_MASS_STORAGE"),
(0x00000200, "NETWORK"),
(0x00000400, "DIRECT_FROM_MEMORY"),
(0x00000800, "RAM_DRIVE"),
(0x00001000, "SVOD"),
(0x01000000, "INSECURE_PACKAGE"),
(0x02000000, "SAVEGAME_PACKAGE"),
(0x04000000, "LOCALLY_SIGNED_PACKAGE"),
(0x08000000, "LIVE_SIGNED_PACKAGE"),
(0x10000000, "XBOX_PACKAGE"),
];
/// Returns a list of human-readable flag names that are set.
pub fn flag_names(self) -> Vec<&'static str> {
Self::FLAGS
.iter()
.filter(|(bit, _)| self.0 & bit != 0)
.map(|(_, name)| *name)
.collect()
}
}
impl fmt::Display for MediaFlags {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let names = self.flag_names();
if names.is_empty() {
write!(f, "0x{:08X}", self.0)
} else {
write!(f, "0x{:08X} [{}]", self.0, names.join(", "))
}
}
}
// ── Page Descriptor ───────────────────────────────────────────────────────────
/// A single page descriptor with section type, page count, and SHA-1 digest.
#[derive(Debug, Clone)]
pub struct PageDescriptor {
/// The section type (code, data, read-only data, or unknown).
pub section_type: SectionType,
/// Number of pages in this section.
pub page_count: u32,
/// SHA-1 hash of the page data (20 bytes).
pub data_digest: [u8; 20],
}
/// Section type from the page descriptor info bits.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SectionType {
Code,
Data,
ReadOnlyData,
Unknown(u8),
}
impl SectionType {
/// Creates a `SectionType` from the raw 4-bit info field.
pub fn from_raw(raw: u8) -> Self {
match raw {
1 => Self::Code,
2 => Self::Data,
3 => Self::ReadOnlyData,
v => Self::Unknown(v),
}
}
}
impl fmt::Display for SectionType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Code => write!(f, "Code"),
Self::Data => write!(f, "Data"),
Self::ReadOnlyData => write!(f, "ReadOnly"),
Self::Unknown(v) => write!(f, "Unknown({v})"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_data() -> Vec<u8> {
let path = format!("{}/tests/data/default.xex", env!("CARGO_MANIFEST_DIR"));
std::fs::read(&path).expect("sample file should exist")
}
#[test]
fn test_parse_security_info_header() {
let data = sample_data();
let sec = parse_security_info(&data, 0x90).unwrap();
assert_eq!(sec.header_size, 0x00000F34);
assert_eq!(sec.image_size, 0x00920000);
}
#[test]
fn test_parse_security_info_fields() {
let data = sample_data();
let sec = parse_security_info(&data, 0x90).unwrap();
assert_eq!(sec.unk_108, 0x00000174);
assert_eq!(sec.image_flags, ImageFlags(0x00000008));
assert_eq!(sec.load_address, 0x82000000);
assert_eq!(sec.import_table_count, 2);
assert_eq!(sec.export_table, 0x00000000);
}
#[test]
fn test_parse_security_region_flags() {
let data = sample_data();
let sec = parse_security_info(&data, 0x90).unwrap();
assert_eq!(sec.region, RegionFlags(0xFFFFFFFF));
assert_eq!(sec.region.description(), "ALL REGIONS");
}
#[test]
fn test_parse_security_media_flags() {
let data = sample_data();
let sec = parse_security_info(&data, 0x90).unwrap();
assert_eq!(sec.allowed_media_types, MediaFlags(0x00000004));
assert_eq!(sec.allowed_media_types.flag_names(), vec!["DVD_CD"]);
}
#[test]
fn test_parse_page_descriptors() {
let data = sample_data();
let sec = parse_security_info(&data, 0x90).unwrap();
assert_eq!(sec.page_descriptor_count, 0x92); // 146
assert_eq!(sec.page_descriptors.len(), 146);
// First descriptor: page_count = 0x13 = 19
assert_eq!(sec.page_descriptors[0].page_count, 19);
}
#[test]
fn test_parse_rsa_signature() {
let data = sample_data();
let sec = parse_security_info(&data, 0x90).unwrap();
// First bytes of RSA signature from hex dump
assert_eq!(sec.rsa_signature[0], 0x2C);
assert_eq!(sec.rsa_signature[1], 0x94);
assert_eq!(sec.rsa_signature[2], 0xEB);
assert_eq!(sec.rsa_signature[3], 0xE6);
}
#[test]
fn test_parse_xgd2_media_id() {
let data = sample_data();
let sec = parse_security_info(&data, 0x90).unwrap();
assert_eq!(sec.xgd2_media_id[0], 0x33);
assert_eq!(sec.xgd2_media_id[1], 0x51);
}
#[test]
fn test_parse_aes_key() {
let data = sample_data();
let sec = parse_security_info(&data, 0x90).unwrap();
assert_eq!(sec.aes_key[0], 0xEA);
assert_eq!(sec.aes_key[1], 0xCB);
}
#[test]
fn test_image_flags_display() {
let f = ImageFlags(0x00000008);
assert_eq!(f.to_string(), "0x00000008 [XGD2_MEDIA_ONLY]");
}
#[test]
fn test_image_flags_page_size() {
assert_eq!(ImageFlags(0x10000000).page_size(), 0x1000);
assert_eq!(ImageFlags(0x00000000).page_size(), 0x10000);
}
#[test]
fn test_region_flags_all() {
assert_eq!(RegionFlags(0xFFFFFFFF).description(), "ALL REGIONS");
}
#[test]
fn test_region_flags_ntscu_only() {
assert_eq!(RegionFlags(0x000000FF).description(), "NTSC/U");
}
#[test]
fn test_region_flags_none() {
assert_eq!(RegionFlags(0).description(), "NONE");
}
#[test]
fn test_region_flags_specific() {
// NTSC/U + PAL-AU/NZ
let r = RegionFlags(0x000100FF);
let desc = r.description();
assert!(desc.contains("NTSC/U"));
assert!(desc.contains("PAL-AU/NZ"));
}
#[test]
fn test_media_flags_display() {
let f = MediaFlags(0x00000004);
assert_eq!(f.to_string(), "0x00000004 [DVD_CD]");
}
#[test]
fn test_media_flags_multiple() {
let f = MediaFlags(0x00000003); // HARD_DISK | DVD_X2
let names = f.flag_names();
assert_eq!(names, vec!["HARD_DISK", "DVD_X2"]);
}
#[test]
fn test_section_type_from_raw() {
assert_eq!(SectionType::from_raw(1), SectionType::Code);
assert_eq!(SectionType::from_raw(2), SectionType::Data);
assert_eq!(SectionType::from_raw(3), SectionType::ReadOnlyData);
assert!(matches!(SectionType::from_raw(0), SectionType::Unknown(0)));
}
#[test]
fn test_section_type_display() {
assert_eq!(SectionType::Code.to_string(), "Code");
assert_eq!(SectionType::Data.to_string(), "Data");
assert_eq!(SectionType::ReadOnlyData.to_string(), "ReadOnly");
assert_eq!(SectionType::Unknown(5).to_string(), "Unknown(5)");
}
#[test]
fn test_invalid_security_offset() {
let data = sample_data();
let err = parse_security_info(&data, 0xFFFFFFFF).unwrap_err();
assert!(matches!(err, Xex2Error::InvalidOffset { .. }));
}
}

View File

@@ -1,5 +1,8 @@
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};
fn sample_data() -> Vec<u8> { fn sample_data() -> Vec<u8> {
let path = format!("{}/tests/data/default.xex", env!("CARGO_MANIFEST_DIR")); let path = format!("{}/tests/data/default.xex", env!("CARGO_MANIFEST_DIR"));
@@ -120,17 +123,138 @@ fn test_optional_import_libraries() {
assert!(!imports.libraries.is_empty()); assert!(!imports.libraries.is_empty());
} }
// ── Security info tests ───────────────────────────────────────────────────────
#[test]
fn test_security_info_parsed() {
let data = sample_data();
let xex = xex2tractor::parse(&data).unwrap();
let sec = &xex.security_info;
assert_eq!(sec.header_size, 0x00000F34);
assert_eq!(sec.image_size, 0x00920000);
assert_eq!(sec.unk_108, 0x00000174);
assert_eq!(sec.load_address, 0x82000000);
assert_eq!(sec.import_table_count, 2);
assert_eq!(sec.export_table, 0);
}
#[test]
fn test_security_flags() {
let data = sample_data();
let xex = xex2tractor::parse(&data).unwrap();
let sec = &xex.security_info;
assert_eq!(sec.image_flags, ImageFlags(0x00000008));
assert_eq!(sec.region, RegionFlags(0xFFFFFFFF));
assert_eq!(sec.allowed_media_types, MediaFlags(0x00000004));
}
#[test]
fn test_security_page_descriptors() {
let data = sample_data();
let xex = xex2tractor::parse(&data).unwrap();
let sec = &xex.security_info;
assert_eq!(sec.page_descriptor_count, 146);
assert_eq!(sec.page_descriptors.len(), 146);
// First descriptor has page_count = 19
assert_eq!(sec.page_descriptors[0].page_count, 19);
// Page size should be 64KB (4KB flag is not set)
assert_eq!(sec.image_flags.page_size(), 0x10000);
// Each page descriptor should have a valid page_count
for desc in &sec.page_descriptors {
assert!(desc.page_count > 0, "page_count should be positive");
}
}
#[test]
fn test_security_crypto_fields() {
let data = sample_data();
let xex = xex2tractor::parse(&data).unwrap();
let sec = &xex.security_info;
// RSA signature starts with 2C94EBE6
assert_eq!(sec.rsa_signature[0..4], [0x2C, 0x94, 0xEB, 0xE6]);
// XGD2 media ID starts with 3351
assert_eq!(sec.xgd2_media_id[0..2], [0x33, 0x51]);
// AES key starts with EACB
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
@@ -148,12 +272,23 @@ 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"));
assert!(stdout.contains("xboxkrnl.exe")); assert!(stdout.contains("xboxkrnl.exe"));
assert!(stdout.contains("default.pe")); // original PE name assert!(stdout.contains("default.pe")); // original PE name
assert!(stdout.contains("PAL50_INCOMPATIBLE")); // system flags assert!(stdout.contains("PAL50_INCOMPATIBLE")); // system flags
// Security info section
assert!(stdout.contains("Security Info"));
assert!(stdout.contains("0x00000F34")); // header size
assert!(stdout.contains("0x00920000")); // image size
assert!(stdout.contains("XGD2_MEDIA_ONLY")); // image flags
assert!(stdout.contains("ALL REGIONS")); // region
assert!(stdout.contains("DVD_CD")); // media type
assert!(stdout.contains("Page Descriptors")); // page descriptors section
} }
#[test] #[test]
@@ -168,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);
}