diff options
Diffstat (limited to 'crates')
| -rw-r--r-- | crates/common/Cargo.toml | 6 | ||||
| -rw-r--r-- | crates/common/src/lib.rs | 44 | ||||
| -rw-r--r-- | crates/nres/Cargo.toml | 10 | ||||
| -rw-r--r-- | crates/nres/README.md | 42 | ||||
| -rw-r--r-- | crates/nres/src/error.rs | 110 | ||||
| -rw-r--r-- | crates/nres/src/lib.rs | 702 | ||||
| -rw-r--r-- | crates/nres/src/tests.rs | 996 | ||||
| -rw-r--r-- | crates/rsli/Cargo.toml | 8 | ||||
| -rw-r--r-- | crates/rsli/README.md | 58 | ||||
| -rw-r--r-- | crates/rsli/src/compress/deflate.rs | 14 | ||||
| -rw-r--r-- | crates/rsli/src/compress/lzh.rs | 298 | ||||
| -rw-r--r-- | crates/rsli/src/compress/lzss.rs | 79 | ||||
| -rw-r--r-- | crates/rsli/src/compress/mod.rs | 9 | ||||
| -rw-r--r-- | crates/rsli/src/compress/xor.rs | 29 | ||||
| -rw-r--r-- | crates/rsli/src/error.rs | 140 | ||||
| -rw-r--r-- | crates/rsli/src/lib.rs | 411 | ||||
| -rw-r--r-- | crates/rsli/src/parse.rs | 267 | ||||
| -rw-r--r-- | crates/rsli/src/tests.rs | 1337 |
18 files changed, 4560 insertions, 0 deletions
diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml new file mode 100644 index 0000000..e020b17 --- /dev/null +++ b/crates/common/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "common" +version = "0.1.0" +edition = "2021" + +[dependencies] diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs new file mode 100644 index 0000000..69796d3 --- /dev/null +++ b/crates/common/src/lib.rs @@ -0,0 +1,44 @@ +use std::io; + +/// Resource payload that can be either borrowed from mapped bytes or owned. +#[derive(Clone, Debug)] +pub enum ResourceData<'a> { + Borrowed(&'a [u8]), + Owned(Vec<u8>), +} + +impl<'a> ResourceData<'a> { + pub fn as_slice(&self) -> &[u8] { + match self { + Self::Borrowed(slice) => slice, + Self::Owned(buf) => buf.as_slice(), + } + } + + pub fn into_owned(self) -> Vec<u8> { + match self { + Self::Borrowed(slice) => slice.to_vec(), + Self::Owned(buf) => buf, + } + } +} + +impl AsRef<[u8]> for ResourceData<'_> { + fn as_ref(&self) -> &[u8] { + self.as_slice() + } +} + +/// Output sink used by `read_into`/`load_into` APIs. +pub trait OutputBuffer { + /// Writes the full payload to the sink, replacing any previous content. + fn write_exact(&mut self, data: &[u8]) -> io::Result<()>; +} + +impl OutputBuffer for Vec<u8> { + fn write_exact(&mut self, data: &[u8]) -> io::Result<()> { + self.clear(); + self.extend_from_slice(data); + Ok(()) + } +} diff --git a/crates/nres/Cargo.toml b/crates/nres/Cargo.toml new file mode 100644 index 0000000..38b8822 --- /dev/null +++ b/crates/nres/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "nres" +version = "0.1.0" +edition = "2021" + +[dependencies] +common = { path = "../common" } + +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem"] } diff --git a/crates/nres/README.md b/crates/nres/README.md new file mode 100644 index 0000000..8b9dfb5 --- /dev/null +++ b/crates/nres/README.md @@ -0,0 +1,42 @@ +# nres + +Rust-библиотека для работы с архивами формата **NRes**. + +## Что умеет + +- Открытие архива из файла (`open_path`) и из памяти (`open_bytes`). +- Поддержка `raw_mode` (весь файл как единый ресурс). +- Чтение метаданных и итерация по записям. +- Поиск по имени без учёта регистра (`find`). +- Чтение данных ресурса (`read`, `read_into`, `raw_slice`). +- Редактирование архива через `Editor`: +- `add`, `replace_data`, `remove`. +- `commit` с пересчётом `sort_index`, выравниванием по 8 байт и атомарной записью файла. + +## Модель ошибок + +Библиотека возвращает типизированные ошибки (`InvalidMagic`, `UnsupportedVersion`, `TotalSizeMismatch`, `DirectoryOutOfBounds`, `EntryDataOutOfBounds`, и др.) без паник в production-коде. + +## Покрытие тестами + +### Реальные файлы + +- Рекурсивный прогон по `testdata/nres/**`. +- Сейчас в наборе: **120 архивов**. +- Для каждого архива проверяется: +- чтение всех записей; +- `read`/`read_into`/`raw_slice`; +- `find`; +- `unpack -> repack (Editor::commit)` с проверкой **byte-to-byte**. + +### Синтетические тесты + +- Проверка основных сценариев редактирования (`add/replace/remove/commit`). +- Проверка валидации и ошибок: +- `InvalidMagic`, `UnsupportedVersion`, `TotalSizeMismatch`, `InvalidEntryCount`, `DirectoryOutOfBounds`, `NameTooLong`, `EntryDataOutOfBounds`, `EntryIdOutOfRange`, `NameContainsNul`. + +## Быстрый запуск тестов + +```bash +cargo test -p nres -- --nocapture +``` diff --git a/crates/nres/src/error.rs b/crates/nres/src/error.rs new file mode 100644 index 0000000..9a3c651 --- /dev/null +++ b/crates/nres/src/error.rs @@ -0,0 +1,110 @@ +use core::fmt; + +#[derive(Debug)] +#[non_exhaustive] +pub enum Error { + Io(std::io::Error), + + InvalidMagic { + got: [u8; 4], + }, + UnsupportedVersion { + got: u32, + }, + TotalSizeMismatch { + header: u32, + actual: u64, + }, + + InvalidEntryCount { + got: i32, + }, + TooManyEntries { + got: usize, + }, + DirectoryOutOfBounds { + directory_offset: u64, + directory_len: u64, + file_len: u64, + }, + + EntryIdOutOfRange { + id: u32, + entry_count: u32, + }, + EntryDataOutOfBounds { + id: u32, + offset: u64, + size: u32, + directory_offset: u64, + }, + NameTooLong { + got: usize, + max: usize, + }, + NameContainsNul, + BadNameEncoding, + + IntegerOverflow, + + RawModeDisallowsOperation(&'static str), +} + +impl From<std::io::Error> for Error { + fn from(value: std::io::Error) -> Self { + Self::Io(value) + } +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Error::Io(e) => write!(f, "I/O error: {e}"), + Error::InvalidMagic { got } => write!(f, "invalid NRes magic: {got:02X?}"), + Error::UnsupportedVersion { got } => { + write!(f, "unsupported NRes version: {got:#x}") + } + Error::TotalSizeMismatch { header, actual } => { + write!(f, "NRes total_size mismatch: header={header}, actual={actual}") + } + Error::InvalidEntryCount { got } => write!(f, "invalid entry_count: {got}"), + Error::TooManyEntries { got } => write!(f, "too many entries: {got} exceeds u32::MAX"), + Error::DirectoryOutOfBounds { + directory_offset, + directory_len, + file_len, + } => write!( + f, + "directory out of bounds: off={directory_offset}, len={directory_len}, file={file_len}" + ), + Error::EntryIdOutOfRange { id, entry_count } => { + write!(f, "entry id out of range: id={id}, count={entry_count}") + } + Error::EntryDataOutOfBounds { + id, + offset, + size, + directory_offset, + } => write!( + f, + "entry data out of bounds: id={id}, off={offset}, size={size}, dir_off={directory_offset}" + ), + Error::NameTooLong { got, max } => write!(f, "name too long: {got} > {max}"), + Error::NameContainsNul => write!(f, "name contains NUL byte"), + Error::BadNameEncoding => write!(f, "bad name encoding"), + Error::IntegerOverflow => write!(f, "integer overflow"), + Error::RawModeDisallowsOperation(op) => { + write!(f, "operation not allowed in raw mode: {op}") + } + } + } +} + +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Io(err) => Some(err), + _ => None, + } + } +} diff --git a/crates/nres/src/lib.rs b/crates/nres/src/lib.rs new file mode 100644 index 0000000..e0631e3 --- /dev/null +++ b/crates/nres/src/lib.rs @@ -0,0 +1,702 @@ +pub mod error; + +use crate::error::Error; +use common::{OutputBuffer, ResourceData}; +use core::ops::Range; +use std::cmp::Ordering; +use std::fs::{self, OpenOptions as FsOpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; + +pub type Result<T> = core::result::Result<T, Error>; + +#[derive(Clone, Debug, Default)] +pub struct OpenOptions { + pub raw_mode: bool, + pub sequential_hint: bool, + pub prefetch_pages: bool, +} + +#[derive(Clone, Debug, Default)] +pub enum OpenMode { + #[default] + ReadOnly, + ReadWrite, +} + +#[derive(Debug)] +pub struct Archive { + bytes: Arc<[u8]>, + entries: Vec<EntryRecord>, + raw_mode: bool, +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] +pub struct EntryId(pub u32); + +#[derive(Clone, Debug)] +pub struct EntryMeta { + pub kind: u32, + pub attr1: u32, + pub attr2: u32, + pub attr3: u32, + pub name: String, + pub data_offset: u64, + pub data_size: u32, + pub sort_index: u32, +} + +#[derive(Copy, Clone, Debug)] +pub struct EntryRef<'a> { + pub id: EntryId, + pub meta: &'a EntryMeta, +} + +#[derive(Clone, Debug)] +struct EntryRecord { + meta: EntryMeta, + name_raw: [u8; 36], +} + +impl Archive { + pub fn open_path(path: impl AsRef<Path>) -> Result<Self> { + Self::open_path_with(path, OpenMode::ReadOnly, OpenOptions::default()) + } + + pub fn open_path_with( + path: impl AsRef<Path>, + _mode: OpenMode, + opts: OpenOptions, + ) -> Result<Self> { + let bytes = fs::read(path.as_ref())?; + let arc: Arc<[u8]> = Arc::from(bytes.into_boxed_slice()); + Self::open_bytes(arc, opts) + } + + pub fn open_bytes(bytes: Arc<[u8]>, opts: OpenOptions) -> Result<Self> { + let (entries, _) = parse_archive(&bytes, opts.raw_mode)?; + if opts.prefetch_pages { + prefetch_pages(&bytes); + } + Ok(Self { + bytes, + entries, + raw_mode: opts.raw_mode, + }) + } + + pub fn entry_count(&self) -> usize { + self.entries.len() + } + + pub fn entries(&self) -> impl Iterator<Item = EntryRef<'_>> { + self.entries + .iter() + .enumerate() + .map(|(idx, entry)| EntryRef { + id: EntryId(u32::try_from(idx).expect("entry count validated at parse")), + meta: &entry.meta, + }) + } + + pub fn find(&self, name: &str) -> Option<EntryId> { + if self.entries.is_empty() { + return None; + } + + if !self.raw_mode { + let mut low = 0usize; + let mut high = self.entries.len(); + while low < high { + let mid = low + (high - low) / 2; + let Ok(target_idx) = usize::try_from(self.entries[mid].meta.sort_index) else { + break; + }; + if target_idx >= self.entries.len() { + break; + } + let cmp = cmp_name_case_insensitive( + name.as_bytes(), + entry_name_bytes(&self.entries[target_idx].name_raw), + ); + match cmp { + Ordering::Less => high = mid, + Ordering::Greater => low = mid + 1, + Ordering::Equal => { + return Some(EntryId( + u32::try_from(target_idx).expect("entry count validated at parse"), + )) + } + } + } + } + + self.entries.iter().enumerate().find_map(|(idx, entry)| { + if cmp_name_case_insensitive(name.as_bytes(), entry_name_bytes(&entry.name_raw)) + == Ordering::Equal + { + Some(EntryId( + u32::try_from(idx).expect("entry count validated at parse"), + )) + } else { + None + } + }) + } + + pub fn get(&self, id: EntryId) -> Option<EntryRef<'_>> { + let idx = usize::try_from(id.0).ok()?; + let entry = self.entries.get(idx)?; + Some(EntryRef { + id, + meta: &entry.meta, + }) + } + + pub fn read(&self, id: EntryId) -> Result<ResourceData<'_>> { + let range = self.entry_range(id)?; + Ok(ResourceData::Borrowed(&self.bytes[range])) + } + + pub fn read_into(&self, id: EntryId, out: &mut dyn OutputBuffer) -> Result<usize> { + let range = self.entry_range(id)?; + out.write_exact(&self.bytes[range.clone()])?; + Ok(range.len()) + } + + pub fn raw_slice(&self, id: EntryId) -> Result<Option<&[u8]>> { + let range = self.entry_range(id)?; + Ok(Some(&self.bytes[range])) + } + + pub fn edit_path(path: impl AsRef<Path>) -> Result<Editor> { + let path_buf = path.as_ref().to_path_buf(); + let bytes = fs::read(&path_buf)?; + let arc: Arc<[u8]> = Arc::from(bytes.into_boxed_slice()); + let (entries, _) = parse_archive(&arc, false)?; + let mut editable = Vec::with_capacity(entries.len()); + for entry in &entries { + let range = checked_range(entry.meta.data_offset, entry.meta.data_size, arc.len())?; + editable.push(EditableEntry { + meta: entry.meta.clone(), + name_raw: entry.name_raw, + data: EntryData::Borrowed(range), // Copy-on-write: only store range + }); + } + Ok(Editor { + path: path_buf, + source: arc, + entries: editable, + }) + } + + fn entry_range(&self, id: EntryId) -> Result<Range<usize>> { + let idx = usize::try_from(id.0).map_err(|_| Error::IntegerOverflow)?; + let Some(entry) = self.entries.get(idx) else { + return Err(Error::EntryIdOutOfRange { + id: id.0, + entry_count: self.entries.len().try_into().unwrap_or(u32::MAX), + }); + }; + checked_range( + entry.meta.data_offset, + entry.meta.data_size, + self.bytes.len(), + ) + } +} + +pub struct Editor { + path: PathBuf, + source: Arc<[u8]>, + entries: Vec<EditableEntry>, +} + +#[derive(Clone, Debug)] +enum EntryData { + Borrowed(Range<usize>), + Modified(Vec<u8>), +} + +#[derive(Clone, Debug)] +struct EditableEntry { + meta: EntryMeta, + name_raw: [u8; 36], + data: EntryData, +} + +impl EditableEntry { + fn data_slice<'a>(&'a self, source: &'a Arc<[u8]>) -> &'a [u8] { + match &self.data { + EntryData::Borrowed(range) => &source[range.clone()], + EntryData::Modified(vec) => vec.as_slice(), + } + } +} + +#[derive(Clone, Debug)] +pub struct NewEntry<'a> { + pub kind: u32, + pub attr1: u32, + pub attr2: u32, + pub attr3: u32, + pub name: &'a str, + pub data: &'a [u8], +} + +impl Editor { + pub fn entries(&self) -> impl Iterator<Item = EntryRef<'_>> { + self.entries + .iter() + .enumerate() + .map(|(idx, entry)| EntryRef { + id: EntryId(u32::try_from(idx).expect("entry count validated at add")), + meta: &entry.meta, + }) + } + + pub fn add(&mut self, entry: NewEntry<'_>) -> Result<EntryId> { + let name_raw = encode_name_field(entry.name)?; + let id_u32 = u32::try_from(self.entries.len()).map_err(|_| Error::IntegerOverflow)?; + let data_size = u32::try_from(entry.data.len()).map_err(|_| Error::IntegerOverflow)?; + self.entries.push(EditableEntry { + meta: EntryMeta { + kind: entry.kind, + attr1: entry.attr1, + attr2: entry.attr2, + attr3: entry.attr3, + name: decode_name(entry_name_bytes(&name_raw)), + data_offset: 0, + data_size, + sort_index: 0, + }, + name_raw, + data: EntryData::Modified(entry.data.to_vec()), + }); + Ok(EntryId(id_u32)) + } + + pub fn replace_data(&mut self, id: EntryId, data: &[u8]) -> Result<()> { + let idx = usize::try_from(id.0).map_err(|_| Error::IntegerOverflow)?; + let Some(entry) = self.entries.get_mut(idx) else { + return Err(Error::EntryIdOutOfRange { + id: id.0, + entry_count: self.entries.len().try_into().unwrap_or(u32::MAX), + }); + }; + entry.meta.data_size = u32::try_from(data.len()).map_err(|_| Error::IntegerOverflow)?; + // Replace with new data (triggers copy-on-write if borrowed) + entry.data = EntryData::Modified(data.to_vec()); + Ok(()) + } + + pub fn remove(&mut self, id: EntryId) -> Result<()> { + let idx = usize::try_from(id.0).map_err(|_| Error::IntegerOverflow)?; + if idx >= self.entries.len() { + return Err(Error::EntryIdOutOfRange { + id: id.0, + entry_count: self.entries.len().try_into().unwrap_or(u32::MAX), + }); + } + self.entries.remove(idx); + Ok(()) + } + + pub fn commit(mut self) -> Result<()> { + let count_u32 = u32::try_from(self.entries.len()).map_err(|_| Error::IntegerOverflow)?; + + // Pre-calculate capacity to avoid reallocations + let total_data_size: usize = self + .entries + .iter() + .map(|e| e.data_slice(&self.source).len()) + .sum(); + let padding_estimate = self.entries.len() * 8; // Max 8 bytes padding per entry + let directory_size = self.entries.len() * 64; // 64 bytes per entry + let capacity = 16 + total_data_size + padding_estimate + directory_size; + + let mut out = Vec::with_capacity(capacity); + out.resize(16, 0); // Header + + // Keep reference to source for copy-on-write + let source = &self.source; + + for entry in &mut self.entries { + entry.meta.data_offset = + u64::try_from(out.len()).map_err(|_| Error::IntegerOverflow)?; + + // Calculate size and get slice separately to avoid borrow conflicts + let data_len = entry.data_slice(source).len(); + entry.meta.data_size = u32::try_from(data_len).map_err(|_| Error::IntegerOverflow)?; + + // Now get the slice again for writing + let data_slice = entry.data_slice(source); + out.extend_from_slice(data_slice); + + let padding = (8 - (out.len() % 8)) % 8; + if padding > 0 { + out.resize(out.len() + padding, 0); + } + } + + let mut sort_order: Vec<usize> = (0..self.entries.len()).collect(); + sort_order.sort_by(|a, b| { + cmp_name_case_insensitive( + entry_name_bytes(&self.entries[*a].name_raw), + entry_name_bytes(&self.entries[*b].name_raw), + ) + }); + + for (idx, entry) in self.entries.iter_mut().enumerate() { + entry.meta.sort_index = + u32::try_from(sort_order[idx]).map_err(|_| Error::IntegerOverflow)?; + } + + for entry in &self.entries { + let data_offset_u32 = + u32::try_from(entry.meta.data_offset).map_err(|_| Error::IntegerOverflow)?; + push_u32(&mut out, entry.meta.kind); + push_u32(&mut out, entry.meta.attr1); + push_u32(&mut out, entry.meta.attr2); + push_u32(&mut out, entry.meta.data_size); + push_u32(&mut out, entry.meta.attr3); + out.extend_from_slice(&entry.name_raw); + push_u32(&mut out, data_offset_u32); + push_u32(&mut out, entry.meta.sort_index); + } + + let total_size_u32 = u32::try_from(out.len()).map_err(|_| Error::IntegerOverflow)?; + out[0..4].copy_from_slice(b"NRes"); + out[4..8].copy_from_slice(&0x100_u32.to_le_bytes()); + out[8..12].copy_from_slice(&count_u32.to_le_bytes()); + out[12..16].copy_from_slice(&total_size_u32.to_le_bytes()); + + write_atomic(&self.path, &out) + } +} + +fn parse_archive(bytes: &[u8], raw_mode: bool) -> Result<(Vec<EntryRecord>, u64)> { + if raw_mode { + let data_size = u32::try_from(bytes.len()).map_err(|_| Error::IntegerOverflow)?; + let entry = EntryRecord { + meta: EntryMeta { + kind: 0, + attr1: 0, + attr2: 0, + attr3: 0, + name: String::from("RAW"), + data_offset: 0, + data_size, + sort_index: 0, + }, + name_raw: { + let mut name = [0u8; 36]; + let bytes_name = b"RAW"; + name[..bytes_name.len()].copy_from_slice(bytes_name); + name + }, + }; + return Ok(( + vec![entry], + u64::try_from(bytes.len()).map_err(|_| Error::IntegerOverflow)?, + )); + } + + if bytes.len() < 16 { + let mut got = [0u8; 4]; + let copy_len = bytes.len().min(4); + got[..copy_len].copy_from_slice(&bytes[..copy_len]); + return Err(Error::InvalidMagic { got }); + } + + let mut magic = [0u8; 4]; + magic.copy_from_slice(&bytes[0..4]); + if &magic != b"NRes" { + return Err(Error::InvalidMagic { got: magic }); + } + + let version = read_u32(bytes, 4)?; + if version != 0x100 { + return Err(Error::UnsupportedVersion { got: version }); + } + + let entry_count_i32 = i32::from_le_bytes( + bytes[8..12] + .try_into() + .map_err(|_| Error::IntegerOverflow)?, + ); + if entry_count_i32 < 0 { + return Err(Error::InvalidEntryCount { + got: entry_count_i32, + }); + } + let entry_count = usize::try_from(entry_count_i32).map_err(|_| Error::IntegerOverflow)?; + + // Validate entry_count fits in u32 (required for EntryId) + if entry_count > u32::MAX as usize { + return Err(Error::TooManyEntries { got: entry_count }); + } + + let total_size = read_u32(bytes, 12)?; + let actual_size = u64::try_from(bytes.len()).map_err(|_| Error::IntegerOverflow)?; + if u64::from(total_size) != actual_size { + return Err(Error::TotalSizeMismatch { + header: total_size, + actual: actual_size, + }); + } + + let directory_len = u64::try_from(entry_count) + .map_err(|_| Error::IntegerOverflow)? + .checked_mul(64) + .ok_or(Error::IntegerOverflow)?; + let directory_offset = + u64::from(total_size) + .checked_sub(directory_len) + .ok_or(Error::DirectoryOutOfBounds { + directory_offset: 0, + directory_len, + file_len: actual_size, + })?; + + if directory_offset < 16 || directory_offset + directory_len > actual_size { + return Err(Error::DirectoryOutOfBounds { + directory_offset, + directory_len, + file_len: actual_size, + }); + } + + let mut entries = Vec::with_capacity(entry_count); + for index in 0..entry_count { + let base = usize::try_from(directory_offset) + .map_err(|_| Error::IntegerOverflow)? + .checked_add(index.checked_mul(64).ok_or(Error::IntegerOverflow)?) + .ok_or(Error::IntegerOverflow)?; + + let kind = read_u32(bytes, base)?; + let attr1 = read_u32(bytes, base + 4)?; + let attr2 = read_u32(bytes, base + 8)?; + let data_size = read_u32(bytes, base + 12)?; + let attr3 = read_u32(bytes, base + 16)?; + + let mut name_raw = [0u8; 36]; + let name_slice = bytes + .get(base + 20..base + 56) + .ok_or(Error::IntegerOverflow)?; + name_raw.copy_from_slice(name_slice); + + let name_bytes = entry_name_bytes(&name_raw); + if name_bytes.len() > 35 { + return Err(Error::NameTooLong { + got: name_bytes.len(), + max: 35, + }); + } + + let data_offset = u64::from(read_u32(bytes, base + 56)?); + let sort_index = read_u32(bytes, base + 60)?; + + let end = data_offset + .checked_add(u64::from(data_size)) + .ok_or(Error::IntegerOverflow)?; + if data_offset < 16 || end > directory_offset { + return Err(Error::EntryDataOutOfBounds { + id: u32::try_from(index).map_err(|_| Error::IntegerOverflow)?, + offset: data_offset, + size: data_size, + directory_offset, + }); + } + + entries.push(EntryRecord { + meta: EntryMeta { + kind, + attr1, + attr2, + attr3, + name: decode_name(name_bytes), + data_offset, + data_size, + sort_index, + }, + name_raw, + }); + } + + Ok((entries, directory_offset)) +} + +fn checked_range(offset: u64, size: u32, bytes_len: usize) -> Result<Range<usize>> { + let start = usize::try_from(offset).map_err(|_| Error::IntegerOverflow)?; + let len = usize::try_from(size).map_err(|_| Error::IntegerOverflow)?; + let end = start.checked_add(len).ok_or(Error::IntegerOverflow)?; + if end > bytes_len { + return Err(Error::IntegerOverflow); + } + Ok(start..end) +} + +fn read_u32(bytes: &[u8], offset: usize) -> Result<u32> { + let data = bytes + .get(offset..offset + 4) + .ok_or(Error::IntegerOverflow)?; + let arr: [u8; 4] = data.try_into().map_err(|_| Error::IntegerOverflow)?; + Ok(u32::from_le_bytes(arr)) +} + +fn push_u32(out: &mut Vec<u8>, value: u32) { + out.extend_from_slice(&value.to_le_bytes()); +} + +fn encode_name_field(name: &str) -> Result<[u8; 36]> { + let bytes = name.as_bytes(); + if bytes.contains(&0) { + return Err(Error::NameContainsNul); + } + if bytes.len() > 35 { + return Err(Error::NameTooLong { + got: bytes.len(), + max: 35, + }); + } + + let mut out = [0u8; 36]; + out[..bytes.len()].copy_from_slice(bytes); + Ok(out) +} + +fn entry_name_bytes(raw: &[u8; 36]) -> &[u8] { + let len = raw.iter().position(|&b| b == 0).unwrap_or(raw.len()); + &raw[..len] +} + +fn decode_name(name: &[u8]) -> String { + name.iter().map(|b| char::from(*b)).collect() +} + +fn cmp_name_case_insensitive(a: &[u8], b: &[u8]) -> Ordering { + let mut idx = 0usize; + let min_len = a.len().min(b.len()); + while idx < min_len { + let left = ascii_lower(a[idx]); + let right = ascii_lower(b[idx]); + if left != right { + return left.cmp(&right); + } + idx += 1; + } + a.len().cmp(&b.len()) +} + +fn ascii_lower(value: u8) -> u8 { + if value.is_ascii_uppercase() { + value + 32 + } else { + value + } +} + +fn prefetch_pages(bytes: &[u8]) { + use std::sync::atomic::{compiler_fence, Ordering}; + + let mut cursor = 0usize; + let mut sink = 0u8; + while cursor < bytes.len() { + sink ^= bytes[cursor]; + cursor = cursor.saturating_add(4096); + } + compiler_fence(Ordering::SeqCst); + let _ = sink; +} + +fn write_atomic(path: &Path, content: &[u8]) -> Result<()> { + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("archive"); + let parent = path.parent().unwrap_or_else(|| Path::new(".")); + + let mut temp_path = None; + for attempt in 0..128u32 { + let name = format!( + ".{}.tmp.{}.{}.{}", + file_name, + std::process::id(), + unix_time_nanos(), + attempt + ); + let candidate = parent.join(name); + let opened = FsOpenOptions::new() + .create_new(true) + .write(true) + .open(&candidate); + if let Ok(mut file) = opened { + file.write_all(content)?; + file.sync_all()?; + temp_path = Some((candidate, file)); + break; + } + } + + let Some((tmp_path, mut file)) = temp_path else { + return Err(Error::Io(std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + "failed to create temporary file for atomic write", + ))); + }; + + file.flush()?; + drop(file); + + if let Err(err) = replace_file_atomically(&tmp_path, path) { + let _ = fs::remove_file(&tmp_path); + return Err(Error::Io(err)); + } + + Ok(()) +} + +#[cfg(not(windows))] +fn replace_file_atomically(src: &Path, dst: &Path) -> std::io::Result<()> { + fs::rename(src, dst) +} + +#[cfg(windows)] +fn replace_file_atomically(src: &Path, dst: &Path) -> std::io::Result<()> { + use std::iter; + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Storage::FileSystem::{ + MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, + }; + + let src_wide: Vec<u16> = src.as_os_str().encode_wide().chain(iter::once(0)).collect(); + let dst_wide: Vec<u16> = dst.as_os_str().encode_wide().chain(iter::once(0)).collect(); + + // Replace destination in one OS call, avoiding remove+rename gaps on Windows. + let ok = unsafe { + MoveFileExW( + src_wide.as_ptr(), + dst_wide.as_ptr(), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, + ) + }; + + if ok == 0 { + Err(std::io::Error::last_os_error()) + } else { + Ok(()) + } +} + +fn unix_time_nanos() -> u128 { + match SystemTime::now().duration_since(UNIX_EPOCH) { + Ok(duration) => duration.as_nanos(), + Err(_) => 0, + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/nres/src/tests.rs b/crates/nres/src/tests.rs new file mode 100644 index 0000000..6de02e5 --- /dev/null +++ b/crates/nres/src/tests.rs @@ -0,0 +1,996 @@ +use super::*; +use std::any::Any; +use std::fs; +use std::panic::{catch_unwind, AssertUnwindSafe}; + +#[derive(Clone)] +struct SyntheticEntry<'a> { + kind: u32, + attr1: u32, + attr2: u32, + attr3: u32, + name: &'a str, + data: &'a [u8], +} + +fn collect_files_recursive(root: &Path, out: &mut Vec<PathBuf>) { + let Ok(entries) = fs::read_dir(root) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect_files_recursive(&path, out); + } else if path.is_file() { + out.push(path); + } + } +} + +fn nres_test_files() -> Vec<PathBuf> { + let root = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("testdata") + .join("nres"); + let mut files = Vec::new(); + collect_files_recursive(&root, &mut files); + files.sort(); + files + .into_iter() + .filter(|path| { + fs::read(path) + .map(|data| data.get(0..4) == Some(b"NRes")) + .unwrap_or(false) + }) + .collect() +} + +fn make_temp_copy(original: &Path, bytes: &[u8]) -> PathBuf { + let mut path = std::env::temp_dir(); + let file_name = original + .file_name() + .and_then(|v| v.to_str()) + .unwrap_or("archive"); + path.push(format!( + "nres-test-{}-{}-{}", + std::process::id(), + unix_time_nanos(), + file_name + )); + fs::write(&path, bytes).expect("failed to create temp file"); + path +} + +fn panic_message(payload: Box<dyn Any + Send>) -> String { + let any = payload.as_ref(); + if let Some(message) = any.downcast_ref::<String>() { + return message.clone(); + } + if let Some(message) = any.downcast_ref::<&str>() { + return (*message).to_string(); + } + String::from("panic without message") +} + +fn read_u32_le(bytes: &[u8], offset: usize) -> u32 { + let slice = bytes + .get(offset..offset + 4) + .expect("u32 read out of bounds in test"); + let arr: [u8; 4] = slice.try_into().expect("u32 conversion failed in test"); + u32::from_le_bytes(arr) +} + +fn read_i32_le(bytes: &[u8], offset: usize) -> i32 { + let slice = bytes + .get(offset..offset + 4) + .expect("i32 read out of bounds in test"); + let arr: [u8; 4] = slice.try_into().expect("i32 conversion failed in test"); + i32::from_le_bytes(arr) +} + +fn name_field_bytes(raw: &[u8; 36]) -> Option<&[u8]> { + let nul = raw.iter().position(|value| *value == 0)?; + Some(&raw[..nul]) +} + +fn build_nres_bytes(entries: &[SyntheticEntry<'_>]) -> Vec<u8> { + let mut out = vec![0u8; 16]; + let mut offsets = Vec::with_capacity(entries.len()); + + for entry in entries { + offsets.push(u32::try_from(out.len()).expect("offset overflow")); + out.extend_from_slice(entry.data); + let padding = (8 - (out.len() % 8)) % 8; + if padding > 0 { + out.resize(out.len() + padding, 0); + } + } + + let mut sort_order: Vec<usize> = (0..entries.len()).collect(); + sort_order.sort_by(|a, b| { + cmp_name_case_insensitive(entries[*a].name.as_bytes(), entries[*b].name.as_bytes()) + }); + + for (index, entry) in entries.iter().enumerate() { + let mut name_raw = [0u8; 36]; + let name_bytes = entry.name.as_bytes(); + assert!(name_bytes.len() <= 35, "name too long in fixture"); + name_raw[..name_bytes.len()].copy_from_slice(name_bytes); + + push_u32(&mut out, entry.kind); + push_u32(&mut out, entry.attr1); + push_u32(&mut out, entry.attr2); + push_u32( + &mut out, + u32::try_from(entry.data.len()).expect("data size overflow"), + ); + push_u32(&mut out, entry.attr3); + out.extend_from_slice(&name_raw); + push_u32(&mut out, offsets[index]); + push_u32( + &mut out, + u32::try_from(sort_order[index]).expect("sort index overflow"), + ); + } + + out[0..4].copy_from_slice(b"NRes"); + out[4..8].copy_from_slice(&0x100_u32.to_le_bytes()); + out[8..12].copy_from_slice( + &u32::try_from(entries.len()) + .expect("count overflow") + .to_le_bytes(), + ); + let total_size = u32::try_from(out.len()).expect("size overflow"); + out[12..16].copy_from_slice(&total_size.to_le_bytes()); + out +} + +#[test] +fn nres_docs_structural_invariants_all_files() { + let files = nres_test_files(); + if files.is_empty() { + eprintln!( + "skipping nres_docs_structural_invariants_all_files: no NRes archives in testdata/nres" + ); + return; + } + + for path in files { + let bytes = fs::read(&path).unwrap_or_else(|err| { + panic!("failed to read {}: {err}", path.display()); + }); + + assert!( + bytes.len() >= 16, + "NRes header too short in {}", + path.display() + ); + assert_eq!(&bytes[0..4], b"NRes", "bad magic in {}", path.display()); + assert_eq!( + read_u32_le(&bytes, 4), + 0x100, + "bad version in {}", + path.display() + ); + assert_eq!( + usize::try_from(read_u32_le(&bytes, 12)).expect("size overflow"), + bytes.len(), + "header.total_size mismatch in {}", + path.display() + ); + + let entry_count_i32 = read_i32_le(&bytes, 8); + assert!( + entry_count_i32 >= 0, + "negative entry_count={} in {}", + entry_count_i32, + path.display() + ); + let entry_count = usize::try_from(entry_count_i32).expect("entry_count overflow"); + let directory_len = entry_count.checked_mul(64).expect("directory_len overflow"); + let directory_offset = bytes + .len() + .checked_sub(directory_len) + .unwrap_or_else(|| panic!("directory underflow in {}", path.display())); + assert!( + directory_offset >= 16, + "directory offset before data area in {}", + path.display() + ); + assert_eq!( + directory_offset + directory_len, + bytes.len(), + "directory not at file end in {}", + path.display() + ); + + let mut sort_indices = Vec::with_capacity(entry_count); + let mut entries = Vec::with_capacity(entry_count); + for index in 0..entry_count { + let base = directory_offset + index * 64; + let size = usize::try_from(read_u32_le(&bytes, base + 12)).expect("size overflow"); + let data_offset = + usize::try_from(read_u32_le(&bytes, base + 56)).expect("offset overflow"); + let sort_index = + usize::try_from(read_u32_le(&bytes, base + 60)).expect("sort_index overflow"); + + let mut name_raw = [0u8; 36]; + name_raw.copy_from_slice( + bytes + .get(base + 20..base + 56) + .expect("name field out of bounds in test"), + ); + let name_bytes = name_field_bytes(&name_raw).unwrap_or_else(|| { + panic!( + "name field without NUL terminator in {} entry #{index}", + path.display() + ) + }); + assert!( + name_bytes.len() <= 35, + "name longer than 35 bytes in {} entry #{index}", + path.display() + ); + + sort_indices.push(sort_index); + entries.push((name_bytes.to_vec(), data_offset, size)); + } + + let mut expected_sort: Vec<usize> = (0..entry_count).collect(); + expected_sort.sort_by(|a, b| cmp_name_case_insensitive(&entries[*a].0, &entries[*b].0)); + assert_eq!( + sort_indices, + expected_sort, + "sort_index table mismatch in {}", + path.display() + ); + + let mut data_regions: Vec<(usize, usize)> = + entries.iter().map(|(_, off, size)| (*off, *size)).collect(); + data_regions.sort_by_key(|(off, _)| *off); + + for (idx, (data_offset, size)) in data_regions.iter().enumerate() { + assert_eq!( + data_offset % 8, + 0, + "data offset is not 8-byte aligned in {} (region #{idx})", + path.display() + ); + assert!( + *data_offset >= 16, + "data offset before header end in {} (region #{idx})", + path.display() + ); + assert!( + data_offset.checked_add(*size).unwrap_or(usize::MAX) <= directory_offset, + "data region overlaps directory in {} (region #{idx})", + path.display() + ); + } + + for pair in data_regions.windows(2) { + let (start, size) = pair[0]; + let (next_start, _) = pair[1]; + let end = start + .checked_add(size) + .unwrap_or_else(|| panic!("size overflow in {}", path.display())); + assert!( + end <= next_start, + "overlapping data regions in {}: [{start}, {end}) and next at {next_start}", + path.display() + ); + + for (offset, value) in bytes[end..next_start].iter().enumerate() { + assert_eq!( + *value, + 0, + "non-zero alignment padding in {} at offset {}", + path.display(), + end + offset + ); + } + } + } +} + +#[test] +fn nres_read_and_roundtrip_all_files() { + let files = nres_test_files(); + if files.is_empty() { + eprintln!("skipping nres_read_and_roundtrip_all_files: no NRes archives in testdata/nres"); + return; + } + + let checked = files.len(); + let mut success = 0usize; + let mut failures = Vec::new(); + + for path in files { + let display_path = path.display().to_string(); + let result = catch_unwind(AssertUnwindSafe(|| { + let original = fs::read(&path).expect("failed to read archive"); + let archive = Archive::open_path(&path) + .unwrap_or_else(|err| panic!("failed to open {}: {err}", path.display())); + + let count = archive.entry_count(); + assert_eq!( + count, + archive.entries().count(), + "entry count mismatch: {}", + path.display() + ); + + for idx in 0..count { + let id = EntryId(idx as u32); + let entry = archive + .get(id) + .unwrap_or_else(|| panic!("missing entry #{idx} in {}", path.display())); + + let payload = archive.read(id).unwrap_or_else(|err| { + panic!("read failed for {} entry #{idx}: {err}", path.display()) + }); + + let mut out = Vec::new(); + let written = archive.read_into(id, &mut out).unwrap_or_else(|err| { + panic!( + "read_into failed for {} entry #{idx}: {err}", + path.display() + ) + }); + assert_eq!( + written, + payload.as_slice().len(), + "size mismatch in {} entry #{idx}", + path.display() + ); + assert_eq!( + out.as_slice(), + payload.as_slice(), + "payload mismatch in {} entry #{idx}", + path.display() + ); + + let raw = archive + .raw_slice(id) + .unwrap_or_else(|err| { + panic!( + "raw_slice failed for {} entry #{idx}: {err}", + path.display() + ) + }) + .expect("raw_slice must return Some for file-backed archive"); + assert_eq!( + raw, + payload.as_slice(), + "raw slice mismatch in {} entry #{idx}", + path.display() + ); + + let found = archive.find(&entry.meta.name).unwrap_or_else(|| { + panic!( + "find failed for name '{}' in {}", + entry.meta.name, + path.display() + ) + }); + let found_meta = archive.get(found).expect("find returned invalid id"); + assert!( + found_meta.meta.name.eq_ignore_ascii_case(&entry.meta.name), + "find returned unrelated entry in {}", + path.display() + ); + } + + let temp_copy = make_temp_copy(&path, &original); + let mut editor = Archive::edit_path(&temp_copy) + .unwrap_or_else(|err| panic!("edit_path failed for {}: {err}", path.display())); + + for idx in 0..count { + let data = archive + .read(EntryId(idx as u32)) + .unwrap_or_else(|err| { + panic!( + "read before replace failed for {} entry #{idx}: {err}", + path.display() + ) + }) + .into_owned(); + editor + .replace_data(EntryId(idx as u32), &data) + .unwrap_or_else(|err| { + panic!( + "replace_data failed for {} entry #{idx}: {err}", + path.display() + ) + }); + } + + editor + .commit() + .unwrap_or_else(|err| panic!("commit failed for {}: {err}", path.display())); + let rebuilt = fs::read(&temp_copy).expect("failed to read rebuilt archive"); + let _ = fs::remove_file(&temp_copy); + + assert_eq!( + original, + rebuilt, + "byte-to-byte roundtrip mismatch for {}", + path.display() + ); + })); + + match result { + Ok(()) => success += 1, + Err(payload) => { + failures.push(format!("{}: {}", display_path, panic_message(payload))); + } + } + } + + let failed = failures.len(); + eprintln!( + "NRes summary: checked={}, success={}, failed={}", + checked, success, failed + ); + if !failures.is_empty() { + panic!( + "NRes validation failed.\nsummary: checked={}, success={}, failed={}\n{}", + checked, + success, + failed, + failures.join("\n") + ); + } +} + +#[test] +fn nres_raw_mode_exposes_whole_file() { + let files = nres_test_files(); + let Some(first) = files.first() else { + eprintln!("skipping nres_raw_mode_exposes_whole_file: no NRes archives in testdata/nres"); + return; + }; + let original = fs::read(first).expect("failed to read archive"); + let arc: Arc<[u8]> = Arc::from(original.clone().into_boxed_slice()); + + let archive = Archive::open_bytes( + arc, + OpenOptions { + raw_mode: true, + sequential_hint: false, + prefetch_pages: false, + }, + ) + .expect("raw mode open failed"); + + assert_eq!(archive.entry_count(), 1); + let data = archive.read(EntryId(0)).expect("raw read failed"); + assert_eq!(data.as_slice(), original.as_slice()); +} + +#[test] +fn nres_raw_mode_accepts_non_nres_bytes() { + let payload = b"not-an-nres-archive".to_vec(); + let bytes: Arc<[u8]> = Arc::from(payload.clone().into_boxed_slice()); + + match Archive::open_bytes(bytes.clone(), OpenOptions::default()) { + Err(Error::InvalidMagic { .. }) => {} + other => panic!("expected InvalidMagic without raw_mode, got {other:?}"), + } + + let archive = Archive::open_bytes( + bytes, + OpenOptions { + raw_mode: true, + sequential_hint: false, + prefetch_pages: false, + }, + ) + .expect("raw_mode should accept any bytes"); + + assert_eq!(archive.entry_count(), 1); + assert_eq!(archive.find("raw"), Some(EntryId(0))); + assert_eq!( + archive + .read(EntryId(0)) + .expect("raw read failed") + .as_slice(), + payload.as_slice() + ); +} + +#[test] +fn nres_open_options_hints_do_not_change_payload() { + let payload: Vec<u8> = (0..70_000u32).map(|v| (v % 251) as u8).collect(); + let src = build_nres_bytes(&[SyntheticEntry { + kind: 7, + attr1: 70, + attr2: 700, + attr3: 7000, + name: "big.bin", + data: &payload, + }]); + let arc: Arc<[u8]> = Arc::from(src.into_boxed_slice()); + + let baseline = Archive::open_bytes(arc.clone(), OpenOptions::default()) + .expect("baseline open should succeed"); + let hinted = Archive::open_bytes( + arc, + OpenOptions { + raw_mode: false, + sequential_hint: true, + prefetch_pages: true, + }, + ) + .expect("open with hints should succeed"); + + assert_eq!(baseline.entry_count(), 1); + assert_eq!(hinted.entry_count(), 1); + assert_eq!(baseline.find("BIG.BIN"), Some(EntryId(0))); + assert_eq!(hinted.find("big.bin"), Some(EntryId(0))); + assert_eq!( + baseline + .read(EntryId(0)) + .expect("baseline read failed") + .as_slice(), + hinted + .read(EntryId(0)) + .expect("hinted read failed") + .as_slice() + ); +} + +#[test] +fn nres_commit_empty_archive_has_minimal_layout() { + let mut path = std::env::temp_dir(); + path.push(format!( + "nres-empty-commit-{}-{}.lib", + std::process::id(), + unix_time_nanos() + )); + fs::write(&path, build_nres_bytes(&[])).expect("write empty archive failed"); + + Archive::edit_path(&path) + .expect("edit_path failed for empty archive") + .commit() + .expect("commit failed for empty archive"); + + let bytes = fs::read(&path).expect("failed to read committed archive"); + assert_eq!(bytes.len(), 16, "empty archive must contain only header"); + assert_eq!(&bytes[0..4], b"NRes"); + assert_eq!(read_u32_le(&bytes, 4), 0x100); + assert_eq!(read_u32_le(&bytes, 8), 0); + assert_eq!(read_u32_le(&bytes, 12), 16); + + let _ = fs::remove_file(&path); +} + +#[test] +fn nres_commit_recomputes_header_directory_and_sort_table() { + let mut path = std::env::temp_dir(); + path.push(format!( + "nres-commit-layout-{}-{}.lib", + std::process::id(), + unix_time_nanos() + )); + fs::write(&path, build_nres_bytes(&[])).expect("write empty archive failed"); + + let mut editor = Archive::edit_path(&path).expect("edit_path failed"); + editor + .add(NewEntry { + kind: 10, + attr1: 1, + attr2: 2, + attr3: 3, + name: "Zulu", + data: b"aaaaa", + }) + .expect("add #0 failed"); + editor + .add(NewEntry { + kind: 11, + attr1: 4, + attr2: 5, + attr3: 6, + name: "alpha", + data: b"bbbbbbbb", + }) + .expect("add #1 failed"); + editor + .add(NewEntry { + kind: 12, + attr1: 7, + attr2: 8, + attr3: 9, + name: "Beta", + data: b"cccc", + }) + .expect("add #2 failed"); + editor.commit().expect("commit failed"); + + let bytes = fs::read(&path).expect("failed to read committed archive"); + assert_eq!(&bytes[0..4], b"NRes"); + assert_eq!(read_u32_le(&bytes, 4), 0x100); + + let entry_count = usize::try_from(read_u32_le(&bytes, 8)).expect("entry_count overflow"); + let total_size = usize::try_from(read_u32_le(&bytes, 12)).expect("total_size overflow"); + assert_eq!(entry_count, 3); + assert_eq!(total_size, bytes.len()); + + let directory_offset = total_size + .checked_sub(entry_count * 64) + .expect("invalid directory offset"); + assert!(directory_offset >= 16); + + let mut sort_indices = Vec::new(); + let mut prev_data_end = 16usize; + for idx in 0..entry_count { + let base = directory_offset + idx * 64; + let data_size = usize::try_from(read_u32_le(&bytes, base + 12)).expect("size overflow"); + let data_offset = usize::try_from(read_u32_le(&bytes, base + 56)).expect("offset overflow"); + let sort_index = + usize::try_from(read_u32_le(&bytes, base + 60)).expect("sort index overflow"); + + assert_eq!( + data_offset % 8, + 0, + "entry #{idx} data offset must be 8-byte aligned" + ); + assert!( + data_offset >= prev_data_end, + "entry #{idx} offset regressed" + ); + assert!( + data_offset + data_size <= directory_offset, + "entry #{idx} overlaps directory" + ); + prev_data_end = data_offset + data_size; + sort_indices.push(sort_index); + } + + let names = ["Zulu", "alpha", "Beta"]; + let mut expected_sort: Vec<usize> = (0..names.len()).collect(); + expected_sort + .sort_by(|a, b| cmp_name_case_insensitive(names[*a].as_bytes(), names[*b].as_bytes())); + assert_eq!( + sort_indices, expected_sort, + "sort table must contain original indexes in case-insensitive alphabetical order" + ); + + let archive = Archive::open_path(&path).expect("re-open failed"); + assert_eq!(archive.find("zulu"), Some(EntryId(0))); + assert_eq!(archive.find("ALPHA"), Some(EntryId(1))); + assert_eq!(archive.find("beta"), Some(EntryId(2))); + + let _ = fs::remove_file(&path); +} + +#[test] +fn nres_synthetic_read_find_and_edit() { + let payload_a = b"alpha"; + let payload_b = b"B"; + let payload_c = b""; + let src = build_nres_bytes(&[ + SyntheticEntry { + kind: 1, + attr1: 10, + attr2: 20, + attr3: 30, + name: "Alpha.TXT", + data: payload_a, + }, + SyntheticEntry { + kind: 2, + attr1: 11, + attr2: 21, + attr3: 31, + name: "beta.bin", + data: payload_b, + }, + SyntheticEntry { + kind: 3, + attr1: 12, + attr2: 22, + attr3: 32, + name: "Gamma", + data: payload_c, + }, + ]); + + let archive = Archive::open_bytes( + Arc::from(src.clone().into_boxed_slice()), + OpenOptions::default(), + ) + .expect("open synthetic nres failed"); + + assert_eq!(archive.entry_count(), 3); + assert_eq!(archive.find("alpha.txt"), Some(EntryId(0))); + assert_eq!(archive.find("BETA.BIN"), Some(EntryId(1))); + assert_eq!(archive.find("gAmMa"), Some(EntryId(2))); + assert_eq!(archive.find("missing"), None); + + assert_eq!( + archive.read(EntryId(0)).expect("read #0 failed").as_slice(), + payload_a + ); + assert_eq!( + archive.read(EntryId(1)).expect("read #1 failed").as_slice(), + payload_b + ); + assert_eq!( + archive.read(EntryId(2)).expect("read #2 failed").as_slice(), + payload_c + ); + + let mut path = std::env::temp_dir(); + path.push(format!( + "nres-synth-edit-{}-{}.lib", + std::process::id(), + unix_time_nanos() + )); + fs::write(&path, &src).expect("write temp synthetic archive failed"); + + let mut editor = Archive::edit_path(&path).expect("edit_path on synthetic archive failed"); + editor + .replace_data(EntryId(1), b"replaced") + .expect("replace_data failed"); + let added = editor + .add(NewEntry { + kind: 4, + attr1: 13, + attr2: 23, + attr3: 33, + name: "delta", + data: b"new payload", + }) + .expect("add failed"); + assert_eq!(added, EntryId(3)); + editor.remove(EntryId(2)).expect("remove failed"); + editor.commit().expect("commit failed"); + + let edited = Archive::open_path(&path).expect("re-open edited archive failed"); + assert_eq!(edited.entry_count(), 3); + assert_eq!( + edited + .read(edited.find("beta.bin").expect("find beta.bin failed")) + .expect("read beta.bin failed") + .as_slice(), + b"replaced" + ); + assert_eq!( + edited + .read(edited.find("delta").expect("find delta failed")) + .expect("read delta failed") + .as_slice(), + b"new payload" + ); + assert_eq!(edited.find("gamma"), None); + + let _ = fs::remove_file(&path); +} + +#[test] +fn nres_max_name_length_roundtrip() { + let max_name = "12345678901234567890123456789012345"; + assert_eq!(max_name.len(), 35); + + let src = build_nres_bytes(&[SyntheticEntry { + kind: 9, + attr1: 1, + attr2: 2, + attr3: 3, + name: max_name, + data: b"payload", + }]); + + let archive = Archive::open_bytes(Arc::from(src.into_boxed_slice()), OpenOptions::default()) + .expect("open synthetic nres failed"); + + assert_eq!(archive.entry_count(), 1); + assert_eq!(archive.find(max_name), Some(EntryId(0))); + assert_eq!( + archive.find(&max_name.to_ascii_lowercase()), + Some(EntryId(0)) + ); + + let entry = archive.get(EntryId(0)).expect("missing entry 0"); + assert_eq!(entry.meta.name, max_name); + assert_eq!( + archive + .read(EntryId(0)) + .expect("read payload failed") + .as_slice(), + b"payload" + ); +} + +#[test] +fn nres_find_falls_back_when_sort_index_is_out_of_range() { + let mut bytes = build_nres_bytes(&[ + SyntheticEntry { + kind: 1, + attr1: 0, + attr2: 0, + attr3: 0, + name: "Alpha", + data: b"a", + }, + SyntheticEntry { + kind: 2, + attr1: 0, + attr2: 0, + attr3: 0, + name: "Beta", + data: b"b", + }, + SyntheticEntry { + kind: 3, + attr1: 0, + attr2: 0, + attr3: 0, + name: "Gamma", + data: b"c", + }, + ]); + + let entry_count = 3usize; + let directory_offset = bytes + .len() + .checked_sub(entry_count * 64) + .expect("directory offset underflow"); + let mid_entry_sort_index = directory_offset + 64 + 60; + bytes[mid_entry_sort_index..mid_entry_sort_index + 4].copy_from_slice(&u32::MAX.to_le_bytes()); + + let archive = Archive::open_bytes(Arc::from(bytes.into_boxed_slice()), OpenOptions::default()) + .expect("open archive with corrupted sort index failed"); + + assert_eq!(archive.find("alpha"), Some(EntryId(0))); + assert_eq!(archive.find("BETA"), Some(EntryId(1))); + assert_eq!(archive.find("gamma"), Some(EntryId(2))); + assert_eq!(archive.find("missing"), None); +} + +#[test] +fn nres_validation_error_cases() { + let valid = build_nres_bytes(&[SyntheticEntry { + kind: 1, + attr1: 2, + attr2: 3, + attr3: 4, + name: "ok", + data: b"1234", + }]); + + let mut invalid_magic = valid.clone(); + invalid_magic[0..4].copy_from_slice(b"FAIL"); + match Archive::open_bytes( + Arc::from(invalid_magic.into_boxed_slice()), + OpenOptions::default(), + ) { + Err(Error::InvalidMagic { .. }) => {} + other => panic!("expected InvalidMagic, got {other:?}"), + } + + let mut invalid_version = valid.clone(); + invalid_version[4..8].copy_from_slice(&0x200_u32.to_le_bytes()); + match Archive::open_bytes( + Arc::from(invalid_version.into_boxed_slice()), + OpenOptions::default(), + ) { + Err(Error::UnsupportedVersion { got }) => assert_eq!(got, 0x200), + other => panic!("expected UnsupportedVersion, got {other:?}"), + } + + let mut bad_total = valid.clone(); + bad_total[12..16].copy_from_slice(&0_u32.to_le_bytes()); + match Archive::open_bytes( + Arc::from(bad_total.into_boxed_slice()), + OpenOptions::default(), + ) { + Err(Error::TotalSizeMismatch { .. }) => {} + other => panic!("expected TotalSizeMismatch, got {other:?}"), + } + + let mut bad_count = valid.clone(); + bad_count[8..12].copy_from_slice(&(-1_i32).to_le_bytes()); + match Archive::open_bytes( + Arc::from(bad_count.into_boxed_slice()), + OpenOptions::default(), + ) { + Err(Error::InvalidEntryCount { got }) => assert_eq!(got, -1), + other => panic!("expected InvalidEntryCount, got {other:?}"), + } + + let mut bad_dir = valid.clone(); + bad_dir[8..12].copy_from_slice(&1000_u32.to_le_bytes()); + match Archive::open_bytes( + Arc::from(bad_dir.into_boxed_slice()), + OpenOptions::default(), + ) { + Err(Error::DirectoryOutOfBounds { .. }) => {} + other => panic!("expected DirectoryOutOfBounds, got {other:?}"), + } + + let mut long_name = valid.clone(); + let entry_base = long_name.len() - 64; + for b in &mut long_name[entry_base + 20..entry_base + 56] { + *b = b'X'; + } + match Archive::open_bytes( + Arc::from(long_name.into_boxed_slice()), + OpenOptions::default(), + ) { + Err(Error::NameTooLong { .. }) => {} + other => panic!("expected NameTooLong, got {other:?}"), + } + + let mut bad_data = valid.clone(); + bad_data[entry_base + 56..entry_base + 60].copy_from_slice(&12_u32.to_le_bytes()); + bad_data[entry_base + 12..entry_base + 16].copy_from_slice(&32_u32.to_le_bytes()); + match Archive::open_bytes( + Arc::from(bad_data.into_boxed_slice()), + OpenOptions::default(), + ) { + Err(Error::EntryDataOutOfBounds { .. }) => {} + other => panic!("expected EntryDataOutOfBounds, got {other:?}"), + } + + let archive = Archive::open_bytes(Arc::from(valid.into_boxed_slice()), OpenOptions::default()) + .expect("open valid archive failed"); + match archive.read(EntryId(99)) { + Err(Error::EntryIdOutOfRange { .. }) => {} + other => panic!("expected EntryIdOutOfRange, got {other:?}"), + } +} + +#[test] +fn nres_editor_validation_error_cases() { + let mut path = std::env::temp_dir(); + path.push(format!( + "nres-editor-errors-{}-{}.lib", + std::process::id(), + unix_time_nanos() + )); + let src = build_nres_bytes(&[]); + fs::write(&path, src).expect("write empty archive failed"); + + let mut editor = Archive::edit_path(&path).expect("edit_path failed"); + + let long_name = "X".repeat(36); + match editor.add(NewEntry { + kind: 0, + attr1: 0, + attr2: 0, + attr3: 0, + name: &long_name, + data: b"", + }) { + Err(Error::NameTooLong { .. }) => {} + other => panic!("expected NameTooLong, got {other:?}"), + } + + match editor.add(NewEntry { + kind: 0, + attr1: 0, + attr2: 0, + attr3: 0, + name: "bad\0name", + data: b"", + }) { + Err(Error::NameContainsNul) => {} + other => panic!("expected NameContainsNul, got {other:?}"), + } + + match editor.replace_data(EntryId(0), b"x") { + Err(Error::EntryIdOutOfRange { .. }) => {} + other => panic!("expected EntryIdOutOfRange, got {other:?}"), + } + + match editor.remove(EntryId(0)) { + Err(Error::EntryIdOutOfRange { .. }) => {} + other => panic!("expected EntryIdOutOfRange, got {other:?}"), + } + + let _ = fs::remove_file(&path); +} diff --git a/crates/rsli/Cargo.toml b/crates/rsli/Cargo.toml new file mode 100644 index 0000000..faad224 --- /dev/null +++ b/crates/rsli/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "rsli" +version = "0.1.0" +edition = "2021" + +[dependencies] +common = { path = "../common" } +flate2 = { version = "1", default-features = false, features = ["rust_backend"] } diff --git a/crates/rsli/README.md b/crates/rsli/README.md new file mode 100644 index 0000000..27816d6 --- /dev/null +++ b/crates/rsli/README.md @@ -0,0 +1,58 @@ +# rsli + +Rust-библиотека для чтения архивов формата **RsLi**. + +## Что умеет + +- Открытие библиотеки из файла (`open_path`, `open_path_with`). +- Дешифрование таблицы записей (XOR stream cipher). +- Поддержка AO-трейлера и media overlay (`allow_ao_trailer`). +- Поддержка quirk для Deflate `EOF+1` (`allow_deflate_eof_plus_one`). +- Поиск по имени (`find`, c приведением запроса к uppercase). +- Загрузка данных: +- `load`, `load_into`, `load_packed`, `unpack`, `load_fast`. + +## Поддерживаемые методы упаковки + +- `0x000` None +- `0x020` XorOnly +- `0x040` Lzss +- `0x060` XorLzss +- `0x080` LzssHuffman +- `0x0A0` XorLzssHuffman +- `0x100` Deflate + +## Модель ошибок + +Типизированные ошибки без паник в production-коде (`InvalidMagic`, `UnsupportedVersion`, `EntryTableOutOfBounds`, `PackedSizePastEof`, `DeflateEofPlusOneQuirkRejected`, `UnsupportedMethod`, и др.). + +## Покрытие тестами + +### Реальные файлы + +- Рекурсивный прогон по `testdata/rsli/**`. +- Сейчас в наборе: **2 архива**. +- На реальных данных подтверждены и проходят byte-to-byte проверки методы: +- `0x040` (LZSS) +- `0x100` (Deflate) +- Для каждого архива проверяется: +- `load`/`load_into`/`load_packed`/`unpack`/`load_fast`; +- `find`; +- пересборка и сравнение **byte-to-byte**. + +### Синтетические тесты + +Из-за отсутствия реальных файлов для части методов добавлены синтетические архивы и тесты: + +- Методы: +- `0x000`, `0x020`, `0x060`, `0x080`, `0x0A0`. +- Спецкейсы формата: + - AO trailer + overlay; + - Deflate `EOF+1` (оба режима: accepted/rejected); +- некорректные заголовки/таблицы/смещения/методы. + +## Быстрый запуск тестов + +```bash +cargo test -p rsli -- --nocapture +``` diff --git a/crates/rsli/src/compress/deflate.rs b/crates/rsli/src/compress/deflate.rs new file mode 100644 index 0000000..6b8ea73 --- /dev/null +++ b/crates/rsli/src/compress/deflate.rs @@ -0,0 +1,14 @@ +use crate::error::Error; +use crate::Result; +use flate2::read::DeflateDecoder; +use std::io::Read; + +/// Decode raw Deflate (RFC 1951) payload. +pub fn decode_deflate(packed: &[u8]) -> Result<Vec<u8>> { + let mut out = Vec::new(); + let mut decoder = DeflateDecoder::new(packed); + decoder + .read_to_end(&mut out) + .map_err(|_| Error::DecompressionFailed("deflate"))?; + Ok(out) +} diff --git a/crates/rsli/src/compress/lzh.rs b/crates/rsli/src/compress/lzh.rs new file mode 100644 index 0000000..07dc0c5 --- /dev/null +++ b/crates/rsli/src/compress/lzh.rs @@ -0,0 +1,298 @@ +use super::xor::XorState; +use crate::error::Error; +use crate::Result; + +pub(crate) const LZH_N: usize = 4096; +pub(crate) const LZH_F: usize = 60; +pub(crate) const LZH_THRESHOLD: usize = 2; +pub(crate) const LZH_N_CHAR: usize = 256 - LZH_THRESHOLD + LZH_F; +pub(crate) const LZH_T: usize = LZH_N_CHAR * 2 - 1; +pub(crate) const LZH_R: usize = LZH_T - 1; +pub(crate) const LZH_MAX_FREQ: u16 = 0x8000; + +/// LZSS-Huffman decompression with optional on-the-fly XOR decryption. +pub fn lzss_huffman_decompress( + data: &[u8], + expected_size: usize, + xor_key: Option<u16>, +) -> Result<Vec<u8>> { + let mut decoder = LzhDecoder::new(data, xor_key); + decoder.decode(expected_size) +} + +struct LzhDecoder<'a> { + bit_reader: BitReader<'a>, + text: [u8; LZH_N], + freq: [u16; LZH_T + 1], + parent: [usize; LZH_T + LZH_N_CHAR], + son: [usize; LZH_T], + d_code: [u8; 256], + d_len: [u8; 256], + ring_pos: usize, +} + +impl<'a> LzhDecoder<'a> { + fn new(data: &'a [u8], xor_key: Option<u16>) -> Self { + let mut decoder = Self { + bit_reader: BitReader::new(data, xor_key), + text: [0x20u8; LZH_N], + freq: [0u16; LZH_T + 1], + parent: [0usize; LZH_T + LZH_N_CHAR], + son: [0usize; LZH_T], + d_code: [0u8; 256], + d_len: [0u8; 256], + ring_pos: LZH_N - LZH_F, + }; + decoder.init_tables(); + decoder.start_huff(); + decoder + } + + fn decode(&mut self, expected_size: usize) -> Result<Vec<u8>> { + let mut out = Vec::with_capacity(expected_size); + + while out.len() < expected_size { + let c = self.decode_char()?; + if c < 256 { + let byte = c as u8; + out.push(byte); + self.text[self.ring_pos] = byte; + self.ring_pos = (self.ring_pos + 1) & (LZH_N - 1); + } else { + let mut offset = self.decode_position()?; + offset = (self.ring_pos.wrapping_sub(offset).wrapping_sub(1)) & (LZH_N - 1); + let mut length = c.saturating_sub(253); + + while length > 0 && out.len() < expected_size { + let byte = self.text[offset]; + out.push(byte); + self.text[self.ring_pos] = byte; + self.ring_pos = (self.ring_pos + 1) & (LZH_N - 1); + offset = (offset + 1) & (LZH_N - 1); + length -= 1; + } + } + } + + if out.len() != expected_size { + return Err(Error::DecompressionFailed("lzss-huffman")); + } + Ok(out) + } + + fn init_tables(&mut self) { + let d_code_group_counts = [1usize, 3, 8, 12, 24, 16]; + let d_len_group_counts = [32usize, 48, 64, 48, 48, 16]; + + let mut group_index = 0u8; + let mut idx = 0usize; + let mut run = 32usize; + for count in d_code_group_counts { + for _ in 0..count { + for _ in 0..run { + self.d_code[idx] = group_index; + idx += 1; + } + group_index = group_index.wrapping_add(1); + } + run >>= 1; + } + + let mut len = 3u8; + idx = 0; + for count in d_len_group_counts { + for _ in 0..count { + self.d_len[idx] = len; + idx += 1; + } + len = len.saturating_add(1); + } + } + + fn start_huff(&mut self) { + for i in 0..LZH_N_CHAR { + self.freq[i] = 1; + self.son[i] = i + LZH_T; + self.parent[i + LZH_T] = i; + } + + let mut i = 0usize; + let mut j = LZH_N_CHAR; + while j <= LZH_R { + self.freq[j] = self.freq[i].saturating_add(self.freq[i + 1]); + self.son[j] = i; + self.parent[i] = j; + self.parent[i + 1] = j; + i += 2; + j += 1; + } + + self.freq[LZH_T] = u16::MAX; + self.parent[LZH_R] = 0; + } + + fn decode_char(&mut self) -> Result<usize> { + let mut node = self.son[LZH_R]; + while node < LZH_T { + let bit = usize::from(self.bit_reader.read_bit()?); + node = self.son[node + bit]; + } + + let c = node - LZH_T; + self.update(c); + Ok(c) + } + + fn decode_position(&mut self) -> Result<usize> { + let i = self.bit_reader.read_bits(8)? as usize; + let mut c = usize::from(self.d_code[i]) << 6; + let mut j = usize::from(self.d_len[i]).saturating_sub(2); + + while j > 0 { + j -= 1; + c |= usize::from(self.bit_reader.read_bit()?) << j; + } + + Ok(c | (i & 0x3F)) + } + + fn update(&mut self, c: usize) { + if self.freq[LZH_R] == LZH_MAX_FREQ { + self.reconstruct(); + } + + let mut current = self.parent[c + LZH_T]; + loop { + self.freq[current] = self.freq[current].saturating_add(1); + let freq = self.freq[current]; + + if current + 1 < self.freq.len() && freq > self.freq[current + 1] { + let mut swap_idx = current + 1; + while swap_idx + 1 < self.freq.len() && freq > self.freq[swap_idx + 1] { + swap_idx += 1; + } + + self.freq.swap(current, swap_idx); + + let left = self.son[current]; + let right = self.son[swap_idx]; + self.son[current] = right; + self.son[swap_idx] = left; + + self.parent[left] = swap_idx; + if left < LZH_T { + self.parent[left + 1] = swap_idx; + } + + self.parent[right] = current; + if right < LZH_T { + self.parent[right + 1] = current; + } + + current = swap_idx; + } + + current = self.parent[current]; + if current == 0 { + break; + } + } + } + + fn reconstruct(&mut self) { + let mut j = 0usize; + for i in 0..LZH_T { + if self.son[i] >= LZH_T { + self.freq[j] = (self.freq[i].saturating_add(1)) / 2; + self.son[j] = self.son[i]; + j += 1; + } + } + + let mut i = 0usize; + let mut current = LZH_N_CHAR; + while current < LZH_T { + let sum = self.freq[i].saturating_add(self.freq[i + 1]); + self.freq[current] = sum; + + let mut insert_at = current; + while insert_at > 0 && sum < self.freq[insert_at - 1] { + insert_at -= 1; + } + + for move_idx in (insert_at..current).rev() { + self.freq[move_idx + 1] = self.freq[move_idx]; + self.son[move_idx + 1] = self.son[move_idx]; + } + + self.freq[insert_at] = sum; + self.son[insert_at] = i; + + i += 2; + current += 1; + } + + for idx in 0..LZH_T { + let node = self.son[idx]; + self.parent[node] = idx; + if node < LZH_T { + self.parent[node + 1] = idx; + } + } + + self.freq[LZH_T] = u16::MAX; + self.parent[LZH_R] = 0; + } +} + +struct BitReader<'a> { + data: &'a [u8], + byte_pos: usize, + bit_mask: u8, + current_byte: u8, + xor_state: Option<XorState>, +} + +impl<'a> BitReader<'a> { + fn new(data: &'a [u8], xor_key: Option<u16>) -> Self { + Self { + data, + byte_pos: 0, + bit_mask: 0x80, + current_byte: 0, + xor_state: xor_key.map(XorState::new), + } + } + + fn read_bit(&mut self) -> Result<u8> { + if self.bit_mask == 0x80 { + let Some(mut byte) = self.data.get(self.byte_pos).copied() else { + return Err(Error::DecompressionFailed("lzss-huffman: unexpected EOF")); + }; + if let Some(state) = &mut self.xor_state { + byte = state.decrypt_byte(byte); + } + self.current_byte = byte; + } + + let bit = if (self.current_byte & self.bit_mask) != 0 { + 1 + } else { + 0 + }; + self.bit_mask >>= 1; + if self.bit_mask == 0 { + self.bit_mask = 0x80; + self.byte_pos = self.byte_pos.saturating_add(1); + } + Ok(bit) + } + + fn read_bits(&mut self, bits: usize) -> Result<u32> { + let mut value = 0u32; + for _ in 0..bits { + value = (value << 1) | u32::from(self.read_bit()?); + } + Ok(value) + } +} diff --git a/crates/rsli/src/compress/lzss.rs b/crates/rsli/src/compress/lzss.rs new file mode 100644 index 0000000..d30345c --- /dev/null +++ b/crates/rsli/src/compress/lzss.rs @@ -0,0 +1,79 @@ +use super::xor::XorState; +use crate::error::Error; +use crate::Result; + +/// Simple LZSS decompression with optional on-the-fly XOR decryption +pub fn lzss_decompress_simple( + data: &[u8], + expected_size: usize, + xor_key: Option<u16>, +) -> Result<Vec<u8>> { + let mut ring = [0x20u8; 0x1000]; + let mut ring_pos = 0xFEEusize; + let mut out = Vec::with_capacity(expected_size); + let mut in_pos = 0usize; + + let mut control = 0u8; + let mut bits_left = 0u8; + + // XOR state for on-the-fly decryption + let mut xor_state = xor_key.map(XorState::new); + + // Helper to read byte with optional XOR decryption + let read_byte = |pos: usize, state: &mut Option<XorState>| -> Option<u8> { + let encrypted = data.get(pos).copied()?; + Some(if let Some(ref mut s) = state { + s.decrypt_byte(encrypted) + } else { + encrypted + }) + }; + + while out.len() < expected_size { + if bits_left == 0 { + let byte = read_byte(in_pos, &mut xor_state) + .ok_or(Error::DecompressionFailed("lzss-simple: unexpected EOF"))?; + control = byte; + in_pos += 1; + bits_left = 8; + } + + if (control & 1) != 0 { + let byte = read_byte(in_pos, &mut xor_state) + .ok_or(Error::DecompressionFailed("lzss-simple: unexpected EOF"))?; + in_pos += 1; + + out.push(byte); + ring[ring_pos] = byte; + ring_pos = (ring_pos + 1) & 0x0FFF; + } else { + let low = read_byte(in_pos, &mut xor_state) + .ok_or(Error::DecompressionFailed("lzss-simple: unexpected EOF"))?; + let high = read_byte(in_pos + 1, &mut xor_state) + .ok_or(Error::DecompressionFailed("lzss-simple: unexpected EOF"))?; + in_pos += 2; + + let offset = usize::from(low) | (usize::from(high & 0xF0) << 4); + let length = usize::from((high & 0x0F) + 3); + + for step in 0..length { + let byte = ring[(offset + step) & 0x0FFF]; + out.push(byte); + ring[ring_pos] = byte; + ring_pos = (ring_pos + 1) & 0x0FFF; + if out.len() >= expected_size { + break; + } + } + } + + control >>= 1; + bits_left -= 1; + } + + if out.len() != expected_size { + return Err(Error::DecompressionFailed("lzss-simple")); + } + + Ok(out) +} diff --git a/crates/rsli/src/compress/mod.rs b/crates/rsli/src/compress/mod.rs new file mode 100644 index 0000000..bd23143 --- /dev/null +++ b/crates/rsli/src/compress/mod.rs @@ -0,0 +1,9 @@ +pub mod deflate; +pub mod lzh; +pub mod lzss; +pub mod xor; + +pub use deflate::decode_deflate; +pub use lzh::lzss_huffman_decompress; +pub use lzss::lzss_decompress_simple; +pub use xor::{xor_stream, XorState}; diff --git a/crates/rsli/src/compress/xor.rs b/crates/rsli/src/compress/xor.rs new file mode 100644 index 0000000..c4c3d7d --- /dev/null +++ b/crates/rsli/src/compress/xor.rs @@ -0,0 +1,29 @@ +/// XOR cipher state for RsLi format +pub struct XorState { + lo: u8, + hi: u8, +} + +impl XorState { + /// Create new XOR state from 16-bit key + pub fn new(key16: u16) -> Self { + Self { + lo: (key16 & 0xFF) as u8, + hi: ((key16 >> 8) & 0xFF) as u8, + } + } + + /// Decrypt a single byte and update state + pub fn decrypt_byte(&mut self, encrypted: u8) -> u8 { + self.lo = self.hi ^ self.lo.wrapping_shl(1); + let decrypted = encrypted ^ self.lo; + self.hi = self.lo ^ (self.hi >> 1); + decrypted + } +} + +/// Decrypt entire buffer with XOR stream cipher +pub fn xor_stream(data: &[u8], key16: u16) -> Vec<u8> { + let mut state = XorState::new(key16); + data.iter().map(|&b| state.decrypt_byte(b)).collect() +} diff --git a/crates/rsli/src/error.rs b/crates/rsli/src/error.rs new file mode 100644 index 0000000..5a36101 --- /dev/null +++ b/crates/rsli/src/error.rs @@ -0,0 +1,140 @@ +use core::fmt; + +#[derive(Debug)] +#[non_exhaustive] +pub enum Error { + Io(std::io::Error), + + InvalidMagic { + got: [u8; 2], + }, + UnsupportedVersion { + got: u8, + }, + InvalidEntryCount { + got: i16, + }, + TooManyEntries { + got: usize, + }, + + EntryTableOutOfBounds { + table_offset: u64, + table_len: u64, + file_len: u64, + }, + EntryTableDecryptFailed, + CorruptEntryTable(&'static str), + + EntryIdOutOfRange { + id: u32, + entry_count: u32, + }, + EntryDataOutOfBounds { + id: u32, + offset: u64, + size: u32, + file_len: u64, + }, + + AoTrailerInvalid, + MediaOverlayOutOfBounds { + overlay: u32, + file_len: u64, + }, + + UnsupportedMethod { + raw: u32, + }, + PackedSizePastEof { + id: u32, + offset: u64, + packed_size: u32, + file_len: u64, + }, + DeflateEofPlusOneQuirkRejected { + id: u32, + }, + + DecompressionFailed(&'static str), + OutputSizeMismatch { + expected: u32, + got: u32, + }, + + IntegerOverflow, +} + +impl From<std::io::Error> for Error { + fn from(value: std::io::Error) -> Self { + Self::Io(value) + } +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Error::Io(e) => write!(f, "I/O error: {e}"), + Error::InvalidMagic { got } => write!(f, "invalid RsLi magic: {got:02X?}"), + Error::UnsupportedVersion { got } => write!(f, "unsupported RsLi version: {got:#x}"), + Error::InvalidEntryCount { got } => write!(f, "invalid entry_count: {got}"), + Error::TooManyEntries { got } => write!(f, "too many entries: {got} exceeds u32::MAX"), + Error::EntryTableOutOfBounds { + table_offset, + table_len, + file_len, + } => write!( + f, + "entry table out of bounds: off={table_offset}, len={table_len}, file={file_len}" + ), + Error::EntryTableDecryptFailed => write!(f, "failed to decrypt entry table"), + Error::CorruptEntryTable(s) => write!(f, "corrupt entry table: {s}"), + Error::EntryIdOutOfRange { id, entry_count } => { + write!(f, "entry id out of range: id={id}, count={entry_count}") + } + Error::EntryDataOutOfBounds { + id, + offset, + size, + file_len, + } => write!( + f, + "entry data out of bounds: id={id}, off={offset}, size={size}, file={file_len}" + ), + Error::AoTrailerInvalid => write!(f, "invalid AO trailer"), + Error::MediaOverlayOutOfBounds { overlay, file_len } => { + write!( + f, + "media overlay out of bounds: overlay={overlay}, file={file_len}" + ) + } + Error::UnsupportedMethod { raw } => write!(f, "unsupported packing method: {raw:#x}"), + Error::PackedSizePastEof { + id, + offset, + packed_size, + file_len, + } => write!( + f, + "packed range past EOF: id={id}, off={offset}, size={packed_size}, file={file_len}" + ), + Error::DeflateEofPlusOneQuirkRejected { id } => { + write!(f, "deflate EOF+1 quirk rejected for entry {id}") + } + Error::DecompressionFailed(s) => write!(f, "decompression failed: {s}"), + Error::OutputSizeMismatch { expected, got } => { + write!(f, "output size mismatch: expected={expected}, got={got}") + } + Error::IntegerOverflow => write!(f, "integer overflow"), + } + } +} + +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Io(err) => Some(err), + _ => None, + } + } +} diff --git a/crates/rsli/src/lib.rs b/crates/rsli/src/lib.rs new file mode 100644 index 0000000..ef29f41 --- /dev/null +++ b/crates/rsli/src/lib.rs @@ -0,0 +1,411 @@ +pub mod compress; +pub mod error; +pub mod parse; + +use crate::compress::{ + decode_deflate, lzss_decompress_simple, lzss_huffman_decompress, xor_stream, +}; +use crate::error::Error; +use crate::parse::{c_name_bytes, cmp_c_string, parse_library}; +use common::{OutputBuffer, ResourceData}; +use std::cmp::Ordering; +use std::fs; +use std::path::Path; +use std::sync::Arc; + +pub type Result<T> = core::result::Result<T, Error>; + +#[derive(Clone, Debug)] +pub struct OpenOptions { + pub allow_ao_trailer: bool, + pub allow_deflate_eof_plus_one: bool, +} + +impl Default for OpenOptions { + fn default() -> Self { + Self { + allow_ao_trailer: true, + allow_deflate_eof_plus_one: true, + } + } +} + +#[derive(Debug)] +pub struct Library { + bytes: Arc<[u8]>, + entries: Vec<EntryRecord>, + #[cfg(test)] + pub(crate) header_raw: [u8; 32], + #[cfg(test)] + pub(crate) table_plain_original: Vec<u8>, + #[cfg(test)] + pub(crate) xor_seed: u32, + #[cfg(test)] + pub(crate) source_size: usize, + #[cfg(test)] + pub(crate) trailer_raw: Option<[u8; 6]>, +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] +pub struct EntryId(pub u32); + +#[derive(Clone, Debug)] +pub struct EntryMeta { + pub name: String, + pub flags: i32, + pub method: PackMethod, + pub data_offset: u64, + pub packed_size: u32, + pub unpacked_size: u32, +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum PackMethod { + None, + XorOnly, + Lzss, + XorLzss, + LzssHuffman, + XorLzssHuffman, + Deflate, + Unknown(u32), +} + +#[derive(Copy, Clone, Debug)] +pub struct EntryRef<'a> { + pub id: EntryId, + pub meta: &'a EntryMeta, +} + +pub struct PackedResource { + pub meta: EntryMeta, + pub packed: Vec<u8>, +} + +#[derive(Clone, Debug)] +pub(crate) struct EntryRecord { + pub(crate) meta: EntryMeta, + pub(crate) name_raw: [u8; 12], + pub(crate) sort_to_original: i16, + pub(crate) key16: u16, + #[cfg(test)] + pub(crate) data_offset_raw: u32, + pub(crate) packed_size_declared: u32, + pub(crate) packed_size_available: usize, + pub(crate) effective_offset: usize, +} + +impl Library { + pub fn open_path(path: impl AsRef<Path>) -> Result<Self> { + Self::open_path_with(path, OpenOptions::default()) + } + + pub fn open_path_with(path: impl AsRef<Path>, opts: OpenOptions) -> Result<Self> { + let bytes = fs::read(path.as_ref())?; + let arc: Arc<[u8]> = Arc::from(bytes.into_boxed_slice()); + parse_library(arc, opts) + } + + pub fn entry_count(&self) -> usize { + self.entries.len() + } + + pub fn entries(&self) -> impl Iterator<Item = EntryRef<'_>> { + self.entries + .iter() + .enumerate() + .map(|(idx, entry)| EntryRef { + id: EntryId(u32::try_from(idx).expect("entry count validated at parse")), + meta: &entry.meta, + }) + } + + pub fn find(&self, name: &str) -> Option<EntryId> { + if self.entries.is_empty() { + return None; + } + + const MAX_INLINE_NAME: usize = 12; + + // Fast path: use stack allocation for short ASCII names (95% of cases) + if name.len() <= MAX_INLINE_NAME && name.is_ascii() { + let mut buf = [0u8; MAX_INLINE_NAME]; + for (i, &b) in name.as_bytes().iter().enumerate() { + buf[i] = b.to_ascii_uppercase(); + } + return self.find_impl(&buf[..name.len()]); + } + + // Slow path: heap allocation for long or non-ASCII names + let query = name.to_ascii_uppercase(); + self.find_impl(query.as_bytes()) + } + + fn find_impl(&self, query_bytes: &[u8]) -> Option<EntryId> { + // Binary search + let mut low = 0usize; + let mut high = self.entries.len(); + while low < high { + let mid = low + (high - low) / 2; + let idx = self.entries[mid].sort_to_original; + if idx < 0 { + break; + } + let idx = usize::try_from(idx).ok()?; + if idx >= self.entries.len() { + break; + } + + let cmp = cmp_c_string(query_bytes, c_name_bytes(&self.entries[idx].name_raw)); + match cmp { + Ordering::Less => high = mid, + Ordering::Greater => low = mid + 1, + Ordering::Equal => { + return Some(EntryId( + u32::try_from(idx).expect("entry count validated at parse"), + )) + } + } + } + + // Linear fallback search + self.entries.iter().enumerate().find_map(|(idx, entry)| { + if cmp_c_string(query_bytes, c_name_bytes(&entry.name_raw)) == Ordering::Equal { + Some(EntryId( + u32::try_from(idx).expect("entry count validated at parse"), + )) + } else { + None + } + }) + } + + pub fn get(&self, id: EntryId) -> Option<EntryRef<'_>> { + let idx = usize::try_from(id.0).ok()?; + let entry = self.entries.get(idx)?; + Some(EntryRef { + id, + meta: &entry.meta, + }) + } + + pub fn load(&self, id: EntryId) -> Result<Vec<u8>> { + let entry = self.entry_by_id(id)?; + let packed = self.packed_slice(id, entry)?; + decode_payload( + packed, + entry.meta.method, + entry.key16, + entry.meta.unpacked_size, + ) + } + + pub fn load_into(&self, id: EntryId, out: &mut dyn OutputBuffer) -> Result<usize> { + let decoded = self.load(id)?; + out.write_exact(&decoded)?; + Ok(decoded.len()) + } + + pub fn load_packed(&self, id: EntryId) -> Result<PackedResource> { + let entry = self.entry_by_id(id)?; + let packed = self.packed_slice(id, entry)?.to_vec(); + Ok(PackedResource { + meta: entry.meta.clone(), + packed, + }) + } + + pub fn unpack(&self, packed: &PackedResource) -> Result<Vec<u8>> { + let key16 = self.resolve_key_for_meta(&packed.meta).unwrap_or(0); + + let method = packed.meta.method; + if needs_xor_key(method) && self.resolve_key_for_meta(&packed.meta).is_none() { + return Err(Error::CorruptEntryTable( + "cannot resolve XOR key for packed resource", + )); + } + + decode_payload(&packed.packed, method, key16, packed.meta.unpacked_size) + } + + pub fn load_fast(&self, id: EntryId) -> Result<ResourceData<'_>> { + let entry = self.entry_by_id(id)?; + if entry.meta.method == PackMethod::None { + let packed = self.packed_slice(id, entry)?; + let size = + usize::try_from(entry.meta.unpacked_size).map_err(|_| Error::IntegerOverflow)?; + if packed.len() < size { + return Err(Error::OutputSizeMismatch { + expected: entry.meta.unpacked_size, + got: u32::try_from(packed.len()).unwrap_or(u32::MAX), + }); + } + return Ok(ResourceData::Borrowed(&packed[..size])); + } + Ok(ResourceData::Owned(self.load(id)?)) + } + + fn entry_by_id(&self, id: EntryId) -> Result<&EntryRecord> { + let idx = usize::try_from(id.0).map_err(|_| Error::IntegerOverflow)?; + self.entries + .get(idx) + .ok_or_else(|| Error::EntryIdOutOfRange { + id: id.0, + entry_count: self.entries.len().try_into().unwrap_or(u32::MAX), + }) + } + + fn packed_slice<'a>(&'a self, id: EntryId, entry: &EntryRecord) -> Result<&'a [u8]> { + let start = entry.effective_offset; + let end = start + .checked_add(entry.packed_size_available) + .ok_or(Error::IntegerOverflow)?; + self.bytes + .get(start..end) + .ok_or(Error::EntryDataOutOfBounds { + id: id.0, + offset: u64::try_from(start).unwrap_or(u64::MAX), + size: entry.packed_size_declared, + file_len: u64::try_from(self.bytes.len()).unwrap_or(u64::MAX), + }) + } + + fn resolve_key_for_meta(&self, meta: &EntryMeta) -> Option<u16> { + self.entries + .iter() + .find(|entry| { + entry.meta.name == meta.name + && entry.meta.flags == meta.flags + && entry.meta.data_offset == meta.data_offset + && entry.meta.packed_size == meta.packed_size + && entry.meta.unpacked_size == meta.unpacked_size + && entry.meta.method == meta.method + }) + .map(|entry| entry.key16) + } + + #[cfg(test)] + pub(crate) fn rebuild_from_parsed_metadata(&self) -> Result<Vec<u8>> { + let trailer_len = usize::from(self.trailer_raw.is_some()) * 6; + let pre_trailer_size = self + .source_size + .checked_sub(trailer_len) + .ok_or(Error::IntegerOverflow)?; + + let count = self.entries.len(); + let table_len = count.checked_mul(32).ok_or(Error::IntegerOverflow)?; + let table_end = 32usize + .checked_add(table_len) + .ok_or(Error::IntegerOverflow)?; + if pre_trailer_size < table_end { + return Err(Error::EntryTableOutOfBounds { + table_offset: 32, + table_len: u64::try_from(table_len).map_err(|_| Error::IntegerOverflow)?, + file_len: u64::try_from(pre_trailer_size).map_err(|_| Error::IntegerOverflow)?, + }); + } + + let mut out = vec![0u8; pre_trailer_size]; + out[0..32].copy_from_slice(&self.header_raw); + let encrypted_table = + xor_stream(&self.table_plain_original, (self.xor_seed & 0xFFFF) as u16); + out[32..table_end].copy_from_slice(&encrypted_table); + + let mut occupied = vec![false; pre_trailer_size]; + for byte in occupied.iter_mut().take(table_end) { + *byte = true; + } + + for (idx, entry) in self.entries.iter().enumerate() { + let packed = self + .load_packed(EntryId( + u32::try_from(idx).expect("entry count validated at parse"), + ))? + .packed; + let start = + usize::try_from(entry.data_offset_raw).map_err(|_| Error::IntegerOverflow)?; + for (offset, byte) in packed.iter().copied().enumerate() { + let pos = start.checked_add(offset).ok_or(Error::IntegerOverflow)?; + if pos >= out.len() { + return Err(Error::PackedSizePastEof { + id: u32::try_from(idx).expect("entry count validated at parse"), + offset: u64::from(entry.data_offset_raw), + packed_size: entry.packed_size_declared, + file_len: u64::try_from(out.len()).map_err(|_| Error::IntegerOverflow)?, + }); + } + if occupied[pos] && out[pos] != byte { + return Err(Error::CorruptEntryTable("packed payload overlap conflict")); + } + out[pos] = byte; + occupied[pos] = true; + } + } + + if let Some(trailer) = self.trailer_raw { + out.extend_from_slice(&trailer); + } + Ok(out) + } +} + +fn decode_payload( + packed: &[u8], + method: PackMethod, + key16: u16, + unpacked_size: u32, +) -> Result<Vec<u8>> { + let expected = usize::try_from(unpacked_size).map_err(|_| Error::IntegerOverflow)?; + + let out = match method { + PackMethod::None => { + if packed.len() < expected { + return Err(Error::OutputSizeMismatch { + expected: unpacked_size, + got: u32::try_from(packed.len()).unwrap_or(u32::MAX), + }); + } + packed[..expected].to_vec() + } + PackMethod::XorOnly => { + if packed.len() < expected { + return Err(Error::OutputSizeMismatch { + expected: unpacked_size, + got: u32::try_from(packed.len()).unwrap_or(u32::MAX), + }); + } + xor_stream(&packed[..expected], key16) + } + PackMethod::Lzss => lzss_decompress_simple(packed, expected, None)?, + PackMethod::XorLzss => { + // Optimized: XOR on-the-fly during decompression instead of creating temp buffer + lzss_decompress_simple(packed, expected, Some(key16))? + } + PackMethod::LzssHuffman => lzss_huffman_decompress(packed, expected, None)?, + PackMethod::XorLzssHuffman => { + // Optimized: XOR on-the-fly during decompression + lzss_huffman_decompress(packed, expected, Some(key16))? + } + PackMethod::Deflate => decode_deflate(packed)?, + PackMethod::Unknown(raw) => return Err(Error::UnsupportedMethod { raw }), + }; + + if out.len() != expected { + return Err(Error::OutputSizeMismatch { + expected: unpacked_size, + got: u32::try_from(out.len()).unwrap_or(u32::MAX), + }); + } + + Ok(out) +} + +fn needs_xor_key(method: PackMethod) -> bool { + matches!( + method, + PackMethod::XorOnly | PackMethod::XorLzss | PackMethod::XorLzssHuffman + ) +} + +#[cfg(test)] +mod tests; diff --git a/crates/rsli/src/parse.rs b/crates/rsli/src/parse.rs new file mode 100644 index 0000000..9a916dc --- /dev/null +++ b/crates/rsli/src/parse.rs @@ -0,0 +1,267 @@ +use crate::compress::xor::xor_stream; +use crate::error::Error; +use crate::{EntryMeta, EntryRecord, Library, OpenOptions, PackMethod, Result}; +use std::cmp::Ordering; +use std::sync::Arc; + +pub fn parse_library(bytes: Arc<[u8]>, opts: OpenOptions) -> Result<Library> { + if bytes.len() < 32 { + return Err(Error::EntryTableOutOfBounds { + table_offset: 32, + table_len: 0, + file_len: u64::try_from(bytes.len()).map_err(|_| Error::IntegerOverflow)?, + }); + } + + let mut header_raw = [0u8; 32]; + header_raw.copy_from_slice(&bytes[0..32]); + + if &bytes[0..2] != b"NL" { + let mut got = [0u8; 2]; + got.copy_from_slice(&bytes[0..2]); + return Err(Error::InvalidMagic { got }); + } + if bytes[3] != 0x01 { + return Err(Error::UnsupportedVersion { got: bytes[3] }); + } + + let entry_count = i16::from_le_bytes([bytes[4], bytes[5]]); + if entry_count < 0 { + return Err(Error::InvalidEntryCount { got: entry_count }); + } + let count = usize::try_from(entry_count).map_err(|_| Error::IntegerOverflow)?; + + // Validate entry_count fits in u32 (required for EntryId) + if count > u32::MAX as usize { + return Err(Error::TooManyEntries { got: count }); + } + + let xor_seed = u32::from_le_bytes([bytes[20], bytes[21], bytes[22], bytes[23]]); + + let table_len = count.checked_mul(32).ok_or(Error::IntegerOverflow)?; + let table_offset = 32usize; + let table_end = table_offset + .checked_add(table_len) + .ok_or(Error::IntegerOverflow)?; + if table_end > bytes.len() { + return Err(Error::EntryTableOutOfBounds { + table_offset: u64::try_from(table_offset).map_err(|_| Error::IntegerOverflow)?, + table_len: u64::try_from(table_len).map_err(|_| Error::IntegerOverflow)?, + file_len: u64::try_from(bytes.len()).map_err(|_| Error::IntegerOverflow)?, + }); + } + + let table_enc = &bytes[table_offset..table_end]; + let table_plain_original = xor_stream(table_enc, (xor_seed & 0xFFFF) as u16); + if table_plain_original.len() != table_len { + return Err(Error::EntryTableDecryptFailed); + } + + let (overlay, trailer_raw) = parse_ao_trailer(&bytes, opts.allow_ao_trailer)?; + #[cfg(not(test))] + let _ = trailer_raw; + + let mut entries = Vec::with_capacity(count); + for idx in 0..count { + let row = &table_plain_original[idx * 32..(idx + 1) * 32]; + + let mut name_raw = [0u8; 12]; + name_raw.copy_from_slice(&row[0..12]); + + let flags_signed = i16::from_le_bytes([row[16], row[17]]); + let sort_to_original = i16::from_le_bytes([row[18], row[19]]); + let unpacked_size = u32::from_le_bytes([row[20], row[21], row[22], row[23]]); + let data_offset_raw = u32::from_le_bytes([row[24], row[25], row[26], row[27]]); + let packed_size_declared = u32::from_le_bytes([row[28], row[29], row[30], row[31]]); + + let method_raw = (flags_signed as u16 as u32) & 0x1E0; + let method = parse_method(method_raw); + + let effective_offset_u64 = u64::from(data_offset_raw) + .checked_add(u64::from(overlay)) + .ok_or(Error::IntegerOverflow)?; + let effective_offset = + usize::try_from(effective_offset_u64).map_err(|_| Error::IntegerOverflow)?; + + let packed_size_usize = + usize::try_from(packed_size_declared).map_err(|_| Error::IntegerOverflow)?; + let mut packed_size_available = packed_size_usize; + + let end = effective_offset_u64 + .checked_add(u64::from(packed_size_declared)) + .ok_or(Error::IntegerOverflow)?; + let file_len_u64 = u64::try_from(bytes.len()).map_err(|_| Error::IntegerOverflow)?; + + if end > file_len_u64 { + if method_raw == 0x100 && end == file_len_u64 + 1 { + if opts.allow_deflate_eof_plus_one { + packed_size_available = packed_size_available + .checked_sub(1) + .ok_or(Error::IntegerOverflow)?; + } else { + return Err(Error::DeflateEofPlusOneQuirkRejected { + id: u32::try_from(idx).expect("entry count validated at parse"), + }); + } + } else { + return Err(Error::PackedSizePastEof { + id: u32::try_from(idx).expect("entry count validated at parse"), + offset: effective_offset_u64, + packed_size: packed_size_declared, + file_len: file_len_u64, + }); + } + } + + let available_end = effective_offset + .checked_add(packed_size_available) + .ok_or(Error::IntegerOverflow)?; + if available_end > bytes.len() { + return Err(Error::EntryDataOutOfBounds { + id: u32::try_from(idx).expect("entry count validated at parse"), + offset: effective_offset_u64, + size: packed_size_declared, + file_len: file_len_u64, + }); + } + + let name = decode_name(c_name_bytes(&name_raw)); + + entries.push(EntryRecord { + meta: EntryMeta { + name, + flags: i32::from(flags_signed), + method, + data_offset: effective_offset_u64, + packed_size: packed_size_declared, + unpacked_size, + }, + name_raw, + sort_to_original, + key16: sort_to_original as u16, + #[cfg(test)] + data_offset_raw, + packed_size_declared, + packed_size_available, + effective_offset, + }); + } + + let presorted_flag = u16::from_le_bytes([bytes[14], bytes[15]]); + if presorted_flag == 0xABBA { + let mut seen = vec![false; count]; + for entry in &entries { + let idx = i32::from(entry.sort_to_original); + if idx < 0 { + return Err(Error::CorruptEntryTable( + "sort_to_original is not a valid permutation index", + )); + } + let idx = usize::try_from(idx).map_err(|_| Error::IntegerOverflow)?; + if idx >= count { + return Err(Error::CorruptEntryTable( + "sort_to_original is not a valid permutation index", + )); + } + if seen[idx] { + return Err(Error::CorruptEntryTable( + "sort_to_original is not a permutation", + )); + } + seen[idx] = true; + } + if seen.iter().any(|value| !*value) { + return Err(Error::CorruptEntryTable( + "sort_to_original is not a permutation", + )); + } + } else { + let mut sorted: Vec<usize> = (0..count).collect(); + sorted.sort_by(|a, b| { + cmp_c_string( + c_name_bytes(&entries[*a].name_raw), + c_name_bytes(&entries[*b].name_raw), + ) + }); + for (idx, entry) in entries.iter_mut().enumerate() { + entry.sort_to_original = + i16::try_from(sorted[idx]).map_err(|_| Error::IntegerOverflow)?; + entry.key16 = entry.sort_to_original as u16; + } + } + + #[cfg(test)] + let source_size = bytes.len(); + + Ok(Library { + bytes, + entries, + #[cfg(test)] + header_raw, + #[cfg(test)] + table_plain_original, + #[cfg(test)] + xor_seed, + #[cfg(test)] + source_size, + #[cfg(test)] + trailer_raw, + }) +} + +fn parse_ao_trailer(bytes: &[u8], allow: bool) -> Result<(u32, Option<[u8; 6]>)> { + if !allow || bytes.len() < 6 { + return Ok((0, None)); + } + + if &bytes[bytes.len() - 6..bytes.len() - 4] != b"AO" { + return Ok((0, None)); + } + + let mut trailer = [0u8; 6]; + trailer.copy_from_slice(&bytes[bytes.len() - 6..]); + let overlay = u32::from_le_bytes([trailer[2], trailer[3], trailer[4], trailer[5]]); + + if u64::from(overlay) > u64::try_from(bytes.len()).map_err(|_| Error::IntegerOverflow)? { + return Err(Error::MediaOverlayOutOfBounds { + overlay, + file_len: u64::try_from(bytes.len()).map_err(|_| Error::IntegerOverflow)?, + }); + } + + Ok((overlay, Some(trailer))) +} + +pub fn parse_method(raw: u32) -> PackMethod { + match raw { + 0x000 => PackMethod::None, + 0x020 => PackMethod::XorOnly, + 0x040 => PackMethod::Lzss, + 0x060 => PackMethod::XorLzss, + 0x080 => PackMethod::LzssHuffman, + 0x0A0 => PackMethod::XorLzssHuffman, + 0x100 => PackMethod::Deflate, + other => PackMethod::Unknown(other), + } +} + +fn decode_name(name: &[u8]) -> String { + name.iter().map(|b| char::from(*b)).collect() +} + +pub fn c_name_bytes(raw: &[u8; 12]) -> &[u8] { + let len = raw.iter().position(|&b| b == 0).unwrap_or(raw.len()); + &raw[..len] +} + +pub fn cmp_c_string(a: &[u8], b: &[u8]) -> Ordering { + let min_len = a.len().min(b.len()); + let mut idx = 0usize; + while idx < min_len { + if a[idx] != b[idx] { + return a[idx].cmp(&b[idx]); + } + idx += 1; + } + a.len().cmp(&b.len()) +} diff --git a/crates/rsli/src/tests.rs b/crates/rsli/src/tests.rs new file mode 100644 index 0000000..07807d3 --- /dev/null +++ b/crates/rsli/src/tests.rs @@ -0,0 +1,1337 @@ +use super::*; +use crate::compress::lzh::{LZH_MAX_FREQ, LZH_N_CHAR, LZH_R, LZH_T}; +use crate::compress::xor::xor_stream; +use flate2::write::DeflateEncoder; +use flate2::write::ZlibEncoder; +use flate2::Compression; +use std::any::Any; +use std::fs; +use std::io::Write as _; +use std::panic::{catch_unwind, AssertUnwindSafe}; +use std::path::PathBuf; + +#[derive(Clone, Debug)] +struct SyntheticRsliEntry { + name: String, + method_raw: u16, + plain: Vec<u8>, + declared_packed_size: Option<u32>, +} + +#[derive(Clone, Debug)] +struct RsliBuildOptions { + seed: u32, + presorted: bool, + overlay: u32, + add_ao_trailer: bool, +} + +impl Default for RsliBuildOptions { + fn default() -> Self { + Self { + seed: 0x1234_5678, + presorted: true, + overlay: 0, + add_ao_trailer: false, + } + } +} + +fn collect_files_recursive(root: &Path, out: &mut Vec<PathBuf>) { + let Ok(entries) = fs::read_dir(root) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect_files_recursive(&path, out); + } else if path.is_file() { + out.push(path); + } + } +} + +fn rsli_test_files() -> Vec<PathBuf> { + let root = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("testdata") + .join("rsli"); + let mut files = Vec::new(); + collect_files_recursive(&root, &mut files); + files.sort(); + files + .into_iter() + .filter(|path| { + fs::read(path) + .map(|data| data.get(0..4) == Some(b"NL\0\x01")) + .unwrap_or(false) + }) + .collect() +} + +fn panic_message(payload: Box<dyn Any + Send>) -> String { + let any = payload.as_ref(); + if let Some(message) = any.downcast_ref::<String>() { + return message.clone(); + } + if let Some(message) = any.downcast_ref::<&str>() { + return (*message).to_string(); + } + String::from("panic without message") +} + +fn write_temp_file(prefix: &str, bytes: &[u8]) -> PathBuf { + let mut path = std::env::temp_dir(); + path.push(format!( + "{}-{}-{}.bin", + prefix, + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + fs::write(&path, bytes).expect("failed to write temp archive"); + path +} + +fn deflate_raw(data: &[u8]) -> Vec<u8> { + let mut encoder = DeflateEncoder::new(Vec::new(), Compression::default()); + encoder + .write_all(data) + .expect("deflate encoder write failed"); + encoder.finish().expect("deflate encoder finish failed") +} + +fn deflate_zlib(data: &[u8]) -> Vec<u8> { + let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default()); + encoder.write_all(data).expect("zlib encoder write failed"); + encoder.finish().expect("zlib encoder finish failed") +} + +fn lzss_pack_literals(data: &[u8]) -> Vec<u8> { + let mut out = Vec::new(); + for chunk in data.chunks(8) { + let mask = if chunk.len() == 8 { + 0xFF + } else { + (1u16 + .checked_shl(u32::try_from(chunk.len()).expect("chunk len overflow")) + .expect("shift overflow") + - 1) as u8 + }; + out.push(mask); + out.extend_from_slice(chunk); + } + out +} + +struct BitWriter { + bytes: Vec<u8>, + current: u8, + mask: u8, +} + +impl BitWriter { + fn new() -> Self { + Self { + bytes: Vec::new(), + current: 0, + mask: 0x80, + } + } + + fn write_bit(&mut self, bit: u8) { + if bit != 0 { + self.current |= self.mask; + } + self.mask >>= 1; + if self.mask == 0 { + self.bytes.push(self.current); + self.current = 0; + self.mask = 0x80; + } + } + + fn finish(mut self) -> Vec<u8> { + if self.mask != 0x80 { + self.bytes.push(self.current); + } + self.bytes + } +} + +struct LzhLiteralModel { + freq: [u16; LZH_T + 1], + parent: [usize; LZH_T + LZH_N_CHAR], + son: [usize; LZH_T + 1], +} + +impl LzhLiteralModel { + fn new() -> Self { + let mut model = Self { + freq: [0; LZH_T + 1], + parent: [0; LZH_T + LZH_N_CHAR], + son: [0; LZH_T + 1], + }; + model.start_huff(); + model + } + + fn encode_literal(&mut self, literal: u8, writer: &mut BitWriter) { + let target = usize::from(literal) + LZH_T; + let mut path = Vec::new(); + let mut visited = [false; LZH_T + 1]; + let found = self.find_path(self.son[LZH_R], target, &mut path, &mut visited); + assert!(found, "failed to encode literal {literal}"); + for bit in path { + writer.write_bit(bit); + } + + self.update(usize::from(literal)); + } + + fn find_path( + &self, + node: usize, + target: usize, + path: &mut Vec<u8>, + visited: &mut [bool; LZH_T + 1], + ) -> bool { + if node == target { + return true; + } + if node >= LZH_T { + return false; + } + if visited[node] { + return false; + } + visited[node] = true; + + for bit in [0u8, 1u8] { + let child = self.son[node + usize::from(bit)]; + path.push(bit); + if self.find_path(child, target, path, visited) { + visited[node] = false; + return true; + } + path.pop(); + } + + visited[node] = false; + false + } + + fn start_huff(&mut self) { + for i in 0..LZH_N_CHAR { + self.freq[i] = 1; + self.son[i] = i + LZH_T; + self.parent[i + LZH_T] = i; + } + + let mut i = 0usize; + let mut j = LZH_N_CHAR; + while j <= LZH_R { + self.freq[j] = self.freq[i].saturating_add(self.freq[i + 1]); + self.son[j] = i; + self.parent[i] = j; + self.parent[i + 1] = j; + i += 2; + j += 1; + } + + self.freq[LZH_T] = u16::MAX; + self.parent[LZH_R] = 0; + } + + fn update(&mut self, c: usize) { + if self.freq[LZH_R] == LZH_MAX_FREQ { + self.reconstruct(); + } + + let mut current = self.parent[c + LZH_T]; + loop { + self.freq[current] = self.freq[current].saturating_add(1); + let freq = self.freq[current]; + + if current + 1 < self.freq.len() && freq > self.freq[current + 1] { + let mut swap_idx = current + 1; + while swap_idx + 1 < self.freq.len() && freq > self.freq[swap_idx + 1] { + swap_idx += 1; + } + + self.freq.swap(current, swap_idx); + + let left = self.son[current]; + let right = self.son[swap_idx]; + self.son[current] = right; + self.son[swap_idx] = left; + + self.parent[left] = swap_idx; + if left < LZH_T { + self.parent[left + 1] = swap_idx; + } + + self.parent[right] = current; + if right < LZH_T { + self.parent[right + 1] = current; + } + + current = swap_idx; + } + + current = self.parent[current]; + if current == 0 { + break; + } + } + } + + fn reconstruct(&mut self) { + let mut j = 0usize; + for i in 0..LZH_T { + if self.son[i] >= LZH_T { + self.freq[j] = self.freq[i].div_ceil(2); + self.son[j] = self.son[i]; + j += 1; + } + } + + let mut i = 0usize; + let mut current = LZH_N_CHAR; + while current < LZH_T { + let sum = self.freq[i].saturating_add(self.freq[i + 1]); + self.freq[current] = sum; + + let mut insert_at = current; + while insert_at > 0 && sum < self.freq[insert_at - 1] { + insert_at -= 1; + } + + for move_idx in (insert_at..current).rev() { + self.freq[move_idx + 1] = self.freq[move_idx]; + self.son[move_idx + 1] = self.son[move_idx]; + } + + self.freq[insert_at] = sum; + self.son[insert_at] = i; + i += 2; + current += 1; + } + + for idx in 0..LZH_T { + let node = self.son[idx]; + self.parent[node] = idx; + if node < LZH_T { + self.parent[node + 1] = idx; + } + } + + self.freq[LZH_T] = u16::MAX; + self.parent[LZH_R] = 0; + } +} + +fn lzh_pack_literals(data: &[u8]) -> Vec<u8> { + let mut writer = BitWriter::new(); + let mut model = LzhLiteralModel::new(); + for byte in data { + model.encode_literal(*byte, &mut writer); + } + writer.finish() +} + +fn packed_for_method(method_raw: u16, plain: &[u8], key16: u16) -> Vec<u8> { + match (u32::from(method_raw)) & 0x1E0 { + 0x000 => plain.to_vec(), + 0x020 => xor_stream(plain, key16), + 0x040 => lzss_pack_literals(plain), + 0x060 => xor_stream(&lzss_pack_literals(plain), key16), + 0x080 => lzh_pack_literals(plain), + 0x0A0 => xor_stream(&lzh_pack_literals(plain), key16), + 0x100 => deflate_raw(plain), + _ => plain.to_vec(), + } +} + +fn build_rsli_bytes(entries: &[SyntheticRsliEntry], opts: &RsliBuildOptions) -> Vec<u8> { + let count = entries.len(); + let mut rows_plain = vec![0u8; count * 32]; + let table_end = 32 + rows_plain.len(); + + let mut sort_lookup: Vec<usize> = (0..count).collect(); + sort_lookup.sort_by(|a, b| entries[*a].name.as_bytes().cmp(entries[*b].name.as_bytes())); + + let mut packed_blobs = Vec::with_capacity(count); + for index in 0..count { + let key16 = u16::try_from(sort_lookup[index]).expect("sort index overflow"); + let packed = packed_for_method(entries[index].method_raw, &entries[index].plain, key16); + packed_blobs.push(packed); + } + + let overlay = usize::try_from(opts.overlay).expect("overlay overflow"); + let mut cursor = table_end + overlay; + let mut output = vec![0u8; cursor]; + + let mut data_offsets = Vec::with_capacity(count); + for (index, packed) in packed_blobs.iter().enumerate() { + let raw_offset = cursor + .checked_sub(overlay) + .expect("overlay larger than cursor"); + data_offsets.push(raw_offset); + + let end = cursor.checked_add(packed.len()).expect("cursor overflow"); + if output.len() < end { + output.resize(end, 0); + } + output[cursor..end].copy_from_slice(packed); + cursor = end; + + let base = index * 32; + let mut name_raw = [0u8; 12]; + let uppercase = entries[index].name.to_ascii_uppercase(); + let name_bytes = uppercase.as_bytes(); + assert!(name_bytes.len() <= 12, "name too long in synthetic fixture"); + name_raw[..name_bytes.len()].copy_from_slice(name_bytes); + + rows_plain[base..base + 12].copy_from_slice(&name_raw); + + let sort_field: i16 = if opts.presorted { + i16::try_from(sort_lookup[index]).expect("sort field overflow") + } else { + 0 + }; + + let packed_size = entries[index] + .declared_packed_size + .unwrap_or_else(|| u32::try_from(packed.len()).expect("packed size overflow")); + + rows_plain[base + 16..base + 18].copy_from_slice(&entries[index].method_raw.to_le_bytes()); + rows_plain[base + 18..base + 20].copy_from_slice(&sort_field.to_le_bytes()); + rows_plain[base + 20..base + 24].copy_from_slice( + &u32::try_from(entries[index].plain.len()) + .expect("unpacked size overflow") + .to_le_bytes(), + ); + rows_plain[base + 24..base + 28].copy_from_slice( + &u32::try_from(data_offsets[index]) + .expect("data offset overflow") + .to_le_bytes(), + ); + rows_plain[base + 28..base + 32].copy_from_slice(&packed_size.to_le_bytes()); + } + + if output.len() < table_end { + output.resize(table_end, 0); + } + + output[0..2].copy_from_slice(b"NL"); + output[2] = 0; + output[3] = 1; + output[4..6].copy_from_slice( + &i16::try_from(count) + .expect("entry count overflow") + .to_le_bytes(), + ); + + let presorted_flag = if opts.presorted { 0xABBA_u16 } else { 0_u16 }; + output[14..16].copy_from_slice(&presorted_flag.to_le_bytes()); + output[20..24].copy_from_slice(&opts.seed.to_le_bytes()); + + let encrypted_table = xor_stream(&rows_plain, (opts.seed & 0xFFFF) as u16); + output[32..table_end].copy_from_slice(&encrypted_table); + + if opts.add_ao_trailer { + output.extend_from_slice(b"AO"); + output.extend_from_slice(&opts.overlay.to_le_bytes()); + } + + output +} + +fn read_u32_le(bytes: &[u8], offset: usize) -> u32 { + let slice = bytes + .get(offset..offset + 4) + .expect("u32 read out of bounds in test"); + let arr: [u8; 4] = slice.try_into().expect("u32 conversion failed in test"); + u32::from_le_bytes(arr) +} + +#[test] +fn rsli_read_unpack_and_repack_all_files() { + let files = rsli_test_files(); + if files.is_empty() { + eprintln!( + "skipping rsli_read_unpack_and_repack_all_files: no RsLi archives in testdata/rsli" + ); + return; + } + + let checked = files.len(); + let mut success = 0usize; + let mut failures = Vec::new(); + + for path in files { + let display_path = path.display().to_string(); + let result = catch_unwind(AssertUnwindSafe(|| { + let original = fs::read(&path).expect("failed to read archive"); + let library = Library::open_path(&path) + .unwrap_or_else(|err| panic!("failed to open {}: {err}", path.display())); + + let count = library.entry_count(); + assert_eq!( + count, + library.entries().count(), + "entry count mismatch: {}", + path.display() + ); + + for idx in 0..count { + let id = EntryId(idx as u32); + let meta_ref = library + .get(id) + .unwrap_or_else(|| panic!("missing entry #{idx} in {}", path.display())); + + let loaded = library.load(id).unwrap_or_else(|err| { + panic!("load failed for {} entry #{idx}: {err}", path.display()) + }); + + let packed = library.load_packed(id).unwrap_or_else(|err| { + panic!( + "load_packed failed for {} entry #{idx}: {err}", + path.display() + ) + }); + let unpacked = library.unpack(&packed).unwrap_or_else(|err| { + panic!("unpack failed for {} entry #{idx}: {err}", path.display()) + }); + assert_eq!( + loaded, + unpacked, + "load != unpack in {} entry #{idx}", + path.display() + ); + + let mut out = Vec::new(); + let written = library.load_into(id, &mut out).unwrap_or_else(|err| { + panic!( + "load_into failed for {} entry #{idx}: {err}", + path.display() + ) + }); + assert_eq!( + written, + loaded.len(), + "load_into size mismatch in {} entry #{idx}", + path.display() + ); + assert_eq!( + out, + loaded, + "load_into payload mismatch in {} entry #{idx}", + path.display() + ); + + let fast = library.load_fast(id).unwrap_or_else(|err| { + panic!( + "load_fast failed for {} entry #{idx}: {err}", + path.display() + ) + }); + assert_eq!( + fast.as_slice(), + loaded.as_slice(), + "load_fast mismatch in {} entry #{idx}", + path.display() + ); + + let found = library.find(&meta_ref.meta.name).unwrap_or_else(|| { + panic!( + "find failed for '{}' in {}", + meta_ref.meta.name, + path.display() + ) + }); + let found_meta = library.get(found).expect("find returned invalid entry id"); + assert_eq!( + found_meta.meta.name, + meta_ref.meta.name, + "find returned a different entry in {}", + path.display() + ); + } + + let rebuilt = library + .rebuild_from_parsed_metadata() + .unwrap_or_else(|err| panic!("rebuild failed for {}: {err}", path.display())); + assert_eq!( + rebuilt, + original, + "byte-to-byte roundtrip mismatch for {}", + path.display() + ); + })); + + match result { + Ok(()) => success += 1, + Err(payload) => failures.push(format!("{}: {}", display_path, panic_message(payload))), + } + } + + let failed = failures.len(); + eprintln!( + "RsLi summary: checked={}, success={}, failed={}", + checked, success, failed + ); + if !failures.is_empty() { + panic!( + "RsLi validation failed.\nsummary: checked={}, success={}, failed={}\n{}", + checked, + success, + failed, + failures.join("\n") + ); + } +} + +#[test] +fn rsli_docs_structural_invariants_all_files() { + let files = rsli_test_files(); + if files.is_empty() { + eprintln!( + "skipping rsli_docs_structural_invariants_all_files: no RsLi archives in testdata/rsli" + ); + return; + } + + let mut deflate_eof_plus_one_quirks = Vec::new(); + + for path in files { + let bytes = fs::read(&path).unwrap_or_else(|err| { + panic!("failed to read {}: {err}", path.display()); + }); + + assert!( + bytes.len() >= 32, + "RsLi header too short in {}", + path.display() + ); + assert_eq!(&bytes[0..2], b"NL", "bad magic in {}", path.display()); + assert_eq!( + bytes[2], + 0, + "reserved header byte must be zero in {}", + path.display() + ); + assert_eq!(bytes[3], 1, "bad version in {}", path.display()); + + let entry_count = i16::from_le_bytes([bytes[4], bytes[5]]); + assert!( + entry_count >= 0, + "negative entry_count={} in {}", + entry_count, + path.display() + ); + let count = usize::try_from(entry_count).expect("entry_count overflow"); + let table_size = count.checked_mul(32).expect("table_size overflow"); + let table_end = 32usize.checked_add(table_size).expect("table_end overflow"); + assert!( + table_end <= bytes.len(), + "table out of bounds in {}", + path.display() + ); + + let seed = read_u32_le(&bytes, 20); + let table_plain = xor_stream(&bytes[32..table_end], (seed & 0xFFFF) as u16); + assert_eq!( + table_plain.len(), + table_size, + "decrypted table size mismatch in {}", + path.display() + ); + + let mut overlay = 0u32; + if bytes.len() >= 6 && &bytes[bytes.len() - 6..bytes.len() - 4] == b"AO" { + overlay = read_u32_le(&bytes, bytes.len() - 4); + assert!( + usize::try_from(overlay).expect("overlay overflow") <= bytes.len(), + "overlay beyond EOF in {}", + path.display() + ); + } + + let presorted_flag = u16::from_le_bytes([bytes[14], bytes[15]]); + let mut sort_values = Vec::with_capacity(count); + + for index in 0..count { + let base = index * 32; + let row = &table_plain[base..base + 32]; + let flags_signed = i16::from_le_bytes([row[16], row[17]]); + let sort_to_original = i16::from_le_bytes([row[18], row[19]]); + let data_offset = u64::from(read_u32_le(row, 24)); + let packed_size = u64::from(read_u32_le(row, 28)); + + let method = (flags_signed as u16 as u32) & 0x1E0; + let effective_offset = data_offset + u64::from(overlay); + let end = effective_offset + packed_size; + let file_len = u64::try_from(bytes.len()).expect("file size overflow"); + + if end > file_len { + assert!( + method == 0x100 && end == file_len + 1, + "packed range out of bounds in {} entry #{index}: method=0x{method:03X}, range=[{effective_offset}, {end}), file={file_len}", + path.display() + ); + deflate_eof_plus_one_quirks.push((path.display().to_string(), index)); + } + + sort_values.push(sort_to_original); + } + + if presorted_flag == 0xABBA { + let mut sorted = sort_values; + sorted.sort_unstable(); + let expected: Vec<i16> = (0..count) + .map(|idx| i16::try_from(idx).expect("too many entries for i16")) + .collect(); + assert_eq!( + sorted, + expected, + "sort_to_original is not a permutation in {}", + path.display() + ); + } + } + + if !deflate_eof_plus_one_quirks.is_empty() { + assert!( + deflate_eof_plus_one_quirks + .iter() + .all(|(file, idx)| file.ends_with("sprites.lib") && *idx == 23), + "unexpected deflate EOF+1 quirks: {:?}", + deflate_eof_plus_one_quirks + ); + } +} + +#[test] +fn rsli_synthetic_all_methods_roundtrip() { + let entries = vec![ + SyntheticRsliEntry { + name: "M_NONE".to_string(), + method_raw: 0x000, + plain: b"plain-data".to_vec(), + declared_packed_size: None, + }, + SyntheticRsliEntry { + name: "M_XOR".to_string(), + method_raw: 0x020, + plain: b"xor-only".to_vec(), + declared_packed_size: None, + }, + SyntheticRsliEntry { + name: "M_LZSS".to_string(), + method_raw: 0x040, + plain: b"lzss literals payload".to_vec(), + declared_packed_size: None, + }, + SyntheticRsliEntry { + name: "M_XLZS".to_string(), + method_raw: 0x060, + plain: b"xor lzss payload".to_vec(), + declared_packed_size: None, + }, + SyntheticRsliEntry { + name: "M_LZHU".to_string(), + method_raw: 0x080, + plain: b"huffman literals payload".to_vec(), + declared_packed_size: None, + }, + SyntheticRsliEntry { + name: "M_XLZH".to_string(), + method_raw: 0x0A0, + plain: b"xor huffman payload".to_vec(), + declared_packed_size: None, + }, + SyntheticRsliEntry { + name: "M_DEFL".to_string(), + method_raw: 0x100, + plain: b"deflate payload with repetition repetition repetition".to_vec(), + declared_packed_size: None, + }, + ]; + + let bytes = build_rsli_bytes( + &entries, + &RsliBuildOptions { + seed: 0xA1B2_C3D4, + presorted: false, + overlay: 0, + add_ao_trailer: false, + }, + ); + let path = write_temp_file("rsli-all-methods", &bytes); + + let library = Library::open_path(&path).expect("open synthetic rsli failed"); + assert_eq!(library.entry_count(), entries.len()); + + for entry in &entries { + let id = library + .find(&entry.name) + .unwrap_or_else(|| panic!("find failed for {}", entry.name)); + let loaded = library + .load(id) + .unwrap_or_else(|err| panic!("load failed for {}: {err}", entry.name)); + assert_eq!( + loaded, entry.plain, + "decoded payload mismatch for {}", + entry.name + ); + + let packed = library + .load_packed(id) + .unwrap_or_else(|err| panic!("load_packed failed for {}: {err}", entry.name)); + let unpacked = library + .unpack(&packed) + .unwrap_or_else(|err| panic!("unpack failed for {}: {err}", entry.name)); + assert_eq!(unpacked, entry.plain, "unpack mismatch for {}", entry.name); + } + + let _ = fs::remove_file(&path); +} + +#[test] +fn rsli_empty_archive_roundtrip() { + let bytes = build_rsli_bytes(&[], &RsliBuildOptions::default()); + let path = write_temp_file("rsli-empty", &bytes); + + let library = Library::open_path(&path).expect("open empty rsli failed"); + assert_eq!(library.entry_count(), 0); + assert_eq!(library.find("ANYTHING"), None); + + let rebuilt = library + .rebuild_from_parsed_metadata() + .expect("rebuild empty rsli failed"); + assert_eq!(rebuilt, bytes, "empty rsli roundtrip mismatch"); + + let _ = fs::remove_file(&path); +} + +#[test] +fn rsli_max_name_length_without_nul_roundtrip() { + let max_name = "NAME12345678"; + assert_eq!(max_name.len(), 12); + + let bytes = build_rsli_bytes( + &[SyntheticRsliEntry { + name: max_name.to_string(), + method_raw: 0x000, + plain: b"payload".to_vec(), + declared_packed_size: None, + }], + &RsliBuildOptions::default(), + ); + let path = write_temp_file("rsli-max-name", &bytes); + + let library = Library::open_path(&path).expect("open max-name rsli failed"); + assert_eq!(library.entry_count(), 1); + assert_eq!(library.find(max_name), Some(EntryId(0))); + assert_eq!( + library.find(&max_name.to_ascii_lowercase()), + Some(EntryId(0)) + ); + assert_eq!( + library.entries[0] + .name_raw + .iter() + .position(|byte| *byte == 0), + None, + "name_raw must occupy full 12 bytes without NUL" + ); + + let entry = library.get(EntryId(0)).expect("missing entry"); + assert_eq!(entry.meta.name, max_name); + assert_eq!( + library.load(EntryId(0)).expect("load failed"), + b"payload", + "payload mismatch" + ); + + let _ = fs::remove_file(&path); +} + +#[test] +fn rsli_lzss_large_payload_over_4k_roundtrip() { + let plain: Vec<u8> = (0..10_000u32).map(|v| (v % 251) as u8).collect(); + let entries = vec![ + SyntheticRsliEntry { + name: "LZSS4K".to_string(), + method_raw: 0x040, + plain: plain.clone(), + declared_packed_size: None, + }, + SyntheticRsliEntry { + name: "XLZS4K".to_string(), + method_raw: 0x060, + plain: plain.clone(), + declared_packed_size: None, + }, + ]; + let bytes = build_rsli_bytes(&entries, &RsliBuildOptions::default()); + let path = write_temp_file("rsli-lzss-4k", &bytes); + + let library = Library::open_path(&path).expect("open large-lzss rsli failed"); + assert_eq!(library.entry_count(), entries.len()); + + for entry in &entries { + let id = library + .find(&entry.name) + .unwrap_or_else(|| panic!("find failed for {}", entry.name)); + let loaded = library + .load(id) + .unwrap_or_else(|err| panic!("load failed for {}: {err}", entry.name)); + assert_eq!(loaded, plain, "payload mismatch for {}", entry.name); + } + + let _ = fs::remove_file(&path); +} + +#[test] +fn rsli_find_falls_back_when_sort_table_corrupted_in_memory() { + let entries = vec![ + SyntheticRsliEntry { + name: "AAA".to_string(), + method_raw: 0x000, + plain: b"a".to_vec(), + declared_packed_size: None, + }, + SyntheticRsliEntry { + name: "BBB".to_string(), + method_raw: 0x000, + plain: b"b".to_vec(), + declared_packed_size: None, + }, + SyntheticRsliEntry { + name: "CCC".to_string(), + method_raw: 0x000, + plain: b"c".to_vec(), + declared_packed_size: None, + }, + ]; + let bytes = build_rsli_bytes( + &entries, + &RsliBuildOptions { + presorted: true, + ..RsliBuildOptions::default() + }, + ); + let path = write_temp_file("rsli-find-fallback", &bytes); + + let mut library = Library::open_path(&path).expect("open synthetic rsli failed"); + library.entries[1].sort_to_original = -1; + + assert_eq!(library.find("AAA"), Some(EntryId(0))); + assert_eq!(library.find("bbb"), Some(EntryId(1))); + assert_eq!(library.find("CcC"), Some(EntryId(2))); + assert_eq!(library.find("missing"), None); + + let _ = fs::remove_file(&path); +} + +#[test] +fn rsli_deflate_method_rejects_zlib_wrapped_stream() { + let plain = b"payload".to_vec(); + let zlib_payload = deflate_zlib(&plain); + let entries = vec![SyntheticRsliEntry { + name: "ZLIB".to_string(), + method_raw: 0x100, + plain, + declared_packed_size: Some( + u32::try_from(zlib_payload.len()).expect("zlib payload size overflow"), + ), + }]; + let mut bytes = build_rsli_bytes( + &entries, + &RsliBuildOptions { + presorted: true, + ..RsliBuildOptions::default() + }, + ); + + let table_end = 32 + entries.len() * 32; + let data_offset = table_end; + let data_end = data_offset + zlib_payload.len(); + if bytes.len() < data_end { + bytes.resize(data_end, 0); + } + bytes[data_offset..data_end].copy_from_slice(&zlib_payload); + + let path = write_temp_file("rsli-zlib-reject", &bytes); + let library = Library::open_path(&path).expect("open zlib-wrapped rsli failed"); + match library.load(EntryId(0)) { + Err(Error::DecompressionFailed(reason)) => { + assert_eq!(reason, "deflate"); + } + other => panic!("expected deflate decompression error, got {other:?}"), + } + let _ = fs::remove_file(&path); +} + +#[test] +fn rsli_lzss_huffman_reports_unexpected_eof() { + let entries = vec![SyntheticRsliEntry { + name: "TRUNC".to_string(), + method_raw: 0x080, + plain: b"this payload is long enough".to_vec(), + declared_packed_size: None, + }]; + let mut bytes = build_rsli_bytes( + &entries, + &RsliBuildOptions { + presorted: true, + ..RsliBuildOptions::default() + }, + ); + + let seed = read_u32_le(&bytes, 20); + let mut table_plain = xor_stream(&bytes[32..64], (seed & 0xFFFF) as u16); + let original_packed_size = u32::from_le_bytes([ + table_plain[28], + table_plain[29], + table_plain[30], + table_plain[31], + ]); + assert!( + original_packed_size > 4, + "packed payload too small for truncation" + ); + let truncated_size = original_packed_size - 3; + table_plain[28..32].copy_from_slice(&truncated_size.to_le_bytes()); + let encrypted_table = xor_stream(&table_plain, (seed & 0xFFFF) as u16); + bytes[32..64].copy_from_slice(&encrypted_table); + + let path = write_temp_file("rsli-lzh-truncated", &bytes); + let library = Library::open_path(&path).expect("open truncated lzh rsli failed"); + match library.load(EntryId(0)) { + Err(Error::DecompressionFailed(reason)) => { + assert_eq!(reason, "lzss-huffman: unexpected EOF"); + } + other => panic!("expected lzss-huffman EOF error, got {other:?}"), + } + let _ = fs::remove_file(&path); +} + +#[test] +fn rsli_presorted_flag_requires_permutation() { + let entries = vec![ + SyntheticRsliEntry { + name: "AAA".to_string(), + method_raw: 0x000, + plain: b"a".to_vec(), + declared_packed_size: None, + }, + SyntheticRsliEntry { + name: "BBB".to_string(), + method_raw: 0x000, + plain: b"b".to_vec(), + declared_packed_size: None, + }, + ]; + let mut bytes = build_rsli_bytes( + &entries, + &RsliBuildOptions { + presorted: true, + ..RsliBuildOptions::default() + }, + ); + + let seed = read_u32_le(&bytes, 20); + let mut table_plain = xor_stream(&bytes[32..32 + entries.len() * 32], (seed & 0xFFFF) as u16); + + // Corrupt sort_to_original: duplicate index 0, so the table is not a permutation. + table_plain[18..20].copy_from_slice(&0i16.to_le_bytes()); + table_plain[50..52].copy_from_slice(&0i16.to_le_bytes()); + + let table_encrypted = xor_stream(&table_plain, (seed & 0xFFFF) as u16); + bytes[32..32 + table_encrypted.len()].copy_from_slice(&table_encrypted); + + let path = write_temp_file("rsli-bad-presorted-perm", &bytes); + match Library::open_path(&path) { + Err(Error::CorruptEntryTable(message)) => { + assert!( + message.contains("permutation"), + "unexpected error message: {message}" + ); + } + other => panic!("expected CorruptEntryTable for invalid permutation, got {other:?}"), + } + let _ = fs::remove_file(&path); +} + +#[test] +fn rsli_load_reports_correct_entry_id_on_range_failure() { + let entries = vec![ + SyntheticRsliEntry { + name: "ONE".to_string(), + method_raw: 0x000, + plain: b"one".to_vec(), + declared_packed_size: None, + }, + SyntheticRsliEntry { + name: "TWO".to_string(), + method_raw: 0x000, + plain: b"two".to_vec(), + declared_packed_size: None, + }, + ]; + let bytes = build_rsli_bytes( + &entries, + &RsliBuildOptions { + presorted: true, + ..RsliBuildOptions::default() + }, + ); + let path = write_temp_file("rsli-entry-id-error", &bytes); + + let mut library = Library::open_path(&path).expect("open synthetic rsli failed"); + library.entries[1].packed_size_available = usize::MAX; + + match library.load(EntryId(1)) { + Err(Error::IntegerOverflow) => {} + other => panic!("expected IntegerOverflow, got {other:?}"), + } + + library.entries[1].packed_size_available = library.bytes.len(); + match library.load(EntryId(1)) { + Err(Error::EntryDataOutOfBounds { id, .. }) => assert_eq!(id, 1), + other => panic!("expected EntryDataOutOfBounds with id=1, got {other:?}"), + } + + let _ = fs::remove_file(&path); +} + +#[test] +fn rsli_xorlzss_huffman_on_the_fly_roundtrip() { + let plain: Vec<u8> = (0..512u16).map(|i| b'A' + (i % 26) as u8).collect(); + let entries = vec![SyntheticRsliEntry { + name: "XLZH_ONFLY".to_string(), + method_raw: 0x0A0, + plain: plain.clone(), + declared_packed_size: None, + }]; + + let bytes = build_rsli_bytes( + &entries, + &RsliBuildOptions { + seed: 0x0BAD_C0DE, + presorted: true, + overlay: 0, + add_ao_trailer: false, + }, + ); + let path = write_temp_file("rsli-xorlzh-onfly", &bytes); + + let library = Library::open_path(&path).expect("open synthetic XLZH archive failed"); + let id = library + .find("XLZH_ONFLY") + .expect("find XLZH_ONFLY entry failed"); + + let loaded = library.load(id).expect("load XLZH_ONFLY failed"); + assert_eq!(loaded, plain); + + let packed = library + .load_packed(id) + .expect("load_packed XLZH_ONFLY failed"); + let unpacked = library.unpack(&packed).expect("unpack XLZH_ONFLY failed"); + assert_eq!(unpacked, loaded); + + let _ = fs::remove_file(&path); +} + +#[test] +fn rsli_synthetic_overlay_and_ao_trailer() { + let entries = vec![SyntheticRsliEntry { + name: "OVERLAY".to_string(), + method_raw: 0x040, + plain: b"overlay-data".to_vec(), + declared_packed_size: None, + }]; + + let bytes = build_rsli_bytes( + &entries, + &RsliBuildOptions { + seed: 0x4433_2211, + presorted: true, + overlay: 128, + add_ao_trailer: true, + }, + ); + let path = write_temp_file("rsli-overlay", &bytes); + + let library = Library::open_path_with( + &path, + OpenOptions { + allow_ao_trailer: true, + allow_deflate_eof_plus_one: true, + }, + ) + .expect("open with AO trailer enabled failed"); + + let id = library.find("OVERLAY").expect("find overlay entry failed"); + let payload = library.load(id).expect("load overlay entry failed"); + assert_eq!(payload, b"overlay-data"); + + let _ = fs::remove_file(&path); +} + +#[test] +fn rsli_deflate_eof_plus_one_quirk() { + let plain = b"quirk deflate payload".to_vec(); + let packed = deflate_raw(&plain); + let declared = u32::try_from(packed.len() + 1).expect("declared size overflow"); + + let entries = vec![SyntheticRsliEntry { + name: "QUIRK".to_string(), + method_raw: 0x100, + plain, + declared_packed_size: Some(declared), + }]; + let bytes = build_rsli_bytes(&entries, &RsliBuildOptions::default()); + let path = write_temp_file("rsli-deflate-quirk", &bytes); + + let lib_ok = Library::open_path_with( + &path, + OpenOptions { + allow_ao_trailer: true, + allow_deflate_eof_plus_one: true, + }, + ) + .expect("open with EOF+1 quirk enabled failed"); + let loaded = lib_ok + .load(lib_ok.find("QUIRK").expect("find quirk entry failed")) + .expect("load quirk entry failed"); + assert_eq!(loaded, b"quirk deflate payload"); + + match Library::open_path_with( + &path, + OpenOptions { + allow_ao_trailer: true, + allow_deflate_eof_plus_one: false, + }, + ) { + Err(Error::DeflateEofPlusOneQuirkRejected { id }) => assert_eq!(id, 0), + other => panic!("expected DeflateEofPlusOneQuirkRejected, got {other:?}"), + } + + let _ = fs::remove_file(&path); +} + +#[test] +fn rsli_validation_error_cases() { + let valid = build_rsli_bytes( + &[SyntheticRsliEntry { + name: "BASE".to_string(), + method_raw: 0x000, + plain: b"abc".to_vec(), + declared_packed_size: None, + }], + &RsliBuildOptions::default(), + ); + + let mut bad_magic = valid.clone(); + bad_magic[0..2].copy_from_slice(b"XX"); + let path = write_temp_file("rsli-bad-magic", &bad_magic); + match Library::open_path(&path) { + Err(Error::InvalidMagic { .. }) => {} + other => panic!("expected InvalidMagic, got {other:?}"), + } + let _ = fs::remove_file(&path); + + let mut bad_version = valid.clone(); + bad_version[3] = 2; + let path = write_temp_file("rsli-bad-version", &bad_version); + match Library::open_path(&path) { + Err(Error::UnsupportedVersion { got }) => assert_eq!(got, 2), + other => panic!("expected UnsupportedVersion, got {other:?}"), + } + let _ = fs::remove_file(&path); + + let mut bad_count = valid.clone(); + bad_count[4..6].copy_from_slice(&(-1_i16).to_le_bytes()); + let path = write_temp_file("rsli-bad-count", &bad_count); + match Library::open_path(&path) { + Err(Error::InvalidEntryCount { got }) => assert_eq!(got, -1), + other => panic!("expected InvalidEntryCount, got {other:?}"), + } + let _ = fs::remove_file(&path); + + let mut bad_table = valid.clone(); + bad_table[4..6].copy_from_slice(&100_i16.to_le_bytes()); + let path = write_temp_file("rsli-bad-table", &bad_table); + match Library::open_path(&path) { + Err(Error::EntryTableOutOfBounds { .. }) => {} + other => panic!("expected EntryTableOutOfBounds, got {other:?}"), + } + let _ = fs::remove_file(&path); + + let mut unknown_method = build_rsli_bytes( + &[SyntheticRsliEntry { + name: "UNK".to_string(), + method_raw: 0x120, + plain: b"x".to_vec(), + declared_packed_size: None, + }], + &RsliBuildOptions::default(), + ); + // Force truly unknown method by writing 0x1C0 mask bits. + let row = 32; + unknown_method[row + 16..row + 18].copy_from_slice(&(0x1C0_u16).to_le_bytes()); + // Re-encrypt table with the same seed. + let seed = u32::from_le_bytes([ + unknown_method[20], + unknown_method[21], + unknown_method[22], + unknown_method[23], + ]); + let mut plain_row = vec![0u8; 32]; + plain_row.copy_from_slice(&unknown_method[32..64]); + plain_row = xor_stream(&plain_row, (seed & 0xFFFF) as u16); + plain_row[16..18].copy_from_slice(&(0x1C0_u16).to_le_bytes()); + let encrypted_row = xor_stream(&plain_row, (seed & 0xFFFF) as u16); + unknown_method[32..64].copy_from_slice(&encrypted_row); + + let path = write_temp_file("rsli-unknown-method", &unknown_method); + let lib = Library::open_path(&path).expect("open archive with unknown method failed"); + match lib.load(EntryId(0)) { + Err(Error::UnsupportedMethod { raw }) => assert_eq!(raw, 0x1C0), + other => panic!("expected UnsupportedMethod, got {other:?}"), + } + let _ = fs::remove_file(&path); + + let mut bad_packed = valid.clone(); + bad_packed[32 + 28..32 + 32].copy_from_slice(&0xFFFF_FFF0_u32.to_le_bytes()); + let path = write_temp_file("rsli-bad-packed", &bad_packed); + match Library::open_path(&path) { + Err(Error::PackedSizePastEof { .. }) => {} + other => panic!("expected PackedSizePastEof, got {other:?}"), + } + let _ = fs::remove_file(&path); + + let mut with_bad_overlay = valid; + with_bad_overlay.extend_from_slice(b"AO"); + with_bad_overlay.extend_from_slice(&0xFFFF_FFFF_u32.to_le_bytes()); + let path = write_temp_file("rsli-bad-overlay", &with_bad_overlay); + match Library::open_path_with( + &path, + OpenOptions { + allow_ao_trailer: true, + allow_deflate_eof_plus_one: true, + }, + ) { + Err(Error::MediaOverlayOutOfBounds { .. }) => {} + other => panic!("expected MediaOverlayOutOfBounds, got {other:?}"), + } + let _ = fs::remove_file(&path); +} |
