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

+161
View File
@@ -0,0 +1,161 @@
//! 探测优先级:顺序写死在注册表里,用例把"谁赢"和"为什么赢"都钉住。
//!
//! 判据分三层,缺一层都可能被"看起来对"糊过去:
//! 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),
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());
}
+170
View File
@@ -0,0 +1,170 @@
//! 扩展点判据:加一个假格式(**一个文件、一条注册**)就能被探测与读写,
//! 且不碰模型、不碰 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) -> Option<&'static str> {
let value = pack.read_json("toy.json").ok().flatten()?;
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
}
+101
View File
@@ -0,0 +1,101 @@
//! `addon::pack` 的单元测试:夹具读回、未知字段往返、探测优先级、扩展点、恶意样本。
//!
//! **全部离线**:没有网络调用,也没有真实整合包样本(见 `fixtures` 的来源声明)。
mod detect;
mod extend;
mod fixtures;
mod roundtrip;
mod safety;
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");
}
@@ -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),
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 的映射一致)"
);
}
+274
View File
@@ -0,0 +1,274 @@
//! 恶意样本:每条安全判据都配一条"会失败的包",而不是靠注释保证。
//!
//! 覆盖:路径穿越四类形状(`..` / 绝对路径 / 反斜杠 / 盘符)、符号链接条目、条目数上限、
//! 解压总体积上限、单条目上限、规整后重名、篡改载荷(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 侧共用同一条判据)"
);
}
}