feat(core): 整合包格式无关模型与格式扩展点(M110)
从零建 `core::addon::pack`:格式无关中间模型 + 格式扩展点 + 安全读取层, 不碰网络、不碰 UI(core-no-ui 依赖表未变)。 - `PackManifest` 无损表达 CF / mrpack / MultiMC 三方字段:包名/版本/作者/简介/ 格式版本/游戏版本/加载器依赖/文件条目/覆盖目录;`files[].hashes` 保留整张摘要表 (mrpack 允许 sha1+sha256+sha512+md5 共存),`disabled` 记语义(导出按 D10 映射成 env.client=optional,CF 落 required=false)。 - 未知字段按条目用 serde flatten 接住(清单顶层 / CF `minecraft` 对象 / 文件条目 / env / 加载器条目),导出时并回且已知字段优先;两格式各有一条「读→写→再读相等」测试。 - `trait PackFormat`(id / label / metadata_entry / detect -> Option<证据> / read / render / 默认 write)+ `PackRegistry`:注册表顺序即优先级,写死 CurseForge → Modrinth → MultiMC;同一份包命中多格式时 `also_matched` 暴露歧义。 测试里用一个 toy 格式(单文件、一行注册)证明扩展点真的可扩展。 - `PackArchive` 把目录与 zip 统一成规整条目表:路径穿越双检(自研七条规则 + zip crate 的 enclosed_name)、符号链接条目拒绝、条目数/解压体积/单条目上限、 规整后重名拒绝、篡改载荷不当场吞掉;每条都有恶意样本测试。 - 格式实现:CF 可读写(缺路径如实记 None)、mrpack 可读写(强制 sha512 形状、 env 三态、三个覆盖目录成三侧)、MultiMC 只读(.minecraft 当覆盖目录, 组件顺序与非加载器组件全保留)。 - 三种格式各一个手造夹具(含截断 TOML / 半截 jar / 零字节 jar),来源在 `tests/fixtures.rs` 里声明为手造,不冒充真实导出。
This commit is contained in:
1 parent
40a4c06d42
commit
b34e86bba5
20 files changed
+3729
-1
No files matched your search
@@ -0,0 +1,30 @@
|
||||
//! 一条规整过的包内条目。
|
||||
|
||||
/// 一条规整过的包内条目。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PackEntryInfo {
|
||||
pub(in crate::addon::pack::archive) name: String,
|
||||
pub(in crate::addon::pack::archive) raw: String,
|
||||
pub(in crate::addon::pack::archive) size: u64,
|
||||
pub(in crate::addon::pack::archive) is_dir: bool,
|
||||
}
|
||||
|
||||
impl PackEntryInfo {
|
||||
/// 规整后的相对路径(`/` 分隔,无前导斜杠、无 `.`/`..` 段)。
|
||||
#[must_use]
|
||||
pub fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
/// 声明/实测的字节数(目录条目为 0)。
|
||||
#[must_use]
|
||||
pub const fn size(&self) -> u64 {
|
||||
self.size
|
||||
}
|
||||
|
||||
/// 是否是目录条目。
|
||||
#[must_use]
|
||||
pub const fn is_dir(&self) -> bool {
|
||||
self.is_dir
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
//! 读取限额:条目数 / 解压总体积 / 单条目,以及两处封顶实现。
|
||||
|
||||
use std::io::Read;
|
||||
|
||||
use super::super::error::{PackError, PackResult};
|
||||
|
||||
/// 默认条目数上限(一个巨型整合包 ≈ 数千条,2 万条已经很宽)。
|
||||
pub const DEFAULT_MAX_ENTRIES: usize = 20_000;
|
||||
/// 默认解压总体积上限(512 MiB;`.mrpack` 的 `overrides` 通常几十 MiB)。
|
||||
pub const DEFAULT_MAX_UNCOMPRESSED_BYTES: u64 = 512 * 1024 * 1024;
|
||||
/// 默认单条目上限(64 MiB;最大的 mod 也不过十几 MiB)。
|
||||
pub const DEFAULT_MAX_ENTRY_BYTES: u64 = 64 * 1024 * 1024;
|
||||
|
||||
/// 包读取限额。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct PackLimits {
|
||||
/// 条目数上限。
|
||||
pub max_entries: usize,
|
||||
/// 解压总体积上限。
|
||||
pub max_uncompressed_bytes: u64,
|
||||
/// 单条目上限。
|
||||
pub max_entry_bytes: u64,
|
||||
}
|
||||
|
||||
impl PackLimits {
|
||||
/// 三档显式给值(测试与"可疑包"复查用)。
|
||||
#[must_use]
|
||||
pub const fn new(
|
||||
max_entries: usize,
|
||||
max_uncompressed_bytes: u64,
|
||||
max_entry_bytes: u64,
|
||||
) -> Self {
|
||||
Self {
|
||||
max_entries,
|
||||
max_uncompressed_bytes,
|
||||
max_entry_bytes,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PackLimits {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_entries: DEFAULT_MAX_ENTRIES,
|
||||
max_uncompressed_bytes: DEFAULT_MAX_UNCOMPRESSED_BYTES,
|
||||
max_entry_bytes: DEFAULT_MAX_ENTRY_BYTES,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 累计与单条目限额检查(打开阶段)。
|
||||
pub(super) fn check_limits(
|
||||
limits: &PackLimits,
|
||||
total: &mut u64,
|
||||
size: u64,
|
||||
name: &str,
|
||||
seen: usize,
|
||||
) -> PackResult<()> {
|
||||
if size > limits.max_entry_bytes {
|
||||
return Err(PackError::limits(format!(
|
||||
"条目 {name} 声明 {} 字节,超过单条目上限 {}",
|
||||
size, limits.max_entry_bytes
|
||||
)));
|
||||
}
|
||||
*total = total.saturating_add(size);
|
||||
if *total > limits.max_uncompressed_bytes {
|
||||
return Err(PackError::limits(format!(
|
||||
"解压总体积超过上限 {} 字节(累计到 {total})",
|
||||
limits.max_uncompressed_bytes
|
||||
)));
|
||||
}
|
||||
if seen >= limits.max_entries {
|
||||
return Err(PackError::limits(format!(
|
||||
"条目数超过上限 {}",
|
||||
limits.max_entries
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 带封顶的读:中央目录里声明的大小可以撒谎,这里按**实际读出的字节**封顶。
|
||||
pub(super) fn read_capped(reader: &mut impl Read, cap: u64, name: &str) -> PackResult<Vec<u8>> {
|
||||
let mut bytes = Vec::new();
|
||||
let mut buffer = [0_u8; 64 * 1024];
|
||||
loop {
|
||||
let read = reader
|
||||
.read(&mut buffer)
|
||||
.map_err(|error| PackError::io(format!("读 {name} 失败:{error}")))?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
if bytes.len() as u64 + read as u64 > cap {
|
||||
return Err(PackError::limits(format!(
|
||||
"{name} 实际解压超过单条目上限 {cap}"
|
||||
)));
|
||||
}
|
||||
bytes.extend_from_slice(&buffer[..read]);
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
//! 打开与首轮校验:目录遍历 / zip 中央目录扫描,两道穿越检查、符号链接、限额都在这里。
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::fs::File;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use zip::ZipArchive;
|
||||
|
||||
use super::entry::PackEntryInfo;
|
||||
use super::limits::{check_limits, PackLimits};
|
||||
use super::{PackArchive, Source};
|
||||
use crate::addon::pack::error::{PackError, PackResult};
|
||||
use crate::addon::pack::path::{has_trailing_slash, normalize_file};
|
||||
|
||||
impl PackArchive {
|
||||
pub(super) fn open_dir(root: PathBuf, limits: PackLimits) -> PackResult<Self> {
|
||||
let mut entries = Vec::new();
|
||||
let mut total: u64 = 0;
|
||||
// 栈式遍历:不递归,避免深层目录把栈打穿。
|
||||
let mut pending = vec![String::new()];
|
||||
while let Some(relative) = pending.pop() {
|
||||
let dir = if relative.is_empty() {
|
||||
root.clone()
|
||||
} else {
|
||||
root.join(&relative)
|
||||
};
|
||||
let mut children = std::fs::read_dir(&dir)
|
||||
.map_err(|error| PackError::io(format!("读目录 {} 失败:{error}", dir.display())))?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(PackError::from)?;
|
||||
children.sort_by_key(std::fs::DirEntry::file_name);
|
||||
for child in children {
|
||||
let file_name =
|
||||
child
|
||||
.file_name()
|
||||
.to_str()
|
||||
.map(str::to_string)
|
||||
.ok_or_else(|| {
|
||||
PackError::metadata(format!(
|
||||
"包内文件名不是 UTF-8:{}",
|
||||
child.path().display()
|
||||
))
|
||||
})?;
|
||||
let raw = if relative.is_empty() {
|
||||
file_name
|
||||
} else {
|
||||
format!("{relative}/{file_name}")
|
||||
};
|
||||
let file_type = child
|
||||
.file_type()
|
||||
.map_err(|error| PackError::io(format!("读 {} 类型失败:{error}", raw)))?;
|
||||
if file_type.is_symlink() {
|
||||
return Err(PackError::symlink(format!("整合包里有符号链接条目:{raw}")));
|
||||
}
|
||||
let name = normalize_file(&raw)?;
|
||||
if file_type.is_dir() {
|
||||
check_limits(&limits, &mut total, 0, &name, entries.len())?;
|
||||
pending.push(name.clone());
|
||||
entries.push(PackEntryInfo {
|
||||
name,
|
||||
raw,
|
||||
size: 0,
|
||||
is_dir: true,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if !file_type.is_file() {
|
||||
continue;
|
||||
}
|
||||
let size = child
|
||||
.metadata()
|
||||
.map_err(|error| PackError::io(format!("读 {raw} 大小失败:{error}")))?
|
||||
.len();
|
||||
check_limits(&limits, &mut total, size, &name, entries.len())?;
|
||||
entries.push(PackEntryInfo {
|
||||
name,
|
||||
raw,
|
||||
size,
|
||||
is_dir: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
entries.sort_by(|left, right| left.name.cmp(&right.name));
|
||||
Self::finish(root, Source::Dir, entries, limits)
|
||||
}
|
||||
|
||||
pub(super) fn open_zip(root: PathBuf, limits: PackLimits) -> PackResult<Self> {
|
||||
let file = File::open(&root)
|
||||
.map_err(|error| PackError::io(format!("打开 {} 失败:{error}", root.display())))?;
|
||||
let mut archive = ZipArchive::new(file)
|
||||
.map_err(|error| PackError::metadata(format!("不是可读的 zip:{error}")))?;
|
||||
if archive.len() > limits.max_entries {
|
||||
return Err(PackError::limits(format!(
|
||||
"zip 条目数 {} 超过上限 {}",
|
||||
archive.len(),
|
||||
limits.max_entries
|
||||
)));
|
||||
}
|
||||
let mut entries = Vec::with_capacity(archive.len());
|
||||
let mut total: u64 = 0;
|
||||
for index in 0..archive.len() {
|
||||
let entry = archive.by_index(index).map_err(|error| {
|
||||
PackError::metadata(format!("读第 {index} 个条目失败:{error}"))
|
||||
})?;
|
||||
let Ok(raw) = entry.name() else {
|
||||
return Err(PackError::metadata(format!(
|
||||
"第 {index} 个条目名编码不合法"
|
||||
)));
|
||||
};
|
||||
let raw = raw.to_string();
|
||||
let is_dir = entry.is_dir() || has_trailing_slash(&raw);
|
||||
if entry.is_symlink() {
|
||||
return Err(PackError::symlink(format!("zip 里有符号链接条目:{raw}")));
|
||||
}
|
||||
let name = normalize_file(&raw)?;
|
||||
// 第二检:zip crate 按 Windows 规则再解析一遍(盘符 / ADS / 保留设备名)。
|
||||
if entry.enclosed_name().is_none() {
|
||||
return Err(PackError::traversal(format!(
|
||||
"zip 条目名过不了 enclosed_name 检查:{raw}"
|
||||
)));
|
||||
}
|
||||
let size = if is_dir { 0 } else { entry.size() };
|
||||
check_limits(&limits, &mut total, size, &name, entries.len())?;
|
||||
entries.push(PackEntryInfo {
|
||||
name,
|
||||
raw,
|
||||
size,
|
||||
is_dir,
|
||||
});
|
||||
}
|
||||
Self::finish(root, Source::Zip(RefCell::new(archive)), entries, limits)
|
||||
}
|
||||
|
||||
fn finish(
|
||||
root: PathBuf,
|
||||
source: Source,
|
||||
entries: Vec<PackEntryInfo>,
|
||||
limits: PackLimits,
|
||||
) -> PackResult<Self> {
|
||||
let mut index = HashMap::with_capacity(entries.len());
|
||||
for (slot, entry) in entries.iter().enumerate() {
|
||||
if index.insert(entry.name.clone(), slot).is_some() {
|
||||
return Err(PackError::metadata(format!(
|
||||
"包里有规整后重名的条目:{}",
|
||||
entry.name
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(Self {
|
||||
root,
|
||||
source,
|
||||
entries,
|
||||
index,
|
||||
limits,
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user