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:
1 parent
4760714743
commit
7f657d225c
17 files changed
+577
-104
No files matched your search
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 上的真实形状。
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
Reference in new issue
Block a user