接 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 行。
200 lines
7.3 KiB
Rust
200 lines
7.3 KiB
Rust
//! `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}");
|
||
}
|