Merge branch 'feat/m110-packmodel-canonical-pack-manifest-a'

This commit is contained in:
AskaEth committed 2026-11-04 00:20:27 +08:00
commit 15eec1160d
23 files changed
+4524 -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,
};
+71
View File
@@ -0,0 +1,71 @@
//! 整合包的**格式无关模型**与**格式扩展点**(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`,**只读**)。
//!
//! # 导出面的边界(r1 之后写死)
//!
//! 默认 [`format::PackFormat::write`] **只保证同格式往返**:覆盖目录按模型原样搬,
//! 不做目录映射 / `side` 分派 / 加载器 id 规范化 / `extra` 分桶(都属于 M112)。
//! 表达不了的时候由 [`format::PackFormat::check_overrides`] 直接报 `pack_unsupported`,
//! 而不是产出一个"成功但内容为空"的包。
//!
//! # 刻意不做的事(分工边界)
//!
//! - **不发网络请求**: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, ReadBudget};
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;
+298
View File
@@ -0,0 +1,298 @@
//! 打包输入的安全读取层:[`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` 规整后撞车) | 规整后的名字重复即拒绝 |
//!
//! **两条检查各补一条对方没有的规则**(谁也不能省):
//!
//! - 第一检管**平台无关**的形状:空名、`\0`、`\`、`:`、前导 `/`、任何 `..` 段。
//! `enclosed_name()` 在这一点上更松——它允许"深度 0 处的根目录",也就是 `"/etc/passwd"`
//! 会返回 `Some("/etc/passwd")`,绝对路径必须由我们先干掉。
//! - 第二检管 **Win32 命名空间**:DOS 设备名(`CON` / `NUL` / `AUX` / `COM1..9` / `LPT1..9`)、
//! 结尾的点或空格(`a.`、`a `)、`< > " | ? *` 与控制字符。这些第一检全放行,而它们会让
//! 落盘时撞到设备、或与另一个名字等价。第二检只在 **Windows 宿主**上生效(`zip` 的
//! `cfg(windows)` 分支),所以 `tests/safety.rs` 里对应的用例也是 `#[cfg(windows)]`。
//!
//! 也就是说:`CON` / `NUL` 这类在 Windows 上**是拦的**(由第二检),非 Windows 宿主上不拦
//! (那里它们只是普通文件名,拦了反而拒掉合法包)。
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, ReadBudget, 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, None, 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)`。
///
/// 单条读取只受 [`PackLimits::max_entry_bytes`] 约束;要跨条目累计限额的批量读取
/// 请用 [`Self::override_files`],或自己建 [`ReadBudget`] 交给 [`Self::override_files_with`]。
pub fn sha1(&self, name: &str) -> PackResult<Option<String>> {
self.sha1_with(name, None)
}
/// 算 SHA-1 并从 `budget` 里扣减实际读出的字节(批量读取用)。
fn sha1_with(&self, name: &str, budget: Option<&ReadBudget>) -> 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
)));
}
if let Some(budget) = budget {
budget.spend(read as u64, name)?;
}
hasher.update(&buffer[..read]);
}
Ok(Some(hasher.finish()))
}
}
}
/// 收集一个覆盖目录下的文件(相对该目录的路径 + SHA-1 + 字节数),按路径排序。
///
/// 目录不存在时返回空表——"包没带 overrides"不是错误。
///
/// 这是**唯一会批量解压的 API**,所以它自己持有一个累计预算
/// (`PackLimits::max_uncompressed_bytes`),本次调用内逐条按实际读出的字节扣减:
/// 声明大小撒谎的包不会靠"很多条各自合规"把 CPU 时间耗光(r1 p3-4)。
///
/// **额度是"每次调用一份",不是"整包一份"**(r2 p3-b):格式读者若对多个覆盖目录各调一次
/// (mrpack 会连调三次:`overrides` / `client-overrides` / `server-overrides`),理论上限
/// 是 `max_uncompressed_bytes × 调用次数`。要整包共用一份额度,就自己建 [`ReadBudget`]
/// 并走 [`Self::override_files_with`];`open` 阶段的"声明总量"仍然是整包只判一次。
pub fn override_files(&self, dir: &str) -> PackResult<Vec<OverrideFile>> {
let budget = ReadBudget::new(self.limits.max_uncompressed_bytes);
self.override_files_with(dir, &budget)
}
/// 同 [`Self::override_files`],但用调用方给的累计预算(M111 想与别处的解压共用额度时用)。
pub fn override_files_with(
&self,
dir: &str,
budget: &ReadBudget,
) -> 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_with(&entry.name, Some(budget))?
.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,167 @@
//! 读取限额:条目数 / 解压总体积 / 单条目,以及两处封顶实现。
//!
//! # 两阶段限额(打开 vs 读取)
//!
//! 打开阶段([`check_limits`])只能拿**中央目录声明的大小**做判断——它会撒谎。
//! 因此读取阶段还有第二道:单条目按实际读出字节封顶([`read_capped`]),批量读取
//! ([`super::PackArchive::override_files`])再按**累计实际字节**从 [`ReadBudget`] 里扣减。
//! 没有预算的批量读取等于把 `max_uncompressed_bytes` 降级成"每条单独算"——一个 2 万条目、
//! 每条声明 1 字节却解出几十 MiB 的包能把 CPU/时间耗在这里(r1 p3-4)。
use std::cell::Cell;
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,
}
}
}
/// 一次批量读取的**累计**预算(读取阶段,按实际读出的字节扣减)。
///
/// 作用域是"拿到它的那次扫描":`PackArchive::override_files` 每次调用新建一份,
/// 所以要跨多次调用共享额度(例如整包只给一份 `max_uncompressed_bytes`),
/// 必须由调用方自建一个并交给 `override_files_with`(r2 p3-b)。
///
/// 为什么需要它:中央目录声明的大小可以撒谎,单个条目也被限制在
/// [`PackLimits::max_entry_bytes`],但"很多条各自合规"累起来仍可能远超
/// `max_uncompressed_bytes`。批量读取(`override_files`)持有一个预算,边读边扣。
#[derive(Debug)]
pub struct ReadBudget {
remaining: Cell<u64>,
}
impl ReadBudget {
/// 按总预算新建。
#[must_use]
pub fn new(limit: u64) -> Self {
Self {
remaining: Cell::new(limit),
}
}
/// 还剩多少字节。
#[must_use]
pub fn remaining(&self) -> u64 {
self.remaining.get()
}
/// 扣减一次实际读出的字节数;超额报 `pack_limits`。
///
/// 超额时预算**照样扣到 0**(这些字节已经从流里读出来了),后续调用会继续失败——
/// 不给"失败一次、下次又能读一点"的机会。
pub(super) fn spend(&self, bytes: u64, name: &str) -> PackResult<()> {
let remaining = self.remaining.get();
self.remaining.set(remaining.saturating_sub(bytes));
if bytes > remaining {
return Err(PackError::limits(format!(
"{name} 之后累计解压超过上限(本次还需 {bytes} 字节,预算已用尽)"
)));
}
Ok(())
}
}
/// 累计与单条目限额检查(打开阶段)。
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(())
}
/// 带封顶的读:中央目录里声明的大小可以撒谎,这里按**实际读出的字节**封顶。
///
/// 给了 `budget` 就同时从累计预算里扣(批量读取);单条读取给 `None`,
/// 只受 [`PackLimits::max_entry_bytes`] 约束。
pub(super) fn read_capped(
reader: &mut impl Read,
cap: u64,
budget: Option<&ReadBudget>,
name: &str,
) -> PackResult<Vec<u8>> {
let mut bytes = Vec::new();
let mut buffer = [0_u8; 64 * 1024];
let mut total = 0_u64;
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 > cap {
return Err(PackError::limits(format!(
"{name} 实际解压超过单条目上限 {cap}"
)));
}
if let Some(budget) = budget {
budget.spend(read as u64, name)?;
}
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,
})
}
}
+334
View File
@@ -0,0 +1,334 @@
//! 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 专属)。
//! - **跨格式导出不完整**(M112 的活):`modLoaders[].id` 只对 CF 来源是合法形状;
//! MultiMC 的 `net.fabricmc.fabric-loader` / 非加载器组件会被逐字写进去(CF 不认);
//! `overrides` 字段会照抄模型里的目录名(从 MultiMC 转过来是 `.minecraft`)。
//! 按 CF 语义 `overrides` 只是 zip 内**前缀**,导入器把该前缀下的内容铺到游戏目录,
//! 所以内容不会丢;但"前缀目录本身要不要跟着拷一层"取决于导入器实现(Prism/ULL 的实例根
//! 本身就叫 `.minecraft` 时可能多出一层)。同一份模型只有 `read` 出来的那个格式才保证往返。
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) -> PackResult<Option<&'static str>> {
// 文件不存在 ≠ 包坏了:没有 `manifest.json` 就是"不是本格式"。
let Some(value) = pack.read_json(METADATA_ENTRY)? else {
return Ok(None);
};
// 文件在、但不是对象 = 形状坏了(`pack_metadata`),不是"不认识"(r1 p3-2)。
let object = value
.as_object()
.ok_or_else(|| PackError::metadata(format!("{METADATA_ENTRY} 存在但不是 JSON 对象")))?;
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)
}
};
Ok(matched.then_some(METADATA_ENTRY))
}
fn check_overrides(&self, manifest: &PackManifest) -> PackResult<()> {
// CF 只有**一个** `overrides` 目录,且清单里只有一个字段能声明它 ⇒ 只表达得了
// 一个 Both 侧组。Client/Server 分侧是 mrpack 的东西,这里宁可拒也不猜(r1 p2-1)。
let mut both = 0_usize;
for group in &manifest.overrides {
if group.side != PackSide::Both {
return Err(PackError::unsupported(format!(
"CurseForge 只有一个 overrides 目录,表达不了 {} 侧的覆盖内容({} 个文件)",
group.side.as_str(),
group.files.len()
))
.with_hint("先按侧合并(或去掉不该带的组)再导出;分侧目录映射属 M112"));
}
both += 1;
}
if both > 1 {
return Err(PackError::unsupported(
"CurseForge 只有一个 overrides 目录字段,模型里却有多个 Both 侧覆盖组",
)
.with_hint("把它们合成一个组再导出;目录名映射属 M112"));
}
Ok(())
}
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();
// CF 的 `overrides` 字段缺省就是 `"overrides"`(规范默认值),`render` 也总会写它;
// 读侧因此**永远**产出一个 Both 侧组,读写才对称(dir 不存在时 files 为空表)。
let overrides_dir = raw
.overrides
.as_deref()
.filter(|dir| !dir.trim().is_empty())
.unwrap_or(DEFAULT_OVERRIDES);
let overrides_dir = normalize_dir(overrides_dir)?;
let overrides = vec![PackOverrides {
files: pack.override_files(&overrides_dir)?,
dir: overrides_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>;
+389
View File
@@ -0,0 +1,389 @@
//! 格式扩展点:[`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` 有一条反序注册的用例)。
//!
//! # 写:默认实现**只保证同格式往返**(这条是给 M111/M112 的边界声明)
//!
//! 默认实现([`PackFormat::write`])替所有格式做同一件事:写清单 entry([`PackFormat::render`]
//! 的产物)+ 把 `manifest.overrides` 的 `(dir, side, files)` **原样**搬进 zip。因此新格式只要
//! 实现 `metadata_entry` + `render` 就同时获得导出能力;只读格式(MultiMC)不实现
//! `metadata_entry`,写会得到明确的 `crate::addon::pack::error::PackErrorKind::Unsupported`。
//!
//! **它不做目标格式映射**,具体缺四件事(都属于 M112,别以为这一层已经做了):
//!
//! 1. **目录名映射**:mrpack 的覆盖目录名由 `side` 决定(`overrides` / `client-overrides` /
//! `server-overrides`),CF 只有一个 `overrides`(名字由清单给)。`check_overrides` 只保证
//! "布局表达不了就报错",不负责改名;
//! 2. **`side` 合并/分派**:CF 表达不了 Client/Server 分侧,现在是**拒**而不是合并;
//! 3. **加载器 id 规范化**:CF 要 `forge-47.2.0` / `fabric-0.15.11`,mrpack 要
//! `forge` / `fabric-loader` 这类键。`render` 只按模型里的 `raw`/`kind` 写,跨格式转出来的
//! id 形态可能不被目标平台接受(尤其 MultiMC 的 `net.fabricmc.fabric-loader`);
//! 4. **`extra` 不按格式分桶**:模型只有一份未知字段集合,跨格式导出会把别的格式的未知键
//! 一起带过去。
//!
//! 换句话说:`X.read` → `X.write` 是可靠往返(有逐字段相等的测试);`X.read` → `Y.write`
//! 要么被 `check_overrides` 拒掉,要么"结构合法但语义不完整"。**要跨格式导出,先做映射。**
use std::fmt::Debug;
use std::fs::File;
use std::io::Write;
use std::path::{Path, PathBuf};
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` 与本次命中的证据)。
///
/// **清单在、但读不出来**时必须返回 `Err`(`pack_metadata` 一类),不要用
/// `.ok().flatten()` 吞掉:那样"包坏了"会被上层报成"没有格式认领",M111 的失败分级
/// 就会把可以重下文件的包当成不支持(r1 p3-2)。
fn detect(&self, pack: &PackArchive) -> PackResult<Option<&'static str>>;
/// 读成中间模型。
fn read(&self, pack: &PackArchive) -> PackResult<PackManifest>;
/// 把模型渲染成清单 JSON(未知字段在这里回写)。
fn render(&self, manifest: &PackManifest) -> PackResult<String>;
/// 这份模型的**覆盖目录集合**能不能被本格式表达(默认 `write` 在写第一个字节前调用)。
///
/// 默认实现不限制("目录名原样写进包里"对同格式往返是对的)。凡是有**固定目录布局**
/// 的格式必须收紧:mrpack 的三个目录由 `side` 决定,CF 只有一个 `overrides` 目录。
/// 不收紧的后果不是报错而是**静默丢内容**——把一份 MultiMC 实例写成 `.mrpack` 时,
/// 文件会落在 `.minecraft/...`,而 mrpack 导入器只认
/// `overrides/` / `client-overrides/` / `server-overrides/`,读出来 mods 数量为 0(r1 p2-1)。
fn check_overrides(&self, manifest: &PackManifest) -> PackResult<()> {
let _ = manifest;
Ok(())
}
/// 导出一个整包(清单 + 覆盖内容)。
///
/// 默认实现做四件事:①只读格式直接拒;②[`Self::check_overrides`] 挡跨格式导出;
/// ③`render` 出清单;④写进**临时文件**再 `rename` 到 `target`(失败不留半个包)。
///
/// # 只保证同格式往返(写给 M111/M112)
///
/// 默认 `write` 的覆盖目录策略是"把 `manifest.overrides` 里 `(dir, side, files)`
/// 原样再打一遍",也就是"读出来的包按原样打包"。它**不做**目标格式的目录映射、`side`
/// 分派、加载器 id 规范化,也**不按格式分桶** `extra`。所以:
///
/// - 同格式往返(`X.read` → `X.write`)可靠,有逐字段相等的测试;
/// - 跨格式导出(`X.read` → `Y.write`)在布局不同时被 [`Self::check_overrides`] 拒掉;
/// 但"布局恰好兼容、语义不兼容"的部分(加载器 id 形态、`extra` 里别的格式的未知键)
/// **仍会被原样带过去**——那是 M112 的活,这一层不替谁做映射。
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 格式"));
};
// 先判"能不能表达",再碰磁盘:跨格式导出宁可报错,也不产出空包。
self.check_overrides(manifest)?;
// 模型可能来自外部构造:搬内容前把每条路径重查一遍。
manifest.check_paths()?;
let json = self.render(manifest)?;
// 临时文件 + rename:中途失败时目标路径上不会留下"看起来能用"的半个 zip(r1 p3-1)。
let temp = temporary_path(target);
match write_container(self.id(), entry, &json, manifest, payload, &temp) {
Ok(()) => std::fs::rename(&temp, target).map_err(|error| {
let _ = std::fs::remove_file(&temp);
PackError::io(format!(
"把 {} 改名成 {} 失败:{error}",
temp.display(),
target.display()
))
}),
Err(error) => {
let _ = std::fs::remove_file(&temp);
Err(error)
}
}
}
}
/// 临时文件路径(与目标同目录,`rename` 才能原子替换)。
fn temporary_path(target: &Path) -> PathBuf {
let mut name = target.file_name().map_or_else(
|| std::ffi::OsString::from("pack"),
std::ffi::OsStr::to_os_string,
);
name.push(format!(".{}.part", std::process::id()));
target.with_file_name(name)
}
/// 真正写 zip 的那一段(失败了由调用方删临时文件)。
fn write_container(
format_id: &str,
entry: &'static str,
json: &str,
manifest: &PackManifest,
payload: PackPayload<'_>,
temp: &Path,
) -> PackResult<()> {
let file = File::create(temp)
.map_err(|error| PackError::io(format!("创建 {} 失败:{error}", temp.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!("收尾 zip 失败:{error}(格式 {format_id})")))?;
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(诊断"这包不标准"用;**只看命中**,单个格式的读错误被忽略——
/// 要区分"包坏了"与"不认识"请用 [`Self::detect`])。
#[must_use]
pub fn detect_all(&self, pack: &PackArchive) -> Vec<&'static str> {
self.formats
.iter()
.filter(|format| matches!(format.detect(pack), Ok(Some(_))))
.map(|format| format.id())
.collect()
}
/// 按优先级选第一个命中的格式。
///
/// 错误语义(M111 的失败分级依赖这两条的区别):
///
/// - **一个都不命中、但某个格式的清单读不出来** → 返回那个格式的错误
/// (`pack_metadata` 一类:包坏了,换格式也没用);
/// - **一个都不命中、也没有任何格式的清单在场** → `pack_unsupported`
/// (真的不认识这份包);
/// - 命中优先于失败:包里躺着一份别人的坏 `manifest.json` 不影响真的 `.mrpack` 被认出来。
pub fn detect(&self, pack: &PackArchive) -> PackResult<PackMatch> {
let mut matched: Vec<(&'static dyn PackFormat, &'static str)> = Vec::new();
// 命中优先于"某个格式的清单坏了":包里躺着一份别人的坏 manifest.json 不该毁掉它。
let mut failures: Vec<PackError> = Vec::new();
for format in self.formats.iter().copied() {
match format.detect(pack) {
Ok(Some(evidence)) => matched.push((format, evidence)),
Ok(None) => {}
// 清单在、但读不出来 = 包坏了(`pack_metadata`),不是"不认识这个格式"。
Err(error) => failures.push(error),
}
}
if matched.is_empty() {
if let Some(error) = failures.into_iter().next() {
return Err(error);
}
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))
}
}
+377
View File
@@ -0,0 +1,377 @@
//! 与格式无关的中间模型 [`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(())
}
/// 撞同一实例路径的覆盖条目:`(路径, 涉及的侧, 条目份数)`,按路径排序。
///
/// **两种冲突都要报**(r2 p2-2:上一版按侧去重,把"同侧撞车"这条能力换掉了):
///
/// - **跨侧**:`overrides/`(Both)与 `client-overrides/`(Client)写同一个实例路径——
/// mrpack 唯一的真实冲突。按 `(side, path)` 计数会恒返回空表(两侧键天然不同)。
/// - **同侧**:两个同侧组撞同一路径,或一个组里同一路径列了两次(模型被合并时才出现)。
/// 按侧去重会把这份重复折叠掉,于是同样"查不出来"。
///
/// 判据:`侧数 > 1 || 条份数 > 侧数`(后者即同侧重复)。冲突由导入器决定优先级,
/// 本模块只报告、不选边。
#[must_use]
pub fn duplicate_override_paths(&self) -> Vec<(String, Vec<PackSide>, usize)> {
struct Slot {
sides: Vec<PackSide>,
occurrences: usize,
}
let mut slots: HashMap<&str, Slot> = HashMap::new();
for (side, file) in self.override_files() {
let slot = slots.entry(file.path.as_str()).or_insert(Slot {
sides: Vec::new(),
occurrences: 0,
});
slot.occurrences += 1;
if !slot.sides.contains(&side) {
slot.sides.push(side);
}
}
let mut duplicated: Vec<(String, Vec<PackSide>, usize)> = slots
.into_iter()
.filter(|(_, slot)| slot.sides.len() > 1 || slot.occurrences > slot.sides.len())
.map(|(path, mut slot)| {
slot.sides.sort_by_key(|side| side.as_str());
(path.to_string(), slot.sides, slot.occurrences)
})
.collect();
duplicated.sort_by(|left, right| left.0.cmp(&right.0));
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());
}
}
}
+378
View File
@@ -0,0 +1,378 @@
//! 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) -> PackResult<Option<&'static str>> {
let Some(value) = pack.read_json(METADATA_ENTRY)? else {
return Ok(None);
};
let object = value
.as_object()
.ok_or_else(|| PackError::metadata(format!("{METADATA_ENTRY} 存在但不是 JSON 对象")))?;
// 有 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));
Ok(matched.then_some(METADATA_ENTRY))
}
fn check_overrides(&self, manifest: &PackManifest) -> PackResult<()> {
// mrpack 的覆盖目录名由 side 决定,且每侧最多一个:不符合就别写(r1 p2-1)。
let mut seen: Vec<PackSide> = Vec::new();
for group in &manifest.overrides {
let expected = group.side.mrpack_dir();
if group.dir != expected {
return Err(PackError::unsupported(format!(
"Modrinth 的覆盖目录名由侧决定:{} 侧必须是 {expected:?},模型里是 {:?}",
group.side.as_str(),
group.dir
))
.with_hint(
"按 side 分派到 overrides / client-overrides / server-overrides 属 M112;\
不同格式的实例目录(如 MultiMC 的 .minecraft)不能直接搬过来",
));
}
if seen.contains(&group.side) {
return Err(PackError::unsupported(format!(
"Modrinth 每个侧只能有一个覆盖目录,{} 侧出现了多次",
group.side.as_str()
)));
}
seen.push(group.side);
}
Ok(())
}
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"
))
})
}
+183
View File
@@ -0,0 +1,183 @@
//! 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) -> PackResult<Option<&'static str>> {
let Some(value) = pack.read_json(METADATA_ENTRY)? else {
return Ok(None);
};
let object = value
.as_object()
.ok_or_else(|| PackError::metadata(format!("{METADATA_ENTRY} 存在但不是 JSON 对象")))?;
// 形状校验:必须有非空 components 数组(MultiMC 的组件图是它的身份证)。
let matched = object
.get("components")
.and_then(Value::as_array)
.is_some_and(|components| !components.is_empty());
Ok(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()
);
}
}
+223
View File
@@ -0,0 +1,223 @@
//! 探测优先级:顺序写死在注册表里,用例把"谁赢"和"为什么赢"都钉住。
//!
//! 判据分三层,缺一层都可能被"看起来对"糊过去:
//! 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).expect("mmc-pack.json 可读"),
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());
}
#[test]
fn a_corrupt_manifest_is_a_metadata_error_not_unsupported() {
// r1 p3-2:清单在、但读不出来 = 包坏了(`pack_metadata`)。之前三个 detect 都用
// `.ok().flatten()` 吞掉解析错误,结果被报成「没有格式认领」——M111 的失败分级会据此
// 把"可以重下文件"的包判成"不支持"。
let target = temp_root("corrupt-manifest").join("bad.zip");
write_zip(&target, &[("manifest.json", &br#"{"name": "#[..])]);
let pack = PackArchive::open_default(&target).expect("zip 本身可读");
let error = PackRegistry::builtin()
.detect(&pack)
.expect_err("坏清单必须报错");
assert_eq!(error.kind(), PackErrorKind::Metadata, "{error}");
assert!(
!error.message().contains("没有格式认领"),
"不许把「包坏了」说成「不认识」:{}",
error.message()
);
}
#[test]
fn a_corrupt_foreign_manifest_does_not_hide_a_valid_mrpack() {
// 命中优先于失败:包里躺着一份别人的坏 manifest.json,不该让真 mrpack 也变得不可用。
let target = temp_root("corrupt-foreign").join("mixed.zip");
write_zip(
&target,
&[
("manifest.json", &br#"{"name": "#[..]),
("modrinth.index.json", MR_INDEX),
],
);
let pack = PackArchive::open_default(&target).expect("zip 本身可读");
let matched = PackRegistry::builtin()
.detect(&pack)
.expect("mrpack 命中应当优先于其它格式的坏文件");
assert_eq!(matched.format().id(), "modrinth");
assert!(matched.also_matched().is_empty(), "CF 没命中(它坏了)");
}
#[test]
fn a_missing_manifest_is_not_an_error() {
// 反向对照:**没有** `manifest.json` 是"不是 CF",不是"包坏了"。
let target = temp_root("no-manifest").join("mmc.zip");
write_zip(
&target,
&[(
"mmc-pack.json",
&br#"{"formatVersion":1,"components":[{"uid":"net.minecraft","version":"1.20.1"}]}"#[..],
)],
);
let pack = PackArchive::open_default(&target).expect("zip 本身可读");
assert_eq!(CURSEFORGE.detect(&pack).expect("缺清单不是错误"), None);
assert_eq!(
PackRegistry::builtin()
.detect(&pack)
.expect("MultiMC 应当命中")
.format()
.id(),
"multimc"
);
}
+156
View File
@@ -0,0 +1,156 @@
//! 导出面的安全闸(r1 p2-1 / p3-1):**跨格式导出必须被拒,失败不留半个包**。
//!
//! 这一组不测"映射是否做对"(那是 M112),只测"表达不了的时候会不会静默产出空包"。
use super::{fixtures, temp_root};
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};
use crate::addon::pack::manifest::{OverrideFile, PackManifest, PackOverrides, PackSide};
use crate::addon::pack::modrinth::MODRINTH;
use crate::addon::pack::multimc::MULTIMC;
#[test]
fn a_multimc_instance_cannot_be_written_as_mrpack() {
// r1 p2-1 实测过的那条:MultiMC 模型写 mrpack 会返回 Ok,但覆盖内容落在 `.minecraft/...`,
// mrpack 导入器只解压 overrides / client-overrides / server-overrides ⇒ mods 数量为 0。
// 现在必须在写第一个字节之前就拒。
let root = fixtures::multimc("export-mmc-mr");
let pack = PackArchive::open_default(&root).expect("打开实例目录");
let mut manifest = MULTIMC.read(&pack).expect("读 MultiMC 清单");
manifest.version = Some("1.0.0".to_string()); // mrpack 需要 versionId,别让别的错误顶包
let target = root.with_file_name("cross.mrpack");
let error = MODRINTH
.write(&manifest, PackPayload::from_root(&root), &target)
.expect_err("跨格式导出必须被拒,不能产出 mods 为 0 的包");
assert_eq!(error.kind(), PackErrorKind::Unsupported, "{error}");
assert!(
error.message().contains(".minecraft"),
"错误要说清是哪个目录表达不了:{}",
error.message()
);
assert!(error.hint().is_some(), "要给下一步提示(目录映射属 M112)");
assert!(!target.exists(), "被拒时不许留下目标文件");
}
#[test]
fn an_mrpack_with_split_sides_cannot_be_written_as_curseforge() {
// CF 只有一个 `overrides` 目录字段,表达不了 client/server 分侧;现在是拒,不是猜。
let fixture = fixtures::modrinth("export-mr-cf");
let pack = PackArchive::open_default(&fixture.zip).expect("打开 mrpack 夹具");
let manifest = MODRINTH.read(&pack).expect("读 mrpack 清单");
let target = fixture.zip.with_file_name("cross.zip");
let error = CURSEFORGE
.write(&manifest, PackPayload::from_root(&fixture.dir), &target)
.expect_err("CF 表达不了分侧覆盖目录");
assert_eq!(error.kind(), PackErrorKind::Unsupported, "{error}");
assert!(
error.message().contains("client"),
"错误要指出被拒的是哪一侧:{}",
error.message()
);
assert!(!target.exists(), "被拒时不许留下目标文件");
}
#[test]
fn curseforge_refuses_more_than_one_both_side_group() {
// CF 的 `overrides` 只有一个字段:两个 Both 组必然有一个不被清单声明(静默丢内容)。
let mut manifest = PackManifest::new("两个 Both 组");
manifest.minecraft = Some("1.20.1".to_string());
manifest
.overrides
.push(PackOverrides::new("overrides", PackSide::Both));
manifest
.overrides
.push(PackOverrides::new("extra-overrides", PackSide::Both));
let target = temp_root("export-cf-two").join("out.zip");
let error = CURSEFORGE
.write(&manifest, PackPayload::metadata_only(), &target)
.expect_err("多出来的 Both 组会被清单漏掉,必须拒");
assert_eq!(error.kind(), PackErrorKind::Unsupported, "{error}");
assert!(!target.exists(), "被拒时不许留下目标文件");
}
#[test]
fn a_failed_export_leaves_neither_a_half_pack_nor_a_temp_file() {
// r1 p3-1:清单写完了、覆盖文件读不出来 → 目标路径与临时文件都不许留。
let mut manifest = PackManifest::new("缺覆盖文件的模型");
manifest.minecraft = Some("1.20.1".to_string());
manifest.overrides.push(PackOverrides {
dir: "overrides".to_string(),
side: PackSide::Both,
files: vec![OverrideFile {
path: "mods/missing.jar".to_string(),
sha1: "0".repeat(40),
size: 1,
}],
});
let root = temp_root("export-half");
std::fs::create_dir_all(&root).expect("建目录");
let target = root.join("out.zip");
let error = CURSEFORGE
.write(&manifest, PackPayload::from_root(&root), &target)
.expect_err("覆盖文件不存在时必须失败");
assert_eq!(error.kind(), PackErrorKind::Io, "{error}");
assert!(!target.exists(), "失败后目标路径不许存在");
let leftovers: Vec<String> = std::fs::read_dir(&root)
.expect("列目录")
.filter_map(Result::ok)
.map(|entry| entry.file_name().to_string_lossy().to_string())
.collect();
assert!(
leftovers.is_empty(),
"临时文件没清干净(p3-1 回退):{leftovers:?}"
);
}
#[test]
fn same_format_export_is_still_allowed() {
// 反向对照:这两条闸不能把同格式往返也挡掉(那是 r1 明确要保住的)。
let fixture = fixtures::curseforge("export-same-cf");
let pack = PackArchive::open_default(&fixture.zip).expect("打开 CF 夹具");
let manifest = CURSEFORGE.read(&pack).expect("读 CF 清单");
CURSEFORGE
.check_overrides(&manifest)
.expect("CF 自己读出来的模型必须能被 CF 表达");
let fixture = fixtures::modrinth("export-same-mr");
let pack = PackArchive::open_default(&fixture.zip).expect("打开 mrpack 夹具");
let manifest = MODRINTH.read(&pack).expect("读 mrpack 清单");
MODRINTH
.check_overrides(&manifest)
.expect("mrpack 自己读出来的模型必须能被 mrpack 表达");
}
#[test]
fn mrpack_refuses_two_override_groups_for_the_same_side() {
// r2 p3-a:mrpack 的「每侧只能有一个覆盖目录」分支此前零测试(删掉它 59 条全绿)。
// 目录名检查管不到这里:两个组的 `dir` 都合法,区别只在于同一侧出现了两次。
let mut manifest = PackManifest::new("同侧两个覆盖目录");
manifest.version = Some("1.0.0".to_string());
manifest.minecraft = Some("1.20.1".to_string());
manifest
.overrides
.push(PackOverrides::new("overrides", PackSide::Both));
manifest
.overrides
.push(PackOverrides::new("overrides", PackSide::Both));
let target = temp_root("export-mr-same-side").join("out.mrpack");
let error = MODRINTH
.write(&manifest, PackPayload::metadata_only(), &target)
.expect_err("同一侧两个覆盖目录必须拒");
assert_eq!(error.kind(), PackErrorKind::Unsupported, "{error}");
assert!(
error.message().contains("每个侧只能有一个覆盖目录"),
"错误要指出是「同侧重复」而不是「目录名不对」:{}",
error.message()
);
assert!(!target.exists(), "被拒时不许留下目标文件");
}
+172
View File
@@ -0,0 +1,172 @@
//! 扩展点判据:加一个假格式(**一个文件、一条注册**)就能被探测与读写,
//! 且不碰模型、不碰 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) -> PackResult<Option<&'static str>> {
let Some(value) = pack.read_json("toy.json")? else {
return Ok(None);
};
Ok(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
}
+104
View File
@@ -0,0 +1,104 @@
//! `addon::pack` 的单元测试:夹具读回、未知字段往返、探测优先级、扩展点、恶意样本。
//!
//! **全部离线**:没有网络调用,也没有真实整合包样本(见 `fixtures` 的来源声明)。
mod detect;
mod export;
mod extend;
mod fixtures;
mod model;
mod roundtrip;
mod safety;
mod unknown_fields;
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");
}
+111
View File
@@ -0,0 +1,111 @@
//! 模型级辅助与读取预算:r1 p2-2(跨侧重名)与 p3-4(批量读取的累计限额)。
use super::fixtures;
use crate::addon::pack::archive::{PackArchive, ReadBudget};
use crate::addon::pack::error::PackErrorKind;
use crate::addon::pack::manifest::{OverrideFile, PackManifest, PackOverrides, PackSide};
fn file(path: &str, sha1: &str, size: u64) -> OverrideFile {
OverrideFile {
path: path.to_string(),
sha1: sha1.to_string(),
size,
}
}
#[test]
fn duplicate_override_paths_counts_across_sides_not_per_side() {
// r1 p2-2:mrpack 的真实冲突是 `overrides`(Both) 与 `client-overrides`(Client) 撞同一路径,
// 按 `(side, path)` 计数时两侧键天然不同 ⇒ 永远返回空表。
let mut both = PackOverrides::new("overrides", PackSide::Both);
both.files.push(file("config/x.toml", "a", 1));
both.files.push(file("config/only-both.toml", "b", 1));
let mut client = PackOverrides::new("client-overrides", PackSide::Client);
client.files.push(file("config/x.toml", "c", 2));
let mut server = PackOverrides::new("server-overrides", PackSide::Server);
server.files.push(file("config/only-server.toml", "d", 3));
let manifest = PackManifest {
overrides: vec![both, client, server],
..PackManifest::default()
};
assert_eq!(
manifest.duplicate_override_paths(),
vec![(
"config/x.toml".to_string(),
vec![PackSide::Both, PackSide::Client],
2
)],
"只报真正撞车的路径,并给出涉及的侧与条目份数"
);
}
#[test]
fn duplicate_override_paths_still_reports_two_groups_on_the_same_side() {
// r2 p2-2:上一版按侧去重,把这半能力换掉了 —— 两个 Both 组撞同一路径实测返回 `[]`。
// 现在判据是「侧数 > 1 || 条份数 > 侧数」,两种冲突同时报得出来。
let mut first = PackOverrides::new("overrides", PackSide::Both);
first.files.push(file("config/x.toml", "a", 1));
let mut second = PackOverrides::new("extra-overrides", PackSide::Both);
second.files.push(file("config/x.toml", "b", 2));
let manifest = PackManifest {
overrides: vec![first, second],
..PackManifest::default()
};
assert_eq!(
manifest.duplicate_override_paths(),
vec![("config/x.toml".to_string(), vec![PackSide::Both], 2)],
"同侧两个组撞同一路径必须报(侧集合只有一个,份数是 2)"
);
}
#[test]
fn duplicate_override_paths_reports_the_same_path_twice_inside_one_group() {
// 同侧撞车的另一种形态:一个组里同一路径列了两次(模型被合并出来时会出现)。
let mut group = PackOverrides::new("overrides", PackSide::Both);
group.files.push(file("config/x.toml", "a", 1));
group.files.push(file("config/x.toml", "b", 2));
let manifest = PackManifest {
overrides: vec![group],
..PackManifest::default()
};
assert_eq!(
manifest.duplicate_override_paths(),
vec![("config/x.toml".to_string(), vec![PackSide::Both], 2)]
);
}
#[test]
fn duplicate_override_paths_is_empty_when_nothing_collides() {
let mut both = PackOverrides::new("overrides", PackSide::Both);
both.files.push(file("config/a.toml", "a", 1));
let mut client = PackOverrides::new("client-overrides", PackSide::Client);
client.files.push(file("config/b.toml", "b", 1));
let manifest = PackManifest {
overrides: vec![both, client],
..PackManifest::default()
};
assert!(manifest.duplicate_override_paths().is_empty());
}
#[test]
fn a_batch_scan_spends_one_cumulative_budget() {
// r1 p3-4:打开阶段只能看声明大小;批量读取(override_files)必须按**实际字节**累计扣减,
// 否则"很多条各自合规"累起来就能绕过 `max_uncompressed_bytes`(CPU/时间 DoS)。
let fixture = fixtures::modrinth("budget");
let pack = PackArchive::open_default(&fixture.zip).expect("打开 mrpack 夹具");
let generous = ReadBudget::new(1024 * 1024);
let files = pack
.override_files_with("overrides", &generous)
.expect("预算够时必须成功");
assert_eq!(files.len(), 2);
// 预算只有 4 字节:第一个文件(config/known.toml,15 字节)就该把预算吃穿。
let tight = ReadBudget::new(4);
let error = pack
.override_files_with("overrides", &tight)
.expect_err("累计预算必须生效");
assert_eq!(error.kind(), PackErrorKind::Limits, "{error}");
assert_eq!(tight.remaining(), 0, "失败后预算不该被退回");
}
@@ -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).expect("清单可读"),
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 的映射一致)"
);
}
+312
View File
@@ -0,0 +1,312 @@
//! 恶意样本:每条安全判据都配一条"会失败的包",而不是靠注释保证。
//!
//! 覆盖:路径穿越四类形状(`..` / 绝对路径 / 反斜杠 / 盘符)、符号链接条目、条目数上限、
//! 解压总体积上限、单条目上限、规整后重名、篡改载荷(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 侧共用同一条判据)"
);
}
}
#[test]
#[cfg(windows)]
fn the_second_check_catches_windows_device_names_and_dot_suffixes() {
// r1 p2-3:第一检(平台无关的七条)对这些名字是**放行**的,拦下它们的是第二检
// `enclosed_name()` 的 Win32 命名空间规则(DOS 设备名、结尾点/空格、`< > " | ? *`)。
// 删掉第二检,这条用例必须红——这是「双检各有一条恶意样本」的第二条。
for (tag, name) in [
("dev-con", "CON"),
("dev-nul-nested", "mods/NUL"),
("dev-com1", "COM1.txt"),
("dev-trailing-dot", "a."),
("dev-trailing-space", "mods/b "),
("dev-wildcard", "mods/*.jar"),
] {
let target = single_entry(tag, name);
let error = open_error(&target);
assert_eq!(
error.kind(),
PackErrorKind::Traversal,
"{name}: {}",
error.message()
);
assert!(
error.message().contains("enclosed_name"),
"{name} 必须是被第二检拦下的(不然这条用例钉错了地方):{}",
error.message()
);
}
// 对照:第一检对这些名字没有任何意见(所以它们只能靠第二检)。
for name in ["CON", "mods/NUL", "a.", "mods/b ", "mods/*.jar"] {
assert!(
crate::addon::pack::path::normalize_file(name).is_ok(),
"第一检不该拦 {name}(拦了这条用例就失去意义)"
);
}
}
@@ -0,0 +1,113 @@
//! 「未知字段不丢」的三处条目级 flatten(r1 p3-3):`env_extra`、CF `files[].extra`、
//! `PackLoader::extra`。三处实现本来是对的,但此前一条断言都没有——而它们恰好是 M115 点名的
//! HMCL 回写 `fileName`/`url` 的位置。每条都走「读 → render → 写整包 → 再读逐字段相等」。
use serde_json::json;
use crate::addon::pack::archive::PackArchive;
use crate::addon::pack::curseforge::CURSEFORGE;
use crate::addon::pack::format::{PackFormat, PackPayload};
use crate::addon::pack::modrinth::MODRINTH;
#[test]
fn mrpack_env_unknown_keys_survive_read_write_reread() {
// r1 p3-3 的一处:`files[].env` 里的未知键(`env_extra`)此前没有任何断言。
let root = super::temp_root("env-extra");
let dir = root.join("src");
super::write_file(
&dir.join("modrinth.index.json"),
br#"{"game":"minecraft","formatVersion":1,"versionId":"1.0","name":"env extra keys",
"files":[{"path":"mods/a.jar",
"hashes":{"sha512":"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"},
"env":{"client":"required","server":"unsupported","customEnvKey":{"k":1}},
"downloads":["https://example.invalid/a.jar"]}],
"dependencies":{"minecraft":"1.20.1"}}"#,
);
let zip = root.join("pack.mrpack");
super::zip_dir(&dir, &zip);
let pack = PackArchive::open_default(&zip).expect("打开包");
let manifest = MODRINTH.read(&pack).expect("读 mrpack 清单");
assert_eq!(manifest.files[0].env_extra["customEnvKey"], json!({"k": 1}));
let target = root.join("out.mrpack");
MODRINTH
.write(&manifest, PackPayload::metadata_only(), &target)
.expect("导出 mrpack");
let reread = MODRINTH
.read(&PackArchive::open_default(&target).expect("打开导出包"))
.expect("再读");
assert_eq!(reread, manifest, "env 里的未知键必须原样回写");
assert_eq!(reread.files[0].env_extra["customEnvKey"], json!({"k": 1}));
}
#[test]
fn curseforge_file_unknown_keys_survive_read_write_reread() {
// r1 p3-3 的另一处:CF `files[].fileName` / `url` 是 HMCL 补全后回写的扩展点。
let root = super::temp_root("cf-file-extra");
let dir = root.join("src");
super::write_file(
&dir.join("manifest.json"),
br#"{"manifestType":"minecraftModpack","manifestVersion":1,"name":"files extra keys",
"version":"1.0","minecraft":{"version":"1.20.1","modLoaders":[{"id":"forge-47.2.0","primary":true}]},
"files":[{"projectID":238222,"fileID":5102025,"required":true,
"fileName":"jei-1.20.1-15.2.0.27.jar",
"url":"https://edge.forgecdn.net/files/5102/25/jei.jar"}]}"#,
);
let zip = root.join("pack.zip");
super::zip_dir(&dir, &zip);
let pack = PackArchive::open_default(&zip).expect("打开包");
let manifest = CURSEFORGE.read(&pack).expect("读 CF 清单");
assert_eq!(
manifest.files[0].extra["fileName"],
json!("jei-1.20.1-15.2.0.27.jar")
);
assert_eq!(
manifest.files[0].extra["url"],
json!("https://edge.forgecdn.net/files/5102/25/jei.jar")
);
let target = root.join("out.zip");
CURSEFORGE
.write(&manifest, PackPayload::metadata_only(), &target)
.expect("导出 CF");
let reread = CURSEFORGE
.read(&PackArchive::open_default(&target).expect("打开导出包"))
.expect("再读");
assert_eq!(reread, manifest, "files[] 里的未知键必须原样回写");
}
#[test]
fn curseforge_loader_unknown_keys_survive_read_write_reread() {
// r1 p3-3 的第三处:`modLoaders[]` 里的未知键(`PackLoader::extra`)。
let root = super::temp_root("cf-loader-extra");
let dir = root.join("src");
super::write_file(
&dir.join("manifest.json"),
br#"{"manifestType":"minecraftModpack","manifestVersion":1,"name":"loader extra keys",
"version":"1.0",
"minecraft":{"version":"1.20.1",
"modLoaders":[{"id":"forge-47.2.0","primary":true,"cachedName":"Forge 47.2.0"}]},
"files":[]}"#,
);
let zip = root.join("pack.zip");
super::zip_dir(&dir, &zip);
let pack = PackArchive::open_default(&zip).expect("打开包");
let manifest = CURSEFORGE.read(&pack).expect("读 CF 清单");
assert_eq!(
manifest.loaders[0].extra["cachedName"],
json!("Forge 47.2.0")
);
let target = root.join("out.zip");
CURSEFORGE
.write(&manifest, PackPayload::metadata_only(), &target)
.expect("导出 CF");
let reread = CURSEFORGE
.read(&PackArchive::open_default(&target).expect("打开导出包"))
.expect("再读");
assert_eq!(reread, manifest, "modLoaders[] 里的未知键必须原样回写");
assert_eq!(reread.loaders[0].extra["cachedName"], json!("Forge 47.2.0"));
}