feat(m160): 整合包逐文件覆盖/保留决策 + 导入前冲突清单 + 限额口径统一
主任务(逐文件覆盖/保留)
- core: 新增 import/scan.rs —— 只读预检产出「冲突清单」(目标已存在且 SHA-1 不同才进),
逐条给路径/来源/两侧体积与哈希;用户删过的、内容相同的、被叠层遮蔽的一律不打扰。
- core: 新增 import/decision.rs —— ConflictDecisions(整批档 + 逐项表,逐项优先),
缺省档恒为 KeepUser;ImportRequest.conflict_policy 改由它承载,CLI 调用点不变。
- core: phase.rs 的 settle 按 path 取决策;非冲突路径不受决策影响。
- presenter: ImportScanView / ImportConflictView / ConflictDecisionsView(全部保留 /
全部覆盖 / 逐项{default,items})+ PackCommand::Scan / PackEvent::Scan;host 新增只读
spawn_scan,且新实例路径不再提前建目录(target_of 只算路径,prepare_target 才建)。
- app-iced: 导入前冲突清单页(分页 40 条/页、三入口、计数摘要、确认/取消、两行式行布局);
预检干净时直接往下导,不打扰用户。出图见 C:/Temp/m160-shots/{pick-light,pick-dark}.png。
- i18n: 新增 18 条 en/zh 文案 + 更新「两者都留」那条口径说明。
限额口径统一
- pack/archive/limits.rs 成为唯一来源:PACK_MAX_ENTRIES/UNCOMPRESSED_BYTES/ENTRY_BYTES
(20k / 512 MiB / 64 MiB);读侧 PackLimits::default 与写侧 WriteLimits::production 同源。
- 写侧不再直接用 zip::ZIP64_BYTES_THR;新增单条目闸,于是「能导出的包一定能导入」。
不等式在 pack/tests/limits.rs 里用 const 断言钉死。
测试临时物泄漏(塔台追加项)
- addon/temp.rs:TempRoot —— Drop 即删(panic 展开同样删),Deref/AsRef 到落点路径,
under()/at() 支持"子树落点、删父根"。形状照 core::update::tests::support::TempDir。
- addon::pack 与 addon::tests 两族全部改用 TempRoot(含 fixtures::Fixture 持守卫、
multimc/fabric_instance 等返回守卫);4 GiB 稀疏文件的语义一字未改,只改"谁负责删"。
- 自证:pack/tests/mod.rs 三条用例(drop 删 / panic 也删 / 子树删父根)。
测试
- import_decisions.rs(11 例):预检清单、不打扰、逐项决策后的最终文件集、基线记包内哈希。
- roundtrip_three.rs(2 例):叠层 + 冲突夹具 import→export→import 三轮文件集逐字节一致,
以及「选保留就按设计分叉」的反向证据。
- pack/tests/limits.rs(5 例):单条/总量边界、读侧用同一批常量、写出即可读回。
证据:cargo fmt --check 干净;cargo clippy --workspace --all-targets -D warnings 干净;
cargo test --workspace --all-targets -j 4 → 2507 passed / 0 failed(51 个 test binary)。
This commit is contained in:
1 parent
a4f096eea3
commit
4defc41e8d
64 files changed
+3819
-442
No files matched your search
@@ -47,6 +47,7 @@ mod appearance;
|
||||
mod boot_env;
|
||||
mod clock;
|
||||
mod content;
|
||||
mod content_pack;
|
||||
mod download;
|
||||
mod instances;
|
||||
mod page_messages;
|
||||
|
||||
@@ -111,12 +111,26 @@ impl App {
|
||||
if events.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut clean_scan = false;
|
||||
for event in events {
|
||||
// M160:预检之后**没有冲突**就不打扰用户——直接带着"全部保留"往下导。
|
||||
if matches!(
|
||||
&event,
|
||||
unlucky_presenter::dto::AppEvent::Core(unlucky_presenter::dto::CoreEvent::Pack(
|
||||
pack
|
||||
)) if matches!(&**pack, unlucky_presenter::dto::PackEvent::Scan(scan) if scan.is_clean())
|
||||
) {
|
||||
clean_scan = true;
|
||||
}
|
||||
self.state.apply(event);
|
||||
}
|
||||
self.view_dirty = true;
|
||||
self.auto_select();
|
||||
self.refresh_view();
|
||||
// 派发放在循环之后:`dispatch` 自己会再 pump 一次,在循环里调会把事件顺序搅乱。
|
||||
if clean_scan {
|
||||
self.continue_import_after_clean_scan();
|
||||
}
|
||||
}
|
||||
|
||||
/// 首屏快照到位后**自动选中第一行**(桌面 list-detail 的惯例:右栏不该开局就是空的)。
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
|
||||
use unlucky_presenter::dto::{
|
||||
AppCommand, ContentInstallRequest, ContentQuery, ContentSource, ExportRequestView,
|
||||
ImportRequestView, PackCommand,
|
||||
};
|
||||
|
||||
use crate::pages::content::{Blocker, Content};
|
||||
@@ -104,149 +103,11 @@ impl App {
|
||||
Task::none()
|
||||
}
|
||||
|
||||
/// 导入/导出区的消息(M113)。
|
||||
///
|
||||
/// 两条口径:
|
||||
///
|
||||
/// 1. **页内选择改本地状态**(路径草稿 / 目标实例 / 格式 / 包名 / 展开的冲突)——
|
||||
/// 它们不属于业务状态,不进 presenter(同 `QueryChanged` 的处理);
|
||||
/// 2. **意图只派指令,不做乐观回显**(导入 / 预检 / 确认写盘 / 关面板):进度、报告、预检结果
|
||||
/// 全部由 `CoreEvent::Pack` 回来(`dto::pack::event` 的 `PackState::apply` 是唯一写入点)。
|
||||
pub(super) fn on_pack(&mut self, message: pages::content::PackMessage) {
|
||||
use pages::content::PackMessage;
|
||||
match message {
|
||||
PackMessage::Dropped(path) => {
|
||||
// 拖进来的包属于「整合包」这一类:顺手把类型切过去,否则这一块根本不在屏上
|
||||
self.content.kind = unlucky_presenter::dto::ContentKind::Modpack;
|
||||
self.content.pack.draft = path.display().to_string();
|
||||
self.content.pack.hover = false;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::Hover(hover) => {
|
||||
self.content.pack.hover = hover;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::PathChanged(path) => {
|
||||
self.content.pack.draft = path;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::SetInstance(instance) => {
|
||||
self.content.pack.instance = instance;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::SetInstanceName(name) => {
|
||||
self.content.pack.instance_name = name;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::SetFormat(format) => {
|
||||
self.content.pack.format = format;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::SetExportName(name) => {
|
||||
self.content.pack.export_name = name;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::SetExportVersion(version) => {
|
||||
self.content.pack.export_version = version;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::SetOutput(path) => {
|
||||
self.content.pack.output = path;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::ToggleConflict(path) => {
|
||||
self.content.pack.expanded = if self.content.pack.expanded.as_deref() == Some(&path)
|
||||
{
|
||||
None
|
||||
} else {
|
||||
Some(path)
|
||||
};
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::OpenExport => {
|
||||
self.content.pack.export_open = true;
|
||||
// 包名缺省取目标实例名(用户不该为一个名字再敲一遍键盘)
|
||||
if self.content.pack.export_name.trim().is_empty() {
|
||||
self.content.pack.export_name =
|
||||
self.content.pack.instance.clone().unwrap_or_default();
|
||||
}
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::AbortExport => {
|
||||
self.content.pack.export_open = false;
|
||||
// 连预检结果一起清:留着会让用户以为「还能再点一次确认」
|
||||
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Dismiss)));
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::Dismiss => {
|
||||
// 报告 / 探测诊断 / 预检都在 presenter 里:关面板 = 让 host 清掉它们
|
||||
// (`PackEvent::Cleared`),于是界面回到入口态,浏览网格重新露出来
|
||||
self.content.pack.expanded = None;
|
||||
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Dismiss)));
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::Import => {
|
||||
if let Some(request) = self.pack_import_request(false) {
|
||||
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Import(Box::new(
|
||||
request,
|
||||
)))));
|
||||
}
|
||||
}
|
||||
PackMessage::Overwrite => {
|
||||
// 「以包为准重新导入」:整次导入翻成 Overwrite 档(core 只有全局档)
|
||||
if let Some(request) = self.pack_import_request(true) {
|
||||
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Import(Box::new(
|
||||
request,
|
||||
)))));
|
||||
}
|
||||
}
|
||||
PackMessage::Cancel => {
|
||||
// 与面板上那个「取消」同一个指令:host 会把导入的取消标记与代次一起处理
|
||||
self.dispatch(AppCommand::CancelInstall);
|
||||
}
|
||||
PackMessage::Precheck => {
|
||||
if let Some(request) = self.pack_export_request() {
|
||||
// 先记下「这次预检对的是哪份请求」,确认写盘时原样发回
|
||||
self.state.pack.set_export_request(request.clone());
|
||||
self.view_dirty = true;
|
||||
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Export(Box::new(
|
||||
request,
|
||||
)))));
|
||||
}
|
||||
}
|
||||
PackMessage::Confirm => {
|
||||
if let Some(request) = self.state.pack.export.request.clone() {
|
||||
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Confirm(Box::new(
|
||||
request,
|
||||
)))));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 组一份导入请求(路径草稿 → DTO)。草稿为空 ⇒ `None`(按钮不该发指令)。
|
||||
///
|
||||
/// `overwrite` 只有「以包为准重新导入」会给 `true`;默认路径永远是 `false`
|
||||
/// (core 那边就是 `ConflictPolicy::KeepUser`,**绝不默认覆盖用户改过的文件**)。
|
||||
fn pack_import_request(&self, overwrite: bool) -> Option<ImportRequestView> {
|
||||
let draft = self.content.pack.draft.trim();
|
||||
if draft.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let name = self.content.pack.instance_name.trim();
|
||||
Some(ImportRequestView {
|
||||
pack: std::path::PathBuf::from(draft),
|
||||
instance: self.content.pack.instance.clone(),
|
||||
instance_name: (!name.is_empty()).then(|| name.to_string()),
|
||||
overwrite,
|
||||
})
|
||||
}
|
||||
|
||||
/// 组一份导出请求(预检与写盘**共用同一份**,确认时原样发回)。
|
||||
///
|
||||
/// 没有选中实例就没有导出对象 ⇒ `None`。**「Save to」必须真的进请求**
|
||||
/// (复审 r1 P1-1:以前这里硬编码 `target: None`,那个输入框是死的)。
|
||||
fn pack_export_request(&self) -> Option<ExportRequestView> {
|
||||
pub(super) fn pack_export_request(&self) -> Option<ExportRequestView> {
|
||||
let instance = self.content.pack.instance.clone()?;
|
||||
Some(pages::content::request_export(
|
||||
&self.content.pack,
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
//! 整合包导入/导出区的消息落点(M113 + M160):**从 `content.rs` 拆出来守 400 行硬线**。
|
||||
//!
|
||||
//! ## 为什么单独一个文件
|
||||
//!
|
||||
//! M160 给这一区加了预检、冲突清单、逐项决策三条通道,`content.rs` 到 409 行越线。拆的口径与
|
||||
//! 仓库其它拆分一致:**纯搬移**,`on_pack` 及其私有 helper(组请求)原样搬过来,别处一行不改。
|
||||
//!
|
||||
//! ## 两条口径(与原来同一句)
|
||||
//!
|
||||
//! 1. **页内选择改本地状态**(路径草稿 / 目标实例 / 格式 / 包名 / 展开的冲突)——它们不属于业务
|
||||
//! 状态,不进 presenter;
|
||||
//! 2. **意图只派指令,不做乐观回显**(导入 / 预检 / 确认 / 关面板):进度、报告、预检结果全部由
|
||||
//! `CoreEvent::Pack` 回来(`dto::pack::event` 的 `PackState::apply` 是唯一写入点)。
|
||||
//!
|
||||
//! ## M160 加的那条纪律
|
||||
//!
|
||||
//! 点「导入」先发 `PackCommand::Scan`(只读),拿到 `PackEvent::Scan` 之后:**没有冲突就直接往下导**
|
||||
//! (`clock::pump` 里的 `continue_import_after_clean_scan`),有冲突才画清单等用户点头。决策的缺省
|
||||
//! 档永远是 `KeepAll`——没问过用户就不会覆盖。
|
||||
|
||||
use unlucky_presenter::dto::{
|
||||
AppCommand, ConflictChoiceView, ConflictDecisionsView, ImportRequestView, PackCommand,
|
||||
};
|
||||
|
||||
use crate::pages;
|
||||
|
||||
use super::*;
|
||||
|
||||
impl App {
|
||||
/// 导入/导出区的消息(M113)。
|
||||
///
|
||||
/// 两条口径:
|
||||
///
|
||||
/// 1. **页内选择改本地状态**(路径草稿 / 目标实例 / 格式 / 包名 / 展开的冲突)——
|
||||
/// 它们不属于业务状态,不进 presenter(同 `QueryChanged` 的处理);
|
||||
/// 2. **意图只派指令,不做乐观回显**(导入 / 预检 / 确认写盘 / 关面板):进度、报告、预检结果
|
||||
/// 全部由 `CoreEvent::Pack` 回来(`dto::pack::event` 的 `PackState::apply` 是唯一写入点)。
|
||||
pub(super) fn on_pack(&mut self, message: pages::content::PackMessage) {
|
||||
use pages::content::PackMessage;
|
||||
match message {
|
||||
PackMessage::Dropped(path) => {
|
||||
// 拖进来的包属于「整合包」这一类:顺手把类型切过去,否则这一块根本不在屏上
|
||||
self.content.kind = unlucky_presenter::dto::ContentKind::Modpack;
|
||||
self.content.pack.draft = path.display().to_string();
|
||||
self.content.pack.hover = false;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::Hover(hover) => {
|
||||
self.content.pack.hover = hover;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::PathChanged(path) => {
|
||||
self.content.pack.draft = path;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::SetInstance(instance) => {
|
||||
self.content.pack.instance = instance;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::SetInstanceName(name) => {
|
||||
self.content.pack.instance_name = name;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::SetFormat(format) => {
|
||||
self.content.pack.format = format;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::SetExportName(name) => {
|
||||
self.content.pack.export_name = name;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::SetExportVersion(version) => {
|
||||
self.content.pack.export_version = version;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::SetOutput(path) => {
|
||||
self.content.pack.output = path;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::ToggleConflict(path) => {
|
||||
self.content.pack.expanded = if self.content.pack.expanded.as_deref() == Some(&path)
|
||||
{
|
||||
None
|
||||
} else {
|
||||
Some(path)
|
||||
};
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::OpenExport => {
|
||||
self.content.pack.export_open = true;
|
||||
// 包名缺省取目标实例名(用户不该为一个名字再敲一遍键盘)
|
||||
if self.content.pack.export_name.trim().is_empty() {
|
||||
self.content.pack.export_name =
|
||||
self.content.pack.instance.clone().unwrap_or_default();
|
||||
}
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::AbortExport => {
|
||||
self.content.pack.export_open = false;
|
||||
// 连预检结果一起清:留着会让用户以为「还能再点一次确认」
|
||||
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Dismiss)));
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::Dismiss => {
|
||||
// 报告 / 探测诊断 / 预检都在 presenter 里:关面板 = 让 host 清掉它们
|
||||
// (`PackEvent::Cleared`),于是界面回到入口态,浏览网格重新露出来
|
||||
self.content.pack.expanded = None;
|
||||
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Dismiss)));
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::Import => {
|
||||
// 先预检(M160):没有冲突就直接导,有冲突就把清单画出来等用户点头。
|
||||
self.content.pack.pick_page = 0;
|
||||
self.content.pack.pick_choices.clear();
|
||||
self.content.pack.pick_overwrite_all = false;
|
||||
if let Some(request) = self.pack_import_request() {
|
||||
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Scan(Box::new(
|
||||
request,
|
||||
)))));
|
||||
}
|
||||
}
|
||||
PackMessage::PickPage(page) => {
|
||||
self.content.pack.pick_page = page;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::TogglePick(path) => {
|
||||
// 点上就翻到另一侧;翻回"保留"时把这条从表里删掉(表里只有"偏离缺省档"的项)
|
||||
let choice = self.content.pack.pick_choice(&path);
|
||||
let flipped = match choice {
|
||||
ConflictChoiceView::KeepUser => ConflictChoiceView::Overwrite,
|
||||
ConflictChoiceView::Overwrite => ConflictChoiceView::KeepUser,
|
||||
};
|
||||
self.content.pack.pick_choices.insert(path, flipped);
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::PickKeepAll => {
|
||||
self.content.pack.pick_overwrite_all = false;
|
||||
self.content.pack.pick_choices.clear();
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::PickOverwriteAll => {
|
||||
self.content.pack.pick_overwrite_all = true;
|
||||
self.content.pack.pick_choices.clear();
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::ConfirmImport => {
|
||||
let decisions = self
|
||||
.content
|
||||
.pack
|
||||
.decisions(self.state.pack.import.scan.as_ref());
|
||||
if let Some(request) = self.pack_import_request_with(decisions) {
|
||||
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Import(Box::new(
|
||||
request,
|
||||
)))));
|
||||
}
|
||||
}
|
||||
PackMessage::CancelImport => {
|
||||
// 取消 = 什么都不导(预检是只读的,没有东西要撤)
|
||||
self.content.pack.pick_page = 0;
|
||||
self.content.pack.pick_choices.clear();
|
||||
self.content.pack.pick_overwrite_all = false;
|
||||
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Dismiss)));
|
||||
self.view_dirty = true;
|
||||
}
|
||||
PackMessage::Overwrite => {
|
||||
// 「以包为准重新导入」:这条是**导入之后**从报告里点的,所以直接带整批覆盖再跑一遍。
|
||||
if let Some(request) =
|
||||
self.pack_import_request_with(ConflictDecisionsView::OverwriteAll)
|
||||
{
|
||||
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Import(Box::new(
|
||||
request,
|
||||
)))));
|
||||
}
|
||||
}
|
||||
PackMessage::Cancel => {
|
||||
// 与面板上那个「取消」同一个指令:host 会把导入的取消标记与代次一起处理
|
||||
self.dispatch(AppCommand::CancelInstall);
|
||||
}
|
||||
PackMessage::Precheck => {
|
||||
if let Some(request) = self.pack_export_request() {
|
||||
// 先记下「这次预检对的是哪份请求」,确认写盘时原样发回
|
||||
self.state.pack.set_export_request(request.clone());
|
||||
self.view_dirty = true;
|
||||
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Export(Box::new(
|
||||
request,
|
||||
)))));
|
||||
}
|
||||
}
|
||||
PackMessage::Confirm => {
|
||||
if let Some(request) = self.state.pack.export.request.clone() {
|
||||
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Confirm(Box::new(
|
||||
request,
|
||||
)))));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 预检干净(没有冲突)时直接往下导。
|
||||
///
|
||||
/// 这条是"不打扰不该打扰的文件"的另一半:清单为空时弹一屏"共 0 项冲突"只会让人以为出错了。
|
||||
/// 决策固定给 [`ConflictDecisionsView::KeepAll`]——即使预检与导入之间磁盘上突然多出个同名文件,
|
||||
/// 结论也只会是"保留用户的"。
|
||||
pub(in crate::app) fn continue_import_after_clean_scan(&mut self) {
|
||||
if let Some(request) = self.pack_import_request_with(ConflictDecisionsView::KeepAll) {
|
||||
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Import(Box::new(
|
||||
request,
|
||||
)))));
|
||||
}
|
||||
}
|
||||
|
||||
/// 组一份导入请求(路径草稿 → DTO)。草稿为空 ⇒ `None`(按钮不该发指令)。
|
||||
///
|
||||
/// **决策缺省是「全部保留」**([`ConflictDecisionsView::default`]):预检时还不知道有没有冲突,
|
||||
/// 而 core 侧那份缺省同样落在保留上——所以"还没问过用户"绝不会变成"覆盖"。
|
||||
fn pack_import_request(&self) -> Option<ImportRequestView> {
|
||||
self.pack_import_request_with(ConflictDecisionsView::default())
|
||||
}
|
||||
|
||||
/// 组一份带决策的导入请求(逐项 / 整批都从这里出去)。
|
||||
fn pack_import_request_with(
|
||||
&self,
|
||||
decisions: ConflictDecisionsView,
|
||||
) -> Option<ImportRequestView> {
|
||||
let draft = self.content.pack.draft.trim();
|
||||
if draft.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let name = self.content.pack.instance_name.trim();
|
||||
Some(ImportRequestView {
|
||||
pack: std::path::PathBuf::from(draft),
|
||||
instance: self.content.pack.instance.clone(),
|
||||
instance_name: (!name.is_empty()).then(|| name.to_string()),
|
||||
decisions,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -28,14 +28,22 @@
|
||||
//! | [`view`] | 画法(进度**整块**复用下载页的安装面板) |
|
||||
//! | `demo` | 出图夹具(`ULL_CONTENT_PACK` 选态) |
|
||||
//!
|
||||
//! ## M160 新增的一屏
|
||||
//!
|
||||
//! `model/pick.rs` + `view/pick.rs` 是**导入前的冲突清单**:分页(每页 `PAGE_SIZE` 条)、
|
||||
//! 三个入口、计数摘要、确认/取消。它出现的前提是 core 的预检给出了至少一条冲突——
|
||||
//! 没有冲突时页面连这一屏都不画(直接往下导)。
|
||||
//!
|
||||
//! ## 本版明确的边界(报告里同样登记)
|
||||
//!
|
||||
//! - **没有系统文件选择框**:依赖树里没有 `rfd` 一类的对话框依赖,加它要动根 `Cargo.toml`、
|
||||
//! 过许可门禁、还要给 Linux 选后端(GTK / xdg-portal),直接影响「单文件产物」。所以入口是
|
||||
//! **拖拽(真能用)+ 路径输入框**,并且把原因写在界面上([`model::Pack::no_dialog_note`])。
|
||||
//! - **冲突没有逐文件决策**:core 的 `ConflictPolicy` 是整次导入一个全局档(`KeepUser` 默认 /
|
||||
//! `Overwrite`),`ConflictFile` 是**事后账**。所以这一块给的是「逐文件看 + 整体翻档」,
|
||||
//! 文案如实写「两者都留(改名)暂不支持」。逐文件策略已登记为后续 core mission。
|
||||
//! - **冲突是逐文件决策了(M160)**:点「导入」先跑一次**只读预检**(core 的 `scan_pack`),
|
||||
//! 没有冲突就直接导(不打扰用户);有冲突就画那一屏清单——分页([`model::PAGE_SIZE`] 条一页)、
|
||||
//! 「全部保留 / 全部覆盖 / 逐项」三个入口、计数摘要、确认与取消。**缺省永远是保留**:
|
||||
//! 没点过的文件一个都不会被包内内容替换。仍然不支持的是「两者都留(改名)」——那要改落盘
|
||||
//! 布局,不在本轮。
|
||||
|
||||
mod demo;
|
||||
mod message;
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
//!
|
||||
//! | 变量 | 取值 | 作用 |
|
||||
//! |---|---|---|
|
||||
//! | `ULL_CONTENT_PACK` | `importing` / `partial` / `conflict` / `failed` / `export` | 选导入导出区的哪一态(不设 = 只有入口) |
|
||||
//! | `ULL_CONTENT_PACK` | `importing` / `pick` / `partial` / `conflict` / `failed` / `export` | 选导入导出区的哪一态(不设 = 只有入口) |
|
||||
//!
|
||||
//! 夹具**不直接造视图模型**,而是造 presenter 侧的快照(`ImportState` / `ExportState`),
|
||||
//! 再走产品路径同一个 [`Pack::of`]。这样截图里看到的每一行字都是产品路径会画的那一行,
|
||||
@@ -68,6 +68,21 @@ pub(super) fn pack_in(state: &str, lang: Lang) -> (Pack, PackPageState) {
|
||||
|
||||
match state {
|
||||
"importing" => import.progress = Some(progress()),
|
||||
// M160:导入前的冲突清单(分页 + 三个入口 + 计数摘要)
|
||||
"pick" => {
|
||||
import.scan = Some(scan(312));
|
||||
// 停在第 2 页,且已经逐项点过两条「覆盖」——图上能同时看见分页与逐项状态
|
||||
page.pick_page = 1;
|
||||
// 两条落到**第 2 页**的路径上:图上能同时看见「保留」与「覆盖」两种行状态
|
||||
page.pick_choices.insert(
|
||||
"config/generated/module-041.toml".to_string(),
|
||||
unlucky_presenter::dto::ConflictChoiceView::Overwrite,
|
||||
);
|
||||
page.pick_choices.insert(
|
||||
"config/generated/module-044.toml".to_string(),
|
||||
unlucky_presenter::dto::ConflictChoiceView::Overwrite,
|
||||
);
|
||||
}
|
||||
"partial" => import.report = Some(report(ImportStatusView::Partial, false)),
|
||||
"conflict" => {
|
||||
import.report = Some(report(ImportStatusView::Partial, true));
|
||||
@@ -194,6 +209,59 @@ fn conflict(path: &str, reason: ConflictReasonView) -> ConflictFileView {
|
||||
}
|
||||
}
|
||||
|
||||
/// 一条待决冲突(M160 预览清单的一行)。
|
||||
fn pending(index: usize) -> unlucky_presenter::dto::ImportConflictView {
|
||||
// 前几条用真实形态的路径,其余按序号生成:截图里既要有可读的真实感,也要有"很多条"的证据。
|
||||
let path = match index {
|
||||
0 => "config/sodium-options.json".to_string(),
|
||||
1 => "mods/create-1.2.3.jar".to_string(),
|
||||
2 => "config/jei/jei-client.ini".to_string(),
|
||||
3 => "options.txt".to_string(),
|
||||
_ => format!("config/generated/module-{index:03}.toml"),
|
||||
};
|
||||
unlucky_presenter::dto::ImportConflictView {
|
||||
path,
|
||||
source: if index.is_multiple_of(3) {
|
||||
unlucky_presenter::dto::ConflictSourceView::ClientOverrides
|
||||
} else {
|
||||
unlucky_presenter::dto::ConflictSourceView::Overrides
|
||||
},
|
||||
reason: if index.is_multiple_of(2) {
|
||||
unlucky_presenter::dto::ConflictReasonView::PreExisting
|
||||
} else {
|
||||
unlucky_presenter::dto::ConflictReasonView::UserModified
|
||||
},
|
||||
disabled: false,
|
||||
pack_sha1: "9f2c41ab77de3350aa1b8c0d5e6f708192a3b4c5".to_string(),
|
||||
pack_size: 3_800 + (index as u64 * 37) % 900,
|
||||
disk_sha1: "c1d2e3f4050617283940a5b6c7d8e9f0a1b2c3d4".to_string(),
|
||||
disk_size: 4_100 + (index as u64 * 53) % 1_200,
|
||||
base_sha1: (!index.is_multiple_of(2))
|
||||
.then(|| "3a7d90ff11bc22445566ef7788990011aabbccdd".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 预检结果(`count` 条冲突;图上靠分页只画当前这一页)。
|
||||
fn scan(count: usize) -> unlucky_presenter::dto::ImportScanView {
|
||||
let conflicts: Vec<unlucky_presenter::dto::ImportConflictView> =
|
||||
(0..count).map(pending).collect();
|
||||
let lost_bytes = conflicts.iter().map(|each| each.disk_size).sum();
|
||||
let incoming_bytes = conflicts.iter().map(|each| each.pack_size).sum();
|
||||
unlucky_presenter::dto::ImportScanView {
|
||||
target: PathBuf::from("C:/ULL/instances/survival/.minecraft"),
|
||||
format: Some(PackFormatView::Modrinth),
|
||||
pack_name: Some("Better MC".to_string()),
|
||||
pack_version: Some("1.2.0".to_string()),
|
||||
game_version: Some("1.20.1".to_string()),
|
||||
conflicts,
|
||||
unknown_remote: 4,
|
||||
lost_bytes,
|
||||
incoming_bytes,
|
||||
notes: Vec::new(),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 导出预检:12 内联 / 1 跳过(包不完整)。
|
||||
fn preview() -> ExportPreviewView {
|
||||
let mut files = vec![ExportFileView {
|
||||
|
||||
@@ -30,10 +30,22 @@ pub enum Message {
|
||||
Cancel,
|
||||
/// 关掉结果面板(报告 / 致命错误都走这一条)
|
||||
Dismiss,
|
||||
/// 以包为准重新导入(对应 `overwrite = true`;**只在用户显式点过之后**)
|
||||
/// 以包为准重新导入(**只在用户显式点过之后**;载荷是整批覆盖档)
|
||||
Overwrite,
|
||||
/// 展开/收起某一条冲突的三方明细
|
||||
ToggleConflict(String),
|
||||
/// 冲突清单翻页(M160;页码会夹在 `[0, pages)` 里)
|
||||
PickPage(usize),
|
||||
/// 逐项切换某一条的处置(保留 ⇄ 覆盖)
|
||||
TogglePick(String),
|
||||
/// 整批档:全部保留(逐项里"覆盖"的那些一起清掉)
|
||||
PickKeepAll,
|
||||
/// 整批档:全部覆盖
|
||||
PickOverwriteAll,
|
||||
/// 按当前选择开始导入
|
||||
ConfirmImport,
|
||||
/// 取消这次导入(不写盘,回到入口态)
|
||||
CancelImport,
|
||||
/// 换目标实例(`None` = 按包名新建)
|
||||
SetInstance(Option<InstanceId>),
|
||||
/// 换新实例名(`None` 语义由 `SetInstance` 承担,这里只改名字)
|
||||
|
||||
@@ -26,15 +26,22 @@
|
||||
//! 是「已整包回滚」。两者字段类型不同、画的时候颜色与图标也不同,不可能混成一个列表。
|
||||
|
||||
mod export;
|
||||
mod pick;
|
||||
mod progress;
|
||||
mod report;
|
||||
|
||||
pub use export::{format_label, request_of as request_export, Export, Preview};
|
||||
pub use pick::{Pick, PickRow};
|
||||
pub use progress::Running;
|
||||
pub use report::{ConflictRow, Report, Row};
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use unlucky_i18n::{Catalog, Key};
|
||||
use unlucky_presenter::dto::{ExportState, ImportState, InstanceId, PackFormatView, ProbeView};
|
||||
use unlucky_presenter::dto::{
|
||||
ConflictChoiceView, ConflictDecisionsView, ExportState, ImportState, InstanceId,
|
||||
PackFormatView, ProbeView,
|
||||
};
|
||||
|
||||
use crate::pages::content::Target;
|
||||
|
||||
@@ -61,6 +68,12 @@ pub struct PackPageState {
|
||||
pub export_open: bool,
|
||||
/// 展开的那条冲突路径
|
||||
pub expanded: Option<String>,
|
||||
/// 冲突清单翻到第几页(0 起;M160)
|
||||
pub pick_page: usize,
|
||||
/// 逐项决策(**只存"覆盖"的路径**:保留 = 表里没有这条,于是"没点过"永远是保留)
|
||||
pub pick_choices: BTreeMap<String, ConflictChoiceView>,
|
||||
/// 「全部覆盖」这一档是否被点过(整批档;逐项决策仍然优先于它)
|
||||
pub pick_overwrite_all: bool,
|
||||
}
|
||||
|
||||
impl Default for PackPageState {
|
||||
@@ -78,10 +91,65 @@ impl Default for PackPageState {
|
||||
output: String::new(),
|
||||
export_open: false,
|
||||
expanded: None,
|
||||
pick_page: 0,
|
||||
pick_choices: BTreeMap::new(),
|
||||
pick_overwrite_all: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PackPageState {
|
||||
/// 用户在冲突清单上点的处置(逐项优先,其次整批档,缺省保留)。
|
||||
#[must_use]
|
||||
pub fn pick_choice(&self, path: &str) -> ConflictChoiceView {
|
||||
if let Some(choice) = self.pick_choices.get(path) {
|
||||
return *choice;
|
||||
}
|
||||
if self.pick_overwrite_all {
|
||||
ConflictChoiceView::Overwrite
|
||||
} else {
|
||||
ConflictChoiceView::KeepUser
|
||||
}
|
||||
}
|
||||
|
||||
/// 把这一屏的选择变成发给宿主的那份决策。
|
||||
///
|
||||
/// 一个都没点 ⇒ `KeepAll`(与 core 的"全部保留"同一个语义);点过 ⇒ `PerItem { default, items }`,
|
||||
/// 其中 `default` 就是整批档。于是"全部覆盖之后又单独放回两条"也能表达(不需要把剩下几百条
|
||||
/// 全点一遍),而"没点过"永远落在保留上。
|
||||
#[must_use]
|
||||
pub fn decisions(
|
||||
&self,
|
||||
scan: Option<&unlucky_presenter::dto::ImportScanView>,
|
||||
) -> ConflictDecisionsView {
|
||||
use unlucky_presenter::dto::{ConflictDecisionView, ConflictDecisionsView};
|
||||
let default = if self.pick_overwrite_all {
|
||||
ConflictChoiceView::Overwrite
|
||||
} else {
|
||||
ConflictChoiceView::KeepUser
|
||||
};
|
||||
// 只发"与缺省档不同"的项:其余在 core 侧自然落到缺省档。
|
||||
let items: Vec<ConflictDecisionView> = self
|
||||
.pick_choices
|
||||
.iter()
|
||||
.filter(|(_, choice)| **choice != default)
|
||||
.map(|(path, choice)| ConflictDecisionView {
|
||||
path: path.clone(),
|
||||
choice: *choice,
|
||||
})
|
||||
.collect();
|
||||
// 冲突清单为空时,`PerItem` 没有对象,直接给整批档(语义等价、日志更短)。
|
||||
let empty = scan.is_none_or(|scan| scan.conflicts.is_empty());
|
||||
if items.is_empty() || empty {
|
||||
return match default {
|
||||
ConflictChoiceView::KeepUser => ConflictDecisionsView::KeepAll,
|
||||
ConflictChoiceView::Overwrite => ConflictDecisionsView::OverwriteAll,
|
||||
};
|
||||
}
|
||||
ConflictDecisionsView::per_item(default, items)
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个实例候选(导入目标与导出源共用)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PackTarget {
|
||||
@@ -132,6 +200,8 @@ pub struct Pack {
|
||||
pub cancelled_note: &'static str,
|
||||
/// 导入结果
|
||||
pub report: Option<Report>,
|
||||
/// **导入前的冲突清单**(M160:`Some` 就画那一屏,别的东西都让位)
|
||||
pub pick: Option<Pick>,
|
||||
/// 导出块
|
||||
pub export: Export,
|
||||
}
|
||||
@@ -181,6 +251,15 @@ impl Pack {
|
||||
.report
|
||||
.as_ref()
|
||||
.map(|report| report::report_of(report, page, catalog)),
|
||||
pick: import.scan.as_ref().and_then(|scan| {
|
||||
pick::pick_of(
|
||||
scan,
|
||||
page.pick_page,
|
||||
&page.pick_choices,
|
||||
page.pick_overwrite_all,
|
||||
catalog,
|
||||
)
|
||||
}),
|
||||
export: export::export_of(export, page, candidates, catalog),
|
||||
}
|
||||
}
|
||||
@@ -190,6 +269,7 @@ impl Pack {
|
||||
self.running.is_none()
|
||||
&& self.probe.is_none()
|
||||
&& self.report.is_none()
|
||||
&& self.pick.is_none()
|
||||
&& !self.cancelled
|
||||
&& !self.export.open
|
||||
&& self.draft.is_empty()
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
//! 导入**冲突清单**的视图模型(M160):分页、计数、逐项处置。
|
||||
//!
|
||||
//! # 为什么是分页而不是"一次画完"
|
||||
//!
|
||||
//! 冲突清单可能有几千条(一个用户改过很多配置的老实例)。`iced` 的每一行都是真实 widget,
|
||||
//! 一次画几千行会同时拖慢布局与滚动,而用户真正要看的永远只有"这一屏"。所以模型里只出
|
||||
//! **当前这一页**([`PAGE_SIZE`] 条),页面上的「上一页 / 下一页」改的是
|
||||
//! [`PackPageState::pick_page`]——数据本身留在 presenter 的 `ImportScanView` 里,页面不复制。
|
||||
//!
|
||||
//! # 三个入口如何体现在计数上
|
||||
//!
|
||||
//! | 用户点了 | 决策表 | 计数 |
|
||||
//! |---|---|---|
|
||||
//! | 全部保留 | [`ConflictDecisionsView::KeepAll`] | 覆盖 0 |
|
||||
//! | 全部覆盖 | [`ConflictDecisionsView::OverwriteAll`] | 覆盖 = 全部 |
|
||||
//! | 逐项 | `PerItem`(只列"覆盖"的那些,其余按保留) | 覆盖 = 点过的条数 |
|
||||
//!
|
||||
//! 页面显示的两个数直接来自 [`PackPageState::decisions`]、[`PackPageState::pick_choice`] 的计算,
|
||||
//! 与发给 host 的那份决策**同源**——不会出现"界面说覆盖 3 个、实际覆盖 0 个"。
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use unlucky_i18n::{Catalog, Key};
|
||||
use unlucky_presenter::dto::{
|
||||
human_bytes, ConflictChoiceView, ConflictReasonView, ConflictSourceView, ImportConflictView,
|
||||
ImportScanView,
|
||||
};
|
||||
|
||||
/// 一页画多少行(一次画几千行是性能陷阱;40 行在一屏内可读且不滚动太多)。
|
||||
pub const PAGE_SIZE: usize = 40;
|
||||
|
||||
/// 冲突清单块(导入前那一屏)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Pick {
|
||||
/// 标题(`导入前:有 12 个文件已存在且内容不同`)
|
||||
pub title: String,
|
||||
/// 计数摘要(`保留 9 · 覆盖 3 · 覆盖将丢弃 2.4 MB`)
|
||||
pub summary: String,
|
||||
/// 缺省说明("没改动的一律按保留")
|
||||
pub default_note: &'static str,
|
||||
/// 判不了的远端条目(`Some` 才画那一行)
|
||||
pub unknown_note: Option<String>,
|
||||
/// **当前页**的行(最多 [`PAGE_SIZE`] 条)
|
||||
pub rows: Vec<PickRow>,
|
||||
/// 当前页(0 起)
|
||||
pub page: usize,
|
||||
/// 总页数(至少 1)
|
||||
pub pages: usize,
|
||||
/// 区间说明(`第 1-40 项,共 312 项`)
|
||||
pub range: String,
|
||||
/// 保留几条
|
||||
pub keep_count: usize,
|
||||
/// 覆盖几条
|
||||
pub overwrite_count: usize,
|
||||
/// 能不能翻页
|
||||
pub can_prev: bool,
|
||||
/// 能不能翻页
|
||||
pub can_next: bool,
|
||||
/// 三个入口的文案
|
||||
pub keep_all: &'static str,
|
||||
/// 三个入口的文案
|
||||
pub overwrite_all: &'static str,
|
||||
/// 逐项按钮(保留)的文案
|
||||
pub row_keep: &'static str,
|
||||
/// 逐项按钮(覆盖)的文案
|
||||
pub row_overwrite: &'static str,
|
||||
/// 翻页文案
|
||||
pub prev: &'static str,
|
||||
/// 翻页文案
|
||||
pub next: &'static str,
|
||||
/// 确认导入
|
||||
pub confirm: String,
|
||||
/// 取消导入
|
||||
pub cancel: &'static str,
|
||||
/// 「全部保留」这一档当前是否生效(整批档切到保留时高亮)
|
||||
pub keep_all_active: bool,
|
||||
/// 「全部覆盖」这一档当前是否生效
|
||||
pub overwrite_all_active: bool,
|
||||
}
|
||||
|
||||
/// 清单里的一行。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PickRow {
|
||||
/// 实例内路径
|
||||
pub path: String,
|
||||
/// 来源(`包内 overrides/` / `清单 files[]`)
|
||||
pub source: String,
|
||||
/// 成因(`本来就在` / `你改过`)
|
||||
pub reason: String,
|
||||
/// 两侧体积(`你的:2.1 MB · 包内:1.8 MB`)
|
||||
pub sizes: String,
|
||||
/// 当前处置([`ConflictChoiceView::KeepUser`] = 保留)
|
||||
pub choice: ConflictChoiceView,
|
||||
/// 这一行按钮上写的字(点了就变成另一侧)
|
||||
pub toggle: &'static str,
|
||||
}
|
||||
|
||||
impl PickRow {
|
||||
/// 这一行是"覆盖"吗。
|
||||
#[must_use]
|
||||
pub const fn overwrites(&self) -> bool {
|
||||
matches!(self.choice, ConflictChoiceView::Overwrite)
|
||||
}
|
||||
}
|
||||
|
||||
/// 从预检结果 + 页面选择造出这一块(`None` = 没有待决冲突,页面不该画它)。
|
||||
#[must_use]
|
||||
pub fn pick_of(
|
||||
scan: &ImportScanView,
|
||||
page_number: usize,
|
||||
per_item: &BTreeMap<String, ConflictChoiceView>,
|
||||
batch_overwrite: bool,
|
||||
catalog: Catalog,
|
||||
) -> Option<Pick> {
|
||||
if scan.conflicts.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let total = scan.conflicts.len();
|
||||
let pages = total.div_ceil(PAGE_SIZE).max(1);
|
||||
let page = page_number.min(pages - 1);
|
||||
let from = page * PAGE_SIZE;
|
||||
let to = (from + PAGE_SIZE).min(total);
|
||||
|
||||
let rows: Vec<PickRow> = scan.conflicts[from..to]
|
||||
.iter()
|
||||
.map(|conflict| row_of(conflict, per_item, batch_overwrite, catalog))
|
||||
.collect();
|
||||
|
||||
let overwrite_count = scan
|
||||
.conflicts
|
||||
.iter()
|
||||
.filter(|conflict| {
|
||||
effective(conflict.path.as_str(), per_item, batch_overwrite)
|
||||
== ConflictChoiceView::Overwrite
|
||||
})
|
||||
.count();
|
||||
let keep_count = total - overwrite_count;
|
||||
// 覆盖会丢掉多少:只算"你真选了覆盖"的那些,避免用最坏数字吓人。
|
||||
let lost = scan
|
||||
.conflicts
|
||||
.iter()
|
||||
.filter(|conflict| {
|
||||
effective(conflict.path.as_str(), per_item, batch_overwrite)
|
||||
== ConflictChoiceView::Overwrite
|
||||
})
|
||||
.map(|conflict| conflict.disk_size)
|
||||
.fold(0_u64, u64::saturating_add);
|
||||
|
||||
Some(Pick {
|
||||
title: catalog.render(Key::PackPickTitle, &[("count", &total.to_string())]),
|
||||
summary: catalog.render(
|
||||
Key::PackPickSummary,
|
||||
&[
|
||||
("keep", &keep_count.to_string()),
|
||||
("overwrite", &overwrite_count.to_string()),
|
||||
("bytes", &human_bytes(lost)),
|
||||
],
|
||||
),
|
||||
default_note: catalog.text(Key::PackPickDefaultNote),
|
||||
unknown_note: (scan.unknown_remote > 0).then(|| {
|
||||
catalog.render(
|
||||
Key::PackPickUnknownRemote,
|
||||
&[("count", &scan.unknown_remote.to_string())],
|
||||
)
|
||||
}),
|
||||
rows,
|
||||
page,
|
||||
pages,
|
||||
range: catalog.render(
|
||||
Key::PackPickPage,
|
||||
&[
|
||||
("from", &(from + 1).to_string()),
|
||||
("to", &to.to_string()),
|
||||
("total", &total.to_string()),
|
||||
],
|
||||
),
|
||||
keep_count,
|
||||
overwrite_count,
|
||||
can_prev: page > 0,
|
||||
can_next: page + 1 < pages,
|
||||
keep_all: catalog.text(Key::PackPickKeepAll),
|
||||
overwrite_all: catalog.text(Key::PackPickOverwriteAll),
|
||||
row_keep: catalog.text(Key::PackPickRowKeep),
|
||||
row_overwrite: catalog.text(Key::PackPickRowOverwrite),
|
||||
prev: catalog.text(Key::PackPickPrev),
|
||||
next: catalog.text(Key::PackPickNext),
|
||||
confirm: catalog.render(Key::PackPickConfirm, &[]),
|
||||
cancel: catalog.text(Key::PackPickCancel),
|
||||
keep_all_active: !batch_overwrite,
|
||||
overwrite_all_active: batch_overwrite,
|
||||
})
|
||||
}
|
||||
|
||||
/// 一条冲突的**有效**处置:逐项优先,其次整批档,缺省保留(与 core 侧同一条规则)。
|
||||
fn effective(
|
||||
path: &str,
|
||||
per_item: &BTreeMap<String, ConflictChoiceView>,
|
||||
batch_overwrite: bool,
|
||||
) -> ConflictChoiceView {
|
||||
if let Some(choice) = per_item.get(path) {
|
||||
return *choice;
|
||||
}
|
||||
if batch_overwrite {
|
||||
ConflictChoiceView::Overwrite
|
||||
} else {
|
||||
ConflictChoiceView::KeepUser
|
||||
}
|
||||
}
|
||||
|
||||
fn row_of(
|
||||
conflict: &ImportConflictView,
|
||||
per_item: &BTreeMap<String, ConflictChoiceView>,
|
||||
batch_overwrite: bool,
|
||||
catalog: Catalog,
|
||||
) -> PickRow {
|
||||
let choice = effective(conflict.path.as_str(), per_item, batch_overwrite);
|
||||
PickRow {
|
||||
path: conflict.path.clone(),
|
||||
source: source_label(conflict.source, catalog).to_owned(),
|
||||
reason: reason_label(conflict.reason, catalog).to_owned(),
|
||||
sizes: format!(
|
||||
"{} · {}",
|
||||
catalog.render(
|
||||
Key::PackPickDisk,
|
||||
&[("size", &human_bytes(conflict.disk_size))]
|
||||
),
|
||||
catalog.render(
|
||||
Key::PackPickIncoming,
|
||||
&[("size", &human_bytes(conflict.pack_size))]
|
||||
)
|
||||
),
|
||||
choice,
|
||||
// 按钮写"点了会变成什么":当前保留 ⇒ 按钮是「覆盖」。
|
||||
toggle: if matches!(choice, ConflictChoiceView::Overwrite) {
|
||||
catalog.text(Key::PackPickRowKeep)
|
||||
} else {
|
||||
catalog.text(Key::PackPickRowOverwrite)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// 来源的人读串。
|
||||
fn source_label(source: ConflictSourceView, catalog: Catalog) -> &'static str {
|
||||
match source {
|
||||
ConflictSourceView::Overrides => catalog.text(Key::PackPickSourceOverrides),
|
||||
ConflictSourceView::ClientOverrides => catalog.text(Key::PackPickSourceClientOverrides),
|
||||
ConflictSourceView::CurseForge | ConflictSourceView::Modrinth => {
|
||||
catalog.text(Key::PackPickSourceManifest)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 成因的人读串(复用导入报告那三句,不另造一套说法)。
|
||||
fn reason_label(reason: ConflictReasonView, catalog: Catalog) -> &'static str {
|
||||
match reason {
|
||||
ConflictReasonView::UserModified => catalog.text(Key::PackConflictUserModified),
|
||||
ConflictReasonView::UserDeleted => catalog.text(Key::PackConflictUserDeleted),
|
||||
ConflictReasonView::PreExisting => catalog.text(Key::PackConflictPreExisting),
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
//! 这里**不测画法**(画法靠出图),测的是「决策」:哪些数字进了哪一行、哪些字段被截短、
|
||||
//! 哪一态该出现哪一块。所有断言都能在没有图形环境的情况下跑。
|
||||
|
||||
mod pick;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use unlucky_i18n::Lang;
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
//! 冲突清单(M160)的视图模型守门:分页、计数、三个入口 → 决策表。
|
||||
//!
|
||||
//! 这里钉的是**不画**的那部分——一屏只出 `PAGE_SIZE` 行、翻页只换数据、计数与最终决策同源。
|
||||
//! 画法本身靠出图(`ULL_CONTENT_PACK=pick`)。
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use super::super::pick::{pick_of, PAGE_SIZE};
|
||||
use super::super::PackPageState;
|
||||
use super::catalog;
|
||||
use unlucky_presenter::dto::{
|
||||
ConflictChoiceView, ConflictDecisionsView, ConflictReasonView, ConflictSourceView,
|
||||
ImportConflictView, ImportScanView,
|
||||
};
|
||||
|
||||
/// `count` 条冲突的预检结果。
|
||||
fn scan(count: usize) -> ImportScanView {
|
||||
ImportScanView {
|
||||
target: std::path::PathBuf::from("instance"),
|
||||
format: None,
|
||||
pack_name: Some("Better MC".to_string()),
|
||||
pack_version: None,
|
||||
game_version: None,
|
||||
conflicts: (0..count)
|
||||
.map(|index| ImportConflictView {
|
||||
path: format!("config/row-{index:03}.toml"),
|
||||
source: ConflictSourceView::Overrides,
|
||||
reason: ConflictReasonView::PreExisting,
|
||||
disabled: false,
|
||||
pack_sha1: "aa".repeat(20),
|
||||
pack_size: 1_000,
|
||||
disk_sha1: "bb".repeat(20),
|
||||
disk_size: 2_000,
|
||||
base_sha1: None,
|
||||
})
|
||||
.collect(),
|
||||
unknown_remote: 0,
|
||||
lost_bytes: 0,
|
||||
incoming_bytes: 0,
|
||||
notes: Vec::new(),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn pick(
|
||||
scan: &ImportScanView,
|
||||
page: usize,
|
||||
per_item: &BTreeMap<String, ConflictChoiceView>,
|
||||
batch: bool,
|
||||
) -> super::super::pick::Pick {
|
||||
pick_of(scan, page, per_item, batch, catalog()).expect("有冲突就该画这一块")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_current_page_is_drawn() {
|
||||
let scan = scan(312);
|
||||
let first = pick(&scan, 0, &BTreeMap::new(), false);
|
||||
assert_eq!(first.rows.len(), PAGE_SIZE, "一屏只许画一页的行");
|
||||
assert_eq!(first.pages, 8, "312 条 / 40 条一页 = 8 页");
|
||||
assert_eq!(first.page, 0);
|
||||
assert!(first.can_next, "还有下一页");
|
||||
assert!(!first.can_prev, "第一页没有上一页");
|
||||
assert_eq!(first.range, "Items 1-40 of 312");
|
||||
assert_eq!(first.rows[0].path, "config/row-000.toml");
|
||||
|
||||
let second = pick(&scan, 1, &BTreeMap::new(), false);
|
||||
assert_eq!(second.page, 1);
|
||||
assert_eq!(
|
||||
second.rows[0].path,
|
||||
format!("config/row-{PAGE_SIZE:03}.toml")
|
||||
);
|
||||
assert_eq!(second.range, "Items 41-80 of 312");
|
||||
assert!(second.can_prev && second.can_next);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_last_page_is_short_and_the_page_number_is_clamped() {
|
||||
let scan = scan(312);
|
||||
let last = pick(&scan, 7, &BTreeMap::new(), false);
|
||||
assert_eq!(last.rows.len(), 312 - 7 * PAGE_SIZE, "最后一页只剩尾巴");
|
||||
assert_eq!(last.range, "Items 281-312 of 312");
|
||||
assert!(!last.can_next);
|
||||
|
||||
// 页码越界(例如用户先在 300 条的清单上翻到第 6 页,再重扫一份 10 条的)⇒ 夹到最后一页。
|
||||
let clamped = pick(&scan, 99, &BTreeMap::new(), false);
|
||||
assert_eq!(clamped.page, 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_summary_counts_what_the_user_actually_picked() {
|
||||
let scan = scan(10);
|
||||
let keep_all = pick(&scan, 0, &BTreeMap::new(), false);
|
||||
assert_eq!(keep_all.keep_count, 10);
|
||||
assert_eq!(keep_all.overwrite_count, 0);
|
||||
assert!(keep_all.summary.contains("Keep 10"), "{}", keep_all.summary);
|
||||
assert!(keep_all.keep_all_active && !keep_all.overwrite_all_active);
|
||||
|
||||
let mut per_item = BTreeMap::new();
|
||||
per_item.insert(
|
||||
"config/row-001.toml".to_string(),
|
||||
ConflictChoiceView::Overwrite,
|
||||
);
|
||||
per_item.insert(
|
||||
"config/row-004.toml".to_string(),
|
||||
ConflictChoiceView::Overwrite,
|
||||
);
|
||||
let mixed = pick(&scan, 0, &per_item, false);
|
||||
assert_eq!(mixed.overwrite_count, 2);
|
||||
assert_eq!(mixed.keep_count, 8);
|
||||
// 丢弃量只算"真选了覆盖"的那两条(各 2 000 字节)。
|
||||
assert!(
|
||||
mixed.summary.contains("Overwrite 2") || mixed.summary.contains("overwrite 2"),
|
||||
"{}",
|
||||
mixed.summary
|
||||
);
|
||||
assert_eq!(
|
||||
mixed.rows[1].choice,
|
||||
ConflictChoiceView::Overwrite,
|
||||
"点过的那一行要显示成覆盖"
|
||||
);
|
||||
assert_eq!(mixed.rows[0].choice, ConflictChoiceView::KeepUser);
|
||||
|
||||
let all = pick(&scan, 0, &BTreeMap::new(), true);
|
||||
assert_eq!(all.overwrite_count, 10);
|
||||
assert!(all.overwrite_all_active && !all.keep_all_active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_conflict_means_no_block() {
|
||||
let scan = scan(0);
|
||||
assert!(
|
||||
pick_of(&scan, 0, &BTreeMap::new(), false, catalog()).is_none(),
|
||||
"没有冲突就不该画这一屏(不打扰用户)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decisions_default_to_keep_all() {
|
||||
let page = PackPageState::default();
|
||||
assert_eq!(page.decisions(None), ConflictDecisionsView::KeepAll);
|
||||
// 逐项点一个"覆盖" ⇒ 逐项表里只有它,缺省档仍是保留。
|
||||
let mut page = PackPageState::default();
|
||||
page.pick_choices
|
||||
.insert("mods/a.jar".to_string(), ConflictChoiceView::Overwrite);
|
||||
let decisions = page.decisions(Some(&scan(3)));
|
||||
assert_eq!(decisions.default_choice(), ConflictChoiceView::KeepUser);
|
||||
assert_eq!(decisions.overwrite_count(), 1);
|
||||
assert_eq!(decisions.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_overwrite_plus_a_kept_file_is_expressible() {
|
||||
// 全部覆盖之后单独放回一条:缺省档是覆盖、逐项里那条是保留。
|
||||
let mut page = PackPageState {
|
||||
pick_overwrite_all: true,
|
||||
..PackPageState::default()
|
||||
};
|
||||
assert_eq!(
|
||||
page.decisions(Some(&scan(3))),
|
||||
ConflictDecisionsView::OverwriteAll,
|
||||
"只点整批覆盖时不需要逐项表"
|
||||
);
|
||||
page.pick_choices
|
||||
.insert("mods/pin.jar".to_string(), ConflictChoiceView::KeepUser);
|
||||
let decisions = page.decisions(Some(&scan(3)));
|
||||
assert_eq!(decisions.default_choice(), ConflictChoiceView::Overwrite);
|
||||
assert_eq!(decisions.keep_count(), 1);
|
||||
assert_eq!(decisions.overwrite_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggling_flips_the_row_and_clearing_the_batch_drops_per_item_choices() {
|
||||
let mut page = PackPageState::default();
|
||||
let path = "mods/a.jar";
|
||||
assert_eq!(page.pick_choice(path), ConflictChoiceView::KeepUser);
|
||||
page.pick_choices
|
||||
.insert(path.to_string(), ConflictChoiceView::Overwrite);
|
||||
assert_eq!(page.pick_choice(path), ConflictChoiceView::Overwrite);
|
||||
|
||||
// 整批覆盖之后,那条逐项"保留"仍然优先于整批档。
|
||||
page.pick_overwrite_all = true;
|
||||
page.pick_choices
|
||||
.insert(path.to_string(), ConflictChoiceView::KeepUser);
|
||||
assert_eq!(page.pick_choice(path), ConflictChoiceView::KeepUser);
|
||||
assert_eq!(
|
||||
page.pick_choice("mods/other.jar"),
|
||||
ConflictChoiceView::Overwrite
|
||||
);
|
||||
}
|
||||
@@ -8,8 +8,8 @@ use unlucky_i18n::Lang;
|
||||
use super::demo::pack_in;
|
||||
use super::model::Pack;
|
||||
|
||||
/// D30 要求的四态(导入中 / 部分成功 / 冲突选择 / 导出预检)。
|
||||
const STATES: [&str; 4] = ["importing", "partial", "conflict", "export"];
|
||||
/// 出图态:导入中 / **导入前冲突清单(M160)** / 部分成功 / 导入后冲突账 / 导出预检。
|
||||
const STATES: [&str; 5] = ["importing", "pick", "partial", "conflict", "export"];
|
||||
|
||||
#[test]
|
||||
fn every_screenshot_state_fills_something() {
|
||||
@@ -28,6 +28,33 @@ fn every_screenshot_state_fills_something() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_pick_state_shows_a_paginated_conflict_list() {
|
||||
// M160 的那一屏:只画一页、三个入口都在、计数与逐项状态一致。
|
||||
let (model, page) = pack_in("pick", Lang::En);
|
||||
let pick = model.pick.expect("pick 态必须有冲突清单");
|
||||
// 一屏只画一页:行数上限来自 `model::pick::PAGE_SIZE`(40)。
|
||||
assert_eq!(pick.rows.len(), 40, "一屏一页(PAGE_SIZE)");
|
||||
assert_eq!(pick.pages, 8, "312 条 / 40 条一页");
|
||||
assert_eq!(pick.page, 1, "夹具停在第 2 页(0 起)");
|
||||
assert!(pick.can_prev && pick.can_next);
|
||||
assert_eq!(pick.overwrite_count, 2, "夹具逐项点了两条覆盖");
|
||||
assert!(
|
||||
pick.keep_all_active && !pick.overwrite_all_active,
|
||||
"整批档停在缺省那一档(全部保留)"
|
||||
);
|
||||
assert_eq!(
|
||||
pick.rows[0].choice,
|
||||
unlucky_presenter::dto::ConflictChoiceView::KeepUser,
|
||||
"没点过的那一行必须是保留"
|
||||
);
|
||||
// 页面自留的选择与模型一致(逐项那条显示成覆盖)
|
||||
assert_eq!(
|
||||
page.pick_choice("config/generated/module-041.toml"),
|
||||
unlucky_presenter::dto::ConflictChoiceView::Overwrite
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn importing_shows_the_shared_install_panel() {
|
||||
let (model, _page) = pack_in("importing", Lang::En);
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
|
||||
mod export;
|
||||
mod parts;
|
||||
mod pick;
|
||||
mod progress;
|
||||
mod report;
|
||||
|
||||
@@ -64,6 +65,7 @@ use crate::pages::Message as PageMessage;
|
||||
use crate::theme::Ui;
|
||||
|
||||
use self::export::export_block;
|
||||
use self::pick::pick_block;
|
||||
use self::progress::{probe_block, running_block};
|
||||
use self::report::report_block;
|
||||
use super::model::Pack;
|
||||
@@ -88,6 +90,9 @@ pub(crate) fn section<'a>(
|
||||
// 事件,任何时候把文件拖进来都算数。
|
||||
if model.export.open {
|
||||
block = block.push(export_block(ui, catalog, &model.export));
|
||||
} else if let Some(pick) = &model.pick {
|
||||
// 冲突清单优先于一切:用户此刻要做的决定就是"哪几个文件覆盖"
|
||||
block = block.push(pick_block(ui, catalog, pick));
|
||||
} else if let Some(running) = &model.running {
|
||||
block = block.push(running_block(ui, catalog, running));
|
||||
} else if let Some(report) = &model.report {
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
//! 冲突清单那一屏的画法(M160):**分页** + 三个入口 + 计数摘要 + 确认/取消。
|
||||
//!
|
||||
//! ## 版面
|
||||
//!
|
||||
//! ```text
|
||||
//! ┌ 导入前:有 312 个文件已存在且内容不同 ───────────────┐
|
||||
//! │ 保留 309 · 覆盖 3 · 覆盖将丢弃 2.4 MB │
|
||||
//! │ 没有改动的一律按「保留」:… │
|
||||
//! │ [全部保留我的] [全部覆盖] │
|
||||
//! ├ 第 1-40 项,共 312 项 [上一页] [下一页] │
|
||||
//! │ config/sodium-options.json 包内 overrides/ · 本来就在 │
|
||||
//! │ 你的:4.1 KB · 包内:3.8 KB [覆盖]│
|
||||
//! │ …(**只画当前这一页**) │
|
||||
//! ├ [按这些选择导入] [取消导入] │
|
||||
//! └─────────────────────────────────────────────────────┘
|
||||
//! ```
|
||||
//!
|
||||
//! ## 三处「不误导」
|
||||
//!
|
||||
//! 1. **缺省说明写在按钮下面**:用户不必猜"没点过的那 309 条会怎样";
|
||||
//! 2. **计数摘要里的丢弃量只算真选了覆盖的**:用最坏数字吓人不算如实;
|
||||
//! 3. **按钮写的是"点了会变成什么"**(当前保留 ⇒ 按钮写「覆盖」),所以点一下就知道后果。
|
||||
//!
|
||||
//! ## 为什么分页而不是滚动
|
||||
//!
|
||||
//! 几千条冲突一次画完 = 几千个 widget:布局与滚动都卡。分页把 widget 数量钉在
|
||||
//! [`super::super::model::PAGE_SIZE`] 条以内,翻页只是换数据。
|
||||
|
||||
use iced::widget::{column, container, row};
|
||||
use iced::{Alignment, Background, Border, Element, Length};
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_design::icons::IconKind;
|
||||
use unlucky_design::shape::ShapeScale;
|
||||
use unlucky_design::spacing::Space;
|
||||
use unlucky_design::typography::TypeRole;
|
||||
|
||||
use unlucky_i18n::Catalog;
|
||||
|
||||
use crate::app::components::buttons::{self, Button};
|
||||
use crate::app::components::selection::{chip, Chip};
|
||||
use crate::pages::frame;
|
||||
use crate::pages::Message as PageMessage;
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::super::model::{Pick, PickRow};
|
||||
use super::super::PackMessage;
|
||||
use super::intent;
|
||||
|
||||
/// 整块。
|
||||
pub(super) fn pick_block<'a>(
|
||||
ui: &'a Ui,
|
||||
catalog: Catalog,
|
||||
pick: &'a Pick,
|
||||
) -> Element<'a, PageMessage> {
|
||||
let mut block = column![
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::TitleSmall,
|
||||
true,
|
||||
pick.title.as_str(),
|
||||
ui.color(Role::OnSurface),
|
||||
),
|
||||
// 计数摘要:这是"我要丢掉多少"的唯一一处数字,用强调色
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodyMedium,
|
||||
true,
|
||||
pick.summary.as_str(),
|
||||
ui.color(Role::Primary),
|
||||
),
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
pick.default_note,
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
),
|
||||
]
|
||||
.spacing(ui.space(Space::X1))
|
||||
.width(Length::Fill);
|
||||
|
||||
if let Some(note) = &pick.unknown_note {
|
||||
block = block.push(
|
||||
row![
|
||||
frame::glyph(IconKind::Warn, ui.color(Role::Tertiary), frame::ICON_PX),
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
note.as_str(),
|
||||
ui.color(Role::Tertiary),
|
||||
),
|
||||
]
|
||||
.spacing(ui.space(Space::X1))
|
||||
.align_y(Alignment::Center),
|
||||
);
|
||||
}
|
||||
|
||||
// 三个入口:整批保留 / 整批覆盖(逐项就是下面每行那个按钮)
|
||||
block = block.push(
|
||||
row![
|
||||
chip(
|
||||
ui,
|
||||
Chip::filter(
|
||||
pick.keep_all,
|
||||
pick.keep_all_active,
|
||||
intent(PackMessage::PickKeepAll),
|
||||
),
|
||||
),
|
||||
chip(
|
||||
ui,
|
||||
Chip::filter(
|
||||
pick.overwrite_all,
|
||||
pick.overwrite_all_active,
|
||||
intent(PackMessage::PickOverwriteAll),
|
||||
),
|
||||
),
|
||||
]
|
||||
.spacing(ui.space(Space::X2)),
|
||||
);
|
||||
|
||||
// 分页头(当前页之外的行一律不画)
|
||||
block = block.push(bounded(
|
||||
column(
|
||||
std::iter::once(pager(ui, catalog, pick))
|
||||
.chain(pick.rows.iter().map(|item| pick_row(ui, item)))
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.spacing(ui.space(Space::X1))
|
||||
.width(Length::Fill),
|
||||
375.0,
|
||||
));
|
||||
|
||||
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(block)
|
||||
.width(Length::Fill)
|
||||
.padding(ui.space(Space::X3))
|
||||
.style(move |_theme| container::Style {
|
||||
border: Border {
|
||||
color: ui.color(Role::Outline),
|
||||
width: 1.0,
|
||||
radius: ui.radius_dp(ShapeScale::Large),
|
||||
},
|
||||
background: Some(Background::Color(ui.color(Role::Surface))),
|
||||
..container::Style::default()
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 分页头:区间 + 上一页 / 下一页。
|
||||
fn pager<'a>(ui: &'a Ui, _catalog: Catalog, pick: &'a Pick) -> Element<'a, PageMessage> {
|
||||
let previous = if pick.can_prev {
|
||||
buttons::text(
|
||||
ui,
|
||||
Button::new(
|
||||
pick.prev,
|
||||
intent(PackMessage::PickPage(pick.page.saturating_sub(1))),
|
||||
)
|
||||
.leading(IconKind::ChevronDown),
|
||||
)
|
||||
} else {
|
||||
// 到边了就把按钮指回当前页(点了不动的死按钮比"没有按钮"更让人困惑)
|
||||
buttons::text(
|
||||
ui,
|
||||
Button::new(pick.prev, intent(PackMessage::PickPage(pick.page))),
|
||||
)
|
||||
};
|
||||
let next = if pick.can_next {
|
||||
buttons::text(
|
||||
ui,
|
||||
Button::new(pick.next, intent(PackMessage::PickPage(pick.page + 1)))
|
||||
.leading(IconKind::ChevronDown),
|
||||
)
|
||||
} else {
|
||||
buttons::text(
|
||||
ui,
|
||||
Button::new(pick.next, intent(PackMessage::PickPage(pick.page))),
|
||||
)
|
||||
};
|
||||
row![
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::LabelMedium,
|
||||
false,
|
||||
pick.range.as_str(),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
),
|
||||
previous,
|
||||
next,
|
||||
]
|
||||
.spacing(ui.space(Space::X2))
|
||||
.align_y(Alignment::Center)
|
||||
.width(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 一条冲突:**第一行**路径 + 处置按钮,**第二行**来源 · 成因 · 两侧体积。
|
||||
///
|
||||
/// 为什么两行:单行里塞四个字段(路径 / 来源 / 体积 / 按钮)会把路径压成一条窄柱,
|
||||
/// 出图实测每行折成四五段——扫读成本比两行高得多。
|
||||
fn pick_row<'a>(ui: &'a Ui, item: &'a PickRow) -> Element<'a, PageMessage> {
|
||||
let toggle = if item.overwrites() {
|
||||
// 已经是覆盖 ⇒ 用描边按钮(弱化:覆盖是更危险的那一侧),字面写"点了会退回保留"
|
||||
buttons::outlined(
|
||||
ui,
|
||||
Button::new(
|
||||
item.toggle,
|
||||
intent(PackMessage::TogglePick(item.path.clone())),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
buttons::text(
|
||||
ui,
|
||||
Button::new(
|
||||
item.toggle,
|
||||
intent(PackMessage::TogglePick(item.path.clone())),
|
||||
)
|
||||
.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)
|
||||
.width(Length::Fill);
|
||||
|
||||
let detail = frame::text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
format!("{} · {} · {}", item.source, item.reason, item.sizes),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
);
|
||||
|
||||
column![head, detail]
|
||||
.spacing(ui.space(Space::X1))
|
||||
.width(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 有界容器:内容多时自己滚(与结果块的 `bounded` 同一条口径,但这里限高不同)。
|
||||
fn bounded<'a>(
|
||||
content: impl Into<Element<'a, PageMessage>>,
|
||||
height: f32,
|
||||
) -> Element<'a, PageMessage> {
|
||||
iced::widget::scrollable(container(content).width(Length::Fill))
|
||||
.height(Length::Fixed(height))
|
||||
.into()
|
||||
}
|
||||
@@ -6,6 +6,21 @@
|
||||
//! `DevDocs/SubAgentsTalk/addon-整合包导入导出对接要点.md`(施工口径)。
|
||||
//! 格式模型与安全闸是 M110 的 [`crate::addon::pack`]:本模块**只消费,不重写**。
|
||||
//!
|
||||
//! # 逐文件冲突决策(M160):预检 → 用户点头 → 导入
|
||||
//!
|
||||
//! | 步骤 | 入口 | 写盘吗 |
|
||||
//! |------|------|--------|
|
||||
//! | 预检:算出"哪几个文件会盖掉用户的" | [`scan_pack`] | **不写**(只读盘算摘要) |
|
||||
//! | 用户点「全部保留 / 全部覆盖 / 逐项」 | [`ConflictDecisions`] | — |
|
||||
//! | 导入 | [`import_pack`] | 写(按决策处置冲突) |
|
||||
//!
|
||||
//! 三条硬口径(改它们之前先看 [`scan`] 与 [`decision`] 的模块文档):
|
||||
//!
|
||||
//! 1. 进冲突清单的**只有**"目标里已存在同名普通文件且内容(SHA-1)不同"的路径;内容相同、
|
||||
//! 目标不存在、以及用户删过的文件都不打扰用户;
|
||||
//! 2. 决策按**路径**给,逐项优先于整批档,整批档缺省 = 保留;
|
||||
//! 3. 缺省**绝不覆盖**:没收到决策的冲突一律保留用户那一侧([`ConflictChoice::default`])。
|
||||
//!
|
||||
//! # 三条硬要求落在哪
|
||||
//!
|
||||
//! | 要求 | 落点 |
|
||||
@@ -63,6 +78,7 @@
|
||||
//! 磁盘满、真实 CurseForge `files[]` 补全(缺 CF 客户端,见 [`fetch`] 的说明)。
|
||||
|
||||
pub mod baseline;
|
||||
pub mod decision;
|
||||
pub mod error;
|
||||
pub mod fetch;
|
||||
pub mod merge;
|
||||
@@ -70,9 +86,11 @@ pub mod phase;
|
||||
pub mod pipeline;
|
||||
pub mod plan;
|
||||
pub mod report;
|
||||
pub mod scan;
|
||||
pub mod stage;
|
||||
|
||||
pub use baseline::{PackBaseline, BASELINE_FILE};
|
||||
pub use decision::ConflictDecisions;
|
||||
pub use error::{ImportError, ImportErrorKind};
|
||||
pub use fetch::{
|
||||
CurseFileMeta, DownloadEngineFetcher, FetchError, FetchErrorKind, FetchFuture, FetchRequest,
|
||||
@@ -86,6 +104,7 @@ pub use report::{
|
||||
ImportProgress, ImportProgressSink, ImportReport, ImportStage, ImportStatus, ImportedFile,
|
||||
LoaderTarget, MissingFile, MissingReason, RegisteredInstance, RejectedComponent,
|
||||
};
|
||||
pub use scan::{scan_pack, ImportConflict, ImportScan};
|
||||
pub use stage::{RollbackOutcome, Staging};
|
||||
|
||||
use std::path::Path;
|
||||
@@ -93,7 +112,15 @@ use std::path::Path;
|
||||
use crate::addon::pack::PackLimits;
|
||||
use crate::platform::layout::LauncherLayout;
|
||||
|
||||
/// 冲突处置策略(默认保留用户文件)。
|
||||
/// 整次导入的**缺省**冲突档(逐项决策见 [`ConflictDecisions`])。
|
||||
///
|
||||
/// 保留这个名字是因为 CLI 已经按它调用(`pack import --overwrite`);语义上它就是
|
||||
/// [`ConflictDecisions::default_choice`] 的构造器:
|
||||
///
|
||||
/// - [`ConflictPolicy::KeepUser`](缺省)= 逐项决策的缺省档是"保留";
|
||||
/// - [`ConflictPolicy::Overwrite`] = 逐项决策的缺省档是"覆盖"。
|
||||
///
|
||||
/// **逐项决策优先于它**,所以"整批覆盖 + 某几条保留"是可表达的。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum ConflictPolicy {
|
||||
/// 保留用户那一侧(不覆盖、不补回被删的文件),冲突逐条进报告。
|
||||
@@ -103,6 +130,17 @@ pub enum ConflictPolicy {
|
||||
Overwrite,
|
||||
}
|
||||
|
||||
impl ConflictPolicy {
|
||||
/// 对应的逐项决策缺省档。
|
||||
#[must_use]
|
||||
const fn choice(self) -> ConflictChoice {
|
||||
match self {
|
||||
Self::KeepUser => ConflictChoice::KeepUser,
|
||||
Self::Overwrite => ConflictChoice::Overwrite,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次导入请求。
|
||||
pub struct ImportRequest<'a> {
|
||||
/// 包路径:zip(`.zip` / `.mrpack`)或 MultiMC 实例目录。
|
||||
@@ -115,8 +153,8 @@ pub struct ImportRequest<'a> {
|
||||
pub register: Option<&'a LauncherLayout>,
|
||||
/// 实例展示名(缺省用包名)。
|
||||
pub instance_name: Option<&'a str>,
|
||||
/// 冲突策略。
|
||||
pub conflict_policy: ConflictPolicy,
|
||||
/// 逐文件冲突决策(含整批缺省档,**缺省 = 全部保留**)。
|
||||
pub decisions: ConflictDecisions,
|
||||
/// 包读取限额(条数 / 单条体积 / 解压总量)。
|
||||
pub limits: PackLimits,
|
||||
/// 进度回调。
|
||||
@@ -133,7 +171,7 @@ impl<'a> ImportRequest<'a> {
|
||||
fetcher: None,
|
||||
register: None,
|
||||
instance_name: None,
|
||||
conflict_policy: ConflictPolicy::KeepUser,
|
||||
decisions: ConflictDecisions::default(),
|
||||
limits: PackLimits::default(),
|
||||
progress: None,
|
||||
}
|
||||
@@ -154,10 +192,24 @@ impl<'a> ImportRequest<'a> {
|
||||
self
|
||||
}
|
||||
|
||||
/// 改冲突策略。
|
||||
/// 整批缺省档(逐项决策仍然优先)。
|
||||
#[must_use]
|
||||
pub fn with_conflict_policy(mut self, policy: ConflictPolicy) -> Self {
|
||||
self.conflict_policy = policy;
|
||||
self.decisions = std::mem::take(&mut self.decisions).with_default(policy.choice());
|
||||
self
|
||||
}
|
||||
|
||||
/// 整表逐文件决策(「全部保留 / 全部覆盖 / 逐项」三条入口的产物)。
|
||||
#[must_use]
|
||||
pub fn with_decisions(mut self, decisions: ConflictDecisions) -> Self {
|
||||
self.decisions = decisions;
|
||||
self
|
||||
}
|
||||
|
||||
/// 挂一条逐项决策(链式,便于测试与命令行逐条给)。
|
||||
#[must_use]
|
||||
pub fn with_decision(mut self, path: impl Into<String>, choice: ConflictChoice) -> Self {
|
||||
self.decisions.set(path, choice);
|
||||
self
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
//! 逐文件冲突决策:`path → Keep / Overwrite`,以及"没收到决策就等于保留"这条硬线。
|
||||
//!
|
||||
//! # 三个入口,一个通道
|
||||
//!
|
||||
//! | 用户在界面上点的 | 落到这里的形状 |
|
||||
//! |---|---|
|
||||
//! | 全部保留 | [`ConflictDecisions::keep_all`](= [`ConflictDecisions::default`]) |
|
||||
//! | 全部覆盖 | [`ConflictDecisions::overwrite_all`] |
|
||||
//! | 逐项 | [`ConflictDecisions::with`] 一条一条挂上去,整批档仍是"保留" |
|
||||
//!
|
||||
//! 三者共用一个类型,是因为它们要回答的是同一个问题:「这个路径上的冲突该怎么处置」。
|
||||
//! 两条独立的落盘规则迟早会打架(一处按整批档、一处按逐项表),所以合并成一处。
|
||||
//!
|
||||
//! # 缺省为什么必须保留
|
||||
//!
|
||||
//! [`ConflictChoice::default`] 是 `KeepUser`:**表里没有这个路径**、或者调用方根本没设过决策时,
|
||||
//! 结论是"用户那份文件留着"。这不是保守,是正确性——导入的输入是别人做的整合包,用户的
|
||||
//! 配置/存档/自己改过的 mod 不能因为"我们没问"就消失。要看"覆盖"只有一个来路:调用方
|
||||
//! **显式**说了覆盖(整批档或逐项决策)。
|
||||
//!
|
||||
//! # 决策只作用于**冲突**
|
||||
//!
|
||||
//! 非冲突路径(磁盘上没有、或内容与包内一致)根本不看这张表:没有冲突就没有"保留/覆盖"这个
|
||||
//! 问题,`merge::decide` 的结论照旧。于是"表里没有这条"永远不会把一份该装的文件漏掉。
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use super::report::ConflictChoice;
|
||||
|
||||
/// 一次导入的逐文件决策表。
|
||||
///
|
||||
/// 顺序稳定(`BTreeMap`),所以同一份决策序列化出来的形状是确定的——诊断与测试都能直接比。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ConflictDecisions {
|
||||
/// 表里没点名的冲突按它处置。
|
||||
default: ConflictChoice,
|
||||
/// 逐项决策(实例内相对路径 → 处置)。
|
||||
entries: BTreeMap<String, ConflictChoice>,
|
||||
}
|
||||
|
||||
impl Default for ConflictDecisions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
// **不许改**:这是"没收到决策不覆盖"的落点。
|
||||
default: ConflictChoice::KeepUser,
|
||||
entries: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ConflictDecisions {
|
||||
/// 全部保留(= 缺省)。
|
||||
#[must_use]
|
||||
pub fn keep_all() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// 全部覆盖(用户显式点了「整合包说了算」)。
|
||||
#[must_use]
|
||||
pub fn overwrite_all() -> Self {
|
||||
Self {
|
||||
default: ConflictChoice::Overwrite,
|
||||
entries: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 改整批缺省档(逐项决策优先于它)。
|
||||
#[must_use]
|
||||
pub fn with_default(mut self, choice: ConflictChoice) -> Self {
|
||||
self.default = choice;
|
||||
self
|
||||
}
|
||||
|
||||
/// 挂一条逐项决策(链式)。
|
||||
#[must_use]
|
||||
pub fn with(mut self, path: impl Into<String>, choice: ConflictChoice) -> Self {
|
||||
self.set(path, choice);
|
||||
self
|
||||
}
|
||||
|
||||
/// 挂一条逐项决策(就地)。
|
||||
pub fn set(&mut self, path: impl Into<String>, choice: ConflictChoice) {
|
||||
self.entries.insert(path.into(), choice);
|
||||
}
|
||||
|
||||
/// 整批缺省档。
|
||||
#[must_use]
|
||||
pub const fn default_choice(&self) -> ConflictChoice {
|
||||
self.default
|
||||
}
|
||||
|
||||
/// 这个路径的处置:逐项决策优先,其次整批档(**整批档缺省是保留**)。
|
||||
#[must_use]
|
||||
pub fn choice_for(&self, path: &str) -> ConflictChoice {
|
||||
self.entries.get(path).copied().unwrap_or(self.default)
|
||||
}
|
||||
|
||||
/// 这个路径有没有被逐项点名(诊断用:区分"用户选的"与"缺省档")。
|
||||
#[must_use]
|
||||
pub fn explicit(&self, path: &str) -> Option<ConflictChoice> {
|
||||
self.entries.get(path).copied()
|
||||
}
|
||||
|
||||
/// 逐项决策的条数(不含整批缺省档)。
|
||||
#[must_use]
|
||||
pub fn len(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
|
||||
/// 有没有逐项决策。
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.entries.is_empty()
|
||||
}
|
||||
|
||||
/// 逐项决策(按路径排序;进报告/日志用)。
|
||||
pub fn iter(&self) -> impl Iterator<Item = (&str, ConflictChoice)> {
|
||||
self.entries
|
||||
.iter()
|
||||
.map(|(path, choice)| (path.as_str(), *choice))
|
||||
}
|
||||
|
||||
/// 逐项决策里"覆盖"的条数。
|
||||
#[must_use]
|
||||
pub fn overwrite_count(&self) -> usize {
|
||||
self.entries
|
||||
.values()
|
||||
.filter(|choice| **choice == ConflictChoice::Overwrite)
|
||||
.count()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn missing_decision_never_overwrites() {
|
||||
// 这张表是"没收到决策就覆盖"的唯一防线:默认档与空表的答案都必须是保留。
|
||||
assert_eq!(ConflictChoice::default(), ConflictChoice::KeepUser);
|
||||
let empty = ConflictDecisions::default();
|
||||
assert_eq!(empty.choice_for("config/a.toml"), ConflictChoice::KeepUser);
|
||||
assert_eq!(
|
||||
ConflictDecisions::keep_all().default_choice(),
|
||||
ConflictChoice::KeepUser
|
||||
);
|
||||
assert!(empty.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn per_item_wins_over_the_batch_default() {
|
||||
let decisions =
|
||||
ConflictDecisions::overwrite_all().with("mods/pin.jar", ConflictChoice::KeepUser);
|
||||
assert_eq!(
|
||||
decisions.choice_for("mods/pin.jar"),
|
||||
ConflictChoice::KeepUser
|
||||
);
|
||||
assert_eq!(
|
||||
decisions.choice_for("mods/other.jar"),
|
||||
ConflictChoice::Overwrite
|
||||
);
|
||||
assert_eq!(
|
||||
decisions.explicit("mods/pin.jar"),
|
||||
Some(ConflictChoice::KeepUser)
|
||||
);
|
||||
assert_eq!(decisions.explicit("mods/other.jar"), None);
|
||||
assert_eq!(decisions.len(), 1);
|
||||
assert_eq!(decisions.overwrite_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_is_idempotent_per_path() {
|
||||
let mut decisions = ConflictDecisions::keep_all();
|
||||
decisions.set("a.toml", ConflictChoice::Overwrite);
|
||||
decisions.set("a.toml", ConflictChoice::KeepUser);
|
||||
assert_eq!(decisions.len(), 1);
|
||||
assert_eq!(decisions.choice_for("a.toml"), ConflictChoice::KeepUser);
|
||||
assert_eq!(decisions.overwrite_count(), 0);
|
||||
let paths: Vec<&str> = decisions.iter().map(|(path, _)| path).collect();
|
||||
assert_eq!(paths, vec!["a.toml"]);
|
||||
}
|
||||
}
|
||||
@@ -15,10 +15,10 @@
|
||||
//! | 有 | 有 | `D == B` | [`MergeAction::Install`] | 用户没动过 ⇒ 整合包升级,覆盖是对的 |
|
||||
//! | 有 | 有 | `D != B` | [`MergeAction::KeepUser`] + `UserModified` | 用户改过:**不许**静默覆盖 |
|
||||
//!
|
||||
//! 两条"不许"就是任务里的硬要求("不得直接盖掉用户改过的文件")。判据只此一处:
|
||||
//! `ImportRequest::conflict_policy = Overwrite` 时,`KeepUser` 才会被判据的调用方翻成
|
||||
//! [`MergeAction::Install`],**判据本身不读策略**——这样"哪些情形算冲突"与"冲突怎么处置"
|
||||
//! 是两个可独立验证的事实。
|
||||
//! 两条"不许"就是任务里的硬要求("不得直接盖掉用户改过的文件")。判据只此一处:决策
|
||||
//! (逐项 [`super::decision::ConflictDecisions`],或整批 [`super::ConflictPolicy`])为
|
||||
//! `Overwrite` 时,`KeepUser` 才会被判据的调用方翻成 [`MergeAction::Install`],**判据本身
|
||||
//! 不读决策**——这样"哪些情形算冲突"与"冲突怎么处置"是两个可独立验证的事实。
|
||||
//!
|
||||
//! # `.disabled` / `.old` 不参与判据
|
||||
//!
|
||||
|
||||
@@ -62,7 +62,9 @@ pub(crate) async fn extract_overrides(
|
||||
next.insert(entry.path.clone(), pack_sha1.clone());
|
||||
let disk = disk_sha1(&target.join(&entry.path))?;
|
||||
let decision = merge::decide(&pack_sha1, baseline.sha1(&entry.path), disk.as_deref());
|
||||
let (action, choice) = settle(decision, disk.is_some(), request.conflict_policy);
|
||||
// 逐项决策优先,其次整批档(缺省是"保留"):见 `decision::ConflictDecisions`。
|
||||
let picked = request.decisions.choice_for(&entry.path);
|
||||
let (action, choice) = settle(decision, disk.is_some(), picked);
|
||||
if let Some(reason) = decision.reason {
|
||||
report.conflicts.push(conflict(
|
||||
entry.path.clone(),
|
||||
@@ -121,7 +123,8 @@ pub(crate) async fn fetch_remote(
|
||||
// 有声明摘要就能先判合并:命中"保留用户 / 已一致"时**不必下载**(省一次网络)。
|
||||
if let Some(declared) = resolved.sha1.clone() {
|
||||
let decision = merge::decide(&declared, baseline.sha1(&resolved.path), disk.as_deref());
|
||||
let (action, choice) = settle(decision, disk.is_some(), request.conflict_policy);
|
||||
let picked = request.decisions.choice_for(&resolved.path);
|
||||
let (action, choice) = settle(decision, disk.is_some(), picked);
|
||||
if !action.wrote() {
|
||||
next.insert(resolved.path.clone(), declared.clone());
|
||||
if let Some(reason) = decision.reason {
|
||||
@@ -185,7 +188,8 @@ pub(crate) async fn fetch_remote(
|
||||
}
|
||||
}
|
||||
let decision = merge::decide(&pack_sha1, baseline.sha1(&resolved.path), disk.as_deref());
|
||||
let (action, choice) = settle(decision, disk.is_some(), request.conflict_policy);
|
||||
let picked = request.decisions.choice_for(&resolved.path);
|
||||
let (action, choice) = settle(decision, disk.is_some(), picked);
|
||||
if let Some(reason) = decision.reason {
|
||||
report.conflicts.push(conflict(
|
||||
resolved.path.clone(),
|
||||
@@ -276,13 +280,17 @@ async fn resolve(
|
||||
}))
|
||||
}
|
||||
|
||||
/// 合并判定 + 策略 → 实际动作。
|
||||
/// 合并判定 + 决策 → 实际动作。
|
||||
///
|
||||
/// `picked` 是这一次的处置(逐项决策或整批档,见 [`super::decision::ConflictDecisions`]):
|
||||
/// 它**只对冲突生效**——`Install` / `SkipIdentical` 两个非冲突结论不受它影响,所以"用户点错
|
||||
/// 一个保留"不会把一份全新文件漏掉。
|
||||
fn settle(
|
||||
decision: merge::MergeDecision,
|
||||
disk_exists: bool,
|
||||
policy: super::ConflictPolicy,
|
||||
picked: ConflictChoice,
|
||||
) -> (FileAction, ConflictChoice) {
|
||||
let overwrite = policy == super::ConflictPolicy::Overwrite;
|
||||
let overwrite = picked == ConflictChoice::Overwrite;
|
||||
match decision.action {
|
||||
MergeAction::Install => (
|
||||
if disk_exists {
|
||||
|
||||
@@ -49,7 +49,11 @@ pub async fn run(request: &ImportRequest<'_>) -> ImportReport {
|
||||
};
|
||||
account_plan(&mut report, &plan);
|
||||
|
||||
let baseline = load_baseline(request.target, &plan, &mut report);
|
||||
let baseline = {
|
||||
let (baseline, warnings) = load_baseline(request.target, &plan);
|
||||
report.warnings.extend(warnings);
|
||||
baseline
|
||||
};
|
||||
let staging = match Staging::begin(request.target) {
|
||||
Ok(staging) => staging,
|
||||
Err(error) => return abandoned(report, error),
|
||||
@@ -134,7 +138,12 @@ pub async fn run(request: &ImportRequest<'_>) -> ImportReport {
|
||||
}
|
||||
|
||||
/// 开包 + 探测 + 组计划(任何失败都是 `Failed`)。
|
||||
fn probe_and_plan(request: &ImportRequest<'_>) -> Result<(PackArchive, ImportPlan), ImportError> {
|
||||
///
|
||||
/// 预检([`super::scan`])走的是**同一段代码**:两边对"这包是什么、要装什么"必须完全一致,
|
||||
/// 否则"预检说没冲突、导入说有冲突"这种事就会发生。
|
||||
pub(super) fn probe_and_plan(
|
||||
request: &ImportRequest<'_>,
|
||||
) -> Result<(PackArchive, ImportPlan), ImportError> {
|
||||
// 路径穿越双检 + 符号链接 + 条数/体积限额都在 `open` 里一次做完(M110 的 `archive`)。
|
||||
let archive = PackArchive::open(request.pack, request.limits)?;
|
||||
let (format, manifest) = PackRegistry::builtin().read(&archive)?;
|
||||
@@ -159,17 +168,18 @@ fn account_plan(report: &mut ImportReport, plan: &ImportPlan) {
|
||||
///
|
||||
/// "属于别的包"要单独说一句:同一目录先后导入两个整合包时,若拿别人的基线当 base,用户改过
|
||||
/// 的文件会被判成"整合包升级"而**被静默覆盖**。所以身份不符时降级成"没有基线"并告警。
|
||||
fn load_baseline(
|
||||
///
|
||||
/// 返回的告警由调用方写进报告(预检也是同一批文案——两侧对"有没有基线"必须有同一个答案)。
|
||||
pub(super) fn load_baseline(
|
||||
target: &std::path::Path,
|
||||
plan: &ImportPlan,
|
||||
report: &mut ImportReport,
|
||||
) -> PackBaseline {
|
||||
) -> (PackBaseline, Vec<ImportIssue>) {
|
||||
match PackBaseline::load(target) {
|
||||
Ok(baseline) => {
|
||||
if baseline.matches_pack(&plan.format, &plan.manifest.name) {
|
||||
return baseline;
|
||||
return (baseline, Vec::new());
|
||||
}
|
||||
report.warnings.push(ImportIssue::new(
|
||||
let warning = ImportIssue::new(
|
||||
keys::NOTE_BASELINE_PACK_MISMATCH,
|
||||
format!(
|
||||
"基线属于另一个整合包({} / {:?}),本次按「没有基线」处理:磁盘上同名文件一律算用户已有内容",
|
||||
@@ -180,17 +190,17 @@ fn load_baseline(
|
||||
},
|
||||
baseline.pack_name
|
||||
),
|
||||
));
|
||||
PackBaseline::default()
|
||||
);
|
||||
(PackBaseline::default(), vec![warning])
|
||||
}
|
||||
Err(reason) => {
|
||||
report.warnings.push(ImportIssue::new(
|
||||
let warning = ImportIssue::new(
|
||||
keys::NOTE_BASELINE_UNREADABLE,
|
||||
format!(
|
||||
"基线读不出来,本次按「没有基线」处理(磁盘上同名文件一律算用户已有内容):{reason}"
|
||||
),
|
||||
));
|
||||
PackBaseline::default()
|
||||
);
|
||||
(PackBaseline::default(), vec![warning])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ impl ConflictReason {
|
||||
}
|
||||
}
|
||||
|
||||
/// 冲突实际采用的处置(由 [`super::super::ConflictPolicy`] 决定)。
|
||||
/// 冲突实际采用的处置(由 [`super::super::ConflictPolicy`] / [`super::super::ConflictDecisions`] 决定)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ConflictChoice {
|
||||
@@ -89,6 +89,13 @@ pub enum ConflictChoice {
|
||||
Overwrite,
|
||||
}
|
||||
|
||||
impl Default for ConflictChoice {
|
||||
/// **缺省是保留**:没有决策时绝不覆盖用户已有文件(M160 的硬线,见 `import::decision`)。
|
||||
fn default() -> Self {
|
||||
Self::KeepUser
|
||||
}
|
||||
}
|
||||
|
||||
/// 一条冲突("无法判定时给可选择的冲突清单"就是这张表)。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ConflictFile {
|
||||
|
||||
@@ -53,6 +53,8 @@ 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";
|
||||
/// 注记:这些远端条目补全前没有路径,预检无法预判它们会不会撞上用户已有文件。
|
||||
pub const NOTE_REMOTE_PATH_UNKNOWN: &str = "pack.import.note.remote_path_unknown";
|
||||
|
||||
/// 缺失:调用方没给 fetcher(离线导入)。
|
||||
pub const MISSING_OFFLINE: &str = "pack.import.missing.offline";
|
||||
@@ -97,6 +99,7 @@ pub const ALL: &[&str] = &[
|
||||
NOTE_ENGINE_ARTIFACTS_DROPPED,
|
||||
NOTE_ROLLBACK_INCOMPLETE,
|
||||
NOTE_OVERRIDE_SHADOWED,
|
||||
NOTE_REMOTE_PATH_UNKNOWN,
|
||||
MISSING_OFFLINE,
|
||||
MISSING_NO_SOURCE,
|
||||
MISSING_FETCH_FAILED,
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
//! 导入**预检**(M160):只读地算出「冲突清单」——**只列真正需要用户点头的文件**。
|
||||
//!
|
||||
//! # 什么进清单,什么不进(口径只此一处)
|
||||
//!
|
||||
//! 一个路径进清单**当且仅当**:目标实例里同名路径是一个普通文件,且它的内容与包内**不同**。
|
||||
//! 等值判据是 **SHA-1**(包内那条来自 M110 读包时现算的摘要,磁盘那条现算);体积只作展示,
|
||||
//! 不参与判定——同一份内容不可能有两个哈希,而"体积相同"并不说明内容相同。
|
||||
//!
|
||||
//! 不进清单的四种情形,每一种都有理由:
|
||||
//!
|
||||
//! | 情形 | 为什么不必打扰用户 |
|
||||
//! |------|--------------------|
|
||||
//! | 目标里没有这个文件 | 全新文件,装就是了 |
|
||||
//! | 内容与包内一致 | 已经是最新,写盘是浪费 |
|
||||
//! | 用户删过(基线有、磁盘没有) | 删是用户的决定,**不偷偷装回来**(`merge::decide` 的 `UserDeleted`) |
|
||||
//! | 包内被同包另一条声明遮蔽(`shadowed`) | 它不落地,用户看不到它,问了也无从选 |
|
||||
//!
|
||||
//! # 叠层优先级不在这层重判
|
||||
//!
|
||||
//! `client-overrides/` 胜 `overrides/`、`server-overrides/` 客户端不解压——这些**已由**
|
||||
//! [`super::plan::ImportPlan::build`] 与 `plan::layer::resolve` 算定(一路径一行,胜者才在
|
||||
//! `overrides` 表里且 `shadowed.is_none()`)。本模块只按胜者取 `source`,从不自己再排一次。
|
||||
//!
|
||||
//! # 远端条目的两种命运
|
||||
//!
|
||||
//! mrpack 的 `files[]` 自带 `path` + `hashes.sha1`,所以能在这里判;判的是"**声明的**摘要 vs
|
||||
//! 磁盘",与导入时"下载下来再校验"是同一份期望值。CurseForge 的 `files[]` 只有
|
||||
//! `projectID/fileID`,补全前**没有路径**,无从判起 ⇒ 不猜、不进清单,只把条数记进
|
||||
//! [`ImportScan::unknown_remote`](界面据此说"还有 N 条补全后才能知道会不会撞车")。
|
||||
//!
|
||||
//! # 便宜在哪、贵在哪
|
||||
//!
|
||||
//! 预检不读包内字节(摘要来自 M110 的读包结果),但要给每条冲突填"目标现有体积与哈希",
|
||||
//! 所以**磁盘上那些同名文件要真读一遍**。于是 `scan` + `import` 会把它们读两次——这是本轮
|
||||
//! 的已知代价(要省就得让导入直接吃预检的判定结果,那是另一件事,报告里登记)。
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::baseline::PackBaseline;
|
||||
use super::decision::ConflictDecisions;
|
||||
use super::error::ImportError;
|
||||
use super::merge;
|
||||
use super::pipeline::{load_baseline, probe_and_plan};
|
||||
use super::report::{keys, ConflictReason, FileSource, ImportIssue, LoaderTarget};
|
||||
use super::ImportRequest;
|
||||
use crate::download::verify;
|
||||
use crate::download::HashAlgo;
|
||||
|
||||
/// 一条**要用户点头**的冲突(目标里已有同名文件且内容不同)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ImportConflict {
|
||||
/// 实例内相对路径。
|
||||
pub path: String,
|
||||
/// 来源(包内 `overrides/` / `client-overrides/` / mrpack 或 CF 的 `files[]`)。
|
||||
pub source: FileSource,
|
||||
/// 成因(`PreExisting` = 没有基线可判;`UserModified` = 基线里记过、用户改过)。
|
||||
pub reason: ConflictReason,
|
||||
/// 包内那条是不是禁用态(`.disabled` 后缀原样保留)。
|
||||
pub disabled: bool,
|
||||
/// 包内声明的 SHA-1。
|
||||
pub pack_sha1: String,
|
||||
/// 包内声明的字节数。
|
||||
pub pack_size: u64,
|
||||
/// 目标现有文件的 SHA-1。
|
||||
pub disk_sha1: String,
|
||||
/// 目标现有文件的字节数。
|
||||
pub disk_size: u64,
|
||||
/// 上次导入写下的 SHA-1(没有基线时 `None`)。
|
||||
pub base_sha1: Option<String>,
|
||||
}
|
||||
|
||||
impl ImportConflict {
|
||||
/// 覆盖它会丢掉多少字节(界面上"覆盖会丢掉 12.4 MB"就是它)。
|
||||
#[must_use]
|
||||
pub const fn lost_bytes(&self) -> u64 {
|
||||
self.disk_size
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次预检的结果。
|
||||
///
|
||||
/// `error` 为 `Some` 时其余字段是"没算出来":探测/开包/组计划失败与导入管线同一条口径
|
||||
/// (同一个 [`PackError`] → [`ImportError`] 的映射),不去新造一套。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ImportScan {
|
||||
/// 目标实例目录。
|
||||
pub target: PathBuf,
|
||||
/// 命中的格式 id。
|
||||
pub format: Option<String>,
|
||||
/// 包名。
|
||||
pub pack_name: Option<String>,
|
||||
/// 包版本。
|
||||
pub pack_version: Option<String>,
|
||||
/// 游戏版本。
|
||||
pub game_version: Option<String>,
|
||||
/// 加载器计划输入(`None` = 纯原版)。
|
||||
pub loader: Option<LoaderTarget>,
|
||||
/// 冲突清单(按路径排序)。
|
||||
pub conflicts: Vec<ImportConflict>,
|
||||
/// 补全前没有路径、因而无法预判的远端条目数(CF `files[]` 的常态)。
|
||||
pub unknown_remote: usize,
|
||||
/// 注记(`.disabled` / 可选条目 / 基线口径 / 被遮蔽的条目……与导入报告同源)。
|
||||
pub notes: Vec<ImportIssue>,
|
||||
/// 开包或组计划失败(`Failed` 口径,目标目录一个字节都没动)。
|
||||
pub error: Option<ImportError>,
|
||||
}
|
||||
|
||||
impl ImportScan {
|
||||
fn empty(target: PathBuf) -> Self {
|
||||
Self {
|
||||
target,
|
||||
format: None,
|
||||
pack_name: None,
|
||||
pack_version: None,
|
||||
game_version: None,
|
||||
loader: None,
|
||||
conflicts: Vec::new(),
|
||||
unknown_remote: 0,
|
||||
notes: Vec::new(),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 预检成功了吗(`false` = 看 [`Self::error`])。
|
||||
#[must_use]
|
||||
pub const fn is_ok(&self) -> bool {
|
||||
self.error.is_none()
|
||||
}
|
||||
|
||||
/// 有没有需要用户点头的项(`false` = 可以直接导入,不必打扰用户)。
|
||||
#[must_use]
|
||||
pub fn needs_decision(&self) -> bool {
|
||||
!self.conflicts.is_empty()
|
||||
}
|
||||
|
||||
/// 「全部保留」的决策(= 缺省档,不覆盖任何用户文件)。
|
||||
#[must_use]
|
||||
pub fn keep_all(&self) -> ConflictDecisions {
|
||||
ConflictDecisions::keep_all()
|
||||
}
|
||||
|
||||
/// 「全部覆盖」的决策(用户显式要求"整合包说了算")。
|
||||
#[must_use]
|
||||
pub fn overwrite_all(&self) -> ConflictDecisions {
|
||||
ConflictDecisions::overwrite_all()
|
||||
}
|
||||
|
||||
/// 覆盖会丢掉的字节总量(界面上的计数摘要)。
|
||||
#[must_use]
|
||||
pub fn lost_bytes(&self) -> u64 {
|
||||
self.conflicts
|
||||
.iter()
|
||||
.map(ImportConflict::lost_bytes)
|
||||
.fold(0, u64::saturating_add)
|
||||
}
|
||||
|
||||
/// 包内那些条目声明了多大(= 覆盖后会占用的字节数)。
|
||||
#[must_use]
|
||||
pub fn incoming_bytes(&self) -> u64 {
|
||||
self.conflicts
|
||||
.iter()
|
||||
.map(|conflict| conflict.pack_size)
|
||||
.fold(0, u64::saturating_add)
|
||||
}
|
||||
}
|
||||
|
||||
/// 跑一次预检:**只读**(不建目录、不写暂存、不改基线)。
|
||||
#[must_use]
|
||||
pub fn scan_pack(request: &ImportRequest<'_>) -> ImportScan {
|
||||
let mut scan = ImportScan::empty(request.target.to_path_buf());
|
||||
let plan = match probe_and_plan(request) {
|
||||
Ok((_, plan)) => plan,
|
||||
Err(error) => {
|
||||
scan.error = Some(error);
|
||||
return scan;
|
||||
}
|
||||
};
|
||||
let (baseline, warnings) = load_baseline(request.target, &plan);
|
||||
scan.notes = warnings;
|
||||
scan.format = Some(plan.format.clone());
|
||||
scan.pack_name = Some(plan.manifest.name.clone());
|
||||
scan.pack_version = plan.manifest.version.clone();
|
||||
scan.game_version = Some(plan.mc_version.clone());
|
||||
scan.loader = plan.loader.clone();
|
||||
|
||||
// 一路径一行:同一路径被覆盖条目与远端条目同时声明时,只留一条(决策是按路径给的)。
|
||||
let mut seen: BTreeMap<String, ()> = BTreeMap::new();
|
||||
for entry in plan
|
||||
.overrides
|
||||
.iter()
|
||||
.filter(|entry| entry.shadowed.is_none())
|
||||
{
|
||||
let Some(conflict) = judge(
|
||||
&entry.path,
|
||||
entry.source,
|
||||
&entry.sha1,
|
||||
entry.size,
|
||||
entry.disabled,
|
||||
&baseline,
|
||||
request.target,
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
seen.insert(entry.path.clone(), ());
|
||||
scan.conflicts.push(conflict);
|
||||
}
|
||||
for entry in &plan.remote {
|
||||
let (Some(path), Some(declared)) = (entry.path.as_deref(), entry.file.sha1()) else {
|
||||
// 没有路径(CF 待补全)或没有声明摘要 ⇒ 判不了,如实计数。
|
||||
scan.unknown_remote += 1;
|
||||
continue;
|
||||
};
|
||||
if seen.contains_key(path) {
|
||||
continue;
|
||||
}
|
||||
let Some(conflict) = judge(
|
||||
path,
|
||||
entry.source,
|
||||
declared,
|
||||
entry.file.size.unwrap_or(0),
|
||||
entry.disabled,
|
||||
&baseline,
|
||||
request.target,
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
seen.insert(path.to_string(), ());
|
||||
scan.conflicts.push(conflict);
|
||||
}
|
||||
if scan.unknown_remote > 0 {
|
||||
scan.notes.push(ImportIssue::new(
|
||||
keys::NOTE_REMOTE_PATH_UNKNOWN,
|
||||
format!(
|
||||
"有 {} 条远端条目补全前没有路径(CurseForge files[]),预检判不出它们会不会撞上已有文件;导入时仍按「缺省保留」处置",
|
||||
scan.unknown_remote
|
||||
),
|
||||
));
|
||||
}
|
||||
scan.conflicts
|
||||
.sort_by(|left, right| left.path.cmp(&right.path));
|
||||
scan
|
||||
}
|
||||
|
||||
/// 判一个路径:`Some` = 要用户点头的冲突。
|
||||
fn judge(
|
||||
path: &str,
|
||||
source: FileSource,
|
||||
pack_sha1: &str,
|
||||
pack_size: u64,
|
||||
disabled: bool,
|
||||
baseline: &PackBaseline,
|
||||
target: &Path,
|
||||
) -> 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);
|
||||
let decision = merge::decide(pack_sha1, base_sha1.as_deref(), Some(&disk.sha1));
|
||||
let reason = decision.reason?;
|
||||
Some(ImportConflict {
|
||||
path: path.to_string(),
|
||||
source,
|
||||
reason,
|
||||
disabled,
|
||||
pack_sha1: pack_sha1.to_string(),
|
||||
pack_size,
|
||||
disk_sha1: disk.sha1,
|
||||
disk_size: disk.size,
|
||||
base_sha1,
|
||||
})
|
||||
}
|
||||
|
||||
/// 磁盘现状:`None` = 那里没有普通文件(不存在或是目录)。
|
||||
struct DiskState {
|
||||
size: u64,
|
||||
sha1: String,
|
||||
}
|
||||
|
||||
fn disk_state(path: &Path) -> Option<DiskState> {
|
||||
let metadata = std::fs::metadata(path).ok()?;
|
||||
if !metadata.is_file() {
|
||||
return None;
|
||||
}
|
||||
let sha1 = verify::digest_file(path, HashAlgo::Sha1).ok()?;
|
||||
Some(DiskState {
|
||||
size: metadata.len(),
|
||||
sha1,
|
||||
})
|
||||
}
|
||||
@@ -31,9 +31,10 @@ pub use export::{
|
||||
RemoteHit,
|
||||
};
|
||||
pub use import::{
|
||||
import_pack, ConflictFile, ConflictPolicy, ImportError, ImportErrorKind, ImportProgress,
|
||||
ImportProgressSink, ImportReport, ImportRequest, ImportStage, ImportStatus, ImportedFile,
|
||||
MissingFile, PackBaseline, PackFetcher, RejectedComponent,
|
||||
import_pack, scan_pack, ConflictChoice, ConflictDecisions, ConflictFile, ConflictPolicy,
|
||||
ImportConflict, ImportError, ImportErrorKind, ImportProgress, ImportProgressSink, ImportReport,
|
||||
ImportRequest, ImportScan, ImportStage, ImportStatus, ImportedFile, MissingFile, PackBaseline,
|
||||
PackFetcher, RejectedComponent,
|
||||
};
|
||||
pub use loader::{
|
||||
LoaderError, LoaderErrorKind, LoaderInstallPlan, LoaderKind, LoaderPlanInput, LoaderRelease,
|
||||
@@ -44,5 +45,7 @@ pub use pack::{
|
||||
PackLoader, PackManifest, PackOverrides, PackRegistry, PackResult, PackSide,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod temp;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -60,7 +60,10 @@ pub mod path;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub use archive::{PackArchive, PackEntryInfo, PackLimits, ReadBudget};
|
||||
pub use archive::{
|
||||
PackArchive, PackEntryInfo, PackLimits, ReadBudget, PACK_MAX_ENTRIES, PACK_MAX_ENTRY_BYTES,
|
||||
PACK_MAX_UNCOMPRESSED_BYTES, PACK_ZIP64_BYTES_THR,
|
||||
};
|
||||
pub use curseforge::CurseForge;
|
||||
pub use error::{PackError, PackErrorKind, PackResult};
|
||||
pub use format::{PackFormat, PackMatch, PackPayload, PackRegistry};
|
||||
|
||||
@@ -50,7 +50,8 @@ mod open;
|
||||
pub use entry::PackEntryInfo;
|
||||
pub use limits::{
|
||||
PackLimits, ReadBudget, DEFAULT_MAX_ENTRIES, DEFAULT_MAX_ENTRY_BYTES,
|
||||
DEFAULT_MAX_UNCOMPRESSED_BYTES,
|
||||
DEFAULT_MAX_UNCOMPRESSED_BYTES, PACK_MAX_ENTRIES, PACK_MAX_ENTRY_BYTES,
|
||||
PACK_MAX_UNCOMPRESSED_BYTES, PACK_ZIP64_BYTES_THR,
|
||||
};
|
||||
|
||||
use limits::read_capped;
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
//! 读取限额:条目数 / 解压总体积 / 单条目,以及两处封顶实现。
|
||||
//!
|
||||
//! # 只此一份口径(导入读取 **与** 导出写入共用)
|
||||
//!
|
||||
//! [`PACK_MAX_ENTRIES`] / [`PACK_MAX_UNCOMPRESSED_BYTES`] / [`PACK_MAX_ENTRY_BYTES`] 是**唯一**
|
||||
//! 的体积口径:读侧 [`PackLimits::default`] 从这里取,写侧 `pack::format::write` 的预检也从
|
||||
//! 这里取(以前写侧直接拿 `zip::ZIP64_BYTES_THR`,于是"导得出来、导不回去"——导出允许 ~4 GiB,
|
||||
//! 导入只允许 512 MiB)。现在的关系是硬约束:
|
||||
//!
|
||||
//! ```text
|
||||
//! PACK_MAX_ENTRY_BYTES ≤ PACK_MAX_UNCOMPRESSED_BYTES < PACK_ZIP64_BYTES_THR ≤ u32::MAX
|
||||
//! ```
|
||||
//!
|
||||
//! 所以「我们能写出的包一定能被自己读回来」,而 ZIP64 阈值退化成一条**不会再被触碰的**
|
||||
//! 物理上限(`tests/limits.rs` 钉住这条不等式,也钉住两侧用的是同一批常数)。
|
||||
//!
|
||||
//! 为什么不让写侧跟着 ZIP64 放宽:读侧把单条目整条读进内存([`read_capped`]),64 MiB 是**内存**
|
||||
//! 护栏;放宽它等于用一个看不见的内存峰值换一个几乎用不到的大包能力。
|
||||
//!
|
||||
//! # 两阶段限额(打开 vs 读取)
|
||||
//!
|
||||
//! 打开阶段([`check_limits`])只能拿**中央目录声明的大小**做判断——它会撒谎。
|
||||
@@ -13,12 +30,24 @@ use std::io::Read;
|
||||
|
||||
use super::super::error::{PackError, PackResult};
|
||||
|
||||
/// 默认条目数上限(一个巨型整合包 ≈ 数千条,2 万条已经很宽)。
|
||||
pub const DEFAULT_MAX_ENTRIES: usize = 20_000;
|
||||
/// 默认解压总体积上限(512 MiB;`.mrpack` 的 `overrides` 通常几十 MiB)。
|
||||
pub const DEFAULT_MAX_UNCOMPRESSED_BYTES: u64 = 512 * 1024 * 1024;
|
||||
/// 默认单条目上限(64 MiB;最大的 mod 也不过十几 MiB)。
|
||||
pub const DEFAULT_MAX_ENTRY_BYTES: u64 = 64 * 1024 * 1024;
|
||||
/// 条目数上限(一个巨型整合包 ≈ 数千条,2 万条已经很宽)。
|
||||
pub const PACK_MAX_ENTRIES: usize = 20_000;
|
||||
/// 解压/打包总体积上限(512 MiB;`.mrpack` 的 `overrides` 通常几十 MiB)。
|
||||
pub const PACK_MAX_UNCOMPRESSED_BYTES: u64 = 512 * 1024 * 1024;
|
||||
/// 单条目上限(64 MiB;同时是读侧的**内存**护栏与写侧的单文件护栏)。
|
||||
pub const PACK_MAX_ENTRY_BYTES: u64 = 64 * 1024 * 1024;
|
||||
/// ZIP64 的物理阈值(`zip` crate 的口径,≈4 GiB)。
|
||||
///
|
||||
/// 生产路径**不再**用它当判据:PACK_MAX_UNCOMPRESSED_BYTES 远低于它(0.5 GiB ≪ 4 GiB),
|
||||
/// 所以 `tests/limits.rs` 只需钉住"统一上限 < 本阈值",ZIP64 就永远不会被需要。
|
||||
pub const PACK_ZIP64_BYTES_THR: u64 = zip::ZIP64_BYTES_THR;
|
||||
|
||||
/// 默认条目数上限(= [`PACK_MAX_ENTRIES`],保留旧名以免破坏既有调用点)。
|
||||
pub const DEFAULT_MAX_ENTRIES: usize = PACK_MAX_ENTRIES;
|
||||
/// 默认解压总体积上限(= [`PACK_MAX_UNCOMPRESSED_BYTES`])。
|
||||
pub const DEFAULT_MAX_UNCOMPRESSED_BYTES: u64 = PACK_MAX_UNCOMPRESSED_BYTES;
|
||||
/// 默认单条目上限(= [`PACK_MAX_ENTRY_BYTES`])。
|
||||
pub const DEFAULT_MAX_ENTRY_BYTES: u64 = PACK_MAX_ENTRY_BYTES;
|
||||
|
||||
/// 包读取限额。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
|
||||
@@ -155,7 +155,8 @@ pub trait PackFormat: Debug + Send + Sync {
|
||||
manifest,
|
||||
payload,
|
||||
&temp,
|
||||
zip::ZIP64_BYTES_THR,
|
||||
// 生产口径只有一处来源:导入读取用的也是这两个数(见 `archive::limits`)。
|
||||
write::WriteLimits::production(),
|
||||
) {
|
||||
Ok(()) => std::fs::rename(&temp, target).map_err(|error| {
|
||||
let _ = std::fs::remove_file(&temp);
|
||||
|
||||
@@ -21,11 +21,16 @@
|
||||
//! 后来修对了,误拒也发生在把几个 GiB 读进内存之后。流式之后第二道闸看的是**已写字节数**,
|
||||
//! 与内存占用无关。
|
||||
//!
|
||||
//! # 阈值与声明口径
|
||||
//! # 阈值与声明口径(**与导入读取同一份常量**)
|
||||
//!
|
||||
//! 判据是**未压缩声明的总量**(清单 JSON + 每个覆盖文件的字节数)≥ `zip::ZIP64_BYTES_THR`
|
||||
//! (≈4 GiB)。zip 条目头与中央目录的几十字节开销不计——这个常数本身就是近似边界,
|
||||
//! 官方实现同样按这个粗粒度判。
|
||||
//! 判据是**未压缩声明的总量**(清单 JSON + 每个覆盖文件的字节数)**与单条目体积**,两个数
|
||||
//! 都来自 `pack::archive::limits`([`WriteLimits::production`]):以前写侧直接拿
|
||||
//! `zip::ZIP64_BYTES_THR`(≈4 GiB),导入读取只允许 512 MiB ⇒「导得出来、导不回去」。现在
|
||||
//! 只此一份口径,关系是
|
||||
//! `PACK_MAX_ENTRY_BYTES ≤ PACK_MAX_UNCOMPRESSED_BYTES < PACK_ZIP64_BYTES_THR`,
|
||||
//! 所以 ZIP64 阈值永远不会被触碰(`pack/tests/limits.rs` 钉住这条不等式)。
|
||||
//! zip 条目头与中央目录的几十字节开销不计——这个常数本身就是近似边界,官方实现同样按这个
|
||||
//! 粗粒度判。
|
||||
|
||||
use std::fs::File;
|
||||
use std::io::{Read, Write};
|
||||
@@ -57,12 +62,12 @@ pub(super) fn container(
|
||||
manifest: &PackManifest,
|
||||
payload: PackPayload<'_>,
|
||||
temp: &Path,
|
||||
limit: u64,
|
||||
limits: WriteLimits,
|
||||
) -> PackResult<()> {
|
||||
// 先判总量再碰磁盘:超限时临时文件都不建,更不会读进一份 4 GiB 的内容。
|
||||
// 先判总量再碰磁盘:超限时临时文件都不建,更不会读进一份几百 MiB 的内容。
|
||||
// **返回值只属于预检**:写循环有自己的计数器(`Written`),拿它当基数会把全量声明尺寸
|
||||
// 再累加一遍 ⇒ 过拒窗口(P2-1)。
|
||||
let _ = preflight(json.len() as u64, manifest, payload, limit)?;
|
||||
let _ = preflight(json.len() as u64, manifest, payload, limits)?;
|
||||
|
||||
let file = File::create(temp)
|
||||
.map_err(|error| PackError::io(format!("创建 {} 失败:{error}", temp.display())))?;
|
||||
@@ -78,7 +83,7 @@ pub(super) fn container(
|
||||
.map_err(|error| PackError::io(format!("写 {entry} 失败:{error}")))?;
|
||||
|
||||
// 第二道闸的计数器:**从清单长度重新起算**,与预检的结果无关(见 `Written`)。
|
||||
let mut written = Written::new(json.len() as u64, limit);
|
||||
let mut written = Written::new(json.len() as u64, limits.total);
|
||||
let mut buffer = vec![0_u8; COPY_CHUNK];
|
||||
for group in &manifest.overrides {
|
||||
let dir = normalize_dir(&group.dir)?;
|
||||
@@ -116,15 +121,18 @@ pub(super) fn container(
|
||||
///
|
||||
/// 返回值是"清单 + 全部覆盖文件的声明总量",**只给测试与诊断看**。写循环有自己的计数器
|
||||
/// (从清单长度起算)——曾经把它直接当累加基数,结果每个文件的字节被算两遍,真实总量过半
|
||||
/// 阈值(~2.15 GiB)就被误拒(P2-1,已有 2.5 GB 的端到端用例钉住这个窗口)。
|
||||
/// 阈值就被误拒(P2-1,已有端到端用例钉住这个窗口)。
|
||||
///
|
||||
/// 判据顺序是**先总量、后单条**:总量超限时给出的那句"包太大"更贴用户的实际处境
|
||||
/// ("把这个文件移出实例"只对单条超限才有意义)。
|
||||
fn preflight(
|
||||
json_len: u64,
|
||||
manifest: &PackManifest,
|
||||
payload: PackPayload<'_>,
|
||||
limit: u64,
|
||||
limits: WriteLimits,
|
||||
) -> PackResult<u64> {
|
||||
let mut total = json_len;
|
||||
guard_with(total, limit)?;
|
||||
let mut entries: Vec<(String, u64)> = Vec::new();
|
||||
for group in &manifest.overrides {
|
||||
let dir = normalize_dir(&group.dir)?;
|
||||
for override_file in &group.files {
|
||||
@@ -138,12 +146,55 @@ fn preflight(
|
||||
})?
|
||||
.len();
|
||||
total += length;
|
||||
guard_with(total, limit)?;
|
||||
entries.push((format!("{dir}/{path}"), length));
|
||||
}
|
||||
}
|
||||
guard_with(total, limits.total)?;
|
||||
for (name, length) in entries {
|
||||
guard_entry(&name, length, limits.entry)?;
|
||||
}
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
/// 写侧的限额口径(**默认值直接取读侧那三个常量**,见 `archive::limits` 的模块文档)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) struct WriteLimits {
|
||||
/// 未压缩总量上限。
|
||||
pub(super) total: u64,
|
||||
/// 单条目上限。
|
||||
pub(super) entry: u64,
|
||||
}
|
||||
|
||||
impl WriteLimits {
|
||||
/// 生产口径:与 [`PackLimits::default`](crate::addon::pack::PackLimits) 同一个数。
|
||||
pub(super) const fn production() -> Self {
|
||||
Self {
|
||||
total: super::super::archive::limits::PACK_MAX_UNCOMPRESSED_BYTES,
|
||||
entry: super::super::archive::limits::PACK_MAX_ENTRY_BYTES,
|
||||
}
|
||||
}
|
||||
|
||||
/// 显式给值(测试用:把边界缩小到可测尺寸)。
|
||||
#[cfg(test)]
|
||||
pub(super) const fn new(total: u64, entry: u64) -> Self {
|
||||
Self { total, entry }
|
||||
}
|
||||
}
|
||||
|
||||
/// 单条目闸:与读侧的 `PACK_MAX_ENTRY_BYTES` **同一份口径**。
|
||||
///
|
||||
/// 没有这道闸时,"导出 100 MiB 的覆盖文件 → 自己的导入器拒读"是能真实发生的(读侧把一条
|
||||
/// 内容整块读进内存,所以有单条上限)。写侧照着读侧的上限拒,才不会产出自己读不回来的包。
|
||||
fn guard_entry(name: &str, length: u64, entry_limit: u64) -> PackResult<()> {
|
||||
if length > entry_limit {
|
||||
return Err(PackError::limits(format!(
|
||||
"覆盖条目 {name} 有 {length} 字节,超过单条目统一上限 {entry_limit}(导入读取用的也是这个数)"
|
||||
))
|
||||
.with_hint("把该文件移出实例(或换一个不需要内联它的内容目录)后再导出"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 单条覆盖文件的来源:来源表优先(跨格式导出用),否则按 `<root>/<dir>/<path>` 拼。
|
||||
fn source_for(payload: PackPayload<'_>, dir: &str, path: &str) -> PackResult<PathBuf> {
|
||||
let name = format!("{dir}/{path}");
|
||||
@@ -183,18 +234,17 @@ impl Written {
|
||||
}
|
||||
}
|
||||
|
||||
/// ZIP64 阈值闸(`pack_limits`):`total` **达到**阈值就拒(`>=` 不是 `>`,官方实现也是
|
||||
/// "撞上就不写")。
|
||||
/// 总量闸(`pack_limits`):`total` **达到**上限就拒(`>=` 不是 `>`,撞上就不写)。
|
||||
///
|
||||
/// 生产路径的 `limit` 恒为 `zip::ZIP64_BYTES_THR`(由 `format.rs` 传下来);把它做成参数是为了
|
||||
/// 能用几十字节的小数值钉住"边界在 `>=` 上"和"写循环不许重复累计"这两件事——真造 4 GiB 的
|
||||
/// 包要几十秒 CPU 与一大片内存/页面文件,本机资源纪律下不经默认套件跑(塔台 2026-11-07)。
|
||||
/// 生产路径的 `limit` 来自 [`WriteLimits::production`](= 读侧 `PACK_MAX_UNCOMPRESSED_BYTES`);
|
||||
/// 把它做成参数是为了能用几十字节的小数值钉住"边界在 `>=` 上"和"写循环不许重复累计"这两件事。
|
||||
fn guard_with(total: u64, limit: u64) -> PackResult<()> {
|
||||
if total >= limit {
|
||||
return Err(PackError::limits(format!(
|
||||
"包体未压缩总量已超过 {limit} 字节(ZIP64 阈值),拒绝写出一个需要 ZIP64 的整合包"
|
||||
"包体未压缩总量超过统一上限 {limit} 字节(ZIP64 阈值 {} 远在其上,所以永远不需要写 ZIP64)",
|
||||
super::super::archive::limits::PACK_ZIP64_BYTES_THR
|
||||
))
|
||||
.with_hint("把超大的覆盖文件移出实例后再导出;mrpack/CF 都不接受 ZIP64"));
|
||||
.with_hint("把超大的覆盖文件移出实例后再导出;导入读取用的是同一个上限"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -219,22 +269,19 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn the_second_gate_counts_from_the_manifest_length_not_the_preflight_total() {
|
||||
// P2-1 的窗口:真实总量在 [阈值一半, 阈值) 之间的包**不许**被拒。这一条不真写
|
||||
// 2.5 GB(那要 ~70 秒 deflate 与一大片内存/页面文件),而是直接把窗口里的字节数
|
||||
// P2-1 的窗口:真实总量在 [上限一半, 上限) 之间的包**不许**被拒。这一条不真写
|
||||
// 几百 MiB(那要几十秒 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 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, zip::ZIP64_BYTES_THR);
|
||||
let error = written
|
||||
.spend(zip::ZIP64_BYTES_THR)
|
||||
.expect_err("撞阈值必须拒");
|
||||
// 对照:撞上上限必须拒,且正好在上限处(不是超一点点才拒)。
|
||||
let mut written = Written::new(28, limit);
|
||||
let error = written.spend(limit).expect_err("撞上限必须拒");
|
||||
assert_eq!(
|
||||
error.kind(),
|
||||
super::super::super::error::PackErrorKind::Limits
|
||||
@@ -275,7 +322,7 @@ mod tests {
|
||||
&manifest,
|
||||
PackPayload::from_root(&root),
|
||||
&target,
|
||||
limit,
|
||||
WriteLimits::new(limit, 1024),
|
||||
)
|
||||
.expect("声明总量没到 limit:写循环不许把预检的 total 重复累计");
|
||||
|
||||
@@ -288,7 +335,7 @@ mod tests {
|
||||
&manifest,
|
||||
PackPayload::from_root(&root),
|
||||
&refused,
|
||||
declared,
|
||||
WriteLimits::new(declared, 1024),
|
||||
)
|
||||
.expect_err("声明总量达到 limit 必须拒");
|
||||
assert_eq!(
|
||||
@@ -333,9 +380,13 @@ mod tests {
|
||||
7,
|
||||
&manifest,
|
||||
PackPayload::from_root(&root),
|
||||
zip::ZIP64_BYTES_THR,
|
||||
// 本条只关心"预检不读内容",所以两道闸都放到最宽(生产口径见 `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 总量仍在阈值之下,预检不许误伤");
|
||||
.expect("3 GiB 总量仍在最宽闸之下,预检不许误伤");
|
||||
assert_eq!(
|
||||
total,
|
||||
7 + 10 + 3 * 1024 * 1024 * 1024,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! 2. 同一份包命中多格式时选谁(含 `also_matched` 记录的其它命中);
|
||||
//! 3. **换一下注册顺序,赢家跟着换**——这才证明优先级来自注册表,而不是代码里散落的 if。
|
||||
|
||||
use super::{fixtures, temp_root, write_zip};
|
||||
use super::{fixtures, write_zip, TempRoot};
|
||||
use crate::addon::pack::archive::PackArchive;
|
||||
use crate::addon::pack::curseforge::CURSEFORGE;
|
||||
use crate::addon::pack::error::PackErrorKind;
|
||||
@@ -23,8 +23,9 @@ const MMC_PACK: &[u8] =
|
||||
br#"{"formatVersion":1,"components":[{"uid":"net.minecraft","version":"1.20.1"}]}"#;
|
||||
|
||||
/// 同时带三种身份标记的"不标准包"。
|
||||
fn hybrid_pack(tag: &str) -> std::path::PathBuf {
|
||||
let target = temp_root(tag).join("hybrid.zip");
|
||||
fn hybrid_pack(tag: &str) -> TempRoot {
|
||||
// 返回的是**守卫**(Deref 到 `hybrid.zip`):包文件活到调用点用完为止。
|
||||
let target = TempRoot::at(tag, "hybrid.zip");
|
||||
write_zip(
|
||||
&target,
|
||||
&[
|
||||
@@ -48,7 +49,8 @@ fn builtin_priority_is_pinned() {
|
||||
|
||||
#[test]
|
||||
fn curseforge_wins_when_a_pack_carries_every_marker() {
|
||||
let pack = PackArchive::open_default(hybrid_pack("detect-hybrid")).expect("打开混合包");
|
||||
let hybrid = hybrid_pack("detect-hybrid");
|
||||
let pack = PackArchive::open_default(&hybrid).expect("打开混合包");
|
||||
let matched = PackRegistry::builtin().detect(&pack).expect("要能选出格式");
|
||||
assert_eq!(matched.format().id(), "curseforge");
|
||||
assert_eq!(
|
||||
@@ -62,7 +64,8 @@ fn curseforge_wins_when_a_pack_carries_every_marker() {
|
||||
#[test]
|
||||
fn a_plain_manifest_json_does_not_claim_curseforge() {
|
||||
// `manifest.json` 这名字太泛:形状不符就不能认领,否则 mrpack 会被抢走。
|
||||
let target = temp_root("detect-plain").join("plain.zip");
|
||||
let root = TempRoot::new("detect-plain");
|
||||
let target = root.join("plain.zip");
|
||||
write_zip(
|
||||
&target,
|
||||
&[
|
||||
@@ -79,7 +82,8 @@ fn a_plain_manifest_json_does_not_claim_curseforge() {
|
||||
#[test]
|
||||
fn registration_order_decides_the_winner() {
|
||||
// 同一份包同时有 CF 与 mrpack 标记:只换注册顺序,赢家就该换。
|
||||
let target = temp_root("detect-order").join("both.zip");
|
||||
let root = TempRoot::new("detect-order");
|
||||
let target = root.join("both.zip");
|
||||
write_zip(
|
||||
&target,
|
||||
&[
|
||||
@@ -107,7 +111,8 @@ fn registration_order_decides_the_winner() {
|
||||
|
||||
#[test]
|
||||
fn unknown_package_is_rejected_with_the_list_of_tried_formats() {
|
||||
let target = temp_root("detect-unknown").join("unknown.zip");
|
||||
let root = TempRoot::new("detect-unknown");
|
||||
let target = root.join("unknown.zip");
|
||||
write_zip(&target, &[("readme.txt", &b"nothing to see"[..])]);
|
||||
let pack = PackArchive::open_default(&target).expect("打开包");
|
||||
assert!(PackRegistry::builtin().detect_all(&pack).is_empty());
|
||||
@@ -126,7 +131,8 @@ fn unknown_package_is_rejected_with_the_list_of_tried_formats() {
|
||||
|
||||
#[test]
|
||||
fn multimc_needs_a_non_empty_component_graph() {
|
||||
let target = temp_root("detect-mmc-empty").join("empty-mmc.zip");
|
||||
let root = TempRoot::new("detect-mmc-empty");
|
||||
let target = root.join("empty-mmc.zip");
|
||||
write_zip(
|
||||
&target,
|
||||
&[(
|
||||
@@ -165,7 +171,8 @@ fn a_corrupt_manifest_is_a_metadata_error_not_unsupported() {
|
||||
// r1 p3-2:清单在、但读不出来 = 包坏了(`pack_metadata`)。之前三个 detect 都用
|
||||
// `.ok().flatten()` 吞掉解析错误,结果被报成「没有格式认领」——M111 的失败分级会据此
|
||||
// 把"可以重下文件"的包判成"不支持"。
|
||||
let target = temp_root("corrupt-manifest").join("bad.zip");
|
||||
let root = TempRoot::new("corrupt-manifest");
|
||||
let target = root.join("bad.zip");
|
||||
write_zip(&target, &[("manifest.json", &br#"{"name": "#[..])]);
|
||||
let pack = PackArchive::open_default(&target).expect("zip 本身可读");
|
||||
|
||||
@@ -183,7 +190,8 @@ fn a_corrupt_manifest_is_a_metadata_error_not_unsupported() {
|
||||
#[test]
|
||||
fn a_corrupt_foreign_manifest_does_not_hide_a_valid_mrpack() {
|
||||
// 命中优先于失败:包里躺着一份别人的坏 manifest.json,不该让真 mrpack 也变得不可用。
|
||||
let target = temp_root("corrupt-foreign").join("mixed.zip");
|
||||
let root = TempRoot::new("corrupt-foreign");
|
||||
let target = root.join("mixed.zip");
|
||||
write_zip(
|
||||
&target,
|
||||
&[
|
||||
@@ -202,7 +210,8 @@ fn a_corrupt_foreign_manifest_does_not_hide_a_valid_mrpack() {
|
||||
#[test]
|
||||
fn a_missing_manifest_is_not_an_error() {
|
||||
// 反向对照:**没有** `manifest.json` 是"不是 CF",不是"包坏了"。
|
||||
let target = temp_root("no-manifest").join("mmc.zip");
|
||||
let root = TempRoot::new("no-manifest");
|
||||
let target = root.join("mmc.zip");
|
||||
write_zip(
|
||||
&target,
|
||||
&[(
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//!
|
||||
//! 这一组不测"映射是否做对"(那是 M112),只测"表达不了的时候会不会静默产出空包"。
|
||||
|
||||
use super::{fixtures, temp_root};
|
||||
use super::{fixtures, TempRoot};
|
||||
use crate::addon::pack::archive::PackArchive;
|
||||
use crate::addon::pack::curseforge::CURSEFORGE;
|
||||
use crate::addon::pack::error::PackErrorKind;
|
||||
@@ -67,7 +67,8 @@ fn curseforge_refuses_more_than_one_both_side_group() {
|
||||
.overrides
|
||||
.push(PackOverrides::new("extra-overrides", PackSide::Both));
|
||||
|
||||
let target = temp_root("export-cf-two").join("out.zip");
|
||||
let root = TempRoot::new("export-cf-two");
|
||||
let target = root.join("out.zip");
|
||||
let error = CURSEFORGE
|
||||
.write(&manifest, PackPayload::metadata_only(), &target)
|
||||
.expect_err("多出来的 Both 组会被清单漏掉,必须拒");
|
||||
@@ -90,7 +91,7 @@ fn a_failed_export_leaves_neither_a_half_pack_nor_a_temp_file() {
|
||||
}],
|
||||
});
|
||||
|
||||
let root = temp_root("export-half");
|
||||
let root = TempRoot::new("export-half");
|
||||
std::fs::create_dir_all(&root).expect("建目录");
|
||||
let target = root.join("out.zip");
|
||||
let error = CURSEFORGE
|
||||
@@ -142,7 +143,8 @@ fn mrpack_refuses_two_override_groups_for_the_same_side() {
|
||||
.overrides
|
||||
.push(PackOverrides::new("overrides", PackSide::Both));
|
||||
|
||||
let target = temp_root("export-mr-same-side").join("out.mrpack");
|
||||
let root = TempRoot::new("export-mr-same-side");
|
||||
let target = root.join("out.mrpack");
|
||||
let error = MODRINTH
|
||||
.write(&manifest, PackPayload::metadata_only(), &target)
|
||||
.expect_err("同一侧两个覆盖目录必须拒");
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::Path;
|
||||
|
||||
use super::{temp_root, write_file};
|
||||
use super::{write_file, TempRoot};
|
||||
use crate::addon::export::catalog::{RemoteCatalog, RemoteHit};
|
||||
use crate::addon::export::scan::{scan_instance, DEFAULT_CONTENT_ROOTS};
|
||||
use crate::addon::pack::curseforge::fingerprint;
|
||||
@@ -46,8 +46,9 @@ pub(super) fn write_layer(game: &Path, id: &str, json: &str) {
|
||||
}
|
||||
|
||||
/// 一个 Fabric 实例目录:两个 mod(其中一个禁用)+ 一份 config + 一份白名单外的日志。
|
||||
pub(super) fn fabric_instance(tag: &str) -> PathBuf {
|
||||
let root = temp_root(tag).join("game");
|
||||
pub(super) fn fabric_instance(tag: &str) -> TempRoot {
|
||||
// 返回守卫本身(`Deref` 到 `<root>/game`):实例目录活到调用点用完为止,drop 即删。
|
||||
let root = TempRoot::under(tag, "game");
|
||||
write_layer(&root, "1.20.1", VANILLA_LAYER);
|
||||
write_layer(&root, "1.20.1-fabric-0.15.11", FABRIC_LAYER);
|
||||
write_file(&root.join("mods/jei.jar"), b"PK\x03\x04 jei body");
|
||||
@@ -58,8 +59,8 @@ pub(super) fn fabric_instance(tag: &str) -> PathBuf {
|
||||
}
|
||||
|
||||
/// 一个 NeoForge 1.20.1 实例目录(CF `modLoaders[].id` 的 MC 前缀特例用它)。
|
||||
pub(super) fn neoforge_instance(tag: &str) -> PathBuf {
|
||||
let root = temp_root(tag).join("game");
|
||||
pub(super) fn neoforge_instance(tag: &str) -> TempRoot {
|
||||
let root = TempRoot::under(tag, "game");
|
||||
write_layer(&root, "1.20.1", VANILLA_LAYER);
|
||||
write_layer(&root, "1.20.1-neoforge-47.1.106", NEOFORGE_LAYER);
|
||||
write_file(&root.join("mods/jei.jar"), b"PK\x03\x04 jei body");
|
||||
|
||||
@@ -10,7 +10,7 @@ use std::path::Path;
|
||||
use super::export_harness::{
|
||||
defaults, digests, fabric_instance, modrinth_hit, neoforge_instance, StubCatalog,
|
||||
};
|
||||
use super::{temp_root, write_file};
|
||||
use super::{write_file, TempRoot};
|
||||
use crate::addon::export::classify::classify_with;
|
||||
use crate::addon::export::scan::{is_disabled_path, scan_instance};
|
||||
use crate::addon::export::{export_instance, ExportRequest, FileClass, FileReason, RemoteHit};
|
||||
@@ -300,7 +300,7 @@ fn a_disabled_file_is_still_disabled_after_a_round_trip_through_the_package() {
|
||||
let target = root.with_file_name("disabled.mrpack");
|
||||
export_instance(&ExportRequest::new(&root, &target, &MODRINTH)).expect("导出");
|
||||
|
||||
let again = temp_root("export-disabled-reimport");
|
||||
let again = TempRoot::new("export-disabled-reimport");
|
||||
let file = std::fs::File::open(&target).expect("打开包");
|
||||
let mut archive = zip::ZipArchive::new(file).expect("读 zip");
|
||||
for index in 0..archive.len() {
|
||||
|
||||
@@ -17,12 +17,12 @@
|
||||
//! | mrpack → mrpack | `files[]` 的条目要靠"文件真的在磁盘上"才会再写回去 | 读出来的模型里那两条是远程条目、本地没有字节;再导出时以磁盘为准 |
|
||||
//! | MultiMC → mrpack | 非加载器组件(`org.lwjgl3`)不进 `dependencies` | 它不是加载器;mrpack 的 `dependencies` 只表达 loader |
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::Path;
|
||||
|
||||
use super::export_harness::{
|
||||
defaults, modrinth_hit, write_layer, StubCatalog, FORGE_LAYER, VANILLA_LAYER,
|
||||
};
|
||||
use super::{fixtures, temp_root, write_file};
|
||||
use super::{fixtures, write_file, TempRoot};
|
||||
use crate::addon::export::snapshot::{InstanceSnapshot, SnapshotLoader};
|
||||
use crate::addon::export::{export_instance, ExportRequest};
|
||||
use crate::addon::pack::archive::PackArchive;
|
||||
@@ -34,8 +34,8 @@ use crate::addon::pack::multimc::MULTIMC;
|
||||
use crate::addon::LoaderKind;
|
||||
|
||||
/// 把夹具里的覆盖内容铺成一个实例目录(`<game>/<path>`),并写一份版本 JSON。
|
||||
fn instance_from(fixture_dir: &Path, manifest: &PackManifest, tag: &str) -> PathBuf {
|
||||
let game = temp_root(tag).join("game");
|
||||
fn instance_from(fixture_dir: &Path, manifest: &PackManifest, tag: &str) -> TempRoot {
|
||||
let game = TempRoot::under(tag, "game");
|
||||
for (_, file) in manifest.override_files() {
|
||||
let group = manifest
|
||||
.overrides
|
||||
@@ -226,7 +226,7 @@ fn multimc_fixture_exports_as_mrpack_from_its_game_directory() {
|
||||
assert!(MULTIMC.metadata_entry().is_none(), "MultiMC 导出不做");
|
||||
|
||||
// 重新打成 MultiMC 形状的目录以确认没有丢内容(把 overrides 解出来再比)。
|
||||
let unpacked = temp_root("rt-mmc-unpacked");
|
||||
let unpacked = TempRoot::new("rt-mmc-unpacked");
|
||||
for entry in pack.entries() {
|
||||
let name = entry.name();
|
||||
let Some(relative) = name.strip_prefix("overrides/") else {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::{temp_root, write_file, write_zip};
|
||||
use super::{write_file, write_zip, TempRoot};
|
||||
use crate::addon::pack::archive::PackArchive;
|
||||
use crate::addon::pack::curseforge::CURSEFORGE;
|
||||
use crate::addon::pack::error::{PackError, PackErrorKind, PackResult};
|
||||
@@ -87,8 +87,8 @@ impl PackFormat for ToyFormat {
|
||||
}
|
||||
|
||||
/// 造一个 toy 包(源目录形式,顺带能当导出 payload)。
|
||||
fn toy_pack(tag: &str) -> std::path::PathBuf {
|
||||
let dir = temp_root(tag).join("toy");
|
||||
fn toy_pack(tag: &str) -> TempRoot {
|
||||
let dir = TempRoot::under(tag, "toy");
|
||||
write_file(
|
||||
&dir.join("toy.json"),
|
||||
br#"{"name":"Toy Pack","minecraft":"1.21","mods":["mods/a.jar","mods/b.jar"]}"#,
|
||||
@@ -140,7 +140,7 @@ fn a_new_format_needs_one_registration_line() {
|
||||
#[test]
|
||||
fn appending_a_format_never_steals_existing_packs() {
|
||||
// 同一份包同时有 CF 清单与 toy 清单。
|
||||
let root = temp_root("extend-priority");
|
||||
let root = TempRoot::new("extend-priority");
|
||||
let target = root.join("both.zip");
|
||||
write_zip(
|
||||
&target,
|
||||
|
||||
@@ -22,14 +22,19 @@
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::{temp_root, write_file, zip_dir};
|
||||
use super::{write_file, zip_dir, TempRoot};
|
||||
|
||||
/// 一个既有源目录(可当导出 payload)又有打好的 zip 的夹具。
|
||||
///
|
||||
/// `_root` 是临时根的**守卫**:夹具活多久,目录就活多久;夹具 drop(含测试 panic 展开)时
|
||||
/// 整个临时根被删。没有它,这些手造包会一直堆在 `%TEMP%` 里(M160 r2:实测堆到 110 GB)。
|
||||
pub(crate) struct Fixture {
|
||||
/// 源目录(zip 的内容来源,也是 `PackPayload::from_root` 的根)。
|
||||
pub dir: PathBuf,
|
||||
/// 打好的包。
|
||||
pub zip: PathBuf,
|
||||
/// 临时根守卫(只为了在夹具存活期间保住 `dir` / `zip`)。
|
||||
_root: TempRoot,
|
||||
}
|
||||
|
||||
/// CF 夹具的包名。
|
||||
@@ -130,7 +135,7 @@ const MMC_INSTANCE: &str = "InstanceType=OneSix\nname=M110 手造 MultiMC 实例
|
||||
|
||||
/// CurseForge 夹具(`manifest.json` + `overrides/`,含一个截断的 TOML)。
|
||||
pub(crate) fn curseforge(tag: &str) -> Fixture {
|
||||
let root = temp_root(tag);
|
||||
let root = TempRoot::new(tag);
|
||||
let dir = root.join("src");
|
||||
write_file(&dir.join("manifest.json"), CF_MANIFEST.as_bytes());
|
||||
write_file(
|
||||
@@ -144,12 +149,16 @@ pub(crate) fn curseforge(tag: &str) -> Fixture {
|
||||
write_file(&dir.join("overrides/config/broken.toml"), CF_BROKEN);
|
||||
let zip = root.join("pack.zip");
|
||||
zip_dir(&dir, &zip);
|
||||
Fixture { dir, zip }
|
||||
Fixture {
|
||||
dir,
|
||||
zip,
|
||||
_root: root,
|
||||
}
|
||||
}
|
||||
|
||||
/// Modrinth 夹具(`modrinth.index.json` + 三个覆盖目录,含一个半截 jar)。
|
||||
pub(crate) fn modrinth(tag: &str) -> Fixture {
|
||||
let root = temp_root(tag);
|
||||
let root = TempRoot::new(tag);
|
||||
let dir = root.join("src");
|
||||
write_file(&dir.join("modrinth.index.json"), mrpack_index().as_bytes());
|
||||
write_file(&dir.join("overrides/mods/half.jar"), MR_HALF_JAR);
|
||||
@@ -164,12 +173,19 @@ pub(crate) fn modrinth(tag: &str) -> Fixture {
|
||||
);
|
||||
let zip = root.join("pack.mrpack");
|
||||
zip_dir(&dir, &zip);
|
||||
Fixture { dir, zip }
|
||||
Fixture {
|
||||
dir,
|
||||
zip,
|
||||
_root: root,
|
||||
}
|
||||
}
|
||||
|
||||
/// MultiMC 夹具:**目录形式**(真实形态就是实例目录,不是 zip),含一个零字节 jar。
|
||||
pub(crate) fn multimc(tag: &str) -> PathBuf {
|
||||
let root = temp_root(tag).join("instance");
|
||||
///
|
||||
/// 返回 `TempRoot`(`Deref<Target = Path>` 指向 `<root>/instance`):调用点照旧当路径用,
|
||||
/// 而临时根由返回值持有——用到哪、活到哪、drop 就删。
|
||||
pub(crate) fn multimc(tag: &str) -> TempRoot {
|
||||
let root = TempRoot::under(tag, "instance");
|
||||
write_file(&root.join("mmc-pack.json"), MMC_PACK.as_bytes());
|
||||
write_file(&root.join("instance.cfg"), MMC_INSTANCE.as_bytes());
|
||||
write_file(
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
//! 限额口径的统一(M160):**导入读取与导出写入只此一份常数**,且不等式关系钉死在这里。
|
||||
//!
|
||||
//! # 为什么需要这个文件
|
||||
//!
|
||||
//! M152 之前两侧各写各的:读侧 `PackLimits` 是 20 000 条 / 512 MiB / 64 MiB,写侧直接拿
|
||||
//! `zip::ZIP64_BYTES_THR`(≈4 GiB)与 u32。结果是一个**能导出、导不回来**的包是合法的。
|
||||
//! 现在写侧改读同一份常量(`WriteLimits::production`),本文件就是那条口径的证据:
|
||||
//!
|
||||
//! 1. 三个常量之间的关系(`entry ≤ total < ZIP64 ≤ u32`);
|
||||
//! 2. 读侧 `PackLimits::default()` 就是这三个数;
|
||||
//! 3. 写侧在**单条边界**上拒(`>` 而不是 `≥`:恰好 64 MiB 合法);
|
||||
//! 4. 写侧在**总量**上拒,且拒绝发生在读内容之前(临时目录里什么都不留)。
|
||||
|
||||
use super::{write_file, TempRoot};
|
||||
use crate::addon::pack::archive::limits::{
|
||||
PACK_MAX_ENTRIES, PACK_MAX_ENTRY_BYTES, PACK_MAX_UNCOMPRESSED_BYTES, PACK_ZIP64_BYTES_THR,
|
||||
};
|
||||
use crate::addon::pack::curseforge::CURSEFORGE;
|
||||
use crate::addon::pack::error::PackErrorKind;
|
||||
use crate::addon::pack::format::{PackFormat, PackPayload};
|
||||
use crate::addon::pack::manifest::{OverrideFile, PackManifest, PackOverrides, PackSide};
|
||||
use crate::addon::pack::PackLimits;
|
||||
|
||||
/// 一个覆盖文件的声明(内容由调用方提供)。
|
||||
fn override_file(path: &str) -> OverrideFile {
|
||||
OverrideFile {
|
||||
path: path.to_string(),
|
||||
sha1: "0".repeat(40),
|
||||
size: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn manifest_of(files: Vec<OverrideFile>) -> PackManifest {
|
||||
let mut manifest = PackManifest::new("限额口径");
|
||||
manifest.version = Some("1.0.0".to_string());
|
||||
manifest.minecraft = Some("1.20.1".to_string());
|
||||
manifest.overrides.push(PackOverrides {
|
||||
dir: "overrides".to_string(),
|
||||
side: PackSide::Both,
|
||||
files,
|
||||
});
|
||||
manifest
|
||||
}
|
||||
|
||||
/// 造一个只占元数据的 `length` 字节文件(`set_len` 不写零)。
|
||||
fn sparse_file(path: &std::path::Path, length: u64) {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).expect("建目录");
|
||||
}
|
||||
let file = std::fs::File::create(path).expect("建稀疏文件");
|
||||
file.set_len(length).expect("拉长文件(稀疏)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_three_limits_form_one_chain_and_the_read_side_uses_them() {
|
||||
// ① 关系:单条 ≤ 总量 < ZIP64 ≤ u32。前一半保证"能写出来的包读得回来",
|
||||
// 后一半保证我们永远不需要写 ZIP64,也永远撞不到 mrpack 的 fileSize(u32) 上限。
|
||||
// 这三条是**编译期**断言(`const` 块):改坏常量会在 `cargo check` 就红,不用等跑测试。
|
||||
const _: () = assert!(
|
||||
PACK_MAX_ENTRY_BYTES <= PACK_MAX_UNCOMPRESSED_BYTES,
|
||||
"单条上限必须 ≤ 总量上限(否则单条规则永远够不着)"
|
||||
);
|
||||
const _: () = assert!(
|
||||
PACK_MAX_UNCOMPRESSED_BYTES < PACK_ZIP64_BYTES_THR,
|
||||
"总量上限必须严格低于 ZIP64 阈值"
|
||||
);
|
||||
const _: () = assert!(
|
||||
PACK_ZIP64_BYTES_THR <= u32::MAX as u64,
|
||||
"ZIP64 阈值也不该越过 u32(mrpack fileSize 的口径)"
|
||||
);
|
||||
|
||||
// ② 读侧默认限额就是这三个数(不是"看起来一样"的另一份拷贝)。
|
||||
let limits = PackLimits::default();
|
||||
assert_eq!(limits.max_entries, PACK_MAX_ENTRIES);
|
||||
assert_eq!(limits.max_uncompressed_bytes, PACK_MAX_UNCOMPRESSED_BYTES);
|
||||
assert_eq!(limits.max_entry_bytes, PACK_MAX_ENTRY_BYTES);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_write_side_refuses_a_single_entry_over_the_unified_entry_limit() {
|
||||
let root = TempRoot::new("limits-entry");
|
||||
// 恰好 +1 字节超限:拒绝理由是**单条**上限(总量 64 MiB + 1 仍在 512 MiB 之下)。
|
||||
sparse_file(
|
||||
&root.join("overrides/mods/too-big.jar"),
|
||||
PACK_MAX_ENTRY_BYTES + 1,
|
||||
);
|
||||
let target = root.join("out.zip");
|
||||
|
||||
let error = CURSEFORGE
|
||||
.write(
|
||||
&manifest_of(vec![override_file("mods/too-big.jar")]),
|
||||
PackPayload::from_root(&root),
|
||||
&target,
|
||||
)
|
||||
.expect_err("超过单条统一上限必须拒");
|
||||
|
||||
assert_eq!(error.kind(), PackErrorKind::Limits, "{error}");
|
||||
assert!(
|
||||
error.message().contains(&PACK_MAX_ENTRY_BYTES.to_string()),
|
||||
"文案要给出那个数(读侧用的是同一个):{error}"
|
||||
);
|
||||
assert!(
|
||||
error.hint().is_some_and(|hint| hint.contains("移出实例")),
|
||||
"要给可执行的下一步:{error:?}"
|
||||
);
|
||||
assert!(!target.exists(), "预检失败时目标路径不许出现");
|
||||
// 预检在建临时文件之前就失败 ⇒ 目录里只有源目录,没有 `out.zip.*.part`。
|
||||
let leftovers: Vec<String> = std::fs::read_dir(&root)
|
||||
.expect("列目录")
|
||||
.filter_map(Result::ok)
|
||||
.map(|entry| entry.file_name().to_string_lossy().to_string())
|
||||
.filter(|name| name != "overrides")
|
||||
.collect();
|
||||
assert!(leftovers.is_empty(), "不许留临时文件:{leftovers:?}");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_entry_boundary_is_exactly_the_limit() {
|
||||
// 边界口径是 `>`:恰好 64 MiB 合法(与读侧的 `size > max_entry_bytes` 同一侧)。
|
||||
let root = TempRoot::new("limits-boundary");
|
||||
sparse_file(&root.join("overrides/mods/exact.jar"), PACK_MAX_ENTRY_BYTES);
|
||||
let target = root.join("out.zip");
|
||||
|
||||
CURSEFORGE
|
||||
.write(
|
||||
&manifest_of(vec![override_file("mods/exact.jar")]),
|
||||
PackPayload::from_root(&root),
|
||||
&target,
|
||||
)
|
||||
.expect("恰好等于上限必须写得出来(否则两侧的边界就错开了)");
|
||||
let written = crate::addon::pack::PackArchive::open(
|
||||
&target,
|
||||
// 读回来时单条上限就是"恰好等于它",证明写出来的东西读侧认。
|
||||
PackLimits {
|
||||
max_entry_bytes: PACK_MAX_ENTRY_BYTES,
|
||||
..PackLimits::default()
|
||||
},
|
||||
)
|
||||
.expect("刚写出来的包必须能被自己的读取层打开");
|
||||
assert!(written.has_entry("overrides/mods/exact.jar"));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_write_side_refuses_a_total_over_the_unified_total_limit() {
|
||||
// 每条都在单条上限之内(64 MiB),但只要 9 条就超总量(576 MiB > 512 MiB)——
|
||||
// 这条与"单条规则"是两件事:判据是**累计**。
|
||||
let root = TempRoot::new("limits-total");
|
||||
let mut files = Vec::new();
|
||||
for index in 0..9 {
|
||||
let relative = format!("mods/part-{index}.jar");
|
||||
sparse_file(
|
||||
&root.join("overrides").join(&relative),
|
||||
PACK_MAX_ENTRY_BYTES,
|
||||
);
|
||||
files.push(override_file(&relative));
|
||||
}
|
||||
let total = PACK_MAX_ENTRY_BYTES * 9;
|
||||
assert!(total > PACK_MAX_UNCOMPRESSED_BYTES, "样本必须真的超总量");
|
||||
let target = root.join("out.zip");
|
||||
|
||||
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_UNCOMPRESSED_BYTES.to_string()),
|
||||
"文案要给出统一总量上限:{error}"
|
||||
);
|
||||
assert!(!target.exists());
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_real_pack_written_under_the_limit_reopens_under_the_read_limits() {
|
||||
// 端到端的那句话:"我们能写出的包一定能被自己读回来"。用一份正常尺寸的覆盖内容跑一遍。
|
||||
let root = TempRoot::new("limits-parity");
|
||||
write_file(&root.join("overrides/mods/jei.jar"), b"PK\x03\x04 jei");
|
||||
write_file(
|
||||
&root.join("overrides/config/known.toml"),
|
||||
b"shared = true\n",
|
||||
);
|
||||
let target = root.join("out.mrpack");
|
||||
|
||||
crate::addon::pack::modrinth::MODRINTH
|
||||
.write(
|
||||
&manifest_of(vec![
|
||||
override_file("mods/jei.jar"),
|
||||
override_file("config/known.toml"),
|
||||
]),
|
||||
PackPayload::from_root(&root),
|
||||
&target,
|
||||
)
|
||||
.expect("正常尺寸必须写得出来");
|
||||
|
||||
let pack = crate::addon::pack::PackArchive::open_default(&target)
|
||||
.expect("默认限额(= 统一口径)必须读得回来");
|
||||
assert!(pack.has_entry("overrides/mods/jei.jar"));
|
||||
assert!(pack.has_entry("overrides/config/known.toml"));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
@@ -1,6 +1,17 @@
|
||||
//! `addon::pack` 的单元测试:夹具读回、未知字段往返、探测优先级、扩展点、恶意样本。
|
||||
//!
|
||||
//! **全部离线**:没有网络调用,也没有真实整合包样本(见 `fixtures` 的来源声明)。
|
||||
//!
|
||||
//! # 临时目录必须**测试自己收尾**(M160 r2)
|
||||
//!
|
||||
//! 每个用例的临时根是 [`TempRoot`]:`Drop` 里 `remove_dir_all`,所以**正常结束与 panic 展开
|
||||
//! 都会删**(Rust 展开会跑析构)。这条不是洁癖:`zip64` 那族用例要造 4 GiB 的**稀疏文件**验
|
||||
//! 阈值,残留会把 `%TEMP%` 堆到撑爆整台机器(实测 1651 个目录 / 110 GB,把 CI 与别的 agent
|
||||
//! 的构建一起咬死)。
|
||||
//!
|
||||
//! 写法的来源是仓库既有的那一份(`crate::update::tests::support::TempDir`):同样的 `new` /
|
||||
//! `join` / `Drop` 三件套,只把前缀换成 `ull-pack-`,不引 `tempfile`、不新增第四种范式。
|
||||
//! 不信"系统会自动清 `%TEMP%`"——那要靠时间或外部工具,测试必须自证干净。
|
||||
|
||||
mod detect;
|
||||
mod export;
|
||||
@@ -9,6 +20,7 @@ mod export_pipeline;
|
||||
mod export_roundtrip;
|
||||
mod extend;
|
||||
mod fixtures;
|
||||
mod limits;
|
||||
mod model;
|
||||
mod roundtrip;
|
||||
mod safety;
|
||||
@@ -18,14 +30,8 @@ mod zip64;
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
/// 本次进程内唯一的临时根目录(沿用仓库既有约定:不引 `tempfile`)。
|
||||
pub(crate) fn temp_root(tag: &str) -> PathBuf {
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
std::env::temp_dir().join(format!("ull-pack-{}-{tag}-{unique}", std::process::id()))
|
||||
}
|
||||
pub(crate) use crate::addon::temp::TempRoot;
|
||||
|
||||
/// 写一个夹具文件(自动建父目录)。
|
||||
pub(crate) fn write_file(path: &Path, bytes: &[u8]) {
|
||||
@@ -106,3 +112,58 @@ pub(crate) fn write_zip_with_symlink(
|
||||
.expect("写符号链接条目");
|
||||
writer.finish().expect("收尾 zip");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_temp_root_is_gone_once_dropped() {
|
||||
// 自证①:正常路径。用完(drop)之后连目录都不该存在。
|
||||
let path = {
|
||||
let root = TempRoot::new("self-proof");
|
||||
write_file(&root.join("overrides/mods/a.jar"), b"x");
|
||||
assert!(root.path().is_dir(), "活在的时候必须是真目录");
|
||||
root.path().to_path_buf()
|
||||
};
|
||||
assert!(
|
||||
!path.exists(),
|
||||
"drop 之后临时根必须被删掉:{}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_panicking_test_still_removes_its_temp_root() {
|
||||
// 自证②:panic 展开路径。断言"panic 也删"——这正是 4 GiB 稀疏文件残留的那条路:
|
||||
// 断言红了不代表可以留下 110 GB。
|
||||
let path = std::sync::Arc::new(std::sync::Mutex::new(PathBuf::new()));
|
||||
let slot = std::sync::Arc::clone(&path);
|
||||
let outcome = std::panic::catch_unwind(move || {
|
||||
let root = TempRoot::new("self-proof-panic");
|
||||
write_file(&root.join("overrides/mods/a.jar"), b"x");
|
||||
*slot.lock().expect("锁") = root.path().to_path_buf();
|
||||
panic!("故意失败:展开时守卫要照删不误");
|
||||
});
|
||||
assert!(outcome.is_err(), "这个用例必须真的走 panic 分支");
|
||||
let path = path.lock().expect("锁").clone();
|
||||
assert!(
|
||||
!path.exists(),
|
||||
"panic 之后临时根必须被删掉:{}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sub_directory_root_takes_its_parent_with_it() {
|
||||
// 自证③:`under` 造的子树删的是**根**,不给 `%TEMP%` 留一堆空壳父目录。
|
||||
let (sub, parent) = {
|
||||
let root = TempRoot::under("self-proof-under", "instance");
|
||||
write_file(&root.join("mmc-pack.json"), b"{}");
|
||||
(
|
||||
root.path().to_path_buf(),
|
||||
root.path().parent().map(Path::to_path_buf),
|
||||
)
|
||||
};
|
||||
assert!(!sub.exists(), "子树要删");
|
||||
assert!(
|
||||
parent.is_some_and(|parent| !parent.exists()),
|
||||
"父目录也不许留空壳"
|
||||
);
|
||||
}
|
||||
@@ -215,7 +215,7 @@ fn multimc_reads_instance_directory() {
|
||||
|
||||
#[test]
|
||||
fn multimc_export_is_refused() {
|
||||
let root = super::temp_root("mmc-write");
|
||||
let root = super::TempRoot::new("mmc-write");
|
||||
let target = root.join("out.zip");
|
||||
let manifest = PackManifest::new("whatever");
|
||||
let error = MULTIMC
|
||||
@@ -236,7 +236,7 @@ fn registry_read_reports_matched_format() {
|
||||
|
||||
#[test]
|
||||
fn curseforge_without_name_is_a_metadata_error() {
|
||||
let root = super::temp_root("cf-noname");
|
||||
let root = super::TempRoot::new("cf-noname");
|
||||
let target = root.join("nope.zip");
|
||||
super::write_zip(
|
||||
&target,
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
//! 解压总体积上限、单条目上限、规整后重名、篡改载荷(CRC 不符)、以及**清单字段里的**穿越
|
||||
//! (mrpack `files[].path`、CF `overrides`)。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::Path;
|
||||
|
||||
use super::{temp_root, write_zip, write_zip_with_symlink};
|
||||
use super::{write_zip, write_zip_with_symlink, TempRoot};
|
||||
use crate::addon::pack::archive::{PackArchive, PackLimits};
|
||||
use crate::addon::pack::curseforge::CURSEFORGE;
|
||||
use crate::addon::pack::error::PackErrorKind;
|
||||
@@ -15,8 +15,8 @@ use crate::addon::pack::manifest::{PackLoader, PackManifest, PackOverrides, Pack
|
||||
use crate::addon::pack::modrinth::MODRINTH;
|
||||
|
||||
/// 打一个只有一条恶意条目的 zip。
|
||||
fn single_entry(tag: &str, name: &str) -> PathBuf {
|
||||
let target = temp_root(tag).join("evil.zip");
|
||||
fn single_entry(tag: &str, name: &str) -> TempRoot {
|
||||
let target = TempRoot::at(tag, "evil.zip");
|
||||
write_zip(&target, &[(name, &b"payload"[..])]);
|
||||
target
|
||||
}
|
||||
@@ -81,7 +81,8 @@ fn rejects_drive_letter_and_ads_entries() {
|
||||
|
||||
#[test]
|
||||
fn rejects_symlink_entries() {
|
||||
let target = temp_root("symlink").join("link.zip");
|
||||
let root = TempRoot::new("symlink");
|
||||
let target = root.join("link.zip");
|
||||
write_zip_with_symlink(
|
||||
&target,
|
||||
&[("readme.txt", &b"x"[..])],
|
||||
@@ -94,7 +95,8 @@ fn rejects_symlink_entries() {
|
||||
|
||||
#[test]
|
||||
fn rejects_too_many_entries() {
|
||||
let target = temp_root("limit-count").join("many.zip");
|
||||
let root = TempRoot::new("limit-count");
|
||||
let target = root.join("many.zip");
|
||||
write_zip(
|
||||
&target,
|
||||
&[
|
||||
@@ -111,7 +113,8 @@ fn rejects_too_many_entries() {
|
||||
|
||||
#[test]
|
||||
fn rejects_uncompressed_volume_over_limit() {
|
||||
let target = temp_root("limit-volume").join("big.zip");
|
||||
let root = TempRoot::new("limit-volume");
|
||||
let target = root.join("big.zip");
|
||||
let payload = vec![b'a'; 4096];
|
||||
write_zip(
|
||||
&target,
|
||||
@@ -124,7 +127,8 @@ fn rejects_uncompressed_volume_over_limit() {
|
||||
|
||||
#[test]
|
||||
fn rejects_a_single_oversized_entry() {
|
||||
let target = temp_root("limit-entry").join("big.zip");
|
||||
let root = TempRoot::new("limit-entry");
|
||||
let target = root.join("big.zip");
|
||||
let payload = vec![b'a'; 2048];
|
||||
write_zip(&target, &[("a.bin", payload.as_slice())]);
|
||||
let error = PackArchive::open(&target, PackLimits::new(16, 1024 * 1024, 512))
|
||||
@@ -135,7 +139,8 @@ fn rejects_a_single_oversized_entry() {
|
||||
#[test]
|
||||
fn rejects_names_that_collide_after_normalization() {
|
||||
// `./a.txt` 与 `a.txt` 规整后同名:留着就是"读到的和落盘的"不是同一个文件的经典混淆。
|
||||
let target = temp_root("dup-name").join("dup.zip");
|
||||
let root = TempRoot::new("dup-name");
|
||||
let target = root.join("dup.zip");
|
||||
write_zip(&target, &[("./a.txt", &b"1"[..]), ("a.txt", &b"2"[..])]);
|
||||
let error = open_error(&target);
|
||||
assert_eq!(error.kind(), PackErrorKind::Metadata, "{}", error.message());
|
||||
@@ -145,7 +150,8 @@ fn rejects_names_that_collide_after_normalization() {
|
||||
fn a_tampered_payload_is_not_silently_accepted() {
|
||||
// 中央目录没问题、数据被改过(CRC 不符):打开能过,读内容必须报错。
|
||||
const MARKER: &[u8] = b"UNIQUE-PAYLOAD-MARKER";
|
||||
let target = temp_root("tampered").join("tampered.zip");
|
||||
let root = TempRoot::new("tampered");
|
||||
let target = root.join("tampered.zip");
|
||||
write_zip(
|
||||
&target,
|
||||
&[
|
||||
@@ -182,7 +188,8 @@ fn a_tampered_payload_is_not_silently_accepted() {
|
||||
|
||||
#[test]
|
||||
fn mrpack_file_path_traversal_is_rejected() {
|
||||
let target = temp_root("mr-trav").join("evil.mrpack");
|
||||
let root = TempRoot::new("mr-trav");
|
||||
let target = root.join("evil.mrpack");
|
||||
let index = r#"{"game":"minecraft","formatVersion":1,"versionId":"1.0","name":"evil pack",
|
||||
"files":[{"path":"../evil.jar",
|
||||
"hashes":{"sha512":"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"},
|
||||
@@ -201,7 +208,8 @@ fn mrpack_file_path_traversal_is_rejected() {
|
||||
|
||||
#[test]
|
||||
fn curseforge_overrides_dir_traversal_is_rejected() {
|
||||
let target = temp_root("cf-trav").join("evil.zip");
|
||||
let root = TempRoot::new("cf-trav");
|
||||
let target = root.join("evil.zip");
|
||||
write_zip(
|
||||
&target,
|
||||
&[(
|
||||
@@ -248,7 +256,8 @@ fn write_side_path_check_catches_hand_built_models() {
|
||||
|
||||
#[test]
|
||||
fn missing_package_is_an_io_error() {
|
||||
let target = temp_root("missing").join("does-not-exist.zip");
|
||||
let root = TempRoot::new("missing");
|
||||
let target = root.join("does-not-exist.zip");
|
||||
let error = open_error(&target);
|
||||
assert_eq!(error.kind(), PackErrorKind::Io, "{}", error.message());
|
||||
}
|
||||
@@ -258,7 +267,7 @@ fn directory_source_rejects_symlinks_when_the_platform_can_make_them() {
|
||||
// Windows 建符号链接需要开发者模式/管理员:建不出来就跳过,但绝不静默当通过。
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let root = temp_root("dir-symlink").join("instance");
|
||||
let root = TempRoot::under("dir-symlink", "instance");
|
||||
std::fs::create_dir_all(&root).expect("建目录");
|
||||
std::fs::write(root.join("mmc-pack.json"), b"{}").expect("写文件");
|
||||
std::os::unix::fs::symlink("/etc/passwd", root.join("escape")).expect("建符号链接");
|
||||
|
||||
@@ -12,7 +12,7 @@ use crate::addon::pack::modrinth::MODRINTH;
|
||||
#[test]
|
||||
fn mrpack_env_unknown_keys_survive_read_write_reread() {
|
||||
// r1 p3-3 的一处:`files[].env` 里的未知键(`env_extra`)此前没有任何断言。
|
||||
let root = super::temp_root("env-extra");
|
||||
let root = super::TempRoot::new("env-extra");
|
||||
let dir = root.join("src");
|
||||
super::write_file(
|
||||
&dir.join("modrinth.index.json"),
|
||||
@@ -44,7 +44,7 @@ fn mrpack_env_unknown_keys_survive_read_write_reread() {
|
||||
#[test]
|
||||
fn curseforge_file_unknown_keys_survive_read_write_reread() {
|
||||
// r1 p3-3 的另一处:CF `files[].fileName` / `url` 是 HMCL 补全后回写的扩展点。
|
||||
let root = super::temp_root("cf-file-extra");
|
||||
let root = super::TempRoot::new("cf-file-extra");
|
||||
let dir = root.join("src");
|
||||
super::write_file(
|
||||
&dir.join("manifest.json"),
|
||||
@@ -81,7 +81,7 @@ fn curseforge_file_unknown_keys_survive_read_write_reread() {
|
||||
#[test]
|
||||
fn curseforge_loader_unknown_keys_survive_read_write_reread() {
|
||||
// r1 p3-3 的第三处:`modLoaders[]` 里的未知键(`PackLoader::extra`)。
|
||||
let root = super::temp_root("cf-loader-extra");
|
||||
let root = super::TempRoot::new("cf-loader-extra");
|
||||
let dir = root.join("src");
|
||||
super::write_file(
|
||||
&dir.join("manifest.json"),
|
||||
@@ -117,7 +117,7 @@ fn mrpack_file_unknown_keys_survive_read_write_reread() {
|
||||
// 第四个假边(M152 补):`files[]` 这一层。上面三条覆盖了 `env` / CF `files[]` /
|
||||
// CF `modLoaders[]`,而 mrpack 的文件级未知键一直没人钉——它恰恰是"社区给某条文件加
|
||||
// 一个私有字段"最常出现的位置(例如镜像站塞的 `mirror` / 作者写的 `optionalReason`)。
|
||||
let root = super::temp_root("mr-file-extra");
|
||||
let root = super::TempRoot::new("mr-file-extra");
|
||||
let dir = root.join("src");
|
||||
super::write_file(
|
||||
&dir.join("modrinth.index.json"),
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
//! ZIP64 拒写(research/18-p2 §8.6 / 表 B #17 的导出侧那条)。
|
||||
//! 导出侧的体积闸:**拒写**过大包(research/18-p2 §8.6 / 表 B #17 的导出侧那条)。
|
||||
//!
|
||||
//! # 为什么这个文件以前是空的
|
||||
//! # 现在的判据是「与导入读取同一份常量」(M160)
|
||||
//!
|
||||
//! M112 的完成报告写明:「ZIP64 阈值那条分支只有代码没有端到端测试(要造 4 GiB 数据)」。
|
||||
//! 而更要紧的是——**那条分支当时根本够不着**:实现先 `std::fs::read` 整个覆盖文件进内存,
|
||||
//! 再累加比较,所以真要导出一个 4 GiB 文件的实例,内存先炸,用户永远看不到那句
|
||||
//! `pack_limits`。现在写侧改成「先按 `metadata().len()` 预检」,两个问题一起解决:
|
||||
//! 以前判据是 `zip::ZIP64_BYTES_THR`(≈4 GiB),而导入读取只允许 512 MiB —— 于是存在
|
||||
//! 「导得出来、导不回去」的包。M160 把写侧上限换成读侧那份常量(`pack::archive::limits`),
|
||||
//! 本文件的两条用例(单文件超大 / 累计超大)仍然钉住**同一件事**:闸门在碰内容之前就把包拒了。
|
||||
//! 只是拒绝的理由从"要写 ZIP64"变成"超过统一上限,所以永远不会写出 ZIP64"。
|
||||
//!
|
||||
//! - 边界是真的会在读第一个字节之前拦下来的(本文件的用例就断言这一点:失败后临时目录里
|
||||
//! 没有 `.part`、目标路径不存在);
|
||||
//! - 测试可以用**稀疏文件**造出 4 GiB 的"声明尺寸"而几乎不占磁盘(`set_len` 只改 EOF,
|
||||
//! NTFS / ext4 都不会真写零)。这也是唯一能在不塞满硬盘的前提下打通这条分支的办法。
|
||||
//! # 为什么用稀疏文件
|
||||
//!
|
||||
//! 判据是**声明尺寸**(`metadata().len()`),所以测试可以用稀疏文件造出 GB 级的"声明尺寸"而
|
||||
//! 几乎不占磁盘(`set_len` 只改 EOF,NTFS / ext4 都不会真写零)。这也是唯一能在不塞满硬盘的
|
||||
//! 前提下打通这条分支的办法。
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use super::temp_root;
|
||||
use super::TempRoot;
|
||||
use crate::addon::pack::curseforge::CURSEFORGE;
|
||||
use crate::addon::pack::error::PackErrorKind;
|
||||
use crate::addon::pack::format::{PackFormat, PackPayload};
|
||||
@@ -64,7 +65,7 @@ fn leftovers(root: &Path, keep: &[&str]) -> Vec<String> {
|
||||
|
||||
#[test]
|
||||
fn one_file_over_the_threshold_is_refused_before_a_single_byte_is_read() {
|
||||
let root = temp_root("zip64-single");
|
||||
let root = TempRoot::new("zip64-single");
|
||||
// 恰好一个字节超过阈值:预检必须在这里就报,不许读内容(真读会先要 4 GiB 内存)。
|
||||
sparse_file(
|
||||
&root.join("overrides/mods/huge.jar"),
|
||||
@@ -91,7 +92,7 @@ fn one_file_over_the_threshold_is_refused_before_a_single_byte_is_read() {
|
||||
#[test]
|
||||
fn the_threshold_is_cumulative_not_per_file() {
|
||||
// 三个文件各自都远小于阈值,加起来超了:判据是**整包未压缩总量**,不是单文件。
|
||||
let root = temp_root("zip64-cumulative");
|
||||
let root = TempRoot::new("zip64-cumulative");
|
||||
let each = 1_610_612_736_u64; // 1.5 GiB
|
||||
assert!(each < zip::ZIP64_BYTES_THR, "单条必须都在阈值以下");
|
||||
assert!(each * 3 > zip::ZIP64_BYTES_THR, "三条加起来必须超标");
|
||||
@@ -115,7 +116,7 @@ fn the_threshold_is_cumulative_not_per_file() {
|
||||
#[test]
|
||||
fn an_export_under_the_threshold_still_writes_a_normal_pack() {
|
||||
// 对照:不超阈值时预检不许误伤(否则这条护栏就变成"导出全挂")。
|
||||
let root = temp_root("zip64-under");
|
||||
let root = TempRoot::new("zip64-under");
|
||||
let content = b"a normal sized override file";
|
||||
std::fs::create_dir_all(root.join("overrides/mods")).expect("建目录");
|
||||
std::fs::write(root.join("overrides/mods/jei.jar"), content).expect("写覆盖文件");
|
||||
@@ -130,53 +131,41 @@ fn an_export_under_the_threshold_still_writes_a_normal_pack() {
|
||||
assert!(written.has_entry("overrides/mods/jei.jar"));
|
||||
}
|
||||
|
||||
/// 端到端版的窗口回归钉(**不进默认套件**:真写 2.5 GB 要 ~70 秒 deflate,且会把机器内存 /
|
||||
/// 页面文件顶上去;塔台 2026-11-07 的资源配置令要求避免)。
|
||||
/// 端到端版的旧窗口回归钉 → **现在钉的是口径统一**(`#[ignore]`:要造稀疏大文件 + 一遍 deflate)。
|
||||
///
|
||||
/// 默认套件里由 `format::write` 的单测 `the_second_gate_counts_from_the_manifest_length_not_
|
||||
/// the_preflight_total` 便宜地钉住同一个窗口(把窗口里的字节数直接喂给第二道闸的计数器)。
|
||||
/// 本条留给"真跑一遍确认容器层接线没错"的场合:
|
||||
/// M160 把写侧的上限换成读侧那份常量(512 MiB 总量 / 64 MiB 单条),所以 M152 时代
|
||||
/// 「2.5 GB 单文件落在 ZIP64 过拒窗口里、必须能导出」这件事**按设计不再成立**:那个包现在导得
|
||||
/// 出来、导不回去。本条留下的是新口径的端到端证据——2.5 GB 的覆盖文件在**碰内容之前**就被
|
||||
/// 统一上限拒掉,且临时文件与目标路径都不留。
|
||||
///
|
||||
/// ```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");
|
||||
#[ignore = "造 2.5 GB 稀疏文件再跑一遍预检:默认套件用 `Written` 的便宜单测与 tests/limits.rs 钉同一口径"]
|
||||
fn a_single_file_over_the_old_zip64_window_is_refused_by_the_unified_limit() {
|
||||
let root = TempRoot::new("zip64-window");
|
||||
let length = 2_500_000_000_u64;
|
||||
assert!(length < zip::ZIP64_BYTES_THR, "样本必须在阈值之下");
|
||||
assert!(
|
||||
length > zip::ZIP64_BYTES_THR / 2,
|
||||
"样本必须落在过拒窗口里(≥ 阈值一半)"
|
||||
length < crate::addon::pack::archive::limits::PACK_ZIP64_BYTES_THR,
|
||||
"样本仍在 ZIP64 阈值之下:拒绝的理由是**统一上限**,不是 ZIP64"
|
||||
);
|
||||
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);
|
||||
let error = CURSEFORGE
|
||||
.write(&manifest, PackPayload::from_root(&root), &target)
|
||||
.expect_err("超过统一上限(512 MiB)必须拒:否则导出的包自己导不回来");
|
||||
assert_eq!(error.kind(), PackErrorKind::Limits, "{error}");
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"2.5 GB 的覆盖文件没超 ZIP64 阈值,不许误拒:{:?}",
|
||||
result.err()
|
||||
error.message().contains(
|
||||
&crate::addon::pack::archive::limits::PACK_MAX_UNCOMPRESSED_BYTES.to_string()
|
||||
),
|
||||
"文案要给出统一上限那个数:{error}"
|
||||
);
|
||||
assert!(!target.exists(), "预检失败时目标路径不许出现");
|
||||
|
||||
// 包内条目的**声明未压缩尺寸**就是 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 越积越多。
|
||||
// 稀疏源文件只占元数据,但目录与临时文件都清掉,别让 temp 越积越多。
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
//! 测试用的**一次性临时根**(`Drop` 即删;panic 展开时同样删)。
|
||||
//!
|
||||
//! # 为什么它必须在 `addon` 里而不是某个测试目录里
|
||||
//!
|
||||
//! `addon::pack` 与 `addon::tests` 两族用例都建临时目录,而 `pack/tests/fixtures.rs` 被**两边
|
||||
//! 同时**通过 `#[path]` 编译(同一份手造包只维护一处)。守卫因此要放在两边的公共祖先上,否则
|
||||
//! `fixtures.rs` 里的 `super::TempRoot` 在其中一侧解析不到。
|
||||
//!
|
||||
//! # 为什么不能靠"系统会清 `%TEMP%`"
|
||||
//!
|
||||
//! 那种清理要等时间或外部工具,而 `zip64` 那族用例每个要造 4 GiB 的**稀疏文件**验阈值:
|
||||
//! M160 r2 实测 `%TEMP%/ull-pack-*` 堆到 **1651 个目录 / 110 GB**,把整台机器(含别的 agent
|
||||
//! 的构建)一起顶死。所以测试自己负责收尾,并且有自证用例
|
||||
//! (`pack/tests/mod.rs` 的 `the_temp_root_is_gone_once_dropped` /
|
||||
//! `a_panicking_test_still_removes_its_temp_root`)。
|
||||
//!
|
||||
//! # 形状来自仓库既有写法
|
||||
//!
|
||||
//! 与 [`crate::update::tests::support::TempDir`] 同一套 `new` / `join` / `Drop` 三件套
|
||||
//! (不引 `tempfile`、不新增范式),只多了两件事:`Deref<Target = Path>` 让调用点能直接当
|
||||
//! `&Path` 用;[`TempRoot::under`] 让"子树落点、删父目录"这件事不留下空壳。
|
||||
|
||||
use std::ops::Deref;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
/// 一次性临时根目录(**`Drop` 即删**)。
|
||||
pub(crate) struct TempRoot {
|
||||
/// 真正要删的根([`Self::under`] 造的子树也删它,不留空壳父目录)。
|
||||
root: PathBuf,
|
||||
/// 本夹具的落点(通常等于 `root`;`under` 时是 `root/<sub>`)。
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl TempRoot {
|
||||
/// 建一个新的临时根(同名残留先清掉:`COUNTER` 在同一进程内唯一,跨进程靠 pid)。
|
||||
pub(crate) fn new(tag: &str) -> Self {
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("ull-pack-{}-{tag}-{unique}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(&root).expect("建临时根");
|
||||
Self {
|
||||
path: root.clone(),
|
||||
root,
|
||||
}
|
||||
}
|
||||
|
||||
/// 在临时根下开一个子目录当落点(`Drop` 删的是**根**,父目录不会剩空壳)。
|
||||
pub(crate) fn under(tag: &str, sub: &str) -> Self {
|
||||
let base = Self::new(tag);
|
||||
let path = base.path.join(sub);
|
||||
std::fs::create_dir_all(&path).expect("建临时子目录");
|
||||
Self {
|
||||
root: base.root.clone(),
|
||||
path,
|
||||
}
|
||||
}
|
||||
|
||||
/// 建一个临时根,并让落点直接是根内的某个文件(`Drop` 删的仍是**根**)。
|
||||
///
|
||||
/// 给"夹具函数要返回一个包路径、而包路径必须活到测试用完"的场景用:返回值 `Deref` 到那个
|
||||
/// 文件,根由它持有。`root.parent()` 拿得到根目录。
|
||||
pub(crate) fn at(tag: &str, file: &str) -> Self {
|
||||
let mut base = Self::new(tag);
|
||||
base.path = base.path.join(file);
|
||||
base
|
||||
}
|
||||
|
||||
/// 本夹具的落点。
|
||||
pub(crate) fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
|
||||
/// 落点下的一条路径(`&str` 与 `Path` 都收,免得把调用点写成两套)。
|
||||
pub(crate) fn join(&self, name: impl AsRef<Path>) -> PathBuf {
|
||||
self.path.join(name)
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for TempRoot {
|
||||
type Target = Path;
|
||||
|
||||
fn deref(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<Path> for TempRoot {
|
||||
/// 让 `&TempRoot` 能直接喂给 `impl AsRef<Path>` 形态的 API(`fs::read_dir` / `remove_dir_all`
|
||||
/// / `PackArchive::open_default` …)。`Deref` 只解决"要 `&Path`"的场景,两者都要。
|
||||
fn as_ref(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempRoot {
|
||||
fn drop(&mut self) {
|
||||
// 尽力删:删不掉留一行日志,别在析构里 panic(二次 panic 会 abort)。
|
||||
if let Err(error) = std::fs::remove_dir_all(&self.root) {
|
||||
if error.kind() != std::io::ErrorKind::NotFound {
|
||||
eprintln!("[temp] 删 {} 失败:{error}", self.root.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ use crate::addon::loader::LoaderKind;
|
||||
use crate::download::blocking::block_on;
|
||||
|
||||
use super::support::{curse_meta, StubFetcher, CURSE_URL};
|
||||
use super::{fixtures, list_tree, read_text, temp_root};
|
||||
use super::{fixtures, list_tree, read_text, TempRoot};
|
||||
|
||||
/// CF 夹具里那两个条目补全出来的内容。
|
||||
const CURSE_BYTES: &[u8] = b"stub curse file bytes";
|
||||
@@ -16,7 +16,8 @@ const CURSE_BYTES: &[u8] = b"stub curse file bytes";
|
||||
#[test]
|
||||
fn imports_curseforge_pack_end_to_end() {
|
||||
let fixture = fixtures::curseforge("cf-e2e");
|
||||
let target = temp_root("cf-e2e-out").join("instance");
|
||||
let root = TempRoot::new("cf-e2e-out");
|
||||
let target = root.join("instance");
|
||||
let fetcher = StubFetcher::new()
|
||||
.with_curse(238222, 5102025, curse_meta("jei.jar", CURSE_BYTES))
|
||||
.with_curse(306612, 4587454, curse_meta("fabric-api.jar", CURSE_BYTES))
|
||||
@@ -94,7 +95,8 @@ fn imports_curseforge_pack_end_to_end() {
|
||||
fn offline_curseforge_import_is_partial_not_success() {
|
||||
// 没有 fetcher ⇒ CF 的 files[] 一条都补不出来。这**不是**失败,也**不许**记成成功。
|
||||
let fixture = fixtures::curseforge("cf-offline");
|
||||
let target = temp_root("cf-offline-out").join("instance");
|
||||
let root = TempRoot::new("cf-offline-out");
|
||||
let target = root.join("instance");
|
||||
let request = ImportRequest::new(&fixture.zip, &target);
|
||||
let report = block_on(import_pack(&request));
|
||||
|
||||
@@ -115,7 +117,8 @@ fn offline_curseforge_import_is_partial_not_success() {
|
||||
fn curseforge_meta_without_download_url_is_missing_not_guessed() {
|
||||
// CF 作者可以关掉 API 分发(`downloadUrl` 为 null):我们**不猜** URL,只如实记缺。
|
||||
let fixture = fixtures::curseforge("cf-nourl");
|
||||
let target = temp_root("cf-nourl-out").join("instance");
|
||||
let root = TempRoot::new("cf-nourl-out");
|
||||
let target = root.join("instance");
|
||||
let mut meta = curse_meta("jei.jar", CURSE_BYTES);
|
||||
meta.url = None;
|
||||
let fetcher = StubFetcher::new()
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
//! 逐文件冲突决策(M160):预检列清单 → 用户点头 → 导入按决策处置。
|
||||
//!
|
||||
//! # 一张表看完全部用例
|
||||
//!
|
||||
//! | 用例 | 钉住的口径 |
|
||||
//! |------|------------|
|
||||
//! | [`scan_lists_only_files_that_exist_and_differ`] | 进清单的条件:**已存在且内容不同**;来源记胜者侧 |
|
||||
//! | [`scan_is_silent_about_identical_and_missing_files`] | 内容相同 / 目标不存在 ⇒ 一行都不列 |
|
||||
//! | [`scan_does_not_ask_about_files_the_user_deleted`] | 用户删过的不算"已存在且内容不同" |
|
||||
//! | [`no_decision_means_keep_and_nothing_is_overwritten`] | **缺省保留**(报告的 choice 与磁盘内容双证) |
|
||||
//! | [`per_item_overwrite_touches_only_the_named_path`] | 逐项决策:只动点名那条,最终文件集逐字节断言 |
|
||||
//! | [`overwrite_all_is_the_batch_channel_from_the_same_table`] | 整批档来自同一张表(`ImportScan::overwrite_all`) |
|
||||
//! | [`a_kept_file_is_still_a_conflict_next_time`] | 基线记的是**包内**哈希,所以保留过一次的文件不会被静默覆盖 |
|
||||
//! | [`curseforge_entries_without_a_path_are_counted_not_guessed`] | 补全前没有路径 ⇒ 不猜,只计数 |
|
||||
//!
|
||||
//! 全部离线:远端走 [`support::StubFetcher`],没有一条网络请求。
|
||||
|
||||
use super::support::{mrpack_stub, sha1, StubFetcher};
|
||||
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,
|
||||
};
|
||||
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");
|
||||
let target = seeded("dec-scan");
|
||||
let scan = scan_pack(&ImportRequest::new(&pack, &target));
|
||||
|
||||
assert!(scan.is_ok(), "预检不许失败:{:?}", scan.error);
|
||||
assert_eq!(
|
||||
paths(&scan),
|
||||
vec!["config/known.toml", "mods/layer.jar"],
|
||||
"只列「已存在且内容不同」的两条(same.toml 与 remote.jar 不该出现)"
|
||||
);
|
||||
let known = &scan.conflicts[0];
|
||||
assert_eq!(
|
||||
known.source,
|
||||
FileSource::ClientOverrides,
|
||||
"叠层的胜者是 client-overrides 侧,来源要记它"
|
||||
);
|
||||
assert_eq!(known.reason, ConflictReason::PreExisting);
|
||||
assert_eq!(known.pack_sha1, sha1(KNOWN_CLIENT));
|
||||
assert_eq!(known.pack_size, KNOWN_CLIENT.len() as u64);
|
||||
assert_eq!(known.disk_sha1, sha1(USER_KNOWN));
|
||||
assert_eq!(
|
||||
known.disk_size,
|
||||
USER_KNOWN.len() as u64,
|
||||
"要给出目标现有体积"
|
||||
);
|
||||
assert_eq!(known.base_sha1, None, "首次导入没有基线");
|
||||
assert_eq!(
|
||||
scan.lost_bytes(),
|
||||
(USER_KNOWN.len() + USER_LAYER.len()) as u64
|
||||
);
|
||||
assert!(
|
||||
scan.needs_decision(),
|
||||
"有冲突就必须让用户点头,不能自己决定"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_is_silent_about_identical_and_missing_files() {
|
||||
let (pack, _) = layered_pack("dec-same");
|
||||
let root = TempRoot::new("dec-same");
|
||||
let target = root.join("instance");
|
||||
// 只放一个**内容与包内一致**的文件:一行都不该列出来。
|
||||
write_file(&target.join("config/same.toml"), SAME);
|
||||
let scan = scan_pack(&ImportRequest::new(&pack, &target));
|
||||
|
||||
assert!(scan.is_ok(), "{:?}", scan.error);
|
||||
assert!(
|
||||
paths(&scan).is_empty(),
|
||||
"内容一致不算冲突:{:?}",
|
||||
paths(&scan)
|
||||
);
|
||||
assert!(!scan.needs_decision(), "没有冲突就不该打扰用户");
|
||||
assert_eq!(scan.unknown_remote, 0, "mrpack 条目自带路径,判得了");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_does_not_ask_about_files_the_user_deleted() {
|
||||
let (pack, fetcher) = layered_pack("dec-deleted");
|
||||
let target = seeded("dec-deleted");
|
||||
// 先完整导入一次(有基线了),再删掉其中两个文件。
|
||||
let first = block_on(import_pack(
|
||||
&ImportRequest::new(&pack, &target)
|
||||
.with_fetcher(&fetcher)
|
||||
.with_decisions(ConflictDecisions::overwrite_all()),
|
||||
));
|
||||
assert_eq!(first.status, ImportStatus::Success, "{:?}", first.error);
|
||||
std::fs::remove_file(target.join("config/known.toml")).expect("删文件");
|
||||
std::fs::remove_file(target.join("mods/remote.jar")).expect("删文件");
|
||||
|
||||
let scan = scan_pack(&ImportRequest::new(&pack, &target));
|
||||
assert!(
|
||||
paths(&scan).is_empty(),
|
||||
"用户删过的文件不是「已存在且内容不同」,不该问:{:?}",
|
||||
paths(&scan)
|
||||
);
|
||||
|
||||
// 导入同样不把它们装回来(原有的 `UserDeleted` 判据仍然生效)。
|
||||
let second = block_on(import_pack(
|
||||
&ImportRequest::new(&pack, &target).with_fetcher(&fetcher),
|
||||
));
|
||||
assert_eq!(second.status, ImportStatus::Partial);
|
||||
assert!(!target.join("config/known.toml").exists(), "不许偷偷装回来");
|
||||
assert!(
|
||||
second.conflicts.iter().any(|conflict| {
|
||||
conflict.path == "config/known.toml" && conflict.reason == ConflictReason::UserDeleted
|
||||
}),
|
||||
"但仍要按事记账:{:?}",
|
||||
second.conflicts
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_decision_means_keep_and_nothing_is_overwritten() {
|
||||
let (pack, fetcher) = layered_pack("dec-keep");
|
||||
let target = seeded("dec-keep");
|
||||
let report = block_on(import_pack(
|
||||
&ImportRequest::new(&pack, &target).with_fetcher(&fetcher),
|
||||
));
|
||||
|
||||
// 磁盘内容逐字节没变(**这是"缺省不覆盖"的直接证据**)。
|
||||
assert_eq!(
|
||||
read_text(&target.join("config/known.toml")),
|
||||
"shared = mine\n"
|
||||
);
|
||||
assert_eq!(read_text(&target.join("mods/layer.jar")), "my layer\n");
|
||||
// 报告里这两条都记成 KeptUser + KeepUser。
|
||||
for path in ["config/known.toml", "mods/layer.jar"] {
|
||||
let conflict = report
|
||||
.conflicts
|
||||
.iter()
|
||||
.find(|conflict| conflict.path == path)
|
||||
.unwrap_or_else(|| panic!("{path} 要进冲突清单:{:?}", report.conflicts));
|
||||
assert_eq!(
|
||||
conflict.choice,
|
||||
crate::addon::import::ConflictChoice::KeepUser
|
||||
);
|
||||
let file = report
|
||||
.files
|
||||
.iter()
|
||||
.find(|file| file.path == path)
|
||||
.expect("逐文件结果里也要有它");
|
||||
assert_eq!(file.action, FileAction::KeptUser);
|
||||
}
|
||||
// 不冲突的那些照常落地(「保留」不会把整次导入变成什么都不做)。
|
||||
assert_eq!(
|
||||
read_text(&target.join("mods/remote.jar")),
|
||||
"remote jar bytes"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn per_item_overwrite_touches_only_the_named_path() {
|
||||
let (pack, fetcher) = layered_pack("dec-item");
|
||||
let target = seeded("dec-item");
|
||||
let scan = scan_pack(&ImportRequest::new(&pack, &target));
|
||||
assert_eq!(paths(&scan), vec!["config/known.toml", "mods/layer.jar"]);
|
||||
|
||||
// 只对 `config/known.toml` 说"覆盖",另一条不给决策。
|
||||
let decisions =
|
||||
ConflictDecisions::keep_all().with("config/known.toml", ConflictChoice::Overwrite);
|
||||
let report = block_on(import_pack(
|
||||
&ImportRequest::new(&pack, &target)
|
||||
.with_fetcher(&fetcher)
|
||||
.with_decisions(decisions),
|
||||
));
|
||||
|
||||
// 最终文件集:点名那条被覆盖,没点名那条原样保留。
|
||||
assert_eq!(
|
||||
read_text(&target.join("config/known.toml")),
|
||||
"shared = client\n"
|
||||
);
|
||||
assert_eq!(read_text(&target.join("mods/layer.jar")), "my layer\n");
|
||||
assert_eq!(read_text(&target.join("config/same.toml")), "same = 1\n");
|
||||
assert_eq!(
|
||||
read_text(&target.join("mods/remote.jar")),
|
||||
"remote jar bytes"
|
||||
);
|
||||
// 有一条冲突按「保留」处置 ⇒ `Partial`(**不是** `Success`:实例可用,但内容与包不全一致)。
|
||||
assert_eq!(report.status, ImportStatus::Partial, "{:?}", report.error);
|
||||
let mut chosen: Vec<(&str, ConflictChoice)> = report
|
||||
.conflicts
|
||||
.iter()
|
||||
.map(|conflict| (conflict.path.as_str(), conflict.choice))
|
||||
.collect();
|
||||
// 报告里的顺序是落盘顺序(overrides 组在前、client-overrides 在后),比较前按路径排序。
|
||||
chosen.sort_by(|left, right| left.0.cmp(right.0));
|
||||
assert_eq!(
|
||||
chosen,
|
||||
vec![
|
||||
("config/known.toml", ConflictChoice::Overwrite),
|
||||
("mods/layer.jar", ConflictChoice::KeepUser),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overwrite_all_is_the_batch_channel_from_the_same_table() {
|
||||
let (pack, fetcher) = layered_pack("dec-all");
|
||||
let target = seeded("dec-all");
|
||||
let scan = scan_pack(&ImportRequest::new(&pack, &target));
|
||||
let report = block_on(import_pack(
|
||||
&ImportRequest::new(&pack, &target)
|
||||
.with_fetcher(&fetcher)
|
||||
.with_decisions(scan.overwrite_all()),
|
||||
));
|
||||
|
||||
assert_eq!(
|
||||
read_text(&target.join("config/known.toml")),
|
||||
"shared = client\n"
|
||||
);
|
||||
assert_eq!(
|
||||
read_text(&target.join("mods/layer.jar")),
|
||||
"layer from overrides\n"
|
||||
);
|
||||
assert_eq!(report.status, ImportStatus::Success, "{:?}", report.error);
|
||||
assert!(report
|
||||
.conflicts
|
||||
.iter()
|
||||
.all(|conflict| conflict.choice == ConflictChoice::Overwrite));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_kept_file_is_still_a_conflict_next_time() {
|
||||
// 基线里必须记**包内**的哈希:若记的是磁盘哈希,下一次导入会判成"用户没动过 ⇒ 整合包升级"
|
||||
// 而静默覆盖——那正是本轮要禁的行为。
|
||||
let (pack, fetcher) = layered_pack("dec-baseline");
|
||||
let target = seeded("dec-baseline");
|
||||
for _ in 0..2 {
|
||||
let report = block_on(import_pack(
|
||||
&ImportRequest::new(&pack, &target).with_fetcher(&fetcher),
|
||||
));
|
||||
let conflict = report
|
||||
.conflicts
|
||||
.iter()
|
||||
.find(|conflict| conflict.path == "config/known.toml")
|
||||
.expect("第二次也必须报冲突");
|
||||
assert_eq!(conflict.choice, ConflictChoice::KeepUser);
|
||||
assert_eq!(
|
||||
read_text(&target.join("config/known.toml")),
|
||||
"shared = mine\n"
|
||||
);
|
||||
}
|
||||
// 第二次的成因变成"用户改过"(有基线了),而不是"没有基线"。
|
||||
let scan = scan_pack(&ImportRequest::new(&pack, &target));
|
||||
let known = &scan.conflicts[0];
|
||||
assert_eq!(known.reason, ConflictReason::UserModified);
|
||||
assert_eq!(
|
||||
known.base_sha1.as_deref(),
|
||||
Some(sha1(KNOWN_CLIENT).as_str())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curseforge_entries_without_a_path_are_counted_not_guessed() {
|
||||
// CF 的 `files[]` 只有 projectID/fileID:补全前没有路径 ⇒ 预检判不了,只记条数。
|
||||
let fixture = super::fixtures::curseforge("dec-cf");
|
||||
let root = TempRoot::new("dec-cf");
|
||||
let target = root.join("instance");
|
||||
let scan = scan_pack(&ImportRequest::new(&fixture.zip, &target));
|
||||
|
||||
assert!(scan.is_ok(), "{:?}", scan.error);
|
||||
assert_eq!(scan.unknown_remote, 2, "两条 CF 远端条目都判不了");
|
||||
assert!(
|
||||
scan.notes
|
||||
.iter()
|
||||
.any(|note| note.message_key == crate::addon::import::keys::NOTE_REMOTE_PATH_UNKNOWN),
|
||||
"要如实说「还有 N 条判不了」:{:?}",
|
||||
scan.notes
|
||||
);
|
||||
// overrides 里的三条(example.jar / known.toml / broken.toml)在空目标上不冲突。
|
||||
assert!(paths(&scan).is_empty(), "{:?}", paths(&scan));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_broken_pack_reports_the_error_instead_of_panicking() {
|
||||
let root = TempRoot::new("dec-broken");
|
||||
let bad = root.join("bad.zip");
|
||||
write_file(&bad, b"not a zip at all");
|
||||
let scan = scan_pack(&ImportRequest::new(&bad, &root.join("instance")));
|
||||
assert!(!scan.is_ok(), "坏包必须给 error");
|
||||
assert!(scan.conflicts.is_empty());
|
||||
assert!(!scan.needs_decision(), "判不了就没有可决策的项");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_scan_does_not_touch_the_target_directory() {
|
||||
// 预检是只读的:连目标目录都不该建出来。
|
||||
let (pack, _) = layered_pack("dec-readonly");
|
||||
let root = TempRoot::new("dec-readonly");
|
||||
let target = root.join("instance");
|
||||
let scan = scan_pack(&ImportRequest::new(&pack, &target));
|
||||
assert!(scan.is_ok(), "{:?}", scan.error);
|
||||
assert!(!target.exists(), "预检不许创建目标目录");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mrpack_stub_keeps_its_remote_entries_out_of_the_conflict_list() {
|
||||
// 对照夹具(M111 的 mrpack_stub):远端条目自带真实摘要,目标里没有 ⇒ 一条冲突都没有。
|
||||
let (fixture, fetcher) = mrpack_stub("dec-stub");
|
||||
let root = TempRoot::new("dec-stub");
|
||||
let target = root.join("instance");
|
||||
let scan = scan_pack(&ImportRequest::new(&fixture.zip, &target));
|
||||
assert!(scan.is_ok(), "{:?}", scan.error);
|
||||
assert!(scan.conflicts.is_empty(), "{:?}", paths(&scan));
|
||||
let report = block_on(import_pack(
|
||||
&ImportRequest::new(&fixture.zip, &target).with_fetcher(&fetcher),
|
||||
));
|
||||
assert_eq!(report.status, ImportStatus::Success, "{:?}", report.error);
|
||||
}
|
||||
@@ -32,7 +32,7 @@ use crate::download::blocking::block_on;
|
||||
|
||||
use super::support::{StubFetcher, JEI_BYTES, JEI_PLACEHOLDER_URL, OPTIONAL_PLACEHOLDER_URL};
|
||||
use super::{
|
||||
fixtures, list_tree, read_text, temp_root, write_file, write_zip, write_zip_with_symlink,
|
||||
fixtures, list_tree, read_text, write_file, write_zip, write_zip_with_symlink, TempRoot,
|
||||
};
|
||||
|
||||
/// 一份形状合法的最小 CF 清单(恶意包要能被"探测成功",失败必须发生在它该发生的阶段)。
|
||||
@@ -56,11 +56,13 @@ fn assert_clean(target: &Path) {
|
||||
}
|
||||
}
|
||||
|
||||
fn failing(tag: &str, entries: &[(&str, &[u8])]) -> (std::path::PathBuf, std::path::PathBuf) {
|
||||
let root = temp_root(tag);
|
||||
let pack = root.join("evil.zip");
|
||||
/// 恶意样本包 + 目标目录。返回的 `TempRoot` **`Deref` 到那个 zip**,同时持有临时根:
|
||||
/// 用它喂 `ImportRequest::new(&pack, ..)` 之后包文件还在(不带守卫的话函数一返回包就被删了,
|
||||
/// 导入会以 `pack_io` 失败——那是"测错了东西还看不出来"的典型)。
|
||||
fn failing(tag: &str, entries: &[(&str, &[u8])]) -> (TempRoot, std::path::PathBuf) {
|
||||
let pack = TempRoot::at(tag, "evil.zip");
|
||||
write_zip(&pack, entries);
|
||||
let target = root.join("instance");
|
||||
let target = pack.parent().expect("临时根").join("instance");
|
||||
(pack, target)
|
||||
}
|
||||
|
||||
@@ -122,7 +124,7 @@ fn windows_device_name_proves_the_second_check_is_live() {
|
||||
|
||||
#[test]
|
||||
fn symlink_entry_rolls_back_with_zero_residue() {
|
||||
let root = temp_root("fail-symlink");
|
||||
let root = TempRoot::new("fail-symlink");
|
||||
let pack = root.join("evil.zip");
|
||||
let entries: [(&str, &[u8]); 1] = [("manifest.json", CF_MANIFEST)];
|
||||
write_zip_with_symlink(&pack, &entries, "mods/evil.jar", "../../../etc/passwd");
|
||||
@@ -162,7 +164,7 @@ fn declared_hash_mismatch_rolls_back_everything() {
|
||||
// 原始 M110 夹具:`hashes.sha1` 是形状正确的**占位值**,必然不等于 stub 给的字节。
|
||||
// 这是"声明与实际不符"——它**不是**"暂时搞不到",必须整包回滚。
|
||||
let fixture = fixtures::modrinth("fail-integrity");
|
||||
let root = temp_root("fail-integrity-out");
|
||||
let root = TempRoot::new("fail-integrity-out");
|
||||
let target = root.join("instance");
|
||||
let fetcher = StubFetcher::new()
|
||||
.with_file(JEI_PLACEHOLDER_URL, JEI_BYTES)
|
||||
@@ -213,7 +215,7 @@ fn commit_failure_rolls_back_partially_applied_files() {
|
||||
// `<dest>.ullbak-<pid>`,而那个位置已被一个**非空目录**占住 ⇒ `rename(dest → backup)`
|
||||
// 必然失败(文件改不成非空目录,Windows / Unix 一致)。
|
||||
// 这覆盖 journal 回滚 + `Drop` 兜底:前半截已经搬进去的文件(基线排最前)必须被撤回。
|
||||
let root = temp_root("fail-commit");
|
||||
let root = TempRoot::new("fail-commit");
|
||||
let source = fixtures::multimc("fail-commit-src");
|
||||
let target = root.join("instance");
|
||||
let first = block_on(import_pack(&ImportRequest::new(&source, &target)));
|
||||
|
||||
@@ -20,7 +20,7 @@ use crate::addon::import::{
|
||||
use crate::download::blocking::block_on;
|
||||
|
||||
use super::support::sha1;
|
||||
use super::{read_text, temp_root, write_file, zip_dir};
|
||||
use super::{read_text, write_file, zip_dir, TempRoot};
|
||||
|
||||
/// 打一个只有 `files: []` 的 mrpack(覆盖内容全在 overrides 系列目录里)。
|
||||
fn index_json(name: &str) -> String {
|
||||
@@ -42,7 +42,7 @@ fn shadow_notes(report: &crate::addon::import::ImportReport) -> Vec<&str> {
|
||||
|
||||
#[test]
|
||||
fn same_path_with_different_bytes_lands_the_later_side_only() {
|
||||
let root = temp_root("layer-same-path");
|
||||
let root = TempRoot::new("layer-same-path");
|
||||
let dir = root.join("src");
|
||||
write_file(
|
||||
&dir.join("modrinth.index.json"),
|
||||
@@ -102,7 +102,7 @@ fn same_path_with_different_bytes_lands_the_later_side_only() {
|
||||
|
||||
#[test]
|
||||
fn the_same_bytes_at_two_paths_are_two_files() {
|
||||
let root = temp_root("layer-same-bytes");
|
||||
let root = TempRoot::new("layer-same-bytes");
|
||||
let dir = root.join("src");
|
||||
write_file(
|
||||
&dir.join("modrinth.index.json"),
|
||||
@@ -143,7 +143,7 @@ fn the_same_bytes_at_two_paths_are_two_files() {
|
||||
|
||||
#[test]
|
||||
fn identical_bytes_on_both_sides_shadow_without_claiming_a_conflict() {
|
||||
let root = temp_root("layer-same-content");
|
||||
let root = TempRoot::new("layer-same-content");
|
||||
let dir = root.join("src");
|
||||
write_file(
|
||||
&dir.join("modrinth.index.json"),
|
||||
@@ -176,7 +176,7 @@ fn identical_bytes_on_both_sides_shadow_without_claiming_a_conflict() {
|
||||
fn the_baseline_remembers_the_winning_bytes() {
|
||||
// 叠层顺序的**后果**:基线要记胜者那份。记错的话,下一次升级会把"用户没动过"判成
|
||||
// "用户改过"(或反过来静默覆盖用户改动)。
|
||||
let root = temp_root("layer-baseline");
|
||||
let root = TempRoot::new("layer-baseline");
|
||||
let dir = root.join("src");
|
||||
write_file(
|
||||
&dir.join("modrinth.index.json"),
|
||||
|
||||
@@ -10,14 +10,15 @@ use crate::addon::import::{
|
||||
use crate::download::blocking::block_on;
|
||||
|
||||
use super::support::mrpack_stub;
|
||||
use super::{list_tree, read_text, temp_root, write_file, zip_dir};
|
||||
use super::{list_tree, read_text, write_file, zip_dir, TempRoot};
|
||||
|
||||
use crate::addon::import::{PackBaseline, BASELINE_FILE};
|
||||
|
||||
#[test]
|
||||
fn user_modified_file_is_kept_and_reported() {
|
||||
let (fixture, fetcher) = mrpack_stub("merge-modified");
|
||||
let target = temp_root("merge-modified-out").join("instance");
|
||||
let root = TempRoot::new("merge-modified-out");
|
||||
let target = root.join("instance");
|
||||
let first = block_on(import_pack(
|
||||
&ImportRequest::new(&fixture.zip, &target).with_fetcher(&fetcher),
|
||||
));
|
||||
@@ -61,7 +62,8 @@ fn user_modified_file_is_kept_and_reported() {
|
||||
#[test]
|
||||
fn user_deleted_file_is_not_restored() {
|
||||
let (fixture, fetcher) = mrpack_stub("merge-deleted");
|
||||
let target = temp_root("merge-deleted-out").join("instance");
|
||||
let root = TempRoot::new("merge-deleted-out");
|
||||
let target = root.join("instance");
|
||||
let first = block_on(import_pack(
|
||||
&ImportRequest::new(&fixture.zip, &target).with_fetcher(&fetcher),
|
||||
));
|
||||
@@ -91,7 +93,8 @@ fn user_deleted_file_is_not_restored() {
|
||||
#[test]
|
||||
fn untouched_file_is_upgraded_by_a_newer_pack() {
|
||||
let (fixture, fetcher) = mrpack_stub("merge-upgrade");
|
||||
let target = temp_root("merge-upgrade-out").join("instance");
|
||||
let root = TempRoot::new("merge-upgrade-out");
|
||||
let target = root.join("instance");
|
||||
let first = block_on(import_pack(
|
||||
&ImportRequest::new(&fixture.zip, &target).with_fetcher(&fetcher),
|
||||
));
|
||||
@@ -123,7 +126,8 @@ fn untouched_file_is_upgraded_by_a_newer_pack() {
|
||||
#[test]
|
||||
fn pre_existing_file_without_baseline_is_kept() {
|
||||
let (fixture, fetcher) = mrpack_stub("merge-existing");
|
||||
let target = temp_root("merge-existing-out").join("instance");
|
||||
let root = TempRoot::new("merge-existing-out");
|
||||
let target = root.join("instance");
|
||||
// 首次导入前磁盘上就有同名文件:没有基线可判 ⇒ 最安全的一侧(不覆盖)。
|
||||
write_file(&target.join("config/known.toml"), b"mine\n");
|
||||
let report = block_on(import_pack(
|
||||
@@ -144,7 +148,8 @@ fn pre_existing_file_without_baseline_is_kept() {
|
||||
#[test]
|
||||
fn overwrite_policy_replaces_user_file_and_reports_the_choice() {
|
||||
let (fixture, fetcher) = mrpack_stub("merge-overwrite");
|
||||
let target = temp_root("merge-overwrite-out").join("instance");
|
||||
let root = TempRoot::new("merge-overwrite-out");
|
||||
let target = root.join("instance");
|
||||
let first = block_on(import_pack(
|
||||
&ImportRequest::new(&fixture.zip, &target).with_fetcher(&fetcher),
|
||||
));
|
||||
@@ -174,7 +179,7 @@ fn overwrite_policy_replaces_user_file_and_reports_the_choice() {
|
||||
#[test]
|
||||
fn client_overrides_wins_over_overrides() {
|
||||
// Modrinth 官方客户端语义:`client-overrides/` 后写 = 胜(HMCL 恰好相反)。
|
||||
let root = temp_root("merge-priority");
|
||||
let root = TempRoot::new("merge-priority");
|
||||
let dir = root.join("src");
|
||||
write_file(
|
||||
&dir.join("modrinth.index.json"),
|
||||
@@ -209,7 +214,7 @@ fn client_overrides_wins_over_overrides() {
|
||||
fn baseline_read_and_write_paths_are_the_same_file() {
|
||||
// 复审 r5 P1-1 的回归钉:读侧(`PackBaseline::path`)与写侧(`relative_path` 拼 root)
|
||||
// **必须解析到同一个路径**。只测"读得到"在大小写不敏感的系统上会假绿。
|
||||
let root = temp_root("baseline-path").join("instance");
|
||||
let root = TempRoot::under("baseline-path", "instance");
|
||||
let read = PackBaseline::path(&root);
|
||||
let write = root.join(PackBaseline::relative_path());
|
||||
assert_eq!(read, write, "读写必须是同一个文件:{read:?} vs {write:?}");
|
||||
@@ -221,7 +226,7 @@ fn baseline_read_and_write_paths_are_the_same_file() {
|
||||
assert_eq!(
|
||||
read.parent(),
|
||||
Some(
|
||||
crate::game::layout::GameLayout::new(&root)
|
||||
crate::game::layout::GameLayout::new(root.to_path_buf())
|
||||
.ull_root()
|
||||
.as_path()
|
||||
)
|
||||
@@ -245,7 +250,7 @@ fn baseline_read_and_write_paths_are_the_same_file() {
|
||||
#[test]
|
||||
fn baseline_of_another_pack_is_not_used_as_base() {
|
||||
// 同一目录先后导入两个整合包:别人的基线当 base 会把"用户改过"误判成"整合包升级"而静默覆盖。
|
||||
let root = temp_root("merge-identity");
|
||||
let root = TempRoot::new("merge-identity");
|
||||
let source = super::fixtures::multimc("merge-identity-a");
|
||||
let target = root.join("instance");
|
||||
let first = block_on(import_pack(&ImportRequest::new(&source, &target)));
|
||||
|
||||
@@ -8,12 +8,13 @@ use crate::addon::loader::LoaderKind;
|
||||
use crate::download::blocking::block_on;
|
||||
|
||||
use super::support::{mrpack_stub, JEI_BYTES, OPTIONAL_BYTES};
|
||||
use super::{fixtures, list_tree, read_text, temp_root};
|
||||
use super::{fixtures, list_tree, read_text, TempRoot};
|
||||
|
||||
#[test]
|
||||
fn imports_mrpack_end_to_end() {
|
||||
let (fixture, fetcher) = mrpack_stub("mr-e2e");
|
||||
let target = temp_root("mr-e2e-out").join("instance");
|
||||
let root = TempRoot::new("mr-e2e-out");
|
||||
let target = root.join("instance");
|
||||
let request = ImportRequest::new(&fixture.zip, &target).with_fetcher(&fetcher);
|
||||
let report = block_on(import_pack(&request));
|
||||
|
||||
@@ -95,7 +96,8 @@ fn engine_artifacts_never_reach_the_instance() {
|
||||
// 供下次续传;它们躺在暂存目录里,提交前必须剔掉,否则会变成实例里的"内容"。
|
||||
let (fixture, fetcher) = mrpack_stub("mr-artifacts");
|
||||
let fetcher = fetcher.with_engine_artifacts();
|
||||
let target = temp_root("mr-artifacts-out").join("instance");
|
||||
let root = TempRoot::new("mr-artifacts-out");
|
||||
let target = root.join("instance");
|
||||
let report = block_on(import_pack(
|
||||
&ImportRequest::new(&fixture.zip, &target).with_fetcher(&fetcher),
|
||||
));
|
||||
@@ -135,7 +137,8 @@ fn disabled_suffix_is_preserved_verbatim() {
|
||||
);
|
||||
super::zip_dir(&fixture.dir, &fixture.zip);
|
||||
|
||||
let target = temp_root("mr-disabled-out").join("instance");
|
||||
let root = TempRoot::new("mr-disabled-out");
|
||||
let target = root.join("instance");
|
||||
let request = ImportRequest::new(&fixture.zip, &target).with_fetcher(&fetcher);
|
||||
let report = block_on(import_pack(&request));
|
||||
|
||||
|
||||
@@ -6,12 +6,13 @@ use crate::addon::import::{import_pack, keys, ImportRequest, ImportStatus};
|
||||
use crate::addon::loader::LoaderKind;
|
||||
use crate::download::blocking::block_on;
|
||||
|
||||
use super::{fixtures, list_tree, read_text, temp_root, write_file};
|
||||
use super::{fixtures, list_tree, read_text, write_file, TempRoot};
|
||||
|
||||
#[test]
|
||||
fn imports_multimc_instance_end_to_end() {
|
||||
let source = fixtures::multimc("mmc-e2e");
|
||||
let target = temp_root("mmc-e2e-out").join("instance");
|
||||
let root = TempRoot::new("mmc-e2e-out");
|
||||
let target = root.join("instance");
|
||||
let request = ImportRequest::new(&source, &target);
|
||||
let report = block_on(import_pack(&request));
|
||||
|
||||
@@ -57,7 +58,8 @@ fn imports_multimc_instance_end_to_end() {
|
||||
#[test]
|
||||
fn unsupported_components_are_listed_one_by_one_not_dropped() {
|
||||
let source = fixtures::multimc("mmc-components");
|
||||
let target = temp_root("mmc-components-out").join("instance");
|
||||
let root = TempRoot::new("mmc-components-out");
|
||||
let target = root.join("instance");
|
||||
let request = ImportRequest::new(&source, &target);
|
||||
let report = block_on(import_pack(&request));
|
||||
|
||||
@@ -106,7 +108,7 @@ fn unsupported_components_are_listed_one_by_one_not_dropped() {
|
||||
|
||||
#[test]
|
||||
fn reads_memory_from_instance_cfg_only_when_override_memory_is_on() {
|
||||
let root = temp_root("mmc-mem");
|
||||
let root = TempRoot::new("mmc-mem");
|
||||
let source = root.join("instance");
|
||||
write_file(
|
||||
&source.join("mmc-pack.json"),
|
||||
@@ -120,7 +122,8 @@ fn reads_memory_from_instance_cfg_only_when_override_memory_is_on() {
|
||||
);
|
||||
write_file(&source.join(".minecraft/mods/a.jar"), b"jar");
|
||||
|
||||
let target = temp_root("mmc-mem-out").join("instance");
|
||||
let root = TempRoot::new("mmc-mem-out");
|
||||
let target = root.join("instance");
|
||||
let report = block_on(import_pack(&ImportRequest::new(&source, &target)));
|
||||
assert_eq!(report.max_memory_mb, Some(6144), "{:?}", report.error);
|
||||
assert!(report
|
||||
@@ -140,7 +143,8 @@ fn reads_memory_from_instance_cfg_only_when_override_memory_is_on() {
|
||||
b"InstanceType=OneSix\nname=gated\nOverrideMemory=false\nMaxMemAlloc=6144\n",
|
||||
);
|
||||
write_file(&gated.join(".minecraft/mods/a.jar"), b"jar");
|
||||
let gated_target = temp_root("mmc-mem-gated-out").join("instance");
|
||||
let root = TempRoot::new("mmc-mem-gated-out");
|
||||
let gated_target = root.join("instance");
|
||||
let gated_report = block_on(import_pack(&ImportRequest::new(&gated, &gated_target)));
|
||||
assert_eq!(gated_report.max_memory_mb, None);
|
||||
assert!(gated_report
|
||||
@@ -153,7 +157,7 @@ fn reads_memory_from_instance_cfg_only_when_override_memory_is_on() {
|
||||
fn registers_the_instance_when_a_launcher_layout_is_given() {
|
||||
// 登记阶段(读取清单 → 解压 → 补全 → 落地 → **注册实例**)的 happy path:
|
||||
// 内容落盘后写 profiles.json + config/instances/<id>.json。
|
||||
let root = temp_root("mmc-register");
|
||||
let root = TempRoot::new("mmc-register");
|
||||
let source = fixtures::multimc("mmc-register-src");
|
||||
let target = root.join("instance");
|
||||
let home = root.join("data");
|
||||
@@ -181,7 +185,7 @@ fn registers_the_instance_when_a_launcher_layout_is_given() {
|
||||
#[test]
|
||||
fn rejects_mmc_pack_with_other_format_version() {
|
||||
// Prism 的规矩:`formatVersion` 必须恰为 1,否则整份丢弃(不是降级解析)。
|
||||
let root = temp_root("mmc-v2");
|
||||
let root = TempRoot::new("mmc-v2");
|
||||
let source = root.join("instance");
|
||||
write_file(
|
||||
&source.join("mmc-pack.json"),
|
||||
@@ -194,7 +198,8 @@ fn rejects_mmc_pack_with_other_format_version() {
|
||||
);
|
||||
write_file(&source.join(".minecraft/mods/a.jar"), b"jar");
|
||||
|
||||
let target = temp_root("mmc-v2-out").join("instance");
|
||||
let root = TempRoot::new("mmc-v2-out");
|
||||
let target = root.join("instance");
|
||||
let report = block_on(import_pack(&ImportRequest::new(&source, &target)));
|
||||
|
||||
assert_eq!(report.status, ImportStatus::Failed);
|
||||
|
||||
@@ -8,7 +8,7 @@ use crate::addon::import::{
|
||||
use crate::download::blocking::block_on;
|
||||
|
||||
use super::support::mrpack_stub;
|
||||
use super::temp_root;
|
||||
use super::TempRoot;
|
||||
|
||||
#[test]
|
||||
fn status_maps_to_three_distinct_exit_codes() {
|
||||
@@ -42,7 +42,7 @@ fn status_maps_to_three_distinct_exit_codes() {
|
||||
#[test]
|
||||
fn report_serialises_to_stable_json_with_structured_error() {
|
||||
// 失败也要输出结构化 JSON(不抛栈):`status` / `error.code` / `rolled_back` 一个不少。
|
||||
let root = temp_root("report-json");
|
||||
let root = TempRoot::new("report-json");
|
||||
let pack = root.join("evil.zip");
|
||||
super::write_zip(&pack, &[("manifest.json", b"{\"manifestType\":")]);
|
||||
let target = root.join("instance");
|
||||
@@ -60,7 +60,8 @@ fn report_serialises_to_stable_json_with_structured_error() {
|
||||
#[test]
|
||||
fn successful_report_serialises_counts_and_loader() {
|
||||
let (fixture, fetcher) = mrpack_stub("report-ok");
|
||||
let target = temp_root("report-ok-out").join("instance");
|
||||
let root = TempRoot::new("report-ok-out");
|
||||
let target = root.join("instance");
|
||||
let report = block_on(import_pack(
|
||||
&ImportRequest::new(&fixture.zip, &target).with_fetcher(&fetcher),
|
||||
));
|
||||
@@ -133,6 +134,10 @@ const SOURCES: &[&str] = &[
|
||||
include_str!("../import/phase.rs"),
|
||||
include_str!("../import/pipeline.rs"),
|
||||
include_str!("../import/plan.rs"),
|
||||
// M160:预检(冲突清单)与逐文件决策的引用也要算进这张表,
|
||||
// 否则 `NOTE_REMOTE_PATH_UNKNOWN` 会被判成"死常量"。
|
||||
include_str!("../import/scan.rs"),
|
||||
include_str!("../import/decision.rs"),
|
||||
include_str!("../import/stage.rs"),
|
||||
include_str!("../mod.rs"),
|
||||
];
|
||||
|
||||
@@ -21,25 +21,23 @@
|
||||
#[path = "../pack/tests/fixtures.rs"]
|
||||
mod fixtures;
|
||||
mod import_curseforge;
|
||||
mod import_decisions;
|
||||
mod import_failures;
|
||||
mod import_layering;
|
||||
mod import_merge;
|
||||
mod import_modrinth;
|
||||
mod import_multimc;
|
||||
mod import_report;
|
||||
mod roundtrip_three;
|
||||
mod support;
|
||||
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
/// 本次进程内唯一的临时根目录(沿用仓库既有约定:不引 `tempfile`)。
|
||||
pub(crate) fn temp_root(tag: &str) -> PathBuf {
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
std::env::temp_dir().join(format!("ull-import-{}-{tag}-{unique}", std::process::id()))
|
||||
}
|
||||
/// 一次性临时根(`Drop` 即删,panic 也删)。实现与 `pack::tests` 共用一份:
|
||||
/// [`crate::addon::temp::TempRoot`](两族用例都会建临时目录,守卫只此一处)。
|
||||
pub(crate) use crate::addon::temp::TempRoot;
|
||||
|
||||
/// 写一个文件(自动建父目录)。
|
||||
pub(crate) fn write_file(path: &Path, bytes: &[u8]) {
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
//! `import → export → import` 三轮一致性(M160)。
|
||||
//!
|
||||
//! # 这条用例补的是哪笔账
|
||||
//!
|
||||
//! M111/M112 的往返各自只有半条链:导入侧测"包 → 实例",导出侧测"实例 → 包"(`export_roundtrip`
|
||||
//! 里明确登记了"M111 合并后要补一条 `import_pack → export → import_pack`")。本文件就是那条,
|
||||
//! 而且是**三轮**:包① → 实例 A → 包② → 实例 B → 包③ → 实例 C,三个实例的内容逐字节相等。
|
||||
//!
|
||||
//! 夹具带 **overrides 叠层**(`client-overrides/` 与 `overrides/` 同名不同内容),并且第二轮
|
||||
//! 的实例**预置了一个与包冲突的文件**:用户选了「覆盖」,于是三轮之后仍然一致——这条同时证明
|
||||
//! "决策真的生效"与"决策之后的一致性仍然成立"。
|
||||
//!
|
||||
//! # 为什么比的是"内容目录里的文件"而不是整棵树
|
||||
//!
|
||||
//! 实例里还有两样东西**不属于整合包内容**:`versions/<id>/<id>.json`(导出要读它,但导出器
|
||||
//! 不会把它打进包)与 `.ull/pack-baseline.json`(本仓库的三方合并基线)。两者都按设计不进包,
|
||||
//! 所以比较口径是 `mods/` `config/` 等 8 个内容目录。
|
||||
//!
|
||||
//! # 没验的部分
|
||||
//!
|
||||
//! 真实社区包(CurseForge / Modrinth 在线下载)不在本轮:远端条目一律走 `StubFetcher`,
|
||||
//! 导出一律走离线目录(`OfflineCatalog` ⇒ 全部内联)。这条欠账在完成报告里同样登记。
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
|
||||
use super::support::{sha1, StubFetcher};
|
||||
use super::{write_file, zip_dir, TempRoot};
|
||||
use crate::addon::export::{export_instance, ExportRequest};
|
||||
use crate::addon::import::{
|
||||
import_pack, ConflictChoice, ConflictDecisions, ImportRequest, ImportStatus,
|
||||
};
|
||||
use crate::addon::pack::format::PackFormat;
|
||||
use crate::addon::pack::modrinth::MODRINTH;
|
||||
use crate::addon::pack::PackArchive;
|
||||
use crate::download::blocking::block_on;
|
||||
|
||||
/// 内容目录白名单(与 `export::scan::DEFAULT_CONTENT_ROOTS` 同一条口径)。
|
||||
const CONTENT_ROOTS: [&str; 4] = ["mods", "config", "resourcepacks", "scripts"];
|
||||
|
||||
/// 夹具里 `client-overrides/` 胜出的那份内容。
|
||||
const KNOWN_CLIENT: &[u8] = b"shared = client\n";
|
||||
/// 夹具里 `overrides/` 侧(被遮蔽)。
|
||||
const KNOWN_OVERRIDES: &[u8] = b"shared = overrides\n";
|
||||
/// 仅 overrides 声明的文件。
|
||||
const LAYER: &[u8] = b"layer from overrides\n";
|
||||
/// 远端条目的内容(走 stub 下载)。
|
||||
const REMOTE: &[u8] = b"remote jar bytes";
|
||||
/// 第二轮实例里用户自己那份(与包冲突)。
|
||||
const USER_KNOWN: &[u8] = b"shared = mine\n";
|
||||
|
||||
/// 带叠层 + 一条远端条目的 mrpack。
|
||||
fn layered_pack(tag: &str) -> (TempRoot, StubFetcher) {
|
||||
let root = TempRoot::at(tag, "pack.mrpack");
|
||||
let dir = root.parent().expect("临时根").join("src");
|
||||
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());
|
||||
write_file(&dir.join("overrides/config/known.toml"), KNOWN_OVERRIDES);
|
||||
write_file(
|
||||
&dir.join("client-overrides/config/known.toml"),
|
||||
KNOWN_CLIENT,
|
||||
);
|
||||
write_file(&dir.join("overrides/mods/layer.jar"), LAYER);
|
||||
write_file(&dir.join("overrides/scripts/start.js"), b"// script\n");
|
||||
zip_dir(&dir, &root);
|
||||
let fetcher = StubFetcher::new().with_file("stub://mods/remote.jar", REMOTE);
|
||||
(root, fetcher)
|
||||
}
|
||||
|
||||
/// 内容目录里的文件(`/` 分隔、排序)→ 字节。
|
||||
fn content_files(root: &Path) -> BTreeMap<String, Vec<u8>> {
|
||||
let mut out = BTreeMap::new();
|
||||
for dir in CONTENT_ROOTS {
|
||||
let base = root.join(dir);
|
||||
if !base.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let mut pending = vec![base];
|
||||
while let Some(current) = pending.pop() {
|
||||
for entry in std::fs::read_dir(¤t).expect("读内容目录").flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
pending.push(path);
|
||||
continue;
|
||||
}
|
||||
let relative = path
|
||||
.strip_prefix(root)
|
||||
.expect("在实例内")
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/");
|
||||
out.insert(relative, std::fs::read(&path).expect("读内容文件"));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 给实例补一份版本 JSON(导出要读它拿游戏版本;导入**不装**游戏,所以得手工放)。
|
||||
fn write_version(root: &Path, id: &str, game: &str) {
|
||||
write_file(
|
||||
&root.join("versions").join(id).join(format!("{id}.json")),
|
||||
format!(r#"{{"id":"{game}"}}"#).as_bytes(),
|
||||
);
|
||||
}
|
||||
|
||||
/// 实例 → 包(走产品路径的导出器,离线目录 ⇒ 全部内联)。
|
||||
fn export(root: &Path, target: &Path) {
|
||||
let request = ExportRequest::new(root, target, &MODRINTH).with_version_id("1.20.1");
|
||||
let report = export_instance(&request).expect("导出");
|
||||
assert!(
|
||||
report.is_complete(),
|
||||
"夹具里每个文件都该能内联进包:{:?}",
|
||||
report.summary()
|
||||
);
|
||||
assert!(target.exists(), "导出产物不存在");
|
||||
}
|
||||
|
||||
/// 导出的包里覆盖条目的 `路径 → sha1`(三轮之间要比这一份)。
|
||||
fn pack_overrides(pack: &Path) -> BTreeMap<String, String> {
|
||||
let archive = PackArchive::open_default(pack).expect("打开导出的包");
|
||||
let (format, manifest) = crate::addon::pack::PackRegistry::builtin()
|
||||
.read(&archive)
|
||||
.expect("读清单");
|
||||
assert_eq!(format.id(), MODRINTH.id(), "导出目标是 mrpack");
|
||||
manifest
|
||||
.override_files()
|
||||
.map(|(_, file)| (file.path.clone(), file.sha1.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_export_import_three_rounds_keep_the_same_file_set() {
|
||||
let (pack1, fetcher) = layered_pack("rt3");
|
||||
let base = TempRoot::new("rt3");
|
||||
let (dir_a, dir_b, dir_c) = (base.join("a"), base.join("b"), base.join("c"));
|
||||
|
||||
// —— 第 1 轮:包① → 实例 A(干净目标)——
|
||||
let first = block_on(import_pack(
|
||||
&ImportRequest::new(&pack1, &dir_a).with_fetcher(&fetcher),
|
||||
));
|
||||
assert_eq!(first.status, ImportStatus::Success, "{:?}", first.error);
|
||||
assert_eq!(
|
||||
content_files(&dir_a).keys().cloned().collect::<Vec<_>>(),
|
||||
vec![
|
||||
"config/known.toml",
|
||||
"mods/layer.jar",
|
||||
"mods/remote.jar",
|
||||
"scripts/start.js"
|
||||
],
|
||||
"叠层的胜者是 client-overrides(不是 overrides);实际树:{:?}",
|
||||
super::list_tree(&dir_a)
|
||||
);
|
||||
write_version(&dir_a, "1.20.1", "1.20.1");
|
||||
let pack2 = base.join("round2.mrpack");
|
||||
export(&dir_a, &pack2);
|
||||
|
||||
// —— 第 2 轮:包② → 实例 B(**预置冲突**,用户选「覆盖」)——
|
||||
write_file(&dir_b.join("config/known.toml"), USER_KNOWN);
|
||||
let report = block_on(import_pack(
|
||||
&ImportRequest::new(&pack2, &dir_b)
|
||||
.with_fetcher(&fetcher)
|
||||
.with_decisions(
|
||||
ConflictDecisions::keep_all().with("config/known.toml", ConflictChoice::Overwrite),
|
||||
),
|
||||
));
|
||||
assert_eq!(report.status, ImportStatus::Success, "{:?}", report.error);
|
||||
let conflict = report
|
||||
.conflicts
|
||||
.iter()
|
||||
.find(|conflict| conflict.path == "config/known.toml")
|
||||
.expect("第二轮必须报出那个冲突");
|
||||
assert_eq!(conflict.choice, ConflictChoice::Overwrite);
|
||||
write_version(&dir_b, "1.20.1", "1.20.1");
|
||||
let pack3 = base.join("round3.mrpack");
|
||||
export(&dir_b, &pack3);
|
||||
|
||||
// —— 第 3 轮:包③ → 实例 C(干净目标)——
|
||||
let third = block_on(import_pack(
|
||||
&ImportRequest::new(&pack3, &dir_c).with_fetcher(&fetcher),
|
||||
));
|
||||
assert_eq!(third.status, ImportStatus::Success, "{:?}", third.error);
|
||||
|
||||
// 三个实例的内容逐字节相等。
|
||||
let a = content_files(&dir_a);
|
||||
let b = content_files(&dir_b);
|
||||
let c = content_files(&dir_c);
|
||||
assert_eq!(a, b, "第 1 轮与第 2 轮的文件集必须一致");
|
||||
assert_eq!(a, c, "第 1 轮与第 3 轮的文件集必须一致");
|
||||
assert_eq!(
|
||||
a.get("config/known.toml").map(Vec::as_slice),
|
||||
Some(KNOWN_CLIENT),
|
||||
"覆盖之后留下的是包内内容"
|
||||
);
|
||||
|
||||
// 包②与包③的覆盖条目(路径 + 哈希)也必须一致。
|
||||
assert_eq!(
|
||||
pack_overrides(&pack2),
|
||||
pack_overrides(&pack3),
|
||||
"两轮导出的覆盖条目必须一样"
|
||||
);
|
||||
// 导出的是"内联"形态(离线目录没有远端命中),所以覆盖条目就是那四条内容文件。
|
||||
assert_eq!(
|
||||
pack_overrides(&pack2).keys().cloned().collect::<Vec<_>>(),
|
||||
vec![
|
||||
"config/known.toml",
|
||||
"mods/layer.jar",
|
||||
"mods/remote.jar",
|
||||
"scripts/start.js"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeping_the_user_file_is_what_makes_the_rounds_diverge() {
|
||||
// 对照:同一个冲突若选「保留」,第二轮结果**按设计**与第一轮不同——这正是"决策真的进了管线"
|
||||
// 的反向证据(否则 `per_item` 那条就只是走了个过场)。
|
||||
let (pack1, fetcher) = layered_pack("rt3-keep");
|
||||
let base = TempRoot::new("rt3-keep");
|
||||
let (dir_a, dir_b) = (base.join("a"), base.join("b"));
|
||||
block_on(import_pack(
|
||||
&ImportRequest::new(&pack1, &dir_a).with_fetcher(&fetcher),
|
||||
));
|
||||
write_version(&dir_a, "1.20.1", "1.20.1");
|
||||
let pack2 = base.join("round2.mrpack");
|
||||
export(&dir_a, &pack2);
|
||||
|
||||
write_file(&dir_b.join("config/known.toml"), USER_KNOWN);
|
||||
let report = block_on(import_pack(
|
||||
&ImportRequest::new(&pack2, &dir_b).with_fetcher(&fetcher),
|
||||
));
|
||||
// 保留了一条冲突 ⇒ `Partial`(实例可用,但与包内容不全一致)。
|
||||
assert_eq!(report.status, ImportStatus::Partial, "{:?}", report.error);
|
||||
assert_eq!(
|
||||
std::fs::read(dir_b.join("config/known.toml")).expect("读"),
|
||||
USER_KNOWN,
|
||||
"缺省决策下用户那份必须原样留着"
|
||||
);
|
||||
let a = content_files(&dir_a);
|
||||
let b = content_files(&dir_b);
|
||||
assert_ne!(a, b, "选了保留就该与第一轮不同(这条证明决策真的生效)");
|
||||
let mut differing: Vec<&str> = Vec::new();
|
||||
for (path, bytes) in &a {
|
||||
if b.get(path) != Some(bytes) {
|
||||
differing.push(path.as_str());
|
||||
}
|
||||
}
|
||||
assert_eq!(differing, vec!["config/known.toml"], "只有那一处不同");
|
||||
}
|
||||
@@ -149,8 +149,8 @@ macro_rules! content_entries {
|
||||
),
|
||||
PackOverwriteAction => ("Re-import, the pack wins", "以包为准重新导入"),
|
||||
PackOverwriteNote => (
|
||||
"Keeping both (renamed) is not supported yet: the import pipeline has one global conflict policy and no per-file decision channel.",
|
||||
"「两者都留(改名)」暂不支持:导入管线只有一套全局冲突策略,没有逐文件决策通道。"
|
||||
"Keeping both (renamed) is not supported yet: for each existing file you pick either “keep” or “overwrite”.",
|
||||
"「两者都留(改名)」暂不支持:每个已存在的文件只能选「保留」或「覆盖」。"
|
||||
),
|
||||
PackMissingOffline => ("offline import", "离线导入"),
|
||||
PackMissingNoSource => (
|
||||
@@ -173,6 +173,36 @@ macro_rules! content_entries {
|
||||
"包内 {pack} · 基线 {base} · 磁盘 {disk}"
|
||||
),
|
||||
PackConflictDetail => ("Hashes", "三方哈希"),
|
||||
PackPickTitle => (
|
||||
"Before importing: {count} files already exist with different content",
|
||||
"导入前:有 {count} 个文件已存在且内容不同"
|
||||
),
|
||||
PackPickSummary => (
|
||||
"Keep {keep} · overwrite {overwrite} · overwriting discards {bytes}",
|
||||
"保留 {keep} · 覆盖 {overwrite} · 覆盖将丢弃 {bytes}"
|
||||
),
|
||||
PackPickKeepAll => ("Keep all mine", "全部保留我的"),
|
||||
PackPickOverwriteAll => ("Overwrite all", "全部覆盖"),
|
||||
PackPickRowKeep => ("Keep", "保留"),
|
||||
PackPickRowOverwrite => ("Overwrite", "覆盖"),
|
||||
PackPickPage => ("Items {from}-{to} of {total}", "第 {from}-{to} 项,共 {total} 项"),
|
||||
PackPickPrev => ("Previous", "上一页"),
|
||||
PackPickNext => ("Next", "下一页"),
|
||||
PackPickConfirm => ("Import with these choices", "按这些选择导入"),
|
||||
PackPickCancel => ("Cancel import", "取消导入"),
|
||||
PackPickDefaultNote => (
|
||||
"Anything you do not change stays as you have it: file content is only replaced where you pick “Overwrite”.",
|
||||
"没有改动的一律按「保留」:只有你点了「覆盖」的文件才会被包内内容替换。"
|
||||
),
|
||||
PackPickUnknownRemote => (
|
||||
"{count} remote entries only learn their path after download; they follow “keep”.",
|
||||
"还有 {count} 条远端条目要下载后才知道路径,一律按「保留」处置。"
|
||||
),
|
||||
PackPickSourceOverrides => ("in the pack (overrides/)", "包内 overrides/"),
|
||||
PackPickSourceClientOverrides => ("in the pack (client-overrides/)", "包内 client-overrides/"),
|
||||
PackPickSourceManifest => ("manifest files[]", "清单 files[]"),
|
||||
PackPickDisk => ("yours: {size}", "你的:{size}"),
|
||||
PackPickIncoming => ("pack: {size}", "包内:{size}"),
|
||||
PackFormatModrinth => ("Modrinth .mrpack", "Modrinth 的 .mrpack"),
|
||||
PackFormatCurseforge => ("CurseForge manifest", "CurseForge 清单"),
|
||||
PackFormatMultimc => ("MultiMC / Prism instance", "MultiMC·Prism 实例"),
|
||||
|
||||
@@ -228,7 +228,7 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[
|
||||
("PackReportRejectedNote", "not supported by the client", "客户端不支持"),
|
||||
("PackCancelled", "Import keeps running in the background — this result is discarded. Files already written stay.", "导入会在后台继续跑完 —— 本次结果作废;已经写下的文件会留下。"),
|
||||
("PackOverwriteAction", "Re-import, the pack wins", "以包为准重新导入"),
|
||||
("PackOverwriteNote", "Keeping both (renamed) is not supported yet: the import pipeline has one global conflict policy and no per-file decision channel.", "「两者都留(改名)」暂不支持:导入管线只有一套全局冲突策略,没有逐文件决策通道。"),
|
||||
("PackOverwriteNote", "Keeping both (renamed) is not supported yet: for each existing file you pick either “keep” or “overwrite”.", "「两者都留(改名)」暂不支持:每个已存在的文件只能选「保留」或「覆盖」。"),
|
||||
("PackMissingOffline", "offline import", "离线导入"),
|
||||
("PackMissingNoSource", "no download source available yet", "暂时拿不到下载地址"),
|
||||
("PackMissingFetchFailed", "download failed: {detail}", "下载失败:{detail}"),
|
||||
@@ -241,6 +241,24 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[
|
||||
("PackChoiceOverwrite", "pack version used", "用包里的"),
|
||||
("PackConflictHashes", "pack {pack} · base {base} · disk {disk}", "包内 {pack} · 基线 {base} · 磁盘 {disk}"),
|
||||
("PackConflictDetail", "Hashes", "三方哈希"),
|
||||
("PackPickTitle", "Before importing: {count} files already exist with different content", "导入前:有 {count} 个文件已存在且内容不同"),
|
||||
("PackPickSummary", "Keep {keep} · overwrite {overwrite} · overwriting discards {bytes}", "保留 {keep} · 覆盖 {overwrite} · 覆盖将丢弃 {bytes}"),
|
||||
("PackPickKeepAll", "Keep all mine", "全部保留我的"),
|
||||
("PackPickOverwriteAll", "Overwrite all", "全部覆盖"),
|
||||
("PackPickRowKeep", "Keep", "保留"),
|
||||
("PackPickRowOverwrite", "Overwrite", "覆盖"),
|
||||
("PackPickPage", "Items {from}-{to} of {total}", "第 {from}-{to} 项,共 {total} 项"),
|
||||
("PackPickPrev", "Previous", "上一页"),
|
||||
("PackPickNext", "Next", "下一页"),
|
||||
("PackPickConfirm", "Import with these choices", "按这些选择导入"),
|
||||
("PackPickCancel", "Cancel import", "取消导入"),
|
||||
("PackPickDefaultNote", "Anything you do not change stays as you have it: file content is only replaced where you pick “Overwrite”.", "没有改动的一律按「保留」:只有你点了「覆盖」的文件才会被包内内容替换。"),
|
||||
("PackPickUnknownRemote", "{count} remote entries only learn their path after download; they follow “keep”.", "还有 {count} 条远端条目要下载后才知道路径,一律按「保留」处置。"),
|
||||
("PackPickSourceOverrides", "in the pack (overrides/)", "包内 overrides/"),
|
||||
("PackPickSourceClientOverrides", "in the pack (client-overrides/)", "包内 client-overrides/"),
|
||||
("PackPickSourceManifest", "manifest files[]", "清单 files[]"),
|
||||
("PackPickDisk", "yours: {size}", "你的:{size}"),
|
||||
("PackPickIncoming", "pack: {size}", "包内:{size}"),
|
||||
("PackFormatModrinth", "Modrinth .mrpack", "Modrinth 的 .mrpack"),
|
||||
("PackFormatCurseforge", "CurseForge manifest", "CurseForge 清单"),
|
||||
("PackFormatMultimc", "MultiMC / Prism instance", "MultiMC·Prism 实例"),
|
||||
|
||||
@@ -54,11 +54,13 @@ pub use logs::{Diagnosis, DiagnosisReport, LogLevel, LogLine, LogOutcome, LogSes
|
||||
pub use notices::{NoticeLevel, NoticeView};
|
||||
// M113: import/export
|
||||
pub use pack::{
|
||||
ConflictChoiceView, ConflictFileView, ConflictReasonView, ExportClassView, ExportDoneView,
|
||||
ExportFileView, ExportPreviewView, ExportReasonView, ExportRequestView, ExportState,
|
||||
FileActionView, ImportErrorView, ImportProgressView, ImportReportView, ImportRequestView,
|
||||
ImportStageView, ImportState, ImportStatusView, ImportedFileView, MissingFileView,
|
||||
MissingReasonView, PackCommand, PackEvent, PackFormatView, PackState, ProbeView,
|
||||
ConflictChoiceView, ConflictDecisionView, ConflictDecisionsView, ConflictFileView,
|
||||
ConflictReasonView, ConflictSourceView, ExportClassView, ExportDoneView, ExportFileView,
|
||||
ExportPreviewView, ExportReasonView, ExportRequestView, ExportState, FileActionView,
|
||||
ImportConflictView, ImportErrorView, ImportProgressView, ImportReportView, ImportRequestView,
|
||||
ImportScanView, ImportStageView, ImportState, ImportStatusView, ImportedFileView,
|
||||
MissingFileView, MissingReasonView, PackCommand, PackEvent, PackFormatView, PackState,
|
||||
ProbeView,
|
||||
};
|
||||
pub use settings::{SettingControl, SettingGroupView, SettingRowView};
|
||||
// M159: 自更新
|
||||
|
||||
@@ -26,10 +26,18 @@
|
||||
//! | [`conflict`] | 冲突成因、处置、三方哈希 |
|
||||
//! | [`export`] | 三分类 / 逐条原因 / 预检 |
|
||||
//!
|
||||
//! ## 冲突:默认一侧永远是用户那一侧
|
||||
//! ## 冲突:预检清单 → 用户点头 → 事后账(M160)
|
||||
//!
|
||||
//! [`ImportRequestView::overwrite`] 缺省 `false` = `ConflictPolicy::KeepUser`。这是 M113 的硬线:
|
||||
//! **绝不默认覆盖用户改过的文件**。
|
||||
//! 三样东西在三处,别混:
|
||||
//!
|
||||
//! | 类型 | 时机 | 语义 |
|
||||
//! |---|---|---|
|
||||
//! | [`ImportScanView`] / [`ImportConflictView`] | 导入**前**([`PackEvent::Scan`]) | 待用户点头的冲突清单 |
|
||||
//! | [`ConflictDecisionsView`] | 页面 → 宿主([`ImportRequestView::decisions`]) | 全部保留 / 全部覆盖 / 逐项 |
|
||||
//! | [`ConflictFileView`] | 导入**后**([`PackEvent::Report`]) | 每条实际怎么处置的 |
|
||||
//!
|
||||
//! 两处缺省都是**保留**([`ConflictDecisionsView::default`] = `KeepAll`,core 的
|
||||
//! `ConflictChoice::default()` = `KeepUser`):**绝不默认覆盖用户已有文件**。
|
||||
|
||||
mod conflict;
|
||||
mod event;
|
||||
@@ -37,7 +45,10 @@ mod export;
|
||||
mod format;
|
||||
mod import;
|
||||
|
||||
pub use conflict::{ConflictChoiceView, ConflictFileView, ConflictReasonView};
|
||||
pub use conflict::{
|
||||
ConflictChoiceView, ConflictDecisionView, ConflictDecisionsView, ConflictFileView,
|
||||
ConflictReasonView, ConflictSourceView, ImportConflictView, ImportScanView,
|
||||
};
|
||||
pub use event::{PackCommand, PackEvent, PackState};
|
||||
pub use export::{
|
||||
ExportClassView, ExportDoneView, ExportFileView, ExportPreviewView, ExportReasonView,
|
||||
|
||||
@@ -1,11 +1,238 @@
|
||||
//! 冲突的 DTO:成因、本次处置、以及三方哈希的那一条账。
|
||||
//! 冲突的 DTO:成因、本次处置、**预检的待决清单**、以及三方哈希的那一条账。
|
||||
//!
|
||||
//! 三条判据与 core 的 `ConflictReason` 一一对应;`choice` 是**事后记录**(本次实际怎么处置的),
|
||||
//! 不是「逐文件决策通道」——core 今天没有那种通道(`ConflictPolicy` 是整次导入一个全局档)。
|
||||
//! 「默认保留用户那一侧」这条硬线在 `ImportRequestView::overwrite` 缺省 `false` 上。
|
||||
//! 三种东西分得清清楚楚(混起来就会把"要不要覆盖"变成"已经覆盖了"):
|
||||
//!
|
||||
//! | 类型 | 什么时候出现 | 语义 |
|
||||
//! |---|---|---|
|
||||
//! | [`ImportConflictView`] | **导入前**(`PackEvent::Scan`) | 待用户点头的一条:路径、来源、目标现有体积与哈希 |
|
||||
//! | [`ConflictFileView`] | **导入后**(`PackEvent::Report`) | 事后账:这条实际怎么处置的(`choice`) |
|
||||
//! | [`ConflictDecisionsView`] | 从页面发回去(`ImportRequestView::decisions`) | 用户点的「全部保留 / 全部覆盖 / 逐项」 |
|
||||
//!
|
||||
//! 「默认保留用户那一侧」这条硬线在 [`ConflictDecisionsView::default`] 上:缺省 = 全部保留,
|
||||
//! 而 core 侧 [`ConflictChoiceView::KeepUser`] 也是缺省档——两侧都不许"没收到决策就覆盖"。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 一条冲突**来源**(预检清单里那一列)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ConflictSourceView {
|
||||
/// 包内 `overrides/`(CF 清单点名的那个目录 / MultiMC 的 `.minecraft/` 也走这条)。
|
||||
Overrides,
|
||||
/// 包内 `client-overrides/`(叠层里胜出的那一侧)。
|
||||
ClientOverrides,
|
||||
/// CurseForge `files[]` 条目。
|
||||
CurseForge,
|
||||
/// Modrinth `files[]` 条目。
|
||||
Modrinth,
|
||||
}
|
||||
|
||||
impl ConflictSourceView {
|
||||
/// 稳定 slug。
|
||||
#[must_use]
|
||||
pub const fn slug(self) -> &'static str {
|
||||
match self {
|
||||
Self::Overrides => "overrides",
|
||||
Self::ClientOverrides => "client_overrides",
|
||||
Self::CurseForge => "curseforge",
|
||||
Self::Modrinth => "modrinth",
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 core 的 id 反解。
|
||||
#[must_use]
|
||||
pub fn from_slug(slug: &str) -> Option<Self> {
|
||||
match slug {
|
||||
"overrides" => Some(Self::Overrides),
|
||||
"client_overrides" => Some(Self::ClientOverrides),
|
||||
"curseforge" => Some(Self::CurseForge),
|
||||
"modrinth" => Some(Self::Modrinth),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 是不是"包内的覆盖目录"(相对于清单里的远端条目)。
|
||||
#[must_use]
|
||||
pub const fn is_override_dir(self) -> bool {
|
||||
matches!(self, Self::Overrides | Self::ClientOverrides)
|
||||
}
|
||||
}
|
||||
|
||||
/// 一条**待用户点头**的冲突(导入前那张清单里的一行)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ImportConflictView {
|
||||
/// 实例内相对路径。
|
||||
pub path: String,
|
||||
/// 来源(包内覆盖目录 / 清单远端条目)。
|
||||
pub source: ConflictSourceView,
|
||||
/// 成因。
|
||||
pub reason: ConflictReasonView,
|
||||
/// 包内那条是不是禁用态(`.disabled`)。
|
||||
pub disabled: bool,
|
||||
/// 包内声明的 SHA-1。
|
||||
pub pack_sha1: String,
|
||||
/// 包内声明的字节数。
|
||||
pub pack_size: u64,
|
||||
/// 目标现有文件的 SHA-1。
|
||||
pub disk_sha1: String,
|
||||
/// 目标现有文件的字节数(覆盖会丢掉的量)。
|
||||
pub disk_size: u64,
|
||||
/// 上次导入写下的 SHA-1(没有基线时 `None`)。
|
||||
pub base_sha1: Option<String>,
|
||||
}
|
||||
|
||||
/// 用户的冲突决策(三条入口共用一个类型)。
|
||||
///
|
||||
/// 三条入口与 core 的 `ConflictDecisions`(`default` + `entries`)是同一件事的两种说法:
|
||||
///
|
||||
/// | 变体 | core 形状 |
|
||||
/// |---|---|
|
||||
/// | [`ConflictDecisionsView::KeepAll`] | `default = KeepUser`, 空表 |
|
||||
/// | [`ConflictDecisionsView::OverwriteAll`] | `default = Overwrite`, 空表 |
|
||||
/// | `PerItem { default, items }` | `default` + 逐项表 |
|
||||
///
|
||||
/// `PerItem` 带一个 `default` 是为了让"全部覆盖之后我又单独放回两条"这种真实操作可表达:
|
||||
/// 若只有"逐项覆盖 + 缺省保留",用户就得把剩下的几百条全点一遍。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ConflictDecisionsView {
|
||||
/// **全部保留**(缺省):用户已有文件一个都不动。
|
||||
KeepAll,
|
||||
/// **全部覆盖**:整合包说了算。
|
||||
OverwriteAll,
|
||||
/// **逐项**:点过的路径按 `items`,其余按 `default`。
|
||||
PerItem {
|
||||
/// 没点过的路径按它处置。
|
||||
default: ConflictChoiceView,
|
||||
/// 点过的路径。
|
||||
items: Vec<ConflictDecisionView>,
|
||||
},
|
||||
}
|
||||
|
||||
impl Default for ConflictDecisionsView {
|
||||
/// **缺省 = 全部保留**(绝不默认覆盖用户已有文件)。
|
||||
fn default() -> Self {
|
||||
Self::KeepAll
|
||||
}
|
||||
}
|
||||
|
||||
impl ConflictDecisionsView {
|
||||
/// 逐项构造(缺省档 + 点过的那些)。
|
||||
#[must_use]
|
||||
pub fn per_item(default: ConflictChoiceView, items: Vec<ConflictDecisionView>) -> Self {
|
||||
if items.is_empty() {
|
||||
return match default {
|
||||
ConflictChoiceView::KeepUser => Self::KeepAll,
|
||||
ConflictChoiceView::Overwrite => Self::OverwriteAll,
|
||||
};
|
||||
}
|
||||
Self::PerItem { default, items }
|
||||
}
|
||||
|
||||
/// 逐项表里的缺省档(`KeepAll` / `OverwriteAll` 也答得出)。
|
||||
#[must_use]
|
||||
pub const fn default_choice(&self) -> ConflictChoiceView {
|
||||
match self {
|
||||
Self::KeepAll => ConflictChoiceView::KeepUser,
|
||||
Self::OverwriteAll => ConflictChoiceView::Overwrite,
|
||||
Self::PerItem { default, .. } => *default,
|
||||
}
|
||||
}
|
||||
|
||||
/// 用户显式要求"以包为准"吗(整批覆盖)。
|
||||
#[must_use]
|
||||
pub fn overwrites_everything(&self) -> bool {
|
||||
matches!(self, Self::OverwriteAll)
|
||||
}
|
||||
|
||||
/// 逐项表里"覆盖"的条数(不含缺省档)。
|
||||
#[must_use]
|
||||
pub fn overwrite_count(&self) -> usize {
|
||||
self.items()
|
||||
.iter()
|
||||
.filter(|item| item.choice == ConflictChoiceView::Overwrite)
|
||||
.count()
|
||||
}
|
||||
|
||||
/// 逐项表里"保留"的条数。
|
||||
#[must_use]
|
||||
pub fn keep_count(&self) -> usize {
|
||||
self.items()
|
||||
.iter()
|
||||
.filter(|item| item.choice == ConflictChoiceView::KeepUser)
|
||||
.count()
|
||||
}
|
||||
|
||||
/// 逐项表(`KeepAll` / `OverwriteAll` 是空表)。
|
||||
#[must_use]
|
||||
pub fn items(&self) -> &[ConflictDecisionView] {
|
||||
match self {
|
||||
Self::PerItem { items, .. } => items,
|
||||
Self::KeepAll | Self::OverwriteAll => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// 逐项表的条数。
|
||||
#[must_use]
|
||||
pub fn len(&self) -> usize {
|
||||
self.items().len()
|
||||
}
|
||||
|
||||
/// 有没有逐项决策。
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.items().is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// 逐项决策的一条(路径 → 处置)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ConflictDecisionView {
|
||||
/// 实例内相对路径。
|
||||
pub path: String,
|
||||
/// 处置。
|
||||
pub choice: ConflictChoiceView,
|
||||
}
|
||||
|
||||
/// 一次导入预检的结果(`PackEvent::Scan` 的载荷)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ImportScanView {
|
||||
/// 目标实例目录(人要看到"装到哪")。
|
||||
pub target: std::path::PathBuf,
|
||||
/// 命中的格式。
|
||||
pub format: Option<crate::dto::PackFormatView>,
|
||||
/// 包名。
|
||||
pub pack_name: Option<String>,
|
||||
/// 包版本。
|
||||
pub pack_version: Option<String>,
|
||||
/// 游戏版本。
|
||||
pub game_version: Option<String>,
|
||||
/// 待用户点头的冲突(按路径排序)。
|
||||
pub conflicts: Vec<ImportConflictView>,
|
||||
/// 补全前没有路径、因而判不了的远端条目数。
|
||||
pub unknown_remote: usize,
|
||||
/// 覆盖会丢掉的字节总量。
|
||||
pub lost_bytes: u64,
|
||||
/// 包内那些条目声明了多大。
|
||||
pub incoming_bytes: u64,
|
||||
/// 注记(与导入报告同源的人读串)。
|
||||
pub notes: Vec<String>,
|
||||
/// 预检本身失败(探测不过 / 开包失败)时的原因。
|
||||
pub error: Option<crate::dto::ImportErrorView>,
|
||||
}
|
||||
|
||||
impl ImportScanView {
|
||||
/// 需要用户点头吗(`false` = 可以直接导入,别打扰)。
|
||||
#[must_use]
|
||||
pub fn needs_decision(&self) -> bool {
|
||||
!self.conflicts.is_empty()
|
||||
}
|
||||
|
||||
/// 一条冲突都没有、预检也成功(可以直接导)。
|
||||
#[must_use]
|
||||
pub fn is_clean(&self) -> bool {
|
||||
self.error.is_none() && self.conflicts.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// 冲突成因(core 的三条判据)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ConflictReasonView {
|
||||
|
||||
@@ -26,12 +26,17 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::dto::pack::{
|
||||
ExportDoneView, ExportPreviewView, ExportRequestView, ExportState, ImportProgressView,
|
||||
ImportReportView, ImportRequestView, ImportState, ProbeView,
|
||||
ImportReportView, ImportRequestView, ImportScanView, ImportState, ProbeView,
|
||||
};
|
||||
|
||||
/// app → host 的导入/导出意图。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum PackCommand {
|
||||
/// **预检**导入(只读:算出冲突清单,**不写一个字节**)
|
||||
///
|
||||
/// 页面先发它,拿到 [`PackEvent::Scan`] 之后:没有冲突就直接发 [`PackCommand::Import`]
|
||||
/// (不打扰用户);有冲突就把清单画出来,用户点完再带着决策发 `Import`。
|
||||
Scan(Box<ImportRequestView>),
|
||||
/// 开始导入(目标实例已选好;`instance` 为 `None` = 按 `instance_name` 新建目录)
|
||||
Import(Box<ImportRequestView>),
|
||||
/// 跑**预检**(只扫描分类,**不写盘**)
|
||||
@@ -47,6 +52,8 @@ pub enum PackCommand {
|
||||
pub enum PackEvent {
|
||||
/// 导入进度(`None` = 收回面板:取消,或流程已经给出结果)
|
||||
Progress(Option<Box<ImportProgressView>>),
|
||||
/// 导入**预检**结果:冲突清单(M160)。空清单 = 没有文件需要用户点头。
|
||||
Scan(Box<ImportScanView>),
|
||||
/// 导入结果(三态 + 逐项原因;致命失败也走它,`report.error` 里有原因)
|
||||
Report(Box<ImportReportView>),
|
||||
/// 探测失败:这个包看起来像什么、缺哪个标记文件
|
||||
@@ -80,15 +87,24 @@ impl PackState {
|
||||
PackEvent::Progress(progress) => {
|
||||
self.import.progress = progress.map(|progress| *progress);
|
||||
}
|
||||
PackEvent::Report(report) => {
|
||||
// 结果到了:进度面板让位,探测诊断作废(这一轮已经有结论了)
|
||||
PackEvent::Scan(scan) => {
|
||||
// 预检到了:进度让位,上一轮的结果/诊断作废(这一轮还没开始导)。
|
||||
self.import.progress = None;
|
||||
self.import.probe = None;
|
||||
self.import.report = None;
|
||||
self.import.scan = Some(*scan);
|
||||
}
|
||||
PackEvent::Report(report) => {
|
||||
// 结果到了:进度面板让位,探测诊断与预检清单都作废(这一轮已经有结论了)
|
||||
self.import.progress = None;
|
||||
self.import.probe = None;
|
||||
self.import.scan = None;
|
||||
self.import.report = Some(*report);
|
||||
}
|
||||
PackEvent::Probe(probe) => {
|
||||
self.import.progress = None;
|
||||
self.import.report = None;
|
||||
self.import.scan = None;
|
||||
self.import.probe = Some(*probe);
|
||||
}
|
||||
PackEvent::Preview(preview) => {
|
||||
|
||||
@@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::dto::InstanceId;
|
||||
|
||||
use super::conflict::ConflictFileView;
|
||||
use super::conflict::{ConflictDecisionsView, ConflictFileView, ImportScanView};
|
||||
use super::format::PackFormatView;
|
||||
|
||||
/// 导入阶段(与 core 的 `ImportStage` 一一对应)。
|
||||
@@ -144,10 +144,11 @@ pub struct ImportRequestView {
|
||||
pub instance: Option<InstanceId>,
|
||||
/// 新实例的展示名(`None` = 用包名)。
|
||||
pub instance_name: Option<String>,
|
||||
/// 冲突翻档:`false`(**默认**)= 保留用户那一侧;`true` = 以包为准覆盖。
|
||||
/// 冲突决策(**缺省 [`ConflictDecisionsView::KeepAll`]** = 用户已有文件一个都不动)。
|
||||
///
|
||||
/// 页面上把 `true` 那条写成「以包为准重新导入」,并且**只在用户显式点过之后**才发。
|
||||
pub overwrite: bool,
|
||||
/// 页面上:`KeepAll` 是缺省与「全部保留」,`OverwriteAll` 是「全部覆盖」,
|
||||
/// `PerItem` 是逐项勾选的结果。只有用户**显式点过**之后才会出现后两者。
|
||||
pub decisions: ConflictDecisionsView,
|
||||
}
|
||||
|
||||
/// 一条导入进度。
|
||||
@@ -334,6 +335,8 @@ pub struct ImportState {
|
||||
/// 用户点了取消(M113 r1):**面板收回但后台还在跑**,所以这一位要留在界面上——
|
||||
/// 否则用户会以为「没建成」,回头再导一次就撞「directory already exists」。
|
||||
pub cancelled: bool,
|
||||
/// 最近一次的预检(M160:冲突清单;`None` = 还没预检过或已被新结果顶掉)。
|
||||
pub scan: Option<ImportScanView>,
|
||||
/// 最近一次的结果(`None` = 还没导过)。
|
||||
pub report: Option<ImportReportView>,
|
||||
/// 探测失败的具名诊断(有它就让用户看清「像 X 缺 Y」,而不是一句「无法识别」)。
|
||||
|
||||
@@ -185,6 +185,11 @@ impl super::super::CoreHost {
|
||||
pub(crate) fn pack(&mut self, command: PackCommand) {
|
||||
let epoch = pack_epoch(&self.env);
|
||||
match command {
|
||||
PackCommand::Scan(request) => {
|
||||
// 预检只读:不点亮导入面板(那会让用户以为已经在装了),也不计"在跑导入"。
|
||||
let cancel = fresh_cancel(&self.env);
|
||||
import::spawn_scan(self.env.clone(), self.tx.clone(), *request, epoch, cancel);
|
||||
}
|
||||
PackCommand::Import(request) => {
|
||||
let cancel = fresh_cancel(&self.env);
|
||||
// 句柄在**派发点**就计上,随线程一起结束(含早退与 panic)
|
||||
|
||||
@@ -12,17 +12,92 @@ use std::path::{Path, PathBuf};
|
||||
|
||||
use unlucky_core::addon::export::{ExportReport, FileClass, FileReason};
|
||||
use unlucky_core::addon::import::{
|
||||
ConflictChoice, ConflictFile, ConflictReason, FileAction, ImportError, ImportProgress,
|
||||
ImportReport, LoaderTarget, MissingReason,
|
||||
ConflictChoice, ConflictDecisions, ConflictFile, ConflictReason, FileAction, FileSource,
|
||||
ImportError, ImportProgress, ImportReport, ImportScan, LoaderTarget, MissingReason,
|
||||
};
|
||||
|
||||
use crate::dto::{
|
||||
ConflictChoiceView, ConflictFileView, ConflictReasonView, ExportClassView, ExportDoneView,
|
||||
ExportReasonView, FileActionView, ImportErrorView, ImportProgressView, ImportReportView,
|
||||
ConflictChoiceView, ConflictDecisionsView, ConflictFileView, ConflictReasonView,
|
||||
ConflictSourceView, ExportClassView, ExportDoneView, ExportReasonView, FileActionView,
|
||||
ImportConflictView, ImportErrorView, ImportProgressView, ImportReportView, ImportScanView,
|
||||
ImportStageView, ImportStatusView, ImportedFileView, MissingFileView, MissingReasonView,
|
||||
PackFormatView,
|
||||
};
|
||||
|
||||
/// 一次导入**预检** → DTO(M160:冲突清单)。
|
||||
pub(super) fn scan_view(scan: &ImportScan) -> ImportScanView {
|
||||
ImportScanView {
|
||||
target: scan.target.clone(),
|
||||
format: scan.format.as_deref().and_then(PackFormatView::from_slug),
|
||||
pack_name: scan.pack_name.clone(),
|
||||
pack_version: scan.pack_version.clone(),
|
||||
game_version: scan.game_version.clone(),
|
||||
conflicts: scan
|
||||
.conflicts
|
||||
.iter()
|
||||
.map(|conflict| ImportConflictView {
|
||||
path: conflict.path.clone(),
|
||||
source: source_view(conflict.source),
|
||||
reason: reason_of(conflict.reason),
|
||||
disabled: conflict.disabled,
|
||||
pack_sha1: conflict.pack_sha1.clone(),
|
||||
pack_size: conflict.pack_size,
|
||||
disk_sha1: conflict.disk_sha1.clone(),
|
||||
disk_size: conflict.disk_size,
|
||||
base_sha1: conflict.base_sha1.clone(),
|
||||
})
|
||||
.collect(),
|
||||
unknown_remote: scan.unknown_remote,
|
||||
lost_bytes: scan.lost_bytes(),
|
||||
incoming_bytes: scan.incoming_bytes(),
|
||||
notes: scan
|
||||
.notes
|
||||
.iter()
|
||||
.map(|issue| issue.message.clone())
|
||||
.collect(),
|
||||
error: scan.error.as_ref().map(error_view),
|
||||
}
|
||||
}
|
||||
|
||||
/// DTO 的决策 → core 的决策表(**形状一换,语义不换**)。
|
||||
///
|
||||
/// 三条入口一一对应;没点名的路径在 core 侧落到"保留"(`ConflictDecisions` 的缺省档),
|
||||
/// 所以"页面少传一条"永远不会变成"覆盖"。
|
||||
pub(super) fn decisions_of(view: &ConflictDecisionsView) -> ConflictDecisions {
|
||||
let mut decisions = match view.default_choice() {
|
||||
ConflictChoiceView::KeepUser => ConflictDecisions::keep_all(),
|
||||
ConflictChoiceView::Overwrite => ConflictDecisions::overwrite_all(),
|
||||
};
|
||||
for item in view.items() {
|
||||
decisions.set(item.path.clone(), choice_of(item.choice));
|
||||
}
|
||||
decisions
|
||||
}
|
||||
|
||||
const fn choice_of(choice: ConflictChoiceView) -> ConflictChoice {
|
||||
match choice {
|
||||
ConflictChoiceView::KeepUser => ConflictChoice::KeepUser,
|
||||
ConflictChoiceView::Overwrite => ConflictChoice::Overwrite,
|
||||
}
|
||||
}
|
||||
|
||||
const fn source_view(source: FileSource) -> ConflictSourceView {
|
||||
match source {
|
||||
FileSource::Overrides => ConflictSourceView::Overrides,
|
||||
FileSource::ClientOverrides => ConflictSourceView::ClientOverrides,
|
||||
FileSource::CurseForge => ConflictSourceView::CurseForge,
|
||||
FileSource::Modrinth => ConflictSourceView::Modrinth,
|
||||
}
|
||||
}
|
||||
|
||||
const fn reason_of(reason: ConflictReason) -> ConflictReasonView {
|
||||
match reason {
|
||||
ConflictReason::UserModified => ConflictReasonView::UserModified,
|
||||
ConflictReason::UserDeleted => ConflictReasonView::UserDeleted,
|
||||
ConflictReason::PreExisting => ConflictReasonView::PreExisting,
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次导入结果 → DTO。
|
||||
pub(super) fn report_view(report: &ImportReport, target: &Path) -> ImportReportView {
|
||||
ImportReportView {
|
||||
@@ -114,15 +189,8 @@ fn error_view(error: &ImportError) -> ImportErrorView {
|
||||
fn conflict_view(file: &ConflictFile) -> ConflictFileView {
|
||||
ConflictFileView {
|
||||
path: file.path.clone(),
|
||||
reason: match file.reason {
|
||||
ConflictReason::UserModified => ConflictReasonView::UserModified,
|
||||
ConflictReason::UserDeleted => ConflictReasonView::UserDeleted,
|
||||
ConflictReason::PreExisting => ConflictReasonView::PreExisting,
|
||||
},
|
||||
choice: match file.choice {
|
||||
ConflictChoice::KeepUser => ConflictChoiceView::KeepUser,
|
||||
ConflictChoice::Overwrite => ConflictChoiceView::Overwrite,
|
||||
},
|
||||
reason: reason_of(file.reason),
|
||||
choice: choice_view(file.choice),
|
||||
pack_sha1: file.pack_sha1.clone(),
|
||||
base_sha1: file.base_sha1.clone(),
|
||||
disk_sha1: file.disk_sha1.clone(),
|
||||
@@ -130,6 +198,14 @@ fn conflict_view(file: &ConflictFile) -> ConflictFileView {
|
||||
}
|
||||
}
|
||||
|
||||
/// 一条 `ConflictChoice` → DTO(预检与事后账共用一张翻译表)。
|
||||
const fn choice_view(choice: ConflictChoice) -> ConflictChoiceView {
|
||||
match choice {
|
||||
ConflictChoice::KeepUser => ConflictChoiceView::KeepUser,
|
||||
ConflictChoice::Overwrite => ConflictChoiceView::Overwrite,
|
||||
}
|
||||
}
|
||||
|
||||
const fn action_view(action: FileAction) -> FileActionView {
|
||||
match action {
|
||||
FileAction::Installed => FileActionView::Installed,
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
//! 后台导入(M113):探测 → 目标目录 → core 的 `import_pack` → 报告。
|
||||
//! 后台导入(M113/M160):探测 → **预检(冲突清单)** → 目标目录 → core 的 `import_pack` → 报告。
|
||||
//!
|
||||
//! 三条纪律:
|
||||
//! 四条纪律:
|
||||
//!
|
||||
//! 1. **探测失败不空跑管线**:先做具名诊断([`super::diagnose`]),失败就只回 `Probe` 事件;
|
||||
//! 2. **默认档永远是 `KeepUser`**:只有 `request.overwrite == true` 才换成 `Overwrite`;
|
||||
//! 3. **取消后不再打扰界面**:进度回调与最终报告都先看取消标记。
|
||||
//! 2. **先预检再导入**(M160):预检只读,把"会盖掉用户哪几个文件"算清楚。没有冲突就直接
|
||||
//! 往下导(不打扰用户);有冲突就回 [`PackEvent::Scan`] 让页面画清单,等用户点完再导;
|
||||
//! 3. **缺省档永远 `KeepAll`**:决策从 DTO 逐字段搬([`convert::decisions_of`]),页面没点过的
|
||||
//! 路径在 core 侧落到"保留";
|
||||
//! 4. **取消后不再打扰界面**:进度回调与最终报告都先看取消标记。
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use unlucky_core::addon::import::{
|
||||
import_pack, ConflictPolicy, DownloadEngineFetcher, ImportProgress, ImportProgressSink,
|
||||
import_pack, scan_pack, DownloadEngineFetcher, ImportProgress, ImportProgressSink,
|
||||
ImportRequest,
|
||||
};
|
||||
use unlucky_core::download::blocking::block_on;
|
||||
@@ -41,6 +44,52 @@ pub(super) fn spawn_import(
|
||||
});
|
||||
}
|
||||
|
||||
/// 起一个后台**预检**线程(M160:只读,不写盘)。
|
||||
pub(super) fn spawn_scan(
|
||||
env: HostEnv,
|
||||
tx: JobSender,
|
||||
request: ImportRequestView,
|
||||
epoch: u64,
|
||||
cancel: Arc<AtomicBool>,
|
||||
) {
|
||||
spawn("pack-scan", move || {
|
||||
// 探测先过一遍:失败就只给具名诊断,不空跑一遍预检。
|
||||
if let Some(probe) = super::diagnose::of(&request.pack) {
|
||||
send(&tx, epoch, PackEvent::Probe(Box::new(probe)));
|
||||
return;
|
||||
}
|
||||
let target = match target_of(&env, &request) {
|
||||
Ok((target, _)) => target,
|
||||
Err(reason) => {
|
||||
// 目标实例不可用:与导入路径同一形状的失败报告(界面不用学第二套)。
|
||||
send(
|
||||
&tx,
|
||||
epoch,
|
||||
PackEvent::Report(Box::new(convert::failed_report(
|
||||
PathBuf::new(),
|
||||
&request.pack,
|
||||
"import_host",
|
||||
"Target instance is not usable",
|
||||
&reason,
|
||||
))),
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let core_request = ImportRequest::new(&request.pack, &target);
|
||||
// 预检是纯读的同步函数(不写盘、不联网),所以直接调用,不必占一个 async 任务。
|
||||
let scan = scan_pack(&core_request);
|
||||
if cancel.load(Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
send(
|
||||
&tx,
|
||||
epoch,
|
||||
PackEvent::Scan(Box::new(convert::scan_view(&scan))),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn run(
|
||||
env: &HostEnv,
|
||||
tx: &JobSender,
|
||||
@@ -71,6 +120,22 @@ pub(super) fn run(
|
||||
return;
|
||||
}
|
||||
};
|
||||
if register {
|
||||
if let Err(reason) = prepare_target(&target) {
|
||||
send(
|
||||
tx,
|
||||
epoch,
|
||||
PackEvent::Report(Box::new(convert::failed_report(
|
||||
target,
|
||||
&request.pack,
|
||||
"import_host",
|
||||
"Target instance is not usable",
|
||||
&reason,
|
||||
))),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let pack_name = request
|
||||
.instance_name
|
||||
@@ -97,10 +162,9 @@ pub(super) fn run(
|
||||
let fetcher = DownloadEngineFetcher::new(&engine);
|
||||
let mut core_request = ImportRequest::new(&request.pack, &target)
|
||||
.with_fetcher(&fetcher)
|
||||
.with_progress(sink);
|
||||
if request.overwrite {
|
||||
core_request = core_request.with_conflict_policy(ConflictPolicy::Overwrite);
|
||||
}
|
||||
.with_progress(sink)
|
||||
// 决策逐字段搬过来:`KeepAll`(缺省)/ `OverwriteAll` / 逐项,语义在 core 那一侧落地。
|
||||
.with_decisions(convert::decisions_of(&request.decisions));
|
||||
if register {
|
||||
core_request = core_request.with_registration(&env.layout, Some(&pack_name));
|
||||
}
|
||||
@@ -134,7 +198,7 @@ fn remove_if_empty(dir: &std::path::Path) {
|
||||
}
|
||||
}
|
||||
|
||||
/// 导入目标:`(目录, 是否登记新实例)`。
|
||||
/// 导入目标:`(目录, 是否登记新实例)` —— **只算路径,不建目录**(预检要能只读地用它)。
|
||||
///
|
||||
/// - 选了实例 ⇒ 它的运行目录,**不登记**(已经是一条实例记录);
|
||||
/// - 没选实例 ⇒ `<data>/instances/<dir_name>` 新目录 + 登记(实例名 = 用户给的名字或包名)。
|
||||
@@ -166,11 +230,13 @@ pub(super) fn target_of(
|
||||
target.display()
|
||||
));
|
||||
}
|
||||
} else {
|
||||
std::fs::create_dir_all(&target)
|
||||
.map_err(|error| format!("create dir failed: {error}"))?;
|
||||
}
|
||||
Ok((target, true))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 真要写盘之前建目录(**预检不调它**:预检是只读的,不建任何东西)。
|
||||
pub(super) fn prepare_target(target: &std::path::Path) -> Result<(), String> {
|
||||
std::fs::create_dir_all(target).map_err(|error| format!("create dir failed: {error}"))
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
//! 夹具(临时目录、写文件、泵 host)在 [`harness`]。
|
||||
|
||||
mod harness;
|
||||
mod scan;
|
||||
|
||||
use harness::{pack_events, pump, pump_until, temp_dir, write};
|
||||
|
||||
@@ -21,7 +22,7 @@ use unlucky_core::addon::export::{
|
||||
DEFAULT_CONTENT_ROOTS,
|
||||
};
|
||||
|
||||
use crate::dto::{ExportClassView, ExportRequestView, PackFormatView};
|
||||
use crate::dto::{ConflictDecisionsView, ExportClassView, ExportRequestView, PackFormatView};
|
||||
|
||||
#[test]
|
||||
fn bad_packs_get_a_named_diagnosis() {
|
||||
@@ -179,7 +180,7 @@ fn a_real_pack_runs_through_the_pipeline_end_to_end() {
|
||||
pack: source,
|
||||
instance: None,
|
||||
instance_name: Some("Imported Pack".to_string()),
|
||||
overwrite: false,
|
||||
decisions: ConflictDecisionsView::default(),
|
||||
};
|
||||
super::import::run(
|
||||
&env,
|
||||
@@ -279,12 +280,17 @@ fn a_failed_new_instance_directory_can_be_reused_for_retry() {
|
||||
pack: dir.join("whatever.mrpack"),
|
||||
instance: None,
|
||||
instance_name: Some("Retry Me".to_string()),
|
||||
overwrite: false,
|
||||
decisions: ConflictDecisionsView::default(),
|
||||
};
|
||||
|
||||
// 第一次:目录不存在 ⇒ 建出来
|
||||
let (first, register) = super::import::target_of(&env, &request).expect("首次应当可以建目录");
|
||||
// 第一次:`target_of` 只算路径(预检要能只读地用它),真要写盘前才 `prepare_target`。
|
||||
let (first, register) = super::import::target_of(&env, &request).expect("首次应当算得出目录");
|
||||
assert!(register);
|
||||
assert!(
|
||||
!first.exists(),
|
||||
"`target_of` 不许建目录(M160:预检是只读的)"
|
||||
);
|
||||
super::import::prepare_target(&first).expect("写盘前建目录");
|
||||
assert!(first.is_dir());
|
||||
|
||||
// 第二次(模拟上一次失败留下的空目录)⇒ 复用,而不是报「已存在」
|
||||
@@ -349,7 +355,7 @@ fn a_failed_import_releases_the_in_flight_flag_and_keeps_install_cancel_intact()
|
||||
pack: dir.join("missing.mrpack"),
|
||||
instance: None,
|
||||
instance_name: Some("Guess".to_string()),
|
||||
overwrite: false,
|
||||
decisions: ConflictDecisionsView::default(),
|
||||
},
|
||||
)))));
|
||||
let events = pump_until(&mut host, |all| {
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
//! 预检与决策通道(M160):DTO ↔ core 的翻译、以及"缺省不覆盖"在 presenter 这一侧的表现。
|
||||
//!
|
||||
//! 这里**不重算**任何判定(判定在 core):只钉两件事——
|
||||
//!
|
||||
//! 1. 三条入口(全部保留 / 全部覆盖 / 逐项)翻成 core 的 `ConflictDecisions` 之后语义不变,
|
||||
//! 尤其"没点名的路径 = 保留";
|
||||
//! 2. 预检清单逐字段搬进 DTO(来源、成因、两侧体积与哈希、计数摘要)。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::harness::{temp_dir, write};
|
||||
use crate::dto::{
|
||||
ConflictChoiceView, ConflictDecisionView, ConflictDecisionsView, ConflictReasonView,
|
||||
ConflictSourceView,
|
||||
};
|
||||
use unlucky_core::addon::import::{
|
||||
ConflictChoice, ConflictReason, FileSource, ImportConflict, ImportScan,
|
||||
};
|
||||
|
||||
/// 造一份 core 侧的预检结果(只填这条用例要看的字段)。
|
||||
fn scan_fixture() -> ImportScan {
|
||||
ImportScan {
|
||||
target: PathBuf::from("instances/survival/.minecraft"),
|
||||
format: Some("modrinth".to_string()),
|
||||
pack_name: Some("Better MC".to_string()),
|
||||
pack_version: Some("1.2.0".to_string()),
|
||||
game_version: Some("1.20.1".to_string()),
|
||||
loader: None,
|
||||
conflicts: vec![ImportConflict {
|
||||
path: "config/sodium-options.json".to_string(),
|
||||
source: FileSource::ClientOverrides,
|
||||
reason: ConflictReason::UserModified,
|
||||
disabled: false,
|
||||
pack_sha1: "aa".repeat(20),
|
||||
pack_size: 4_200,
|
||||
disk_sha1: "bb".repeat(20),
|
||||
disk_size: 5_100,
|
||||
base_sha1: Some("cc".repeat(20)),
|
||||
}],
|
||||
unknown_remote: 2,
|
||||
notes: Vec::new(),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_view_carries_every_field_and_the_counts() {
|
||||
let scan = scan_fixture();
|
||||
let view = super::super::convert::scan_view(&scan);
|
||||
|
||||
assert_eq!(view.format, Some(crate::dto::PackFormatView::Modrinth));
|
||||
assert_eq!(view.pack_name.as_deref(), Some("Better MC"));
|
||||
assert_eq!(view.unknown_remote, 2, "判不了的远端条目要如实计数");
|
||||
assert!(view.needs_decision(), "有一条冲突就要问用户");
|
||||
let conflict = &view.conflicts[0];
|
||||
assert_eq!(conflict.path, "config/sodium-options.json");
|
||||
assert_eq!(
|
||||
conflict.source,
|
||||
ConflictSourceView::ClientOverrides,
|
||||
"叠层胜者那一侧的来源"
|
||||
);
|
||||
assert_eq!(conflict.reason, ConflictReasonView::UserModified);
|
||||
assert_eq!(conflict.pack_size, 4_200);
|
||||
assert_eq!(conflict.disk_size, 5_100, "目标现有体积要出现在清单里");
|
||||
assert_eq!(conflict.disk_sha1.len(), 40, "目标现有哈希也要有");
|
||||
assert_eq!(view.lost_bytes, 5_100, "覆盖会丢掉的字节");
|
||||
assert_eq!(view.incoming_bytes, 4_200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_clean_scan_is_not_a_reason_to_bother_the_user() {
|
||||
let mut scan = scan_fixture();
|
||||
scan.conflicts.clear();
|
||||
let view = super::super::convert::scan_view(&scan);
|
||||
assert!(!view.needs_decision());
|
||||
assert!(view.is_clean(), "没有冲突也没有错误 ⇒ 可以直接导入");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decisions_are_keep_all_by_default() {
|
||||
// 缺省 = 全部保留:页面不发决策、或发默认值,结果都必须是"一个文件都不动"。
|
||||
let view = ConflictDecisionsView::default();
|
||||
assert_eq!(view, ConflictDecisionsView::KeepAll);
|
||||
assert!(!view.overwrites_everything());
|
||||
let core = super::super::convert::decisions_of(&view);
|
||||
assert_eq!(core.choice_for("config/a.toml"), ConflictChoice::KeepUser);
|
||||
assert_eq!(core.default_choice(), ConflictChoice::KeepUser);
|
||||
assert!(core.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_three_channels_map_one_to_one() {
|
||||
// 全部覆盖
|
||||
let all = super::super::convert::decisions_of(&ConflictDecisionsView::OverwriteAll);
|
||||
assert_eq!(all.choice_for("anything.toml"), ConflictChoice::Overwrite);
|
||||
assert_eq!(all.default_choice(), ConflictChoice::Overwrite);
|
||||
|
||||
// 逐项:点名的照点名走,没点名的落到"保留"。
|
||||
let view = ConflictDecisionsView::per_item(
|
||||
ConflictChoiceView::KeepUser,
|
||||
vec![
|
||||
ConflictDecisionView {
|
||||
path: "mods/pin.jar".to_string(),
|
||||
choice: ConflictChoiceView::Overwrite,
|
||||
},
|
||||
ConflictDecisionView {
|
||||
path: "config/keep.toml".to_string(),
|
||||
choice: ConflictChoiceView::KeepUser,
|
||||
},
|
||||
],
|
||||
);
|
||||
assert_eq!(view.overwrite_count(), 1);
|
||||
assert_eq!(view.keep_count(), 1);
|
||||
assert_eq!(view.len(), 2);
|
||||
assert!(!view.overwrites_everything(), "逐项不等于整批覆盖");
|
||||
|
||||
let core = super::super::convert::decisions_of(&view);
|
||||
assert_eq!(core.choice_for("mods/pin.jar"), ConflictChoice::Overwrite);
|
||||
assert_eq!(
|
||||
core.choice_for("config/keep.toml"),
|
||||
ConflictChoice::KeepUser
|
||||
);
|
||||
assert_eq!(
|
||||
core.choice_for("mods/unlisted.jar"),
|
||||
ConflictChoice::KeepUser,
|
||||
"没点名的路径不许被逐项表顺手覆盖"
|
||||
);
|
||||
assert_eq!(core.overwrite_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_scan_runs_without_writing_and_says_nothing_when_there_is_no_conflict() {
|
||||
// 真跑一次后台预检:全新目标(什么都没有)⇒ 没有冲突,且**目标目录没被建出来**。
|
||||
let dir = temp_dir("scan-fresh");
|
||||
let source = dir.join("Better MC");
|
||||
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; 64]);
|
||||
|
||||
let data = dir.join("data");
|
||||
std::fs::create_dir_all(&data).expect("data root");
|
||||
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 Only".to_string()),
|
||||
decisions: ConflictDecisionsView::default(),
|
||||
};
|
||||
super::super::import::spawn_scan(
|
||||
env,
|
||||
tx,
|
||||
request,
|
||||
9,
|
||||
std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
);
|
||||
|
||||
let mut scan = None;
|
||||
for task in rx.iter() {
|
||||
if let crate::host::job::HostTask::Pack { event, epoch } = task {
|
||||
assert_eq!(epoch, 9, "代次必须原样带回");
|
||||
if let crate::dto::PackEvent::Scan(view) = *event {
|
||||
scan = Some(*view);
|
||||
}
|
||||
}
|
||||
}
|
||||
let scan = scan.expect("必须回一条预检事件");
|
||||
assert!(scan.error.is_none(), "{:?}", scan.error);
|
||||
assert!(
|
||||
!scan.needs_decision(),
|
||||
"空目标上不该有冲突:{:?}",
|
||||
scan.conflicts
|
||||
);
|
||||
assert_eq!(scan.format, Some(crate::dto::PackFormatView::Multimc));
|
||||
assert!(
|
||||
!scan.target.exists(),
|
||||
"预检只读:目标目录不许被建出来({})",
|
||||
scan.target.display()
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
Reference in new issue
Block a user