merge: origin/dev(1623f68)合入 M197 分支——解 golden/tail.rs 冲突
基线:合的是 **origin/dev**(`1623f68`)。`origin/main` 是 D41 的孤儿发布快照 (`d616d65` parents 为空),不参与合并。用 merge 而不是 rebase,保住三轮复审 clean 的 tip。 冲突只有一个:`crates/i18n/src/catalog/golden/tail.rs`(热点文件,多条 mission 都往这里加 key)。 解法(不重排别人的行、不改别人的译文): 1. 以 **dev 侧 398 行**为底稿(含其它 mission 后加的 key); 2. 把 M197 的 68 行(`SettingsJavaTooNew` … `SettingsJavaReasonInstallRootTooLong`) 整块插在 `SettingsJavaReasonWriteFailed` 之后、M174 助手域(`SettingsGroupAssistant`)之前 —— 即 `settings_java_entries` 那段之后,装配链的正确位置; 3. 两侧合起来 463 行会越过 400 行硬线,于是按既有先例把**M174 助手域起的那半截** (dev 的 260 行)整段纯搬移到 `golden/tail_ai.rs`:`tail.rs` 211 行 / `tail_ai.rs` 274 行, 链序 `golden → tail → tail_ai → tail2` 不变,逐字节不删不改; 4. 解冲突时发现 dev 的 golden 行还是 M197 之前的旧文案(`SettingsJavaEmptyBody` / `SettingsJavaAddHint` 的反引号、`SettingsJavaNeverScannedBody` 的旧说法),已把这三条的 金表行同步回域文件里的现行文案(`split_keeps_every_translation` 逐位置比对当场抓出来的)。 顺带(合并导致的越线):`crates/core/src/download/engine.rs` 两侧各 400 行、合并后 401 (我 +1 行 `kind: err.kind`,dev +1 行),把模块文档里那三行散文重排成两行(一字未删)→ 400 行。 同一次还把这文件里对 `MirrorTable::set_gh_proxy` 的旧描述改成 M193 的现实(那两个函数已被删)。 验证:fmt 0 · check(app-iced+core+i18n all-targets)0 · i18n 36 · engine_mock 8 · app-iced java 45 · core lib java 71 · setting 116 · app-iced lib 全量 832;全仓 file-size 守门脚本复制执行 → 超限 0。
This commit is contained in:
commit
6e80fd3563
283 files changed
+22601
-2188
No files matched your search
@@ -29,6 +29,14 @@
|
||||
//! | 两阶段补全 + **失败分级**(仅"缺文件"可容忍) | [`pipeline`] 的顺序 + [`report::MissingFile`] 与 [`error::ImportError`] 的分工 |
|
||||
//! | 路径穿越**双检** | 开包时 M110 [`crate::addon::pack::PackArchive::open`],落地时 [`stage`] 的 `normalize_file` + `starts_with` 复核 |
|
||||
//!
|
||||
//! # 取消(M195):每条停下路线都回滚,且**取消不是失败**
|
||||
//!
|
||||
//! 用户点「取消」之后管线真的停手:每个阶段边界与长循环内都有检查点,取消后目标目录与导入前
|
||||
//! 一致(先写临时目录、最后 `rename` / journal 合并),**不登记实例**。结果类型单列一档
|
||||
//! [`ImportStatus::Cancelled`](`error` 为空、CLI 退出码 `130`、UI 不弹红),与
|
||||
//! [`ImportStatus::Failed`] 是两条路。逐段检查点表(含"哪几段没有检查点、为什么")在
|
||||
//! [`cancel`] 模块文档里。
|
||||
//!
|
||||
//! # 三种格式的语义(tower 定案,与 HMCL 相反的两条要记住)
|
||||
//!
|
||||
//! - **mrpack**:`overrides/` 与 `client-overrides/` 都解压,**后者胜**(Modrinth 官方客户端语义;
|
||||
@@ -55,11 +63,13 @@
|
||||
//! ```ignore
|
||||
//! let engine = /* core::download::DownloadEngine */;
|
||||
//! let fetcher = DownloadEngineFetcher::new(&engine);
|
||||
//! let cancel = ImportCancel::new(); // 交给「取消」按钮:cancel.cancel()
|
||||
//! let request = ImportRequest::new(pack_path, instance_dir)
|
||||
//! .with_fetcher(&fetcher)
|
||||
//! .with_cancel(&cancel)
|
||||
//! .with_registration(&launcher_layout, None);
|
||||
//! let report = download::blocking::block_on(import_pack(&request));
|
||||
//! match report.status { /* Success / Partial / Failed */ }
|
||||
//! match report.status { /* Success / Partial / Failed / Cancelled */ }
|
||||
//! ```
|
||||
//!
|
||||
//! # 刻意不做的事(分工边界)
|
||||
@@ -76,8 +86,13 @@
|
||||
//!
|
||||
//! 真实 CF / MultiMC 导出物(夹具是手造的)、超大包(>2G)、Windows 之外的平台、断网中途、
|
||||
//! 磁盘满、真实 CurseForge `files[]` 补全(缺 CF 客户端,见 [`fetch`] 的说明)。
|
||||
//!
|
||||
//! 取消这一条另有三个欠账(M195 报告同样登记):①超大整合包(几千文件 / 数 GB)在真实磁盘
|
||||
//! 上的**取消响应延迟**没测——最坏要等"当前这一条文件读完/下完 + 一次回滚";②**单条下载在飞
|
||||
//! 不可中断**([`fetch::PackFetcher`] 没有取消参数),只能等它返回;③macOS / Linux 未验。
|
||||
|
||||
pub mod baseline;
|
||||
pub mod cancel;
|
||||
pub(crate) mod complete;
|
||||
pub mod decision;
|
||||
pub mod error;
|
||||
@@ -86,11 +101,13 @@ pub mod merge;
|
||||
pub mod phase;
|
||||
pub mod pipeline;
|
||||
pub mod plan;
|
||||
pub(crate) mod prepare;
|
||||
pub mod report;
|
||||
pub mod scan;
|
||||
pub mod stage;
|
||||
|
||||
pub use baseline::{PackBaseline, BASELINE_FILE};
|
||||
pub use cancel::ImportCancel;
|
||||
pub use decision::ConflictDecisions;
|
||||
pub use error::{ImportError, ImportErrorKind};
|
||||
pub use fetch::{
|
||||
@@ -98,8 +115,8 @@ pub use fetch::{
|
||||
FetchedFile, ModrinthFileMeta, PackFetcher,
|
||||
};
|
||||
pub use merge::{MergeAction, MergeDecision};
|
||||
pub use pipeline::baseline_relative;
|
||||
pub use plan::{ImportPlan, PlanOverride, PlanRemote, Shadowed, SuffixVariant};
|
||||
pub use prepare::baseline_relative;
|
||||
pub use report::{
|
||||
keys, ConflictChoice, ConflictFile, ConflictReason, FileAction, FileSource, ImportIssue,
|
||||
ImportProgress, ImportProgressSink, ImportReport, ImportStage, ImportStatus, ImportedFile,
|
||||
@@ -156,6 +173,11 @@ pub struct ImportRequest<'a> {
|
||||
pub instance_name: Option<&'a str>,
|
||||
/// 逐文件冲突决策(含整批缺省档,**缺省 = 全部保留**)。
|
||||
pub decisions: ConflictDecisions,
|
||||
/// 取消令牌;缺省是永不取消的独立令牌(没挂令牌的调用方不必写 `Option`)。
|
||||
///
|
||||
/// 语义与检查点位置见 [`cancel`] 模块文档:取消后**不得**留下半包 / 覆盖已有实例,也不许
|
||||
/// 登记实例;判据是报告里的 [`report::ImportStatus::Cancelled`](**不是**失败)。
|
||||
pub cancel: ImportCancel,
|
||||
/// 包读取限额(条数 / 单条体积 / 解压总量)。
|
||||
pub limits: PackLimits,
|
||||
/// 进度回调。
|
||||
@@ -173,6 +195,7 @@ impl<'a> ImportRequest<'a> {
|
||||
register: None,
|
||||
instance_name: None,
|
||||
decisions: ConflictDecisions::default(),
|
||||
cancel: ImportCancel::default(),
|
||||
limits: PackLimits::default(),
|
||||
progress: None,
|
||||
}
|
||||
@@ -227,9 +250,22 @@ impl<'a> ImportRequest<'a> {
|
||||
self.progress = Some(sink);
|
||||
self
|
||||
}
|
||||
|
||||
/// 挂取消令牌(与调用方手里的那份**共享同一个开关**)。
|
||||
///
|
||||
/// 取消的结果是 [`ImportStatus`] 里的 `Cancelled`(不是失败);停在哪一段、哪些段落没有
|
||||
/// 检查点,见 [`cancel`] 模块文档的逐段表。
|
||||
#[must_use]
|
||||
pub fn with_cancel(mut self, token: &ImportCancel) -> Self {
|
||||
self.cancel = token.clone();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// 导入一个整合包。**永不返回 `Err`**:三态与逐项原因都在 [`ImportReport`] 里。
|
||||
/// 导入一个整合包。**永不返回 `Err`**:三态 + 取消 + 逐项原因都在 [`ImportReport`] 里。
|
||||
pub async fn import_pack(request: &ImportRequest<'_>) -> ImportReport {
|
||||
pipeline::run(request).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod cancel_tests;
|
||||
@@ -0,0 +1,152 @@
|
||||
//! 导入的**取消令牌**(M195):用户点「取消」之后,后台真的停手并回滚,而不是跑完再假装停了。
|
||||
//!
|
||||
//! # 取消不是失败(这是本模块存在的唯一理由)
|
||||
//!
|
||||
//! | 现象 | 取消 | 失败 |
|
||||
//! |------|------|------|
|
||||
//! | 结果类型 | [`super::report::ImportStatus::Cancelled`] | [`super::report::ImportStatus::Failed`] |
|
||||
//! | `ImportReport::error` | `None` | 必有 |
|
||||
//! | CLI 退出码 | `130` | `1` |
|
||||
//! | UI | 面板收回 / 「已取消」,**不弹红** | 红色结果块 |
|
||||
//!
|
||||
//! 管线内部因此有**两条**停止路线:[`Stopped`]。它故意不是 [`super::error::ImportError`] 的
|
||||
//! 第 N 个大类——把两者并成一个变体,调用方就再也分不清"用户改主意"与"这包装坏了",
|
||||
//! 而这两件事的收尾文案、退出码、重试语义全都不同。
|
||||
//!
|
||||
//! # 取消到底停在哪一段(逐段点名,没有检查点的段落要说明为什么)
|
||||
//!
|
||||
//! | 段 | 代码 | 检查点 | 为什么是这个粒度 |
|
||||
//! |----|------|--------|------------------|
|
||||
//! | 读包 | `PackArchive::open` → `PackRegistry::read` → `ImportPlan::build` | 入口前、**解析后**各一次 | 一次 `open` 是**不可分割**的(zip 目录 + 四检 + 限额一次扫完),取消等它返回;解析完立刻有一次,所以"用户刚点取消时包已经读完"也不会白写盘 |
|
||||
//! | 校验 / 补全 | `pipeline::load_baseline`、`complete::complete` | 补全循环**每条目**一次 | 补全是逐条网络请求(按 sha512 反查 sha1),条目之间是天然的取消点 |
|
||||
//! | 解压 / 写入(暂存目录) | `phase::extract_overrides` | **每条**一次 | 只写临时目录,取消 = 临时目录整个删掉,目标目录一个字节都没动 |
|
||||
//! | 远端下载(暂存目录) | `phase::fetch_remote` | **每条**一次 | 单条 `PackFetcher::fetch_file` **在飞不可中断**(fetcher 接口没有取消参数),取消在它返回后的下一次检查生效 |
|
||||
//! | 落盘 | `Staging::commit` | 提交前一次;合并态**逐文件**一次 | 全新导入是一次 `rename`(单步原子,之后无可取消);合并态是逐文件 `rename` + journal,中途取消**逆序回滚**(新文件删掉、旧备份改回原名) |
|
||||
//! | 登记实例 | `game::install::register` | 落盘后、登记前一次 | 内容已进目标目录,取消必须**连内容一起回滚**,否则留下"有目录没档案"的半个实例 |
|
||||
//! | 提交前清中间产物 | `Staging::purge_engine_artifacts` | 与"提交前"共用一次 | 它只是暂存目录里的一次本地遍历,与 `commit` 同一条边界 |
|
||||
//!
|
||||
//! **预检([`super::scan`])不接取消**:它只读盘、不写一个字节,取消了也不会留下任何东西;
|
||||
//! 给它加令牌只是让"用户点完取消还得等预检跑完"这件事更难看。
|
||||
//!
|
||||
//! # 与 M186 的下载取消不是一套(不许照搬)
|
||||
//!
|
||||
//! M186 的口径是"解析后查一次 + 下载循环每次迭代前查一次",因为那边是**一批独立文件**的下载
|
||||
//! 队列,随时可以在文件之间停。导入是**单包解压 + 两阶段落盘**:真正的风险不是"少下几个文件",
|
||||
//! 而是"临时目录里的半包被提交进目标目录"。所以这里多了两处 M186 没有的检查点——**落盘逐文件**
|
||||
//! 与**登记前**——它们对应两种只能靠回滚才能收场的状态(合并态已搬了一半 / 内容已落盘但档案
|
||||
//! 还没建)。照搬 M186 的两处检查会漏掉这两段,正是"取消后仍写盘"的原始 finding 形态。
|
||||
//!
|
||||
//! # 目标的形态(取消后的机器位)
|
||||
//!
|
||||
//! 报告的收尾在 [`super::report::ImportReport::cancel`](三档注记与 `rolled_back` 都在那里):
|
||||
//!
|
||||
//! - 取消发生在**落盘之前** ⇒ 临时目录已删,`rolled_back = true`、`rollback` 为 `None`;
|
||||
//! - 取消发生在**落盘之中/之后** ⇒ 回滚已做,`rollback` 里有结果,`rolled_back` 只在**完整**
|
||||
//! 回滚时为 `true`(回滚本身失败时如实为 `false` 并逐项列出,与失败路径同一套口径)。
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::error::ImportError;
|
||||
|
||||
/// 取消令牌:一个可跨线程共享的布尔开关。
|
||||
///
|
||||
/// [`Default`] 造出的是**永不取消**的独立令牌(没挂令牌的调用方零成本,不必到处写 `Option`)。
|
||||
/// 要用既有 `Arc<AtomicBool>`(presenter 的 `cancel_pack` 就是这么存的)时走
|
||||
/// [`ImportCancel::from_flag`],两边共享同一个开关。
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ImportCancel {
|
||||
flag: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl ImportCancel {
|
||||
/// 一个新的、未取消的令牌。
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// 接管一个既有开关(共享同一份状态:外面置位,这里立刻看得见)。
|
||||
#[must_use]
|
||||
pub fn from_flag(flag: Arc<AtomicBool>) -> Self {
|
||||
Self { flag }
|
||||
}
|
||||
|
||||
/// 底层开关(要交给别的线程持有时用)。
|
||||
#[must_use]
|
||||
pub fn flag(&self) -> Arc<AtomicBool> {
|
||||
Arc::clone(&self.flag)
|
||||
}
|
||||
|
||||
/// 置位(幂等)。
|
||||
pub fn cancel(&self) {
|
||||
self.flag.store(true, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// 是否已被取消。
|
||||
#[must_use]
|
||||
pub fn is_cancelled(&self) -> bool {
|
||||
self.flag.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// 一个检查点:已取消 ⇒ [`Stopped::Cancelled`](由 `?` 冒泡到编排层)。
|
||||
pub(crate) fn stopped(&self) -> Result<(), Stopped> {
|
||||
if self.is_cancelled() {
|
||||
Err(Stopped::Cancelled)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 管线内部的两条停止路线:**取消**与**失败**。
|
||||
///
|
||||
/// 分开的理由见模块文档:"用户改主意"不是"这包装坏了"。`From<ImportError>` 让所有既有的
|
||||
/// `?`(IO / 穿越 / 摘要不符 / 限额)继续直接冒泡成 [`Stopped::Failed`],不用把每个调用点
|
||||
/// 都改成手写映射。
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum Stopped {
|
||||
/// 用户在检查点之前点了取消。
|
||||
Cancelled,
|
||||
/// 一次必须回滚的失败。
|
||||
Failed(ImportError),
|
||||
}
|
||||
|
||||
impl From<ImportError> for Stopped {
|
||||
fn from(error: ImportError) -> Self {
|
||||
Self::Failed(error)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<crate::addon::pack::PackError> for Stopped {
|
||||
fn from(error: crate::addon::pack::PackError) -> Self {
|
||||
Self::Failed(error.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<super::fetch::FetchError> for Stopped {
|
||||
fn from(error: super::fetch::FetchError) -> Self {
|
||||
Self::Failed(error.into())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_token_is_shared_only_with_the_flags_it_was_built_from() {
|
||||
let token = ImportCancel::new();
|
||||
assert!(!token.is_cancelled());
|
||||
token.cancel();
|
||||
assert!(token.is_cancelled(), "自己置位自己要看得见");
|
||||
|
||||
let foreign = ImportCancel::new();
|
||||
assert!(!foreign.is_cancelled(), "默认令牌之间不共享状态");
|
||||
|
||||
let flag = Arc::new(AtomicBool::new(false));
|
||||
let shared = ImportCancel::from_flag(Arc::clone(&flag));
|
||||
flag.store(true, Ordering::SeqCst);
|
||||
assert!(shared.is_cancelled(), "外部置位要被看见");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
//! 导入取消(M195)的守门用例:**取消不是失败**、**取消不留半包 / 不覆盖已有实例**、
|
||||
//! **取消后不登记实例**。
|
||||
//!
|
||||
//! # 每条用例挡哪种回归(变异证明见 mission 报告)
|
||||
//!
|
||||
//! | 用例 | 删掉哪一处就红 |
|
||||
//! |------|----------------|
|
||||
//! | `a_cancel_before_the_first_write_touches_nothing` | 入口检查点 |
|
||||
//! | `a_cancel_inside_the_extract_loop_leaves_no_half_unpacked_pack` | 解压循环检查点(判据 = 解压进度条数:有 = 1、删掉 = 3) |
|
||||
//! | `a_cancel_in_the_middle_of_the_merge_restores_the_users_file` | 取消时的 `rollback_merge` |
|
||||
//! | `a_cancel_right_before_registering_rolls_the_whole_instance_back` | 登记前的检查点 |
|
||||
//! | `cancel_and_failure_are_two_different_result_types` | 把取消并进 `fail()` 那条路 |
|
||||
//!
|
||||
//! # 解压那条必须数事件,其余没有真守卫的检查点
|
||||
//!
|
||||
//! 解压只写**暂存目录**,所以"status == Cancelled / 目标目录不存在 / `files` 为空 / 无残留暂存"
|
||||
//! 在删掉循环检查点后**依然成立**(提交前检查点会替它停一次)——只有"解了几条"能区分两者。
|
||||
//!
|
||||
//! 有变异证明的只有上表五处。**没有**:检查点②解析后 / ③补全后 / ④开暂存目录前 / ⑤提交前(删掉
|
||||
//! 任一处,别的检查点会接住同一次取消,`pipeline.rs` 的断言仍绿)、全新导入 `commit` 的取消分支、
|
||||
//! [`super::phase`] 远端下载逐条检查点、[`super::complete`] 补全循环的 `break`——补它们要两段式
|
||||
//! 夹具或更多计数探针,本轮没做。
|
||||
//!
|
||||
//! 触发点全部走**进度回调**(置位 = 用户此刻点了取消,无 sleep、无竞态);全部离线。
|
||||
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::cancel::ImportCancel;
|
||||
use super::report::{ImportProgress, ImportProgressSink, ImportStage, ImportStatus};
|
||||
use super::{import_pack, ImportRequest};
|
||||
use crate::addon::temp::TempRoot;
|
||||
use crate::download::blocking::block_on;
|
||||
|
||||
/// 一份只含覆盖内容的 mrpack 清单(`files` 为空 ⇒ 全部内容来自 `overrides/`,无远端条目)。
|
||||
const INDEX: &str = r#"{
|
||||
"game": "minecraft",
|
||||
"formatVersion": 1,
|
||||
"versionId": "1.0.0",
|
||||
"name": "Cancelable",
|
||||
"files": [],
|
||||
"dependencies": {"minecraft": "1.20.1", "fabric-loader": "0.15.11"}
|
||||
}
|
||||
"#;
|
||||
|
||||
/// 手造 mrpack(`entries` 是 `overrides/` 下的实例内路径与字节)。
|
||||
fn mrpack(tag: &str, entries: &[(&str, &[u8])]) -> (TempRoot, PathBuf) {
|
||||
let root = TempRoot::new(tag);
|
||||
let source = root.join("src");
|
||||
write_file(&source.join("modrinth.index.json"), INDEX.as_bytes());
|
||||
for (path, bytes) in entries {
|
||||
write_file(&source.join("overrides").join(path), bytes);
|
||||
}
|
||||
let pack = root.join("pack.mrpack");
|
||||
zip_dir(&source, &pack);
|
||||
(root, pack)
|
||||
}
|
||||
|
||||
/// 三个文件的包。
|
||||
fn three_files(tag: &str) -> (TempRoot, PathBuf) {
|
||||
mrpack(
|
||||
tag,
|
||||
&[
|
||||
("mods/a.jar", b"pack a"),
|
||||
("mods/b.jar", b"pack b"),
|
||||
("mods/c.jar", b"pack c"),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
fn write_file(path: &Path, bytes: &[u8]) {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).expect("建目录");
|
||||
}
|
||||
std::fs::write(path, bytes).expect("写文件");
|
||||
}
|
||||
|
||||
fn zip_dir(source: &Path, target: &Path) {
|
||||
let file = std::fs::File::create(target).expect("建 zip");
|
||||
let mut writer = zip::ZipWriter::new(file);
|
||||
let options =
|
||||
zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||
let mut pending = vec![PathBuf::new()];
|
||||
while let Some(relative) = pending.pop() {
|
||||
let dir = source.join(&relative);
|
||||
let mut children: Vec<_> = std::fs::read_dir(&dir)
|
||||
.expect("读目录")
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.expect("目录项");
|
||||
children.sort_by_key(std::fs::DirEntry::file_name);
|
||||
for child in children {
|
||||
let relative = relative.join(child.file_name());
|
||||
if child.file_type().expect("类型").is_dir() {
|
||||
pending.push(relative);
|
||||
continue;
|
||||
}
|
||||
let bytes = std::fs::read(child.path()).expect("读夹具文件");
|
||||
let entry = relative.to_string_lossy().replace('\\', "/");
|
||||
writer.start_file(entry, options).expect("写 zip 条目");
|
||||
writer.write_all(&bytes).expect("写 zip 内容");
|
||||
}
|
||||
}
|
||||
writer.finish().expect("收尾 zip");
|
||||
}
|
||||
|
||||
/// 在**指定阶段的第一条进度事件**上置位取消(= 用户此刻点了取消)。
|
||||
fn cancel_on(token: &ImportCancel, stage: ImportStage) -> ImportProgressSink {
|
||||
let token = token.clone();
|
||||
Arc::new(move |progress: &ImportProgress| {
|
||||
if progress.stage == stage {
|
||||
token.cancel();
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// 数**解压阶段**的进度条数,并在第一条上置位取消(这是解压循环检查点唯一的可观测判据:
|
||||
/// 有它 ⇒ 第一条之后立刻停 = 1;删掉 ⇒ 一路解完 = 文件条数)。
|
||||
fn cancel_on_first_extract_counting(
|
||||
token: &ImportCancel,
|
||||
) -> (ImportProgressSink, Arc<AtomicUsize>) {
|
||||
let token = token.clone();
|
||||
let count = Arc::new(AtomicUsize::new(0));
|
||||
let seen = Arc::clone(&count);
|
||||
let sink: ImportProgressSink = Arc::new(move |progress: &ImportProgress| {
|
||||
if progress.stage != ImportStage::Extract {
|
||||
return;
|
||||
}
|
||||
if seen.fetch_add(1, Ordering::SeqCst) == 0 {
|
||||
token.cancel();
|
||||
}
|
||||
});
|
||||
(sink, count)
|
||||
}
|
||||
|
||||
/// 在**合并态第二条逐文件进度**上置位取消。合并是排序后逐文件搬:第 0 条是基线
|
||||
/// (`.ull/pack-baseline.json`,`.` 排最前),第 1 条才是 `mods/a.jar`——`done == 1` 正好是
|
||||
/// "用户那个被覆盖的文件已经搬进去、后面还有"的窗口,journal 回滚要接住的正是这一刻。
|
||||
fn cancel_on_second_merged_file(token: &ImportCancel) -> ImportProgressSink {
|
||||
let token = token.clone();
|
||||
Arc::new(move |progress: &ImportProgress| {
|
||||
if progress.stage == ImportStage::Commit && progress.done == 1 {
|
||||
token.cancel();
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// 目标目录里全部文件的实例内相对路径(排序后):钉"取消后与导入前逐文件相同"。
|
||||
fn files_under(root: &Path) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut pending = vec![root.to_path_buf()];
|
||||
while let Some(dir) = pending.pop() {
|
||||
let Ok(entries) = std::fs::read_dir(&dir) else {
|
||||
continue;
|
||||
};
|
||||
for entry in entries.filter_map(Result::ok) {
|
||||
let path = entry.path();
|
||||
if entry.file_type().map(|kind| kind.is_dir()).unwrap_or(false) {
|
||||
pending.push(path);
|
||||
continue;
|
||||
}
|
||||
let relative = path
|
||||
.strip_prefix(root)
|
||||
.map(|tail| tail.to_string_lossy().replace('\\', "/"))
|
||||
.unwrap_or_default();
|
||||
out.push(relative);
|
||||
}
|
||||
}
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
|
||||
/// 父目录里有没有遗留的暂存目录(`.<name>.ull-import-<pid>-<seq>`)。
|
||||
fn stray_staging(parent: &Path) -> Vec<String> {
|
||||
std::fs::read_dir(parent)
|
||||
.map(|entries| {
|
||||
entries
|
||||
.filter_map(Result::ok)
|
||||
.map(|entry| entry.file_name().to_string_lossy().to_string())
|
||||
.filter(|name| name.contains(".ull-import-"))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cancel_before_the_first_write_touches_nothing() {
|
||||
// 入口检查点:包还没读,目标目录还没建。
|
||||
let (root, pack) = three_files("cancel-entry");
|
||||
let target = root.join("instance");
|
||||
let token = ImportCancel::new();
|
||||
token.cancel();
|
||||
|
||||
let report = block_on(import_pack(
|
||||
&ImportRequest::new(&pack, &target).with_cancel(&token),
|
||||
));
|
||||
|
||||
assert_eq!(report.status, ImportStatus::Cancelled);
|
||||
assert!(
|
||||
report.error.is_none(),
|
||||
"取消不是失败:{}",
|
||||
report.status.as_str()
|
||||
);
|
||||
assert!(report.pack_name.is_none(), "停在入口 = 包都没读");
|
||||
assert!(report.files.is_empty());
|
||||
assert!(report.rolled_back, "一个字节都没写,目标目录与导入前一致");
|
||||
assert!(!target.exists(), "目标目录不许出现");
|
||||
assert!(
|
||||
stray_staging(root.path()).is_empty(),
|
||||
"{:?}",
|
||||
stray_staging(root.path())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cancel_inside_the_extract_loop_leaves_no_half_unpacked_pack() {
|
||||
// 解压循环里的检查点:第一条进度事件上取消 ⇒ 第一条写进临时目录,第二条之前停下,
|
||||
// 临时目录整个删掉 ⇒ 目标目录**不出现半成品**,报告里也不许有"已落地 N 条"的假账。
|
||||
let (root, pack) = three_files("cancel-extract");
|
||||
let target = root.join("instance");
|
||||
let token = ImportCancel::new();
|
||||
let (sink, extract_events) = cancel_on_first_extract_counting(&token);
|
||||
|
||||
let report = block_on(import_pack(
|
||||
&ImportRequest::new(&pack, &target)
|
||||
.with_cancel(&token)
|
||||
.with_progress(sink),
|
||||
));
|
||||
|
||||
assert_eq!(report.status, ImportStatus::Cancelled, "{:?}", report.error);
|
||||
// **这一条才是解压循环检查点的判据**:有它 ⇒ 第一条之后立刻停(3 个文件的包里只报了 1 条
|
||||
// 解压进度);删掉它 ⇒ 一路解完(3 条)。其余断言在删掉检查点后仍然成立,挡不住回归。
|
||||
assert_eq!(
|
||||
extract_events.load(Ordering::SeqCst),
|
||||
1,
|
||||
"取消必须在第一条解压之后立刻生效(包里有 3 个文件,解完再停 = 解压段没有检查点)"
|
||||
);
|
||||
assert!(report.error.is_none(), "取消不许带错误");
|
||||
assert!(!target.exists(), "不许留下半包:{}", target.display());
|
||||
assert!(
|
||||
report.files.is_empty(),
|
||||
"取消前的处理记录一条都没落地,报告不许冒充已装:{:?}",
|
||||
report.files
|
||||
);
|
||||
assert_eq!(report.bytes_written, 0, "写入量要归零");
|
||||
assert!(report.registered.is_none());
|
||||
assert!(report.rolled_back);
|
||||
assert!(
|
||||
stray_staging(root.path()).is_empty(),
|
||||
"{:?}",
|
||||
stray_staging(root.path())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cancel_in_the_middle_of_the_merge_restores_the_users_file() {
|
||||
// 合并态(目标目录已有内容):提交是逐文件 rename + journal。取消落在两条搬动之间 ⇒
|
||||
// **逆序回滚**必须把用户被覆盖的文件改回来,且不留备份、不留暂存目录。
|
||||
let (root, pack) = three_files("cancel-merge");
|
||||
let target = root.join("instance");
|
||||
write_file(&target.join("mods/a.jar"), b"user a");
|
||||
write_file(&target.join("mods/user.jar"), b"user only");
|
||||
let token = ImportCancel::new();
|
||||
|
||||
let report = block_on(import_pack(
|
||||
&ImportRequest::new(&pack, &target)
|
||||
.with_cancel(&token)
|
||||
// 覆盖档:让 a.jar 真的走"备份 + 覆盖"那条路,取消后必须恢复用户内容。
|
||||
.with_conflict_policy(super::ConflictPolicy::Overwrite)
|
||||
.with_progress(cancel_on_second_merged_file(&token)),
|
||||
));
|
||||
|
||||
assert_eq!(report.status, ImportStatus::Cancelled, "{:?}", report.error);
|
||||
assert!(report.error.is_none());
|
||||
assert_eq!(
|
||||
std::fs::read(target.join("mods/a.jar")).expect("用户文件要还在"),
|
||||
b"user a",
|
||||
"取消后必须恢复被覆盖的用户文件(回滚被删掉就会读到包内字节)"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(target.join("mods/user.jar")).expect("用户文件要还在"),
|
||||
b"user only"
|
||||
);
|
||||
assert!(!target.join("mods/b.jar").exists(), "取消后不许有半包");
|
||||
assert!(!target.join("mods/c.jar").exists(), "取消后不许有半包");
|
||||
assert_eq!(
|
||||
files_under(&target),
|
||||
vec!["mods/a.jar".to_string(), "mods/user.jar".to_string()],
|
||||
"目标目录必须与导入前**逐文件相同**(多出基线 / 少掉用户文件都是错)"
|
||||
);
|
||||
assert!(
|
||||
std::fs::read_dir(target.join("mods"))
|
||||
.expect("读 mods")
|
||||
.filter_map(Result::ok)
|
||||
.all(|entry| !entry.file_name().to_string_lossy().contains(".ullbak-")),
|
||||
"备份必须被清掉,不许在实例里留 .ullbak-*"
|
||||
);
|
||||
assert!(
|
||||
stray_staging(root.path()).is_empty(),
|
||||
"{:?}",
|
||||
stray_staging(root.path())
|
||||
);
|
||||
// 回滚做过,且完整 ⇒ 机器位与文案位都说"与导入前一致"。
|
||||
assert!(report.rolled_back, "{:?}", report.rollback);
|
||||
assert_eq!(
|
||||
report
|
||||
.rollback
|
||||
.as_ref()
|
||||
.map(super::stage::RollbackOutcome::failure_count),
|
||||
Some(0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cancel_right_before_registering_rolls_the_whole_instance_back() {
|
||||
// 登记前的检查点(**取消后不得登记实例**):取消在"内容已落盘、档案还没建"的窗口里落下
|
||||
// ⇒ 连内容一起回滚。删掉这条检查,实例就会被登记上(本用例立刻红)。
|
||||
let (root, pack) = three_files("cancel-register");
|
||||
let target = root.join("instance");
|
||||
let home = root.join("data");
|
||||
let layout = crate::platform::layout::LauncherLayout::new(&home);
|
||||
let token = ImportCancel::new();
|
||||
|
||||
let report = block_on(import_pack(
|
||||
&ImportRequest::new(&pack, &target)
|
||||
.with_cancel(&token)
|
||||
.with_registration(&layout, Some("cancelled pack"))
|
||||
.with_progress(cancel_on(&token, ImportStage::Register)),
|
||||
));
|
||||
|
||||
assert_eq!(report.status, ImportStatus::Cancelled, "{:?}", report.error);
|
||||
assert!(report.error.is_none());
|
||||
assert!(report.registered.is_none(), "取消后不许登记实例");
|
||||
let profiles = crate::game::install::instance::read_profiles(&layout).expect("读档案");
|
||||
assert!(
|
||||
profiles.profiles.is_empty(),
|
||||
"profiles.json 里不许出现被取消的实例:{:?}",
|
||||
profiles.profiles
|
||||
);
|
||||
assert!(!target.exists(), "内容已提交也要连内容一起回滚");
|
||||
assert!(report.rolled_back);
|
||||
assert!(
|
||||
stray_staging(root.path()).is_empty(),
|
||||
"{:?}",
|
||||
stray_staging(root.path())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_and_failure_are_two_different_result_types() {
|
||||
// 条款③:取消与真失败必须产生**不同的结果类型**(状态变体、错误位、退出码、文案 key、
|
||||
// JSON 的 status 全不同)。把两条路合并成同一个错误,这里就红。
|
||||
let (root, pack) = three_files("cancel-vs-fail");
|
||||
let token = ImportCancel::new();
|
||||
token.cancel();
|
||||
let cancelled = block_on(import_pack(
|
||||
&ImportRequest::new(&pack, &root.join("cancelled")).with_cancel(&token),
|
||||
));
|
||||
|
||||
// 真失败:清单坏掉的包(开包成功、读清单失败)。
|
||||
let broken = root.join("broken.zip");
|
||||
{
|
||||
let file = std::fs::File::create(&broken).expect("建 zip");
|
||||
let mut writer = zip::ZipWriter::new(file);
|
||||
let options = zip::write::SimpleFileOptions::default()
|
||||
.compression_method(zip::CompressionMethod::Stored);
|
||||
writer.start_file("manifest.json", options).expect("写条目");
|
||||
writer.write_all(b"{\"manifestType\":").expect("写内容");
|
||||
writer.finish().expect("收尾");
|
||||
}
|
||||
let failed = block_on(import_pack(&ImportRequest::new(
|
||||
&broken,
|
||||
&root.join("failed"),
|
||||
)));
|
||||
|
||||
assert_eq!(cancelled.status, ImportStatus::Cancelled);
|
||||
assert_eq!(failed.status, ImportStatus::Failed);
|
||||
assert!(cancelled.error.is_none(), "取消不是错误");
|
||||
assert!(failed.error.is_some(), "失败必须带错误");
|
||||
assert_ne!(
|
||||
cancelled.status.exit_code(),
|
||||
failed.status.exit_code(),
|
||||
"退出码必须能区分「用户取消」与「装坏了」"
|
||||
);
|
||||
assert_eq!(cancelled.status.exit_code(), 130);
|
||||
assert_eq!(failed.status.exit_code(), 1);
|
||||
assert_ne!(cancelled.status.message_key(), failed.status.message_key());
|
||||
let cancelled_json = serde_json::to_value(&cancelled).expect("序列化");
|
||||
let failed_json = serde_json::to_value(&failed).expect("序列化");
|
||||
assert_eq!(cancelled_json["status"], serde_json::json!("cancelled"));
|
||||
assert_eq!(failed_json["status"], serde_json::json!("failed"));
|
||||
assert!(
|
||||
cancelled_json["error"].is_null(),
|
||||
"取消的 JSON 里不许有 error:{}",
|
||||
cancelled_json["error"]
|
||||
);
|
||||
}
|
||||
|
||||
mod report_shaping;
|
||||
@@ -0,0 +1,111 @@
|
||||
//! 取消报告的**三档诚实口径**:`ImportReport::cancel` 的直接用例(不需要跑管线)。
|
||||
//!
|
||||
//! 从 `cancel_tests.rs` 拆出来只为行数:那边装下五个端到端用例就够满了。
|
||||
|
||||
use crate::addon::import::report::{FileAction, FileSource, ImportReport, ImportedFile};
|
||||
use crate::addon::import::stage::RollbackOutcome;
|
||||
use crate::addon::import::ImportStatus;
|
||||
|
||||
fn base() -> ImportReport {
|
||||
ImportReport::empty(std::path::PathBuf::from("instance"))
|
||||
}
|
||||
|
||||
fn notes(report: &ImportReport) -> Vec<&str> {
|
||||
report
|
||||
.warnings
|
||||
.iter()
|
||||
.map(|note| note.message.as_str())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cancel_before_writing_never_claims_the_target_was_touched() {
|
||||
let mut report = base();
|
||||
report.cancel(RollbackOutcome::default());
|
||||
|
||||
assert_eq!(report.status, ImportStatus::Cancelled);
|
||||
assert!(report.error.is_none(), "取消不许带错误");
|
||||
assert!(report.rolled_back);
|
||||
assert!(report.rollback.is_none(), "没做过回滚就不该有回滚记录");
|
||||
let notes = notes(&report);
|
||||
assert!(
|
||||
notes.iter().any(|line| line.contains("未被写入")),
|
||||
"{notes:?}"
|
||||
);
|
||||
assert!(
|
||||
!notes.iter().any(|line| line.contains("恢复为导入前")),
|
||||
"{notes:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cancel_after_commit_says_it_rolled_back_exactly_once() {
|
||||
let mut report = base();
|
||||
report.cancel(RollbackOutcome {
|
||||
attempted: 2,
|
||||
failed: Vec::new(),
|
||||
});
|
||||
|
||||
assert_eq!(report.status, ImportStatus::Cancelled);
|
||||
assert!(report.rolled_back);
|
||||
assert_eq!(
|
||||
report.rollback.as_ref().map(RollbackOutcome::failure_count),
|
||||
Some(0)
|
||||
);
|
||||
let notes = notes(&report);
|
||||
assert!(
|
||||
notes.iter().any(|line| line.contains("恢复为导入前")),
|
||||
"{notes:?}"
|
||||
);
|
||||
assert!(
|
||||
!notes.iter().any(|line| line.contains("未被写入")),
|
||||
"{notes:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_incomplete_cancel_rollback_never_claims_consistency() {
|
||||
let mut report = base();
|
||||
report.cancel(RollbackOutcome {
|
||||
attempted: 3,
|
||||
failed: vec!["删除 config/a.toml 失败:拒绝访问".to_string()],
|
||||
});
|
||||
|
||||
assert_eq!(report.status, ImportStatus::Cancelled);
|
||||
assert!(!report.rolled_back, "回滚不完整时不许宣称一致");
|
||||
let notes = notes(&report);
|
||||
assert!(
|
||||
notes
|
||||
.iter()
|
||||
.any(|line| line.contains("未能回滚") || line.contains("未完成")),
|
||||
"{notes:?}"
|
||||
);
|
||||
assert!(
|
||||
!notes
|
||||
.iter()
|
||||
.any(|line| line.contains("恢复为导入前的样子") || line.contains("未被写入")),
|
||||
"{notes:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelling_clears_the_per_file_ledger_it_never_landed() {
|
||||
// 取消前的 `files` 是"处理过"而不是"落地过":留着就是假账(UI 会照它画"已装 N 个")。
|
||||
let mut report = base();
|
||||
report.bytes_written = 4096;
|
||||
report.files.push(ImportedFile {
|
||||
path: "mods/a.jar".to_string(),
|
||||
source: FileSource::Overrides,
|
||||
sha1: None,
|
||||
bytes: 4096,
|
||||
disabled: false,
|
||||
action: FileAction::Installed,
|
||||
note: None,
|
||||
});
|
||||
|
||||
report.cancel(RollbackOutcome::default());
|
||||
|
||||
assert!(report.files.is_empty(), "取消后不许留下「已落地」的账");
|
||||
assert_eq!(report.bytes_written, 0);
|
||||
assert_eq!(report.installed(), 0);
|
||||
}
|
||||
@@ -77,7 +77,13 @@ pub(crate) async fn complete_and_note(
|
||||
plan: &mut ImportPlan,
|
||||
report: &mut ImportReport,
|
||||
) {
|
||||
if let Some(note) = complete(request, plan).await.note("") {
|
||||
let completion = complete(request, plan).await;
|
||||
// 取消时这条注记会撒谎(剩下的条目**没试过**,不是"补不出"):取消收尾的注记已经说清
|
||||
// 停在哪一步,这里不再补一句计数。
|
||||
if request.cancel.is_cancelled() {
|
||||
return;
|
||||
}
|
||||
if let Some(note) = completion.note("") {
|
||||
report.warnings.push(note);
|
||||
}
|
||||
}
|
||||
@@ -94,6 +100,10 @@ pub(crate) async fn complete(request: &ImportRequest<'_>, plan: &mut ImportPlan)
|
||||
return state;
|
||||
};
|
||||
for entry in plan.remote.iter_mut() {
|
||||
// 取消检查点:补全是**逐条网络请求**,条目之间是天然的落点;已补的只改内存,不写盘。
|
||||
if request.cancel.is_cancelled() {
|
||||
break;
|
||||
}
|
||||
if entry.file.sha1().is_some() {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -7,26 +7,33 @@ use std::path::Path;
|
||||
|
||||
use super::super::pack::{PackArchive, PackError};
|
||||
use super::baseline::PackBaseline;
|
||||
use super::cancel::Stopped;
|
||||
use super::error::ImportError;
|
||||
use super::fetch::{FetchError, FetchRequest};
|
||||
use super::merge::{self, MergeAction};
|
||||
use super::pipeline::tell;
|
||||
use super::merge;
|
||||
use super::plan::{curse_class_dir, ImportPlan, PlanRemote};
|
||||
use super::report::{
|
||||
ConflictChoice, ConflictFile, ConflictReason, FileAction, ImportReport, ImportStage,
|
||||
ImportedFile, MissingFile, MissingReason,
|
||||
};
|
||||
use super::report::tell;
|
||||
use super::report::{ImportReport, ImportStage, ImportedFile, MissingFile, MissingReason};
|
||||
use super::stage::Staging;
|
||||
use super::ImportRequest;
|
||||
use crate::download::verify::{self, digest_bytes};
|
||||
use crate::download::HashAlgo;
|
||||
|
||||
mod settle;
|
||||
|
||||
use settle::{conflict, settle};
|
||||
|
||||
/// 阶段一:覆盖条目落暂存 + 三方合并。
|
||||
///
|
||||
/// 被同包另一条声明遮蔽的条目([`super::plan::PlanOverride::shadowed`])**不读字节、不落地**:
|
||||
/// 它的结论已经由 `plan::layer::resolve` 算成一条 `NOTE_OVERRIDE_SHADOWED` 注记
|
||||
/// (进 `report.warnings`),而这条路径的行由真正的胜者给出——一路径一行,不再出现
|
||||
/// "两条 Installed、前一条的哈希其实没落地"。
|
||||
///
|
||||
/// # 取消检查点
|
||||
///
|
||||
/// 解压阶段**每条一次**(循环开头):只写临时目录,取消 = 临时目录整个删掉,目标目录一个
|
||||
/// 字节都没动。被 `?` 冒泡出去的 [`Stopped::Cancelled`] 与失败**不是同一条路**。
|
||||
pub(crate) async fn extract_overrides(
|
||||
request: &ImportRequest<'_>,
|
||||
archive: &PackArchive,
|
||||
@@ -35,7 +42,7 @@ pub(crate) async fn extract_overrides(
|
||||
staging: &Staging,
|
||||
report: &mut ImportReport,
|
||||
next: &mut PackBaseline,
|
||||
) -> Result<(), ImportError> {
|
||||
) -> Result<(), Stopped> {
|
||||
let live: Vec<&super::plan::PlanOverride> = plan
|
||||
.overrides
|
||||
.iter()
|
||||
@@ -44,6 +51,8 @@ pub(crate) async fn extract_overrides(
|
||||
let total = live.len();
|
||||
let target = staging.target().to_path_buf();
|
||||
for (index, entry) in live.iter().enumerate() {
|
||||
// 取消检查点(每条一次):只写临时目录,停下就是"什么都没发生"。
|
||||
request.cancel.stopped()?;
|
||||
tell(
|
||||
&request.progress,
|
||||
ImportStage::Extract,
|
||||
@@ -94,6 +103,12 @@ pub(crate) async fn extract_overrides(
|
||||
}
|
||||
|
||||
/// 阶段二:远端条目补全 + 下载 + 同一张合并表。
|
||||
///
|
||||
/// # 取消检查点
|
||||
///
|
||||
/// 每条远端条目一次(循环开头)。**单条下载在飞不可中断**([`super::fetch::PackFetcher`] 没有
|
||||
/// 取消参数),所以取消在最坏情况下要等"当前这一条下载返回";返回后下一次检查立刻生效,
|
||||
/// 已下到临时目录的字节随临时目录一起删掉。
|
||||
pub(crate) async fn fetch_remote(
|
||||
request: &ImportRequest<'_>,
|
||||
plan: &ImportPlan,
|
||||
@@ -101,10 +116,12 @@ pub(crate) async fn fetch_remote(
|
||||
staging: &Staging,
|
||||
report: &mut ImportReport,
|
||||
next: &mut PackBaseline,
|
||||
) -> Result<(), ImportError> {
|
||||
) -> Result<(), Stopped> {
|
||||
let total = plan.remote.len();
|
||||
let target = staging.target().to_path_buf();
|
||||
for (index, entry) in plan.remote.iter().enumerate() {
|
||||
// 取消检查点(每条一次):上一条下载返回之后立刻生效。
|
||||
request.cancel.stopped()?;
|
||||
let display = entry.path.clone().unwrap_or_else(|| entry.file.key());
|
||||
tell(
|
||||
&request.progress,
|
||||
@@ -185,7 +202,8 @@ pub(crate) async fn fetch_remote(
|
||||
return Err(ImportError::integrity(format!(
|
||||
"{} 的摘要与清单声明不符(声明 {declared},实际 {pack_sha1})",
|
||||
resolved.path
|
||||
)));
|
||||
))
|
||||
.into());
|
||||
}
|
||||
}
|
||||
let decision = merge::decide(&pack_sha1, baseline.sha1(&resolved.path), disk.as_deref());
|
||||
@@ -284,60 +302,6 @@ async fn resolve(
|
||||
}))
|
||||
}
|
||||
|
||||
/// 合并判定 + 决策 → 实际动作。
|
||||
///
|
||||
/// `picked` 是这一次的处置(逐项决策或整批档,见 [`super::decision::ConflictDecisions`]):
|
||||
/// 它**只对冲突生效**——`Install` / `SkipIdentical` 不受它影响,所以"用户点错一条保留"不会漏装。
|
||||
fn settle(
|
||||
decision: merge::MergeDecision,
|
||||
disk_exists: bool,
|
||||
picked: ConflictChoice,
|
||||
) -> (FileAction, ConflictChoice) {
|
||||
let overwrite = picked == ConflictChoice::Overwrite;
|
||||
match decision.action {
|
||||
MergeAction::Install => (
|
||||
if disk_exists {
|
||||
FileAction::Replaced
|
||||
} else {
|
||||
FileAction::Installed
|
||||
},
|
||||
ConflictChoice::Overwrite,
|
||||
),
|
||||
MergeAction::SkipIdentical => (FileAction::SkippedIdentical, ConflictChoice::KeepUser),
|
||||
MergeAction::KeepUser if overwrite => (
|
||||
if disk_exists {
|
||||
FileAction::Replaced
|
||||
} else {
|
||||
FileAction::Installed
|
||||
},
|
||||
ConflictChoice::Overwrite,
|
||||
),
|
||||
MergeAction::KeepUser => (FileAction::KeptUser, ConflictChoice::KeepUser),
|
||||
}
|
||||
}
|
||||
|
||||
/// 组装一条冲突记录。
|
||||
fn conflict(
|
||||
path: String,
|
||||
reason: ConflictReason,
|
||||
choice: ConflictChoice,
|
||||
pack_sha1: &str,
|
||||
pack_size: u64,
|
||||
base_sha1: Option<String>,
|
||||
disk_sha1: Option<String>,
|
||||
) -> ConflictFile {
|
||||
ConflictFile {
|
||||
path,
|
||||
reason,
|
||||
choice,
|
||||
message_key: reason.message_key(),
|
||||
pack_sha1: pack_sha1.to_string(),
|
||||
pack_size,
|
||||
base_sha1,
|
||||
disk_sha1,
|
||||
}
|
||||
}
|
||||
|
||||
/// 组装一条缺失记录(条目本身补不出来)。
|
||||
fn missing_for(entry: &PlanRemote, fetcher_present: bool) -> MissingFile {
|
||||
let reason = if fetcher_present {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
//! 合并判定 + 决策 → 实际动作,以及冲突记录的组装。
|
||||
//!
|
||||
//! 从 [`super`] 拆出来的唯一原因是行数上限(400 行),行为逐字未变:这两个函数是**纯函数**,
|
||||
//! 不碰盘、不看时钟,所以搬动它们不影响任何判据。
|
||||
|
||||
use super::super::merge::{self, MergeAction};
|
||||
use super::super::report::{ConflictChoice, ConflictFile, ConflictReason, FileAction};
|
||||
|
||||
/// 合并判定 + 决策 → 实际动作。
|
||||
///
|
||||
/// `picked` 是这一次的处置(逐项决策或整批档,见 [`crate::addon::import::decision::ConflictDecisions`]):
|
||||
/// 它**只对冲突生效**——`Install` / `SkipIdentical` 不受它影响,所以"用户点错一条保留"不会漏装。
|
||||
pub(super) fn settle(
|
||||
decision: merge::MergeDecision,
|
||||
disk_exists: bool,
|
||||
picked: ConflictChoice,
|
||||
) -> (FileAction, ConflictChoice) {
|
||||
let overwrite = picked == ConflictChoice::Overwrite;
|
||||
match decision.action {
|
||||
MergeAction::Install => (
|
||||
if disk_exists {
|
||||
FileAction::Replaced
|
||||
} else {
|
||||
FileAction::Installed
|
||||
},
|
||||
ConflictChoice::Overwrite,
|
||||
),
|
||||
MergeAction::SkipIdentical => (FileAction::SkippedIdentical, ConflictChoice::KeepUser),
|
||||
MergeAction::KeepUser if overwrite => (
|
||||
if disk_exists {
|
||||
FileAction::Replaced
|
||||
} else {
|
||||
FileAction::Installed
|
||||
},
|
||||
ConflictChoice::Overwrite,
|
||||
),
|
||||
MergeAction::KeepUser => (FileAction::KeptUser, ConflictChoice::KeepUser),
|
||||
}
|
||||
}
|
||||
|
||||
/// 组装一条冲突记录。
|
||||
pub(super) fn conflict(
|
||||
path: String,
|
||||
reason: ConflictReason,
|
||||
choice: ConflictChoice,
|
||||
pack_sha1: &str,
|
||||
pack_size: u64,
|
||||
base_sha1: Option<String>,
|
||||
disk_sha1: Option<String>,
|
||||
) -> ConflictFile {
|
||||
ConflictFile {
|
||||
path,
|
||||
reason,
|
||||
choice,
|
||||
message_key: reason.message_key(),
|
||||
pack_sha1: pack_sha1.to_string(),
|
||||
pack_size,
|
||||
base_sha1,
|
||||
disk_sha1,
|
||||
}
|
||||
}
|
||||
@@ -26,35 +26,58 @@
|
||||
//!
|
||||
//! 失败后目标目录与导入前一致(或明确告诉你哪几项没回去),重跑同一个包是安全的;成功后再跑
|
||||
//! 一遍会靠基线里的哈希走"跳过 / 已一致"路径,不会重复写盘。
|
||||
//!
|
||||
//! # 取消(M195):七个检查点
|
||||
//!
|
||||
//! 编排层负责**阶段边界**的检查点(入口 → 解析后 → 补全后 → 开暂存目录前 → 提交前 → 登记前),
|
||||
//! 长循环里的**逐条**检查在各自实现里(解压/下载在 [`super::phase`],合并落盘在
|
||||
//! [`super::stage`])。逐段表(含"哪几段没有检查点、为什么")见 [`super::cancel`]。
|
||||
//! 每次停下都按"回滚做过什么"收尾:没写盘 ⇒ 删暂存目录;写过 ⇒ 回滚结果如实进报告。
|
||||
//! **取消不走 [`failed`]**:它走 [`ImportReport::cancel`],结果类型是 `Cancelled`
|
||||
//! 而不是 `Failed`。
|
||||
|
||||
use super::super::pack::{PackArchive, PackError, PackRegistry};
|
||||
use super::baseline::PackBaseline;
|
||||
use super::cancel::Stopped;
|
||||
use super::error::ImportError;
|
||||
use super::phase;
|
||||
use super::plan::ImportPlan;
|
||||
use super::report::{
|
||||
keys, ImportIssue, ImportProgress, ImportReport, ImportStage, RegisteredInstance,
|
||||
};
|
||||
use super::stage::{RollbackOutcome, Staging};
|
||||
use super::prepare::{account_plan, probe_and_plan, write_baseline};
|
||||
use super::report::{keys, tell, ImportIssue, ImportReport, ImportStage, RegisteredInstance};
|
||||
use super::stage::{CommitStop, RollbackOutcome, Staging};
|
||||
use super::ImportRequest;
|
||||
|
||||
/// 跑一次导入。**永不返回 `Err`**:所有失败都在报告的三态里。
|
||||
/// 跑一次导入。**永不返回 `Err`**:成功 / 部分成功 / 失败 / 取消全在报告里。
|
||||
pub async fn run(request: &ImportRequest<'_>) -> ImportReport {
|
||||
let mut report = ImportReport::empty(request.target.to_path_buf());
|
||||
|
||||
// 检查点①:入口(用户可能在上一次点开面板之后、线程起来之前就取消了)。
|
||||
if request.cancel.is_cancelled() {
|
||||
return cancelled(report, RollbackOutcome::default());
|
||||
}
|
||||
tell(&request.progress, ImportStage::Probe, 0, 0, None);
|
||||
let (archive, mut plan) = match probe_and_plan(request) {
|
||||
Ok(pair) => pair,
|
||||
Err(error) => return abandoned(report, error),
|
||||
};
|
||||
account_plan(&mut report, &plan);
|
||||
// 检查点②:解析后(包读完了,目标目录还一个字节都没写——停下最便宜)。
|
||||
if request.cancel.is_cancelled() {
|
||||
return cancelled(report, RollbackOutcome::default());
|
||||
}
|
||||
super::complete::complete_and_note(request, &mut plan, &mut report).await;
|
||||
// 检查点③:补全后(补全是逐条网络请求,条目之间已经在 `complete` 里停过一次)。
|
||||
if request.cancel.is_cancelled() {
|
||||
return cancelled(report, RollbackOutcome::default());
|
||||
}
|
||||
|
||||
let baseline = {
|
||||
let (baseline, warnings) = load_baseline(request.target, &plan);
|
||||
report.warnings.extend(warnings);
|
||||
baseline
|
||||
};
|
||||
// 检查点④:开暂存目录前(此后磁盘上就有临时目录了)。
|
||||
if request.cancel.is_cancelled() {
|
||||
return cancelled(report, RollbackOutcome::default());
|
||||
}
|
||||
let staging = match Staging::begin(request.target) {
|
||||
Ok(staging) => staging,
|
||||
Err(error) => return abandoned(report, error),
|
||||
@@ -73,12 +96,27 @@ pub async fn run(request: &ImportRequest<'_>) -> ImportReport {
|
||||
)
|
||||
.await?;
|
||||
phase::fetch_remote(request, &plan, &baseline, &staging, &mut report, &mut next).await?;
|
||||
write_baseline(&staging, &plan, &next)
|
||||
write_baseline(&staging, &plan, &next)?;
|
||||
Ok::<(), Stopped>(())
|
||||
}
|
||||
.await;
|
||||
if let Err(error) = staged {
|
||||
match staged {
|
||||
Ok(()) => {}
|
||||
// 停在暂存阶段:临时目录整个删掉,目标目录与导入前一致(一个字节都没写过)。
|
||||
Err(Stopped::Cancelled) => {
|
||||
staging.abort();
|
||||
return cancelled(report, RollbackOutcome::default());
|
||||
}
|
||||
Err(Stopped::Failed(error)) => {
|
||||
staging.abort();
|
||||
return abandoned(report, error);
|
||||
}
|
||||
}
|
||||
|
||||
// 检查点⑤:提交前(与"清中间产物"同一条边界)。
|
||||
if request.cancel.is_cancelled() {
|
||||
staging.abort();
|
||||
return abandoned(report, error);
|
||||
return cancelled(report, RollbackOutcome::default());
|
||||
}
|
||||
|
||||
// 引擎的续传残片(`.ullpart-*`)与 meta 不是整合包内容,提交前剔掉。
|
||||
@@ -95,13 +133,24 @@ pub async fn run(request: &ImportRequest<'_>) -> ImportReport {
|
||||
}
|
||||
|
||||
tell(&request.progress, ImportStage::Commit, 0, 0, None);
|
||||
let guard = match staging.commit() {
|
||||
// 检查点⑥:落盘(全新导入只在 rename 之前;合并态在 `apply_merge` 里逐文件)。
|
||||
let token = request.cancel.clone();
|
||||
let guard = match staging.commit(&token, &request.progress) {
|
||||
Ok(guard) => guard,
|
||||
Err(failure) => return record_rollback(failed(report, failure.error), failure.rollback),
|
||||
Err(CommitStop::Cancelled(outcome)) => return cancelled(report, outcome),
|
||||
Err(CommitStop::Failed(failure)) => {
|
||||
return record_rollback(failed(report, failure.error), failure.rollback);
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(layout) = request.register {
|
||||
tell(&request.progress, ImportStage::Register, 0, 0, None);
|
||||
// 检查点⑦:登记前。内容已经落进目标目录,所以取消要**连内容一起回滚**,否则留下
|
||||
// "有目录没档案"的半个实例——这正是"取消后不得登记实例"的判据。
|
||||
if request.cancel.is_cancelled() {
|
||||
let outcome = guard.rollback();
|
||||
return cancelled(report, outcome);
|
||||
}
|
||||
let name = request
|
||||
.instance_name
|
||||
.filter(|name| !name.trim().is_empty())
|
||||
@@ -138,31 +187,12 @@ pub async fn run(request: &ImportRequest<'_>) -> ImportReport {
|
||||
report
|
||||
}
|
||||
|
||||
/// 开包 + 探测 + 组计划(任何失败都是 `Failed`)。
|
||||
/// 取消收尾:把「用户取消」写进报告(**不是失败**),返回收好尾的报告。
|
||||
///
|
||||
/// 预检([`super::scan`])走的是**同一段代码**:两边对"这包是什么、要装什么"必须完全一致,
|
||||
/// 否则"预检说没冲突、导入说有冲突"这种事就会发生。
|
||||
pub(super) fn probe_and_plan(
|
||||
request: &ImportRequest<'_>,
|
||||
) -> Result<(PackArchive, ImportPlan), ImportError> {
|
||||
// 路径穿越双检 + 符号链接 + 条数/体积限额都在 `open` 里一次做完(M110 的 `archive`)。
|
||||
let archive = PackArchive::open(request.pack, request.limits)?;
|
||||
let (format, manifest) = PackRegistry::builtin().read(&archive)?;
|
||||
let plan = ImportPlan::build(&archive, format.id(), manifest)?;
|
||||
Ok((archive, plan))
|
||||
}
|
||||
|
||||
/// 把计划里的静态信息抄进报告。
|
||||
fn account_plan(report: &mut ImportReport, plan: &ImportPlan) {
|
||||
report.format = Some(plan.format.clone());
|
||||
report.pack_name = Some(plan.manifest.name.clone());
|
||||
report.pack_version = plan.manifest.version.clone();
|
||||
report.game_version = Some(plan.mc_version.clone());
|
||||
report.loader = plan.loader.clone();
|
||||
report.version_id = Some(plan.version_id.clone());
|
||||
report.max_memory_mb = plan.max_memory_mb;
|
||||
report.rejected_components.clone_from(&plan.rejected);
|
||||
report.warnings.extend(plan.notes.iter().cloned());
|
||||
/// 三档注记与 `rolled_back` 的判据在 [`ImportReport::cancel`](那里也是失败路径的同形三档)。
|
||||
fn cancelled(mut report: ImportReport, outcome: RollbackOutcome) -> ImportReport {
|
||||
report.cancel(outcome);
|
||||
report
|
||||
}
|
||||
|
||||
/// 读三方合并的 base 侧:没有 / 读不出来 / **属于别的包** 三条路都不致命。
|
||||
@@ -233,47 +263,6 @@ fn record_rollback(mut report: ImportReport, outcome: RollbackOutcome) -> Import
|
||||
report
|
||||
}
|
||||
|
||||
/// 基线作为**最后一个暂存文件**写进去:它跟同一个事务一起提交 / 回滚。
|
||||
fn write_baseline(
|
||||
staging: &Staging,
|
||||
plan: &ImportPlan,
|
||||
next: &PackBaseline,
|
||||
) -> Result<(), ImportError> {
|
||||
let mut baseline = next.clone();
|
||||
baseline.pack_name.clone_from(&plan.manifest.name);
|
||||
baseline.format.clone_from(&plan.format);
|
||||
let bytes = baseline.to_bytes().map_err(PackError::io)?;
|
||||
staging.write(&baseline_relative(), &bytes)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 基线相对实例根的路径(`<根>/.ull/pack-baseline.json`;与读侧同源,见
|
||||
/// [`PackBaseline::relative_path`])。
|
||||
#[must_use]
|
||||
pub fn baseline_relative() -> String {
|
||||
PackBaseline::relative_path()
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/")
|
||||
}
|
||||
|
||||
/// 发一条进度事件(`sink` 为 `None` 时什么都不做)。
|
||||
pub(super) fn tell(
|
||||
sink: &Option<super::report::ImportProgressSink>,
|
||||
stage: ImportStage,
|
||||
done: usize,
|
||||
total: usize,
|
||||
current: Option<String>,
|
||||
) {
|
||||
if let Some(sink) = sink {
|
||||
sink(&ImportProgress {
|
||||
stage,
|
||||
done,
|
||||
total,
|
||||
current,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 失败收尾:**只落错误,不宣称目标目录状态**。
|
||||
///
|
||||
/// 口径由调用方补:从未写过目标目录 ⇒ [`abandoned`];已经写过 ⇒ [`record_rollback`]。
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
//! 导入管线的**准备段**:读包 → 组计划 → 抄进报告 → 写基线(一个字节都还没进目标目录)。
|
||||
//!
|
||||
//! 从 [`super::pipeline`] 拆出来的是"碰盘之前"的那一段,行为逐字未变:编排文件(`pipeline.rs`)
|
||||
//! 要装下取消的七个检查点与回滚收尾,把这一段移到同级模块是为了让它仍在一屏之内。
|
||||
//!
|
||||
//! 这里的函数**都不查取消令牌**:整段落在"解析后一次检查"之前/之后的两侧,而 `open` 是
|
||||
//! 不可分割的(zip 目录 + 四检 + 限额一次扫完)——见 [`super::cancel`] 的逐段表。
|
||||
|
||||
use super::baseline::PackBaseline;
|
||||
use super::error::ImportError;
|
||||
use super::plan::ImportPlan;
|
||||
use super::report::ImportReport;
|
||||
use super::stage::Staging;
|
||||
use super::ImportRequest;
|
||||
use crate::addon::pack::{PackArchive, PackError, PackRegistry};
|
||||
|
||||
/// 开包 + 探测 + 组计划(任何失败都是 `Failed`)。
|
||||
///
|
||||
/// 预检([`super::scan`])走的是**同一段代码**:两边对"这包是什么、要装什么"必须完全一致,
|
||||
/// 否则"预检说没冲突、导入说有冲突"这种事就会发生。
|
||||
pub(super) fn probe_and_plan(
|
||||
request: &ImportRequest<'_>,
|
||||
) -> Result<(PackArchive, ImportPlan), ImportError> {
|
||||
// 路径穿越双检 + 符号链接 + 条数/体积限额都在 `open` 里一次做完(M110 的 `archive`)。
|
||||
let archive = PackArchive::open(request.pack, request.limits)?;
|
||||
let (format, manifest) = PackRegistry::builtin().read(&archive)?;
|
||||
let plan = ImportPlan::build(&archive, format.id(), manifest)?;
|
||||
Ok((archive, plan))
|
||||
}
|
||||
|
||||
/// 把计划里的静态信息抄进报告。
|
||||
pub(super) fn account_plan(report: &mut ImportReport, plan: &ImportPlan) {
|
||||
report.format = Some(plan.format.clone());
|
||||
report.pack_name = Some(plan.manifest.name.clone());
|
||||
report.pack_version = plan.manifest.version.clone();
|
||||
report.game_version = Some(plan.mc_version.clone());
|
||||
report.loader = plan.loader.clone();
|
||||
report.version_id = Some(plan.version_id.clone());
|
||||
report.max_memory_mb = plan.max_memory_mb;
|
||||
report.rejected_components.clone_from(&plan.rejected);
|
||||
report.warnings.extend(plan.notes.iter().cloned());
|
||||
}
|
||||
|
||||
/// 基线作为**最后一个暂存文件**写进去:它跟同一个事务一起提交 / 回滚。
|
||||
pub(super) fn write_baseline(
|
||||
staging: &Staging,
|
||||
plan: &ImportPlan,
|
||||
next: &PackBaseline,
|
||||
) -> Result<(), ImportError> {
|
||||
let mut baseline = next.clone();
|
||||
baseline.pack_name.clone_from(&plan.manifest.name);
|
||||
baseline.format.clone_from(&plan.format);
|
||||
let bytes = baseline.to_bytes().map_err(PackError::io)?;
|
||||
staging.write(&baseline_relative(), &bytes)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 基线相对实例根的路径(`<根>/.ull/pack-baseline.json`;与读侧同源,见
|
||||
/// [`PackBaseline::relative_path`])。
|
||||
#[must_use]
|
||||
pub fn baseline_relative() -> String {
|
||||
PackBaseline::relative_path()
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/")
|
||||
}
|
||||
@@ -32,7 +32,11 @@ use serde::Serialize;
|
||||
use super::error::ImportError;
|
||||
use super::stage::RollbackOutcome;
|
||||
|
||||
/// 导入三态。
|
||||
/// 导入结果(三态 + 一档"用户取消")。
|
||||
///
|
||||
/// 取消**单列一档、不是第四种失败**(M195):它有自己的变体、自己的文案 key、自己的退出码,
|
||||
/// 且 `ImportReport::error` 恒为 `None`。把两者并成一个错误变体,UI 就会把"用户自己点的"
|
||||
/// 画成红的,脚本也会把"我改主意了"当成"装坏了"。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ImportStatus {
|
||||
@@ -42,6 +46,8 @@ pub enum ImportStatus {
|
||||
Partial,
|
||||
/// 致命失败,已整包回滚。
|
||||
Failed,
|
||||
/// 用户主动取消:管线在检查点停下,目标目录与导入前一致(`error` 为 `None`)。
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
impl ImportStatus {
|
||||
@@ -52,6 +58,7 @@ impl ImportStatus {
|
||||
Self::Success => "success",
|
||||
Self::Partial => "partial",
|
||||
Self::Failed => "failed",
|
||||
Self::Cancelled => "cancelled",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,16 +69,21 @@ impl ImportStatus {
|
||||
Self::Success => keys::STATUS_SUCCESS,
|
||||
Self::Partial => keys::STATUS_PARTIAL,
|
||||
Self::Failed => keys::STATUS_FAILED,
|
||||
Self::Cancelled => keys::STATUS_CANCELLED,
|
||||
}
|
||||
}
|
||||
|
||||
/// CLI 退出码:`0` / `3` / `1`(`2` 留给内部错误,`1` 与用法错误同码但 JSON 里可区分)。
|
||||
/// CLI 退出码:`0` / `3` / `1` / `130`(`2` 留给内部错误,`1` 与用法错误同码但 JSON 里可区分)。
|
||||
///
|
||||
/// `130` 是「被用户中断」的惯例码(`128 + SIGINT`):**与失败不同码**,脚本不会把
|
||||
/// 「用户改主意」记成「装坏了」,也不会误判成成功。
|
||||
#[must_use]
|
||||
pub const fn exit_code(self) -> i32 {
|
||||
match self {
|
||||
Self::Success => 0,
|
||||
Self::Partial => 3,
|
||||
Self::Failed => 1,
|
||||
Self::Cancelled => 130,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -134,6 +146,27 @@ pub struct ImportProgress {
|
||||
/// 进度回调。
|
||||
pub type ImportProgressSink = std::sync::Arc<dyn Fn(&ImportProgress) + Send + Sync>;
|
||||
|
||||
/// 发一条进度事件(`sink` 为 `None` 时什么都不做)。
|
||||
///
|
||||
/// 放在这里是因为它是 [`ImportProgress`] 的唯一出口:编排、阶段实现、提交事务都走它,
|
||||
/// 谁都不许自己拼一条进度事件。
|
||||
pub(crate) fn tell(
|
||||
sink: &Option<ImportProgressSink>,
|
||||
stage: ImportStage,
|
||||
done: usize,
|
||||
total: usize,
|
||||
current: Option<String>,
|
||||
) {
|
||||
if let Some(sink) = sink {
|
||||
sink(&ImportProgress {
|
||||
stage,
|
||||
done,
|
||||
total,
|
||||
current,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次导入的完整结果。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ImportReport {
|
||||
@@ -240,4 +273,65 @@ impl ImportReport {
|
||||
self.error = Some(error);
|
||||
self.status = ImportStatus::Failed;
|
||||
}
|
||||
|
||||
/// 取消收尾:状态落到 [`ImportStatus::Cancelled`],并说准**目标目录现在的样子**。
|
||||
///
|
||||
/// 与 [`ImportReport::fail`] 是**两条路**(M195 的守门条款③):取消不是失败,`error` 保持
|
||||
/// `None`,UI 不弹红、CLI 退出码 `130`。把两者并成一个错误变体,这一条就守不住。
|
||||
///
|
||||
/// `outcome` 是取消之前已经做过的回滚动作(落盘之前取消 ⇒ `attempted == 0`,即"没写过")。
|
||||
/// 三档文案与失败路径**同形**:没写过 / 回滚干净 / 回滚没做完——回滚不完整时同样不许宣称
|
||||
/// "与导入前一致"。
|
||||
pub fn cancel(&mut self, outcome: RollbackOutcome) {
|
||||
// 取消前"处理过"的条目一条都没进目标目录(暂存目录已删 / 已回滚):留着它们会让
|
||||
// `installed()` / `bytes_written` 谎报"装了多少",所以清空并把这笔账写进注记。
|
||||
let handled = self.files.len();
|
||||
self.files.clear();
|
||||
self.conflicts.clear();
|
||||
self.missing.clear();
|
||||
self.bytes_written = 0;
|
||||
self.error = None;
|
||||
self.status = ImportStatus::Cancelled;
|
||||
|
||||
if outcome.attempted == 0 {
|
||||
// 落盘之前就停了:目标目录一个字节都没动。
|
||||
self.rolled_back = true;
|
||||
self.warnings.push(ImportIssue::new(
|
||||
keys::NOTE_CANCELLED,
|
||||
format!(
|
||||
"已取消:目标目录未被写入({});已处理的 {handled} 条文件未落地",
|
||||
self.target.display()
|
||||
),
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
if outcome.is_complete() {
|
||||
self.rolled_back = true;
|
||||
self.warnings.push(ImportIssue::new(
|
||||
keys::NOTE_CANCELLED,
|
||||
format!(
|
||||
"已取消:已回滚 {} 项,目标目录恢复为导入前的样子({})",
|
||||
outcome.attempted,
|
||||
self.target.display()
|
||||
),
|
||||
));
|
||||
} else {
|
||||
// 回滚本身失败:与失败路径同一口径——不许在"可能没回干净"时宣称一致。
|
||||
self.rolled_back = false;
|
||||
self.warnings.push(ImportIssue::new(
|
||||
keys::NOTE_ROLLBACK_INCOMPLETE,
|
||||
format!(
|
||||
"取消时回滚有 {} 项未完成(目标目录可能与导入前不同):{}",
|
||||
outcome.failure_count(),
|
||||
outcome.failed.join(";")
|
||||
),
|
||||
));
|
||||
self.warnings.push(ImportIssue::new(
|
||||
keys::NOTE_CANCELLED,
|
||||
format!("已取消:已处理 {handled} 条文件、回滚未完整(详见 rollback.failed)"),
|
||||
));
|
||||
}
|
||||
self.rollback = Some(outcome);
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,8 @@ pub const STATUS_SUCCESS: &str = "pack.import.status.success";
|
||||
pub const STATUS_PARTIAL: &str = "pack.import.status.partial";
|
||||
/// 状态:失败(是否回滚干净看 `ImportReport::rolled_back`)。
|
||||
pub const STATUS_FAILED: &str = "pack.import.status.failed";
|
||||
/// 状态:**用户取消**(不是失败,`error` 为空)。
|
||||
pub const STATUS_CANCELLED: &str = "pack.import.status.cancelled";
|
||||
|
||||
/// 注记:`server-overrides/` 客户端不解压。
|
||||
pub const NOTE_SERVER_OVERRIDES_SKIPPED: &str = "pack.import.note.server_overrides_skipped";
|
||||
@@ -51,6 +53,8 @@ pub const NOTE_BASELINE_PACK_MISMATCH: &str = "pack.import.note.baseline_pack_mi
|
||||
pub const NOTE_ENGINE_ARTIFACTS_DROPPED: &str = "pack.import.note.engine_artifacts_dropped";
|
||||
/// 注记:回滚**未完整完成**(逐项列失败,`rolled_back` 为 false)。
|
||||
pub const NOTE_ROLLBACK_INCOMPLETE: &str = "pack.import.note.rollback_incomplete";
|
||||
/// 注记:用户取消,管线在检查点停下(说明停在哪一步、已处理的条目未落地)。
|
||||
pub const NOTE_CANCELLED: &str = "pack.import.note.cancelled";
|
||||
/// 注记:同包另一条声明遮蔽了这个路径(说清谁赢、两份内容是否相同)。
|
||||
pub const NOTE_OVERRIDE_SHADOWED: &str = "pack.import.note.override_shadowed";
|
||||
/// 注记:这些远端条目预检判不了(补全前没有路径 / 有路径但缺 sha1 声明),无法预判冲突。
|
||||
@@ -87,6 +91,7 @@ pub const ALL: &[&str] = &[
|
||||
STATUS_SUCCESS,
|
||||
STATUS_PARTIAL,
|
||||
STATUS_FAILED,
|
||||
STATUS_CANCELLED,
|
||||
NOTE_SERVER_OVERRIDES_SKIPPED,
|
||||
NOTE_UNKNOWN_DEPENDENCY,
|
||||
NOTE_ENV_UNSUPPORTED,
|
||||
@@ -100,6 +105,7 @@ pub const ALL: &[&str] = &[
|
||||
NOTE_BASELINE_PACK_MISMATCH,
|
||||
NOTE_ENGINE_ARTIFACTS_DROPPED,
|
||||
NOTE_ROLLBACK_INCOMPLETE,
|
||||
NOTE_CANCELLED,
|
||||
NOTE_OVERRIDE_SHADOWED,
|
||||
NOTE_REMOTE_PATH_UNKNOWN,
|
||||
NOTE_REMOTE_COMPLETED,
|
||||
|
||||
@@ -42,8 +42,9 @@ use super::baseline::PackBaseline;
|
||||
use super::decision::ConflictDecisions;
|
||||
use super::error::ImportError;
|
||||
use super::merge;
|
||||
use super::pipeline::{load_baseline, probe_and_plan};
|
||||
use super::pipeline::load_baseline;
|
||||
use super::plan::ImportPlan;
|
||||
use super::prepare::probe_and_plan;
|
||||
use super::report::{keys, ConflictReason, FileSource, ImportIssue, LoaderTarget};
|
||||
use super::ImportRequest;
|
||||
use crate::download::verify;
|
||||
|
||||
@@ -10,6 +10,15 @@
|
||||
//! 为什么全新导入不逐文件搬:逐文件搬意味着"搬到一半失败"会留下一个**看起来存在**的
|
||||
//! 半个实例,而 `rename` 一个目录是单步原子操作——失败就只有"没有"这一种结果。
|
||||
//!
|
||||
//! # 取消落在这层怎么办(M195)
|
||||
//!
|
||||
//! | 起点 | 取消点 | 收场 |
|
||||
//! |------|--------|------|
|
||||
//! | 全新导入 | **只在 `rename` 之前** | 删暂存目录,目标目录从未出现 |
|
||||
//! | 合并态 | 每条搬动之前 | journal **逆序**撤回(新文件删掉、旧备份改回原名) |
|
||||
//!
|
||||
//! 合并态逐文件报进度(`ImportStage::Commit`):既是界面该有的动静,也是取消能落下的地方。
|
||||
//!
|
||||
//! # 两道路径检查都在这层再走一遍
|
||||
//!
|
||||
//! 第一道是 M110 的 [`normalize_file`](七条写死规则:空名 / `\0` / `\` / `:` / 前导 `/` /
|
||||
@@ -19,14 +28,17 @@
|
||||
|
||||
pub mod commit;
|
||||
|
||||
pub use commit::{CommitFailure, CommitGuard, RollbackOutcome};
|
||||
pub use commit::{CommitFailure, CommitGuard, CommitStop, RollbackOutcome};
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use super::super::pack::path::normalize_file;
|
||||
use super::super::pack::PackError;
|
||||
use super::cancel::{ImportCancel, Stopped};
|
||||
use super::error::ImportError;
|
||||
use super::report::tell;
|
||||
use super::report::{ImportProgressSink, ImportStage};
|
||||
use crate::platform::atomic::write_atomic;
|
||||
|
||||
use crate::download::atomic::{META_SUFFIX, PART_SUFFIX};
|
||||
@@ -177,38 +189,62 @@ impl Staging {
|
||||
|
||||
/// 提交事务:返回一个**必须显式 `finish`** 的守卫(析构即回滚)。
|
||||
///
|
||||
/// `cancel` 是取消令牌,`progress` 是进度回调(合并态**逐文件**报一条:几千文件的合并本来
|
||||
/// 就该看得见动静,而"取消"也正需要文件之间的落点)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// `rename` 失败、合并态逐文件搬动失败。**回滚已经在这条路径上做过**,其结果连同错误
|
||||
/// 一起返回([`CommitFailure::rollback`]):调用方必须把它写进报告,不许假设"失败 = 已回滚干净"。
|
||||
pub fn commit(self) -> Result<CommitGuard, CommitFailure> {
|
||||
/// - [`CommitStop::Cancelled`]:取消落在提交前 / 合并中途。**回滚已经在这条路径上做过**,
|
||||
/// 结果一并返回(调用方把它写进报告的 `rollback` 机器位);
|
||||
/// - [`CommitStop::Failed`]:`rename` 失败、合并态逐文件搬动失败。回滚同样已经做过,其结果
|
||||
/// 随错误返回([`CommitFailure::rollback`]):调用方不许假设"失败 = 已回滚干净"。
|
||||
///
|
||||
/// 全新导入只有一次 `rename`:那是单步原子操作,**取消点只在它之前**——之后不存在"半个
|
||||
/// 实例"这种中间态,也就没有"取消"这一说。
|
||||
pub fn commit(
|
||||
self,
|
||||
cancel: &ImportCancel,
|
||||
progress: &Option<ImportProgressSink>,
|
||||
) -> Result<CommitGuard, CommitStop> {
|
||||
match self.mode {
|
||||
Mode::Fresh => match std::fs::rename(&self.temp, &self.target) {
|
||||
Ok(()) => Ok(CommitGuard::fresh(self.target.clone(), self.temp.clone())),
|
||||
Err(error) => {
|
||||
Mode::Fresh => {
|
||||
if cancel.is_cancelled() {
|
||||
// 还没 rename:目标目录一个字节都没动,删掉暂存目录就是零残留。
|
||||
self.abort_inner();
|
||||
Err(CommitFailure::io(
|
||||
format!(
|
||||
"把 {} 改名成 {} 失败:{error}",
|
||||
self.temp.display(),
|
||||
self.target.display()
|
||||
),
|
||||
RollbackOutcome::default(),
|
||||
))
|
||||
return Err(CommitStop::Cancelled(RollbackOutcome::default()));
|
||||
}
|
||||
},
|
||||
match std::fs::rename(&self.temp, &self.target) {
|
||||
Ok(()) => Ok(CommitGuard::fresh(self.target.clone(), self.temp.clone())),
|
||||
Err(error) => {
|
||||
self.abort_inner();
|
||||
Err(CommitStop::Failed(CommitFailure::io(
|
||||
format!(
|
||||
"把 {} 改名成 {} 失败:{error}",
|
||||
self.temp.display(),
|
||||
self.target.display()
|
||||
),
|
||||
RollbackOutcome::default(),
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
Mode::Merge => {
|
||||
let mut applied: Vec<Applied> = Vec::new();
|
||||
match self.apply_merge(&mut applied) {
|
||||
match self.apply_merge(&mut applied, cancel, progress) {
|
||||
Ok(()) => Ok(CommitGuard::merge(
|
||||
self.target.clone(),
|
||||
self.temp.clone(),
|
||||
applied,
|
||||
)),
|
||||
Err(error) => {
|
||||
Err(Stopped::Cancelled) => {
|
||||
let rollback = rollback_merge(&mut applied);
|
||||
self.abort_inner();
|
||||
Err(CommitFailure::new(error, rollback))
|
||||
Err(CommitStop::Cancelled(rollback))
|
||||
}
|
||||
Err(Stopped::Failed(error)) => {
|
||||
let rollback = rollback_merge(&mut applied);
|
||||
self.abort_inner();
|
||||
Err(CommitStop::Failed(CommitFailure::new(error, rollback)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -244,15 +280,32 @@ impl Staging {
|
||||
}
|
||||
|
||||
/// 合并态:把临时目录里的文件逐个搬进目标目录(每个都带备份)。
|
||||
fn apply_merge(&self, applied: &mut Vec<Applied>) -> Result<(), ImportError> {
|
||||
///
|
||||
/// **逐文件取消点**:每条搬动之前查一次令牌,已取消 ⇒ `Err(Stopped::Cancelled)`,调用方
|
||||
/// 按 journal 逆序回滚(新文件删掉、旧备份改回原名)。
|
||||
fn apply_merge(
|
||||
&self,
|
||||
applied: &mut Vec<Applied>,
|
||||
cancel: &ImportCancel,
|
||||
progress: &Option<ImportProgressSink>,
|
||||
) -> Result<(), Stopped> {
|
||||
let mut staged: Vec<PathBuf> = Vec::new();
|
||||
collect_files(&self.temp, &mut staged)?;
|
||||
staged.sort();
|
||||
for source in staged {
|
||||
let total = staged.len();
|
||||
for (index, source) in staged.into_iter().enumerate() {
|
||||
cancel.stopped()?;
|
||||
let relative = source
|
||||
.strip_prefix(&self.temp)
|
||||
.map_err(|_| io_error(format!("暂存路径不在临时目录里:{}", source.display())))?;
|
||||
let relative_text = relative.to_string_lossy().replace('\\', "/");
|
||||
tell(
|
||||
progress,
|
||||
ImportStage::Commit,
|
||||
index,
|
||||
total,
|
||||
Some(relative_text.clone()),
|
||||
);
|
||||
// 搬动前再规整一次(第三道,防"临时目录里被塞了奇怪的名字")。
|
||||
let normalized = normalize_file(&relative_text)?;
|
||||
let dest = self.target.join(&normalized);
|
||||
|
||||
@@ -207,6 +207,18 @@ pub(super) fn backup_path(dest: &Path) -> PathBuf {
|
||||
dest.with_file_name(name)
|
||||
}
|
||||
|
||||
/// 提交阶段的两条停止路线(M195):**取消不是失败**,所以不共用 [`CommitFailure`]。
|
||||
///
|
||||
/// 两条路都**已经把回滚做过**(全新导入是 `abort`,合并态是 journal 逆序撤回):
|
||||
/// 调用方拿到它们只管写报告,不必再自己清一遍。
|
||||
#[derive(Debug)]
|
||||
pub enum CommitStop {
|
||||
/// 用户取消(回滚结果一并交回,可能不完整)。
|
||||
Cancelled(RollbackOutcome),
|
||||
/// 真失败(回滚结果连同错误一起交回)。
|
||||
Failed(CommitFailure),
|
||||
}
|
||||
|
||||
/// 一次**失败的回滚**(提交阶段失败时连同错误一起交回调用方)。
|
||||
///
|
||||
/// `io_error` 让"把一个 IO 原因包成 `pack_io`"只在一处定义。
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
//! 依赖图谱的**形状**:一次解析要什么([`ResolveRequest`])、出什么([`DependencyPlan`])。
|
||||
//!
|
||||
//! 算法在 [`super::resolve`];本文件只放类型与不变量,于是「两档失败从类型上可分辨」这件事
|
||||
//! 不必读算法也能核对。
|
||||
//!
|
||||
//! # 三份名单,一份都不许丢
|
||||
//!
|
||||
//! | 名单 | 含义 | 上层该做什么 |
|
||||
//! |------|------|--------------|
|
||||
//! | [`DependencyPlan::nodes`] | 要下的(BFS 顺序、父子关系齐全) | 逐个下载 |
|
||||
//! | [`DependencyPlan::skipped`] | 目标目录里已经有同名文件 | 提示「已存在,跳过」 |
|
||||
//! | [`DependencyPlan::failures`] | 装不了的,**每条自带 [`FailureKind`]** | 两档分开说;进可重试名单 |
|
||||
//!
|
||||
//! `truncated` 是第四样东西:撞到 [`DEFAULT_MAX_DEPTH`] 而没展开的项目。它既不是成功也不是
|
||||
//! 失败(站点没拒绝,我们主动停),但**必须**露出来——静默截断会让用户看着「装好了」而缺前置。
|
||||
//!
|
||||
//! # 两档为什么不能合成一句
|
||||
//!
|
||||
//! 「所选版本/加载器没有匹配项」与「网络异常 / 项目不存在」要用户做的事完全不同:
|
||||
//! 前者换版本或换加载器,后者检查网络再试。字符串错误只能靠 `contains("网络")` 猜,
|
||||
//! 一改文案就失效;[`FailureKind`] 把判据放在类型上,叶子成因(404 / 连不上 / 5xx)
|
||||
//! 另存在 [`DependencyFailure::cause`] 上,允许上层再细分而不用重新判。
|
||||
|
||||
use super::model::{VersionFile, VersionQuery};
|
||||
|
||||
/// 默认最大深度(根 = 0)。六层足够覆盖真实套娃(前置的前置的前置……),
|
||||
/// 同时给环与异常链一个硬边界——撞上就记 [`DependencyPlan::truncated`],**不静默截断**。
|
||||
pub const DEFAULT_MAX_DEPTH: u8 = 6;
|
||||
|
||||
/// 目标目录里已存在的文件(调用方扫描后给;core 不读盘)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct PresentFile {
|
||||
/// 文件名(可以带路径,比对时会砍掉)
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
impl PresentFile {
|
||||
/// 记一个已存在的文件名。
|
||||
#[must_use]
|
||||
pub fn new(name: impl Into<String>) -> Self {
|
||||
Self { name: name.into() }
|
||||
}
|
||||
|
||||
/// 归一化后的比对键(见 [`present_key`])。
|
||||
#[must_use]
|
||||
pub fn key(&self) -> String {
|
||||
present_key(&self.name)
|
||||
}
|
||||
}
|
||||
|
||||
/// 文件名 → 比对键:只留文件名、小写、去掉 `.disabled` 后缀。
|
||||
///
|
||||
/// 为什么带 `.disabled`:本仓用**路径后缀**表示禁用(`mods/x.jar.disabled`,见
|
||||
/// `core::addon::export`),禁用中的 jar 也是「装过」,再下一份会和它并存。
|
||||
#[must_use]
|
||||
pub fn present_key(name: &str) -> String {
|
||||
let base = name
|
||||
.rsplit(['/', '\\'])
|
||||
.next()
|
||||
.unwrap_or(name)
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
base.strip_suffix(".disabled").unwrap_or(&base).to_string()
|
||||
}
|
||||
|
||||
/// 一次解析请求。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ResolveRequest {
|
||||
/// 起点(项目 id 或 slug);同层处理时按项目键字典序,与这里给的顺序无关
|
||||
pub roots: Vec<String>,
|
||||
/// 目标游戏版本(选版本用;`None` = 不限)
|
||||
pub game_version: Option<String>,
|
||||
/// 目标加载器(选版本用;`None` = 不限)
|
||||
pub loader: Option<String>,
|
||||
/// 最大深度(根 = 0,见 [`DEFAULT_MAX_DEPTH`])
|
||||
pub max_depth: u8,
|
||||
/// 目标目录里已存在的文件(盘上去重的依据;空 = 目录是空的)
|
||||
pub present: Vec<PresentFile>,
|
||||
}
|
||||
|
||||
impl Default for ResolveRequest {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
roots: Vec::new(),
|
||||
game_version: None,
|
||||
loader: None,
|
||||
max_depth: DEFAULT_MAX_DEPTH,
|
||||
present: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ResolveRequest {
|
||||
/// 单根请求(内容库「装进实例」的入口形状)。
|
||||
#[must_use]
|
||||
pub fn single(project: impl Into<String>) -> Self {
|
||||
Self {
|
||||
roots: vec![project.into()],
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// 这个项目的版本请求(过滤条件相同,只换项目)。
|
||||
#[must_use]
|
||||
pub fn version_query(&self, project: &str) -> VersionQuery {
|
||||
VersionQuery {
|
||||
project: project.to_string(),
|
||||
game_version: self.game_version.clone(),
|
||||
loader: self.loader.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 计划里的一条「要装什么」。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PlannedNode {
|
||||
/// 规范项目 id(站点回的 `project_id`;空则用请求时的键)
|
||||
pub project: String,
|
||||
/// 当初请求它用的键(slug 或 id)——重试要用同一个
|
||||
pub requested_as: String,
|
||||
/// 版本 id
|
||||
pub version_id: String,
|
||||
/// 版本名
|
||||
pub version_name: String,
|
||||
/// 版本号
|
||||
pub version_number: String,
|
||||
/// 要下载的那个文件(`primary` 优先,见 `Version::primary_file`)
|
||||
pub file: VersionFile,
|
||||
/// 距根的层数(根 = 0)
|
||||
pub depth: u8,
|
||||
/// 谁要求装它(规范项目 id,排序去重;根为空 = 用户自己点的)
|
||||
pub required_by: Vec<String>,
|
||||
}
|
||||
|
||||
impl PlannedNode {
|
||||
/// 是用户点的那个项目吗(根 = 层数 0)。
|
||||
///
|
||||
/// 判据是 `depth` 而不是 `required_by.is_empty()`:环里根也会被别人「要求」
|
||||
/// (A ⇄ B 时 A 的 `required_by` 里有 B),但它在计划里仍是起点。
|
||||
#[must_use]
|
||||
pub fn is_root(&self) -> bool {
|
||||
self.depth == 0
|
||||
}
|
||||
}
|
||||
|
||||
/// 为什么跳过。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SkipReason {
|
||||
/// 目标目录里已经有同名文件(含 `.disabled`)
|
||||
AlreadyPresent,
|
||||
}
|
||||
|
||||
impl SkipReason {
|
||||
/// 稳定字符串 id(日志 / DTO 用,**不要改名**)。
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::AlreadyPresent => "already_present",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一条被跳过的(本来要下载的)项目。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SkippedNode {
|
||||
/// 规范项目 id
|
||||
pub project: String,
|
||||
/// 请求它用的键
|
||||
pub requested_as: String,
|
||||
/// 计划里的文件名(在目录里找到了同名的那个)
|
||||
pub file_name: String,
|
||||
/// 距根的层数
|
||||
pub depth: u8,
|
||||
/// 谁要求装它
|
||||
pub required_by: Vec<String>,
|
||||
/// 跳过原因
|
||||
pub reason: SkipReason,
|
||||
}
|
||||
|
||||
/// 失败的两档(**类型上就分开,不许拼成一句「下载失败」**)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FailureKind {
|
||||
/// 站点有这个项目,但所选游戏版本 / 加载器下没有可装的条目
|
||||
/// (版本列表为空,或选中的版本没有可下载的主文件)。
|
||||
///
|
||||
/// 用户该做的动作是**换版本或换加载器**;原样重试得到的还是这一档。
|
||||
Incompatible,
|
||||
/// 取不到:没联网 / 项目不存在 / 站点出错 / 响应坏。
|
||||
///
|
||||
/// 用户该做的动作是**检查网络后再试**;具体叶子成因保留在
|
||||
/// [`DependencyFailure::cause`] 上(`NotFound` 与 `NotConnected` 不能混为一谈)。
|
||||
Unreachable,
|
||||
}
|
||||
|
||||
impl FailureKind {
|
||||
/// 稳定字符串 id(日志 / 命令行输出用,**不要改名**)。
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Incompatible => "incompatible",
|
||||
Self::Unreachable => "unreachable",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一条解析失败(进「可重试名单」的就是它们)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DependencyFailure {
|
||||
/// 请求它用的键(重试也用它)
|
||||
pub requested_as: String,
|
||||
/// 当初因为谁而被要求(规范项目 id;根为空)
|
||||
pub required_by: Vec<String>,
|
||||
/// 距根的层数
|
||||
pub depth: u8,
|
||||
/// 哪一档
|
||||
pub kind: FailureKind,
|
||||
/// 叶子成因(只有 [`FailureKind::Unreachable`] 有)
|
||||
pub cause: Option<super::transport::OnlineErrorKind>,
|
||||
/// 人读说明(站点/引擎原话 + 上下文,不是界面文案)
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// 声明了 `required`、但**只给了 `version_id`**(没有 `project_id`)的依赖。
|
||||
///
|
||||
/// BFS 需要项目 id 才能取版本(`/v2/project/{id}/version`),所以这条既装不了、也不该被
|
||||
/// 假装没看见:留在计划里,上层才能说「有 N 条前置没法解析」而不是默默少装一个。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UnresolvedRequired {
|
||||
/// 声明它的项目(规范 id)
|
||||
pub parent: String,
|
||||
/// 站点给的版本 id(`None` = 连版本 id 都没有,只有文件名)
|
||||
pub version_id: Option<String>,
|
||||
/// 站点给的文件名
|
||||
pub file_name: Option<String>,
|
||||
}
|
||||
|
||||
/// 一次 BFS 的完整结果(成功、跳过、失败三份名单,都**不丢**)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DependencyPlan {
|
||||
/// 这次用的请求(重试要沿用同样的过滤条件与同一份 `present`)
|
||||
pub request: ResolveRequest,
|
||||
/// 要装的(BFS 顺序:层号小的在前,同层按项目键字典序)
|
||||
pub nodes: Vec<PlannedNode>,
|
||||
/// 目录里已经有了、不用再下的
|
||||
pub skipped: Vec<SkippedNode>,
|
||||
/// 装不了的(两档混在同一个列表里,但每条自带 [`FailureKind`])
|
||||
pub failures: Vec<DependencyFailure>,
|
||||
/// 撞到 `max_depth` 而没展开的项目键(排序去重)
|
||||
pub truncated: Vec<String>,
|
||||
/// 有 `required` 但没有项目 id、解析不了的前置(排序去重)
|
||||
pub unresolved_required: Vec<UnresolvedRequired>,
|
||||
}
|
||||
|
||||
impl DependencyPlan {
|
||||
/// 空计划(`resolve` 从这里开始填)。
|
||||
#[must_use]
|
||||
pub fn new(request: &ResolveRequest) -> Self {
|
||||
Self {
|
||||
request: request.clone(),
|
||||
nodes: Vec::new(),
|
||||
skipped: Vec::new(),
|
||||
failures: Vec::new(),
|
||||
truncated: Vec::new(),
|
||||
unresolved_required: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 一条失败都没有吗(空循环也算干净)。
|
||||
#[must_use]
|
||||
pub fn is_clean(&self) -> bool {
|
||||
self.failures.is_empty()
|
||||
}
|
||||
|
||||
/// 某一档的失败(两档分开看时用)。
|
||||
pub fn failures_of(&self, kind: FailureKind) -> impl Iterator<Item = &DependencyFailure> {
|
||||
self.failures.iter().filter(move |item| item.kind == kind)
|
||||
}
|
||||
|
||||
/// 记一条失败——**按 `requested_as` 去重**,父边合并。
|
||||
///
|
||||
/// 为什么要有这道去重:同一个项目可能被两条路径要求(A→GONE、B→C→GONE)。若不去重,
|
||||
/// 「可只重试这 N 个」会写出 `GONE、GONE`,用户按名单重试等于发两次一样的请求。
|
||||
/// 解析阶段与下载阶段的失败都走这里,于是名单里一个项目只出现一次。
|
||||
pub fn record_failure(&mut self, failure: DependencyFailure) {
|
||||
if let Some(existing) = self
|
||||
.failures
|
||||
.iter_mut()
|
||||
.find(|item| item.requested_as == failure.requested_as)
|
||||
{
|
||||
existing.required_by.extend(failure.required_by);
|
||||
existing.required_by.sort();
|
||||
existing.required_by.dedup();
|
||||
// 深度取更浅的那条:越靠近根越该先修
|
||||
existing.depth = existing.depth.min(failure.depth);
|
||||
return;
|
||||
}
|
||||
self.failures.push(failure);
|
||||
}
|
||||
|
||||
/// 「可重试名单」:失败项原样重启一次解析的请求。
|
||||
///
|
||||
/// 只含失败项 ⇒ 用户只重试这一部分,**不重下**已经装好的文件(`present` 沿用)。
|
||||
/// 两档都在名单里,但每条自带 [`FailureKind`]:不兼容那一档重试前得换版本/加载器,
|
||||
/// 换完再调它才有意义。**这不是「后台自动重试」**——本模块只给请求,什么时候发由上层决定。
|
||||
///
|
||||
/// 调用方如果刚跑完一轮下载,重试前应**重新扫一遍目录**再填 `present`:这一轮新落盘的
|
||||
/// 文件不在旧的 `present` 里,不重扫就会把它们当「还没有」再下一份。
|
||||
#[must_use]
|
||||
pub fn retry_request(&self) -> ResolveRequest {
|
||||
let mut roots: Vec<String> = Vec::new();
|
||||
for failure in &self.failures {
|
||||
if !roots.contains(&failure.requested_as) {
|
||||
roots.push(failure.requested_as.clone());
|
||||
}
|
||||
}
|
||||
ResolveRequest {
|
||||
roots,
|
||||
..self.request.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// 这个项目为什么在计划里(规范 id → 要求装它的项目)。`None` = 不在计划里。
|
||||
#[must_use]
|
||||
pub fn required_by(&self, project: &str) -> Option<&[String]> {
|
||||
self.nodes
|
||||
.iter()
|
||||
.find(|node| node.project == project)
|
||||
.map(|node| node.required_by.as_slice())
|
||||
}
|
||||
}
|
||||
@@ -46,23 +46,43 @@
|
||||
//! | [`modrinth`] | 端点拼装与响应解析(纯逻辑,测试直接喂录制 JSON) |
|
||||
//! | [`download`] | 取下载地址之后那一步:引擎下载 + **sha1/sha512 复核** + 重试 + 不留半截文件 |
|
||||
//! | [`complete`] | 按摘要认文件([`identity_of`]):站点返回的版本里哪条才是我们要的那个 |
|
||||
//! | [`dependency`] | 依赖图谱的形状(请求 / 计划 / **两档失败**) |
|
||||
//! | [`resolve`] | BFS 解析(只跟 `required`):去重、按项目缓存、深度上限、确定性顺序 |
|
||||
//!
|
||||
//! # 一次「装一个模组」到底发几个请求
|
||||
//!
|
||||
//! M186 之前的实测口径(也是现在仍然成立的基座):`GET /v2/project/{id}/version` 一次
|
||||
//! (带 `game_versions` / `loaders` 两个数组参数)+ 文件下载一次 = **2 个请求**,
|
||||
//! 其中**没有任何**依赖解析——`dependencies` 字段根本没进模型,所以「装了模组但进游戏崩」
|
||||
//! 这条路只能靠用户自己发现缺前置。
|
||||
//!
|
||||
//! M186 之后:根项目同样是 2 个请求(版本 + 下载),每个 `required` 前置再各加 1 次版本
|
||||
//! 请求 + 1 次下载。与参考实现相比省掉了「每个前置再查一次 `/v2/project/{id}` 换 slug」,
|
||||
//! 因为依赖里给的就是项目 id,而 `/project/{id}/version` 认 id。
|
||||
|
||||
pub mod complete;
|
||||
pub mod dependency;
|
||||
pub mod download;
|
||||
pub mod model;
|
||||
pub mod modrinth;
|
||||
pub mod resolve;
|
||||
pub mod transport;
|
||||
|
||||
pub use complete::identity_of;
|
||||
pub use dependency::{
|
||||
present_key, DependencyFailure, DependencyPlan, FailureKind, PlannedNode, PresentFile,
|
||||
ResolveRequest, SkipReason, SkippedNode, UnresolvedRequired, DEFAULT_MAX_DEPTH,
|
||||
};
|
||||
pub use download::{
|
||||
digest_file, download_verified, ArtifactFetcher, Digest, Downloaded, EngineArtifactFetcher,
|
||||
FetchArgs, RETRY_ATTEMPTS,
|
||||
};
|
||||
pub use model::{
|
||||
ContentType, FileHashes, ProjectDetail, ProjectHit, SearchPage, SearchQuery, SortIndex,
|
||||
Version, VersionFile, VersionQuery, VersionType,
|
||||
ContentType, Dependency, DependencyType, FileHashes, ProjectDetail, ProjectHit, SearchPage,
|
||||
SearchQuery, SortIndex, Version, VersionFile, VersionQuery, VersionType,
|
||||
};
|
||||
pub use modrinth::{HashAlgorithm, Modrinth, MODRINTH_API};
|
||||
pub use resolve::resolve;
|
||||
pub use transport::{EngineTransport, OnlineError, OnlineErrorKind, OnlineFuture, Transport};
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -32,6 +32,6 @@ mod types;
|
||||
pub use parse::{known_loaders, parse_project, parse_search, parse_version, parse_versions};
|
||||
pub use query::{ContentType, SearchQuery, SortIndex, KNOWN_LOADERS};
|
||||
pub use types::{
|
||||
FileHashes, ProjectDetail, ProjectHit, SearchPage, Version, VersionFile, VersionQuery,
|
||||
VersionType,
|
||||
Dependency, DependencyType, FileHashes, ProjectDetail, ProjectHit, SearchPage, Version,
|
||||
VersionFile, VersionQuery, VersionType,
|
||||
};
|
||||
@@ -8,7 +8,10 @@ use std::collections::BTreeMap;
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::query::KNOWN_LOADERS;
|
||||
use super::types::{ProjectDetail, ProjectHit, SearchPage, Version, VersionFile, VersionType};
|
||||
use super::types::{
|
||||
Dependency, DependencyType, ProjectDetail, ProjectHit, SearchPage, Version, VersionFile,
|
||||
VersionType,
|
||||
};
|
||||
use crate::addon::online::transport::{OnlineError, OnlineErrorKind};
|
||||
|
||||
// --------------------------------------------------------------- 解析
|
||||
@@ -112,6 +115,23 @@ struct RawVersion {
|
||||
downloads: u64,
|
||||
#[serde(default)]
|
||||
files: Vec<RawFile>,
|
||||
#[serde(default)]
|
||||
dependencies: Vec<RawDependency>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct RawDependency {
|
||||
#[serde(default)]
|
||||
project_id: Option<String>,
|
||||
#[serde(default)]
|
||||
version_id: Option<String>,
|
||||
#[serde(default)]
|
||||
file_name: Option<String>,
|
||||
// 站点给的是 `required` / `optional` / `incompatible` / `embedded`。
|
||||
// 必须是 `Option<String>`:`#[serde(default)]` 只兜「字段缺失」,兜不住 `null`
|
||||
// (`"dependency_type": null` 用 `String` 会整份响应报 Malformed,于是整个项目都取不到版本)。
|
||||
#[serde(default)]
|
||||
dependency_type: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
@@ -238,6 +258,18 @@ fn version_of(raw: RawVersion) -> Version {
|
||||
primary: file.primary,
|
||||
})
|
||||
.collect(),
|
||||
dependencies: raw
|
||||
.dependencies
|
||||
.into_iter()
|
||||
.map(|dependency| Dependency {
|
||||
project_id: non_empty(dependency.project_id),
|
||||
version_id: non_empty(dependency.version_id),
|
||||
file_name: non_empty(dependency.file_name),
|
||||
dependency_type: DependencyType::parse(
|
||||
dependency.dependency_type.as_deref().unwrap_or_default(),
|
||||
),
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -83,7 +83,20 @@ impl SortIndex {
|
||||
|
||||
/// 站点认的加载器 facet 值(`categories` 里混着 `optimization` 这种**分类标签**,
|
||||
/// 直接整份当加载器会画出假加载器)。
|
||||
pub const KNOWN_LOADERS: [&str; 6] = ["fabric", "forge", "neoforge", "quilt", "liteloader", "rift"];
|
||||
///
|
||||
/// 后两项是**光影**的加载器(Modrinth 把 shader 的 `iris` / `optifine` 也放在 `categories`
|
||||
/// 里):不登记它们,光影检索结果的 `loaders` 就会恒为空,页面既画不出「支持 Iris」,
|
||||
/// 也用不上「只看 Iris」这种判断。
|
||||
pub const KNOWN_LOADERS: [&str; 8] = [
|
||||
"fabric",
|
||||
"forge",
|
||||
"neoforge",
|
||||
"quilt",
|
||||
"liteloader",
|
||||
"rift",
|
||||
"iris",
|
||||
"optifine",
|
||||
];
|
||||
|
||||
/// 一次检索请求(分页游标也在里面:换页就是换一份 query)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
|
||||
@@ -140,6 +140,67 @@ impl VersionType {
|
||||
}
|
||||
}
|
||||
|
||||
/// 前置依赖的类型(`/version` 的 `dependencies[].dependency_type`)。
|
||||
///
|
||||
/// # 为什么要留 `Unknown` 而不是「认不出就当可选」
|
||||
///
|
||||
/// 站点以后加新取值时,**只有 `Required` 才该触发递归**。认不出的值落进 `Unknown`,
|
||||
/// 于是「站点改名」的下场是「少装一个前置」(可观察、可补),而不是「把所有建议项都当必备项
|
||||
/// 递归拉一遍」(一次装模组变成几十个请求,还装了一堆不兼容的东西)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
|
||||
pub enum DependencyType {
|
||||
/// 必须装(BFS **只跟这一档**)
|
||||
Required,
|
||||
/// 可选(装不装都能跑)
|
||||
#[default]
|
||||
Optional,
|
||||
/// 明确不兼容(**不要**和它一起装)
|
||||
Incompatible,
|
||||
/// 已内嵌在被依赖的文件里(不用另外装)
|
||||
Embedded,
|
||||
/// 站点给了个我们不认识的取值
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl DependencyType {
|
||||
/// 站点原文。
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Required => "required",
|
||||
Self::Optional => "optional",
|
||||
Self::Incompatible => "incompatible",
|
||||
Self::Embedded => "embedded",
|
||||
Self::Unknown => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
/// 从站点原文解析;认不出(含空串)返回 [`Self::Unknown`]。
|
||||
#[must_use]
|
||||
pub fn parse(raw: &str) -> Self {
|
||||
match raw.trim() {
|
||||
"required" => Self::Required,
|
||||
"optional" => Self::Optional,
|
||||
"incompatible" => Self::Incompatible,
|
||||
"embedded" => Self::Embedded,
|
||||
_ => Self::Unknown,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 版本声明的一条前置(`dependencies[]`)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct Dependency {
|
||||
/// 依赖的**项目** id(BFS 的地址;有了它就不用像参考实现那样再查一次 slug)
|
||||
pub project_id: Option<String>,
|
||||
/// 依赖的版本 id(站点基本只对 `embedded` 给)
|
||||
pub version_id: Option<String>,
|
||||
/// 站点给的文件名(只对 `embedded` / `incompatible` 有意义)
|
||||
pub file_name: Option<String>,
|
||||
/// 依赖类型
|
||||
pub dependency_type: DependencyType,
|
||||
}
|
||||
|
||||
/// 一个版本(`/v2/project/{id}/version[]` 与 `/v2/version_file/{hash}` 同一形状)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct Version {
|
||||
@@ -163,6 +224,8 @@ pub struct Version {
|
||||
pub downloads: u64,
|
||||
/// 文件列表
|
||||
pub files: Vec<VersionFile>,
|
||||
/// 前置声明(站点原文顺序;空 = 站点没说 / 没有)
|
||||
pub dependencies: Vec<Dependency>,
|
||||
}
|
||||
|
||||
impl Version {
|
||||
@@ -176,6 +239,45 @@ impl Version {
|
||||
.find(|file| file.primary)
|
||||
.or_else(|| self.files.first())
|
||||
}
|
||||
|
||||
/// 要跟下去的**前置项目 id**:只取 `required`,去掉空值,**排序去重**。
|
||||
///
|
||||
/// 排序在这里做(不是让调用方排):BFS 的展开顺序必须是数据本身决定的,
|
||||
/// 否则站点把 `dependencies` 换个顺序、或者用 `HashMap` 收一遍,测试就会随机红。
|
||||
#[must_use]
|
||||
pub fn required_project_ids(&self) -> Vec<&str> {
|
||||
let mut ids: Vec<&str> = self
|
||||
.dependencies
|
||||
.iter()
|
||||
.filter(|dependency| dependency.dependency_type == DependencyType::Required)
|
||||
.filter_map(|dependency| dependency.project_id.as_deref())
|
||||
.map(str::trim)
|
||||
.filter(|id| !id.is_empty())
|
||||
.collect();
|
||||
ids.sort_unstable();
|
||||
ids.dedup();
|
||||
ids
|
||||
}
|
||||
|
||||
/// 声明了 `required`、却**没有项目 id** 的前置(站点只给了版本 id / 文件名)。
|
||||
///
|
||||
/// 这些条目 BFS 跟不下去(取版本要项目 id),但也不能当成「没有这条依赖」——
|
||||
/// 上层据此说「有 N 条前置没法解析」,见 `DependencyPlan::unresolved_required`。
|
||||
#[must_use]
|
||||
pub fn unresolved_required(&self) -> Vec<&Dependency> {
|
||||
self.dependencies
|
||||
.iter()
|
||||
.filter(|dependency| dependency.dependency_type == DependencyType::Required)
|
||||
.filter(|dependency| {
|
||||
dependency
|
||||
.project_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.map(str::is_empty)
|
||||
.unwrap_or(true)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// 版本里的一个文件。
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
//! BFS 依赖解析(**只跟 `required`**)。形状在 [`super::dependency`],这里只有算法。
|
||||
//!
|
||||
//! # 与参考实现(Python 版 Modrinth 下载器)的四处差别
|
||||
//!
|
||||
//! | 参考实现 | 这里 | 为什么 |
|
||||
//! |---|---|---|
|
||||
//! | 每个前置再 `GET /v2/project/{id}` 换 slug | **不查**,直接拿 `dependencies[].project_id` 当地址 | 依赖里给的就是项目 id,`/project/{id}/version` 认它;一次安装省掉每个前置一个请求 |
|
||||
//! | 失败名单是字符串,靠括号里的中文区分「不兼容」与「网络异常」 | [`FailureKind`] 枚举 | 「哪一档」要能从类型上判,不能靠 `contains("网络")` |
|
||||
//! | 一个 dict 兼做缓存与去重 | 键 = **规范项目 id**,别名单设一张表 | 同一项目可能一次以 slug、一次以 id 出现;只按请求串去重会把它装两遍 |
|
||||
//! | 队列按 `dependencies` 原文顺序展开 | 每层按项目键**字典序**处理 | 顺序必须是数据决定的,否则测试靠 HashMap 运气 |
|
||||
//!
|
||||
//! # 失败是**数据**,不是 `Err`
|
||||
//!
|
||||
//! [`resolve`] 永远返回一个 [`DependencyPlan`]:某个前置取不到版本时,它进 `failures`,
|
||||
//! 解析继续做别的分支。若中途 `return Err`,用户看到的是「装这个模组失败」,而真正该看到的
|
||||
//! 是「主文件装上了,缺 fabric-api(不兼容 1.20.1/fabric)」+一份可单独重试的名单。
|
||||
//!
|
||||
//! # 两张去重网
|
||||
//!
|
||||
//! 1. **图内**:同一项目只解一次版本(`nodes` 里每个项目最多一条),多条依赖路径折进
|
||||
//! [`PlannedNode::required_by`];
|
||||
//! 2. **盘上**:目标目录里已经有同名文件(`.disabled` 也算)就进 `skipped`、**不再下载**。
|
||||
//! 这是「已装过的同一项目跳过并提示」里我们真能判的那一半;另一半(同项目**另一个版本**
|
||||
//! 已装)需要实例侧的已装清单,本模块判不出来(见报告)。
|
||||
//!
|
||||
//! 盘上判定只比文件名(`present` 由调用方扫目录得到,core 不读盘):内容级去重需要先算一份
|
||||
//! sha1,为了「跳过」把整个 `mods/` 哈希一遍不划算;同名文件就是同一份 jar 的最常见形态
|
||||
//! (我们落盘用站点给的文件名,不重命名)。
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use super::dependency::{
|
||||
present_key, DependencyFailure, DependencyPlan, FailureKind, PlannedNode, PresentFile,
|
||||
ResolveRequest, SkipReason, SkippedNode, UnresolvedRequired,
|
||||
};
|
||||
use super::model::Version;
|
||||
use super::modrinth::Modrinth;
|
||||
use super::transport::{OnlineErrorKind, Transport};
|
||||
|
||||
/// 一层里还没处理的一项。
|
||||
struct Wave {
|
||||
key: String,
|
||||
depth: u8,
|
||||
parents: Vec<String>,
|
||||
}
|
||||
|
||||
/// BFS 解析。**不返回 `Err`**:失败进 [`DependencyPlan::failures`](见模块文档)。
|
||||
pub async fn resolve<T: Transport + ?Sized>(
|
||||
client: &Modrinth<'_, T>,
|
||||
request: &ResolveRequest,
|
||||
) -> DependencyPlan {
|
||||
let mut plan = DependencyPlan::new(request);
|
||||
let present: BTreeSet<String> = request.present.iter().map(PresentFile::key).collect();
|
||||
// 请求键 → 规范 id(同一项目可能一次给 slug、一次给 id)
|
||||
let mut alias: BTreeMap<String, String> = BTreeMap::new();
|
||||
let mut resolved: BTreeSet<String> = BTreeSet::new();
|
||||
|
||||
let mut layer: Vec<Wave> = Vec::new();
|
||||
for root in &request.roots {
|
||||
let key = root.trim();
|
||||
if !key.is_empty() {
|
||||
wave_in(&mut layer, key, 0, None);
|
||||
}
|
||||
}
|
||||
|
||||
while !layer.is_empty() {
|
||||
// 同层按项目键字典序:顺序由数据决定,不靠插入顺序或 HashMap 的运气。
|
||||
layer.sort_by(|left, right| left.key.cmp(&right.key));
|
||||
let mut next: Vec<Wave> = Vec::new();
|
||||
for mut item in layer.drain(..) {
|
||||
normalize(&mut item.parents);
|
||||
// ① 已经解过(含「A 依赖 B、B 又依赖 A」的环)⇒ 只把父边折进已有节点
|
||||
if let Some(canonical) = alias.get(&item.key).cloned() {
|
||||
fold_parents(&mut plan, &canonical, &item.parents);
|
||||
continue;
|
||||
}
|
||||
let version = match client.versions(&request.version_query(&item.key)).await {
|
||||
Ok(versions) => match versions.into_iter().next() {
|
||||
Some(version) => version,
|
||||
None => {
|
||||
let message = filter_message(request);
|
||||
fail(
|
||||
&mut plan,
|
||||
&mut alias,
|
||||
item,
|
||||
FailureKind::Incompatible,
|
||||
None,
|
||||
message,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
},
|
||||
Err(error) => {
|
||||
let message = format!(
|
||||
"取版本失败({}):{}",
|
||||
error.kind().as_str(),
|
||||
error.message()
|
||||
);
|
||||
fail(
|
||||
&mut plan,
|
||||
&mut alias,
|
||||
item,
|
||||
FailureKind::Unreachable,
|
||||
Some(error.kind()),
|
||||
message,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let Some(file) = version
|
||||
.primary_file()
|
||||
.filter(|file| !file.url.trim().is_empty())
|
||||
.cloned()
|
||||
else {
|
||||
let message = format!(
|
||||
"{} 的版本 {} 里没有可下载的主文件",
|
||||
item.key,
|
||||
version_label(&version)
|
||||
);
|
||||
fail(
|
||||
&mut plan,
|
||||
&mut alias,
|
||||
item,
|
||||
FailureKind::Incompatible,
|
||||
None,
|
||||
message,
|
||||
);
|
||||
continue;
|
||||
};
|
||||
|
||||
let canonical = canonical_of(&item.key, &version);
|
||||
alias.insert(item.key.clone(), canonical.clone());
|
||||
alias
|
||||
.entry(canonical.clone())
|
||||
.or_insert_with(|| canonical.clone());
|
||||
// ② 同一项目走了两条路(slug 与 id 各一次)⇒ 折边,不重复装
|
||||
if !resolved.insert(canonical.clone()) {
|
||||
fold_parents(&mut plan, &canonical, &item.parents);
|
||||
continue;
|
||||
}
|
||||
|
||||
let file_name = file.filename.clone();
|
||||
if present.contains(&present_key(&file_name)) {
|
||||
plan.skipped.push(SkippedNode {
|
||||
project: canonical.clone(),
|
||||
requested_as: item.key.clone(),
|
||||
file_name,
|
||||
depth: item.depth,
|
||||
required_by: item.parents.clone(),
|
||||
reason: SkipReason::AlreadyPresent,
|
||||
});
|
||||
} else {
|
||||
plan.nodes.push(PlannedNode {
|
||||
project: canonical.clone(),
|
||||
requested_as: item.key.clone(),
|
||||
version_id: version.id.clone(),
|
||||
version_name: version.name.clone(),
|
||||
version_number: version.version_number.clone(),
|
||||
file,
|
||||
depth: item.depth,
|
||||
required_by: item.parents.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
// 子项:只跟 required(排序去重由 `required_project_ids` 保证)
|
||||
for dependency in version.required_project_ids() {
|
||||
if item.depth >= request.max_depth {
|
||||
plan.truncated.push(dependency.to_string());
|
||||
} else {
|
||||
wave_in(&mut next, dependency, item.depth + 1, Some(&canonical));
|
||||
}
|
||||
}
|
||||
// 只有版本 id、没有项目 id 的 required:没有地址可取,记下来不假装没看见
|
||||
for dependency in version.unresolved_required() {
|
||||
plan.unresolved_required.push(UnresolvedRequired {
|
||||
parent: canonical.clone(),
|
||||
version_id: dependency.version_id.clone(),
|
||||
file_name: dependency.file_name.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
plan.truncated.sort();
|
||||
plan.truncated.dedup();
|
||||
layer = next;
|
||||
}
|
||||
|
||||
plan.unresolved_required.sort_by(|left, right| {
|
||||
(&left.parent, &left.version_id).cmp(&(&right.parent, &right.version_id))
|
||||
});
|
||||
plan.unresolved_required.dedup();
|
||||
plan
|
||||
}
|
||||
|
||||
/// 记一条失败,并把这个请求键标成「已处理」。
|
||||
///
|
||||
/// 标键这一步是**失败侧的去重网**:成功路径靠 `alias` 短路,失败路径以前直接 `continue`,
|
||||
/// 于是同一个项目被两条路径要求时会各取一次版本、各记一条失败,重试名单里出现两次。
|
||||
/// 标记之后,后续同键请求在①处短路,走现成的 [`fold_parents`] 把父边折进那条失败。
|
||||
///
|
||||
/// # 这道去重按**请求键**,不是规范 id(已知边界,别当成全覆盖)
|
||||
///
|
||||
/// 规范 id(`project_id`)要拿到响应才知道;失败时没有响应,能用的只有请求键。所以同一个
|
||||
/// 失败项目如果**先以 slug、后以 project_id** 被两个不同的父项目要求,仍会被取两次、名单里
|
||||
/// 出现两个名字。真实载荷里 `dependencies[].project_id` 与用户点的 slug 混用的情况没有证据,
|
||||
/// 且这一档本来就是「取不到」,重复一条的代价是名单多一行、不是多装一个东西——故本轮不消掉,
|
||||
/// 只把边界写在这里(复审 r2 nit③)。
|
||||
fn fail(
|
||||
plan: &mut DependencyPlan,
|
||||
alias: &mut BTreeMap<String, String>,
|
||||
item: Wave,
|
||||
kind: FailureKind,
|
||||
cause: Option<OnlineErrorKind>,
|
||||
message: String,
|
||||
) {
|
||||
plan.record_failure(DependencyFailure {
|
||||
requested_as: item.key.clone(),
|
||||
required_by: item.parents,
|
||||
depth: item.depth,
|
||||
kind,
|
||||
cause,
|
||||
message,
|
||||
});
|
||||
alias.insert(item.key.clone(), item.key);
|
||||
}
|
||||
|
||||
/// 把一项放进这一层的待处理表(同一个键已经排队 ⇒ 只追加父项目)。
|
||||
fn wave_in(layer: &mut Vec<Wave>, key: &str, depth: u8, parent: Option<&str>) {
|
||||
if let Some(existing) = layer.iter_mut().find(|item| item.key == key) {
|
||||
if let Some(parent) = parent {
|
||||
existing.parents.push(parent.to_string());
|
||||
}
|
||||
return;
|
||||
}
|
||||
layer.push(Wave {
|
||||
key: key.to_string(),
|
||||
depth,
|
||||
parents: parent
|
||||
.map(|parent| vec![parent.to_string()])
|
||||
.unwrap_or_default(),
|
||||
});
|
||||
}
|
||||
|
||||
/// 往已经建好的节点上折一条父边(节点可能在被跳过的那一档,也可能只出现在失败名单里)。
|
||||
fn fold_parents(plan: &mut DependencyPlan, project: &str, parents: &[String]) {
|
||||
if parents.is_empty() {
|
||||
return;
|
||||
}
|
||||
let node = plan
|
||||
.nodes
|
||||
.iter_mut()
|
||||
.find(|node| node.project == project)
|
||||
.map(|node| &mut node.required_by);
|
||||
let node = node.or_else(|| {
|
||||
plan.skipped
|
||||
.iter_mut()
|
||||
.find(|node| node.project == project)
|
||||
.map(|node| &mut node.required_by)
|
||||
});
|
||||
if let Some(required_by) = node {
|
||||
required_by.extend(parents.iter().cloned());
|
||||
normalize(required_by);
|
||||
return;
|
||||
}
|
||||
if let Some(failure) = plan
|
||||
.failures
|
||||
.iter_mut()
|
||||
.find(|failure| failure.requested_as == project)
|
||||
{
|
||||
failure.required_by.extend(parents.iter().cloned());
|
||||
normalize(&mut failure.required_by);
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize(list: &mut Vec<String>) {
|
||||
list.sort();
|
||||
list.dedup();
|
||||
}
|
||||
|
||||
/// 规范身份:站点回的 `project_id` 才是身份;没给就退回请求键。
|
||||
fn canonical_of(key: &str, version: &Version) -> String {
|
||||
let project_id = version.project_id.trim();
|
||||
if project_id.is_empty() {
|
||||
key.to_string()
|
||||
} else {
|
||||
project_id.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// 「哪里没有匹配项」这句话(第一档的固定说法,带出用的是什么过滤条件)。
|
||||
fn filter_message(request: &ResolveRequest) -> String {
|
||||
let game_version = request.game_version.as_deref().unwrap_or("不限");
|
||||
let loader = request.loader.as_deref().unwrap_or("不限");
|
||||
format!("没有匹配所选游戏版本/加载器({game_version} / {loader})的版本")
|
||||
}
|
||||
|
||||
fn version_label(version: &Version) -> String {
|
||||
let number = version.version_number.trim();
|
||||
if number.is_empty() {
|
||||
version.id.clone()
|
||||
} else {
|
||||
number.to_string()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
//! 依赖解析的离线用例(录制 JSON 夹具,**不打真实网络**)。
|
||||
//!
|
||||
//! 三条守门对应任务书的三条:
|
||||
//! 1. [`two_nested_levels_are_pulled_in_a_predictable_order`] —— 嵌套两层能拉全,顺序确定;
|
||||
//! 2. [`a_dependency_cycle_terminates_and_each_project_appears_once`] —— 环不无限递归;
|
||||
//! 3. [`an_incompatible_dependency_lands_in_the_retry_list`] —— 不兼容进重试名单,不静默丢。
|
||||
//!
|
||||
//! 每条都配过一次「把实现改坏就该红」的变异(过程写在 M186 报告里),所以断言不是装饰。
|
||||
|
||||
use crate::addon::online::dependency::{
|
||||
present_key, DependencyPlan, FailureKind, PresentFile, ResolveRequest, SkipReason,
|
||||
};
|
||||
use crate::addon::online::model::parse_versions;
|
||||
use crate::addon::online::{resolve, Modrinth, OnlineErrorKind, MODRINTH_API};
|
||||
use crate::download::blocking::block_on;
|
||||
|
||||
use super::fixtures_deps as deps;
|
||||
use super::support::FixtureTransport;
|
||||
|
||||
const SODIUM_VERSIONS: &str = "https://api.modrinth.com/v2/project/sodium/version";
|
||||
const FABRIC_API_VERSIONS: &str = "https://api.modrinth.com/v2/project/P7dR8mSH/version";
|
||||
const YACL_VERSIONS: &str = "https://api.modrinth.com/v2/project/1eAoo2KR/version";
|
||||
|
||||
fn request(root: &str) -> ResolveRequest {
|
||||
ResolveRequest {
|
||||
roots: vec![root.to_string()],
|
||||
game_version: Some("1.20.1".to_string()),
|
||||
loader: Some("fabric".to_string()),
|
||||
..ResolveRequest::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// 三层链的传输层(sodium → fabric-api → yacl)。
|
||||
fn chain_transport() -> FixtureTransport {
|
||||
FixtureTransport::new()
|
||||
.with(SODIUM_VERSIONS, deps::SODIUM_WITH_DEPENDENCIES)
|
||||
.with(FABRIC_API_VERSIONS, deps::FABRIC_API_WITH_DEPENDENCIES)
|
||||
.with(YACL_VERSIONS, deps::YACL)
|
||||
}
|
||||
|
||||
fn resolve_with(transport: &FixtureTransport, request: &ResolveRequest) -> DependencyPlan {
|
||||
let client = Modrinth::new(transport);
|
||||
block_on(resolve(&client, request))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- ① 嵌套两层
|
||||
|
||||
#[test]
|
||||
fn two_nested_levels_are_pulled_in_a_predictable_order() {
|
||||
let transport = chain_transport();
|
||||
let plan = resolve_with(&transport, &request("sodium"));
|
||||
|
||||
assert!(plan.is_clean(), "不该有失败:{:?}", plan.failures);
|
||||
// 顺序 = BFS 层序,同层按项目键字典序:根 → fabric-api → yacl
|
||||
let order: Vec<(&str, u8)> = plan
|
||||
.nodes
|
||||
.iter()
|
||||
.map(|node| (node.project.as_str(), node.depth))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
order,
|
||||
vec![("AANobbMI", 0), ("P7dR8mSH", 1), ("1eAoo2KR", 2)]
|
||||
);
|
||||
|
||||
// 「为什么装」:父子关系逐条对得上
|
||||
assert!(plan.required_by("AANobbMI").expect("根在计划里").is_empty());
|
||||
assert_eq!(
|
||||
plan.required_by("P7dR8mSH").expect("fabric-api 在计划里"),
|
||||
["AANobbMI".to_string()].as_slice()
|
||||
);
|
||||
assert_eq!(
|
||||
plan.required_by("1eAoo2KR").expect("yacl 在计划里"),
|
||||
["P7dR8mSH".to_string()].as_slice()
|
||||
);
|
||||
|
||||
// 只跟 required:optional / incompatible / embedded 三个 id 一次请求都没发
|
||||
let seen = transport.seen();
|
||||
assert_eq!(seen.len(), 3, "只该有 3 次版本请求:{seen:?}");
|
||||
for quiet in ["OCS3z7f6", "AAAABBBB", "embedded-lib"] {
|
||||
assert!(
|
||||
!seen.iter().any(|url| url.contains(quiet)),
|
||||
"{quiet} 不该被跟:{seen:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// 主文件取 primary(第一个是 sources.jar)
|
||||
assert_eq!(plan.nodes[0].file.filename, "sodium-fabric-0.6.0.jar");
|
||||
assert_eq!(plan.nodes[1].file.filename, "fabric-api-0.92.2+1.20.1.jar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_required_dependencies_are_followed() {
|
||||
// 模型层:四值都解析得出来,`required_project_ids` 只吐 required 那一条。
|
||||
let versions = parse_versions(deps::SODIUM_WITH_DEPENDENCIES).expect("夹具该解析成功");
|
||||
let version = &versions[0];
|
||||
assert_eq!(version.dependencies.len(), 4);
|
||||
let kinds: Vec<&str> = version
|
||||
.dependencies
|
||||
.iter()
|
||||
.map(|dependency| dependency.dependency_type.as_str())
|
||||
.collect();
|
||||
assert_eq!(kinds, ["required", "optional", "incompatible", "embedded"]);
|
||||
assert_eq!(version.required_project_ids(), ["P7dR8mSH"]);
|
||||
// embedded 那条只有 file_name、没有 project_id,不该冒出一个空 id
|
||||
assert!(version.dependencies[3].project_id.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_depth_limit_is_reported_instead_of_silently_cutting_the_chain() {
|
||||
let transport = chain_transport();
|
||||
let mut request = request("sodium");
|
||||
request.max_depth = 1;
|
||||
let plan = resolve_with(&transport, &request);
|
||||
|
||||
assert_eq!(plan.nodes.len(), 2, "根 + 第一层");
|
||||
assert_eq!(plan.truncated, ["1eAoo2KR"], "截断必须写在计划里");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- ② 环
|
||||
|
||||
#[test]
|
||||
fn a_dependency_cycle_terminates_and_each_project_appears_once() {
|
||||
let transport = FixtureTransport::new()
|
||||
.with(
|
||||
"https://api.modrinth.com/v2/project/cycle-a/version",
|
||||
deps::CYCLE_A,
|
||||
)
|
||||
.with(
|
||||
"https://api.modrinth.com/v2/project/cycBid01/version",
|
||||
deps::CYCLE_B,
|
||||
);
|
||||
let plan = resolve_with(&transport, &request("cycle-a"));
|
||||
|
||||
let projects: Vec<&str> = plan
|
||||
.nodes
|
||||
.iter()
|
||||
.map(|node| node.project.as_str())
|
||||
.collect();
|
||||
assert_eq!(projects, ["cycAid01", "cycBid01"], "每个项目只出现一次");
|
||||
assert_eq!(transport.seen().len(), 2, "环不许把请求数放大");
|
||||
// B 是被 A 要来的;A 又被 B 要(环)——这条边折回已有节点,不新建节点
|
||||
assert_eq!(
|
||||
plan.required_by("cycBid01").expect("B 在计划里"),
|
||||
["cycAid01".to_string()].as_slice()
|
||||
);
|
||||
assert_eq!(
|
||||
plan.required_by("cycAid01").expect("A 在计划里"),
|
||||
["cycBid01".to_string()].as_slice()
|
||||
);
|
||||
assert!(plan.nodes[0].is_root(), "根判据是 depth,不是 required_by");
|
||||
assert!(!plan.nodes[1].is_root());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- ③ 两档失败
|
||||
|
||||
#[test]
|
||||
fn an_incompatible_dependency_lands_in_the_retry_list() {
|
||||
// fabric-api 在 1.20.1/fabric 下没有条目(站点回空数组)
|
||||
let transport = FixtureTransport::new()
|
||||
.with(SODIUM_VERSIONS, deps::SODIUM_WITH_DEPENDENCIES)
|
||||
.with(FABRIC_API_VERSIONS, "[]");
|
||||
let plan = resolve_with(&transport, &request("sodium"));
|
||||
|
||||
// 根还在计划里(失败不连坐),缺的那条单独立案
|
||||
assert_eq!(plan.nodes.len(), 1);
|
||||
assert_eq!(plan.failures.len(), 1, "不许静默丢:{:?}", plan.failures);
|
||||
let failure = &plan.failures[0];
|
||||
assert_eq!(failure.kind, FailureKind::Incompatible);
|
||||
assert_eq!(failure.requested_as, "P7dR8mSH");
|
||||
assert_eq!(failure.depth, 1);
|
||||
assert_eq!(failure.required_by, ["AANobbMI".to_string()]);
|
||||
assert!(failure.cause.is_none(), "这一档不是网络成因");
|
||||
assert!(failure.message.contains("1.20.1"), "文案要带过滤条件");
|
||||
assert!(plan.failures_of(FailureKind::Unreachable).next().is_none());
|
||||
|
||||
// 可重试名单只含失败项,过滤条件沿用
|
||||
let retry = plan.retry_request();
|
||||
assert_eq!(retry.roots, ["P7dR8mSH"]);
|
||||
assert_eq!(retry.game_version.as_deref(), Some("1.20.1"));
|
||||
assert_eq!(retry.loader.as_deref(), Some("fabric"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_project_is_unreachable_and_keeps_its_leaf_cause() {
|
||||
let transport = FixtureTransport::new()
|
||||
.with(SODIUM_VERSIONS, deps::SODIUM_WITH_DEPENDENCIES)
|
||||
.missing(FABRIC_API_VERSIONS);
|
||||
let plan = resolve_with(&transport, &request("sodium"));
|
||||
|
||||
let failure = &plan.failures[0];
|
||||
assert_eq!(failure.kind, FailureKind::Unreachable);
|
||||
assert_eq!(failure.cause, Some(OnlineErrorKind::NotFound));
|
||||
// 两档从类型上就分得开:同一个计划里两种成因不会挤进一条
|
||||
assert_eq!(plan.failures_of(FailureKind::Incompatible).count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_version_without_a_usable_file_is_incompatible_not_a_network_error() {
|
||||
let transport = FixtureTransport::new()
|
||||
.with(SODIUM_VERSIONS, deps::SODIUM_WITH_DEPENDENCIES)
|
||||
.with(
|
||||
FABRIC_API_VERSIONS,
|
||||
r#"[{"id": "ver-empty", "project_id": "P7dR8mSH",
|
||||
"version_number": "0.0", "files": []}]"#,
|
||||
);
|
||||
let plan = resolve_with(&transport, &request("sodium"));
|
||||
assert_eq!(plan.failures[0].kind, FailureKind::Incompatible);
|
||||
assert!(plan.failures[0].message.contains("没有可下载的主文件"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 两道去重
|
||||
|
||||
#[test]
|
||||
fn one_project_required_by_two_parents_is_planned_once_with_both_parents() {
|
||||
let transport = FixtureTransport::new()
|
||||
.with(
|
||||
"https://api.modrinth.com/v2/project/fanin-root/version",
|
||||
deps::FANIN_ROOT,
|
||||
)
|
||||
.with(
|
||||
"https://api.modrinth.com/v2/project/BRANCHAA/version",
|
||||
deps::FANIN_A,
|
||||
)
|
||||
.with(
|
||||
"https://api.modrinth.com/v2/project/BRANCHBB/version",
|
||||
deps::FANIN_B,
|
||||
)
|
||||
.with(
|
||||
"https://api.modrinth.com/v2/project/SHARED01/version",
|
||||
deps::SHARED_LIB,
|
||||
);
|
||||
let plan = resolve_with(&transport, &request("fanin-root"));
|
||||
|
||||
let projects: Vec<&str> = plan
|
||||
.nodes
|
||||
.iter()
|
||||
.map(|node| node.project.as_str())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
projects,
|
||||
["ROOT0001", "BRANCHAA", "BRANCHBB", "SHARED01"],
|
||||
"同层按项目键字典序,公共前置只出现一次"
|
||||
);
|
||||
assert_eq!(
|
||||
plan.required_by("SHARED01").expect("公共前置在计划里"),
|
||||
["BRANCHAA".to_string(), "BRANCHBB".to_string()].as_slice(),
|
||||
"两条依赖路径都要留下"
|
||||
);
|
||||
let shared_requests = transport
|
||||
.seen()
|
||||
.iter()
|
||||
.filter(|url| url.contains("SHARED01"))
|
||||
.count();
|
||||
assert_eq!(shared_requests, 1, "一个项目只解一次版本");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_that_already_exists_is_skipped_but_its_dependencies_are_still_walked() {
|
||||
let mut request = request("sodium");
|
||||
// 禁用中的 jar 也算「装过」(本仓用 `.disabled` 后缀表示禁用)
|
||||
request.present = vec![PresentFile::new("fabric-api-0.92.2+1.20.1.jar.disabled")];
|
||||
let transport = chain_transport();
|
||||
let plan = resolve_with(&transport, &request);
|
||||
|
||||
assert_eq!(plan.skipped.len(), 1);
|
||||
let skipped = &plan.skipped[0];
|
||||
assert_eq!(skipped.project, "P7dR8mSH");
|
||||
assert_eq!(skipped.reason, SkipReason::AlreadyPresent);
|
||||
assert_eq!(skipped.file_name, "fabric-api-0.92.2+1.20.1.jar");
|
||||
// 跳过的是**下载**,不是图:fabric-api 的前置照样解析出来
|
||||
let projects: Vec<&str> = plan
|
||||
.nodes
|
||||
.iter()
|
||||
.map(|node| node.project.as_str())
|
||||
.collect();
|
||||
assert_eq!(projects, ["AANobbMI", "1eAoo2KR"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn present_keys_normalize_paths_case_and_the_disabled_suffix() {
|
||||
assert_eq!(present_key("mods/Sodium-0.6.0.jar"), "sodium-0.6.0.jar");
|
||||
assert_eq!(present_key("a\\b\\x.jar.disabled"), "x.jar");
|
||||
assert_eq!(present_key(" y.jar "), "y.jar");
|
||||
assert_eq!(present_key(""), "");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 光影过滤器
|
||||
|
||||
#[test]
|
||||
fn shader_loaders_are_recognised_in_search_hits() {
|
||||
let transport =
|
||||
FixtureTransport::new().with(&format!("{MODRINTH_API}/search"), deps::SEARCH_SHADER);
|
||||
let client = Modrinth::new(&transport);
|
||||
let page = block_on(client.search(&crate::addon::online::SearchQuery {
|
||||
text: String::new(),
|
||||
project_type: Some(crate::addon::online::ContentType::Shader),
|
||||
limit: 1,
|
||||
..crate::addon::online::SearchQuery::default()
|
||||
}))
|
||||
.expect("解析应当成功");
|
||||
assert_eq!(
|
||||
page.hits[0].loaders,
|
||||
vec!["iris".to_string(), "optifine".to_string()],
|
||||
"光影的加载器不该被当成分类标签丢掉"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
//! 依赖解析用的录制响应(形状按站点原文;数值是示意,不是某天的真值)。
|
||||
//!
|
||||
//! 从 `fixtures.rs` 分出来的原因很实在:那里已经在钉「字段名与 `primary`」这类解析细节,
|
||||
//! 再往里塞四五个带 `dependencies` 的版本对象就会贴到 400 行硬线。
|
||||
//!
|
||||
//! 三条刻意的布置:
|
||||
//! - 根项目(`sodium`)的版本里**同时有四类依赖**:只有 `required` 该被跟;
|
||||
//! - 每个版本都有 `primary: false` 的 `sources.jar`,取第一个就会装错;
|
||||
//! - 环(`cycle-a` ⇄ `cycle-b`)与扇入(两个分支都要 `shared-lib`)各有一组独立夹具。
|
||||
|
||||
/// 根项目 `sodium` 的版本:一条 required + 一条 optional + 一条 incompatible + 一条 embedded。
|
||||
pub(super) const SODIUM_WITH_DEPENDENCIES: &str = r#"[
|
||||
{
|
||||
"id": "ver-sodium",
|
||||
"project_id": "AANobbMI",
|
||||
"name": "Sodium 0.6.0 for 1.20.1",
|
||||
"version_number": "mc1.20.1-0.6.0",
|
||||
"version_type": "release",
|
||||
"loaders": ["fabric"],
|
||||
"game_versions": ["1.20.1"],
|
||||
"date_published": "2026-02-01T12:00:00Z",
|
||||
"downloads": 4321,
|
||||
"files": [
|
||||
{
|
||||
"hashes": {"sha1": "1111111111111111111111111111111111111111", "sha512": "aa"},
|
||||
"url": "https://cdn.modrinth.com/data/AANobbMI/versions/ver-sodium/sodium-sources.jar",
|
||||
"filename": "sodium-sources.jar",
|
||||
"size": 1000,
|
||||
"primary": false
|
||||
},
|
||||
{
|
||||
"hashes": {"sha1": "2222222222222222222222222222222222222222", "sha512": "bb"},
|
||||
"url": "https://cdn.modrinth.com/data/AANobbMI/versions/ver-sodium/sodium-fabric-0.6.0.jar",
|
||||
"filename": "sodium-fabric-0.6.0.jar",
|
||||
"size": 2048,
|
||||
"primary": true
|
||||
}
|
||||
],
|
||||
"dependencies": [
|
||||
{"version_id": null, "project_id": "P7dR8mSH", "file_name": null, "dependency_type": "required"},
|
||||
{"version_id": null, "project_id": "OCS3z7f6", "file_name": null, "dependency_type": "optional"},
|
||||
{"version_id": null, "project_id": "AAAABBBB", "file_name": null, "dependency_type": "incompatible"},
|
||||
{"version_id": "ver-embedded", "project_id": null, "file_name": "embedded-lib.jar", "dependency_type": "embedded"}
|
||||
]
|
||||
}
|
||||
]"#;
|
||||
|
||||
/// `fabric-api`(`P7dR8mSH`):它自己又要求 `yacl`(`1eAoo2KR`)⇒ 第二层嵌套。
|
||||
pub(super) const FABRIC_API_WITH_DEPENDENCIES: &str = r#"[
|
||||
{
|
||||
"id": "ver-fabric-api",
|
||||
"project_id": "P7dR8mSH",
|
||||
"name": "Fabric API 0.92.2 for 1.20.1",
|
||||
"version_number": "0.92.2+1.20.1",
|
||||
"version_type": "release",
|
||||
"loaders": ["fabric"],
|
||||
"game_versions": ["1.20.1"],
|
||||
"date_published": "2025-12-01T00:00:00Z",
|
||||
"downloads": 9999,
|
||||
"files": [
|
||||
{
|
||||
"hashes": {"sha1": "3333333333333333333333333333333333333333", "sha512": "cc"},
|
||||
"url": "https://cdn.modrinth.com/data/P7dR8mSH/versions/ver-fabric-api/fabric-api-0.92.2-sources.jar",
|
||||
"filename": "fabric-api-0.92.2-sources.jar",
|
||||
"size": 512,
|
||||
"primary": false
|
||||
},
|
||||
{
|
||||
"hashes": {"sha1": "4444444444444444444444444444444444444444", "sha512": "dd"},
|
||||
"url": "https://cdn.modrinth.com/data/P7dR8mSH/versions/ver-fabric-api/fabric-api-0.92.2+1.20.1.jar",
|
||||
"filename": "fabric-api-0.92.2+1.20.1.jar",
|
||||
"size": 4096,
|
||||
"primary": true
|
||||
}
|
||||
],
|
||||
"dependencies": [
|
||||
{"version_id": null, "project_id": "1eAoo2KR", "file_name": null, "dependency_type": "required"}
|
||||
]
|
||||
}
|
||||
]"#;
|
||||
|
||||
/// `yacl`(`1eAoo2KR`):链的末端,没有依赖。
|
||||
pub(super) const YACL: &str = r#"[
|
||||
{
|
||||
"id": "ver-yacl",
|
||||
"project_id": "1eAoo2KR",
|
||||
"name": "YetAnotherConfigLib 3.3.2",
|
||||
"version_number": "3.3.2+1.20.1",
|
||||
"version_type": "release",
|
||||
"loaders": ["fabric"],
|
||||
"game_versions": ["1.20.1"],
|
||||
"date_published": "2025-11-01T00:00:00Z",
|
||||
"downloads": 1234,
|
||||
"files": [
|
||||
{
|
||||
"hashes": {"sha1": "5555555555555555555555555555555555555555", "sha512": "ee"},
|
||||
"url": "https://cdn.modrinth.com/data/1eAoo2KR/versions/ver-yacl/yacl-3.3.2.jar",
|
||||
"filename": "yacl-3.3.2+1.20.1.jar",
|
||||
"size": 8192,
|
||||
"primary": true
|
||||
}
|
||||
],
|
||||
"dependencies": []
|
||||
}
|
||||
]"#;
|
||||
|
||||
/// 扇入的根:两个 required 分支。
|
||||
pub(super) const FANIN_ROOT: &str = r#"[
|
||||
{
|
||||
"id": "ver-root",
|
||||
"project_id": "ROOT0001",
|
||||
"name": "Fan-in Root 1.0",
|
||||
"version_number": "1.0",
|
||||
"version_type": "release",
|
||||
"loaders": ["fabric"],
|
||||
"game_versions": ["1.20.1"],
|
||||
"files": [
|
||||
{
|
||||
"hashes": {"sha1": "6666666666666666666666666666666666666666", "sha512": "ff"},
|
||||
"url": "https://cdn.modrinth.com/data/ROOT0001/versions/ver-root/fanin-root-1.0.jar",
|
||||
"filename": "fanin-root-1.0.jar",
|
||||
"size": 1024,
|
||||
"primary": true
|
||||
}
|
||||
],
|
||||
"dependencies": [
|
||||
{"project_id": "BRANCHAA", "dependency_type": "required"},
|
||||
{"project_id": "BRANCHBB", "dependency_type": "required"}
|
||||
]
|
||||
}
|
||||
]"#;
|
||||
|
||||
/// 分支 A:也要 `shared-lib`。
|
||||
pub(super) const FANIN_A: &str = r#"[
|
||||
{
|
||||
"id": "ver-branch-a",
|
||||
"project_id": "BRANCHAA",
|
||||
"name": "Branch A 2.0",
|
||||
"version_number": "2.0",
|
||||
"version_type": "release",
|
||||
"loaders": ["fabric"],
|
||||
"game_versions": ["1.20.1"],
|
||||
"files": [
|
||||
{
|
||||
"hashes": {"sha1": "7777777777777777777777777777777777777777", "sha512": "ab"},
|
||||
"url": "https://cdn.modrinth.com/data/BRANCHAA/versions/ver-branch-a/branch-a-2.0.jar",
|
||||
"filename": "branch-a-2.0.jar",
|
||||
"size": 1024,
|
||||
"primary": true
|
||||
}
|
||||
],
|
||||
"dependencies": [
|
||||
{"project_id": "SHARED01", "dependency_type": "required"}
|
||||
]
|
||||
}
|
||||
]"#;
|
||||
|
||||
/// 分支 B:也要 `shared-lib`(同一项目两条路径 ⇒ 只装一次)。
|
||||
pub(super) const FANIN_B: &str = r#"[
|
||||
{
|
||||
"id": "ver-branch-b",
|
||||
"project_id": "BRANCHBB",
|
||||
"name": "Branch B 3.0",
|
||||
"version_number": "3.0",
|
||||
"version_type": "release",
|
||||
"loaders": ["fabric"],
|
||||
"game_versions": ["1.20.1"],
|
||||
"files": [
|
||||
{
|
||||
"hashes": {"sha1": "8888888888888888888888888888888888888888", "sha512": "ac"},
|
||||
"url": "https://cdn.modrinth.com/data/BRANCHBB/versions/ver-branch-b/branch-b-3.0.jar",
|
||||
"filename": "branch-b-3.0.jar",
|
||||
"size": 1024,
|
||||
"primary": true
|
||||
}
|
||||
],
|
||||
"dependencies": [
|
||||
{"project_id": "SHARED01", "dependency_type": "required"}
|
||||
]
|
||||
}
|
||||
]"#;
|
||||
|
||||
/// 扇入的公共前置。
|
||||
pub(super) const SHARED_LIB: &str = r#"[
|
||||
{
|
||||
"id": "ver-shared",
|
||||
"project_id": "SHARED01",
|
||||
"name": "Shared Lib 1.5",
|
||||
"version_number": "1.5",
|
||||
"version_type": "release",
|
||||
"loaders": ["fabric"],
|
||||
"game_versions": ["1.20.1"],
|
||||
"files": [
|
||||
{
|
||||
"hashes": {"sha1": "9999999999999999999999999999999999999999", "sha512": "ad"},
|
||||
"url": "https://cdn.modrinth.com/data/SHARED01/versions/ver-shared/shared-lib-1.5.jar",
|
||||
"filename": "shared-lib-1.5.jar",
|
||||
"size": 2048,
|
||||
"primary": true
|
||||
}
|
||||
],
|
||||
"dependencies": []
|
||||
}
|
||||
]"#;
|
||||
|
||||
/// 环的一端:`cycle-a` 要求 `cycle-b`。
|
||||
pub(super) const CYCLE_A: &str = r#"[
|
||||
{
|
||||
"id": "ver-cycle-a",
|
||||
"project_id": "cycAid01",
|
||||
"name": "Cycle A 1.0",
|
||||
"version_number": "1.0",
|
||||
"version_type": "release",
|
||||
"loaders": ["fabric"],
|
||||
"game_versions": ["1.20.1"],
|
||||
"files": [
|
||||
{
|
||||
"hashes": {"sha1": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "sha512": "a1"},
|
||||
"url": "https://cdn.modrinth.com/data/cycAid01/versions/ver-cycle-a/cycle-a-1.0.jar",
|
||||
"filename": "cycle-a-1.0.jar",
|
||||
"size": 1024,
|
||||
"primary": true
|
||||
}
|
||||
],
|
||||
"dependencies": [
|
||||
{"project_id": "cycBid01", "dependency_type": "required"}
|
||||
]
|
||||
}
|
||||
]"#;
|
||||
|
||||
/// 环的另一端:`cycle-b` 又要求 `cycle-a`(不设防就是无限递归)。
|
||||
pub(super) const CYCLE_B: &str = r#"[
|
||||
{
|
||||
"id": "ver-cycle-b",
|
||||
"project_id": "cycBid01",
|
||||
"name": "Cycle B 1.0",
|
||||
"version_number": "1.0",
|
||||
"version_type": "release",
|
||||
"loaders": ["fabric"],
|
||||
"game_versions": ["1.20.1"],
|
||||
"files": [
|
||||
{
|
||||
"hashes": {"sha1": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "sha512": "b1"},
|
||||
"url": "https://cdn.modrinth.com/data/cycBid01/versions/ver-cycle-b/cycle-b-1.0.jar",
|
||||
"filename": "cycle-b-1.0.jar",
|
||||
"size": 1024,
|
||||
"primary": true
|
||||
}
|
||||
],
|
||||
"dependencies": [
|
||||
{"project_id": "cycAid01", "dependency_type": "required"}
|
||||
]
|
||||
}
|
||||
]"#;
|
||||
|
||||
/// 光影检索的一页:`categories` 里是 `iris` / `optifine`(光影的加载器)。
|
||||
pub(super) const SEARCH_SHADER: &str = r#"{
|
||||
"hits": [
|
||||
{
|
||||
"project_id": "shader01",
|
||||
"project_type": "shader",
|
||||
"slug": "complementary-reimagined",
|
||||
"author": "EminGT",
|
||||
"title": "Complementary Reimagined",
|
||||
"description": "A shader pack",
|
||||
"categories": ["iris", "optifine", "shaders"],
|
||||
"versions": ["1.20.1"],
|
||||
"downloads": 1234567,
|
||||
"follows": 4242,
|
||||
"icon_url": "",
|
||||
"date_modified": "2026-01-02T03:04:05Z"
|
||||
}
|
||||
],
|
||||
"offset": 0,
|
||||
"limit": 1,
|
||||
"total_hits": 1
|
||||
}"#;
|
||||
@@ -8,6 +8,10 @@
|
||||
|
||||
mod api;
|
||||
mod classify;
|
||||
mod dependency;
|
||||
mod download;
|
||||
mod fixtures;
|
||||
mod fixtures_deps;
|
||||
mod order;
|
||||
mod regressions;
|
||||
mod support;
|
||||
@@ -0,0 +1,137 @@
|
||||
//! 顺序确定性(r1 P2-4):同层 ≥2 个前置、夹具数组故意逆序、多根乱序,结果必须一样。
|
||||
//!
|
||||
//! # 这条用例守的是什么
|
||||
//!
|
||||
//! `resolve` 在每层处理前把队列按**项目键字典序**排一遍。把那一行删掉,原来那 11 条用例
|
||||
//! (以及 core 的全部用例)**仍然全绿**——因为三跳链每层只有一个前置、扇入用例里两个同层
|
||||
//! 节点的先后恰好等于夹具数组顺序。也就是说「顺序由数据决定」这条性质当时没有守门。
|
||||
//!
|
||||
//! # 归因(实测过,别写成「都靠这两条守门」)
|
||||
//!
|
||||
//! 删掉 `resolve` 里那行 `layer.sort_by` ⇒
|
||||
//! [`prerequisites_from_two_roots_are_ordered_by_project_key`] 与
|
||||
//! [`multiple_roots_are_processed_in_key_order_whatever_the_argument_order`] 红,
|
||||
//! [`same_root_prerequisites_follow_key_order_not_payload_order`] 仍绿——因为那两个前置
|
||||
//! 是**同一个根**入队的,顺序已经被 `Version::required_project_ids()` 内部的排序排过了。
|
||||
//! 反过来,只删 `required_project_ids()` 的排序时三条都绿:`layer.sort_by` 会把结果重排一遍。
|
||||
//!
|
||||
//! 也就是说:跨层顺序由 `layer.sort_by` 兜底(这两条用例钉它),同一个根内的顺序**两层保护**
|
||||
//! 冗余存在;`required_project_ids()` 自己的「排序去重」契约由 `tests/regressions.rs` 的
|
||||
//! `required_project_ids_are_sorted_and_deduplicated_on_their_own` 单独钉(那条只删排序就会红)。
|
||||
|
||||
use crate::addon::online::dependency::{DependencyPlan, ResolveRequest};
|
||||
use crate::addon::online::{resolve, Modrinth};
|
||||
use crate::download::blocking::block_on;
|
||||
|
||||
use super::support::{version_json, FixtureTransport};
|
||||
|
||||
const API: &str = "https://api.modrinth.com/v2/project";
|
||||
|
||||
fn request(roots: &[&str]) -> ResolveRequest {
|
||||
ResolveRequest {
|
||||
roots: roots.iter().map(|root| (*root).to_string()).collect(),
|
||||
game_version: Some("1.20.1".to_string()),
|
||||
loader: Some("fabric".to_string()),
|
||||
..ResolveRequest::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn plan_of(transport: &FixtureTransport, roots: &[&str]) -> DependencyPlan {
|
||||
let client = Modrinth::new(transport);
|
||||
block_on(resolve(&client, &request(roots)))
|
||||
}
|
||||
|
||||
fn projects(plan: &DependencyPlan) -> Vec<&str> {
|
||||
plan.nodes
|
||||
.iter()
|
||||
.map(|node| node.project.as_str())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_root_prerequisites_follow_key_order_not_payload_order() {
|
||||
// 夹具里 ZEBRA01 在前、ALPHA01 在后(站点原文顺序反过来是常态)。
|
||||
// 这一条守的是 `Version::required_project_ids()` 里的排序:靠「入队顺序」展开的话,
|
||||
// 站点换个数组顺序,安装顺序就跟着变。
|
||||
let transport = FixtureTransport::new()
|
||||
.with(
|
||||
&format!("{API}/order-root/version"),
|
||||
&version_json(
|
||||
"order-root",
|
||||
"order-root.jar",
|
||||
&[("ZEBRA01", "required"), ("ALPHA01", "required")],
|
||||
),
|
||||
)
|
||||
.with(
|
||||
&format!("{API}/ALPHA01/version"),
|
||||
&version_json("ALPHA01", "alpha.jar", &[]),
|
||||
)
|
||||
.with(
|
||||
&format!("{API}/ZEBRA01/version"),
|
||||
&version_json("ZEBRA01", "zebra.jar", &[]),
|
||||
);
|
||||
|
||||
let plan = plan_of(&transport, &["order-root"]);
|
||||
assert!(plan.is_clean(), "{:?}", plan.failures);
|
||||
assert_eq!(
|
||||
projects(&plan),
|
||||
["order-root", "ALPHA01", "ZEBRA01"],
|
||||
"同层按项目键字典序,与 `dependencies` 数组顺序无关"
|
||||
);
|
||||
}
|
||||
|
||||
/// 两个根各带一个前置 ⇒ 它们在**同一层**相遇,且项目键的顺序与根的先后相反。
|
||||
///
|
||||
/// 这一条守的是 `resolve` 里每层的 `sort_by`:不排的话,层内顺序就是「谁先被入队」——
|
||||
/// 也就是参数顺序与站点响应顺序的混合,删掉那行 `sort_by` 这条会红
|
||||
/// (实测:`left: ["zebra-branch","alpha-branch"]` 之类的参数序)。
|
||||
#[test]
|
||||
fn prerequisites_from_two_roots_are_ordered_by_project_key() {
|
||||
let transport = FixtureTransport::new()
|
||||
.with(
|
||||
&format!("{API}/branch-a/version"),
|
||||
&version_json("branch-a", "branch-a.jar", &[("ZEBRA01", "required")]),
|
||||
)
|
||||
.with(
|
||||
&format!("{API}/branch-b/version"),
|
||||
&version_json("branch-b", "branch-b.jar", &[("ALPHA01", "required")]),
|
||||
)
|
||||
.with(
|
||||
&format!("{API}/ALPHA01/version"),
|
||||
&version_json("ALPHA01", "alpha.jar", &[]),
|
||||
)
|
||||
.with(
|
||||
&format!("{API}/ZEBRA01/version"),
|
||||
&version_json("ZEBRA01", "zebra.jar", &[]),
|
||||
);
|
||||
|
||||
let plan = plan_of(&transport, &["branch-a", "branch-b"]);
|
||||
assert!(plan.is_clean(), "{:?}", plan.failures);
|
||||
assert_eq!(
|
||||
projects(&plan),
|
||||
["branch-a", "branch-b", "ALPHA01", "ZEBRA01"],
|
||||
"第二层两个前置按项目键排,不按「谁先入队」"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_roots_are_processed_in_key_order_whatever_the_argument_order() {
|
||||
let transport = FixtureTransport::new()
|
||||
.with(
|
||||
&format!("{API}/zebra-root/version"),
|
||||
&version_json("zebra-root", "zebra-root.jar", &[]),
|
||||
)
|
||||
.with(
|
||||
&format!("{API}/alpha-root/version"),
|
||||
&version_json("alpha-root", "alpha-root.jar", &[]),
|
||||
);
|
||||
|
||||
let forward = plan_of(&transport, &["zebra-root", "alpha-root"]);
|
||||
let backward = plan_of(&transport, &["alpha-root", "zebra-root"]);
|
||||
assert_eq!(
|
||||
projects(&forward),
|
||||
["alpha-root", "zebra-root"],
|
||||
"参数逆序也要按项目键处理"
|
||||
);
|
||||
assert_eq!(projects(&backward), projects(&forward), "两次结果必须一样");
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
//! M186 复审(r1)补的用例:失败去重、null 载荷、版本级去重。
|
||||
//!
|
||||
//! 三条都对应一轮真实缺陷或真实缺口,而不是「补覆盖率」:
|
||||
//! 1. [`the_same_failing_project_is_recorded_once_however_many_paths_ask_for_it`] — P2-1:
|
||||
//! 失败分支以前直接 `continue`,同一项目被两条路径要求时取两次版本、记两条失败、
|
||||
//! 重试名单里写两遍同一个名字;
|
||||
//! 2. [`a_null_dependency_type_does_not_poison_the_whole_version_list`] — P2-2:
|
||||
//! `"dependency_type": null` 曾让整份 `/version` 报 Malformed,于是整个项目都装不上;
|
||||
//! 3. [`another_version_of_the_same_mod_in_the_directory_still_downloads`] — nit③:
|
||||
//! 归一化一旦改松,同 mod 的另一个版本会被误判成「已装」而跳过。
|
||||
|
||||
use crate::addon::online::dependency::{present_key, DependencyPlan, ResolveRequest};
|
||||
use crate::addon::online::model::parse_versions;
|
||||
use crate::addon::online::{resolve, DependencyType, Modrinth, PresentFile, UnresolvedRequired};
|
||||
use crate::download::blocking::block_on;
|
||||
|
||||
use super::fixtures_deps as deps;
|
||||
use super::support::{version_json, FixtureTransport};
|
||||
|
||||
const API: &str = "https://api.modrinth.com/v2/project";
|
||||
const SODIUM_VERSIONS: &str = "https://api.modrinth.com/v2/project/sodium/version";
|
||||
const FABRIC_API_VERSIONS: &str = "https://api.modrinth.com/v2/project/P7dR8mSH/version";
|
||||
const YACL_VERSIONS: &str = "https://api.modrinth.com/v2/project/1eAoo2KR/version";
|
||||
|
||||
fn request(root: &str) -> ResolveRequest {
|
||||
ResolveRequest {
|
||||
roots: vec![root.to_string()],
|
||||
game_version: Some("1.20.1".to_string()),
|
||||
loader: Some("fabric".to_string()),
|
||||
..ResolveRequest::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_with(transport: &FixtureTransport, request: &ResolveRequest) -> DependencyPlan {
|
||||
let client = Modrinth::new(transport);
|
||||
block_on(resolve(&client, request))
|
||||
}
|
||||
|
||||
fn chain_transport() -> FixtureTransport {
|
||||
FixtureTransport::new()
|
||||
.with(SODIUM_VERSIONS, deps::SODIUM_WITH_DEPENDENCIES)
|
||||
.with(FABRIC_API_VERSIONS, deps::FABRIC_API_WITH_DEPENDENCIES)
|
||||
.with(YACL_VERSIONS, deps::YACL)
|
||||
}
|
||||
|
||||
/// 同一个项目被两条路径要求、且它 404:**只请求一次、只记一条失败**,父边合起来。
|
||||
///
|
||||
/// r1 P2-1 的探针:`ROOT0001 → {BRANCH01, MIDDLE01}`、`BRANCH01 → GONE0001`(404)、
|
||||
/// `MIDDLE01 → LATE0001 → GONE0001`。修之前 GONE0001 被请求 2 次、失败 2 条、
|
||||
/// 重试名单是 `["GONE0001","GONE0001"]`(用户会看到「可只重试这 2 个:GONE0001、GONE0001」)。
|
||||
#[test]
|
||||
fn the_same_failing_project_is_recorded_once_however_many_paths_ask_for_it() {
|
||||
let transport = FixtureTransport::new()
|
||||
.with(
|
||||
&format!("{API}/root/version"),
|
||||
&version_json(
|
||||
"ROOT0001",
|
||||
"root.jar",
|
||||
&[("BRANCH01", "required"), ("MIDDLE01", "required")],
|
||||
),
|
||||
)
|
||||
.with(
|
||||
&format!("{API}/BRANCH01/version"),
|
||||
&version_json("BRANCH01", "branch.jar", &[("GONE0001", "required")]),
|
||||
)
|
||||
.with(
|
||||
&format!("{API}/MIDDLE01/version"),
|
||||
&version_json("MIDDLE01", "middle.jar", &[("LATE0001", "required")]),
|
||||
)
|
||||
.with(
|
||||
&format!("{API}/LATE0001/version"),
|
||||
&version_json("LATE0001", "late.jar", &[("GONE0001", "required")]),
|
||||
)
|
||||
.missing(&format!("{API}/GONE0001/version"));
|
||||
|
||||
let plan = resolve_with(&transport, &request("root"));
|
||||
|
||||
let gone_requests = transport
|
||||
.seen()
|
||||
.iter()
|
||||
.filter(|url| url.contains("GONE0001"))
|
||||
.count();
|
||||
assert_eq!(
|
||||
gone_requests,
|
||||
1,
|
||||
"同一个失败项目不许取两次:{:?}",
|
||||
transport.seen()
|
||||
);
|
||||
assert_eq!(plan.failures.len(), 1, "{:?}", plan.failures);
|
||||
assert_eq!(plan.failures[0].requested_as, "GONE0001");
|
||||
assert_eq!(
|
||||
plan.failures[0].required_by,
|
||||
["BRANCH01".to_string(), "LATE0001".to_string()],
|
||||
"两条路径的父边都要留下"
|
||||
);
|
||||
assert_eq!(plan.retry_request().roots, ["GONE0001"], "名单不许重复");
|
||||
}
|
||||
|
||||
/// `"dependency_type": null` 不许把整份版本响应打成 Malformed(r1 P2-2 的回归)。
|
||||
///
|
||||
/// 以前 `RawDependency.dependency_type` 是 `String`:`#[serde(default)]` 只兜「字段缺失」,
|
||||
/// 兜不住 null ⇒ 整份 `/version` 解析失败 ⇒ 根项目记 `Malformed`、`nodes` 为空 ⇒ 一个都装不上。
|
||||
#[test]
|
||||
fn a_null_dependency_type_does_not_poison_the_whole_version_list() {
|
||||
let body = r#"[{"id": "ver-null", "project_id": "NULLTYPE", "version_number": "1.0",
|
||||
"files": [{"url": "https://cdn.modrinth.com/data/NULLTYPE/1.0/null-type.jar",
|
||||
"filename": "null-type.jar", "size": 16, "primary": true}],
|
||||
"dependencies": [{"project_id": "X1", "dependency_type": null}]}]"#;
|
||||
|
||||
// 解析层:null 与缺失一样落 `Unknown`
|
||||
let versions = parse_versions(body).expect("null 不该让整份响应失败");
|
||||
assert_eq!(
|
||||
versions[0].dependencies[0].dependency_type,
|
||||
DependencyType::Unknown
|
||||
);
|
||||
assert_eq!(versions[0].required_project_ids(), Vec::<&str>::new());
|
||||
|
||||
// 解析流程:根照样装得上,X1 一次请求都不发
|
||||
let transport = FixtureTransport::new().with(&format!("{API}/null-type/version"), body);
|
||||
let plan = resolve_with(&transport, &request("null-type"));
|
||||
assert!(plan.is_clean(), "{:?}", plan.failures);
|
||||
assert_eq!(plan.nodes.len(), 1, "根项目要还在计划里");
|
||||
assert_eq!(transport.seen().len(), 1, "未知类型不许递归");
|
||||
}
|
||||
|
||||
/// 目录里是**同 mod 的另一个版本** ⇒ 文件名不同,仍然要下载(r1 nit③ 的负向用例)。
|
||||
///
|
||||
/// 归一化如果改松(例如「砍到第一个 `-` 前 + 砍扩展名」),`sodium-fabric-0.5.11.jar` 与
|
||||
/// `sodium-fabric-0.6.0.jar` 会被判成同一个文件,该装的被跳过。
|
||||
#[test]
|
||||
fn another_version_of_the_same_mod_in_the_directory_still_downloads() {
|
||||
let mut request = request("sodium");
|
||||
request.present = vec![PresentFile::new("sodium-fabric-0.5.11.jar")];
|
||||
let transport = chain_transport();
|
||||
let plan = resolve_with(&transport, &request);
|
||||
|
||||
assert!(plan.skipped.is_empty(), "{:?}", plan.skipped);
|
||||
assert_eq!(plan.nodes[0].file.filename, "sodium-fabric-0.6.0.jar");
|
||||
assert_ne!(
|
||||
present_key("sodium-fabric-0.5.11.jar"),
|
||||
present_key("sodium-fabric-0.6.0.jar"),
|
||||
"不同版本的文件名归一化后不许相等"
|
||||
);
|
||||
}
|
||||
|
||||
/// 未知/大小写不同的取值一律 `Unknown`,**绝不**猜成 required(这条设计不许被 r1 的修法改坏)。
|
||||
#[test]
|
||||
fn unknown_dependency_types_never_trigger_recursion() {
|
||||
for raw in [
|
||||
"required_by_default",
|
||||
"Required ",
|
||||
"Recommended",
|
||||
"",
|
||||
"REQUIRED",
|
||||
"req",
|
||||
] {
|
||||
assert_eq!(
|
||||
DependencyType::parse(raw),
|
||||
DependencyType::Unknown,
|
||||
"{raw:?} 不该被当成 required"
|
||||
);
|
||||
}
|
||||
assert_eq!(DependencyType::parse("required"), DependencyType::Required);
|
||||
}
|
||||
|
||||
/// `required_project_ids()` 的「排序 + 去重」契约单独钉住。
|
||||
///
|
||||
/// 为什么要单独钉:resolver 里 `layer.sort_by` 会把它重排一遍、`wave_in` 也会按同键合并,
|
||||
/// 于是**这条契约在端到端用例里被兜住了**(删掉这里的 `sort_unstable` 三条顺序用例仍绿)。
|
||||
/// 但 `dedup()` 只去相邻重复,不排序就留下 `["ZEBRA01","ALPHA01","ZEBRA01"]`——
|
||||
/// 公开 API 说好了「排序去重」,就得有自己的一条。
|
||||
#[test]
|
||||
fn required_project_ids_are_sorted_and_deduplicated_on_their_own() {
|
||||
let body = version_json(
|
||||
"dup-root",
|
||||
"dup-root.jar",
|
||||
&[
|
||||
("ZEBRA01", "required"),
|
||||
("ALPHA01", "required"),
|
||||
("ZEBRA01", "required"),
|
||||
],
|
||||
);
|
||||
let versions = parse_versions(&body).expect("夹具该解析成功");
|
||||
assert_eq!(
|
||||
versions[0].required_project_ids(),
|
||||
["ALPHA01", "ZEBRA01"],
|
||||
"既要去重(非相邻)也要排序"
|
||||
);
|
||||
}
|
||||
|
||||
/// 只有 `version_id` 的必备前置:**core 自己**要把它们收进 `unresolved_required`(r2 nit①)。
|
||||
///
|
||||
/// presenter 那条用例是直接赋值 `plan.unresolved_required`,绕过了 core;这里从夹具走一遍解析,
|
||||
/// 顺带钉住「别为了收集把正常的 required 也吞掉」——同一版本里带 `project_id` 的那条仍要被跟。
|
||||
#[test]
|
||||
fn a_required_dependency_with_only_a_version_id_is_collected_by_the_resolver() {
|
||||
let body = r#"[{"id": "ver-mixed", "project_id": "MIXED001", "version_number": "1.0",
|
||||
"files": [{"url": "https://cdn.modrinth.com/data/MIXED001/1.0/mixed.jar",
|
||||
"filename": "mixed.jar", "size": 16, "primary": true}],
|
||||
"dependencies": [
|
||||
{"version_id": "vid-only", "dependency_type": "required"},
|
||||
{"project_id": "FOLLOW01", "dependency_type": "required"}
|
||||
]}]"#;
|
||||
let transport = FixtureTransport::new()
|
||||
.with(&format!("{API}/mixed/version"), body)
|
||||
.with(
|
||||
&format!("{API}/FOLLOW01/version"),
|
||||
&version_json("FOLLOW01", "follow.jar", &[]),
|
||||
);
|
||||
|
||||
let plan = resolve_with(&transport, &request("mixed"));
|
||||
|
||||
assert_eq!(
|
||||
plan.unresolved_required,
|
||||
[UnresolvedRequired {
|
||||
parent: "MIXED001".to_string(),
|
||||
version_id: Some("vid-only".to_string()),
|
||||
file_name: None,
|
||||
}],
|
||||
"只给版本 id 的 required 必须被记下来"
|
||||
);
|
||||
let projects: Vec<&str> = plan
|
||||
.nodes
|
||||
.iter()
|
||||
.map(|node| node.project.as_str())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
projects,
|
||||
["MIXED001", "FOLLOW01"],
|
||||
"同一版本里带 project_id 的 required 仍要被跟"
|
||||
);
|
||||
}
|
||||
@@ -167,3 +167,23 @@ pub(super) fn hashes_of(bytes: &[u8]) -> (String, String) {
|
||||
crate::addon::online::download::to_hex(&sha512),
|
||||
)
|
||||
}
|
||||
|
||||
/// 拼一份 `/v2/project/{id}/version` 响应:一个版本、一个主文件、按给定顺序声明依赖。
|
||||
///
|
||||
/// `deps` 的每一项是 `(project_id, dependency_type)`,**顺序照抄站点原文**——顺序无关性由
|
||||
/// `resolve` 保证(`tests/order.rs` 用它做「夹具逆序也得到同一顺序」的守门)。
|
||||
pub(super) fn version_json(project: &str, file: &str, deps: &[(&str, &str)]) -> String {
|
||||
let dependencies: Vec<String> = deps
|
||||
.iter()
|
||||
.map(|(id, kind)| format!(r#"{{"project_id":"{id}","dependency_type":"{kind}"}}"#))
|
||||
.collect();
|
||||
format!(
|
||||
r#"[{{"id":"ver-{project}","project_id":"{project}","name":"{project}",
|
||||
"version_number":"1.0","version_type":"release","loaders":["fabric"],
|
||||
"game_versions":["1.20.1"],
|
||||
"files":[{{"hashes":{{}},"url":"https://cdn.modrinth.com/data/{project}/1.0/{file}",
|
||||
"filename":"{file}","size":16,"primary":true}}],
|
||||
"dependencies":[{}]}}]"#,
|
||||
dependencies.join(",")
|
||||
)
|
||||
}
|
||||
@@ -29,7 +29,9 @@ use super::chain::complete_chain;
|
||||
use super::endpoints::MicrosoftEndpoints;
|
||||
use super::flow::{oauth_error, tokens_from, OAuthTokens, SCOPE};
|
||||
use super::now_ms;
|
||||
use super::preflight::{self, RedirectRegistration};
|
||||
use super::session::MicrosoftSession;
|
||||
use super::{BrowserFallback, BrowserFallbackReason};
|
||||
|
||||
/// 授权码 grant 类型。
|
||||
pub const AUTHORIZATION_CODE_GRANT: &str = "authorization_code";
|
||||
@@ -171,11 +173,28 @@ pub(crate) async fn login<F>(
|
||||
policy: AuthCodePolicy,
|
||||
present: F,
|
||||
) -> AuthResult<MicrosoftSession>
|
||||
where
|
||||
F: FnOnce(&AuthCodePrompt),
|
||||
{
|
||||
login_counting(client, endpoints, client_id, policy, None, present).await
|
||||
}
|
||||
|
||||
/// 同 [`login`],但把**绑定现场**的监听口次数记进 `counter`(M178 的取证计数)。
|
||||
pub(crate) async fn login_counting<F>(
|
||||
client: &Client,
|
||||
endpoints: &MicrosoftEndpoints,
|
||||
client_id: &str,
|
||||
policy: AuthCodePolicy,
|
||||
counter: Option<&std::sync::atomic::AtomicUsize>,
|
||||
present: F,
|
||||
) -> AuthResult<MicrosoftSession>
|
||||
where
|
||||
F: FnOnce(&AuthCodePrompt),
|
||||
{
|
||||
let state = policy.state.clone().unwrap_or_else(nonce);
|
||||
let server = CallbackServer::bind(policy.port, policy.bind_attempts, state.clone()).await?;
|
||||
let server =
|
||||
CallbackServer::bind_counting(policy.port, policy.bind_attempts, state.clone(), counter)
|
||||
.await?;
|
||||
let redirect_uri = RedirectUri::new(server.port());
|
||||
present(&AuthCodePrompt {
|
||||
authorization_url: authorization_url(endpoints, client_id, &redirect_uri, &state),
|
||||
@@ -194,6 +213,51 @@ where
|
||||
complete_chain(client, endpoints, tokens, now_ms()).await
|
||||
}
|
||||
|
||||
/// 一次「浏览器优先」尝试的结果(M178)。
|
||||
pub(crate) enum AuthCodeAttempt {
|
||||
/// 授权码流走通(凭据已换到,整链完成)。
|
||||
LoggedIn(MicrosoftSession),
|
||||
/// **没去试**:预检说这个回环 URI 没登记(或问不出来)。监听端口一次都没开。
|
||||
Skipped(BrowserFallback),
|
||||
}
|
||||
|
||||
/// 带预检的授权码登录:注册里有这个回环 URI 才真去绑端口、弹浏览器。
|
||||
///
|
||||
/// 顺序是刻意的——**预检在绑定之前**:没登记的机器上连监听口都不开(`counter` 是绑定现场的
|
||||
/// 真计数,见 [`CallbackServer::bind_counting`]),也就不会出现「浏览器里一张 AAD 错误页 +
|
||||
/// 启动器干等 5 分钟」的那套现象(M178 用户实测的那个 bug)。
|
||||
pub(crate) async fn login_checked<F>(
|
||||
client: &Client,
|
||||
endpoints: &MicrosoftEndpoints,
|
||||
client_id: &str,
|
||||
policy: AuthCodePolicy,
|
||||
counter: &std::sync::atomic::AtomicUsize,
|
||||
present: F,
|
||||
) -> AuthResult<AuthCodeAttempt>
|
||||
where
|
||||
F: FnOnce(&AuthCodePrompt),
|
||||
{
|
||||
let probe = RedirectUri::new(preflight::PROBE_PORT);
|
||||
match preflight::check(client, endpoints, client_id, &probe).await {
|
||||
RedirectRegistration::Registered => {}
|
||||
RedirectRegistration::NotRegistered { detail } => {
|
||||
return Ok(AuthCodeAttempt::Skipped(BrowserFallback::new(
|
||||
BrowserFallbackReason::RedirectNotRegistered,
|
||||
detail,
|
||||
)))
|
||||
}
|
||||
RedirectRegistration::Unknown { detail } => {
|
||||
return Ok(AuthCodeAttempt::Skipped(BrowserFallback::new(
|
||||
BrowserFallbackReason::PreflightUnavailable,
|
||||
detail,
|
||||
)))
|
||||
}
|
||||
}
|
||||
let session =
|
||||
login_counting(client, endpoints, client_id, policy, Some(counter), present).await?;
|
||||
Ok(AuthCodeAttempt::LoggedIn(session))
|
||||
}
|
||||
|
||||
/// 每次登录一个 `state`(不用于加密,只用来把迟到的旧回调挡掉)。
|
||||
///
|
||||
/// 128 位(两个独立随机种子的 SipHash 输出拼接):RFC 6749 §10.10 建议 `state` 至少
|
||||
|
||||
@@ -66,11 +66,29 @@ impl CallbackServer {
|
||||
port: Option<u16>,
|
||||
attempts: u8,
|
||||
state: impl Into<String>,
|
||||
) -> AuthResult<Self> {
|
||||
Self::bind_counting(port, attempts, state, None).await
|
||||
}
|
||||
|
||||
/// 同 [`Self::bind`],但把**每一次真正调用 `TcpListener::bind`** 记进 `counter`(M178)。
|
||||
///
|
||||
/// 为什么不把计数写在外层:`LoginOutcome::listeners_opened` 是「这次登录有没有偷开监听口」
|
||||
/// 的唯一证据,它必须是**绑定现场**的数,不能是「进没进授权码分支」的代理指标
|
||||
/// (复审 r1 P2-1 的变异 D 实测:代理指标对「真的多绑了一次」不敏感)。
|
||||
/// 计数发生在 `TcpListener::bind` 调用之前:要数的是「去开了没」,不是「开成功了没」。
|
||||
pub(crate) async fn bind_counting(
|
||||
port: Option<u16>,
|
||||
attempts: u8,
|
||||
state: impl Into<String>,
|
||||
counter: Option<&std::sync::atomic::AtomicUsize>,
|
||||
) -> AuthResult<Self> {
|
||||
let wanted = port.unwrap_or(0);
|
||||
let attempts = attempts.max(1);
|
||||
let mut last = String::new();
|
||||
for attempt in 0..attempts {
|
||||
if let Some(counter) = counter {
|
||||
counter.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
match TcpListener::bind(("127.0.0.1", wanted)).await {
|
||||
Ok(listener) => {
|
||||
let port = listener
|
||||
|
||||
@@ -116,140 +116,5 @@ pub fn client_id_status() -> ClientIdStatus {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Mutex;
|
||||
|
||||
use super::{
|
||||
client_id_status, resolve_client_id, ClientIdSource, CLIENT_ID_BUILD, CLIENT_ID_ENV,
|
||||
MISSING_CLIENT_ID_MESSAGE,
|
||||
};
|
||||
use crate::auth::error::AuthError;
|
||||
use crate::auth::microsoft::MicrosoftAuth;
|
||||
|
||||
/// 环境变量是**进程级**的:动它的用例必须串行,否则并行跑会互相摘/设,结论随机。
|
||||
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
/// 用例结束后把环境变量还原(panic 也还原:`Drop` 跑得到)。
|
||||
struct EnvGuard {
|
||||
saved: Option<String>,
|
||||
}
|
||||
|
||||
impl EnvGuard {
|
||||
fn clear() -> Self {
|
||||
let saved = std::env::var(CLIENT_ID_ENV).ok();
|
||||
std::env::remove_var(CLIENT_ID_ENV);
|
||||
Self { saved }
|
||||
}
|
||||
|
||||
fn set(value: &str) -> Self {
|
||||
let saved = std::env::var(CLIENT_ID_ENV).ok();
|
||||
std::env::set_var(CLIENT_ID_ENV, value);
|
||||
Self { saved }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EnvGuard {
|
||||
fn drop(&mut self) {
|
||||
match self.saved.take() {
|
||||
Some(value) => std::env::set_var(CLIENT_ID_ENV, value),
|
||||
None => std::env::remove_var(CLIENT_ID_ENV),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_client_id_message_says_unconfigured_not_unimplemented() {
|
||||
// 抓得住:文案退回「本期未支持」这类说法(用户实跑到的那个 bug),
|
||||
// 或者只说「需要 id」却不给怎么配(用户下一步就卡在这里)。
|
||||
// 抓不住:文案好不好读——那要靠 review。
|
||||
assert!(MISSING_CLIENT_ID_MESSAGE.contains("客户端 id"));
|
||||
assert!(MISSING_CLIENT_ID_MESSAGE.contains("未配置"));
|
||||
assert!(!MISSING_CLIENT_ID_MESSAGE.contains("未支持"));
|
||||
assert!(!MISSING_CLIENT_ID_MESSAGE.contains("未实现"));
|
||||
assert!(MISSING_CLIENT_ID_MESSAGE.contains(CLIENT_ID_ENV));
|
||||
assert!(MISSING_CLIENT_ID_MESSAGE.contains("cargo build"));
|
||||
// 硬约束:这句话进日志、进横幅、进错误,**示例一律是 `<id>` 占位**,不出现真 id。
|
||||
assert!(MISSING_CLIENT_ID_MESSAGE.contains("<id>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_env_without_client_id_is_a_config_error_not_a_feature_gap() {
|
||||
let _lock = ENV_LOCK.lock().unwrap_or_else(|error| error.into_inner());
|
||||
let _guard = EnvGuard::clear();
|
||||
let result = MicrosoftAuth::from_env();
|
||||
match result {
|
||||
Err(AuthError::Unsupported(message)) => {
|
||||
// 机读码保持 `unsupported`(CLI `--json` 与既有调用方靠它分流),
|
||||
// 但**人读文案**必须说「未配置」。
|
||||
assert_eq!(message, MISSING_CLIENT_ID_MESSAGE);
|
||||
assert!(!message.contains("未支持"));
|
||||
}
|
||||
// 构建期注入过 id 时唯一合法(`ULL_MSA_CLIENT_ID` 也设进 `cargo test` 的环境就会走到这里;
|
||||
// 官方发布构建即烘了 id,但门禁里的 `cargo test` 不带该变量,故常态走上面那条。
|
||||
// 注意:把该变量导出到跑测试的 shell 会让 presenter 的 M132 入口锚**故意红**
|
||||
// (它要求「两处都没有 id」的构建,见 crates/presenter/src/host/accounts/tests.rs)。
|
||||
Ok(_) => assert!(
|
||||
CLIENT_ID_BUILD.is_some_and(|value| !value.trim().is_empty()),
|
||||
"没配 id 却构造成功"
|
||||
),
|
||||
Err(other) => panic!("应是 Unsupported,实际 {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_client_id_wins_over_build_and_the_log_says_so() {
|
||||
let _lock = ENV_LOCK.lock().unwrap_or_else(|error| error.into_inner());
|
||||
let _guard = EnvGuard::set("0000-test-env-id");
|
||||
|
||||
let (id, source) = resolve_client_id().expect("环境变量给了 id");
|
||||
assert_eq!(id, "0000-test-env-id");
|
||||
// 两者都存在时环境变量优先,且**不告警**,但来源必须是 env。
|
||||
assert_eq!(source, ClientIdSource::Env);
|
||||
|
||||
let status = client_id_status();
|
||||
assert!(status.is_configured());
|
||||
assert_eq!(status.source(), Some(ClientIdSource::Env));
|
||||
assert_eq!(
|
||||
status.log_line(),
|
||||
"[auth] msa client_id=configured source=env"
|
||||
);
|
||||
|
||||
// 有 id 时**不再报配置错误**:只验到「进得了登录流程的入口」为止,
|
||||
// `from_env` 本身零网络(它只建 reqwest 客户端,不发请求)。
|
||||
let auth = MicrosoftAuth::from_env().expect("配了 id 就该构造成功");
|
||||
assert!(auth.endpoints().profile.contains("minecraftservices.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_client_id_log_line_is_machine_readable() {
|
||||
let _lock = ENV_LOCK.lock().unwrap_or_else(|error| error.into_inner());
|
||||
let _guard = EnvGuard::clear();
|
||||
if CLIENT_ID_BUILD.is_some_and(|value| !value.trim().is_empty()) {
|
||||
// 构建期注入过 id:这条没有「缺配置」可言,跳过。
|
||||
return;
|
||||
}
|
||||
let status = client_id_status();
|
||||
assert!(!status.is_configured());
|
||||
assert_eq!(status.source(), None);
|
||||
assert_eq!(
|
||||
status.log_line(),
|
||||
"[auth] msa client_id=missing source=none"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_env_value_counts_as_missing() {
|
||||
// 抓得住:`ULL_MSA_CLIENT_ID=""`(空串/全空白)被当成配好了,进而拿空 id 去联网。
|
||||
let _lock = ENV_LOCK.lock().unwrap_or_else(|error| error.into_inner());
|
||||
let _guard = EnvGuard::set(" ");
|
||||
if CLIENT_ID_BUILD.is_some_and(|value| !value.trim().is_empty()) {
|
||||
assert_eq!(
|
||||
resolve_client_id().map(|(_, source)| source),
|
||||
Some(ClientIdSource::Build),
|
||||
"空白环境变量必须让位给构建期注入"
|
||||
);
|
||||
} else {
|
||||
assert!(resolve_client_id().is_none());
|
||||
}
|
||||
}
|
||||
}
|
||||
#[path = "client_id/tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,358 @@
|
||||
//! `client_id` 的判据(从 `client_id.rs` 拆出来守 400 行硬线)。
|
||||
//!
|
||||
//! 两块:①「缺 id」文案与日志行的措辞纪律(M117);②**M178 守卫**——client id 只能构建期注入,
|
||||
//! 微软轨源码里不许出现 UUID 形状的字面量(复审 r1 P2-3 加严:`concat!` / 跨行 / `format!`
|
||||
//! 三种拼法都要抓得住,且不许误杀 `session.rs` 里那个 profile UUID)。
|
||||
|
||||
use std::sync::Mutex;
|
||||
|
||||
use super::{
|
||||
client_id_status, resolve_client_id, ClientIdSource, CLIENT_ID_BUILD, CLIENT_ID_ENV,
|
||||
MISSING_CLIENT_ID_MESSAGE,
|
||||
};
|
||||
use crate::auth::error::AuthError;
|
||||
use crate::auth::microsoft::MicrosoftAuth;
|
||||
|
||||
/// 环境变量是**进程级**的:动它的用例必须串行,否则并行跑会互相摘/设,结论随机。
|
||||
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
/// 用例结束后把环境变量还原(panic 也还原:`Drop` 跑得到)。
|
||||
struct EnvGuard {
|
||||
saved: Option<String>,
|
||||
}
|
||||
|
||||
impl EnvGuard {
|
||||
fn clear() -> Self {
|
||||
let saved = std::env::var(CLIENT_ID_ENV).ok();
|
||||
std::env::remove_var(CLIENT_ID_ENV);
|
||||
Self { saved }
|
||||
}
|
||||
|
||||
fn set(value: &str) -> Self {
|
||||
let saved = std::env::var(CLIENT_ID_ENV).ok();
|
||||
std::env::set_var(CLIENT_ID_ENV, value);
|
||||
Self { saved }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EnvGuard {
|
||||
fn drop(&mut self) {
|
||||
match self.saved.take() {
|
||||
Some(value) => std::env::set_var(CLIENT_ID_ENV, value),
|
||||
None => std::env::remove_var(CLIENT_ID_ENV),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_client_id_message_says_unconfigured_not_unimplemented() {
|
||||
// 抓得住:文案退回「本期未支持」这类说法(用户实跑到的那个 bug),
|
||||
// 或者只说「需要 id」却不给怎么配(用户下一步就卡在这里)。
|
||||
// 抓不住:文案好不好读——那要靠 review。
|
||||
assert!(MISSING_CLIENT_ID_MESSAGE.contains("客户端 id"));
|
||||
assert!(MISSING_CLIENT_ID_MESSAGE.contains("未配置"));
|
||||
assert!(!MISSING_CLIENT_ID_MESSAGE.contains("未支持"));
|
||||
assert!(!MISSING_CLIENT_ID_MESSAGE.contains("未实现"));
|
||||
assert!(MISSING_CLIENT_ID_MESSAGE.contains(CLIENT_ID_ENV));
|
||||
assert!(MISSING_CLIENT_ID_MESSAGE.contains("cargo build"));
|
||||
// 硬约束:这句话进日志、进横幅、进错误,**示例一律是 `<id>` 占位**,不出现真 id。
|
||||
assert!(MISSING_CLIENT_ID_MESSAGE.contains("<id>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_env_without_client_id_is_a_config_error_not_a_feature_gap() {
|
||||
let _lock = ENV_LOCK.lock().unwrap_or_else(|error| error.into_inner());
|
||||
let _guard = EnvGuard::clear();
|
||||
let result = MicrosoftAuth::from_env();
|
||||
match result {
|
||||
Err(AuthError::Unsupported(message)) => {
|
||||
// 机读码保持 `unsupported`(CLI `--json` 与既有调用方靠它分流),
|
||||
// 但**人读文案**必须说「未配置」。
|
||||
assert_eq!(message, MISSING_CLIENT_ID_MESSAGE);
|
||||
assert!(!message.contains("未支持"));
|
||||
}
|
||||
// 构建期注入过 id 时唯一合法(`ULL_MSA_CLIENT_ID` 也设进 `cargo test` 的环境就会走到这里;
|
||||
// 官方发布构建即烘了 id,但门禁里的 `cargo test` 不带该变量,故常态走上面那条。
|
||||
// 注意:把该变量导出到跑测试的 shell 会让 presenter 的 M132 入口锚**故意红**
|
||||
// (它要求「两处都没有 id」的构建,见 crates/presenter/src/host/accounts/tests.rs)。
|
||||
Ok(_) => assert!(
|
||||
CLIENT_ID_BUILD.is_some_and(|value| !value.trim().is_empty()),
|
||||
"没配 id 却构造成功"
|
||||
),
|
||||
Err(other) => panic!("应是 Unsupported,实际 {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_client_id_wins_over_build_and_the_log_says_so() {
|
||||
let _lock = ENV_LOCK.lock().unwrap_or_else(|error| error.into_inner());
|
||||
let _guard = EnvGuard::set("0000-test-env-id");
|
||||
|
||||
let (id, source) = resolve_client_id().expect("环境变量给了 id");
|
||||
assert_eq!(id, "0000-test-env-id");
|
||||
// 两者都存在时环境变量优先,且**不告警**,但来源必须是 env。
|
||||
assert_eq!(source, ClientIdSource::Env);
|
||||
|
||||
let status = client_id_status();
|
||||
assert!(status.is_configured());
|
||||
assert_eq!(status.source(), Some(ClientIdSource::Env));
|
||||
assert_eq!(
|
||||
status.log_line(),
|
||||
"[auth] msa client_id=configured source=env"
|
||||
);
|
||||
|
||||
// 有 id 时**不再报配置错误**:只验到「进得了登录流程的入口」为止,
|
||||
// `from_env` 本身零网络(它只建 reqwest 客户端,不发请求)。
|
||||
let auth = MicrosoftAuth::from_env().expect("配了 id 就该构造成功");
|
||||
assert!(auth.endpoints().profile.contains("minecraftservices.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_client_id_log_line_is_machine_readable() {
|
||||
let _lock = ENV_LOCK.lock().unwrap_or_else(|error| error.into_inner());
|
||||
let _guard = EnvGuard::clear();
|
||||
if CLIENT_ID_BUILD.is_some_and(|value| !value.trim().is_empty()) {
|
||||
// 构建期注入过 id:这条没有「缺配置」可言,跳过。
|
||||
return;
|
||||
}
|
||||
let status = client_id_status();
|
||||
assert!(!status.is_configured());
|
||||
assert_eq!(status.source(), None);
|
||||
assert_eq!(
|
||||
status.log_line(),
|
||||
"[auth] msa client_id=missing source=none"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_env_value_counts_as_missing() {
|
||||
// 抓得住:`ULL_MSA_CLIENT_ID=""`(空串/全空白)被当成配好了,进而拿空 id 去联网。
|
||||
let _lock = ENV_LOCK.lock().unwrap_or_else(|error| error.into_inner());
|
||||
let _guard = EnvGuard::set(" ");
|
||||
if CLIENT_ID_BUILD.is_some_and(|value| !value.trim().is_empty()) {
|
||||
assert_eq!(
|
||||
resolve_client_id().map(|(_, source)| source),
|
||||
Some(ClientIdSource::Build),
|
||||
"空白环境变量必须让位给构建期注入"
|
||||
);
|
||||
} else {
|
||||
assert!(resolve_client_id().is_none());
|
||||
}
|
||||
}
|
||||
|
||||
/// **M178 守卫**:client id 只能构建期/运行期注入,**微软轨源码里不许出现任何真实 id**。
|
||||
///
|
||||
/// 抓得住:为了让登录「开箱可用」而把一个真 client id 写进源码(或写进本模块的默认值)。
|
||||
/// 抓不住:`.github/workflows` 里 Secret 的接线(那不在本 crate 的范围内,靠发布链自己的
|
||||
/// `packaging/check_release_semantics.py` 与 Actions 日志看)。
|
||||
#[test]
|
||||
fn no_client_id_is_hardcoded_in_the_microsoft_sources() {
|
||||
// 判据一:本模块的 id 只能来自 `option_env!`(构建期注入)。
|
||||
let manifest = env!("CARGO_MANIFEST_DIR");
|
||||
let source = std::fs::read_to_string(format!("{manifest}/src/auth/microsoft/client_id.rs"))
|
||||
.expect("读 client_id.rs");
|
||||
assert!(
|
||||
source.contains("option_env!(\"ULL_MSA_CLIENT_ID\")"),
|
||||
"client id 的构建期注入点不见了"
|
||||
);
|
||||
// 判据二:`auth/microsoft/` 下的任何源码都不许出现 UUID 形状的字面量
|
||||
// (Azure 客户端 id 就是 UUID)。扫**全部文件**,所以把 id 挪到别的模块同样会红。
|
||||
// 范围只到 `auth/microsoft/`:`auth/offline.rs` 里那个带连字符的 UUID 是离线名字的
|
||||
// 测试向量,与 client id 无关。
|
||||
let mut offenders = Vec::new();
|
||||
let mut stack = vec![format!("{manifest}/src/auth/microsoft")];
|
||||
while let Some(dir) = stack.pop() {
|
||||
let Ok(entries) = std::fs::read_dir(&dir) else {
|
||||
continue;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
stack.push(path.display().to_string());
|
||||
continue;
|
||||
}
|
||||
if path.extension().is_some_and(|ext| ext == "rs") {
|
||||
let text = std::fs::read_to_string(&path).unwrap_or_default();
|
||||
if contains_uuid_literal(&text) {
|
||||
offenders.push(path.display().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
offenders.is_empty(),
|
||||
"这些源文件里出现了 UUID 形状的字面量(client id 只能构建期注入):{offenders:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// 源码里有没有「Azure 客户端 id 形状」的字面量(复审 r1 P2-3 加严)。
|
||||
///
|
||||
/// 两条判据,覆盖复审点名的三种拼法:
|
||||
/// 1. **带连字符**(`8-4-4-4-12`):把全部字面量首尾相接后扫,于是
|
||||
/// `concat!("83fd9b2f-", "da89-…")`、跨行相邻两段、单段完整 UUID 都能抓到;
|
||||
/// 2. **无连字符的 32 位十六进制、且由 ≥2 个**连续**的「纯十六进制字面量**拼起来
|
||||
/// (`format!("{}-{}-{}-{}", "83fd9b2f", "da89", …)` 这类逐段拼法)。
|
||||
///
|
||||
/// 为什么判据 2 要求 ≥2 段:`session.rs` 的测试数据里有 profile UUID
|
||||
/// (`"0f8fad5bd9cb469fa16570867728950e"`)——单个字面量的 32 位 hex 是**档案 id**,
|
||||
/// 不是 client id;只有「拼出来的」才可疑。也不能把字面量无分隔地全部相接去数 hex 段:
|
||||
/// 那样下一个字面量的开头若恰好是 hex 字符(如 `"Bearer"` 的 `Bea`)会把上一段续长造成误杀。
|
||||
fn contains_uuid_literal(text: &str) -> bool {
|
||||
let literals = string_literals(text);
|
||||
let joined: String = literals.iter().map(String::as_str).collect();
|
||||
if has_dashed_uuid(&joined) {
|
||||
return true;
|
||||
}
|
||||
// 判据 2:连续的「纯 hex 字面量」累计长度 ≥32 且至少两段。
|
||||
let mut run_len = 0;
|
||||
let mut run_parts = 0;
|
||||
for literal in &literals {
|
||||
let pure_hex = !literal.is_empty() && literal.bytes().all(|b| b.is_ascii_hexdigit());
|
||||
if pure_hex {
|
||||
run_len += literal.len();
|
||||
run_parts += 1;
|
||||
if run_len >= 32 && run_parts >= 2 {
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
run_len = 0;
|
||||
run_parts = 0;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// 收集源码里的字符串字面量内容(跳过注释;`\` 转义按「跳一个字符」处理)。
|
||||
///
|
||||
/// 为什么跳注释:本模块的文档里就写着 `concat!("83fd9b2f-", "da89-…")` 这类**说明性**例子,
|
||||
/// 不跳的话自检样本会被自己的文档抓成 offender(实测踩过)。注释里出现 id 不构成「代码里
|
||||
/// 硬编码 id」,判据只管可执行的字面量。
|
||||
///
|
||||
/// 近似处(够用即可):字符字面量 `'a'` 不当字符串;`r#"…"#` 按普通字面量近似(前缀 `r`
|
||||
/// 不在引号内,不影响内容提取)。
|
||||
fn string_literals(source: &str) -> Vec<String> {
|
||||
let bytes = source.as_bytes();
|
||||
let mut out = Vec::new();
|
||||
let mut index = 0;
|
||||
while index < bytes.len() {
|
||||
match bytes[index] {
|
||||
// 行注释:跳到行尾
|
||||
b'/' if bytes.get(index + 1) == Some(&b'/') => {
|
||||
while index < bytes.len() && bytes[index] != b'\n' {
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
// 块注释:跳到 `*/`
|
||||
b'/' if bytes.get(index + 1) == Some(&b'*') => {
|
||||
index += 2;
|
||||
while index + 1 < bytes.len() && !(bytes[index] == b'*' && bytes[index + 1] == b'/')
|
||||
{
|
||||
index += 1;
|
||||
}
|
||||
index = (index + 2).min(bytes.len());
|
||||
}
|
||||
b'"' => {
|
||||
index += 1;
|
||||
let start = index;
|
||||
let mut escaped = false;
|
||||
while index < bytes.len() {
|
||||
let byte = bytes[index];
|
||||
if escaped {
|
||||
escaped = false;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
match byte {
|
||||
b'\\' => {
|
||||
escaped = true;
|
||||
index += 1;
|
||||
}
|
||||
b'"' => break,
|
||||
_ => index += 1,
|
||||
}
|
||||
}
|
||||
let end = index.min(bytes.len());
|
||||
out.push(String::from_utf8_lossy(&bytes[start..end]).into_owned());
|
||||
index += 1;
|
||||
}
|
||||
_ => index += 1,
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 拼接后的串里有没有 `8-4-4-4-12` 的十六进制段(间距 5-5-5-13)。
|
||||
fn has_dashed_uuid(text: &str) -> bool {
|
||||
fn hex(value: &[u8]) -> bool {
|
||||
!value.is_empty() && value.iter().all(u8::is_ascii_hexdigit)
|
||||
}
|
||||
let bytes = text.as_bytes();
|
||||
let dashes: Vec<usize> = bytes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, byte)| **byte == b'-')
|
||||
.map(|(index, _)| index)
|
||||
.collect();
|
||||
dashes.windows(4).any(|window| {
|
||||
let (first, last) = (window[0], window[3]);
|
||||
let gaps = [
|
||||
window[1] - first,
|
||||
window[2] - window[1],
|
||||
window[3] - window[2],
|
||||
];
|
||||
if gaps != [5, 5, 5] || last + 13 > bytes.len() || first < 8 {
|
||||
return false;
|
||||
}
|
||||
hex(&bytes[first - 8..first])
|
||||
&& hex(&bytes[first + 1..window[1]])
|
||||
&& hex(&bytes[window[1] + 1..window[2]])
|
||||
&& hex(&bytes[window[2] + 1..last])
|
||||
&& hex(&bytes[last + 1..last + 13])
|
||||
})
|
||||
}
|
||||
|
||||
/// 判据自检:三种拼法都要抓得住,且不能把普通代码当 client id。
|
||||
///
|
||||
/// 没有这条,扫描可能因为判据本身坏掉而永远为真(假绿)。
|
||||
/// 样本**运行时拼、且源码里不出现十六进制字面量**(用 `char` 数组拼):否则这条自检的
|
||||
/// 样本本身会被扫描抓成 offender——自指陷阱(实测踩过一次)。
|
||||
#[test]
|
||||
fn the_uuid_literal_detector_bites_three_shapes() {
|
||||
let part = |chars: &[char]| chars.iter().collect::<String>();
|
||||
let head = part(&['8', '3', 'f', 'd', '9', 'b', '2', 'f']);
|
||||
let p2 = part(&['d', 'a', '8', '9']);
|
||||
let p3 = part(&['4', '1', 'f', '7']);
|
||||
let p4 = part(&['b', '4', '1', 'b']);
|
||||
let tail = part(&['1', '3', '7', '4', '2', 'a', '7', '3', '8', '4', '1', 'c']);
|
||||
let uuid = format!("{head}-{p2}-{p3}-{p4}-{tail}");
|
||||
|
||||
// ① 单个完整字面量(基线)。
|
||||
let plain = format!("let id = \"{uuid}\";");
|
||||
assert!(contains_uuid_literal(&plain), "{plain}");
|
||||
// ② `concat!` 拼出来(复审点名的形态之一)。
|
||||
let concatenated = format!("const ID: &str = concat!(\"{head}-\", \"{p2}-{p3}-{p4}-{tail}\");");
|
||||
assert!(contains_uuid_literal(&concatenated), "{concatenated}");
|
||||
// ③ 跨行两段字面量。
|
||||
let split_lines =
|
||||
format!("let id = concat!(\n \"{head}-{p2}-\",\n \"{p3}-{p4}-{tail}\"\n);");
|
||||
assert!(contains_uuid_literal(&split_lines), "{split_lines}");
|
||||
// ④ `format!` 逐段拼(无连字符落点,靠「连续纯 hex 字面量累计 ≥32 且 ≥2 段」抓)。
|
||||
let formatted = format!(
|
||||
"let id = format!(\"{{}}-{{}}-{{}}-{{}}-{{}}\", \"{head}\", \"{p2}\", \"{p3}\", \"{p4}\", \"{tail}\");"
|
||||
);
|
||||
assert!(contains_uuid_literal(&formatted), "{formatted}");
|
||||
|
||||
// 反例:这些都不许误杀。
|
||||
assert!(!contains_uuid_literal("// 0000-test-client"));
|
||||
assert!(!contains_uuid_literal("a-b-c-d"));
|
||||
assert!(!contains_uuid_literal(
|
||||
"let uri = \"http://localhost/auth?client_id=not-a-uuid\";"
|
||||
));
|
||||
// 单个字面量的 32 位 hex = 既有测试数据(`session.rs` 的 profile UUID),不算 client id。
|
||||
let profile = format!("let profile_id = \"{head}{p2}{p3}{p4}{tail}\";");
|
||||
assert!(!contains_uuid_literal(&profile), "{profile}");
|
||||
// 段长不对的连字符串不算(`8-4-4-4-12` 之外一律放行)。
|
||||
assert!(!contains_uuid_literal(
|
||||
"let x = \"abcdefgh-1234-5678-9012-12345678901\";"
|
||||
));
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
//! 「浏览器优先、失败自动回退设备码」(M178)——GUI 那条可选加速路的编排。
|
||||
//!
|
||||
//! ## 为什么这条流必须自己会退
|
||||
//!
|
||||
//! 授权码(浏览器)流把用户送回 `http://localhost:{port}/auth`。随机端口只有在「回环 URI
|
||||
//! 的端口不参与匹配」成立时才可能被接受——微软官方文档
|
||||
//! ([*Redirect URI (reply URL) best practices and limitations*](https://learn.microsoft.com/en-us/entra/identity-platform/reply-url),
|
||||
//! *Localhost exceptions*)原文:
|
||||
//!
|
||||
//! > "Due to ephemeral port ranges often required by native applications, the port component
|
||||
//! > (for example, :5001 or :443) is ignored for the purposes of matching a localhost redirect URI."
|
||||
//!
|
||||
//! 也就是说**注册一次 `http://localhost/auth` 就能接住任意端口**;但本项目这个注册当初是为
|
||||
//! device-code 建的,重定向 URI 一项都没登记(`DevDocs/ops/azure-msa-app.md`)。没登记时
|
||||
//! 授权端点当场回 `invalid_request ... 'redirect_uri' is not valid`,**而且不会**再跳回本地
|
||||
//! 回调——用户只看到一张 AAD 原始错误页,启动器干等到超时。这正是 2026-11-08 官方产物上
|
||||
//! 「微软登录整体不可用」的根因。
|
||||
//!
|
||||
//! 于是这里的分工是:设备码当默认(不依赖任何注册项),浏览器流只在**用户显式要求**时试,
|
||||
//! 而且试之前先预检([`super::preflight`]):没登记就一个监听口都不开。
|
||||
//!
|
||||
//! 回退说明有两层:结构化码 [`BrowserFallbackReason`](宿主据它出 i18n,**能说清怎么修**)
|
||||
//! 与原始诊断 [`BrowserFallback::detail`](只进日志,不上屏)。
|
||||
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use crate::auth::error::{AuthError, AuthResult};
|
||||
|
||||
use super::authcode::{self, AuthCodeAttempt, AuthCodePrompt};
|
||||
use super::chain;
|
||||
use super::flow::{self, DeviceCodePrompt};
|
||||
use super::now_ms;
|
||||
use super::preflight;
|
||||
use super::session::MicrosoftSession;
|
||||
use super::MicrosoftAuth;
|
||||
|
||||
/// 「浏览器优先但没走成」的原因(**上屏只看码**,`detail` 只进日志)。
|
||||
///
|
||||
/// 三个码对应三种不同的下一步:
|
||||
/// - [`BrowserFallbackReason::RedirectNotRegistered`]:应用注册里没登记回环重定向 URI。
|
||||
/// 修法是**具体可执行的**(把 `http://localhost/auth` 加进「重定向 URI」,端口不参与匹配)。
|
||||
/// - [`BrowserFallbackReason::PreflightUnavailable`]:没法确认(网络/超时),只能先用设备码。
|
||||
/// - [`BrowserFallbackReason::AttemptFailed`]:试过了但失败(超时、换 token 被拒…)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BrowserFallbackReason {
|
||||
/// 授权端点当场拒绝了这个回环 `redirect_uri`(`invalid_request` / `AADSTS50011`)
|
||||
RedirectNotRegistered,
|
||||
/// 预检本身没问出结论(网络/超时/非预期应答)
|
||||
PreflightUnavailable,
|
||||
/// 预检通过、但授权码流仍然没走通
|
||||
AttemptFailed,
|
||||
}
|
||||
|
||||
impl BrowserFallbackReason {
|
||||
/// 稳定的 ascii 码(日志 / 页面「Code: …」/ 测试断言都用它)。
|
||||
#[must_use]
|
||||
pub const fn code(self) -> &'static str {
|
||||
match self {
|
||||
Self::RedirectNotRegistered => "redirect_not_registered",
|
||||
Self::PreflightUnavailable => "preflight_unavailable",
|
||||
Self::AttemptFailed => "attempt_failed",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 回退到设备码时的说明:原因码 + 原始诊断(诊断只进日志,不上屏)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BrowserFallback {
|
||||
/// 原因码
|
||||
pub reason: BrowserFallbackReason,
|
||||
/// 原始诊断(**只进日志**;截断到 240 字符)
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
impl BrowserFallback {
|
||||
/// 构造(诊断串截断:只进日志,别把整张错误页灌进去)。
|
||||
#[must_use]
|
||||
pub fn new(reason: BrowserFallbackReason, detail: impl Into<String>) -> Self {
|
||||
let detail = detail.into();
|
||||
let detail = match detail.char_indices().nth(240) {
|
||||
Some((index, _)) => format!("{}…", &detail[..index]),
|
||||
None => detail,
|
||||
};
|
||||
Self { reason, detail }
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次「浏览器优先」登录的完整结果(M178)。
|
||||
///
|
||||
/// `listeners_opened` 是**这次登录**真的去开本地监听口的次数(0 或 1,`bind_counting` 在
|
||||
/// `TcpListener::bind` 现场 +1)。它存在的唯一理由是守卫能直接数:「没登记回环」「走设备码」
|
||||
/// 这两条路上必须是 0。用进程级全局计数器做不到这一点(并行跑的测试会互相搅动),所以计数
|
||||
/// 是 per-login 的 `AtomicUsize`,但**增长点在绑定现场**,不在「进没进某个分支」。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LoginOutcome {
|
||||
/// 拿到的会话(两条流任意一条走通)
|
||||
pub session: MicrosoftSession,
|
||||
/// 回退说明(`None` = 没走浏览器 / 浏览器走通了)
|
||||
pub browser: Option<BrowserFallback>,
|
||||
/// 这次登录一共开了几个本地监听口(设备码路恒 0)
|
||||
pub listeners_opened: usize,
|
||||
}
|
||||
|
||||
impl LoginOutcome {
|
||||
/// 浏览器(授权码)流是不是被跳过了。
|
||||
#[must_use]
|
||||
pub fn browser_was_skipped(&self) -> bool {
|
||||
self.browser.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
impl MicrosoftAuth {
|
||||
/// 浏览器优先、失败自动回退设备码(M178:GUI 的「用浏览器登录」入口)。
|
||||
///
|
||||
/// 顺序与判据:
|
||||
/// 1. 先预检回环注册([`authcode::login_checked`])。没登记 / 问不出来 → **不绑端口**、
|
||||
/// 不弹浏览器,带一条结构化说明直接进设备码;
|
||||
/// 2. 预检通过 → 真走授权码流(绑端口 + 弹浏览器);
|
||||
/// 3. 授权码流失败(超时 / 换 token 被拒 / 端口绑不上)→ 同样回退设备码,
|
||||
/// 说明码是 [`BrowserFallbackReason::AttemptFailed`](拒绝原因若属于「重定向 URI 没登记」
|
||||
/// 则归一成 [`BrowserFallbackReason::RedirectNotRegistered`],好让用户看到怎么修);
|
||||
/// 4. **用户自己取消不算失败**([`AuthError::Canceled`] 原样上抛):那是明确意图,
|
||||
/// 不能偷偷换一条流继续问用户要码。
|
||||
///
|
||||
/// `present_device` 额外收到回退说明(`None` = 根本没走浏览器),宿主据此在设备码面板上
|
||||
/// 画一句「为什么改用设备码 + 怎么修」。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 用户取消、设备码流本身的失败(网络/超时/被拒/无拥有权/无档案),见 [`AuthError`]。
|
||||
pub async fn browser_then_device_login<FB, FD>(
|
||||
&self,
|
||||
present_browser: FB,
|
||||
mut present_device: FD,
|
||||
) -> AuthResult<LoginOutcome>
|
||||
where
|
||||
FB: FnOnce(&AuthCodePrompt),
|
||||
FD: FnMut(&DeviceCodePrompt, Option<&BrowserFallback>),
|
||||
{
|
||||
let mut fallback: Option<BrowserFallback> = None;
|
||||
// 绑定现场的**真计数**(`CallbackServer::bind_counting` 每次调 `TcpListener::bind` 前 +1):
|
||||
// 不是「进没进授权码分支」的代理指标——那正是复审 r1 P2-1 变异 D 打穿的地方。
|
||||
let listeners = AtomicUsize::new(0);
|
||||
if self.browser_first {
|
||||
match authcode::login_checked(
|
||||
&self.client,
|
||||
&self.endpoints,
|
||||
&self.client_id,
|
||||
self.authcode.clone(),
|
||||
&listeners,
|
||||
present_browser,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(AuthCodeAttempt::LoggedIn(session)) => {
|
||||
return Ok(LoginOutcome {
|
||||
session,
|
||||
browser: None,
|
||||
listeners_opened: listeners.load(Ordering::Relaxed),
|
||||
})
|
||||
}
|
||||
// 跳过:**没绑端口**(预检在绑定之前),计数仍是 0。
|
||||
Ok(AuthCodeAttempt::Skipped(note)) => fallback = Some(note),
|
||||
Err(AuthError::Canceled) => return Err(AuthError::Canceled),
|
||||
Err(error) => {
|
||||
self.record_failure("authcode_login", &error);
|
||||
let reason = if preflight::error_means_unregistered(&error) {
|
||||
BrowserFallbackReason::RedirectNotRegistered
|
||||
} else {
|
||||
BrowserFallbackReason::AttemptFailed
|
||||
};
|
||||
fallback = Some(BrowserFallback::new(reason, error.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
let grant =
|
||||
flow::request_device_code(&self.client, &self.endpoints, &self.client_id).await?;
|
||||
present_device(&grant.prompt, fallback.as_ref());
|
||||
let tokens = flow::poll_device_token(
|
||||
&self.client,
|
||||
&self.endpoints,
|
||||
&self.client_id,
|
||||
&grant,
|
||||
self.poll,
|
||||
)
|
||||
.await?;
|
||||
let session =
|
||||
chain::complete_chain(&self.client, &self.endpoints, tokens, now_ms()).await?;
|
||||
Ok(LoginOutcome {
|
||||
session,
|
||||
browser: fallback,
|
||||
listeners_opened: listeners.load(Ordering::Relaxed),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,29 +1,39 @@
|
||||
//! 微软轨:device-code 与授权码(浏览器)两条登录流 + 会话/刷新语义
|
||||
//! (`PlanDoc/07-accounts.md` §1、`analyse/04` §4–§5、`research/08` §3)。
|
||||
//!
|
||||
//! 分六块,各管一件事:
|
||||
//! - [`client_id`]:Azure 公共客户端 id 的**配置状态**(来源 / 配没配 / 启动日志那一行,零网络);
|
||||
//! - [`session`]:`MicrosoftSession`(凭据载荷)与**刷新判定**(纯函数,可脱离网络单测);
|
||||
//! - [`endpoints`]:端点表(生产 = 微软消费版直连;测试 = 整表指向 mock);
|
||||
//! - [`flow`]:device-code 发起 / 轮询 / refresh(OAuth 层);
|
||||
//! - [`authcode`] + [`callback`]:授权码流(授权 URL + 本地 HTTP 回调服务器 + code 换 token);
|
||||
//! - [`endpoints`] + [`flow`]:端点表与 device-code 发起 / 轮询 / refresh(OAuth 层);
|
||||
//! - [`authcode`] + [`callback`] + [`preflight`] + [`login`]:授权码流(授权 URL + 本地回调 +
|
||||
//! 回环注册预检 + 回退设备码的编排);
|
||||
//! - [`chain`]:XBL → XSTS → login_with_xbox → entitlements → profile(Xbox/MC 层)。
|
||||
//!
|
||||
//! 两条流只差「怎么拿到 MS access_token」:拿到之后都进同一个 [`chain::complete_chain`],
|
||||
//! 落盘/刷新也共用 [`MicrosoftSession`],所以 GUI 账户页的两颗按钮不会长出两套凭据语义。
|
||||
//! # 默认走哪条(M178)
|
||||
//!
|
||||
//! **设备码是默认**,授权码是可选加速路:授权码流要求应用注册里登记回环重定向 URI
|
||||
//! (`http://localhost/auth`,端口由微软忽略),而本项目的注册当初只为 device-code 而建。
|
||||
//! 没登记时授权端点当场拒掉且**不**把浏览器送回本地回调,用户只看到
|
||||
//! `invalid_request ... 'redirect_uri' is not valid` 那张 AAD 原始错误页。所以:
|
||||
//!
|
||||
//! | 入口 | 走什么 |
|
||||
//! |---|---|
|
||||
//! | [`MicrosoftAuth::device_code_login`] | 设备码,**一个监听口都不开** |
|
||||
//! | [`MicrosoftAuth::browser_then_device_login`] | 先预检回环注册:没登记就直接设备码;登记了走授权码,失败回退设备码 |
|
||||
//! | [`MicrosoftAuth::authcode_login`] | 强制授权码(测试/排障用,不做预检) |
|
||||
//!
|
||||
//! # client id 从哪来
|
||||
//!
|
||||
//! 两条流都必须带一个**已在 Azure 注册的公共客户端 id**(且租户是 `consumers`、
|
||||
//! 重定向 URI 登记为 `http://localhost`)。HMCL 把它烘进 jar 的
|
||||
//! manifest(`hmcl.microsoft.auth.id`),我们同理:二进制里不硬编码,改由
|
||||
//! `ULL_MSA_CLIENT_ID`(运行期)或构建期 `option_env!` 提供。没有配时
|
||||
//! 两条流都必须带一个**已在 Azure 注册的公共客户端 id**(租户必须是 `consumers`)。
|
||||
//! 授权码流额外要求注册里登记过回环重定向 URI(`http://localhost/auth`,端口不参与匹配;
|
||||
//! 见 [`login`] 引的微软官方原文)。id 与 HMCL 同款模型:二进制里不硬编码,
|
||||
//! 改由 `ULL_MSA_CLIENT_ID`(运行期)或构建期 `option_env!` 提供。没有配时
|
||||
//! [`MicrosoftAuth::from_env`] 返回 [`AuthError::Unsupported`],文案是
|
||||
//! [`client_id::MISSING_CLIENT_ID_MESSAGE`](「未配置」+ 怎么配,见 [`client_id`])——
|
||||
//! 这比编一个假 id 让用户看到 `invalid_client` 诚实。
|
||||
//!
|
||||
//! 措辞纪律(M117):id 缺失是**没配置**,不是「本期未支持」。判定与那行启动日志都在
|
||||
//! [`client_id`],本模块只消费结论。
|
||||
//! 措辞纪律(M117):id 缺失是**没配置**不是「本期未支持」,判定与启动日志都在 [`client_id`]。
|
||||
|
||||
pub mod authcode;
|
||||
pub mod callback;
|
||||
@@ -31,11 +41,15 @@ pub mod chain;
|
||||
pub mod client_id;
|
||||
pub mod endpoints;
|
||||
pub mod flow;
|
||||
pub mod login;
|
||||
pub mod preflight;
|
||||
|
||||
pub mod session;
|
||||
|
||||
pub use authcode::{AuthCodePolicy, AuthCodePrompt, RedirectUri};
|
||||
pub use callback::CallbackServer;
|
||||
// 回环重定向 URI 的注册预检(M178):`RedirectRegistration` 是宿主排障时唯一要看的结论。
|
||||
pub use preflight::{RedirectRegistration, PROBE_PORT};
|
||||
// client id 的判定与启动日志行留一层 re-export:调用方(CLI / GUI / 启动日志)只认
|
||||
// `auth::microsoft::*` 这一条路径,不必知道它是单独一块(M117)。
|
||||
pub use client_id::{
|
||||
@@ -44,6 +58,7 @@ pub use client_id::{
|
||||
};
|
||||
pub use endpoints::MicrosoftEndpoints;
|
||||
pub use flow::{DeviceCodePrompt, PollPolicy};
|
||||
pub use login::{BrowserFallback, BrowserFallbackReason, LoginOutcome};
|
||||
pub use session::{MicrosoftSession, RefreshDecision, RefreshReason, USER_TYPE_MSA};
|
||||
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
@@ -63,6 +78,7 @@ pub struct MicrosoftAuth {
|
||||
endpoints: MicrosoftEndpoints,
|
||||
poll: PollPolicy,
|
||||
authcode: AuthCodePolicy,
|
||||
browser_first: bool,
|
||||
journal: Option<AuthJournal>,
|
||||
}
|
||||
|
||||
@@ -81,6 +97,7 @@ impl MicrosoftAuth {
|
||||
endpoints: MicrosoftEndpoints::consumers(),
|
||||
poll: PollPolicy::default(),
|
||||
authcode: AuthCodePolicy::default(),
|
||||
browser_first: false,
|
||||
journal: None,
|
||||
})
|
||||
}
|
||||
@@ -155,6 +172,22 @@ impl MicrosoftAuth {
|
||||
self
|
||||
}
|
||||
|
||||
/// 「浏览器优先」(M178):授权码流只是**可选加速路**,默认不走。
|
||||
///
|
||||
/// 只有宿主确认用户显式要求时才置位(GUI 的「用浏览器登录」按钮)。置位后仍然先做
|
||||
/// 回环注册预检([`preflight`]):没登记就**不绑端口、不弹浏览器**,直接设备码。
|
||||
#[must_use]
|
||||
pub fn with_browser_first(mut self, enabled: bool) -> Self {
|
||||
self.browser_first = enabled;
|
||||
self
|
||||
}
|
||||
|
||||
/// 是否允许尝试浏览器(授权码)流。
|
||||
#[must_use]
|
||||
pub fn browser_first(&self) -> bool {
|
||||
self.browser_first
|
||||
}
|
||||
|
||||
/// 当前端点表。
|
||||
#[must_use]
|
||||
pub fn endpoints(&self) -> &MicrosoftEndpoints {
|
||||
@@ -354,13 +387,14 @@ mod tests {
|
||||
.endpoints()
|
||||
.profile
|
||||
.contains("api.minecraftservices.com"));
|
||||
// 默认**不是**浏览器优先(M178:设备码才是默认路)。
|
||||
assert!(!auth.browser_first());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn now_ms_is_sane() {
|
||||
// 2020-01-01 之后、2100 之前(防止本地时钟/单位抄错)。
|
||||
let now = now_ms();
|
||||
assert!(now > 1_577_836_800_000);
|
||||
assert!(now < 4_102_444_800_000);
|
||||
assert!(now > 1_577_836_800_000 && now < 4_102_444_800_000);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
//! 授权码流的「回环重定向 URI 注册预检」(M178)。
|
||||
//!
|
||||
//! ## 为什么需要预检
|
||||
//!
|
||||
//! 授权码流把浏览器送回 `http://localhost:{port}/auth`。**随机端口**只有在「回环 URI 的
|
||||
//! 端口不参与匹配」这件事成立时才可能被接受——微软官方文档写得很清楚
|
||||
//! ([*Redirect URI (reply URL) best practices and limitations*](https://learn.microsoft.com/en-us/entra/identity-platform/reply-url),
|
||||
//! 小节 *Localhost exceptions*):
|
||||
//!
|
||||
//! > "Due to ephemeral port ranges often required by native applications, the port component
|
||||
//! > (for example, :5001 or :443) is ignored for the purposes of matching a localhost redirect URI.
|
||||
//! > As a result, all of these URIs are considered equivalent: `http://localhost/MyApp`,
|
||||
//! > `http://localhost:1234/MyApp`, `http://localhost:5000/MyApp`, `http://localhost:8080/MyApp`"
|
||||
//!
|
||||
//! 换句话说:**注册一次 `http://localhost/auth` 就能接住任意端口**;但**一次都没注册**时,
|
||||
//! 任何端口的回环 URI 都会被授权端点当场拒掉(`invalid_request: ... 'redirect_uri' is not valid`),
|
||||
//! 而且**不会**再跳回本地回调——用户只在浏览器里看到一张 AAD 错误页,启动器只能干等超时。
|
||||
//! 本项目的 Azure 应用注册当初是为 device-code 建的,重定向 URI 一项都没登记
|
||||
//! (`DevDocs/ops/azure-msa-app.md`),所以这条流在官方产物上必然失败。
|
||||
//!
|
||||
//! ## 预检做什么、不做什么
|
||||
//!
|
||||
//! 做:拿**即将使用的那个** `redirect_uri`(只有端口可以是任意的,所以用固定探测端口拼形状)
|
||||
//! 去授权端点发一次**不带凭据的 GET**,看它是否当场报 `invalid_request` / `AADSTS50011`。
|
||||
//! 不注册 → 不绑本地端口、不弹浏览器,直接回退设备码。这样「没登记回调地址」的机器上
|
||||
//! 监听口一次都不开(守卫断言 `LoginOutcome::listeners_opened == 0`,那个数是
|
||||
//! [`super::callback::CallbackServer::bind_counting`] 在 `TcpListener::bind` 现场加的)。
|
||||
//!
|
||||
//! 不做:预检失败**不等于**登录被拒——它只决定「要不要试浏览器」。
|
||||
//! `Unknown`(网络问题/整体超时)同样按「别试」处理:设备码流不依赖任何注册项,永远更稳。
|
||||
//! 整次预检有 [`PREFLIGHT_TIMEOUT`] 的上限:服务器接了连接却不出响应时,不能让「浏览器登录」
|
||||
//! 按钮把界面卡在 Requesting(取消按钮只收面板、停不掉已经跑起来的线程)。
|
||||
|
||||
use reqwest::Client;
|
||||
|
||||
use crate::auth::error::AuthError;
|
||||
|
||||
use super::authcode::{authorization_url, RedirectUri};
|
||||
use super::endpoints::MicrosoftEndpoints;
|
||||
|
||||
/// 预检用的回环端口。
|
||||
///
|
||||
/// **不是要绑它**:端口对回环匹配不参与(见模块文档引的那段官方原文),所以随便一个
|
||||
/// 「看起来像临时端口」的值就能代表真实绑定后的那个。固定值让日志与测试可复现。
|
||||
pub const PROBE_PORT: u16 = 53_691;
|
||||
|
||||
/// 预检请求里的 `state`(不参与任何校验,只为拼出形状完整的授权 URL)。
|
||||
const PROBE_STATE: &str = "ull-redirect-probe";
|
||||
|
||||
/// 整次预检的时间上限(复审 r1 P2-2)。
|
||||
///
|
||||
/// 为什么必须有:下载引擎那个 HTTP 客户端只设了 `connect_timeout`(10s),**没有**整体超时
|
||||
/// ——`read_timeout` 从未应用到 reqwest 客户端。授权端点接了连接却不出响应(或 header 后停住)时,
|
||||
/// 这一次「用浏览器登录」会把界面卡在 Requesting,而取消按钮只收面板、停不掉已经跑起来的线程。
|
||||
/// 超时按 [`RedirectRegistration::Unknown`] 处理:回退设备码,原因码「没确认」。
|
||||
pub const PREFLIGHT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
|
||||
/// 授权端点对 `redirect_uri` 的态度。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum RedirectRegistration {
|
||||
/// 注册里有这个回环 URI(端口随意)→ 可以真的走授权码流。
|
||||
Registered,
|
||||
/// 明确没登记(`invalid_request` / `AADSTS50011`)→ 别试,直接设备码。
|
||||
NotRegistered {
|
||||
/// 诊断原文(只进日志)
|
||||
detail: String,
|
||||
},
|
||||
/// 问不出来(网络/超时/非预期应答)→ 同样不试,但原因是「没确认」而不是「没配」。
|
||||
Unknown {
|
||||
/// 诊断原文(只进日志)
|
||||
detail: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl RedirectRegistration {
|
||||
/// 是否值得继续走授权码流。
|
||||
#[must_use]
|
||||
pub fn is_registered(&self) -> bool {
|
||||
matches!(self, Self::Registered)
|
||||
}
|
||||
}
|
||||
|
||||
/// 预检:注册里有没有这个回环重定向 URI。
|
||||
///
|
||||
/// 判据(顺序即优先级):
|
||||
/// 1. 应答里出现 `invalid_request` / `AADSTS50011` / `redirect_uri is not valid` 一类字样 → 没登记;
|
||||
/// 2. 非 2xx/3xx → 按「没登记」处理(AAD 对不合法的 redirect_uri 直接渲染错误页,状态码 400);
|
||||
/// 3. 其余 2xx/3xx 且没有拒绝字样 → 认为已登记(那就是登录页本身);
|
||||
/// 4. 连不上 → `Unknown`。
|
||||
///
|
||||
/// 阅读提示:这条判据**宁可错杀**——错过一次浏览器登录只是慢一步,误放行会让用户撞上
|
||||
/// AAD 的原始错误页,而那正是本 mission 要消灭的现象。
|
||||
pub(super) async fn check(
|
||||
client: &Client,
|
||||
endpoints: &MicrosoftEndpoints,
|
||||
client_id: &str,
|
||||
redirect_uri: &RedirectUri,
|
||||
) -> RedirectRegistration {
|
||||
check_with_deadline(
|
||||
client,
|
||||
endpoints,
|
||||
client_id,
|
||||
redirect_uri,
|
||||
PREFLIGHT_TIMEOUT,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// [`check`] 的可注入 deadline 版本(单测用短 deadline,不必等 10 秒)。
|
||||
pub(super) async fn check_with_deadline(
|
||||
client: &Client,
|
||||
endpoints: &MicrosoftEndpoints,
|
||||
client_id: &str,
|
||||
redirect_uri: &RedirectUri,
|
||||
deadline: std::time::Duration,
|
||||
) -> RedirectRegistration {
|
||||
match tokio::time::timeout(deadline, probe(client, endpoints, client_id, redirect_uri)).await {
|
||||
Ok(verdict) => verdict,
|
||||
Err(_) => RedirectRegistration::Unknown {
|
||||
detail: format!(
|
||||
"预检 {} 毫秒内没有结论,按「没确认」处理",
|
||||
deadline.as_millis()
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// 真正的预检请求(不含超时;由 [`check_with_deadline`] 套 deadline)。
|
||||
async fn probe(
|
||||
client: &Client,
|
||||
endpoints: &MicrosoftEndpoints,
|
||||
client_id: &str,
|
||||
redirect_uri: &RedirectUri,
|
||||
) -> RedirectRegistration {
|
||||
let url = authorization_url(endpoints, client_id, redirect_uri, PROBE_STATE);
|
||||
let response = client.get(&url).send().await;
|
||||
let response = match response {
|
||||
Ok(response) => response,
|
||||
Err(error) => {
|
||||
return RedirectRegistration::Unknown {
|
||||
detail: format!("预检请求失败:{error}"),
|
||||
}
|
||||
}
|
||||
};
|
||||
let status = response.status().as_u16();
|
||||
// 登录页可能带上几十上百 KB 的脚本;拒绝页只有一小段。读整页是为了抓正文里的错误码,
|
||||
// reqwest 的 `text()` 会把正文一次读尽(授权页不含任何凭据,不需要脱敏)。
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
if rejects_redirect_uri(&body) {
|
||||
return RedirectRegistration::NotRegistered {
|
||||
detail: format!("HTTP {status}:{}", snippet(&body)),
|
||||
};
|
||||
}
|
||||
if !(200..400).contains(&status) {
|
||||
return RedirectRegistration::NotRegistered {
|
||||
detail: format!("HTTP {status}:{}", snippet(&body)),
|
||||
};
|
||||
}
|
||||
RedirectRegistration::Registered
|
||||
}
|
||||
|
||||
/// 这个错误是不是「重定向 URI 没登记」那一类(`AuthError` 侧重判,回调/换 token 用)。
|
||||
///
|
||||
/// 与 [`rejects_redirect_uri`] 同源:一个看页面正文,一个看结构化错误。两者都覆盖
|
||||
/// `invalid_request` 与 `AADSTS50011`,因为同一件事在两条流上的形状不一样。
|
||||
#[must_use]
|
||||
pub(super) fn error_means_unregistered(error: &AuthError) -> bool {
|
||||
match error {
|
||||
AuthError::Denied { code, description } => {
|
||||
is_redirect_code(code) || redirects_rejected(description)
|
||||
}
|
||||
AuthError::Response(detail) => {
|
||||
redirects_rejected(detail.server_message.as_deref().unwrap_or_default())
|
||||
|| redirects_rejected(detail.digest.as_str())
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_redirect_code(code: &str) -> bool {
|
||||
matches!(
|
||||
code,
|
||||
"invalid_request"
|
||||
| "invalid_redirect_uri"
|
||||
| "redirect_uri_mismatch"
|
||||
| "unauthorized_client"
|
||||
)
|
||||
}
|
||||
|
||||
fn redirects_rejected(text: &str) -> bool {
|
||||
let lowered = text.to_lowercase();
|
||||
lowered.contains("aadsts50011")
|
||||
|| lowered.contains("invalid_request")
|
||||
|| lowered.contains("redirect_uri is not valid")
|
||||
|| lowered.contains("redirect_uri+is+not+valid")
|
||||
|| lowered.contains("reply url specified in the request does not match")
|
||||
|| (lowered.contains("redirect") && lowered.contains("not valid"))
|
||||
}
|
||||
|
||||
/// 页面正文里有没有「重定向 URI 不合法」的拒绝标记。
|
||||
fn rejects_redirect_uri(body: &str) -> bool {
|
||||
let lowered = body.to_lowercase();
|
||||
lowered.contains("aadsts50011")
|
||||
|| lowered.contains("invalid_request")
|
||||
|| lowered.contains("redirect_uri is not valid")
|
||||
|| lowered.contains("redirect_uri+is+not+valid")
|
||||
|| (lowered.contains("redirect") && lowered.contains("not valid"))
|
||||
}
|
||||
|
||||
/// 正文摘要(单行、截断):只进日志,不上屏。
|
||||
fn snippet(body: &str) -> String {
|
||||
let one_line: String = body
|
||||
.chars()
|
||||
.map(|c| if c.is_control() { ' ' } else { c })
|
||||
.collect();
|
||||
let collapsed = one_line.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
match collapsed.char_indices().nth(200) {
|
||||
Some((index, _)) => format!("{}…", &collapsed[..index]),
|
||||
None => collapsed,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
check_with_deadline, error_means_unregistered, rejects_redirect_uri, snippet,
|
||||
RedirectRegistration,
|
||||
};
|
||||
use crate::auth::body::ResponseDetail;
|
||||
use crate::auth::error::AuthError;
|
||||
use crate::auth::microsoft::{MicrosoftEndpoints, RedirectUri};
|
||||
|
||||
/// 用户 2026-11-08 实测的那张错误页(原文截取)必须被判成「没登记」。
|
||||
#[test]
|
||||
fn the_reported_aad_page_is_recognized_as_unregistered() {
|
||||
let page = "error=invalid_request&error_description=The+provided+value+for+the+input+\
|
||||
parameter+%27redirect_uri%27+is+not+valid.+The+expected+value+is+a+URI+which+matches+a+\
|
||||
redirect+URI+registered+for+this+client+application.";
|
||||
assert!(rejects_redirect_uri(page));
|
||||
// 登录页本身(有脚本、有表单)不能被误判成拒绝。
|
||||
assert!(!rejects_redirect_uri(
|
||||
"<html><head><title>Sign in to your account</title></head><body>...</body></html>"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aadsts50011_is_also_a_registration_problem() {
|
||||
assert!(rejects_redirect_uri(
|
||||
"AADSTS50011: The redirect URI 'http://localhost:61492/auth' specified in the request does not match"
|
||||
));
|
||||
}
|
||||
|
||||
/// 两条流上的同一件事:授权页拒绝(`Denied`)与 token 端点拒绝(`Response`)都要认。
|
||||
#[test]
|
||||
fn structured_errors_of_both_shapes_are_recognized() {
|
||||
assert!(error_means_unregistered(&AuthError::Denied {
|
||||
code: "invalid_request".to_string(),
|
||||
description: "bad redirect".to_string(),
|
||||
}));
|
||||
assert!(error_means_unregistered(&AuthError::Response(Box::new(
|
||||
ResponseDetail {
|
||||
step: "token".to_string(),
|
||||
what: "换 token".to_string(),
|
||||
status: 400,
|
||||
content_type: None,
|
||||
kind: crate::auth::BodyKind::Json,
|
||||
server_message: Some("AADSTS50011: reply url mismatch".to_string()),
|
||||
digest: "digest".to_string(),
|
||||
}
|
||||
))));
|
||||
// 不相干的失败不是注册问题(别把网络/白名单也归到这条)。
|
||||
assert!(!error_means_unregistered(&AuthError::Canceled));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registration_state_is_answered_by_one_predicate() {
|
||||
assert!(RedirectRegistration::Registered.is_registered());
|
||||
assert!(!RedirectRegistration::NotRegistered {
|
||||
detail: String::new()
|
||||
}
|
||||
.is_registered());
|
||||
assert!(!RedirectRegistration::Unknown {
|
||||
detail: String::new()
|
||||
}
|
||||
.is_registered());
|
||||
}
|
||||
|
||||
/// **复审 r1 P2-2**:服务器接了连接却永不出响应时,预检必须靠**整体 deadline** 收场
|
||||
/// (不是 connect_timeout:连接早就建好了),结论是「没确认」⇒ 上层回退设备码。
|
||||
///
|
||||
/// 抓得住:把 `tokio::time::timeout` 去掉(那条路会一直挂着,本用例在 deadline 后
|
||||
/// 断言超时不出来,必然失败)。
|
||||
/// 抓不住:真机上网关的其它卡法(这里只有一个「不回字」的假服务器)。
|
||||
#[tokio::test]
|
||||
async fn a_silent_authorize_server_hits_the_overall_deadline() {
|
||||
// 假授权端点:accept 之后一个字节都不写(connect 成功、读永远等不到)。
|
||||
let silent = tokio::net::TcpListener::bind(("127.0.0.1", 0))
|
||||
.await
|
||||
.expect("假服务器起不来");
|
||||
let port = silent.local_addr().expect("地址").port();
|
||||
tokio::spawn(async move {
|
||||
let mut held = Vec::new();
|
||||
loop {
|
||||
let Ok((stream, _)) = silent.accept().await else {
|
||||
break;
|
||||
};
|
||||
held.push(stream);
|
||||
}
|
||||
});
|
||||
|
||||
let client =
|
||||
crate::download::http::build_client(&crate::download::config::EngineConfig::default())
|
||||
.expect("HTTP 客户端");
|
||||
let mut endpoints = MicrosoftEndpoints::all_on("http://127.0.0.1:1");
|
||||
endpoints.authorize = format!("http://127.0.0.1:{port}/authorize");
|
||||
let verdict = check_with_deadline(
|
||||
&client,
|
||||
&endpoints,
|
||||
"test-client-id",
|
||||
&RedirectUri::new(53_691),
|
||||
std::time::Duration::from_millis(200),
|
||||
)
|
||||
.await;
|
||||
match verdict {
|
||||
RedirectRegistration::Unknown { detail } => {
|
||||
assert!(detail.contains("200"), "文案要带 deadline:{detail}");
|
||||
}
|
||||
other => panic!("静默服务器必须走 Unknown,实际 {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snippet_is_single_line_and_capped() {
|
||||
let long = format!("a\nb\t{}", "x".repeat(400));
|
||||
let text = snippet(&long);
|
||||
assert!(!text.contains('\n'), "{text}");
|
||||
assert!(text.ends_with('…'), "{text}");
|
||||
assert_eq!(text.chars().count(), 201);
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,51 @@
|
||||
//! 引擎配置:设置层(core::setting)直接映射到这里。
|
||||
//!
|
||||
//! # 出厂默认值与设置 schema 的唯一来源(M193)
|
||||
//!
|
||||
//! 四个常量是**唯一**写死数字的地方:schema 的默认值/范围、`EngineConfig::default()`、
|
||||
//! `Semaphore` 容量全从它们取。此前 schema 写 8、引擎写 64 ⇒ 调并发完全无效(引擎侧零消费者)。
|
||||
//! 收成常量后「改一处另一个不变」会直接变红(守卫见 `shared.rs` 与本文件的用例)。
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::Duration;
|
||||
|
||||
use super::backoff::BackoffPolicy;
|
||||
use super::chunked::ChunkedConfig;
|
||||
use super::region::RegionProfile;
|
||||
use super::types::ResumePolicy;
|
||||
|
||||
/// 全局并发许可数的出厂默认(`download.concurrency` 的 schema 默认值同源)。
|
||||
pub const DEFAULT_MAX_CONCURRENCY: usize = 8;
|
||||
|
||||
/// 全局并发许可的**硬上限**(`download.concurrency` 的 schema 上界同源)。schema 在写入口就拒绝
|
||||
/// 越界值,这里再夹一次是纵深防御(手改文件、老 schema 允许的大数)——9999 会把机器拖死。
|
||||
pub const MAX_CONCURRENCY: usize = 64;
|
||||
|
||||
/// 每个源尝试次数的出厂默认(`download.retry_limit` 的 schema 默认值同源)。
|
||||
pub const DEFAULT_MAX_ATTEMPTS_PER_SOURCE: u32 = 3;
|
||||
|
||||
/// 每个源尝试次数的硬上限(`download.retry_limit` 的 schema 上界同源)。
|
||||
pub const MAX_ATTEMPTS_PER_SOURCE: u32 = 10;
|
||||
|
||||
/// 三个设置键名(层间契约):登记在 `setting::keys`,读取在宿主。只写在这里,两侧都引用常量。
|
||||
pub const KEY_CONCURRENCY: &str = "download.concurrency";
|
||||
/// 见 [`KEY_CONCURRENCY`]。
|
||||
pub const KEY_RETRY_LIMIT: &str = "download.retry_limit";
|
||||
/// gh-proxy 前缀覆盖键(三态语义见 [`gh_proxy_override`])。
|
||||
///
|
||||
/// **暂未登记**:由 M197 在 `setting::keys::proxy` 登记,尚未合入 dev;宿主读它得到 `None` ⇒
|
||||
/// [`GhProxyOverride::Builtin`](行为正确)。**M197 合入后这条断言应升级为 schema 断言**——现在
|
||||
/// 只断言常量本身,别把这条弱断言当成可以随手删的。
|
||||
pub const KEY_GH_PROXY: &str = "download.gh_proxy";
|
||||
|
||||
/// 引擎配置。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EngineConfig {
|
||||
/// 全局并发许可数(tokio `Semaphore`),默认 64。
|
||||
/// 全局并发许可数(tokio `Semaphore`),默认 [`DEFAULT_MAX_CONCURRENCY`]。
|
||||
pub max_concurrency: usize,
|
||||
/// 每个源的尝试次数上限,默认 3。
|
||||
/// 每个源的尝试次数上限,默认 [`DEFAULT_MAX_ATTEMPTS_PER_SOURCE`]。
|
||||
pub max_attempts_per_source: u32,
|
||||
/// 连接超时,默认 10s。
|
||||
pub connect_timeout: Duration,
|
||||
@@ -32,8 +66,8 @@ pub struct EngineConfig {
|
||||
impl Default for EngineConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_concurrency: 64,
|
||||
max_attempts_per_source: 3,
|
||||
max_concurrency: DEFAULT_MAX_CONCURRENCY,
|
||||
max_attempts_per_source: DEFAULT_MAX_ATTEMPTS_PER_SOURCE,
|
||||
connect_timeout: Duration::from_secs(10),
|
||||
read_timeout: Duration::from_secs(30),
|
||||
user_agent: concat!("UnluckyLauncher/", env!("CARGO_PKG_VERSION")).to_string(),
|
||||
@@ -44,3 +78,323 @@ impl Default for EngineConfig {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `download.gh_proxy` 的三种取值(三态语义见 [`gh_proxy_override`])。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub enum GhProxyOverride {
|
||||
/// 空串:**没有覆盖**,跟随内置默认(地区判定 + 镜像表里的前缀)。
|
||||
#[default]
|
||||
Builtin,
|
||||
/// 明确关掉:一个 gh-proxy 候选都不生成(`RegionProfile::with_gh_proxy(None)`)。
|
||||
Off,
|
||||
/// 用这个前缀覆盖(仍然只在镜像优先 / 国内判定时启用)。
|
||||
Prefix(String),
|
||||
}
|
||||
|
||||
/// 设置原文 → gh-proxy 三态(纯函数:`off` 与 URL 的边界在这里定死,不散落到宿主)。
|
||||
///
|
||||
/// **空串不等于关**:「我不想用加速」和「我还没决定」是两件事。空串 = 跟随内置默认,
|
||||
/// 要关必须写 `off`(`none` / `direct` / `disable` / `disabled` / `no` 这些口语词一并认,
|
||||
/// 大小写不敏感、前后空白忽略——用户拿哪个词表达「关掉」都合理,专门要求某一个会造出
|
||||
/// 「填了没生效」的坑)。
|
||||
#[must_use]
|
||||
pub fn gh_proxy_override(raw: &str) -> GhProxyOverride {
|
||||
let text = raw.trim();
|
||||
if text.is_empty() {
|
||||
return GhProxyOverride::Builtin;
|
||||
}
|
||||
let lowered = text.to_ascii_lowercase();
|
||||
if matches!(
|
||||
lowered.as_str(),
|
||||
"off" | "none" | "direct" | "disable" | "disabled" | "no"
|
||||
) {
|
||||
return GhProxyOverride::Off;
|
||||
}
|
||||
GhProxyOverride::Prefix(text.to_string())
|
||||
}
|
||||
|
||||
/// 设置侧灌进来的下载调优(M193):并发 / 每个源的尝试次数 / gh-proxy 覆盖。
|
||||
///
|
||||
/// 这是「`launcher.json` 的三个键」到「引擎装配参数」之间**唯一**的翻译层:键名由宿主读(core
|
||||
/// 不认识设置键名),默认值、合法区间、回落策略都在这里——否则「UI 说 8、引擎跑 64」会从缝里长回来。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DownloadTuning {
|
||||
/// 全局并发许可数(已夹在 `1..=MAX_CONCURRENCY`)。
|
||||
pub max_concurrency: usize,
|
||||
/// 每个源的尝试次数上限(已夹在 `1..=MAX_ATTEMPTS_PER_SOURCE`)。
|
||||
pub max_attempts_per_source: u32,
|
||||
/// gh-proxy 覆盖。
|
||||
pub gh_proxy: GhProxyOverride,
|
||||
}
|
||||
|
||||
impl Default for DownloadTuning {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_concurrency: DEFAULT_MAX_CONCURRENCY,
|
||||
max_attempts_per_source: DEFAULT_MAX_ATTEMPTS_PER_SOURCE,
|
||||
gh_proxy: GhProxyOverride::Builtin,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DownloadTuning {
|
||||
/// 设置原文 → 调优(**未填 / 越界 / 非法值一律回落出厂默认**,绝不报错阻断下载)。
|
||||
///
|
||||
/// 口径与 schema 的 `(min, max)` 同源但独立实现(用户可能手改文件绕过 `store.set`):键不存在
|
||||
/// → 出厂默认;落在区间内 → 原值;负数 / 超上限 → 出厂默认(**不夹到边界**:那是「想 9999 给
|
||||
/// 64」,界面仍会说谎)。`retry_limit` 的 `0` 在区间内,落成「只试一次」(见实现里的注释)。
|
||||
#[must_use]
|
||||
pub fn from_settings(
|
||||
concurrency: Option<i64>,
|
||||
attempts: Option<i64>,
|
||||
gh_proxy: Option<&str>,
|
||||
) -> Self {
|
||||
let default = Self::default();
|
||||
Self {
|
||||
max_concurrency: concurrency
|
||||
.and_then(|raw| usize::try_from(raw).ok())
|
||||
.filter(|raw| (1..=MAX_CONCURRENCY).contains(raw))
|
||||
.unwrap_or(default.max_concurrency),
|
||||
// `0` 是**合法值**(P2-1 复审:旧配置里真的有 0,且旧 GUI 把它当档位):语义是
|
||||
// 「只试一次 = 不重试」,所以 0 与 1 都落成 1;负数 / 超上限才回落出厂默认。
|
||||
max_attempts_per_source: attempts
|
||||
.and_then(|raw| u32::try_from(raw).ok())
|
||||
.filter(|raw| *raw <= MAX_ATTEMPTS_PER_SOURCE)
|
||||
.map_or(default.max_attempts_per_source, |raw| raw.max(1)),
|
||||
gh_proxy: gh_proxy.map_or(GhProxyOverride::Builtin, gh_proxy_override),
|
||||
}
|
||||
}
|
||||
|
||||
/// 把调优盖到 `EngineConfig` 上(其余字段保持传入值)。
|
||||
#[must_use]
|
||||
pub fn apply_to(&self, mut cfg: EngineConfig) -> EngineConfig {
|
||||
cfg.max_concurrency = self.max_concurrency;
|
||||
cfg.max_attempts_per_source = self.max_attempts_per_source;
|
||||
cfg
|
||||
}
|
||||
|
||||
/// 把 gh-proxy 覆盖盖到档案上(`Builtin` 原样返回 = 跟随地区判定与镜像表)。
|
||||
#[must_use]
|
||||
pub fn apply_gh_proxy(&self, profile: RegionProfile) -> RegionProfile {
|
||||
match &self.gh_proxy {
|
||||
GhProxyOverride::Builtin => profile,
|
||||
GhProxyOverride::Off => profile.with_gh_proxy(None),
|
||||
GhProxyOverride::Prefix(prefix) => profile.with_gh_proxy(Some(prefix)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 每个缓存根一份「宿主想要什么」([`wanted`] 用)。按数据根而不是进程级:登记处本就是「一个数据根
|
||||
/// 一个引擎」;进程级那份在多数据根时会互相踩(A 根改 2、B 根冲回 8)。
|
||||
fn wanted_slot() -> &'static Mutex<HashMap<PathBuf, DownloadTuning>> {
|
||||
static WANTED: OnceLock<Mutex<HashMap<PathBuf, DownloadTuning>>> = OnceLock::new();
|
||||
WANTED.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
/// 该数据根当前想要的调优(没灌过 = 出厂默认)。
|
||||
#[must_use]
|
||||
pub fn wanted(cache_root: &Path) -> DownloadTuning {
|
||||
wanted_slot()
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
.get(cache_root)
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// 记下该数据根想要的调优,返回**是否真的变了**(没变时调用方不该重建引擎)。
|
||||
pub fn publish(cache_root: &Path, next: DownloadTuning) -> bool {
|
||||
let mut wanted = wanted_slot()
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
// 归一化比较:「没灌过」与「灌了出厂默认」是同一件事,不能因为 `None != Some(default)`
|
||||
// 就把每次取引擎都当成「参数变了」(那会每次重建引擎、把连接池与健康度记忆全丢掉)。
|
||||
let current = wanted.get(cache_root).cloned().unwrap_or_default();
|
||||
if current == next {
|
||||
return false;
|
||||
}
|
||||
if next == DownloadTuning::default() {
|
||||
// 默认值不占格子(也顺手回收测试造的临时根)。
|
||||
wanted.remove(cache_root);
|
||||
} else {
|
||||
wanted.insert(cache_root.to_path_buf(), next);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::setting::keys::{launcher_schema, launcher_version};
|
||||
|
||||
/// **守门③**:schema 的默认值 / 区间 / 键名与 `EngineConfig::default()` / 调优默认值取同一组常量。
|
||||
/// 变异证明(只改实现侧):`EngineConfig::default()` 的并发写回 64、schema 默认值写成 16、
|
||||
/// `MAX_CONCURRENCY` 改了而 schema 仍写 64 ⇒ 各自红。
|
||||
#[test]
|
||||
fn the_schema_contract_is_single_sourced() {
|
||||
let schema = launcher_schema();
|
||||
assert_eq!(schema.version(), launcher_version());
|
||||
let cfg = EngineConfig::default();
|
||||
let tune = DownloadTuning::default();
|
||||
|
||||
// 并发 1~64(0 张许可没意义);尝试次数 **0**~10(P2-1:0 = 只试一次,旧配置里真有)。
|
||||
let contract: [(&str, i64, i64, usize); 2] = [
|
||||
(
|
||||
KEY_CONCURRENCY,
|
||||
i64::try_from(DEFAULT_MAX_CONCURRENCY).expect("装得下"),
|
||||
1,
|
||||
MAX_CONCURRENCY,
|
||||
),
|
||||
(
|
||||
KEY_RETRY_LIMIT,
|
||||
i64::from(DEFAULT_MAX_ATTEMPTS_PER_SOURCE),
|
||||
0,
|
||||
usize::try_from(MAX_ATTEMPTS_PER_SOURCE).expect("装得下"),
|
||||
),
|
||||
];
|
||||
for (key, default, min, max) in contract {
|
||||
let spec = schema.spec(key).expect("键已登记");
|
||||
assert_eq!(spec.default, serde_json::json!(default), "{key}");
|
||||
assert_eq!(
|
||||
spec.kind,
|
||||
crate::setting::spec::ValueKind::Int {
|
||||
min,
|
||||
max: i64::try_from(max).expect("装得下"),
|
||||
},
|
||||
"{key}"
|
||||
);
|
||||
}
|
||||
assert_eq!(cfg.max_concurrency, DEFAULT_MAX_CONCURRENCY);
|
||||
assert_eq!(tune.max_concurrency, DEFAULT_MAX_CONCURRENCY);
|
||||
assert_eq!(cfg.max_attempts_per_source, DEFAULT_MAX_ATTEMPTS_PER_SOURCE);
|
||||
assert_eq!(
|
||||
tune.max_attempts_per_source,
|
||||
DEFAULT_MAX_ATTEMPTS_PER_SOURCE
|
||||
);
|
||||
}
|
||||
|
||||
/// `SettingSpec.doc` 是 `&'static str`(`concat!` 插不进常量),描述里的数字只能手写——这条钉住它,
|
||||
/// 改常量不改文案就红(`catalog.rs` 会把这些 doc 当 JSON schema 的 description 发出去)。
|
||||
#[test]
|
||||
fn the_key_docs_quote_the_real_limits() {
|
||||
let schema = launcher_schema();
|
||||
let doc = |key: &str| schema.spec(key).expect("键已登记").doc;
|
||||
assert!(doc(KEY_CONCURRENCY).contains(&format!("1~{MAX_CONCURRENCY}")));
|
||||
assert!(doc(KEY_CONCURRENCY).contains(&format!("出厂 {DEFAULT_MAX_CONCURRENCY}")));
|
||||
assert!(doc(KEY_RETRY_LIMIT).contains(&format!("出厂 {DEFAULT_MAX_ATTEMPTS_PER_SOURCE}")));
|
||||
}
|
||||
|
||||
/// 未填 / 越界回落默认(不阻断下载);**`retry_limit: 0` 是合法值**,语义 = 只试一次。
|
||||
///
|
||||
/// 变异证明:把并发的区间过滤删掉 ⇒ `0` / `9999` 两条红;把尝试次数的 `.max(1)` 删掉 ⇒
|
||||
/// `0 → 1` 那条红(引擎会拿到 0 次尝试);把 `0` 判成越界 ⇒ 同样红。
|
||||
#[test]
|
||||
fn missing_or_illegal_values_fall_back_to_the_defaults() {
|
||||
assert_eq!(
|
||||
DownloadTuning::from_settings(None, None, None),
|
||||
DownloadTuning::default()
|
||||
);
|
||||
|
||||
let just_over = i64::try_from(MAX_CONCURRENCY).expect("上限装得下") + 1;
|
||||
for bad in [0_i64, -1, 9_999, just_over] {
|
||||
let tune = DownloadTuning::from_settings(Some(bad), None, None);
|
||||
assert_eq!(tune.max_concurrency, DEFAULT_MAX_CONCURRENCY, "{bad}");
|
||||
}
|
||||
// 0 与 1 都 = 只试一次:旧配置里的 0 不许回落成出厂 3(那正是「界面 0、引擎 3」)。
|
||||
for zero_or_one in [0_i64, 1] {
|
||||
let tune = DownloadTuning::from_settings(None, Some(zero_or_one), None);
|
||||
assert_eq!(tune.max_attempts_per_source, 1, "{zero_or_one}");
|
||||
}
|
||||
for bad in [-1_i64, 9_999] {
|
||||
let tune = DownloadTuning::from_settings(None, Some(bad), None);
|
||||
assert_eq!(
|
||||
tune.max_attempts_per_source,
|
||||
DEFAULT_MAX_ATTEMPTS_PER_SOURCE
|
||||
);
|
||||
}
|
||||
|
||||
let tune = DownloadTuning::from_settings(Some(2), Some(2), None);
|
||||
assert_eq!(tune.max_concurrency, 2);
|
||||
assert_eq!(tune.max_attempts_per_source, 2);
|
||||
}
|
||||
|
||||
/// 三态:**空串 ≠ 关**(这条是「设置项说假话」的高发点,M197 的键语义同源)。
|
||||
#[test]
|
||||
fn empty_gh_proxy_means_follow_builtin_not_off() {
|
||||
assert_eq!(gh_proxy_override(""), GhProxyOverride::Builtin);
|
||||
assert_eq!(gh_proxy_override(" "), GhProxyOverride::Builtin);
|
||||
for off in [
|
||||
"off", "OFF", " none ", "direct", "disable", "disabled", "no",
|
||||
] {
|
||||
assert_eq!(gh_proxy_override(off), GhProxyOverride::Off, "{off}");
|
||||
}
|
||||
assert_eq!(
|
||||
gh_proxy_override(" https://gh.example/ "),
|
||||
GhProxyOverride::Prefix("https://gh.example/".to_string())
|
||||
);
|
||||
// 未填(键不存在)也是 Builtin,不是 Off。
|
||||
assert_eq!(
|
||||
DownloadTuning::from_settings(None, None, None).gh_proxy,
|
||||
GhProxyOverride::Builtin
|
||||
);
|
||||
}
|
||||
|
||||
/// `apply_gh_proxy` 的三档真的改到档案上、并且**改到候选链上**(`expand()` 读的就是档案里那个
|
||||
/// 前缀;自定义前缀那一档的链断言在 `shared.rs`)。变异证明:`Off` 分支改成原样返回 ⇒ `off` 红。
|
||||
#[test]
|
||||
fn gh_proxy_override_reaches_the_profile_and_the_chain() {
|
||||
use crate::download::mirrors::{MirrorTable, SOURCE_GH_PROXY};
|
||||
use crate::download::types::ResourceKind;
|
||||
|
||||
let url = "https://github.com/o/r/releases/download/v1/a.jar";
|
||||
let table = MirrorTable::builtin();
|
||||
let sources = |profile: &RegionProfile| -> Vec<String> {
|
||||
table
|
||||
.expand(url, ResourceKind::Other, profile)
|
||||
.into_iter()
|
||||
.map(|candidate| candidate.source)
|
||||
.collect()
|
||||
};
|
||||
|
||||
let mirror_first = RegionProfile::mirror_first(0);
|
||||
let kept = DownloadTuning::default().apply_gh_proxy(mirror_first.clone());
|
||||
assert_eq!(kept.gh_proxy, mirror_first.gh_proxy, "Builtin 不该动档案");
|
||||
assert!(
|
||||
sources(&kept).contains(&SOURCE_GH_PROXY.to_string()),
|
||||
"出厂套前缀"
|
||||
);
|
||||
|
||||
let off = DownloadTuning::from_settings(None, None, Some("off"))
|
||||
.apply_gh_proxy(mirror_first.clone());
|
||||
assert!(
|
||||
!sources(&off).contains(&SOURCE_GH_PROXY.to_string()),
|
||||
"关了就没候选"
|
||||
);
|
||||
|
||||
let prefix = DownloadTuning::from_settings(None, None, Some("https://gh.example/"))
|
||||
.apply_gh_proxy(mirror_first);
|
||||
assert_eq!(prefix.gh_proxy.as_deref(), Some("https://gh.example/"));
|
||||
assert!(prefix.gh_proxy_enabled(), "前缀生效");
|
||||
}
|
||||
|
||||
/// 每个数据根的调优互不串味(一个根改成 2 不该改另一个根的引擎)。
|
||||
/// 变异证明:把 `wanted`/`publish` 的键改回单一进程级槽位 ⇒ 第二个根读到 2,红。
|
||||
#[test]
|
||||
fn tuning_is_per_cache_root_and_no_change_is_reported_as_such() {
|
||||
let a = PathBuf::from("/tmp/ull-tuning-a");
|
||||
let b = PathBuf::from("/tmp/ull-tuning-b");
|
||||
assert_eq!(wanted(&a), DownloadTuning::default());
|
||||
|
||||
let changed = DownloadTuning::from_settings(Some(2), None, None);
|
||||
assert!(publish(&a, changed.clone()), "真变了要报变更");
|
||||
assert_eq!(wanted(&a), changed);
|
||||
assert!(!publish(&a, changed.clone()), "同值不该报变更");
|
||||
assert_eq!(wanted(&b), DownloadTuning::default(), "别的根不受影响");
|
||||
|
||||
assert!(
|
||||
publish(&a, DownloadTuning::default()),
|
||||
"恢复默认也是一次变更"
|
||||
);
|
||||
assert_eq!(wanted(&a), DownloadTuning::default());
|
||||
}
|
||||
}
|
||||
@@ -5,18 +5,14 @@
|
||||
//! ```text
|
||||
//! 校验请求 → 内容寻址缓存直取(零网络)
|
||||
//! → 候选规划(镜像表展开 + 健康度排序)
|
||||
//! → Semaphore 限流
|
||||
//! → Semaphore 限流 ← 容量来自设置(M193,见 config.rs)
|
||||
//! → 候选源外循环(换源),每源内循环(指数退避 + 抖动)
|
||||
//! → 续传判定(保守)→ 单连接 / 多连接分块 → 流式校验 → 原子落盘
|
||||
//! → 失败则保留中间文件供下次续传
|
||||
//! ```
|
||||
//!
|
||||
//! 最后一步保留的是**本会话命名的**中间文件(`<name>.ullpart-<session>-<seq>`,不再固定
|
||||
//! 叫 `<name>.ullpart`):下次(甚至下次启动、重启后再下同一个文件)由 `PartFile::for_dest`
|
||||
//! 在目标目录里认领它并改名成新的会话名,续传判定照旧。详细身份规则见 `atomic.rs` 模块头的
|
||||
//! 「中间文件的身份」。
|
||||
//!
|
||||
//! 具体传输细节在 `transfer.rs`,错误分类在 `http.rs`,本文件只负责顺序与策略。
|
||||
//! 中间文件是本会话命名的(`<name>.ullpart-<session>-<seq>`),下次(甚至下次启动)由 `PartFile::for_dest` 认领并改名,
|
||||
//! 续传判定照旧(身份规则见 `atomic.rs` 模块头)。传输细节在 `transfer.rs`,错误分类在 `http.rs`,本文件只负责顺序与策略。
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
@@ -27,7 +23,7 @@ use tokio::sync::Semaphore;
|
||||
use super::atomic::PartFile;
|
||||
use super::backoff::Jitter;
|
||||
use super::cache::{now_ms, ContentCache};
|
||||
use super::config::EngineConfig;
|
||||
use super::config::{EngineConfig, MAX_CONCURRENCY};
|
||||
use super::error::{DownloadError, DownloadErrorKind};
|
||||
use super::health::HealthRegistry;
|
||||
use super::http;
|
||||
@@ -72,8 +68,10 @@ impl DownloadEngine {
|
||||
cache: Arc<ContentCache>,
|
||||
) -> Self {
|
||||
let http = http::build_client(&cfg).unwrap_or_else(|_| reqwest::Client::new());
|
||||
let sem = Arc::new(Semaphore::new(cfg.max_concurrency.max(1)));
|
||||
let seed = now_ms() ^ (u64::from(cfg.max_concurrency as u32) << 32);
|
||||
// 硬上限夹取(M193):手改的 launcher.json / CLI 自拼的 cfg 都可能写着 9999,在唯一构造点再夹一次。
|
||||
let permits = cfg.max_concurrency.clamp(1, MAX_CONCURRENCY);
|
||||
let sem = Arc::new(Semaphore::new(permits));
|
||||
let seed = now_ms() ^ (u64::from(u32::try_from(permits).unwrap_or(u32::MAX)) << 32);
|
||||
// 纯记账(M165):卡在构造这个点上,才统计得到绕过登记处的调用点(守卫靠 `constructions`)。
|
||||
super::shared::record_build(cache.root());
|
||||
Self {
|
||||
@@ -102,10 +100,15 @@ impl DownloadEngine {
|
||||
&self.cfg
|
||||
}
|
||||
|
||||
/// HTTP 客户端(同一条连接池与超时配置)。
|
||||
///
|
||||
/// 给「一锤子查询」用——例如 Adoptium 的版本查询(`core::game::java::api`):
|
||||
/// 没必要为了一个 GET 再造一个 client,代理/超时/证书栈都在这里统一。
|
||||
/// **真实并发闸门**此刻的许可数(探针,M193):读 [`Self::acquire`] 会去抢的那张
|
||||
/// `Semaphore`,**不是** `config().max_concurrency`。有下载在飞时偏小,空闲等于容量。
|
||||
#[must_use]
|
||||
pub fn concurrency_probe(&self) -> usize {
|
||||
self.sem.available_permits()
|
||||
}
|
||||
|
||||
/// HTTP 客户端(同一条连接池与超时配置):给「一锤子查询」用(如 Adoptium 版本查询),
|
||||
/// 没必要为了一个 GET 再造一个 client。
|
||||
#[must_use]
|
||||
pub fn client(&self) -> &reqwest::Client {
|
||||
&self.http
|
||||
@@ -243,7 +246,7 @@ impl DownloadEngine {
|
||||
}
|
||||
Err(err) => {
|
||||
// 4xx(NotFound)不算「源坏」:院校镜像只留最新补丁,请求旧补丁必然 404,
|
||||
// 记成失败会把唯一可用镜像冷却掉(M33/M34 实测:TUNA 每个目录只保 1 个归档)。
|
||||
// 记成失败会把唯一可用镜像冷却掉(M33/M34 实测:TUNA 每目录只保 1 个归档)。
|
||||
if err.kind == DownloadErrorKind::NotFound {
|
||||
self.health.record_missing(
|
||||
&candidate.source,
|
||||
@@ -347,11 +350,8 @@ impl DownloadEngine {
|
||||
})
|
||||
}
|
||||
|
||||
/// 该源此刻给几次尝试:
|
||||
///
|
||||
/// * 冷却中 → 1 次(这次就是「恢复探测」,失败会重新冷却);
|
||||
/// * 刚出冷却、还没洗白 → 也只给 1 次(`is_probe()`,别让死源每次到期都吃满 3 次);
|
||||
/// * 正常 → 配置的 `max_attempts_per_source`。
|
||||
/// 该源此刻给几次尝试:冷却中 / 刚出冷却未洗白 → 1 次(那就是恢复探测,别让死源每次到期
|
||||
/// 都吃满预算);正常 → 设置里的 `download.retry_limit`(= `max_attempts_per_source`)。
|
||||
pub(crate) fn attempts_for(&self, source: &str, kind: ResourceKind, now_ms: u64) -> u32 {
|
||||
let health = self.health.health_of(source, kind);
|
||||
if health.is_cooling_down(now_ms) || health.is_probe(now_ms) {
|
||||
|
||||
@@ -130,7 +130,8 @@ pub struct MirrorTable {
|
||||
/// Zulu 元数据(v3 新增)。
|
||||
#[serde(default)]
|
||||
pub zulu: Option<ZuluTable>,
|
||||
/// gh-proxy 类前缀。
|
||||
/// gh-proxy 类前缀(**目前无生产读者**:生效前缀的唯一真相源是 `RegionProfile.gh_proxy`;
|
||||
/// 这一列只作为 `mirrors.json` 的解析留档,别再按它做「改这里能生效」的接口)。
|
||||
#[serde(default)]
|
||||
pub gh_proxies: Vec<GhProxy>,
|
||||
}
|
||||
@@ -175,49 +176,18 @@ impl MirrorTable {
|
||||
self.rules.insert(0, rule);
|
||||
}
|
||||
|
||||
/// 覆盖 gh-proxy 前缀(设置项);传 `None` 表示不默认启用。
|
||||
pub fn set_gh_proxy(&mut self, prefix: Option<&str>) {
|
||||
match prefix {
|
||||
Some(prefix) => {
|
||||
if let Some(proxy) = self.gh_proxies.first_mut() {
|
||||
proxy.prefix = prefix.to_string();
|
||||
proxy.enabled = true;
|
||||
} else {
|
||||
self.gh_proxies.push(GhProxy {
|
||||
id: "gh-proxy".to_string(),
|
||||
name: "自定义 gh-proxy".to_string(),
|
||||
prefix: prefix.to_string(),
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
None => {
|
||||
for proxy in &mut self.gh_proxies {
|
||||
proxy.enabled = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 找到第一条命中的前缀规则。
|
||||
#[must_use]
|
||||
pub fn prefix_rule_for(&self, url: &str, kind: ResourceKind) -> Option<&PrefixRule> {
|
||||
self.rules.iter().find(|rule| rule.matches(url, kind))
|
||||
}
|
||||
|
||||
/// 清单里第一个启用的 gh-proxy 前缀(默认值来源;设置层用它初始化地区档案)。
|
||||
#[must_use]
|
||||
pub fn default_gh_proxy(&self) -> Option<&str> {
|
||||
self.gh_proxies
|
||||
.iter()
|
||||
.find(|proxy| proxy.enabled && !proxy.prefix.is_empty())
|
||||
.map(|proxy| proxy.prefix.as_str())
|
||||
}
|
||||
|
||||
/// GitHub host 且允许时套 gh-proxy 前缀。
|
||||
///
|
||||
/// **生效前缀的唯一真相源是 `region.gh_proxy`**(设置层/地区判定写入,`None` = 用户关闭)。
|
||||
/// 表格里的 `gh_proxies` 只是「已知可用前缀」清单,配合 `default_gh_proxy()` 给设置做默认值。
|
||||
/// 表格里的 `gh_proxies` 只是「已知可用前缀」的留档(无生产读者),**改它不会生效**——M193
|
||||
/// 删掉了原先那个 `MirrorTable::set_gh_proxy`,正是因为它改了这张表而 `expand()` 读的是档案,
|
||||
/// 属于「看起来能改、其实没人读」的入口。
|
||||
#[must_use]
|
||||
pub fn gh_proxy_candidates(&self, url: &str, region: &RegionProfile) -> Vec<Candidate> {
|
||||
if region.mode != RegionMode::MirrorFirst {
|
||||
|
||||
@@ -32,23 +32,23 @@
|
||||
//! 只 forget 的话它会一直用旧实例(含旧地区档案)直到进程结束。所以「重新检测地区」那条
|
||||
//! 设置动作除了 [`forget`] 还调 `CoreHost::refresh_update_factory()` 把新引擎接过去
|
||||
//! (M165 复审 P2-1:这是本模块文档原先说漏的一半)。
|
||||
//! * **并发多少**:`EngineConfig::default().max_concurrency`(64)现在是**进程级**上限,
|
||||
//! 不再是「每请求 64」。单请求独占时与从前一致;多请求并发时它们分摊这 64 张许可。
|
||||
//! * **并发多少**(M193):容量来自设置 `download.concurrency`(出厂 8、硬上限 64,见 [`set_tuning`]),
|
||||
//! 而且是**进程级**上限——多请求并发时分摊这批许可,不是「每请求 8」。
|
||||
//! * **镜像切换 / 失效**:一律走引擎原有的候选规划 + 健康度冷却,本模块不参与判定;唯一可见的
|
||||
//! 差别是健康度记忆现在真的跨请求累积(这本就是它存在的理由)。
|
||||
//!
|
||||
//! # 与 [`super::blocking::block_on`] 的绑定关系
|
||||
//!
|
||||
//! `reqwest` 的连接池绑在**驱动连接的运行时**上:引擎复用了而每次 `block_on` 都新起一个运行时,
|
||||
//! 池里的空闲连接就属于已经销毁的运行时,复用会退化成「拿到的都是死连接」。所以引擎复用必须
|
||||
//! 配运行时常驻(`blocking` 模块那头一并改成进程内唯一运行时)。两者是同一件事的两半。
|
||||
//! 池里的空闲连接就属于已销毁的运行时,复用会退化成「拿到的都是死连接」。所以引擎复用必须配
|
||||
//! 运行时常驻(`blocking` 那一头一并改成进程内唯一运行时)。两者是同一件事的两半。
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
use super::cache::{now_ms, ContentCache};
|
||||
use super::config::EngineConfig;
|
||||
use super::config::{publish, wanted, DownloadTuning, EngineConfig};
|
||||
use super::engine::DownloadEngine;
|
||||
use super::health::HealthRegistry;
|
||||
use super::mirrors::MirrorTable;
|
||||
@@ -102,9 +102,8 @@ pub fn engine_for(cache_root: impl AsRef<Path>) -> Arc<DownloadEngine> {
|
||||
/// 让该数据根的共享引擎失效(下次 [`engine_for`] 重新构造)。
|
||||
///
|
||||
/// **只让登记处不再持有那一份**:已经拿到 `Arc` 的人继续用旧的。短命请求自然结束;长命持有者
|
||||
/// (presenter 的自更新 `Factory`)必须自己重建,否则它会一直用旧实例——本仓在「重新检测地区」
|
||||
/// 的设置动作里调 `CoreHost::refresh_update_factory()` 补上这一步(见模块文档「`forget` 到什么范围」)。
|
||||
/// 返回是否真淘汰了一份。
|
||||
/// (presenter 的自更新 `Factory`)必须自己重建——「重新检测地区」那条设置动作就补调了
|
||||
/// `CoreHost::refresh_update_factory()`。返回是否真淘汰了一份。
|
||||
pub fn forget(cache_root: impl AsRef<Path>) -> bool {
|
||||
registry()
|
||||
.lock()
|
||||
@@ -116,9 +115,7 @@ pub fn forget(cache_root: impl AsRef<Path>) -> bool {
|
||||
/// 记一笔「这个缓存根上又造了一个引擎」。
|
||||
///
|
||||
/// **唯一调用点是 [`DownloadEngine::new`]**(不是 [`engine_for`]):记账卡在构造这个卡口上,
|
||||
/// 于是 [`constructions`] 统计的是**进程里真实发生过几次构造**,与「谁造的」无关。
|
||||
/// 若只在 `engine_for` 里记账,任何绕过登记处、自己 `DownloadEngine::new` 的调用点都统计不到
|
||||
/// (M165 复审备注 2 的诉求)。
|
||||
/// [`constructions`] 才是「进程里真实构造过几次」,与谁造的无关(M165 复审备注 2)。
|
||||
pub(crate) fn record_build(cache_root: &Path) {
|
||||
*ledger()
|
||||
.lock()
|
||||
@@ -127,10 +124,8 @@ pub(crate) fn record_build(cache_root: &Path) {
|
||||
.or_default() += 1;
|
||||
}
|
||||
|
||||
/// 该数据根在本进程里**实际构造过几次引擎**(不论谁构造的)。
|
||||
///
|
||||
/// 这是给守卫与诊断用的:跨请求复用成立时,同一个根无论被请求多少次,这个数都停在 `1`
|
||||
/// (`forget` 之后每重建一次 +1)。只断言「结果一样」测不出复用——引擎建几次都能下同一个文件。
|
||||
/// 该数据根在本进程里**实际构造过几次引擎**(不论谁构造的):复用成立时停在 `1`(`forget`
|
||||
/// 之后每重建一次 +1)。只断言「结果一样」测不出复用——引擎建几次都能下同一个文件。
|
||||
#[must_use]
|
||||
pub fn constructions(cache_root: impl AsRef<Path>) -> usize {
|
||||
ledger()
|
||||
@@ -141,9 +136,13 @@ pub fn constructions(cache_root: impl AsRef<Path>) -> usize {
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// 真实构造(**唯一**一处 `DownloadEngine::new` 的宿主侧装配)。
|
||||
/// 真实构造(**presenter 侧**唯一一处 `DownloadEngine::new` 的宿主侧装配;CLI 还有三处自建,
|
||||
/// 见 `docs/download-settings.md` §3 的「哪些路径吃这套设置」)。装配参数从该数据根的 [`wanted`] 取,
|
||||
/// 不用裸 `EngineConfig::default()`(M193):并发 → `Semaphore` 容量、尝试次数 → `attempts_for()`
|
||||
/// 上限、gh_proxy → 地区档案(`expand()` 只认档案里那个前缀)。值由宿主经 [`set_tuning`] 灌入。
|
||||
fn build(cache_root: &Path) -> DownloadEngine {
|
||||
let now = now_ms();
|
||||
let tune = wanted(cache_root);
|
||||
let region_path = cache_root.join("region.json");
|
||||
let region = load_cached(®ion_path, now).unwrap_or_else(|| {
|
||||
let profile = decide_from(&detect_signals(), now);
|
||||
@@ -151,19 +150,35 @@ fn build(cache_root: &Path) -> DownloadEngine {
|
||||
profile
|
||||
});
|
||||
DownloadEngine::new(
|
||||
EngineConfig::default(),
|
||||
tune.apply_to(EngineConfig::default()),
|
||||
Arc::new(MirrorTable::builtin()),
|
||||
Arc::new(region),
|
||||
Arc::new(tune.apply_gh_proxy(region)),
|
||||
Arc::new(HealthRegistry::open_in(cache_root, now)),
|
||||
Arc::new(ContentCache::open(cache_root)),
|
||||
)
|
||||
}
|
||||
|
||||
/// 换该数据根的下载调优(宿主取引擎前 / 设置变更时调用),返回**是否真的变了**。变了就清掉该根的
|
||||
/// 登记项,下次 [`engine_for`] 按新参数重建——否则「改了设置要重启才生效」。已拿到 `Arc` 的长命
|
||||
/// 持有者继续用旧的(与 [`forget`] 同一条边界);自更新 `Factory` 由 dispatch 侧换引擎。
|
||||
/// **锁序**:`publish` 的锁先释放再拿登记处的锁,[`build`] 反之——两段不嵌套。
|
||||
pub fn set_tuning(cache_root: impl AsRef<Path>, next: DownloadTuning) -> bool {
|
||||
let key = key_of(cache_root.as_ref());
|
||||
if !publish(&key, next) {
|
||||
return false;
|
||||
}
|
||||
let _ = registry()
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
.remove(&key);
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// 每个用例一个独立数据根(登记处是进程级的,键撞了会互相干扰)。
|
||||
/// 每个用例一个独立数据根(登记处与调优都是进程级、按根存,键撞了会互相干扰)。
|
||||
fn root(tag: &str) -> PathBuf {
|
||||
static SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
||||
let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
@@ -308,4 +323,74 @@ mod tests {
|
||||
assert_eq!(conns, 1, "两次下载应复用同一条连接");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// **守门①②**:设置说 2 ⇒ 引擎**真实的并发闸门**就是 2(探针读 `Semaphore` 而非配置);gh-proxy
|
||||
/// 三档也真的改变 `plan()` 的候选链;`retry_limit = 0` 落成「只试一次」。
|
||||
///
|
||||
/// 变异证明(只改实现侧):① `build` 换回裸 `EngineConfig::default()` ⇒ 探针 8 红;②
|
||||
/// `apply_gh_proxy(region)` 换回 `region` ⇒ `off` 档仍有候选,红;③ 删 `set_tuning` 的
|
||||
/// `remove` ⇒ 换参数后交回旧引擎,红。gh-proxy 的端到端(schema 键 → 调优)等 M197 登记该键。
|
||||
#[test]
|
||||
fn the_setting_and_the_gh_proxy_override_reach_the_engine() {
|
||||
use super::super::types::{DownloadRequest, ResourceKind};
|
||||
use super::super::{config, RegionProfile};
|
||||
|
||||
let dir = root("tune");
|
||||
// 镜像优先的档案:gh-proxy 只在这个模式下才生成候选。
|
||||
store_cached(
|
||||
&dir.join("region.json"),
|
||||
&RegionProfile::mirror_first(now_ms()),
|
||||
)
|
||||
.expect("地区档案");
|
||||
let req = DownloadRequest::new(
|
||||
ResourceKind::Other,
|
||||
"https://github.com/o/r/releases/download/v1/a.jar",
|
||||
dir.join("a.jar"),
|
||||
);
|
||||
let chain = |dir: &Path| -> Vec<String> {
|
||||
engine_for(dir)
|
||||
.plan(&req, 0)
|
||||
.into_iter()
|
||||
.map(|c| c.url)
|
||||
.collect()
|
||||
};
|
||||
let set = |c: Option<i64>, a: Option<i64>, g: &str| {
|
||||
set_tuning(&dir, DownloadTuning::from_settings(c, a, Some(g)))
|
||||
};
|
||||
|
||||
set(Some(2), Some(1), "off");
|
||||
let engine = engine_for(&dir);
|
||||
assert_eq!(engine.concurrency_probe(), 2, "闸门必须真是 2 张许可");
|
||||
let off = chain(&dir);
|
||||
assert!(!off.iter().any(|u| u.contains("gh-proxy")), "{off:?}");
|
||||
|
||||
set(Some(5), None, "https://gh.example/");
|
||||
let rebuilt = engine_for(&dir);
|
||||
assert!(!Arc::ptr_eq(&engine, &rebuilt), "参数变了必须换引擎");
|
||||
assert_eq!(rebuilt.concurrency_probe(), 5, "新闸门吃新值");
|
||||
let over = chain(&dir);
|
||||
assert!(
|
||||
over.iter().any(|u| u.starts_with("https://gh.example/")),
|
||||
"{over:?}"
|
||||
);
|
||||
|
||||
// P2-1:`retry_limit = 0`(旧配置里真有)不许回落出厂 3。硬上限:塞 9999 也只开 64。
|
||||
set(Some(8), Some(0), "");
|
||||
let zero = engine_for(&dir);
|
||||
assert_eq!(zero.config().max_attempts_per_source, 1);
|
||||
assert_eq!(
|
||||
zero.attempts_for("official", ResourceKind::Other, now_ms()),
|
||||
1
|
||||
);
|
||||
let wild = DownloadTuning {
|
||||
max_concurrency: 9_999,
|
||||
..Default::default()
|
||||
};
|
||||
set_tuning(&dir, wild);
|
||||
assert_eq!(
|
||||
engine_for(&dir).concurrency_probe(),
|
||||
config::MAX_CONCURRENCY
|
||||
);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
//! 诊断包(M187):把一次启动失败的现场打成一个 zip 交给用户。
|
||||
//!
|
||||
//! ## 为什么不只是「打开日志文件夹」
|
||||
//!
|
||||
//! 用户实测(2026-11-08):「MC 启动失败『游戏崩溃 退出码 1』,找半天没找到崩溃日志」。
|
||||
//! 现场散在四处——启动器日志(`.ULL/logs/`)、会话事件流 JSONL(`data/agent/sessions/game/`)、
|
||||
//! 游戏自己写的 `crash-reports/` 与 `logs/latest.log`、还有环境(系统 / Java / 命令行)。
|
||||
//! 让用户自己去四个目录里拼,等于没修。打成一个包 + 一行清单,才是「能发给别人看」的东西。
|
||||
//!
|
||||
//! ## 三条纪律
|
||||
//!
|
||||
//! 1. **落盘即脱敏**(`launch::redact` 的唯一一份规则):主目录 → `<home>`,token → 占位符,
|
||||
//! 长不透明串(base64/hex)与带点 token(JWT 形状)同样替换;调用方另外把**会话 id**
|
||||
//! 之类也要抹掉的串塞进 [`BundleSpec::secrets`]。
|
||||
//! 2. **缺什么要写出来**:`crash-reports/` 不存在不是错误——那是「游戏没来得及写」的**证据**,
|
||||
//! 清单里必须有一行说清楚,不许静默漏掉。见 [`BundleSpec::missing`]。
|
||||
//! 3. **有上限**:单个文件最多收 [`MAX_ENTRY_BYTES`],包内总字节最多 [`MAX_TOTAL_BYTES`]。
|
||||
//! 超了就在清单里记一行「已截断」。数百 MB 的 `latest.log` 不该把导出卡成十秒。
|
||||
//!
|
||||
//! 不做的事:不判断崩溃原因(那是诊断引擎 `diagnose` 的活),不改任何图片之外的二进制,
|
||||
//! 不联网。打包只读盘。
|
||||
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::redact::Redactor;
|
||||
|
||||
/// 单个文件最多收多少字节(超出按**头 + 尾**截断,清单里记「已截断」)。
|
||||
///
|
||||
/// 为什么不是「只收头部」:崩溃证据在**尾部**——会话 JSONL 的最后几行是 `exit` 事件与
|
||||
/// stderr,`latest.log` 的栈也在尾。只留头部的包等于「证据是残的,而用户不知道」。
|
||||
pub const MAX_ENTRY_BYTES: u64 = 4 * 1024 * 1024;
|
||||
/// 截断时头部保留的字节(另一半点给尾部)。
|
||||
pub const HEAD_BYTES: u64 = MAX_ENTRY_BYTES / 2;
|
||||
/// 整个包最多收多少字节(超出后剩余文件只记清单、不收内容)。
|
||||
pub const MAX_TOTAL_BYTES: u64 = 48 * 1024 * 1024;
|
||||
|
||||
/// 一个要进包的文件。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BundleEntry {
|
||||
/// 包内分组(`launcher` / `session` / `game` / `instance`),决定包内目录。
|
||||
pub group: &'static str,
|
||||
/// 源文件绝对路径。
|
||||
pub path: PathBuf,
|
||||
/// 包内固定文件名(`None` = 用源文件名)。
|
||||
///
|
||||
/// 为什么要能钉死:会话 JSONL 的源文件名就是**会话 id**(`<uuid>.jsonl`),
|
||||
/// 拿它当条目名要么泄漏 id、要么让「清单里的名字」与「包里的名字」对不上。钉成
|
||||
/// `session.jsonl` 两个问题一起消失(见 [`BundleSpec::entry_names`])。
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
impl BundleEntry {
|
||||
/// 建一条(包内名 = 源文件名)。
|
||||
pub fn new(group: &'static str, path: impl Into<PathBuf>) -> Self {
|
||||
Self {
|
||||
group,
|
||||
path: path.into(),
|
||||
name: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 建一条并**钉死包内文件名**(会话 JSONL、`latest.log` 这类有唯一身份的用它)。
|
||||
pub fn named(group: &'static str, path: impl Into<PathBuf>, name: &str) -> Self {
|
||||
Self {
|
||||
group,
|
||||
path: path.into(),
|
||||
name: Some(name.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次导出的输入(全部是事实,不含任何结论)。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct BundleSpec {
|
||||
/// 实例名。
|
||||
pub instance: String,
|
||||
/// 版本 id。
|
||||
pub version: String,
|
||||
/// 会话 id(**同时会被脱敏**)。
|
||||
pub session: String,
|
||||
/// 退出码(`None` = 进程没起来)。
|
||||
pub exit_code: Option<i32>,
|
||||
/// 退出判定 slug(`application_error` / `jvm_error` / `normal` …)。
|
||||
pub exit_type: String,
|
||||
/// 存活毫秒数。
|
||||
pub uptime_ms: u64,
|
||||
/// 脱敏后的命令行(**调用方**已经过 [`Redactor::scrub_all`])。
|
||||
pub command: String,
|
||||
/// Java 一行说明(路径 + 主版本)。
|
||||
pub java: String,
|
||||
/// 系统一行说明(`OsInfo::summary_line`)。
|
||||
pub system: String,
|
||||
/// 额外说明(例如「本次没有生成 crash-report」)。
|
||||
pub notes: Vec<String>,
|
||||
/// 明确不存在的现场(清单里写成「没有:…」)。
|
||||
pub missing: Vec<String>,
|
||||
/// 要收的文件。
|
||||
pub entries: Vec<BundleEntry>,
|
||||
/// 除了主目录之外还要抹掉的串(会话 id 等)。
|
||||
pub secrets: Vec<String>,
|
||||
/// 用户主目录(`None` = 不抹)。
|
||||
pub home: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl BundleSpec {
|
||||
/// 清单文本(`manifest.txt`;导出前会再过一遍脱敏)。
|
||||
///
|
||||
/// 这是「自算条目名」的包装(测试与单独看清单用);真正落盘时用的是
|
||||
/// [`Self::header`] + 收集循环里长出来的行,保证与包内条目一字不差。
|
||||
#[must_use]
|
||||
pub fn manifest(&self) -> String {
|
||||
let rows: Vec<String> = self
|
||||
.entry_names()
|
||||
.iter()
|
||||
.zip(&self.entries)
|
||||
.map(|(name, entry)| format!("- {name} <- {}", entry.path.display()))
|
||||
.collect();
|
||||
// 对外出口一律脱敏(r2 复审 P3-6):不给「谁调谁泄漏」的口子。
|
||||
self.redactor().scrub(&self.header(&rows)).into_owned()
|
||||
}
|
||||
|
||||
/// 清单的**事实段 + files 段**(files 段由调用方给,行内已是最终条目名)。
|
||||
fn header(&self, rows: &[String]) -> String {
|
||||
let mut text = String::new();
|
||||
text.push_str("Unlucky Launcher — launch failure bundle\n");
|
||||
text.push_str("=======================================\n");
|
||||
text.push_str(&format!("instance: {}\n", self.instance));
|
||||
text.push_str(&format!("version: {}\n", self.version));
|
||||
text.push_str(&format!("session: {}\n", self.session));
|
||||
text.push_str(&format!(
|
||||
"exit_code: {}\n",
|
||||
self.exit_code
|
||||
.map(|code| code.to_string())
|
||||
.unwrap_or_else(|| "(none)".to_string())
|
||||
));
|
||||
text.push_str(&format!("exit_type: {}\n", self.exit_type));
|
||||
text.push_str(&format!("uptime: {:.1}s\n", self.uptime_ms as f64 / 1000.0));
|
||||
text.push_str(&format!("os: {}\n", self.system));
|
||||
text.push_str(&format!("java: {}\n", self.java));
|
||||
text.push_str(&format!("command: {}\n", self.command));
|
||||
|
||||
if !self.notes.is_empty() {
|
||||
text.push_str("\nnotes\n-----\n");
|
||||
for note in &self.notes {
|
||||
text.push_str(&format!("- {note}\n"));
|
||||
}
|
||||
}
|
||||
if !self.missing.is_empty() {
|
||||
text.push_str("\nmissing (不存在)\n----------------\n");
|
||||
for item in &self.missing {
|
||||
text.push_str(&format!("- {item}\n"));
|
||||
}
|
||||
}
|
||||
|
||||
text.push_str("\nfiles\n-----\n");
|
||||
// 清单里的名字**必须**与包里的条目一一对应(r1 复审 P2-2 就是这里对不上):
|
||||
// 两边都走同一份解析(`rows` 由调用方给出,行内已是最终条目名):
|
||||
// 清单与 zip 不许各算一遍 —— 各算一遍就是 r1 复审 P2-2。
|
||||
for row in rows {
|
||||
text.push_str(row);
|
||||
text.push('\n');
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
/// 包内条目名(与 `entries` 一一对应;**名字也过脱敏**)。
|
||||
///
|
||||
/// 名字与内容分开治:内容走 `redactor` 的整套规则,名字走同一份 redactor 的 token 规则。
|
||||
/// **两边都以返回值为准**——清单与 zip 都读这一份,不许各算一遍(各算一遍就是 P2-2)。
|
||||
#[must_use]
|
||||
pub fn entry_names(&self) -> Vec<String> {
|
||||
let redactor = self.redactor();
|
||||
self.entries
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
let raw = entry.name.clone().unwrap_or_else(|| file_name(&entry.path));
|
||||
let safe = redactor.scrub(&raw).into_owned();
|
||||
format!("{}/{}", entry.group, safe)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn redactor(&self) -> Redactor {
|
||||
let mut redactor = Redactor::new();
|
||||
if let Some(home) = &self.home {
|
||||
redactor = redactor.with_home(home);
|
||||
}
|
||||
for secret in &self.secrets {
|
||||
redactor = redactor.with_secret(secret.clone());
|
||||
}
|
||||
redactor
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次导出的结果。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Bundle {
|
||||
/// 落盘路径。
|
||||
pub path: PathBuf,
|
||||
/// 字节数(**落盘后**的真实大小)。
|
||||
pub bytes: u64,
|
||||
/// 收进包的文件数(不含清单)。
|
||||
pub files: usize,
|
||||
/// 被截断 / 因总量上限没收内容的文件数。
|
||||
pub truncated: usize,
|
||||
}
|
||||
|
||||
impl Bundle {
|
||||
/// 一行说明(Toast 与日志共用)。
|
||||
pub fn describe(&self) -> String {
|
||||
format!(
|
||||
"{} ({} bytes, {} files, {} truncated)",
|
||||
self.path.display(),
|
||||
self.bytes,
|
||||
self.files,
|
||||
self.truncated
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 把 `spec` 打成一个 zip 写到 `target`(父目录不存在会建)。
|
||||
///
|
||||
/// # Errors
|
||||
/// 目录建不出来、zip 写失败、清单写失败时返回人话(导出失败要能一眼看出哪一步断)。
|
||||
pub fn write_bundle(spec: &BundleSpec, target: &Path) -> Result<Bundle, String> {
|
||||
let redactor = spec.redactor();
|
||||
|
||||
if let Some(parent) = target.parent() {
|
||||
if !parent.as_os_str().is_empty() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|error| format!("建目录 {} 失败:{error}", parent.display()))?;
|
||||
}
|
||||
}
|
||||
let file = File::create(target)
|
||||
.map_err(|error| format!("建诊断包 {} 失败:{error}", target.display()))?;
|
||||
let mut zip = zip::ZipWriter::new(file);
|
||||
let options =
|
||||
zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||
|
||||
// 名字与清单**先在同一个循环里长出来**:`files` 段只列真进了包的条目,
|
||||
// 谁被跳过、谁被截断都在 `collected` 段写明(r1 复审 P2-2 的修法)。
|
||||
let names = spec.entry_names();
|
||||
let mut listed: Vec<String> = Vec::new();
|
||||
let mut collected: Vec<String> = Vec::new();
|
||||
let mut total: u64 = 0;
|
||||
let mut files = 0usize;
|
||||
let mut truncated = 0usize;
|
||||
let mut used: Vec<String> = Vec::new();
|
||||
|
||||
for (index, entry) in spec.entries.iter().enumerate() {
|
||||
let name = unique_name(&mut used, &names[index]);
|
||||
let Ok(bytes) = std::fs::read(&entry.path) else {
|
||||
collected.push(format!("- {name}: 读不出来(已跳过)"));
|
||||
continue;
|
||||
};
|
||||
let original_len = bytes.len();
|
||||
// **唯一的通路**:解码 → 脱敏 → 按字符边界截断(见 `clip.rs` 的模块文档)。
|
||||
// 没有「二进制原样」分支,所以也不存在「截坏一个字就整份跳过脱敏」的旁路
|
||||
// (r2 复审 P2-1)。`lossy` 保证任何字节序列都能进脱敏器。
|
||||
let text = String::from_utf8_lossy(&bytes).into_owned();
|
||||
let scrubbed = redactor.scrub(&text).into_owned();
|
||||
let budget = MAX_TOTAL_BYTES.saturating_sub(total).min(MAX_ENTRY_BYTES);
|
||||
let mut kept = scrubbed;
|
||||
if kept.len() as u64 > budget {
|
||||
let Some(clipped) = clip_text(&kept, budget) else {
|
||||
collected.push(format!("- {name}: 超出总量上限,未收内容"));
|
||||
truncated += 1;
|
||||
continue;
|
||||
};
|
||||
kept = clipped;
|
||||
collected.push(format!(
|
||||
"- {name}: 已截断(收 {} B,原 {original_len} B)",
|
||||
kept.len()
|
||||
));
|
||||
truncated += 1;
|
||||
}
|
||||
let body = kept.into_bytes();
|
||||
total += body.len() as u64;
|
||||
files += 1;
|
||||
listed.push(format!("- {name} <- {}", entry.path.display()));
|
||||
write_entry(&mut zip, &name, &body, options)?;
|
||||
}
|
||||
|
||||
let mut manifest = spec.header(&listed);
|
||||
if !collected.is_empty() {
|
||||
manifest.push_str("\ncollected\n---------\n");
|
||||
for line in collected {
|
||||
manifest.push_str(&line);
|
||||
manifest.push('\n');
|
||||
}
|
||||
}
|
||||
write_entry(
|
||||
&mut zip,
|
||||
"manifest.txt",
|
||||
redactor.scrub(&manifest).as_bytes(),
|
||||
options,
|
||||
)?;
|
||||
zip.finish()
|
||||
.map_err(|error| format!("收尾诊断包失败:{error}"))?;
|
||||
|
||||
let bytes = std::fs::metadata(target)
|
||||
.map(|meta| meta.len())
|
||||
.unwrap_or(0);
|
||||
Ok(Bundle {
|
||||
path: target.to_path_buf(),
|
||||
bytes,
|
||||
files,
|
||||
truncated,
|
||||
})
|
||||
}
|
||||
|
||||
fn write_entry(
|
||||
zip: &mut zip::ZipWriter<File>,
|
||||
name: &str,
|
||||
body: &[u8],
|
||||
options: zip::write::SimpleFileOptions,
|
||||
) -> Result<(), String> {
|
||||
zip.start_file(name, options)
|
||||
.map_err(|error| format!("写入 {name} 失败:{error}"))?;
|
||||
zip.write_all(body)
|
||||
.map_err(|error| format!("写入 {name} 失败:{error}"))
|
||||
}
|
||||
|
||||
fn file_name(path: &Path) -> String {
|
||||
path.file_name()
|
||||
.map(|name| name.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "file".to_string())
|
||||
}
|
||||
|
||||
/// 包内重名时在**目录名后**加序号(`game/latest.log` 与 `game/2-latest.log` 不会互相覆盖)。
|
||||
fn unique_name(used: &mut Vec<String>, name: &str) -> String {
|
||||
let base = name.to_string();
|
||||
if !used.contains(&base) {
|
||||
used.push(base.clone());
|
||||
return base;
|
||||
}
|
||||
let (group, file) = base.split_once('/').unwrap_or(("", base.as_str()));
|
||||
for index in 2.. {
|
||||
let candidate = format!("{group}/{index}-{file}");
|
||||
if !used.contains(&candidate) {
|
||||
used.push(candidate.clone());
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
unreachable!("序号一定找得到空位")
|
||||
}
|
||||
|
||||
/// 默认落点:启动器日志分区下的 `exports/`。
|
||||
///
|
||||
/// 为什么不弹文件选择框:本仓没有 `rfd` 这类原生对话框依赖(Cargo.toml 不在本 mission scope),
|
||||
/// 也不该为一次导出引一棵新依赖树。导出后界面把**完整路径**与字节数都给出来,另有
|
||||
/// 「打开文件夹」按钮直达——用户不用猜。
|
||||
pub fn default_target_dir(logs_dir: &Path) -> PathBuf {
|
||||
logs_dir.join("exports")
|
||||
}
|
||||
|
||||
mod clip;
|
||||
|
||||
use clip::clip_text;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,138 @@
|
||||
//! 超限文件的**文本流**截断(头 + 尾),切点落在字符/行边界上。
|
||||
//!
|
||||
//! ## 为什么截断必须发生在「文本流」里,而不是字节上
|
||||
//!
|
||||
//! 上一版在**字节**上切两刀(头 2 MiB / 尾 2 MiB),切完才试 `String::from_utf8`
|
||||
//! 决定要不要脱敏。中文与 emoji 是 3~4 字节,只要切点落在字中间,`from_utf8` 就失败,
|
||||
//! 于是**整份文件退回「原样字节」分支、一条脱敏规则都没跑**(r2 复审 P2-1 抓到的隐私旁路:
|
||||
//! 长会话的 `latest.log` 与会话 JSONL 正好是最容易超大、也最该脱敏的两个文件;
|
||||
//! 中文 Windows 用户名让家目录串本身就是 CJK,切在它附近必然命中)。
|
||||
//!
|
||||
//! 现在这条通路只有一种形态:**解码 → 脱敏 → 按字符边界截断**。没有「二进制原样」这条分支,
|
||||
//! 也就没有旁路。
|
||||
//!
|
||||
//! ## 为什么先脱敏再截断(而不是先截断再脱敏)
|
||||
//!
|
||||
//! 顺序反过来会在切点留下**半截 secret**:被切开的前缀不再匹配任何规则,于是以原文形态留在
|
||||
//! 包里。先脱敏就没有这个问题——整串已经变成占位符,截断只能切占位符。
|
||||
//!
|
||||
//! 代价:要先把整份文本过一遍脱敏器。导出是低频人工动作、上限 4 MiB,代价可以忽略。
|
||||
|
||||
/// 在**字符边界**上截断,保留头部与尾部,中间插一行说明。
|
||||
///
|
||||
/// 返回 `None` 表示不需要截(预算足够或预算为 0)。
|
||||
/// 返回值里的 `String` 已按预算裁好;调用方负责把 `truncated` 记进清单。
|
||||
pub(super) fn clip_text(text: &str, budget: u64) -> Option<String> {
|
||||
let budget = usize::try_from(budget).unwrap_or(usize::MAX);
|
||||
if budget == 0 || text.len() <= budget {
|
||||
return None;
|
||||
}
|
||||
|
||||
let head_budget = usize::try_from(super::HEAD_BYTES)
|
||||
.unwrap_or(usize::MAX)
|
||||
.min(budget / 2);
|
||||
let tail_budget = budget - head_budget;
|
||||
|
||||
let head_end = snap_back(text, head_budget);
|
||||
let tail_start = snap_forward(text, text.len() - tail_budget);
|
||||
if tail_start <= head_end {
|
||||
return None;
|
||||
}
|
||||
|
||||
let dropped = tail_start - head_end;
|
||||
Some(format!(
|
||||
"{}\n<truncated {dropped} bytes>\n{}",
|
||||
&text[..head_end],
|
||||
&text[tail_start..]
|
||||
))
|
||||
}
|
||||
|
||||
/// 从 `index` 向左找最近的**行首**,找不到就在字符边界上回退。
|
||||
///
|
||||
/// 行边界只为可读性:切在一条日志行中间,读的人会以为那份日志本身是坏的。
|
||||
fn snap_back(text: &str, index: usize) -> usize {
|
||||
let index = floor_char(text, index);
|
||||
let window = floor_char(text, index.saturating_sub(200));
|
||||
match text[window..index].rfind('\n') {
|
||||
Some(newline) => window + newline + 1,
|
||||
None => index,
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 `index` 向右找最近的**行首**,找不到就在字符边界上前进。
|
||||
fn snap_forward(text: &str, index: usize) -> usize {
|
||||
let index = ceil_char(text, index);
|
||||
let window = ceil_char(text, (index + 200).min(text.len()));
|
||||
match text[index..window].find('\n') {
|
||||
Some(newline) => index + newline + 1,
|
||||
None => index,
|
||||
}
|
||||
}
|
||||
|
||||
/// 向左回退到最近的字符边界(`str::floor_char_boundary` 仍是 nightly)。
|
||||
fn floor_char(text: &str, index: usize) -> usize {
|
||||
let mut at = index.min(text.len());
|
||||
while at > 0 && !text.is_char_boundary(at) {
|
||||
at -= 1;
|
||||
}
|
||||
at
|
||||
}
|
||||
|
||||
/// 向右前进到最近的字符边界。
|
||||
fn ceil_char(text: &str, index: usize) -> usize {
|
||||
let mut at = index.min(text.len());
|
||||
while at < text.len() && !text.is_char_boundary(at) {
|
||||
at += 1;
|
||||
}
|
||||
at
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{clip_text, snap_back, snap_forward};
|
||||
use crate::launch::crash_bundle::MAX_ENTRY_BYTES;
|
||||
|
||||
/// 切点必须落在字符边界上(否则下游 `str` 切片会直接 panic)。
|
||||
#[test]
|
||||
fn cuts_land_on_char_boundaries() {
|
||||
// 每个 CJK 字 3 字节:把边界刻意放在字的中间
|
||||
let text = "中".repeat(2000);
|
||||
let clipped = clip_text(&text, 3 * 500 + 1).expect("应当截断");
|
||||
assert!(clipped.contains("<truncated "), "{clipped:.80}");
|
||||
assert!(!clipped.contains('\u{fffd}'), "不许出现替换字符");
|
||||
// 两段都是合法 UTF-8 边界 ⇒ 能再按 `str` 切
|
||||
let head = clipped.split("<truncated ").next().unwrap_or_default();
|
||||
assert!(head.chars().all(|ch| ch == '中' || ch == '\n'), "{head:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn head_and_tail_are_both_kept_and_snapped_to_lines() {
|
||||
let mut text = String::new();
|
||||
while text.len() < usize::try_from(MAX_ENTRY_BYTES).expect("常数") + 128 {
|
||||
text.push_str("2026-11-08 line with some words\n");
|
||||
}
|
||||
text.push_str("{\"kind\":\"exit\"}\n");
|
||||
let clipped = clip_text(&text, MAX_ENTRY_BYTES).expect("应当截断");
|
||||
assert!(clipped.starts_with("2026-11-08 line"), "头部丢了");
|
||||
assert!(clipped.ends_with("{\"kind\":\"exit\"}\n"), "尾部丢了");
|
||||
assert!(
|
||||
clipped.len() as u64 <= MAX_ENTRY_BYTES + 64,
|
||||
"截断后不该超出预算({} > {})",
|
||||
clipped.len(),
|
||||
MAX_ENTRY_BYTES
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_files_are_not_touched() {
|
||||
assert!(clip_text("short", 1024).is_none());
|
||||
assert!(clip_text("short", 0).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapping_prefers_line_bounds() {
|
||||
let text = "aaaa\nbbbb\ncccc";
|
||||
assert_eq!(snap_back(text, 7), 5, "应回退到「bbbb」行首");
|
||||
assert_eq!(snap_forward(text, 5), 10, "应前进到「cccc」行首");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
//! 诊断包的断言:清单齐全、脱敏生效、缺件写明白、超大文件截断。
|
||||
//!
|
||||
//! 为什么这些值得单开文件:守卫的三条(内容有清单 / 脱敏生效 / 没 crash-report 也能导出)
|
||||
//! 都要**真读回 zip** 才作数——只断言 `write_bundle` 返回 `Ok` 抓不住任何一种坏法。
|
||||
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::{
|
||||
default_target_dir, write_bundle, BundleEntry, BundleSpec, HEAD_BYTES, MAX_ENTRY_BYTES,
|
||||
};
|
||||
|
||||
fn temp_dir(tag: &str) -> PathBuf {
|
||||
let root = std::env::temp_dir().join(format!("ull-m187-{}-{tag}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(&root).expect("建夹具目录");
|
||||
root
|
||||
}
|
||||
|
||||
fn write(path: &Path, text: &str) {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).expect("建父目录");
|
||||
}
|
||||
std::fs::write(path, text).expect("写夹具");
|
||||
}
|
||||
|
||||
/// 打开 zip 读某个条目的全文(不存在返回 `None`)。
|
||||
fn entry_text(path: &Path, name: &str) -> Option<String> {
|
||||
let file = std::fs::File::open(path).expect("打开 zip");
|
||||
let mut archive = zip::ZipArchive::new(file).expect("读 zip");
|
||||
let mut entry = archive.by_name(name).ok()?;
|
||||
let mut text = String::new();
|
||||
entry.read_to_string(&mut text).expect("读条目");
|
||||
Some(text)
|
||||
}
|
||||
|
||||
fn names(path: &Path) -> Vec<String> {
|
||||
let file = std::fs::File::open(path).expect("打开 zip");
|
||||
let mut archive = zip::ZipArchive::new(file).expect("读 zip");
|
||||
(0..archive.len())
|
||||
.filter_map(|index| {
|
||||
let item = archive.by_index(index).ok()?;
|
||||
item.name().ok().map(|name| name.to_string())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 接近真实日志的填充物。
|
||||
///
|
||||
/// 为什么不用 `"x".repeat(n)`:连续 4 MiB 的同字符会被脱敏器的「长不透明串」规则
|
||||
/// (长度 ≥64 的 base64/hex 形状)整段替换成占位符,长度就不再超限 —— 那样测的是脱敏,
|
||||
/// 不是截断。
|
||||
fn filler(bytes: usize) -> String {
|
||||
let line = "2026-11-08T15:30:12.441Z [Render thread/INFO]: loading chunk with some words
|
||||
";
|
||||
let mut text = String::with_capacity(bytes + line.len());
|
||||
while text.len() < bytes {
|
||||
text.push_str(line);
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
fn spec(root: &Path, secrets: Vec<String>) -> BundleSpec {
|
||||
BundleSpec {
|
||||
instance: "1.20.4 · Fabric".into(),
|
||||
version: "1.20.4-fabric".into(),
|
||||
session: "0f8fad5b-d9cb-469f-a165-70867728950e".into(),
|
||||
exit_code: Some(1),
|
||||
exit_type: "application_error".into(),
|
||||
uptime_ms: 12_345,
|
||||
command: "javac -Xmx4096m -Djava.library.path=<home>".into(),
|
||||
java: "C:/Java/bin/javaw.exe (major 21)".into(),
|
||||
system: "os=windows version=10.0 arch=x86_64".into(),
|
||||
notes: vec!["本次没有生成 crash-report".into()],
|
||||
missing: vec!["crash-reports/ 不存在".into()],
|
||||
entries: vec![
|
||||
BundleEntry::new("launcher", root.join("launcher.log")),
|
||||
BundleEntry::new("session", root.join("game").join("s1.jsonl")),
|
||||
],
|
||||
secrets,
|
||||
home: Some(root.join("home")),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundle_has_a_manifest_and_the_collected_files() {
|
||||
let root = temp_dir("manifest");
|
||||
write(&root.join("launcher.log"), "[launch] spawn argv=...\n");
|
||||
write(&root.join("game").join("s1.jsonl"), "{\"kind\":\"exit\"}\n");
|
||||
|
||||
let target = default_target_dir(&root).join("m187.zip");
|
||||
let bundle = write_bundle(&spec(&root, Vec::new()), &target).expect("导出");
|
||||
|
||||
assert!(bundle.bytes > 0, "落盘字节数不该是 0");
|
||||
assert_eq!(bundle.files, 2, "两个现场文件都该进包");
|
||||
assert_eq!(bundle.truncated, 0);
|
||||
|
||||
let listed = names(&target);
|
||||
assert!(listed.contains(&"manifest.txt".to_string()), "{listed:?}");
|
||||
assert!(
|
||||
listed.contains(&"launcher/launcher.log".to_string()),
|
||||
"{listed:?}"
|
||||
);
|
||||
assert!(
|
||||
listed.contains(&"session/s1.jsonl".to_string()),
|
||||
"{listed:?}"
|
||||
);
|
||||
|
||||
let manifest = entry_text(&target, "manifest.txt").expect("清单");
|
||||
// 清单要能把「发给别人看」需要的三件事说清:哪一场、什么退出、文件在哪。
|
||||
assert!(manifest.contains("instance: 1.20.4 · Fabric"), "{manifest}");
|
||||
assert!(manifest.contains("exit_code: 1"), "{manifest}");
|
||||
assert!(
|
||||
manifest.contains("exit_type: application_error"),
|
||||
"{manifest}"
|
||||
);
|
||||
assert!(manifest.contains("launcher/launcher.log <-"), "{manifest}");
|
||||
}
|
||||
|
||||
/// 守卫②:**脱敏真的生效**(主目录 / token / 会话 id 三条各自替换)。
|
||||
#[test]
|
||||
fn secrets_home_and_session_ids_are_scrubbed_in_every_entry() {
|
||||
let root = temp_dir("scrub");
|
||||
let home = root.join("home");
|
||||
let token = "eyJhbGciOiJIUzI1NiJ9.eyJ4IjoxMjN9.c2lnbmF0dXJlLXZhbHVl";
|
||||
let session = "0f8fad5b-d9cb-469f-a165-70867728950e";
|
||||
let dirty = format!(
|
||||
"argv=--accessToken {token}\npath={}\nsession={session}\n",
|
||||
home.join("AppData").display()
|
||||
);
|
||||
write(&root.join("launcher.log"), &dirty);
|
||||
write(&root.join("game").join("s1.jsonl"), &dirty);
|
||||
|
||||
let target = default_target_dir(&root).join("m187.zip");
|
||||
let mut spec = spec(&root, vec![session.to_string()]);
|
||||
spec.command = format!("java -accessToken {token}");
|
||||
let bundle = write_bundle(&spec, &target).expect("导出");
|
||||
assert_eq!(bundle.files, 2);
|
||||
|
||||
for name in ["manifest.txt", "launcher/launcher.log", "session/s1.jsonl"] {
|
||||
let text = entry_text(&target, name).unwrap_or_else(|| panic!("缺条目 {name}"));
|
||||
assert!(!text.contains(token), "{name} 漏了 token:{text}");
|
||||
assert!(!text.contains(session), "{name} 漏了会话 id:{text}");
|
||||
}
|
||||
let log = entry_text(&target, "launcher/launcher.log").expect("日志");
|
||||
assert!(log.contains("<home>"), "主目录没被替换:{log}");
|
||||
assert!(
|
||||
!log.contains(&home.display().to_string()),
|
||||
"家目录原文还在:{log}"
|
||||
);
|
||||
}
|
||||
|
||||
/// 守卫③:**没有 crash-report 也能导出**,而且「没有」这件事写进清单(不许静默漏掉)。
|
||||
#[test]
|
||||
fn missing_crash_report_is_stated_in_the_manifest() {
|
||||
let root = temp_dir("missing");
|
||||
write(
|
||||
&root.join("game").join("s1.jsonl"),
|
||||
"{\"kind\":\"stderr\"}\n",
|
||||
);
|
||||
|
||||
let target = default_target_dir(&root).join("m187.zip");
|
||||
let bundle = write_bundle(&spec(&root, Vec::new()), &target).expect("导出");
|
||||
assert_eq!(bundle.files, 1, "只有会话 JSONL 时也要出包");
|
||||
|
||||
let manifest = entry_text(&target, "manifest.txt").expect("清单");
|
||||
assert!(manifest.contains("missing"), "{manifest}");
|
||||
assert!(manifest.contains("crash-reports/ 不存在"), "{manifest}");
|
||||
assert!(manifest.contains("本次没有生成 crash-report"), "{manifest}");
|
||||
// 清单里也要能看见那个「不存在的文件名」,否则用户以为我们忘收了。
|
||||
assert!(manifest.contains("session/s1.jsonl"), "{manifest}");
|
||||
}
|
||||
|
||||
/// 有上限:单个超限文件只收头部,并在清单里记一行「已截断」。
|
||||
#[test]
|
||||
fn oversized_entries_are_truncated_and_recorded() {
|
||||
let root = temp_dir("truncate");
|
||||
let big = root.join("game").join("latest.log");
|
||||
let body = filler(usize::try_from(MAX_ENTRY_BYTES).expect("常数") + 32);
|
||||
write(&big, &body);
|
||||
|
||||
let target = default_target_dir(&root).join("m187.zip");
|
||||
let mut spec = spec(&root, Vec::new());
|
||||
spec.entries = vec![BundleEntry::new("game", &big)];
|
||||
let bundle = write_bundle(&spec, &target).expect("导出");
|
||||
|
||||
assert_eq!(bundle.truncated, 1, "超限文件必须计入截断数");
|
||||
let manifest = entry_text(&target, "manifest.txt").expect("清单");
|
||||
assert!(manifest.contains("已截断"), "{manifest}");
|
||||
assert!(
|
||||
bundle.bytes < MAX_ENTRY_BYTES + 8 * 1024,
|
||||
"包不该把整个超限文件塞进去({} 字节)",
|
||||
bundle.bytes
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_source_files_are_skipped_not_fatal() {
|
||||
let root = temp_dir("gone");
|
||||
let mut spec = spec(&root, Vec::new());
|
||||
spec.entries = vec![BundleEntry::new("game", root.join("nope/absent.log"))];
|
||||
|
||||
let target = default_target_dir(&root).join("m187.zip");
|
||||
let bundle = write_bundle(&spec, &target).expect("缺文件也要出包");
|
||||
assert_eq!(bundle.files, 0);
|
||||
let manifest = entry_text(&target, "manifest.txt").expect("清单");
|
||||
assert!(manifest.contains("读不出来"), "{manifest}");
|
||||
}
|
||||
|
||||
/// P2-2:**包内条目名与清单里的名字必须一字不差**,且名字也要过脱敏。
|
||||
///
|
||||
/// 抓得住:r1 复审抓到的原 bug —— 条目名用原始文件名(会话 id 原样泄漏),而 manifest 过了
|
||||
/// redactor(写 `<redacted:token>`)⇒ 清单指向的条目在包里不存在。
|
||||
#[test]
|
||||
fn entry_names_are_scrubbed_and_match_the_manifest_exactly() {
|
||||
let root = temp_dir("names");
|
||||
let session = "0f8fad5b-d9cb-469f-a165-70867728950e";
|
||||
write(
|
||||
&root.join("game").join(format!("{session}.jsonl")),
|
||||
"{}
|
||||
",
|
||||
);
|
||||
write(
|
||||
&root.join("launcher.log"),
|
||||
"boot
|
||||
",
|
||||
);
|
||||
|
||||
// 第三条**不钉名**:它的源文件名里带会话 id,靠 `entry_names` 的脱敏兜住。
|
||||
write(
|
||||
&root.join("game").join(format!("hs-{session}.log")),
|
||||
"x
|
||||
",
|
||||
);
|
||||
let mut spec = spec(&root, vec![session.to_string()]);
|
||||
spec.entries = vec![
|
||||
BundleEntry::new("launcher", root.join("launcher.log")),
|
||||
// 源文件名就是会话 id:钉成 session.jsonl(调用方 app-iced 的做法)
|
||||
BundleEntry::named(
|
||||
"session",
|
||||
root.join("game").join(format!("{session}.jsonl")),
|
||||
"session.jsonl",
|
||||
),
|
||||
BundleEntry::new("game", root.join("game").join(format!("hs-{session}.log"))),
|
||||
];
|
||||
let target = default_target_dir(&root).join("names.zip");
|
||||
write_bundle(&spec, &target).expect("导出");
|
||||
|
||||
let listed = names(&target);
|
||||
assert!(
|
||||
listed.iter().any(|name| name == "session/session.jsonl"),
|
||||
"钉死的条目名没生效:{listed:?}"
|
||||
);
|
||||
assert!(
|
||||
!listed.iter().any(|name| name.contains(session)),
|
||||
"条目名里还有原始会话 id:{listed:?}"
|
||||
);
|
||||
|
||||
// 清单的 files 段必须与真实条目一一对应(顺序也一致)。
|
||||
let manifest = entry_text(&target, "manifest.txt").expect("清单");
|
||||
let manifest_names: Vec<String> = manifest
|
||||
.lines()
|
||||
.filter(|line| line.starts_with("- ") && line.contains(" <- "))
|
||||
.map(|line| {
|
||||
line.trim_start_matches("- ")
|
||||
.split(" <- ")
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
})
|
||||
.collect();
|
||||
let zip_names: Vec<String> = listed
|
||||
.iter()
|
||||
.filter(|name| name.as_str() != "manifest.txt")
|
||||
.cloned()
|
||||
.collect();
|
||||
assert_eq!(manifest_names, zip_names, "清单与包内条目对不上");
|
||||
}
|
||||
|
||||
/// P2-3:截断必须**保留尾部**——崩溃证据(`exit` 事件 / 栈尾)在尾部。
|
||||
///
|
||||
/// 抓得住:只留头部的截断(r1 复审 P2-3 的原实现)会让包里没有 `exit` 行。
|
||||
#[test]
|
||||
fn truncation_keeps_the_tail_where_the_crash_evidence_lives() {
|
||||
let root = temp_dir("tail");
|
||||
let session = root.join("game").join("session.jsonl");
|
||||
let head_marker = "HEAD-MARKER-first-line";
|
||||
let tail_marker = "{\"kind\":\"exit\",\"payload\":{\"code\":1}}";
|
||||
let body = format!(
|
||||
"{head_marker}
|
||||
{}{tail_marker}
|
||||
",
|
||||
filler(usize::try_from(MAX_ENTRY_BYTES).expect("常数") + 64)
|
||||
);
|
||||
write(&session, &body);
|
||||
|
||||
let mut spec = spec(&root, Vec::new());
|
||||
spec.entries = vec![BundleEntry::named("session", &session, "session.jsonl")];
|
||||
let target = default_target_dir(&root).join("tail.zip");
|
||||
let bundle = write_bundle(&spec, &target).expect("导出");
|
||||
|
||||
assert_eq!(bundle.truncated, 1, "超限文件必须计入截断数");
|
||||
let kept = entry_text(&target, "session/session.jsonl").expect("条目");
|
||||
assert!(kept.contains(head_marker), "头部标记丢了");
|
||||
assert!(
|
||||
kept.contains(tail_marker),
|
||||
"尾部标记丢了 —— 包里将没有 exit 事件(证据是残的)"
|
||||
);
|
||||
assert!(
|
||||
kept.contains("<truncated "),
|
||||
"中间该有一行截断说明:{kept:.80}"
|
||||
);
|
||||
let manifest = entry_text(&target, "manifest.txt").expect("清单");
|
||||
assert!(manifest.contains("已截断"), "{manifest}");
|
||||
}
|
||||
|
||||
/// P2-1(r2 复审新增,按 p1 对待):**截断过的文件也必须脱敏**。
|
||||
///
|
||||
/// 抓得住的原 bug:上一版在字节上切两刀,切在多字节字符中间时 `from_utf8` 失败 ⇒
|
||||
/// 整份 entry 走「原样字节」分支,家目录 / 用户名 / 令牌原文按字节进包(隐私旁路)。
|
||||
///
|
||||
/// 构造要点:让一个 3 字节的 `中` **正好跨在 `HEAD_BYTES` 边界上**,文件里放注册过的
|
||||
/// 会话 id 与家目录路径,尾部再放 exit 事件(证明截断仍然保尾)。
|
||||
#[test]
|
||||
fn truncated_entries_are_still_redacted() {
|
||||
let root = temp_dir("trunc-redact");
|
||||
let home = root.join("home");
|
||||
let session = "0f8fad5b-d9cb-469f-a165-70867728950e";
|
||||
let head_budget = usize::try_from(HEAD_BYTES).expect("常数");
|
||||
|
||||
// 前段:真实的日志行 + 家目录 + 会话 id;填充到「离边界只差 2 字节」,
|
||||
// 再放一个 3 字节的 CJK 字 ⇒ 它必然跨在 HEAD_BYTES 上。
|
||||
let mut text = String::new();
|
||||
text.push_str(&format!(
|
||||
"[boot] home={}
|
||||
",
|
||||
home.display()
|
||||
));
|
||||
text.push_str(&format!(
|
||||
"[launch] session={session}
|
||||
"
|
||||
));
|
||||
let line = "2026-11-08T15:30:12.441Z [Render thread/INFO]: loading chunk
|
||||
";
|
||||
while text.len() + line.len() < head_budget - 2 {
|
||||
text.push_str(line);
|
||||
}
|
||||
while text.len() < head_budget - 2 {
|
||||
text.push('a');
|
||||
}
|
||||
text.push('中');
|
||||
while text.len() < usize::try_from(MAX_ENTRY_BYTES).expect("常数") + 64 {
|
||||
text.push_str(line);
|
||||
}
|
||||
text.push_str(
|
||||
"{\"kind\":\"exit\",\"payload\":{\"code\":1}}
|
||||
",
|
||||
);
|
||||
|
||||
let log = root.join("game").join("latest.log");
|
||||
write(&log, &text);
|
||||
assert!(
|
||||
!text.is_char_boundary(head_budget),
|
||||
"构造失败:切点应落在字中间"
|
||||
);
|
||||
|
||||
let mut spec = spec(&root, vec![session.to_string()]);
|
||||
spec.entries = vec![BundleEntry::named("game", &log, "latest.log")];
|
||||
let target = default_target_dir(&root).join("trunc-redact.zip");
|
||||
let bundle = write_bundle(&spec, &target).expect("导出");
|
||||
assert_eq!(
|
||||
bundle.truncated, 1,
|
||||
"这份文件必须真的被截断(否则测不到旁路)"
|
||||
);
|
||||
|
||||
let kept = entry_text(&target, "game/latest.log").expect("条目");
|
||||
// ① 包里找不到原文(家目录 / 会话 id)
|
||||
assert!(
|
||||
!kept.contains(&home.display().to_string()),
|
||||
"截断后的文件仍带着家目录原文 —— 脱敏被跳过了"
|
||||
);
|
||||
assert!(!kept.contains(session), "截断后的文件仍带着会话 id 原文");
|
||||
// ② 占位符在(说明脱敏真的跑过)
|
||||
assert!(kept.contains("<home>"), "没有 <home> 占位符");
|
||||
// ③ 尾部证据仍在(截断仍保尾)
|
||||
assert!(kept.contains("\"kind\":\"exit\""), "尾部 exit 事件丢了");
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
//! 一次失败启动的**现场**(M187):路径 + 捕获输出 + 退出事实,供失败弹窗与诊断包共用。
|
||||
//!
|
||||
//! ## 为什么是「事实」而不是「结论」
|
||||
//!
|
||||
//! 弹窗要说三件事——**是哪一场失败**(装配阶段 / 起进程后崩溃)、**退出码**、**证据在哪**。
|
||||
//! 这三件都是启动器已经知道的事实,不含任何推断。崩溃原因的智能诊断是另一条线(诊断引擎
|
||||
//! `diagnose` / AI),本模块只把事实摆齐,让用户能一键把证据带走。
|
||||
//!
|
||||
//! ## 两种失败形态必须分开(用户投诉的原点)
|
||||
//!
|
||||
//! | 形态 | 判据 | 弹窗该说什么 |
|
||||
//! |---|---|---|
|
||||
//! | 游戏自己写了崩溃报告 | [`CrashReport::has_game_crash_report`] 为真 | 「游戏写出了崩溃报告」,贴 Description/栈片段 |
|
||||
//! | 游戏**没来得及写** | 没有 crash-report,但有捕获输出 | 「这次没有生成崩溃报告,下面是捕获的最后若干行」 |
|
||||
//! | 什么输出都没抓到 | 两者皆无 | 「游戏还没来得及写日志」+ 最可能原因分类(缺 Java / 内存 / 参数 / 库) |
|
||||
//!
|
||||
//! 第二、三种**不许**显示成「找不到日志」——日志在启动器这里,只是游戏没写它自己那份。
|
||||
//! 进程级槽位只存**已脱敏**的文本([`CrashReport::collect`] 的出口就是脱敏边界)。
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use super::crash_scan::{self, CrashArtifact, CrashSource};
|
||||
use super::launch_session::SessionHandle;
|
||||
use super::redact::Redactor;
|
||||
|
||||
/// 弹窗里贴「捕获输出」的行数上限(够看清 JVM 报错,又不把弹窗撑破)。
|
||||
pub const TAIL_LINES: usize = 30;
|
||||
/// 现场文件的扫描条数上限。
|
||||
pub const MAX_ARTIFACTS: usize = 8;
|
||||
/// 弹窗里贴崩溃报告摘要的字符上限。
|
||||
pub const MAX_EXCERPT_CHARS: usize = 1200;
|
||||
|
||||
/// 一个现场文件(来源 + 路径 + 人读摘要)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ArtifactFact {
|
||||
/// 来源 slug(`crash_report` / `hs_err` / `latest_log`)。
|
||||
pub source: String,
|
||||
/// 文件路径。
|
||||
pub path: PathBuf,
|
||||
/// 摘要(崩溃报告的 Description + 栈片段;`latest.log` 与 `hs_err` 不给)。
|
||||
pub excerpt: Option<String>,
|
||||
}
|
||||
|
||||
/// 一次失败启动的现场。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct CrashReport {
|
||||
/// 实例 id。
|
||||
pub instance: String,
|
||||
/// 实例名。
|
||||
pub name: String,
|
||||
/// 绑定的版本 id。
|
||||
pub version: String,
|
||||
/// 会话 id(空 = 进程根本没起来)。
|
||||
pub session: String,
|
||||
/// 会话事件流 JSONL(**游戏侧证据的真正落点**)。
|
||||
pub session_log: Option<PathBuf>,
|
||||
/// 该实例绑定的版本 JSON(诊断包要带上它,别人才能复现装配)。
|
||||
pub version_json: Option<PathBuf>,
|
||||
/// 启动器自己的日志目录(`.ULL/logs`)。
|
||||
pub launcher_logs: Option<PathBuf>,
|
||||
/// 实例运行目录(`crash-reports/` 与 `logs/` 的父目录)。
|
||||
pub run_dir: Option<PathBuf>,
|
||||
/// 崩溃现场文件(按修改时间倒序)。
|
||||
pub artifacts: Vec<ArtifactFact>,
|
||||
/// 退出码(`None` = 进程没起来 / 平台没给)。
|
||||
pub exit_code: Option<i32>,
|
||||
/// 退出判定 slug(`application_error` / `jvm_error` / `normal` …;进程没起来时是 `spawn`)。
|
||||
pub exit_type: String,
|
||||
/// 存活毫秒数。
|
||||
pub uptime_ms: u64,
|
||||
/// 脱敏后的命令行一行。
|
||||
pub command: String,
|
||||
/// Java 一行说明(二进制 + 主版本)。
|
||||
pub java: String,
|
||||
/// 系统一行说明。
|
||||
pub system: String,
|
||||
/// 装配阶段失败的 slug(`version_manifest` / `spawn` …);`None` = 进程起来过。
|
||||
pub stage: Option<String>,
|
||||
/// 装配阶段的原始错误文本(不翻译,可能含路径)。
|
||||
pub stage_detail: Option<String>,
|
||||
/// 启动器捕获的最后若干行(已脱敏、已截断到 [`TAIL_LINES`])。
|
||||
pub stderr_tail: Vec<String>,
|
||||
}
|
||||
|
||||
/// 证据形态(界面按它选 i18n key,**不许**再自己判断)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Evidence {
|
||||
/// 游戏自己写了 `crash-reports/crash-*.txt`。
|
||||
GameCrashReport,
|
||||
/// 游戏没写崩溃报告,但启动器抓到了输出。
|
||||
CapturedOutput,
|
||||
/// 连输出都没抓到(JVM 都没起来 / 立刻失败)。
|
||||
Nothing,
|
||||
}
|
||||
|
||||
impl Evidence {
|
||||
/// 稳定 slug(测试与日志用;不是文案)。
|
||||
#[must_use]
|
||||
pub const fn slug(self) -> &'static str {
|
||||
match self {
|
||||
Self::GameCrashReport => "game_crash_report",
|
||||
Self::CapturedOutput => "captured_output",
|
||||
Self::Nothing => "nothing",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CrashReport {
|
||||
/// 游戏侧证据的形态(界面按它选文案,**不许**再自己判断)。
|
||||
#[must_use]
|
||||
pub fn evidence(&self) -> Evidence {
|
||||
if self.has_game_crash_report() {
|
||||
Evidence::GameCrashReport
|
||||
} else if self.stderr_tail.is_empty() {
|
||||
Evidence::Nothing
|
||||
} else {
|
||||
Evidence::CapturedOutput
|
||||
}
|
||||
}
|
||||
|
||||
/// 是否找到游戏自己写的崩溃报告。
|
||||
#[must_use]
|
||||
pub fn has_game_crash_report(&self) -> bool {
|
||||
self.artifacts
|
||||
.iter()
|
||||
.any(|a| a.source == CrashSource::CrashReport.as_str())
|
||||
}
|
||||
|
||||
/// 进程是否起来过(决定标题写「启动失败」还是「游戏崩溃」)。
|
||||
#[must_use]
|
||||
pub fn started(&self) -> bool {
|
||||
self.stage.is_none()
|
||||
}
|
||||
|
||||
/// 该实例运行目录下的 `crash-reports/`。
|
||||
#[must_use]
|
||||
pub fn crash_reports_dir(&self) -> Option<PathBuf> {
|
||||
self.run_dir.as_ref().map(|dir| dir.join("crash-reports"))
|
||||
}
|
||||
|
||||
/// 该实例的 `logs/latest.log`。
|
||||
#[must_use]
|
||||
pub fn latest_log_path(&self) -> Option<PathBuf> {
|
||||
self.run_dir
|
||||
.as_ref()
|
||||
.map(|dir| dir.join("logs").join("latest.log"))
|
||||
}
|
||||
|
||||
/// 供「复制路径」用的(标签, 路径)列表(提示词在前、目录在后)。
|
||||
#[must_use]
|
||||
pub fn path_lines(&self) -> Vec<(String, PathBuf)> {
|
||||
let mut lines = Vec::new();
|
||||
if let Some(log) = &self.session_log {
|
||||
lines.push(("session".to_string(), log.clone()));
|
||||
}
|
||||
if let Some(dir) = &self.launcher_logs {
|
||||
lines.push(("launcher_logs".to_string(), dir.clone()));
|
||||
}
|
||||
if let Some(path) = self.latest_log_path() {
|
||||
lines.push(("latest_log".to_string(), path));
|
||||
}
|
||||
if let Some(dir) = self.crash_reports_dir() {
|
||||
lines.push(("crash_reports".to_string(), dir));
|
||||
}
|
||||
if let Some(dir) = &self.run_dir {
|
||||
lines.push(("run_dir".to_string(), dir.clone()));
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
/// 收集一次**已经起来过**的启动的现场。
|
||||
///
|
||||
/// `session` 由调用方在 `finish()` 之后给出;`lines` 是该会话的事件流行(**调用方读**,
|
||||
/// 因为读 JSONL 属于 host 的活)。只有最后 [`TAIL_LINES`] 行进弹窗。
|
||||
#[must_use]
|
||||
pub fn collect(session: &SessionHandle, ctx: &ReportContext, lines: &[String]) -> Self {
|
||||
let run_dir = ctx.run_dir.clone();
|
||||
let artifacts = crash_scan::scan(&run_dir, ctx.started_at, MAX_ARTIFACTS)
|
||||
.into_iter()
|
||||
.map(|artifact| fact(&artifact, &ctx.redactor()))
|
||||
.collect();
|
||||
|
||||
let raw: Vec<String> = lines.iter().rev().take(TAIL_LINES).rev().cloned().collect();
|
||||
|
||||
Self {
|
||||
instance: ctx.instance.clone(),
|
||||
name: ctx.name.clone(),
|
||||
version: ctx.version.clone(),
|
||||
session: session.session_id.clone(),
|
||||
session_log: Some(session.log_path.clone()),
|
||||
version_json: ctx.version_json.clone(),
|
||||
launcher_logs: Some(ctx.launcher_logs.clone()),
|
||||
run_dir: Some(run_dir),
|
||||
artifacts,
|
||||
exit_code: Some(session.exit.code),
|
||||
exit_type: session.exit.exit_type.as_str().to_string(),
|
||||
uptime_ms: session.uptime_ms,
|
||||
command: ctx
|
||||
.redactor()
|
||||
.scrub(session.redacted_display().as_str())
|
||||
.into_owned(),
|
||||
java: ctx.java.clone(),
|
||||
system: ctx.system.clone(),
|
||||
stage: None,
|
||||
stage_detail: None,
|
||||
stderr_tail: scrub_tail(&raw, &ctx.redactor()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 收集一次**进程没起来**的现场(装配 / 起进程阶段失败)。
|
||||
///
|
||||
/// 这种失败最常见的形态就是「找不到 crash-report」——游戏根本没被执行到,所以
|
||||
/// [`Evidence`] 落到 [`Evidence::Nothing`],界面必须说「游戏还没来得及写日志」。
|
||||
#[must_use]
|
||||
pub fn before_start(ctx: &ReportContext, stage: &str, detail: &str, exit_type: &str) -> Self {
|
||||
let redactor = ctx.redactor();
|
||||
Self {
|
||||
instance: ctx.instance.clone(),
|
||||
name: ctx.name.clone(),
|
||||
version: ctx.version.clone(),
|
||||
session: String::new(),
|
||||
session_log: None,
|
||||
version_json: ctx.version_json.clone(),
|
||||
launcher_logs: Some(ctx.launcher_logs.clone()),
|
||||
run_dir: Some(ctx.run_dir.clone()),
|
||||
artifacts: Vec::new(),
|
||||
exit_code: None,
|
||||
exit_type: exit_type.to_string(),
|
||||
uptime_ms: 0,
|
||||
command: String::new(),
|
||||
java: ctx.java.clone(),
|
||||
system: ctx.system.clone(),
|
||||
stage: Some(stage.to_string()),
|
||||
stage_detail: Some(redactor.scrub(detail).into_owned()),
|
||||
stderr_tail: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 诊断包清单里要写明的「没有的东西」(**不许静默省略**)。
|
||||
#[must_use]
|
||||
pub fn missing_lines(&self) -> Vec<String> {
|
||||
let mut missing = Vec::new();
|
||||
if !self.has_game_crash_report() {
|
||||
missing.push("crash-reports/crash-*.txt: 本次没有(游戏没来得及写)".to_string());
|
||||
}
|
||||
if let Some(path) = self.latest_log_path() {
|
||||
if !path.is_file() {
|
||||
missing.push(format!("logs/latest.log: 不存在({})", path.display()));
|
||||
}
|
||||
}
|
||||
if self.session_log.is_none() {
|
||||
missing.push("会话 JSONL: 不存在(进程没起来)".to_string());
|
||||
}
|
||||
missing
|
||||
}
|
||||
|
||||
/// 备注(清单里「这次发生了什么」那一段)。
|
||||
#[must_use]
|
||||
pub fn note_lines(&self) -> Vec<String> {
|
||||
let mut notes = match self.evidence() {
|
||||
Evidence::GameCrashReport => vec!["游戏写出了崩溃报告(crash-reports/)".to_string()],
|
||||
Evidence::CapturedOutput => {
|
||||
vec!["本次没有生成崩溃报告;证据是启动器捕获的输出".to_string()]
|
||||
}
|
||||
Evidence::Nothing => vec![
|
||||
"本次没有崩溃报告,也没捕获到输出(最可能:缺 Java / 内存不足 / 参数错误 / 库缺失)"
|
||||
.to_string(),
|
||||
],
|
||||
};
|
||||
if let Some(stage) = &self.stage {
|
||||
notes.push(format!("进程没有起来,失败在:{stage}"));
|
||||
}
|
||||
notes
|
||||
}
|
||||
}
|
||||
|
||||
/// [`CrashReport::collect`] / [`CrashReport::before_start`] 的输入(全是启动侧已有的东西)。
|
||||
///
|
||||
/// 字段是**拥有的**:构造一次、在四个失败出口上反复用(借用版会让每个出口都要重算一遍
|
||||
/// 主目录与脱敏器,而这条路径低频、克隆代价可以忽略)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ReportContext {
|
||||
/// 实例 id。
|
||||
pub instance: String,
|
||||
/// 实例名。
|
||||
pub name: String,
|
||||
/// 版本 id。
|
||||
pub version: String,
|
||||
/// 实例运行目录。
|
||||
pub run_dir: PathBuf,
|
||||
/// 启动器日志目录(`.ULL/logs`)。
|
||||
pub launcher_logs: PathBuf,
|
||||
/// 本次会话的开始时刻(现场文件的时间闸)。
|
||||
pub started_at: std::time::SystemTime,
|
||||
/// 该实例绑定的版本 JSON。
|
||||
pub version_json: Option<PathBuf>,
|
||||
/// Java 一行说明。
|
||||
pub java: String,
|
||||
/// 系统一行说明。
|
||||
pub system: String,
|
||||
/// 要额外抹掉的串(会话 id、access token 等)。
|
||||
pub secrets: Vec<String>,
|
||||
}
|
||||
|
||||
impl ReportContext {
|
||||
/// 建一份(`java` 可以后补:装配阶段还没选 Java)。
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
instance: impl Into<String>,
|
||||
name: impl Into<String>,
|
||||
version: impl Into<String>,
|
||||
run_dir: impl Into<PathBuf>,
|
||||
launcher_logs: impl Into<PathBuf>,
|
||||
started_at: std::time::SystemTime,
|
||||
system: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
instance: instance.into(),
|
||||
name: name.into(),
|
||||
version: version.into(),
|
||||
run_dir: run_dir.into(),
|
||||
launcher_logs: launcher_logs.into(),
|
||||
started_at,
|
||||
version_json: None,
|
||||
java: String::new(),
|
||||
system: system.into(),
|
||||
secrets: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn redactor(&self) -> Redactor {
|
||||
let mut redactor = Redactor::new();
|
||||
for key in ["USERPROFILE", "HOME"] {
|
||||
if let Ok(home) = std::env::var(key) {
|
||||
if !home.trim().is_empty() {
|
||||
redactor = redactor.with_home_str(home);
|
||||
}
|
||||
}
|
||||
}
|
||||
for secret in &self.secrets {
|
||||
redactor = redactor.with_secret(secret.clone());
|
||||
}
|
||||
redactor
|
||||
}
|
||||
}
|
||||
|
||||
fn scrub_tail(lines: &[String], redactor: &Redactor) -> Vec<String> {
|
||||
lines
|
||||
.iter()
|
||||
.map(|line| redactor.scrub(line).into_owned())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn fact(artifact: &CrashArtifact, redactor: &Redactor) -> ArtifactFact {
|
||||
let excerpt = artifact.excerpt.as_ref().map(|text| {
|
||||
let scrubbed = redactor.scrub(text);
|
||||
let trimmed: String = scrubbed.chars().take(MAX_EXCERPT_CHARS).collect();
|
||||
trimmed
|
||||
});
|
||||
ArtifactFact {
|
||||
source: artifact.source.as_str().to_string(),
|
||||
path: artifact.path.clone(),
|
||||
excerpt,
|
||||
}
|
||||
}
|
||||
|
||||
fn slot() -> &'static Mutex<Option<CrashReport>> {
|
||||
static SLOT: OnceLock<Mutex<Option<CrashReport>>> = OnceLock::new();
|
||||
SLOT.get_or_init(|| Mutex::new(None))
|
||||
}
|
||||
|
||||
/// 登记一次失败启动的现场(启动线程收尾时调用)。
|
||||
pub fn publish(report: CrashReport) {
|
||||
if let Ok(mut guard) = slot().lock() {
|
||||
*guard = Some(report);
|
||||
}
|
||||
}
|
||||
|
||||
/// 读最近一次失败启动的现场。
|
||||
#[must_use]
|
||||
pub fn failing_launch() -> Option<CrashReport> {
|
||||
slot().lock().ok().and_then(|guard| guard.clone())
|
||||
}
|
||||
|
||||
/// 清空槽位(测试用)。
|
||||
pub fn clear() {
|
||||
if let Ok(mut guard) = slot().lock() {
|
||||
*guard = None;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,125 @@
|
||||
//! 现场事实的断言:三态证据分类、脱敏、缺件写明、槽位读写。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::{clear, failing_launch, publish, ArtifactFact, CrashReport, Evidence, ReportContext};
|
||||
|
||||
fn report(artifacts: Vec<ArtifactFact>, tail: Vec<String>) -> CrashReport {
|
||||
CrashReport {
|
||||
instance: "survival".into(),
|
||||
name: "生存".into(),
|
||||
version: "1.20.4".into(),
|
||||
session: "0f8fad5b".into(),
|
||||
session_log: Some(PathBuf::from("/sessions/game/0f8fad5b.jsonl")),
|
||||
version_json: None,
|
||||
launcher_logs: Some(PathBuf::from("/.ULL/logs")),
|
||||
run_dir: Some(PathBuf::from("/instances/survival")),
|
||||
artifacts,
|
||||
exit_code: Some(1),
|
||||
exit_type: "application_error".into(),
|
||||
uptime_ms: 12_000,
|
||||
command: "javaw -Xmx4096m".into(),
|
||||
java: "javaw.exe (21)".into(),
|
||||
system: "os=windows".into(),
|
||||
stage: None,
|
||||
stage_detail: None,
|
||||
stderr_tail: tail,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evidence_tells_the_three_shapes_apart() {
|
||||
let with_report = report(
|
||||
vec![ArtifactFact {
|
||||
source: "crash_report".into(),
|
||||
path: PathBuf::from("/instances/survival/crash-reports/crash-1.txt"),
|
||||
excerpt: Some("Description: Ticking entity".into()),
|
||||
}],
|
||||
vec!["Exception in thread main".into()],
|
||||
);
|
||||
assert_eq!(with_report.evidence(), Evidence::GameCrashReport);
|
||||
assert_eq!(with_report.evidence().slug(), "game_crash_report");
|
||||
|
||||
let stderr_only = report(Vec::new(), vec!["Error: could not find java.dll".into()]);
|
||||
assert_eq!(
|
||||
stderr_only.evidence(),
|
||||
Evidence::CapturedOutput,
|
||||
"没有 crash-report 但有捕获输出 ⇒ 不许说「找不到日志」"
|
||||
);
|
||||
|
||||
let nothing = report(Vec::new(), Vec::new());
|
||||
assert_eq!(nothing.evidence(), Evidence::Nothing);
|
||||
|
||||
// 三种 slug 必须互不相同(界面按它选 key,撞了就会显示错的一句)。
|
||||
let slugs = [
|
||||
with_report.evidence().slug(),
|
||||
stderr_only.evidence().slug(),
|
||||
nothing.evidence().slug(),
|
||||
];
|
||||
let mut unique = slugs.to_vec();
|
||||
unique.sort_unstable();
|
||||
unique.dedup();
|
||||
assert_eq!(unique.len(), 3, "{slugs:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_crash_report_is_listed_not_silently_dropped() {
|
||||
let report = report(Vec::new(), vec!["line".into()]);
|
||||
let missing = report.missing_lines().join("\n");
|
||||
assert!(missing.contains("crash-reports"), "{missing}");
|
||||
assert!(missing.contains("游戏没来得及写"), "{missing}");
|
||||
assert!(
|
||||
!missing.contains("会话 JSONL"),
|
||||
"会话在,不该说没会话:{missing}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notes_name_the_fallback_reason_classification() {
|
||||
let report = report(Vec::new(), Vec::new());
|
||||
let notes = report.note_lines().join("\n");
|
||||
assert!(notes.contains("缺 Java"), "{notes}");
|
||||
assert!(notes.contains("内存不足"), "{notes}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn before_start_marks_the_launch_as_never_started_and_scrubs_the_detail() {
|
||||
let home = std::env::var("USERPROFILE")
|
||||
.or_else(|_| std::env::var("HOME"))
|
||||
.unwrap_or_default();
|
||||
if home.is_empty() {
|
||||
return;
|
||||
}
|
||||
let ctx = ReportContext::new(
|
||||
"survival",
|
||||
"生存",
|
||||
"1.20.4",
|
||||
std::path::Path::new("/instances/survival"),
|
||||
std::path::Path::new("/.ULL/logs"),
|
||||
std::time::SystemTime::now(),
|
||||
"os=windows",
|
||||
);
|
||||
let report = CrashReport::before_start(
|
||||
&ctx,
|
||||
"spawn",
|
||||
&format!("{home}/java/bin/javaw.exe 不存在"),
|
||||
"spawn",
|
||||
);
|
||||
assert!(!report.started(), "进程没起来");
|
||||
assert_eq!(report.evidence(), Evidence::Nothing);
|
||||
let detail = report.stage_detail.expect("阶段原文");
|
||||
assert!(detail.contains("<home>"), "{detail}");
|
||||
assert!(!detail.contains(&home), "主目录原文漏了:{detail}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slot_keeps_the_last_failure_only() {
|
||||
clear();
|
||||
assert!(failing_launch().is_none());
|
||||
publish(report(Vec::new(), vec!["first".into()]));
|
||||
publish(report(Vec::new(), vec!["second".into()]));
|
||||
let last = failing_launch().expect("最近一次");
|
||||
assert_eq!(last.stderr_tail, vec!["second".to_string()]);
|
||||
clear();
|
||||
assert!(failing_launch().is_none());
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
//! 启动器**自己的**运行日志(M187):`<.ULL>/logs/launcher.log`。
|
||||
//!
|
||||
//! ## 为什么要有这个文件
|
||||
//!
|
||||
//! 用户实测(2026-11-08):「游戏崩溃后只有一个安装日志,没有崩溃日志」。核查结论是:
|
||||
//! `logs/` 分区里此前只有 `install.log`(安装流水)与 auth journal——**启动器自己没有运行期
|
||||
//! 日志**。双击启动的 GUI 进程没有 stderr 去向,所有 `eprintln!` 诊断当场蒸发,于是
|
||||
//! 「启动器视角发生了什么」在事故之后无法复原。
|
||||
//!
|
||||
//! 本模块只做一件事:把一行行 `[scope] message` 追加到一个文件里,**落盘即脱敏**。
|
||||
//!
|
||||
//! ## 三条约束(都是有意的)
|
||||
//!
|
||||
//! 1. **best-effort**:写不进去就静默放过。日志失败不该让启动器崩、也不该弹提示。
|
||||
//! 2. **不阻塞**:不建后台线程、不做异步队列。调用点都是「一次启动 / 一次导出」这种低频事件,
|
||||
//! 每次一行 `write_all` 的代价可以忽略;引入通道反而多一处要收尾的生命周期。
|
||||
//! 3. **有上限**:超过 [`MAX_BYTES`] 就滚成 `launcher.1.log`(只保留一份历史)。日志文件
|
||||
//! 无限长会让「导出诊断包」变成搬砖。
|
||||
//!
|
||||
//! ## 目录从哪来
|
||||
//!
|
||||
//! 目录**不是**本模块自己解析的:`.ULL` 的落点由 `platform::data_dir` 的三模式决定(导出的
|
||||
//! exe 目录 / 标准目录 / 自定义根),解析它需要读配置——那是宿主(presenter / app)已经做过
|
||||
//! 的事。所以这里是「**先到者登记**」:知道真相的调用方用 [`set_dir`] 登记,
|
||||
//! 登记之前 [`record`] 的行进内存缓冲(最多 [`MAX_BUFFERED`] 行),登记时一次刷出去。
|
||||
//! 这样启动早期(字体 / GPU / 路由)那几行也不会丢。
|
||||
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use super::redact::Redactor;
|
||||
|
||||
/// 日志文件名。
|
||||
pub const FILE_NAME: &str = "launcher.log";
|
||||
/// 单个文件上限(超过就滚成 `launcher.1.log`,只留一份历史)。
|
||||
pub const MAX_BYTES: u64 = 1024 * 1024;
|
||||
/// 登记目录之前最多缓冲多少行。
|
||||
pub const MAX_BUFFERED: usize = 200;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct State {
|
||||
dir: Option<PathBuf>,
|
||||
buffer: Vec<String>,
|
||||
}
|
||||
|
||||
fn state() -> &'static Mutex<State> {
|
||||
static STATE: OnceLock<Mutex<State>> = OnceLock::new();
|
||||
STATE.get_or_init(|| Mutex::new(State::default()))
|
||||
}
|
||||
|
||||
/// 登记日志目录(`<.ULL>/logs`)。重复登记时以**最后一次**为准(数据根可能被 `redirect.json` 改)。
|
||||
pub fn set_dir(logs_dir: impl Into<PathBuf>) {
|
||||
let dir = logs_dir.into();
|
||||
let mut guard = match state().lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(_) => return,
|
||||
};
|
||||
if guard.dir.as_ref() == Some(&dir) {
|
||||
return;
|
||||
}
|
||||
guard.dir = Some(dir);
|
||||
let buffered = std::mem::take(&mut guard.buffer);
|
||||
let Some(directory) = guard.dir.clone() else {
|
||||
return;
|
||||
};
|
||||
drop(guard);
|
||||
for line in buffered {
|
||||
append(&directory, &line);
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前日志文件路径(`None` = 还没登记目录)。
|
||||
pub fn path() -> Option<PathBuf> {
|
||||
let guard = state().lock().ok()?;
|
||||
guard.dir.as_ref().map(|dir| dir.join(FILE_NAME))
|
||||
}
|
||||
|
||||
/// 记一行(`[scope] message`)。scope 是稳定短的英文标签(`boot` / `launch` / `dialog`)。
|
||||
///
|
||||
/// 时间戳走 `launch::time::now()`(与事件流同一份时钟口径),所以日志行与 JSONL 能对上。
|
||||
pub fn record(scope: &str, message: &str) {
|
||||
let line = format!(
|
||||
"{} [{scope}] {message}",
|
||||
super::time::format_rfc3339_millis(super::time::now())
|
||||
);
|
||||
let dir = {
|
||||
let mut guard = match state().lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(_) => return,
|
||||
};
|
||||
match guard.dir.clone() {
|
||||
Some(dir) => dir,
|
||||
None => {
|
||||
if guard.buffer.len() < MAX_BUFFERED {
|
||||
guard.buffer.push(line);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
append(&dir, &line);
|
||||
}
|
||||
|
||||
/// 脱敏器:主目录换成 `<home>`(同一份规则见 [`super::redact`])。
|
||||
fn redactor() -> Redactor {
|
||||
let mut redactor = Redactor::new();
|
||||
for key in ["USERPROFILE", "HOME"] {
|
||||
if let Ok(home) = std::env::var(key) {
|
||||
if !home.trim().is_empty() {
|
||||
redactor = redactor.with_home_str(home);
|
||||
}
|
||||
}
|
||||
}
|
||||
redactor
|
||||
}
|
||||
|
||||
fn append(dir: &Path, line: &str) {
|
||||
let path = dir.join(FILE_NAME);
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
// 滚动:只在超过上限时做一次 rename,平时的路径只有「打开 → 追加 → 关」。
|
||||
if std::fs::metadata(&path).is_ok_and(|meta| meta.len() > MAX_BYTES) {
|
||||
let _ = std::fs::rename(&path, dir.join("launcher.1.log"));
|
||||
}
|
||||
let scrubbed = redactor().scrub(line).into_owned();
|
||||
if let Ok(mut file) = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&path)
|
||||
{
|
||||
let _ = file.write_all(scrubbed.as_bytes());
|
||||
let _ = file.write_all(b"\n");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn state_reset() {
|
||||
if let Ok(mut guard) = state().lock() {
|
||||
guard.dir = None;
|
||||
guard.buffer.clear();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::MAX_BUFFERED;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// 测试之间共享全局状态,必须串行(`cargo test` 默认多线程)。
|
||||
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
fn temp_dir(tag: &str) -> PathBuf {
|
||||
let root = std::env::temp_dir().join(format!("ull-m187-log-{}-{tag}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(&root).expect("建夹具目录");
|
||||
root
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buffered_lines_are_flushed_once_the_dir_is_known() {
|
||||
let _guard = LOCK.lock().unwrap_or_else(|error| error.into_inner());
|
||||
super::state_reset();
|
||||
let dir = temp_dir("flush");
|
||||
|
||||
super::record("boot", "before the dir was known");
|
||||
assert!(super::path().is_none(), "没登记目录时不该报出路径");
|
||||
super::set_dir(&dir);
|
||||
super::record("launch", "after");
|
||||
|
||||
let text = std::fs::read_to_string(dir.join(super::FILE_NAME)).expect("读日志");
|
||||
assert!(text.contains("before the dir was known"), "{text}");
|
||||
assert!(text.contains("[launch] after"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn home_paths_are_scrubbed_on_write() {
|
||||
let _guard = LOCK.lock().unwrap_or_else(|error| error.into_inner());
|
||||
super::state_reset();
|
||||
let dir = temp_dir("scrub");
|
||||
super::set_dir(&dir);
|
||||
let home = std::env::var("USERPROFILE").or_else(|_| std::env::var("HOME"));
|
||||
let Ok(home) = home else {
|
||||
return;
|
||||
};
|
||||
super::record("launch", &format!("argv --nativesDir {home}/natives"));
|
||||
let text = std::fs::read_to_string(dir.join(super::FILE_NAME)).expect("读日志");
|
||||
assert!(text.contains("<home>"), "{text}");
|
||||
assert!(!text.contains(home.as_str()), "家目录原文还在:{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buffer_is_bounded_before_the_dir_is_known() {
|
||||
let _guard = LOCK.lock().unwrap_or_else(|error| error.into_inner());
|
||||
super::state_reset();
|
||||
for index in 0..(MAX_BUFFERED + 50) {
|
||||
super::record("boot", &format!("line {index}"));
|
||||
}
|
||||
let dir = temp_dir("bounded");
|
||||
super::set_dir(&dir);
|
||||
let text = std::fs::read_to_string(dir.join(super::FILE_NAME)).expect("读日志");
|
||||
let lines = text.lines().count();
|
||||
assert!(lines <= MAX_BUFFERED, "缓冲没有上限:{lines} 行");
|
||||
assert!(lines > 0);
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,8 @@ pub mod adapter;
|
||||
pub mod args_table;
|
||||
pub mod argument;
|
||||
pub mod command_builder;
|
||||
pub mod crash_bundle;
|
||||
pub mod crash_report;
|
||||
pub mod crash_scan;
|
||||
pub mod defaults;
|
||||
pub mod diagnosis_hook;
|
||||
@@ -38,6 +40,7 @@ pub mod game_version;
|
||||
pub mod jvm_args;
|
||||
pub mod launch_session;
|
||||
pub mod launcher;
|
||||
pub mod launcher_log;
|
||||
pub mod log_level;
|
||||
pub mod natives;
|
||||
pub mod overrides;
|
||||
@@ -62,6 +65,7 @@ pub use adapter::{
|
||||
build_request, AdapterOptions, AdapterOutput, AdapterWarning, LibraryInput, SnapshotInput,
|
||||
};
|
||||
pub use args_table::{assemble, AssembledCommand, AssemblyWarning};
|
||||
pub use crash_report::{ArtifactFact, CrashReport, Evidence, ReportContext};
|
||||
pub use crash_scan::{CrashArtifact, CrashSource};
|
||||
pub use diagnosis_hook::{DiagnosisHook, DiagnosisRequest, NoopDiagnosis};
|
||||
pub use error::{LaunchError, MissingComponent, Result};
|
||||
|
||||
@@ -19,6 +19,8 @@ pub const DATA_DIR: &str = "data";
|
||||
pub const CACHE_DIR: &str = "cache";
|
||||
/// 分区:启动器日志。
|
||||
pub const LOGS_DIR: &str = "logs";
|
||||
/// `logs/` 下的游戏会话目录名(事件流与 `index.jsonl`;M188)。
|
||||
pub const GAME_LOG_DIR: &str = "game";
|
||||
|
||||
/// 全局设置文件名。
|
||||
pub const LAUNCHER_CONFIG_FILE: &str = "launcher.json";
|
||||
@@ -181,7 +183,25 @@ impl LauncherLayout {
|
||||
self.data().join("auth")
|
||||
}
|
||||
|
||||
/// `data/agent/sessions/`——AI 会话 jsonl 与审计日志。
|
||||
/// 会话根(M188 起 = `logs/`)。
|
||||
///
|
||||
/// `launch::LauncherConfig` 在会话根下自己建 `game/`,所以「会话根」与「事件流目录」差一层:
|
||||
/// 根是这里,真落点是 [`Self::game_logs`]。
|
||||
#[must_use]
|
||||
pub fn sessions_root(&self) -> PathBuf {
|
||||
self.logs()
|
||||
}
|
||||
|
||||
/// `logs/game/`——游戏会话事件流(`<session-id>.jsonl`)与 `index.jsonl` 的真落点(M188)。
|
||||
#[must_use]
|
||||
pub fn game_logs(&self) -> PathBuf {
|
||||
self.logs().join(GAME_LOG_DIR)
|
||||
}
|
||||
|
||||
/// `data/agent/sessions/`——会话日志的**旧家**(M188 起只读兼容)。
|
||||
///
|
||||
/// 目录名里的 `agent` 是历史遗留:它从来只装游戏会话,与 AI 无关。M188 把新会话挪到
|
||||
/// `logs/game/`,这里**不搬不删**(用户的数据不该被偷偷移动),只是继续被读出来。
|
||||
#[must_use]
|
||||
pub fn agent_sessions(&self) -> PathBuf {
|
||||
self.data().join("agent").join("sessions")
|
||||
|
||||
@@ -108,17 +108,17 @@ pub fn launcher_schema() -> SettingSchema {
|
||||
))
|
||||
.with(SettingSpec::int(
|
||||
"download.concurrency",
|
||||
8,
|
||||
(1, 256),
|
||||
crate::download::config::DEFAULT_MAX_CONCURRENCY as i64,
|
||||
(1, crate::download::config::MAX_CONCURRENCY as i64),
|
||||
AiPolicy::silent(),
|
||||
"下载并发数",
|
||||
"同时下载的文件数(1~64,出厂 8)。这是引擎真实持有的并发许可,不是展示值;越界或手改坏值按出厂值生效",
|
||||
))
|
||||
.with(SettingSpec::int(
|
||||
"download.retry_limit",
|
||||
3,
|
||||
(0, 10),
|
||||
crate::download::config::DEFAULT_MAX_ATTEMPTS_PER_SOURCE as i64,
|
||||
(0, crate::download::config::MAX_ATTEMPTS_PER_SOURCE as i64),
|
||||
AiPolicy::silent(),
|
||||
"单个文件重试次数",
|
||||
"单个下载源的最多尝试次数(含首次;出厂 3)。0 与 1 都等于「只试一次、不重试」——0 是旧版 GUI 提供过的档位,保留合法但不代表「不下载」。换源不受此限",
|
||||
))
|
||||
.with(SettingSpec::enumeration(
|
||||
"download.region",
|
||||
|
||||
@@ -132,7 +132,7 @@ impl<S: UpdateSource> UpdateChecker<S> {
|
||||
/// 清单取不到、清单不合法、渠道不符都返回 [`UpdateError`]。
|
||||
/// 「没有更新」不是错误,走 [`CheckOutcome::UpToDate`]。
|
||||
pub fn check(&self, current: &Version, channel: Channel) -> Result<CheckOutcome, UpdateError> {
|
||||
let Some(manifest) = self.source.fetch_manifest(channel)? else {
|
||||
let Some(manifest) = super::source::verified_manifest(&self.source, channel)? else {
|
||||
return Ok(CheckOutcome::UpToDate {
|
||||
current: current.clone(),
|
||||
latest: None,
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
//! Ed25519 **严格验签**(`ed25519-dalek::verify_strict`),只验不签、只用公开数据。
|
||||
//!
|
||||
//! # 为什么是官方实现,而不是本仓自写
|
||||
//!
|
||||
//! M190 第一版为了「scope 里不含 `crates/core/Cargo.toml`」把曲线算术写在了仓里
|
||||
//! (5x51 素域 + 扩展坐标 + 平方-乘)。r1 复审的裁定是**不允许**:这是**信任锚**,
|
||||
//! 非规范编码、小阶点这类微妙错误的后果是「验签通过但本不该通过」,自写的向量覆盖再全也证明不了
|
||||
//! 「严格性判据选对了」。当时的时机也最好:正式信任表(`trust::PRODUCTION`)还是空表,换实现
|
||||
//! **零兼容负担**。于是这里只剩一层薄封装。
|
||||
//!
|
||||
//! # 判据分工(**实测口径**,`ed25519-dalek 2.2.0`;别照抄「常识」)
|
||||
//!
|
||||
//! | 步骤 | 它实际做什么 | 它**不**做什么 |
|
||||
//! |---|---|---|
|
||||
//! | `VerifyingKey::from_bytes` | 解压成曲线上的合法点;解不出来才 `Err` | **不**校验编码规范性:`y >= p` 这种第二种写法照样 `Ok`(此时 `is_weak() == true`)。M190 r2 复审实测并纠正了先前「要求规范编码」的说法 |
|
||||
//! | `VerifyingKey::verify`(**非 strict**,本仓不用) | 验方程 `[S]B = R + [k]A`,并检查 `S` 是**规范标量**(`S >= L` 会拒) | **不**检查小阶点:小阶公钥 / 小阶 `R` 下「自洽的伪造四元组」能验过 |
|
||||
//! | `VerifyingKey::verify_strict`(**本仓用的这个**) | 上面全部 + 拒**小阶公钥**与**小阶 `R`** | —— |
|
||||
//!
|
||||
//! 换成大白话:在这批拒绝类用例里,strict 与**非** strict 唯一的分歧是**小阶点**(不是可锻性——
|
||||
//! `verify` 自己就查 `S` 规范性)。所以守卫里真正承重的是
|
||||
//! `tests/ed25519_cases.rs::small_order_public_key_is_rejected` 与
|
||||
//! `small_order_r_is_rejected_only_by_strict` 这两条;`malleable_s_plus_l_is_rejected` 等其余几条在
|
||||
//! 非 strict 下**照样绿**,它们钉的是「判据没被删成恒真」,不要误当成 strict 的守卫。
|
||||
//!
|
||||
//! # 签名侧不在二进制里
|
||||
//!
|
||||
//! 本仓没有任何私钥代码:清单由 `packaging/sign_manifest.py`(纯 Python 参考实现,零第三方依赖)
|
||||
//! 在发布侧签。测试里的签名夹具走 dalek 的 `SigningKey`,只在 `cfg(test)` 里编译。
|
||||
|
||||
use ed25519_dalek::{Signature, VerifyingKey};
|
||||
|
||||
/// 公钥字节数。
|
||||
pub(crate) const PUBLIC_KEY_LEN: usize = 32;
|
||||
/// 签名字节数。
|
||||
pub(crate) const SIGNATURE_LEN: usize = 64;
|
||||
|
||||
/// 验一条 Ed25519 签名(**严格**语义,见模块文档)。
|
||||
///
|
||||
/// `message` 是任意长原文——这里恒为清单文件的**原始字节**(见 [`super::signature`])。
|
||||
#[must_use]
|
||||
pub(crate) fn verify(
|
||||
public_key: &[u8; PUBLIC_KEY_LEN],
|
||||
message: &[u8],
|
||||
signature: &[u8; SIGNATURE_LEN],
|
||||
) -> bool {
|
||||
let Ok(key) = VerifyingKey::from_bytes(public_key) else {
|
||||
// 不是曲线上的合法点:直接拒。注意 `from_bytes` **不**管编码规范性(见模块文档的表)。
|
||||
return false;
|
||||
};
|
||||
key.verify_strict(message, &Signature::from_bytes(signature))
|
||||
.is_ok()
|
||||
}
|
||||
@@ -33,6 +33,21 @@ pub enum UpdateErrorKind {
|
||||
DigestMismatch,
|
||||
/// 长度不符:典型成因是下载被截断,也可能清单写错。
|
||||
SizeMismatch,
|
||||
/// 清单没有配套签名文件(`<清单名>.sig` 不在源里)。
|
||||
///
|
||||
/// 与 [`Self::SignatureInvalid`] 分开:这条是**发布侧漏了签名**,不是清单被改;
|
||||
/// 处置也不同(等发布侧补,或回滚发布),所以必须是两个分类。
|
||||
SignatureMissing,
|
||||
/// 签名文件本身坏了:不是 JSON、字段名拼错、`format` 不认识、`signatures` 是空的。
|
||||
SignatureMalformed,
|
||||
/// 本构建没有钉任何清单签名公钥(正式信任表为空)。
|
||||
///
|
||||
/// 这是「构建形态」的问题,不是清单的问题:空表一律拒装,绝不「没钥就放行」。
|
||||
KeyringEmpty,
|
||||
/// 清单的签名者不在钉住的信任表里(`key_id` 一个都没命中)。
|
||||
SignatureUnknownKey,
|
||||
/// 签名对不上清单字节(清单被改过,或签名是给另一份清单的)。
|
||||
SignatureInvalid,
|
||||
}
|
||||
|
||||
impl UpdateErrorKind {
|
||||
@@ -49,6 +64,11 @@ impl UpdateErrorKind {
|
||||
Self::Io => "io",
|
||||
Self::DigestMismatch => "digest_mismatch",
|
||||
Self::SizeMismatch => "size_mismatch",
|
||||
Self::SignatureMissing => "signature_missing",
|
||||
Self::SignatureMalformed => "signature_malformed",
|
||||
Self::KeyringEmpty => "keyring_empty",
|
||||
Self::SignatureUnknownKey => "signature_unknown_key",
|
||||
Self::SignatureInvalid => "signature_invalid",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +76,9 @@ impl UpdateErrorKind {
|
||||
///
|
||||
/// 清单本身不合法、协议不支持、渠道不符这几类换多少源都一样,所以只有字节层
|
||||
/// (摘要/长度)、网络(可能是瞬时抖动)与 IO(可能是瞬时占用)算可重试。
|
||||
///
|
||||
/// 签名五兄弟**一律不可重试**:签名不对不是抖动,重试一千次还是不对。把它们标成可重试
|
||||
/// 会让壳层反复弹「网络问题,稍后重试」,掩盖真正的原因(发布侧漏签 / 钥没钉 / 清单被改)。
|
||||
#[must_use]
|
||||
pub const fn is_retryable(self) -> bool {
|
||||
matches!(
|
||||
@@ -63,6 +86,19 @@ impl UpdateErrorKind {
|
||||
Self::DigestMismatch | Self::SizeMismatch | Self::Network | Self::Io
|
||||
)
|
||||
}
|
||||
|
||||
/// 是否属于「清单签名」这一组(壳层据此换文案:这不是网络问题,别让用户重试)。
|
||||
#[must_use]
|
||||
pub const fn is_signature(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::SignatureMissing
|
||||
| Self::SignatureMalformed
|
||||
| Self::KeyringEmpty
|
||||
| Self::SignatureUnknownKey
|
||||
| Self::SignatureInvalid
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 自更新错误。
|
||||
|
||||
@@ -60,8 +60,8 @@ use crate::download::{
|
||||
|
||||
use super::channel::Channel;
|
||||
use super::error::{UpdateError, UpdateErrorKind};
|
||||
use super::manifest::{Artifact, ReleaseManifest};
|
||||
use super::source::UpdateSource;
|
||||
use super::manifest::Artifact;
|
||||
use super::source::{ManifestDocuments, UpdateSource};
|
||||
|
||||
/// 默认仓库 owner。
|
||||
pub const DEFAULT_OWNER: &str = "AskaEth";
|
||||
@@ -71,6 +71,8 @@ pub const DEFAULT_REPO: &str = "Unlucky-Launcher";
|
||||
pub const DEFAULT_BASE: &str = "https://github.com";
|
||||
/// 清单文件名(挂在最新 release 的产物里,地址由 D50 定死)。
|
||||
pub const MANIFEST_ASSET: &str = "manifest.json";
|
||||
/// 清单签名的资产名(M190:与清单并列挂在同一个 release 上)。
|
||||
pub const MANIFEST_SIGNATURE_ASSET: &str = "manifest.json.sig";
|
||||
|
||||
/// GitHub Releases 更新源。
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -265,27 +267,24 @@ impl UpdateSource for GitHubReleaseSource {
|
||||
&self.id
|
||||
}
|
||||
|
||||
fn fetch_manifest(&self, channel: Channel) -> Result<Option<ReleaseManifest>, UpdateError> {
|
||||
fn fetch_manifest_documents(
|
||||
&self,
|
||||
channel: Channel,
|
||||
) -> Result<Option<ManifestDocuments>, UpdateError> {
|
||||
let Some(url) = self.manifest_url(channel) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(text) = self.fetch_text(&url)? else {
|
||||
let Some(manifest) = self.fetch_text(&url)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let manifest = ReleaseManifest::from_json(&text).map_err(|mut error| {
|
||||
error.message = format!("{}(来源 {url})", error.message);
|
||||
error
|
||||
})?;
|
||||
if manifest.channel != channel {
|
||||
return Err(UpdateError::new(
|
||||
UpdateErrorKind::ChannelMismatch,
|
||||
format!(
|
||||
"请求的是 {channel} 渠道,清单 {url} 自报 {}",
|
||||
manifest.channel
|
||||
),
|
||||
));
|
||||
}
|
||||
Ok(Some(manifest))
|
||||
// 签名文件的 404 与清单的 404 处理不同:清单没有 = 该渠道没发过版(不是错误);
|
||||
// 签名没有 = 发布侧漏了签名(上层判 SignatureMissing 并拒装)。
|
||||
let signature_url = format!("{}/{MANIFEST_SIGNATURE_ASSET}", self.download_base());
|
||||
let signatures = self.fetch_text(&signature_url)?;
|
||||
Ok(Some(ManifestDocuments {
|
||||
manifest,
|
||||
signatures,
|
||||
}))
|
||||
}
|
||||
|
||||
fn fetch_artifact(&self, artifact: &Artifact, dest: &Path) -> Result<u64, UpdateError> {
|
||||
|
||||
@@ -227,11 +227,11 @@ impl UpdateSource for SourceRef<'_> {
|
||||
self.0.source_id()
|
||||
}
|
||||
|
||||
fn fetch_manifest(
|
||||
fn fetch_manifest_documents(
|
||||
&self,
|
||||
channel: Channel,
|
||||
) -> Result<Option<super::manifest::ReleaseManifest>, UpdateError> {
|
||||
self.0.fetch_manifest(channel)
|
||||
) -> Result<Option<super::source::ManifestDocuments>, UpdateError> {
|
||||
self.0.fetch_manifest_documents(channel)
|
||||
}
|
||||
|
||||
fn fetch_artifact(
|
||||
|
||||
@@ -30,6 +30,8 @@
|
||||
//! manifest 清单模型 + JSON 校验 target 目标平台与包形态
|
||||
//! source UpdateSource trait + 本地目录源 github GitHub Releases 源(D50)
|
||||
//! verify 暂存 + 长度/摘要校验 checker 选择哪一份发布、急不急
|
||||
//! signature 清单签名信封 + 验签 trust 钉进二进制的公钥表(keyring)
|
||||
//! ed25519 Ed25519 严格验签(ed25519-dalek::verify_strict 的薄封装)
|
||||
//! install 原子替换 + 回滚 replace_io 改名/复制/重试与跨卷判据(注入点)
|
||||
//! error 分类与文案 job 检查/应用的一条龙(GUI 与 CLI 共用同一入口)
|
||||
//! ```
|
||||
@@ -49,17 +51,32 @@
|
||||
//! `--apply-to`),属启动器外壳的编排;本模块只保证搬运本身原子且可回滚。
|
||||
//! * **不装安装器形态**(msi/dmg/deb/rpm):交给系统包管理器。
|
||||
//! * **不做差分/增量**:清单里没有 patch 字段,宁可每次全量,也别先上一套没验过的差分协议。
|
||||
//!
|
||||
//! # 信任根(M190)
|
||||
//!
|
||||
//! ```text
|
||||
//! 源只取字节 ──> signature::verify_manifest(清单**原始字节** + `manifest.json.sig`)
|
||||
//! │ 判据:钉死的公钥(trust::keyring,编译期常量)验 Ed25519
|
||||
//! ├─ 过了 ──> manifest::from_json ──> checker/apply
|
||||
//! └─ 不过 ──> 五类签名错误(不可重试),**什么都不会被安装**
|
||||
//! ```
|
||||
//!
|
||||
//! 一句话:**先验签、再解析**。清单的 sha256 只保证「包体和清单对上」,清单本身可不可信由签名回答——
|
||||
//! 两条判据互不替代(`tests/signature_cases.rs` 分别证明)。
|
||||
|
||||
pub mod channel;
|
||||
pub mod checker;
|
||||
mod ed25519;
|
||||
pub mod error;
|
||||
pub mod github;
|
||||
pub mod install;
|
||||
pub mod job;
|
||||
pub mod manifest;
|
||||
mod replace_io;
|
||||
mod signature;
|
||||
pub mod source;
|
||||
pub mod target;
|
||||
mod trust;
|
||||
pub mod verify;
|
||||
pub mod version;
|
||||
|
||||
@@ -77,7 +94,8 @@ pub use self::job::{
|
||||
default_source, process_dest, staging_dir, Applied, Job, UPDATE_DIR_ENV, UPDATE_TEST_SOURCE_ENV,
|
||||
};
|
||||
pub use self::manifest::{Artifact, Release, ReleaseManifest};
|
||||
pub use self::source::{DirSource, UpdateSource};
|
||||
pub use self::signature::VerifiedSignature;
|
||||
pub use self::source::{verified_manifest, DirSource, ManifestDocuments, UpdateSource};
|
||||
pub use self::target::{Arch, PackageFormat, Target};
|
||||
pub use self::verify::{stage, verify_file, StagedPackage};
|
||||
pub use self::version::{Stage, Version, VersionError};
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
//! 清单签名信封:`manifest.json.sig` 的解析与验签。
|
||||
//!
|
||||
//! # 为什么是**分离**的签名文件,而不是清单里的一个 `signatures` 字段
|
||||
//!
|
||||
//! 签名只能覆盖「签的时候那份字节」。把签名塞进清单本身,验签就得先把那个字段从 JSON 里摘出来
|
||||
//! 再规范化(空白、键序、数字写法全都得钉死),任何一处规范化差异都会让「同一份内容」有两种字节,
|
||||
//! 于是「改字节不改语义」的老把戏就回来了。分离文件把这件事变简单:**签名覆盖清单文件的原始字节**,
|
||||
//! 一个 bit 都不能变。顺序也就固定了:先验签,再解析。
|
||||
//!
|
||||
//! ```json
|
||||
//! {"format": 1,
|
||||
//! "signatures": [
|
||||
//! {"alg": "ed25519", "key_id": "ull-release-2026a", "sig": "<128 位十六进制>",
|
||||
//! "manifest_sha256": "<64 位十六进制>"}
|
||||
//! ]}
|
||||
//! ```
|
||||
//!
|
||||
//! `manifest_sha256` 是冗余但**有意**的:它让「清单变了而签名没跟着换」这种最常见的发布事故
|
||||
//! 给出**具体**原因(签名声明的是哪一份、手上是哪一份),而不是一句「验签失败」。它不参与判据
|
||||
//! ——判据永远是 Ed25519 的那条方程。
|
||||
//!
|
||||
//! # 多签名
|
||||
//!
|
||||
//! 数组里可以有任意多条,**任意一条**由钉死的钥验过就放行。这给了「新旧钥并存」的窗口期:
|
||||
//! 轮换期一份清单同时带旧钥与新钥签名,装着旧二进制的用户与装着新二进制的用户都过得去。
|
||||
//!
|
||||
//! # 判据顺序(每条都能单独变红)
|
||||
//!
|
||||
//! 1. 信封存在且能解析(`format` 必须是 1);
|
||||
//! 2. 本构建钉了至少一把公钥([`super::trust::keyring`] 非空)——空表一律拒,绝不「没钥就放行」;
|
||||
//! 3. 至少一条签名的 `key_id` 命中钉住的表,且它的 `alg` 是 `ed25519`;
|
||||
//! 4. 那条签名对**清单原始字节**验过;
|
||||
//! 5. 若签名带 `manifest_sha256`,与清单字节的 SHA-256 相符(只为给出可读原因,不替代第 4 条)。
|
||||
|
||||
use serde::Deserialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use super::ed25519::{verify, PUBLIC_KEY_LEN, SIGNATURE_LEN};
|
||||
use super::error::{UpdateError, UpdateErrorKind};
|
||||
use super::trust;
|
||||
|
||||
/// 信封格式版本(改字段形状要抬它,老客户端会以「格式不支持」明确拒绝,而不是静默不认签名)。
|
||||
pub(crate) const ENVELOPE_FORMAT: u32 = 1;
|
||||
|
||||
/// 验收通过的签名摘要(进日志/CLI 输出:谁签的、用哪把钥验过的)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct VerifiedSignature {
|
||||
/// 验过的 `key_id`。
|
||||
pub key_id: String,
|
||||
/// 算法标识(当前只有 `ed25519`)。
|
||||
pub alg: String,
|
||||
}
|
||||
|
||||
/// 清单的签名信封。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct Envelope {
|
||||
format: u32,
|
||||
signatures: Vec<EnvelopeSignature>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct EnvelopeSignature {
|
||||
alg: String,
|
||||
key_id: String,
|
||||
sig: String,
|
||||
manifest_sha256: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct RawEnvelope {
|
||||
format: u32,
|
||||
#[serde(default)]
|
||||
signatures: Vec<RawEnvelopeSignature>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct RawEnvelopeSignature {
|
||||
alg: String,
|
||||
key_id: String,
|
||||
sig: String,
|
||||
manifest_sha256: Option<String>,
|
||||
}
|
||||
|
||||
/// 验一份清单的签名。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// * [`UpdateErrorKind::SignatureMissing`]:源里没有配套的签名文件;
|
||||
/// * [`UpdateErrorKind::SignatureMalformed`]:信封不是合法 JSON / 字段名拼错 / `format` 不认识;
|
||||
/// * [`UpdateErrorKind::KeyringEmpty`]:本构建一把公钥都没钉(正式表还没录钥);
|
||||
/// * [`UpdateErrorKind::SignatureUnknownKey`]:所有签名的 `key_id` 都不在钉住的表里;
|
||||
/// * [`UpdateErrorKind::SignatureInvalid`]:命中了钥但对清单字节验不过(清单被改过、签名不是给这份的)。
|
||||
pub(crate) fn verify_manifest(
|
||||
manifest_bytes: &[u8],
|
||||
envelope_text: Option<&str>,
|
||||
) -> Result<VerifiedSignature, UpdateError> {
|
||||
let Some(text) = envelope_text else {
|
||||
return Err(UpdateError::new(
|
||||
UpdateErrorKind::SignatureMissing,
|
||||
"清单没有配套签名(清单旁边缺 `<清单名>.sig`):本启动器只安装「清单签名对得上」的更新,\
|
||||
没有签名一律拒装——这不是你的问题,是发布侧漏了签名",
|
||||
));
|
||||
};
|
||||
let envelope = parse_envelope(text)?;
|
||||
let keys = trust::keyring();
|
||||
if keys.is_empty() {
|
||||
return Err(UpdateError::new(
|
||||
UpdateErrorKind::KeyringEmpty,
|
||||
"本构建没有钉任何清单签名公钥(正式表为空),因此任何清单都无法验签:\
|
||||
请勿绕过,这是「还没录钥」的构建,不是清单有问题",
|
||||
));
|
||||
}
|
||||
let actual_sha256 = hex_sha256(manifest_bytes);
|
||||
let mut unknown = Vec::new();
|
||||
let mut attempted = Vec::new();
|
||||
for signature in &envelope.signatures {
|
||||
if signature.alg != "ed25519" {
|
||||
unknown.push(format!("{}({})", signature.key_id, signature.alg));
|
||||
continue;
|
||||
}
|
||||
let Some(pinned) = keys.iter().find(|key| key.key_id == signature.key_id) else {
|
||||
unknown.push(format!("{}(未钉住)", signature.key_id));
|
||||
continue;
|
||||
};
|
||||
let Some(public_key) = trust::decode_hex(pinned.public_key_hex) else {
|
||||
return Err(UpdateError::invalid_manifest(format!(
|
||||
"钉住的公钥 `{}` 不是合法十六进制(这是构建缺陷,不是清单的问题)",
|
||||
pinned.key_id
|
||||
)));
|
||||
};
|
||||
if public_key.len() != PUBLIC_KEY_LEN {
|
||||
return Err(UpdateError::invalid_manifest(format!(
|
||||
"钉住的公钥 `{}` 长 {} 字节,Ed25519 公钥必须是 {PUBLIC_KEY_LEN} 字节(构建缺陷)",
|
||||
pinned.key_id,
|
||||
public_key.len()
|
||||
)));
|
||||
}
|
||||
let mut key_bytes = [0_u8; PUBLIC_KEY_LEN];
|
||||
key_bytes.copy_from_slice(&public_key);
|
||||
let Some(signature_bytes) = trust::decode_hex(&signature.sig) else {
|
||||
attempted.push(format!("{}(`sig` 不是十六进制)", signature.key_id));
|
||||
continue;
|
||||
};
|
||||
if signature_bytes.len() != SIGNATURE_LEN {
|
||||
attempted.push(format!(
|
||||
"{}(`sig` 长 {} 字节,应为 {SIGNATURE_LEN})",
|
||||
signature.key_id,
|
||||
signature_bytes.len()
|
||||
));
|
||||
continue;
|
||||
}
|
||||
let mut sig_bytes = [0_u8; SIGNATURE_LEN];
|
||||
sig_bytes.copy_from_slice(&signature_bytes);
|
||||
if !verify(&key_bytes, manifest_bytes, &sig_bytes) {
|
||||
attempted.push(signature.key_id.clone());
|
||||
continue;
|
||||
}
|
||||
// 第 5 条:只为「把话说清楚」,不替代上面那条方程。
|
||||
if let Some(declared) = &signature.manifest_sha256 {
|
||||
if declared.to_ascii_lowercase() != actual_sha256 {
|
||||
return Err(UpdateError::new(
|
||||
UpdateErrorKind::SignatureInvalid,
|
||||
format!(
|
||||
"清单签名声明的是另一份清单(签名里的 sha256 = {},手上这份 = {}):\
|
||||
签名对上了、内容不是它签的那份",
|
||||
short(declared),
|
||||
short(&actual_sha256)
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
return Ok(VerifiedSignature {
|
||||
key_id: signature.key_id.clone(),
|
||||
alg: signature.alg.clone(),
|
||||
});
|
||||
}
|
||||
if attempted.is_empty() {
|
||||
return Err(UpdateError::new(
|
||||
UpdateErrorKind::SignatureUnknownKey,
|
||||
format!(
|
||||
"清单由本构建不认识的钥签名({}):本构建钉住的 key_id = [{}]。\
|
||||
要么清单是别人签的,要么发布侧用了还没进信任表的新钥",
|
||||
unknown.join("、"),
|
||||
keys.iter()
|
||||
.map(|key| key.key_id)
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
),
|
||||
));
|
||||
}
|
||||
Err(UpdateError::new(
|
||||
UpdateErrorKind::SignatureInvalid,
|
||||
format!(
|
||||
"清单签名验不过(key_id = {},清单 sha256 = {}):这份清单不是那把钥签的,\
|
||||
或签名之后清单被改过。绝不安装",
|
||||
attempted.join("、"),
|
||||
short(&actual_sha256)
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
/// 解析信封(`deny_unknown_fields`:字段名拼错宁可在 CI/发布侧当场炸,也别在用户机器上变成「验签失败」)。
|
||||
fn parse_envelope(text: &str) -> Result<Envelope, UpdateError> {
|
||||
let raw: RawEnvelope = serde_json::from_str(text).map_err(|error| {
|
||||
UpdateError::new(
|
||||
UpdateErrorKind::SignatureMalformed,
|
||||
format!("清单签名文件不是合法 JSON({error})"),
|
||||
)
|
||||
})?;
|
||||
if raw.format != ENVELOPE_FORMAT {
|
||||
return Err(UpdateError::new(
|
||||
UpdateErrorKind::SignatureMalformed,
|
||||
format!(
|
||||
"清单签名格式 {} 不认识(本构建只认 {ENVELOPE_FORMAT}):请升级启动器",
|
||||
raw.format
|
||||
),
|
||||
));
|
||||
}
|
||||
if raw.signatures.is_empty() {
|
||||
return Err(UpdateError::new(
|
||||
UpdateErrorKind::SignatureMalformed,
|
||||
"清单签名文件里 `signatures` 是空的:签了个空",
|
||||
));
|
||||
}
|
||||
Ok(Envelope {
|
||||
format: raw.format,
|
||||
signatures: raw
|
||||
.signatures
|
||||
.into_iter()
|
||||
.map(|item| EnvelopeSignature {
|
||||
alg: item.alg,
|
||||
key_id: item.key_id,
|
||||
sig: item.sig,
|
||||
manifest_sha256: item.manifest_sha256,
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
|
||||
/// 清单字节的 SHA-256(小写十六进制)。
|
||||
pub(crate) fn hex_sha256(bytes: &[u8]) -> String {
|
||||
let digest = Sha256::digest(bytes);
|
||||
let mut out = String::with_capacity(64);
|
||||
for byte in digest {
|
||||
out.push_str(&format!("{byte:02x}"));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 日志/文案里只露前 8 位。
|
||||
fn short(hex: &str) -> String {
|
||||
let trimmed: String = hex.trim().chars().take(8).collect();
|
||||
format!("{trimmed}…")
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
//!
|
||||
//! ```text
|
||||
//! <root>/stable.json 渠道清单(文件名 = 渠道名 + .json)
|
||||
//! <root>/stable.json.sig 清单签名信封(M190;缺了它清单一律拒装,见 `signature`)
|
||||
//! <root>/beta.json
|
||||
//! <root>/<产物文件> 清单里 url 写相对路径时,相对 root
|
||||
//! ```
|
||||
@@ -26,6 +27,19 @@ use super::channel::Channel;
|
||||
use super::error::{UpdateError, UpdateErrorKind};
|
||||
use super::manifest::{Artifact, ReleaseManifest};
|
||||
|
||||
/// 源交出来的「清单两份原文」:清单本身 + 配套签名信封(M190)。
|
||||
///
|
||||
/// 刻意是**原文**而不是解析后的结构:签名只能覆盖字节,先解析再验签就等于给「解析器认了、
|
||||
/// 签名没认」的东西开了门。判据顺序因此固定为「先验签、再解析」,两者都在
|
||||
/// [`verified_manifest`] 里,源实现自己不做任何验签。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ManifestDocuments {
|
||||
/// 清单原文(`manifest.json` / `<channel>.json` 的字节,按 UTF-8 解释)。
|
||||
pub manifest: String,
|
||||
/// 签名信封原文(`<清单名>.sig`);`None` = 源里没有这份文件(会被判 [`UpdateErrorKind::SignatureMissing`])。
|
||||
pub signatures: Option<String>,
|
||||
}
|
||||
|
||||
/// 更新源:清单与包体的来源。
|
||||
///
|
||||
/// 实现必须是 `Send + Sync`(下载与替换可能在不同线程上跑),且**不持有运行时**:
|
||||
@@ -34,12 +48,16 @@ pub trait UpdateSource: Send + Sync {
|
||||
/// 源标识(日志/事件里的 `source` 字段,如 `file:D:\dist`;健康度也按它归并)。
|
||||
fn source_id(&self) -> &str;
|
||||
|
||||
/// 取某渠道的清单;`Ok(None)` = 该渠道还没发布过(不是错误)。
|
||||
/// 取某渠道清单的两份原文;`Ok(None)` = 该渠道还没发布过(不是错误)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 读不到文件、JSON 坏、清单自报的渠道与 `channel` 不符都返回 [`UpdateError`]。
|
||||
fn fetch_manifest(&self, channel: Channel) -> Result<Option<ReleaseManifest>, UpdateError>;
|
||||
/// 读不到文件、响应为空、网络失败都返回 [`UpdateError`]。**这里不验签、不解析**
|
||||
/// (见 [`ManifestDocuments`])——管线的下一站是 [`verified_manifest`]。
|
||||
fn fetch_manifest_documents(
|
||||
&self,
|
||||
channel: Channel,
|
||||
) -> Result<Option<ManifestDocuments>, UpdateError>;
|
||||
|
||||
/// 把产物取到 `dest`,返回写入的字节数。
|
||||
///
|
||||
@@ -52,6 +70,52 @@ pub trait UpdateSource: Send + Sync {
|
||||
fn fetch_artifact(&self, artifact: &Artifact, dest: &Path) -> Result<u64, UpdateError>;
|
||||
}
|
||||
|
||||
/// 取清单 → 验签 → 解析 → 渠道校验(**唯一**入口,GUI 与 CLI 共用)。
|
||||
///
|
||||
/// 它是个**自由函数**而不是 trait 的默认方法:trait 方法可被任何实现方覆写,那样「信任根的收口点」
|
||||
/// 就只剩纪律(M190 r1 复审 P2-2)。删掉那条默认实现之后,调用方手里只有源交出来的**原文**
|
||||
/// ([`ManifestDocuments`]),想绕过验签就必须自己重写一遍解析 + 渠道判据 —— 不再是「一行覆写」。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 源里没有该渠道清单 ⇒ `Ok(None)`;签名/解析/渠道任一不过 ⇒ [`UpdateError`]。
|
||||
/// 签名相关的分类见 [`UpdateErrorKind::is_signature`],它们**不可重试**。
|
||||
pub fn verified_manifest<S: UpdateSource + ?Sized>(
|
||||
source: &S,
|
||||
channel: Channel,
|
||||
) -> Result<Option<ReleaseManifest>, UpdateError> {
|
||||
let Some(documents) = source.fetch_manifest_documents(channel)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
// ① 先验签:拿到的是不是「我们发的那份字节」,在这里一次说清。
|
||||
let source_label = source.source_id().to_string();
|
||||
super::signature::verify_manifest(
|
||||
documents.manifest.as_bytes(),
|
||||
documents.signatures.as_deref(),
|
||||
)
|
||||
.map_err(|mut error| {
|
||||
error.message = format!("{}(来源 {source_label})", error.message);
|
||||
error
|
||||
})?;
|
||||
// ② 再解析:到了这一步,字节已经是发布侧签过的那份,解析失败只可能是签名者也写错了。
|
||||
let manifest = ReleaseManifest::from_json(&documents.manifest).map_err(|mut error| {
|
||||
error.message = format!("{}(来源 {source_label})", error.message);
|
||||
error
|
||||
})?;
|
||||
// ③ 渠道:清单自报的渠道与请求的对不上是源的问题,不能悄悄当成「没有更新」。
|
||||
if manifest.channel != channel {
|
||||
return Err(UpdateError::new(
|
||||
UpdateErrorKind::ChannelMismatch,
|
||||
format!(
|
||||
"请求的是 {channel} 渠道,清单(源 {})自报 {}",
|
||||
source.source_id(),
|
||||
manifest.channel
|
||||
),
|
||||
));
|
||||
}
|
||||
Ok(Some(manifest))
|
||||
}
|
||||
|
||||
/// `Box<dyn UpdateSource>` 也是 `UpdateSource`。
|
||||
///
|
||||
/// 为什么需要:壳层要**运行期**才决定用哪个源(生产走 GitHub、测试/内网走本地目录),而
|
||||
@@ -62,8 +126,11 @@ impl<T: UpdateSource + ?Sized> UpdateSource for Box<T> {
|
||||
(**self).source_id()
|
||||
}
|
||||
|
||||
fn fetch_manifest(&self, channel: Channel) -> Result<Option<ReleaseManifest>, UpdateError> {
|
||||
(**self).fetch_manifest(channel)
|
||||
fn fetch_manifest_documents(
|
||||
&self,
|
||||
channel: Channel,
|
||||
) -> Result<Option<ManifestDocuments>, UpdateError> {
|
||||
(**self).fetch_manifest_documents(channel)
|
||||
}
|
||||
|
||||
fn fetch_artifact(&self, artifact: &Artifact, dest: &Path) -> Result<u64, UpdateError> {
|
||||
@@ -111,6 +178,12 @@ impl DirSource {
|
||||
self.root.join(format!("{}.json", channel.as_str()))
|
||||
}
|
||||
|
||||
/// 某渠道清单的签名文件路径(`stable.json` → `stable.json.sig`)。
|
||||
#[must_use]
|
||||
pub fn signature_path(&self, channel: Channel) -> PathBuf {
|
||||
self.root.join(format!("{}.json.sig", channel.as_str()))
|
||||
}
|
||||
|
||||
/// 把清单里的 `url` 解析成源侧本地路径。
|
||||
fn resolve(&self, url: &str) -> Result<PathBuf, UpdateError> {
|
||||
if let Some(rest) = url.strip_prefix("file://") {
|
||||
@@ -149,27 +222,27 @@ impl UpdateSource for DirSource {
|
||||
&self.id
|
||||
}
|
||||
|
||||
fn fetch_manifest(&self, channel: Channel) -> Result<Option<ReleaseManifest>, UpdateError> {
|
||||
fn fetch_manifest_documents(
|
||||
&self,
|
||||
channel: Channel,
|
||||
) -> Result<Option<ManifestDocuments>, UpdateError> {
|
||||
let path = self.manifest_path(channel);
|
||||
let text = match fs::read_to_string(&path) {
|
||||
let manifest = match fs::read_to_string(&path) {
|
||||
Ok(text) => text,
|
||||
Err(source) if source.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(source) => return Err(UpdateError::io_at(&path, &source)),
|
||||
};
|
||||
let manifest = ReleaseManifest::from_json(&text).map_err(|error| error.with_path(&path))?;
|
||||
if manifest.channel != channel {
|
||||
return Err(UpdateError::new(
|
||||
UpdateErrorKind::ChannelMismatch,
|
||||
format!(
|
||||
"请求的是 {} 渠道,清单 {} 自报 {}",
|
||||
channel,
|
||||
path.display(),
|
||||
manifest.channel
|
||||
),
|
||||
)
|
||||
.with_path(&path));
|
||||
}
|
||||
Ok(Some(manifest))
|
||||
// 签名文件缺失**不是**「没有清单」:清单在、签名不在 ⇒ 上层判 SignatureMissing 并拒装。
|
||||
let signature_path = self.signature_path(channel);
|
||||
let signatures = match fs::read_to_string(&signature_path) {
|
||||
Ok(text) => Some(text),
|
||||
Err(source) if source.kind() == std::io::ErrorKind::NotFound => None,
|
||||
Err(source) => return Err(UpdateError::io_at(&signature_path, &source)),
|
||||
};
|
||||
Ok(Some(ManifestDocuments {
|
||||
manifest,
|
||||
signatures,
|
||||
}))
|
||||
}
|
||||
|
||||
fn fetch_artifact(&self, artifact: &Artifact, dest: &Path) -> Result<u64, UpdateError> {
|
||||
|
||||
@@ -17,7 +17,7 @@ fn target() -> Target {
|
||||
/// 新建一个只放了这一份清单的本地源;返回的 `TempDir` 要活到用例结束。
|
||||
fn source_with(json: &str, channel: Channel) -> (TempDir, DirSource) {
|
||||
let dir = TempDir::new("checker");
|
||||
dir.write(&format!("{}.json", channel.as_str()), json.as_bytes());
|
||||
dir.write_signed(&format!("{}.json", channel.as_str()), json);
|
||||
let source = DirSource::new(dir.path());
|
||||
(dir, source)
|
||||
}
|
||||
@@ -215,7 +215,7 @@ fn falls_back_to_the_newest_release_that_does_have_the_target() {
|
||||
#[test]
|
||||
fn channel_mismatch_in_the_source_is_an_error() {
|
||||
let tmp = TempDir::new("checker-mismatch");
|
||||
tmp.write("stable.json", manifest("beta", &[]).as_bytes());
|
||||
tmp.write_signed("stable.json", &manifest("beta", &[]));
|
||||
let source = crate::update::DirSource::new(tmp.path());
|
||||
let current = crate::update::Version::parse("0.2.1").expect("版本");
|
||||
let error = UpdateChecker::new(source, target())
|
||||
@@ -228,7 +228,7 @@ fn channel_mismatch_in_the_source_is_an_error() {
|
||||
fn prepare_stages_the_selected_artifact() {
|
||||
let tmp = TempDir::new("checker-prepare");
|
||||
tmp.write("a.zip", PAYLOAD);
|
||||
tmp.write("stable.json", stable_release("0.3.0").as_bytes());
|
||||
tmp.write_signed("stable.json", &stable_release("0.3.0"));
|
||||
let source = crate::update::DirSource::new(tmp.path());
|
||||
let checker = UpdateChecker::new(source, target());
|
||||
let current = crate::update::Version::parse("0.2.0").expect("版本");
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
//! Ed25519 验签判据:**钉住 `ed25519-dalek::verify_strict` 的行为**(RFC 8032 §7.1 向量 + 六条拒绝类)。
|
||||
//!
|
||||
//! M190 r1 复审把自写曲线算术整体换成了官方实现(理由见 `crate::update::ed25519` 的模块文档)。
|
||||
//! 这批用例的定位因此从「测自己的实现」变成「**钉 dalek 的严格语义**」:
|
||||
//!
|
||||
//! | 用例 | 它挡住什么 | 把 `verify_strict` 换成 `verify` 后会怎样(r2 实测) |
|
||||
//! |---|---|---|
|
||||
//! | `small_order_public_key_is_rejected` | 小阶公钥下「自洽的伪造」(谁都能凑) | **红** —— 这是**唯一**承重的守卫 |
|
||||
//! | `small_order_r_is_rejected_only_by_strict` | 小阶 `R`(R = 单位元)的同一族伪造 | **红**(其余几条不会) |
|
||||
//! | `rfc8032_vectors_verify` | 实现换成任何一个错的版本(向量来自 RFC 附录) | 仍绿(`verify` 也验方程) |
|
||||
//! | `malleable_s_plus_l_is_rejected` | `S >= L` 的可锻变体 | **仍绿**:`verify` 自己就查 `S` 规范性,别把它当 strict 的守卫 |
|
||||
//! | `non_canonical_r_encoding_is_rejected` | 非规范编码的点(`y >= p` 的第二种写法) | 仍绿(dalek 靠自己的下游判据拒,见该用例注释) |
|
||||
//! | `empty_signature_is_rejected` / `message_bit_flip` / `signature_bit_flip` | 判据本身失效(恒真) | 仍绿 |
|
||||
//! | `signing_key_round_trips` | 测试夹具(dalek `SigningKey`)与验签侧同一套语义 | 仍绿 |
|
||||
//!
|
||||
//! 口径来源:M190 r2 复审在 `ed25519-dalek 2.2.0` 上逐条实测(并把先前的四处错误说法打回)。
|
||||
//! **结论**:strict 与**非** strict 在这批输入上唯一的分歧是**小阶点**——所以想确认「strict 还开着」,
|
||||
//! 看前两条;其余几条只在「判据被删成恒真」时才会红。
|
||||
|
||||
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
|
||||
|
||||
use crate::update::ed25519::{verify, PUBLIC_KEY_LEN, SIGNATURE_LEN};
|
||||
|
||||
use super::support_sign::{hex_of, public_key_hex, sign_bytes};
|
||||
|
||||
/// 十六进制转字节(长度不对/字符非法当场 panic:这些向量是写死的常量)。
|
||||
fn unhex(text: &str) -> Vec<u8> {
|
||||
let cleaned: String = text.chars().filter(|c| !c.is_whitespace()).collect();
|
||||
assert!(
|
||||
cleaned.len().is_multiple_of(2),
|
||||
"十六进制串长度必须是偶数:{text}"
|
||||
);
|
||||
let bytes = cleaned.as_bytes();
|
||||
let mut out = Vec::with_capacity(bytes.len() / 2);
|
||||
let (pairs, _) = bytes.as_chunks::<2>();
|
||||
for pair in pairs {
|
||||
let hi = (pair[0] as char).to_digit(16).expect("十六进制字符");
|
||||
let lo = (pair[1] as char).to_digit(16).expect("十六进制字符");
|
||||
out.push(u8::try_from(hi * 16 + lo).expect("字节"));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn array32(bytes: &[u8]) -> [u8; 32] {
|
||||
let mut out = [0_u8; 32];
|
||||
out.copy_from_slice(bytes);
|
||||
out
|
||||
}
|
||||
|
||||
fn array64(bytes: &[u8]) -> [u8; 64] {
|
||||
let mut out = [0_u8; 64];
|
||||
out.copy_from_slice(bytes);
|
||||
out
|
||||
}
|
||||
|
||||
/// L(群阶)的小端字节,用于构造「S + L」这种可锻变体。
|
||||
const L_BYTES: [u8; 32] = [
|
||||
0xed, 0xd3, 0xf5, 0x5c, 0x1a, 0x63, 0x12, 0x58, 0xd6, 0x9c, 0xf7, 0xa2, 0xde, 0xf9, 0xde, 0x14,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10,
|
||||
];
|
||||
|
||||
/// RFC 8032 §7.1 的四条向量:(public, message, signature)。
|
||||
fn rfc_vectors() -> Vec<(&'static str, &'static str, &'static str)> {
|
||||
vec![
|
||||
(
|
||||
"d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a",
|
||||
"",
|
||||
"e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e065224901555fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b",
|
||||
),
|
||||
(
|
||||
"3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c",
|
||||
"72",
|
||||
"92a009a9f0d4cab8720e820b5f642540a2b27b5416503f8fb3762223ebdb69da085ac1e43e15996e458f3613d0f11d8c387b2eaeb4302aeeb00d291612bb0c00",
|
||||
),
|
||||
(
|
||||
"fc51cd8e6218a1a38da47ed00230f0580816ed13ba3303ac5deb911548908025",
|
||||
"af82",
|
||||
"6291d657deec24024827e69c3abe01a30ce548a284743a445e3680d7db5ac3ac18ff9b538d16f290ae67f760984dc6594a7c15e9716ed28dc027beceea1ec40a",
|
||||
),
|
||||
(
|
||||
"ec172b93ad5e563bf4932c70e1245034c35467ef2efd4d64ebf819683467e2bf",
|
||||
"ddaf35a193617abacc417349ae20413112e6fa4e89a97ea20a9eeee64b55d39a2192992a274fc1a836ba3c23a3feebbd454d4423643ce80e2a9ac94fa54ca49f",
|
||||
"dc2a4459e7369633a52b1bf277839a00201009a3efbf3ecb69bea2186c26b58909351fc9ac90b3ecfdfbc7c66431e0303dca179c138ac17ad9bef1177331a704",
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rfc8032_vectors_verify() {
|
||||
for (index, (public, message, signature)) in rfc_vectors().into_iter().enumerate() {
|
||||
assert!(
|
||||
verify(
|
||||
&array32(&unhex(public)),
|
||||
&unhex(message),
|
||||
&array64(&unhex(signature))
|
||||
),
|
||||
"RFC 8032 第 {} 条向量必须通过",
|
||||
index + 1
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_bit_flip_is_rejected() {
|
||||
let (public, message, signature) = rfc_vectors()[1];
|
||||
let mut message = unhex(message);
|
||||
message[0] ^= 0x01;
|
||||
assert!(
|
||||
!verify(
|
||||
&array32(&unhex(public)),
|
||||
&message,
|
||||
&array64(&unhex(signature))
|
||||
),
|
||||
"改一个消息字节必须被拒"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signature_bit_flip_is_rejected() {
|
||||
let (public, message, signature) = rfc_vectors()[2];
|
||||
let mut r_flipped = array64(&unhex(signature));
|
||||
r_flipped[31] ^= 0x01;
|
||||
assert!(
|
||||
!verify(&array32(&unhex(public)), &unhex(message), &r_flipped),
|
||||
"改 R 必须被拒"
|
||||
);
|
||||
let mut s_flipped = array64(&unhex(signature));
|
||||
s_flipped[63] ^= 0x04;
|
||||
assert!(
|
||||
!verify(&array32(&unhex(public)), &unhex(message), &s_flipped),
|
||||
"改 S 必须被拒"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malleable_s_plus_l_is_rejected() {
|
||||
// 可锻性:把 S 换成 S + L,曲线方程照样成立(S 与 S+L 模 L 同余)。RFC 8032 的最低要求允许它。
|
||||
// ⚠ 这条**不是** strict 的守卫:dalek 的非 strict `verify` 自己就检查 `S` 规范性,换掉 strict 它
|
||||
// 仍绿(r2 实测)。它挡的是「判据被删成恒真」。strict 的守卫见 `small_order_*` 两条。
|
||||
let (public, message, signature) = rfc_vectors()[0];
|
||||
let mut signature = array64(&unhex(signature));
|
||||
let mut carry = 0_u16;
|
||||
for index in 0..32 {
|
||||
let sum = u16::from(signature[32 + index]) + u16::from(L_BYTES[index]) + carry;
|
||||
signature[32 + index] = (sum & 0xFF) as u8;
|
||||
carry = sum >> 8;
|
||||
}
|
||||
assert_eq!(
|
||||
carry, 0,
|
||||
"S + L 不该溢出 256 位(否则这条用例本身没造出来)"
|
||||
);
|
||||
assert!(
|
||||
!verify(&array32(&unhex(public)), &unhex(message), &signature),
|
||||
"S >= L 必须被拒(可锻变体)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_order_public_key_is_rejected() {
|
||||
// 阶为 1 的点(y = 1, x = 0)与零字节公钥:用它们做公钥,`verify`(非 strict)会接受一批伪造。
|
||||
let message = b"manifest bytes";
|
||||
let identity_key = {
|
||||
let mut key = [0_u8; PUBLIC_KEY_LEN];
|
||||
key[0] = 1;
|
||||
key
|
||||
};
|
||||
let mut signature = [0_u8; SIGNATURE_LEN];
|
||||
signature[0] = 1; // R = 单位元
|
||||
assert!(
|
||||
!verify(&identity_key, message, &signature),
|
||||
"小阶公钥必须被拒"
|
||||
);
|
||||
assert!(
|
||||
!verify(&[0_u8; PUBLIC_KEY_LEN], message, &signature),
|
||||
"零公钥必须被拒"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_canonical_r_encoding_is_rejected() {
|
||||
// R = p(= 2^255-19)的字节:它与 y = 0 同值但编码不同(非规范)。
|
||||
// dalek 的实际行为(r2 实测,与先前的说法不同):
|
||||
// * `CompressedEdwardsY::decompress` **不**校验编码规范性 —— 这串字节解出来是**阶为 4**的点;
|
||||
// * 于是它被 `verify_strict` 的**小阶**判据拒,而不是被「编码非规范」拒;
|
||||
// * 公钥侧同理:`VerifyingKey::from_bytes` 对非规范编码返回 **Ok**(`is_weak() == true`),
|
||||
// 所以要拒它只能靠下游判据 —— 下面把 dalek 的这个真实行为**断言下来**,将来 dalek 升级
|
||||
// 改了口径会红(否则这条断言对任何实现都绿,等于没测)。
|
||||
let (public, message, _) = rfc_vectors()[0];
|
||||
let p_bytes: [u8; 32] = [
|
||||
0xed, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
|
||||
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
|
||||
0xff, 0x7f,
|
||||
];
|
||||
let mut signature = [0_u8; SIGNATURE_LEN];
|
||||
signature[..32].copy_from_slice(&p_bytes);
|
||||
assert!(
|
||||
!verify(&array32(&unhex(public)), &unhex(message), &signature),
|
||||
"非规范 R 编码必须被拒(dalek 的理由是小阶点)"
|
||||
);
|
||||
|
||||
// dalek 的真实行为:非规范编码的公钥**能**通过 `from_bytes`,并被标成 weak。
|
||||
let weak = VerifyingKey::from_bytes(&p_bytes).expect("dalek 2.2 不校验编码规范性,应返回 Ok");
|
||||
assert!(
|
||||
weak.is_weak(),
|
||||
"这个非规范编码解出的是小阶点,dalek 应标记 is_weak"
|
||||
);
|
||||
// 我们的 `verify` 对同一串公钥仍然拒(下游方程/小阶判据兜住)。
|
||||
assert!(
|
||||
!verify(
|
||||
&p_bytes,
|
||||
&unhex(message),
|
||||
&array64(&unhex(rfc_vectors()[0].2))
|
||||
),
|
||||
"非规范/小阶公钥不得验过"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_order_r_is_rejected_only_by_strict() {
|
||||
// **第二条只由 strict 承重的判据**(r2 复审建议补的):小阶 `R`。
|
||||
// 构造:R = 单位元(阶 1),S = k·a mod L,其中 k = SHA-512(R‖A‖M) mod L、a 是测试钥的标量。
|
||||
// 这样方程 `[S]B = R + [k]A` 自洽 ⇒ **非 strict 的 `verify` 会通过**;`verify_strict` 因
|
||||
// 「R 是小阶点」拒。下面两边都断言,所以将来谁把 strict 换成非 strict,这条会红。
|
||||
let public = array32(&unhex(
|
||||
"f3ea49a34ebda73d6500c9416f425e0358baf094b957baf3ea7daa6df63e876b",
|
||||
));
|
||||
let message = b"manifest bytes";
|
||||
let forged = array64(&unhex(concat!(
|
||||
"0100000000000000000000000000000000000000000000000000000000000000",
|
||||
"698a94a4c82aa13c1a4da6248799849f2254ef333b5f5d69dcbd71b6320e5e0e"
|
||||
)));
|
||||
|
||||
// 非 strict 侧:dalek 的 `verify` 会接受这条伪造(这就是 strict 存在的理由)。
|
||||
let key = VerifyingKey::from_bytes(&public).expect("合法公钥");
|
||||
assert!(
|
||||
key.verify(message, &Signature::from_bytes(&forged)).is_ok(),
|
||||
"非 strict 的 verify 应当接受这条伪造(构造前提;若 dalek 改了行为,这条先红)"
|
||||
);
|
||||
// strict 侧:我们导出的 `verify` 必须拒。
|
||||
assert!(
|
||||
!verify(&public, message, &forged),
|
||||
"小阶 R 的伪造必须被 verify_strict 拒"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_signature_is_rejected() {
|
||||
let (public, message, _) = rfc_vectors()[0];
|
||||
assert!(!verify(
|
||||
&array32(&unhex(public)),
|
||||
&unhex(message),
|
||||
&[0_u8; 64]
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signing_key_round_trips() {
|
||||
// 测试夹具(dalek `SigningKey`)与验签侧同一套语义:RFC 的 secret 反推出的公钥要对得上,
|
||||
// 签出来的能验过,改一个字节就验不过。
|
||||
let seed = array32(&unhex(
|
||||
"9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60",
|
||||
));
|
||||
assert_eq!(
|
||||
public_key_hex(&seed),
|
||||
rfc_vectors()[0].0,
|
||||
"种子展开必须与 RFC 一致"
|
||||
);
|
||||
assert_eq!(
|
||||
hex_of(&sign_bytes(&seed, b"")),
|
||||
rfc_vectors()[0].2,
|
||||
"空消息的签名必须逐字节等于 RFC 向量(测试夹具与向量同一套语义)"
|
||||
);
|
||||
let signature = sign_bytes(&seed, b"unlucky");
|
||||
assert!(verify(
|
||||
&array32(&unhex(rfc_vectors()[0].0)),
|
||||
b"unlucky",
|
||||
&signature
|
||||
));
|
||||
let mut tampered = signature;
|
||||
tampered[0] ^= 0x80;
|
||||
assert!(!verify(
|
||||
&array32(&unhex(rfc_vectors()[0].0)),
|
||||
b"unlucky",
|
||||
&tampered
|
||||
));
|
||||
}
|
||||
@@ -10,11 +10,12 @@ use crate::download::mirrors::SOURCE_GH_PROXY;
|
||||
use crate::download::{DownloadEngine, RegionProfile, SOURCE_OFFICIAL};
|
||||
use crate::update::github::{DEFAULT_BASE, DEFAULT_OWNER, DEFAULT_REPO, MANIFEST_ASSET};
|
||||
use crate::update::{
|
||||
Channel, DirSource, GitHubReleaseSource, ReleaseManifest, Target, UpdateChecker, UpdateError,
|
||||
UpdateErrorKind, UpdateSource, Version,
|
||||
verified_manifest, Channel, DirSource, GitHubReleaseSource, ReleaseManifest, Target,
|
||||
UpdateChecker, UpdateError, UpdateErrorKind, UpdateSource, Version,
|
||||
};
|
||||
|
||||
use super::support::{artifact, engine_for, manifest, release, FakeHttp, FakeResponse, TempDir};
|
||||
use super::support_sign::signature_envelope;
|
||||
|
||||
const PAYLOAD: &[u8] = b"github release payload";
|
||||
|
||||
@@ -23,6 +24,22 @@ fn manifest_path() -> String {
|
||||
format!("/{DEFAULT_OWNER}/{DEFAULT_REPO}/releases/latest/download/{MANIFEST_ASSET}")
|
||||
}
|
||||
|
||||
/// 假服务上签名所在路径(M190:与清单并列)。
|
||||
fn signature_path() -> String {
|
||||
format!("{}.sig", manifest_path())
|
||||
}
|
||||
|
||||
/// 「清单 + 签名」两条路由:签名用测试钥现签,与 `DirSource` 夹具同一把钥。
|
||||
fn signed_routes(manifest_json: &str) -> Vec<(String, FakeResponse)> {
|
||||
vec![
|
||||
(manifest_path(), FakeResponse::json(manifest_json)),
|
||||
(
|
||||
signature_path(),
|
||||
FakeResponse::json(&signature_envelope(manifest_json)),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// 按 D49 命名规则的产物文件名。
|
||||
fn asset_name() -> &'static str {
|
||||
"unlucky-launcher_0.6.0_windows_x64.exe"
|
||||
@@ -104,10 +121,7 @@ fn manifest_url_is_the_fixed_latest_download_path() {
|
||||
#[test]
|
||||
fn channels_other_than_stable_publish_nothing() {
|
||||
let tmp = TempDir::new("gh-channels");
|
||||
let server = FakeHttp::start(vec![(
|
||||
manifest_path().as_str(),
|
||||
FakeResponse::json(&stable_manifest_json()),
|
||||
)]);
|
||||
let server = FakeHttp::start(signed_routes(&stable_manifest_json()));
|
||||
let source = local_source(&tmp, &server);
|
||||
|
||||
for channel in [Channel::Beta, Channel::Alpha, Channel::Nightly] {
|
||||
@@ -115,7 +129,7 @@ fn channels_other_than_stable_publish_nothing() {
|
||||
source.manifest_url(channel).is_none(),
|
||||
"{channel} 不该有清单地址"
|
||||
);
|
||||
let manifest = source.fetch_manifest(channel).expect("没开通道不是错误");
|
||||
let manifest = verified_manifest(&source, channel).expect("没开通道不是错误");
|
||||
assert!(
|
||||
manifest.is_none(),
|
||||
"{channel} 应返回 Ok(None)(不发布即无更新)"
|
||||
@@ -173,32 +187,30 @@ fn artifact_url_appends_the_release_download_path() {
|
||||
#[test]
|
||||
fn fetches_and_parses_the_manifest_from_the_base() {
|
||||
let tmp = TempDir::new("gh-manifest-ok");
|
||||
let server = FakeHttp::start(vec![(
|
||||
manifest_path().as_str(),
|
||||
FakeResponse::json(&stable_manifest_json()),
|
||||
)]);
|
||||
let server = FakeHttp::start(signed_routes(&stable_manifest_json()));
|
||||
let source = local_source(&tmp, &server);
|
||||
|
||||
let manifest = source
|
||||
.fetch_manifest(Channel::Stable)
|
||||
let manifest = verified_manifest(&source, Channel::Stable)
|
||||
.expect("应取到清单")
|
||||
.expect("清单存在");
|
||||
assert_eq!(manifest.channel, Channel::Stable);
|
||||
assert_eq!(manifest.releases.len(), 1);
|
||||
assert_eq!(manifest.releases[0].version.to_string(), "0.6.0");
|
||||
assert_eq!(server.hits(), vec![manifest_path()]);
|
||||
assert_eq!(
|
||||
server.hits(),
|
||||
vec![manifest_path(), signature_path()],
|
||||
"先取清单、再取它旁边的签名,两个请求都发过"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_manifest_in_the_release_is_not_an_error() {
|
||||
// 最新 release 没挂 manifest.json → 404 → 当成「还没发布」,不是红字。
|
||||
let tmp = TempDir::new("gh-manifest-404");
|
||||
let server = FakeHttp::start(vec![]);
|
||||
let server = FakeHttp::start(Vec::new());
|
||||
let source = local_source(&tmp, &server);
|
||||
|
||||
let outcome = source
|
||||
.fetch_manifest(Channel::Stable)
|
||||
.expect("404 不是错误");
|
||||
let outcome = verified_manifest(&source, Channel::Stable).expect("404 不是错误");
|
||||
assert!(outcome.is_none(), "取不到清单 = 还没发过版");
|
||||
assert_eq!(
|
||||
server.hits(),
|
||||
@@ -210,19 +222,15 @@ fn missing_manifest_in_the_release_is_not_an_error() {
|
||||
#[test]
|
||||
fn broken_json_is_reported_as_invalid_manifest() {
|
||||
let tmp = TempDir::new("gh-manifest-broken");
|
||||
let server = FakeHttp::start(vec![(
|
||||
manifest_path().as_str(),
|
||||
FakeResponse::json("{ not json"),
|
||||
)]);
|
||||
// 签名是对的(签的就是这串坏字节)——只有「签名对、内容坏」才走得到解析这一步。
|
||||
let server = FakeHttp::start(signed_routes("{ not json"));
|
||||
let source = local_source(&tmp, &server);
|
||||
|
||||
let error = source
|
||||
.fetch_manifest(Channel::Stable)
|
||||
.expect_err("坏 JSON 必须报错");
|
||||
let error = verified_manifest(&source, Channel::Stable).expect_err("坏 JSON 必须报错");
|
||||
assert_eq!(error.kind, UpdateErrorKind::InvalidManifest);
|
||||
assert!(
|
||||
error.message.contains(&server.base()),
|
||||
"错误里要带出来源地址,日志才定位得到(实际:{})",
|
||||
error.message.contains("github:"),
|
||||
"错误里要带出来源标识,日志才定位得到(实际:{})",
|
||||
error.message
|
||||
);
|
||||
assert!(!error.is_retryable(), "清单坏重试多少次都一样");
|
||||
@@ -233,39 +241,30 @@ fn illegal_manifest_fields_are_reported() {
|
||||
let tmp = TempDir::new("gh-manifest-illegal");
|
||||
// size 为 0(发布脚本写漏)——字段层面的非法,走同一条 InvalidManifest 分类。
|
||||
let json = r#"{"channel":"stable","releases":[{"version":"0.6.0","artifacts":[{"target":"win-x64","format":"portable-exe","url":"ull.exe","size":0,"sha256":"0000000000000000000000000000000000000000000000000000000000000000"}]}]}"#;
|
||||
let server = FakeHttp::start(vec![(manifest_path().as_str(), FakeResponse::json(json))]);
|
||||
let server = FakeHttp::start(signed_routes(json));
|
||||
let source = local_source(&tmp, &server);
|
||||
|
||||
let error = source
|
||||
.fetch_manifest(Channel::Stable)
|
||||
.expect_err("size 为 0 必须报错");
|
||||
let error = verified_manifest(&source, Channel::Stable).expect_err("size 为 0 必须报错");
|
||||
assert_eq!(error.kind, UpdateErrorKind::InvalidManifest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_self_reporting_another_channel_is_a_mismatch() {
|
||||
let tmp = TempDir::new("gh-manifest-mismatch");
|
||||
let server = FakeHttp::start(vec![(
|
||||
manifest_path().as_str(),
|
||||
FakeResponse::json(&manifest("beta", &[])),
|
||||
)]);
|
||||
let server = FakeHttp::start(signed_routes(&manifest("beta", &[])));
|
||||
let source = local_source(&tmp, &server);
|
||||
|
||||
let error = source
|
||||
.fetch_manifest(Channel::Stable)
|
||||
.expect_err("渠道不符必须报错");
|
||||
let error = verified_manifest(&source, Channel::Stable).expect_err("渠道不符必须报错");
|
||||
assert_eq!(error.kind, UpdateErrorKind::ChannelMismatch);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_errors_are_network_errors() {
|
||||
let tmp = TempDir::new("gh-manifest-500");
|
||||
let server = FakeHttp::start(vec![(manifest_path().as_str(), FakeResponse::status(500))]);
|
||||
let server = FakeHttp::start(vec![(manifest_path(), FakeResponse::status(500))]);
|
||||
let source = local_source(&tmp, &server);
|
||||
|
||||
let error = source
|
||||
.fetch_manifest(Channel::Stable)
|
||||
.expect_err("5xx 必须报错");
|
||||
let error = verified_manifest(&source, Channel::Stable).expect_err("5xx 必须报错");
|
||||
assert_eq!(error.kind, UpdateErrorKind::Network);
|
||||
assert!(error.is_retryable(), "网络抖动值得隔一会儿再试");
|
||||
}
|
||||
@@ -273,7 +272,7 @@ fn server_errors_are_network_errors() {
|
||||
#[test]
|
||||
fn downloads_the_artifact_through_the_engine() {
|
||||
let tmp = TempDir::new("gh-artifact-download");
|
||||
let server = FakeHttp::start(vec![(asset_path().as_str(), FakeResponse::bin(PAYLOAD))]);
|
||||
let server = FakeHttp::start(vec![(asset_path(), FakeResponse::bin(PAYLOAD))]);
|
||||
let source = local_source(&tmp, &server);
|
||||
let dest = tmp.join("staging/package.exe");
|
||||
|
||||
@@ -292,7 +291,7 @@ fn downloads_the_artifact_through_the_engine() {
|
||||
#[test]
|
||||
fn missing_artifact_is_reported_as_source_missing() {
|
||||
let tmp = TempDir::new("gh-artifact-404");
|
||||
let server = FakeHttp::start(vec![]);
|
||||
let server = FakeHttp::start(Vec::new());
|
||||
let source = local_source(&tmp, &server);
|
||||
|
||||
let error: UpdateError = source
|
||||
@@ -354,18 +353,14 @@ fn agrees_with_dir_source_on_the_same_manifest() {
|
||||
let json = stable_manifest_json();
|
||||
|
||||
// 本地目录源:清单与产物都在临时目录里(产物 url 是相对文件名)。
|
||||
tmp.write(
|
||||
&format!("{}.json", Channel::Stable.as_str()),
|
||||
json.as_bytes(),
|
||||
);
|
||||
tmp.write_signed(&format!("{}.json", Channel::Stable.as_str()), &json);
|
||||
tmp.write(asset_name(), PAYLOAD);
|
||||
let dir = DirSource::new(tmp.path());
|
||||
|
||||
// GitHub 源:同一份 JSON 由假服务提供,产物 url 解析成 release 产物地址。
|
||||
let server = FakeHttp::start(vec![
|
||||
(manifest_path().as_str(), FakeResponse::json(&json)),
|
||||
(asset_path().as_str(), FakeResponse::bin(PAYLOAD)),
|
||||
]);
|
||||
let mut routes = signed_routes(&json);
|
||||
routes.push((asset_path(), FakeResponse::bin(PAYLOAD)));
|
||||
let server = FakeHttp::start(routes);
|
||||
let github = local_source(&tmp, &server);
|
||||
|
||||
let current = Version::parse("0.5.0").expect("版本号合法");
|
||||
|
||||
@@ -22,17 +22,16 @@ fn target() -> Target {
|
||||
/// 造一个本地源 + 一个替换目标;返回的 `TempDir` 要活到用例结束。
|
||||
fn fixture(format: &str, version: &str) -> (TempDir, Job) {
|
||||
let dir = TempDir::new("job");
|
||||
dir.write(
|
||||
dir.write_signed(
|
||||
"stable.json",
|
||||
manifest(
|
||||
&manifest(
|
||||
"stable",
|
||||
&[release(
|
||||
version,
|
||||
"",
|
||||
&[artifact("win-x64", format, "asset.bin", PAYLOAD)],
|
||||
)],
|
||||
)
|
||||
.as_bytes(),
|
||||
),
|
||||
);
|
||||
dir.write("asset.bin", PAYLOAD);
|
||||
let dest = dir.join("bin/unlucky-launcher.exe");
|
||||
@@ -119,17 +118,16 @@ fn a_portable_exe_runs_the_whole_chain() {
|
||||
#[test]
|
||||
fn nothing_to_apply_does_not_touch_the_target() {
|
||||
let dir = TempDir::new("job-none");
|
||||
dir.write(
|
||||
dir.write_signed(
|
||||
"stable.json",
|
||||
manifest(
|
||||
&manifest(
|
||||
"stable",
|
||||
&[release(
|
||||
"0.2.2",
|
||||
"",
|
||||
&[artifact("win-x64", "portable-exe", "asset.bin", PAYLOAD)],
|
||||
)],
|
||||
)
|
||||
.as_bytes(),
|
||||
),
|
||||
);
|
||||
let dest = dir.join("bin/unlucky-launcher.exe");
|
||||
std::fs::create_dir_all(dest.parent().expect("父目录")).expect("建目录");
|
||||
|
||||
@@ -20,14 +20,17 @@
|
||||
|
||||
mod channel_cases;
|
||||
mod checker_cases;
|
||||
mod ed25519_cases;
|
||||
mod github_cases;
|
||||
mod install_cases;
|
||||
mod install_io_cases;
|
||||
mod install_xdev_cases;
|
||||
mod job_cases;
|
||||
mod manifest_cases;
|
||||
mod signature_cases;
|
||||
mod source_cases;
|
||||
mod support;
|
||||
mod support_sign;
|
||||
mod target_cases;
|
||||
mod verify_cases;
|
||||
mod version_cases;
|
||||
@@ -0,0 +1,294 @@
|
||||
//! 清单签名:**先验签再解析**(M190)。
|
||||
//!
|
||||
//! 这一组用例是 mission 守卫里「更新侧」的那几条,每条都能单独变红:
|
||||
//!
|
||||
//! | 用例 | 红掉意味着什么 |
|
||||
//! |---|---|
|
||||
//! | `tampered_field_is_rejected` | 改清单里任意一个字段仍能装 → 签名判据被绕过 |
|
||||
//! | `tampered_signature_only_is_rejected` | 只改签名文件、内容不动仍能装 → 「只改签名不换内容」没被拒 |
|
||||
//! | `missing_signature_is_rejected` | 没有 `.sig` 也照装 → 发布侧漏签名即可绕过整套信任根 |
|
||||
//! | `unknown_key_is_rejected` | 别人用自己的钥签的清单被认 → 信任锚不是钉死的那把 |
|
||||
//! | `key_id_spoofing_does_not_help` | 按 `key_id` 名字信任而不是按公钥字节验 → 伪个名字就能签 |
|
||||
//! | `unsigned_install_never_touches_the_target` | 验签失败却已经动过目标 exe → 判据跑在安装之后(顺序错) |
|
||||
//! | `no_runtime_keyring_override` | 环境变量/旁边的 keyring 文件能换信任锚 → 钉在二进制里的公钥白钉 |
|
||||
//!
|
||||
//! 还有一条「两条判据互不替代」:`sha256_mismatch_is_reported_separately` 证明签名过了、包体摘要
|
||||
//! 不符时仍是 `DigestMismatch`——签名管「清单是不是我们发的」,sha256 管「包体和清单对不对得上」。
|
||||
|
||||
use std::fs;
|
||||
|
||||
use crate::update::{
|
||||
trust, verified_manifest, Channel, DirSource, Job, OsFamily, Target, UpdateChecker,
|
||||
UpdateErrorKind, Version,
|
||||
};
|
||||
|
||||
use super::support::{artifact, manifest, release, TempDir};
|
||||
use super::support_sign::{
|
||||
public_key_hex, sign_bytes, signature_entry, signature_entry_from_bytes, signature_envelope,
|
||||
signature_envelope_of, signature_envelope_with, TEST_KEY_ID, TEST_SIGNING_SEED,
|
||||
};
|
||||
|
||||
const PAYLOAD: &[u8] = b"signed release payload";
|
||||
|
||||
/// 另一把「攻击者的钥」:种子换一个词,公钥就完全不同。
|
||||
const ATTACKER_SEED: &[u8; 32] = b"ull-m190-attacker-seed-000000001";
|
||||
|
||||
fn target() -> Target {
|
||||
Target::new(OsFamily::Windows, crate::update::Arch::X64)
|
||||
}
|
||||
|
||||
fn stable_json(version: &str) -> String {
|
||||
manifest(
|
||||
"stable",
|
||||
&[release(
|
||||
version,
|
||||
"",
|
||||
&[artifact("win-x64", "portable-exe", "asset.bin", PAYLOAD)],
|
||||
)],
|
||||
)
|
||||
}
|
||||
|
||||
fn current() -> Version {
|
||||
Version::parse("0.2.0").expect("版本号合法")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pinned_test_key_matches_the_test_seed() {
|
||||
// 信任表里那把测试钥必须就是测试种子推出来的那把。漂了的话「钉住的公钥」与「签名的钥」不是同一个,
|
||||
// 下面所有正路用例会一起变红——先在这里单独钉死。
|
||||
let derived = public_key_hex(TEST_SIGNING_SEED);
|
||||
let pinned = trust::keyring()
|
||||
.iter()
|
||||
.find(|key| key.key_id == TEST_KEY_ID)
|
||||
.expect("测试构建的信任表里必须有测试钥");
|
||||
assert_eq!(
|
||||
pinned.public_key_hex, derived,
|
||||
"钉住的测试公钥与测试种子不匹配"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_manifest_is_accepted_and_picks_the_candidate() {
|
||||
let dir = TempDir::new("sig-ok");
|
||||
dir.write_signed("stable.json", &stable_json("0.3.0"));
|
||||
let outcome = UpdateChecker::new(DirSource::new(dir.path()), target())
|
||||
.check(¤t(), Channel::Stable)
|
||||
.expect("签名对得上就不该报错");
|
||||
assert_eq!(
|
||||
outcome.candidate().map(|c| c.release.version.to_string()),
|
||||
Some("0.3.0".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tampered_field_is_rejected() {
|
||||
// 守卫①:清单里改一个字段(版本号多一个数字),签名不跟着换 ⇒ 必须拒。
|
||||
let original = stable_json("0.3.0");
|
||||
let tampered = stable_json("0.3.1");
|
||||
assert_ne!(original, tampered, "两次生成的清单必须真的不同");
|
||||
|
||||
let dir = TempDir::new("sig-tamper-field");
|
||||
dir.write("stable.json", tampered.as_bytes());
|
||||
// 签名还是**原始**那份的签名。
|
||||
dir.write("stable.json.sig", signature_envelope(&original).as_bytes());
|
||||
let error = verified_manifest(&DirSource::new(dir.path()), Channel::Stable)
|
||||
.expect_err("改过字段的清单必须验不过");
|
||||
assert_eq!(error.kind, UpdateErrorKind::SignatureInvalid);
|
||||
assert!(
|
||||
error.message.contains("sha256"),
|
||||
"要说清「签名声明的是哪一份、手上是哪一份」(实际:{})",
|
||||
error.message
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tampered_signature_only_is_rejected() {
|
||||
// 守卫①的另一半:清单字节一个不动,只把签名的最后一个字节改掉。
|
||||
let json = stable_json("0.3.0");
|
||||
let mut signature = sign_bytes(TEST_SIGNING_SEED, json.as_bytes());
|
||||
let good = signature_envelope(&json);
|
||||
signature[63] ^= 0x04;
|
||||
let broken =
|
||||
signature_envelope_of(&[signature_entry_from_bytes(TEST_KEY_ID, &signature, &json)]);
|
||||
assert_ne!(good, broken, "两份信封必须真的不同");
|
||||
|
||||
let dir = TempDir::new("sig-tamper-sig");
|
||||
dir.write("stable.json", json.as_bytes());
|
||||
dir.write("stable.json.sig", broken.as_bytes());
|
||||
let error = verified_manifest(&DirSource::new(dir.path()), Channel::Stable)
|
||||
.expect_err("只改签名也必须被拒");
|
||||
assert_eq!(error.kind, UpdateErrorKind::SignatureInvalid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_signature_is_rejected() {
|
||||
let dir = TempDir::new("sig-missing");
|
||||
dir.write("stable.json", stable_json("0.3.0").as_bytes());
|
||||
let error = verified_manifest(&DirSource::new(dir.path()), Channel::Stable)
|
||||
.expect_err("没有签名文件必须拒");
|
||||
assert_eq!(error.kind, UpdateErrorKind::SignatureMissing);
|
||||
assert!(!error.is_retryable(), "漏签名不是抖动,重试没有意义");
|
||||
assert!(error.kind.is_signature(), "壳层据此换文案");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_signature_array_is_malformed() {
|
||||
let dir = TempDir::new("sig-empty-array");
|
||||
dir.write("stable.json", stable_json("0.3.0").as_bytes());
|
||||
dir.write("stable.json.sig", br#"{"format":1,"signatures":[]}"#);
|
||||
let error = verified_manifest(&DirSource::new(dir.path()), Channel::Stable)
|
||||
.expect_err("空签名数组必须拒");
|
||||
assert_eq!(error.kind, UpdateErrorKind::SignatureMalformed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_field_in_envelope_is_malformed() {
|
||||
// `deny_unknown_fields`:发布侧拼错字段名要在 CI/发布侧当场炸,而不是变成用户机器上的「验签失败」。
|
||||
let json = stable_json("0.3.0");
|
||||
let dir = TempDir::new("sig-unknown-field");
|
||||
dir.write("stable.json", json.as_bytes());
|
||||
dir.write(
|
||||
"stable.json.sig",
|
||||
br#"{"format":1,"signatures":[],"comment":"hi"}"#,
|
||||
);
|
||||
let error = verified_manifest(&DirSource::new(dir.path()), Channel::Stable)
|
||||
.expect_err("未知字段必须拒");
|
||||
assert_eq!(error.kind, UpdateErrorKind::SignatureMalformed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_key_is_rejected() {
|
||||
// 攻击者用自己那把钥签了一份**完全合法**的清单:字节没问题,只是钥不在钉住的表里。
|
||||
let json = stable_json("9.9.9");
|
||||
let dir = TempDir::new("sig-unknown-key");
|
||||
dir.write("stable.json", json.as_bytes());
|
||||
dir.write(
|
||||
"stable.json.sig",
|
||||
signature_envelope_with(ATTACKER_SEED, "attacker-key", &json).as_bytes(),
|
||||
);
|
||||
let error = verified_manifest(&DirSource::new(dir.path()), Channel::Stable)
|
||||
.expect_err("不认识的钥必须拒");
|
||||
assert_eq!(error.kind, UpdateErrorKind::SignatureUnknownKey);
|
||||
assert!(
|
||||
error.message.contains("attacker-key"),
|
||||
"要说出是谁签的(实际:{})",
|
||||
error.message
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_id_spoofing_does_not_help() {
|
||||
// 攻击者把自己那把钥的 key_id 伪造成钉住的那个:查表命中,但**公钥字节**对不上 ⇒ 仍拒。
|
||||
// 这条就是「公钥钉在二进制里,不是按名字信任」的直接证据。
|
||||
let json = stable_json("9.9.9");
|
||||
let dir = TempDir::new("sig-key-id-spoof");
|
||||
dir.write("stable.json", json.as_bytes());
|
||||
dir.write(
|
||||
"stable.json.sig",
|
||||
signature_envelope_with(ATTACKER_SEED, TEST_KEY_ID, &json).as_bytes(),
|
||||
);
|
||||
let error = verified_manifest(&DirSource::new(dir.path()), Channel::Stable)
|
||||
.expect_err("key_id 撞名不能替代公钥");
|
||||
assert_eq!(error.kind, UpdateErrorKind::SignatureInvalid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_signature_accepts_any_pinned_key() {
|
||||
// 轮换窗口:一份清单同时带「钉住的旧钥」与「本构建不认识的新钥」的签名,只要有一条能过就放行。
|
||||
let json = stable_json("0.3.0");
|
||||
let dir = TempDir::new("sig-multi");
|
||||
dir.write("stable.json", json.as_bytes());
|
||||
let merged = signature_envelope_of(&[
|
||||
signature_entry(ATTACKER_SEED, "someone-else", &json),
|
||||
signature_entry(TEST_SIGNING_SEED, TEST_KEY_ID, &json),
|
||||
]);
|
||||
dir.write("stable.json.sig", merged.as_bytes());
|
||||
let manifest =
|
||||
verified_manifest(&DirSource::new(dir.path()), Channel::Stable).expect("有一条能过就放行");
|
||||
assert!(manifest.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsigned_install_never_touches_the_target() {
|
||||
// 顺序证据:验签在**取包之前**,因此目标 exe 与暂存目录一个字节都没动过。
|
||||
let dir = TempDir::new("sig-apply-order");
|
||||
dir.write("stable.json", stable_json("0.3.0").as_bytes());
|
||||
dir.write("asset.bin", PAYLOAD);
|
||||
let dest = dir.join("bin/unlucky-launcher.exe");
|
||||
fs::create_dir_all(dest.parent().expect("父目录")).expect("建目录");
|
||||
fs::write(&dest, b"old-bytes").expect("写旧 exe");
|
||||
|
||||
let job = Job::new(
|
||||
Box::new(DirSource::new(dir.path())),
|
||||
target(),
|
||||
dir.join("staging"),
|
||||
dest.clone(),
|
||||
);
|
||||
let error = job
|
||||
.apply(¤t(), Channel::Stable)
|
||||
.expect_err("没有签名不许 apply");
|
||||
assert_eq!(error.kind, UpdateErrorKind::SignatureMissing);
|
||||
assert_eq!(
|
||||
fs::read(&dest).expect("目标仍在"),
|
||||
b"old-bytes",
|
||||
"目标 exe 不许被动过"
|
||||
);
|
||||
let staging_empty = match fs::read_dir(dir.join("staging")) {
|
||||
Ok(mut entries) => entries.next().is_none(),
|
||||
Err(_) => true,
|
||||
};
|
||||
assert!(staging_empty, "暂存目录不该留下任何东西(验签在取包之前)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sha256_mismatch_is_reported_separately() {
|
||||
// 两条判据互不替代:签名对(内容就是发布侧签的那份),但包体摘要对不上 ⇒ DigestMismatch。
|
||||
let dir = TempDir::new("sig-vs-digest");
|
||||
dir.write_signed("stable.json", &stable_json("0.3.0"));
|
||||
// 等长但不同内容:先过长度判据,才走得到摘要判据(顺序在 verify.rs 里是有意的)。
|
||||
dir.write("asset.bin", &[b'X'; PAYLOAD.len()]);
|
||||
let job = Job::new(
|
||||
Box::new(DirSource::new(dir.path())),
|
||||
target(),
|
||||
dir.join("staging"),
|
||||
dir.join("bin/unlucky-launcher.exe"),
|
||||
);
|
||||
let error = job
|
||||
.apply(¤t(), Channel::Stable)
|
||||
.expect_err("摘要不符必须拒");
|
||||
assert_eq!(error.kind, UpdateErrorKind::DigestMismatch);
|
||||
assert!(!error.kind.is_signature(), "这是摘要判据,不是签名判据");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_runtime_keyring_override() {
|
||||
// 守卫②:信任锚是编译期常量。运行期旁边放一份「信任表」、或设环境变量,都不该有任何作用
|
||||
// (现在压根没有这种口子;将来谁加了,这条会红)。
|
||||
let json = stable_json("9.9.9");
|
||||
let dir = TempDir::new("sig-no-override");
|
||||
dir.write("stable.json", json.as_bytes());
|
||||
dir.write(
|
||||
"stable.json.sig",
|
||||
signature_envelope_with(ATTACKER_SEED, "attacker-key", &json).as_bytes(),
|
||||
);
|
||||
let attacker_public = public_key_hex(ATTACKER_SEED);
|
||||
dir.write(
|
||||
"keyring.json",
|
||||
format!(r#"{{"keys":[{{"key_id":"attacker-key","public_key_hex":"{attacker_public}"}}]}}"#)
|
||||
.as_bytes(),
|
||||
);
|
||||
dir.write("attacker.pub", attacker_public.as_bytes());
|
||||
let keyring_path = dir.join("keyring.json");
|
||||
std::env::set_var("ULL_UPDATE_KEYRING", &keyring_path);
|
||||
std::env::set_var("ULL_UPDATE_KEYRING_FILE", &keyring_path);
|
||||
let outcome = verified_manifest(&DirSource::new(dir.path()), Channel::Stable);
|
||||
std::env::remove_var("ULL_UPDATE_KEYRING");
|
||||
std::env::remove_var("ULL_UPDATE_KEYRING_FILE");
|
||||
let error = outcome.expect_err("旁边的信任表文件不许生效");
|
||||
assert_eq!(
|
||||
error.kind,
|
||||
UpdateErrorKind::SignatureUnknownKey,
|
||||
"改不了信任锚:攻击者的钥仍在表外(实际:{})",
|
||||
error.message
|
||||
);
|
||||
}
|
||||
@@ -3,23 +3,21 @@
|
||||
use std::fs;
|
||||
|
||||
use crate::update::manifest::ReleaseManifest;
|
||||
use crate::update::{Channel, DirSource, UpdateErrorKind, UpdateSource};
|
||||
use crate::update::{verified_manifest, Channel, DirSource, UpdateErrorKind, UpdateSource};
|
||||
|
||||
use super::support::{artifact, manifest, release, TempDir};
|
||||
|
||||
const PAYLOAD: &[u8] = b"source payload";
|
||||
|
||||
fn publish(tmp: &TempDir, channel: Channel, json: &str) {
|
||||
tmp.write(&format!("{}.json", channel.as_str()), json.as_bytes());
|
||||
tmp.write_signed(&format!("{}.json", channel.as_str()), json);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_channel_manifest_is_not_an_error() {
|
||||
let tmp = TempDir::new("source-missing");
|
||||
let source = DirSource::new(tmp.path());
|
||||
let manifest = source
|
||||
.fetch_manifest(Channel::Stable)
|
||||
.expect("缺清单不该报错");
|
||||
let manifest = verified_manifest(&source, Channel::Stable).expect("缺清单不该报错");
|
||||
assert!(manifest.is_none(), "还没发过版 = Ok(None)");
|
||||
}
|
||||
|
||||
@@ -36,8 +34,7 @@ fn reads_and_validates_the_channel_manifest() {
|
||||
);
|
||||
publish(&tmp, Channel::Stable, &json);
|
||||
let source = DirSource::new(tmp.path());
|
||||
let parsed = source
|
||||
.fetch_manifest(Channel::Stable)
|
||||
let parsed = verified_manifest(&source, Channel::Stable)
|
||||
.expect("应取到清单")
|
||||
.expect("清单存在");
|
||||
assert_eq!(parsed.channel, Channel::Stable);
|
||||
@@ -52,8 +49,7 @@ fn channel_mismatch_is_an_error() {
|
||||
let tmp = TempDir::new("source-mismatch");
|
||||
// 清单自称 beta,却躺在 stable.json 里
|
||||
publish(&tmp, Channel::Stable, &manifest("beta", &[]));
|
||||
let error = DirSource::new(tmp.path())
|
||||
.fetch_manifest(Channel::Stable)
|
||||
let error = verified_manifest(&DirSource::new(tmp.path()), Channel::Stable)
|
||||
.expect_err("渠道不符必须报错");
|
||||
assert_eq!(error.kind, UpdateErrorKind::ChannelMismatch);
|
||||
}
|
||||
@@ -62,11 +58,14 @@ fn channel_mismatch_is_an_error() {
|
||||
fn broken_manifest_json_is_reported_with_path() {
|
||||
let tmp = TempDir::new("source-broken");
|
||||
publish(&tmp, Channel::Stable, "{ not json");
|
||||
let error = DirSource::new(tmp.path())
|
||||
.fetch_manifest(Channel::Stable)
|
||||
let error = verified_manifest(&DirSource::new(tmp.path()), Channel::Stable)
|
||||
.expect_err("坏 JSON 必须报错");
|
||||
assert_eq!(error.kind, UpdateErrorKind::InvalidManifest);
|
||||
assert!(error.path.is_some(), "错误应带清单路径");
|
||||
assert!(
|
||||
error.message.contains("来源 file:"),
|
||||
"错误里要带出来源(本地源的 id 就是根目录),日志才定位得到(实际:{})",
|
||||
error.message
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -280,13 +280,9 @@ pub(crate) struct FakeHttp {
|
||||
|
||||
impl FakeHttp {
|
||||
/// 起服务(`routes` 是「路径 → 应答」表)。
|
||||
pub(crate) fn start(routes: Vec<(&str, FakeResponse)>) -> Self {
|
||||
pub(crate) fn start(routes: Vec<(String, FakeResponse)>) -> Self {
|
||||
let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).expect("绑定本地端口");
|
||||
let addr = listener.local_addr().expect("取本地地址");
|
||||
let routes: Vec<(String, FakeResponse)> = routes
|
||||
.into_iter()
|
||||
.map(|(path, response)| (path.to_string(), response))
|
||||
.collect();
|
||||
let hits = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
//! 清单签名的**测试夹具**:用测试钥签出 `manifest.json.sig` 的信封(M190)。
|
||||
//!
|
||||
//! 为什么单独一个文件:`support.rs` 是 update 测试的通用夹具(临时目录、假 HTTP、清单片段),
|
||||
//! 已经贴着 400 行红线;签名这一摊只被 `signature_cases` / `github_cases` 用到,搬出来两清。
|
||||
//!
|
||||
//! 签名走 `ed25519-dalek` 的 `SigningKey`(与仓里验签用的 `verify_strict` 同一棵树),而**不是**
|
||||
//! 自写实现——M190 r1 复审把自写曲线算术整体删掉了,这里也不留第二条算法。测试钥的种子是公开的
|
||||
//! (见下),它只对 `cfg(test)` 与 `update-test-source` 构建有意义。
|
||||
|
||||
use super::support::TempDir;
|
||||
|
||||
/// 测试签名钥的 32 字节种子。
|
||||
///
|
||||
/// **它是公开的,故意不保密**:它只对 `cfg(test)` 与 `update-test-source` 构建有意义
|
||||
/// (`trust::TEST_ONLY` 才认这把钥),正式构建的信任表里没有它,所以拿它签不出任何正式版本
|
||||
/// 能装的清单。正式私钥只能来自仓库 Secret(见 `packaging/docs/signing.md`)。
|
||||
pub(crate) const TEST_SIGNING_SEED: &[u8; 32] = b"ull-m190-test-key-seed-000000001";
|
||||
|
||||
/// 测试钥的 `key_id`(与 `trust::TEST_ONLY` 里那条一致)。
|
||||
pub(crate) const TEST_KEY_ID: &str = "test-only-key";
|
||||
|
||||
/// 字节转小写十六进制。
|
||||
pub(crate) fn hex_of(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
|
||||
}
|
||||
|
||||
/// 用测试钥签一份清单,返回 `manifest.json.sig` 的信封原文。
|
||||
pub(crate) fn signature_envelope(manifest_text: &str) -> String {
|
||||
signature_envelope_with(TEST_SIGNING_SEED, TEST_KEY_ID, manifest_text)
|
||||
}
|
||||
|
||||
/// 用指定种子/`key_id` 签一份清单(负向用例要造「不认识的钥」「改过内容的签名」)。
|
||||
pub(crate) fn signature_envelope_with(
|
||||
seed: &[u8; 32],
|
||||
key_id: &str,
|
||||
manifest_text: &str,
|
||||
) -> String {
|
||||
signature_envelope_of(&[signature_entry(seed, key_id, manifest_text)])
|
||||
}
|
||||
|
||||
/// 多签名信封(轮换窗口的夹具:一份清单同时带几把钥的签名)。
|
||||
pub(crate) fn signature_envelope_of(entries: &[String]) -> String {
|
||||
format!(r#"{{"format":1,"signatures":[{}]}}"#, entries.join(","))
|
||||
}
|
||||
|
||||
/// 一条签名条目(可以被 [`signature_envelope_of`] 拼起来,也可以单独改坏再拼)。
|
||||
pub(crate) fn signature_entry(seed: &[u8; 32], key_id: &str, manifest_text: &str) -> String {
|
||||
signature_entry_from_bytes(
|
||||
key_id,
|
||||
&sign_bytes(seed, manifest_text.as_bytes()),
|
||||
manifest_text,
|
||||
)
|
||||
}
|
||||
|
||||
/// 用**现成的签名字节**拼一条条目(「只改签名字节」的用例靠它)。
|
||||
pub(crate) fn signature_entry_from_bytes(
|
||||
key_id: &str,
|
||||
signature: &[u8; 64],
|
||||
manifest_text: &str,
|
||||
) -> String {
|
||||
let sha = crate::update::signature::hex_sha256(manifest_text.as_bytes());
|
||||
format!(
|
||||
r#"{{"alg":"ed25519","key_id":"{key_id}","sig":"{}","manifest_sha256":"{sha}"}}"#,
|
||||
hex_of(signature)
|
||||
)
|
||||
}
|
||||
|
||||
/// 用 32 字节种子签一段字节(dalek 的 `SigningKey`;测试专用,正式构建里没有签名代码)。
|
||||
pub(crate) fn sign_bytes(seed: &[u8; 32], message: &[u8]) -> [u8; 64] {
|
||||
use ed25519_dalek::{Signer, SigningKey};
|
||||
let key = SigningKey::from_bytes(seed);
|
||||
key.sign(message).to_bytes()
|
||||
}
|
||||
|
||||
/// 种子对应的公钥(用来让「钉住的公钥」与「签名的钥」对得上——漂了就当场红)。
|
||||
pub(crate) fn public_key_hex(seed: &[u8; 32]) -> String {
|
||||
use ed25519_dalek::SigningKey;
|
||||
hex_of(SigningKey::from_bytes(seed).verifying_key().as_bytes())
|
||||
}
|
||||
|
||||
impl TempDir {
|
||||
/// 写一份清单**连它的签名**(`<name>` + `<name>.sig`),返回清单路径。
|
||||
///
|
||||
/// M190 起「有清单没签名」是被拒的一档,所以夹具一律成对落盘:只写清单的用例都是为了
|
||||
/// 证明「缺签名会被拒」,它们自己显式 `write` 清单即可。
|
||||
pub(crate) fn write_signed(&self, name: &str, manifest_text: &str) -> std::path::PathBuf {
|
||||
let path = self.write(name, manifest_text.as_bytes());
|
||||
self.write(
|
||||
&format!("{name}.sig"),
|
||||
signature_envelope(manifest_text).as_bytes(),
|
||||
);
|
||||
path
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
//! 钉进二进制的清单签名信任锚(keyring):**多把**公钥,`key_id` 只是查表用的标签。
|
||||
//!
|
||||
//! # 为什么是一张编译期常量表
|
||||
//!
|
||||
//! 自更新的信任根必须不可被「用户机器上的一枚文件」替换:清单与签名都能被能改 Release 的人一起
|
||||
//! 换掉,唯一挡得住的是「客户端**编进二进制**的那几把公钥」。所以这张表是 `const`:
|
||||
//!
|
||||
//! * 改仓库里的本文件只会改变**下一次构建**的行为,不会改变任何**已发布二进制**的验签结果;
|
||||
//! * 没有环境变量、没有设置键、没有「旁边的 keyring.json」这类运行期覆盖口子
|
||||
//! (`tests/signature_cases.rs::no_runtime_keyring_override` 守这条)。
|
||||
//!
|
||||
//! # 为什么是「一组」而不是「一把」
|
||||
//!
|
||||
//! 换签名方(例如日后接 SignPath 的托管签名,或旧钥泄露后轮换)时,用户机器上装着的是**旧**二进制,
|
||||
//! 它只认旧钥。做成数组才有两条路同时成立:新签出的清单可以同时带新旧两把钥的签名(旧二进制认旧的、
|
||||
//! 新二进制认新的),以及新二进制可以一把一把地收紧。步骤见 `packaging/docs/signing.md`。
|
||||
//!
|
||||
//! # 唯一的加钥匙纪律:**旧钥签新钥**
|
||||
//!
|
||||
//! 往本表里加一把公钥,必须同时有「旧钥对新钥的背书」——即那次发布的清单同时带旧钥签名(见上),
|
||||
//! 以及 changelog 里点名新钥的 `key_id` 与指纹。**不允许**在没有旧钥签名的情况下替换这张表:
|
||||
//! 那等于让「能改仓库的人」变成「能改信任根的人」。
|
||||
//!
|
||||
//! # 正式构建的表当前是**空的**(fail-closed)
|
||||
//!
|
||||
//! 私钥只能来自仓库 Secret,本轮没有(也不该由代码仓库产生)私钥,所以正式表里一个条目都没有:
|
||||
//! 任何正式构建拿到清单都会以 [`super::error::UpdateErrorKind::SignatureMissing`] 之类的分类拒绝更新,
|
||||
//! 而 `packaging/sign_manifest.py --check-pinned-keyring` 会让发布流水线在「Secret 没配 / 公钥没钉」
|
||||
//! 时**当场红**(宁可不出 Release,也不出未签名的 Release)。
|
||||
//!
|
||||
//! 录一把真钥的两步(细节见 `packaging/docs/signing.md`):
|
||||
//!
|
||||
//! ```text
|
||||
//! python packaging/sign_manifest.py --generate-key --out-key release.key # ① 生成,私钥写进 Secret
|
||||
//! # ② 把打印出来的 `PinnedKey { … }` 一行贴到下面 PINNED-KEYS 标记之间
|
||||
//! ```
|
||||
//!
|
||||
//! 测试/验收构建另有一把**只在** `cfg(test)` 与 `update-test-source` feature 里存在的测试钥:
|
||||
//! 它的私钥种子就写在测试夹具里、公开可见,因此它可以签出「本仓库自己认得」的清单,但对任何正式
|
||||
//! 构建毫无意义(正式表里没有它)。这条也由 `packaging/check_test_source_off.py` 的同一道闸守着
|
||||
//! ——正式发布流水线与打包脚本都不许打开 `update-test-source`。
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// 一把钉死的公钥。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) struct PinnedKey {
|
||||
/// 标签(清单签名里的 `key_id` 靠它查表;**改名就是换钥**)。
|
||||
pub(crate) key_id: &'static str,
|
||||
/// Ed25519 公钥的 64 位十六进制小写形态(32 字节)。
|
||||
pub(crate) public_key_hex: &'static str,
|
||||
}
|
||||
|
||||
/// 正式构建的信任锚(发布二进制里就是这一份)。
|
||||
///
|
||||
/// ── 加钥匙只改下面 `PINNED-KEYS` 标记之间的内容,别改标记本身 ──
|
||||
/// (`packaging/sign_manifest.py --check-pinned-keyring` 按标记解析这一段,标记变了它就找不到表)。
|
||||
pub(crate) const PRODUCTION: &[PinnedKey] = &[
|
||||
// >>> PINNED-KEYS >>>
|
||||
// <<< PINNED-KEYS <<<
|
||||
];
|
||||
|
||||
/// 测试钥:**只**在 `cfg(test)` 与 `update-test-source` 构建里存在(正式表里没有它)。
|
||||
///
|
||||
/// 公钥由 `tests/support.rs` 里那串公开可见的 32 字节种子推出来(种子 = `ull-m190-test-key-seed-000000001`),
|
||||
/// 两者一旦漂开,`tests/signature_cases.rs::pinned_test_key_matches_the_test_seed` 会当场红。
|
||||
pub(crate) const TEST_ONLY: &[PinnedKey] = &[PinnedKey {
|
||||
key_id: "test-only-key",
|
||||
public_key_hex: "f3ea49a34ebda73d6500c9416f425e0358baf094b957baf3ea7daa6df63e876b",
|
||||
}];
|
||||
|
||||
/// 本构建实际的信任锚(正式表 + 仅测试构建才有的那把测试钥)。
|
||||
pub(crate) fn keyring() -> &'static [PinnedKey] {
|
||||
static ALL: OnceLock<Vec<PinnedKey>> = OnceLock::new();
|
||||
ALL.get_or_init(|| {
|
||||
let mut keys = PRODUCTION.to_vec();
|
||||
if cfg!(any(test, feature = "update-test-source")) {
|
||||
keys.extend_from_slice(TEST_ONLY);
|
||||
}
|
||||
keys
|
||||
})
|
||||
}
|
||||
|
||||
/// 十六进制解码(只收小写/大写十六进制,长度必须是偶数)。
|
||||
pub(crate) fn decode_hex(text: &str) -> Option<Vec<u8>> {
|
||||
if !text.len().is_multiple_of(2) {
|
||||
return None;
|
||||
}
|
||||
let bytes = text.as_bytes();
|
||||
let mut out = Vec::with_capacity(bytes.len() / 2);
|
||||
let (pairs, _) = bytes.as_chunks::<2>();
|
||||
for pair in pairs {
|
||||
let hi = (pair[0] as char).to_digit(16)?;
|
||||
let lo = (pair[1] as char).to_digit(16)?;
|
||||
out.push(u8::try_from(hi * 16 + lo).ok()?);
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
Reference in new issue
Block a user