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,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());
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 的映射一致)"
|
||||
);
|
||||
}
|
||||
@@ -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 侧共用同一条判据)"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user