|
|
|
@@ -5,7 +5,7 @@ use crate::parser::UnityFsParser;
|
|
|
|
|
use crate::serialized::{
|
|
|
|
|
UnitySerializedField, UnitySerializedReplacementValue, UnitySerializedValue,
|
|
|
|
|
};
|
|
|
|
|
use crate::types::UnityFsBundle;
|
|
|
|
|
use crate::types::{UnityFsBundle, UnityFsCompression};
|
|
|
|
|
use md5::{Digest, Md5};
|
|
|
|
|
|
|
|
|
|
/// One TextAsset replacement inside a serialized UnityFS directory file.
|
|
|
|
@@ -105,9 +105,9 @@ impl FieldPatch {
|
|
|
|
|
|
|
|
|
|
/// Patches one TextAsset and rebuilds the UnityFS container.
|
|
|
|
|
///
|
|
|
|
|
/// The rebuilt bundle uses a single uncompressed data block. This keeps the
|
|
|
|
|
/// patch path deterministic and avoids relying on a compressor-specific
|
|
|
|
|
/// implementation while preserving all directory file paths and metadata.
|
|
|
|
|
/// The rebuilt bundle retains the parsed block count, compression modes,
|
|
|
|
|
/// alignment flags and directory metadata while recalculating all variable
|
|
|
|
|
/// offsets and sizes.
|
|
|
|
|
pub fn patch_unityfs_text_asset(data: &[u8], patch: &TextAssetPatch) -> Result<Vec<u8>> {
|
|
|
|
|
let parser = UnityFsParser::new();
|
|
|
|
|
let mut bundle = parser.parse_bytes(data)?;
|
|
|
|
@@ -142,6 +142,13 @@ pub fn patch_unityfs_text_asset(data: &[u8], patch: &TextAssetPatch) -> Result<V
|
|
|
|
|
|
|
|
|
|
let rebuilt = rebuild_unityfs(&bundle)?;
|
|
|
|
|
let verified = parser.parse_bytes(&rebuilt)?;
|
|
|
|
|
verify_rebuild_preserves_unmodified_content(
|
|
|
|
|
&bundle,
|
|
|
|
|
&verified,
|
|
|
|
|
&patch.serialized_file_path,
|
|
|
|
|
patch.path_id,
|
|
|
|
|
None,
|
|
|
|
|
)?;
|
|
|
|
|
let asset = verified
|
|
|
|
|
.text_assets
|
|
|
|
|
.iter()
|
|
|
|
@@ -197,6 +204,13 @@ pub fn patch_unityfs_string_field(data: &[u8], patch: &StringFieldPatch) -> Resu
|
|
|
|
|
|
|
|
|
|
let rebuilt = rebuild_unityfs(&bundle)?;
|
|
|
|
|
let verified = parser.parse_bytes(&rebuilt)?;
|
|
|
|
|
verify_rebuild_preserves_unmodified_content(
|
|
|
|
|
&bundle,
|
|
|
|
|
&verified,
|
|
|
|
|
&patch.serialized_file_path,
|
|
|
|
|
patch.path_id,
|
|
|
|
|
Some(&patch.field_path),
|
|
|
|
|
)?;
|
|
|
|
|
let serialized = verified
|
|
|
|
|
.serialized_files
|
|
|
|
|
.iter()
|
|
|
|
@@ -259,6 +273,13 @@ pub fn patch_unityfs_field(data: &[u8], patch: &FieldPatch) -> Result<Vec<u8>> {
|
|
|
|
|
|
|
|
|
|
let rebuilt = rebuild_unityfs(&bundle)?;
|
|
|
|
|
let verified = parser.parse_bytes(&rebuilt)?;
|
|
|
|
|
verify_rebuild_preserves_unmodified_content(
|
|
|
|
|
&bundle,
|
|
|
|
|
&verified,
|
|
|
|
|
&patch.serialized_file_path,
|
|
|
|
|
patch.path_id,
|
|
|
|
|
Some(&patch.field_path),
|
|
|
|
|
)?;
|
|
|
|
|
let serialized = verified
|
|
|
|
|
.serialized_files
|
|
|
|
|
.iter()
|
|
|
|
@@ -285,7 +306,12 @@ pub fn patch_unityfs_field(data: &[u8], patch: &FieldPatch) -> Result<Vec<u8>> {
|
|
|
|
|
Ok(rebuilt)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn rebuild_unityfs(bundle: &UnityFsBundle) -> Result<Vec<u8>> {
|
|
|
|
|
/// Rebuilds a parsed UnityFS bundle while preserving its container shape.
|
|
|
|
|
///
|
|
|
|
|
/// The directory order, path, flags, header version and block compression
|
|
|
|
|
/// modes are retained. Variable-length file changes are reflected in
|
|
|
|
|
/// directory offsets and block sizes; no raw offset is patched blindly.
|
|
|
|
|
pub fn rebuild_unityfs_bundle(bundle: &UnityFsBundle) -> Result<Vec<u8>> {
|
|
|
|
|
if bundle.files.len() != bundle.directories.len() {
|
|
|
|
|
return Err(AssetBundleError::Parse(format!(
|
|
|
|
|
"UnityFS file/directory count mismatch: files={}, directories={}",
|
|
|
|
@@ -293,24 +319,123 @@ fn rebuild_unityfs(bundle: &UnityFsBundle) -> Result<Vec<u8>> {
|
|
|
|
|
bundle.directories.len()
|
|
|
|
|
)));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mut uncompressed_data = Vec::new();
|
|
|
|
|
let mut directory_offsets = Vec::with_capacity(bundle.files.len());
|
|
|
|
|
for file in &bundle.files {
|
|
|
|
|
let offset = u64::try_from(uncompressed_data.len())
|
|
|
|
|
.map_err(|_| AssetBundleError::Parse("UnityFS data offset overflow".to_string()))?;
|
|
|
|
|
directory_offsets.push(offset);
|
|
|
|
|
uncompressed_data.extend_from_slice(&file.data);
|
|
|
|
|
let block_uncompressed_size = bundle
|
|
|
|
|
.blocks
|
|
|
|
|
.iter()
|
|
|
|
|
.try_fold(0u64, |total, block| {
|
|
|
|
|
total.checked_add(u64::from(block.uncompressed_size))
|
|
|
|
|
})
|
|
|
|
|
.ok_or_else(|| AssetBundleError::Parse("UnityFS block size overflow".to_string()))?;
|
|
|
|
|
let retained_uncompressed_size = u64::try_from(bundle.uncompressed_data.len())
|
|
|
|
|
.map_err(|_| AssetBundleError::Parse("UnityFS data size does not fit u64".to_string()))?;
|
|
|
|
|
if block_uncompressed_size != retained_uncompressed_size
|
|
|
|
|
|| bundle.uncompressed_data_size != retained_uncompressed_size
|
|
|
|
|
{
|
|
|
|
|
return Err(AssetBundleError::Parse(format!(
|
|
|
|
|
"UnityFS block table/data size mismatch: blocks={}, retained={}, declared={}",
|
|
|
|
|
block_uncompressed_size, retained_uncompressed_size, bundle.uncompressed_data_size
|
|
|
|
|
)));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mut ordered_directories = bundle.directories.iter().enumerate().collect::<Vec<_>>();
|
|
|
|
|
ordered_directories.sort_by_key(|(_, directory)| directory.offset);
|
|
|
|
|
let mut uncompressed_data = Vec::with_capacity(bundle.uncompressed_data.len());
|
|
|
|
|
let mut directory_offsets = vec![0u64; bundle.files.len()];
|
|
|
|
|
let mut cursor = 0usize;
|
|
|
|
|
for (index, directory) in ordered_directories {
|
|
|
|
|
let file = bundle.files.get(index).ok_or_else(|| {
|
|
|
|
|
AssetBundleError::Parse(format!(
|
|
|
|
|
"UnityFS directory {} has no corresponding file",
|
|
|
|
|
directory.path
|
|
|
|
|
))
|
|
|
|
|
})?;
|
|
|
|
|
if file.path != directory.path {
|
|
|
|
|
return Err(AssetBundleError::Parse(format!(
|
|
|
|
|
"UnityFS file/directory path mismatch: file={:?}, directory={:?}",
|
|
|
|
|
file.path, directory.path
|
|
|
|
|
)));
|
|
|
|
|
}
|
|
|
|
|
let start = usize::try_from(directory.offset).map_err(|_| {
|
|
|
|
|
AssetBundleError::Parse(format!(
|
|
|
|
|
"UnityFS directory {} offset does not fit usize",
|
|
|
|
|
directory.path
|
|
|
|
|
))
|
|
|
|
|
})?;
|
|
|
|
|
let end = start
|
|
|
|
|
.checked_add(usize::try_from(directory.size).map_err(|_| {
|
|
|
|
|
AssetBundleError::Parse(format!(
|
|
|
|
|
"UnityFS directory {} size does not fit usize",
|
|
|
|
|
directory.path
|
|
|
|
|
))
|
|
|
|
|
})?)
|
|
|
|
|
.ok_or_else(|| {
|
|
|
|
|
AssetBundleError::Parse(format!(
|
|
|
|
|
"UnityFS directory {} range overflows usize",
|
|
|
|
|
directory.path
|
|
|
|
|
))
|
|
|
|
|
})?;
|
|
|
|
|
if start < cursor || end > bundle.uncompressed_data.len() {
|
|
|
|
|
return Err(AssetBundleError::Parse(format!(
|
|
|
|
|
"UnityFS directory {} overlaps or exceeds the original data region",
|
|
|
|
|
directory.path
|
|
|
|
|
)));
|
|
|
|
|
}
|
|
|
|
|
uncompressed_data.extend_from_slice(&bundle.uncompressed_data[cursor..start]);
|
|
|
|
|
directory_offsets[index] = u64::try_from(uncompressed_data.len()).map_err(|_| {
|
|
|
|
|
AssetBundleError::Parse("UnityFS directory offset overflow".to_string())
|
|
|
|
|
})?;
|
|
|
|
|
uncompressed_data.extend_from_slice(&file.data);
|
|
|
|
|
cursor = end;
|
|
|
|
|
}
|
|
|
|
|
uncompressed_data.extend_from_slice(&bundle.uncompressed_data[cursor..]);
|
|
|
|
|
|
|
|
|
|
let block_sizes = repartition_blocks(uncompressed_data.len(), &bundle.blocks)?;
|
|
|
|
|
let mut compressed_blocks = Vec::with_capacity(bundle.blocks.len());
|
|
|
|
|
for (index, (block, size)) in bundle.blocks.iter().zip(&block_sizes).enumerate() {
|
|
|
|
|
let start = block_sizes[..index].iter().sum::<usize>();
|
|
|
|
|
let end = start.checked_add(*size).ok_or_else(|| {
|
|
|
|
|
AssetBundleError::Parse(format!("UnityFS rebuilt block {index} range overflows"))
|
|
|
|
|
})?;
|
|
|
|
|
let bytes = uncompressed_data.get(start..end).ok_or_else(|| {
|
|
|
|
|
AssetBundleError::Parse(format!(
|
|
|
|
|
"UnityFS rebuilt block {index} range {}..{} exceeds data {}",
|
|
|
|
|
start,
|
|
|
|
|
end,
|
|
|
|
|
uncompressed_data.len()
|
|
|
|
|
))
|
|
|
|
|
})?;
|
|
|
|
|
compressed_blocks.push(compress_unityfs_bytes(
|
|
|
|
|
bytes,
|
|
|
|
|
block.compression,
|
|
|
|
|
&format!("UnityFS data block {index}"),
|
|
|
|
|
)?);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let data_size = u32::try_from(uncompressed_data.len()).map_err(|_| {
|
|
|
|
|
AssetBundleError::Parse("UnityFS rebuilt data exceeds u32 size".to_string())
|
|
|
|
|
})?;
|
|
|
|
|
let mut blocks_info_body = Vec::new();
|
|
|
|
|
push_i32_be(&mut blocks_info_body, 1);
|
|
|
|
|
push_u32_be(&mut blocks_info_body, data_size);
|
|
|
|
|
push_u32_be(&mut blocks_info_body, data_size);
|
|
|
|
|
push_u16_be(&mut blocks_info_body, 0);
|
|
|
|
|
push_i32_be(
|
|
|
|
|
&mut blocks_info_body,
|
|
|
|
|
i32::try_from(bundle.blocks.len())
|
|
|
|
|
.map_err(|_| AssetBundleError::Parse("UnityFS block count exceeds i32".to_string()))?,
|
|
|
|
|
);
|
|
|
|
|
for (block, (bytes, uncompressed_size)) in bundle
|
|
|
|
|
.blocks
|
|
|
|
|
.iter()
|
|
|
|
|
.zip(compressed_blocks.iter().zip(block_sizes.iter()))
|
|
|
|
|
{
|
|
|
|
|
push_u32_be(
|
|
|
|
|
&mut blocks_info_body,
|
|
|
|
|
u32::try_from(*uncompressed_size).map_err(|_| {
|
|
|
|
|
AssetBundleError::Parse("UnityFS rebuilt block exceeds u32 size".to_string())
|
|
|
|
|
})?,
|
|
|
|
|
);
|
|
|
|
|
push_u32_be(
|
|
|
|
|
&mut blocks_info_body,
|
|
|
|
|
u32::try_from(bytes.len()).map_err(|_| {
|
|
|
|
|
AssetBundleError::Parse("UnityFS compressed block exceeds u32 size".to_string())
|
|
|
|
|
})?,
|
|
|
|
|
);
|
|
|
|
|
push_u16_be(&mut blocks_info_body, block.flags);
|
|
|
|
|
}
|
|
|
|
|
push_i32_be(
|
|
|
|
|
&mut blocks_info_body,
|
|
|
|
|
i32::try_from(bundle.files.len()).map_err(|_| {
|
|
|
|
@@ -328,9 +453,14 @@ fn rebuild_unityfs(bundle: &UnityFsBundle) -> Result<Vec<u8>> {
|
|
|
|
|
push_c_string(&mut blocks_info_body, &file.path);
|
|
|
|
|
}
|
|
|
|
|
let digest = Md5::digest(&blocks_info_body);
|
|
|
|
|
let mut blocks_info = Vec::with_capacity(16 + blocks_info_body.len());
|
|
|
|
|
blocks_info.extend_from_slice(&digest);
|
|
|
|
|
blocks_info.extend_from_slice(&blocks_info_body);
|
|
|
|
|
let mut blocks_info_uncompressed = Vec::with_capacity(16 + blocks_info_body.len());
|
|
|
|
|
blocks_info_uncompressed.extend_from_slice(&digest);
|
|
|
|
|
blocks_info_uncompressed.extend_from_slice(&blocks_info_body);
|
|
|
|
|
let blocks_info = compress_unityfs_bytes(
|
|
|
|
|
&blocks_info_uncompressed,
|
|
|
|
|
compression_from_header_flags(bundle.header.flags),
|
|
|
|
|
"UnityFS block info",
|
|
|
|
|
)?;
|
|
|
|
|
|
|
|
|
|
let mut output = Vec::new();
|
|
|
|
|
push_c_string(&mut output, "UnityFS");
|
|
|
|
@@ -342,27 +472,395 @@ fn rebuild_unityfs(bundle: &UnityFsBundle) -> Result<Vec<u8>> {
|
|
|
|
|
push_u32_be(
|
|
|
|
|
&mut output,
|
|
|
|
|
u32::try_from(blocks_info.len()).map_err(|_| {
|
|
|
|
|
AssetBundleError::Parse("UnityFS block info exceeds u32 size".to_string())
|
|
|
|
|
AssetBundleError::Parse("UnityFS compressed block info exceeds u32 size".to_string())
|
|
|
|
|
})?,
|
|
|
|
|
);
|
|
|
|
|
push_u32_be(
|
|
|
|
|
&mut output,
|
|
|
|
|
u32::try_from(blocks_info.len()).map_err(|_| {
|
|
|
|
|
u32::try_from(blocks_info_uncompressed.len()).map_err(|_| {
|
|
|
|
|
AssetBundleError::Parse("UnityFS block info exceeds u32 size".to_string())
|
|
|
|
|
})?,
|
|
|
|
|
);
|
|
|
|
|
push_u32_be(&mut output, 0);
|
|
|
|
|
push_u32_be(&mut output, bundle.header.flags);
|
|
|
|
|
if bundle.header.format_version >= 7 {
|
|
|
|
|
align_vec(&mut output, 16);
|
|
|
|
|
}
|
|
|
|
|
output.extend_from_slice(&blocks_info);
|
|
|
|
|
output.extend_from_slice(&uncompressed_data);
|
|
|
|
|
if block_info_at_end(bundle.header.flags) {
|
|
|
|
|
if block_data_aligned(bundle.header.flags) {
|
|
|
|
|
align_vec(&mut output, 16);
|
|
|
|
|
}
|
|
|
|
|
for bytes in &compressed_blocks {
|
|
|
|
|
output.extend_from_slice(bytes);
|
|
|
|
|
}
|
|
|
|
|
output.extend_from_slice(&blocks_info);
|
|
|
|
|
} else {
|
|
|
|
|
output.extend_from_slice(&blocks_info);
|
|
|
|
|
if block_data_aligned(bundle.header.flags) {
|
|
|
|
|
align_vec(&mut output, 16);
|
|
|
|
|
}
|
|
|
|
|
for bytes in &compressed_blocks {
|
|
|
|
|
output.extend_from_slice(bytes);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
let total_size = u64::try_from(output.len())
|
|
|
|
|
.map_err(|_| AssetBundleError::Parse("UnityFS rebuilt size overflow".to_string()))?;
|
|
|
|
|
output[total_size_offset..total_size_offset + 8].copy_from_slice(&total_size.to_be_bytes());
|
|
|
|
|
Ok(output)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn rebuild_unityfs(bundle: &UnityFsBundle) -> Result<Vec<u8>> {
|
|
|
|
|
rebuild_unityfs_bundle(bundle)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn compression_from_header_flags(flags: u32) -> UnityFsCompression {
|
|
|
|
|
crate::parser::compression_from_flags((flags & 0x3f) as u16)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn block_info_at_end(flags: u32) -> bool {
|
|
|
|
|
flags & 0x80 != 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn block_data_aligned(flags: u32) -> bool {
|
|
|
|
|
flags & 0x200 != 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn repartition_blocks(
|
|
|
|
|
total_size: usize,
|
|
|
|
|
blocks: &[crate::types::UnityFsBlockInfo],
|
|
|
|
|
) -> Result<Vec<usize>> {
|
|
|
|
|
if blocks.is_empty() {
|
|
|
|
|
return Err(AssetBundleError::Parse(
|
|
|
|
|
"UnityFS bundle has no blocks to rebuild".to_string(),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
let original_total = blocks
|
|
|
|
|
.iter()
|
|
|
|
|
.try_fold(0u64, |total, block| {
|
|
|
|
|
total.checked_add(u64::from(block.uncompressed_size))
|
|
|
|
|
})
|
|
|
|
|
.ok_or_else(|| AssetBundleError::Parse("UnityFS block size overflow".to_string()))?;
|
|
|
|
|
if original_total == 0 {
|
|
|
|
|
return Ok(vec![0; blocks.len()]);
|
|
|
|
|
}
|
|
|
|
|
let total = u64::try_from(total_size)
|
|
|
|
|
.map_err(|_| AssetBundleError::Parse("UnityFS data size does not fit u64".to_string()))?;
|
|
|
|
|
let mut result = Vec::with_capacity(blocks.len());
|
|
|
|
|
let mut previous = 0u64;
|
|
|
|
|
let mut cumulative = 0u64;
|
|
|
|
|
for block in blocks {
|
|
|
|
|
cumulative = cumulative
|
|
|
|
|
.checked_add(u64::from(block.uncompressed_size))
|
|
|
|
|
.ok_or_else(|| {
|
|
|
|
|
AssetBundleError::Parse("UnityFS block boundary overflow".to_string())
|
|
|
|
|
})?;
|
|
|
|
|
let boundary = total.checked_mul(cumulative).ok_or_else(|| {
|
|
|
|
|
AssetBundleError::Parse("UnityFS block boundary overflow".to_string())
|
|
|
|
|
})? / original_total;
|
|
|
|
|
result.push(usize::try_from(boundary - previous).map_err(|_| {
|
|
|
|
|
AssetBundleError::Parse("UnityFS rebuilt block size does not fit usize".to_string())
|
|
|
|
|
})?);
|
|
|
|
|
previous = boundary;
|
|
|
|
|
}
|
|
|
|
|
Ok(result)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn compress_unityfs_bytes(
|
|
|
|
|
data: &[u8],
|
|
|
|
|
compression: UnityFsCompression,
|
|
|
|
|
context: &str,
|
|
|
|
|
) -> Result<Vec<u8>> {
|
|
|
|
|
match compression {
|
|
|
|
|
UnityFsCompression::None => Ok(data.to_vec()),
|
|
|
|
|
UnityFsCompression::Lz4 => {
|
|
|
|
|
lz4::block::compress(data, Some(lz4::block::CompressionMode::FAST(1)), false).map_err(
|
|
|
|
|
|error| {
|
|
|
|
|
AssetBundleError::Parse(format!("Failed to compress {context} as LZ4: {error}"))
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
UnityFsCompression::Lz4Hc => lz4::block::compress(
|
|
|
|
|
data,
|
|
|
|
|
Some(lz4::block::CompressionMode::HIGHCOMPRESSION(9)),
|
|
|
|
|
false,
|
|
|
|
|
)
|
|
|
|
|
.map_err(|error| {
|
|
|
|
|
AssetBundleError::Parse(format!("Failed to compress {context} as LZ4HC: {error}"))
|
|
|
|
|
}),
|
|
|
|
|
UnityFsCompression::Lzma => {
|
|
|
|
|
let mut output = Vec::new();
|
|
|
|
|
lzma_rs::lzma_compress(&mut std::io::Cursor::new(data), &mut output).map_err(
|
|
|
|
|
|error| {
|
|
|
|
|
AssetBundleError::Parse(format!(
|
|
|
|
|
"Failed to compress {context} as LZMA: {error}"
|
|
|
|
|
))
|
|
|
|
|
},
|
|
|
|
|
)?;
|
|
|
|
|
Ok(output)
|
|
|
|
|
}
|
|
|
|
|
UnityFsCompression::Unknown(value) => Err(AssetBundleError::UnsupportedFormat(format!(
|
|
|
|
|
"unsupported {context} compression flag: {value}"
|
|
|
|
|
))),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn verify_rebuild_preserves_unmodified_content(
|
|
|
|
|
original: &UnityFsBundle,
|
|
|
|
|
rebuilt_bytes: &UnityFsBundle,
|
|
|
|
|
modified_serialized_path: &str,
|
|
|
|
|
modified_path_id: i64,
|
|
|
|
|
modified_field_path: Option<&str>,
|
|
|
|
|
) -> Result<()> {
|
|
|
|
|
if original.header.format_version != rebuilt_bytes.header.format_version
|
|
|
|
|
|| original.header.target_version != rebuilt_bytes.header.target_version
|
|
|
|
|
|| original.header.unity_version != rebuilt_bytes.header.unity_version
|
|
|
|
|
|| original.header.flags != rebuilt_bytes.header.flags
|
|
|
|
|
{
|
|
|
|
|
return Err(AssetBundleError::Parse(
|
|
|
|
|
"UnityFS rebuild changed immutable header metadata".to_string(),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
if original.blocks.len() != rebuilt_bytes.blocks.len()
|
|
|
|
|
|| original.directories.len() != rebuilt_bytes.directories.len()
|
|
|
|
|
{
|
|
|
|
|
return Err(AssetBundleError::Parse(
|
|
|
|
|
"UnityFS rebuild changed block or directory count".to_string(),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
if original.serialized_files.len() != rebuilt_bytes.serialized_files.len() {
|
|
|
|
|
return Err(AssetBundleError::Parse(
|
|
|
|
|
"UnityFS rebuild changed serialized-file count".to_string(),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
if original.serialized_parse_errors != rebuilt_bytes.serialized_parse_errors {
|
|
|
|
|
return Err(AssetBundleError::Parse(
|
|
|
|
|
"UnityFS rebuild changed serialized-file diagnostics".to_string(),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
for (original, rebuilt) in original.blocks.iter().zip(&rebuilt_bytes.blocks) {
|
|
|
|
|
if original.flags != rebuilt.flags || original.compression != rebuilt.compression {
|
|
|
|
|
return Err(AssetBundleError::Parse(
|
|
|
|
|
"UnityFS rebuild changed block compression metadata".to_string(),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
for (original, rebuilt) in original.directories.iter().zip(&rebuilt_bytes.directories) {
|
|
|
|
|
if original.path != rebuilt.path || original.flags != rebuilt.flags {
|
|
|
|
|
return Err(AssetBundleError::Parse(
|
|
|
|
|
"UnityFS rebuild changed directory path or flags".to_string(),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (original_file, rebuilt_file) in original.files.iter().zip(&rebuilt_bytes.files) {
|
|
|
|
|
if original_file.path != modified_serialized_path
|
|
|
|
|
&& (original_file.path != rebuilt_file.path || original_file.data != rebuilt_file.data)
|
|
|
|
|
{
|
|
|
|
|
return Err(AssetBundleError::Parse(format!(
|
|
|
|
|
"UnityFS rebuild changed an unmodified directory file: {}",
|
|
|
|
|
original_file.path
|
|
|
|
|
)));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for original_file in &original.serialized_files {
|
|
|
|
|
let rebuilt_file = rebuilt_bytes
|
|
|
|
|
.serialized_files
|
|
|
|
|
.iter()
|
|
|
|
|
.find(|candidate| candidate.source_path == original_file.source_path)
|
|
|
|
|
.ok_or_else(|| {
|
|
|
|
|
AssetBundleError::Parse(format!(
|
|
|
|
|
"UnityFS rebuild lost serialized file {:?}",
|
|
|
|
|
original_file.source_path
|
|
|
|
|
))
|
|
|
|
|
})?;
|
|
|
|
|
if original_file.source_path != rebuilt_file.source_path
|
|
|
|
|
|| original_file.version != rebuilt_file.version
|
|
|
|
|
|| original_file.unity_version != rebuilt_file.unity_version
|
|
|
|
|
|| original_file.platform != rebuilt_file.platform
|
|
|
|
|
|| original_file.types != rebuilt_file.types
|
|
|
|
|
|| original_file.objects.len() != rebuilt_file.objects.len()
|
|
|
|
|
{
|
|
|
|
|
return Err(AssetBundleError::Parse(
|
|
|
|
|
"UnityFS rebuild changed serialized-file metadata".to_string(),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
for (original_object, rebuilt_object) in
|
|
|
|
|
original_file.objects.iter().zip(&rebuilt_file.objects)
|
|
|
|
|
{
|
|
|
|
|
if original_object.path_id != rebuilt_object.path_id
|
|
|
|
|
|| original_object.type_index != rebuilt_object.type_index
|
|
|
|
|
|| original_object.class_id != rebuilt_object.class_id
|
|
|
|
|
{
|
|
|
|
|
return Err(AssetBundleError::Parse(format!(
|
|
|
|
|
"UnityFS rebuild changed object table entry path_id {}",
|
|
|
|
|
original_object.path_id
|
|
|
|
|
)));
|
|
|
|
|
}
|
|
|
|
|
let is_modified_object = original_file.source_path.as_deref()
|
|
|
|
|
== Some(modified_serialized_path)
|
|
|
|
|
&& original_object.path_id == modified_path_id;
|
|
|
|
|
if !is_modified_object && original_object.byte_size != rebuilt_object.byte_size {
|
|
|
|
|
return Err(AssetBundleError::Parse(format!(
|
|
|
|
|
"UnityFS rebuild changed unmodified object byte size path_id {}",
|
|
|
|
|
original_object.path_id
|
|
|
|
|
)));
|
|
|
|
|
}
|
|
|
|
|
if is_modified_object {
|
|
|
|
|
if let Some(field_path) = modified_field_path {
|
|
|
|
|
let original_fields = original_file.fields_for_object_entry(original_object)?;
|
|
|
|
|
let rebuilt_fields = rebuilt_file.fields_for_object_entry(rebuilt_object)?;
|
|
|
|
|
verify_unmodified_fields(
|
|
|
|
|
&original_fields,
|
|
|
|
|
&rebuilt_fields,
|
|
|
|
|
field_path,
|
|
|
|
|
original_object.path_id,
|
|
|
|
|
)?;
|
|
|
|
|
} else {
|
|
|
|
|
let original_asset = original_file
|
|
|
|
|
.text_assets()
|
|
|
|
|
.iter()
|
|
|
|
|
.find(|asset| asset.path_id == modified_path_id);
|
|
|
|
|
let rebuilt_asset = rebuilt_file
|
|
|
|
|
.text_assets()
|
|
|
|
|
.iter()
|
|
|
|
|
.find(|asset| asset.path_id == modified_path_id);
|
|
|
|
|
if original_asset.map(|asset| &asset.name)
|
|
|
|
|
!= rebuilt_asset.map(|asset| &asset.name)
|
|
|
|
|
{
|
|
|
|
|
return Err(AssetBundleError::Parse(format!(
|
|
|
|
|
"TextAsset path_id {} name changed while rebuilding",
|
|
|
|
|
modified_path_id
|
|
|
|
|
)));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
if original_file.object_bytes(original_object)?
|
|
|
|
|
!= rebuilt_file.object_bytes(rebuilt_object)?
|
|
|
|
|
{
|
|
|
|
|
return Err(AssetBundleError::Parse(format!(
|
|
|
|
|
"UnityFS rebuild changed unmodified object path_id {}",
|
|
|
|
|
original_object.path_id
|
|
|
|
|
)));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn verify_unmodified_fields(
|
|
|
|
|
original: &[UnitySerializedField],
|
|
|
|
|
rebuilt: &[UnitySerializedField],
|
|
|
|
|
modified_field_path: &str,
|
|
|
|
|
path_id: i64,
|
|
|
|
|
) -> Result<()> {
|
|
|
|
|
if original.len() != rebuilt.len() {
|
|
|
|
|
return Err(AssetBundleError::Parse(format!(
|
|
|
|
|
"Unity object path_id {path_id} changed field count while rebuilding"
|
|
|
|
|
)));
|
|
|
|
|
}
|
|
|
|
|
for (original, rebuilt) in original.iter().zip(rebuilt) {
|
|
|
|
|
if original.path != rebuilt.path
|
|
|
|
|
|| original.name != rebuilt.name
|
|
|
|
|
|| original.type_name != rebuilt.type_name
|
|
|
|
|
|| original.type_tree_node_index != rebuilt.type_tree_node_index
|
|
|
|
|
{
|
|
|
|
|
return Err(AssetBundleError::Parse(format!(
|
|
|
|
|
"Unity object path_id {path_id} changed field metadata at {}",
|
|
|
|
|
original.path
|
|
|
|
|
)));
|
|
|
|
|
}
|
|
|
|
|
if !field_path_contains(&original.path, modified_field_path)
|
|
|
|
|
&& original.byte_size != rebuilt.byte_size
|
|
|
|
|
{
|
|
|
|
|
return Err(AssetBundleError::Parse(format!(
|
|
|
|
|
"Unity object path_id {path_id} changed unmodified field byte size at {}",
|
|
|
|
|
original.path
|
|
|
|
|
)));
|
|
|
|
|
}
|
|
|
|
|
if original.path == modified_field_path {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
verify_unmodified_values(
|
|
|
|
|
&original.value,
|
|
|
|
|
&rebuilt.value,
|
|
|
|
|
modified_field_path,
|
|
|
|
|
path_id,
|
|
|
|
|
)?;
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn field_path_contains(parent: &str, child: &str) -> bool {
|
|
|
|
|
child == parent
|
|
|
|
|
|| child
|
|
|
|
|
.strip_prefix(parent)
|
|
|
|
|
.is_some_and(|suffix| suffix.starts_with('.') || suffix.starts_with('['))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn verify_unmodified_values(
|
|
|
|
|
original: &UnitySerializedValue,
|
|
|
|
|
rebuilt: &UnitySerializedValue,
|
|
|
|
|
modified_field_path: &str,
|
|
|
|
|
path_id: i64,
|
|
|
|
|
) -> Result<()> {
|
|
|
|
|
match (original, rebuilt) {
|
|
|
|
|
(UnitySerializedValue::Object(original), UnitySerializedValue::Object(rebuilt))
|
|
|
|
|
| (UnitySerializedValue::Array(original), UnitySerializedValue::Array(rebuilt))
|
|
|
|
|
| (UnitySerializedValue::Map(original), UnitySerializedValue::Map(rebuilt)) => {
|
|
|
|
|
verify_unmodified_fields(original, rebuilt, modified_field_path, path_id)
|
|
|
|
|
}
|
|
|
|
|
(
|
|
|
|
|
UnitySerializedValue::ManagedReference {
|
|
|
|
|
type_name: original_type,
|
|
|
|
|
metadata: original_metadata,
|
|
|
|
|
fields: original_fields,
|
|
|
|
|
bytes: original_bytes,
|
|
|
|
|
},
|
|
|
|
|
UnitySerializedValue::ManagedReference {
|
|
|
|
|
type_name: rebuilt_type,
|
|
|
|
|
metadata: rebuilt_metadata,
|
|
|
|
|
fields: rebuilt_fields,
|
|
|
|
|
bytes: rebuilt_bytes,
|
|
|
|
|
},
|
|
|
|
|
) => {
|
|
|
|
|
if original_type != rebuilt_type
|
|
|
|
|
|| original_metadata != rebuilt_metadata
|
|
|
|
|
|| original_bytes != rebuilt_bytes
|
|
|
|
|
{
|
|
|
|
|
return Err(AssetBundleError::Parse(format!(
|
|
|
|
|
"Unity object path_id {path_id} changed managed-reference metadata"
|
|
|
|
|
)));
|
|
|
|
|
}
|
|
|
|
|
verify_unmodified_fields(
|
|
|
|
|
original_fields,
|
|
|
|
|
rebuilt_fields,
|
|
|
|
|
modified_field_path,
|
|
|
|
|
path_id,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
(
|
|
|
|
|
UnitySerializedValue::ManagedReferenceRegistry {
|
|
|
|
|
references: _,
|
|
|
|
|
fields: original_fields,
|
|
|
|
|
},
|
|
|
|
|
UnitySerializedValue::ManagedReferenceRegistry {
|
|
|
|
|
references: _,
|
|
|
|
|
fields: rebuilt_fields,
|
|
|
|
|
},
|
|
|
|
|
) => verify_unmodified_fields(
|
|
|
|
|
original_fields,
|
|
|
|
|
rebuilt_fields,
|
|
|
|
|
modified_field_path,
|
|
|
|
|
path_id,
|
|
|
|
|
),
|
|
|
|
|
(original, rebuilt) if original != rebuilt => Err(AssetBundleError::Parse(format!(
|
|
|
|
|
"Unity object path_id {path_id} changed an unmodified field value"
|
|
|
|
|
))),
|
|
|
|
|
_ => Ok(()),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn find_field_value<'a>(
|
|
|
|
|
fields: &'a [UnitySerializedField],
|
|
|
|
|
field_path: &str,
|
|
|
|
@@ -489,6 +987,99 @@ mod tests {
|
|
|
|
|
data
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn synthetic_bundle_with_compressed_blocks(
|
|
|
|
|
files: &[(&str, &[u8])],
|
|
|
|
|
block_flags: u16,
|
|
|
|
|
header_flags: u32,
|
|
|
|
|
) -> Vec<u8> {
|
|
|
|
|
let mut uncompressed = Vec::new();
|
|
|
|
|
let mut directory_offsets = Vec::with_capacity(files.len());
|
|
|
|
|
for (_, bytes) in files {
|
|
|
|
|
directory_offsets.push(uncompressed.len() as u64);
|
|
|
|
|
uncompressed.extend_from_slice(bytes);
|
|
|
|
|
}
|
|
|
|
|
let split = uncompressed.len() / 2;
|
|
|
|
|
let split = if uncompressed.is_empty() {
|
|
|
|
|
0
|
|
|
|
|
} else {
|
|
|
|
|
split.max(1).min(uncompressed.len())
|
|
|
|
|
};
|
|
|
|
|
let chunks = [&uncompressed[..split], &uncompressed[split..]];
|
|
|
|
|
let compressed_chunks = chunks
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|chunk| match block_flags & 0x3f {
|
|
|
|
|
0 => chunk.to_vec(),
|
|
|
|
|
1 => {
|
|
|
|
|
let mut output = Vec::new();
|
|
|
|
|
lzma_rs::lzma_compress(&mut std::io::Cursor::new(chunk), &mut output).unwrap();
|
|
|
|
|
output
|
|
|
|
|
}
|
|
|
|
|
2..=4 => lz4::block::compress(chunk, None, false).unwrap(),
|
|
|
|
|
value => panic!("unsupported fixture compression {value}"),
|
|
|
|
|
})
|
|
|
|
|
.collect::<Vec<_>>();
|
|
|
|
|
|
|
|
|
|
let mut block_info_body = Vec::new();
|
|
|
|
|
push_i32_be(&mut block_info_body, chunks.len() as i32);
|
|
|
|
|
for (chunk, compressed) in chunks.iter().zip(&compressed_chunks) {
|
|
|
|
|
push_u32_be(&mut block_info_body, chunk.len() as u32);
|
|
|
|
|
push_u32_be(&mut block_info_body, compressed.len() as u32);
|
|
|
|
|
push_u16_be(&mut block_info_body, block_flags);
|
|
|
|
|
}
|
|
|
|
|
push_i32_be(&mut block_info_body, files.len() as i32);
|
|
|
|
|
for ((path, bytes), offset) in files.iter().zip(directory_offsets) {
|
|
|
|
|
push_u64_be(&mut block_info_body, offset);
|
|
|
|
|
push_u64_be(&mut block_info_body, bytes.len() as u64);
|
|
|
|
|
push_u32_be(&mut block_info_body, 0);
|
|
|
|
|
push_c_string(&mut block_info_body, path);
|
|
|
|
|
}
|
|
|
|
|
let mut block_info = vec![0; 16];
|
|
|
|
|
block_info.extend_from_slice(&block_info_body);
|
|
|
|
|
let block_info = match header_flags & 0x3f {
|
|
|
|
|
0 => block_info,
|
|
|
|
|
1 => {
|
|
|
|
|
let mut output = Vec::new();
|
|
|
|
|
lzma_rs::lzma_compress(&mut std::io::Cursor::new(&block_info), &mut output)
|
|
|
|
|
.unwrap();
|
|
|
|
|
output
|
|
|
|
|
}
|
|
|
|
|
2..=4 => lz4::block::compress(&block_info, None, false).unwrap(),
|
|
|
|
|
value => panic!("unsupported fixture block-info compression {value}"),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let mut data = Vec::new();
|
|
|
|
|
push_c_string(&mut data, "UnityFS");
|
|
|
|
|
push_u32_be(&mut data, 8);
|
|
|
|
|
push_c_string(&mut data, "5.x.x");
|
|
|
|
|
push_c_string(&mut data, "2021.3.56f2");
|
|
|
|
|
let total_size_offset = data.len();
|
|
|
|
|
push_u64_be(&mut data, 0);
|
|
|
|
|
push_u32_be(&mut data, block_info.len() as u32);
|
|
|
|
|
push_u32_be(&mut data, (16 + block_info_body.len()) as u32);
|
|
|
|
|
push_u32_be(&mut data, header_flags);
|
|
|
|
|
align_vec(&mut data, 16);
|
|
|
|
|
if header_flags & 0x80 != 0 {
|
|
|
|
|
if header_flags & 0x200 != 0 {
|
|
|
|
|
align_vec(&mut data, 16);
|
|
|
|
|
}
|
|
|
|
|
for compressed in &compressed_chunks {
|
|
|
|
|
data.extend_from_slice(compressed);
|
|
|
|
|
}
|
|
|
|
|
data.extend_from_slice(&block_info);
|
|
|
|
|
} else {
|
|
|
|
|
data.extend_from_slice(&block_info);
|
|
|
|
|
if header_flags & 0x200 != 0 {
|
|
|
|
|
align_vec(&mut data, 16);
|
|
|
|
|
}
|
|
|
|
|
for compressed in &compressed_chunks {
|
|
|
|
|
data.extend_from_slice(compressed);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
let total_size = data.len() as u64;
|
|
|
|
|
data[total_size_offset..total_size_offset + 8].copy_from_slice(&total_size.to_be_bytes());
|
|
|
|
|
data
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn push_i16_le(data: &mut Vec<u8>, value: i16) {
|
|
|
|
|
data.extend_from_slice(&value.to_le_bytes());
|
|
|
|
|
}
|
|
|
|
@@ -549,6 +1140,66 @@ mod tests {
|
|
|
|
|
file
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn synthetic_serialized_two_text_assets(first: &[u8], second: &[u8]) -> Vec<u8> {
|
|
|
|
|
fn text_asset_object(bytes: &[u8]) -> Vec<u8> {
|
|
|
|
|
let mut object = Vec::new();
|
|
|
|
|
push_u32_le(&mut object, 8);
|
|
|
|
|
object.extend_from_slice(b"Scenario");
|
|
|
|
|
align_vec(&mut object, 4);
|
|
|
|
|
push_u32_le(&mut object, bytes.len() as u32);
|
|
|
|
|
object.extend_from_slice(bytes);
|
|
|
|
|
object
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let first_object = text_asset_object(first);
|
|
|
|
|
let second_object = text_asset_object(second);
|
|
|
|
|
let mut object_data = Vec::new();
|
|
|
|
|
let first_offset = object_data.len() as u64;
|
|
|
|
|
object_data.extend_from_slice(&first_object);
|
|
|
|
|
let first_size = object_data.len() as u64 - first_offset;
|
|
|
|
|
let second_offset = object_data.len() as u64;
|
|
|
|
|
object_data.extend_from_slice(&second_object);
|
|
|
|
|
let second_size = object_data.len() as u64 - second_offset;
|
|
|
|
|
|
|
|
|
|
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, 2);
|
|
|
|
|
align_vec(&mut metadata, 4);
|
|
|
|
|
for (path_id, offset, size) in [
|
|
|
|
|
(1i64, first_offset, first_size),
|
|
|
|
|
(2, second_offset, second_size),
|
|
|
|
|
] {
|
|
|
|
|
metadata.extend_from_slice(&path_id.to_le_bytes());
|
|
|
|
|
push_u64_le(&mut metadata, offset);
|
|
|
|
|
push_u32_le(&mut metadata, size 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_be(&mut file, metadata.len() as u32);
|
|
|
|
|
push_u32_be(&mut file, file_size as u32);
|
|
|
|
|
push_u32_be(&mut file, 22);
|
|
|
|
|
push_u32_be(&mut file, 0);
|
|
|
|
|
file.extend_from_slice(&[0, 0, 0, 0]);
|
|
|
|
|
push_u32_be(&mut file, metadata.len() as u32);
|
|
|
|
|
push_u64_be(&mut file, file_size as u64);
|
|
|
|
|
push_u64_be(&mut file, data_offset as u64);
|
|
|
|
|
push_u64_be(&mut file, 0);
|
|
|
|
|
file.extend_from_slice(&metadata);
|
|
|
|
|
file.extend_from_slice(&object_data);
|
|
|
|
|
file
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn synthetic_serialized_monobehaviour() -> Vec<u8> {
|
|
|
|
|
let mut object_data = Vec::new();
|
|
|
|
|
push_u32_le(&mut object_data, 5);
|
|
|
|
@@ -1391,6 +2042,135 @@ mod tests {
|
|
|
|
|
assert_eq!(parsed.unity_version, reparsed.unity_version);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn rebuild_preserves_compressed_blocks_alignment_and_untouched_files() {
|
|
|
|
|
let serialized = synthetic_serialized_text_asset(b"old");
|
|
|
|
|
let source = synthetic_bundle_with_compressed_blocks(
|
|
|
|
|
&[("CAB-scenario", &serialized), ("CAB-untouched", b"keep")],
|
|
|
|
|
2,
|
|
|
|
|
2 | 0x200,
|
|
|
|
|
);
|
|
|
|
|
let parsed = UnityFsParser::new().parse_bytes(&source).unwrap();
|
|
|
|
|
assert_eq!(parsed.blocks.len(), 2);
|
|
|
|
|
assert_eq!(parsed.blocks[0].compression, UnityFsCompression::Lz4);
|
|
|
|
|
assert_eq!(parsed.header.flags, 2 | 0x200);
|
|
|
|
|
|
|
|
|
|
let patched = patch_unityfs_text_asset(
|
|
|
|
|
&source,
|
|
|
|
|
&TextAssetPatch {
|
|
|
|
|
serialized_file_path: "CAB-scenario".to_string(),
|
|
|
|
|
path_id: 1,
|
|
|
|
|
expected_name: Some("Scenario".to_string()),
|
|
|
|
|
replacement: b"a longer localized payload".to_vec(),
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
let reparsed = UnityFsParser::new().parse_bytes(&patched).unwrap();
|
|
|
|
|
|
|
|
|
|
assert_eq!(reparsed.blocks.len(), 2);
|
|
|
|
|
assert_eq!(reparsed.blocks[0].compression, UnityFsCompression::Lz4);
|
|
|
|
|
assert_eq!(reparsed.blocks[1].compression, UnityFsCompression::Lz4);
|
|
|
|
|
assert_eq!(reparsed.header.flags, parsed.header.flags);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
reparsed
|
|
|
|
|
.files
|
|
|
|
|
.iter()
|
|
|
|
|
.find(|file| file.path == "CAB-untouched")
|
|
|
|
|
.unwrap()
|
|
|
|
|
.data,
|
|
|
|
|
b"keep"
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(reparsed.text_assets[0].bytes, b"a longer localized payload");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn rebuild_preserves_unmodified_serialized_objects() {
|
|
|
|
|
let serialized = synthetic_serialized_two_text_assets(b"old", b"keep-object");
|
|
|
|
|
let source = synthetic_bundle_with_path(&serialized, "CAB-scenario");
|
|
|
|
|
let patched = patch_unityfs_text_asset(
|
|
|
|
|
&source,
|
|
|
|
|
&TextAssetPatch {
|
|
|
|
|
serialized_file_path: "CAB-scenario".to_string(),
|
|
|
|
|
path_id: 1,
|
|
|
|
|
expected_name: Some("Scenario".to_string()),
|
|
|
|
|
replacement: b"localized".to_vec(),
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
let reparsed = UnityFsParser::new().parse_bytes(&patched).unwrap();
|
|
|
|
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
reparsed
|
|
|
|
|
.text_assets
|
|
|
|
|
.iter()
|
|
|
|
|
.find(|asset| asset.path_id == 1)
|
|
|
|
|
.unwrap()
|
|
|
|
|
.bytes,
|
|
|
|
|
b"localized"
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
reparsed
|
|
|
|
|
.text_assets
|
|
|
|
|
.iter()
|
|
|
|
|
.find(|asset| asset.path_id == 2)
|
|
|
|
|
.unwrap()
|
|
|
|
|
.bytes,
|
|
|
|
|
b"keep-object"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn rebuild_preserves_block_info_at_end_and_lzma_compression() {
|
|
|
|
|
let serialized = synthetic_serialized_text_asset(b"old");
|
|
|
|
|
let source = synthetic_bundle_with_compressed_blocks(
|
|
|
|
|
&[("CAB-scenario", &serialized)],
|
|
|
|
|
1,
|
|
|
|
|
1 | 0x80 | 0x200,
|
|
|
|
|
);
|
|
|
|
|
let patched = patch_unityfs_text_asset(
|
|
|
|
|
&source,
|
|
|
|
|
&TextAssetPatch {
|
|
|
|
|
serialized_file_path: "CAB-scenario".to_string(),
|
|
|
|
|
path_id: 1,
|
|
|
|
|
expected_name: None,
|
|
|
|
|
replacement: b"localized".to_vec(),
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
let reparsed = UnityFsParser::new().parse_bytes(&patched).unwrap();
|
|
|
|
|
|
|
|
|
|
assert_eq!(reparsed.header.flags, 1 | 0x80 | 0x200);
|
|
|
|
|
assert_eq!(reparsed.blocks[0].compression, UnityFsCompression::Lzma);
|
|
|
|
|
assert_eq!(reparsed.blocks[1].compression, UnityFsCompression::Lzma);
|
|
|
|
|
assert_eq!(reparsed.text_assets[0].bytes, b"localized");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn rebuild_rejects_unknown_compression_without_guessing() {
|
|
|
|
|
let source = synthetic_bundle(b"payload");
|
|
|
|
|
let mut parsed = UnityFsParser::new().parse_bytes(&source).unwrap();
|
|
|
|
|
parsed.blocks[0].compression = UnityFsCompression::Unknown(63);
|
|
|
|
|
|
|
|
|
|
let error = rebuild_unityfs_bundle(&parsed).unwrap_err();
|
|
|
|
|
|
|
|
|
|
assert!(matches!(
|
|
|
|
|
error,
|
|
|
|
|
AssetBundleError::UnsupportedFormat(message)
|
|
|
|
|
if message.contains("unsupported UnityFS data block 0 compression flag: 63")
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn rebuild_rejects_inconsistent_uncompressed_block_table() {
|
|
|
|
|
let source = synthetic_bundle(b"payload");
|
|
|
|
|
let mut parsed = UnityFsParser::new().parse_bytes(&source).unwrap();
|
|
|
|
|
parsed.blocks[0].uncompressed_size += 1;
|
|
|
|
|
|
|
|
|
|
let error = rebuild_unityfs_bundle(&parsed).unwrap_err().to_string();
|
|
|
|
|
|
|
|
|
|
assert!(error.contains("block table/data size mismatch"), "{error}");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn patches_text_asset_and_verifies_reparsed_payload() {
|
|
|
|
|
let original_text = "こんにちは".as_bytes();
|
|
|
|
|