Merge branch 'feat/m112-packexport-mrpack-and-curseforge-ex'

This commit is contained in:
AskaEth committed 2026-11-04 22:01:07 +08:00
commit d690d09b84
20 files changed
+2491 -60

No files matched your search

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