From 72767067f3bc308a218f4414c0d7218db7d6c754 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 8 Nov 2026 09:54:31 +0800 Subject: [PATCH 1/2] =?UTF-8?q?test(m170):=20=E9=97=B8=E9=A1=BA=E5=BA=8F?= =?UTF-8?q?=E7=94=A8=E4=BE=8B=E5=9C=A8=20Windows=20=E4=B8=8A=E4=B9=9F?= =?UTF-8?q?=E7=9C=9F=E8=B7=91=EF=BC=88=E5=A3=B0=E6=98=8E=E9=95=BF=E5=BA=A6?= =?UTF-8?q?=E8=87=AA=E5=B7=B1=E9=80=A0=20+=20=E5=8F=8D=E8=AF=81=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M166 r2 复审的两条「合并后收口」观察: ① `zip64::the_gate_runs_before_any_content_is_opened` 原来量夹具目录的 `metadata().len()`,量到 0 就 `return`——而本机 NTFS 上空目录就是 0(实测:空 0、 第 7 个条目起 4096),于是这条「行为证明」在 Windows 上一条断言都没执行。 现在:先把目录填到长度非零(32 个 filler),拿「声明长度减一字节」当总量上限, 前提不满足就 `assert!`;单条目上限给 `u64::MAX`,不让 `guard_entry` 当替身。 另加一条**反证**:同一份夹具把限额放到 `u64::MAX`,必须死在 `File::open`/`read` 上拿到 `pack_io`——于是上面拿到 `pack_limits` 只能解释为「闸在碰内容之前」。 同时更正 `one_file_over_the_threshold_...` 里那句过头的注释:`leftovers` / `!target.exists()` 在任何失败路径上都成立,属结构侧检,不是「没读一个字节」的证明。 ② `probe.rs` 的模块级 `#![allow(clippy::missing_const_for_thread_local)]` 收窄到 `thread_local!` 内那个 `static` 上(A/B 实测:没带的那组只报一条 lint,带了就干净, 且不判 unused),模块级 allow 不再替未来的真漏 const 打掩护。 变异验证(本机 Windows/NTFS,跑完已还原): - 预检挪到 `File::open` 之后 → 本用例红(`left: Io` / `right: Limits`),改前那版全绿; - 预检改成用 `std::fs::read` 量尺寸 → 同样红; - 只留结构断言(拆掉 kind/文案/反证)→ 同一变异下**全绿**,证明它们确实区分不了顺序。 --- crates/core/src/addon/pack/format/probe.rs | 13 +-- crates/core/src/addon/pack/tests/zip64.rs | 101 ++++++++++++++++++--- 2 files changed, 95 insertions(+), 19 deletions(-) diff --git a/crates/core/src/addon/pack/format/probe.rs b/crates/core/src/addon/pack/format/probe.rs index f7e2a3d..3c08e22 100644 --- a/crates/core/src/addon/pack/format/probe.rs +++ b/crates/core/src/addon/pack/format/probe.rs @@ -18,18 +18,19 @@ //! 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`] 收到的限额。 + /// + /// `#[allow]` 贴在**这个 `static`** 上(M170):clippy 1.99 的 + /// `missing_const_for_thread_local` 在初始化**已经是** `const { … }` 的写法上仍然报误报 + /// (最小复现 `static A: Cell> = const { Cell::new(None) };` 一样红), + /// 所以要压掉。放在这里是最窄的作用域;反过来,外挂在 `thread_local!` **本身**上的 + /// `#[allow]` 才是无效的(被宏展开吞掉,报成 unused attribute)。 + #[allow(clippy::missing_const_for_thread_local)] static LAST_LIMITS: Cell> = const { Cell::new(None) }; } diff --git a/crates/core/src/addon/pack/tests/zip64.rs b/crates/core/src/addon/pack/tests/zip64.rs index 3b61167..b46e87e 100644 --- a/crates/core/src/addon/pack/tests/zip64.rs +++ b/crates/core/src/addon/pack/tests/zip64.rs @@ -25,7 +25,16 @@ //! 同一条预检分支(`write::preflight` → `guard_with` / `guard_entry`),一条用例都不 `#[ignore]`、 //! 一条断言都没删。**GiB 级的数值判定**(>2 GiB 与 >4 GiB)搬到了 `format/write/tests.rs`: //! 那两道闸吃的是 `u64`,本来就不需要落盘。 +//! +//! # 「声明长度」必须自己造出来(M170) +//! +//! M166 的 `the_gate_runs_before_any_content_is_opened` 把源做成目录、量 `metadata().len()`, +//! 量到 0 就 `return`。而**本机 NTFS 上空目录的长度就是 0**(实测:空目录 0,第 7 个条目起 +//! 4096;ext4 空目录本来就报 4096)——所以那条"行为证明"在 Windows 上一条断言都没执行, +//! 只有 CI 的 ubuntu 那侧真跑。现在改成:先把目录填到长度非零,拿**这个长度减一字节**当 +//! 总量上限,并且前提不满足时 `assert!` 而不是 `return`——要么真判过东西,要么在这台机器上红。 +use std::io::Read; use std::path::Path; use super::write_file; @@ -42,6 +51,11 @@ const SMALL_TOTAL: u64 = 8 * 1024; const SMALL_ENTRY: u64 = 2 * 1024; const SMALL_ENTRIES: usize = 16; +/// 把目录填充到「`metadata().len()` 非零」要几个条目(本机 NTFS 实测第 7 个起报 4096、 +/// ext4 空目录就报 4096)。取 32 而不是临界值:不多花一个字节,也不卡在文件系统的条数 +/// 阈值上(见 `the_gate_runs_before_any_content_is_opened`)。 +const DIR_LENGTH_FILLERS: usize = 32; + /// 上面那三个数组成的限额(`WriteLimits::new` 是 `#[cfg(test)]` 的测试入口)。 fn small_limits() -> WriteLimits { WriteLimits::new(SMALL_TOTAL, SMALL_ENTRY, SMALL_ENTRIES) @@ -120,8 +134,11 @@ fn one_file_over_the_threshold_is_refused_before_a_single_byte_is_read() { "要给「把大文件移出实例」这种可执行的提示:{error:?}" ); assert!(!target.exists(), "失败后目标路径不许存在"); - // 临时文件也不许留:`container` 的顺序是 预检 → `File::create(temp)` → 读源文件, - // 所以"目录里只有源文件"证明**一个字节的内容都没被打开过**。 + // 临时文件也不许留。**但这条不是"一个字节都没读"的证明**(M170 更正):失败分支自己会 + // `remove_file(&temp)`,所以它在任何失败路径上都成立,等于"确实返回了 Err"的同义反复。 + // 真有牙的判据是上面的 `kind()`:闸若被挪到读内容之后,源文件能读时这里拿到的是 + // `pack_io`。顺序本身由 `the_gate_runs_before_any_content_is_opened` 用"读不出来的源" + // 正面证明(含反证)。 assert_eq!(leftovers(&root, &["overrides"]), Vec::::new()); } @@ -185,22 +202,44 @@ fn a_single_entry_over_the_entry_limit_is_refused_before_a_single_byte_is_read() #[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` —— 比只看"临时文件没被创建"更硬。 + // **行为**证明(复审 r1 的观察 2):源做成**目录**——`metadata()` 拿得到长度,把它当内容 + // 读却读不出来(Windows NTFS:`File::open` 直接"拒绝访问";Linux:`open` 成功但 `read` + // 报 EISDIR)。于是闸若被挪到"读内容"那一段,这里拿到的会是 `pack_io` 而不是 + // `pack_limits`——比只看"临时文件没被创建"硬,因为后者在任何失败路径上都成立。 + // + // 但"硬"的前提是**真有声明长度可判**:见下面的两块 `assert!`(M170)。 let root = TempRoot::new("zip64-before-read"); let directory = root.join("overrides/mods/not-a-file"); std::fs::create_dir_all(&directory).expect("建目录"); + // 前提①:长度得**自己造**。NTFS 上空目录报 0(本机实测:空 0、第 7 个条目起 4096), + // 直接量空目录会得到 0 —— r2 版本就是在这里 `return` 的,于是 Windows 上一条断言都没跑。 + for index in 0..DIR_LENGTH_FILLERS { + write_file(&directory.join(format!("filler-{index:02}")), b"x"); + } let declared = std::fs::metadata(&directory) .expect("stat 目录(这一步必须成功,否则区分不了两道闸)") .len(); - if declared == 0 { - // 少数文件系统把目录长度报成 0:这条行为断言在那上面无从构造。此时覆盖仍在 - // (`leftovers` / `!target.exists()` 的结构证明不受影响)。 - return; - } - // 上限比目录的**声明长度**小一字节:总量闸必然先命中,且内容一个字节都不可能读成功。 + assert!( + declared > 0, + "前提:目录的声明长度必须非零才有判据可造;{DIR_LENGTH_FILLERS} 个条目之后仍报 0,\ + 说明该文件系统不报目录长度,这条用例在那里无法证明闸的顺序(**不许静默 return**)" + ); + + // 前提②:这条源**确实**读不出来,否则 `pack_limits` / `pack_io` 区分不了闸的位置。 + // 走的就是写循环那一对调用(`File::open` → `read`),不满足照样 `assert!` 而不是跳过。 + let consumed = std::fs::File::open(&directory).and_then(|mut file| { + let mut byte = [0_u8; 1]; + file.read(&mut byte).map(|_| ()) + }); + assert!( + consumed.is_err(), + "前提:目录源必须读不出来,否则拿到 pack_limits 也说明不了闸在内容之前:{consumed:?}" + ); + + // 总量上限 = 目录的**声明长度减一字节**:声明尺寸自己就越线(`guard_with` 是 `>=`), + // 所以拒写的理由是这条覆盖条目的声明尺寸,而不是清单 JSON 顺带越线。 + // 单条目上限给 `u64::MAX`:不让 `guard_entry` 当替身——如果总量闸漏算了覆盖条目的声明 + // 尺寸,这里就不会拿到 `pack_limits`,而是一路走到读内容上拿到 `pack_io`(用例变红)。 let limit = declared - 1; let target = root.join("out.zip"); @@ -209,7 +248,7 @@ fn the_gate_runs_before_any_content_is_opened() { &manifest_of(vec![override_file("mods/not-a-file")]), PackPayload::from_root(&root), &target, - WriteLimits::new(limit, limit, SMALL_ENTRIES), + WriteLimits::new(limit, u64::MAX, SMALL_ENTRIES), ) .expect_err("声明尺寸超限必须拒"); @@ -218,6 +257,42 @@ fn the_gate_runs_before_any_content_is_opened() { PackErrorKind::Limits, "闸必须在打开内容**之前**:拿到 pack_io 说明它被挪到读之后了:{error}" ); + assert!( + error.message().contains("总量") && error.message().contains(&limit.to_string()), + "拒写理由必须是**总量闸**、且报出在用的上限({limit}):{error}" + ); + assert!( + !error.message().contains("读覆盖文件"), + "文案是「读覆盖文件失败」的口径 ⇒ 闸已经在碰内容了:{error}" + ); + + // # 反证:同一个夹具,闸一放行就必定死在"读内容"上 + // + // 这一步是"上面那个 `kind` 断言不是同义反复"的证据:把限额放到远大于声明尺寸,同一个 + // 目录源会在 `File::open` / `read` 上炸成 `pack_io`。于是上面拿到 `pack_limits` 只有 + // 一种解释——闸在碰内容**之前**就拒了。(而 `!target.exists()` 与"目录里没 `.part`" + // 这两条:失败分支会 `remove_file(temp)`、只有成功分支才 `rename`,所以它们在任何失败 + // 路径上都成立,属于**结构侧检**、不是判据。r2 曾把后者当成"一个字节都没读"的证明, + // 那才是同义反复。) + let error = write_with_limits( + &CURSEFORGE, + &manifest_of(vec![override_file("mods/not-a-file")]), + PackPayload::from_root(&root), + &root.join("out-loose.zip"), + WriteLimits::new(u64::MAX, u64::MAX, SMALL_ENTRIES), + ) + .expect_err("目录源读不出来:闸放行就只能死在读内容上"); + assert_eq!( + error.kind(), + PackErrorKind::Io, + "反证不成立:闸放行后没有死在读内容上,那上面那个 kind 断言就区分不了闸的位置:{error}" + ); + assert!(error.message().contains("读覆盖文件"), "{error}"); + + // 结构侧检(**不是判据**):两次调用到这里都没留下 `out.zip` / `out-loose.zip` / `.part`。 + // 这两条在任何失败路径上都成立(失败分支自己 `remove_file(temp)`,只有成功分支才 + // `rename`),所以它们只值"确实返回了 Err"的同义反复——上面那条反证把真正有牙的判据 + // (`kind()` + 文案)与它们区分开。 assert!(!target.exists(), "预检失败时目标路径不许出现"); assert_eq!(leftovers(&root, &["overrides"]), Vec::::new()); } From add7f2e67242aeec7cbd4cda4086cbb55d5d9bce Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 8 Nov 2026 10:39:28 +0800 Subject: [PATCH 2/2] =?UTF-8?q?test(m170):=20=E6=94=B9=E5=AF=B9=E5=A4=8D?= =?UTF-8?q?=E5=AE=A1=E7=82=B9=E5=87=BA=E7=9A=84=E4=B8=A4=E5=A4=84=E8=AF=81?= =?UTF-8?q?=E6=8D=AE=E9=99=88=E8=BF=B0=20+=20=E6=94=B6=E7=B4=A7=E7=94=A8?= =?UTF-8?q?=E4=BE=8B=E5=89=8D=E6=8F=90=EF=BC=88=E9=9B=B6=E8=A1=8C=E4=B8=BA?= =?UTF-8?q?=E6=94=B9=E5=8A=A8=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit r1 复审 p2-2items(守卫本身判 clean,四条变异自跑确认有牙)。这一轮**只动注释与测试 前提,产品行为零变化**:`format/write.rs` 一字未改,`probe.rs` 保持上一提交的收窄结果。 p2-1 复审核正的事实:`one_file_over_the_threshold_...` 的 `kind()` **不区分闸的位置**。 它的源是**可读的** 8 KiB 真文件,闸被挪到 `File::open` 之后时写循环第二道闸 `Written::spend`(每块之后 `guard_with`)照样返回 `Limits`——所以它在「闸挪位置」的 变异下仍然绿(我这轮重跑变异 A 也复现:其余 4 条全绿)。原来那句「源文件能读时这里 拿到的是 pack_io」是错的,改成「可读源命中第二道闸」并把顺序的举证责任明确交给 `the_gate_runs_before_any_content_is_opened`。 p2-2 目录长度的实测数字之前钉错了:我早先那条探针用的是**短名**(`f0`..`f39`,7 条 才到 4096、40 条仍 4096),而用例里填的是 `filler-NN`。这轮按用例真实形状重测三组 (`std::fs::metadata`,同一台机器同一 `%TEMP%`): 短名 `f0`…`f39`:空 0 | 3 条 0 | 7 条 4096 | 20/32/39/40 条 4096 `filler-NN`: 空 0 | 3 条 4096 | 7 条 4096 | 20 条 8192 | 32 条 8192 | 39 条 12288 | 40 条 16384 两次独立跑同形状结果一致。所以「第几个条目起非零」没有平台无关答案(NTFS 目录索引按 4 KiB 一档分配,跟条目名长度与条数都有关),三处注释改成区间 + 一张实测表,并注明 「只钉 32 个 `filler-NN` 这个形状(本机 8192)」,不再硬凑一个好看的数字。 顺带做掉复审那条「不计入 p2 但建议收紧」:前提从 `declared > 0` 提到 **`limit > 清单 JSON 长度`**(`limit = declared.saturating_sub(1)`,`json_len` 由 `CURSEFORGE.render(&manifest)` 现算,本用例实测 220 字节)。否则某个对 32 个条目报得很 小的文件系统会让用例因「清单自己越线」而假绿。同时把 `declared - 1` 换成 `saturating_sub`,让长度为 0 时走到清楚的 assert 文案而不是整数下溢 panic。 把 `DIR_LENGTH_FILLERS` 临时改成 0 验证过这条 assert 是活的:红在这句上 (`声明长度 0 − 1 = 0 … 必须大于清单 JSON 自身长度(220)`)。 变异复跑(本机 Windows/NTFS,跑完已还原 write.rs): - A:`preflight` 挪到 `File::open(&source)` 之后 → 新用例**红**(left Io / right Limits), 其余 4 条仍绿(含上面那条可读源用例,正好印证 p2-1 的更正); - C:A 之上只留 `expect_err` / `!target.exists()` / `leftovers` → **5 条全绿**, 结构断言确属同义反复。 门禁:`cargo test -p unlucky-core --lib` 613 passed / 0 failed / 0 ignored; `cargo fmt --all --check` 干净;`cargo clippy -p unlucky-core --all-targets -- -D warnings` 干净;zip64.rs 345 行(<400)、probe.rs 45 行。 --- crates/core/src/addon/pack/tests/zip64.rs | 64 ++++++++++++++++------- 1 file changed, 46 insertions(+), 18 deletions(-) diff --git a/crates/core/src/addon/pack/tests/zip64.rs b/crates/core/src/addon/pack/tests/zip64.rs index b46e87e..af8dff8 100644 --- a/crates/core/src/addon/pack/tests/zip64.rs +++ b/crates/core/src/addon/pack/tests/zip64.rs @@ -29,10 +29,11 @@ //! # 「声明长度」必须自己造出来(M170) //! //! M166 的 `the_gate_runs_before_any_content_is_opened` 把源做成目录、量 `metadata().len()`, -//! 量到 0 就 `return`。而**本机 NTFS 上空目录的长度就是 0**(实测:空目录 0,第 7 个条目起 -//! 4096;ext4 空目录本来就报 4096)——所以那条"行为证明"在 Windows 上一条断言都没执行, -//! 只有 CI 的 ubuntu 那侧真跑。现在改成:先把目录填到长度非零,拿**这个长度减一字节**当 -//! 总量上限,并且前提不满足时 `assert!` 而不是 `return`——要么真判过东西,要么在这台机器上红。 +//! 量到 0 就 `return`。而**本机 NTFS 上空目录的长度就是 0**(实测:空目录 0;条目塞进去之后 +//! 才变成 4–16 KiB 量级,见 [`DIR_LENGTH_FILLERS`] 的注释;ext4 空目录直接报 4096)——所以 +//! 那条"行为证明"在 Windows 上一条断言都没执行,只有 CI 的 ubuntu 那侧真跑。现在改成:先把 +//! 目录填到长度**大于清单 JSON 自身**,拿**这个长度减一字节**当总量上限,并且前提不满足时 +//! `assert!` 而不是 `return`——要么真判过东西,要么在这台机器上红。 use std::io::Read; use std::path::Path; @@ -51,9 +52,20 @@ const SMALL_TOTAL: u64 = 8 * 1024; const SMALL_ENTRY: u64 = 2 * 1024; const SMALL_ENTRIES: usize = 16; -/// 把目录填充到「`metadata().len()` 非零」要几个条目(本机 NTFS 实测第 7 个起报 4096、 -/// ext4 空目录就报 4096)。取 32 而不是临界值:不多花一个字节,也不卡在文件系统的条数 -/// 阈值上(见 `the_gate_runs_before_any_content_is_opened`)。 +/// 把目录填充到「`metadata().len()` 大到能当判据」用几个条目。 +/// +/// **这个数字不是阈值,是区间里挑的一个稳的点**:NTFS 的目录长度是索引分配的字节数,按 4 KiB +/// 一档往上加,**跟条目名的长度和条目数都有关**——本机实测(`std::fs::metadata`,同一台机器、 +/// 同一个 `%TEMP%`): +/// +/// | 条目名 | 空 | 3 条 | 7 条 | 20 条 | 32 条 | 39 条 | 40 条 | +/// | --- | --- | --- | --- | --- | --- | --- | --- | +/// | `f0`…`f39`(短名) | 0 | 0 | 4096 | 4096 | 4096 | 4096 | 4096 | +/// | `filler-NN`(本用例的形状) | 0 | 4096 | 4096 | 8192 | **8192** | 12288 | 16384 | +/// +/// 所以"第几个条目起非零"没有平台无关的答案,别在注释里钉死一个漂亮的数字:这里只钉 +/// **32 个 `filler-NN` 条目**这个形状(本机 8192),其余文件系统(ext4 空目录 4096、tmpfs +/// 680 量级)由下面那条 `limit > 清单 JSON 长度` 的 `assert!` 现场把关——不满足就用例红。 const DIR_LENGTH_FILLERS: usize = 32; /// 上面那三个数组成的限额(`WriteLimits::new` 是 `#[cfg(test)]` 的测试入口)。 @@ -136,9 +148,11 @@ fn one_file_over_the_threshold_is_refused_before_a_single_byte_is_read() { assert!(!target.exists(), "失败后目标路径不许存在"); // 临时文件也不许留。**但这条不是"一个字节都没读"的证明**(M170 更正):失败分支自己会 // `remove_file(&temp)`,所以它在任何失败路径上都成立,等于"确实返回了 Err"的同义反复。 - // 真有牙的判据是上面的 `kind()`:闸若被挪到读内容之后,源文件能读时这里拿到的是 - // `pack_io`。顺序本身由 `the_gate_runs_before_any_content_is_opened` 用"读不出来的源" - // 正面证明(含反证)。 + // 上面的 `kind()` 也**不区分闸的位置**:这条用例的源是**可读的** 8 KiB 真文件,闸被挪到 + // `File::open` 之后时,写循环的第二道闸 `Written::spend`(`write.rs` 每块之后 + // `guard_with`)照样返回 `Limits`——所以本用例在"闸挪位置"的变异下仍然绿(复审 r1 实测, + // 我自己的变异 A 表里"其余 4 条仍绿"就是同一件事)。**顺序只由 + // `the_gate_runs_before_any_content_is_opened` 用"读不出来的源"证明**(含常驻反证)。 assert_eq!(leftovers(&root, &["overrides"]), Vec::::new()); } @@ -211,7 +225,8 @@ fn the_gate_runs_before_any_content_is_opened() { let root = TempRoot::new("zip64-before-read"); let directory = root.join("overrides/mods/not-a-file"); std::fs::create_dir_all(&directory).expect("建目录"); - // 前提①:长度得**自己造**。NTFS 上空目录报 0(本机实测:空 0、第 7 个条目起 4096), + // 长度得**自己造**:NTFS 上空目录报 0(本机实测:空 0;塞条目之后按 4 KiB 一档 + // 往上跳,`filler-NN` 形状 3 条 → 4096、32 条 → 8192,详见 `DIR_LENGTH_FILLERS`), // 直接量空目录会得到 0 —— r2 版本就是在这里 `return` 的,于是 Windows 上一条断言都没跑。 for index in 0..DIR_LENGTH_FILLERS { write_file(&directory.join(format!("filler-{index:02}")), b"x"); @@ -219,10 +234,26 @@ fn the_gate_runs_before_any_content_is_opened() { let declared = std::fs::metadata(&directory) .expect("stat 目录(这一步必须成功,否则区分不了两道闸)") .len(); + let manifest = manifest_of(vec![override_file("mods/not-a-file")]); + let json_len = CURSEFORGE + .render(&manifest) + .expect("渲染清单(只是为了量它多长)") + .len() as u64; + // 上限 = 声明长度减一字节(`guard_with` 是 `>=`,声明尺寸自己就越线)。用 + // `saturating_sub` 而不是 `-`:目录长度报 0 时要走到下一条 `assert!` 的清楚信息, + // 而不是一个整数下溢的 panic。 + let limit = declared.saturating_sub(1); + // 前提①(r1 复审建议的收紧):上限必须**大于清单 JSON 自身长度**。`total` 是 + // `清单 + 声明尺寸`,若上限比清单还小,清单自己就能把 `total` 顶过线——用例照样绿, + // 却悄悄失去对「总量闸漏算了覆盖条目的声明尺寸」那类洞的判别力(复审的变异 E)。 + // 本用例这份 CurseForge 清单渲染出来实测 **220 字节**(复审用另一份清单量到约 165,差别 + // 来自清单字段);ext4(4095)、tmpfs(679)、本机 NTFS(8191)都远在它之上。对 32 个 + // 条目报不到 220 字节的文件系统会让这条用例红——那是对的,它在那里证明不了声明尺寸承重 + // (把 `DIR_LENGTH_FILLERS` 临时改成 0 就能看到这句 assert 的话:`声明长度 0 − 1 = 0`)。 assert!( - declared > 0, - "前提:目录的声明长度必须非零才有判据可造;{DIR_LENGTH_FILLERS} 个条目之后仍报 0,\ - 说明该文件系统不报目录长度,这条用例在那里无法证明闸的顺序(**不许静默 return**)" + limit > json_len, + "前提:总量上限(目录声明长度 {declared} − 1 = {limit})必须大于清单 JSON 自身长度\ + ({json_len}),否则拒写可能只是清单越线,声明尺寸没被证明承重(**不许静默 return**)" ); // 前提②:这条源**确实**读不出来,否则 `pack_limits` / `pack_io` 区分不了闸的位置。 @@ -236,16 +267,13 @@ fn the_gate_runs_before_any_content_is_opened() { "前提:目录源必须读不出来,否则拿到 pack_limits 也说明不了闸在内容之前:{consumed:?}" ); - // 总量上限 = 目录的**声明长度减一字节**:声明尺寸自己就越线(`guard_with` 是 `>=`), - // 所以拒写的理由是这条覆盖条目的声明尺寸,而不是清单 JSON 顺带越线。 // 单条目上限给 `u64::MAX`:不让 `guard_entry` 当替身——如果总量闸漏算了覆盖条目的声明 // 尺寸,这里就不会拿到 `pack_limits`,而是一路走到读内容上拿到 `pack_io`(用例变红)。 - 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")]), + &manifest, PackPayload::from_root(&root), &target, WriteLimits::new(limit, u64::MAX, SMALL_ENTRIES),