From b34e86bba58c311a18cb093f8700b079f9f5ae57 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 3 Nov 2026 21:32:09 +0800 Subject: [PATCH] =?UTF-8?q?feat(core):=20=E6=95=B4=E5=90=88=E5=8C=85?= =?UTF-8?q?=E6=A0=BC=E5=BC=8F=E6=97=A0=E5=85=B3=E6=A8=A1=E5=9E=8B=E4=B8=8E?= =?UTF-8?q?=E6=A0=BC=E5=BC=8F=E6=89=A9=E5=B1=95=E7=82=B9=EF=BC=88M110?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 从零建 `core::addon::pack`:格式无关中间模型 + 格式扩展点 + 安全读取层, 不碰网络、不碰 UI(core-no-ui 依赖表未变)。 - `PackManifest` 无损表达 CF / mrpack / MultiMC 三方字段:包名/版本/作者/简介/ 格式版本/游戏版本/加载器依赖/文件条目/覆盖目录;`files[].hashes` 保留整张摘要表 (mrpack 允许 sha1+sha256+sha512+md5 共存),`disabled` 记语义(导出按 D10 映射成 env.client=optional,CF 落 required=false)。 - 未知字段按条目用 serde flatten 接住(清单顶层 / CF `minecraft` 对象 / 文件条目 / env / 加载器条目),导出时并回且已知字段优先;两格式各有一条「读→写→再读相等」测试。 - `trait PackFormat`(id / label / metadata_entry / detect -> Option<证据> / read / render / 默认 write)+ `PackRegistry`:注册表顺序即优先级,写死 CurseForge → Modrinth → MultiMC;同一份包命中多格式时 `also_matched` 暴露歧义。 测试里用一个 toy 格式(单文件、一行注册)证明扩展点真的可扩展。 - `PackArchive` 把目录与 zip 统一成规整条目表:路径穿越双检(自研七条规则 + zip crate 的 enclosed_name)、符号链接条目拒绝、条目数/解压体积/单条目上限、 规整后重名拒绝、篡改载荷不当场吞掉;每条都有恶意样本测试。 - 格式实现:CF 可读写(缺路径如实记 None)、mrpack 可读写(强制 sha512 形状、 env 三态、三个覆盖目录成三侧)、MultiMC 只读(.minecraft 当覆盖目录, 组件顺序与非加载器组件全保留)。 - 三种格式各一个手造夹具(含截断 TOML / 半截 jar / 零字节 jar),来源在 `tests/fixtures.rs` 里声明为手造,不冒充真实导出。 --- crates/core/src/addon/mod.rs | 10 +- crates/core/src/addon/pack.rs | 64 ++++ crates/core/src/addon/pack/archive.rs | 259 +++++++++++++ crates/core/src/addon/pack/archive/entry.rs | 30 ++ crates/core/src/addon/pack/archive/limits.rs | 100 +++++ crates/core/src/addon/pack/archive/open.rs | 158 ++++++++ crates/core/src/addon/pack/curseforge.rs | 298 +++++++++++++++ crates/core/src/addon/pack/error.rs | 161 ++++++++ crates/core/src/addon/pack/format.rs | 290 +++++++++++++++ crates/core/src/addon/pack/manifest.rs | 350 ++++++++++++++++++ .../core/src/addon/pack/manifest/entries.rs | 154 ++++++++ crates/core/src/addon/pack/modrinth.rs | 347 +++++++++++++++++ crates/core/src/addon/pack/multimc.rs | 179 +++++++++ crates/core/src/addon/pack/path.rs | 140 +++++++ crates/core/src/addon/pack/tests/detect.rs | 161 ++++++++ crates/core/src/addon/pack/tests/extend.rs | 170 +++++++++ crates/core/src/addon/pack/tests/fixtures.rs | 185 +++++++++ crates/core/src/addon/pack/tests/mod.rs | 101 +++++ crates/core/src/addon/pack/tests/roundtrip.rs | 299 +++++++++++++++ crates/core/src/addon/pack/tests/safety.rs | 274 ++++++++++++++ 20 files changed, 3729 insertions(+), 1 deletion(-) create mode 100644 crates/core/src/addon/pack.rs create mode 100644 crates/core/src/addon/pack/archive.rs create mode 100644 crates/core/src/addon/pack/archive/entry.rs create mode 100644 crates/core/src/addon/pack/archive/limits.rs create mode 100644 crates/core/src/addon/pack/archive/open.rs create mode 100644 crates/core/src/addon/pack/curseforge.rs create mode 100644 crates/core/src/addon/pack/error.rs create mode 100644 crates/core/src/addon/pack/format.rs create mode 100644 crates/core/src/addon/pack/manifest.rs create mode 100644 crates/core/src/addon/pack/manifest/entries.rs create mode 100644 crates/core/src/addon/pack/modrinth.rs create mode 100644 crates/core/src/addon/pack/multimc.rs create mode 100644 crates/core/src/addon/pack/path.rs create mode 100644 crates/core/src/addon/pack/tests/detect.rs create mode 100644 crates/core/src/addon/pack/tests/extend.rs create mode 100644 crates/core/src/addon/pack/tests/fixtures.rs create mode 100644 crates/core/src/addon/pack/tests/mod.rs create mode 100644 crates/core/src/addon/pack/tests/roundtrip.rs create mode 100644 crates/core/src/addon/pack/tests/safety.rs diff --git a/crates/core/src/addon/mod.rs b/crates/core/src/addon/mod.rs index 8cb22d4..fb8e361 100644 --- a/crates/core/src/addon/mod.rs +++ b/crates/core/src/addon/mod.rs @@ -8,12 +8,20 @@ //! //! - [`loader`](P2 · M32):加载器安装——官方元数据 → 带 `inheritsFrom` 的 loader 版本 JSON //! → 补库清单,落盘与实例登记复用 `core::game::install`(M27)。 +//! - [`pack`](P2 · M110):整合包的**格式无关模型**与**格式扩展点**——CF / Modrinth / MultiMC +//! 三种格式的探测与读取、CF / mrpack 的渲染与导出、zip 安全读取(穿越双检 / 符号链接 / +//! 条数体积上限)。导入导出**管线**(补全、解压、合并)是 M111/M112,不在这里。 //! -//! 规划中:整合包导入导出(D10 §2)、本地 mod 管理(§3)、CF/Modrinth 客户端(§5)。 +//! 规划中:本地 mod 管理(D10 §3)、CF/Modrinth 客户端(§5)。 pub mod loader; +pub mod pack; pub use loader::{ LoaderError, LoaderErrorKind, LoaderInstallPlan, LoaderKind, LoaderPlanInput, LoaderRelease, LoaderSource, LoaderSupport, }; +pub use pack::{ + EnvSupport, PackArchive, PackError, PackErrorKind, PackFile, PackFormat, PackLimits, + PackLoader, PackManifest, PackOverrides, PackRegistry, PackResult, PackSide, +}; diff --git a/crates/core/src/addon/pack.rs b/crates/core/src/addon/pack.rs new file mode 100644 index 0000000..f4a8bcc --- /dev/null +++ b/crates/core/src/addon/pack.rs @@ -0,0 +1,64 @@ +//! 整合包的**格式无关模型**与**格式扩展点**(M110)。导入/导出管线(M111/M112)建在这上面。 +//! +//! 设计定稿:`DevDocs/PlanDoc/08-mod-and-recommend.md` §2(D10),格式细节来自 +//! `DevDocs/analyse/05-modloader-modpack-p2.md` §2.1–§2.3(HMCL 源码级拆解,A 级来源)。 +//! +//! # 本模块只做三件事 +//! +//! | 层 | 模块 | 职责 | +//! |----|------|------| +//! | 安全读取 | [`archive`] | 目录与 zip 统一成"规整过的条目表":路径穿越双检、符号链接拒绝、条数/体积上限 | +//! | 格式 | [`format`] | [`format::PackFormat`] trait + [`format::PackRegistry`]:探测、读、渲染、写,**优先级写死** | +//! | 模型 | [`manifest`] | [`manifest::PackManifest`]:包名/版本/作者/游戏版本/加载器/文件条目/覆盖目录,未知字段原样保留 | +//! +//! 三个内置格式:[`curseforge`](`manifest.json`,可读可写)、[`modrinth`](`.mrpack`,可读可写)、 +//! [`multimc`](`mmc-pack.json`,**只读**)。 +//! +//! # 刻意不做的事(分工边界) +//! +//! - **不发网络请求**:CF 的 `projectID/fileID` → 文件名/URL 补全属于 M111(要用 CF API 与 +//! MurmurHash2 指纹),本模块只把"缺什么"如实记成 `None`; +//! - **不落盘解压**:`archive` 只读,不写实例目录。overrides 的三方合并(base/theirs/ours) +//! 是 M111 的解压器职责,本模块提供它要的输入:每条覆盖文件的实例内相对路径 + SHA-1; +//! - **不碰 UI 类型**:core 零 UI 依赖(`core-no-ui` 门禁),这里连 `String` 之外的表现层 +//! 类型都不出现; +//! - **不猜格式**:探测顺序写死在 [`format::PackRegistry::builtin`],同一份包命中多格式时 +//! 用固定的注册顺序选,并把其余命中记进 [`format::PackMatch::also_matched`]。 +//! +//! # 安全(每条都有恶意样本测试,见 `tests/safety.rs`) +//! +//! 路径穿越判据是**两条独立检查**:①本模块的 [`path::normalize_file`](七条写死的规则: +//! 空名/`\0`/`\`/`:`/前导 `/`/任何 `..` 段/规整后为空);②`zip` crate 的 +//! `enclosed_name()`(按 Windows 规则解析盘符、NTFS 数据流、保留设备名)。 +//! 为什么不能只留一条:`enclosed_name()` 允许"深度 0 的根目录",也就是 `"/etc/passwd"` +//! 它会放行;反过来,本模块的规则不认 Windows 保留设备名。**两条都过才算安全**。 +//! +//! # 测试夹具的来源(不许把"看起来像"当真实) +//! +//! `tests/fixtures.rs` 里的 CF / mrpack / MultiMC 三个包**全部是手造的**:字段取自 +//! analyse/05 记录的 HMCL 解析器形状(A 级来源),内容是为了覆盖边界而编的(含一个刻意的 +//! 损坏文件:截断的 `config/broken.toml`、半截 `mods/half.jar`、零字节 `mods/broken.jar`)。 +//! **没有一个来自真实导出**——本任务环境离线,没有真实整合包样本可对照。真实样本的字段 +//! 偏移风险因此只能由 M111/M115(格式字段盘点)在拿到真包后回收,报告里已登记这条欠账。 + +pub mod archive; +pub mod curseforge; +pub mod error; +pub mod format; +pub mod manifest; +pub mod modrinth; +pub mod multimc; +pub mod path; + +#[cfg(test)] +mod tests; + +pub use archive::{PackArchive, PackEntryInfo, PackLimits}; +pub use curseforge::CurseForge; +pub use error::{PackError, PackErrorKind, PackResult}; +pub use format::{PackFormat, PackMatch, PackPayload, PackRegistry}; +pub use manifest::{ + EnvSupport, OverrideFile, PackFile, PackLoader, PackManifest, PackOverrides, PackSide, +}; +pub use modrinth::Modrinth; +pub use multimc::MultiMc; diff --git a/crates/core/src/addon/pack/archive.rs b/crates/core/src/addon/pack/archive.rs new file mode 100644 index 0000000..a34740e --- /dev/null +++ b/crates/core/src/addon/pack/archive.rs @@ -0,0 +1,259 @@ +//! 打包输入的安全读取层:[`PackArchive`] 把"目录"与"zip"统一成"一组规整过的条目"。 +//! +//! # 为什么要有这一层 +//! +//! 格式探测器要能对**目录**(MultiMC 实例、已解开的整合包)与**zip**(`.zip` / `.mrpack`) +//! 一视同仁地问"有没有 `modrinth.index.json`",而安全判据只有一份实现才不会漂。 +//! +//! # 安全判据(每条都有恶意样本测试,见 `tests/safety.rs`) +//! +//! | 威胁 | 做法 | +//! |------|------| +//! | 路径穿越(`../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` 的同名规则。 + +use std::cell::RefCell; +use std::collections::HashMap; +use std::fs::File; +use std::io::Read; +use std::path::{Path, PathBuf}; + +use serde_json::Value; +use zip::ZipArchive; + +use super::error::{PackError, PackResult}; +use super::manifest::OverrideFile; +use super::path::normalize_dir; +use crate::download::types::HashAlgo; +use crate::download::verify::{self, Hasher}; + +pub mod entry; +pub mod limits; +mod open; + +pub use entry::PackEntryInfo; +pub use limits::{ + PackLimits, DEFAULT_MAX_ENTRIES, DEFAULT_MAX_ENTRY_BYTES, DEFAULT_MAX_UNCOMPRESSED_BYTES, +}; + +use limits::read_capped; + +/// 输入来源。 +pub(super) enum Source { + /// 目录(MultiMC 实例、已解开的包)。 + Dir, + /// zip(`.zip` / `.mrpack`)。`RefCell` 让只读方法能取 `&mut ZipArchive`(解压需要)。 + Zip(RefCell>), +} + +/// 一个已校验的整合包输入(目录或 zip)。 +pub struct PackArchive { + root: PathBuf, + source: Source, + entries: Vec, + index: HashMap, + limits: PackLimits, +} + +impl std::fmt::Debug for PackArchive { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("PackArchive") + .field("root", &self.root) + .field( + "source", + &match self.source { + Source::Dir => "dir", + Source::Zip(_) => "zip", + }, + ) + .field("entries", &self.entries.len()) + .finish() + } +} + +impl PackArchive { + /// 打开一个目录或 zip,按 [`PackLimits::default`] 校验。 + pub fn open_default(path: impl AsRef) -> PackResult { + Self::open(path, PackLimits::default()) + } + + /// 打开一个目录或 zip,按给定限额校验。 + /// + /// 校验是**全量**的:打开成功就说明整包的条目名都过了两道穿越检查、没有符号链接、 + /// 条数与体积都在限额内。后续读取不再需要重复判断来源是否恶意。 + pub fn open(path: impl AsRef, limits: PackLimits) -> PackResult { + let root = path.as_ref().to_path_buf(); + let metadata = std::fs::symlink_metadata(&root) + .map_err(|error| PackError::io(format!("打不开整合包 {}:{error}", root.display())))?; + if metadata.file_type().is_symlink() { + return Err(PackError::symlink(format!( + "整合包本身是符号链接:{}", + root.display() + ))); + } + if metadata.is_dir() { + Self::open_dir(root, limits) + } else { + Self::open_zip(root, limits) + } + } + + /// 输入路径。 + #[must_use] + pub fn root(&self) -> &Path { + &self.root + } + + /// 是否目录输入。 + #[must_use] + pub fn is_dir_source(&self) -> bool { + matches!(self.source, Source::Dir) + } + + /// 全部条目(目录输入按名字排序,zip 输入保持中央目录顺序)。 + #[must_use] + pub fn entries(&self) -> &[PackEntryInfo] { + &self.entries + } + + /// 按规整名查一条。 + #[must_use] + pub fn entry(&self, name: &str) -> Option<&PackEntryInfo> { + self.index.get(name).map(|slot| &self.entries[*slot]) + } + + /// 有没有这个条目(探测器用:只问存不存在,不读内容)。 + #[must_use] + pub fn has_entry(&self, name: &str) -> bool { + self.index.contains_key(name) + } + + /// 读一个条目;不存在返回 `Ok(None)`。 + pub fn read(&self, name: &str) -> PackResult>> { + let Some(entry) = self.entry(name) else { + return Ok(None); + }; + if entry.is_dir { + return Ok(None); + } + match &self.source { + Source::Dir => { + let path = self.root.join(&entry.raw); + let bytes = std::fs::read(&path).map_err(|error| { + PackError::io(format!("读 {} 失败:{error}", path.display())) + })?; + Ok(Some(bytes)) + } + Source::Zip(archive) => { + let mut archive = archive.borrow_mut(); + 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)?; + Ok(Some(bytes)) + } + } + } + + /// 按 UTF-8 读文本条目。 + pub fn read_text(&self, name: &str) -> PackResult> { + let Some(bytes) = self.read(name)? else { + return Ok(None); + }; + String::from_utf8(bytes) + .map(Some) + .map_err(|error| PackError::metadata(format!("{name} 不是 UTF-8 文本:{error}"))) + } + + /// 读 JSON 条目;不存在返回 `Ok(None)`。 + pub fn read_json(&self, name: &str) -> PackResult> { + let Some(text) = self.read_text(name)? else { + return Ok(None); + }; + let value = serde_json::from_str(&text) + .map_err(|error| PackError::metadata(format!("{name} 不是合法 JSON:{error}")))?; + Ok(Some(value)) + } + + /// 算一个条目的 SHA-1;不存在返回 `Ok(None)`。 + pub fn sha1(&self, name: &str) -> PackResult> { + let Some(entry) = self.entry(name) else { + return Ok(None); + }; + if entry.is_dir { + return Ok(None); + } + match &self.source { + Source::Dir => { + let path = self.root.join(&entry.raw); + let digest = verify::digest_file(&path, HashAlgo::Sha1).map_err(|error| { + PackError::io(format!("算 {} 的 SHA-1 失败:{error}", path.display())) + })?; + Ok(Some(digest)) + } + Source::Zip(archive) => { + let mut archive = archive.borrow_mut(); + let mut reader = archive + .by_name(&entry.raw) + .map_err(|error| PackError::metadata(format!("解压 {name} 失败:{error}")))?; + let mut hasher = Hasher::new(HashAlgo::Sha1); + let mut total = 0_u64; + let mut buffer = [0_u8; 64 * 1024]; + loop { + let read = reader + .read(&mut buffer) + .map_err(|error| PackError::io(format!("读 {name} 失败:{error}")))?; + if read == 0 { + break; + } + total += read as u64; + if total > self.limits.max_entry_bytes { + return Err(PackError::limits(format!( + "{name} 实际解压超过单条目上限 {}", + self.limits.max_entry_bytes + ))); + } + hasher.update(&buffer[..read]); + } + Ok(Some(hasher.finish())) + } + } + } + + /// 收集一个覆盖目录下的文件(相对该目录的路径 + SHA-1 + 字节数),按路径排序。 + /// + /// 目录不存在时返回空表——"包没带 overrides"不是错误。 + pub fn override_files(&self, dir: &str) -> PackResult> { + let dir = normalize_dir(dir)?; + let prefix = format!("{dir}/"); + let mut collected = Vec::new(); + for entry in &self.entries { + if entry.is_dir || !entry.name.starts_with(&prefix) { + continue; + } + let relative = &entry.name[prefix.len()..]; + if relative.is_empty() { + continue; + } + let sha1 = self.sha1(&entry.name)?.unwrap_or_default(); + collected.push(OverrideFile { + path: relative.to_string(), + sha1, + size: entry.size, + }); + } + collected.sort_by(|left, right| left.path.cmp(&right.path)); + Ok(collected) + } +} diff --git a/crates/core/src/addon/pack/archive/entry.rs b/crates/core/src/addon/pack/archive/entry.rs new file mode 100644 index 0000000..852c26a --- /dev/null +++ b/crates/core/src/addon/pack/archive/entry.rs @@ -0,0 +1,30 @@ +//! 一条规整过的包内条目。 + +/// 一条规整过的包内条目。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PackEntryInfo { + pub(in crate::addon::pack::archive) name: String, + pub(in crate::addon::pack::archive) raw: String, + pub(in crate::addon::pack::archive) size: u64, + pub(in crate::addon::pack::archive) is_dir: bool, +} + +impl PackEntryInfo { + /// 规整后的相对路径(`/` 分隔,无前导斜杠、无 `.`/`..` 段)。 + #[must_use] + pub fn name(&self) -> &str { + &self.name + } + + /// 声明/实测的字节数(目录条目为 0)。 + #[must_use] + pub const fn size(&self) -> u64 { + self.size + } + + /// 是否是目录条目。 + #[must_use] + pub const fn is_dir(&self) -> bool { + self.is_dir + } +} diff --git a/crates/core/src/addon/pack/archive/limits.rs b/crates/core/src/addon/pack/archive/limits.rs new file mode 100644 index 0000000..ae4bb6f --- /dev/null +++ b/crates/core/src/addon/pack/archive/limits.rs @@ -0,0 +1,100 @@ +//! 读取限额:条目数 / 解压总体积 / 单条目,以及两处封顶实现。 + +use std::io::Read; + +use super::super::error::{PackError, PackResult}; + +/// 默认条目数上限(一个巨型整合包 ≈ 数千条,2 万条已经很宽)。 +pub const DEFAULT_MAX_ENTRIES: usize = 20_000; +/// 默认解压总体积上限(512 MiB;`.mrpack` 的 `overrides` 通常几十 MiB)。 +pub const DEFAULT_MAX_UNCOMPRESSED_BYTES: u64 = 512 * 1024 * 1024; +/// 默认单条目上限(64 MiB;最大的 mod 也不过十几 MiB)。 +pub const DEFAULT_MAX_ENTRY_BYTES: u64 = 64 * 1024 * 1024; + +/// 包读取限额。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PackLimits { + /// 条目数上限。 + pub max_entries: usize, + /// 解压总体积上限。 + pub max_uncompressed_bytes: u64, + /// 单条目上限。 + pub max_entry_bytes: u64, +} + +impl PackLimits { + /// 三档显式给值(测试与"可疑包"复查用)。 + #[must_use] + pub const fn new( + max_entries: usize, + max_uncompressed_bytes: u64, + max_entry_bytes: u64, + ) -> Self { + Self { + max_entries, + max_uncompressed_bytes, + max_entry_bytes, + } + } +} + +impl Default for PackLimits { + fn default() -> Self { + Self { + max_entries: DEFAULT_MAX_ENTRIES, + max_uncompressed_bytes: DEFAULT_MAX_UNCOMPRESSED_BYTES, + max_entry_bytes: DEFAULT_MAX_ENTRY_BYTES, + } + } +} + +/// 累计与单条目限额检查(打开阶段)。 +pub(super) fn check_limits( + limits: &PackLimits, + total: &mut u64, + size: u64, + name: &str, + seen: usize, +) -> PackResult<()> { + if size > limits.max_entry_bytes { + return Err(PackError::limits(format!( + "条目 {name} 声明 {} 字节,超过单条目上限 {}", + size, limits.max_entry_bytes + ))); + } + *total = total.saturating_add(size); + if *total > limits.max_uncompressed_bytes { + return Err(PackError::limits(format!( + "解压总体积超过上限 {} 字节(累计到 {total})", + limits.max_uncompressed_bytes + ))); + } + if seen >= limits.max_entries { + return Err(PackError::limits(format!( + "条目数超过上限 {}", + limits.max_entries + ))); + } + Ok(()) +} + +/// 带封顶的读:中央目录里声明的大小可以撒谎,这里按**实际读出的字节**封顶。 +pub(super) fn read_capped(reader: &mut impl Read, cap: u64, name: &str) -> PackResult> { + let mut bytes = Vec::new(); + let mut buffer = [0_u8; 64 * 1024]; + loop { + let read = reader + .read(&mut buffer) + .map_err(|error| PackError::io(format!("读 {name} 失败:{error}")))?; + if read == 0 { + break; + } + if bytes.len() as u64 + read as u64 > cap { + return Err(PackError::limits(format!( + "{name} 实际解压超过单条目上限 {cap}" + ))); + } + bytes.extend_from_slice(&buffer[..read]); + } + Ok(bytes) +} diff --git a/crates/core/src/addon/pack/archive/open.rs b/crates/core/src/addon/pack/archive/open.rs new file mode 100644 index 0000000..e91b6c4 --- /dev/null +++ b/crates/core/src/addon/pack/archive/open.rs @@ -0,0 +1,158 @@ +//! 打开与首轮校验:目录遍历 / zip 中央目录扫描,两道穿越检查、符号链接、限额都在这里。 + +use std::cell::RefCell; +use std::collections::HashMap; +use std::fs::File; +use std::path::PathBuf; + +use zip::ZipArchive; + +use super::entry::PackEntryInfo; +use super::limits::{check_limits, PackLimits}; +use super::{PackArchive, Source}; +use crate::addon::pack::error::{PackError, PackResult}; +use crate::addon::pack::path::{has_trailing_slash, normalize_file}; + +impl PackArchive { + pub(super) fn open_dir(root: PathBuf, limits: PackLimits) -> PackResult { + let mut entries = Vec::new(); + let mut total: u64 = 0; + // 栈式遍历:不递归,避免深层目录把栈打穿。 + let mut pending = vec![String::new()]; + while let Some(relative) = pending.pop() { + let dir = if relative.is_empty() { + root.clone() + } else { + root.join(&relative) + }; + let mut children = std::fs::read_dir(&dir) + .map_err(|error| PackError::io(format!("读目录 {} 失败:{error}", dir.display())))? + .collect::, _>>() + .map_err(PackError::from)?; + children.sort_by_key(std::fs::DirEntry::file_name); + for child in children { + let file_name = + child + .file_name() + .to_str() + .map(str::to_string) + .ok_or_else(|| { + PackError::metadata(format!( + "包内文件名不是 UTF-8:{}", + child.path().display() + )) + })?; + let raw = if relative.is_empty() { + file_name + } else { + format!("{relative}/{file_name}") + }; + let file_type = child + .file_type() + .map_err(|error| PackError::io(format!("读 {} 类型失败:{error}", raw)))?; + if file_type.is_symlink() { + return Err(PackError::symlink(format!("整合包里有符号链接条目:{raw}"))); + } + let name = normalize_file(&raw)?; + if file_type.is_dir() { + check_limits(&limits, &mut total, 0, &name, entries.len())?; + pending.push(name.clone()); + entries.push(PackEntryInfo { + name, + raw, + size: 0, + is_dir: true, + }); + continue; + } + if !file_type.is_file() { + continue; + } + let size = child + .metadata() + .map_err(|error| PackError::io(format!("读 {raw} 大小失败:{error}")))? + .len(); + check_limits(&limits, &mut total, size, &name, entries.len())?; + entries.push(PackEntryInfo { + name, + raw, + size, + is_dir: false, + }); + } + } + entries.sort_by(|left, right| left.name.cmp(&right.name)); + Self::finish(root, Source::Dir, entries, limits) + } + + pub(super) fn open_zip(root: PathBuf, limits: PackLimits) -> PackResult { + let file = File::open(&root) + .map_err(|error| PackError::io(format!("打开 {} 失败:{error}", root.display())))?; + let mut archive = ZipArchive::new(file) + .map_err(|error| PackError::metadata(format!("不是可读的 zip:{error}")))?; + if archive.len() > limits.max_entries { + return Err(PackError::limits(format!( + "zip 条目数 {} 超过上限 {}", + archive.len(), + limits.max_entries + ))); + } + let mut entries = Vec::with_capacity(archive.len()); + let mut total: u64 = 0; + for index in 0..archive.len() { + let entry = archive.by_index(index).map_err(|error| { + PackError::metadata(format!("读第 {index} 个条目失败:{error}")) + })?; + let Ok(raw) = entry.name() else { + return Err(PackError::metadata(format!( + "第 {index} 个条目名编码不合法" + ))); + }; + let raw = raw.to_string(); + let is_dir = entry.is_dir() || has_trailing_slash(&raw); + if entry.is_symlink() { + return Err(PackError::symlink(format!("zip 里有符号链接条目:{raw}"))); + } + let name = normalize_file(&raw)?; + // 第二检:zip crate 按 Windows 规则再解析一遍(盘符 / ADS / 保留设备名)。 + if entry.enclosed_name().is_none() { + return Err(PackError::traversal(format!( + "zip 条目名过不了 enclosed_name 检查:{raw}" + ))); + } + let size = if is_dir { 0 } else { entry.size() }; + check_limits(&limits, &mut total, size, &name, entries.len())?; + entries.push(PackEntryInfo { + name, + raw, + size, + is_dir, + }); + } + Self::finish(root, Source::Zip(RefCell::new(archive)), entries, limits) + } + + fn finish( + root: PathBuf, + source: Source, + entries: Vec, + limits: PackLimits, + ) -> PackResult { + let mut index = HashMap::with_capacity(entries.len()); + for (slot, entry) in entries.iter().enumerate() { + if index.insert(entry.name.clone(), slot).is_some() { + return Err(PackError::metadata(format!( + "包里有规整后重名的条目:{}", + entry.name + ))); + } + } + Ok(Self { + root, + source, + entries, + index, + limits, + }) + } +} diff --git a/crates/core/src/addon/pack/curseforge.rs b/crates/core/src/addon/pack/curseforge.rs new file mode 100644 index 0000000..79744b0 --- /dev/null +++ b/crates/core/src/addon/pack/curseforge.rs @@ -0,0 +1,298 @@ +//! CurseForge 格式:`manifest.json` + `overrides/`。 +//! +//! # 形状(A 级来源:HMCL `CurseManifest.java` / `CurseManifestFile.java`,见 analyse/05 §2.2) +//! +//! ```jsonc +//! { +//! "manifestType": "minecraftModpack", "manifestVersion": 1, +//! "name": "…", "version": "1.0.0", "author": "…", +//! "overrides": "overrides", +//! "minecraft": {"version": "1.20.1", "modLoaders": [{"id": "forge-47.2.0", "primary": true}]}, +//! "files": [{"projectID": 238222, "fileID": 5102025, "required": true}] +//! } +//! ``` +//! +//! 关键缺口(**模型如实记录,不假装有**): +//! +//! - `files[]` **没有路径、没有 URL、没有哈希**。路径要按项目 `classId` 猜(`mods/` / +//! `resourcepacks/` / `shaderpacks/`),文件名要按 ForgeCDN 规则或 API 补——这正是 D10 §2 +//! 两阶段补全要补的东西,属于 M111,不在本模块。所以 [`PackFile::path`] 读出来是 `None`。 +//! - `minecraft.modLoaders[].id` 只认 `forge-` / `neoforge-` / `fabric-` / `quilt-` 前缀 +//! (CurseForge 没有 Quilt 的正规表达方式);不认识的 id **原样保留**在 +//! [`PackLoader::raw`],写回时逐字还原。 +//! - 只有一个 `required` 布尔 → 客户端/服务端同值;导出时 `client == Optional` 或 +//! `disabled` 即 `required: false`。`client`/`server` 分侧语义 CF 表达不了(mrpack 专属)。 + +use serde::Deserialize; +use serde_json::{json, Map, Value}; + +use super::archive::PackArchive; +use super::error::{PackError, PackResult}; +use super::format::PackFormat; +use super::manifest::{ + merge_unknown, EnvSupport, PackFile, PackLoader, PackManifest, PackOverrides, PackSide, +}; +use super::path::normalize_dir; + +/// 格式 id。 +pub const ID: &str = "curseforge"; +/// 清单入口名。 +pub const METADATA_ENTRY: &str = "manifest.json"; +/// `manifestType` 的固定值。 +pub const MARKER: &str = "minecraftModpack"; +/// 默认覆盖目录名。 +pub const DEFAULT_OVERRIDES: &str = "overrides"; + +/// 单例(进注册表用)。 +pub static CURSEFORGE: CurseForge = CurseForge; + +/// CurseForge 格式的值类型实现。 +#[derive(Debug, Clone, Copy, Default)] +pub struct CurseForge; + +/// `manifest.json` 的原始形状(未知字段落 `extra`)。 +#[derive(Debug, Default, Deserialize)] +struct RawManifest { + #[serde(rename = "manifestType", default)] + manifest_type: Option, + #[serde(rename = "manifestVersion", default)] + manifest_version: Option, + #[serde(default)] + name: Option, + #[serde(default)] + version: Option, + #[serde(default)] + author: Option, + #[serde(default)] + overrides: Option, + #[serde(default)] + minecraft: Option, + #[serde(default)] + files: Vec, + #[serde(flatten)] + extra: Map, +} + +/// `minecraft` 对象。 +#[derive(Debug, Default, Deserialize)] +struct RawMinecraft { + #[serde(default)] + version: Option, + #[serde(rename = "modLoaders", default)] + mod_loaders: Vec, + #[serde(flatten)] + extra: Map, +} + +/// `minecraft.modLoaders[]` 的一条。 +#[derive(Debug, Default, Deserialize)] +struct RawLoader { + #[serde(default)] + id: Option, + #[serde(default)] + primary: bool, + #[serde(flatten)] + extra: Map, +} + +/// `files[]` 的一条。 +#[derive(Debug, Default, Deserialize)] +struct RawFile { + #[serde(rename = "projectID", default)] + project_id: Option, + #[serde(rename = "fileID", default)] + file_id: Option, + #[serde(default = "default_required")] + required: bool, + #[serde(flatten)] + extra: Map, +} + +/// CF 的 `required` 缺省是 `true`(老包可能不写)。 +const fn default_required() -> bool { + true +} + +impl PackFormat for CurseForge { + fn id(&self) -> &'static str { + ID + } + + fn label(&self) -> &'static str { + "CurseForge" + } + + fn metadata_entry(&self) -> Option<&'static str> { + 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()?; + let matched = match object.get("manifestType").and_then(Value::as_str) { + // 有标记:只认固定值(`manifest.json` 这名字太泛,光凭名字不能认领)。 + Some(kind) => kind == MARKER, + // 没有标记的老包:要求形状对得上(有 minecraft 对象 + files 数组)。 + None => { + object.get("minecraft").is_some_and(Value::is_object) + && object.get("files").is_some_and(Value::is_array) + } + }; + matched.then_some(METADATA_ENTRY) + } + + fn read(&self, pack: &PackArchive) -> PackResult { + let value = pack + .read_json(METADATA_ENTRY)? + .ok_or_else(|| PackError::metadata(format!("包里没有 {METADATA_ENTRY}")))?; + let raw: RawManifest = serde_json::from_value(value) + .map_err(|error| PackError::metadata(format!("{METADATA_ENTRY} 形状不符:{error}")))?; + if let Some(kind) = &raw.manifest_type { + if kind != MARKER { + return Err(PackError::unsupported(format!( + "{METADATA_ENTRY} 的 manifestType 是 {kind:?},不是 {MARKER:?}" + ))); + } + } + let name = raw + .name + .filter(|name| !name.trim().is_empty()) + .ok_or_else(|| PackError::metadata(format!("{METADATA_ENTRY} 缺 name")))?; + let minecraft = raw.minecraft.unwrap_or_default(); + let game_version = minecraft + .version + .filter(|version| !version.trim().is_empty()) + .ok_or_else(|| PackError::metadata(format!("{METADATA_ENTRY} 缺 minecraft.version")))?; + + let loaders = minecraft + .mod_loaders + .into_iter() + .map(|loader| { + let mut parsed = PackLoader::parse_curse( + loader.id.as_deref().unwrap_or_default(), + loader.primary, + ); + parsed.extra = loader.extra; + parsed + }) + .collect(); + + let files = raw + .files + .into_iter() + .map(|file| { + let env = if file.required { + EnvSupport::Required + } else { + EnvSupport::Optional + }; + PackFile { + project_id: file.project_id, + file_id: file.file_id, + client: env, + server: env, + extra: file.extra, + ..PackFile::default() + } + }) + .collect(); + + let mut overrides = Vec::new(); + if let Some(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, + }); + } + + Ok(PackManifest { + name, + version: raw.version, + author: raw.author, + summary: None, + format_version: raw.manifest_version, + game: None, + minecraft: Some(game_version), + loaders, + files, + overrides, + extra: raw.extra, + minecraft_extra: minecraft.extra, + }) + } + + fn render(&self, manifest: &PackManifest) -> PackResult { + let game_version = manifest.minecraft.as_deref().ok_or_else(|| { + PackError::metadata("CurseForge 清单需要 minecraft.version,模型里没有") + })?; + let mut root = Map::new(); + root.insert("manifestType".into(), json!(MARKER)); + root.insert( + "manifestVersion".into(), + json!(manifest.format_version.unwrap_or(1)), + ); + root.insert("name".into(), json!(manifest.name)); + if let Some(version) = &manifest.version { + root.insert("version".into(), json!(version)); + } + if let Some(author) = &manifest.author { + root.insert("author".into(), json!(author)); + } + let overrides_dir = manifest + .overrides_for(PackSide::Both) + .map_or(DEFAULT_OVERRIDES, |group| group.dir.as_str()); + root.insert("overrides".into(), json!(overrides_dir)); + + let mut minecraft = Map::new(); + minecraft.insert("version".into(), json!(game_version)); + let mut mod_loaders = Vec::with_capacity(manifest.loaders.len()); + for loader in &manifest.loaders { + let id = loader.curse_id().ok_or_else(|| { + PackError::unsupported(format!( + "CurseForge 表达不了这条加载器依赖:raw={:?} kind={:?} version={:?}", + loader.raw, loader.kind, loader.version + )) + })?; + let mut object = Map::new(); + object.insert("id".into(), json!(id)); + object.insert("primary".into(), json!(loader.primary)); + merge_unknown(&mut object, &loader.extra); + mod_loaders.push(Value::Object(object)); + } + minecraft.insert("modLoaders".into(), Value::Array(mod_loaders)); + merge_unknown(&mut minecraft, &manifest.minecraft_extra); + root.insert("minecraft".into(), Value::Object(minecraft)); + + let mut files = Vec::with_capacity(manifest.files.len()); + for file in &manifest.files { + let (Some(project_id), Some(file_id)) = (file.project_id, file.file_id) else { + return Err(PackError::unsupported(format!( + "CurseForge 的 files[] 需要 projectID/fileID,这条没有(path={:?})", + file.path + )) + .with_hint("先用 CurseForge API 补全条目再导出,或改用 Modrinth 格式")); + }; + let mut object = Map::new(); + object.insert("projectID".into(), json!(project_id)); + object.insert("fileID".into(), json!(file_id)); + // CF 只有一个 `required` 布尔:`optional` 与"禁用"都只能落到 `false` + // (Prism 也是用 `required: false` 表达禁用的,见 M115 调研 §6)。 + object.insert( + "required".into(), + json!(!(file.client.is_optional() || file.disabled)), + ); + merge_unknown(&mut object, &file.extra); + files.push(Value::Object(object)); + } + root.insert("files".into(), Value::Array(files)); + + merge_unknown(&mut root, &manifest.extra); + serde_json::to_string_pretty(&Value::Object(root)).map_err(Into::into) + } +} diff --git a/crates/core/src/addon/pack/error.rs b/crates/core/src/addon/pack/error.rs new file mode 100644 index 0000000..92ab28d --- /dev/null +++ b/crates/core/src/addon/pack/error.rs @@ -0,0 +1,161 @@ +//! 整合包失败分类:**可诊断**比"报了个错"重要。 +//! +//! 每一类都对应导入链路上一个**不同的处置动作**(M111 据此分级): +//! +//! | 大类 | 稳定码 | 谁该负责 | +//! |------|--------|----------| +//! | [`PackErrorKind::Unsupported`] | `pack_unsupported` | 换格式 / 让用户手工处理(没格式认领、或模型表达不了该格式) | +//! | [`PackErrorKind::Metadata`] | `pack_metadata` | 包本身坏了(JSON 形状不符、哈希缺失),重试无意义 | +//! | [`PackErrorKind::Traversal`] | `pack_traversal` | **拒绝**,且不写任何文件(恶意包) | +//! | [`PackErrorKind::Symlink`] | `pack_symlink` | **拒绝**(符号链接条目在 Windows 上无合法用途) | +//! | [`PackErrorKind::Limits`] | `pack_limits` | **拒绝**(条数/体积超限,zip 炸弹防线) | +//! | [`PackErrorKind::Io`] | `pack_io` | 环境问题,可重试 | +//! +//! 文案与日志分离:`message()` 给人看,`kind().as_str()` 进事件流与 `--json`。 + +use std::fmt; + +/// 整合包失败的大类。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PackErrorKind { + /// 没有格式认领这份包,或模型里的信息不足以写成该格式。 + Unsupported, + /// 清单 JSON 坏了 / 形状不符 / 必需字段缺失。 + Metadata, + /// 包内路径穿越(绝对路径、`..` 上跳、盘符、ADS、反斜杠分隔)。 + Traversal, + /// zip 里有符号链接条目。 + Symlink, + /// 条目数 / 解压体积超过限额。 + Limits, + /// 本地 IO。 + Io, +} + +impl PackErrorKind { + /// 稳定字符串 id(进 `--json` 的 `error.code` 与日志,**不要改名**)。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Unsupported => "pack_unsupported", + Self::Metadata => "pack_metadata", + Self::Traversal => "pack_traversal", + Self::Symlink => "pack_symlink", + Self::Limits => "pack_limits", + Self::Io => "pack_io", + } + } +} + +/// 整合包错误:大类 + 人读说明 + 可选下一步提示。 +#[derive(Debug, Clone)] +pub struct PackError { + kind: PackErrorKind, + message: String, + hint: Option, +} + +impl PackError { + /// 构造。 + #[must_use] + pub fn new(kind: PackErrorKind, message: impl Into) -> Self { + Self { + kind, + message: message.into(), + hint: None, + } + } + + /// 没格式认领 / 模型表达不了该格式。 + #[must_use] + pub fn unsupported(message: impl Into) -> Self { + Self::new(PackErrorKind::Unsupported, message) + } + + /// 清单形状不对 / 必填字段缺失。 + #[must_use] + pub fn metadata(message: impl Into) -> Self { + Self::new(PackErrorKind::Metadata, message) + } + + /// 路径穿越。 + #[must_use] + pub fn traversal(message: impl Into) -> Self { + Self::new(PackErrorKind::Traversal, message) + } + + /// 符号链接条目。 + #[must_use] + pub fn symlink(message: impl Into) -> Self { + Self::new(PackErrorKind::Symlink, message) + } + + /// 超过限额。 + #[must_use] + pub fn limits(message: impl Into) -> Self { + Self::new(PackErrorKind::Limits, message) + } + + /// 本地 IO。 + #[must_use] + pub fn io(message: impl Into) -> Self { + Self::new(PackErrorKind::Io, message) + } + + /// 补一句提示。 + #[must_use] + pub fn with_hint(mut self, hint: impl Into) -> Self { + self.hint = Some(hint.into()); + self + } + + /// 大类。 + #[must_use] + pub const fn kind(&self) -> PackErrorKind { + self.kind + } + + /// 人读说明。 + #[must_use] + pub fn message(&self) -> &str { + &self.message + } + + /// 下一步提示。 + #[must_use] + pub fn hint(&self) -> Option<&str> { + self.hint.as_deref() + } +} + +impl fmt::Display for PackError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.message) + } +} + +impl std::error::Error for PackError {} + +impl From for PackError { + fn from(error: std::io::Error) -> Self { + Self::io(error.to_string()) + } +} + +impl From for PackError { + fn from(error: zip::result::ZipError) -> Self { + match error { + zip::result::ZipError::FileNotFound => Self::io("包内条目不存在"), + other => Self::metadata(format!("zip 读取失败:{other}")), + } + } +} + +impl From for PackError { + fn from(error: serde_json::Error) -> Self { + Self::metadata(format!("JSON 解析失败:{error}")) + } +} + +/// `pack` 模块内的结果别名。 +pub type PackResult = Result; diff --git a/crates/core/src/addon/pack/format.rs b/crates/core/src/addon/pack/format.rs new file mode 100644 index 0000000..6aea435 --- /dev/null +++ b/crates/core/src/addon/pack/format.rs @@ -0,0 +1,290 @@ +//! 格式扩展点:[`PackFormat`] trait + 注册表 [`PackRegistry`] + **写死的探测优先级**。 +//! +//! # 可扩展性的判据是测试,不是文档 +//! +//! `tests/extend.rs` 里有一个只有几十行的假格式 `ToyFormat`(单文件、`toy.json` 当清单): +//! 它**只加一行注册**(`PackRegistry::builtin().with(&TOY)`)就能被探测、读取、渲染、写回, +//! 不改模型、不改 CurseForge / Modrinth / MultiMC 任何一行。这是"社区标准加一个格式"的 +//! 最小代价证明。 +//! +//! # 探测优先级写死在这里,顺序由注册表 `Vec` 位置决定 +//! +//! [`PackRegistry::builtin`] 的顺序:**CurseForge → Modrinth → MultiMC**。 +//! +//! - 跟 HMCL 对这三种格式的相对次序一致(`ModpackHelper.readModpackManifest`:Curse 在 +//! Modrinth 前、Modrinth 在 MultiMC 前),减少与社区工具的行为差异; +//! - 每个格式的 `detect` 都带**形状校验**(CF 要 `manifestType: minecraftModpack`、mrpack 要 +//! `formatVersion` + `files` 数组、MultiMC 要 `components` 非空),所以名字很泛的 +//! `manifest.json` 不会把 `.mrpack` 抢走; +//! - 同一份包同时命中多个格式时,[`PackMatch::also_matched`] 会记下其余命中,调用方可以提示 +//! "这包不标准"——不做静默判定,也不做自动合并; +//! - [`PackRegistry::register`] 追加到**末尾**(= 最低优先级),所以测试里改一行顺序就能 +//! 证明"优先级来自注册表顺序"(`tests/detect.rs` 有一条反序注册的用例)。 +//! +//! # 写 +//! +//! 默认实现([`PackFormat::write`])替所有格式做同一件事:写清单 entry([`PackFormat::render`] +//! 的产物)+ 从 [`PackPayload::root`] 里搬覆盖目录内容。因此新格式只要实现 +//! `metadata_entry` + `render` 就同时获得导出能力;只读格式(MultiMC)不实现 `metadata_entry`, +//! 写会得到明确的 `crate::addon::pack::error::PackErrorKind::Unsupported`。 + +use std::fmt::Debug; +use std::fs::File; +use std::io::Write; +use std::path::Path; + +use super::archive::PackArchive; +use super::curseforge; +use super::error::{PackError, PackResult}; +use super::manifest::PackManifest; +use super::modrinth; +use super::multimc; +use super::path::{normalize_dir, normalize_file}; + +/// 导出时的覆盖内容来源。 +/// +/// [`PackPayload::metadata_only`] 只写清单(不用磁盘上的任何内容); +/// [`PackPayload::from_root`] 给一个根目录,覆盖目录按 `//` 取文件。 +#[derive(Debug, Clone, Copy)] +pub struct PackPayload<'a> { + root: Option<&'a Path>, +} + +impl<'a> PackPayload<'a> { + /// 只写清单,不搬覆盖内容。 + #[must_use] + pub const fn metadata_only() -> Self { + Self { root: None } + } + + /// 覆盖内容从 `root` 取。 + #[must_use] + pub const fn from_root(root: &'a Path) -> Self { + Self { root: Some(root) } + } + + /// 来源根目录。 + #[must_use] + pub const fn root(&self) -> Option<&'a Path> { + self.root + } +} + +/// 一种整合包格式(导入 + 导出的全部能力都在这里)。 +pub trait PackFormat: Debug + Send + Sync { + /// 稳定字符串 id(进日志/事件/CLI 参数,**不要改名**)。 + fn id(&self) -> &'static str; + + /// 人读名称。 + fn label(&self) -> &'static str; + + /// 清单文件在包内的入口名(`Some` = 本格式可导出)。 + fn metadata_entry(&self) -> Option<&'static str> { + None + } + + /// 这份输入是不是本格式;命中时返回**证据**(凭哪个条目认领的),未命中返回 `None`。 + /// + /// **必须带形状校验**,不能只看文件名存在:`manifest.json` 这种泛名到处都是。 + /// "选谁"不是格式自己的事——排序与歧义在 [`PackRegistry::detect`] 那一层做 + /// (返回 [`PackMatch`],带 `also_matched` 与本次命中的证据)。 + fn detect(&self, pack: &PackArchive) -> Option<&'static str>; + + /// 读成中间模型。 + fn read(&self, pack: &PackArchive) -> PackResult; + + /// 把模型渲染成清单 JSON(未知字段在这里回写)。 + fn render(&self, manifest: &PackManifest) -> PackResult; + + /// 导出一个整包(清单 + 覆盖内容)。 + /// + /// 默认实现对"只读格式"(`metadata_entry` 为 `None`)直接报不支持。 + fn write( + &self, + manifest: &PackManifest, + payload: PackPayload<'_>, + target: &Path, + ) -> PackResult<()> { + let Some(entry) = self.metadata_entry() else { + return Err(PackError::unsupported(format!( + "格式 {}({})只支持读取,不支持导出", + self.id(), + self.label() + )) + .with_hint("导出请选 Modrinth(.mrpack)或 CurseForge 格式")); + }; + // 模型可能来自外部构造:搬内容前把每条路径重查一遍。 + 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}")))?; + } + } + writer + .finish() + .map_err(|error| PackError::io(format!("收尾 {} 失败:{error}", target.display())))?; + Ok(()) + } +} + +/// 一次探测的结果。 +#[derive(Debug, Clone)] +pub struct PackMatch { + format: &'static dyn PackFormat, + evidence: &'static str, + also_matched: Vec<&'static str>, +} + +impl PackMatch { + /// 选中的格式。 + #[must_use] + pub fn format(&self) -> &'static dyn PackFormat { + self.format + } + + /// 命中的证据条目名(探测器凭哪个文件认领的,进日志用)。 + #[must_use] + pub fn evidence(&self) -> &'static str { + self.evidence + } + + /// 同样命中的其它格式 id(探测顺序里排在后面的那些)。 + #[must_use] + pub fn also_matched(&self) -> &[&'static str] { + &self.also_matched + } + + /// 是否有"多格式同时命中"的歧义(不标准包)。 + #[must_use] + pub fn is_ambiguous(&self) -> bool { + !self.also_matched.is_empty() + } +} + +/// 格式注册表:**顺序即优先级**。 +#[derive(Debug, Clone, Default)] +pub struct PackRegistry { + formats: Vec<&'static dyn PackFormat>, +} + +impl PackRegistry { + /// 一个都没有的注册表(测试"没有格式认领"用)。 + #[must_use] + pub fn empty() -> Self { + Self { + formats: Vec::new(), + } + } + + /// 内置三格式,顺序即优先级:CurseForge → Modrinth → MultiMC。 + #[must_use] + pub fn builtin() -> Self { + Self { + formats: vec![ + &curseforge::CURSEFORGE, + &modrinth::MODRINTH, + &multimc::MULTIMC, + ], + } + } + + /// 追加一种格式(追加 = 最低优先级)。加一种格式真的只要这一行。 + pub fn register(&mut self, format: &'static dyn PackFormat) { + self.formats.push(format); + } + + /// 链式注册。 + #[must_use] + pub fn with(mut self, format: &'static dyn PackFormat) -> Self { + self.register(format); + self + } + + /// 按优先级列出格式 id。 + #[must_use] + pub fn ids(&self) -> Vec<&'static str> { + self.formats.iter().map(|format| format.id()).collect() + } + + /// 按 id 找格式。 + #[must_use] + pub fn get(&self, id: &str) -> Option<&'static dyn PackFormat> { + self.formats + .iter() + .copied() + .find(|format| format.id() == id) + } + + /// 全部命中的格式 id(诊断"这包不标准"用)。 + #[must_use] + pub fn detect_all(&self, pack: &PackArchive) -> Vec<&'static str> { + self.formats + .iter() + .filter(|format| format.detect(pack).is_some()) + .map(|format| format.id()) + .collect() + } + + /// 按优先级选第一个命中的格式。 + 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(); + if matched.is_empty() { + let tried = self.ids().join(" / "); + return Err( + PackError::unsupported(format!("没有格式认领这份整合包(试过:{tried})")) + .with_hint( + "确认它是 CurseForge manifest.json、Modrinth .mrpack 或 MultiMC 实例", + ), + ); + } + let (format, evidence) = matched.remove(0); + let also_matched = matched.iter().map(|(other, _)| other.id()).collect(); + Ok(PackMatch { + format, + evidence, + also_matched, + }) + } + + /// 探测 + 读成模型(导入管线的第一步)。 + pub fn read(&self, pack: &PackArchive) -> PackResult<(&'static dyn PackFormat, PackManifest)> { + let matched = self.detect(pack)?; + let manifest = matched.format().read(pack)?; + Ok((matched.format(), manifest)) + } +} diff --git a/crates/core/src/addon/pack/manifest.rs b/crates/core/src/addon/pack/manifest.rs new file mode 100644 index 0000000..0c9bb26 --- /dev/null +++ b/crates/core/src/addon/pack/manifest.rs @@ -0,0 +1,350 @@ +//! 与格式无关的中间模型 [`PackManifest`]。 +//! +//! # 为什么要中间模型 +//! +//! CF 的 `manifest.json` 与 Modrinth 的 `modrinth.index.json` 表达同一件事的方式不同: +//! +//! | 概念 | CurseForge | Modrinth `.mrpack` | 模型字段 | +//! |------|-----------|--------------------|----------| +//! | 包名 | `name` | `name` | [`PackManifest::name`] | +//! | 包版本 | `version` | `versionId` | [`PackManifest::version`] | +//! | 作者 | `author` | **没有** | [`PackManifest::author`] | +//! | 简介 | **没有** | `summary` | [`PackManifest::summary`] | +//! | 游戏版本 | `minecraft.version` | `dependencies.minecraft` | [`PackManifest::minecraft`] | +//! | 加载器 | `minecraft.modLoaders[].id`(`forge-47.2.0`) | `dependencies["forge"] = "47.2.0"` | [`PackManifest::loaders`] | +//! | 远端文件 | `files[].projectID/fileID`(**无路径、无 URL**) | `files[].path/hashes/downloads/env` | [`PackManifest::files`] | +//! | 覆盖目录 | `overrides`(目录名由清单给,可任意) | `overrides` + `client-overrides` + `server-overrides` | [`PackManifest::overrides`] | +//! | 禁用位 | 无(Prism 借 `required: false` 表达) | 路径 `.disabled` 后缀(官方)或 `env.client=optional`(D10) | [`PackFile::disabled`] | +//! | 多 MC 实例(MultiMC) | — | — | 同上(`.minecraft` 当覆盖目录) | +//! +//! 方向不同造成的缺口**不靠"假装字段都在"抹平**,而是显式记录: +//! +//! - CF 的 `files[]` 没有路径 → [`PackFile::path`] 是 `Option`,`None` 表示"要靠远端 API 补全" +//! (D10 §2 的两阶段补全就是补这个);写 mrpack 时缺路径即 +//! `crate::addon::pack::error::PackErrorKind::Unsupported`。 +//! - CF 只有一个 `required` 布尔 → 读成 `client`/`server` 同值,写回时 `client == Optional` 即 +//! `required = false`;`client`/`server` 不同的条目不适用于 CF 导出(文档写明,不静默降级)。 +//! +//! # 未知字段不丢("社区标准加字段不炸"的具体手段) +//! +//! 三处 `extra` 用 `serde(flatten)` 接住不认识的 JSON 字段,**原样 Value 保存**, +//! 各格式的 `render` 再把它们并回去(已知字段优先,冲突时丢弃 extra 里的同名键): +//! +//! - [`PackManifest::extra`]:清单顶层; +//! - [`PackManifest::minecraft_extra`]:CF `minecraft` 对象内部(mrpack 没有这一层); +//! - [`PackFile::extra`] / [`PackFile::env_extra`] / [`PackLoader::extra`]:条目、`env`、加载器条目内部。 +//! +//! 哈希不按"sha1/sha512 两个字段"建模,而是 [`PackFile::hashes`] 整张表:mrpack 允许 +//! `sha1`/`sha256`/`sha512`/`md5` 同时出现,只留两个字段就会丢信息。 + +mod entries; + +pub use entries::{merge_unknown, OverrideFile, PackFile, PackOverrides}; + +use std::collections::HashMap; + +use serde_json::{Map, Value}; + +use super::path::normalize_file; +use crate::addon::LoaderKind; + +/// 客户端 / 服务端环境标记(mrpack `env` 的三值,CF 只有 `required` 布尔)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum EnvSupport { + /// 必需(mrpack `required` / CF `required: true`)。 + #[default] + Required, + /// 可选(mrpack `optional` / CF `required: false`;导出侧 `.disabled` → 这个)。 + Optional, + /// 本侧不支持(mrpack `unsupported`;CF 表达不了)。 + Unsupported, +} + +impl EnvSupport { + /// 解析 mrpack 的字符串形式;不认识的返回 `None`(调用方决定报错还是忽略)。 + #[must_use] + pub fn parse(raw: &str) -> Option { + match raw.trim().to_ascii_lowercase().as_str() { + "required" => Some(Self::Required), + "optional" => Some(Self::Optional), + "unsupported" => Some(Self::Unsupported), + _ => None, + } + } + + /// mrpack 的字符串形式。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Required => "required", + Self::Optional => "optional", + Self::Unsupported => "unsupported", + } + } + + /// 是否"可以不要"。 + #[must_use] + pub const fn is_optional(self) -> bool { + matches!(self, Self::Optional) + } +} + +/// 覆盖目录的目标侧(mrpack 用三个目录区分,CF 只有一个)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub enum PackSide { + /// 客户端与服务端都要(`overrides` / `.minecraft`)。 + #[default] + Both, + /// 只要客户端(`client-overrides`)。 + Client, + /// 只要服务端(`server-overrides`)。 + Server, +} + +impl PackSide { + /// 人读名称(进日志/报告)。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Both => "both", + Self::Client => "client", + Self::Server => "server", + } + } + + /// mrpack 里这一侧的标准目录名。 + #[must_use] + pub const fn mrpack_dir(self) -> &'static str { + match self { + Self::Both => "overrides", + Self::Client => "client-overrides", + Self::Server => "server-overrides", + } + } + + /// 该侧的覆盖内容对客户端是否生效。 + #[must_use] + pub const fn applies_to_client(self) -> bool { + matches!(self, Self::Both | Self::Client) + } + + /// 该侧的覆盖内容对服务端是否生效。 + #[must_use] + pub const fn applies_to_server(self) -> bool { + matches!(self, Self::Both | Self::Server) + } +} + +/// 一个加载器依赖。 +/// +/// `raw` 保留原始标识,读写两侧因此无损: +/// - CF:`raw = "forge-47.2.0"`,`kind = Forge`,`version = "47.2.0"`; +/// - mrpack:`raw = "fabric-loader"`,`kind = Fabric`,`version = "0.15.11"`; +/// - MultiMC:`raw = "net.fabricmc.fabric-loader"`,`kind = Fabric`,`version = "0.15.11"`。 +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct PackLoader { + /// 原始标识(按格式原样保存)。 + pub raw: String, + /// 规范化加载器种类;不认识的加载器是 `None`(仍然保留 `raw` 与 `version`)。 + pub kind: Option, + /// 加载器版本。 + pub version: Option, + /// 是否主加载器(CF `primary`;其它格式取"第一条能认出来的")。 + pub primary: bool, + /// 该加载器条目的未知字段。 + pub extra: Map, +} + +impl PackLoader { + /// 从 CF 的 `modLoaders[].id`(`forge-47.2.0`)解析。 + #[must_use] + pub fn parse_curse(id: &str, primary: bool) -> Self { + let (kind, version) = split_curse_id(id); + Self { + raw: id.to_string(), + kind, + version, + primary, + extra: Map::new(), + } + } + + /// 从 mrpack `dependencies` 的一条键值解析(`fabric-loader` → `0.15.11`)。 + #[must_use] + pub fn parse_dependency(key: &str, version: &str, primary: bool) -> Self { + Self { + raw: key.to_string(), + kind: parse_kind_key(key), + version: Some(version.to_string()), + primary, + extra: Map::new(), + } + } + + /// 从 MultiMC 组件 `uid` + `version` 解析(`net.fabricmc.fabric-loader`)。 + #[must_use] + pub fn parse_multimc(uid: &str, version: &str, primary: bool) -> Self { + let kind = match uid { + "net.fabricmc.fabric-loader" => Some(LoaderKind::Fabric), + "org.quiltmc.quilt-loader" => Some(LoaderKind::Quilt), + "net.minecraftforge" => Some(LoaderKind::Forge), + "net.neoforged" => Some(LoaderKind::NeoForge), + _ => None, + }; + Self { + raw: uid.to_string(), + kind, + version: Some(version.to_string()), + primary, + extra: Map::new(), + } + } + + /// 写成 CF 的 `id`。 + /// + /// `raw` 非空时**逐字回写**(CF 的 id 是不透明字符串,不认识的加载器也得原样带过去); + /// 只有从别的格式转过来的 `raw` 为空的情况才用 `kind`+`version` 拼。 + #[must_use] + pub fn curse_id(&self) -> Option { + if !self.raw.is_empty() { + return Some(self.raw.clone()); + } + let kind = self.kind?; + let version = self.version.as_deref()?; + Some(format!("{}-{version}", kind.as_str())) + } + + /// 写成 mrpack `dependencies` 的键。 + /// + /// 认得种类时用 Modrinth 的规范拼法(`fabric-loader` / `quilt-loader` / `forge` / + /// `neoforge`)——这是 Modrinth 自己的键名约定,CF 的 `forge-47.2.0` 与 MultiMC 的 + /// `net.fabricmc.fabric-loader` 都靠它翻译过去。种类未知时(社区新加载器)逐字回写 `raw`。 + #[must_use] + pub fn dependency_key(&self) -> String { + match self.kind { + Some(LoaderKind::Fabric) => "fabric-loader".to_string(), + Some(LoaderKind::Quilt) => "quilt-loader".to_string(), + Some(kind) => kind.as_str().to_string(), + None => self.raw.clone(), + } + } +} + +/// CF 的 `modLoaders[].id` 里认得上哪些前缀。 +fn split_curse_id(id: &str) -> (Option, Option) { + for kind in LoaderKind::all() { + let prefix = format!("{}-", kind.as_str()); + if let Some(rest) = id.strip_prefix(&prefix) { + if !rest.is_empty() { + return (Some(*kind), Some(rest.to_string())); + } + } + } + (None, None) +} + +/// mrpack `dependencies` 的键 → 加载器种类(`fabric-loader` / `neo-forge` 两种拼法都认)。 +fn parse_kind_key(key: &str) -> Option { + let lowered = key.trim().to_ascii_lowercase(); + let bare = lowered.strip_suffix("-loader").unwrap_or(&lowered); + LoaderKind::parse(bare) +} + +/// 与格式无关的整合包模型。 +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct PackManifest { + /// 包名(CF `name` / mrpack `name`)。 + pub name: String, + /// 包版本(CF `version` / mrpack `versionId`)。 + pub version: Option, + /// 作者(CF `author`;mrpack 没有这一项)。 + pub author: Option, + /// 简介(mrpack `summary`;CF 没有这一项)。 + pub summary: Option, + /// 清单 schema 版本(CF `manifestVersion` / mrpack `formatVersion` / MultiMC `formatVersion`)。 + pub format_version: Option, + /// 平台标识(mrpack `game`,恒为 `minecraft`;CF 隐式,`None`)。 + pub game: Option, + /// 游戏版本(`1.20.1`)。 + pub minecraft: Option, + /// 加载器依赖集合。 + pub loaders: Vec, + /// 远端文件条目(不含覆盖目录里的文件)。 + pub files: Vec, + /// 覆盖目录清单。 + pub overrides: Vec, + /// 清单顶层的未知字段(原样保存、导出时回写)。 + pub extra: Map, + /// CF `minecraft` 对象里的未知字段(mrpack/MultiMC 没有这一层)。 + pub minecraft_extra: Map, +} + +impl PackManifest { + /// 建一个只有包名的空清单。 + #[must_use] + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + ..Self::default() + } + } + + /// 按目标侧找覆盖目录。 + #[must_use] + pub fn overrides_for(&self, side: PackSide) -> Option<&PackOverrides> { + self.overrides.iter().find(|group| group.side == side) + } + + /// 覆盖目录里的全部文件,按 `(side, path)` 展平(三方合并要按实例路径查)。 + pub fn override_files(&self) -> impl Iterator { + self.overrides + .iter() + .flat_map(|group| group.files.iter().map(move |file| (group.side, file))) + } + + /// 某个加载器的依赖(有就返回第一条)。 + #[must_use] + pub fn loader(&self, kind: LoaderKind) -> Option<&PackLoader> { + self.loaders.iter().find(|loader| loader.kind == Some(kind)) + } + + /// 有远端来源的条目(M111 的补全目标)。 + pub fn remote_files(&self) -> impl Iterator { + self.files.iter().filter(|file| file.is_remote_reachable()) + } + + /// 校验模型里所有路径安全(导出前必过:模型可能来自外部构造)。 + /// + /// 覆盖目录前缀与文件路径都要过 [`normalize_file`] / [`normalize_dir`] 的同一条规则。 + pub fn check_paths(&self) -> super::error::PackResult<()> { + use super::path::normalize_dir; + + for group in &self.overrides { + normalize_dir(&group.dir)?; + for file in &group.files { + normalize_file(&file.path)?; + } + } + for file in &self.files { + if let Some(path) = &file.path { + normalize_file(path)?; + } + } + Ok(()) + } + + /// 覆盖目录里重复的实例路径(两份 overrides 撞同一文件时的提示,导入器据此决策)。 + #[must_use] + pub fn duplicate_override_paths(&self) -> Vec { + let mut seen: HashMap<(PackSide, &str), usize> = HashMap::new(); + let mut duplicated = Vec::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()); + } + } + duplicated + } +} diff --git a/crates/core/src/addon/pack/manifest/entries.rs b/crates/core/src/addon/pack/manifest/entries.rs new file mode 100644 index 0000000..caffda4 --- /dev/null +++ b/crates/core/src/addon/pack/manifest/entries.rs @@ -0,0 +1,154 @@ +//! 条目级模型:远端文件、覆盖目录、以及"未知字段并回"的唯一实现。 +//! +//! 拆在这里是为了守 400 行上限;对外仍是 [`crate::addon::pack::manifest`] 的一部分 +//! (`manifest` 用 `pub use` 重新导出)。 + +use std::collections::BTreeMap; + +use serde_json::{Map, Value}; + +use super::{EnvSupport, PackSide}; + +/// 一个远端文件条目。 +/// +/// CF 只填 `project_id`/`file_id`/`client`/`server`;mrpack 只填 `path`/`hashes`/`downloads`/ +/// `size`/`client`/`server`。两者可以同时存在(例如 M111 补全 CF 条目之后)。 +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct PackFile { + /// 实例内相对路径(`mods/jei.jar`)。CF 官方清单没有这一项 → `None`。 + pub path: Option, + /// CF `projectID`。 + pub project_id: Option, + /// CF `fileID`。 + pub file_id: Option, + /// 摘要表(键名小写:`sha1` / `sha512` / `sha256` / `md5`)。mrpack 要求有 `sha512`。 + pub hashes: BTreeMap, + /// 下载候选(mrpack `downloads`,多源;CF 官方清单没有,要靠 CDN 规则或 API 拼)。 + pub downloads: Vec, + /// 文件字节数(mrpack `fileSize`)。 + pub size: Option, + /// 是否"以禁用态安装"(`.disabled`)。 + /// + /// 社区里 `.disabled` 有两种表达方式(M115 调研 §字段缺口 2,A 级来源 Modrinth 官方 + /// `export_mrpack.rs` + B 级 HMCL/D10 约定): + /// - 官方 mrpack 导出器:路径本身带 `.disabled` 后缀; + /// - HMCL/D10:去掉后缀,用 `env.client = optional` 表达。 + /// + /// 模型只记**语义**(这条要被禁用),两种拼法留给导出策略(M112)决定; + /// 读包时不会因为 `env.client=optional` 就把它置 `true`——"可选"与"禁用"是两个事实。 + pub disabled: bool, + /// 客户端支持度。 + pub client: EnvSupport, + /// 服务端支持度。 + pub server: EnvSupport, + /// 该条目的未知字段。 + pub extra: Map, + /// `env` 对象里的未知字段。 + pub env_extra: Map, +} + +impl PackFile { + /// 只带路径的条目(mrpack 侧最简形态)。 + #[must_use] + pub fn with_path(path: impl Into) -> Self { + Self { + path: Some(path.into()), + ..Self::default() + } + } + + /// 只带 CF 标识的条目。 + #[must_use] + pub fn with_curse(project_id: i64, file_id: i64) -> Self { + Self { + project_id: Some(project_id), + file_id: Some(file_id), + ..Self::default() + } + } + + /// SHA-1(有就返回)。 + #[must_use] + pub fn sha1(&self) -> Option<&str> { + self.hashes.get("sha1").map(String::as_str) + } + + /// SHA-512(有就返回)。 + #[must_use] + pub fn sha512(&self) -> Option<&str> { + self.hashes.get("sha512").map(String::as_str) + } + + /// 稳定标识:用于"用户取消勾选某文件"的排除集合,跨版本比对要稳。 + /// + /// 与 HMCL 同构:`curseforge::` / `modrinth::`, + /// 两者都没有时退回路径。 + #[must_use] + pub fn key(&self) -> String { + if let (Some(project), Some(file)) = (self.project_id, self.file_id) { + return format!("curseforge:{project}:{file}"); + } + if let (Some(sha512), Some(path)) = (self.sha512(), self.path.as_deref()) { + return format!("modrinth:{sha512}:{path}"); + } + self.path.clone().unwrap_or_default() + } + + /// 这条目是否有远端下载来源(决定导入时能否自己补齐)。 + #[must_use] + pub fn is_remote_reachable(&self) -> bool { + if self.downloads.iter().any(|url| !url.trim().is_empty()) { + return true; + } + self.project_id.is_some() && self.file_id.is_some() + } +} + +/// 覆盖目录里的一个文件(三方合并的 `base` 侧:上次整合包给的哈希)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OverrideFile { + /// 相对该覆盖目录的路径(等价于实例内相对路径,例如 `mods/jei.jar`)。 + pub path: String, + /// 内容 SHA-1(读包时现算)。 + pub sha1: String, + /// 字节数。 + pub size: u64, +} + +/// 一个覆盖目录(`overrides` / `client-overrides` / `server-overrides` / MultiMC 的 `.minecraft`)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PackOverrides { + /// 包内目录前缀(`/` 分隔,无尾斜杠)。 + pub dir: String, + /// 目标侧。 + pub side: PackSide, + /// 目录内的文件清单(按路径排序,保证同一份包两次读出的模型相等)。 + pub files: Vec, +} + +impl PackOverrides { + /// 建一个空目录清单。 + #[must_use] + pub fn new(dir: impl Into, side: PackSide) -> Self { + Self { + dir: dir.into(), + side, + files: Vec::new(), + } + } + + /// 按实例内相对路径查一条。 + #[must_use] + pub fn find(&self, path: &str) -> Option<&OverrideFile> { + self.files.iter().find(|file| file.path == path) + } +} + +/// 把未知字段并回 JSON 对象:**已知字段优先**,extra 里的同名键丢弃(不覆盖模型的判断)。 +pub fn merge_unknown(target: &mut Map, extra: &Map) { + for (key, value) in extra { + if !target.contains_key(key) { + target.insert(key.clone(), value.clone()); + } + } +} diff --git a/crates/core/src/addon/pack/modrinth.rs b/crates/core/src/addon/pack/modrinth.rs new file mode 100644 index 0000000..0615554 --- /dev/null +++ b/crates/core/src/addon/pack/modrinth.rs @@ -0,0 +1,347 @@ +//! Modrinth 格式:`modrinth.index.json` + `overrides/` `client-overrides/` `server-overrides/`。 +//! +//! # 形状(A 级来源:HMCL `ModrinthManifest.java`,见 analyse/05 §2.3) +//! +//! ```jsonc +//! { +//! "game": "minecraft", "formatVersion": 1, "versionId": "1.0.0", +//! "name": "…", "summary": "…", +//! "files": [{ +//! "path": "mods/jei.jar", +//! "hashes": {"sha1": "…", "sha512": "…"}, +//! "env": {"client": "required", "server": "unsupported"}, +//! "downloads": ["https://cdn.modrinth.com/data/…/jei.jar"], +//! "fileSize": 123456 +//! }], +//! "dependencies": {"minecraft": "1.20.1", "fabric-loader": "0.15.11"} +//! } +//! ``` +//! +//! 比 CF 强的地方(也是模型主路径为什么按 mrpack 设计):**有路径、有多源 URL、有摘要表、 +//! 有客户端/服务端分侧语义**。这三样正是「能否被远程替代」判据(D10 §2 导出侧)需要的输入。 +//! +//! # 读取时坚持的三条校验(与 HMCL `File.validate()` 同源) +//! +//! 1. `path` 必须是不越界的相对路径([`normalize_file`] 七条规则,第二检在 `archive`); +//! 2. `hashes.sha512` 必须存在且是 128 位十六进制——没有它,"远程替代"无从判断; +//! 3. `env` 出现时 `client` 必须存在(`env.server` 缺省按 `required`)。 +//! +//! 另有两条事实(M115 调研 §字段缺口 1/4):`fileSize` 在规范里是**必填**(模型里是 +//! `Option`,未知就不写,由 M112 补全),`server-overrides` 客户端**不解压**(模型照记, +//! 落地判断留给 M111)。 +//! +//! 这三条是**读**的硬门槛;写(导出)另有两条:条目缺 `path` 或缺 `downloads` 一律报 +//! `PackErrorKind::Unsupported`(CF 的裸 `projectID/fileID` 条目转不成 mrpack, +//! 必须先补全——这就是 M111/M112 的分工边界)。 +//! +//! `dependencies` 里的 `neoforge` 与 `neo-forge` 两种拼法都认(HMCL PR #5170 的坑), +//! 认不出的键(社区新加载器如 `parchment`)**原样保留**成一条 [`PackLoader`]。 + +use std::collections::BTreeMap; + +use serde::Deserialize; +use serde_json::{json, Map, Value}; + +use super::archive::PackArchive; +use super::error::{PackError, PackResult}; +use super::format::PackFormat; +use super::manifest::{ + merge_unknown, EnvSupport, PackFile, PackLoader, PackManifest, PackOverrides, PackSide, +}; +use super::path::normalize_file; + +/// 格式 id。 +pub const ID: &str = "modrinth"; +/// 清单入口名。 +pub const METADATA_ENTRY: &str = "modrinth.index.json"; +/// `game` 的固定值。 +pub const GAME: &str = "minecraft"; +/// 依赖表里代表游戏版本的那个键。 +pub const MINECRAFT_KEY: &str = "minecraft"; + +/// 单例(进注册表用)。 +pub static MODRINTH: Modrinth = Modrinth; + +/// Modrinth 格式的值类型实现。 +#[derive(Debug, Clone, Copy, Default)] +pub struct Modrinth; + +/// `modrinth.index.json` 的原始形状(未知字段落 `extra`)。 +#[derive(Debug, Default, Deserialize)] +struct RawIndex { + #[serde(default)] + game: Option, + #[serde(rename = "formatVersion", default)] + format_version: Option, + #[serde(rename = "versionId", default)] + version_id: Option, + #[serde(default)] + name: Option, + #[serde(default)] + summary: Option, + #[serde(default)] + files: Vec, + #[serde(default)] + dependencies: BTreeMap, + #[serde(flatten)] + extra: Map, +} + +/// `files[]` 的一条。 +#[derive(Debug, Default, Deserialize)] +struct RawFile { + #[serde(default)] + path: Option, + #[serde(default)] + hashes: BTreeMap, + #[serde(default)] + env: Option, + #[serde(default)] + downloads: Vec, + #[serde(rename = "fileSize", default)] + file_size: Option, + #[serde(flatten)] + extra: Map, +} + +/// `files[].env`。 +#[derive(Debug, Default, Deserialize)] +struct RawEnv { + #[serde(default)] + client: Option, + #[serde(default)] + server: Option, + #[serde(flatten)] + extra: Map, +} + +impl PackFormat for Modrinth { + fn id(&self) -> &'static str { + ID + } + + fn label(&self) -> &'static str { + "Modrinth" + } + + fn metadata_entry(&self) -> Option<&'static str> { + 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()?; + // 有 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) + } + + fn read(&self, pack: &PackArchive) -> PackResult { + let value = pack + .read_json(METADATA_ENTRY)? + .ok_or_else(|| PackError::metadata(format!("包里没有 {METADATA_ENTRY}")))?; + let raw: RawIndex = serde_json::from_value(value) + .map_err(|error| PackError::metadata(format!("{METADATA_ENTRY} 形状不符:{error}")))?; + let name = raw + .name + .filter(|name| !name.trim().is_empty()) + .ok_or_else(|| PackError::metadata(format!("{METADATA_ENTRY} 缺 name")))?; + + let mut minecraft = None; + let mut loaders: Vec = Vec::new(); + for (key, version) in &raw.dependencies { + if key == MINECRAFT_KEY { + minecraft = Some(version.clone()); + continue; + } + let mut loader = PackLoader::parse_dependency(key, version, false); + // 第一条认得出来的加载器当主加载器(mrpack 没有 primary 概念)。 + if loader.kind.is_some() && !loaders.iter().any(|seen| seen.kind.is_some()) { + loader.primary = true; + } + loaders.push(loader); + } + + let mut files = Vec::with_capacity(raw.files.len()); + for file in raw.files { + let path = file.path.as_deref().ok_or_else(|| { + PackError::metadata(format!("{METADATA_ENTRY} 有一条 files[] 没有 path")) + })?; + let path = normalize_file(path)?; + check_sha512(&path, &file.hashes)?; + let (client, server, env_extra) = match file.env { + None => (EnvSupport::Required, EnvSupport::Required, Map::new()), + Some(env) => { + let client_raw = env.client.as_deref().ok_or_else(|| { + PackError::metadata(format!( + "files[] 条目 {path} 有 env 但没有 env.client(Modrinth 规范要求)" + )) + })?; + let client = parse_env(&path, "client", client_raw)?; + let server = match env.server.as_deref() { + Some(raw) => parse_env(&path, "server", raw)?, + None => EnvSupport::Required, + }; + (client, server, env.extra) + } + }; + files.push(PackFile { + path: Some(path), + hashes: file.hashes, + downloads: file.downloads, + size: file.file_size, + client, + server, + extra: file.extra, + env_extra, + ..PackFile::default() + }); + } + + let mut overrides = Vec::new(); + for side in [PackSide::Both, PackSide::Client, PackSide::Server] { + let dir = side.mrpack_dir(); + let files = pack.override_files(dir)?; + if pack.has_entry(dir) || !files.is_empty() { + overrides.push(PackOverrides { + dir: dir.to_string(), + side, + files, + }); + } + } + + Ok(PackManifest { + name, + version: raw.version_id, + author: None, + summary: raw.summary, + format_version: raw.format_version, + game: raw.game.or_else(|| Some(GAME.to_string())), + minecraft, + loaders, + files, + overrides, + extra: raw.extra, + minecraft_extra: Map::new(), + }) + } + + fn render(&self, manifest: &PackManifest) -> PackResult { + let version_id = manifest.version.as_deref().ok_or_else(|| { + PackError::metadata("Modrinth 清单需要 versionId(包版本),模型里没有") + })?; + let mut root = Map::new(); + root.insert( + "game".into(), + json!(manifest.game.as_deref().unwrap_or(GAME)), + ); + root.insert( + "formatVersion".into(), + json!(manifest.format_version.unwrap_or(1)), + ); + root.insert("versionId".into(), json!(version_id)); + root.insert("name".into(), json!(manifest.name)); + if let Some(summary) = &manifest.summary { + root.insert("summary".into(), json!(summary)); + } + + let mut dependencies = Map::new(); + if let Some(minecraft) = &manifest.minecraft { + dependencies.insert(MINECRAFT_KEY.into(), json!(minecraft)); + } + for loader in &manifest.loaders { + let key = loader.dependency_key(); + let Some(version) = loader.version.as_deref() else { + return Err(PackError::unsupported(format!( + "Modrinth dependencies 需要版本号,这条加载器没有:raw={:?}", + loader.raw + ))); + }; + if key.is_empty() { + return Err(PackError::unsupported(format!( + "Modrinth dependencies 需要键名,这条加载器没有:version={version:?}" + ))); + } + dependencies.insert(key, json!(version)); + } + root.insert("dependencies".into(), Value::Object(dependencies)); + + let mut files = Vec::with_capacity(manifest.files.len()); + for file in &manifest.files { + let path = file.path.as_deref().ok_or_else(|| { + PackError::unsupported(format!( + "Modrinth 的 files[] 需要 path,这条没有(projectID={:?})", + file.project_id + )) + .with_hint("先用 CurseForge/Modrinth API 补全条目再导出") + })?; + let path = normalize_file(path)?; + file.sha512().ok_or_else(|| { + PackError::unsupported(format!("Modrinth 的 files[] 需要 hashes.sha512:{path}")) + })?; + if file.downloads.iter().all(|url| url.trim().is_empty()) { + return Err(PackError::unsupported(format!( + "Modrinth 的 files[] 需要至少一个 downloads URL:{path}" + )) + .with_hint("内联覆盖内容无法用 mrpack 表达(见 M112 的内联兜底方案)")); + } + let mut hashes = Map::new(); + for (algorithm, digest) in &file.hashes { + hashes.insert(algorithm.clone(), json!(digest)); + } + let mut env = Map::new(); + merge_unknown(&mut env, &file.env_extra); + // 禁用位走 D10 的映射(去掉 `.disabled` 后缀 + `env.client=optional`), + // 而不是把后缀塞回路径——两条路都能表达,选跟本仓库既有约定一致的那条。 + let client = if file.disabled { + EnvSupport::Optional + } else { + file.client + }; + env.insert("client".into(), json!(client.as_str())); + env.insert("server".into(), json!(file.server.as_str())); + + let mut object = Map::new(); + object.insert("path".into(), json!(path)); + object.insert("hashes".into(), Value::Object(hashes)); + object.insert("env".into(), Value::Object(env)); + object.insert("downloads".into(), json!(file.downloads)); + if let Some(size) = file.size { + object.insert("fileSize".into(), json!(size)); + } + merge_unknown(&mut object, &file.extra); + files.push(Value::Object(object)); + } + root.insert("files".into(), Value::Array(files)); + + merge_unknown(&mut root, &manifest.extra); + serde_json::to_string_pretty(&Value::Object(root)).map_err(Into::into) + } +} + +/// `hashes.sha512` 必须是 128 位十六进制。 +fn check_sha512(path: &str, hashes: &BTreeMap) -> PackResult<()> { + let Some(digest) = hashes.get("sha512") else { + return Err(PackError::metadata(format!( + "files[] 条目 {path} 缺 hashes.sha512(Modrinth 规范要求)" + ))); + }; + if digest.len() != 128 || !digest.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(PackError::metadata(format!( + "files[] 条目 {path} 的 sha512 不是 128 位十六进制" + ))); + } + Ok(()) +} + +/// 解析 `env.client` / `env.server`;不认识的取值直接拒绝(不静默降级成全都要)。 +fn parse_env(path: &str, field: &str, raw: &str) -> PackResult { + EnvSupport::parse(raw).ok_or_else(|| { + PackError::metadata(format!( + "files[] 条目 {path} 的 env.{field} 是 {raw:?},只认 required/optional/unsupported" + )) + }) +} diff --git a/crates/core/src/addon/pack/multimc.rs b/crates/core/src/addon/pack/multimc.rs new file mode 100644 index 0000000..40b491a --- /dev/null +++ b/crates/core/src/addon/pack/multimc.rs @@ -0,0 +1,179 @@ +//! MultiMC / Prism 格式:`mmc-pack.json` + `instance.cfg` + `.minecraft/` 目录。 +//! +//! # 形状(A 级来源:HMCL `MultiMCModpackProvider` / `toGameSettings`,见 analyse/05 §2.6) +//! +//! ```jsonc +//! // mmc-pack.json +//! {"formatVersion": 1, "components": [ +//! {"uid": "net.minecraft", "version": "1.20.1", "important": true}, +//! {"uid": "net.fabricmc.fabric-loader", "version": "0.15.11"}]} +//! // instance.cfg(ini) +//! InstanceType=OneSix +//! name=我的整合包 +//! ``` +//! +//! **它不是一个"包",而是一个"实例目录"**:模组就在 `.minecraft/mods/` 里,没有任何远端 +//! 条目、没有 URL、没有摘要表。所以: +//! +//! - 读:把 `.minecraft` 当成一个覆盖目录([`PackSide::Both`]),模组按目录里的实际文件逐条 +//! 记 SHA-1;[`PackManifest::files`] 恒为空——这是格式事实,不是我们的偷懒; +//! - 写:**不支持**。写回 MultiMC 需要重组 `mmc-pack.json` 的组件图(还要处理 +//! `patches/`、`libraries/`、`jarmods/`),那是另一套协议,不在本模块的模型职责内, +//! 也不该伪装成"能导出"。`metadata_entry` 返回 `None`,写会得到明确的 +//! `pack_unsupported`。 +//! +//! `components` 是**保序列表**,里面既有加载器,也有 mappings(`net.fabricmc.intermediary` +//! 等)与 `org.lwjgl3` 这类非加载器组件——所以模型把**每一条组件**都留在 +//! [`PackManifest::loaders`] 里(顺序不动、原始 uid 在 [`PackLoader::raw`]、`kind` 能认就认), +//! 组件自身的 `important` / `dependencyOnly` / `cachedName` 落在 [`PackLoader::extra`](M115 +//! 调研 §字段缺口 3)。这样"加载器集合"是它的派生视图,而不是丢信息的收窄。 +//! +//! 也就是说:本格式**能探测、能导入**,**不能导出**——报告里已把这条写清楚。 + +use serde::Deserialize; +use serde_json::{Map, Value}; + +use super::archive::PackArchive; +use super::error::{PackError, PackResult}; +use super::format::PackFormat; +use super::manifest::{PackLoader, PackManifest, PackOverrides, PackSide}; + +/// 格式 id。 +pub const ID: &str = "multimc"; +/// 清单入口名。 +pub const METADATA_ENTRY: &str = "mmc-pack.json"; +/// 实例配置文件名(包名在这里)。 +pub const INSTANCE_ENTRY: &str = "instance.cfg"; +/// 实例内容目录(模组/配置都在里面)。 +pub const CONTENT_DIR: &str = ".minecraft"; +/// 游戏本体的组件 uid。 +pub const MINECRAFT_UID: &str = "net.minecraft"; + +/// 单例(进注册表用)。 +pub static MULTIMC: MultiMc = MultiMc; + +/// MultiMC / Prism 格式的值类型实现。 +#[derive(Debug, Clone, Copy, Default)] +pub struct MultiMc; + +/// `mmc-pack.json` 的原始形状。 +#[derive(Debug, Default, Deserialize)] +struct RawPack { + #[serde(rename = "formatVersion", default)] + format_version: Option, + #[serde(default)] + components: Vec, + #[serde(flatten)] + extra: Map, +} + +/// `components[]` 的一条。 +#[derive(Debug, Default, Deserialize)] +struct RawComponent { + #[serde(default)] + uid: Option, + #[serde(default)] + version: Option, + #[serde(flatten)] + extra: Map, +} + +impl PackFormat for MultiMc { + fn id(&self) -> &'static str { + ID + } + + fn label(&self) -> &'static str { + "MultiMC / Prism" + } + + fn detect(&self, pack: &PackArchive) -> Option<&'static str> { + let value = pack.read_json(METADATA_ENTRY).ok().flatten()?; + let object = value.as_object()?; + // 形状校验:必须有非空 components 数组(MultiMC 的组件图是它的身份证)。 + let matched = object + .get("components") + .and_then(Value::as_array) + .is_some_and(|components| !components.is_empty()); + matched.then_some(METADATA_ENTRY) + } + + fn read(&self, pack: &PackArchive) -> PackResult { + let value = pack + .read_json(METADATA_ENTRY)? + .ok_or_else(|| PackError::metadata(format!("包里没有 {METADATA_ENTRY}")))?; + let raw: RawPack = serde_json::from_value(value) + .map_err(|error| PackError::metadata(format!("{METADATA_ENTRY} 形状不符:{error}")))?; + + let mut minecraft = None; + let mut loaders: Vec = Vec::new(); + for component in &raw.components { + let Some(uid) = component.uid.as_deref() else { + continue; + }; + let Some(version) = component.version.as_deref() else { + continue; + }; + if uid == MINECRAFT_UID { + minecraft = Some(version.to_string()); + continue; + } + let mut loader = PackLoader::parse_multimc(uid, version, false); + if loader.kind.is_some() && !loaders.iter().any(|seen| seen.kind.is_some()) { + loader.primary = true; + } + loader.extra = component.extra.clone(); + loaders.push(loader); + } + + let name = instance_name(pack).unwrap_or_else(|| { + pack.root() + .file_stem() + .map(|stem| stem.to_string_lossy().to_string()) + .unwrap_or_default() + }); + + let mut overrides = Vec::new(); + let content = pack.override_files(CONTENT_DIR)?; + if pack.has_entry(CONTENT_DIR) || !content.is_empty() { + overrides.push(PackOverrides { + dir: CONTENT_DIR.to_string(), + side: PackSide::Both, + files: content, + }); + } + + Ok(PackManifest { + name, + version: None, + author: None, + summary: None, + format_version: raw.format_version.or(Some(1)), + game: Some("minecraft".to_string()), + minecraft, + loaders, + // MultiMC 没有远端条目:模组就是覆盖目录里的文件。 + files: Vec::new(), + overrides, + extra: raw.extra, + minecraft_extra: Map::new(), + }) + } + + fn render(&self, _manifest: &PackManifest) -> PackResult { + Err(PackError::unsupported( + "MultiMC 格式只支持导入:写回需要重组组件图与 patches/libraries,不在模型职责内", + ) + .with_hint("导出请选 Modrinth(.mrpack)或 CurseForge 格式")) + } +} + +/// 从 `instance.cfg` 的 `name=` 行取实例名(MultiMC 的 ini 很宽松,只认这一行)。 +fn instance_name(pack: &PackArchive) -> Option { + let text = pack.read_text(INSTANCE_ENTRY).ok().flatten()?; + text.lines() + .filter_map(|line| line.trim().strip_prefix("name=")) + .map(str::trim) + .find(|name| !name.is_empty()) + .map(str::to_string) +} diff --git a/crates/core/src/addon/pack/path.rs b/crates/core/src/addon/pack/path.rs new file mode 100644 index 0000000..323cd39 --- /dev/null +++ b/crates/core/src/addon/pack/path.rs @@ -0,0 +1,140 @@ +//! 包内路径规整:路径穿越**第一检**(第二检是 `zip` crate 的 `enclosed_name()`,见 `archive`)。 +//! +//! 判据不是"看起来安全",而是一条条写死的规则。任一条不满足就报 +//! `crate::addon::pack::error::PackErrorKind::Traversal`: +//! +//! 1. 空名字拒绝(`""` 与全 `/` 都不成路径); +//! 2. `\0` 拒绝(底层会被截断,产生"看得见查不着"的名字); +//! 3. `\` 拒绝——zip 规范要求 `/` 分隔,但 Win32 把 `\` 也当分隔符,于是 +//! `mods\..\..\evil` 在"按 `/` 切"的检查里是一个普通名字、在落盘时却跳三层; +//! 4. `:` 拒绝——盘符(`C:/x`)与 NTFS 交换数据流(`file:Zone.Identifier`)的共同记号; +//! 5. 前导 `/` 拒绝(绝对路径); +//! 6. 任何 `..` 段拒绝(不"抵消"后放行:`a/../b` 与 `../b` 一律拒,避免归一化差异); +//! 7. `.` 段与空段(`a//b`)**丢弃**(纯噪声,不是穿越手法)。 +//! +//! 规整后的名字一律 `/` 分隔、无前导斜杠、无 `.` / `..` 段,因此可以安全地 +//! `root.join(name)`——但**写盘前仍要再查一次**(`archive` 的 `sha1`/`read` 与 `format` 的 +//! `write` 都重新过一遍本模块),因为模型可能来自外部构造而非本模块的读者。 + +use super::error::{PackError, PackResult}; + +/// 规整一条**文件**条目名(目录条目请用 [`normalize_dir`])。 +/// +/// 结尾的 `/` 视为目录标记并被去掉;去掉后为空则拒绝。 +pub fn normalize_file(raw: &str) -> PackResult { + let text = normalize_text(raw)?; + if text.is_empty() { + return Err(PackError::traversal(format!("包内路径为空:{raw:?}"))); + } + Ok(text) +} + +/// 规整一条**目录**前缀(例如 CF 的 `overrides`、mrpack 的 `client-overrides`)。 +pub fn normalize_dir(raw: &str) -> PackResult { + let text = normalize_text(raw)?; + if text.is_empty() { + return Err(PackError::traversal(format!("包内目录为空:{raw:?}"))); + } + Ok(text) +} + +/// 文件名是否以 `/` 结尾(zip 的目录条目约定)。 +#[must_use] +pub fn has_trailing_slash(raw: &str) -> bool { + raw.ends_with('/') +} + +/// 规则 1–7 的实现。返回可能为空的规整串(由调用方判定"空"是否合法)。 +fn normalize_text(raw: &str) -> PackResult { + if raw.is_empty() { + return Err(PackError::traversal("包内路径为空")); + } + if raw.contains('\0') { + return Err(PackError::traversal(format!("包内路径含 NUL:{raw:?}"))); + } + if raw.contains('\\') { + return Err(PackError::traversal(format!( + "包内路径含反斜杠(Win32 会当分隔符):{raw:?}" + ))); + } + if raw.contains(':') { + return Err(PackError::traversal(format!( + "包内路径含冒号(盘符 / NTFS 数据流):{raw:?}" + ))); + } + if raw.starts_with('/') { + return Err(PackError::traversal(format!("包内路径是绝对路径:{raw:?}"))); + } + + let mut segments: Vec<&str> = Vec::new(); + for segment in raw.split('/') { + match segment { + "" | "." => continue, + ".." => { + return Err(PackError::traversal(format!( + "包内路径含 `..` 上跳:{raw:?}" + ))); + } + other => segments.push(other), + } + } + Ok(segments.join("/")) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::addon::pack::error::PackErrorKind; + + #[test] + fn keeps_plain_relative_names() { + assert_eq!(normalize_file("mods/jei.jar").unwrap(), "mods/jei.jar"); + assert_eq!( + normalize_file("config/a/b.toml").unwrap(), + "config/a/b.toml" + ); + // `.` 段与空段是噪声,丢掉而不是报错。 + assert_eq!(normalize_file("./mods//jei.jar").unwrap(), "mods/jei.jar"); + assert_eq!(normalize_dir("overrides/").unwrap(), "overrides"); + assert_eq!( + normalize_dir("client-overrides//").unwrap(), + "client-overrides" + ); + } + + #[test] + fn rejects_traversal_shapes() { + for raw in [ + "../evil.jar", + "mods/../../evil.jar", + "a/../b", + "/etc/passwd", + "//server/share", + "C:/Windows/evil.dll", + "mods\\..\\..\\evil.jar", + "mods/jei.jar:Zone.Identifier", + "mods/\0jei.jar", + "", + "/", + "./", + ] { + let error = normalize_file(raw).expect_err(raw); + assert_eq!( + error.kind(), + PackErrorKind::Traversal, + "应判为穿越,实际 {raw:?} → {error}" + ); + } + } + + #[test] + fn trailing_slash_marks_directories() { + assert!(has_trailing_slash("overrides/")); + assert!(!has_trailing_slash("overrides")); + // 目录标记不改变规整结果:`overrides/` 与 `overrides` 是同一个前缀。 + assert_eq!( + normalize_dir("overrides/").unwrap(), + normalize_dir("overrides").unwrap() + ); + } +} diff --git a/crates/core/src/addon/pack/tests/detect.rs b/crates/core/src/addon/pack/tests/detect.rs new file mode 100644 index 0000000..1cdab20 --- /dev/null +++ b/crates/core/src/addon/pack/tests/detect.rs @@ -0,0 +1,161 @@ +//! 探测优先级:顺序写死在注册表里,用例把"谁赢"和"为什么赢"都钉住。 +//! +//! 判据分三层,缺一层都可能被"看起来对"糊过去: +//! 1. 内置顺序本身([`PackRegistry::ids`]); +//! 2. 同一份包命中多格式时选谁(含 `also_matched` 记录的其它命中); +//! 3. **换一下注册顺序,赢家跟着换**——这才证明优先级来自注册表,而不是代码里散落的 if。 + +use super::{fixtures, temp_root, write_zip}; +use crate::addon::pack::archive::PackArchive; +use crate::addon::pack::curseforge::CURSEFORGE; +use crate::addon::pack::error::PackErrorKind; +use crate::addon::pack::format::{PackFormat, PackRegistry}; +use crate::addon::pack::modrinth::MODRINTH; +use crate::addon::pack::multimc::MULTIMC; + +const CF_MANIFEST: &[u8] = br#"{"manifestType":"minecraftModpack","manifestVersion":1, + "name":"hybrid pack","version":"1.0","minecraft":{"version":"1.20.1"},"files":[]}"#; + +const MR_INDEX: &[u8] = br#"{"game":"minecraft","formatVersion":1,"versionId":"1.0", + "name":"hybrid pack","files":[],"dependencies":{"minecraft":"1.20.1"}}"#; + +const MMC_PACK: &[u8] = + br#"{"formatVersion":1,"components":[{"uid":"net.minecraft","version":"1.20.1"}]}"#; + +/// 同时带三种身份标记的"不标准包"。 +fn hybrid_pack(tag: &str) -> std::path::PathBuf { + let target = temp_root(tag).join("hybrid.zip"); + write_zip( + &target, + &[ + ("manifest.json", CF_MANIFEST), + ("modrinth.index.json", MR_INDEX), + ("mmc-pack.json", MMC_PACK), + ("overrides/config/x.toml", &b"x = 1\n"[..]), + ], + ); + target +} + +#[test] +fn builtin_priority_is_pinned() { + assert_eq!( + PackRegistry::builtin().ids(), + vec!["curseforge", "modrinth", "multimc"], + "探测顺序是决策,不是实现细节:改了要连着报告与测试一起改" + ); +} + +#[test] +fn curseforge_wins_when_a_pack_carries_every_marker() { + let pack = PackArchive::open_default(hybrid_pack("detect-hybrid")).expect("打开混合包"); + let matched = PackRegistry::builtin().detect(&pack).expect("要能选出格式"); + assert_eq!(matched.format().id(), "curseforge"); + assert_eq!( + matched.also_matched().to_vec(), + vec!["modrinth", "multimc"], + "其余命中必须可见(调用方据此提示「这包不标准」)" + ); + assert!(matched.is_ambiguous()); +} + +#[test] +fn a_plain_manifest_json_does_not_claim_curseforge() { + // `manifest.json` 这名字太泛:形状不符就不能认领,否则 mrpack 会被抢走。 + let target = temp_root("detect-plain").join("plain.zip"); + write_zip( + &target, + &[ + ("manifest.json", &br#"{"hello":"world"}"#[..]), + ("modrinth.index.json", MR_INDEX), + ], + ); + let pack = PackArchive::open_default(&target).expect("打开包"); + let matched = PackRegistry::builtin().detect(&pack).expect("要能选出格式"); + assert_eq!(matched.format().id(), "modrinth"); + assert!(matched.also_matched().is_empty(), "CF 不该被「泛名」骗到"); +} + +#[test] +fn registration_order_decides_the_winner() { + // 同一份包同时有 CF 与 mrpack 标记:只换注册顺序,赢家就该换。 + let target = temp_root("detect-order").join("both.zip"); + write_zip( + &target, + &[ + ("manifest.json", CF_MANIFEST), + ("modrinth.index.json", MR_INDEX), + ], + ); + let pack = PackArchive::open_default(&target).expect("打开双标记包"); + + let builtin = PackRegistry::builtin() + .detect(&pack) + .expect("默认顺序要能选"); + assert_eq!(builtin.format().id(), "curseforge"); + assert_eq!(builtin.also_matched().to_vec(), vec!["modrinth"]); + + let reversed = PackRegistry::empty().with(&MODRINTH).with(&CURSEFORGE); + let matched = reversed.detect(&pack).expect("反序注册也要能选"); + assert_eq!( + matched.format().id(), + "modrinth", + "反序注册后 Modrinth 排第一,就该它赢" + ); + assert_eq!(matched.also_matched().to_vec(), vec!["curseforge"]); +} + +#[test] +fn unknown_package_is_rejected_with_the_list_of_tried_formats() { + let target = temp_root("detect-unknown").join("unknown.zip"); + write_zip(&target, &[("readme.txt", &b"nothing to see"[..])]); + let pack = PackArchive::open_default(&target).expect("打开包"); + assert!(PackRegistry::builtin().detect_all(&pack).is_empty()); + let error = PackRegistry::builtin() + .detect(&pack) + .expect_err("没有格式认领就必须报错"); + assert_eq!(error.kind(), PackErrorKind::Unsupported); + for id in ["curseforge", "modrinth", "multimc"] { + assert!( + error.message().contains(id), + "错误里要列出试过哪些格式,实际:{}", + error.message() + ); + } +} + +#[test] +fn multimc_needs_a_non_empty_component_graph() { + let target = temp_root("detect-mmc-empty").join("empty-mmc.zip"); + write_zip( + &target, + &[( + "mmc-pack.json", + &br#"{"formatVersion":1,"components":[]}"#[..], + )], + ); + let pack = PackArchive::open_default(&target).expect("打开包"); + assert_eq!( + MULTIMC.detect(&pack), + None, + "空 components 是空壳,不能当 MultiMC 实例" + ); + assert_eq!( + PackRegistry::builtin() + .detect(&pack) + .expect_err("不该有格式认领") + .kind(), + PackErrorKind::Unsupported + ); +} + +#[test] +fn multimc_instance_directory_is_detected() { + let root = fixtures::multimc("detect-mmc-dir"); + let pack = PackArchive::open_default(&root).expect("打开实例目录"); + let matched = PackRegistry::builtin() + .detect(&pack) + .expect("目录形式也要能探测"); + assert_eq!(matched.format().id(), "multimc"); + assert!(!matched.is_ambiguous()); +} diff --git a/crates/core/src/addon/pack/tests/extend.rs b/crates/core/src/addon/pack/tests/extend.rs new file mode 100644 index 0000000..cf16344 --- /dev/null +++ b/crates/core/src/addon/pack/tests/extend.rs @@ -0,0 +1,170 @@ +//! 扩展点判据:加一个假格式(**一个文件、一条注册**)就能被探测与读写, +//! 且不碰模型、不碰 CurseForge / Modrinth / MultiMC 任何一行。 +//! +//! 这里刻意不写"文档说它可扩展",而是真的实现一个 `toy.json` 格式走完整条链: +//! 注册 → 探测 → 读 → 渲染 → 写 → 再读。 + +use serde_json::{json, Value}; + +use super::{temp_root, write_file, write_zip}; +use crate::addon::pack::archive::PackArchive; +use crate::addon::pack::curseforge::CURSEFORGE; +use crate::addon::pack::error::{PackError, PackErrorKind, PackResult}; +use crate::addon::pack::format::{PackFormat, PackPayload, PackRegistry}; +use crate::addon::pack::manifest::{PackFile, PackManifest}; + +/// 测试专用假格式:`toy.json` = `{"name": …, "minecraft": …, "mods": ["mods/a.jar"]}`。 +/// +/// 全部实现加起来不到 40 行——这就是"社区加一个新格式"的代价上限。 +#[derive(Debug)] +struct ToyFormat; + +/// 注册表要的是 `&'static dyn PackFormat`,所以用 `static`。 +static TOY: ToyFormat = ToyFormat; + +impl PackFormat for ToyFormat { + fn id(&self) -> &'static str { + "toy" + } + + fn label(&self) -> &'static str { + "Toy(测试专用假格式)" + } + + fn metadata_entry(&self) -> Option<&'static str> { + 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 read(&self, pack: &PackArchive) -> PackResult { + let value = pack + .read_json("toy.json")? + .ok_or_else(|| PackError::metadata("没有 toy.json"))?; + let files = value + .get("mods") + .and_then(Value::as_array) + .map(|mods| { + mods.iter() + .filter_map(Value::as_str) + .map(PackFile::with_path) + .collect() + }) + .unwrap_or_default(); + Ok(PackManifest { + name: value + .get("name") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + minecraft: value + .get("minecraft") + .and_then(Value::as_str) + .map(str::to_string), + files, + ..PackManifest::default() + }) + } + + fn render(&self, manifest: &PackManifest) -> PackResult { + let mods: Vec<&str> = manifest + .files + .iter() + .filter_map(|file| file.path.as_deref()) + .collect(); + Ok(json!({ + "name": manifest.name, + "minecraft": manifest.minecraft, + "mods": mods, + }) + .to_string()) + } +} + +/// 造一个 toy 包(源目录形式,顺带能当导出 payload)。 +fn toy_pack(tag: &str) -> std::path::PathBuf { + let dir = temp_root(tag).join("toy"); + write_file( + &dir.join("toy.json"), + br#"{"name":"Toy Pack","minecraft":"1.21","mods":["mods/a.jar","mods/b.jar"]}"#, + ); + dir +} + +#[test] +fn a_new_format_needs_one_registration_line() { + let dir = toy_pack("extend-register"); + let pack = PackArchive::open_default(&dir).expect("打开 toy 包"); + + // ① 没注册时:内置三格式都不认它(证明它不是"顺手"被认出来的)。 + let error = PackRegistry::builtin() + .detect(&pack) + .expect_err("内置格式不该认领 toy 包"); + assert_eq!(error.kind(), PackErrorKind::Unsupported); + + // ② 加一行注册。 + let registry = PackRegistry::builtin().with(&TOY); + + // ③ 探测得到。 + let matched = registry.detect(&pack).expect("注册后就该探测到"); + assert_eq!(matched.format().id(), "toy"); + assert!(!matched.is_ambiguous()); + + // ④ 读得到。 + let manifest = matched.format().read(&pack).expect("读 toy 清单"); + assert_eq!(manifest.name, "Toy Pack"); + assert_eq!(manifest.minecraft.as_deref(), Some("1.21")); + assert_eq!(manifest.files.len(), 2); + + // ⑤ 渲染 + 写(默认 write 实现直接用 metadata_entry + render,不用重写一遍 zip 逻辑)。 + let rendered = matched.format().render(&manifest).expect("渲染 toy 清单"); + assert!(rendered.contains("Toy Pack")); + + let target = dir.with_file_name("toy.zip"); + matched + .format() + .write(&manifest, PackPayload::metadata_only(), &target) + .expect("导出 toy 包"); + + // ⑥ 再读回来必须一样。 + let written = PackArchive::open_default(&target).expect("打开导出的 toy 包"); + let reread = matched.format().read(&written).expect("再读 toy 清单"); + assert_eq!(reread, manifest); +} + +#[test] +fn appending_a_format_never_steals_existing_packs() { + // 同一份包同时有 CF 清单与 toy 清单。 + let root = temp_root("extend-priority"); + let target = root.join("both.zip"); + write_zip( + &target, + &[ + ( + "manifest.json", + &br#"{"manifestType":"minecraftModpack","manifestVersion":1, + "name":"real cf pack","minecraft":{"version":"1.20.1"},"files":[]}"#[..], + ), + ( + "toy.json", + &br#"{"name":"Toy shadow","minecraft":"1.21","mods":[]}"#[..], + ), + ], + ); + let pack = PackArchive::open_default(&target).expect("打开包"); + + // 追加到末尾 = 最低优先级:老包的行为不变,但歧义要看得见。 + let appended = PackRegistry::builtin().with(&TOY); + let matched = appended.detect(&pack).expect("探测"); + assert_eq!(matched.format().id(), "curseforge"); + assert_eq!(matched.also_matched().to_vec(), vec!["toy"]); + + // 反过来排到最前,就该 toy 赢——注册顺序就是优先级。 + let front = PackRegistry::empty().with(&TOY).with(&CURSEFORGE); + let matched = front.detect(&pack).expect("探测"); + assert_eq!(matched.format().id(), "toy"); + assert_eq!(matched.also_matched().to_vec(), vec!["curseforge"]); +} diff --git a/crates/core/src/addon/pack/tests/fixtures.rs b/crates/core/src/addon/pack/tests/fixtures.rs new file mode 100644 index 0000000..565f4df --- /dev/null +++ b/crates/core/src/addon/pack/tests/fixtures.rs @@ -0,0 +1,185 @@ +//! 本地夹具:CurseForge / Modrinth / MultiMC 三种格式各一个最小但**形状真实**的包。 +//! +//! # 来源声明(不许把"看起来像"当真实) +//! +//! | 夹具 | 来源 | 说明 | +//! |------|------|------| +//! | [`curseforge`] | **手造** | 字段取自 analyse/05 §2.2(HMCL `CurseManifest` / `CurseManifestFile`);内容与数值是编的 | +//! | [`modrinth`] | **手造** | 字段取自 analyse/05 §2.3(HMCL `ModrinthManifest`);`hashes` 里的摘要是**形状正确的占位值**,不对应任何真实文件 | +//! | [`multimc`] | **手造** | 字段取自 analyse/05 §2.6(HMCL `MultiMCModpackProvider`);组件表比真实 Prism 实例小得多 | +//! +//! **没有一个来自真实导出**:本任务环境离线、仓库里也没有真实整合包样本。真实导出物的字段 +//! 偏移风险只能等 M111/M115 拿到真包后回收(报告里已登记这条欠账)。 +//! +//! # 刻意造的坏文件(导入器不许崩、也不许悄悄丢掉) +//! +//! - CF:`overrides/config/broken.toml` —— 截断的 TOML(引号未闭合、段落未收尾); +//! - mrpack:`overrides/mods/half.jar` —— 只有半个 zip 头; +//! - MultiMC:`.minecraft/mods/broken.jar` —— 零字节。 +//! +//! 三种损坏都是**覆盖目录里的文件**:它们没有清单声明的摘要可比,读包时只能如实记下实际 +//! SHA-1/大小(三方合并的 base 侧就是这么来的),谁想"校验并修掉"必须另做判断。 + +use std::path::PathBuf; + +use super::{temp_root, write_file, zip_dir}; + +/// 一个既有源目录(可当导出 payload)又有打好的 zip 的夹具。 +pub(crate) struct Fixture { + /// 源目录(zip 的内容来源,也是 `PackPayload::from_root` 的根)。 + pub dir: PathBuf, + /// 打好的包。 + pub zip: PathBuf, +} + +/// CF 夹具的包名。 +pub(crate) const CF_NAME: &str = "M110 手造 CurseForge 包"; +/// CF 夹具里那个坏文件的内容。 +pub(crate) const CF_BROKEN: &[u8] = b"broken = \"unterminated\n[section\n"; +/// mrpack 夹具的包名。 +pub(crate) const MR_NAME: &str = "M110 手造 Modrinth 包"; +/// mrpack 夹具里那个半截 jar 的内容。 +pub(crate) const MR_HALF_JAR: &[u8] = b"PK\x03\x04half"; +/// MultiMC 夹具的实例名(写在 `instance.cfg`)。 +pub(crate) const MMC_NAME: &str = "M110 手造 MultiMC 实例"; + +/// 手造的 SHA-1 占位值(40 位十六进制)。 +const FAKE_SHA1: &str = "0123456789abcdef0123456789abcdef01234567"; +/// 手造的 SHA-256 占位值(64 位十六进制)。 +const FAKE_SHA256: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +/// 手造的 SHA-512 占位值(128 位十六进制)。 +const FAKE_SHA512: &str = concat!( + "0123456789abcdef", + "0123456789abcdef", + "0123456789abcdef", + "0123456789abcdef", + "0123456789abcdef", + "0123456789abcdef", + "0123456789abcdef", + "0123456789abcdef" +); + +const CF_MANIFEST: &str = r#"{ + "manifestType": "minecraftModpack", + "manifestVersion": 1, + "name": "M110 手造 CurseForge 包", + "version": "1.0.0", + "author": "AskaEth", + "overrides": "overrides", + "minecraft": { + "version": "1.20.1", + "modLoaders": [{"id": "forge-47.2.0", "primary": true}], + "recommendedRam": 4096 + }, + "files": [ + {"projectID": 238222, "fileID": 5102025, "required": true}, + {"projectID": 306612, "fileID": 4587454, "required": false} + ], + "customCommunityField": {"since": 2, "note": "社区新增字段,必须原样回写"} +} +"#; + +fn mrpack_index() -> String { + format!( + r#"{{ + "game": "minecraft", + "formatVersion": 1, + "versionId": "2.0.0", + "name": "{MR_NAME}", + "summary": "手造夹具:只验证形状与边界,不是真实导出", + "files": [ + {{ + "path": "mods/jei.jar", + "hashes": {{"sha1": "{FAKE_SHA1}", "sha256": "{FAKE_SHA256}", "sha512": "{FAKE_SHA512}"}}, + "env": {{"client": "required", "server": "unsupported"}}, + "downloads": ["https://cdn.modrinth.com/data/AANobbMI/versions/abc/jei.jar"], + "fileSize": 1234567, + "customFileField": "社区新增字段" + }}, + {{ + "path": "mods/optional.jar", + "hashes": {{"sha1": "{FAKE_SHA1}", "sha512": "{FAKE_SHA512}"}}, + "env": {{"client": "optional", "server": "unsupported"}}, + "downloads": ["https://cdn.modrinth.com/data/opt/versions/x/optional.jar"], + "fileSize": 11 + }} + ], + "dependencies": {{ + "minecraft": "1.20.1", + "fabric-loader": "0.15.11", + "parchment": "2023.09.03" + }}, + "customPackField": [1, 2, 3] +}} +"# + ) +} + +const MMC_PACK: &str = r#"{ + "formatVersion": 1, + "components": [ + {"uid": "net.minecraft", "version": "1.20.1", "important": true, "cachedName": "1.20.1"}, + {"uid": "net.fabricmc.fabric-loader", "version": "0.15.11"}, + {"uid": "org.lwjgl3", "version": "3.3.2"} + ], + "customComponentGraphField": true +} +"#; + +const MMC_INSTANCE: &str = "InstanceType=OneSix\nname=M110 手造 MultiMC 实例\niconKey=default\n"; + +/// CurseForge 夹具(`manifest.json` + `overrides/`,含一个截断的 TOML)。 +pub(crate) fn curseforge(tag: &str) -> Fixture { + let root = temp_root(tag); + let dir = root.join("src"); + write_file(&dir.join("manifest.json"), CF_MANIFEST.as_bytes()); + write_file( + &dir.join("overrides/mods/example.jar"), + b"PK\x03\x04 hand-made jar stub", + ); + write_file( + &dir.join("overrides/config/known.toml"), + b"key = \"value\"\n", + ); + write_file(&dir.join("overrides/config/broken.toml"), CF_BROKEN); + let zip = root.join("pack.zip"); + zip_dir(&dir, &zip); + Fixture { dir, zip } +} + +/// Modrinth 夹具(`modrinth.index.json` + 三个覆盖目录,含一个半截 jar)。 +pub(crate) fn modrinth(tag: &str) -> Fixture { + let root = temp_root(tag); + let dir = root.join("src"); + write_file(&dir.join("modrinth.index.json"), mrpack_index().as_bytes()); + write_file(&dir.join("overrides/mods/half.jar"), MR_HALF_JAR); + write_file(&dir.join("overrides/config/known.toml"), b"shared = true\n"); + write_file( + &dir.join("client-overrides/config/client-only.toml"), + b"client = true\n", + ); + write_file( + &dir.join("server-overrides/config/server.properties"), + b"online-mode=true\n", + ); + let zip = root.join("pack.mrpack"); + zip_dir(&dir, &zip); + Fixture { dir, zip } +} + +/// MultiMC 夹具:**目录形式**(真实形态就是实例目录,不是 zip),含一个零字节 jar。 +pub(crate) fn multimc(tag: &str) -> PathBuf { + let root = temp_root(tag).join("instance"); + write_file(&root.join("mmc-pack.json"), MMC_PACK.as_bytes()); + write_file(&root.join("instance.cfg"), MMC_INSTANCE.as_bytes()); + write_file( + &root.join(".minecraft/mods/example.jar"), + b"PK\x03\x04 hand-made jar stub", + ); + write_file(&root.join(".minecraft/mods/broken.jar"), b""); + write_file( + &root.join(".minecraft/config/known.toml"), + b"key = \"value\"\n", + ); + root +} diff --git a/crates/core/src/addon/pack/tests/mod.rs b/crates/core/src/addon/pack/tests/mod.rs new file mode 100644 index 0000000..bf3d5f0 --- /dev/null +++ b/crates/core/src/addon/pack/tests/mod.rs @@ -0,0 +1,101 @@ +//! `addon::pack` 的单元测试:夹具读回、未知字段往返、探测优先级、扩展点、恶意样本。 +//! +//! **全部离线**:没有网络调用,也没有真实整合包样本(见 `fixtures` 的来源声明)。 + +mod detect; +mod extend; +mod fixtures; +mod roundtrip; +mod safety; + +use std::fs::File; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +/// 本次进程内唯一的临时根目录(沿用仓库既有约定:不引 `tempfile`)。 +pub(crate) fn temp_root(tag: &str) -> PathBuf { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let unique = COUNTER.fetch_add(1, Ordering::Relaxed); + std::env::temp_dir().join(format!("ull-pack-{}-{tag}-{unique}", std::process::id())) +} + +/// 写一个夹具文件(自动建父目录)。 +pub(crate) fn write_file(path: &Path, bytes: &[u8]) { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).expect("建夹具目录"); + } + std::fs::write(path, bytes).expect("写夹具文件"); +} + +/// 把一个目录打成 zip(`Stored` 压缩,方便测试直接改字节)。 +pub(crate) fn zip_dir(source: &Path, target: &Path) { + if let Some(parent) = target.parent() { + std::fs::create_dir_all(parent).expect("建 zip 目录"); + } + let file = File::create(target).expect("建 zip"); + let mut writer = zip::ZipWriter::new(file); + let options = + zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); + let mut pending = vec![PathBuf::new()]; + while let Some(relative) = pending.pop() { + let dir = source.join(&relative); + let mut children: Vec<_> = std::fs::read_dir(&dir) + .expect("读夹具目录") + .collect::, _>>() + .expect("目录项"); + children.sort_by_key(std::fs::DirEntry::file_name); + for child in children { + let relative = relative.join(child.file_name()); + if child.file_type().expect("夹具类型").is_dir() { + pending.push(relative); + continue; + } + let bytes = std::fs::read(child.path()).expect("读夹具文件"); + let entry = relative.to_string_lossy().replace('\\', "/"); + writer.start_file(entry, options).expect("写 zip 条目"); + writer.write_all(&bytes).expect("写 zip 内容"); + } + } + writer.finish().expect("收尾 zip"); +} + +/// 用一张显式条目表打 zip(恶意样本用)。 +pub(crate) fn write_zip(target: &Path, entries: &[(&str, &[u8])]) { + if let Some(parent) = target.parent() { + std::fs::create_dir_all(parent).expect("建 zip 目录"); + } + let file = File::create(target).expect("建 zip"); + let mut writer = zip::ZipWriter::new(file); + let options = + zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); + for (name, bytes) in entries { + writer.start_file(*name, options).expect("写 zip 条目"); + writer.write_all(bytes).expect("写 zip 内容"); + } + writer.finish().expect("收尾 zip"); +} + +/// 打一个带**符号链接条目**的 zip(`S_IFLNK` + 目标当内容,与真实恶意包同构)。 +pub(crate) fn write_zip_with_symlink( + target: &Path, + entries: &[(&str, &[u8])], + link_name: &str, + link_target: &str, +) { + if let Some(parent) = target.parent() { + std::fs::create_dir_all(parent).expect("建 zip 目录"); + } + let file = File::create(target).expect("建 zip"); + let mut writer = zip::ZipWriter::new(file); + let options = + zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); + for (name, bytes) in entries { + writer.start_file(*name, options).expect("写 zip 条目"); + writer.write_all(bytes).expect("写 zip 内容"); + } + writer + .add_symlink(link_name, link_target, options) + .expect("写符号链接条目"); + writer.finish().expect("收尾 zip"); +} diff --git a/crates/core/src/addon/pack/tests/roundtrip.rs b/crates/core/src/addon/pack/tests/roundtrip.rs new file mode 100644 index 0000000..a57bafa --- /dev/null +++ b/crates/core/src/addon/pack/tests/roundtrip.rs @@ -0,0 +1,299 @@ +//! 读回模型 + "导出后再读"的往返一致性:**未知字段不丢**就在这些断言里被钉住。 + +use serde_json::json; + +use super::fixtures; +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, PackRegistry}; +use crate::addon::pack::manifest::{EnvSupport, PackManifest, PackSide}; +use crate::addon::pack::modrinth::MODRINTH; +use crate::addon::pack::multimc::MULTIMC; +use crate::addon::LoaderKind; +use crate::download::{verify, HashAlgo}; + +fn sha1_hex(bytes: &[u8]) -> String { + verify::digest_bytes(HashAlgo::Sha1, bytes) +} + +#[test] +fn curseforge_reads_into_model() { + let fixture = fixtures::curseforge("cf-read"); + let pack = PackArchive::open_default(&fixture.zip).expect("打开 CF 夹具"); + let manifest = CURSEFORGE.read(&pack).expect("读 CF 清单"); + + assert_eq!(manifest.name, fixtures::CF_NAME); + assert_eq!(manifest.version.as_deref(), Some("1.0.0")); + assert_eq!(manifest.author.as_deref(), Some("AskaEth")); + assert_eq!(manifest.summary, None, "CF 没有 summary 字段"); + assert_eq!(manifest.minecraft.as_deref(), Some("1.20.1")); + assert_eq!(manifest.game, None, "CF 没有 game 字段"); + assert_eq!(manifest.format_version, Some(1)); + + assert_eq!(manifest.loaders.len(), 1); + let loader = &manifest.loaders[0]; + assert_eq!(loader.raw, "forge-47.2.0"); + assert_eq!(loader.kind, Some(LoaderKind::Forge)); + assert_eq!(loader.version.as_deref(), Some("47.2.0")); + assert!(loader.primary, "primary=true 必须保住"); + + // CF 官方 files[] 没有路径/URL/摘要:模型如实记成 None,不猜。 + assert_eq!(manifest.files.len(), 2); + let required = &manifest.files[0]; + assert_eq!(required.project_id, Some(238222)); + assert_eq!(required.file_id, Some(5102025)); + assert_eq!(required.path, None); + assert_eq!(required.client, EnvSupport::Required); + assert_eq!(required.key(), "curseforge:238222:5102025"); + let optional = &manifest.files[1]; + assert_eq!( + optional.client, + EnvSupport::Optional, + "required=false → Optional" + ); + assert_eq!(optional.server, EnvSupport::Optional); + + // 覆盖目录:3 个文件,含那个刻意截断的 TOML(它没有清单摘要可比,只能记实测值)。 + assert_eq!(manifest.overrides.len(), 1); + let group = &manifest.overrides[0]; + assert_eq!(group.dir, "overrides"); + assert_eq!(group.side, PackSide::Both); + assert_eq!(group.files.len(), 3); + let broken = group.find("config/broken.toml").expect("坏文件也要进清单"); + assert_eq!(broken.size, fixtures::CF_BROKEN.len() as u64); + assert_eq!(broken.sha1, sha1_hex(fixtures::CF_BROKEN)); + + // 未知字段:顶层与 `minecraft` 对象各一处。 + assert_eq!(manifest.extra["customCommunityField"]["since"], json!(2)); + assert_eq!(manifest.minecraft_extra["recommendedRam"], json!(4096)); +} + +#[test] +fn curseforge_export_round_trips_unknown_fields() { + let fixture = fixtures::curseforge("cf-roundtrip"); + let pack = PackArchive::open_default(&fixture.zip).expect("打开 CF 夹具"); + let manifest = CURSEFORGE.read(&pack).expect("读 CF 清单"); + + let target = fixture.zip.with_file_name("rewritten.zip"); + CURSEFORGE + .write(&manifest, PackPayload::from_root(&fixture.dir), &target) + .expect("导出 CF 包"); + + let written = PackArchive::open_default(&target).expect("打开导出的 CF 包"); + let reread = CURSEFORGE.read(&written).expect("再读导出的 CF 包"); + assert_eq!(reread, manifest, "导出→再读必须逐字段相等(含未知字段)"); + assert_eq!( + reread.extra["customCommunityField"]["note"], + json!("社区新增字段,必须原样回写") + ); + assert_eq!(reread.minecraft_extra["recommendedRam"], json!(4096)); +} + +#[test] +fn modrinth_reads_into_model() { + let fixture = fixtures::modrinth("mr-read"); + let pack = PackArchive::open_default(&fixture.zip).expect("打开 mrpack 夹具"); + let manifest = MODRINTH.read(&pack).expect("读 mrpack 清单"); + + assert_eq!(manifest.name, fixtures::MR_NAME); + assert_eq!(manifest.version.as_deref(), Some("2.0.0")); + assert_eq!( + manifest.summary.as_deref(), + Some("手造夹具:只验证形状与边界,不是真实导出") + ); + assert_eq!(manifest.game.as_deref(), Some("minecraft")); + assert_eq!(manifest.minecraft.as_deref(), Some("1.20.1")); + assert_eq!(manifest.author, None, "mrpack 没有 author 字段"); + + // `dependencies` 里非 minecraft 的每条都成一条加载器依赖(认不出的也保留)。 + assert_eq!(manifest.loaders.len(), 2); + let fabric = manifest + .loader(LoaderKind::Fabric) + .expect("要认出 fabric-loader"); + assert_eq!(fabric.raw, "fabric-loader"); + assert_eq!(fabric.version.as_deref(), Some("0.15.11")); + assert!(fabric.primary); + let parchment = manifest + .loaders + .iter() + .find(|loader| loader.raw == "parchment") + .expect("社区键也要保留"); + assert_eq!(parchment.kind, None); + assert_eq!(parchment.version.as_deref(), Some("2023.09.03")); + + assert_eq!(manifest.files.len(), 2); + let jei = &manifest.files[0]; + assert_eq!(jei.path.as_deref(), Some("mods/jei.jar")); + assert_eq!(jei.size, Some(1_234_567)); + assert_eq!(jei.client, EnvSupport::Required); + assert_eq!(jei.server, EnvSupport::Unsupported); + assert_eq!(jei.downloads.len(), 1); + assert!(jei.hashes.contains_key("sha256"), "整张摘要表都要留着"); + assert!(jei.sha512().is_some()); + assert!(jei.key().starts_with("modrinth:")); + assert_eq!(jei.extra["customFileField"], json!("社区新增字段")); + let optional = &manifest.files[1]; + assert!(optional.client.is_optional(), "env.client=optional 要保住"); + + // 三个覆盖目录各成一侧。 + assert_eq!(manifest.overrides.len(), 3); + let both = manifest.overrides_for(PackSide::Both).expect("overrides"); + assert_eq!(both.files.len(), 2); + let client = manifest + .overrides_for(PackSide::Client) + .expect("client-overrides"); + assert_eq!(client.files.len(), 1); + let server = manifest + .overrides_for(PackSide::Server) + .expect("server-overrides"); + assert_eq!(server.files.len(), 1); + let half = both.find("mods/half.jar").expect("半截 jar 也要进清单"); + assert_eq!(half.size, fixtures::MR_HALF_JAR.len() as u64); + assert_eq!(half.sha1, sha1_hex(fixtures::MR_HALF_JAR)); + + assert_eq!(manifest.extra["customPackField"], json!([1, 2, 3])); +} + +#[test] +fn modrinth_export_round_trips_unknown_fields() { + let fixture = fixtures::modrinth("mr-roundtrip"); + let pack = PackArchive::open_default(&fixture.zip).expect("打开 mrpack 夹具"); + let manifest = MODRINTH.read(&pack).expect("读 mrpack 清单"); + + let target = fixture.zip.with_file_name("rewritten.mrpack"); + MODRINTH + .write(&manifest, PackPayload::from_root(&fixture.dir), &target) + .expect("导出 mrpack"); + + let written = PackArchive::open_default(&target).expect("打开导出的 mrpack"); + let reread = MODRINTH.read(&written).expect("再读导出的 mrpack"); + assert_eq!(reread, manifest, "导出→再读必须逐字段相等(含未知字段)"); + assert_eq!(reread.extra["customPackField"], json!([1, 2, 3])); +} + +#[test] +fn multimc_reads_instance_directory() { + let root = fixtures::multimc("mmc-read"); + let pack = PackArchive::open_default(&root).expect("打开 MultiMC 实例目录"); + assert!(pack.is_dir_source()); + let manifest = MULTIMC.read(&pack).expect("读 MultiMC 清单"); + + assert_eq!(manifest.name, fixtures::MMC_NAME, "包名来自 instance.cfg"); + assert_eq!(manifest.minecraft.as_deref(), Some("1.20.1")); + assert_eq!(manifest.game.as_deref(), Some("minecraft")); + assert_eq!(manifest.format_version, Some(1)); + assert_eq!(manifest.version, None, "MultiMC 的实例没有包版本"); + + let fabric = manifest + .loader(LoaderKind::Fabric) + .expect("组件 uid 要翻译成加载器"); + assert_eq!(fabric.raw, "net.fabricmc.fabric-loader"); + assert_eq!(fabric.version.as_deref(), Some("0.15.11")); + assert!(fabric.primary); + assert!( + manifest + .loaders + .iter() + .any(|loader| loader.raw == "org.lwjgl3"), + "认不出的组件也要保留" + ); + + assert!(manifest.files.is_empty(), "MultiMC 没有远端条目"); + let content = manifest + .overrides_for(PackSide::Both) + .expect(".minecraft 当覆盖目录"); + assert_eq!(content.dir, ".minecraft"); + let broken = content + .find("mods/broken.jar") + .expect("零字节 jar 也要进清单"); + assert_eq!(broken.size, 0); + assert_eq!(broken.sha1, sha1_hex(b"")); + assert_eq!(content.files.len(), 3); + assert_eq!(manifest.extra["customComponentGraphField"], json!(true)); +} + +#[test] +fn multimc_export_is_refused() { + let root = super::temp_root("mmc-write"); + let target = root.join("out.zip"); + let manifest = PackManifest::new("whatever"); + let error = MULTIMC + .write(&manifest, PackPayload::metadata_only(), &target) + .expect_err("MultiMC 格式不支持导出"); + assert_eq!(error.kind(), PackErrorKind::Unsupported); + assert!(!target.exists(), "拒绝导出时不该留下半个文件"); +} + +#[test] +fn registry_read_reports_matched_format() { + let fixture = fixtures::curseforge("registry-read"); + let pack = PackArchive::open_default(&fixture.zip).expect("打开 CF 夹具"); + let (format, manifest) = PackRegistry::builtin().read(&pack).expect("探测 + 读"); + assert_eq!(format.id(), "curseforge"); + assert_eq!(manifest.name, fixtures::CF_NAME); +} + +#[test] +fn curseforge_without_name_is_a_metadata_error() { + let root = super::temp_root("cf-noname"); + let target = root.join("nope.zip"); + super::write_zip( + &target, + &[( + "manifest.json", + &br#"{"manifestType":"minecraftModpack","minecraft":{"version":"1.20.1"}}"#[..], + )], + ); + let pack = PackArchive::open_default(&target).expect("zip 本身可读"); + assert_eq!( + CURSEFORGE.detect(&pack), + Some("manifest.json"), + "形状对得上就该被探测到" + ); + let error = CURSEFORGE.read(&pack).expect_err("缺 name 必须报错"); + assert_eq!(error.kind(), PackErrorKind::Metadata); +} + +#[test] +fn disabled_entries_export_as_optional_on_both_formats() { + // 禁用位(M115 调研 §字段缺口 2)在两边的落法不同,且都不该靠"猜": + // CF → `required: false`;mrpack → `env.client: optional`(D10 的映射)。 + let cf = fixtures::curseforge("disabled-cf"); + let pack = PackArchive::open_default(&cf.zip).expect("打开 CF 夹具"); + let mut manifest = CURSEFORGE.read(&pack).expect("读 CF 清单"); + assert!(!manifest.files[0].disabled, "读包不会凭空造出禁用位"); + manifest.files[0].disabled = true; + let target = cf.zip.with_file_name("disabled.zip"); + CURSEFORGE + .write(&manifest, PackPayload::from_root(&cf.dir), &target) + .expect("导出 CF"); + let written = PackArchive::open_default(&target).expect("打开导出的 CF 包"); + let reread = CURSEFORGE.read(&written).expect("再读 CF"); + assert_eq!( + reread.files[0].client, + EnvSupport::Optional, + "CF 用 required=false 表达禁用" + ); + + let mr = fixtures::modrinth("disabled-mr"); + let pack = PackArchive::open_default(&mr.zip).expect("打开 mrpack 夹具"); + let mut manifest = MODRINTH.read(&pack).expect("读 mrpack 清单"); + assert!( + !manifest.files[1].disabled, + "env.client=optional 是「可选」,不是「禁用」:两个事实不合并" + ); + manifest.files[0].disabled = true; + let target = mr.zip.with_file_name("disabled.mrpack"); + MODRINTH + .write(&manifest, PackPayload::from_root(&mr.dir), &target) + .expect("导出 mrpack"); + let written = PackArchive::open_default(&target).expect("打开导出的 mrpack"); + let reread = MODRINTH.read(&written).expect("再读 mrpack"); + assert_eq!(reread.files[0].client, EnvSupport::Optional); + assert_eq!( + reread.files[0].path.as_deref(), + Some("mods/jei.jar"), + "禁用不靠路径后缀表达(与 D10 的映射一致)" + ); +} diff --git a/crates/core/src/addon/pack/tests/safety.rs b/crates/core/src/addon/pack/tests/safety.rs new file mode 100644 index 0000000..252d0c3 --- /dev/null +++ b/crates/core/src/addon/pack/tests/safety.rs @@ -0,0 +1,274 @@ +//! 恶意样本:每条安全判据都配一条"会失败的包",而不是靠注释保证。 +//! +//! 覆盖:路径穿越四类形状(`..` / 绝对路径 / 反斜杠 / 盘符)、符号链接条目、条目数上限、 +//! 解压总体积上限、单条目上限、规整后重名、篡改载荷(CRC 不符)、以及**清单字段里的**穿越 +//! (mrpack `files[].path`、CF `overrides`)。 + +use std::path::{Path, PathBuf}; + +use super::{temp_root, write_zip, write_zip_with_symlink}; +use crate::addon::pack::archive::{PackArchive, PackLimits}; +use crate::addon::pack::curseforge::CURSEFORGE; +use crate::addon::pack::error::PackErrorKind; +use crate::addon::pack::format::PackFormat; +use crate::addon::pack::manifest::{PackLoader, PackManifest, PackOverrides, PackSide}; +use crate::addon::pack::modrinth::MODRINTH; + +/// 打一个只有一条恶意条目的 zip。 +fn single_entry(tag: &str, name: &str) -> PathBuf { + let target = temp_root(tag).join("evil.zip"); + write_zip(&target, &[(name, &b"payload"[..])]); + target +} + +fn open_error(target: &Path) -> crate::addon::pack::error::PackError { + PackArchive::open_default(target).expect_err("恶意包必须被拒") +} + +#[test] +fn rejects_parent_directory_entries() { + let target = single_entry("trav-parent", "../evil.txt"); + let error = open_error(&target); + assert_eq!( + error.kind(), + PackErrorKind::Traversal, + "{}", + error.message() + ); +} + +#[test] +fn rejects_absolute_entries() { + let target = single_entry("trav-absolute", "/etc/passwd"); + let error = open_error(&target); + assert_eq!( + error.kind(), + PackErrorKind::Traversal, + "{}", + error.message() + ); +} + +#[test] +fn rejects_backslash_separators() { + // Win32 把 `\` 当分隔符:按 `/` 切是"一个普通名字",落盘却跳三层。 + let target = single_entry("trav-backslash", "mods\\..\\..\\evil.jar"); + let error = open_error(&target); + assert_eq!( + error.kind(), + PackErrorKind::Traversal, + "{}", + error.message() + ); +} + +#[test] +fn rejects_drive_letter_and_ads_entries() { + for (tag, name) in [ + ("trav-drive", "C:/Windows/evil.dll"), + ("trav-ads", "mods/jei.jar:Zone.Identifier"), + ] { + let target = single_entry(tag, name); + let error = open_error(&target); + assert_eq!( + error.kind(), + PackErrorKind::Traversal, + "{name}: {}", + error.message() + ); + } +} + +#[test] +fn rejects_symlink_entries() { + let target = temp_root("symlink").join("link.zip"); + write_zip_with_symlink( + &target, + &[("readme.txt", &b"x"[..])], + "mods/evil.jar", + "../../../etc/passwd", + ); + let error = open_error(&target); + assert_eq!(error.kind(), PackErrorKind::Symlink, "{}", error.message()); +} + +#[test] +fn rejects_too_many_entries() { + let target = temp_root("limit-count").join("many.zip"); + write_zip( + &target, + &[ + ("a.txt", &b"1"[..]), + ("b.txt", &b"2"[..]), + ("c.txt", &b"3"[..]), + ("d.txt", &b"4"[..]), + ], + ); + let error = PackArchive::open(&target, PackLimits::new(3, 1024 * 1024, 1024 * 1024)) + .expect_err("条目数超限必须拒"); + assert_eq!(error.kind(), PackErrorKind::Limits, "{}", error.message()); +} + +#[test] +fn rejects_uncompressed_volume_over_limit() { + let target = temp_root("limit-volume").join("big.zip"); + let payload = vec![b'a'; 4096]; + write_zip( + &target, + &[("a.bin", payload.as_slice()), ("b.bin", payload.as_slice())], + ); + let error = PackArchive::open(&target, PackLimits::new(16, 4096, 4096)) + .expect_err("解压总体积超限必须拒"); + assert_eq!(error.kind(), PackErrorKind::Limits, "{}", error.message()); +} + +#[test] +fn rejects_a_single_oversized_entry() { + let target = temp_root("limit-entry").join("big.zip"); + let payload = vec![b'a'; 2048]; + write_zip(&target, &[("a.bin", payload.as_slice())]); + let error = PackArchive::open(&target, PackLimits::new(16, 1024 * 1024, 512)) + .expect_err("单条目超限必须拒"); + assert_eq!(error.kind(), PackErrorKind::Limits, "{}", error.message()); +} + +#[test] +fn rejects_names_that_collide_after_normalization() { + // `./a.txt` 与 `a.txt` 规整后同名:留着就是"读到的和落盘的"不是同一个文件的经典混淆。 + let target = temp_root("dup-name").join("dup.zip"); + write_zip(&target, &[("./a.txt", &b"1"[..]), ("a.txt", &b"2"[..])]); + let error = open_error(&target); + assert_eq!(error.kind(), PackErrorKind::Metadata, "{}", error.message()); +} + +#[test] +fn a_tampered_payload_is_not_silently_accepted() { + // 中央目录没问题、数据被改过(CRC 不符):打开能过,读内容必须报错。 + const MARKER: &[u8] = b"UNIQUE-PAYLOAD-MARKER"; + let target = temp_root("tampered").join("tampered.zip"); + write_zip( + &target, + &[ + ( + "manifest.json", + br#"{"manifestType":"minecraftModpack","manifestVersion":1, + "name":"tampered pack","minecraft":{"version":"1.20.1"},"files":[], + "overrides":"overrides"}"#, + ), + ("overrides/config/bad.toml", MARKER), + ], + ); + let mut bytes = std::fs::read(&target).expect("读 zip"); + let at = bytes + .windows(MARKER.len()) + .position(|window| window == MARKER) + .expect("找得到载荷"); + bytes[at] = b'X'; + std::fs::write(&target, bytes).expect("写回 zip"); + + let pack = PackArchive::open_default(&target).expect("中央目录完好,打开该成功"); + let error = pack + .read("overrides/config/bad.toml") + .expect_err("载荷被改过,读内容必须失败"); + assert!( + matches!(error.kind(), PackErrorKind::Io | PackErrorKind::Metadata), + "实际大类:{:?}({})", + error.kind(), + error.message() + ); + // 整包读取会算覆盖文件的 SHA-1,因此也必须失败,而不是"算出一个错的哈希"。 + assert!(CURSEFORGE.read(&pack).is_err()); +} + +#[test] +fn mrpack_file_path_traversal_is_rejected() { + let target = temp_root("mr-trav").join("evil.mrpack"); + let index = r#"{"game":"minecraft","formatVersion":1,"versionId":"1.0","name":"evil pack", + "files":[{"path":"../evil.jar", + "hashes":{"sha512":"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"}, + "downloads":["https://example.invalid/evil.jar"]}], + "dependencies":{"minecraft":"1.20.1"}}"#; + write_zip(&target, &[("modrinth.index.json", index.as_bytes())]); + let pack = PackArchive::open_default(&target).expect("zip 本身可读"); + let error = MODRINTH.read(&pack).expect_err("清单里的穿越路径必须拒"); + assert_eq!( + error.kind(), + PackErrorKind::Traversal, + "{}", + error.message() + ); +} + +#[test] +fn curseforge_overrides_dir_traversal_is_rejected() { + let target = temp_root("cf-trav").join("evil.zip"); + write_zip( + &target, + &[( + "manifest.json", + br#"{"manifestType":"minecraftModpack","manifestVersion":1,"name":"evil pack", + "minecraft":{"version":"1.20.1"},"files":[],"overrides":"../../etc"}"#, + )], + ); + let pack = PackArchive::open_default(&target).expect("zip 本身可读"); + let error = CURSEFORGE + .read(&pack) + .expect_err("overrides 目录指向包外必须拒"); + assert_eq!( + error.kind(), + PackErrorKind::Traversal, + "{}", + error.message() + ); +} + +#[test] +fn write_side_path_check_catches_hand_built_models() { + // 模型可能不是我们读出来的(UI/AI 手搓):导出前必须再查一遍。 + let mut manifest = PackManifest::new("手搓模型"); + manifest.overrides.push(PackOverrides { + dir: "overrides".to_string(), + side: PackSide::Both, + files: Vec::new(), + }); + manifest + .loaders + .push(PackLoader::parse_curse("forge-47.2.0", true)); + assert!(manifest.check_paths().is_ok()); + + manifest.overrides[0].dir = "../escape".to_string(); + assert_eq!( + manifest + .check_paths() + .expect_err("越界目录必须被写侧检查拦下") + .kind(), + PackErrorKind::Traversal + ); +} + +#[test] +fn missing_package_is_an_io_error() { + let target = temp_root("missing").join("does-not-exist.zip"); + let error = open_error(&target); + assert_eq!(error.kind(), PackErrorKind::Io, "{}", error.message()); +} + +#[test] +fn directory_source_rejects_symlinks_when_the_platform_can_make_them() { + // Windows 建符号链接需要开发者模式/管理员:建不出来就跳过,但绝不静默当通过。 + #[cfg(unix)] + { + let root = temp_root("dir-symlink").join("instance"); + std::fs::create_dir_all(&root).expect("建目录"); + std::fs::write(root.join("mmc-pack.json"), b"{}").expect("写文件"); + std::os::unix::fs::symlink("/etc/passwd", root.join("escape")).expect("建符号链接"); + let error = open_error(&root); + assert_eq!(error.kind(), PackErrorKind::Symlink, "{}", error.message()); + } + #[cfg(not(unix))] + { + eprintln!( + "跳过:当前平台建符号链接需要额外权限(目录侧的检查逻辑与 zip 侧共用同一条判据)" + ); + } +}