diff --git a/crates/core/src/addon/import.rs b/crates/core/src/addon/import.rs index 201b67f..c2d27e4 100644 --- a/crates/core/src/addon/import.rs +++ b/crates/core/src/addon/import.rs @@ -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, diff --git a/crates/core/src/addon/import/phase.rs b/crates/core/src/addon/import/phase.rs index 1d24268..cb97ac2 100644 --- a/crates/core/src/addon/import/phase.rs +++ b/crates/core/src/addon/import/phase.rs @@ -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, diff --git a/crates/core/src/addon/import/plan.rs b/crates/core/src/addon/import/plan.rs index f9d5631..f66e70a 100644 --- a/crates/core/src/addon/import/plan.rs +++ b/crates/core/src/addon/import/plan.rs @@ -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, + /// 被同包另一条声明遮蔽时的事实(`None` = 本条目就是这条路径的最终胜者)。 + pub shadowed: Option, } /// 一条要补全/下载的远端条目。 @@ -136,7 +143,7 @@ impl ImportPlan { let mut overrides: Vec = 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, diff --git a/crates/core/src/addon/import/plan/layer.rs b/crates/core/src/addon/import/plan/layer.rs new file mode 100644 index 0000000..885570a --- /dev/null +++ b/crates/core/src/addon/import/plan/layer.rs @@ -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) { + let mut last: HashMap = 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); + } +} diff --git a/crates/core/src/addon/import/report/keys.rs b/crates/core/src/addon/import/report/keys.rs index e373a37..907b55d 100644 --- a/crates/core/src/addon/import/report/keys.rs +++ b/crates/core/src/addon/import/report/keys.rs @@ -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, diff --git a/crates/core/src/addon/pack/format.rs b/crates/core/src/addon/pack/format.rs index 43cb9d4..a4a1e47 100644 --- a/crates/core/src/addon/pack/format.rs +++ b/crates/core/src/addon/pack/format.rs @@ -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 { diff --git a/crates/core/src/addon/pack/format/write.rs b/crates/core/src/addon/pack/format/write.rs new file mode 100644 index 0000000..a19d5dc --- /dev/null +++ b/crates/core/src/addon/pack/format/write.rs @@ -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 { + 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) +} + +/// 单条覆盖文件的来源:来源表优先(跨格式导出用),否则按 `//` 拼。 +fn source_for(payload: PackPayload<'_>, dir: &str, path: &str) -> PackResult { + 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); + } +} diff --git a/crates/core/src/addon/pack/tests/mod.rs b/crates/core/src/addon/pack/tests/mod.rs index d4458ee..f4379d9 100644 --- a/crates/core/src/addon/pack/tests/mod.rs +++ b/crates/core/src/addon/pack/tests/mod.rs @@ -13,6 +13,7 @@ mod model; mod roundtrip; mod safety; mod unknown_fields; +mod zip64; use std::fs::File; use std::io::Write; diff --git a/crates/core/src/addon/pack/tests/unknown_fields.rs b/crates/core/src/addon/pack/tests/unknown_fields.rs index bc7fa79..8813c7c 100644 --- a/crates/core/src/addon/pack/tests/unknown_fields.rs +++ b/crates/core/src/addon/pack/tests/unknown_fields.rs @@ -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"}) + ); +} diff --git a/crates/core/src/addon/pack/tests/zip64.rs b/crates/core/src/addon/pack/tests/zip64.rs new file mode 100644 index 0000000..d1b7b33 --- /dev/null +++ b/crates/core/src/addon/pack/tests/zip64.rs @@ -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) -> 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 { + let mut names: Vec = 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::::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); +} diff --git a/crates/core/src/addon/tests/import_layering.rs b/crates/core/src/addon/tests/import_layering.rs new file mode 100644 index 0000000..0531979 --- /dev/null +++ b/crates/core/src/addon/tests/import_layering.rs @@ -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>> = 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)); +} diff --git a/crates/core/src/addon/tests/mod.rs b/crates/core/src/addon/tests/mod.rs index 6823527..40045bf 100644 --- a/crates/core/src/addon/tests/mod.rs +++ b/crates/core/src/addon/tests/mod.rs @@ -22,6 +22,7 @@ mod fixtures; mod import_curseforge; mod import_failures; +mod import_layering; mod import_merge; mod import_modrinth; mod import_multimc;