fix(core): M110 r1 复审修复:导出安全闸、跨侧重名、第二检用例、半包与失败分级

按 tower 裁定清单逐条改,不扩战场(模型与扩展点未动)。

- p2-1 跨格式导出不再静默丢内容:`PackFormat::check_overrides` 在默认 `write` 写第一个
  字节前判定"这份模型的覆盖目录集合能否被本格式表达"。mrpack 要求目录名由 side 决定
  (overrides / client-overrides / server-overrides)、CF 只接受单一 Both 侧组;表达不了
  即 `pack_unsupported` + hint。`format.rs` 模块文档写死"默认 write 只保证同格式往返",
  并列出 M112 必须补的四件事(目录映射、side 合并、加载器 id 规范化、extra 分桶)。
- p2-2 `duplicate_override_paths` 改为按 `path` 跨侧计数,返回 `(路径, 侧集合)`;
  Both×Client 同路径这条 mrpack 真实冲突现在查得出来。
- p2-3 补 `#[cfg(windows)]` 第二检用例(CON / NUL / COM1.txt / `a.` / 尾空格 / 通配符),
  并订正 `archive.rs` 文档里"保留设备名这里不拦"的自相矛盾(实际由 enclosed_name 在
  Windows 宿主上拦,第一检放行)。
- p3-1 导出改临时文件 + rename,失败清理临时文件:目标路径不再留半个 zip。
- p3-2 三个 `detect` 不再 `.ok().flatten()` 吞错误:清单在但读不出来 = `pack_metadata`;
  注册表汇总时"命中优先于失败",一个都不命中才报,最后才退化成 `pack_unsupported`。
- p3-3 `env_extra`、CF `files[].extra`、`PackLoader::extra` 各补一条读→render→写→再读用例。
- p3-4 新增 `ReadBudget`(Cell<u64>):`override_files` 持有累计预算,按实际读出字节
  逐条扣减,声明大小撒谎的包不能靠"很多条各自合规"绕过 `max_uncompressed_bytes`。
- CF 读侧始终产出一个 Both 侧 overrides 组(字段缺省 `overrides`),与 render 对称。

每处修复都有变异证据(改坏 → 对应用例红):p2-1/p2-2/p2-3/p3-1/p3-2/p3-3a-c/p3-4 共 9 条。
门禁:fmt 0 / clippy --workspace --all-targets -D warnings 0 / cargo test --workspace -j 4
exit 0(58 个 test binary,passed 1930,failed 0);全部改动文件 ≤400 行(最大 389)。
This commit is contained in:
AskaEth committed 2026-11-04 00:20:24 +08:00
1 parent b34e86bba5
commit 6dd652aced
16 files changed
+804 -98

No files matched your search

+8 -1
View File
@@ -14,6 +14,13 @@
//! 三个内置格式:[`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 与
@@ -53,7 +60,7 @@ pub mod path;
#[cfg(test)]
mod tests;
pub use archive::{PackArchive, PackEntryInfo, PackLimits};
pub use archive::{PackArchive, PackEntryInfo, PackLimits, ReadBudget};
pub use curseforge::CurseForge;
pub use error::{PackError, PackErrorKind, PackResult};
pub use format::{PackFormat, PackMatch, PackPayload, PackRegistry};
+43 -9
View File
@@ -9,18 +9,24 @@
//!
//! | 威胁 | 做法 |
//! |------|------|
//! | 路径穿越(`../x`、`/etc/x`、`C:\x`、`a\..\b`、`file:ads`) | 条目名过 [`normalize_file`](第一检),再过 `zip` crate 的 `enclosed_name()`(第二检:它按 **Windows 规则**解析盘符/ADS/保留设备名);任一拒绝即整包拒绝 |
//! | 路径穿越(`../x`、`/etc/x`、`C:\x`、`a\..\b`、`file:ads`) | 条目名过 [`normalize_file`](第一检,平台无关的七条规则),再过 `zip` crate 的 `enclosed_name()`(第二检,**按 Windows 规则**解析盘符/ADS/保留设备名);任一拒绝即整包拒绝 |
//! | 符号链接条目 | `unix_mode()` 的 `S_IFLNK` 位命中即拒绝(Windows 上无合法用途;解压器会把目标当内容写出来) |
//! | 条目数爆炸 | [`PackLimits::max_entries`],打开时按中央目录长度先判 |
//! | 解压体积爆炸(zip 炸弹) | [`PackLimits::max_uncompressed_bytes`] 累计 + [`PackLimits::max_entry_bytes`] 单条目;**读取时**再按实际读出的字节数封顶(中央目录里声明的大小可以撒谎) |
//! | 同名条目(`./a` 与 `a` 规整后撞车) | 规整后的名字重复即拒绝 |
//!
//! **为什么第一检不能省**:`enclosed_name()` 允许"深度 0 处的根目录",也就是
//! `"/etc/passwd"` 会返回 `Some("/etc/passwd")`——绝对路径必须由我们自己的规则先干掉,
//! 库的检查只当跨平台兜底。
//! **两条检查各补一条对方没有的规则**(谁也不能省):
//!
//! **不在这里做的事**:不写盘(那是 M111 导入管线的事),所以 Windows 保留设备名
//! (`CON` / `NUL` / `COM1`…)这里不拦——落盘前必须拦,见 `zip::path` 的同名规则。
//! - 第一检管**平台无关**的形状:空名、`\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;
@@ -43,7 +49,8 @@ mod open;
pub use entry::PackEntryInfo;
pub use limits::{
PackLimits, DEFAULT_MAX_ENTRIES, DEFAULT_MAX_ENTRY_BYTES, DEFAULT_MAX_UNCOMPRESSED_BYTES,
PackLimits, ReadBudget, DEFAULT_MAX_ENTRIES, DEFAULT_MAX_ENTRY_BYTES,
DEFAULT_MAX_UNCOMPRESSED_BYTES,
};
use limits::read_capped;
@@ -160,7 +167,7 @@ impl PackArchive {
let mut reader = archive
.by_name(&entry.raw)
.map_err(|error| PackError::metadata(format!("解压 {name} 失败:{error}")))?;
let bytes = read_capped(&mut reader, self.limits.max_entry_bytes, name)?;
let bytes = read_capped(&mut reader, self.limits.max_entry_bytes, None, name)?;
Ok(Some(bytes))
}
}
@@ -187,7 +194,15 @@ impl PackArchive {
}
/// 算一个条目的 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);
};
@@ -224,6 +239,9 @@ impl PackArchive {
self.limits.max_entry_bytes
)));
}
if let Some(budget) = budget {
budget.spend(read as u64, name)?;
}
hasher.update(&buffer[..read]);
}
Ok(Some(hasher.finish()))
@@ -234,7 +252,21 @@ impl PackArchive {
/// 收集一个覆盖目录下的文件(相对该目录的路径 + SHA-1 + 字节数),按路径排序。
///
/// 目录不存在时返回空表——"包没带 overrides"不是错误。
///
/// 这是**唯一会批量解压的 API**,所以它自己持有一个累计预算
/// (`PackLimits::max_uncompressed_bytes`),逐条按实际读出的字节扣减:
/// 声明大小撒谎的包不会在这里把 CPU 时间耗光(r1 p3-4)。
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();
@@ -246,7 +278,9 @@ impl PackArchive {
if relative.is_empty() {
continue;
}
let sha1 = self.sha1(&entry.name)?.unwrap_or_default();
let sha1 = self
.sha1_with(&entry.name, Some(budget))?
.unwrap_or_default();
collected.push(OverrideFile {
path: relative.to_string(),
sha1,
+65 -2
View File
@@ -1,5 +1,14 @@
//! 读取限额:条目数 / 解压总体积 / 单条目,以及两处封顶实现。
//!
//! # 两阶段限额(打开 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};
@@ -48,6 +57,47 @@ impl Default for PackLimits {
}
}
/// 一次批量读取的**累计**预算(读取阶段,按实际读出的字节扣减)。
///
/// 为什么需要它:中央目录声明的大小可以撒谎,单个条目也被限制在
/// [`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,
@@ -79,9 +129,18 @@ pub(super) fn check_limits(
}
/// 带封顶的读:中央目录里声明的大小可以撒谎,这里按**实际读出的字节**封顶。
pub(super) fn read_capped(reader: &mut impl Read, cap: u64, name: &str) -> PackResult<Vec<u8>> {
///
/// 给了 `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)
@@ -89,11 +148,15 @@ pub(super) fn read_capped(reader: &mut impl Read, cap: u64, name: &str) -> PackR
if read == 0 {
break;
}
if bytes.len() as u64 + read as u64 > cap {
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)
+48 -14
View File
@@ -22,6 +22,10 @@
//! [`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`,落到 ULL 实例里
//! 会变成 `.minecraft/.minecraft/…`)。同一份模型只有 `read` 出来的那个格式才保证往返。
use serde::Deserialize;
use serde_json::{json, Map, Value};
@@ -126,9 +130,15 @@ impl PackFormat for CurseForge {
Some(METADATA_ENTRY)
}
fn detect(&self, pack: &PackArchive) -> Option<&'static str> {
let value = pack.read_json(METADATA_ENTRY).ok().flatten()?;
let object = value.as_object()?;
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,
@@ -138,7 +148,31 @@ impl PackFormat for CurseForge {
&& object.get("files").is_some_and(Value::is_array)
}
};
matched.then_some(METADATA_ENTRY)
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> {
@@ -197,19 +231,19 @@ impl PackFormat for CurseForge {
})
.collect();
let mut overrides = Vec::new();
if let Some(dir) = raw
// CF 的 `overrides` 字段缺省就是 `"overrides"`(规范默认值),`render` 也总会写它;
// 读侧因此**永远**产出一个 Both 侧组,读写才对称(dir 不存在时 files 为空表)。
let overrides_dir = raw
.overrides
.as_deref()
.filter(|dir| !dir.trim().is_empty())
{
let dir = normalize_dir(dir)?;
overrides.push(PackOverrides {
files: pack.override_files(&dir)?,
dir,
side: PackSide::Both,
});
}
.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,
+150 -51
View File
@@ -21,17 +21,32 @@
//! - [`PackRegistry::register`] 追加到**末尾**(= 最低优先级),所以测试里改一行顺序就能
//! 证明"优先级来自注册表顺序"(`tests/detect.rs` 有一条反序注册的用例)。
//!
//! # 写
//! # 写:默认实现**只保证同格式往返**(这条是给 M111/M112 的边界声明)
//!
//! 默认实现([`PackFormat::write`])替所有格式做同一件事:写清单 entry([`PackFormat::render`]
//! 的产物)+ 从 [`PackPayload::root`] 里搬覆盖目录内容。因此新格式只要实现
//! `metadata_entry` + `render` 就同时获得导出能力;只读格式(MultiMC)不实现 `metadata_entry`,
//! 写会得到明确的 `crate::addon::pack::error::PackErrorKind::Unsupported`。
//! 的产物)+ 把 `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;
use std::path::{Path, PathBuf};
use super::archive::PackArchive;
use super::curseforge;
@@ -88,7 +103,11 @@ pub trait PackFormat: Debug + Send + Sync {
/// **必须带形状校验**,不能只看文件名存在:`manifest.json` 这种泛名到处都是。
/// "选谁"不是格式自己的事——排序与歧义在 [`PackRegistry::detect`] 那一层做
/// (返回 [`PackMatch`],带 `also_matched` 与本次命中的证据)。
fn detect(&self, pack: &PackArchive) -> Option<&'static str>;
///
/// **清单在、但读不出来**时必须返回 `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>;
@@ -96,9 +115,33 @@ pub trait PackFormat: Debug + Send + Sync {
/// 把模型渲染成清单 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(())
}
/// 导出一个整包(清单 + 覆盖内容)。
///
/// 默认实现对"只读格式"(`metadata_entry` 为 `None`)直接报不支持。
/// 默认实现做四件事:①只读格式直接拒;②[`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,
@@ -113,50 +156,89 @@ pub trait PackFormat: Debug + Send + Sync {
))
.with_hint("导出请选 Modrinth(.mrpack)或 CurseForge 格式"));
};
// 先判"能不能表达",再碰磁盘:跨格式导出宁可报错,也不产出空包。
self.check_overrides(manifest)?;
// 模型可能来自外部构造:搬内容前把每条路径重查一遍。
manifest.check_paths()?;
let json = self.render(manifest)?;
let file = File::create(target)
.map_err(|error| PackError::io(format!("创建 {} 失败:{error}", target.display())))?;
let mut writer = zip::ZipWriter::new(file);
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated)
.unix_permissions(0o644);
writer
.start_file(entry, options)
.map_err(|error| PackError::io(format!("写 {entry} 失败:{error}")))?;
writer
.write_all(json.as_bytes())
.map_err(|error| PackError::io(format!("写 {entry} 失败:{error}")))?;
for group in &manifest.overrides {
let dir = normalize_dir(&group.dir)?;
for override_file in &group.files {
let path = normalize_file(&override_file.path)?;
let name = format!("{dir}/{path}");
let Some(root) = payload.root() else {
return Err(PackError::unsupported(format!(
"导出需要覆盖内容({name}),但没给 payload 根目录"
)));
};
let source = root.join(&dir).join(&path);
let bytes = std::fs::read(&source).map_err(|error| {
PackError::io(format!("读覆盖文件 {} 失败:{error}", source.display()))
})?;
writer
.start_file(name.clone(), options)
.map_err(|error| PackError::io(format!("写 {name} 失败:{error}")))?;
writer
.write_all(&bytes)
.map_err(|error| PackError::io(format!("写 {name} 失败:{error}")))?;
// 临时文件 + 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)
}
}
writer
.finish()
.map_err(|error| PackError::io(format!("收尾 {} 失败:{error}", target.display())))?;
Ok(())
}
}
/// 临时文件路径(与目标同目录,`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 {
@@ -245,25 +327,42 @@ impl PackRegistry {
.find(|format| format.id() == id)
}
/// 全部命中的格式 id(诊断"这包不标准"用)。
/// 全部命中的格式 id(诊断"这包不标准"用;**只看命中**,单个格式的读错误被忽略——
/// 要区分"包坏了"与"不认识"请用 [`Self::detect`])。
#[must_use]
pub fn detect_all(&self, pack: &PackArchive) -> Vec<&'static str> {
self.formats
.iter()
.filter(|format| format.detect(pack).is_some())
.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)> = self
.formats
.iter()
.copied()
.filter_map(|format| format.detect(pack).map(|evidence| (format, evidence)))
.collect();
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})"))
+21 -8
View File
@@ -333,18 +333,31 @@ impl PackManifest {
Ok(())
}
/// 覆盖目录里重复的实例路径(两份 overrides 撞同一文件时的提示,导入器据此决策)。
/// 跨覆盖组撞同一实例路径的清单:`(路径, 撞车的侧集合)`,按路径排序。
///
/// **按 `path` 跨侧计数**,不是按 `(side, path)`:mrpack 里 `overrides/`(Both 侧)与
/// `client-overrides/`(Client 侧)写到同一个实例路径才是真实冲突,而两侧的键天然不同
/// (r1 p2-2 之前这里恒返回空表,正好漏掉它要支撑的那个决策)。
///
/// 返回的多侧冲突由导入器决定优先级(本模块只报告,不选边)。
#[must_use]
pub fn duplicate_override_paths(&self) -> Vec<String> {
let mut seen: HashMap<(PackSide, &str), usize> = HashMap::new();
let mut duplicated = Vec::new();
pub fn duplicate_override_paths(&self) -> Vec<(String, Vec<PackSide>)> {
let mut sides: HashMap<&str, Vec<PackSide>> = HashMap::new();
for (side, file) in self.override_files() {
let counter = seen.entry((side, file.path.as_str())).or_insert(0);
*counter += 1;
if *counter == 2 {
duplicated.push(file.path.clone());
let entry = sides.entry(file.path.as_str()).or_default();
if !entry.contains(&side) {
entry.push(side);
}
}
let mut duplicated: Vec<(String, Vec<PackSide>)> = sides
.into_iter()
.filter(|(_, sides)| sides.len() > 1)
.map(|(path, mut sides)| {
sides.sort_by_key(|side| side.as_str());
(path.to_string(), sides)
})
.collect();
duplicated.sort_by(|left, right| left.0.cmp(&right.0));
duplicated
}
}
+34 -4
View File
@@ -128,14 +128,44 @@ impl PackFormat for Modrinth {
Some(METADATA_ENTRY)
}
fn detect(&self, pack: &PackArchive) -> Option<&'static str> {
let value = pack.read_json(METADATA_ENTRY).ok().flatten()?;
let object = value.as_object()?;
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));
matched.then_some(METADATA_ENTRY)
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> {
+8 -4
View File
@@ -87,15 +87,19 @@ impl PackFormat for MultiMc {
"MultiMC / Prism"
}
fn detect(&self, pack: &PackArchive) -> Option<&'static str> {
let value = pack.read_json(METADATA_ENTRY).ok().flatten()?;
let object = value.as_object()?;
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());
matched.then_some(METADATA_ENTRY)
Ok(matched.then_some(METADATA_ENTRY))
}
fn read(&self, pack: &PackArchive) -> PackResult<PackManifest> {
+63 -1
View File
@@ -136,7 +136,7 @@ fn multimc_needs_a_non_empty_component_graph() {
);
let pack = PackArchive::open_default(&target).expect("打开包");
assert_eq!(
MULTIMC.detect(&pack),
MULTIMC.detect(&pack).expect("mmc-pack.json 可读"),
None,
"空 components 是空壳,不能当 MultiMC 实例"
);
@@ -159,3 +159,65 @@ fn multimc_instance_directory_is_detected() {
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"
);
}
+129
View File
@@ -0,0 +1,129 @@
//! 导出面的安全闸(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 表达");
}
+5 -3
View File
@@ -35,9 +35,11 @@ impl PackFormat for ToyFormat {
Some("toy.json")
}
fn detect(&self, pack: &PackArchive) -> Option<&'static str> {
let value = pack.read_json("toy.json").ok().flatten()?;
value.is_object().then_some("toy.json")
fn 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> {
+3
View File
@@ -3,10 +3,13 @@
//! **全部离线**:没有网络调用,也没有真实整合包样本(见 `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;
+75
View File
@@ -0,0 +1,75 @@
//! 模型级辅助与读取预算: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]
)],
"只报真正撞车的路径,并给出涉及的侧"
);
}
#[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, "失败后预算不该被退回");
}
@@ -247,7 +247,7 @@ fn curseforge_without_name_is_a_metadata_error() {
);
let pack = PackArchive::open_default(&target).expect("zip 本身可读");
assert_eq!(
CURSEFORGE.detect(&pack),
CURSEFORGE.detect(&pack).expect("清单可读"),
Some("manifest.json"),
"形状对得上就该被探测到"
);
@@ -272,3 +272,41 @@ fn directory_source_rejects_symlinks_when_the_platform_can_make_them() {
);
}
}
#[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"));
}