Files
Unlucky-Launcher/crates/core/src/addon/pack/manifest.rs
T
AskaEth b34e86bba5 feat(core): 整合包格式无关模型与格式扩展点(M110)
从零建 `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` 里声明为手造,不冒充真实导出。
2026-11-04 00:20:24 +08:00

351 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 与格式无关的中间模型 [`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<Self> {
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<LoaderKind>,
/// 加载器版本。
pub version: Option<String>,
/// 是否主加载器(CF `primary`;其它格式取"第一条能认出来的")。
pub primary: bool,
/// 该加载器条目的未知字段。
pub extra: Map<String, Value>,
}
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<String> {
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<LoaderKind>, Option<String>) {
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<LoaderKind> {
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<String>,
/// 作者(CF `author`;mrpack 没有这一项)。
pub author: Option<String>,
/// 简介(mrpack `summary`;CF 没有这一项)。
pub summary: Option<String>,
/// 清单 schema 版本(CF `manifestVersion` / mrpack `formatVersion` / MultiMC `formatVersion`)。
pub format_version: Option<i64>,
/// 平台标识(mrpack `game`,恒为 `minecraft`;CF 隐式,`None`)。
pub game: Option<String>,
/// 游戏版本(`1.20.1`)。
pub minecraft: Option<String>,
/// 加载器依赖集合。
pub loaders: Vec<PackLoader>,
/// 远端文件条目(不含覆盖目录里的文件)。
pub files: Vec<PackFile>,
/// 覆盖目录清单。
pub overrides: Vec<PackOverrides>,
/// 清单顶层的未知字段(原样保存、导出时回写)。
pub extra: Map<String, Value>,
/// CF `minecraft` 对象里的未知字段(mrpack/MultiMC 没有这一层)。
pub minecraft_extra: Map<String, Value>,
}
impl PackManifest {
/// 建一个只有包名的空清单。
#[must_use]
pub fn new(name: impl Into<String>) -> 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<Item = (PackSide, &OverrideFile)> {
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<Item = &PackFile> {
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<String> {
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
}
}