mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-07-22 04:15:14 +08:00
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>
This commit is contained in:
+1
-1
@@ -214,7 +214,7 @@ HTTPS_PROXY=http://user:pass@127.0.0.1:7890 bat --auto-discover --daemon
|
|||||||
| `BAT-ERR-600001` | manifest_parse_failed | 否 | Manifest / Addressables catalog 解析失败 |
|
| `BAT-ERR-600001` | manifest_parse_failed | 否 | Manifest / Addressables catalog 解析失败 |
|
||||||
| `BAT-ERR-600002` | unityfs_parse_failed | 否 | UnityFS 解析失败 |
|
| `BAT-ERR-600002` | unityfs_parse_failed | 否 | UnityFS 解析失败 |
|
||||||
| `BAT-ERR-600003` | game_main_config_failed | 否 | GameMainConfig 解密/解析失败 |
|
| `BAT-ERR-600003` | game_main_config_failed | 否 | GameMainConfig 解密/解析失败 |
|
||||||
| `BAT-ERR-600004` | launcher_response_invalid | 否 | 官方启动器 API 响应无法解析或缺少必需字段 |
|
| `BAT-ERR-600004` | launcher_response_invalid | 否 | 官方启动器链内容无效(API 响应、远端 manifest 或包内容无法解析/缺少必需内容) |
|
||||||
| `BAT-ERR-700001` | rpc_unknown_method | 否 | 未知 RPC 方法 |
|
| `BAT-ERR-700001` | rpc_unknown_method | 否 | 未知 RPC 方法 |
|
||||||
| `BAT-ERR-700002` | rpc_invalid_params | 否 | RPC 参数无效 |
|
| `BAT-ERR-700002` | rpc_invalid_params | 否 | RPC 参数无效 |
|
||||||
| `BAT-ERR-700003` | rpc_not_implemented | 否 | 方法/命名空间尚未实现 |
|
| `BAT-ERR-700003` | rpc_not_implemented | 否 | 方法/命名空间尚未实现 |
|
||||||
|
|||||||
@@ -206,7 +206,9 @@ impl ErrorCode {
|
|||||||
pub const UNITYFS_PARSE_FAILED: Self = Self::new(600_002, "unityfs_parse_failed", false);
|
pub const UNITYFS_PARSE_FAILED: Self = Self::new(600_002, "unityfs_parse_failed", false);
|
||||||
/// GameMainConfig 解密/解析失败。
|
/// GameMainConfig 解密/解析失败。
|
||||||
pub const GAME_MAIN_CONFIG_FAILED: Self = Self::new(600_003, "game_main_config_failed", false);
|
pub const GAME_MAIN_CONFIG_FAILED: Self = Self::new(600_003, "game_main_config_failed", false);
|
||||||
/// 官方启动器 API 响应无法解析或缺少必需字段(版本/路径/URL 等)。
|
/// 官方启动器链内容无效:API 响应、远端 manifest 或包内容无法解析
|
||||||
|
/// 或缺少必需内容(版本/路径/URL/文件条目等)。资源侧 manifest /
|
||||||
|
/// Addressables catalog 的解析失败归 `MANIFEST_PARSE_FAILED`。
|
||||||
pub const LAUNCHER_RESPONSE_INVALID: Self =
|
pub const LAUNCHER_RESPONSE_INVALID: Self =
|
||||||
Self::new(600_004, "launcher_response_invalid", false);
|
Self::new(600_004, "launcher_response_invalid", false);
|
||||||
|
|
||||||
|
|||||||
@@ -171,7 +171,18 @@ impl OfficialGameMainConfigBootstrapService {
|
|||||||
match self.download_file(primary_url, destination) {
|
match self.download_file(primary_url, destination) {
|
||||||
Ok(()) => Ok(()),
|
Ok(()) => Ok(()),
|
||||||
Err(primary_error) => {
|
Err(primary_error) => {
|
||||||
let backup_url = launcher_package_url(backup_cdn_root, relative_path)?;
|
// 备用 URL 无法构造(如 API 下发非官方备用 CDN 根)时不得丢弃
|
||||||
|
// 主地址失败上下文;错误码与两次下载都失败的策略一致:以最终
|
||||||
|
// 一次失败为准。
|
||||||
|
let backup_url =
|
||||||
|
launcher_package_url(backup_cdn_root, relative_path).map_err(|backup_error| {
|
||||||
|
DownloadError::new(
|
||||||
|
backup_error.code(),
|
||||||
|
format!(
|
||||||
|
"下载官方游戏资源失败:主地址失败({primary_error}),且备用 CDN 地址无法构造({backup_error})"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
self.download_file(&backup_url, destination).map_err(|backup_error| {
|
self.download_file(&backup_url, destination).map_err(|backup_error| {
|
||||||
// 保留备用 CDN 失败的错误码(两次都失败时以最终一次为准)。
|
// 保留备用 CDN 失败的错误码(两次都失败时以最终一次为准)。
|
||||||
DownloadError::new(
|
DownloadError::new(
|
||||||
@@ -209,8 +220,13 @@ impl OfficialGameMainConfigBootstrapService {
|
|||||||
fs::create_dir_all(&extract_root)
|
fs::create_dir_all(&extract_root)
|
||||||
.map_err(|error| format!("创建解压目录失败:{error}"))?;
|
.map_err(|error| format!("创建解压目录失败:{error}"))?;
|
||||||
self.extract_archive(&archive_path, &extract_root)?;
|
self.extract_archive(&archive_path, &extract_root)?;
|
||||||
let resources_assets = find_resources_assets(&extract_root)
|
// 包内容缺少必需文件也是 launcher 链内容缺陷(远端可触发)。
|
||||||
.ok_or_else(|| "官方启动器包内没有找到 resources.assets".to_string())?;
|
let resources_assets = find_resources_assets(&extract_root).ok_or_else(|| {
|
||||||
|
DownloadError::new(
|
||||||
|
ErrorCode::LAUNCHER_RESPONSE_INVALID,
|
||||||
|
"官方启动器包内没有找到 resources.assets",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
Ok((game_zip_url, resources_assets))
|
Ok((game_zip_url, resources_assets))
|
||||||
}
|
}
|
||||||
GameMainConfigSource::ManifestFile { source_dir, file } => {
|
GameMainConfigSource::ManifestFile { source_dir, file } => {
|
||||||
@@ -224,14 +240,17 @@ impl OfficialGameMainConfigBootstrapService {
|
|||||||
&relative_path,
|
&relative_path,
|
||||||
&resources_assets,
|
&resources_assets,
|
||||||
)?;
|
)?;
|
||||||
verify_manifest_file_size(&resources_assets, file)
|
verify_manifest_file_size(&resources_assets, file)?;
|
||||||
.map_err(|error| DownloadError::new(ErrorCode::SIZE_MISMATCH, error))?;
|
|
||||||
Ok((resources_assets_url, resources_assets))
|
Ok((resources_assets_url, resources_assets))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn extract_archive(&self, archive_path: &Path, destination: &Path) -> Result<(), String> {
|
fn extract_archive(
|
||||||
|
&self,
|
||||||
|
archive_path: &Path,
|
||||||
|
destination: &Path,
|
||||||
|
) -> Result<(), DownloadError> {
|
||||||
let output = Command::new(&self.unzip_command)
|
let output = Command::new(&self.unzip_command)
|
||||||
.arg("-q")
|
.arg("-q")
|
||||||
.arg(archive_path)
|
.arg(archive_path)
|
||||||
@@ -239,18 +258,24 @@ impl OfficialGameMainConfigBootstrapService {
|
|||||||
.arg(destination)
|
.arg(destination)
|
||||||
.output()
|
.output()
|
||||||
.map_err(|error| {
|
.map_err(|error| {
|
||||||
|
DownloadError::new(
|
||||||
|
ErrorCode::INTERNAL,
|
||||||
format!(
|
format!(
|
||||||
"启动 unzip 命令失败 {}:{error}",
|
"启动 unzip 命令失败 {}:{error}",
|
||||||
self.unzip_command.display()
|
self.unzip_command.display()
|
||||||
|
),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
if !output.status.success() {
|
if !output.status.success() {
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
return Err(format!(
|
// validate_zip_structure 只校验 EOCD/中央目录,不校验压缩数据流;
|
||||||
"解压失败 {}:{}",
|
// 数据区损坏(bad CRC 等)会在这里首次暴露。解压失败的主导成因是
|
||||||
archive_path.display(),
|
// 损坏/截断的下载,归完整性域 ZIP_STRUCTURE_INVALID;少数本地原因
|
||||||
stderr.trim()
|
// (磁盘满、权限)的真实信息保留在消息的 unzip stderr 中。
|
||||||
|
return Err(DownloadError::new(
|
||||||
|
ErrorCode::ZIP_STRUCTURE_INVALID,
|
||||||
|
format!("解压失败 {}:{}", archive_path.display(), stderr.trim()),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,6 +283,7 @@ impl OfficialGameMainConfigBootstrapService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
enum GameMainConfigSource<'a> {
|
enum GameMainConfigSource<'a> {
|
||||||
Archive(&'a str),
|
Archive(&'a str),
|
||||||
ManifestFile {
|
ManifestFile {
|
||||||
@@ -270,7 +296,7 @@ fn select_game_main_config_source<'a>(
|
|||||||
game_config: &'a YostarJpLauncherGameConfig,
|
game_config: &'a YostarJpLauncherGameConfig,
|
||||||
manifest_source: &'a str,
|
manifest_source: &'a str,
|
||||||
manifest: &'a YostarJpLauncherRemoteManifest,
|
manifest: &'a YostarJpLauncherRemoteManifest,
|
||||||
) -> Result<GameMainConfigSource<'a>, String> {
|
) -> Result<GameMainConfigSource<'a>, DownloadError> {
|
||||||
if is_launcher_archive_path(manifest_source) {
|
if is_launcher_archive_path(manifest_source) {
|
||||||
return Ok(GameMainConfigSource::Archive(manifest_source));
|
return Ok(GameMainConfigSource::Archive(manifest_source));
|
||||||
}
|
}
|
||||||
@@ -282,8 +308,13 @@ fn select_game_main_config_source<'a>(
|
|||||||
file,
|
file,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return Err(format!(
|
// 由远端 manifest 内容触发:目录 source 缺少必需文件条目。官方 manifest
|
||||||
|
// 布局已发生过一次演进(单 ZIP → 目录 source),这是现实的主要失败面。
|
||||||
|
return Err(DownloadError::new(
|
||||||
|
ErrorCode::LAUNCHER_RESPONSE_INVALID,
|
||||||
|
format!(
|
||||||
"官方启动器 manifest 目录 source 缺少 BlueArchive_Data/resources.assets 条目:{manifest_source}"
|
"官方启动器 manifest 目录 source 缺少 BlueArchive_Data/resources.assets 条目:{manifest_source}"
|
||||||
|
),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,9 +324,12 @@ fn select_game_main_config_source<'a>(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
Err(format!(
|
Err(DownloadError::new(
|
||||||
|
ErrorCode::LAUNCHER_RESPONSE_INVALID,
|
||||||
|
format!(
|
||||||
"官方启动器元数据没有可用的游戏包路径:source={manifest_source}, game_latest_file_path={}",
|
"官方启动器元数据没有可用的游戏包路径:source={manifest_source}, game_latest_file_path={}",
|
||||||
game_config.game_latest_file_path
|
game_config.game_latest_file_path
|
||||||
|
),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,18 +403,31 @@ fn normalize_safe_path(path: &str) -> Option<String> {
|
|||||||
fn verify_manifest_file_size(
|
fn verify_manifest_file_size(
|
||||||
path: &Path,
|
path: &Path,
|
||||||
file: &YostarJpLauncherManifestFile,
|
file: &YostarJpLauncherManifestFile,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), DownloadError> {
|
||||||
let expected_size = file
|
// 三种失败原因分别归码:manifest size 字段无效是远端内容缺陷
|
||||||
.size
|
// (LAUNCHER_RESPONSE_INVALID);本地读文件失败是内部错误(INTERNAL);
|
||||||
.parse::<u64>()
|
// 只有真正比较过且不相等才是 SIZE_MISMATCH。
|
||||||
.map_err(|error| format!("官方启动器 manifest 中 {} 的 size 无效:{error}", file.path))?;
|
let expected_size = file.size.parse::<u64>().map_err(|error| {
|
||||||
|
DownloadError::new(
|
||||||
|
ErrorCode::LAUNCHER_RESPONSE_INVALID,
|
||||||
|
format!("官方启动器 manifest 中 {} 的 size 无效:{error}", file.path),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
let actual_size = fs::metadata(path)
|
let actual_size = fs::metadata(path)
|
||||||
.map_err(|error| format!("读取已下载文件信息失败 {}:{error}", path.display()))?
|
.map_err(|error| {
|
||||||
|
DownloadError::new(
|
||||||
|
ErrorCode::INTERNAL,
|
||||||
|
format!("读取已下载文件信息失败 {}:{error}", path.display()),
|
||||||
|
)
|
||||||
|
})?
|
||||||
.len();
|
.len();
|
||||||
if actual_size != expected_size {
|
if actual_size != expected_size {
|
||||||
return Err(format!(
|
return Err(DownloadError::new(
|
||||||
|
ErrorCode::SIZE_MISMATCH,
|
||||||
|
format!(
|
||||||
"已下载官方启动器文件大小不匹配 {}:期望 {},实际 {}",
|
"已下载官方启动器文件大小不匹配 {}:期望 {},实际 {}",
|
||||||
file.path, expected_size, actual_size
|
file.path, expected_size, actual_size
|
||||||
|
),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -484,4 +531,154 @@ esac
|
|||||||
assert!(urls
|
assert!(urls
|
||||||
.contains("https://launcher-pkg-ba-jp-bk.yo-star.com/prod/manifest/resources.assets"));
|
.contains("https://launcher-pkg-ba-jp-bk.yo-star.com/prod/manifest/resources.assets"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn fake_all_404_curl_script() -> &'static str {
|
||||||
|
r#"#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
printf 'curl: (22) The requested URL returned error: 404\n' >&2
|
||||||
|
exit 22
|
||||||
|
"#
|
||||||
|
}
|
||||||
|
|
||||||
|
fn manifest_file(path: &str, size: &str) -> YostarJpLauncherManifestFile {
|
||||||
|
YostarJpLauncherManifestFile {
|
||||||
|
path: path.to_string(),
|
||||||
|
size: size.to_string(),
|
||||||
|
hash: "0".to_string(),
|
||||||
|
vc: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fallback_failure_keeps_final_error_code() {
|
||||||
|
// 主备两次下载都失败时,错误码以最终一次(备用 CDN)为准。
|
||||||
|
let temp = TempDir::new().unwrap();
|
||||||
|
let curl_path = temp.path().join("curl");
|
||||||
|
write_shell_script(&curl_path, fake_all_404_curl_script());
|
||||||
|
let service =
|
||||||
|
OfficialGameMainConfigBootstrapService::with_commands("1.17.0", &curl_path, "unzip");
|
||||||
|
let destination = temp.path().join("resources.assets");
|
||||||
|
|
||||||
|
let error = service
|
||||||
|
.download_file_with_fallback(
|
||||||
|
"https://launcher-pkg-ba-jp.yo-star.com/prod/manifest/resources.assets",
|
||||||
|
"https://launcher-pkg-ba-jp-bk.yo-star.com",
|
||||||
|
"prod/manifest/resources.assets",
|
||||||
|
&destination,
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert_eq!(error.code().id(), ErrorCode::HTTP_NOT_FOUND.id());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fallback_url_construction_failure_keeps_primary_context() {
|
||||||
|
// API 下发非官方备用 CDN 根时:错误码为 NON_OFFICIAL_URL(以最终失败
|
||||||
|
// 为准),且主地址失败原因不被丢弃。
|
||||||
|
let temp = TempDir::new().unwrap();
|
||||||
|
let curl_path = temp.path().join("curl");
|
||||||
|
write_shell_script(&curl_path, fake_all_404_curl_script());
|
||||||
|
let service =
|
||||||
|
OfficialGameMainConfigBootstrapService::with_commands("1.17.0", &curl_path, "unzip");
|
||||||
|
let destination = temp.path().join("resources.assets");
|
||||||
|
|
||||||
|
let error = service
|
||||||
|
.download_file_with_fallback(
|
||||||
|
"https://launcher-pkg-ba-jp.yo-star.com/prod/manifest/resources.assets",
|
||||||
|
"https://launcher-pkg-ba-jp.bluearchive.cafe",
|
||||||
|
"prod/manifest/resources.assets",
|
||||||
|
&destination,
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert_eq!(error.code().id(), ErrorCode::NON_OFFICIAL_URL.id());
|
||||||
|
let message = error.to_string();
|
||||||
|
assert!(message.contains("主地址失败"));
|
||||||
|
assert!(message.contains("404"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_resources_assets_entry_maps_to_launcher_response_invalid() {
|
||||||
|
// 目录型 source 但 manifest 缺少 resources.assets 条目:远端内容缺陷。
|
||||||
|
let game_config: YostarJpLauncherGameConfig = serde_json::from_str(
|
||||||
|
r#"{"game_latest_version":"1.70.0","game_latest_file_path":"/BlueArchive_JP-game"}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let manifest = YostarJpLauncherRemoteManifest {
|
||||||
|
source: Some("/BlueArchive_JP-game".to_string()),
|
||||||
|
files: vec![manifest_file("/BlueArchive.exe", "100")],
|
||||||
|
};
|
||||||
|
|
||||||
|
let error = select_game_main_config_source(&game_config, "/BlueArchive_JP-game", &manifest)
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(error.code().id(), ErrorCode::LAUNCHER_RESPONSE_INVALID.id());
|
||||||
|
|
||||||
|
// 元数据完全没有可用包路径时同理。
|
||||||
|
let error =
|
||||||
|
select_game_main_config_source(&game_config, "bad?query", &manifest).unwrap_err();
|
||||||
|
assert_eq!(error.code().id(), ErrorCode::LAUNCHER_RESPONSE_INVALID.id());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verify_manifest_file_size_maps_each_failure_cause() {
|
||||||
|
let temp = TempDir::new().unwrap();
|
||||||
|
let downloaded = temp.path().join("resources.assets");
|
||||||
|
fs::write(&downloaded, b"12345").unwrap();
|
||||||
|
|
||||||
|
// manifest size 字段无效:远端内容缺陷。
|
||||||
|
let error = verify_manifest_file_size(
|
||||||
|
&downloaded,
|
||||||
|
&manifest_file("/BlueArchive_Data/resources.assets", "not-a-number"),
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(error.code().id(), ErrorCode::LAUNCHER_RESPONSE_INVALID.id());
|
||||||
|
|
||||||
|
// 真正的大小不符。
|
||||||
|
let error = verify_manifest_file_size(
|
||||||
|
&downloaded,
|
||||||
|
&manifest_file("/BlueArchive_Data/resources.assets", "999"),
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(error.code().id(), ErrorCode::SIZE_MISMATCH.id());
|
||||||
|
|
||||||
|
// 本地读文件失败:内部错误。
|
||||||
|
let error = verify_manifest_file_size(
|
||||||
|
&temp.path().join("missing-file"),
|
||||||
|
&manifest_file("/BlueArchive_Data/resources.assets", "5"),
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(error.code().id(), ErrorCode::INTERNAL.id());
|
||||||
|
|
||||||
|
// 大小一致时通过。
|
||||||
|
verify_manifest_file_size(
|
||||||
|
&downloaded,
|
||||||
|
&manifest_file("/BlueArchive_Data/resources.assets", "5"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_failure_maps_to_zip_structure_invalid() {
|
||||||
|
// 结构校验通过但数据区损坏的下载会在 unzip 阶段失败(bad CRC),
|
||||||
|
// 应归完整性域而非 internal。
|
||||||
|
let temp = TempDir::new().unwrap();
|
||||||
|
let unzip_path = temp.path().join("unzip");
|
||||||
|
write_shell_script(
|
||||||
|
&unzip_path,
|
||||||
|
r#"#!/bin/sh
|
||||||
|
printf 'bad CRC\n' >&2
|
||||||
|
exit 2
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
let service =
|
||||||
|
OfficialGameMainConfigBootstrapService::with_commands("1.17.0", "curl", &unzip_path);
|
||||||
|
let archive = temp.path().join("game.zip");
|
||||||
|
fs::write(&archive, b"whatever").unwrap();
|
||||||
|
|
||||||
|
let error = service
|
||||||
|
.extract_archive(&archive, &temp.path().join("extract"))
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(error.code().id(), ErrorCode::ZIP_STRUCTURE_INVALID.id());
|
||||||
|
assert!(error.to_string().contains("bad CRC"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -217,16 +217,19 @@ impl OfficialLauncherBootstrapService {
|
|||||||
command
|
command
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
// launcher 链内容问题(API 响应、远端 manifest、包内容)统一归
|
||||||
|
// LAUNCHER_RESPONSE_INVALID;MANIFEST_PARSE_FAILED 保留给资源侧
|
||||||
|
// manifest / Addressables catalog。
|
||||||
let manifest: YostarJpLauncherRemoteManifest = serde_json::from_slice(&output.stdout)
|
let manifest: YostarJpLauncherRemoteManifest = serde_json::from_slice(&output.stdout)
|
||||||
.map_err(|error| {
|
.map_err(|error| {
|
||||||
DownloadError::new(
|
DownloadError::new(
|
||||||
ErrorCode::MANIFEST_PARSE_FAILED,
|
ErrorCode::LAUNCHER_RESPONSE_INVALID,
|
||||||
format!("解析官方启动器 manifest 失败:{error}"),
|
format!("解析官方启动器 manifest 失败:{error}"),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
if manifest.files.is_empty() {
|
if manifest.files.is_empty() {
|
||||||
return Err(DownloadError::new(
|
return Err(DownloadError::new(
|
||||||
ErrorCode::MANIFEST_PARSE_FAILED,
|
ErrorCode::LAUNCHER_RESPONSE_INVALID,
|
||||||
"官方启动器远端 manifest 没有文件条目",
|
"官方启动器远端 manifest 没有文件条目",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -258,6 +261,14 @@ impl OfficialLauncherBootstrapService {
|
|||||||
/// Fetches a signed official launcher API endpoint and parses the generic
|
/// Fetches a signed official launcher API endpoint and parses the generic
|
||||||
/// response envelope.
|
/// response envelope.
|
||||||
pub fn fetch_launcher_envelope(&self, path: &str) -> Result<LauncherEnvelope, DownloadError> {
|
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 url = launcher_api_url(path)?;
|
||||||
let authorization = launcher_authorization_header(&self.launcher_version, "", None)?;
|
let authorization = launcher_authorization_header(&self.launcher_version, "", None)?;
|
||||||
|
|
||||||
@@ -363,12 +374,21 @@ pub fn is_official_launcher_package_url(url: &str) -> bool {
|
|||||||
|
|
||||||
/// Builds an official launcher package URL from an official CDN root and a
|
/// Builds an official launcher package URL from an official CDN root and a
|
||||||
/// relative package path.
|
/// relative package path.
|
||||||
pub fn launcher_package_url(cdn_root: &str, file_path: &str) -> Result<String, String> {
|
///
|
||||||
|
/// 生产调用方传入的 `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) {
|
if !is_official_launcher_package_url(cdn_root) {
|
||||||
return Err(format!("启动器 CDN 根地址不是官方地址:{cdn_root}"));
|
return Err(DownloadError::new(
|
||||||
|
ErrorCode::NON_OFFICIAL_URL,
|
||||||
|
format!("启动器 CDN 根地址不是官方地址:{cdn_root}"),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
validate_launcher_package_path(file_path)?;
|
validate_launcher_package_path(file_path)
|
||||||
|
.map_err(|error| DownloadError::new(ErrorCode::LAUNCHER_RESPONSE_INVALID, error))?;
|
||||||
Ok(format!(
|
Ok(format!(
|
||||||
"{}/{}",
|
"{}/{}",
|
||||||
cdn_root.trim_end_matches('/'),
|
cdn_root.trim_end_matches('/'),
|
||||||
@@ -703,6 +723,57 @@ exit 22
|
|||||||
assert_eq!(error.code().id(), ErrorCode::NON_OFFICIAL_URL.id());
|
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]
|
#[test]
|
||||||
fn retries_transient_launcher_api_failures() {
|
fn retries_transient_launcher_api_failures() {
|
||||||
let bin_dir = TempDir::new().unwrap();
|
let bin_dir = TempDir::new().unwrap();
|
||||||
|
|||||||
Reference in New Issue
Block a user