feat(m160): 整合包逐文件覆盖/保留决策 + 导入前冲突清单 + 限额口径统一

主任务(逐文件覆盖/保留)
- core: 新增 import/scan.rs —— 只读预检产出「冲突清单」(目标已存在且 SHA-1 不同才进),
  逐条给路径/来源/两侧体积与哈希;用户删过的、内容相同的、被叠层遮蔽的一律不打扰。
- core: 新增 import/decision.rs —— ConflictDecisions(整批档 + 逐项表,逐项优先),
  缺省档恒为 KeepUser;ImportRequest.conflict_policy 改由它承载,CLI 调用点不变。
- core: phase.rs 的 settle 按 path 取决策;非冲突路径不受决策影响。
- presenter: ImportScanView / ImportConflictView / ConflictDecisionsView(全部保留 /
  全部覆盖 / 逐项{default,items})+ PackCommand::Scan / PackEvent::Scan;host 新增只读
  spawn_scan,且新实例路径不再提前建目录(target_of 只算路径,prepare_target 才建)。
- app-iced: 导入前冲突清单页(分页 40 条/页、三入口、计数摘要、确认/取消、两行式行布局);
  预检干净时直接往下导,不打扰用户。出图见 C:/Temp/m160-shots/{pick-light,pick-dark}.png。
- i18n: 新增 18 条 en/zh 文案 + 更新「两者都留」那条口径说明。

限额口径统一
- pack/archive/limits.rs 成为唯一来源:PACK_MAX_ENTRIES/UNCOMPRESSED_BYTES/ENTRY_BYTES
  (20k / 512 MiB / 64 MiB);读侧 PackLimits::default 与写侧 WriteLimits::production 同源。
- 写侧不再直接用 zip::ZIP64_BYTES_THR;新增单条目闸,于是「能导出的包一定能导入」。
  不等式在 pack/tests/limits.rs 里用 const 断言钉死。

测试临时物泄漏(塔台追加项)
- addon/temp.rs:TempRoot —— Drop 即删(panic 展开同样删),Deref/AsRef 到落点路径,
  under()/at() 支持"子树落点、删父根"。形状照 core::update::tests::support::TempDir。
- addon::pack 与 addon::tests 两族全部改用 TempRoot(含 fixtures::Fixture 持守卫、
  multimc/fabric_instance 等返回守卫);4 GiB 稀疏文件的语义一字未改,只改"谁负责删"。
- 自证:pack/tests/mod.rs 三条用例(drop 删 / panic 也删 / 子树删父根)。

测试
- import_decisions.rs(11 例):预检清单、不打扰、逐项决策后的最终文件集、基线记包内哈希。
- roundtrip_three.rs(2 例):叠层 + 冲突夹具 import→export→import 三轮文件集逐字节一致,
  以及「选保留就按设计分叉」的反向证据。
- pack/tests/limits.rs(5 例):单条/总量边界、读侧用同一批常量、写出即可读回。

证据:cargo fmt --check 干净;cargo clippy --workspace --all-targets -D warnings 干净;
cargo test --workspace --all-targets -j 4 → 2507 passed / 0 failed(51 个 test binary)。
This commit is contained in:
worker-modpackpick authored and AskaEth committed 2026-11-07 13:52:43 +08:00
1 parent a4f096eea3
commit 4defc41e8d
64 files changed
+3819 -442

No files matched your search

+2 -1
View File
@@ -50,7 +50,8 @@ mod open;
pub use entry::PackEntryInfo;
pub use limits::{
PackLimits, ReadBudget, DEFAULT_MAX_ENTRIES, DEFAULT_MAX_ENTRY_BYTES,
DEFAULT_MAX_UNCOMPRESSED_BYTES,
DEFAULT_MAX_UNCOMPRESSED_BYTES, PACK_MAX_ENTRIES, PACK_MAX_ENTRY_BYTES,
PACK_MAX_UNCOMPRESSED_BYTES, PACK_ZIP64_BYTES_THR,
};
use limits::read_capped;
+35 -6
View File
@@ -1,5 +1,22 @@
//! 读取限额:条目数 / 解压总体积 / 单条目,以及两处封顶实现。
//!
//! # 只此一份口径(导入读取 **与** 导出写入共用)
//!
//! [`PACK_MAX_ENTRIES`] / [`PACK_MAX_UNCOMPRESSED_BYTES`] / [`PACK_MAX_ENTRY_BYTES`] 是**唯一**
//! 的体积口径:读侧 [`PackLimits::default`] 从这里取,写侧 `pack::format::write` 的预检也从
//! 这里取(以前写侧直接拿 `zip::ZIP64_BYTES_THR`,于是"导得出来、导不回去"——导出允许 ~4 GiB,
//! 导入只允许 512 MiB)。现在的关系是硬约束:
//!
//! ```text
//! PACK_MAX_ENTRY_BYTES ≤ PACK_MAX_UNCOMPRESSED_BYTES < PACK_ZIP64_BYTES_THR ≤ u32::MAX
//! ```
//!
//! 所以「我们能写出的包一定能被自己读回来」,而 ZIP64 阈值退化成一条**不会再被触碰的**
//! 物理上限(`tests/limits.rs` 钉住这条不等式,也钉住两侧用的是同一批常数)。
//!
//! 为什么不让写侧跟着 ZIP64 放宽:读侧把单条目整条读进内存([`read_capped`]),64 MiB 是**内存**
//! 护栏;放宽它等于用一个看不见的内存峰值换一个几乎用不到的大包能力。
//!
//! # 两阶段限额(打开 vs 读取)
//!
//! 打开阶段([`check_limits`])只能拿**中央目录声明的大小**做判断——它会撒谎。
@@ -13,12 +30,24 @@ use std::io::Read;
use super::super::error::{PackError, PackResult};
/// 默认条目数上限(一个巨型整合包 ≈ 数千条,2 万条已经很宽)。
pub const DEFAULT_MAX_ENTRIES: usize = 20_000;
/// 默认解压总体积上限(512 MiB;`.mrpack` 的 `overrides` 通常几十 MiB)。
pub const DEFAULT_MAX_UNCOMPRESSED_BYTES: u64 = 512 * 1024 * 1024;
/// 默认单条目上限(64 MiB;最大的 mod 也不过十几 MiB)。
pub const DEFAULT_MAX_ENTRY_BYTES: u64 = 64 * 1024 * 1024;
/// 条目数上限(一个巨型整合包 ≈ 数千条,2 万条已经很宽)。
pub const PACK_MAX_ENTRIES: usize = 20_000;
/// 解压/打包总体积上限(512 MiB;`.mrpack` 的 `overrides` 通常几十 MiB)。
pub const PACK_MAX_UNCOMPRESSED_BYTES: u64 = 512 * 1024 * 1024;
/// 单条目上限(64 MiB;同时是读侧的**内存**护栏与写侧的单文件护栏)。
pub const PACK_MAX_ENTRY_BYTES: u64 = 64 * 1024 * 1024;
/// ZIP64 的物理阈值(`zip` crate 的口径,≈4 GiB)。
///
/// 生产路径**不再**用它当判据:PACK_MAX_UNCOMPRESSED_BYTES 远低于它(0.5 GiB ≪ 4 GiB),
/// 所以 `tests/limits.rs` 只需钉住"统一上限 < 本阈值",ZIP64 就永远不会被需要。
pub const PACK_ZIP64_BYTES_THR: u64 = zip::ZIP64_BYTES_THR;
/// 默认条目数上限(= [`PACK_MAX_ENTRIES`],保留旧名以免破坏既有调用点)。
pub const DEFAULT_MAX_ENTRIES: usize = PACK_MAX_ENTRIES;
/// 默认解压总体积上限(= [`PACK_MAX_UNCOMPRESSED_BYTES`])。
pub const DEFAULT_MAX_UNCOMPRESSED_BYTES: u64 = PACK_MAX_UNCOMPRESSED_BYTES;
/// 默认单条目上限(= [`PACK_MAX_ENTRY_BYTES`])。
pub const DEFAULT_MAX_ENTRY_BYTES: u64 = PACK_MAX_ENTRY_BYTES;
/// 包读取限额。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+2 -1
View File
@@ -155,7 +155,8 @@ pub trait PackFormat: Debug + Send + Sync {
manifest,
payload,
&temp,
zip::ZIP64_BYTES_THR,
// 生产口径只有一处来源:导入读取用的也是这两个数(见 `archive::limits`)。
write::WriteLimits::production(),
) {
Ok(()) => std::fs::rename(&temp, target).map_err(|error| {
let _ = std::fs::remove_file(&temp);
+87 -36
View File
@@ -21,11 +21,16 @@
//! 后来修对了,误拒也发生在把几个 GiB 读进内存之后。流式之后第二道闸看的是**已写字节数**,
//! 与内存占用无关。
//!
//! # 阈值与声明口径
//! # 阈值与声明口径(**与导入读取同一份常量**)
//!
//! 判据是**未压缩声明的总量**(清单 JSON + 每个覆盖文件的字节数)≥ `zip::ZIP64_BYTES_THR`
//! (≈4 GiB)。zip 条目头与中央目录的几十字节开销不计——这个常数本身就是近似边界,
//! 官方实现同样按这个粗粒度判。
//! 判据是**未压缩声明的总量**(清单 JSON + 每个覆盖文件的字节数)**与单条目体积**,两个数
//! 都来自 `pack::archive::limits`([`WriteLimits::production`]):以前写侧直接拿
//! `zip::ZIP64_BYTES_THR`(≈4 GiB),导入读取只允许 512 MiB ⇒「导得出来、导不回去」。现在
//! 只此一份口径,关系是
//! `PACK_MAX_ENTRY_BYTES ≤ PACK_MAX_UNCOMPRESSED_BYTES < PACK_ZIP64_BYTES_THR`,
//! 所以 ZIP64 阈值永远不会被触碰(`pack/tests/limits.rs` 钉住这条不等式)。
//! zip 条目头与中央目录的几十字节开销不计——这个常数本身就是近似边界,官方实现同样按这个
//! 粗粒度判。
use std::fs::File;
use std::io::{Read, Write};
@@ -57,12 +62,12 @@ pub(super) fn container(
manifest: &PackManifest,
payload: PackPayload<'_>,
temp: &Path,
limit: u64,
limits: WriteLimits,
) -> PackResult<()> {
// 先判总量再碰磁盘:超限时临时文件都不建,更不会读进一份 4 GiB 的内容。
// 先判总量再碰磁盘:超限时临时文件都不建,更不会读进一份几百 MiB 的内容。
// **返回值只属于预检**:写循环有自己的计数器(`Written`),拿它当基数会把全量声明尺寸
// 再累加一遍 ⇒ 过拒窗口(P2-1)。
let _ = preflight(json.len() as u64, manifest, payload, limit)?;
let _ = preflight(json.len() as u64, manifest, payload, limits)?;
let file = File::create(temp)
.map_err(|error| PackError::io(format!("创建 {} 失败:{error}", temp.display())))?;
@@ -78,7 +83,7 @@ pub(super) fn container(
.map_err(|error| PackError::io(format!("写 {entry} 失败:{error}")))?;
// 第二道闸的计数器:**从清单长度重新起算**,与预检的结果无关(见 `Written`)。
let mut written = Written::new(json.len() as u64, limit);
let mut written = Written::new(json.len() as u64, limits.total);
let mut buffer = vec![0_u8; COPY_CHUNK];
for group in &manifest.overrides {
let dir = normalize_dir(&group.dir)?;
@@ -116,15 +121,18 @@ pub(super) fn container(
///
/// 返回值是"清单 + 全部覆盖文件的声明总量",**只给测试与诊断看**。写循环有自己的计数器
/// (从清单长度起算)——曾经把它直接当累加基数,结果每个文件的字节被算两遍,真实总量过半
/// 阈值(~2.15 GiB)就被误拒(P2-1,已有 2.5 GB 的端到端用例钉住这个窗口)。
/// 阈值就被误拒(P2-1,已有端到端用例钉住这个窗口)。
///
/// 判据顺序是**先总量、后单条**:总量超限时给出的那句"包太大"更贴用户的实际处境
/// ("把这个文件移出实例"只对单条超限才有意义)。
fn preflight(
json_len: u64,
manifest: &PackManifest,
payload: PackPayload<'_>,
limit: u64,
limits: WriteLimits,
) -> PackResult<u64> {
let mut total = json_len;
guard_with(total, limit)?;
let mut entries: Vec<(String, u64)> = Vec::new();
for group in &manifest.overrides {
let dir = normalize_dir(&group.dir)?;
for override_file in &group.files {
@@ -138,12 +146,55 @@ fn preflight(
})?
.len();
total += length;
guard_with(total, limit)?;
entries.push((format!("{dir}/{path}"), length));
}
}
guard_with(total, limits.total)?;
for (name, length) in entries {
guard_entry(&name, length, limits.entry)?;
}
Ok(total)
}
/// 写侧的限额口径(**默认值直接取读侧那三个常量**,见 `archive::limits` 的模块文档)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) struct WriteLimits {
/// 未压缩总量上限。
pub(super) total: u64,
/// 单条目上限。
pub(super) entry: u64,
}
impl WriteLimits {
/// 生产口径:与 [`PackLimits::default`](crate::addon::pack::PackLimits) 同一个数。
pub(super) const fn production() -> Self {
Self {
total: super::super::archive::limits::PACK_MAX_UNCOMPRESSED_BYTES,
entry: super::super::archive::limits::PACK_MAX_ENTRY_BYTES,
}
}
/// 显式给值(测试用:把边界缩小到可测尺寸)。
#[cfg(test)]
pub(super) const fn new(total: u64, entry: u64) -> Self {
Self { total, entry }
}
}
/// 单条目闸:与读侧的 `PACK_MAX_ENTRY_BYTES` **同一份口径**。
///
/// 没有这道闸时,"导出 100 MiB 的覆盖文件 → 自己的导入器拒读"是能真实发生的(读侧把一条
/// 内容整块读进内存,所以有单条上限)。写侧照着读侧的上限拒,才不会产出自己读不回来的包。
fn guard_entry(name: &str, length: u64, entry_limit: u64) -> PackResult<()> {
if length > entry_limit {
return Err(PackError::limits(format!(
"覆盖条目 {name} 有 {length} 字节,超过单条目统一上限 {entry_limit}(导入读取用的也是这个数)"
))
.with_hint("把该文件移出实例(或换一个不需要内联它的内容目录)后再导出"));
}
Ok(())
}
/// 单条覆盖文件的来源:来源表优先(跨格式导出用),否则按 `<root>/<dir>/<path>` 拼。
fn source_for(payload: PackPayload<'_>, dir: &str, path: &str) -> PackResult<PathBuf> {
let name = format!("{dir}/{path}");
@@ -183,18 +234,17 @@ impl Written {
}
}
/// ZIP64 阈值闸(`pack_limits`):`total` **达到**阈值就拒(`>=` 不是 `>`,官方实现也是
/// "撞上就不写")。
/// 总量闸(`pack_limits`):`total` **达到**上限就拒(`>=` 不是 `>`,撞上就不写)。
///
/// 生产路径的 `limit` 恒为 `zip::ZIP64_BYTES_THR`(由 `format.rs` 传下来);把它做成参数是为了
/// 能用几十字节的小数值钉住"边界在 `>=` 上"和"写循环不许重复累计"这两件事——真造 4 GiB 的
/// 包要几十秒 CPU 与一大片内存/页面文件,本机资源纪律下不经默认套件跑(塔台 2026-11-07)。
/// 生产路径的 `limit` 来自 [`WriteLimits::production`](= 读侧 `PACK_MAX_UNCOMPRESSED_BYTES`);
/// 把它做成参数是为了能用几十字节的小数值钉住"边界在 `>=` 上"和"写循环不许重复累计"这两件事。
fn guard_with(total: u64, limit: u64) -> PackResult<()> {
if total >= limit {
return Err(PackError::limits(format!(
"包体未压缩总量已超过 {limit} 字节(ZIP64 阈值),拒绝写出一个需要 ZIP64 的整合包"
"包体未压缩总量超过统一上限 {limit} 字节(ZIP64 阈值 {} 远在其上,所以永远不需要写 ZIP64)",
super::super::archive::limits::PACK_ZIP64_BYTES_THR
))
.with_hint("把超大的覆盖文件移出实例后再导出;mrpack/CF 都不接受 ZIP64"));
.with_hint("把超大的覆盖文件移出实例后再导出;导入读取用的是同一个上限"));
}
Ok(())
}
@@ -219,22 +269,19 @@ mod tests {
#[test]
fn the_second_gate_counts_from_the_manifest_length_not_the_preflight_total() {
// P2-1 的窗口:真实总量在 [阈值一半, 阈值) 之间的包**不许**被拒。这一条不真写
// 2.5 GB(那要 ~70 秒 deflate 与一大片内存/页面文件),而是直接把窗口里的字节数
// P2-1 的窗口:真实总量在 [上限一半, 上限) 之间的包**不许**被拒。这一条不真写
// 几百 MiB(那要几十秒 deflate 与一大片内存/页面文件),而是直接把窗口里的字节数
// 喂给第二道闸的计数器——它的起点是清单长度,所以只有"把预检的全量 total 当基数"
// 那种写法才会在这里红(红法是 `spend` 返回 Limits)。
let half = zip::ZIP64_BYTES_THR / 2;
let mut written = Written::new(28, zip::ZIP64_BYTES_THR);
written
.spend(2_500_000_000)
.expect("2.5 GB 没到 4 GiB,第二道闸不许拒");
assert!(2_500_000_000 > half, "样本要落在过拒窗口里");
let limit = WriteLimits::production().total;
let inside = limit / 2 + limit / 4;
let mut written = Written::new(28, limit);
written.spend(inside).expect("没到统一上限,第二道闸不许拒");
assert!(inside > limit / 2, "样本要落在过拒窗口里");
// 对照:撞上阈值必须拒,且正好在阈值处(不是超一点点才拒)。
let mut written = Written::new(28, zip::ZIP64_BYTES_THR);
let error = written
.spend(zip::ZIP64_BYTES_THR)
.expect_err("撞阈值必须拒");
// 对照:撞上上限必须拒,且正好在上限处(不是超一点点才拒)。
let mut written = Written::new(28, limit);
let error = written.spend(limit).expect_err("撞上限必须拒");
assert_eq!(
error.kind(),
super::super::super::error::PackErrorKind::Limits
@@ -275,7 +322,7 @@ mod tests {
&manifest,
PackPayload::from_root(&root),
&target,
limit,
WriteLimits::new(limit, 1024),
)
.expect("声明总量没到 limit:写循环不许把预检的 total 重复累计");
@@ -288,7 +335,7 @@ mod tests {
&manifest,
PackPayload::from_root(&root),
&refused,
declared,
WriteLimits::new(declared, 1024),
)
.expect_err("声明总量达到 limit 必须拒");
assert_eq!(
@@ -333,9 +380,13 @@ mod tests {
7,
&manifest,
PackPayload::from_root(&root),
zip::ZIP64_BYTES_THR,
// 本条只关心"预检不读内容",所以两道闸都放到最宽(生产口径见 `tests/limits.rs`)。
WriteLimits::new(
crate::addon::pack::archive::limits::PACK_ZIP64_BYTES_THR,
crate::addon::pack::archive::limits::PACK_ZIP64_BYTES_THR,
),
)
.expect("3 GiB 总量仍在阈值之下,预检不许误伤");
.expect("3 GiB 总量仍在最宽闸之下,预检不许误伤");
assert_eq!(
total,
7 + 10 + 3 * 1024 * 1024 * 1024,
+20 -11
View File
@@ -5,7 +5,7 @@
//! 2. 同一份包命中多格式时选谁(含 `also_matched` 记录的其它命中);
//! 3. **换一下注册顺序,赢家跟着换**——这才证明优先级来自注册表,而不是代码里散落的 if。
use super::{fixtures, temp_root, write_zip};
use super::{fixtures, write_zip, TempRoot};
use crate::addon::pack::archive::PackArchive;
use crate::addon::pack::curseforge::CURSEFORGE;
use crate::addon::pack::error::PackErrorKind;
@@ -23,8 +23,9 @@ 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");
fn hybrid_pack(tag: &str) -> TempRoot {
// 返回的是**守卫**(Deref 到 `hybrid.zip`):包文件活到调用点用完为止。
let target = TempRoot::at(tag, "hybrid.zip");
write_zip(
&target,
&[
@@ -48,7 +49,8 @@ fn builtin_priority_is_pinned() {
#[test]
fn curseforge_wins_when_a_pack_carries_every_marker() {
let pack = PackArchive::open_default(hybrid_pack("detect-hybrid")).expect("打开混合包");
let hybrid = hybrid_pack("detect-hybrid");
let pack = PackArchive::open_default(&hybrid).expect("打开混合包");
let matched = PackRegistry::builtin().detect(&pack).expect("要能选出格式");
assert_eq!(matched.format().id(), "curseforge");
assert_eq!(
@@ -62,7 +64,8 @@ fn curseforge_wins_when_a_pack_carries_every_marker() {
#[test]
fn a_plain_manifest_json_does_not_claim_curseforge() {
// `manifest.json` 这名字太泛:形状不符就不能认领,否则 mrpack 会被抢走。
let target = temp_root("detect-plain").join("plain.zip");
let root = TempRoot::new("detect-plain");
let target = root.join("plain.zip");
write_zip(
&target,
&[
@@ -79,7 +82,8 @@ fn a_plain_manifest_json_does_not_claim_curseforge() {
#[test]
fn registration_order_decides_the_winner() {
// 同一份包同时有 CF 与 mrpack 标记:只换注册顺序,赢家就该换。
let target = temp_root("detect-order").join("both.zip");
let root = TempRoot::new("detect-order");
let target = root.join("both.zip");
write_zip(
&target,
&[
@@ -107,7 +111,8 @@ fn registration_order_decides_the_winner() {
#[test]
fn unknown_package_is_rejected_with_the_list_of_tried_formats() {
let target = temp_root("detect-unknown").join("unknown.zip");
let root = TempRoot::new("detect-unknown");
let target = root.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());
@@ -126,7 +131,8 @@ fn unknown_package_is_rejected_with_the_list_of_tried_formats() {
#[test]
fn multimc_needs_a_non_empty_component_graph() {
let target = temp_root("detect-mmc-empty").join("empty-mmc.zip");
let root = TempRoot::new("detect-mmc-empty");
let target = root.join("empty-mmc.zip");
write_zip(
&target,
&[(
@@ -165,7 +171,8 @@ 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");
let root = TempRoot::new("corrupt-manifest");
let target = root.join("bad.zip");
write_zip(&target, &[("manifest.json", &br#"{"name": "#[..])]);
let pack = PackArchive::open_default(&target).expect("zip 本身可读");
@@ -183,7 +190,8 @@ fn a_corrupt_manifest_is_a_metadata_error_not_unsupported() {
#[test]
fn a_corrupt_foreign_manifest_does_not_hide_a_valid_mrpack() {
// 命中优先于失败:包里躺着一份别人的坏 manifest.json,不该让真 mrpack 也变得不可用。
let target = temp_root("corrupt-foreign").join("mixed.zip");
let root = TempRoot::new("corrupt-foreign");
let target = root.join("mixed.zip");
write_zip(
&target,
&[
@@ -202,7 +210,8 @@ fn a_corrupt_foreign_manifest_does_not_hide_a_valid_mrpack() {
#[test]
fn a_missing_manifest_is_not_an_error() {
// 反向对照:**没有** `manifest.json` 是"不是 CF",不是"包坏了"。
let target = temp_root("no-manifest").join("mmc.zip");
let root = TempRoot::new("no-manifest");
let target = root.join("mmc.zip");
write_zip(
&target,
&[(
+6 -4
View File
@@ -2,7 +2,7 @@
//!
//! 这一组不测"映射是否做对"(那是 M112),只测"表达不了的时候会不会静默产出空包"。
use super::{fixtures, temp_root};
use super::{fixtures, TempRoot};
use crate::addon::pack::archive::PackArchive;
use crate::addon::pack::curseforge::CURSEFORGE;
use crate::addon::pack::error::PackErrorKind;
@@ -67,7 +67,8 @@ fn curseforge_refuses_more_than_one_both_side_group() {
.overrides
.push(PackOverrides::new("extra-overrides", PackSide::Both));
let target = temp_root("export-cf-two").join("out.zip");
let root = TempRoot::new("export-cf-two");
let target = root.join("out.zip");
let error = CURSEFORGE
.write(&manifest, PackPayload::metadata_only(), &target)
.expect_err("多出来的 Both 组会被清单漏掉,必须拒");
@@ -90,7 +91,7 @@ fn a_failed_export_leaves_neither_a_half_pack_nor_a_temp_file() {
}],
});
let root = temp_root("export-half");
let root = TempRoot::new("export-half");
std::fs::create_dir_all(&root).expect("建目录");
let target = root.join("out.zip");
let error = CURSEFORGE
@@ -142,7 +143,8 @@ fn mrpack_refuses_two_override_groups_for_the_same_side() {
.overrides
.push(PackOverrides::new("overrides", PackSide::Both));
let target = temp_root("export-mr-same-side").join("out.mrpack");
let root = TempRoot::new("export-mr-same-side");
let target = root.join("out.mrpack");
let error = MODRINTH
.write(&manifest, PackPayload::metadata_only(), &target)
.expect_err("同一侧两个覆盖目录必须拒");
@@ -5,9 +5,9 @@
use std::cell::RefCell;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::path::Path;
use super::{temp_root, write_file};
use super::{write_file, TempRoot};
use crate::addon::export::catalog::{RemoteCatalog, RemoteHit};
use crate::addon::export::scan::{scan_instance, DEFAULT_CONTENT_ROOTS};
use crate::addon::pack::curseforge::fingerprint;
@@ -46,8 +46,9 @@ pub(super) fn write_layer(game: &Path, id: &str, json: &str) {
}
/// 一个 Fabric 实例目录:两个 mod(其中一个禁用)+ 一份 config + 一份白名单外的日志。
pub(super) fn fabric_instance(tag: &str) -> PathBuf {
let root = temp_root(tag).join("game");
pub(super) fn fabric_instance(tag: &str) -> TempRoot {
// 返回守卫本身(`Deref` 到 `<root>/game`):实例目录活到调用点用完为止,drop 即删。
let root = TempRoot::under(tag, "game");
write_layer(&root, "1.20.1", VANILLA_LAYER);
write_layer(&root, "1.20.1-fabric-0.15.11", FABRIC_LAYER);
write_file(&root.join("mods/jei.jar"), b"PK\x03\x04 jei body");
@@ -58,8 +59,8 @@ pub(super) fn fabric_instance(tag: &str) -> PathBuf {
}
/// 一个 NeoForge 1.20.1 实例目录(CF `modLoaders[].id` 的 MC 前缀特例用它)。
pub(super) fn neoforge_instance(tag: &str) -> PathBuf {
let root = temp_root(tag).join("game");
pub(super) fn neoforge_instance(tag: &str) -> TempRoot {
let root = TempRoot::under(tag, "game");
write_layer(&root, "1.20.1", VANILLA_LAYER);
write_layer(&root, "1.20.1-neoforge-47.1.106", NEOFORGE_LAYER);
write_file(&root.join("mods/jei.jar"), b"PK\x03\x04 jei body");
@@ -10,7 +10,7 @@ use std::path::Path;
use super::export_harness::{
defaults, digests, fabric_instance, modrinth_hit, neoforge_instance, StubCatalog,
};
use super::{temp_root, write_file};
use super::{write_file, TempRoot};
use crate::addon::export::classify::classify_with;
use crate::addon::export::scan::{is_disabled_path, scan_instance};
use crate::addon::export::{export_instance, ExportRequest, FileClass, FileReason, RemoteHit};
@@ -300,7 +300,7 @@ fn a_disabled_file_is_still_disabled_after_a_round_trip_through_the_package() {
let target = root.with_file_name("disabled.mrpack");
export_instance(&ExportRequest::new(&root, &target, &MODRINTH)).expect("导出");
let again = temp_root("export-disabled-reimport");
let again = TempRoot::new("export-disabled-reimport");
let file = std::fs::File::open(&target).expect("打开包");
let mut archive = zip::ZipArchive::new(file).expect("读 zip");
for index in 0..archive.len() {
@@ -17,12 +17,12 @@
//! | mrpack → mrpack | `files[]` 的条目要靠"文件真的在磁盘上"才会再写回去 | 读出来的模型里那两条是远程条目、本地没有字节;再导出时以磁盘为准 |
//! | MultiMC → mrpack | 非加载器组件(`org.lwjgl3`)不进 `dependencies` | 它不是加载器;mrpack 的 `dependencies` 只表达 loader |
use std::path::{Path, PathBuf};
use std::path::Path;
use super::export_harness::{
defaults, modrinth_hit, write_layer, StubCatalog, FORGE_LAYER, VANILLA_LAYER,
};
use super::{fixtures, temp_root, write_file};
use super::{fixtures, write_file, TempRoot};
use crate::addon::export::snapshot::{InstanceSnapshot, SnapshotLoader};
use crate::addon::export::{export_instance, ExportRequest};
use crate::addon::pack::archive::PackArchive;
@@ -34,8 +34,8 @@ use crate::addon::pack::multimc::MULTIMC;
use crate::addon::LoaderKind;
/// 把夹具里的覆盖内容铺成一个实例目录(`<game>/<path>`),并写一份版本 JSON。
fn instance_from(fixture_dir: &Path, manifest: &PackManifest, tag: &str) -> PathBuf {
let game = temp_root(tag).join("game");
fn instance_from(fixture_dir: &Path, manifest: &PackManifest, tag: &str) -> TempRoot {
let game = TempRoot::under(tag, "game");
for (_, file) in manifest.override_files() {
let group = manifest
.overrides
@@ -226,7 +226,7 @@ fn multimc_fixture_exports_as_mrpack_from_its_game_directory() {
assert!(MULTIMC.metadata_entry().is_none(), "MultiMC 导出不做");
// 重新打成 MultiMC 形状的目录以确认没有丢内容(把 overrides 解出来再比)。
let unpacked = temp_root("rt-mmc-unpacked");
let unpacked = TempRoot::new("rt-mmc-unpacked");
for entry in pack.entries() {
let name = entry.name();
let Some(relative) = name.strip_prefix("overrides/") else {
+4 -4
View File
@@ -6,7 +6,7 @@
use serde_json::{json, Value};
use super::{temp_root, write_file, write_zip};
use super::{write_file, write_zip, TempRoot};
use crate::addon::pack::archive::PackArchive;
use crate::addon::pack::curseforge::CURSEFORGE;
use crate::addon::pack::error::{PackError, PackErrorKind, PackResult};
@@ -87,8 +87,8 @@ impl PackFormat for ToyFormat {
}
/// 造一个 toy 包(源目录形式,顺带能当导出 payload)。
fn toy_pack(tag: &str) -> std::path::PathBuf {
let dir = temp_root(tag).join("toy");
fn toy_pack(tag: &str) -> TempRoot {
let dir = TempRoot::under(tag, "toy");
write_file(
&dir.join("toy.json"),
br#"{"name":"Toy Pack","minecraft":"1.21","mods":["mods/a.jar","mods/b.jar"]}"#,
@@ -140,7 +140,7 @@ fn a_new_format_needs_one_registration_line() {
#[test]
fn appending_a_format_never_steals_existing_packs() {
// 同一份包同时有 CF 清单与 toy 清单。
let root = temp_root("extend-priority");
let root = TempRoot::new("extend-priority");
let target = root.join("both.zip");
write_zip(
&target,
+23 -7
View File
@@ -22,14 +22,19 @@
use std::path::PathBuf;
use super::{temp_root, write_file, zip_dir};
use super::{write_file, zip_dir, TempRoot};
/// 一个既有源目录(可当导出 payload)又有打好的 zip 的夹具。
///
/// `_root` 是临时根的**守卫**:夹具活多久,目录就活多久;夹具 drop(含测试 panic 展开)时
/// 整个临时根被删。没有它,这些手造包会一直堆在 `%TEMP%` 里(M160 r2:实测堆到 110 GB)。
pub(crate) struct Fixture {
/// 源目录(zip 的内容来源,也是 `PackPayload::from_root` 的根)。
pub dir: PathBuf,
/// 打好的包。
pub zip: PathBuf,
/// 临时根守卫(只为了在夹具存活期间保住 `dir` / `zip`)。
_root: TempRoot,
}
/// CF 夹具的包名。
@@ -130,7 +135,7 @@ const MMC_INSTANCE: &str = "InstanceType=OneSix\nname=M110 手造 MultiMC 实例
/// CurseForge 夹具(`manifest.json` + `overrides/`,含一个截断的 TOML)。
pub(crate) fn curseforge(tag: &str) -> Fixture {
let root = temp_root(tag);
let root = TempRoot::new(tag);
let dir = root.join("src");
write_file(&dir.join("manifest.json"), CF_MANIFEST.as_bytes());
write_file(
@@ -144,12 +149,16 @@ pub(crate) fn curseforge(tag: &str) -> Fixture {
write_file(&dir.join("overrides/config/broken.toml"), CF_BROKEN);
let zip = root.join("pack.zip");
zip_dir(&dir, &zip);
Fixture { dir, zip }
Fixture {
dir,
zip,
_root: root,
}
}
/// Modrinth 夹具(`modrinth.index.json` + 三个覆盖目录,含一个半截 jar)。
pub(crate) fn modrinth(tag: &str) -> Fixture {
let root = temp_root(tag);
let root = TempRoot::new(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);
@@ -164,12 +173,19 @@ pub(crate) fn modrinth(tag: &str) -> Fixture {
);
let zip = root.join("pack.mrpack");
zip_dir(&dir, &zip);
Fixture { dir, zip }
Fixture {
dir,
zip,
_root: root,
}
}
/// MultiMC 夹具:**目录形式**(真实形态就是实例目录,不是 zip),含一个零字节 jar。
pub(crate) fn multimc(tag: &str) -> PathBuf {
let root = temp_root(tag).join("instance");
///
/// 返回 `TempRoot`(`Deref<Target = Path>` 指向 `<root>/instance`):调用点照旧当路径用,
/// 而临时根由返回值持有——用到哪、活到哪、drop 就删。
pub(crate) fn multimc(tag: &str) -> TempRoot {
let root = TempRoot::under(tag, "instance");
write_file(&root.join("mmc-pack.json"), MMC_PACK.as_bytes());
write_file(&root.join("instance.cfg"), MMC_INSTANCE.as_bytes());
write_file(
+205
View File
@@ -0,0 +1,205 @@
//! 限额口径的统一(M160):**导入读取与导出写入只此一份常数**,且不等式关系钉死在这里。
//!
//! # 为什么需要这个文件
//!
//! M152 之前两侧各写各的:读侧 `PackLimits` 是 20 000 条 / 512 MiB / 64 MiB,写侧直接拿
//! `zip::ZIP64_BYTES_THR`(≈4 GiB)与 u32。结果是一个**能导出、导不回来**的包是合法的。
//! 现在写侧改读同一份常量(`WriteLimits::production`),本文件就是那条口径的证据:
//!
//! 1. 三个常量之间的关系(`entry ≤ total < ZIP64 ≤ u32`);
//! 2. 读侧 `PackLimits::default()` 就是这三个数;
//! 3. 写侧在**单条边界**上拒(`>` 而不是 `≥`:恰好 64 MiB 合法);
//! 4. 写侧在**总量**上拒,且拒绝发生在读内容之前(临时目录里什么都不留)。
use super::{write_file, TempRoot};
use crate::addon::pack::archive::limits::{
PACK_MAX_ENTRIES, PACK_MAX_ENTRY_BYTES, PACK_MAX_UNCOMPRESSED_BYTES, PACK_ZIP64_BYTES_THR,
};
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::PackLimits;
/// 一个覆盖文件的声明(内容由调用方提供)。
fn override_file(path: &str) -> OverrideFile {
OverrideFile {
path: path.to_string(),
sha1: "0".repeat(40),
size: 0,
}
}
fn manifest_of(files: Vec<OverrideFile>) -> PackManifest {
let mut manifest = PackManifest::new("限额口径");
manifest.version = Some("1.0.0".to_string());
manifest.minecraft = Some("1.20.1".to_string());
manifest.overrides.push(PackOverrides {
dir: "overrides".to_string(),
side: PackSide::Both,
files,
});
manifest
}
/// 造一个只占元数据的 `length` 字节文件(`set_len` 不写零)。
fn sparse_file(path: &std::path::Path, length: u64) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("建目录");
}
let file = std::fs::File::create(path).expect("建稀疏文件");
file.set_len(length).expect("拉长文件(稀疏)");
}
#[test]
fn the_three_limits_form_one_chain_and_the_read_side_uses_them() {
// ① 关系:单条 ≤ 总量 < ZIP64 ≤ u32。前一半保证"能写出来的包读得回来",
// 后一半保证我们永远不需要写 ZIP64,也永远撞不到 mrpack 的 fileSize(u32) 上限。
// 这三条是**编译期**断言(`const` 块):改坏常量会在 `cargo check` 就红,不用等跑测试。
const _: () = assert!(
PACK_MAX_ENTRY_BYTES <= PACK_MAX_UNCOMPRESSED_BYTES,
"单条上限必须 ≤ 总量上限(否则单条规则永远够不着)"
);
const _: () = assert!(
PACK_MAX_UNCOMPRESSED_BYTES < PACK_ZIP64_BYTES_THR,
"总量上限必须严格低于 ZIP64 阈值"
);
const _: () = assert!(
PACK_ZIP64_BYTES_THR <= u32::MAX as u64,
"ZIP64 阈值也不该越过 u32(mrpack fileSize 的口径)"
);
// ② 读侧默认限额就是这三个数(不是"看起来一样"的另一份拷贝)。
let limits = PackLimits::default();
assert_eq!(limits.max_entries, PACK_MAX_ENTRIES);
assert_eq!(limits.max_uncompressed_bytes, PACK_MAX_UNCOMPRESSED_BYTES);
assert_eq!(limits.max_entry_bytes, PACK_MAX_ENTRY_BYTES);
}
#[test]
fn the_write_side_refuses_a_single_entry_over_the_unified_entry_limit() {
let root = TempRoot::new("limits-entry");
// 恰好 +1 字节超限:拒绝理由是**单条**上限(总量 64 MiB + 1 仍在 512 MiB 之下)。
sparse_file(
&root.join("overrides/mods/too-big.jar"),
PACK_MAX_ENTRY_BYTES + 1,
);
let target = root.join("out.zip");
let error = CURSEFORGE
.write(
&manifest_of(vec![override_file("mods/too-big.jar")]),
PackPayload::from_root(&root),
&target,
)
.expect_err("超过单条统一上限必须拒");
assert_eq!(error.kind(), PackErrorKind::Limits, "{error}");
assert!(
error.message().contains(&PACK_MAX_ENTRY_BYTES.to_string()),
"文案要给出那个数(读侧用的是同一个):{error}"
);
assert!(
error.hint().is_some_and(|hint| hint.contains("移出实例")),
"要给可执行的下一步:{error:?}"
);
assert!(!target.exists(), "预检失败时目标路径不许出现");
// 预检在建临时文件之前就失败 ⇒ 目录里只有源目录,没有 `out.zip.*.part`。
let leftovers: Vec<String> = std::fs::read_dir(&root)
.expect("列目录")
.filter_map(Result::ok)
.map(|entry| entry.file_name().to_string_lossy().to_string())
.filter(|name| name != "overrides")
.collect();
assert!(leftovers.is_empty(), "不许留临时文件:{leftovers:?}");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn the_entry_boundary_is_exactly_the_limit() {
// 边界口径是 `>`:恰好 64 MiB 合法(与读侧的 `size > max_entry_bytes` 同一侧)。
let root = TempRoot::new("limits-boundary");
sparse_file(&root.join("overrides/mods/exact.jar"), PACK_MAX_ENTRY_BYTES);
let target = root.join("out.zip");
CURSEFORGE
.write(
&manifest_of(vec![override_file("mods/exact.jar")]),
PackPayload::from_root(&root),
&target,
)
.expect("恰好等于上限必须写得出来(否则两侧的边界就错开了)");
let written = crate::addon::pack::PackArchive::open(
&target,
// 读回来时单条上限就是"恰好等于它",证明写出来的东西读侧认。
PackLimits {
max_entry_bytes: PACK_MAX_ENTRY_BYTES,
..PackLimits::default()
},
)
.expect("刚写出来的包必须能被自己的读取层打开");
assert!(written.has_entry("overrides/mods/exact.jar"));
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn the_write_side_refuses_a_total_over_the_unified_total_limit() {
// 每条都在单条上限之内(64 MiB),但只要 9 条就超总量(576 MiB > 512 MiB)——
// 这条与"单条规则"是两件事:判据是**累计**。
let root = TempRoot::new("limits-total");
let mut files = Vec::new();
for index in 0..9 {
let relative = format!("mods/part-{index}.jar");
sparse_file(
&root.join("overrides").join(&relative),
PACK_MAX_ENTRY_BYTES,
);
files.push(override_file(&relative));
}
let total = PACK_MAX_ENTRY_BYTES * 9;
assert!(total > PACK_MAX_UNCOMPRESSED_BYTES, "样本必须真的超总量");
let target = root.join("out.zip");
let error = CURSEFORGE
.write(&manifest_of(files), PackPayload::from_root(&root), &target)
.expect_err("累计超总量必须拒");
assert_eq!(error.kind(), PackErrorKind::Limits, "{error}");
assert!(
error
.message()
.contains(&PACK_MAX_UNCOMPRESSED_BYTES.to_string()),
"文案要给出统一总量上限:{error}"
);
assert!(!target.exists());
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_real_pack_written_under_the_limit_reopens_under_the_read_limits() {
// 端到端的那句话:"我们能写出的包一定能被自己读回来"。用一份正常尺寸的覆盖内容跑一遍。
let root = TempRoot::new("limits-parity");
write_file(&root.join("overrides/mods/jei.jar"), b"PK\x03\x04 jei");
write_file(
&root.join("overrides/config/known.toml"),
b"shared = true\n",
);
let target = root.join("out.mrpack");
crate::addon::pack::modrinth::MODRINTH
.write(
&manifest_of(vec![
override_file("mods/jei.jar"),
override_file("config/known.toml"),
]),
PackPayload::from_root(&root),
&target,
)
.expect("正常尺寸必须写得出来");
let pack = crate::addon::pack::PackArchive::open_default(&target)
.expect("默认限额(= 统一口径)必须读得回来");
assert!(pack.has_entry("overrides/mods/jei.jar"));
assert!(pack.has_entry("overrides/config/known.toml"));
let _ = std::fs::remove_dir_all(&root);
}
+68 -7
View File
@@ -1,6 +1,17 @@
//! `addon::pack` 的单元测试:夹具读回、未知字段往返、探测优先级、扩展点、恶意样本。
//!
//! **全部离线**:没有网络调用,也没有真实整合包样本(见 `fixtures` 的来源声明)。
//!
//! # 临时目录必须**测试自己收尾**(M160 r2)
//!
//! 每个用例的临时根是 [`TempRoot`]:`Drop` 里 `remove_dir_all`,所以**正常结束与 panic 展开
//! 都会删**(Rust 展开会跑析构)。这条不是洁癖:`zip64` 那族用例要造 4 GiB 的**稀疏文件**验
//! 阈值,残留会把 `%TEMP%` 堆到撑爆整台机器(实测 1651 个目录 / 110 GB,把 CI 与别的 agent
//! 的构建一起咬死)。
//!
//! 写法的来源是仓库既有的那一份(`crate::update::tests::support::TempDir`):同样的 `new` /
//! `join` / `Drop` 三件套,只把前缀换成 `ull-pack-`,不引 `tempfile`、不新增第四种范式。
//! 不信"系统会自动清 `%TEMP%`"——那要靠时间或外部工具,测试必须自证干净。
mod detect;
mod export;
@@ -9,6 +20,7 @@ mod export_pipeline;
mod export_roundtrip;
mod extend;
mod fixtures;
mod limits;
mod model;
mod roundtrip;
mod safety;
@@ -18,14 +30,8 @@ mod zip64;
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) use crate::addon::temp::TempRoot;
/// 写一个夹具文件(自动建父目录)。
pub(crate) fn write_file(path: &Path, bytes: &[u8]) {
@@ -106,3 +112,58 @@ pub(crate) fn write_zip_with_symlink(
.expect("写符号链接条目");
writer.finish().expect("收尾 zip");
}
#[test]
fn the_temp_root_is_gone_once_dropped() {
// 自证①:正常路径。用完(drop)之后连目录都不该存在。
let path = {
let root = TempRoot::new("self-proof");
write_file(&root.join("overrides/mods/a.jar"), b"x");
assert!(root.path().is_dir(), "活在的时候必须是真目录");
root.path().to_path_buf()
};
assert!(
!path.exists(),
"drop 之后临时根必须被删掉:{}",
path.display()
);
}
#[test]
fn a_panicking_test_still_removes_its_temp_root() {
// 自证②:panic 展开路径。断言"panic 也删"——这正是 4 GiB 稀疏文件残留的那条路:
// 断言红了不代表可以留下 110 GB。
let path = std::sync::Arc::new(std::sync::Mutex::new(PathBuf::new()));
let slot = std::sync::Arc::clone(&path);
let outcome = std::panic::catch_unwind(move || {
let root = TempRoot::new("self-proof-panic");
write_file(&root.join("overrides/mods/a.jar"), b"x");
*slot.lock().expect("锁") = root.path().to_path_buf();
panic!("故意失败:展开时守卫要照删不误");
});
assert!(outcome.is_err(), "这个用例必须真的走 panic 分支");
let path = path.lock().expect("锁").clone();
assert!(
!path.exists(),
"panic 之后临时根必须被删掉:{}",
path.display()
);
}
#[test]
fn a_sub_directory_root_takes_its_parent_with_it() {
// 自证③:`under` 造的子树删的是**根**,不给 `%TEMP%` 留一堆空壳父目录。
let (sub, parent) = {
let root = TempRoot::under("self-proof-under", "instance");
write_file(&root.join("mmc-pack.json"), b"{}");
(
root.path().to_path_buf(),
root.path().parent().map(Path::to_path_buf),
)
};
assert!(!sub.exists(), "子树要删");
assert!(
parent.is_some_and(|parent| !parent.exists()),
"父目录也不许留空壳"
);
}
@@ -215,7 +215,7 @@ fn multimc_reads_instance_directory() {
#[test]
fn multimc_export_is_refused() {
let root = super::temp_root("mmc-write");
let root = super::TempRoot::new("mmc-write");
let target = root.join("out.zip");
let manifest = PackManifest::new("whatever");
let error = MULTIMC
@@ -236,7 +236,7 @@ fn registry_read_reports_matched_format() {
#[test]
fn curseforge_without_name_is_a_metadata_error() {
let root = super::temp_root("cf-noname");
let root = super::TempRoot::new("cf-noname");
let target = root.join("nope.zip");
super::write_zip(
&target,
+23 -14
View File
@@ -4,9 +4,9 @@
//! 解压总体积上限、单条目上限、规整后重名、篡改载荷(CRC 不符)、以及**清单字段里的**穿越
//! (mrpack `files[].path`、CF `overrides`)。
use std::path::{Path, PathBuf};
use std::path::Path;
use super::{temp_root, write_zip, write_zip_with_symlink};
use super::{write_zip, write_zip_with_symlink, TempRoot};
use crate::addon::pack::archive::{PackArchive, PackLimits};
use crate::addon::pack::curseforge::CURSEFORGE;
use crate::addon::pack::error::PackErrorKind;
@@ -15,8 +15,8 @@ use crate::addon::pack::manifest::{PackLoader, PackManifest, PackOverrides, Pack
use crate::addon::pack::modrinth::MODRINTH;
/// 打一个只有一条恶意条目的 zip。
fn single_entry(tag: &str, name: &str) -> PathBuf {
let target = temp_root(tag).join("evil.zip");
fn single_entry(tag: &str, name: &str) -> TempRoot {
let target = TempRoot::at(tag, "evil.zip");
write_zip(&target, &[(name, &b"payload"[..])]);
target
}
@@ -81,7 +81,8 @@ fn rejects_drive_letter_and_ads_entries() {
#[test]
fn rejects_symlink_entries() {
let target = temp_root("symlink").join("link.zip");
let root = TempRoot::new("symlink");
let target = root.join("link.zip");
write_zip_with_symlink(
&target,
&[("readme.txt", &b"x"[..])],
@@ -94,7 +95,8 @@ fn rejects_symlink_entries() {
#[test]
fn rejects_too_many_entries() {
let target = temp_root("limit-count").join("many.zip");
let root = TempRoot::new("limit-count");
let target = root.join("many.zip");
write_zip(
&target,
&[
@@ -111,7 +113,8 @@ fn rejects_too_many_entries() {
#[test]
fn rejects_uncompressed_volume_over_limit() {
let target = temp_root("limit-volume").join("big.zip");
let root = TempRoot::new("limit-volume");
let target = root.join("big.zip");
let payload = vec![b'a'; 4096];
write_zip(
&target,
@@ -124,7 +127,8 @@ fn rejects_uncompressed_volume_over_limit() {
#[test]
fn rejects_a_single_oversized_entry() {
let target = temp_root("limit-entry").join("big.zip");
let root = TempRoot::new("limit-entry");
let target = root.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))
@@ -135,7 +139,8 @@ fn rejects_a_single_oversized_entry() {
#[test]
fn rejects_names_that_collide_after_normalization() {
// `./a.txt` 与 `a.txt` 规整后同名:留着就是"读到的和落盘的"不是同一个文件的经典混淆。
let target = temp_root("dup-name").join("dup.zip");
let root = TempRoot::new("dup-name");
let target = root.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());
@@ -145,7 +150,8 @@ fn rejects_names_that_collide_after_normalization() {
fn a_tampered_payload_is_not_silently_accepted() {
// 中央目录没问题、数据被改过(CRC 不符):打开能过,读内容必须报错。
const MARKER: &[u8] = b"UNIQUE-PAYLOAD-MARKER";
let target = temp_root("tampered").join("tampered.zip");
let root = TempRoot::new("tampered");
let target = root.join("tampered.zip");
write_zip(
&target,
&[
@@ -182,7 +188,8 @@ fn a_tampered_payload_is_not_silently_accepted() {
#[test]
fn mrpack_file_path_traversal_is_rejected() {
let target = temp_root("mr-trav").join("evil.mrpack");
let root = TempRoot::new("mr-trav");
let target = root.join("evil.mrpack");
let index = r#"{"game":"minecraft","formatVersion":1,"versionId":"1.0","name":"evil pack",
"files":[{"path":"../evil.jar",
"hashes":{"sha512":"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"},
@@ -201,7 +208,8 @@ fn mrpack_file_path_traversal_is_rejected() {
#[test]
fn curseforge_overrides_dir_traversal_is_rejected() {
let target = temp_root("cf-trav").join("evil.zip");
let root = TempRoot::new("cf-trav");
let target = root.join("evil.zip");
write_zip(
&target,
&[(
@@ -248,7 +256,8 @@ fn write_side_path_check_catches_hand_built_models() {
#[test]
fn missing_package_is_an_io_error() {
let target = temp_root("missing").join("does-not-exist.zip");
let root = TempRoot::new("missing");
let target = root.join("does-not-exist.zip");
let error = open_error(&target);
assert_eq!(error.kind(), PackErrorKind::Io, "{}", error.message());
}
@@ -258,7 +267,7 @@ fn directory_source_rejects_symlinks_when_the_platform_can_make_them() {
// Windows 建符号链接需要开发者模式/管理员:建不出来就跳过,但绝不静默当通过。
#[cfg(unix)]
{
let root = temp_root("dir-symlink").join("instance");
let root = TempRoot::under("dir-symlink", "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("建符号链接");
@@ -12,7 +12,7 @@ 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 root = super::TempRoot::new("env-extra");
let dir = root.join("src");
super::write_file(
&dir.join("modrinth.index.json"),
@@ -44,7 +44,7 @@ fn mrpack_env_unknown_keys_survive_read_write_reread() {
#[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 root = super::TempRoot::new("cf-file-extra");
let dir = root.join("src");
super::write_file(
&dir.join("manifest.json"),
@@ -81,7 +81,7 @@ fn curseforge_file_unknown_keys_survive_read_write_reread() {
#[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 root = super::TempRoot::new("cf-loader-extra");
let dir = root.join("src");
super::write_file(
&dir.join("manifest.json"),
@@ -117,7 +117,7 @@ fn mrpack_file_unknown_keys_survive_read_write_reread() {
// 第四个假边(M152 补):`files[]` 这一层。上面三条覆盖了 `env` / CF `files[]` /
// CF `modLoaders[]`,而 mrpack 的文件级未知键一直没人钉——它恰恰是"社区给某条文件加
// 一个私有字段"最常出现的位置(例如镜像站塞的 `mirror` / 作者写的 `optionalReason`)。
let root = super::temp_root("mr-file-extra");
let root = super::TempRoot::new("mr-file-extra");
let dir = root.join("src");
super::write_file(
&dir.join("modrinth.index.json"),
+35 -46
View File
@@ -1,20 +1,21 @@
//! ZIP64 拒写(research/18-p2 §8.6 / 表 B #17 的导出侧那条)。
//! 导出侧的体积闸:**拒写**过大包(research/18-p2 §8.6 / 表 B #17 的导出侧那条)。
//!
//! # 为什么这个文件以前是空的
//! # 现在的判据是「与导入读取同一份常量」(M160)
//!
//! M112 的完成报告写明:「ZIP64 阈值那条分支只有代码没有端到端测试(要造 4 GiB 数据)」。
//! 而更要紧的是——**那条分支当时根本够不着**:实现先 `std::fs::read` 整个覆盖文件进内存,
//! 再累加比较,所以真要导出一个 4 GiB 文件的实例,内存先炸,用户永远看不到那句
//! `pack_limits`。现在写侧改成「先按 `metadata().len()` 预检」,两个问题一起解决:
//! 以前判据是 `zip::ZIP64_BYTES_THR`(≈4 GiB),而导入读取只允许 512 MiB —— 于是存在
//! 「导得出来、导不回去」的包。M160 把写侧上限换成读侧那份常量(`pack::archive::limits`),
//! 本文件的两条用例(单文件超大 / 累计超大)仍然钉住**同一件事**:闸门在碰内容之前就把包拒了。
//! 只是拒绝的理由从"要写 ZIP64"变成"超过统一上限,所以永远不会写出 ZIP64"。
//!
//! - 边界是真的会在读第一个字节之前拦下来的(本文件的用例就断言这一点:失败后临时目录里
//! 没有 `.part`、目标路径不存在);
//! - 测试可以用**稀疏文件**造出 4 GiB 的"声明尺寸"而几乎不占磁盘(`set_len` 只改 EOF,
//! NTFS / ext4 都不会真写零)。这也是唯一能在不塞满硬盘的前提下打通这条分支的办法。
//! # 为什么用稀疏文件
//!
//! 判据是**声明尺寸**(`metadata().len()`),所以测试可以用稀疏文件造出 GB 级的"声明尺寸"而
//! 几乎不占磁盘(`set_len` 只改 EOF,NTFS / ext4 都不会真写零)。这也是唯一能在不塞满硬盘的
//! 前提下打通这条分支的办法。
use std::path::Path;
use super::temp_root;
use super::TempRoot;
use crate::addon::pack::curseforge::CURSEFORGE;
use crate::addon::pack::error::PackErrorKind;
use crate::addon::pack::format::{PackFormat, PackPayload};
@@ -64,7 +65,7 @@ fn leftovers(root: &Path, keep: &[&str]) -> Vec<String> {
#[test]
fn one_file_over_the_threshold_is_refused_before_a_single_byte_is_read() {
let root = temp_root("zip64-single");
let root = TempRoot::new("zip64-single");
// 恰好一个字节超过阈值:预检必须在这里就报,不许读内容(真读会先要 4 GiB 内存)。
sparse_file(
&root.join("overrides/mods/huge.jar"),
@@ -91,7 +92,7 @@ fn one_file_over_the_threshold_is_refused_before_a_single_byte_is_read() {
#[test]
fn the_threshold_is_cumulative_not_per_file() {
// 三个文件各自都远小于阈值,加起来超了:判据是**整包未压缩总量**,不是单文件。
let root = temp_root("zip64-cumulative");
let root = TempRoot::new("zip64-cumulative");
let each = 1_610_612_736_u64; // 1.5 GiB
assert!(each < zip::ZIP64_BYTES_THR, "单条必须都在阈值以下");
assert!(each * 3 > zip::ZIP64_BYTES_THR, "三条加起来必须超标");
@@ -115,7 +116,7 @@ fn the_threshold_is_cumulative_not_per_file() {
#[test]
fn an_export_under_the_threshold_still_writes_a_normal_pack() {
// 对照:不超阈值时预检不许误伤(否则这条护栏就变成"导出全挂")。
let root = temp_root("zip64-under");
let root = TempRoot::new("zip64-under");
let content = b"a normal sized override file";
std::fs::create_dir_all(root.join("overrides/mods")).expect("建目录");
std::fs::write(root.join("overrides/mods/jei.jar"), content).expect("写覆盖文件");
@@ -130,53 +131,41 @@ fn an_export_under_the_threshold_still_writes_a_normal_pack() {
assert!(written.has_entry("overrides/mods/jei.jar"));
}
/// 端到端版的窗口回归钉(**不进默认套件**:真写 2.5 GB 要 ~70 秒 deflate,且会把机器内存 /
/// 页面文件顶上去;塔台 2026-11-07 的资源配置令要求避免)。
/// 端到端版的旧窗口回归钉 → **现在钉的是口径统一**(`#[ignore]`:要造稀疏大文件 + 一遍 deflate)。
///
/// 默认套件里由 `format::write` 的单测 `the_second_gate_counts_from_the_manifest_length_not_
/// the_preflight_total` 便宜地钉住同一个窗口(把窗口里的字节数直接喂给第二道闸的计数器)。
/// 本条留给"真跑一遍确认容器层接线没错"的场合:
/// M160 把写侧的上限换成读侧那份常量(512 MiB 总量 / 64 MiB 单条),所以 M152 时代
/// 「2.5 GB 单文件落在 ZIP64 过拒窗口里、必须能导出」这件事**按设计不再成立**:那个包现在导得
/// 出来、导不回去。本条留下的是新口径的端到端证据——2.5 GB 的覆盖文件在**碰内容之前**就被
/// 统一上限拒掉,且临时文件与目标路径都不留。
///
/// ```text
/// cargo test -p unlucky-core --lib -- zip64 --ignored --nocapture
/// ```
///
/// 交付时的实测(M152 r2):单文件 2 500 000 000 字节导出**成功**,包内条目声明尺寸原样为
/// 2.5 GB,耗时 ~70 秒、源文件稀疏(占盘 0),产物是压缩后的零(几 MB)。
#[test]
#[ignore = "真写 2.5 GB(~70 秒 deflate + 一片内存):默认套件用 Written 的便宜单测钉同一窗口"]
fn a_single_file_inside_the_double_count_window_still_exports() {
// P2-1 的回归钉:预检返回的是**全量**声明尺寸,写循环曾经直接拿它当累加基数,于是每个
// 文件的字节被算两遍 —— 真实总量只要过半阈值(≈2.15 GiB)就在内容读完**之后**被误拒。
// 2.5 GB 正落在这个窗口里:它必须能导出,且中途不许把整个人内容摊进内存。
let root = temp_root("zip64-window");
#[ignore = "造 2.5 GB 稀疏文件再跑一遍预检:默认套件用 `Written` 的便宜单测与 tests/limits.rs 钉同一口径"]
fn a_single_file_over_the_old_zip64_window_is_refused_by_the_unified_limit() {
let root = TempRoot::new("zip64-window");
let length = 2_500_000_000_u64;
assert!(length < zip::ZIP64_BYTES_THR, "样本必须在阈值之下");
assert!(
length > zip::ZIP64_BYTES_THR / 2,
"样本必须落在过拒窗口里(≥ 阈值一半)"
length < crate::addon::pack::archive::limits::PACK_ZIP64_BYTES_THR,
"样本仍在 ZIP64 阈值之下:拒绝的理由是**统一上限**,不是 ZIP64"
);
sparse_file(&root.join("overrides/mods/big.jar"), length);
let manifest = manifest_of(vec![override_file("mods/big.jar")]);
let target = root.join("out.zip");
let result = CURSEFORGE.write(&manifest, PackPayload::from_root(&root), &target);
let error = CURSEFORGE
.write(&manifest, PackPayload::from_root(&root), &target)
.expect_err("超过统一上限(512 MiB)必须拒:否则导出的包自己导不回来");
assert_eq!(error.kind(), PackErrorKind::Limits, "{error}");
assert!(
result.is_ok(),
"2.5 GB 的覆盖文件没超 ZIP64 阈值,不许误拒:{:?}",
result.err()
error.message().contains(
&crate::addon::pack::archive::limits::PACK_MAX_UNCOMPRESSED_BYTES.to_string()
),
"文案要给出统一上限那个数:{error}"
);
assert!(!target.exists(), "预检失败时目标路径不许出现");
// 包内条目的**声明未压缩尺寸**就是 2.5 GB(真写出来的是压缩后的零,只有几 MB)。
// 这里直接用 `zip` 的读者,而不是 `PackArchive::open_default`:后者对**不可信输入**有
// 单条目 64 MiB 的上限(`PackLimits`),拿它读我们自己刚写的包会撞限额,与本次判据无关。
let file = std::fs::File::open(&target).expect("打开导出包");
let mut archive = zip::ZipArchive::new(file).expect("读 zip");
let entry = archive
.by_name("overrides/mods/big.jar")
.expect("覆盖条目要在包里");
assert_eq!(entry.size(), length, "声明尺寸要原样带过去");
// 稀疏源文件只占元数据,但产物与目录都清掉,别让 temp 越积越多。
// 稀疏源文件只占元数据,但目录与临时文件都清掉,别让 temp 越积越多。
let _ = std::fs::remove_dir_all(&root);
}