diff --git a/crates/app-iced/src/app/update.rs b/crates/app-iced/src/app/update.rs index 434dcf6..110d8a6 100644 --- a/crates/app-iced/src/app/update.rs +++ b/crates/app-iced/src/app/update.rs @@ -47,6 +47,7 @@ mod appearance; mod boot_env; mod clock; mod content; +mod content_pack; mod download; mod instances; mod page_messages; diff --git a/crates/app-iced/src/app/update/clock.rs b/crates/app-iced/src/app/update/clock.rs index b36909e..da1c759 100644 --- a/crates/app-iced/src/app/update/clock.rs +++ b/crates/app-iced/src/app/update/clock.rs @@ -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 的惯例:右栏不该开局就是空的)。 diff --git a/crates/app-iced/src/app/update/content.rs b/crates/app-iced/src/app/update/content.rs index 230f229..00b5140 100644 --- a/crates/app-iced/src/app/update/content.rs +++ b/crates/app-iced/src/app/update/content.rs @@ -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 { - 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 { + pub(super) fn pack_export_request(&self) -> Option { let instance = self.content.pack.instance.clone()?; Some(pages::content::request_export( &self.content.pack, diff --git a/crates/app-iced/src/app/update/content_pack.rs b/crates/app-iced/src/app/update/content_pack.rs new file mode 100644 index 0000000..fc875fd --- /dev/null +++ b/crates/app-iced/src/app/update/content_pack.rs @@ -0,0 +1,240 @@ +//! 整合包导入/导出区的消息落点(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) => { + // 点一下就翻到另一侧,并把结果**原样记进表里**(保留也记)。 + // 表里为什么保留"保留"项:整批覆盖之后单独放回某几条时,「保留」必须能盖住 + // 整批档;等到发请求时 `PackPageState::decisions` 再把"与缺省档相同"的项过滤掉 + // (所以"一次都没点过"仍然等于 `KeepAll`)。 + 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 { + self.pack_import_request_with(ConflictDecisionsView::default()) + } + + /// 组一份带决策的导入请求(逐项 / 整批都从这里出去)。 + fn pack_import_request_with( + &self, + decisions: ConflictDecisionsView, + ) -> Option { + 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, + }) + } +} diff --git a/crates/app-iced/src/pages/content/packui.rs b/crates/app-iced/src/pages/content/packui.rs index c734909..8ea46e3 100644 --- a/crates/app-iced/src/pages/content/packui.rs +++ b/crates/app-iced/src/pages/content/packui.rs @@ -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; diff --git a/crates/app-iced/src/pages/content/packui/demo.rs b/crates/app-iced/src/pages/content/packui/demo.rs index 47ad0ea..4ec3874 100644 --- a/crates/app-iced/src/pages/content/packui/demo.rs +++ b/crates/app-iced/src/pages/content/packui/demo.rs @@ -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 = + (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 { diff --git a/crates/app-iced/src/pages/content/packui/message.rs b/crates/app-iced/src/pages/content/packui/message.rs index 62b8983..8b1a754 100644 --- a/crates/app-iced/src/pages/content/packui/message.rs +++ b/crates/app-iced/src/pages/content/packui/message.rs @@ -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), /// 换新实例名(`None` 语义由 `SetInstance` 承担,这里只改名字) diff --git a/crates/app-iced/src/pages/content/packui/model.rs b/crates/app-iced/src/pages/content/packui/model.rs index 46bfa9d..d35830a 100644 --- a/crates/app-iced/src/pages/content/packui/model.rs +++ b/crates/app-iced/src/pages/content/packui/model.rs @@ -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, + /// 冲突清单翻到第几页(0 起;M160) + pub pick_page: usize, + /// 逐项决策(**只存"覆盖"的路径**:保留 = 表里没有这条,于是"没点过"永远是保留) + pub pick_choices: BTreeMap, + /// 「全部覆盖」这一档是否被点过(整批档;逐项决策仍然优先于它) + 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 = 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, + /// **导入前的冲突清单**(M160:`Some` 就画那一屏,别的东西都让位) + pub pick: Option, /// 导出块 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() diff --git a/crates/app-iced/src/pages/content/packui/model/pick.rs b/crates/app-iced/src/pages/content/packui/model/pick.rs new file mode 100644 index 0000000..1877502 --- /dev/null +++ b/crates/app-iced/src/pages/content/packui/model/pick.rs @@ -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, + /// **当前页**的行(最多 [`PAGE_SIZE`] 条) + pub rows: Vec, + /// 当前页(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, + batch_overwrite: bool, + catalog: Catalog, +) -> Option { + 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 = 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, + 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, + 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), + } +} diff --git a/crates/app-iced/src/pages/content/packui/model/tests.rs b/crates/app-iced/src/pages/content/packui/model/tests.rs index 991fcbd..9b78113 100644 --- a/crates/app-iced/src/pages/content/packui/model/tests.rs +++ b/crates/app-iced/src/pages/content/packui/model/tests.rs @@ -3,6 +3,8 @@ //! 这里**不测画法**(画法靠出图),测的是「决策」:哪些数字进了哪一行、哪些字段被截短、 //! 哪一态该出现哪一块。所有断言都能在没有图形环境的情况下跑。 +mod pick; + use std::path::PathBuf; use unlucky_i18n::Lang; diff --git a/crates/app-iced/src/pages/content/packui/model/tests/pick.rs b/crates/app-iced/src/pages/content/packui/model/tests/pick.rs new file mode 100644 index 0000000..6fbf08d --- /dev/null +++ b/crates/app-iced/src/pages/content/packui/model/tests/pick.rs @@ -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, + 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 + ); +} diff --git a/crates/app-iced/src/pages/content/packui/tests.rs b/crates/app-iced/src/pages/content/packui/tests.rs index f0d0d5a..41fd2a4 100644 --- a/crates/app-iced/src/pages/content/packui/tests.rs +++ b/crates/app-iced/src/pages/content/packui/tests.rs @@ -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); diff --git a/crates/app-iced/src/pages/content/packui/view.rs b/crates/app-iced/src/pages/content/packui/view.rs index 7e41d0a..f282664 100644 --- a/crates/app-iced/src/pages/content/packui/view.rs +++ b/crates/app-iced/src/pages/content/packui/view.rs @@ -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 { diff --git a/crates/app-iced/src/pages/content/packui/view/pick.rs b/crates/app-iced/src/pages/content/packui/view/pick.rs new file mode 100644 index 0000000..17d86cf --- /dev/null +++ b/crates/app-iced/src/pages/content/packui/view/pick.rs @@ -0,0 +1,311 @@ +//! 冲突清单那一屏的画法(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, Padding}; + +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; + +/// 处置按钮槽的宽度(px):取最宽状态「⟳ Overwrite」的实际占用再留余量, +/// 保证「Keep」与「Overwrite」两种状态的右边缘落在同一条线上。 +const TOGGLE_SLOT: f32 = 132.0; + +/// 整块。 +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::>(), + ) + .spacing(ui.space(Space::X1)) + .width(Length::Fill), + 375.0, + )); + + // 列表与动作行之间留出与其它区块一致的呼吸(复审 r1 视觉 2):滚到底时最后一行会紧贴 + // 动作行,靠这层 padding 把两者分开(直接调列 spacing 会把行间距也一起拉开)。 + block = block.push( + container( + row![ + buttons::filled( + ui, + Button::new(pick.confirm.as_str(), intent(PackMessage::ConfirmImport)) + .leading(IconKind::Check), + ), + buttons::text( + ui, + Button::new(pick.cancel, intent(PackMessage::CancelImport)), + ), + ] + .spacing(ui.space(Space::X2)) + .align_y(Alignment::Center), + ) + .padding(Padding { + top: ui.space(Space::X2), + ..Padding::ZERO + }), + ); + + container(block) + .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))), + ) + }; + // 分页行与行内控件共用同一条右锚点(复审 r1 视觉 3):区间文案靠左,两个按钮用 + // `horizontal_space` 顶到右边,于是「Previous · Next」的右边缘与下面每一行的处置槽对齐。 + // 两个按钮进一个**右对齐的槽**,槽的右边缘就是行的右边缘 —— 于是它们的右端与下面每行的 + // 处置槽落在同一条线上(按钮自身的内边距也一起对齐,不会差那十几像素)。 + let buttons_slot = container( + row![previous, next] + .spacing(ui.space(Space::X2)) + .align_y(Alignment::Center), + ) + .width(Length::Fixed(TOGGLE_SLOT * 2.0)) + .align_x(Alignment::End); + row![ + frame::text_role( + ui, + TypeRole::LabelMedium, + false, + pick.range.as_str(), + ui.color(Role::OnSurfaceVariant), + ), + iced::widget::Space::new().width(Length::Fill), + buttons_slot, + ] + .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> { + // 两种取值都是**同一种几何**(等高的 pill),只是填充不同: + // · 当前=覆盖 ⇒ **tonal 填充**(这一行会被盖掉,是"待反悔"的那一档) + // · 当前=保留 ⇒ **描边**(缺省档;点了才会覆盖) + // 两种 pill 再各自右对齐进同宽度的槽,于是**右边缘落在同一条线**(复审 r1 视觉 1 的必修项)—— + // 之前一边是描边 pill、一边是无容器的文字按钮,宽窄与右端都不一致。 + let toggle = if item.overwrites() { + buttons::tonal( + ui, + Button::new( + item.toggle, + intent(PackMessage::TogglePick(item.path.clone())), + ) + .leading(IconKind::Refresh), + ) + } else { + buttons::outlined( + ui, + Button::new( + item.toggle, + intent(PackMessage::TogglePick(item.path.clone())), + ) + .leading(IconKind::Refresh), + ) + }; + // 路径那半列必须自己吃掉剩余宽度(`Length::Fill`),否则处置槽会紧跟文字、离右边缘一大截, + // 与分页行的右锚点对不上(出图实测:行内按钮停在 x≈680、分页按钮在 x≈780)。 + let path_slot = container(frame::text_role( + ui, + TypeRole::BodySmall, + false, + item.path.as_str(), + ui.color(Role::OnSurface), + )) + .width(Length::Fill); + // 处置按钮自己那一格:定宽 + 右对齐 ⇒ 两种取值的 pill 右边缘落在同一条线上。 + // 右侧再留 8dp:滚动条就贴在内容右边缘(出图实测 x≈791),pill 紧贴着它显得挤。 + let toggle_slot = container(toggle) + .width(Length::Fixed(TOGGLE_SLOT)) + .align_x(Alignment::End) + .padding(Padding { + right: ui.space(Space::X2), + ..Padding::ZERO + }); + let head = row![path_slot, toggle_slot] + .spacing(ui.space(Space::X2)) + .align_y(Alignment::Center) + .width(Length::Fill); + + let detail = frame::text_role_owned( + ui, + 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>, + height: f32, +) -> Element<'a, PageMessage> { + iced::widget::scrollable(container(content).width(Length::Fill)) + .height(Length::Fixed(height)) + .into() +} diff --git a/crates/core/src/addon/import.rs b/crates/core/src/addon/import.rs index c2d27e4..02a947d 100644 --- a/crates/core/src/addon/import.rs +++ b/crates/core/src/addon/import.rs @@ -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, choice: ConflictChoice) -> Self { + self.decisions.set(path, choice); self } diff --git a/crates/core/src/addon/import/decision.rs b/crates/core/src/addon/import/decision.rs new file mode 100644 index 0000000..3dee659 --- /dev/null +++ b/crates/core/src/addon/import/decision.rs @@ -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, +} + +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, choice: ConflictChoice) -> Self { + self.set(path, choice); + self + } + + /// 挂一条逐项决策(就地)。 + pub fn set(&mut self, path: impl Into, 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 { + 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 { + 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"]); + } +} diff --git a/crates/core/src/addon/import/merge.rs b/crates/core/src/addon/import/merge.rs index 95c6775..27ec720 100644 --- a/crates/core/src/addon/import/merge.rs +++ b/crates/core/src/addon/import/merge.rs @@ -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` 不参与判据 //! @@ -76,7 +76,11 @@ pub fn decide(pack_sha1: &str, base_sha1: Option<&str>, disk_sha1: Option<&str>) // 磁盘上没有这个文件。 (None, None) => MergeDecision::plain(MergeAction::Install), (None, Some(_)) => MergeDecision::conflict(ConflictReason::UserDeleted), - // 磁盘上有。 + // 磁盘上有、内容与包内**逐字节相同**(同一份 SHA-1)。 + // + // 这是"内容相同"的**唯一判据**:预检(`super::scan::judge`)刻意不再自己写一遍 + // `disk == pack` 的早退(复审 r1 P3-2),而是走这里的 `reason == None`。改这一行 + // 会同时影响"导入怎么处置"与"预检列不列这条",所以两边的测试都会红。 (Some(disk), _) if disk == pack_sha1 => MergeDecision::plain(MergeAction::SkipIdentical), (Some(_), None) => MergeDecision::conflict(ConflictReason::PreExisting), (Some(disk), Some(base)) if disk == base => MergeDecision::plain(MergeAction::Install), diff --git a/crates/core/src/addon/import/phase.rs b/crates/core/src/addon/import/phase.rs index cb97ac2..d5caa0c 100644 --- a/crates/core/src/addon/import/phase.rs +++ b/crates/core/src/addon/import/phase.rs @@ -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 { diff --git a/crates/core/src/addon/import/pipeline.rs b/crates/core/src/addon/import/pipeline.rs index 22c2d04..c4304ae 100644 --- a/crates/core/src/addon/import/pipeline.rs +++ b/crates/core/src/addon/import/pipeline.rs @@ -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) { 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]) } } } diff --git a/crates/core/src/addon/import/report/items.rs b/crates/core/src/addon/import/report/items.rs index 9434a93..80b5dff 100644 --- a/crates/core/src/addon/import/report/items.rs +++ b/crates/core/src/addon/import/report/items.rs @@ -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 { diff --git a/crates/core/src/addon/import/report/keys.rs b/crates/core/src/addon/import/report/keys.rs index 907b55d..e92f160 100644 --- a/crates/core/src/addon/import/report/keys.rs +++ b/crates/core/src/addon/import/report/keys.rs @@ -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"; +/// 注记:这些远端条目预检判不了(补全前没有路径 / 有路径但缺 sha1 声明),无法预判冲突。 +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, diff --git a/crates/core/src/addon/import/scan.rs b/crates/core/src/addon/import/scan.rs new file mode 100644 index 0000000..87eb0be --- /dev/null +++ b/crates/core/src/addon/import/scan.rs @@ -0,0 +1,306 @@ +//! 导入**预检**(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 条补全后才能知道会不会撞车")。计数与 +//! 文案同一口径:**判不了**的两种来路(补全前没有路径 / 有路径但缺 `hashes.sha1`)都算。 +//! +//! # 便宜在哪、贵在哪 +//! +//! 预检不读包内字节(摘要来自 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, +} + +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, + /// 包名。 + pub pack_name: Option, + /// 包版本。 + pub pack_version: Option, + /// 游戏版本。 + pub game_version: Option, + /// 加载器计划输入(`None` = 纯原版)。 + pub loader: Option, + /// 冲突清单(按路径排序)。 + pub conflicts: Vec, + /// 预检阶段**判不了**的远端条目数。 + /// + /// 判不了有两种来路,计数把两者都算上(口径与注记文案一致,复审 r1 P3-4): + /// ①CurseForge `files[]` 补全前**没有路径**(常态);②极少数 mrpack 条目**有路径但缺 + /// `hashes.sha1`**,没有声明摘要就没法与磁盘现状比对。 + pub unknown_remote: usize, + /// 注记(`.disabled` / 可选条目 / 基线口径 / 被遮蔽的条目……与导入报告同源)。 + pub notes: Vec, + /// 开包或组计划失败(`Failed` 口径,目标目录一个字节都没动)。 + pub error: Option, +} + +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 = 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 待补全)或没有声明摘要(缺 `hashes.sha1`)。 + 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[] 补全前没有路径,个别条目有路径但没有 sha1 声明;这些条目会不会撞上已有文件只能等补全/下载后才知道,导入时仍按「缺省保留」处置", + scan.unknown_remote + ), + )); + } + scan.conflicts + .sort_by(|left, right| left.path.cmp(&right.path)); + scan +} + +/// 判一个路径:`Some` = 要用户点头的冲突。 +/// +/// # 「内容相同」的判据**只此一处**:[`merge::decide`] +/// +/// 这里**故意不再写** `if disk.sha1 == pack_sha1 { return None }`(复审 r1 P3-2 抓到的重复判据): +/// 那种早退与 `merge::decide` 的 `SkipIdentical` 分支是同一件事的两次实现,改一处漏一处的后果 +/// 就是"预检说没冲突、导入却判 SkipIdentical"或者反过来。 +/// +/// 现在只有一条链路:`decide` 给出 `MergeDecision`,**没有 `reason` 就不是冲突** +/// (`SkipIdentical` 与 `Install` 都是 `reason == None`),`?` 把它一步交代掉。 +/// 于是"内容相同 ⇒ 不进清单"这条结论的源头是 `merge.rs` 的那张表——与导入用的是同一张。 +fn judge( + path: &str, + source: FileSource, + pack_sha1: &str, + pack_size: u64, + disabled: bool, + baseline: &PackBaseline, + target: &Path, +) -> Option { + let disk = disk_state(&target.join(path))?; + // 包内那条的摘要来自 M110 读包(`override_files` 边读边算),与导入时现算的是同一份字节。 + let base_sha1 = baseline.sha1(path).map(str::to_string); + // 唯一判据:`reason` 为 `None` 就是"不是冲突"(内容相同 → `SkipIdentical`; + // 磁盘上没有 → `Install`,而那一条 `disk_state` 已经先返回 `None` 了)。 + let decision = merge::decide(pack_sha1, base_sha1.as_deref(), Some(&disk.sha1)); + let reason = decision.reason?; + Some(ImportConflict { + 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 { + 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, + }) +} diff --git a/crates/core/src/addon/mod.rs b/crates/core/src/addon/mod.rs index 8cd9ca0..0030341 100644 --- a/crates/core/src/addon/mod.rs +++ b/crates/core/src/addon/mod.rs @@ -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; diff --git a/crates/core/src/addon/pack.rs b/crates/core/src/addon/pack.rs index a308012..8cbc2aa 100644 --- a/crates/core/src/addon/pack.rs +++ b/crates/core/src/addon/pack.rs @@ -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}; diff --git a/crates/core/src/addon/pack/archive.rs b/crates/core/src/addon/pack/archive.rs index 3a231a3..6d4b518 100644 --- a/crates/core/src/addon/pack/archive.rs +++ b/crates/core/src/addon/pack/archive.rs @@ -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; diff --git a/crates/core/src/addon/pack/archive/limits.rs b/crates/core/src/addon/pack/archive/limits.rs index 03dbc11..acbbec5 100644 --- a/crates/core/src/addon/pack/archive/limits.rs +++ b/crates/core/src/addon/pack/archive/limits.rs @@ -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)] diff --git a/crates/core/src/addon/pack/format.rs b/crates/core/src/addon/pack/format.rs index a4a1e47..a67bcda 100644 --- a/crates/core/src/addon/pack/format.rs +++ b/crates/core/src/addon/pack/format.rs @@ -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); diff --git a/crates/core/src/addon/pack/format/write.rs b/crates/core/src/addon/pack/format/write.rs index a19d5dc..5f637cf 100644 --- a/crates/core/src/addon/pack/format/write.rs +++ b/crates/core/src/addon/pack/format/write.rs @@ -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,27 @@ 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 { + // **条目数闸**(读侧同一条:`PACK_MAX_ENTRIES`)。放在读盘之前:超 2 万条时连一次 + // `metadata()` 都不必做,也顺手证明"闸在碰内容之前"。清单条目自己也占一条。 + let declared_entries = 1 + manifest + .overrides + .iter() + .map(|group| group.files.len()) + .fold(0_usize, usize::saturating_add); + guard_entries(declared_entries, limits.entries)?; + let mut total = json_len; - 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 +155,78 @@ 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, + /// 条目数上限(**与读侧同一个数**:清单条目 + 每个覆盖文件)。 + pub(super) entries: usize, +} + +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, + entries: super::super::archive::limits::PACK_MAX_ENTRIES, + } + } + + /// 显式给值(测试用:把边界缩小到可测尺寸)。 + #[cfg(test)] + pub(super) const fn new(total: u64, entry: u64, entries: usize) -> Self { + Self { + total, + entry, + entries, + } + } +} + +/// 条目数闸:与读侧的 `PACK_MAX_ENTRIES` **同一份口径**。 +/// +/// 读侧在 `archive::open` 里对"中央目录条数"判 `>= limit` 就拒;写侧产出的是"清单 + N 个覆盖 +/// 文件",比读侧少掉目录条目,所以这里按同一侧的口径判(`count > limit` 拒,`== limit` 许可, +/// 与读侧能读回的最大值一致)。没有这道闸时,一个内容目录超过 2 万条的实例**导得出来、 +/// 自己的导入器拒读**——正是"M152 遗留的限额不衔接"那条。 +fn guard_entries(count: usize, entries_limit: usize) -> PackResult<()> { + if count > entries_limit { + return Err(PackError::limits(format!( + "包内条目数 {count} 超过统一上限 {entries_limit}(导入读取用的也是这个数)" + )) + .with_hint("把内容目录里多余的文件移出实例后再导出;单文件与总量上限也是同一份口径")); + } + Ok(()) +} + +/// 单条目闸:与读侧的 `PACK_MAX_ENTRY_BYTES` **同一份口径**。 +/// +/// 没有这道闸时,"导出 100 MiB 的覆盖文件 → 自己的导入器拒读"是能真实发生的(读侧把一条 +/// 内容整块读进内存,所以有单条上限)。写侧照着读侧的上限拒,才不会产出自己读不回来的包。 +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(()) +} + /// 单条覆盖文件的来源:来源表优先(跨格式导出用),否则按 `//` 拼。 fn source_for(payload: PackPayload<'_>, dir: &str, path: &str) -> PackResult { let name = format!("{dir}/{path}"); @@ -183,164 +266,20 @@ 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(()) } #[cfg(test)] -mod tests { - use super::*; - use crate::addon::pack::manifest::{OverrideFile, PackOverrides, PackSide}; - - #[test] - fn the_threshold_is_reached_not_exceeded() { - // `>=` 而不是 `>`:恰好等于阈值就已经需要 ZIP64 了。 - assert!(guard_with(99, 100).is_ok()); - let error = guard_with(100, 100).expect_err("等于阈值必须拒"); - assert_eq!( - error.kind(), - super::super::super::error::PackErrorKind::Limits - ); - assert!(error.message().contains("100"), "{error}"); - assert!(guard_with(101, 100).is_err()); - } - - #[test] - fn the_second_gate_counts_from_the_manifest_length_not_the_preflight_total() { - // P2-1 的窗口:真实总量在 [阈值一半, 阈值) 之间的包**不许**被拒。这一条不真写 - // 2.5 GB(那要 ~70 秒 deflate 与一大片内存/页面文件),而是直接把窗口里的字节数 - // 喂给第二道闸的计数器——它的起点是清单长度,所以只有"把预检的全量 total 当基数" - // 那种写法才会在这里红(红法是 `spend` 返回 Limits)。 - let half = zip::ZIP64_BYTES_THR / 2; - let mut written = Written::new(28, zip::ZIP64_BYTES_THR); - written - .spend(2_500_000_000) - .expect("2.5 GB 没到 4 GiB,第二道闸不许拒"); - assert!(2_500_000_000 > half, "样本要落在过拒窗口里"); - - // 对照:撞上阈值必须拒,且正好在阈值处(不是超一点点才拒)。 - let mut written = Written::new(28, zip::ZIP64_BYTES_THR); - let error = written - .spend(zip::ZIP64_BYTES_THR) - .expect_err("撞阈值必须拒"); - assert_eq!( - error.kind(), - super::super::super::error::PackErrorKind::Limits - ); - } - - #[test] - fn the_container_does_not_double_count_the_preflight_total() { - // 容器层的窗口回归钉(P2-1),**用几十字节的 limit 把窗口缩小到可测尺寸**: - // `json` 30 字节 + 覆盖文件 40 字节 = 声明总量 70。 - // - 正确实现:第二道闸从 30 起算,写到 70 也不到 100 ⇒ 导出成功; - // - P2-1 的写法(把预检返回的全量 70 当基数):写循环再累加 40 ⇒ 110 ≥ 100 ⇒ 误拒。 - // 真造 2.5 GB 才能暴露同一个 bug,本机资源纪律下不跑(那条留成 `zip64.rs` 里的 - // `#[ignore]` 用例)。 - let root = std::env::temp_dir().join(format!("ull-double-count-{}", std::process::id())); - std::fs::create_dir_all(root.join("overrides/mods")).expect("建目录"); - std::fs::write(root.join("overrides/mods/a.bin"), [0_u8; 40]).expect("写覆盖文件"); - // 清单很短,limit 取"声明总量 + 20 字节"的余量:正确实现只写到 70,重复累计会写到 110。 - let json = "{\"name\":\"0123456789012345\"}"; - let declared = json.len() as u64 + 40; - let limit = declared + 20; - let mut manifest = PackManifest::new("double count"); - manifest.overrides.push(PackOverrides { - dir: "overrides".to_string(), - side: PackSide::Both, - files: vec![OverrideFile { - path: "mods/a.bin".to_string(), - sha1: String::new(), - size: 40, - }], - }); - let target = root.join("out.zip"); - - container( - "curseforge", - "manifest.json", - json, - &manifest, - PackPayload::from_root(&root), - &target, - limit, - ) - .expect("声明总量没到 limit:写循环不许把预检的 total 重复累计"); - - // 对照:limit 降到声明总量本身 ⇒ 预检就该拒,且临时文件都没建。 - let refused = root.join("refused.zip"); - let error = container( - "curseforge", - "manifest.json", - json, - &manifest, - PackPayload::from_root(&root), - &refused, - declared, - ) - .expect_err("声明总量达到 limit 必须拒"); - assert_eq!( - error.kind(), - super::super::super::error::PackErrorKind::Limits - ); - assert!(!refused.exists(), "预检失败时目标路径不许出现"); - let _ = std::fs::remove_dir_all(&root); - } - - #[test] - fn the_preflight_measures_declared_sizes_not_content() { - // 预检必须只量 `metadata().len()`:这一条用"小文件 + 3 GiB 稀疏文件"钉住, - // 真读内容的话要么慢要么直接吃 3 GiB 内存。 - let root = std::env::temp_dir().join(format!("ull-preflight-{}", std::process::id())); - std::fs::create_dir_all(root.join("overrides/mods")).expect("建目录"); - std::fs::write(root.join("overrides/mods/small.jar"), b"0123456789").expect("写小文件"); - let huge = root.join("overrides/mods/huge.jar"); - let file = std::fs::File::create(&huge).expect("建稀疏文件"); - file.set_len(3 * 1024 * 1024 * 1024).expect("拉长(稀疏)"); - drop(file); - - let mut manifest = PackManifest::new("preflight"); - manifest.overrides.push(PackOverrides { - dir: "overrides".to_string(), - side: PackSide::Both, - files: vec![ - OverrideFile { - path: "mods/small.jar".to_string(), - sha1: String::new(), - size: 10, - }, - OverrideFile { - path: "mods/huge.jar".to_string(), - sha1: String::new(), - size: 0, - }, - ], - }); - - let total = preflight( - 7, - &manifest, - PackPayload::from_root(&root), - zip::ZIP64_BYTES_THR, - ) - .expect("3 GiB 总量仍在阈值之下,预检不许误伤"); - assert_eq!( - total, - 7 + 10 + 3 * 1024 * 1024 * 1024, - "清单 + 每个覆盖文件的声明尺寸" - ); - let _ = std::fs::remove_dir_all(&root); - } -} +mod tests; diff --git a/crates/core/src/addon/pack/format/write/tests.rs b/crates/core/src/addon/pack/format/write/tests.rs new file mode 100644 index 0000000..e705e1f --- /dev/null +++ b/crates/core/src/addon/pack/format/write/tests.rs @@ -0,0 +1,150 @@ +//! `format/write.rs` 的单测:**从 `write.rs` 拆出来守 400 行硬线**。 +//! +//! 拆分口径与仓库其它拆分一致:**纯搬移**,断言与注释一行不改(只在外面加一层 `mod tests;`)。 +//! 这些用例钉的是「闸在碰内容之前」「边界在 `>=` 上」「写循环不许重复累计」三件事。 + +use super::*; +use crate::addon::pack::manifest::{OverrideFile, PackOverrides, PackSide}; +use crate::addon::temp::TempRoot; + +#[test] +fn the_threshold_is_reached_not_exceeded() { + // `>=` 而不是 `>`:恰好等于阈值就已经需要 ZIP64 了。 + assert!(guard_with(99, 100).is_ok()); + let error = guard_with(100, 100).expect_err("等于阈值必须拒"); + assert_eq!( + error.kind(), + super::super::super::error::PackErrorKind::Limits + ); + assert!(error.message().contains("100"), "{error}"); + assert!(guard_with(101, 100).is_err()); +} + +#[test] +fn the_second_gate_counts_from_the_manifest_length_not_the_preflight_total() { + // P2-1 的窗口:真实总量在 [上限一半, 上限) 之间的包**不许**被拒。这一条不真写 + // 几百 MiB(那要几十秒 deflate 与一大片内存/页面文件),而是直接把窗口里的字节数 + // 喂给第二道闸的计数器——它的起点是清单长度,所以只有"把预检的全量 total 当基数" + // 那种写法才会在这里红(红法是 `spend` 返回 Limits)。 + let limit = WriteLimits::production().total; + let inside = limit / 2 + limit / 4; + let mut written = Written::new(28, limit); + written.spend(inside).expect("没到统一上限,第二道闸不许拒"); + assert!(inside > limit / 2, "样本要落在过拒窗口里"); + + // 对照:撞上上限必须拒,且正好在上限处(不是超一点点才拒)。 + let mut written = Written::new(28, limit); + let error = written.spend(limit).expect_err("撞上限必须拒"); + assert_eq!( + error.kind(), + super::super::super::error::PackErrorKind::Limits + ); +} + +#[test] +fn the_container_does_not_double_count_the_preflight_total() { + // 容器层的窗口回归钉(P2-1),**用几十字节的 limit 把窗口缩小到可测尺寸**: + // `json` 30 字节 + 覆盖文件 40 字节 = 声明总量 70。 + // - 正确实现:第二道闸从 30 起算,写到 70 也不到 100 ⇒ 导出成功; + // - P2-1 的写法(把预检返回的全量 70 当基数):写循环再累加 40 ⇒ 110 ≥ 100 ⇒ 误拒。 + // 真造 2.5 GB 才能暴露同一个 bug,本机资源纪律下不跑(那条留成 `zip64.rs` 里的 + // `#[ignore]` 用例)。 + // 临时根用 `TempRoot`(drop 即删、panic 也删):这两个用例以前自己拼 `%TEMP%` 路径、 + // 只在**正常收尾**时删,断言一红就留下 `ull-double-count-*`(M160 r2 的泄漏口径)。 + let root = TempRoot::new("double-count"); + std::fs::create_dir_all(root.join("overrides/mods")).expect("建目录"); + std::fs::write(root.join("overrides/mods/a.bin"), [0_u8; 40]).expect("写覆盖文件"); + // 清单很短,limit 取"声明总量 + 20 字节"的余量:正确实现只写到 70,重复累计会写到 110。 + let json = "{\"name\":\"0123456789012345\"}"; + let declared = json.len() as u64 + 40; + let limit = declared + 20; + let mut manifest = PackManifest::new("double count"); + manifest.overrides.push(PackOverrides { + dir: "overrides".to_string(), + side: PackSide::Both, + files: vec![OverrideFile { + path: "mods/a.bin".to_string(), + sha1: String::new(), + size: 40, + }], + }); + let target = root.join("out.zip"); + + container( + "curseforge", + "manifest.json", + json, + &manifest, + PackPayload::from_root(&root), + &target, + WriteLimits::new(limit, 1024, 16), + ) + .expect("声明总量没到 limit:写循环不许把预检的 total 重复累计"); + + // 对照:limit 降到声明总量本身 ⇒ 预检就该拒,且临时文件都没建。 + let refused = root.join("refused.zip"); + let error = container( + "curseforge", + "manifest.json", + json, + &manifest, + PackPayload::from_root(&root), + &refused, + WriteLimits::new(declared, 1024, 16), + ) + .expect_err("声明总量达到 limit 必须拒"); + assert_eq!( + error.kind(), + super::super::super::error::PackErrorKind::Limits + ); + assert!(!refused.exists(), "预检失败时目标路径不许出现"); +} + +#[test] +fn the_preflight_measures_declared_sizes_not_content() { + // 预检必须只量 `metadata().len()`:这一条用"小文件 + 3 GiB 稀疏文件"钉住, + // 真读内容的话要么慢要么直接吃 3 GiB 内存。 + let root = TempRoot::new("preflight"); + std::fs::create_dir_all(root.join("overrides/mods")).expect("建目录"); + std::fs::write(root.join("overrides/mods/small.jar"), b"0123456789").expect("写小文件"); + let huge = root.join("overrides/mods/huge.jar"); + let file = std::fs::File::create(&huge).expect("建稀疏文件"); + file.set_len(3 * 1024 * 1024 * 1024).expect("拉长(稀疏)"); + drop(file); + + let mut manifest = PackManifest::new("preflight"); + manifest.overrides.push(PackOverrides { + dir: "overrides".to_string(), + side: PackSide::Both, + files: vec![ + OverrideFile { + path: "mods/small.jar".to_string(), + sha1: String::new(), + size: 10, + }, + OverrideFile { + path: "mods/huge.jar".to_string(), + sha1: String::new(), + size: 0, + }, + ], + }); + + let total = preflight( + 7, + &manifest, + PackPayload::from_root(&root), + // 本条只关心"预检不读内容",所以三道闸都放到最宽(生产口径见 `tests/limits.rs`)。 + WriteLimits::new( + crate::addon::pack::archive::limits::PACK_ZIP64_BYTES_THR, + crate::addon::pack::archive::limits::PACK_ZIP64_BYTES_THR, + crate::addon::pack::archive::limits::PACK_MAX_ENTRIES, + ), + ) + .expect("3 GiB 总量仍在最宽闸之下,预检不许误伤"); + assert_eq!( + total, + 7 + 10 + 3 * 1024 * 1024 * 1024, + "清单 + 每个覆盖文件的声明尺寸" + ); +} diff --git a/crates/core/src/addon/pack/tests/detect.rs b/crates/core/src/addon/pack/tests/detect.rs index 2692169..11be5eb 100644 --- a/crates/core/src/addon/pack/tests/detect.rs +++ b/crates/core/src/addon/pack/tests/detect.rs @@ -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, &[( diff --git a/crates/core/src/addon/pack/tests/export.rs b/crates/core/src/addon/pack/tests/export.rs index 14ab2aa..3b94e89 100644 --- a/crates/core/src/addon/pack/tests/export.rs +++ b/crates/core/src/addon/pack/tests/export.rs @@ -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("同一侧两个覆盖目录必须拒"); diff --git a/crates/core/src/addon/pack/tests/export_harness.rs b/crates/core/src/addon/pack/tests/export_harness.rs index a6d4bcb..be3ccc0 100644 --- a/crates/core/src/addon/pack/tests/export_harness.rs +++ b/crates/core/src/addon/pack/tests/export_harness.rs @@ -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` 到 `/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"); diff --git a/crates/core/src/addon/pack/tests/export_pipeline.rs b/crates/core/src/addon/pack/tests/export_pipeline.rs index 6e052c4..6d3966c 100644 --- a/crates/core/src/addon/pack/tests/export_pipeline.rs +++ b/crates/core/src/addon/pack/tests/export_pipeline.rs @@ -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() { diff --git a/crates/core/src/addon/pack/tests/export_roundtrip.rs b/crates/core/src/addon/pack/tests/export_roundtrip.rs index fb28874..f02a1c5 100644 --- a/crates/core/src/addon/pack/tests/export_roundtrip.rs +++ b/crates/core/src/addon/pack/tests/export_roundtrip.rs @@ -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; /// 把夹具里的覆盖内容铺成一个实例目录(`/`),并写一份版本 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 { diff --git a/crates/core/src/addon/pack/tests/extend.rs b/crates/core/src/addon/pack/tests/extend.rs index f7554ab..78dcf9d 100644 --- a/crates/core/src/addon/pack/tests/extend.rs +++ b/crates/core/src/addon/pack/tests/extend.rs @@ -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, diff --git a/crates/core/src/addon/pack/tests/fixtures.rs b/crates/core/src/addon/pack/tests/fixtures.rs index 565f4df..a4fa451 100644 --- a/crates/core/src/addon/pack/tests/fixtures.rs +++ b/crates/core/src/addon/pack/tests/fixtures.rs @@ -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` 指向 `/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( diff --git a/crates/core/src/addon/pack/tests/limits.rs b/crates/core/src/addon/pack/tests/limits.rs new file mode 100644 index 0000000..99cbca6 --- /dev/null +++ b/crates/core/src/addon/pack/tests/limits.rs @@ -0,0 +1,256 @@ +//! 限额口径的统一(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) -> 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_write_side_refuses_a_pack_over_the_unified_entry_count() { + // 写侧以前只有总量与单条两道闸:内容目录超过 2 万条的实例**导得出来、自己拒读** + // (读侧 `archive::open` 的「条目数超过上限」)。这条钉住第三道闸,以及它挡在**读盘之前**。 + let root = TempRoot::new("limits-entries"); + write_file(&root.join("overrides/mods/a.jar"), b"x"); + let target = root.join("out.zip"); + + // ① 对照:条目数远低于上限时不许误伤。 + CURSEFORGE + .write( + &manifest_of(vec![override_file("mods/a.jar")]), + PackPayload::from_root(&root), + &target, + ) + .expect("单个覆盖条目必须写得出来"); + let _ = std::fs::remove_file(&target); + + // ② 声明 20001 条(上限 20000 + 1):只有**第一条**的文件真的存在,其余 2 万条谁都不在盘上。 + // 报出来的必须是 `pack_limits` 而不是 `pack_io` —— 那就是"条目数闸在读第一个文件之前"的证据 + // (真去 `metadata()` 的话,第二个文件就已经报 `pack_io` 了)。 + let mut files = vec![override_file("mods/a.jar")]; + for index in 1..=PACK_MAX_ENTRIES { + files.push(override_file(&format!("mods/{index:05}.jar"))); + } + assert_eq!(files.len(), PACK_MAX_ENTRIES + 1, "上限 + 1 条"); + let error = CURSEFORGE + .write(&manifest_of(files), PackPayload::from_root(&root), &target) + .expect_err("超过条目数上限必须拒"); + assert_eq!(error.kind(), PackErrorKind::Limits, "{error}"); + assert!( + error.message().contains(&PACK_MAX_ENTRIES.to_string()), + "文案要给出统一上限那个数(读侧用的是同一个):{error}" + ); + assert!( + error.hint().is_some_and(|hint| hint.contains("移出实例")), + "要给可执行的下一步:{error:?}" + ); + assert!(!target.exists(), "预检失败时目标路径不许出现"); +} + +#[test] +fn the_entry_count_gate_is_the_same_number_as_the_read_side() { + // 「三个常量同源」的写侧那一半:生产口径的条目数上限必须**就是**读侧那个常量。 + // 改坏它会先在 `pick` 这层红,而不是等某个用户导出 2 万条内容的实例才发现。 + let production = crate::addon::pack::archive::limits::PACK_MAX_ENTRIES; + let read_side = PackLimits::default().max_entries; + assert_eq!(production, read_side, "写侧与读侧必须是同一个数"); + assert_eq!(read_side, 20_000, "上限本身也钉住(改数值要显式改这里)"); +} + +#[test] +fn the_three_limits_form_one_chain_and_the_read_side_uses_them() { + // ① 关系:单条 ≤ 总量 < ZIP64 ≤ u32。前一半保证"能写出来的包读得回来", + // 后一半保证我们永远不需要写 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 = 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); +} diff --git a/crates/core/src/addon/pack/tests/mod.rs b/crates/core/src/addon/pack/tests/mod.rs index f4379d9..d85d1ba 100644 --- a/crates/core/src/addon/pack/tests/mod.rs +++ b/crates/core/src/addon/pack/tests/mod.rs @@ -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()), + "父目录也不许留空壳" + ); +} diff --git a/crates/core/src/addon/pack/tests/roundtrip.rs b/crates/core/src/addon/pack/tests/roundtrip.rs index 6e891ce..fb00102 100644 --- a/crates/core/src/addon/pack/tests/roundtrip.rs +++ b/crates/core/src/addon/pack/tests/roundtrip.rs @@ -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, diff --git a/crates/core/src/addon/pack/tests/safety.rs b/crates/core/src/addon/pack/tests/safety.rs index dad4f55..157c915 100644 --- a/crates/core/src/addon/pack/tests/safety.rs +++ b/crates/core/src/addon/pack/tests/safety.rs @@ -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("建符号链接"); diff --git a/crates/core/src/addon/pack/tests/unknown_fields.rs b/crates/core/src/addon/pack/tests/unknown_fields.rs index 8813c7c..364faa2 100644 --- a/crates/core/src/addon/pack/tests/unknown_fields.rs +++ b/crates/core/src/addon/pack/tests/unknown_fields.rs @@ -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"), diff --git a/crates/core/src/addon/pack/tests/zip64.rs b/crates/core/src/addon/pack/tests/zip64.rs index d1b7b33..6bcf3ae 100644 --- a/crates/core/src/addon/pack/tests/zip64.rs +++ b/crates/core/src/addon/pack/tests/zip64.rs @@ -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 { #[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); } diff --git a/crates/core/src/addon/temp.rs b/crates/core/src/addon/temp.rs new file mode 100644 index 0000000..eecb334 --- /dev/null +++ b/crates/core/src/addon/temp.rs @@ -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` 让调用点能直接当 +//! `&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/`)。 + 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) -> PathBuf { + self.path.join(name) + } +} + +impl Deref for TempRoot { + type Target = Path; + + fn deref(&self) -> &Path { + &self.path + } +} + +impl AsRef for TempRoot { + /// 让 `&TempRoot` 能直接喂给 `impl AsRef` 形态的 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()); + } + } + } +} diff --git a/crates/core/src/addon/tests/decisions_support.rs b/crates/core/src/addon/tests/decisions_support.rs new file mode 100644 index 0000000..249a1de --- /dev/null +++ b/crates/core/src/addon/tests/decisions_support.rs @@ -0,0 +1,69 @@ +//! `import_decisions` 的夹具与断言小工具:**从 `import_decisions.rs` 拆出来守 400 行硬线**。 +//! +//! 拆分口径与仓库其它拆分一致:**纯搬移**,内容一行不改。这些夹具本身也是"口径"的一部分—— +//! 叠层(`client-overrides/` 胜 `overrides/`)、目标里已存在的用户内容、以及"内容与包内一致" +//! 的那一份,都是冲突判定的边界样本。 + +use super::support::{sha1, StubFetcher}; +use super::{write_file, zip_dir, TempRoot}; +use crate::addon::import::ImportScan; + +/// 夹具里 `client-overrides/` 胜出的那份内容(`overrides/` 里同名文件是不同的内容)。 +pub(super) const KNOWN_CLIENT: &[u8] = b"shared = client\n"; +/// 夹具里被遮蔽的那份(`overrides/` 侧)。 +pub(super) const KNOWN_OVERRIDES: &[u8] = b"shared = overrides\n"; +/// 与目标内容**一致**的那个文件。 +pub(super) const SAME: &[u8] = b"same = 1\n"; +/// 只有 overrides 声明、目标里还有一个不同内容的同名文件。 +pub(super) const LAYER: &[u8] = b"layer from overrides\n"; +/// 目标里用户自己的内容(会被覆盖 / 被保留的那一侧)。 +pub(super) const USER_KNOWN: &[u8] = b"shared = mine\n"; +/// 目标里用户自己的 layer 内容。 +pub(super) const USER_LAYER: &[u8] = b"my layer\n"; + +/// 一份带 **overrides 叠层** 的 mrpack(`client-overrides/` 与 `overrides/` 同名不同内容)+ 一条远端条目。 +pub(super) fn layered_pack(tag: &str) -> (TempRoot, StubFetcher) { + // 返回值 `Deref` 到 `pack.mrpack`,根由它持有(`src/` 在 `root.parent()` 下)。 + let root = TempRoot::at(tag, "pack.mrpack"); + let dir = root.parent().expect("临时根").join("src"); + let remote = b"remote jar bytes"; + let index = format!( + r#"{{"game":"minecraft","formatVersion":1,"versionId":"1.0.0","name":"M160 夹具", + "files":[{{"path":"mods/remote.jar", + "hashes":{{"sha1":"{}","sha512":"{}"}}, + "downloads":["stub://mods/remote.jar"],"fileSize":{}}}], + "dependencies":{{"minecraft":"1.20.1"}}}}"#, + sha1(remote), + "0".repeat(128), + remote.len() + ); + write_file(&dir.join("modrinth.index.json"), index.as_bytes()); + // 叠层:同一路径两份声明,client-overrides 胜(Modrinth 官方语义)。 + write_file(&dir.join("overrides/config/known.toml"), KNOWN_OVERRIDES); + write_file( + &dir.join("client-overrides/config/known.toml"), + KNOWN_CLIENT, + ); + // 内容与目标一致的那个(不在叠层里,来源 = overrides)。 + write_file(&dir.join("overrides/config/same.toml"), SAME); + write_file(&dir.join("overrides/mods/layer.jar"), LAYER); + zip_dir(&dir, &root); + let fetcher = StubFetcher::new().with_file("stub://mods/remote.jar", remote); + (root, fetcher) +} + +/// 预置一个"用户已有文件"的实例目录。 +pub(super) fn seeded(tag: &str) -> TempRoot { + let target = TempRoot::under(tag, "instance"); + write_file(&target.join("config/known.toml"), USER_KNOWN); + write_file(&target.join("config/same.toml"), SAME); + write_file(&target.join("mods/layer.jar"), USER_LAYER); + target +} + +pub(super) fn paths(scan: &ImportScan) -> Vec<&str> { + scan.conflicts + .iter() + .map(|conflict| conflict.path.as_str()) + .collect() +} diff --git a/crates/core/src/addon/tests/import_curseforge.rs b/crates/core/src/addon/tests/import_curseforge.rs index 03d6d8c..470427f 100644 --- a/crates/core/src/addon/tests/import_curseforge.rs +++ b/crates/core/src/addon/tests/import_curseforge.rs @@ -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() diff --git a/crates/core/src/addon/tests/import_decisions.rs b/crates/core/src/addon/tests/import_decisions.rs new file mode 100644 index 0000000..4b913ae --- /dev/null +++ b/crates/core/src/addon/tests/import_decisions.rs @@ -0,0 +1,354 @@ +//! 逐文件冲突决策(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::decisions_support::{ + layered_pack, paths, seeded, KNOWN_CLIENT, SAME, USER_KNOWN, USER_LAYER, +}; +use super::support::{mrpack_stub, sha1}; +use super::{read_text, write_file, zip_dir, TempRoot}; +use crate::addon::import::{ + import_pack, scan_pack, ConflictChoice, ConflictDecisions, ConflictReason, FileAction, + FileSource, ImportRequest, ImportStatus, +}; +use crate::download::blocking::block_on; + +#[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_remote_entry_with_a_path_but_no_sha1_is_counted_as_unjudgeable() { + // 复审 r1 P3-4:`unknown_remote` 的**计数口径**与注记文案要同一件事。 + // 这一条只放 `hashes` 里的 sha512、**不写 sha1**:有路径但没有声明摘要 ⇒ 预检没法比对磁盘, + // 也就不该假装它"没冲突",而是要计入 unknown_remote 并如实注记。 + let root = TempRoot::new("dec-no-sha1"); + let dir = root.join("src"); + let index = format!( + r#"{{"game":"minecraft","formatVersion":1,"versionId":"1.0.0","name":"缺 sha1", + "files":[{{"path":"mods/no-sha1.jar", + "hashes":{{"sha512":"{}"}}, + "downloads":["stub://mods/no-sha1.jar"],"fileSize":16}}], + "dependencies":{{"minecraft":"1.20.1"}}}}"#, + "0".repeat(128) + ); + write_file(&dir.join("modrinth.index.json"), index.as_bytes()); + let pack = root.join("pack.mrpack"); + zip_dir(&dir, &pack); + let target = root.join("instance"); + + let scan = scan_pack(&ImportRequest::new(&pack, &target)); + assert!(scan.is_ok(), "{:?}", scan.error); + assert_eq!(scan.unknown_remote, 1, "有路径但缺 sha1 ⇒ 判不了,要计数"); + let note = scan + .notes + .iter() + .find(|note| note.message_key == crate::addon::import::keys::NOTE_REMOTE_PATH_UNKNOWN) + .expect("要如实注记"); + assert!( + note.message.contains("sha1"), + "文案要把「缺 sha1 声明」这条来路说清楚(计数口径与文案一致):{}", + note.message + ); +} + +#[test] +fn a_broken_pack_reports_the_error_instead_of_panicking() { + let root = TempRoot::new("dec-broken"); + 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); +} diff --git a/crates/core/src/addon/tests/import_failures.rs b/crates/core/src/addon/tests/import_failures.rs index 57e8511..cbe73fb 100644 --- a/crates/core/src/addon/tests/import_failures.rs +++ b/crates/core/src/addon/tests/import_failures.rs @@ -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() { // `.ullbak-`,而那个位置已被一个**非空目录**占住 ⇒ `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))); diff --git a/crates/core/src/addon/tests/import_layering.rs b/crates/core/src/addon/tests/import_layering.rs index 0531979..f7833fc 100644 --- a/crates/core/src/addon/tests/import_layering.rs +++ b/crates/core/src/addon/tests/import_layering.rs @@ -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"), diff --git a/crates/core/src/addon/tests/import_merge.rs b/crates/core/src/addon/tests/import_merge.rs index 64cd84a..7ca69f3 100644 --- a/crates/core/src/addon/tests/import_merge.rs +++ b/crates/core/src/addon/tests/import_merge.rs @@ -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))); diff --git a/crates/core/src/addon/tests/import_modrinth.rs b/crates/core/src/addon/tests/import_modrinth.rs index 0dbb934..be93253 100644 --- a/crates/core/src/addon/tests/import_modrinth.rs +++ b/crates/core/src/addon/tests/import_modrinth.rs @@ -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)); diff --git a/crates/core/src/addon/tests/import_multimc.rs b/crates/core/src/addon/tests/import_multimc.rs index 170b82e..6d9789c 100644 --- a/crates/core/src/addon/tests/import_multimc.rs +++ b/crates/core/src/addon/tests/import_multimc.rs @@ -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/.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); diff --git a/crates/core/src/addon/tests/import_report.rs b/crates/core/src/addon/tests/import_report.rs index 8c63dd3..97f4297 100644 --- a/crates/core/src/addon/tests/import_report.rs +++ b/crates/core/src/addon/tests/import_report.rs @@ -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"), ]; diff --git a/crates/core/src/addon/tests/mod.rs b/crates/core/src/addon/tests/mod.rs index 40045bf..b385a35 100644 --- a/crates/core/src/addon/tests/mod.rs +++ b/crates/core/src/addon/tests/mod.rs @@ -17,29 +17,28 @@ // `fixtures.rs` 被 `addon::pack::tests` 与本目录**同时**编译(同一份手造包只维护一处), // 所以 clippy 的 `duplicate_mod` 在这里是刻意为之:改成两份副本才是真的错。 +mod decisions_support; #[allow(clippy::duplicate_mod)] #[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]) { diff --git a/crates/core/src/addon/tests/roundtrip_three.rs b/crates/core/src/addon/tests/roundtrip_three.rs new file mode 100644 index 0000000..ea3a78d --- /dev/null +++ b/crates/core/src/addon/tests/roundtrip_three.rs @@ -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//.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> { + 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 { + 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![ + "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![ + "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"], "只有那一处不同"); +} diff --git a/crates/i18n/src/catalog/content.rs b/crates/i18n/src/catalog/content.rs index 0f492a8..54ee392 100644 --- a/crates/i18n/src/catalog/content.rs +++ b/crates/i18n/src/catalog/content.rs @@ -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 实例"), diff --git a/crates/i18n/src/catalog/golden/tail.rs b/crates/i18n/src/catalog/golden/tail.rs index e5e38de..eafae40 100644 --- a/crates/i18n/src/catalog/golden/tail.rs +++ b/crates/i18n/src/catalog/golden/tail.rs @@ -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 实例"), diff --git a/crates/presenter/src/dto/mod.rs b/crates/presenter/src/dto/mod.rs index 843bcfd..5b271c7 100644 --- a/crates/presenter/src/dto/mod.rs +++ b/crates/presenter/src/dto/mod.rs @@ -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: 自更新 diff --git a/crates/presenter/src/dto/pack.rs b/crates/presenter/src/dto/pack.rs index 7fbd1fb..b86d750 100644 --- a/crates/presenter/src/dto/pack.rs +++ b/crates/presenter/src/dto/pack.rs @@ -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, diff --git a/crates/presenter/src/dto/pack/conflict.rs b/crates/presenter/src/dto/pack/conflict.rs index e5c07f1..740158e 100644 --- a/crates/presenter/src/dto/pack/conflict.rs +++ b/crates/presenter/src/dto/pack/conflict.rs @@ -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 { + 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, +} + +/// 用户的冲突决策(三条入口共用一个类型)。 +/// +/// 三条入口与 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, + }, +} + +impl Default for ConflictDecisionsView { + /// **缺省 = 全部保留**(绝不默认覆盖用户已有文件)。 + fn default() -> Self { + Self::KeepAll + } +} + +impl ConflictDecisionsView { + /// 逐项构造(缺省档 + 点过的那些)。 + #[must_use] + pub fn per_item(default: ConflictChoiceView, items: Vec) -> 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, + /// 包名。 + pub pack_name: Option, + /// 包版本。 + pub pack_version: Option, + /// 游戏版本。 + pub game_version: Option, + /// 待用户点头的冲突(按路径排序)。 + pub conflicts: Vec, + /// 补全前没有路径、因而判不了的远端条目数。 + pub unknown_remote: usize, + /// 覆盖会丢掉的字节总量。 + pub lost_bytes: u64, + /// 包内那些条目声明了多大。 + pub incoming_bytes: u64, + /// 注记(与导入报告同源的人读串)。 + pub notes: Vec, + /// 预检本身失败(探测不过 / 开包失败)时的原因。 + pub error: Option, +} + +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 { diff --git a/crates/presenter/src/dto/pack/event.rs b/crates/presenter/src/dto/pack/event.rs index 61b0038..b32ae9c 100644 --- a/crates/presenter/src/dto/pack/event.rs +++ b/crates/presenter/src/dto/pack/event.rs @@ -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), /// 开始导入(目标实例已选好;`instance` 为 `None` = 按 `instance_name` 新建目录) Import(Box), /// 跑**预检**(只扫描分类,**不写盘**) @@ -47,6 +52,8 @@ pub enum PackCommand { pub enum PackEvent { /// 导入进度(`None` = 收回面板:取消,或流程已经给出结果) Progress(Option>), + /// 导入**预检**结果:冲突清单(M160)。空清单 = 没有文件需要用户点头。 + Scan(Box), /// 导入结果(三态 + 逐项原因;致命失败也走它,`report.error` 里有原因) Report(Box), /// 探测失败:这个包看起来像什么、缺哪个标记文件 @@ -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) => { diff --git a/crates/presenter/src/dto/pack/import.rs b/crates/presenter/src/dto/pack/import.rs index 2109558..7cd8a4b 100644 --- a/crates/presenter/src/dto/pack/import.rs +++ b/crates/presenter/src/dto/pack/import.rs @@ -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, /// 新实例的展示名(`None` = 用包名)。 pub instance_name: Option, - /// 冲突翻档:`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, /// 最近一次的结果(`None` = 还没导过)。 pub report: Option, /// 探测失败的具名诊断(有它就让用户看清「像 X 缺 Y」,而不是一句「无法识别」)。 diff --git a/crates/presenter/src/host/addon/pack.rs b/crates/presenter/src/host/addon/pack.rs index 3137a4b..df0c498 100644 --- a/crates/presenter/src/host/addon/pack.rs +++ b/crates/presenter/src/host/addon/pack.rs @@ -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) diff --git a/crates/presenter/src/host/addon/pack/convert.rs b/crates/presenter/src/host/addon/pack/convert.rs index ec19cbf..a319f59 100644 --- a/crates/presenter/src/host/addon/pack/convert.rs +++ b/crates/presenter/src/host/addon/pack/convert.rs @@ -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, @@ -175,6 +251,33 @@ pub(super) fn failed_report( label: &str, message: &str, ) -> ImportReportView { + failed_report_with( + target, + pack, + ImportErrorView { + code: code.to_string(), + label: label.to_string(), + message: message.to_string(), + hint: None, + }, + ) +} + +/// 一条 core 的 [`ImportError`] → 失败报告(**逐字段照搬**,含 `hint`)。 +/// +/// 预检失败(`scan.error`)走它:那些错本来就是 core 判出来的(`pack_traversal` / +/// `pack_limits` / `pack_metadata` …),没必要在宿主层再解释一遍,更不能把 `hint` +/// ("先装好游戏再导出"这种可执行下一步)丢掉——丢掉的话用户看到的就是"失败了,然后呢"。 +pub(super) fn failed_report_of( + target: PathBuf, + pack: &Path, + error: &ImportError, +) -> ImportReportView { + failed_report_with(target, pack, error_view(error)) +} + +/// 组装:形状只此一处,文案由调用方给。 +fn failed_report_with(target: PathBuf, pack: &Path, error: ImportErrorView) -> ImportReportView { ImportReportView { status: ImportStatusView::Failed, format: None, @@ -189,12 +292,7 @@ pub(super) fn failed_report( missing: Vec::new(), rejected: Vec::new(), warnings: Vec::new(), - error: Some(ImportErrorView { - code: code.to_string(), - label: label.to_string(), - message: message.to_string(), - hint: None, - }), + error: Some(error), registered: None, bytes_written: 0, rolled_back: true, diff --git a/crates/presenter/src/host/addon/pack/import.rs b/crates/presenter/src/host/addon/pack/import.rs index 10f5c2c..8854b35 100644 --- a/crates/presenter/src/host/addon/pack/import.rs +++ b/crates/presenter/src/host/addon/pack/import.rs @@ -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,70 @@ pub(super) fn spawn_import( }); } +/// 起一个后台**预检**线程(M160:只读,不写盘)。 +pub(super) fn spawn_scan( + env: HostEnv, + tx: JobSender, + request: ImportRequestView, + epoch: u64, + cancel: Arc, +) { + 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; + } + // 先发预检结果(`error` 也照原样带出去:调用方按"有没有冲突"分流要用它)。 + send( + &tx, + epoch, + PackEvent::Scan(Box::new(convert::scan_view(&scan))), + ); + // **预检自己没算出来 ⇒ 必须再发一条结果事件**(复审 r1 P2-1)。 + // + // 只发 `Scan(error)` 的话,页面那边 `pick` 为空(它只由 `conflicts` 生成)、进度与报告 + // 又被 `Scan` 清空 ⇒ 落回入口态,用户点完「导入」屏幕上一个字都不变(静默空转)。 + // 走 `Report` 就回到 M160 之前那条既有形状:结果块的「致命 —— 已回滚」把原因画出来, + // UI 一行不用改;`code`/`label`/`message`/`hint` 全部从 `scan.error` 搬。 + if let Some(error) = &scan.error { + send( + &tx, + epoch, + PackEvent::Report(Box::new(convert::failed_report_of( + target, + &request.pack, + error, + ))), + ); + } + }); +} + pub(super) fn run( env: &HostEnv, tx: &JobSender, @@ -71,6 +138,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 +180,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 +216,7 @@ fn remove_if_empty(dir: &std::path::Path) { } } -/// 导入目标:`(目录, 是否登记新实例)`。 +/// 导入目标:`(目录, 是否登记新实例)` —— **只算路径,不建目录**(预检要能只读地用它)。 /// /// - 选了实例 ⇒ 它的运行目录,**不登记**(已经是一条实例记录); /// - 没选实例 ⇒ `/instances/` 新目录 + 登记(实例名 = 用户给的名字或包名)。 @@ -166,11 +248,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}")) +} diff --git a/crates/presenter/src/host/addon/pack/tests.rs b/crates/presenter/src/host/addon/pack/tests.rs index b9127fe..feb2da5 100644 --- a/crates/presenter/src/host/addon/pack/tests.rs +++ b/crates/presenter/src/host/addon/pack/tests.rs @@ -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| { diff --git a/crates/presenter/src/host/addon/pack/tests/scan.rs b/crates/presenter/src/host/addon/pack/tests/scan.rs new file mode 100644 index 0000000..681f6fb --- /dev/null +++ b/crates/presenter/src/host/addon/pack/tests/scan.rs @@ -0,0 +1,303 @@ +//! 预检与决策通道(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); +} + +/// 事件序列的名字(断言"用户到底能不能看见"用)。 +fn kind_of(event: &crate::dto::PackEvent) -> &'static str { + match event { + crate::dto::PackEvent::Progress(_) => "progress", + crate::dto::PackEvent::Scan(scan) if scan.error.is_some() => "scan-with-error", + crate::dto::PackEvent::Scan(_) => "scan", + crate::dto::PackEvent::Report(report) if report.error.is_some() => "report-failed", + crate::dto::PackEvent::Report(_) => "report", + crate::dto::PackEvent::Probe(_) => "probe", + crate::dto::PackEvent::Preview(_) => "preview", + crate::dto::PackEvent::Exported(_) => "exported", + crate::dto::PackEvent::ExportFailed(_) => "export-failed", + crate::dto::PackEvent::Cancelled => "cancelled", + crate::dto::PackEvent::Cleared => "cleared", + } +} + +/// 跑一次 `spawn_scan`,把事件按顺序收回来。 +fn run_scan(source: std::path::PathBuf, data: &std::path::Path) -> Vec { + let env = crate::host::HostEnv::at_root(data); + let (tx, rx) = crate::host::job::channel(); + let request = crate::dto::ImportRequestView { + pack: source, + instance: None, + instance_name: Some("Scan Rework".to_string()), + decisions: ConflictDecisionsView::default(), + }; + super::super::import::spawn_scan( + env, + tx, + request, + 3, + std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + ); + let mut events = Vec::new(); + for task in rx.iter() { + if let crate::host::job::HostTask::Pack { event, epoch } = task { + assert_eq!(epoch, 3, "代次必须原样带回"); + events.push(*event); + } + } + events +} + +#[test] +fn a_failed_scan_still_reaches_the_screen_as_a_report() { + // 复审 r1 P2-1 的回归钉:**这条用例验的是"用户看得见",不是"字段被填了"**。 + // + // 达标形状(旧行为 / M160 之前):`Import` 失败 → `PackEvent::Report` → 结果块画「已回滚」+ 原因。 + // 出错形状(本 diff 引入的回归):只发 `Scan(error)` ⇒ 页面 pick 为空、report 被清空 ⇒ 入口态, + // 屏幕上什么都不发生。所以这里逐一断言**事件序列**,而不只看 `scan.error`。 + // + // 可达面:detect 认得、read 才失败的包。这里用最普通的一种——MultiMC 的 + // `formatVersion: 2`(Prism 自己也会整份丢弃,不是刻意构造的畸形包)。 + let dir = temp_dir("scan-error"); + let source = dir.join("Prism 2"); + write( + &source.join("mmc-pack.json"), + br#"{"formatVersion": 2, "components": [ + {"uid": "net.minecraft", "version": "1.20.1"}]}"#, + ); + write(&source.join(".minecraft/mods/sodium.jar"), &[0x11; 32]); + let data = dir.join("data"); + std::fs::create_dir_all(&data).expect("data root"); + + let events = run_scan(source, &data); + let kinds: Vec<&str> = events.iter().map(kind_of).collect(); + assert_eq!( + kinds, + vec!["scan-with-error", "report-failed"], + "预检自己失败时必须再发一条结果事件(否则页面静默空转)" + ); + + let crate::dto::PackEvent::Scan(scan) = &events[0] else { + panic!("第一条必须是预检结果"); + }; + let error = scan.error.as_ref().expect("预检要带出错误"); + assert_eq!( + error.code, "pack_metadata", + "formatVersion != 1 是清单自身非法" + ); + assert!( + error.message.contains("formatVersion"), + "原因要能读:{}", + error.message + ); + + // 结果事件:三态是 Failed、原因与预检那份同源(code/label/message 都搬过来)。 + let crate::dto::PackEvent::Report(report) = &events[1] else { + panic!("第二条必须是结果"); + }; + assert_eq!(report.status, crate::dto::ImportStatusView::Failed); + let reported = report.error.as_ref().expect("报告要带出原因"); + assert_eq!(reported.code, error.code); + assert_eq!(reported.message, error.message); + assert!(!reported.label.is_empty(), "人读大类名也要有"); + + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn a_clean_scan_does_not_emit_a_report() { + // 对照:干净预检只发一条 Scan(多发 Report 会把页面从"清单"顶到"结果")。 + let dir = temp_dir("scan-clean"); + let source = dir.join("Vanilla"); + write( + &source.join("mmc-pack.json"), + br#"{"formatVersion": 1, "components": [{"uid": "net.minecraft", "version": "1.20.1"}]}"#, + ); + write(&source.join(".minecraft/mods/sodium.jar"), &[0x11; 32]); + let data = dir.join("data"); + std::fs::create_dir_all(&data).expect("data root"); + + let kinds: Vec<&str> = run_scan(source, &data).iter().map(kind_of).collect(); + assert_eq!(kinds, vec!["scan"], "干净预检只发一条"); + let _ = std::fs::remove_dir_all(&dir); +}