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:
worker-modpackpick authored and AskaEth committed 2026-11-07 13:52:43 +08:00
1 parent a4f096eea3
commit 4defc41e8d
64 files changed
+3819 -442

No files matched your search

+58 -6
View File
@@ -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
}
+182
View File
@@ -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"]);
}
}
+4 -4
View File
@@ -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` 不参与判据
//!
+14 -6
View File
@@ -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 {
+22 -12
View File
@@ -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])
}
}
}
+8 -1
View File
@@ -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,
+292
View File
@@ -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,
})
}
+6 -3
View File
@@ -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;
+4 -1
View File
@@ -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};
+2 -1
View File
@@ -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;
+35 -6
View File
@@ -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)]
+2 -1
View File
@@ -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);
+87 -36
View File
@@ -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,
+20 -11
View File
@@ -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,
&[(
+6 -4
View File
@@ -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 {
+4 -4
View File
@@ -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,
+23 -7
View File
@@ -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(
+205
View 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);
}
+68 -7
View File
@@ -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,
+23 -14
View File
@@ -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"),
+35 -46
View File
@@ -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);
}
+107
View File
@@ -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);
}
+10 -8
View File
@@ -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"),
+15 -10
View File
@@ -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));
+14 -9
View File
@@ -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 -3
View File
@@ -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"),
];
+5 -7
View File
@@ -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(&current).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"], "只有那一处不同");
}