feat: prepare experiment push package

This commit is contained in:
2026-06-30 00:08:36 +08:00
parent 3c00659691
commit adc1cd5b91
45 changed files with 7858 additions and 65 deletions
+369
View File
@@ -0,0 +1,369 @@
//! 资源导入服务。
use bat_adapters::manifest::GenericManifest;
use bat_adapters::unity::{RawAssetBundle, UnityAdapterRegistry};
use bat_core::domain::{Resource, ResourceType};
use bat_core::repositories::{CasRepository, ResourceRepository};
use std::path::{Path, PathBuf};
/// 待导入的 AssetBundle 数据。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BundleSource {
/// Bundle 路径或文件名。
pub path: String,
/// Bundle 原始字节。
pub data: Vec<u8>,
}
impl BundleSource {
/// 创建 Bundle 输入。
pub fn new(path: impl Into<String>, data: impl Into<Vec<u8>>) -> Self {
Self {
path: path.into(),
data: data.into(),
}
}
}
/// 单个导入结果。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ImportedResource {
/// 写入 `ResourceRepository` 的资源 ID。
pub id: String,
/// Manifest 中的资源路径。
pub source_path: String,
/// CAS 返回的对象 ID。
pub object_id: String,
/// 写入 CAS 的字节数。
pub bytes: u64,
/// UnityFS 解析摘要。
pub unityfs: UnityFsImportSummary,
}
/// 导入时解析到的 UnityFS 基础结构摘要。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UnityFsImportSummary {
/// Bundle 声明的 Unity 版本。
pub unity_version: String,
/// UnityFS block 数量。
pub block_count: usize,
/// UnityFS directory 数量。
pub directory_count: usize,
/// UnityFS directory 路径。
pub directories: Vec<String>,
}
/// Manifest 导入报告。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ResourceImportReport {
/// 成功导入的资源。
pub imported: Vec<ImportedResource>,
/// 被跳过的非 AssetBundle 资源路径。
pub skipped: Vec<String>,
}
/// 将解析后的资源清单写入 CAS 与资源仓储的应用服务。
pub struct ResourceImportService<'a> {
cas: &'a dyn CasRepository,
resources: &'a dyn ResourceRepository,
unity_adapters: UnityAdapterRegistry,
}
impl<'a> ResourceImportService<'a> {
/// 创建导入服务。
pub fn new(cas: &'a dyn CasRepository, resources: &'a dyn ResourceRepository) -> Self {
Self::with_unity_adapters(cas, resources, UnityAdapterRegistry::with_defaults())
}
/// 使用指定 Unity 适配器注册表创建导入服务。
pub fn with_unity_adapters(
cas: &'a dyn CasRepository,
resources: &'a dyn ResourceRepository,
unity_adapters: UnityAdapterRegistry,
) -> Self {
Self {
cas,
resources,
unity_adapters,
}
}
/// 导入 manifest 中的 AssetBundle 资源。
///
/// 非 AssetBundle 条目会记录到 `skipped`。AssetBundle 条目必须能在
/// `bundles` 中按完整路径或文件名找到对应数据,否则返回错误。
pub async fn import_manifest_bundles(
&self,
manifest: &GenericManifest,
bundles: &[BundleSource],
) -> bat_core::Result<ResourceImportReport> {
let mut report = ResourceImportReport::default();
for entry in &manifest.resources {
if entry.resource_type != ResourceType::AssetBundle {
report.skipped.push(entry.path.clone());
continue;
}
let bundle = find_bundle(bundles, &entry.path).ok_or_else(|| {
bat_core::Error::InvalidArgument(format!(
"Missing bundle data for manifest resource: {}",
entry.path
))
})?;
let unityfs = self.parse_unityfs_summary(&entry.path, bundle).await?;
let object_id = self.cas.store(&bundle.data).await?;
let mut stored_entry = entry.clone();
stored_entry.hash = object_id.clone();
stored_entry.size = bundle.data.len() as u64;
let resource = Resource {
id: resource_id_for_path(&entry.path),
local_path: PathBuf::from(&entry.path),
entry: stored_entry,
};
let id = self.resources.add(resource).await?;
report.imported.push(ImportedResource {
id,
source_path: entry.path.clone(),
object_id,
bytes: bundle.data.len() as u64,
unityfs,
});
}
Ok(report)
}
async fn parse_unityfs_summary(
&self,
manifest_path: &str,
bundle: &BundleSource,
) -> bat_core::Result<UnityFsImportSummary> {
let raw_bundle = RawAssetBundle {
data: bundle.data.clone(),
path: Some(manifest_path.to_string()),
};
let adapter = self
.unity_adapters
.select_adapter(&raw_bundle)
.map_err(|error| {
bat_core::Error::InvalidArgument(format!(
"No Unity adapter for manifest resource {}: {}",
manifest_path, error
))
})?;
let parsed = adapter.parse(&raw_bundle).await.map_err(|error| {
bat_core::Error::InvalidArgument(format!(
"Invalid UnityFS bundle for manifest resource {}: {}",
manifest_path, error
))
})?;
Ok(UnityFsImportSummary {
unity_version: parsed.unity_version,
block_count: parsed.blocks.len(),
directory_count: parsed.directories.len(),
directories: parsed
.directories
.into_iter()
.map(|directory| directory.path)
.collect(),
})
}
}
fn resource_id_for_path(path: &str) -> String {
format!("resource/{}", path)
}
fn find_bundle<'a>(bundles: &'a [BundleSource], manifest_path: &str) -> Option<&'a BundleSource> {
bundles
.iter()
.find(|bundle| bundle.path == manifest_path)
.or_else(|| {
file_name(manifest_path).and_then(|manifest_name| {
bundles
.iter()
.find(|bundle| file_name(&bundle.path) == Some(manifest_name))
})
})
}
fn file_name(path: &str) -> Option<&str> {
Path::new(path).file_name()?.to_str()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{FileSystemCasRepository, InMemoryResourceRepository};
use bat_adapters::manifest::{GenericManifest, ManifestFormat, ManifestMetadata};
use bat_core::domain::ResourceEntry;
use bat_core::repositories::cas_repository::CasRepository;
use bat_core::repositories::resource_repository::{ResourceQuery, ResourceRepository};
use std::collections::HashMap;
use tempfile::TempDir;
fn push_c_string(data: &mut Vec<u8>, value: &str) {
data.extend_from_slice(value.as_bytes());
data.push(0);
}
fn push_u16(data: &mut Vec<u8>, value: u16) {
data.extend_from_slice(&value.to_be_bytes());
}
fn push_u32(data: &mut Vec<u8>, value: u32) {
data.extend_from_slice(&value.to_be_bytes());
}
fn push_i32(data: &mut Vec<u8>, value: i32) {
data.extend_from_slice(&value.to_be_bytes());
}
fn push_u64(data: &mut Vec<u8>, value: u64) {
data.extend_from_slice(&value.to_be_bytes());
}
fn align(data: &mut Vec<u8>, alignment: usize) {
let remainder = data.len() % alignment;
if remainder != 0 {
data.resize(data.len() + alignment - remainder, 0);
}
}
fn synthetic_minimal_unityfs_bundle() -> Vec<u8> {
let mut blocks_info = Vec::new();
blocks_info.extend_from_slice(&[0; 16]);
push_i32(&mut blocks_info, 1);
push_u32(&mut blocks_info, 4);
push_u32(&mut blocks_info, 4);
push_u16(&mut blocks_info, 0);
push_i32(&mut blocks_info, 1);
push_u64(&mut blocks_info, 0);
push_u64(&mut blocks_info, 4);
push_u32(&mut blocks_info, 0);
push_c_string(&mut blocks_info, "SYNTHETIC-CAB");
let mut data = Vec::new();
push_c_string(&mut data, "UnityFS");
push_u32(&mut data, 8);
push_c_string(&mut data, "5.x.x");
push_c_string(&mut data, "2021.3.56f2");
push_u64(&mut data, 0);
push_u32(&mut data, blocks_info.len() as u32);
push_u32(&mut data, blocks_info.len() as u32);
push_u32(&mut data, 0);
align(&mut data, 16);
data.extend_from_slice(&blocks_info);
data.extend_from_slice(b"data");
let total_size = data.len() as u64;
let total_size_offset = b"UnityFS\0".len() + 4 + b"5.x.x\0".len() + b"2021.3.56f2\0".len();
data[total_size_offset..total_size_offset + 8].copy_from_slice(&total_size.to_be_bytes());
data
}
fn synthetic_manifest() -> GenericManifest {
GenericManifest {
format: ManifestFormat::AddressablesCatalog,
resources: vec![
ResourceEntry {
path: "synthetic/minimal.bundle".to_string(),
hash: "synthetic-manifest-hash".to_string(),
size: 99,
resource_type: ResourceType::AssetBundle,
},
ResourceEntry {
path: "synthetic/catalog.json".to_string(),
hash: "synthetic-catalog-hash".to_string(),
size: 1,
resource_type: ResourceType::Manifest,
},
],
metadata: ManifestMetadata {
locator_id: None,
cdn_prefixes: Vec::new(),
extra: HashMap::new(),
},
}
}
#[tokio::test]
async fn imports_synthetic_manifest_bundles_into_cas_and_resource_repository() {
let temp_dir = TempDir::new().unwrap();
let cas = FileSystemCasRepository::new(temp_dir.path().join("cas"));
let resources = InMemoryResourceRepository::new();
let service = ResourceImportService::new(&cas, &resources);
let report = service
.import_manifest_bundles(
&synthetic_manifest(),
&[BundleSource::new(
"minimal.bundle",
synthetic_minimal_unityfs_bundle(),
)],
)
.await
.unwrap();
assert_eq!(report.imported.len(), 1);
assert_eq!(report.skipped, vec!["synthetic/catalog.json".to_string()]);
assert!(cas.exists(&report.imported[0].object_id).await);
assert_eq!(report.imported[0].unityfs.unity_version, "2021.3.56f2");
assert_eq!(report.imported[0].unityfs.block_count, 1);
assert_eq!(report.imported[0].unityfs.directory_count, 1);
assert_eq!(
report.imported[0].unityfs.directories,
vec!["SYNTHETIC-CAB".to_string()]
);
let indexed = resources.list(ResourceQuery::all()).await.unwrap();
assert_eq!(indexed.len(), 1);
assert_eq!(indexed[0].entry.hash, report.imported[0].object_id);
assert_eq!(
indexed[0].entry.size,
synthetic_minimal_unityfs_bundle().len() as u64
);
}
#[tokio::test]
async fn returns_error_when_bundle_data_is_missing() {
let temp_dir = TempDir::new().unwrap();
let cas = FileSystemCasRepository::new(temp_dir.path().join("cas"));
let resources = InMemoryResourceRepository::new();
let service = ResourceImportService::new(&cas, &resources);
let error = service
.import_manifest_bundles(&synthetic_manifest(), &[])
.await
.unwrap_err();
assert!(matches!(error, bat_core::Error::InvalidArgument(_)));
}
#[tokio::test]
async fn returns_error_when_bundle_is_not_unityfs() {
let temp_dir = TempDir::new().unwrap();
let cas = FileSystemCasRepository::new(temp_dir.path().join("cas"));
let resources = InMemoryResourceRepository::new();
let service = ResourceImportService::new(&cas, &resources);
let error = service
.import_manifest_bundles(
&synthetic_manifest(),
&[BundleSource::new(
"minimal.bundle",
b"not-a-unityfs-bundle".to_vec(),
)],
)
.await
.unwrap_err();
assert!(matches!(error, bat_core::Error::InvalidArgument(_)));
assert_eq!(resources.count(ResourceQuery::all()).await.unwrap(), 0);
}
}
+27
View File
@@ -11,8 +11,35 @@
#![warn(clippy::all)]
pub mod cas;
pub mod import;
pub mod official_game_main_config;
pub mod official_download;
pub mod official_launcher;
pub mod official_pull;
pub mod official_sync;
pub mod resources;
pub use cas::FileSystemCasRepository;
pub use import::{BundleSource, ImportedResource, ResourceImportReport, ResourceImportService};
pub use official_download::{
OfficialResourcePullItem, OfficialResourcePullReport, OfficialResourcePullService,
};
pub use official_game_main_config::OfficialGameMainConfigBootstrapService;
pub use official_launcher::{
is_official_launcher_package_url, launcher_api_url, launcher_authorization_header,
OfficialLauncherBootstrapService, YostarJpLauncherCdnConfig, YostarJpLauncherGameConfig,
YostarJpLauncherManifestUrl, YostarJpLauncherRemoteManifest,
};
pub use official_pull::{
build_official_pull_plan, build_official_pull_plan_for_platform_inventory,
build_official_pull_plan_for_platforms, build_official_pull_plan_from_platform_inventory,
OfficialResourcePullPlan,
};
pub use official_sync::{
build_official_sync_plan, changed_endpoint_urls, classify_sync_decision,
default_official_platforms, OfficialSyncDecision, OfficialSyncPlan,
};
pub use resources::InMemoryResourceRepository;
/// Infrastructure 版本号
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
+396
View File
@@ -0,0 +1,396 @@
//! Official JP resource download execution.
use crate::official_pull::OfficialResourcePullPlan;
use bat_adapters::official::yostar_jp::is_official_yostar_jp_url;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
/// One downloaded official resource.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OfficialResourcePullItem {
/// Original URL.
pub url: String,
/// Local destination path under the output root.
pub destination: PathBuf,
/// Downloaded byte count.
pub bytes: u64,
}
/// Download report for one executed pull plan.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct OfficialResourcePullReport {
/// Downloaded resources in execution order.
pub items: Vec<OfficialResourcePullItem>,
}
impl OfficialResourcePullReport {
/// Returns the total downloaded byte count.
pub fn total_bytes(&self) -> u64 {
self.items.iter().map(|item| item.bytes).sum()
}
}
/// Executes an official pull plan with the system `curl` command.
#[derive(Debug, Clone)]
pub struct OfficialResourcePullService {
output_root: PathBuf,
curl_command: PathBuf,
}
impl OfficialResourcePullService {
/// Creates a pull service that uses `curl` from `PATH`.
pub fn new(output_root: impl Into<PathBuf>) -> Self {
Self::with_curl_command(output_root, "curl")
}
/// Creates a pull service with an explicit `curl` binary path.
pub fn with_curl_command(
output_root: impl Into<PathBuf>,
curl_command: impl Into<PathBuf>,
) -> Self {
Self {
output_root: output_root.into(),
curl_command: curl_command.into(),
}
}
/// Returns the output root used for downloaded files.
pub fn output_root(&self) -> &Path {
&self.output_root
}
/// Executes the plan and downloads every official URL to disk.
pub fn pull(
&self,
plan: &OfficialResourcePullPlan,
) -> Result<OfficialResourcePullReport, String> {
fs::create_dir_all(&self.output_root).map_err(|error| {
format!(
"Failed to create output root {}: {error}",
self.output_root.display()
)
})?;
let mut items = Vec::new();
for url in plan.all_urls()? {
if !is_official_yostar_jp_url(&url) {
return Err(format!("Refusing to download non-official URL: {url}"));
}
let destination = self.destination_for_url(&url)?;
if let Some(parent) = destination.parent() {
fs::create_dir_all(parent).map_err(|error| {
format!(
"Failed to create destination directory {}: {error}",
parent.display()
)
})?;
}
self.download_one(&url, &destination)?;
let bytes = fs::metadata(&destination)
.map_err(|error| {
format!(
"Downloaded file missing at {}: {error}",
destination.display()
)
})?
.len();
items.push(OfficialResourcePullItem {
url,
destination,
bytes,
});
}
Ok(OfficialResourcePullReport { items })
}
/// Fetches an official URL into memory.
///
/// This is intended for small discovery inputs such as `server-info`,
/// `TableCatalog.bytes`, `BundlePackingInfo.bytes`, and
/// `MediaCatalog.bytes`.
pub fn fetch_bytes(&self, url: &str) -> Result<Vec<u8>, String> {
if !is_official_yostar_jp_url(url) {
return Err(format!("Refusing to fetch non-official URL: {url}"));
}
let output = Command::new(&self.curl_command)
.arg("--fail")
.arg("--location")
.arg("--silent")
.arg("--show-error")
.arg("--url")
.arg(url)
.output()
.map_err(|error| {
format!(
"Failed to launch curl command {}: {error}",
self.curl_command.display()
)
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("curl failed for {url}: {}", stderr.trim()));
}
Ok(output.stdout)
}
fn download_one(&self, url: &str, destination: &Path) -> Result<(), String> {
let output = Command::new(&self.curl_command)
.arg("--fail")
.arg("--location")
.arg("--silent")
.arg("--show-error")
.arg("--output")
.arg(destination)
.arg("--url")
.arg(url)
.output()
.map_err(|error| {
format!(
"Failed to launch curl command {}: {error}",
self.curl_command.display()
)
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!(
"curl failed for {url} -> {}: {}",
destination.display(),
stderr.trim()
));
}
Ok(())
}
fn destination_for_url(&self, url: &str) -> Result<PathBuf, String> {
if !is_official_yostar_jp_url(url) {
return Err(format!("URL is not an official JP host: {url}"));
}
let rest = url
.strip_prefix("https://")
.ok_or_else(|| format!("Official URL must use https: {url}"))?;
let (host, path) = rest
.split_once('/')
.ok_or_else(|| format!("Official URL has no path: {url}"))?;
let mut destination = PathBuf::from(sanitize_segment(host));
for segment in path.split('/') {
if segment.is_empty() {
continue;
}
if segment == "." || segment == ".." {
return Err(format!("Official URL has unsafe path segment: {url}"));
}
let segment = segment.split(['?', '#']).next().unwrap_or(segment);
if segment.is_empty() {
continue;
}
destination.push(sanitize_segment(segment));
}
Ok(self.output_root.join(destination))
}
}
fn sanitize_segment(segment: &str) -> String {
segment
.chars()
.map(|ch| {
if matches!(ch, ':' | '*' | '?' | '"' | '<' | '>' | '|') {
'_'
} else {
ch
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::official_pull::{
build_official_pull_plan, build_official_pull_plan_for_platforms, OfficialResourcePullPlan,
};
use bat_adapters::official::inventory::{
YostarJpDownloadInventory, YostarJpPlatformDownloadInventory,
};
use bat_adapters::official::yostar_jp::{
PatchPlatform, YostarJpResourceDiscoveryPlan, YostarJpResourceEndpoint,
YostarJpResourceEndpointKind,
};
use std::fs;
use tempfile::TempDir;
fn discovery_plan() -> YostarJpResourceDiscoveryPlan {
YostarJpResourceDiscoveryPlan {
connection_group_name: "Prod-Audit".to_string(),
app_version: "1.70.0".to_string(),
bundle_version: Some("s8tloc7lo3".to_string()),
addressables_root:
"https://prod-clientpatch.bluearchiveyostar.com/r93_token".to_string(),
endpoints: vec![
YostarJpResourceEndpoint {
kind: YostarJpResourceEndpointKind::TableCatalog,
platform: None,
url: "https://prod-clientpatch.bluearchiveyostar.com/r93_token/TableBundles/TableCatalog.bytes".to_string(),
},
YostarJpResourceEndpoint {
kind: YostarJpResourceEndpointKind::TableCatalogHash,
platform: None,
url: "https://prod-clientpatch.bluearchiveyostar.com/r93_token/TableBundles/TableCatalog.hash".to_string(),
},
],
}
}
fn inventory() -> YostarJpDownloadInventory {
YostarJpDownloadInventory::from_catalog_bytes(
b"FullPatch_000.zip",
b"ExcelDB.db",
b"JP_Airi.zip",
)
}
fn platform_inventory() -> YostarJpPlatformDownloadInventory {
YostarJpPlatformDownloadInventory::from_shared_inventory(
inventory(),
&[PatchPlatform::Windows],
)
}
fn fake_curl_script() -> &'static str {
r#"#!/bin/sh
set -eu
out=""
url=""
while [ "$#" -gt 0 ]; do
case "$1" in
--output)
out="$2"
shift 2
;;
--url)
url="$2"
shift 2
;;
*)
shift
;;
esac
done
if [ -n "$out" ]; then
mkdir -p "$(dirname "$out")"
printf '%s' "$url" > "$out"
else
printf '%s' "$url"
fi
"#
}
fn write_fake_curl(path: &Path) {
fs::write(path, fake_curl_script()).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut permissions = fs::metadata(path).unwrap().permissions();
permissions.set_mode(0o755);
fs::set_permissions(path, permissions).unwrap();
}
}
#[test]
fn downloads_verified_pull_plan_to_disk() {
let out_dir = TempDir::new().unwrap();
let bin_dir = TempDir::new().unwrap();
let curl_path = bin_dir.path().join("curl");
write_fake_curl(&curl_path);
let service = OfficialResourcePullService::with_curl_command(out_dir.path(), &curl_path);
let plan = build_official_pull_plan(discovery_plan(), inventory());
let report = service.pull(&plan).unwrap();
assert_eq!(report.items.len(), 7);
assert!(report.total_bytes() > 0);
assert_eq!(report.items[0].url, plan.discovery.endpoints[0].url);
for item in &report.items {
assert!(item.destination.exists());
assert_eq!(fs::read_to_string(&item.destination).unwrap(), item.url);
}
}
#[test]
fn custom_platform_selection_is_supported() {
let out_dir = TempDir::new().unwrap();
let bin_dir = TempDir::new().unwrap();
let curl_path = bin_dir.path().join("curl");
write_fake_curl(&curl_path);
let service = OfficialResourcePullService::with_curl_command(out_dir.path(), &curl_path);
let plan = build_official_pull_plan_for_platforms(
discovery_plan(),
inventory(),
&[PatchPlatform::Windows, PatchPlatform::Android],
);
let report = service.pull(&plan).unwrap();
assert_eq!(report.items.len(), 7);
assert!(report
.items
.iter()
.any(|item| item.url.contains("Android_PatchPack")));
}
#[test]
fn fetches_official_seed_bytes_with_curl() {
let out_dir = TempDir::new().unwrap();
let bin_dir = TempDir::new().unwrap();
let curl_path = bin_dir.path().join("curl");
write_fake_curl(&curl_path);
let service = OfficialResourcePullService::with_curl_command(out_dir.path(), &curl_path);
let bytes = service
.fetch_bytes(
"https://prod-clientpatch.bluearchiveyostar.com/r93_token/TableBundles/TableCatalog.bytes",
)
.unwrap();
assert_eq!(
String::from_utf8(bytes).unwrap(),
"https://prod-clientpatch.bluearchiveyostar.com/r93_token/TableBundles/TableCatalog.bytes"
);
}
#[test]
fn rejects_non_official_urls() {
let service = OfficialResourcePullService::new("/tmp/unused");
let plan = OfficialResourcePullPlan {
discovery: YostarJpResourceDiscoveryPlan {
connection_group_name: "Prod-Audit".to_string(),
app_version: "1.70.0".to_string(),
bundle_version: None,
addressables_root:
"https://prod-clientpatch.bluearchiveyostar.com/r93_token".to_string(),
endpoints: vec![YostarJpResourceEndpoint {
kind: YostarJpResourceEndpointKind::TableCatalog,
platform: None,
url: "https://prod-clientpatch.bluearchive.cafe/r93_token/TableBundles/TableCatalog.bytes".to_string(),
}],
},
inventory: platform_inventory(),
platforms: vec![PatchPlatform::Windows],
};
assert!(service.pull(&plan).is_err());
}
}
@@ -0,0 +1,199 @@
//! Official launcher package bootstrap for `GameMainConfig`.
use crate::official_launcher::OfficialLauncherBootstrapService;
use crate::official_launcher::launcher_package_url;
use bat_adapters::official::game_main_config::YostarJpGameMainConfig;
use crate::official_launcher::{YostarJpLauncherCdnConfig, YostarJpLauncherGameConfig};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use tempfile::TempDir;
/// Combined official bootstrap data for the latest client package.
#[derive(Debug, Clone)]
pub struct OfficialGameMainConfigBootstrap {
/// Latest launcher-reported client metadata.
pub game_config: YostarJpLauncherGameConfig,
/// Launcher CDN configuration used to resolve the package host.
pub cdn_config: YostarJpLauncherCdnConfig,
/// Official remote manifest URL used for the package chain.
pub manifest_url: String,
/// Remote manifest `source` path, if present.
pub manifest_source: Option<String>,
/// Official game ZIP URL built from the launcher CDN root.
pub game_zip_url: String,
/// Number of files declared by the remote manifest.
pub manifest_file_count: usize,
/// Decrypted `GameMainConfig`.
pub game_main_config: YostarJpGameMainConfig,
}
/// Loads and decrypts the official `GameMainConfig` by following the official
/// launcher package chain.
#[derive(Debug, Clone)]
pub struct OfficialGameMainConfigBootstrapService {
launcher: OfficialLauncherBootstrapService,
curl_command: PathBuf,
unzip_command: PathBuf,
}
impl OfficialGameMainConfigBootstrapService {
/// Creates a bootstrapper using `curl` and `unzip` from `PATH`.
pub fn new(launcher_version: impl Into<String>) -> Self {
Self::with_commands(launcher_version, "curl", "unzip")
}
/// Creates a bootstrapper with explicit commands.
pub fn with_commands(
launcher_version: impl Into<String>,
curl_command: impl Into<PathBuf>,
unzip_command: impl Into<PathBuf>,
) -> Self {
let curl_command = curl_command.into();
let unzip_command = unzip_command.into();
let launcher = OfficialLauncherBootstrapService::with_curl_command(
launcher_version,
curl_command.to_string_lossy().to_string(),
);
Self {
launcher,
curl_command,
unzip_command,
}
}
/// Fetches the latest official game ZIP by re-querying the official
/// launcher API, extracts `resources.assets`, and decrypts `GameMainConfig`.
pub fn fetch_bootstrap(&self) -> Result<OfficialGameMainConfigBootstrap, String> {
let (game_config, manifest_url, manifest) = self.launcher.fetch_latest_remote_manifest()?;
let manifest_source = manifest
.source
.clone()
.filter(|value| !value.is_empty())
.ok_or_else(|| "Official launcher remote manifest has no source".to_string())?;
let cdn_config = self.launcher.fetch_cdn_config()?;
let game_zip_url = launcher_package_url(&cdn_config.primary_cdn, &manifest_source)?;
let temp_dir = TempDir::new()
.map_err(|error| format!("Failed to create temporary directory: {error}"))?;
let archive_path = temp_dir.path().join("official-game.zip");
self.download_file_with_fallback(
&game_zip_url,
&cdn_config.back_up_cdn,
&manifest_source,
&archive_path,
)?;
let extract_root = temp_dir.path().join("extract");
fs::create_dir_all(&extract_root)
.map_err(|error| format!("Failed to create extract root: {error}"))?;
self.extract_archive(&archive_path, &extract_root)?;
let resources_assets = find_resources_assets(&extract_root)
.ok_or_else(|| "resources.assets not found in official launcher package".to_string())?;
let game_main_config = YostarJpGameMainConfig::from_resources_assets(resources_assets)?;
Ok(OfficialGameMainConfigBootstrap {
game_config,
cdn_config,
manifest_url: manifest_url.url,
manifest_source: Some(manifest_source),
game_zip_url,
manifest_file_count: manifest.files.len(),
game_main_config,
})
}
/// Fetches the latest official game ZIP, extracts `resources.assets`, and
/// decrypts `GameMainConfig`.
pub fn fetch_game_main_config(&self) -> Result<YostarJpGameMainConfig, String> {
self.fetch_bootstrap().map(|bootstrap| bootstrap.game_main_config)
}
fn download_file(&self, url: &str, destination: &Path) -> Result<(), String> {
let output = Command::new(&self.curl_command)
.arg("--fail")
.arg("--location")
.arg("--silent")
.arg("--show-error")
.arg("--output")
.arg(destination)
.arg("--url")
.arg(url)
.output()
.map_err(|error| {
format!(
"Failed to launch curl command {}: {error}",
self.curl_command.display()
)
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("curl failed for {url}: {}", stderr.trim()));
}
Ok(())
}
fn download_file_with_fallback(
&self,
primary_url: &str,
backup_cdn_root: &str,
relative_path: &str,
destination: &Path,
) -> Result<(), String> {
match self.download_file(primary_url, destination) {
Ok(()) => Ok(()),
Err(primary_error) => {
let backup_url = launcher_package_url(backup_cdn_root, relative_path)?;
self.download_file(&backup_url, destination).map_err(|backup_error| {
format!(
"failed to download official game ZIP from primary ({primary_error}) and backup ({backup_error})"
)
})
}
}
}
fn extract_archive(&self, archive_path: &Path, destination: &Path) -> Result<(), String> {
let output = Command::new(&self.unzip_command)
.arg("-q")
.arg(archive_path)
.arg("-d")
.arg(destination)
.output()
.map_err(|error| {
format!(
"Failed to launch unzip command {}: {error}",
self.unzip_command.display()
)
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!(
"unzip failed for {}: {}",
archive_path.display(),
stderr.trim()
));
}
Ok(())
}
}
fn find_resources_assets(root: &Path) -> Option<PathBuf> {
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
let entries = fs::read_dir(&dir).ok()?;
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
continue;
}
if path.file_name().and_then(|name| name.to_str()) == Some("resources.assets") {
return Some(path);
}
}
}
None
}
+513
View File
@@ -0,0 +1,513 @@
//! Official Yostar JP launcher API bootstrap.
//!
//! This models the PC launcher update API used by the official JP launcher.
//! It is separate from Unity resource `server-info`: the launcher API can
//! discover the latest Windows client package and manifest, while resource
//! updates still come from the game client's server-info flow.
use bat_adapters::official::launcher::{YostarJpLauncherManifestFile, YOSTAR_JP_GAME_TAG};
use md5::{Digest, Md5};
use serde::Deserialize;
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};
/// Official JP launcher API root.
pub const YOSTAR_JP_LAUNCHER_API_ROOT: &str = "https://api-launcher-jp.yo-star.com";
/// Salt embedded in the official JP launcher for request signing.
pub const YOSTAR_JP_LAUNCHER_AUTH_SALT: &str = "DE7108E9B2842FD460F4777702727869";
/// Official primary JP PC launcher package CDN host.
pub const YOSTAR_JP_LAUNCHER_PRIMARY_CDN_HOST: &str = "launcher-pkg-ba-jp.yo-star.com";
/// Official backup JP PC launcher package CDN host.
pub const YOSTAR_JP_LAUNCHER_BACKUP_CDN_HOST: &str = "launcher-pkg-ba-jp-bk.yo-star.com";
/// Latest official PC client metadata returned by `/api/launcher/game/config`.
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct YostarJpLauncherGameConfig {
/// Latest Windows client version.
pub game_latest_version: String,
/// Latest manifest basis path.
pub game_latest_file_path: String,
/// Lowest allowed installed client version.
#[serde(default)]
pub game_lowest_version: Option<String>,
/// Executable name without `.exe`.
#[serde(default)]
pub game_start_exe_name: Option<String>,
/// Launcher arguments.
#[serde(default)]
pub game_start_params: Vec<String>,
/// Displayed decompressed size.
#[serde(default)]
pub decompression_size: Option<String>,
}
/// Official launcher CDN roots used for Windows client package files.
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct YostarJpLauncherCdnConfig {
/// Primary CDN root.
pub primary_cdn: String,
/// Backup CDN root.
pub back_up_cdn: String,
}
/// Official launcher manifest URL response returned by
/// `/api/launcher/game/config/json`.
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct YostarJpLauncherManifestUrl {
/// Remote manifest URL on the official launcher package CDN.
pub url: String,
}
/// Remote PC launcher manifest used to compute install/update diffs.
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct YostarJpLauncherRemoteManifest {
/// Optional manifest source field.
#[serde(default)]
pub source: Option<String>,
/// Remote package files.
#[serde(default, rename = "file")]
pub files: Vec<YostarJpLauncherManifestFile>,
}
/// Signed official launcher API fetcher using `curl`.
#[derive(Debug, Clone)]
pub struct OfficialLauncherBootstrapService {
curl_command: String,
launcher_version: String,
}
impl OfficialLauncherBootstrapService {
/// Creates a fetcher using `curl` from `PATH`.
pub fn new(launcher_version: impl Into<String>) -> Self {
Self::with_curl_command(launcher_version, "curl")
}
/// Creates a fetcher with an explicit `curl` command.
pub fn with_curl_command(
launcher_version: impl Into<String>,
curl_command: impl Into<String>,
) -> Self {
Self {
curl_command: curl_command.into(),
launcher_version: launcher_version.into(),
}
}
/// Fetches latest official PC game config.
pub fn fetch_game_config(&self) -> Result<YostarJpLauncherGameConfig, String> {
let envelope = self.fetch_launcher_envelope("/api/launcher/game/config")?;
let config: YostarJpLauncherGameConfig =
serde_json::from_value(envelope.data).map_err(|error| {
format!("Failed to parse official launcher game config data: {error}")
})?;
if config.game_latest_version.is_empty() || config.game_latest_file_path.is_empty() {
return Err("Official launcher game config is missing latest version or basis".into());
}
Ok(config)
}
/// Fetches official CDN config for PC client package downloads.
pub fn fetch_cdn_config(&self) -> Result<YostarJpLauncherCdnConfig, String> {
let envelope = self.fetch_launcher_envelope("/api/launcher/advanced/game/download/cdn")?;
serde_json::from_value(envelope.data)
.map_err(|error| format!("Failed to parse official launcher CDN config data: {error}"))
}
/// Fetches the official remote manifest URL for a PC client version and
/// basis path.
pub fn fetch_manifest_url(
&self,
version: &str,
file_path: &str,
) -> Result<YostarJpLauncherManifestUrl, String> {
if version.is_empty() || file_path.is_empty() {
return Err("Launcher manifest version and file path must not be empty".into());
}
let path = format!(
"/api/launcher/game/config/json?version={}&file_path={}",
percent_encode_query_value(version),
percent_encode_query_value(file_path)
);
let envelope = self.fetch_launcher_envelope(&path)?;
let manifest_url: YostarJpLauncherManifestUrl = serde_json::from_value(envelope.data)
.map_err(|error| {
format!("Failed to parse official launcher manifest URL data: {error}")
})?;
if !is_official_launcher_package_url(&manifest_url.url) {
return Err(format!(
"Official launcher API returned non-official manifest URL: {}",
manifest_url.url
));
}
Ok(manifest_url)
}
/// Fetches and parses an official remote PC launcher manifest.
pub fn fetch_remote_manifest(
&self,
manifest_url: &str,
) -> Result<YostarJpLauncherRemoteManifest, String> {
if !is_official_launcher_package_url(manifest_url) {
return Err(format!(
"Refusing to fetch non-official launcher manifest URL: {manifest_url}"
));
}
let output = Command::new(&self.curl_command)
.arg("--fail")
.arg("--location")
.arg("--silent")
.arg("--show-error")
.arg("--url")
.arg(manifest_url)
.output()
.map_err(|error| {
format!(
"Failed to launch curl command {}: {error}",
self.curl_command
)
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!(
"curl failed for launcher manifest {manifest_url}: {}",
stderr.trim()
));
}
let manifest: YostarJpLauncherRemoteManifest = serde_json::from_slice(&output.stdout)
.map_err(|error| format!("Failed to parse official launcher manifest: {error}"))?;
if manifest.files.is_empty() {
return Err("Official launcher remote manifest has no files".into());
}
Ok(manifest)
}
/// Fetches latest PC game config, then follows the official manifest URL.
pub fn fetch_latest_remote_manifest(
&self,
) -> Result<
(
YostarJpLauncherGameConfig,
YostarJpLauncherManifestUrl,
YostarJpLauncherRemoteManifest,
),
String,
> {
let game_config = self.fetch_game_config()?;
let manifest_url = self.fetch_manifest_url(
&game_config.game_latest_version,
&game_config.game_latest_file_path,
)?;
let manifest = self.fetch_remote_manifest(&manifest_url.url)?;
Ok((game_config, manifest_url, manifest))
}
/// Fetches a signed official launcher API endpoint and parses the generic
/// response envelope.
pub fn fetch_launcher_envelope(&self, path: &str) -> Result<LauncherEnvelope, String> {
let url = launcher_api_url(path)?;
let authorization = launcher_authorization_header(&self.launcher_version, "", None)?;
let output = Command::new(&self.curl_command)
.arg("--fail")
.arg("--location")
.arg("--silent")
.arg("--show-error")
.arg("--header")
.arg("Content-Type: application/json;charset=UTF-8")
.arg("--header")
.arg(format!("Authorization: {authorization}"))
.arg("--url")
.arg(&url)
.output()
.map_err(|error| {
format!(
"Failed to launch curl command {}: {error}",
self.curl_command
)
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("curl failed for {url}: {}", stderr.trim()));
}
let envelope: LauncherEnvelope = serde_json::from_slice(&output.stdout)
.map_err(|error| format!("Failed to parse official launcher API response: {error}"))?;
if envelope.code != 200 {
return Err(format!(
"Official launcher API returned code {}: {}",
envelope.code,
envelope
.message
.as_deref()
.or(envelope.msg.as_deref())
.unwrap_or("<no message>")
));
}
Ok(envelope)
}
}
/// Generic official launcher API response envelope.
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct LauncherEnvelope {
/// Numeric response code. Official launcher treats `200` as success.
pub code: i64,
/// Optional message field.
#[serde(default)]
pub message: Option<String>,
/// Optional alternate message field.
#[serde(default)]
pub msg: Option<String>,
/// Response payload.
pub data: serde_json::Value,
}
/// Builds an official launcher API URL from a path.
pub fn launcher_api_url(path: &str) -> Result<String, String> {
if !path.starts_with('/') || path.contains("..") || path.contains('\\') {
return Err(format!("Invalid official launcher API path: {path}"));
}
Ok(format!("{YOSTAR_JP_LAUNCHER_API_ROOT}{path}"))
}
/// Returns true when a URL points to an official JP PC launcher package host.
pub fn is_official_launcher_package_url(url: &str) -> bool {
let Some(rest) = url.strip_prefix("https://") else {
return false;
};
let host = rest.split('/').next().unwrap_or_default();
matches!(
host,
YOSTAR_JP_LAUNCHER_PRIMARY_CDN_HOST | YOSTAR_JP_LAUNCHER_BACKUP_CDN_HOST
)
}
/// Builds an official launcher package URL from an official CDN root and a
/// relative package path.
pub fn launcher_package_url(cdn_root: &str, file_path: &str) -> Result<String, String> {
if !is_official_launcher_package_url(cdn_root) {
return Err(format!("Launcher CDN root is not official: {cdn_root}"));
}
validate_launcher_package_path(file_path)?;
Ok(format!(
"{}/{}",
cdn_root.trim_end_matches('/'),
normalize_relative_path(file_path)
))
}
fn percent_encode_query_value(value: &str) -> String {
let mut encoded = String::new();
for byte in value.bytes() {
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
encoded.push(byte as char);
} else {
encoded.push_str(&format!("%{byte:02X}"));
}
}
encoded
}
fn normalize_relative_path(path: &str) -> String {
path.replace('\\', "/")
}
fn validate_launcher_package_path(path: &str) -> Result<(), String> {
if path.is_empty() || path.starts_with('/') || path.starts_with('\\') {
return Err(format!("Invalid launcher package path: {path}"));
}
for segment in path.split('/') {
if segment.is_empty() || segment == "." || segment == ".." {
return Err(format!("Invalid launcher package path: {path}"));
}
if segment.contains('\\') || segment.contains('?') || segment.contains('#') {
return Err(format!("Invalid launcher package path: {path}"));
}
}
Ok(())
}
/// Builds the official launcher `Authorization` header.
///
/// This matches the official launcher logic:
/// `md5(JSON(head) + body + salt)`, where `head` contains `game_tag`,
/// Unix timestamp, and launcher version.
pub fn launcher_authorization_header(
launcher_version: &str,
body: &str,
timestamp: Option<u64>,
) -> Result<String, String> {
if launcher_version.is_empty() {
return Err("Launcher version must not be empty".into());
}
let timestamp = match timestamp {
Some(timestamp) => timestamp,
None => SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|error| format!("System time is before Unix epoch: {error}"))?
.as_secs(),
};
let head = serde_json::json!({
"game_tag": YOSTAR_JP_GAME_TAG,
"time": timestamp,
"version": launcher_version,
});
let head_json = serde_json::to_string(&head)
.map_err(|error| format!("Failed to serialize launcher auth head: {error}"))?;
let mut hasher = Md5::new();
hasher.update(head_json.as_bytes());
hasher.update(body.as_bytes());
hasher.update(YOSTAR_JP_LAUNCHER_AUTH_SALT.as_bytes());
let sign = hex::encode(hasher.finalize());
serde_json::to_string(&serde_json::json!({
"head": head,
"sign": sign,
}))
.map_err(|error| format!("Failed to serialize launcher auth header: {error}"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builds_official_launcher_api_url() {
assert_eq!(
launcher_api_url("/api/launcher/game/config").unwrap(),
"https://api-launcher-jp.yo-star.com/api/launcher/game/config"
);
assert!(launcher_api_url("api/launcher/game/config").is_err());
assert!(launcher_api_url("/api/../secret").is_err());
}
#[test]
fn identifies_official_launcher_package_urls_only() {
assert!(is_official_launcher_package_url(
"https://launcher-pkg-ba-jp.yo-star.com/prod/manifest.json"
));
assert!(is_official_launcher_package_url(
"https://launcher-pkg-ba-jp-bk.yo-star.com/prod/manifest.json"
));
assert!(!is_official_launcher_package_url(
"https://launcher-pkg-ba-jp.bluearchive.cafe/prod/manifest.json"
));
assert!(!is_official_launcher_package_url(
"http://launcher-pkg-ba-jp.yo-star.com/prod/manifest.json"
));
}
#[test]
fn builds_official_launcher_package_url() {
assert_eq!(
launcher_package_url(
"https://launcher-pkg-ba-jp.yo-star.com",
"prod/ZIP_TEMP/BlueArchive_JP_TEMP/BlueArchive_JP-1.70.436321-game.zip"
)
.unwrap(),
"https://launcher-pkg-ba-jp.yo-star.com/prod/ZIP_TEMP/BlueArchive_JP_TEMP/BlueArchive_JP-1.70.436321-game.zip"
);
assert!(launcher_package_url(
"https://launcher-pkg-ba-jp.bluearchive.cafe",
"prod/ZIP_TEMP/BlueArchive_JP_TEMP/BlueArchive_JP-1.70.436321-game.zip"
)
.is_err());
}
#[test]
fn encodes_launcher_manifest_query_values() {
assert_eq!(
percent_encode_query_value("prod/ZIP_TEMP/BlueArchive_JP_TEMP/game.zip"),
"prod%2FZIP_TEMP%2FBlueArchive_JP_TEMP%2Fgame.zip"
);
}
#[test]
fn builds_stable_launcher_authorization_header() {
let header = launcher_authorization_header("1.7.2", "", Some(1_700_000_000)).unwrap();
let value: serde_json::Value = serde_json::from_str(&header).unwrap();
assert_eq!(value["head"]["game_tag"], "BlueArchive_JP");
assert_eq!(value["head"]["time"], 1_700_000_000);
assert_eq!(value["head"]["version"], "1.7.2");
assert_eq!(value["sign"], "b457b5bc3d7796d7a37040d4b7990cb9");
}
#[test]
fn parses_launcher_game_config_payload() {
let config: YostarJpLauncherGameConfig = serde_json::from_str(
r#"{
"game_latest_version": "1.70.0",
"game_latest_file_path": "prod/ZIP_TEMP/BlueArchive_JP_TEMP/BlueArchive_JP-1.70.436321-game.zip",
"game_lowest_version": "1.69.0",
"game_start_exe_name": "xldr_BlueArchiveOnline_JP_loader_x64",
"game_start_params": ["BlueArchive.exe"]
}"#,
)
.unwrap();
assert_eq!(config.game_latest_version, "1.70.0");
assert_eq!(
config.game_start_exe_name.as_deref(),
Some("xldr_BlueArchiveOnline_JP_loader_x64")
);
assert_eq!(config.game_start_params, vec!["BlueArchive.exe"]);
}
#[test]
fn parses_launcher_manifest_url_payload() {
let manifest_url: YostarJpLauncherManifestUrl = serde_json::from_str(
r#"{
"url": "https://launcher-pkg-ba-jp.yo-star.com/prod/ZIP_TEMP/BlueArchive_JP_TEMP/manifest.json"
}"#,
)
.unwrap();
assert!(is_official_launcher_package_url(&manifest_url.url));
}
#[test]
fn parses_remote_manifest_payload() {
let manifest: YostarJpLauncherRemoteManifest = serde_json::from_str(
r#"{
"source": "prod/ZIP_TEMP/BlueArchive_JP_TEMP/BlueArchive_JP-1.70.436321-game.zip",
"file": [
{
"path": "/BlueArchive.exe",
"size": "100",
"hash": "123",
"vc": "abc"
}
]
}"#,
)
.unwrap();
assert_eq!(manifest.files.len(), 1);
assert_eq!(manifest.files[0].path, "/BlueArchive.exe");
}
}
+284
View File
@@ -0,0 +1,284 @@
//! Official JP resource pull planning.
use bat_adapters::official::inventory::{
YostarJpDownloadInventory, YostarJpPlatformDownloadInventory,
};
use bat_adapters::official::yostar_jp::{
verified_official_platforms, PatchPlatform, YostarJpResourceDiscoveryPlan, YostarJpResourceRoot,
};
use std::collections::HashSet;
/// Full official JP pull plan, covering discovery endpoints and concrete
/// resource downloads.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OfficialResourcePullPlan {
/// Seed URLs and selected root from server-info.
pub discovery: YostarJpResourceDiscoveryPlan,
/// Concrete file inventory extracted from the catalog bytes.
pub inventory: YostarJpPlatformDownloadInventory,
/// Platforms that will be pulled.
pub platforms: Vec<PatchPlatform>,
}
impl OfficialResourcePullPlan {
/// Creates a pull plan for the requested platforms.
pub fn new(
discovery: YostarJpResourceDiscoveryPlan,
inventory: YostarJpDownloadInventory,
platforms: &[PatchPlatform],
) -> Self {
Self::new_platform_inventory(
discovery,
YostarJpPlatformDownloadInventory::from_shared_inventory(inventory, platforms),
platforms,
)
}
/// Creates a pull plan for the requested platforms using platform-specific
/// bundle/media catalogs.
pub fn new_platform_inventory(
discovery: YostarJpResourceDiscoveryPlan,
inventory: YostarJpPlatformDownloadInventory,
platforms: &[PatchPlatform],
) -> Self {
Self {
discovery,
inventory,
platforms: unique_platforms(platforms),
}
}
/// Creates a pull plan for the verified official JP platforms.
pub fn verified(
discovery: YostarJpResourceDiscoveryPlan,
inventory: YostarJpDownloadInventory,
) -> Self {
Self::new(discovery, inventory, &verified_official_platforms())
}
/// Creates a pull plan for the verified official JP platforms using
/// platform-specific bundle/media catalogs.
pub fn verified_platform_inventory(
discovery: YostarJpResourceDiscoveryPlan,
inventory: YostarJpPlatformDownloadInventory,
) -> Self {
Self::new_platform_inventory(discovery, inventory, &verified_official_platforms())
}
/// Returns all discovery URLs in server-provided order.
pub fn discovery_urls(&self) -> Vec<String> {
self.discovery
.endpoints
.iter()
.map(|endpoint| endpoint.url.clone())
.collect()
}
/// Returns the validated official resource root used by the pull plan.
pub fn resource_root(&self) -> Result<YostarJpResourceRoot, String> {
YostarJpResourceRoot::from_addressables_root(&self.discovery.addressables_root)
}
/// Returns all concrete download URLs for the selected platforms.
pub fn content_urls(&self) -> Result<Vec<String>, String> {
let root = self.resource_root()?;
self.inventory
.direct_download_urls_for_platforms(&root, &self.platforms)
}
/// Returns discovery URLs followed by concrete content URLs.
pub fn all_urls(&self) -> Result<Vec<String>, String> {
let mut urls = self.discovery_urls();
let mut seen = urls.iter().cloned().collect::<HashSet<_>>();
for url in self.content_urls()? {
if seen.insert(url.clone()) {
urls.push(url);
}
}
Ok(urls)
}
}
/// Builds a pull plan for the verified official JP platforms.
pub fn build_official_pull_plan(
discovery: YostarJpResourceDiscoveryPlan,
inventory: YostarJpDownloadInventory,
) -> OfficialResourcePullPlan {
OfficialResourcePullPlan::verified(discovery, inventory)
}
/// Builds a pull plan for the verified official JP platforms using
/// platform-specific bundle/media catalogs.
pub fn build_official_pull_plan_from_platform_inventory(
discovery: YostarJpResourceDiscoveryPlan,
inventory: YostarJpPlatformDownloadInventory,
) -> OfficialResourcePullPlan {
OfficialResourcePullPlan::verified_platform_inventory(discovery, inventory)
}
/// Builds a pull plan for the requested platforms.
pub fn build_official_pull_plan_for_platforms(
discovery: YostarJpResourceDiscoveryPlan,
inventory: YostarJpDownloadInventory,
platforms: &[PatchPlatform],
) -> OfficialResourcePullPlan {
OfficialResourcePullPlan::new(discovery, inventory, platforms)
}
/// Builds a pull plan for the requested platforms using platform-specific
/// bundle/media catalogs.
pub fn build_official_pull_plan_for_platform_inventory(
discovery: YostarJpResourceDiscoveryPlan,
inventory: YostarJpPlatformDownloadInventory,
platforms: &[PatchPlatform],
) -> OfficialResourcePullPlan {
OfficialResourcePullPlan::new_platform_inventory(discovery, inventory, platforms)
}
fn unique_platforms(platforms: &[PatchPlatform]) -> Vec<PatchPlatform> {
platforms
.iter()
.copied()
.fold(Vec::new(), |mut unique, platform| {
if !unique.contains(&platform) {
unique.push(platform);
}
unique
})
}
#[cfg(test)]
mod tests {
use super::*;
use bat_adapters::official::inventory::{
YostarJpDownloadInventory, YostarJpPlatformCatalogInventory,
YostarJpPlatformDownloadInventory,
};
use bat_adapters::official::yostar_jp::{
YostarJpResourceEndpoint, YostarJpResourceEndpointKind,
};
fn discovery_plan() -> YostarJpResourceDiscoveryPlan {
YostarJpResourceDiscoveryPlan {
connection_group_name: "Prod-Audit".to_string(),
app_version: "1.70.0".to_string(),
bundle_version: Some("s8tloc7lo3".to_string()),
addressables_root:
"https://prod-clientpatch.bluearchiveyostar.com/r93_token".to_string(),
endpoints: vec![
YostarJpResourceEndpoint {
kind: YostarJpResourceEndpointKind::TableCatalog,
platform: None,
url: "https://prod-clientpatch.bluearchiveyostar.com/r93_token/TableBundles/TableCatalog.bytes".to_string(),
},
YostarJpResourceEndpoint {
kind: YostarJpResourceEndpointKind::TableCatalogHash,
platform: None,
url: "https://prod-clientpatch.bluearchiveyostar.com/r93_token/TableBundles/TableCatalog.hash".to_string(),
},
],
}
}
fn inventory() -> YostarJpDownloadInventory {
YostarJpDownloadInventory::from_catalog_bytes(
b"FullPatch_000.zip",
b"ExcelDB.db",
b"JP_Airi.zip",
)
}
fn platform_inventory() -> YostarJpPlatformDownloadInventory {
YostarJpPlatformDownloadInventory::from_catalog_bytes(
b"ExcelDB.db",
vec![
YostarJpPlatformCatalogInventory::from_catalog_bytes(
PatchPlatform::Windows,
b"FullPatch_000.zip",
b"JP_Airi_Win.zip",
),
YostarJpPlatformCatalogInventory::from_catalog_bytes(
PatchPlatform::Android,
b"FullPatch_001.zip",
b"JP_Airi_Android.zip",
),
],
)
}
#[test]
fn builds_verified_pull_plan_with_all_urls() {
let plan = build_official_pull_plan(discovery_plan(), inventory());
assert_eq!(plan.platforms, verified_official_platforms());
assert_eq!(plan.discovery_urls().len(), 2);
let content_urls = plan.content_urls().unwrap();
assert!(content_urls
.iter()
.any(|url| url.contains("/Windows_PatchPack/FullPatch_000.zip")));
assert!(content_urls
.iter()
.any(|url| url.contains("/Android_PatchPack/FullPatch_000.zip")));
assert_eq!(content_urls.len(), 5);
let all_urls = plan.all_urls().unwrap();
assert_eq!(all_urls.len(), 7);
assert!(all_urls[0].ends_with("TableCatalog.bytes"));
assert!(all_urls
.iter()
.any(|url| url.ends_with("/MediaResources-Windows/JP_Airi.zip")));
assert_eq!(
all_urls
.iter()
.filter(|url| url.ends_with("/MediaResources/JP_Airi.zip"))
.count(),
1
);
}
#[test]
fn deduplicates_requested_platforms() {
let plan = build_official_pull_plan_for_platforms(
discovery_plan(),
inventory(),
&[PatchPlatform::Windows, PatchPlatform::Windows],
);
assert_eq!(plan.platforms, vec![PatchPlatform::Windows]);
}
#[test]
fn verified_pull_plan_is_windows_and_android_only() {
let plan = build_official_pull_plan(discovery_plan(), inventory());
assert_eq!(
plan.platforms,
vec![PatchPlatform::Windows, PatchPlatform::Android]
);
}
#[test]
fn builds_platform_inventory_pull_plan_without_cross_mixing_catalogs() {
let plan = build_official_pull_plan_from_platform_inventory(
discovery_plan(),
platform_inventory(),
);
let all_urls = plan.all_urls().unwrap();
assert_eq!(plan.platforms, verified_official_platforms());
assert_eq!(all_urls.len(), 7);
assert!(all_urls
.iter()
.any(|url| url.ends_with("/Windows_PatchPack/FullPatch_000.zip")));
assert!(all_urls
.iter()
.any(|url| url.ends_with("/Android_PatchPack/FullPatch_001.zip")));
assert!(!all_urls
.iter()
.any(|url| url.ends_with("/Android_PatchPack/FullPatch_000.zip")));
}
}
+204
View File
@@ -0,0 +1,204 @@
//! Official JP synchronization planning.
use bat_adapters::official::yostar_jp::{
verified_official_platforms, PatchPlatform, YostarJpResourceDiscoveryPlan,
YostarJpResourceEndpointChange, YostarJpSyncDelta, YostarJpSyncSnapshot,
};
/// High-level official update decision.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OfficialSyncDecision {
/// No material change was detected.
UpToDate,
/// Official state changed and content should be downloaded and verified.
DownloadAndVerify,
/// Official state changed enough that the published client snapshot must be updated.
DownloadVerifyAndPublish,
}
/// Comparison result used by schedulers and manual actions.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OfficialSyncPlan {
/// Observed official snapshot.
pub snapshot: YostarJpSyncSnapshot,
/// Difference from the previous stored snapshot.
pub delta: YostarJpSyncDelta,
/// Whether the sync should be executed now.
pub decision: OfficialSyncDecision,
}
impl OfficialSyncPlan {
/// Returns true when a download should start.
pub fn should_download(&self) -> bool {
matches!(
self.decision,
OfficialSyncDecision::DownloadAndVerify
| OfficialSyncDecision::DownloadVerifyAndPublish
)
}
/// Returns true when the result may be pushed to clients after verification.
pub fn should_publish(&self) -> bool {
matches!(
self.decision,
OfficialSyncDecision::DownloadVerifyAndPublish
)
}
}
/// Builds a sync plan from an observed snapshot and an optional previous snapshot.
pub fn build_official_sync_plan(
snapshot: YostarJpSyncSnapshot,
previous: Option<&YostarJpSyncSnapshot>,
) -> OfficialSyncPlan {
let delta = snapshot.diff(previous);
let decision = classify_sync_decision(&delta);
OfficialSyncPlan {
snapshot,
delta,
decision,
}
}
/// Classifies the sync decision from a snapshot delta.
pub fn classify_sync_decision(delta: &YostarJpSyncDelta) -> OfficialSyncDecision {
if delta.is_initial {
return OfficialSyncDecision::DownloadVerifyAndPublish;
}
if !delta.has_changes() {
return OfficialSyncDecision::UpToDate;
}
if delta.addressables_root_changed
|| delta.root_token_changed
|| !delta.endpoint_changes.is_empty()
{
return OfficialSyncDecision::DownloadVerifyAndPublish;
}
if delta.bundle_version_changed {
return OfficialSyncDecision::DownloadAndVerify;
}
OfficialSyncDecision::UpToDate
}
/// Returns the ordered platform set used by the official JP downloader.
pub fn default_official_platforms() -> Vec<PatchPlatform> {
verified_official_platforms()
}
/// Returns all endpoints that should be fetched before any patch publish.
pub fn stable_discovery_endpoints(plan: &YostarJpResourceDiscoveryPlan) -> Vec<String> {
plan.endpoints
.iter()
.map(|endpoint| endpoint.url.clone())
.collect()
}
/// Returns endpoint URLs that changed compared with a previous sync snapshot.
pub fn changed_endpoint_urls(delta: &YostarJpSyncDelta) -> Vec<String> {
delta
.endpoint_changes
.iter()
.map(|change| match change {
YostarJpResourceEndpointChange::Added(endpoint) => endpoint.url.clone(),
YostarJpResourceEndpointChange::Removed(endpoint) => endpoint.url.clone(),
YostarJpResourceEndpointChange::Updated { current, .. } => current.url.clone(),
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use bat_adapters::official::yostar_jp::{
YostarJpResourceDiscoveryPlan, YostarJpResourceEndpoint, YostarJpResourceEndpointKind,
};
fn snapshot() -> YostarJpSyncSnapshot {
YostarJpSyncSnapshot {
connection_group_name: "Prod-Audit".to_string(),
app_version: "1.70.0".to_string(),
bundle_version: Some("s8tloc7lo3".to_string()),
addressables_root: "https://prod-clientpatch.bluearchiveyostar.com/r93_token".to_string(),
endpoints: vec![
YostarJpResourceEndpoint {
kind: YostarJpResourceEndpointKind::TableCatalog,
platform: None,
url: "https://prod-clientpatch.bluearchiveyostar.com/r93_token/TableBundles/TableCatalog.bytes".to_string(),
},
YostarJpResourceEndpoint {
kind: YostarJpResourceEndpointKind::AddressablesCatalog,
platform: Some(PatchPlatform::Windows),
url: "https://prod-clientpatch.bluearchiveyostar.com/r93_token/Windows_PatchPack/catalog_StandaloneWindows64.zip".to_string(),
},
],
}
}
fn discovery_plan() -> YostarJpResourceDiscoveryPlan {
YostarJpResourceDiscoveryPlan {
connection_group_name: "Prod-Audit".to_string(),
app_version: "1.70.0".to_string(),
bundle_version: Some("s8tloc7lo3".to_string()),
addressables_root: "https://prod-clientpatch.bluearchiveyostar.com/r93_token"
.to_string(),
endpoints: snapshot().endpoints,
}
}
#[test]
fn classifies_initial_snapshot_as_publishable() {
let plan = build_official_sync_plan(snapshot(), None);
assert!(plan.delta.is_initial);
assert_eq!(
plan.decision,
OfficialSyncDecision::DownloadVerifyAndPublish
);
assert!(plan.should_download());
assert!(plan.should_publish());
}
#[test]
fn classifies_identical_snapshot_as_up_to_date() {
let current = snapshot();
let plan = build_official_sync_plan(current.clone(), Some(&current));
assert_eq!(plan.decision, OfficialSyncDecision::UpToDate);
assert!(!plan.should_download());
assert!(!plan.should_publish());
}
#[test]
fn classifies_bundle_version_only_change_as_download_only() {
let current = snapshot();
let previous = YostarJpSyncSnapshot {
bundle_version: Some("older".to_string()),
..current.clone()
};
let delta = current.diff(Some(&previous));
assert!(delta.bundle_version_changed);
assert_eq!(
classify_sync_decision(&delta),
OfficialSyncDecision::DownloadAndVerify
);
}
#[test]
fn exposes_changed_endpoint_urls() {
let mut current = snapshot();
current.endpoints[0].url = "https://prod-clientpatch.bluearchiveyostar.com/r93_token/TableBundles/TableCatalog.bytes?v=2".to_string();
let delta = current.diff(Some(&snapshot()));
let urls = changed_endpoint_urls(&delta);
assert_eq!(urls.len(), 1);
assert!(urls[0].contains("v=2"));
}
#[test]
fn keeps_discovery_endpoint_urls_stable() {
let plan = discovery_plan();
assert_eq!(stable_discovery_endpoints(&plan).len(), 2);
}
}
+217
View File
@@ -0,0 +1,217 @@
//! 内存资源仓储实现。
use async_trait::async_trait;
use bat_core::domain::Resource;
use bat_core::repositories::resource_repository::{ResourceQuery, ResourceRepository};
use std::collections::HashMap;
use tokio::sync::RwLock;
/// 以内存 `HashMap` 保存资源索引的仓储实现。
#[derive(Debug, Default)]
pub struct InMemoryResourceRepository {
resources: RwLock<HashMap<String, Resource>>,
}
impl InMemoryResourceRepository {
/// 创建空资源仓储。
pub fn new() -> Self {
Self::default()
}
fn sorted_resources(resources: &HashMap<String, Resource>) -> Vec<Resource> {
let mut resources = resources.values().cloned().collect::<Vec<_>>();
resources.sort_by(|left, right| left.id.cmp(&right.id));
resources
}
}
#[async_trait]
impl ResourceRepository for InMemoryResourceRepository {
async fn add(&self, resource: Resource) -> bat_core::Result<String> {
let id = resource.id.clone();
self.resources.write().await.insert(id.clone(), resource);
Ok(id)
}
async fn find_by_id(&self, id: &str) -> bat_core::Result<Resource> {
self.resources
.read()
.await
.get(id)
.cloned()
.ok_or_else(|| bat_core::Error::NotFound(id.to_string()))
}
async fn find_by_hash(&self, hash: &str) -> bat_core::Result<Resource> {
let resources = self.resources.read().await;
Self::sorted_resources(&resources)
.into_iter()
.find(|resource| resource.entry.hash == hash)
.ok_or_else(|| bat_core::Error::NotFound(hash.to_string()))
}
async fn list(&self, query: ResourceQuery) -> bat_core::Result<Vec<Resource>> {
let resources = self.resources.read().await;
let resources = Self::sorted_resources(&resources)
.into_iter()
.filter(|resource| query_matches(&query, resource))
.collect();
Ok(resources)
}
async fn update(&self, resource: Resource) -> bat_core::Result<()> {
let mut resources = self.resources.write().await;
if !resources.contains_key(&resource.id) {
return Err(bat_core::Error::NotFound(resource.id));
}
resources.insert(resource.id.clone(), resource);
Ok(())
}
async fn delete(&self, id: &str) -> bat_core::Result<()> {
self.resources
.write()
.await
.remove(id)
.map(|_| ())
.ok_or_else(|| bat_core::Error::NotFound(id.to_string()))
}
async fn count(&self, query: ResourceQuery) -> bat_core::Result<u64> {
Ok(self.list(query).await?.len() as u64)
}
}
fn query_matches(query: &ResourceQuery, resource: &Resource) -> bool {
if let Some(resource_type) = query.resource_type {
if resource.entry.resource_type != resource_type {
return false;
}
}
if let Some(hash) = &query.hash {
if resource.entry.hash != *hash {
return false;
}
}
if let Some(pattern) = &query.path_pattern {
if !wildcard_matches(pattern, &resource.entry.path) {
return false;
}
}
true
}
fn wildcard_matches(pattern: &str, value: &str) -> bool {
wildcard_matches_bytes(pattern.as_bytes(), value.as_bytes())
}
fn wildcard_matches_bytes(pattern: &[u8], value: &[u8]) -> bool {
match (pattern.first(), value.first()) {
(None, None) => true,
(None, Some(_)) => false,
(Some(b'*'), _) => {
wildcard_matches_bytes(&pattern[1..], value)
|| value
.first()
.is_some_and(|_| wildcard_matches_bytes(pattern, &value[1..]))
}
(Some(b'?'), Some(_)) => wildcard_matches_bytes(&pattern[1..], &value[1..]),
(Some(pattern_byte), Some(value_byte)) if pattern_byte == value_byte => {
wildcard_matches_bytes(&pattern[1..], &value[1..])
}
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use bat_core::domain::{ResourceEntry, ResourceType};
use std::path::PathBuf;
fn resource(id: &str, path: &str, hash: &str, resource_type: ResourceType) -> Resource {
Resource {
id: id.to_string(),
local_path: PathBuf::from(path),
entry: ResourceEntry {
path: path.to_string(),
hash: hash.to_string(),
size: 7,
resource_type,
},
}
}
#[tokio::test]
async fn add_find_update_and_delete_resource() {
let repository = InMemoryResourceRepository::new();
let id = repository
.add(resource(
"resource/synthetic-minimal.bundle",
"synthetic-minimal.bundle",
"hash-a",
ResourceType::AssetBundle,
))
.await
.unwrap();
assert_eq!(id, "resource/synthetic-minimal.bundle");
assert_eq!(
repository.find_by_id(&id).await.unwrap().entry.path,
"synthetic-minimal.bundle"
);
let mut updated = repository.find_by_id(&id).await.unwrap();
updated.entry.size = 12;
repository.update(updated).await.unwrap();
assert_eq!(repository.find_by_id(&id).await.unwrap().entry.size, 12);
repository.delete(&id).await.unwrap();
assert!(matches!(
repository.find_by_id(&id).await,
Err(bat_core::Error::NotFound(_))
));
}
#[tokio::test]
async fn list_filters_by_type_hash_and_path() {
let repository = InMemoryResourceRepository::new();
repository
.add(resource(
"resource/a",
"synthetic-minimal.bundle",
"hash-a",
ResourceType::AssetBundle,
))
.await
.unwrap();
repository
.add(resource(
"resource/b",
"catalog.json",
"hash-b",
ResourceType::Manifest,
))
.await
.unwrap();
let query = ResourceQuery {
resource_type: Some(ResourceType::AssetBundle),
hash: Some("hash-a".to_string()),
path_pattern: Some("synthetic-*.bundle".to_string()),
};
let results = repository.list(query).await.unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].id, "resource/a");
assert_eq!(
repository.find_by_hash("hash-a").await.unwrap().id,
"resource/a"
);
assert_eq!(repository.count(ResourceQuery::all()).await.unwrap(), 2);
}
}