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
@@ -124,7 +124,10 @@ impl App {
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::TogglePick(path) => {
|
||||
// 点上就翻到另一侧;翻回"保留"时把这条从表里删掉(表里只有"偏离缺省档"的项)
|
||||
// 点一下就翻到另一侧,并把结果**原样记进表里**(保留也记)。
|
||||
// 表里为什么保留"保留"项:整批覆盖之后单独放回某几条时,「保留」必须能盖住
|
||||
// 整批档;等到发请求时 `PackPageState::decisions` 再把"与缺省档相同"的项过滤掉
|
||||
// (所以"一次都没点过"仍然等于 `KeepAll`)。
|
||||
let choice = self.content.pack.pick_choice(&path);
|
||||
let flipped = match choice {
|
||||
ConflictChoiceView::KeepUser => ConflictChoiceView::Overwrite,
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
//! [`super::super::model::PAGE_SIZE`] 条以内,翻页只是换数据。
|
||||
|
||||
use iced::widget::{column, container, row};
|
||||
use iced::{Alignment, Background, Border, Element, Length};
|
||||
use iced::{Alignment, Background, Border, Element, Length, Padding};
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_design::icons::IconKind;
|
||||
@@ -47,6 +47,10 @@ use super::super::model::{Pick, PickRow};
|
||||
use super::super::PackMessage;
|
||||
use super::intent;
|
||||
|
||||
/// 处置按钮槽的宽度(px):取最宽状态「⟳ Overwrite」的实际占用再留余量,
|
||||
/// 保证「Keep」与「Overwrite」两种状态的右边缘落在同一条线上。
|
||||
const TOGGLE_SLOT: f32 = 132.0;
|
||||
|
||||
/// 整块。
|
||||
pub(super) fn pick_block<'a>(
|
||||
ui: &'a Ui,
|
||||
@@ -132,20 +136,28 @@ pub(super) fn pick_block<'a>(
|
||||
375.0,
|
||||
));
|
||||
|
||||
// 列表与动作行之间留出与其它区块一致的呼吸(复审 r1 视觉 2):滚到底时最后一行会紧贴
|
||||
// 动作行,靠这层 padding 把两者分开(直接调列 spacing 会把行间距也一起拉开)。
|
||||
block = block.push(
|
||||
row![
|
||||
buttons::filled(
|
||||
ui,
|
||||
Button::new(pick.confirm.as_str(), intent(PackMessage::ConfirmImport))
|
||||
.leading(IconKind::Check),
|
||||
),
|
||||
buttons::text(
|
||||
ui,
|
||||
Button::new(pick.cancel, intent(PackMessage::CancelImport)),
|
||||
),
|
||||
]
|
||||
.spacing(ui.space(Space::X2))
|
||||
.align_y(Alignment::Center),
|
||||
container(
|
||||
row![
|
||||
buttons::filled(
|
||||
ui,
|
||||
Button::new(pick.confirm.as_str(), intent(PackMessage::ConfirmImport))
|
||||
.leading(IconKind::Check),
|
||||
),
|
||||
buttons::text(
|
||||
ui,
|
||||
Button::new(pick.cancel, intent(PackMessage::CancelImport)),
|
||||
),
|
||||
]
|
||||
.spacing(ui.space(Space::X2))
|
||||
.align_y(Alignment::Center),
|
||||
)
|
||||
.padding(Padding {
|
||||
top: ui.space(Space::X2),
|
||||
..Padding::ZERO
|
||||
}),
|
||||
);
|
||||
|
||||
container(block)
|
||||
@@ -193,6 +205,17 @@ fn pager<'a>(ui: &'a Ui, _catalog: Catalog, pick: &'a Pick) -> Element<'a, PageM
|
||||
Button::new(pick.next, intent(PackMessage::PickPage(pick.page))),
|
||||
)
|
||||
};
|
||||
// 分页行与行内控件共用同一条右锚点(复审 r1 视觉 3):区间文案靠左,两个按钮用
|
||||
// `horizontal_space` 顶到右边,于是「Previous · Next」的右边缘与下面每一行的处置槽对齐。
|
||||
// 两个按钮进一个**右对齐的槽**,槽的右边缘就是行的右边缘 —— 于是它们的右端与下面每行的
|
||||
// 处置槽落在同一条线上(按钮自身的内边距也一起对齐,不会差那十几像素)。
|
||||
let buttons_slot = container(
|
||||
row![previous, next]
|
||||
.spacing(ui.space(Space::X2))
|
||||
.align_y(Alignment::Center),
|
||||
)
|
||||
.width(Length::Fixed(TOGGLE_SLOT * 2.0))
|
||||
.align_x(Alignment::End);
|
||||
row![
|
||||
frame::text_role(
|
||||
ui,
|
||||
@@ -201,8 +224,8 @@ fn pager<'a>(ui: &'a Ui, _catalog: Catalog, pick: &'a Pick) -> Element<'a, PageM
|
||||
pick.range.as_str(),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
),
|
||||
previous,
|
||||
next,
|
||||
iced::widget::Space::new().width(Length::Fill),
|
||||
buttons_slot,
|
||||
]
|
||||
.spacing(ui.space(Space::X2))
|
||||
.align_y(Alignment::Center)
|
||||
@@ -215,17 +238,22 @@ fn pager<'a>(ui: &'a Ui, _catalog: Catalog, pick: &'a Pick) -> Element<'a, PageM
|
||||
/// 为什么两行:单行里塞四个字段(路径 / 来源 / 体积 / 按钮)会把路径压成一条窄柱,
|
||||
/// 出图实测每行折成四五段——扫读成本比两行高得多。
|
||||
fn pick_row<'a>(ui: &'a Ui, item: &'a PickRow) -> Element<'a, PageMessage> {
|
||||
// 两种取值都是**同一种几何**(等高的 pill),只是填充不同:
|
||||
// · 当前=覆盖 ⇒ **tonal 填充**(这一行会被盖掉,是"待反悔"的那一档)
|
||||
// · 当前=保留 ⇒ **描边**(缺省档;点了才会覆盖)
|
||||
// 两种 pill 再各自右对齐进同宽度的槽,于是**右边缘落在同一条线**(复审 r1 视觉 1 的必修项)——
|
||||
// 之前一边是描边 pill、一边是无容器的文字按钮,宽窄与右端都不一致。
|
||||
let toggle = if item.overwrites() {
|
||||
// 已经是覆盖 ⇒ 用描边按钮(弱化:覆盖是更危险的那一侧),字面写"点了会退回保留"
|
||||
buttons::outlined(
|
||||
buttons::tonal(
|
||||
ui,
|
||||
Button::new(
|
||||
item.toggle,
|
||||
intent(PackMessage::TogglePick(item.path.clone())),
|
||||
),
|
||||
)
|
||||
.leading(IconKind::Refresh),
|
||||
)
|
||||
} else {
|
||||
buttons::text(
|
||||
buttons::outlined(
|
||||
ui,
|
||||
Button::new(
|
||||
item.toggle,
|
||||
@@ -234,19 +262,29 @@ fn pick_row<'a>(ui: &'a Ui, item: &'a PickRow) -> Element<'a, PageMessage> {
|
||||
.leading(IconKind::Refresh),
|
||||
)
|
||||
};
|
||||
let head = row![
|
||||
crate::pages::frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
item.path.as_str(),
|
||||
ui.color(Role::OnSurface),
|
||||
),
|
||||
toggle,
|
||||
]
|
||||
.spacing(ui.space(Space::X2))
|
||||
.align_y(Alignment::Center)
|
||||
// 路径那半列必须自己吃掉剩余宽度(`Length::Fill`),否则处置槽会紧跟文字、离右边缘一大截,
|
||||
// 与分页行的右锚点对不上(出图实测:行内按钮停在 x≈680、分页按钮在 x≈780)。
|
||||
let path_slot = container(frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
item.path.as_str(),
|
||||
ui.color(Role::OnSurface),
|
||||
))
|
||||
.width(Length::Fill);
|
||||
// 处置按钮自己那一格:定宽 + 右对齐 ⇒ 两种取值的 pill 右边缘落在同一条线上。
|
||||
// 右侧再留 8dp:滚动条就贴在内容右边缘(出图实测 x≈791),pill 紧贴着它显得挤。
|
||||
let toggle_slot = container(toggle)
|
||||
.width(Length::Fixed(TOGGLE_SLOT))
|
||||
.align_x(Alignment::End)
|
||||
.padding(Padding {
|
||||
right: ui.space(Space::X2),
|
||||
..Padding::ZERO
|
||||
});
|
||||
let head = row![path_slot, toggle_slot]
|
||||
.spacing(ui.space(Space::X2))
|
||||
.align_y(Alignment::Center)
|
||||
.width(Length::Fill);
|
||||
|
||||
let detail = frame::text_role_owned(
|
||||
ui,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -251,6 +251,33 @@ pub(super) fn failed_report(
|
||||
label: &str,
|
||||
message: &str,
|
||||
) -> ImportReportView {
|
||||
failed_report_with(
|
||||
target,
|
||||
pack,
|
||||
ImportErrorView {
|
||||
code: code.to_string(),
|
||||
label: label.to_string(),
|
||||
message: message.to_string(),
|
||||
hint: None,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// 一条 core 的 [`ImportError`] → 失败报告(**逐字段照搬**,含 `hint`)。
|
||||
///
|
||||
/// 预检失败(`scan.error`)走它:那些错本来就是 core 判出来的(`pack_traversal` /
|
||||
/// `pack_limits` / `pack_metadata` …),没必要在宿主层再解释一遍,更不能把 `hint`
|
||||
/// ("先装好游戏再导出"这种可执行下一步)丢掉——丢掉的话用户看到的就是"失败了,然后呢"。
|
||||
pub(super) fn failed_report_of(
|
||||
target: PathBuf,
|
||||
pack: &Path,
|
||||
error: &ImportError,
|
||||
) -> ImportReportView {
|
||||
failed_report_with(target, pack, error_view(error))
|
||||
}
|
||||
|
||||
/// 组装:形状只此一处,文案由调用方给。
|
||||
fn failed_report_with(target: PathBuf, pack: &Path, error: ImportErrorView) -> ImportReportView {
|
||||
ImportReportView {
|
||||
status: ImportStatusView::Failed,
|
||||
format: None,
|
||||
@@ -265,12 +292,7 @@ pub(super) fn failed_report(
|
||||
missing: Vec::new(),
|
||||
rejected: Vec::new(),
|
||||
warnings: Vec::new(),
|
||||
error: Some(ImportErrorView {
|
||||
code: code.to_string(),
|
||||
label: label.to_string(),
|
||||
message: message.to_string(),
|
||||
hint: None,
|
||||
}),
|
||||
error: Some(error),
|
||||
registered: None,
|
||||
bytes_written: 0,
|
||||
rolled_back: true,
|
||||
|
||||
@@ -82,11 +82,29 @@ pub(super) fn spawn_scan(
|
||||
if cancel.load(Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
// 先发预检结果(`error` 也照原样带出去:调用方按"有没有冲突"分流要用它)。
|
||||
send(
|
||||
&tx,
|
||||
epoch,
|
||||
PackEvent::Scan(Box::new(convert::scan_view(&scan))),
|
||||
);
|
||||
// **预检自己没算出来 ⇒ 必须再发一条结果事件**(复审 r1 P2-1)。
|
||||
//
|
||||
// 只发 `Scan(error)` 的话,页面那边 `pick` 为空(它只由 `conflicts` 生成)、进度与报告
|
||||
// 又被 `Scan` 清空 ⇒ 落回入口态,用户点完「导入」屏幕上一个字都不变(静默空转)。
|
||||
// 走 `Report` 就回到 M160 之前那条既有形状:结果块的「致命 —— 已回滚」把原因画出来,
|
||||
// UI 一行不用改;`code`/`label`/`message`/`hint` 全部从 `scan.error` 搬。
|
||||
if let Some(error) = &scan.error {
|
||||
send(
|
||||
&tx,
|
||||
epoch,
|
||||
PackEvent::Report(Box::new(convert::failed_report_of(
|
||||
target,
|
||||
&request.pack,
|
||||
error,
|
||||
))),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -183,3 +183,121 @@ fn the_scan_runs_without_writing_and_says_nothing_when_there_is_no_conflict() {
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// 事件序列的名字(断言"用户到底能不能看见"用)。
|
||||
fn kind_of(event: &crate::dto::PackEvent) -> &'static str {
|
||||
match event {
|
||||
crate::dto::PackEvent::Progress(_) => "progress",
|
||||
crate::dto::PackEvent::Scan(scan) if scan.error.is_some() => "scan-with-error",
|
||||
crate::dto::PackEvent::Scan(_) => "scan",
|
||||
crate::dto::PackEvent::Report(report) if report.error.is_some() => "report-failed",
|
||||
crate::dto::PackEvent::Report(_) => "report",
|
||||
crate::dto::PackEvent::Probe(_) => "probe",
|
||||
crate::dto::PackEvent::Preview(_) => "preview",
|
||||
crate::dto::PackEvent::Exported(_) => "exported",
|
||||
crate::dto::PackEvent::ExportFailed(_) => "export-failed",
|
||||
crate::dto::PackEvent::Cancelled => "cancelled",
|
||||
crate::dto::PackEvent::Cleared => "cleared",
|
||||
}
|
||||
}
|
||||
|
||||
/// 跑一次 `spawn_scan`,把事件按顺序收回来。
|
||||
fn run_scan(source: std::path::PathBuf, data: &std::path::Path) -> Vec<crate::dto::PackEvent> {
|
||||
let env = crate::host::HostEnv::at_root(data);
|
||||
let (tx, rx) = crate::host::job::channel();
|
||||
let request = crate::dto::ImportRequestView {
|
||||
pack: source,
|
||||
instance: None,
|
||||
instance_name: Some("Scan Rework".to_string()),
|
||||
decisions: ConflictDecisionsView::default(),
|
||||
};
|
||||
super::super::import::spawn_scan(
|
||||
env,
|
||||
tx,
|
||||
request,
|
||||
3,
|
||||
std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
);
|
||||
let mut events = Vec::new();
|
||||
for task in rx.iter() {
|
||||
if let crate::host::job::HostTask::Pack { event, epoch } = task {
|
||||
assert_eq!(epoch, 3, "代次必须原样带回");
|
||||
events.push(*event);
|
||||
}
|
||||
}
|
||||
events
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_scan_still_reaches_the_screen_as_a_report() {
|
||||
// 复审 r1 P2-1 的回归钉:**这条用例验的是"用户看得见",不是"字段被填了"**。
|
||||
//
|
||||
// 达标形状(旧行为 / M160 之前):`Import` 失败 → `PackEvent::Report` → 结果块画「已回滚」+ 原因。
|
||||
// 出错形状(本 diff 引入的回归):只发 `Scan(error)` ⇒ 页面 pick 为空、report 被清空 ⇒ 入口态,
|
||||
// 屏幕上什么都不发生。所以这里逐一断言**事件序列**,而不只看 `scan.error`。
|
||||
//
|
||||
// 可达面:detect 认得、read 才失败的包。这里用最普通的一种——MultiMC 的
|
||||
// `formatVersion: 2`(Prism 自己也会整份丢弃,不是刻意构造的畸形包)。
|
||||
let dir = temp_dir("scan-error");
|
||||
let source = dir.join("Prism 2");
|
||||
write(
|
||||
&source.join("mmc-pack.json"),
|
||||
br#"{"formatVersion": 2, "components": [
|
||||
{"uid": "net.minecraft", "version": "1.20.1"}]}"#,
|
||||
);
|
||||
write(&source.join(".minecraft/mods/sodium.jar"), &[0x11; 32]);
|
||||
let data = dir.join("data");
|
||||
std::fs::create_dir_all(&data).expect("data root");
|
||||
|
||||
let events = run_scan(source, &data);
|
||||
let kinds: Vec<&str> = events.iter().map(kind_of).collect();
|
||||
assert_eq!(
|
||||
kinds,
|
||||
vec!["scan-with-error", "report-failed"],
|
||||
"预检自己失败时必须再发一条结果事件(否则页面静默空转)"
|
||||
);
|
||||
|
||||
let crate::dto::PackEvent::Scan(scan) = &events[0] else {
|
||||
panic!("第一条必须是预检结果");
|
||||
};
|
||||
let error = scan.error.as_ref().expect("预检要带出错误");
|
||||
assert_eq!(
|
||||
error.code, "pack_metadata",
|
||||
"formatVersion != 1 是清单自身非法"
|
||||
);
|
||||
assert!(
|
||||
error.message.contains("formatVersion"),
|
||||
"原因要能读:{}",
|
||||
error.message
|
||||
);
|
||||
|
||||
// 结果事件:三态是 Failed、原因与预检那份同源(code/label/message 都搬过来)。
|
||||
let crate::dto::PackEvent::Report(report) = &events[1] else {
|
||||
panic!("第二条必须是结果");
|
||||
};
|
||||
assert_eq!(report.status, crate::dto::ImportStatusView::Failed);
|
||||
let reported = report.error.as_ref().expect("报告要带出原因");
|
||||
assert_eq!(reported.code, error.code);
|
||||
assert_eq!(reported.message, error.message);
|
||||
assert!(!reported.label.is_empty(), "人读大类名也要有");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_clean_scan_does_not_emit_a_report() {
|
||||
// 对照:干净预检只发一条 Scan(多发 Report 会把页面从"清单"顶到"结果")。
|
||||
let dir = temp_dir("scan-clean");
|
||||
let source = dir.join("Vanilla");
|
||||
write(
|
||||
&source.join("mmc-pack.json"),
|
||||
br#"{"formatVersion": 1, "components": [{"uid": "net.minecraft", "version": "1.20.1"}]}"#,
|
||||
);
|
||||
write(&source.join(".minecraft/mods/sodium.jar"), &[0x11; 32]);
|
||||
let data = dir.join("data");
|
||||
std::fs::create_dir_all(&data).expect("data root");
|
||||
|
||||
let kinds: Vec<&str> = run_scan(source, &data).iter().map(kind_of).collect();
|
||||
assert_eq!(kinds, vec!["scan"], "干净预检只发一条");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
Reference in new issue
Block a user