Merge branch 'feat/m166-zip64fix-shrink-zip64-test-fixture'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s

This commit is contained in:
AskaEth committed 2026-11-08 09:30:30 +08:00
commit 5965575210
8 files changed
+462 -190

No files matched your search

+65 -34
View File
@@ -63,6 +63,11 @@ use super::multimc;
mod payload;
mod write;
/// 测试用的限额记录钩子(`probe.rs`,**只在 `cfg(test)` 里编译**):把"生产 `write()`
/// 到底传了哪份限额"变成可观测的,见该模块文档。
#[cfg(test)]
pub(in crate::addon::pack) mod probe;
/// 导出时的覆盖内容来源在 [`payload`] 子模块里(`payload.rs`,与本文件拆开是为了守 400 行):
/// [`PackPayload::metadata_only`] 只写清单、[`PackPayload::from_root`] 按
/// `<root>/<dir>/<path>` 取文件、[`PackPayload::with_sources`] 按**包内条目名**查表
@@ -133,47 +138,73 @@ pub trait PackFormat: Debug + Send + Sync {
payload: PackPayload<'_>,
target: &Path,
) -> PackResult<()> {
let Some(entry) = self.metadata_entry() else {
return Err(PackError::unsupported(format!(
"格式 {}({})只支持读取,不支持导出",
self.id(),
self.label()
))
.with_hint("导出请选 Modrinth(.mrpack)或 CurseForge 格式"));
};
// 先判"能不能表达",再碰磁盘:跨格式导出宁可报错,也不产出空包。
self.check_overrides(manifest)?;
// 模型可能来自外部构造:搬内容前把每条路径重查一遍。
manifest.check_paths()?;
let json = self.render(manifest)?;
// 临时文件 + rename:中途失败时目标路径上不会留下"看起来能用"的半个 zip(r1 p3-1)。
let temp = write::temporary_path(target);
match write::container(
self.id(),
entry,
&json,
// 生产口径只有一处来源:导入读取用的也是这三个数(见 `archive::limits`)。
write_with_limits(
self,
manifest,
payload,
&temp,
// 生产口径只有一处来源:导入读取用的也是这两个数(见 `archive::limits`)。
target,
write::WriteLimits::production(),
) {
Ok(()) => std::fs::rename(&temp, target).map_err(|error| {
let _ = std::fs::remove_file(&temp);
PackError::io(format!(
"把 {} 改名成 {} 失败:{error}",
temp.display(),
target.display()
))
}),
Err(error) => {
let _ = std::fs::remove_file(&temp);
Err(error)
}
)
}
}
/// [`PackFormat::write`] 的实体:限额由调用方给。
///
/// # 为什么把 limits 做成参数
///
/// 预检的判据是「**声明尺寸** ≥ 上限」,与夹具真实大小无关。生产路径只传
/// [`write::WriteLimits::production`];测试传几百字节的小值,走的是**同一条**
/// `write::preflight` 分支——这样"闸在碰内容之前"不必再造 GiB 级夹具来证明。
/// (旧版测试用 `set_len` 造"稀疏"大文件,实测在 NTFS 上**不是稀疏的**:可用空间真的
/// 掉了那么多,见 `tests/zip64.rs` 的模块文档。)
pub(in crate::addon::pack) fn write_with_limits<F: PackFormat + ?Sized>(
format: &F,
manifest: &PackManifest,
payload: PackPayload<'_>,
target: &Path,
limits: write::WriteLimits,
) -> PackResult<()> {
// 测试构建里记一笔"这次传的是哪份限额":夹具缩小之后,只有它能把
// 「生产 `write()` 传的就是 `WriteLimits::production()`」这件事变成可观测的(见 `probe`)。
#[cfg(test)]
probe::record(limits);
let Some(entry) = format.metadata_entry() else {
return Err(PackError::unsupported(format!(
"格式 {}({})只支持读取,不支持导出",
format.id(),
format.label()
))
.with_hint("导出请选 Modrinth(.mrpack)或 CurseForge 格式"));
};
// 先判"能不能表达",再碰磁盘:跨格式导出宁可报错,也不产出空包。
format.check_overrides(manifest)?;
// 模型可能来自外部构造:搬内容前把每条路径重查一遍。
manifest.check_paths()?;
let json = format.render(manifest)?;
// 临时文件 + rename:中途失败时目标路径上不会留下"看起来能用"的半个 zip(r1 p3-1)。
let temp = write::temporary_path(target);
match write::container(format.id(), entry, &json, manifest, payload, &temp, limits) {
Ok(()) => std::fs::rename(&temp, target).map_err(|error| {
let _ = std::fs::remove_file(&temp);
PackError::io(format!(
"把 {} 改名成 {} 失败:{error}",
temp.display(),
target.display()
))
}),
Err(error) => {
let _ = std::fs::remove_file(&temp);
Err(error)
}
}
}
/// 测试要能自己给 limits:把 `write` 子模块里的类型借出来(生产代码不走这条)。
#[cfg(test)]
pub(in crate::addon::pack) use write::WriteLimits;
/// 一次探测的结果。
#[derive(Debug, Clone)]
pub struct PackMatch {
@@ -0,0 +1,44 @@
//! **只在测试构建里存在**的限额记录钩子(p2-1,复审 r1)。
//!
//! # 为什么需要它
//!
//! M166 把夹具从 GiB 缩到 KiB 之后,「生产路径 + 真超限内容」那种顺带守住生产限额的证法没了。
//! 于是「[`super::PackFormat::write`] 到底把哪份限额交给 [`super::write_with_limits`]」
//! **没有任何测试能观测**:把它换成 `WriteLimits::new(u64::MAX, u64::MAX, …)` 会全绿
//! (复审 r1 实测 606 passed)。`the_production_limits_are_the_shared_constants` 只钉了
//! `production()` 的**返回值**,没钉 `write()` 的**调用点**。
//!
//! 所以这里加一条捕获缝:`write_with_limits` 进门时记一笔,测试走**生产**的
//! `CURSEFORGE.write(…)` 之后断言记到的就是 [`super::write::WriteLimits::production`]。
//!
//! # 三条纪律
//!
//! 1. **只在 `cfg(test)` 里编译**:发布构建里这个模块不存在,`write_with_limits` 的签名也没动;
//! 2. 记录是**线程局部**的:`cargo test` 每个用例一个线程,并行跑也不会串味;
//! 3. 这是**测试设施**,不是给生产留的"放宽限额"开关——生产路径只有
//! [`super::write::WriteLimits::production`] 一个来源。
// clippy 1.99 的 `missing_const_for_thread_local` 在**已经是** `const { … }` 的写法上仍然报:
// 最小复现 `thread_local! { static A: Cell<Option<u64>> = const { Cell::new(None) }; }` 一样红
// (`-D warnings` 下直接失败)。是这条 lint 的误报,不是这里漏了 const,所以整模块压掉
// (外挂在 `thread_local!` 上的 `#[allow]` 对展开里的诊断无效,会被报成 unused attribute)。
#![allow(clippy::missing_const_for_thread_local)]
use std::cell::Cell;
use super::write::WriteLimits;
thread_local! {
/// 本线程最近一次 [`super::write_with_limits`] 收到的限额。
static LAST_LIMITS: Cell<Option<WriteLimits>> = const { Cell::new(None) };
}
/// 记一笔(由 [`super::write_with_limits`] 进门时调用)。
pub(super) fn record(limits: WriteLimits) {
LAST_LIMITS.with(|slot| slot.set(Some(limits)));
}
/// 本线程最近一次记到的限额(`None` = 还没走过 `write_with_limits`)。
pub(in crate::addon::pack) fn last_limits() -> Option<WriteLimits> {
LAST_LIMITS.with(Cell::get)
}
+10 -6
View File
@@ -166,19 +166,23 @@ fn preflight(
}
/// 写侧的限额口径(**默认值直接取读侧那三个常量**,见 `archive::limits` 的模块文档)。
///
/// 可见性是 `pub(in crate::addon::pack)`——不是 `pub(super)`:`tests/zip64.rs` 与
/// `tests/limits.rs`(`pack::tests` 下)要能用**几百字节的小 limits** 走同一条预检分支,
/// 详见 [`super::write_with_limits`]。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) struct WriteLimits {
pub(in crate::addon::pack) struct WriteLimits {
/// 未压缩总量上限。
pub(super) total: u64,
pub(in crate::addon::pack) total: u64,
/// 单条目上限。
pub(super) entry: u64,
pub(in crate::addon::pack) entry: u64,
/// 条目数上限(**与读侧同一个数**:清单条目 + 每个覆盖文件)。
pub(super) entries: usize,
pub(in crate::addon::pack) entries: usize,
}
impl WriteLimits {
/// 生产口径:与 [`PackLimits::default`](crate::addon::pack::PackLimits) 同一个数。
pub(super) const fn production() -> Self {
pub(in crate::addon::pack) const fn production() -> Self {
Self {
total: super::super::archive::limits::PACK_MAX_UNCOMPRESSED_BYTES,
entry: super::super::archive::limits::PACK_MAX_ENTRY_BYTES,
@@ -188,7 +192,7 @@ impl WriteLimits {
/// 显式给值(测试用:把边界缩小到可测尺寸)。
#[cfg(test)]
pub(super) const fn new(total: u64, entry: u64, entries: usize) -> Self {
pub(in crate::addon::pack) const fn new(total: u64, entry: u64, entries: usize) -> Self {
Self {
total,
entry,
@@ -47,10 +47,10 @@ fn the_container_does_not_double_count_the_preflight_total() {
// `json` 30 字节 + 覆盖文件 40 字节 = 声明总量 70。
// - 正确实现:第二道闸从 30 起算,写到 70 也不到 100 ⇒ 导出成功;
// - P2-1 的写法(把预检返回的全量 70 当基数):写循环再累加 40 ⇒ 110 ≥ 100 ⇒ 误拒。
// 真造 2.5 GB 才能暴露同一个 bug,本机资源纪律下不跑(那条留成 `zip64.rs` 里的
// `#[ignore]` 用例)。
// 临时根用 `TempRoot`(drop 即删、panic 也删):这两个用例以前自己拼 `%TEMP%` 路径、
// 只在**正常收尾**时删,断言一红就留下 `ull-double-count-*`(M160 r2 的泄漏口径)。
// 真造 2.5 GB 才能暴露同一个 bug,本机资源纪律下不跑:GiB 级的**数值判定**由
// `the_gib_scale_boundaries_decide_without_any_fixture` 用纯算术钉住。测试全部用
// `TempRoot`(drop 即删、panic 也删):旧版自己拼 `%TEMP%` 路径、只在**正常收尾**时删,
// 断言一红就留下 `ull-double-count-*`(M160 r2 的泄漏口径)。
let root = TempRoot::new("double-count");
std::fs::create_dir_all(root.join("overrides/mods")).expect("建目录");
std::fs::write(root.join("overrides/mods/a.bin"), [0_u8; 40]).expect("写覆盖文件");
@@ -102,15 +102,20 @@ fn the_container_does_not_double_count_the_preflight_total() {
#[test]
fn the_preflight_measures_declared_sizes_not_content() {
// 预检必须只量 `metadata().len()`:这一条用"小文件 + 3 GiB 稀疏文件"钉住,
// 真读内容的话要么慢要么直接吃 3 GiB 内存。
// 预检必须只量 `metadata().len()`。夹具刻意**只有几 KiB**(M166):把"没读内容"钉住的是
// **结构**而不是字节数——`container` 的顺序是 预检 → `File::create(temp)` → 打开源文件读,
// 所以"临时文件根本没被创建"等价于"一个字节的内容都没被打开过"。
// 旧版这里造一个 3 GiB 的"稀疏"文件,实测在 NTFS 上真占 3 GiB(见 `zip64.rs` 模块文档)。
const LIMIT: u64 = 8 * 1024;
const HUGE: u64 = 6 * 1024;
let root = TempRoot::new("preflight");
std::fs::create_dir_all(root.join("overrides/mods")).expect("建目录");
std::fs::write(root.join("overrides/mods/small.jar"), b"0123456789").expect("写小文件");
let huge = root.join("overrides/mods/huge.jar");
let file = std::fs::File::create(&huge).expect("建稀疏文件");
file.set_len(3 * 1024 * 1024 * 1024).expect("拉长(稀疏)");
drop(file);
std::fs::write(
root.join("overrides/mods/huge.jar"),
vec![0_u8; HUGE as usize],
)
.expect("写大一点的覆盖文件");
let mut manifest = PackManifest::new("preflight");
manifest.overrides.push(PackOverrides {
@@ -134,17 +139,68 @@ fn the_preflight_measures_declared_sizes_not_content() {
7,
&manifest,
PackPayload::from_root(&root),
// 本条只关心"预检不读内容",所以三道闸都放到最宽(生产口径见 `tests/limits.rs`)。
WriteLimits::new(
crate::addon::pack::archive::limits::PACK_ZIP64_BYTES_THR,
crate::addon::pack::archive::limits::PACK_ZIP64_BYTES_THR,
crate::addon::pack::archive::limits::PACK_MAX_ENTRIES,
),
// 总量闸放到 8 KiB:声明总量 7 + 10 + 6144 仍在它之下,预检不许误伤。
WriteLimits::new(LIMIT, LIMIT, 16),
)
.expect("3 GiB 总量仍在最宽闸之下,预检不许误伤");
.expect("声明总量仍在闸下,预检不许误伤");
assert_eq!(total, 7 + 10 + HUGE, "清单 + 每个覆盖文件的声明尺寸");
}
#[test]
fn the_production_limits_are_the_shared_constants() {
// 写侧口径的**接线**证明:生产路径拿到的就是读侧那三个常量,不是另抄一份。
// 这条是纯算术。旧版是靠"造 64 MiB 文件、看报错文案里出现的数字"来证的——
// 那既要真占 64 MiB,也只覆盖到文案碰巧提到的字段。
let production = WriteLimits::production();
assert_eq!(
total,
7 + 10 + 3 * 1024 * 1024 * 1024,
"清单 + 每个覆盖文件的声明尺寸"
production.total,
crate::addon::pack::archive::limits::PACK_MAX_UNCOMPRESSED_BYTES
);
assert_eq!(
production.entry,
crate::addon::pack::archive::limits::PACK_MAX_ENTRY_BYTES
);
assert_eq!(
production.entries,
crate::addon::pack::archive::limits::PACK_MAX_ENTRIES
);
}
#[test]
fn the_gib_scale_boundaries_decide_without_any_fixture() {
// M166:**>2 GiB 与 >4 GiB 两条边界**在这里钉,不需要落盘——两道闸吃的都是 `u64`。
// 以前它们只有"造 4 GiB / 2.5 GB 夹具"这一条证法,而那在本机 NTFS 上是真占 4 GiB。
use crate::addon::pack::archive::limits::{PACK_MAX_UNCOMPRESSED_BYTES, PACK_ZIP64_BYTES_THR};
const GIB: u64 = 1024 * 1024 * 1024;
// ① >4 GiB 那条:撞上 ZIP64 阈值就拒,差一字节就放行(`>=` 不是 `>`)。
let zip64 = PACK_ZIP64_BYTES_THR;
assert_eq!(zip64, u32::MAX as u64, "ZIP64 阈值就是 u32::MAX ≈ 4 GiB");
assert!(guard_with(zip64 - 1, zip64).is_ok(), "差一字节必须放行");
let error = guard_with(zip64, zip64).expect_err("撞上 ZIP64 阈值必须拒");
assert!(error.message().contains("ZIP64"), "{error}");
// ② >2 GiB 那条:M152 时代那个 2.5 GB 的样本,现在按**统一上限**(512 MiB)拒。
let old_sample: u64 = 2_500_000_000;
assert!(old_sample > 2 * GIB, "样本必须真的在 2 GiB 之上");
assert!(
old_sample < zip64,
"样本又必须在 ZIP64 阈值之下:拒绝理由是统一上限"
);
let error =
guard_with(old_sample, PACK_MAX_UNCOMPRESSED_BYTES).expect_err("统一上限必须拒 2.5 GB");
assert!(
error
.message()
.contains(&PACK_MAX_UNCOMPRESSED_BYTES.to_string()),
"文案要给出统一上限那个数:{error}"
);
// ③ 第二道闸(`Written`)在同一批数值上也不许误拒,越界才拒。
let mut written = Written::new(0, zip64);
written
.spend(old_sample)
.expect("2.5 GB < ZIP64 阈值,第二道闸不许拒");
written.spend(GIB).expect("3.5 GB 仍在上限之下");
assert!(written.spend(GIB).is_err(), "越过 4 GiB 必须拒");
}
+116 -52
View File
@@ -10,6 +10,17 @@
//! 2. 读侧 `PackLimits::default()` 就是这三个数;
//! 3. 写侧在**单条边界**上拒(`>` 而不是 `≥`:恰好 64 MiB 合法);
//! 4. 写侧在**总量**上拒,且拒绝发生在读内容之前(临时目录里什么都不留)。
//!
//! # 夹具是 KiB 级,判据仍是生产口径(M166)
//!
//! 第 3、4 条以前靠"造 64 MiB 的**稀疏**文件"验,以为稀疏不占磁盘。**实测不成立**:本机
//! NTFS 上 `set_len` 真占(`SetLength(1 GiB)` ⇒ C 盘可用空间掉 1024 MiB,见
//! `tests/zip64.rs` 的模块文档),那三条用例合计真占 ~700 MiB。
//!
//! 现在判据的**数值**用纯算术钉(`format/write/tests.rs` 的
//! `the_production_limits_are_the_shared_constants` 证明生产路径拿的就是这三个常量),
//! **分支**用 `format::write_with_limits` 把 limits 缩到 KiB 级来验——两条合起来等价于
//! 原来的"造 64 MiB 看文案",但一条用例都 `#[ignore]`、一条断言都没删。
use super::{write_file, TempRoot};
use crate::addon::pack::archive::limits::{
@@ -17,10 +28,13 @@ use crate::addon::pack::archive::limits::{
};
use crate::addon::pack::curseforge::CURSEFORGE;
use crate::addon::pack::error::PackErrorKind;
use crate::addon::pack::format::{PackFormat, PackPayload};
use crate::addon::pack::format::{write_with_limits, PackFormat, PackPayload, WriteLimits};
use crate::addon::pack::manifest::{OverrideFile, PackManifest, PackOverrides, PackSide};
use crate::addon::pack::PackLimits;
/// 小 limits 用的单条目上限(生产口径是 64 MiB,这里缩到 2 KiB)。
const ENTRY: u64 = 2 * 1024;
/// 一个覆盖文件的声明(内容由调用方提供)。
fn override_file(path: &str) -> OverrideFile {
OverrideFile {
@@ -42,13 +56,12 @@ fn manifest_of(files: Vec<OverrideFile>) -> PackManifest {
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("拉长文件(稀疏)");
/// 造一个真有 `length` 字节内容的覆盖文件(**不稀疏**,见模块文档)。
fn content_file(path: &std::path::Path, length: u64) {
write_file(
path,
&vec![0_u8; usize::try_from(length).expect("夹具尺寸")],
);
}
#[test]
@@ -72,6 +85,7 @@ fn the_write_side_refuses_a_pack_over_the_unified_entry_count() {
// ② 声明 20001 条(上限 20000 + 1):只有**第一条**的文件真的存在,其余 2 万条谁都不在盘上。
// 报出来的必须是 `pack_limits` 而不是 `pack_io` —— 那就是"条目数闸在读第一个文件之前"的证据
// (真去 `metadata()` 的话,第二个文件就已经报 `pack_io` 了)。
// 这条**不需要**小 limits:清单条目本身不占磁盘,2 万条只是一个 JSON 数组。
let mut files = vec![override_file("mods/a.jar")];
for index in 1..=PACK_MAX_ENTRIES {
files.push(override_file(&format!("mods/{index:05}.jar")));
@@ -106,7 +120,10 @@ fn the_entry_count_gate_is_the_same_number_as_the_read_side() {
fn the_three_limits_form_one_chain_and_the_read_side_uses_them() {
// ① 关系:单条 ≤ 总量 < ZIP64 ≤ u32。前一半保证"能写出来的包读得回来",
// 后一半保证我们永远不需要写 ZIP64,也永远撞不到 mrpack 的 fileSize(u32) 上限。
// 这三条是**编译期**断言(`const` 块):改坏常量会在 `cargo check` 就红,不用等跑测试。
// 这三条是**编译期**断言(`const` 块,而且写在 `#[test] fn` 体内 ⇒ **只在测试构建里存在**):
// 改坏常量会在 `cargo test` / `cargo clippy --all-targets` 的编译期就红(E0080),
// 不用等跑到测试函数;但**裸 `cargo check -p unlucky-core`** 是 lib 非测试构建,
// 看不见这些断言(复审 r1 实测:E0080 只在 `--no-run`/`--all-targets` 下出现)。
const _: () = assert!(
PACK_MAX_ENTRY_BYTES <= PACK_MAX_UNCOMPRESSED_BYTES,
"单条上限必须 ≤ 总量上限(否则单条规则永远够不着)"
@@ -129,26 +146,33 @@ fn the_three_limits_form_one_chain_and_the_read_side_uses_them() {
#[test]
fn the_write_side_refuses_a_single_entry_over_the_unified_entry_limit() {
// 单条目那道闸。生产口径下"恰好 +1 字节超 64 MiB"要真占 64 MiB,所以这里把 limits
// 缩到 KiB 级、触发**同一条** `guard_entry` 分支;生产数值由
// `format/write/tests.rs::the_production_limits_are_the_shared_constants` 钉住。
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 limits = WriteLimits::new(ENTRY * 4, ENTRY, 16);
// 恰好 +1 字节超**单条**上限;声明总量(清单 + 2049)仍在总量上限 ENTRY*4 之下,
// 所以只有 `guard_entry` 能拒——两档判据没串味。
content_file(&root.join("overrides/mods/too-big.jar"), ENTRY + 1);
assert!(
ENTRY + 1 < limits.total,
"样本的声明总量必须仍在总量上限之下"
);
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("超过单条统一上限必须拒");
let error = write_with_limits(
&CURSEFORGE,
&manifest_of(vec![override_file("mods/too-big.jar")]),
PackPayload::from_root(&root),
&target,
limits,
)
.expect_err("超过单条统一上限必须拒");
assert_eq!(error.kind(), PackErrorKind::Limits, "{error}");
assert!(
error.message().contains(&PACK_MAX_ENTRY_BYTES.to_string()),
"文案要给出那个数(读侧用的是同一个):{error}"
error.message().contains(&ENTRY.to_string()),
"文案要给出在用的那个单条上限:{error}"
);
assert!(
error.hint().is_some_and(|hint| hint.contains("移出实例")),
@@ -163,67 +187,108 @@ fn the_write_side_refuses_a_single_entry_over_the_unified_entry_limit() {
.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` 同一侧)。
// 边界口径是 `>`:恰好等于上限合法(与读侧的 `size > max_entry_bytes` 同一侧)。
let root = TempRoot::new("limits-boundary");
sparse_file(&root.join("overrides/mods/exact.jar"), PACK_MAX_ENTRY_BYTES);
let limits = WriteLimits::new(ENTRY * 4, ENTRY, 16);
content_file(&root.join("overrides/mods/exact.jar"), ENTRY);
let target = root.join("out.zip");
CURSEFORGE
.write(
&manifest_of(vec![override_file("mods/exact.jar")]),
PackPayload::from_root(&root),
&target,
)
.expect("恰好等于上限必须写得出来(否则两侧的边界就错开了)");
write_with_limits(
&CURSEFORGE,
&manifest_of(vec![override_file("mods/exact.jar")]),
PackPayload::from_root(&root),
&target,
limits,
)
.expect("恰好等于上限必须写得出来(否则两侧的边界就错开了)");
let written = crate::addon::pack::PackArchive::open(
&target,
// 读回来时单条上限就是"恰好等于它",证明写出来的东西读侧认。
PackLimits {
max_entry_bytes: PACK_MAX_ENTRY_BYTES,
max_entry_bytes: ENTRY,
..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)——
// 这条与"单条规则"是两件事:判据是**累计**。
// 每条都在单条上限之内(2 KiB),但只要 3 条就超总量(6 KiB + 清单 ≥ 6 KiB)——
// 这条与"单条规则"是两件事:判据是**累计**。以前是 9 × 64 MiB = 576 MiB 真占用。
let root = TempRoot::new("limits-total");
let limits = WriteLimits::new(ENTRY * 3, ENTRY, 16);
let mut files = Vec::new();
for index in 0..9 {
for index in 0..3 {
let relative = format!("mods/part-{index}.jar");
sparse_file(
&root.join("overrides").join(&relative),
PACK_MAX_ENTRY_BYTES,
);
content_file(&root.join("overrides").join(&relative), ENTRY);
files.push(override_file(&relative));
}
let total = PACK_MAX_ENTRY_BYTES * 9;
assert!(total > PACK_MAX_UNCOMPRESSED_BYTES, "样本必须真的超总量");
let total = ENTRY * 3;
assert!(
ENTRY <= limits.entry,
"每条都必须在单条上限之内(判据是累计,不是单条)"
);
assert!(total >= limits.total, "三条加起来必须真的超总量");
let target = root.join("out.zip");
let error = CURSEFORGE
.write(&manifest_of(files), PackPayload::from_root(&root), &target)
.expect_err("累计超总量必须拒");
let error = write_with_limits(
&CURSEFORGE,
&manifest_of(files),
PackPayload::from_root(&root),
&target,
limits,
)
.expect_err("累计超总量必须拒");
assert_eq!(error.kind(), PackErrorKind::Limits, "{error}");
assert!(
error
.message()
.contains(&PACK_MAX_UNCOMPRESSED_BYTES.to_string()),
"文案要给出统一总量上限:{error}"
error.message().contains(&total.to_string()),
"文案要给出在用的总量上限:{error}"
);
assert!(!target.exists());
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn the_production_write_path_hands_the_shared_constants_to_the_preflight() {
// p2-1(复审 r1):夹具缩到 KiB 之后,"生产路径 + 真超限内容"那种顺带守住生产限额的
// 证法没了,于是「`PackFormat::write` 到底把哪份限额交给 `write_with_limits`」
// 没有任何测试能观测——把它换成 `WriteLimits::new(u64::MAX, u64::MAX, …)` 会全绿。
// `the_production_limits_are_the_shared_constants` 只钉了 `production()` 的**返回值**,
// 这条钉的是**调用点**(走生产的 `CURSEFORGE.write`,靠 `format::probe` 记录)。
let root = TempRoot::new("limits-binding");
write_file(&root.join("overrides/mods/jei.jar"), b"PK\x03\x04 jei");
let target = root.join("out.zip");
CURSEFORGE
.write(
&manifest_of(vec![override_file("mods/jei.jar")]),
PackPayload::from_root(&root),
&target,
)
.expect("正常尺寸必须写得出来");
let recorded = crate::addon::pack::format::probe::last_limits()
.expect("生产 write() 必须经过 write_with_limits");
let production = WriteLimits::production();
assert_eq!(recorded.total, production.total, "总量限额:生产路径传错了");
assert_eq!(
recorded.entry, production.entry,
"单条目限额:生产路径传错了"
);
assert_eq!(
recorded.entries, production.entries,
"条目数限额:生产路径传错了"
);
// 再把生产值本身钉在统一常量上:两条合起来才等于"生产路径用的是读侧那三个数"。
assert_eq!(recorded.total, PACK_MAX_UNCOMPRESSED_BYTES);
assert_eq!(recorded.entry, PACK_MAX_ENTRY_BYTES);
assert_eq!(recorded.entries, PACK_MAX_ENTRIES);
}
#[test]
@@ -252,5 +317,4 @@ fn a_real_pack_written_under_the_limit_reopens_under_the_read_limits() {
.expect("默认限额(= 统一口径)必须读得回来");
assert!(pack.has_entry("overrides/mods/jei.jar"));
assert!(pack.has_entry("overrides/config/known.toml"));
let _ = std::fs::remove_dir_all(&root);
}
+5 -4
View File
@@ -5,9 +5,10 @@
//! # 临时目录必须**测试自己收尾**(M160 r2)
//!
//! 每个用例的临时根是 [`TempRoot`]:`Drop` 里 `remove_dir_all`,所以**正常结束与 panic 展开
//! 都会删**(Rust 展开会跑析构)。这条不是洁癖:`zip64` 那族用例要造 4 GiB 的**稀疏文件**验
//! 阈值,残留会把 `%TEMP%` 堆到撑爆整台机器(实测 1651 个目录 / 110 GB,把 CI 与别的 agent
//! 的构建一起咬死)。
//! 都会删**(Rust 展开会跑析构)。这条不是洁癖:`zip64` 那族用例历史上要造 4 GiB 的"稀疏"
//! 文件验阈值(**实测在 NTFS 上真占 4 GiB**,见 `tests/zip64.rs` 的模块文档),残留会把
//! `%TEMP%` 堆到撑爆整台机器(实测 1651 个目录 / 110 GB,把 CI 与别的 agent 的构建一起咬死)。
//! M166 之后夹具缩到 KiB 级,但"测试自己收尾"这条不变——泄漏一次就是几十 GB 的量级。
//!
//! 写法的来源是仓库既有的那一份(`crate::update::tests::support::TempDir`):同样的 `new` /
//! `join` / `Drop` 三件套,只把前缀换成 `ull-pack-`,不引 `tempfile`、不新增第四种范式。
@@ -131,7 +132,7 @@ fn the_temp_root_is_gone_once_dropped() {
#[test]
fn a_panicking_test_still_removes_its_temp_root() {
// 自证②:panic 展开路径。断言"panic 也删"——这正是 4 GiB 稀疏文件残留的那条路:
// 自证②:panic 展开路径。断言"panic 也删"——这正是大夹具残留的那条路:
// 断言红了不代表可以留下 110 GB。
let path = std::sync::Arc::new(std::sync::Mutex::new(PathBuf::new()));
let slot = std::sync::Arc::clone(&path);
+144 -73
View File
@@ -4,23 +4,49 @@
//!
//! 以前判据是 `zip::ZIP64_BYTES_THR`(≈4 GiB),而导入读取只允许 512 MiB —— 于是存在
//! 「导得出来、导不回去」的包。M160 把写侧上限换成读侧那份常量(`pack::archive::limits`),
//! 本文件的两条用例(单文件超大 / 累计超大)仍然钉住**同一件事**:闸门在碰内容之前就把包拒了。
//! 只是拒绝的理由从"要写 ZIP64"变成"超过统一上限,所以永远不会写出 ZIP64"。
//! 本文件仍钉**同一件事**:闸门在碰内容之前就把包拒了。只是拒绝的理由从"要写 ZIP64"
//! 变成"超过统一上限,所以永远不会写出 ZIP64"。
//!
//! # 为什么用稀疏文件
//! # 夹具只有几 KiB(M166)
//!
//! 判据是**声明尺寸**(`metadata().len()`),所以测试可以用稀疏文件造出 GB 级的"声明尺寸"而
//! 几乎不占磁盘(`set_len` 只改 EOF,NTFS / ext4 都不会真写零)。这也是唯一能在不塞满硬盘的
//! 前提下打通这条分支的办法。
//! 判据是**声明尺寸**(`metadata().len()`),所以判据本身跟夹具真实大小无关。旧版本靠
//! `File::set_len` 造 4 GiB「稀疏文件」,以为"只改 EOF、不占磁盘"——**实测是错的**:
//!
//! ```text
//! PS> $fs=[IO.File]::Create("$env:TEMP\probe.bin"); $fs.SetLength(1GB); $fs.Close()
//! logical=1024.0MiB freeDelta=1024.0MiB # 可用空间真的掉了 1 GiB
//! ```
//!
//! 本机是 NTFS(Windows 11)。ext4 / APFS 上 `truncate`(= `set_len`)通常确实不分配块,
//! **但那是平台差异,不是可以依赖的性质**;而在 NTFS 上这两条用例的声明尺寸合计
//! `u32::MAX + 1`(≈4 GiB)+ `3 × 1.5 GiB`(4.5 GiB)= **8.5 GiB 真占用**。
//!
//! 现在改走 [`format::write_with_limits`]:limits 缩到 KiB 级,几百字节的**真**文件就触发
//! 同一条预检分支(`write::preflight` → `guard_with` / `guard_entry`),一条用例都不 `#[ignore]`、
//! 一条断言都没删。**GiB 级的数值判定**(>2 GiB 与 >4 GiB)搬到了 `format/write/tests.rs`:
//! 那两道闸吃的是 `u64`,本来就不需要落盘。
use std::path::Path;
use super::write_file;
use super::TempRoot;
use crate::addon::pack::curseforge::CURSEFORGE;
use crate::addon::pack::error::PackErrorKind;
use crate::addon::pack::format::{PackFormat, PackPayload};
use crate::addon::pack::format::{write_with_limits, PackFormat, PackPayload, WriteLimits};
use crate::addon::pack::manifest::{OverrideFile, PackManifest, PackOverrides, PackSide};
/// 小 limits:生产口径是 512 MiB 总量 / 64 MiB 单条 / 2 万条,这里缩到 KiB 级。
///
/// 判据只吃"声明尺寸 ≥ 上限"这个**关系**,所以缩小的只是夹具,不是分支。
const SMALL_TOTAL: u64 = 8 * 1024;
const SMALL_ENTRY: u64 = 2 * 1024;
const SMALL_ENTRIES: usize = 16;
/// 上面那三个数组成的限额(`WriteLimits::new` 是 `#[cfg(test)]` 的测试入口)。
fn small_limits() -> WriteLimits {
WriteLimits::new(SMALL_TOTAL, SMALL_ENTRY, SMALL_ENTRIES)
}
/// 一个覆盖文件的声明(内容由调用方提供)。
fn override_file(path: &str) -> OverrideFile {
OverrideFile {
@@ -42,13 +68,12 @@ fn manifest_of(files: Vec<OverrideFile>) -> PackManifest {
manifest
}
/// 造一个只占元数据的 `length` 字节文件(`set_len` 不写零)。
fn sparse_file(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("拉长文件(稀疏)");
/// 造一个真有 `length` 字节内容的覆盖文件(**不稀疏**,见模块文档)。
fn content_file(path: &Path, length: u64) {
write_file(
path,
&vec![0_u8; usize::try_from(length).expect("夹具尺寸")],
);
}
/// 临时目录里除 `keep` 之外的文件名(断言"没留下 .part"用)。
@@ -66,60 +91,145 @@ 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 = TempRoot::new("zip64-single");
// 恰好一个字节超过阈值:预检必须在这里就报,不许读内容(真读会先要 4 GiB 内存)。
sparse_file(
&root.join("overrides/mods/huge.jar"),
zip::ZIP64_BYTES_THR + 1,
);
// 单条就等于总量上限:预检在 `guard_with` 上拒,**不许读内容**。
// (旧版这里是 `ZIP64_BYTES_THR + 1` 的 4 GiB 夹具,见模块文档。)
content_file(&root.join("overrides/mods/huge.jar"), SMALL_TOTAL);
let manifest = manifest_of(vec![override_file("mods/huge.jar")]);
let target = root.join("out.zip");
let error = CURSEFORGE
.write(&manifest, PackPayload::from_root(&root), &target)
.expect_err("超 ZIP64 阈值必须拒写");
let error = write_with_limits(
&CURSEFORGE,
&manifest,
PackPayload::from_root(&root),
&target,
small_limits(),
)
.expect_err("超统一上限必须拒写");
assert_eq!(error.kind(), PackErrorKind::Limits, "{error}");
// 文案要点名「我们永远不会写 ZIP64」这件事,而不是拿 ZIP64 阈值当判据。
assert!(error.message().contains("ZIP64"), "{error}");
// 报出来的必须是**在用的**那个上限(生产路径上它就是 PACK_MAX_UNCOMPRESSED_BYTES,
// 接线由 `format/write/tests.rs` 的 `the_production_limits_are_the_shared_constants` 钉)。
assert!(
error.message().contains(&SMALL_TOTAL.to_string()),
"要报出在用的总量上限:{error}"
);
assert!(
error.hint().is_some_and(|hint| hint.contains("移出实例")),
"要给「把大文件移出实例」这种可执行的提示:{error:?}"
);
assert!(!target.exists(), "失败后目标路径不许存在");
// 临时文件也不许留(预检在建临时文件之前就失败 ⇒ 目录里只有源文件)。
// 临时文件也不许留:`container` 的顺序是 预检 → `File::create(temp)` → 读源文件,
// 所以"目录里只有源文件"证明**一个字节的内容都没被打开过**。
assert_eq!(leftovers(&root, &["overrides"]), Vec::<String>::new());
}
#[test]
fn the_threshold_is_cumulative_not_per_file() {
// 三个文件各自都远小于阈值,加起来超了:判据是**整包未压缩总量**,不是单文件。
// 四条各自都**没有**越过单条目上限,加起来越过了总量上限:判据是**整包未压缩总量**。
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, "三条加起来必须超标");
let each = SMALL_ENTRY;
assert!(each * 4 >= SMALL_TOTAL, "四条加起来必须真的越过总量上限");
let mut files = Vec::new();
for index in 0..3 {
for index in 0..4 {
let relative = format!("mods/part-{index}.jar");
sparse_file(&root.join("overrides").join(&relative), each);
content_file(&root.join("overrides").join(&relative), each);
files.push(override_file(&relative));
}
let target = root.join("out.zip");
let error = CURSEFORGE
.write(&manifest_of(files), PackPayload::from_root(&root), &target)
.expect_err("累计超阈值同样必须拒写");
let error = write_with_limits(
&CURSEFORGE,
&manifest_of(files),
PackPayload::from_root(&root),
&target,
small_limits(),
)
.expect_err("累计超上限同样必须拒写");
assert_eq!(error.kind(), PackErrorKind::Limits, "{error}");
assert!(error.message().contains("ZIP64"), "{error}");
assert!(!target.exists());
assert_eq!(leftovers(&root, &["overrides"]), Vec::<String>::new());
}
#[test]
fn a_single_entry_over_the_entry_limit_is_refused_before_a_single_byte_is_read() {
// **单条目**那道闸(`guard_entry`):总量还远在上限之下,只有一条越界。
// 这条顶替了旧版 `#[ignore]` 的「2.5 GB 单文件落在 ZIP64 过拒窗口」端到端用例——
// M160 之后那个窗口按设计不再存在,留下的是"单条越界就拒、且拒在读内容之前"。
// 2.5 GB 那个**数值**的判定搬到 `format/write/tests.rs`(那里不需要落盘)。
let root = TempRoot::new("zip64-entry");
content_file(&root.join("overrides/mods/big.jar"), SMALL_ENTRY + 1);
let manifest = manifest_of(vec![override_file("mods/big.jar")]);
let target = root.join("out.zip");
let error = write_with_limits(
&CURSEFORGE,
&manifest,
PackPayload::from_root(&root),
&target,
small_limits(),
)
.expect_err("超过单条目上限必须拒");
assert_eq!(error.kind(), PackErrorKind::Limits, "{error}");
assert!(
error.message().contains(&SMALL_ENTRY.to_string()),
"要报出在用的单条目上限:{error}"
);
assert!(!target.exists(), "预检失败时目标路径不许出现");
assert_eq!(leftovers(&root, &["overrides"]), Vec::<String>::new());
}
#[test]
fn the_gate_runs_before_any_content_is_opened() {
// **行为**证明(复审 r1 的观察 2):源做成**目录**——`metadata()` 拿得到长度,
// `File::open` 会失败(Windows NTFS: "拒绝访问",实测 `C:\Windows` = 20480 字节、
// 仓库里的 `crates/core/src` = 4096 字节)。所以闸若被挪到"读完内容之后",这里拿到的
// 会是 `pack_io` 而不是 `pack_limits` —— 比只看"临时文件没被创建"更硬。
let root = TempRoot::new("zip64-before-read");
let directory = root.join("overrides/mods/not-a-file");
std::fs::create_dir_all(&directory).expect("建目录");
let declared = std::fs::metadata(&directory)
.expect("stat 目录(这一步必须成功,否则区分不了两道闸)")
.len();
if declared == 0 {
// 少数文件系统把目录长度报成 0:这条行为断言在那上面无从构造。此时覆盖仍在
// (`leftovers` / `!target.exists()` 的结构证明不受影响)。
return;
}
// 上限比目录的**声明长度**小一字节:总量闸必然先命中,且内容一个字节都不可能读成功。
let limit = declared - 1;
let target = root.join("out.zip");
let error = write_with_limits(
&CURSEFORGE,
&manifest_of(vec![override_file("mods/not-a-file")]),
PackPayload::from_root(&root),
&target,
WriteLimits::new(limit, limit, SMALL_ENTRIES),
)
.expect_err("声明尺寸超限必须拒");
assert_eq!(
error.kind(),
PackErrorKind::Limits,
"闸必须在打开内容**之前**:拿到 pack_io 说明它被挪到读之后了:{error}"
);
assert!(!target.exists(), "预检失败时目标路径不许出现");
assert_eq!(leftovers(&root, &["overrides"]), Vec::<String>::new());
}
#[test]
fn an_export_under_the_threshold_still_writes_a_normal_pack() {
// 对照:不超阈值时预检不许误伤(否则这条护栏就变成"导出全挂")。
// 走**生产口径**(`CURSEFORGE.write` → `WriteLimits::production()`):小 limits 那几条
// 证明的是分支,这条证明的是生产接线不会把正常包拒掉。
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("写覆盖文件");
write_file(&root.join("overrides/mods/jei.jar"), content);
let manifest = manifest_of(vec![override_file("mods/jei.jar")]);
let target = root.join("out.zip");
@@ -130,42 +240,3 @@ fn an_export_under_the_threshold_still_writes_a_normal_pack() {
let written = crate::addon::pack::PackArchive::open_default(&target).expect("打开导出的包");
assert!(written.has_entry("overrides/mods/jei.jar"));
}
/// 端到端版的旧窗口回归钉 → **现在钉的是口径统一**(`#[ignore]`:要造稀疏大文件 + 一遍 deflate)。
///
/// M160 把写侧的上限换成读侧那份常量(512 MiB 总量 / 64 MiB 单条),所以 M152 时代
/// 「2.5 GB 单文件落在 ZIP64 过拒窗口里、必须能导出」这件事**按设计不再成立**:那个包现在导得
/// 出来、导不回去。本条留下的是新口径的端到端证据——2.5 GB 的覆盖文件在**碰内容之前**就被
/// 统一上限拒掉,且临时文件与目标路径都不留。
///
/// ```text
/// cargo test -p unlucky-core --lib -- zip64 --ignored --nocapture
/// ```
#[test]
#[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 < 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 error = CURSEFORGE
.write(&manifest, PackPayload::from_root(&root), &target)
.expect_err("超过统一上限(512 MiB)必须拒:否则导出的包自己导不回来");
assert_eq!(error.kind(), PackErrorKind::Limits, "{error}");
assert!(
error.message().contains(
&crate::addon::pack::archive::limits::PACK_MAX_UNCOMPRESSED_BYTES.to_string()
),
"文案要给出统一上限那个数:{error}"
);
assert!(!target.exists(), "预检失败时目标路径不许出现");
// 稀疏源文件只占元数据,但目录与临时文件都清掉,别让 temp 越积越多。
let _ = std::fs::remove_dir_all(&root);
}
+2 -1
View File
@@ -8,7 +8,8 @@
//!
//! # 为什么不能靠"系统会清 `%TEMP%`"
//!
//! 那种清理要等时间或外部工具,而 `zip64` 那族用例每个要造 4 GiB 的**稀疏文件**验阈值:
//! 那种清理要等时间或外部工具,而 `zip64` 那族用例历史上每个要造 4 GiB 的"稀疏"文件验阈值
//! (**实测在 NTFS 上 `set_len` 真占空间**,见 `pack/tests/zip64.rs` 的模块文档):
//! M160 r2 实测 `%TEMP%/ull-pack-*` 堆到 **1651 个目录 / 110 GB**,把整台机器(含别的 agent
//! 的构建)一起顶死。所以测试自己负责收尾,并且有自证用例
//! (`pack/tests/mod.rs` 的 `the_temp_root_is_gone_once_dropped` /