From 7f657d225c763a01a9eb4800c55aec7db5ca9eb4 Mon Sep 17 00:00:00 2001 From: worker-packimport Date: Wed, 4 Nov 2026 20:09:43 +0800 Subject: [PATCH] =?UTF-8?q?fix(addon):=20M111=20r5=20=E2=80=94=E2=80=94=20?= =?UTF-8?q?=E5=9F=BA=E7=BA=BF=E8=AF=BB=E5=86=99=E5=90=8C=E6=BA=90=EF=BC=88?= =?UTF-8?q?.ull=EF=BC=89=E3=80=81plan.rs=20=E6=8B=86=E5=9B=9E=20400=20?= =?UTF-8?q?=E5=86=85=E3=80=81=E4=B8=AD=E9=97=B4=E4=BA=A7=E7=89=A9/?= =?UTF-8?q?=E5=9B=9E=E6=BB=9A/=E5=8C=85=E8=BA=AB=E4=BB=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复审 r5 两条 P1 + 三条 P2。 P1-1 基线读、写走了两条路径(ext4 上是两个文件,Windows/macOS 上假绿) - 写侧 pipeline 用 `LAUNCHER_HOME_DIR`(`.ULL`,启动器**数据根**的名字),读侧 `PackBaseline::path()` 用 `GameLayout::ull_root()`(`.ull`,实例**游戏根**内的私有目录)。 - 现在读写都从 `ull_root()` 派生:`PackBaseline::path()`(绝对)+ 新增 `PackBaseline::relative_path()`(相对,空根求 `ull_root()`),pipeline 的 `baseline_relative()` 只做 `/` 分隔,不再出现第二份目录名字符串;`LAUNCHER_HOME_DIR` 的引用从导入链路里删掉。 - 新断言 `baseline_read_and_write_paths_are_the_same_file`:`path(root) == root.join(relative_path())`、`read.parent() == ull_root()`、写侧字符串不含 `.ULL` (路径比较是大小写敏感的,所以在大小写不敏感的系统上也抓得住)。 - 选 `.ull` 的理由:基线住在**实例的游戏根**里,那是 `GameLayout` 的地盘(且 `crates/core/tests/game/layout.rs:45` 早已钉死 `.ull`);`.ULL` 是 portable 模式下 exe 旁边的启动器数据根(`config/ data/ cache/ logs/`),层级不同,不能混用。 - 同时清掉 4 个测试里手抄的 `.ULL/pack-baseline.json`,改用 `baseline_rel()`。 P1-2 `import/plan.rs` 401 行撞穿 file-size 守门 - 把 `suffix_note()`(后缀注记)连同它的单测搬进 `plan/paths.rs`(那里本来就有 `suffix_variant`),plan.rs 401 → 373;paths.rs 119 → 157。没有压注释换行数。 P2-1 引擎中间产物会被当成内容提交 - `Staging::purge_engine_artifacts()`:提交前递归剔除 `.ullpart-*` 与 `.ullmeta.json` (判据 `is_engine_artifact`,后缀取自 `core::download::atomic`),删掉的个数进报告注记。 - 测试:`StubFetcher::with_engine_artifacts()` 像真引擎那样在每次"下载"后留下残片, 断言实例目录里没有任何 `.ullpart` / `.ullmeta`。 P2-2 回滚失败被 `let _ =` 吞掉 - 新增 `RollbackOutcome { attempted, failed }`:`rollback_merge` 逐项记账, `CommitGuard::rollback()` 返回它,提交阶段失败时 `CommitFailure { error, rollback }` 把两者一起交回调用方;`ImportReport` 新增 `rollback` 字段,`rolled_back` 改成 "回滚完整 / 从未写过目标目录"才为 true,不完整时 `false` 并在 warnings 里列失败项。 - CLI 人读输出相应分叉(`回滚未完整:N 项没回去`),不再无条件宣称一致。 - `Drop` 那条兜底路径仍拿不到调用方(文档写明是"防漏"不是"承诺")。 - 单测:`complete_rollback_has_no_failures` 与 `rollback_failures_are_reported_not_swallowed` (用非空目录占住目标位置,让 remove/rename 都失败,跨平台确定)。 P2-3 基线不校验包身份 - `PackBaseline::matches_pack(format, name)`:同一目录先后导入两个包时,别人的基线一律 不拿来当 base(否则"用户改过"会被误判成"整合包升级"而静默覆盖),降级为"无基线"并 记 `pack.import.note.baseline_pack_mismatch`。 - 测试 `baseline_of_another_pack_is_not_used_as_base`:断言告警 key + 冲突降级为 pre_existing + 用户内容保留。 门禁(落盘读 $?):fmt 0 / clippy(workspace,all-targets,-D warnings) 0 / test -p unlucky-core 0(363 lib)/ test --workspace --exclude unlucky-app 0(1993 passed)。 改动文件最大 373 行。core 依赖表未动(core-no-ui 不受影响)。 --- crates/cli/src/commands/pack.rs | 5 + crates/core/src/addon/import.rs | 4 +- crates/core/src/addon/import/baseline.rs | 58 ++++-- crates/core/src/addon/import/pipeline.rs | 116 +++++++++--- crates/core/src/addon/import/plan.rs | 30 +-- crates/core/src/addon/import/plan/paths.rs | 43 ++++- crates/core/src/addon/import/report.rs | 7 +- crates/core/src/addon/import/report/keys.rs | 9 + crates/core/src/addon/import/stage.rs | 70 +++++-- crates/core/src/addon/import/stage/commit.rs | 173 ++++++++++++++++-- .../core/src/addon/tests/import_curseforge.rs | 4 +- .../core/src/addon/tests/import_failures.rs | 2 +- crates/core/src/addon/tests/import_merge.rs | 91 ++++++++- .../core/src/addon/tests/import_modrinth.rs | 30 ++- crates/core/src/addon/tests/import_multimc.rs | 3 +- crates/core/src/addon/tests/mod.rs | 10 + crates/core/src/addon/tests/support.rs | 26 +++ 17 files changed, 577 insertions(+), 104 deletions(-) diff --git a/crates/cli/src/commands/pack.rs b/crates/cli/src/commands/pack.rs index 42c6ea9..001aa8e 100644 --- a/crates/cli/src/commands/pack.rs +++ b/crates/cli/src/commands/pack.rs @@ -196,6 +196,11 @@ fn outcome( } if report.rolled_back { human.push("已整包回滚:目标目录与导入前一致".to_string()); + } else if let Some(rollback) = &report.rollback { + human.push(format!( + "回滚未完整:{} 项没回去(详见 --json 的 rollback.failed)", + rollback.failure_count() + )); } } diff --git a/crates/core/src/addon/import.rs b/crates/core/src/addon/import.rs index 117f2de..201b67f 100644 --- a/crates/core/src/addon/import.rs +++ b/crates/core/src/addon/import.rs @@ -69,7 +69,7 @@ pub mod plan; pub mod report; pub mod stage; -pub use baseline::PackBaseline; +pub use baseline::{PackBaseline, BASELINE_FILE}; pub use error::{ImportError, ImportErrorKind}; pub use fetch::{ CurseFileMeta, DownloadEngineFetcher, FetchError, FetchErrorKind, FetchFuture, FetchRequest, @@ -83,7 +83,7 @@ pub use report::{ ImportProgress, ImportProgressSink, ImportReport, ImportStage, ImportStatus, ImportedFile, LoaderTarget, MissingFile, MissingReason, RegisteredInstance, RejectedComponent, }; -pub use stage::Staging; +pub use stage::{RollbackOutcome, Staging}; use std::path::Path; diff --git a/crates/core/src/addon/import/baseline.rs b/crates/core/src/addon/import/baseline.rs index 7a68fc5..db49a12 100644 --- a/crates/core/src/addon/import/baseline.rs +++ b/crates/core/src/addon/import/baseline.rs @@ -9,21 +9,31 @@ //! - 磁盘内容 ≠ 新包内容,且磁盘内容 == 上次包内容 ⇒ **整合包升级**,该覆盖(用户没动过); //! - 磁盘内容 ≠ 新包内容,且磁盘内容 ≠ 上次包内容 ⇒ **用户改过**,不许静默覆盖。 //! -//! 所以第一次导入就在实例根写一份基线;没有基线时(首次导入 / 用户手动塞进来的文件)一律按 -//! "磁盘上已有同名文件"处理 —— 不覆盖、进冲突清单(最安全的一侧)。 +//! 所以第一次导入就在实例根写一份基线;没有基线时(首次导入 / 用户手动塞进来的文件 / 基线 +//! 属于别的整合包)一律按"磁盘上已有同名文件"处理 —— 不覆盖、进冲突清单(最安全的一侧)。 //! -//! # 落点与形状 +//! # 落点:**唯一来源是 [`crate::game::layout::GameLayout::ull_root`]** //! -//! `<实例根>/.ULL/pack-baseline.json`(沿用 D20 的 `.ULL/` 分区,不往 mods/ 里掺文件): +//! `<实例根>/.ull/pack-baseline.json`(游戏根内的启动器私有目录)。 //! -//! ```jsonc -//! {"schemaVersion": 1, "packName": "…", "format": "modrinth", -//! "files": {"mods/jei.jar": "<包的 sha1>", "mods/xxx.jar.disabled": "<包的 sha1>"}} -//! ``` +//! 这里有个踩过的坑(复审 r5 P1-1):基线最初写成 `<实例根>/.ULL/…`(用了 +//! [`crate::platform::layout::LAUNCHER_HOME_DIR`],那是**启动器数据根**的名字,在 exe/portable +//! 那一层),而读侧用 `GameLayout::ull_root()`(`<实例根>/.ull`,小写,实例**游戏根**内的 +//! 私有目录)。Windows / macOS 的大小写不敏感文件系统上两者是同一个文件,本机全绿;ext4 上 +//! 是两个文件 ⇒ 基线永远读不到(`UserModified`/`UserDeleted` 永不出现)。 //! -//! 基线记的是**包声明的哈希**(不是磁盘实际哈希):用户改过的文件在基线里仍是包的值,下次 -//! 导入才会继续判成"用户改过",而不会被"上次也是改动值"洗白。`.disabled` / `.old` 后缀按 -//! 原样进键名(后缀是路径的一部分,不附加语义)。 +//! 现在**读写都从 `ull_root()` 派生**:绝对路径 [`PackBaseline::path`],相对路径 +//! [`PackBaseline::relative_path`],两者由 `addon/tests/import_merge.rs` 的一条断言钉死 +//! (`path(root) == root.join(relative_path())`)——这条在大小写敏感的系统上也抓得住。 +//! +//! # 包身份 +//! +//! 基线记包名与格式;下次导入发现"基线属于另一个包"时**不拿它当 base**(否则跨包导入同一 +//! 目录会把别人的哈希当成本次包的历史),而是降级成"无基线"并如实告警。 +//! +//! 基线里存的是**包声明的哈希**(不是磁盘实际哈希):用户改过的文件在基线里仍是包的值, +//! 下次导入才会继续判成"用户改过",而不会被"上次也是改动值"洗白。`.disabled` / `.old` +//! 后缀按原样进键名(后缀是路径的一部分,不附加语义)。 //! //! 读失败**不是**致命错误:基线坏了最多退化成"全部按 pre-existing 冲突处理"(保留用户文件), //! 比"因为读不出基线就整包回滚"合理。 @@ -47,10 +57,10 @@ pub struct PackBaseline { /// schema 版本(迁移链入口)。 #[serde(default = "default_schema")] pub schema_version: u32, - /// 上次导入的包名(诊断用)。 + /// 上次导入的包名(诊断 + 包身份判据)。 #[serde(default)] pub pack_name: String, - /// 上次导入命中的格式 id。 + /// 上次导入命中的格式 id(包身份判据的另一半)。 #[serde(default)] pub format: String, /// 实例内相对路径 → 包声明的 SHA-1。 @@ -74,12 +84,32 @@ impl Default for PackBaseline { } impl PackBaseline { - /// 实例根下的基线路径(`/.ULL/pack-baseline.json`)。 + /// 实例根下的基线**绝对**路径。 + /// + /// 唯一来源是 `GameLayout::ull_root()`:实例的游戏根里那个启动器私有目录。 #[must_use] pub fn path(game_root: &Path) -> PathBuf { GameLayout::new(game_root).ull_root().join(BASELINE_FILE) } + /// 基线**相对实例根**的路径(暂存期与写盘用;与 [`Self::path`] 同源)。 + #[must_use] + pub fn relative_path() -> PathBuf { + // 用空根求 `ull_root()` 就得到那一段相对目录名(`.ull`),不另抄一份字符串。 + GameLayout::new(Path::new("")) + .ull_root() + .join(BASELINE_FILE) + } + + /// 基线是否属于这份包(空基线 / 没有身份信息的老基线一律放行)。 + #[must_use] + pub fn matches_pack(&self, format: &str, pack_name: &str) -> bool { + if self.pack_name.is_empty() && self.format.is_empty() { + return true; + } + self.format == format && self.pack_name == pack_name + } + /// 读基线;文件不存在返回空表(**不是错误**),形状不符返回 `Err`。 /// /// # Errors diff --git a/crates/core/src/addon/import/pipeline.rs b/crates/core/src/addon/import/pipeline.rs index 20d83f6..8aad4a3 100644 --- a/crates/core/src/addon/import/pipeline.rs +++ b/crates/core/src/addon/import/pipeline.rs @@ -19,22 +19,24 @@ //! | 本地写盘失败 | `Failed` | //! | 实例登记失败 | `Failed`(连内容一起回滚,不留"有目录没档案"的半个实例) | //! +//! **回滚也要如实**:回滚本身可能失败(文件被占用、权限),这时 `rolled_back` 为 `false` +//! 并在 `rollback.failed` 里逐项列出——不许在"可能没回干净"时宣称一致。 +//! //! # 可重试 //! -//! 失败后目标目录与导入前完全一致,重跑同一个包是安全的;成功后再跑一遍会靠基线里的哈希 -//! 走"跳过 / 已一致"路径,不会重复写盘。 +//! 失败后目标目录与导入前一致(或明确告诉你哪几项没回去),重跑同一个包是安全的;成功后再跑 +//! 一遍会靠基线里的哈希走"跳过 / 已一致"路径,不会重复写盘。 use super::super::pack::{PackArchive, PackError, PackRegistry}; -use super::baseline::{PackBaseline, BASELINE_FILE}; +use super::baseline::PackBaseline; use super::error::ImportError; use super::phase; use super::plan::ImportPlan; use super::report::{ keys, ImportIssue, ImportProgress, ImportReport, ImportStage, RegisteredInstance, }; -use super::stage::Staging; +use super::stage::{RollbackOutcome, Staging}; use super::ImportRequest; -use crate::platform::layout::LAUNCHER_HOME_DIR; /// 跑一次导入。**永不返回 `Err`**:所有失败都在报告的三态里。 pub async fn run(request: &ImportRequest<'_>) -> ImportReport { @@ -47,19 +49,7 @@ pub async fn run(request: &ImportRequest<'_>) -> ImportReport { }; account_plan(&mut report, &plan); - let baseline = match PackBaseline::load(request.target) { - Ok(baseline) => baseline, - Err(reason) => { - report.warnings.push(ImportIssue::new( - keys::NOTE_BASELINE_UNREADABLE, - format!( - "基线读不出来,本次按「没有基线」处理(磁盘上同名文件一律算用户已有内容):{reason}" - ), - )); - PackBaseline::default() - } - }; - + let baseline = load_baseline(request.target, &plan, &mut report); let staging = match Staging::begin(request.target) { Ok(staging) => staging, Err(error) => return failed(report, error), @@ -86,10 +76,27 @@ pub async fn run(request: &ImportRequest<'_>) -> ImportReport { return failed(report, error); } + // 引擎的续传残片(`.ullpart-*`)与 meta 不是整合包内容,提交前剔掉。 + match staging.purge_engine_artifacts() { + Ok(0) => {} + Ok(removed) => report.warnings.push(ImportIssue::new( + keys::NOTE_ENGINE_ARTIFACTS_DROPPED, + format!("提交前剔除了 {removed} 个下载引擎的中间产物(.ullpart-* / .ullmeta.json)"), + )), + Err(error) => { + staging.abort(); + return failed(report, error); + } + } + tell(&request.progress, ImportStage::Commit, 0, 0, None); let guard = match staging.commit() { Ok(guard) => guard, - Err(error) => return failed(report, error), + Err(failure) => { + let mut report = failed(report, failure.error); + record_rollback(&mut report, failure.rollback); + return report; + } }; if let Some(layout) = request.register { @@ -116,8 +123,10 @@ pub async fn run(request: &ImportRequest<'_>) -> ImportReport { } Err(error) => { // 内容落盘了但档案没建起来 = 用户看不见的半个实例 ⇒ 连内容一起回滚。 - guard.rollback(); - return failed(report, ImportError::instance(error.to_string())); + let outcome = guard.rollback(); + let mut report = failed(report, ImportError::instance(error.to_string())); + record_rollback(&mut report, outcome); + return report; } } } @@ -149,6 +158,62 @@ fn account_plan(report: &mut ImportReport, plan: &ImportPlan) { report.warnings.extend(plan.notes.iter().cloned()); } +/// 读三方合并的 base 侧:没有 / 读不出来 / **属于别的包** 三条路都不致命。 +/// +/// "属于别的包"要单独说一句:同一目录先后导入两个整合包时,若拿别人的基线当 base,用户改过 +/// 的文件会被判成"整合包升级"而**被静默覆盖**。所以身份不符时降级成"没有基线"并告警。 +fn load_baseline( + target: &std::path::Path, + plan: &ImportPlan, + report: &mut ImportReport, +) -> PackBaseline { + match PackBaseline::load(target) { + Ok(baseline) => { + if baseline.matches_pack(&plan.format, &plan.manifest.name) { + return baseline; + } + report.warnings.push(ImportIssue::new( + keys::NOTE_BASELINE_PACK_MISMATCH, + format!( + "基线属于另一个整合包({} / {:?}),本次按「没有基线」处理:磁盘上同名文件一律算用户已有内容", + if baseline.format.is_empty() { + "?" + } else { + &baseline.format + }, + baseline.pack_name + ), + )); + PackBaseline::default() + } + Err(reason) => { + report.warnings.push(ImportIssue::new( + keys::NOTE_BASELINE_UNREADABLE, + format!( + "基线读不出来,本次按「没有基线」处理(磁盘上同名文件一律算用户已有内容):{reason}" + ), + )); + PackBaseline::default() + } + } +} + +/// 把一次回滚的结果写进报告:不完整时**不许**继续宣称"目标目录与导入前一致"。 +fn record_rollback(report: &mut ImportReport, outcome: RollbackOutcome) { + if !outcome.is_complete() { + report.rolled_back = false; + report.warnings.push(ImportIssue::new( + keys::NOTE_ROLLBACK_INCOMPLETE, + format!( + "有 {} 项未能回滚(目标目录可能与导入前不同):{}", + outcome.failure_count(), + outcome.failed.join(";") + ), + )); + } + report.rollback = Some(outcome); +} + /// 基线作为**最后一个暂存文件**写进去:它跟同一个事务一起提交 / 回滚。 fn write_baseline( staging: &Staging, @@ -163,10 +228,13 @@ fn write_baseline( Ok(()) } -/// 基线相对实例根的路径(`/.ULL/pack-baseline.json`)。 +/// 基线相对实例根的路径(`<根>/.ull/pack-baseline.json`;与读侧同源,见 +/// [`PackBaseline::relative_path`])。 #[must_use] pub fn baseline_relative() -> String { - format!("{LAUNCHER_HOME_DIR}/{BASELINE_FILE}") + PackBaseline::relative_path() + .to_string_lossy() + .replace('\\', "/") } /// 发一条进度事件(`sink` 为 `None` 时什么都不做)。 @@ -187,7 +255,7 @@ pub(super) fn tell( } } -/// 失败收尾(目标目录此时要么不存在、要么与导入前一致)。 +/// 失败收尾:走到这里的失败都**没有写过目标目录**(写盘在 `staging` 里),因此可以宣称一致。 fn failed(mut report: ImportReport, error: ImportError) -> ImportReport { report.warnings.push(ImportIssue::new( keys::ROLLBACK_DONE, diff --git a/crates/core/src/addon/import/plan.rs b/crates/core/src/addon/import/plan.rs index caf7882..f9d5631 100644 --- a/crates/core/src/addon/import/plan.rs +++ b/crates/core/src/addon/import/plan.rs @@ -25,7 +25,7 @@ pub mod paths; -pub use paths::{curse_class_dir, strip_mc_prefix, suffix_variant, SuffixVariant}; +pub use paths::{curse_class_dir, strip_mc_prefix, suffix_note, suffix_variant, SuffixVariant}; use super::super::pack::{ curseforge, multimc, EnvSupport, PackArchive, PackError, PackFile, PackLoader, PackManifest, @@ -243,34 +243,6 @@ fn ordered_overrides(manifest: &PackManifest) -> Vec<&PackOverrides> { groups } -/// 后缀注记(`.disabled` / `.old`)。 -fn suffix_note(variant: SuffixVariant, path: &str) -> Option { - match variant { - SuffixVariant::Plain => None, - SuffixVariant::Disabled => Some( - ImportIssue::new( - keys::NOTE_DISABLED_SUFFIX, - "路径以 .disabled 结尾:按禁用态安装,后缀原样保留".to_string(), - ) - .with_path(path), - ), - SuffixVariant::Old => Some( - ImportIssue::new( - keys::NOTE_OLD_SUFFIX, - "路径以 .old 结尾:识别并原样保留(不清除、不改名)".to_string(), - ) - .with_path(path), - ), - SuffixVariant::DisabledOld => Some( - ImportIssue::new( - keys::NOTE_DISABLED_SUFFIX, - "路径以 .disabled.old 结尾:禁用态的旧文件,原样保留".to_string(), - ) - .with_path(path), - ), - } -} - /// 选一个加载器并翻译成 [`LoaderTarget`]。 fn select_loader( manifest: &PackManifest, diff --git a/crates/core/src/addon/import/plan/paths.rs b/crates/core/src/addon/import/plan/paths.rs index 86503aa..366596e 100644 --- a/crates/core/src/addon/import/plan/paths.rs +++ b/crates/core/src/addon/import/plan/paths.rs @@ -1,8 +1,10 @@ -//! 路径与版本号的小判据(纯函数):`.disabled` / `.old` 后缀变体、CF 类别目录、MC 版本前缀。 +//! 路径与版本号的小判据(纯函数):`.disabled` / `.old` 后缀变体与注记、CF 类别目录、MC 版本前缀。 //! //! 拆出来是因为它们是**格式事实**而非管线逻辑:`.disabled` 只按路径后缀(tower 定案,不用 //! `env.client=optional`)、`.old` 只识别与保留(不清不删)、CF 的 `classId` 认不出就不猜。 +use super::super::report::{keys, ImportIssue}; + /// 路径后缀变体(`.disabled` / `.old` 的四种组合)。 #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SuffixVariant { @@ -45,6 +47,35 @@ pub fn suffix_variant(path: &str) -> SuffixVariant { } } +/// 后缀注记(`.disabled` / `.old`):进报告,告诉用户"这个名字我们是认得的"。 +#[must_use] +pub fn suffix_note(variant: SuffixVariant, path: &str) -> Option { + match variant { + SuffixVariant::Plain => None, + SuffixVariant::Disabled => Some( + ImportIssue::new( + keys::NOTE_DISABLED_SUFFIX, + "路径以 .disabled 结尾:按禁用态安装,后缀原样保留".to_string(), + ) + .with_path(path), + ), + SuffixVariant::Old => Some( + ImportIssue::new( + keys::NOTE_OLD_SUFFIX, + "路径以 .old 结尾:识别并原样保留(不清除、不改名)".to_string(), + ) + .with_path(path), + ), + SuffixVariant::DisabledOld => Some( + ImportIssue::new( + keys::NOTE_DISABLED_SUFFIX, + "路径以 .disabled.old 结尾:禁用态的旧文件,原样保留".to_string(), + ) + .with_path(path), + ), + } +} + /// CurseForge `classId` → 实例内目录(**认不出的类别返回 `None`**,由调用方告警)。 /// /// 依据:CF 官方 `classId` 枚举(`6` mods / `12` resource packs / `6945` shaders / @@ -98,6 +129,16 @@ mod tests { assert!(suffix_variant("mods/x.jar.old").is_old()); } + #[test] + fn suffix_notes_only_for_recognised_variants() { + assert!(suffix_note(SuffixVariant::Plain, "mods/x.jar").is_none()); + let disabled = suffix_note(SuffixVariant::Disabled, "mods/x.jar.disabled").expect("有注记"); + assert_eq!(disabled.message_key, keys::NOTE_DISABLED_SUFFIX); + assert_eq!(disabled.path.as_deref(), Some("mods/x.jar.disabled")); + let old = suffix_note(SuffixVariant::Old, "mods/x.jar.old").expect("有注记"); + assert_eq!(old.message_key, keys::NOTE_OLD_SUFFIX); + } + #[test] fn strips_mc_prefix_from_loader_version() { // NeoForge 在 1.20.1 上的真实形状。 diff --git a/crates/core/src/addon/import/report.rs b/crates/core/src/addon/import/report.rs index 4b0854d..9181c2d 100644 --- a/crates/core/src/addon/import/report.rs +++ b/crates/core/src/addon/import/report.rs @@ -30,6 +30,7 @@ use std::path::PathBuf; use serde::Serialize; use super::error::ImportError; +use super::stage::RollbackOutcome; /// 导入三态。 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] @@ -166,11 +167,14 @@ pub struct ImportReport { pub error: Option, /// 登记的实例。 pub registered: Option, + /// 回滚结果(只有真的执行过回滚才有)。[`ImportReport::rolled_back`] 只在它**完整**时为真。 + pub rollback: Option, /// `instance.cfg` 里受开关门控的内存上限(MB;仅 MultiMC / Prism 有)。 pub max_memory_mb: Option, /// 本次写入的字节数。 pub bytes_written: u64, - /// 是否已回滚(`status == Failed` 时为目标目录零残留的证据位)。 + /// 是否已确认"目标目录与导入前一致":回滚**完整**完成、或从未写过目标目录时为 `true`; + /// 回滚有失败项时为 `false`(失败项在 [`ImportReport::rollback`] 里)。 pub rolled_back: bool, } @@ -194,6 +198,7 @@ impl ImportReport { warnings: Vec::new(), error: None, registered: None, + rollback: None, max_memory_mb: None, bytes_written: 0, rolled_back: false, diff --git a/crates/core/src/addon/import/report/keys.rs b/crates/core/src/addon/import/report/keys.rs index af6a1a8..d565ee8 100644 --- a/crates/core/src/addon/import/report/keys.rs +++ b/crates/core/src/addon/import/report/keys.rs @@ -45,6 +45,12 @@ pub const NOTE_REGISTERED: &str = "pack.import.note.registered"; pub const NOTE_INSTANCE_CFG_MEMORY: &str = "pack.import.note.instance_cfg_memory"; /// 注记:基线文件读不出来(本次按"没有基线"处理)。 pub const NOTE_BASELINE_UNREADABLE: &str = "pack.import.note.baseline_unreadable"; +/// 注记:基线属于**另一个整合包**(本次按"没有基线"处理)。 +pub const NOTE_BASELINE_PACK_MISMATCH: &str = "pack.import.note.baseline_pack_mismatch"; +/// 注记:提交前清掉了下载引擎的中间产物(`.ullpart-*` / `.ullmeta.json`)。 +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"; /// 缺失:调用方没给 fetcher(离线导入)。 pub const MISSING_OFFLINE: &str = "pack.import.missing.offline"; @@ -85,6 +91,9 @@ pub const ALL: &[&str] = &[ NOTE_REGISTERED, NOTE_INSTANCE_CFG_MEMORY, NOTE_BASELINE_UNREADABLE, + NOTE_BASELINE_PACK_MISMATCH, + NOTE_ENGINE_ARTIFACTS_DROPPED, + NOTE_ROLLBACK_INCOMPLETE, MISSING_OFFLINE, MISSING_NO_SOURCE, MISSING_FETCH_FAILED, diff --git a/crates/core/src/addon/import/stage.rs b/crates/core/src/addon/import/stage.rs index 525d308..23dee9d 100644 --- a/crates/core/src/addon/import/stage.rs +++ b/crates/core/src/addon/import/stage.rs @@ -19,7 +19,7 @@ pub mod commit; -pub use commit::CommitGuard; +pub use commit::{CommitFailure, CommitGuard, RollbackOutcome}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; @@ -29,6 +29,7 @@ use super::super::pack::PackError; use super::error::ImportError; use crate::platform::atomic::write_atomic; +use crate::download::atomic::{META_SUFFIX, PART_SUFFIX}; use commit::{rollback_merge, Applied}; /// 临时目录名里的进程内序号(同一进程并发导入不撞名)。 @@ -178,20 +179,24 @@ impl Staging { /// /// # Errors /// - /// `rename` 失败、合并态逐文件搬动失败(此时已回滚)。 - pub fn commit(self) -> Result { + /// `rename` 失败、合并态逐文件搬动失败。**回滚已经在这条路径上做过**,其结果连同错误 + /// 一起返回([`CommitFailure::rollback`]):调用方必须把它写进报告,不许假设"失败 = 已回滚干净"。 + pub fn commit(self) -> Result { match self.mode { - Mode::Fresh => { - std::fs::rename(&self.temp, &self.target).map_err(|error| { + Mode::Fresh => match std::fs::rename(&self.temp, &self.target) { + Ok(()) => Ok(CommitGuard::fresh(self.target.clone(), self.temp.clone())), + Err(error) => { self.abort_inner(); - io_error(format!( - "把 {} 改名成 {} 失败:{error}", - self.temp.display(), - self.target.display() + Err(CommitFailure::io( + format!( + "把 {} 改名成 {} 失败:{error}", + self.temp.display(), + self.target.display() + ), + RollbackOutcome::default(), )) - })?; - Ok(CommitGuard::fresh(self.target.clone(), self.temp.clone())) - } + } + }, Mode::Merge => { let mut applied: Vec = Vec::new(); match self.apply_merge(&mut applied) { @@ -201,15 +206,43 @@ impl Staging { applied, )), Err(error) => { - rollback_merge(&mut applied); + let rollback = rollback_merge(&mut applied); self.abort_inner(); - Err(error) + Err(CommitFailure::new(error, rollback)) } } } } } + /// 删掉暂存目录里的**下载引擎中间产物**(续传 part 与 meta),返回删掉的个数。 + /// + /// 为什么必须在提交前删:引擎在下载失败时**故意**保留 `.ullpart--` + /// 与 `.ullmeta.json` 供下次续传(`core::download::atomic` 的设计)。这些文件躺在 + /// 暂存目录里,提交阶段会跟内容一起搬进实例——实例里冒出 `.ullpart-…` 既不是整合包内容, + /// 又会被下次导入的基线/冲突判定当成"用户文件",从此谁也说不清。 + /// + /// 冲突口径:整合包里真有一个叫 `x.ullpart` 的文件几乎不可能;真出现时以"不入实例"为准。 + /// + /// # Errors + /// + /// 遍历或删除失败返回 `pack_io`。 + pub fn purge_engine_artifacts(&self) -> Result { + let mut files: Vec = Vec::new(); + collect_files(&self.temp, &mut files)?; + let mut removed = 0_usize; + for path in files { + if !is_engine_artifact(&path) { + continue; + } + std::fs::remove_file(&path).map_err(|error| { + io_error(format!("删中间产物 {} 失败:{error}", path.display())) + })?; + removed += 1; + } + Ok(removed) + } + /// 合并态:把临时目录里的文件逐个搬进目标目录(每个都带备份)。 fn apply_merge(&self, applied: &mut Vec) -> Result<(), ImportError> { let mut staged: Vec = Vec::new(); @@ -266,6 +299,15 @@ impl Staging { } } +/// 引擎中间产物的文件名判据(`.ullpart` 在中段、`.ullmeta.json` 在结尾)。 +#[must_use] +pub fn is_engine_artifact(path: &Path) -> bool { + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + return false; + }; + name.ends_with(META_SUFFIX) || name.contains(PART_SUFFIX) +} + /// 递归收集目录下的全部文件(绝对路径)。 fn collect_files(dir: &Path, out: &mut Vec) -> Result<(), ImportError> { let entries = std::fs::read_dir(dir) diff --git a/crates/core/src/addon/import/stage/commit.rs b/crates/core/src/addon/import/stage/commit.rs index 1d20769..25a1e38 100644 --- a/crates/core/src/addon/import/stage/commit.rs +++ b/crates/core/src/addon/import/stage/commit.rs @@ -1,4 +1,4 @@ -//! 提交守卫与回滚:**`Drop` 即回滚**,除非显式 `finish`。 +//! 提交守卫、回滚与**回滚本身失败的记账**:`Drop` 即回滚,除非显式 `finish`。 //! //! 靠调用方在每个错误分支手动清理,早晚会漏一条——所以把"回滚"绑在析构上:任何提前 //! `return`、`?` 冒泡、panic 展开都不会留下半个实例。 @@ -7,12 +7,47 @@ //! //! - **全新导入**:目标目录整个是本事务建的 ⇒ 删干净就是零残留; //! - **合并态**:按 journal **逆序**撤回——新文件删掉、被替换的旧文件从 `.ullbak-` 改回原名。 +//! +//! # 回滚也可能失败(复审 r5 P2-2) +//! +//! 早期实现把每一步写成 `let _ = …`,失败被吞,报告却照样宣称"目标目录与导入前一致"。 +//! 现在每次回滚都返回 [`RollbackOutcome`]:调用的**显式**回滚路径(`CommitGuard::rollback`) +//! 由导入管线把结果写进报告(`rolled_back` 只在**完整**回滚时为 `true`,否则列出失败项); +//! `Drop` 那条兜底路径拿不到调用方,只能尽力而为——所以它是"防漏"而不是"承诺",文档与 +//! 报告口径都按这个写。 use std::path::{Path, PathBuf}; +use super::io_error; +use crate::addon::import::ImportError; + /// 备份后缀(合并态的 journal 备份文件)。 const BACKUP_TAG: &str = "ullbak"; +/// 一次回滚的结果(**失败项要能被报告出来**)。 +#[derive(Debug, Clone, Default, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RollbackOutcome { + /// 尝试过的回滚动作数(删新文件 + 恢复备份)。 + pub attempted: usize, + /// 失败项的人读描述(路径 + 原因);空表 = 完整回滚。 + pub failed: Vec, +} + +impl RollbackOutcome { + /// 是否完整回滚(没有失败项)。 + #[must_use] + pub fn is_complete(&self) -> bool { + self.failed.is_empty() + } + + /// 失败项数。 + #[must_use] + pub fn failure_count(&self) -> usize { + self.failed.len() + } +} + /// 一条已应用的搬动记录。 #[derive(Debug)] pub struct Applied { @@ -88,10 +123,13 @@ impl CommitGuard { } } - /// 显式回滚(`Drop` 也会做,这里给"想马上回滚并继续"的调用方一个明确入口)。 - pub fn rollback(mut self) { - if let Some(inner) = self.inner.as_mut() { - rollback_inner(inner); + /// 显式回滚,并**返回结果**(失败的项由调用方写进报告)。 + /// + /// `Drop` 也会回滚,但那条路径没法上报——因此真正需要"宣称一致"的地方必须调这个。 + pub fn rollback(mut self) -> RollbackOutcome { + match self.inner.as_mut() { + Some(inner) => rollback_inner(inner), + None => RollbackOutcome::default(), } } } @@ -100,36 +138,63 @@ impl Drop for CommitGuard { fn drop(&mut self) { if let Some(inner) = self.inner.as_mut() { if !inner.finished { - rollback_inner(inner); + // 兜底:拿不到调用方,失败只能丢在这里(所以别依赖 Drop 去做"宣称")。 + let _ = rollback_inner(inner); } } } } /// 回滚一次提交。 -fn rollback_inner(inner: &mut Inner) { +fn rollback_inner(inner: &mut Inner) -> RollbackOutcome { + let mut outcome = RollbackOutcome::default(); if inner.created_target { // 全新导入:目标目录整个是本事务建的,删干净 = 零残留。 - let _ = std::fs::remove_dir_all(&inner.target); + outcome.attempted += 1; + if let Err(error) = std::fs::remove_dir_all(&inner.target) { + if error.kind() != std::io::ErrorKind::NotFound { + outcome.failed.push(format!( + "删除本次新建的实例目录 {} 失败:{error}", + inner.target.display() + )); + } + } } else { - rollback_merge(&mut inner.applied); + outcome = rollback_merge(&mut inner.applied); } let _ = std::fs::remove_dir_all(&inner.temp); inner.finished = true; + outcome } -/// 合并态回滚:新文件删掉、备份改回原名。 -pub(super) fn rollback_merge(applied: &mut [Applied]) { +/// 合并态回滚:新文件删掉、备份改回原名(**失败逐项记下来**)。 +pub(super) fn rollback_merge(applied: &mut [Applied]) -> RollbackOutcome { + let mut outcome = RollbackOutcome::default(); for entry in applied.iter().rev() { if entry.moved { - let _ = std::fs::remove_file(&entry.dest); + outcome.attempted += 1; + match std::fs::remove_file(&entry.dest) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => outcome + .failed + .push(format!("删除 {} 失败:{error}", entry.dest.display())), + } } if let Some(backup) = &entry.backup { if backup.exists() { - let _ = std::fs::rename(backup, &entry.dest); + outcome.attempted += 1; + if let Err(error) = std::fs::rename(backup, &entry.dest) { + outcome.failed.push(format!( + "把备份 {} 改回 {} 失败:{error}", + backup.display(), + entry.dest.display() + )); + } } } } + outcome } /// 备份路径:`.ullbak-`。 @@ -141,3 +206,85 @@ pub(super) fn backup_path(dest: &Path) -> PathBuf { name.push(format!(".{BACKUP_TAG}-{}", std::process::id())); dest.with_file_name(name) } + +/// 一次**失败的回滚**(提交阶段失败时连同错误一起交回调用方)。 +/// +/// `io_error` 让"把一个 IO 原因包成 `pack_io`"只在一处定义。 +#[derive(Debug)] +pub struct CommitFailure { + /// 提交阶段本身的错误。 + pub error: ImportError, + /// 回滚结果(可能不完整)。 + pub rollback: RollbackOutcome, +} + +impl CommitFailure { + /// 用一条 IO 原因建失败结果。 + #[must_use] + pub(super) fn io(message: impl Into, rollback: RollbackOutcome) -> Self { + Self { + error: io_error(message), + rollback, + } + } + + /// 用已有的导入错误建失败结果。 + #[must_use] + pub(super) fn new(error: ImportError, rollback: RollbackOutcome) -> Self { + Self { error, rollback } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn applied(dest: PathBuf, backup: Option, moved: bool) -> Applied { + Applied { + dest, + backup, + moved, + } + } + + #[test] + fn complete_rollback_has_no_failures() { + let root = std::env::temp_dir().join(format!("ull-rb-ok-{}", std::process::id())); + std::fs::create_dir_all(root.join("config")).expect("建目录"); + let dest = root.join("config/a.toml"); + let backup = root.join("config/a.toml.ullbak-test"); + std::fs::write(&dest, b"new").expect("写新文件"); + std::fs::write(&backup, b"old").expect("写备份"); + let mut journal = vec![applied(dest.clone(), Some(backup.clone()), true)]; + + let outcome = rollback_merge(&mut journal); + + assert!(outcome.is_complete(), "{outcome:?}"); + assert_eq!(outcome.attempted, 2); + assert_eq!( + std::fs::read(&dest).expect("读回"), + b"old", + "备份要改回原名" + ); + assert!(!backup.exists()); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn rollback_failures_are_reported_not_swallowed() { + // 目标位置上是个**非空目录**:`remove_file` 与随后的备份改回都会失败 + // (Windows / Unix 一致),于是"回滚失败"这条路径被确定性地打到。 + let root = std::env::temp_dir().join(format!("ull-rb-fail-{}", std::process::id())); + let dest = root.join("a.toml"); + std::fs::create_dir_all(dest.join("occupied")).expect("用非空目录占住目标"); + let backup = root.join("a.toml.ullbak-test"); + std::fs::write(&backup, b"old").expect("写备份"); + let mut journal = vec![applied(dest.clone(), Some(backup), true)]; + + let outcome = rollback_merge(&mut journal); + + assert!(!outcome.is_complete(), "失败必须被记下来:{outcome:?}"); + assert_eq!(outcome.failure_count(), 2, "{outcome:?}"); + let _ = std::fs::remove_dir_all(&root); + } +} diff --git a/crates/core/src/addon/tests/import_curseforge.rs b/crates/core/src/addon/tests/import_curseforge.rs index a48640e..03d6d8c 100644 --- a/crates/core/src/addon/tests/import_curseforge.rs +++ b/crates/core/src/addon/tests/import_curseforge.rs @@ -73,14 +73,14 @@ fn imports_curseforge_pack_end_to_end() { ); // 基线写进去了(下次导入的 base 侧)。 - let baseline = read_text(&target.join(".ULL/pack-baseline.json")); + let baseline = read_text(&target.join(super::baseline_rel())); assert!(baseline.contains("mods/jei.jar"), "基线:{baseline}"); assert!(baseline.contains("config/known.toml"), "基线:{baseline}"); assert_eq!( list_tree(&target), vec![ - ".ULL/pack-baseline.json".to_string(), + super::baseline_rel(), "config/broken.toml".to_string(), "config/known.toml".to_string(), "mods/example.jar".to_string(), diff --git a/crates/core/src/addon/tests/import_failures.rs b/crates/core/src/addon/tests/import_failures.rs index bb62583..57e8511 100644 --- a/crates/core/src/addon/tests/import_failures.rs +++ b/crates/core/src/addon/tests/import_failures.rs @@ -245,7 +245,7 @@ fn commit_failure_rolls_back_partially_applied_files() { assert!(second.rolled_back); assert_eq!(list_tree(&target), before, "目标目录必须逐项回到导入前"); // 提交阶段排在最前面的基线被成功搬入又被撤回:内容仍是第一次导入的那份。 - assert!(target.join(".ULL/pack-baseline.json").is_file()); + assert!(target.join(super::baseline_rel()).is_file()); assert_eq!( read_text(&target.join("config/known.toml")), "key = \"value\"\n", diff --git a/crates/core/src/addon/tests/import_merge.rs b/crates/core/src/addon/tests/import_merge.rs index fe41d9e..64cd84a 100644 --- a/crates/core/src/addon/tests/import_merge.rs +++ b/crates/core/src/addon/tests/import_merge.rs @@ -10,7 +10,9 @@ use crate::addon::import::{ use crate::download::blocking::block_on; use super::support::mrpack_stub; -use super::{read_text, temp_root, write_file, zip_dir}; +use super::{list_tree, read_text, temp_root, write_file, zip_dir}; + +use crate::addon::import::{PackBaseline, BASELINE_FILE}; #[test] fn user_modified_file_is_kept_and_reported() { @@ -202,3 +204,90 @@ fn client_overrides_wins_over_overrides() { report.conflicts ); } + +#[test] +fn baseline_read_and_write_paths_are_the_same_file() { + // 复审 r5 P1-1 的回归钉:读侧(`PackBaseline::path`)与写侧(`relative_path` 拼 root) + // **必须解析到同一个路径**。只测"读得到"在大小写不敏感的系统上会假绿。 + let root = temp_root("baseline-path").join("instance"); + let read = PackBaseline::path(&root); + let write = root.join(PackBaseline::relative_path()); + assert_eq!(read, write, "读写必须是同一个文件:{read:?} vs {write:?}"); + // 相对路径的唯一来源是 `GameLayout::ull_root()`(游戏根内的 `.ull/`),不是启动器数据根。 + assert_eq!( + PackBaseline::relative_path(), + std::path::PathBuf::from(".ull").join(BASELINE_FILE) + ); + assert_eq!( + read.parent(), + Some( + crate::game::layout::GameLayout::new(&root) + .ull_root() + .as_path() + ) + ); + // 写侧(pipeline 提交时用的相对路径)必须同源;`.ULL` 是**启动器数据根**的名字, + // 出现在实例内路径里就是 P1-1 那个 bug(大小写敏感的系统上会变成两个文件)。 + let write_side = crate::addon::import::baseline_relative(); + assert_eq!( + write_side, + PackBaseline::relative_path() + .to_string_lossy() + .replace('\\', "/"), + "写侧必须从同一个来源派生" + ); + assert!( + !write_side.contains(".ULL"), + "实例内路径不许用启动器数据根的 `.ULL`:{write_side}" + ); +} + +#[test] +fn baseline_of_another_pack_is_not_used_as_base() { + // 同一目录先后导入两个整合包:别人的基线当 base 会把"用户改过"误判成"整合包升级"而静默覆盖。 + let root = temp_root("merge-identity"); + let source = super::fixtures::multimc("merge-identity-a"); + let target = root.join("instance"); + let first = block_on(import_pack(&ImportRequest::new(&source, &target))); + assert_eq!(first.status, ImportStatus::Success, "{:?}", first.error); + // 用户改了一个包内文件。 + write_file(&target.join("mods/example.jar"), b"mine"); + + // 另一个包(名字不同 + 同名文件内容不同)导进同一目录。 + let other = root.join("other"); + write_file( + &other.join("mmc-pack.json"), + br#"{"formatVersion": 1, "components": [ + {"uid": "net.minecraft", "version": "1.20.1"}, + {"uid": "net.fabricmc.fabric-loader", "version": "0.15.11"}]}"#, + ); + write_file( + &other.join("instance.cfg"), + "InstanceType=OneSix\nname=另一个包\n".as_bytes(), + ); + write_file(&other.join(".minecraft/mods/example.jar"), b"pack B jar"); + let report = block_on(import_pack(&ImportRequest::new(&other, &target))); + + assert!( + report + .warnings + .iter() + .any(|note| note.message_key == crate::addon::import::keys::NOTE_BASELINE_PACK_MISMATCH), + "跨包基线要具名告警:{:?}", + report.warnings + ); + let conflict = report + .conflicts + .iter() + .find(|conflict| conflict.path == "mods/example.jar") + .expect("要有冲突"); + assert_eq!( + conflict.reason, + ConflictReason::PreExisting, + "别人的基线不能拿来判 user_modified" + ); + assert!(conflict.base_sha1.is_none(), "身份不符时基线应被忽略"); + assert_eq!(read_text(&target.join("mods/example.jar")), "mine"); + assert_eq!(report.status, ImportStatus::Partial); + assert!(list_tree(&target).contains(&"mods/example.jar".to_string())); +} diff --git a/crates/core/src/addon/tests/import_modrinth.rs b/crates/core/src/addon/tests/import_modrinth.rs index 5e2e61f..0dbb934 100644 --- a/crates/core/src/addon/tests/import_modrinth.rs +++ b/crates/core/src/addon/tests/import_modrinth.rs @@ -79,7 +79,7 @@ fn imports_mrpack_end_to_end() { assert_eq!( list_tree(&target), vec![ - ".ULL/pack-baseline.json".to_string(), + super::baseline_rel(), "config/client-only.toml".to_string(), "config/known.toml".to_string(), "mods/half.jar".to_string(), @@ -89,6 +89,34 @@ fn imports_mrpack_end_to_end() { ); } +#[test] +fn engine_artifacts_never_reach_the_instance() { + // 引擎在下载失败时故意留下 `.ullpart--` 与 `.ullmeta.json` + // 供下次续传;它们躺在暂存目录里,提交前必须剔掉,否则会变成实例里的"内容"。 + let (fixture, fetcher) = mrpack_stub("mr-artifacts"); + let fetcher = fetcher.with_engine_artifacts(); + let target = temp_root("mr-artifacts-out").join("instance"); + let report = block_on(import_pack( + &ImportRequest::new(&fixture.zip, &target).with_fetcher(&fetcher), + )); + + assert_eq!(report.status, ImportStatus::Success, "{:?}", report.error); + let tree = list_tree(&target); + assert!( + tree.iter() + .all(|path| !path.contains(".ullpart") && !path.contains(".ullmeta")), + "中间产物不许进实例:{tree:?}" + ); + assert!( + report + .warnings + .iter() + .any(|note| note.message_key == keys::NOTE_ENGINE_ARTIFACTS_DROPPED), + "剔除了就该留一条注记:{:?}", + report.warnings + ); +} + #[test] fn disabled_suffix_is_preserved_verbatim() { // 四种 `.disabled` / `.old` 变体都按原后缀落地,不改名、不清理(tower 定案)。 diff --git a/crates/core/src/addon/tests/import_multimc.rs b/crates/core/src/addon/tests/import_multimc.rs index 3a72eaa..170b82e 100644 --- a/crates/core/src/addon/tests/import_multimc.rs +++ b/crates/core/src/addon/tests/import_multimc.rs @@ -46,7 +46,7 @@ fn imports_multimc_instance_end_to_end() { assert_eq!( list_tree(&target), vec![ - ".ULL/pack-baseline.json".to_string(), + super::baseline_rel(), "config/known.toml".to_string(), "mods/broken.jar".to_string(), "mods/example.jar".to_string(), @@ -175,6 +175,7 @@ fn registers_the_instance_when_a_launcher_layout_is_given() { .warnings .iter() .any(|note| note.message_key == keys::NOTE_REGISTERED)); + assert!(report.rollback.is_none(), "成功路径不该有回滚记录"); } #[test] diff --git a/crates/core/src/addon/tests/mod.rs b/crates/core/src/addon/tests/mod.rs index ea5d6ca..6823527 100644 --- a/crates/core/src/addon/tests/mod.rs +++ b/crates/core/src/addon/tests/mod.rs @@ -148,6 +148,16 @@ pub(crate) fn list_tree(root: &Path) -> Vec { out } +/// 基线相对实例根的路径(`/` 分隔,给 `list_tree` 断言用)。 +/// +/// 从 `PackBaseline::relative_path()` 派生,**不在测试里手抄**目录名——手抄的那一份正是 +/// 复审 r5 P1-1 的成因(读写两条路径各写各的)。 +pub(crate) fn baseline_rel() -> String { + crate::addon::import::PackBaseline::relative_path() + .to_string_lossy() + .replace('\\', "/") +} + /// 读一个文件成字符串(断言覆盖内容用)。 pub(crate) fn read_text(path: &Path) -> String { let bytes = diff --git a/crates/core/src/addon/tests/support.rs b/crates/core/src/addon/tests/support.rs index 7c5a36a..e795797 100644 --- a/crates/core/src/addon/tests/support.rs +++ b/crates/core/src/addon/tests/support.rs @@ -53,6 +53,8 @@ pub(crate) struct StubFetcher { curse: BTreeMap<(i64, i64), CurseFileMeta>, /// 被请求过的 URL(断言"没白下载"用)。 calls: Mutex>, + /// 是否像真引擎那样在目标旁边留下续传残片(`.ullpart-*` / `.ullmeta.json`)。 + artifacts: bool, } impl StubFetcher { @@ -62,6 +64,7 @@ impl StubFetcher { files: BTreeMap::new(), curse: BTreeMap::new(), calls: Mutex::new(Vec::new()), + artifacts: false, } } @@ -77,6 +80,12 @@ impl StubFetcher { self } + /// 像真引擎那样在每次"下载"后留下续传残片与 meta(用来钉"中间产物不许进实例")。 + pub(crate) fn with_engine_artifacts(mut self) -> Self { + self.artifacts = true; + self + } + /// 被请求过的 URL 列表。 pub(crate) fn calls(&self) -> Vec { self.calls.lock().expect("calls 锁").clone() @@ -131,6 +140,9 @@ impl PackFetcher for StubFetcher { error.to_string(), ) })?; + if self.artifacts { + leave_engine_artifacts(request.dest); + } Ok(FetchedFile { bytes: bytes.len() as u64, }) @@ -180,3 +192,17 @@ pub(crate) fn curse_meta(file_name: &str, bytes: &[u8]) -> CurseFileMeta { class_id: Some(6), } } + +/// 在目标旁边留下与 `core::download::atomic` 同名的续传残片与 meta。 +fn leave_engine_artifacts(dest: &std::path::Path) { + let Some(name) = dest + .file_name() + .map(|name| name.to_string_lossy().to_string()) + else { + return; + }; + let part = dest.with_file_name(format!("{name}.ullpart-{}-0", std::process::id())); + let meta = dest.with_file_name(format!("{name}.ullmeta.json")); + std::fs::write(part, b"partial bytes").expect("写续传残片"); + std::fs::write(meta, b"{}").expect("写续传 meta"); +}