feat(core): M112 整合包导出(mrpack 优先 + CF 清单,逐文件远程判定与内联兜底)

新增 addon::export(实例目录 → .mrpack / CF manifest.json):
- 逐文件三分:远程引用 / 内联进 overrides/ / 跳过(读不了、符号链接、路径不安全)。
  「能否被远程替代」= 目录能证明身份(Modrinth SHA-1、CF 指纹)**且**目标格式表达得了;
  证明不了的一律内联,绝不丢、绝不编,逐条进结构化报告(ExportReport)。
- game / dependencies 来自实例快照:versions/<id>/<id>.json 的继承链「叶子」+ 库坐标推加载器,
  不解析 <mc>-<loader>-<ver> 字符串(社区实例名不统一,那是猜)。
- .disabled 走路径后缀(不是 env.client=optional:后者装回来默认启用,跨启动器不保真)。
- CF files[] 只写能确定 projectID/fileID 的项;禁用文件 CF 表达不了,内联并记原因。
  CF modLoaders[].id 按官方写法:NeoForge 在 1.20.1 上写 neoforge-1.20.1-<ver>。
- 尺寸护栏:fileSize 超 u32 报 pack_limits;整包撞 ZIP64 阈值由 format::write 拒绝。

配套改动:
- pack/curseforge/fingerprint:MurmurHash2-32 + 0x09/0x0A/0x0D/0x20 过滤、seed=1、
  长度用过滤后长度、哨兵 811513880 短路;金标准由一份独立 Java 实现算出(双向验证)。
- pack/format/payload:PackPayload 加 with_sources(包内条目名 → 实例源文件),
  让「实例里的 mods/x.jar → 包里的 overrides/mods/x.jar」不用拼路径。
- pack/format:写盘时按未压缩总量守 ZIP64 阈值。
- pack/modrinth:禁用位改写路径后缀;fileSize 按 u32 校验。
- pack/manifest:PackLoader::curse_id_for 处理 NeoForge 的 MC 前缀特例。

测试:导出管线 10 条 + 往返保真 4 条(三种格式夹具;允许的差异逐条在测试文件头登记,
并在报告 notes 里给出)。M111 未合并,故本轮「导入」用 M110 的 read + 同形实例目录,
真 import_pack → export → import_pack 端到端留作欠账。
This commit is contained in:
AskaEth committed 2026-11-04 21:32:37 +08:00
1 parent 3ffb1c4fee
commit 90b233088f
20 files changed
+2491 -60

No files matched your search

@@ -0,0 +1,121 @@
//! 导出测试的共用夹具(M112):实例目录、`versions/` 版本层、假的远端目录。
//!
//! 这里的东西**只给测试用**:本仓库没有 Modrinth/CF 客户端(要网络与 key),所以远端查表一律
//! 由 [`StubCatalog`] 注入;实例目录手造成"M111 导入产物"的形状(内容目录 + 版本 JSON)。
use std::cell::RefCell;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use super::{temp_root, write_file};
use crate::addon::export::catalog::{RemoteCatalog, RemoteHit};
use crate::addon::export::scan::{scan_instance, DEFAULT_CONTENT_ROOTS};
use crate::addon::pack::curseforge::fingerprint;
pub(super) const VANILLA_LAYER: &str = r#"{"id": "1.20.1", "libraries": []}"#;
pub(super) const FABRIC_LAYER: &str = r#"{
"id": "1.20.1-fabric-0.15.11",
"inheritsFrom": "1.20.1",
"libraries": [{"name": "net.fabricmc:fabric-loader:0.15.11"}]
}"#;
pub(super) const NEOFORGE_LAYER: &str = r#"{
"id": "1.20.1-neoforge-47.1.106",
"inheritsFrom": "1.20.1",
"libraries": [
{"name": "net.neoforged:neoforge:1.20.1-47.1.106"},
{"name": "net.fancymodloader:loader:3.0.5"}
]
}"#;
pub(super) const FORGE_LAYER: &str = r#"{
"id": "1.20.1-forge-47.2.0",
"inheritsFrom": "1.20.1",
"libraries": [{"name": "net.minecraftforge:forge:1.20.1-47.2.0:universal"}]
}"#;
/// 内容目录清单(与导出默认值同一份)。
pub(super) fn defaults() -> Vec<String> {
DEFAULT_CONTENT_ROOTS.map(str::to_string).to_vec()
}
/// 写一份 `<id>/<id>.json`。
pub(super) fn write_layer(game: &Path, id: &str, json: &str) {
write_file(
&game.join(format!("versions/{id}/{id}.json")),
json.as_bytes(),
);
}
/// 一个 Fabric 实例目录:两个 mod(其中一个禁用)+ 一份 config + 一份白名单外的日志。
pub(super) fn fabric_instance(tag: &str) -> PathBuf {
let root = temp_root(tag).join("game");
write_layer(&root, "1.20.1", VANILLA_LAYER);
write_layer(&root, "1.20.1-fabric-0.15.11", FABRIC_LAYER);
write_file(&root.join("mods/jei.jar"), b"PK\x03\x04 jei body");
write_file(&root.join("mods/off.jar.disabled"), b"PK\x03\x04 off body");
write_file(&root.join("config/known.toml"), b"key = \"value\"\n");
write_file(&root.join("logs/latest.log"), b"not pack content\n");
root
}
/// 一个 NeoForge 1.20.1 实例目录(CF `modLoaders[].id` 的 MC 前缀特例用它)。
pub(super) fn neoforge_instance(tag: &str) -> PathBuf {
let root = temp_root(tag).join("game");
write_layer(&root, "1.20.1", VANILLA_LAYER);
write_layer(&root, "1.20.1-neoforge-47.1.106", NEOFORGE_LAYER);
write_file(&root.join("mods/jei.jar"), b"PK\x03\x04 jei body");
root
}
/// 目录里的一个文件(路径 + SHA-1 + CF 指纹),与 `scan_instance` 同源。
pub(super) fn digests(root: &Path, path: &str) -> (String, u32) {
let (locals, _) = scan_instance(root, &defaults());
let local = locals
.iter()
.find(|file| file.path == path)
.unwrap_or_else(|| panic!("夹具里没有 {path}"));
let value = fingerprint::fingerprint_file(&local.disk).expect("算指纹");
(local.sha1.clone(), value)
}
/// 一个能写进 mrpack 的 Modrinth 命中(sha1 + sha512 + 直链)。
pub(super) fn modrinth_hit(sha1: &str, url: &str) -> RemoteHit {
let mut hashes = BTreeMap::new();
hashes.insert("sha1".to_string(), sha1.to_string());
hashes.insert("sha512".to_string(), "a".repeat(128));
RemoteHit::modrinth(vec![url.to_string()], hashes, Some(11))
}
/// 一个**假**的远端目录:查表结果由测试塞,调用过的指纹记下来(哨兵值那条分支靠它断言)。
#[derive(Default)]
pub(super) struct StubCatalog {
modrinth: BTreeMap<String, RemoteHit>,
curse: BTreeMap<u32, RemoteHit>,
curse_calls: RefCell<Vec<u32>>,
}
impl StubCatalog {
pub(super) fn with_modrinth(mut self, sha1: &str, hit: RemoteHit) -> Self {
self.modrinth.insert(sha1.to_string(), hit);
self
}
pub(super) fn with_curse(mut self, value: u32, hit: RemoteHit) -> Self {
self.curse.insert(value, hit);
self
}
pub(super) fn curse_calls(&self) -> Vec<u32> {
self.curse_calls.borrow().clone()
}
}
impl RemoteCatalog for StubCatalog {
fn modrinth_by_sha1(&self, sha1: &str) -> Option<RemoteHit> {
self.modrinth.get(sha1).cloned()
}
fn curseforge_by_fingerprint(&self, value: u32) -> Option<RemoteHit> {
self.curse_calls.borrow_mut().push(value);
self.curse.get(&value).cloned()
}
}
@@ -0,0 +1,370 @@
//! 导出管线(M112):三分类、`.disabled` 路径后缀、`game`/`dependencies` 来自实例快照。
//!
//! 夹具在 [`super::export_harness`]:手造的实例目录(形状与 M111 导入产物一致)+
//! 注入式假远端目录(本仓库没有 Modrinth/CF 客户端,测试也不许联网)。
use std::collections::BTreeMap;
use std::io::Read;
use std::path::Path;
use super::export_harness::{
defaults, digests, fabric_instance, modrinth_hit, neoforge_instance, StubCatalog,
};
use super::{temp_root, write_file};
use crate::addon::export::classify::classify_with;
use crate::addon::export::scan::{is_disabled_path, scan_instance};
use crate::addon::export::{export_instance, ExportRequest, FileClass, FileReason, RemoteHit};
use crate::addon::pack::archive::PackArchive;
use crate::addon::pack::curseforge::{fingerprint, CURSEFORGE};
use crate::addon::pack::error::PackErrorKind;
use crate::addon::pack::format::PackFormat;
use crate::addon::pack::manifest::{PackFile, PackManifest};
use crate::addon::pack::modrinth::MODRINTH;
use crate::addon::LoaderKind;
fn entry_names(path: &Path) -> Vec<String> {
let pack = PackArchive::open_default(path).expect("打开 zip");
pack.entries()
.iter()
.map(|entry| entry.name().to_string())
.collect()
}
#[test]
fn offline_mrpack_export_inlines_every_file_with_its_suffix() {
let root = fabric_instance("export-offline-mr");
let target = root.with_file_name("offline.mrpack");
let report =
export_instance(&ExportRequest::new(&root, &target, &MODRINTH)).expect("离线导出必须成功");
assert_eq!(report.format, "modrinth");
assert_eq!(report.remote_count(), 0);
assert_eq!(report.inline_count(), 3, "{:?}", report.files);
assert_eq!(report.skipped_count(), 0);
assert!(report.is_complete());
assert!(report.bytes > 0, "包路径与字节数必须结构化给出");
assert_eq!(report.minecraft.as_deref(), Some("1.20.1"));
assert_eq!(report.loaders, vec!["fabric-loader 0.15.11".to_string()]);
let pack = PackArchive::open_default(&target).expect("打开导出的包");
let manifest = MODRINTH.read(&pack).expect("读回 mrpack");
assert_eq!(manifest.name, "game");
assert_eq!(manifest.version.as_deref(), Some("1.0.0"));
assert_eq!(manifest.minecraft.as_deref(), Some("1.20.1"));
let loader = manifest.loader(LoaderKind::Fabric).expect("加载器不能漏");
assert_eq!(loader.version.as_deref(), Some("0.15.11"));
let paths: Vec<&str> = manifest
.override_files()
.map(|(_, file)| file.path.as_str())
.collect();
assert_eq!(
paths,
vec!["config/known.toml", "mods/jei.jar", "mods/off.jar.disabled"],
"三个内容文件都要在 overrides/ 里,禁用位保留后缀"
);
assert!(pack.has_entry("overrides/mods/off.jar.disabled"));
assert!(
!pack.has_entry("overrides/logs/latest.log"),
"白名单外(logs/)不打包"
);
}
#[test]
fn mrpack_index_declares_game_and_every_dependency() {
let root = fabric_instance("export-index-mr");
let target = root.with_file_name("index.mrpack");
export_instance(&ExportRequest::new(&root, &target, &MODRINTH)).expect("导出");
let pack = PackArchive::open_default(&target).expect("打开");
let index = pack
.read_json("modrinth.index.json")
.expect("读清单")
.expect("有清单");
assert_eq!(index["game"], "minecraft");
assert_eq!(index["formatVersion"], 1);
assert_eq!(index["name"], "game");
assert_eq!(index["dependencies"]["minecraft"], "1.20.1");
assert_eq!(
index["dependencies"]["fabric-loader"], "0.15.11",
"只写 minecraft 漏加载器是本任务点名的不许"
);
assert!(index["files"].as_array().is_some_and(Vec::is_empty));
}
#[test]
fn a_modrinth_hit_becomes_a_files_entry_with_both_catalogs_urls() {
let root = fabric_instance("export-remote-mr");
let (sha1, value) = digests(&root, "mods/jei.jar");
let catalog = StubCatalog::default()
.with_modrinth(
&sha1,
modrinth_hit(&sha1, "https://cdn.modrinth.com/data/x/jei.jar"),
)
.with_curse(
value,
RemoteHit::curseforge(238_222, 5_102_025)
.with_download("https://edge.forgecdn.net/files/5102/025/jei.jar"),
);
let target = root.with_file_name("remote.mrpack");
let report =
export_instance(&ExportRequest::new(&root, &target, &MODRINTH).with_catalog(&catalog))
.expect("导出");
assert_eq!(report.remote_count(), 1);
let entry = report
.files
.iter()
.find(|file| file.path == "mods/jei.jar")
.expect("jei 在报告里");
assert_eq!(entry.class, FileClass::Remote);
assert_eq!(entry.reason, FileReason::ModrinthSha1);
assert_eq!(
entry.downloads.len(),
2,
"两条目录的直链都要写进 downloads[]"
);
let pack = PackArchive::open_default(&target).expect("打开");
let manifest = MODRINTH.read(&pack).expect("读回");
assert_eq!(manifest.files.len(), 1);
let file = &manifest.files[0];
assert_eq!(file.path.as_deref(), Some("mods/jei.jar"));
assert_eq!(file.downloads.len(), 2);
assert_eq!(file.sha512().map(str::len), Some(128));
assert_eq!(
file.sha1(),
Some(sha1.as_str()),
"本地实测的 sha1 要覆盖目录给的值"
);
assert_eq!(file.size, Some(13), "字节数按磁盘实测");
assert_eq!(manifest.override_files().count(), 2, "另外两个文件仍然内联");
}
#[test]
fn a_hit_without_sha512_is_inlined_with_a_reason() {
let root = fabric_instance("export-no-sha512");
let (sha1, _) = digests(&root, "mods/jei.jar");
let mut hashes = BTreeMap::new();
hashes.insert("sha1".to_string(), sha1.clone());
let catalog = StubCatalog::default().with_modrinth(
&sha1,
RemoteHit::modrinth(
vec!["https://cdn.modrinth.com/data/x/jei.jar".into()],
hashes,
None,
),
);
let target = root.with_file_name("no-sha512.mrpack");
let report =
export_instance(&ExportRequest::new(&root, &target, &MODRINTH).with_catalog(&catalog))
.expect("导出");
let entry = report
.files
.iter()
.find(|file| file.path == "mods/jei.jar")
.expect("jei");
assert_eq!(entry.class, FileClass::Inline);
assert_eq!(entry.reason, FileReason::MissingSha512);
assert_eq!(report.remote_count(), 0);
}
#[test]
fn a_curseforge_only_hit_cannot_be_written_into_an_mrpack() {
let root = fabric_instance("export-curse-only");
let (_, value) = digests(&root, "mods/jei.jar");
let catalog =
StubCatalog::default().with_curse(value, RemoteHit::curseforge(238_222, 5_102_025));
let target = root.with_file_name("curse-only.mrpack");
let report =
export_instance(&ExportRequest::new(&root, &target, &MODRINTH).with_catalog(&catalog))
.expect("导出");
let entry = report
.files
.iter()
.find(|file| file.path == "mods/jei.jar")
.expect("jei");
assert_eq!(entry.reason, FileReason::CurseOnlyMatch);
assert_eq!(entry.class, FileClass::Inline);
}
#[test]
fn curseforge_export_writes_only_known_project_and_file_ids() {
let root = fabric_instance("export-cf");
let (_, value) = digests(&root, "mods/jei.jar");
let catalog =
StubCatalog::default().with_curse(value, RemoteHit::curseforge(238_222, 5_102_025));
let target = root.with_file_name("pack.zip");
let report =
export_instance(&ExportRequest::new(&root, &target, &CURSEFORGE).with_catalog(&catalog))
.expect("导出");
assert_eq!(report.format, "curseforge");
let reasons: Vec<(String, FileReason)> = report
.files
.iter()
.map(|file| (file.path.clone(), file.reason))
.collect();
assert_eq!(
reasons,
vec![
("config/known.toml".to_string(), FileReason::NoRemoteMatch),
(
"mods/jei.jar".to_string(),
FileReason::CurseForgeFingerprint
),
(
"mods/off.jar.disabled".to_string(),
FileReason::DisabledNotExpressible
),
]
);
let pack = PackArchive::open_default(&target).expect("打开");
let manifest = CURSEFORGE.read(&pack).expect("读回 CF 清单");
assert_eq!(manifest.files.len(), 1, "files[] 只写能确定 id 的项");
assert_eq!(manifest.files[0].project_id, Some(238_222));
assert_eq!(manifest.files[0].file_id, Some(5_102_025));
let names = entry_names(&target);
assert!(names.contains(&"overrides/mods/off.jar.disabled".to_string()));
assert!(names.contains(&"overrides/config/known.toml".to_string()));
}
#[test]
fn curseforge_modloaders_use_the_official_id_shape() {
let root = neoforge_instance("export-cf-neo");
let target = root.with_file_name("neo.zip");
export_instance(&ExportRequest::new(&root, &target, &CURSEFORGE)).expect("导出");
let pack = PackArchive::open_default(&target).expect("打开");
let manifest = pack
.read_json("manifest.json")
.expect("读清单")
.expect("有清单");
assert_eq!(manifest["minecraft"]["version"], "1.20.1");
assert_eq!(
manifest["minecraft"]["modLoaders"][0]["id"], "neoforge-1.20.1-47.1.106",
"CF 官方导出在 1.20.1 上把 MC 版本拼回 NeoForge 版本号"
);
assert_eq!(manifest["minecraft"]["modLoaders"][0]["primary"], true);
let root = fabric_instance("export-cf-fabric");
let target = root.with_file_name("fabric.zip");
export_instance(&ExportRequest::new(&root, &target, &CURSEFORGE)).expect("导出");
let pack = PackArchive::open_default(&target).expect("打开");
let manifest = pack
.read_json("manifest.json")
.expect("读清单")
.expect("有清单");
assert_eq!(
manifest["minecraft"]["modLoaders"][0]["id"],
"fabric-0.15.11"
);
assert_eq!(manifest["overrides"], "overrides");
}
#[test]
fn the_sentinel_fingerprint_never_reaches_the_catalog() {
let root = fabric_instance("export-sentinel");
let (locals, _) = scan_instance(&root, &defaults());
let local = locals
.iter()
.find(|file| file.path == "mods/jei.jar")
.expect("jei");
let catalog = StubCatalog::default()
.with_curse(fingerprint::SENTINEL, RemoteHit::curseforge(1, 2))
.with_curse(12345, RemoteHit::curseforge(1, 2));
let (reason, hit) = classify_with(local, &catalog, true, Some(fingerprint::SENTINEL));
assert_eq!(reason, FileReason::NoRemoteMatch);
assert!(hit.is_none());
assert!(
catalog.curse_calls().is_empty(),
"哨兵值必须短路,不能拿去查表(HMCL issue #4597 的误命中)"
);
// 对照:真实指纹会去查表。
let (reason, hit) = classify_with(local, &catalog, true, Some(12345));
assert_eq!(catalog.curse_calls(), vec![12345]);
assert_eq!(reason, FileReason::CurseForgeFingerprint, "塞了 id 就命中");
assert!(hit.is_some());
}
#[test]
fn a_disabled_file_is_still_disabled_after_a_round_trip_through_the_package() {
// 任务要求的那条:禁用文件导出后仍能被"重新导入"且保持禁用。
// 本轮没有 M111 的导入器(M111 未合并),所以"重新导入"= 把包里的 overrides 解出来 +
// 重新扫一遍实例目录:扫出来的 disabled 位必须是 true。
let root = fabric_instance("export-disabled");
let target = root.with_file_name("disabled.mrpack");
export_instance(&ExportRequest::new(&root, &target, &MODRINTH)).expect("导出");
let again = temp_root("export-disabled-reimport");
let file = std::fs::File::open(&target).expect("打开包");
let mut archive = zip::ZipArchive::new(file).expect("读 zip");
for index in 0..archive.len() {
let mut entry = archive.by_index(index).expect("条目");
let name = entry.name().expect("条目名").to_string();
let Some(relative) = name.strip_prefix("overrides/") else {
continue;
};
let mut bytes = Vec::new();
entry.read_to_end(&mut bytes).expect("读条目内容");
write_file(&again.join(relative), &bytes);
}
let (locals, skipped) = scan_instance(&again, &defaults());
assert!(skipped.is_empty(), "{skipped:?}");
let disabled: Vec<&str> = locals
.iter()
.filter(|file| file.disabled)
.map(|file| file.path.as_str())
.collect();
assert_eq!(disabled, vec!["mods/off.jar.disabled"]);
assert!(is_disabled_path("mods/off.jar.disabled.old"));
assert!(
!is_disabled_path("mods/off.jar.old"),
"`.old` 只是旧残留,不是禁用"
);
}
#[test]
fn a_file_bigger_than_u32_is_a_named_limits_error() {
// 真造一个 4 GiB 文件不现实:直接测格式层的那条闸(它是唯一写 `fileSize` 的地方)。
let mut manifest = PackManifest::new("超大文件");
manifest.version = Some("1.0.0".to_string());
manifest.minecraft = Some("1.20.1".to_string());
let mut hashes = BTreeMap::new();
hashes.insert("sha512".to_string(), "b".repeat(128));
manifest.files.push(PackFile {
path: Some("mods/huge.jar".to_string()),
hashes,
downloads: vec!["https://cdn.modrinth.com/data/x/huge.jar".to_string()],
size: Some(u64::from(u32::MAX) + 1),
..PackFile::default()
});
let error = MODRINTH.render(&manifest).expect_err("超限必须报错");
assert_eq!(error.kind(), PackErrorKind::Limits);
assert!(error.message().contains("fileSize"), "{error}");
}
#[test]
fn content_roots_can_be_extended_without_touching_the_pipeline() {
// 「社区标准的扩展性」的最小证明:加一个目录不用改管线。
let root = fabric_instance("export-extra-root");
write_file(&root.join("custom/extra.txt"), b"extra content\n");
let target = root.with_file_name("extra.mrpack");
let mut roots = defaults();
roots.push("custom".to_string());
let report =
export_instance(&ExportRequest::new(&root, &target, &MODRINTH).with_content_roots(roots))
.expect("导出");
assert!(report
.files
.iter()
.any(|file| file.path == "custom/extra.txt"));
let pack = PackArchive::open_default(&target).expect("打开");
assert!(pack.has_entry("overrides/custom/extra.txt"));
}
@@ -0,0 +1,280 @@
//! 往返保真(M112):用 M110 的三种格式夹具做「读出来 → 摆成实例 → 导出 → 再读」。
//!
//! # 为什么不是 `import_pack → export → import_pack`
//!
//! M111 的导入管线**还没合并进本分支**(它在复审中)。所以这里的"导入"是 M110 的
//! `PackFormat::read` + 一个**形状与 M111 导入产物一致**的实例目录(`mods/`、`config/`、
//! `versions/<id>/<id>.json`)。真端到端欠账在 M112 报告里登记:M111 合并后要补一条
//! `import_pack → export → import_pack`。
//!
//! # 允许的差异(逐条显式列出,别让后来人以为是 bug)
//!
//! | 方向 | 差异 | 原因 |
//! |------|------|------|
//! | CF → mrpack | `files[]` 的 `projectID/fileID` 条目全丢 | CF 的 `files[]` **没有路径**,磁盘上无法复原成文件;导出侧只能看到磁盘,看不见"清单里说该有但没落地"的条目 |
//! | CF → mrpack | `author` / `minecraft.recommendedRam` 等扩展字段丢 | mrpack 没有这些字段(`extra` 不跨格式搬) |
//! | mrpack → mrpack | 认不出的 dependency 键(`parchment`)丢 | 快照只从版本 JSON 的库坐标来;`parchment` 没有对应库 |
//! | mrpack → mrpack | `files[]` 的条目要靠"文件真的在磁盘上"才会再写回去 | 读出来的模型里那两条是远程条目、本地没有字节;再导出时以磁盘为准 |
//! | MultiMC → mrpack | 非加载器组件(`org.lwjgl3`)不进 `dependencies` | 它不是加载器;mrpack 的 `dependencies` 只表达 loader |
use std::path::{Path, PathBuf};
use super::export_harness::{
defaults, modrinth_hit, write_layer, StubCatalog, FORGE_LAYER, VANILLA_LAYER,
};
use super::{fixtures, temp_root, write_file};
use crate::addon::export::snapshot::{InstanceSnapshot, SnapshotLoader};
use crate::addon::export::{export_instance, ExportRequest};
use crate::addon::pack::archive::PackArchive;
use crate::addon::pack::curseforge::CURSEFORGE;
use crate::addon::pack::format::PackFormat;
use crate::addon::pack::manifest::{PackManifest, PackSide};
use crate::addon::pack::modrinth::MODRINTH;
use crate::addon::pack::multimc::MULTIMC;
use crate::addon::LoaderKind;
/// 把夹具里的覆盖内容铺成一个实例目录(`<game>/<path>`),并写一份版本 JSON。
fn instance_from(fixture_dir: &Path, manifest: &PackManifest, tag: &str) -> PathBuf {
let game = temp_root(tag).join("game");
for (_, file) in manifest.override_files() {
let group = manifest
.overrides
.iter()
.find(|group| group.files.iter().any(|each| each.path == file.path))
.expect("找到文件所属的组");
let source = fixture_dir.join(&group.dir).join(&file.path);
let bytes = std::fs::read(&source).unwrap_or_else(|error| {
panic!("读夹具 {} 失败:{error}", source.display());
});
write_file(&game.join(&file.path), &bytes);
}
write_layer(&game, "1.20.1", VANILLA_LAYER);
game
}
/// 一个「读出来的 mrpack」的文件集合,按路径排序(比较用)。
fn override_paths(manifest: &PackManifest) -> Vec<String> {
let mut paths: Vec<String> = manifest
.override_files()
.map(|(_, file)| file.path.clone())
.collect();
paths.sort();
paths
}
#[test]
fn curseforge_fixture_round_trips_through_mrpack() {
let fixture = fixtures::curseforge("rt-cf");
let pack = PackArchive::open_default(&fixture.zip).expect("打开 CF 夹具");
let original = CURSEFORGE.read(&pack).expect("读 CF 清单");
let game = instance_from(&fixture.dir, &original, "rt-cf-mr");
write_layer(&game, "1.20.1-forge-47.2.0", FORGE_LAYER);
let target = game.with_file_name("cf-to-mr.mrpack");
let request = ExportRequest::new(&game, &target, &MODRINTH)
.with_name(original.name.clone())
.with_version(original.version.clone().unwrap_or_default())
.with_version_id("1.20.1-forge-47.2.0");
let report = export_instance(&request).expect("导出");
assert_eq!(report.inline_count(), 3, "{:?}", report.files);
let pack = PackArchive::open_default(&target).expect("打开导出的包");
let again = MODRINTH.read(&pack).expect("再读回来");
// 关键字段:名称 / 版本 / 游戏版本 / 加载器 / 文件集合。
assert_eq!(again.name, original.name);
assert_eq!(again.version, original.version);
assert_eq!(again.minecraft, original.minecraft);
let loader = again.loader(LoaderKind::Forge).expect("Forge 加载器");
assert_eq!(loader.version.as_deref(), Some("47.2.0"));
assert_eq!(override_paths(&again), override_paths(&original));
// 允许的差异①:CF 的 files[] 没有路径,磁盘上复原不出来 ⇒ 往返后 files[] 为空。
assert!(
again.files.is_empty(),
"CF 的 projectID/fileID 条目没有路径,无法从磁盘复原(已在文件头登记)"
);
}
#[test]
fn mrpack_fixture_round_trips_with_a_catalog_and_keeps_the_disabled_suffix() {
let fixture = fixtures::modrinth("rt-mr");
let pack = PackArchive::open_default(&fixture.zip).expect("打开 mrpack 夹具");
let original = MODRINTH.read(&pack).expect("读 mrpack 清单");
// 三个覆盖目录都摊到同一个实例里(实例目录不分侧)。
let game = instance_from(&fixture.dir, &original, "rt-mr-mr");
let fabric = r#"{
"id": "1.20.1-fabric-0.15.11",
"inheritsFrom": "1.20.1",
"libraries": [{"name": "net.fabricmc:fabric-loader:0.15.11"}]
}"#;
write_layer(&game, "1.20.1", VANILLA_LAYER);
write_layer(&game, "1.20.1-fabric-0.15.11", fabric);
// 夹具里那两条远程条目在磁盘上不存在;这里补一条**真**文件,让"远程引用"这条路径被往返到。
write_file(&game.join("mods/jei.jar"), b"PK\x03\x04 jei body");
write_file(&game.join("mods/disabled.jar.disabled"), b"PK\x03\x04 off");
let (locals, _) = crate::addon::export::scan_instance(&game, &defaults());
let jei = locals
.iter()
.find(|file| file.path == "mods/jei.jar")
.expect("jei");
let catalog = StubCatalog::default().with_modrinth(
&jei.sha1,
modrinth_hit(&jei.sha1, "https://cdn.modrinth.com/data/x/jei.jar"),
);
let target = game.with_file_name("mr-to-mr.mrpack");
let request = ExportRequest::new(&game, &target, &MODRINTH)
.with_name(original.name.clone())
.with_version(original.version.clone().unwrap_or_default())
.with_version_id("1.20.1-fabric-0.15.11")
.with_catalog(&catalog);
let report = export_instance(&request).expect("导出");
assert_eq!(report.remote_count(), 1);
assert!(report.is_complete());
let pack = PackArchive::open_default(&target).expect("打开导出的包");
let again = MODRINTH.read(&pack).expect("再读回来");
assert_eq!(again.name, original.name);
assert_eq!(again.version, original.version);
assert_eq!(again.minecraft, original.minecraft);
let loader = again.loader(LoaderKind::Fabric).expect("Fabric 加载器");
assert_eq!(loader.version.as_deref(), Some("0.15.11"));
// 文件集合:远程那条回来了(同一个 path),其余内联。
assert_eq!(again.files.len(), 1);
assert_eq!(again.files[0].path.as_deref(), Some("mods/jei.jar"));
let mut files = override_paths(&again);
files.sort();
assert_eq!(
files,
vec![
"config/client-only.toml".to_string(),
"config/known.toml".to_string(),
"config/server.properties".to_string(),
"mods/disabled.jar.disabled".to_string(),
"mods/half.jar".to_string(),
],
"夹具自带的四个覆盖文件 + 新增的禁用文件"
);
// 禁用位仍然靠路径后缀表达。
assert!(files
.iter()
.any(|path| path == "mods/disabled.jar.disabled"));
assert!(pack.has_entry("overrides/mods/disabled.jar.disabled"));
// 允许的差异②:认不出的 dependency 键(夹具里的 `parchment`)不复存在——
// 快照只来自版本 JSON 的库坐标,`parchment` 没有对应库。
assert!(
again.loaders.iter().all(|loader| loader.kind.is_some()),
"非加载器依赖不回写(已在文件头登记)"
);
}
#[test]
fn multimc_fixture_exports_as_mrpack_from_its_game_directory() {
// MultiMC/Prism **格式**的导出不做(整实例快照包,成本极高),但"把 Prism 实例的游戏目录
// 导出成 mrpack"是正当用例:快照从 `mmc-pack.json` 的组件表来(调用方用 `pack::multimc` 读)。
let instance = fixtures::multimc("rt-mmc");
let pack = PackArchive::open_default(&instance).expect("打开 MultiMC 实例");
let original = MULTIMC.read(&pack).expect("读 mmc-pack.json");
let snapshot = InstanceSnapshot {
game_version: original.minecraft.clone().expect("net.minecraft 组件"),
loaders: original
.loaders
.iter()
.filter_map(|loader| {
Some(SnapshotLoader {
kind: loader.kind?,
version: loader.version.clone()?,
})
})
.collect(),
};
let game = instance.join(".minecraft");
let target = instance.with_file_name("mmc.mrpack");
let report = export_instance(
&ExportRequest::new(&game, &target, &MODRINTH)
.with_name("MultiMC 实例导出")
.with_snapshot(&snapshot),
)
.expect("导出");
assert_eq!(report.minecraft.as_deref(), Some("1.20.1"));
assert_eq!(report.loaders, vec!["fabric-loader 0.15.11".to_string()]);
assert_eq!(report.inline_count(), 3, "{:?}", report.files);
let pack = PackArchive::open_default(&target).expect("打开导出的包");
let again = MODRINTH.read(&pack).expect("再读回来");
assert_eq!(again.minecraft.as_deref(), Some("1.20.1"));
assert_eq!(
again
.loader(LoaderKind::Fabric)
.and_then(|loader| loader.version.clone()),
Some("0.15.11".to_string())
);
assert_eq!(
override_paths(&again),
vec![
"config/known.toml".to_string(),
"mods/broken.jar".to_string(),
"mods/example.jar".to_string(),
]
);
// 有意的省略:MultiMC 格式本身仍然只读不写。
assert!(MULTIMC.metadata_entry().is_none(), "MultiMC 导出不做");
// 重新打成 MultiMC 形状的目录以确认没有丢内容(把 overrides 解出来再比)。
let unpacked = temp_root("rt-mmc-unpacked");
for entry in pack.entries() {
let name = entry.name();
let Some(relative) = name.strip_prefix("overrides/") else {
continue;
};
let bytes = pack.read(name).expect("读条目").expect("有条目");
write_file(&unpacked.join(relative), &bytes);
}
for relative in ["config/known.toml", "mods/broken.jar", "mods/example.jar"] {
assert!(
unpacked.join(relative).is_file(),
"{relative} 没进包(内容丢了)"
);
}
}
#[test]
fn a_same_format_export_is_still_allowed_and_byte_stable() {
// 反向对照:新管线不能把 M110 的同格式往返弄坏(r1 明确要保住的能力)。
let fixture = fixtures::curseforge("rt-same-cf");
let pack = PackArchive::open_default(&fixture.zip).expect("打开 CF 夹具");
let original = CURSEFORGE.read(&pack).expect("读 CF 清单");
let game = instance_from(&fixture.dir, &original, "rt-same-cf-game");
write_layer(&game, "1.20.1-forge-47.2.0", FORGE_LAYER);
let target = game.with_file_name("same.zip");
export_instance(
&ExportRequest::new(&game, &target, &CURSEFORGE)
.with_name(original.name.clone())
.with_version(original.version.clone().unwrap_or_default())
.with_version_id("1.20.1-forge-47.2.0"),
)
.expect("导出");
let pack = PackArchive::open_default(&target).expect("打开导出结果");
let again = CURSEFORGE.read(&pack).expect("再读回来");
assert_eq!(again.name, original.name);
assert_eq!(again.minecraft, original.minecraft);
assert_eq!(
again
.loader(LoaderKind::Forge)
.and_then(|loader| loader.version.clone()),
Some("47.2.0".to_string())
);
assert_eq!(override_paths(&again), override_paths(&original));
assert_eq!(
again
.overrides_for(PackSide::Both)
.map(|group| group.dir.clone()),
Some("overrides".to_string())
);
}
+3
View File
@@ -4,6 +4,9 @@
mod detect;
mod export;
mod export_harness;
mod export_pipeline;
mod export_roundtrip;
mod extend;
mod fixtures;
mod model;
+14 -7
View File
@@ -256,9 +256,12 @@ fn curseforge_without_name_is_a_metadata_error() {
}
#[test]
fn disabled_entries_export_as_optional_on_both_formats() {
// 禁用位(M115 调研 §字段缺口 2)在两边的落法不同,且都不该靠"猜":
// CF → `required: false`;mrpack → `env.client: optional`(D10 的映射)。
fn disabled_entries_export_as_path_suffix_on_mrpack_and_required_false_on_curseforge() {
// 禁用位(M115 调研 §字段缺口 2 / §8.1)在两边的落法不同,且都不该靠"猜":
// mrpack → `files[].path` 结尾 `.disabled`(官方导出的表达,**不是** env.client=optional:
// 后者只是"安装时可取消勾选",装回来默认仍然启用 ⇒ 跨启动器不保真);
// CF → 没有原生表达,格式层只能写 `required: false`(Prism 同款);导出**管线**更严:
// 禁用文件直接内联进 overrides/ 并保留后缀(见 export::pipeline 的 DisabledNotExpressible)。
let cf = fixtures::curseforge("disabled-cf");
let pack = PackArchive::open_default(&cf.zip).expect("打开 CF 夹具");
let mut manifest = CURSEFORGE.read(&pack).expect("读 CF 清单");
@@ -273,7 +276,7 @@ fn disabled_entries_export_as_optional_on_both_formats() {
assert_eq!(
reread.files[0].client,
EnvSupport::Optional,
"CF 用 required=false 表达禁用"
"CF 没有禁用表达,格式层只能落 required=false"
);
let mr = fixtures::modrinth("disabled-mr");
@@ -290,10 +293,14 @@ fn disabled_entries_export_as_optional_on_both_formats() {
.expect("导出 mrpack");
let written = PackArchive::open_default(&target).expect("打开导出的 mrpack");
let reread = MODRINTH.read(&written).expect("再读 mrpack");
assert_eq!(reread.files[0].client, EnvSupport::Optional);
assert_eq!(
reread.files[0].path.as_deref(),
Some("mods/jei.jar"),
"禁用不靠路径后缀表达(与 D10 的映射一致)"
Some("mods/jei.jar.disabled"),
"禁用靠路径后缀表达(官方导出的做法,跨启动器保真)"
);
assert_eq!(
reread.files[0].client,
EnvSupport::Required,
"禁用不再被翻译成 env.client=optional(tower 定案)"
);
}