Files
BlueArchiveToolkit/core/src/error_code.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

407 lines
16 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.
//! 统一错误码模型。
//!
//! 为 CLI、daemon RPC 和结构化日志提供一套稳定的数字错误码(`BAT-ERR-NNNNNN`)、
//! 错误类别(kind)、问题位置(location)和可重试标记,作为项目公共错误契约。
//!
//! 码格式:`BAT-ERR-<域 3 位><序号 3 位>`,共 6 位,例如 `BAT-ERR-300012`。
//! 域号按百位分大类,中间位保留给子域。
use serde::ser::{Serialize, SerializeStruct, Serializer};
/// 错误大类(域),对应错误码首位所在的百位区间。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ErrorDomain {
/// 输入/配置(`1xxxxx`):CLI 参数、配置校验、代理配置等。
Input,
/// 路径/安全边界(`2xxxxx`):危险目录、路径逃逸、symlink、权限。
PathSecurity,
/// 网络/下载(`3xxxxx`):curl HTTP/网络错误、代理故障、重试耗尽、quarantine。
Network,
/// 校验/完整性(`4xxxxx`):BLAKE3、官方 seed hash、size、ZIP 结构。
Integrity,
/// 发布/版本状态/存储(`5xxxxx`):staging、原子发布、version-state、锁、CAS。
PublishStorage,
/// 解析/适配(`6xxxxx`):manifest、UnityFS、GameMainConfig。
Parse,
/// 任务/RPC`7xxxxx`):未知方法、参数非法、未实现、任务不存在。
TaskRpc,
/// 内部/未知(`9xxxxx`):兜底。
Internal,
}
impl ErrorDomain {
/// 从错误码数字推断所属域。
pub fn from_number(number: u32) -> Self {
match number / 100_000 {
1 => Self::Input,
2 => Self::PathSecurity,
3 => Self::Network,
4 => Self::Integrity,
5 => Self::PublishStorage,
6 => Self::Parse,
7 => Self::TaskRpc,
_ => Self::Internal,
}
}
/// 返回域的稳定英文标签。
pub fn label(self) -> &'static str {
match self {
Self::Input => "input",
Self::PathSecurity => "path_security",
Self::Network => "network",
Self::Integrity => "integrity",
Self::PublishStorage => "publish_storage",
Self::Parse => "parse",
Self::TaskRpc => "task_rpc",
Self::Internal => "internal",
}
}
}
/// 单个稳定错误码:数字、类别 slug 和默认可重试标记。
///
/// 通过命名常量引用(如 [`ErrorCode::HTTP_FORBIDDEN`]),保证码值稳定、可查表。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ErrorCode {
number: u32,
kind: &'static str,
retryable: bool,
}
impl ErrorCode {
const fn new(number: u32, kind: &'static str, retryable: bool) -> Self {
Self {
number,
kind,
retryable,
}
}
/// 返回稳定的展示 ID,例如 `BAT-ERR-300012`。
pub fn id(&self) -> String {
format!("BAT-ERR-{:06}", self.number)
}
/// 返回错误码数字。
pub fn number(&self) -> u32 {
self.number
}
/// 返回错误类别 slug(英文、稳定)。
pub fn kind(&self) -> &'static str {
self.kind
}
/// 返回该类错误默认是否可重试。
pub fn retryable(&self) -> bool {
self.retryable
}
/// 返回所属错误域。
pub fn domain(&self) -> ErrorDomain {
ErrorDomain::from_number(self.number)
}
}
impl Serialize for ErrorCode {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&self.id())
}
}
/// 错误码码表。新增错误码只需在此登记,`docs`/`USERGUIDE.md` 从这里同步。
impl ErrorCode {
// ---- 100xxx 输入/配置 ----
/// 缺少应用版本(未传 `--app-version` 且未启用 `--auto-discover`)。
pub const MISSING_APP_VERSION: Self = Self::new(100_001, "missing_app_version", false);
/// 缺少连接组。
pub const MISSING_CONNECTION_GROUP: Self =
Self::new(100_002, "missing_connection_group", false);
/// 缺少服务器信息来源。
pub const MISSING_SERVER_INFO_SOURCE: Self =
Self::new(100_003, "missing_server_info_source", false);
/// 代理 URL scheme 不受支持。
pub const INVALID_PROXY_SCHEME: Self = Self::new(100_004, "invalid_proxy_scheme", false);
/// 命令行参数无效。
pub const INVALID_ARGUMENT: Self = Self::new(100_010, "invalid_argument", false);
// ---- 200xxx 路径/安全边界 ----
/// 输出目录被判定为危险路径。
pub const DANGEROUS_OUTPUT_ROOT: Self = Self::new(200_001, "dangerous_output_root", false);
/// 路径逃逸出允许根目录。
pub const PATH_ESCAPE: Self = Self::new(200_002, "path_escape", false);
/// 目标不允许是 symlink 或路径组件含 symlink。
pub const SYMLINK_REJECTED: Self = Self::new(200_003, "symlink_rejected", false);
/// 文件权限或模式错误。
pub const FILE_PERMISSION: Self = Self::new(200_004, "file_permission", false);
// ---- 300xxx 网络/下载 ----
/// HTTP 403。
pub const HTTP_FORBIDDEN: Self = Self::new(300_001, "http_forbidden", false);
/// HTTP 404。
pub const HTTP_NOT_FOUND: Self = Self::new(300_002, "http_not_found", false);
/// HTTP 其它 4xx。
pub const HTTP_CLIENT_ERROR: Self = Self::new(300_003, "http_client_error", false);
/// HTTP 429 / 请求过多。
pub const HTTP_TOO_MANY_REQUESTS: Self = Self::new(300_004, "http_too_many_requests", true);
/// HTTP 5xx。
pub const HTTP_SERVER_ERROR: Self = Self::new(300_005, "http_server_error", true);
/// DNS 解析失败。
pub const NETWORK_DNS: Self = Self::new(300_010, "network_dns", true);
/// 连接失败。
pub const NETWORK_CONNECT: Self = Self::new(300_011, "network_connect", true);
/// 超时。
pub const NETWORK_TIMEOUT: Self = Self::new(300_012, "network_timeout", true);
/// TLS 失败。
pub const NETWORK_TLS: Self = Self::new(300_013, "network_tls", true);
/// 传输中断。
pub const NETWORK_INTERRUPTED: Self = Self::new(300_014, "network_interrupted", true);
/// 其它网络错误。
pub const NETWORK_OTHER: Self = Self::new(300_015, "network_other", true);
/// 下载重试次数耗尽。
pub const RETRY_EXHAUSTED: Self = Self::new(300_020, "retry_exhausted", false);
/// URL 因反复失败进入 quarantine。
pub const QUARANTINED: Self = Self::new(300_021, "quarantined", false);
/// URL 不是官方 host(被拒绝)。
pub const NON_OFFICIAL_URL: Self = Self::new(300_030, "non_official_url", false);
/// 官方启动器 API 返回非 200 业务码(版本/鉴权被拒等)。
pub const LAUNCHER_API_REJECTED: Self = Self::new(300_031, "launcher_api_rejected", false);
/// 代理自身故障(认证/解析/连接代理失败)。
pub const PROXY_FAILURE: Self = Self::new(310_001, "proxy_failure", false);
// ---- 400xxx 校验/完整性 ----
/// 本地 BLAKE3 与 manifest 不符。
pub const BLAKE3_MISMATCH: Self = Self::new(400_001, "blake3_mismatch", false);
/// 官方 seed `.hash`xxHash32)校验不符。
pub const OFFICIAL_HASH_MISMATCH: Self = Self::new(400_002, "official_hash_mismatch", false);
/// 文件大小与 manifest 不符。
pub const SIZE_MISMATCH: Self = Self::new(400_003, "size_mismatch", false);
/// ZIP 结构无效。
pub const ZIP_STRUCTURE_INVALID: Self = Self::new(400_004, "zip_structure_invalid", false);
// ---- 500xxx 发布/版本状态/存储 ----
/// 资源目录锁冲突。
pub const RESOURCE_LOCKED: Self = Self::new(500_001, "resource_locked", false);
/// staging 准备失败。
pub const STAGING_PREPARE_FAILED: Self = Self::new(500_002, "staging_prepare_failed", false);
/// 原子发布失败。
pub const PUBLISH_FAILED: Self = Self::new(500_003, "publish_failed", false);
/// 版本状态写入失败。
pub const VERSION_STATE_WRITE_FAILED: Self =
Self::new(500_004, "version_state_write_failed", true);
/// CAS 对象 Hash 不匹配。
pub const CAS_HASH_MISMATCH: Self = Self::new(500_010, "cas_hash_mismatch", false);
/// CAS 对象不存在。
pub const CAS_OBJECT_NOT_FOUND: Self = Self::new(500_011, "cas_object_not_found", false);
/// CAS 引用计数下溢。
pub const CAS_REFERENCE_UNDERFLOW: Self = Self::new(500_012, "cas_reference_underflow", false);
/// CAS 元数据库错误。
pub const CAS_DATABASE: Self = Self::new(500_013, "cas_database", false);
// ---- 600xxx 解析/适配 ----
/// Manifest / Addressables catalog 解析失败。
pub const MANIFEST_PARSE_FAILED: Self = Self::new(600_001, "manifest_parse_failed", false);
/// UnityFS 解析失败。
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 响应、远端 manifest 或包内容无法解析
/// 或缺少必需内容(版本/路径/URL/文件条目等)。资源侧 manifest /
/// Addressables catalog 的解析失败归 `MANIFEST_PARSE_FAILED`。
pub const LAUNCHER_RESPONSE_INVALID: Self =
Self::new(600_004, "launcher_response_invalid", false);
// ---- 700xxx 任务/RPC ----
/// 未知 RPC 方法。
pub const RPC_UNKNOWN_METHOD: Self = Self::new(700_001, "rpc_unknown_method", false);
/// RPC 参数无效。
pub const RPC_INVALID_PARAMS: Self = Self::new(700_002, "rpc_invalid_params", false);
/// 方法/命名空间尚未实现。
pub const RPC_NOT_IMPLEMENTED: Self = Self::new(700_003, "rpc_not_implemented", false);
/// 任务不存在。
pub const TASK_NOT_FOUND: Self = Self::new(700_004, "task_not_found", false);
// ---- 900xxx 内部/未知 ----
/// 未归类的内部错误。
pub const INTERNAL: Self = Self::new(900_001, "internal", false);
}
/// 结构化 API 错误:进入 RPC envelope、CLI `--json` 输出和结构化日志的统一形态。
#[derive(Debug, Clone)]
pub struct ApiError {
code: ErrorCode,
location: &'static str,
message: String,
}
impl ApiError {
/// 构造错误:给定错误码、稳定的问题位置标签(如 `official-sync.download.pull_one`
/// 和人类可读消息。
pub fn new(code: ErrorCode, location: &'static str, message: impl Into<String>) -> Self {
Self {
code,
location,
message: message.into(),
}
}
/// 错误码。
pub fn code(&self) -> ErrorCode {
self.code
}
/// 问题位置标签。
pub fn location(&self) -> &'static str {
self.location
}
/// 人类可读消息。
pub fn message(&self) -> &str {
&self.message
}
/// 是否可重试(取自错误码默认值)。
pub fn retryable(&self) -> bool {
self.code.retryable()
}
}
impl std::fmt::Display for ApiError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
formatter,
"[{} {}] {} (@{})",
self.code.id(),
self.code.kind(),
self.message,
self.location
)
}
}
impl std::error::Error for ApiError {}
impl Serialize for ApiError {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let mut state = serializer.serialize_struct("ApiError", 6)?;
state.serialize_field("code", &self.code.id())?;
state.serialize_field("kind", self.code.kind())?;
state.serialize_field("domain", self.code.domain().label())?;
state.serialize_field("location", self.location)?;
state.serialize_field("message", &self.message)?;
state.serialize_field("retryable", &self.code.retryable())?;
state.end()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn code_id_is_zero_padded_six_digits() {
assert_eq!(ErrorCode::MISSING_APP_VERSION.id(), "BAT-ERR-100001");
assert_eq!(ErrorCode::HTTP_FORBIDDEN.id(), "BAT-ERR-300001");
assert_eq!(ErrorCode::PROXY_FAILURE.id(), "BAT-ERR-310001");
assert_eq!(ErrorCode::INTERNAL.id(), "BAT-ERR-900001");
}
#[test]
fn code_maps_to_domain() {
assert_eq!(ErrorCode::INVALID_ARGUMENT.domain(), ErrorDomain::Input);
assert_eq!(ErrorCode::PATH_ESCAPE.domain(), ErrorDomain::PathSecurity);
assert_eq!(ErrorCode::HTTP_SERVER_ERROR.domain(), ErrorDomain::Network);
assert_eq!(ErrorCode::PROXY_FAILURE.domain(), ErrorDomain::Network);
assert_eq!(ErrorCode::BLAKE3_MISMATCH.domain(), ErrorDomain::Integrity);
assert_eq!(
ErrorCode::PUBLISH_FAILED.domain(),
ErrorDomain::PublishStorage
);
assert_eq!(ErrorCode::UNITYFS_PARSE_FAILED.domain(), ErrorDomain::Parse);
assert_eq!(ErrorCode::TASK_NOT_FOUND.domain(), ErrorDomain::TaskRpc);
assert_eq!(ErrorCode::INTERNAL.domain(), ErrorDomain::Internal);
}
#[test]
fn retryable_reflects_code_default() {
assert!(ErrorCode::HTTP_SERVER_ERROR.retryable());
assert!(!ErrorCode::HTTP_FORBIDDEN.retryable());
assert!(!ErrorCode::PROXY_FAILURE.retryable());
}
#[test]
fn api_error_serializes_full_envelope_shape() {
let error = ApiError::new(
ErrorCode::PROXY_FAILURE,
"official-sync.download.pull_one",
"代理返回 407 认证失败",
);
let value = serde_json::to_value(&error).unwrap();
assert_eq!(value["code"], "BAT-ERR-310001");
assert_eq!(value["kind"], "proxy_failure");
assert_eq!(value["domain"], "network");
assert_eq!(value["location"], "official-sync.download.pull_one");
assert_eq!(value["message"], "代理返回 407 认证失败");
assert_eq!(value["retryable"], false);
}
#[test]
fn error_codes_are_unique() {
// 防止新增码值撞号:列举全部命名常量,断言 number 唯一。
let codes = [
ErrorCode::MISSING_APP_VERSION,
ErrorCode::MISSING_CONNECTION_GROUP,
ErrorCode::MISSING_SERVER_INFO_SOURCE,
ErrorCode::INVALID_PROXY_SCHEME,
ErrorCode::INVALID_ARGUMENT,
ErrorCode::DANGEROUS_OUTPUT_ROOT,
ErrorCode::PATH_ESCAPE,
ErrorCode::SYMLINK_REJECTED,
ErrorCode::FILE_PERMISSION,
ErrorCode::HTTP_FORBIDDEN,
ErrorCode::HTTP_NOT_FOUND,
ErrorCode::HTTP_CLIENT_ERROR,
ErrorCode::HTTP_TOO_MANY_REQUESTS,
ErrorCode::HTTP_SERVER_ERROR,
ErrorCode::NETWORK_DNS,
ErrorCode::NETWORK_CONNECT,
ErrorCode::NETWORK_TIMEOUT,
ErrorCode::NETWORK_TLS,
ErrorCode::NETWORK_INTERRUPTED,
ErrorCode::NETWORK_OTHER,
ErrorCode::RETRY_EXHAUSTED,
ErrorCode::QUARANTINED,
ErrorCode::NON_OFFICIAL_URL,
ErrorCode::LAUNCHER_API_REJECTED,
ErrorCode::PROXY_FAILURE,
ErrorCode::BLAKE3_MISMATCH,
ErrorCode::OFFICIAL_HASH_MISMATCH,
ErrorCode::SIZE_MISMATCH,
ErrorCode::ZIP_STRUCTURE_INVALID,
ErrorCode::RESOURCE_LOCKED,
ErrorCode::STAGING_PREPARE_FAILED,
ErrorCode::PUBLISH_FAILED,
ErrorCode::VERSION_STATE_WRITE_FAILED,
ErrorCode::CAS_HASH_MISMATCH,
ErrorCode::CAS_OBJECT_NOT_FOUND,
ErrorCode::CAS_REFERENCE_UNDERFLOW,
ErrorCode::CAS_DATABASE,
ErrorCode::MANIFEST_PARSE_FAILED,
ErrorCode::UNITYFS_PARSE_FAILED,
ErrorCode::GAME_MAIN_CONFIG_FAILED,
ErrorCode::LAUNCHER_RESPONSE_INVALID,
ErrorCode::RPC_UNKNOWN_METHOD,
ErrorCode::RPC_INVALID_PARAMS,
ErrorCode::RPC_NOT_IMPLEMENTED,
ErrorCode::TASK_NOT_FOUND,
ErrorCode::INTERNAL,
];
let mut numbers: Vec<u32> = codes.iter().map(ErrorCode::number).collect();
let total = numbers.len();
numbers.sort_unstable();
numbers.dedup();
assert_eq!(numbers.len(), total, "存在重复的错误码 number");
}
}