aboutsummaryrefslogtreecommitdiff
path: root/crates/nres
diff options
context:
space:
mode:
authorValentin Popov <valentin@popov.link>2026-06-22 12:12:27 +0300
committerValentin Popov <valentin@popov.link>2026-06-22 12:13:32 +0300
commitd0bdbaa1ed76dfbf3211bb43eee48c49cc4fd448 (patch)
treea0bd35c3940be62a5b5de1acc2366af377ffd181 /crates/nres
parent7416fdc7e9a48837fff5056e6dc8d0774e90964b (diff)
downloadfparkan-d0bdbaa1ed76dfbf3211bb43eee48c49cc4fd448.tar.xz
fparkan-d0bdbaa1ed76dfbf3211bb43eee48c49cc4fd448.zip
feat: implement FParkan architecture foundation
Add the modular fparkan workspace, domain crates, adapters, apps, xtask policy/CI, acceptance evidence, and licensed corpus gates for the macOS-focused roadmap foundation.
Diffstat (limited to 'crates/nres')
-rw-r--r--crates/nres/Cargo.toml10
-rw-r--r--crates/nres/README.md42
-rw-r--r--crates/nres/src/error.rs110
-rw-r--r--crates/nres/src/lib.rs772
-rw-r--r--crates/nres/src/tests.rs983
5 files changed, 0 insertions, 1917 deletions
diff --git a/crates/nres/Cargo.toml b/crates/nres/Cargo.toml
deleted file mode 100644
index 38b8822..0000000
--- a/crates/nres/Cargo.toml
+++ /dev/null
@@ -1,10 +0,0 @@
-[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
deleted file mode 100644
index 8b9dfb5..0000000
--- a/crates/nres/README.md
+++ /dev/null
@@ -1,42 +0,0 @@
-# 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
deleted file mode 100644
index 9a3c651..0000000
--- a/crates/nres/src/error.rs
+++ /dev/null
@@ -1,110 +0,0 @@
-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
deleted file mode 100644
index 571b395..0000000
--- a/crates/nres/src/lib.rs
+++ /dev/null
@@ -1,772 +0,0 @@
-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(Clone, Debug)]
-pub struct ArchiveHeader {
- pub magic: [u8; 4],
- pub version: u32,
- pub entry_count: u32,
- pub total_size: u32,
- pub directory_offset: u64,
- pub directory_size: u64,
-}
-
-#[derive(Clone, Debug)]
-pub struct ArchiveInfo {
- pub raw_mode: bool,
- pub file_size: u64,
- pub header: Option<ArchiveHeader>,
-}
-
-#[derive(Debug)]
-pub struct Archive {
- bytes: Arc<[u8]>,
- entries: Vec<EntryRecord>,
- info: ArchiveInfo,
- 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(Copy, Clone, Debug)]
-pub struct EntryInspect<'a> {
- pub id: EntryId,
- pub meta: &'a EntryMeta,
- pub name_raw: &'a [u8; 36],
-}
-
-#[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 file_size = u64::try_from(bytes.len()).map_err(|_| Error::IntegerOverflow)?;
- let (entries, header) = parse_archive(&bytes, opts.raw_mode)?;
- if opts.prefetch_pages {
- prefetch_pages(&bytes);
- }
- Ok(Self {
- bytes,
- entries,
- info: ArchiveInfo {
- raw_mode: opts.raw_mode,
- file_size,
- header,
- },
- raw_mode: opts.raw_mode,
- })
- }
-
- pub fn info(&self) -> &ArchiveInfo {
- &self.info
- }
-
- pub fn entry_count(&self) -> usize {
- self.entries.len()
- }
-
- pub fn entries(&self) -> impl Iterator<Item = EntryRef<'_>> {
- self.entries.iter().enumerate().filter_map(|(idx, entry)| {
- let id = u32::try_from(idx).ok()?;
- Some(EntryRef {
- id: EntryId(id),
- meta: &entry.meta,
- })
- })
- }
-
- pub fn entries_inspect(&self) -> impl Iterator<Item = EntryInspect<'_>> {
- self.entries.iter().enumerate().filter_map(|(idx, entry)| {
- let id = u32::try_from(idx).ok()?;
- Some(EntryInspect {
- id: EntryId(id),
- meta: &entry.meta,
- name_raw: &entry.name_raw,
- })
- })
- }
-
- 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 => {
- let id = u32::try_from(target_idx).ok()?;
- return Some(EntryId(id));
- }
- }
- }
- }
-
- self.entries.iter().enumerate().find_map(|(idx, entry)| {
- if cmp_name_case_insensitive(name.as_bytes(), entry_name_bytes(&entry.name_raw))
- == Ordering::Equal
- {
- let id = u32::try_from(idx).ok()?;
- Some(EntryId(id))
- } 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 inspect(&self, id: EntryId) -> Option<EntryInspect<'_>> {
- let idx = usize::try_from(id.0).ok()?;
- let entry = self.entries.get(idx)?;
- Some(EntryInspect {
- id,
- meta: &entry.meta,
- name_raw: &entry.name_raw,
- })
- }
-
- 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: saturating_u32_len(self.entries.len()),
- });
- };
- 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().filter_map(|(idx, entry)| {
- let id = u32::try_from(idx).ok()?;
- Some(EntryRef {
- id: EntryId(id),
- 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: saturating_u32_len(self.entries.len()),
- });
- };
- 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: saturating_u32_len(self.entries.len()),
- });
- }
- 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() {
- // sort_index stores the original-entry index at sorted position `idx`.
- // This mirrors the format emitted by the retail assets and test fixtures.
- 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>, Option<ArchiveHeader>)> {
- 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], None));
- }
-
- 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,
- Some(ArchiveHeader {
- magic: *b"NRes",
- version,
- entry_count: u32::try_from(entry_count).map_err(|_| Error::IntegerOverflow)?,
- total_size,
- directory_offset,
- directory_size: directory_len,
- }),
- ))
-}
-
-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 saturating_u32_len(len: usize) -> u32 {
- u32::try_from(len).unwrap_or(u32::MAX)
-}
-
-fn prefetch_pages(bytes: &[u8]) {
- use std::hint::black_box;
-
- let mut cursor = 0usize;
- let mut sink = 0u8;
- while cursor < bytes.len() {
- sink ^= bytes[cursor];
- cursor = cursor.saturating_add(4096);
- }
- black_box(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();
-
- // SAFETY: pointers reference NUL-terminated UTF-16 buffers that stay alive
- // for the duration of the call; flags and argument contract match WinAPI.
- 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
deleted file mode 100644
index bfa75a8..0000000
--- a/crates/nres/src/tests.rs
+++ /dev/null
@@ -1,983 +0,0 @@
-use super::*;
-use common::collect_files_recursive;
-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 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);
-}