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:
AskaEth committed 2026-11-04 00:20:24 +08:00
1 parent 40a4c06d42
commit b34e86bba5
20 files changed
+3729 -1

No files matched your search

+9 -1
View File
@@ -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,
};
+64
View File
@@ -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;
+259
View File
@@ -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)
}
+158
View File
@@ -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,
})
}
}
+298
View File
@@ -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)
}
}
+161
View File
@@ -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>;
+290
View File
@@ -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))
}
}
+350
View File
@@ -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());
}
}
}
+347
View File
@@ -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"
))
})
}
+179
View File
@@ -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)
}
+140
View File
@@ -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()
);
}
}
+161
View File
@@ -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());
}
+170
View File
@@ -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
}
+101
View File
@@ -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 的映射一致)"
);
}
+274
View File
@@ -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 侧共用同一条判据)"
);
}
}