Files
Unlucky-Launcher/crates/core/src/addon/import.rs
T
AskaEth d728ed836f feat(core): M152 整合包收尾——同路径遮蔽逐文件上报 + ZIP64 拒写预检
导入侧把「同名文件冲突」从整包一个策略推进到逐文件事实:同一实例路径被本包多份声明
命中时(跨侧 overrides/client-overrides,或同侧重复),按叠层顺序(Both → Client)定
胜者,其余标 PlanOverride::shadowed(winner / winner_dir / same_bytes),逐条留
pack.import.note.override_shadowed 注记;phase 只遍历未被遮蔽的条目,不再白读字节,
报告里也不再出现"两条 Installed、前一条的哈希其实没落地"。

不新增 FileAction/ConflictReason 变体:这三个枚举被 crates/presenter 的 convert.rs
穷尽 match,而那个 crate 不在本 purpose 的作用域内,所以走既有的 ImportIssue 通道
(与 server-overrides/ 被跳过同一条)。

导出侧那条 ZIP64 闸原来够不着:先 std::fs::read 整个覆盖文件进内存再累加判断,真要
导出 4 GiB 文件的实例,内存先炸、用户永远看不到那句 pack_limits。改成写前按
metadata().len() 做声明尺寸预检,超阈值在任何字节进内存之前就报;写循环里的增量检查
留作第二道。写 zip 的实现搬到 format/write.rs 以守 400 行。

测试:pack/tests/zip64.rs(稀疏文件真跑到 ZIP64 分支,含累计口径与对照)、
tests/import_layering.rs(同路径不同哈希 / 同哈希不同路径 / 叠层顺序 / 基线记胜者)、
unknown_fields.rs 补第 4 条假边(mrpack files[].extra + 未知哈希算法)。
五条变异各自变红(遮蔽 no-op 6 红、按哈希判重 6 红、翻转叠层 2 红、错误码改 io 3 红、
预检不累计则预检单测红且端到端用例从 0.01s 变成 95s/约 4.5 GiB 内存)。
2026-11-07 08:22:47 +08:00

183 lines
8.0 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 整合包**导入**(M111):CurseForge `manifest.json` / Modrinth `.mrpack` / MultiMC·Prism 实例
//! → 一个可启动的实例目录。
//!
//! 设计依据:`DevDocs/PlanDoc/08-mod-and-recommend.md` §2(D10)、
//! `DevDocs/PlanDoc/research/18-modpack-formats*.md`(字段清单,A/B 级来源)、
//! `DevDocs/SubAgentsTalk/addon-整合包导入导出对接要点.md`(施工口径)。
//! 格式模型与安全闸是 M110 的 [`crate::addon::pack`]:本模块**只消费,不重写**。
//!
//! # 三条硬要求落在哪
//!
//! | 要求 | 落点 |
//! |------|------|
//! | overrides 哈希**三方合并**(不静默覆盖用户改动) | [`merge`] 的判据 + [`baseline`] 的 base 侧 |
//! | 两阶段补全 + **失败分级**(仅"缺文件"可容忍) | [`pipeline`] 的顺序 + [`report::MissingFile`] 与 [`error::ImportError`] 的分工 |
//! | 路径穿越**双检** | 开包时 M110 [`crate::addon::pack::PackArchive::open`],落地时 [`stage`] 的 `normalize_file` + `starts_with` 复核 |
//!
//! # 三种格式的语义(tower 定案,与 HMCL 相反的两条要记住)
//!
//! - **mrpack**:`overrides/` 与 `client-overrides/` 都解压,**后者胜**(Modrinth 官方客户端语义;
//! HMCL 恰好相反);`server-overrides/` 客户端**不解压**;其余同名路径无官方优先级 ⇒ 按侧
//! 优先级落地,不自创排序。**同一实例路径被本包多份声明命中时**(跨侧或同侧重复),
//! 逐条算清楚:胜者按上面这条规则定,被遮蔽的那条不读字节、只留一条
//! `pack.import.note.override_shadowed` 注记说明「谁赢了 / 两份内容是否逐字节相同」
//! (见 [`plan::Shadowed`])。一路径一行,不再出现"两条 Installed、前一条没落地"。
//! - **CF**:溢出目录名取自清单 `overrides` 字段;`files[]` 只有 `projectID/fileID`,要 CF API
//! 才能补出文件名/地址——仓库里还没有 CF 客户端(D10 §5),因此这部分条目的**当前正确行为**
//! 是记成 `missing`(`Partial`),**绝不猜 `edge.forgecdn.net` 那类 URL**。
//! - **MultiMC / Prism**:`mmc-pack.json` 的 `formatVersion` 必须恰为 `1`(M110 的读者会拒绝
//! 其余值);`.minecraft/` 直拷;非加载器 component(`org.lwjgl3` 等)**逐项**进
//! [`report::RejectedComponent`],不静默丢;只有导入,没有导出。
//!
//! # `.disabled` 只按路径后缀
//!
//! `x.jar` / `x.jar.disabled` / `x.jar.old` / `x.jar.disabled.old` 四种变体都识别,后缀**原样
//! 保留**;`.old` 只识别与保留(不清除、不改名、不当回滚语义)。`env.client=optional` **不是**
//! 禁用——"可选"的语义是"安装时可取消勾选",装回来默认仍然启用,两者不是一回事。
//!
//! # 用法
//!
//! ```ignore
//! let engine = /* core::download::DownloadEngine */;
//! let fetcher = DownloadEngineFetcher::new(&engine);
//! let request = ImportRequest::new(pack_path, instance_dir)
//! .with_fetcher(&fetcher)
//! .with_registration(&launcher_layout, None);
//! let report = download::blocking::block_on(import_pack(&request));
//! match report.status { /* Success / Partial / Failed */ }
//! ```
//!
//! # 刻意不做的事(分工边界)
//!
//! - **不删除**整合包升级后不再包含的文件:磁盘上的 `mods/` 里可能有用户自己装的模组,
//! 删旧文件是"清理"而不是"导入"(要做得先有"这个文件是整合包装的"的完整账本 + 用户确认);
//! - **不装加载器**:只把清单翻译成 [`report::LoaderTarget`](`kind` + 原版版本 + 加载器版本
//! + 版本 id),真正的元数据解析/库下载仍是 `addon::loader` + `game::install` 的既有路径;
//! - **不碰 UI 类型**:core 零 UI 依赖(`core-no-ui` 门禁),报告只出现 `String` / `&'static str`;
//! - **不绕过下载引擎**:所有网络只经 [`fetch::PackFetcher`],生产实现只有
//! [`fetch::DownloadEngineFetcher`](走 `core::download`)。
//!
//! # 没验的部分(真实世界的欠账,报告里同样登记)
//!
//! 真实 CF / MultiMC 导出物(夹具是手造的)、超大包(>2G)、Windows 之外的平台、断网中途、
//! 磁盘满、真实 CurseForge `files[]` 补全(缺 CF 客户端,见 [`fetch`] 的说明)。
pub mod baseline;
pub mod error;
pub mod fetch;
pub mod merge;
pub mod phase;
pub mod pipeline;
pub mod plan;
pub mod report;
pub mod stage;
pub use baseline::{PackBaseline, BASELINE_FILE};
pub use error::{ImportError, ImportErrorKind};
pub use fetch::{
CurseFileMeta, DownloadEngineFetcher, FetchError, FetchErrorKind, FetchFuture, FetchRequest,
FetchedFile, PackFetcher,
};
pub use merge::{MergeAction, MergeDecision};
pub use pipeline::baseline_relative;
pub use plan::{ImportPlan, PlanOverride, PlanRemote, Shadowed, SuffixVariant};
pub use report::{
keys, ConflictChoice, ConflictFile, ConflictReason, FileAction, FileSource, ImportIssue,
ImportProgress, ImportProgressSink, ImportReport, ImportStage, ImportStatus, ImportedFile,
LoaderTarget, MissingFile, MissingReason, RegisteredInstance, RejectedComponent,
};
pub use stage::{RollbackOutcome, Staging};
use std::path::Path;
use crate::addon::pack::PackLimits;
use crate::platform::layout::LauncherLayout;
/// 冲突处置策略(默认保留用户文件)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ConflictPolicy {
/// 保留用户那一侧(不覆盖、不补回被删的文件),冲突逐条进报告。
#[default]
KeepUser,
/// 用包内内容覆盖(用户明确要求"整合包说了算")。
Overwrite,
}
/// 一次导入请求。
pub struct ImportRequest<'a> {
/// 包路径:zip(`.zip` / `.mrpack`)或 MultiMC 实例目录。
pub pack: &'a Path,
/// 目标实例的游戏目录(`mods/` 直接在其下)。
pub target: &'a Path,
/// 远端补全 + 下载;`None` = 离线导入(远端条目全部记成 `missing`)。
pub fetcher: Option<&'a dyn PackFetcher>,
/// 数据根布局;给了才登记实例(`profiles.json`)。
pub register: Option<&'a LauncherLayout>,
/// 实例展示名(缺省用包名)。
pub instance_name: Option<&'a str>,
/// 冲突策略。
pub conflict_policy: ConflictPolicy,
/// 包读取限额(条数 / 单条体积 / 解压总量)。
pub limits: PackLimits,
/// 进度回调。
pub progress: Option<ImportProgressSink>,
}
impl<'a> ImportRequest<'a> {
/// 最小请求(离线、不登记、保留用户文件、默认限额)。
#[must_use]
pub fn new(pack: &'a Path, target: &'a Path) -> Self {
Self {
pack,
target,
fetcher: None,
register: None,
instance_name: None,
conflict_policy: ConflictPolicy::KeepUser,
limits: PackLimits::default(),
progress: None,
}
}
/// 挂上远端补全 / 下载。
#[must_use]
pub fn with_fetcher(mut self, fetcher: &'a dyn PackFetcher) -> Self {
self.fetcher = Some(fetcher);
self
}
/// 挂上实例登记(`name` 为 `None` 时用包名)。
#[must_use]
pub fn with_registration(mut self, layout: &'a LauncherLayout, name: Option<&'a str>) -> Self {
self.register = Some(layout);
self.instance_name = name;
self
}
/// 改冲突策略。
#[must_use]
pub fn with_conflict_policy(mut self, policy: ConflictPolicy) -> Self {
self.conflict_policy = policy;
self
}
/// 改读取限额。
#[must_use]
pub fn with_limits(mut self, limits: PackLimits) -> Self {
self.limits = limits;
self
}
/// 挂进度回调。
#[must_use]
pub fn with_progress(mut self, sink: ImportProgressSink) -> Self {
self.progress = Some(sink);
self
}
}
/// 导入一个整合包。**永不返回 `Err`**:三态与逐项原因都在 [`ImportReport`] 里。
pub async fn import_pack(request: &ImportRequest<'_>) -> ImportReport {
pipeline::run(request).await
}