From be280c16a3332722b6b5846cea77e2f59ec07ea4 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sat, 7 Nov 2026 08:22:42 +0800 Subject: [PATCH] =?UTF-8?q?fix(core):=20ZIP64=20=E7=AC=AC=E4=BA=8C?= =?UTF-8?q?=E9=81=93=E9=97=B8=E4=B8=8D=E5=86=8D=E9=87=8D=E5=A4=8D=E7=B4=AF?= =?UTF-8?q?=E8=AE=A1=E9=A2=84=E6=A3=80=E6=80=BB=E9=87=8F=EF=BC=88=E5=A4=8D?= =?UTF-8?q?=E5=AE=A1=20P2-1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复审在 tip 54c2d75 上实证的缺陷:`container()` 把 `preflight()` 返回的**全量**声明尺寸 当成写循环累加基数,循环里又对每个文件 `+= bytes.len()`,于是真实总量只要过半阈值 (~2.15 GiB)就被误拒,而且拒在内容读出来之后——"闸在内容进内存之前"的性质同时失效。 两处修: 1. 记账拆成独立类型 `Written`,**从清单 JSON 长度起算**,与预检返回值无关;预检的返回值 现在只给测试与诊断看(`let _ =`,文档写明不许当基数)。 2. 内容改成 1 MiB 缓冲流式写进 zip(`COPY_CHUNK`),不再 `std::fs::read` 整份摊在内存里。 第二道闸看的是"已写字节数",与内存占用解耦;顺带把"拿预检 total 当基数"这类写法 在内存侧也变成无害的小错而不是几个 GiB 的读。 测试(按塔台资源令,默认套件不再真写 2.5 GB): - `write.rs` 新增 `the_container_does_not_double_count_the_preflight_total`:把阈值注入成 几十字节(`container`/`preflight`/`Written` 都收 `limit`,生产路径恒为 `zip::ZIP64_BYTES_THR`),构造"正确实现 70 < 90 通过 / 重复累计 110 ≥ 90 被拒"的窗口, **0.01 秒**钉住 P2-1;实测把 bug 写回去即红。 - `write.rs` 新增 `the_second_gate_counts_from_the_manifest_length_not_the_preflight_total`: 直接对 `Written` 断言 2.5 GB 不拒、撞阈值必拒。 - `zip64.rs` 的 2.5 GB 稀疏文件端到端用例保留但 `#[ignore]`(真跑要 ~70 秒 deflate 与一大片 内存/页面文件,C 盘页面文件已被上一轮撑到 34.8G),实测过一次成功:条目声明尺寸原样 2 500 000 000、产物只有几 MB。跑法写在该用例的文档里。 门禁:fmt 0 / clippy -p unlucky-core --all-targets 0 / cargo test -p unlucky-core 1033 passed 0 failed(lib 480 passed + 1 ignored)。 --- crates/core/src/addon/pack/format.rs | 10 +- crates/core/src/addon/pack/format/write.rs | 181 ++++++++++++++++++--- crates/core/src/addon/pack/tests/zip64.rs | 51 ++++++ 3 files changed, 221 insertions(+), 21 deletions(-) diff --git a/crates/core/src/addon/pack/format.rs b/crates/core/src/addon/pack/format.rs index e378b79..a4a1e47 100644 --- a/crates/core/src/addon/pack/format.rs +++ b/crates/core/src/addon/pack/format.rs @@ -148,7 +148,15 @@ pub trait PackFormat: Debug + Send + Sync { let json = self.render(manifest)?; // 临时文件 + rename:中途失败时目标路径上不会留下"看起来能用"的半个 zip(r1 p3-1)。 let temp = write::temporary_path(target); - match write::container(self.id(), entry, &json, manifest, payload, &temp) { + match write::container( + self.id(), + entry, + &json, + manifest, + payload, + &temp, + zip::ZIP64_BYTES_THR, + ) { Ok(()) => std::fs::rename(&temp, target).map_err(|error| { let _ = std::fs::remove_file(&temp); PackError::io(format!( diff --git a/crates/core/src/addon/pack/format/write.rs b/crates/core/src/addon/pack/format/write.rs index a960bfd..a19d5dc 100644 --- a/crates/core/src/addon/pack/format/write.rs +++ b/crates/core/src/addon/pack/format/write.rs @@ -11,6 +11,15 @@ //! (`preflight`),超阈值在任何内容被读进内存**之前**就报 `pack_limits`。 //! //! 写循环里那份增量检查保留,当第二道:预检与写入之间文件可能变大(实例目录不是快照)。 +//! **第二道自己从清单长度起算**,绝不拿预检的返回值当基数——两者合起来会重复累计, +//! 于是真实总量只要过半阈值(~2.15 GiB)就被误拒,而且拒在内容已经读出来之后(P2-1)。 +//! +//! # 内容按块搬运,不整份读进内存 +//! +//! 覆盖文件用 1 MiB 缓冲流式写进 zip(`COPY_CHUNK`)。这不是优化癖:`std::fs::read` 会把 +//! 一份 2.5 GiB 的 mod 整份摊在内存里,恰恰破坏"闸在内容进内存之前"这条性质——即使判据 +//! 后来修对了,误拒也发生在把几个 GiB 读进内存之后。流式之后第二道闸看的是**已写字节数**, +//! 与内存占用无关。 //! //! # 阈值与声明口径 //! @@ -19,7 +28,7 @@ //! 官方实现同样按这个粗粒度判。 use std::fs::File; -use std::io::Write; +use std::io::{Read, Write}; use std::path::{Path, PathBuf}; use super::super::error::{PackError, PackResult}; @@ -27,6 +36,9 @@ use super::super::manifest::PackManifest; use super::super::path::{normalize_dir, normalize_file}; use super::payload::PackPayload; +/// 搬覆盖内容时的缓冲大小(流式,不把文件整份读进内存)。 +const COPY_CHUNK: usize = 1024 * 1024; + /// 与目标同目录的临时文件路径(同卷 `rename` 才能原子替换)。 pub(super) fn temporary_path(target: &Path) -> PathBuf { let mut name = target.file_name().map_or_else( @@ -45,9 +57,12 @@ pub(super) fn container( manifest: &PackManifest, payload: PackPayload<'_>, temp: &Path, + limit: u64, ) -> PackResult<()> { // 先判总量再碰磁盘:超限时临时文件都不建,更不会读进一份 4 GiB 的内容。 - let mut total = preflight(json.len() as u64, manifest, payload)?; + // **返回值只属于预检**:写循环有自己的计数器(`Written`),拿它当基数会把全量声明尺寸 + // 再累加一遍 ⇒ 过拒窗口(P2-1)。 + let _ = preflight(json.len() as u64, manifest, payload, limit)?; let file = File::create(temp) .map_err(|error| PackError::io(format!("创建 {} 失败:{error}", temp.display())))?; @@ -62,23 +77,33 @@ pub(super) fn container( .write_all(json.as_bytes()) .map_err(|error| PackError::io(format!("写 {entry} 失败:{error}")))?; + // 第二道闸的计数器:**从清单长度重新起算**,与预检的结果无关(见 `Written`)。 + let mut written = Written::new(json.len() as u64, limit); + let mut buffer = vec![0_u8; COPY_CHUNK]; for group in &manifest.overrides { let dir = normalize_dir(&group.dir)?; for override_file in &group.files { let path = normalize_file(&override_file.path)?; let name = format!("{dir}/{path}"); let source = source_for(payload, &dir, &path)?; - let bytes = std::fs::read(&source).map_err(|error| { + let mut source_file = File::open(&source).map_err(|error| { PackError::io(format!("读覆盖文件 {} 失败:{error}", source.display())) })?; - total += bytes.len() as u64; - guard(total)?; writer .start_file(name.clone(), options) .map_err(|error| PackError::io(format!("写 {name} 失败:{error}")))?; - writer - .write_all(&bytes) - .map_err(|error| PackError::io(format!("写 {name} 失败:{error}")))?; + loop { + let read = source_file.read(&mut buffer).map_err(|error| { + PackError::io(format!("读覆盖文件 {} 失败:{error}", source.display())) + })?; + if read == 0 { + break; + } + writer + .write_all(&buffer[..read]) + .map_err(|error| PackError::io(format!("写 {name} 失败:{error}")))?; + written.spend(read as u64)?; + } } } writer @@ -89,10 +114,17 @@ pub(super) fn container( /// 写盘前的**声明尺寸预检**:只量 `metadata().len()`,不读内容。 /// -/// 返回清单 + 全部覆盖文件的声明总量(供写循环接着累加当第二道)。 -fn preflight(json_len: u64, manifest: &PackManifest, payload: PackPayload<'_>) -> PackResult { +/// 返回值是"清单 + 全部覆盖文件的声明总量",**只给测试与诊断看**。写循环有自己的计数器 +/// (从清单长度起算)——曾经把它直接当累加基数,结果每个文件的字节被算两遍,真实总量过半 +/// 阈值(~2.15 GiB)就被误拒(P2-1,已有 2.5 GB 的端到端用例钉住这个窗口)。 +fn preflight( + json_len: u64, + manifest: &PackManifest, + payload: PackPayload<'_>, + limit: u64, +) -> PackResult { let mut total = json_len; - guard(total)?; + guard_with(total, limit)?; for group in &manifest.overrides { let dir = normalize_dir(&group.dir)?; for override_file in &group.files { @@ -106,7 +138,7 @@ fn preflight(json_len: u64, manifest: &PackManifest, payload: PackPayload<'_>) - })? .len(); total += length; - guard(total)?; + guard_with(total, limit)?; } } Ok(total) @@ -126,15 +158,37 @@ fn source_for(payload: PackPayload<'_>, dir: &str, path: &str) -> PackResult PackResult<()> { - guard_with(total, zip::ZIP64_BYTES_THR) +/// 写循环的第二道闸(**已写出**的未压缩字节数)。 +/// +/// 起点是**清单 JSON 的长度**,与 `preflight` 的返回值无关:两者混用会把每个文件的字节算 +/// 两遍,真实总量过半阈值(~2.15 GiB)就被误拒(P2-1)。类型上也没法混用——`Written` +/// 由 `u64` 构造,而预检的返回值是"全量声明尺寸",只有显式取值才拿得到。 +struct Written { + total: u64, + limit: u64, } -/// 判据本体:`total` **达到**阈值就拒(`>=` 不是 `>`,官方实现也是"撞上就不写")。 +impl Written { + const fn new(json_len: u64, limit: u64) -> Self { + Self { + total: json_len, + limit, + } + } + + /// 记一笔实际写出的字节,并在**每一块之后**过闸(大文件因此能在中途被拦下)。 + fn spend(&mut self, bytes: u64) -> PackResult<()> { + self.total = self.total.saturating_add(bytes); + guard_with(self.total, self.limit) + } +} + +/// ZIP64 阈值闸(`pack_limits`):`total` **达到**阈值就拒(`>=` 不是 `>`,官方实现也是 +/// "撞上就不写")。 /// -/// 抽出 `limit` 参数只为了能在小数值上钉住这条比较——端到端用例必须造 4 GiB 的声明尺寸, -/// 而"`>=` 还是 `>`"这种一位之差在小数值上就能验。 +/// 生产路径的 `limit` 恒为 `zip::ZIP64_BYTES_THR`(由 `format.rs` 传下来);把它做成参数是为了 +/// 能用几十字节的小数值钉住"边界在 `>=` 上"和"写循环不许重复累计"这两件事——真造 4 GiB 的 +/// 包要几十秒 CPU 与一大片内存/页面文件,本机资源纪律下不经默认套件跑(塔台 2026-11-07)。 fn guard_with(total: u64, limit: u64) -> PackResult<()> { if total >= limit { return Err(PackError::limits(format!( @@ -163,6 +217,88 @@ mod tests { assert!(guard_with(101, 100).is_err()); } + #[test] + fn the_second_gate_counts_from_the_manifest_length_not_the_preflight_total() { + // P2-1 的窗口:真实总量在 [阈值一半, 阈值) 之间的包**不许**被拒。这一条不真写 + // 2.5 GB(那要 ~70 秒 deflate 与一大片内存/页面文件),而是直接把窗口里的字节数 + // 喂给第二道闸的计数器——它的起点是清单长度,所以只有"把预检的全量 total 当基数" + // 那种写法才会在这里红(红法是 `spend` 返回 Limits)。 + let half = zip::ZIP64_BYTES_THR / 2; + let mut written = Written::new(28, zip::ZIP64_BYTES_THR); + written + .spend(2_500_000_000) + .expect("2.5 GB 没到 4 GiB,第二道闸不许拒"); + assert!(2_500_000_000 > half, "样本要落在过拒窗口里"); + + // 对照:撞上阈值必须拒,且正好在阈值处(不是超一点点才拒)。 + let mut written = Written::new(28, zip::ZIP64_BYTES_THR); + let error = written + .spend(zip::ZIP64_BYTES_THR) + .expect_err("撞阈值必须拒"); + assert_eq!( + error.kind(), + super::super::super::error::PackErrorKind::Limits + ); + } + + #[test] + fn the_container_does_not_double_count_the_preflight_total() { + // 容器层的窗口回归钉(P2-1),**用几十字节的 limit 把窗口缩小到可测尺寸**: + // `json` 30 字节 + 覆盖文件 40 字节 = 声明总量 70。 + // - 正确实现:第二道闸从 30 起算,写到 70 也不到 100 ⇒ 导出成功; + // - P2-1 的写法(把预检返回的全量 70 当基数):写循环再累加 40 ⇒ 110 ≥ 100 ⇒ 误拒。 + // 真造 2.5 GB 才能暴露同一个 bug,本机资源纪律下不跑(那条留成 `zip64.rs` 里的 + // `#[ignore]` 用例)。 + let root = std::env::temp_dir().join(format!("ull-double-count-{}", std::process::id())); + std::fs::create_dir_all(root.join("overrides/mods")).expect("建目录"); + std::fs::write(root.join("overrides/mods/a.bin"), [0_u8; 40]).expect("写覆盖文件"); + // 清单很短,limit 取"声明总量 + 20 字节"的余量:正确实现只写到 70,重复累计会写到 110。 + let json = "{\"name\":\"0123456789012345\"}"; + let declared = json.len() as u64 + 40; + let limit = declared + 20; + let mut manifest = PackManifest::new("double count"); + manifest.overrides.push(PackOverrides { + dir: "overrides".to_string(), + side: PackSide::Both, + files: vec![OverrideFile { + path: "mods/a.bin".to_string(), + sha1: String::new(), + size: 40, + }], + }); + let target = root.join("out.zip"); + + container( + "curseforge", + "manifest.json", + json, + &manifest, + PackPayload::from_root(&root), + &target, + limit, + ) + .expect("声明总量没到 limit:写循环不许把预检的 total 重复累计"); + + // 对照:limit 降到声明总量本身 ⇒ 预检就该拒,且临时文件都没建。 + let refused = root.join("refused.zip"); + let error = container( + "curseforge", + "manifest.json", + json, + &manifest, + PackPayload::from_root(&root), + &refused, + declared, + ) + .expect_err("声明总量达到 limit 必须拒"); + assert_eq!( + error.kind(), + super::super::super::error::PackErrorKind::Limits + ); + assert!(!refused.exists(), "预检失败时目标路径不许出现"); + let _ = std::fs::remove_dir_all(&root); + } + #[test] fn the_preflight_measures_declared_sizes_not_content() { // 预检必须只量 `metadata().len()`:这一条用"小文件 + 3 GiB 稀疏文件"钉住, @@ -193,8 +329,13 @@ mod tests { ], }); - let total = preflight(7, &manifest, PackPayload::from_root(&root)) - .expect("3 GiB 总量仍在阈值之下,预检不许误伤"); + let total = preflight( + 7, + &manifest, + PackPayload::from_root(&root), + zip::ZIP64_BYTES_THR, + ) + .expect("3 GiB 总量仍在阈值之下,预检不许误伤"); assert_eq!( total, 7 + 10 + 3 * 1024 * 1024 * 1024, diff --git a/crates/core/src/addon/pack/tests/zip64.rs b/crates/core/src/addon/pack/tests/zip64.rs index 399666b..d1b7b33 100644 --- a/crates/core/src/addon/pack/tests/zip64.rs +++ b/crates/core/src/addon/pack/tests/zip64.rs @@ -129,3 +129,54 @@ 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")); } + +/// 端到端版的窗口回归钉(**不进默认套件**:真写 2.5 GB 要 ~70 秒 deflate,且会把机器内存 / +/// 页面文件顶上去;塔台 2026-11-07 的资源配置令要求避免)。 +/// +/// 默认套件里由 `format::write` 的单测 `the_second_gate_counts_from_the_manifest_length_not_ +/// the_preflight_total` 便宜地钉住同一个窗口(把窗口里的字节数直接喂给第二道闸的计数器)。 +/// 本条留给"真跑一遍确认容器层接线没错"的场合: +/// +/// ```text +/// cargo test -p unlucky-core --lib -- zip64 --ignored --nocapture +/// ``` +/// +/// 交付时的实测(M152 r2):单文件 2 500 000 000 字节导出**成功**,包内条目声明尺寸原样为 +/// 2.5 GB,耗时 ~70 秒、源文件稀疏(占盘 0),产物是压缩后的零(几 MB)。 +#[test] +#[ignore = "真写 2.5 GB(~70 秒 deflate + 一片内存):默认套件用 Written 的便宜单测钉同一窗口"] +fn a_single_file_inside_the_double_count_window_still_exports() { + // P2-1 的回归钉:预检返回的是**全量**声明尺寸,写循环曾经直接拿它当累加基数,于是每个 + // 文件的字节被算两遍 —— 真实总量只要过半阈值(≈2.15 GiB)就在内容读完**之后**被误拒。 + // 2.5 GB 正落在这个窗口里:它必须能导出,且中途不许把整个人内容摊进内存。 + let root = temp_root("zip64-window"); + let length = 2_500_000_000_u64; + assert!(length < zip::ZIP64_BYTES_THR, "样本必须在阈值之下"); + assert!( + length > zip::ZIP64_BYTES_THR / 2, + "样本必须落在过拒窗口里(≥ 阈值一半)" + ); + sparse_file(&root.join("overrides/mods/big.jar"), length); + let manifest = manifest_of(vec![override_file("mods/big.jar")]); + let target = root.join("out.zip"); + + let result = CURSEFORGE.write(&manifest, PackPayload::from_root(&root), &target); + assert!( + result.is_ok(), + "2.5 GB 的覆盖文件没超 ZIP64 阈值,不许误拒:{:?}", + result.err() + ); + + // 包内条目的**声明未压缩尺寸**就是 2.5 GB(真写出来的是压缩后的零,只有几 MB)。 + // 这里直接用 `zip` 的读者,而不是 `PackArchive::open_default`:后者对**不可信输入**有 + // 单条目 64 MiB 的上限(`PackLimits`),拿它读我们自己刚写的包会撞限额,与本次判据无关。 + let file = std::fs::File::open(&target).expect("打开导出包"); + let mut archive = zip::ZipArchive::new(file).expect("读 zip"); + let entry = archive + .by_name("overrides/mods/big.jar") + .expect("覆盖条目要在包里"); + assert_eq!(entry.size(), length, "声明尺寸要原样带过去"); + + // 稀疏源文件只占元数据,但产物与目录都清掉,别让 temp 越积越多。 + let _ = std::fs::remove_dir_all(&root); +}