fix(core): M110 r1 复审修复:导出安全闸、跨侧重名、第二检用例、半包与失败分级

按 tower 裁定清单逐条改,不扩战场(模型与扩展点未动)。

- p2-1 跨格式导出不再静默丢内容:`PackFormat::check_overrides` 在默认 `write` 写第一个
  字节前判定"这份模型的覆盖目录集合能否被本格式表达"。mrpack 要求目录名由 side 决定
  (overrides / client-overrides / server-overrides)、CF 只接受单一 Both 侧组;表达不了
  即 `pack_unsupported` + hint。`format.rs` 模块文档写死"默认 write 只保证同格式往返",
  并列出 M112 必须补的四件事(目录映射、side 合并、加载器 id 规范化、extra 分桶)。
- p2-2 `duplicate_override_paths` 改为按 `path` 跨侧计数,返回 `(路径, 侧集合)`;
  Both×Client 同路径这条 mrpack 真实冲突现在查得出来。
- p2-3 补 `#[cfg(windows)]` 第二检用例(CON / NUL / COM1.txt / `a.` / 尾空格 / 通配符),
  并订正 `archive.rs` 文档里"保留设备名这里不拦"的自相矛盾(实际由 enclosed_name 在
  Windows 宿主上拦,第一检放行)。
- p3-1 导出改临时文件 + rename,失败清理临时文件:目标路径不再留半个 zip。
- p3-2 三个 `detect` 不再 `.ok().flatten()` 吞错误:清单在但读不出来 = `pack_metadata`;
  注册表汇总时"命中优先于失败",一个都不命中才报,最后才退化成 `pack_unsupported`。
- p3-3 `env_extra`、CF `files[].extra`、`PackLoader::extra` 各补一条读→render→写→再读用例。
- p3-4 新增 `ReadBudget`(Cell<u64>):`override_files` 持有累计预算,按实际读出字节
  逐条扣减,声明大小撒谎的包不能靠"很多条各自合规"绕过 `max_uncompressed_bytes`。
- CF 读侧始终产出一个 Both 侧 overrides 组(字段缺省 `overrides`),与 render 对称。

每处修复都有变异证据(改坏 → 对应用例红):p2-1/p2-2/p2-3/p3-1/p3-2/p3-3a-c/p3-4 共 9 条。
门禁:fmt 0 / clippy --workspace --all-targets -D warnings 0 / cargo test --workspace -j 4
exit 0(58 个 test binary,passed 1930,failed 0);全部改动文件 ≤400 行(最大 389)。
This commit is contained in:
AskaEth committed 2026-11-04 00:20:24 +08:00
1 parent b34e86bba5
commit 6dd652aced
16 files changed
+804 -98

No files matched your search

+63 -1
View File
@@ -136,7 +136,7 @@ fn multimc_needs_a_non_empty_component_graph() {
);
let pack = PackArchive::open_default(&target).expect("打开包");
assert_eq!(
MULTIMC.detect(&pack),
MULTIMC.detect(&pack).expect("mmc-pack.json 可读"),
None,
"空 components 是空壳,不能当 MultiMC 实例"
);
@@ -159,3 +159,65 @@ fn multimc_instance_directory_is_detected() {
assert_eq!(matched.format().id(), "multimc");
assert!(!matched.is_ambiguous());
}
#[test]
fn a_corrupt_manifest_is_a_metadata_error_not_unsupported() {
// r1 p3-2:清单在、但读不出来 = 包坏了(`pack_metadata`)。之前三个 detect 都用
// `.ok().flatten()` 吞掉解析错误,结果被报成「没有格式认领」——M111 的失败分级会据此
// 把"可以重下文件"的包判成"不支持"。
let target = temp_root("corrupt-manifest").join("bad.zip");
write_zip(&target, &[("manifest.json", &br#"{"name": "#[..])]);
let pack = PackArchive::open_default(&target).expect("zip 本身可读");
let error = PackRegistry::builtin()
.detect(&pack)
.expect_err("坏清单必须报错");
assert_eq!(error.kind(), PackErrorKind::Metadata, "{error}");
assert!(
!error.message().contains("没有格式认领"),
"不许把「包坏了」说成「不认识」:{}",
error.message()
);
}
#[test]
fn a_corrupt_foreign_manifest_does_not_hide_a_valid_mrpack() {
// 命中优先于失败:包里躺着一份别人的坏 manifest.json,不该让真 mrpack 也变得不可用。
let target = temp_root("corrupt-foreign").join("mixed.zip");
write_zip(
&target,
&[
("manifest.json", &br#"{"name": "#[..]),
("modrinth.index.json", MR_INDEX),
],
);
let pack = PackArchive::open_default(&target).expect("zip 本身可读");
let matched = PackRegistry::builtin()
.detect(&pack)
.expect("mrpack 命中应当优先于其它格式的坏文件");
assert_eq!(matched.format().id(), "modrinth");
assert!(matched.also_matched().is_empty(), "CF 没命中(它坏了)");
}
#[test]
fn a_missing_manifest_is_not_an_error() {
// 反向对照:**没有** `manifest.json` 是"不是 CF",不是"包坏了"。
let target = temp_root("no-manifest").join("mmc.zip");
write_zip(
&target,
&[(
"mmc-pack.json",
&br#"{"formatVersion":1,"components":[{"uid":"net.minecraft","version":"1.20.1"}]}"#[..],
)],
);
let pack = PackArchive::open_default(&target).expect("zip 本身可读");
assert_eq!(CURSEFORGE.detect(&pack).expect("缺清单不是错误"), None);
assert_eq!(
PackRegistry::builtin()
.detect(&pack)
.expect("MultiMC 应当命中")
.format()
.id(),
"multimc"
);
}
+129
View File
@@ -0,0 +1,129 @@
//! 导出面的安全闸(r1 p2-1 / p3-1):**跨格式导出必须被拒,失败不留半个包**。
//!
//! 这一组不测"映射是否做对"(那是 M112),只测"表达不了的时候会不会静默产出空包"。
use super::{fixtures, temp_root};
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};
use crate::addon::pack::manifest::{OverrideFile, PackManifest, PackOverrides, PackSide};
use crate::addon::pack::modrinth::MODRINTH;
use crate::addon::pack::multimc::MULTIMC;
#[test]
fn a_multimc_instance_cannot_be_written_as_mrpack() {
// r1 p2-1 实测过的那条:MultiMC 模型写 mrpack 会返回 Ok,但覆盖内容落在 `.minecraft/...`,
// mrpack 导入器只解压 overrides / client-overrides / server-overrides ⇒ mods 数量为 0。
// 现在必须在写第一个字节之前就拒。
let root = fixtures::multimc("export-mmc-mr");
let pack = PackArchive::open_default(&root).expect("打开实例目录");
let mut manifest = MULTIMC.read(&pack).expect("读 MultiMC 清单");
manifest.version = Some("1.0.0".to_string()); // mrpack 需要 versionId,别让别的错误顶包
let target = root.with_file_name("cross.mrpack");
let error = MODRINTH
.write(&manifest, PackPayload::from_root(&root), &target)
.expect_err("跨格式导出必须被拒,不能产出 mods 为 0 的包");
assert_eq!(error.kind(), PackErrorKind::Unsupported, "{error}");
assert!(
error.message().contains(".minecraft"),
"错误要说清是哪个目录表达不了:{}",
error.message()
);
assert!(error.hint().is_some(), "要给下一步提示(目录映射属 M112)");
assert!(!target.exists(), "被拒时不许留下目标文件");
}
#[test]
fn an_mrpack_with_split_sides_cannot_be_written_as_curseforge() {
// CF 只有一个 `overrides` 目录字段,表达不了 client/server 分侧;现在是拒,不是猜。
let fixture = fixtures::modrinth("export-mr-cf");
let pack = PackArchive::open_default(&fixture.zip).expect("打开 mrpack 夹具");
let manifest = MODRINTH.read(&pack).expect("读 mrpack 清单");
let target = fixture.zip.with_file_name("cross.zip");
let error = CURSEFORGE
.write(&manifest, PackPayload::from_root(&fixture.dir), &target)
.expect_err("CF 表达不了分侧覆盖目录");
assert_eq!(error.kind(), PackErrorKind::Unsupported, "{error}");
assert!(
error.message().contains("client"),
"错误要指出被拒的是哪一侧:{}",
error.message()
);
assert!(!target.exists(), "被拒时不许留下目标文件");
}
#[test]
fn curseforge_refuses_more_than_one_both_side_group() {
// CF 的 `overrides` 只有一个字段:两个 Both 组必然有一个不被清单声明(静默丢内容)。
let mut manifest = PackManifest::new("两个 Both 组");
manifest.minecraft = Some("1.20.1".to_string());
manifest
.overrides
.push(PackOverrides::new("overrides", PackSide::Both));
manifest
.overrides
.push(PackOverrides::new("extra-overrides", PackSide::Both));
let target = temp_root("export-cf-two").join("out.zip");
let error = CURSEFORGE
.write(&manifest, PackPayload::metadata_only(), &target)
.expect_err("多出来的 Both 组会被清单漏掉,必须拒");
assert_eq!(error.kind(), PackErrorKind::Unsupported, "{error}");
assert!(!target.exists(), "被拒时不许留下目标文件");
}
#[test]
fn a_failed_export_leaves_neither_a_half_pack_nor_a_temp_file() {
// r1 p3-1:清单写完了、覆盖文件读不出来 → 目标路径与临时文件都不许留。
let mut manifest = PackManifest::new("缺覆盖文件的模型");
manifest.minecraft = Some("1.20.1".to_string());
manifest.overrides.push(PackOverrides {
dir: "overrides".to_string(),
side: PackSide::Both,
files: vec![OverrideFile {
path: "mods/missing.jar".to_string(),
sha1: "0".repeat(40),
size: 1,
}],
});
let root = temp_root("export-half");
std::fs::create_dir_all(&root).expect("建目录");
let target = root.join("out.zip");
let error = CURSEFORGE
.write(&manifest, PackPayload::from_root(&root), &target)
.expect_err("覆盖文件不存在时必须失败");
assert_eq!(error.kind(), PackErrorKind::Io, "{error}");
assert!(!target.exists(), "失败后目标路径不许存在");
let leftovers: Vec<String> = std::fs::read_dir(&root)
.expect("列目录")
.filter_map(Result::ok)
.map(|entry| entry.file_name().to_string_lossy().to_string())
.collect();
assert!(
leftovers.is_empty(),
"临时文件没清干净(p3-1 回退):{leftovers:?}"
);
}
#[test]
fn same_format_export_is_still_allowed() {
// 反向对照:这两条闸不能把同格式往返也挡掉(那是 r1 明确要保住的)。
let fixture = fixtures::curseforge("export-same-cf");
let pack = PackArchive::open_default(&fixture.zip).expect("打开 CF 夹具");
let manifest = CURSEFORGE.read(&pack).expect("读 CF 清单");
CURSEFORGE
.check_overrides(&manifest)
.expect("CF 自己读出来的模型必须能被 CF 表达");
let fixture = fixtures::modrinth("export-same-mr");
let pack = PackArchive::open_default(&fixture.zip).expect("打开 mrpack 夹具");
let manifest = MODRINTH.read(&pack).expect("读 mrpack 清单");
MODRINTH
.check_overrides(&manifest)
.expect("mrpack 自己读出来的模型必须能被 mrpack 表达");
}
+5 -3
View File
@@ -35,9 +35,11 @@ impl PackFormat for ToyFormat {
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 detect(&self, pack: &PackArchive) -> PackResult<Option<&'static str>> {
let Some(value) = pack.read_json("toy.json")? else {
return Ok(None);
};
Ok(value.is_object().then_some("toy.json"))
}
fn read(&self, pack: &PackArchive) -> PackResult<PackManifest> {
+3
View File
@@ -3,10 +3,13 @@
//! **全部离线**:没有网络调用,也没有真实整合包样本(见 `fixtures` 的来源声明)。
mod detect;
mod export;
mod extend;
mod fixtures;
mod model;
mod roundtrip;
mod safety;
mod unknown_fields;
use std::fs::File;
use std::io::Write;
+75
View File
@@ -0,0 +1,75 @@
//! 模型级辅助与读取预算:r1 p2-2(跨侧重名)与 p3-4(批量读取的累计限额)。
use super::fixtures;
use crate::addon::pack::archive::{PackArchive, ReadBudget};
use crate::addon::pack::error::PackErrorKind;
use crate::addon::pack::manifest::{OverrideFile, PackManifest, PackOverrides, PackSide};
fn file(path: &str, sha1: &str, size: u64) -> OverrideFile {
OverrideFile {
path: path.to_string(),
sha1: sha1.to_string(),
size,
}
}
#[test]
fn duplicate_override_paths_counts_across_sides_not_per_side() {
// r1 p2-2:mrpack 的真实冲突是 `overrides`(Both) 与 `client-overrides`(Client) 撞同一路径,
// 按 `(side, path)` 计数时两侧键天然不同 ⇒ 永远返回空表。
let mut both = PackOverrides::new("overrides", PackSide::Both);
both.files.push(file("config/x.toml", "a", 1));
both.files.push(file("config/only-both.toml", "b", 1));
let mut client = PackOverrides::new("client-overrides", PackSide::Client);
client.files.push(file("config/x.toml", "c", 2));
let mut server = PackOverrides::new("server-overrides", PackSide::Server);
server.files.push(file("config/only-server.toml", "d", 3));
let manifest = PackManifest {
overrides: vec![both, client, server],
..PackManifest::default()
};
assert_eq!(
manifest.duplicate_override_paths(),
vec![(
"config/x.toml".to_string(),
vec![PackSide::Both, PackSide::Client]
)],
"只报真正撞车的路径,并给出涉及的侧"
);
}
#[test]
fn duplicate_override_paths_is_empty_when_nothing_collides() {
let mut both = PackOverrides::new("overrides", PackSide::Both);
both.files.push(file("config/a.toml", "a", 1));
let mut client = PackOverrides::new("client-overrides", PackSide::Client);
client.files.push(file("config/b.toml", "b", 1));
let manifest = PackManifest {
overrides: vec![both, client],
..PackManifest::default()
};
assert!(manifest.duplicate_override_paths().is_empty());
}
#[test]
fn a_batch_scan_spends_one_cumulative_budget() {
// r1 p3-4:打开阶段只能看声明大小;批量读取(override_files)必须按**实际字节**累计扣减,
// 否则"很多条各自合规"累起来就能绕过 `max_uncompressed_bytes`(CPU/时间 DoS)。
let fixture = fixtures::modrinth("budget");
let pack = PackArchive::open_default(&fixture.zip).expect("打开 mrpack 夹具");
let generous = ReadBudget::new(1024 * 1024);
let files = pack
.override_files_with("overrides", &generous)
.expect("预算够时必须成功");
assert_eq!(files.len(), 2);
// 预算只有 4 字节:第一个文件(config/known.toml,15 字节)就该把预算吃穿。
let tight = ReadBudget::new(4);
let error = pack
.override_files_with("overrides", &tight)
.expect_err("累计预算必须生效");
assert_eq!(error.kind(), PackErrorKind::Limits, "{error}");
assert_eq!(tight.remaining(), 0, "失败后预算不该被退回");
}
@@ -247,7 +247,7 @@ fn curseforge_without_name_is_a_metadata_error() {
);
let pack = PackArchive::open_default(&target).expect("zip 本身可读");
assert_eq!(
CURSEFORGE.detect(&pack),
CURSEFORGE.detect(&pack).expect("清单可读"),
Some("manifest.json"),
"形状对得上就该被探测到"
);
@@ -272,3 +272,41 @@ fn directory_source_rejects_symlinks_when_the_platform_can_make_them() {
);
}
}
#[test]
#[cfg(windows)]
fn the_second_check_catches_windows_device_names_and_dot_suffixes() {
// r1 p2-3:第一检(平台无关的七条)对这些名字是**放行**的,拦下它们的是第二检
// `enclosed_name()` 的 Win32 命名空间规则(DOS 设备名、结尾点/空格、`< > " | ? *`)。
// 删掉第二检,这条用例必须红——这是「双检各有一条恶意样本」的第二条。
for (tag, name) in [
("dev-con", "CON"),
("dev-nul-nested", "mods/NUL"),
("dev-com1", "COM1.txt"),
("dev-trailing-dot", "a."),
("dev-trailing-space", "mods/b "),
("dev-wildcard", "mods/*.jar"),
] {
let target = single_entry(tag, name);
let error = open_error(&target);
assert_eq!(
error.kind(),
PackErrorKind::Traversal,
"{name}: {}",
error.message()
);
assert!(
error.message().contains("enclosed_name"),
"{name} 必须是被第二检拦下的(不然这条用例钉错了地方):{}",
error.message()
);
}
// 对照:第一检对这些名字没有任何意见(所以它们只能靠第二检)。
for name in ["CON", "mods/NUL", "a.", "mods/b ", "mods/*.jar"] {
assert!(
crate::addon::pack::path::normalize_file(name).is_ok(),
"第一检不该拦 {name}(拦了这条用例就失去意义)"
);
}
}
@@ -0,0 +1,113 @@
//! 「未知字段不丢」的三处条目级 flatten(r1 p3-3):`env_extra`、CF `files[].extra`、
//! `PackLoader::extra`。三处实现本来是对的,但此前一条断言都没有——而它们恰好是 M115 点名的
//! HMCL 回写 `fileName`/`url` 的位置。每条都走「读 → render → 写整包 → 再读逐字段相等」。
use serde_json::json;
use crate::addon::pack::archive::PackArchive;
use crate::addon::pack::curseforge::CURSEFORGE;
use crate::addon::pack::format::{PackFormat, PackPayload};
use crate::addon::pack::modrinth::MODRINTH;
#[test]
fn mrpack_env_unknown_keys_survive_read_write_reread() {
// r1 p3-3 的一处:`files[].env` 里的未知键(`env_extra`)此前没有任何断言。
let root = super::temp_root("env-extra");
let dir = root.join("src");
super::write_file(
&dir.join("modrinth.index.json"),
br#"{"game":"minecraft","formatVersion":1,"versionId":"1.0","name":"env extra keys",
"files":[{"path":"mods/a.jar",
"hashes":{"sha512":"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"},
"env":{"client":"required","server":"unsupported","customEnvKey":{"k":1}},
"downloads":["https://example.invalid/a.jar"]}],
"dependencies":{"minecraft":"1.20.1"}}"#,
);
let zip = root.join("pack.mrpack");
super::zip_dir(&dir, &zip);
let pack = PackArchive::open_default(&zip).expect("打开包");
let manifest = MODRINTH.read(&pack).expect("读 mrpack 清单");
assert_eq!(manifest.files[0].env_extra["customEnvKey"], json!({"k": 1}));
let target = root.join("out.mrpack");
MODRINTH
.write(&manifest, PackPayload::metadata_only(), &target)
.expect("导出 mrpack");
let reread = MODRINTH
.read(&PackArchive::open_default(&target).expect("打开导出包"))
.expect("再读");
assert_eq!(reread, manifest, "env 里的未知键必须原样回写");
assert_eq!(reread.files[0].env_extra["customEnvKey"], json!({"k": 1}));
}
#[test]
fn curseforge_file_unknown_keys_survive_read_write_reread() {
// r1 p3-3 的另一处:CF `files[].fileName` / `url` 是 HMCL 补全后回写的扩展点。
let root = super::temp_root("cf-file-extra");
let dir = root.join("src");
super::write_file(
&dir.join("manifest.json"),
br#"{"manifestType":"minecraftModpack","manifestVersion":1,"name":"files extra keys",
"version":"1.0","minecraft":{"version":"1.20.1","modLoaders":[{"id":"forge-47.2.0","primary":true}]},
"files":[{"projectID":238222,"fileID":5102025,"required":true,
"fileName":"jei-1.20.1-15.2.0.27.jar",
"url":"https://edge.forgecdn.net/files/5102/25/jei.jar"}]}"#,
);
let zip = root.join("pack.zip");
super::zip_dir(&dir, &zip);
let pack = PackArchive::open_default(&zip).expect("打开包");
let manifest = CURSEFORGE.read(&pack).expect("读 CF 清单");
assert_eq!(
manifest.files[0].extra["fileName"],
json!("jei-1.20.1-15.2.0.27.jar")
);
assert_eq!(
manifest.files[0].extra["url"],
json!("https://edge.forgecdn.net/files/5102/25/jei.jar")
);
let target = root.join("out.zip");
CURSEFORGE
.write(&manifest, PackPayload::metadata_only(), &target)
.expect("导出 CF");
let reread = CURSEFORGE
.read(&PackArchive::open_default(&target).expect("打开导出包"))
.expect("再读");
assert_eq!(reread, manifest, "files[] 里的未知键必须原样回写");
}
#[test]
fn curseforge_loader_unknown_keys_survive_read_write_reread() {
// r1 p3-3 的第三处:`modLoaders[]` 里的未知键(`PackLoader::extra`)。
let root = super::temp_root("cf-loader-extra");
let dir = root.join("src");
super::write_file(
&dir.join("manifest.json"),
br#"{"manifestType":"minecraftModpack","manifestVersion":1,"name":"loader extra keys",
"version":"1.0",
"minecraft":{"version":"1.20.1",
"modLoaders":[{"id":"forge-47.2.0","primary":true,"cachedName":"Forge 47.2.0"}]},
"files":[]}"#,
);
let zip = root.join("pack.zip");
super::zip_dir(&dir, &zip);
let pack = PackArchive::open_default(&zip).expect("打开包");
let manifest = CURSEFORGE.read(&pack).expect("读 CF 清单");
assert_eq!(
manifest.loaders[0].extra["cachedName"],
json!("Forge 47.2.0")
);
let target = root.join("out.zip");
CURSEFORGE
.write(&manifest, PackPayload::metadata_only(), &target)
.expect("导出 CF");
let reread = CURSEFORGE
.read(&PackArchive::open_default(&target).expect("打开导出包"))
.expect("再读");
assert_eq!(reread, manifest, "modLoaders[] 里的未知键必须原样回写");
assert_eq!(reread.loaders[0].extra["cachedName"], json!("Forge 47.2.0"));
}