Refactor tests and move them to a dedicated module
- Moved the test suite from `lib.rs` to a new `tests.rs` file for better organization. - Added a `SyntheticRsliEntry` struct to facilitate synthetic test cases. - Introduced `RsliBuildOptions` struct to manage options for building RsLi byte arrays. - Implemented various utility functions for file handling, data compression, and bit manipulation. - Enhanced the `rsli_read_unpack_and_repack_all_files` test to validate all RsLi archives. - Added new tests for synthetic entries covering all packing methods, overlay handling, and validation error cases.
This commit is contained in:
@@ -20,18 +20,14 @@ pub struct OpenOptions {
|
|||||||
pub prefetch_pages: bool,
|
pub prefetch_pages: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug, Default)]
|
||||||
pub enum OpenMode {
|
pub enum OpenMode {
|
||||||
|
#[default]
|
||||||
ReadOnly,
|
ReadOnly,
|
||||||
ReadWrite,
|
ReadWrite,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for OpenMode {
|
#[derive(Debug)]
|
||||||
fn default() -> Self {
|
|
||||||
Self::ReadOnly
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct Archive {
|
pub struct Archive {
|
||||||
bytes: Arc<[u8]>,
|
bytes: Arc<[u8]>,
|
||||||
entries: Vec<EntryRecord>,
|
entries: Vec<EntryRecord>,
|
||||||
@@ -286,8 +282,7 @@ impl Editor {
|
|||||||
|
|
||||||
pub fn commit(mut self) -> Result<()> {
|
pub fn commit(mut self) -> Result<()> {
|
||||||
let count_u32 = u32::try_from(self.entries.len()).map_err(|_| Error::IntegerOverflow)?;
|
let count_u32 = u32::try_from(self.entries.len()).map_err(|_| Error::IntegerOverflow)?;
|
||||||
let mut out = Vec::new();
|
let mut out = vec![0; 16];
|
||||||
out.resize(16, 0);
|
|
||||||
|
|
||||||
for entry in &mut self.entries {
|
for entry in &mut self.entries {
|
||||||
entry.meta.data_offset =
|
entry.meta.data_offset =
|
||||||
@@ -626,238 +621,4 @@ fn unix_time_nanos() -> u128 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests;
|
||||||
use super::*;
|
|
||||||
use std::any::Any;
|
|
||||||
use std::fs;
|
|
||||||
use std::panic::{catch_unwind, AssertUnwindSafe};
|
|
||||||
|
|
||||||
fn collect_files_recursive(root: &Path, out: &mut Vec<PathBuf>) {
|
|
||||||
let Ok(entries) = fs::read_dir(root) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
for entry in entries.flatten() {
|
|
||||||
let path = entry.path();
|
|
||||||
if path.is_dir() {
|
|
||||||
collect_files_recursive(&path, out);
|
|
||||||
} else if path.is_file() {
|
|
||||||
out.push(path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn nres_test_files() -> Vec<PathBuf> {
|
|
||||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
|
||||||
.join("..")
|
|
||||||
.join("..")
|
|
||||||
.join("testdata")
|
|
||||||
.join("nres");
|
|
||||||
let mut files = Vec::new();
|
|
||||||
collect_files_recursive(&root, &mut files);
|
|
||||||
files.sort();
|
|
||||||
files
|
|
||||||
.into_iter()
|
|
||||||
.filter(|path| {
|
|
||||||
fs::read(path)
|
|
||||||
.map(|data| data.get(0..4) == Some(b"NRes"))
|
|
||||||
.unwrap_or(false)
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn make_temp_copy(original: &Path, bytes: &[u8]) -> PathBuf {
|
|
||||||
let mut path = std::env::temp_dir();
|
|
||||||
let file_name = original
|
|
||||||
.file_name()
|
|
||||||
.and_then(|v| v.to_str())
|
|
||||||
.unwrap_or("archive");
|
|
||||||
path.push(format!(
|
|
||||||
"nres-test-{}-{}-{}",
|
|
||||||
std::process::id(),
|
|
||||||
unix_time_nanos(),
|
|
||||||
file_name
|
|
||||||
));
|
|
||||||
fs::write(&path, bytes).expect("failed to create temp file");
|
|
||||||
path
|
|
||||||
}
|
|
||||||
|
|
||||||
fn panic_message(payload: Box<dyn Any + Send>) -> String {
|
|
||||||
let any = payload.as_ref();
|
|
||||||
if let Some(message) = any.downcast_ref::<String>() {
|
|
||||||
return message.clone();
|
|
||||||
}
|
|
||||||
if let Some(message) = any.downcast_ref::<&str>() {
|
|
||||||
return (*message).to_string();
|
|
||||||
}
|
|
||||||
String::from("panic without message")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn nres_read_and_roundtrip_all_files() {
|
|
||||||
let files = nres_test_files();
|
|
||||||
assert!(!files.is_empty(), "testdata/nres contains no NRes archives");
|
|
||||||
|
|
||||||
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 first = files.first().expect("testdata/nres has no archives");
|
|
||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
543
crates/nres/src/tests.rs
Normal file
543
crates/nres/src/tests.rs
Normal file
@@ -0,0 +1,543 @@
|
|||||||
|
use super::*;
|
||||||
|
use std::any::Any;
|
||||||
|
use std::fs;
|
||||||
|
use std::panic::{catch_unwind, AssertUnwindSafe};
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct SyntheticEntry<'a> {
|
||||||
|
kind: u32,
|
||||||
|
attr1: u32,
|
||||||
|
attr2: u32,
|
||||||
|
attr3: u32,
|
||||||
|
name: &'a str,
|
||||||
|
data: &'a [u8],
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_files_recursive(root: &Path, out: &mut Vec<PathBuf>) {
|
||||||
|
let Ok(entries) = fs::read_dir(root) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
if path.is_dir() {
|
||||||
|
collect_files_recursive(&path, out);
|
||||||
|
} else if path.is_file() {
|
||||||
|
out.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn nres_test_files() -> Vec<PathBuf> {
|
||||||
|
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.join("..")
|
||||||
|
.join("..")
|
||||||
|
.join("testdata")
|
||||||
|
.join("nres");
|
||||||
|
let mut files = Vec::new();
|
||||||
|
collect_files_recursive(&root, &mut files);
|
||||||
|
files.sort();
|
||||||
|
files
|
||||||
|
.into_iter()
|
||||||
|
.filter(|path| {
|
||||||
|
fs::read(path)
|
||||||
|
.map(|data| data.get(0..4) == Some(b"NRes"))
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_temp_copy(original: &Path, bytes: &[u8]) -> PathBuf {
|
||||||
|
let mut path = std::env::temp_dir();
|
||||||
|
let file_name = original
|
||||||
|
.file_name()
|
||||||
|
.and_then(|v| v.to_str())
|
||||||
|
.unwrap_or("archive");
|
||||||
|
path.push(format!(
|
||||||
|
"nres-test-{}-{}-{}",
|
||||||
|
std::process::id(),
|
||||||
|
unix_time_nanos(),
|
||||||
|
file_name
|
||||||
|
));
|
||||||
|
fs::write(&path, bytes).expect("failed to create temp file");
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
fn panic_message(payload: Box<dyn Any + Send>) -> String {
|
||||||
|
let any = payload.as_ref();
|
||||||
|
if let Some(message) = any.downcast_ref::<String>() {
|
||||||
|
return message.clone();
|
||||||
|
}
|
||||||
|
if let Some(message) = any.downcast_ref::<&str>() {
|
||||||
|
return (*message).to_string();
|
||||||
|
}
|
||||||
|
String::from("panic without message")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn 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_read_and_roundtrip_all_files() {
|
||||||
|
let files = nres_test_files();
|
||||||
|
assert!(!files.is_empty(), "testdata/nres contains no NRes archives");
|
||||||
|
|
||||||
|
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 first = files.first().expect("testdata/nres has no archives");
|
||||||
|
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_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_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);
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ impl Default for OpenOptions {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
pub struct Library {
|
pub struct Library {
|
||||||
bytes: Arc<[u8]>,
|
bytes: Arc<[u8]>,
|
||||||
entries: Vec<EntryRecord>,
|
entries: Vec<EntryRecord>,
|
||||||
@@ -979,187 +980,4 @@ fn needs_xor_key(method: PackMethod) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests;
|
||||||
use super::*;
|
|
||||||
use std::any::Any;
|
|
||||||
use std::panic::{catch_unwind, AssertUnwindSafe};
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
fn collect_files_recursive(root: &Path, out: &mut Vec<PathBuf>) {
|
|
||||||
let Ok(entries) = fs::read_dir(root) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
for entry in entries.flatten() {
|
|
||||||
let path = entry.path();
|
|
||||||
if path.is_dir() {
|
|
||||||
collect_files_recursive(&path, out);
|
|
||||||
} else if path.is_file() {
|
|
||||||
out.push(path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn rsli_test_files() -> Vec<PathBuf> {
|
|
||||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
|
||||||
.join("..")
|
|
||||||
.join("..")
|
|
||||||
.join("testdata")
|
|
||||||
.join("rsli");
|
|
||||||
let mut files = Vec::new();
|
|
||||||
collect_files_recursive(&root, &mut files);
|
|
||||||
files.sort();
|
|
||||||
files
|
|
||||||
.into_iter()
|
|
||||||
.filter(|path| {
|
|
||||||
fs::read(path)
|
|
||||||
.map(|data| data.get(0..4) == Some(b"NL\0\x01"))
|
|
||||||
.unwrap_or(false)
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn panic_message(payload: Box<dyn Any + Send>) -> String {
|
|
||||||
let any = payload.as_ref();
|
|
||||||
if let Some(message) = any.downcast_ref::<String>() {
|
|
||||||
return message.clone();
|
|
||||||
}
|
|
||||||
if let Some(message) = any.downcast_ref::<&str>() {
|
|
||||||
return (*message).to_string();
|
|
||||||
}
|
|
||||||
String::from("panic without message")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rsli_read_unpack_and_repack_all_files() {
|
|
||||||
let files = rsli_test_files();
|
|
||||||
assert!(!files.is_empty(), "testdata/rsli contains no RsLi archives");
|
|
||||||
|
|
||||||
let checked = files.len();
|
|
||||||
let mut success = 0usize;
|
|
||||||
let mut failures = Vec::new();
|
|
||||||
|
|
||||||
for path in files {
|
|
||||||
let display_path = path.display().to_string();
|
|
||||||
let result = catch_unwind(AssertUnwindSafe(|| {
|
|
||||||
let original = fs::read(&path).expect("failed to read archive");
|
|
||||||
let library = Library::open_path(&path)
|
|
||||||
.unwrap_or_else(|err| panic!("failed to open {}: {err}", path.display()));
|
|
||||||
|
|
||||||
let count = library.entry_count();
|
|
||||||
assert_eq!(
|
|
||||||
count,
|
|
||||||
library.entries().count(),
|
|
||||||
"entry count mismatch: {}",
|
|
||||||
path.display()
|
|
||||||
);
|
|
||||||
|
|
||||||
for idx in 0..count {
|
|
||||||
let id = EntryId(idx as u32);
|
|
||||||
let meta_ref = library
|
|
||||||
.get(id)
|
|
||||||
.unwrap_or_else(|| panic!("missing entry #{idx} in {}", path.display()));
|
|
||||||
|
|
||||||
let loaded = library.load(id).unwrap_or_else(|err| {
|
|
||||||
panic!("load failed for {} entry #{idx}: {err}", path.display())
|
|
||||||
});
|
|
||||||
|
|
||||||
let packed = library.load_packed(id).unwrap_or_else(|err| {
|
|
||||||
panic!(
|
|
||||||
"load_packed failed for {} entry #{idx}: {err}",
|
|
||||||
path.display()
|
|
||||||
)
|
|
||||||
});
|
|
||||||
let unpacked = library.unpack(&packed).unwrap_or_else(|err| {
|
|
||||||
panic!("unpack failed for {} entry #{idx}: {err}", path.display())
|
|
||||||
});
|
|
||||||
assert_eq!(
|
|
||||||
loaded,
|
|
||||||
unpacked,
|
|
||||||
"load != unpack in {} entry #{idx}",
|
|
||||||
path.display()
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut out = Vec::new();
|
|
||||||
let written = library.load_into(id, &mut out).unwrap_or_else(|err| {
|
|
||||||
panic!(
|
|
||||||
"load_into failed for {} entry #{idx}: {err}",
|
|
||||||
path.display()
|
|
||||||
)
|
|
||||||
});
|
|
||||||
assert_eq!(
|
|
||||||
written,
|
|
||||||
loaded.len(),
|
|
||||||
"load_into size mismatch in {} entry #{idx}",
|
|
||||||
path.display()
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
out,
|
|
||||||
loaded,
|
|
||||||
"load_into payload mismatch in {} entry #{idx}",
|
|
||||||
path.display()
|
|
||||||
);
|
|
||||||
|
|
||||||
let fast = library.load_fast(id).unwrap_or_else(|err| {
|
|
||||||
panic!(
|
|
||||||
"load_fast failed for {} entry #{idx}: {err}",
|
|
||||||
path.display()
|
|
||||||
)
|
|
||||||
});
|
|
||||||
assert_eq!(
|
|
||||||
fast.as_slice(),
|
|
||||||
loaded.as_slice(),
|
|
||||||
"load_fast mismatch in {} entry #{idx}",
|
|
||||||
path.display()
|
|
||||||
);
|
|
||||||
|
|
||||||
let found = library.find(&meta_ref.meta.name).unwrap_or_else(|| {
|
|
||||||
panic!(
|
|
||||||
"find failed for '{}' in {}",
|
|
||||||
meta_ref.meta.name,
|
|
||||||
path.display()
|
|
||||||
)
|
|
||||||
});
|
|
||||||
let found_meta = library.get(found).expect("find returned invalid entry id");
|
|
||||||
assert_eq!(
|
|
||||||
found_meta.meta.name,
|
|
||||||
meta_ref.meta.name,
|
|
||||||
"find returned a different entry in {}",
|
|
||||||
path.display()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let rebuilt = library
|
|
||||||
.rebuild_from_parsed_metadata()
|
|
||||||
.unwrap_or_else(|err| panic!("rebuild failed for {}: {err}", path.display()));
|
|
||||||
assert_eq!(
|
|
||||||
rebuilt,
|
|
||||||
original,
|
|
||||||
"byte-to-byte roundtrip mismatch for {}",
|
|
||||||
path.display()
|
|
||||||
);
|
|
||||||
}));
|
|
||||||
|
|
||||||
match result {
|
|
||||||
Ok(()) => success += 1,
|
|
||||||
Err(payload) => {
|
|
||||||
failures.push(format!("{}: {}", display_path, panic_message(payload)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let failed = failures.len();
|
|
||||||
eprintln!(
|
|
||||||
"RsLi summary: checked={}, success={}, failed={}",
|
|
||||||
checked, success, failed
|
|
||||||
);
|
|
||||||
if !failures.is_empty() {
|
|
||||||
panic!(
|
|
||||||
"RsLi validation failed.\nsummary: checked={}, success={}, failed={}\n{}",
|
|
||||||
checked,
|
|
||||||
success,
|
|
||||||
failed,
|
|
||||||
failures.join("\n")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
847
crates/rsli/src/tests.rs
Normal file
847
crates/rsli/src/tests.rs
Normal file
@@ -0,0 +1,847 @@
|
|||||||
|
use super::*;
|
||||||
|
use flate2::write::DeflateEncoder;
|
||||||
|
use flate2::Compression;
|
||||||
|
use std::any::Any;
|
||||||
|
use std::fs;
|
||||||
|
use std::io::Write as _;
|
||||||
|
use std::panic::{catch_unwind, AssertUnwindSafe};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct SyntheticRsliEntry {
|
||||||
|
name: String,
|
||||||
|
method_raw: u16,
|
||||||
|
plain: Vec<u8>,
|
||||||
|
declared_packed_size: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct RsliBuildOptions {
|
||||||
|
seed: u32,
|
||||||
|
presorted: bool,
|
||||||
|
overlay: u32,
|
||||||
|
add_ao_trailer: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for RsliBuildOptions {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
seed: 0x1234_5678,
|
||||||
|
presorted: true,
|
||||||
|
overlay: 0,
|
||||||
|
add_ao_trailer: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_files_recursive(root: &Path, out: &mut Vec<PathBuf>) {
|
||||||
|
let Ok(entries) = fs::read_dir(root) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
if path.is_dir() {
|
||||||
|
collect_files_recursive(&path, out);
|
||||||
|
} else if path.is_file() {
|
||||||
|
out.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rsli_test_files() -> Vec<PathBuf> {
|
||||||
|
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.join("..")
|
||||||
|
.join("..")
|
||||||
|
.join("testdata")
|
||||||
|
.join("rsli");
|
||||||
|
let mut files = Vec::new();
|
||||||
|
collect_files_recursive(&root, &mut files);
|
||||||
|
files.sort();
|
||||||
|
files
|
||||||
|
.into_iter()
|
||||||
|
.filter(|path| {
|
||||||
|
fs::read(path)
|
||||||
|
.map(|data| data.get(0..4) == Some(b"NL\0\x01"))
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn panic_message(payload: Box<dyn Any + Send>) -> String {
|
||||||
|
let any = payload.as_ref();
|
||||||
|
if let Some(message) = any.downcast_ref::<String>() {
|
||||||
|
return message.clone();
|
||||||
|
}
|
||||||
|
if let Some(message) = any.downcast_ref::<&str>() {
|
||||||
|
return (*message).to_string();
|
||||||
|
}
|
||||||
|
String::from("panic without message")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_temp_file(prefix: &str, bytes: &[u8]) -> PathBuf {
|
||||||
|
let mut path = std::env::temp_dir();
|
||||||
|
path.push(format!(
|
||||||
|
"{}-{}-{}.bin",
|
||||||
|
prefix,
|
||||||
|
std::process::id(),
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_nanos())
|
||||||
|
.unwrap_or(0)
|
||||||
|
));
|
||||||
|
fs::write(&path, bytes).expect("failed to write temp archive");
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
fn deflate_raw(data: &[u8]) -> Vec<u8> {
|
||||||
|
let mut encoder = DeflateEncoder::new(Vec::new(), Compression::default());
|
||||||
|
encoder
|
||||||
|
.write_all(data)
|
||||||
|
.expect("deflate encoder write failed");
|
||||||
|
encoder.finish().expect("deflate encoder finish failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lzss_pack_literals(data: &[u8]) -> Vec<u8> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for chunk in data.chunks(8) {
|
||||||
|
let mask = if chunk.len() == 8 {
|
||||||
|
0xFF
|
||||||
|
} else {
|
||||||
|
(1u16
|
||||||
|
.checked_shl(u32::try_from(chunk.len()).expect("chunk len overflow"))
|
||||||
|
.expect("shift overflow")
|
||||||
|
- 1) as u8
|
||||||
|
};
|
||||||
|
out.push(mask);
|
||||||
|
out.extend_from_slice(chunk);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BitWriter {
|
||||||
|
bytes: Vec<u8>,
|
||||||
|
current: u8,
|
||||||
|
mask: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BitWriter {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
bytes: Vec::new(),
|
||||||
|
current: 0,
|
||||||
|
mask: 0x80,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_bit(&mut self, bit: u8) {
|
||||||
|
if bit != 0 {
|
||||||
|
self.current |= self.mask;
|
||||||
|
}
|
||||||
|
self.mask >>= 1;
|
||||||
|
if self.mask == 0 {
|
||||||
|
self.bytes.push(self.current);
|
||||||
|
self.current = 0;
|
||||||
|
self.mask = 0x80;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish(mut self) -> Vec<u8> {
|
||||||
|
if self.mask != 0x80 {
|
||||||
|
self.bytes.push(self.current);
|
||||||
|
}
|
||||||
|
self.bytes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LzhLiteralModel {
|
||||||
|
freq: [u16; LZH_T + 1],
|
||||||
|
parent: [usize; LZH_T + LZH_N_CHAR],
|
||||||
|
son: [usize; LZH_T + 1],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LzhLiteralModel {
|
||||||
|
fn new() -> Self {
|
||||||
|
let mut model = Self {
|
||||||
|
freq: [0; LZH_T + 1],
|
||||||
|
parent: [0; LZH_T + LZH_N_CHAR],
|
||||||
|
son: [0; LZH_T + 1],
|
||||||
|
};
|
||||||
|
model.start_huff();
|
||||||
|
model
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_literal(&mut self, literal: u8, writer: &mut BitWriter) {
|
||||||
|
let target = usize::from(literal) + LZH_T;
|
||||||
|
let mut path = Vec::new();
|
||||||
|
let mut visited = [false; LZH_T + 1];
|
||||||
|
let found = self.find_path(self.son[LZH_R], target, &mut path, &mut visited);
|
||||||
|
assert!(found, "failed to encode literal {literal}");
|
||||||
|
for bit in path {
|
||||||
|
writer.write_bit(bit);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.update(usize::from(literal));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_path(
|
||||||
|
&self,
|
||||||
|
node: usize,
|
||||||
|
target: usize,
|
||||||
|
path: &mut Vec<u8>,
|
||||||
|
visited: &mut [bool; LZH_T + 1],
|
||||||
|
) -> bool {
|
||||||
|
if node == target {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if node >= LZH_T {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if visited[node] {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
visited[node] = true;
|
||||||
|
|
||||||
|
for bit in [0u8, 1u8] {
|
||||||
|
let child = self.son[node + usize::from(bit)];
|
||||||
|
path.push(bit);
|
||||||
|
if self.find_path(child, target, path, visited) {
|
||||||
|
visited[node] = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
path.pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
visited[node] = false;
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_huff(&mut self) {
|
||||||
|
for i in 0..LZH_N_CHAR {
|
||||||
|
self.freq[i] = 1;
|
||||||
|
self.son[i] = i + LZH_T;
|
||||||
|
self.parent[i + LZH_T] = i;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut i = 0usize;
|
||||||
|
let mut j = LZH_N_CHAR;
|
||||||
|
while j <= LZH_R {
|
||||||
|
self.freq[j] = self.freq[i].saturating_add(self.freq[i + 1]);
|
||||||
|
self.son[j] = i;
|
||||||
|
self.parent[i] = j;
|
||||||
|
self.parent[i + 1] = j;
|
||||||
|
i += 2;
|
||||||
|
j += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.freq[LZH_T] = u16::MAX;
|
||||||
|
self.parent[LZH_R] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(&mut self, c: usize) {
|
||||||
|
if self.freq[LZH_R] == LZH_MAX_FREQ {
|
||||||
|
self.reconstruct();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut current = self.parent[c + LZH_T];
|
||||||
|
loop {
|
||||||
|
self.freq[current] = self.freq[current].saturating_add(1);
|
||||||
|
let freq = self.freq[current];
|
||||||
|
|
||||||
|
if current + 1 < self.freq.len() && freq > self.freq[current + 1] {
|
||||||
|
let mut swap_idx = current + 1;
|
||||||
|
while swap_idx + 1 < self.freq.len() && freq > self.freq[swap_idx + 1] {
|
||||||
|
swap_idx += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.freq.swap(current, swap_idx);
|
||||||
|
|
||||||
|
let left = self.son[current];
|
||||||
|
let right = self.son[swap_idx];
|
||||||
|
self.son[current] = right;
|
||||||
|
self.son[swap_idx] = left;
|
||||||
|
|
||||||
|
self.parent[left] = swap_idx;
|
||||||
|
if left < LZH_T {
|
||||||
|
self.parent[left + 1] = swap_idx;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.parent[right] = current;
|
||||||
|
if right < LZH_T {
|
||||||
|
self.parent[right + 1] = current;
|
||||||
|
}
|
||||||
|
|
||||||
|
current = swap_idx;
|
||||||
|
}
|
||||||
|
|
||||||
|
current = self.parent[current];
|
||||||
|
if current == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reconstruct(&mut self) {
|
||||||
|
let mut j = 0usize;
|
||||||
|
for i in 0..LZH_T {
|
||||||
|
if self.son[i] >= LZH_T {
|
||||||
|
self.freq[j] = self.freq[i].div_ceil(2);
|
||||||
|
self.son[j] = self.son[i];
|
||||||
|
j += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut i = 0usize;
|
||||||
|
let mut current = LZH_N_CHAR;
|
||||||
|
while current < LZH_T {
|
||||||
|
let sum = self.freq[i].saturating_add(self.freq[i + 1]);
|
||||||
|
self.freq[current] = sum;
|
||||||
|
|
||||||
|
let mut insert_at = current;
|
||||||
|
while insert_at > 0 && sum < self.freq[insert_at - 1] {
|
||||||
|
insert_at -= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
for move_idx in (insert_at..current).rev() {
|
||||||
|
self.freq[move_idx + 1] = self.freq[move_idx];
|
||||||
|
self.son[move_idx + 1] = self.son[move_idx];
|
||||||
|
}
|
||||||
|
|
||||||
|
self.freq[insert_at] = sum;
|
||||||
|
self.son[insert_at] = i;
|
||||||
|
i += 2;
|
||||||
|
current += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
for idx in 0..LZH_T {
|
||||||
|
let node = self.son[idx];
|
||||||
|
self.parent[node] = idx;
|
||||||
|
if node < LZH_T {
|
||||||
|
self.parent[node + 1] = idx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.freq[LZH_T] = u16::MAX;
|
||||||
|
self.parent[LZH_R] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lzh_pack_literals(data: &[u8]) -> Vec<u8> {
|
||||||
|
let mut writer = BitWriter::new();
|
||||||
|
let mut model = LzhLiteralModel::new();
|
||||||
|
for byte in data {
|
||||||
|
model.encode_literal(*byte, &mut writer);
|
||||||
|
}
|
||||||
|
writer.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn packed_for_method(method_raw: u16, plain: &[u8], key16: u16) -> Vec<u8> {
|
||||||
|
match (u32::from(method_raw)) & 0x1E0 {
|
||||||
|
0x000 => plain.to_vec(),
|
||||||
|
0x020 => xor_stream(plain, key16),
|
||||||
|
0x040 => lzss_pack_literals(plain),
|
||||||
|
0x060 => xor_stream(&lzss_pack_literals(plain), key16),
|
||||||
|
0x080 => lzh_pack_literals(plain),
|
||||||
|
0x0A0 => xor_stream(&lzh_pack_literals(plain), key16),
|
||||||
|
0x100 => deflate_raw(plain),
|
||||||
|
_ => plain.to_vec(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_rsli_bytes(entries: &[SyntheticRsliEntry], opts: &RsliBuildOptions) -> Vec<u8> {
|
||||||
|
let count = entries.len();
|
||||||
|
let mut rows_plain = vec![0u8; count * 32];
|
||||||
|
let table_end = 32 + rows_plain.len();
|
||||||
|
|
||||||
|
let mut sort_lookup: Vec<usize> = (0..count).collect();
|
||||||
|
sort_lookup.sort_by(|a, b| entries[*a].name.as_bytes().cmp(entries[*b].name.as_bytes()));
|
||||||
|
|
||||||
|
let mut packed_blobs = Vec::with_capacity(count);
|
||||||
|
for index in 0..count {
|
||||||
|
let key16 = u16::try_from(sort_lookup[index]).expect("sort index overflow");
|
||||||
|
let packed = packed_for_method(entries[index].method_raw, &entries[index].plain, key16);
|
||||||
|
packed_blobs.push(packed);
|
||||||
|
}
|
||||||
|
|
||||||
|
let overlay = usize::try_from(opts.overlay).expect("overlay overflow");
|
||||||
|
let mut cursor = table_end + overlay;
|
||||||
|
let mut output = vec![0u8; cursor];
|
||||||
|
|
||||||
|
let mut data_offsets = Vec::with_capacity(count);
|
||||||
|
for (index, packed) in packed_blobs.iter().enumerate() {
|
||||||
|
let raw_offset = cursor
|
||||||
|
.checked_sub(overlay)
|
||||||
|
.expect("overlay larger than cursor");
|
||||||
|
data_offsets.push(raw_offset);
|
||||||
|
|
||||||
|
let end = cursor.checked_add(packed.len()).expect("cursor overflow");
|
||||||
|
if output.len() < end {
|
||||||
|
output.resize(end, 0);
|
||||||
|
}
|
||||||
|
output[cursor..end].copy_from_slice(packed);
|
||||||
|
cursor = end;
|
||||||
|
|
||||||
|
let base = index * 32;
|
||||||
|
let mut name_raw = [0u8; 12];
|
||||||
|
let uppercase = entries[index].name.to_ascii_uppercase();
|
||||||
|
let name_bytes = uppercase.as_bytes();
|
||||||
|
assert!(name_bytes.len() <= 12, "name too long in synthetic fixture");
|
||||||
|
name_raw[..name_bytes.len()].copy_from_slice(name_bytes);
|
||||||
|
|
||||||
|
rows_plain[base..base + 12].copy_from_slice(&name_raw);
|
||||||
|
|
||||||
|
let sort_field: i16 = if opts.presorted {
|
||||||
|
i16::try_from(sort_lookup[index]).expect("sort field overflow")
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
|
||||||
|
let packed_size = entries[index]
|
||||||
|
.declared_packed_size
|
||||||
|
.unwrap_or_else(|| u32::try_from(packed.len()).expect("packed size overflow"));
|
||||||
|
|
||||||
|
rows_plain[base + 16..base + 18].copy_from_slice(&entries[index].method_raw.to_le_bytes());
|
||||||
|
rows_plain[base + 18..base + 20].copy_from_slice(&sort_field.to_le_bytes());
|
||||||
|
rows_plain[base + 20..base + 24].copy_from_slice(
|
||||||
|
&u32::try_from(entries[index].plain.len())
|
||||||
|
.expect("unpacked size overflow")
|
||||||
|
.to_le_bytes(),
|
||||||
|
);
|
||||||
|
rows_plain[base + 24..base + 28].copy_from_slice(
|
||||||
|
&u32::try_from(data_offsets[index])
|
||||||
|
.expect("data offset overflow")
|
||||||
|
.to_le_bytes(),
|
||||||
|
);
|
||||||
|
rows_plain[base + 28..base + 32].copy_from_slice(&packed_size.to_le_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
if output.len() < table_end {
|
||||||
|
output.resize(table_end, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
output[0..2].copy_from_slice(b"NL");
|
||||||
|
output[2] = 0;
|
||||||
|
output[3] = 1;
|
||||||
|
output[4..6].copy_from_slice(
|
||||||
|
&i16::try_from(count)
|
||||||
|
.expect("entry count overflow")
|
||||||
|
.to_le_bytes(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let presorted_flag = if opts.presorted { 0xABBA_u16 } else { 0_u16 };
|
||||||
|
output[14..16].copy_from_slice(&presorted_flag.to_le_bytes());
|
||||||
|
output[20..24].copy_from_slice(&opts.seed.to_le_bytes());
|
||||||
|
|
||||||
|
let encrypted_table = xor_stream(&rows_plain, (opts.seed & 0xFFFF) as u16);
|
||||||
|
output[32..table_end].copy_from_slice(&encrypted_table);
|
||||||
|
|
||||||
|
if opts.add_ao_trailer {
|
||||||
|
output.extend_from_slice(b"AO");
|
||||||
|
output.extend_from_slice(&opts.overlay.to_le_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
output
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rsli_read_unpack_and_repack_all_files() {
|
||||||
|
let files = rsli_test_files();
|
||||||
|
assert!(!files.is_empty(), "testdata/rsli contains no RsLi archives");
|
||||||
|
|
||||||
|
let checked = files.len();
|
||||||
|
let mut success = 0usize;
|
||||||
|
let mut failures = Vec::new();
|
||||||
|
|
||||||
|
for path in files {
|
||||||
|
let display_path = path.display().to_string();
|
||||||
|
let result = catch_unwind(AssertUnwindSafe(|| {
|
||||||
|
let original = fs::read(&path).expect("failed to read archive");
|
||||||
|
let library = Library::open_path(&path)
|
||||||
|
.unwrap_or_else(|err| panic!("failed to open {}: {err}", path.display()));
|
||||||
|
|
||||||
|
let count = library.entry_count();
|
||||||
|
assert_eq!(
|
||||||
|
count,
|
||||||
|
library.entries().count(),
|
||||||
|
"entry count mismatch: {}",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
|
||||||
|
for idx in 0..count {
|
||||||
|
let id = EntryId(idx as u32);
|
||||||
|
let meta_ref = library
|
||||||
|
.get(id)
|
||||||
|
.unwrap_or_else(|| panic!("missing entry #{idx} in {}", path.display()));
|
||||||
|
|
||||||
|
let loaded = library.load(id).unwrap_or_else(|err| {
|
||||||
|
panic!("load failed for {} entry #{idx}: {err}", path.display())
|
||||||
|
});
|
||||||
|
|
||||||
|
let packed = library.load_packed(id).unwrap_or_else(|err| {
|
||||||
|
panic!(
|
||||||
|
"load_packed failed for {} entry #{idx}: {err}",
|
||||||
|
path.display()
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let unpacked = library.unpack(&packed).unwrap_or_else(|err| {
|
||||||
|
panic!("unpack failed for {} entry #{idx}: {err}", path.display())
|
||||||
|
});
|
||||||
|
assert_eq!(
|
||||||
|
loaded,
|
||||||
|
unpacked,
|
||||||
|
"load != unpack in {} entry #{idx}",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let written = library.load_into(id, &mut out).unwrap_or_else(|err| {
|
||||||
|
panic!(
|
||||||
|
"load_into failed for {} entry #{idx}: {err}",
|
||||||
|
path.display()
|
||||||
|
)
|
||||||
|
});
|
||||||
|
assert_eq!(
|
||||||
|
written,
|
||||||
|
loaded.len(),
|
||||||
|
"load_into size mismatch in {} entry #{idx}",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
out,
|
||||||
|
loaded,
|
||||||
|
"load_into payload mismatch in {} entry #{idx}",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
|
||||||
|
let fast = library.load_fast(id).unwrap_or_else(|err| {
|
||||||
|
panic!(
|
||||||
|
"load_fast failed for {} entry #{idx}: {err}",
|
||||||
|
path.display()
|
||||||
|
)
|
||||||
|
});
|
||||||
|
assert_eq!(
|
||||||
|
fast.as_slice(),
|
||||||
|
loaded.as_slice(),
|
||||||
|
"load_fast mismatch in {} entry #{idx}",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
|
||||||
|
let found = library.find(&meta_ref.meta.name).unwrap_or_else(|| {
|
||||||
|
panic!(
|
||||||
|
"find failed for '{}' in {}",
|
||||||
|
meta_ref.meta.name,
|
||||||
|
path.display()
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let found_meta = library.get(found).expect("find returned invalid entry id");
|
||||||
|
assert_eq!(
|
||||||
|
found_meta.meta.name,
|
||||||
|
meta_ref.meta.name,
|
||||||
|
"find returned a different entry in {}",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let rebuilt = library
|
||||||
|
.rebuild_from_parsed_metadata()
|
||||||
|
.unwrap_or_else(|err| panic!("rebuild failed for {}: {err}", path.display()));
|
||||||
|
assert_eq!(
|
||||||
|
rebuilt,
|
||||||
|
original,
|
||||||
|
"byte-to-byte roundtrip mismatch for {}",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
}));
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(()) => success += 1,
|
||||||
|
Err(payload) => failures.push(format!("{}: {}", display_path, panic_message(payload))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let failed = failures.len();
|
||||||
|
eprintln!(
|
||||||
|
"RsLi summary: checked={}, success={}, failed={}",
|
||||||
|
checked, success, failed
|
||||||
|
);
|
||||||
|
if !failures.is_empty() {
|
||||||
|
panic!(
|
||||||
|
"RsLi validation failed.\nsummary: checked={}, success={}, failed={}\n{}",
|
||||||
|
checked,
|
||||||
|
success,
|
||||||
|
failed,
|
||||||
|
failures.join("\n")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rsli_synthetic_all_methods_roundtrip() {
|
||||||
|
let entries = vec![
|
||||||
|
SyntheticRsliEntry {
|
||||||
|
name: "M_NONE".to_string(),
|
||||||
|
method_raw: 0x000,
|
||||||
|
plain: b"plain-data".to_vec(),
|
||||||
|
declared_packed_size: None,
|
||||||
|
},
|
||||||
|
SyntheticRsliEntry {
|
||||||
|
name: "M_XOR".to_string(),
|
||||||
|
method_raw: 0x020,
|
||||||
|
plain: b"xor-only".to_vec(),
|
||||||
|
declared_packed_size: None,
|
||||||
|
},
|
||||||
|
SyntheticRsliEntry {
|
||||||
|
name: "M_LZSS".to_string(),
|
||||||
|
method_raw: 0x040,
|
||||||
|
plain: b"lzss literals payload".to_vec(),
|
||||||
|
declared_packed_size: None,
|
||||||
|
},
|
||||||
|
SyntheticRsliEntry {
|
||||||
|
name: "M_XLZS".to_string(),
|
||||||
|
method_raw: 0x060,
|
||||||
|
plain: b"xor lzss payload".to_vec(),
|
||||||
|
declared_packed_size: None,
|
||||||
|
},
|
||||||
|
SyntheticRsliEntry {
|
||||||
|
name: "M_LZHU".to_string(),
|
||||||
|
method_raw: 0x080,
|
||||||
|
plain: b"huffman literals payload".to_vec(),
|
||||||
|
declared_packed_size: None,
|
||||||
|
},
|
||||||
|
SyntheticRsliEntry {
|
||||||
|
name: "M_XLZH".to_string(),
|
||||||
|
method_raw: 0x0A0,
|
||||||
|
plain: b"xor huffman payload".to_vec(),
|
||||||
|
declared_packed_size: None,
|
||||||
|
},
|
||||||
|
SyntheticRsliEntry {
|
||||||
|
name: "M_DEFL".to_string(),
|
||||||
|
method_raw: 0x100,
|
||||||
|
plain: b"deflate payload with repetition repetition repetition".to_vec(),
|
||||||
|
declared_packed_size: None,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let bytes = build_rsli_bytes(
|
||||||
|
&entries,
|
||||||
|
&RsliBuildOptions {
|
||||||
|
seed: 0xA1B2_C3D4,
|
||||||
|
presorted: false,
|
||||||
|
overlay: 0,
|
||||||
|
add_ao_trailer: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let path = write_temp_file("rsli-all-methods", &bytes);
|
||||||
|
|
||||||
|
let library = Library::open_path(&path).expect("open synthetic rsli failed");
|
||||||
|
assert_eq!(library.entry_count(), entries.len());
|
||||||
|
|
||||||
|
for entry in &entries {
|
||||||
|
let id = library
|
||||||
|
.find(&entry.name)
|
||||||
|
.unwrap_or_else(|| panic!("find failed for {}", entry.name));
|
||||||
|
let loaded = library
|
||||||
|
.load(id)
|
||||||
|
.unwrap_or_else(|err| panic!("load failed for {}: {err}", entry.name));
|
||||||
|
assert_eq!(
|
||||||
|
loaded, entry.plain,
|
||||||
|
"decoded payload mismatch for {}",
|
||||||
|
entry.name
|
||||||
|
);
|
||||||
|
|
||||||
|
let packed = library
|
||||||
|
.load_packed(id)
|
||||||
|
.unwrap_or_else(|err| panic!("load_packed failed for {}: {err}", entry.name));
|
||||||
|
let unpacked = library
|
||||||
|
.unpack(&packed)
|
||||||
|
.unwrap_or_else(|err| panic!("unpack failed for {}: {err}", entry.name));
|
||||||
|
assert_eq!(unpacked, entry.plain, "unpack mismatch for {}", entry.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rsli_synthetic_overlay_and_ao_trailer() {
|
||||||
|
let entries = vec![SyntheticRsliEntry {
|
||||||
|
name: "OVERLAY".to_string(),
|
||||||
|
method_raw: 0x040,
|
||||||
|
plain: b"overlay-data".to_vec(),
|
||||||
|
declared_packed_size: None,
|
||||||
|
}];
|
||||||
|
|
||||||
|
let bytes = build_rsli_bytes(
|
||||||
|
&entries,
|
||||||
|
&RsliBuildOptions {
|
||||||
|
seed: 0x4433_2211,
|
||||||
|
presorted: true,
|
||||||
|
overlay: 128,
|
||||||
|
add_ao_trailer: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let path = write_temp_file("rsli-overlay", &bytes);
|
||||||
|
|
||||||
|
let library = Library::open_path_with(
|
||||||
|
&path,
|
||||||
|
OpenOptions {
|
||||||
|
allow_ao_trailer: true,
|
||||||
|
allow_deflate_eof_plus_one: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("open with AO trailer enabled failed");
|
||||||
|
|
||||||
|
let id = library.find("OVERLAY").expect("find overlay entry failed");
|
||||||
|
let payload = library.load(id).expect("load overlay entry failed");
|
||||||
|
assert_eq!(payload, b"overlay-data");
|
||||||
|
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rsli_deflate_eof_plus_one_quirk() {
|
||||||
|
let plain = b"quirk deflate payload".to_vec();
|
||||||
|
let packed = deflate_raw(&plain);
|
||||||
|
let declared = u32::try_from(packed.len() + 1).expect("declared size overflow");
|
||||||
|
|
||||||
|
let entries = vec![SyntheticRsliEntry {
|
||||||
|
name: "QUIRK".to_string(),
|
||||||
|
method_raw: 0x100,
|
||||||
|
plain,
|
||||||
|
declared_packed_size: Some(declared),
|
||||||
|
}];
|
||||||
|
let bytes = build_rsli_bytes(&entries, &RsliBuildOptions::default());
|
||||||
|
let path = write_temp_file("rsli-deflate-quirk", &bytes);
|
||||||
|
|
||||||
|
let lib_ok = Library::open_path_with(
|
||||||
|
&path,
|
||||||
|
OpenOptions {
|
||||||
|
allow_ao_trailer: true,
|
||||||
|
allow_deflate_eof_plus_one: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("open with EOF+1 quirk enabled failed");
|
||||||
|
let loaded = lib_ok
|
||||||
|
.load(lib_ok.find("QUIRK").expect("find quirk entry failed"))
|
||||||
|
.expect("load quirk entry failed");
|
||||||
|
assert_eq!(loaded, b"quirk deflate payload");
|
||||||
|
|
||||||
|
match Library::open_path_with(
|
||||||
|
&path,
|
||||||
|
OpenOptions {
|
||||||
|
allow_ao_trailer: true,
|
||||||
|
allow_deflate_eof_plus_one: false,
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Err(Error::DeflateEofPlusOneQuirkRejected { id }) => assert_eq!(id, 0),
|
||||||
|
other => panic!("expected DeflateEofPlusOneQuirkRejected, got {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rsli_validation_error_cases() {
|
||||||
|
let valid = build_rsli_bytes(
|
||||||
|
&[SyntheticRsliEntry {
|
||||||
|
name: "BASE".to_string(),
|
||||||
|
method_raw: 0x000,
|
||||||
|
plain: b"abc".to_vec(),
|
||||||
|
declared_packed_size: None,
|
||||||
|
}],
|
||||||
|
&RsliBuildOptions::default(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut bad_magic = valid.clone();
|
||||||
|
bad_magic[0..2].copy_from_slice(b"XX");
|
||||||
|
let path = write_temp_file("rsli-bad-magic", &bad_magic);
|
||||||
|
match Library::open_path(&path) {
|
||||||
|
Err(Error::InvalidMagic { .. }) => {}
|
||||||
|
other => panic!("expected InvalidMagic, got {other:?}"),
|
||||||
|
}
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
|
||||||
|
let mut bad_version = valid.clone();
|
||||||
|
bad_version[3] = 2;
|
||||||
|
let path = write_temp_file("rsli-bad-version", &bad_version);
|
||||||
|
match Library::open_path(&path) {
|
||||||
|
Err(Error::UnsupportedVersion { got }) => assert_eq!(got, 2),
|
||||||
|
other => panic!("expected UnsupportedVersion, got {other:?}"),
|
||||||
|
}
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
|
||||||
|
let mut bad_count = valid.clone();
|
||||||
|
bad_count[4..6].copy_from_slice(&(-1_i16).to_le_bytes());
|
||||||
|
let path = write_temp_file("rsli-bad-count", &bad_count);
|
||||||
|
match Library::open_path(&path) {
|
||||||
|
Err(Error::InvalidEntryCount { got }) => assert_eq!(got, -1),
|
||||||
|
other => panic!("expected InvalidEntryCount, got {other:?}"),
|
||||||
|
}
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
|
||||||
|
let mut bad_table = valid.clone();
|
||||||
|
bad_table[4..6].copy_from_slice(&100_i16.to_le_bytes());
|
||||||
|
let path = write_temp_file("rsli-bad-table", &bad_table);
|
||||||
|
match Library::open_path(&path) {
|
||||||
|
Err(Error::EntryTableOutOfBounds { .. }) => {}
|
||||||
|
other => panic!("expected EntryTableOutOfBounds, got {other:?}"),
|
||||||
|
}
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
|
||||||
|
let mut unknown_method = build_rsli_bytes(
|
||||||
|
&[SyntheticRsliEntry {
|
||||||
|
name: "UNK".to_string(),
|
||||||
|
method_raw: 0x120,
|
||||||
|
plain: b"x".to_vec(),
|
||||||
|
declared_packed_size: None,
|
||||||
|
}],
|
||||||
|
&RsliBuildOptions::default(),
|
||||||
|
);
|
||||||
|
// Force truly unknown method by writing 0x1C0 mask bits.
|
||||||
|
let row = 32;
|
||||||
|
unknown_method[row + 16..row + 18].copy_from_slice(&(0x1C0_u16).to_le_bytes());
|
||||||
|
// Re-encrypt table with the same seed.
|
||||||
|
let seed = u32::from_le_bytes([
|
||||||
|
unknown_method[20],
|
||||||
|
unknown_method[21],
|
||||||
|
unknown_method[22],
|
||||||
|
unknown_method[23],
|
||||||
|
]);
|
||||||
|
let mut plain_row = vec![0u8; 32];
|
||||||
|
plain_row.copy_from_slice(&unknown_method[32..64]);
|
||||||
|
plain_row = xor_stream(&plain_row, (seed & 0xFFFF) as u16);
|
||||||
|
plain_row[16..18].copy_from_slice(&(0x1C0_u16).to_le_bytes());
|
||||||
|
let encrypted_row = xor_stream(&plain_row, (seed & 0xFFFF) as u16);
|
||||||
|
unknown_method[32..64].copy_from_slice(&encrypted_row);
|
||||||
|
|
||||||
|
let path = write_temp_file("rsli-unknown-method", &unknown_method);
|
||||||
|
let lib = Library::open_path(&path).expect("open archive with unknown method failed");
|
||||||
|
match lib.load(EntryId(0)) {
|
||||||
|
Err(Error::UnsupportedMethod { raw }) => assert_eq!(raw, 0x1C0),
|
||||||
|
other => panic!("expected UnsupportedMethod, got {other:?}"),
|
||||||
|
}
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
|
||||||
|
let mut bad_packed = valid.clone();
|
||||||
|
bad_packed[32 + 28..32 + 32].copy_from_slice(&0xFFFF_FFF0_u32.to_le_bytes());
|
||||||
|
let path = write_temp_file("rsli-bad-packed", &bad_packed);
|
||||||
|
match Library::open_path(&path) {
|
||||||
|
Err(Error::PackedSizePastEof { .. }) => {}
|
||||||
|
other => panic!("expected PackedSizePastEof, got {other:?}"),
|
||||||
|
}
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
|
||||||
|
let mut with_bad_overlay = valid;
|
||||||
|
with_bad_overlay.extend_from_slice(b"AO");
|
||||||
|
with_bad_overlay.extend_from_slice(&0xFFFF_FFFF_u32.to_le_bytes());
|
||||||
|
let path = write_temp_file("rsli-bad-overlay", &with_bad_overlay);
|
||||||
|
match Library::open_path_with(
|
||||||
|
&path,
|
||||||
|
OpenOptions {
|
||||||
|
allow_ao_trailer: true,
|
||||||
|
allow_deflate_eof_plus_one: true,
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Err(Error::MediaOverlayOutOfBounds { .. }) => {}
|
||||||
|
other => panic!("expected MediaOverlayOutOfBounds, got {other:?}"),
|
||||||
|
}
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user