mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-18 06:34:54 +08:00
355 lines
12 KiB
Rust
355 lines
12 KiB
Rust
//! 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<SidecarHashVerification, String>;
|
||
}
|
||
|
||
/// 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<SidecarHashVerification, String> {
|
||
let expected = std::str::from_utf8(sidecar)
|
||
.map_err(|error| format!("官方 hash sidecar 不是 UTF-8:{error}"))?
|
||
.trim()
|
||
.parse::<u32>()
|
||
.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<String, String>;
|
||
|
||
/// 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<YostarJpResourceDiscoveryPlan, String>;
|
||
|
||
/// 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<PathBuf, String>;
|
||
}
|
||
|
||
/// 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<String, String> {
|
||
server_info_url(file_name)
|
||
}
|
||
|
||
fn discovery_plan(
|
||
&self,
|
||
server_info: &YostarJpServerInfo,
|
||
connection_group: &str,
|
||
app_version: &str,
|
||
platforms: &[PatchPlatform],
|
||
) -> Result<YostarJpResourceDiscoveryPlan, String> {
|
||
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<PathBuf, String> {
|
||
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, String> {
|
||
YostarJpResourceRoot::from_addressables_root(addressables_root)
|
||
}
|
||
}
|
||
|
||
fn sanitize_component(component: &str, url: &str) -> Result<String, String> {
|
||
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<PathBuf, String> {
|
||
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"));
|
||
}
|
||
}
|