mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-07-22 01:46:44 +08:00
对 d2466d6 做三视角对抗性核查(错误码语义/消费者破坏面/测试与文档),
确认 8 项(去重后 5 类)误归类,全部集中在 official_game_main_config
的 From<String> 兜底面。逐项修正:
- launcher_package_url 改返回 DownloadError:非官方 CDN 根(来自远端
API 响应,安全边界拒绝)→ NON_OFFICIAL_URL;非法包路径(来自远端
manifest 内容)→ LAUNCHER_RESPONSE_INVALID。此前提交说明称其为
"硬编码常量上的内部不变量"不成立——三个生产调用点传入的都是远端
API 下发的 cdn_root。
- select_game_main_config_source / find_resources_assets:目录 source
缺 resources.assets 条目、无可用游戏包路径、包内容缺必需文件均为
远端可触发的内容缺陷 → LAUNCHER_RESPONSE_INVALID(此前落 INTERNAL,
且官方 manifest 布局已演进过一次,是现实的主要失败面)。
- extract_archive 解压失败 → ZIP_STRUCTURE_INVALID:结构校验不覆盖
压缩数据流,数据区损坏(bad CRC)在 unzip 阶段首次暴露,主导成因
是损坏/截断下载,归完整性域;本地原因保留在 stderr 消息中。
- verify_manifest_file_size 三种失败分码:size 字段无效 →
LAUNCHER_RESPONSE_INVALID、本地读文件失败 → INTERNAL、真正不符 →
SIZE_MISMATCH(此前三者统归 SIZE_MISMATCH)。
- 空 --launcher-version(用户 CLI 输入)→ INVALID_ARGUMENT,在发起
任何请求前拒绝(此前落 INTERNAL)。
- 统一 launcher 链内容口径:API 响应、远端 manifest、包内容的解析/
缺失问题全部归 LAUNCHER_RESPONSE_INVALID(600004),
MANIFEST_PARSE_FAILED(600001) 保留给资源侧 manifest/catalog;
fetch_remote_manifest 两处 600001 改 600004,error_code.rs 注释与
USERGUIDE 描述同步。
- download_file_with_fallback:备用 URL 构造失败不再经 `?` 丢弃主地址
失败上下文,错误码与"两次都失败以最终一次为准"策略一致。
新增 7 个错误码断言测试(launcher_package_url 两类拒绝、空版本、
manifest 无条目、fallback 终码保留、fallback 构造失败保留主上下文、
select/verify/extract 各失败面)。全量 fmt / clippy --workspace
--all-targets -D warnings / test --workspace 全绿。
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
798 lines
27 KiB
Rust
798 lines
27 KiB
Rust
//! 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 crate::curl_transfer::{
|
||
run_curl_with_retry_with_proxy as run_curl_command_with_retry, CurlProxyConfig,
|
||
};
|
||
use crate::official_download::DownloadError;
|
||
use bat_adapters::official::launcher::{YostarJpLauncherManifestFile, YOSTAR_JP_GAME_TAG};
|
||
use bat_core::ErrorCode;
|
||
use md5::{Digest, Md5};
|
||
use serde::Deserialize;
|
||
use std::path::Path;
|
||
use std::process::{Command, Output};
|
||
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";
|
||
|
||
const DEFAULT_LAUNCHER_RETRY_ATTEMPTS: usize = 3;
|
||
|
||
/// 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,
|
||
curl_proxy: CurlProxyConfig,
|
||
launcher_version: String,
|
||
retry_attempts: usize,
|
||
}
|
||
|
||
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(),
|
||
curl_proxy: CurlProxyConfig::default(),
|
||
launcher_version: launcher_version.into(),
|
||
retry_attempts: DEFAULT_LAUNCHER_RETRY_ATTEMPTS,
|
||
}
|
||
}
|
||
|
||
/// Sets the proxy configuration for launcher `curl` transfers.
|
||
pub fn with_proxy_config(mut self, curl_proxy: CurlProxyConfig) -> Self {
|
||
self.curl_proxy = curl_proxy;
|
||
self
|
||
}
|
||
|
||
/// Sets the number of attempts for each launcher `curl` transfer.
|
||
pub fn with_retry_attempts(mut self, retry_attempts: usize) -> Self {
|
||
self.retry_attempts = retry_attempts.max(1);
|
||
self
|
||
}
|
||
|
||
/// Fetches latest official PC game config.
|
||
pub fn fetch_game_config(&self) -> Result<YostarJpLauncherGameConfig, DownloadError> {
|
||
let envelope = self.fetch_launcher_envelope("/api/launcher/game/config")?;
|
||
let config: YostarJpLauncherGameConfig =
|
||
serde_json::from_value(envelope.data).map_err(|error| {
|
||
DownloadError::new(
|
||
ErrorCode::LAUNCHER_RESPONSE_INVALID,
|
||
format!("解析官方启动器 game config 数据失败:{error}"),
|
||
)
|
||
})?;
|
||
|
||
if config.game_latest_version.is_empty() || config.game_latest_file_path.is_empty() {
|
||
return Err(DownloadError::new(
|
||
ErrorCode::LAUNCHER_RESPONSE_INVALID,
|
||
"官方启动器 game config 缺少最新版本或基准路径",
|
||
));
|
||
}
|
||
|
||
Ok(config)
|
||
}
|
||
|
||
/// Fetches official CDN config for PC client package downloads.
|
||
pub fn fetch_cdn_config(&self) -> Result<YostarJpLauncherCdnConfig, DownloadError> {
|
||
let envelope = self.fetch_launcher_envelope("/api/launcher/advanced/game/download/cdn")?;
|
||
serde_json::from_value(envelope.data).map_err(|error| {
|
||
DownloadError::new(
|
||
ErrorCode::LAUNCHER_RESPONSE_INVALID,
|
||
format!("解析官方启动器 CDN config 数据失败:{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, DownloadError> {
|
||
if version.is_empty() || file_path.is_empty() {
|
||
return Err(DownloadError::new(
|
||
ErrorCode::INVALID_ARGUMENT,
|
||
"启动器 manifest 版本和文件路径不能为空",
|
||
));
|
||
}
|
||
|
||
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| {
|
||
DownloadError::new(
|
||
ErrorCode::LAUNCHER_RESPONSE_INVALID,
|
||
format!("解析官方启动器 manifest URL 数据失败:{error}"),
|
||
)
|
||
})?;
|
||
|
||
if !is_official_launcher_package_url(&manifest_url.url) {
|
||
return Err(DownloadError::new(
|
||
ErrorCode::NON_OFFICIAL_URL,
|
||
format!(
|
||
"官方启动器 API 返回了非官方 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, DownloadError> {
|
||
if !is_official_launcher_package_url(manifest_url) {
|
||
return Err(DownloadError::new(
|
||
ErrorCode::NON_OFFICIAL_URL,
|
||
format!("拒绝拉取非官方启动器 manifest URL:{manifest_url}"),
|
||
));
|
||
}
|
||
|
||
let output = self.run_curl_with_retry(manifest_url, || {
|
||
let mut command = Command::new(&self.curl_command);
|
||
command
|
||
.arg("--fail")
|
||
.arg("--location")
|
||
.arg("--silent")
|
||
.arg("--show-error")
|
||
.arg("--url")
|
||
.arg(manifest_url);
|
||
command
|
||
})?;
|
||
|
||
// launcher 链内容问题(API 响应、远端 manifest、包内容)统一归
|
||
// LAUNCHER_RESPONSE_INVALID;MANIFEST_PARSE_FAILED 保留给资源侧
|
||
// manifest / Addressables catalog。
|
||
let manifest: YostarJpLauncherRemoteManifest = serde_json::from_slice(&output.stdout)
|
||
.map_err(|error| {
|
||
DownloadError::new(
|
||
ErrorCode::LAUNCHER_RESPONSE_INVALID,
|
||
format!("解析官方启动器 manifest 失败:{error}"),
|
||
)
|
||
})?;
|
||
if manifest.files.is_empty() {
|
||
return Err(DownloadError::new(
|
||
ErrorCode::LAUNCHER_RESPONSE_INVALID,
|
||
"官方启动器远端 manifest 没有文件条目",
|
||
));
|
||
}
|
||
|
||
Ok(manifest)
|
||
}
|
||
|
||
/// Fetches latest PC game config, then follows the official manifest URL.
|
||
pub fn fetch_latest_remote_manifest(
|
||
&self,
|
||
) -> Result<
|
||
(
|
||
YostarJpLauncherGameConfig,
|
||
YostarJpLauncherManifestUrl,
|
||
YostarJpLauncherRemoteManifest,
|
||
),
|
||
DownloadError,
|
||
> {
|
||
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, DownloadError> {
|
||
// launcher_version 是用户可控的 CLI 输入(--launcher-version),空值是
|
||
// 参数错误而非内部错误,在进入签名流程前显式归 INVALID_ARGUMENT。
|
||
if self.launcher_version.is_empty() {
|
||
return Err(DownloadError::new(
|
||
ErrorCode::INVALID_ARGUMENT,
|
||
"启动器版本不能为空",
|
||
));
|
||
}
|
||
let url = launcher_api_url(path)?;
|
||
let authorization = launcher_authorization_header(&self.launcher_version, "", None)?;
|
||
|
||
let output = self.run_curl_with_retry(&url, || {
|
||
let mut command = Command::new(&self.curl_command);
|
||
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);
|
||
command
|
||
})?;
|
||
|
||
let envelope: LauncherEnvelope =
|
||
serde_json::from_slice(&output.stdout).map_err(|error| {
|
||
DownloadError::new(
|
||
ErrorCode::LAUNCHER_RESPONSE_INVALID,
|
||
format!("解析官方启动器 API 响应失败:{error}"),
|
||
)
|
||
})?;
|
||
|
||
if envelope.code != 200 {
|
||
return Err(DownloadError::new(
|
||
ErrorCode::LAUNCHER_API_REJECTED,
|
||
format!(
|
||
"官方启动器 API 返回错误码 {}:{}",
|
||
envelope.code,
|
||
envelope
|
||
.message
|
||
.as_deref()
|
||
.or(envelope.msg.as_deref())
|
||
.unwrap_or("<无消息>")
|
||
),
|
||
));
|
||
}
|
||
|
||
Ok(envelope)
|
||
}
|
||
|
||
fn run_curl_with_retry(
|
||
&self,
|
||
url: &str,
|
||
mut build_command: impl FnMut() -> Command,
|
||
) -> Result<Output, DownloadError> {
|
||
run_curl_command_with_retry(
|
||
Path::new(&self.curl_command),
|
||
url,
|
||
None,
|
||
self.retry_attempts,
|
||
&self.curl_proxy,
|
||
&mut build_command,
|
||
)
|
||
.map_err(|error| {
|
||
// 保留 curl 失败的准确网络域码(403/404/DNS/连接/超时/TLS/代理等),
|
||
// 进程类失败归 INTERNAL。
|
||
let code = error.final_error_code().unwrap_or(ErrorCode::INTERNAL);
|
||
DownloadError::new(code, format!("curl 拉取失败 {url}:{error}"))
|
||
})
|
||
}
|
||
}
|
||
|
||
/// 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!("官方启动器 API 路径无效:{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.
|
||
///
|
||
/// 生产调用方传入的 `cdn_root` 来自官方 API 的远端响应(`fetch_cdn_config`),
|
||
/// `file_path` 来自远端 manifest 内容——两者都是运行时外部输入而非硬编码常量,
|
||
/// 因此这里的拒绝必须携带准确错误码:非官方根地址是安全边界拒绝
|
||
/// (NON_OFFICIAL_URL),非法包路径是远端内容缺陷(LAUNCHER_RESPONSE_INVALID)。
|
||
pub fn launcher_package_url(cdn_root: &str, file_path: &str) -> Result<String, DownloadError> {
|
||
if !is_official_launcher_package_url(cdn_root) {
|
||
return Err(DownloadError::new(
|
||
ErrorCode::NON_OFFICIAL_URL,
|
||
format!("启动器 CDN 根地址不是官方地址:{cdn_root}"),
|
||
));
|
||
}
|
||
|
||
validate_launcher_package_path(file_path)
|
||
.map_err(|error| DownloadError::new(ErrorCode::LAUNCHER_RESPONSE_INVALID, error))?;
|
||
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!("启动器包路径无效:{path}"));
|
||
}
|
||
|
||
for segment in path.split('/') {
|
||
if segment.is_empty() || segment == "." || segment == ".." {
|
||
return Err(format!("启动器包路径无效:{path}"));
|
||
}
|
||
if segment.contains('\\') || segment.contains('?') || segment.contains('#') {
|
||
return Err(format!("启动器包路径无效:{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("启动器版本不能为空".into());
|
||
}
|
||
|
||
let timestamp = match timestamp {
|
||
Some(timestamp) => timestamp,
|
||
None => SystemTime::now()
|
||
.duration_since(UNIX_EPOCH)
|
||
.map_err(|error| format!("系统时间早于 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!("序列化启动器认证 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!("序列化启动器认证 header 失败:{error}"))
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use std::fs;
|
||
use std::path::Path;
|
||
use tempfile::TempDir;
|
||
|
||
fn write_shell_script(path: &Path, script: &str) {
|
||
fs::write(path, 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();
|
||
}
|
||
}
|
||
|
||
fn fake_flaky_launcher_curl_script() -> &'static str {
|
||
r#"#!/bin/sh
|
||
set -eu
|
||
url=""
|
||
while [ "$#" -gt 0 ]; do
|
||
case "$1" in
|
||
--url)
|
||
url="$2"
|
||
shift 2
|
||
;;
|
||
*)
|
||
shift
|
||
;;
|
||
esac
|
||
done
|
||
state="$0.state"
|
||
count=0
|
||
if [ -f "$state" ]; then
|
||
count="$(cat "$state")"
|
||
fi
|
||
count=$((count + 1))
|
||
printf '%s' "$count" > "$state"
|
||
if [ "$count" -eq 1 ]; then
|
||
printf '%s\n' "temporary launcher failure" >&2
|
||
exit 35
|
||
fi
|
||
case "$url" in
|
||
*/api/launcher/game/config)
|
||
printf '%s' '{"code":200,"data":{"game_latest_version":"1.70.436321","game_latest_file_path":"prod/manifest/BlueArchive_JP_TEMP","game_lowest_version":"1.69.0","game_start_params":[]}}'
|
||
;;
|
||
*)
|
||
printf '%s\n' "unexpected url: $url" >&2
|
||
exit 22
|
||
;;
|
||
esac
|
||
"#
|
||
}
|
||
|
||
#[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");
|
||
}
|
||
|
||
fn fake_launcher_config_response_script(payload: &str) -> String {
|
||
format!(
|
||
r#"#!/bin/sh
|
||
set -eu
|
||
url=""
|
||
while [ "$#" -gt 0 ]; do
|
||
case "$1" in
|
||
--url) url="$2"; shift 2 ;;
|
||
*) shift ;;
|
||
esac
|
||
done
|
||
case "$url" in
|
||
*/api/launcher/game/config)
|
||
printf '%s' '{payload}'
|
||
;;
|
||
*)
|
||
printf '%s\n' "unexpected url: $url" >&2
|
||
exit 22
|
||
;;
|
||
esac
|
||
"#,
|
||
payload = payload
|
||
)
|
||
}
|
||
|
||
fn fake_launcher_http_404_script() -> &'static str {
|
||
r#"#!/bin/sh
|
||
set -eu
|
||
printf 'curl: (22) The requested URL returned error: 404\n' >&2
|
||
exit 22
|
||
"#
|
||
}
|
||
|
||
fn launcher_service_with_script(script: &str) -> (TempDir, OfficialLauncherBootstrapService) {
|
||
let bin_dir = TempDir::new().unwrap();
|
||
let curl_path = bin_dir.path().join("curl");
|
||
write_shell_script(&curl_path, script);
|
||
let service = OfficialLauncherBootstrapService::with_curl_command(
|
||
"1.7.2",
|
||
curl_path.to_string_lossy().to_string(),
|
||
)
|
||
.with_retry_attempts(1);
|
||
(bin_dir, service)
|
||
}
|
||
|
||
#[test]
|
||
fn launcher_api_non_200_maps_to_launcher_api_rejected() {
|
||
let (_bin, service) = launcher_service_with_script(&fake_launcher_config_response_script(
|
||
r#"{"code":403,"message":"version rejected","data":{}}"#,
|
||
));
|
||
let error = service.fetch_game_config().unwrap_err();
|
||
assert_eq!(error.code().id(), ErrorCode::LAUNCHER_API_REJECTED.id());
|
||
}
|
||
|
||
#[test]
|
||
fn launcher_api_bad_json_maps_to_launcher_response_invalid() {
|
||
let (_bin, service) =
|
||
launcher_service_with_script(&fake_launcher_config_response_script("not-json"));
|
||
let error = service.fetch_game_config().unwrap_err();
|
||
assert_eq!(error.code().id(), ErrorCode::LAUNCHER_RESPONSE_INVALID.id());
|
||
}
|
||
|
||
#[test]
|
||
fn launcher_http_404_preserves_network_error_code() {
|
||
let (_bin, service) = launcher_service_with_script(fake_launcher_http_404_script());
|
||
let error = service.fetch_game_config().unwrap_err();
|
||
assert_eq!(error.code().id(), ErrorCode::HTTP_NOT_FOUND.id());
|
||
}
|
||
|
||
#[test]
|
||
fn non_official_manifest_url_maps_to_non_official_url() {
|
||
let (_bin, service) = launcher_service_with_script(fake_launcher_http_404_script());
|
||
let error = service
|
||
.fetch_remote_manifest("https://launcher-pkg-ba-jp.bluearchive.cafe/prod/manifest.json")
|
||
.unwrap_err();
|
||
assert_eq!(error.code().id(), ErrorCode::NON_OFFICIAL_URL.id());
|
||
}
|
||
|
||
#[test]
|
||
fn launcher_package_url_maps_rejections_to_error_codes() {
|
||
// cdn_root 来自远端 API 响应:非官方根是安全边界拒绝。
|
||
let error = launcher_package_url(
|
||
"https://launcher-pkg-ba-jp.bluearchive.cafe",
|
||
"prod/game.zip",
|
||
)
|
||
.unwrap_err();
|
||
assert_eq!(error.code().id(), ErrorCode::NON_OFFICIAL_URL.id());
|
||
|
||
// file_path 来自远端 manifest 内容:非法路径是远端内容缺陷。
|
||
let error = launcher_package_url(
|
||
"https://launcher-pkg-ba-jp.yo-star.com",
|
||
"prod/../escape.zip",
|
||
)
|
||
.unwrap_err();
|
||
assert_eq!(error.code().id(), ErrorCode::LAUNCHER_RESPONSE_INVALID.id());
|
||
}
|
||
|
||
#[test]
|
||
fn empty_launcher_version_maps_to_invalid_argument() {
|
||
// 空 --launcher-version 是用户参数错误,应在发起任何请求前归
|
||
// INVALID_ARGUMENT(curl 指向不存在的路径以证明未发起请求)。
|
||
let service = OfficialLauncherBootstrapService::with_curl_command(
|
||
"",
|
||
"/nonexistent/curl-must-not-run",
|
||
);
|
||
let error = service.fetch_game_config().unwrap_err();
|
||
assert_eq!(error.code().id(), ErrorCode::INVALID_ARGUMENT.id());
|
||
}
|
||
|
||
#[test]
|
||
fn launcher_manifest_without_files_maps_to_launcher_response_invalid() {
|
||
let bin_dir = TempDir::new().unwrap();
|
||
let curl_path = bin_dir.path().join("curl");
|
||
write_shell_script(
|
||
&curl_path,
|
||
r#"#!/bin/sh
|
||
printf '%s' '{"source":"prod/game.zip","file":[]}'
|
||
"#,
|
||
);
|
||
let service = OfficialLauncherBootstrapService::with_curl_command(
|
||
"1.7.2",
|
||
curl_path.to_string_lossy().to_string(),
|
||
);
|
||
let error = service
|
||
.fetch_remote_manifest("https://launcher-pkg-ba-jp.yo-star.com/prod/manifest.json")
|
||
.unwrap_err();
|
||
assert_eq!(error.code().id(), ErrorCode::LAUNCHER_RESPONSE_INVALID.id());
|
||
}
|
||
|
||
#[test]
|
||
fn retries_transient_launcher_api_failures() {
|
||
let bin_dir = TempDir::new().unwrap();
|
||
let curl_path = bin_dir.path().join("curl");
|
||
write_shell_script(&curl_path, fake_flaky_launcher_curl_script());
|
||
|
||
let service = OfficialLauncherBootstrapService::with_curl_command(
|
||
"1.7.2",
|
||
curl_path.to_string_lossy().to_string(),
|
||
)
|
||
.with_retry_attempts(2);
|
||
|
||
let config = service.fetch_game_config().unwrap();
|
||
|
||
assert_eq!(config.game_latest_version, "1.70.436321");
|
||
assert_eq!(
|
||
fs::read_to_string(curl_path.with_extension("state")).unwrap(),
|
||
"2"
|
||
);
|
||
}
|
||
}
|