4 Commits

Author SHA1 Message Date
MechaCat02
ac24488444 Merge feature/m4-decryption: AES-128-CBC decryption and clap CLI 2026-03-28 21:31:35 +01:00
MechaCat02
df26b028b6 feat: add AES-128-CBC decryption and clap CLI (M4)
Add session key derivation and payload decryption using AES-128-CBC
with well-known XEX2 master keys. Refactor CLI to use clap with
inspect/extract subcommands. Extend FileFormatInfo to parse
compression metadata (basic blocks, LZX window size/block chain).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 21:31:31 +01:00
MechaCat02
38d1cc1b6d Merge feature/m3-security-info: parse and display security info 2026-03-28 19:04:48 +01:00
MechaCat02
66e078363c feat: parse and display security info (M3)
Implement security info parsing including RSA signature, encrypted AES
key, image/region/media flags, load address, SHA-1 digests, and page
descriptors with section type classification. Add comprehensive unit
and integration tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 19:04:41 +01:00
11 changed files with 1400 additions and 27 deletions

285
Cargo.lock generated
View File

@@ -2,6 +2,289 @@
# 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 = "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.2.0"
version = "0.4.0"
dependencies = [
"aes",
"cbc",
"clap",
]

View File

@@ -1,8 +1,11 @@
[package]
name = "xex2tractor"
version = "0.2.0"
version = "0.4.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"] }

View File

@@ -44,6 +44,20 @@ Header Count: 15
[IMPORT_LIBRARIES] (2 libraries)
xam.xex v2.0.4552.0 (min v2.0.4552.0) - 104 imports
xboxkrnl.exe v2.0.4552.0 (min v2.0.4552.0) - 294 imports
=== Security Info ===
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]
...
Page Descriptors (146 entries, 64KB pages):
#0 Unknown(0) 19 pages ( 1245184 bytes) offset +0x00000000 SHA1: B136058FBBAD...
...
```
## Building

178
src/crypto.rs Normal file
View File

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

View File

@@ -1,8 +1,9 @@
/// Pretty-print formatting for parsed XEX2 structures.
use crate::header::Xex2Header;
use crate::optional::{
format_hex_bytes, format_rating, format_timestamp, HeaderKey, OptionalHeaders,
format_hex_bytes, format_rating, format_timestamp, CompressionInfo, HeaderKey, OptionalHeaders,
};
use crate::security::SecurityInfo;
/// Prints the XEX2 main header in a human-readable format.
pub fn display_header(header: &Xex2Header) {
@@ -78,6 +79,25 @@ pub fn display_optional_headers(headers: &OptionalHeaders) {
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
@@ -200,3 +220,91 @@ pub fn display_optional_headers(headers: &OptionalHeaders) {
}
}
}
/// Prints the security info in a human-readable format.
pub fn display_security_info(security: &SecurityInfo) {
println!();
println!("=== Security Info ===");
println!(
"Header Size: 0x{:08X} ({} bytes)",
security.header_size, security.header_size
);
println!(
"Image Size: 0x{:08X} ({} bytes)",
security.image_size, security.image_size
);
// RSA signature — show first 8 and last 8 bytes
let sig = &security.rsa_signature;
println!(
"RSA Signature: {}...{} (256 bytes)",
sig[..4].iter().map(|b| format!("{b:02X}")).collect::<String>(),
sig[252..].iter().map(|b| format!("{b:02X}")).collect::<String>()
);
println!("Unknown (0x108): 0x{:08X}", security.unk_108);
println!("Image Flags: {}", security.image_flags);
println!("Load Address: 0x{:08X}", security.load_address);
println!(
"Section Digest: {}",
format_hex_bytes(&security.section_digest)
);
println!("Import Table Count: {}", security.import_table_count);
println!(
"Import Table Digest: {}",
format_hex_bytes(&security.import_table_digest)
);
println!(
"XGD2 Media ID: {}",
security
.xgd2_media_id
.iter()
.map(|b| format!("{b:02X}"))
.collect::<String>()
);
println!(
"AES Key (encrypted): {}",
format_hex_bytes(&security.aes_key)
);
if security.export_table == 0 {
println!("Export Table: 0x00000000 (none)");
} else {
println!("Export Table: 0x{:08X}", security.export_table);
}
println!(
"Header Digest: {}",
format_hex_bytes(&security.header_digest)
);
println!("Region: {}", security.region);
println!("Allowed Media Types: {}", security.allowed_media_types);
// Page descriptors
println!();
let page_size = security.image_flags.page_size();
let page_size_label = if page_size == 0x1000 { "4KB" } else { "64KB" };
println!(
"Page Descriptors ({} entries, {} pages):",
security.page_descriptor_count, page_size_label
);
let mut address_offset: u64 = 0;
for (i, desc) in security.page_descriptors.iter().enumerate() {
let digest_preview: String = desc.data_digest[..6]
.iter()
.map(|b| format!("{b:02X}"))
.collect();
let size = desc.page_count as u64 * page_size as u64;
println!(
" #{i:<4} {:<10} {:<4} pages ({:>8} bytes) offset +0x{address_offset:08X} SHA1: {digest_preview}...",
desc.section_type.to_string(),
desc.page_count,
size
);
address_offset += size;
}
println!(
" Total mapped size: 0x{address_offset:X} ({address_offset} bytes)"
);
}

View File

@@ -18,6 +18,10 @@ pub enum Xex2Error {
},
/// 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),
}
impl fmt::Display for Xex2Error {
@@ -41,6 +45,10 @@ impl fmt::Display for Xex2Error {
)
}
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}"),
}
}
}

View File

@@ -6,15 +6,18 @@
//! provides types and functions to parse the binary format and extract
//! structured information from XEX2 files.
pub mod crypto;
pub mod display;
pub mod error;
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)]
@@ -23,6 +26,8 @@ pub struct Xex2File {
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.
@@ -32,9 +37,11 @@ pub struct Xex2File {
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,
})
}

View File

@@ -1,30 +1,77 @@
use clap::{Parser, Subcommand};
use std::path::PathBuf;
use std::process;
fn main() {
let path = match std::env::args().nth(1) {
Some(p) => p,
None => {
eprintln!("Usage: xex2tractor <file.xex>");
process::exit(1);
}
};
/// A tool for extracting and inspecting Xbox 360 XEX2 executable files.
#[derive(Parser)]
#[command(name = "xex2tractor", version, about)]
struct Cli {
#[command(subcommand)]
command: Command,
}
let data = match std::fs::read(&path) {
#[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);
// TODO(M5): decrypt + decompress pipeline
// TODO(M6): verify PE and write to output_path
eprintln!("Error: extraction not yet implemented (coming in M5/M6)");
process::exit(1);
}
fn read_file(path: &PathBuf) -> Vec<u8> {
match std::fs::read(path) {
Ok(d) => d,
Err(e) => {
eprintln!("Error reading {path}: {e}");
eprintln!("Error reading {}: {e}", path.display());
process::exit(1);
}
};
}
}
let xex = match xex2tractor::parse(&data) {
fn parse_xex(data: &[u8]) -> xex2tractor::Xex2File {
match xex2tractor::parse(data) {
Ok(x) => x,
Err(e) => {
eprintln!("Error parsing XEX2: {e}");
process::exit(1);
}
};
xex2tractor::display::display_header(&xex.header);
xex2tractor::display::display_optional_headers(&xex.optional_headers);
}
}

View File

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

510
src/security.rs Normal file
View File

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

View File

@@ -1,5 +1,7 @@
use xex2tractor::crypto;
use xex2tractor::header::{ModuleFlags, XEX2_MAGIC};
use xex2tractor::optional::{CompressionType, EncryptionType, SystemFlags};
use xex2tractor::optional::{CompressionInfo, CompressionType, EncryptionType, SystemFlags};
use xex2tractor::security::{ImageFlags, MediaFlags, RegionFlags};
fn sample_data() -> Vec<u8> {
let path = format!("{}/tests/data/default.xex", env!("CARGO_MANIFEST_DIR"));
@@ -120,17 +122,138 @@ fn test_optional_import_libraries() {
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_runs_with_sample() {
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"))
.arg(&path)
.args(["inspect", &path])
.output()
.expect("failed to run xex2tractor");
assert!(output.status.success(), "CLI should exit successfully");
assert!(output.status.success(), "CLI inspect should exit successfully");
let stdout = String::from_utf8_lossy(&output.stdout);
// Header section
@@ -148,12 +271,23 @@ fn test_cli_runs_with_sample() {
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]
@@ -168,11 +302,25 @@ fn test_cli_no_args() {
}
#[test]
fn test_cli_missing_file() {
fn test_cli_inspect_missing_file() {
let output = std::process::Command::new(env!("CARGO_BIN_EXE_xex2tractor"))
.arg("/nonexistent/file.xex")
.args(["inspect", "/nonexistent/file.xex"])
.output()
.expect("failed to run xex2tractor");
assert!(!output.status.success());
}
#[test]
fn test_cli_extract_not_yet_implemented() {
let path = format!("{}/tests/data/default.xex", env!("CARGO_MANIFEST_DIR"));
let output = std::process::Command::new(env!("CARGO_BIN_EXE_xex2tractor"))
.args(["extract", &path])
.output()
.expect("failed to run xex2tractor");
// Extract should fail with "not yet implemented" for now
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("not yet implemented"));
}