mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-18 06:34:54 +08:00
fix(unityfs): 加固基础容器解析校验
补充 UnityFS 总大小、block/directory 计数、目录路径、重复项和边界校验,覆盖 LZMA 数据块与损坏输入回归。新增隔离真实 UnityPy bundle 测试入口,并用 /tmp 中的 char_118_yuki.ab 完成实际回归。复杂对象解析与发布级重打包继续保留在 G-005 后续范围。 Fixes #3
This commit is contained in:
@@ -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>()
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user