Files
Unlucky-Launcher/crates/cli/tests/pack_test.rs
T
AskaEth 4760714743 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 行。
2026-11-04 20:54:04 +08:00

200 lines
7.3 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! `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}");
}