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:
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())
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,9 @@
|
||||
|
||||
mod detect;
|
||||
mod export;
|
||||
mod export_harness;
|
||||
mod export_pipeline;
|
||||
mod export_roundtrip;
|
||||
mod extend;
|
||||
mod fixtures;
|
||||
mod model;
|
||||
|
||||
@@ -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 定案)"
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user