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:
1 parent
4defc41e8d
commit
d568cc6f37
14 files changed
+611
-257
No files matched your search
@@ -76,7 +76,11 @@ pub fn decide(pack_sha1: &str, base_sha1: Option<&str>, disk_sha1: Option<&str>)
|
||||
// 磁盘上没有这个文件。
|
||||
(None, None) => MergeDecision::plain(MergeAction::Install),
|
||||
(None, Some(_)) => MergeDecision::conflict(ConflictReason::UserDeleted),
|
||||
// 磁盘上有。
|
||||
// 磁盘上有、内容与包内**逐字节相同**(同一份 SHA-1)。
|
||||
//
|
||||
// 这是"内容相同"的**唯一判据**:预检(`super::scan::judge`)刻意不再自己写一遍
|
||||
// `disk == pack` 的早退(复审 r1 P3-2),而是走这里的 `reason == None`。改这一行
|
||||
// 会同时影响"导入怎么处置"与"预检列不列这条",所以两边的测试都会红。
|
||||
(Some(disk), _) if disk == pack_sha1 => MergeDecision::plain(MergeAction::SkipIdentical),
|
||||
(Some(_), None) => MergeDecision::conflict(ConflictReason::PreExisting),
|
||||
(Some(disk), Some(base)) if disk == base => MergeDecision::plain(MergeAction::Install),
|
||||
|
||||
@@ -53,7 +53,7 @@ pub const NOTE_ENGINE_ARTIFACTS_DROPPED: &str = "pack.import.note.engine_artifac
|
||||
pub const NOTE_ROLLBACK_INCOMPLETE: &str = "pack.import.note.rollback_incomplete";
|
||||
/// 注记:同包另一条声明遮蔽了这个路径(说清谁赢、两份内容是否相同)。
|
||||
pub const NOTE_OVERRIDE_SHADOWED: &str = "pack.import.note.override_shadowed";
|
||||
/// 注记:这些远端条目补全前没有路径,预检无法预判它们会不会撞上用户已有文件。
|
||||
/// 注记:这些远端条目预检判不了(补全前没有路径 / 有路径但缺 sha1 声明),无法预判冲突。
|
||||
pub const NOTE_REMOTE_PATH_UNKNOWN: &str = "pack.import.note.remote_path_unknown";
|
||||
|
||||
/// 缺失:调用方没给 fetcher(离线导入)。
|
||||
|
||||
@@ -26,7 +26,8 @@
|
||||
//! mrpack 的 `files[]` 自带 `path` + `hashes.sha1`,所以能在这里判;判的是"**声明的**摘要 vs
|
||||
//! 磁盘",与导入时"下载下来再校验"是同一份期望值。CurseForge 的 `files[]` 只有
|
||||
//! `projectID/fileID`,补全前**没有路径**,无从判起 ⇒ 不猜、不进清单,只把条数记进
|
||||
//! [`ImportScan::unknown_remote`](界面据此说"还有 N 条补全后才能知道会不会撞车")。
|
||||
//! [`ImportScan::unknown_remote`](界面据此说"还有 N 条补全后才能知道会不会撞车")。计数与
|
||||
//! 文案同一口径:**判不了**的两种来路(补全前没有路径 / 有路径但缺 `hashes.sha1`)都算。
|
||||
//!
|
||||
//! # 便宜在哪、贵在哪
|
||||
//!
|
||||
@@ -98,7 +99,11 @@ pub struct ImportScan {
|
||||
pub loader: Option<LoaderTarget>,
|
||||
/// 冲突清单(按路径排序)。
|
||||
pub conflicts: Vec<ImportConflict>,
|
||||
/// 补全前没有路径、因而无法预判的远端条目数(CF `files[]` 的常态)。
|
||||
/// 预检阶段**判不了**的远端条目数。
|
||||
///
|
||||
/// 判不了有两种来路,计数把两者都算上(口径与注记文案一致,复审 r1 P3-4):
|
||||
/// ①CurseForge `files[]` 补全前**没有路径**(常态);②极少数 mrpack 条目**有路径但缺
|
||||
/// `hashes.sha1`**,没有声明摘要就没法与磁盘现状比对。
|
||||
pub unknown_remote: usize,
|
||||
/// 注记(`.disabled` / 可选条目 / 基线口径 / 被遮蔽的条目……与导入报告同源)。
|
||||
pub notes: Vec<ImportIssue>,
|
||||
@@ -207,7 +212,7 @@ pub fn scan_pack(request: &ImportRequest<'_>) -> ImportScan {
|
||||
}
|
||||
for entry in &plan.remote {
|
||||
let (Some(path), Some(declared)) = (entry.path.as_deref(), entry.file.sha1()) else {
|
||||
// 没有路径(CF 待补全)或没有声明摘要 ⇒ 判不了,如实计数。
|
||||
// 两种来路都算「判不了」:没有路径(CF 待补全)或没有声明摘要(缺 `hashes.sha1`)。
|
||||
scan.unknown_remote += 1;
|
||||
continue;
|
||||
};
|
||||
@@ -232,7 +237,7 @@ pub fn scan_pack(request: &ImportRequest<'_>) -> ImportScan {
|
||||
scan.notes.push(ImportIssue::new(
|
||||
keys::NOTE_REMOTE_PATH_UNKNOWN,
|
||||
format!(
|
||||
"有 {} 条远端条目补全前没有路径(CurseForge files[]),预检判不出它们会不会撞上已有文件;导入时仍按「缺省保留」处置",
|
||||
"有 {} 条远端条目预检判不了:CurseForge files[] 补全前没有路径,个别条目有路径但没有 sha1 声明;这些条目会不会撞上已有文件只能等补全/下载后才知道,导入时仍按「缺省保留」处置",
|
||||
scan.unknown_remote
|
||||
),
|
||||
));
|
||||
@@ -243,6 +248,16 @@ pub fn scan_pack(request: &ImportRequest<'_>) -> ImportScan {
|
||||
}
|
||||
|
||||
/// 判一个路径:`Some` = 要用户点头的冲突。
|
||||
///
|
||||
/// # 「内容相同」的判据**只此一处**:[`merge::decide`]
|
||||
///
|
||||
/// 这里**故意不再写** `if disk.sha1 == pack_sha1 { return None }`(复审 r1 P3-2 抓到的重复判据):
|
||||
/// 那种早退与 `merge::decide` 的 `SkipIdentical` 分支是同一件事的两次实现,改一处漏一处的后果
|
||||
/// 就是"预检说没冲突、导入却判 SkipIdentical"或者反过来。
|
||||
///
|
||||
/// 现在只有一条链路:`decide` 给出 `MergeDecision`,**没有 `reason` 就不是冲突**
|
||||
/// (`SkipIdentical` 与 `Install` 都是 `reason == None`),`?` 把它一步交代掉。
|
||||
/// 于是"内容相同 ⇒ 不进清单"这条结论的源头是 `merge.rs` 的那张表——与导入用的是同一张。
|
||||
fn judge(
|
||||
path: &str,
|
||||
source: FileSource,
|
||||
@@ -254,10 +269,9 @@ fn judge(
|
||||
) -> Option<ImportConflict> {
|
||||
let disk = disk_state(&target.join(path))?;
|
||||
// 包内那条的摘要来自 M110 读包(`override_files` 边读边算),与导入时现算的是同一份字节。
|
||||
if disk.sha1 == pack_sha1 {
|
||||
return None;
|
||||
}
|
||||
let base_sha1 = baseline.sha1(path).map(str::to_string);
|
||||
// 唯一判据:`reason` 为 `None` 就是"不是冲突"(内容相同 → `SkipIdentical`;
|
||||
// 磁盘上没有 → `Install`,而那一条 `disk_state` 已经先返回 `None` 了)。
|
||||
let decision = merge::decide(pack_sha1, base_sha1.as_deref(), Some(&disk.sha1));
|
||||
let reason = decision.reason?;
|
||||
Some(ImportConflict {
|
||||
|
||||
@@ -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。前一半保证"能写出来的包读得回来",
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
//! `import_decisions` 的夹具与断言小工具:**从 `import_decisions.rs` 拆出来守 400 行硬线**。
|
||||
//!
|
||||
//! 拆分口径与仓库其它拆分一致:**纯搬移**,内容一行不改。这些夹具本身也是"口径"的一部分——
|
||||
//! 叠层(`client-overrides/` 胜 `overrides/`)、目标里已存在的用户内容、以及"内容与包内一致"
|
||||
//! 的那一份,都是冲突判定的边界样本。
|
||||
|
||||
use super::support::{sha1, StubFetcher};
|
||||
use super::{write_file, zip_dir, TempRoot};
|
||||
use crate::addon::import::ImportScan;
|
||||
|
||||
/// 夹具里 `client-overrides/` 胜出的那份内容(`overrides/` 里同名文件是不同的内容)。
|
||||
pub(super) const KNOWN_CLIENT: &[u8] = b"shared = client\n";
|
||||
/// 夹具里被遮蔽的那份(`overrides/` 侧)。
|
||||
pub(super) const KNOWN_OVERRIDES: &[u8] = b"shared = overrides\n";
|
||||
/// 与目标内容**一致**的那个文件。
|
||||
pub(super) const SAME: &[u8] = b"same = 1\n";
|
||||
/// 只有 overrides 声明、目标里还有一个不同内容的同名文件。
|
||||
pub(super) const LAYER: &[u8] = b"layer from overrides\n";
|
||||
/// 目标里用户自己的内容(会被覆盖 / 被保留的那一侧)。
|
||||
pub(super) const USER_KNOWN: &[u8] = b"shared = mine\n";
|
||||
/// 目标里用户自己的 layer 内容。
|
||||
pub(super) const USER_LAYER: &[u8] = b"my layer\n";
|
||||
|
||||
/// 一份带 **overrides 叠层** 的 mrpack(`client-overrides/` 与 `overrides/` 同名不同内容)+ 一条远端条目。
|
||||
pub(super) fn layered_pack(tag: &str) -> (TempRoot, StubFetcher) {
|
||||
// 返回值 `Deref` 到 `pack.mrpack`,根由它持有(`src/` 在 `root.parent()` 下)。
|
||||
let root = TempRoot::at(tag, "pack.mrpack");
|
||||
let dir = root.parent().expect("临时根").join("src");
|
||||
let remote = b"remote jar bytes";
|
||||
let index = format!(
|
||||
r#"{{"game":"minecraft","formatVersion":1,"versionId":"1.0.0","name":"M160 夹具",
|
||||
"files":[{{"path":"mods/remote.jar",
|
||||
"hashes":{{"sha1":"{}","sha512":"{}"}},
|
||||
"downloads":["stub://mods/remote.jar"],"fileSize":{}}}],
|
||||
"dependencies":{{"minecraft":"1.20.1"}}}}"#,
|
||||
sha1(remote),
|
||||
"0".repeat(128),
|
||||
remote.len()
|
||||
);
|
||||
write_file(&dir.join("modrinth.index.json"), index.as_bytes());
|
||||
// 叠层:同一路径两份声明,client-overrides 胜(Modrinth 官方语义)。
|
||||
write_file(&dir.join("overrides/config/known.toml"), KNOWN_OVERRIDES);
|
||||
write_file(
|
||||
&dir.join("client-overrides/config/known.toml"),
|
||||
KNOWN_CLIENT,
|
||||
);
|
||||
// 内容与目标一致的那个(不在叠层里,来源 = overrides)。
|
||||
write_file(&dir.join("overrides/config/same.toml"), SAME);
|
||||
write_file(&dir.join("overrides/mods/layer.jar"), LAYER);
|
||||
zip_dir(&dir, &root);
|
||||
let fetcher = StubFetcher::new().with_file("stub://mods/remote.jar", remote);
|
||||
(root, fetcher)
|
||||
}
|
||||
|
||||
/// 预置一个"用户已有文件"的实例目录。
|
||||
pub(super) fn seeded(tag: &str) -> TempRoot {
|
||||
let target = TempRoot::under(tag, "instance");
|
||||
write_file(&target.join("config/known.toml"), USER_KNOWN);
|
||||
write_file(&target.join("config/same.toml"), SAME);
|
||||
write_file(&target.join("mods/layer.jar"), USER_LAYER);
|
||||
target
|
||||
}
|
||||
|
||||
pub(super) fn paths(scan: &ImportScan) -> Vec<&str> {
|
||||
scan.conflicts
|
||||
.iter()
|
||||
.map(|conflict| conflict.path.as_str())
|
||||
.collect()
|
||||
}
|
||||
@@ -15,74 +15,17 @@
|
||||
//!
|
||||
//! 全部离线:远端走 [`support::StubFetcher`],没有一条网络请求。
|
||||
|
||||
use super::support::{mrpack_stub, sha1, StubFetcher};
|
||||
use super::decisions_support::{
|
||||
layered_pack, paths, seeded, KNOWN_CLIENT, SAME, USER_KNOWN, USER_LAYER,
|
||||
};
|
||||
use super::support::{mrpack_stub, sha1};
|
||||
use super::{read_text, write_file, zip_dir, TempRoot};
|
||||
use crate::addon::import::{
|
||||
import_pack, scan_pack, ConflictChoice, ConflictDecisions, ConflictReason, FileAction,
|
||||
FileSource, ImportRequest, ImportScan, ImportStatus,
|
||||
FileSource, ImportRequest, ImportStatus,
|
||||
};
|
||||
use crate::download::blocking::block_on;
|
||||
|
||||
/// 夹具里 `client-overrides/` 胜出的那份内容(`overrides/` 里同名文件是不同的内容)。
|
||||
const KNOWN_CLIENT: &[u8] = b"shared = client\n";
|
||||
/// 夹具里被遮蔽的那份(`overrides/` 侧)。
|
||||
const KNOWN_OVERRIDES: &[u8] = b"shared = overrides\n";
|
||||
/// 与目标内容**一致**的那个文件。
|
||||
const SAME: &[u8] = b"same = 1\n";
|
||||
/// 只有 overrides 声明、目标里还有一个不同内容的同名文件。
|
||||
const LAYER: &[u8] = b"layer from overrides\n";
|
||||
/// 目标里用户自己的内容(会被覆盖 / 被保留的那一侧)。
|
||||
const USER_KNOWN: &[u8] = b"shared = mine\n";
|
||||
/// 目标里用户自己的 layer 内容。
|
||||
const USER_LAYER: &[u8] = b"my layer\n";
|
||||
|
||||
/// 一份带 **overrides 叠层** 的 mrpack(`client-overrides/` 与 `overrides/` 同名不同内容)+ 一条远端条目。
|
||||
fn layered_pack(tag: &str) -> (TempRoot, StubFetcher) {
|
||||
// 返回值 `Deref` 到 `pack.mrpack`,根由它持有(`src/` 在 `root.parent()` 下)。
|
||||
let root = TempRoot::at(tag, "pack.mrpack");
|
||||
let dir = root.parent().expect("临时根").join("src");
|
||||
let remote = b"remote jar bytes";
|
||||
let index = format!(
|
||||
r#"{{"game":"minecraft","formatVersion":1,"versionId":"1.0.0","name":"M160 夹具",
|
||||
"files":[{{"path":"mods/remote.jar",
|
||||
"hashes":{{"sha1":"{}","sha512":"{}"}},
|
||||
"downloads":["stub://mods/remote.jar"],"fileSize":{}}}],
|
||||
"dependencies":{{"minecraft":"1.20.1"}}}}"#,
|
||||
sha1(remote),
|
||||
"0".repeat(128),
|
||||
remote.len()
|
||||
);
|
||||
write_file(&dir.join("modrinth.index.json"), index.as_bytes());
|
||||
// 叠层:同一路径两份声明,client-overrides 胜(Modrinth 官方语义)。
|
||||
write_file(&dir.join("overrides/config/known.toml"), KNOWN_OVERRIDES);
|
||||
write_file(
|
||||
&dir.join("client-overrides/config/known.toml"),
|
||||
KNOWN_CLIENT,
|
||||
);
|
||||
// 内容与目标一致的那个(不在叠层里,来源 = overrides)。
|
||||
write_file(&dir.join("overrides/config/same.toml"), SAME);
|
||||
write_file(&dir.join("overrides/mods/layer.jar"), LAYER);
|
||||
zip_dir(&dir, &root);
|
||||
let fetcher = StubFetcher::new().with_file("stub://mods/remote.jar", remote);
|
||||
(root, fetcher)
|
||||
}
|
||||
|
||||
/// 预置一个"用户已有文件"的实例目录。
|
||||
fn seeded(tag: &str) -> TempRoot {
|
||||
let target = TempRoot::under(tag, "instance");
|
||||
write_file(&target.join("config/known.toml"), USER_KNOWN);
|
||||
write_file(&target.join("config/same.toml"), SAME);
|
||||
write_file(&target.join("mods/layer.jar"), USER_LAYER);
|
||||
target
|
||||
}
|
||||
|
||||
fn paths(scan: &ImportScan) -> Vec<&str> {
|
||||
scan.conflicts
|
||||
.iter()
|
||||
.map(|conflict| conflict.path.as_str())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_lists_only_files_that_exist_and_differ() {
|
||||
let (pack, _) = layered_pack("dec-scan");
|
||||
@@ -338,6 +281,41 @@ fn curseforge_entries_without_a_path_are_counted_not_guessed() {
|
||||
assert!(paths(&scan).is_empty(), "{:?}", paths(&scan));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_remote_entry_with_a_path_but_no_sha1_is_counted_as_unjudgeable() {
|
||||
// 复审 r1 P3-4:`unknown_remote` 的**计数口径**与注记文案要同一件事。
|
||||
// 这一条只放 `hashes` 里的 sha512、**不写 sha1**:有路径但没有声明摘要 ⇒ 预检没法比对磁盘,
|
||||
// 也就不该假装它"没冲突",而是要计入 unknown_remote 并如实注记。
|
||||
let root = TempRoot::new("dec-no-sha1");
|
||||
let dir = root.join("src");
|
||||
let index = format!(
|
||||
r#"{{"game":"minecraft","formatVersion":1,"versionId":"1.0.0","name":"缺 sha1",
|
||||
"files":[{{"path":"mods/no-sha1.jar",
|
||||
"hashes":{{"sha512":"{}"}},
|
||||
"downloads":["stub://mods/no-sha1.jar"],"fileSize":16}}],
|
||||
"dependencies":{{"minecraft":"1.20.1"}}}}"#,
|
||||
"0".repeat(128)
|
||||
);
|
||||
write_file(&dir.join("modrinth.index.json"), index.as_bytes());
|
||||
let pack = root.join("pack.mrpack");
|
||||
zip_dir(&dir, &pack);
|
||||
let target = root.join("instance");
|
||||
|
||||
let scan = scan_pack(&ImportRequest::new(&pack, &target));
|
||||
assert!(scan.is_ok(), "{:?}", scan.error);
|
||||
assert_eq!(scan.unknown_remote, 1, "有路径但缺 sha1 ⇒ 判不了,要计数");
|
||||
let note = scan
|
||||
.notes
|
||||
.iter()
|
||||
.find(|note| note.message_key == crate::addon::import::keys::NOTE_REMOTE_PATH_UNKNOWN)
|
||||
.expect("要如实注记");
|
||||
assert!(
|
||||
note.message.contains("sha1"),
|
||||
"文案要把「缺 sha1 声明」这条来路说清楚(计数口径与文案一致):{}",
|
||||
note.message
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_broken_pack_reports_the_error_instead_of_panicking() {
|
||||
let root = TempRoot::new("dec-broken");
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
|
||||
// `fixtures.rs` 被 `addon::pack::tests` 与本目录**同时**编译(同一份手造包只维护一处),
|
||||
// 所以 clippy 的 `duplicate_mod` 在这里是刻意为之:改成两份副本才是真的错。
|
||||
mod decisions_support;
|
||||
#[allow(clippy::duplicate_mod)]
|
||||
#[path = "../pack/tests/fixtures.rs"]
|
||||
mod fixtures;
|
||||
|
||||
Reference in new issue
Block a user