Files
benilla-octo/crates/benilla-mpq/src/lib.rs
T
SamandClaude Fable 5 2ad0043523 benilla: initial public release
A from-scratch World of Warcraft 1.12.1 client in Rust + Bevy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 14:27:01 +07:00

663 lines
29 KiB
Rust

//! A read-only MPQ archive reader for **WoW 1.12.1 (build 5875)** — in-repo, replacing `wow-mpq`
//! (decision 0021).
//!
//! Deliberately narrow to what the real `Data/` chain actually is (verified by probing every archive):
//! format **V1/V2**, every file `COMPRESS`-flagged and **sectored**, **no encrypted files**, **no
//! single-unit**, **no PTCH** incremental patches. So this does header parse → hash/block **table**
//! decrypt → name lookup → sectored read (per-sector inflate). It does *not* write archives, decrypt
//! file data, verify signatures, or load the `(attributes)` table — the wasteful per-open parse that
//! motivated 0021. Anything outside the verified envelope is a hard error, never a silent guess.
//!
//! **Delete-markers.** The patch archives *do* carry file tombstones — a block entry flagged
//! `MPQ_FILE_DELETE_MARKER` (`0x02000000`), `size == 0`, meaning "this path is deleted from the
//! composite; do not use any lower-priority archive's copy." (`patch.MPQ` uses these to remove stock
//! FrameXML it replaces with backported addons — decision 0246.) These are **not** readable files:
//! [`Archive::read_file`] refuses one with [`Error::NotFound`] rather than returning the empty buffer
//! a naive `size == 0` stored-read would (the silent mislead that cost 0246 a day), and
//! [`Archive::is_delete_marker`] lets the chain walker see a tombstone and stop instead of falling
//! through. A delete-marked entry still *exists* in the hash table, so [`Archive::contains`] reports
//! it present — the chain must consult [`Archive::is_delete_marker`] to tell "deleted" from "readable".
//!
//! ## Concurrency
//! The parsed [`Index`] (header offsets + decrypted hash/block tables) is immutable, so [`Archive`] is
//! a cheap `Arc` handle that is `Send + Sync` and `Clone`. [`Archive::read_file`] is `&self`: it opens
//! a **fresh** OS file handle for the read, so concurrent reads share no seek state and need no lock.
//! Opening the handle is a cheap syscall; the expensive table parse happens once, in [`Archive::open`].
//!
//! Correctness was proven by a differential pass against `wow-mpq` over real archives — 37,971 reads
//! byte-identical across all 13 (decision 0021; the throwaway oracle test is in git history). The
//! `benilla-formats` loaders now exercise this reader end-to-end against real data on every run.
//!
//! Byte access goes through `benilla-bytes` (decision 0064), migrated minimally: the local rd_u16/
//! rd_u32 are gone in favor of `ByteExt`, and every allocation sized from a header/block-table count
//! or a sector length — all attacker-controllable in a hostile archive — is capped by what the
//! archive file could actually hold (`capped`), so a lying header fails with [`Error::Corrupt`]
//! instead of aborting the allocator. Nothing else here changes.
use std::fs::File;
use std::io::{self, Read, Seek, SeekFrom};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use benilla_bytes::{capped, ByteExt};
mod crypto;
use crypto::{decrypt_block, hash_string, hash_type};
/// `MPQ\x1A` — the archive header signature (little-endian u32).
const SIGNATURE: u32 = 0x1A51_504D;
/// `MPQ\x1B` — the user-data header signature (some archives offset the real header past it).
const USERDATA_SIGNATURE: u32 = 0x1B51_504D;
// Block-table entry flags (only the ones the 1.12.1 envelope can present; others are asserted absent).
const FLAG_IMPLODE: u32 = 0x0000_0100;
const FLAG_COMPRESS: u32 = 0x0000_0200;
const FLAG_ENCRYPTED: u32 = 0x0001_0000;
const FLAG_SINGLE_UNIT: u32 = 0x0100_0000;
/// A patch-archive tombstone: the path is deleted from the composite chain (size 0). Recognised so a
/// tombstone reads as [`Error::NotFound`], not an empty buffer (decision 0246).
const FLAG_DELETE_MARKER: u32 = 0x0200_0000;
const FLAG_EXISTS: u32 = 0x8000_0000;
/// Hash-table sentinels.
const HASH_EMPTY: u32 = 0xFFFF_FFFF; // slot never used — stop probing
const HASH_DELETED: u32 = 0xFFFF_FFFE; // slot deleted — skip, keep probing
/// One decrypted hash-table entry (16 bytes on disk).
#[derive(Clone, Copy)]
struct HashEntry {
name_a: u32,
name_b: u32,
block_index: u32,
}
/// One decrypted block-table entry (16 bytes on disk). The on-disk `compressed_size` (word 1) is
/// skipped — sector sizes come from the offset table, so it's never needed for reading.
#[derive(Clone, Copy)]
struct BlockEntry {
file_pos: u32,
file_size: u32,
flags: u32,
}
/// The immutable, parsed-once index of an archive: where the data starts, the sector size, and the
/// decrypted hash/block tables. Shared behind an `Arc` so [`Archive`] clones are cheap and reads borrow
/// it without locking.
struct Index {
path: PathBuf,
/// Byte offset of the MPQ header within the file (0 for vanilla, but found, not assumed).
archive_offset: u64,
/// `512 << block_size` — the uncompressed sector size.
sector_size: usize,
hash_table: Vec<HashEntry>,
block_table: Vec<BlockEntry>,
}
/// An open MPQ archive: a cheap, `Clone`, `Send + Sync` handle over a shared [`Index`]. Reads open a
/// fresh OS handle, so they are `&self` and lock-free.
#[derive(Clone)]
pub struct Archive {
index: Arc<Index>,
}
/// Errors a read can produce. Anything outside the verified 1.12.1 envelope (encrypted file, unknown
/// codec, …) surfaces here rather than being papered over.
#[derive(Debug)]
pub enum Error {
Io(io::Error),
NotMpq,
NotFound(String),
/// A file or codec shape the 1.12.1 envelope shouldn't contain — investigate, don't guess.
Unsupported(String),
Decompress(String),
/// A header/block-table count or sector length claims more than the archive file could possibly
/// hold. Caught by the capped reservations (decision 0064) before the allocator would; a corrupt
/// or truncated archive, not a coding bug.
Corrupt(String),
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Error::Io(e) => write!(f, "io: {e}"),
Error::NotMpq => write!(f, "not an MPQ archive (no header signature found)"),
Error::NotFound(n) => write!(f, "file not in archive: {n}"),
Error::Unsupported(m) => write!(f, "unsupported (outside 1.12.1 envelope): {m}"),
Error::Decompress(m) => write!(f, "decompress: {m}"),
Error::Corrupt(m) => write!(f, "corrupt archive: {m}"),
}
}
}
impl std::error::Error for Error {}
impl From<io::Error> for Error {
fn from(e: io::Error) -> Self {
Error::Io(e)
}
}
type Result<T> = std::result::Result<T, Error>;
/// Reinterpret a byte buffer (length a multiple of 4) as a `Vec<u32>` (little-endian) for table decrypt.
fn to_u32s(bytes: &[u8]) -> Vec<u32> {
bytes
.chunks_exact(4)
.map(|c| u32::from_le_bytes([c[0], c[1], c[2], c[3]]))
.collect()
}
/// Bytes actually available in the archive file from `pos` to EOF, clamped into `usize`. Table counts
/// and sector lengths are attacker-controllable header/block-table values (decision 0064); every
/// reservation sized from one of them is capped against this, so a lying value reserves at most the
/// archive's own size and the mismatch surfaces as a clean [`Error::Corrupt`], never an allocator abort.
fn avail_from(file_len: u64, pos: u64) -> usize {
usize::try_from(file_len.saturating_sub(pos)).unwrap_or(usize::MAX)
}
impl Archive {
/// Open and index an archive: find the header, then read+decrypt the hash and block tables. The
/// file handle used here is dropped on return; reads reopen their own.
pub fn open(path: impl AsRef<Path>) -> Result<Self> {
let path = path.as_ref().to_path_buf();
let mut file = File::open(&path)?;
let file_len = file.metadata()?.len();
// Find the header. Vanilla archives put it at 0, but the format allows a user-data header
// first, so scan 512-aligned boundaries for the `MPQ\x1A` signature (resolving `MPQ\x1B`).
let archive_offset = find_header(&mut file, file_len)?;
// Read the 32-byte V1 header prefix (V2/V3/V4 only extend it; the V1 fields are all we need —
// every real table offset fits in u32, so the V2 hi-block table is irrelevant here).
file.seek(SeekFrom::Start(archive_offset))?;
let mut hdr = [0u8; 32];
file.read_exact(&mut hdr)?;
// Every field below is a fixed offset within a buffer that was just `read_exact`'d to exactly
// 32 bytes, so `u16_at`/`u32_at` can only return `None` if that invariant is somehow broken —
// map it to `Error::NotMpq` (a header we can't decode isn't a valid one) rather than trust it.
let signature = hdr.u32_at(0).ok_or(Error::NotMpq)?;
debug_assert_eq!(signature, SIGNATURE);
let block_size_shift = hdr.u16_at(14).ok_or(Error::NotMpq)?;
// The shift is a raw header u16: unchecked, a corrupt value ≥ 55 overflows the `usize`
// shift (a panic in debug, a masked shift in release — both wrong). 23 (512 B << 23 = 4 GiB
// sectors) is already far beyond any real archive (1.12 data uses 3 → 4 KiB sectors).
if block_size_shift > 23 {
return Err(Error::Corrupt(format!(
"block size shift {block_size_shift} exceeds the sanity cap"
)));
}
let sector_size = 512usize << block_size_shift;
let hash_table_pos = hdr.u32_at(16).ok_or(Error::NotMpq)? as u64;
let block_table_pos = hdr.u32_at(20).ok_or(Error::NotMpq)? as u64;
let hash_table_size = hdr.u32_at(24).ok_or(Error::NotMpq)? as usize;
let block_table_size = hdr.u32_at(28).ok_or(Error::NotMpq)? as usize;
let hash_table = read_hash_table(
&mut file,
archive_offset + hash_table_pos,
hash_table_size,
file_len,
)?;
let block_table = read_block_table(
&mut file,
archive_offset + block_table_pos,
block_table_size,
file_len,
)?;
Ok(Archive {
index: Arc::new(Index {
path,
archive_offset,
sector_size,
hash_table,
block_table,
}),
})
}
/// Whether the archive holds `name` (case-insensitive; `/` and `\` equivalent). No I/O.
pub fn contains(&self, name: &str) -> bool {
self.index.find(name).is_some()
}
/// The uncompressed size of `name`, if present. No I/O (read from the block table).
pub fn file_size(&self, name: &str) -> Option<u32> {
self.index.find(name).map(|b| b.file_size)
}
/// Whether `name`'s entry is a **delete-marker** (a patch tombstone) rather than a readable file.
/// [`Archive::contains`] is still `true` for one (the hash entry exists); the chain walker calls
/// this to tell "the client deleted this path" (stop, don't fall through) from "readable file"
/// (decision 0246). No I/O.
pub fn is_delete_marker(&self, name: &str) -> bool {
self.index
.find(name)
.is_some_and(|b| b.flags & FLAG_DELETE_MARKER != 0)
}
/// The archive's path.
pub fn path(&self) -> &Path {
&self.index.path
}
/// Read and decompress a file by its internal path. `/` or `\`; case-insensitive. `&self`: opens a
/// fresh OS handle, so concurrent reads need no lock.
pub fn read_file(&self, name: &str) -> Result<Vec<u8>> {
let idx = &self.index;
let block = idx.find(name).ok_or_else(|| Error::NotFound(name.into()))?;
if block.flags & FLAG_EXISTS == 0 {
return Err(Error::NotFound(name.into()));
}
// A delete-marker is a tombstone, not a file: `size == 0` with no data. Reading it as a
// stored 0-byte file would hand back an empty buffer that looks like a real (empty) file —
// the silent mislead of 0246. It is "not found" (the composite deleted this path).
if block.flags & FLAG_DELETE_MARKER != 0 {
return Err(Error::NotFound(name.into()));
}
// The verified 1.12.1 envelope: no encrypted files, no single-unit. Refuse rather than guess.
if block.flags & FLAG_ENCRYPTED != 0 {
return Err(Error::Unsupported(format!("encrypted file {name}")));
}
if block.flags & FLAG_SINGLE_UNIT != 0 {
return Err(Error::Unsupported(format!("single-unit file {name}")));
}
let mut file = File::open(&idx.path)?;
let file_pos = idx.archive_offset + block.file_pos as u64;
let file_size = block.file_size as usize;
let compressed = block.flags & (FLAG_COMPRESS | FLAG_IMPLODE) != 0;
let implode_only = block.flags & FLAG_IMPLODE != 0 && block.flags & FLAG_COMPRESS == 0;
if !compressed {
// Stored: raw bytes, exactly file_size.
file.seek(SeekFrom::Start(file_pos))?;
let mut out = vec![0u8; file_size];
file.read_exact(&mut out)?;
return Ok(out);
}
// Sectored read. The offset table is `sector_count + 1` u32s at file_pos; sector offsets are
// absolute from file_pos, so a SECTOR_CRC table (present only on the patch archives) sits
// between the offset table and the first sector and is skipped for free.
//
// `file_size` (hence `sector_count`) and each sector's `comp_len` all come from the
// block-table entry / on-disk offset table — the same attacker-controllable-header shape as
// the hash/block tables (decision 0064). Cap every reservation below by what the archive file
// could actually hold from `file_pos` on, so a corrupt entry fails cleanly instead of the
// allocator aborting.
let file_len = file.metadata()?.len();
let avail = avail_from(file_len, file_pos);
let sector_count = file_size.div_ceil(idx.sector_size);
let otab_len = sector_count.checked_add(1).ok_or_else(|| {
Error::Corrupt(format!("{name}: sector count overflow ({sector_count})"))
})?;
let otab_cap = capped(otab_len, 4, avail);
if otab_cap < otab_len {
return Err(Error::Corrupt(format!(
"{name}: sector offset table ({otab_len} entries) larger than the archive"
)));
}
file.seek(SeekFrom::Start(file_pos))?;
let mut otab = vec![0u8; otab_cap * 4]; // == otab_len * 4, proven <= avail above
file.read_exact(&mut otab)?;
let offsets: Vec<u32> = otab
.chunks_exact(4)
.map(|c| {
c.u32_at(0)
.ok_or_else(|| Error::Corrupt(format!("{name}: corrupt sector offset table")))
})
.collect::<Result<Vec<u32>>>()?;
let mut out = Vec::with_capacity(capped(file_size, 1, avail));
for i in 0..sector_count {
let start = offsets[i] as u64;
let end = offsets[i + 1] as u64;
let comp_len = end
.checked_sub(start)
.ok_or_else(|| Error::Decompress(format!("{name}: sector {i} offsets reversed")))?
as usize;
let comp_cap = capped(comp_len, 1, avail);
if comp_cap < comp_len {
return Err(Error::Corrupt(format!(
"{name}: sector {i} length ({comp_len}) larger than the archive"
)));
}
let want = (file_size - out.len()).min(idx.sector_size); // last sector may be short
file.seek(SeekFrom::Start(file_pos + start))?;
let mut raw = vec![0u8; comp_len]; // proven <= avail above
file.read_exact(&mut raw)?;
if comp_len >= want {
// Not actually compressed (a sector that wouldn't shrink is stored verbatim).
out.extend_from_slice(&raw[..want]);
} else if implode_only {
// IMPLODE files carry no per-sector method byte. (Verified absent in 1.12.1; kept for
// completeness so the shape is explicit rather than a silent fall-through.)
out.extend_from_slice(&decompress(0x08, &raw, want, name)?);
} else {
// COMPRESS: leading byte is the codec mask, rest is the payload.
let method = raw[0];
out.extend_from_slice(&decompress(method, &raw[1..], want, name)?);
}
}
Ok(out)
}
}
impl Index {
/// Locate a file's block entry via the hash table (Blizzard's open-addressed probe).
fn find(&self, name: &str) -> Option<BlockEntry> {
let len = self.hash_table.len();
if len == 0 {
return None;
}
let mask = (len - 1) as u32;
let start = (hash_string(name, hash_type::TABLE_OFFSET) & mask) as usize;
let want_a = hash_string(name, hash_type::NAME_A);
let want_b = hash_string(name, hash_type::NAME_B);
for i in 0..len {
let e = &self.hash_table[(start + i) % len];
if e.block_index == HASH_EMPTY {
return None; // never-used slot ends the chain
}
if e.block_index == HASH_DELETED {
continue;
}
if e.name_a == want_a && e.name_b == want_b {
return self.block_table.get(e.block_index as usize).copied();
}
}
None
}
}
/// Scan 512-aligned boundaries for the MPQ header, resolving a `MPQ\x1B` user-data header to the real
/// one it points at. Returns the byte offset of the `MPQ\x1A` header.
fn find_header(file: &mut File, file_len: u64) -> Result<u64> {
let mut off = 0u64;
while off + 4 <= file_len {
file.seek(SeekFrom::Start(off))?;
let mut sig = [0u8; 4];
if file.read_exact(&mut sig).is_err() {
break;
}
match u32::from_le_bytes(sig) {
SIGNATURE => return Ok(off),
USERDATA_SIGNATURE => {
// user-data header: u32 sig, u32 user_data_size, u32 header_offset (relative to here).
let mut rest = [0u8; 8];
file.read_exact(&mut rest)?;
let header_offset = u32::from_le_bytes([rest[4], rest[5], rest[6], rest[7]]) as u64;
return Ok(off + header_offset);
}
_ => {}
}
off += 512;
}
Err(Error::NotMpq)
}
fn read_hash_table(
file: &mut File,
pos: u64,
count: usize,
file_len: u64,
) -> Result<Vec<HashEntry>> {
// `count` is a header u32 (decision 0064): cap the reservation by what the archive could actually
// hold from `pos` on, so a lying header can at worst reserve the file's own size. If the capped
// reservation had to shrink below `count`, the header is lying (or the file is truncated) — fail
// cleanly here rather than let the entry-building loop below index past a short `words`.
let avail = avail_from(file_len, pos);
let cap = capped(count, 16, avail);
if cap < count {
return Err(Error::Corrupt(format!(
"hash table: header claims {count} entries, only room for {cap} in the archive"
)));
}
let mut bytes = vec![0u8; cap * 16]; // == count * 16, proven <= avail above — never a wild reserve
file.seek(SeekFrom::Start(pos))?;
file.read_exact(&mut bytes)?;
let mut words = to_u32s(&bytes);
decrypt_block(&mut words, hash_string("(hash table)", hash_type::FILE_KEY));
Ok((0..count)
.map(|i| {
let o = i * 4;
HashEntry {
name_a: words[o],
name_b: words[o + 1],
// words[o+2] packs locale(u16)+platform(u16); 1.12.1 data is locale-neutral, ignore.
block_index: words[o + 3],
}
})
.collect())
}
fn read_block_table(
file: &mut File,
pos: u64,
count: usize,
file_len: u64,
) -> Result<Vec<BlockEntry>> {
// Same treatment as `read_hash_table` above: `count` is an attacker-controllable header u32.
let avail = avail_from(file_len, pos);
let cap = capped(count, 16, avail);
if cap < count {
return Err(Error::Corrupt(format!(
"block table: header claims {count} entries, only room for {cap} in the archive"
)));
}
let mut bytes = vec![0u8; cap * 16]; // == count * 16, proven <= avail above
file.seek(SeekFrom::Start(pos))?;
file.read_exact(&mut bytes)?;
let mut words = to_u32s(&bytes);
decrypt_block(
&mut words,
hash_string("(block table)", hash_type::FILE_KEY),
);
Ok((0..count)
.map(|i| {
let o = i * 4;
BlockEntry {
file_pos: words[o],
// words[o + 1] = compressed_size on disk; unused (offset table sizes sectors).
file_size: words[o + 2],
flags: words[o + 3],
}
})
.collect())
}
/// Decompress one sector payload by its MPQ codec mask. 1.12.1 uses ZLIB (and store, handled by the
/// caller); any other mask is an error so the differential pass flags it for us to add deliberately.
fn decompress(method: u8, data: &[u8], expected: usize, name: &str) -> Result<Vec<u8>> {
match method {
0x02 => {
use flate2::read::ZlibDecoder;
let mut out = Vec::with_capacity(expected);
ZlibDecoder::new(data)
.read_to_end(&mut out)
.map_err(|e| Error::Decompress(format!("{name}: zlib: {e}")))?;
Ok(out)
}
other => Err(Error::Unsupported(format!(
"{name}: sector codec 0x{other:02X} not yet implemented"
))),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU32, Ordering};
/// A unique path under the OS temp dir — no `tempfile` dependency (decision 0064 keeps the leaf
/// crates dependency-light), so uniqueness is hand-rolled: pid + a per-process counter, enough to
/// never collide across parallel `cargo test` threads in this crate.
fn temp_path(tag: &str) -> PathBuf {
static COUNTER: AtomicU32 = AtomicU32::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!(
"benilla_mpq_test_{}_{}_{}.mpq",
std::process::id(),
n,
tag
))
}
/// Build a minimal 32-byte V1 MPQ header (unused fields zeroed) at the start of a buffer.
fn header(
block_size_shift: u16,
hash_table_pos: u32,
block_table_pos: u32,
hash_table_size: u32,
block_table_size: u32,
) -> [u8; 32] {
let mut h = [0u8; 32];
h[0..4].copy_from_slice(&SIGNATURE.to_le_bytes());
h[14..16].copy_from_slice(&block_size_shift.to_le_bytes());
h[16..20].copy_from_slice(&hash_table_pos.to_le_bytes());
h[20..24].copy_from_slice(&block_table_pos.to_le_bytes());
h[24..28].copy_from_slice(&hash_table_size.to_le_bytes());
h[28..32].copy_from_slice(&block_table_size.to_le_bytes());
h
}
/// Write `bytes` to a fresh temp file, run `Archive::open` on it, and remove the file afterwards
/// regardless of the outcome.
fn open_temp(tag: &str, bytes: &[u8]) -> Result<Archive> {
let path = temp_path(tag);
std::fs::write(&path, bytes).expect("write temp archive");
let result = Archive::open(&path);
let _ = std::fs::remove_file(&path);
result
}
#[test]
fn open_rejects_hash_table_count_bigger_than_the_file() {
// A header claiming u32::MAX hash-table entries in a 40-byte file: pre-0064 this would have
// tried `vec![0u8; u32::MAX as usize * 16]` (~68 GiB) and aborted the allocator. It must now
// return a clean `Error::Corrupt` instead.
let hdr = header(0, 32, 32, u32::MAX, 0);
let mut bytes = hdr.to_vec();
bytes.extend_from_slice(&[0u8; 8]); // pad past the header so `pos` (32) is in-bounds
match open_temp("hash_overflow", &bytes) {
Err(Error::Corrupt(_)) => {}
Ok(_) => panic!("expected Error::Corrupt, got Ok(_)"),
Err(other) => panic!("expected Error::Corrupt, got {other:?}"),
}
}
#[test]
fn open_rejects_block_table_count_bigger_than_the_file() {
let hdr = header(0, 32, 32, 0, u32::MAX);
let mut bytes = hdr.to_vec();
bytes.extend_from_slice(&[0u8; 8]);
match open_temp("block_overflow", &bytes) {
Err(Error::Corrupt(_)) => {}
Ok(_) => panic!("expected Error::Corrupt, got Ok(_)"),
Err(other) => panic!("expected Error::Corrupt, got {other:?}"),
}
}
#[test]
fn open_accepts_a_valid_archive_with_empty_tables() {
// The boundary case the cap must not reject: zero-entry tables pointing exactly at EOF. Proves
// the new cap logic doesn't disturb a legitimately-shaped (if degenerate) archive.
let hdr = header(0, 32, 32, 0, 0);
let archive =
open_temp("empty_tables", &hdr).expect("a well-formed empty-table archive must open");
assert!(!archive.contains("anything"));
assert_eq!(archive.file_size("anything"), None);
}
/// Like [`open_temp`] but leaves the backing file on disk so [`Archive::read_file`] (which reopens
/// the archive by path) works; returns the path for the caller to remove.
fn open_temp_kept(tag: &str, bytes: &[u8]) -> (Archive, PathBuf) {
let path = temp_path(tag);
std::fs::write(&path, bytes).expect("write temp archive");
let archive = Archive::open(&path).expect("open temp archive");
(archive, path)
}
/// Build a minimal V1 archive holding exactly one entry (`name`) with the given block `flags` and
/// stored `data`. Tables are encrypted with the real keys so the reader's decrypt round-trips
/// them — enough to exercise flag handling (delete-marker vs. a plain stored file).
fn archive_with_one_entry(name: &str, flags: u32, data: &[u8]) -> Vec<u8> {
use crypto::{encrypt_block, hash_type};
const HASH_SLOTS: u32 = 4; // power of two — the reader masks with len-1
let hash_pos = 32u32;
let block_pos = hash_pos + HASH_SLOTS * 16;
let data_pos = block_pos + 16; // one 16-byte block entry
// Hash table (plaintext): every slot empty (0xFFFFFFFF) except our name's home slot.
let mut hash = vec![0xFFFF_FFFFu32; HASH_SLOTS as usize * 4];
let slot = (hash_string(name, hash_type::TABLE_OFFSET) & (HASH_SLOTS - 1)) as usize;
hash[slot * 4] = hash_string(name, hash_type::NAME_A);
hash[slot * 4 + 1] = hash_string(name, hash_type::NAME_B);
hash[slot * 4 + 2] = 0; // locale | platform
hash[slot * 4 + 3] = 0; // → block index 0
encrypt_block(&mut hash, hash_string("(hash table)", hash_type::FILE_KEY));
// Block table (plaintext): one entry pointing at the trailing data.
let mut block = vec![data_pos, data.len() as u32, data.len() as u32, flags];
encrypt_block(
&mut block,
hash_string("(block table)", hash_type::FILE_KEY),
);
let mut bytes = header(0, hash_pos, block_pos, HASH_SLOTS, 1).to_vec();
for w in hash.into_iter().chain(block) {
bytes.extend_from_slice(&w.to_le_bytes());
}
bytes.extend_from_slice(data);
bytes
}
/// The builder is honest: a plain stored (EXISTS-only) entry reads its bytes back and is not a
/// delete-marker — so the negative result in the next test can't be a broken-archive artifact.
#[test]
fn stored_entry_reads_its_bytes() {
let (arc, path) = open_temp_kept(
"stored",
&archive_with_one_entry("Interface\\a.txt", FLAG_EXISTS, b"hello"),
);
assert!(arc.contains("Interface\\a.txt"));
assert!(!arc.is_delete_marker("Interface\\a.txt"));
assert_eq!(arc.read_file("Interface\\a.txt").unwrap(), b"hello");
let _ = std::fs::remove_file(&path);
}
/// A delete-marker (`EXISTS | DELETE_MARKER`, size 0) is a tombstone, not an empty file: it is
/// still `contains`ed (the hash entry exists) and flagged `is_delete_marker`, but `read_file`
/// refuses it with `NotFound` rather than handing back an empty buffer (decision 0246 — the silent
/// mislead that made the trainer arc quote a stale, deleted stock file).
#[test]
fn delete_marker_is_not_a_readable_empty_file() {
let name = "Interface\\FrameXML\\ClassTrainerFrame.xml";
let (arc, path) = open_temp_kept(
"delete_marker",
&archive_with_one_entry(name, FLAG_EXISTS | FLAG_DELETE_MARKER, &[]),
);
assert!(arc.contains(name), "the hash entry exists");
assert!(arc.is_delete_marker(name), "flagged as a tombstone");
// The file is present on disk, so a NotFound here is the tombstone guard firing — not a
// missing backing file.
match arc.read_file(name) {
Err(Error::NotFound(_)) => {}
other => panic!("expected NotFound for a tombstone, got {other:?}"),
}
let _ = std::fs::remove_file(&path);
}
#[test]
fn avail_from_never_underflows_when_pos_exceeds_file_len() {
// `pos` derived from a corrupt header can point past EOF; the helper must saturate to 0
// rather than wrap `file_len - pos` around to a huge usize.
assert_eq!(avail_from(10, 100), 0);
assert_eq!(avail_from(100, 10), 90);
assert_eq!(avail_from(0, 0), 0);
}
}