feat(addon): M111 整合包导入管线(CF / mrpack / MultiMC)+ ull pack import

接 M110 的 pack 模型与安全闸,做导入:三格式 → 实例目录,落 D10 §2 三条硬要求。

core(`addon::import`):
- 三方合并:`merge::decide` 判据表(磁盘 × 基线 × 包内),base 侧落在
  `<实例根>/.ULL/pack-baseline.json`;无基线时磁盘同名文件算用户已有内容 ⇒ 不覆盖。
  冲突逐条进报告(user_modified / user_deleted / pre_existing + 可选处置)。
- 两阶段:先 overrides 再补远端;失败分级只有一条判据(是不是"某个文件搞不到"):
  缺文件 ⇒ Partial;穿越/符号链接/限额/清单坏/摘要不符/IO ⇒ Failed 且整包回滚。
- 事务:`Staging` 临时目录 + `CommitGuard`(全新导入 rename 整目录、合并态 journal 逆序
  回滚、Drop 即回滚),基线作为最后一个暂存文件同生共死。
- 三格式语义:mrpack `client-overrides` 胜 `overrides`、`server-overrides` 不解压;
  `.disabled`/`.old` 按路径后缀识别并原样保留;MultiMC `formatVersion != 1` 整份拒绝、
  非加载器 component 逐项进报告;CF `classId` 认不出就不猜。
- 加载器只做翻译(`LoaderTarget`:kind + 原版 + 加载器版本 + 版本 id,含 CF NeoForge
  `1.20.1-47.1.106` 的 MC 前缀剥离),网络全部经注入的 `PackFetcher`
  (生产实现 `DownloadEngineFetcher` 只走 core::download,无第二个 reqwest client)。
- 报告:三态 + 逐项原因 + 稳定文案 key 表(`report::keys`,含 ALL 守门表)。

CLI(`ull pack import <file> [--target] [--name] [--overwrite] [--no-register]`):
- 退出码三态 0 / 3 / 1(`Outcome::exit_code` + runner 透传,其它命令仍 0);
- `--json` 恒有信封(`data.status` / `data.error.code` / `data.stages`),Failed 不抛栈。

测试:29 条 core(三格式端到端、合并 6 条、必须先失败 8 条含双检互补样本与确定性
提交失败回滚、报告契约 5 条)+ 5 条 CLI(三态退出码、JSON 契约、既有命令回归、help)。
fmt / clippy(core+cli, -D warnings) 绿;文件全部 ≤400 行。
This commit is contained in:
AskaEth authored and AskaEth committed 2026-11-04 20:54:04 +08:00
1 parent 78bcc3038b
commit 4760714743
32 files changed
+5044 -5

No files matched your search

+26 -1
View File
@@ -19,6 +19,10 @@ pub const fn general() -> &'static str {
时再叠一层加载器)→ 登记实例;--dry-run 只出计划
launch <instance> [--dry-run] [--account <name>] [--java <path>] [--username <name>]
[--max-memory <MB>] 启动实例;--dry-run 只打印组装后的命令行
pack import <file> [--target <dir>] [--name N] [--overwrite] [--no-register]
导入整合包(CF manifest.json / Modrinth .mrpack /
MultiMC·Prism 实例)→ 落内容 → 登记实例;
缺文件降级为「部分成功」(退出码 3)
accounts list 列出账户(名称 / 轨道 / UUID,**不含凭据**)
accounts add offline <name> 添加离线账户(HMCL 兼容 UUID)
accounts add msa 添加微软账户(设备码交互式登录)
@@ -46,7 +50,7 @@ pub const fn general() -> &'static str {
--help, -h 帮助(有位置参数时按该命令给帮助)
--version, -V 版本 + 系统摘要
退出码: 0 成功 / 1 用户错误 / 2 内部错误"
退出码: 0 成功 / 1 用户错误 / 2 内部错误 / 3 部分成功(仅 pack import)"
}
/// 单个命令的帮助;未知主题返回 `None`(调用方退回总帮助)。
@@ -59,6 +63,27 @@ pub fn topic(name: &str) -> Option<&'static str> {
列出 profiles.json 里的实例与 versions/ 下已安装的版本。
--json 输出 {\"data\":{\"instances\":[…],\"installed_versions\":[…],\"data_root\":…}}",
),
"pack" => Some(
"\
%prog% pack import <file> [--target <dir>] [--name N] [--overwrite] [--no-register]
导入一个整合包到实例目录。三种格式按优先级探测(形状校验在前,不靠文件名抢):
CurseForge manifestType=minecraftModpack 的 manifest.json + overrides/
Modrinth modrinth.index.json + overrides/ client-overrides/(后者胜;
server-overrides/ 客户端不解压)
MultiMC mmc-pack.json(formatVersion 必须为 1)+ instance.cfg + .minecraft/
(Prism 实例;非加载器 component 会逐项列进报告,不静默丢)
目标目录缺省 = 数据根的共享游戏目录(.minecraft/);`--target` 可钉到实例目录。
两阶段落盘:先 overrides(三方合并:包内原值 / 磁盘现状 / 上次基线),再补远端文件。
· 用户改过的文件不会被静默覆盖(默认保留,逐条进冲突清单);`--overwrite` 才用包覆盖
· 缺文件(断网 / CurseForge files[] 补不出来)可容忍,但结果降级为「部分成功」
· 目录穿越 / 符号链接 / 清单损坏 / 摘要不符一律整包回滚,目标目录零残留
`--no-register`:只落内容,不写 profiles.json。
退出码:0 成功 / 3 部分成功 / 1 失败(2 内部错误)。
--json:信封恒为 {\"ok\":true,\"data\":{…}},分流看 data.status 与 data.error.code;
data.stages 列出实际走过的阶段。",
),
"install" => Some(
"\
+1 -1
View File
@@ -13,7 +13,7 @@ pub mod program;
pub use model::{
AccountsAddArgs, AccountsAddKind, AgentServeArgs, Command, ConfigGetArgs, ConfigSetArgs,
DiagArgs, Globals, InstallArgs, Invocation, LaunchArgs, LogsArgs,
DiagArgs, Globals, InstallArgs, Invocation, LaunchArgs, LogsArgs, PackImportArgs,
};
pub use parse::parse;
+17
View File
@@ -28,6 +28,8 @@ pub enum Command {
InstancesList,
/// `install <version> [--loader X] [--name N]`。
Install(InstallArgs),
/// `pack import <file> [--target <dir>] [--name N] [--overwrite] [--no-register]`。
PackImport(PackImportArgs),
/// `launch <instance> [--dry-run]`。
Launch(LaunchArgs),
/// `sessions list [--limit N]`。
@@ -133,6 +135,21 @@ pub struct InstallArgs {
pub json_progress: bool,
}
/// `pack import` 的参数(M111)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PackImportArgs {
/// 整合包路径:`.zip` / `.mrpack` / MultiMC 实例目录。
pub path: PathBuf,
/// 目标实例目录(缺省 = 数据根的共享游戏目录 `game_home`)。
pub target: Option<PathBuf>,
/// 实例显示名(缺省 = 包名)。
pub name: Option<String>,
/// 冲突时用包内内容覆盖(缺省保留用户文件)。
pub overwrite: bool,
/// 不登记实例(只落内容)。
pub no_register: bool,
}
/// `launch` 的参数。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LaunchArgs {
+28 -1
View File
@@ -12,7 +12,7 @@ mod agent;
use super::model::{
Command, ConfigGetArgs, ConfigSetArgs, DiagArgs, Globals, InstallArgs, Invocation, LaunchArgs,
LogsArgs,
LogsArgs, PackImportArgs,
};
/// `logs` 缺省尾巴行数。
@@ -83,6 +83,7 @@ fn parse_command(rest: &[String], globals: &Globals) -> Result<Command, CliError
"help" => Ok(Command::Help(tail.first().cloned())),
"instances" => parse_instances(tail),
"install" => parse_install(tail, globals),
"pack" => parse_pack(tail),
"launch" => parse_launch(tail),
"sessions" => parse_sessions(tail),
"logs" => parse_logs(tail),
@@ -128,6 +129,32 @@ fn parse_install(tail: &[String], globals: &Globals) -> Result<Command, CliError
}))
}
/// `pack import <file> [--target <dir>] [--name N] [--overwrite] [--no-register]`。
fn parse_pack(tail: &[String]) -> Result<Command, CliError> {
let Some(sub) = tail.first().map(String::as_str) else {
return Err(CliError::usage("pack 需要子命令 import")
.with_hint("%prog% pack import <file> [--target <dir>]"));
};
if sub != "import" {
return Err(CliError::usage(format!(
"pack 的子命令只有 import,收到 `{sub}`"
)));
}
let parsed = parse_flags(
tail.get(1..).unwrap_or_default(),
&["target", "name"],
&["overwrite", "no-register"],
)?;
let path = require_single_positional(&parsed, "pack import <file>")?;
Ok(Command::PackImport(PackImportArgs {
path: PathBuf::from(path),
target: parsed.value("target").map(PathBuf::from),
name: parsed.value("name"),
overwrite: parsed.has("overwrite"),
no_register: parsed.has("no-register"),
}))
}
fn parse_launch(tail: &[String]) -> Result<Command, CliError> {
let parsed = parse_flags(
tail,
+22
View File
@@ -11,6 +11,7 @@ pub mod java_launch;
pub mod java_vendors;
pub mod launch;
pub mod meta;
pub mod pack;
pub mod sessions;
pub mod snapshot;
@@ -32,6 +33,9 @@ pub fn run(invocation: &Invocation) -> Result<Outcome, CliError> {
Command::Help(topic) => Ok(Outcome::new(meta::help(topic.as_deref()))),
Command::InstancesList => with_ctx(&invocation.globals, instances::list),
Command::Install(args) => with_ctx(&invocation.globals, |ctx| install::run(ctx, args)),
Command::PackImport(args) => {
with_ctx_outcome(&invocation.globals, |ctx| pack::run(ctx, args))
}
Command::Launch(args) => with_ctx(&invocation.globals, |ctx| launch::run(ctx, args)),
Command::SessionsList { limit } => {
with_ctx(&invocation.globals, |ctx| sessions::list(ctx, *limit))
@@ -121,6 +125,24 @@ where
Ok(Outcome::with_warnings(report, ctx.notes()))
}
/// 同 [`with_ctx`],但命令自己组装 [`Outcome`](需要指定退出码或多态结果时用)。
///
/// 只有 `pack import` 走这条路:它的三态要 0 / 3 / 1 三个退出码,`Report` 表达不了。
fn with_ctx_outcome<F>(globals: &Globals, action: F) -> Result<Outcome, CliError>
where
F: FnOnce(&Ctx) -> Result<Outcome, CliError>,
{
let ctx = Ctx::resolve(globals)?;
ctx.ensure_supported()?;
let mut outcome = action(&ctx)?;
outcome.warnings.extend(
ctx.notes()
.into_iter()
.map(|line| crate::cli::program::fill(&line)),
);
Ok(outcome)
}
#[cfg(test)]
mod tests {
use super::map_auth_error;
+239
View File
@@ -0,0 +1,239 @@
//! `pack import <file>`:整合包导入(M111)。
//!
//! 逻辑**全在** `core::addon::import`:本文件只做三件事——参数落地、下载引擎接线、两种输出。
//! 之所以坚持这一层很薄,是因为 M113 的导入页要复用的是 core 的 `import_pack` + `ImportReport`,
//! 不是这里的打印逻辑。
//!
//! # 三态退出码(脚本契约)
//!
//! | `data.status` | 退出码 | 含义 |
//! |---------------|--------|------|
//! | `success` | `0` | 全部落地、无冲突 |
//! | `partial` | `3` | 有缺文件或有保留用户文件的冲突(实例可用但不完整) |
//! | `failed` | `1` | 致命失败,**已整包回滚** |
//!
//! `--json` 的信封在三种状态下**都是** `{"ok":true,"data":{…}}`(命令跑完了),分流看
//! `data.status` / `data.error.code` 或退出码;`ok:false` 只用于"命令没跑起来"(参数错、
//! 数据根不可用),与其它命令一致。
use std::path::Path;
use std::sync::{Arc, Mutex};
use serde_json::json;
use unlucky_core::addon::import::{
import_pack, ConflictChoice, ConflictPolicy, DownloadEngineFetcher, FileAction, ImportProgress,
ImportReport, ImportRequest, ImportStatus,
};
use unlucky_core::download::blocking::block_on;
use unlucky_core::download::cache::now_ms;
use unlucky_core::download::region::{decide_from, load_cached, store_cached};
use unlucky_core::download::region_system::detect_signals;
use unlucky_core::download::{
ContentCache, DownloadEngine, EngineConfig, HealthRegistry, MirrorTable,
};
use crate::cli::PackImportArgs;
use crate::context::Ctx;
use crate::error::CliError;
use crate::output::{Outcome, Report};
/// `ull pack import`。
///
/// # Errors
///
/// 整合包路径不存在(用户错误)。导入本身的失败**不是** `Err`:它走三态报告 + 退出码。
pub fn run(ctx: &Ctx, args: &PackImportArgs) -> Result<Outcome, CliError> {
if !args.path.exists() {
return Err(CliError::user(
"not_found",
format!("整合包不存在:{}", args.path.display()),
)
.with_hint("给 .zip / .mrpack 文件,或 MultiMC·Prism 的实例目录"));
}
let target = args
.target
.clone()
.unwrap_or_else(|| ctx.game().root().to_path_buf());
let stages: Arc<Mutex<Vec<&'static str>>> = Arc::new(Mutex::new(Vec::new()));
let recorder = Arc::clone(&stages);
let sink: unlucky_core::addon::ImportProgressSink =
Arc::new(move |progress: &ImportProgress| {
if let Ok(mut seen) = recorder.lock() {
if seen.last().copied() != Some(progress.stage.as_str()) {
seen.push(progress.stage.as_str());
}
}
});
let engine = build_engine(ctx);
let fetcher = DownloadEngineFetcher::new(&engine);
let layout = ctx.layout();
let mut request = ImportRequest::new(&args.path, &target)
.with_fetcher(&fetcher)
.with_progress(sink);
if args.overwrite {
request = request.with_conflict_policy(ConflictPolicy::Overwrite);
}
if !args.no_register {
request = request.with_registration(&layout, args.name.as_deref());
}
let report = block_on(import_pack(&request));
let seen = stages.lock().map(|seen| seen.clone()).unwrap_or_default();
Ok(outcome(ctx, &report, &target, &seen, args))
}
/// 组装 `Outcome`(三态 → 退出码 + 两种输出)。
fn outcome(
ctx: &Ctx,
report: &ImportReport,
target: &Path,
stages: &[&'static str],
args: &PackImportArgs,
) -> Outcome {
let mut data = serde_json::to_value(report).unwrap_or_else(|_| {
json!({
"status": report.status.as_str(),
"error": {"code": "serialize", "message": "报告序列化失败"},
})
});
if let Some(object) = data.as_object_mut() {
object.insert("target".to_string(), json!(target.display().to_string()));
object.insert("stages".to_string(), json!(stages));
object.insert("status_key".to_string(), json!(report.status.message_key()));
object.insert("installed_files".to_string(), json!(report.installed()));
object.insert(
"target_is_default".to_string(),
json!(args.target.is_none()),
);
}
let mut human: Vec<String> = Vec::new();
human.push(format!(
"整合包 {}({})→ {}{}",
report.pack_name.as_deref().unwrap_or("<未读成>"),
report.format.as_deref().unwrap_or("-"),
target.display(),
if args.target.is_none() {
"(缺省 = 数据根的共享游戏目录)"
} else {
""
}
));
if !stages.is_empty() {
human.push(format!("阶段:{}", stages.join(" → ")));
}
human.push(format!(
"结果:{} 文件 新{} / 覆盖{} / 跳过{} / 保留{} 冲突 {} 缺文件 {}",
status_label(report.status),
count_action(report, FileAction::Installed),
count_action(report, FileAction::Replaced),
count_action(report, FileAction::SkippedIdentical),
count_action(report, FileAction::KeptUser),
report.conflicts.len(),
report.missing.len(),
));
if let Some(loader) = &report.loader {
human.push(format!(
"加载器:{} {}(版本 id {})",
loader.kind.label(),
loader.loader_version,
loader.version_id
));
if let Some(version_id) = &report.version_id {
if !ctx.game().is_version_installed(version_id) {
human.push(format!(
"提示:{version_id} 还没安装,启动前先跑 `%prog% install` 装它"
));
}
}
}
if let Some(memory) = report.max_memory_mb {
human.push(format!("内存:{memory} MB(来自 instance.cfg)"));
}
for component in &report.rejected_components {
human.push(format!(
"未支持的 component:{} {}({})",
component.uid,
component.version.as_deref().unwrap_or("-"),
component.message_key
));
}
for conflict in &report.conflicts {
human.push(format!(
"冲突:{}({},已{})",
conflict.path,
conflict.message_key,
match conflict.choice {
ConflictChoice::KeepUser => "保留你的文件",
ConflictChoice::Overwrite => "按包覆盖",
}
));
}
for missing in &report.missing {
human.push(format!(
"缺文件:{}({}:{})",
missing.display, missing.message_key, missing.detail
));
}
if let Some(registered) = &report.registered {
human.push(format!(
"已登记实例 {}({}){}",
registered.name,
registered.version_id,
if registered.created {
""
} else {
"·复用已有档案"
}
));
}
if let Some(error) = &report.error {
human.push(format!("错误:{}({})", error.message(), error.code()));
if let Some(hint) = error.hint() {
human.push(format!("提示:{hint}"));
}
if report.rolled_back {
human.push("已整包回滚:目标目录与导入前一致".to_string());
}
}
Outcome::new(Report { data, human }).with_exit_code(report.status.exit_code())
}
fn status_label(status: ImportStatus) -> &'static str {
match status {
ImportStatus::Success => "成功",
ImportStatus::Partial => "部分成功",
ImportStatus::Failed => "失败(已回滚)",
}
}
fn count_action(report: &ImportReport, action: FileAction) -> usize {
report
.files
.iter()
.filter(|file| file.action == action)
.count()
}
/// 与 `install` 共用同一套引擎构造(镜像 / 地区 / 健康度 / 内容缓存)。
fn build_engine(ctx: &Ctx) -> DownloadEngine {
let layout = ctx.layout();
let cache_root = layout.cache();
let now = now_ms();
let region_path = cache_root.join("region.json");
let region = load_cached(&region_path, now).unwrap_or_else(|| {
let profile = decide_from(&detect_signals(), now);
let _ = store_cached(&region_path, &profile);
profile
});
DownloadEngine::new(
EngineConfig::default(),
Arc::new(MirrorTable::builtin()),
Arc::new(region),
Arc::new(HealthRegistry::open_in(&cache_root, now)),
Arc::new(ContentCache::open(&cache_root)),
)
}
+16
View File
@@ -37,6 +37,13 @@ pub struct Outcome {
pub report: Report,
/// 环境级提示(人类模式进 stderr,JSON 模式进信封的 `warnings`)。
pub warnings: Vec<String>,
/// 命令自定义的退出码;`None` = 0(绝大多数命令)。
///
/// 只有"结果多态且脚本要分流"的命令才用得上:`ull pack import` 的三态是
/// `0` 成功 / `3` 部分成功 / `1` 失败,光靠 0/1 分不出来。加这个字段而不是让命令
/// 自己 `std::process::exit`,是为了让**输出照常刷新**(绕过 runner 的输出刷新会把
/// "输出没刷完就退出"这种 bug 装进 CLI)。
pub exit_code: Option<i32>,
}
impl Outcome {
@@ -46,6 +53,7 @@ impl Outcome {
Self {
report,
warnings: Vec::new(),
exit_code: None,
}
}
@@ -58,8 +66,16 @@ impl Outcome {
.into_iter()
.map(|line| fill_program(&line))
.collect(),
exit_code: None,
}
}
/// 指定进程退出码(不指定 = 0)。
#[must_use]
pub fn with_exit_code(mut self, code: i32) -> Self {
self.exit_code = Some(code);
self
}
}
/// 打印成功结果。
+3 -1
View File
@@ -44,7 +44,9 @@ pub fn run(args: &[String], out: &mut dyn Write, err: &mut dyn Write) -> i32 {
let _ = writeln!(err, "错误: 写输出失败:{write_err}");
return crate::error::EXIT_INTERNAL;
}
EXIT_OK
// 命令可以自己指定退出码(`ull pack import` 的三态:0 / 3 / 1);
// 没指定就是 0——其它命令的行为不变。
outcome.exit_code.unwrap_or(EXIT_OK)
}
Err(error) => {
let _ = output::print_error(&error, json, out, err);
+199
View File
@@ -0,0 +1,199 @@
//! `ull pack import` 的三态契约:退出码 0 / 3 / 1 + `--json` 信封恒有结构。
//!
//! **全部离线、全部不依赖 zip**:
//!
//! - MultiMC / Prism 的真实形态就是**目录**,直接拷 → `0`;
//! - mrpack 也能以**目录**打开(`PackArchive` 对目录与 zip 一视同仁),造一条 `downloads` 为空的
//! `files[]` 条目 ⇒ 补不出来 ⇒ `3`,不会发任何请求;
//! - 坏包用**一个不是 zip 的普通文件** ⇒ 开包即拒 ⇒ `1`(具体的恶意样本分类在 core 的
//! `addon/tests/import_failures.rs`,这里只钉 CLI 的退出码与 JSON 契约)。
#[path = "support/mod.rs"]
mod support;
use std::path::{Path, PathBuf};
use serde_json::Value;
use support::{run_cli, TempDir};
/// 写一个文件(自动建父目录)。
fn write_file(path: &Path, bytes: &[u8]) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("建目录");
}
std::fs::write(path, bytes).expect("写文件");
}
/// 造一个最小 MultiMC / Prism 实例目录。
fn multimc_instance(root: &Path) -> PathBuf {
let instance = root.join("mmc");
write_file(
&instance.join("mmc-pack.json"),
br#"{"formatVersion": 1, "components": [
{"uid": "net.minecraft", "version": "1.20.1"},
{"uid": "net.fabricmc.fabric-loader", "version": "0.15.11"},
{"uid": "org.lwjgl3", "version": "3.3.2"}]}"#,
);
write_file(
&instance.join("instance.cfg"),
b"InstanceType=OneSix\nname=cli pack\n",
);
write_file(&instance.join(".minecraft/mods/a.jar"), b"jar bytes");
instance
}
/// 造一个目录形式的 mrpack:一条远端条目,但 `downloads` 为空(补不出来 ⇒ 缺文件)。
fn mrpack_directory(root: &Path) -> PathBuf {
let source = root.join("mr-src");
write_file(
&source.join("modrinth.index.json"),
br#"{"game": "minecraft", "formatVersion": 1, "versionId": "1.0.0",
"name": "cli mrpack", "summary": "dir-form mrpack",
"files": [{"path": "mods/ghost.jar",
"hashes": {"sha1": "0123456789abcdef0123456789abcdef01234567",
"sha512": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"},
"env": {"client": "required", "server": "required"},
"downloads": [], "fileSize": 11}],
"dependencies": {"minecraft": "1.20.1", "fabric-loader": "0.15.11"}}"#,
);
write_file(&source.join("overrides/config/known.toml"), b"k = 1\n");
source
}
fn parse_json(stdout: &str) -> Value {
serde_json::from_str(stdout)
.unwrap_or_else(|error| panic!("stdout 不是合法 JSON({error}):{stdout}"))
}
#[test]
fn imports_a_multimc_instance_and_exits_zero() {
let data = TempDir::new("pack-ok");
let source = multimc_instance(data.path());
let target = data.path().join("inst");
let (code, stdout, stderr) = run_cli(&[
"pack",
"import",
&source.display().to_string(),
"--target",
&target.display().to_string(),
"--data-dir",
&data.arg(),
"--no-register",
"--json",
]);
assert_eq!(code, 0, "stdout={stdout} stderr={stderr}");
let value = parse_json(&stdout);
assert_eq!(value["ok"], Value::Bool(true));
assert_eq!(value["data"]["status"], Value::String("success".into()));
assert_eq!(value["data"]["format"], Value::String("multimc".into()));
assert_eq!(
value["data"]["loader"]["kind"],
Value::String("fabric".into())
);
// 走过的阶段都记下来了。
let stages = value["data"]["stages"].as_array().expect("stages 数组");
assert!(stages.iter().any(|stage| stage == "probe"), "{stages:?}");
assert!(stages.iter().any(|stage| stage == "commit"), "{stages:?}");
// 内容真的落地了。
assert_eq!(
std::fs::read(target.join("mods/a.jar")).expect("a.jar"),
b"jar bytes"
);
// 未支持的 component 逐项进 JSON。
assert_eq!(
value["data"]["rejected_components"][0]["uid"],
Value::String("org.lwjgl3".into())
);
}
#[test]
fn missing_files_make_it_partial_and_exit_three() {
let data = TempDir::new("pack-partial");
let source = mrpack_directory(data.path());
let target = data.path().join("inst");
let (code, stdout, stderr) = run_cli(&[
"pack",
"import",
&source.display().to_string(),
"--target",
&target.display().to_string(),
"--data-dir",
&data.arg(),
"--no-register",
"--json",
]);
assert_eq!(
code, 3,
"部分成功是 3,不是 0/1:stdout={stdout} stderr={stderr}"
);
let value = parse_json(&stdout);
assert_eq!(value["data"]["status"], Value::String("partial".into()));
assert_eq!(
value["data"]["missing"].as_array().map(Vec::len),
Some(1),
"补不出来的条目要如实记缺:{stdout}"
);
// 覆盖内容照装。
assert!(target.join("config/known.toml").is_file());
}
#[test]
fn failed_import_exits_one_and_still_emits_structured_json() {
let data = TempDir::new("pack-fail");
let pack = data.path().join("broken.zip");
// 一个根本不是 zip 的文件:开包即拒。
write_file(&pack, b"this is not a zip archive");
let target = data.path().join("inst");
let (code, stdout, stderr) = run_cli(&[
"pack",
"import",
&pack.display().to_string(),
"--target",
&target.display().to_string(),
"--data-dir",
&data.arg(),
"--no-register",
"--json",
]);
assert_eq!(code, 1, "stdout={stdout} stderr={stderr}");
// 失败也**必须**是结构化 JSON(不抛栈、不打人类可读文本到 stdout)。
let value = parse_json(&stdout);
assert_eq!(value["ok"], Value::Bool(true));
assert_eq!(value["data"]["status"], Value::String("failed".into()));
assert!(
value["data"]["error"]["code"].is_string(),
"失败要带稳定码:{stdout}"
);
assert_eq!(value["data"]["rolled_back"], Value::Bool(true));
// 零残留。
assert!(!target.exists(), "致命失败后目标目录必须不存在");
// 失败也不是"崩了":stderr 里不该出现 panic 之类的栈信息。
assert!(!stderr.contains("panicked"), "{stderr}");
}
#[test]
fn other_commands_still_exit_zero() {
// `Outcome::exit_code` 是给 pack import 用的;其它命令的行为不许变。
let data = TempDir::new("pack-regression");
let (code, stdout, _) = run_cli(&["instances", "list", "--data-dir", &data.arg()]);
assert_eq!(code, 0, "{stdout}");
let (code, _, _) = run_cli(&["--help"]);
assert_eq!(code, 0);
let (code, _, _) = run_cli(&["config", "get", "--data-dir", &data.arg()]);
assert_eq!(code, 0);
}
#[test]
fn help_lists_the_pack_command() {
let (code, stdout, _) = run_cli(&["help", "pack"]);
assert_eq!(code, 0);
assert!(stdout.contains("pack import"), "{stdout}");
assert!(stdout.contains("部分成功"), "{stdout}");
let (code, stdout, _) = run_cli(&["--help"]);
assert_eq!(code, 0);
assert!(stdout.contains("pack import"), "总帮助里也要有:{stdout}");
}
+179
View File
@@ -0,0 +1,179 @@
//! 整合包**导入**(M111):CurseForge `manifest.json` / Modrinth `.mrpack` / MultiMC·Prism 实例
//! → 一个可启动的实例目录。
//!
//! 设计依据:`DevDocs/PlanDoc/08-mod-and-recommend.md` §2(D10)、
//! `DevDocs/PlanDoc/research/18-modpack-formats*.md`(字段清单,A/B 级来源)、
//! `DevDocs/SubAgentsTalk/addon-整合包导入导出对接要点.md`(施工口径)。
//! 格式模型与安全闸是 M110 的 [`crate::addon::pack`]:本模块**只消费,不重写**。
//!
//! # 三条硬要求落在哪
//!
//! | 要求 | 落点 |
//! |------|------|
//! | overrides 哈希**三方合并**(不静默覆盖用户改动) | [`merge`] 的判据 + [`baseline`] 的 base 侧 |
//! | 两阶段补全 + **失败分级**(仅"缺文件"可容忍) | [`pipeline`] 的顺序 + [`report::MissingFile`] 与 [`error::ImportError`] 的分工 |
//! | 路径穿越**双检** | 开包时 M110 [`crate::addon::pack::PackArchive::open`],落地时 [`stage`] 的 `normalize_file` + `starts_with` 复核 |
//!
//! # 三种格式的语义(tower 定案,与 HMCL 相反的两条要记住)
//!
//! - **mrpack**:`overrides/` 与 `client-overrides/` 都解压,**后者胜**(Modrinth 官方客户端语义;
//! HMCL 恰好相反);`server-overrides/` 客户端**不解压**;其余同名路径无官方优先级 ⇒ 按侧
//! 优先级落地,不自创排序。
//! - **CF**:溢出目录名取自清单 `overrides` 字段;`files[]` 只有 `projectID/fileID`,要 CF API
//! 才能补出文件名/地址——仓库里还没有 CF 客户端(D10 §5),因此这部分条目的**当前正确行为**
//! 是记成 `missing`(`Partial`),**绝不猜 `edge.forgecdn.net` 那类 URL**。
//! - **MultiMC / Prism**:`mmc-pack.json` 的 `formatVersion` 必须恰为 `1`(M110 的读者会拒绝
//! 其余值);`.minecraft/` 直拷;非加载器 component(`org.lwjgl3` 等)**逐项**进
//! [`report::RejectedComponent`],不静默丢;只有导入,没有导出。
//!
//! # `.disabled` 只按路径后缀
//!
//! `x.jar` / `x.jar.disabled` / `x.jar.old` / `x.jar.disabled.old` 四种变体都识别,后缀**原样
//! 保留**;`.old` 只识别与保留(不清除、不改名、不当回滚语义)。`env.client=optional` **不是**
//! 禁用——"可选"的语义是"安装时可取消勾选",装回来默认仍然启用,两者不是一回事。
//!
//! # 用法
//!
//! ```ignore
//! let engine = /* core::download::DownloadEngine */;
//! let fetcher = DownloadEngineFetcher::new(&engine);
//! let request = ImportRequest::new(pack_path, instance_dir)
//! .with_fetcher(&fetcher)
//! .with_registration(&launcher_layout, None);
//! let report = download::blocking::block_on(import_pack(&request));
//! match report.status { /* Success / Partial / Failed */ }
//! ```
//!
//! # 刻意不做的事(分工边界)
//!
//! - **不删除**整合包升级后不再包含的文件:磁盘上的 `mods/` 里可能有用户自己装的模组,
//! 删旧文件是"清理"而不是"导入"(要做得先有"这个文件是整合包装的"的完整账本 + 用户确认);
//! - **不装加载器**:只把清单翻译成 [`report::LoaderTarget`](`kind` + 原版版本 + 加载器版本
//! + 版本 id),真正的元数据解析/库下载仍是 `addon::loader` + `game::install` 的既有路径;
//! - **不碰 UI 类型**:core 零 UI 依赖(`core-no-ui` 门禁),报告只出现 `String` / `&'static str`;
//! - **不绕过下载引擎**:所有网络只经 [`fetch::PackFetcher`],生产实现只有
//! [`fetch::DownloadEngineFetcher`](走 `core::download`)。
//!
//! # 没验的部分(真实世界的欠账,报告里同样登记)
//!
//! 真实 CF / MultiMC 导出物(夹具是手造的)、超大包(>2G)、Windows 之外的平台、断网中途、
//! 磁盘满、真实 CurseForge `files[]` 补全(缺 CF 客户端,见 [`fetch`] 的说明)。
pub mod baseline;
pub mod error;
pub mod fetch;
pub mod merge;
pub mod phase;
pub mod pipeline;
pub mod plan;
pub mod report;
pub mod stage;
pub use baseline::PackBaseline;
pub use error::{ImportError, ImportErrorKind};
pub use fetch::{
CurseFileMeta, DownloadEngineFetcher, FetchError, FetchErrorKind, FetchFuture, FetchRequest,
FetchedFile, PackFetcher,
};
pub use merge::{MergeAction, MergeDecision};
pub use pipeline::baseline_relative;
pub use plan::{ImportPlan, PlanOverride, PlanRemote, SuffixVariant};
pub use report::{
keys, ConflictChoice, ConflictFile, ConflictReason, FileAction, FileSource, ImportIssue,
ImportProgress, ImportProgressSink, ImportReport, ImportStage, ImportStatus, ImportedFile,
LoaderTarget, MissingFile, MissingReason, RegisteredInstance, RejectedComponent,
};
pub use stage::Staging;
use std::path::Path;
use crate::addon::pack::PackLimits;
use crate::platform::layout::LauncherLayout;
/// 冲突处置策略(默认保留用户文件)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ConflictPolicy {
/// 保留用户那一侧(不覆盖、不补回被删的文件),冲突逐条进报告。
#[default]
KeepUser,
/// 用包内内容覆盖(用户明确要求"整合包说了算")。
Overwrite,
}
/// 一次导入请求。
pub struct ImportRequest<'a> {
/// 包路径:zip(`.zip` / `.mrpack`)或 MultiMC 实例目录。
pub pack: &'a Path,
/// 目标实例的游戏目录(`mods/` 直接在其下)。
pub target: &'a Path,
/// 远端补全 + 下载;`None` = 离线导入(远端条目全部记成 `missing`)。
pub fetcher: Option<&'a dyn PackFetcher>,
/// 数据根布局;给了才登记实例(`profiles.json`)。
pub register: Option<&'a LauncherLayout>,
/// 实例展示名(缺省用包名)。
pub instance_name: Option<&'a str>,
/// 冲突策略。
pub conflict_policy: ConflictPolicy,
/// 包读取限额(条数 / 单条体积 / 解压总量)。
pub limits: PackLimits,
/// 进度回调。
pub progress: Option<ImportProgressSink>,
}
impl<'a> ImportRequest<'a> {
/// 最小请求(离线、不登记、保留用户文件、默认限额)。
#[must_use]
pub fn new(pack: &'a Path, target: &'a Path) -> Self {
Self {
pack,
target,
fetcher: None,
register: None,
instance_name: None,
conflict_policy: ConflictPolicy::KeepUser,
limits: PackLimits::default(),
progress: None,
}
}
/// 挂上远端补全 / 下载。
#[must_use]
pub fn with_fetcher(mut self, fetcher: &'a dyn PackFetcher) -> Self {
self.fetcher = Some(fetcher);
self
}
/// 挂上实例登记(`name` 为 `None` 时用包名)。
#[must_use]
pub fn with_registration(mut self, layout: &'a LauncherLayout, name: Option<&'a str>) -> Self {
self.register = Some(layout);
self.instance_name = name;
self
}
/// 改冲突策略。
#[must_use]
pub fn with_conflict_policy(mut self, policy: ConflictPolicy) -> Self {
self.conflict_policy = policy;
self
}
/// 改读取限额。
#[must_use]
pub fn with_limits(mut self, limits: PackLimits) -> Self {
self.limits = limits;
self
}
/// 挂进度回调。
#[must_use]
pub fn with_progress(mut self, sink: ImportProgressSink) -> Self {
self.progress = Some(sink);
self
}
}
/// 导入一个整合包。**永不返回 `Err`**:三态与逐项原因都在 [`ImportReport`] 里。
pub async fn import_pack(request: &ImportRequest<'_>) -> ImportReport {
pipeline::run(request).await
}
+132
View File
@@ -0,0 +1,132 @@
//! 三方合并的 **base 侧**:上次导入写下的"包给的哈希"。
//!
//! # 为什么必须落盘
//!
//! 三方合并的三个输入是:`theirs`(这次包里给的)、`ours`(磁盘现状)、`base`(**上次**包里
//! 给的)。`base` 不能从这次的包现算——那是 `theirs`;也不能从磁盘现算——那是 `ours`。少了
//! `base` 就分不清两种截然不同的事实:
//!
//! - 磁盘内容 ≠ 新包内容,且磁盘内容 == 上次包内容 ⇒ **整合包升级**,该覆盖(用户没动过);
//! - 磁盘内容 ≠ 新包内容,且磁盘内容 ≠ 上次包内容 ⇒ **用户改过**,不许静默覆盖。
//!
//! 所以第一次导入就在实例根写一份基线;没有基线时(首次导入 / 用户手动塞进来的文件)一律按
//! "磁盘上已有同名文件"处理 —— 不覆盖、进冲突清单(最安全的一侧)。
//!
//! # 落点与形状
//!
//! `<实例根>/.ULL/pack-baseline.json`(沿用 D20 的 `.ULL/` 分区,不往 mods/ 里掺文件):
//!
//! ```jsonc
//! {"schemaVersion": 1, "packName": "…", "format": "modrinth",
//! "files": {"mods/jei.jar": "<包的 sha1>", "mods/xxx.jar.disabled": "<包的 sha1>"}}
//! ```
//!
//! 基线记的是**包声明的哈希**(不是磁盘实际哈希):用户改过的文件在基线里仍是包的值,下次
//! 导入才会继续判成"用户改过",而不会被"上次也是改动值"洗白。`.disabled` / `.old` 后缀按
//! 原样进键名(后缀是路径的一部分,不附加语义)。
//!
//! 读失败**不是**致命错误:基线坏了最多退化成"全部按 pre-existing 冲突处理"(保留用户文件),
//! 比"因为读不出基线就整包回滚"合理。
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::game::layout::GameLayout;
/// 基线文件名。
pub const BASELINE_FILE: &str = "pack-baseline.json";
/// 基线 schema 版本。
pub const BASELINE_SCHEMA: u32 = 1;
/// 上次导入写下的包声明哈希表。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PackBaseline {
/// schema 版本(迁移链入口)。
#[serde(default = "default_schema")]
pub schema_version: u32,
/// 上次导入的包名(诊断用)。
#[serde(default)]
pub pack_name: String,
/// 上次导入命中的格式 id。
#[serde(default)]
pub format: String,
/// 实例内相对路径 → 包声明的 SHA-1。
#[serde(default)]
pub files: BTreeMap<String, String>,
}
const fn default_schema() -> u32 {
BASELINE_SCHEMA
}
impl Default for PackBaseline {
fn default() -> Self {
Self {
schema_version: BASELINE_SCHEMA,
pack_name: String::new(),
format: String::new(),
files: BTreeMap::new(),
}
}
}
impl PackBaseline {
/// 实例根下的基线路径(`<root>/.ULL/pack-baseline.json`)。
#[must_use]
pub fn path(game_root: &Path) -> PathBuf {
GameLayout::new(game_root).ull_root().join(BASELINE_FILE)
}
/// 读基线;文件不存在返回空表(**不是错误**),形状不符返回 `Err`。
///
/// # Errors
///
/// 非 UTF-8 / JSON 非法 / 版本号比本进程新(未来版本写的)时返回人读原因。
pub fn load(game_root: &Path) -> Result<Self, String> {
let path = Self::path(game_root);
let bytes = match std::fs::read(&path) {
Ok(bytes) => bytes,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Ok(Self::default())
}
Err(error) => {
return Err(format!("读 {} 失败:{error}", path.display()));
}
};
let text = String::from_utf8(bytes)
.map_err(|error| format!("{} 不是 UTF-8:{error}", path.display()))?;
let baseline: Self = serde_json::from_str(&text)
.map_err(|error| format!("{} 形状不符:{error}", path.display()))?;
if baseline.schema_version > BASELINE_SCHEMA {
return Err(format!(
"{} 的 schemaVersion={} 比本进程({BASELINE_SCHEMA})新",
path.display(),
baseline.schema_version
));
}
Ok(baseline)
}
/// 查上次给这个路径的哈希。
#[must_use]
pub fn sha1(&self, path: &str) -> Option<&str> {
self.files.get(path).map(String::as_str)
}
/// 记一条。
pub fn insert(&mut self, path: impl Into<String>, sha1: impl Into<String>) {
self.files.insert(path.into(), sha1.into());
}
/// 序列化成落盘字节(pretty:这文件是给人排障看的)。
///
/// # Errors
///
/// 序列化失败(纯理论,`BTreeMap<String, String>` 不会失败)。
pub fn to_bytes(&self) -> Result<Vec<u8>, String> {
serde_json::to_vec_pretty(self).map_err(|error| format!("基线序列化失败:{error}"))
}
}
+204
View File
@@ -0,0 +1,204 @@
//! 导入链路的**致命**失败分类(能容忍的缺文件不在这里,它们在
//! [`super::report::MissingFile`] 里逐条记账)。
//!
//! # 为什么要跟 `MissingFile` 分开
//!
//! D10 §2 的失败分级只有一条判据:**这个错误是不是"某个文件搞不到"**。
//!
//! | 情形 | 处置 | 落在哪 |
//! |------|------|--------|
//! | 某个远端文件下载失败 / 补全不出地址 / 没有网络 | 容忍,实例照装,状态降级 `Partial` | [`super::report::MissingFile`] |
//! | 路径穿越 / 符号链接 / 超限额 / 清单坏 / 本地 IO / 摘要不符 | **整包回滚**,目标目录零残留 | 本模块 |
//!
//! 因此 [`ImportError`] 只描述**必须回滚**的失败;能容忍的东西一旦也塞进来,调用方就再也
//! 分不清"该重试"和"该报恶意包"了。
//!
//! # 稳定码
//!
//! 包相关六类**直接沿用 M110 的码**(`pack_unsupported` / `pack_metadata` /
//! `pack_traversal` / `pack_symlink` / `pack_limits` / `pack_io`),不另起一套同义词:
//! 同一个事实在 CLI、日志、事件流里只能有一个字符串。导入独有的三类加 `import_` 前缀。
use std::fmt;
use super::super::pack::{PackError, PackErrorKind};
use super::fetch::{FetchError, FetchErrorKind};
/// 导入失败大类(稳定码进 `--json` 的 `error.code` 与日志,**不要改名**)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ImportErrorKind {
/// 透传 M110 的六类包错误(稳定码沿用 `pack_*`)。
Pack(PackErrorKind),
/// 下载到的内容与清单声明的摘要/大小**不符**(`import_integrity`)。
Integrity,
/// 远端补全/下载在**致命**情形下失败(`import_fetch`;本地 IO 归 `pack_io`)。
Fetch,
/// 清单里的加载器信息不足以组出安装计划(`import_loader`)。
Loader,
/// 实例登记失败(`import_instance`)。
Instance,
}
impl ImportErrorKind {
/// 稳定字符串 id(进 `--json` 与日志)。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Pack(kind) => kind.as_str(),
Self::Integrity => "import_integrity",
Self::Fetch => "import_fetch",
Self::Loader => "import_loader",
Self::Instance => "import_instance",
}
}
/// 人读大类名(报告表格用)。
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::Pack(kind) => pack_label(kind),
Self::Integrity => "内容与清单不符",
Self::Fetch => "远端补全失败",
Self::Loader => "加载器信息不足",
Self::Instance => "实例登记失败",
}
}
}
/// M110 六个大类的人读名(与 `PackErrorKind` 的稳定码一一对应,不另起一套枚举)。
const fn pack_label(kind: PackErrorKind) -> &'static str {
match kind {
PackErrorKind::Unsupported => "格式不支持",
PackErrorKind::Metadata => "清单损坏",
PackErrorKind::Traversal => "路径穿越",
PackErrorKind::Symlink => "符号链接条目",
PackErrorKind::Limits => "超出安全限额",
PackErrorKind::Io => "本地 IO 失败",
}
}
/// 一次**必须回滚**的导入失败。
#[derive(Debug, Clone)]
pub struct ImportError {
kind: ImportErrorKind,
message: String,
hint: Option<String>,
}
impl ImportError {
/// 构造。
#[must_use]
pub fn new(kind: ImportErrorKind, message: impl Into<String>) -> Self {
Self {
kind,
message: message.into(),
hint: None,
}
}
/// 内容与清单声明不符。
#[must_use]
pub fn integrity(message: impl Into<String>) -> Self {
Self::new(ImportErrorKind::Integrity, message)
}
/// 远端补全/下载的致命失败。
#[must_use]
pub fn fetch(message: impl Into<String>) -> Self {
Self::new(ImportErrorKind::Fetch, message)
}
/// 加载器信息不足。
#[must_use]
pub fn loader(message: impl Into<String>) -> Self {
Self::new(ImportErrorKind::Loader, message)
}
/// 实例登记失败。
#[must_use]
pub fn instance(message: impl Into<String>) -> Self {
Self::new(ImportErrorKind::Instance, message)
}
/// 补一句提示。
#[must_use]
pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
self.hint = Some(hint.into());
self
}
/// 大类。
#[must_use]
pub const fn kind(&self) -> ImportErrorKind {
self.kind
}
/// 稳定码。
#[must_use]
pub const fn code(&self) -> &'static str {
self.kind.as_str()
}
/// 人读说明。
#[must_use]
pub fn message(&self) -> &str {
&self.message
}
/// 下一步提示。
#[must_use]
pub fn hint(&self) -> Option<&str> {
self.hint.as_deref()
}
}
impl fmt::Display for ImportError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.message)
}
}
impl std::error::Error for ImportError {}
impl From<PackError> for ImportError {
fn from(error: PackError) -> Self {
Self {
kind: ImportErrorKind::Pack(error.kind()),
message: error.message().to_string(),
hint: error.hint().map(str::to_string),
}
}
}
impl From<FetchError> for ImportError {
fn from(error: FetchError) -> Self {
// 分级判据只有一处:`Integrity` 与 `Io` 必须回滚,其余三类是"搞不到"。
let kind = match error.kind() {
FetchErrorKind::Integrity => ImportErrorKind::Integrity,
FetchErrorKind::Io => ImportErrorKind::Pack(PackErrorKind::Io),
FetchErrorKind::NotFound | FetchErrorKind::Network | FetchErrorKind::Unsupported => {
ImportErrorKind::Fetch
}
};
Self {
kind,
message: error.message().to_string(),
hint: None,
}
}
}
impl serde::Serialize for ImportError {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
use serde::ser::SerializeMap;
let mut map = serializer.serialize_map(None)?;
map.serialize_entry("code", self.code())?;
map.serialize_entry("kind", self.kind.label())?;
map.serialize_entry("message", &self.message)?;
if let Some(hint) = &self.hint {
map.serialize_entry("hint", hint)?;
}
map.end()
}
}
+234
View File
@@ -0,0 +1,234 @@
//! 远端补全与下载的**注入点**:导入管线自己不碰网络,只认这个 trait。
//!
//! # 为什么是 trait 而不是直接调 `DownloadEngine`
//!
//! 三条理由,每条都对应一个真实麻烦:
//!
//! 1. **测试不能联网**:三种格式的端到端用例必须离线跑(CI 没有网,也不该有);
//! 2. **CurseForge 的 `files[]` 补全还没有客户端**:CF 官方清单只有 `projectID/fileID`,
//! 要 `x-api-key` 的 `api.curseforge.com/v1/mods/<id>/files/<fid>` 才能换出文件名与地址。
//! 仓库里没有这个客户端(D10 §5 规划中),而 [`crate::download::DownloadRequest`] **没有
//! 自定义 header 字段**——硬塞进去会把"补不出来"伪装成"网络失败"。所以默认实现诚实地
//! 返回 [`FetchErrorKind::Unsupported`](= 可容忍的"搞不到",进报告的 `missing`),
//! **绝不猜下载 URL**:猜出来的地址一旦 404,用户看到的是"导入失败"而不是"这条暂时补不了";
//! 3. **依赖方向**:`addon` 属于更高层的领域逻辑,让它直接持有一个 `DownloadEngine` 会把
//! 镜像/健康度/地区档案这些下载侧策略涌进导入链路。
//!
//! # 生产实现只走 `core::download`
//!
//! [`DownloadEngineFetcher`] 是本 crate 里**唯一**的生产实现,它只做一件事:把
//! [`FetchRequest`] 翻译成 [`crate::download::DownloadRequest`] 交给引擎(镜像展开、退避、
//! 续传、边下边算都在引擎里)。导入链路里**没有**第二个 `reqwest::Client`。
use std::future::Future;
use std::path::Path;
use std::pin::Pin;
use crate::download::{
DownloadEngine, DownloadError, DownloadErrorKind, DownloadRequest, Integrity, ResourceKind,
};
/// "搞不到"的成因(**分级判据只在这里**)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FetchErrorKind {
/// 源上确实没有(404 / 文件被删)。
NotFound,
/// 网络侧失败(超时、连不上、所有候选源都失败)。
Network,
/// 本地 IO(写盘失败、磁盘满)。
Io,
/// 内容与声明的摘要不符。
Integrity,
/// 这条路走不通(CF 无客户端 / 没有可用下载地址)。
Unsupported,
}
impl FetchErrorKind {
/// 稳定字符串 id。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::NotFound => "fetch_not_found",
Self::Network => "fetch_network",
Self::Io => "fetch_io",
Self::Integrity => "fetch_integrity",
Self::Unsupported => "fetch_unsupported",
}
}
/// 是否属于"某个文件搞不到"(可容忍,实例照装)。
#[must_use]
pub const fn is_tolerable(self) -> bool {
matches!(self, Self::NotFound | Self::Network | Self::Unsupported)
}
}
/// 一次取数失败。
#[derive(Debug, Clone)]
pub struct FetchError {
kind: FetchErrorKind,
message: String,
}
impl FetchError {
/// 构造。
#[must_use]
pub fn new(kind: FetchErrorKind, message: impl Into<String>) -> Self {
Self {
kind,
message: message.into(),
}
}
/// 这条路走不通。
#[must_use]
pub fn unsupported(message: impl Into<String>) -> Self {
Self::new(FetchErrorKind::Unsupported, message)
}
/// 内容与声明不符。
#[must_use]
pub fn integrity(message: impl Into<String>) -> Self {
Self::new(FetchErrorKind::Integrity, message)
}
/// 大类。
#[must_use]
pub const fn kind(&self) -> FetchErrorKind {
self.kind
}
/// 人读说明。
#[must_use]
pub fn message(&self) -> &str {
&self.message
}
}
impl std::fmt::Display for FetchError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(&self.message)
}
}
impl std::error::Error for FetchError {}
impl From<DownloadError> for FetchError {
fn from(error: DownloadError) -> Self {
let kind = match error.kind {
DownloadErrorKind::NotFound => FetchErrorKind::NotFound,
DownloadErrorKind::Integrity => FetchErrorKind::Integrity,
DownloadErrorKind::Io => FetchErrorKind::Io,
DownloadErrorKind::InvalidRequest => FetchErrorKind::Unsupported,
DownloadErrorKind::ServerError
| DownloadErrorKind::Timeout
| DownloadErrorKind::Network
| DownloadErrorKind::RangeUnsupported
| DownloadErrorKind::AllSourcesFailed => FetchErrorKind::Network,
};
Self::new(kind, error.message)
}
}
/// trait 方法返回的装箱 future(core 不引 `async-trait`,手写 `Pin<Box<dyn Future>>` 即可)。
pub type FetchFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
/// CurseForge 一个文件的元数据(`files[]` 补全的产物)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CurseFileMeta {
/// 文件名(`jei-1.20.1-15.2.0.27.jar`)。
pub file_name: String,
/// 下载地址;`None` = 作者关了 API 分发(CF 官方允许),需要用户手动提供。
pub url: Option<String>,
/// 官方给的 SHA-1。
pub sha1: Option<String>,
/// 字节数。
pub size: Option<u64>,
/// 项目类别(CF `classId`:6 = mods、12 = resource packs、6945 = shaders …)。
pub class_id: Option<i64>,
}
/// 一次文件下载请求。
#[derive(Debug, Clone, Copy)]
pub struct FetchRequest<'a> {
/// 逻辑下载地址(官方源)。
pub url: &'a str,
/// 落盘路径(导入器给的**暂存**路径,不是最终实例路径)。
pub dest: &'a Path,
/// 期望 SHA-1(清单声明的)。
pub sha1: Option<&'a str>,
/// 期望字节数。
pub size: Option<u64>,
}
/// 一次下载的产物。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FetchedFile {
/// 落盘字节数。
pub bytes: u64,
}
/// 远端补全 + 下载(测试注入假件,生产走 [`DownloadEngineFetcher`])。
pub trait PackFetcher: Send + Sync {
/// CurseForge `projectID/fileID` → 文件名 / 下载地址 / 摘要。
///
/// 默认实现返回 [`FetchErrorKind::Unsupported`]:见模块文档第 2 条(**不许猜 URL**)。
fn curse_file<'a>(
&'a self,
project_id: i64,
file_id: i64,
) -> FetchFuture<'a, Result<CurseFileMeta, FetchError>> {
Box::pin(async move {
Err(FetchError::unsupported(format!(
"CurseForge files[] 补全需要 CF API 客户端(projectID={project_id} fileID={file_id});当前版本没有,本条记为缺文件"
)))
})
}
/// 把 `request.url` 下到 `request.dest`。
fn fetch_file<'a>(
&'a self,
request: FetchRequest<'a>,
) -> FetchFuture<'a, Result<FetchedFile, FetchError>>;
}
/// 生产实现:把请求交给 `core::download` 的引擎。
#[derive(Debug, Clone, Copy)]
pub struct DownloadEngineFetcher<'a> {
engine: &'a DownloadEngine,
}
impl<'a> DownloadEngineFetcher<'a> {
/// 绑定引擎。
#[must_use]
pub const fn new(engine: &'a DownloadEngine) -> Self {
Self { engine }
}
}
impl PackFetcher for DownloadEngineFetcher<'_> {
fn fetch_file<'a>(
&'a self,
request: FetchRequest<'a>,
) -> FetchFuture<'a, Result<FetchedFile, FetchError>> {
Box::pin(async move {
let mut download = DownloadRequest::new(
ResourceKind::ModFile,
request.url,
request.dest.to_path_buf(),
);
// 摘要长度不对就当"没给":引擎白跑一趟网络比这更贵。
if let Some(sha1) = request.sha1 {
let integrity = Integrity::sha1(sha1);
if integrity.is_well_formed() {
download.integrity = Some(integrity);
}
}
download.expected_size = request.size;
let outcome = self.engine.download(download).await?;
Ok(FetchedFile {
bytes: outcome.bytes,
})
})
}
}
+139
View File
@@ -0,0 +1,139 @@
//! overrides 三方合并的**判据**(纯函数,无 IO):包内原值 / 磁盘现状 / 基线哈希。
//!
//! # 一张表看完全部情形
//!
//! 记 `T` = 包内声明(theirs)、`B` = 基线(base,上次导入的包声明)、`D` = 磁盘现状(ours)。
//! `D` 为 `None` 表示文件不存在。
//!
//! | `D` | `B` | 关系 | 结论 | 理由 |
//! |-----|-----|------|------|------|
//! | 无 | 无 | — | [`MergeAction::Install`] | 全新文件,没有可保护的东西 |
//! | 无 | 有 | `B` 任意 | [`MergeAction::KeepUser`] + `UserDeleted` | 用户删过:**不许**偷偷装回来 |
//! | 有 | 无 | `D == T` | [`MergeAction::SkipIdentical`] | 内容一样,写盘是浪费 |
//! | 有 | 无 | `D != T` | [`MergeAction::KeepUser`] + `PreExisting` | 没有基线可判 ⇒ 当用户已有内容 |
//! | 有 | 有 | `D == T` | [`MergeAction::SkipIdentical`] | 已经是最新 |
//! | 有 | 有 | `D == B` | [`MergeAction::Install`] | 用户没动过 ⇒ 整合包升级,覆盖是对的 |
//! | 有 | 有 | `D != B` | [`MergeAction::KeepUser`] + `UserModified` | 用户改过:**不许**静默覆盖 |
//!
//! 两条"不许"就是任务里的硬要求("不得直接盖掉用户改过的文件")。判据只此一处:
//! `ImportRequest::conflict_policy = Overwrite` 时,`KeepUser` 才会被判据的调用方翻成
//! [`MergeAction::Install`],**判据本身不读策略**——这样"哪些情形算冲突"与"冲突怎么处置"
//! 是两个可独立验证的事实。
//!
//! # `.disabled` / `.old` 不参与判据
//!
//! 后缀是路径的一部分(`mods/x.jar.disabled` 与 `mods/x.jar` 是**两个文件**),所以它们
//! 天然按不同键各自走上面的表。不给 `.old` 附加"清理/回滚"语义(tower 定案:只识别与保留)。
use super::report::ConflictReason;
/// 判据给出的动作(策略开关在调用方)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MergeAction {
/// 写入包内内容(新建或覆盖)。
Install,
/// 磁盘内容已经与包内一致,什么都不做。
SkipIdentical,
/// 保留用户那一侧(不写盘)。
KeepUser,
}
/// 一次判定。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MergeDecision {
/// 动作。
pub action: MergeAction,
/// 算冲突时的成因(`None` = 不是冲突)。
pub reason: Option<ConflictReason>,
}
impl MergeDecision {
const fn plain(action: MergeAction) -> Self {
Self {
action,
reason: None,
}
}
const fn conflict(reason: ConflictReason) -> Self {
Self {
action: MergeAction::KeepUser,
reason: Some(reason),
}
}
/// 是否冲突(与最终处置无关)。
#[must_use]
pub const fn is_conflict(self) -> bool {
self.reason.is_some()
}
}
/// 三方合并判据。
#[must_use]
pub fn decide(pack_sha1: &str, base_sha1: Option<&str>, disk_sha1: Option<&str>) -> MergeDecision {
match (disk_sha1, base_sha1) {
// 磁盘上没有这个文件。
(None, None) => MergeDecision::plain(MergeAction::Install),
(None, Some(_)) => MergeDecision::conflict(ConflictReason::UserDeleted),
// 磁盘上有。
(Some(disk), _) if disk == pack_sha1 => MergeDecision::plain(MergeAction::SkipIdentical),
(Some(_), None) => MergeDecision::conflict(ConflictReason::PreExisting),
(Some(disk), Some(base)) if disk == base => MergeDecision::plain(MergeAction::Install),
(Some(_), Some(_)) => MergeDecision::conflict(ConflictReason::UserModified),
}
}
#[cfg(test)]
mod tests {
use super::*;
const T: &str = "aaaa";
const B: &str = "bbbb";
#[test]
fn fresh_file_is_installed() {
let decision = decide(T, None, None);
assert_eq!(decision.action, MergeAction::Install);
assert!(!decision.is_conflict());
}
#[test]
fn untouched_file_is_replaced_on_upgrade() {
// 磁盘 == 基线(用户没动过)⇒ 整合包升级,覆盖。
let decision = decide(T, Some(B), Some(B));
assert_eq!(decision.action, MergeAction::Install);
assert!(!decision.is_conflict());
}
#[test]
fn user_modification_is_a_conflict() {
let decision = decide(T, Some(B), Some("cccc"));
assert_eq!(decision.action, MergeAction::KeepUser);
assert_eq!(decision.reason, Some(ConflictReason::UserModified));
}
#[test]
fn user_deletion_is_a_conflict() {
let decision = decide(T, Some(B), None);
assert_eq!(decision.action, MergeAction::KeepUser);
assert_eq!(decision.reason, Some(ConflictReason::UserDeleted));
}
#[test]
fn pre_existing_without_baseline_is_a_conflict() {
let decision = decide(T, None, Some("dddd"));
assert_eq!(decision.action, MergeAction::KeepUser);
assert_eq!(decision.reason, Some(ConflictReason::PreExisting));
}
#[test]
fn identical_content_is_skipped() {
// 无论有没有基线,磁盘与包内一致就不写。
assert_eq!(decide(T, None, Some(T)).action, MergeAction::SkipIdentical);
assert_eq!(
decide(T, Some(B), Some(T)).action,
MergeAction::SkipIdentical
);
}
}
+379
View File
@@ -0,0 +1,379 @@
//! 两个落盘阶段的实现:**覆盖内容解压** 与 **远端补全下载**(共用同一张三方合并表)。
//!
//! 拆出来是为了让 `pipeline.rs` 只剩"顺序与收尾":阶段实现里看得见"每个文件怎么判",
//! 编排里看得见"什么时候回滚"。
use std::path::Path;
use super::super::pack::{PackArchive, PackError};
use super::baseline::PackBaseline;
use super::error::ImportError;
use super::fetch::{FetchError, FetchRequest};
use super::merge::{self, MergeAction};
use super::pipeline::tell;
use super::plan::{curse_class_dir, ImportPlan, PlanRemote};
use super::report::{
ConflictChoice, ConflictFile, ConflictReason, FileAction, ImportReport, ImportStage,
ImportedFile, MissingFile, MissingReason,
};
use super::stage::Staging;
use super::ImportRequest;
use crate::download::verify::{self, digest_bytes};
use crate::download::HashAlgo;
/// 阶段一:覆盖条目落暂存 + 三方合并。
pub(crate) async fn extract_overrides(
request: &ImportRequest<'_>,
archive: &PackArchive,
plan: &ImportPlan,
baseline: &PackBaseline,
staging: &Staging,
report: &mut ImportReport,
next: &mut PackBaseline,
) -> Result<(), ImportError> {
let total = plan.overrides.len();
let target = staging.target().to_path_buf();
for (index, entry) in plan.overrides.iter().enumerate() {
tell(
&request.progress,
ImportStage::Extract,
index,
total,
Some(entry.path.clone()),
);
let name = format!("{}/{}", entry.dir, entry.path);
let bytes = archive.read(&name)?.ok_or_else(|| {
ImportError::from(PackError::metadata(format!(
"清单列了 {name},但包里读不到这个条目(声明与实际不符)"
)))
})?;
// 用**实测**摘要而不是模型里记的那个:模型可能来自外部构造。
let pack_sha1 = digest_bytes(HashAlgo::Sha1, &bytes);
next.insert(entry.path.clone(), pack_sha1.clone());
let disk = disk_sha1(&target.join(&entry.path))?;
let decision = merge::decide(&pack_sha1, baseline.sha1(&entry.path), disk.as_deref());
let (action, choice) = settle(decision, disk.is_some(), request.conflict_policy);
if let Some(reason) = decision.reason {
report.conflicts.push(conflict(
entry.path.clone(),
reason,
choice,
&pack_sha1,
bytes.len() as u64,
baseline.sha1(&entry.path).map(str::to_string),
disk,
));
}
if action.wrote() {
staging.write(&entry.path, &bytes)?;
report.bytes_written += bytes.len() as u64;
}
report.files.push(ImportedFile {
path: entry.path.clone(),
source: entry.source,
sha1: Some(pack_sha1),
bytes: bytes.len() as u64,
disabled: entry.disabled,
action,
note: entry.note.clone(),
});
}
Ok(())
}
/// 阶段二:远端条目补全 + 下载 + 同一张合并表。
pub(crate) async fn fetch_remote(
request: &ImportRequest<'_>,
plan: &ImportPlan,
baseline: &PackBaseline,
staging: &Staging,
report: &mut ImportReport,
next: &mut PackBaseline,
) -> Result<(), ImportError> {
let total = plan.remote.len();
let target = staging.target().to_path_buf();
for (index, entry) in plan.remote.iter().enumerate() {
let display = entry.path.clone().unwrap_or_else(|| entry.file.key());
tell(
&request.progress,
ImportStage::Fetch,
index,
total,
Some(display),
);
let Some(resolved) = resolve(request, entry).await? else {
report
.missing
.push(missing_for(entry, request.fetcher.is_some()));
continue;
};
let disk = disk_sha1(&target.join(&resolved.path))?;
// 有声明摘要就能先判合并:命中"保留用户 / 已一致"时**不必下载**(省一次网络)。
if let Some(declared) = resolved.sha1.clone() {
let decision = merge::decide(&declared, baseline.sha1(&resolved.path), disk.as_deref());
let (action, choice) = settle(decision, disk.is_some(), request.conflict_policy);
if !action.wrote() {
next.insert(resolved.path.clone(), declared.clone());
if let Some(reason) = decision.reason {
report.conflicts.push(conflict(
resolved.path.clone(),
reason,
choice,
&declared,
resolved.size.unwrap_or(0),
baseline.sha1(&resolved.path).map(str::to_string),
disk.clone(),
));
}
report.files.push(ImportedFile {
path: resolved.path,
source: entry.source,
sha1: Some(declared),
bytes: resolved.size.unwrap_or(0),
disabled: entry.disabled,
action,
note: None,
});
continue;
}
}
let dest = staging.dest_for(&resolved.path)?;
let fetched = match request.fetcher {
Some(fetcher) => {
fetcher
.fetch_file(FetchRequest {
url: &resolved.url,
dest: &dest,
sha1: resolved.sha1.as_deref(),
size: resolved.size,
})
.await
}
None => Err(FetchError::unsupported("本次导入没有 fetcher(离线)")),
};
let fetched = match fetched {
Ok(fetched) => fetched,
Err(error) if error.kind().is_tolerable() => {
report.missing.push(missing_from_error(
&resolved.path,
entry,
MissingReason::FetchFailed,
error.message(),
));
continue;
}
Err(error) => return Err(error.into()),
};
let pack_sha1 = digest_file(&dest)?;
if let Some(declared) = resolved.sha1.as_deref() {
if !verify::matches(declared, &pack_sha1) {
// 摘要不符 = 内容坏了 / 被换过,**不许**当成"暂时搞不到"混过去。
return Err(ImportError::integrity(format!(
"{} 的摘要与清单声明不符(声明 {declared},实际 {pack_sha1})",
resolved.path
)));
}
}
let decision = merge::decide(&pack_sha1, baseline.sha1(&resolved.path), disk.as_deref());
let (action, choice) = settle(decision, disk.is_some(), request.conflict_policy);
if let Some(reason) = decision.reason {
report.conflicts.push(conflict(
resolved.path.clone(),
reason,
choice,
&pack_sha1,
fetched.bytes,
baseline.sha1(&resolved.path).map(str::to_string),
disk,
));
}
if action.wrote() {
report.bytes_written += fetched.bytes;
} else {
staging.discard(&resolved.path)?;
}
next.insert(resolved.path.clone(), pack_sha1.clone());
report.files.push(ImportedFile {
path: resolved.path,
source: entry.source,
sha1: Some(pack_sha1),
bytes: fetched.bytes,
disabled: entry.disabled,
action,
note: None,
});
}
Ok(())
}
/// 一个已解析出"路径 + 地址"的远端条目。
struct Resolved {
path: String,
url: String,
sha1: Option<String>,
size: Option<u64>,
}
/// 把条目补全成"有路径、有地址"。
///
/// `Ok(None)` = 这条补不出来(进 `missing`);`Err` = 致命(本地 IO / 内容不符)。
async fn resolve(
request: &ImportRequest<'_>,
entry: &PlanRemote,
) -> Result<Option<Resolved>, ImportError> {
if let Some(path) = &entry.path {
// mrpack:路径与地址都在清单里,取第一个非空地址。
let Some(url) = entry
.file
.downloads
.iter()
.find(|url| !url.trim().is_empty())
.cloned()
else {
return Ok(None);
};
return Ok(Some(Resolved {
path: path.clone(),
url,
sha1: entry.file.sha1().map(str::to_string),
size: entry.file.size,
}));
}
// CurseForge:只有 projectID/fileID,必须靠 CF API 补全(当前默认不支持 ⇒ 如实记缺)。
let (Some(project_id), Some(file_id)) = (entry.file.project_id, entry.file.file_id) else {
return Ok(None);
};
let Some(fetcher) = request.fetcher else {
return Ok(None);
};
let meta = match fetcher.curse_file(project_id, file_id).await {
Ok(meta) => meta,
Err(error) if error.kind().is_tolerable() => return Ok(None),
Err(error) => return Err(error.into()),
};
let (Some(url), Some(dir)) = (
meta.url.clone().filter(|url| !url.trim().is_empty()),
meta.class_id.and_then(curse_class_dir),
) else {
// 作者关了 API 分发 / classId 认不出 ⇒ **不猜**目录、不猜 URL。
return Ok(None);
};
Ok(Some(Resolved {
path: format!("{dir}/{}", meta.file_name),
url,
sha1: meta.sha1,
size: meta.size,
}))
}
/// 合并判定 + 策略 → 实际动作。
fn settle(
decision: merge::MergeDecision,
disk_exists: bool,
policy: super::ConflictPolicy,
) -> (FileAction, ConflictChoice) {
let overwrite = policy == super::ConflictPolicy::Overwrite;
match decision.action {
MergeAction::Install => (
if disk_exists {
FileAction::Replaced
} else {
FileAction::Installed
},
ConflictChoice::Overwrite,
),
MergeAction::SkipIdentical => (FileAction::SkippedIdentical, ConflictChoice::KeepUser),
MergeAction::KeepUser if overwrite => (
if disk_exists {
FileAction::Replaced
} else {
FileAction::Installed
},
ConflictChoice::Overwrite,
),
MergeAction::KeepUser => (FileAction::KeptUser, ConflictChoice::KeepUser),
}
}
/// 组装一条冲突记录。
fn conflict(
path: String,
reason: ConflictReason,
choice: ConflictChoice,
pack_sha1: &str,
pack_size: u64,
base_sha1: Option<String>,
disk_sha1: Option<String>,
) -> ConflictFile {
ConflictFile {
path,
reason,
choice,
message_key: reason.message_key(),
pack_sha1: pack_sha1.to_string(),
pack_size,
base_sha1,
disk_sha1,
}
}
/// 组装一条缺失记录(条目本身补不出来)。
fn missing_for(entry: &PlanRemote, fetcher_present: bool) -> MissingFile {
let reason = if fetcher_present {
MissingReason::NoSource
} else {
MissingReason::Offline
};
let detail = if fetcher_present {
"清单里没有可用下载地址,或 CurseForge 元数据补不出来(不猜 URL)"
} else {
"本次导入没有接 fetcher(离线),远端文件一律跳过"
};
missing_from_error(
&entry.path.clone().unwrap_or_else(|| entry.file.key()),
entry,
reason,
detail,
)
}
/// 组装一条缺失记录(已知原因与细节)。
fn missing_from_error(
display: &str,
entry: &PlanRemote,
reason: MissingReason,
detail: &str,
) -> MissingFile {
MissingFile {
path: entry.path.clone(),
display: display.to_string(),
project_id: entry.file.project_id,
file_id: entry.file.file_id,
reason,
message_key: reason.message_key(),
detail: detail.to_string(),
}
}
/// 磁盘现状的 SHA-1(不存在 ⇒ `None`)。
fn disk_sha1(path: &Path) -> Result<Option<String>, ImportError> {
match std::fs::metadata(path) {
Ok(metadata) if metadata.is_file() => verify::digest_file(path, HashAlgo::Sha1)
.map(Some)
.map_err(|error| pack_io(format!("算 {} 的摘要失败:{error}", path.display()))),
Ok(_) => Ok(None),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(pack_io(format!("看 {} 失败:{error}", path.display()))),
}
}
/// 文件的 SHA-1。
fn digest_file(path: &Path) -> Result<String, ImportError> {
verify::digest_file(path, HashAlgo::Sha1)
.map_err(|error| pack_io(format!("算 {} 的摘要失败:{error}", path.display())))
}
/// `pack_io` 便捷构造(本地 IO 复用 M110 的稳定码,不再造新码)。
fn pack_io(message: impl Into<String>) -> ImportError {
PackError::io(message).into()
}
+202
View File
@@ -0,0 +1,202 @@
//! 导入编排:**读取清单 → 解压覆盖内容 → 补全下载 → 落地 → 登记**,每步都可中断、可失败、可重试。
//!
//! # 两阶段落盘(任务硬要求)
//!
//! 先落 `overrides/`(离线、包内自带),再去补远端文件。顺序不是审美问题:
//!
//! - 覆盖内容是最可能被用户改过的那部分——先判定冲突,才能在"用户改过"时**既不覆盖**也不
//! 白下载同名文件(远端条目走同一张三方合并表,冲突就直接跳过下载);
//! - 远端补全是唯一会失败于网络的一步,放最后意味着"网络不通"最多让结果变成 `Partial`,
//! 不会让已经解好的覆盖内容白干。
//!
//! # 失败分级(判据只在一处:是不是"某个文件搞不到")
//!
//! | 失败 | 结果 |
//! |------|------|
//! | 开包/探测/读清单/组计划失败(穿越、符号链接、限额、清单坏) | `Failed`,临时目录删掉,目标目录零残留 |
//! | 某条远端文件摸不到(离线 / CF 无客户端 / 404) | `Partial`,逐条进 `missing`,实例照装 |
//! | 下载到的内容与声明摘要不符 | `Failed`(这是**损坏或被换过**,不是"暂时搞不到") |
//! | 本地写盘失败 | `Failed` |
//! | 实例登记失败 | `Failed`(连内容一起回滚,不留"有目录没档案"的半个实例) |
//!
//! # 可重试
//!
//! 失败后目标目录与导入前完全一致,重跑同一个包是安全的;成功后再跑一遍会靠基线里的哈希
//! 走"跳过 / 已一致"路径,不会重复写盘。
use super::super::pack::{PackArchive, PackError, PackRegistry};
use super::baseline::{PackBaseline, BASELINE_FILE};
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::ImportRequest;
use crate::platform::layout::LAUNCHER_HOME_DIR;
/// 跑一次导入。**永不返回 `Err`**:所有失败都在报告的三态里。
pub async fn run(request: &ImportRequest<'_>) -> ImportReport {
let mut report = ImportReport::empty(request.target.to_path_buf());
tell(&request.progress, ImportStage::Probe, 0, 0, None);
let (archive, plan) = match probe_and_plan(request) {
Ok(pair) => pair,
Err(error) => return failed(report, error),
};
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 staging = match Staging::begin(request.target) {
Ok(staging) => staging,
Err(error) => return failed(report, error),
};
let mut next = baseline.clone();
let staged = async {
phase::extract_overrides(
request,
&archive,
&plan,
&baseline,
&staging,
&mut report,
&mut next,
)
.await?;
phase::fetch_remote(request, &plan, &baseline, &staging, &mut report, &mut next).await?;
write_baseline(&staging, &plan, &next)
}
.await;
if let Err(error) = staged {
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),
};
if let Some(layout) = request.register {
tell(&request.progress, ImportStage::Register, 0, 0, None);
let name = request
.instance_name
.filter(|name| !name.trim().is_empty())
.unwrap_or(plan.manifest.name.as_str());
match crate::game::install::register(layout, name, &plan.version_id, None) {
Ok(registration) => {
report.registered = Some(RegisteredInstance {
id: registration.profile.id.as_str().to_string(),
name: registration.profile.name.clone(),
version_id: registration.profile.version_id.clone(),
created: registration.created,
});
report.warnings.push(ImportIssue::new(
keys::NOTE_REGISTERED,
format!(
"已登记实例 {:?}({})",
registration.profile.name, plan.version_id
),
));
}
Err(error) => {
// 内容落盘了但档案没建起来 = 用户看不见的半个实例 ⇒ 连内容一起回滚。
guard.rollback();
return failed(report, ImportError::instance(error.to_string()));
}
}
}
guard.finish();
report.finish_status();
report
}
/// 开包 + 探测 + 组计划(任何失败都是 `Failed`)。
fn probe_and_plan(request: &ImportRequest<'_>) -> Result<(PackArchive, ImportPlan), ImportError> {
// 路径穿越双检 + 符号链接 + 条数/体积限额都在 `open` 里一次做完(M110 的 `archive`)。
let archive = PackArchive::open(request.pack, request.limits)?;
let (format, manifest) = PackRegistry::builtin().read(&archive)?;
let plan = ImportPlan::build(&archive, format.id(), manifest)?;
Ok((archive, plan))
}
/// 把计划里的静态信息抄进报告。
fn account_plan(report: &mut ImportReport, plan: &ImportPlan) {
report.format = Some(plan.format.clone());
report.pack_name = Some(plan.manifest.name.clone());
report.pack_version = plan.manifest.version.clone();
report.game_version = Some(plan.mc_version.clone());
report.loader = plan.loader.clone();
report.version_id = Some(plan.version_id.clone());
report.max_memory_mb = plan.max_memory_mb;
report.rejected_components.clone_from(&plan.rejected);
report.warnings.extend(plan.notes.iter().cloned());
}
/// 基线作为**最后一个暂存文件**写进去:它跟同一个事务一起提交 / 回滚。
fn write_baseline(
staging: &Staging,
plan: &ImportPlan,
next: &PackBaseline,
) -> Result<(), ImportError> {
let mut baseline = next.clone();
baseline.pack_name.clone_from(&plan.manifest.name);
baseline.format.clone_from(&plan.format);
let bytes = baseline.to_bytes().map_err(PackError::io)?;
staging.write(&baseline_relative(), &bytes)?;
Ok(())
}
/// 基线相对实例根的路径(`<root>/.ULL/pack-baseline.json`)。
#[must_use]
pub fn baseline_relative() -> String {
format!("{LAUNCHER_HOME_DIR}/{BASELINE_FILE}")
}
/// 发一条进度事件(`sink` 为 `None` 时什么都不做)。
pub(super) fn tell(
sink: &Option<super::report::ImportProgressSink>,
stage: ImportStage,
done: usize,
total: usize,
current: Option<String>,
) {
if let Some(sink) = sink {
sink(&ImportProgress {
stage,
done,
total,
current,
});
}
}
/// 失败收尾(目标目录此时要么不存在、要么与导入前一致)。
fn failed(mut report: ImportReport, error: ImportError) -> ImportReport {
report.warnings.push(ImportIssue::new(
keys::ROLLBACK_DONE,
format!(
"导入失败,已清理临时目录;目标目录保持导入前的样子({})",
report.target.display()
),
));
report.rolled_back = true;
report.fail(error);
report
}
+401
View File
@@ -0,0 +1,401 @@
//! 清单 → **导入计划**:把三种格式的差异在进解压器之前全部拍平。
//!
//! 计划是纯计算(只额外读一次 `instance.cfg`),产出四张表:覆盖条目、远端条目、
//! 加载器计划输入、被拒绝的 component。落盘与合并都不在这里。
//!
//! # 三种格式的语义在这层定死
//!
//! | 事 | CurseForge | Modrinth | MultiMC / Prism |
//! |----|-----------|----------|-----------------|
//! | 覆盖目录 | 清单点名的**一个**目录(任意名字) | `overrides/` + `client-overrides/`(`server-overrides/` **不解压**) | `.minecraft/` |
//! | 同名冲突 | 不存在(单目录) | `client-overrides` **胜** `overrides`(Modrinth 官方客户端语义;与 HMCL 相反) | 不存在 |
//! | 远端条目 | `projectID/fileID`,**无路径无地址** ⇒ 必须补全 | 自带 `path`/`downloads`/`hashes` | 没有远端条目(内容就是目录) |
//! | 加载器 | `modLoaders[].id`(`neoforge-1.20.1-47.1.106` 的版本段**带 MC 前缀**) | `dependencies` 的键 | `components[].uid` |
//! # 三处"不许自创"的边界
//!
//! 1. **覆盖目录的优先级只按侧**:`overrides/` 先、`client-overrides/` 后(后者胜)。同一
//! 目录内的条目顺序**不重要**——M110 的 [`crate::addon::pack::PackOverrides::files`] 按路径
//! 排序,zip 条目顺序没有保留;而同一目录内路径唯一,所以"按 zip 条目顺序落盘"与"按路径
//! 排序落盘"在无重复时等价。有重复(跨目录)时由侧优先级决定,不靠条目顺序。
//! 2. **`.disabled` 只认路径后缀**(官方 Modrinth 表达),`.old` **只识别与保留**(不清不删
//! 不改名)。`env.client=optional` **不是**禁用("可选"=安装时可取消勾选)。判据在
//! [`paths`]。
//! 3. **MultiMC 的非加载器 component 逐项进报告**(`org.lwjgl3` / `net.fabricmc.intermediary`
//! / 社区自定义 uid):静默丢它们等于"看起来装好了、其实少了补丁层"。
pub mod paths;
pub use paths::{curse_class_dir, strip_mc_prefix, suffix_variant, SuffixVariant};
use super::super::pack::{
curseforge, multimc, EnvSupport, PackArchive, PackError, PackFile, PackLoader, PackManifest,
PackOverrides, PackSide,
};
use super::error::ImportError;
use super::report::{keys, FileSource, ImportIssue, LoaderTarget, RejectedComponent};
/// 一条要落地的覆盖条目。
#[derive(Debug, Clone)]
pub struct PlanOverride {
/// 包内目录前缀(读包时拼 `<dir>/<path>`)。
pub dir: String,
/// 实例内相对路径。
pub path: String,
/// 来源侧。
pub source: FileSource,
/// 包内实际 SHA-1(M110 读包时现算)。
pub sha1: String,
/// 字节数。
pub size: u64,
/// 是否禁用态(路径后缀)。
pub disabled: bool,
/// 可选注记(`.disabled` / `.old`)。
pub note: Option<ImportIssue>,
}
/// 一条要补全/下载的远端条目。
#[derive(Debug, Clone)]
pub struct PlanRemote {
/// 原清单条目(摘要表 / downloads / env / project/file id 都在里面)。
pub file: PackFile,
/// 已知的实例内相对路径(mrpack 有;CF 需要补全才有)。
pub path: Option<String>,
/// 来源。
pub source: FileSource,
/// 是否禁用态(路径后缀)。
pub disabled: bool,
}
/// 一次导入的计划。
#[derive(Debug, Clone)]
pub struct ImportPlan {
/// 命中的格式 id。
pub format: String,
/// 原始模型(`extra` 等诊断信息留着)。
pub manifest: PackManifest,
/// 游戏版本。
pub mc_version: String,
/// 加载器安装计划输入(`None` = 纯原版)。
pub loader: Option<LoaderTarget>,
/// 实例绑定版本 id。
pub version_id: String,
/// 不支持的 component(逐项)。
pub rejected: Vec<RejectedComponent>,
/// 注记。
pub notes: Vec<ImportIssue>,
/// 覆盖条目(已按侧优先级排好、已剔除 `server-overrides`)。
pub overrides: Vec<PlanOverride>,
/// 远端条目。
pub remote: Vec<PlanRemote>,
/// `instance.cfg` 里受开关门控的内存上限(MB)。
pub max_memory_mb: Option<u64>,
}
impl ImportPlan {
/// 组计划。
///
/// # Errors
///
/// 清单缺游戏版本、加载器条目缺版本号、MultiMC 的 `formatVersion != 1` 等"清单自身非法"
/// 的情形返回 [`ImportError`](调用方据此整包回滚)。"某条文件缺地址"**不是**错误。
pub fn build(
pack: &PackArchive,
format_id: &str,
manifest: PackManifest,
) -> Result<Self, ImportError> {
let mut notes: Vec<ImportIssue> = Vec::new();
let mut rejected: Vec<RejectedComponent> = Vec::new();
// Prism 的规矩(A 级来源 `PackProfile.cpp:207-210`):`formatVersion` 必须恰为 1,
// 否则**整份丢弃**——不是降级解析。M110 的读者把缺省值当 1(老包不写这一行),
// 因此这里只拒"写了非 1 的值"。
if format_id == multimc::ID && manifest.format_version != Some(1) {
return Err(ImportError::from(PackError::metadata(format!(
"{} 的 formatVersion 是 {:?},必须恰为 1(Prism 自己也是整份丢弃,不降级解析)",
multimc::METADATA_ENTRY,
manifest.format_version
))));
}
let mc_version = manifest
.minecraft
.as_deref()
.map(str::trim)
.filter(|version| !version.is_empty())
.map(str::to_string)
.ok_or_else(|| {
ImportError::from(PackError::metadata(
"清单里没有游戏版本(CF minecraft.version / mrpack dependencies.minecraft / MultiMC net.minecraft 组件)",
))
})?;
let loader = select_loader(&manifest, &mc_version, format_id, &mut notes, &mut rejected)?;
let version_id = loader
.as_ref()
.map_or_else(|| mc_version.clone(), |target| target.version_id.clone());
let mut overrides: Vec<PlanOverride> = Vec::new();
let mut skipped_server_files = 0_usize;
for group in ordered_overrides(&manifest) {
if group.side == PackSide::Server {
// Modrinth 官方客户端安装器就不解压 server-overrides/(A 级来源:
// install_mrpack.rs 的 strip_prefix 链只有 overrides/ 与 client-overrides/)。
skipped_server_files += group.files.len();
continue;
}
for file in &group.files {
let variant = suffix_variant(&file.path);
overrides.push(PlanOverride {
dir: group.dir.clone(),
path: file.path.clone(),
source: FileSource::from_side(group.side),
sha1: file.sha1.clone(),
size: file.size,
disabled: variant.is_disabled(),
note: suffix_note(variant, &file.path),
});
}
}
if skipped_server_files > 0 {
notes.push(ImportIssue::new(
keys::NOTE_SERVER_OVERRIDES_SKIPPED,
format!(
"server-overrides/ 里的 {skipped_server_files} 个文件是服务端内容,客户端导入不解压(Modrinth 官方语义)"
),
));
}
let mut remote: Vec<PlanRemote> = Vec::new();
let source = if format_id == curseforge::ID {
FileSource::CurseForge
} else {
FileSource::Modrinth
};
for file in &manifest.files {
if file.client == EnvSupport::Unsupported {
notes.push(
ImportIssue::new(
keys::NOTE_ENV_UNSUPPORTED,
"env.client=unsupported:客户端不需要这个文件,跳过".to_string(),
)
.with_path(file.path.clone().unwrap_or_else(|| file.key())),
);
continue;
}
let variant = file
.path
.as_deref()
.map_or(SuffixVariant::Plain, suffix_variant);
if file.client == EnvSupport::Optional {
notes.push(
ImportIssue::new(
keys::NOTE_OPTIONAL_FILE,
"env.client=optional:可选条目照常安装(「可选」不等于「禁用」)"
.to_string(),
)
.with_path(file.path.clone().unwrap_or_else(|| file.key())),
);
}
if let Some(note) = file
.path
.as_deref()
.and_then(|path| suffix_note(variant, path))
{
notes.push(note);
}
remote.push(PlanRemote {
path: file.path.clone(),
source,
disabled: variant.is_disabled(),
file: file.clone(),
});
}
let max_memory_mb = if format_id == multimc::ID {
instance_memory(pack, &mut notes)?
} else {
None
};
Ok(Self {
format: format_id.to_string(),
manifest,
mc_version,
loader,
version_id,
rejected,
notes,
overrides,
remote,
max_memory_mb,
})
}
}
/// 覆盖目录按侧排序:`Both`(`overrides/`)先、`Client`(`client-overrides/`)后 = 后者胜。
fn ordered_overrides(manifest: &PackManifest) -> Vec<&PackOverrides> {
let mut groups: Vec<&PackOverrides> = manifest.overrides.iter().collect();
groups.sort_by_key(|group| match group.side {
PackSide::Both => 0_u8,
PackSide::Client => 1,
PackSide::Server => 2,
});
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,
mc_version: &str,
format_id: &str,
notes: &mut Vec<ImportIssue>,
rejected: &mut Vec<RejectedComponent>,
) -> Result<Option<LoaderTarget>, ImportError> {
let recognized: Vec<&PackLoader> = manifest
.loaders
.iter()
.filter(|loader| loader.kind.is_some())
.collect();
// 认不出的条目:MultiMC 是"不支持的 component"(逐项列),其它格式是"不认识的依赖键"。
for loader in manifest
.loaders
.iter()
.filter(|loader| loader.kind.is_none())
{
if format_id == multimc::ID {
rejected.push(RejectedComponent {
uid: loader.raw.clone(),
version: loader.version.clone(),
message_key: keys::REJECTED_COMPONENT,
});
} else {
notes.push(ImportIssue::new(
keys::NOTE_UNKNOWN_DEPENDENCY,
format!("认不出的加载器/依赖键 {:?}:本启动器不安装它", loader.raw),
));
}
}
if recognized.len() > 1 {
notes.push(ImportIssue::new(
keys::NOTE_LOADER_MULTIPLE,
format!(
"清单里有 {} 个可识别的加载器,只按 primary/第一条组计划:{}",
recognized.len(),
recognized
.iter()
.map(|loader| loader.raw.as_str())
.collect::<Vec<_>>()
.join(", ")
),
));
}
let Some(loader) = recognized
.iter()
.find(|loader| loader.primary)
.or_else(|| recognized.first())
.copied()
else {
return Ok(None);
};
let Some(kind) = loader.kind else {
return Ok(None);
};
let raw_version = loader
.version
.as_deref()
.map(str::trim)
.filter(|version| !version.is_empty())
.ok_or_else(|| {
ImportError::loader(format!(
"加载器条目 {:?} 没有版本号,组不出安装计划(清单自身非法)",
loader.raw
))
})?;
let loader_version = strip_mc_prefix(mc_version, raw_version);
Ok(Some(LoaderTarget {
kind,
mc_version: mc_version.to_string(),
version_id: kind.version_id(mc_version, &loader_version),
loader_version,
raw: loader.raw.clone(),
}))
}
/// 从 `instance.cfg` 取受开关门控的内存上限(MB)。
fn instance_memory(
pack: &PackArchive,
notes: &mut Vec<ImportIssue>,
) -> Result<Option<u64>, ImportError> {
let Some(text) = pack.read_text(multimc::INSTANCE_ENTRY)? else {
return Ok(None);
};
let mut values: Vec<(&str, &str)> = Vec::new();
for line in text.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
continue;
}
// `[General]` 这类小节标记直接跳过:instance.cfg 平铺,真出现也不该把它们当键。
if line.starts_with('[') {
continue;
}
// 值里含 `:` 与 `\`(Windows 路径)是常态,所以只按**第一个** `=` 切。
if let Some((key, value)) = line.split_once('=') {
values.push((key.trim(), value.trim()));
}
}
let get = |name: &str| -> Option<&str> {
values
.iter()
.find(|(key, _)| key.eq_ignore_ascii_case(name))
.map(|(_, value)| *value)
};
let Some(max) = get("MaxMemAlloc").and_then(|value| value.parse::<u64>().ok()) else {
return Ok(None);
};
// Prism 的规矩:这些值只在开关为 true 时生效(只看值不看开关会误判)。
let enabled = get("OverrideMemory").is_some_and(|value| value.eq_ignore_ascii_case("true"));
if !enabled {
notes.push(ImportIssue::new(
keys::NOTE_INSTANCE_CFG_MEMORY,
format!("instance.cfg 写了 MaxMemAlloc={max},但 OverrideMemory 未开:不采用"),
));
return Ok(None);
}
notes.push(ImportIssue::new(
keys::NOTE_INSTANCE_CFG_MEMORY,
format!("从 instance.cfg 取到内存上限 {max} MB(OverrideMemory=true)"),
));
Ok(Some(max))
}
+119
View File
@@ -0,0 +1,119 @@
//! 路径与版本号的小判据(纯函数):`.disabled` / `.old` 后缀变体、CF 类别目录、MC 版本前缀。
//!
//! 拆出来是因为它们是**格式事实**而非管线逻辑:`.disabled` 只按路径后缀(tower 定案,不用
//! `env.client=optional`)、`.old` 只识别与保留(不清不删)、CF 的 `classId` 认不出就不猜。
/// 路径后缀变体(`.disabled` / `.old` 的四种组合)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SuffixVariant {
/// `mods/x.jar`。
Plain,
/// `mods/x.jar.disabled`(官方 Modrinth 的禁用表达)。
Disabled,
/// `mods/x.jar.old`(HMCL 生态常见:旧版本残留)。
Old,
/// `mods/x.jar.disabled.old`。
DisabledOld,
}
impl SuffixVariant {
/// 是否禁用态。
#[must_use]
pub const fn is_disabled(self) -> bool {
matches!(self, Self::Disabled | Self::DisabledOld)
}
/// 是否带 `.old`(只用于报告,不附加清理语义)。
#[must_use]
pub const fn is_old(self) -> bool {
matches!(self, Self::Old | Self::DisabledOld)
}
}
/// 识别四种路径后缀变体。
#[must_use]
pub fn suffix_variant(path: &str) -> SuffixVariant {
let lowered = path.trim_end().to_ascii_lowercase();
let stripped = lowered.strip_suffix(".old").unwrap_or(&lowered);
let old = stripped.len() != lowered.len();
let disabled = stripped.ends_with(".disabled");
match (disabled, old) {
(false, false) => SuffixVariant::Plain,
(true, false) => SuffixVariant::Disabled,
(false, true) => SuffixVariant::Old,
(true, true) => SuffixVariant::DisabledOld,
}
}
/// CurseForge `classId` → 实例内目录(**认不出的类别返回 `None`**,由调用方告警)。
///
/// 依据:CF 官方 `classId` 枚举(`6` mods / `12` resource packs / `6945` shaders /
/// `17` worlds / `4546` customization / `4471` modpacks)。未知类别**不猜**:
/// 计划层返回 `None`,导入时进 `missing`/告警,而不是"随手塞进 mods/"。
#[must_use]
pub const fn curse_class_dir(class_id: i64) -> Option<&'static str> {
match class_id {
6 => Some("mods"),
12 => Some("resourcepacks"),
6945 => Some("shaderpacks"),
17 => Some("saves"),
4546 => Some("customize"),
_ => None,
}
}
/// 剥掉 CF 在版本段里又写一遍的 MC 前缀(`neoforge-1.20.1-47.1.106` 的坑)。
///
/// 只在"版本段以 `<mc>-` 开头"时剥——这是 CF 导出器在 NeoForge/Forge 上会写出的形状
/// (A 级来源 `FlamePackExportTask.cpp:404-410`)。不按 `-` 硬切第三段:`47.1.106` 本身
/// 也有不同风格,切错会把版本号喂成 `1.20.1`。
#[must_use]
pub fn strip_mc_prefix(mc_version: &str, loader_version: &str) -> String {
let prefix = format!("{mc_version}-");
loader_version
.strip_prefix(&prefix)
.filter(|rest| !rest.is_empty())
.unwrap_or(loader_version)
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn recognises_four_suffix_variants() {
assert_eq!(suffix_variant("mods/x.jar"), SuffixVariant::Plain);
assert_eq!(
suffix_variant("mods/x.jar.disabled"),
SuffixVariant::Disabled
);
assert_eq!(suffix_variant("mods/x.jar.old"), SuffixVariant::Old);
assert_eq!(
suffix_variant("mods/x.jar.disabled.old"),
SuffixVariant::DisabledOld
);
assert!(suffix_variant("mods/x.jar.disabled").is_disabled());
assert!(!suffix_variant("mods/x.jar.old").is_disabled());
assert!(suffix_variant("mods/x.jar.old").is_old());
}
#[test]
fn strips_mc_prefix_from_loader_version() {
// NeoForge 在 1.20.1 上的真实形状。
assert_eq!(strip_mc_prefix("1.20.1", "1.20.1-47.1.106"), "47.1.106");
// 没有前缀的照原样(Fabric / 正常 Forge)。
assert_eq!(strip_mc_prefix("1.20.1", "0.15.11"), "0.15.11");
// 只剥一层,且剥完不能是空串。
assert_eq!(strip_mc_prefix("1.20.1", "1.20.1-"), "1.20.1-");
}
#[test]
fn knows_curse_class_directories() {
assert_eq!(curse_class_dir(6), Some("mods"));
assert_eq!(curse_class_dir(12), Some("resourcepacks"));
assert_eq!(curse_class_dir(6945), Some("shaderpacks"));
// 认不出的类别不猜。
assert_eq!(curse_class_dir(999_999), None);
}
}
+238
View File
@@ -0,0 +1,238 @@
//! 导入结果:**三态 + 逐项原因**,给 CLI 的 `--json` 与 M109/M113 的页面直接消费。
//!
//! # 三态不是"成功/失败"两态(M111 任务原文要求)
//!
//! | 状态 | 判据 | 退出码(CLI) |
//! |------|------|---------------|
//! | [`ImportStatus::Success`] | 清单里每条文件都落地、没有冲突 | `0` |
//! | [`ImportStatus::Partial`] | 有"搞不到"的文件([`MissingFile`])或有保留用户文件的冲突([`ConflictFile`]) | `3` |
//! | [`ImportStatus::Failed`] | 致命错误([`ImportError`]),**已整包回滚** | `1` |
//!
//! 把"部分成功"记成 `Success` 是这条管线最容易犯的错:用户会以为 mods 齐了,启动才发现缺
//! 依赖。所以 [`ImportReport::finish_status`] 是**唯一**算状态的地方,字段私有语义由它收敛。
//!
//! # 分文件
//!
//! - [`keys`]:文案 key 常量表(稳定字符串,含 `ALL` 守门表);
//! - [`items`]:条目类型(一条文件 / 一条冲突 / 一条缺失 / 一个 component / 加载器目标);
//! - 本文件:三态、阶段、进度、以及总的 [`ImportReport`]。
pub mod items;
pub mod keys;
pub use items::{
ConflictChoice, ConflictFile, ConflictReason, FileAction, FileSource, ImportIssue,
ImportedFile, LoaderTarget, MissingFile, MissingReason, RegisteredInstance, RejectedComponent,
};
use std::path::PathBuf;
use serde::Serialize;
use super::error::ImportError;
/// 导入三态。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ImportStatus {
/// 全部落地、无冲突。
Success,
/// 有缺文件或有冲突(内容不完整,但实例可用)。
Partial,
/// 致命失败,已整包回滚。
Failed,
}
impl ImportStatus {
/// 稳定字符串 id。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Success => "success",
Self::Partial => "partial",
Self::Failed => "failed",
}
}
/// 文案 key。
#[must_use]
pub const fn message_key(self) -> &'static str {
match self {
Self::Success => keys::STATUS_SUCCESS,
Self::Partial => keys::STATUS_PARTIAL,
Self::Failed => keys::STATUS_FAILED,
}
}
/// CLI 退出码:`0` / `3` / `1`(`2` 留给内部错误,`1` 与用法错误同码但 JSON 里可区分)。
#[must_use]
pub const fn exit_code(self) -> i32 {
match self {
Self::Success => 0,
Self::Partial => 3,
Self::Failed => 1,
}
}
}
/// 导入阶段(进度回调按这个分组)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ImportStage {
/// 读取清单(开包 + 安全四检 + 探测 + 读成模型)。
Probe,
/// 解压 / 三方合并覆盖内容。
Extract,
/// 补全并下载远端文件。
Fetch,
/// 落地(临时目录 rename 或 journal 合并)。
Commit,
/// 登记实例。
Register,
}
impl ImportStage {
/// 稳定字符串 id。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Probe => "probe",
Self::Extract => "extract",
Self::Fetch => "fetch",
Self::Commit => "commit",
Self::Register => "register",
}
}
/// 文案 key。
#[must_use]
pub const fn message_key(self) -> &'static str {
match self {
Self::Probe => keys::STAGE_PROBE,
Self::Extract => keys::STAGE_EXTRACT,
Self::Fetch => keys::STAGE_FETCH,
Self::Commit => keys::STAGE_COMMIT,
Self::Register => keys::STAGE_REGISTER,
}
}
}
/// 一条进度事件(同步回调,跨线程安全)。
#[derive(Debug, Clone, Serialize)]
pub struct ImportProgress {
/// 当前阶段。
pub stage: ImportStage,
/// 阶段内已完成条目数。
pub done: usize,
/// 阶段内总条目数(未知时为 0)。
pub total: usize,
/// 当前条目的实例内路径(阶段刚开始时为 `None`)。
pub current: Option<String>,
}
/// 进度回调。
pub type ImportProgressSink = std::sync::Arc<dyn Fn(&ImportProgress) + Send + Sync>;
/// 一次导入的完整结果。
#[derive(Debug, Clone, Serialize)]
pub struct ImportReport {
/// 三态。
pub status: ImportStatus,
/// 命中的格式 id(`curseforge` / `modrinth` / `multimc`)。
pub format: Option<String>,
/// 包名。
pub pack_name: Option<String>,
/// 包版本。
pub pack_version: Option<String>,
/// 游戏版本。
pub game_version: Option<String>,
/// 加载器安装计划输入(没有加载器时 `None` = 纯原版)。
pub loader: Option<LoaderTarget>,
/// 实例绑定的版本 id。
pub version_id: Option<String>,
/// 目标实例目录。
pub target: PathBuf,
/// 落地的文件(含 `SkippedIdentical`)。
pub files: Vec<ImportedFile>,
/// 冲突清单。
pub conflicts: Vec<ConflictFile>,
/// 缺文件清单(容忍)。
pub missing: Vec<MissingFile>,
/// MultiMC 不支持的 component(逐项)。
pub rejected_components: Vec<RejectedComponent>,
/// 注记。
pub warnings: Vec<ImportIssue>,
/// 致命错误(`status == Failed` 时必有)。
pub error: Option<ImportError>,
/// 登记的实例。
pub registered: Option<RegisteredInstance>,
/// `instance.cfg` 里受开关门控的内存上限(MB;仅 MultiMC / Prism 有)。
pub max_memory_mb: Option<u64>,
/// 本次写入的字节数。
pub bytes_written: u64,
/// 是否已回滚(`status == Failed` 时为目标目录零残留的证据位)。
pub rolled_back: bool,
}
impl ImportReport {
/// 一次尚未发生任何事的空报告(失败路径的起点)。
#[must_use]
pub fn empty(target: PathBuf) -> Self {
Self {
status: ImportStatus::Success,
format: None,
pack_name: None,
pack_version: None,
game_version: None,
loader: None,
version_id: None,
target,
files: Vec::new(),
conflicts: Vec::new(),
missing: Vec::new(),
rejected_components: Vec::new(),
warnings: Vec::new(),
error: None,
registered: None,
max_memory_mb: None,
bytes_written: 0,
rolled_back: false,
}
}
/// 真正写了盘的文件数。
#[must_use]
pub fn installed(&self) -> usize {
self.files.iter().filter(|file| file.action.wrote()).count()
}
/// 是否"部分成功"。
///
/// 判据是**结果对用户是否干净**:有"搞不到"的文件,或有保留了用户文件的冲突(说明整合包
/// 与磁盘内容不一致)都不是干净的成功。用户选了"覆盖"的冲突不算——那侧的意图已落实。
#[must_use]
pub fn is_partial(&self) -> bool {
!self.missing.is_empty()
|| self
.conflicts
.iter()
.any(|conflict| conflict.choice == ConflictChoice::KeepUser)
}
/// 按 `error` / `missing` / `conflicts` 收敛三态(**唯一**算状态的地方)。
pub fn finish_status(&mut self) {
self.status = if self.error.is_some() {
ImportStatus::Failed
} else if self.is_partial() {
ImportStatus::Partial
} else {
ImportStatus::Success
};
}
/// 记一条致命错误并把状态落到 `Failed`。
pub fn fail(&mut self, error: ImportError) {
self.error = Some(error);
self.status = ImportStatus::Failed;
}
}
@@ -0,0 +1,265 @@
//! 报告里的**条目类型**:一条文件、一条冲突、一条缺失、一个 component、一个加载器目标。
use serde::Serialize;
use super::keys;
use crate::addon::loader::LoaderKind;
use crate::addon::pack::PackSide;
/// 一条文件来自哪里。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum FileSource {
/// 包内 `overrides/`(或 CF 清单点名的那个目录 / MultiMC 的 `.minecraft/`)。
Overrides,
/// 包内 `client-overrides/`。
ClientOverrides,
/// CurseForge `files[]` 条目(靠 projectID/fileID 补全)。
CurseForge,
/// Modrinth `files[]` 条目(清单自带 path + downloads)。
Modrinth,
}
impl FileSource {
/// 从覆盖目录的侧别取来源。
#[must_use]
pub const fn from_side(side: PackSide) -> Self {
match side {
PackSide::Both => Self::Overrides,
PackSide::Client => Self::ClientOverrides,
// 服务端覆盖在客户端导入里不会落地(见 `plan` 的侧别优先级)。
PackSide::Server => Self::Overrides,
}
}
}
/// 一个文件本次被怎么处理。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum FileAction {
/// 新写入(磁盘上原本没有)。
Installed,
/// 覆盖写入(用户没改过,整合包升级)。
Replaced,
/// 磁盘内容与包内一致,没动。
SkippedIdentical,
/// 用户改过 / 删过,按策略保留了用户那一侧。
KeptUser,
}
impl FileAction {
/// 是否真的写了盘。
#[must_use]
pub const fn wrote(self) -> bool {
matches!(self, Self::Installed | Self::Replaced)
}
}
/// 冲突的成因。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ConflictReason {
/// 基线里记过这个文件,磁盘内容与基线不同 ⇒ 用户改过。
UserModified,
/// 基线里记过这个文件,磁盘上却没了 ⇒ 用户删过。
UserDeleted,
/// 没有基线可判,磁盘上本来就有同名文件。
PreExisting,
}
impl ConflictReason {
/// 文案 key。
#[must_use]
pub const fn message_key(self) -> &'static str {
match self {
Self::UserModified => keys::CONFLICT_USER_MODIFIED,
Self::UserDeleted => keys::CONFLICT_USER_DELETED,
Self::PreExisting => keys::CONFLICT_PRE_EXISTING,
}
}
}
/// 冲突实际采用的处置(由 [`super::super::ConflictPolicy`] 决定)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ConflictChoice {
/// 保留用户侧(不写盘)。
KeepUser,
/// 用包内内容覆盖(用户选了"覆盖")。
Overwrite,
}
/// 一条冲突("无法判定时给可选择的冲突清单"就是这张表)。
#[derive(Debug, Clone, Serialize)]
pub struct ConflictFile {
/// 实例内相对路径。
pub path: String,
/// 成因。
pub reason: ConflictReason,
/// 本次实际采用的一侧。
pub choice: ConflictChoice,
/// 文案 key(= `reason.message_key()`)。
pub message_key: &'static str,
/// 包内声明的 SHA-1。
pub pack_sha1: String,
/// 包内声明的字节数。
pub pack_size: u64,
/// 上次导入写下的 SHA-1(没有基线时 `None`)。
pub base_sha1: Option<String>,
/// 磁盘现状的 SHA-1(文件不存在时 `None`)。
pub disk_sha1: Option<String>,
}
/// 一个"搞不到"的文件(**容忍**,但必须逐条列出)。
#[derive(Debug, Clone, Serialize)]
pub struct MissingFile {
/// 实例内相对路径(mrpack 有;CF 补全失败时没有)。
pub path: Option<String>,
/// 人读标识(CF 条目是 `projectID:fileID`,其余是路径)。
pub display: String,
/// CF `projectID`。
pub project_id: Option<i64>,
/// CF `fileID`。
pub file_id: Option<i64>,
/// 为什么搞不到。
pub reason: MissingReason,
/// 文案 key。
pub message_key: &'static str,
/// 细节(错误原文)。
pub detail: String,
}
/// "搞不到"的成因。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum MissingReason {
/// 调用方没给 fetcher(离线)。
Offline,
/// 条目补不出下载地址(CF 缺 API 客户端 / `downloads` 为空)。
NoSource,
/// 尝试过但失败(404 / 网络)。
FetchFailed,
}
impl MissingReason {
/// 文案 key。
#[must_use]
pub const fn message_key(self) -> &'static str {
match self {
Self::Offline => keys::MISSING_OFFLINE,
Self::NoSource => keys::MISSING_NO_SOURCE,
Self::FetchFailed => keys::MISSING_FETCH_FAILED,
}
}
}
/// 一个 MultiMC / Prism component 被识别为"本启动器不支持"(**逐项**列出,不静默丢)。
#[derive(Debug, Clone, Serialize)]
pub struct RejectedComponent {
/// 原始 uid(`org.lwjgl3` / `net.fabricmc.intermediary` / 社区自定义)。
pub uid: String,
/// 该组件声明的版本。
pub version: Option<String>,
/// 文案 key(恒为 [`keys::REJECTED_COMPONENT`])。
pub message_key: &'static str,
}
/// 加载器安装计划的输入:**只需要这三个参数**就能接上 `addon::loader` 的既有能力。
#[derive(Debug, Clone)]
pub struct LoaderTarget {
/// 加载器种类。
pub kind: LoaderKind,
/// 目标原版版本。
pub mc_version: String,
/// 加载器版本(已剥掉 CF 的 MC 版本前缀)。
pub loader_version: String,
/// 清单里的原始标识(诊断用)。
pub raw: String,
/// 实例绑定的版本 id(`1.20.1-fabric-0.15.11` 或纯原版 `1.20.1`)。
pub version_id: String,
}
impl LoaderTarget {
/// 直接喂给 `LoaderSource::resolve(kind, mc_version, Some(loader_version))`。
#[must_use]
pub fn resolve_args(&self) -> (LoaderKind, &str, &str) {
(self.kind, &self.mc_version, &self.loader_version)
}
}
impl Serialize for LoaderTarget {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
use serde::ser::SerializeMap;
let mut map = serializer.serialize_map(None)?;
map.serialize_entry("kind", self.kind.as_str())?;
map.serialize_entry("label", self.kind.label())?;
map.serialize_entry("mc_version", &self.mc_version)?;
map.serialize_entry("loader_version", &self.loader_version)?;
map.serialize_entry("version_id", &self.version_id)?;
map.serialize_entry("raw", &self.raw)?;
map.end()
}
}
/// 一条落地的文件。
#[derive(Debug, Clone, Serialize)]
pub struct ImportedFile {
/// 实例内相对路径(`.disabled` 后缀原样保留)。
pub path: String,
/// 来源。
pub source: FileSource,
/// 实际写入/比对用的 SHA-1。
pub sha1: Option<String>,
/// 字节数。
pub bytes: u64,
/// 是否禁用态(路径后缀表达,不附加语义)。
pub disabled: bool,
/// 处理方式。
pub action: FileAction,
/// 可选注记(`.disabled` / `optional` 之类)。
pub note: Option<ImportIssue>,
}
/// 一条人读注记(带稳定 key)。
#[derive(Debug, Clone, Serialize)]
pub struct ImportIssue {
/// 文案 key。
pub message_key: &'static str,
/// 人读细节。
pub message: String,
/// 相关实例内路径。
pub path: Option<String>,
}
impl ImportIssue {
/// 构造。
#[must_use]
pub fn new(message_key: &'static str, message: impl Into<String>) -> Self {
Self {
message_key,
message: message.into(),
path: None,
}
}
/// 挂一条路径。
#[must_use]
pub fn with_path(mut self, path: impl Into<String>) -> Self {
self.path = Some(path.into());
self
}
}
/// 登记出来的实例(给 UI 显示"装到哪了")。
#[derive(Debug, Clone, Serialize)]
pub struct RegisteredInstance {
/// 实例 id(稳定 UUID)。
pub id: String,
/// 展示名。
pub name: String,
/// 绑定版本 id。
pub version_id: String,
/// 是否本次新建(`false` = 复用已有档案)。
pub created: bool,
}
@@ -0,0 +1,96 @@
//! 文案 key 常量表(**稳定字符串**,M109/M113 的页面按这些字符串查本地化文案)。
//!
//! core 不出现面向用户的成品文案(诊断信息除外);三态/阶段/缺失/冲突/注记各自带一个 key,
//! 逐条原因也在报告条目里(`message_key` 字段)。
//!
//! `ALL` 是"没有死常量"的守门用表:`addon/tests/import_report.rs` 会核对
//! ①常量定义集合 == `ALL`(不多不少);②每个常量除了定义与 `ALL` 里的列举之外,至少还有
//! 一处真实引用。任何一条不满足就失败——避免这张表腐烂成"看着齐全、其实没人用"。
/// 阶段:读取清单。
pub const STAGE_PROBE: &str = "pack.import.stage.probe";
/// 阶段:解压覆盖内容。
pub const STAGE_EXTRACT: &str = "pack.import.stage.extract";
/// 阶段:补全缺失文件。
pub const STAGE_FETCH: &str = "pack.import.stage.fetch";
/// 阶段:落地(rename / journal)。
pub const STAGE_COMMIT: &str = "pack.import.stage.commit";
/// 阶段:登记实例。
pub const STAGE_REGISTER: &str = "pack.import.stage.register";
/// 状态:成功。
pub const STATUS_SUCCESS: &str = "pack.import.status.success";
/// 状态:部分成功。
pub const STATUS_PARTIAL: &str = "pack.import.status.partial";
/// 状态:失败(已回滚)。
pub const STATUS_FAILED: &str = "pack.import.status.failed";
/// 注记:`server-overrides/` 客户端不解压。
pub const NOTE_SERVER_OVERRIDES_SKIPPED: &str = "pack.import.note.server_overrides_skipped";
/// 注记:`dependencies` 里不认识的键(`parchment` 之类)。
pub const NOTE_UNKNOWN_DEPENDENCY: &str = "pack.import.note.unknown_dependency";
/// 注记:`env.client=unsupported` 的条目被跳过。
pub const NOTE_ENV_UNSUPPORTED: &str = "pack.import.note.env_unsupported";
/// 注记:路径以 `.disabled` 结尾(禁用态,按原后缀落地)。
pub const NOTE_DISABLED_SUFFIX: &str = "pack.import.note.disabled_suffix";
/// 注记:路径以 `.old` 结尾(只识别与保留,不加语义)。
pub const NOTE_OLD_SUFFIX: &str = "pack.import.note.old_suffix";
/// 注记:`env.client=optional`("可选"不等于"禁用",照装)。
pub const NOTE_OPTIONAL_FILE: &str = "pack.import.note.optional_file";
/// 注记:清单里有多个加载器,只按 primary/第一条组计划。
pub const NOTE_LOADER_MULTIPLE: &str = "pack.import.note.loader_multiple";
/// 注记:实例已登记。
pub const NOTE_REGISTERED: &str = "pack.import.note.registered";
/// 注记:从 `instance.cfg` 取到了内存设置。
pub const NOTE_INSTANCE_CFG_MEMORY: &str = "pack.import.note.instance_cfg_memory";
/// 注记:基线文件读不出来(本次按"没有基线"处理)。
pub const NOTE_BASELINE_UNREADABLE: &str = "pack.import.note.baseline_unreadable";
/// 缺失:调用方没给 fetcher(离线导入)。
pub const MISSING_OFFLINE: &str = "pack.import.missing.offline";
/// 缺失:条目本身补不出下载地址(CF `files[]` 缺 API 客户端 / `downloads` 为空)。
pub const MISSING_NO_SOURCE: &str = "pack.import.missing.no_source";
/// 缺失:下载/补全尝试过但失败(404 / 网络)。
pub const MISSING_FETCH_FAILED: &str = "pack.import.missing.fetch_failed";
/// 冲突:用户改过这个文件。
pub const CONFLICT_USER_MODIFIED: &str = "pack.import.conflict.user_modified";
/// 冲突:用户删过这个文件。
pub const CONFLICT_USER_DELETED: &str = "pack.import.conflict.user_deleted";
/// 冲突:磁盘上本来就有同名内容(没有基线可判)。
pub const CONFLICT_PRE_EXISTING: &str = "pack.import.conflict.pre_existing";
/// MultiMC 不支持的 component(逐项列出)。
pub const REJECTED_COMPONENT: &str = "pack.import.rejected.component";
/// 致命失败后已完成回滚(目标目录零残留)。
pub const ROLLBACK_DONE: &str = "pack.import.rollback.done";
/// 全部 key(守门断言"没有死常量"用)。
pub const ALL: &[&str] = &[
STAGE_PROBE,
STAGE_EXTRACT,
STAGE_FETCH,
STAGE_COMMIT,
STAGE_REGISTER,
STATUS_SUCCESS,
STATUS_PARTIAL,
STATUS_FAILED,
NOTE_SERVER_OVERRIDES_SKIPPED,
NOTE_UNKNOWN_DEPENDENCY,
NOTE_ENV_UNSUPPORTED,
NOTE_DISABLED_SUFFIX,
NOTE_OLD_SUFFIX,
NOTE_OPTIONAL_FILE,
NOTE_LOADER_MULTIPLE,
NOTE_REGISTERED,
NOTE_INSTANCE_CFG_MEMORY,
NOTE_BASELINE_UNREADABLE,
MISSING_OFFLINE,
MISSING_NO_SOURCE,
MISSING_FETCH_FAILED,
CONFLICT_USER_MODIFIED,
CONFLICT_USER_DELETED,
CONFLICT_PRE_EXISTING,
REJECTED_COMPONENT,
ROLLBACK_DONE,
];
+292
View File
@@ -0,0 +1,292 @@
//! 落地事务:**临时目录 + rename**,合并态用 journal 回滚(回滚细节在 [`commit`])。
//!
//! # 两种起点,两条事务路径
//!
//! | 起点 | 做法 | 失败后的样子 |
//! |------|------|--------------|
//! | 目标目录不存在 / 是空目录(全新导入) | 在**同卷兄弟目录**里把整个实例建好,最后一次 `rename` 到位 | 目标目录**从未出现**(rename 是原子的) |
//! | 目标目录已有内容(升级 / 重导) | 逐文件 `rename` 进目标;被替换的旧文件先改名成 `<dest>.ullbak-<pid>` 当备份 | journal 逆序回滚:新文件删掉、旧备份改回原名 |
//!
//! 为什么全新导入不逐文件搬:逐文件搬意味着"搬到一半失败"会留下一个**看起来存在**的
//! 半个实例,而 `rename` 一个目录是单步原子操作——失败就只有"没有"这一种结果。
//!
//! # 两道路径检查都在这层再走一遍
//!
//! 第一道是 M110 的 [`normalize_file`](七条写死规则:空名 / `\0` / `\` / `:` / 前导 `/` /
//! 任何 `..` 段 / 规整后为空),第二道是**落盘前的 `starts_with` 复核**(Modrinth 官方安装器
//! 同款做法:`install_mrpack.rs:169-170` 按 `strip_prefix(instance_root)` 再判一次)。
//! 模型可能来自外部构造,`zip` 侧的双检挡不住"调用方直接塞了个 `../x` 进来"。
pub mod commit;
pub use commit::CommitGuard;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use super::super::pack::path::normalize_file;
use super::super::pack::PackError;
use super::error::ImportError;
use crate::platform::atomic::write_atomic;
use commit::{rollback_merge, Applied};
/// 临时目录名里的进程内序号(同一进程并发导入不撞名)。
static SEQ: AtomicU64 = AtomicU64::new(0);
/// 目标目录的起点形态。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum Mode {
/// 全新:整目录 rename。
Fresh,
/// 合并:逐文件 rename + journal。
Merge,
}
/// 一个进行中的落地事务。
#[derive(Debug)]
pub struct Staging {
target: PathBuf,
temp: PathBuf,
mode: Mode,
/// 本次是否由我们创建了目标目录的父目录(回滚时顺手清掉,避免"空壳目录"残留)。
created_parent: bool,
}
impl Staging {
/// 开一个事务(临时目录立刻建好,目标目录此刻一个字节都没动)。
///
/// # Errors
///
/// 目标路径是文件、父目录建不出来、临时目录建不出来时返回 `pack_io`。
pub fn begin(target: &Path) -> Result<Self, ImportError> {
if target.is_file() {
return Err(io_error(format!(
"目标路径是个文件,不是目录:{}",
target.display()
)));
}
let parent = target
.parent()
.filter(|path| !path.as_os_str().is_empty())
.map_or_else(|| PathBuf::from("."), Path::to_path_buf);
let created_parent = !parent.exists();
std::fs::create_dir_all(&parent)
.map_err(|error| io_error(format!("建 {} 失败:{error}", parent.display())))?;
let name = target.file_name().map_or_else(
|| "instance".to_string(),
|name| name.to_string_lossy().to_string(),
);
let seq = SEQ.fetch_add(1, Ordering::Relaxed);
let temp = parent.join(format!(".{name}.ull-import-{}-{seq}", std::process::id()));
if temp.exists() {
std::fs::remove_dir_all(&temp).map_err(|error| {
io_error(format!("清旧临时目录 {} 失败:{error}", temp.display()))
})?;
}
std::fs::create_dir_all(&temp)
.map_err(|error| io_error(format!("建临时目录 {} 失败:{error}", temp.display())))?;
let mode = match std::fs::read_dir(target) {
Ok(mut entries) => {
if entries.next().is_some() {
Mode::Merge
} else {
Mode::Fresh
}
}
Err(_) => Mode::Fresh,
};
Ok(Self {
target: target.to_path_buf(),
temp,
mode,
created_parent,
})
}
/// 临时目录根(调试 / 测试断言用)。
#[must_use]
pub fn temp(&self) -> &Path {
&self.temp
}
/// 目标目录。
#[must_use]
pub fn target(&self) -> &Path {
&self.target
}
/// 是否合并态(目标目录已有内容)。
#[must_use]
pub const fn is_merge(&self) -> bool {
matches!(self.mode, Mode::Merge)
}
/// 规划一个暂存路径:走**两道**路径检查后返回可写的绝对路径。
///
/// # Errors
///
/// 路径穿越 / 空路径 / 规整后越出临时目录时返回 `pack_traversal`。
pub fn dest_for(&self, relative: &str) -> Result<PathBuf, ImportError> {
let normalized = normalize_file(relative)?;
let path = self.temp.join(&normalized);
// 第二检:规整之后仍要确认它真的在临时目录下面(不靠 `join` 的语义"讲道理")。
if !path.starts_with(&self.temp) {
return Err(PackError::traversal(format!("暂存路径越出临时目录:{relative:?}")).into());
}
Ok(path)
}
/// 写一个暂存文件(自动建父目录)。
///
/// # Errors
///
/// 路径不安全、写盘失败。
pub fn write(&self, relative: &str, bytes: &[u8]) -> Result<PathBuf, ImportError> {
let dest = self.dest_for(relative)?;
write_atomic(&dest, bytes)
.map_err(|error| io_error(format!("写 {} 失败:{error}", dest.display())))?;
Ok(dest)
}
/// 删掉一个暂存文件(例如发现内容与磁盘一致,不必落地)。
pub fn discard(&self, relative: &str) -> Result<(), ImportError> {
let dest = self.dest_for(relative)?;
match std::fs::remove_file(&dest) {
Ok(()) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(io_error(format!("删 {} 失败:{error}", dest.display()))),
}
}
/// 放弃事务:临时目录整个删掉,目标目录保持导入前的样子。
pub fn abort(self) {
self.abort_inner();
}
fn abort_inner(&self) {
let _ = std::fs::remove_dir_all(&self.temp);
if self.created_parent {
// 只删空目录:非空说明有别人用了,别动。
let _ = std::fs::remove_dir(self.temp.parent().unwrap_or(&self.temp));
}
}
/// 提交事务:返回一个**必须显式 `finish`** 的守卫(析构即回滚)。
///
/// # Errors
///
/// `rename` 失败、合并态逐文件搬动失败(此时已回滚)。
pub fn commit(self) -> Result<CommitGuard, ImportError> {
match self.mode {
Mode::Fresh => {
std::fs::rename(&self.temp, &self.target).map_err(|error| {
self.abort_inner();
io_error(format!(
"把 {} 改名成 {} 失败:{error}",
self.temp.display(),
self.target.display()
))
})?;
Ok(CommitGuard::fresh(self.target.clone(), self.temp.clone()))
}
Mode::Merge => {
let mut applied: Vec<Applied> = Vec::new();
match self.apply_merge(&mut applied) {
Ok(()) => Ok(CommitGuard::merge(
self.target.clone(),
self.temp.clone(),
applied,
)),
Err(error) => {
rollback_merge(&mut applied);
self.abort_inner();
Err(error)
}
}
}
}
}
/// 合并态:把临时目录里的文件逐个搬进目标目录(每个都带备份)。
fn apply_merge(&self, applied: &mut Vec<Applied>) -> Result<(), ImportError> {
let mut staged: Vec<PathBuf> = Vec::new();
collect_files(&self.temp, &mut staged)?;
staged.sort();
for source in staged {
let relative = source
.strip_prefix(&self.temp)
.map_err(|_| io_error(format!("暂存路径不在临时目录里:{}", source.display())))?;
let relative_text = relative.to_string_lossy().replace('\\', "/");
// 搬动前再规整一次(第三道,防"临时目录里被塞了奇怪的名字")。
let normalized = normalize_file(&relative_text)?;
let dest = self.target.join(&normalized);
if !dest.starts_with(&self.target) {
return Err(PackError::traversal(format!(
"落地路径越出实例目录:{relative_text:?}"
))
.into());
}
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)
.map_err(|error| io_error(format!("建 {} 失败:{error}", parent.display())))?;
}
let backup = if dest.exists() {
let backup = commit::backup_path(&dest);
std::fs::rename(&dest, &backup).map_err(|error| {
io_error(format!(
"备份 {} → {} 失败:{error}",
dest.display(),
backup.display()
))
})?;
Some(backup)
} else {
None
};
applied.push(Applied {
dest: dest.clone(),
backup,
moved: false,
});
std::fs::rename(&source, &dest).map_err(|error| {
io_error(format!(
"把 {} 搬到 {} 失败:{error}",
source.display(),
dest.display()
))
})?;
if let Some(last) = applied.last_mut() {
last.moved = true;
}
}
Ok(())
}
}
/// 递归收集目录下的全部文件(绝对路径)。
fn collect_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<(), ImportError> {
let entries = std::fs::read_dir(dir)
.map_err(|error| io_error(format!("读 {} 失败:{error}", dir.display())))?;
for entry in entries {
let entry =
entry.map_err(|error| io_error(format!("读 {} 失败:{error}", dir.display())))?;
let path = entry.path();
let file_type = entry
.file_type()
.map_err(|error| io_error(format!("读 {} 类型失败:{error}", path.display())))?;
if file_type.is_dir() {
collect_files(&path, out)?;
} else {
out.push(path);
}
}
Ok(())
}
/// 本地 IO 失败(统一包成 M110 的 `pack_io`,不再造新码)。
pub(super) fn io_error(message: impl Into<String>) -> ImportError {
PackError::io(message).into()
}
@@ -0,0 +1,143 @@
//! 提交守卫与回滚:**`Drop` 即回滚**,除非显式 `finish`。
//!
//! 靠调用方在每个错误分支手动清理,早晚会漏一条——所以把"回滚"绑在析构上:任何提前
//! `return`、`?` 冒泡、panic 展开都不会留下半个实例。
//!
//! 回滚分两种:
//!
//! - **全新导入**:目标目录整个是本事务建的 ⇒ 删干净就是零残留;
//! - **合并态**:按 journal **逆序**撤回——新文件删掉、被替换的旧文件从 `.ullbak-<pid>` 改回原名。
use std::path::{Path, PathBuf};
/// 备份后缀(合并态的 journal 备份文件)。
const BACKUP_TAG: &str = "ullbak";
/// 一条已应用的搬动记录。
#[derive(Debug)]
pub struct Applied {
/// 目标路径。
pub(super) dest: PathBuf,
/// 被替换下来的旧文件备份。
pub(super) backup: Option<PathBuf>,
/// 新文件是否已经搬到位。
pub(super) moved: bool,
}
/// 提交守卫:**析构即回滚**,除非显式 [`CommitGuard::finish`]。
#[derive(Debug)]
pub struct CommitGuard {
inner: Option<Inner>,
}
#[derive(Debug)]
struct Inner {
target: PathBuf,
temp: PathBuf,
/// 本次是否**新建**了目标目录(全新导入)。
created_target: bool,
applied: Vec<Applied>,
finished: bool,
}
impl CommitGuard {
/// 全新导入留下的守卫(目标目录已是 rename 后的成品)。
pub(super) fn fresh(target: PathBuf, temp: PathBuf) -> Self {
Self {
inner: Some(Inner {
target,
temp,
created_target: true,
applied: Vec::new(),
finished: false,
}),
}
}
/// 合并态留下的守卫(`applied` 是已搬进去的 journal)。
pub(super) fn merge(target: PathBuf, temp: PathBuf, applied: Vec<Applied>) -> Self {
Self {
inner: Some(Inner {
target,
temp,
created_target: false,
applied,
finished: false,
}),
}
}
/// 目标目录。
#[must_use]
pub fn target(&self) -> &Path {
self.inner
.as_ref()
.map_or(Path::new(""), |inner| inner.target.as_path())
}
/// 确认提交:删备份、清临时目录。
pub fn finish(mut self) {
if let Some(inner) = self.inner.as_mut() {
for applied in &inner.applied {
if let Some(backup) = &applied.backup {
let _ = std::fs::remove_file(backup);
}
}
let _ = std::fs::remove_dir_all(&inner.temp);
inner.finished = true;
}
}
/// 显式回滚(`Drop` 也会做,这里给"想马上回滚并继续"的调用方一个明确入口)。
pub fn rollback(mut self) {
if let Some(inner) = self.inner.as_mut() {
rollback_inner(inner);
}
}
}
impl Drop for CommitGuard {
fn drop(&mut self) {
if let Some(inner) = self.inner.as_mut() {
if !inner.finished {
rollback_inner(inner);
}
}
}
}
/// 回滚一次提交。
fn rollback_inner(inner: &mut Inner) {
if inner.created_target {
// 全新导入:目标目录整个是本事务建的,删干净 = 零残留。
let _ = std::fs::remove_dir_all(&inner.target);
} else {
rollback_merge(&mut inner.applied);
}
let _ = std::fs::remove_dir_all(&inner.temp);
inner.finished = true;
}
/// 合并态回滚:新文件删掉、备份改回原名。
pub(super) fn rollback_merge(applied: &mut [Applied]) {
for entry in applied.iter().rev() {
if entry.moved {
let _ = std::fs::remove_file(&entry.dest);
}
if let Some(backup) = &entry.backup {
if backup.exists() {
let _ = std::fs::rename(backup, &entry.dest);
}
}
}
}
/// 备份路径:`<dest>.ullbak-<pid>`。
pub(super) fn backup_path(dest: &Path) -> PathBuf {
let mut name = dest.file_name().map_or_else(
|| std::ffi::OsString::from("file"),
std::ffi::OsStr::to_os_string,
);
name.push(format!(".{BACKUP_TAG}-{}", std::process::id()));
dest.with_file_name(name)
}
+13 -1
View File
@@ -10,13 +10,22 @@
//! → 补库清单,落盘与实例登记复用 `core::game::install`(M27)。
//! - [`pack`](P2 · M110):整合包的**格式无关模型**与**格式扩展点**——CF / Modrinth / MultiMC
//! 三种格式的探测与读取、CF / mrpack 的渲染与导出、zip 安全读取(穿越双检 / 符号链接 /
//! 条数体积上限)。导入导出**管线**(补全、解压、合并)是 M111/M112,不在这里。
//! 条数体积上限)。
//! - [`import`](P2 · M111):**导入管线**——三种格式 → 实例目录:跨段落地、overrides 三方
//! 合并(保用户改动)、两阶段补全与失败分级、临时目录 + rename/journal 回滚、结构化报告。
//! 导出(M112)另起模块。
//!
//! 规划中:本地 mod 管理(D10 §3)、CF/Modrinth 客户端(§5)。
pub mod import;
pub mod loader;
pub mod pack;
pub use import::{
import_pack, ConflictFile, ConflictPolicy, ImportError, ImportErrorKind, ImportProgress,
ImportProgressSink, ImportReport, ImportRequest, ImportStage, ImportStatus, ImportedFile,
MissingFile, PackBaseline, PackFetcher, RejectedComponent,
};
pub use loader::{
LoaderError, LoaderErrorKind, LoaderInstallPlan, LoaderKind, LoaderPlanInput, LoaderRelease,
LoaderSource, LoaderSupport,
@@ -25,3 +34,6 @@ pub use pack::{
EnvSupport, PackArchive, PackError, PackErrorKind, PackFile, PackFormat, PackLimits,
PackLoader, PackManifest, PackOverrides, PackRegistry, PackResult, PackSide,
};
#[cfg(test)]
mod tests;
@@ -0,0 +1,134 @@
//! CurseForge `manifest.json` 端到端导入(离线,stub fetcher 充当 CF API + CDN)。
//!
//! 夹具是 M110 的手造包(`fixtures::curseforge`):`overrides/` 里有一个**刻意的截断 TOML**
//! (`config/broken.toml`)与一个假 jar。导入器不许因为坏内容崩,也不许悄悄丢掉它们。
use crate::addon::import::{import_pack, ImportRequest, ImportStatus};
use crate::addon::loader::LoaderKind;
use crate::download::blocking::block_on;
use super::support::{curse_meta, StubFetcher, CURSE_URL};
use super::{fixtures, list_tree, read_text, temp_root};
/// CF 夹具里那两个条目补全出来的内容。
const CURSE_BYTES: &[u8] = b"stub curse file bytes";
#[test]
fn imports_curseforge_pack_end_to_end() {
let fixture = fixtures::curseforge("cf-e2e");
let target = temp_root("cf-e2e-out").join("instance");
let fetcher = StubFetcher::new()
.with_curse(238222, 5102025, curse_meta("jei.jar", CURSE_BYTES))
.with_curse(306612, 4587454, curse_meta("fabric-api.jar", CURSE_BYTES))
.with_file(CURSE_URL, CURSE_BYTES);
let request = ImportRequest::new(&fixture.zip, &target).with_fetcher(&fetcher);
let report = block_on(import_pack(&request));
assert_eq!(
report.status,
ImportStatus::Success,
"状态 {:?},缺文件 {:?},冲突 {:?},错误 {:?}",
report.status,
report.missing,
report.conflicts,
report.error
);
assert_eq!(report.format.as_deref(), Some("curseforge"));
assert_eq!(report.pack_name.as_deref(), Some(fixtures::CF_NAME));
assert_eq!(report.pack_version.as_deref(), Some("1.0.0"));
assert_eq!(report.game_version.as_deref(), Some("1.20.1"));
assert!(!report.rolled_back);
// 加载器被翻译成 addon::loader 的三元组(不另写一套安装逻辑)。
let loader = report.loader.as_ref().expect("CF 夹具带 forge");
assert_eq!(loader.kind, LoaderKind::Forge);
assert_eq!(loader.loader_version, "47.2.0");
assert_eq!(loader.version_id, "1.20.1-forge-47.2.0");
assert_eq!(
loader.resolve_args(),
(LoaderKind::Forge, "1.20.1", "47.2.0")
);
assert_eq!(report.version_id.as_deref(), Some("1.20.1-forge-47.2.0"));
// 覆盖内容(`overrides/` 的内容铺到实例根,不多一层目录)。
assert!(read_text(&target.join("config/known.toml")).contains("key"));
// 刻意的截断 TOML 必须原样落地:导入器不是校验器,不许因为内容坏就丢文件。
assert_eq!(
read_text(&target.join("config/broken.toml")),
String::from_utf8_lossy(fixtures::CF_BROKEN)
);
// 两个 CF 条目都被补全 + 下载(文件名与目录都来自 CF 元数据)。
assert_eq!(
std::fs::read(target.join("mods/jei.jar")).expect("jei.jar"),
CURSE_BYTES
);
assert_eq!(
std::fs::read(target.join("mods/fabric-api.jar")).expect("fabric-api.jar"),
CURSE_BYTES
);
assert_eq!(
fetcher.calls(),
vec![CURSE_URL.to_string(), CURSE_URL.to_string()]
);
// 基线写进去了(下次导入的 base 侧)。
let baseline = read_text(&target.join(".ULL/pack-baseline.json"));
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(),
"config/broken.toml".to_string(),
"config/known.toml".to_string(),
"mods/example.jar".to_string(),
"mods/fabric-api.jar".to_string(),
"mods/jei.jar".to_string(),
]
);
}
#[test]
fn offline_curseforge_import_is_partial_not_success() {
// 没有 fetcher ⇒ CF 的 files[] 一条都补不出来。这**不是**失败,也**不许**记成成功。
let fixture = fixtures::curseforge("cf-offline");
let target = temp_root("cf-offline-out").join("instance");
let request = ImportRequest::new(&fixture.zip, &target);
let report = block_on(import_pack(&request));
assert_eq!(report.status, ImportStatus::Partial);
assert_eq!(report.missing.len(), 2, "{:?}", report.missing);
assert!(report
.missing
.iter()
.all(|missing| missing.message_key == crate::addon::import::keys::MISSING_OFFLINE));
// 覆盖内容照装(离线不影响包内自带的内容)。
assert!(target.join("config/known.toml").is_file());
assert!(!target.join("mods/jei.jar").exists());
// 3 = Partial 的退出码(CLI 契约)。
assert_eq!(report.status.exit_code(), 3);
}
#[test]
fn curseforge_meta_without_download_url_is_missing_not_guessed() {
// CF 作者可以关掉 API 分发(`downloadUrl` 为 null):我们**不猜** URL,只如实记缺。
let fixture = fixtures::curseforge("cf-nourl");
let target = temp_root("cf-nourl-out").join("instance");
let mut meta = curse_meta("jei.jar", CURSE_BYTES);
meta.url = None;
let fetcher = StubFetcher::new()
.with_curse(238222, 5102025, meta)
.with_file(CURSE_URL, CURSE_BYTES);
let request = ImportRequest::new(&fixture.zip, &target).with_fetcher(&fetcher);
let report = block_on(import_pack(&request));
assert_eq!(report.status, ImportStatus::Partial);
assert!(report
.missing
.iter()
.any(|missing| missing.message_key == crate::addon::import::keys::MISSING_NO_SOURCE));
// 没有发出任何下载请求。
assert!(fetcher.calls().is_empty(), "{:?}", fetcher.calls());
}
@@ -0,0 +1,254 @@
//! **必须先失败**的样本:四条恶意/坏包 + 两条"证明双检真的在导入链上跑"的用例。
//!
//! # 复审要的那张表
//!
//! | 样本 | 期望结果 | 断言在哪 |
//! |------|----------|----------|
//! | 路径穿越条目(`../evil.jar`) | `Failed` + `pack_traversal` + 目标目录零残留 | [`traversal_entry_rolls_back_with_zero_residue`] |
//! | 符号链接条目 | `Failed` + `pack_symlink` + 零残留 | [`symlink_entry_rolls_back_with_zero_residue`] |
//! | 损坏清单(非法 JSON) | `Failed` + `pack_metadata` + 零残留 | [`corrupt_manifest_rolls_back_with_zero_residue`] |
//! | 声明与实际不符(sha1 不符) | `Failed` + `import_integrity` + 零残留 | [`declared_hash_mismatch_rolls_back_everything`] |
//!
//! # 零残留的验证方式
//!
//! [`assert_clean`]:①目标目录不存在;②目标目录的父目录里没有 `.ull-import-*` 临时目录
//! (临时目录是"事务没收拾干净"的唯一痕迹);③合并态用例另外断言目标目录内容与导入前
//! **逐项相同**。三条合起来就是"失败后既没有半个实例,也没有残留垃圾"。
//!
//! # 两条双检证明
//!
//! 路径穿越是**两条独立检查**(M110 `pack::path::normalize_file` + `zip` 的
//! `enclosed_name()`)。要证明两条都真的接在导入链上,得各给一条**只有其中一条能拦**的样本:
//!
//! - 绝对路径 `/etc/passwd`:`enclosed_name()` 在深度 0 处**放行**(它允许根目录),只有第一检
//! 能拦 ⇒ 它失败就说明第一检活着;
//! - Windows 设备名 `NUL`:第一检七条规则全放行,只有第二检(按 Win32 命名空间解析)能拦
//! ⇒ 它失败就说明第二检活着(该用例仅在 Windows 宿主上有效,与 M110 的用例同构)。
use std::path::Path;
use crate::addon::import::{import_pack, ImportRequest, ImportStatus};
use crate::download::blocking::block_on;
use super::support::{StubFetcher, JEI_BYTES, JEI_PLACEHOLDER_URL, OPTIONAL_PLACEHOLDER_URL};
use super::{
fixtures, list_tree, read_text, temp_root, write_file, write_zip, write_zip_with_symlink,
};
/// 一份形状合法的最小 CF 清单(恶意包要能被"探测成功",失败必须发生在它该发生的阶段)。
const CF_MANIFEST: &[u8] = br#"{"manifestType": "minecraftModpack", "manifestVersion": 1,
"name": "evil", "version": "1.0.0", "overrides": "overrides",
"minecraft": {"version": "1.20.1", "modLoaders": []}, "files": []}"#;
/// 断言"目标目录零残留":目录不存在、父目录里没有临时目录。
fn assert_clean(target: &Path) {
assert!(
!target.exists(),
"目标目录必须不存在(零残留),实际:{:?}",
list_tree(target)
);
let parent = target.parent().expect("target 有父目录");
if let Ok(entries) = std::fs::read_dir(parent) {
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
assert!(!name.contains(".ull-import-"), "临时目录没清掉:{name}");
}
}
}
fn failing(tag: &str, entries: &[(&str, &[u8])]) -> (std::path::PathBuf, std::path::PathBuf) {
let root = temp_root(tag);
let pack = root.join("evil.zip");
write_zip(&pack, entries);
let target = root.join("instance");
(pack, target)
}
#[test]
fn traversal_entry_rolls_back_with_zero_residue() {
let (pack, target) = failing(
"fail-traversal",
&[("manifest.json", CF_MANIFEST), ("../evil.jar", b"payload")],
);
let report = block_on(import_pack(&ImportRequest::new(&pack, &target)));
assert_eq!(report.status, ImportStatus::Failed);
assert_eq!(
report.error.as_ref().expect("具名错误").code(),
"pack_traversal"
);
assert!(report.rolled_back);
assert_clean(&target);
assert_eq!(report.status.exit_code(), 1);
}
#[test]
fn absolute_entry_proves_the_first_check_is_live() {
// `enclosed_name()` 会放行深度 0 的绝对路径,只有 `normalize_file` 能拦。
let (pack, target) = failing(
"fail-absolute",
&[
("manifest.json", CF_MANIFEST),
("/etc/passwd", b"root::0:0"),
],
);
let report = block_on(import_pack(&ImportRequest::new(&pack, &target)));
assert_eq!(report.status, ImportStatus::Failed);
assert_eq!(
report.error.as_ref().expect("具名错误").code(),
"pack_traversal"
);
assert_clean(&target);
}
#[cfg(windows)]
#[test]
fn windows_device_name_proves_the_second_check_is_live() {
// `NUL` 过得了第一检的七条规则,只有 `zip` 的 `enclosed_name()`(Win32 命名空间)会拦。
let (pack, target) = failing(
"fail-device",
&[("manifest.json", CF_MANIFEST), ("mods/NUL", b"x")],
);
let report = block_on(import_pack(&ImportRequest::new(&pack, &target)));
assert_eq!(report.status, ImportStatus::Failed);
assert_eq!(
report.error.as_ref().expect("具名错误").code(),
"pack_traversal"
);
assert_clean(&target);
}
#[test]
fn symlink_entry_rolls_back_with_zero_residue() {
let root = temp_root("fail-symlink");
let pack = root.join("evil.zip");
let entries: [(&str, &[u8]); 1] = [("manifest.json", CF_MANIFEST)];
write_zip_with_symlink(&pack, &entries, "mods/evil.jar", "../../../etc/passwd");
let target = root.join("instance");
let report = block_on(import_pack(&ImportRequest::new(&pack, &target)));
assert_eq!(report.status, ImportStatus::Failed);
assert_eq!(
report.error.as_ref().expect("具名错误").code(),
"pack_symlink"
);
assert_clean(&target);
}
#[test]
fn corrupt_manifest_rolls_back_with_zero_residue() {
let (pack, target) = failing(
"fail-json",
&[
("manifest.json", b"{\"manifestType\": \"minecraftModpack\","),
("overrides/config/a.toml", b"k = 1\n"),
],
);
let report = block_on(import_pack(&ImportRequest::new(&pack, &target)));
assert_eq!(report.status, ImportStatus::Failed);
let code = report.error.as_ref().expect("具名错误").code();
assert!(
code == "pack_metadata" || code == "pack_unsupported",
"坏 JSON 要报成因,实际 {code}"
);
assert_clean(&target);
}
#[test]
fn declared_hash_mismatch_rolls_back_everything() {
// 原始 M110 夹具:`hashes.sha1` 是形状正确的**占位值**,必然不等于 stub 给的字节。
// 这是"声明与实际不符"——它**不是**"暂时搞不到",必须整包回滚。
let fixture = fixtures::modrinth("fail-integrity");
let root = temp_root("fail-integrity-out");
let target = root.join("instance");
let fetcher = StubFetcher::new()
.with_file(JEI_PLACEHOLDER_URL, JEI_BYTES)
.with_file(OPTIONAL_PLACEHOLDER_URL, b"stub optional");
let report = block_on(import_pack(
&ImportRequest::new(&fixture.zip, &target).with_fetcher(&fetcher),
));
assert_eq!(
report.status,
ImportStatus::Failed,
"缺文件 {:?}",
report.missing
);
assert_eq!(
report.error.as_ref().expect("具名错误").code(),
"import_integrity"
);
assert!(report.rolled_back);
assert_clean(&target);
}
#[test]
fn fatal_error_in_merge_mode_leaves_the_target_untouched() {
// 目标目录已有内容(升级/重导):致命错误时**只**清临时目录,不动目标目录。
let (pack, target) = failing(
"fail-merge",
&[("manifest.json", CF_MANIFEST), ("../evil.jar", b"payload")],
);
write_file(&target.join("keep.txt"), b"untouched");
write_file(&target.join("mods/mine.jar"), b"mine");
let before = list_tree(&target);
let report = block_on(import_pack(&ImportRequest::new(&pack, &target)));
assert_eq!(report.status, ImportStatus::Failed);
assert!(report.rolled_back);
assert_eq!(list_tree(&target), before, "目标目录必须逐项不变");
assert_eq!(
std::fs::read(target.join("keep.txt")).expect("keep.txt"),
b"untouched"
);
}
#[test]
fn commit_failure_rolls_back_partially_applied_files() {
// 让合并态的**提交阶段**在中途失败:目标文件要被替换时必须先备份成
// `<dest>.ullbak-<pid>`,而那个位置已被一个**非空目录**占住 ⇒ `rename(dest → backup)`
// 必然失败(文件改不成非空目录,Windows / Unix 一致)。
// 这覆盖 journal 回滚 + `Drop` 兜底:前半截已经搬进去的文件(基线排最前)必须被撤回。
let root = temp_root("fail-commit");
let source = fixtures::multimc("fail-commit-src");
let target = root.join("instance");
let first = block_on(import_pack(&ImportRequest::new(&source, &target)));
assert_eq!(first.status, ImportStatus::Success, "{:?}", first.error);
// 新版本包内容变了(用户没动过 ⇒ 判据是 Install,会被暂存并尝试替换)。
write_file(
&source.join(".minecraft/config/known.toml"),
b"key = \"v2\"\n",
);
// 备份位置被非空目录占住。
let blocker = target.join(format!("config/known.toml.ullbak-{}", std::process::id()));
write_file(&blocker.join("blocker.txt"), b"block");
let before = list_tree(&target);
let second = block_on(import_pack(&ImportRequest::new(&source, &target)));
assert_eq!(
second.status,
ImportStatus::Failed,
"提交阶段失败必须整包回滚"
);
assert_eq!(
second.error.as_ref().expect("具名错误").code(),
"pack_io",
"{}",
second.error.as_ref().expect("具名错误").message()
);
assert!(second.rolled_back);
assert_eq!(list_tree(&target), before, "目标目录必须逐项回到导入前");
// 提交阶段排在最前面的基线被成功搬入又被撤回:内容仍是第一次导入的那份。
assert!(target.join(".ULL/pack-baseline.json").is_file());
assert_eq!(
read_text(&target.join("config/known.toml")),
"key = \"value\"\n",
"旧内容必须还在(新内容没落地)"
);
}
+204
View File
@@ -0,0 +1,204 @@
//! overrides 三方合并的**行为**测试:用户改过 / 用户删过 / 没动过 / 没有基线 / 侧优先级。
//!
//! 判据本身(纯函数)在 `import::merge` 的单元测试里;这里验证的是"判据真的接在管线上了":
//! 真写盘、真重导、真读回内容。
use crate::addon::import::{
import_pack, ConflictChoice, ConflictPolicy, ConflictReason, FileAction, ImportRequest,
ImportStatus,
};
use crate::download::blocking::block_on;
use super::support::mrpack_stub;
use super::{read_text, temp_root, write_file, zip_dir};
#[test]
fn user_modified_file_is_kept_and_reported() {
let (fixture, fetcher) = mrpack_stub("merge-modified");
let target = temp_root("merge-modified-out").join("instance");
let first = block_on(import_pack(
&ImportRequest::new(&fixture.zip, &target).with_fetcher(&fetcher),
));
assert_eq!(first.status, ImportStatus::Success, "{:?}", first.error);
// 用户动手改了一个包内文件。
write_file(&target.join("config/known.toml"), b"shared = false\n");
let second = block_on(import_pack(
&ImportRequest::new(&fixture.zip, &target).with_fetcher(&fetcher),
));
assert_eq!(
second.status,
ImportStatus::Partial,
"保留用户文件 ⇒ 部分成功"
);
assert_eq!(second.conflicts.len(), 1, "{:?}", second.conflicts);
let conflict = &second.conflicts[0];
assert_eq!(conflict.path, "config/known.toml");
assert_eq!(conflict.reason, ConflictReason::UserModified);
assert_eq!(conflict.choice, ConflictChoice::KeepUser);
assert_eq!(
conflict.message_key,
crate::addon::import::keys::CONFLICT_USER_MODIFIED
);
assert!(conflict.base_sha1.is_some(), "基线里应有上次的哈希");
assert!(conflict.disk_sha1.is_some());
// 磁盘内容没被盖掉。
assert_eq!(
read_text(&target.join("config/known.toml")),
"shared = false\n"
);
// 报告里这条的结局是"保留用户"。
assert!(second
.files
.iter()
.any(|file| file.path == "config/known.toml" && file.action == FileAction::KeptUser));
assert_eq!(second.status.exit_code(), 3);
}
#[test]
fn user_deleted_file_is_not_restored() {
let (fixture, fetcher) = mrpack_stub("merge-deleted");
let target = temp_root("merge-deleted-out").join("instance");
let first = block_on(import_pack(
&ImportRequest::new(&fixture.zip, &target).with_fetcher(&fetcher),
));
assert_eq!(first.status, ImportStatus::Success, "{:?}", first.error);
// 用户删了一个包内文件。
std::fs::remove_file(target.join("mods/half.jar")).expect("删夹具文件");
let second = block_on(import_pack(
&ImportRequest::new(&fixture.zip, &target).with_fetcher(&fetcher),
));
assert_eq!(second.status, ImportStatus::Partial);
let conflict = second
.conflicts
.iter()
.find(|conflict| conflict.path == "mods/half.jar")
.expect("删除也要进冲突清单");
assert_eq!(conflict.reason, ConflictReason::UserDeleted);
assert_eq!(conflict.choice, ConflictChoice::KeepUser);
assert_eq!(conflict.disk_sha1, None);
assert!(
!target.join("mods/half.jar").exists(),
"用户删掉的文件不许偷偷装回来"
);
}
#[test]
fn untouched_file_is_upgraded_by_a_newer_pack() {
let (fixture, fetcher) = mrpack_stub("merge-upgrade");
let target = temp_root("merge-upgrade-out").join("instance");
let first = block_on(import_pack(
&ImportRequest::new(&fixture.zip, &target).with_fetcher(&fetcher),
));
assert_eq!(first.status, ImportStatus::Success, "{:?}", first.error);
// 新版本包:同一个路径,内容变了;用户没动过。
write_file(
&fixture.dir.join("overrides/config/known.toml"),
b"shared = 2\n",
);
zip_dir(&fixture.dir, &fixture.zip);
let second = block_on(import_pack(
&ImportRequest::new(&fixture.zip, &target).with_fetcher(&fetcher),
));
assert_eq!(
second.status,
ImportStatus::Success,
"未改过的文件升级不算冲突"
);
assert!(second.conflicts.is_empty(), "{:?}", second.conflicts);
assert_eq!(read_text(&target.join("config/known.toml")), "shared = 2\n");
assert!(second
.files
.iter()
.any(|file| file.path == "config/known.toml" && file.action == FileAction::Replaced));
}
#[test]
fn pre_existing_file_without_baseline_is_kept() {
let (fixture, fetcher) = mrpack_stub("merge-existing");
let target = temp_root("merge-existing-out").join("instance");
// 首次导入前磁盘上就有同名文件:没有基线可判 ⇒ 最安全的一侧(不覆盖)。
write_file(&target.join("config/known.toml"), b"mine\n");
let report = block_on(import_pack(
&ImportRequest::new(&fixture.zip, &target).with_fetcher(&fetcher),
));
assert_eq!(report.status, ImportStatus::Partial);
let conflict = report
.conflicts
.iter()
.find(|conflict| conflict.path == "config/known.toml")
.expect("要有冲突清单");
assert_eq!(conflict.reason, ConflictReason::PreExisting);
assert!(conflict.base_sha1.is_none());
assert_eq!(read_text(&target.join("config/known.toml")), "mine\n");
}
#[test]
fn overwrite_policy_replaces_user_file_and_reports_the_choice() {
let (fixture, fetcher) = mrpack_stub("merge-overwrite");
let target = temp_root("merge-overwrite-out").join("instance");
let first = block_on(import_pack(
&ImportRequest::new(&fixture.zip, &target).with_fetcher(&fetcher),
));
assert_eq!(first.status, ImportStatus::Success, "{:?}", first.error);
write_file(&target.join("config/known.toml"), b"shared = false\n");
let report = block_on(import_pack(
&ImportRequest::new(&fixture.zip, &target)
.with_fetcher(&fetcher)
.with_conflict_policy(ConflictPolicy::Overwrite),
));
assert_eq!(report.status, ImportStatus::Success, "覆盖后结果是干净的");
let conflict = report
.conflicts
.iter()
.find(|conflict| conflict.path == "config/known.toml")
.expect("冲突仍要记账");
assert_eq!(conflict.reason, ConflictReason::UserModified);
assert_eq!(conflict.choice, ConflictChoice::Overwrite);
assert_eq!(
read_text(&target.join("config/known.toml")),
"shared = true\n"
);
}
#[test]
fn client_overrides_wins_over_overrides() {
// Modrinth 官方客户端语义:`client-overrides/` 后写 = 胜(HMCL 恰好相反)。
let root = temp_root("merge-priority");
let dir = root.join("src");
write_file(
&dir.join("modrinth.index.json"),
br#"{"game": "minecraft", "formatVersion": 1, "versionId": "1.0.0",
"name": "dup", "files": [], "dependencies": {"minecraft": "1.20.1"}}"#,
);
write_file(&dir.join("overrides/config/dup.toml"), b"from-overrides\n");
write_file(
&dir.join("client-overrides/config/dup.toml"),
b"from-client\n",
);
write_file(
&dir.join("server-overrides/config/dup.toml"),
b"from-server\n",
);
let pack = root.join("pack.mrpack");
zip_dir(&dir, &pack);
let target = root.join("instance");
let report = block_on(import_pack(&ImportRequest::new(&pack, &target)));
assert_eq!(report.status, ImportStatus::Success, "{:?}", report.error);
assert_eq!(read_text(&target.join("config/dup.toml")), "from-client\n");
assert!(
report.conflicts.is_empty(),
"侧优先级不是冲突:{:?}",
report.conflicts
);
}
@@ -0,0 +1,150 @@
//! Modrinth `.mrpack` 端到端导入:三目录优先级、`.disabled` 后缀、依赖翻译。
//!
//! 成功路径吃 [`super::support::mrpack_stub`](同一份 M110 夹具,只把 `sha1`/`downloads`
//! 换成真实值);"声明与实际不符"那条用例用**原始**夹具,见 `import_failures.rs`。
use crate::addon::import::{import_pack, keys, ImportRequest, ImportStatus};
use crate::addon::loader::LoaderKind;
use crate::download::blocking::block_on;
use super::support::{mrpack_stub, JEI_BYTES, OPTIONAL_BYTES};
use super::{fixtures, list_tree, read_text, temp_root};
#[test]
fn imports_mrpack_end_to_end() {
let (fixture, fetcher) = mrpack_stub("mr-e2e");
let target = temp_root("mr-e2e-out").join("instance");
let request = ImportRequest::new(&fixture.zip, &target).with_fetcher(&fetcher);
let report = block_on(import_pack(&request));
assert_eq!(
report.status,
ImportStatus::Success,
"缺文件 {:?} 冲突 {:?} 错误 {:?}",
report.missing,
report.conflicts,
report.error
);
assert_eq!(report.format.as_deref(), Some("modrinth"));
assert_eq!(report.pack_name.as_deref(), Some(fixtures::MR_NAME));
assert_eq!(report.pack_version.as_deref(), Some("2.0.0"));
assert_eq!(report.game_version.as_deref(), Some("1.20.1"));
let loader = report.loader.as_ref().expect("mrpack 带 fabric-loader");
assert_eq!(loader.kind, LoaderKind::Fabric);
assert_eq!(loader.loader_version, "0.15.11");
assert_eq!(loader.version_id, "1.20.1-fabric-0.15.11");
// 覆盖内容:`overrides/` + `client-overrides/` 落地,`server-overrides/` **不落地**。
assert_eq!(
std::fs::read(target.join("mods/half.jar")).expect("half.jar"),
fixtures::MR_HALF_JAR
);
assert!(target.join("config/known.toml").is_file());
assert!(target.join("config/client-only.toml").is_file());
assert!(
!target.join("config/server.properties").exists(),
"server-overrides/ 客户端不许解压(Modrinth 官方语义)"
);
// 远端条目:路径与地址都在清单里,内容来自 stub。
assert_eq!(
std::fs::read(target.join("mods/jei.jar")).expect("jei.jar"),
JEI_BYTES
);
assert_eq!(
std::fs::read(target.join("mods/optional.jar")).expect("optional.jar"),
OPTIONAL_BYTES
);
// 三条注记:服务端覆盖被跳过 / 不认识的依赖键 / 可选条目不等于禁用。
let note_keys: Vec<&str> = report
.warnings
.iter()
.map(|note| note.message_key)
.collect();
assert!(
note_keys.contains(&keys::NOTE_SERVER_OVERRIDES_SKIPPED),
"{note_keys:?}"
);
assert!(
note_keys.contains(&keys::NOTE_UNKNOWN_DEPENDENCY),
"parchment 这类键要如实告警:{note_keys:?}"
);
assert!(
note_keys.contains(&keys::NOTE_OPTIONAL_FILE),
"env.client=optional 要提示但照装:{note_keys:?}"
);
assert_eq!(
list_tree(&target),
vec![
".ULL/pack-baseline.json".to_string(),
"config/client-only.toml".to_string(),
"config/known.toml".to_string(),
"mods/half.jar".to_string(),
"mods/jei.jar".to_string(),
"mods/optional.jar".to_string(),
]
);
}
#[test]
fn disabled_suffix_is_preserved_verbatim() {
// 四种 `.disabled` / `.old` 变体都按原后缀落地,不改名、不清理(tower 定案)。
let (fixture, fetcher) = mrpack_stub("mr-disabled");
super::write_file(
&fixture.dir.join("overrides/mods/variant.jar.disabled"),
b"disabled bytes",
);
super::write_file(
&fixture.dir.join("overrides/mods/variant.jar.disabled.old"),
b"disabled old bytes",
);
super::write_file(
&fixture.dir.join("overrides/mods/variant.jar.old"),
b"old bytes",
);
super::zip_dir(&fixture.dir, &fixture.zip);
let target = temp_root("mr-disabled-out").join("instance");
let request = ImportRequest::new(&fixture.zip, &target).with_fetcher(&fetcher);
let report = block_on(import_pack(&request));
assert_eq!(report.status, ImportStatus::Success, "{:?}", report.error);
for name in [
"mods/variant.jar.disabled",
"mods/variant.jar.disabled.old",
"mods/variant.jar.old",
] {
assert!(target.join(name).is_file(), "{name} 应当按原后缀落地");
}
// 禁用位从后缀读出来,`.old` 不被当成禁用。
let disabled: Vec<(&str, bool)> = report
.files
.iter()
.filter(|file| file.path.starts_with("mods/variant"))
.map(|file| (file.path.as_str(), file.disabled))
.collect();
assert_eq!(
disabled,
vec![
("mods/variant.jar.disabled", true),
("mods/variant.jar.disabled.old", true),
("mods/variant.jar.old", false),
]
);
let note_keys: Vec<&str> = report
.files
.iter()
.filter(|file| file.path.starts_with("mods/variant"))
.filter_map(|file| file.note.as_ref().map(|note| note.message_key))
.collect();
assert!(
note_keys.contains(&keys::NOTE_DISABLED_SUFFIX),
"{note_keys:?}"
);
assert!(note_keys.contains(&keys::NOTE_OLD_SUFFIX), "{note_keys:?}");
// 报告里的内容仍是原文(没有"读出来再写一遍"导致的空白变化)。
assert_eq!(read_text(&target.join("mods/variant.jar.old")), "old bytes");
}
@@ -0,0 +1,209 @@
//! MultiMC / Prism 实例目录导入:`.minecraft/` 直拷、component 逐项交代、`instance.cfg` 取内存。
use serde_json::Value;
use crate::addon::import::{import_pack, keys, ImportRequest, ImportStatus};
use crate::addon::loader::LoaderKind;
use crate::download::blocking::block_on;
use super::{fixtures, list_tree, read_text, temp_root, write_file};
#[test]
fn imports_multimc_instance_end_to_end() {
let source = fixtures::multimc("mmc-e2e");
let target = temp_root("mmc-e2e-out").join("instance");
let request = ImportRequest::new(&source, &target);
let report = block_on(import_pack(&request));
assert_eq!(report.status, ImportStatus::Success, "{:?}", report.error);
assert_eq!(report.format.as_deref(), Some("multimc"));
assert_eq!(report.pack_name.as_deref(), Some(fixtures::MMC_NAME));
assert_eq!(report.game_version.as_deref(), Some("1.20.1"));
let loader = report.loader.as_ref().expect("fabric-loader 组件");
assert_eq!(loader.kind, LoaderKind::Fabric);
assert_eq!(loader.loader_version, "0.15.11");
assert_eq!(loader.version_id, "1.20.1-fabric-0.15.11");
// MultiMC 没有远端条目:内容就是目录。
assert!(report.missing.is_empty());
assert!(report
.files
.iter()
.all(|file| file.source == crate::addon::import::FileSource::Overrides));
// `.minecraft/` 直拷到实例根(不多一层 `.minecraft/`),零字节 jar 也照拷。
assert_eq!(
std::fs::read(target.join("mods/example.jar")).expect("example.jar"),
b"PK\x03\x04 hand-made jar stub"
);
assert_eq!(
std::fs::metadata(target.join("mods/broken.jar"))
.expect("broken.jar")
.len(),
0,
"零字节 jar 是夹具刻意造的:导入器不许悄悄丢掉它"
);
assert!(target.join("config/known.toml").is_file());
assert_eq!(
list_tree(&target),
vec![
".ULL/pack-baseline.json".to_string(),
"config/known.toml".to_string(),
"mods/broken.jar".to_string(),
"mods/example.jar".to_string(),
]
);
}
#[test]
fn unsupported_components_are_listed_one_by_one_not_dropped() {
let source = fixtures::multimc("mmc-components");
let target = temp_root("mmc-components-out").join("instance");
let request = ImportRequest::new(&source, &target);
let report = block_on(import_pack(&request));
// 夹具里的 `org.lwjgl3` 不是加载器 ⇒ 必须逐项进"未支持清单"。
assert_eq!(
report.rejected_components.len(),
1,
"{:?}",
report.rejected_components
);
let rejected = &report.rejected_components[0];
assert_eq!(rejected.uid, "org.lwjgl3");
assert_eq!(rejected.version.as_deref(), Some("3.3.2"));
assert_eq!(rejected.message_key, keys::REJECTED_COMPONENT);
// **不静默丢**的断言:`mmc-pack.json` 里每一个 component 都能在报告里找到归宿。
let pack: Value =
serde_json::from_str(&read_text(&source.join("mmc-pack.json"))).expect("解析 mmc-pack");
let components = pack["components"].as_array().expect("components 数组");
assert!(!components.is_empty());
for component in components {
let uid = component["uid"].as_str().expect("uid");
let version = component["version"].as_str().expect("version");
if uid == "net.minecraft" {
assert_eq!(
report.game_version.as_deref(),
Some(version),
"{uid} 要变成游戏版本"
);
continue;
}
let as_loader = report
.loader
.as_ref()
.is_some_and(|loader| loader.raw == uid);
let as_rejected = report
.rejected_components
.iter()
.any(|rejected| rejected.uid == uid);
assert!(
as_loader || as_rejected,
"component {uid} 既没成为加载器、也没进未支持清单(= 静默丢了)"
);
}
}
#[test]
fn reads_memory_from_instance_cfg_only_when_override_memory_is_on() {
let root = temp_root("mmc-mem");
let source = root.join("instance");
write_file(
&source.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(
&source.join("instance.cfg"),
b"InstanceType=OneSix\nname=mem pack\nOverrideMemory=true\nMaxMemAlloc=6144\nMinMemAlloc=1024\n",
);
write_file(&source.join(".minecraft/mods/a.jar"), b"jar");
let target = temp_root("mmc-mem-out").join("instance");
let report = block_on(import_pack(&ImportRequest::new(&source, &target)));
assert_eq!(report.max_memory_mb, Some(6144), "{:?}", report.error);
assert!(report
.warnings
.iter()
.any(|note| note.message_key == keys::NOTE_INSTANCE_CFG_MEMORY));
// 开关没开时不采用(只看值不看开关会误判)。
let gated = root.join("gated");
write_file(
&gated.join("mmc-pack.json"),
br#"{"formatVersion": 1, "components": [
{"uid": "net.minecraft", "version": "1.20.1"}]}"#,
);
write_file(
&gated.join("instance.cfg"),
b"InstanceType=OneSix\nname=gated\nOverrideMemory=false\nMaxMemAlloc=6144\n",
);
write_file(&gated.join(".minecraft/mods/a.jar"), b"jar");
let gated_target = temp_root("mmc-mem-gated-out").join("instance");
let gated_report = block_on(import_pack(&ImportRequest::new(&gated, &gated_target)));
assert_eq!(gated_report.max_memory_mb, None);
assert!(gated_report
.warnings
.iter()
.any(|note| note.message_key == keys::NOTE_INSTANCE_CFG_MEMORY));
}
#[test]
fn registers_the_instance_when_a_launcher_layout_is_given() {
// 登记阶段(读取清单 → 解压 → 补全 → 落地 → **注册实例**)的 happy path:
// 内容落盘后写 profiles.json + config/instances/<id>.json。
let root = temp_root("mmc-register");
let source = fixtures::multimc("mmc-register-src");
let target = root.join("instance");
let home = root.join("data");
let layout = crate::platform::layout::LauncherLayout::new(&home);
let request = ImportRequest::new(&source, &target).with_registration(&layout, Some("my pack"));
let report = block_on(import_pack(&request));
assert_eq!(report.status, ImportStatus::Success, "{:?}", report.error);
let registered = report.registered.as_ref().expect("要登记实例");
assert_eq!(registered.name, "my pack");
assert_eq!(registered.version_id, "1.20.1-fabric-0.15.11");
assert!(registered.created);
// profiles.json 真的写出来了,且指回同一个版本 id。
let profiles = crate::game::install::instance::read_profiles(&layout).expect("读档案");
assert_eq!(profiles.profiles.len(), 1);
assert_eq!(profiles.profiles[0].version_id, "1.20.1-fabric-0.15.11");
assert_eq!(profiles.profiles[0].name, "my pack");
assert!(report
.warnings
.iter()
.any(|note| note.message_key == keys::NOTE_REGISTERED));
}
#[test]
fn rejects_mmc_pack_with_other_format_version() {
// Prism 的规矩:`formatVersion` 必须恰为 1,否则整份丢弃(不是降级解析)。
let root = temp_root("mmc-v2");
let source = root.join("instance");
write_file(
&source.join("mmc-pack.json"),
br#"{"formatVersion": 2, "components": [
{"uid": "net.minecraft", "version": "1.20.1"}]}"#,
);
write_file(
&source.join("instance.cfg"),
b"InstanceType=OneSix\nname=v2\n",
);
write_file(&source.join(".minecraft/mods/a.jar"), b"jar");
let target = temp_root("mmc-v2-out").join("instance");
let report = block_on(import_pack(&ImportRequest::new(&source, &target)));
assert_eq!(report.status, ImportStatus::Failed);
let error = report.error.as_ref().expect("必须有具名错误");
assert_eq!(error.code(), "pack_metadata");
assert!(
error.message().contains("formatVersion"),
"{}",
error.message()
);
assert!(report.rolled_back);
super::assert_absent(&target);
}
@@ -0,0 +1,159 @@
//! 报告的**契约**测试:三态退出码、JSON 形状、文案 key 表没有死常量。
use serde_json::Value;
use crate::addon::import::{
import_pack, keys, ImportRequest, ImportStage, ImportStatus, MissingReason,
};
use crate::download::blocking::block_on;
use super::support::mrpack_stub;
use super::temp_root;
#[test]
fn status_maps_to_three_distinct_exit_codes() {
// CLI 契约:0 = Success,3 = Partial,1 = Failed(2 留给内部错误)。
assert_eq!(ImportStatus::Success.exit_code(), 0);
assert_eq!(ImportStatus::Partial.exit_code(), 3);
assert_eq!(ImportStatus::Failed.exit_code(), 1);
// 每个状态都有独立文案 key。
let mut keyed = vec![
ImportStatus::Success.message_key(),
ImportStatus::Partial.message_key(),
ImportStatus::Failed.message_key(),
];
keyed.sort_unstable();
keyed.dedup();
assert_eq!(keyed.len(), 3);
// 五个阶段也各有 key,且都能进报告。
let stages = [
ImportStage::Probe,
ImportStage::Extract,
ImportStage::Fetch,
ImportStage::Commit,
ImportStage::Register,
];
assert_eq!(stages.len(), 5);
for stage in stages {
assert!(keys::ALL.contains(&stage.message_key()), "{:?}", stage);
}
}
#[test]
fn report_serialises_to_stable_json_with_structured_error() {
// 失败也要输出结构化 JSON(不抛栈):`status` / `error.code` / `rolled_back` 一个不少。
let root = temp_root("report-json");
let pack = root.join("evil.zip");
super::write_zip(&pack, &[("manifest.json", b"{\"manifestType\":")]);
let target = root.join("instance");
let report = block_on(import_pack(&ImportRequest::new(&pack, &target)));
let value: Value = serde_json::to_value(&report).expect("报告要能序列化");
assert_eq!(value["status"], Value::String("failed".to_string()));
assert_eq!(value["rolled_back"], Value::Bool(true));
let error = value["error"].as_object().expect("error 是对象");
assert!(error["code"].is_string(), "{error:?}");
assert!(error["message"].is_string(), "{error:?}");
assert_eq!(report.status.exit_code(), 1);
}
#[test]
fn successful_report_serialises_counts_and_loader() {
let (fixture, fetcher) = mrpack_stub("report-ok");
let target = temp_root("report-ok-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 value: Value = serde_json::to_value(&report).expect("序列化");
assert_eq!(value["status"], Value::String("success".to_string()));
assert_eq!(value["format"], Value::String("modrinth".to_string()));
assert_eq!(value["loader"]["kind"], Value::String("fabric".to_string()));
assert_eq!(
value["loader"]["version_id"],
Value::String("1.20.1-fabric-0.15.11".to_string())
);
assert!(value["files"]
.as_array()
.is_some_and(|files| !files.is_empty()));
assert!(value["conflicts"].as_array().is_some_and(Vec::is_empty));
assert!(value["missing"].as_array().is_some_and(Vec::is_empty));
}
#[test]
fn every_message_key_is_referenced_somewhere() {
// 防"死常量表":keys 模块里每个常量除了**定义**与 `ALL` 里的**一处**列举,至少还要有
// 一处真实引用(`keys::X` 或同模块里的 `X`)。少于此数就说明它没人用。
let table = key_table();
let mut values: Vec<&str> = table.iter().map(|(_, value)| value.as_str()).collect();
values.sort_unstable();
let mut declared = keys::ALL.to_vec();
declared.sort_unstable();
assert_eq!(values, declared, "keys::ALL 与常量定义不一致");
let sources = SOURCES.concat();
let mut dead: Vec<&str> = Vec::new();
for (name, value) in &table {
let hits = sources.matches(name.as_str()).count();
assert!(
hits >= 3,
"key {name}({value})只出现 {hits} 次,像是死常量"
);
if hits < 3 {
dead.push(name);
}
}
assert!(dead.is_empty(), "{dead:?}");
}
#[test]
fn reason_enums_carry_their_own_keys() {
for reason in [
MissingReason::Offline,
MissingReason::NoSource,
MissingReason::FetchFailed,
] {
assert!(keys::ALL.contains(&reason.message_key()), "{reason:?}");
}
}
/// 导入模块的全部源码(key 引用计数用)。
const SOURCES: &[&str] = &[
include_str!("../import.rs"),
include_str!("../import/error.rs"),
include_str!("../import/report.rs"),
include_str!("../import/report/keys.rs"),
include_str!("../import/report/items.rs"),
include_str!("../import/plan/paths.rs"),
include_str!("../import/stage/commit.rs"),
include_str!("../import/baseline.rs"),
include_str!("../import/fetch.rs"),
include_str!("../import/merge.rs"),
include_str!("../import/phase.rs"),
include_str!("../import/pipeline.rs"),
include_str!("../import/plan.rs"),
include_str!("../import/stage.rs"),
include_str!("../mod.rs"),
];
/// 从 `keys` 模块的源码里解析出 `(常量名, 常量值)` 表(不手抄第二份清单)。
fn key_table() -> Vec<(String, String)> {
let source = include_str!("../import/report/keys.rs");
let mut out = Vec::new();
for line in source.lines() {
let line = line.trim();
let Some(rest) = line.strip_prefix("pub const ") else {
continue;
};
let Some((name, tail)) = rest.split_once(": &str = \"") else {
continue;
};
let Some((value, _)) = tail.split_once('"') else {
continue;
};
out.push((name.to_string(), value.to_string()));
}
assert!(!out.is_empty(), "没有解析到任何 key 常量");
out
}
+165
View File
@@ -0,0 +1,165 @@
//! `addon` 的**导入管线**测试(M111):三格式端到端 + 三方合并 + 必须先失败的恶意样本。
//!
//! # 夹具来源
//!
//! [`fixtures`] 不是本目录的文件,而是用 `#[path]` 直接吃 M110 的
//! `addon/pack/tests/fixtures.rs`——**同一份手造包只维护一处**。它的来源声明也在那边:
//! 三份 CF / mrpack / MultiMC 包全部手造(字段取自 analyse/05 的 HMCL 解析器形状),
//! 没有一个来自真实导出;`mods/half.jar` / `config/broken.toml` / `mods/broken.jar`
//! 是刻意造的坏文件(导入器不许崩,也不许悄悄丢)。
//!
//! 为了让 `mod` 路径上的 `use super::{temp_root, write_file, zip_dir}` 解析得到,本文件把
//! 这三个辅助函数放在与 `addon/pack/tests/mod.rs` 同名同形的位置。
//!
//! # 全部离线
//!
//! 网络一律走 [`support::StubFetcher`](内存里的假文件),没有一个用例发请求。
// `fixtures.rs` 被 `addon::pack::tests` 与本目录**同时**编译(同一份手造包只维护一处),
// 所以 clippy 的 `duplicate_mod` 在这里是刻意为之:改成两份副本才是真的错。
#[allow(clippy::duplicate_mod)]
#[path = "../pack/tests/fixtures.rs"]
mod fixtures;
mod import_curseforge;
mod import_failures;
mod import_merge;
mod import_modrinth;
mod import_multimc;
mod import_report;
mod support;
use std::fs::File;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
/// 本次进程内唯一的临时根目录(沿用仓库既有约定:不引 `tempfile`)。
pub(crate) fn temp_root(tag: &str) -> PathBuf {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!("ull-import-{}-{tag}-{unique}", std::process::id()))
}
/// 写一个文件(自动建父目录)。
pub(crate) fn write_file(path: &Path, bytes: &[u8]) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("建目录");
}
std::fs::write(path, bytes).expect("写文件");
}
/// 把一个目录打成 zip(`Stored`,方便断言字节)。
pub(crate) fn zip_dir(source: &Path, target: &Path) {
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent).expect("建 zip 目录");
}
let file = File::create(target).expect("建 zip");
let mut writer = zip::ZipWriter::new(file);
let options =
zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
let mut pending = vec![PathBuf::new()];
while let Some(relative) = pending.pop() {
let dir = source.join(&relative);
let mut children: Vec<_> = std::fs::read_dir(&dir)
.expect("读目录")
.collect::<Result<Vec<_>, _>>()
.expect("目录项");
children.sort_by_key(std::fs::DirEntry::file_name);
for child in children {
let relative = relative.join(child.file_name());
if child.file_type().expect("类型").is_dir() {
pending.push(relative);
continue;
}
let bytes = std::fs::read(child.path()).expect("读夹具文件");
let entry = relative.to_string_lossy().replace('\\', "/");
writer.start_file(entry, options).expect("写 zip 条目");
writer.write_all(&bytes).expect("写 zip 内容");
}
}
writer.finish().expect("收尾 zip");
}
/// 用一张显式条目表打 zip(恶意样本用)。
pub(crate) fn write_zip(target: &Path, entries: &[(&str, &[u8])]) {
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent).expect("建 zip 目录");
}
let file = File::create(target).expect("建 zip");
let mut writer = zip::ZipWriter::new(file);
let options =
zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
for (name, bytes) in entries {
writer.start_file(*name, options).expect("写 zip 条目");
writer.write_all(bytes).expect("写 zip 内容");
}
writer.finish().expect("收尾 zip");
}
/// 打一个带**符号链接条目**的 zip(`S_IFLNK` + 目标当内容)。
pub(crate) fn write_zip_with_symlink(
target: &Path,
entries: &[(&str, &[u8])],
link_name: &str,
link_target: &str,
) {
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent).expect("建 zip 目录");
}
let file = File::create(target).expect("建 zip");
let mut writer = zip::ZipWriter::new(file);
let options =
zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
for (name, bytes) in entries {
writer.start_file(*name, options).expect("写 zip 条目");
writer.write_all(bytes).expect("写 zip 内容");
}
writer
.add_symlink(link_name, link_target, options)
.expect("写符号链接条目");
writer.finish().expect("收尾 zip");
}
/// 递归列出目录下的文件(`/` 分隔的相对路径,排序)。
///
/// "失败后目标目录零残留"就是靠它断言的:`Failed` 之后这里必须返回空表。
pub(crate) fn list_tree(root: &Path) -> Vec<String> {
fn walk(base: &Path, dir: &Path, out: &mut Vec<String>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
walk(base, &path, out);
} else {
out.push(
path.strip_prefix(base)
.unwrap_or(&path)
.to_string_lossy()
.replace('\\', "/"),
);
}
}
}
let mut out = Vec::new();
walk(root, root, &mut out);
out.sort();
out
}
/// 读一个文件成字符串(断言覆盖内容用)。
pub(crate) fn read_text(path: &Path) -> String {
let bytes =
std::fs::read(path).unwrap_or_else(|error| panic!("读 {} 失败:{error}", path.display()));
String::from_utf8_lossy(&bytes).to_string()
}
/// 断言目录不存在(= 零残留)。
pub(crate) fn assert_absent(path: &Path) {
assert!(
!path.exists(),
"目标目录应当不存在(零残留),实际:{:?}",
list_tree(path)
);
}
+182
View File
@@ -0,0 +1,182 @@
//! 测试替身:内存里的远端(零网络)+ 把 M110 的 mrpack 夹具改成"声明与实际一致"的帮助器。
//!
//! # 为什么要改哈希
//!
//! M110 的 mrpack 夹具里 `hashes` 是**形状正确的占位值**(`0123456789abcdef…`),不对应任何
//! 真实字节——那份夹具的用途是"读得出、写得回",不是"能装上"。导入链要校验"声明与实际
//! 是否相符",所以:
//!
//! - **成功路径**用 [`mrpack_stub`]:沿用同一份夹具目录,只把 `sha1` 换成真实摘要、把
//! `downloads` 换成 `stub://` 地址(`sha512` 是占位值不影响:导入只校验 `sha1`,
//! 而 M110 的读者只校验 `sha512` 的**形状**);
//! - **声明与实际不符**用原始夹具:占位 `sha1` 一定不等于实际字节 ⇒ 必须 `Failed`。
//!
//! 两件事用同一份夹具,比手搓两个包更能说明"判据真的在跑"。
use std::collections::BTreeMap;
use std::sync::Mutex;
use serde_json::json;
use super::super::import::{CurseFileMeta, FetchFuture, FetchRequest, FetchedFile, PackFetcher};
use super::fixtures;
use super::fixtures::Fixture;
use super::{write_file, zip_dir};
use crate::download::verify::digest_bytes;
use crate::download::HashAlgo;
/// 成功路径上 `mods/jei.jar` 的内容。
pub(crate) const JEI_BYTES: &[u8] = b"stub jei jar: not a real jar, just bytes";
/// 成功路径上 `mods/optional.jar` 的内容。
pub(crate) const OPTIONAL_BYTES: &[u8] = b"stub optional";
/// 成功路径上两个文件的下载地址。
pub(crate) const JEI_URL: &str = "stub://mods/jei.jar";
/// 第二个文件的下载地址。
pub(crate) const OPTIONAL_URL: &str = "stub://mods/optional.jar";
/// CF 夹具里两个条目的补全地址(stub)。
pub(crate) const CURSE_URL: &str = "stub://curse/jei.jar";
/// M110 mrpack 夹具里 `mods/jei.jar` 的**占位**下载地址(原始夹具用,配的是假哈希)。
pub(crate) const JEI_PLACEHOLDER_URL: &str =
"https://cdn.modrinth.com/data/AANobbMI/versions/abc/jei.jar";
/// M110 mrpack 夹具里 `mods/optional.jar` 的**占位**下载地址。
pub(crate) const OPTIONAL_PLACEHOLDER_URL: &str =
"https://cdn.modrinth.com/data/opt/versions/x/optional.jar";
/// 一个字节的 SHA-1(小写十六进制)。
pub(crate) fn sha1(bytes: &[u8]) -> String {
digest_bytes(HashAlgo::Sha1, bytes)
}
/// 内存 fetcher:按 URL / (project, file) 查表,写盘后回成功。
pub(crate) struct StubFetcher {
files: BTreeMap<String, Vec<u8>>,
curse: BTreeMap<(i64, i64), CurseFileMeta>,
/// 被请求过的 URL(断言"没白下载"用)。
calls: Mutex<Vec<String>>,
}
impl StubFetcher {
/// 空表。
pub(crate) fn new() -> Self {
Self {
files: BTreeMap::new(),
curse: BTreeMap::new(),
calls: Mutex::new(Vec::new()),
}
}
/// 挂一个可下载的地址。
pub(crate) fn with_file(mut self, url: &str, bytes: &[u8]) -> Self {
self.files.insert(url.to_string(), bytes.to_vec());
self
}
/// 挂一条 CurseForge 元数据。
pub(crate) fn with_curse(mut self, project_id: i64, file_id: i64, meta: CurseFileMeta) -> Self {
self.curse.insert((project_id, file_id), meta);
self
}
/// 被请求过的 URL 列表。
pub(crate) fn calls(&self) -> Vec<String> {
self.calls.lock().expect("calls 锁").clone()
}
}
impl PackFetcher for StubFetcher {
fn curse_file<'a>(
&'a self,
project_id: i64,
file_id: i64,
) -> FetchFuture<'a, Result<CurseFileMeta, super::super::import::FetchError>> {
Box::pin(async move {
self.curse
.get(&(project_id, file_id))
.cloned()
.ok_or_else(|| {
super::super::import::FetchError::new(
super::super::import::FetchErrorKind::NotFound,
format!("stub 里没有 {project_id}:{file_id}"),
)
})
})
}
fn fetch_file<'a>(
&'a self,
request: FetchRequest<'a>,
) -> FetchFuture<'a, Result<FetchedFile, super::super::import::FetchError>> {
Box::pin(async move {
self.calls
.lock()
.expect("calls 锁")
.push(request.url.to_string());
let bytes = self.files.get(request.url).cloned().ok_or_else(|| {
super::super::import::FetchError::new(
super::super::import::FetchErrorKind::NotFound,
format!("stub 里没有 {}", request.url),
)
})?;
if let Some(parent) = request.dest.parent() {
std::fs::create_dir_all(parent).map_err(|error| {
super::super::import::FetchError::new(
super::super::import::FetchErrorKind::Io,
error.to_string(),
)
})?;
}
std::fs::write(request.dest, &bytes).map_err(|error| {
super::super::import::FetchError::new(
super::super::import::FetchErrorKind::Io,
error.to_string(),
)
})?;
Ok(FetchedFile {
bytes: bytes.len() as u64,
})
})
}
}
/// 把 M110 的 mrpack 夹具改成"声明与实际一致",并配好 stub fetcher。
///
/// 只改两处:`files[].hashes.sha1`(换成真实摘要)与 `files[].downloads`(换成 `stub://`)。
pub(crate) fn mrpack_stub(tag: &str) -> (Fixture, StubFetcher) {
let fixture = fixtures::modrinth(tag);
let index_path = fixture.dir.join("modrinth.index.json");
let text = std::fs::read_to_string(&index_path).expect("读夹具 index");
let mut value: serde_json::Value = serde_json::from_str(&text).expect("解析夹具 index");
let entries = [
("mods/jei.jar", JEI_BYTES, JEI_URL),
("mods/optional.jar", OPTIONAL_BYTES, OPTIONAL_URL),
];
let files = value["files"].as_array_mut().expect("夹具 files 是数组");
for (entry, (path, bytes, url)) in files.iter_mut().zip(entries) {
assert_eq!(
entry["path"],
json!(path),
"夹具条目顺序变了,本帮助器要跟着改"
);
entry["hashes"]["sha1"] = json!(sha1(bytes));
entry["downloads"] = json!([url]);
entry["fileSize"] = json!(bytes.len());
}
write_file(&index_path, value.to_string().as_bytes());
zip_dir(&fixture.dir, &fixture.zip);
let fetcher = StubFetcher::new()
.with_file(JEI_URL, JEI_BYTES)
.with_file(OPTIONAL_URL, OPTIONAL_BYTES);
(fixture, fetcher)
}
/// CF 夹具的 stub 元数据(两个条目都补得出来,落在 `mods/`)。
pub(crate) fn curse_meta(file_name: &str, bytes: &[u8]) -> CurseFileMeta {
CurseFileMeta {
file_name: file_name.to_string(),
url: Some(CURSE_URL.to_string()),
sha1: Some(sha1(bytes)),
size: Some(bytes.len() as u64),
class_id: Some(6),
}
}