Files
BlueArchiveToolkit/infrastructure/src/official_launcher.rs
T
nyaKazuhaandClaude Fable 5 e9b7ce219f fix(official-sync): 修正对抗性审查发现的 launcher 链错误码误归类
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>
2026-07-17 09:42:17 -07:00

798 lines
27 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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_INVALIDMANIFEST_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_ARGUMENTcurl 指向不存在的路径以证明未发起请求)。
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"
);
}
}