diff options
Diffstat (limited to 'crates/nres')
| -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 |
5 files changed, 1860 insertions, 0 deletions
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); +} |
