Compare commits
13 Commits
abbd264e4c
...
v0.6.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee5e0b60f8 | ||
|
|
1e773e6cdc | ||
|
|
475e1d555c | ||
|
|
ba3b5a0ac3 | ||
|
|
c665868b1b | ||
|
|
ac24488444 | ||
|
|
df26b028b6 | ||
|
|
38d1cc1b6d | ||
|
|
66e078363c | ||
|
|
b1f90a55b6 | ||
|
|
a9436a3a7a | ||
|
|
a2e390a3fe | ||
|
|
b5f2abe09a |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1 +1,2 @@
|
||||
/target
|
||||
tests/data/
|
||||
|
||||
292
Cargo.lock
generated
292
Cargo.lock
generated
@@ -2,6 +2,296 @@
|
||||
# It is not intended for manual editing.
|
||||
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]]
|
||||
name = "xex2tractor"
|
||||
version = "0.1.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"cbc",
|
||||
"clap",
|
||||
"lzxd",
|
||||
]
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
[package]
|
||||
name = "xex2tractor"
|
||||
version = "0.1.0"
|
||||
version = "0.6.0"
|
||||
edition = "2024"
|
||||
description = "A tool for extracting and inspecting Xbox 360 XEX2 executable files"
|
||||
license = "MIT"
|
||||
|
||||
[dependencies]
|
||||
aes = "0.8.4"
|
||||
cbc = "0.1.2"
|
||||
clap = { version = "4.6.0", features = ["derive"] }
|
||||
lzxd = "0.2.6"
|
||||
|
||||
87
README.md
87
README.md
@@ -2,12 +2,99 @@
|
||||
|
||||
A tool for extracting and inspecting Xbox 360 XEX2 executable files, written in Rust.
|
||||
|
||||
## Usage
|
||||
|
||||
### Inspect
|
||||
|
||||
Display XEX2 file information (headers, security info, etc.):
|
||||
|
||||
```sh
|
||||
xex2tractor inspect <file.xex>
|
||||
```
|
||||
|
||||
#### Example Output
|
||||
|
||||
```
|
||||
=== XEX2 Header ===
|
||||
Magic: XEX2 (0x58455832)
|
||||
Module Flags: 0x00000001 [TITLE]
|
||||
Header Size: 0x00003000 (12288 bytes)
|
||||
Reserved: 0x00000000
|
||||
Security Offset: 0x00000090
|
||||
Header Count: 15
|
||||
|
||||
=== Optional Headers (15 entries) ===
|
||||
|
||||
[ENTRY_POINT] 0x824AB748
|
||||
[IMAGE_BASE_ADDRESS] 0x82000000
|
||||
[DEFAULT_STACK_SIZE] 0x00080000 (524288 bytes)
|
||||
[SYSTEM_FLAGS] 0x00000400 [PAL50_INCOMPATIBLE]
|
||||
|
||||
[EXECUTION_INFO]
|
||||
Media ID: 0x2D2E2EEB
|
||||
Title ID: 0x535107D4
|
||||
Version: 0.0.0.2
|
||||
...
|
||||
|
||||
[FILE_FORMAT_INFO]
|
||||
Encryption: Normal (AES-128-CBC)
|
||||
Compression: Normal (LZX)
|
||||
Window Size: 0x8000 (32 KB)
|
||||
...
|
||||
|
||||
=== Security Info ===
|
||||
Header Size: 0x00000F34 (3892 bytes)
|
||||
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
|
||||
|
||||
```sh
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Place a sample XEX2 file at `tests/data/default.xex`, then run:
|
||||
|
||||
```sh
|
||||
cargo test
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
See [doc/xex2_format.md](doc/xex2_format.md) for the XEX2 file format specification.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License. See [LICENSE](LICENSE) for details.
|
||||
|
||||
178
src/crypto.rs
Normal file
178
src/crypto.rs
Normal 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
187
src/decompress.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
310
src/display.rs
Normal file
310
src/display.rs
Normal file
@@ -0,0 +1,310 @@
|
||||
/// Pretty-print formatting for parsed XEX2 structures.
|
||||
use crate::header::Xex2Header;
|
||||
use crate::optional::{
|
||||
format_hex_bytes, format_rating, format_timestamp, CompressionInfo, HeaderKey, OptionalHeaders,
|
||||
};
|
||||
use crate::security::SecurityInfo;
|
||||
|
||||
/// Prints the XEX2 main header in a human-readable format.
|
||||
pub fn display_header(header: &Xex2Header) {
|
||||
println!("=== XEX2 Header ===");
|
||||
println!("Magic: XEX2 (0x{:08X})", header.magic);
|
||||
println!("Module Flags: {}", header.module_flags);
|
||||
println!(
|
||||
"Header Size: 0x{:08X} ({} bytes)",
|
||||
header.header_size, header.header_size
|
||||
);
|
||||
println!("Reserved: 0x{:08X}", header.reserved);
|
||||
println!("Security Offset: 0x{:08X}", header.security_offset);
|
||||
println!("Header Count: {}", header.header_count);
|
||||
}
|
||||
|
||||
/// Prints all parsed optional headers in a human-readable format.
|
||||
pub fn display_optional_headers(headers: &OptionalHeaders) {
|
||||
println!();
|
||||
println!("=== Optional Headers ({} entries) ===", headers.entries.len());
|
||||
|
||||
// Display inline u32 values first
|
||||
if let Some(v) = headers.entry_point {
|
||||
println!();
|
||||
println!("[ENTRY_POINT] 0x{v:08X}");
|
||||
}
|
||||
if let Some(v) = headers.original_base_address {
|
||||
println!("[ORIGINAL_BASE_ADDRESS] 0x{v:08X}");
|
||||
}
|
||||
if let Some(v) = headers.image_base_address {
|
||||
println!("[IMAGE_BASE_ADDRESS] 0x{v:08X}");
|
||||
}
|
||||
if let Some(v) = headers.default_stack_size {
|
||||
println!("[DEFAULT_STACK_SIZE] 0x{v:08X} ({v} bytes)");
|
||||
}
|
||||
if let Some(v) = headers.default_filesystem_cache_size {
|
||||
println!("[DEFAULT_FILESYSTEM_CACHE_SIZE] 0x{v:08X} ({v} bytes)");
|
||||
}
|
||||
if let Some(v) = headers.default_heap_size {
|
||||
println!("[DEFAULT_HEAP_SIZE] 0x{v:08X} ({v} bytes)");
|
||||
}
|
||||
if let Some(v) = headers.title_workspace_size {
|
||||
println!("[TITLE_WORKSPACE_SIZE] 0x{v:08X} ({v} bytes)");
|
||||
}
|
||||
if let Some(v) = headers.additional_title_memory {
|
||||
println!("[ADDITIONAL_TITLE_MEMORY] 0x{v:08X} ({v} bytes)");
|
||||
}
|
||||
if let Some(v) = headers.enabled_for_fastcap {
|
||||
println!("[ENABLED_FOR_FASTCAP] 0x{v:08X}");
|
||||
}
|
||||
|
||||
// System flags
|
||||
if let Some(ref flags) = headers.system_flags {
|
||||
println!("[SYSTEM_FLAGS] {flags}");
|
||||
}
|
||||
|
||||
// Execution info
|
||||
if let Some(ref exec) = headers.execution_info {
|
||||
println!();
|
||||
println!("[EXECUTION_INFO]");
|
||||
println!(" Media ID: 0x{:08X}", exec.media_id);
|
||||
println!(" Title ID: 0x{:08X}", exec.title_id);
|
||||
println!(" Version: {}", exec.version);
|
||||
println!(" Base Version: {}", exec.base_version);
|
||||
println!(" Platform: {}", exec.platform);
|
||||
println!(" Executable Type: {}", exec.executable_type);
|
||||
println!(" Disc: {}/{}", exec.disc_number, exec.disc_count);
|
||||
println!(" Savegame ID: 0x{:08X}", exec.savegame_id);
|
||||
}
|
||||
|
||||
// File format info
|
||||
if let Some(ref fmt) = headers.file_format_info {
|
||||
println!();
|
||||
println!("[FILE_FORMAT_INFO]");
|
||||
println!(" Encryption: {}", fmt.encryption_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
|
||||
if let Some(ref ct) = headers.checksum_timestamp {
|
||||
println!();
|
||||
println!("[CHECKSUM_TIMESTAMP]");
|
||||
println!(" Checksum: 0x{:08X}", ct.checksum);
|
||||
println!(
|
||||
" Timestamp: 0x{:08X} ({})",
|
||||
ct.timestamp,
|
||||
format_timestamp(ct.timestamp)
|
||||
);
|
||||
}
|
||||
|
||||
// Original PE name
|
||||
if let Some(ref name) = headers.original_pe_name {
|
||||
println!();
|
||||
println!("[ORIGINAL_PE_NAME] \"{name}\"");
|
||||
}
|
||||
|
||||
// Bounding path
|
||||
if let Some(ref path) = headers.bounding_path {
|
||||
println!("[BOUNDING_PATH] \"{path}\"");
|
||||
}
|
||||
|
||||
// TLS info
|
||||
if let Some(ref tls) = headers.tls_info {
|
||||
println!();
|
||||
println!("[TLS_INFO]");
|
||||
println!(" Slot Count: {}", tls.slot_count);
|
||||
println!(" Raw Data Address: 0x{:08X}", tls.raw_data_address);
|
||||
println!(" Data Size: {} bytes", tls.data_size);
|
||||
println!(" Raw Data Size: {} bytes", tls.raw_data_size);
|
||||
}
|
||||
|
||||
// Static libraries
|
||||
if let Some(ref libs) = headers.static_libraries {
|
||||
println!();
|
||||
println!("[STATIC_LIBRARIES] ({} libraries)", libs.len());
|
||||
for lib in libs {
|
||||
println!(" {lib}");
|
||||
}
|
||||
}
|
||||
|
||||
// Import libraries
|
||||
if let Some(ref imports) = headers.import_libraries {
|
||||
println!();
|
||||
println!("[IMPORT_LIBRARIES] ({} libraries)", imports.libraries.len());
|
||||
for lib in &imports.libraries {
|
||||
println!(
|
||||
" {} v{} (min v{}) - {} imports",
|
||||
lib.name, lib.version, lib.version_min, lib.record_count
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Resource info
|
||||
if let Some(ref resources) = headers.resource_info {
|
||||
println!();
|
||||
println!("[RESOURCE_INFO] ({} entries)", resources.len());
|
||||
for res in resources {
|
||||
println!(
|
||||
" \"{}\" @ 0x{:08X}, size: {} bytes",
|
||||
res.name, res.address, res.size
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Game ratings
|
||||
if let Some(ref ratings) = headers.game_ratings {
|
||||
println!();
|
||||
println!("[GAME_RATINGS]");
|
||||
println!(" ESRB: {} | PEGI: {} | PEGI-FI: {} | PEGI-PT: {}",
|
||||
format_rating(ratings.esrb), format_rating(ratings.pegi),
|
||||
format_rating(ratings.pegi_fi), format_rating(ratings.pegi_pt));
|
||||
println!(" BBFC: {} | CERO: {} | USK: {} | OFLC-AU: {}",
|
||||
format_rating(ratings.bbfc), format_rating(ratings.cero),
|
||||
format_rating(ratings.usk), format_rating(ratings.oflc_au));
|
||||
println!(" OFLC-NZ: {} | KMRB: {} | Brazil: {} | FPB: {}",
|
||||
format_rating(ratings.oflc_nz), format_rating(ratings.kmrb),
|
||||
format_rating(ratings.brazil), format_rating(ratings.fpb));
|
||||
}
|
||||
|
||||
// LAN key
|
||||
if let Some(ref key) = headers.lan_key {
|
||||
println!();
|
||||
println!("[LAN_KEY] {}", format_hex_bytes(key));
|
||||
}
|
||||
|
||||
// Callcap imports
|
||||
if let Some(ref callcap) = headers.enabled_for_callcap {
|
||||
println!();
|
||||
println!("[ENABLED_FOR_CALLCAP]");
|
||||
println!(" Start Thunk: 0x{:08X}", callcap.start_func_thunk_addr);
|
||||
println!(" End Thunk: 0x{:08X}", callcap.end_func_thunk_addr);
|
||||
}
|
||||
|
||||
// Exports by name
|
||||
if let Some(ref dir) = headers.exports_by_name {
|
||||
println!();
|
||||
println!("[EXPORTS_BY_NAME]");
|
||||
println!(" Offset: 0x{:08X}", dir.offset);
|
||||
println!(" Size: {} bytes", dir.size);
|
||||
}
|
||||
|
||||
// Xbox 360 logo
|
||||
if let Some(size) = headers.xbox360_logo_size {
|
||||
println!();
|
||||
println!("[XBOX360_LOGO] {size} bytes");
|
||||
}
|
||||
|
||||
// Unknown headers
|
||||
for entry in &headers.entries {
|
||||
if let HeaderKey::Unknown(raw) = entry.key {
|
||||
println!();
|
||||
println!(
|
||||
"[UNKNOWN(0x{raw:08X})] value/offset: 0x{:08X}",
|
||||
entry.value
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)"
|
||||
);
|
||||
}
|
||||
82
src/error.rs
Normal file
82
src/error.rs
Normal file
@@ -0,0 +1,82 @@
|
||||
/// Error types for XEX2 parsing operations.
|
||||
use std::fmt;
|
||||
|
||||
/// All errors that can occur during XEX2 file parsing.
|
||||
#[derive(Debug)]
|
||||
pub enum Xex2Error {
|
||||
/// An I/O error occurred while reading the file.
|
||||
Io(std::io::Error),
|
||||
/// The file does not start with the expected XEX2 magic bytes (0x58455832).
|
||||
InvalidMagic(u32),
|
||||
/// The file or buffer is too small to contain the expected data.
|
||||
FileTooSmall { expected: usize, actual: usize },
|
||||
/// An offset points outside the file boundaries.
|
||||
InvalidOffset {
|
||||
name: &'static str,
|
||||
offset: u32,
|
||||
file_size: usize,
|
||||
},
|
||||
/// A string field contains invalid UTF-8.
|
||||
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 {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Xex2Error::Io(err) => write!(f, "I/O error: {err}"),
|
||||
Xex2Error::InvalidMagic(magic) => {
|
||||
write!(f, "invalid magic: 0x{magic:08X} (expected 0x58455832)")
|
||||
}
|
||||
Xex2Error::FileTooSmall { expected, actual } => {
|
||||
write!(f, "file too small: need {expected} bytes, got {actual}")
|
||||
}
|
||||
Xex2Error::InvalidOffset {
|
||||
name,
|
||||
offset,
|
||||
file_size,
|
||||
} => {
|
||||
write!(
|
||||
f,
|
||||
"invalid offset for {name}: 0x{offset:08X} exceeds file size {file_size}"
|
||||
)
|
||||
}
|
||||
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}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Xex2Error {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
Xex2Error::Io(err) => Some(err),
|
||||
Xex2Error::Utf8Error(err) => Some(err),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for Xex2Error {
|
||||
fn from(err: std::io::Error) -> Self {
|
||||
Xex2Error::Io(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::str::Utf8Error> for Xex2Error {
|
||||
fn from(err: std::str::Utf8Error) -> Self {
|
||||
Xex2Error::Utf8Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience type alias for XEX2 parsing results.
|
||||
pub type Result<T> = std::result::Result<T, Xex2Error>;
|
||||
211
src/extract.rs
Normal file
211
src/extract.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
208
src/header.rs
Normal file
208
src/header.rs
Normal file
@@ -0,0 +1,208 @@
|
||||
/// XEX2 main header parsing.
|
||||
///
|
||||
/// The main header is located at the very beginning of the XEX2 file (offset 0x00)
|
||||
/// and contains the magic bytes, module flags, header size, security info offset,
|
||||
/// and the count of optional header entries that follow.
|
||||
use std::fmt;
|
||||
|
||||
use crate::error::{Result, Xex2Error};
|
||||
use crate::util::read_u32_be;
|
||||
|
||||
/// Expected magic value at offset 0x00: ASCII "XEX2" = 0x58455832.
|
||||
pub const XEX2_MAGIC: u32 = 0x58455832;
|
||||
|
||||
/// Size of the fixed portion of the main header (before optional header entries).
|
||||
pub const HEADER_SIZE: usize = 0x18;
|
||||
|
||||
/// The parsed XEX2 main header.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Xex2Header {
|
||||
/// Magic bytes — must be `XEX2_MAGIC` (0x58455832).
|
||||
pub magic: u32,
|
||||
/// Bitfield indicating the module type (title, DLL, patch, etc.).
|
||||
pub module_flags: ModuleFlags,
|
||||
/// Total size of all headers in bytes. The PE image data starts at this offset.
|
||||
pub header_size: u32,
|
||||
/// Reserved field (typically 0).
|
||||
pub reserved: u32,
|
||||
/// File offset to the `xex2_security_info` structure.
|
||||
pub security_offset: u32,
|
||||
/// Number of optional header entries following the main header.
|
||||
pub header_count: u32,
|
||||
}
|
||||
|
||||
/// Parses the XEX2 main header from the beginning of `data`.
|
||||
///
|
||||
/// Validates that the magic bytes match `XEX2_MAGIC` and that the buffer is
|
||||
/// large enough to contain the fixed header fields.
|
||||
pub fn parse_header(data: &[u8]) -> Result<Xex2Header> {
|
||||
if data.len() < HEADER_SIZE {
|
||||
return Err(Xex2Error::FileTooSmall {
|
||||
expected: HEADER_SIZE,
|
||||
actual: data.len(),
|
||||
});
|
||||
}
|
||||
|
||||
let magic = read_u32_be(data, 0x00)?;
|
||||
if magic != XEX2_MAGIC {
|
||||
return Err(Xex2Error::InvalidMagic(magic));
|
||||
}
|
||||
|
||||
Ok(Xex2Header {
|
||||
magic,
|
||||
module_flags: ModuleFlags(read_u32_be(data, 0x04)?),
|
||||
header_size: read_u32_be(data, 0x08)?,
|
||||
reserved: read_u32_be(data, 0x0C)?,
|
||||
security_offset: read_u32_be(data, 0x10)?,
|
||||
header_count: read_u32_be(data, 0x14)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Wrapper around the module flags bitmask from the XEX2 header.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ModuleFlags(pub u32);
|
||||
|
||||
impl ModuleFlags {
|
||||
pub const TITLE: u32 = 0x00000001;
|
||||
pub const EXPORTS_TO_TITLE: u32 = 0x00000002;
|
||||
pub const SYSTEM_DEBUGGER: u32 = 0x00000004;
|
||||
pub const DLL_MODULE: u32 = 0x00000008;
|
||||
pub const MODULE_PATCH: u32 = 0x00000010;
|
||||
pub const PATCH_FULL: u32 = 0x00000020;
|
||||
pub const PATCH_DELTA: u32 = 0x00000040;
|
||||
pub const USER_MODE: u32 = 0x00000080;
|
||||
|
||||
/// All known flags paired with their display names, in bit order.
|
||||
const FLAGS: &[(u32, &str)] = &[
|
||||
(Self::TITLE, "TITLE"),
|
||||
(Self::EXPORTS_TO_TITLE, "EXPORTS_TO_TITLE"),
|
||||
(Self::SYSTEM_DEBUGGER, "SYSTEM_DEBUGGER"),
|
||||
(Self::DLL_MODULE, "DLL_MODULE"),
|
||||
(Self::MODULE_PATCH, "MODULE_PATCH"),
|
||||
(Self::PATCH_FULL, "PATCH_FULL"),
|
||||
(Self::PATCH_DELTA, "PATCH_DELTA"),
|
||||
(Self::USER_MODE, "USER_MODE"),
|
||||
];
|
||||
|
||||
/// Returns the raw `u32` value.
|
||||
pub fn bits(self) -> u32 {
|
||||
self.0
|
||||
}
|
||||
|
||||
/// 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 ModuleFlags {
|
||||
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(", "))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Builds a minimal valid XEX2 header buffer with the given field values.
|
||||
fn make_header(
|
||||
magic: u32,
|
||||
module_flags: u32,
|
||||
header_size: u32,
|
||||
reserved: u32,
|
||||
security_offset: u32,
|
||||
header_count: u32,
|
||||
) -> Vec<u8> {
|
||||
let mut buf = Vec::with_capacity(HEADER_SIZE);
|
||||
buf.extend_from_slice(&magic.to_be_bytes());
|
||||
buf.extend_from_slice(&module_flags.to_be_bytes());
|
||||
buf.extend_from_slice(&header_size.to_be_bytes());
|
||||
buf.extend_from_slice(&reserved.to_be_bytes());
|
||||
buf.extend_from_slice(&security_offset.to_be_bytes());
|
||||
buf.extend_from_slice(&header_count.to_be_bytes());
|
||||
buf
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_valid_header() {
|
||||
let data = make_header(XEX2_MAGIC, 0x01, 0x3000, 0, 0x90, 15);
|
||||
let header = parse_header(&data).unwrap();
|
||||
assert_eq!(header.magic, XEX2_MAGIC);
|
||||
assert_eq!(header.module_flags, ModuleFlags(0x01));
|
||||
assert_eq!(header.header_size, 0x3000);
|
||||
assert_eq!(header.reserved, 0);
|
||||
assert_eq!(header.security_offset, 0x90);
|
||||
assert_eq!(header.header_count, 15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_magic() {
|
||||
let data = make_header(0xDEADBEEF, 0, 0, 0, 0, 0);
|
||||
let err = parse_header(&data).unwrap_err();
|
||||
assert!(matches!(err, Xex2Error::InvalidMagic(0xDEADBEEF)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_too_small() {
|
||||
let data = [0u8; 10];
|
||||
let err = parse_header(&data).unwrap_err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
Xex2Error::FileTooSmall {
|
||||
expected: HEADER_SIZE,
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_module_flags_display_title() {
|
||||
let flags = ModuleFlags(0x01);
|
||||
assert_eq!(flags.to_string(), "0x00000001 [TITLE]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_module_flags_display_multiple() {
|
||||
let flags = ModuleFlags(0x09); // TITLE | DLL_MODULE
|
||||
assert_eq!(flags.to_string(), "0x00000009 [TITLE, DLL_MODULE]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_module_flags_display_none() {
|
||||
let flags = ModuleFlags(0);
|
||||
assert_eq!(flags.to_string(), "0x00000000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_module_flags_display_all() {
|
||||
let flags = ModuleFlags(0xFF);
|
||||
let names = flags.flag_names();
|
||||
assert_eq!(names.len(), 8);
|
||||
assert_eq!(names[0], "TITLE");
|
||||
assert_eq!(names[7], "USER_MODE");
|
||||
}
|
||||
|
||||
/// Test against the actual default.xex sample file.
|
||||
#[test]
|
||||
fn test_parse_sample_header() {
|
||||
let path = format!("{}/tests/data/default.xex", env!("CARGO_MANIFEST_DIR"));
|
||||
let data = std::fs::read(&path).expect("sample file should exist at tests/data/default.xex");
|
||||
let header = parse_header(&data).unwrap();
|
||||
assert_eq!(header.magic, XEX2_MAGIC);
|
||||
assert_eq!(header.module_flags, ModuleFlags(0x00000001));
|
||||
assert_eq!(header.header_size, 0x00003000);
|
||||
assert_eq!(header.reserved, 0x00000000);
|
||||
assert_eq!(header.security_offset, 0x00000090);
|
||||
assert_eq!(header.header_count, 15);
|
||||
}
|
||||
}
|
||||
49
src/lib.rs
Normal file
49
src/lib.rs
Normal file
@@ -0,0 +1,49 @@
|
||||
//! # xex2tractor
|
||||
//!
|
||||
//! A library for parsing Xbox 360 XEX2 executable files.
|
||||
//!
|
||||
//! XEX2 is the executable format used by the Xbox 360 console. This crate
|
||||
//! provides types and functions to parse the binary format and extract
|
||||
//! structured information from XEX2 files.
|
||||
|
||||
pub mod crypto;
|
||||
pub mod decompress;
|
||||
pub mod display;
|
||||
pub mod error;
|
||||
pub mod extract;
|
||||
pub mod header;
|
||||
pub mod optional;
|
||||
pub mod security;
|
||||
pub mod util;
|
||||
|
||||
use error::Result;
|
||||
use header::Xex2Header;
|
||||
use optional::OptionalHeaders;
|
||||
use security::SecurityInfo;
|
||||
|
||||
/// A parsed XEX2 file containing all extracted structures.
|
||||
#[derive(Debug)]
|
||||
pub struct Xex2File {
|
||||
/// The main XEX2 header (magic, flags, sizes, offsets).
|
||||
pub header: Xex2Header,
|
||||
/// All parsed optional headers.
|
||||
pub optional_headers: OptionalHeaders,
|
||||
/// Security info (signatures, keys, page descriptors).
|
||||
pub security_info: SecurityInfo,
|
||||
}
|
||||
|
||||
/// Parses an XEX2 file from a byte slice.
|
||||
///
|
||||
/// The `data` slice should contain the entire XEX2 file contents.
|
||||
/// Returns a [`Xex2File`] with all successfully parsed structures.
|
||||
pub fn parse(data: &[u8]) -> Result<Xex2File> {
|
||||
let header = header::parse_header(data)?;
|
||||
let optional_headers = optional::parse_optional_headers(data, &header)?;
|
||||
let security_info = security::parse_security_info(data, header.security_offset)?;
|
||||
|
||||
Ok(Xex2File {
|
||||
header,
|
||||
optional_headers,
|
||||
security_info,
|
||||
})
|
||||
}
|
||||
98
src/main.rs
98
src/main.rs
@@ -1,3 +1,97 @@
|
||||
fn main() {
|
||||
println!("Hello, world!");
|
||||
use clap::{Parser, Subcommand};
|
||||
use std::path::PathBuf;
|
||||
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() {
|
||||
let cli = Cli::parse();
|
||||
|
||||
match cli.command {
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
1309
src/optional.rs
Normal file
1309
src/optional.rs
Normal file
File diff suppressed because it is too large
Load Diff
510
src/security.rs
Normal file
510
src/security.rs
Normal 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 { .. }));
|
||||
}
|
||||
}
|
||||
117
src/util.rs
Normal file
117
src/util.rs
Normal file
@@ -0,0 +1,117 @@
|
||||
/// Big-endian binary read helpers with bounds checking.
|
||||
use crate::error::{Result, Xex2Error};
|
||||
|
||||
/// Reads a big-endian `u32` from `data` at the given byte `offset`.
|
||||
pub fn read_u32_be(data: &[u8], offset: usize) -> Result<u32> {
|
||||
let end = offset + 4;
|
||||
if end > data.len() {
|
||||
return Err(Xex2Error::FileTooSmall {
|
||||
expected: end,
|
||||
actual: data.len(),
|
||||
});
|
||||
}
|
||||
Ok(u32::from_be_bytes([
|
||||
data[offset],
|
||||
data[offset + 1],
|
||||
data[offset + 2],
|
||||
data[offset + 3],
|
||||
]))
|
||||
}
|
||||
|
||||
/// Reads a big-endian `u16` from `data` at the given byte `offset`.
|
||||
pub fn read_u16_be(data: &[u8], offset: usize) -> Result<u16> {
|
||||
let end = offset + 2;
|
||||
if end > data.len() {
|
||||
return Err(Xex2Error::FileTooSmall {
|
||||
expected: end,
|
||||
actual: data.len(),
|
||||
});
|
||||
}
|
||||
Ok(u16::from_be_bytes([data[offset], data[offset + 1]]))
|
||||
}
|
||||
|
||||
/// Reads a single byte from `data` at the given `offset`.
|
||||
pub fn read_u8(data: &[u8], offset: usize) -> Result<u8> {
|
||||
if offset >= data.len() {
|
||||
return Err(Xex2Error::FileTooSmall {
|
||||
expected: offset + 1,
|
||||
actual: data.len(),
|
||||
});
|
||||
}
|
||||
Ok(data[offset])
|
||||
}
|
||||
|
||||
/// Returns a byte slice of `len` bytes from `data` starting at `offset`.
|
||||
pub fn read_bytes(data: &[u8], offset: usize, len: usize) -> Result<&[u8]> {
|
||||
let end = offset + len;
|
||||
if end > data.len() {
|
||||
return Err(Xex2Error::FileTooSmall {
|
||||
expected: end,
|
||||
actual: data.len(),
|
||||
});
|
||||
}
|
||||
Ok(&data[offset..end])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_read_u32_be() {
|
||||
let data = [0x58, 0x45, 0x58, 0x32];
|
||||
assert_eq!(read_u32_be(&data, 0).unwrap(), 0x58455832);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_u32_be_with_offset() {
|
||||
let data = [0x00, 0x00, 0x58, 0x45, 0x58, 0x32];
|
||||
assert_eq!(read_u32_be(&data, 2).unwrap(), 0x58455832);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_u16_be() {
|
||||
let data = [0x12, 0x34];
|
||||
assert_eq!(read_u16_be(&data, 0).unwrap(), 0x1234);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_u8() {
|
||||
let data = [0xAB, 0xCD];
|
||||
assert_eq!(read_u8(&data, 1).unwrap(), 0xCD);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_bytes() {
|
||||
let data = [0x01, 0x02, 0x03, 0x04, 0x05];
|
||||
assert_eq!(read_bytes(&data, 1, 3).unwrap(), &[0x02, 0x03, 0x04]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_u32_be_out_of_bounds() {
|
||||
let data = [0x00, 0x01];
|
||||
let err = read_u32_be(&data, 0).unwrap_err();
|
||||
assert!(matches!(err, Xex2Error::FileTooSmall { expected: 4, actual: 2 }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_u16_be_out_of_bounds() {
|
||||
let data = [0x00];
|
||||
let err = read_u16_be(&data, 0).unwrap_err();
|
||||
assert!(matches!(err, Xex2Error::FileTooSmall { expected: 2, actual: 1 }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_u8_out_of_bounds() {
|
||||
let data: [u8; 0] = [];
|
||||
let err = read_u8(&data, 0).unwrap_err();
|
||||
assert!(matches!(err, Xex2Error::FileTooSmall { expected: 1, actual: 0 }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_bytes_out_of_bounds() {
|
||||
let data = [0x01, 0x02];
|
||||
let err = read_bytes(&data, 1, 3).unwrap_err();
|
||||
assert!(matches!(err, Xex2Error::FileTooSmall { expected: 4, actual: 2 }));
|
||||
}
|
||||
}
|
||||
442
tests/integration.rs
Normal file
442
tests/integration.rs
Normal file
@@ -0,0 +1,442 @@
|
||||
use xex2tractor::crypto;
|
||||
use xex2tractor::extract;
|
||||
use xex2tractor::header::{ModuleFlags, XEX2_MAGIC};
|
||||
use xex2tractor::optional::{CompressionInfo, CompressionType, EncryptionType, SystemFlags};
|
||||
use xex2tractor::security::{ImageFlags, MediaFlags, RegionFlags};
|
||||
|
||||
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 at tests/data/default.xex")
|
||||
}
|
||||
|
||||
// ── Header tests ──────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_full_parse() {
|
||||
let data = sample_data();
|
||||
let xex = xex2tractor::parse(&data).unwrap();
|
||||
|
||||
assert_eq!(xex.header.magic, XEX2_MAGIC);
|
||||
assert_eq!(xex.header.module_flags, ModuleFlags(0x00000001));
|
||||
assert_eq!(xex.header.header_size, 0x00003000);
|
||||
assert_eq!(xex.header.reserved, 0x00000000);
|
||||
assert_eq!(xex.header.security_offset, 0x00000090);
|
||||
assert_eq!(xex.header.header_count, 15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_empty_file() {
|
||||
let data = vec![];
|
||||
assert!(xex2tractor::parse(&data).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_invalid_magic() {
|
||||
let mut data = sample_data();
|
||||
data[0] = 0x00;
|
||||
assert!(xex2tractor::parse(&data).is_err());
|
||||
}
|
||||
|
||||
// ── Optional header tests ─────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_optional_headers_all_present() {
|
||||
let data = sample_data();
|
||||
let xex = xex2tractor::parse(&data).unwrap();
|
||||
let opt = &xex.optional_headers;
|
||||
|
||||
// All 15 entries should be parsed
|
||||
assert_eq!(opt.entries.len(), 15);
|
||||
|
||||
// Verify presence of all expected headers
|
||||
assert!(opt.entry_point.is_some());
|
||||
assert!(opt.image_base_address.is_some());
|
||||
assert!(opt.default_stack_size.is_some());
|
||||
assert!(opt.system_flags.is_some());
|
||||
assert!(opt.execution_info.is_some());
|
||||
assert!(opt.file_format_info.is_some());
|
||||
assert!(opt.checksum_timestamp.is_some());
|
||||
assert!(opt.original_pe_name.is_some());
|
||||
assert!(opt.tls_info.is_some());
|
||||
assert!(opt.static_libraries.is_some());
|
||||
assert!(opt.import_libraries.is_some());
|
||||
assert!(opt.resource_info.is_some());
|
||||
assert!(opt.game_ratings.is_some());
|
||||
assert!(opt.lan_key.is_some());
|
||||
assert!(opt.xbox360_logo_size.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_optional_inline_values() {
|
||||
let data = sample_data();
|
||||
let xex = xex2tractor::parse(&data).unwrap();
|
||||
let opt = &xex.optional_headers;
|
||||
|
||||
assert_eq!(opt.entry_point.unwrap(), 0x824AB748);
|
||||
assert_eq!(opt.image_base_address.unwrap(), 0x82000000);
|
||||
assert_eq!(opt.default_stack_size.unwrap(), 0x00080000);
|
||||
assert_eq!(opt.system_flags.unwrap(), SystemFlags(0x00000400));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_optional_execution_info() {
|
||||
let data = sample_data();
|
||||
let xex = xex2tractor::parse(&data).unwrap();
|
||||
let exec = xex.optional_headers.execution_info.as_ref().unwrap();
|
||||
|
||||
assert_eq!(exec.title_id, 0x535107D4);
|
||||
assert_eq!(exec.media_id, 0x2D2E2EEB);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_optional_file_format() {
|
||||
let data = sample_data();
|
||||
let xex = xex2tractor::parse(&data).unwrap();
|
||||
let fmt = xex.optional_headers.file_format_info.as_ref().unwrap();
|
||||
|
||||
assert_eq!(fmt.encryption_type, EncryptionType::Normal);
|
||||
assert_eq!(fmt.compression_type, CompressionType::Normal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_optional_static_libraries() {
|
||||
let data = sample_data();
|
||||
let xex = xex2tractor::parse(&data).unwrap();
|
||||
let libs = xex.optional_headers.static_libraries.as_ref().unwrap();
|
||||
|
||||
assert_eq!(libs.len(), 12);
|
||||
// Verify first and a few known libraries
|
||||
assert_eq!(libs[0].name, "XAPILIB");
|
||||
assert_eq!(libs[1].name, "D3D9");
|
||||
assert_eq!(libs[3].name, "XBOXKRNL");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_optional_import_libraries() {
|
||||
let data = sample_data();
|
||||
let xex = xex2tractor::parse(&data).unwrap();
|
||||
let imports = xex.optional_headers.import_libraries.as_ref().unwrap();
|
||||
|
||||
assert_eq!(imports.string_table.len(), 2);
|
||||
assert_eq!(imports.string_table[0], "xam.xex");
|
||||
assert_eq!(imports.string_table[1], "xboxkrnl.exe");
|
||||
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 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_cli_inspect_with_sample() {
|
||||
let path = format!("{}/tests/data/default.xex", env!("CARGO_MANIFEST_DIR"));
|
||||
let output = std::process::Command::new(env!("CARGO_BIN_EXE_xex2tractor"))
|
||||
.args(["inspect", &path])
|
||||
.output()
|
||||
.expect("failed to run xex2tractor");
|
||||
|
||||
assert!(output.status.success(), "CLI inspect should exit successfully");
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
|
||||
// Header section
|
||||
assert!(stdout.contains("XEX2 Header"));
|
||||
assert!(stdout.contains("0x58455832"));
|
||||
assert!(stdout.contains("TITLE"));
|
||||
assert!(stdout.contains("Header Count: 15"));
|
||||
|
||||
// Optional headers section
|
||||
assert!(stdout.contains("Optional Headers (15 entries)"));
|
||||
assert!(stdout.contains("[ENTRY_POINT] 0x824AB748"));
|
||||
assert!(stdout.contains("[IMAGE_BASE_ADDRESS] 0x82000000"));
|
||||
assert!(stdout.contains("EXECUTION_INFO"));
|
||||
assert!(stdout.contains("0x535107D4")); // title ID
|
||||
assert!(stdout.contains("FILE_FORMAT_INFO"));
|
||||
assert!(stdout.contains("Normal (AES-128-CBC)"));
|
||||
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("XAPILIB"));
|
||||
assert!(stdout.contains("IMPORT_LIBRARIES"));
|
||||
assert!(stdout.contains("xboxkrnl.exe"));
|
||||
assert!(stdout.contains("default.pe")); // original PE name
|
||||
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]
|
||||
fn test_cli_no_args() {
|
||||
let output = std::process::Command::new(env!("CARGO_BIN_EXE_xex2tractor"))
|
||||
.output()
|
||||
.expect("failed to run xex2tractor");
|
||||
|
||||
assert!(!output.status.success());
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(stderr.contains("Usage"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cli_inspect_missing_file() {
|
||||
let output = std::process::Command::new(env!("CARGO_BIN_EXE_xex2tractor"))
|
||||
.args(["inspect", "/nonexistent/file.xex"])
|
||||
.output()
|
||||
.expect("failed to run xex2tractor");
|
||||
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user