From 66e078363cec6ca233a651a43c7b314bdf0774e5 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Sat, 28 Mar 2026 19:04:41 +0100 Subject: [PATCH] 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 --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 14 ++ src/display.rs | 89 ++++++++ src/lib.rs | 6 + src/main.rs | 1 + src/security.rs | 510 +++++++++++++++++++++++++++++++++++++++++++ tests/integration.rs | 74 +++++++ 8 files changed, 696 insertions(+), 2 deletions(-) create mode 100644 src/security.rs diff --git a/Cargo.lock b/Cargo.lock index 8db8de6..57887d3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,4 +4,4 @@ version = 4 [[package]] name = "xex2tractor" -version = "0.2.0" +version = "0.3.0" diff --git a/Cargo.toml b/Cargo.toml index 4834c3f..2c73081 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "xex2tractor" -version = "0.2.0" +version = "0.3.0" edition = "2024" description = "A tool for extracting and inspecting Xbox 360 XEX2 executable files" license = "MIT" diff --git a/README.md b/README.md index 8a85b9d..4097861 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/display.rs b/src/display.rs index 505796e..5cf2851 100644 --- a/src/display.rs +++ b/src/display.rs @@ -3,6 +3,7 @@ use crate::header::Xex2Header; use crate::optional::{ format_hex_bytes, format_rating, format_timestamp, HeaderKey, OptionalHeaders, }; +use crate::security::SecurityInfo; /// Prints the XEX2 main header in a human-readable format. pub fn display_header(header: &Xex2Header) { @@ -200,3 +201,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::(), + sig[252..].iter().map(|b| format!("{b:02X}")).collect::() + ); + + 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::() + ); + 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)" + ); +} diff --git a/src/lib.rs b/src/lib.rs index ed6877d..b59d58d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,11 +10,13 @@ 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 +25,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 +36,11 @@ pub struct Xex2File { pub fn parse(data: &[u8]) -> Result { 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, }) } diff --git a/src/main.rs b/src/main.rs index 8b04f40..1459a4c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -27,4 +27,5 @@ fn main() { xex2tractor::display::display_header(&xex.header); xex2tractor::display::display_optional_headers(&xex.optional_headers); + xex2tractor::display::display_security_info(&xex.security_info); } diff --git a/src/security.rs b/src/security.rs new file mode 100644 index 0000000..cde2869 --- /dev/null +++ b/src/security.rs @@ -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, +} + +/// Parses the security info structure from `data` at the given file `offset`. +pub fn parse_security_info(data: &[u8], offset: u32) -> Result { + 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 { + 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 { .. })); + } +} diff --git a/tests/integration.rs b/tests/integration.rs index 1d90a1c..040557a 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -1,5 +1,6 @@ use xex2tractor::header::{ModuleFlags, XEX2_MAGIC}; use xex2tractor::optional::{CompressionType, EncryptionType, SystemFlags}; +use xex2tractor::security::{ImageFlags, MediaFlags, RegionFlags}; fn sample_data() -> Vec { let path = format!("{}/tests/data/default.xex", env!("CARGO_MANIFEST_DIR")); @@ -120,6 +121,70 @@ 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]); +} + // ── CLI tests ───────────────────────────────────────────────────────────────── #[test] @@ -154,6 +219,15 @@ fn test_cli_runs_with_sample() { 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]