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