From d0bdbaa1ed76dfbf3211bb43eee48c49cc4fd448 Mon Sep 17 00:00:00 2001 From: Valentin Popov Date: Mon, 22 Jun 2026 13:12:27 +0400 Subject: 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. --- crates/nres/src/error.rs | 110 ------ crates/nres/src/lib.rs | 772 ------------------------------------- crates/nres/src/tests.rs | 983 ----------------------------------------------- 3 files changed, 1865 deletions(-) delete mode 100644 crates/nres/src/error.rs delete mode 100644 crates/nres/src/lib.rs delete mode 100644 crates/nres/src/tests.rs (limited to 'crates/nres/src') 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 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 = core::result::Result; - -#[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, -} - -#[derive(Debug)] -pub struct Archive { - bytes: Arc<[u8]>, - entries: Vec, - 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) -> Result { - Self::open_path_with(path, OpenMode::ReadOnly, OpenOptions::default()) - } - - pub fn open_path_with( - path: impl AsRef, - _mode: OpenMode, - opts: OpenOptions, - ) -> Result { - 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 { - 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> { - 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> { - 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 { - 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> { - 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> { - 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> { - let range = self.entry_range(id)?; - Ok(ResourceData::Borrowed(&self.bytes[range])) - } - - pub fn read_into(&self, id: EntryId, out: &mut dyn OutputBuffer) -> Result { - let range = self.entry_range(id)?; - out.write_exact(&self.bytes[range.clone()])?; - Ok(range.len()) - } - - pub fn raw_slice(&self, id: EntryId) -> Result> { - let range = self.entry_range(id)?; - Ok(Some(&self.bytes[range])) - } - - pub fn edit_path(path: impl AsRef) -> Result { - 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> { - 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, -} - -#[derive(Clone, Debug)] -enum EntryData { - Borrowed(Range), - Modified(Vec), -} - -#[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> { - 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 { - 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 = (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, Option)> { - 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> { - 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 { - 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, 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 = src.as_os_str().encode_wide().chain(iter::once(0)).collect(); - let dst_wide: Vec = 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 { - 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) -> String { - let any = payload.as_ref(); - if let Some(message) = any.downcast_ref::() { - 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 { - 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 = (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 = (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 = (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 = (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); -} -- cgit v1.2.3