diff --git a/crates/core/src/addon/pack.rs b/crates/core/src/addon/pack.rs index f4a8bcc..a308012 100644 --- a/crates/core/src/addon/pack.rs +++ b/crates/core/src/addon/pack.rs @@ -14,6 +14,13 @@ //! 三个内置格式:[`curseforge`](`manifest.json`,可读可写)、[`modrinth`](`.mrpack`,可读可写)、 //! [`multimc`](`mmc-pack.json`,**只读**)。 //! +//! # 导出面的边界(r1 之后写死) +//! +//! 默认 [`format::PackFormat::write`] **只保证同格式往返**:覆盖目录按模型原样搬, +//! 不做目录映射 / `side` 分派 / 加载器 id 规范化 / `extra` 分桶(都属于 M112)。 +//! 表达不了的时候由 [`format::PackFormat::check_overrides`] 直接报 `pack_unsupported`, +//! 而不是产出一个"成功但内容为空"的包。 +//! //! # 刻意不做的事(分工边界) //! //! - **不发网络请求**:CF 的 `projectID/fileID` → 文件名/URL 补全属于 M111(要用 CF API 与 @@ -53,7 +60,7 @@ pub mod path; #[cfg(test)] mod tests; -pub use archive::{PackArchive, PackEntryInfo, PackLimits}; +pub use archive::{PackArchive, PackEntryInfo, PackLimits, ReadBudget}; pub use curseforge::CurseForge; pub use error::{PackError, PackErrorKind, PackResult}; pub use format::{PackFormat, PackMatch, PackPayload, PackRegistry}; diff --git a/crates/core/src/addon/pack/archive.rs b/crates/core/src/addon/pack/archive.rs index a34740e..141d6d7 100644 --- a/crates/core/src/addon/pack/archive.rs +++ b/crates/core/src/addon/pack/archive.rs @@ -9,18 +9,24 @@ //! //! | 威胁 | 做法 | //! |------|------| -//! | 路径穿越(`../x`、`/etc/x`、`C:\x`、`a\..\b`、`file:ads`) | 条目名过 [`normalize_file`](第一检),再过 `zip` crate 的 `enclosed_name()`(第二检:它按 **Windows 规则**解析盘符/ADS/保留设备名);任一拒绝即整包拒绝 | +//! | 路径穿越(`../x`、`/etc/x`、`C:\x`、`a\..\b`、`file:ads`) | 条目名过 [`normalize_file`](第一检,平台无关的七条规则),再过 `zip` crate 的 `enclosed_name()`(第二检,**按 Windows 规则**解析盘符/ADS/保留设备名);任一拒绝即整包拒绝 | //! | 符号链接条目 | `unix_mode()` 的 `S_IFLNK` 位命中即拒绝(Windows 上无合法用途;解压器会把目标当内容写出来) | //! | 条目数爆炸 | [`PackLimits::max_entries`],打开时按中央目录长度先判 | //! | 解压体积爆炸(zip 炸弹) | [`PackLimits::max_uncompressed_bytes`] 累计 + [`PackLimits::max_entry_bytes`] 单条目;**读取时**再按实际读出的字节数封顶(中央目录里声明的大小可以撒谎) | //! | 同名条目(`./a` 与 `a` 规整后撞车) | 规整后的名字重复即拒绝 | //! -//! **为什么第一检不能省**:`enclosed_name()` 允许"深度 0 处的根目录",也就是 -//! `"/etc/passwd"` 会返回 `Some("/etc/passwd")`——绝对路径必须由我们自己的规则先干掉, -//! 库的检查只当跨平台兜底。 +//! **两条检查各补一条对方没有的规则**(谁也不能省): //! -//! **不在这里做的事**:不写盘(那是 M111 导入管线的事),所以 Windows 保留设备名 -//! (`CON` / `NUL` / `COM1`…)这里不拦——落盘前必须拦,见 `zip::path` 的同名规则。 +//! - 第一检管**平台无关**的形状:空名、`\0`、`\`、`:`、前导 `/`、任何 `..` 段。 +//! `enclosed_name()` 在这一点上更松——它允许"深度 0 处的根目录",也就是 `"/etc/passwd"` +//! 会返回 `Some("/etc/passwd")`,绝对路径必须由我们先干掉。 +//! - 第二检管 **Win32 命名空间**:DOS 设备名(`CON` / `NUL` / `AUX` / `COM1..9` / `LPT1..9`)、 +//! 结尾的点或空格(`a.`、`a `)、`< > " | ? *` 与控制字符。这些第一检全放行,而它们会让 +//! 落盘时撞到设备、或与另一个名字等价。第二检只在 **Windows 宿主**上生效(`zip` 的 +//! `cfg(windows)` 分支),所以 `tests/safety.rs` 里对应的用例也是 `#[cfg(windows)]`。 +//! +//! 也就是说:`CON` / `NUL` 这类在 Windows 上**是拦的**(由第二检),非 Windows 宿主上不拦 +//! (那里它们只是普通文件名,拦了反而拒掉合法包)。 use std::cell::RefCell; use std::collections::HashMap; @@ -43,7 +49,8 @@ mod open; pub use entry::PackEntryInfo; pub use limits::{ - PackLimits, DEFAULT_MAX_ENTRIES, DEFAULT_MAX_ENTRY_BYTES, DEFAULT_MAX_UNCOMPRESSED_BYTES, + PackLimits, ReadBudget, DEFAULT_MAX_ENTRIES, DEFAULT_MAX_ENTRY_BYTES, + DEFAULT_MAX_UNCOMPRESSED_BYTES, }; use limits::read_capped; @@ -160,7 +167,7 @@ impl PackArchive { let mut reader = archive .by_name(&entry.raw) .map_err(|error| PackError::metadata(format!("解压 {name} 失败:{error}")))?; - let bytes = read_capped(&mut reader, self.limits.max_entry_bytes, name)?; + let bytes = read_capped(&mut reader, self.limits.max_entry_bytes, None, name)?; Ok(Some(bytes)) } } @@ -187,7 +194,15 @@ impl PackArchive { } /// 算一个条目的 SHA-1;不存在返回 `Ok(None)`。 + /// + /// 单条读取只受 [`PackLimits::max_entry_bytes`] 约束;要跨条目累计限额的批量读取 + /// 请用 [`Self::override_files`],或自己建 [`ReadBudget`] 交给 [`Self::override_files_with`]。 pub fn sha1(&self, name: &str) -> PackResult> { + self.sha1_with(name, None) + } + + /// 算 SHA-1 并从 `budget` 里扣减实际读出的字节(批量读取用)。 + fn sha1_with(&self, name: &str, budget: Option<&ReadBudget>) -> PackResult> { let Some(entry) = self.entry(name) else { return Ok(None); }; @@ -224,6 +239,9 @@ impl PackArchive { self.limits.max_entry_bytes ))); } + if let Some(budget) = budget { + budget.spend(read as u64, name)?; + } hasher.update(&buffer[..read]); } Ok(Some(hasher.finish())) @@ -234,7 +252,21 @@ impl PackArchive { /// 收集一个覆盖目录下的文件(相对该目录的路径 + SHA-1 + 字节数),按路径排序。 /// /// 目录不存在时返回空表——"包没带 overrides"不是错误。 + /// + /// 这是**唯一会批量解压的 API**,所以它自己持有一个累计预算 + /// (`PackLimits::max_uncompressed_bytes`),逐条按实际读出的字节扣减: + /// 声明大小撒谎的包不会在这里把 CPU 时间耗光(r1 p3-4)。 pub fn override_files(&self, dir: &str) -> PackResult> { + let budget = ReadBudget::new(self.limits.max_uncompressed_bytes); + self.override_files_with(dir, &budget) + } + + /// 同 [`Self::override_files`],但用调用方给的累计预算(M111 想与别处的解压共用额度时用)。 + pub fn override_files_with( + &self, + dir: &str, + budget: &ReadBudget, + ) -> PackResult> { let dir = normalize_dir(dir)?; let prefix = format!("{dir}/"); let mut collected = Vec::new(); @@ -246,7 +278,9 @@ impl PackArchive { if relative.is_empty() { continue; } - let sha1 = self.sha1(&entry.name)?.unwrap_or_default(); + let sha1 = self + .sha1_with(&entry.name, Some(budget))? + .unwrap_or_default(); collected.push(OverrideFile { path: relative.to_string(), sha1, diff --git a/crates/core/src/addon/pack/archive/limits.rs b/crates/core/src/addon/pack/archive/limits.rs index ae4bb6f..524306c 100644 --- a/crates/core/src/addon/pack/archive/limits.rs +++ b/crates/core/src/addon/pack/archive/limits.rs @@ -1,5 +1,14 @@ //! 读取限额:条目数 / 解压总体积 / 单条目,以及两处封顶实现。 +//! +//! # 两阶段限额(打开 vs 读取) +//! +//! 打开阶段([`check_limits`])只能拿**中央目录声明的大小**做判断——它会撒谎。 +//! 因此读取阶段还有第二道:单条目按实际读出字节封顶([`read_capped`]),批量读取 +//! ([`super::PackArchive::override_files`])再按**累计实际字节**从 [`ReadBudget`] 里扣减。 +//! 没有预算的批量读取等于把 `max_uncompressed_bytes` 降级成"每条单独算"——一个 2 万条目、 +//! 每条声明 1 字节却解出几十 MiB 的包能把 CPU/时间耗在这里(r1 p3-4)。 +use std::cell::Cell; use std::io::Read; use super::super::error::{PackError, PackResult}; @@ -48,6 +57,47 @@ impl Default for PackLimits { } } +/// 一次批量读取的**累计**预算(读取阶段,按实际读出的字节扣减)。 +/// +/// 为什么需要它:中央目录声明的大小可以撒谎,单个条目也被限制在 +/// [`PackLimits::max_entry_bytes`],但"很多条各自合规"累起来仍可能远超 +/// `max_uncompressed_bytes`。批量读取(`override_files`)持有一个预算,边读边扣。 +#[derive(Debug)] +pub struct ReadBudget { + remaining: Cell, +} + +impl ReadBudget { + /// 按总预算新建。 + #[must_use] + pub fn new(limit: u64) -> Self { + Self { + remaining: Cell::new(limit), + } + } + + /// 还剩多少字节。 + #[must_use] + pub fn remaining(&self) -> u64 { + self.remaining.get() + } + + /// 扣减一次实际读出的字节数;超额报 `pack_limits`。 + /// + /// 超额时预算**照样扣到 0**(这些字节已经从流里读出来了),后续调用会继续失败—— + /// 不给"失败一次、下次又能读一点"的机会。 + pub(super) fn spend(&self, bytes: u64, name: &str) -> PackResult<()> { + let remaining = self.remaining.get(); + self.remaining.set(remaining.saturating_sub(bytes)); + if bytes > remaining { + return Err(PackError::limits(format!( + "{name} 之后累计解压超过上限(本次还需 {bytes} 字节,预算已用尽)" + ))); + } + Ok(()) + } +} + /// 累计与单条目限额检查(打开阶段)。 pub(super) fn check_limits( limits: &PackLimits, @@ -79,9 +129,18 @@ pub(super) fn check_limits( } /// 带封顶的读:中央目录里声明的大小可以撒谎,这里按**实际读出的字节**封顶。 -pub(super) fn read_capped(reader: &mut impl Read, cap: u64, name: &str) -> PackResult> { +/// +/// 给了 `budget` 就同时从累计预算里扣(批量读取);单条读取给 `None`, +/// 只受 [`PackLimits::max_entry_bytes`] 约束。 +pub(super) fn read_capped( + reader: &mut impl Read, + cap: u64, + budget: Option<&ReadBudget>, + name: &str, +) -> PackResult> { let mut bytes = Vec::new(); let mut buffer = [0_u8; 64 * 1024]; + let mut total = 0_u64; loop { let read = reader .read(&mut buffer) @@ -89,11 +148,15 @@ pub(super) fn read_capped(reader: &mut impl Read, cap: u64, name: &str) -> PackR if read == 0 { break; } - if bytes.len() as u64 + read as u64 > cap { + total += read as u64; + if total > cap { return Err(PackError::limits(format!( "{name} 实际解压超过单条目上限 {cap}" ))); } + if let Some(budget) = budget { + budget.spend(read as u64, name)?; + } bytes.extend_from_slice(&buffer[..read]); } Ok(bytes) diff --git a/crates/core/src/addon/pack/curseforge.rs b/crates/core/src/addon/pack/curseforge.rs index 79744b0..466e5ed 100644 --- a/crates/core/src/addon/pack/curseforge.rs +++ b/crates/core/src/addon/pack/curseforge.rs @@ -22,6 +22,10 @@ //! [`PackLoader::raw`],写回时逐字还原。 //! - 只有一个 `required` 布尔 → 客户端/服务端同值;导出时 `client == Optional` 或 //! `disabled` 即 `required: false`。`client`/`server` 分侧语义 CF 表达不了(mrpack 专属)。 +//! - **跨格式导出不完整**(M112 的活):`modLoaders[].id` 只对 CF 来源是合法形状; +//! MultiMC 的 `net.fabricmc.fabric-loader` / 非加载器组件会被逐字写进去(CF 不认); +//! `overrides` 字段会照抄模型里的目录名(从 MultiMC 转过来是 `.minecraft`,落到 ULL 实例里 +//! 会变成 `.minecraft/.minecraft/…`)。同一份模型只有 `read` 出来的那个格式才保证往返。 use serde::Deserialize; use serde_json::{json, Map, Value}; @@ -126,9 +130,15 @@ impl PackFormat for CurseForge { Some(METADATA_ENTRY) } - fn detect(&self, pack: &PackArchive) -> Option<&'static str> { - let value = pack.read_json(METADATA_ENTRY).ok().flatten()?; - let object = value.as_object()?; + fn detect(&self, pack: &PackArchive) -> PackResult> { + // 文件不存在 ≠ 包坏了:没有 `manifest.json` 就是"不是本格式"。 + let Some(value) = pack.read_json(METADATA_ENTRY)? else { + return Ok(None); + }; + // 文件在、但不是对象 = 形状坏了(`pack_metadata`),不是"不认识"(r1 p3-2)。 + let object = value + .as_object() + .ok_or_else(|| PackError::metadata(format!("{METADATA_ENTRY} 存在但不是 JSON 对象")))?; let matched = match object.get("manifestType").and_then(Value::as_str) { // 有标记:只认固定值(`manifest.json` 这名字太泛,光凭名字不能认领)。 Some(kind) => kind == MARKER, @@ -138,7 +148,31 @@ impl PackFormat for CurseForge { && object.get("files").is_some_and(Value::is_array) } }; - matched.then_some(METADATA_ENTRY) + Ok(matched.then_some(METADATA_ENTRY)) + } + + fn check_overrides(&self, manifest: &PackManifest) -> PackResult<()> { + // CF 只有**一个** `overrides` 目录,且清单里只有一个字段能声明它 ⇒ 只表达得了 + // 一个 Both 侧组。Client/Server 分侧是 mrpack 的东西,这里宁可拒也不猜(r1 p2-1)。 + let mut both = 0_usize; + for group in &manifest.overrides { + if group.side != PackSide::Both { + return Err(PackError::unsupported(format!( + "CurseForge 只有一个 overrides 目录,表达不了 {} 侧的覆盖内容({} 个文件)", + group.side.as_str(), + group.files.len() + )) + .with_hint("先按侧合并(或去掉不该带的组)再导出;分侧目录映射属 M112")); + } + both += 1; + } + if both > 1 { + return Err(PackError::unsupported( + "CurseForge 只有一个 overrides 目录字段,模型里却有多个 Both 侧覆盖组", + ) + .with_hint("把它们合成一个组再导出;目录名映射属 M112")); + } + Ok(()) } fn read(&self, pack: &PackArchive) -> PackResult { @@ -197,19 +231,19 @@ impl PackFormat for CurseForge { }) .collect(); - let mut overrides = Vec::new(); - if let Some(dir) = raw + // CF 的 `overrides` 字段缺省就是 `"overrides"`(规范默认值),`render` 也总会写它; + // 读侧因此**永远**产出一个 Both 侧组,读写才对称(dir 不存在时 files 为空表)。 + let overrides_dir = raw .overrides .as_deref() .filter(|dir| !dir.trim().is_empty()) - { - let dir = normalize_dir(dir)?; - overrides.push(PackOverrides { - files: pack.override_files(&dir)?, - dir, - side: PackSide::Both, - }); - } + .unwrap_or(DEFAULT_OVERRIDES); + let overrides_dir = normalize_dir(overrides_dir)?; + let overrides = vec![PackOverrides { + files: pack.override_files(&overrides_dir)?, + dir: overrides_dir, + side: PackSide::Both, + }]; Ok(PackManifest { name, diff --git a/crates/core/src/addon/pack/format.rs b/crates/core/src/addon/pack/format.rs index 6aea435..fb41177 100644 --- a/crates/core/src/addon/pack/format.rs +++ b/crates/core/src/addon/pack/format.rs @@ -21,17 +21,32 @@ //! - [`PackRegistry::register`] 追加到**末尾**(= 最低优先级),所以测试里改一行顺序就能 //! 证明"优先级来自注册表顺序"(`tests/detect.rs` 有一条反序注册的用例)。 //! -//! # 写 +//! # 写:默认实现**只保证同格式往返**(这条是给 M111/M112 的边界声明) //! //! 默认实现([`PackFormat::write`])替所有格式做同一件事:写清单 entry([`PackFormat::render`] -//! 的产物)+ 从 [`PackPayload::root`] 里搬覆盖目录内容。因此新格式只要实现 -//! `metadata_entry` + `render` 就同时获得导出能力;只读格式(MultiMC)不实现 `metadata_entry`, -//! 写会得到明确的 `crate::addon::pack::error::PackErrorKind::Unsupported`。 +//! 的产物)+ 把 `manifest.overrides` 的 `(dir, side, files)` **原样**搬进 zip。因此新格式只要 +//! 实现 `metadata_entry` + `render` 就同时获得导出能力;只读格式(MultiMC)不实现 +//! `metadata_entry`,写会得到明确的 `crate::addon::pack::error::PackErrorKind::Unsupported`。 +//! +//! **它不做目标格式映射**,具体缺四件事(都属于 M112,别以为这一层已经做了): +//! +//! 1. **目录名映射**:mrpack 的覆盖目录名由 `side` 决定(`overrides` / `client-overrides` / +//! `server-overrides`),CF 只有一个 `overrides`(名字由清单给)。`check_overrides` 只保证 +//! "布局表达不了就报错",不负责改名; +//! 2. **`side` 合并/分派**:CF 表达不了 Client/Server 分侧,现在是**拒**而不是合并; +//! 3. **加载器 id 规范化**:CF 要 `forge-47.2.0` / `fabric-0.15.11`,mrpack 要 +//! `forge` / `fabric-loader` 这类键。`render` 只按模型里的 `raw`/`kind` 写,跨格式转出来的 +//! id 形态可能不被目标平台接受(尤其 MultiMC 的 `net.fabricmc.fabric-loader`); +//! 4. **`extra` 不按格式分桶**:模型只有一份未知字段集合,跨格式导出会把别的格式的未知键 +//! 一起带过去。 +//! +//! 换句话说:`X.read` → `X.write` 是可靠往返(有逐字段相等的测试);`X.read` → `Y.write` +//! 要么被 `check_overrides` 拒掉,要么"结构合法但语义不完整"。**要跨格式导出,先做映射。** use std::fmt::Debug; use std::fs::File; use std::io::Write; -use std::path::Path; +use std::path::{Path, PathBuf}; use super::archive::PackArchive; use super::curseforge; @@ -88,7 +103,11 @@ pub trait PackFormat: Debug + Send + Sync { /// **必须带形状校验**,不能只看文件名存在:`manifest.json` 这种泛名到处都是。 /// "选谁"不是格式自己的事——排序与歧义在 [`PackRegistry::detect`] 那一层做 /// (返回 [`PackMatch`],带 `also_matched` 与本次命中的证据)。 - fn detect(&self, pack: &PackArchive) -> Option<&'static str>; + /// + /// **清单在、但读不出来**时必须返回 `Err`(`pack_metadata` 一类),不要用 + /// `.ok().flatten()` 吞掉:那样"包坏了"会被上层报成"没有格式认领",M111 的失败分级 + /// 就会把可以重下文件的包当成不支持(r1 p3-2)。 + fn detect(&self, pack: &PackArchive) -> PackResult>; /// 读成中间模型。 fn read(&self, pack: &PackArchive) -> PackResult; @@ -96,9 +115,33 @@ pub trait PackFormat: Debug + Send + Sync { /// 把模型渲染成清单 JSON(未知字段在这里回写)。 fn render(&self, manifest: &PackManifest) -> PackResult; + /// 这份模型的**覆盖目录集合**能不能被本格式表达(默认 `write` 在写第一个字节前调用)。 + /// + /// 默认实现不限制("目录名原样写进包里"对同格式往返是对的)。凡是有**固定目录布局** + /// 的格式必须收紧:mrpack 的三个目录由 `side` 决定,CF 只有一个 `overrides` 目录。 + /// 不收紧的后果不是报错而是**静默丢内容**——把一份 MultiMC 实例写成 `.mrpack` 时, + /// 文件会落在 `.minecraft/...`,而 mrpack 导入器只认 + /// `overrides/` / `client-overrides/` / `server-overrides/`,读出来 mods 数量为 0(r1 p2-1)。 + fn check_overrides(&self, manifest: &PackManifest) -> PackResult<()> { + let _ = manifest; + Ok(()) + } + /// 导出一个整包(清单 + 覆盖内容)。 /// - /// 默认实现对"只读格式"(`metadata_entry` 为 `None`)直接报不支持。 + /// 默认实现做四件事:①只读格式直接拒;②[`Self::check_overrides`] 挡跨格式导出; + /// ③`render` 出清单;④写进**临时文件**再 `rename` 到 `target`(失败不留半个包)。 + /// + /// # 只保证同格式往返(写给 M111/M112) + /// + /// 默认 `write` 的覆盖目录策略是"把 `manifest.overrides` 里 `(dir, side, files)` + /// 原样再打一遍",也就是"读出来的包按原样打包"。它**不做**目标格式的目录映射、`side` + /// 分派、加载器 id 规范化,也**不按格式分桶** `extra`。所以: + /// + /// - 同格式往返(`X.read` → `X.write`)可靠,有逐字段相等的测试; + /// - 跨格式导出(`X.read` → `Y.write`)在布局不同时被 [`Self::check_overrides`] 拒掉; + /// 但"布局恰好兼容、语义不兼容"的部分(加载器 id 形态、`extra` 里别的格式的未知键) + /// **仍会被原样带过去**——那是 M112 的活,这一层不替谁做映射。 fn write( &self, manifest: &PackManifest, @@ -113,50 +156,89 @@ pub trait PackFormat: Debug + Send + Sync { )) .with_hint("导出请选 Modrinth(.mrpack)或 CurseForge 格式")); }; + // 先判"能不能表达",再碰磁盘:跨格式导出宁可报错,也不产出空包。 + self.check_overrides(manifest)?; // 模型可能来自外部构造:搬内容前把每条路径重查一遍。 manifest.check_paths()?; let json = self.render(manifest)?; - let file = File::create(target) - .map_err(|error| PackError::io(format!("创建 {} 失败:{error}", target.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}")))?; - 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 Some(root) = payload.root() else { - return Err(PackError::unsupported(format!( - "导出需要覆盖内容({name}),但没给 payload 根目录" - ))); - }; - let source = root.join(&dir).join(&path); - let bytes = std::fs::read(&source).map_err(|error| { - PackError::io(format!("读覆盖文件 {} 失败:{error}", source.display())) - })?; - 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}")))?; + // 临时文件 + rename:中途失败时目标路径上不会留下"看起来能用"的半个 zip(r1 p3-1)。 + let temp = temporary_path(target); + match write_container(self.id(), entry, &json, manifest, payload, &temp) { + Ok(()) => std::fs::rename(&temp, target).map_err(|error| { + let _ = std::fs::remove_file(&temp); + PackError::io(format!( + "把 {} 改名成 {} 失败:{error}", + temp.display(), + target.display() + )) + }), + Err(error) => { + let _ = std::fs::remove_file(&temp); + Err(error) } } - writer - .finish() - .map_err(|error| PackError::io(format!("收尾 {} 失败:{error}", target.display())))?; - Ok(()) } } +/// 临时文件路径(与目标同目录,`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}")))?; + 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 Some(root) = payload.root() else { + return Err(PackError::unsupported(format!( + "导出需要覆盖内容({name}),但没给 payload 根目录" + ))); + }; + let source = root.join(&dir).join(&path); + let bytes = std::fs::read(&source).map_err(|error| { + PackError::io(format!("读覆盖文件 {} 失败:{error}", source.display())) + })?; + 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 { @@ -245,25 +327,42 @@ impl PackRegistry { .find(|format| format.id() == id) } - /// 全部命中的格式 id(诊断"这包不标准"用)。 + /// 全部命中的格式 id(诊断"这包不标准"用;**只看命中**,单个格式的读错误被忽略—— + /// 要区分"包坏了"与"不认识"请用 [`Self::detect`])。 #[must_use] pub fn detect_all(&self, pack: &PackArchive) -> Vec<&'static str> { self.formats .iter() - .filter(|format| format.detect(pack).is_some()) + .filter(|format| matches!(format.detect(pack), Ok(Some(_)))) .map(|format| format.id()) .collect() } /// 按优先级选第一个命中的格式。 + /// + /// 错误语义(M111 的失败分级依赖这两条的区别): + /// + /// - **一个都不命中、但某个格式的清单读不出来** → 返回那个格式的错误 + /// (`pack_metadata` 一类:包坏了,换格式也没用); + /// - **一个都不命中、也没有任何格式的清单在场** → `pack_unsupported` + /// (真的不认识这份包); + /// - 命中优先于失败:包里躺着一份别人的坏 `manifest.json` 不影响真的 `.mrpack` 被认出来。 pub fn detect(&self, pack: &PackArchive) -> PackResult { - let mut matched: Vec<(&'static dyn PackFormat, &'static str)> = self - .formats - .iter() - .copied() - .filter_map(|format| format.detect(pack).map(|evidence| (format, evidence))) - .collect(); + let mut matched: Vec<(&'static dyn PackFormat, &'static str)> = Vec::new(); + // 命中优先于"某个格式的清单坏了":包里躺着一份别人的坏 manifest.json 不该毁掉它。 + let mut failures: Vec = Vec::new(); + for format in self.formats.iter().copied() { + match format.detect(pack) { + Ok(Some(evidence)) => matched.push((format, evidence)), + Ok(None) => {} + // 清单在、但读不出来 = 包坏了(`pack_metadata`),不是"不认识这个格式"。 + Err(error) => failures.push(error), + } + } if matched.is_empty() { + if let Some(error) = failures.into_iter().next() { + return Err(error); + } let tried = self.ids().join(" / "); return Err( PackError::unsupported(format!("没有格式认领这份整合包(试过:{tried})")) diff --git a/crates/core/src/addon/pack/manifest.rs b/crates/core/src/addon/pack/manifest.rs index 0c9bb26..475b9ae 100644 --- a/crates/core/src/addon/pack/manifest.rs +++ b/crates/core/src/addon/pack/manifest.rs @@ -333,18 +333,31 @@ impl PackManifest { Ok(()) } - /// 覆盖目录里重复的实例路径(两份 overrides 撞同一文件时的提示,导入器据此决策)。 + /// 跨覆盖组撞同一实例路径的清单:`(路径, 撞车的侧集合)`,按路径排序。 + /// + /// **按 `path` 跨侧计数**,不是按 `(side, path)`:mrpack 里 `overrides/`(Both 侧)与 + /// `client-overrides/`(Client 侧)写到同一个实例路径才是真实冲突,而两侧的键天然不同 + /// (r1 p2-2 之前这里恒返回空表,正好漏掉它要支撑的那个决策)。 + /// + /// 返回的多侧冲突由导入器决定优先级(本模块只报告,不选边)。 #[must_use] - pub fn duplicate_override_paths(&self) -> Vec { - let mut seen: HashMap<(PackSide, &str), usize> = HashMap::new(); - let mut duplicated = Vec::new(); + pub fn duplicate_override_paths(&self) -> Vec<(String, Vec)> { + let mut sides: HashMap<&str, Vec> = HashMap::new(); for (side, file) in self.override_files() { - let counter = seen.entry((side, file.path.as_str())).or_insert(0); - *counter += 1; - if *counter == 2 { - duplicated.push(file.path.clone()); + let entry = sides.entry(file.path.as_str()).or_default(); + if !entry.contains(&side) { + entry.push(side); } } + let mut duplicated: Vec<(String, Vec)> = sides + .into_iter() + .filter(|(_, sides)| sides.len() > 1) + .map(|(path, mut sides)| { + sides.sort_by_key(|side| side.as_str()); + (path.to_string(), sides) + }) + .collect(); + duplicated.sort_by(|left, right| left.0.cmp(&right.0)); duplicated } } diff --git a/crates/core/src/addon/pack/modrinth.rs b/crates/core/src/addon/pack/modrinth.rs index 0615554..bde0a6e 100644 --- a/crates/core/src/addon/pack/modrinth.rs +++ b/crates/core/src/addon/pack/modrinth.rs @@ -128,14 +128,44 @@ impl PackFormat for Modrinth { Some(METADATA_ENTRY) } - fn detect(&self, pack: &PackArchive) -> Option<&'static str> { - let value = pack.read_json(METADATA_ENTRY).ok().flatten()?; - let object = value.as_object()?; + fn detect(&self, pack: &PackArchive) -> PackResult> { + let Some(value) = pack.read_json(METADATA_ENTRY)? else { + return Ok(None); + }; + let object = value + .as_object() + .ok_or_else(|| PackError::metadata(format!("{METADATA_ENTRY} 存在但不是 JSON 对象")))?; // 有 formatVersion 数字即可认领;没有则要求 game + files 的形状对得上。 let matched = object.get("formatVersion").is_some_and(Value::is_number) || (object.get("game").and_then(Value::as_str) == Some(GAME) && object.get("files").is_some_and(Value::is_array)); - matched.then_some(METADATA_ENTRY) + Ok(matched.then_some(METADATA_ENTRY)) + } + + fn check_overrides(&self, manifest: &PackManifest) -> PackResult<()> { + // mrpack 的覆盖目录名由 side 决定,且每侧最多一个:不符合就别写(r1 p2-1)。 + let mut seen: Vec = Vec::new(); + for group in &manifest.overrides { + let expected = group.side.mrpack_dir(); + if group.dir != expected { + return Err(PackError::unsupported(format!( + "Modrinth 的覆盖目录名由侧决定:{} 侧必须是 {expected:?},模型里是 {:?}", + group.side.as_str(), + group.dir + )) + .with_hint( + "按 side 分派到 overrides / client-overrides / server-overrides 属 M112; 不同格式的实例目录(如 MultiMC 的 .minecraft)不能直接搬过来", + )); + } + if seen.contains(&group.side) { + return Err(PackError::unsupported(format!( + "Modrinth 每个侧只能有一个覆盖目录,{} 侧出现了多次", + group.side.as_str() + ))); + } + seen.push(group.side); + } + Ok(()) } fn read(&self, pack: &PackArchive) -> PackResult { diff --git a/crates/core/src/addon/pack/multimc.rs b/crates/core/src/addon/pack/multimc.rs index 40b491a..b4d958b 100644 --- a/crates/core/src/addon/pack/multimc.rs +++ b/crates/core/src/addon/pack/multimc.rs @@ -87,15 +87,19 @@ impl PackFormat for MultiMc { "MultiMC / Prism" } - fn detect(&self, pack: &PackArchive) -> Option<&'static str> { - let value = pack.read_json(METADATA_ENTRY).ok().flatten()?; - let object = value.as_object()?; + fn detect(&self, pack: &PackArchive) -> PackResult> { + let Some(value) = pack.read_json(METADATA_ENTRY)? else { + return Ok(None); + }; + let object = value + .as_object() + .ok_or_else(|| PackError::metadata(format!("{METADATA_ENTRY} 存在但不是 JSON 对象")))?; // 形状校验:必须有非空 components 数组(MultiMC 的组件图是它的身份证)。 let matched = object .get("components") .and_then(Value::as_array) .is_some_and(|components| !components.is_empty()); - matched.then_some(METADATA_ENTRY) + Ok(matched.then_some(METADATA_ENTRY)) } fn read(&self, pack: &PackArchive) -> PackResult { diff --git a/crates/core/src/addon/pack/tests/detect.rs b/crates/core/src/addon/pack/tests/detect.rs index 1cdab20..2692169 100644 --- a/crates/core/src/addon/pack/tests/detect.rs +++ b/crates/core/src/addon/pack/tests/detect.rs @@ -136,7 +136,7 @@ fn multimc_needs_a_non_empty_component_graph() { ); let pack = PackArchive::open_default(&target).expect("打开包"); assert_eq!( - MULTIMC.detect(&pack), + MULTIMC.detect(&pack).expect("mmc-pack.json 可读"), None, "空 components 是空壳,不能当 MultiMC 实例" ); @@ -159,3 +159,65 @@ fn multimc_instance_directory_is_detected() { assert_eq!(matched.format().id(), "multimc"); assert!(!matched.is_ambiguous()); } + +#[test] +fn a_corrupt_manifest_is_a_metadata_error_not_unsupported() { + // r1 p3-2:清单在、但读不出来 = 包坏了(`pack_metadata`)。之前三个 detect 都用 + // `.ok().flatten()` 吞掉解析错误,结果被报成「没有格式认领」——M111 的失败分级会据此 + // 把"可以重下文件"的包判成"不支持"。 + let target = temp_root("corrupt-manifest").join("bad.zip"); + write_zip(&target, &[("manifest.json", &br#"{"name": "#[..])]); + let pack = PackArchive::open_default(&target).expect("zip 本身可读"); + + let error = PackRegistry::builtin() + .detect(&pack) + .expect_err("坏清单必须报错"); + assert_eq!(error.kind(), PackErrorKind::Metadata, "{error}"); + assert!( + !error.message().contains("没有格式认领"), + "不许把「包坏了」说成「不认识」:{}", + error.message() + ); +} + +#[test] +fn a_corrupt_foreign_manifest_does_not_hide_a_valid_mrpack() { + // 命中优先于失败:包里躺着一份别人的坏 manifest.json,不该让真 mrpack 也变得不可用。 + let target = temp_root("corrupt-foreign").join("mixed.zip"); + write_zip( + &target, + &[ + ("manifest.json", &br#"{"name": "#[..]), + ("modrinth.index.json", MR_INDEX), + ], + ); + let pack = PackArchive::open_default(&target).expect("zip 本身可读"); + let matched = PackRegistry::builtin() + .detect(&pack) + .expect("mrpack 命中应当优先于其它格式的坏文件"); + assert_eq!(matched.format().id(), "modrinth"); + assert!(matched.also_matched().is_empty(), "CF 没命中(它坏了)"); +} + +#[test] +fn a_missing_manifest_is_not_an_error() { + // 反向对照:**没有** `manifest.json` 是"不是 CF",不是"包坏了"。 + let target = temp_root("no-manifest").join("mmc.zip"); + write_zip( + &target, + &[( + "mmc-pack.json", + &br#"{"formatVersion":1,"components":[{"uid":"net.minecraft","version":"1.20.1"}]}"#[..], + )], + ); + let pack = PackArchive::open_default(&target).expect("zip 本身可读"); + assert_eq!(CURSEFORGE.detect(&pack).expect("缺清单不是错误"), None); + assert_eq!( + PackRegistry::builtin() + .detect(&pack) + .expect("MultiMC 应当命中") + .format() + .id(), + "multimc" + ); +} diff --git a/crates/core/src/addon/pack/tests/export.rs b/crates/core/src/addon/pack/tests/export.rs new file mode 100644 index 0000000..a31d6af --- /dev/null +++ b/crates/core/src/addon/pack/tests/export.rs @@ -0,0 +1,129 @@ +//! 导出面的安全闸(r1 p2-1 / p3-1):**跨格式导出必须被拒,失败不留半个包**。 +//! +//! 这一组不测"映射是否做对"(那是 M112),只测"表达不了的时候会不会静默产出空包"。 + +use super::{fixtures, temp_root}; +use crate::addon::pack::archive::PackArchive; +use crate::addon::pack::curseforge::CURSEFORGE; +use crate::addon::pack::error::PackErrorKind; +use crate::addon::pack::format::{PackFormat, PackPayload}; +use crate::addon::pack::manifest::{OverrideFile, PackManifest, PackOverrides, PackSide}; +use crate::addon::pack::modrinth::MODRINTH; +use crate::addon::pack::multimc::MULTIMC; + +#[test] +fn a_multimc_instance_cannot_be_written_as_mrpack() { + // r1 p2-1 实测过的那条:MultiMC 模型写 mrpack 会返回 Ok,但覆盖内容落在 `.minecraft/...`, + // mrpack 导入器只解压 overrides / client-overrides / server-overrides ⇒ mods 数量为 0。 + // 现在必须在写第一个字节之前就拒。 + let root = fixtures::multimc("export-mmc-mr"); + let pack = PackArchive::open_default(&root).expect("打开实例目录"); + let mut manifest = MULTIMC.read(&pack).expect("读 MultiMC 清单"); + manifest.version = Some("1.0.0".to_string()); // mrpack 需要 versionId,别让别的错误顶包 + + let target = root.with_file_name("cross.mrpack"); + let error = MODRINTH + .write(&manifest, PackPayload::from_root(&root), &target) + .expect_err("跨格式导出必须被拒,不能产出 mods 为 0 的包"); + assert_eq!(error.kind(), PackErrorKind::Unsupported, "{error}"); + assert!( + error.message().contains(".minecraft"), + "错误要说清是哪个目录表达不了:{}", + error.message() + ); + assert!(error.hint().is_some(), "要给下一步提示(目录映射属 M112)"); + assert!(!target.exists(), "被拒时不许留下目标文件"); +} + +#[test] +fn an_mrpack_with_split_sides_cannot_be_written_as_curseforge() { + // CF 只有一个 `overrides` 目录字段,表达不了 client/server 分侧;现在是拒,不是猜。 + let fixture = fixtures::modrinth("export-mr-cf"); + let pack = PackArchive::open_default(&fixture.zip).expect("打开 mrpack 夹具"); + let manifest = MODRINTH.read(&pack).expect("读 mrpack 清单"); + + let target = fixture.zip.with_file_name("cross.zip"); + let error = CURSEFORGE + .write(&manifest, PackPayload::from_root(&fixture.dir), &target) + .expect_err("CF 表达不了分侧覆盖目录"); + assert_eq!(error.kind(), PackErrorKind::Unsupported, "{error}"); + assert!( + error.message().contains("client"), + "错误要指出被拒的是哪一侧:{}", + error.message() + ); + assert!(!target.exists(), "被拒时不许留下目标文件"); +} + +#[test] +fn curseforge_refuses_more_than_one_both_side_group() { + // CF 的 `overrides` 只有一个字段:两个 Both 组必然有一个不被清单声明(静默丢内容)。 + let mut manifest = PackManifest::new("两个 Both 组"); + manifest.minecraft = Some("1.20.1".to_string()); + manifest + .overrides + .push(PackOverrides::new("overrides", PackSide::Both)); + manifest + .overrides + .push(PackOverrides::new("extra-overrides", PackSide::Both)); + + let target = temp_root("export-cf-two").join("out.zip"); + let error = CURSEFORGE + .write(&manifest, PackPayload::metadata_only(), &target) + .expect_err("多出来的 Both 组会被清单漏掉,必须拒"); + assert_eq!(error.kind(), PackErrorKind::Unsupported, "{error}"); + assert!(!target.exists(), "被拒时不许留下目标文件"); +} + +#[test] +fn a_failed_export_leaves_neither_a_half_pack_nor_a_temp_file() { + // r1 p3-1:清单写完了、覆盖文件读不出来 → 目标路径与临时文件都不许留。 + let mut manifest = PackManifest::new("缺覆盖文件的模型"); + manifest.minecraft = Some("1.20.1".to_string()); + manifest.overrides.push(PackOverrides { + dir: "overrides".to_string(), + side: PackSide::Both, + files: vec![OverrideFile { + path: "mods/missing.jar".to_string(), + sha1: "0".repeat(40), + size: 1, + }], + }); + + let root = temp_root("export-half"); + std::fs::create_dir_all(&root).expect("建目录"); + let target = root.join("out.zip"); + let error = CURSEFORGE + .write(&manifest, PackPayload::from_root(&root), &target) + .expect_err("覆盖文件不存在时必须失败"); + assert_eq!(error.kind(), PackErrorKind::Io, "{error}"); + assert!(!target.exists(), "失败后目标路径不许存在"); + + let leftovers: Vec = std::fs::read_dir(&root) + .expect("列目录") + .filter_map(Result::ok) + .map(|entry| entry.file_name().to_string_lossy().to_string()) + .collect(); + assert!( + leftovers.is_empty(), + "临时文件没清干净(p3-1 回退):{leftovers:?}" + ); +} + +#[test] +fn same_format_export_is_still_allowed() { + // 反向对照:这两条闸不能把同格式往返也挡掉(那是 r1 明确要保住的)。 + let fixture = fixtures::curseforge("export-same-cf"); + let pack = PackArchive::open_default(&fixture.zip).expect("打开 CF 夹具"); + let manifest = CURSEFORGE.read(&pack).expect("读 CF 清单"); + CURSEFORGE + .check_overrides(&manifest) + .expect("CF 自己读出来的模型必须能被 CF 表达"); + + let fixture = fixtures::modrinth("export-same-mr"); + let pack = PackArchive::open_default(&fixture.zip).expect("打开 mrpack 夹具"); + let manifest = MODRINTH.read(&pack).expect("读 mrpack 清单"); + MODRINTH + .check_overrides(&manifest) + .expect("mrpack 自己读出来的模型必须能被 mrpack 表达"); +} diff --git a/crates/core/src/addon/pack/tests/extend.rs b/crates/core/src/addon/pack/tests/extend.rs index cf16344..f7554ab 100644 --- a/crates/core/src/addon/pack/tests/extend.rs +++ b/crates/core/src/addon/pack/tests/extend.rs @@ -35,9 +35,11 @@ impl PackFormat for ToyFormat { Some("toy.json") } - fn detect(&self, pack: &PackArchive) -> Option<&'static str> { - let value = pack.read_json("toy.json").ok().flatten()?; - value.is_object().then_some("toy.json") + fn detect(&self, pack: &PackArchive) -> PackResult> { + let Some(value) = pack.read_json("toy.json")? else { + return Ok(None); + }; + Ok(value.is_object().then_some("toy.json")) } fn read(&self, pack: &PackArchive) -> PackResult { diff --git a/crates/core/src/addon/pack/tests/mod.rs b/crates/core/src/addon/pack/tests/mod.rs index bf3d5f0..e0fa1ac 100644 --- a/crates/core/src/addon/pack/tests/mod.rs +++ b/crates/core/src/addon/pack/tests/mod.rs @@ -3,10 +3,13 @@ //! **全部离线**:没有网络调用,也没有真实整合包样本(见 `fixtures` 的来源声明)。 mod detect; +mod export; mod extend; mod fixtures; +mod model; mod roundtrip; mod safety; +mod unknown_fields; use std::fs::File; use std::io::Write; diff --git a/crates/core/src/addon/pack/tests/model.rs b/crates/core/src/addon/pack/tests/model.rs new file mode 100644 index 0000000..3184b25 --- /dev/null +++ b/crates/core/src/addon/pack/tests/model.rs @@ -0,0 +1,75 @@ +//! 模型级辅助与读取预算:r1 p2-2(跨侧重名)与 p3-4(批量读取的累计限额)。 + +use super::fixtures; +use crate::addon::pack::archive::{PackArchive, ReadBudget}; +use crate::addon::pack::error::PackErrorKind; +use crate::addon::pack::manifest::{OverrideFile, PackManifest, PackOverrides, PackSide}; + +fn file(path: &str, sha1: &str, size: u64) -> OverrideFile { + OverrideFile { + path: path.to_string(), + sha1: sha1.to_string(), + size, + } +} + +#[test] +fn duplicate_override_paths_counts_across_sides_not_per_side() { + // r1 p2-2:mrpack 的真实冲突是 `overrides`(Both) 与 `client-overrides`(Client) 撞同一路径, + // 按 `(side, path)` 计数时两侧键天然不同 ⇒ 永远返回空表。 + let mut both = PackOverrides::new("overrides", PackSide::Both); + both.files.push(file("config/x.toml", "a", 1)); + both.files.push(file("config/only-both.toml", "b", 1)); + let mut client = PackOverrides::new("client-overrides", PackSide::Client); + client.files.push(file("config/x.toml", "c", 2)); + let mut server = PackOverrides::new("server-overrides", PackSide::Server); + server.files.push(file("config/only-server.toml", "d", 3)); + let manifest = PackManifest { + overrides: vec![both, client, server], + ..PackManifest::default() + }; + + assert_eq!( + manifest.duplicate_override_paths(), + vec![( + "config/x.toml".to_string(), + vec![PackSide::Both, PackSide::Client] + )], + "只报真正撞车的路径,并给出涉及的侧" + ); +} + +#[test] +fn duplicate_override_paths_is_empty_when_nothing_collides() { + let mut both = PackOverrides::new("overrides", PackSide::Both); + both.files.push(file("config/a.toml", "a", 1)); + let mut client = PackOverrides::new("client-overrides", PackSide::Client); + client.files.push(file("config/b.toml", "b", 1)); + let manifest = PackManifest { + overrides: vec![both, client], + ..PackManifest::default() + }; + assert!(manifest.duplicate_override_paths().is_empty()); +} + +#[test] +fn a_batch_scan_spends_one_cumulative_budget() { + // r1 p3-4:打开阶段只能看声明大小;批量读取(override_files)必须按**实际字节**累计扣减, + // 否则"很多条各自合规"累起来就能绕过 `max_uncompressed_bytes`(CPU/时间 DoS)。 + let fixture = fixtures::modrinth("budget"); + let pack = PackArchive::open_default(&fixture.zip).expect("打开 mrpack 夹具"); + + let generous = ReadBudget::new(1024 * 1024); + let files = pack + .override_files_with("overrides", &generous) + .expect("预算够时必须成功"); + assert_eq!(files.len(), 2); + + // 预算只有 4 字节:第一个文件(config/known.toml,15 字节)就该把预算吃穿。 + let tight = ReadBudget::new(4); + let error = pack + .override_files_with("overrides", &tight) + .expect_err("累计预算必须生效"); + assert_eq!(error.kind(), PackErrorKind::Limits, "{error}"); + assert_eq!(tight.remaining(), 0, "失败后预算不该被退回"); +} diff --git a/crates/core/src/addon/pack/tests/roundtrip.rs b/crates/core/src/addon/pack/tests/roundtrip.rs index a57bafa..5943c45 100644 --- a/crates/core/src/addon/pack/tests/roundtrip.rs +++ b/crates/core/src/addon/pack/tests/roundtrip.rs @@ -247,7 +247,7 @@ fn curseforge_without_name_is_a_metadata_error() { ); let pack = PackArchive::open_default(&target).expect("zip 本身可读"); assert_eq!( - CURSEFORGE.detect(&pack), + CURSEFORGE.detect(&pack).expect("清单可读"), Some("manifest.json"), "形状对得上就该被探测到" ); diff --git a/crates/core/src/addon/pack/tests/safety.rs b/crates/core/src/addon/pack/tests/safety.rs index 252d0c3..dad4f55 100644 --- a/crates/core/src/addon/pack/tests/safety.rs +++ b/crates/core/src/addon/pack/tests/safety.rs @@ -272,3 +272,41 @@ fn directory_source_rejects_symlinks_when_the_platform_can_make_them() { ); } } + +#[test] +#[cfg(windows)] +fn the_second_check_catches_windows_device_names_and_dot_suffixes() { + // r1 p2-3:第一检(平台无关的七条)对这些名字是**放行**的,拦下它们的是第二检 + // `enclosed_name()` 的 Win32 命名空间规则(DOS 设备名、结尾点/空格、`< > " | ? *`)。 + // 删掉第二检,这条用例必须红——这是「双检各有一条恶意样本」的第二条。 + for (tag, name) in [ + ("dev-con", "CON"), + ("dev-nul-nested", "mods/NUL"), + ("dev-com1", "COM1.txt"), + ("dev-trailing-dot", "a."), + ("dev-trailing-space", "mods/b "), + ("dev-wildcard", "mods/*.jar"), + ] { + let target = single_entry(tag, name); + let error = open_error(&target); + assert_eq!( + error.kind(), + PackErrorKind::Traversal, + "{name}: {}", + error.message() + ); + assert!( + error.message().contains("enclosed_name"), + "{name} 必须是被第二检拦下的(不然这条用例钉错了地方):{}", + error.message() + ); + } + + // 对照:第一检对这些名字没有任何意见(所以它们只能靠第二检)。 + for name in ["CON", "mods/NUL", "a.", "mods/b ", "mods/*.jar"] { + assert!( + crate::addon::pack::path::normalize_file(name).is_ok(), + "第一检不该拦 {name}(拦了这条用例就失去意义)" + ); + } +} diff --git a/crates/core/src/addon/pack/tests/unknown_fields.rs b/crates/core/src/addon/pack/tests/unknown_fields.rs new file mode 100644 index 0000000..bc7fa79 --- /dev/null +++ b/crates/core/src/addon/pack/tests/unknown_fields.rs @@ -0,0 +1,113 @@ +//! 「未知字段不丢」的三处条目级 flatten(r1 p3-3):`env_extra`、CF `files[].extra`、 +//! `PackLoader::extra`。三处实现本来是对的,但此前一条断言都没有——而它们恰好是 M115 点名的 +//! HMCL 回写 `fileName`/`url` 的位置。每条都走「读 → render → 写整包 → 再读逐字段相等」。 + +use serde_json::json; + +use crate::addon::pack::archive::PackArchive; +use crate::addon::pack::curseforge::CURSEFORGE; +use crate::addon::pack::format::{PackFormat, PackPayload}; +use crate::addon::pack::modrinth::MODRINTH; + +#[test] +fn mrpack_env_unknown_keys_survive_read_write_reread() { + // r1 p3-3 的一处:`files[].env` 里的未知键(`env_extra`)此前没有任何断言。 + let root = super::temp_root("env-extra"); + let dir = root.join("src"); + super::write_file( + &dir.join("modrinth.index.json"), + br#"{"game":"minecraft","formatVersion":1,"versionId":"1.0","name":"env extra keys", + "files":[{"path":"mods/a.jar", + "hashes":{"sha512":"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"}, + "env":{"client":"required","server":"unsupported","customEnvKey":{"k":1}}, + "downloads":["https://example.invalid/a.jar"]}], + "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].env_extra["customEnvKey"], json!({"k": 1})); + + 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, "env 里的未知键必须原样回写"); + assert_eq!(reread.files[0].env_extra["customEnvKey"], json!({"k": 1})); +} + +#[test] +fn curseforge_file_unknown_keys_survive_read_write_reread() { + // r1 p3-3 的另一处:CF `files[].fileName` / `url` 是 HMCL 补全后回写的扩展点。 + let root = super::temp_root("cf-file-extra"); + let dir = root.join("src"); + super::write_file( + &dir.join("manifest.json"), + br#"{"manifestType":"minecraftModpack","manifestVersion":1,"name":"files extra keys", + "version":"1.0","minecraft":{"version":"1.20.1","modLoaders":[{"id":"forge-47.2.0","primary":true}]}, + "files":[{"projectID":238222,"fileID":5102025,"required":true, + "fileName":"jei-1.20.1-15.2.0.27.jar", + "url":"https://edge.forgecdn.net/files/5102/25/jei.jar"}]}"#, + ); + let zip = root.join("pack.zip"); + super::zip_dir(&dir, &zip); + + let pack = PackArchive::open_default(&zip).expect("打开包"); + let manifest = CURSEFORGE.read(&pack).expect("读 CF 清单"); + assert_eq!( + manifest.files[0].extra["fileName"], + json!("jei-1.20.1-15.2.0.27.jar") + ); + assert_eq!( + manifest.files[0].extra["url"], + json!("https://edge.forgecdn.net/files/5102/25/jei.jar") + ); + + let target = root.join("out.zip"); + CURSEFORGE + .write(&manifest, PackPayload::metadata_only(), &target) + .expect("导出 CF"); + let reread = CURSEFORGE + .read(&PackArchive::open_default(&target).expect("打开导出包")) + .expect("再读"); + assert_eq!(reread, manifest, "files[] 里的未知键必须原样回写"); +} + +#[test] +fn curseforge_loader_unknown_keys_survive_read_write_reread() { + // r1 p3-3 的第三处:`modLoaders[]` 里的未知键(`PackLoader::extra`)。 + let root = super::temp_root("cf-loader-extra"); + let dir = root.join("src"); + super::write_file( + &dir.join("manifest.json"), + br#"{"manifestType":"minecraftModpack","manifestVersion":1,"name":"loader extra keys", + "version":"1.0", + "minecraft":{"version":"1.20.1", + "modLoaders":[{"id":"forge-47.2.0","primary":true,"cachedName":"Forge 47.2.0"}]}, + "files":[]}"#, + ); + let zip = root.join("pack.zip"); + super::zip_dir(&dir, &zip); + + let pack = PackArchive::open_default(&zip).expect("打开包"); + let manifest = CURSEFORGE.read(&pack).expect("读 CF 清单"); + assert_eq!( + manifest.loaders[0].extra["cachedName"], + json!("Forge 47.2.0") + ); + + let target = root.join("out.zip"); + CURSEFORGE + .write(&manifest, PackPayload::metadata_only(), &target) + .expect("导出 CF"); + let reread = CURSEFORGE + .read(&PackArchive::open_default(&target).expect("打开导出包")) + .expect("再读"); + assert_eq!(reread, manifest, "modLoaders[] 里的未知键必须原样回写"); + assert_eq!(reread.loaders[0].extra["cachedName"], json!("Forge 47.2.0")); +}