diff --git a/adapters/src/manifest/addressables.rs b/adapters/src/manifest/addressables.rs index 8596f6e..d0463ae 100644 --- a/adapters/src/manifest/addressables.rs +++ b/adapters/src/manifest/addressables.rs @@ -272,6 +272,12 @@ impl AddressablesCatalogDriver { .unwrap_or_default(); let dependencies = Self::dependencies_from_entry(value); + let crc = value + .get("crc") + .or_else(|| value.get("Crc")) + .or_else(|| value.get("m_Crc")) + .and_then(|value| value.as_u64()) + .and_then(|value| u32::try_from(value).ok()); Some(ResourceEntry { path: path.to_string(), @@ -280,6 +286,7 @@ impl AddressablesCatalogDriver { resource_type: Self::resource_type_for_path(path), address, dependencies, + crc, }) } @@ -376,6 +383,7 @@ impl AddressablesCatalogDriver { resource_type: Self::resource_type_for_path(path), address: None, dependencies: Vec::new(), + crc: None, }) }) .collect() @@ -413,6 +421,7 @@ impl AddressablesCatalogDriver { resource_type, address: None, dependencies: Vec::new(), + crc: None, }); } @@ -483,6 +492,7 @@ impl AddressablesCatalogDriver { Some(primary_key) }, dependencies, + crc: extra.crc, }) }) .collect() @@ -619,6 +629,11 @@ impl AddressablesCatalogDriver { .and_then(|value| value.as_str()) .map(ToOwned::to_owned), bundle_size: json.get("m_BundleSize").and_then(|value| value.as_u64()), + // m_Crc 是 bundle 的 IEEE CRC-32;0 表示不做 CRC 校验,忠实保留原值。 + crc: json + .get("m_Crc") + .and_then(|value| value.as_u64()) + .and_then(|value| u32::try_from(value).ok()), } } @@ -821,6 +836,7 @@ struct AddressablesExtraData { hash: Option, bundle_name: Option, bundle_size: Option, + crc: Option, } impl Default for AddressablesCatalogDriver { @@ -954,6 +970,7 @@ mod tests { "internal_id": "synthetic/minimal.bundle", "hash": "synthetic-entry-hash", "size": 119, + "crc": 3735928559, "address": "Character_001", "dependencies": ["synthetic/shared.bundle"] }, @@ -971,6 +988,9 @@ mod tests { assert_eq!(manifest.resources[0].path, "synthetic/minimal.bundle"); assert_eq!(manifest.resources[0].hash, "synthetic-entry-hash"); assert_eq!(manifest.resources[0].size, 119); + // m_Crc(此处 0xDEADBEEF)应被提取;缺该字段的条目为 None。 + assert_eq!(manifest.resources[0].crc, Some(0xDEAD_BEEF)); + assert_eq!(manifest.resources[1].crc, None); assert_eq!( manifest.resources[0].address.as_deref(), Some("Character_001") diff --git a/adapters/tests/golden/real_addressables.json b/adapters/tests/golden/real_addressables.json index 0d236e2..5c5efd0 100644 --- a/adapters/tests/golden/real_addressables.json +++ b/adapters/tests/golden/real_addressables.json @@ -12,7 +12,8 @@ "address": "academy-_mxload-prefabs-2025-07-02_assets_all_638981069.bundle", "dependencies": [ "shared_assets_all_123.bundle" - ] + ], + "crc": 0 }, { "path": "academy-_mxload-prefabs-2025-08-26_assets_all_1581352935.bundle", @@ -20,7 +21,8 @@ "size": 162134, "resource_type": "AssetBundle", "address": "academy-_mxload-prefabs-2025-08-26_assets_all_1581352935.bundle", - "dependencies": [] + "dependencies": [], + "crc": 0 }, { "path": "shared_assets_all_123.bundle", @@ -28,7 +30,8 @@ "size": 153480, "resource_type": "AssetBundle", "address": "shared_assets_all_123.bundle", - "dependencies": [] + "dependencies": [], + "crc": 0 } ], "metadata": { diff --git a/adapters/tests/real_addressables_golden.rs b/adapters/tests/real_addressables_golden.rs index 1824e87..e39382b 100644 --- a/adapters/tests/real_addressables_golden.rs +++ b/adapters/tests/real_addressables_golden.rs @@ -22,6 +22,7 @@ async fn parses_real_shape_addressables_catalog_against_golden() { "resource_type": format!("{:?}", resource.resource_type), "address": resource.address, "dependencies": resource.dependencies, + "crc": resource.crc, }) }).collect::>(), "metadata": manifest.metadata.extra, diff --git a/core/src/domain/mod.rs b/core/src/domain/mod.rs index 06ee801..aacc4eb 100644 --- a/core/src/domain/mod.rs +++ b/core/src/domain/mod.rs @@ -7,7 +7,7 @@ pub mod translation; pub use game_client::{ClientStatus, GameClient, GameRegion}; pub use game_version::{GameVersion, UnityVersion}; -pub use resource::{Resource, ResourceEntry, ResourceType}; +pub use resource::{crc32_ieee, IntegrityMismatch, Resource, ResourceEntry, ResourceType}; pub use translation::{ ExtractedText, SourceText, TextContext, TextMetadata, TextSource, TranslatedText, TranslationStatus, diff --git a/core/src/domain/resource.rs b/core/src/domain/resource.rs index 41ef93e..1cb7cad 100644 --- a/core/src/domain/resource.rs +++ b/core/src/domain/resource.rs @@ -34,6 +34,94 @@ pub struct ResourceEntry { pub address: Option, /// 该资源依赖的其他资源标识 pub dependencies: Vec, + /// Addressables bundle 的 CRC32(catalog 中的 `m_Crc`)。 + /// + /// `None` 表示 catalog 未提供该字段;Unity 用 `0` 表示「不做 CRC 校验」, + /// 因此 `Some(0)` 与 `None` 在校验时同样视为「无 CRC」。为向后兼容旧的 + /// 持久化数据,反序列化时缺省为 `None`。 + #[serde(default)] + pub crc: Option, +} + +/// 已下载字节与 catalog 声明的可校验字段不一致。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum IntegrityMismatch { + /// 实际字节数与声明的 `size` 不符。 + Size { + /// catalog 声明的大小。 + expected: u64, + /// 实际字节数。 + actual: u64, + }, + /// 实际 CRC32 与声明的 `crc` 不符。 + Crc { + /// catalog 声明的 CRC32。 + expected: u32, + /// 实际计算出的 CRC32。 + actual: u32, + }, +} + +impl std::fmt::Display for IntegrityMismatch { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Size { expected, actual } => { + write!(formatter, "大小不符:声明 {expected},实际 {actual}") + } + Self::Crc { expected, actual } => write!( + formatter, + "CRC32 不符:声明 {expected:#010x},实际 {actual:#010x}" + ), + } + } +} + +impl std::error::Error for IntegrityMismatch {} + +impl ResourceEntry { + /// catalog 声明的 CRC32(`m_Crc`),`0` 归一化为「无 CRC」(返回 `None`)。 + pub fn declared_crc(&self) -> Option { + self.crc.filter(|value| *value != 0) + } + + /// 用 catalog 声明的可校验字段(`size`、`crc`)校验已下载/已解出的字节。 + /// + /// - `size`:声明值为 `0` 视为未提供,跳过;否则要求与 `data.len()` 相等。 + /// - `crc`:无声明(`None`/`Some(0)`)时跳过;否则按 IEEE CRC-32 计算 `data` + /// 的 CRC 并比对。Unity AssetBundle 的 `m_Crc` 即标准 IEEE CRC-32(与 + /// zlib `crc32` 一致,UnityPy/AssetStudio 等生态一致采用)。 + /// + /// 校验通过返回 `Ok(())`;不一致返回首个失败项(先 size 后 crc)。 + pub fn verify_downloaded_bytes(&self, data: &[u8]) -> Result<(), IntegrityMismatch> { + if self.size != 0 && self.size != data.len() as u64 { + return Err(IntegrityMismatch::Size { + expected: self.size, + actual: data.len() as u64, + }); + } + if let Some(expected) = self.declared_crc() { + let actual = crc32_ieee(data); + if actual != expected { + return Err(IntegrityMismatch::Crc { expected, actual }); + } + } + Ok(()) + } +} + +/// 计算 IEEE CRC-32(多项式 `0xEDB88320`,反射,初值/终值 `0xFFFFFFFF`)。 +/// +/// 与 zlib `crc32` 及 Unity AssetBundle `m_Crc` 使用的算法一致。 +pub fn crc32_ieee(data: &[u8]) -> u32 { + let mut crc: u32 = 0xFFFF_FFFF; + for &byte in data { + crc ^= u32::from(byte); + for _ in 0..8 { + let mask = (crc & 1).wrapping_neg(); + crc = (crc >> 1) ^ (0xEDB8_8320 & mask); + } + } + !crc } /// 资源 @@ -51,18 +139,70 @@ pub struct Resource { mod tests { use super::*; - #[test] - fn test_resource_entry() { - let entry = ResourceEntry { + fn entry_with(size: u64, crc: Option) -> ResourceEntry { + ResourceEntry { path: "test.bundle".to_string(), hash: "abc123".to_string(), - size: 1024, + size, resource_type: ResourceType::AssetBundle, address: None, dependencies: Vec::new(), - }; + crc, + } + } + #[test] + fn test_resource_entry() { + let entry = entry_with(1024, None); assert_eq!(entry.path, "test.bundle"); assert_eq!(entry.size, 1024); + assert_eq!(entry.crc, None); + } + + #[test] + fn crc32_matches_known_vector() { + // 标准 IEEE CRC-32 测试向量:crc32("123456789") == 0xCBF43926。 + assert_eq!(crc32_ieee(b"123456789"), 0xCBF4_3926); + assert_eq!(crc32_ieee(b""), 0); + } + + #[test] + fn declared_crc_treats_zero_as_absent() { + assert_eq!(entry_with(0, None).declared_crc(), None); + assert_eq!(entry_with(0, Some(0)).declared_crc(), None); + assert_eq!(entry_with(0, Some(42)).declared_crc(), Some(42)); + } + + #[test] + fn verify_downloaded_bytes_checks_size_and_crc() { + let data = b"123456789"; + let crc = crc32_ieee(data); + + // size + crc 均匹配。 + assert!(entry_with(data.len() as u64, Some(crc)) + .verify_downloaded_bytes(data) + .is_ok()); + + // size=0 与 crc=0/None 视为未声明,跳过校验。 + assert!(entry_with(0, None).verify_downloaded_bytes(data).is_ok()); + assert!(entry_with(0, Some(0)).verify_downloaded_bytes(data).is_ok()); + + // size 不符。 + assert_eq!( + entry_with(3, None).verify_downloaded_bytes(data), + Err(IntegrityMismatch::Size { + expected: 3, + actual: 9 + }) + ); + + // size 通过、crc 不符。 + assert_eq!( + entry_with(data.len() as u64, Some(0xDEAD_BEEF)).verify_downloaded_bytes(data), + Err(IntegrityMismatch::Crc { + expected: 0xDEAD_BEEF, + actual: crc + }) + ); } } diff --git a/infrastructure/src/import.rs b/infrastructure/src/import.rs index 9f5f210..84d5935 100644 --- a/infrastructure/src/import.rs +++ b/infrastructure/src/import.rs @@ -425,6 +425,7 @@ mod tests { resource_type: ResourceType::AssetBundle, address: None, dependencies: Vec::new(), + crc: None, }, ResourceEntry { path: "synthetic/catalog.json".to_string(), @@ -433,6 +434,7 @@ mod tests { resource_type: ResourceType::Manifest, address: None, dependencies: Vec::new(), + crc: None, }, ResourceEntry { path: "TextAssets/dialogue.csv".to_string(), @@ -441,6 +443,7 @@ mod tests { resource_type: ResourceType::TextAsset, address: Some("dialogue".to_string()), dependencies: Vec::new(), + crc: None, }, ResourceEntry { path: "TableBundles/ExcelDB.db".to_string(), @@ -449,6 +452,7 @@ mod tests { resource_type: ResourceType::TableBundle, address: None, dependencies: Vec::new(), + crc: None, }, ResourceEntry { path: "MediaResources-Windows/voice/title.acb".to_string(), @@ -457,6 +461,7 @@ mod tests { resource_type: ResourceType::Media, address: None, dependencies: Vec::new(), + crc: None, }, ], metadata: ManifestMetadata { @@ -487,6 +492,7 @@ mod tests { resource_type: ResourceType::TextAsset, address: None, dependencies: Vec::new(), + crc: None, } } @@ -507,6 +513,7 @@ mod tests { resource_type: ResourceType::AssetBundle, address: None, dependencies: Vec::new(), + crc: None, }, ]); diff --git a/infrastructure/src/resources.rs b/infrastructure/src/resources.rs index bf551ce..909bff7 100644 --- a/infrastructure/src/resources.rs +++ b/infrastructure/src/resources.rs @@ -129,13 +129,18 @@ impl SqliteResourceRepository { resource_type TEXT NOT NULL, local_path TEXT NOT NULL, address TEXT, - dependencies_json TEXT NOT NULL DEFAULT '[]' + dependencies_json TEXT NOT NULL DEFAULT '[]', + crc INTEGER ) "#, ), ) .await?; + // 向后兼容:早于 crc 列的旧库缺少该列,按需补加(新建库已含该列, + // pragma 检查后不会重复 ALTER)。 + Self::ensure_column(&self.pool, "resources", "crc", "INTEGER").await?; + Self::execute_query( &self.pool, sqlx::query( @@ -182,6 +187,33 @@ impl SqliteResourceRepository { .map_err(|error| bat_core::Error::Other(error.into())) } + /// 幂等地为 `table` 补加 `column`(若尚不存在)。用于向后兼容的 schema 迁移。 + async fn ensure_column( + pool: &SqlitePool, + table: &str, + column: &str, + column_type: &str, + ) -> bat_core::Result<()> { + let exists: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM pragma_table_info(?1) WHERE name = ?2") + .bind(table) + .bind(column) + .fetch_one(pool) + .await + .map_err(|error| bat_core::Error::Other(error.into()))?; + if exists == 0 { + // 表名/列名/类型均为内部常量,非用户输入,可安全内插。 + Self::execute_query( + pool, + sqlx::query(&format!( + "ALTER TABLE {table} ADD COLUMN {column} {column_type}" + )), + ) + .await?; + } + Ok(()) + } + fn resource_type_to_str(resource_type: ResourceType) -> &'static str { match resource_type { ResourceType::AssetBundle => "AssetBundle", @@ -219,7 +251,8 @@ impl SqliteResourceRepository { } fn resource_from_row(row: ResourceRow) -> bat_core::Result { - let (id, path, hash, size, resource_type, local_path, address, dependencies_json) = row; + let (id, path, hash, size, resource_type, local_path, address, dependencies_json, crc) = + row; Ok(Resource { id, local_path: PathBuf::from(local_path), @@ -230,6 +263,7 @@ impl SqliteResourceRepository { resource_type: Self::resource_type_from_str(&resource_type)?, address, dependencies: Self::dependencies_from_json(&dependencies_json)?, + crc: crc.and_then(|value| u32::try_from(value).ok()), }, }) } @@ -269,7 +303,7 @@ impl SqliteResourceRepository { limit: Option, ) -> bat_core::Result> { let mut builder = QueryBuilder::::new( - "SELECT id, path, hash, size, resource_type, local_path, address, dependencies_json FROM resources", + "SELECT id, path, hash, size, resource_type, local_path, address, dependencies_json, crc FROM resources", ); Self::apply_filters(&mut builder, query)?; builder.push(" ORDER BY id"); @@ -308,9 +342,9 @@ impl ResourceRepository for SqliteResourceRepository { sqlx::query( r#" INSERT INTO resources ( - id, path, hash, size, resource_type, local_path, address, dependencies_json + id, path, hash, size, resource_type, local_path, address, dependencies_json, crc ) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) ON CONFLICT(id) DO UPDATE SET path = excluded.path, hash = excluded.hash, @@ -318,7 +352,8 @@ impl ResourceRepository for SqliteResourceRepository { resource_type = excluded.resource_type, local_path = excluded.local_path, address = excluded.address, - dependencies_json = excluded.dependencies_json + dependencies_json = excluded.dependencies_json, + crc = excluded.crc "#, ) .bind(resource.id.clone()) @@ -328,7 +363,8 @@ impl ResourceRepository for SqliteResourceRepository { .bind(Self::resource_type_to_str(resource.entry.resource_type)) .bind(resource.local_path.to_string_lossy().to_string()) .bind(resource.entry.address.clone()) - .bind(dependencies), + .bind(dependencies) + .bind(resource.entry.crc.map(i64::from)), ) .await?; @@ -338,7 +374,7 @@ impl ResourceRepository for SqliteResourceRepository { async fn find_by_id(&self, id: &str) -> bat_core::Result { let row: Option = sqlx::query_as( r#" - SELECT id, path, hash, size, resource_type, local_path, address, dependencies_json + SELECT id, path, hash, size, resource_type, local_path, address, dependencies_json, crc FROM resources WHERE id = ?1 "#, @@ -356,7 +392,7 @@ impl ResourceRepository for SqliteResourceRepository { async fn find_by_hash(&self, hash: &str) -> bat_core::Result { let row: Option = sqlx::query_as( r#" - SELECT id, path, hash, size, resource_type, local_path, address, dependencies_json + SELECT id, path, hash, size, resource_type, local_path, address, dependencies_json, crc FROM resources WHERE hash = ?1 ORDER BY id @@ -418,6 +454,7 @@ type ResourceRow = ( String, Option, String, + Option, ); fn glob_to_like(pattern: &str) -> String { @@ -505,6 +542,7 @@ mod tests { resource_type, address: None, dependencies: Vec::new(), + crc: None, }, } }