feat(core): 整合包格式无关模型与格式扩展点(M110)

从零建 `core::addon::pack`:格式无关中间模型 + 格式扩展点 + 安全读取层,
不碰网络、不碰 UI(core-no-ui 依赖表未变)。

- `PackManifest` 无损表达 CF / mrpack / MultiMC 三方字段:包名/版本/作者/简介/
  格式版本/游戏版本/加载器依赖/文件条目/覆盖目录;`files[].hashes` 保留整张摘要表
  (mrpack 允许 sha1+sha256+sha512+md5 共存),`disabled` 记语义(导出按 D10 映射成
  env.client=optional,CF 落 required=false)。
- 未知字段按条目用 serde flatten 接住(清单顶层 / CF `minecraft` 对象 / 文件条目 /
  env / 加载器条目),导出时并回且已知字段优先;两格式各有一条「读→写→再读相等」测试。
- `trait PackFormat`(id / label / metadata_entry / detect -> Option<证据> /
  read / render / 默认 write)+ `PackRegistry`:注册表顺序即优先级,写死
  CurseForge → Modrinth → MultiMC;同一份包命中多格式时 `also_matched` 暴露歧义。
  测试里用一个 toy 格式(单文件、一行注册)证明扩展点真的可扩展。
- `PackArchive` 把目录与 zip 统一成规整条目表:路径穿越双检(自研七条规则 +
  zip crate 的 enclosed_name)、符号链接条目拒绝、条目数/解压体积/单条目上限、
  规整后重名拒绝、篡改载荷不当场吞掉;每条都有恶意样本测试。
- 格式实现:CF 可读写(缺路径如实记 None)、mrpack 可读写(强制 sha512 形状、
  env 三态、三个覆盖目录成三侧)、MultiMC 只读(.minecraft 当覆盖目录,
  组件顺序与非加载器组件全保留)。
- 三种格式各一个手造夹具(含截断 TOML / 半截 jar / 零字节 jar),来源在
  `tests/fixtures.rs` 里声明为手造,不冒充真实导出。
This commit is contained in:
AskaEth committed 2026-11-04 00:20:24 +08:00
1 parent 40a4c06d42
commit b34e86bba5
20 files changed
+3729 -1

No files matched your search

@@ -0,0 +1,30 @@
//! 一条规整过的包内条目。
/// 一条规整过的包内条目。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PackEntryInfo {
pub(in crate::addon::pack::archive) name: String,
pub(in crate::addon::pack::archive) raw: String,
pub(in crate::addon::pack::archive) size: u64,
pub(in crate::addon::pack::archive) is_dir: bool,
}
impl PackEntryInfo {
/// 规整后的相对路径(`/` 分隔,无前导斜杠、无 `.`/`..` 段)。
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
/// 声明/实测的字节数(目录条目为 0)。
#[must_use]
pub const fn size(&self) -> u64 {
self.size
}
/// 是否是目录条目。
#[must_use]
pub const fn is_dir(&self) -> bool {
self.is_dir
}
}
@@ -0,0 +1,100 @@
//! 读取限额:条目数 / 解压总体积 / 单条目,以及两处封顶实现。
use std::io::Read;
use super::super::error::{PackError, PackResult};
/// 默认条目数上限(一个巨型整合包 ≈ 数千条,2 万条已经很宽)。
pub const DEFAULT_MAX_ENTRIES: usize = 20_000;
/// 默认解压总体积上限(512 MiB;`.mrpack` 的 `overrides` 通常几十 MiB)。
pub const DEFAULT_MAX_UNCOMPRESSED_BYTES: u64 = 512 * 1024 * 1024;
/// 默认单条目上限(64 MiB;最大的 mod 也不过十几 MiB)。
pub const DEFAULT_MAX_ENTRY_BYTES: u64 = 64 * 1024 * 1024;
/// 包读取限额。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PackLimits {
/// 条目数上限。
pub max_entries: usize,
/// 解压总体积上限。
pub max_uncompressed_bytes: u64,
/// 单条目上限。
pub max_entry_bytes: u64,
}
impl PackLimits {
/// 三档显式给值(测试与"可疑包"复查用)。
#[must_use]
pub const fn new(
max_entries: usize,
max_uncompressed_bytes: u64,
max_entry_bytes: u64,
) -> Self {
Self {
max_entries,
max_uncompressed_bytes,
max_entry_bytes,
}
}
}
impl Default for PackLimits {
fn default() -> Self {
Self {
max_entries: DEFAULT_MAX_ENTRIES,
max_uncompressed_bytes: DEFAULT_MAX_UNCOMPRESSED_BYTES,
max_entry_bytes: DEFAULT_MAX_ENTRY_BYTES,
}
}
}
/// 累计与单条目限额检查(打开阶段)。
pub(super) fn check_limits(
limits: &PackLimits,
total: &mut u64,
size: u64,
name: &str,
seen: usize,
) -> PackResult<()> {
if size > limits.max_entry_bytes {
return Err(PackError::limits(format!(
"条目 {name} 声明 {} 字节,超过单条目上限 {}",
size, limits.max_entry_bytes
)));
}
*total = total.saturating_add(size);
if *total > limits.max_uncompressed_bytes {
return Err(PackError::limits(format!(
"解压总体积超过上限 {} 字节(累计到 {total})",
limits.max_uncompressed_bytes
)));
}
if seen >= limits.max_entries {
return Err(PackError::limits(format!(
"条目数超过上限 {}",
limits.max_entries
)));
}
Ok(())
}
/// 带封顶的读:中央目录里声明的大小可以撒谎,这里按**实际读出的字节**封顶。
pub(super) fn read_capped(reader: &mut impl Read, cap: u64, name: &str) -> PackResult<Vec<u8>> {
let mut bytes = Vec::new();
let mut buffer = [0_u8; 64 * 1024];
loop {
let read = reader
.read(&mut buffer)
.map_err(|error| PackError::io(format!("读 {name} 失败:{error}")))?;
if read == 0 {
break;
}
if bytes.len() as u64 + read as u64 > cap {
return Err(PackError::limits(format!(
"{name} 实际解压超过单条目上限 {cap}"
)));
}
bytes.extend_from_slice(&buffer[..read]);
}
Ok(bytes)
}
+158
View File
@@ -0,0 +1,158 @@
//! 打开与首轮校验:目录遍历 / zip 中央目录扫描,两道穿越检查、符号链接、限额都在这里。
use std::cell::RefCell;
use std::collections::HashMap;
use std::fs::File;
use std::path::PathBuf;
use zip::ZipArchive;
use super::entry::PackEntryInfo;
use super::limits::{check_limits, PackLimits};
use super::{PackArchive, Source};
use crate::addon::pack::error::{PackError, PackResult};
use crate::addon::pack::path::{has_trailing_slash, normalize_file};
impl PackArchive {
pub(super) fn open_dir(root: PathBuf, limits: PackLimits) -> PackResult<Self> {
let mut entries = Vec::new();
let mut total: u64 = 0;
// 栈式遍历:不递归,避免深层目录把栈打穿。
let mut pending = vec![String::new()];
while let Some(relative) = pending.pop() {
let dir = if relative.is_empty() {
root.clone()
} else {
root.join(&relative)
};
let mut children = std::fs::read_dir(&dir)
.map_err(|error| PackError::io(format!("读目录 {} 失败:{error}", dir.display())))?
.collect::<Result<Vec<_>, _>>()
.map_err(PackError::from)?;
children.sort_by_key(std::fs::DirEntry::file_name);
for child in children {
let file_name =
child
.file_name()
.to_str()
.map(str::to_string)
.ok_or_else(|| {
PackError::metadata(format!(
"包内文件名不是 UTF-8:{}",
child.path().display()
))
})?;
let raw = if relative.is_empty() {
file_name
} else {
format!("{relative}/{file_name}")
};
let file_type = child
.file_type()
.map_err(|error| PackError::io(format!("读 {} 类型失败:{error}", raw)))?;
if file_type.is_symlink() {
return Err(PackError::symlink(format!("整合包里有符号链接条目:{raw}")));
}
let name = normalize_file(&raw)?;
if file_type.is_dir() {
check_limits(&limits, &mut total, 0, &name, entries.len())?;
pending.push(name.clone());
entries.push(PackEntryInfo {
name,
raw,
size: 0,
is_dir: true,
});
continue;
}
if !file_type.is_file() {
continue;
}
let size = child
.metadata()
.map_err(|error| PackError::io(format!("读 {raw} 大小失败:{error}")))?
.len();
check_limits(&limits, &mut total, size, &name, entries.len())?;
entries.push(PackEntryInfo {
name,
raw,
size,
is_dir: false,
});
}
}
entries.sort_by(|left, right| left.name.cmp(&right.name));
Self::finish(root, Source::Dir, entries, limits)
}
pub(super) fn open_zip(root: PathBuf, limits: PackLimits) -> PackResult<Self> {
let file = File::open(&root)
.map_err(|error| PackError::io(format!("打开 {} 失败:{error}", root.display())))?;
let mut archive = ZipArchive::new(file)
.map_err(|error| PackError::metadata(format!("不是可读的 zip:{error}")))?;
if archive.len() > limits.max_entries {
return Err(PackError::limits(format!(
"zip 条目数 {} 超过上限 {}",
archive.len(),
limits.max_entries
)));
}
let mut entries = Vec::with_capacity(archive.len());
let mut total: u64 = 0;
for index in 0..archive.len() {
let entry = archive.by_index(index).map_err(|error| {
PackError::metadata(format!("读第 {index} 个条目失败:{error}"))
})?;
let Ok(raw) = entry.name() else {
return Err(PackError::metadata(format!(
"第 {index} 个条目名编码不合法"
)));
};
let raw = raw.to_string();
let is_dir = entry.is_dir() || has_trailing_slash(&raw);
if entry.is_symlink() {
return Err(PackError::symlink(format!("zip 里有符号链接条目:{raw}")));
}
let name = normalize_file(&raw)?;
// 第二检:zip crate 按 Windows 规则再解析一遍(盘符 / ADS / 保留设备名)。
if entry.enclosed_name().is_none() {
return Err(PackError::traversal(format!(
"zip 条目名过不了 enclosed_name 检查:{raw}"
)));
}
let size = if is_dir { 0 } else { entry.size() };
check_limits(&limits, &mut total, size, &name, entries.len())?;
entries.push(PackEntryInfo {
name,
raw,
size,
is_dir,
});
}
Self::finish(root, Source::Zip(RefCell::new(archive)), entries, limits)
}
fn finish(
root: PathBuf,
source: Source,
entries: Vec<PackEntryInfo>,
limits: PackLimits,
) -> PackResult<Self> {
let mut index = HashMap::with_capacity(entries.len());
for (slot, entry) in entries.iter().enumerate() {
if index.insert(entry.name.clone(), slot).is_some() {
return Err(PackError::metadata(format!(
"包里有规整后重名的条目:{}",
entry.name
)));
}
}
Ok(Self {
root,
source,
entries,
index,
limits,
})
}
}