From 49a2fafe51d57e5b61f0793e19f6ba07ac7dc276 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 8 Nov 2026 08:39:17 +0800 Subject: [PATCH 1/2] =?UTF-8?q?test(m166):=20zip64=20=E5=A4=B9=E5=85=B7?= =?UTF-8?q?=E6=94=B9=E7=94=A8=E5=B0=8F=20limits=EF=BC=8C=E5=8E=BB=E6=8E=89?= =?UTF-8?q?=208.5=20GiB=20=E7=9C=9F=E8=90=BD=E7=9B=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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`),避免下一个人照着再踩一次。 --- crates/core/src/addon/pack/format.rs | 89 +++++---- crates/core/src/addon/pack/format/write.rs | 16 +- .../core/src/addon/pack/format/write/tests.rs | 96 ++++++++-- crates/core/src/addon/pack/tests/limits.rs | 126 ++++++++----- crates/core/src/addon/pack/tests/mod.rs | 9 +- crates/core/src/addon/pack/tests/zip64.rs | 178 +++++++++++------- crates/core/src/addon/temp.rs | 3 +- 7 files changed, 328 insertions(+), 189 deletions(-) diff --git a/crates/core/src/addon/pack/format.rs b/crates/core/src/addon/pack/format.rs index a67bcda..220bb27 100644 --- a/crates/core/src/addon/pack/format.rs +++ b/crates/core/src/addon/pack/format.rs @@ -133,47 +133,68 @@ 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( + 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)] pub struct PackMatch { diff --git a/crates/core/src/addon/pack/format/write.rs b/crates/core/src/addon/pack/format/write.rs index 5f637cf..68e73cb 100644 --- a/crates/core/src/addon/pack/format/write.rs +++ b/crates/core/src/addon/pack/format/write.rs @@ -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, diff --git a/crates/core/src/addon/pack/format/write/tests.rs b/crates/core/src/addon/pack/format/write/tests.rs index e705e1f..2ef4b19 100644 --- a/crates/core/src/addon/pack/format/write/tests.rs +++ b/crates/core/src/addon/pack/format/write/tests.rs @@ -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 必须拒"); +} diff --git a/crates/core/src/addon/pack/tests/limits.rs b/crates/core/src/addon/pack/tests/limits.rs index 99cbca6..c2d77e9 100644 --- a/crates/core/src/addon/pack/tests/limits.rs +++ b/crates/core/src/addon/pack/tests/limits.rs @@ -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) -> 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"))); @@ -129,26 +143,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 +184,71 @@ 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] @@ -252,5 +277,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); } diff --git a/crates/core/src/addon/pack/tests/mod.rs b/crates/core/src/addon/pack/tests/mod.rs index d85d1ba..8de9bb5 100644 --- a/crates/core/src/addon/pack/tests/mod.rs +++ b/crates/core/src/addon/pack/tests/mod.rs @@ -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); diff --git a/crates/core/src/addon/pack/tests/zip64.rs b/crates/core/src/addon/pack/tests/zip64.rs index 6bcf3ae..6d3069d 100644 --- a/crates/core/src/addon/pack/tests/zip64.rs +++ b/crates/core/src/addon/pack/tests/zip64.rs @@ -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) -> 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,106 @@ fn leftovers(root: &Path, keep: &[&str]) -> Vec { #[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::::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::::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::::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 +201,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); -} diff --git a/crates/core/src/addon/temp.rs b/crates/core/src/addon/temp.rs index eecb334..544b900 100644 --- a/crates/core/src/addon/temp.rs +++ b/crates/core/src/addon/temp.rs @@ -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` / From 9b23221a62d3081ecb8fdd4d21c6b933715ddc70 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 8 Nov 2026 09:07:01 +0800 Subject: [PATCH 2/2] =?UTF-8?q?test(m166):=20=E9=92=89=E4=BD=8F=E7=94=9F?= =?UTF-8?q?=E4=BA=A7=20write()=20=E7=9A=84=E9=99=90=E9=A2=9D=E7=BB=91?= =?UTF-8?q?=E5=AE=9A=EF=BC=88=E5=A4=8D=E5=AE=A1=20r1=20p2-1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复审 r1 的变异:把 `format.rs` 里 `write()` 传给 `write_with_limits` 的 `WriteLimits::production()` 换成 `WriteLimits::new(u64::MAX, u64::MAX, entries)`, **606 条全绿**。原因是夹具缩到 KiB 之后,"生产路径 + 真超限内容"那种顺带守住生产 限额的证法没了,而 `the_production_limits_are_the_shared_constants` 只钉了 `production()` 的**返回值**、没钉 `write()` 的**调用点**。 补法(不动生产签名、不进发布构建): - 新增 `format/probe.rs`(`#[cfg(test)]`):线程局部的限额记录钩子; `write_with_limits` 进门时 `#[cfg(test)] probe::record(limits);` 记一笔。 - `tests/limits.rs::the_production_write_path_hands_the_shared_constants_to_the_preflight` 走**生产**的 `CURSEFORGE.write`(KiB 级 payload)后断言记到的就是 `WriteLimits::production()` 的逐字段值,并再钉一遍统一常量。 - 反向验:上述变异现在恰好 1 条红(`总量限额:生产路径传错了`,2026-11-08 实测 18446744073709551615 vs 536870912);只把 entry 换成 u64::MAX 也红(`单条目限额`)。 附带(复审非阻断观察): - 观察 2 采纳:新增 `zip64::the_gate_runs_before_any_content_is_opened`,把"闸在读之前" 从结构证明升级成行为证明——源做成**目录**(`metadata()` 有长度、`File::open` 拒绝访问, 本机实测 C:\Windows 20480 字节 / crates/core/src 4096 字节),闸若挪到读之后会拿到 `pack_io` 而不是 `pack_limits`。 - 观察 1 采纳:`tests/limits.rs` 里"改坏常量会在 cargo check 就红"的旧注释改成准确说法 (E0080 只在测试构建出现,裸 `cargo check` 是绿的)。 - `probe.rs` 里 `#![allow(clippy::missing_const_for_thread_local)]`:clippy 1.99 对已经 写成 `const { Cell::new(None) }` 的 `thread_local!` 仍报错(最小复现同样红),是误报; 外挂在宏上的 `#[allow]` 会被判 unused attribute,故用模块级 inner attribute。 cargo test -p unlucky-core --lib:608 passed / 0 failed / 0 ignored(+2 条); clippy --all-targets -D warnings、fmt --check 干净;全仓 0 个 >400 行文件。 --- crates/core/src/addon/pack/format.rs | 10 +++++ crates/core/src/addon/pack/format/probe.rs | 44 ++++++++++++++++++++++ crates/core/src/addon/pack/tests/limits.rs | 42 ++++++++++++++++++++- crates/core/src/addon/pack/tests/zip64.rs | 39 +++++++++++++++++++ 4 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 crates/core/src/addon/pack/format/probe.rs diff --git a/crates/core/src/addon/pack/format.rs b/crates/core/src/addon/pack/format.rs index 220bb27..534c4d8 100644 --- a/crates/core/src/addon/pack/format.rs +++ b/crates/core/src/addon/pack/format.rs @@ -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`] 按 /// `//` 取文件、[`PackPayload::with_sources`] 按**包内条目名**查表 @@ -160,6 +165,11 @@ pub(in crate::addon::pack) fn write_with_limits( 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!( "格式 {}({})只支持读取,不支持导出", diff --git a/crates/core/src/addon/pack/format/probe.rs b/crates/core/src/addon/pack/format/probe.rs new file mode 100644 index 0000000..f7e2a3d --- /dev/null +++ b/crates/core/src/addon/pack/format/probe.rs @@ -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> = 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> = 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 { + LAST_LIMITS.with(Cell::get) +} diff --git a/crates/core/src/addon/pack/tests/limits.rs b/crates/core/src/addon/pack/tests/limits.rs index c2d77e9..ee5d7ae 100644 --- a/crates/core/src/addon/pack/tests/limits.rs +++ b/crates/core/src/addon/pack/tests/limits.rs @@ -120,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, "单条上限必须 ≤ 总量上限(否则单条规则永远够不着)" @@ -251,6 +254,43 @@ fn the_write_side_refuses_a_total_over_the_unified_total_limit() { assert!(!target.exists()); } +#[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] fn a_real_pack_written_under_the_limit_reopens_under_the_read_limits() { // 端到端的那句话:"我们能写出的包一定能被自己读回来"。用一份正常尺寸的覆盖内容跑一遍。 diff --git a/crates/core/src/addon/pack/tests/zip64.rs b/crates/core/src/addon/pack/tests/zip64.rs index 6d3069d..3b61167 100644 --- a/crates/core/src/addon/pack/tests/zip64.rs +++ b/crates/core/src/addon/pack/tests/zip64.rs @@ -183,6 +183,45 @@ fn a_single_entry_over_the_entry_limit_is_refused_before_a_single_byte_is_read() assert_eq!(leftovers(&root, &["overrides"]), Vec::::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::::new()); +} + #[test] fn an_export_under_the_threshold_still_writes_a_normal_pack() { // 对照:不超阈值时预检不许误伤(否则这条护栏就变成"导出全挂")。