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