mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-18 08:54:55 +08:00
1027 lines
34 KiB
Rust
1027 lines
34 KiB
Rust
//! Unity AssetBundle parser implementations.
|
|
|
|
use crate::error::{AssetBundleError, Result};
|
|
use crate::serialized::UnitySerializedFile;
|
|
use crate::types::{
|
|
ParsedAssetBundle, RawAssetBundle, UnityFsBlockInfo, UnityFsBundle, UnityFsCompression,
|
|
UnityFsDirectoryInfo, UnityFsFile, UnityFsHeader, UnitySerializedParseError,
|
|
};
|
|
use std::io::Cursor;
|
|
|
|
const UNITYFS_COMPRESSION_MASK: u32 = 0x3f;
|
|
const UNITYFS_COMPRESSION_MASK_U16: u16 = 0x3f;
|
|
const UNITYFS_BLOCK_INFO_AT_END_FLAG: u32 = 0x80;
|
|
const UNITYFS_BLOCK_DATA_ALIGNED_FLAG: u32 = 0x200;
|
|
const UNITYFS_ALIGNMENT: usize = 16;
|
|
|
|
/// AssetBundle parser interface.
|
|
pub trait Parser {
|
|
/// Parser name.
|
|
fn name(&self) -> &str;
|
|
|
|
/// Returns true when this parser can handle the bytes.
|
|
fn can_parse(&self, data: &[u8]) -> bool;
|
|
|
|
/// Parses raw bytes into a structured bundle summary.
|
|
fn parse(&self, data: &[u8]) -> Result<ParsedAssetBundle>;
|
|
}
|
|
|
|
/// UnityFS container parser.
|
|
#[derive(Debug, Default, Clone, Copy)]
|
|
pub struct UnityFsParser;
|
|
|
|
impl UnityFsParser {
|
|
/// Creates a parser instance.
|
|
pub fn new() -> Self {
|
|
Self
|
|
}
|
|
|
|
/// Returns true if the byte stream starts with the UnityFS signature.
|
|
pub fn has_unityfs_signature(data: &[u8]) -> bool {
|
|
data.starts_with(b"UnityFS\0")
|
|
}
|
|
|
|
/// Detects the Unity version string from a UnityFS header without parsing
|
|
/// the full block table.
|
|
pub fn detect_unity_version(data: &[u8]) -> Option<String> {
|
|
if !Self::has_unityfs_signature(data) {
|
|
return None;
|
|
}
|
|
|
|
let mut reader = UnityFsReader::new(data);
|
|
let signature = reader.read_c_string("signature").ok()?;
|
|
if signature != "UnityFS" {
|
|
return None;
|
|
}
|
|
reader.read_u32("format_version").ok()?;
|
|
reader.read_c_string("target_version").ok()?;
|
|
reader.read_c_string("unity_version").ok()
|
|
}
|
|
|
|
/// Parses a raw bundle wrapper.
|
|
pub fn parse_asset_bundle(&self, bundle: &RawAssetBundle) -> Result<ParsedAssetBundle> {
|
|
self.parse(&bundle.data)
|
|
}
|
|
|
|
/// Parses UnityFS bytes into the engine-level container representation.
|
|
pub fn parse_bytes(&self, data: &[u8]) -> Result<UnityFsBundle> {
|
|
parse_unityfs(data)
|
|
}
|
|
}
|
|
|
|
impl Parser for UnityFsParser {
|
|
fn name(&self) -> &str {
|
|
"UnityFS"
|
|
}
|
|
|
|
fn can_parse(&self, data: &[u8]) -> bool {
|
|
Self::has_unityfs_signature(data)
|
|
}
|
|
|
|
fn parse(&self, data: &[u8]) -> Result<ParsedAssetBundle> {
|
|
self.parse_bytes(data).map(Into::into)
|
|
}
|
|
}
|
|
|
|
/// Decodes UnityFS compression bits from raw flags.
|
|
pub fn compression_from_flags(flags: u16) -> UnityFsCompression {
|
|
match flags & UNITYFS_COMPRESSION_MASK_U16 {
|
|
0 => UnityFsCompression::None,
|
|
1 => UnityFsCompression::Lzma,
|
|
2 => UnityFsCompression::Lz4,
|
|
3 | 4 => UnityFsCompression::Lz4Hc,
|
|
value => UnityFsCompression::Unknown(value),
|
|
}
|
|
}
|
|
|
|
fn parse_unityfs(data: &[u8]) -> Result<UnityFsBundle> {
|
|
let mut reader = UnityFsReader::new(data);
|
|
let signature = reader.read_c_string("signature")?;
|
|
if signature != "UnityFS" {
|
|
return Err(AssetBundleError::UnsupportedFormat(format!(
|
|
"unsupported AssetBundle signature: {signature}"
|
|
)));
|
|
}
|
|
|
|
let header = UnityFsHeader {
|
|
format_version: reader.read_u32("format_version")?,
|
|
target_version: reader.read_c_string("target_version")?,
|
|
unity_version: reader.read_c_string("unity_version")?,
|
|
total_size: reader.read_u64("total_size")?,
|
|
compressed_blocks_info_size: reader.read_u32("compressed_blocks_info_size")?,
|
|
uncompressed_blocks_info_size: reader.read_u32("uncompressed_blocks_info_size")?,
|
|
flags: reader.read_u32("flags")?,
|
|
};
|
|
|
|
if header.total_size > data.len() as u64 {
|
|
return Err(AssetBundleError::Parse(format!(
|
|
"UnityFS total_size {} exceeds file size {}",
|
|
header.total_size,
|
|
data.len()
|
|
)));
|
|
}
|
|
|
|
if header.format_version >= 7 {
|
|
reader.align(UNITYFS_ALIGNMENT)?;
|
|
}
|
|
|
|
let blocks_info_inline_offset = reader.offset();
|
|
let blocks_info_bytes = read_blocks_info_bytes(data, &mut reader, &header)?;
|
|
let blocks_info_len = blocks_info_bytes.len();
|
|
let block_info = decompress_blocks_info(
|
|
blocks_info_bytes,
|
|
header.compressed_blocks_info_size,
|
|
header.uncompressed_blocks_info_size,
|
|
header.flags,
|
|
)?;
|
|
let (blocks_info_hash, blocks, directories) = parse_blocks_info(&block_info)?;
|
|
let mut data_start_offset = if blocks_info_at_end(header.flags) {
|
|
blocks_info_inline_offset
|
|
} else {
|
|
reader.offset()
|
|
};
|
|
if block_data_aligned(header.flags) {
|
|
data_start_offset = align_offset(data_start_offset, UNITYFS_ALIGNMENT)?;
|
|
}
|
|
let compressed_data_size = sum_compressed_block_sizes(&blocks)?;
|
|
let uncompressed_data_size = sum_uncompressed_block_sizes(&blocks)?;
|
|
validate_compressed_region(
|
|
data.len(),
|
|
data_start_offset,
|
|
compressed_data_size,
|
|
blocks_info_at_end(header.flags),
|
|
blocks_info_len,
|
|
)?;
|
|
validate_directory_bounds(uncompressed_data_size, &directories)?;
|
|
let uncompressed_data = decompress_data_blocks(data, data_start_offset, &blocks)?;
|
|
if uncompressed_data.len() as u64 != uncompressed_data_size {
|
|
return Err(AssetBundleError::Parse(format!(
|
|
"UnityFS uncompressed data size mismatch: expected {}, got {}",
|
|
uncompressed_data_size,
|
|
uncompressed_data.len()
|
|
)));
|
|
}
|
|
let files = extract_files(&uncompressed_data, &directories)?;
|
|
let (serialized_files, text_assets, serialized_parse_errors) = parse_serialized_files(&files);
|
|
|
|
Ok(UnityFsBundle {
|
|
header,
|
|
blocks_info_hash,
|
|
blocks,
|
|
directories,
|
|
data_start_offset: data_start_offset as u64,
|
|
compressed_data_size,
|
|
uncompressed_data_size,
|
|
raw_data: data.to_vec(),
|
|
files,
|
|
serialized_files,
|
|
text_assets,
|
|
serialized_parse_errors,
|
|
})
|
|
}
|
|
|
|
fn read_blocks_info_bytes<'a>(
|
|
data: &'a [u8],
|
|
reader: &mut UnityFsReader<'a>,
|
|
header: &UnityFsHeader,
|
|
) -> Result<&'a [u8]> {
|
|
let len = header.compressed_blocks_info_size as usize;
|
|
if blocks_info_at_end(header.flags) {
|
|
let start = data.len().checked_sub(len).ok_or_else(|| {
|
|
AssetBundleError::Parse(format!(
|
|
"UnityFS block info at end underflow: compressed size {}, file size {}",
|
|
len,
|
|
data.len()
|
|
))
|
|
})?;
|
|
return Ok(&data[start..]);
|
|
}
|
|
|
|
reader.read_bytes(len, "blocks_info")
|
|
}
|
|
|
|
fn blocks_info_at_end(flags: u32) -> bool {
|
|
flags & UNITYFS_BLOCK_INFO_AT_END_FLAG != 0
|
|
}
|
|
|
|
fn block_data_aligned(flags: u32) -> bool {
|
|
flags & UNITYFS_BLOCK_DATA_ALIGNED_FLAG != 0
|
|
}
|
|
|
|
fn align_offset(offset: usize, alignment: usize) -> Result<usize> {
|
|
if alignment == 0 {
|
|
return Ok(offset);
|
|
}
|
|
let remainder = offset % alignment;
|
|
if remainder == 0 {
|
|
return Ok(offset);
|
|
}
|
|
offset.checked_add(alignment - remainder).ok_or_else(|| {
|
|
AssetBundleError::Parse(format!(
|
|
"UnityFS aligned offset overflow: offset {offset}, alignment {alignment}"
|
|
))
|
|
})
|
|
}
|
|
|
|
fn decompress_blocks_info(
|
|
data: &[u8],
|
|
compressed_size: u32,
|
|
uncompressed_size: u32,
|
|
flags: u32,
|
|
) -> Result<Vec<u8>> {
|
|
if data.len() != compressed_size as usize {
|
|
return Err(AssetBundleError::Parse(format!(
|
|
"UnityFS block info size mismatch: header says {}, read {}",
|
|
compressed_size,
|
|
data.len()
|
|
)));
|
|
}
|
|
|
|
decompress_unityfs_bytes(
|
|
data,
|
|
compressed_size,
|
|
uncompressed_size,
|
|
compression_from_flags((flags & UNITYFS_COMPRESSION_MASK) as u16),
|
|
"UnityFS block info",
|
|
)
|
|
}
|
|
|
|
fn decompress_unityfs_bytes(
|
|
data: &[u8],
|
|
compressed_size: u32,
|
|
uncompressed_size: u32,
|
|
compression: UnityFsCompression,
|
|
context: &str,
|
|
) -> Result<Vec<u8>> {
|
|
match compression {
|
|
UnityFsCompression::None => {
|
|
if compressed_size != uncompressed_size {
|
|
return Err(AssetBundleError::Parse(format!(
|
|
"{context} uncompressed size mismatch: compressed {} != uncompressed {}",
|
|
compressed_size, uncompressed_size
|
|
)));
|
|
}
|
|
Ok(data.to_vec())
|
|
}
|
|
UnityFsCompression::Lz4 | UnityFsCompression::Lz4Hc => {
|
|
lz4::block::decompress(data, Some(uncompressed_size as i32)).map_err(|error| {
|
|
AssetBundleError::Parse(format!("Failed to decompress {context} as LZ4: {error}"))
|
|
})
|
|
}
|
|
UnityFsCompression::Lzma => {
|
|
let mut output = Vec::with_capacity(uncompressed_size as usize);
|
|
lzma_rs::lzma_decompress(&mut Cursor::new(data), &mut output).map_err(|error| {
|
|
AssetBundleError::Parse(format!("Failed to decompress {context} as LZMA: {error}"))
|
|
})?;
|
|
if output.len() != uncompressed_size as usize {
|
|
return Err(AssetBundleError::Parse(format!(
|
|
"{context} LZMA size mismatch: expected {}, got {}",
|
|
uncompressed_size,
|
|
output.len()
|
|
)));
|
|
}
|
|
Ok(output)
|
|
}
|
|
UnityFsCompression::Unknown(value) => Err(AssetBundleError::UnsupportedFormat(format!(
|
|
"unsupported {context} compression flag: {value}"
|
|
))),
|
|
}
|
|
}
|
|
|
|
fn decompress_data_blocks(
|
|
data: &[u8],
|
|
data_start_offset: usize,
|
|
blocks: &[UnityFsBlockInfo],
|
|
) -> Result<Vec<u8>> {
|
|
let payload = data.get(data_start_offset..).ok_or_else(|| {
|
|
AssetBundleError::Parse(format!(
|
|
"UnityFS data_start_offset {} exceeds file size {}",
|
|
data_start_offset,
|
|
data.len()
|
|
))
|
|
})?;
|
|
let mut reader = UnityFsReader::new(payload);
|
|
let uncompressed_capacity = sum_uncompressed_block_sizes(blocks)?;
|
|
let mut output = Vec::with_capacity(usize::try_from(uncompressed_capacity).unwrap_or(0));
|
|
|
|
for (index, block) in blocks.iter().enumerate() {
|
|
let compressed = reader.read_bytes(
|
|
block.compressed_size as usize,
|
|
&format!("data_block_{index}"),
|
|
)?;
|
|
let bytes = decompress_unityfs_bytes(
|
|
compressed,
|
|
block.compressed_size,
|
|
block.uncompressed_size,
|
|
block.compression,
|
|
&format!("UnityFS data block {index}"),
|
|
)?;
|
|
if bytes.len() != block.uncompressed_size as usize {
|
|
return Err(AssetBundleError::Parse(format!(
|
|
"UnityFS data block {index} size mismatch: expected {}, got {}",
|
|
block.uncompressed_size,
|
|
bytes.len()
|
|
)));
|
|
}
|
|
output.extend_from_slice(&bytes);
|
|
}
|
|
|
|
Ok(output)
|
|
}
|
|
|
|
fn extract_files(
|
|
uncompressed_data: &[u8],
|
|
directories: &[UnityFsDirectoryInfo],
|
|
) -> Result<Vec<UnityFsFile>> {
|
|
let mut files = Vec::with_capacity(directories.len());
|
|
for directory in directories {
|
|
let start = usize::try_from(directory.offset).map_err(|_| {
|
|
AssetBundleError::Parse(format!(
|
|
"UnityFS directory {} offset {} does not fit usize",
|
|
directory.path, directory.offset
|
|
))
|
|
})?;
|
|
let size = usize::try_from(directory.size).map_err(|_| {
|
|
AssetBundleError::Parse(format!(
|
|
"UnityFS directory {} size {} does not fit usize",
|
|
directory.path, directory.size
|
|
))
|
|
})?;
|
|
let end = start.checked_add(size).ok_or_else(|| {
|
|
AssetBundleError::Parse(format!(
|
|
"UnityFS directory {} start + size overflows usize",
|
|
directory.path
|
|
))
|
|
})?;
|
|
let data = uncompressed_data.get(start..end).ok_or_else(|| {
|
|
AssetBundleError::Parse(format!(
|
|
"UnityFS directory {} byte range {}..{} exceeds uncompressed data {}",
|
|
directory.path,
|
|
start,
|
|
end,
|
|
uncompressed_data.len()
|
|
))
|
|
})?;
|
|
files.push(UnityFsFile {
|
|
path: directory.path.clone(),
|
|
offset: directory.offset,
|
|
size: directory.size,
|
|
flags: directory.flags,
|
|
data: data.to_vec(),
|
|
});
|
|
}
|
|
Ok(files)
|
|
}
|
|
|
|
fn parse_serialized_files(
|
|
files: &[UnityFsFile],
|
|
) -> (
|
|
Vec<UnitySerializedFile>,
|
|
Vec<crate::serialized::UnitySerializedTextAsset>,
|
|
Vec<UnitySerializedParseError>,
|
|
) {
|
|
let mut serialized_files = Vec::new();
|
|
let mut text_assets = Vec::new();
|
|
let mut serialized_parse_errors = Vec::new();
|
|
|
|
for file in files {
|
|
if !looks_like_serialized_file(&file.data) {
|
|
continue;
|
|
}
|
|
match UnitySerializedFile::from_named_slice(Some(file.path.clone()), &file.data) {
|
|
Ok(serialized_file) => {
|
|
text_assets.extend(serialized_file.text_assets().iter().cloned());
|
|
serialized_files.push(serialized_file);
|
|
}
|
|
Err(error) => serialized_parse_errors.push(UnitySerializedParseError {
|
|
path: file.path.clone(),
|
|
error: error.to_string(),
|
|
}),
|
|
}
|
|
}
|
|
|
|
(serialized_files, text_assets, serialized_parse_errors)
|
|
}
|
|
|
|
fn looks_like_serialized_file(data: &[u8]) -> bool {
|
|
if data.len() < 20 {
|
|
return false;
|
|
}
|
|
|
|
let version = u32::from_be_bytes([data[8], data[9], data[10], data[11]]);
|
|
if !(5..=30).contains(&version) {
|
|
return false;
|
|
}
|
|
|
|
if version >= 22 {
|
|
if data.len() < 48 {
|
|
return false;
|
|
}
|
|
let file_size = u64::from_be_bytes([
|
|
data[24], data[25], data[26], data[27], data[28], data[29], data[30], data[31],
|
|
]);
|
|
let data_offset = u64::from_be_bytes([
|
|
data[32], data[33], data[34], data[35], data[36], data[37], data[38], data[39],
|
|
]);
|
|
file_size <= data.len() as u64 && data_offset <= data.len() as u64
|
|
} else {
|
|
let file_size = u32::from_be_bytes([data[4], data[5], data[6], data[7]]);
|
|
let data_offset = u32::from_be_bytes([data[12], data[13], data[14], data[15]]);
|
|
file_size as usize <= data.len() && data_offset as usize <= data.len()
|
|
}
|
|
}
|
|
|
|
fn parse_blocks_info(
|
|
data: &[u8],
|
|
) -> Result<([u8; 16], Vec<UnityFsBlockInfo>, Vec<UnityFsDirectoryInfo>)> {
|
|
let mut reader = UnityFsReader::new(data);
|
|
let hash = reader.read_bytes(16, "blocks_info_hash")?;
|
|
let blocks_info_hash: [u8; 16] = hash
|
|
.try_into()
|
|
.map_err(|_| AssetBundleError::parse_field("blocks_info_hash", 0, "expected 16 bytes"))?;
|
|
|
|
let block_count = reader.read_i32("block_count")?;
|
|
if block_count < 0 {
|
|
return Err(AssetBundleError::Parse(format!(
|
|
"invalid UnityFS block count: {block_count}"
|
|
)));
|
|
}
|
|
|
|
let mut blocks = Vec::with_capacity(block_count as usize);
|
|
for _ in 0..block_count {
|
|
let uncompressed_size = reader.read_u32("block_uncompressed_size")?;
|
|
let compressed_size = reader.read_u32("block_compressed_size")?;
|
|
let flags = reader.read_u16("block_flags")?;
|
|
blocks.push(UnityFsBlockInfo {
|
|
uncompressed_size,
|
|
compressed_size,
|
|
flags,
|
|
compression: compression_from_flags(flags),
|
|
});
|
|
}
|
|
|
|
let directory_count = reader.read_i32("directory_count")?;
|
|
if directory_count < 0 {
|
|
return Err(AssetBundleError::Parse(format!(
|
|
"invalid UnityFS directory count: {directory_count}"
|
|
)));
|
|
}
|
|
|
|
let mut directories = Vec::with_capacity(directory_count as usize);
|
|
for _ in 0..directory_count {
|
|
directories.push(UnityFsDirectoryInfo {
|
|
offset: reader.read_u64("directory_offset")?,
|
|
size: reader.read_u64("directory_size")?,
|
|
flags: reader.read_u32("directory_flags")?,
|
|
path: reader.read_c_string("directory_path")?,
|
|
});
|
|
}
|
|
|
|
Ok((blocks_info_hash, blocks, directories))
|
|
}
|
|
|
|
fn sum_compressed_block_sizes(blocks: &[UnityFsBlockInfo]) -> Result<u64> {
|
|
let mut total = 0u64;
|
|
for (index, block) in blocks.iter().enumerate() {
|
|
total = total
|
|
.checked_add(u64::from(block.compressed_size))
|
|
.ok_or_else(|| {
|
|
AssetBundleError::Parse(format!(
|
|
"UnityFS compressed data size overflow at block {index}"
|
|
))
|
|
})?;
|
|
}
|
|
Ok(total)
|
|
}
|
|
|
|
fn sum_uncompressed_block_sizes(blocks: &[UnityFsBlockInfo]) -> Result<u64> {
|
|
let mut total = 0u64;
|
|
for (index, block) in blocks.iter().enumerate() {
|
|
total = total
|
|
.checked_add(u64::from(block.uncompressed_size))
|
|
.ok_or_else(|| {
|
|
AssetBundleError::Parse(format!(
|
|
"UnityFS uncompressed data size overflow at block {index}"
|
|
))
|
|
})?;
|
|
}
|
|
Ok(total)
|
|
}
|
|
|
|
fn validate_compressed_region(
|
|
file_size: usize,
|
|
data_start_offset: usize,
|
|
compressed_data_size: u64,
|
|
block_info_at_end: bool,
|
|
block_info_len: usize,
|
|
) -> Result<()> {
|
|
let end = data_start_offset
|
|
.checked_add(usize::try_from(compressed_data_size).map_err(|_| {
|
|
AssetBundleError::Parse(format!(
|
|
"UnityFS compressed data size {} does not fit usize",
|
|
compressed_data_size
|
|
))
|
|
})?)
|
|
.ok_or_else(|| AssetBundleError::Parse("UnityFS compressed data end overflow".into()))?;
|
|
|
|
let max_end = if block_info_at_end {
|
|
file_size.checked_sub(block_info_len).ok_or_else(|| {
|
|
AssetBundleError::Parse(format!(
|
|
"UnityFS block info size {block_info_len} exceeds file size {file_size}"
|
|
))
|
|
})?
|
|
} else {
|
|
file_size
|
|
};
|
|
|
|
if end > max_end {
|
|
return Err(AssetBundleError::Parse(format!(
|
|
"UnityFS compressed data region exceeds file bounds: start {data_start_offset}, size {compressed_data_size}, max_end {max_end}"
|
|
)));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_directory_bounds(
|
|
data_region_size: u64,
|
|
directories: &[UnityFsDirectoryInfo],
|
|
) -> Result<()> {
|
|
for (index, directory) in directories.iter().enumerate() {
|
|
let end = directory
|
|
.offset
|
|
.checked_add(directory.size)
|
|
.ok_or_else(|| {
|
|
AssetBundleError::Parse(format!(
|
|
"UnityFS directory {} offset({}) + size({}) overflows u64",
|
|
directory.path, directory.offset, directory.size
|
|
))
|
|
})?;
|
|
if end > data_region_size {
|
|
return Err(AssetBundleError::Parse(format!(
|
|
"UnityFS directory {} (index {index}) out of bounds: offset({}) + size({}) = {} exceeds uncompressed data region size {}",
|
|
directory.path, directory.offset, directory.size, end, data_region_size
|
|
)));
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
struct UnityFsReader<'a> {
|
|
data: &'a [u8],
|
|
offset: usize,
|
|
}
|
|
|
|
impl<'a> UnityFsReader<'a> {
|
|
fn new(data: &'a [u8]) -> Self {
|
|
Self { data, offset: 0 }
|
|
}
|
|
|
|
fn offset(&self) -> usize {
|
|
self.offset
|
|
}
|
|
|
|
fn read_bytes(&mut self, len: usize, field: &str) -> Result<&'a [u8]> {
|
|
let start = self.offset;
|
|
let end = self.offset.checked_add(len).ok_or_else(|| {
|
|
AssetBundleError::parse_field(field, start, "field length overflows usize")
|
|
})?;
|
|
if end > self.data.len() {
|
|
return Err(AssetBundleError::parse_field(
|
|
field,
|
|
start,
|
|
format!(
|
|
"unexpected end: need {}, have {}",
|
|
len,
|
|
self.data.len().saturating_sub(start)
|
|
),
|
|
));
|
|
}
|
|
|
|
let bytes = &self.data[self.offset..end];
|
|
self.offset = end;
|
|
Ok(bytes)
|
|
}
|
|
|
|
fn align(&mut self, alignment: usize) -> Result<()> {
|
|
if alignment == 0 {
|
|
return Ok(());
|
|
}
|
|
|
|
let remainder = self.offset % alignment;
|
|
if remainder == 0 {
|
|
return Ok(());
|
|
}
|
|
|
|
let padding = alignment - remainder;
|
|
self.read_bytes(padding, "alignment_padding").map(|_| ())
|
|
}
|
|
|
|
fn read_u16(&mut self, field: &str) -> Result<u16> {
|
|
let bytes = self.read_bytes(2, field)?;
|
|
Ok(u16::from_be_bytes([bytes[0], bytes[1]]))
|
|
}
|
|
|
|
fn read_u32(&mut self, field: &str) -> Result<u32> {
|
|
let bytes = self.read_bytes(4, field)?;
|
|
Ok(u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]))
|
|
}
|
|
|
|
fn read_i32(&mut self, field: &str) -> Result<i32> {
|
|
let bytes = self.read_bytes(4, field)?;
|
|
Ok(i32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]))
|
|
}
|
|
|
|
fn read_u64(&mut self, field: &str) -> Result<u64> {
|
|
let bytes = self.read_bytes(8, field)?;
|
|
Ok(u64::from_be_bytes([
|
|
bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7],
|
|
]))
|
|
}
|
|
|
|
fn read_c_string(&mut self, field: &str) -> Result<String> {
|
|
let start = self.offset;
|
|
let remaining = &self.data[self.offset..];
|
|
let Some(length) = remaining.iter().position(|&byte| byte == 0) else {
|
|
return Err(AssetBundleError::parse_field(
|
|
field,
|
|
start,
|
|
"missing null terminator",
|
|
));
|
|
};
|
|
let bytes = self.read_bytes(length, field)?;
|
|
self.offset += 1;
|
|
std::str::from_utf8(bytes)
|
|
.map(ToOwned::to_owned)
|
|
.map_err(|error| {
|
|
AssetBundleError::parse_field(field, start, format!("invalid UTF-8: {error}"))
|
|
})
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn push_c_string(data: &mut Vec<u8>, value: &str) {
|
|
data.extend_from_slice(value.as_bytes());
|
|
data.push(0);
|
|
}
|
|
|
|
fn push_u16(data: &mut Vec<u8>, value: u16) {
|
|
data.extend_from_slice(&value.to_be_bytes());
|
|
}
|
|
|
|
fn push_u32(data: &mut Vec<u8>, value: u32) {
|
|
data.extend_from_slice(&value.to_be_bytes());
|
|
}
|
|
|
|
fn push_i32(data: &mut Vec<u8>, value: i32) {
|
|
data.extend_from_slice(&value.to_be_bytes());
|
|
}
|
|
|
|
fn push_u64(data: &mut Vec<u8>, value: u64) {
|
|
data.extend_from_slice(&value.to_be_bytes());
|
|
}
|
|
|
|
fn push_i16_le(data: &mut Vec<u8>, value: i16) {
|
|
data.extend_from_slice(&value.to_le_bytes());
|
|
}
|
|
|
|
fn push_u32_le(data: &mut Vec<u8>, value: u32) {
|
|
data.extend_from_slice(&value.to_le_bytes());
|
|
}
|
|
|
|
fn push_i32_le(data: &mut Vec<u8>, value: i32) {
|
|
data.extend_from_slice(&value.to_le_bytes());
|
|
}
|
|
|
|
fn push_i64_le(data: &mut Vec<u8>, value: i64) {
|
|
data.extend_from_slice(&value.to_le_bytes());
|
|
}
|
|
|
|
fn push_u64_le(data: &mut Vec<u8>, value: u64) {
|
|
data.extend_from_slice(&value.to_le_bytes());
|
|
}
|
|
|
|
fn align(data: &mut Vec<u8>, alignment: usize) {
|
|
let remainder = data.len() % alignment;
|
|
if remainder != 0 {
|
|
data.resize(data.len() + alignment - remainder, 0);
|
|
}
|
|
}
|
|
|
|
fn blocks_info(directory_size: u64) -> Vec<u8> {
|
|
let mut blocks_info = Vec::new();
|
|
blocks_info.extend_from_slice(&[0xAB; 16]);
|
|
push_i32(&mut blocks_info, 1);
|
|
push_u32(&mut blocks_info, 4);
|
|
push_u32(&mut blocks_info, 4);
|
|
push_u16(&mut blocks_info, 0);
|
|
push_i32(&mut blocks_info, 1);
|
|
push_u64(&mut blocks_info, 0);
|
|
push_u64(&mut blocks_info, directory_size);
|
|
push_u32(&mut blocks_info, 0);
|
|
push_c_string(&mut blocks_info, "CAB-test");
|
|
blocks_info
|
|
}
|
|
|
|
fn blocks_info_for_payload(
|
|
directory_path: &str,
|
|
uncompressed_size: u32,
|
|
compressed_size: u32,
|
|
block_flags: u16,
|
|
) -> Vec<u8> {
|
|
let mut blocks_info = Vec::new();
|
|
blocks_info.extend_from_slice(&[0xCD; 16]);
|
|
push_i32(&mut blocks_info, 1);
|
|
push_u32(&mut blocks_info, uncompressed_size);
|
|
push_u32(&mut blocks_info, compressed_size);
|
|
push_u16(&mut blocks_info, block_flags);
|
|
push_i32(&mut blocks_info, 1);
|
|
push_u64(&mut blocks_info, 0);
|
|
push_u64(&mut blocks_info, u64::from(uncompressed_size));
|
|
push_u32(&mut blocks_info, 0);
|
|
push_c_string(&mut blocks_info, directory_path);
|
|
blocks_info
|
|
}
|
|
|
|
fn synthetic_unityfs_bundle(
|
|
blocks_info: &[u8],
|
|
header_flags: u32,
|
|
block_info_at_end: bool,
|
|
) -> Vec<u8> {
|
|
let mut data = Vec::new();
|
|
push_c_string(&mut data, "UnityFS");
|
|
push_u32(&mut data, 8);
|
|
push_c_string(&mut data, "5.x.x");
|
|
push_c_string(&mut data, "2021.3.56f2");
|
|
push_u64(&mut data, 0);
|
|
push_u32(&mut data, blocks_info.len() as u32);
|
|
push_u32(&mut data, blocks_info.len() as u32);
|
|
push_u32(&mut data, header_flags);
|
|
align(&mut data, UNITYFS_ALIGNMENT);
|
|
|
|
if block_info_at_end {
|
|
data.extend_from_slice(b"data");
|
|
data.extend_from_slice(blocks_info);
|
|
} else {
|
|
data.extend_from_slice(blocks_info);
|
|
data.extend_from_slice(b"data");
|
|
}
|
|
|
|
let total_size = data.len() as u64;
|
|
let total_size_offset = b"UnityFS\0".len() + 4 + b"5.x.x\0".len() + b"2021.3.56f2\0".len();
|
|
data[total_size_offset..total_size_offset + 8].copy_from_slice(&total_size.to_be_bytes());
|
|
data
|
|
}
|
|
|
|
fn synthetic_unityfs_bundle_with_payload(
|
|
directory_path: &str,
|
|
payload: &[u8],
|
|
block_payload: &[u8],
|
|
block_flags: u16,
|
|
) -> Vec<u8> {
|
|
synthetic_unityfs_bundle_with_payload_and_header_flags(
|
|
directory_path,
|
|
payload,
|
|
block_payload,
|
|
block_flags,
|
|
0,
|
|
)
|
|
}
|
|
|
|
fn synthetic_unityfs_bundle_with_payload_and_header_flags(
|
|
directory_path: &str,
|
|
payload: &[u8],
|
|
block_payload: &[u8],
|
|
block_flags: u16,
|
|
header_flags: u32,
|
|
) -> Vec<u8> {
|
|
let blocks_info = blocks_info_for_payload(
|
|
directory_path,
|
|
payload.len() as u32,
|
|
block_payload.len() as u32,
|
|
block_flags,
|
|
);
|
|
let mut data = Vec::new();
|
|
push_c_string(&mut data, "UnityFS");
|
|
push_u32(&mut data, 8);
|
|
push_c_string(&mut data, "5.x.x");
|
|
push_c_string(&mut data, "2021.3.56f2");
|
|
push_u64(&mut data, 0);
|
|
push_u32(&mut data, blocks_info.len() as u32);
|
|
push_u32(&mut data, blocks_info.len() as u32);
|
|
push_u32(&mut data, header_flags);
|
|
align(&mut data, UNITYFS_ALIGNMENT);
|
|
data.extend_from_slice(&blocks_info);
|
|
if block_data_aligned(header_flags) {
|
|
align(&mut data, UNITYFS_ALIGNMENT);
|
|
}
|
|
data.extend_from_slice(block_payload);
|
|
|
|
let total_size = data.len() as u64;
|
|
let total_size_offset = b"UnityFS\0".len() + 4 + b"5.x.x\0".len() + b"2021.3.56f2\0".len();
|
|
data[total_size_offset..total_size_offset + 8].copy_from_slice(&total_size.to_be_bytes());
|
|
data
|
|
}
|
|
|
|
fn synthetic_serialized_text_asset() -> Vec<u8> {
|
|
let mut object_data = Vec::new();
|
|
push_u32_le(&mut object_data, 8);
|
|
object_data.extend_from_slice(b"Scenario");
|
|
align(&mut object_data, 4);
|
|
push_u32_le(&mut object_data, 15);
|
|
object_data.extend_from_slice("こんにちは".as_bytes());
|
|
|
|
let mut metadata = Vec::new();
|
|
metadata.extend_from_slice(b"2021.3.56f2\0");
|
|
push_i32_le(&mut metadata, 19);
|
|
metadata.push(0);
|
|
push_i32_le(&mut metadata, 1);
|
|
push_i32_le(&mut metadata, 49);
|
|
metadata.push(0);
|
|
push_i16_le(&mut metadata, 0);
|
|
metadata.extend_from_slice(&[0; 16]);
|
|
push_i32_le(&mut metadata, 1);
|
|
align(&mut metadata, 4);
|
|
push_i64_le(&mut metadata, 9);
|
|
push_u64_le(&mut metadata, 0);
|
|
push_u32_le(&mut metadata, object_data.len() as u32);
|
|
push_i32_le(&mut metadata, 0);
|
|
|
|
let header_len = 48usize;
|
|
let data_offset = header_len + metadata.len();
|
|
let file_size = data_offset + object_data.len();
|
|
|
|
let mut file = Vec::new();
|
|
push_u32(&mut file, metadata.len() as u32);
|
|
push_u32(&mut file, file_size as u32);
|
|
push_u32(&mut file, 22);
|
|
push_u32(&mut file, 0);
|
|
file.push(0);
|
|
file.extend_from_slice(&[0, 0, 0]);
|
|
push_u32(&mut file, metadata.len() as u32);
|
|
push_u64(&mut file, file_size as u64);
|
|
push_u64(&mut file, data_offset as u64);
|
|
push_u64(&mut file, 0);
|
|
file.extend_from_slice(&metadata);
|
|
file.extend_from_slice(&object_data);
|
|
file
|
|
}
|
|
|
|
#[test]
|
|
fn parses_uncompressed_unityfs_metadata() {
|
|
let parser = UnityFsParser::new();
|
|
let data = synthetic_unityfs_bundle(&blocks_info(4), 0, false);
|
|
|
|
let parsed = parser.parse_bytes(&data).unwrap();
|
|
|
|
assert_eq!(parsed.header.format_version, 8);
|
|
assert_eq!(parsed.header.unity_version, "2021.3.56f2");
|
|
assert_eq!(parsed.blocks_info_hash, [0xAB; 16]);
|
|
assert_eq!(parsed.blocks.len(), 1);
|
|
assert_eq!(parsed.blocks[0].compression, UnityFsCompression::None);
|
|
assert_eq!(parsed.directories.len(), 1);
|
|
assert_eq!(parsed.directories[0].path, "CAB-test");
|
|
assert_eq!(parsed.uncompressed_data_size, 4);
|
|
assert_eq!(parsed.files.len(), 1);
|
|
assert_eq!(parsed.files[0].data, b"data");
|
|
assert_eq!(parser.parse(&data).unwrap().assets, vec!["CAB-test"]);
|
|
}
|
|
|
|
#[test]
|
|
fn parses_block_info_at_end() {
|
|
let parser = UnityFsParser::new();
|
|
let data = synthetic_unityfs_bundle(&blocks_info(4), UNITYFS_BLOCK_INFO_AT_END_FLAG, true);
|
|
|
|
let parsed = parser.parse_bytes(&data).unwrap();
|
|
|
|
assert_eq!(parsed.directories[0].path, "CAB-test");
|
|
assert_eq!(parsed.compressed_data_size, 4);
|
|
}
|
|
|
|
#[test]
|
|
fn parses_lz4_compressed_block_info() {
|
|
let parser = UnityFsParser::new();
|
|
let plain = blocks_info(4);
|
|
let compressed = lz4::block::compress(&plain, None, false).unwrap();
|
|
let mut data = Vec::new();
|
|
push_c_string(&mut data, "UnityFS");
|
|
push_u32(&mut data, 8);
|
|
push_c_string(&mut data, "5.x.x");
|
|
push_c_string(&mut data, "2021.3.56f2");
|
|
push_u64(&mut data, 0);
|
|
push_u32(&mut data, compressed.len() as u32);
|
|
push_u32(&mut data, plain.len() as u32);
|
|
push_u32(&mut data, 2);
|
|
align(&mut data, UNITYFS_ALIGNMENT);
|
|
data.extend_from_slice(&compressed);
|
|
data.extend_from_slice(b"data");
|
|
let total_size = data.len() as u64;
|
|
let total_size_offset = b"UnityFS\0".len() + 4 + b"5.x.x\0".len() + b"2021.3.56f2\0".len();
|
|
data[total_size_offset..total_size_offset + 8].copy_from_slice(&total_size.to_be_bytes());
|
|
|
|
let parsed = parser.parse_bytes(&data).unwrap();
|
|
|
|
assert_eq!(parsed.directories[0].path, "CAB-test");
|
|
}
|
|
|
|
#[test]
|
|
fn extracts_files_and_text_assets_from_unityfs_payload() {
|
|
let parser = UnityFsParser::new();
|
|
let serialized_file = synthetic_serialized_text_asset();
|
|
let data = synthetic_unityfs_bundle_with_payload(
|
|
"CAB-scenario",
|
|
&serialized_file,
|
|
&serialized_file,
|
|
0,
|
|
);
|
|
|
|
let parsed = parser.parse_bytes(&data).unwrap();
|
|
|
|
assert_eq!(parsed.files.len(), 1);
|
|
assert_eq!(parsed.files[0].path, "CAB-scenario");
|
|
assert_eq!(parsed.files[0].data, serialized_file);
|
|
assert_eq!(parsed.serialized_files.len(), 1);
|
|
assert!(parsed.serialized_parse_errors.is_empty());
|
|
assert_eq!(parsed.text_assets.len(), 1);
|
|
assert_eq!(
|
|
parsed.text_assets[0].source_path.as_deref(),
|
|
Some("CAB-scenario")
|
|
);
|
|
assert_eq!(parsed.text_assets[0].name, "Scenario");
|
|
assert_eq!(parsed.text_assets[0].bytes, "こんにちは".as_bytes());
|
|
|
|
let summary = parser.parse(&data).unwrap();
|
|
assert_eq!(summary.text_assets.len(), 1);
|
|
assert_eq!(summary.files.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn extracts_lz4_compressed_data_block() {
|
|
let parser = UnityFsParser::new();
|
|
let payload = b"localized-text-payload";
|
|
let compressed = lz4::block::compress(payload, None, false).unwrap();
|
|
let data = synthetic_unityfs_bundle_with_payload("CAB-lz4", payload, &compressed, 2);
|
|
|
|
let parsed = parser.parse_bytes(&data).unwrap();
|
|
|
|
assert_eq!(parsed.files.len(), 1);
|
|
assert_eq!(parsed.files[0].path, "CAB-lz4");
|
|
assert_eq!(parsed.files[0].data, payload);
|
|
}
|
|
|
|
#[test]
|
|
fn extracts_data_block_after_block_info_alignment_padding() {
|
|
let parser = UnityFsParser::new();
|
|
let payload = b"aligned-payload";
|
|
let data = synthetic_unityfs_bundle_with_payload_and_header_flags(
|
|
"CAB-aligned",
|
|
payload,
|
|
payload,
|
|
0,
|
|
UNITYFS_BLOCK_DATA_ALIGNED_FLAG,
|
|
);
|
|
|
|
let parsed = parser.parse_bytes(&data).unwrap();
|
|
|
|
assert_eq!(parsed.files.len(), 1);
|
|
assert_eq!(parsed.files[0].data, payload);
|
|
assert_eq!(parsed.data_start_offset % UNITYFS_ALIGNMENT as u64, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_directory_past_uncompressed_data_region() {
|
|
let parser = UnityFsParser::new();
|
|
let data = synthetic_unityfs_bundle(&blocks_info(99), 0, false);
|
|
|
|
let error = parser.parse_bytes(&data).unwrap_err().to_string();
|
|
|
|
assert!(error.contains("out of bounds"), "{error}");
|
|
assert!(error.contains("uncompressed data region size 4"), "{error}");
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_truncated_header_with_field_context() {
|
|
let parser = UnityFsParser::new();
|
|
let error = parser.parse_bytes(b"UnityFS\0").unwrap_err().to_string();
|
|
|
|
assert!(error.contains("format_version"), "{error}");
|
|
assert!(error.contains("offset"), "{error}");
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_unknown_block_info_compression() {
|
|
let parser = UnityFsParser::new();
|
|
let data = synthetic_unityfs_bundle(&blocks_info(4), 5, false);
|
|
|
|
let error = parser.parse_bytes(&data).unwrap_err().to_string();
|
|
|
|
assert!(
|
|
error.contains("unsupported UnityFS block info compression"),
|
|
"{error}"
|
|
);
|
|
}
|
|
}
|