fix(m160-r1): 预检失败不再静默空转 + 写侧条目数闸 + 判据归一 + 冲突页对齐

复审 reviewer-modpackpick-r1(p2-1items / fix-then-merge)的返工。

P2-1 预检失败被吞掉(错误呈现回归)
- host::import::spawn_scan 在 `scan.error.is_some()` 时**再发一条 PackEvent::Report**
  (`convert::failed_report_of` 把 core 的 ImportError 含 hint 逐字段搬过去),于是页面回到
  既有的「致命 —— 已回滚」结果块,不再落回入口态。
- convert 里把失败报告的组装收成一处(`failed_report` / `failed_report_of` → `failed_report_with`)。
- 证据:`a_failed_scan_still_reaches_the_screen_as_a_report` 断言**事件序列**
  `["scan-with-error", "report-failed"]` + 报告里 code/message 与预检同源(MultiMC
  `formatVersion: 2`,detect 认得、read 才失败);`a_clean_scan_does_not_emit_a_report` 是对照。
  变异验证:删掉那条 Report 发送 ⇒ 该用例红。

P3-1 写侧补条目数闸
- `WriteLimits` 增加 `entries`(生产口径 = `PACK_MAX_ENTRIES`);`preflight` 在**读盘之前**
  按「清单 1 条 + 覆盖文件 N 条」判 `count > limit`。
- 证据:`the_write_side_refuses_a_pack_over_the_unified_entry_count`(20001 条声明只放 1 个
  真文件 ⇒ 报 `pack_limits` 而不是 `pack_io`,即闸在读盘前)、
  `the_entry_count_gate_is_the_same_number_as_the_read_side`(两侧同一个数)。
  变异验证:注释掉 `guard_entries` 调用 ⇒ 前一条红。

P3-2 「内容相同」判据归一
- 删掉 `scan::judge` 里与 `merge::decide` 重复的 `disk.sha1 == pack_sha1` 早退,只留
  `decide().reason?`;两处文档互相指路(`merge.rs` 注明这是唯一判据)。
- 变异验证:把 `decide` 的相同分支改成"冲突" ⇒ **8 条**红(含 4 条 scan 用例与 merge 单测)。

P3-3/P3-4 口径与注释
- `TogglePick` 注释改成与代码一致(保留项留在表里,靠 `decisions()` 过滤)。
- `unknown_remote` 的计数与文案统一:判不了的两种来路(补全前没路径 / 有路径但缺 sha1)都算,
  注记载明两者;新增 `a_remote_entry_with_a_path_but_no_sha1_is_counted_as_unjudgeable`。

视觉(tower 看图给的三条)
- 冲突行两种取值都改成**同几何的 pill**(保留=描边、覆盖=tonal),各自右对齐进同宽度的槽 ⇒
  右边缘同一条线;路径列吃 `Fill` 把槽推到行右缘。
- 列表与动作行之间加 8dp 呼吸;分页行的 Previous/Next 用 `Space::Fill` 顶到右端,
  与内容列对齐(不再是右侧一大片留白)。
- 重新出图:C:/Temp/m160-shots/pick-light.png / pick-dark.png。

顺手
- `format/write.rs` 的单测拆到 `format/write/tests.rs`(纯搬移,守 400 行),并把里面两个
  自己拼 `%TEMP%` 路径的用例改用 `TempRoot`(断言红了也不留垃圾)。
- `import_decisions.rs` 的夹具拆到 `tests/decisions_support.rs`(纯搬移,守 400 行)。

证据:fmt 干净、clippy -D warnings 干净、`cargo test --workspace --all-targets -j 4`
= 2512 passed / 0 failed / **4 ignored**(51 个 test binary);跑前/跑后 `%TEMP%` 的
`ull-pack-*` 计数 1922→1922、`ull-pack-*-zip64-*` 0→0、`ull-import-*` 1360→1360(增量 0),
df 106G→106G 余量不变。
This commit is contained in:
worker-modpackpick authored and AskaEth committed 2026-11-07 13:52:44 +08:00
1 parent 4defc41e8d
commit d568cc6f37
14 files changed
+611 -257

No files matched your search

+35 -147
View File
@@ -131,6 +131,15 @@ fn preflight(
payload: PackPayload<'_>,
limits: WriteLimits,
) -> PackResult<u64> {
// **条目数闸**(读侧同一条:`PACK_MAX_ENTRIES`)。放在读盘之前:超 2 万条时连一次
// `metadata()` 都不必做,也顺手证明"闸在碰内容之前"。清单条目自己也占一条。
let declared_entries = 1 + manifest
.overrides
.iter()
.map(|group| group.files.len())
.fold(0_usize, usize::saturating_add);
guard_entries(declared_entries, limits.entries)?;
let mut total = json_len;
let mut entries: Vec<(String, u64)> = Vec::new();
for group in &manifest.overrides {
@@ -163,6 +172,8 @@ pub(super) struct WriteLimits {
pub(super) total: u64,
/// 单条目上限。
pub(super) entry: u64,
/// 条目数上限(**与读侧同一个数**:清单条目 + 每个覆盖文件)。
pub(super) entries: usize,
}
impl WriteLimits {
@@ -171,16 +182,37 @@ impl WriteLimits {
Self {
total: super::super::archive::limits::PACK_MAX_UNCOMPRESSED_BYTES,
entry: super::super::archive::limits::PACK_MAX_ENTRY_BYTES,
entries: super::super::archive::limits::PACK_MAX_ENTRIES,
}
}
/// 显式给值(测试用:把边界缩小到可测尺寸)。
#[cfg(test)]
pub(super) const fn new(total: u64, entry: u64) -> Self {
Self { total, entry }
pub(super) const fn new(total: u64, entry: u64, entries: usize) -> Self {
Self {
total,
entry,
entries,
}
}
}
/// 条目数闸:与读侧的 `PACK_MAX_ENTRIES` **同一份口径**。
///
/// 读侧在 `archive::open` 里对"中央目录条数"判 `>= limit` 就拒;写侧产出的是"清单 + N 个覆盖
/// 文件",比读侧少掉目录条目,所以这里按同一侧的口径判(`count > limit` 拒,`== limit` 许可,
/// 与读侧能读回的最大值一致)。没有这道闸时,一个内容目录超过 2 万条的实例**导得出来、
/// 自己的导入器拒读**——正是"M152 遗留的限额不衔接"那条。
fn guard_entries(count: usize, entries_limit: usize) -> PackResult<()> {
if count > entries_limit {
return Err(PackError::limits(format!(
"包内条目数 {count} 超过统一上限 {entries_limit}(导入读取用的也是这个数)"
))
.with_hint("把内容目录里多余的文件移出实例后再导出;单文件与总量上限也是同一份口径"));
}
Ok(())
}
/// 单条目闸:与读侧的 `PACK_MAX_ENTRY_BYTES` **同一份口径**。
///
/// 没有这道闸时,"导出 100 MiB 的覆盖文件 → 自己的导入器拒读"是能真实发生的(读侧把一条
@@ -250,148 +282,4 @@ fn guard_with(total: u64, limit: u64) -> PackResult<()> {
}
#[cfg(test)]
mod tests {
use super::*;
use crate::addon::pack::manifest::{OverrideFile, PackOverrides, PackSide};
#[test]
fn the_threshold_is_reached_not_exceeded() {
// `>=` 而不是 `>`:恰好等于阈值就已经需要 ZIP64 了。
assert!(guard_with(99, 100).is_ok());
let error = guard_with(100, 100).expect_err("等于阈值必须拒");
assert_eq!(
error.kind(),
super::super::super::error::PackErrorKind::Limits
);
assert!(error.message().contains("100"), "{error}");
assert!(guard_with(101, 100).is_err());
}
#[test]
fn the_second_gate_counts_from_the_manifest_length_not_the_preflight_total() {
// P2-1 的窗口:真实总量在 [上限一半, 上限) 之间的包**不许**被拒。这一条不真写
// 几百 MiB(那要几十秒 deflate 与一大片内存/页面文件),而是直接把窗口里的字节数
// 喂给第二道闸的计数器——它的起点是清单长度,所以只有"把预检的全量 total 当基数"
// 那种写法才会在这里红(红法是 `spend` 返回 Limits)。
let limit = WriteLimits::production().total;
let inside = limit / 2 + limit / 4;
let mut written = Written::new(28, limit);
written.spend(inside).expect("没到统一上限,第二道闸不许拒");
assert!(inside > limit / 2, "样本要落在过拒窗口里");
// 对照:撞上上限必须拒,且正好在上限处(不是超一点点才拒)。
let mut written = Written::new(28, limit);
let error = written.spend(limit).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,
WriteLimits::new(limit, 1024),
)
.expect("声明总量没到 limit:写循环不许把预检的 total 重复累计");
// 对照:limit 降到声明总量本身 ⇒ 预检就该拒,且临时文件都没建。
let refused = root.join("refused.zip");
let error = container(
"curseforge",
"manifest.json",
json,
&manifest,
PackPayload::from_root(&root),
&refused,
WriteLimits::new(declared, 1024),
)
.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 稀疏文件"钉住,
// 真读内容的话要么慢要么直接吃 3 GiB 内存。
let root = std::env::temp_dir().join(format!("ull-preflight-{}", std::process::id()));
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);
let mut manifest = PackManifest::new("preflight");
manifest.overrides.push(PackOverrides {
dir: "overrides".to_string(),
side: PackSide::Both,
files: vec![
OverrideFile {
path: "mods/small.jar".to_string(),
sha1: String::new(),
size: 10,
},
OverrideFile {
path: "mods/huge.jar".to_string(),
sha1: String::new(),
size: 0,
},
],
});
let total = preflight(
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,
),
)
.expect("3 GiB 总量仍在最宽闸之下,预检不许误伤");
assert_eq!(
total,
7 + 10 + 3 * 1024 * 1024 * 1024,
"清单 + 每个覆盖文件的声明尺寸"
);
let _ = std::fs::remove_dir_all(&root);
}
}
mod tests;
@@ -0,0 +1,150 @@
//! `format/write.rs` 的单测:**从 `write.rs` 拆出来守 400 行硬线**。
//!
//! 拆分口径与仓库其它拆分一致:**纯搬移**,断言与注释一行不改(只在外面加一层 `mod tests;`)。
//! 这些用例钉的是「闸在碰内容之前」「边界在 `>=` 上」「写循环不许重复累计」三件事。
use super::*;
use crate::addon::pack::manifest::{OverrideFile, PackOverrides, PackSide};
use crate::addon::temp::TempRoot;
#[test]
fn the_threshold_is_reached_not_exceeded() {
// `>=` 而不是 `>`:恰好等于阈值就已经需要 ZIP64 了。
assert!(guard_with(99, 100).is_ok());
let error = guard_with(100, 100).expect_err("等于阈值必须拒");
assert_eq!(
error.kind(),
super::super::super::error::PackErrorKind::Limits
);
assert!(error.message().contains("100"), "{error}");
assert!(guard_with(101, 100).is_err());
}
#[test]
fn the_second_gate_counts_from_the_manifest_length_not_the_preflight_total() {
// P2-1 的窗口:真实总量在 [上限一半, 上限) 之间的包**不许**被拒。这一条不真写
// 几百 MiB(那要几十秒 deflate 与一大片内存/页面文件),而是直接把窗口里的字节数
// 喂给第二道闸的计数器——它的起点是清单长度,所以只有"把预检的全量 total 当基数"
// 那种写法才会在这里红(红法是 `spend` 返回 Limits)。
let limit = WriteLimits::production().total;
let inside = limit / 2 + limit / 4;
let mut written = Written::new(28, limit);
written.spend(inside).expect("没到统一上限,第二道闸不许拒");
assert!(inside > limit / 2, "样本要落在过拒窗口里");
// 对照:撞上上限必须拒,且正好在上限处(不是超一点点才拒)。
let mut written = Written::new(28, limit);
let error = written.spend(limit).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]` 用例)。
// 临时根用 `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("写覆盖文件");
// 清单很短,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,
WriteLimits::new(limit, 1024, 16),
)
.expect("声明总量没到 limit:写循环不许把预检的 total 重复累计");
// 对照:limit 降到声明总量本身 ⇒ 预检就该拒,且临时文件都没建。
let refused = root.join("refused.zip");
let error = container(
"curseforge",
"manifest.json",
json,
&manifest,
PackPayload::from_root(&root),
&refused,
WriteLimits::new(declared, 1024, 16),
)
.expect_err("声明总量达到 limit 必须拒");
assert_eq!(
error.kind(),
super::super::super::error::PackErrorKind::Limits
);
assert!(!refused.exists(), "预检失败时目标路径不许出现");
}
#[test]
fn the_preflight_measures_declared_sizes_not_content() {
// 预检必须只量 `metadata().len()`:这一条用"小文件 + 3 GiB 稀疏文件"钉住,
// 真读内容的话要么慢要么直接吃 3 GiB 内存。
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);
let mut manifest = PackManifest::new("preflight");
manifest.overrides.push(PackOverrides {
dir: "overrides".to_string(),
side: PackSide::Both,
files: vec![
OverrideFile {
path: "mods/small.jar".to_string(),
sha1: String::new(),
size: 10,
},
OverrideFile {
path: "mods/huge.jar".to_string(),
sha1: String::new(),
size: 0,
},
],
});
let total = preflight(
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,
),
)
.expect("3 GiB 总量仍在最宽闸之下,预检不许误伤");
assert_eq!(
total,
7 + 10 + 3 * 1024 * 1024 * 1024,
"清单 + 每个覆盖文件的声明尺寸"
);
}
@@ -51,6 +51,57 @@ fn sparse_file(path: &std::path::Path, length: u64) {
file.set_len(length).expect("拉长文件(稀疏)");
}
#[test]
fn the_write_side_refuses_a_pack_over_the_unified_entry_count() {
// 写侧以前只有总量与单条两道闸:内容目录超过 2 万条的实例**导得出来、自己拒读**
// (读侧 `archive::open` 的「条目数超过上限」)。这条钉住第三道闸,以及它挡在**读盘之前**。
let root = TempRoot::new("limits-entries");
write_file(&root.join("overrides/mods/a.jar"), b"x");
let target = root.join("out.zip");
// ① 对照:条目数远低于上限时不许误伤。
CURSEFORGE
.write(
&manifest_of(vec![override_file("mods/a.jar")]),
PackPayload::from_root(&root),
&target,
)
.expect("单个覆盖条目必须写得出来");
let _ = std::fs::remove_file(&target);
// ② 声明 20001 条(上限 20000 + 1):只有**第一条**的文件真的存在,其余 2 万条谁都不在盘上。
// 报出来的必须是 `pack_limits` 而不是 `pack_io` —— 那就是"条目数闸在读第一个文件之前"的证据
// (真去 `metadata()` 的话,第二个文件就已经报 `pack_io` 了)。
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")));
}
assert_eq!(files.len(), PACK_MAX_ENTRIES + 1, "上限 + 1 条");
let error = CURSEFORGE
.write(&manifest_of(files), PackPayload::from_root(&root), &target)
.expect_err("超过条目数上限必须拒");
assert_eq!(error.kind(), PackErrorKind::Limits, "{error}");
assert!(
error.message().contains(&PACK_MAX_ENTRIES.to_string()),
"文案要给出统一上限那个数(读侧用的是同一个):{error}"
);
assert!(
error.hint().is_some_and(|hint| hint.contains("移出实例")),
"要给可执行的下一步:{error:?}"
);
assert!(!target.exists(), "预检失败时目标路径不许出现");
}
#[test]
fn the_entry_count_gate_is_the_same_number_as_the_read_side() {
// 「三个常量同源」的写侧那一半:生产口径的条目数上限必须**就是**读侧那个常量。
// 改坏它会先在 `pick` 这层红,而不是等某个用户导出 2 万条内容的实例才发现。
let production = crate::addon::pack::archive::limits::PACK_MAX_ENTRIES;
let read_side = PackLimits::default().max_entries;
assert_eq!(production, read_side, "写侧与读侧必须是同一个数");
assert_eq!(read_side, 20_000, "上限本身也钉住(改数值要显式改这里)");
}
#[test]
fn the_three_limits_form_one_chain_and_the_read_side_uses_them() {
// ① 关系:单条 ≤ 总量 < ZIP64 ≤ u32。前一半保证"能写出来的包读得回来",