Merge branch 'feat/m112-packexport-mrpack-and-curseforge-ex'
This commit is contained in:
commit
d690d09b84
20 files changed
+2491
-60
No files matched your search
@@ -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};
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,9 @@
|
||||
|
||||
mod detect;
|
||||
mod export;
|
||||
mod export_harness;
|
||||
mod export_pipeline;
|
||||
mod export_roundtrip;
|
||||
mod extend;
|
||||
mod fixtures;
|
||||
mod model;
|
||||
|
||||
@@ -256,9 +256,12 @@ fn curseforge_without_name_is_a_metadata_error() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_entries_export_as_optional_on_both_formats() {
|
||||
// 禁用位(M115 调研 §字段缺口 2)在两边的落法不同,且都不该靠"猜":
|
||||
// CF → `required: false`;mrpack → `env.client: optional`(D10 的映射)。
|
||||
fn disabled_entries_export_as_path_suffix_on_mrpack_and_required_false_on_curseforge() {
|
||||
// 禁用位(M115 调研 §字段缺口 2 / §8.1)在两边的落法不同,且都不该靠"猜":
|
||||
// mrpack → `files[].path` 结尾 `.disabled`(官方导出的表达,**不是** env.client=optional:
|
||||
// 后者只是"安装时可取消勾选",装回来默认仍然启用 ⇒ 跨启动器不保真);
|
||||
// CF → 没有原生表达,格式层只能写 `required: false`(Prism 同款);导出**管线**更严:
|
||||
// 禁用文件直接内联进 overrides/ 并保留后缀(见 export::pipeline 的 DisabledNotExpressible)。
|
||||
let cf = fixtures::curseforge("disabled-cf");
|
||||
let pack = PackArchive::open_default(&cf.zip).expect("打开 CF 夹具");
|
||||
let mut manifest = CURSEFORGE.read(&pack).expect("读 CF 清单");
|
||||
@@ -273,7 +276,7 @@ fn disabled_entries_export_as_optional_on_both_formats() {
|
||||
assert_eq!(
|
||||
reread.files[0].client,
|
||||
EnvSupport::Optional,
|
||||
"CF 用 required=false 表达禁用"
|
||||
"CF 没有禁用表达,格式层只能落 required=false"
|
||||
);
|
||||
|
||||
let mr = fixtures::modrinth("disabled-mr");
|
||||
@@ -290,10 +293,14 @@ fn disabled_entries_export_as_optional_on_both_formats() {
|
||||
.expect("导出 mrpack");
|
||||
let written = PackArchive::open_default(&target).expect("打开导出的 mrpack");
|
||||
let reread = MODRINTH.read(&written).expect("再读 mrpack");
|
||||
assert_eq!(reread.files[0].client, EnvSupport::Optional);
|
||||
assert_eq!(
|
||||
reread.files[0].path.as_deref(),
|
||||
Some("mods/jei.jar"),
|
||||
"禁用不靠路径后缀表达(与 D10 的映射一致)"
|
||||
Some("mods/jei.jar.disabled"),
|
||||
"禁用靠路径后缀表达(官方导出的做法,跨启动器保真)"
|
||||
);
|
||||
assert_eq!(
|
||||
reread.files[0].client,
|
||||
EnvSupport::Required,
|
||||
"禁用不再被翻译成 env.client=optional(tower 定案)"
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user