feat(core): 整合包格式无关模型与格式扩展点(M110)
从零建 `core::addon::pack`:格式无关中间模型 + 格式扩展点 + 安全读取层, 不碰网络、不碰 UI(core-no-ui 依赖表未变)。 - `PackManifest` 无损表达 CF / mrpack / MultiMC 三方字段:包名/版本/作者/简介/ 格式版本/游戏版本/加载器依赖/文件条目/覆盖目录;`files[].hashes` 保留整张摘要表 (mrpack 允许 sha1+sha256+sha512+md5 共存),`disabled` 记语义(导出按 D10 映射成 env.client=optional,CF 落 required=false)。 - 未知字段按条目用 serde flatten 接住(清单顶层 / CF `minecraft` 对象 / 文件条目 / env / 加载器条目),导出时并回且已知字段优先;两格式各有一条「读→写→再读相等」测试。 - `trait PackFormat`(id / label / metadata_entry / detect -> Option<证据> / read / render / 默认 write)+ `PackRegistry`:注册表顺序即优先级,写死 CurseForge → Modrinth → MultiMC;同一份包命中多格式时 `also_matched` 暴露歧义。 测试里用一个 toy 格式(单文件、一行注册)证明扩展点真的可扩展。 - `PackArchive` 把目录与 zip 统一成规整条目表:路径穿越双检(自研七条规则 + zip crate 的 enclosed_name)、符号链接条目拒绝、条目数/解压体积/单条目上限、 规整后重名拒绝、篡改载荷不当场吞掉;每条都有恶意样本测试。 - 格式实现:CF 可读写(缺路径如实记 None)、mrpack 可读写(强制 sha512 形状、 env 三态、三个覆盖目录成三侧)、MultiMC 只读(.minecraft 当覆盖目录, 组件顺序与非加载器组件全保留)。 - 三种格式各一个手造夹具(含截断 TOML / 半截 jar / 零字节 jar),来源在 `tests/fixtures.rs` 里声明为手造,不冒充真实导出。
This commit is contained in:
1 parent
40a4c06d42
commit
b34e86bba5
20 files changed
+3729
-1
No files matched your search
@@ -8,12 +8,20 @@
|
||||
//!
|
||||
//! - [`loader`](P2 · M32):加载器安装——官方元数据 → 带 `inheritsFrom` 的 loader 版本 JSON
|
||||
//! → 补库清单,落盘与实例登记复用 `core::game::install`(M27)。
|
||||
//! - [`pack`](P2 · M110):整合包的**格式无关模型**与**格式扩展点**——CF / Modrinth / MultiMC
|
||||
//! 三种格式的探测与读取、CF / mrpack 的渲染与导出、zip 安全读取(穿越双检 / 符号链接 /
|
||||
//! 条数体积上限)。导入导出**管线**(补全、解压、合并)是 M111/M112,不在这里。
|
||||
//!
|
||||
//! 规划中:整合包导入导出(D10 §2)、本地 mod 管理(§3)、CF/Modrinth 客户端(§5)。
|
||||
//! 规划中:本地 mod 管理(D10 §3)、CF/Modrinth 客户端(§5)。
|
||||
|
||||
pub mod loader;
|
||||
pub mod pack;
|
||||
|
||||
pub use loader::{
|
||||
LoaderError, LoaderErrorKind, LoaderInstallPlan, LoaderKind, LoaderPlanInput, LoaderRelease,
|
||||
LoaderSource, LoaderSupport,
|
||||
};
|
||||
pub use pack::{
|
||||
EnvSupport, PackArchive, PackError, PackErrorKind, PackFile, PackFormat, PackLimits,
|
||||
PackLoader, PackManifest, PackOverrides, PackRegistry, PackResult, PackSide,
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
//! 整合包的**格式无关模型**与**格式扩展点**(M110)。导入/导出管线(M111/M112)建在这上面。
|
||||
//!
|
||||
//! 设计定稿:`DevDocs/PlanDoc/08-mod-and-recommend.md` §2(D10),格式细节来自
|
||||
//! `DevDocs/analyse/05-modloader-modpack-p2.md` §2.1–§2.3(HMCL 源码级拆解,A 级来源)。
|
||||
//!
|
||||
//! # 本模块只做三件事
|
||||
//!
|
||||
//! | 层 | 模块 | 职责 |
|
||||
//! |----|------|------|
|
||||
//! | 安全读取 | [`archive`] | 目录与 zip 统一成"规整过的条目表":路径穿越双检、符号链接拒绝、条数/体积上限 |
|
||||
//! | 格式 | [`format`] | [`format::PackFormat`] trait + [`format::PackRegistry`]:探测、读、渲染、写,**优先级写死** |
|
||||
//! | 模型 | [`manifest`] | [`manifest::PackManifest`]:包名/版本/作者/游戏版本/加载器/文件条目/覆盖目录,未知字段原样保留 |
|
||||
//!
|
||||
//! 三个内置格式:[`curseforge`](`manifest.json`,可读可写)、[`modrinth`](`.mrpack`,可读可写)、
|
||||
//! [`multimc`](`mmc-pack.json`,**只读**)。
|
||||
//!
|
||||
//! # 刻意不做的事(分工边界)
|
||||
//!
|
||||
//! - **不发网络请求**:CF 的 `projectID/fileID` → 文件名/URL 补全属于 M111(要用 CF API 与
|
||||
//! MurmurHash2 指纹),本模块只把"缺什么"如实记成 `None`;
|
||||
//! - **不落盘解压**:`archive` 只读,不写实例目录。overrides 的三方合并(base/theirs/ours)
|
||||
//! 是 M111 的解压器职责,本模块提供它要的输入:每条覆盖文件的实例内相对路径 + SHA-1;
|
||||
//! - **不碰 UI 类型**:core 零 UI 依赖(`core-no-ui` 门禁),这里连 `String` 之外的表现层
|
||||
//! 类型都不出现;
|
||||
//! - **不猜格式**:探测顺序写死在 [`format::PackRegistry::builtin`],同一份包命中多格式时
|
||||
//! 用固定的注册顺序选,并把其余命中记进 [`format::PackMatch::also_matched`]。
|
||||
//!
|
||||
//! # 安全(每条都有恶意样本测试,见 `tests/safety.rs`)
|
||||
//!
|
||||
//! 路径穿越判据是**两条独立检查**:①本模块的 [`path::normalize_file`](七条写死的规则:
|
||||
//! 空名/`\0`/`\`/`:`/前导 `/`/任何 `..` 段/规整后为空);②`zip` crate 的
|
||||
//! `enclosed_name()`(按 Windows 规则解析盘符、NTFS 数据流、保留设备名)。
|
||||
//! 为什么不能只留一条:`enclosed_name()` 允许"深度 0 的根目录",也就是 `"/etc/passwd"`
|
||||
//! 它会放行;反过来,本模块的规则不认 Windows 保留设备名。**两条都过才算安全**。
|
||||
//!
|
||||
//! # 测试夹具的来源(不许把"看起来像"当真实)
|
||||
//!
|
||||
//! `tests/fixtures.rs` 里的 CF / mrpack / MultiMC 三个包**全部是手造的**:字段取自
|
||||
//! analyse/05 记录的 HMCL 解析器形状(A 级来源),内容是为了覆盖边界而编的(含一个刻意的
|
||||
//! 损坏文件:截断的 `config/broken.toml`、半截 `mods/half.jar`、零字节 `mods/broken.jar`)。
|
||||
//! **没有一个来自真实导出**——本任务环境离线,没有真实整合包样本可对照。真实样本的字段
|
||||
//! 偏移风险因此只能由 M111/M115(格式字段盘点)在拿到真包后回收,报告里已登记这条欠账。
|
||||
|
||||
pub mod archive;
|
||||
pub mod curseforge;
|
||||
pub mod error;
|
||||
pub mod format;
|
||||
pub mod manifest;
|
||||
pub mod modrinth;
|
||||
pub mod multimc;
|
||||
pub mod path;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub use archive::{PackArchive, PackEntryInfo, PackLimits};
|
||||
pub use curseforge::CurseForge;
|
||||
pub use error::{PackError, PackErrorKind, PackResult};
|
||||
pub use format::{PackFormat, PackMatch, PackPayload, PackRegistry};
|
||||
pub use manifest::{
|
||||
EnvSupport, OverrideFile, PackFile, PackLoader, PackManifest, PackOverrides, PackSide,
|
||||
};
|
||||
pub use modrinth::Modrinth;
|
||||
pub use multimc::MultiMc;
|
||||
@@ -0,0 +1,259 @@
|
||||
//! 打包输入的安全读取层:[`PackArchive`] 把"目录"与"zip"统一成"一组规整过的条目"。
|
||||
//!
|
||||
//! # 为什么要有这一层
|
||||
//!
|
||||
//! 格式探测器要能对**目录**(MultiMC 实例、已解开的整合包)与**zip**(`.zip` / `.mrpack`)
|
||||
//! 一视同仁地问"有没有 `modrinth.index.json`",而安全判据只有一份实现才不会漂。
|
||||
//!
|
||||
//! # 安全判据(每条都有恶意样本测试,见 `tests/safety.rs`)
|
||||
//!
|
||||
//! | 威胁 | 做法 |
|
||||
//! |------|------|
|
||||
//! | 路径穿越(`../x`、`/etc/x`、`C:\x`、`a\..\b`、`file:ads`) | 条目名过 [`normalize_file`](第一检),再过 `zip` crate 的 `enclosed_name()`(第二检:它按 **Windows 规则**解析盘符/ADS/保留设备名);任一拒绝即整包拒绝 |
|
||||
//! | 符号链接条目 | `unix_mode()` 的 `S_IFLNK` 位命中即拒绝(Windows 上无合法用途;解压器会把目标当内容写出来) |
|
||||
//! | 条目数爆炸 | [`PackLimits::max_entries`],打开时按中央目录长度先判 |
|
||||
//! | 解压体积爆炸(zip 炸弹) | [`PackLimits::max_uncompressed_bytes`] 累计 + [`PackLimits::max_entry_bytes`] 单条目;**读取时**再按实际读出的字节数封顶(中央目录里声明的大小可以撒谎) |
|
||||
//! | 同名条目(`./a` 与 `a` 规整后撞车) | 规整后的名字重复即拒绝 |
|
||||
//!
|
||||
//! **为什么第一检不能省**:`enclosed_name()` 允许"深度 0 处的根目录",也就是
|
||||
//! `"/etc/passwd"` 会返回 `Some("/etc/passwd")`——绝对路径必须由我们自己的规则先干掉,
|
||||
//! 库的检查只当跨平台兜底。
|
||||
//!
|
||||
//! **不在这里做的事**:不写盘(那是 M111 导入管线的事),所以 Windows 保留设备名
|
||||
//! (`CON` / `NUL` / `COM1`…)这里不拦——落盘前必须拦,见 `zip::path` 的同名规则。
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::fs::File;
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde_json::Value;
|
||||
use zip::ZipArchive;
|
||||
|
||||
use super::error::{PackError, PackResult};
|
||||
use super::manifest::OverrideFile;
|
||||
use super::path::normalize_dir;
|
||||
use crate::download::types::HashAlgo;
|
||||
use crate::download::verify::{self, Hasher};
|
||||
|
||||
pub mod entry;
|
||||
pub mod limits;
|
||||
mod open;
|
||||
|
||||
pub use entry::PackEntryInfo;
|
||||
pub use limits::{
|
||||
PackLimits, DEFAULT_MAX_ENTRIES, DEFAULT_MAX_ENTRY_BYTES, DEFAULT_MAX_UNCOMPRESSED_BYTES,
|
||||
};
|
||||
|
||||
use limits::read_capped;
|
||||
|
||||
/// 输入来源。
|
||||
pub(super) enum Source {
|
||||
/// 目录(MultiMC 实例、已解开的包)。
|
||||
Dir,
|
||||
/// zip(`.zip` / `.mrpack`)。`RefCell` 让只读方法能取 `&mut ZipArchive`(解压需要)。
|
||||
Zip(RefCell<ZipArchive<File>>),
|
||||
}
|
||||
|
||||
/// 一个已校验的整合包输入(目录或 zip)。
|
||||
pub struct PackArchive {
|
||||
root: PathBuf,
|
||||
source: Source,
|
||||
entries: Vec<PackEntryInfo>,
|
||||
index: HashMap<String, usize>,
|
||||
limits: PackLimits,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for PackArchive {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
.debug_struct("PackArchive")
|
||||
.field("root", &self.root)
|
||||
.field(
|
||||
"source",
|
||||
&match self.source {
|
||||
Source::Dir => "dir",
|
||||
Source::Zip(_) => "zip",
|
||||
},
|
||||
)
|
||||
.field("entries", &self.entries.len())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl PackArchive {
|
||||
/// 打开一个目录或 zip,按 [`PackLimits::default`] 校验。
|
||||
pub fn open_default(path: impl AsRef<Path>) -> PackResult<Self> {
|
||||
Self::open(path, PackLimits::default())
|
||||
}
|
||||
|
||||
/// 打开一个目录或 zip,按给定限额校验。
|
||||
///
|
||||
/// 校验是**全量**的:打开成功就说明整包的条目名都过了两道穿越检查、没有符号链接、
|
||||
/// 条数与体积都在限额内。后续读取不再需要重复判断来源是否恶意。
|
||||
pub fn open(path: impl AsRef<Path>, limits: PackLimits) -> PackResult<Self> {
|
||||
let root = path.as_ref().to_path_buf();
|
||||
let metadata = std::fs::symlink_metadata(&root)
|
||||
.map_err(|error| PackError::io(format!("打不开整合包 {}:{error}", root.display())))?;
|
||||
if metadata.file_type().is_symlink() {
|
||||
return Err(PackError::symlink(format!(
|
||||
"整合包本身是符号链接:{}",
|
||||
root.display()
|
||||
)));
|
||||
}
|
||||
if metadata.is_dir() {
|
||||
Self::open_dir(root, limits)
|
||||
} else {
|
||||
Self::open_zip(root, limits)
|
||||
}
|
||||
}
|
||||
|
||||
/// 输入路径。
|
||||
#[must_use]
|
||||
pub fn root(&self) -> &Path {
|
||||
&self.root
|
||||
}
|
||||
|
||||
/// 是否目录输入。
|
||||
#[must_use]
|
||||
pub fn is_dir_source(&self) -> bool {
|
||||
matches!(self.source, Source::Dir)
|
||||
}
|
||||
|
||||
/// 全部条目(目录输入按名字排序,zip 输入保持中央目录顺序)。
|
||||
#[must_use]
|
||||
pub fn entries(&self) -> &[PackEntryInfo] {
|
||||
&self.entries
|
||||
}
|
||||
|
||||
/// 按规整名查一条。
|
||||
#[must_use]
|
||||
pub fn entry(&self, name: &str) -> Option<&PackEntryInfo> {
|
||||
self.index.get(name).map(|slot| &self.entries[*slot])
|
||||
}
|
||||
|
||||
/// 有没有这个条目(探测器用:只问存不存在,不读内容)。
|
||||
#[must_use]
|
||||
pub fn has_entry(&self, name: &str) -> bool {
|
||||
self.index.contains_key(name)
|
||||
}
|
||||
|
||||
/// 读一个条目;不存在返回 `Ok(None)`。
|
||||
pub fn read(&self, name: &str) -> PackResult<Option<Vec<u8>>> {
|
||||
let Some(entry) = self.entry(name) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if entry.is_dir {
|
||||
return Ok(None);
|
||||
}
|
||||
match &self.source {
|
||||
Source::Dir => {
|
||||
let path = self.root.join(&entry.raw);
|
||||
let bytes = std::fs::read(&path).map_err(|error| {
|
||||
PackError::io(format!("读 {} 失败:{error}", path.display()))
|
||||
})?;
|
||||
Ok(Some(bytes))
|
||||
}
|
||||
Source::Zip(archive) => {
|
||||
let mut archive = archive.borrow_mut();
|
||||
let mut reader = archive
|
||||
.by_name(&entry.raw)
|
||||
.map_err(|error| PackError::metadata(format!("解压 {name} 失败:{error}")))?;
|
||||
let bytes = read_capped(&mut reader, self.limits.max_entry_bytes, name)?;
|
||||
Ok(Some(bytes))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 按 UTF-8 读文本条目。
|
||||
pub fn read_text(&self, name: &str) -> PackResult<Option<String>> {
|
||||
let Some(bytes) = self.read(name)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
String::from_utf8(bytes)
|
||||
.map(Some)
|
||||
.map_err(|error| PackError::metadata(format!("{name} 不是 UTF-8 文本:{error}")))
|
||||
}
|
||||
|
||||
/// 读 JSON 条目;不存在返回 `Ok(None)`。
|
||||
pub fn read_json(&self, name: &str) -> PackResult<Option<Value>> {
|
||||
let Some(text) = self.read_text(name)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let value = serde_json::from_str(&text)
|
||||
.map_err(|error| PackError::metadata(format!("{name} 不是合法 JSON:{error}")))?;
|
||||
Ok(Some(value))
|
||||
}
|
||||
|
||||
/// 算一个条目的 SHA-1;不存在返回 `Ok(None)`。
|
||||
pub fn sha1(&self, name: &str) -> PackResult<Option<String>> {
|
||||
let Some(entry) = self.entry(name) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if entry.is_dir {
|
||||
return Ok(None);
|
||||
}
|
||||
match &self.source {
|
||||
Source::Dir => {
|
||||
let path = self.root.join(&entry.raw);
|
||||
let digest = verify::digest_file(&path, HashAlgo::Sha1).map_err(|error| {
|
||||
PackError::io(format!("算 {} 的 SHA-1 失败:{error}", path.display()))
|
||||
})?;
|
||||
Ok(Some(digest))
|
||||
}
|
||||
Source::Zip(archive) => {
|
||||
let mut archive = archive.borrow_mut();
|
||||
let mut reader = archive
|
||||
.by_name(&entry.raw)
|
||||
.map_err(|error| PackError::metadata(format!("解压 {name} 失败:{error}")))?;
|
||||
let mut hasher = Hasher::new(HashAlgo::Sha1);
|
||||
let mut total = 0_u64;
|
||||
let mut buffer = [0_u8; 64 * 1024];
|
||||
loop {
|
||||
let read = reader
|
||||
.read(&mut buffer)
|
||||
.map_err(|error| PackError::io(format!("读 {name} 失败:{error}")))?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
total += read as u64;
|
||||
if total > self.limits.max_entry_bytes {
|
||||
return Err(PackError::limits(format!(
|
||||
"{name} 实际解压超过单条目上限 {}",
|
||||
self.limits.max_entry_bytes
|
||||
)));
|
||||
}
|
||||
hasher.update(&buffer[..read]);
|
||||
}
|
||||
Ok(Some(hasher.finish()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 收集一个覆盖目录下的文件(相对该目录的路径 + SHA-1 + 字节数),按路径排序。
|
||||
///
|
||||
/// 目录不存在时返回空表——"包没带 overrides"不是错误。
|
||||
pub fn override_files(&self, dir: &str) -> PackResult<Vec<OverrideFile>> {
|
||||
let dir = normalize_dir(dir)?;
|
||||
let prefix = format!("{dir}/");
|
||||
let mut collected = Vec::new();
|
||||
for entry in &self.entries {
|
||||
if entry.is_dir || !entry.name.starts_with(&prefix) {
|
||||
continue;
|
||||
}
|
||||
let relative = &entry.name[prefix.len()..];
|
||||
if relative.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let sha1 = self.sha1(&entry.name)?.unwrap_or_default();
|
||||
collected.push(OverrideFile {
|
||||
path: relative.to_string(),
|
||||
sha1,
|
||||
size: entry.size,
|
||||
});
|
||||
}
|
||||
collected.sort_by(|left, right| left.path.cmp(&right.path));
|
||||
Ok(collected)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
//! 一条规整过的包内条目。
|
||||
|
||||
/// 一条规整过的包内条目。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PackEntryInfo {
|
||||
pub(in crate::addon::pack::archive) name: String,
|
||||
pub(in crate::addon::pack::archive) raw: String,
|
||||
pub(in crate::addon::pack::archive) size: u64,
|
||||
pub(in crate::addon::pack::archive) is_dir: bool,
|
||||
}
|
||||
|
||||
impl PackEntryInfo {
|
||||
/// 规整后的相对路径(`/` 分隔,无前导斜杠、无 `.`/`..` 段)。
|
||||
#[must_use]
|
||||
pub fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
/// 声明/实测的字节数(目录条目为 0)。
|
||||
#[must_use]
|
||||
pub const fn size(&self) -> u64 {
|
||||
self.size
|
||||
}
|
||||
|
||||
/// 是否是目录条目。
|
||||
#[must_use]
|
||||
pub const fn is_dir(&self) -> bool {
|
||||
self.is_dir
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
//! 读取限额:条目数 / 解压总体积 / 单条目,以及两处封顶实现。
|
||||
|
||||
use std::io::Read;
|
||||
|
||||
use super::super::error::{PackError, PackResult};
|
||||
|
||||
/// 默认条目数上限(一个巨型整合包 ≈ 数千条,2 万条已经很宽)。
|
||||
pub const DEFAULT_MAX_ENTRIES: usize = 20_000;
|
||||
/// 默认解压总体积上限(512 MiB;`.mrpack` 的 `overrides` 通常几十 MiB)。
|
||||
pub const DEFAULT_MAX_UNCOMPRESSED_BYTES: u64 = 512 * 1024 * 1024;
|
||||
/// 默认单条目上限(64 MiB;最大的 mod 也不过十几 MiB)。
|
||||
pub const DEFAULT_MAX_ENTRY_BYTES: u64 = 64 * 1024 * 1024;
|
||||
|
||||
/// 包读取限额。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct PackLimits {
|
||||
/// 条目数上限。
|
||||
pub max_entries: usize,
|
||||
/// 解压总体积上限。
|
||||
pub max_uncompressed_bytes: u64,
|
||||
/// 单条目上限。
|
||||
pub max_entry_bytes: u64,
|
||||
}
|
||||
|
||||
impl PackLimits {
|
||||
/// 三档显式给值(测试与"可疑包"复查用)。
|
||||
#[must_use]
|
||||
pub const fn new(
|
||||
max_entries: usize,
|
||||
max_uncompressed_bytes: u64,
|
||||
max_entry_bytes: u64,
|
||||
) -> Self {
|
||||
Self {
|
||||
max_entries,
|
||||
max_uncompressed_bytes,
|
||||
max_entry_bytes,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PackLimits {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_entries: DEFAULT_MAX_ENTRIES,
|
||||
max_uncompressed_bytes: DEFAULT_MAX_UNCOMPRESSED_BYTES,
|
||||
max_entry_bytes: DEFAULT_MAX_ENTRY_BYTES,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 累计与单条目限额检查(打开阶段)。
|
||||
pub(super) fn check_limits(
|
||||
limits: &PackLimits,
|
||||
total: &mut u64,
|
||||
size: u64,
|
||||
name: &str,
|
||||
seen: usize,
|
||||
) -> PackResult<()> {
|
||||
if size > limits.max_entry_bytes {
|
||||
return Err(PackError::limits(format!(
|
||||
"条目 {name} 声明 {} 字节,超过单条目上限 {}",
|
||||
size, limits.max_entry_bytes
|
||||
)));
|
||||
}
|
||||
*total = total.saturating_add(size);
|
||||
if *total > limits.max_uncompressed_bytes {
|
||||
return Err(PackError::limits(format!(
|
||||
"解压总体积超过上限 {} 字节(累计到 {total})",
|
||||
limits.max_uncompressed_bytes
|
||||
)));
|
||||
}
|
||||
if seen >= limits.max_entries {
|
||||
return Err(PackError::limits(format!(
|
||||
"条目数超过上限 {}",
|
||||
limits.max_entries
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 带封顶的读:中央目录里声明的大小可以撒谎,这里按**实际读出的字节**封顶。
|
||||
pub(super) fn read_capped(reader: &mut impl Read, cap: u64, name: &str) -> PackResult<Vec<u8>> {
|
||||
let mut bytes = Vec::new();
|
||||
let mut buffer = [0_u8; 64 * 1024];
|
||||
loop {
|
||||
let read = reader
|
||||
.read(&mut buffer)
|
||||
.map_err(|error| PackError::io(format!("读 {name} 失败:{error}")))?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
if bytes.len() as u64 + read as u64 > cap {
|
||||
return Err(PackError::limits(format!(
|
||||
"{name} 实际解压超过单条目上限 {cap}"
|
||||
)));
|
||||
}
|
||||
bytes.extend_from_slice(&buffer[..read]);
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
//! 打开与首轮校验:目录遍历 / zip 中央目录扫描,两道穿越检查、符号链接、限额都在这里。
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::fs::File;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use zip::ZipArchive;
|
||||
|
||||
use super::entry::PackEntryInfo;
|
||||
use super::limits::{check_limits, PackLimits};
|
||||
use super::{PackArchive, Source};
|
||||
use crate::addon::pack::error::{PackError, PackResult};
|
||||
use crate::addon::pack::path::{has_trailing_slash, normalize_file};
|
||||
|
||||
impl PackArchive {
|
||||
pub(super) fn open_dir(root: PathBuf, limits: PackLimits) -> PackResult<Self> {
|
||||
let mut entries = Vec::new();
|
||||
let mut total: u64 = 0;
|
||||
// 栈式遍历:不递归,避免深层目录把栈打穿。
|
||||
let mut pending = vec![String::new()];
|
||||
while let Some(relative) = pending.pop() {
|
||||
let dir = if relative.is_empty() {
|
||||
root.clone()
|
||||
} else {
|
||||
root.join(&relative)
|
||||
};
|
||||
let mut children = std::fs::read_dir(&dir)
|
||||
.map_err(|error| PackError::io(format!("读目录 {} 失败:{error}", dir.display())))?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(PackError::from)?;
|
||||
children.sort_by_key(std::fs::DirEntry::file_name);
|
||||
for child in children {
|
||||
let file_name =
|
||||
child
|
||||
.file_name()
|
||||
.to_str()
|
||||
.map(str::to_string)
|
||||
.ok_or_else(|| {
|
||||
PackError::metadata(format!(
|
||||
"包内文件名不是 UTF-8:{}",
|
||||
child.path().display()
|
||||
))
|
||||
})?;
|
||||
let raw = if relative.is_empty() {
|
||||
file_name
|
||||
} else {
|
||||
format!("{relative}/{file_name}")
|
||||
};
|
||||
let file_type = child
|
||||
.file_type()
|
||||
.map_err(|error| PackError::io(format!("读 {} 类型失败:{error}", raw)))?;
|
||||
if file_type.is_symlink() {
|
||||
return Err(PackError::symlink(format!("整合包里有符号链接条目:{raw}")));
|
||||
}
|
||||
let name = normalize_file(&raw)?;
|
||||
if file_type.is_dir() {
|
||||
check_limits(&limits, &mut total, 0, &name, entries.len())?;
|
||||
pending.push(name.clone());
|
||||
entries.push(PackEntryInfo {
|
||||
name,
|
||||
raw,
|
||||
size: 0,
|
||||
is_dir: true,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if !file_type.is_file() {
|
||||
continue;
|
||||
}
|
||||
let size = child
|
||||
.metadata()
|
||||
.map_err(|error| PackError::io(format!("读 {raw} 大小失败:{error}")))?
|
||||
.len();
|
||||
check_limits(&limits, &mut total, size, &name, entries.len())?;
|
||||
entries.push(PackEntryInfo {
|
||||
name,
|
||||
raw,
|
||||
size,
|
||||
is_dir: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
entries.sort_by(|left, right| left.name.cmp(&right.name));
|
||||
Self::finish(root, Source::Dir, entries, limits)
|
||||
}
|
||||
|
||||
pub(super) fn open_zip(root: PathBuf, limits: PackLimits) -> PackResult<Self> {
|
||||
let file = File::open(&root)
|
||||
.map_err(|error| PackError::io(format!("打开 {} 失败:{error}", root.display())))?;
|
||||
let mut archive = ZipArchive::new(file)
|
||||
.map_err(|error| PackError::metadata(format!("不是可读的 zip:{error}")))?;
|
||||
if archive.len() > limits.max_entries {
|
||||
return Err(PackError::limits(format!(
|
||||
"zip 条目数 {} 超过上限 {}",
|
||||
archive.len(),
|
||||
limits.max_entries
|
||||
)));
|
||||
}
|
||||
let mut entries = Vec::with_capacity(archive.len());
|
||||
let mut total: u64 = 0;
|
||||
for index in 0..archive.len() {
|
||||
let entry = archive.by_index(index).map_err(|error| {
|
||||
PackError::metadata(format!("读第 {index} 个条目失败:{error}"))
|
||||
})?;
|
||||
let Ok(raw) = entry.name() else {
|
||||
return Err(PackError::metadata(format!(
|
||||
"第 {index} 个条目名编码不合法"
|
||||
)));
|
||||
};
|
||||
let raw = raw.to_string();
|
||||
let is_dir = entry.is_dir() || has_trailing_slash(&raw);
|
||||
if entry.is_symlink() {
|
||||
return Err(PackError::symlink(format!("zip 里有符号链接条目:{raw}")));
|
||||
}
|
||||
let name = normalize_file(&raw)?;
|
||||
// 第二检:zip crate 按 Windows 规则再解析一遍(盘符 / ADS / 保留设备名)。
|
||||
if entry.enclosed_name().is_none() {
|
||||
return Err(PackError::traversal(format!(
|
||||
"zip 条目名过不了 enclosed_name 检查:{raw}"
|
||||
)));
|
||||
}
|
||||
let size = if is_dir { 0 } else { entry.size() };
|
||||
check_limits(&limits, &mut total, size, &name, entries.len())?;
|
||||
entries.push(PackEntryInfo {
|
||||
name,
|
||||
raw,
|
||||
size,
|
||||
is_dir,
|
||||
});
|
||||
}
|
||||
Self::finish(root, Source::Zip(RefCell::new(archive)), entries, limits)
|
||||
}
|
||||
|
||||
fn finish(
|
||||
root: PathBuf,
|
||||
source: Source,
|
||||
entries: Vec<PackEntryInfo>,
|
||||
limits: PackLimits,
|
||||
) -> PackResult<Self> {
|
||||
let mut index = HashMap::with_capacity(entries.len());
|
||||
for (slot, entry) in entries.iter().enumerate() {
|
||||
if index.insert(entry.name.clone(), slot).is_some() {
|
||||
return Err(PackError::metadata(format!(
|
||||
"包里有规整后重名的条目:{}",
|
||||
entry.name
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(Self {
|
||||
root,
|
||||
source,
|
||||
entries,
|
||||
index,
|
||||
limits,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
//! CurseForge 格式:`manifest.json` + `overrides/`。
|
||||
//!
|
||||
//! # 形状(A 级来源:HMCL `CurseManifest.java` / `CurseManifestFile.java`,见 analyse/05 §2.2)
|
||||
//!
|
||||
//! ```jsonc
|
||||
//! {
|
||||
//! "manifestType": "minecraftModpack", "manifestVersion": 1,
|
||||
//! "name": "…", "version": "1.0.0", "author": "…",
|
||||
//! "overrides": "overrides",
|
||||
//! "minecraft": {"version": "1.20.1", "modLoaders": [{"id": "forge-47.2.0", "primary": true}]},
|
||||
//! "files": [{"projectID": 238222, "fileID": 5102025, "required": true}]
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! 关键缺口(**模型如实记录,不假装有**):
|
||||
//!
|
||||
//! - `files[]` **没有路径、没有 URL、没有哈希**。路径要按项目 `classId` 猜(`mods/` /
|
||||
//! `resourcepacks/` / `shaderpacks/`),文件名要按 ForgeCDN 规则或 API 补——这正是 D10 §2
|
||||
//! 两阶段补全要补的东西,属于 M111,不在本模块。所以 [`PackFile::path`] 读出来是 `None`。
|
||||
//! - `minecraft.modLoaders[].id` 只认 `forge-` / `neoforge-` / `fabric-` / `quilt-` 前缀
|
||||
//! (CurseForge 没有 Quilt 的正规表达方式);不认识的 id **原样保留**在
|
||||
//! [`PackLoader::raw`],写回时逐字还原。
|
||||
//! - 只有一个 `required` 布尔 → 客户端/服务端同值;导出时 `client == Optional` 或
|
||||
//! `disabled` 即 `required: false`。`client`/`server` 分侧语义 CF 表达不了(mrpack 专属)。
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
use super::archive::PackArchive;
|
||||
use super::error::{PackError, PackResult};
|
||||
use super::format::PackFormat;
|
||||
use super::manifest::{
|
||||
merge_unknown, EnvSupport, PackFile, PackLoader, PackManifest, PackOverrides, PackSide,
|
||||
};
|
||||
use super::path::normalize_dir;
|
||||
|
||||
/// 格式 id。
|
||||
pub const ID: &str = "curseforge";
|
||||
/// 清单入口名。
|
||||
pub const METADATA_ENTRY: &str = "manifest.json";
|
||||
/// `manifestType` 的固定值。
|
||||
pub const MARKER: &str = "minecraftModpack";
|
||||
/// 默认覆盖目录名。
|
||||
pub const DEFAULT_OVERRIDES: &str = "overrides";
|
||||
|
||||
/// 单例(进注册表用)。
|
||||
pub static CURSEFORGE: CurseForge = CurseForge;
|
||||
|
||||
/// CurseForge 格式的值类型实现。
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct CurseForge;
|
||||
|
||||
/// `manifest.json` 的原始形状(未知字段落 `extra`)。
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct RawManifest {
|
||||
#[serde(rename = "manifestType", default)]
|
||||
manifest_type: Option<String>,
|
||||
#[serde(rename = "manifestVersion", default)]
|
||||
manifest_version: Option<i64>,
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
#[serde(default)]
|
||||
version: Option<String>,
|
||||
#[serde(default)]
|
||||
author: Option<String>,
|
||||
#[serde(default)]
|
||||
overrides: Option<String>,
|
||||
#[serde(default)]
|
||||
minecraft: Option<RawMinecraft>,
|
||||
#[serde(default)]
|
||||
files: Vec<RawFile>,
|
||||
#[serde(flatten)]
|
||||
extra: Map<String, Value>,
|
||||
}
|
||||
|
||||
/// `minecraft` 对象。
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct RawMinecraft {
|
||||
#[serde(default)]
|
||||
version: Option<String>,
|
||||
#[serde(rename = "modLoaders", default)]
|
||||
mod_loaders: Vec<RawLoader>,
|
||||
#[serde(flatten)]
|
||||
extra: Map<String, Value>,
|
||||
}
|
||||
|
||||
/// `minecraft.modLoaders[]` 的一条。
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct RawLoader {
|
||||
#[serde(default)]
|
||||
id: Option<String>,
|
||||
#[serde(default)]
|
||||
primary: bool,
|
||||
#[serde(flatten)]
|
||||
extra: Map<String, Value>,
|
||||
}
|
||||
|
||||
/// `files[]` 的一条。
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct RawFile {
|
||||
#[serde(rename = "projectID", default)]
|
||||
project_id: Option<i64>,
|
||||
#[serde(rename = "fileID", default)]
|
||||
file_id: Option<i64>,
|
||||
#[serde(default = "default_required")]
|
||||
required: bool,
|
||||
#[serde(flatten)]
|
||||
extra: Map<String, Value>,
|
||||
}
|
||||
|
||||
/// CF 的 `required` 缺省是 `true`(老包可能不写)。
|
||||
const fn default_required() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl PackFormat for CurseForge {
|
||||
fn id(&self) -> &'static str {
|
||||
ID
|
||||
}
|
||||
|
||||
fn label(&self) -> &'static str {
|
||||
"CurseForge"
|
||||
}
|
||||
|
||||
fn metadata_entry(&self) -> Option<&'static str> {
|
||||
Some(METADATA_ENTRY)
|
||||
}
|
||||
|
||||
fn detect(&self, pack: &PackArchive) -> Option<&'static str> {
|
||||
let value = pack.read_json(METADATA_ENTRY).ok().flatten()?;
|
||||
let object = value.as_object()?;
|
||||
let matched = match object.get("manifestType").and_then(Value::as_str) {
|
||||
// 有标记:只认固定值(`manifest.json` 这名字太泛,光凭名字不能认领)。
|
||||
Some(kind) => kind == MARKER,
|
||||
// 没有标记的老包:要求形状对得上(有 minecraft 对象 + files 数组)。
|
||||
None => {
|
||||
object.get("minecraft").is_some_and(Value::is_object)
|
||||
&& object.get("files").is_some_and(Value::is_array)
|
||||
}
|
||||
};
|
||||
matched.then_some(METADATA_ENTRY)
|
||||
}
|
||||
|
||||
fn read(&self, pack: &PackArchive) -> PackResult<PackManifest> {
|
||||
let value = pack
|
||||
.read_json(METADATA_ENTRY)?
|
||||
.ok_or_else(|| PackError::metadata(format!("包里没有 {METADATA_ENTRY}")))?;
|
||||
let raw: RawManifest = serde_json::from_value(value)
|
||||
.map_err(|error| PackError::metadata(format!("{METADATA_ENTRY} 形状不符:{error}")))?;
|
||||
if let Some(kind) = &raw.manifest_type {
|
||||
if kind != MARKER {
|
||||
return Err(PackError::unsupported(format!(
|
||||
"{METADATA_ENTRY} 的 manifestType 是 {kind:?},不是 {MARKER:?}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
let name = raw
|
||||
.name
|
||||
.filter(|name| !name.trim().is_empty())
|
||||
.ok_or_else(|| PackError::metadata(format!("{METADATA_ENTRY} 缺 name")))?;
|
||||
let minecraft = raw.minecraft.unwrap_or_default();
|
||||
let game_version = minecraft
|
||||
.version
|
||||
.filter(|version| !version.trim().is_empty())
|
||||
.ok_or_else(|| PackError::metadata(format!("{METADATA_ENTRY} 缺 minecraft.version")))?;
|
||||
|
||||
let loaders = minecraft
|
||||
.mod_loaders
|
||||
.into_iter()
|
||||
.map(|loader| {
|
||||
let mut parsed = PackLoader::parse_curse(
|
||||
loader.id.as_deref().unwrap_or_default(),
|
||||
loader.primary,
|
||||
);
|
||||
parsed.extra = loader.extra;
|
||||
parsed
|
||||
})
|
||||
.collect();
|
||||
|
||||
let files = raw
|
||||
.files
|
||||
.into_iter()
|
||||
.map(|file| {
|
||||
let env = if file.required {
|
||||
EnvSupport::Required
|
||||
} else {
|
||||
EnvSupport::Optional
|
||||
};
|
||||
PackFile {
|
||||
project_id: file.project_id,
|
||||
file_id: file.file_id,
|
||||
client: env,
|
||||
server: env,
|
||||
extra: file.extra,
|
||||
..PackFile::default()
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut overrides = Vec::new();
|
||||
if let Some(dir) = raw
|
||||
.overrides
|
||||
.as_deref()
|
||||
.filter(|dir| !dir.trim().is_empty())
|
||||
{
|
||||
let dir = normalize_dir(dir)?;
|
||||
overrides.push(PackOverrides {
|
||||
files: pack.override_files(&dir)?,
|
||||
dir,
|
||||
side: PackSide::Both,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(PackManifest {
|
||||
name,
|
||||
version: raw.version,
|
||||
author: raw.author,
|
||||
summary: None,
|
||||
format_version: raw.manifest_version,
|
||||
game: None,
|
||||
minecraft: Some(game_version),
|
||||
loaders,
|
||||
files,
|
||||
overrides,
|
||||
extra: raw.extra,
|
||||
minecraft_extra: minecraft.extra,
|
||||
})
|
||||
}
|
||||
|
||||
fn render(&self, manifest: &PackManifest) -> PackResult<String> {
|
||||
let game_version = manifest.minecraft.as_deref().ok_or_else(|| {
|
||||
PackError::metadata("CurseForge 清单需要 minecraft.version,模型里没有")
|
||||
})?;
|
||||
let mut root = Map::new();
|
||||
root.insert("manifestType".into(), json!(MARKER));
|
||||
root.insert(
|
||||
"manifestVersion".into(),
|
||||
json!(manifest.format_version.unwrap_or(1)),
|
||||
);
|
||||
root.insert("name".into(), json!(manifest.name));
|
||||
if let Some(version) = &manifest.version {
|
||||
root.insert("version".into(), json!(version));
|
||||
}
|
||||
if let Some(author) = &manifest.author {
|
||||
root.insert("author".into(), json!(author));
|
||||
}
|
||||
let overrides_dir = manifest
|
||||
.overrides_for(PackSide::Both)
|
||||
.map_or(DEFAULT_OVERRIDES, |group| group.dir.as_str());
|
||||
root.insert("overrides".into(), json!(overrides_dir));
|
||||
|
||||
let mut minecraft = Map::new();
|
||||
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(|| {
|
||||
PackError::unsupported(format!(
|
||||
"CurseForge 表达不了这条加载器依赖:raw={:?} kind={:?} version={:?}",
|
||||
loader.raw, loader.kind, loader.version
|
||||
))
|
||||
})?;
|
||||
let mut object = Map::new();
|
||||
object.insert("id".into(), json!(id));
|
||||
object.insert("primary".into(), json!(loader.primary));
|
||||
merge_unknown(&mut object, &loader.extra);
|
||||
mod_loaders.push(Value::Object(object));
|
||||
}
|
||||
minecraft.insert("modLoaders".into(), Value::Array(mod_loaders));
|
||||
merge_unknown(&mut minecraft, &manifest.minecraft_extra);
|
||||
root.insert("minecraft".into(), Value::Object(minecraft));
|
||||
|
||||
let mut files = Vec::with_capacity(manifest.files.len());
|
||||
for file in &manifest.files {
|
||||
let (Some(project_id), Some(file_id)) = (file.project_id, file.file_id) else {
|
||||
return Err(PackError::unsupported(format!(
|
||||
"CurseForge 的 files[] 需要 projectID/fileID,这条没有(path={:?})",
|
||||
file.path
|
||||
))
|
||||
.with_hint("先用 CurseForge API 补全条目再导出,或改用 Modrinth 格式"));
|
||||
};
|
||||
let mut object = Map::new();
|
||||
object.insert("projectID".into(), json!(project_id));
|
||||
object.insert("fileID".into(), json!(file_id));
|
||||
// CF 只有一个 `required` 布尔:`optional` 与"禁用"都只能落到 `false`
|
||||
// (Prism 也是用 `required: false` 表达禁用的,见 M115 调研 §6)。
|
||||
object.insert(
|
||||
"required".into(),
|
||||
json!(!(file.client.is_optional() || file.disabled)),
|
||||
);
|
||||
merge_unknown(&mut object, &file.extra);
|
||||
files.push(Value::Object(object));
|
||||
}
|
||||
root.insert("files".into(), Value::Array(files));
|
||||
|
||||
merge_unknown(&mut root, &manifest.extra);
|
||||
serde_json::to_string_pretty(&Value::Object(root)).map_err(Into::into)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
//! 整合包失败分类:**可诊断**比"报了个错"重要。
|
||||
//!
|
||||
//! 每一类都对应导入链路上一个**不同的处置动作**(M111 据此分级):
|
||||
//!
|
||||
//! | 大类 | 稳定码 | 谁该负责 |
|
||||
//! |------|--------|----------|
|
||||
//! | [`PackErrorKind::Unsupported`] | `pack_unsupported` | 换格式 / 让用户手工处理(没格式认领、或模型表达不了该格式) |
|
||||
//! | [`PackErrorKind::Metadata`] | `pack_metadata` | 包本身坏了(JSON 形状不符、哈希缺失),重试无意义 |
|
||||
//! | [`PackErrorKind::Traversal`] | `pack_traversal` | **拒绝**,且不写任何文件(恶意包) |
|
||||
//! | [`PackErrorKind::Symlink`] | `pack_symlink` | **拒绝**(符号链接条目在 Windows 上无合法用途) |
|
||||
//! | [`PackErrorKind::Limits`] | `pack_limits` | **拒绝**(条数/体积超限,zip 炸弹防线) |
|
||||
//! | [`PackErrorKind::Io`] | `pack_io` | 环境问题,可重试 |
|
||||
//!
|
||||
//! 文案与日志分离:`message()` 给人看,`kind().as_str()` 进事件流与 `--json`。
|
||||
|
||||
use std::fmt;
|
||||
|
||||
/// 整合包失败的大类。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PackErrorKind {
|
||||
/// 没有格式认领这份包,或模型里的信息不足以写成该格式。
|
||||
Unsupported,
|
||||
/// 清单 JSON 坏了 / 形状不符 / 必需字段缺失。
|
||||
Metadata,
|
||||
/// 包内路径穿越(绝对路径、`..` 上跳、盘符、ADS、反斜杠分隔)。
|
||||
Traversal,
|
||||
/// zip 里有符号链接条目。
|
||||
Symlink,
|
||||
/// 条目数 / 解压体积超过限额。
|
||||
Limits,
|
||||
/// 本地 IO。
|
||||
Io,
|
||||
}
|
||||
|
||||
impl PackErrorKind {
|
||||
/// 稳定字符串 id(进 `--json` 的 `error.code` 与日志,**不要改名**)。
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Unsupported => "pack_unsupported",
|
||||
Self::Metadata => "pack_metadata",
|
||||
Self::Traversal => "pack_traversal",
|
||||
Self::Symlink => "pack_symlink",
|
||||
Self::Limits => "pack_limits",
|
||||
Self::Io => "pack_io",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 整合包错误:大类 + 人读说明 + 可选下一步提示。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PackError {
|
||||
kind: PackErrorKind,
|
||||
message: String,
|
||||
hint: Option<String>,
|
||||
}
|
||||
|
||||
impl PackError {
|
||||
/// 构造。
|
||||
#[must_use]
|
||||
pub fn new(kind: PackErrorKind, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
kind,
|
||||
message: message.into(),
|
||||
hint: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 没格式认领 / 模型表达不了该格式。
|
||||
#[must_use]
|
||||
pub fn unsupported(message: impl Into<String>) -> Self {
|
||||
Self::new(PackErrorKind::Unsupported, message)
|
||||
}
|
||||
|
||||
/// 清单形状不对 / 必填字段缺失。
|
||||
#[must_use]
|
||||
pub fn metadata(message: impl Into<String>) -> Self {
|
||||
Self::new(PackErrorKind::Metadata, message)
|
||||
}
|
||||
|
||||
/// 路径穿越。
|
||||
#[must_use]
|
||||
pub fn traversal(message: impl Into<String>) -> Self {
|
||||
Self::new(PackErrorKind::Traversal, message)
|
||||
}
|
||||
|
||||
/// 符号链接条目。
|
||||
#[must_use]
|
||||
pub fn symlink(message: impl Into<String>) -> Self {
|
||||
Self::new(PackErrorKind::Symlink, message)
|
||||
}
|
||||
|
||||
/// 超过限额。
|
||||
#[must_use]
|
||||
pub fn limits(message: impl Into<String>) -> Self {
|
||||
Self::new(PackErrorKind::Limits, message)
|
||||
}
|
||||
|
||||
/// 本地 IO。
|
||||
#[must_use]
|
||||
pub fn io(message: impl Into<String>) -> Self {
|
||||
Self::new(PackErrorKind::Io, message)
|
||||
}
|
||||
|
||||
/// 补一句提示。
|
||||
#[must_use]
|
||||
pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
|
||||
self.hint = Some(hint.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// 大类。
|
||||
#[must_use]
|
||||
pub const fn kind(&self) -> PackErrorKind {
|
||||
self.kind
|
||||
}
|
||||
|
||||
/// 人读说明。
|
||||
#[must_use]
|
||||
pub fn message(&self) -> &str {
|
||||
&self.message
|
||||
}
|
||||
|
||||
/// 下一步提示。
|
||||
#[must_use]
|
||||
pub fn hint(&self) -> Option<&str> {
|
||||
self.hint.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for PackError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str(&self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for PackError {}
|
||||
|
||||
impl From<std::io::Error> for PackError {
|
||||
fn from(error: std::io::Error) -> Self {
|
||||
Self::io(error.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<zip::result::ZipError> for PackError {
|
||||
fn from(error: zip::result::ZipError) -> Self {
|
||||
match error {
|
||||
zip::result::ZipError::FileNotFound => Self::io("包内条目不存在"),
|
||||
other => Self::metadata(format!("zip 读取失败:{other}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for PackError {
|
||||
fn from(error: serde_json::Error) -> Self {
|
||||
Self::metadata(format!("JSON 解析失败:{error}"))
|
||||
}
|
||||
}
|
||||
|
||||
/// `pack` 模块内的结果别名。
|
||||
pub type PackResult<T> = Result<T, PackError>;
|
||||
@@ -0,0 +1,290 @@
|
||||
//! 格式扩展点:[`PackFormat`] trait + 注册表 [`PackRegistry`] + **写死的探测优先级**。
|
||||
//!
|
||||
//! # 可扩展性的判据是测试,不是文档
|
||||
//!
|
||||
//! `tests/extend.rs` 里有一个只有几十行的假格式 `ToyFormat`(单文件、`toy.json` 当清单):
|
||||
//! 它**只加一行注册**(`PackRegistry::builtin().with(&TOY)`)就能被探测、读取、渲染、写回,
|
||||
//! 不改模型、不改 CurseForge / Modrinth / MultiMC 任何一行。这是"社区标准加一个格式"的
|
||||
//! 最小代价证明。
|
||||
//!
|
||||
//! # 探测优先级写死在这里,顺序由注册表 `Vec` 位置决定
|
||||
//!
|
||||
//! [`PackRegistry::builtin`] 的顺序:**CurseForge → Modrinth → MultiMC**。
|
||||
//!
|
||||
//! - 跟 HMCL 对这三种格式的相对次序一致(`ModpackHelper.readModpackManifest`:Curse 在
|
||||
//! Modrinth 前、Modrinth 在 MultiMC 前),减少与社区工具的行为差异;
|
||||
//! - 每个格式的 `detect` 都带**形状校验**(CF 要 `manifestType: minecraftModpack`、mrpack 要
|
||||
//! `formatVersion` + `files` 数组、MultiMC 要 `components` 非空),所以名字很泛的
|
||||
//! `manifest.json` 不会把 `.mrpack` 抢走;
|
||||
//! - 同一份包同时命中多个格式时,[`PackMatch::also_matched`] 会记下其余命中,调用方可以提示
|
||||
//! "这包不标准"——不做静默判定,也不做自动合并;
|
||||
//! - [`PackRegistry::register`] 追加到**末尾**(= 最低优先级),所以测试里改一行顺序就能
|
||||
//! 证明"优先级来自注册表顺序"(`tests/detect.rs` 有一条反序注册的用例)。
|
||||
//!
|
||||
//! # 写
|
||||
//!
|
||||
//! 默认实现([`PackFormat::write`])替所有格式做同一件事:写清单 entry([`PackFormat::render`]
|
||||
//! 的产物)+ 从 [`PackPayload::root`] 里搬覆盖目录内容。因此新格式只要实现
|
||||
//! `metadata_entry` + `render` 就同时获得导出能力;只读格式(MultiMC)不实现 `metadata_entry`,
|
||||
//! 写会得到明确的 `crate::addon::pack::error::PackErrorKind::Unsupported`。
|
||||
|
||||
use std::fmt::Debug;
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
|
||||
use super::archive::PackArchive;
|
||||
use super::curseforge;
|
||||
use super::error::{PackError, PackResult};
|
||||
use super::manifest::PackManifest;
|
||||
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>,
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/// 一种整合包格式(导入 + 导出的全部能力都在这里)。
|
||||
pub trait PackFormat: Debug + Send + Sync {
|
||||
/// 稳定字符串 id(进日志/事件/CLI 参数,**不要改名**)。
|
||||
fn id(&self) -> &'static str;
|
||||
|
||||
/// 人读名称。
|
||||
fn label(&self) -> &'static str;
|
||||
|
||||
/// 清单文件在包内的入口名(`Some` = 本格式可导出)。
|
||||
fn metadata_entry(&self) -> Option<&'static str> {
|
||||
None
|
||||
}
|
||||
|
||||
/// 这份输入是不是本格式;命中时返回**证据**(凭哪个条目认领的),未命中返回 `None`。
|
||||
///
|
||||
/// **必须带形状校验**,不能只看文件名存在:`manifest.json` 这种泛名到处都是。
|
||||
/// "选谁"不是格式自己的事——排序与歧义在 [`PackRegistry::detect`] 那一层做
|
||||
/// (返回 [`PackMatch`],带 `also_matched` 与本次命中的证据)。
|
||||
fn detect(&self, pack: &PackArchive) -> Option<&'static str>;
|
||||
|
||||
/// 读成中间模型。
|
||||
fn read(&self, pack: &PackArchive) -> PackResult<PackManifest>;
|
||||
|
||||
/// 把模型渲染成清单 JSON(未知字段在这里回写)。
|
||||
fn render(&self, manifest: &PackManifest) -> PackResult<String>;
|
||||
|
||||
/// 导出一个整包(清单 + 覆盖内容)。
|
||||
///
|
||||
/// 默认实现对"只读格式"(`metadata_entry` 为 `None`)直接报不支持。
|
||||
fn write(
|
||||
&self,
|
||||
manifest: &PackManifest,
|
||||
payload: PackPayload<'_>,
|
||||
target: &Path,
|
||||
) -> PackResult<()> {
|
||||
let Some(entry) = self.metadata_entry() else {
|
||||
return Err(PackError::unsupported(format!(
|
||||
"格式 {}({})只支持读取,不支持导出",
|
||||
self.id(),
|
||||
self.label()
|
||||
))
|
||||
.with_hint("导出请选 Modrinth(.mrpack)或 CurseForge 格式"));
|
||||
};
|
||||
// 模型可能来自外部构造:搬内容前把每条路径重查一遍。
|
||||
manifest.check_paths()?;
|
||||
let json = self.render(manifest)?;
|
||||
let file = File::create(target)
|
||||
.map_err(|error| PackError::io(format!("创建 {} 失败:{error}", target.display())))?;
|
||||
let mut writer = zip::ZipWriter::new(file);
|
||||
let options = zip::write::SimpleFileOptions::default()
|
||||
.compression_method(zip::CompressionMethod::Deflated)
|
||||
.unix_permissions(0o644);
|
||||
writer
|
||||
.start_file(entry, options)
|
||||
.map_err(|error| PackError::io(format!("写 {entry} 失败:{error}")))?;
|
||||
writer
|
||||
.write_all(json.as_bytes())
|
||||
.map_err(|error| PackError::io(format!("写 {entry} 失败:{error}")))?;
|
||||
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 = root.join(&dir).join(&path);
|
||||
let bytes = std::fs::read(&source).map_err(|error| {
|
||||
PackError::io(format!("读覆盖文件 {} 失败:{error}", source.display()))
|
||||
})?;
|
||||
writer
|
||||
.start_file(name.clone(), options)
|
||||
.map_err(|error| PackError::io(format!("写 {name} 失败:{error}")))?;
|
||||
writer
|
||||
.write_all(&bytes)
|
||||
.map_err(|error| PackError::io(format!("写 {name} 失败:{error}")))?;
|
||||
}
|
||||
}
|
||||
writer
|
||||
.finish()
|
||||
.map_err(|error| PackError::io(format!("收尾 {} 失败:{error}", target.display())))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次探测的结果。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PackMatch {
|
||||
format: &'static dyn PackFormat,
|
||||
evidence: &'static str,
|
||||
also_matched: Vec<&'static str>,
|
||||
}
|
||||
|
||||
impl PackMatch {
|
||||
/// 选中的格式。
|
||||
#[must_use]
|
||||
pub fn format(&self) -> &'static dyn PackFormat {
|
||||
self.format
|
||||
}
|
||||
|
||||
/// 命中的证据条目名(探测器凭哪个文件认领的,进日志用)。
|
||||
#[must_use]
|
||||
pub fn evidence(&self) -> &'static str {
|
||||
self.evidence
|
||||
}
|
||||
|
||||
/// 同样命中的其它格式 id(探测顺序里排在后面的那些)。
|
||||
#[must_use]
|
||||
pub fn also_matched(&self) -> &[&'static str] {
|
||||
&self.also_matched
|
||||
}
|
||||
|
||||
/// 是否有"多格式同时命中"的歧义(不标准包)。
|
||||
#[must_use]
|
||||
pub fn is_ambiguous(&self) -> bool {
|
||||
!self.also_matched.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// 格式注册表:**顺序即优先级**。
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct PackRegistry {
|
||||
formats: Vec<&'static dyn PackFormat>,
|
||||
}
|
||||
|
||||
impl PackRegistry {
|
||||
/// 一个都没有的注册表(测试"没有格式认领"用)。
|
||||
#[must_use]
|
||||
pub fn empty() -> Self {
|
||||
Self {
|
||||
formats: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 内置三格式,顺序即优先级:CurseForge → Modrinth → MultiMC。
|
||||
#[must_use]
|
||||
pub fn builtin() -> Self {
|
||||
Self {
|
||||
formats: vec![
|
||||
&curseforge::CURSEFORGE,
|
||||
&modrinth::MODRINTH,
|
||||
&multimc::MULTIMC,
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/// 追加一种格式(追加 = 最低优先级)。加一种格式真的只要这一行。
|
||||
pub fn register(&mut self, format: &'static dyn PackFormat) {
|
||||
self.formats.push(format);
|
||||
}
|
||||
|
||||
/// 链式注册。
|
||||
#[must_use]
|
||||
pub fn with(mut self, format: &'static dyn PackFormat) -> Self {
|
||||
self.register(format);
|
||||
self
|
||||
}
|
||||
|
||||
/// 按优先级列出格式 id。
|
||||
#[must_use]
|
||||
pub fn ids(&self) -> Vec<&'static str> {
|
||||
self.formats.iter().map(|format| format.id()).collect()
|
||||
}
|
||||
|
||||
/// 按 id 找格式。
|
||||
#[must_use]
|
||||
pub fn get(&self, id: &str) -> Option<&'static dyn PackFormat> {
|
||||
self.formats
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|format| format.id() == id)
|
||||
}
|
||||
|
||||
/// 全部命中的格式 id(诊断"这包不标准"用)。
|
||||
#[must_use]
|
||||
pub fn detect_all(&self, pack: &PackArchive) -> Vec<&'static str> {
|
||||
self.formats
|
||||
.iter()
|
||||
.filter(|format| format.detect(pack).is_some())
|
||||
.map(|format| format.id())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 按优先级选第一个命中的格式。
|
||||
pub fn detect(&self, pack: &PackArchive) -> PackResult<PackMatch> {
|
||||
let mut matched: Vec<(&'static dyn PackFormat, &'static str)> = self
|
||||
.formats
|
||||
.iter()
|
||||
.copied()
|
||||
.filter_map(|format| format.detect(pack).map(|evidence| (format, evidence)))
|
||||
.collect();
|
||||
if matched.is_empty() {
|
||||
let tried = self.ids().join(" / ");
|
||||
return Err(
|
||||
PackError::unsupported(format!("没有格式认领这份整合包(试过:{tried})"))
|
||||
.with_hint(
|
||||
"确认它是 CurseForge manifest.json、Modrinth .mrpack 或 MultiMC 实例",
|
||||
),
|
||||
);
|
||||
}
|
||||
let (format, evidence) = matched.remove(0);
|
||||
let also_matched = matched.iter().map(|(other, _)| other.id()).collect();
|
||||
Ok(PackMatch {
|
||||
format,
|
||||
evidence,
|
||||
also_matched,
|
||||
})
|
||||
}
|
||||
|
||||
/// 探测 + 读成模型(导入管线的第一步)。
|
||||
pub fn read(&self, pack: &PackArchive) -> PackResult<(&'static dyn PackFormat, PackManifest)> {
|
||||
let matched = self.detect(pack)?;
|
||||
let manifest = matched.format().read(pack)?;
|
||||
Ok((matched.format(), manifest))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
//! 与格式无关的中间模型 [`PackManifest`]。
|
||||
//!
|
||||
//! # 为什么要中间模型
|
||||
//!
|
||||
//! CF 的 `manifest.json` 与 Modrinth 的 `modrinth.index.json` 表达同一件事的方式不同:
|
||||
//!
|
||||
//! | 概念 | CurseForge | Modrinth `.mrpack` | 模型字段 |
|
||||
//! |------|-----------|--------------------|----------|
|
||||
//! | 包名 | `name` | `name` | [`PackManifest::name`] |
|
||||
//! | 包版本 | `version` | `versionId` | [`PackManifest::version`] |
|
||||
//! | 作者 | `author` | **没有** | [`PackManifest::author`] |
|
||||
//! | 简介 | **没有** | `summary` | [`PackManifest::summary`] |
|
||||
//! | 游戏版本 | `minecraft.version` | `dependencies.minecraft` | [`PackManifest::minecraft`] |
|
||||
//! | 加载器 | `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`] |
|
||||
//! | 多 MC 实例(MultiMC) | — | — | 同上(`.minecraft` 当覆盖目录) |
|
||||
//!
|
||||
//! 方向不同造成的缺口**不靠"假装字段都在"抹平**,而是显式记录:
|
||||
//!
|
||||
//! - CF 的 `files[]` 没有路径 → [`PackFile::path`] 是 `Option`,`None` 表示"要靠远端 API 补全"
|
||||
//! (D10 §2 的两阶段补全就是补这个);写 mrpack 时缺路径即
|
||||
//! `crate::addon::pack::error::PackErrorKind::Unsupported`。
|
||||
//! - CF 只有一个 `required` 布尔 → 读成 `client`/`server` 同值,写回时 `client == Optional` 即
|
||||
//! `required = false`;`client`/`server` 不同的条目不适用于 CF 导出(文档写明,不静默降级)。
|
||||
//!
|
||||
//! # 未知字段不丢("社区标准加字段不炸"的具体手段)
|
||||
//!
|
||||
//! 三处 `extra` 用 `serde(flatten)` 接住不认识的 JSON 字段,**原样 Value 保存**,
|
||||
//! 各格式的 `render` 再把它们并回去(已知字段优先,冲突时丢弃 extra 里的同名键):
|
||||
//!
|
||||
//! - [`PackManifest::extra`]:清单顶层;
|
||||
//! - [`PackManifest::minecraft_extra`]:CF `minecraft` 对象内部(mrpack 没有这一层);
|
||||
//! - [`PackFile::extra`] / [`PackFile::env_extra`] / [`PackLoader::extra`]:条目、`env`、加载器条目内部。
|
||||
//!
|
||||
//! 哈希不按"sha1/sha512 两个字段"建模,而是 [`PackFile::hashes`] 整张表:mrpack 允许
|
||||
//! `sha1`/`sha256`/`sha512`/`md5` 同时出现,只留两个字段就会丢信息。
|
||||
|
||||
mod entries;
|
||||
|
||||
pub use entries::{merge_unknown, OverrideFile, PackFile, PackOverrides};
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use super::path::normalize_file;
|
||||
use crate::addon::LoaderKind;
|
||||
|
||||
/// 客户端 / 服务端环境标记(mrpack `env` 的三值,CF 只有 `required` 布尔)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum EnvSupport {
|
||||
/// 必需(mrpack `required` / CF `required: true`)。
|
||||
#[default]
|
||||
Required,
|
||||
/// 可选(mrpack `optional` / CF `required: false`;导出侧 `.disabled` → 这个)。
|
||||
Optional,
|
||||
/// 本侧不支持(mrpack `unsupported`;CF 表达不了)。
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
impl EnvSupport {
|
||||
/// 解析 mrpack 的字符串形式;不认识的返回 `None`(调用方决定报错还是忽略)。
|
||||
#[must_use]
|
||||
pub fn parse(raw: &str) -> Option<Self> {
|
||||
match raw.trim().to_ascii_lowercase().as_str() {
|
||||
"required" => Some(Self::Required),
|
||||
"optional" => Some(Self::Optional),
|
||||
"unsupported" => Some(Self::Unsupported),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// mrpack 的字符串形式。
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Required => "required",
|
||||
Self::Optional => "optional",
|
||||
Self::Unsupported => "unsupported",
|
||||
}
|
||||
}
|
||||
|
||||
/// 是否"可以不要"。
|
||||
#[must_use]
|
||||
pub const fn is_optional(self) -> bool {
|
||||
matches!(self, Self::Optional)
|
||||
}
|
||||
}
|
||||
|
||||
/// 覆盖目录的目标侧(mrpack 用三个目录区分,CF 只有一个)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
|
||||
pub enum PackSide {
|
||||
/// 客户端与服务端都要(`overrides` / `.minecraft`)。
|
||||
#[default]
|
||||
Both,
|
||||
/// 只要客户端(`client-overrides`)。
|
||||
Client,
|
||||
/// 只要服务端(`server-overrides`)。
|
||||
Server,
|
||||
}
|
||||
|
||||
impl PackSide {
|
||||
/// 人读名称(进日志/报告)。
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Both => "both",
|
||||
Self::Client => "client",
|
||||
Self::Server => "server",
|
||||
}
|
||||
}
|
||||
|
||||
/// mrpack 里这一侧的标准目录名。
|
||||
#[must_use]
|
||||
pub const fn mrpack_dir(self) -> &'static str {
|
||||
match self {
|
||||
Self::Both => "overrides",
|
||||
Self::Client => "client-overrides",
|
||||
Self::Server => "server-overrides",
|
||||
}
|
||||
}
|
||||
|
||||
/// 该侧的覆盖内容对客户端是否生效。
|
||||
#[must_use]
|
||||
pub const fn applies_to_client(self) -> bool {
|
||||
matches!(self, Self::Both | Self::Client)
|
||||
}
|
||||
|
||||
/// 该侧的覆盖内容对服务端是否生效。
|
||||
#[must_use]
|
||||
pub const fn applies_to_server(self) -> bool {
|
||||
matches!(self, Self::Both | Self::Server)
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个加载器依赖。
|
||||
///
|
||||
/// `raw` 保留原始标识,读写两侧因此无损:
|
||||
/// - CF:`raw = "forge-47.2.0"`,`kind = Forge`,`version = "47.2.0"`;
|
||||
/// - mrpack:`raw = "fabric-loader"`,`kind = Fabric`,`version = "0.15.11"`;
|
||||
/// - MultiMC:`raw = "net.fabricmc.fabric-loader"`,`kind = Fabric`,`version = "0.15.11"`。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct PackLoader {
|
||||
/// 原始标识(按格式原样保存)。
|
||||
pub raw: String,
|
||||
/// 规范化加载器种类;不认识的加载器是 `None`(仍然保留 `raw` 与 `version`)。
|
||||
pub kind: Option<LoaderKind>,
|
||||
/// 加载器版本。
|
||||
pub version: Option<String>,
|
||||
/// 是否主加载器(CF `primary`;其它格式取"第一条能认出来的")。
|
||||
pub primary: bool,
|
||||
/// 该加载器条目的未知字段。
|
||||
pub extra: Map<String, Value>,
|
||||
}
|
||||
|
||||
impl PackLoader {
|
||||
/// 从 CF 的 `modLoaders[].id`(`forge-47.2.0`)解析。
|
||||
#[must_use]
|
||||
pub fn parse_curse(id: &str, primary: bool) -> Self {
|
||||
let (kind, version) = split_curse_id(id);
|
||||
Self {
|
||||
raw: id.to_string(),
|
||||
kind,
|
||||
version,
|
||||
primary,
|
||||
extra: Map::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 mrpack `dependencies` 的一条键值解析(`fabric-loader` → `0.15.11`)。
|
||||
#[must_use]
|
||||
pub fn parse_dependency(key: &str, version: &str, primary: bool) -> Self {
|
||||
Self {
|
||||
raw: key.to_string(),
|
||||
kind: parse_kind_key(key),
|
||||
version: Some(version.to_string()),
|
||||
primary,
|
||||
extra: Map::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 MultiMC 组件 `uid` + `version` 解析(`net.fabricmc.fabric-loader`)。
|
||||
#[must_use]
|
||||
pub fn parse_multimc(uid: &str, version: &str, primary: bool) -> Self {
|
||||
let kind = match uid {
|
||||
"net.fabricmc.fabric-loader" => Some(LoaderKind::Fabric),
|
||||
"org.quiltmc.quilt-loader" => Some(LoaderKind::Quilt),
|
||||
"net.minecraftforge" => Some(LoaderKind::Forge),
|
||||
"net.neoforged" => Some(LoaderKind::NeoForge),
|
||||
_ => None,
|
||||
};
|
||||
Self {
|
||||
raw: uid.to_string(),
|
||||
kind,
|
||||
version: Some(version.to_string()),
|
||||
primary,
|
||||
extra: Map::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 写成 CF 的 `id`。
|
||||
///
|
||||
/// `raw` 非空时**逐字回写**(CF 的 id 是不透明字符串,不认识的加载器也得原样带过去);
|
||||
/// 只有从别的格式转过来的 `raw` 为空的情况才用 `kind`+`version` 拼。
|
||||
#[must_use]
|
||||
pub fn curse_id(&self) -> Option<String> {
|
||||
if !self.raw.is_empty() {
|
||||
return Some(self.raw.clone());
|
||||
}
|
||||
let kind = self.kind?;
|
||||
let version = self.version.as_deref()?;
|
||||
Some(format!("{}-{version}", kind.as_str()))
|
||||
}
|
||||
|
||||
/// 写成 mrpack `dependencies` 的键。
|
||||
///
|
||||
/// 认得种类时用 Modrinth 的规范拼法(`fabric-loader` / `quilt-loader` / `forge` /
|
||||
/// `neoforge`)——这是 Modrinth 自己的键名约定,CF 的 `forge-47.2.0` 与 MultiMC 的
|
||||
/// `net.fabricmc.fabric-loader` 都靠它翻译过去。种类未知时(社区新加载器)逐字回写 `raw`。
|
||||
#[must_use]
|
||||
pub fn dependency_key(&self) -> String {
|
||||
match self.kind {
|
||||
Some(LoaderKind::Fabric) => "fabric-loader".to_string(),
|
||||
Some(LoaderKind::Quilt) => "quilt-loader".to_string(),
|
||||
Some(kind) => kind.as_str().to_string(),
|
||||
None => self.raw.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// CF 的 `modLoaders[].id` 里认得上哪些前缀。
|
||||
fn split_curse_id(id: &str) -> (Option<LoaderKind>, Option<String>) {
|
||||
for kind in LoaderKind::all() {
|
||||
let prefix = format!("{}-", kind.as_str());
|
||||
if let Some(rest) = id.strip_prefix(&prefix) {
|
||||
if !rest.is_empty() {
|
||||
return (Some(*kind), Some(rest.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
(None, None)
|
||||
}
|
||||
|
||||
/// mrpack `dependencies` 的键 → 加载器种类(`fabric-loader` / `neo-forge` 两种拼法都认)。
|
||||
fn parse_kind_key(key: &str) -> Option<LoaderKind> {
|
||||
let lowered = key.trim().to_ascii_lowercase();
|
||||
let bare = lowered.strip_suffix("-loader").unwrap_or(&lowered);
|
||||
LoaderKind::parse(bare)
|
||||
}
|
||||
|
||||
/// 与格式无关的整合包模型。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct PackManifest {
|
||||
/// 包名(CF `name` / mrpack `name`)。
|
||||
pub name: String,
|
||||
/// 包版本(CF `version` / mrpack `versionId`)。
|
||||
pub version: Option<String>,
|
||||
/// 作者(CF `author`;mrpack 没有这一项)。
|
||||
pub author: Option<String>,
|
||||
/// 简介(mrpack `summary`;CF 没有这一项)。
|
||||
pub summary: Option<String>,
|
||||
/// 清单 schema 版本(CF `manifestVersion` / mrpack `formatVersion` / MultiMC `formatVersion`)。
|
||||
pub format_version: Option<i64>,
|
||||
/// 平台标识(mrpack `game`,恒为 `minecraft`;CF 隐式,`None`)。
|
||||
pub game: Option<String>,
|
||||
/// 游戏版本(`1.20.1`)。
|
||||
pub minecraft: Option<String>,
|
||||
/// 加载器依赖集合。
|
||||
pub loaders: Vec<PackLoader>,
|
||||
/// 远端文件条目(不含覆盖目录里的文件)。
|
||||
pub files: Vec<PackFile>,
|
||||
/// 覆盖目录清单。
|
||||
pub overrides: Vec<PackOverrides>,
|
||||
/// 清单顶层的未知字段(原样保存、导出时回写)。
|
||||
pub extra: Map<String, Value>,
|
||||
/// CF `minecraft` 对象里的未知字段(mrpack/MultiMC 没有这一层)。
|
||||
pub minecraft_extra: Map<String, Value>,
|
||||
}
|
||||
|
||||
impl PackManifest {
|
||||
/// 建一个只有包名的空清单。
|
||||
#[must_use]
|
||||
pub fn new(name: impl Into<String>) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// 按目标侧找覆盖目录。
|
||||
#[must_use]
|
||||
pub fn overrides_for(&self, side: PackSide) -> Option<&PackOverrides> {
|
||||
self.overrides.iter().find(|group| group.side == side)
|
||||
}
|
||||
|
||||
/// 覆盖目录里的全部文件,按 `(side, path)` 展平(三方合并要按实例路径查)。
|
||||
pub fn override_files(&self) -> impl Iterator<Item = (PackSide, &OverrideFile)> {
|
||||
self.overrides
|
||||
.iter()
|
||||
.flat_map(|group| group.files.iter().map(move |file| (group.side, file)))
|
||||
}
|
||||
|
||||
/// 某个加载器的依赖(有就返回第一条)。
|
||||
#[must_use]
|
||||
pub fn loader(&self, kind: LoaderKind) -> Option<&PackLoader> {
|
||||
self.loaders.iter().find(|loader| loader.kind == Some(kind))
|
||||
}
|
||||
|
||||
/// 有远端来源的条目(M111 的补全目标)。
|
||||
pub fn remote_files(&self) -> impl Iterator<Item = &PackFile> {
|
||||
self.files.iter().filter(|file| file.is_remote_reachable())
|
||||
}
|
||||
|
||||
/// 校验模型里所有路径安全(导出前必过:模型可能来自外部构造)。
|
||||
///
|
||||
/// 覆盖目录前缀与文件路径都要过 [`normalize_file`] / [`normalize_dir`] 的同一条规则。
|
||||
pub fn check_paths(&self) -> super::error::PackResult<()> {
|
||||
use super::path::normalize_dir;
|
||||
|
||||
for group in &self.overrides {
|
||||
normalize_dir(&group.dir)?;
|
||||
for file in &group.files {
|
||||
normalize_file(&file.path)?;
|
||||
}
|
||||
}
|
||||
for file in &self.files {
|
||||
if let Some(path) = &file.path {
|
||||
normalize_file(path)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 覆盖目录里重复的实例路径(两份 overrides 撞同一文件时的提示,导入器据此决策)。
|
||||
#[must_use]
|
||||
pub fn duplicate_override_paths(&self) -> Vec<String> {
|
||||
let mut seen: HashMap<(PackSide, &str), usize> = HashMap::new();
|
||||
let mut duplicated = Vec::new();
|
||||
for (side, file) in self.override_files() {
|
||||
let counter = seen.entry((side, file.path.as_str())).or_insert(0);
|
||||
*counter += 1;
|
||||
if *counter == 2 {
|
||||
duplicated.push(file.path.clone());
|
||||
}
|
||||
}
|
||||
duplicated
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
//! 条目级模型:远端文件、覆盖目录、以及"未知字段并回"的唯一实现。
|
||||
//!
|
||||
//! 拆在这里是为了守 400 行上限;对外仍是 [`crate::addon::pack::manifest`] 的一部分
|
||||
//! (`manifest` 用 `pub use` 重新导出)。
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use super::{EnvSupport, PackSide};
|
||||
|
||||
/// 一个远端文件条目。
|
||||
///
|
||||
/// CF 只填 `project_id`/`file_id`/`client`/`server`;mrpack 只填 `path`/`hashes`/`downloads`/
|
||||
/// `size`/`client`/`server`。两者可以同时存在(例如 M111 补全 CF 条目之后)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct PackFile {
|
||||
/// 实例内相对路径(`mods/jei.jar`)。CF 官方清单没有这一项 → `None`。
|
||||
pub path: Option<String>,
|
||||
/// CF `projectID`。
|
||||
pub project_id: Option<i64>,
|
||||
/// CF `fileID`。
|
||||
pub file_id: Option<i64>,
|
||||
/// 摘要表(键名小写:`sha1` / `sha512` / `sha256` / `md5`)。mrpack 要求有 `sha512`。
|
||||
pub hashes: BTreeMap<String, String>,
|
||||
/// 下载候选(mrpack `downloads`,多源;CF 官方清单没有,要靠 CDN 规则或 API 拼)。
|
||||
pub downloads: Vec<String>,
|
||||
/// 文件字节数(mrpack `fileSize`)。
|
||||
pub size: Option<u64>,
|
||||
/// 是否"以禁用态安装"(`.disabled`)。
|
||||
///
|
||||
/// 社区里 `.disabled` 有两种表达方式(M115 调研 §字段缺口 2,A 级来源 Modrinth 官方
|
||||
/// `export_mrpack.rs` + B 级 HMCL/D10 约定):
|
||||
/// - 官方 mrpack 导出器:路径本身带 `.disabled` 后缀;
|
||||
/// - HMCL/D10:去掉后缀,用 `env.client = optional` 表达。
|
||||
///
|
||||
/// 模型只记**语义**(这条要被禁用),两种拼法留给导出策略(M112)决定;
|
||||
/// 读包时不会因为 `env.client=optional` 就把它置 `true`——"可选"与"禁用"是两个事实。
|
||||
pub disabled: bool,
|
||||
/// 客户端支持度。
|
||||
pub client: EnvSupport,
|
||||
/// 服务端支持度。
|
||||
pub server: EnvSupport,
|
||||
/// 该条目的未知字段。
|
||||
pub extra: Map<String, Value>,
|
||||
/// `env` 对象里的未知字段。
|
||||
pub env_extra: Map<String, Value>,
|
||||
}
|
||||
|
||||
impl PackFile {
|
||||
/// 只带路径的条目(mrpack 侧最简形态)。
|
||||
#[must_use]
|
||||
pub fn with_path(path: impl Into<String>) -> Self {
|
||||
Self {
|
||||
path: Some(path.into()),
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// 只带 CF 标识的条目。
|
||||
#[must_use]
|
||||
pub fn with_curse(project_id: i64, file_id: i64) -> Self {
|
||||
Self {
|
||||
project_id: Some(project_id),
|
||||
file_id: Some(file_id),
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// SHA-1(有就返回)。
|
||||
#[must_use]
|
||||
pub fn sha1(&self) -> Option<&str> {
|
||||
self.hashes.get("sha1").map(String::as_str)
|
||||
}
|
||||
|
||||
/// SHA-512(有就返回)。
|
||||
#[must_use]
|
||||
pub fn sha512(&self) -> Option<&str> {
|
||||
self.hashes.get("sha512").map(String::as_str)
|
||||
}
|
||||
|
||||
/// 稳定标识:用于"用户取消勾选某文件"的排除集合,跨版本比对要稳。
|
||||
///
|
||||
/// 与 HMCL 同构:`curseforge:<projectID>:<fileID>` / `modrinth:<sha512>:<path>`,
|
||||
/// 两者都没有时退回路径。
|
||||
#[must_use]
|
||||
pub fn key(&self) -> String {
|
||||
if let (Some(project), Some(file)) = (self.project_id, self.file_id) {
|
||||
return format!("curseforge:{project}:{file}");
|
||||
}
|
||||
if let (Some(sha512), Some(path)) = (self.sha512(), self.path.as_deref()) {
|
||||
return format!("modrinth:{sha512}:{path}");
|
||||
}
|
||||
self.path.clone().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// 这条目是否有远端下载来源(决定导入时能否自己补齐)。
|
||||
#[must_use]
|
||||
pub fn is_remote_reachable(&self) -> bool {
|
||||
if self.downloads.iter().any(|url| !url.trim().is_empty()) {
|
||||
return true;
|
||||
}
|
||||
self.project_id.is_some() && self.file_id.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// 覆盖目录里的一个文件(三方合并的 `base` 侧:上次整合包给的哈希)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct OverrideFile {
|
||||
/// 相对该覆盖目录的路径(等价于实例内相对路径,例如 `mods/jei.jar`)。
|
||||
pub path: String,
|
||||
/// 内容 SHA-1(读包时现算)。
|
||||
pub sha1: String,
|
||||
/// 字节数。
|
||||
pub size: u64,
|
||||
}
|
||||
|
||||
/// 一个覆盖目录(`overrides` / `client-overrides` / `server-overrides` / MultiMC 的 `.minecraft`)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PackOverrides {
|
||||
/// 包内目录前缀(`/` 分隔,无尾斜杠)。
|
||||
pub dir: String,
|
||||
/// 目标侧。
|
||||
pub side: PackSide,
|
||||
/// 目录内的文件清单(按路径排序,保证同一份包两次读出的模型相等)。
|
||||
pub files: Vec<OverrideFile>,
|
||||
}
|
||||
|
||||
impl PackOverrides {
|
||||
/// 建一个空目录清单。
|
||||
#[must_use]
|
||||
pub fn new(dir: impl Into<String>, side: PackSide) -> Self {
|
||||
Self {
|
||||
dir: dir.into(),
|
||||
side,
|
||||
files: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 按实例内相对路径查一条。
|
||||
#[must_use]
|
||||
pub fn find(&self, path: &str) -> Option<&OverrideFile> {
|
||||
self.files.iter().find(|file| file.path == path)
|
||||
}
|
||||
}
|
||||
|
||||
/// 把未知字段并回 JSON 对象:**已知字段优先**,extra 里的同名键丢弃(不覆盖模型的判断)。
|
||||
pub fn merge_unknown(target: &mut Map<String, Value>, extra: &Map<String, Value>) {
|
||||
for (key, value) in extra {
|
||||
if !target.contains_key(key) {
|
||||
target.insert(key.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
//! Modrinth 格式:`modrinth.index.json` + `overrides/` `client-overrides/` `server-overrides/`。
|
||||
//!
|
||||
//! # 形状(A 级来源:HMCL `ModrinthManifest.java`,见 analyse/05 §2.3)
|
||||
//!
|
||||
//! ```jsonc
|
||||
//! {
|
||||
//! "game": "minecraft", "formatVersion": 1, "versionId": "1.0.0",
|
||||
//! "name": "…", "summary": "…",
|
||||
//! "files": [{
|
||||
//! "path": "mods/jei.jar",
|
||||
//! "hashes": {"sha1": "…", "sha512": "…"},
|
||||
//! "env": {"client": "required", "server": "unsupported"},
|
||||
//! "downloads": ["https://cdn.modrinth.com/data/…/jei.jar"],
|
||||
//! "fileSize": 123456
|
||||
//! }],
|
||||
//! "dependencies": {"minecraft": "1.20.1", "fabric-loader": "0.15.11"}
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! 比 CF 强的地方(也是模型主路径为什么按 mrpack 设计):**有路径、有多源 URL、有摘要表、
|
||||
//! 有客户端/服务端分侧语义**。这三样正是「能否被远程替代」判据(D10 §2 导出侧)需要的输入。
|
||||
//!
|
||||
//! # 读取时坚持的三条校验(与 HMCL `File.validate()` 同源)
|
||||
//!
|
||||
//! 1. `path` 必须是不越界的相对路径([`normalize_file`] 七条规则,第二检在 `archive`);
|
||||
//! 2. `hashes.sha512` 必须存在且是 128 位十六进制——没有它,"远程替代"无从判断;
|
||||
//! 3. `env` 出现时 `client` 必须存在(`env.server` 缺省按 `required`)。
|
||||
//!
|
||||
//! 另有两条事实(M115 调研 §字段缺口 1/4):`fileSize` 在规范里是**必填**(模型里是
|
||||
//! `Option`,未知就不写,由 M112 补全),`server-overrides` 客户端**不解压**(模型照记,
|
||||
//! 落地判断留给 M111)。
|
||||
//!
|
||||
//! 这三条是**读**的硬门槛;写(导出)另有两条:条目缺 `path` 或缺 `downloads` 一律报
|
||||
//! `PackErrorKind::Unsupported`(CF 的裸 `projectID/fileID` 条目转不成 mrpack,
|
||||
//! 必须先补全——这就是 M111/M112 的分工边界)。
|
||||
//!
|
||||
//! `dependencies` 里的 `neoforge` 与 `neo-forge` 两种拼法都认(HMCL PR #5170 的坑),
|
||||
//! 认不出的键(社区新加载器如 `parchment`)**原样保留**成一条 [`PackLoader`]。
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
use super::archive::PackArchive;
|
||||
use super::error::{PackError, PackResult};
|
||||
use super::format::PackFormat;
|
||||
use super::manifest::{
|
||||
merge_unknown, EnvSupport, PackFile, PackLoader, PackManifest, PackOverrides, PackSide,
|
||||
};
|
||||
use super::path::normalize_file;
|
||||
|
||||
/// 格式 id。
|
||||
pub const ID: &str = "modrinth";
|
||||
/// 清单入口名。
|
||||
pub const METADATA_ENTRY: &str = "modrinth.index.json";
|
||||
/// `game` 的固定值。
|
||||
pub const GAME: &str = "minecraft";
|
||||
/// 依赖表里代表游戏版本的那个键。
|
||||
pub const MINECRAFT_KEY: &str = "minecraft";
|
||||
|
||||
/// 单例(进注册表用)。
|
||||
pub static MODRINTH: Modrinth = Modrinth;
|
||||
|
||||
/// Modrinth 格式的值类型实现。
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct Modrinth;
|
||||
|
||||
/// `modrinth.index.json` 的原始形状(未知字段落 `extra`)。
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct RawIndex {
|
||||
#[serde(default)]
|
||||
game: Option<String>,
|
||||
#[serde(rename = "formatVersion", default)]
|
||||
format_version: Option<i64>,
|
||||
#[serde(rename = "versionId", default)]
|
||||
version_id: Option<String>,
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
#[serde(default)]
|
||||
summary: Option<String>,
|
||||
#[serde(default)]
|
||||
files: Vec<RawFile>,
|
||||
#[serde(default)]
|
||||
dependencies: BTreeMap<String, String>,
|
||||
#[serde(flatten)]
|
||||
extra: Map<String, Value>,
|
||||
}
|
||||
|
||||
/// `files[]` 的一条。
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct RawFile {
|
||||
#[serde(default)]
|
||||
path: Option<String>,
|
||||
#[serde(default)]
|
||||
hashes: BTreeMap<String, String>,
|
||||
#[serde(default)]
|
||||
env: Option<RawEnv>,
|
||||
#[serde(default)]
|
||||
downloads: Vec<String>,
|
||||
#[serde(rename = "fileSize", default)]
|
||||
file_size: Option<u64>,
|
||||
#[serde(flatten)]
|
||||
extra: Map<String, Value>,
|
||||
}
|
||||
|
||||
/// `files[].env`。
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct RawEnv {
|
||||
#[serde(default)]
|
||||
client: Option<String>,
|
||||
#[serde(default)]
|
||||
server: Option<String>,
|
||||
#[serde(flatten)]
|
||||
extra: Map<String, Value>,
|
||||
}
|
||||
|
||||
impl PackFormat for Modrinth {
|
||||
fn id(&self) -> &'static str {
|
||||
ID
|
||||
}
|
||||
|
||||
fn label(&self) -> &'static str {
|
||||
"Modrinth"
|
||||
}
|
||||
|
||||
fn metadata_entry(&self) -> Option<&'static str> {
|
||||
Some(METADATA_ENTRY)
|
||||
}
|
||||
|
||||
fn detect(&self, pack: &PackArchive) -> Option<&'static str> {
|
||||
let value = pack.read_json(METADATA_ENTRY).ok().flatten()?;
|
||||
let object = value.as_object()?;
|
||||
// 有 formatVersion 数字即可认领;没有则要求 game + files 的形状对得上。
|
||||
let matched = object.get("formatVersion").is_some_and(Value::is_number)
|
||||
|| (object.get("game").and_then(Value::as_str) == Some(GAME)
|
||||
&& object.get("files").is_some_and(Value::is_array));
|
||||
matched.then_some(METADATA_ENTRY)
|
||||
}
|
||||
|
||||
fn read(&self, pack: &PackArchive) -> PackResult<PackManifest> {
|
||||
let value = pack
|
||||
.read_json(METADATA_ENTRY)?
|
||||
.ok_or_else(|| PackError::metadata(format!("包里没有 {METADATA_ENTRY}")))?;
|
||||
let raw: RawIndex = serde_json::from_value(value)
|
||||
.map_err(|error| PackError::metadata(format!("{METADATA_ENTRY} 形状不符:{error}")))?;
|
||||
let name = raw
|
||||
.name
|
||||
.filter(|name| !name.trim().is_empty())
|
||||
.ok_or_else(|| PackError::metadata(format!("{METADATA_ENTRY} 缺 name")))?;
|
||||
|
||||
let mut minecraft = None;
|
||||
let mut loaders: Vec<PackLoader> = Vec::new();
|
||||
for (key, version) in &raw.dependencies {
|
||||
if key == MINECRAFT_KEY {
|
||||
minecraft = Some(version.clone());
|
||||
continue;
|
||||
}
|
||||
let mut loader = PackLoader::parse_dependency(key, version, false);
|
||||
// 第一条认得出来的加载器当主加载器(mrpack 没有 primary 概念)。
|
||||
if loader.kind.is_some() && !loaders.iter().any(|seen| seen.kind.is_some()) {
|
||||
loader.primary = true;
|
||||
}
|
||||
loaders.push(loader);
|
||||
}
|
||||
|
||||
let mut files = Vec::with_capacity(raw.files.len());
|
||||
for file in raw.files {
|
||||
let path = file.path.as_deref().ok_or_else(|| {
|
||||
PackError::metadata(format!("{METADATA_ENTRY} 有一条 files[] 没有 path"))
|
||||
})?;
|
||||
let path = normalize_file(path)?;
|
||||
check_sha512(&path, &file.hashes)?;
|
||||
let (client, server, env_extra) = match file.env {
|
||||
None => (EnvSupport::Required, EnvSupport::Required, Map::new()),
|
||||
Some(env) => {
|
||||
let client_raw = env.client.as_deref().ok_or_else(|| {
|
||||
PackError::metadata(format!(
|
||||
"files[] 条目 {path} 有 env 但没有 env.client(Modrinth 规范要求)"
|
||||
))
|
||||
})?;
|
||||
let client = parse_env(&path, "client", client_raw)?;
|
||||
let server = match env.server.as_deref() {
|
||||
Some(raw) => parse_env(&path, "server", raw)?,
|
||||
None => EnvSupport::Required,
|
||||
};
|
||||
(client, server, env.extra)
|
||||
}
|
||||
};
|
||||
files.push(PackFile {
|
||||
path: Some(path),
|
||||
hashes: file.hashes,
|
||||
downloads: file.downloads,
|
||||
size: file.file_size,
|
||||
client,
|
||||
server,
|
||||
extra: file.extra,
|
||||
env_extra,
|
||||
..PackFile::default()
|
||||
});
|
||||
}
|
||||
|
||||
let mut overrides = Vec::new();
|
||||
for side in [PackSide::Both, PackSide::Client, PackSide::Server] {
|
||||
let dir = side.mrpack_dir();
|
||||
let files = pack.override_files(dir)?;
|
||||
if pack.has_entry(dir) || !files.is_empty() {
|
||||
overrides.push(PackOverrides {
|
||||
dir: dir.to_string(),
|
||||
side,
|
||||
files,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(PackManifest {
|
||||
name,
|
||||
version: raw.version_id,
|
||||
author: None,
|
||||
summary: raw.summary,
|
||||
format_version: raw.format_version,
|
||||
game: raw.game.or_else(|| Some(GAME.to_string())),
|
||||
minecraft,
|
||||
loaders,
|
||||
files,
|
||||
overrides,
|
||||
extra: raw.extra,
|
||||
minecraft_extra: Map::new(),
|
||||
})
|
||||
}
|
||||
|
||||
fn render(&self, manifest: &PackManifest) -> PackResult<String> {
|
||||
let version_id = manifest.version.as_deref().ok_or_else(|| {
|
||||
PackError::metadata("Modrinth 清单需要 versionId(包版本),模型里没有")
|
||||
})?;
|
||||
let mut root = Map::new();
|
||||
root.insert(
|
||||
"game".into(),
|
||||
json!(manifest.game.as_deref().unwrap_or(GAME)),
|
||||
);
|
||||
root.insert(
|
||||
"formatVersion".into(),
|
||||
json!(manifest.format_version.unwrap_or(1)),
|
||||
);
|
||||
root.insert("versionId".into(), json!(version_id));
|
||||
root.insert("name".into(), json!(manifest.name));
|
||||
if let Some(summary) = &manifest.summary {
|
||||
root.insert("summary".into(), json!(summary));
|
||||
}
|
||||
|
||||
let mut dependencies = Map::new();
|
||||
if let Some(minecraft) = &manifest.minecraft {
|
||||
dependencies.insert(MINECRAFT_KEY.into(), json!(minecraft));
|
||||
}
|
||||
for loader in &manifest.loaders {
|
||||
let key = loader.dependency_key();
|
||||
let Some(version) = loader.version.as_deref() else {
|
||||
return Err(PackError::unsupported(format!(
|
||||
"Modrinth dependencies 需要版本号,这条加载器没有:raw={:?}",
|
||||
loader.raw
|
||||
)));
|
||||
};
|
||||
if key.is_empty() {
|
||||
return Err(PackError::unsupported(format!(
|
||||
"Modrinth dependencies 需要键名,这条加载器没有:version={version:?}"
|
||||
)));
|
||||
}
|
||||
dependencies.insert(key, json!(version));
|
||||
}
|
||||
root.insert("dependencies".into(), Value::Object(dependencies));
|
||||
|
||||
let mut files = Vec::with_capacity(manifest.files.len());
|
||||
for file in &manifest.files {
|
||||
let path = file.path.as_deref().ok_or_else(|| {
|
||||
PackError::unsupported(format!(
|
||||
"Modrinth 的 files[] 需要 path,这条没有(projectID={:?})",
|
||||
file.project_id
|
||||
))
|
||||
.with_hint("先用 CurseForge/Modrinth API 补全条目再导出")
|
||||
})?;
|
||||
let path = normalize_file(path)?;
|
||||
file.sha512().ok_or_else(|| {
|
||||
PackError::unsupported(format!("Modrinth 的 files[] 需要 hashes.sha512:{path}"))
|
||||
})?;
|
||||
if file.downloads.iter().all(|url| url.trim().is_empty()) {
|
||||
return Err(PackError::unsupported(format!(
|
||||
"Modrinth 的 files[] 需要至少一个 downloads URL:{path}"
|
||||
))
|
||||
.with_hint("内联覆盖内容无法用 mrpack 表达(见 M112 的内联兜底方案)"));
|
||||
}
|
||||
let mut hashes = Map::new();
|
||||
for (algorithm, digest) in &file.hashes {
|
||||
hashes.insert(algorithm.clone(), json!(digest));
|
||||
}
|
||||
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("server".into(), json!(file.server.as_str()));
|
||||
|
||||
let mut object = Map::new();
|
||||
object.insert("path".into(), json!(path));
|
||||
object.insert("hashes".into(), Value::Object(hashes));
|
||||
object.insert("env".into(), Value::Object(env));
|
||||
object.insert("downloads".into(), json!(file.downloads));
|
||||
if let Some(size) = file.size {
|
||||
object.insert("fileSize".into(), json!(size));
|
||||
}
|
||||
merge_unknown(&mut object, &file.extra);
|
||||
files.push(Value::Object(object));
|
||||
}
|
||||
root.insert("files".into(), Value::Array(files));
|
||||
|
||||
merge_unknown(&mut root, &manifest.extra);
|
||||
serde_json::to_string_pretty(&Value::Object(root)).map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
/// `hashes.sha512` 必须是 128 位十六进制。
|
||||
fn check_sha512(path: &str, hashes: &BTreeMap<String, String>) -> PackResult<()> {
|
||||
let Some(digest) = hashes.get("sha512") else {
|
||||
return Err(PackError::metadata(format!(
|
||||
"files[] 条目 {path} 缺 hashes.sha512(Modrinth 规范要求)"
|
||||
)));
|
||||
};
|
||||
if digest.len() != 128 || !digest.bytes().all(|byte| byte.is_ascii_hexdigit()) {
|
||||
return Err(PackError::metadata(format!(
|
||||
"files[] 条目 {path} 的 sha512 不是 128 位十六进制"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 解析 `env.client` / `env.server`;不认识的取值直接拒绝(不静默降级成全都要)。
|
||||
fn parse_env(path: &str, field: &str, raw: &str) -> PackResult<EnvSupport> {
|
||||
EnvSupport::parse(raw).ok_or_else(|| {
|
||||
PackError::metadata(format!(
|
||||
"files[] 条目 {path} 的 env.{field} 是 {raw:?},只认 required/optional/unsupported"
|
||||
))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
//! MultiMC / Prism 格式:`mmc-pack.json` + `instance.cfg` + `.minecraft/` 目录。
|
||||
//!
|
||||
//! # 形状(A 级来源:HMCL `MultiMCModpackProvider` / `toGameSettings`,见 analyse/05 §2.6)
|
||||
//!
|
||||
//! ```jsonc
|
||||
//! // mmc-pack.json
|
||||
//! {"formatVersion": 1, "components": [
|
||||
//! {"uid": "net.minecraft", "version": "1.20.1", "important": true},
|
||||
//! {"uid": "net.fabricmc.fabric-loader", "version": "0.15.11"}]}
|
||||
//! // instance.cfg(ini)
|
||||
//! InstanceType=OneSix
|
||||
//! name=我的整合包
|
||||
//! ```
|
||||
//!
|
||||
//! **它不是一个"包",而是一个"实例目录"**:模组就在 `.minecraft/mods/` 里,没有任何远端
|
||||
//! 条目、没有 URL、没有摘要表。所以:
|
||||
//!
|
||||
//! - 读:把 `.minecraft` 当成一个覆盖目录([`PackSide::Both`]),模组按目录里的实际文件逐条
|
||||
//! 记 SHA-1;[`PackManifest::files`] 恒为空——这是格式事实,不是我们的偷懒;
|
||||
//! - 写:**不支持**。写回 MultiMC 需要重组 `mmc-pack.json` 的组件图(还要处理
|
||||
//! `patches/`、`libraries/`、`jarmods/`),那是另一套协议,不在本模块的模型职责内,
|
||||
//! 也不该伪装成"能导出"。`metadata_entry` 返回 `None`,写会得到明确的
|
||||
//! `pack_unsupported`。
|
||||
//!
|
||||
//! `components` 是**保序列表**,里面既有加载器,也有 mappings(`net.fabricmc.intermediary`
|
||||
//! 等)与 `org.lwjgl3` 这类非加载器组件——所以模型把**每一条组件**都留在
|
||||
//! [`PackManifest::loaders`] 里(顺序不动、原始 uid 在 [`PackLoader::raw`]、`kind` 能认就认),
|
||||
//! 组件自身的 `important` / `dependencyOnly` / `cachedName` 落在 [`PackLoader::extra`](M115
|
||||
//! 调研 §字段缺口 3)。这样"加载器集合"是它的派生视图,而不是丢信息的收窄。
|
||||
//!
|
||||
//! 也就是说:本格式**能探测、能导入**,**不能导出**——报告里已把这条写清楚。
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use super::archive::PackArchive;
|
||||
use super::error::{PackError, PackResult};
|
||||
use super::format::PackFormat;
|
||||
use super::manifest::{PackLoader, PackManifest, PackOverrides, PackSide};
|
||||
|
||||
/// 格式 id。
|
||||
pub const ID: &str = "multimc";
|
||||
/// 清单入口名。
|
||||
pub const METADATA_ENTRY: &str = "mmc-pack.json";
|
||||
/// 实例配置文件名(包名在这里)。
|
||||
pub const INSTANCE_ENTRY: &str = "instance.cfg";
|
||||
/// 实例内容目录(模组/配置都在里面)。
|
||||
pub const CONTENT_DIR: &str = ".minecraft";
|
||||
/// 游戏本体的组件 uid。
|
||||
pub const MINECRAFT_UID: &str = "net.minecraft";
|
||||
|
||||
/// 单例(进注册表用)。
|
||||
pub static MULTIMC: MultiMc = MultiMc;
|
||||
|
||||
/// MultiMC / Prism 格式的值类型实现。
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct MultiMc;
|
||||
|
||||
/// `mmc-pack.json` 的原始形状。
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct RawPack {
|
||||
#[serde(rename = "formatVersion", default)]
|
||||
format_version: Option<i64>,
|
||||
#[serde(default)]
|
||||
components: Vec<RawComponent>,
|
||||
#[serde(flatten)]
|
||||
extra: Map<String, Value>,
|
||||
}
|
||||
|
||||
/// `components[]` 的一条。
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct RawComponent {
|
||||
#[serde(default)]
|
||||
uid: Option<String>,
|
||||
#[serde(default)]
|
||||
version: Option<String>,
|
||||
#[serde(flatten)]
|
||||
extra: Map<String, Value>,
|
||||
}
|
||||
|
||||
impl PackFormat for MultiMc {
|
||||
fn id(&self) -> &'static str {
|
||||
ID
|
||||
}
|
||||
|
||||
fn label(&self) -> &'static str {
|
||||
"MultiMC / Prism"
|
||||
}
|
||||
|
||||
fn detect(&self, pack: &PackArchive) -> Option<&'static str> {
|
||||
let value = pack.read_json(METADATA_ENTRY).ok().flatten()?;
|
||||
let object = value.as_object()?;
|
||||
// 形状校验:必须有非空 components 数组(MultiMC 的组件图是它的身份证)。
|
||||
let matched = object
|
||||
.get("components")
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|components| !components.is_empty());
|
||||
matched.then_some(METADATA_ENTRY)
|
||||
}
|
||||
|
||||
fn read(&self, pack: &PackArchive) -> PackResult<PackManifest> {
|
||||
let value = pack
|
||||
.read_json(METADATA_ENTRY)?
|
||||
.ok_or_else(|| PackError::metadata(format!("包里没有 {METADATA_ENTRY}")))?;
|
||||
let raw: RawPack = serde_json::from_value(value)
|
||||
.map_err(|error| PackError::metadata(format!("{METADATA_ENTRY} 形状不符:{error}")))?;
|
||||
|
||||
let mut minecraft = None;
|
||||
let mut loaders: Vec<PackLoader> = Vec::new();
|
||||
for component in &raw.components {
|
||||
let Some(uid) = component.uid.as_deref() else {
|
||||
continue;
|
||||
};
|
||||
let Some(version) = component.version.as_deref() else {
|
||||
continue;
|
||||
};
|
||||
if uid == MINECRAFT_UID {
|
||||
minecraft = Some(version.to_string());
|
||||
continue;
|
||||
}
|
||||
let mut loader = PackLoader::parse_multimc(uid, version, false);
|
||||
if loader.kind.is_some() && !loaders.iter().any(|seen| seen.kind.is_some()) {
|
||||
loader.primary = true;
|
||||
}
|
||||
loader.extra = component.extra.clone();
|
||||
loaders.push(loader);
|
||||
}
|
||||
|
||||
let name = instance_name(pack).unwrap_or_else(|| {
|
||||
pack.root()
|
||||
.file_stem()
|
||||
.map(|stem| stem.to_string_lossy().to_string())
|
||||
.unwrap_or_default()
|
||||
});
|
||||
|
||||
let mut overrides = Vec::new();
|
||||
let content = pack.override_files(CONTENT_DIR)?;
|
||||
if pack.has_entry(CONTENT_DIR) || !content.is_empty() {
|
||||
overrides.push(PackOverrides {
|
||||
dir: CONTENT_DIR.to_string(),
|
||||
side: PackSide::Both,
|
||||
files: content,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(PackManifest {
|
||||
name,
|
||||
version: None,
|
||||
author: None,
|
||||
summary: None,
|
||||
format_version: raw.format_version.or(Some(1)),
|
||||
game: Some("minecraft".to_string()),
|
||||
minecraft,
|
||||
loaders,
|
||||
// MultiMC 没有远端条目:模组就是覆盖目录里的文件。
|
||||
files: Vec::new(),
|
||||
overrides,
|
||||
extra: raw.extra,
|
||||
minecraft_extra: Map::new(),
|
||||
})
|
||||
}
|
||||
|
||||
fn render(&self, _manifest: &PackManifest) -> PackResult<String> {
|
||||
Err(PackError::unsupported(
|
||||
"MultiMC 格式只支持导入:写回需要重组组件图与 patches/libraries,不在模型职责内",
|
||||
)
|
||||
.with_hint("导出请选 Modrinth(.mrpack)或 CurseForge 格式"))
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 `instance.cfg` 的 `name=` 行取实例名(MultiMC 的 ini 很宽松,只认这一行)。
|
||||
fn instance_name(pack: &PackArchive) -> Option<String> {
|
||||
let text = pack.read_text(INSTANCE_ENTRY).ok().flatten()?;
|
||||
text.lines()
|
||||
.filter_map(|line| line.trim().strip_prefix("name="))
|
||||
.map(str::trim)
|
||||
.find(|name| !name.is_empty())
|
||||
.map(str::to_string)
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
//! 包内路径规整:路径穿越**第一检**(第二检是 `zip` crate 的 `enclosed_name()`,见 `archive`)。
|
||||
//!
|
||||
//! 判据不是"看起来安全",而是一条条写死的规则。任一条不满足就报
|
||||
//! `crate::addon::pack::error::PackErrorKind::Traversal`:
|
||||
//!
|
||||
//! 1. 空名字拒绝(`""` 与全 `/` 都不成路径);
|
||||
//! 2. `\0` 拒绝(底层会被截断,产生"看得见查不着"的名字);
|
||||
//! 3. `\` 拒绝——zip 规范要求 `/` 分隔,但 Win32 把 `\` 也当分隔符,于是
|
||||
//! `mods\..\..\evil` 在"按 `/` 切"的检查里是一个普通名字、在落盘时却跳三层;
|
||||
//! 4. `:` 拒绝——盘符(`C:/x`)与 NTFS 交换数据流(`file:Zone.Identifier`)的共同记号;
|
||||
//! 5. 前导 `/` 拒绝(绝对路径);
|
||||
//! 6. 任何 `..` 段拒绝(不"抵消"后放行:`a/../b` 与 `../b` 一律拒,避免归一化差异);
|
||||
//! 7. `.` 段与空段(`a//b`)**丢弃**(纯噪声,不是穿越手法)。
|
||||
//!
|
||||
//! 规整后的名字一律 `/` 分隔、无前导斜杠、无 `.` / `..` 段,因此可以安全地
|
||||
//! `root.join(name)`——但**写盘前仍要再查一次**(`archive` 的 `sha1`/`read` 与 `format` 的
|
||||
//! `write` 都重新过一遍本模块),因为模型可能来自外部构造而非本模块的读者。
|
||||
|
||||
use super::error::{PackError, PackResult};
|
||||
|
||||
/// 规整一条**文件**条目名(目录条目请用 [`normalize_dir`])。
|
||||
///
|
||||
/// 结尾的 `/` 视为目录标记并被去掉;去掉后为空则拒绝。
|
||||
pub fn normalize_file(raw: &str) -> PackResult<String> {
|
||||
let text = normalize_text(raw)?;
|
||||
if text.is_empty() {
|
||||
return Err(PackError::traversal(format!("包内路径为空:{raw:?}")));
|
||||
}
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
/// 规整一条**目录**前缀(例如 CF 的 `overrides`、mrpack 的 `client-overrides`)。
|
||||
pub fn normalize_dir(raw: &str) -> PackResult<String> {
|
||||
let text = normalize_text(raw)?;
|
||||
if text.is_empty() {
|
||||
return Err(PackError::traversal(format!("包内目录为空:{raw:?}")));
|
||||
}
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
/// 文件名是否以 `/` 结尾(zip 的目录条目约定)。
|
||||
#[must_use]
|
||||
pub fn has_trailing_slash(raw: &str) -> bool {
|
||||
raw.ends_with('/')
|
||||
}
|
||||
|
||||
/// 规则 1–7 的实现。返回可能为空的规整串(由调用方判定"空"是否合法)。
|
||||
fn normalize_text(raw: &str) -> PackResult<String> {
|
||||
if raw.is_empty() {
|
||||
return Err(PackError::traversal("包内路径为空"));
|
||||
}
|
||||
if raw.contains('\0') {
|
||||
return Err(PackError::traversal(format!("包内路径含 NUL:{raw:?}")));
|
||||
}
|
||||
if raw.contains('\\') {
|
||||
return Err(PackError::traversal(format!(
|
||||
"包内路径含反斜杠(Win32 会当分隔符):{raw:?}"
|
||||
)));
|
||||
}
|
||||
if raw.contains(':') {
|
||||
return Err(PackError::traversal(format!(
|
||||
"包内路径含冒号(盘符 / NTFS 数据流):{raw:?}"
|
||||
)));
|
||||
}
|
||||
if raw.starts_with('/') {
|
||||
return Err(PackError::traversal(format!("包内路径是绝对路径:{raw:?}")));
|
||||
}
|
||||
|
||||
let mut segments: Vec<&str> = Vec::new();
|
||||
for segment in raw.split('/') {
|
||||
match segment {
|
||||
"" | "." => continue,
|
||||
".." => {
|
||||
return Err(PackError::traversal(format!(
|
||||
"包内路径含 `..` 上跳:{raw:?}"
|
||||
)));
|
||||
}
|
||||
other => segments.push(other),
|
||||
}
|
||||
}
|
||||
Ok(segments.join("/"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::addon::pack::error::PackErrorKind;
|
||||
|
||||
#[test]
|
||||
fn keeps_plain_relative_names() {
|
||||
assert_eq!(normalize_file("mods/jei.jar").unwrap(), "mods/jei.jar");
|
||||
assert_eq!(
|
||||
normalize_file("config/a/b.toml").unwrap(),
|
||||
"config/a/b.toml"
|
||||
);
|
||||
// `.` 段与空段是噪声,丢掉而不是报错。
|
||||
assert_eq!(normalize_file("./mods//jei.jar").unwrap(), "mods/jei.jar");
|
||||
assert_eq!(normalize_dir("overrides/").unwrap(), "overrides");
|
||||
assert_eq!(
|
||||
normalize_dir("client-overrides//").unwrap(),
|
||||
"client-overrides"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_traversal_shapes() {
|
||||
for raw in [
|
||||
"../evil.jar",
|
||||
"mods/../../evil.jar",
|
||||
"a/../b",
|
||||
"/etc/passwd",
|
||||
"//server/share",
|
||||
"C:/Windows/evil.dll",
|
||||
"mods\\..\\..\\evil.jar",
|
||||
"mods/jei.jar:Zone.Identifier",
|
||||
"mods/\0jei.jar",
|
||||
"",
|
||||
"/",
|
||||
"./",
|
||||
] {
|
||||
let error = normalize_file(raw).expect_err(raw);
|
||||
assert_eq!(
|
||||
error.kind(),
|
||||
PackErrorKind::Traversal,
|
||||
"应判为穿越,实际 {raw:?} → {error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trailing_slash_marks_directories() {
|
||||
assert!(has_trailing_slash("overrides/"));
|
||||
assert!(!has_trailing_slash("overrides"));
|
||||
// 目录标记不改变规整结果:`overrides/` 与 `overrides` 是同一个前缀。
|
||||
assert_eq!(
|
||||
normalize_dir("overrides/").unwrap(),
|
||||
normalize_dir("overrides").unwrap()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
//! 探测优先级:顺序写死在注册表里,用例把"谁赢"和"为什么赢"都钉住。
|
||||
//!
|
||||
//! 判据分三层,缺一层都可能被"看起来对"糊过去:
|
||||
//! 1. 内置顺序本身([`PackRegistry::ids`]);
|
||||
//! 2. 同一份包命中多格式时选谁(含 `also_matched` 记录的其它命中);
|
||||
//! 3. **换一下注册顺序,赢家跟着换**——这才证明优先级来自注册表,而不是代码里散落的 if。
|
||||
|
||||
use super::{fixtures, temp_root, write_zip};
|
||||
use crate::addon::pack::archive::PackArchive;
|
||||
use crate::addon::pack::curseforge::CURSEFORGE;
|
||||
use crate::addon::pack::error::PackErrorKind;
|
||||
use crate::addon::pack::format::{PackFormat, PackRegistry};
|
||||
use crate::addon::pack::modrinth::MODRINTH;
|
||||
use crate::addon::pack::multimc::MULTIMC;
|
||||
|
||||
const CF_MANIFEST: &[u8] = br#"{"manifestType":"minecraftModpack","manifestVersion":1,
|
||||
"name":"hybrid pack","version":"1.0","minecraft":{"version":"1.20.1"},"files":[]}"#;
|
||||
|
||||
const MR_INDEX: &[u8] = br#"{"game":"minecraft","formatVersion":1,"versionId":"1.0",
|
||||
"name":"hybrid pack","files":[],"dependencies":{"minecraft":"1.20.1"}}"#;
|
||||
|
||||
const MMC_PACK: &[u8] =
|
||||
br#"{"formatVersion":1,"components":[{"uid":"net.minecraft","version":"1.20.1"}]}"#;
|
||||
|
||||
/// 同时带三种身份标记的"不标准包"。
|
||||
fn hybrid_pack(tag: &str) -> std::path::PathBuf {
|
||||
let target = temp_root(tag).join("hybrid.zip");
|
||||
write_zip(
|
||||
&target,
|
||||
&[
|
||||
("manifest.json", CF_MANIFEST),
|
||||
("modrinth.index.json", MR_INDEX),
|
||||
("mmc-pack.json", MMC_PACK),
|
||||
("overrides/config/x.toml", &b"x = 1\n"[..]),
|
||||
],
|
||||
);
|
||||
target
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_priority_is_pinned() {
|
||||
assert_eq!(
|
||||
PackRegistry::builtin().ids(),
|
||||
vec!["curseforge", "modrinth", "multimc"],
|
||||
"探测顺序是决策,不是实现细节:改了要连着报告与测试一起改"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curseforge_wins_when_a_pack_carries_every_marker() {
|
||||
let pack = PackArchive::open_default(hybrid_pack("detect-hybrid")).expect("打开混合包");
|
||||
let matched = PackRegistry::builtin().detect(&pack).expect("要能选出格式");
|
||||
assert_eq!(matched.format().id(), "curseforge");
|
||||
assert_eq!(
|
||||
matched.also_matched().to_vec(),
|
||||
vec!["modrinth", "multimc"],
|
||||
"其余命中必须可见(调用方据此提示「这包不标准」)"
|
||||
);
|
||||
assert!(matched.is_ambiguous());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_plain_manifest_json_does_not_claim_curseforge() {
|
||||
// `manifest.json` 这名字太泛:形状不符就不能认领,否则 mrpack 会被抢走。
|
||||
let target = temp_root("detect-plain").join("plain.zip");
|
||||
write_zip(
|
||||
&target,
|
||||
&[
|
||||
("manifest.json", &br#"{"hello":"world"}"#[..]),
|
||||
("modrinth.index.json", MR_INDEX),
|
||||
],
|
||||
);
|
||||
let pack = PackArchive::open_default(&target).expect("打开包");
|
||||
let matched = PackRegistry::builtin().detect(&pack).expect("要能选出格式");
|
||||
assert_eq!(matched.format().id(), "modrinth");
|
||||
assert!(matched.also_matched().is_empty(), "CF 不该被「泛名」骗到");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registration_order_decides_the_winner() {
|
||||
// 同一份包同时有 CF 与 mrpack 标记:只换注册顺序,赢家就该换。
|
||||
let target = temp_root("detect-order").join("both.zip");
|
||||
write_zip(
|
||||
&target,
|
||||
&[
|
||||
("manifest.json", CF_MANIFEST),
|
||||
("modrinth.index.json", MR_INDEX),
|
||||
],
|
||||
);
|
||||
let pack = PackArchive::open_default(&target).expect("打开双标记包");
|
||||
|
||||
let builtin = PackRegistry::builtin()
|
||||
.detect(&pack)
|
||||
.expect("默认顺序要能选");
|
||||
assert_eq!(builtin.format().id(), "curseforge");
|
||||
assert_eq!(builtin.also_matched().to_vec(), vec!["modrinth"]);
|
||||
|
||||
let reversed = PackRegistry::empty().with(&MODRINTH).with(&CURSEFORGE);
|
||||
let matched = reversed.detect(&pack).expect("反序注册也要能选");
|
||||
assert_eq!(
|
||||
matched.format().id(),
|
||||
"modrinth",
|
||||
"反序注册后 Modrinth 排第一,就该它赢"
|
||||
);
|
||||
assert_eq!(matched.also_matched().to_vec(), vec!["curseforge"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_package_is_rejected_with_the_list_of_tried_formats() {
|
||||
let target = temp_root("detect-unknown").join("unknown.zip");
|
||||
write_zip(&target, &[("readme.txt", &b"nothing to see"[..])]);
|
||||
let pack = PackArchive::open_default(&target).expect("打开包");
|
||||
assert!(PackRegistry::builtin().detect_all(&pack).is_empty());
|
||||
let error = PackRegistry::builtin()
|
||||
.detect(&pack)
|
||||
.expect_err("没有格式认领就必须报错");
|
||||
assert_eq!(error.kind(), PackErrorKind::Unsupported);
|
||||
for id in ["curseforge", "modrinth", "multimc"] {
|
||||
assert!(
|
||||
error.message().contains(id),
|
||||
"错误里要列出试过哪些格式,实际:{}",
|
||||
error.message()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multimc_needs_a_non_empty_component_graph() {
|
||||
let target = temp_root("detect-mmc-empty").join("empty-mmc.zip");
|
||||
write_zip(
|
||||
&target,
|
||||
&[(
|
||||
"mmc-pack.json",
|
||||
&br#"{"formatVersion":1,"components":[]}"#[..],
|
||||
)],
|
||||
);
|
||||
let pack = PackArchive::open_default(&target).expect("打开包");
|
||||
assert_eq!(
|
||||
MULTIMC.detect(&pack),
|
||||
None,
|
||||
"空 components 是空壳,不能当 MultiMC 实例"
|
||||
);
|
||||
assert_eq!(
|
||||
PackRegistry::builtin()
|
||||
.detect(&pack)
|
||||
.expect_err("不该有格式认领")
|
||||
.kind(),
|
||||
PackErrorKind::Unsupported
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multimc_instance_directory_is_detected() {
|
||||
let root = fixtures::multimc("detect-mmc-dir");
|
||||
let pack = PackArchive::open_default(&root).expect("打开实例目录");
|
||||
let matched = PackRegistry::builtin()
|
||||
.detect(&pack)
|
||||
.expect("目录形式也要能探测");
|
||||
assert_eq!(matched.format().id(), "multimc");
|
||||
assert!(!matched.is_ambiguous());
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
//! 扩展点判据:加一个假格式(**一个文件、一条注册**)就能被探测与读写,
|
||||
//! 且不碰模型、不碰 CurseForge / Modrinth / MultiMC 任何一行。
|
||||
//!
|
||||
//! 这里刻意不写"文档说它可扩展",而是真的实现一个 `toy.json` 格式走完整条链:
|
||||
//! 注册 → 探测 → 读 → 渲染 → 写 → 再读。
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::{temp_root, write_file, write_zip};
|
||||
use crate::addon::pack::archive::PackArchive;
|
||||
use crate::addon::pack::curseforge::CURSEFORGE;
|
||||
use crate::addon::pack::error::{PackError, PackErrorKind, PackResult};
|
||||
use crate::addon::pack::format::{PackFormat, PackPayload, PackRegistry};
|
||||
use crate::addon::pack::manifest::{PackFile, PackManifest};
|
||||
|
||||
/// 测试专用假格式:`toy.json` = `{"name": …, "minecraft": …, "mods": ["mods/a.jar"]}`。
|
||||
///
|
||||
/// 全部实现加起来不到 40 行——这就是"社区加一个新格式"的代价上限。
|
||||
#[derive(Debug)]
|
||||
struct ToyFormat;
|
||||
|
||||
/// 注册表要的是 `&'static dyn PackFormat`,所以用 `static`。
|
||||
static TOY: ToyFormat = ToyFormat;
|
||||
|
||||
impl PackFormat for ToyFormat {
|
||||
fn id(&self) -> &'static str {
|
||||
"toy"
|
||||
}
|
||||
|
||||
fn label(&self) -> &'static str {
|
||||
"Toy(测试专用假格式)"
|
||||
}
|
||||
|
||||
fn metadata_entry(&self) -> Option<&'static str> {
|
||||
Some("toy.json")
|
||||
}
|
||||
|
||||
fn detect(&self, pack: &PackArchive) -> Option<&'static str> {
|
||||
let value = pack.read_json("toy.json").ok().flatten()?;
|
||||
value.is_object().then_some("toy.json")
|
||||
}
|
||||
|
||||
fn read(&self, pack: &PackArchive) -> PackResult<PackManifest> {
|
||||
let value = pack
|
||||
.read_json("toy.json")?
|
||||
.ok_or_else(|| PackError::metadata("没有 toy.json"))?;
|
||||
let files = value
|
||||
.get("mods")
|
||||
.and_then(Value::as_array)
|
||||
.map(|mods| {
|
||||
mods.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.map(PackFile::with_path)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
Ok(PackManifest {
|
||||
name: value
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
minecraft: value
|
||||
.get("minecraft")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string),
|
||||
files,
|
||||
..PackManifest::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn render(&self, manifest: &PackManifest) -> PackResult<String> {
|
||||
let mods: Vec<&str> = manifest
|
||||
.files
|
||||
.iter()
|
||||
.filter_map(|file| file.path.as_deref())
|
||||
.collect();
|
||||
Ok(json!({
|
||||
"name": manifest.name,
|
||||
"minecraft": manifest.minecraft,
|
||||
"mods": mods,
|
||||
})
|
||||
.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// 造一个 toy 包(源目录形式,顺带能当导出 payload)。
|
||||
fn toy_pack(tag: &str) -> std::path::PathBuf {
|
||||
let dir = temp_root(tag).join("toy");
|
||||
write_file(
|
||||
&dir.join("toy.json"),
|
||||
br#"{"name":"Toy Pack","minecraft":"1.21","mods":["mods/a.jar","mods/b.jar"]}"#,
|
||||
);
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_format_needs_one_registration_line() {
|
||||
let dir = toy_pack("extend-register");
|
||||
let pack = PackArchive::open_default(&dir).expect("打开 toy 包");
|
||||
|
||||
// ① 没注册时:内置三格式都不认它(证明它不是"顺手"被认出来的)。
|
||||
let error = PackRegistry::builtin()
|
||||
.detect(&pack)
|
||||
.expect_err("内置格式不该认领 toy 包");
|
||||
assert_eq!(error.kind(), PackErrorKind::Unsupported);
|
||||
|
||||
// ② 加一行注册。
|
||||
let registry = PackRegistry::builtin().with(&TOY);
|
||||
|
||||
// ③ 探测得到。
|
||||
let matched = registry.detect(&pack).expect("注册后就该探测到");
|
||||
assert_eq!(matched.format().id(), "toy");
|
||||
assert!(!matched.is_ambiguous());
|
||||
|
||||
// ④ 读得到。
|
||||
let manifest = matched.format().read(&pack).expect("读 toy 清单");
|
||||
assert_eq!(manifest.name, "Toy Pack");
|
||||
assert_eq!(manifest.minecraft.as_deref(), Some("1.21"));
|
||||
assert_eq!(manifest.files.len(), 2);
|
||||
|
||||
// ⑤ 渲染 + 写(默认 write 实现直接用 metadata_entry + render,不用重写一遍 zip 逻辑)。
|
||||
let rendered = matched.format().render(&manifest).expect("渲染 toy 清单");
|
||||
assert!(rendered.contains("Toy Pack"));
|
||||
|
||||
let target = dir.with_file_name("toy.zip");
|
||||
matched
|
||||
.format()
|
||||
.write(&manifest, PackPayload::metadata_only(), &target)
|
||||
.expect("导出 toy 包");
|
||||
|
||||
// ⑥ 再读回来必须一样。
|
||||
let written = PackArchive::open_default(&target).expect("打开导出的 toy 包");
|
||||
let reread = matched.format().read(&written).expect("再读 toy 清单");
|
||||
assert_eq!(reread, manifest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appending_a_format_never_steals_existing_packs() {
|
||||
// 同一份包同时有 CF 清单与 toy 清单。
|
||||
let root = temp_root("extend-priority");
|
||||
let target = root.join("both.zip");
|
||||
write_zip(
|
||||
&target,
|
||||
&[
|
||||
(
|
||||
"manifest.json",
|
||||
&br#"{"manifestType":"minecraftModpack","manifestVersion":1,
|
||||
"name":"real cf pack","minecraft":{"version":"1.20.1"},"files":[]}"#[..],
|
||||
),
|
||||
(
|
||||
"toy.json",
|
||||
&br#"{"name":"Toy shadow","minecraft":"1.21","mods":[]}"#[..],
|
||||
),
|
||||
],
|
||||
);
|
||||
let pack = PackArchive::open_default(&target).expect("打开包");
|
||||
|
||||
// 追加到末尾 = 最低优先级:老包的行为不变,但歧义要看得见。
|
||||
let appended = PackRegistry::builtin().with(&TOY);
|
||||
let matched = appended.detect(&pack).expect("探测");
|
||||
assert_eq!(matched.format().id(), "curseforge");
|
||||
assert_eq!(matched.also_matched().to_vec(), vec!["toy"]);
|
||||
|
||||
// 反过来排到最前,就该 toy 赢——注册顺序就是优先级。
|
||||
let front = PackRegistry::empty().with(&TOY).with(&CURSEFORGE);
|
||||
let matched = front.detect(&pack).expect("探测");
|
||||
assert_eq!(matched.format().id(), "toy");
|
||||
assert_eq!(matched.also_matched().to_vec(), vec!["curseforge"]);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
//! 本地夹具:CurseForge / Modrinth / MultiMC 三种格式各一个最小但**形状真实**的包。
|
||||
//!
|
||||
//! # 来源声明(不许把"看起来像"当真实)
|
||||
//!
|
||||
//! | 夹具 | 来源 | 说明 |
|
||||
//! |------|------|------|
|
||||
//! | [`curseforge`] | **手造** | 字段取自 analyse/05 §2.2(HMCL `CurseManifest` / `CurseManifestFile`);内容与数值是编的 |
|
||||
//! | [`modrinth`] | **手造** | 字段取自 analyse/05 §2.3(HMCL `ModrinthManifest`);`hashes` 里的摘要是**形状正确的占位值**,不对应任何真实文件 |
|
||||
//! | [`multimc`] | **手造** | 字段取自 analyse/05 §2.6(HMCL `MultiMCModpackProvider`);组件表比真实 Prism 实例小得多 |
|
||||
//!
|
||||
//! **没有一个来自真实导出**:本任务环境离线、仓库里也没有真实整合包样本。真实导出物的字段
|
||||
//! 偏移风险只能等 M111/M115 拿到真包后回收(报告里已登记这条欠账)。
|
||||
//!
|
||||
//! # 刻意造的坏文件(导入器不许崩、也不许悄悄丢掉)
|
||||
//!
|
||||
//! - CF:`overrides/config/broken.toml` —— 截断的 TOML(引号未闭合、段落未收尾);
|
||||
//! - mrpack:`overrides/mods/half.jar` —— 只有半个 zip 头;
|
||||
//! - MultiMC:`.minecraft/mods/broken.jar` —— 零字节。
|
||||
//!
|
||||
//! 三种损坏都是**覆盖目录里的文件**:它们没有清单声明的摘要可比,读包时只能如实记下实际
|
||||
//! SHA-1/大小(三方合并的 base 侧就是这么来的),谁想"校验并修掉"必须另做判断。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::{temp_root, write_file, zip_dir};
|
||||
|
||||
/// 一个既有源目录(可当导出 payload)又有打好的 zip 的夹具。
|
||||
pub(crate) struct Fixture {
|
||||
/// 源目录(zip 的内容来源,也是 `PackPayload::from_root` 的根)。
|
||||
pub dir: PathBuf,
|
||||
/// 打好的包。
|
||||
pub zip: PathBuf,
|
||||
}
|
||||
|
||||
/// CF 夹具的包名。
|
||||
pub(crate) const CF_NAME: &str = "M110 手造 CurseForge 包";
|
||||
/// CF 夹具里那个坏文件的内容。
|
||||
pub(crate) const CF_BROKEN: &[u8] = b"broken = \"unterminated\n[section\n";
|
||||
/// mrpack 夹具的包名。
|
||||
pub(crate) const MR_NAME: &str = "M110 手造 Modrinth 包";
|
||||
/// mrpack 夹具里那个半截 jar 的内容。
|
||||
pub(crate) const MR_HALF_JAR: &[u8] = b"PK\x03\x04half";
|
||||
/// MultiMC 夹具的实例名(写在 `instance.cfg`)。
|
||||
pub(crate) const MMC_NAME: &str = "M110 手造 MultiMC 实例";
|
||||
|
||||
/// 手造的 SHA-1 占位值(40 位十六进制)。
|
||||
const FAKE_SHA1: &str = "0123456789abcdef0123456789abcdef01234567";
|
||||
/// 手造的 SHA-256 占位值(64 位十六进制)。
|
||||
const FAKE_SHA256: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
/// 手造的 SHA-512 占位值(128 位十六进制)。
|
||||
const FAKE_SHA512: &str = concat!(
|
||||
"0123456789abcdef",
|
||||
"0123456789abcdef",
|
||||
"0123456789abcdef",
|
||||
"0123456789abcdef",
|
||||
"0123456789abcdef",
|
||||
"0123456789abcdef",
|
||||
"0123456789abcdef",
|
||||
"0123456789abcdef"
|
||||
);
|
||||
|
||||
const CF_MANIFEST: &str = r#"{
|
||||
"manifestType": "minecraftModpack",
|
||||
"manifestVersion": 1,
|
||||
"name": "M110 手造 CurseForge 包",
|
||||
"version": "1.0.0",
|
||||
"author": "AskaEth",
|
||||
"overrides": "overrides",
|
||||
"minecraft": {
|
||||
"version": "1.20.1",
|
||||
"modLoaders": [{"id": "forge-47.2.0", "primary": true}],
|
||||
"recommendedRam": 4096
|
||||
},
|
||||
"files": [
|
||||
{"projectID": 238222, "fileID": 5102025, "required": true},
|
||||
{"projectID": 306612, "fileID": 4587454, "required": false}
|
||||
],
|
||||
"customCommunityField": {"since": 2, "note": "社区新增字段,必须原样回写"}
|
||||
}
|
||||
"#;
|
||||
|
||||
fn mrpack_index() -> String {
|
||||
format!(
|
||||
r#"{{
|
||||
"game": "minecraft",
|
||||
"formatVersion": 1,
|
||||
"versionId": "2.0.0",
|
||||
"name": "{MR_NAME}",
|
||||
"summary": "手造夹具:只验证形状与边界,不是真实导出",
|
||||
"files": [
|
||||
{{
|
||||
"path": "mods/jei.jar",
|
||||
"hashes": {{"sha1": "{FAKE_SHA1}", "sha256": "{FAKE_SHA256}", "sha512": "{FAKE_SHA512}"}},
|
||||
"env": {{"client": "required", "server": "unsupported"}},
|
||||
"downloads": ["https://cdn.modrinth.com/data/AANobbMI/versions/abc/jei.jar"],
|
||||
"fileSize": 1234567,
|
||||
"customFileField": "社区新增字段"
|
||||
}},
|
||||
{{
|
||||
"path": "mods/optional.jar",
|
||||
"hashes": {{"sha1": "{FAKE_SHA1}", "sha512": "{FAKE_SHA512}"}},
|
||||
"env": {{"client": "optional", "server": "unsupported"}},
|
||||
"downloads": ["https://cdn.modrinth.com/data/opt/versions/x/optional.jar"],
|
||||
"fileSize": 11
|
||||
}}
|
||||
],
|
||||
"dependencies": {{
|
||||
"minecraft": "1.20.1",
|
||||
"fabric-loader": "0.15.11",
|
||||
"parchment": "2023.09.03"
|
||||
}},
|
||||
"customPackField": [1, 2, 3]
|
||||
}}
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
const MMC_PACK: &str = r#"{
|
||||
"formatVersion": 1,
|
||||
"components": [
|
||||
{"uid": "net.minecraft", "version": "1.20.1", "important": true, "cachedName": "1.20.1"},
|
||||
{"uid": "net.fabricmc.fabric-loader", "version": "0.15.11"},
|
||||
{"uid": "org.lwjgl3", "version": "3.3.2"}
|
||||
],
|
||||
"customComponentGraphField": true
|
||||
}
|
||||
"#;
|
||||
|
||||
const MMC_INSTANCE: &str = "InstanceType=OneSix\nname=M110 手造 MultiMC 实例\niconKey=default\n";
|
||||
|
||||
/// CurseForge 夹具(`manifest.json` + `overrides/`,含一个截断的 TOML)。
|
||||
pub(crate) fn curseforge(tag: &str) -> Fixture {
|
||||
let root = temp_root(tag);
|
||||
let dir = root.join("src");
|
||||
write_file(&dir.join("manifest.json"), CF_MANIFEST.as_bytes());
|
||||
write_file(
|
||||
&dir.join("overrides/mods/example.jar"),
|
||||
b"PK\x03\x04 hand-made jar stub",
|
||||
);
|
||||
write_file(
|
||||
&dir.join("overrides/config/known.toml"),
|
||||
b"key = \"value\"\n",
|
||||
);
|
||||
write_file(&dir.join("overrides/config/broken.toml"), CF_BROKEN);
|
||||
let zip = root.join("pack.zip");
|
||||
zip_dir(&dir, &zip);
|
||||
Fixture { dir, zip }
|
||||
}
|
||||
|
||||
/// Modrinth 夹具(`modrinth.index.json` + 三个覆盖目录,含一个半截 jar)。
|
||||
pub(crate) fn modrinth(tag: &str) -> Fixture {
|
||||
let root = temp_root(tag);
|
||||
let dir = root.join("src");
|
||||
write_file(&dir.join("modrinth.index.json"), mrpack_index().as_bytes());
|
||||
write_file(&dir.join("overrides/mods/half.jar"), MR_HALF_JAR);
|
||||
write_file(&dir.join("overrides/config/known.toml"), b"shared = true\n");
|
||||
write_file(
|
||||
&dir.join("client-overrides/config/client-only.toml"),
|
||||
b"client = true\n",
|
||||
);
|
||||
write_file(
|
||||
&dir.join("server-overrides/config/server.properties"),
|
||||
b"online-mode=true\n",
|
||||
);
|
||||
let zip = root.join("pack.mrpack");
|
||||
zip_dir(&dir, &zip);
|
||||
Fixture { dir, zip }
|
||||
}
|
||||
|
||||
/// MultiMC 夹具:**目录形式**(真实形态就是实例目录,不是 zip),含一个零字节 jar。
|
||||
pub(crate) fn multimc(tag: &str) -> PathBuf {
|
||||
let root = temp_root(tag).join("instance");
|
||||
write_file(&root.join("mmc-pack.json"), MMC_PACK.as_bytes());
|
||||
write_file(&root.join("instance.cfg"), MMC_INSTANCE.as_bytes());
|
||||
write_file(
|
||||
&root.join(".minecraft/mods/example.jar"),
|
||||
b"PK\x03\x04 hand-made jar stub",
|
||||
);
|
||||
write_file(&root.join(".minecraft/mods/broken.jar"), b"");
|
||||
write_file(
|
||||
&root.join(".minecraft/config/known.toml"),
|
||||
b"key = \"value\"\n",
|
||||
);
|
||||
root
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
//! `addon::pack` 的单元测试:夹具读回、未知字段往返、探测优先级、扩展点、恶意样本。
|
||||
//!
|
||||
//! **全部离线**:没有网络调用,也没有真实整合包样本(见 `fixtures` 的来源声明)。
|
||||
|
||||
mod detect;
|
||||
mod extend;
|
||||
mod fixtures;
|
||||
mod roundtrip;
|
||||
mod safety;
|
||||
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
/// 本次进程内唯一的临时根目录(沿用仓库既有约定:不引 `tempfile`)。
|
||||
pub(crate) fn temp_root(tag: &str) -> PathBuf {
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
std::env::temp_dir().join(format!("ull-pack-{}-{tag}-{unique}", std::process::id()))
|
||||
}
|
||||
|
||||
/// 写一个夹具文件(自动建父目录)。
|
||||
pub(crate) fn write_file(path: &Path, bytes: &[u8]) {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).expect("建夹具目录");
|
||||
}
|
||||
std::fs::write(path, bytes).expect("写夹具文件");
|
||||
}
|
||||
|
||||
/// 把一个目录打成 zip(`Stored` 压缩,方便测试直接改字节)。
|
||||
pub(crate) fn zip_dir(source: &Path, target: &Path) {
|
||||
if let Some(parent) = target.parent() {
|
||||
std::fs::create_dir_all(parent).expect("建 zip 目录");
|
||||
}
|
||||
let file = File::create(target).expect("建 zip");
|
||||
let mut writer = zip::ZipWriter::new(file);
|
||||
let options =
|
||||
zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||
let mut pending = vec![PathBuf::new()];
|
||||
while let Some(relative) = pending.pop() {
|
||||
let dir = source.join(&relative);
|
||||
let mut children: Vec<_> = std::fs::read_dir(&dir)
|
||||
.expect("读夹具目录")
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.expect("目录项");
|
||||
children.sort_by_key(std::fs::DirEntry::file_name);
|
||||
for child in children {
|
||||
let relative = relative.join(child.file_name());
|
||||
if child.file_type().expect("夹具类型").is_dir() {
|
||||
pending.push(relative);
|
||||
continue;
|
||||
}
|
||||
let bytes = std::fs::read(child.path()).expect("读夹具文件");
|
||||
let entry = relative.to_string_lossy().replace('\\', "/");
|
||||
writer.start_file(entry, options).expect("写 zip 条目");
|
||||
writer.write_all(&bytes).expect("写 zip 内容");
|
||||
}
|
||||
}
|
||||
writer.finish().expect("收尾 zip");
|
||||
}
|
||||
|
||||
/// 用一张显式条目表打 zip(恶意样本用)。
|
||||
pub(crate) fn write_zip(target: &Path, entries: &[(&str, &[u8])]) {
|
||||
if let Some(parent) = target.parent() {
|
||||
std::fs::create_dir_all(parent).expect("建 zip 目录");
|
||||
}
|
||||
let file = File::create(target).expect("建 zip");
|
||||
let mut writer = zip::ZipWriter::new(file);
|
||||
let options =
|
||||
zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||
for (name, bytes) in entries {
|
||||
writer.start_file(*name, options).expect("写 zip 条目");
|
||||
writer.write_all(bytes).expect("写 zip 内容");
|
||||
}
|
||||
writer.finish().expect("收尾 zip");
|
||||
}
|
||||
|
||||
/// 打一个带**符号链接条目**的 zip(`S_IFLNK` + 目标当内容,与真实恶意包同构)。
|
||||
pub(crate) fn write_zip_with_symlink(
|
||||
target: &Path,
|
||||
entries: &[(&str, &[u8])],
|
||||
link_name: &str,
|
||||
link_target: &str,
|
||||
) {
|
||||
if let Some(parent) = target.parent() {
|
||||
std::fs::create_dir_all(parent).expect("建 zip 目录");
|
||||
}
|
||||
let file = File::create(target).expect("建 zip");
|
||||
let mut writer = zip::ZipWriter::new(file);
|
||||
let options =
|
||||
zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||
for (name, bytes) in entries {
|
||||
writer.start_file(*name, options).expect("写 zip 条目");
|
||||
writer.write_all(bytes).expect("写 zip 内容");
|
||||
}
|
||||
writer
|
||||
.add_symlink(link_name, link_target, options)
|
||||
.expect("写符号链接条目");
|
||||
writer.finish().expect("收尾 zip");
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
//! 读回模型 + "导出后再读"的往返一致性:**未知字段不丢**就在这些断言里被钉住。
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use super::fixtures;
|
||||
use crate::addon::pack::archive::PackArchive;
|
||||
use crate::addon::pack::curseforge::CURSEFORGE;
|
||||
use crate::addon::pack::error::PackErrorKind;
|
||||
use crate::addon::pack::format::{PackFormat, PackPayload, PackRegistry};
|
||||
use crate::addon::pack::manifest::{EnvSupport, PackManifest, PackSide};
|
||||
use crate::addon::pack::modrinth::MODRINTH;
|
||||
use crate::addon::pack::multimc::MULTIMC;
|
||||
use crate::addon::LoaderKind;
|
||||
use crate::download::{verify, HashAlgo};
|
||||
|
||||
fn sha1_hex(bytes: &[u8]) -> String {
|
||||
verify::digest_bytes(HashAlgo::Sha1, bytes)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curseforge_reads_into_model() {
|
||||
let fixture = fixtures::curseforge("cf-read");
|
||||
let pack = PackArchive::open_default(&fixture.zip).expect("打开 CF 夹具");
|
||||
let manifest = CURSEFORGE.read(&pack).expect("读 CF 清单");
|
||||
|
||||
assert_eq!(manifest.name, fixtures::CF_NAME);
|
||||
assert_eq!(manifest.version.as_deref(), Some("1.0.0"));
|
||||
assert_eq!(manifest.author.as_deref(), Some("AskaEth"));
|
||||
assert_eq!(manifest.summary, None, "CF 没有 summary 字段");
|
||||
assert_eq!(manifest.minecraft.as_deref(), Some("1.20.1"));
|
||||
assert_eq!(manifest.game, None, "CF 没有 game 字段");
|
||||
assert_eq!(manifest.format_version, Some(1));
|
||||
|
||||
assert_eq!(manifest.loaders.len(), 1);
|
||||
let loader = &manifest.loaders[0];
|
||||
assert_eq!(loader.raw, "forge-47.2.0");
|
||||
assert_eq!(loader.kind, Some(LoaderKind::Forge));
|
||||
assert_eq!(loader.version.as_deref(), Some("47.2.0"));
|
||||
assert!(loader.primary, "primary=true 必须保住");
|
||||
|
||||
// CF 官方 files[] 没有路径/URL/摘要:模型如实记成 None,不猜。
|
||||
assert_eq!(manifest.files.len(), 2);
|
||||
let required = &manifest.files[0];
|
||||
assert_eq!(required.project_id, Some(238222));
|
||||
assert_eq!(required.file_id, Some(5102025));
|
||||
assert_eq!(required.path, None);
|
||||
assert_eq!(required.client, EnvSupport::Required);
|
||||
assert_eq!(required.key(), "curseforge:238222:5102025");
|
||||
let optional = &manifest.files[1];
|
||||
assert_eq!(
|
||||
optional.client,
|
||||
EnvSupport::Optional,
|
||||
"required=false → Optional"
|
||||
);
|
||||
assert_eq!(optional.server, EnvSupport::Optional);
|
||||
|
||||
// 覆盖目录:3 个文件,含那个刻意截断的 TOML(它没有清单摘要可比,只能记实测值)。
|
||||
assert_eq!(manifest.overrides.len(), 1);
|
||||
let group = &manifest.overrides[0];
|
||||
assert_eq!(group.dir, "overrides");
|
||||
assert_eq!(group.side, PackSide::Both);
|
||||
assert_eq!(group.files.len(), 3);
|
||||
let broken = group.find("config/broken.toml").expect("坏文件也要进清单");
|
||||
assert_eq!(broken.size, fixtures::CF_BROKEN.len() as u64);
|
||||
assert_eq!(broken.sha1, sha1_hex(fixtures::CF_BROKEN));
|
||||
|
||||
// 未知字段:顶层与 `minecraft` 对象各一处。
|
||||
assert_eq!(manifest.extra["customCommunityField"]["since"], json!(2));
|
||||
assert_eq!(manifest.minecraft_extra["recommendedRam"], json!(4096));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curseforge_export_round_trips_unknown_fields() {
|
||||
let fixture = fixtures::curseforge("cf-roundtrip");
|
||||
let pack = PackArchive::open_default(&fixture.zip).expect("打开 CF 夹具");
|
||||
let manifest = CURSEFORGE.read(&pack).expect("读 CF 清单");
|
||||
|
||||
let target = fixture.zip.with_file_name("rewritten.zip");
|
||||
CURSEFORGE
|
||||
.write(&manifest, PackPayload::from_root(&fixture.dir), &target)
|
||||
.expect("导出 CF 包");
|
||||
|
||||
let written = PackArchive::open_default(&target).expect("打开导出的 CF 包");
|
||||
let reread = CURSEFORGE.read(&written).expect("再读导出的 CF 包");
|
||||
assert_eq!(reread, manifest, "导出→再读必须逐字段相等(含未知字段)");
|
||||
assert_eq!(
|
||||
reread.extra["customCommunityField"]["note"],
|
||||
json!("社区新增字段,必须原样回写")
|
||||
);
|
||||
assert_eq!(reread.minecraft_extra["recommendedRam"], json!(4096));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modrinth_reads_into_model() {
|
||||
let fixture = fixtures::modrinth("mr-read");
|
||||
let pack = PackArchive::open_default(&fixture.zip).expect("打开 mrpack 夹具");
|
||||
let manifest = MODRINTH.read(&pack).expect("读 mrpack 清单");
|
||||
|
||||
assert_eq!(manifest.name, fixtures::MR_NAME);
|
||||
assert_eq!(manifest.version.as_deref(), Some("2.0.0"));
|
||||
assert_eq!(
|
||||
manifest.summary.as_deref(),
|
||||
Some("手造夹具:只验证形状与边界,不是真实导出")
|
||||
);
|
||||
assert_eq!(manifest.game.as_deref(), Some("minecraft"));
|
||||
assert_eq!(manifest.minecraft.as_deref(), Some("1.20.1"));
|
||||
assert_eq!(manifest.author, None, "mrpack 没有 author 字段");
|
||||
|
||||
// `dependencies` 里非 minecraft 的每条都成一条加载器依赖(认不出的也保留)。
|
||||
assert_eq!(manifest.loaders.len(), 2);
|
||||
let fabric = manifest
|
||||
.loader(LoaderKind::Fabric)
|
||||
.expect("要认出 fabric-loader");
|
||||
assert_eq!(fabric.raw, "fabric-loader");
|
||||
assert_eq!(fabric.version.as_deref(), Some("0.15.11"));
|
||||
assert!(fabric.primary);
|
||||
let parchment = manifest
|
||||
.loaders
|
||||
.iter()
|
||||
.find(|loader| loader.raw == "parchment")
|
||||
.expect("社区键也要保留");
|
||||
assert_eq!(parchment.kind, None);
|
||||
assert_eq!(parchment.version.as_deref(), Some("2023.09.03"));
|
||||
|
||||
assert_eq!(manifest.files.len(), 2);
|
||||
let jei = &manifest.files[0];
|
||||
assert_eq!(jei.path.as_deref(), Some("mods/jei.jar"));
|
||||
assert_eq!(jei.size, Some(1_234_567));
|
||||
assert_eq!(jei.client, EnvSupport::Required);
|
||||
assert_eq!(jei.server, EnvSupport::Unsupported);
|
||||
assert_eq!(jei.downloads.len(), 1);
|
||||
assert!(jei.hashes.contains_key("sha256"), "整张摘要表都要留着");
|
||||
assert!(jei.sha512().is_some());
|
||||
assert!(jei.key().starts_with("modrinth:"));
|
||||
assert_eq!(jei.extra["customFileField"], json!("社区新增字段"));
|
||||
let optional = &manifest.files[1];
|
||||
assert!(optional.client.is_optional(), "env.client=optional 要保住");
|
||||
|
||||
// 三个覆盖目录各成一侧。
|
||||
assert_eq!(manifest.overrides.len(), 3);
|
||||
let both = manifest.overrides_for(PackSide::Both).expect("overrides");
|
||||
assert_eq!(both.files.len(), 2);
|
||||
let client = manifest
|
||||
.overrides_for(PackSide::Client)
|
||||
.expect("client-overrides");
|
||||
assert_eq!(client.files.len(), 1);
|
||||
let server = manifest
|
||||
.overrides_for(PackSide::Server)
|
||||
.expect("server-overrides");
|
||||
assert_eq!(server.files.len(), 1);
|
||||
let half = both.find("mods/half.jar").expect("半截 jar 也要进清单");
|
||||
assert_eq!(half.size, fixtures::MR_HALF_JAR.len() as u64);
|
||||
assert_eq!(half.sha1, sha1_hex(fixtures::MR_HALF_JAR));
|
||||
|
||||
assert_eq!(manifest.extra["customPackField"], json!([1, 2, 3]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modrinth_export_round_trips_unknown_fields() {
|
||||
let fixture = fixtures::modrinth("mr-roundtrip");
|
||||
let pack = PackArchive::open_default(&fixture.zip).expect("打开 mrpack 夹具");
|
||||
let manifest = MODRINTH.read(&pack).expect("读 mrpack 清单");
|
||||
|
||||
let target = fixture.zip.with_file_name("rewritten.mrpack");
|
||||
MODRINTH
|
||||
.write(&manifest, PackPayload::from_root(&fixture.dir), &target)
|
||||
.expect("导出 mrpack");
|
||||
|
||||
let written = PackArchive::open_default(&target).expect("打开导出的 mrpack");
|
||||
let reread = MODRINTH.read(&written).expect("再读导出的 mrpack");
|
||||
assert_eq!(reread, manifest, "导出→再读必须逐字段相等(含未知字段)");
|
||||
assert_eq!(reread.extra["customPackField"], json!([1, 2, 3]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multimc_reads_instance_directory() {
|
||||
let root = fixtures::multimc("mmc-read");
|
||||
let pack = PackArchive::open_default(&root).expect("打开 MultiMC 实例目录");
|
||||
assert!(pack.is_dir_source());
|
||||
let manifest = MULTIMC.read(&pack).expect("读 MultiMC 清单");
|
||||
|
||||
assert_eq!(manifest.name, fixtures::MMC_NAME, "包名来自 instance.cfg");
|
||||
assert_eq!(manifest.minecraft.as_deref(), Some("1.20.1"));
|
||||
assert_eq!(manifest.game.as_deref(), Some("minecraft"));
|
||||
assert_eq!(manifest.format_version, Some(1));
|
||||
assert_eq!(manifest.version, None, "MultiMC 的实例没有包版本");
|
||||
|
||||
let fabric = manifest
|
||||
.loader(LoaderKind::Fabric)
|
||||
.expect("组件 uid 要翻译成加载器");
|
||||
assert_eq!(fabric.raw, "net.fabricmc.fabric-loader");
|
||||
assert_eq!(fabric.version.as_deref(), Some("0.15.11"));
|
||||
assert!(fabric.primary);
|
||||
assert!(
|
||||
manifest
|
||||
.loaders
|
||||
.iter()
|
||||
.any(|loader| loader.raw == "org.lwjgl3"),
|
||||
"认不出的组件也要保留"
|
||||
);
|
||||
|
||||
assert!(manifest.files.is_empty(), "MultiMC 没有远端条目");
|
||||
let content = manifest
|
||||
.overrides_for(PackSide::Both)
|
||||
.expect(".minecraft 当覆盖目录");
|
||||
assert_eq!(content.dir, ".minecraft");
|
||||
let broken = content
|
||||
.find("mods/broken.jar")
|
||||
.expect("零字节 jar 也要进清单");
|
||||
assert_eq!(broken.size, 0);
|
||||
assert_eq!(broken.sha1, sha1_hex(b""));
|
||||
assert_eq!(content.files.len(), 3);
|
||||
assert_eq!(manifest.extra["customComponentGraphField"], json!(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multimc_export_is_refused() {
|
||||
let root = super::temp_root("mmc-write");
|
||||
let target = root.join("out.zip");
|
||||
let manifest = PackManifest::new("whatever");
|
||||
let error = MULTIMC
|
||||
.write(&manifest, PackPayload::metadata_only(), &target)
|
||||
.expect_err("MultiMC 格式不支持导出");
|
||||
assert_eq!(error.kind(), PackErrorKind::Unsupported);
|
||||
assert!(!target.exists(), "拒绝导出时不该留下半个文件");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_read_reports_matched_format() {
|
||||
let fixture = fixtures::curseforge("registry-read");
|
||||
let pack = PackArchive::open_default(&fixture.zip).expect("打开 CF 夹具");
|
||||
let (format, manifest) = PackRegistry::builtin().read(&pack).expect("探测 + 读");
|
||||
assert_eq!(format.id(), "curseforge");
|
||||
assert_eq!(manifest.name, fixtures::CF_NAME);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curseforge_without_name_is_a_metadata_error() {
|
||||
let root = super::temp_root("cf-noname");
|
||||
let target = root.join("nope.zip");
|
||||
super::write_zip(
|
||||
&target,
|
||||
&[(
|
||||
"manifest.json",
|
||||
&br#"{"manifestType":"minecraftModpack","minecraft":{"version":"1.20.1"}}"#[..],
|
||||
)],
|
||||
);
|
||||
let pack = PackArchive::open_default(&target).expect("zip 本身可读");
|
||||
assert_eq!(
|
||||
CURSEFORGE.detect(&pack),
|
||||
Some("manifest.json"),
|
||||
"形状对得上就该被探测到"
|
||||
);
|
||||
let error = CURSEFORGE.read(&pack).expect_err("缺 name 必须报错");
|
||||
assert_eq!(error.kind(), PackErrorKind::Metadata);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_entries_export_as_optional_on_both_formats() {
|
||||
// 禁用位(M115 调研 §字段缺口 2)在两边的落法不同,且都不该靠"猜":
|
||||
// CF → `required: false`;mrpack → `env.client: optional`(D10 的映射)。
|
||||
let cf = fixtures::curseforge("disabled-cf");
|
||||
let pack = PackArchive::open_default(&cf.zip).expect("打开 CF 夹具");
|
||||
let mut manifest = CURSEFORGE.read(&pack).expect("读 CF 清单");
|
||||
assert!(!manifest.files[0].disabled, "读包不会凭空造出禁用位");
|
||||
manifest.files[0].disabled = true;
|
||||
let target = cf.zip.with_file_name("disabled.zip");
|
||||
CURSEFORGE
|
||||
.write(&manifest, PackPayload::from_root(&cf.dir), &target)
|
||||
.expect("导出 CF");
|
||||
let written = PackArchive::open_default(&target).expect("打开导出的 CF 包");
|
||||
let reread = CURSEFORGE.read(&written).expect("再读 CF");
|
||||
assert_eq!(
|
||||
reread.files[0].client,
|
||||
EnvSupport::Optional,
|
||||
"CF 用 required=false 表达禁用"
|
||||
);
|
||||
|
||||
let mr = fixtures::modrinth("disabled-mr");
|
||||
let pack = PackArchive::open_default(&mr.zip).expect("打开 mrpack 夹具");
|
||||
let mut manifest = MODRINTH.read(&pack).expect("读 mrpack 清单");
|
||||
assert!(
|
||||
!manifest.files[1].disabled,
|
||||
"env.client=optional 是「可选」,不是「禁用」:两个事实不合并"
|
||||
);
|
||||
manifest.files[0].disabled = true;
|
||||
let target = mr.zip.with_file_name("disabled.mrpack");
|
||||
MODRINTH
|
||||
.write(&manifest, PackPayload::from_root(&mr.dir), &target)
|
||||
.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 的映射一致)"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
//! 恶意样本:每条安全判据都配一条"会失败的包",而不是靠注释保证。
|
||||
//!
|
||||
//! 覆盖:路径穿越四类形状(`..` / 绝对路径 / 反斜杠 / 盘符)、符号链接条目、条目数上限、
|
||||
//! 解压总体积上限、单条目上限、规整后重名、篡改载荷(CRC 不符)、以及**清单字段里的**穿越
|
||||
//! (mrpack `files[].path`、CF `overrides`)。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::{temp_root, write_zip, write_zip_with_symlink};
|
||||
use crate::addon::pack::archive::{PackArchive, PackLimits};
|
||||
use crate::addon::pack::curseforge::CURSEFORGE;
|
||||
use crate::addon::pack::error::PackErrorKind;
|
||||
use crate::addon::pack::format::PackFormat;
|
||||
use crate::addon::pack::manifest::{PackLoader, PackManifest, PackOverrides, PackSide};
|
||||
use crate::addon::pack::modrinth::MODRINTH;
|
||||
|
||||
/// 打一个只有一条恶意条目的 zip。
|
||||
fn single_entry(tag: &str, name: &str) -> PathBuf {
|
||||
let target = temp_root(tag).join("evil.zip");
|
||||
write_zip(&target, &[(name, &b"payload"[..])]);
|
||||
target
|
||||
}
|
||||
|
||||
fn open_error(target: &Path) -> crate::addon::pack::error::PackError {
|
||||
PackArchive::open_default(target).expect_err("恶意包必须被拒")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_parent_directory_entries() {
|
||||
let target = single_entry("trav-parent", "../evil.txt");
|
||||
let error = open_error(&target);
|
||||
assert_eq!(
|
||||
error.kind(),
|
||||
PackErrorKind::Traversal,
|
||||
"{}",
|
||||
error.message()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_absolute_entries() {
|
||||
let target = single_entry("trav-absolute", "/etc/passwd");
|
||||
let error = open_error(&target);
|
||||
assert_eq!(
|
||||
error.kind(),
|
||||
PackErrorKind::Traversal,
|
||||
"{}",
|
||||
error.message()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_backslash_separators() {
|
||||
// Win32 把 `\` 当分隔符:按 `/` 切是"一个普通名字",落盘却跳三层。
|
||||
let target = single_entry("trav-backslash", "mods\\..\\..\\evil.jar");
|
||||
let error = open_error(&target);
|
||||
assert_eq!(
|
||||
error.kind(),
|
||||
PackErrorKind::Traversal,
|
||||
"{}",
|
||||
error.message()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_drive_letter_and_ads_entries() {
|
||||
for (tag, name) in [
|
||||
("trav-drive", "C:/Windows/evil.dll"),
|
||||
("trav-ads", "mods/jei.jar:Zone.Identifier"),
|
||||
] {
|
||||
let target = single_entry(tag, name);
|
||||
let error = open_error(&target);
|
||||
assert_eq!(
|
||||
error.kind(),
|
||||
PackErrorKind::Traversal,
|
||||
"{name}: {}",
|
||||
error.message()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_symlink_entries() {
|
||||
let target = temp_root("symlink").join("link.zip");
|
||||
write_zip_with_symlink(
|
||||
&target,
|
||||
&[("readme.txt", &b"x"[..])],
|
||||
"mods/evil.jar",
|
||||
"../../../etc/passwd",
|
||||
);
|
||||
let error = open_error(&target);
|
||||
assert_eq!(error.kind(), PackErrorKind::Symlink, "{}", error.message());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_too_many_entries() {
|
||||
let target = temp_root("limit-count").join("many.zip");
|
||||
write_zip(
|
||||
&target,
|
||||
&[
|
||||
("a.txt", &b"1"[..]),
|
||||
("b.txt", &b"2"[..]),
|
||||
("c.txt", &b"3"[..]),
|
||||
("d.txt", &b"4"[..]),
|
||||
],
|
||||
);
|
||||
let error = PackArchive::open(&target, PackLimits::new(3, 1024 * 1024, 1024 * 1024))
|
||||
.expect_err("条目数超限必须拒");
|
||||
assert_eq!(error.kind(), PackErrorKind::Limits, "{}", error.message());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_uncompressed_volume_over_limit() {
|
||||
let target = temp_root("limit-volume").join("big.zip");
|
||||
let payload = vec![b'a'; 4096];
|
||||
write_zip(
|
||||
&target,
|
||||
&[("a.bin", payload.as_slice()), ("b.bin", payload.as_slice())],
|
||||
);
|
||||
let error = PackArchive::open(&target, PackLimits::new(16, 4096, 4096))
|
||||
.expect_err("解压总体积超限必须拒");
|
||||
assert_eq!(error.kind(), PackErrorKind::Limits, "{}", error.message());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_a_single_oversized_entry() {
|
||||
let target = temp_root("limit-entry").join("big.zip");
|
||||
let payload = vec![b'a'; 2048];
|
||||
write_zip(&target, &[("a.bin", payload.as_slice())]);
|
||||
let error = PackArchive::open(&target, PackLimits::new(16, 1024 * 1024, 512))
|
||||
.expect_err("单条目超限必须拒");
|
||||
assert_eq!(error.kind(), PackErrorKind::Limits, "{}", error.message());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_names_that_collide_after_normalization() {
|
||||
// `./a.txt` 与 `a.txt` 规整后同名:留着就是"读到的和落盘的"不是同一个文件的经典混淆。
|
||||
let target = temp_root("dup-name").join("dup.zip");
|
||||
write_zip(&target, &[("./a.txt", &b"1"[..]), ("a.txt", &b"2"[..])]);
|
||||
let error = open_error(&target);
|
||||
assert_eq!(error.kind(), PackErrorKind::Metadata, "{}", error.message());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tampered_payload_is_not_silently_accepted() {
|
||||
// 中央目录没问题、数据被改过(CRC 不符):打开能过,读内容必须报错。
|
||||
const MARKER: &[u8] = b"UNIQUE-PAYLOAD-MARKER";
|
||||
let target = temp_root("tampered").join("tampered.zip");
|
||||
write_zip(
|
||||
&target,
|
||||
&[
|
||||
(
|
||||
"manifest.json",
|
||||
br#"{"manifestType":"minecraftModpack","manifestVersion":1,
|
||||
"name":"tampered pack","minecraft":{"version":"1.20.1"},"files":[],
|
||||
"overrides":"overrides"}"#,
|
||||
),
|
||||
("overrides/config/bad.toml", MARKER),
|
||||
],
|
||||
);
|
||||
let mut bytes = std::fs::read(&target).expect("读 zip");
|
||||
let at = bytes
|
||||
.windows(MARKER.len())
|
||||
.position(|window| window == MARKER)
|
||||
.expect("找得到载荷");
|
||||
bytes[at] = b'X';
|
||||
std::fs::write(&target, bytes).expect("写回 zip");
|
||||
|
||||
let pack = PackArchive::open_default(&target).expect("中央目录完好,打开该成功");
|
||||
let error = pack
|
||||
.read("overrides/config/bad.toml")
|
||||
.expect_err("载荷被改过,读内容必须失败");
|
||||
assert!(
|
||||
matches!(error.kind(), PackErrorKind::Io | PackErrorKind::Metadata),
|
||||
"实际大类:{:?}({})",
|
||||
error.kind(),
|
||||
error.message()
|
||||
);
|
||||
// 整包读取会算覆盖文件的 SHA-1,因此也必须失败,而不是"算出一个错的哈希"。
|
||||
assert!(CURSEFORGE.read(&pack).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mrpack_file_path_traversal_is_rejected() {
|
||||
let target = temp_root("mr-trav").join("evil.mrpack");
|
||||
let index = r#"{"game":"minecraft","formatVersion":1,"versionId":"1.0","name":"evil pack",
|
||||
"files":[{"path":"../evil.jar",
|
||||
"hashes":{"sha512":"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"},
|
||||
"downloads":["https://example.invalid/evil.jar"]}],
|
||||
"dependencies":{"minecraft":"1.20.1"}}"#;
|
||||
write_zip(&target, &[("modrinth.index.json", index.as_bytes())]);
|
||||
let pack = PackArchive::open_default(&target).expect("zip 本身可读");
|
||||
let error = MODRINTH.read(&pack).expect_err("清单里的穿越路径必须拒");
|
||||
assert_eq!(
|
||||
error.kind(),
|
||||
PackErrorKind::Traversal,
|
||||
"{}",
|
||||
error.message()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curseforge_overrides_dir_traversal_is_rejected() {
|
||||
let target = temp_root("cf-trav").join("evil.zip");
|
||||
write_zip(
|
||||
&target,
|
||||
&[(
|
||||
"manifest.json",
|
||||
br#"{"manifestType":"minecraftModpack","manifestVersion":1,"name":"evil pack",
|
||||
"minecraft":{"version":"1.20.1"},"files":[],"overrides":"../../etc"}"#,
|
||||
)],
|
||||
);
|
||||
let pack = PackArchive::open_default(&target).expect("zip 本身可读");
|
||||
let error = CURSEFORGE
|
||||
.read(&pack)
|
||||
.expect_err("overrides 目录指向包外必须拒");
|
||||
assert_eq!(
|
||||
error.kind(),
|
||||
PackErrorKind::Traversal,
|
||||
"{}",
|
||||
error.message()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_side_path_check_catches_hand_built_models() {
|
||||
// 模型可能不是我们读出来的(UI/AI 手搓):导出前必须再查一遍。
|
||||
let mut manifest = PackManifest::new("手搓模型");
|
||||
manifest.overrides.push(PackOverrides {
|
||||
dir: "overrides".to_string(),
|
||||
side: PackSide::Both,
|
||||
files: Vec::new(),
|
||||
});
|
||||
manifest
|
||||
.loaders
|
||||
.push(PackLoader::parse_curse("forge-47.2.0", true));
|
||||
assert!(manifest.check_paths().is_ok());
|
||||
|
||||
manifest.overrides[0].dir = "../escape".to_string();
|
||||
assert_eq!(
|
||||
manifest
|
||||
.check_paths()
|
||||
.expect_err("越界目录必须被写侧检查拦下")
|
||||
.kind(),
|
||||
PackErrorKind::Traversal
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_package_is_an_io_error() {
|
||||
let target = temp_root("missing").join("does-not-exist.zip");
|
||||
let error = open_error(&target);
|
||||
assert_eq!(error.kind(), PackErrorKind::Io, "{}", error.message());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directory_source_rejects_symlinks_when_the_platform_can_make_them() {
|
||||
// Windows 建符号链接需要开发者模式/管理员:建不出来就跳过,但绝不静默当通过。
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let root = temp_root("dir-symlink").join("instance");
|
||||
std::fs::create_dir_all(&root).expect("建目录");
|
||||
std::fs::write(root.join("mmc-pack.json"), b"{}").expect("写文件");
|
||||
std::os::unix::fs::symlink("/etc/passwd", root.join("escape")).expect("建符号链接");
|
||||
let error = open_error(&root);
|
||||
assert_eq!(error.kind(), PackErrorKind::Symlink, "{}", error.message());
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
eprintln!(
|
||||
"跳过:当前平台建符号链接需要额外权限(目录侧的检查逻辑与 zip 侧共用同一条判据)"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user