//! Official resource backend seams. //! //! The update pipeline consumes these small contracts instead of depending on //! one region's URL and catalog rules everywhere. The JP implementation is //! the only production adapter today; adding another region should implement //! this module's contracts without changing downloader orchestration. use super::inventory::{YostarJpPlatformCatalogInventory, YostarJpPlatformDownloadInventory}; use super::yostar_jp::{ is_official_yostar_jp_url, server_info_url, PatchPlatform, YostarJpResourceDiscoveryPlan, YostarJpResourceRoot, YostarJpServerInfo, }; use std::path::{Path, PathBuf}; /// Catalog bytes required to build one platform's download inventory. #[derive(Debug, Clone, Copy)] pub struct PlatformCatalogInput<'a> { /// Platform represented by the catalog. pub platform: PatchPlatform, /// `BundlePackingInfo.bytes` payload. pub bundle_packing_info: &'a [u8], /// `MediaCatalog.bytes` payload. 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: InventoryParser + Send + Sync { /// Stable backend identifier persisted in diagnostics. fn backend_id(&self) -> &'static str; /// Builds the server-info URL from an official metadata file name. fn server_info_url(&self, file_name: &str) -> Result; /// Selects a discovery plan from server-info and requested platforms. fn discovery_plan( &self, server_info: &YostarJpServerInfo, connection_group: &str, app_version: &str, platforms: &[PatchPlatform], ) -> Result; /// Validates that a URL belongs to this backend's official hosts. fn is_official_url(&self, url: &str) -> bool; } /// URL-to-destination mapping contract for a resource backend. pub trait DownloadUrlMapper: Send + Sync { /// Maps an official HTTPS URL to a relative release destination. fn relative_destination(&self, url: &str) -> Result; } /// The currently supported official Blue Archive JP backend. #[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" } fn server_info_url(&self, file_name: &str) -> Result { server_info_url(file_name) } fn discovery_plan( &self, server_info: &YostarJpServerInfo, connection_group: &str, app_version: &str, platforms: &[PatchPlatform], ) -> Result { server_info.discovery_plan(connection_group, app_version, platforms) } 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 .strip_prefix("https://") .ok_or_else(|| format!("官方 URL 必须使用 https:{url}"))?; let (host, path) = rest .split_once('/') .ok_or_else(|| format!("官方 URL 缺少路径:{url}"))?; let mut destination = PathBuf::from(sanitize_component(host, url)?); for segment in path.split('/') { if segment.is_empty() { continue; } destination.push(sanitize_component(segment, url)?); } Ok(destination) } } impl YostarJpBackend { /// Returns the validated resource-root builder for an official root. pub fn resource_root(&self, addressables_root: &str) -> Result { YostarJpResourceRoot::from_addressables_root(addressables_root) } } fn sanitize_component(component: &str, url: &str) -> Result { if component == "." || component == ".." || component.is_empty() { return Err(format!("官方 URL 包含不安全路径片段:{url}")); } if component.contains('?') || component.contains('#') { return Err(format!( "官方资源 URL 包含 query 或 fragment 等不安全路径字符:{url}" )); } if component.contains('\\') { return Err(format!("官方资源 URL 包含不安全路径字符:{url}")); } Ok(component.to_string()) } /// Joins a backend-relative destination below an output root. pub fn destination_under_root(root: &Path, relative: &Path) -> Result { if relative.is_absolute() { return Err(format!( "backend destination must be relative: {}", relative.display() )); } let destination = root.join(relative); if destination .components() .any(|component| matches!(component, std::path::Component::ParentDir)) { return Err(format!( "backend destination escapes output root: {}", relative.display() )); } Ok(destination) } #[cfg(test)] mod tests { use super::*; #[test] fn jp_backend_keeps_discovery_and_inventory_rules_in_one_adapter() { let backend = YostarJpBackend; let server_info = YostarJpServerInfo::from_json( r#"{"ConnectionGroups":[{"Name":"Prod","AddressablesCatalogUrlRoot":"https://prod-clientpatch.bluearchiveyostar.com/r93_fixture"}]}"#, ) .unwrap(); let plan = backend .discovery_plan(&server_info, "Prod", "1.70.0", &[PatchPlatform::Windows]) .unwrap(); assert_eq!(backend.backend_id(), "bluearchive.yostar.jp"); assert!(backend.is_official_url(&plan.endpoints[0].url)); let inventory = backend.parse_inventory( b"ExcelDB.db ExcelDB.db", &[PlatformCatalogInput { platform: PatchPlatform::Windows, bundle_packing_info: b"FullPatch_000.zip", media_catalog: b"GameData/Audio/JP.zip", }], ); assert_eq!(inventory.table_file_names, vec!["ExcelDB.db"]); 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; assert_eq!( backend .relative_destination( "https://prod-clientpatch.bluearchiveyostar.com/r93/TableBundles/a.bytes" ) .unwrap(), PathBuf::from("prod-clientpatch.bluearchiveyostar.com/r93/TableBundles/a.bytes") ); assert!(backend .relative_destination("https://prod-clientpatch.bluearchiveyostar.com/r93/../secret") .is_err()); assert!(!backend.is_official_url("https://example.invalid/a")); } }