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>
407 lines
16 KiB
Rust
407 lines
16 KiB
Rust
//! 统一错误码模型。
|
||
//!
|
||
//! 为 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");
|
||
}
|
||
}
|