fix(addon): M111 r5 —— 基线读写同源(.ull)、plan.rs 拆回 400 内、中间产物/回滚/包身份

复审 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 不受影响)。
This commit is contained in:
AskaEth authored and AskaEth committed 2026-11-04 20:54:05 +08:00
1 parent 4760714743
commit 7f657d225c
17 files changed
+577 -104

No files matched your search

+2 -2
View File
@@ -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;
+44 -14
View File
@@ -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 {
/// 实例根下的基线路径(`<root>/.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
+92 -24
View File
@@ -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(())
}
/// 基线相对实例根的路径(`<root>/.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,
+1 -29
View File
@@ -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<ImportIssue> {
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,
+42 -1
View File
@@ -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<ImportIssue> {
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 上的真实形状。
+6 -1
View File
@@ -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<ImportError>,
/// 登记的实例。
pub registered: Option<RegisteredInstance>,
/// 回滚结果(只有真的执行过回滚才有)。[`ImportReport::rolled_back`] 只在它**完整**时为真。
pub rollback: Option<RollbackOutcome>,
/// `instance.cfg` 里受开关门控的内存上限(MB;仅 MultiMC / Prism 有)。
pub max_memory_mb: Option<u64>,
/// 本次写入的字节数。
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,
@@ -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,
+56 -14
View File
@@ -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<CommitGuard, ImportError> {
/// `rename` 失败、合并态逐文件搬动失败。**回滚已经在这条路径上做过**,其结果连同错误
/// 一起返回([`CommitFailure::rollback`]):调用方必须把它写进报告,不许假设"失败 = 已回滚干净"。
pub fn commit(self) -> Result<CommitGuard, CommitFailure> {
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<Applied> = 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),返回删掉的个数。
///
/// 为什么必须在提交前删:引擎在下载失败时**故意**保留 `<name>.ullpart-<session>-<seq>`
/// 与 `<name>.ullmeta.json` 供下次续传(`core::download::atomic` 的设计)。这些文件躺在
/// 暂存目录里,提交阶段会跟内容一起搬进实例——实例里冒出 `.ullpart-…` 既不是整合包内容,
/// 又会被下次导入的基线/冲突判定当成"用户文件",从此谁也说不清。
///
/// 冲突口径:整合包里真有一个叫 `x.ullpart` 的文件几乎不可能;真出现时以"不入实例"为准。
///
/// # Errors
///
/// 遍历或删除失败返回 `pack_io`。
pub fn purge_engine_artifacts(&self) -> Result<usize, ImportError> {
let mut files: Vec<PathBuf> = 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<Applied>) -> Result<(), ImportError> {
let mut staged: Vec<PathBuf> = 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<PathBuf>) -> Result<(), ImportError> {
let entries = std::fs::read_dir(dir)
+160 -13
View File
@@ -1,4 +1,4 @@
//! 提交守卫与回滚:**`Drop` 即回滚**,除非显式 `finish`。
//! 提交守卫、回滚与**回滚本身失败的记账**:`Drop` 即回滚,除非显式 `finish`。
//!
//! 靠调用方在每个错误分支手动清理,早晚会漏一条——所以把"回滚"绑在析构上:任何提前
//! `return`、`?` 冒泡、panic 展开都不会留下半个实例。
@@ -7,12 +7,47 @@
//!
//! - **全新导入**:目标目录整个是本事务建的 ⇒ 删干净就是零残留;
//! - **合并态**:按 journal **逆序**撤回——新文件删掉、被替换的旧文件从 `.ullbak-<pid>` 改回原名。
//!
//! # 回滚也可能失败(复审 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<String>,
}
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
}
/// 备份路径:`<dest>.ullbak-<pid>`。
@@ -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<String>, 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<PathBuf>, 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);
}
}
@@ -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(),
@@ -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",
+90 -1
View File
@@ -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()));
}
+29 -1
View File
@@ -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() {
// 引擎在下载失败时故意留下 `<name>.ullpart-<session>-<seq>` 与 `<name>.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 定案)。
@@ -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]
+10
View File
@@ -148,6 +148,16 @@ pub(crate) fn list_tree(root: &Path) -> Vec<String> {
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 =
+26
View File
@@ -53,6 +53,8 @@ pub(crate) struct StubFetcher {
curse: BTreeMap<(i64, i64), CurseFileMeta>,
/// 被请求过的 URL(断言"没白下载"用)。
calls: Mutex<Vec<String>>,
/// 是否像真引擎那样在目标旁边留下续传残片(`.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<String> {
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");
}