From e9b7ce219fcf2bc642f00376306aeab3fbbe065d Mon Sep 17 00:00:00 2001 From: Yuyi-Oak <1722157266@qq.com> Date: Fri, 17 Jul 2026 09:42:17 -0700 Subject: [PATCH] =?UTF-8?q?fix(official-sync):=20=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=E5=AF=B9=E6=8A=97=E6=80=A7=E5=AE=A1=E6=9F=A5=E5=8F=91=E7=8E=B0?= =?UTF-8?q?=E7=9A=84=20launcher=20=E9=93=BE=E9=94=99=E8=AF=AF=E7=A0=81?= =?UTF-8?q?=E8=AF=AF=E5=BD=92=E7=B1=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对 d2466d6 做三视角对抗性核查(错误码语义/消费者破坏面/测试与文档), 确认 8 项(去重后 5 类)误归类,全部集中在 official_game_main_config 的 From 兜底面。逐项修正: - 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 --- USERGUIDE.md | 2 +- core/src/error_code.rs | 4 +- .../src/official_game_main_config.rs | 253 ++++++++++++++++-- infrastructure/src/official_launcher.rs | 81 +++++- 4 files changed, 305 insertions(+), 35 deletions(-) diff --git a/USERGUIDE.md b/USERGUIDE.md index 2d326ae..8379a50 100644 --- a/USERGUIDE.md +++ b/USERGUIDE.md @@ -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-600002` | unityfs_parse_failed | 否 | UnityFS 解析失败 | | `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-700002` | rpc_invalid_params | 否 | RPC 参数无效 | | `BAT-ERR-700003` | rpc_not_implemented | 否 | 方法/命名空间尚未实现 | diff --git a/core/src/error_code.rs b/core/src/error_code.rs index 0e427e8..5175c20 100644 --- a/core/src/error_code.rs +++ b/core/src/error_code.rs @@ -206,7 +206,9 @@ impl ErrorCode { pub const UNITYFS_PARSE_FAILED: Self = Self::new(600_002, "unityfs_parse_failed", false); /// GameMainConfig 解密/解析失败。 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 = Self::new(600_004, "launcher_response_invalid", false); diff --git a/infrastructure/src/official_game_main_config.rs b/infrastructure/src/official_game_main_config.rs index b3f0cef..074959c 100644 --- a/infrastructure/src/official_game_main_config.rs +++ b/infrastructure/src/official_game_main_config.rs @@ -171,7 +171,18 @@ impl OfficialGameMainConfigBootstrapService { match self.download_file(primary_url, destination) { Ok(()) => Ok(()), 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| { // 保留备用 CDN 失败的错误码(两次都失败时以最终一次为准)。 DownloadError::new( @@ -209,8 +220,13 @@ impl OfficialGameMainConfigBootstrapService { fs::create_dir_all(&extract_root) .map_err(|error| format!("创建解压目录失败:{error}"))?; self.extract_archive(&archive_path, &extract_root)?; - let resources_assets = find_resources_assets(&extract_root) - .ok_or_else(|| "官方启动器包内没有找到 resources.assets".to_string())?; + // 包内容缺少必需文件也是 launcher 链内容缺陷(远端可触发)。 + 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)) } GameMainConfigSource::ManifestFile { source_dir, file } => { @@ -224,14 +240,17 @@ impl OfficialGameMainConfigBootstrapService { &relative_path, &resources_assets, )?; - verify_manifest_file_size(&resources_assets, file) - .map_err(|error| DownloadError::new(ErrorCode::SIZE_MISMATCH, error))?; + verify_manifest_file_size(&resources_assets, file)?; 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) .arg("-q") .arg(archive_path) @@ -239,18 +258,24 @@ impl OfficialGameMainConfigBootstrapService { .arg(destination) .output() .map_err(|error| { - format!( - "启动 unzip 命令失败 {}:{error}", - self.unzip_command.display() + DownloadError::new( + ErrorCode::INTERNAL, + format!( + "启动 unzip 命令失败 {}:{error}", + self.unzip_command.display() + ), ) })?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!( - "解压失败 {}:{}", - archive_path.display(), - stderr.trim() + // validate_zip_structure 只校验 EOCD/中央目录,不校验压缩数据流; + // 数据区损坏(bad CRC 等)会在这里首次暴露。解压失败的主导成因是 + // 损坏/截断的下载,归完整性域 ZIP_STRUCTURE_INVALID;少数本地原因 + // (磁盘满、权限)的真实信息保留在消息的 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> { Archive(&'a str), ManifestFile { @@ -270,7 +296,7 @@ fn select_game_main_config_source<'a>( game_config: &'a YostarJpLauncherGameConfig, manifest_source: &'a str, manifest: &'a YostarJpLauncherRemoteManifest, -) -> Result, String> { +) -> Result, DownloadError> { if is_launcher_archive_path(manifest_source) { return Ok(GameMainConfigSource::Archive(manifest_source)); } @@ -282,8 +308,13 @@ fn select_game_main_config_source<'a>( file, }); } - return Err(format!( - "官方启动器 manifest 目录 source 缺少 BlueArchive_Data/resources.assets 条目:{manifest_source}" + // 由远端 manifest 内容触发:目录 source 缺少必需文件条目。官方 manifest + // 布局已发生过一次演进(单 ZIP → 目录 source),这是现实的主要失败面。 + return Err(DownloadError::new( + ErrorCode::LAUNCHER_RESPONSE_INVALID, + format!( + "官方启动器 manifest 目录 source 缺少 BlueArchive_Data/resources.assets 条目:{manifest_source}" + ), )); } @@ -293,9 +324,12 @@ fn select_game_main_config_source<'a>( )); } - Err(format!( - "官方启动器元数据没有可用的游戏包路径:source={manifest_source}, game_latest_file_path={}", - game_config.game_latest_file_path + Err(DownloadError::new( + ErrorCode::LAUNCHER_RESPONSE_INVALID, + format!( + "官方启动器元数据没有可用的游戏包路径:source={manifest_source}, game_latest_file_path={}", + game_config.game_latest_file_path + ), )) } @@ -369,18 +403,31 @@ fn normalize_safe_path(path: &str) -> Option { fn verify_manifest_file_size( path: &Path, file: &YostarJpLauncherManifestFile, -) -> Result<(), String> { - let expected_size = file - .size - .parse::() - .map_err(|error| format!("官方启动器 manifest 中 {} 的 size 无效:{error}", file.path))?; +) -> Result<(), DownloadError> { + // 三种失败原因分别归码:manifest size 字段无效是远端内容缺陷 + // (LAUNCHER_RESPONSE_INVALID);本地读文件失败是内部错误(INTERNAL); + // 只有真正比较过且不相等才是 SIZE_MISMATCH。 + let expected_size = file.size.parse::().map_err(|error| { + DownloadError::new( + ErrorCode::LAUNCHER_RESPONSE_INVALID, + format!("官方启动器 manifest 中 {} 的 size 无效:{error}", file.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(); if actual_size != expected_size { - return Err(format!( - "已下载官方启动器文件大小不匹配 {}:期望 {},实际 {}", - file.path, expected_size, actual_size + return Err(DownloadError::new( + ErrorCode::SIZE_MISMATCH, + format!( + "已下载官方启动器文件大小不匹配 {}:期望 {},实际 {}", + file.path, expected_size, actual_size + ), )); } Ok(()) @@ -484,4 +531,154 @@ esac assert!(urls .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")); + } } diff --git a/infrastructure/src/official_launcher.rs b/infrastructure/src/official_launcher.rs index 193f012..a56c735 100644 --- a/infrastructure/src/official_launcher.rs +++ b/infrastructure/src/official_launcher.rs @@ -217,16 +217,19 @@ impl OfficialLauncherBootstrapService { 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::MANIFEST_PARSE_FAILED, + ErrorCode::LAUNCHER_RESPONSE_INVALID, format!("解析官方启动器 manifest 失败:{error}"), ) })?; if manifest.files.is_empty() { return Err(DownloadError::new( - ErrorCode::MANIFEST_PARSE_FAILED, + ErrorCode::LAUNCHER_RESPONSE_INVALID, "官方启动器远端 manifest 没有文件条目", )); } @@ -258,6 +261,14 @@ impl OfficialLauncherBootstrapService { /// Fetches a signed official launcher API endpoint and parses the generic /// response envelope. pub fn fetch_launcher_envelope(&self, path: &str) -> Result { + // 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)?; @@ -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 /// relative package path. -pub fn launcher_package_url(cdn_root: &str, file_path: &str) -> Result { +/// +/// 生产调用方传入的 `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 { 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!( "{}/{}", cdn_root.trim_end_matches('/'), @@ -703,6 +723,57 @@ exit 22 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();