Merge branch 'feat/m152-packlast-finish-modpack-import-expo'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
This commit is contained in:
commit
4bb1591aca
12 files changed
+1047
-99
No files matched your search
@@ -18,7 +18,10 @@
|
||||
//!
|
||||
//! - **mrpack**:`overrides/` 与 `client-overrides/` 都解压,**后者胜**(Modrinth 官方客户端语义;
|
||||
//! HMCL 恰好相反);`server-overrides/` 客户端**不解压**;其余同名路径无官方优先级 ⇒ 按侧
|
||||
//! 优先级落地,不自创排序。
|
||||
//! 优先级落地,不自创排序。**同一实例路径被本包多份声明命中时**(跨侧或同侧重复),
|
||||
//! 逐条算清楚:胜者按上面这条规则定,被遮蔽的那条不读字节、只留一条
|
||||
//! `pack.import.note.override_shadowed` 注记说明「谁赢了 / 两份内容是否逐字节相同」
|
||||
//! (见 [`plan::Shadowed`])。一路径一行,不再出现"两条 Installed、前一条没落地"。
|
||||
//! - **CF**:溢出目录名取自清单 `overrides` 字段;`files[]` 只有 `projectID/fileID`,要 CF API
|
||||
//! 才能补出文件名/地址——仓库里还没有 CF 客户端(D10 §5),因此这部分条目的**当前正确行为**
|
||||
//! 是记成 `missing`(`Partial`),**绝不猜 `edge.forgecdn.net` 那类 URL**。
|
||||
@@ -77,7 +80,7 @@ pub use fetch::{
|
||||
};
|
||||
pub use merge::{MergeAction, MergeDecision};
|
||||
pub use pipeline::baseline_relative;
|
||||
pub use plan::{ImportPlan, PlanOverride, PlanRemote, SuffixVariant};
|
||||
pub use plan::{ImportPlan, PlanOverride, PlanRemote, Shadowed, SuffixVariant};
|
||||
pub use report::{
|
||||
keys, ConflictChoice, ConflictFile, ConflictReason, FileAction, FileSource, ImportIssue,
|
||||
ImportProgress, ImportProgressSink, ImportReport, ImportStage, ImportStatus, ImportedFile,
|
||||
|
||||
@@ -22,6 +22,11 @@ use crate::download::verify::{self, digest_bytes};
|
||||
use crate::download::HashAlgo;
|
||||
|
||||
/// 阶段一:覆盖条目落暂存 + 三方合并。
|
||||
///
|
||||
/// 被同包另一条声明遮蔽的条目([`super::plan::PlanOverride::shadowed`])**不读字节、不落地**:
|
||||
/// 它的结论已经由 `plan::layer::resolve` 算成一条 `NOTE_OVERRIDE_SHADOWED` 注记
|
||||
/// (进 `report.warnings`),而这条路径的行由真正的胜者给出——一路径一行,不再出现
|
||||
/// "两条 Installed、前一条的哈希其实没落地"。
|
||||
pub(crate) async fn extract_overrides(
|
||||
request: &ImportRequest<'_>,
|
||||
archive: &PackArchive,
|
||||
@@ -31,9 +36,14 @@ pub(crate) async fn extract_overrides(
|
||||
report: &mut ImportReport,
|
||||
next: &mut PackBaseline,
|
||||
) -> Result<(), ImportError> {
|
||||
let total = plan.overrides.len();
|
||||
let live: Vec<&super::plan::PlanOverride> = plan
|
||||
.overrides
|
||||
.iter()
|
||||
.filter(|entry| entry.shadowed.is_none())
|
||||
.collect();
|
||||
let total = live.len();
|
||||
let target = staging.target().to_path_buf();
|
||||
for (index, entry) in plan.overrides.iter().enumerate() {
|
||||
for (index, entry) in live.iter().enumerate() {
|
||||
tell(
|
||||
&request.progress,
|
||||
ImportStage::Extract,
|
||||
|
||||
@@ -17,19 +17,24 @@
|
||||
//! 目录内的条目顺序**不重要**——M110 的 [`crate::addon::pack::PackOverrides::files`] 按路径
|
||||
//! 排序,zip 条目顺序没有保留;而同一目录内路径唯一,所以"按 zip 条目顺序落盘"与"按路径
|
||||
//! 排序落盘"在无重复时等价。有重复(跨目录)时由侧优先级决定,不靠条目顺序。
|
||||
//! **同一实例路径被多份声明命中时逐条算清楚**([`PlanOverride::shadowed`]):胜者按上面
|
||||
//! 这条规则定,被遮蔽的那条不再读字节、只留一条 `NOTE_OVERRIDE_SHADOWED` 注记说明
|
||||
//! "谁赢了"和"两份内容是否逐字节相同"。这是"整包一个策略"到"逐文件事实"的那一步。
|
||||
//! 2. **`.disabled` 只认路径后缀**(官方 Modrinth 表达),`.old` **只识别与保留**(不清不删
|
||||
//! 不改名)。`env.client=optional` **不是**禁用("可选"=安装时可取消勾选)。判据在
|
||||
//! [`paths`]。
|
||||
//! 3. **MultiMC 的非加载器 component 逐项进报告**(`org.lwjgl3` / `net.fabricmc.intermediary`
|
||||
//! / 社区自定义 uid):静默丢它们等于"看起来装好了、其实少了补丁层"。
|
||||
|
||||
mod layer;
|
||||
pub mod paths;
|
||||
|
||||
pub use layer::Shadowed;
|
||||
pub use paths::{curse_class_dir, strip_mc_prefix, suffix_note, suffix_variant, SuffixVariant};
|
||||
|
||||
use super::super::pack::{
|
||||
curseforge, multimc, EnvSupport, PackArchive, PackError, PackFile, PackLoader, PackManifest,
|
||||
PackOverrides, PackSide,
|
||||
PackSide,
|
||||
};
|
||||
use super::error::ImportError;
|
||||
use super::report::{keys, FileSource, ImportIssue, LoaderTarget, RejectedComponent};
|
||||
@@ -51,6 +56,8 @@ pub struct PlanOverride {
|
||||
pub disabled: bool,
|
||||
/// 可选注记(`.disabled` / `.old`)。
|
||||
pub note: Option<ImportIssue>,
|
||||
/// 被同包另一条声明遮蔽时的事实(`None` = 本条目就是这条路径的最终胜者)。
|
||||
pub shadowed: Option<Shadowed>,
|
||||
}
|
||||
|
||||
/// 一条要补全/下载的远端条目。
|
||||
@@ -136,7 +143,7 @@ impl ImportPlan {
|
||||
|
||||
let mut overrides: Vec<PlanOverride> = Vec::new();
|
||||
let mut skipped_server_files = 0_usize;
|
||||
for group in ordered_overrides(&manifest) {
|
||||
for group in layer::ordered(&manifest) {
|
||||
if group.side == PackSide::Server {
|
||||
// Modrinth 官方客户端安装器就不解压 server-overrides/(A 级来源:
|
||||
// install_mrpack.rs 的 strip_prefix 链只有 overrides/ 与 client-overrides/)。
|
||||
@@ -153,9 +160,11 @@ impl ImportPlan {
|
||||
size: file.size,
|
||||
disabled: variant.is_disabled(),
|
||||
note: suffix_note(variant, &file.path),
|
||||
shadowed: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
layer::resolve(&mut overrides, &mut notes);
|
||||
if skipped_server_files > 0 {
|
||||
notes.push(ImportIssue::new(
|
||||
keys::NOTE_SERVER_OVERRIDES_SKIPPED,
|
||||
@@ -232,17 +241,6 @@ impl ImportPlan {
|
||||
}
|
||||
}
|
||||
|
||||
/// 覆盖目录按侧排序:`Both`(`overrides/`)先、`Client`(`client-overrides/`)后 = 后者胜。
|
||||
fn ordered_overrides(manifest: &PackManifest) -> Vec<&PackOverrides> {
|
||||
let mut groups: Vec<&PackOverrides> = manifest.overrides.iter().collect();
|
||||
groups.sort_by_key(|group| match group.side {
|
||||
PackSide::Both => 0_u8,
|
||||
PackSide::Client => 1,
|
||||
PackSide::Server => 2,
|
||||
});
|
||||
groups
|
||||
}
|
||||
|
||||
/// 选一个加载器并翻译成 [`LoaderTarget`]。
|
||||
fn select_loader(
|
||||
manifest: &PackManifest,
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
//! 覆盖目录的**叠层顺序**与**同路径遮蔽**:导入计划里"逐文件"那一半的判据(纯计算,无 IO)。
|
||||
//!
|
||||
//! 拆成单独一个文件有两个原因:①`plan.rs` 要守 400 行;②这一层的两条规则(先按侧排、
|
||||
//! 再按路径结算胜负)是导入器里唯一"我们自己定的确定性规则",值得放在一处被单独读完。
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::super::report::{keys, FileSource, ImportIssue};
|
||||
use super::PlanOverride;
|
||||
use crate::addon::pack::{PackManifest, PackOverrides, PackSide};
|
||||
|
||||
/// 「这条覆盖条目被同一份包里的另一条声明遮蔽」。
|
||||
///
|
||||
/// 为什么不是新的 `FileAction` 变体:`FileAction` / `ConflictReason` / `ConflictChoice` 三个
|
||||
/// 枚举被 `crates/presenter/src/host/addon/pack/convert.rs` **穷尽 match**(那个 crate 不在
|
||||
/// `addon` 的作用域里)。所以"遮蔽"这件事走 [`ImportIssue`] 的 note 通道,与
|
||||
/// `server-overrides/ 被跳过` 完全同一条通道。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Shadowed {
|
||||
/// 胜出那条的来源侧。
|
||||
pub winner: FileSource,
|
||||
/// 胜出那条的包内目录名(人读用,例如 `client-overrides`)。
|
||||
pub winner_dir: String,
|
||||
/// 两条声明的字节是否完全相同(`true` = 遮蔽不丢内容,只是重复了一份)。
|
||||
pub same_bytes: bool,
|
||||
}
|
||||
|
||||
/// 覆盖目录按侧排序:`Both`(`overrides/`)先、`Client`(`client-overrides/`)后 = 后者胜。
|
||||
pub(super) fn ordered(manifest: &PackManifest) -> Vec<&PackOverrides> {
|
||||
let mut groups: Vec<&PackOverrides> = manifest.overrides.iter().collect();
|
||||
groups.sort_by_key(|group| match group.side {
|
||||
PackSide::Both => 0_u8,
|
||||
PackSide::Client => 1,
|
||||
PackSide::Server => 2,
|
||||
});
|
||||
groups
|
||||
}
|
||||
|
||||
/// 同一实例路径被本包多份声明命中时:**叠层顺序里最后一条胜**,其余标记为遮蔽并逐条注记。
|
||||
///
|
||||
/// # 这是"逐文件"而不是"整包一个策略"的那一步
|
||||
///
|
||||
/// research/18 §3.3 的 A 级结论:mrpack 的 `overrides/` 与 `client-overrides/` 之间,
|
||||
/// **规范层面只有极少数固定路径有优先级**,其余按 zip 条目顺序落盘;HMCL 连顺序都相反。
|
||||
/// 上一版导入器对此的处理是"按侧排序后逐条写"——结果是对的,但**报告是错的**:同一路径
|
||||
/// 会出现两条 `Installed`,其中前一条的哈希根本没落地,也没人说得清谁遮蔽了谁。
|
||||
///
|
||||
/// 现在把这层算清楚,逐条给出两个事实:
|
||||
///
|
||||
/// 1. **赢了的是哪一侧**(叠层顺序 = [`ordered`] 的 `Both → Client`,同组内按路径排序,
|
||||
/// 所以与 zip 条目顺序无关——那是我们自己定死的确定性规则,不是规范);
|
||||
/// 2. **两份内容是否逐字节相同**(相同 = 重复声明,遮蔽不丢内容;不同 = 真的换了一份)。
|
||||
///
|
||||
/// 判据只看模型里的 `sha1`:M110 扫包时是**现算**的真实内容哈希;手工构造的模型可能撒谎,
|
||||
/// 但那最多让 `same_bytes` 这句注记不准,**谁落地不受影响**(胜者永远按叠层顺序定)。
|
||||
pub(super) fn resolve(overrides: &mut [PlanOverride], notes: &mut Vec<ImportIssue>) {
|
||||
let mut last: HashMap<String, usize> = HashMap::new();
|
||||
for (index, entry) in overrides.iter().enumerate() {
|
||||
last.insert(entry.path.clone(), index);
|
||||
}
|
||||
for index in 0..overrides.len() {
|
||||
let path = overrides[index].path.clone();
|
||||
let Some(&winner_index) = last.get(&path) else {
|
||||
continue;
|
||||
};
|
||||
if winner_index == index {
|
||||
continue;
|
||||
}
|
||||
let winner_dir = overrides[winner_index].dir.clone();
|
||||
let loser_dir = overrides[index].dir.clone();
|
||||
let same_bytes = overrides[winner_index].sha1 == overrides[index].sha1;
|
||||
let conclusion = if same_bytes {
|
||||
"两份内容逐字节相同,只落地一次"
|
||||
} else {
|
||||
"两份内容不同,以这一条为准"
|
||||
};
|
||||
notes.push(
|
||||
ImportIssue::new(
|
||||
keys::NOTE_OVERRIDE_SHADOWED,
|
||||
format!(
|
||||
"{loser_dir}/{path} 与 {winner_dir}/{path} 声明同一实例路径:叠层顺序 \
|
||||
overrides/ 在前、client-overrides/ 在后(Modrinth 官方客户端语义),\
|
||||
{conclusion}"
|
||||
),
|
||||
)
|
||||
.with_path(path),
|
||||
);
|
||||
overrides[index].shadowed = Some(Shadowed {
|
||||
winner: overrides[winner_index].source,
|
||||
winner_dir,
|
||||
same_bytes,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn entry(dir: &str, path: &str, source: FileSource, sha1: &str) -> PlanOverride {
|
||||
PlanOverride {
|
||||
dir: dir.to_string(),
|
||||
path: path.to_string(),
|
||||
source,
|
||||
sha1: sha1.to_string(),
|
||||
size: 1,
|
||||
disabled: false,
|
||||
note: None,
|
||||
shadowed: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_later_layer_wins_and_the_earlier_one_is_shadowed() {
|
||||
// 叠层顺序:overrides/(Both)在前、client-overrides/(Client)在后 ⇒ 后者胜。
|
||||
let mut overrides = vec![
|
||||
entry(
|
||||
"overrides",
|
||||
"config/dup.toml",
|
||||
FileSource::Overrides,
|
||||
"aaaa",
|
||||
),
|
||||
entry(
|
||||
"client-overrides",
|
||||
"config/dup.toml",
|
||||
FileSource::ClientOverrides,
|
||||
"bbbb",
|
||||
),
|
||||
];
|
||||
let mut notes = Vec::new();
|
||||
resolve(&mut overrides, &mut notes);
|
||||
|
||||
let shadowed = overrides[0].shadowed.as_ref().expect("前一条要被标记");
|
||||
assert_eq!(shadowed.winner, FileSource::ClientOverrides);
|
||||
assert_eq!(shadowed.winner_dir, "client-overrides");
|
||||
assert!(!shadowed.same_bytes, "同路径不同哈希");
|
||||
assert!(overrides[1].shadowed.is_none(), "胜者不标遮蔽");
|
||||
assert_eq!(notes.len(), 1);
|
||||
assert_eq!(notes[0].message_key, keys::NOTE_OVERRIDE_SHADOWED);
|
||||
assert_eq!(notes[0].path.as_deref(), Some("config/dup.toml"));
|
||||
let message = ¬es[0].message;
|
||||
for expected in [
|
||||
"overrides/config/dup.toml",
|
||||
"client-overrides/config/dup.toml",
|
||||
"内容不同",
|
||||
] {
|
||||
assert!(message.contains(expected), "缺 {expected}:{message}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identical_bytes_are_reported_as_same_bytes() {
|
||||
let mut overrides = vec![
|
||||
entry(
|
||||
"overrides",
|
||||
"config/same.toml",
|
||||
FileSource::Overrides,
|
||||
"aaaa",
|
||||
),
|
||||
entry(
|
||||
"client-overrides",
|
||||
"config/same.toml",
|
||||
FileSource::ClientOverrides,
|
||||
"aaaa",
|
||||
),
|
||||
];
|
||||
let mut notes = Vec::new();
|
||||
resolve(&mut overrides, &mut notes);
|
||||
|
||||
assert!(overrides[0].shadowed.as_ref().expect("标记").same_bytes);
|
||||
assert!(
|
||||
notes[0].message.contains("逐字节相同"),
|
||||
"内容一致要说清楚,别让人以为丢了东西:{}",
|
||||
notes[0].message
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_paths_with_the_same_bytes_are_never_shadowed() {
|
||||
// 同哈希不同路径 = 两份文件,不是重复声明。按哈希去重会**静默丢一个文件**。
|
||||
let mut overrides = vec![
|
||||
entry("overrides", "mods/a.jar", FileSource::Overrides, "aaaa"),
|
||||
entry("overrides", "mods/b.jar", FileSource::Overrides, "aaaa"),
|
||||
];
|
||||
let mut notes = Vec::new();
|
||||
resolve(&mut overrides, &mut notes);
|
||||
|
||||
assert!(overrides.iter().all(|entry| entry.shadowed.is_none()));
|
||||
assert!(notes.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_same_path_twice_inside_one_group_is_shadowed_too() {
|
||||
// 同侧重复(模型被合并 / 手造时才会出现):照样只落地一次,且注记两份来自同一目录。
|
||||
let mut overrides = vec![
|
||||
entry("overrides", "mods/x.jar", FileSource::Overrides, "aaaa"),
|
||||
entry("overrides", "mods/x.jar", FileSource::Overrides, "bbbb"),
|
||||
];
|
||||
let mut notes = Vec::new();
|
||||
resolve(&mut overrides, &mut notes);
|
||||
|
||||
assert_eq!(
|
||||
overrides[0].shadowed.as_ref().expect("标记").winner,
|
||||
FileSource::Overrides
|
||||
);
|
||||
assert_eq!(notes.len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,8 @@ pub const NOTE_BASELINE_PACK_MISMATCH: &str = "pack.import.note.baseline_pack_mi
|
||||
pub const NOTE_ENGINE_ARTIFACTS_DROPPED: &str = "pack.import.note.engine_artifacts_dropped";
|
||||
/// 注记:回滚**未完整完成**(逐项列失败,`rolled_back` 为 false)。
|
||||
pub const NOTE_ROLLBACK_INCOMPLETE: &str = "pack.import.note.rollback_incomplete";
|
||||
/// 注记:同包另一条声明遮蔽了这个路径(说清谁赢、两份内容是否相同)。
|
||||
pub const NOTE_OVERRIDE_SHADOWED: &str = "pack.import.note.override_shadowed";
|
||||
|
||||
/// 缺失:调用方没给 fetcher(离线导入)。
|
||||
pub const MISSING_OFFLINE: &str = "pack.import.missing.offline";
|
||||
@@ -94,6 +96,7 @@ pub const ALL: &[&str] = &[
|
||||
NOTE_BASELINE_PACK_MISMATCH,
|
||||
NOTE_ENGINE_ARTIFACTS_DROPPED,
|
||||
NOTE_ROLLBACK_INCOMPLETE,
|
||||
NOTE_OVERRIDE_SHADOWED,
|
||||
MISSING_OFFLINE,
|
||||
MISSING_NO_SOURCE,
|
||||
MISSING_FETCH_FAILED,
|
||||
|
||||
@@ -51,9 +51,7 @@
|
||||
//! 仍然不做映射(那是格式之上的事),但"跨格式导出无路可走"已经不再成立。
|
||||
|
||||
use std::fmt::Debug;
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::Path;
|
||||
|
||||
use super::archive::PackArchive;
|
||||
use super::curseforge;
|
||||
@@ -61,9 +59,9 @@ use super::error::{PackError, PackResult};
|
||||
use super::manifest::PackManifest;
|
||||
use super::modrinth;
|
||||
use super::multimc;
|
||||
use super::path::{normalize_dir, normalize_file};
|
||||
|
||||
mod payload;
|
||||
mod write;
|
||||
|
||||
/// 导出时的覆盖内容来源在 [`payload`] 子模块里(`payload.rs`,与本文件拆开是为了守 400 行):
|
||||
/// [`PackPayload::metadata_only`] 只写清单、[`PackPayload::from_root`] 按
|
||||
@@ -116,7 +114,8 @@ pub trait PackFormat: Debug + Send + Sync {
|
||||
/// 导出一个整包(清单 + 覆盖内容)。
|
||||
///
|
||||
/// 默认实现做四件事:①只读格式直接拒;②[`Self::check_overrides`] 挡跨格式导出;
|
||||
/// ③`render` 出清单;④写进**临时文件**再 `rename` 到 `target`(失败不留半个包)。
|
||||
/// ③`render` 出清单;④**先按声明尺寸预检 ZIP64 阈值**,再写进**临时文件**并 `rename` 到
|
||||
/// `target`(失败不留半个包)。第④步的细节见 `format::write` 模块文档。
|
||||
///
|
||||
/// # 只保证同格式往返(写给 M111/M112)
|
||||
///
|
||||
@@ -148,8 +147,16 @@ pub trait PackFormat: Debug + Send + Sync {
|
||||
manifest.check_paths()?;
|
||||
let json = self.render(manifest)?;
|
||||
// 临时文件 + rename:中途失败时目标路径上不会留下"看起来能用"的半个 zip(r1 p3-1)。
|
||||
let temp = temporary_path(target);
|
||||
match write_container(self.id(), entry, &json, manifest, payload, &temp) {
|
||||
let temp = write::temporary_path(target);
|
||||
match write::container(
|
||||
self.id(),
|
||||
entry,
|
||||
&json,
|
||||
manifest,
|
||||
payload,
|
||||
&temp,
|
||||
zip::ZIP64_BYTES_THR,
|
||||
) {
|
||||
Ok(()) => std::fs::rename(&temp, target).map_err(|error| {
|
||||
let _ = std::fs::remove_file(&temp);
|
||||
PackError::io(format!(
|
||||
@@ -166,81 +173,6 @@ pub trait PackFormat: Debug + Send + Sync {
|
||||
}
|
||||
}
|
||||
|
||||
/// 临时文件路径(与目标同目录,`rename` 才能原子替换)。
|
||||
fn temporary_path(target: &Path) -> PathBuf {
|
||||
let mut name = target.file_name().map_or_else(
|
||||
|| std::ffi::OsString::from("pack"),
|
||||
std::ffi::OsStr::to_os_string,
|
||||
);
|
||||
name.push(format!(".{}.part", std::process::id()));
|
||||
target.with_file_name(name)
|
||||
}
|
||||
|
||||
/// 真正写 zip 的那一段(失败了由调用方删临时文件)。
|
||||
fn write_container(
|
||||
format_id: &str,
|
||||
entry: &'static str,
|
||||
json: &str,
|
||||
manifest: &PackManifest,
|
||||
payload: PackPayload<'_>,
|
||||
temp: &Path,
|
||||
) -> PackResult<()> {
|
||||
let file = File::create(temp)
|
||||
.map_err(|error| PackError::io(format!("创建 {} 失败:{error}", temp.display())))?;
|
||||
let mut writer = zip::ZipWriter::new(file);
|
||||
let options = zip::write::SimpleFileOptions::default()
|
||||
.compression_method(zip::CompressionMethod::Deflated)
|
||||
.unix_permissions(0o644);
|
||||
writer
|
||||
.start_file(entry, options)
|
||||
.map_err(|error| PackError::io(format!("写 {entry} 失败:{error}")))?;
|
||||
writer
|
||||
.write_all(json.as_bytes())
|
||||
.map_err(|error| PackError::io(format!("写 {entry} 失败:{error}")))?;
|
||||
// 未压缩总量封顶在 ZIP64 阈值(≈4 GiB):Modrinth 官方导出也是这样拒绝的,而不是
|
||||
// 静默截断(HMCL 那边 `(int)` 截断只打 warning 是反面教材)。临时文件由调用方删。
|
||||
let mut total = json.len() as u64;
|
||||
for group in &manifest.overrides {
|
||||
let dir = normalize_dir(&group.dir)?;
|
||||
for override_file in &group.files {
|
||||
let path = normalize_file(&override_file.path)?;
|
||||
let name = format!("{dir}/{path}");
|
||||
let source = match payload.source_for(&name) {
|
||||
Some(source) => source.to_path_buf(),
|
||||
None => {
|
||||
let Some(root) = payload.root() else {
|
||||
return Err(PackError::unsupported(format!(
|
||||
"导出需要覆盖内容({name}),但既没有来源映射也没有 payload 根目录"
|
||||
)));
|
||||
};
|
||||
root.join(&dir).join(&path)
|
||||
}
|
||||
};
|
||||
let bytes = std::fs::read(&source).map_err(|error| {
|
||||
PackError::io(format!("读覆盖文件 {} 失败:{error}", source.display()))
|
||||
})?;
|
||||
total += bytes.len() as u64;
|
||||
if total >= zip::ZIP64_BYTES_THR {
|
||||
return Err(PackError::limits(format!(
|
||||
"包体未压缩总量已超过 {} 字节(ZIP64 阈值),拒绝写出一个需要 ZIP64 的整合包",
|
||||
zip::ZIP64_BYTES_THR
|
||||
))
|
||||
.with_hint("把超大的覆盖文件移出实例后再导出;mrpack/CF 都不接受 ZIP64"));
|
||||
}
|
||||
writer
|
||||
.start_file(name.clone(), options)
|
||||
.map_err(|error| PackError::io(format!("写 {name} 失败:{error}")))?;
|
||||
writer
|
||||
.write_all(&bytes)
|
||||
.map_err(|error| PackError::io(format!("写 {name} 失败:{error}")))?;
|
||||
}
|
||||
}
|
||||
writer
|
||||
.finish()
|
||||
.map_err(|error| PackError::io(format!("收尾 zip 失败:{error}(格式 {format_id})")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 一次探测的结果。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PackMatch {
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
//! 把模型写成 zip:**声明尺寸预检 → 写清单 → 逐条搬覆盖内容**(`format.rs` 只留 trait 与注册表)。
|
||||
//!
|
||||
//! # 为什么预检必须在读第一个字节之前
|
||||
//!
|
||||
//! 一条写死的边界(research/18-p2 §8.6,A 级 `export_mrpack.rs:305-311`):**不写 ZIP64 包**。
|
||||
//! Modrinth 官方导出撞到阈值直接报错,HMCL 那边 `(int)` 截断只打 warning 是反面教材。
|
||||
//!
|
||||
//! 早先的实现是「读一个文件 → 累加 → 比较」:真要导出一个 4 GiB 覆盖文件的实例,
|
||||
//! `std::fs::read` 先把它整份读进内存,闸门**永远够不着**——用户看到的是内存打爆或 IO 错,
|
||||
//! 而不是"包太大,把这个文件移出实例"。所以现在先跑一遍 `metadata().len()` 累加
|
||||
//! (`preflight`),超阈值在任何内容被读进内存**之前**就报 `pack_limits`。
|
||||
//!
|
||||
//! 写循环里那份增量检查保留,当第二道:预检与写入之间文件可能变大(实例目录不是快照)。
|
||||
//! **第二道自己从清单长度起算**,绝不拿预检的返回值当基数——两者合起来会重复累计,
|
||||
//! 于是真实总量只要过半阈值(~2.15 GiB)就被误拒,而且拒在内容已经读出来之后(P2-1)。
|
||||
//!
|
||||
//! # 内容按块搬运,不整份读进内存
|
||||
//!
|
||||
//! 覆盖文件用 1 MiB 缓冲流式写进 zip(`COPY_CHUNK`)。这不是优化癖:`std::fs::read` 会把
|
||||
//! 一份 2.5 GiB 的 mod 整份摊在内存里,恰恰破坏"闸在内容进内存之前"这条性质——即使判据
|
||||
//! 后来修对了,误拒也发生在把几个 GiB 读进内存之后。流式之后第二道闸看的是**已写字节数**,
|
||||
//! 与内存占用无关。
|
||||
//!
|
||||
//! # 阈值与声明口径
|
||||
//!
|
||||
//! 判据是**未压缩声明的总量**(清单 JSON + 每个覆盖文件的字节数)≥ `zip::ZIP64_BYTES_THR`
|
||||
//! (≈4 GiB)。zip 条目头与中央目录的几十字节开销不计——这个常数本身就是近似边界,
|
||||
//! 官方实现同样按这个粗粒度判。
|
||||
|
||||
use std::fs::File;
|
||||
use std::io::{Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::super::error::{PackError, PackResult};
|
||||
use super::super::manifest::PackManifest;
|
||||
use super::super::path::{normalize_dir, normalize_file};
|
||||
use super::payload::PackPayload;
|
||||
|
||||
/// 搬覆盖内容时的缓冲大小(流式,不把文件整份读进内存)。
|
||||
const COPY_CHUNK: usize = 1024 * 1024;
|
||||
|
||||
/// 与目标同目录的临时文件路径(同卷 `rename` 才能原子替换)。
|
||||
pub(super) fn temporary_path(target: &Path) -> PathBuf {
|
||||
let mut name = target.file_name().map_or_else(
|
||||
|| std::ffi::OsString::from("pack"),
|
||||
std::ffi::OsStr::to_os_string,
|
||||
);
|
||||
name.push(format!(".{}.part", std::process::id()));
|
||||
target.with_file_name(name)
|
||||
}
|
||||
|
||||
/// 真正写 zip 的那一段(失败了由调用方删临时文件)。
|
||||
pub(super) fn container(
|
||||
format_id: &str,
|
||||
entry: &'static str,
|
||||
json: &str,
|
||||
manifest: &PackManifest,
|
||||
payload: PackPayload<'_>,
|
||||
temp: &Path,
|
||||
limit: u64,
|
||||
) -> PackResult<()> {
|
||||
// 先判总量再碰磁盘:超限时临时文件都不建,更不会读进一份 4 GiB 的内容。
|
||||
// **返回值只属于预检**:写循环有自己的计数器(`Written`),拿它当基数会把全量声明尺寸
|
||||
// 再累加一遍 ⇒ 过拒窗口(P2-1)。
|
||||
let _ = preflight(json.len() as u64, manifest, payload, limit)?;
|
||||
|
||||
let file = File::create(temp)
|
||||
.map_err(|error| PackError::io(format!("创建 {} 失败:{error}", temp.display())))?;
|
||||
let mut writer = zip::ZipWriter::new(file);
|
||||
let options = zip::write::SimpleFileOptions::default()
|
||||
.compression_method(zip::CompressionMethod::Deflated)
|
||||
.unix_permissions(0o644);
|
||||
writer
|
||||
.start_file(entry, options)
|
||||
.map_err(|error| PackError::io(format!("写 {entry} 失败:{error}")))?;
|
||||
writer
|
||||
.write_all(json.as_bytes())
|
||||
.map_err(|error| PackError::io(format!("写 {entry} 失败:{error}")))?;
|
||||
|
||||
// 第二道闸的计数器:**从清单长度重新起算**,与预检的结果无关(见 `Written`)。
|
||||
let mut written = Written::new(json.len() as u64, limit);
|
||||
let mut buffer = vec![0_u8; COPY_CHUNK];
|
||||
for group in &manifest.overrides {
|
||||
let dir = normalize_dir(&group.dir)?;
|
||||
for override_file in &group.files {
|
||||
let path = normalize_file(&override_file.path)?;
|
||||
let name = format!("{dir}/{path}");
|
||||
let source = source_for(payload, &dir, &path)?;
|
||||
let mut source_file = File::open(&source).map_err(|error| {
|
||||
PackError::io(format!("读覆盖文件 {} 失败:{error}", source.display()))
|
||||
})?;
|
||||
writer
|
||||
.start_file(name.clone(), options)
|
||||
.map_err(|error| PackError::io(format!("写 {name} 失败:{error}")))?;
|
||||
loop {
|
||||
let read = source_file.read(&mut buffer).map_err(|error| {
|
||||
PackError::io(format!("读覆盖文件 {} 失败:{error}", source.display()))
|
||||
})?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
writer
|
||||
.write_all(&buffer[..read])
|
||||
.map_err(|error| PackError::io(format!("写 {name} 失败:{error}")))?;
|
||||
written.spend(read as u64)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
writer
|
||||
.finish()
|
||||
.map_err(|error| PackError::io(format!("收尾 zip 失败:{error}(格式 {format_id})")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 写盘前的**声明尺寸预检**:只量 `metadata().len()`,不读内容。
|
||||
///
|
||||
/// 返回值是"清单 + 全部覆盖文件的声明总量",**只给测试与诊断看**。写循环有自己的计数器
|
||||
/// (从清单长度起算)——曾经把它直接当累加基数,结果每个文件的字节被算两遍,真实总量过半
|
||||
/// 阈值(~2.15 GiB)就被误拒(P2-1,已有 2.5 GB 的端到端用例钉住这个窗口)。
|
||||
fn preflight(
|
||||
json_len: u64,
|
||||
manifest: &PackManifest,
|
||||
payload: PackPayload<'_>,
|
||||
limit: u64,
|
||||
) -> PackResult<u64> {
|
||||
let mut total = json_len;
|
||||
guard_with(total, limit)?;
|
||||
for group in &manifest.overrides {
|
||||
let dir = normalize_dir(&group.dir)?;
|
||||
for override_file in &group.files {
|
||||
let path = normalize_file(&override_file.path)?;
|
||||
let source = source_for(payload, &dir, &path)?;
|
||||
// 缺文件 / 读不到元数据在这里就报(与"读内容失败"同一个 `pack_io` 口径:
|
||||
// 目标路径上不会留下半个包,临时文件由调用方删)。
|
||||
let length = std::fs::metadata(&source)
|
||||
.map_err(|error| {
|
||||
PackError::io(format!("读覆盖文件 {} 失败:{error}", source.display()))
|
||||
})?
|
||||
.len();
|
||||
total += length;
|
||||
guard_with(total, limit)?;
|
||||
}
|
||||
}
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
/// 单条覆盖文件的来源:来源表优先(跨格式导出用),否则按 `<root>/<dir>/<path>` 拼。
|
||||
fn source_for(payload: PackPayload<'_>, dir: &str, path: &str) -> PackResult<PathBuf> {
|
||||
let name = format!("{dir}/{path}");
|
||||
if let Some(source) = payload.source_for(&name) {
|
||||
return Ok(source.to_path_buf());
|
||||
}
|
||||
let Some(root) = payload.root() else {
|
||||
return Err(PackError::unsupported(format!(
|
||||
"导出需要覆盖内容({name}),但既没有来源映射也没有 payload 根目录"
|
||||
)));
|
||||
};
|
||||
Ok(root.join(dir).join(path))
|
||||
}
|
||||
|
||||
/// 写循环的第二道闸(**已写出**的未压缩字节数)。
|
||||
///
|
||||
/// 起点是**清单 JSON 的长度**,与 `preflight` 的返回值无关:两者混用会把每个文件的字节算
|
||||
/// 两遍,真实总量过半阈值(~2.15 GiB)就被误拒(P2-1)。类型上也没法混用——`Written`
|
||||
/// 由 `u64` 构造,而预检的返回值是"全量声明尺寸",只有显式取值才拿得到。
|
||||
struct Written {
|
||||
total: u64,
|
||||
limit: u64,
|
||||
}
|
||||
|
||||
impl Written {
|
||||
const fn new(json_len: u64, limit: u64) -> Self {
|
||||
Self {
|
||||
total: json_len,
|
||||
limit,
|
||||
}
|
||||
}
|
||||
|
||||
/// 记一笔实际写出的字节,并在**每一块之后**过闸(大文件因此能在中途被拦下)。
|
||||
fn spend(&mut self, bytes: u64) -> PackResult<()> {
|
||||
self.total = self.total.saturating_add(bytes);
|
||||
guard_with(self.total, self.limit)
|
||||
}
|
||||
}
|
||||
|
||||
/// ZIP64 阈值闸(`pack_limits`):`total` **达到**阈值就拒(`>=` 不是 `>`,官方实现也是
|
||||
/// "撞上就不写")。
|
||||
///
|
||||
/// 生产路径的 `limit` 恒为 `zip::ZIP64_BYTES_THR`(由 `format.rs` 传下来);把它做成参数是为了
|
||||
/// 能用几十字节的小数值钉住"边界在 `>=` 上"和"写循环不许重复累计"这两件事——真造 4 GiB 的
|
||||
/// 包要几十秒 CPU 与一大片内存/页面文件,本机资源纪律下不经默认套件跑(塔台 2026-11-07)。
|
||||
fn guard_with(total: u64, limit: u64) -> PackResult<()> {
|
||||
if total >= limit {
|
||||
return Err(PackError::limits(format!(
|
||||
"包体未压缩总量已超过 {limit} 字节(ZIP64 阈值),拒绝写出一个需要 ZIP64 的整合包"
|
||||
))
|
||||
.with_hint("把超大的覆盖文件移出实例后再导出;mrpack/CF 都不接受 ZIP64"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::addon::pack::manifest::{OverrideFile, PackOverrides, PackSide};
|
||||
|
||||
#[test]
|
||||
fn the_threshold_is_reached_not_exceeded() {
|
||||
// `>=` 而不是 `>`:恰好等于阈值就已经需要 ZIP64 了。
|
||||
assert!(guard_with(99, 100).is_ok());
|
||||
let error = guard_with(100, 100).expect_err("等于阈值必须拒");
|
||||
assert_eq!(
|
||||
error.kind(),
|
||||
super::super::super::error::PackErrorKind::Limits
|
||||
);
|
||||
assert!(error.message().contains("100"), "{error}");
|
||||
assert!(guard_with(101, 100).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_second_gate_counts_from_the_manifest_length_not_the_preflight_total() {
|
||||
// P2-1 的窗口:真实总量在 [阈值一半, 阈值) 之间的包**不许**被拒。这一条不真写
|
||||
// 2.5 GB(那要 ~70 秒 deflate 与一大片内存/页面文件),而是直接把窗口里的字节数
|
||||
// 喂给第二道闸的计数器——它的起点是清单长度,所以只有"把预检的全量 total 当基数"
|
||||
// 那种写法才会在这里红(红法是 `spend` 返回 Limits)。
|
||||
let half = zip::ZIP64_BYTES_THR / 2;
|
||||
let mut written = Written::new(28, zip::ZIP64_BYTES_THR);
|
||||
written
|
||||
.spend(2_500_000_000)
|
||||
.expect("2.5 GB 没到 4 GiB,第二道闸不许拒");
|
||||
assert!(2_500_000_000 > half, "样本要落在过拒窗口里");
|
||||
|
||||
// 对照:撞上阈值必须拒,且正好在阈值处(不是超一点点才拒)。
|
||||
let mut written = Written::new(28, zip::ZIP64_BYTES_THR);
|
||||
let error = written
|
||||
.spend(zip::ZIP64_BYTES_THR)
|
||||
.expect_err("撞阈值必须拒");
|
||||
assert_eq!(
|
||||
error.kind(),
|
||||
super::super::super::error::PackErrorKind::Limits
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_container_does_not_double_count_the_preflight_total() {
|
||||
// 容器层的窗口回归钉(P2-1),**用几十字节的 limit 把窗口缩小到可测尺寸**:
|
||||
// `json` 30 字节 + 覆盖文件 40 字节 = 声明总量 70。
|
||||
// - 正确实现:第二道闸从 30 起算,写到 70 也不到 100 ⇒ 导出成功;
|
||||
// - P2-1 的写法(把预检返回的全量 70 当基数):写循环再累加 40 ⇒ 110 ≥ 100 ⇒ 误拒。
|
||||
// 真造 2.5 GB 才能暴露同一个 bug,本机资源纪律下不跑(那条留成 `zip64.rs` 里的
|
||||
// `#[ignore]` 用例)。
|
||||
let root = std::env::temp_dir().join(format!("ull-double-count-{}", std::process::id()));
|
||||
std::fs::create_dir_all(root.join("overrides/mods")).expect("建目录");
|
||||
std::fs::write(root.join("overrides/mods/a.bin"), [0_u8; 40]).expect("写覆盖文件");
|
||||
// 清单很短,limit 取"声明总量 + 20 字节"的余量:正确实现只写到 70,重复累计会写到 110。
|
||||
let json = "{\"name\":\"0123456789012345\"}";
|
||||
let declared = json.len() as u64 + 40;
|
||||
let limit = declared + 20;
|
||||
let mut manifest = PackManifest::new("double count");
|
||||
manifest.overrides.push(PackOverrides {
|
||||
dir: "overrides".to_string(),
|
||||
side: PackSide::Both,
|
||||
files: vec![OverrideFile {
|
||||
path: "mods/a.bin".to_string(),
|
||||
sha1: String::new(),
|
||||
size: 40,
|
||||
}],
|
||||
});
|
||||
let target = root.join("out.zip");
|
||||
|
||||
container(
|
||||
"curseforge",
|
||||
"manifest.json",
|
||||
json,
|
||||
&manifest,
|
||||
PackPayload::from_root(&root),
|
||||
&target,
|
||||
limit,
|
||||
)
|
||||
.expect("声明总量没到 limit:写循环不许把预检的 total 重复累计");
|
||||
|
||||
// 对照:limit 降到声明总量本身 ⇒ 预检就该拒,且临时文件都没建。
|
||||
let refused = root.join("refused.zip");
|
||||
let error = container(
|
||||
"curseforge",
|
||||
"manifest.json",
|
||||
json,
|
||||
&manifest,
|
||||
PackPayload::from_root(&root),
|
||||
&refused,
|
||||
declared,
|
||||
)
|
||||
.expect_err("声明总量达到 limit 必须拒");
|
||||
assert_eq!(
|
||||
error.kind(),
|
||||
super::super::super::error::PackErrorKind::Limits
|
||||
);
|
||||
assert!(!refused.exists(), "预检失败时目标路径不许出现");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_preflight_measures_declared_sizes_not_content() {
|
||||
// 预检必须只量 `metadata().len()`:这一条用"小文件 + 3 GiB 稀疏文件"钉住,
|
||||
// 真读内容的话要么慢要么直接吃 3 GiB 内存。
|
||||
let root = std::env::temp_dir().join(format!("ull-preflight-{}", std::process::id()));
|
||||
std::fs::create_dir_all(root.join("overrides/mods")).expect("建目录");
|
||||
std::fs::write(root.join("overrides/mods/small.jar"), b"0123456789").expect("写小文件");
|
||||
let huge = root.join("overrides/mods/huge.jar");
|
||||
let file = std::fs::File::create(&huge).expect("建稀疏文件");
|
||||
file.set_len(3 * 1024 * 1024 * 1024).expect("拉长(稀疏)");
|
||||
drop(file);
|
||||
|
||||
let mut manifest = PackManifest::new("preflight");
|
||||
manifest.overrides.push(PackOverrides {
|
||||
dir: "overrides".to_string(),
|
||||
side: PackSide::Both,
|
||||
files: vec![
|
||||
OverrideFile {
|
||||
path: "mods/small.jar".to_string(),
|
||||
sha1: String::new(),
|
||||
size: 10,
|
||||
},
|
||||
OverrideFile {
|
||||
path: "mods/huge.jar".to_string(),
|
||||
sha1: String::new(),
|
||||
size: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let total = preflight(
|
||||
7,
|
||||
&manifest,
|
||||
PackPayload::from_root(&root),
|
||||
zip::ZIP64_BYTES_THR,
|
||||
)
|
||||
.expect("3 GiB 总量仍在阈值之下,预检不许误伤");
|
||||
assert_eq!(
|
||||
total,
|
||||
7 + 10 + 3 * 1024 * 1024 * 1024,
|
||||
"清单 + 每个覆盖文件的声明尺寸"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ mod model;
|
||||
mod roundtrip;
|
||||
mod safety;
|
||||
mod unknown_fields;
|
||||
mod zip64;
|
||||
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
|
||||
@@ -111,3 +111,56 @@ fn curseforge_loader_unknown_keys_survive_read_write_reread() {
|
||||
assert_eq!(reread, manifest, "modLoaders[] 里的未知键必须原样回写");
|
||||
assert_eq!(reread.loaders[0].extra["cachedName"], json!("Forge 47.2.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
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 dir = root.join("src");
|
||||
super::write_file(
|
||||
&dir.join("modrinth.index.json"),
|
||||
br#"{"game":"minecraft","formatVersion":1,"versionId":"1.0","name":"file extra keys",
|
||||
"files":[{"path":"mods/a.jar",
|
||||
"hashes":{"sha1":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"sha512":"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
"md5":"d41d8cd98f00b204e9800998ecf8427e"},
|
||||
"downloads":["https://cdn.modrinth.com/data/abc/versions/x/a.jar"],
|
||||
"fileSize":42,
|
||||
"mirror":"https://mirror.example.invalid/a.jar",
|
||||
"optionalReason":{"why":"kept for the community"}}],
|
||||
"dependencies":{"minecraft":"1.20.1"}}"#,
|
||||
);
|
||||
let zip = root.join("pack.mrpack");
|
||||
super::zip_dir(&dir, &zip);
|
||||
|
||||
let pack = PackArchive::open_default(&zip).expect("打开包");
|
||||
let manifest = MODRINTH.read(&pack).expect("读 mrpack 清单");
|
||||
assert_eq!(
|
||||
manifest.files[0].extra["mirror"],
|
||||
json!("https://mirror.example.invalid/a.jar")
|
||||
);
|
||||
assert_eq!(
|
||||
manifest.files[0].extra["optionalReason"],
|
||||
json!({"why": "kept for the community"})
|
||||
);
|
||||
// 未知**哈希算法**也不丢:`hashes` 是整张表,不是两个字段。
|
||||
assert_eq!(
|
||||
manifest.files[0].hashes.get("md5").map(String::as_str),
|
||||
Some("d41d8cd98f00b204e9800998ecf8427e")
|
||||
);
|
||||
|
||||
let target = root.join("out.mrpack");
|
||||
MODRINTH
|
||||
.write(&manifest, PackPayload::metadata_only(), &target)
|
||||
.expect("导出 mrpack");
|
||||
let reread = MODRINTH
|
||||
.read(&PackArchive::open_default(&target).expect("打开导出包"))
|
||||
.expect("再读");
|
||||
assert_eq!(reread, manifest, "files[] 里的未知键必须原样回写");
|
||||
assert_eq!(
|
||||
reread.files[0].extra["optionalReason"],
|
||||
json!({"why": "kept for the community"})
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
//! ZIP64 拒写(research/18-p2 §8.6 / 表 B #17 的导出侧那条)。
|
||||
//!
|
||||
//! # 为什么这个文件以前是空的
|
||||
//!
|
||||
//! M112 的完成报告写明:「ZIP64 阈值那条分支只有代码没有端到端测试(要造 4 GiB 数据)」。
|
||||
//! 而更要紧的是——**那条分支当时根本够不着**:实现先 `std::fs::read` 整个覆盖文件进内存,
|
||||
//! 再累加比较,所以真要导出一个 4 GiB 文件的实例,内存先炸,用户永远看不到那句
|
||||
//! `pack_limits`。现在写侧改成「先按 `metadata().len()` 预检」,两个问题一起解决:
|
||||
//!
|
||||
//! - 边界是真的会在读第一个字节之前拦下来的(本文件的用例就断言这一点:失败后临时目录里
|
||||
//! 没有 `.part`、目标路径不存在);
|
||||
//! - 测试可以用**稀疏文件**造出 4 GiB 的"声明尺寸"而几乎不占磁盘(`set_len` 只改 EOF,
|
||||
//! NTFS / ext4 都不会真写零)。这也是唯一能在不塞满硬盘的前提下打通这条分支的办法。
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use super::temp_root;
|
||||
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};
|
||||
|
||||
/// 一个覆盖文件的声明(内容由调用方提供)。
|
||||
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: &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("拉长文件(稀疏)");
|
||||
}
|
||||
|
||||
/// 临时目录里除 `keep` 之外的文件名(断言"没留下 .part"用)。
|
||||
fn leftovers(root: &Path, keep: &[&str]) -> Vec<String> {
|
||||
let mut names: Vec<String> = std::fs::read_dir(root)
|
||||
.expect("列目录")
|
||||
.filter_map(Result::ok)
|
||||
.map(|entry| entry.file_name().to_string_lossy().to_string())
|
||||
.filter(|name| !keep.contains(&name.as_str()))
|
||||
.collect();
|
||||
names.sort();
|
||||
names
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_file_over_the_threshold_is_refused_before_a_single_byte_is_read() {
|
||||
let root = temp_root("zip64-single");
|
||||
// 恰好一个字节超过阈值:预检必须在这里就报,不许读内容(真读会先要 4 GiB 内存)。
|
||||
sparse_file(
|
||||
&root.join("overrides/mods/huge.jar"),
|
||||
zip::ZIP64_BYTES_THR + 1,
|
||||
);
|
||||
let manifest = manifest_of(vec![override_file("mods/huge.jar")]);
|
||||
let target = root.join("out.zip");
|
||||
|
||||
let error = CURSEFORGE
|
||||
.write(&manifest, PackPayload::from_root(&root), &target)
|
||||
.expect_err("超 ZIP64 阈值必须拒写");
|
||||
|
||||
assert_eq!(error.kind(), PackErrorKind::Limits, "{error}");
|
||||
assert!(error.message().contains("ZIP64"), "{error}");
|
||||
assert!(
|
||||
error.hint().is_some_and(|hint| hint.contains("移出实例")),
|
||||
"要给「把大文件移出实例」这种可执行的提示:{error:?}"
|
||||
);
|
||||
assert!(!target.exists(), "失败后目标路径不许存在");
|
||||
// 临时文件也不许留(预检在建临时文件之前就失败 ⇒ 目录里只有源文件)。
|
||||
assert_eq!(leftovers(&root, &["overrides"]), Vec::<String>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_threshold_is_cumulative_not_per_file() {
|
||||
// 三个文件各自都远小于阈值,加起来超了:判据是**整包未压缩总量**,不是单文件。
|
||||
let root = temp_root("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, "三条加起来必须超标");
|
||||
let mut files = Vec::new();
|
||||
for index in 0..3 {
|
||||
let relative = format!("mods/part-{index}.jar");
|
||||
sparse_file(&root.join("overrides").join(&relative), each);
|
||||
files.push(override_file(&relative));
|
||||
}
|
||||
|
||||
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("ZIP64"), "{error}");
|
||||
assert!(!target.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_export_under_the_threshold_still_writes_a_normal_pack() {
|
||||
// 对照:不超阈值时预检不许误伤(否则这条护栏就变成"导出全挂")。
|
||||
let root = temp_root("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("写覆盖文件");
|
||||
let manifest = manifest_of(vec![override_file("mods/jei.jar")]);
|
||||
let target = root.join("out.zip");
|
||||
|
||||
CURSEFORGE
|
||||
.write(&manifest, PackPayload::from_root(&root), &target)
|
||||
.expect("正常尺寸必须写得出来");
|
||||
assert!(target.exists());
|
||||
let written = crate::addon::pack::PackArchive::open_default(&target).expect("打开导出的包");
|
||||
assert!(written.has_entry("overrides/mods/jei.jar"));
|
||||
}
|
||||
|
||||
/// 端到端版的窗口回归钉(**不进默认套件**:真写 2.5 GB 要 ~70 秒 deflate,且会把机器内存 /
|
||||
/// 页面文件顶上去;塔台 2026-11-07 的资源配置令要求避免)。
|
||||
///
|
||||
/// 默认套件里由 `format::write` 的单测 `the_second_gate_counts_from_the_manifest_length_not_
|
||||
/// the_preflight_total` 便宜地钉住同一个窗口(把窗口里的字节数直接喂给第二道闸的计数器)。
|
||||
/// 本条留给"真跑一遍确认容器层接线没错"的场合:
|
||||
///
|
||||
/// ```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");
|
||||
let length = 2_500_000_000_u64;
|
||||
assert!(length < zip::ZIP64_BYTES_THR, "样本必须在阈值之下");
|
||||
assert!(
|
||||
length > zip::ZIP64_BYTES_THR / 2,
|
||||
"样本必须落在过拒窗口里(≥ 阈值一半)"
|
||||
);
|
||||
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);
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"2.5 GB 的覆盖文件没超 ZIP64 阈值,不许误拒:{:?}",
|
||||
result.err()
|
||||
);
|
||||
|
||||
// 包内条目的**声明未压缩尺寸**就是 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 越积越多。
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
//! 逐文件冲突策略的**边界样本**(research/18 §3.3 / §6 表 B #1–#2 的那几条)。
|
||||
//!
|
||||
//! 三条样本各自钉一个事实,缺一条就会复现不同的 bug:
|
||||
//!
|
||||
//! | 样本 | 必须成立的事实 | 写错的后果 |
|
||||
//! |------|----------------|-----------|
|
||||
//! | 同路径不同哈希 | 叠层顺序里**最后一条**胜;被遮蔽的那条不落地、只留一条具名注记 | 报告里两条 `Installed`,前一条的哈希根本没落地 |
|
||||
//! | 同哈希不同路径 | **两个文件都落地**(按路径判重,不许按哈希判重) | 静默丢一个文件(内容一样不等于同一个文件) |
|
||||
//! | overrides 叠层顺序 | `overrides/` 先、`client-overrides/` 后(= 后者胜,Modrinth 官方语义) | 与 HMCL 的相反语义混进来,同一包在不同启动器装出不同内容 |
|
||||
//!
|
||||
//! 判据的纯函数版本(`plan::layer::resolve`)在那边有单元测试;这里是**接在管线上的行为**:
|
||||
//! 真打 zip、真导入、真读回磁盘内容与报告字段。
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crate::addon::import::{
|
||||
import_pack, keys, ConflictReason, FileAction, ImportProgress, ImportRequest, ImportStage,
|
||||
ImportStatus,
|
||||
};
|
||||
use crate::download::blocking::block_on;
|
||||
|
||||
use super::support::sha1;
|
||||
use super::{read_text, temp_root, write_file, zip_dir};
|
||||
|
||||
/// 打一个只有 `files: []` 的 mrpack(覆盖内容全在 overrides 系列目录里)。
|
||||
fn index_json(name: &str) -> String {
|
||||
format!(
|
||||
r#"{{"game": "minecraft", "formatVersion": 1, "versionId": "1.0.0",
|
||||
"name": "{name}", "files": [], "dependencies": {{"minecraft": "1.20.1"}}}}"#
|
||||
)
|
||||
}
|
||||
|
||||
/// 报告里所有 `NOTE_OVERRIDE_SHADOWED` 注记的消息。
|
||||
fn shadow_notes(report: &crate::addon::import::ImportReport) -> Vec<&str> {
|
||||
report
|
||||
.warnings
|
||||
.iter()
|
||||
.filter(|note| note.message_key == keys::NOTE_OVERRIDE_SHADOWED)
|
||||
.map(|note| note.message.as_str())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_path_with_different_bytes_lands_the_later_side_only() {
|
||||
let root = temp_root("layer-same-path");
|
||||
let dir = root.join("src");
|
||||
write_file(
|
||||
&dir.join("modrinth.index.json"),
|
||||
index_json("dup conflict").as_bytes(),
|
||||
);
|
||||
write_file(&dir.join("overrides/config/dup.toml"), b"from-overrides\n");
|
||||
write_file(
|
||||
&dir.join("client-overrides/config/dup.toml"),
|
||||
b"from-client\n",
|
||||
);
|
||||
let pack = root.join("pack.mrpack");
|
||||
zip_dir(&dir, &pack);
|
||||
let target = root.join("instance");
|
||||
|
||||
// Extract 阶段的总条数就是"要落地的条目数":两点声明 ⇒ 只算 1 条(被遮蔽的不读字节)。
|
||||
let totals: Arc<Mutex<Vec<usize>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
let sink = {
|
||||
let totals = Arc::clone(&totals);
|
||||
Arc::new(move |progress: &ImportProgress| {
|
||||
if progress.stage == ImportStage::Extract {
|
||||
totals.lock().expect("锁").push(progress.total);
|
||||
}
|
||||
})
|
||||
};
|
||||
let report = block_on(import_pack(
|
||||
&ImportRequest::new(&pack, &target).with_progress(sink),
|
||||
));
|
||||
|
||||
assert_eq!(report.status, ImportStatus::Success, "{:?}", report.error);
|
||||
// Modrinth 官方语义:client-overrides/ 后写 = 胜。
|
||||
assert_eq!(read_text(&target.join("config/dup.toml")), "from-client\n");
|
||||
// 一路径一行,且那一行的哈希必须是**真落地**的那份内容。
|
||||
let rows: Vec<_> = report
|
||||
.files
|
||||
.iter()
|
||||
.filter(|file| file.path == "config/dup.toml")
|
||||
.collect();
|
||||
assert_eq!(rows.len(), 1, "同一路径只许一行:{:?}", report.files);
|
||||
assert_eq!(
|
||||
rows[0].sha1.as_deref(),
|
||||
Some(sha1(b"from-client\n").as_str())
|
||||
);
|
||||
assert_eq!(rows[0].action, FileAction::Installed);
|
||||
// 遮蔽要留下具名注记:谁赢了、两份内容不同。
|
||||
let notes = shadow_notes(&report);
|
||||
assert_eq!(notes.len(), 1, "{:?}", report.warnings);
|
||||
assert!(notes[0].contains("内容不同"), "{}", notes[0]);
|
||||
assert!(notes[0].contains("client-overrides"), "{}", notes[0]);
|
||||
assert!(
|
||||
report.conflicts.is_empty(),
|
||||
"包内重复声明不是用户冲突:{:?}",
|
||||
report.conflicts
|
||||
);
|
||||
// 被遮蔽的条目不再进 Extract 的总数(1 条真落地)。
|
||||
assert_eq!(totals.lock().expect("锁").last().copied(), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_same_bytes_at_two_paths_are_two_files() {
|
||||
let root = temp_root("layer-same-bytes");
|
||||
let dir = root.join("src");
|
||||
write_file(
|
||||
&dir.join("modrinth.index.json"),
|
||||
index_json("same bytes").as_bytes(),
|
||||
);
|
||||
let content = b"identical payload\n";
|
||||
write_file(&dir.join("overrides/mods/a.jar"), content);
|
||||
write_file(&dir.join("overrides/mods/b.jar"), content);
|
||||
let pack = root.join("pack.mrpack");
|
||||
zip_dir(&dir, &pack);
|
||||
let target = root.join("instance");
|
||||
|
||||
let report = block_on(import_pack(&ImportRequest::new(&pack, &target)));
|
||||
|
||||
assert_eq!(report.status, ImportStatus::Success, "{:?}", report.error);
|
||||
for path in ["mods/a.jar", "mods/b.jar"] {
|
||||
assert_eq!(
|
||||
read_text(&target.join(path)),
|
||||
"identical payload\n",
|
||||
"{path}"
|
||||
);
|
||||
}
|
||||
let rows: Vec<_> = report
|
||||
.files
|
||||
.iter()
|
||||
.filter(|file| file.path.ends_with(".jar"))
|
||||
.collect();
|
||||
assert_eq!(rows.len(), 2, "同哈希不同路径不许折叠:{:?}", report.files);
|
||||
assert!(rows
|
||||
.iter()
|
||||
.all(|row| row.sha1.as_deref() == Some(sha1(content).as_str())));
|
||||
assert!(
|
||||
shadow_notes(&report).is_empty(),
|
||||
"不同路径不是重复声明:{:?}",
|
||||
report.warnings
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identical_bytes_on_both_sides_shadow_without_claiming_a_conflict() {
|
||||
let root = temp_root("layer-same-content");
|
||||
let dir = root.join("src");
|
||||
write_file(
|
||||
&dir.join("modrinth.index.json"),
|
||||
index_json("dup identical").as_bytes(),
|
||||
);
|
||||
let content = b"shared = true\n";
|
||||
write_file(&dir.join("overrides/config/shared.toml"), content);
|
||||
write_file(&dir.join("client-overrides/config/shared.toml"), content);
|
||||
let pack = root.join("pack.mrpack");
|
||||
zip_dir(&dir, &pack);
|
||||
let target = root.join("instance");
|
||||
|
||||
let report = block_on(import_pack(&ImportRequest::new(&pack, &target)));
|
||||
|
||||
assert_eq!(report.status, ImportStatus::Success, "{:?}", report.error);
|
||||
assert_eq!(
|
||||
read_text(&target.join("config/shared.toml")),
|
||||
"shared = true\n"
|
||||
);
|
||||
let notes = shadow_notes(&report);
|
||||
assert_eq!(notes.len(), 1, "{:?}", report.warnings);
|
||||
assert!(
|
||||
notes[0].contains("逐字节相同"),
|
||||
"内容一致必须说清楚,别让人以为丢了东西:{}",
|
||||
notes[0]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_baseline_remembers_the_winning_bytes() {
|
||||
// 叠层顺序的**后果**:基线要记胜者那份。记错的话,下一次升级会把"用户没动过"判成
|
||||
// "用户改过"(或反过来静默覆盖用户改动)。
|
||||
let root = temp_root("layer-baseline");
|
||||
let dir = root.join("src");
|
||||
write_file(
|
||||
&dir.join("modrinth.index.json"),
|
||||
index_json("baseline dup").as_bytes(),
|
||||
);
|
||||
write_file(&dir.join("overrides/config/dup.toml"), b"from-overrides\n");
|
||||
write_file(
|
||||
&dir.join("client-overrides/config/dup.toml"),
|
||||
b"from-client\n",
|
||||
);
|
||||
let pack = root.join("pack.mrpack");
|
||||
zip_dir(&dir, &pack);
|
||||
let target = root.join("instance");
|
||||
let first = block_on(import_pack(&ImportRequest::new(&pack, &target)));
|
||||
assert_eq!(first.status, ImportStatus::Success, "{:?}", first.error);
|
||||
|
||||
// 同一份包再导一次:磁盘内容 == 胜者内容 == 基线 ⇒ 既不该报冲突,也不该重写盘。
|
||||
let second = block_on(import_pack(&ImportRequest::new(&pack, &target)));
|
||||
assert_eq!(second.status, ImportStatus::Success, "{:?}", second.error);
|
||||
assert!(
|
||||
second.conflicts.is_empty(),
|
||||
"基线记的必须是胜者那份,否则这里会冒出一条假的 user_modified:{:?}",
|
||||
second.conflicts
|
||||
);
|
||||
assert_eq!(read_text(&target.join("config/dup.toml")), "from-client\n");
|
||||
// 反向确认:冲突清单里不该出现这条路径的**任何**成因。
|
||||
assert!(!second
|
||||
.conflicts
|
||||
.iter()
|
||||
.any(|conflict| conflict.path == "config/dup.toml"
|
||||
&& conflict.reason != ConflictReason::PreExisting));
|
||||
}
|
||||
@@ -22,6 +22,7 @@
|
||||
mod fixtures;
|
||||
mod import_curseforge;
|
||||
mod import_failures;
|
||||
mod import_layering;
|
||||
mod import_merge;
|
||||
mod import_modrinth;
|
||||
mod import_multimc;
|
||||
|
||||
Reference in new issue
Block a user