feat(m160): 整合包逐文件覆盖/保留决策 + 导入前冲突清单 + 限额口径统一
主任务(逐文件覆盖/保留)
- core: 新增 import/scan.rs —— 只读预检产出「冲突清单」(目标已存在且 SHA-1 不同才进),
逐条给路径/来源/两侧体积与哈希;用户删过的、内容相同的、被叠层遮蔽的一律不打扰。
- core: 新增 import/decision.rs —— ConflictDecisions(整批档 + 逐项表,逐项优先),
缺省档恒为 KeepUser;ImportRequest.conflict_policy 改由它承载,CLI 调用点不变。
- core: phase.rs 的 settle 按 path 取决策;非冲突路径不受决策影响。
- presenter: ImportScanView / ImportConflictView / ConflictDecisionsView(全部保留 /
全部覆盖 / 逐项{default,items})+ PackCommand::Scan / PackEvent::Scan;host 新增只读
spawn_scan,且新实例路径不再提前建目录(target_of 只算路径,prepare_target 才建)。
- app-iced: 导入前冲突清单页(分页 40 条/页、三入口、计数摘要、确认/取消、两行式行布局);
预检干净时直接往下导,不打扰用户。出图见 C:/Temp/m160-shots/{pick-light,pick-dark}.png。
- i18n: 新增 18 条 en/zh 文案 + 更新「两者都留」那条口径说明。
限额口径统一
- pack/archive/limits.rs 成为唯一来源:PACK_MAX_ENTRIES/UNCOMPRESSED_BYTES/ENTRY_BYTES
(20k / 512 MiB / 64 MiB);读侧 PackLimits::default 与写侧 WriteLimits::production 同源。
- 写侧不再直接用 zip::ZIP64_BYTES_THR;新增单条目闸,于是「能导出的包一定能导入」。
不等式在 pack/tests/limits.rs 里用 const 断言钉死。
测试临时物泄漏(塔台追加项)
- addon/temp.rs:TempRoot —— Drop 即删(panic 展开同样删),Deref/AsRef 到落点路径,
under()/at() 支持"子树落点、删父根"。形状照 core::update::tests::support::TempDir。
- addon::pack 与 addon::tests 两族全部改用 TempRoot(含 fixtures::Fixture 持守卫、
multimc/fabric_instance 等返回守卫);4 GiB 稀疏文件的语义一字未改,只改"谁负责删"。
- 自证:pack/tests/mod.rs 三条用例(drop 删 / panic 也删 / 子树删父根)。
测试
- import_decisions.rs(11 例):预检清单、不打扰、逐项决策后的最终文件集、基线记包内哈希。
- roundtrip_three.rs(2 例):叠层 + 冲突夹具 import→export→import 三轮文件集逐字节一致,
以及「选保留就按设计分叉」的反向证据。
- pack/tests/limits.rs(5 例):单条/总量边界、读侧用同一批常量、写出即可读回。
证据:cargo fmt --check 干净;cargo clippy --workspace --all-targets -D warnings 干净;
cargo test --workspace --all-targets -j 4 → 2507 passed / 0 failed(51 个 test binary)。
This commit is contained in:
1 parent
a4f096eea3
commit
4defc41e8d
64 files changed
+3819
-442
No files matched your search
@@ -6,6 +6,21 @@
|
||||
//! `DevDocs/SubAgentsTalk/addon-整合包导入导出对接要点.md`(施工口径)。
|
||||
//! 格式模型与安全闸是 M110 的 [`crate::addon::pack`]:本模块**只消费,不重写**。
|
||||
//!
|
||||
//! # 逐文件冲突决策(M160):预检 → 用户点头 → 导入
|
||||
//!
|
||||
//! | 步骤 | 入口 | 写盘吗 |
|
||||
//! |------|------|--------|
|
||||
//! | 预检:算出"哪几个文件会盖掉用户的" | [`scan_pack`] | **不写**(只读盘算摘要) |
|
||||
//! | 用户点「全部保留 / 全部覆盖 / 逐项」 | [`ConflictDecisions`] | — |
|
||||
//! | 导入 | [`import_pack`] | 写(按决策处置冲突) |
|
||||
//!
|
||||
//! 三条硬口径(改它们之前先看 [`scan`] 与 [`decision`] 的模块文档):
|
||||
//!
|
||||
//! 1. 进冲突清单的**只有**"目标里已存在同名普通文件且内容(SHA-1)不同"的路径;内容相同、
|
||||
//! 目标不存在、以及用户删过的文件都不打扰用户;
|
||||
//! 2. 决策按**路径**给,逐项优先于整批档,整批档缺省 = 保留;
|
||||
//! 3. 缺省**绝不覆盖**:没收到决策的冲突一律保留用户那一侧([`ConflictChoice::default`])。
|
||||
//!
|
||||
//! # 三条硬要求落在哪
|
||||
//!
|
||||
//! | 要求 | 落点 |
|
||||
@@ -63,6 +78,7 @@
|
||||
//! 磁盘满、真实 CurseForge `files[]` 补全(缺 CF 客户端,见 [`fetch`] 的说明)。
|
||||
|
||||
pub mod baseline;
|
||||
pub mod decision;
|
||||
pub mod error;
|
||||
pub mod fetch;
|
||||
pub mod merge;
|
||||
@@ -70,9 +86,11 @@ pub mod phase;
|
||||
pub mod pipeline;
|
||||
pub mod plan;
|
||||
pub mod report;
|
||||
pub mod scan;
|
||||
pub mod stage;
|
||||
|
||||
pub use baseline::{PackBaseline, BASELINE_FILE};
|
||||
pub use decision::ConflictDecisions;
|
||||
pub use error::{ImportError, ImportErrorKind};
|
||||
pub use fetch::{
|
||||
CurseFileMeta, DownloadEngineFetcher, FetchError, FetchErrorKind, FetchFuture, FetchRequest,
|
||||
@@ -86,6 +104,7 @@ pub use report::{
|
||||
ImportProgress, ImportProgressSink, ImportReport, ImportStage, ImportStatus, ImportedFile,
|
||||
LoaderTarget, MissingFile, MissingReason, RegisteredInstance, RejectedComponent,
|
||||
};
|
||||
pub use scan::{scan_pack, ImportConflict, ImportScan};
|
||||
pub use stage::{RollbackOutcome, Staging};
|
||||
|
||||
use std::path::Path;
|
||||
@@ -93,7 +112,15 @@ use std::path::Path;
|
||||
use crate::addon::pack::PackLimits;
|
||||
use crate::platform::layout::LauncherLayout;
|
||||
|
||||
/// 冲突处置策略(默认保留用户文件)。
|
||||
/// 整次导入的**缺省**冲突档(逐项决策见 [`ConflictDecisions`])。
|
||||
///
|
||||
/// 保留这个名字是因为 CLI 已经按它调用(`pack import --overwrite`);语义上它就是
|
||||
/// [`ConflictDecisions::default_choice`] 的构造器:
|
||||
///
|
||||
/// - [`ConflictPolicy::KeepUser`](缺省)= 逐项决策的缺省档是"保留";
|
||||
/// - [`ConflictPolicy::Overwrite`] = 逐项决策的缺省档是"覆盖"。
|
||||
///
|
||||
/// **逐项决策优先于它**,所以"整批覆盖 + 某几条保留"是可表达的。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum ConflictPolicy {
|
||||
/// 保留用户那一侧(不覆盖、不补回被删的文件),冲突逐条进报告。
|
||||
@@ -103,6 +130,17 @@ pub enum ConflictPolicy {
|
||||
Overwrite,
|
||||
}
|
||||
|
||||
impl ConflictPolicy {
|
||||
/// 对应的逐项决策缺省档。
|
||||
#[must_use]
|
||||
const fn choice(self) -> ConflictChoice {
|
||||
match self {
|
||||
Self::KeepUser => ConflictChoice::KeepUser,
|
||||
Self::Overwrite => ConflictChoice::Overwrite,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次导入请求。
|
||||
pub struct ImportRequest<'a> {
|
||||
/// 包路径:zip(`.zip` / `.mrpack`)或 MultiMC 实例目录。
|
||||
@@ -115,8 +153,8 @@ pub struct ImportRequest<'a> {
|
||||
pub register: Option<&'a LauncherLayout>,
|
||||
/// 实例展示名(缺省用包名)。
|
||||
pub instance_name: Option<&'a str>,
|
||||
/// 冲突策略。
|
||||
pub conflict_policy: ConflictPolicy,
|
||||
/// 逐文件冲突决策(含整批缺省档,**缺省 = 全部保留**)。
|
||||
pub decisions: ConflictDecisions,
|
||||
/// 包读取限额(条数 / 单条体积 / 解压总量)。
|
||||
pub limits: PackLimits,
|
||||
/// 进度回调。
|
||||
@@ -133,7 +171,7 @@ impl<'a> ImportRequest<'a> {
|
||||
fetcher: None,
|
||||
register: None,
|
||||
instance_name: None,
|
||||
conflict_policy: ConflictPolicy::KeepUser,
|
||||
decisions: ConflictDecisions::default(),
|
||||
limits: PackLimits::default(),
|
||||
progress: None,
|
||||
}
|
||||
@@ -154,10 +192,24 @@ impl<'a> ImportRequest<'a> {
|
||||
self
|
||||
}
|
||||
|
||||
/// 改冲突策略。
|
||||
/// 整批缺省档(逐项决策仍然优先)。
|
||||
#[must_use]
|
||||
pub fn with_conflict_policy(mut self, policy: ConflictPolicy) -> Self {
|
||||
self.conflict_policy = policy;
|
||||
self.decisions = std::mem::take(&mut self.decisions).with_default(policy.choice());
|
||||
self
|
||||
}
|
||||
|
||||
/// 整表逐文件决策(「全部保留 / 全部覆盖 / 逐项」三条入口的产物)。
|
||||
#[must_use]
|
||||
pub fn with_decisions(mut self, decisions: ConflictDecisions) -> Self {
|
||||
self.decisions = decisions;
|
||||
self
|
||||
}
|
||||
|
||||
/// 挂一条逐项决策(链式,便于测试与命令行逐条给)。
|
||||
#[must_use]
|
||||
pub fn with_decision(mut self, path: impl Into<String>, choice: ConflictChoice) -> Self {
|
||||
self.decisions.set(path, choice);
|
||||
self
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
//! 逐文件冲突决策:`path → Keep / Overwrite`,以及"没收到决策就等于保留"这条硬线。
|
||||
//!
|
||||
//! # 三个入口,一个通道
|
||||
//!
|
||||
//! | 用户在界面上点的 | 落到这里的形状 |
|
||||
//! |---|---|
|
||||
//! | 全部保留 | [`ConflictDecisions::keep_all`](= [`ConflictDecisions::default`]) |
|
||||
//! | 全部覆盖 | [`ConflictDecisions::overwrite_all`] |
|
||||
//! | 逐项 | [`ConflictDecisions::with`] 一条一条挂上去,整批档仍是"保留" |
|
||||
//!
|
||||
//! 三者共用一个类型,是因为它们要回答的是同一个问题:「这个路径上的冲突该怎么处置」。
|
||||
//! 两条独立的落盘规则迟早会打架(一处按整批档、一处按逐项表),所以合并成一处。
|
||||
//!
|
||||
//! # 缺省为什么必须保留
|
||||
//!
|
||||
//! [`ConflictChoice::default`] 是 `KeepUser`:**表里没有这个路径**、或者调用方根本没设过决策时,
|
||||
//! 结论是"用户那份文件留着"。这不是保守,是正确性——导入的输入是别人做的整合包,用户的
|
||||
//! 配置/存档/自己改过的 mod 不能因为"我们没问"就消失。要看"覆盖"只有一个来路:调用方
|
||||
//! **显式**说了覆盖(整批档或逐项决策)。
|
||||
//!
|
||||
//! # 决策只作用于**冲突**
|
||||
//!
|
||||
//! 非冲突路径(磁盘上没有、或内容与包内一致)根本不看这张表:没有冲突就没有"保留/覆盖"这个
|
||||
//! 问题,`merge::decide` 的结论照旧。于是"表里没有这条"永远不会把一份该装的文件漏掉。
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use super::report::ConflictChoice;
|
||||
|
||||
/// 一次导入的逐文件决策表。
|
||||
///
|
||||
/// 顺序稳定(`BTreeMap`),所以同一份决策序列化出来的形状是确定的——诊断与测试都能直接比。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ConflictDecisions {
|
||||
/// 表里没点名的冲突按它处置。
|
||||
default: ConflictChoice,
|
||||
/// 逐项决策(实例内相对路径 → 处置)。
|
||||
entries: BTreeMap<String, ConflictChoice>,
|
||||
}
|
||||
|
||||
impl Default for ConflictDecisions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
// **不许改**:这是"没收到决策不覆盖"的落点。
|
||||
default: ConflictChoice::KeepUser,
|
||||
entries: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ConflictDecisions {
|
||||
/// 全部保留(= 缺省)。
|
||||
#[must_use]
|
||||
pub fn keep_all() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// 全部覆盖(用户显式点了「整合包说了算」)。
|
||||
#[must_use]
|
||||
pub fn overwrite_all() -> Self {
|
||||
Self {
|
||||
default: ConflictChoice::Overwrite,
|
||||
entries: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 改整批缺省档(逐项决策优先于它)。
|
||||
#[must_use]
|
||||
pub fn with_default(mut self, choice: ConflictChoice) -> Self {
|
||||
self.default = choice;
|
||||
self
|
||||
}
|
||||
|
||||
/// 挂一条逐项决策(链式)。
|
||||
#[must_use]
|
||||
pub fn with(mut self, path: impl Into<String>, choice: ConflictChoice) -> Self {
|
||||
self.set(path, choice);
|
||||
self
|
||||
}
|
||||
|
||||
/// 挂一条逐项决策(就地)。
|
||||
pub fn set(&mut self, path: impl Into<String>, choice: ConflictChoice) {
|
||||
self.entries.insert(path.into(), choice);
|
||||
}
|
||||
|
||||
/// 整批缺省档。
|
||||
#[must_use]
|
||||
pub const fn default_choice(&self) -> ConflictChoice {
|
||||
self.default
|
||||
}
|
||||
|
||||
/// 这个路径的处置:逐项决策优先,其次整批档(**整批档缺省是保留**)。
|
||||
#[must_use]
|
||||
pub fn choice_for(&self, path: &str) -> ConflictChoice {
|
||||
self.entries.get(path).copied().unwrap_or(self.default)
|
||||
}
|
||||
|
||||
/// 这个路径有没有被逐项点名(诊断用:区分"用户选的"与"缺省档")。
|
||||
#[must_use]
|
||||
pub fn explicit(&self, path: &str) -> Option<ConflictChoice> {
|
||||
self.entries.get(path).copied()
|
||||
}
|
||||
|
||||
/// 逐项决策的条数(不含整批缺省档)。
|
||||
#[must_use]
|
||||
pub fn len(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
|
||||
/// 有没有逐项决策。
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.entries.is_empty()
|
||||
}
|
||||
|
||||
/// 逐项决策(按路径排序;进报告/日志用)。
|
||||
pub fn iter(&self) -> impl Iterator<Item = (&str, ConflictChoice)> {
|
||||
self.entries
|
||||
.iter()
|
||||
.map(|(path, choice)| (path.as_str(), *choice))
|
||||
}
|
||||
|
||||
/// 逐项决策里"覆盖"的条数。
|
||||
#[must_use]
|
||||
pub fn overwrite_count(&self) -> usize {
|
||||
self.entries
|
||||
.values()
|
||||
.filter(|choice| **choice == ConflictChoice::Overwrite)
|
||||
.count()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn missing_decision_never_overwrites() {
|
||||
// 这张表是"没收到决策就覆盖"的唯一防线:默认档与空表的答案都必须是保留。
|
||||
assert_eq!(ConflictChoice::default(), ConflictChoice::KeepUser);
|
||||
let empty = ConflictDecisions::default();
|
||||
assert_eq!(empty.choice_for("config/a.toml"), ConflictChoice::KeepUser);
|
||||
assert_eq!(
|
||||
ConflictDecisions::keep_all().default_choice(),
|
||||
ConflictChoice::KeepUser
|
||||
);
|
||||
assert!(empty.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn per_item_wins_over_the_batch_default() {
|
||||
let decisions =
|
||||
ConflictDecisions::overwrite_all().with("mods/pin.jar", ConflictChoice::KeepUser);
|
||||
assert_eq!(
|
||||
decisions.choice_for("mods/pin.jar"),
|
||||
ConflictChoice::KeepUser
|
||||
);
|
||||
assert_eq!(
|
||||
decisions.choice_for("mods/other.jar"),
|
||||
ConflictChoice::Overwrite
|
||||
);
|
||||
assert_eq!(
|
||||
decisions.explicit("mods/pin.jar"),
|
||||
Some(ConflictChoice::KeepUser)
|
||||
);
|
||||
assert_eq!(decisions.explicit("mods/other.jar"), None);
|
||||
assert_eq!(decisions.len(), 1);
|
||||
assert_eq!(decisions.overwrite_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_is_idempotent_per_path() {
|
||||
let mut decisions = ConflictDecisions::keep_all();
|
||||
decisions.set("a.toml", ConflictChoice::Overwrite);
|
||||
decisions.set("a.toml", ConflictChoice::KeepUser);
|
||||
assert_eq!(decisions.len(), 1);
|
||||
assert_eq!(decisions.choice_for("a.toml"), ConflictChoice::KeepUser);
|
||||
assert_eq!(decisions.overwrite_count(), 0);
|
||||
let paths: Vec<&str> = decisions.iter().map(|(path, _)| path).collect();
|
||||
assert_eq!(paths, vec!["a.toml"]);
|
||||
}
|
||||
}
|
||||
@@ -15,10 +15,10 @@
|
||||
//! | 有 | 有 | `D == B` | [`MergeAction::Install`] | 用户没动过 ⇒ 整合包升级,覆盖是对的 |
|
||||
//! | 有 | 有 | `D != B` | [`MergeAction::KeepUser`] + `UserModified` | 用户改过:**不许**静默覆盖 |
|
||||
//!
|
||||
//! 两条"不许"就是任务里的硬要求("不得直接盖掉用户改过的文件")。判据只此一处:
|
||||
//! `ImportRequest::conflict_policy = Overwrite` 时,`KeepUser` 才会被判据的调用方翻成
|
||||
//! [`MergeAction::Install`],**判据本身不读策略**——这样"哪些情形算冲突"与"冲突怎么处置"
|
||||
//! 是两个可独立验证的事实。
|
||||
//! 两条"不许"就是任务里的硬要求("不得直接盖掉用户改过的文件")。判据只此一处:决策
|
||||
//! (逐项 [`super::decision::ConflictDecisions`],或整批 [`super::ConflictPolicy`])为
|
||||
//! `Overwrite` 时,`KeepUser` 才会被判据的调用方翻成 [`MergeAction::Install`],**判据本身
|
||||
//! 不读决策**——这样"哪些情形算冲突"与"冲突怎么处置"是两个可独立验证的事实。
|
||||
//!
|
||||
//! # `.disabled` / `.old` 不参与判据
|
||||
//!
|
||||
|
||||
@@ -62,7 +62,9 @@ pub(crate) async fn extract_overrides(
|
||||
next.insert(entry.path.clone(), pack_sha1.clone());
|
||||
let disk = disk_sha1(&target.join(&entry.path))?;
|
||||
let decision = merge::decide(&pack_sha1, baseline.sha1(&entry.path), disk.as_deref());
|
||||
let (action, choice) = settle(decision, disk.is_some(), request.conflict_policy);
|
||||
// 逐项决策优先,其次整批档(缺省是"保留"):见 `decision::ConflictDecisions`。
|
||||
let picked = request.decisions.choice_for(&entry.path);
|
||||
let (action, choice) = settle(decision, disk.is_some(), picked);
|
||||
if let Some(reason) = decision.reason {
|
||||
report.conflicts.push(conflict(
|
||||
entry.path.clone(),
|
||||
@@ -121,7 +123,8 @@ pub(crate) async fn fetch_remote(
|
||||
// 有声明摘要就能先判合并:命中"保留用户 / 已一致"时**不必下载**(省一次网络)。
|
||||
if let Some(declared) = resolved.sha1.clone() {
|
||||
let decision = merge::decide(&declared, baseline.sha1(&resolved.path), disk.as_deref());
|
||||
let (action, choice) = settle(decision, disk.is_some(), request.conflict_policy);
|
||||
let picked = request.decisions.choice_for(&resolved.path);
|
||||
let (action, choice) = settle(decision, disk.is_some(), picked);
|
||||
if !action.wrote() {
|
||||
next.insert(resolved.path.clone(), declared.clone());
|
||||
if let Some(reason) = decision.reason {
|
||||
@@ -185,7 +188,8 @@ pub(crate) async fn fetch_remote(
|
||||
}
|
||||
}
|
||||
let decision = merge::decide(&pack_sha1, baseline.sha1(&resolved.path), disk.as_deref());
|
||||
let (action, choice) = settle(decision, disk.is_some(), request.conflict_policy);
|
||||
let picked = request.decisions.choice_for(&resolved.path);
|
||||
let (action, choice) = settle(decision, disk.is_some(), picked);
|
||||
if let Some(reason) = decision.reason {
|
||||
report.conflicts.push(conflict(
|
||||
resolved.path.clone(),
|
||||
@@ -276,13 +280,17 @@ async fn resolve(
|
||||
}))
|
||||
}
|
||||
|
||||
/// 合并判定 + 策略 → 实际动作。
|
||||
/// 合并判定 + 决策 → 实际动作。
|
||||
///
|
||||
/// `picked` 是这一次的处置(逐项决策或整批档,见 [`super::decision::ConflictDecisions`]):
|
||||
/// 它**只对冲突生效**——`Install` / `SkipIdentical` 两个非冲突结论不受它影响,所以"用户点错
|
||||
/// 一个保留"不会把一份全新文件漏掉。
|
||||
fn settle(
|
||||
decision: merge::MergeDecision,
|
||||
disk_exists: bool,
|
||||
policy: super::ConflictPolicy,
|
||||
picked: ConflictChoice,
|
||||
) -> (FileAction, ConflictChoice) {
|
||||
let overwrite = policy == super::ConflictPolicy::Overwrite;
|
||||
let overwrite = picked == ConflictChoice::Overwrite;
|
||||
match decision.action {
|
||||
MergeAction::Install => (
|
||||
if disk_exists {
|
||||
|
||||
@@ -49,7 +49,11 @@ pub async fn run(request: &ImportRequest<'_>) -> ImportReport {
|
||||
};
|
||||
account_plan(&mut report, &plan);
|
||||
|
||||
let baseline = load_baseline(request.target, &plan, &mut report);
|
||||
let baseline = {
|
||||
let (baseline, warnings) = load_baseline(request.target, &plan);
|
||||
report.warnings.extend(warnings);
|
||||
baseline
|
||||
};
|
||||
let staging = match Staging::begin(request.target) {
|
||||
Ok(staging) => staging,
|
||||
Err(error) => return abandoned(report, error),
|
||||
@@ -134,7 +138,12 @@ pub async fn run(request: &ImportRequest<'_>) -> ImportReport {
|
||||
}
|
||||
|
||||
/// 开包 + 探测 + 组计划(任何失败都是 `Failed`)。
|
||||
fn probe_and_plan(request: &ImportRequest<'_>) -> Result<(PackArchive, ImportPlan), ImportError> {
|
||||
///
|
||||
/// 预检([`super::scan`])走的是**同一段代码**:两边对"这包是什么、要装什么"必须完全一致,
|
||||
/// 否则"预检说没冲突、导入说有冲突"这种事就会发生。
|
||||
pub(super) fn probe_and_plan(
|
||||
request: &ImportRequest<'_>,
|
||||
) -> Result<(PackArchive, ImportPlan), ImportError> {
|
||||
// 路径穿越双检 + 符号链接 + 条数/体积限额都在 `open` 里一次做完(M110 的 `archive`)。
|
||||
let archive = PackArchive::open(request.pack, request.limits)?;
|
||||
let (format, manifest) = PackRegistry::builtin().read(&archive)?;
|
||||
@@ -159,17 +168,18 @@ fn account_plan(report: &mut ImportReport, plan: &ImportPlan) {
|
||||
///
|
||||
/// "属于别的包"要单独说一句:同一目录先后导入两个整合包时,若拿别人的基线当 base,用户改过
|
||||
/// 的文件会被判成"整合包升级"而**被静默覆盖**。所以身份不符时降级成"没有基线"并告警。
|
||||
fn load_baseline(
|
||||
///
|
||||
/// 返回的告警由调用方写进报告(预检也是同一批文案——两侧对"有没有基线"必须有同一个答案)。
|
||||
pub(super) fn load_baseline(
|
||||
target: &std::path::Path,
|
||||
plan: &ImportPlan,
|
||||
report: &mut ImportReport,
|
||||
) -> PackBaseline {
|
||||
) -> (PackBaseline, Vec<ImportIssue>) {
|
||||
match PackBaseline::load(target) {
|
||||
Ok(baseline) => {
|
||||
if baseline.matches_pack(&plan.format, &plan.manifest.name) {
|
||||
return baseline;
|
||||
return (baseline, Vec::new());
|
||||
}
|
||||
report.warnings.push(ImportIssue::new(
|
||||
let warning = ImportIssue::new(
|
||||
keys::NOTE_BASELINE_PACK_MISMATCH,
|
||||
format!(
|
||||
"基线属于另一个整合包({} / {:?}),本次按「没有基线」处理:磁盘上同名文件一律算用户已有内容",
|
||||
@@ -180,17 +190,17 @@ fn load_baseline(
|
||||
},
|
||||
baseline.pack_name
|
||||
),
|
||||
));
|
||||
PackBaseline::default()
|
||||
);
|
||||
(PackBaseline::default(), vec![warning])
|
||||
}
|
||||
Err(reason) => {
|
||||
report.warnings.push(ImportIssue::new(
|
||||
let warning = ImportIssue::new(
|
||||
keys::NOTE_BASELINE_UNREADABLE,
|
||||
format!(
|
||||
"基线读不出来,本次按「没有基线」处理(磁盘上同名文件一律算用户已有内容):{reason}"
|
||||
),
|
||||
));
|
||||
PackBaseline::default()
|
||||
);
|
||||
(PackBaseline::default(), vec![warning])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ impl ConflictReason {
|
||||
}
|
||||
}
|
||||
|
||||
/// 冲突实际采用的处置(由 [`super::super::ConflictPolicy`] 决定)。
|
||||
/// 冲突实际采用的处置(由 [`super::super::ConflictPolicy`] / [`super::super::ConflictDecisions`] 决定)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ConflictChoice {
|
||||
@@ -89,6 +89,13 @@ pub enum ConflictChoice {
|
||||
Overwrite,
|
||||
}
|
||||
|
||||
impl Default for ConflictChoice {
|
||||
/// **缺省是保留**:没有决策时绝不覆盖用户已有文件(M160 的硬线,见 `import::decision`)。
|
||||
fn default() -> Self {
|
||||
Self::KeepUser
|
||||
}
|
||||
}
|
||||
|
||||
/// 一条冲突("无法判定时给可选择的冲突清单"就是这张表)。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ConflictFile {
|
||||
|
||||
@@ -53,6 +53,8 @@ pub const NOTE_ENGINE_ARTIFACTS_DROPPED: &str = "pack.import.note.engine_artifac
|
||||
pub const NOTE_ROLLBACK_INCOMPLETE: &str = "pack.import.note.rollback_incomplete";
|
||||
/// 注记:同包另一条声明遮蔽了这个路径(说清谁赢、两份内容是否相同)。
|
||||
pub const NOTE_OVERRIDE_SHADOWED: &str = "pack.import.note.override_shadowed";
|
||||
/// 注记:这些远端条目补全前没有路径,预检无法预判它们会不会撞上用户已有文件。
|
||||
pub const NOTE_REMOTE_PATH_UNKNOWN: &str = "pack.import.note.remote_path_unknown";
|
||||
|
||||
/// 缺失:调用方没给 fetcher(离线导入)。
|
||||
pub const MISSING_OFFLINE: &str = "pack.import.missing.offline";
|
||||
@@ -97,6 +99,7 @@ pub const ALL: &[&str] = &[
|
||||
NOTE_ENGINE_ARTIFACTS_DROPPED,
|
||||
NOTE_ROLLBACK_INCOMPLETE,
|
||||
NOTE_OVERRIDE_SHADOWED,
|
||||
NOTE_REMOTE_PATH_UNKNOWN,
|
||||
MISSING_OFFLINE,
|
||||
MISSING_NO_SOURCE,
|
||||
MISSING_FETCH_FAILED,
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
//! 导入**预检**(M160):只读地算出「冲突清单」——**只列真正需要用户点头的文件**。
|
||||
//!
|
||||
//! # 什么进清单,什么不进(口径只此一处)
|
||||
//!
|
||||
//! 一个路径进清单**当且仅当**:目标实例里同名路径是一个普通文件,且它的内容与包内**不同**。
|
||||
//! 等值判据是 **SHA-1**(包内那条来自 M110 读包时现算的摘要,磁盘那条现算);体积只作展示,
|
||||
//! 不参与判定——同一份内容不可能有两个哈希,而"体积相同"并不说明内容相同。
|
||||
//!
|
||||
//! 不进清单的四种情形,每一种都有理由:
|
||||
//!
|
||||
//! | 情形 | 为什么不必打扰用户 |
|
||||
//! |------|--------------------|
|
||||
//! | 目标里没有这个文件 | 全新文件,装就是了 |
|
||||
//! | 内容与包内一致 | 已经是最新,写盘是浪费 |
|
||||
//! | 用户删过(基线有、磁盘没有) | 删是用户的决定,**不偷偷装回来**(`merge::decide` 的 `UserDeleted`) |
|
||||
//! | 包内被同包另一条声明遮蔽(`shadowed`) | 它不落地,用户看不到它,问了也无从选 |
|
||||
//!
|
||||
//! # 叠层优先级不在这层重判
|
||||
//!
|
||||
//! `client-overrides/` 胜 `overrides/`、`server-overrides/` 客户端不解压——这些**已由**
|
||||
//! [`super::plan::ImportPlan::build`] 与 `plan::layer::resolve` 算定(一路径一行,胜者才在
|
||||
//! `overrides` 表里且 `shadowed.is_none()`)。本模块只按胜者取 `source`,从不自己再排一次。
|
||||
//!
|
||||
//! # 远端条目的两种命运
|
||||
//!
|
||||
//! mrpack 的 `files[]` 自带 `path` + `hashes.sha1`,所以能在这里判;判的是"**声明的**摘要 vs
|
||||
//! 磁盘",与导入时"下载下来再校验"是同一份期望值。CurseForge 的 `files[]` 只有
|
||||
//! `projectID/fileID`,补全前**没有路径**,无从判起 ⇒ 不猜、不进清单,只把条数记进
|
||||
//! [`ImportScan::unknown_remote`](界面据此说"还有 N 条补全后才能知道会不会撞车")。
|
||||
//!
|
||||
//! # 便宜在哪、贵在哪
|
||||
//!
|
||||
//! 预检不读包内字节(摘要来自 M110 的读包结果),但要给每条冲突填"目标现有体积与哈希",
|
||||
//! 所以**磁盘上那些同名文件要真读一遍**。于是 `scan` + `import` 会把它们读两次——这是本轮
|
||||
//! 的已知代价(要省就得让导入直接吃预检的判定结果,那是另一件事,报告里登记)。
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::baseline::PackBaseline;
|
||||
use super::decision::ConflictDecisions;
|
||||
use super::error::ImportError;
|
||||
use super::merge;
|
||||
use super::pipeline::{load_baseline, probe_and_plan};
|
||||
use super::report::{keys, ConflictReason, FileSource, ImportIssue, LoaderTarget};
|
||||
use super::ImportRequest;
|
||||
use crate::download::verify;
|
||||
use crate::download::HashAlgo;
|
||||
|
||||
/// 一条**要用户点头**的冲突(目标里已有同名文件且内容不同)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ImportConflict {
|
||||
/// 实例内相对路径。
|
||||
pub path: String,
|
||||
/// 来源(包内 `overrides/` / `client-overrides/` / mrpack 或 CF 的 `files[]`)。
|
||||
pub source: FileSource,
|
||||
/// 成因(`PreExisting` = 没有基线可判;`UserModified` = 基线里记过、用户改过)。
|
||||
pub reason: ConflictReason,
|
||||
/// 包内那条是不是禁用态(`.disabled` 后缀原样保留)。
|
||||
pub disabled: bool,
|
||||
/// 包内声明的 SHA-1。
|
||||
pub pack_sha1: String,
|
||||
/// 包内声明的字节数。
|
||||
pub pack_size: u64,
|
||||
/// 目标现有文件的 SHA-1。
|
||||
pub disk_sha1: String,
|
||||
/// 目标现有文件的字节数。
|
||||
pub disk_size: u64,
|
||||
/// 上次导入写下的 SHA-1(没有基线时 `None`)。
|
||||
pub base_sha1: Option<String>,
|
||||
}
|
||||
|
||||
impl ImportConflict {
|
||||
/// 覆盖它会丢掉多少字节(界面上"覆盖会丢掉 12.4 MB"就是它)。
|
||||
#[must_use]
|
||||
pub const fn lost_bytes(&self) -> u64 {
|
||||
self.disk_size
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次预检的结果。
|
||||
///
|
||||
/// `error` 为 `Some` 时其余字段是"没算出来":探测/开包/组计划失败与导入管线同一条口径
|
||||
/// (同一个 [`PackError`] → [`ImportError`] 的映射),不去新造一套。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ImportScan {
|
||||
/// 目标实例目录。
|
||||
pub target: PathBuf,
|
||||
/// 命中的格式 id。
|
||||
pub format: Option<String>,
|
||||
/// 包名。
|
||||
pub pack_name: Option<String>,
|
||||
/// 包版本。
|
||||
pub pack_version: Option<String>,
|
||||
/// 游戏版本。
|
||||
pub game_version: Option<String>,
|
||||
/// 加载器计划输入(`None` = 纯原版)。
|
||||
pub loader: Option<LoaderTarget>,
|
||||
/// 冲突清单(按路径排序)。
|
||||
pub conflicts: Vec<ImportConflict>,
|
||||
/// 补全前没有路径、因而无法预判的远端条目数(CF `files[]` 的常态)。
|
||||
pub unknown_remote: usize,
|
||||
/// 注记(`.disabled` / 可选条目 / 基线口径 / 被遮蔽的条目……与导入报告同源)。
|
||||
pub notes: Vec<ImportIssue>,
|
||||
/// 开包或组计划失败(`Failed` 口径,目标目录一个字节都没动)。
|
||||
pub error: Option<ImportError>,
|
||||
}
|
||||
|
||||
impl ImportScan {
|
||||
fn empty(target: PathBuf) -> Self {
|
||||
Self {
|
||||
target,
|
||||
format: None,
|
||||
pack_name: None,
|
||||
pack_version: None,
|
||||
game_version: None,
|
||||
loader: None,
|
||||
conflicts: Vec::new(),
|
||||
unknown_remote: 0,
|
||||
notes: Vec::new(),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 预检成功了吗(`false` = 看 [`Self::error`])。
|
||||
#[must_use]
|
||||
pub const fn is_ok(&self) -> bool {
|
||||
self.error.is_none()
|
||||
}
|
||||
|
||||
/// 有没有需要用户点头的项(`false` = 可以直接导入,不必打扰用户)。
|
||||
#[must_use]
|
||||
pub fn needs_decision(&self) -> bool {
|
||||
!self.conflicts.is_empty()
|
||||
}
|
||||
|
||||
/// 「全部保留」的决策(= 缺省档,不覆盖任何用户文件)。
|
||||
#[must_use]
|
||||
pub fn keep_all(&self) -> ConflictDecisions {
|
||||
ConflictDecisions::keep_all()
|
||||
}
|
||||
|
||||
/// 「全部覆盖」的决策(用户显式要求"整合包说了算")。
|
||||
#[must_use]
|
||||
pub fn overwrite_all(&self) -> ConflictDecisions {
|
||||
ConflictDecisions::overwrite_all()
|
||||
}
|
||||
|
||||
/// 覆盖会丢掉的字节总量(界面上的计数摘要)。
|
||||
#[must_use]
|
||||
pub fn lost_bytes(&self) -> u64 {
|
||||
self.conflicts
|
||||
.iter()
|
||||
.map(ImportConflict::lost_bytes)
|
||||
.fold(0, u64::saturating_add)
|
||||
}
|
||||
|
||||
/// 包内那些条目声明了多大(= 覆盖后会占用的字节数)。
|
||||
#[must_use]
|
||||
pub fn incoming_bytes(&self) -> u64 {
|
||||
self.conflicts
|
||||
.iter()
|
||||
.map(|conflict| conflict.pack_size)
|
||||
.fold(0, u64::saturating_add)
|
||||
}
|
||||
}
|
||||
|
||||
/// 跑一次预检:**只读**(不建目录、不写暂存、不改基线)。
|
||||
#[must_use]
|
||||
pub fn scan_pack(request: &ImportRequest<'_>) -> ImportScan {
|
||||
let mut scan = ImportScan::empty(request.target.to_path_buf());
|
||||
let plan = match probe_and_plan(request) {
|
||||
Ok((_, plan)) => plan,
|
||||
Err(error) => {
|
||||
scan.error = Some(error);
|
||||
return scan;
|
||||
}
|
||||
};
|
||||
let (baseline, warnings) = load_baseline(request.target, &plan);
|
||||
scan.notes = warnings;
|
||||
scan.format = Some(plan.format.clone());
|
||||
scan.pack_name = Some(plan.manifest.name.clone());
|
||||
scan.pack_version = plan.manifest.version.clone();
|
||||
scan.game_version = Some(plan.mc_version.clone());
|
||||
scan.loader = plan.loader.clone();
|
||||
|
||||
// 一路径一行:同一路径被覆盖条目与远端条目同时声明时,只留一条(决策是按路径给的)。
|
||||
let mut seen: BTreeMap<String, ()> = BTreeMap::new();
|
||||
for entry in plan
|
||||
.overrides
|
||||
.iter()
|
||||
.filter(|entry| entry.shadowed.is_none())
|
||||
{
|
||||
let Some(conflict) = judge(
|
||||
&entry.path,
|
||||
entry.source,
|
||||
&entry.sha1,
|
||||
entry.size,
|
||||
entry.disabled,
|
||||
&baseline,
|
||||
request.target,
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
seen.insert(entry.path.clone(), ());
|
||||
scan.conflicts.push(conflict);
|
||||
}
|
||||
for entry in &plan.remote {
|
||||
let (Some(path), Some(declared)) = (entry.path.as_deref(), entry.file.sha1()) else {
|
||||
// 没有路径(CF 待补全)或没有声明摘要 ⇒ 判不了,如实计数。
|
||||
scan.unknown_remote += 1;
|
||||
continue;
|
||||
};
|
||||
if seen.contains_key(path) {
|
||||
continue;
|
||||
}
|
||||
let Some(conflict) = judge(
|
||||
path,
|
||||
entry.source,
|
||||
declared,
|
||||
entry.file.size.unwrap_or(0),
|
||||
entry.disabled,
|
||||
&baseline,
|
||||
request.target,
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
seen.insert(path.to_string(), ());
|
||||
scan.conflicts.push(conflict);
|
||||
}
|
||||
if scan.unknown_remote > 0 {
|
||||
scan.notes.push(ImportIssue::new(
|
||||
keys::NOTE_REMOTE_PATH_UNKNOWN,
|
||||
format!(
|
||||
"有 {} 条远端条目补全前没有路径(CurseForge files[]),预检判不出它们会不会撞上已有文件;导入时仍按「缺省保留」处置",
|
||||
scan.unknown_remote
|
||||
),
|
||||
));
|
||||
}
|
||||
scan.conflicts
|
||||
.sort_by(|left, right| left.path.cmp(&right.path));
|
||||
scan
|
||||
}
|
||||
|
||||
/// 判一个路径:`Some` = 要用户点头的冲突。
|
||||
fn judge(
|
||||
path: &str,
|
||||
source: FileSource,
|
||||
pack_sha1: &str,
|
||||
pack_size: u64,
|
||||
disabled: bool,
|
||||
baseline: &PackBaseline,
|
||||
target: &Path,
|
||||
) -> Option<ImportConflict> {
|
||||
let disk = disk_state(&target.join(path))?;
|
||||
// 包内那条的摘要来自 M110 读包(`override_files` 边读边算),与导入时现算的是同一份字节。
|
||||
if disk.sha1 == pack_sha1 {
|
||||
return None;
|
||||
}
|
||||
let base_sha1 = baseline.sha1(path).map(str::to_string);
|
||||
let decision = merge::decide(pack_sha1, base_sha1.as_deref(), Some(&disk.sha1));
|
||||
let reason = decision.reason?;
|
||||
Some(ImportConflict {
|
||||
path: path.to_string(),
|
||||
source,
|
||||
reason,
|
||||
disabled,
|
||||
pack_sha1: pack_sha1.to_string(),
|
||||
pack_size,
|
||||
disk_sha1: disk.sha1,
|
||||
disk_size: disk.size,
|
||||
base_sha1,
|
||||
})
|
||||
}
|
||||
|
||||
/// 磁盘现状:`None` = 那里没有普通文件(不存在或是目录)。
|
||||
struct DiskState {
|
||||
size: u64,
|
||||
sha1: String,
|
||||
}
|
||||
|
||||
fn disk_state(path: &Path) -> Option<DiskState> {
|
||||
let metadata = std::fs::metadata(path).ok()?;
|
||||
if !metadata.is_file() {
|
||||
return None;
|
||||
}
|
||||
let sha1 = verify::digest_file(path, HashAlgo::Sha1).ok()?;
|
||||
Some(DiskState {
|
||||
size: metadata.len(),
|
||||
sha1,
|
||||
})
|
||||
}
|
||||
@@ -31,9 +31,10 @@ pub use export::{
|
||||
RemoteHit,
|
||||
};
|
||||
pub use import::{
|
||||
import_pack, ConflictFile, ConflictPolicy, ImportError, ImportErrorKind, ImportProgress,
|
||||
ImportProgressSink, ImportReport, ImportRequest, ImportStage, ImportStatus, ImportedFile,
|
||||
MissingFile, PackBaseline, PackFetcher, RejectedComponent,
|
||||
import_pack, scan_pack, ConflictChoice, ConflictDecisions, ConflictFile, ConflictPolicy,
|
||||
ImportConflict, ImportError, ImportErrorKind, ImportProgress, ImportProgressSink, ImportReport,
|
||||
ImportRequest, ImportScan, ImportStage, ImportStatus, ImportedFile, MissingFile, PackBaseline,
|
||||
PackFetcher, RejectedComponent,
|
||||
};
|
||||
pub use loader::{
|
||||
LoaderError, LoaderErrorKind, LoaderInstallPlan, LoaderKind, LoaderPlanInput, LoaderRelease,
|
||||
@@ -44,5 +45,7 @@ pub use pack::{
|
||||
PackLoader, PackManifest, PackOverrides, PackRegistry, PackResult, PackSide,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod temp;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -60,7 +60,10 @@ pub mod path;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub use archive::{PackArchive, PackEntryInfo, PackLimits, ReadBudget};
|
||||
pub use archive::{
|
||||
PackArchive, PackEntryInfo, PackLimits, ReadBudget, PACK_MAX_ENTRIES, PACK_MAX_ENTRY_BYTES,
|
||||
PACK_MAX_UNCOMPRESSED_BYTES, PACK_ZIP64_BYTES_THR,
|
||||
};
|
||||
pub use curseforge::CurseForge;
|
||||
pub use error::{PackError, PackErrorKind, PackResult};
|
||||
pub use format::{PackFormat, PackMatch, PackPayload, PackRegistry};
|
||||
|
||||
@@ -50,7 +50,8 @@ mod open;
|
||||
pub use entry::PackEntryInfo;
|
||||
pub use limits::{
|
||||
PackLimits, ReadBudget, DEFAULT_MAX_ENTRIES, DEFAULT_MAX_ENTRY_BYTES,
|
||||
DEFAULT_MAX_UNCOMPRESSED_BYTES,
|
||||
DEFAULT_MAX_UNCOMPRESSED_BYTES, PACK_MAX_ENTRIES, PACK_MAX_ENTRY_BYTES,
|
||||
PACK_MAX_UNCOMPRESSED_BYTES, PACK_ZIP64_BYTES_THR,
|
||||
};
|
||||
|
||||
use limits::read_capped;
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
//! 读取限额:条目数 / 解压总体积 / 单条目,以及两处封顶实现。
|
||||
//!
|
||||
//! # 只此一份口径(导入读取 **与** 导出写入共用)
|
||||
//!
|
||||
//! [`PACK_MAX_ENTRIES`] / [`PACK_MAX_UNCOMPRESSED_BYTES`] / [`PACK_MAX_ENTRY_BYTES`] 是**唯一**
|
||||
//! 的体积口径:读侧 [`PackLimits::default`] 从这里取,写侧 `pack::format::write` 的预检也从
|
||||
//! 这里取(以前写侧直接拿 `zip::ZIP64_BYTES_THR`,于是"导得出来、导不回去"——导出允许 ~4 GiB,
|
||||
//! 导入只允许 512 MiB)。现在的关系是硬约束:
|
||||
//!
|
||||
//! ```text
|
||||
//! PACK_MAX_ENTRY_BYTES ≤ PACK_MAX_UNCOMPRESSED_BYTES < PACK_ZIP64_BYTES_THR ≤ u32::MAX
|
||||
//! ```
|
||||
//!
|
||||
//! 所以「我们能写出的包一定能被自己读回来」,而 ZIP64 阈值退化成一条**不会再被触碰的**
|
||||
//! 物理上限(`tests/limits.rs` 钉住这条不等式,也钉住两侧用的是同一批常数)。
|
||||
//!
|
||||
//! 为什么不让写侧跟着 ZIP64 放宽:读侧把单条目整条读进内存([`read_capped`]),64 MiB 是**内存**
|
||||
//! 护栏;放宽它等于用一个看不见的内存峰值换一个几乎用不到的大包能力。
|
||||
//!
|
||||
//! # 两阶段限额(打开 vs 读取)
|
||||
//!
|
||||
//! 打开阶段([`check_limits`])只能拿**中央目录声明的大小**做判断——它会撒谎。
|
||||
@@ -13,12 +30,24 @@ use std::io::Read;
|
||||
|
||||
use super::super::error::{PackError, PackResult};
|
||||
|
||||
/// 默认条目数上限(一个巨型整合包 ≈ 数千条,2 万条已经很宽)。
|
||||
pub const DEFAULT_MAX_ENTRIES: usize = 20_000;
|
||||
/// 默认解压总体积上限(512 MiB;`.mrpack` 的 `overrides` 通常几十 MiB)。
|
||||
pub const DEFAULT_MAX_UNCOMPRESSED_BYTES: u64 = 512 * 1024 * 1024;
|
||||
/// 默认单条目上限(64 MiB;最大的 mod 也不过十几 MiB)。
|
||||
pub const DEFAULT_MAX_ENTRY_BYTES: u64 = 64 * 1024 * 1024;
|
||||
/// 条目数上限(一个巨型整合包 ≈ 数千条,2 万条已经很宽)。
|
||||
pub const PACK_MAX_ENTRIES: usize = 20_000;
|
||||
/// 解压/打包总体积上限(512 MiB;`.mrpack` 的 `overrides` 通常几十 MiB)。
|
||||
pub const PACK_MAX_UNCOMPRESSED_BYTES: u64 = 512 * 1024 * 1024;
|
||||
/// 单条目上限(64 MiB;同时是读侧的**内存**护栏与写侧的单文件护栏)。
|
||||
pub const PACK_MAX_ENTRY_BYTES: u64 = 64 * 1024 * 1024;
|
||||
/// ZIP64 的物理阈值(`zip` crate 的口径,≈4 GiB)。
|
||||
///
|
||||
/// 生产路径**不再**用它当判据:PACK_MAX_UNCOMPRESSED_BYTES 远低于它(0.5 GiB ≪ 4 GiB),
|
||||
/// 所以 `tests/limits.rs` 只需钉住"统一上限 < 本阈值",ZIP64 就永远不会被需要。
|
||||
pub const PACK_ZIP64_BYTES_THR: u64 = zip::ZIP64_BYTES_THR;
|
||||
|
||||
/// 默认条目数上限(= [`PACK_MAX_ENTRIES`],保留旧名以免破坏既有调用点)。
|
||||
pub const DEFAULT_MAX_ENTRIES: usize = PACK_MAX_ENTRIES;
|
||||
/// 默认解压总体积上限(= [`PACK_MAX_UNCOMPRESSED_BYTES`])。
|
||||
pub const DEFAULT_MAX_UNCOMPRESSED_BYTES: u64 = PACK_MAX_UNCOMPRESSED_BYTES;
|
||||
/// 默认单条目上限(= [`PACK_MAX_ENTRY_BYTES`])。
|
||||
pub const DEFAULT_MAX_ENTRY_BYTES: u64 = PACK_MAX_ENTRY_BYTES;
|
||||
|
||||
/// 包读取限额。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
|
||||
@@ -155,7 +155,8 @@ pub trait PackFormat: Debug + Send + Sync {
|
||||
manifest,
|
||||
payload,
|
||||
&temp,
|
||||
zip::ZIP64_BYTES_THR,
|
||||
// 生产口径只有一处来源:导入读取用的也是这两个数(见 `archive::limits`)。
|
||||
write::WriteLimits::production(),
|
||||
) {
|
||||
Ok(()) => std::fs::rename(&temp, target).map_err(|error| {
|
||||
let _ = std::fs::remove_file(&temp);
|
||||
|
||||
@@ -21,11 +21,16 @@
|
||||
//! 后来修对了,误拒也发生在把几个 GiB 读进内存之后。流式之后第二道闸看的是**已写字节数**,
|
||||
//! 与内存占用无关。
|
||||
//!
|
||||
//! # 阈值与声明口径
|
||||
//! # 阈值与声明口径(**与导入读取同一份常量**)
|
||||
//!
|
||||
//! 判据是**未压缩声明的总量**(清单 JSON + 每个覆盖文件的字节数)≥ `zip::ZIP64_BYTES_THR`
|
||||
//! (≈4 GiB)。zip 条目头与中央目录的几十字节开销不计——这个常数本身就是近似边界,
|
||||
//! 官方实现同样按这个粗粒度判。
|
||||
//! 判据是**未压缩声明的总量**(清单 JSON + 每个覆盖文件的字节数)**与单条目体积**,两个数
|
||||
//! 都来自 `pack::archive::limits`([`WriteLimits::production`]):以前写侧直接拿
|
||||
//! `zip::ZIP64_BYTES_THR`(≈4 GiB),导入读取只允许 512 MiB ⇒「导得出来、导不回去」。现在
|
||||
//! 只此一份口径,关系是
|
||||
//! `PACK_MAX_ENTRY_BYTES ≤ PACK_MAX_UNCOMPRESSED_BYTES < PACK_ZIP64_BYTES_THR`,
|
||||
//! 所以 ZIP64 阈值永远不会被触碰(`pack/tests/limits.rs` 钉住这条不等式)。
|
||||
//! zip 条目头与中央目录的几十字节开销不计——这个常数本身就是近似边界,官方实现同样按这个
|
||||
//! 粗粒度判。
|
||||
|
||||
use std::fs::File;
|
||||
use std::io::{Read, Write};
|
||||
@@ -57,12 +62,12 @@ pub(super) fn container(
|
||||
manifest: &PackManifest,
|
||||
payload: PackPayload<'_>,
|
||||
temp: &Path,
|
||||
limit: u64,
|
||||
limits: WriteLimits,
|
||||
) -> PackResult<()> {
|
||||
// 先判总量再碰磁盘:超限时临时文件都不建,更不会读进一份 4 GiB 的内容。
|
||||
// 先判总量再碰磁盘:超限时临时文件都不建,更不会读进一份几百 MiB 的内容。
|
||||
// **返回值只属于预检**:写循环有自己的计数器(`Written`),拿它当基数会把全量声明尺寸
|
||||
// 再累加一遍 ⇒ 过拒窗口(P2-1)。
|
||||
let _ = preflight(json.len() as u64, manifest, payload, limit)?;
|
||||
let _ = preflight(json.len() as u64, manifest, payload, limits)?;
|
||||
|
||||
let file = File::create(temp)
|
||||
.map_err(|error| PackError::io(format!("创建 {} 失败:{error}", temp.display())))?;
|
||||
@@ -78,7 +83,7 @@ pub(super) fn container(
|
||||
.map_err(|error| PackError::io(format!("写 {entry} 失败:{error}")))?;
|
||||
|
||||
// 第二道闸的计数器:**从清单长度重新起算**,与预检的结果无关(见 `Written`)。
|
||||
let mut written = Written::new(json.len() as u64, limit);
|
||||
let mut written = Written::new(json.len() as u64, limits.total);
|
||||
let mut buffer = vec![0_u8; COPY_CHUNK];
|
||||
for group in &manifest.overrides {
|
||||
let dir = normalize_dir(&group.dir)?;
|
||||
@@ -116,15 +121,18 @@ pub(super) fn container(
|
||||
///
|
||||
/// 返回值是"清单 + 全部覆盖文件的声明总量",**只给测试与诊断看**。写循环有自己的计数器
|
||||
/// (从清单长度起算)——曾经把它直接当累加基数,结果每个文件的字节被算两遍,真实总量过半
|
||||
/// 阈值(~2.15 GiB)就被误拒(P2-1,已有 2.5 GB 的端到端用例钉住这个窗口)。
|
||||
/// 阈值就被误拒(P2-1,已有端到端用例钉住这个窗口)。
|
||||
///
|
||||
/// 判据顺序是**先总量、后单条**:总量超限时给出的那句"包太大"更贴用户的实际处境
|
||||
/// ("把这个文件移出实例"只对单条超限才有意义)。
|
||||
fn preflight(
|
||||
json_len: u64,
|
||||
manifest: &PackManifest,
|
||||
payload: PackPayload<'_>,
|
||||
limit: u64,
|
||||
limits: WriteLimits,
|
||||
) -> PackResult<u64> {
|
||||
let mut total = json_len;
|
||||
guard_with(total, limit)?;
|
||||
let mut entries: Vec<(String, u64)> = Vec::new();
|
||||
for group in &manifest.overrides {
|
||||
let dir = normalize_dir(&group.dir)?;
|
||||
for override_file in &group.files {
|
||||
@@ -138,12 +146,55 @@ fn preflight(
|
||||
})?
|
||||
.len();
|
||||
total += length;
|
||||
guard_with(total, limit)?;
|
||||
entries.push((format!("{dir}/{path}"), length));
|
||||
}
|
||||
}
|
||||
guard_with(total, limits.total)?;
|
||||
for (name, length) in entries {
|
||||
guard_entry(&name, length, limits.entry)?;
|
||||
}
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
/// 写侧的限额口径(**默认值直接取读侧那三个常量**,见 `archive::limits` 的模块文档)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) struct WriteLimits {
|
||||
/// 未压缩总量上限。
|
||||
pub(super) total: u64,
|
||||
/// 单条目上限。
|
||||
pub(super) entry: u64,
|
||||
}
|
||||
|
||||
impl WriteLimits {
|
||||
/// 生产口径:与 [`PackLimits::default`](crate::addon::pack::PackLimits) 同一个数。
|
||||
pub(super) const fn production() -> Self {
|
||||
Self {
|
||||
total: super::super::archive::limits::PACK_MAX_UNCOMPRESSED_BYTES,
|
||||
entry: super::super::archive::limits::PACK_MAX_ENTRY_BYTES,
|
||||
}
|
||||
}
|
||||
|
||||
/// 显式给值(测试用:把边界缩小到可测尺寸)。
|
||||
#[cfg(test)]
|
||||
pub(super) const fn new(total: u64, entry: u64) -> Self {
|
||||
Self { total, entry }
|
||||
}
|
||||
}
|
||||
|
||||
/// 单条目闸:与读侧的 `PACK_MAX_ENTRY_BYTES` **同一份口径**。
|
||||
///
|
||||
/// 没有这道闸时,"导出 100 MiB 的覆盖文件 → 自己的导入器拒读"是能真实发生的(读侧把一条
|
||||
/// 内容整块读进内存,所以有单条上限)。写侧照着读侧的上限拒,才不会产出自己读不回来的包。
|
||||
fn guard_entry(name: &str, length: u64, entry_limit: u64) -> PackResult<()> {
|
||||
if length > entry_limit {
|
||||
return Err(PackError::limits(format!(
|
||||
"覆盖条目 {name} 有 {length} 字节,超过单条目统一上限 {entry_limit}(导入读取用的也是这个数)"
|
||||
))
|
||||
.with_hint("把该文件移出实例(或换一个不需要内联它的内容目录)后再导出"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 单条覆盖文件的来源:来源表优先(跨格式导出用),否则按 `<root>/<dir>/<path>` 拼。
|
||||
fn source_for(payload: PackPayload<'_>, dir: &str, path: &str) -> PackResult<PathBuf> {
|
||||
let name = format!("{dir}/{path}");
|
||||
@@ -183,18 +234,17 @@ impl Written {
|
||||
}
|
||||
}
|
||||
|
||||
/// ZIP64 阈值闸(`pack_limits`):`total` **达到**阈值就拒(`>=` 不是 `>`,官方实现也是
|
||||
/// "撞上就不写")。
|
||||
/// 总量闸(`pack_limits`):`total` **达到**上限就拒(`>=` 不是 `>`,撞上就不写)。
|
||||
///
|
||||
/// 生产路径的 `limit` 恒为 `zip::ZIP64_BYTES_THR`(由 `format.rs` 传下来);把它做成参数是为了
|
||||
/// 能用几十字节的小数值钉住"边界在 `>=` 上"和"写循环不许重复累计"这两件事——真造 4 GiB 的
|
||||
/// 包要几十秒 CPU 与一大片内存/页面文件,本机资源纪律下不经默认套件跑(塔台 2026-11-07)。
|
||||
/// 生产路径的 `limit` 来自 [`WriteLimits::production`](= 读侧 `PACK_MAX_UNCOMPRESSED_BYTES`);
|
||||
/// 把它做成参数是为了能用几十字节的小数值钉住"边界在 `>=` 上"和"写循环不许重复累计"这两件事。
|
||||
fn guard_with(total: u64, limit: u64) -> PackResult<()> {
|
||||
if total >= limit {
|
||||
return Err(PackError::limits(format!(
|
||||
"包体未压缩总量已超过 {limit} 字节(ZIP64 阈值),拒绝写出一个需要 ZIP64 的整合包"
|
||||
"包体未压缩总量超过统一上限 {limit} 字节(ZIP64 阈值 {} 远在其上,所以永远不需要写 ZIP64)",
|
||||
super::super::archive::limits::PACK_ZIP64_BYTES_THR
|
||||
))
|
||||
.with_hint("把超大的覆盖文件移出实例后再导出;mrpack/CF 都不接受 ZIP64"));
|
||||
.with_hint("把超大的覆盖文件移出实例后再导出;导入读取用的是同一个上限"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -219,22 +269,19 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn the_second_gate_counts_from_the_manifest_length_not_the_preflight_total() {
|
||||
// P2-1 的窗口:真实总量在 [阈值一半, 阈值) 之间的包**不许**被拒。这一条不真写
|
||||
// 2.5 GB(那要 ~70 秒 deflate 与一大片内存/页面文件),而是直接把窗口里的字节数
|
||||
// P2-1 的窗口:真实总量在 [上限一半, 上限) 之间的包**不许**被拒。这一条不真写
|
||||
// 几百 MiB(那要几十秒 deflate 与一大片内存/页面文件),而是直接把窗口里的字节数
|
||||
// 喂给第二道闸的计数器——它的起点是清单长度,所以只有"把预检的全量 total 当基数"
|
||||
// 那种写法才会在这里红(红法是 `spend` 返回 Limits)。
|
||||
let half = zip::ZIP64_BYTES_THR / 2;
|
||||
let mut written = Written::new(28, zip::ZIP64_BYTES_THR);
|
||||
written
|
||||
.spend(2_500_000_000)
|
||||
.expect("2.5 GB 没到 4 GiB,第二道闸不许拒");
|
||||
assert!(2_500_000_000 > half, "样本要落在过拒窗口里");
|
||||
let limit = WriteLimits::production().total;
|
||||
let inside = limit / 2 + limit / 4;
|
||||
let mut written = Written::new(28, limit);
|
||||
written.spend(inside).expect("没到统一上限,第二道闸不许拒");
|
||||
assert!(inside > limit / 2, "样本要落在过拒窗口里");
|
||||
|
||||
// 对照:撞上阈值必须拒,且正好在阈值处(不是超一点点才拒)。
|
||||
let mut written = Written::new(28, zip::ZIP64_BYTES_THR);
|
||||
let error = written
|
||||
.spend(zip::ZIP64_BYTES_THR)
|
||||
.expect_err("撞阈值必须拒");
|
||||
// 对照:撞上上限必须拒,且正好在上限处(不是超一点点才拒)。
|
||||
let mut written = Written::new(28, limit);
|
||||
let error = written.spend(limit).expect_err("撞上限必须拒");
|
||||
assert_eq!(
|
||||
error.kind(),
|
||||
super::super::super::error::PackErrorKind::Limits
|
||||
@@ -275,7 +322,7 @@ mod tests {
|
||||
&manifest,
|
||||
PackPayload::from_root(&root),
|
||||
&target,
|
||||
limit,
|
||||
WriteLimits::new(limit, 1024),
|
||||
)
|
||||
.expect("声明总量没到 limit:写循环不许把预检的 total 重复累计");
|
||||
|
||||
@@ -288,7 +335,7 @@ mod tests {
|
||||
&manifest,
|
||||
PackPayload::from_root(&root),
|
||||
&refused,
|
||||
declared,
|
||||
WriteLimits::new(declared, 1024),
|
||||
)
|
||||
.expect_err("声明总量达到 limit 必须拒");
|
||||
assert_eq!(
|
||||
@@ -333,9 +380,13 @@ mod tests {
|
||||
7,
|
||||
&manifest,
|
||||
PackPayload::from_root(&root),
|
||||
zip::ZIP64_BYTES_THR,
|
||||
// 本条只关心"预检不读内容",所以两道闸都放到最宽(生产口径见 `tests/limits.rs`)。
|
||||
WriteLimits::new(
|
||||
crate::addon::pack::archive::limits::PACK_ZIP64_BYTES_THR,
|
||||
crate::addon::pack::archive::limits::PACK_ZIP64_BYTES_THR,
|
||||
),
|
||||
)
|
||||
.expect("3 GiB 总量仍在阈值之下,预检不许误伤");
|
||||
.expect("3 GiB 总量仍在最宽闸之下,预检不许误伤");
|
||||
assert_eq!(
|
||||
total,
|
||||
7 + 10 + 3 * 1024 * 1024 * 1024,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! 2. 同一份包命中多格式时选谁(含 `also_matched` 记录的其它命中);
|
||||
//! 3. **换一下注册顺序,赢家跟着换**——这才证明优先级来自注册表,而不是代码里散落的 if。
|
||||
|
||||
use super::{fixtures, temp_root, write_zip};
|
||||
use super::{fixtures, write_zip, TempRoot};
|
||||
use crate::addon::pack::archive::PackArchive;
|
||||
use crate::addon::pack::curseforge::CURSEFORGE;
|
||||
use crate::addon::pack::error::PackErrorKind;
|
||||
@@ -23,8 +23,9 @@ const MMC_PACK: &[u8] =
|
||||
br#"{"formatVersion":1,"components":[{"uid":"net.minecraft","version":"1.20.1"}]}"#;
|
||||
|
||||
/// 同时带三种身份标记的"不标准包"。
|
||||
fn hybrid_pack(tag: &str) -> std::path::PathBuf {
|
||||
let target = temp_root(tag).join("hybrid.zip");
|
||||
fn hybrid_pack(tag: &str) -> TempRoot {
|
||||
// 返回的是**守卫**(Deref 到 `hybrid.zip`):包文件活到调用点用完为止。
|
||||
let target = TempRoot::at(tag, "hybrid.zip");
|
||||
write_zip(
|
||||
&target,
|
||||
&[
|
||||
@@ -48,7 +49,8 @@ fn builtin_priority_is_pinned() {
|
||||
|
||||
#[test]
|
||||
fn curseforge_wins_when_a_pack_carries_every_marker() {
|
||||
let pack = PackArchive::open_default(hybrid_pack("detect-hybrid")).expect("打开混合包");
|
||||
let hybrid = hybrid_pack("detect-hybrid");
|
||||
let pack = PackArchive::open_default(&hybrid).expect("打开混合包");
|
||||
let matched = PackRegistry::builtin().detect(&pack).expect("要能选出格式");
|
||||
assert_eq!(matched.format().id(), "curseforge");
|
||||
assert_eq!(
|
||||
@@ -62,7 +64,8 @@ fn curseforge_wins_when_a_pack_carries_every_marker() {
|
||||
#[test]
|
||||
fn a_plain_manifest_json_does_not_claim_curseforge() {
|
||||
// `manifest.json` 这名字太泛:形状不符就不能认领,否则 mrpack 会被抢走。
|
||||
let target = temp_root("detect-plain").join("plain.zip");
|
||||
let root = TempRoot::new("detect-plain");
|
||||
let target = root.join("plain.zip");
|
||||
write_zip(
|
||||
&target,
|
||||
&[
|
||||
@@ -79,7 +82,8 @@ fn a_plain_manifest_json_does_not_claim_curseforge() {
|
||||
#[test]
|
||||
fn registration_order_decides_the_winner() {
|
||||
// 同一份包同时有 CF 与 mrpack 标记:只换注册顺序,赢家就该换。
|
||||
let target = temp_root("detect-order").join("both.zip");
|
||||
let root = TempRoot::new("detect-order");
|
||||
let target = root.join("both.zip");
|
||||
write_zip(
|
||||
&target,
|
||||
&[
|
||||
@@ -107,7 +111,8 @@ fn registration_order_decides_the_winner() {
|
||||
|
||||
#[test]
|
||||
fn unknown_package_is_rejected_with_the_list_of_tried_formats() {
|
||||
let target = temp_root("detect-unknown").join("unknown.zip");
|
||||
let root = TempRoot::new("detect-unknown");
|
||||
let target = root.join("unknown.zip");
|
||||
write_zip(&target, &[("readme.txt", &b"nothing to see"[..])]);
|
||||
let pack = PackArchive::open_default(&target).expect("打开包");
|
||||
assert!(PackRegistry::builtin().detect_all(&pack).is_empty());
|
||||
@@ -126,7 +131,8 @@ fn unknown_package_is_rejected_with_the_list_of_tried_formats() {
|
||||
|
||||
#[test]
|
||||
fn multimc_needs_a_non_empty_component_graph() {
|
||||
let target = temp_root("detect-mmc-empty").join("empty-mmc.zip");
|
||||
let root = TempRoot::new("detect-mmc-empty");
|
||||
let target = root.join("empty-mmc.zip");
|
||||
write_zip(
|
||||
&target,
|
||||
&[(
|
||||
@@ -165,7 +171,8 @@ fn a_corrupt_manifest_is_a_metadata_error_not_unsupported() {
|
||||
// r1 p3-2:清单在、但读不出来 = 包坏了(`pack_metadata`)。之前三个 detect 都用
|
||||
// `.ok().flatten()` 吞掉解析错误,结果被报成「没有格式认领」——M111 的失败分级会据此
|
||||
// 把"可以重下文件"的包判成"不支持"。
|
||||
let target = temp_root("corrupt-manifest").join("bad.zip");
|
||||
let root = TempRoot::new("corrupt-manifest");
|
||||
let target = root.join("bad.zip");
|
||||
write_zip(&target, &[("manifest.json", &br#"{"name": "#[..])]);
|
||||
let pack = PackArchive::open_default(&target).expect("zip 本身可读");
|
||||
|
||||
@@ -183,7 +190,8 @@ fn a_corrupt_manifest_is_a_metadata_error_not_unsupported() {
|
||||
#[test]
|
||||
fn a_corrupt_foreign_manifest_does_not_hide_a_valid_mrpack() {
|
||||
// 命中优先于失败:包里躺着一份别人的坏 manifest.json,不该让真 mrpack 也变得不可用。
|
||||
let target = temp_root("corrupt-foreign").join("mixed.zip");
|
||||
let root = TempRoot::new("corrupt-foreign");
|
||||
let target = root.join("mixed.zip");
|
||||
write_zip(
|
||||
&target,
|
||||
&[
|
||||
@@ -202,7 +210,8 @@ fn a_corrupt_foreign_manifest_does_not_hide_a_valid_mrpack() {
|
||||
#[test]
|
||||
fn a_missing_manifest_is_not_an_error() {
|
||||
// 反向对照:**没有** `manifest.json` 是"不是 CF",不是"包坏了"。
|
||||
let target = temp_root("no-manifest").join("mmc.zip");
|
||||
let root = TempRoot::new("no-manifest");
|
||||
let target = root.join("mmc.zip");
|
||||
write_zip(
|
||||
&target,
|
||||
&[(
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//!
|
||||
//! 这一组不测"映射是否做对"(那是 M112),只测"表达不了的时候会不会静默产出空包"。
|
||||
|
||||
use super::{fixtures, temp_root};
|
||||
use super::{fixtures, TempRoot};
|
||||
use crate::addon::pack::archive::PackArchive;
|
||||
use crate::addon::pack::curseforge::CURSEFORGE;
|
||||
use crate::addon::pack::error::PackErrorKind;
|
||||
@@ -67,7 +67,8 @@ fn curseforge_refuses_more_than_one_both_side_group() {
|
||||
.overrides
|
||||
.push(PackOverrides::new("extra-overrides", PackSide::Both));
|
||||
|
||||
let target = temp_root("export-cf-two").join("out.zip");
|
||||
let root = TempRoot::new("export-cf-two");
|
||||
let target = root.join("out.zip");
|
||||
let error = CURSEFORGE
|
||||
.write(&manifest, PackPayload::metadata_only(), &target)
|
||||
.expect_err("多出来的 Both 组会被清单漏掉,必须拒");
|
||||
@@ -90,7 +91,7 @@ fn a_failed_export_leaves_neither_a_half_pack_nor_a_temp_file() {
|
||||
}],
|
||||
});
|
||||
|
||||
let root = temp_root("export-half");
|
||||
let root = TempRoot::new("export-half");
|
||||
std::fs::create_dir_all(&root).expect("建目录");
|
||||
let target = root.join("out.zip");
|
||||
let error = CURSEFORGE
|
||||
@@ -142,7 +143,8 @@ fn mrpack_refuses_two_override_groups_for_the_same_side() {
|
||||
.overrides
|
||||
.push(PackOverrides::new("overrides", PackSide::Both));
|
||||
|
||||
let target = temp_root("export-mr-same-side").join("out.mrpack");
|
||||
let root = TempRoot::new("export-mr-same-side");
|
||||
let target = root.join("out.mrpack");
|
||||
let error = MODRINTH
|
||||
.write(&manifest, PackPayload::metadata_only(), &target)
|
||||
.expect_err("同一侧两个覆盖目录必须拒");
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::Path;
|
||||
|
||||
use super::{temp_root, write_file};
|
||||
use super::{write_file, TempRoot};
|
||||
use crate::addon::export::catalog::{RemoteCatalog, RemoteHit};
|
||||
use crate::addon::export::scan::{scan_instance, DEFAULT_CONTENT_ROOTS};
|
||||
use crate::addon::pack::curseforge::fingerprint;
|
||||
@@ -46,8 +46,9 @@ pub(super) fn write_layer(game: &Path, id: &str, json: &str) {
|
||||
}
|
||||
|
||||
/// 一个 Fabric 实例目录:两个 mod(其中一个禁用)+ 一份 config + 一份白名单外的日志。
|
||||
pub(super) fn fabric_instance(tag: &str) -> PathBuf {
|
||||
let root = temp_root(tag).join("game");
|
||||
pub(super) fn fabric_instance(tag: &str) -> TempRoot {
|
||||
// 返回守卫本身(`Deref` 到 `<root>/game`):实例目录活到调用点用完为止,drop 即删。
|
||||
let root = TempRoot::under(tag, "game");
|
||||
write_layer(&root, "1.20.1", VANILLA_LAYER);
|
||||
write_layer(&root, "1.20.1-fabric-0.15.11", FABRIC_LAYER);
|
||||
write_file(&root.join("mods/jei.jar"), b"PK\x03\x04 jei body");
|
||||
@@ -58,8 +59,8 @@ pub(super) fn fabric_instance(tag: &str) -> PathBuf {
|
||||
}
|
||||
|
||||
/// 一个 NeoForge 1.20.1 实例目录(CF `modLoaders[].id` 的 MC 前缀特例用它)。
|
||||
pub(super) fn neoforge_instance(tag: &str) -> PathBuf {
|
||||
let root = temp_root(tag).join("game");
|
||||
pub(super) fn neoforge_instance(tag: &str) -> TempRoot {
|
||||
let root = TempRoot::under(tag, "game");
|
||||
write_layer(&root, "1.20.1", VANILLA_LAYER);
|
||||
write_layer(&root, "1.20.1-neoforge-47.1.106", NEOFORGE_LAYER);
|
||||
write_file(&root.join("mods/jei.jar"), b"PK\x03\x04 jei body");
|
||||
|
||||
@@ -10,7 +10,7 @@ use std::path::Path;
|
||||
use super::export_harness::{
|
||||
defaults, digests, fabric_instance, modrinth_hit, neoforge_instance, StubCatalog,
|
||||
};
|
||||
use super::{temp_root, write_file};
|
||||
use super::{write_file, TempRoot};
|
||||
use crate::addon::export::classify::classify_with;
|
||||
use crate::addon::export::scan::{is_disabled_path, scan_instance};
|
||||
use crate::addon::export::{export_instance, ExportRequest, FileClass, FileReason, RemoteHit};
|
||||
@@ -300,7 +300,7 @@ fn a_disabled_file_is_still_disabled_after_a_round_trip_through_the_package() {
|
||||
let target = root.with_file_name("disabled.mrpack");
|
||||
export_instance(&ExportRequest::new(&root, &target, &MODRINTH)).expect("导出");
|
||||
|
||||
let again = temp_root("export-disabled-reimport");
|
||||
let again = TempRoot::new("export-disabled-reimport");
|
||||
let file = std::fs::File::open(&target).expect("打开包");
|
||||
let mut archive = zip::ZipArchive::new(file).expect("读 zip");
|
||||
for index in 0..archive.len() {
|
||||
|
||||
@@ -17,12 +17,12 @@
|
||||
//! | mrpack → mrpack | `files[]` 的条目要靠"文件真的在磁盘上"才会再写回去 | 读出来的模型里那两条是远程条目、本地没有字节;再导出时以磁盘为准 |
|
||||
//! | MultiMC → mrpack | 非加载器组件(`org.lwjgl3`)不进 `dependencies` | 它不是加载器;mrpack 的 `dependencies` 只表达 loader |
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::Path;
|
||||
|
||||
use super::export_harness::{
|
||||
defaults, modrinth_hit, write_layer, StubCatalog, FORGE_LAYER, VANILLA_LAYER,
|
||||
};
|
||||
use super::{fixtures, temp_root, write_file};
|
||||
use super::{fixtures, write_file, TempRoot};
|
||||
use crate::addon::export::snapshot::{InstanceSnapshot, SnapshotLoader};
|
||||
use crate::addon::export::{export_instance, ExportRequest};
|
||||
use crate::addon::pack::archive::PackArchive;
|
||||
@@ -34,8 +34,8 @@ use crate::addon::pack::multimc::MULTIMC;
|
||||
use crate::addon::LoaderKind;
|
||||
|
||||
/// 把夹具里的覆盖内容铺成一个实例目录(`<game>/<path>`),并写一份版本 JSON。
|
||||
fn instance_from(fixture_dir: &Path, manifest: &PackManifest, tag: &str) -> PathBuf {
|
||||
let game = temp_root(tag).join("game");
|
||||
fn instance_from(fixture_dir: &Path, manifest: &PackManifest, tag: &str) -> TempRoot {
|
||||
let game = TempRoot::under(tag, "game");
|
||||
for (_, file) in manifest.override_files() {
|
||||
let group = manifest
|
||||
.overrides
|
||||
@@ -226,7 +226,7 @@ fn multimc_fixture_exports_as_mrpack_from_its_game_directory() {
|
||||
assert!(MULTIMC.metadata_entry().is_none(), "MultiMC 导出不做");
|
||||
|
||||
// 重新打成 MultiMC 形状的目录以确认没有丢内容(把 overrides 解出来再比)。
|
||||
let unpacked = temp_root("rt-mmc-unpacked");
|
||||
let unpacked = TempRoot::new("rt-mmc-unpacked");
|
||||
for entry in pack.entries() {
|
||||
let name = entry.name();
|
||||
let Some(relative) = name.strip_prefix("overrides/") else {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::{temp_root, write_file, write_zip};
|
||||
use super::{write_file, write_zip, TempRoot};
|
||||
use crate::addon::pack::archive::PackArchive;
|
||||
use crate::addon::pack::curseforge::CURSEFORGE;
|
||||
use crate::addon::pack::error::{PackError, PackErrorKind, PackResult};
|
||||
@@ -87,8 +87,8 @@ impl PackFormat for ToyFormat {
|
||||
}
|
||||
|
||||
/// 造一个 toy 包(源目录形式,顺带能当导出 payload)。
|
||||
fn toy_pack(tag: &str) -> std::path::PathBuf {
|
||||
let dir = temp_root(tag).join("toy");
|
||||
fn toy_pack(tag: &str) -> TempRoot {
|
||||
let dir = TempRoot::under(tag, "toy");
|
||||
write_file(
|
||||
&dir.join("toy.json"),
|
||||
br#"{"name":"Toy Pack","minecraft":"1.21","mods":["mods/a.jar","mods/b.jar"]}"#,
|
||||
@@ -140,7 +140,7 @@ fn a_new_format_needs_one_registration_line() {
|
||||
#[test]
|
||||
fn appending_a_format_never_steals_existing_packs() {
|
||||
// 同一份包同时有 CF 清单与 toy 清单。
|
||||
let root = temp_root("extend-priority");
|
||||
let root = TempRoot::new("extend-priority");
|
||||
let target = root.join("both.zip");
|
||||
write_zip(
|
||||
&target,
|
||||
|
||||
@@ -22,14 +22,19 @@
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::{temp_root, write_file, zip_dir};
|
||||
use super::{write_file, zip_dir, TempRoot};
|
||||
|
||||
/// 一个既有源目录(可当导出 payload)又有打好的 zip 的夹具。
|
||||
///
|
||||
/// `_root` 是临时根的**守卫**:夹具活多久,目录就活多久;夹具 drop(含测试 panic 展开)时
|
||||
/// 整个临时根被删。没有它,这些手造包会一直堆在 `%TEMP%` 里(M160 r2:实测堆到 110 GB)。
|
||||
pub(crate) struct Fixture {
|
||||
/// 源目录(zip 的内容来源,也是 `PackPayload::from_root` 的根)。
|
||||
pub dir: PathBuf,
|
||||
/// 打好的包。
|
||||
pub zip: PathBuf,
|
||||
/// 临时根守卫(只为了在夹具存活期间保住 `dir` / `zip`)。
|
||||
_root: TempRoot,
|
||||
}
|
||||
|
||||
/// CF 夹具的包名。
|
||||
@@ -130,7 +135,7 @@ const MMC_INSTANCE: &str = "InstanceType=OneSix\nname=M110 手造 MultiMC 实例
|
||||
|
||||
/// CurseForge 夹具(`manifest.json` + `overrides/`,含一个截断的 TOML)。
|
||||
pub(crate) fn curseforge(tag: &str) -> Fixture {
|
||||
let root = temp_root(tag);
|
||||
let root = TempRoot::new(tag);
|
||||
let dir = root.join("src");
|
||||
write_file(&dir.join("manifest.json"), CF_MANIFEST.as_bytes());
|
||||
write_file(
|
||||
@@ -144,12 +149,16 @@ pub(crate) fn curseforge(tag: &str) -> Fixture {
|
||||
write_file(&dir.join("overrides/config/broken.toml"), CF_BROKEN);
|
||||
let zip = root.join("pack.zip");
|
||||
zip_dir(&dir, &zip);
|
||||
Fixture { dir, zip }
|
||||
Fixture {
|
||||
dir,
|
||||
zip,
|
||||
_root: root,
|
||||
}
|
||||
}
|
||||
|
||||
/// Modrinth 夹具(`modrinth.index.json` + 三个覆盖目录,含一个半截 jar)。
|
||||
pub(crate) fn modrinth(tag: &str) -> Fixture {
|
||||
let root = temp_root(tag);
|
||||
let root = TempRoot::new(tag);
|
||||
let dir = root.join("src");
|
||||
write_file(&dir.join("modrinth.index.json"), mrpack_index().as_bytes());
|
||||
write_file(&dir.join("overrides/mods/half.jar"), MR_HALF_JAR);
|
||||
@@ -164,12 +173,19 @@ pub(crate) fn modrinth(tag: &str) -> Fixture {
|
||||
);
|
||||
let zip = root.join("pack.mrpack");
|
||||
zip_dir(&dir, &zip);
|
||||
Fixture { dir, zip }
|
||||
Fixture {
|
||||
dir,
|
||||
zip,
|
||||
_root: root,
|
||||
}
|
||||
}
|
||||
|
||||
/// MultiMC 夹具:**目录形式**(真实形态就是实例目录,不是 zip),含一个零字节 jar。
|
||||
pub(crate) fn multimc(tag: &str) -> PathBuf {
|
||||
let root = temp_root(tag).join("instance");
|
||||
///
|
||||
/// 返回 `TempRoot`(`Deref<Target = Path>` 指向 `<root>/instance`):调用点照旧当路径用,
|
||||
/// 而临时根由返回值持有——用到哪、活到哪、drop 就删。
|
||||
pub(crate) fn multimc(tag: &str) -> TempRoot {
|
||||
let root = TempRoot::under(tag, "instance");
|
||||
write_file(&root.join("mmc-pack.json"), MMC_PACK.as_bytes());
|
||||
write_file(&root.join("instance.cfg"), MMC_INSTANCE.as_bytes());
|
||||
write_file(
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
//! 限额口径的统一(M160):**导入读取与导出写入只此一份常数**,且不等式关系钉死在这里。
|
||||
//!
|
||||
//! # 为什么需要这个文件
|
||||
//!
|
||||
//! M152 之前两侧各写各的:读侧 `PackLimits` 是 20 000 条 / 512 MiB / 64 MiB,写侧直接拿
|
||||
//! `zip::ZIP64_BYTES_THR`(≈4 GiB)与 u32。结果是一个**能导出、导不回来**的包是合法的。
|
||||
//! 现在写侧改读同一份常量(`WriteLimits::production`),本文件就是那条口径的证据:
|
||||
//!
|
||||
//! 1. 三个常量之间的关系(`entry ≤ total < ZIP64 ≤ u32`);
|
||||
//! 2. 读侧 `PackLimits::default()` 就是这三个数;
|
||||
//! 3. 写侧在**单条边界**上拒(`>` 而不是 `≥`:恰好 64 MiB 合法);
|
||||
//! 4. 写侧在**总量**上拒,且拒绝发生在读内容之前(临时目录里什么都不留)。
|
||||
|
||||
use super::{write_file, TempRoot};
|
||||
use crate::addon::pack::archive::limits::{
|
||||
PACK_MAX_ENTRIES, PACK_MAX_ENTRY_BYTES, PACK_MAX_UNCOMPRESSED_BYTES, PACK_ZIP64_BYTES_THR,
|
||||
};
|
||||
use crate::addon::pack::curseforge::CURSEFORGE;
|
||||
use crate::addon::pack::error::PackErrorKind;
|
||||
use crate::addon::pack::format::{PackFormat, PackPayload};
|
||||
use crate::addon::pack::manifest::{OverrideFile, PackManifest, PackOverrides, PackSide};
|
||||
use crate::addon::pack::PackLimits;
|
||||
|
||||
/// 一个覆盖文件的声明(内容由调用方提供)。
|
||||
fn override_file(path: &str) -> OverrideFile {
|
||||
OverrideFile {
|
||||
path: path.to_string(),
|
||||
sha1: "0".repeat(40),
|
||||
size: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn manifest_of(files: Vec<OverrideFile>) -> PackManifest {
|
||||
let mut manifest = PackManifest::new("限额口径");
|
||||
manifest.version = Some("1.0.0".to_string());
|
||||
manifest.minecraft = Some("1.20.1".to_string());
|
||||
manifest.overrides.push(PackOverrides {
|
||||
dir: "overrides".to_string(),
|
||||
side: PackSide::Both,
|
||||
files,
|
||||
});
|
||||
manifest
|
||||
}
|
||||
|
||||
/// 造一个只占元数据的 `length` 字节文件(`set_len` 不写零)。
|
||||
fn sparse_file(path: &std::path::Path, length: u64) {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).expect("建目录");
|
||||
}
|
||||
let file = std::fs::File::create(path).expect("建稀疏文件");
|
||||
file.set_len(length).expect("拉长文件(稀疏)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_three_limits_form_one_chain_and_the_read_side_uses_them() {
|
||||
// ① 关系:单条 ≤ 总量 < ZIP64 ≤ u32。前一半保证"能写出来的包读得回来",
|
||||
// 后一半保证我们永远不需要写 ZIP64,也永远撞不到 mrpack 的 fileSize(u32) 上限。
|
||||
// 这三条是**编译期**断言(`const` 块):改坏常量会在 `cargo check` 就红,不用等跑测试。
|
||||
const _: () = assert!(
|
||||
PACK_MAX_ENTRY_BYTES <= PACK_MAX_UNCOMPRESSED_BYTES,
|
||||
"单条上限必须 ≤ 总量上限(否则单条规则永远够不着)"
|
||||
);
|
||||
const _: () = assert!(
|
||||
PACK_MAX_UNCOMPRESSED_BYTES < PACK_ZIP64_BYTES_THR,
|
||||
"总量上限必须严格低于 ZIP64 阈值"
|
||||
);
|
||||
const _: () = assert!(
|
||||
PACK_ZIP64_BYTES_THR <= u32::MAX as u64,
|
||||
"ZIP64 阈值也不该越过 u32(mrpack fileSize 的口径)"
|
||||
);
|
||||
|
||||
// ② 读侧默认限额就是这三个数(不是"看起来一样"的另一份拷贝)。
|
||||
let limits = PackLimits::default();
|
||||
assert_eq!(limits.max_entries, PACK_MAX_ENTRIES);
|
||||
assert_eq!(limits.max_uncompressed_bytes, PACK_MAX_UNCOMPRESSED_BYTES);
|
||||
assert_eq!(limits.max_entry_bytes, PACK_MAX_ENTRY_BYTES);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_write_side_refuses_a_single_entry_over_the_unified_entry_limit() {
|
||||
let root = TempRoot::new("limits-entry");
|
||||
// 恰好 +1 字节超限:拒绝理由是**单条**上限(总量 64 MiB + 1 仍在 512 MiB 之下)。
|
||||
sparse_file(
|
||||
&root.join("overrides/mods/too-big.jar"),
|
||||
PACK_MAX_ENTRY_BYTES + 1,
|
||||
);
|
||||
let target = root.join("out.zip");
|
||||
|
||||
let error = CURSEFORGE
|
||||
.write(
|
||||
&manifest_of(vec![override_file("mods/too-big.jar")]),
|
||||
PackPayload::from_root(&root),
|
||||
&target,
|
||||
)
|
||||
.expect_err("超过单条统一上限必须拒");
|
||||
|
||||
assert_eq!(error.kind(), PackErrorKind::Limits, "{error}");
|
||||
assert!(
|
||||
error.message().contains(&PACK_MAX_ENTRY_BYTES.to_string()),
|
||||
"文案要给出那个数(读侧用的是同一个):{error}"
|
||||
);
|
||||
assert!(
|
||||
error.hint().is_some_and(|hint| hint.contains("移出实例")),
|
||||
"要给可执行的下一步:{error:?}"
|
||||
);
|
||||
assert!(!target.exists(), "预检失败时目标路径不许出现");
|
||||
// 预检在建临时文件之前就失败 ⇒ 目录里只有源目录,没有 `out.zip.*.part`。
|
||||
let leftovers: Vec<String> = std::fs::read_dir(&root)
|
||||
.expect("列目录")
|
||||
.filter_map(Result::ok)
|
||||
.map(|entry| entry.file_name().to_string_lossy().to_string())
|
||||
.filter(|name| name != "overrides")
|
||||
.collect();
|
||||
assert!(leftovers.is_empty(), "不许留临时文件:{leftovers:?}");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_entry_boundary_is_exactly_the_limit() {
|
||||
// 边界口径是 `>`:恰好 64 MiB 合法(与读侧的 `size > max_entry_bytes` 同一侧)。
|
||||
let root = TempRoot::new("limits-boundary");
|
||||
sparse_file(&root.join("overrides/mods/exact.jar"), PACK_MAX_ENTRY_BYTES);
|
||||
let target = root.join("out.zip");
|
||||
|
||||
CURSEFORGE
|
||||
.write(
|
||||
&manifest_of(vec![override_file("mods/exact.jar")]),
|
||||
PackPayload::from_root(&root),
|
||||
&target,
|
||||
)
|
||||
.expect("恰好等于上限必须写得出来(否则两侧的边界就错开了)");
|
||||
let written = crate::addon::pack::PackArchive::open(
|
||||
&target,
|
||||
// 读回来时单条上限就是"恰好等于它",证明写出来的东西读侧认。
|
||||
PackLimits {
|
||||
max_entry_bytes: PACK_MAX_ENTRY_BYTES,
|
||||
..PackLimits::default()
|
||||
},
|
||||
)
|
||||
.expect("刚写出来的包必须能被自己的读取层打开");
|
||||
assert!(written.has_entry("overrides/mods/exact.jar"));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_write_side_refuses_a_total_over_the_unified_total_limit() {
|
||||
// 每条都在单条上限之内(64 MiB),但只要 9 条就超总量(576 MiB > 512 MiB)——
|
||||
// 这条与"单条规则"是两件事:判据是**累计**。
|
||||
let root = TempRoot::new("limits-total");
|
||||
let mut files = Vec::new();
|
||||
for index in 0..9 {
|
||||
let relative = format!("mods/part-{index}.jar");
|
||||
sparse_file(
|
||||
&root.join("overrides").join(&relative),
|
||||
PACK_MAX_ENTRY_BYTES,
|
||||
);
|
||||
files.push(override_file(&relative));
|
||||
}
|
||||
let total = PACK_MAX_ENTRY_BYTES * 9;
|
||||
assert!(total > PACK_MAX_UNCOMPRESSED_BYTES, "样本必须真的超总量");
|
||||
let target = root.join("out.zip");
|
||||
|
||||
let error = CURSEFORGE
|
||||
.write(&manifest_of(files), PackPayload::from_root(&root), &target)
|
||||
.expect_err("累计超总量必须拒");
|
||||
|
||||
assert_eq!(error.kind(), PackErrorKind::Limits, "{error}");
|
||||
assert!(
|
||||
error
|
||||
.message()
|
||||
.contains(&PACK_MAX_UNCOMPRESSED_BYTES.to_string()),
|
||||
"文案要给出统一总量上限:{error}"
|
||||
);
|
||||
assert!(!target.exists());
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_real_pack_written_under_the_limit_reopens_under_the_read_limits() {
|
||||
// 端到端的那句话:"我们能写出的包一定能被自己读回来"。用一份正常尺寸的覆盖内容跑一遍。
|
||||
let root = TempRoot::new("limits-parity");
|
||||
write_file(&root.join("overrides/mods/jei.jar"), b"PK\x03\x04 jei");
|
||||
write_file(
|
||||
&root.join("overrides/config/known.toml"),
|
||||
b"shared = true\n",
|
||||
);
|
||||
let target = root.join("out.mrpack");
|
||||
|
||||
crate::addon::pack::modrinth::MODRINTH
|
||||
.write(
|
||||
&manifest_of(vec![
|
||||
override_file("mods/jei.jar"),
|
||||
override_file("config/known.toml"),
|
||||
]),
|
||||
PackPayload::from_root(&root),
|
||||
&target,
|
||||
)
|
||||
.expect("正常尺寸必须写得出来");
|
||||
|
||||
let pack = crate::addon::pack::PackArchive::open_default(&target)
|
||||
.expect("默认限额(= 统一口径)必须读得回来");
|
||||
assert!(pack.has_entry("overrides/mods/jei.jar"));
|
||||
assert!(pack.has_entry("overrides/config/known.toml"));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
@@ -1,6 +1,17 @@
|
||||
//! `addon::pack` 的单元测试:夹具读回、未知字段往返、探测优先级、扩展点、恶意样本。
|
||||
//!
|
||||
//! **全部离线**:没有网络调用,也没有真实整合包样本(见 `fixtures` 的来源声明)。
|
||||
//!
|
||||
//! # 临时目录必须**测试自己收尾**(M160 r2)
|
||||
//!
|
||||
//! 每个用例的临时根是 [`TempRoot`]:`Drop` 里 `remove_dir_all`,所以**正常结束与 panic 展开
|
||||
//! 都会删**(Rust 展开会跑析构)。这条不是洁癖:`zip64` 那族用例要造 4 GiB 的**稀疏文件**验
|
||||
//! 阈值,残留会把 `%TEMP%` 堆到撑爆整台机器(实测 1651 个目录 / 110 GB,把 CI 与别的 agent
|
||||
//! 的构建一起咬死)。
|
||||
//!
|
||||
//! 写法的来源是仓库既有的那一份(`crate::update::tests::support::TempDir`):同样的 `new` /
|
||||
//! `join` / `Drop` 三件套,只把前缀换成 `ull-pack-`,不引 `tempfile`、不新增第四种范式。
|
||||
//! 不信"系统会自动清 `%TEMP%`"——那要靠时间或外部工具,测试必须自证干净。
|
||||
|
||||
mod detect;
|
||||
mod export;
|
||||
@@ -9,6 +20,7 @@ mod export_pipeline;
|
||||
mod export_roundtrip;
|
||||
mod extend;
|
||||
mod fixtures;
|
||||
mod limits;
|
||||
mod model;
|
||||
mod roundtrip;
|
||||
mod safety;
|
||||
@@ -18,14 +30,8 @@ mod zip64;
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
/// 本次进程内唯一的临时根目录(沿用仓库既有约定:不引 `tempfile`)。
|
||||
pub(crate) fn temp_root(tag: &str) -> PathBuf {
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
std::env::temp_dir().join(format!("ull-pack-{}-{tag}-{unique}", std::process::id()))
|
||||
}
|
||||
pub(crate) use crate::addon::temp::TempRoot;
|
||||
|
||||
/// 写一个夹具文件(自动建父目录)。
|
||||
pub(crate) fn write_file(path: &Path, bytes: &[u8]) {
|
||||
@@ -106,3 +112,58 @@ pub(crate) fn write_zip_with_symlink(
|
||||
.expect("写符号链接条目");
|
||||
writer.finish().expect("收尾 zip");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_temp_root_is_gone_once_dropped() {
|
||||
// 自证①:正常路径。用完(drop)之后连目录都不该存在。
|
||||
let path = {
|
||||
let root = TempRoot::new("self-proof");
|
||||
write_file(&root.join("overrides/mods/a.jar"), b"x");
|
||||
assert!(root.path().is_dir(), "活在的时候必须是真目录");
|
||||
root.path().to_path_buf()
|
||||
};
|
||||
assert!(
|
||||
!path.exists(),
|
||||
"drop 之后临时根必须被删掉:{}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_panicking_test_still_removes_its_temp_root() {
|
||||
// 自证②:panic 展开路径。断言"panic 也删"——这正是 4 GiB 稀疏文件残留的那条路:
|
||||
// 断言红了不代表可以留下 110 GB。
|
||||
let path = std::sync::Arc::new(std::sync::Mutex::new(PathBuf::new()));
|
||||
let slot = std::sync::Arc::clone(&path);
|
||||
let outcome = std::panic::catch_unwind(move || {
|
||||
let root = TempRoot::new("self-proof-panic");
|
||||
write_file(&root.join("overrides/mods/a.jar"), b"x");
|
||||
*slot.lock().expect("锁") = root.path().to_path_buf();
|
||||
panic!("故意失败:展开时守卫要照删不误");
|
||||
});
|
||||
assert!(outcome.is_err(), "这个用例必须真的走 panic 分支");
|
||||
let path = path.lock().expect("锁").clone();
|
||||
assert!(
|
||||
!path.exists(),
|
||||
"panic 之后临时根必须被删掉:{}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sub_directory_root_takes_its_parent_with_it() {
|
||||
// 自证③:`under` 造的子树删的是**根**,不给 `%TEMP%` 留一堆空壳父目录。
|
||||
let (sub, parent) = {
|
||||
let root = TempRoot::under("self-proof-under", "instance");
|
||||
write_file(&root.join("mmc-pack.json"), b"{}");
|
||||
(
|
||||
root.path().to_path_buf(),
|
||||
root.path().parent().map(Path::to_path_buf),
|
||||
)
|
||||
};
|
||||
assert!(!sub.exists(), "子树要删");
|
||||
assert!(
|
||||
parent.is_some_and(|parent| !parent.exists()),
|
||||
"父目录也不许留空壳"
|
||||
);
|
||||
}
|
||||
@@ -215,7 +215,7 @@ fn multimc_reads_instance_directory() {
|
||||
|
||||
#[test]
|
||||
fn multimc_export_is_refused() {
|
||||
let root = super::temp_root("mmc-write");
|
||||
let root = super::TempRoot::new("mmc-write");
|
||||
let target = root.join("out.zip");
|
||||
let manifest = PackManifest::new("whatever");
|
||||
let error = MULTIMC
|
||||
@@ -236,7 +236,7 @@ fn registry_read_reports_matched_format() {
|
||||
|
||||
#[test]
|
||||
fn curseforge_without_name_is_a_metadata_error() {
|
||||
let root = super::temp_root("cf-noname");
|
||||
let root = super::TempRoot::new("cf-noname");
|
||||
let target = root.join("nope.zip");
|
||||
super::write_zip(
|
||||
&target,
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
//! 解压总体积上限、单条目上限、规整后重名、篡改载荷(CRC 不符)、以及**清单字段里的**穿越
|
||||
//! (mrpack `files[].path`、CF `overrides`)。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::Path;
|
||||
|
||||
use super::{temp_root, write_zip, write_zip_with_symlink};
|
||||
use super::{write_zip, write_zip_with_symlink, TempRoot};
|
||||
use crate::addon::pack::archive::{PackArchive, PackLimits};
|
||||
use crate::addon::pack::curseforge::CURSEFORGE;
|
||||
use crate::addon::pack::error::PackErrorKind;
|
||||
@@ -15,8 +15,8 @@ use crate::addon::pack::manifest::{PackLoader, PackManifest, PackOverrides, Pack
|
||||
use crate::addon::pack::modrinth::MODRINTH;
|
||||
|
||||
/// 打一个只有一条恶意条目的 zip。
|
||||
fn single_entry(tag: &str, name: &str) -> PathBuf {
|
||||
let target = temp_root(tag).join("evil.zip");
|
||||
fn single_entry(tag: &str, name: &str) -> TempRoot {
|
||||
let target = TempRoot::at(tag, "evil.zip");
|
||||
write_zip(&target, &[(name, &b"payload"[..])]);
|
||||
target
|
||||
}
|
||||
@@ -81,7 +81,8 @@ fn rejects_drive_letter_and_ads_entries() {
|
||||
|
||||
#[test]
|
||||
fn rejects_symlink_entries() {
|
||||
let target = temp_root("symlink").join("link.zip");
|
||||
let root = TempRoot::new("symlink");
|
||||
let target = root.join("link.zip");
|
||||
write_zip_with_symlink(
|
||||
&target,
|
||||
&[("readme.txt", &b"x"[..])],
|
||||
@@ -94,7 +95,8 @@ fn rejects_symlink_entries() {
|
||||
|
||||
#[test]
|
||||
fn rejects_too_many_entries() {
|
||||
let target = temp_root("limit-count").join("many.zip");
|
||||
let root = TempRoot::new("limit-count");
|
||||
let target = root.join("many.zip");
|
||||
write_zip(
|
||||
&target,
|
||||
&[
|
||||
@@ -111,7 +113,8 @@ fn rejects_too_many_entries() {
|
||||
|
||||
#[test]
|
||||
fn rejects_uncompressed_volume_over_limit() {
|
||||
let target = temp_root("limit-volume").join("big.zip");
|
||||
let root = TempRoot::new("limit-volume");
|
||||
let target = root.join("big.zip");
|
||||
let payload = vec![b'a'; 4096];
|
||||
write_zip(
|
||||
&target,
|
||||
@@ -124,7 +127,8 @@ fn rejects_uncompressed_volume_over_limit() {
|
||||
|
||||
#[test]
|
||||
fn rejects_a_single_oversized_entry() {
|
||||
let target = temp_root("limit-entry").join("big.zip");
|
||||
let root = TempRoot::new("limit-entry");
|
||||
let target = root.join("big.zip");
|
||||
let payload = vec![b'a'; 2048];
|
||||
write_zip(&target, &[("a.bin", payload.as_slice())]);
|
||||
let error = PackArchive::open(&target, PackLimits::new(16, 1024 * 1024, 512))
|
||||
@@ -135,7 +139,8 @@ fn rejects_a_single_oversized_entry() {
|
||||
#[test]
|
||||
fn rejects_names_that_collide_after_normalization() {
|
||||
// `./a.txt` 与 `a.txt` 规整后同名:留着就是"读到的和落盘的"不是同一个文件的经典混淆。
|
||||
let target = temp_root("dup-name").join("dup.zip");
|
||||
let root = TempRoot::new("dup-name");
|
||||
let target = root.join("dup.zip");
|
||||
write_zip(&target, &[("./a.txt", &b"1"[..]), ("a.txt", &b"2"[..])]);
|
||||
let error = open_error(&target);
|
||||
assert_eq!(error.kind(), PackErrorKind::Metadata, "{}", error.message());
|
||||
@@ -145,7 +150,8 @@ fn rejects_names_that_collide_after_normalization() {
|
||||
fn a_tampered_payload_is_not_silently_accepted() {
|
||||
// 中央目录没问题、数据被改过(CRC 不符):打开能过,读内容必须报错。
|
||||
const MARKER: &[u8] = b"UNIQUE-PAYLOAD-MARKER";
|
||||
let target = temp_root("tampered").join("tampered.zip");
|
||||
let root = TempRoot::new("tampered");
|
||||
let target = root.join("tampered.zip");
|
||||
write_zip(
|
||||
&target,
|
||||
&[
|
||||
@@ -182,7 +188,8 @@ fn a_tampered_payload_is_not_silently_accepted() {
|
||||
|
||||
#[test]
|
||||
fn mrpack_file_path_traversal_is_rejected() {
|
||||
let target = temp_root("mr-trav").join("evil.mrpack");
|
||||
let root = TempRoot::new("mr-trav");
|
||||
let target = root.join("evil.mrpack");
|
||||
let index = r#"{"game":"minecraft","formatVersion":1,"versionId":"1.0","name":"evil pack",
|
||||
"files":[{"path":"../evil.jar",
|
||||
"hashes":{"sha512":"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"},
|
||||
@@ -201,7 +208,8 @@ fn mrpack_file_path_traversal_is_rejected() {
|
||||
|
||||
#[test]
|
||||
fn curseforge_overrides_dir_traversal_is_rejected() {
|
||||
let target = temp_root("cf-trav").join("evil.zip");
|
||||
let root = TempRoot::new("cf-trav");
|
||||
let target = root.join("evil.zip");
|
||||
write_zip(
|
||||
&target,
|
||||
&[(
|
||||
@@ -248,7 +256,8 @@ fn write_side_path_check_catches_hand_built_models() {
|
||||
|
||||
#[test]
|
||||
fn missing_package_is_an_io_error() {
|
||||
let target = temp_root("missing").join("does-not-exist.zip");
|
||||
let root = TempRoot::new("missing");
|
||||
let target = root.join("does-not-exist.zip");
|
||||
let error = open_error(&target);
|
||||
assert_eq!(error.kind(), PackErrorKind::Io, "{}", error.message());
|
||||
}
|
||||
@@ -258,7 +267,7 @@ fn directory_source_rejects_symlinks_when_the_platform_can_make_them() {
|
||||
// Windows 建符号链接需要开发者模式/管理员:建不出来就跳过,但绝不静默当通过。
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let root = temp_root("dir-symlink").join("instance");
|
||||
let root = TempRoot::under("dir-symlink", "instance");
|
||||
std::fs::create_dir_all(&root).expect("建目录");
|
||||
std::fs::write(root.join("mmc-pack.json"), b"{}").expect("写文件");
|
||||
std::os::unix::fs::symlink("/etc/passwd", root.join("escape")).expect("建符号链接");
|
||||
|
||||
@@ -12,7 +12,7 @@ use crate::addon::pack::modrinth::MODRINTH;
|
||||
#[test]
|
||||
fn mrpack_env_unknown_keys_survive_read_write_reread() {
|
||||
// r1 p3-3 的一处:`files[].env` 里的未知键(`env_extra`)此前没有任何断言。
|
||||
let root = super::temp_root("env-extra");
|
||||
let root = super::TempRoot::new("env-extra");
|
||||
let dir = root.join("src");
|
||||
super::write_file(
|
||||
&dir.join("modrinth.index.json"),
|
||||
@@ -44,7 +44,7 @@ fn mrpack_env_unknown_keys_survive_read_write_reread() {
|
||||
#[test]
|
||||
fn curseforge_file_unknown_keys_survive_read_write_reread() {
|
||||
// r1 p3-3 的另一处:CF `files[].fileName` / `url` 是 HMCL 补全后回写的扩展点。
|
||||
let root = super::temp_root("cf-file-extra");
|
||||
let root = super::TempRoot::new("cf-file-extra");
|
||||
let dir = root.join("src");
|
||||
super::write_file(
|
||||
&dir.join("manifest.json"),
|
||||
@@ -81,7 +81,7 @@ fn curseforge_file_unknown_keys_survive_read_write_reread() {
|
||||
#[test]
|
||||
fn curseforge_loader_unknown_keys_survive_read_write_reread() {
|
||||
// r1 p3-3 的第三处:`modLoaders[]` 里的未知键(`PackLoader::extra`)。
|
||||
let root = super::temp_root("cf-loader-extra");
|
||||
let root = super::TempRoot::new("cf-loader-extra");
|
||||
let dir = root.join("src");
|
||||
super::write_file(
|
||||
&dir.join("manifest.json"),
|
||||
@@ -117,7 +117,7 @@ fn mrpack_file_unknown_keys_survive_read_write_reread() {
|
||||
// 第四个假边(M152 补):`files[]` 这一层。上面三条覆盖了 `env` / CF `files[]` /
|
||||
// CF `modLoaders[]`,而 mrpack 的文件级未知键一直没人钉——它恰恰是"社区给某条文件加
|
||||
// 一个私有字段"最常出现的位置(例如镜像站塞的 `mirror` / 作者写的 `optionalReason`)。
|
||||
let root = super::temp_root("mr-file-extra");
|
||||
let root = super::TempRoot::new("mr-file-extra");
|
||||
let dir = root.join("src");
|
||||
super::write_file(
|
||||
&dir.join("modrinth.index.json"),
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
//! ZIP64 拒写(research/18-p2 §8.6 / 表 B #17 的导出侧那条)。
|
||||
//! 导出侧的体积闸:**拒写**过大包(research/18-p2 §8.6 / 表 B #17 的导出侧那条)。
|
||||
//!
|
||||
//! # 为什么这个文件以前是空的
|
||||
//! # 现在的判据是「与导入读取同一份常量」(M160)
|
||||
//!
|
||||
//! M112 的完成报告写明:「ZIP64 阈值那条分支只有代码没有端到端测试(要造 4 GiB 数据)」。
|
||||
//! 而更要紧的是——**那条分支当时根本够不着**:实现先 `std::fs::read` 整个覆盖文件进内存,
|
||||
//! 再累加比较,所以真要导出一个 4 GiB 文件的实例,内存先炸,用户永远看不到那句
|
||||
//! `pack_limits`。现在写侧改成「先按 `metadata().len()` 预检」,两个问题一起解决:
|
||||
//! 以前判据是 `zip::ZIP64_BYTES_THR`(≈4 GiB),而导入读取只允许 512 MiB —— 于是存在
|
||||
//! 「导得出来、导不回去」的包。M160 把写侧上限换成读侧那份常量(`pack::archive::limits`),
|
||||
//! 本文件的两条用例(单文件超大 / 累计超大)仍然钉住**同一件事**:闸门在碰内容之前就把包拒了。
|
||||
//! 只是拒绝的理由从"要写 ZIP64"变成"超过统一上限,所以永远不会写出 ZIP64"。
|
||||
//!
|
||||
//! - 边界是真的会在读第一个字节之前拦下来的(本文件的用例就断言这一点:失败后临时目录里
|
||||
//! 没有 `.part`、目标路径不存在);
|
||||
//! - 测试可以用**稀疏文件**造出 4 GiB 的"声明尺寸"而几乎不占磁盘(`set_len` 只改 EOF,
|
||||
//! NTFS / ext4 都不会真写零)。这也是唯一能在不塞满硬盘的前提下打通这条分支的办法。
|
||||
//! # 为什么用稀疏文件
|
||||
//!
|
||||
//! 判据是**声明尺寸**(`metadata().len()`),所以测试可以用稀疏文件造出 GB 级的"声明尺寸"而
|
||||
//! 几乎不占磁盘(`set_len` 只改 EOF,NTFS / ext4 都不会真写零)。这也是唯一能在不塞满硬盘的
|
||||
//! 前提下打通这条分支的办法。
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use super::temp_root;
|
||||
use super::TempRoot;
|
||||
use crate::addon::pack::curseforge::CURSEFORGE;
|
||||
use crate::addon::pack::error::PackErrorKind;
|
||||
use crate::addon::pack::format::{PackFormat, PackPayload};
|
||||
@@ -64,7 +65,7 @@ fn leftovers(root: &Path, keep: &[&str]) -> Vec<String> {
|
||||
|
||||
#[test]
|
||||
fn one_file_over_the_threshold_is_refused_before_a_single_byte_is_read() {
|
||||
let root = temp_root("zip64-single");
|
||||
let root = TempRoot::new("zip64-single");
|
||||
// 恰好一个字节超过阈值:预检必须在这里就报,不许读内容(真读会先要 4 GiB 内存)。
|
||||
sparse_file(
|
||||
&root.join("overrides/mods/huge.jar"),
|
||||
@@ -91,7 +92,7 @@ fn one_file_over_the_threshold_is_refused_before_a_single_byte_is_read() {
|
||||
#[test]
|
||||
fn the_threshold_is_cumulative_not_per_file() {
|
||||
// 三个文件各自都远小于阈值,加起来超了:判据是**整包未压缩总量**,不是单文件。
|
||||
let root = temp_root("zip64-cumulative");
|
||||
let root = TempRoot::new("zip64-cumulative");
|
||||
let each = 1_610_612_736_u64; // 1.5 GiB
|
||||
assert!(each < zip::ZIP64_BYTES_THR, "单条必须都在阈值以下");
|
||||
assert!(each * 3 > zip::ZIP64_BYTES_THR, "三条加起来必须超标");
|
||||
@@ -115,7 +116,7 @@ fn the_threshold_is_cumulative_not_per_file() {
|
||||
#[test]
|
||||
fn an_export_under_the_threshold_still_writes_a_normal_pack() {
|
||||
// 对照:不超阈值时预检不许误伤(否则这条护栏就变成"导出全挂")。
|
||||
let root = temp_root("zip64-under");
|
||||
let root = TempRoot::new("zip64-under");
|
||||
let content = b"a normal sized override file";
|
||||
std::fs::create_dir_all(root.join("overrides/mods")).expect("建目录");
|
||||
std::fs::write(root.join("overrides/mods/jei.jar"), content).expect("写覆盖文件");
|
||||
@@ -130,53 +131,41 @@ fn an_export_under_the_threshold_still_writes_a_normal_pack() {
|
||||
assert!(written.has_entry("overrides/mods/jei.jar"));
|
||||
}
|
||||
|
||||
/// 端到端版的窗口回归钉(**不进默认套件**:真写 2.5 GB 要 ~70 秒 deflate,且会把机器内存 /
|
||||
/// 页面文件顶上去;塔台 2026-11-07 的资源配置令要求避免)。
|
||||
/// 端到端版的旧窗口回归钉 → **现在钉的是口径统一**(`#[ignore]`:要造稀疏大文件 + 一遍 deflate)。
|
||||
///
|
||||
/// 默认套件里由 `format::write` 的单测 `the_second_gate_counts_from_the_manifest_length_not_
|
||||
/// the_preflight_total` 便宜地钉住同一个窗口(把窗口里的字节数直接喂给第二道闸的计数器)。
|
||||
/// 本条留给"真跑一遍确认容器层接线没错"的场合:
|
||||
/// M160 把写侧的上限换成读侧那份常量(512 MiB 总量 / 64 MiB 单条),所以 M152 时代
|
||||
/// 「2.5 GB 单文件落在 ZIP64 过拒窗口里、必须能导出」这件事**按设计不再成立**:那个包现在导得
|
||||
/// 出来、导不回去。本条留下的是新口径的端到端证据——2.5 GB 的覆盖文件在**碰内容之前**就被
|
||||
/// 统一上限拒掉,且临时文件与目标路径都不留。
|
||||
///
|
||||
/// ```text
|
||||
/// cargo test -p unlucky-core --lib -- zip64 --ignored --nocapture
|
||||
/// ```
|
||||
///
|
||||
/// 交付时的实测(M152 r2):单文件 2 500 000 000 字节导出**成功**,包内条目声明尺寸原样为
|
||||
/// 2.5 GB,耗时 ~70 秒、源文件稀疏(占盘 0),产物是压缩后的零(几 MB)。
|
||||
#[test]
|
||||
#[ignore = "真写 2.5 GB(~70 秒 deflate + 一片内存):默认套件用 Written 的便宜单测钉同一窗口"]
|
||||
fn a_single_file_inside_the_double_count_window_still_exports() {
|
||||
// P2-1 的回归钉:预检返回的是**全量**声明尺寸,写循环曾经直接拿它当累加基数,于是每个
|
||||
// 文件的字节被算两遍 —— 真实总量只要过半阈值(≈2.15 GiB)就在内容读完**之后**被误拒。
|
||||
// 2.5 GB 正落在这个窗口里:它必须能导出,且中途不许把整个人内容摊进内存。
|
||||
let root = temp_root("zip64-window");
|
||||
#[ignore = "造 2.5 GB 稀疏文件再跑一遍预检:默认套件用 `Written` 的便宜单测与 tests/limits.rs 钉同一口径"]
|
||||
fn a_single_file_over_the_old_zip64_window_is_refused_by_the_unified_limit() {
|
||||
let root = TempRoot::new("zip64-window");
|
||||
let length = 2_500_000_000_u64;
|
||||
assert!(length < zip::ZIP64_BYTES_THR, "样本必须在阈值之下");
|
||||
assert!(
|
||||
length > zip::ZIP64_BYTES_THR / 2,
|
||||
"样本必须落在过拒窗口里(≥ 阈值一半)"
|
||||
length < crate::addon::pack::archive::limits::PACK_ZIP64_BYTES_THR,
|
||||
"样本仍在 ZIP64 阈值之下:拒绝的理由是**统一上限**,不是 ZIP64"
|
||||
);
|
||||
sparse_file(&root.join("overrides/mods/big.jar"), length);
|
||||
let manifest = manifest_of(vec![override_file("mods/big.jar")]);
|
||||
let target = root.join("out.zip");
|
||||
|
||||
let result = CURSEFORGE.write(&manifest, PackPayload::from_root(&root), &target);
|
||||
let error = CURSEFORGE
|
||||
.write(&manifest, PackPayload::from_root(&root), &target)
|
||||
.expect_err("超过统一上限(512 MiB)必须拒:否则导出的包自己导不回来");
|
||||
assert_eq!(error.kind(), PackErrorKind::Limits, "{error}");
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"2.5 GB 的覆盖文件没超 ZIP64 阈值,不许误拒:{:?}",
|
||||
result.err()
|
||||
error.message().contains(
|
||||
&crate::addon::pack::archive::limits::PACK_MAX_UNCOMPRESSED_BYTES.to_string()
|
||||
),
|
||||
"文案要给出统一上限那个数:{error}"
|
||||
);
|
||||
assert!(!target.exists(), "预检失败时目标路径不许出现");
|
||||
|
||||
// 包内条目的**声明未压缩尺寸**就是 2.5 GB(真写出来的是压缩后的零,只有几 MB)。
|
||||
// 这里直接用 `zip` 的读者,而不是 `PackArchive::open_default`:后者对**不可信输入**有
|
||||
// 单条目 64 MiB 的上限(`PackLimits`),拿它读我们自己刚写的包会撞限额,与本次判据无关。
|
||||
let file = std::fs::File::open(&target).expect("打开导出包");
|
||||
let mut archive = zip::ZipArchive::new(file).expect("读 zip");
|
||||
let entry = archive
|
||||
.by_name("overrides/mods/big.jar")
|
||||
.expect("覆盖条目要在包里");
|
||||
assert_eq!(entry.size(), length, "声明尺寸要原样带过去");
|
||||
|
||||
// 稀疏源文件只占元数据,但产物与目录都清掉,别让 temp 越积越多。
|
||||
// 稀疏源文件只占元数据,但目录与临时文件都清掉,别让 temp 越积越多。
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
//! 测试用的**一次性临时根**(`Drop` 即删;panic 展开时同样删)。
|
||||
//!
|
||||
//! # 为什么它必须在 `addon` 里而不是某个测试目录里
|
||||
//!
|
||||
//! `addon::pack` 与 `addon::tests` 两族用例都建临时目录,而 `pack/tests/fixtures.rs` 被**两边
|
||||
//! 同时**通过 `#[path]` 编译(同一份手造包只维护一处)。守卫因此要放在两边的公共祖先上,否则
|
||||
//! `fixtures.rs` 里的 `super::TempRoot` 在其中一侧解析不到。
|
||||
//!
|
||||
//! # 为什么不能靠"系统会清 `%TEMP%`"
|
||||
//!
|
||||
//! 那种清理要等时间或外部工具,而 `zip64` 那族用例每个要造 4 GiB 的**稀疏文件**验阈值:
|
||||
//! M160 r2 实测 `%TEMP%/ull-pack-*` 堆到 **1651 个目录 / 110 GB**,把整台机器(含别的 agent
|
||||
//! 的构建)一起顶死。所以测试自己负责收尾,并且有自证用例
|
||||
//! (`pack/tests/mod.rs` 的 `the_temp_root_is_gone_once_dropped` /
|
||||
//! `a_panicking_test_still_removes_its_temp_root`)。
|
||||
//!
|
||||
//! # 形状来自仓库既有写法
|
||||
//!
|
||||
//! 与 [`crate::update::tests::support::TempDir`] 同一套 `new` / `join` / `Drop` 三件套
|
||||
//! (不引 `tempfile`、不新增范式),只多了两件事:`Deref<Target = Path>` 让调用点能直接当
|
||||
//! `&Path` 用;[`TempRoot::under`] 让"子树落点、删父目录"这件事不留下空壳。
|
||||
|
||||
use std::ops::Deref;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
/// 一次性临时根目录(**`Drop` 即删**)。
|
||||
pub(crate) struct TempRoot {
|
||||
/// 真正要删的根([`Self::under`] 造的子树也删它,不留空壳父目录)。
|
||||
root: PathBuf,
|
||||
/// 本夹具的落点(通常等于 `root`;`under` 时是 `root/<sub>`)。
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl TempRoot {
|
||||
/// 建一个新的临时根(同名残留先清掉:`COUNTER` 在同一进程内唯一,跨进程靠 pid)。
|
||||
pub(crate) fn new(tag: &str) -> Self {
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("ull-pack-{}-{tag}-{unique}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(&root).expect("建临时根");
|
||||
Self {
|
||||
path: root.clone(),
|
||||
root,
|
||||
}
|
||||
}
|
||||
|
||||
/// 在临时根下开一个子目录当落点(`Drop` 删的是**根**,父目录不会剩空壳)。
|
||||
pub(crate) fn under(tag: &str, sub: &str) -> Self {
|
||||
let base = Self::new(tag);
|
||||
let path = base.path.join(sub);
|
||||
std::fs::create_dir_all(&path).expect("建临时子目录");
|
||||
Self {
|
||||
root: base.root.clone(),
|
||||
path,
|
||||
}
|
||||
}
|
||||
|
||||
/// 建一个临时根,并让落点直接是根内的某个文件(`Drop` 删的仍是**根**)。
|
||||
///
|
||||
/// 给"夹具函数要返回一个包路径、而包路径必须活到测试用完"的场景用:返回值 `Deref` 到那个
|
||||
/// 文件,根由它持有。`root.parent()` 拿得到根目录。
|
||||
pub(crate) fn at(tag: &str, file: &str) -> Self {
|
||||
let mut base = Self::new(tag);
|
||||
base.path = base.path.join(file);
|
||||
base
|
||||
}
|
||||
|
||||
/// 本夹具的落点。
|
||||
pub(crate) fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
|
||||
/// 落点下的一条路径(`&str` 与 `Path` 都收,免得把调用点写成两套)。
|
||||
pub(crate) fn join(&self, name: impl AsRef<Path>) -> PathBuf {
|
||||
self.path.join(name)
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for TempRoot {
|
||||
type Target = Path;
|
||||
|
||||
fn deref(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<Path> for TempRoot {
|
||||
/// 让 `&TempRoot` 能直接喂给 `impl AsRef<Path>` 形态的 API(`fs::read_dir` / `remove_dir_all`
|
||||
/// / `PackArchive::open_default` …)。`Deref` 只解决"要 `&Path`"的场景,两者都要。
|
||||
fn as_ref(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempRoot {
|
||||
fn drop(&mut self) {
|
||||
// 尽力删:删不掉留一行日志,别在析构里 panic(二次 panic 会 abort)。
|
||||
if let Err(error) = std::fs::remove_dir_all(&self.root) {
|
||||
if error.kind() != std::io::ErrorKind::NotFound {
|
||||
eprintln!("[temp] 删 {} 失败:{error}", self.root.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ use crate::addon::loader::LoaderKind;
|
||||
use crate::download::blocking::block_on;
|
||||
|
||||
use super::support::{curse_meta, StubFetcher, CURSE_URL};
|
||||
use super::{fixtures, list_tree, read_text, temp_root};
|
||||
use super::{fixtures, list_tree, read_text, TempRoot};
|
||||
|
||||
/// CF 夹具里那两个条目补全出来的内容。
|
||||
const CURSE_BYTES: &[u8] = b"stub curse file bytes";
|
||||
@@ -16,7 +16,8 @@ const CURSE_BYTES: &[u8] = b"stub curse file bytes";
|
||||
#[test]
|
||||
fn imports_curseforge_pack_end_to_end() {
|
||||
let fixture = fixtures::curseforge("cf-e2e");
|
||||
let target = temp_root("cf-e2e-out").join("instance");
|
||||
let root = TempRoot::new("cf-e2e-out");
|
||||
let target = root.join("instance");
|
||||
let fetcher = StubFetcher::new()
|
||||
.with_curse(238222, 5102025, curse_meta("jei.jar", CURSE_BYTES))
|
||||
.with_curse(306612, 4587454, curse_meta("fabric-api.jar", CURSE_BYTES))
|
||||
@@ -94,7 +95,8 @@ fn imports_curseforge_pack_end_to_end() {
|
||||
fn offline_curseforge_import_is_partial_not_success() {
|
||||
// 没有 fetcher ⇒ CF 的 files[] 一条都补不出来。这**不是**失败,也**不许**记成成功。
|
||||
let fixture = fixtures::curseforge("cf-offline");
|
||||
let target = temp_root("cf-offline-out").join("instance");
|
||||
let root = TempRoot::new("cf-offline-out");
|
||||
let target = root.join("instance");
|
||||
let request = ImportRequest::new(&fixture.zip, &target);
|
||||
let report = block_on(import_pack(&request));
|
||||
|
||||
@@ -115,7 +117,8 @@ fn offline_curseforge_import_is_partial_not_success() {
|
||||
fn curseforge_meta_without_download_url_is_missing_not_guessed() {
|
||||
// CF 作者可以关掉 API 分发(`downloadUrl` 为 null):我们**不猜** URL,只如实记缺。
|
||||
let fixture = fixtures::curseforge("cf-nourl");
|
||||
let target = temp_root("cf-nourl-out").join("instance");
|
||||
let root = TempRoot::new("cf-nourl-out");
|
||||
let target = root.join("instance");
|
||||
let mut meta = curse_meta("jei.jar", CURSE_BYTES);
|
||||
meta.url = None;
|
||||
let fetcher = StubFetcher::new()
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
//! 逐文件冲突决策(M160):预检列清单 → 用户点头 → 导入按决策处置。
|
||||
//!
|
||||
//! # 一张表看完全部用例
|
||||
//!
|
||||
//! | 用例 | 钉住的口径 |
|
||||
//! |------|------------|
|
||||
//! | [`scan_lists_only_files_that_exist_and_differ`] | 进清单的条件:**已存在且内容不同**;来源记胜者侧 |
|
||||
//! | [`scan_is_silent_about_identical_and_missing_files`] | 内容相同 / 目标不存在 ⇒ 一行都不列 |
|
||||
//! | [`scan_does_not_ask_about_files_the_user_deleted`] | 用户删过的不算"已存在且内容不同" |
|
||||
//! | [`no_decision_means_keep_and_nothing_is_overwritten`] | **缺省保留**(报告的 choice 与磁盘内容双证) |
|
||||
//! | [`per_item_overwrite_touches_only_the_named_path`] | 逐项决策:只动点名那条,最终文件集逐字节断言 |
|
||||
//! | [`overwrite_all_is_the_batch_channel_from_the_same_table`] | 整批档来自同一张表(`ImportScan::overwrite_all`) |
|
||||
//! | [`a_kept_file_is_still_a_conflict_next_time`] | 基线记的是**包内**哈希,所以保留过一次的文件不会被静默覆盖 |
|
||||
//! | [`curseforge_entries_without_a_path_are_counted_not_guessed`] | 补全前没有路径 ⇒ 不猜,只计数 |
|
||||
//!
|
||||
//! 全部离线:远端走 [`support::StubFetcher`],没有一条网络请求。
|
||||
|
||||
use super::support::{mrpack_stub, sha1, StubFetcher};
|
||||
use super::{read_text, write_file, zip_dir, TempRoot};
|
||||
use crate::addon::import::{
|
||||
import_pack, scan_pack, ConflictChoice, ConflictDecisions, ConflictReason, FileAction,
|
||||
FileSource, ImportRequest, ImportScan, ImportStatus,
|
||||
};
|
||||
use crate::download::blocking::block_on;
|
||||
|
||||
/// 夹具里 `client-overrides/` 胜出的那份内容(`overrides/` 里同名文件是不同的内容)。
|
||||
const KNOWN_CLIENT: &[u8] = b"shared = client\n";
|
||||
/// 夹具里被遮蔽的那份(`overrides/` 侧)。
|
||||
const KNOWN_OVERRIDES: &[u8] = b"shared = overrides\n";
|
||||
/// 与目标内容**一致**的那个文件。
|
||||
const SAME: &[u8] = b"same = 1\n";
|
||||
/// 只有 overrides 声明、目标里还有一个不同内容的同名文件。
|
||||
const LAYER: &[u8] = b"layer from overrides\n";
|
||||
/// 目标里用户自己的内容(会被覆盖 / 被保留的那一侧)。
|
||||
const USER_KNOWN: &[u8] = b"shared = mine\n";
|
||||
/// 目标里用户自己的 layer 内容。
|
||||
const USER_LAYER: &[u8] = b"my layer\n";
|
||||
|
||||
/// 一份带 **overrides 叠层** 的 mrpack(`client-overrides/` 与 `overrides/` 同名不同内容)+ 一条远端条目。
|
||||
fn layered_pack(tag: &str) -> (TempRoot, StubFetcher) {
|
||||
// 返回值 `Deref` 到 `pack.mrpack`,根由它持有(`src/` 在 `root.parent()` 下)。
|
||||
let root = TempRoot::at(tag, "pack.mrpack");
|
||||
let dir = root.parent().expect("临时根").join("src");
|
||||
let remote = b"remote jar bytes";
|
||||
let index = format!(
|
||||
r#"{{"game":"minecraft","formatVersion":1,"versionId":"1.0.0","name":"M160 夹具",
|
||||
"files":[{{"path":"mods/remote.jar",
|
||||
"hashes":{{"sha1":"{}","sha512":"{}"}},
|
||||
"downloads":["stub://mods/remote.jar"],"fileSize":{}}}],
|
||||
"dependencies":{{"minecraft":"1.20.1"}}}}"#,
|
||||
sha1(remote),
|
||||
"0".repeat(128),
|
||||
remote.len()
|
||||
);
|
||||
write_file(&dir.join("modrinth.index.json"), index.as_bytes());
|
||||
// 叠层:同一路径两份声明,client-overrides 胜(Modrinth 官方语义)。
|
||||
write_file(&dir.join("overrides/config/known.toml"), KNOWN_OVERRIDES);
|
||||
write_file(
|
||||
&dir.join("client-overrides/config/known.toml"),
|
||||
KNOWN_CLIENT,
|
||||
);
|
||||
// 内容与目标一致的那个(不在叠层里,来源 = overrides)。
|
||||
write_file(&dir.join("overrides/config/same.toml"), SAME);
|
||||
write_file(&dir.join("overrides/mods/layer.jar"), LAYER);
|
||||
zip_dir(&dir, &root);
|
||||
let fetcher = StubFetcher::new().with_file("stub://mods/remote.jar", remote);
|
||||
(root, fetcher)
|
||||
}
|
||||
|
||||
/// 预置一个"用户已有文件"的实例目录。
|
||||
fn seeded(tag: &str) -> TempRoot {
|
||||
let target = TempRoot::under(tag, "instance");
|
||||
write_file(&target.join("config/known.toml"), USER_KNOWN);
|
||||
write_file(&target.join("config/same.toml"), SAME);
|
||||
write_file(&target.join("mods/layer.jar"), USER_LAYER);
|
||||
target
|
||||
}
|
||||
|
||||
fn paths(scan: &ImportScan) -> Vec<&str> {
|
||||
scan.conflicts
|
||||
.iter()
|
||||
.map(|conflict| conflict.path.as_str())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_lists_only_files_that_exist_and_differ() {
|
||||
let (pack, _) = layered_pack("dec-scan");
|
||||
let target = seeded("dec-scan");
|
||||
let scan = scan_pack(&ImportRequest::new(&pack, &target));
|
||||
|
||||
assert!(scan.is_ok(), "预检不许失败:{:?}", scan.error);
|
||||
assert_eq!(
|
||||
paths(&scan),
|
||||
vec!["config/known.toml", "mods/layer.jar"],
|
||||
"只列「已存在且内容不同」的两条(same.toml 与 remote.jar 不该出现)"
|
||||
);
|
||||
let known = &scan.conflicts[0];
|
||||
assert_eq!(
|
||||
known.source,
|
||||
FileSource::ClientOverrides,
|
||||
"叠层的胜者是 client-overrides 侧,来源要记它"
|
||||
);
|
||||
assert_eq!(known.reason, ConflictReason::PreExisting);
|
||||
assert_eq!(known.pack_sha1, sha1(KNOWN_CLIENT));
|
||||
assert_eq!(known.pack_size, KNOWN_CLIENT.len() as u64);
|
||||
assert_eq!(known.disk_sha1, sha1(USER_KNOWN));
|
||||
assert_eq!(
|
||||
known.disk_size,
|
||||
USER_KNOWN.len() as u64,
|
||||
"要给出目标现有体积"
|
||||
);
|
||||
assert_eq!(known.base_sha1, None, "首次导入没有基线");
|
||||
assert_eq!(
|
||||
scan.lost_bytes(),
|
||||
(USER_KNOWN.len() + USER_LAYER.len()) as u64
|
||||
);
|
||||
assert!(
|
||||
scan.needs_decision(),
|
||||
"有冲突就必须让用户点头,不能自己决定"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_is_silent_about_identical_and_missing_files() {
|
||||
let (pack, _) = layered_pack("dec-same");
|
||||
let root = TempRoot::new("dec-same");
|
||||
let target = root.join("instance");
|
||||
// 只放一个**内容与包内一致**的文件:一行都不该列出来。
|
||||
write_file(&target.join("config/same.toml"), SAME);
|
||||
let scan = scan_pack(&ImportRequest::new(&pack, &target));
|
||||
|
||||
assert!(scan.is_ok(), "{:?}", scan.error);
|
||||
assert!(
|
||||
paths(&scan).is_empty(),
|
||||
"内容一致不算冲突:{:?}",
|
||||
paths(&scan)
|
||||
);
|
||||
assert!(!scan.needs_decision(), "没有冲突就不该打扰用户");
|
||||
assert_eq!(scan.unknown_remote, 0, "mrpack 条目自带路径,判得了");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_does_not_ask_about_files_the_user_deleted() {
|
||||
let (pack, fetcher) = layered_pack("dec-deleted");
|
||||
let target = seeded("dec-deleted");
|
||||
// 先完整导入一次(有基线了),再删掉其中两个文件。
|
||||
let first = block_on(import_pack(
|
||||
&ImportRequest::new(&pack, &target)
|
||||
.with_fetcher(&fetcher)
|
||||
.with_decisions(ConflictDecisions::overwrite_all()),
|
||||
));
|
||||
assert_eq!(first.status, ImportStatus::Success, "{:?}", first.error);
|
||||
std::fs::remove_file(target.join("config/known.toml")).expect("删文件");
|
||||
std::fs::remove_file(target.join("mods/remote.jar")).expect("删文件");
|
||||
|
||||
let scan = scan_pack(&ImportRequest::new(&pack, &target));
|
||||
assert!(
|
||||
paths(&scan).is_empty(),
|
||||
"用户删过的文件不是「已存在且内容不同」,不该问:{:?}",
|
||||
paths(&scan)
|
||||
);
|
||||
|
||||
// 导入同样不把它们装回来(原有的 `UserDeleted` 判据仍然生效)。
|
||||
let second = block_on(import_pack(
|
||||
&ImportRequest::new(&pack, &target).with_fetcher(&fetcher),
|
||||
));
|
||||
assert_eq!(second.status, ImportStatus::Partial);
|
||||
assert!(!target.join("config/known.toml").exists(), "不许偷偷装回来");
|
||||
assert!(
|
||||
second.conflicts.iter().any(|conflict| {
|
||||
conflict.path == "config/known.toml" && conflict.reason == ConflictReason::UserDeleted
|
||||
}),
|
||||
"但仍要按事记账:{:?}",
|
||||
second.conflicts
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_decision_means_keep_and_nothing_is_overwritten() {
|
||||
let (pack, fetcher) = layered_pack("dec-keep");
|
||||
let target = seeded("dec-keep");
|
||||
let report = block_on(import_pack(
|
||||
&ImportRequest::new(&pack, &target).with_fetcher(&fetcher),
|
||||
));
|
||||
|
||||
// 磁盘内容逐字节没变(**这是"缺省不覆盖"的直接证据**)。
|
||||
assert_eq!(
|
||||
read_text(&target.join("config/known.toml")),
|
||||
"shared = mine\n"
|
||||
);
|
||||
assert_eq!(read_text(&target.join("mods/layer.jar")), "my layer\n");
|
||||
// 报告里这两条都记成 KeptUser + KeepUser。
|
||||
for path in ["config/known.toml", "mods/layer.jar"] {
|
||||
let conflict = report
|
||||
.conflicts
|
||||
.iter()
|
||||
.find(|conflict| conflict.path == path)
|
||||
.unwrap_or_else(|| panic!("{path} 要进冲突清单:{:?}", report.conflicts));
|
||||
assert_eq!(
|
||||
conflict.choice,
|
||||
crate::addon::import::ConflictChoice::KeepUser
|
||||
);
|
||||
let file = report
|
||||
.files
|
||||
.iter()
|
||||
.find(|file| file.path == path)
|
||||
.expect("逐文件结果里也要有它");
|
||||
assert_eq!(file.action, FileAction::KeptUser);
|
||||
}
|
||||
// 不冲突的那些照常落地(「保留」不会把整次导入变成什么都不做)。
|
||||
assert_eq!(
|
||||
read_text(&target.join("mods/remote.jar")),
|
||||
"remote jar bytes"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn per_item_overwrite_touches_only_the_named_path() {
|
||||
let (pack, fetcher) = layered_pack("dec-item");
|
||||
let target = seeded("dec-item");
|
||||
let scan = scan_pack(&ImportRequest::new(&pack, &target));
|
||||
assert_eq!(paths(&scan), vec!["config/known.toml", "mods/layer.jar"]);
|
||||
|
||||
// 只对 `config/known.toml` 说"覆盖",另一条不给决策。
|
||||
let decisions =
|
||||
ConflictDecisions::keep_all().with("config/known.toml", ConflictChoice::Overwrite);
|
||||
let report = block_on(import_pack(
|
||||
&ImportRequest::new(&pack, &target)
|
||||
.with_fetcher(&fetcher)
|
||||
.with_decisions(decisions),
|
||||
));
|
||||
|
||||
// 最终文件集:点名那条被覆盖,没点名那条原样保留。
|
||||
assert_eq!(
|
||||
read_text(&target.join("config/known.toml")),
|
||||
"shared = client\n"
|
||||
);
|
||||
assert_eq!(read_text(&target.join("mods/layer.jar")), "my layer\n");
|
||||
assert_eq!(read_text(&target.join("config/same.toml")), "same = 1\n");
|
||||
assert_eq!(
|
||||
read_text(&target.join("mods/remote.jar")),
|
||||
"remote jar bytes"
|
||||
);
|
||||
// 有一条冲突按「保留」处置 ⇒ `Partial`(**不是** `Success`:实例可用,但内容与包不全一致)。
|
||||
assert_eq!(report.status, ImportStatus::Partial, "{:?}", report.error);
|
||||
let mut chosen: Vec<(&str, ConflictChoice)> = report
|
||||
.conflicts
|
||||
.iter()
|
||||
.map(|conflict| (conflict.path.as_str(), conflict.choice))
|
||||
.collect();
|
||||
// 报告里的顺序是落盘顺序(overrides 组在前、client-overrides 在后),比较前按路径排序。
|
||||
chosen.sort_by(|left, right| left.0.cmp(right.0));
|
||||
assert_eq!(
|
||||
chosen,
|
||||
vec![
|
||||
("config/known.toml", ConflictChoice::Overwrite),
|
||||
("mods/layer.jar", ConflictChoice::KeepUser),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overwrite_all_is_the_batch_channel_from_the_same_table() {
|
||||
let (pack, fetcher) = layered_pack("dec-all");
|
||||
let target = seeded("dec-all");
|
||||
let scan = scan_pack(&ImportRequest::new(&pack, &target));
|
||||
let report = block_on(import_pack(
|
||||
&ImportRequest::new(&pack, &target)
|
||||
.with_fetcher(&fetcher)
|
||||
.with_decisions(scan.overwrite_all()),
|
||||
));
|
||||
|
||||
assert_eq!(
|
||||
read_text(&target.join("config/known.toml")),
|
||||
"shared = client\n"
|
||||
);
|
||||
assert_eq!(
|
||||
read_text(&target.join("mods/layer.jar")),
|
||||
"layer from overrides\n"
|
||||
);
|
||||
assert_eq!(report.status, ImportStatus::Success, "{:?}", report.error);
|
||||
assert!(report
|
||||
.conflicts
|
||||
.iter()
|
||||
.all(|conflict| conflict.choice == ConflictChoice::Overwrite));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_kept_file_is_still_a_conflict_next_time() {
|
||||
// 基线里必须记**包内**的哈希:若记的是磁盘哈希,下一次导入会判成"用户没动过 ⇒ 整合包升级"
|
||||
// 而静默覆盖——那正是本轮要禁的行为。
|
||||
let (pack, fetcher) = layered_pack("dec-baseline");
|
||||
let target = seeded("dec-baseline");
|
||||
for _ in 0..2 {
|
||||
let report = block_on(import_pack(
|
||||
&ImportRequest::new(&pack, &target).with_fetcher(&fetcher),
|
||||
));
|
||||
let conflict = report
|
||||
.conflicts
|
||||
.iter()
|
||||
.find(|conflict| conflict.path == "config/known.toml")
|
||||
.expect("第二次也必须报冲突");
|
||||
assert_eq!(conflict.choice, ConflictChoice::KeepUser);
|
||||
assert_eq!(
|
||||
read_text(&target.join("config/known.toml")),
|
||||
"shared = mine\n"
|
||||
);
|
||||
}
|
||||
// 第二次的成因变成"用户改过"(有基线了),而不是"没有基线"。
|
||||
let scan = scan_pack(&ImportRequest::new(&pack, &target));
|
||||
let known = &scan.conflicts[0];
|
||||
assert_eq!(known.reason, ConflictReason::UserModified);
|
||||
assert_eq!(
|
||||
known.base_sha1.as_deref(),
|
||||
Some(sha1(KNOWN_CLIENT).as_str())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curseforge_entries_without_a_path_are_counted_not_guessed() {
|
||||
// CF 的 `files[]` 只有 projectID/fileID:补全前没有路径 ⇒ 预检判不了,只记条数。
|
||||
let fixture = super::fixtures::curseforge("dec-cf");
|
||||
let root = TempRoot::new("dec-cf");
|
||||
let target = root.join("instance");
|
||||
let scan = scan_pack(&ImportRequest::new(&fixture.zip, &target));
|
||||
|
||||
assert!(scan.is_ok(), "{:?}", scan.error);
|
||||
assert_eq!(scan.unknown_remote, 2, "两条 CF 远端条目都判不了");
|
||||
assert!(
|
||||
scan.notes
|
||||
.iter()
|
||||
.any(|note| note.message_key == crate::addon::import::keys::NOTE_REMOTE_PATH_UNKNOWN),
|
||||
"要如实说「还有 N 条判不了」:{:?}",
|
||||
scan.notes
|
||||
);
|
||||
// overrides 里的三条(example.jar / known.toml / broken.toml)在空目标上不冲突。
|
||||
assert!(paths(&scan).is_empty(), "{:?}", paths(&scan));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_broken_pack_reports_the_error_instead_of_panicking() {
|
||||
let root = TempRoot::new("dec-broken");
|
||||
let bad = root.join("bad.zip");
|
||||
write_file(&bad, b"not a zip at all");
|
||||
let scan = scan_pack(&ImportRequest::new(&bad, &root.join("instance")));
|
||||
assert!(!scan.is_ok(), "坏包必须给 error");
|
||||
assert!(scan.conflicts.is_empty());
|
||||
assert!(!scan.needs_decision(), "判不了就没有可决策的项");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_scan_does_not_touch_the_target_directory() {
|
||||
// 预检是只读的:连目标目录都不该建出来。
|
||||
let (pack, _) = layered_pack("dec-readonly");
|
||||
let root = TempRoot::new("dec-readonly");
|
||||
let target = root.join("instance");
|
||||
let scan = scan_pack(&ImportRequest::new(&pack, &target));
|
||||
assert!(scan.is_ok(), "{:?}", scan.error);
|
||||
assert!(!target.exists(), "预检不许创建目标目录");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mrpack_stub_keeps_its_remote_entries_out_of_the_conflict_list() {
|
||||
// 对照夹具(M111 的 mrpack_stub):远端条目自带真实摘要,目标里没有 ⇒ 一条冲突都没有。
|
||||
let (fixture, fetcher) = mrpack_stub("dec-stub");
|
||||
let root = TempRoot::new("dec-stub");
|
||||
let target = root.join("instance");
|
||||
let scan = scan_pack(&ImportRequest::new(&fixture.zip, &target));
|
||||
assert!(scan.is_ok(), "{:?}", scan.error);
|
||||
assert!(scan.conflicts.is_empty(), "{:?}", paths(&scan));
|
||||
let report = block_on(import_pack(
|
||||
&ImportRequest::new(&fixture.zip, &target).with_fetcher(&fetcher),
|
||||
));
|
||||
assert_eq!(report.status, ImportStatus::Success, "{:?}", report.error);
|
||||
}
|
||||
@@ -32,7 +32,7 @@ use crate::download::blocking::block_on;
|
||||
|
||||
use super::support::{StubFetcher, JEI_BYTES, JEI_PLACEHOLDER_URL, OPTIONAL_PLACEHOLDER_URL};
|
||||
use super::{
|
||||
fixtures, list_tree, read_text, temp_root, write_file, write_zip, write_zip_with_symlink,
|
||||
fixtures, list_tree, read_text, write_file, write_zip, write_zip_with_symlink, TempRoot,
|
||||
};
|
||||
|
||||
/// 一份形状合法的最小 CF 清单(恶意包要能被"探测成功",失败必须发生在它该发生的阶段)。
|
||||
@@ -56,11 +56,13 @@ fn assert_clean(target: &Path) {
|
||||
}
|
||||
}
|
||||
|
||||
fn failing(tag: &str, entries: &[(&str, &[u8])]) -> (std::path::PathBuf, std::path::PathBuf) {
|
||||
let root = temp_root(tag);
|
||||
let pack = root.join("evil.zip");
|
||||
/// 恶意样本包 + 目标目录。返回的 `TempRoot` **`Deref` 到那个 zip**,同时持有临时根:
|
||||
/// 用它喂 `ImportRequest::new(&pack, ..)` 之后包文件还在(不带守卫的话函数一返回包就被删了,
|
||||
/// 导入会以 `pack_io` 失败——那是"测错了东西还看不出来"的典型)。
|
||||
fn failing(tag: &str, entries: &[(&str, &[u8])]) -> (TempRoot, std::path::PathBuf) {
|
||||
let pack = TempRoot::at(tag, "evil.zip");
|
||||
write_zip(&pack, entries);
|
||||
let target = root.join("instance");
|
||||
let target = pack.parent().expect("临时根").join("instance");
|
||||
(pack, target)
|
||||
}
|
||||
|
||||
@@ -122,7 +124,7 @@ fn windows_device_name_proves_the_second_check_is_live() {
|
||||
|
||||
#[test]
|
||||
fn symlink_entry_rolls_back_with_zero_residue() {
|
||||
let root = temp_root("fail-symlink");
|
||||
let root = TempRoot::new("fail-symlink");
|
||||
let pack = root.join("evil.zip");
|
||||
let entries: [(&str, &[u8]); 1] = [("manifest.json", CF_MANIFEST)];
|
||||
write_zip_with_symlink(&pack, &entries, "mods/evil.jar", "../../../etc/passwd");
|
||||
@@ -162,7 +164,7 @@ fn declared_hash_mismatch_rolls_back_everything() {
|
||||
// 原始 M110 夹具:`hashes.sha1` 是形状正确的**占位值**,必然不等于 stub 给的字节。
|
||||
// 这是"声明与实际不符"——它**不是**"暂时搞不到",必须整包回滚。
|
||||
let fixture = fixtures::modrinth("fail-integrity");
|
||||
let root = temp_root("fail-integrity-out");
|
||||
let root = TempRoot::new("fail-integrity-out");
|
||||
let target = root.join("instance");
|
||||
let fetcher = StubFetcher::new()
|
||||
.with_file(JEI_PLACEHOLDER_URL, JEI_BYTES)
|
||||
@@ -213,7 +215,7 @@ fn commit_failure_rolls_back_partially_applied_files() {
|
||||
// `<dest>.ullbak-<pid>`,而那个位置已被一个**非空目录**占住 ⇒ `rename(dest → backup)`
|
||||
// 必然失败(文件改不成非空目录,Windows / Unix 一致)。
|
||||
// 这覆盖 journal 回滚 + `Drop` 兜底:前半截已经搬进去的文件(基线排最前)必须被撤回。
|
||||
let root = temp_root("fail-commit");
|
||||
let root = TempRoot::new("fail-commit");
|
||||
let source = fixtures::multimc("fail-commit-src");
|
||||
let target = root.join("instance");
|
||||
let first = block_on(import_pack(&ImportRequest::new(&source, &target)));
|
||||
|
||||
@@ -20,7 +20,7 @@ use crate::addon::import::{
|
||||
use crate::download::blocking::block_on;
|
||||
|
||||
use super::support::sha1;
|
||||
use super::{read_text, temp_root, write_file, zip_dir};
|
||||
use super::{read_text, write_file, zip_dir, TempRoot};
|
||||
|
||||
/// 打一个只有 `files: []` 的 mrpack(覆盖内容全在 overrides 系列目录里)。
|
||||
fn index_json(name: &str) -> String {
|
||||
@@ -42,7 +42,7 @@ fn shadow_notes(report: &crate::addon::import::ImportReport) -> Vec<&str> {
|
||||
|
||||
#[test]
|
||||
fn same_path_with_different_bytes_lands_the_later_side_only() {
|
||||
let root = temp_root("layer-same-path");
|
||||
let root = TempRoot::new("layer-same-path");
|
||||
let dir = root.join("src");
|
||||
write_file(
|
||||
&dir.join("modrinth.index.json"),
|
||||
@@ -102,7 +102,7 @@ fn same_path_with_different_bytes_lands_the_later_side_only() {
|
||||
|
||||
#[test]
|
||||
fn the_same_bytes_at_two_paths_are_two_files() {
|
||||
let root = temp_root("layer-same-bytes");
|
||||
let root = TempRoot::new("layer-same-bytes");
|
||||
let dir = root.join("src");
|
||||
write_file(
|
||||
&dir.join("modrinth.index.json"),
|
||||
@@ -143,7 +143,7 @@ fn the_same_bytes_at_two_paths_are_two_files() {
|
||||
|
||||
#[test]
|
||||
fn identical_bytes_on_both_sides_shadow_without_claiming_a_conflict() {
|
||||
let root = temp_root("layer-same-content");
|
||||
let root = TempRoot::new("layer-same-content");
|
||||
let dir = root.join("src");
|
||||
write_file(
|
||||
&dir.join("modrinth.index.json"),
|
||||
@@ -176,7 +176,7 @@ fn identical_bytes_on_both_sides_shadow_without_claiming_a_conflict() {
|
||||
fn the_baseline_remembers_the_winning_bytes() {
|
||||
// 叠层顺序的**后果**:基线要记胜者那份。记错的话,下一次升级会把"用户没动过"判成
|
||||
// "用户改过"(或反过来静默覆盖用户改动)。
|
||||
let root = temp_root("layer-baseline");
|
||||
let root = TempRoot::new("layer-baseline");
|
||||
let dir = root.join("src");
|
||||
write_file(
|
||||
&dir.join("modrinth.index.json"),
|
||||
|
||||
@@ -10,14 +10,15 @@ use crate::addon::import::{
|
||||
use crate::download::blocking::block_on;
|
||||
|
||||
use super::support::mrpack_stub;
|
||||
use super::{list_tree, read_text, temp_root, write_file, zip_dir};
|
||||
use super::{list_tree, read_text, write_file, zip_dir, TempRoot};
|
||||
|
||||
use crate::addon::import::{PackBaseline, BASELINE_FILE};
|
||||
|
||||
#[test]
|
||||
fn user_modified_file_is_kept_and_reported() {
|
||||
let (fixture, fetcher) = mrpack_stub("merge-modified");
|
||||
let target = temp_root("merge-modified-out").join("instance");
|
||||
let root = TempRoot::new("merge-modified-out");
|
||||
let target = root.join("instance");
|
||||
let first = block_on(import_pack(
|
||||
&ImportRequest::new(&fixture.zip, &target).with_fetcher(&fetcher),
|
||||
));
|
||||
@@ -61,7 +62,8 @@ fn user_modified_file_is_kept_and_reported() {
|
||||
#[test]
|
||||
fn user_deleted_file_is_not_restored() {
|
||||
let (fixture, fetcher) = mrpack_stub("merge-deleted");
|
||||
let target = temp_root("merge-deleted-out").join("instance");
|
||||
let root = TempRoot::new("merge-deleted-out");
|
||||
let target = root.join("instance");
|
||||
let first = block_on(import_pack(
|
||||
&ImportRequest::new(&fixture.zip, &target).with_fetcher(&fetcher),
|
||||
));
|
||||
@@ -91,7 +93,8 @@ fn user_deleted_file_is_not_restored() {
|
||||
#[test]
|
||||
fn untouched_file_is_upgraded_by_a_newer_pack() {
|
||||
let (fixture, fetcher) = mrpack_stub("merge-upgrade");
|
||||
let target = temp_root("merge-upgrade-out").join("instance");
|
||||
let root = TempRoot::new("merge-upgrade-out");
|
||||
let target = root.join("instance");
|
||||
let first = block_on(import_pack(
|
||||
&ImportRequest::new(&fixture.zip, &target).with_fetcher(&fetcher),
|
||||
));
|
||||
@@ -123,7 +126,8 @@ fn untouched_file_is_upgraded_by_a_newer_pack() {
|
||||
#[test]
|
||||
fn pre_existing_file_without_baseline_is_kept() {
|
||||
let (fixture, fetcher) = mrpack_stub("merge-existing");
|
||||
let target = temp_root("merge-existing-out").join("instance");
|
||||
let root = TempRoot::new("merge-existing-out");
|
||||
let target = root.join("instance");
|
||||
// 首次导入前磁盘上就有同名文件:没有基线可判 ⇒ 最安全的一侧(不覆盖)。
|
||||
write_file(&target.join("config/known.toml"), b"mine\n");
|
||||
let report = block_on(import_pack(
|
||||
@@ -144,7 +148,8 @@ fn pre_existing_file_without_baseline_is_kept() {
|
||||
#[test]
|
||||
fn overwrite_policy_replaces_user_file_and_reports_the_choice() {
|
||||
let (fixture, fetcher) = mrpack_stub("merge-overwrite");
|
||||
let target = temp_root("merge-overwrite-out").join("instance");
|
||||
let root = TempRoot::new("merge-overwrite-out");
|
||||
let target = root.join("instance");
|
||||
let first = block_on(import_pack(
|
||||
&ImportRequest::new(&fixture.zip, &target).with_fetcher(&fetcher),
|
||||
));
|
||||
@@ -174,7 +179,7 @@ fn overwrite_policy_replaces_user_file_and_reports_the_choice() {
|
||||
#[test]
|
||||
fn client_overrides_wins_over_overrides() {
|
||||
// Modrinth 官方客户端语义:`client-overrides/` 后写 = 胜(HMCL 恰好相反)。
|
||||
let root = temp_root("merge-priority");
|
||||
let root = TempRoot::new("merge-priority");
|
||||
let dir = root.join("src");
|
||||
write_file(
|
||||
&dir.join("modrinth.index.json"),
|
||||
@@ -209,7 +214,7 @@ fn client_overrides_wins_over_overrides() {
|
||||
fn baseline_read_and_write_paths_are_the_same_file() {
|
||||
// 复审 r5 P1-1 的回归钉:读侧(`PackBaseline::path`)与写侧(`relative_path` 拼 root)
|
||||
// **必须解析到同一个路径**。只测"读得到"在大小写不敏感的系统上会假绿。
|
||||
let root = temp_root("baseline-path").join("instance");
|
||||
let root = TempRoot::under("baseline-path", "instance");
|
||||
let read = PackBaseline::path(&root);
|
||||
let write = root.join(PackBaseline::relative_path());
|
||||
assert_eq!(read, write, "读写必须是同一个文件:{read:?} vs {write:?}");
|
||||
@@ -221,7 +226,7 @@ fn baseline_read_and_write_paths_are_the_same_file() {
|
||||
assert_eq!(
|
||||
read.parent(),
|
||||
Some(
|
||||
crate::game::layout::GameLayout::new(&root)
|
||||
crate::game::layout::GameLayout::new(root.to_path_buf())
|
||||
.ull_root()
|
||||
.as_path()
|
||||
)
|
||||
@@ -245,7 +250,7 @@ fn baseline_read_and_write_paths_are_the_same_file() {
|
||||
#[test]
|
||||
fn baseline_of_another_pack_is_not_used_as_base() {
|
||||
// 同一目录先后导入两个整合包:别人的基线当 base 会把"用户改过"误判成"整合包升级"而静默覆盖。
|
||||
let root = temp_root("merge-identity");
|
||||
let root = TempRoot::new("merge-identity");
|
||||
let source = super::fixtures::multimc("merge-identity-a");
|
||||
let target = root.join("instance");
|
||||
let first = block_on(import_pack(&ImportRequest::new(&source, &target)));
|
||||
|
||||
@@ -8,12 +8,13 @@ use crate::addon::loader::LoaderKind;
|
||||
use crate::download::blocking::block_on;
|
||||
|
||||
use super::support::{mrpack_stub, JEI_BYTES, OPTIONAL_BYTES};
|
||||
use super::{fixtures, list_tree, read_text, temp_root};
|
||||
use super::{fixtures, list_tree, read_text, TempRoot};
|
||||
|
||||
#[test]
|
||||
fn imports_mrpack_end_to_end() {
|
||||
let (fixture, fetcher) = mrpack_stub("mr-e2e");
|
||||
let target = temp_root("mr-e2e-out").join("instance");
|
||||
let root = TempRoot::new("mr-e2e-out");
|
||||
let target = root.join("instance");
|
||||
let request = ImportRequest::new(&fixture.zip, &target).with_fetcher(&fetcher);
|
||||
let report = block_on(import_pack(&request));
|
||||
|
||||
@@ -95,7 +96,8 @@ fn engine_artifacts_never_reach_the_instance() {
|
||||
// 供下次续传;它们躺在暂存目录里,提交前必须剔掉,否则会变成实例里的"内容"。
|
||||
let (fixture, fetcher) = mrpack_stub("mr-artifacts");
|
||||
let fetcher = fetcher.with_engine_artifacts();
|
||||
let target = temp_root("mr-artifacts-out").join("instance");
|
||||
let root = TempRoot::new("mr-artifacts-out");
|
||||
let target = root.join("instance");
|
||||
let report = block_on(import_pack(
|
||||
&ImportRequest::new(&fixture.zip, &target).with_fetcher(&fetcher),
|
||||
));
|
||||
@@ -135,7 +137,8 @@ fn disabled_suffix_is_preserved_verbatim() {
|
||||
);
|
||||
super::zip_dir(&fixture.dir, &fixture.zip);
|
||||
|
||||
let target = temp_root("mr-disabled-out").join("instance");
|
||||
let root = TempRoot::new("mr-disabled-out");
|
||||
let target = root.join("instance");
|
||||
let request = ImportRequest::new(&fixture.zip, &target).with_fetcher(&fetcher);
|
||||
let report = block_on(import_pack(&request));
|
||||
|
||||
|
||||
@@ -6,12 +6,13 @@ use crate::addon::import::{import_pack, keys, ImportRequest, ImportStatus};
|
||||
use crate::addon::loader::LoaderKind;
|
||||
use crate::download::blocking::block_on;
|
||||
|
||||
use super::{fixtures, list_tree, read_text, temp_root, write_file};
|
||||
use super::{fixtures, list_tree, read_text, write_file, TempRoot};
|
||||
|
||||
#[test]
|
||||
fn imports_multimc_instance_end_to_end() {
|
||||
let source = fixtures::multimc("mmc-e2e");
|
||||
let target = temp_root("mmc-e2e-out").join("instance");
|
||||
let root = TempRoot::new("mmc-e2e-out");
|
||||
let target = root.join("instance");
|
||||
let request = ImportRequest::new(&source, &target);
|
||||
let report = block_on(import_pack(&request));
|
||||
|
||||
@@ -57,7 +58,8 @@ fn imports_multimc_instance_end_to_end() {
|
||||
#[test]
|
||||
fn unsupported_components_are_listed_one_by_one_not_dropped() {
|
||||
let source = fixtures::multimc("mmc-components");
|
||||
let target = temp_root("mmc-components-out").join("instance");
|
||||
let root = TempRoot::new("mmc-components-out");
|
||||
let target = root.join("instance");
|
||||
let request = ImportRequest::new(&source, &target);
|
||||
let report = block_on(import_pack(&request));
|
||||
|
||||
@@ -106,7 +108,7 @@ fn unsupported_components_are_listed_one_by_one_not_dropped() {
|
||||
|
||||
#[test]
|
||||
fn reads_memory_from_instance_cfg_only_when_override_memory_is_on() {
|
||||
let root = temp_root("mmc-mem");
|
||||
let root = TempRoot::new("mmc-mem");
|
||||
let source = root.join("instance");
|
||||
write_file(
|
||||
&source.join("mmc-pack.json"),
|
||||
@@ -120,7 +122,8 @@ fn reads_memory_from_instance_cfg_only_when_override_memory_is_on() {
|
||||
);
|
||||
write_file(&source.join(".minecraft/mods/a.jar"), b"jar");
|
||||
|
||||
let target = temp_root("mmc-mem-out").join("instance");
|
||||
let root = TempRoot::new("mmc-mem-out");
|
||||
let target = root.join("instance");
|
||||
let report = block_on(import_pack(&ImportRequest::new(&source, &target)));
|
||||
assert_eq!(report.max_memory_mb, Some(6144), "{:?}", report.error);
|
||||
assert!(report
|
||||
@@ -140,7 +143,8 @@ fn reads_memory_from_instance_cfg_only_when_override_memory_is_on() {
|
||||
b"InstanceType=OneSix\nname=gated\nOverrideMemory=false\nMaxMemAlloc=6144\n",
|
||||
);
|
||||
write_file(&gated.join(".minecraft/mods/a.jar"), b"jar");
|
||||
let gated_target = temp_root("mmc-mem-gated-out").join("instance");
|
||||
let root = TempRoot::new("mmc-mem-gated-out");
|
||||
let gated_target = root.join("instance");
|
||||
let gated_report = block_on(import_pack(&ImportRequest::new(&gated, &gated_target)));
|
||||
assert_eq!(gated_report.max_memory_mb, None);
|
||||
assert!(gated_report
|
||||
@@ -153,7 +157,7 @@ fn reads_memory_from_instance_cfg_only_when_override_memory_is_on() {
|
||||
fn registers_the_instance_when_a_launcher_layout_is_given() {
|
||||
// 登记阶段(读取清单 → 解压 → 补全 → 落地 → **注册实例**)的 happy path:
|
||||
// 内容落盘后写 profiles.json + config/instances/<id>.json。
|
||||
let root = temp_root("mmc-register");
|
||||
let root = TempRoot::new("mmc-register");
|
||||
let source = fixtures::multimc("mmc-register-src");
|
||||
let target = root.join("instance");
|
||||
let home = root.join("data");
|
||||
@@ -181,7 +185,7 @@ fn registers_the_instance_when_a_launcher_layout_is_given() {
|
||||
#[test]
|
||||
fn rejects_mmc_pack_with_other_format_version() {
|
||||
// Prism 的规矩:`formatVersion` 必须恰为 1,否则整份丢弃(不是降级解析)。
|
||||
let root = temp_root("mmc-v2");
|
||||
let root = TempRoot::new("mmc-v2");
|
||||
let source = root.join("instance");
|
||||
write_file(
|
||||
&source.join("mmc-pack.json"),
|
||||
@@ -194,7 +198,8 @@ fn rejects_mmc_pack_with_other_format_version() {
|
||||
);
|
||||
write_file(&source.join(".minecraft/mods/a.jar"), b"jar");
|
||||
|
||||
let target = temp_root("mmc-v2-out").join("instance");
|
||||
let root = TempRoot::new("mmc-v2-out");
|
||||
let target = root.join("instance");
|
||||
let report = block_on(import_pack(&ImportRequest::new(&source, &target)));
|
||||
|
||||
assert_eq!(report.status, ImportStatus::Failed);
|
||||
|
||||
@@ -8,7 +8,7 @@ use crate::addon::import::{
|
||||
use crate::download::blocking::block_on;
|
||||
|
||||
use super::support::mrpack_stub;
|
||||
use super::temp_root;
|
||||
use super::TempRoot;
|
||||
|
||||
#[test]
|
||||
fn status_maps_to_three_distinct_exit_codes() {
|
||||
@@ -42,7 +42,7 @@ fn status_maps_to_three_distinct_exit_codes() {
|
||||
#[test]
|
||||
fn report_serialises_to_stable_json_with_structured_error() {
|
||||
// 失败也要输出结构化 JSON(不抛栈):`status` / `error.code` / `rolled_back` 一个不少。
|
||||
let root = temp_root("report-json");
|
||||
let root = TempRoot::new("report-json");
|
||||
let pack = root.join("evil.zip");
|
||||
super::write_zip(&pack, &[("manifest.json", b"{\"manifestType\":")]);
|
||||
let target = root.join("instance");
|
||||
@@ -60,7 +60,8 @@ fn report_serialises_to_stable_json_with_structured_error() {
|
||||
#[test]
|
||||
fn successful_report_serialises_counts_and_loader() {
|
||||
let (fixture, fetcher) = mrpack_stub("report-ok");
|
||||
let target = temp_root("report-ok-out").join("instance");
|
||||
let root = TempRoot::new("report-ok-out");
|
||||
let target = root.join("instance");
|
||||
let report = block_on(import_pack(
|
||||
&ImportRequest::new(&fixture.zip, &target).with_fetcher(&fetcher),
|
||||
));
|
||||
@@ -133,6 +134,10 @@ const SOURCES: &[&str] = &[
|
||||
include_str!("../import/phase.rs"),
|
||||
include_str!("../import/pipeline.rs"),
|
||||
include_str!("../import/plan.rs"),
|
||||
// M160:预检(冲突清单)与逐文件决策的引用也要算进这张表,
|
||||
// 否则 `NOTE_REMOTE_PATH_UNKNOWN` 会被判成"死常量"。
|
||||
include_str!("../import/scan.rs"),
|
||||
include_str!("../import/decision.rs"),
|
||||
include_str!("../import/stage.rs"),
|
||||
include_str!("../mod.rs"),
|
||||
];
|
||||
|
||||
@@ -21,25 +21,23 @@
|
||||
#[path = "../pack/tests/fixtures.rs"]
|
||||
mod fixtures;
|
||||
mod import_curseforge;
|
||||
mod import_decisions;
|
||||
mod import_failures;
|
||||
mod import_layering;
|
||||
mod import_merge;
|
||||
mod import_modrinth;
|
||||
mod import_multimc;
|
||||
mod import_report;
|
||||
mod roundtrip_three;
|
||||
mod support;
|
||||
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
/// 本次进程内唯一的临时根目录(沿用仓库既有约定:不引 `tempfile`)。
|
||||
pub(crate) fn temp_root(tag: &str) -> PathBuf {
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
std::env::temp_dir().join(format!("ull-import-{}-{tag}-{unique}", std::process::id()))
|
||||
}
|
||||
/// 一次性临时根(`Drop` 即删,panic 也删)。实现与 `pack::tests` 共用一份:
|
||||
/// [`crate::addon::temp::TempRoot`](两族用例都会建临时目录,守卫只此一处)。
|
||||
pub(crate) use crate::addon::temp::TempRoot;
|
||||
|
||||
/// 写一个文件(自动建父目录)。
|
||||
pub(crate) fn write_file(path: &Path, bytes: &[u8]) {
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
//! `import → export → import` 三轮一致性(M160)。
|
||||
//!
|
||||
//! # 这条用例补的是哪笔账
|
||||
//!
|
||||
//! M111/M112 的往返各自只有半条链:导入侧测"包 → 实例",导出侧测"实例 → 包"(`export_roundtrip`
|
||||
//! 里明确登记了"M111 合并后要补一条 `import_pack → export → import_pack`")。本文件就是那条,
|
||||
//! 而且是**三轮**:包① → 实例 A → 包② → 实例 B → 包③ → 实例 C,三个实例的内容逐字节相等。
|
||||
//!
|
||||
//! 夹具带 **overrides 叠层**(`client-overrides/` 与 `overrides/` 同名不同内容),并且第二轮
|
||||
//! 的实例**预置了一个与包冲突的文件**:用户选了「覆盖」,于是三轮之后仍然一致——这条同时证明
|
||||
//! "决策真的生效"与"决策之后的一致性仍然成立"。
|
||||
//!
|
||||
//! # 为什么比的是"内容目录里的文件"而不是整棵树
|
||||
//!
|
||||
//! 实例里还有两样东西**不属于整合包内容**:`versions/<id>/<id>.json`(导出要读它,但导出器
|
||||
//! 不会把它打进包)与 `.ull/pack-baseline.json`(本仓库的三方合并基线)。两者都按设计不进包,
|
||||
//! 所以比较口径是 `mods/` `config/` 等 8 个内容目录。
|
||||
//!
|
||||
//! # 没验的部分
|
||||
//!
|
||||
//! 真实社区包(CurseForge / Modrinth 在线下载)不在本轮:远端条目一律走 `StubFetcher`,
|
||||
//! 导出一律走离线目录(`OfflineCatalog` ⇒ 全部内联)。这条欠账在完成报告里同样登记。
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
|
||||
use super::support::{sha1, StubFetcher};
|
||||
use super::{write_file, zip_dir, TempRoot};
|
||||
use crate::addon::export::{export_instance, ExportRequest};
|
||||
use crate::addon::import::{
|
||||
import_pack, ConflictChoice, ConflictDecisions, ImportRequest, ImportStatus,
|
||||
};
|
||||
use crate::addon::pack::format::PackFormat;
|
||||
use crate::addon::pack::modrinth::MODRINTH;
|
||||
use crate::addon::pack::PackArchive;
|
||||
use crate::download::blocking::block_on;
|
||||
|
||||
/// 内容目录白名单(与 `export::scan::DEFAULT_CONTENT_ROOTS` 同一条口径)。
|
||||
const CONTENT_ROOTS: [&str; 4] = ["mods", "config", "resourcepacks", "scripts"];
|
||||
|
||||
/// 夹具里 `client-overrides/` 胜出的那份内容。
|
||||
const KNOWN_CLIENT: &[u8] = b"shared = client\n";
|
||||
/// 夹具里 `overrides/` 侧(被遮蔽)。
|
||||
const KNOWN_OVERRIDES: &[u8] = b"shared = overrides\n";
|
||||
/// 仅 overrides 声明的文件。
|
||||
const LAYER: &[u8] = b"layer from overrides\n";
|
||||
/// 远端条目的内容(走 stub 下载)。
|
||||
const REMOTE: &[u8] = b"remote jar bytes";
|
||||
/// 第二轮实例里用户自己那份(与包冲突)。
|
||||
const USER_KNOWN: &[u8] = b"shared = mine\n";
|
||||
|
||||
/// 带叠层 + 一条远端条目的 mrpack。
|
||||
fn layered_pack(tag: &str) -> (TempRoot, StubFetcher) {
|
||||
let root = TempRoot::at(tag, "pack.mrpack");
|
||||
let dir = root.parent().expect("临时根").join("src");
|
||||
let index = format!(
|
||||
r#"{{"game":"minecraft","formatVersion":1,"versionId":"1.0.0","name":"M160 三轮夹具",
|
||||
"files":[{{"path":"mods/remote.jar",
|
||||
"hashes":{{"sha1":"{}","sha512":"{}"}},
|
||||
"downloads":["stub://mods/remote.jar"],"fileSize":{}}}],
|
||||
"dependencies":{{"minecraft":"1.20.1"}}}}"#,
|
||||
sha1(REMOTE),
|
||||
"0".repeat(128),
|
||||
REMOTE.len()
|
||||
);
|
||||
write_file(&dir.join("modrinth.index.json"), index.as_bytes());
|
||||
write_file(&dir.join("overrides/config/known.toml"), KNOWN_OVERRIDES);
|
||||
write_file(
|
||||
&dir.join("client-overrides/config/known.toml"),
|
||||
KNOWN_CLIENT,
|
||||
);
|
||||
write_file(&dir.join("overrides/mods/layer.jar"), LAYER);
|
||||
write_file(&dir.join("overrides/scripts/start.js"), b"// script\n");
|
||||
zip_dir(&dir, &root);
|
||||
let fetcher = StubFetcher::new().with_file("stub://mods/remote.jar", REMOTE);
|
||||
(root, fetcher)
|
||||
}
|
||||
|
||||
/// 内容目录里的文件(`/` 分隔、排序)→ 字节。
|
||||
fn content_files(root: &Path) -> BTreeMap<String, Vec<u8>> {
|
||||
let mut out = BTreeMap::new();
|
||||
for dir in CONTENT_ROOTS {
|
||||
let base = root.join(dir);
|
||||
if !base.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let mut pending = vec![base];
|
||||
while let Some(current) = pending.pop() {
|
||||
for entry in std::fs::read_dir(¤t).expect("读内容目录").flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
pending.push(path);
|
||||
continue;
|
||||
}
|
||||
let relative = path
|
||||
.strip_prefix(root)
|
||||
.expect("在实例内")
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/");
|
||||
out.insert(relative, std::fs::read(&path).expect("读内容文件"));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 给实例补一份版本 JSON(导出要读它拿游戏版本;导入**不装**游戏,所以得手工放)。
|
||||
fn write_version(root: &Path, id: &str, game: &str) {
|
||||
write_file(
|
||||
&root.join("versions").join(id).join(format!("{id}.json")),
|
||||
format!(r#"{{"id":"{game}"}}"#).as_bytes(),
|
||||
);
|
||||
}
|
||||
|
||||
/// 实例 → 包(走产品路径的导出器,离线目录 ⇒ 全部内联)。
|
||||
fn export(root: &Path, target: &Path) {
|
||||
let request = ExportRequest::new(root, target, &MODRINTH).with_version_id("1.20.1");
|
||||
let report = export_instance(&request).expect("导出");
|
||||
assert!(
|
||||
report.is_complete(),
|
||||
"夹具里每个文件都该能内联进包:{:?}",
|
||||
report.summary()
|
||||
);
|
||||
assert!(target.exists(), "导出产物不存在");
|
||||
}
|
||||
|
||||
/// 导出的包里覆盖条目的 `路径 → sha1`(三轮之间要比这一份)。
|
||||
fn pack_overrides(pack: &Path) -> BTreeMap<String, String> {
|
||||
let archive = PackArchive::open_default(pack).expect("打开导出的包");
|
||||
let (format, manifest) = crate::addon::pack::PackRegistry::builtin()
|
||||
.read(&archive)
|
||||
.expect("读清单");
|
||||
assert_eq!(format.id(), MODRINTH.id(), "导出目标是 mrpack");
|
||||
manifest
|
||||
.override_files()
|
||||
.map(|(_, file)| (file.path.clone(), file.sha1.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_export_import_three_rounds_keep_the_same_file_set() {
|
||||
let (pack1, fetcher) = layered_pack("rt3");
|
||||
let base = TempRoot::new("rt3");
|
||||
let (dir_a, dir_b, dir_c) = (base.join("a"), base.join("b"), base.join("c"));
|
||||
|
||||
// —— 第 1 轮:包① → 实例 A(干净目标)——
|
||||
let first = block_on(import_pack(
|
||||
&ImportRequest::new(&pack1, &dir_a).with_fetcher(&fetcher),
|
||||
));
|
||||
assert_eq!(first.status, ImportStatus::Success, "{:?}", first.error);
|
||||
assert_eq!(
|
||||
content_files(&dir_a).keys().cloned().collect::<Vec<_>>(),
|
||||
vec![
|
||||
"config/known.toml",
|
||||
"mods/layer.jar",
|
||||
"mods/remote.jar",
|
||||
"scripts/start.js"
|
||||
],
|
||||
"叠层的胜者是 client-overrides(不是 overrides);实际树:{:?}",
|
||||
super::list_tree(&dir_a)
|
||||
);
|
||||
write_version(&dir_a, "1.20.1", "1.20.1");
|
||||
let pack2 = base.join("round2.mrpack");
|
||||
export(&dir_a, &pack2);
|
||||
|
||||
// —— 第 2 轮:包② → 实例 B(**预置冲突**,用户选「覆盖」)——
|
||||
write_file(&dir_b.join("config/known.toml"), USER_KNOWN);
|
||||
let report = block_on(import_pack(
|
||||
&ImportRequest::new(&pack2, &dir_b)
|
||||
.with_fetcher(&fetcher)
|
||||
.with_decisions(
|
||||
ConflictDecisions::keep_all().with("config/known.toml", ConflictChoice::Overwrite),
|
||||
),
|
||||
));
|
||||
assert_eq!(report.status, ImportStatus::Success, "{:?}", report.error);
|
||||
let conflict = report
|
||||
.conflicts
|
||||
.iter()
|
||||
.find(|conflict| conflict.path == "config/known.toml")
|
||||
.expect("第二轮必须报出那个冲突");
|
||||
assert_eq!(conflict.choice, ConflictChoice::Overwrite);
|
||||
write_version(&dir_b, "1.20.1", "1.20.1");
|
||||
let pack3 = base.join("round3.mrpack");
|
||||
export(&dir_b, &pack3);
|
||||
|
||||
// —— 第 3 轮:包③ → 实例 C(干净目标)——
|
||||
let third = block_on(import_pack(
|
||||
&ImportRequest::new(&pack3, &dir_c).with_fetcher(&fetcher),
|
||||
));
|
||||
assert_eq!(third.status, ImportStatus::Success, "{:?}", third.error);
|
||||
|
||||
// 三个实例的内容逐字节相等。
|
||||
let a = content_files(&dir_a);
|
||||
let b = content_files(&dir_b);
|
||||
let c = content_files(&dir_c);
|
||||
assert_eq!(a, b, "第 1 轮与第 2 轮的文件集必须一致");
|
||||
assert_eq!(a, c, "第 1 轮与第 3 轮的文件集必须一致");
|
||||
assert_eq!(
|
||||
a.get("config/known.toml").map(Vec::as_slice),
|
||||
Some(KNOWN_CLIENT),
|
||||
"覆盖之后留下的是包内内容"
|
||||
);
|
||||
|
||||
// 包②与包③的覆盖条目(路径 + 哈希)也必须一致。
|
||||
assert_eq!(
|
||||
pack_overrides(&pack2),
|
||||
pack_overrides(&pack3),
|
||||
"两轮导出的覆盖条目必须一样"
|
||||
);
|
||||
// 导出的是"内联"形态(离线目录没有远端命中),所以覆盖条目就是那四条内容文件。
|
||||
assert_eq!(
|
||||
pack_overrides(&pack2).keys().cloned().collect::<Vec<_>>(),
|
||||
vec![
|
||||
"config/known.toml",
|
||||
"mods/layer.jar",
|
||||
"mods/remote.jar",
|
||||
"scripts/start.js"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeping_the_user_file_is_what_makes_the_rounds_diverge() {
|
||||
// 对照:同一个冲突若选「保留」,第二轮结果**按设计**与第一轮不同——这正是"决策真的进了管线"
|
||||
// 的反向证据(否则 `per_item` 那条就只是走了个过场)。
|
||||
let (pack1, fetcher) = layered_pack("rt3-keep");
|
||||
let base = TempRoot::new("rt3-keep");
|
||||
let (dir_a, dir_b) = (base.join("a"), base.join("b"));
|
||||
block_on(import_pack(
|
||||
&ImportRequest::new(&pack1, &dir_a).with_fetcher(&fetcher),
|
||||
));
|
||||
write_version(&dir_a, "1.20.1", "1.20.1");
|
||||
let pack2 = base.join("round2.mrpack");
|
||||
export(&dir_a, &pack2);
|
||||
|
||||
write_file(&dir_b.join("config/known.toml"), USER_KNOWN);
|
||||
let report = block_on(import_pack(
|
||||
&ImportRequest::new(&pack2, &dir_b).with_fetcher(&fetcher),
|
||||
));
|
||||
// 保留了一条冲突 ⇒ `Partial`(实例可用,但与包内容不全一致)。
|
||||
assert_eq!(report.status, ImportStatus::Partial, "{:?}", report.error);
|
||||
assert_eq!(
|
||||
std::fs::read(dir_b.join("config/known.toml")).expect("读"),
|
||||
USER_KNOWN,
|
||||
"缺省决策下用户那份必须原样留着"
|
||||
);
|
||||
let a = content_files(&dir_a);
|
||||
let b = content_files(&dir_b);
|
||||
assert_ne!(a, b, "选了保留就该与第一轮不同(这条证明决策真的生效)");
|
||||
let mut differing: Vec<&str> = Vec::new();
|
||||
for (path, bytes) in &a {
|
||||
if b.get(path) != Some(bytes) {
|
||||
differing.push(path.as_str());
|
||||
}
|
||||
}
|
||||
assert_eq!(differing, vec!["config/known.toml"], "只有那一处不同");
|
||||
}
|
||||
Reference in new issue
Block a user