diff --git a/.gitignore b/.gitignore index ea8c4bf..ea14af9 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /target +tests/data/ diff --git a/README.md b/README.md index bf1f9a8..b6476fd 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,42 @@ A tool for extracting and inspecting Xbox 360 XEX2 executable files, written in Rust. +## Usage + +```sh +xex2tractor +``` + +### Example Output + +``` +=== XEX2 Header === +Magic: XEX2 (0x58455832) +Module Flags: 0x00000001 [TITLE] +Header Size: 0x00003000 (12288 bytes) +Reserved: 0x00000000 +Security Offset: 0x00000090 +Header Count: 15 +``` + ## Building ```sh cargo build --release ``` +## Testing + +Place a sample XEX2 file at `tests/data/default.xex`, then run: + +```sh +cargo test +``` + +## Documentation + +See [doc/xex2_format.md](doc/xex2_format.md) for the XEX2 file format specification. + ## License This project is licensed under the MIT License. See [LICENSE](LICENSE) for details. diff --git a/src/display.rs b/src/display.rs new file mode 100644 index 0000000..72ecda6 --- /dev/null +++ b/src/display.rs @@ -0,0 +1,16 @@ +/// Pretty-print formatting for parsed XEX2 structures. +use crate::header::Xex2Header; + +/// Prints the XEX2 main header in a human-readable format. +pub fn display_header(header: &Xex2Header) { + println!("=== XEX2 Header ==="); + println!("Magic: XEX2 (0x{:08X})", header.magic); + println!("Module Flags: {}", header.module_flags); + println!( + "Header Size: 0x{:08X} ({} bytes)", + header.header_size, header.header_size + ); + println!("Reserved: 0x{:08X}", header.reserved); + println!("Security Offset: 0x{:08X}", header.security_offset); + println!("Header Count: {}", header.header_count); +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..2fa130f --- /dev/null +++ b/src/error.rs @@ -0,0 +1,71 @@ +/// Error types for XEX2 parsing operations. +use std::fmt; + +/// All errors that can occur during XEX2 file parsing. +#[derive(Debug)] +pub enum Xex2Error { + /// An I/O error occurred while reading the file. + Io(std::io::Error), + /// The file does not start with the expected XEX2 magic bytes (0x58455832). + InvalidMagic(u32), + /// The file or buffer is too small to contain the expected data. + FileTooSmall { expected: usize, actual: usize }, + /// An offset points outside the file boundaries. + InvalidOffset { + name: &'static str, + offset: u32, + file_size: usize, + }, + /// A string field contains invalid UTF-8. + Utf8Error(std::str::Utf8Error), +} + +impl fmt::Display for Xex2Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Xex2Error::Io(err) => write!(f, "I/O error: {err}"), + Xex2Error::InvalidMagic(magic) => { + write!(f, "invalid magic: 0x{magic:08X} (expected 0x58455832)") + } + Xex2Error::FileTooSmall { expected, actual } => { + write!(f, "file too small: need {expected} bytes, got {actual}") + } + Xex2Error::InvalidOffset { + name, + offset, + file_size, + } => { + write!( + f, + "invalid offset for {name}: 0x{offset:08X} exceeds file size {file_size}" + ) + } + Xex2Error::Utf8Error(err) => write!(f, "invalid UTF-8: {err}"), + } + } +} + +impl std::error::Error for Xex2Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Xex2Error::Io(err) => Some(err), + Xex2Error::Utf8Error(err) => Some(err), + _ => None, + } + } +} + +impl From for Xex2Error { + fn from(err: std::io::Error) -> Self { + Xex2Error::Io(err) + } +} + +impl From for Xex2Error { + fn from(err: std::str::Utf8Error) -> Self { + Xex2Error::Utf8Error(err) + } +} + +/// Convenience type alias for XEX2 parsing results. +pub type Result = std::result::Result; diff --git a/src/header.rs b/src/header.rs new file mode 100644 index 0000000..3b9db59 --- /dev/null +++ b/src/header.rs @@ -0,0 +1,208 @@ +/// XEX2 main header parsing. +/// +/// The main header is located at the very beginning of the XEX2 file (offset 0x00) +/// and contains the magic bytes, module flags, header size, security info offset, +/// and the count of optional header entries that follow. +use std::fmt; + +use crate::error::{Result, Xex2Error}; +use crate::util::read_u32_be; + +/// Expected magic value at offset 0x00: ASCII "XEX2" = 0x58455832. +pub const XEX2_MAGIC: u32 = 0x58455832; + +/// Size of the fixed portion of the main header (before optional header entries). +pub const HEADER_SIZE: usize = 0x18; + +/// The parsed XEX2 main header. +#[derive(Debug, Clone)] +pub struct Xex2Header { + /// Magic bytes — must be `XEX2_MAGIC` (0x58455832). + pub magic: u32, + /// Bitfield indicating the module type (title, DLL, patch, etc.). + pub module_flags: ModuleFlags, + /// Total size of all headers in bytes. The PE image data starts at this offset. + pub header_size: u32, + /// Reserved field (typically 0). + pub reserved: u32, + /// File offset to the `xex2_security_info` structure. + pub security_offset: u32, + /// Number of optional header entries following the main header. + pub header_count: u32, +} + +/// Parses the XEX2 main header from the beginning of `data`. +/// +/// Validates that the magic bytes match `XEX2_MAGIC` and that the buffer is +/// large enough to contain the fixed header fields. +pub fn parse_header(data: &[u8]) -> Result { + if data.len() < HEADER_SIZE { + return Err(Xex2Error::FileTooSmall { + expected: HEADER_SIZE, + actual: data.len(), + }); + } + + let magic = read_u32_be(data, 0x00)?; + if magic != XEX2_MAGIC { + return Err(Xex2Error::InvalidMagic(magic)); + } + + Ok(Xex2Header { + magic, + module_flags: ModuleFlags(read_u32_be(data, 0x04)?), + header_size: read_u32_be(data, 0x08)?, + reserved: read_u32_be(data, 0x0C)?, + security_offset: read_u32_be(data, 0x10)?, + header_count: read_u32_be(data, 0x14)?, + }) +} + +/// Wrapper around the module flags bitmask from the XEX2 header. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ModuleFlags(pub u32); + +impl ModuleFlags { + pub const TITLE: u32 = 0x00000001; + pub const EXPORTS_TO_TITLE: u32 = 0x00000002; + pub const SYSTEM_DEBUGGER: u32 = 0x00000004; + pub const DLL_MODULE: u32 = 0x00000008; + pub const MODULE_PATCH: u32 = 0x00000010; + pub const PATCH_FULL: u32 = 0x00000020; + pub const PATCH_DELTA: u32 = 0x00000040; + pub const USER_MODE: u32 = 0x00000080; + + /// All known flags paired with their display names, in bit order. + const FLAGS: &[(u32, &str)] = &[ + (Self::TITLE, "TITLE"), + (Self::EXPORTS_TO_TITLE, "EXPORTS_TO_TITLE"), + (Self::SYSTEM_DEBUGGER, "SYSTEM_DEBUGGER"), + (Self::DLL_MODULE, "DLL_MODULE"), + (Self::MODULE_PATCH, "MODULE_PATCH"), + (Self::PATCH_FULL, "PATCH_FULL"), + (Self::PATCH_DELTA, "PATCH_DELTA"), + (Self::USER_MODE, "USER_MODE"), + ]; + + /// Returns the raw `u32` value. + pub fn bits(self) -> u32 { + self.0 + } + + /// Returns a list of human-readable flag names that are set. + pub fn flag_names(self) -> Vec<&'static str> { + Self::FLAGS + .iter() + .filter(|(bit, _)| self.0 & bit != 0) + .map(|(_, name)| *name) + .collect() + } +} + +impl fmt::Display for ModuleFlags { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let names = self.flag_names(); + if names.is_empty() { + write!(f, "0x{:08X}", self.0) + } else { + write!(f, "0x{:08X} [{}]", self.0, names.join(", ")) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Builds a minimal valid XEX2 header buffer with the given field values. + fn make_header( + magic: u32, + module_flags: u32, + header_size: u32, + reserved: u32, + security_offset: u32, + header_count: u32, + ) -> Vec { + let mut buf = Vec::with_capacity(HEADER_SIZE); + buf.extend_from_slice(&magic.to_be_bytes()); + buf.extend_from_slice(&module_flags.to_be_bytes()); + buf.extend_from_slice(&header_size.to_be_bytes()); + buf.extend_from_slice(&reserved.to_be_bytes()); + buf.extend_from_slice(&security_offset.to_be_bytes()); + buf.extend_from_slice(&header_count.to_be_bytes()); + buf + } + + #[test] + fn test_parse_valid_header() { + let data = make_header(XEX2_MAGIC, 0x01, 0x3000, 0, 0x90, 15); + let header = parse_header(&data).unwrap(); + assert_eq!(header.magic, XEX2_MAGIC); + assert_eq!(header.module_flags, ModuleFlags(0x01)); + assert_eq!(header.header_size, 0x3000); + assert_eq!(header.reserved, 0); + assert_eq!(header.security_offset, 0x90); + assert_eq!(header.header_count, 15); + } + + #[test] + fn test_invalid_magic() { + let data = make_header(0xDEADBEEF, 0, 0, 0, 0, 0); + let err = parse_header(&data).unwrap_err(); + assert!(matches!(err, Xex2Error::InvalidMagic(0xDEADBEEF))); + } + + #[test] + fn test_file_too_small() { + let data = [0u8; 10]; + let err = parse_header(&data).unwrap_err(); + assert!(matches!( + err, + Xex2Error::FileTooSmall { + expected: HEADER_SIZE, + .. + } + )); + } + + #[test] + fn test_module_flags_display_title() { + let flags = ModuleFlags(0x01); + assert_eq!(flags.to_string(), "0x00000001 [TITLE]"); + } + + #[test] + fn test_module_flags_display_multiple() { + let flags = ModuleFlags(0x09); // TITLE | DLL_MODULE + assert_eq!(flags.to_string(), "0x00000009 [TITLE, DLL_MODULE]"); + } + + #[test] + fn test_module_flags_display_none() { + let flags = ModuleFlags(0); + assert_eq!(flags.to_string(), "0x00000000"); + } + + #[test] + fn test_module_flags_display_all() { + let flags = ModuleFlags(0xFF); + let names = flags.flag_names(); + assert_eq!(names.len(), 8); + assert_eq!(names[0], "TITLE"); + assert_eq!(names[7], "USER_MODE"); + } + + /// Test against the actual default.xex sample file. + #[test] + fn test_parse_sample_header() { + let path = format!("{}/tests/data/default.xex", env!("CARGO_MANIFEST_DIR")); + let data = std::fs::read(&path).expect("sample file should exist at tests/data/default.xex"); + let header = parse_header(&data).unwrap(); + assert_eq!(header.magic, XEX2_MAGIC); + assert_eq!(header.module_flags, ModuleFlags(0x00000001)); + assert_eq!(header.header_size, 0x00003000); + assert_eq!(header.reserved, 0x00000000); + assert_eq!(header.security_offset, 0x00000090); + assert_eq!(header.header_count, 15); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..5b243d7 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,32 @@ +//! # xex2tractor +//! +//! A library for parsing Xbox 360 XEX2 executable files. +//! +//! XEX2 is the executable format used by the Xbox 360 console. This crate +//! provides types and functions to parse the binary format and extract +//! structured information from XEX2 files. + +pub mod display; +pub mod error; +pub mod header; +pub mod util; + +use error::Result; +use header::Xex2Header; + +/// A parsed XEX2 file containing all extracted structures. +#[derive(Debug)] +pub struct Xex2File { + /// The main XEX2 header (magic, flags, sizes, offsets). + pub header: Xex2Header, +} + +/// Parses an XEX2 file from a byte slice. +/// +/// The `data` slice should contain the entire XEX2 file contents. +/// Returns a [`Xex2File`] with all successfully parsed structures. +pub fn parse(data: &[u8]) -> Result { + let header = header::parse_header(data)?; + + Ok(Xex2File { header }) +} diff --git a/src/main.rs b/src/main.rs index e7a11a9..752a3f1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,29 @@ +use std::process; + fn main() { - println!("Hello, world!"); + let path = match std::env::args().nth(1) { + Some(p) => p, + None => { + eprintln!("Usage: xex2tractor "); + process::exit(1); + } + }; + + let data = match std::fs::read(&path) { + Ok(d) => d, + Err(e) => { + eprintln!("Error reading {path}: {e}"); + process::exit(1); + } + }; + + let xex = match xex2tractor::parse(&data) { + Ok(x) => x, + Err(e) => { + eprintln!("Error parsing XEX2: {e}"); + process::exit(1); + } + }; + + xex2tractor::display::display_header(&xex.header); } diff --git a/src/util.rs b/src/util.rs new file mode 100644 index 0000000..626cce0 --- /dev/null +++ b/src/util.rs @@ -0,0 +1,117 @@ +/// Big-endian binary read helpers with bounds checking. +use crate::error::{Result, Xex2Error}; + +/// Reads a big-endian `u32` from `data` at the given byte `offset`. +pub fn read_u32_be(data: &[u8], offset: usize) -> Result { + let end = offset + 4; + if end > data.len() { + return Err(Xex2Error::FileTooSmall { + expected: end, + actual: data.len(), + }); + } + Ok(u32::from_be_bytes([ + data[offset], + data[offset + 1], + data[offset + 2], + data[offset + 3], + ])) +} + +/// Reads a big-endian `u16` from `data` at the given byte `offset`. +pub fn read_u16_be(data: &[u8], offset: usize) -> Result { + let end = offset + 2; + if end > data.len() { + return Err(Xex2Error::FileTooSmall { + expected: end, + actual: data.len(), + }); + } + Ok(u16::from_be_bytes([data[offset], data[offset + 1]])) +} + +/// Reads a single byte from `data` at the given `offset`. +pub fn read_u8(data: &[u8], offset: usize) -> Result { + if offset >= data.len() { + return Err(Xex2Error::FileTooSmall { + expected: offset + 1, + actual: data.len(), + }); + } + Ok(data[offset]) +} + +/// Returns a byte slice of `len` bytes from `data` starting at `offset`. +pub fn read_bytes(data: &[u8], offset: usize, len: usize) -> Result<&[u8]> { + let end = offset + len; + if end > data.len() { + return Err(Xex2Error::FileTooSmall { + expected: end, + actual: data.len(), + }); + } + Ok(&data[offset..end]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_read_u32_be() { + let data = [0x58, 0x45, 0x58, 0x32]; + assert_eq!(read_u32_be(&data, 0).unwrap(), 0x58455832); + } + + #[test] + fn test_read_u32_be_with_offset() { + let data = [0x00, 0x00, 0x58, 0x45, 0x58, 0x32]; + assert_eq!(read_u32_be(&data, 2).unwrap(), 0x58455832); + } + + #[test] + fn test_read_u16_be() { + let data = [0x12, 0x34]; + assert_eq!(read_u16_be(&data, 0).unwrap(), 0x1234); + } + + #[test] + fn test_read_u8() { + let data = [0xAB, 0xCD]; + assert_eq!(read_u8(&data, 1).unwrap(), 0xCD); + } + + #[test] + fn test_read_bytes() { + let data = [0x01, 0x02, 0x03, 0x04, 0x05]; + assert_eq!(read_bytes(&data, 1, 3).unwrap(), &[0x02, 0x03, 0x04]); + } + + #[test] + fn test_read_u32_be_out_of_bounds() { + let data = [0x00, 0x01]; + let err = read_u32_be(&data, 0).unwrap_err(); + assert!(matches!(err, Xex2Error::FileTooSmall { expected: 4, actual: 2 })); + } + + #[test] + fn test_read_u16_be_out_of_bounds() { + let data = [0x00]; + let err = read_u16_be(&data, 0).unwrap_err(); + assert!(matches!(err, Xex2Error::FileTooSmall { expected: 2, actual: 1 })); + } + + #[test] + fn test_read_u8_out_of_bounds() { + let data: [u8; 0] = []; + let err = read_u8(&data, 0).unwrap_err(); + assert!(matches!(err, Xex2Error::FileTooSmall { expected: 1, actual: 0 })); + } + + #[test] + fn test_read_bytes_out_of_bounds() { + let data = [0x01, 0x02]; + let err = read_bytes(&data, 1, 3).unwrap_err(); + assert!(matches!(err, Xex2Error::FileTooSmall { expected: 4, actual: 2 })); + } +} diff --git a/tests/integration.rs b/tests/integration.rs new file mode 100644 index 0000000..34d3cf3 --- /dev/null +++ b/tests/integration.rs @@ -0,0 +1,70 @@ +use xex2tractor::header::{ModuleFlags, XEX2_MAGIC}; + +fn sample_data() -> Vec { + let path = format!("{}/tests/data/default.xex", env!("CARGO_MANIFEST_DIR")); + std::fs::read(&path).expect("sample file should exist at tests/data/default.xex") +} + +#[test] +fn test_full_parse() { + let data = sample_data(); + let xex = xex2tractor::parse(&data).unwrap(); + + assert_eq!(xex.header.magic, XEX2_MAGIC); + assert_eq!(xex.header.module_flags, ModuleFlags(0x00000001)); + assert_eq!(xex.header.header_size, 0x00003000); + assert_eq!(xex.header.reserved, 0x00000000); + assert_eq!(xex.header.security_offset, 0x00000090); + assert_eq!(xex.header.header_count, 15); +} + +#[test] +fn test_parse_empty_file() { + let data = vec![]; + assert!(xex2tractor::parse(&data).is_err()); +} + +#[test] +fn test_parse_invalid_magic() { + let mut data = sample_data(); + // Corrupt the magic bytes + data[0] = 0x00; + assert!(xex2tractor::parse(&data).is_err()); +} + +#[test] +fn test_cli_runs_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) + .output() + .expect("failed to run xex2tractor"); + + assert!(output.status.success(), "CLI should exit successfully"); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("XEX2 Header"), "should display header section"); + assert!(stdout.contains("0x58455832"), "should display magic value"); + assert!(stdout.contains("TITLE"), "should display module flag name"); + assert!(stdout.contains("Header Count: 15"), "should show 15 headers"); +} + +#[test] +fn test_cli_no_args() { + let output = std::process::Command::new(env!("CARGO_BIN_EXE_xex2tractor")) + .output() + .expect("failed to run xex2tractor"); + + assert!(!output.status.success(), "CLI should fail without args"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("Usage"), "should print usage message"); +} + +#[test] +fn test_cli_missing_file() { + let output = std::process::Command::new(env!("CARGO_BIN_EXE_xex2tractor")) + .arg("/nonexistent/file.xex") + .output() + .expect("failed to run xex2tractor"); + + assert!(!output.status.success(), "CLI should fail with missing file"); +}