mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-18 10:00:40 +08:00
fix: 完成下载并发与翻译交接链路
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
//! 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],
|
||||
}
|
||||
|
||||
/// Region/backend contract used by official resource orchestration.
|
||||
pub trait OfficialResourceBackend: 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>;
|
||||
|
||||
/// 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;
|
||||
}
|
||||
|
||||
/// 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 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 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)
|
||||
}
|
||||
}
|
||||
|
||||
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('#') || 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.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_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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user