From 4760714743d76d576c4c2193525297cab5a89428 Mon Sep 17 00:00:00 2001 From: worker-packimport Date: Wed, 4 Nov 2026 13:24:20 +0800 Subject: [PATCH] =?UTF-8?q?feat(addon):=20M111=20=E6=95=B4=E5=90=88?= =?UTF-8?q?=E5=8C=85=E5=AF=BC=E5=85=A5=E7=AE=A1=E7=BA=BF=EF=BC=88CF=20/=20?= =?UTF-8?q?mrpack=20/=20MultiMC=EF=BC=89+=20ull=20pack=20import?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 接 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 [--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 行。 --- crates/cli/src/cli/help.rs | 27 +- crates/cli/src/cli/mod.rs | 2 +- crates/cli/src/cli/model.rs | 17 + crates/cli/src/cli/parse.rs | 29 +- crates/cli/src/commands/mod.rs | 22 + crates/cli/src/commands/pack.rs | 239 +++++++++++ crates/cli/src/output.rs | 16 + crates/cli/src/runner.rs | 4 +- crates/cli/tests/pack_test.rs | 199 +++++++++ crates/core/src/addon/import.rs | 179 ++++++++ crates/core/src/addon/import/baseline.rs | 132 ++++++ crates/core/src/addon/import/error.rs | 204 +++++++++ crates/core/src/addon/import/fetch.rs | 234 ++++++++++ crates/core/src/addon/import/merge.rs | 139 ++++++ crates/core/src/addon/import/phase.rs | 379 +++++++++++++++++ crates/core/src/addon/import/pipeline.rs | 202 +++++++++ crates/core/src/addon/import/plan.rs | 401 ++++++++++++++++++ crates/core/src/addon/import/plan/paths.rs | 119 ++++++ crates/core/src/addon/import/report.rs | 238 +++++++++++ crates/core/src/addon/import/report/items.rs | 265 ++++++++++++ crates/core/src/addon/import/report/keys.rs | 96 +++++ crates/core/src/addon/import/stage.rs | 292 +++++++++++++ crates/core/src/addon/import/stage/commit.rs | 143 +++++++ crates/core/src/addon/mod.rs | 14 +- .../core/src/addon/tests/import_curseforge.rs | 134 ++++++ .../core/src/addon/tests/import_failures.rs | 254 +++++++++++ crates/core/src/addon/tests/import_merge.rs | 204 +++++++++ .../core/src/addon/tests/import_modrinth.rs | 150 +++++++ crates/core/src/addon/tests/import_multimc.rs | 209 +++++++++ crates/core/src/addon/tests/import_report.rs | 159 +++++++ crates/core/src/addon/tests/mod.rs | 165 +++++++ crates/core/src/addon/tests/support.rs | 182 ++++++++ 32 files changed, 5044 insertions(+), 5 deletions(-) create mode 100644 crates/cli/src/commands/pack.rs create mode 100644 crates/cli/tests/pack_test.rs create mode 100644 crates/core/src/addon/import.rs create mode 100644 crates/core/src/addon/import/baseline.rs create mode 100644 crates/core/src/addon/import/error.rs create mode 100644 crates/core/src/addon/import/fetch.rs create mode 100644 crates/core/src/addon/import/merge.rs create mode 100644 crates/core/src/addon/import/phase.rs create mode 100644 crates/core/src/addon/import/pipeline.rs create mode 100644 crates/core/src/addon/import/plan.rs create mode 100644 crates/core/src/addon/import/plan/paths.rs create mode 100644 crates/core/src/addon/import/report.rs create mode 100644 crates/core/src/addon/import/report/items.rs create mode 100644 crates/core/src/addon/import/report/keys.rs create mode 100644 crates/core/src/addon/import/stage.rs create mode 100644 crates/core/src/addon/import/stage/commit.rs create mode 100644 crates/core/src/addon/tests/import_curseforge.rs create mode 100644 crates/core/src/addon/tests/import_failures.rs create mode 100644 crates/core/src/addon/tests/import_merge.rs create mode 100644 crates/core/src/addon/tests/import_modrinth.rs create mode 100644 crates/core/src/addon/tests/import_multimc.rs create mode 100644 crates/core/src/addon/tests/import_report.rs create mode 100644 crates/core/src/addon/tests/mod.rs create mode 100644 crates/core/src/addon/tests/support.rs diff --git a/crates/cli/src/cli/help.rs b/crates/cli/src/cli/help.rs index 290a562..f09222f 100644 --- a/crates/cli/src/cli/help.rs +++ b/crates/cli/src/cli/help.rs @@ -19,6 +19,10 @@ pub const fn general() -> &'static str { 时再叠一层加载器)→ 登记实例;--dry-run 只出计划 launch [--dry-run] [--account ] [--java ] [--username ] [--max-memory ] 启动实例;--dry-run 只打印组装后的命令行 + pack import [--target ] [--name N] [--overwrite] [--no-register] + 导入整合包(CF manifest.json / Modrinth .mrpack / + MultiMC·Prism 实例)→ 落内容 → 登记实例; + 缺文件降级为「部分成功」(退出码 3) accounts list 列出账户(名称 / 轨道 / UUID,**不含凭据**) accounts add offline 添加离线账户(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 [--target ] [--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( "\ diff --git a/crates/cli/src/cli/mod.rs b/crates/cli/src/cli/mod.rs index 9fa99ce..8f04c14 100644 --- a/crates/cli/src/cli/mod.rs +++ b/crates/cli/src/cli/mod.rs @@ -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; diff --git a/crates/cli/src/cli/model.rs b/crates/cli/src/cli/model.rs index 702889d..19f5fd0 100644 --- a/crates/cli/src/cli/model.rs +++ b/crates/cli/src/cli/model.rs @@ -28,6 +28,8 @@ pub enum Command { InstancesList, /// `install [--loader X] [--name N]`。 Install(InstallArgs), + /// `pack import [--target ] [--name N] [--overwrite] [--no-register]`。 + PackImport(PackImportArgs), /// `launch [--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, + /// 实例显示名(缺省 = 包名)。 + pub name: Option, + /// 冲突时用包内内容覆盖(缺省保留用户文件)。 + pub overwrite: bool, + /// 不登记实例(只落内容)。 + pub no_register: bool, +} + /// `launch` 的参数。 #[derive(Debug, Clone, PartialEq, Eq)] pub struct LaunchArgs { diff --git a/crates/cli/src/cli/parse.rs b/crates/cli/src/cli/parse.rs index 0e8e349..7280b77 100644 --- a/crates/cli/src/cli/parse.rs +++ b/crates/cli/src/cli/parse.rs @@ -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 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 [--target ] [--name N] [--overwrite] [--no-register]`。 +fn parse_pack(tail: &[String]) -> Result { + let Some(sub) = tail.first().map(String::as_str) else { + return Err(CliError::usage("pack 需要子命令 import") + .with_hint("%prog% pack import [--target ]")); + }; + 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 ")?; + 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 { let parsed = parse_flags( tail, diff --git a/crates/cli/src/commands/mod.rs b/crates/cli/src/commands/mod.rs index b39ca94..ffc4f69 100644 --- a/crates/cli/src/commands/mod.rs +++ b/crates/cli/src/commands/mod.rs @@ -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 { 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(globals: &Globals, action: F) -> Result +where + F: FnOnce(&Ctx) -> Result, +{ + 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; diff --git a/crates/cli/src/commands/pack.rs b/crates/cli/src/commands/pack.rs new file mode 100644 index 0000000..42c6ea9 --- /dev/null +++ b/crates/cli/src/commands/pack.rs @@ -0,0 +1,239 @@ +//! `pack import `:整合包导入(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 { + 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>> = 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 = 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(®ion_path, now).unwrap_or_else(|| { + let profile = decide_from(&detect_signals(), now); + let _ = store_cached(®ion_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)), + ) +} diff --git a/crates/cli/src/output.rs b/crates/cli/src/output.rs index f08ba20..b34869c 100644 --- a/crates/cli/src/output.rs +++ b/crates/cli/src/output.rs @@ -37,6 +37,13 @@ pub struct Outcome { pub report: Report, /// 环境级提示(人类模式进 stderr,JSON 模式进信封的 `warnings`)。 pub warnings: Vec, + /// 命令自定义的退出码;`None` = 0(绝大多数命令)。 + /// + /// 只有"结果多态且脚本要分流"的命令才用得上:`ull pack import` 的三态是 + /// `0` 成功 / `3` 部分成功 / `1` 失败,光靠 0/1 分不出来。加这个字段而不是让命令 + /// 自己 `std::process::exit`,是为了让**输出照常刷新**(绕过 runner 的输出刷新会把 + /// "输出没刷完就退出"这种 bug 装进 CLI)。 + pub exit_code: Option, } 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 + } } /// 打印成功结果。 diff --git a/crates/cli/src/runner.rs b/crates/cli/src/runner.rs index fe1c5e3..4bf4d0e 100644 --- a/crates/cli/src/runner.rs +++ b/crates/cli/src/runner.rs @@ -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); diff --git a/crates/cli/tests/pack_test.rs b/crates/cli/tests/pack_test.rs new file mode 100644 index 0000000..196b8b1 --- /dev/null +++ b/crates/cli/tests/pack_test.rs @@ -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}"); +} diff --git a/crates/core/src/addon/import.rs b/crates/core/src/addon/import.rs new file mode 100644 index 0000000..117f2de --- /dev/null +++ b/crates/core/src/addon/import.rs @@ -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, +} + +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 +} diff --git a/crates/core/src/addon/import/baseline.rs b/crates/core/src/addon/import/baseline.rs new file mode 100644 index 0000000..7a68fc5 --- /dev/null +++ b/crates/core/src/addon/import/baseline.rs @@ -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, +} + +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 { + /// 实例根下的基线路径(`/.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 { + 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, sha1: impl Into) { + self.files.insert(path.into(), sha1.into()); + } + + /// 序列化成落盘字节(pretty:这文件是给人排障看的)。 + /// + /// # Errors + /// + /// 序列化失败(纯理论,`BTreeMap` 不会失败)。 + pub fn to_bytes(&self) -> Result, String> { + serde_json::to_vec_pretty(self).map_err(|error| format!("基线序列化失败:{error}")) + } +} diff --git a/crates/core/src/addon/import/error.rs b/crates/core/src/addon/import/error.rs new file mode 100644 index 0000000..600a977 --- /dev/null +++ b/crates/core/src/addon/import/error.rs @@ -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, +} + +impl ImportError { + /// 构造。 + #[must_use] + pub fn new(kind: ImportErrorKind, message: impl Into) -> Self { + Self { + kind, + message: message.into(), + hint: None, + } + } + + /// 内容与清单声明不符。 + #[must_use] + pub fn integrity(message: impl Into) -> Self { + Self::new(ImportErrorKind::Integrity, message) + } + + /// 远端补全/下载的致命失败。 + #[must_use] + pub fn fetch(message: impl Into) -> Self { + Self::new(ImportErrorKind::Fetch, message) + } + + /// 加载器信息不足。 + #[must_use] + pub fn loader(message: impl Into) -> Self { + Self::new(ImportErrorKind::Loader, message) + } + + /// 实例登记失败。 + #[must_use] + pub fn instance(message: impl Into) -> Self { + Self::new(ImportErrorKind::Instance, message) + } + + /// 补一句提示。 + #[must_use] + pub fn with_hint(mut self, hint: impl Into) -> 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 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 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(&self, serializer: S) -> Result { + 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() + } +} diff --git a/crates/core/src/addon/import/fetch.rs b/crates/core/src/addon/import/fetch.rs new file mode 100644 index 0000000..3017224 --- /dev/null +++ b/crates/core/src/addon/import/fetch.rs @@ -0,0 +1,234 @@ +//! 远端补全与下载的**注入点**:导入管线自己不碰网络,只认这个 trait。 +//! +//! # 为什么是 trait 而不是直接调 `DownloadEngine` +//! +//! 三条理由,每条都对应一个真实麻烦: +//! +//! 1. **测试不能联网**:三种格式的端到端用例必须离线跑(CI 没有网,也不该有); +//! 2. **CurseForge 的 `files[]` 补全还没有客户端**:CF 官方清单只有 `projectID/fileID`, +//! 要 `x-api-key` 的 `api.curseforge.com/v1/mods//files/` 才能换出文件名与地址。 +//! 仓库里没有这个客户端(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) -> Self { + Self { + kind, + message: message.into(), + } + } + + /// 这条路走不通。 + #[must_use] + pub fn unsupported(message: impl Into) -> Self { + Self::new(FetchErrorKind::Unsupported, message) + } + + /// 内容与声明不符。 + #[must_use] + pub fn integrity(message: impl Into) -> 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 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>` 即可)。 +pub type FetchFuture<'a, T> = Pin + 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, + /// 官方给的 SHA-1。 + pub sha1: Option, + /// 字节数。 + pub size: Option, + /// 项目类别(CF `classId`:6 = mods、12 = resource packs、6945 = shaders …)。 + pub class_id: Option, +} + +/// 一次文件下载请求。 +#[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, +} + +/// 一次下载的产物。 +#[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> { + 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>; +} + +/// 生产实现:把请求交给 `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> { + 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, + }) + }) + } +} diff --git a/crates/core/src/addon/import/merge.rs b/crates/core/src/addon/import/merge.rs new file mode 100644 index 0000000..95c6775 --- /dev/null +++ b/crates/core/src/addon/import/merge.rs @@ -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, +} + +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 + ); + } +} diff --git a/crates/core/src/addon/import/phase.rs b/crates/core/src/addon/import/phase.rs new file mode 100644 index 0000000..1d24268 --- /dev/null +++ b/crates/core/src/addon/import/phase.rs @@ -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, + size: Option, +} + +/// 把条目补全成"有路径、有地址"。 +/// +/// `Ok(None)` = 这条补不出来(进 `missing`);`Err` = 致命(本地 IO / 内容不符)。 +async fn resolve( + request: &ImportRequest<'_>, + entry: &PlanRemote, +) -> Result, 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, + disk_sha1: Option, +) -> 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, 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 { + 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) -> ImportError { + PackError::io(message).into() +} diff --git a/crates/core/src/addon/import/pipeline.rs b/crates/core/src/addon/import/pipeline.rs new file mode 100644 index 0000000..20d83f6 --- /dev/null +++ b/crates/core/src/addon/import/pipeline.rs @@ -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(()) +} + +/// 基线相对实例根的路径(`/.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, + stage: ImportStage, + done: usize, + total: usize, + current: Option, +) { + 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 +} diff --git a/crates/core/src/addon/import/plan.rs b/crates/core/src/addon/import/plan.rs new file mode 100644 index 0000000..caf7882 --- /dev/null +++ b/crates/core/src/addon/import/plan.rs @@ -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 { + /// 包内目录前缀(读包时拼 `/`)。 + 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, +} + +/// 一条要补全/下载的远端条目。 +#[derive(Debug, Clone)] +pub struct PlanRemote { + /// 原清单条目(摘要表 / downloads / env / project/file id 都在里面)。 + pub file: PackFile, + /// 已知的实例内相对路径(mrpack 有;CF 需要补全才有)。 + pub path: Option, + /// 来源。 + 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, + /// 实例绑定版本 id。 + pub version_id: String, + /// 不支持的 component(逐项)。 + pub rejected: Vec, + /// 注记。 + pub notes: Vec, + /// 覆盖条目(已按侧优先级排好、已剔除 `server-overrides`)。 + pub overrides: Vec, + /// 远端条目。 + pub remote: Vec, + /// `instance.cfg` 里受开关门控的内存上限(MB)。 + pub max_memory_mb: Option, +} + +impl ImportPlan { + /// 组计划。 + /// + /// # Errors + /// + /// 清单缺游戏版本、加载器条目缺版本号、MultiMC 的 `formatVersion != 1` 等"清单自身非法" + /// 的情形返回 [`ImportError`](调用方据此整包回滚)。"某条文件缺地址"**不是**错误。 + pub fn build( + pack: &PackArchive, + format_id: &str, + manifest: PackManifest, + ) -> Result { + let mut notes: Vec = Vec::new(); + let mut rejected: Vec = 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 = 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 = 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 { + 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, + rejected: &mut Vec, +) -> Result, 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::>() + .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, +) -> Result, 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::().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)) +} diff --git a/crates/core/src/addon/import/plan/paths.rs b/crates/core/src/addon/import/plan/paths.rs new file mode 100644 index 0000000..86503aa --- /dev/null +++ b/crates/core/src/addon/import/plan/paths.rs @@ -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` 的坑)。 +/// +/// 只在"版本段以 `-` 开头"时剥——这是 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); + } +} diff --git a/crates/core/src/addon/import/report.rs b/crates/core/src/addon/import/report.rs new file mode 100644 index 0000000..4b0854d --- /dev/null +++ b/crates/core/src/addon/import/report.rs @@ -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, +} + +/// 进度回调。 +pub type ImportProgressSink = std::sync::Arc; + +/// 一次导入的完整结果。 +#[derive(Debug, Clone, Serialize)] +pub struct ImportReport { + /// 三态。 + pub status: ImportStatus, + /// 命中的格式 id(`curseforge` / `modrinth` / `multimc`)。 + pub format: Option, + /// 包名。 + pub pack_name: Option, + /// 包版本。 + pub pack_version: Option, + /// 游戏版本。 + pub game_version: Option, + /// 加载器安装计划输入(没有加载器时 `None` = 纯原版)。 + pub loader: Option, + /// 实例绑定的版本 id。 + pub version_id: Option, + /// 目标实例目录。 + pub target: PathBuf, + /// 落地的文件(含 `SkippedIdentical`)。 + pub files: Vec, + /// 冲突清单。 + pub conflicts: Vec, + /// 缺文件清单(容忍)。 + pub missing: Vec, + /// MultiMC 不支持的 component(逐项)。 + pub rejected_components: Vec, + /// 注记。 + pub warnings: Vec, + /// 致命错误(`status == Failed` 时必有)。 + pub error: Option, + /// 登记的实例。 + pub registered: Option, + /// `instance.cfg` 里受开关门控的内存上限(MB;仅 MultiMC / Prism 有)。 + pub max_memory_mb: Option, + /// 本次写入的字节数。 + 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; + } +} diff --git a/crates/core/src/addon/import/report/items.rs b/crates/core/src/addon/import/report/items.rs new file mode 100644 index 0000000..9434a93 --- /dev/null +++ b/crates/core/src/addon/import/report/items.rs @@ -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, + /// 磁盘现状的 SHA-1(文件不存在时 `None`)。 + pub disk_sha1: Option, +} + +/// 一个"搞不到"的文件(**容忍**,但必须逐条列出)。 +#[derive(Debug, Clone, Serialize)] +pub struct MissingFile { + /// 实例内相对路径(mrpack 有;CF 补全失败时没有)。 + pub path: Option, + /// 人读标识(CF 条目是 `projectID:fileID`,其余是路径)。 + pub display: String, + /// CF `projectID`。 + pub project_id: Option, + /// CF `fileID`。 + pub file_id: Option, + /// 为什么搞不到。 + 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, + /// 文案 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(&self, serializer: S) -> Result { + 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, + /// 字节数。 + pub bytes: u64, + /// 是否禁用态(路径后缀表达,不附加语义)。 + pub disabled: bool, + /// 处理方式。 + pub action: FileAction, + /// 可选注记(`.disabled` / `optional` 之类)。 + pub note: Option, +} + +/// 一条人读注记(带稳定 key)。 +#[derive(Debug, Clone, Serialize)] +pub struct ImportIssue { + /// 文案 key。 + pub message_key: &'static str, + /// 人读细节。 + pub message: String, + /// 相关实例内路径。 + pub path: Option, +} + +impl ImportIssue { + /// 构造。 + #[must_use] + pub fn new(message_key: &'static str, message: impl Into) -> Self { + Self { + message_key, + message: message.into(), + path: None, + } + } + + /// 挂一条路径。 + #[must_use] + pub fn with_path(mut self, path: impl Into) -> 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, +} diff --git a/crates/core/src/addon/import/report/keys.rs b/crates/core/src/addon/import/report/keys.rs new file mode 100644 index 0000000..af6a1a8 --- /dev/null +++ b/crates/core/src/addon/import/report/keys.rs @@ -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, +]; diff --git a/crates/core/src/addon/import/stage.rs b/crates/core/src/addon/import/stage.rs new file mode 100644 index 0000000..525d308 --- /dev/null +++ b/crates/core/src/addon/import/stage.rs @@ -0,0 +1,292 @@ +//! 落地事务:**临时目录 + rename**,合并态用 journal 回滚(回滚细节在 [`commit`])。 +//! +//! # 两种起点,两条事务路径 +//! +//! | 起点 | 做法 | 失败后的样子 | +//! |------|------|--------------| +//! | 目标目录不存在 / 是空目录(全新导入) | 在**同卷兄弟目录**里把整个实例建好,最后一次 `rename` 到位 | 目标目录**从未出现**(rename 是原子的) | +//! | 目标目录已有内容(升级 / 重导) | 逐文件 `rename` 进目标;被替换的旧文件先改名成 `.ullbak-` 当备份 | 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 { + 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 { + 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 { + 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 { + 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 = 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) -> Result<(), ImportError> { + let mut staged: Vec = 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) -> 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) -> ImportError { + PackError::io(message).into() +} diff --git a/crates/core/src/addon/import/stage/commit.rs b/crates/core/src/addon/import/stage/commit.rs new file mode 100644 index 0000000..1d20769 --- /dev/null +++ b/crates/core/src/addon/import/stage/commit.rs @@ -0,0 +1,143 @@ +//! 提交守卫与回滚:**`Drop` 即回滚**,除非显式 `finish`。 +//! +//! 靠调用方在每个错误分支手动清理,早晚会漏一条——所以把"回滚"绑在析构上:任何提前 +//! `return`、`?` 冒泡、panic 展开都不会留下半个实例。 +//! +//! 回滚分两种: +//! +//! - **全新导入**:目标目录整个是本事务建的 ⇒ 删干净就是零残留; +//! - **合并态**:按 journal **逆序**撤回——新文件删掉、被替换的旧文件从 `.ullbak-` 改回原名。 + +use std::path::{Path, PathBuf}; + +/// 备份后缀(合并态的 journal 备份文件)。 +const BACKUP_TAG: &str = "ullbak"; + +/// 一条已应用的搬动记录。 +#[derive(Debug)] +pub struct Applied { + /// 目标路径。 + pub(super) dest: PathBuf, + /// 被替换下来的旧文件备份。 + pub(super) backup: Option, + /// 新文件是否已经搬到位。 + pub(super) moved: bool, +} + +/// 提交守卫:**析构即回滚**,除非显式 [`CommitGuard::finish`]。 +#[derive(Debug)] +pub struct CommitGuard { + inner: Option, +} + +#[derive(Debug)] +struct Inner { + target: PathBuf, + temp: PathBuf, + /// 本次是否**新建**了目标目录(全新导入)。 + created_target: bool, + applied: Vec, + 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) -> 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); + } + } + } +} + +/// 备份路径:`.ullbak-`。 +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) +} diff --git a/crates/core/src/addon/mod.rs b/crates/core/src/addon/mod.rs index fb8e361..e7bc9ca 100644 --- a/crates/core/src/addon/mod.rs +++ b/crates/core/src/addon/mod.rs @@ -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; diff --git a/crates/core/src/addon/tests/import_curseforge.rs b/crates/core/src/addon/tests/import_curseforge.rs new file mode 100644 index 0000000..a48640e --- /dev/null +++ b/crates/core/src/addon/tests/import_curseforge.rs @@ -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()); +} diff --git a/crates/core/src/addon/tests/import_failures.rs b/crates/core/src/addon/tests/import_failures.rs new file mode 100644 index 0000000..bb62583 --- /dev/null +++ b/crates/core/src/addon/tests/import_failures.rs @@ -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() { + // 让合并态的**提交阶段**在中途失败:目标文件要被替换时必须先备份成 + // `.ullbak-`,而那个位置已被一个**非空目录**占住 ⇒ `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", + "旧内容必须还在(新内容没落地)" + ); +} diff --git a/crates/core/src/addon/tests/import_merge.rs b/crates/core/src/addon/tests/import_merge.rs new file mode 100644 index 0000000..fe41d9e --- /dev/null +++ b/crates/core/src/addon/tests/import_merge.rs @@ -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 + ); +} diff --git a/crates/core/src/addon/tests/import_modrinth.rs b/crates/core/src/addon/tests/import_modrinth.rs new file mode 100644 index 0000000..5e2e61f --- /dev/null +++ b/crates/core/src/addon/tests/import_modrinth.rs @@ -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"); +} diff --git a/crates/core/src/addon/tests/import_multimc.rs b/crates/core/src/addon/tests/import_multimc.rs new file mode 100644 index 0000000..3a72eaa --- /dev/null +++ b/crates/core/src/addon/tests/import_multimc.rs @@ -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/.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); +} diff --git a/crates/core/src/addon/tests/import_report.rs b/crates/core/src/addon/tests/import_report.rs new file mode 100644 index 0000000..8c63dd3 --- /dev/null +++ b/crates/core/src/addon/tests/import_report.rs @@ -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 +} diff --git a/crates/core/src/addon/tests/mod.rs b/crates/core/src/addon/tests/mod.rs new file mode 100644 index 0000000..ea5d6ca --- /dev/null +++ b/crates/core/src/addon/tests/mod.rs @@ -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::, _>>() + .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 { + fn walk(base: &Path, dir: &Path, out: &mut Vec) { + 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) + ); +} diff --git a/crates/core/src/addon/tests/support.rs b/crates/core/src/addon/tests/support.rs new file mode 100644 index 0000000..7c5a36a --- /dev/null +++ b/crates/core/src/addon/tests/support.rs @@ -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>, + curse: BTreeMap<(i64, i64), CurseFileMeta>, + /// 被请求过的 URL(断言"没白下载"用)。 + calls: Mutex>, +} + +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 { + 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> { + 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> { + 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), + } +}