From df26b028b638f3fad9ecf5aab8cc1aa46b391716 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Sat, 28 Mar 2026 21:31:31 +0100 Subject: [PATCH] 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 --- Cargo.lock | 285 ++++++++++++++++++++++++++++++++++++++++++- Cargo.toml | 5 +- src/crypto.rs | 178 +++++++++++++++++++++++++++ src/display.rs | 21 +++- src/error.rs | 8 ++ src/lib.rs | 1 + src/main.rs | 88 +++++++++---- src/optional.rs | 71 ++++++++++- tests/integration.rs | 86 ++++++++++++- 9 files changed, 711 insertions(+), 32 deletions(-) create mode 100644 src/crypto.rs diff --git a/Cargo.lock b/Cargo.lock index 57887d3..2d35860 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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.3.0" +version = "0.4.0" +dependencies = [ + "aes", + "cbc", + "clap", +] diff --git a/Cargo.toml b/Cargo.toml index 2c73081..a5961cf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,8 +1,11 @@ [package] name = "xex2tractor" -version = "0.3.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"] } diff --git a/src/crypto.rs b/src/crypto.rs new file mode 100644 index 0000000..646a75c --- /dev/null +++ b/src/crypto.rs @@ -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; + +/// 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::(&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::(&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]); + } +} diff --git a/src/display.rs b/src/display.rs index 5cf2851..47166d9 100644 --- a/src/display.rs +++ b/src/display.rs @@ -1,7 +1,7 @@ /// 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; @@ -79,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 diff --git a/src/error.rs b/src/error.rs index 2fa130f..9fd085a 100644 --- a/src/error.rs +++ b/src/error.rs @@ -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}"), } } } diff --git a/src/lib.rs b/src/lib.rs index b59d58d..b7cb39e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,6 +6,7 @@ //! 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; diff --git a/src/main.rs b/src/main.rs index 1459a4c..8fb9f00 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,31 +1,77 @@ +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, + }, +} + fn main() { - let path = match std::env::args().nth(1) { - Some(p) => p, - None => { - eprintln!("Usage: xex2tractor "); - process::exit(1); - } - }; + let cli = Cli::parse(); - let data = match std::fs::read(&path) { - Ok(d) => d, - Err(e) => { - eprintln!("Error reading {path}: {e}"); - process::exit(1); - } - }; + match cli.command { + Command::Inspect { file } => cmd_inspect(&file), + Command::Extract { file, output } => cmd_extract(&file, output), + } +} - let xex = match xex2tractor::parse(&data) { - Ok(x) => x, - Err(e) => { - eprintln!("Error parsing XEX2: {e}"); - process::exit(1); - } - }; +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) { + 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 { + 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); + } + } +} diff --git a/src/optional.rs b/src/optional.rs index f3ba338..778eb45 100644 --- a/src/optional.rs +++ b/src/optional.rs @@ -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, + }, + 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 { } fn parse_file_format_info(data: &[u8], offset: usize) -> Result { - // 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 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, }) } diff --git a/tests/integration.rs b/tests/integration.rs index 040557a..9728a5a 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -1,5 +1,6 @@ +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 { @@ -185,17 +186,74 @@ fn test_security_crypto_fields() { 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 @@ -213,6 +271,8 @@ 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")); @@ -242,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")); +}