test(m166): zip64 夹具改用小 limits,去掉 8.5 GiB 真落盘

`set_len` 造的所谓稀疏文件在 NTFS 上并不稀疏:实测 `SetLength(1 GiB)` 之后
C 盘可用空间正好掉 1024 MiB。于是 `zip64.rs` 那两条用例的声明尺寸
(`u32::MAX + 1` ≈ 4 GiB 加 `3 × 1.5 GiB` = 4.5 GiB)就是 8.5 GiB **真占用**,
再加上 `write/tests.rs` 的 3 GiB 与 `limits.rs` 的三条 64 MiB 级用例,
单次跑的落盘峰值到过 ~12 GiB。

判据本身跟夹具大小无关(吃的是 `metadata().len()`),所以把 limits 做成参数:
`format::write_with_limits` 是 `PackFormat::write` 的实体,生产路径仍只传
`WriteLimits::production()`,测试传 KiB 级小值走**同一条** `preflight` 分支。
`WriteLimits` 的可见性顺势开到 `pub(in crate::addon::pack)`。

GiB 级的**数值判定**搬进 `format/write/tests.rs` 的纯算术用例:>4 GiB(撞 ZIP64
阈值即拒、差一字节放行)与 >2 GiB(M152 那个 2.5 GB 样本现在按统一上限拒),
以及第二道闸 `Written` 在同一批数值上的行为——两道闸吃的都是 u64,不需要落盘。

断言一条没删、用例一条没新加 `#[ignore]`(反而把旧的那条 `#[ignore]` 的
2.5 GB 用例换成会跑的 KiB 级版本,补上 `guard_entry` 分支的端到端覆盖)。

扫描:`grep -rn 稀疏 crates/ packaging/ docs/` 里凡写着"稀疏文件不占磁盘"的
注释一并改成实测口径(含 `addon/temp.rs`),避免下一个人照着再踩一次。
This commit is contained in:
AskaEth committed 2026-11-08 09:30:26 +08:00
1 parent 5e1f0868f1
commit 49a2fafe51
7 files changed
+328 -189

No files matched your search

+55 -34
View File
@@ -133,47 +133,68 @@ pub trait PackFormat: Debug + Send + Sync {
payload: PackPayload<'_>, payload: PackPayload<'_>,
target: &Path, target: &Path,
) -> PackResult<()> { ) -> PackResult<()> {
let Some(entry) = self.metadata_entry() else { // 生产口径只有一处来源:导入读取用的也是这三个数(见 `archive::limits`)。
return Err(PackError::unsupported(format!( write_with_limits(
"格式 {}({})只支持读取,不支持导出", self,
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,
manifest, manifest,
payload, payload,
&temp, target,
// 生产口径只有一处来源:导入读取用的也是这两个数(见 `archive::limits`)。
write::WriteLimits::production(), write::WriteLimits::production(),
) { )
Ok(()) => std::fs::rename(&temp, target).map_err(|error| { }
let _ = std::fs::remove_file(&temp); }
PackError::io(format!(
"把 {} 改名成 {} 失败:{error}", /// [`PackFormat::write`] 的实体:限额由调用方给。
temp.display(), ///
target.display() /// # 为什么把 limits 做成参数
)) ///
}), /// 预检的判据是「**声明尺寸** ≥ 上限」,与夹具真实大小无关。生产路径只传
Err(error) => { /// [`write::WriteLimits::production`];测试传几百字节的小值,走的是**同一条**
let _ = std::fs::remove_file(&temp); /// `write::preflight` 分支——这样"闸在碰内容之前"不必再造 GiB 级夹具来证明。
Err(error) /// (旧版测试用 `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<()> {
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)] #[derive(Debug, Clone)]
pub struct PackMatch { pub struct PackMatch {
+10 -6
View File
@@ -166,19 +166,23 @@ fn preflight(
} }
/// 写侧的限额口径(**默认值直接取读侧那三个常量**,见 `archive::limits` 的模块文档)。 /// 写侧的限额口径(**默认值直接取读侧那三个常量**,见 `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)] #[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 { impl WriteLimits {
/// 生产口径:与 [`PackLimits::default`](crate::addon::pack::PackLimits) 同一个数。 /// 生产口径:与 [`PackLimits::default`](crate::addon::pack::PackLimits) 同一个数。
pub(super) const fn production() -> Self { pub(in crate::addon::pack) const fn production() -> Self {
Self { Self {
total: super::super::archive::limits::PACK_MAX_UNCOMPRESSED_BYTES, total: super::super::archive::limits::PACK_MAX_UNCOMPRESSED_BYTES,
entry: super::super::archive::limits::PACK_MAX_ENTRY_BYTES, entry: super::super::archive::limits::PACK_MAX_ENTRY_BYTES,
@@ -188,7 +192,7 @@ impl WriteLimits {
/// 显式给值(测试用:把边界缩小到可测尺寸)。 /// 显式给值(测试用:把边界缩小到可测尺寸)。
#[cfg(test)] #[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 { Self {
total, total,
entry, entry,
@@ -47,10 +47,10 @@ fn the_container_does_not_double_count_the_preflight_total() {
// `json` 30 字节 + 覆盖文件 40 字节 = 声明总量 70。 // `json` 30 字节 + 覆盖文件 40 字节 = 声明总量 70。
// - 正确实现:第二道闸从 30 起算,写到 70 也不到 100 ⇒ 导出成功; // - 正确实现:第二道闸从 30 起算,写到 70 也不到 100 ⇒ 导出成功;
// - P2-1 的写法(把预检返回的全量 70 当基数):写循环再累加 40 ⇒ 110 ≥ 100 ⇒ 误拒。 // - P2-1 的写法(把预检返回的全量 70 当基数):写循环再累加 40 ⇒ 110 ≥ 100 ⇒ 误拒。
// 真造 2.5 GB 才能暴露同一个 bug,本机资源纪律下不跑(那条留成 `zip64.rs` 里的 // 真造 2.5 GB 才能暴露同一个 bug,本机资源纪律下不跑:GiB 级的**数值判定**由
// `#[ignore]` 用例)。 // `the_gib_scale_boundaries_decide_without_any_fixture` 用纯算术钉住。测试全部用
// 临时根用 `TempRoot`(drop 即删、panic 也删):这两个用例以前自己拼 `%TEMP%` 路径、 // `TempRoot`(drop 即删、panic 也删):旧版自己拼 `%TEMP%` 路径、只在**正常收尾**时删,
// 只在**正常收尾**时删,断言一红就留下 `ull-double-count-*`(M160 r2 的泄漏口径)。 // 断言一红就留下 `ull-double-count-*`(M160 r2 的泄漏口径)。
let root = TempRoot::new("double-count"); let root = TempRoot::new("double-count");
std::fs::create_dir_all(root.join("overrides/mods")).expect("建目录"); std::fs::create_dir_all(root.join("overrides/mods")).expect("建目录");
std::fs::write(root.join("overrides/mods/a.bin"), [0_u8; 40]).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] #[test]
fn the_preflight_measures_declared_sizes_not_content() { fn the_preflight_measures_declared_sizes_not_content() {
// 预检必须只量 `metadata().len()`:这一条用"小文件 + 3 GiB 稀疏文件"钉住, // 预检必须只量 `metadata().len()`。夹具刻意**只有几 KiB**(M166):把"没读内容"钉住的是
// 真读内容的话要么慢要么直接吃 3 GiB 内存。 // **结构**而不是字节数——`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"); let root = TempRoot::new("preflight");
std::fs::create_dir_all(root.join("overrides/mods")).expect("建目录"); std::fs::create_dir_all(root.join("overrides/mods")).expect("建目录");
std::fs::write(root.join("overrides/mods/small.jar"), b"0123456789").expect("写小文件"); std::fs::write(root.join("overrides/mods/small.jar"), b"0123456789").expect("写小文件");
let huge = root.join("overrides/mods/huge.jar"); std::fs::write(
let file = std::fs::File::create(&huge).expect("建稀疏文件"); root.join("overrides/mods/huge.jar"),
file.set_len(3 * 1024 * 1024 * 1024).expect("拉长(稀疏)"); vec![0_u8; HUGE as usize],
drop(file); )
.expect("写大一点的覆盖文件");
let mut manifest = PackManifest::new("preflight"); let mut manifest = PackManifest::new("preflight");
manifest.overrides.push(PackOverrides { manifest.overrides.push(PackOverrides {
@@ -134,17 +139,68 @@ fn the_preflight_measures_declared_sizes_not_content() {
7, 7,
&manifest, &manifest,
PackPayload::from_root(&root), PackPayload::from_root(&root),
// 本条只关心"预检不读内容",所以三道闸都放到最宽(生产口径见 `tests/limits.rs`)。 // 总量闸放到 8 KiB:声明总量 7 + 10 + 6144 仍在它之下,预检不许误伤。
WriteLimits::new( WriteLimits::new(LIMIT, LIMIT, 16),
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,
),
) )
.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!( assert_eq!(
total, production.total,
7 + 10 + 3 * 1024 * 1024 * 1024, 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 必须拒");
}
+75 -51
View File
@@ -10,6 +10,17 @@
//! 2. 读侧 `PackLimits::default()` 就是这三个数; //! 2. 读侧 `PackLimits::default()` 就是这三个数;
//! 3. 写侧在**单条边界**上拒(`>` 而不是 `≥`:恰好 64 MiB 合法); //! 3. 写侧在**单条边界**上拒(`>` 而不是 `≥`:恰好 64 MiB 合法);
//! 4. 写侧在**总量**上拒,且拒绝发生在读内容之前(临时目录里什么都不留)。 //! 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 super::{write_file, TempRoot};
use crate::addon::pack::archive::limits::{ 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::curseforge::CURSEFORGE;
use crate::addon::pack::error::PackErrorKind; 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::manifest::{OverrideFile, PackManifest, PackOverrides, PackSide};
use crate::addon::pack::PackLimits; use crate::addon::pack::PackLimits;
/// 小 limits 用的单条目上限(生产口径是 64 MiB,这里缩到 2 KiB)。
const ENTRY: u64 = 2 * 1024;
/// 一个覆盖文件的声明(内容由调用方提供)。 /// 一个覆盖文件的声明(内容由调用方提供)。
fn override_file(path: &str) -> OverrideFile { fn override_file(path: &str) -> OverrideFile {
OverrideFile { OverrideFile {
@@ -42,13 +56,12 @@ fn manifest_of(files: Vec<OverrideFile>) -> PackManifest {
manifest manifest
} }
/// 造一个只占元数据的 `length` 字节文件(`set_len` 不写零)。 /// 造一个真有 `length` 字节内容的覆盖文件(**不稀疏**,见模块文档)。
fn sparse_file(path: &std::path::Path, length: u64) { fn content_file(path: &std::path::Path, length: u64) {
if let Some(parent) = path.parent() { write_file(
std::fs::create_dir_all(parent).expect("建目录"); path,
} &vec![0_u8; usize::try_from(length).expect("夹具尺寸")],
let file = std::fs::File::create(path).expect("建稀疏文件"); );
file.set_len(length).expect("拉长文件(稀疏)");
} }
#[test] #[test]
@@ -72,6 +85,7 @@ fn the_write_side_refuses_a_pack_over_the_unified_entry_count() {
// ② 声明 20001 条(上限 20000 + 1):只有**第一条**的文件真的存在,其余 2 万条谁都不在盘上。 // ② 声明 20001 条(上限 20000 + 1):只有**第一条**的文件真的存在,其余 2 万条谁都不在盘上。
// 报出来的必须是 `pack_limits` 而不是 `pack_io` —— 那就是"条目数闸在读第一个文件之前"的证据 // 报出来的必须是 `pack_limits` 而不是 `pack_io` —— 那就是"条目数闸在读第一个文件之前"的证据
// (真去 `metadata()` 的话,第二个文件就已经报 `pack_io` 了)。 // (真去 `metadata()` 的话,第二个文件就已经报 `pack_io` 了)。
// 这条**不需要**小 limits:清单条目本身不占磁盘,2 万条只是一个 JSON 数组。
let mut files = vec![override_file("mods/a.jar")]; let mut files = vec![override_file("mods/a.jar")];
for index in 1..=PACK_MAX_ENTRIES { for index in 1..=PACK_MAX_ENTRIES {
files.push(override_file(&format!("mods/{index:05}.jar"))); files.push(override_file(&format!("mods/{index:05}.jar")));
@@ -129,26 +143,33 @@ fn the_three_limits_form_one_chain_and_the_read_side_uses_them() {
#[test] #[test]
fn the_write_side_refuses_a_single_entry_over_the_unified_entry_limit() { 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"); let root = TempRoot::new("limits-entry");
// 恰好 +1 字节超限:拒绝理由是**单条**上限(总量 64 MiB + 1 仍在 512 MiB 之下)。 let limits = WriteLimits::new(ENTRY * 4, ENTRY, 16);
sparse_file( // 恰好 +1 字节超**单条**上限;声明总量(清单 + 2049)仍在总量上限 ENTRY*4 之下,
&root.join("overrides/mods/too-big.jar"), // 所以只有 `guard_entry` 能拒——两档判据没串味。
PACK_MAX_ENTRY_BYTES + 1, content_file(&root.join("overrides/mods/too-big.jar"), ENTRY + 1);
assert!(
ENTRY + 1 < limits.total,
"样本的声明总量必须仍在总量上限之下"
); );
let target = root.join("out.zip"); let target = root.join("out.zip");
let error = CURSEFORGE let error = write_with_limits(
.write( &CURSEFORGE,
&manifest_of(vec![override_file("mods/too-big.jar")]), &manifest_of(vec![override_file("mods/too-big.jar")]),
PackPayload::from_root(&root), PackPayload::from_root(&root),
&target, &target,
) limits,
.expect_err("超过单条统一上限必须拒"); )
.expect_err("超过单条统一上限必须拒");
assert_eq!(error.kind(), PackErrorKind::Limits, "{error}"); assert_eq!(error.kind(), PackErrorKind::Limits, "{error}");
assert!( assert!(
error.message().contains(&PACK_MAX_ENTRY_BYTES.to_string()), error.message().contains(&ENTRY.to_string()),
"文案要给出那个数(读侧用的是同一个):{error}" "文案要给出在用的那个单条上限:{error}"
); );
assert!( assert!(
error.hint().is_some_and(|hint| hint.contains("移出实例")), error.hint().is_some_and(|hint| hint.contains("移出实例")),
@@ -163,67 +184,71 @@ fn the_write_side_refuses_a_single_entry_over_the_unified_entry_limit() {
.filter(|name| name != "overrides") .filter(|name| name != "overrides")
.collect(); .collect();
assert!(leftovers.is_empty(), "不许留临时文件:{leftovers:?}"); assert!(leftovers.is_empty(), "不许留临时文件:{leftovers:?}");
let _ = std::fs::remove_dir_all(&root);
} }
#[test] #[test]
fn the_entry_boundary_is_exactly_the_limit() { fn the_entry_boundary_is_exactly_the_limit() {
// 边界口径是 `>`:恰好 64 MiB 合法(与读侧的 `size > max_entry_bytes` 同一侧)。 // 边界口径是 `>`:恰好等于上限合法(与读侧的 `size > max_entry_bytes` 同一侧)。
let root = TempRoot::new("limits-boundary"); 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"); let target = root.join("out.zip");
CURSEFORGE write_with_limits(
.write( &CURSEFORGE,
&manifest_of(vec![override_file("mods/exact.jar")]), &manifest_of(vec![override_file("mods/exact.jar")]),
PackPayload::from_root(&root), PackPayload::from_root(&root),
&target, &target,
) limits,
.expect("恰好等于上限必须写得出来(否则两侧的边界就错开了)"); )
.expect("恰好等于上限必须写得出来(否则两侧的边界就错开了)");
let written = crate::addon::pack::PackArchive::open( let written = crate::addon::pack::PackArchive::open(
&target, &target,
// 读回来时单条上限就是"恰好等于它",证明写出来的东西读侧认。 // 读回来时单条上限就是"恰好等于它",证明写出来的东西读侧认。
PackLimits { PackLimits {
max_entry_bytes: PACK_MAX_ENTRY_BYTES, max_entry_bytes: ENTRY,
..PackLimits::default() ..PackLimits::default()
}, },
) )
.expect("刚写出来的包必须能被自己的读取层打开"); .expect("刚写出来的包必须能被自己的读取层打开");
assert!(written.has_entry("overrides/mods/exact.jar")); assert!(written.has_entry("overrides/mods/exact.jar"));
let _ = std::fs::remove_dir_all(&root);
} }
#[test] #[test]
fn the_write_side_refuses_a_total_over_the_unified_total_limit() { 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 root = TempRoot::new("limits-total");
let limits = WriteLimits::new(ENTRY * 3, ENTRY, 16);
let mut files = Vec::new(); let mut files = Vec::new();
for index in 0..9 { for index in 0..3 {
let relative = format!("mods/part-{index}.jar"); let relative = format!("mods/part-{index}.jar");
sparse_file( content_file(&root.join("overrides").join(&relative), ENTRY);
&root.join("overrides").join(&relative),
PACK_MAX_ENTRY_BYTES,
);
files.push(override_file(&relative)); files.push(override_file(&relative));
} }
let total = PACK_MAX_ENTRY_BYTES * 9; let total = ENTRY * 3;
assert!(total > PACK_MAX_UNCOMPRESSED_BYTES, "样本必须真的超总量"); assert!(
ENTRY <= limits.entry,
"每条都必须在单条上限之内(判据是累计,不是单条)"
);
assert!(total >= limits.total, "三条加起来必须真的超总量");
let target = root.join("out.zip"); let target = root.join("out.zip");
let error = CURSEFORGE let error = write_with_limits(
.write(&manifest_of(files), PackPayload::from_root(&root), &target) &CURSEFORGE,
.expect_err("累计超总量必须拒"); &manifest_of(files),
PackPayload::from_root(&root),
&target,
limits,
)
.expect_err("累计超总量必须拒");
assert_eq!(error.kind(), PackErrorKind::Limits, "{error}"); assert_eq!(error.kind(), PackErrorKind::Limits, "{error}");
assert!( assert!(
error error.message().contains(&total.to_string()),
.message() "文案要给出在用的总量上限:{error}"
.contains(&PACK_MAX_UNCOMPRESSED_BYTES.to_string()),
"文案要给出统一总量上限:{error}"
); );
assert!(!target.exists()); assert!(!target.exists());
let _ = std::fs::remove_dir_all(&root);
} }
#[test] #[test]
@@ -252,5 +277,4 @@ fn a_real_pack_written_under_the_limit_reopens_under_the_read_limits() {
.expect("默认限额(= 统一口径)必须读得回来"); .expect("默认限额(= 统一口径)必须读得回来");
assert!(pack.has_entry("overrides/mods/jei.jar")); assert!(pack.has_entry("overrides/mods/jei.jar"));
assert!(pack.has_entry("overrides/config/known.toml")); 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) //! # 临时目录必须**测试自己收尾**(M160 r2)
//! //!
//! 每个用例的临时根是 [`TempRoot`]:`Drop` 里 `remove_dir_all`,所以**正常结束与 panic 展开 //! 每个用例的临时根是 [`TempRoot`]:`Drop` 里 `remove_dir_all`,所以**正常结束与 panic 展开
//! 都会删**(Rust 展开会跑析构)。这条不是洁癖:`zip64` 那族用例要造 4 GiB 的**稀疏文件**验 //! 都会删**(Rust 展开会跑析构)。这条不是洁癖:`zip64` 那族用例历史上要造 4 GiB 的"稀疏"
//! 阈值,残留会把 `%TEMP%` 堆到撑爆整台机器(实测 1651 个目录 / 110 GB,把 CI 与别的 agent //! 文件验阈值(**实测在 NTFS 上真占 4 GiB**,见 `tests/zip64.rs` 的模块文档),残留会把
//! 的构建一起咬死)。 //! `%TEMP%` 堆到撑爆整台机器(实测 1651 个目录 / 110 GB,把 CI 与别的 agent 的构建一起咬死)。
//! M166 之后夹具缩到 KiB 级,但"测试自己收尾"这条不变——泄漏一次就是几十 GB 的量级。
//! //!
//! 写法的来源是仓库既有的那一份(`crate::update::tests::support::TempDir`):同样的 `new` / //! 写法的来源是仓库既有的那一份(`crate::update::tests::support::TempDir`):同样的 `new` /
//! `join` / `Drop` 三件套,只把前缀换成 `ull-pack-`,不引 `tempfile`、不新增第四种范式。 //! `join` / `Drop` 三件套,只把前缀换成 `ull-pack-`,不引 `tempfile`、不新增第四种范式。
@@ -131,7 +132,7 @@ fn the_temp_root_is_gone_once_dropped() {
#[test] #[test]
fn a_panicking_test_still_removes_its_temp_root() { fn a_panicking_test_still_removes_its_temp_root() {
// 自证②:panic 展开路径。断言"panic 也删"——这正是 4 GiB 稀疏文件残留的那条路: // 自证②:panic 展开路径。断言"panic 也删"——这正是大夹具残留的那条路:
// 断言红了不代表可以留下 110 GB。 // 断言红了不代表可以留下 110 GB。
let path = std::sync::Arc::new(std::sync::Mutex::new(PathBuf::new())); let path = std::sync::Arc::new(std::sync::Mutex::new(PathBuf::new()));
let slot = std::sync::Arc::clone(&path); let slot = std::sync::Arc::clone(&path);
+105 -73
View File
@@ -4,23 +4,49 @@
//! //!
//! 以前判据是 `zip::ZIP64_BYTES_THR`(≈4 GiB),而导入读取只允许 512 MiB —— 于是存在 //! 以前判据是 `zip::ZIP64_BYTES_THR`(≈4 GiB),而导入读取只允许 512 MiB —— 于是存在
//! 「导得出来、导不回去」的包。M160 把写侧上限换成读侧那份常量(`pack::archive::limits`), //! 「导得出来、导不回去」的包。M160 把写侧上限换成读侧那份常量(`pack::archive::limits`),
//! 本文件的两条用例(单文件超大 / 累计超大)仍然钉住**同一件事**:闸门在碰内容之前就把包拒了。 //! 本文件仍钉**同一件事**:闸门在碰内容之前就把包拒了。只是拒绝的理由从"要写 ZIP64"
//! 只是拒绝的理由从"要写 ZIP64"变成"超过统一上限,所以永远不会写出 ZIP64"。 //! 变成"超过统一上限,所以永远不会写出 ZIP64"。
//! //!
//! # 为什么用稀疏文件 //! # 夹具只有几 KiB(M166)
//! //!
//! 判据是**声明尺寸**(`metadata().len()`),所以测试可以用稀疏文件造出 GB 级的"声明尺寸"而 //! 判据是**声明尺寸**(`metadata().len()`),所以判据本身跟夹具真实大小无关。旧版本靠
//! 几乎不占磁盘(`set_len` 只改 EOF,NTFS / ext4 都不会真写零)。这也是唯一能在不塞满硬盘的 //! `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 std::path::Path;
use super::write_file;
use super::TempRoot; use super::TempRoot;
use crate::addon::pack::curseforge::CURSEFORGE; use crate::addon::pack::curseforge::CURSEFORGE;
use crate::addon::pack::error::PackErrorKind; 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::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 { fn override_file(path: &str) -> OverrideFile {
OverrideFile { OverrideFile {
@@ -42,13 +68,12 @@ fn manifest_of(files: Vec<OverrideFile>) -> PackManifest {
manifest manifest
} }
/// 造一个只占元数据的 `length` 字节文件(`set_len` 不写零)。 /// 造一个真有 `length` 字节内容的覆盖文件(**不稀疏**,见模块文档)。
fn sparse_file(path: &Path, length: u64) { fn content_file(path: &Path, length: u64) {
if let Some(parent) = path.parent() { write_file(
std::fs::create_dir_all(parent).expect("建目录"); path,
} &vec![0_u8; usize::try_from(length).expect("夹具尺寸")],
let file = std::fs::File::create(path).expect("建稀疏文件"); );
file.set_len(length).expect("拉长文件(稀疏)");
} }
/// 临时目录里除 `keep` 之外的文件名(断言"没留下 .part"用)。 /// 临时目录里除 `keep` 之外的文件名(断言"没留下 .part"用)。
@@ -66,60 +91,106 @@ fn leftovers(root: &Path, keep: &[&str]) -> Vec<String> {
#[test] #[test]
fn one_file_over_the_threshold_is_refused_before_a_single_byte_is_read() { fn one_file_over_the_threshold_is_refused_before_a_single_byte_is_read() {
let root = TempRoot::new("zip64-single"); let root = TempRoot::new("zip64-single");
// 恰好一个字节超过阈值:预检必须在这里就报,不许读内容(真读会先要 4 GiB 内存)。 // 单条就等于总量上限:预检在 `guard_with` 上拒,**不许读内容**。
sparse_file( // (旧版这里是 `ZIP64_BYTES_THR + 1` 的 4 GiB 夹具,见模块文档。)
&root.join("overrides/mods/huge.jar"), content_file(&root.join("overrides/mods/huge.jar"), SMALL_TOTAL);
zip::ZIP64_BYTES_THR + 1,
);
let manifest = manifest_of(vec![override_file("mods/huge.jar")]); let manifest = manifest_of(vec![override_file("mods/huge.jar")]);
let target = root.join("out.zip"); let target = root.join("out.zip");
let error = CURSEFORGE let error = write_with_limits(
.write(&manifest, PackPayload::from_root(&root), &target) &CURSEFORGE,
.expect_err("超 ZIP64 阈值必须拒写"); &manifest,
PackPayload::from_root(&root),
&target,
small_limits(),
)
.expect_err("超统一上限必须拒写");
assert_eq!(error.kind(), PackErrorKind::Limits, "{error}"); assert_eq!(error.kind(), PackErrorKind::Limits, "{error}");
// 文案要点名「我们永远不会写 ZIP64」这件事,而不是拿 ZIP64 阈值当判据。
assert!(error.message().contains("ZIP64"), "{error}"); 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!( assert!(
error.hint().is_some_and(|hint| hint.contains("移出实例")), error.hint().is_some_and(|hint| hint.contains("移出实例")),
"要给「把大文件移出实例」这种可执行的提示:{error:?}" "要给「把大文件移出实例」这种可执行的提示:{error:?}"
); );
assert!(!target.exists(), "失败后目标路径不许存在"); assert!(!target.exists(), "失败后目标路径不许存在");
// 临时文件也不许留(预检在建临时文件之前就失败 ⇒ 目录里只有源文件)。 // 临时文件也不许留:`container` 的顺序是 预检 → `File::create(temp)` → 读源文件,
// 所以"目录里只有源文件"证明**一个字节的内容都没被打开过**。
assert_eq!(leftovers(&root, &["overrides"]), Vec::<String>::new()); assert_eq!(leftovers(&root, &["overrides"]), Vec::<String>::new());
} }
#[test] #[test]
fn the_threshold_is_cumulative_not_per_file() { fn the_threshold_is_cumulative_not_per_file() {
// 三个文件各自都远小于阈值,加起来超了:判据是**整包未压缩总量**,不是单文件。 // 四条各自都**没有**越过单条目上限,加起来越过了总量上限:判据是**整包未压缩总量**。
let root = TempRoot::new("zip64-cumulative"); let root = TempRoot::new("zip64-cumulative");
let each = 1_610_612_736_u64; // 1.5 GiB let each = SMALL_ENTRY;
assert!(each < zip::ZIP64_BYTES_THR, "单条必须都在阈值以下"); assert!(each * 4 >= SMALL_TOTAL, "四条加起来必须真的越过总量上限");
assert!(each * 3 > zip::ZIP64_BYTES_THR, "三条加起来必须超标");
let mut files = Vec::new(); let mut files = Vec::new();
for index in 0..3 { for index in 0..4 {
let relative = format!("mods/part-{index}.jar"); 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)); files.push(override_file(&relative));
} }
let target = root.join("out.zip"); let target = root.join("out.zip");
let error = CURSEFORGE let error = write_with_limits(
.write(&manifest_of(files), PackPayload::from_root(&root), &target) &CURSEFORGE,
.expect_err("累计超阈值同样必须拒写"); &manifest_of(files),
PackPayload::from_root(&root),
&target,
small_limits(),
)
.expect_err("累计超上限同样必须拒写");
assert_eq!(error.kind(), PackErrorKind::Limits, "{error}"); assert_eq!(error.kind(), PackErrorKind::Limits, "{error}");
assert!(error.message().contains("ZIP64"), "{error}"); assert!(error.message().contains("ZIP64"), "{error}");
assert!(!target.exists()); 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] #[test]
fn an_export_under_the_threshold_still_writes_a_normal_pack() { fn an_export_under_the_threshold_still_writes_a_normal_pack() {
// 对照:不超阈值时预检不许误伤(否则这条护栏就变成"导出全挂")。 // 对照:不超阈值时预检不许误伤(否则这条护栏就变成"导出全挂")。
// 走**生产口径**(`CURSEFORGE.write` → `WriteLimits::production()`):小 limits 那几条
// 证明的是分支,这条证明的是生产接线不会把正常包拒掉。
let root = TempRoot::new("zip64-under"); let root = TempRoot::new("zip64-under");
let content = b"a normal sized override file"; let content = b"a normal sized override file";
std::fs::create_dir_all(root.join("overrides/mods")).expect("建目录"); write_file(&root.join("overrides/mods/jei.jar"), content);
std::fs::write(root.join("overrides/mods/jei.jar"), content).expect("写覆盖文件");
let manifest = manifest_of(vec![override_file("mods/jei.jar")]); let manifest = manifest_of(vec![override_file("mods/jei.jar")]);
let target = root.join("out.zip"); let target = root.join("out.zip");
@@ -130,42 +201,3 @@ fn an_export_under_the_threshold_still_writes_a_normal_pack() {
let written = crate::addon::pack::PackArchive::open_default(&target).expect("打开导出的包"); let written = crate::addon::pack::PackArchive::open_default(&target).expect("打开导出的包");
assert!(written.has_entry("overrides/mods/jei.jar")); 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%`" //! # 为什么不能靠"系统会清 `%TEMP%`"
//! //!
//! 那种清理要等时间或外部工具,而 `zip64` 那族用例每个要造 4 GiB 的**稀疏文件**验阈值: //! 那种清理要等时间或外部工具,而 `zip64` 那族用例历史上每个要造 4 GiB 的"稀疏"文件验阈值
//! (**实测在 NTFS 上 `set_len` 真占空间**,见 `pack/tests/zip64.rs` 的模块文档):
//! M160 r2 实测 `%TEMP%/ull-pack-*` 堆到 **1651 个目录 / 110 GB**,把整台机器(含别的 agent //! M160 r2 实测 `%TEMP%/ull-pack-*` 堆到 **1651 个目录 / 110 GB**,把整台机器(含别的 agent
//! 的构建)一起顶死。所以测试自己负责收尾,并且有自证用例 //! 的构建)一起顶死。所以测试自己负责收尾,并且有自证用例
//! (`pack/tests/mod.rs` 的 `the_temp_root_is_gone_once_dropped` / //! (`pack/tests/mod.rs` 的 `the_temp_root_is_gone_once_dropped` /