diff --git a/adapters/src/official/backend.rs b/adapters/src/official/backend.rs index f45454f..d1399d5 100644 --- a/adapters/src/official/backend.rs +++ b/adapters/src/official/backend.rs @@ -23,8 +23,74 @@ pub struct PlatformCatalogInput<'a> { pub media_catalog: &'a [u8], } +/// Platform catalog parser selected by an official resource backend. +pub trait InventoryParser: Send + Sync { + /// Parses verified seed catalog payloads into a platform-aware inventory. + fn parse_inventory( + &self, + table_catalog: &[u8], + platform_catalogs: &[PlatformCatalogInput<'_>], + ) -> YostarJpPlatformDownloadInventory; +} + +/// Verification result returned by a sidecar hash strategy. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SidecarHashVerification { + /// Decimal or textual expected digest parsed from the sidecar. + pub expected: String, + /// Digest computed from the resource bytes. + pub actual: String, +} + +/// Hash sidecar policy independent from download orchestration. +pub trait SidecarHashStrategy: Send + Sync { + /// Stable algorithm identifier used in diagnostics. + fn algorithm_id(&self) -> &'static str; + + /// Parses and verifies one resource payload against a sidecar. + fn verify(&self, data: &[u8], sidecar: &[u8]) -> Result; +} + +/// Official JP decimal `xxHash32(seed=0)` sidecar strategy. +#[derive(Debug, Clone, Copy, Default)] +pub struct XxHash32DecimalSeedZero; + +impl XxHash32DecimalSeedZero { + /// Computes the decimal digest used by this sidecar strategy. + pub fn digest(self, data: &[u8]) -> String { + xxhash32(data).to_string() + } +} + +impl SidecarHashStrategy for XxHash32DecimalSeedZero { + fn algorithm_id(&self) -> &'static str { + "xxhash32_decimal" + } + + fn verify(&self, data: &[u8], sidecar: &[u8]) -> Result { + let expected = std::str::from_utf8(sidecar) + .map_err(|error| format!("官方 hash sidecar 不是 UTF-8:{error}"))? + .trim() + .parse::() + .map_err(|error| format!("官方 hash sidecar 不是十进制 xxHash32:{error}"))?; + let actual = self.digest(data); + let mismatch = expected.to_string() != actual; + let verification = SidecarHashVerification { + expected: expected.to_string(), + actual, + }; + if mismatch { + return Err(format!( + "官方 hash 校验失败:期望 {},实际 {}", + verification.expected, verification.actual + )); + } + Ok(verification) + } +} + /// Region/backend contract used by official resource orchestration. -pub trait OfficialResourceBackend: Send + Sync { +pub trait OfficialResourceBackend: InventoryParser + Send + Sync { /// Stable backend identifier persisted in diagnostics. fn backend_id(&self) -> &'static str; @@ -40,13 +106,6 @@ pub trait OfficialResourceBackend: Send + Sync { platforms: &[PatchPlatform], ) -> Result; - /// Builds platform-aware inventory from the verified seed catalog bytes. - fn inventory( - &self, - table_catalog: &[u8], - platform_catalogs: &[PlatformCatalogInput<'_>], - ) -> YostarJpPlatformDownloadInventory; - /// Validates that a URL belongs to this backend's official hosts. fn is_official_url(&self, url: &str) -> bool; } @@ -61,6 +120,26 @@ pub trait DownloadUrlMapper: Send + Sync { #[derive(Debug, Clone, Copy, Default)] pub struct YostarJpBackend; +impl InventoryParser for YostarJpBackend { + fn parse_inventory( + &self, + table_catalog: &[u8], + platform_catalogs: &[PlatformCatalogInput<'_>], + ) -> YostarJpPlatformDownloadInventory { + let catalogs = platform_catalogs + .iter() + .map(|catalog| { + YostarJpPlatformCatalogInventory::from_catalog_bytes( + catalog.platform, + catalog.bundle_packing_info, + catalog.media_catalog, + ) + }) + .collect(); + YostarJpPlatformDownloadInventory::from_catalog_bytes(table_catalog, catalogs) + } +} + impl OfficialResourceBackend for YostarJpBackend { fn backend_id(&self) -> &'static str { "bluearchive.yostar.jp" @@ -80,29 +159,77 @@ impl OfficialResourceBackend for YostarJpBackend { server_info.discovery_plan(connection_group, app_version, platforms) } - fn inventory( - &self, - table_catalog: &[u8], - platform_catalogs: &[PlatformCatalogInput<'_>], - ) -> YostarJpPlatformDownloadInventory { - let catalogs = platform_catalogs - .iter() - .map(|catalog| { - YostarJpPlatformCatalogInventory::from_catalog_bytes( - catalog.platform, - catalog.bundle_packing_info, - catalog.media_catalog, - ) - }) - .collect(); - YostarJpPlatformDownloadInventory::from_catalog_bytes(table_catalog, catalogs) - } - fn is_official_url(&self, url: &str) -> bool { is_official_yostar_jp_url(url) } } +fn xxhash32(bytes: &[u8]) -> u32 { + const PRIME1: u32 = 0x9E37_79B1; + const PRIME2: u32 = 0x85EB_CA77; + const PRIME3: u32 = 0xC2B2_AE3D; + const PRIME4: u32 = 0x27D4_EB2F; + const PRIME5: u32 = 0x1656_67B1; + + let len = bytes.len(); + let mut index = 0usize; + let mut hash = if len >= 16 { + let mut v1 = PRIME1.wrapping_add(PRIME2); + let mut v2 = PRIME2; + let mut v3 = 0; + let mut v4 = 0u32.wrapping_sub(PRIME1); + while index + 16 <= len { + v1 = xxhash32_round(v1, read_u32_le(bytes, index)); + v2 = xxhash32_round(v2, read_u32_le(bytes, index + 4)); + v3 = xxhash32_round(v3, read_u32_le(bytes, index + 8)); + v4 = xxhash32_round(v4, read_u32_le(bytes, index + 12)); + index += 16; + } + v1.rotate_left(1) + .wrapping_add(v2.rotate_left(7)) + .wrapping_add(v3.rotate_left(12)) + .wrapping_add(v4.rotate_left(18)) + } else { + PRIME5 + } + .wrapping_add(len as u32); + + while index + 4 <= len { + hash = hash + .wrapping_add(read_u32_le(bytes, index).wrapping_mul(PRIME3)) + .rotate_left(17) + .wrapping_mul(PRIME4); + index += 4; + } + while index < len { + hash = hash + .wrapping_add((bytes[index] as u32).wrapping_mul(PRIME5)) + .rotate_left(11) + .wrapping_mul(PRIME1); + index += 1; + } + hash ^= hash >> 15; + hash = hash.wrapping_mul(PRIME2); + hash ^= hash >> 13; + hash = hash.wrapping_mul(PRIME3); + hash ^ (hash >> 16) +} + +fn xxhash32_round(acc: u32, input: u32) -> u32 { + acc.wrapping_add(input.wrapping_mul(0x85EB_CA77)) + .rotate_left(13) + .wrapping_mul(0x9E37_79B1) +} + +fn read_u32_le(bytes: &[u8], offset: usize) -> u32 { + u32::from_le_bytes([ + bytes[offset], + bytes[offset + 1], + bytes[offset + 2], + bytes[offset + 3], + ]) +} + impl DownloadUrlMapper for YostarJpBackend { fn relative_destination(&self, url: &str) -> Result { let rest = url @@ -182,7 +309,7 @@ mod tests { assert_eq!(backend.backend_id(), "bluearchive.yostar.jp"); assert!(backend.is_official_url(&plan.endpoints[0].url)); - let inventory = backend.inventory( + let inventory = backend.parse_inventory( b"ExcelDB.db ExcelDB.db", &[PlatformCatalogInput { platform: PatchPlatform::Windows, @@ -194,6 +321,20 @@ mod tests { assert_eq!(inventory.platform_catalogs.len(), 1); } + #[test] + fn jp_hash_strategy_verifies_decimal_xxhash32_sidecars() { + let strategy = XxHash32DecimalSeedZero; + assert_eq!(strategy.algorithm_id(), "xxhash32_decimal"); + assert_eq!( + strategy.verify(b"", b"46947589").unwrap(), + SidecarHashVerification { + expected: "46947589".to_string(), + actual: "46947589".to_string(), + } + ); + assert!(strategy.verify(b"changed", b"46947589").is_err()); + } + #[test] fn jp_backend_maps_and_rejects_unsafe_destinations() { let backend = YostarJpBackend; diff --git a/adapters/src/official/mod.rs b/adapters/src/official/mod.rs index 755873a..8311d0b 100644 --- a/adapters/src/official/mod.rs +++ b/adapters/src/official/mod.rs @@ -11,7 +11,8 @@ pub mod launcher; pub mod yostar_jp; pub use backend::{ - destination_under_root, DownloadUrlMapper, OfficialResourceBackend, PlatformCatalogInput, + destination_under_root, DownloadUrlMapper, InventoryParser, OfficialResourceBackend, + PlatformCatalogInput, SidecarHashStrategy, SidecarHashVerification, XxHash32DecimalSeedZero, YostarJpBackend, }; pub use game_main_config::YostarJpGameMainConfig; diff --git a/infrastructure/src/official_download.rs b/infrastructure/src/official_download.rs index 2f89b44..5c546eb 100644 --- a/infrastructure/src/official_download.rs +++ b/infrastructure/src/official_download.rs @@ -15,7 +15,8 @@ use crate::zip_validation::{ }; use bat_adapters::official::yostar_jp::YostarJpResourceEndpointKind; use bat_adapters::official::{ - destination_under_root, DownloadUrlMapper, OfficialResourceBackend, YostarJpBackend, + destination_under_root, DownloadUrlMapper, OfficialResourceBackend, SidecarHashStrategy, + XxHash32DecimalSeedZero, YostarJpBackend, }; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, HashSet}; @@ -1835,12 +1836,15 @@ pub fn verify_official_seed_catalog_hash( hash_url: &str, hash_bytes: &[u8], ) -> Result { - let expected = parse_official_xxhash32_decimal(hash_url, hash_bytes)?; - let actual = xxhash32(data); + let strategy = XxHash32DecimalSeedZero; + let verification = strategy + .verify(data, hash_bytes) + .map_err(|error| format!("{error}:{hash_url}"))?; - if actual != expected { + if verification.expected != verification.actual { return Err(format!( - "官方 hash 校验失败 {data_url}:期望 {expected}(来自 {hash_url}),实际 {actual}" + "官方 hash 校验失败 {data_url}:期望 {}(来自 {hash_url}),实际 {}", + verification.expected, verification.actual )); } @@ -1848,8 +1852,8 @@ pub fn verify_official_seed_catalog_hash( data_url: data_url.to_string(), hash_url: hash_url.to_string(), algorithm: OfficialResourceHashAlgorithm::XxHash32Decimal, - expected: expected.to_string(), - actual: actual.to_string(), + expected: verification.expected, + actual: verification.actual, }) } @@ -2209,92 +2213,6 @@ fn blake3_file_hex(path: &Path) -> Result { Ok(hasher.finalize().to_hex().to_string()) } -fn parse_official_xxhash32_decimal(hash_url: &str, bytes: &[u8]) -> Result { - let text = std::str::from_utf8(bytes) - .map_err(|error| format!("官方 hash sidecar 不是 UTF-8:{hash_url}:{error}"))? - .trim(); - text.parse::() - .map_err(|error| format!("官方 hash sidecar 不是十进制 xxHash32:{hash_url}:{error}")) -} - -fn xxhash32(bytes: &[u8]) -> u32 { - const PRIME1: u32 = 0x9E37_79B1; - const PRIME2: u32 = 0x85EB_CA77; - const PRIME3: u32 = 0xC2B2_AE3D; - const PRIME4: u32 = 0x27D4_EB2F; - const PRIME5: u32 = 0x1656_67B1; - - let len = bytes.len(); - let mut index = 0usize; - let mut hash = if len >= 16 { - let mut v1 = PRIME1.wrapping_add(PRIME2); - let mut v2 = PRIME2; - let mut v3 = 0; - let mut v4 = 0u32.wrapping_sub(PRIME1); - - while index + 16 <= len { - v1 = xxhash32_round(v1, read_u32_le(bytes, index)); - v2 = xxhash32_round(v2, read_u32_le(bytes, index + 4)); - v3 = xxhash32_round(v3, read_u32_le(bytes, index + 8)); - v4 = xxhash32_round(v4, read_u32_le(bytes, index + 12)); - index += 16; - } - - v1.rotate_left(1) - .wrapping_add(v2.rotate_left(7)) - .wrapping_add(v3.rotate_left(12)) - .wrapping_add(v4.rotate_left(18)) - } else { - PRIME5 - } - .wrapping_add(len as u32); - - while index + 4 <= len { - hash = hash - .wrapping_add(read_u32_le(bytes, index).wrapping_mul(PRIME3)) - .rotate_left(17) - .wrapping_mul(PRIME4); - index += 4; - } - - while index < len { - hash = hash - .wrapping_add((bytes[index] as u32).wrapping_mul(PRIME5)) - .rotate_left(11) - .wrapping_mul(PRIME1); - index += 1; - } - - xxhash32_avalanche(hash) -} - -fn xxhash32_round(acc: u32, input: u32) -> u32 { - const PRIME2: u32 = 0x85EB_CA77; - const PRIME1: u32 = 0x9E37_79B1; - - acc.wrapping_add(input.wrapping_mul(PRIME2)) - .rotate_left(13) - .wrapping_mul(PRIME1) -} - -fn xxhash32_avalanche(mut hash: u32) -> u32 { - hash ^= hash >> 15; - hash = hash.wrapping_mul(0x85EB_CA77); - hash ^= hash >> 13; - hash = hash.wrapping_mul(0xC2B2_AE3D); - hash ^= hash >> 16; - hash -} - -fn read_u32_le(bytes: &[u8], offset: usize) -> u32 { - u32::from_le_bytes([ - bytes[offset], - bytes[offset + 1], - bytes[offset + 2], - bytes[offset + 3], - ]) -} - #[cfg(test)] mod tests { use super::*; @@ -2311,6 +2229,13 @@ mod tests { use std::fs; use tempfile::TempDir; + fn xxhash32(bytes: &[u8]) -> u32 { + XxHash32DecimalSeedZero + .digest(bytes) + .parse() + .expect("xxHash32 decimal digest") + } + #[test] fn fetch_bytes_maps_rejections_to_error_codes() { // 非官方 URL:安全边界拒绝。 diff --git a/infrastructure/src/official_update.rs b/infrastructure/src/official_update.rs index 95b1885..e8da844 100644 --- a/infrastructure/src/official_update.rs +++ b/infrastructure/src/official_update.rs @@ -53,7 +53,7 @@ use bat_adapters::official::yostar_jp::{ YostarJpResourceEndpointKind, YostarJpServerInfo, YostarJpSyncSnapshot, }; use bat_adapters::official::{ - OfficialResourceBackend, PlatformCatalogInput, YostarJpBackend, + InventoryParser, OfficialResourceBackend, PlatformCatalogInput, YostarJpBackend, YostarJpPlatformDownloadInventory, }; use bat_core::ErrorCode; @@ -4141,7 +4141,7 @@ fn build_inventory_from_seed_catalogs( }); } - Ok(YostarJpBackend.inventory(table_catalog, &platform_catalogs)) + Ok(YostarJpBackend.parse_inventory(table_catalog, &platform_catalogs)) } #[derive(Debug, Default)]