fix(unityfs): 加固基础容器解析校验
bat-rust / Build and test Rust (push) Canceled after 0s
bat-rust / Build and test Go API (push) Canceled after 0s

补充 UnityFS 总大小、block/directory 计数、目录路径、重复项和边界校验,覆盖 LZMA 数据块与损坏输入回归。新增隔离真实 UnityPy bundle 测试入口,并用 /tmp 中的 char_118_yuki.ab 完成实际回归。复杂对象解析与发布级重打包继续保留在 G-005 后续范围。

Fixes #3
This commit is contained in:
2026-08-20 12:13:04 +08:00
parent 9d4f8d903c
commit 90083302a2
6 changed files with 181 additions and 21 deletions
+139 -4
View File
@@ -6,6 +6,7 @@ use crate::types::{
ParsedAssetBundle, RawAssetBundle, UnityFsBlockInfo, UnityFsBundle, UnityFsCompression,
UnityFsDirectoryInfo, UnityFsFile, UnityFsHeader, UnitySerializedParseError,
};
use std::collections::HashSet;
use std::io::Cursor;
const UNITYFS_COMPRESSION_MASK: u32 = 0x3f;
@@ -113,9 +114,9 @@ fn parse_unityfs(data: &[u8]) -> Result<UnityFsBundle> {
flags: reader.read_u32("flags")?,
};
if header.total_size > data.len() as u64 {
if header.total_size != data.len() as u64 {
return Err(AssetBundleError::Parse(format!(
"UnityFS total_size {} exceeds file size {}",
"UnityFS total_size {} does not match file size {}",
header.total_size,
data.len()
)));
@@ -446,8 +447,14 @@ fn parse_blocks_info(
"invalid UnityFS block count: {block_count}"
)));
}
let block_count = checked_record_count(
block_count,
data.len().saturating_sub(reader.offset()),
10,
"block_count",
)?;
let mut blocks = Vec::with_capacity(block_count as usize);
let mut blocks = Vec::with_capacity(block_count);
for _ in 0..block_count {
let uncompressed_size = reader.read_u32("block_uncompressed_size")?;
let compressed_size = reader.read_u32("block_compressed_size")?;
@@ -466,8 +473,14 @@ fn parse_blocks_info(
"invalid UnityFS directory count: {directory_count}"
)));
}
let directory_count = checked_record_count(
directory_count,
data.len().saturating_sub(reader.offset()),
21,
"directory_count",
)?;
let mut directories = Vec::with_capacity(directory_count as usize);
let mut directories = Vec::with_capacity(directory_count);
for _ in 0..directory_count {
directories.push(UnityFsDirectoryInfo {
offset: reader.read_u64("directory_offset")?,
@@ -546,7 +559,15 @@ fn validate_directory_bounds(
data_region_size: u64,
directories: &[UnityFsDirectoryInfo],
) -> Result<()> {
let mut paths = HashSet::with_capacity(directories.len());
for (index, directory) in directories.iter().enumerate() {
validate_directory_path(index, &directory.path)?;
if !paths.insert(directory.path.as_str()) {
return Err(AssetBundleError::Parse(format!(
"UnityFS directory {} (index {index}) is duplicated",
directory.path
)));
}
let end = directory
.offset
.checked_add(directory.size)
@@ -567,6 +588,45 @@ fn validate_directory_bounds(
Ok(())
}
fn checked_record_count(
count: i32,
remaining_bytes: usize,
minimum_record_size: usize,
field: &str,
) -> Result<usize> {
let count = usize::try_from(count)
.map_err(|_| AssetBundleError::Parse(format!("invalid UnityFS {field}: {count}")))?;
let required_bytes = count.checked_mul(minimum_record_size).ok_or_else(|| {
AssetBundleError::Parse(format!(
"UnityFS {field} record size overflows usize: count {count}, minimum {minimum_record_size}"
))
})?;
if required_bytes > remaining_bytes {
return Err(AssetBundleError::Parse(format!(
"UnityFS {field} count {count} requires at least {required_bytes} bytes, only {remaining_bytes} remain"
)));
}
Ok(count)
}
fn validate_directory_path(index: usize, path: &str) -> Result<()> {
let is_windows_absolute = path.as_bytes().get(1) == Some(&b':');
if path.is_empty()
|| path.starts_with('/')
|| path.starts_with('\\')
|| is_windows_absolute
|| path
.replace('\\', "/")
.split('/')
.any(|component| component == "..")
{
return Err(AssetBundleError::Parse(format!(
"UnityFS directory path is unsafe at index {index}: {path:?}"
)));
}
Ok(())
}
struct UnityFsReader<'a> {
data: &'a [u8],
offset: usize,
@@ -972,6 +1032,21 @@ mod tests {
assert_eq!(parsed.files[0].data, payload);
}
#[test]
fn extracts_lzma_compressed_data_block() {
let parser = UnityFsParser::new();
let payload = b"localized-lzma-payload";
let mut compressed = Vec::new();
lzma_rs::lzma_compress(&mut Cursor::new(payload), &mut compressed).unwrap();
let data = synthetic_unityfs_bundle_with_payload("CAB-lzma", payload, &compressed, 1);
let parsed = parser.parse_bytes(&data).unwrap();
assert_eq!(parsed.files.len(), 1);
assert_eq!(parsed.files[0].path, "CAB-lzma");
assert_eq!(parsed.files[0].data, payload);
}
#[test]
fn extracts_data_block_after_block_info_alignment_padding() {
let parser = UnityFsParser::new();
@@ -1002,6 +1077,62 @@ mod tests {
assert!(error.contains("uncompressed data region size 4"), "{error}");
}
#[test]
fn rejects_unsafe_directory_path() {
let parser = UnityFsParser::new();
let data = synthetic_unityfs_bundle_with_payload("../outside", b"data", b"data", 0);
let error = parser.parse_bytes(&data).unwrap_err().to_string();
assert!(error.contains("unsafe"), "{error}");
assert!(error.contains("../outside"), "{error}");
}
#[test]
fn rejects_duplicate_directory_path() {
let parser = UnityFsParser::new();
let mut blocks_info = blocks_info(4);
push_i32_at(&mut blocks_info, 16 + 4 + 10, 2);
push_u64(&mut blocks_info, 0);
push_u64(&mut blocks_info, 0);
push_u32(&mut blocks_info, 0);
push_c_string(&mut blocks_info, "CAB-test");
let data = synthetic_unityfs_bundle(&blocks_info, 0, false);
let error = parser.parse_bytes(&data).unwrap_err().to_string();
assert!(error.contains("duplicated"), "{error}");
}
#[test]
fn rejects_declared_total_size_mismatch() {
let parser = UnityFsParser::new();
let mut data = synthetic_unityfs_bundle(&blocks_info(4), 0, false);
let total_size_offset = b"UnityFS\0".len() + 4 + b"5.x.x\0".len() + b"2021.3.56f2\0".len();
let declared_size = (data.len() as u64) - 1;
data[total_size_offset..total_size_offset + 8]
.copy_from_slice(&declared_size.to_be_bytes());
let error = parser.parse_bytes(&data).unwrap_err().to_string();
assert!(error.contains("does not match file size"), "{error}");
}
#[test]
fn rejects_block_count_that_cannot_fit_in_block_info() {
let parser = UnityFsParser::new();
let mut data = synthetic_unityfs_bundle(&blocks_info(4), 0, false);
// The fixed test header is aligned to offset 64; block_count follows
// the 16-byte blocks-info hash.
let block_count_offset = 64 + 16;
data[block_count_offset..block_count_offset + 4].copy_from_slice(&i32::MAX.to_be_bytes());
let error = parser.parse_bytes(&data).unwrap_err().to_string();
assert!(error.contains("block_count"), "{error}");
assert!(error.contains("requires at least"), "{error}");
}
#[test]
fn rejects_truncated_header_with_field_context() {
let parser = UnityFsParser::new();
@@ -1023,4 +1154,8 @@ mod tests {
"{error}"
);
}
fn push_i32_at(data: &mut [u8], offset: usize, value: i32) {
data[offset..offset + 4].copy_from_slice(&value.to_be_bytes());
}
}
@@ -0,0 +1,24 @@
use bat_assetbundle::UnityFsParser;
use std::path::PathBuf;
#[test]
#[ignore = "requires BAT_REAL_UNITYFS_BUNDLE pointing at an isolated real UnityFS bundle"]
fn parses_isolated_real_unityfs_bundle() {
let path = PathBuf::from(
std::env::var("BAT_REAL_UNITYFS_BUNDLE").expect("BAT_REAL_UNITYFS_BUNDLE must be set"),
);
let data = std::fs::read(&path).expect("read isolated real UnityFS bundle");
let parsed = UnityFsParser::new()
.parse_bytes(&data)
.expect("parse isolated real UnityFS bundle");
assert_eq!(parsed.header.total_size, data.len() as u64);
assert!(!parsed.header.unity_version.is_empty());
assert!(!parsed.blocks.is_empty());
assert!(!parsed.directories.is_empty());
assert_eq!(parsed.files.len(), parsed.directories.len());
assert_eq!(
parsed.uncompressed_data_size,
parsed.files.iter().map(|file| file.size).sum::<u64>()
);
}