From 90b233088fe2e9dead96797f1e22bea3e1d80c31 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 4 Nov 2026 18:45:46 +0800 Subject: [PATCH] =?UTF-8?q?feat(core):=20M112=20=E6=95=B4=E5=90=88?= =?UTF-8?q?=E5=8C=85=E5=AF=BC=E5=87=BA=EF=BC=88mrpack=20=E4=BC=98=E5=85=88?= =?UTF-8?q?=20+=20CF=20=E6=B8=85=E5=8D=95=EF=BC=8C=E9=80=90=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E8=BF=9C=E7=A8=8B=E5=88=A4=E5=AE=9A=E4=B8=8E=E5=86=85?= =?UTF-8?q?=E8=81=94=E5=85=9C=E5=BA=95=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 addon::export(实例目录 → .mrpack / CF manifest.json): - 逐文件三分:远程引用 / 内联进 overrides/ / 跳过(读不了、符号链接、路径不安全)。 「能否被远程替代」= 目录能证明身份(Modrinth SHA-1、CF 指纹)**且**目标格式表达得了; 证明不了的一律内联,绝不丢、绝不编,逐条进结构化报告(ExportReport)。 - game / dependencies 来自实例快照:versions//.json 的继承链「叶子」+ 库坐标推加载器, 不解析 -- 字符串(社区实例名不统一,那是猜)。 - .disabled 走路径后缀(不是 env.client=optional:后者装回来默认启用,跨启动器不保真)。 - CF files[] 只写能确定 projectID/fileID 的项;禁用文件 CF 表达不了,内联并记原因。 CF modLoaders[].id 按官方写法:NeoForge 在 1.20.1 上写 neoforge-1.20.1-。 - 尺寸护栏: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 端到端留作欠账。 --- crates/core/src/addon/export.rs | 65 +++ crates/core/src/addon/export/catalog.rs | 147 +++++++ crates/core/src/addon/export/classify.rs | 112 ++++++ crates/core/src/addon/export/pipeline.rs | 310 +++++++++++++++ crates/core/src/addon/export/report.rs | 235 +++++++++++ crates/core/src/addon/export/scan.rs | 189 +++++++++ crates/core/src/addon/export/snapshot.rs | 209 ++++++++++ crates/core/src/addon/mod.rs | 9 + crates/core/src/addon/pack/curseforge.rs | 7 +- .../src/addon/pack/curseforge/fingerprint.rs | 262 +++++++++++++ crates/core/src/addon/pack/format.rs | 66 ++-- crates/core/src/addon/pack/format/payload.rs | 70 ++++ crates/core/src/addon/pack/manifest.rs | 24 +- .../core/src/addon/pack/manifest/entries.rs | 13 +- crates/core/src/addon/pack/modrinth.rs | 38 +- .../src/addon/pack/tests/export_harness.rs | 121 ++++++ .../src/addon/pack/tests/export_pipeline.rs | 370 ++++++++++++++++++ .../src/addon/pack/tests/export_roundtrip.rs | 280 +++++++++++++ crates/core/src/addon/pack/tests/mod.rs | 3 + crates/core/src/addon/pack/tests/roundtrip.rs | 21 +- 20 files changed, 2491 insertions(+), 60 deletions(-) create mode 100644 crates/core/src/addon/export.rs create mode 100644 crates/core/src/addon/export/catalog.rs create mode 100644 crates/core/src/addon/export/classify.rs create mode 100644 crates/core/src/addon/export/pipeline.rs create mode 100644 crates/core/src/addon/export/report.rs create mode 100644 crates/core/src/addon/export/scan.rs create mode 100644 crates/core/src/addon/export/snapshot.rs create mode 100644 crates/core/src/addon/pack/curseforge/fingerprint.rs create mode 100644 crates/core/src/addon/pack/format/payload.rs create mode 100644 crates/core/src/addon/pack/tests/export_harness.rs create mode 100644 crates/core/src/addon/pack/tests/export_pipeline.rs create mode 100644 crates/core/src/addon/pack/tests/export_roundtrip.rs diff --git a/crates/core/src/addon/export.rs b/crates/core/src/addon/export.rs new file mode 100644 index 0000000..7068744 --- /dev/null +++ b/crates/core/src/addon/export.rs @@ -0,0 +1,65 @@ +//! 整合包**导出**(M112):实例目录 → `.mrpack`(优先)/ CurseForge `manifest.json`(次之)。 +//! +//! 设计依据:`DevDocs/PlanDoc/08-mod-and-recommend.md` §2(D10)、 +//! `DevDocs/SubAgentsTalk/addon-整合包导入导出对接要点.md` §4(导出八条),格式模型与安全闸 +//! 是 M110 的 [`crate::addon::pack`],本模块**只消费,不重写**。 +//! +//! # 一句话口径:不认识的 mod 不能丢、不能编 +//! +//! 用户原话(M112 任务书):「要么能证明它可被远程替代,要么就必须内联进 `overrides/` 并在 +//! 结果里逐条报告」。落到代码就是三分类,**没有第四类**: +//! +//! | 分类 | 判据 | 产物 | +//! |------|------|------| +//! | 远程引用([`FileClass::Remote`]) | 目标格式**能表达**它 + 某个目录([`RemoteCatalog`])**能证明**它的身份 | 清单 `files[]` 一条 | +//! | 内联([`FileClass::Inline`]) | 证明不了,或证明了但目标格式表达不了 | 进 `overrides/`,原字节照搬 | +//! | 跳过([`FileClass::Skipped`]) | 读不了 / 是符号链接 / 路径不安全 | 不写进包,**逐条**进报告 | +//! +//! 「能证明」用两条独立查询(契约 §4.4 的顺序):**Modrinth SHA-1** → +//! [`RemoteCatalog::modrinth_by_sha1`];**CurseForge 指纹**([`crate::addon::pack::curseforge::fingerprint`]) +//! → [`RemoteCatalog::curseforge_by_fingerprint`]。两条都命中时两条直链都进 `downloads[]` +//! (HMCL 同款,兼容面更大)。**没有网络时**用 [`OfflineCatalog`]:所有文件都内联,包照样完整可导入 +//! ——这不是降级失败,是"证明不了就内联"的正面行为。 +//! +//! # 每条格式决策落在哪(tower 定案,别按任务书原话改回去) +//! +//! 1. **`.disabled` 走路径后缀**(`mods/x.jar.disabled`),不是 `env.client=optional`:后者只是 +//! "安装时可取消勾选",装回来默认仍然启用 ⇒ 跨启动器不保真。官方 Modrinth 导出也是后缀。 +//! 2. **覆盖目录一律 `overrides/`**(不用 HMCL 那套 `client-overrides/`):Modrinth 官方与 +//! Prism 都用它,兼容面最大。分侧语义在本轮导出里不产生(实例目录本身不分侧)。 +//! 3. **`game` + `dependencies` 从实例快照取**,绝不只写 `minecraft` 漏加载器:快照来自 +//! `versions//.json` 的继承链 + 库坐标([`snapshot`]),**不解析** +//! `--` 这类实例 id 字符串(社区实例名不统一,那是猜)。 +//! 4. **CF `files[]` 只写能确定 `projectID`/`fileID` 的项**:CF 没有"内联到 files[]"的概念, +//! 证明不了的(以及**禁用文件**:CF 根本没有禁用表达)一律进 `overrides/` 并进报告。 +//! 5. **尺寸护栏**:`fileSize` 是 u32(超了报 `pack_limits`,不静默截断)、整包撞 ZIP64 阈值由 +//! `format::write` 拒绝(`pack_limits`,不留半个包)。 +//! +//! # 刻意不做的事(分工边界,别以为是漏做) +//! +//! - **MultiMC / Prism 导出**:不做(P1 只做导入)。它的包是"整实例快照",要先把所有远程文件 +//! 下载下来再打包,成本极高;导入侧 M111 已覆盖。**这是有意省略。** +//! - **不装加载器 / 不补全清单**:加载器元数据解析与库下载是 `addon::loader` + `game::install`; +//! CF 的 `projectID/fileID → 文件名/URL` 补全需要 CF 客户端(D10 §5 规划中),本轮由调用方注入 +//! [`RemoteCatalog`],本模块不发网络请求。 +//! - **不碰 UI 类型**:core 零 UI 依赖(`core-no-ui` 门禁),报告只出现 `String` / `&'static str`。 +//! - **不改实例**:导出只读实例目录。 +//! +//! # 没验的部分(报告里同样登记) +//! +//! 真实 Modrinth / CurseForge API 查询(要网络与 key,`RemoteCatalog` 目前只有离线实现)、 +//! 超大实例(GB 级、上千文件——指纹要对每个文件多读两遍)、Windows 之外的平台、 +//! 以及「M111 合并后的 `import_pack → export → import_pack` 真端到端」。 + +pub mod catalog; +pub(crate) mod classify; +pub mod pipeline; +pub mod report; +pub mod scan; +pub mod snapshot; + +pub use catalog::{OfflineCatalog, RemoteCatalog, RemoteHit, RemoteSource}; +pub use pipeline::{export_instance, ExportRequest}; +pub use report::{ExportFile, ExportReport, FileClass, FileReason}; +pub use scan::{scan_instance, LocalFile, SkippedLocal, DEFAULT_CONTENT_ROOTS, OVERRIDES_DIR}; +pub use snapshot::{read_instance_snapshot, InstanceSnapshot, SnapshotLoader}; diff --git a/crates/core/src/addon/export/catalog.rs b/crates/core/src/addon/export/catalog.rs new file mode 100644 index 0000000..6147886 --- /dev/null +++ b/crates/core/src/addon/export/catalog.rs @@ -0,0 +1,147 @@ +//! 「能否被远程替代」的**注入点**:两条查询 + 一个离线默认实现。 +//! +//! # 为什么是 trait 而不是直接调 API +//! +//! 与 M111 的 `PackFetcher` 同一条理由:测试不能联网;CF 的查表要 `x-api-key`(D10 §5 的客户端 +//! 还没写);而"证明不了"必须能被如实表达——查表失败 ≠ 文件有问题。所以导出管线只认这个 trait, +//! 生产实现将来接 Modrinth `/v2/version_file/{sha1}` 与 CF `/v1/fingerprints/432`。 +//! +//! # 两条查询的语义差别(别合并成一个) +//! +//! | 查询 | 键 | 命中能给出什么 | 谁用得上 | +//! |------|-----|----------------|----------| +//! | [`RemoteCatalog::modrinth_by_sha1`] | 文件 SHA-1 | `downloads[]` + `hashes`(含 `sha512`)+ `fileSize` | mrpack(它要求 sha512) | +//! | [`RemoteCatalog::curseforge_by_fingerprint`] | CF 指纹 | `projectID`/`fileID`(+ 可选直链) | CF 清单(它只认这两个 id) | +//! +//! 只命中一条时,另一格式通常用不上它——这正是 [`crate::addon::export::report::FileReason`] +//! 要区分 `CurseOnlyMatch` / `ModrinthOnlyMatch` 的原因:报告要说清"证明了,但目标格式表达不了", +//! 而不是笼统地写"找不到"。 +//! +//! # 哨兵值 +//! +//! CF 指纹 `811513880` 会误命中(HMCL issue #4597),由**调用方**在建表前短路: +//! 见 `curseforge::fingerprint::is_sentinel`。 + +use std::collections::BTreeMap; + +/// 这条命中来自哪个目录。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RemoteSource { + /// Modrinth(按 SHA-1 查)。 + Modrinth, + /// CurseForge(按文件指纹查)。 + CurseForge, +} + +/// 一次远程命中的结果。 +/// +/// 字段刻意按"能写进目标清单"组织,而不是按某个 API 的响应形状: +/// mrpack 要 `downloads` + `hashes`,CF 要 `projectID`/`fileID`,两个都给了就是两份清单都能写。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemoteHit { + /// 来源目录。 + pub source: RemoteSource, + /// 直链候选(多源;mrpack 写进 `downloads[]`,CF 清单没有这个字段但也留着做日志)。 + pub downloads: Vec, + /// 摘要表(键名小写)。Modrinth 侧必须含 `sha1` 与 `sha512`——后者是 mrpack 的硬要求。 + pub hashes: BTreeMap, + /// 服务端记录的文件字节数(与本地实际字节数不一致时以本地为准,见 `pipeline`)。 + pub size: Option, + /// CurseForge `projectID`。 + pub project_id: Option, + /// CurseForge `fileID`。 + pub file_id: Option, +} + +impl RemoteHit { + /// 一次 Modrinth 命中:直链 + 摘要表(必须有 `sha512`,否则 mrpack 写不出来)。 + #[must_use] + pub fn modrinth( + downloads: Vec, + hashes: BTreeMap, + size: Option, + ) -> Self { + Self { + source: RemoteSource::Modrinth, + downloads, + hashes, + size, + project_id: None, + file_id: None, + } + } + + /// 一次 CurseForge 命中:`projectID`/`fileID` 是 CF 清单的**全部**必需信息。 + #[must_use] + pub fn curseforge(project_id: i64, file_id: i64) -> Self { + Self { + source: RemoteSource::CurseForge, + downloads: Vec::new(), + hashes: BTreeMap::new(), + size: None, + project_id: Some(project_id), + file_id: Some(file_id), + } + } + + /// 顺带给一条 CF 直链(有就写进 `downloads[]`,没有也不影响 CF 清单)。 + #[must_use] + pub fn with_download(mut self, url: impl Into) -> Self { + self.downloads.push(url.into()); + self + } + + /// `hashes.sha512`。 + #[must_use] + pub fn sha512(&self) -> Option<&str> { + self.hashes.get("sha512").map(String::as_str) + } + + /// 能不能写进 CF 的 `files[]`。 + #[must_use] + pub fn curse_ids(&self) -> Option<(i64, i64)> { + match (self.project_id, self.file_id) { + (Some(project), Some(file)) => Some((project, file)), + _ => None, + } + } + + /// 有没有能写进 mrpack 的 `downloads[]` 的非空直链。 + #[must_use] + pub fn has_download(&self) -> bool { + self.downloads.iter().any(|url| !url.trim().is_empty()) + } +} + +/// 「这个文件在远端是谁」的查询接口。 +/// +/// 两个方法都必须**只做本地判据 + 调用方给的缓存**:实现里如果联网,那就是生产的 +/// Modrinth/CF 客户端(D10 §5),本模块不关心它怎么拿到答案。 +pub trait RemoteCatalog { + /// 按 SHA-1 查 Modrinth 版本文件;查不到返回 `None`(**不要**用 `None` 表示网络失败以外的 + /// 「本地构造不了查询」——那种情况应当返回 `None` 并让文件内联)。 + fn modrinth_by_sha1(&self, sha1: &str) -> Option; + + /// 按 CurseForge 指纹查项目/文件;查不到返回 `None`。 + /// + /// 调用方已按哨兵值(`811513880`)短路,实现不必重复判断。 + fn curseforge_by_fingerprint(&self, fingerprint: u32) -> Option; +} + +/// 离线:什么都证明不了 ⇒ 所有文件内联。 +/// +/// 这是**默认**行为,也是本仓库当前的唯一实现:没有 CF 客户端(要 key)、没有 Modrinth 客户端 +/// (D10 §5 规划中),联网查询在测试里也不允许。用它的导出结果是"一个完整、可导入、全部走 +/// `overrides/` 的包"——不是残废包。 +#[derive(Debug, Clone, Copy, Default)] +pub struct OfflineCatalog; + +impl RemoteCatalog for OfflineCatalog { + fn modrinth_by_sha1(&self, _sha1: &str) -> Option { + None + } + + fn curseforge_by_fingerprint(&self, _fingerprint: u32) -> Option { + None + } +} diff --git a/crates/core/src/addon/export/classify.rs b/crates/core/src/addon/export/classify.rs new file mode 100644 index 0000000..e302331 --- /dev/null +++ b/crates/core/src/addon/export/classify.rs @@ -0,0 +1,112 @@ +//! 逐文件判定(导出管线的第 ③ 步):这份本地文件在目标格式里能不能被远程替代。 +//! +//! 判定要**两个条件同时成立**才算远程引用: +//! +//! 1. 某个目录([`RemoteCatalog`])能**证明**它的身份——Modrinth 按 SHA-1、CurseForge 按指纹; +//! 2. 目标格式**表达得了**这个证明——mrpack 要 `path` + `sha512` + `downloads`, +//! CF 清单要 `projectID` + `fileID`。 +//! +//! 只满足第一条时就是"证明了但用不上",它有自己的原因(`CurseOnlyMatch` / +//! `ModrinthOnlyMatch`),不能和"两个目录都不认识"混成一句话。 +//! +//! 分成单独一个文件是为了让 [`super::pipeline`] 只讲流程;这里全是判据,可以逐条对着测 +//! (`classify_with` 甚至允许测试直接喂指纹——哨兵值 `811513880` 用真实文件测不出来)。 + +use super::catalog::{RemoteCatalog, RemoteHit}; +use super::report::{ExportFile, FileReason}; +use super::scan::LocalFile; +use crate::addon::pack::curseforge::fingerprint; +use crate::addon::pack::manifest::{EnvSupport, PackFile}; + +/// 判定一条本地文件属于哪一类(外加命中,供组 `files[]` 用)。 +pub(super) fn classify( + local: &LocalFile, + catalog: &dyn RemoteCatalog, + target_is_curse: bool, +) -> (FileReason, Option) { + // 指纹要读两遍盘;读不出来就按"没有 CF 侧证据"处理(文件本身仍会内联进包)。 + let value = fingerprint::fingerprint_file(&local.disk).ok(); + classify_with(local, catalog, target_is_curse, value) +} + +/// 同 [`classify`],但指纹由调用方给(`None` = 读不出来)。 +/// +/// `pub(crate)` 只有一个理由:哨兵值 `811513880` 那条分支**没法用真实文件测**——它是误命中, +/// 找不到一个能算出它的样本(要 2^32 次穷举)。测试直接喂这个数,验证它被当成"查不到"。 +pub(crate) fn classify_with( + local: &LocalFile, + catalog: &dyn RemoteCatalog, + target_is_curse: bool, + fingerprint: Option, +) -> (FileReason, Option) { + let modrinth = catalog.modrinth_by_sha1(&local.sha1); + let curseforge = match fingerprint { + Some(value) if !fingerprint::is_sentinel(value) => catalog.curseforge_by_fingerprint(value), + Some(_) | None => None, + }; + if target_is_curse { + if local.disabled { + // CF 没有禁用表达:`required:false` 是"可选"不是"禁用",硬写就等于把它变成启用。 + return (FileReason::DisabledNotExpressible, None); + } + return match curseforge { + Some(hit) if hit.curse_ids().is_some() => { + (FileReason::CurseForgeFingerprint, Some(hit)) + } + Some(_) => (FileReason::MissingCurseIds, None), + None if modrinth.is_some() => (FileReason::ModrinthOnlyMatch, None), + None => (FileReason::NoRemoteMatch, None), + }; + } + match modrinth { + Some(hit) if hit.sha512().is_some() && hit.has_download() => { + let mut merged = hit; + // 两条都命中:两条直链都写进 downloads[](HMCL 同款,兼容面更大)。 + if let Some(curse) = curseforge { + for url in curse.downloads { + if !url.trim().is_empty() && !merged.downloads.iter().any(|seen| seen == &url) { + merged.downloads.push(url); + } + } + merged.project_id = merged.project_id.or(curse.project_id); + merged.file_id = merged.file_id.or(curse.file_id); + } + (FileReason::ModrinthSha1, Some(merged)) + } + Some(_) => (FileReason::MissingSha512, None), + None if curseforge.is_some() => (FileReason::CurseOnlyMatch, None), + None => (FileReason::NoRemoteMatch, None), + } +} + +/// 远程条目:`path` 用实例内路径(禁用文件已带 `.disabled` 后缀),`size` 用本地实测字节数。 +pub(super) fn remote_entry(local: &LocalFile, hit: &RemoteHit) -> PackFile { + let mut hashes = hit.hashes.clone(); + hashes.insert("sha1".to_string(), local.sha1.clone()); + PackFile { + path: Some(local.path.clone()), + project_id: hit.project_id, + file_id: hit.file_id, + hashes, + downloads: hit.downloads.clone(), + size: Some(local.bytes), + disabled: local.disabled, + client: EnvSupport::Required, + server: EnvSupport::Required, + ..PackFile::default() + } +} + +/// 报告里的一条。 +pub(super) fn entry(local: &LocalFile, reason: FileReason, hit: Option<&RemoteHit>) -> ExportFile { + ExportFile { + path: local.path.clone(), + class: reason.class(), + reason, + bytes: local.bytes, + downloads: hit.map(|hit| hit.downloads.clone()).unwrap_or_default(), + project_id: hit.and_then(|hit| hit.project_id), + file_id: hit.and_then(|hit| hit.file_id), + disabled: local.disabled, + } +} diff --git a/crates/core/src/addon/export/pipeline.rs b/crates/core/src/addon/export/pipeline.rs new file mode 100644 index 0000000..89fc21c --- /dev/null +++ b/crates/core/src/addon/export/pipeline.rs @@ -0,0 +1,310 @@ +//! 导出管线:实例目录 → 三分类 → 清单 + `overrides/` → 包。 +//! +//! 顺序是写死的,每一步都可单独测: +//! +//! ```text +//! ① 实例快照(snapshot) versions//.json 的继承链 + 库坐标 ⇒ game / dependencies +//! ② 扫描(scan) 内容目录 → 路径 + 字节数 + SHA-1 + 禁用位;读不了的进跳过表 +//! ③ 逐文件判定(classify) Modrinth SHA-1 → CF 指纹 → 目标格式能不能表达 ⇒ 远程 / 内联 / 跳过 +//! ④ 组模型 files[] = 远程;overrides = 内联(统一 `overrides/`) +//! ⑤ 写包 走 M110 的 `PackFormat::write`(临时文件 + rename,失败不留半个包) +//! ``` +//! +//! # 判定为什么要"目标格式能力"参与 +//! +//! 「能被远程替代」不是文件自己的属性,是**文件 + 目标格式**的属性:同一个文件,CF 认得 +//! `projectID/fileID` 就能写进 CF 的 `files[]`,但 mrpack 要 `sha512` 才写得出来;反过来 +//! mrpack 认可 Modrinth 直链,CF 清单却只认两个整数 id。所以 +//! [`crate::addon::export::report::FileReason`] 里 `CurseOnlyMatch` 与 `ModrinthOnlyMatch` +//! 是两个不同的事实,不能合并成"没找到"。 +//! +//! # 一条禁令 +//! +//! **不猜**:目录没给命中就内联;命中缺必需字段(`sha512` / `projectID`)也内联;只有 +//! "两条硬要求都齐"才写进 `files[]`。这样导出的包在任何启动器里都能装出**同一份内容**, +//! 最多是"该远程的走了内联"(体积大一点,内容不会变)。 + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use serde_json::Map; + +use super::catalog::{OfflineCatalog, RemoteCatalog}; +use super::classify::{classify, entry, remote_entry}; +use super::report::{ExportFile, ExportReport, FileClass}; +use super::scan::{scan_instance, DEFAULT_CONTENT_ROOTS, OVERRIDES_DIR}; +use super::snapshot::{read_instance_snapshot, InstanceSnapshot}; +use crate::addon::pack::curseforge; +use crate::addon::pack::error::{PackError, PackResult}; +use crate::addon::pack::format::{PackFormat, PackPayload}; +use crate::addon::pack::manifest::{ + OverrideFile, PackLoader, PackManifest, PackOverrides, PackSide, +}; + +/// 包版本缺省值(CF `version` / mrpack `versionId` 都必填,调用方没给就用它)。 +pub const DEFAULT_VERSION: &str = "1.0.0"; + +/// 默认目录(离线):所有文件内联。 +static OFFLINE: OfflineCatalog = OfflineCatalog; + +/// 一次导出的输入。 +pub struct ExportRequest<'a> { + root: &'a Path, + target: &'a Path, + format: &'static dyn PackFormat, + name: Option, + version: Option, + version_id: Option<&'a str>, + content_roots: Option>, + catalog: &'a dyn RemoteCatalog, + snapshot: Option<&'a InstanceSnapshot>, +} + +impl<'a> ExportRequest<'a> { + /// 从 `root`(实例游戏目录)导出到 `target`,用 `format` 的清单形状。 + /// + /// 默认:包名取目录名、版本 [`DEFAULT_VERSION`]、内容目录取 [`DEFAULT_CONTENT_ROOTS`]、 + /// 目录用 [`OfflineCatalog`](= 全部内联)。 + #[must_use] + pub fn new(root: &'a Path, target: &'a Path, format: &'static dyn PackFormat) -> Self { + Self { + root, + target, + format, + name: None, + version: None, + version_id: None, + content_roots: None, + catalog: &OFFLINE, + snapshot: None, + } + } + + /// 包名(写进清单 `name`)。 + #[must_use] + pub fn with_name(mut self, name: impl Into) -> Self { + self.name = Some(name.into()); + self + } + + /// 包版本(写进 CF `version` / mrpack `versionId`)。 + #[must_use] + pub fn with_version(mut self, version: impl Into) -> Self { + self.version = Some(version.into()); + self + } + + /// 实例绑定的版本 id;不给就要求 `versions/` 下只有一个版本。 + #[must_use] + pub fn with_version_id(mut self, version_id: &'a str) -> Self { + self.version_id = Some(version_id); + self + } + + /// 覆盖要扫的内容目录(默认 [`DEFAULT_CONTENT_ROOTS`])。 + #[must_use] + pub fn with_content_roots(mut self, roots: Vec) -> Self { + self.content_roots = Some(roots); + self + } + + /// 注入远端查表(默认离线:全部内联)。 + #[must_use] + pub fn with_catalog(mut self, catalog: &'a dyn RemoteCatalog) -> Self { + self.catalog = catalog; + self + } + + /// 直接给实例快照,跳过 `versions//.json` 的读取。 + /// + /// 用于两种实例:①**Prism / MultiMC 实例**(游戏目录里没有 `versions/`,快照只能从 + /// `mmc-pack.json` 的组件表来,调用方用 `pack::multimc` 读出来再传);②调用方已经解析过 + /// 快照(UI 侧手里就有 profile + 加载器信息),不必再读一遍盘。 + #[must_use] + pub fn with_snapshot(mut self, snapshot: &'a InstanceSnapshot) -> Self { + self.snapshot = Some(snapshot); + self + } +} + +/// 导出一个实例。 +/// +/// # Errors +/// +/// - `pack_unsupported`:实例里找不到唯一的版本 JSON / 目标格式表达不了布局; +/// - `pack_metadata`:版本 JSON 坏了(继承成环、不是 JSON); +/// - `pack_limits`:单文件 `fileSize` 超 u32、或整包撞 ZIP64 阈值; +/// - `pack_io`:读写盘失败(失败时目标路径不留半个包)。 +pub fn export_instance(request: &ExportRequest<'_>) -> PackResult { + let snapshot = match request.snapshot { + Some(snapshot) => snapshot.clone(), + None => read_instance_snapshot(request.root, request.version_id)?, + }; + let roots = request + .content_roots + .clone() + .unwrap_or_else(|| DEFAULT_CONTENT_ROOTS.map(str::to_string).to_vec()); + let (locals, skipped) = scan_instance(request.root, &roots); + + // CF 清单只认 projectID/fileID;mrpack 要 path + sha512 + downloads。 + let target_is_curse = request.format.id() == curseforge::ID; + let mut manifest = build_manifest(request, &snapshot); + let mut entries: Vec = Vec::with_capacity(locals.len() + skipped.len()); + let mut overrides: Vec = Vec::new(); + let mut sources: BTreeMap = BTreeMap::new(); + + for local in &locals { + let (reason, hit) = classify(local, request.catalog, target_is_curse); + match reason.class() { + FileClass::Remote => { + let Some(hit) = hit else { + // 判定逻辑的自我约束:Remote 必带命中。真走到这里说明分类函数被改坏了。 + return Err(PackError::unsupported(format!( + "内部不一致:{} 被判为远程引用却没有命中", + local.path + ))); + }; + manifest.files.push(remote_entry(local, &hit)); + entries.push(entry(local, reason, Some(&hit))); + } + FileClass::Inline => { + overrides.push(OverrideFile { + path: local.path.clone(), + sha1: local.sha1.clone(), + size: local.bytes, + }); + sources.insert( + format!("{OVERRIDES_DIR}/{}", local.path), + local.disk.clone(), + ); + entries.push(entry(local, reason, None)); + } + FileClass::Skipped => unreachable!("扫描阶段不会产出可导出项"), + } + } + // 跳过项照原样进报告(逐条给原因),不进包。 + for item in &skipped { + entries.push(ExportFile { + path: item.path.clone(), + class: FileClass::Skipped, + reason: item.reason, + bytes: 0, + downloads: Vec::new(), + project_id: None, + file_id: None, + disabled: false, + }); + } + + if !overrides.is_empty() { + // 一律 `overrides/`(Modrinth 官方 + Prism 的写法)。实例目录不分侧,所以只有一个 Both 组。 + manifest.overrides.push(PackOverrides { + dir: OVERRIDES_DIR.to_string(), + side: PackSide::Both, + files: overrides, + }); + } + + request.format.write( + &manifest, + PackPayload::with_sources(request.root, &sources), + request.target, + )?; + let bytes = std::fs::metadata(request.target) + .map_err(|error| { + PackError::io(format!( + "量 {} 的字节数失败:{error}", + request.target.display() + )) + })? + .len(); + + entries.sort_by(|left, right| left.path.cmp(&right.path)); + let notes = notes(target_is_curse, &manifest); + Ok(ExportReport { + format: request.format.id(), + label: request.format.label(), + path: request.target.to_path_buf(), + bytes, + name: manifest.name.clone(), + version: manifest.version.clone(), + minecraft: manifest.minecraft.clone(), + loaders: manifest + .loaders + .iter() + .map(|loader| { + format!( + "{} {}", + loader.dependency_key(), + loader.version.clone().unwrap_or_default() + ) + }) + .collect(), + files: entries, + notes, + }) +} + +/// 组清单:`game` / `minecraft` / `loaders` 全部来自实例快照。 +fn build_manifest(request: &ExportRequest<'_>, snapshot: &InstanceSnapshot) -> PackManifest { + let name = request + .name + .clone() + .or_else(|| { + request + .root + .file_name() + .map(|name| name.to_string_lossy().to_string()) + }) + .unwrap_or_else(|| "整合包".to_string()); + let mut manifest = PackManifest::new(name); + manifest.version = Some( + request + .version + .clone() + .unwrap_or_else(|| DEFAULT_VERSION.to_string()), + ); + manifest.format_version = Some(1); + manifest.game = Some(crate::addon::pack::modrinth::GAME.to_string()); + manifest.minecraft = Some(snapshot.game_version.clone()); + manifest.loaders = snapshot + .loaders + .iter() + .enumerate() + .map(|(index, loader)| PackLoader { + raw: String::new(), + kind: Some(loader.kind), + version: Some(loader.version.clone()), + primary: index == 0, + extra: Map::new(), + }) + .collect(); + manifest +} + +/// 格式层面表达不了的东西(已知损失)——必须显式列出来,别让用户以为往返无损。 +fn notes(target_is_curse: bool, manifest: &PackManifest) -> Vec { + let mut notes = vec![ + "覆盖内容统一写进 `overrides/`(Modrinth 官方与 Prism 的写法);实例目录不分侧,\ + 所以不产生 client-overrides / server-overrides。" + .to_string(), + "禁用文件用**路径后缀 `.disabled`** 表达(不是 `env.client=optional`:后者装回来默认启用)。" + .to_string(), + "MultiMC / Prism 导出本轮不做(只有导入),这是有意省略,不是遗漏。".to_string(), + ]; + if target_is_curse { + notes.push("mrpack 的 `summary` 在 CF 清单里没有字段;CF 也只认 projectID/fileID,禁用文件只能内联。".to_string()); + } else { + notes.push( + "CF 清单的 `author` / `manifestVersion` 之外的扩展字段在 mrpack 里没有对应位置。" + .to_string(), + ); + } + if !manifest.files.is_empty() { + notes.push(format!( + "{} 条写进 files[](远程引用),其余在 overrides/ 里;每条的原因见 files[].reason。", + manifest.files.len() + )); + } + notes +} diff --git a/crates/core/src/addon/export/report.rs b/crates/core/src/addon/export/report.rs new file mode 100644 index 0000000..231570a --- /dev/null +++ b/crates/core/src/addon/export/report.rs @@ -0,0 +1,235 @@ +//! 导出结果:**三分类 + 逐条原因**,给 CLI 的 `--json` 与 M113 的页面直接消费。 +//! +//! # 为什么每条都要有原因 +//! +//! 用户要的是「不认识的 mod 不能丢、不能编」。光说"导出了 120 个文件"会掩盖真正的信息: +//! 其中多少条**能证明**可被远程替代、多少条**证明不了所以内联**、多少条**干脆没进包**。 +//! 所以 [`ExportFile`] 的 `class` 与 `reason` 是两个独立维度,且 `reason` 决定 `class` +//! ([`FileReason::class`] 是唯一映射处,写死在这里,别在别处再判一遍)。 +//! +//! # 「允许的差异」也进报告 +//! +//! [`ExportReport::notes`] 收的是**格式层面表达不了**的东西(CF 的 `author`、mrpack 的 +//! `summary`、CF 表达不了禁用位……)。它们是已知损失,必须显式列出,不能让用户以为"往返无损"。 + +use std::path::PathBuf; + +use serde::{Serialize, Serializer}; + +/// 一条文件在导出结果里的归类。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FileClass { + /// 写进清单 `files[]`(靠远端直链/项目 id 代替本地字节)。 + Remote, + /// 写进 `overrides/`(原字节照搬)。 + Inline, + /// 没进包(读不了 / 符号链接 / 路径不安全)。 + Skipped, +} + +impl FileClass { + /// 稳定字符串 id(进 `--json` 与日志,**不要改名**)。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Remote => "remote", + Self::Inline => "inline", + Self::Skipped => "skipped", + } + } +} + +impl Serialize for FileClass { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(self.as_str()) + } +} + +/// 一条文件为什么归到那个类里。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FileReason { + /// 远程引用:Modrinth SHA-1 查到了版本文件。 + ModrinthSha1, + /// 远程引用:CurseForge 指纹查到了项目/文件。 + CurseForgeFingerprint, + /// 内联:两个目录都不认识它。 + NoRemoteMatch, + /// 内联:CF 认识,但目标格式(mrpack)用不上——CF 命中不含 `sha512`。 + CurseOnlyMatch, + /// 内联:Modrinth 认识,但目标格式(CF 清单)只认 `projectID`/`fileID`。 + ModrinthOnlyMatch, + /// 内联:目录给了命中但缺 `hashes.sha512`(mrpack 的硬要求)。 + MissingSha512, + /// 内联:目录给了命中但缺 `projectID`/`fileID`(CF 清单的硬要求)。 + MissingCurseIds, + /// 内联:文件是禁用的(`.disabled`),而目标格式表达不了禁用位(CF)。 + DisabledNotExpressible, + /// 跳过:符号链接。 + SymbolicLink, + /// 跳过:文件名过不了包内路径规整(`:` / `\` / `..` / 绝对路径…)。 + UnsafePath, + /// 跳过:读盘或算摘要失败。 + Unreadable, +} + +impl FileReason { + /// 稳定字符串 id(进 `--json` 与日志,**不要改名**)。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::ModrinthSha1 => "modrinth_sha1", + Self::CurseForgeFingerprint => "curseforge_fingerprint", + Self::NoRemoteMatch => "no_remote_match", + Self::CurseOnlyMatch => "curse_only_match", + Self::ModrinthOnlyMatch => "modrinth_only_match", + Self::MissingSha512 => "missing_sha512", + Self::MissingCurseIds => "missing_curse_ids", + Self::DisabledNotExpressible => "disabled_not_expressible", + Self::SymbolicLink => "symbolic_link", + Self::UnsafePath => "unsafe_path", + Self::Unreadable => "unreadable", + } + } + + /// 人读说明(中文,进报告与日志;UI 文案后续按 key 本地化)。 + #[must_use] + pub const fn label(self) -> &'static str { + match self { + Self::ModrinthSha1 => "Modrinth 命中(SHA-1)", + Self::CurseForgeFingerprint => "CurseForge 命中(文件指纹)", + Self::NoRemoteMatch => "两个目录都不认识,已内联", + Self::CurseOnlyMatch => "只有 CF 认识,目标格式用不上,已内联", + Self::ModrinthOnlyMatch => "只有 Modrinth 认识,CF 清单用不上,已内联", + Self::MissingSha512 => "命中缺 sha512,mrpack 写不出,已内联", + Self::MissingCurseIds => "命中缺 projectID/fileID,CF 清单写不出,已内联", + Self::DisabledNotExpressible => "禁用文件 CF 表达不了,已内联(保留 .disabled 后缀)", + Self::SymbolicLink => "符号链接,已跳过", + Self::UnsafePath => "路径过不了包内规整,已跳过", + Self::Unreadable => "读不出来,已跳过", + } + } + + /// 这个原因属于哪一类(**唯一映射处**)。 + #[must_use] + pub const fn class(self) -> FileClass { + match self { + Self::ModrinthSha1 | Self::CurseForgeFingerprint => FileClass::Remote, + Self::NoRemoteMatch + | Self::CurseOnlyMatch + | Self::ModrinthOnlyMatch + | Self::MissingSha512 + | Self::MissingCurseIds + | Self::DisabledNotExpressible => FileClass::Inline, + Self::SymbolicLink | Self::UnsafePath | Self::Unreadable => FileClass::Skipped, + } + } +} + +impl Serialize for FileReason { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(self.as_str()) + } +} + +/// 一条文件的导出结果。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ExportFile { + /// 实例内相对路径(含 `.disabled` 后缀,与磁盘上一致)。 + pub path: String, + /// 归类。 + pub class: FileClass, + /// 原因。 + pub reason: FileReason, + /// 字节数(跳过项记磁盘上的大小,读不到就是 0)。 + pub bytes: u64, + /// 远程引用给的直链(内联/跳过为空)。 + pub downloads: Vec, + /// CurseForge `projectID`(远程引用到 CF 时才有)。 + pub project_id: Option, + /// CurseForge `fileID`。 + pub file_id: Option, + /// 是否禁用态(`.disabled`)。 + pub disabled: bool, +} + +/// 一次导出的结构化结果。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ExportReport { + /// 目标格式 id(`modrinth` / `curseforge`)。 + pub format: &'static str, + /// 目标格式人读名。 + pub label: &'static str, + /// 产出的包路径。 + pub path: PathBuf, + /// 包的字节数(写完之后量的,不是估算)。 + pub bytes: u64, + /// 包名(写进清单的 `name`)。 + pub name: String, + /// 包版本(`versionId` / `version`)。 + pub version: Option, + /// 游戏版本(来自实例快照)。 + pub minecraft: Option, + /// 加载器(`fabric-loader 0.15.11` 这种人读串,来自实例快照)。 + pub loaders: Vec, + /// 逐文件结果(按路径排序)。 + pub files: Vec, + /// 已知的表达不了的差异 / 值得用户知道的取舍。 + pub notes: Vec, +} + +impl ExportReport { + /// 远程引用的条数。 + #[must_use] + pub fn remote_count(&self) -> usize { + self.count(FileClass::Remote) + } + + /// 内联的条数。 + #[must_use] + pub fn inline_count(&self) -> usize { + self.count(FileClass::Inline) + } + + /// 跳过的条数。 + #[must_use] + pub fn skipped_count(&self) -> usize { + self.count(FileClass::Skipped) + } + + fn count(&self, class: FileClass) -> usize { + self.files.iter().filter(|file| file.class == class).count() + } + + /// 有没有文件没进包(`false` = 包内容完整,只是有些走了内联)。 + #[must_use] + pub fn is_complete(&self) -> bool { + self.skipped_count() == 0 + } + + /// 内联文件(`overrides/` 里的那些)。 + pub fn inline_files(&self) -> impl Iterator { + self.files + .iter() + .filter(|file| file.class == FileClass::Inline) + } + + /// 不可导出的项(逐条给 `path` + `reason` 用)。 + pub fn skipped_files(&self) -> impl Iterator { + self.files + .iter() + .filter(|file| file.class == FileClass::Skipped) + } + + /// 一句话摘要(日志/CLI 一行输出用)。 + #[must_use] + pub fn summary(&self) -> String { + format!( + "{}:{} 个远程引用 / {} 个内联 / {} 个跳过,{} 字节", + self.label, + self.remote_count(), + self.inline_count(), + self.skipped_count(), + self.bytes + ) + } +} diff --git a/crates/core/src/addon/export/scan.rs b/crates/core/src/addon/export/scan.rs new file mode 100644 index 0000000..1b6e274 --- /dev/null +++ b/crates/core/src/addon/export/scan.rs @@ -0,0 +1,189 @@ +//! 实例目录扫描:把"磁盘上有什么"变成 [`LocalFile`] 表(路径 + 字节数 + SHA-1 + 禁用位)。 +//! +//! # 扫哪些目录(写死白名单,不是"排除法") +//! +//! 默认只扫 [`DEFAULT_CONTENT_ROOTS`] 这 8 个内容目录:`mods` / `config` / `defaultconfigs` / +//! `scripts` / `resourcepacks` / `shaderpacks` / `kubejs` / `openloader`。 +//! +//! 为什么用白名单而不是"排除 `versions/` `libraries/` `assets/`": +//! +//! - 那三个目录是**启动器/游戏自己管的**(版本 JSON、依赖库、资源对象),塞进整合包只会让包 +//! 变成几 GB,且与"远程可替代"完全无关; +//! - `journals` `logs/` `crash-reports/` `saves/` `screenshots/` `backups/` **不属于**整合包内容 +//! (社区导出器一致不打包它们); +//! - 排除法是"忘了就漏"的写法:以后实例里出现新目录(`.mixin.out`、`essential`…)会被静默打进去。 +//! +//! 白名单之外的东西**不是丢了**——它们本来就不该在整合包里。要打别的目录,用 +//! [`crate::addon::export::ExportRequest::with_content_roots`] 显式加。 +//! +//! # 内容目录里的一切都收(认不出的 mod 不能丢) +//! +//! 白名单目录里**不按扩展名过滤**:`mods/` 下的 `.jar`、`.jar.disabled`、`.jar.old`、 +//! `.ds_store`、社区自造后缀,一律按文件收。丢掉"我不认识的东西"正是用户点名不许的。 +//! +//! # 禁用它只看路径后缀 +//! +//! `x.jar` / `x.jar.disabled` / `x.jar.old` / `x.jar.disabled.old` 四种里,含 `.disabled` +//! (中间可能夹 `.old`)的才算禁用;`.old` 单独出现只是"旧版本残留",原样保留、语义不变。 +//! 后缀**原样进包**——这是官方 Modrinth 导出的表达,也是唯一跨启动器保真的那条 +//! (`env.client=optional` 不是禁用,见 `crate::addon::pack::manifest::PackFile::disabled`)。 +//! +//! # 安全 +//! +//! 每条相对路径都要过 [`normalize_file`](七条规则)才进表;不过的记 +//! [`SkipReason::UnsafePath`]。符号链接**不跟随**,记 [`SkipReason::SymbolicLink`] +//! (导出包里出现符号链接条目,解压端会把它当内容写出来)。 + +use std::path::{Path, PathBuf}; + +use super::report::FileReason; +use crate::addon::pack::path::{normalize_dir, normalize_file}; +use crate::download::types::HashAlgo; +use crate::download::verify::digest_file; + +/// 包内唯一的覆盖目录名(Modrinth 官方与 Prism 都用它,兼容面最大)。 +pub const OVERRIDES_DIR: &str = "overrides"; + +/// 默认扫的 8 个内容目录。 +pub const DEFAULT_CONTENT_ROOTS: [&str; 8] = [ + "mods", + "config", + "defaultconfigs", + "scripts", + "resourcepacks", + "shaderpacks", + "kubejs", + "openloader", +]; + +/// 一个待判定的本地文件。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LocalFile { + /// 实例内相对路径(`/` 分隔,含 `.disabled` / `.old` 后缀,与磁盘上一致)。 + pub path: String, + /// 磁盘上的绝对路径(导出时按这个读字节)。 + pub disk: PathBuf, + /// 字节数。 + pub bytes: u64, + /// 内容 SHA-1(小写十六进制)。 + pub sha1: String, + /// 是否禁用态(路径后缀含 `.disabled`)。 + pub disabled: bool, +} + +/// 一个没能进包的文件,与它为什么没进。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SkippedLocal { + /// 实例内相对路径(过不了规整时给原始串)。 + pub path: String, + /// 原因。 + pub reason: FileReason, +} + +/// 扫描一个实例目录,返回(可导出的文件表,跳过表),两张表都按路径排序。 +/// +/// 内容目录不存在不算错(空实例导出空包是合法的,只是报告里一个文件都没有)。 +#[must_use] +pub fn scan_instance(root: &Path, content_roots: &[String]) -> (Vec, Vec) { + let mut files: Vec = Vec::new(); + let mut skipped: Vec = Vec::new(); + + for content_root in content_roots { + let Ok(dir_name) = normalize_dir(content_root) else { + skipped.push(SkippedLocal { + path: content_root.clone(), + reason: FileReason::UnsafePath, + }); + continue; + }; + let start = root.join(&dir_name); + if !start.is_dir() { + continue; + } + // 迭代而不是递归:深度由磁盘决定,别让深层目录把栈吃掉。 + let mut pending: Vec = vec![start]; + while let Some(dir) = pending.pop() { + let Ok(entries) = std::fs::read_dir(&dir) else { + skipped.push(SkippedLocal { + path: relative_name(root, &dir), + reason: FileReason::Unreadable, + }); + continue; + }; + let mut children: Vec<_> = entries.collect::, _>>().unwrap_or_default(); + children.sort_by_key(std::fs::DirEntry::file_name); + for child in children { + let path = child.path(); + let name = relative_name(root, &path); + let Ok(file_type) = child.file_type() else { + skipped.push(SkippedLocal { + path: name, + reason: FileReason::Unreadable, + }); + continue; + }; + if file_type.is_symlink() { + skipped.push(SkippedLocal { + path: name, + reason: FileReason::SymbolicLink, + }); + continue; + } + if file_type.is_dir() { + pending.push(path); + continue; + } + if !file_type.is_file() { + // 设备文件 / 管道 / 套接字:不是整合包内容。 + skipped.push(SkippedLocal { + path: name, + reason: FileReason::Unreadable, + }); + continue; + } + let Ok(normalized) = normalize_file(&name) else { + skipped.push(SkippedLocal { + path: name, + reason: FileReason::UnsafePath, + }); + continue; + }; + let bytes = child.metadata().map(|meta| meta.len()).unwrap_or(0); + let Ok(sha1) = digest_file(&path, HashAlgo::Sha1) else { + skipped.push(SkippedLocal { + path: normalized, + reason: FileReason::Unreadable, + }); + continue; + }; + files.push(LocalFile { + disabled: is_disabled_path(&normalized), + path: normalized, + disk: path, + bytes, + sha1, + }); + } + } + } + + files.sort_by(|left, right| left.path.cmp(&right.path)); + skipped.sort_by(|left, right| left.path.cmp(&right.path)); + (files, skipped) +} + +/// 实例内相对路径(`/` 分隔);拿不到就退回文件名。 +fn relative_name(root: &Path, path: &Path) -> String { + path.strip_prefix(root) + .unwrap_or(path) + .to_string_lossy() + .replace('\\', "/") +} + +/// 路径后缀是否带 `.disabled`(允许 `x.jar.disabled.old` 这种"禁用 + 旧残留"组合)。 +#[must_use] +pub fn is_disabled_path(path: &str) -> bool { + let lowered = path.trim_end().to_ascii_lowercase(); + let without_old = lowered.strip_suffix(".old").unwrap_or(&lowered); + without_old.ends_with(".disabled") +} diff --git a/crates/core/src/addon/export/snapshot.rs b/crates/core/src/addon/export/snapshot.rs new file mode 100644 index 0000000..c733a99 --- /dev/null +++ b/crates/core/src/addon/export/snapshot.rs @@ -0,0 +1,209 @@ +//! 实例快照:`game` 与 `dependencies` 的唯一来源(**不解析实例 id 字符串**)。 +//! +//! # 为什么要读版本 JSON 而不是拆 `versions/` 的名字 +//! +//! 实例 id 的形状是社区约定,不是规范:`1.20.1-fabric-0.15.11`(本仓库 `LoaderKind::version_id` +//! 的拼法)、`1.20.1-forge-47.2.0`、`Fabric 1.20.1`、`我的整合包 v3`……按 `-` 切串去猜 +//! "哪段是加载器、哪段是版本"必然在真实实例上翻车。**证据只能在版本 JSON 里**: +//! +//! - 游戏版本 = `inheritsFrom` 链**最深的那个祖先**的 `id`(加载器层都叠在原版之上); +//! - 加载器 = 各层 `libraries[]` 里的 Maven 坐标:`net.fabricmc:fabric-loader:`、 +//! `org.quiltmc:quilt-loader:`、`net.minecraftforge:forge:`、 +//! `net.neoforged:neoforge:`(`fmlloader` 也认,它是 Forge 的拆分包)。 +//! +//! 这条口径由 tower 批准(M112):"解析 `--` 字符串是猜"。 +//! +//! # 版本号里的 MC 前缀 +//! +//! NeoForge 在 MC 1.20.1 上的库坐标版本是 `1.20.1-47.1.106`(CF 导出的 +//! `neoforge-1.20.1-47.1.106` 同源)。这里统一剥成 `47.1.106`:mrpack 的 +//! `dependencies["neoforge"]` 要裸版本,CF 的 `modLoaders[].id` 由 +//! `PackLoader::curse_id_for` 在 1.20.1 上再把前缀拼回去。 +//! +//! # 没有加载器是合法的 +//! +//! 原版实例的 `dependencies` 只有 `minecraft`——那不是"漏了加载器",是真的没装。任务书说的 +//! 「不得只写 `minecraft` 漏加载器」针对的是**装了加载器却漏写**,两者靠这条链区分得清清楚楚。 + +use std::path::Path; + +use crate::addon::pack::error::{PackError, PackResult}; +use crate::addon::LoaderKind; +use crate::game::library::MavenCoordinate; +use crate::game::manifest::VersionJson; + +/// 实例里认出的一条加载器依赖。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SnapshotLoader { + /// 加载器种类。 + pub kind: LoaderKind, + /// 加载器版本(已剥掉可能存在的 `-` 前缀)。 + pub version: String, +} + +/// 实例快照:游戏版本 + 加载器集合(顺序即 `libraries[]` 里出现的顺序)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InstanceSnapshot { + /// 原版游戏版本(`1.20.1`)。 + pub game_version: String, + /// 加载器依赖(原版实例为空)。 + pub loaders: Vec, +} + +/// 读一个实例目录的快照。 +/// +/// `version_id` 给定时只读 `versions//.json`;`None` 时取 `versions/` 里**继承链的叶子** +/// (真实实例有"原版 + 加载器层"两个目录,叶子才是实例自己)。叶子多于一个时无法判断, +/// 宁可报错也不猜。 +pub fn read_instance_snapshot( + root: &Path, + version_id: Option<&str>, +) -> PackResult { + let versions_dir = root.join("versions"); + let id = match version_id { + Some(id) => id.to_string(), + None => instance_version_id(&versions_dir)?, + }; + let layers = load_chain(&versions_dir, &id)?; + + let deepest = layers + .last() + .and_then(|layer| layer.id.clone()) + .ok_or_else(|| { + PackError::metadata(format!("versions/{id}/{id}.json 的继承链末端没有 id")) + })?; + + let mut loaders: Vec = Vec::new(); + for layer in &layers { + for library in layer.libraries.iter().flatten() { + let Ok(coordinate) = MavenCoordinate::parse(&library.name) else { + continue; + }; + let Some(kind) = loader_kind_of(&coordinate.group, &coordinate.artifact) else { + continue; + }; + if loaders.iter().any(|seen| seen.kind == kind) { + continue; + } + loaders.push(SnapshotLoader { + kind, + version: strip_game_prefix(&deepest, &coordinate.version), + }); + } + } + + Ok(InstanceSnapshot { + game_version: deepest, + loaders, + }) +} + +/// `versions/` 下"这个实例"的版本 id:**继承链的叶子**(没有被别的版本当作 `inheritsFrom`)。 +/// +/// 为什么不是"只有一个版本目录":真实实例必然有"原版 + 加载器层"两个目录(`1.20.1` 与 +/// `1.20.1-fabric-0.15.11`),叶子才是实例自己。这条判据不看目录名形状,只看 JSON 里的 +/// `inheritsFrom` 关系——与"不解析实例 id 字符串"是同一条纪律。 +fn instance_version_id(versions_dir: &Path) -> PackResult { + let entries = std::fs::read_dir(versions_dir).map_err(|error| { + PackError::unsupported(format!( + "实例里没有可读的 {}:{error}", + versions_dir.display() + )) + .with_hint("导出需要实例的版本 JSON(versions//.json 给出游戏版本与加载器)") + })?; + let mut versions: Vec<(String, Option)> = Vec::new(); + for entry in entries.flatten() { + let Ok(file_type) = entry.file_type() else { + continue; + }; + if !file_type.is_dir() { + continue; + } + let name = entry.file_name().to_string_lossy().to_string(); + let path = entry.path().join(format!("{name}.json")); + if !path.is_file() { + continue; + } + let text = std::fs::read_to_string(&path).map_err(|error| { + PackError::metadata(format!("读版本 JSON {} 失败:{error}", path.display())) + })?; + let layer: VersionJson = serde_json::from_str(&text).map_err(|error| { + PackError::metadata(format!("{} 不是合法版本 JSON:{error}", path.display())) + })?; + versions.push((name, layer.inherits_from)); + } + if versions.is_empty() { + return Err( + PackError::unsupported("实例的 versions/ 下没有 /.json".to_string()).with_hint( + "先装好游戏/加载器再导出;导出需要版本 JSON 才能写 game 与 dependencies", + ), + ); + } + let referenced: Vec<&str> = versions + .iter() + .filter_map(|(_, parent)| parent.as_deref()) + .collect(); + let mut leaves: Vec = versions + .iter() + .filter(|(id, _)| !referenced.contains(&id.as_str())) + .map(|(id, _)| id.clone()) + .collect(); + leaves.sort(); + match leaves.len() { + 1 => Ok(leaves.remove(0)), + _ => Err(PackError::unsupported(format!( + "实例的 versions/ 下有 {} 条继承链末端({}),无法判断哪个属于这个实例", + leaves.len(), + leaves.join(", ") + )) + .with_hint("调用方应显式给出 versionId(实例档案的 version_id)")), + } +} + +/// 从 `id` 起沿 `inheritsFrom` 收集各层(第一层是实例自己,最后一层是最深的祖先)。 +fn load_chain(versions_dir: &Path, id: &str) -> PackResult> { + let mut layers: Vec = Vec::new(); + let mut visited: Vec = Vec::new(); + let mut current = Some(id.to_string()); + while let Some(next) = current { + if visited.iter().any(|seen| seen == &next) { + return Err(PackError::metadata(format!( + "版本 JSON 的继承链成环:{} → {next}", + visited.join(" → ") + ))); + } + visited.push(next.clone()); + let path = versions_dir.join(&next).join(format!("{next}.json")); + let text = std::fs::read_to_string(&path).map_err(|error| { + PackError::metadata(format!("读版本 JSON {} 失败:{error}", path.display())) + .with_hint("继承链上的父版本也必须装在实例里") + })?; + let layer: VersionJson = serde_json::from_str(&text).map_err(|error| { + PackError::metadata(format!("{} 不是合法版本 JSON:{error}", path.display())) + })?; + current = layer.inherits_from.clone(); + layers.push(layer); + } + Ok(layers) +} + +/// Maven 坐标 → 加载器种类(认不出的返回 `None`,不影响别的库)。 +fn loader_kind_of(group: &str, artifact: &str) -> Option { + match (group, artifact) { + ("net.fabricmc", "fabric-loader") => Some(LoaderKind::Fabric), + ("org.quiltmc", "quilt-loader") => Some(LoaderKind::Quilt), + ("net.minecraftforge", "forge" | "fmlloader") => Some(LoaderKind::Forge), + ("net.neoforged", "neoforge") => Some(LoaderKind::NeoForge), + _ => None, + } +} + +/// 剥掉版本号里又写了一遍的 `-` 前缀(`1.20.1-47.1.106` → `47.1.106`)。 +fn strip_game_prefix(game_version: &str, version: &str) -> String { + let prefix = format!("{game_version}-"); + version + .strip_prefix(&prefix) + .filter(|rest| !rest.is_empty()) + .unwrap_or(version) + .to_string() +} diff --git a/crates/core/src/addon/mod.rs b/crates/core/src/addon/mod.rs index e7bc9ca..8cd9ca0 100644 --- a/crates/core/src/addon/mod.rs +++ b/crates/core/src/addon/mod.rs @@ -14,13 +14,22 @@ //! - [`import`](P2 · M111):**导入管线**——三种格式 → 实例目录:跨段落地、overrides 三方 //! 合并(保用户改动)、两阶段补全与失败分级、临时目录 + rename/journal 回滚、结构化报告。 //! 导出(M112)另起模块。 +//! - [`export`](P2 · M112):**导出管线**——实例目录 → `.mrpack`(优先)/ CF `manifest.json`。 +//! 逐文件判定「能否被远程替代」(Modrinth SHA-1 + CF 指纹),判定不了的**内联进 `overrides/`** +//! 并逐条进报告;`.disabled` 用路径后缀;`game`/`dependencies` 从实例快照取。 +//! MultiMC/Prism **只导入不导出**(有意省略)。 //! //! 规划中:本地 mod 管理(D10 §3)、CF/Modrinth 客户端(§5)。 +pub mod export; pub mod import; pub mod loader; pub mod pack; +pub use export::{ + export_instance, ExportFile, ExportReport, ExportRequest, FileClass, FileReason, RemoteCatalog, + RemoteHit, +}; pub use import::{ import_pack, ConflictFile, ConflictPolicy, ImportError, ImportErrorKind, ImportProgress, ImportProgressSink, ImportReport, ImportRequest, ImportStage, ImportStatus, ImportedFile, diff --git a/crates/core/src/addon/pack/curseforge.rs b/crates/core/src/addon/pack/curseforge.rs index 8ee4364..e1e6365 100644 --- a/crates/core/src/addon/pack/curseforge.rs +++ b/crates/core/src/addon/pack/curseforge.rs @@ -28,6 +28,11 @@ //! 按 CF 语义 `overrides` 只是 zip 内**前缀**,导入器把该前缀下的内容铺到游戏目录, //! 所以内容不会丢;但"前缀目录本身要不要跟着拷一层"取决于导入器实现(Prism/ULL 的实例根 //! 本身就叫 `.minecraft` 时可能多出一层)。同一份模型只有 `read` 出来的那个格式才保证往返。 +//! +//! CF 的**文件指纹**(`MurmurHash2-32` + 空白过滤)是导出侧「能否被远程替代」的判据,见 +//! [`fingerprint`]:它是"CF 格式知识",不属于导出流程,所以留在这里而不是 `addon::export`。 + +pub mod fingerprint; use serde::Deserialize; use serde_json::{json, Map, Value}; @@ -289,7 +294,7 @@ impl PackFormat for CurseForge { minecraft.insert("version".into(), json!(game_version)); let mut mod_loaders = Vec::with_capacity(manifest.loaders.len()); for loader in &manifest.loaders { - let id = loader.curse_id().ok_or_else(|| { + let id = loader.curse_id_for(Some(game_version)).ok_or_else(|| { PackError::unsupported(format!( "CurseForge 表达不了这条加载器依赖:raw={:?} kind={:?} version={:?}", loader.raw, loader.kind, loader.version diff --git a/crates/core/src/addon/pack/curseforge/fingerprint.rs b/crates/core/src/addon/pack/curseforge/fingerprint.rs new file mode 100644 index 0000000..6184b91 --- /dev/null +++ b/crates/core/src/addon/pack/curseforge/fingerprint.rs @@ -0,0 +1,262 @@ +//! CurseForge 文件指纹(`MurmurHash2-32` + 空白字节过滤),导出侧的「能否被远程替代」判据之一。 +//! +//! # 算法(A 级规范缺失,这里是 B 级实现 + 双向验证) +//! +//! ```text +//! data = 原文件去掉所有 0x09 / 0x0A / 0x0D / 0x20 字节 +//! fingerprint = MurmurHash2_32(data, seed = 1, len = data 的长度) +//! ``` +//! +//! 三条容易写错的点,逐条钉住: +//! +//! 1. **长度用过滤后的长度**参与初始态(`h = seed ^ len`),不是原文件长度; +//! 2. **seed 恒为 1**(MurmurHash2 的默认 seed 是 `0x9747b28c`,那是别的调用方的约定); +//! 3. 过滤是**逐字节**的,只在 0x09/0x0A/0x0D/0x20 上生效——不是"去空白"(`0x0B` 竖tab、 +//! `0x0C` 换页都保留)。 +//! +//! # 来源与验证 +//! +//! CurseForge 官方只公开 `POST /v1/fingerprints/432` 端点,**没有公开算法**(A 级规范不存在)。 +//! 这里的实现对齐 B 级参考 `HMCL CurseForgeRemoteAddonRepository.calculateFingerprint` +//! 与 `MurmurHash2.hash32(byte[], int, int)`(SMHasher `MurmurHash2.cpp` 的忠实转写)。 +//! +//! **双向验证**(`tests` 模块):一组固定输入的金标准值由一份**独立的 Java 实现**(HMCL 两份 +//! 源码的逐行移植,不在本仓库内)算出后写死在这里;Rust 侧同时跑「一次性字节序」与「流式文件」 +//! 两条路径,两条都要与金标准相等。所以这一层是"两套实现 + 两个入口"的四向对齐,而不是自证。 +//! 仍未验的部分:**真实 CF 文件**的官方指纹(要网络 + `x-api-key`,本任务环境离线)。 +//! +//! # 哨兵值 `811513880` +//! +//! 这个值会误命中(HMCL issue #4597 的临时规避):无论文件是什么,只要算出这个数就不能当成 +//! "已找到"。判据写成 [`is_sentinel`],由调用方(导出管线)在查表前短路。 + +use std::fs::File; +use std::io::{self, Read}; +use std::path::Path; + +/// 误命中的哨兵指纹(HMCL issue #4597)。命中时按「查不到」处理。 +pub const SENTINEL: u32 = 811_513_880; + +/// 被过滤掉的四个字节:水平制表、换行、回车、空格。 +pub const FILTERED: [u8; 4] = [0x09, 0x0a, 0x0d, 0x20]; + +/// 指纹查表时是否该按「没找到」处理。 +#[must_use] +pub const fn is_sentinel(fingerprint: u32) -> bool { + fingerprint == SENTINEL +} + +/// 这个字节参与计算吗(`0x09` / `0x0A` / `0x0D` / `0x20` 不参与)。 +#[must_use] +pub const fn keeps(byte: u8) -> bool { + !matches!(byte, 0x09 | 0x0a | 0x0d | 0x20) +} + +/// 对**内存里的一份内容**算指纹(过滤 + 一次性哈希)。 +/// +/// 小文件与测试用;大文件请用 [`fingerprint_file`](两遍流式读,不把整份读进内存)。 +#[must_use] +pub fn fingerprint_bytes(bytes: &[u8]) -> u32 { + let filtered: Vec = bytes.iter().copied().filter(|byte| keeps(*byte)).collect(); + murmur2(&filtered) +} + +/// 对一个**文件**算指纹:两遍流式读(先数过滤后长度,再喂哈希)。 +/// +/// MurmurHash2 的初始态要事先知道总长度,所以必须两遍;这里不把文件读进内存(mod jar 可以 +/// 有几百 MB,整包导出要对每个文件算一次)。 +pub fn fingerprint_file(path: &Path) -> io::Result { + let mut filtered_length = 0_u64; + let mut buffer = vec![0_u8; 1024 * 1024]; + let mut file = File::open(path)?; + loop { + let read = file.read(&mut buffer)?; + if read == 0 { + break; + } + filtered_length += buffer[..read].iter().filter(|byte| keeps(**byte)).count() as u64; + } + + // 长度参数是 u32(MurmurHash2-32 的初始态),超了要报错而不是截断。 + let length = u32::try_from(filtered_length).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("文件过滤后仍有 {filtered_length} 字节,超出指纹长度参数(u32)上限"), + ) + })?; + let mut hasher = Stream::new(length, 1); + file = File::open(path)?; + loop { + let read = file.read(&mut buffer)?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + Ok(hasher.finish()) +} + +/// MurmurHash2 32 位,seed 固定 1(CurseForge 的约定)。 +/// +/// 实现是 SMHasher `MurmurHash2.cpp` 的形状:按 4 字节小端块混合,尾部 1–3 字节按小端补齐后 +/// 再乘一次;收尾两次移位混合。**不在这里做字节过滤**(调用方先过滤,见 [`fingerprint_bytes`])。 +#[must_use] +pub fn murmur2(data: &[u8]) -> u32 { + const M: u32 = 0x5bd1_e995; + const R: u32 = 24; + + let mut h = 1_u32 ^ (data.len() as u32); + let blocks = data.len() / 4; + for index in 0..blocks { + let mut k = u32::from_le_bytes([ + data[index * 4], + data[index * 4 + 1], + data[index * 4 + 2], + data[index * 4 + 3], + ]); + k = k.wrapping_mul(M); + k ^= k >> R; + k = k.wrapping_mul(M); + h = h.wrapping_mul(M); + h ^= k; + } + let tail = &data[blocks * 4..]; + if !tail.is_empty() { + let mut packed = 0_u32; + for (shift, byte) in tail.iter().enumerate() { + packed |= u32::from(*byte) << (8 * shift); + } + h ^= packed; + h = h.wrapping_mul(M); + } + h ^= h >> 13; + h = h.wrapping_mul(M); + h ^= h >> 15; + h +} + +/// 流式 MurmurHash2:长度先知道,字节可以分任意多段喂进来。 +struct Stream { + hash: u32, + tail: u32, + tail_len: u32, +} + +impl Stream { + const fn new(length: u32, seed: u32) -> Self { + Self { + hash: seed ^ length, + tail: 0, + tail_len: 0, + } + } + + fn update(&mut self, bytes: &[u8]) { + const M: u32 = 0x5bd1_e995; + const R: u32 = 24; + for byte in bytes { + if !keeps(*byte) { + continue; + } + self.tail |= u32::from(*byte) << (8 * self.tail_len); + self.tail_len += 1; + if self.tail_len == 4 { + let mut k = self.tail; + k = k.wrapping_mul(M); + k ^= k >> R; + k = k.wrapping_mul(M); + self.hash = self.hash.wrapping_mul(M); + self.hash ^= k; + self.tail = 0; + self.tail_len = 0; + } + } + } + + fn finish(mut self) -> u32 { + const M: u32 = 0x5bd1_e995; + if self.tail_len != 0 { + self.hash ^= self.tail; + self.hash = self.hash.wrapping_mul(M); + } + self.hash ^= self.hash >> 13; + self.hash = self.hash.wrapping_mul(M); + self.hash ^= self.hash >> 15; + self.hash + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// 金标准值由一份独立的 Java 实现(HMCL `MurmurHash2.hash32` + `calculateFingerprint` 的 + /// 逐行移植)对同一批输入算出。改了算法这两列会同时红——这就是"双向验证"的用处。 + const GOLDEN: &[(&str, &[u8], u32)] = &[ + ("空文件", b"", 1_540_447_798), + ("只有空白", b" \t\r\n \t\r\n", 1_540_447_798), + ( + "夹具 jar 存根", + b"PK\x03\x04 hand-made jar stub", + 489_715_614, + ), + ("混入空白", b"\x00\t\n\r AB\tC\n\r \x7f\xff", 3_734_724_774), + ( + "ascii 36", + b"abcdefghijklmnopqrstuvwxyz0123456789", + 3_026_742_571, + ), + ]; + + fn pattern(length: usize) -> Vec { + (0..length).map(|index| (index & 0xff) as u8).collect() + } + + #[test] + fn matches_the_independent_implementation() { + for (label, bytes, expected) in GOLDEN { + assert_eq!(fingerprint_bytes(bytes), *expected, "{label}(一次性路径)"); + } + assert_eq!( + fingerprint_bytes(&pattern(512)), + 3_451_065_282, + "pattern-512" + ); + assert_eq!(fingerprint_bytes(&pattern(5)), 813_985_283, "pattern-5"); + assert_eq!(fingerprint_bytes(&pattern(7)), 2_174_351_703, "pattern-7"); + } + + #[test] + fn filtered_length_enters_the_initial_state() { + // 原文件长度 8、过滤后长度 0:与空文件同值。用原长度当 len 会得到另一个数(这里不是)。 + assert_eq!(fingerprint_bytes(b" \t\r\n \t\r\n"), fingerprint_bytes(b""),); + assert_ne!(fingerprint_bytes(b"aaaaaaaa"), fingerprint_bytes(b"aaaa"),); + } + + #[test] + fn only_the_four_documented_bytes_are_dropped() { + assert!(!keeps(0x09) && !keeps(0x0a) && !keeps(0x0d) && !keeps(0x20)); + // 竖 tab、换页、NUL 都保留:过滤是四个字节,不是"去空白"。 + assert!(keeps(0x0b) && keeps(0x0c) && keeps(0x00)); + } + + #[test] + fn streaming_and_one_shot_agree_at_every_split() { + let data = pattern(64); + let expected = fingerprint_bytes(&data); + for split in 0..=data.len() { + let mut hasher = + Stream::new(data.iter().filter(|byte| keeps(**byte)).count() as u32, 1); + hasher.update(&data[..split]); + hasher.update(&data[split..]); + assert_eq!(hasher.finish(), expected, "split={split}"); + } + } + + #[test] + fn sentinel_is_treated_as_missing() { + assert!(is_sentinel(SENTINEL)); + assert!(!is_sentinel(0)); + assert!(!is_sentinel(SENTINEL + 1)); + } +} diff --git a/crates/core/src/addon/pack/format.rs b/crates/core/src/addon/pack/format.rs index fb41177..43cb9d4 100644 --- a/crates/core/src/addon/pack/format.rs +++ b/crates/core/src/addon/pack/format.rs @@ -42,6 +42,13 @@ //! //! 换句话说:`X.read` → `X.write` 是可靠往返(有逐字段相等的测试);`X.read` → `Y.write` //! 要么被 `check_overrides` 拒掉,要么"结构合法但语义不完整"。**要跨格式导出,先做映射。** +//! +//! # 映射层已经在了(M112) +//! +//! [`crate::addon::export`] 就是那层映射:它把实例目录扫成模型(覆盖目录统一到 `overrides/`、 +//! 禁用位落成路径后缀 `.disabled`、`dependencies` 从实例快照取),再用 +//! [`PackPayload::with_sources`] 把"包内条目名 → 实例里的源文件"交给本模块写盘。所以本文件 +//! 仍然不做映射(那是格式之上的事),但"跨格式导出无路可走"已经不再成立。 use std::fmt::Debug; use std::fs::File; @@ -56,34 +63,13 @@ use super::modrinth; use super::multimc; use super::path::{normalize_dir, normalize_file}; -/// 导出时的覆盖内容来源。 -/// -/// [`PackPayload::metadata_only`] 只写清单(不用磁盘上的任何内容); -/// [`PackPayload::from_root`] 给一个根目录,覆盖目录按 `//` 取文件。 -#[derive(Debug, Clone, Copy)] -pub struct PackPayload<'a> { - root: Option<&'a Path>, -} +mod payload; -impl<'a> PackPayload<'a> { - /// 只写清单,不搬覆盖内容。 - #[must_use] - pub const fn metadata_only() -> Self { - Self { root: None } - } - - /// 覆盖内容从 `root` 取。 - #[must_use] - pub const fn from_root(root: &'a Path) -> Self { - Self { root: Some(root) } - } - - /// 来源根目录。 - #[must_use] - pub const fn root(&self) -> Option<&'a Path> { - self.root - } -} +/// 导出时的覆盖内容来源在 [`payload`] 子模块里(`payload.rs`,与本文件拆开是为了守 400 行): +/// [`PackPayload::metadata_only`] 只写清单、[`PackPayload::from_root`] 按 +/// `//` 取文件、[`PackPayload::with_sources`] 按**包内条目名**查表 +/// (跨格式导出 / 从实例导出用,M112)。 +pub use payload::PackPayload; /// 一种整合包格式(导入 + 导出的全部能力都在这里)。 pub trait PackFormat: Debug + Send + Sync { @@ -211,20 +197,36 @@ fn write_container( writer .write_all(json.as_bytes()) .map_err(|error| PackError::io(format!("写 {entry} 失败:{error}")))?; + // 未压缩总量封顶在 ZIP64 阈值(≈4 GiB):Modrinth 官方导出也是这样拒绝的,而不是 + // 静默截断(HMCL 那边 `(int)` 截断只打 warning 是反面教材)。临时文件由调用方删。 + let mut total = json.len() as u64; for group in &manifest.overrides { let dir = normalize_dir(&group.dir)?; for override_file in &group.files { let path = normalize_file(&override_file.path)?; let name = format!("{dir}/{path}"); - let Some(root) = payload.root() else { - return Err(PackError::unsupported(format!( - "导出需要覆盖内容({name}),但没给 payload 根目录" - ))); + let source = match payload.source_for(&name) { + Some(source) => source.to_path_buf(), + None => { + let Some(root) = payload.root() else { + return Err(PackError::unsupported(format!( + "导出需要覆盖内容({name}),但既没有来源映射也没有 payload 根目录" + ))); + }; + root.join(&dir).join(&path) + } }; - let source = root.join(&dir).join(&path); let bytes = std::fs::read(&source).map_err(|error| { PackError::io(format!("读覆盖文件 {} 失败:{error}", source.display())) })?; + total += bytes.len() as u64; + if total >= zip::ZIP64_BYTES_THR { + return Err(PackError::limits(format!( + "包体未压缩总量已超过 {} 字节(ZIP64 阈值),拒绝写出一个需要 ZIP64 的整合包", + zip::ZIP64_BYTES_THR + )) + .with_hint("把超大的覆盖文件移出实例后再导出;mrpack/CF 都不接受 ZIP64")); + } writer .start_file(name.clone(), options) .map_err(|error| PackError::io(format!("写 {name} 失败:{error}")))?; diff --git a/crates/core/src/addon/pack/format/payload.rs b/crates/core/src/addon/pack/format/payload.rs new file mode 100644 index 0000000..ca852a7 --- /dev/null +++ b/crates/core/src/addon/pack/format/payload.rs @@ -0,0 +1,70 @@ +//! 导出时的覆盖内容来源:[`PackPayload`]。 +//! +//! # 两种来源 +//! +//! | 构造 | 用在哪 | +//! |------|--------| +//! | [`PackPayload::from_root`] | 同格式往返:包内 `overrides/mods/x.jar` 就是 `/overrides/mods/x.jar` | +//! | [`PackPayload::with_sources`] | 跨格式 / 从实例导出:包内路径由模型决定,源文件在实例目录的任意位置 | +//! +//! 第二种是 M112 需要的形状:实例里的 `mods/x.jar` 要落到包里的 `overrides/mods/x.jar` +//! (CF 与 mrpack 都是这个前缀),路径**不能靠拼**(拼接只能表达"目录同名")。所以来源表 +//! 以**包内条目名**为键:`{"overrides/mods/x.jar": <实例>/mods/x.jar}`。 +//! +//! 查不到映射时退回 `root/dir/path`;两者都没有就报 `pack_unsupported`(不写空内容、不猜)。 + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +/// 导出时的覆盖内容来源。 +#[derive(Debug, Clone, Copy)] +pub struct PackPayload<'a> { + root: Option<&'a Path>, + sources: Option<&'a BTreeMap>, +} + +impl<'a> PackPayload<'a> { + /// 只写清单,不搬覆盖内容。 + #[must_use] + pub const fn metadata_only() -> Self { + Self { + root: None, + sources: None, + } + } + + /// 覆盖内容从 `root` 取(`//`,同格式往返用)。 + #[must_use] + pub const fn from_root(root: &'a Path) -> Self { + Self { + root: Some(root), + sources: None, + } + } + + /// 覆盖内容按**包内条目名**查表(`overrides/mods/x.jar` → 实例里的绝对/相对路径)。 + /// + /// `root` 仍然要给:映射没覆盖到的条目退回 `//`, + /// 这样"大部分文件原位、少数文件改名"的混合场景不必手写全表。 + #[must_use] + pub const fn with_sources(root: &'a Path, sources: &'a BTreeMap) -> Self { + Self { + root: Some(root), + sources: Some(sources), + } + } + + /// 来源根目录。 + #[must_use] + pub const fn root(&self) -> Option<&'a Path> { + self.root + } + + /// 包内条目名 → 源文件(没有来源表或表里没这条时返回 `None`)。 + #[must_use] + pub fn source_for(&self, entry_name: &str) -> Option<&'a Path> { + self.sources + .and_then(|sources| sources.get(entry_name)) + .map(PathBuf::as_path) + } +} diff --git a/crates/core/src/addon/pack/manifest.rs b/crates/core/src/addon/pack/manifest.rs index 12fa353..64c4857 100644 --- a/crates/core/src/addon/pack/manifest.rs +++ b/crates/core/src/addon/pack/manifest.rs @@ -14,7 +14,7 @@ //! | 加载器 | `minecraft.modLoaders[].id`(`forge-47.2.0`) | `dependencies["forge"] = "47.2.0"` | [`PackManifest::loaders`] | //! | 远端文件 | `files[].projectID/fileID`(**无路径、无 URL**) | `files[].path/hashes/downloads/env` | [`PackManifest::files`] | //! | 覆盖目录 | `overrides`(目录名由清单给,可任意) | `overrides` + `client-overrides` + `server-overrides` | [`PackManifest::overrides`] | -//! | 禁用位 | 无(Prism 借 `required: false` 表达) | 路径 `.disabled` 后缀(官方)或 `env.client=optional`(D10) | [`PackFile::disabled`] | +//! | 禁用位 | 无(Prism 借 `required: false` 表达) | 路径 `.disabled` 后缀(官方,本仓库采这条) | [`PackFile::disabled`] | //! | 多 MC 实例(MultiMC) | — | — | 同上(`.minecraft` 当覆盖目录) | //! //! 方向不同造成的缺口**不靠"假装字段都在"抹平**,而是显式记录: @@ -54,7 +54,10 @@ pub enum EnvSupport { /// 必需(mrpack `required` / CF `required: true`)。 #[default] Required, - /// 可选(mrpack `optional` / CF `required: false`;导出侧 `.disabled` → 这个)。 + /// 可选(mrpack `optional` / CF `required: false`)。 + /// + /// **不是禁用位**:真实语义是"安装时用户可取消勾选",装回来默认仍然启用。 + /// 禁用是两回事,在 mrpack 里表达为路径后缀 `.disabled`(见 [`super::PackFile::disabled`])。 Optional, /// 本侧不支持(mrpack `unsupported`;CF 表达不了)。 Unsupported, @@ -203,14 +206,29 @@ impl PackLoader { /// 写成 CF 的 `id`。 /// /// `raw` 非空时**逐字回写**(CF 的 id 是不透明字符串,不认识的加载器也得原样带过去); - /// 只有从别的格式转过来的 `raw` 为空的情况才用 `kind`+`version` 拼。 + /// 只有从别的格式转过来的 `raw` 为空的情况才用 `kind`+`version` 拼。等于 + /// `curse_id_for(None)`——跨格式导出请用带游戏版本的那个,见 [`Self::curse_id_for`]。 #[must_use] pub fn curse_id(&self) -> Option { + self.curse_id_for(None) + } + + /// 写成 CF 的 `id`,并按 CF 官方导出器的写法处理 **NeoForge 的 MC 前缀特例**。 + /// + /// 证据(A 级 `FlamePackExportTask.cpp:404-410`,转述见 research/18 §8.2):Prism 的 CF + /// 导出在 MC **1.20.1** 上写 `neoforge-1.20.1-`,其它版本写 `neoforge-`。 + /// 原因是 NeoForge 在 1.20.1 上沿用 Forge 的 `47.x` 版本号,不写 MC 版本就分不清代际。 + /// 读侧(`split_curse_id`)不剥这个前缀——那是 M111 的 `strip_mc_prefix` 的活。 + #[must_use] + pub fn curse_id_for(&self, mc_version: Option<&str>) -> Option { if !self.raw.is_empty() { return Some(self.raw.clone()); } let kind = self.kind?; let version = self.version.as_deref()?; + if kind == LoaderKind::NeoForge && mc_version == Some("1.20.1") { + return Some(format!("{}-1.20.1-{version}", kind.as_str())); + } Some(format!("{}-{version}", kind.as_str())) } diff --git a/crates/core/src/addon/pack/manifest/entries.rs b/crates/core/src/addon/pack/manifest/entries.rs index caffda4..85be86e 100644 --- a/crates/core/src/addon/pack/manifest/entries.rs +++ b/crates/core/src/addon/pack/manifest/entries.rs @@ -29,13 +29,14 @@ pub struct PackFile { pub size: Option, /// 是否"以禁用态安装"(`.disabled`)。 /// - /// 社区里 `.disabled` 有两种表达方式(M115 调研 §字段缺口 2,A 级来源 Modrinth 官方 - /// `export_mrpack.rs` + B 级 HMCL/D10 约定): - /// - 官方 mrpack 导出器:路径本身带 `.disabled` 后缀; - /// - HMCL/D10:去掉后缀,用 `env.client = optional` 表达。 + /// 社区里 `.disabled` 有三种表达(M115 调研 §字段缺口 2 / §8.1): + /// - 官方 mrpack 导出器:**路径本身带 `.disabled` 后缀**(A 级); + /// - HMCL/D10 旧措辞:去掉后缀,用 `env.client = optional`(B 级,**跨启动器不保真**); + /// - CF:没有原生表达,Prism 借 `required: false`。 /// - /// 模型只记**语义**(这条要被禁用),两种拼法留给导出策略(M112)决定; - /// 读包时不会因为 `env.client=optional` 就把它置 `true`——"可选"与"禁用"是两个事实。 + /// **本仓库的定案(tower,M112)**:一律走路径后缀。`env.client=optional` 只是"安装时可 + /// 取消勾选",装回来默认仍然启用,不能当禁用;CF 表达不了禁用位,所以禁用文件只能内联进 + /// `overrides/`(导出管线负责,见 `crate::addon::export`)。 pub disabled: bool, /// 客户端支持度。 pub client: EnvSupport, diff --git a/crates/core/src/addon/pack/modrinth.rs b/crates/core/src/addon/pack/modrinth.rs index 15be43b..4bb2a14 100644 --- a/crates/core/src/addon/pack/modrinth.rs +++ b/crates/core/src/addon/pack/modrinth.rs @@ -30,9 +30,15 @@ //! `Option`,未知就不写,由 M112 补全),`server-overrides` 客户端**不解压**(模型照记, //! 落地判断留给 M111)。 //! -//! 这三条是**读**的硬门槛;写(导出)另有两条:条目缺 `path` 或缺 `downloads` 一律报 -//! `PackErrorKind::Unsupported`(CF 的裸 `projectID/fileID` 条目转不成 mrpack, -//! 必须先补全——这就是 M111/M112 的分工边界)。 +//! 这三条是**读**的硬门槛;写(导出)另有四条: +//! +//! 1. 条目缺 `path` 或缺 `downloads` 一律报 `PackErrorKind::Unsupported`(CF 的裸 +//! `projectID/fileID` 条目转不成 mrpack,必须先补全——这就是 M111/M112 的分工边界); +//! 2. 禁用位([`PackFile::disabled`])落成 **`path` 结尾的 `.disabled`**,不是 +//! `env.client=optional`(tower 定案;后者跨启动器不保真,见 [`DISABLED_SUFFIX`]); +//! 3. `fileSize` 按规范的 u32 校验,超了报 `pack_limits`(HMCL 那边 `(int)` 截断只打 warning +//! 是反面教材); +//! 4. 整包未压缩总量撞 ZIP64 阈值时由 `format::write` 拒绝(`pack_limits`),不写半成品。 //! //! `dependencies` 里的 `neoforge` 与 `neo-forge` 两种拼法都认(HMCL PR #5170 的坑), //! 认不出的键(社区新加载器如 `parchment`)**原样保留**成一条 [`PackLoader`]。 @@ -58,6 +64,8 @@ pub const METADATA_ENTRY: &str = "modrinth.index.json"; pub const GAME: &str = "minecraft"; /// 依赖表里代表游戏版本的那个键。 pub const MINECRAFT_KEY: &str = "minecraft"; +/// 禁用文件的路径后缀(Modrinth 官方导出用它表达"装进来但禁用")。 +pub const DISABLED_SUFFIX: &str = ".disabled"; /// 单例(进注册表用)。 pub static MODRINTH: Modrinth = Modrinth; @@ -325,15 +333,16 @@ impl PackFormat for Modrinth { } let mut env = Map::new(); merge_unknown(&mut env, &file.env_extra); - // 禁用位走 D10 的映射(去掉 `.disabled` 后缀 + `env.client=optional`), - // 而不是把后缀塞回路径——两条路都能表达,选跟本仓库既有约定一致的那条。 - let client = if file.disabled { - EnvSupport::Optional - } else { - file.client - }; - env.insert("client".into(), json!(client.as_str())); + env.insert("client".into(), json!(file.client.as_str())); env.insert("server".into(), json!(file.server.as_str())); + // 禁用位用**路径后缀**表达(Modrinth 官方导出同款,`export_mrpack.rs:653-658`), + // 不用 `env.client=optional`:后者只是"安装时可取消勾选",装回来默认仍然启用, + // 跨启动器不保真(tower 定案,D10 §2 的旧措辞已作废)。 + let path = if file.disabled && !path.ends_with(DISABLED_SUFFIX) { + format!("{path}{DISABLED_SUFFIX}") + } else { + path + }; let mut object = Map::new(); object.insert("path".into(), json!(path)); @@ -341,6 +350,13 @@ impl PackFormat for Modrinth { object.insert("env".into(), Value::Object(env)); object.insert("downloads".into(), json!(file.downloads)); if let Some(size) = file.size { + // 规范里 `fileSize` 是 u32(≈4 GiB):超了要具名报错,不静默截断。 + let size = u32::try_from(size).map_err(|_| { + PackError::limits(format!( + "文件 {path} 有 {size} 字节,超过 mrpack 的 fileSize 上限(u32,4294967295)" + )) + .with_hint("这种尺寸只能靠 overrides/ 内联,而内联又会撞 ZIP64 上限;先缩减实例") + })?; object.insert("fileSize".into(), json!(size)); } merge_unknown(&mut object, &file.extra); diff --git a/crates/core/src/addon/pack/tests/export_harness.rs b/crates/core/src/addon/pack/tests/export_harness.rs new file mode 100644 index 0000000..a6d4bcb --- /dev/null +++ b/crates/core/src/addon/pack/tests/export_harness.rs @@ -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 { + DEFAULT_CONTENT_ROOTS.map(str::to_string).to_vec() +} + +/// 写一份 `/.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, + curse: BTreeMap, + curse_calls: RefCell>, +} + +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 { + self.curse_calls.borrow().clone() + } +} + +impl RemoteCatalog for StubCatalog { + fn modrinth_by_sha1(&self, sha1: &str) -> Option { + self.modrinth.get(sha1).cloned() + } + + fn curseforge_by_fingerprint(&self, value: u32) -> Option { + self.curse_calls.borrow_mut().push(value); + self.curse.get(&value).cloned() + } +} diff --git a/crates/core/src/addon/pack/tests/export_pipeline.rs b/crates/core/src/addon/pack/tests/export_pipeline.rs new file mode 100644 index 0000000..6e052c4 --- /dev/null +++ b/crates/core/src/addon/pack/tests/export_pipeline.rs @@ -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 { + 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")); +} diff --git a/crates/core/src/addon/pack/tests/export_roundtrip.rs b/crates/core/src/addon/pack/tests/export_roundtrip.rs new file mode 100644 index 0000000..fb28874 --- /dev/null +++ b/crates/core/src/addon/pack/tests/export_roundtrip.rs @@ -0,0 +1,280 @@ +//! 往返保真(M112):用 M110 的三种格式夹具做「读出来 → 摆成实例 → 导出 → 再读」。 +//! +//! # 为什么不是 `import_pack → export → import_pack` +//! +//! M111 的导入管线**还没合并进本分支**(它在复审中)。所以这里的"导入"是 M110 的 +//! `PackFormat::read` + 一个**形状与 M111 导入产物一致**的实例目录(`mods/`、`config/`、 +//! `versions//.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; + +/// 把夹具里的覆盖内容铺成一个实例目录(`/`),并写一份版本 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 { + let mut paths: Vec = 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()) + ); +} diff --git a/crates/core/src/addon/pack/tests/mod.rs b/crates/core/src/addon/pack/tests/mod.rs index e0fa1ac..d4458ee 100644 --- a/crates/core/src/addon/pack/tests/mod.rs +++ b/crates/core/src/addon/pack/tests/mod.rs @@ -4,6 +4,9 @@ mod detect; mod export; +mod export_harness; +mod export_pipeline; +mod export_roundtrip; mod extend; mod fixtures; mod model; diff --git a/crates/core/src/addon/pack/tests/roundtrip.rs b/crates/core/src/addon/pack/tests/roundtrip.rs index 5943c45..6e891ce 100644 --- a/crates/core/src/addon/pack/tests/roundtrip.rs +++ b/crates/core/src/addon/pack/tests/roundtrip.rs @@ -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 定案)" ); }