//! 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; } /// 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 { 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 { self.parse(&bundle.data) } /// Parses UnityFS bytes into the engine-level container representation. pub fn parse_bytes(&self, data: &[u8]) -> Result { 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 { 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 { 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 { 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> { 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> { 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> { 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> { 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, Vec, Vec, ) { 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, Vec)> { 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 { 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 { 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 { let bytes = self.read_bytes(2, field)?; Ok(u16::from_be_bytes([bytes[0], bytes[1]])) } fn read_u32(&mut self, field: &str) -> Result { 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 { 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 { 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 { 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, value: &str) { data.extend_from_slice(value.as_bytes()); data.push(0); } fn push_u16(data: &mut Vec, value: u16) { data.extend_from_slice(&value.to_be_bytes()); } fn push_u32(data: &mut Vec, value: u32) { data.extend_from_slice(&value.to_be_bytes()); } fn push_i32(data: &mut Vec, value: i32) { data.extend_from_slice(&value.to_be_bytes()); } fn push_u64(data: &mut Vec, value: u64) { data.extend_from_slice(&value.to_be_bytes()); } fn push_i16_le(data: &mut Vec, value: i16) { data.extend_from_slice(&value.to_le_bytes()); } fn push_u32_le(data: &mut Vec, value: u32) { data.extend_from_slice(&value.to_le_bytes()); } fn push_i32_le(data: &mut Vec, value: i32) { data.extend_from_slice(&value.to_le_bytes()); } fn push_i64_le(data: &mut Vec, value: i64) { data.extend_from_slice(&value.to_le_bytes()); } fn push_u64_le(data: &mut Vec, value: u64) { data.extend_from_slice(&value.to_le_bytes()); } fn align(data: &mut Vec, alignment: usize) { let remainder = data.len() % alignment; if remainder != 0 { data.resize(data.len() + alignment - remainder, 0); } } fn blocks_info(directory_size: u64) -> Vec { 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 { 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 { 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 { 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 { 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 { 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}" ); } }