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

+259
View File
@@ -0,0 +1,259 @@
//! 打包输入的安全读取层:[`PackArchive`] 把"目录"与"zip"统一成"一组规整过的条目"。
//!
//! # 为什么要有这一层
//!
//! 格式探测器要能对**目录**(MultiMC 实例、已解开的整合包)与**zip**(`.zip` / `.mrpack`)
//! 一视同仁地问"有没有 `modrinth.index.json`",而安全判据只有一份实现才不会漂。
//!
//! # 安全判据(每条都有恶意样本测试,见 `tests/safety.rs`)
//!
//! | 威胁 | 做法 |
//! |------|------|
//! | 路径穿越(`../x`、`/etc/x`、`C:\x`、`a\..\b`、`file:ads`) | 条目名过 [`normalize_file`](第一检),再过 `zip` crate 的 `enclosed_name()`(第二检:它按 **Windows 规则**解析盘符/ADS/保留设备名);任一拒绝即整包拒绝 |
//! | 符号链接条目 | `unix_mode()` 的 `S_IFLNK` 位命中即拒绝(Windows 上无合法用途;解压器会把目标当内容写出来) |
//! | 条目数爆炸 | [`PackLimits::max_entries`],打开时按中央目录长度先判 |
//! | 解压体积爆炸(zip 炸弹) | [`PackLimits::max_uncompressed_bytes`] 累计 + [`PackLimits::max_entry_bytes`] 单条目;**读取时**再按实际读出的字节数封顶(中央目录里声明的大小可以撒谎) |
//! | 同名条目(`./a` 与 `a` 规整后撞车) | 规整后的名字重复即拒绝 |
//!
//! **为什么第一检不能省**:`enclosed_name()` 允许"深度 0 处的根目录",也就是
//! `"/etc/passwd"` 会返回 `Some("/etc/passwd")`——绝对路径必须由我们自己的规则先干掉,
//! 库的检查只当跨平台兜底。
//!
//! **不在这里做的事**:不写盘(那是 M111 导入管线的事),所以 Windows 保留设备名
//! (`CON` / `NUL` / `COM1`…)这里不拦——落盘前必须拦,见 `zip::path` 的同名规则。
use std::cell::RefCell;
use std::collections::HashMap;
use std::fs::File;
use std::io::Read;
use std::path::{Path, PathBuf};
use serde_json::Value;
use zip::ZipArchive;
use super::error::{PackError, PackResult};
use super::manifest::OverrideFile;
use super::path::normalize_dir;
use crate::download::types::HashAlgo;
use crate::download::verify::{self, Hasher};
pub mod entry;
pub mod limits;
mod open;
pub use entry::PackEntryInfo;
pub use limits::{
PackLimits, DEFAULT_MAX_ENTRIES, DEFAULT_MAX_ENTRY_BYTES, DEFAULT_MAX_UNCOMPRESSED_BYTES,
};
use limits::read_capped;
/// 输入来源。
pub(super) enum Source {
/// 目录(MultiMC 实例、已解开的包)。
Dir,
/// zip(`.zip` / `.mrpack`)。`RefCell` 让只读方法能取 `&mut ZipArchive`(解压需要)。
Zip(RefCell<ZipArchive<File>>),
}
/// 一个已校验的整合包输入(目录或 zip)。
pub struct PackArchive {
root: PathBuf,
source: Source,
entries: Vec<PackEntryInfo>,
index: HashMap<String, usize>,
limits: PackLimits,
}
impl std::fmt::Debug for PackArchive {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("PackArchive")
.field("root", &self.root)
.field(
"source",
&match self.source {
Source::Dir => "dir",
Source::Zip(_) => "zip",
},
)
.field("entries", &self.entries.len())
.finish()
}
}
impl PackArchive {
/// 打开一个目录或 zip,按 [`PackLimits::default`] 校验。
pub fn open_default(path: impl AsRef<Path>) -> PackResult<Self> {
Self::open(path, PackLimits::default())
}
/// 打开一个目录或 zip,按给定限额校验。
///
/// 校验是**全量**的:打开成功就说明整包的条目名都过了两道穿越检查、没有符号链接、
/// 条数与体积都在限额内。后续读取不再需要重复判断来源是否恶意。
pub fn open(path: impl AsRef<Path>, limits: PackLimits) -> PackResult<Self> {
let root = path.as_ref().to_path_buf();
let metadata = std::fs::symlink_metadata(&root)
.map_err(|error| PackError::io(format!("打不开整合包 {}:{error}", root.display())))?;
if metadata.file_type().is_symlink() {
return Err(PackError::symlink(format!(
"整合包本身是符号链接:{}",
root.display()
)));
}
if metadata.is_dir() {
Self::open_dir(root, limits)
} else {
Self::open_zip(root, limits)
}
}
/// 输入路径。
#[must_use]
pub fn root(&self) -> &Path {
&self.root
}
/// 是否目录输入。
#[must_use]
pub fn is_dir_source(&self) -> bool {
matches!(self.source, Source::Dir)
}
/// 全部条目(目录输入按名字排序,zip 输入保持中央目录顺序)。
#[must_use]
pub fn entries(&self) -> &[PackEntryInfo] {
&self.entries
}
/// 按规整名查一条。
#[must_use]
pub fn entry(&self, name: &str) -> Option<&PackEntryInfo> {
self.index.get(name).map(|slot| &self.entries[*slot])
}
/// 有没有这个条目(探测器用:只问存不存在,不读内容)。
#[must_use]
pub fn has_entry(&self, name: &str) -> bool {
self.index.contains_key(name)
}
/// 读一个条目;不存在返回 `Ok(None)`。
pub fn read(&self, name: &str) -> PackResult<Option<Vec<u8>>> {
let Some(entry) = self.entry(name) else {
return Ok(None);
};
if entry.is_dir {
return Ok(None);
}
match &self.source {
Source::Dir => {
let path = self.root.join(&entry.raw);
let bytes = std::fs::read(&path).map_err(|error| {
PackError::io(format!("读 {} 失败:{error}", path.display()))
})?;
Ok(Some(bytes))
}
Source::Zip(archive) => {
let mut archive = archive.borrow_mut();
let mut reader = archive
.by_name(&entry.raw)
.map_err(|error| PackError::metadata(format!("解压 {name} 失败:{error}")))?;
let bytes = read_capped(&mut reader, self.limits.max_entry_bytes, name)?;
Ok(Some(bytes))
}
}
}
/// 按 UTF-8 读文本条目。
pub fn read_text(&self, name: &str) -> PackResult<Option<String>> {
let Some(bytes) = self.read(name)? else {
return Ok(None);
};
String::from_utf8(bytes)
.map(Some)
.map_err(|error| PackError::metadata(format!("{name} 不是 UTF-8 文本:{error}")))
}
/// 读 JSON 条目;不存在返回 `Ok(None)`。
pub fn read_json(&self, name: &str) -> PackResult<Option<Value>> {
let Some(text) = self.read_text(name)? else {
return Ok(None);
};
let value = serde_json::from_str(&text)
.map_err(|error| PackError::metadata(format!("{name} 不是合法 JSON:{error}")))?;
Ok(Some(value))
}
/// 算一个条目的 SHA-1;不存在返回 `Ok(None)`。
pub fn sha1(&self, name: &str) -> PackResult<Option<String>> {
let Some(entry) = self.entry(name) else {
return Ok(None);
};
if entry.is_dir {
return Ok(None);
}
match &self.source {
Source::Dir => {
let path = self.root.join(&entry.raw);
let digest = verify::digest_file(&path, HashAlgo::Sha1).map_err(|error| {
PackError::io(format!("算 {} 的 SHA-1 失败:{error}", path.display()))
})?;
Ok(Some(digest))
}
Source::Zip(archive) => {
let mut archive = archive.borrow_mut();
let mut reader = archive
.by_name(&entry.raw)
.map_err(|error| PackError::metadata(format!("解压 {name} 失败:{error}")))?;
let mut hasher = Hasher::new(HashAlgo::Sha1);
let mut total = 0_u64;
let mut buffer = [0_u8; 64 * 1024];
loop {
let read = reader
.read(&mut buffer)
.map_err(|error| PackError::io(format!("读 {name} 失败:{error}")))?;
if read == 0 {
break;
}
total += read as u64;
if total > self.limits.max_entry_bytes {
return Err(PackError::limits(format!(
"{name} 实际解压超过单条目上限 {}",
self.limits.max_entry_bytes
)));
}
hasher.update(&buffer[..read]);
}
Ok(Some(hasher.finish()))
}
}
}
/// 收集一个覆盖目录下的文件(相对该目录的路径 + SHA-1 + 字节数),按路径排序。
///
/// 目录不存在时返回空表——"包没带 overrides"不是错误。
pub fn override_files(&self, dir: &str) -> PackResult<Vec<OverrideFile>> {
let dir = normalize_dir(dir)?;
let prefix = format!("{dir}/");
let mut collected = Vec::new();
for entry in &self.entries {
if entry.is_dir || !entry.name.starts_with(&prefix) {
continue;
}
let relative = &entry.name[prefix.len()..];
if relative.is_empty() {
continue;
}
let sha1 = self.sha1(&entry.name)?.unwrap_or_default();
collected.push(OverrideFile {
path: relative.to_string(),
sha1,
size: entry.size,
});
}
collected.sort_by(|left, right| left.path.cmp(&right.path));
Ok(collected)
}
}