Unlucky Launcher v0.2.2
CI / runtime smoke (macos arm64, probe) (push) Skipped
CI / fmt (rustfmt --check) (push) Canceled after 0s
CI / clippy (ubuntu-latest) (push) Canceled after 0s
CI / clippy (windows-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
CI / build release (ubuntu-latest) (push) Canceled after 0s
CI / build release (windows-latest) (push) Canceled after 0s
CI / runtime smoke (ubuntu x64, xvfb) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Release / 选槽位(默认两个 Windows) (push) Canceled after 0s
Release / build (${{ matrix.slot }}) (push) Canceled after 0s
Release / GitHub Release (push) Canceled after 0s

This commit is contained in:
AskaEth committed 2026-11-07 08:46:11 +08:00
commit 1105a86448
1128 files changed
+217164

No files matched your search

+74
View File
@@ -0,0 +1,74 @@
[package]
name = "unlucky-core"
description = "Unlucky-Launcher 领域库:纯逻辑,零 UI 依赖(D3 架构纪律)"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
repository.workspace = true
rust-version.workspace = true
publish.workspace = true
# 零 UI 依赖是硬约束:这里只允许纯逻辑/协议/IO 类依赖(serde/tokio/reqwest 等后续按模块加),
# 任何时候都不得出现 slint / egui / iced / winit / gtk / javafx —— CI 的 core-no-ui job 守门。
[dependencies]
# M15(core::diagnose)预接线:59 条规则正则、规则 YAML 解析、诊断输出序列化。
# 真正使用方是 crates/core/src/diagnose/ 下的 collectors/engine/hs_err/loader/rules(M15 落地)。
regex.workspace = true
serde.workspace = true
serde_json.workspace = true
serde_yaml_ng.workspace = true
# M13(core::download)预接线:HTTP / 异步运行时 / 流 / 哈希校验。
reqwest.workspace = true
tokio.workspace = true
futures-util.workspace = true
sha1.workspace = true
sha2.workspace = true
zip.workspace = true
# M31(core::auth):离线 UUID = MD5 v3(与 HMCL / 服务端 nameUUIDFromBytes 对拍)。
md-5.workspace = true
# M33(core::game::java)预接线:tar.gz 解压(Temurin 的 Linux/macOS 产物)。
flate2.workspace = true
tar.workspace = true
# M13 的 8 个下载测试 target(文件由 M13 阶段 B 用真实用例覆盖;脚手架期是占位)。
# `tests/download/support/mod.rs` 是被各 test include 的公共夹具,不单独注册。
[[test]]
name = "backoff"
path = "tests/download/backoff_test.rs"
[[test]]
name = "health"
path = "tests/download/health_test.rs"
[[test]]
name = "mirrors"
path = "tests/download/mirrors_test.rs"
[[test]]
name = "region"
path = "tests/download/region_test.rs"
[[test]]
name = "cache"
path = "tests/download/cache_test.rs"
[[test]]
name = "chunked"
path = "tests/download/chunked_test.rs"
[[test]]
name = "resume"
path = "tests/download/resume_test.rs"
[[test]]
name = "engine_mock"
path = "tests/download/engine_mock_test.rs"
[[test]]
name = "partfile"
path = "tests/download/partfile_test.rs"
[lints]
workspace = true
+65
View File
@@ -0,0 +1,65 @@
//! 整合包**导出**(M112):实例目录 → `.mrpack`(优先)/ CurseForge `manifest.json`(次之)。
//!
//! 设计依据:`DevDocs/PlanDoc/08-mod-and-recommend.md` §2(D10)、
//! `DevDocs/SubAgentsTalk/addon-整合包导入导出对接要点.md` §4(导出八条),格式模型与安全闸
//! 是 M110 的 [`crate::addon::pack`],本模块**只消费,不重写**。
//!
//! # 一句话口径:不认识的 mod 不能丢、不能编
//!
//! 用户原话(M112 任务书):「要么能证明它可被远程替代,要么就必须内联进 `overrides/` 并在
//! 结果里逐条报告」。落到代码就是三分类,**没有第四类**:
//!
//! | 分类 | 判据 | 产物 |
//! |------|------|------|
//! | 远程引用([`FileClass::Remote`]) | 目标格式**能表达**它 + 某个目录([`RemoteCatalog`])**能证明**它的身份 | 清单 `files[]` 一条 |
//! | 内联([`FileClass::Inline`]) | 证明不了,或证明了但目标格式表达不了 | 进 `overrides/`,原字节照搬 |
//! | 跳过([`FileClass::Skipped`]) | 读不了 / 是符号链接 / 路径不安全 | 不写进包,**逐条**进报告 |
//!
//! 「能证明」用两条独立查询(契约 §4.4 的顺序):**Modrinth SHA-1** →
//! [`RemoteCatalog::modrinth_by_sha1`];**CurseForge 指纹**([`crate::addon::pack::curseforge::fingerprint`])
//! → [`RemoteCatalog::curseforge_by_fingerprint`]。两条都命中时两条直链都进 `downloads[]`
//! (HMCL 同款,兼容面更大)。**没有网络时**用 [`OfflineCatalog`]:所有文件都内联,包照样完整可导入
//! ——这不是降级失败,是"证明不了就内联"的正面行为。
//!
//! # 每条格式决策落在哪(tower 定案,别按任务书原话改回去)
//!
//! 1. **`.disabled` 走路径后缀**(`mods/x.jar.disabled`),不是 `env.client=optional`:后者只是
//! "安装时可取消勾选",装回来默认仍然启用 ⇒ 跨启动器不保真。官方 Modrinth 导出也是后缀。
//! 2. **覆盖目录一律 `overrides/`**(不用 HMCL 那套 `client-overrides/`):Modrinth 官方与
//! Prism 都用它,兼容面最大。分侧语义在本轮导出里不产生(实例目录本身不分侧)。
//! 3. **`game` + `dependencies` 从实例快照取**,绝不只写 `minecraft` 漏加载器:快照来自
//! `versions/<id>/<id>.json` 的继承链 + 库坐标([`snapshot`]),**不解析**
//! `<mc>-<loader>-<ver>` 这类实例 id 字符串(社区实例名不统一,那是猜)。
//! 4. **CF `files[]` 只写能确定 `projectID`/`fileID` 的项**:CF 没有"内联到 files[]"的概念,
//! 证明不了的(以及**禁用文件**:CF 根本没有禁用表达)一律进 `overrides/` 并进报告。
//! 5. **尺寸护栏**:`fileSize` 是 u32(超了报 `pack_limits`,不静默截断)、整包撞 ZIP64 阈值由
//! `format::write` 拒绝(`pack_limits`,不留半个包)。
//!
//! # 刻意不做的事(分工边界,别以为是漏做)
//!
//! - **MultiMC / Prism 导出**:不做(P1 只做导入)。它的包是"整实例快照",要先把所有远程文件
//! 下载下来再打包,成本极高;导入侧 M111 已覆盖。**这是有意省略。**
//! - **不装加载器 / 不补全清单**:加载器元数据解析与库下载是 `addon::loader` + `game::install`;
//! CF 的 `projectID/fileID → 文件名/URL` 补全需要 CF 客户端(D10 §5 规划中),本轮由调用方注入
//! [`RemoteCatalog`],本模块不发网络请求。
//! - **不碰 UI 类型**:core 零 UI 依赖(`core-no-ui` 门禁),报告只出现 `String` / `&'static str`。
//! - **不改实例**:导出只读实例目录。
//!
//! # 没验的部分(报告里同样登记)
//!
//! 真实 Modrinth / CurseForge API 查询(要网络与 key,`RemoteCatalog` 目前只有离线实现)、
//! 超大实例(GB 级、上千文件——指纹要对每个文件多读两遍)、Windows 之外的平台、
//! 以及「M111 合并后的 `import_pack → export → import_pack` 真端到端」。
pub mod catalog;
pub(crate) mod classify;
pub mod pipeline;
pub mod report;
pub mod scan;
pub mod snapshot;
pub use catalog::{OfflineCatalog, RemoteCatalog, RemoteHit, RemoteSource};
pub use pipeline::{export_instance, ExportRequest};
pub use report::{ExportFile, ExportReport, FileClass, FileReason};
pub use scan::{scan_instance, LocalFile, SkippedLocal, DEFAULT_CONTENT_ROOTS, OVERRIDES_DIR};
pub use snapshot::{read_instance_snapshot, InstanceSnapshot, SnapshotLoader};
+147
View File
@@ -0,0 +1,147 @@
//! 「能否被远程替代」的**注入点**:两条查询 + 一个离线默认实现。
//!
//! # 为什么是 trait 而不是直接调 API
//!
//! 与 M111 的 `PackFetcher` 同一条理由:测试不能联网;CF 的查表要 `x-api-key`(D10 §5 的客户端
//! 还没写);而"证明不了"必须能被如实表达——查表失败 ≠ 文件有问题。所以导出管线只认这个 trait,
//! 生产实现将来接 Modrinth `/v2/version_file/{sha1}` 与 CF `/v1/fingerprints/432`。
//!
//! # 两条查询的语义差别(别合并成一个)
//!
//! | 查询 | 键 | 命中能给出什么 | 谁用得上 |
//! |------|-----|----------------|----------|
//! | [`RemoteCatalog::modrinth_by_sha1`] | 文件 SHA-1 | `downloads[]` + `hashes`(含 `sha512`)+ `fileSize` | mrpack(它要求 sha512) |
//! | [`RemoteCatalog::curseforge_by_fingerprint`] | CF 指纹 | `projectID`/`fileID`(+ 可选直链) | CF 清单(它只认这两个 id) |
//!
//! 只命中一条时,另一格式通常用不上它——这正是 [`crate::addon::export::report::FileReason`]
//! 要区分 `CurseOnlyMatch` / `ModrinthOnlyMatch` 的原因:报告要说清"证明了,但目标格式表达不了",
//! 而不是笼统地写"找不到"。
//!
//! # 哨兵值
//!
//! CF 指纹 `811513880` 会误命中(HMCL issue #4597),由**调用方**在建表前短路:
//! 见 `curseforge::fingerprint::is_sentinel`。
use std::collections::BTreeMap;
/// 这条命中来自哪个目录。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RemoteSource {
/// Modrinth(按 SHA-1 查)。
Modrinth,
/// CurseForge(按文件指纹查)。
CurseForge,
}
/// 一次远程命中的结果。
///
/// 字段刻意按"能写进目标清单"组织,而不是按某个 API 的响应形状:
/// mrpack 要 `downloads` + `hashes`,CF 要 `projectID`/`fileID`,两个都给了就是两份清单都能写。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RemoteHit {
/// 来源目录。
pub source: RemoteSource,
/// 直链候选(多源;mrpack 写进 `downloads[]`,CF 清单没有这个字段但也留着做日志)。
pub downloads: Vec<String>,
/// 摘要表(键名小写)。Modrinth 侧必须含 `sha1` 与 `sha512`——后者是 mrpack 的硬要求。
pub hashes: BTreeMap<String, String>,
/// 服务端记录的文件字节数(与本地实际字节数不一致时以本地为准,见 `pipeline`)。
pub size: Option<u64>,
/// CurseForge `projectID`。
pub project_id: Option<i64>,
/// CurseForge `fileID`。
pub file_id: Option<i64>,
}
impl RemoteHit {
/// 一次 Modrinth 命中:直链 + 摘要表(必须有 `sha512`,否则 mrpack 写不出来)。
#[must_use]
pub fn modrinth(
downloads: Vec<String>,
hashes: BTreeMap<String, String>,
size: Option<u64>,
) -> Self {
Self {
source: RemoteSource::Modrinth,
downloads,
hashes,
size,
project_id: None,
file_id: None,
}
}
/// 一次 CurseForge 命中:`projectID`/`fileID` 是 CF 清单的**全部**必需信息。
#[must_use]
pub fn curseforge(project_id: i64, file_id: i64) -> Self {
Self {
source: RemoteSource::CurseForge,
downloads: Vec::new(),
hashes: BTreeMap::new(),
size: None,
project_id: Some(project_id),
file_id: Some(file_id),
}
}
/// 顺带给一条 CF 直链(有就写进 `downloads[]`,没有也不影响 CF 清单)。
#[must_use]
pub fn with_download(mut self, url: impl Into<String>) -> Self {
self.downloads.push(url.into());
self
}
/// `hashes.sha512`。
#[must_use]
pub fn sha512(&self) -> Option<&str> {
self.hashes.get("sha512").map(String::as_str)
}
/// 能不能写进 CF 的 `files[]`。
#[must_use]
pub fn curse_ids(&self) -> Option<(i64, i64)> {
match (self.project_id, self.file_id) {
(Some(project), Some(file)) => Some((project, file)),
_ => None,
}
}
/// 有没有能写进 mrpack 的 `downloads[]` 的非空直链。
#[must_use]
pub fn has_download(&self) -> bool {
self.downloads.iter().any(|url| !url.trim().is_empty())
}
}
/// 「这个文件在远端是谁」的查询接口。
///
/// 两个方法都必须**只做本地判据 + 调用方给的缓存**:实现里如果联网,那就是生产的
/// Modrinth/CF 客户端(D10 §5),本模块不关心它怎么拿到答案。
pub trait RemoteCatalog {
/// 按 SHA-1 查 Modrinth 版本文件;查不到返回 `None`(**不要**用 `None` 表示网络失败以外的
/// 「本地构造不了查询」——那种情况应当返回 `None` 并让文件内联)。
fn modrinth_by_sha1(&self, sha1: &str) -> Option<RemoteHit>;
/// 按 CurseForge 指纹查项目/文件;查不到返回 `None`。
///
/// 调用方已按哨兵值(`811513880`)短路,实现不必重复判断。
fn curseforge_by_fingerprint(&self, fingerprint: u32) -> Option<RemoteHit>;
}
/// 离线:什么都证明不了 ⇒ 所有文件内联。
///
/// 这是**默认**行为,也是本仓库当前的唯一实现:没有 CF 客户端(要 key)、没有 Modrinth 客户端
/// (D10 §5 规划中),联网查询在测试里也不允许。用它的导出结果是"一个完整、可导入、全部走
/// `overrides/` 的包"——不是残废包。
#[derive(Debug, Clone, Copy, Default)]
pub struct OfflineCatalog;
impl RemoteCatalog for OfflineCatalog {
fn modrinth_by_sha1(&self, _sha1: &str) -> Option<RemoteHit> {
None
}
fn curseforge_by_fingerprint(&self, _fingerprint: u32) -> Option<RemoteHit> {
None
}
}
+112
View File
@@ -0,0 +1,112 @@
//! 逐文件判定(导出管线的第 ③ 步):这份本地文件在目标格式里能不能被远程替代。
//!
//! 判定要**两个条件同时成立**才算远程引用:
//!
//! 1. 某个目录([`RemoteCatalog`])能**证明**它的身份——Modrinth 按 SHA-1、CurseForge 按指纹;
//! 2. 目标格式**表达得了**这个证明——mrpack 要 `path` + `sha512` + `downloads`,
//! CF 清单要 `projectID` + `fileID`。
//!
//! 只满足第一条时就是"证明了但用不上",它有自己的原因(`CurseOnlyMatch` /
//! `ModrinthOnlyMatch`),不能和"两个目录都不认识"混成一句话。
//!
//! 分成单独一个文件是为了让 [`super::pipeline`] 只讲流程;这里全是判据,可以逐条对着测
//! (`classify_with` 甚至允许测试直接喂指纹——哨兵值 `811513880` 用真实文件测不出来)。
use super::catalog::{RemoteCatalog, RemoteHit};
use super::report::{ExportFile, FileReason};
use super::scan::LocalFile;
use crate::addon::pack::curseforge::fingerprint;
use crate::addon::pack::manifest::{EnvSupport, PackFile};
/// 判定一条本地文件属于哪一类(外加命中,供组 `files[]` 用)。
pub(super) fn classify(
local: &LocalFile,
catalog: &dyn RemoteCatalog,
target_is_curse: bool,
) -> (FileReason, Option<RemoteHit>) {
// 指纹要读两遍盘;读不出来就按"没有 CF 侧证据"处理(文件本身仍会内联进包)。
let value = fingerprint::fingerprint_file(&local.disk).ok();
classify_with(local, catalog, target_is_curse, value)
}
/// 同 [`classify`],但指纹由调用方给(`None` = 读不出来)。
///
/// `pub(crate)` 只有一个理由:哨兵值 `811513880` 那条分支**没法用真实文件测**——它是误命中,
/// 找不到一个能算出它的样本(要 2^32 次穷举)。测试直接喂这个数,验证它被当成"查不到"。
pub(crate) fn classify_with(
local: &LocalFile,
catalog: &dyn RemoteCatalog,
target_is_curse: bool,
fingerprint: Option<u32>,
) -> (FileReason, Option<RemoteHit>) {
let modrinth = catalog.modrinth_by_sha1(&local.sha1);
let curseforge = match fingerprint {
Some(value) if !fingerprint::is_sentinel(value) => catalog.curseforge_by_fingerprint(value),
Some(_) | None => None,
};
if target_is_curse {
if local.disabled {
// CF 没有禁用表达:`required:false` 是"可选"不是"禁用",硬写就等于把它变成启用。
return (FileReason::DisabledNotExpressible, None);
}
return match curseforge {
Some(hit) if hit.curse_ids().is_some() => {
(FileReason::CurseForgeFingerprint, Some(hit))
}
Some(_) => (FileReason::MissingCurseIds, None),
None if modrinth.is_some() => (FileReason::ModrinthOnlyMatch, None),
None => (FileReason::NoRemoteMatch, None),
};
}
match modrinth {
Some(hit) if hit.sha512().is_some() && hit.has_download() => {
let mut merged = hit;
// 两条都命中:两条直链都写进 downloads[](HMCL 同款,兼容面更大)。
if let Some(curse) = curseforge {
for url in curse.downloads {
if !url.trim().is_empty() && !merged.downloads.iter().any(|seen| seen == &url) {
merged.downloads.push(url);
}
}
merged.project_id = merged.project_id.or(curse.project_id);
merged.file_id = merged.file_id.or(curse.file_id);
}
(FileReason::ModrinthSha1, Some(merged))
}
Some(_) => (FileReason::MissingSha512, None),
None if curseforge.is_some() => (FileReason::CurseOnlyMatch, None),
None => (FileReason::NoRemoteMatch, None),
}
}
/// 远程条目:`path` 用实例内路径(禁用文件已带 `.disabled` 后缀),`size` 用本地实测字节数。
pub(super) fn remote_entry(local: &LocalFile, hit: &RemoteHit) -> PackFile {
let mut hashes = hit.hashes.clone();
hashes.insert("sha1".to_string(), local.sha1.clone());
PackFile {
path: Some(local.path.clone()),
project_id: hit.project_id,
file_id: hit.file_id,
hashes,
downloads: hit.downloads.clone(),
size: Some(local.bytes),
disabled: local.disabled,
client: EnvSupport::Required,
server: EnvSupport::Required,
..PackFile::default()
}
}
/// 报告里的一条。
pub(super) fn entry(local: &LocalFile, reason: FileReason, hit: Option<&RemoteHit>) -> ExportFile {
ExportFile {
path: local.path.clone(),
class: reason.class(),
reason,
bytes: local.bytes,
downloads: hit.map(|hit| hit.downloads.clone()).unwrap_or_default(),
project_id: hit.and_then(|hit| hit.project_id),
file_id: hit.and_then(|hit| hit.file_id),
disabled: local.disabled,
}
}
+310
View File
@@ -0,0 +1,310 @@
//! 导出管线:实例目录 → 三分类 → 清单 + `overrides/` → 包。
//!
//! 顺序是写死的,每一步都可单独测:
//!
//! ```text
//! ① 实例快照(snapshot) versions/<id>/<id>.json 的继承链 + 库坐标 ⇒ game / dependencies
//! ② 扫描(scan) 内容目录 → 路径 + 字节数 + SHA-1 + 禁用位;读不了的进跳过表
//! ③ 逐文件判定(classify) Modrinth SHA-1 → CF 指纹 → 目标格式能不能表达 ⇒ 远程 / 内联 / 跳过
//! ④ 组模型 files[] = 远程;overrides = 内联(统一 `overrides/`)
//! ⑤ 写包 走 M110 的 `PackFormat::write`(临时文件 + rename,失败不留半个包)
//! ```
//!
//! # 判定为什么要"目标格式能力"参与
//!
//! 「能被远程替代」不是文件自己的属性,是**文件 + 目标格式**的属性:同一个文件,CF 认得
//! `projectID/fileID` 就能写进 CF 的 `files[]`,但 mrpack 要 `sha512` 才写得出来;反过来
//! mrpack 认可 Modrinth 直链,CF 清单却只认两个整数 id。所以
//! [`crate::addon::export::report::FileReason`] 里 `CurseOnlyMatch` 与 `ModrinthOnlyMatch`
//! 是两个不同的事实,不能合并成"没找到"。
//!
//! # 一条禁令
//!
//! **不猜**:目录没给命中就内联;命中缺必需字段(`sha512` / `projectID`)也内联;只有
//! "两条硬要求都齐"才写进 `files[]`。这样导出的包在任何启动器里都能装出**同一份内容**,
//! 最多是"该远程的走了内联"(体积大一点,内容不会变)。
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde_json::Map;
use super::catalog::{OfflineCatalog, RemoteCatalog};
use super::classify::{classify, entry, remote_entry};
use super::report::{ExportFile, ExportReport, FileClass};
use super::scan::{scan_instance, DEFAULT_CONTENT_ROOTS, OVERRIDES_DIR};
use super::snapshot::{read_instance_snapshot, InstanceSnapshot};
use crate::addon::pack::curseforge;
use crate::addon::pack::error::{PackError, PackResult};
use crate::addon::pack::format::{PackFormat, PackPayload};
use crate::addon::pack::manifest::{
OverrideFile, PackLoader, PackManifest, PackOverrides, PackSide,
};
/// 包版本缺省值(CF `version` / mrpack `versionId` 都必填,调用方没给就用它)。
pub const DEFAULT_VERSION: &str = "1.0.0";
/// 默认目录(离线):所有文件内联。
static OFFLINE: OfflineCatalog = OfflineCatalog;
/// 一次导出的输入。
pub struct ExportRequest<'a> {
root: &'a Path,
target: &'a Path,
format: &'static dyn PackFormat,
name: Option<String>,
version: Option<String>,
version_id: Option<&'a str>,
content_roots: Option<Vec<String>>,
catalog: &'a dyn RemoteCatalog,
snapshot: Option<&'a InstanceSnapshot>,
}
impl<'a> ExportRequest<'a> {
/// 从 `root`(实例游戏目录)导出到 `target`,用 `format` 的清单形状。
///
/// 默认:包名取目录名、版本 [`DEFAULT_VERSION`]、内容目录取 [`DEFAULT_CONTENT_ROOTS`]、
/// 目录用 [`OfflineCatalog`](= 全部内联)。
#[must_use]
pub fn new(root: &'a Path, target: &'a Path, format: &'static dyn PackFormat) -> Self {
Self {
root,
target,
format,
name: None,
version: None,
version_id: None,
content_roots: None,
catalog: &OFFLINE,
snapshot: None,
}
}
/// 包名(写进清单 `name`)。
#[must_use]
pub fn with_name(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
/// 包版本(写进 CF `version` / mrpack `versionId`)。
#[must_use]
pub fn with_version(mut self, version: impl Into<String>) -> Self {
self.version = Some(version.into());
self
}
/// 实例绑定的版本 id;不给就要求 `versions/` 下只有一个版本。
#[must_use]
pub fn with_version_id(mut self, version_id: &'a str) -> Self {
self.version_id = Some(version_id);
self
}
/// 覆盖要扫的内容目录(默认 [`DEFAULT_CONTENT_ROOTS`])。
#[must_use]
pub fn with_content_roots(mut self, roots: Vec<String>) -> Self {
self.content_roots = Some(roots);
self
}
/// 注入远端查表(默认离线:全部内联)。
#[must_use]
pub fn with_catalog(mut self, catalog: &'a dyn RemoteCatalog) -> Self {
self.catalog = catalog;
self
}
/// 直接给实例快照,跳过 `versions/<id>/<id>.json` 的读取。
///
/// 用于两种实例:①**Prism / MultiMC 实例**(游戏目录里没有 `versions/`,快照只能从
/// `mmc-pack.json` 的组件表来,调用方用 `pack::multimc` 读出来再传);②调用方已经解析过
/// 快照(UI 侧手里就有 profile + 加载器信息),不必再读一遍盘。
#[must_use]
pub fn with_snapshot(mut self, snapshot: &'a InstanceSnapshot) -> Self {
self.snapshot = Some(snapshot);
self
}
}
/// 导出一个实例。
///
/// # Errors
///
/// - `pack_unsupported`:实例里找不到唯一的版本 JSON / 目标格式表达不了布局;
/// - `pack_metadata`:版本 JSON 坏了(继承成环、不是 JSON);
/// - `pack_limits`:单文件 `fileSize` 超 u32、或整包撞 ZIP64 阈值;
/// - `pack_io`:读写盘失败(失败时目标路径不留半个包)。
pub fn export_instance(request: &ExportRequest<'_>) -> PackResult<ExportReport> {
let snapshot = match request.snapshot {
Some(snapshot) => snapshot.clone(),
None => read_instance_snapshot(request.root, request.version_id)?,
};
let roots = request
.content_roots
.clone()
.unwrap_or_else(|| DEFAULT_CONTENT_ROOTS.map(str::to_string).to_vec());
let (locals, skipped) = scan_instance(request.root, &roots);
// CF 清单只认 projectID/fileID;mrpack 要 path + sha512 + downloads。
let target_is_curse = request.format.id() == curseforge::ID;
let mut manifest = build_manifest(request, &snapshot);
let mut entries: Vec<ExportFile> = Vec::with_capacity(locals.len() + skipped.len());
let mut overrides: Vec<OverrideFile> = Vec::new();
let mut sources: BTreeMap<String, PathBuf> = BTreeMap::new();
for local in &locals {
let (reason, hit) = classify(local, request.catalog, target_is_curse);
match reason.class() {
FileClass::Remote => {
let Some(hit) = hit else {
// 判定逻辑的自我约束:Remote 必带命中。真走到这里说明分类函数被改坏了。
return Err(PackError::unsupported(format!(
"内部不一致:{} 被判为远程引用却没有命中",
local.path
)));
};
manifest.files.push(remote_entry(local, &hit));
entries.push(entry(local, reason, Some(&hit)));
}
FileClass::Inline => {
overrides.push(OverrideFile {
path: local.path.clone(),
sha1: local.sha1.clone(),
size: local.bytes,
});
sources.insert(
format!("{OVERRIDES_DIR}/{}", local.path),
local.disk.clone(),
);
entries.push(entry(local, reason, None));
}
FileClass::Skipped => unreachable!("扫描阶段不会产出可导出项"),
}
}
// 跳过项照原样进报告(逐条给原因),不进包。
for item in &skipped {
entries.push(ExportFile {
path: item.path.clone(),
class: FileClass::Skipped,
reason: item.reason,
bytes: 0,
downloads: Vec::new(),
project_id: None,
file_id: None,
disabled: false,
});
}
if !overrides.is_empty() {
// 一律 `overrides/`(Modrinth 官方 + Prism 的写法)。实例目录不分侧,所以只有一个 Both 组。
manifest.overrides.push(PackOverrides {
dir: OVERRIDES_DIR.to_string(),
side: PackSide::Both,
files: overrides,
});
}
request.format.write(
&manifest,
PackPayload::with_sources(request.root, &sources),
request.target,
)?;
let bytes = std::fs::metadata(request.target)
.map_err(|error| {
PackError::io(format!(
"量 {} 的字节数失败:{error}",
request.target.display()
))
})?
.len();
entries.sort_by(|left, right| left.path.cmp(&right.path));
let notes = notes(target_is_curse, &manifest);
Ok(ExportReport {
format: request.format.id(),
label: request.format.label(),
path: request.target.to_path_buf(),
bytes,
name: manifest.name.clone(),
version: manifest.version.clone(),
minecraft: manifest.minecraft.clone(),
loaders: manifest
.loaders
.iter()
.map(|loader| {
format!(
"{} {}",
loader.dependency_key(),
loader.version.clone().unwrap_or_default()
)
})
.collect(),
files: entries,
notes,
})
}
/// 组清单:`game` / `minecraft` / `loaders` 全部来自实例快照。
fn build_manifest(request: &ExportRequest<'_>, snapshot: &InstanceSnapshot) -> PackManifest {
let name = request
.name
.clone()
.or_else(|| {
request
.root
.file_name()
.map(|name| name.to_string_lossy().to_string())
})
.unwrap_or_else(|| "整合包".to_string());
let mut manifest = PackManifest::new(name);
manifest.version = Some(
request
.version
.clone()
.unwrap_or_else(|| DEFAULT_VERSION.to_string()),
);
manifest.format_version = Some(1);
manifest.game = Some(crate::addon::pack::modrinth::GAME.to_string());
manifest.minecraft = Some(snapshot.game_version.clone());
manifest.loaders = snapshot
.loaders
.iter()
.enumerate()
.map(|(index, loader)| PackLoader {
raw: String::new(),
kind: Some(loader.kind),
version: Some(loader.version.clone()),
primary: index == 0,
extra: Map::new(),
})
.collect();
manifest
}
/// 格式层面表达不了的东西(已知损失)——必须显式列出来,别让用户以为往返无损。
fn notes(target_is_curse: bool, manifest: &PackManifest) -> Vec<String> {
let mut notes = vec![
"覆盖内容统一写进 `overrides/`(Modrinth 官方与 Prism 的写法);实例目录不分侧,\
所以不产生 client-overrides / server-overrides。"
.to_string(),
"禁用文件用**路径后缀 `.disabled`** 表达(不是 `env.client=optional`:后者装回来默认启用)。"
.to_string(),
"MultiMC / Prism 导出本轮不做(只有导入),这是有意省略,不是遗漏。".to_string(),
];
if target_is_curse {
notes.push("mrpack 的 `summary` 在 CF 清单里没有字段;CF 也只认 projectID/fileID,禁用文件只能内联。".to_string());
} else {
notes.push(
"CF 清单的 `author` / `manifestVersion` 之外的扩展字段在 mrpack 里没有对应位置。"
.to_string(),
);
}
if !manifest.files.is_empty() {
notes.push(format!(
"{} 条写进 files[](远程引用),其余在 overrides/ 里;每条的原因见 files[].reason。",
manifest.files.len()
));
}
notes
}
+235
View File
@@ -0,0 +1,235 @@
//! 导出结果:**三分类 + 逐条原因**,给 CLI 的 `--json` 与 M113 的页面直接消费。
//!
//! # 为什么每条都要有原因
//!
//! 用户要的是「不认识的 mod 不能丢、不能编」。光说"导出了 120 个文件"会掩盖真正的信息:
//! 其中多少条**能证明**可被远程替代、多少条**证明不了所以内联**、多少条**干脆没进包**。
//! 所以 [`ExportFile`] 的 `class` 与 `reason` 是两个独立维度,且 `reason` 决定 `class`
//! ([`FileReason::class`] 是唯一映射处,写死在这里,别在别处再判一遍)。
//!
//! # 「允许的差异」也进报告
//!
//! [`ExportReport::notes`] 收的是**格式层面表达不了**的东西(CF 的 `author`、mrpack 的
//! `summary`、CF 表达不了禁用位……)。它们是已知损失,必须显式列出,不能让用户以为"往返无损"。
use std::path::PathBuf;
use serde::{Serialize, Serializer};
/// 一条文件在导出结果里的归类。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FileClass {
/// 写进清单 `files[]`(靠远端直链/项目 id 代替本地字节)。
Remote,
/// 写进 `overrides/`(原字节照搬)。
Inline,
/// 没进包(读不了 / 符号链接 / 路径不安全)。
Skipped,
}
impl FileClass {
/// 稳定字符串 id(进 `--json` 与日志,**不要改名**)。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Remote => "remote",
Self::Inline => "inline",
Self::Skipped => "skipped",
}
}
}
impl Serialize for FileClass {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(self.as_str())
}
}
/// 一条文件为什么归到那个类里。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FileReason {
/// 远程引用:Modrinth SHA-1 查到了版本文件。
ModrinthSha1,
/// 远程引用:CurseForge 指纹查到了项目/文件。
CurseForgeFingerprint,
/// 内联:两个目录都不认识它。
NoRemoteMatch,
/// 内联:CF 认识,但目标格式(mrpack)用不上——CF 命中不含 `sha512`。
CurseOnlyMatch,
/// 内联:Modrinth 认识,但目标格式(CF 清单)只认 `projectID`/`fileID`。
ModrinthOnlyMatch,
/// 内联:目录给了命中但缺 `hashes.sha512`(mrpack 的硬要求)。
MissingSha512,
/// 内联:目录给了命中但缺 `projectID`/`fileID`(CF 清单的硬要求)。
MissingCurseIds,
/// 内联:文件是禁用的(`.disabled`),而目标格式表达不了禁用位(CF)。
DisabledNotExpressible,
/// 跳过:符号链接。
SymbolicLink,
/// 跳过:文件名过不了包内路径规整(`:` / `\` / `..` / 绝对路径…)。
UnsafePath,
/// 跳过:读盘或算摘要失败。
Unreadable,
}
impl FileReason {
/// 稳定字符串 id(进 `--json` 与日志,**不要改名**)。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::ModrinthSha1 => "modrinth_sha1",
Self::CurseForgeFingerprint => "curseforge_fingerprint",
Self::NoRemoteMatch => "no_remote_match",
Self::CurseOnlyMatch => "curse_only_match",
Self::ModrinthOnlyMatch => "modrinth_only_match",
Self::MissingSha512 => "missing_sha512",
Self::MissingCurseIds => "missing_curse_ids",
Self::DisabledNotExpressible => "disabled_not_expressible",
Self::SymbolicLink => "symbolic_link",
Self::UnsafePath => "unsafe_path",
Self::Unreadable => "unreadable",
}
}
/// 人读说明(中文,进报告与日志;UI 文案后续按 key 本地化)。
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::ModrinthSha1 => "Modrinth 命中(SHA-1)",
Self::CurseForgeFingerprint => "CurseForge 命中(文件指纹)",
Self::NoRemoteMatch => "两个目录都不认识,已内联",
Self::CurseOnlyMatch => "只有 CF 认识,目标格式用不上,已内联",
Self::ModrinthOnlyMatch => "只有 Modrinth 认识,CF 清单用不上,已内联",
Self::MissingSha512 => "命中缺 sha512,mrpack 写不出,已内联",
Self::MissingCurseIds => "命中缺 projectID/fileID,CF 清单写不出,已内联",
Self::DisabledNotExpressible => "禁用文件 CF 表达不了,已内联(保留 .disabled 后缀)",
Self::SymbolicLink => "符号链接,已跳过",
Self::UnsafePath => "路径过不了包内规整,已跳过",
Self::Unreadable => "读不出来,已跳过",
}
}
/// 这个原因属于哪一类(**唯一映射处**)。
#[must_use]
pub const fn class(self) -> FileClass {
match self {
Self::ModrinthSha1 | Self::CurseForgeFingerprint => FileClass::Remote,
Self::NoRemoteMatch
| Self::CurseOnlyMatch
| Self::ModrinthOnlyMatch
| Self::MissingSha512
| Self::MissingCurseIds
| Self::DisabledNotExpressible => FileClass::Inline,
Self::SymbolicLink | Self::UnsafePath | Self::Unreadable => FileClass::Skipped,
}
}
}
impl Serialize for FileReason {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(self.as_str())
}
}
/// 一条文件的导出结果。
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ExportFile {
/// 实例内相对路径(含 `.disabled` 后缀,与磁盘上一致)。
pub path: String,
/// 归类。
pub class: FileClass,
/// 原因。
pub reason: FileReason,
/// 字节数(跳过项记磁盘上的大小,读不到就是 0)。
pub bytes: u64,
/// 远程引用给的直链(内联/跳过为空)。
pub downloads: Vec<String>,
/// CurseForge `projectID`(远程引用到 CF 时才有)。
pub project_id: Option<i64>,
/// CurseForge `fileID`。
pub file_id: Option<i64>,
/// 是否禁用态(`.disabled`)。
pub disabled: bool,
}
/// 一次导出的结构化结果。
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ExportReport {
/// 目标格式 id(`modrinth` / `curseforge`)。
pub format: &'static str,
/// 目标格式人读名。
pub label: &'static str,
/// 产出的包路径。
pub path: PathBuf,
/// 包的字节数(写完之后量的,不是估算)。
pub bytes: u64,
/// 包名(写进清单的 `name`)。
pub name: String,
/// 包版本(`versionId` / `version`)。
pub version: Option<String>,
/// 游戏版本(来自实例快照)。
pub minecraft: Option<String>,
/// 加载器(`fabric-loader 0.15.11` 这种人读串,来自实例快照)。
pub loaders: Vec<String>,
/// 逐文件结果(按路径排序)。
pub files: Vec<ExportFile>,
/// 已知的表达不了的差异 / 值得用户知道的取舍。
pub notes: Vec<String>,
}
impl ExportReport {
/// 远程引用的条数。
#[must_use]
pub fn remote_count(&self) -> usize {
self.count(FileClass::Remote)
}
/// 内联的条数。
#[must_use]
pub fn inline_count(&self) -> usize {
self.count(FileClass::Inline)
}
/// 跳过的条数。
#[must_use]
pub fn skipped_count(&self) -> usize {
self.count(FileClass::Skipped)
}
fn count(&self, class: FileClass) -> usize {
self.files.iter().filter(|file| file.class == class).count()
}
/// 有没有文件没进包(`false` = 包内容完整,只是有些走了内联)。
#[must_use]
pub fn is_complete(&self) -> bool {
self.skipped_count() == 0
}
/// 内联文件(`overrides/` 里的那些)。
pub fn inline_files(&self) -> impl Iterator<Item = &ExportFile> {
self.files
.iter()
.filter(|file| file.class == FileClass::Inline)
}
/// 不可导出的项(逐条给 `path` + `reason` 用)。
pub fn skipped_files(&self) -> impl Iterator<Item = &ExportFile> {
self.files
.iter()
.filter(|file| file.class == FileClass::Skipped)
}
/// 一句话摘要(日志/CLI 一行输出用)。
#[must_use]
pub fn summary(&self) -> String {
format!(
"{}:{} 个远程引用 / {} 个内联 / {} 个跳过,{} 字节",
self.label,
self.remote_count(),
self.inline_count(),
self.skipped_count(),
self.bytes
)
}
}
+189
View File
@@ -0,0 +1,189 @@
//! 实例目录扫描:把"磁盘上有什么"变成 [`LocalFile`] 表(路径 + 字节数 + SHA-1 + 禁用位)。
//!
//! # 扫哪些目录(写死白名单,不是"排除法")
//!
//! 默认只扫 [`DEFAULT_CONTENT_ROOTS`] 这 8 个内容目录:`mods` / `config` / `defaultconfigs` /
//! `scripts` / `resourcepacks` / `shaderpacks` / `kubejs` / `openloader`。
//!
//! 为什么用白名单而不是"排除 `versions/` `libraries/` `assets/`":
//!
//! - 那三个目录是**启动器/游戏自己管的**(版本 JSON、依赖库、资源对象),塞进整合包只会让包
//! 变成几 GB,且与"远程可替代"完全无关;
//! - `journals` `logs/` `crash-reports/` `saves/` `screenshots/` `backups/` **不属于**整合包内容
//! (社区导出器一致不打包它们);
//! - 排除法是"忘了就漏"的写法:以后实例里出现新目录(`.mixin.out`、`essential`…)会被静默打进去。
//!
//! 白名单之外的东西**不是丢了**——它们本来就不该在整合包里。要打别的目录,用
//! [`crate::addon::export::ExportRequest::with_content_roots`] 显式加。
//!
//! # 内容目录里的一切都收(认不出的 mod 不能丢)
//!
//! 白名单目录里**不按扩展名过滤**:`mods/` 下的 `.jar`、`.jar.disabled`、`.jar.old`、
//! `.ds_store`、社区自造后缀,一律按文件收。丢掉"我不认识的东西"正是用户点名不许的。
//!
//! # 禁用它只看路径后缀
//!
//! `x.jar` / `x.jar.disabled` / `x.jar.old` / `x.jar.disabled.old` 四种里,含 `.disabled`
//! (中间可能夹 `.old`)的才算禁用;`.old` 单独出现只是"旧版本残留",原样保留、语义不变。
//! 后缀**原样进包**——这是官方 Modrinth 导出的表达,也是唯一跨启动器保真的那条
//! (`env.client=optional` 不是禁用,见 `crate::addon::pack::manifest::PackFile::disabled`)。
//!
//! # 安全
//!
//! 每条相对路径都要过 [`normalize_file`](七条规则)才进表;不过的记
//! [`SkipReason::UnsafePath`]。符号链接**不跟随**,记 [`SkipReason::SymbolicLink`]
//! (导出包里出现符号链接条目,解压端会把它当内容写出来)。
use std::path::{Path, PathBuf};
use super::report::FileReason;
use crate::addon::pack::path::{normalize_dir, normalize_file};
use crate::download::types::HashAlgo;
use crate::download::verify::digest_file;
/// 包内唯一的覆盖目录名(Modrinth 官方与 Prism 都用它,兼容面最大)。
pub const OVERRIDES_DIR: &str = "overrides";
/// 默认扫的 8 个内容目录。
pub const DEFAULT_CONTENT_ROOTS: [&str; 8] = [
"mods",
"config",
"defaultconfigs",
"scripts",
"resourcepacks",
"shaderpacks",
"kubejs",
"openloader",
];
/// 一个待判定的本地文件。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LocalFile {
/// 实例内相对路径(`/` 分隔,含 `.disabled` / `.old` 后缀,与磁盘上一致)。
pub path: String,
/// 磁盘上的绝对路径(导出时按这个读字节)。
pub disk: PathBuf,
/// 字节数。
pub bytes: u64,
/// 内容 SHA-1(小写十六进制)。
pub sha1: String,
/// 是否禁用态(路径后缀含 `.disabled`)。
pub disabled: bool,
}
/// 一个没能进包的文件,与它为什么没进。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SkippedLocal {
/// 实例内相对路径(过不了规整时给原始串)。
pub path: String,
/// 原因。
pub reason: FileReason,
}
/// 扫描一个实例目录,返回(可导出的文件表,跳过表),两张表都按路径排序。
///
/// 内容目录不存在不算错(空实例导出空包是合法的,只是报告里一个文件都没有)。
#[must_use]
pub fn scan_instance(root: &Path, content_roots: &[String]) -> (Vec<LocalFile>, Vec<SkippedLocal>) {
let mut files: Vec<LocalFile> = Vec::new();
let mut skipped: Vec<SkippedLocal> = Vec::new();
for content_root in content_roots {
let Ok(dir_name) = normalize_dir(content_root) else {
skipped.push(SkippedLocal {
path: content_root.clone(),
reason: FileReason::UnsafePath,
});
continue;
};
let start = root.join(&dir_name);
if !start.is_dir() {
continue;
}
// 迭代而不是递归:深度由磁盘决定,别让深层目录把栈吃掉。
let mut pending: Vec<PathBuf> = vec![start];
while let Some(dir) = pending.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else {
skipped.push(SkippedLocal {
path: relative_name(root, &dir),
reason: FileReason::Unreadable,
});
continue;
};
let mut children: Vec<_> = entries.collect::<Result<Vec<_>, _>>().unwrap_or_default();
children.sort_by_key(std::fs::DirEntry::file_name);
for child in children {
let path = child.path();
let name = relative_name(root, &path);
let Ok(file_type) = child.file_type() else {
skipped.push(SkippedLocal {
path: name,
reason: FileReason::Unreadable,
});
continue;
};
if file_type.is_symlink() {
skipped.push(SkippedLocal {
path: name,
reason: FileReason::SymbolicLink,
});
continue;
}
if file_type.is_dir() {
pending.push(path);
continue;
}
if !file_type.is_file() {
// 设备文件 / 管道 / 套接字:不是整合包内容。
skipped.push(SkippedLocal {
path: name,
reason: FileReason::Unreadable,
});
continue;
}
let Ok(normalized) = normalize_file(&name) else {
skipped.push(SkippedLocal {
path: name,
reason: FileReason::UnsafePath,
});
continue;
};
let bytes = child.metadata().map(|meta| meta.len()).unwrap_or(0);
let Ok(sha1) = digest_file(&path, HashAlgo::Sha1) else {
skipped.push(SkippedLocal {
path: normalized,
reason: FileReason::Unreadable,
});
continue;
};
files.push(LocalFile {
disabled: is_disabled_path(&normalized),
path: normalized,
disk: path,
bytes,
sha1,
});
}
}
}
files.sort_by(|left, right| left.path.cmp(&right.path));
skipped.sort_by(|left, right| left.path.cmp(&right.path));
(files, skipped)
}
/// 实例内相对路径(`/` 分隔);拿不到就退回文件名。
fn relative_name(root: &Path, path: &Path) -> String {
path.strip_prefix(root)
.unwrap_or(path)
.to_string_lossy()
.replace('\\', "/")
}
/// 路径后缀是否带 `.disabled`(允许 `x.jar.disabled.old` 这种"禁用 + 旧残留"组合)。
#[must_use]
pub fn is_disabled_path(path: &str) -> bool {
let lowered = path.trim_end().to_ascii_lowercase();
let without_old = lowered.strip_suffix(".old").unwrap_or(&lowered);
without_old.ends_with(".disabled")
}
+209
View File
@@ -0,0 +1,209 @@
//! 实例快照:`game` 与 `dependencies` 的唯一来源(**不解析实例 id 字符串**)。
//!
//! # 为什么要读版本 JSON 而不是拆 `versions/<id>` 的名字
//!
//! 实例 id 的形状是社区约定,不是规范:`1.20.1-fabric-0.15.11`(本仓库 `LoaderKind::version_id`
//! 的拼法)、`1.20.1-forge-47.2.0`、`Fabric 1.20.1`、`我的整合包 v3`……按 `-` 切串去猜
//! "哪段是加载器、哪段是版本"必然在真实实例上翻车。**证据只能在版本 JSON 里**:
//!
//! - 游戏版本 = `inheritsFrom` 链**最深的那个祖先**的 `id`(加载器层都叠在原版之上);
//! - 加载器 = 各层 `libraries[]` 里的 Maven 坐标:`net.fabricmc:fabric-loader:<ver>`、
//! `org.quiltmc:quilt-loader:<ver>`、`net.minecraftforge:forge:<ver>`、
//! `net.neoforged:neoforge:<ver>`(`fmlloader` 也认,它是 Forge 的拆分包)。
//!
//! 这条口径由 tower 批准(M112):"解析 `<mc>-<loader>-<ver>` 字符串是猜"。
//!
//! # 版本号里的 MC 前缀
//!
//! NeoForge 在 MC 1.20.1 上的库坐标版本是 `1.20.1-47.1.106`(CF 导出的
//! `neoforge-1.20.1-47.1.106` 同源)。这里统一剥成 `47.1.106`:mrpack 的
//! `dependencies["neoforge"]` 要裸版本,CF 的 `modLoaders[].id` 由
//! `PackLoader::curse_id_for` 在 1.20.1 上再把前缀拼回去。
//!
//! # 没有加载器是合法的
//!
//! 原版实例的 `dependencies` 只有 `minecraft`——那不是"漏了加载器",是真的没装。任务书说的
//! 「不得只写 `minecraft` 漏加载器」针对的是**装了加载器却漏写**,两者靠这条链区分得清清楚楚。
use std::path::Path;
use crate::addon::pack::error::{PackError, PackResult};
use crate::addon::LoaderKind;
use crate::game::library::MavenCoordinate;
use crate::game::manifest::VersionJson;
/// 实例里认出的一条加载器依赖。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SnapshotLoader {
/// 加载器种类。
pub kind: LoaderKind,
/// 加载器版本(已剥掉可能存在的 `<mc>-` 前缀)。
pub version: String,
}
/// 实例快照:游戏版本 + 加载器集合(顺序即 `libraries[]` 里出现的顺序)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InstanceSnapshot {
/// 原版游戏版本(`1.20.1`)。
pub game_version: String,
/// 加载器依赖(原版实例为空)。
pub loaders: Vec<SnapshotLoader>,
}
/// 读一个实例目录的快照。
///
/// `version_id` 给定时只读 `versions/<id>/<id>.json`;`None` 时取 `versions/` 里**继承链的叶子**
/// (真实实例有"原版 + 加载器层"两个目录,叶子才是实例自己)。叶子多于一个时无法判断,
/// 宁可报错也不猜。
pub fn read_instance_snapshot(
root: &Path,
version_id: Option<&str>,
) -> PackResult<InstanceSnapshot> {
let versions_dir = root.join("versions");
let id = match version_id {
Some(id) => id.to_string(),
None => instance_version_id(&versions_dir)?,
};
let layers = load_chain(&versions_dir, &id)?;
let deepest = layers
.last()
.and_then(|layer| layer.id.clone())
.ok_or_else(|| {
PackError::metadata(format!("versions/{id}/{id}.json 的继承链末端没有 id"))
})?;
let mut loaders: Vec<SnapshotLoader> = Vec::new();
for layer in &layers {
for library in layer.libraries.iter().flatten() {
let Ok(coordinate) = MavenCoordinate::parse(&library.name) else {
continue;
};
let Some(kind) = loader_kind_of(&coordinate.group, &coordinate.artifact) else {
continue;
};
if loaders.iter().any(|seen| seen.kind == kind) {
continue;
}
loaders.push(SnapshotLoader {
kind,
version: strip_game_prefix(&deepest, &coordinate.version),
});
}
}
Ok(InstanceSnapshot {
game_version: deepest,
loaders,
})
}
/// `versions/` 下"这个实例"的版本 id:**继承链的叶子**(没有被别的版本当作 `inheritsFrom`)。
///
/// 为什么不是"只有一个版本目录":真实实例必然有"原版 + 加载器层"两个目录(`1.20.1` 与
/// `1.20.1-fabric-0.15.11`),叶子才是实例自己。这条判据不看目录名形状,只看 JSON 里的
/// `inheritsFrom` 关系——与"不解析实例 id 字符串"是同一条纪律。
fn instance_version_id(versions_dir: &Path) -> PackResult<String> {
let entries = std::fs::read_dir(versions_dir).map_err(|error| {
PackError::unsupported(format!(
"实例里没有可读的 {}:{error}",
versions_dir.display()
))
.with_hint("导出需要实例的版本 JSON(versions/<id>/<id>.json 给出游戏版本与加载器)")
})?;
let mut versions: Vec<(String, Option<String>)> = Vec::new();
for entry in entries.flatten() {
let Ok(file_type) = entry.file_type() else {
continue;
};
if !file_type.is_dir() {
continue;
}
let name = entry.file_name().to_string_lossy().to_string();
let path = entry.path().join(format!("{name}.json"));
if !path.is_file() {
continue;
}
let text = std::fs::read_to_string(&path).map_err(|error| {
PackError::metadata(format!("读版本 JSON {} 失败:{error}", path.display()))
})?;
let layer: VersionJson = serde_json::from_str(&text).map_err(|error| {
PackError::metadata(format!("{} 不是合法版本 JSON:{error}", path.display()))
})?;
versions.push((name, layer.inherits_from));
}
if versions.is_empty() {
return Err(
PackError::unsupported("实例的 versions/ 下没有 <id>/<id>.json".to_string()).with_hint(
"先装好游戏/加载器再导出;导出需要版本 JSON 才能写 game 与 dependencies",
),
);
}
let referenced: Vec<&str> = versions
.iter()
.filter_map(|(_, parent)| parent.as_deref())
.collect();
let mut leaves: Vec<String> = versions
.iter()
.filter(|(id, _)| !referenced.contains(&id.as_str()))
.map(|(id, _)| id.clone())
.collect();
leaves.sort();
match leaves.len() {
1 => Ok(leaves.remove(0)),
_ => Err(PackError::unsupported(format!(
"实例的 versions/ 下有 {} 条继承链末端({}),无法判断哪个属于这个实例",
leaves.len(),
leaves.join(", ")
))
.with_hint("调用方应显式给出 versionId(实例档案的 version_id)")),
}
}
/// 从 `id` 起沿 `inheritsFrom` 收集各层(第一层是实例自己,最后一层是最深的祖先)。
fn load_chain(versions_dir: &Path, id: &str) -> PackResult<Vec<VersionJson>> {
let mut layers: Vec<VersionJson> = Vec::new();
let mut visited: Vec<String> = Vec::new();
let mut current = Some(id.to_string());
while let Some(next) = current {
if visited.iter().any(|seen| seen == &next) {
return Err(PackError::metadata(format!(
"版本 JSON 的继承链成环:{} → {next}",
visited.join(" → ")
)));
}
visited.push(next.clone());
let path = versions_dir.join(&next).join(format!("{next}.json"));
let text = std::fs::read_to_string(&path).map_err(|error| {
PackError::metadata(format!("读版本 JSON {} 失败:{error}", path.display()))
.with_hint("继承链上的父版本也必须装在实例里")
})?;
let layer: VersionJson = serde_json::from_str(&text).map_err(|error| {
PackError::metadata(format!("{} 不是合法版本 JSON:{error}", path.display()))
})?;
current = layer.inherits_from.clone();
layers.push(layer);
}
Ok(layers)
}
/// Maven 坐标 → 加载器种类(认不出的返回 `None`,不影响别的库)。
fn loader_kind_of(group: &str, artifact: &str) -> Option<LoaderKind> {
match (group, artifact) {
("net.fabricmc", "fabric-loader") => Some(LoaderKind::Fabric),
("org.quiltmc", "quilt-loader") => Some(LoaderKind::Quilt),
("net.minecraftforge", "forge" | "fmlloader") => Some(LoaderKind::Forge),
("net.neoforged", "neoforge") => Some(LoaderKind::NeoForge),
_ => None,
}
}
/// 剥掉版本号里又写了一遍的 `<mc>-` 前缀(`1.20.1-47.1.106` → `47.1.106`)。
fn strip_game_prefix(game_version: &str, version: &str) -> String {
let prefix = format!("{game_version}-");
version
.strip_prefix(&prefix)
.filter(|rest| !rest.is_empty())
.unwrap_or(version)
.to_string()
}
+182
View File
@@ -0,0 +1,182 @@
//! 整合包**导入**(M111):CurseForge `manifest.json` / Modrinth `.mrpack` / MultiMC·Prism 实例
//! → 一个可启动的实例目录。
//!
//! 设计依据:`DevDocs/PlanDoc/08-mod-and-recommend.md` §2(D10)、
//! `DevDocs/PlanDoc/research/18-modpack-formats*.md`(字段清单,A/B 级来源)、
//! `DevDocs/SubAgentsTalk/addon-整合包导入导出对接要点.md`(施工口径)。
//! 格式模型与安全闸是 M110 的 [`crate::addon::pack`]:本模块**只消费,不重写**。
//!
//! # 三条硬要求落在哪
//!
//! | 要求 | 落点 |
//! |------|------|
//! | overrides 哈希**三方合并**(不静默覆盖用户改动) | [`merge`] 的判据 + [`baseline`] 的 base 侧 |
//! | 两阶段补全 + **失败分级**(仅"缺文件"可容忍) | [`pipeline`] 的顺序 + [`report::MissingFile`] 与 [`error::ImportError`] 的分工 |
//! | 路径穿越**双检** | 开包时 M110 [`crate::addon::pack::PackArchive::open`],落地时 [`stage`] 的 `normalize_file` + `starts_with` 复核 |
//!
//! # 三种格式的语义(tower 定案,与 HMCL 相反的两条要记住)
//!
//! - **mrpack**:`overrides/` 与 `client-overrides/` 都解压,**后者胜**(Modrinth 官方客户端语义;
//! HMCL 恰好相反);`server-overrides/` 客户端**不解压**;其余同名路径无官方优先级 ⇒ 按侧
//! 优先级落地,不自创排序。**同一实例路径被本包多份声明命中时**(跨侧或同侧重复),
//! 逐条算清楚:胜者按上面这条规则定,被遮蔽的那条不读字节、只留一条
//! `pack.import.note.override_shadowed` 注记说明「谁赢了 / 两份内容是否逐字节相同」
//! (见 [`plan::Shadowed`])。一路径一行,不再出现"两条 Installed、前一条没落地"。
//! - **CF**:溢出目录名取自清单 `overrides` 字段;`files[]` 只有 `projectID/fileID`,要 CF API
//! 才能补出文件名/地址——仓库里还没有 CF 客户端(D10 §5),因此这部分条目的**当前正确行为**
//! 是记成 `missing`(`Partial`),**绝不猜 `edge.forgecdn.net` 那类 URL**。
//! - **MultiMC / Prism**:`mmc-pack.json` 的 `formatVersion` 必须恰为 `1`(M110 的读者会拒绝
//! 其余值);`.minecraft/` 直拷;非加载器 component(`org.lwjgl3` 等)**逐项**进
//! [`report::RejectedComponent`],不静默丢;只有导入,没有导出。
//!
//! # `.disabled` 只按路径后缀
//!
//! `x.jar` / `x.jar.disabled` / `x.jar.old` / `x.jar.disabled.old` 四种变体都识别,后缀**原样
//! 保留**;`.old` 只识别与保留(不清除、不改名、不当回滚语义)。`env.client=optional` **不是**
//! 禁用——"可选"的语义是"安装时可取消勾选",装回来默认仍然启用,两者不是一回事。
//!
//! # 用法
//!
//! ```ignore
//! let engine = /* core::download::DownloadEngine */;
//! let fetcher = DownloadEngineFetcher::new(&engine);
//! let request = ImportRequest::new(pack_path, instance_dir)
//! .with_fetcher(&fetcher)
//! .with_registration(&launcher_layout, None);
//! let report = download::blocking::block_on(import_pack(&request));
//! match report.status { /* Success / Partial / Failed */ }
//! ```
//!
//! # 刻意不做的事(分工边界)
//!
//! - **不删除**整合包升级后不再包含的文件:磁盘上的 `mods/` 里可能有用户自己装的模组,
//! 删旧文件是"清理"而不是"导入"(要做得先有"这个文件是整合包装的"的完整账本 + 用户确认);
//! - **不装加载器**:只把清单翻译成 [`report::LoaderTarget`](`kind` + 原版版本 + 加载器版本
//! + 版本 id),真正的元数据解析/库下载仍是 `addon::loader` + `game::install` 的既有路径;
//! - **不碰 UI 类型**:core 零 UI 依赖(`core-no-ui` 门禁),报告只出现 `String` / `&'static str`;
//! - **不绕过下载引擎**:所有网络只经 [`fetch::PackFetcher`],生产实现只有
//! [`fetch::DownloadEngineFetcher`](走 `core::download`)。
//!
//! # 没验的部分(真实世界的欠账,报告里同样登记)
//!
//! 真实 CF / MultiMC 导出物(夹具是手造的)、超大包(>2G)、Windows 之外的平台、断网中途、
//! 磁盘满、真实 CurseForge `files[]` 补全(缺 CF 客户端,见 [`fetch`] 的说明)。
pub mod baseline;
pub mod error;
pub mod fetch;
pub mod merge;
pub mod phase;
pub mod pipeline;
pub mod plan;
pub mod report;
pub mod stage;
pub use baseline::{PackBaseline, BASELINE_FILE};
pub use error::{ImportError, ImportErrorKind};
pub use fetch::{
CurseFileMeta, DownloadEngineFetcher, FetchError, FetchErrorKind, FetchFuture, FetchRequest,
FetchedFile, PackFetcher,
};
pub use merge::{MergeAction, MergeDecision};
pub use pipeline::baseline_relative;
pub use plan::{ImportPlan, PlanOverride, PlanRemote, Shadowed, SuffixVariant};
pub use report::{
keys, ConflictChoice, ConflictFile, ConflictReason, FileAction, FileSource, ImportIssue,
ImportProgress, ImportProgressSink, ImportReport, ImportStage, ImportStatus, ImportedFile,
LoaderTarget, MissingFile, MissingReason, RegisteredInstance, RejectedComponent,
};
pub use stage::{RollbackOutcome, Staging};
use std::path::Path;
use crate::addon::pack::PackLimits;
use crate::platform::layout::LauncherLayout;
/// 冲突处置策略(默认保留用户文件)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ConflictPolicy {
/// 保留用户那一侧(不覆盖、不补回被删的文件),冲突逐条进报告。
#[default]
KeepUser,
/// 用包内内容覆盖(用户明确要求"整合包说了算")。
Overwrite,
}
/// 一次导入请求。
pub struct ImportRequest<'a> {
/// 包路径:zip(`.zip` / `.mrpack`)或 MultiMC 实例目录。
pub pack: &'a Path,
/// 目标实例的游戏目录(`mods/` 直接在其下)。
pub target: &'a Path,
/// 远端补全 + 下载;`None` = 离线导入(远端条目全部记成 `missing`)。
pub fetcher: Option<&'a dyn PackFetcher>,
/// 数据根布局;给了才登记实例(`profiles.json`)。
pub register: Option<&'a LauncherLayout>,
/// 实例展示名(缺省用包名)。
pub instance_name: Option<&'a str>,
/// 冲突策略。
pub conflict_policy: ConflictPolicy,
/// 包读取限额(条数 / 单条体积 / 解压总量)。
pub limits: PackLimits,
/// 进度回调。
pub progress: Option<ImportProgressSink>,
}
impl<'a> ImportRequest<'a> {
/// 最小请求(离线、不登记、保留用户文件、默认限额)。
#[must_use]
pub fn new(pack: &'a Path, target: &'a Path) -> Self {
Self {
pack,
target,
fetcher: None,
register: None,
instance_name: None,
conflict_policy: ConflictPolicy::KeepUser,
limits: PackLimits::default(),
progress: None,
}
}
/// 挂上远端补全 / 下载。
#[must_use]
pub fn with_fetcher(mut self, fetcher: &'a dyn PackFetcher) -> Self {
self.fetcher = Some(fetcher);
self
}
/// 挂上实例登记(`name` 为 `None` 时用包名)。
#[must_use]
pub fn with_registration(mut self, layout: &'a LauncherLayout, name: Option<&'a str>) -> Self {
self.register = Some(layout);
self.instance_name = name;
self
}
/// 改冲突策略。
#[must_use]
pub fn with_conflict_policy(mut self, policy: ConflictPolicy) -> Self {
self.conflict_policy = policy;
self
}
/// 改读取限额。
#[must_use]
pub fn with_limits(mut self, limits: PackLimits) -> Self {
self.limits = limits;
self
}
/// 挂进度回调。
#[must_use]
pub fn with_progress(mut self, sink: ImportProgressSink) -> Self {
self.progress = Some(sink);
self
}
}
/// 导入一个整合包。**永不返回 `Err`**:三态与逐项原因都在 [`ImportReport`] 里。
pub async fn import_pack(request: &ImportRequest<'_>) -> ImportReport {
pipeline::run(request).await
}
+162
View File
@@ -0,0 +1,162 @@
//! 三方合并的 **base 侧**:上次导入写下的"包给的哈希"。
//!
//! # 为什么必须落盘
//!
//! 三方合并的三个输入是:`theirs`(这次包里给的)、`ours`(磁盘现状)、`base`(**上次**包里
//! 给的)。`base` 不能从这次的包现算——那是 `theirs`;也不能从磁盘现算——那是 `ours`。少了
//! `base` 就分不清两种截然不同的事实:
//!
//! - 磁盘内容 ≠ 新包内容,且磁盘内容 == 上次包内容 ⇒ **整合包升级**,该覆盖(用户没动过);
//! - 磁盘内容 ≠ 新包内容,且磁盘内容 ≠ 上次包内容 ⇒ **用户改过**,不许静默覆盖。
//!
//! 所以第一次导入就在实例根写一份基线;没有基线时(首次导入 / 用户手动塞进来的文件 / 基线
//! 属于别的整合包)一律按"磁盘上已有同名文件"处理 —— 不覆盖、进冲突清单(最安全的一侧)。
//!
//! # 落点:**唯一来源是 [`crate::game::layout::GameLayout::ull_root`]**
//!
//! `<实例根>/.ull/pack-baseline.json`(游戏根内的启动器私有目录)。
//!
//! 这里有个踩过的坑(复审 r5 P1-1):基线最初写成 `<实例根>/.ULL/…`(用了
//! [`crate::platform::layout::LAUNCHER_HOME_DIR`],那是**启动器数据根**的名字,在 exe/portable
//! 那一层),而读侧用 `GameLayout::ull_root()`(`<实例根>/.ull`,小写,实例**游戏根**内的
//! 私有目录)。Windows / macOS 的大小写不敏感文件系统上两者是同一个文件,本机全绿;ext4 上
//! 是两个文件 ⇒ 基线永远读不到(`UserModified`/`UserDeleted` 永不出现)。
//!
//! 现在**读写都从 `ull_root()` 派生**:绝对路径 [`PackBaseline::path`],相对路径
//! [`PackBaseline::relative_path`],两者由 `addon/tests/import_merge.rs` 的一条断言钉死
//! (`path(root) == root.join(relative_path())`)——这条在大小写敏感的系统上也抓得住。
//!
//! # 包身份
//!
//! 基线记包名与格式;下次导入发现"基线属于另一个包"时**不拿它当 base**(否则跨包导入同一
//! 目录会把别人的哈希当成本次包的历史),而是降级成"无基线"并如实告警。
//!
//! 基线里存的是**包声明的哈希**(不是磁盘实际哈希):用户改过的文件在基线里仍是包的值,
//! 下次导入才会继续判成"用户改过",而不会被"上次也是改动值"洗白。`.disabled` / `.old`
//! 后缀按原样进键名(后缀是路径的一部分,不附加语义)。
//!
//! 读失败**不是**致命错误:基线坏了最多退化成"全部按 pre-existing 冲突处理"(保留用户文件),
//! 比"因为读不出基线就整包回滚"合理。
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::game::layout::GameLayout;
/// 基线文件名。
pub const BASELINE_FILE: &str = "pack-baseline.json";
/// 基线 schema 版本。
pub const BASELINE_SCHEMA: u32 = 1;
/// 上次导入写下的包声明哈希表。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PackBaseline {
/// schema 版本(迁移链入口)。
#[serde(default = "default_schema")]
pub schema_version: u32,
/// 上次导入的包名(诊断 + 包身份判据)。
#[serde(default)]
pub pack_name: String,
/// 上次导入命中的格式 id(包身份判据的另一半)。
#[serde(default)]
pub format: String,
/// 实例内相对路径 → 包声明的 SHA-1。
#[serde(default)]
pub files: BTreeMap<String, String>,
}
const fn default_schema() -> u32 {
BASELINE_SCHEMA
}
impl Default for PackBaseline {
fn default() -> Self {
Self {
schema_version: BASELINE_SCHEMA,
pack_name: String::new(),
format: String::new(),
files: BTreeMap::new(),
}
}
}
impl PackBaseline {
/// 实例根下的基线**绝对**路径。
///
/// 唯一来源是 `GameLayout::ull_root()`:实例的游戏根里那个启动器私有目录。
#[must_use]
pub fn path(game_root: &Path) -> PathBuf {
GameLayout::new(game_root).ull_root().join(BASELINE_FILE)
}
/// 基线**相对实例根**的路径(暂存期与写盘用;与 [`Self::path`] 同源)。
#[must_use]
pub fn relative_path() -> PathBuf {
// 用空根求 `ull_root()` 就得到那一段相对目录名(`.ull`),不另抄一份字符串。
GameLayout::new(Path::new(""))
.ull_root()
.join(BASELINE_FILE)
}
/// 基线是否属于这份包(空基线 / 没有身份信息的老基线一律放行)。
#[must_use]
pub fn matches_pack(&self, format: &str, pack_name: &str) -> bool {
if self.pack_name.is_empty() && self.format.is_empty() {
return true;
}
self.format == format && self.pack_name == pack_name
}
/// 读基线;文件不存在返回空表(**不是错误**),形状不符返回 `Err`。
///
/// # Errors
///
/// 非 UTF-8 / JSON 非法 / 版本号比本进程新(未来版本写的)时返回人读原因。
pub fn load(game_root: &Path) -> Result<Self, String> {
let path = Self::path(game_root);
let bytes = match std::fs::read(&path) {
Ok(bytes) => bytes,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Ok(Self::default())
}
Err(error) => {
return Err(format!("读 {} 失败:{error}", path.display()));
}
};
let text = String::from_utf8(bytes)
.map_err(|error| format!("{} 不是 UTF-8:{error}", path.display()))?;
let baseline: Self = serde_json::from_str(&text)
.map_err(|error| format!("{} 形状不符:{error}", path.display()))?;
if baseline.schema_version > BASELINE_SCHEMA {
return Err(format!(
"{} 的 schemaVersion={} 比本进程({BASELINE_SCHEMA})新",
path.display(),
baseline.schema_version
));
}
Ok(baseline)
}
/// 查上次给这个路径的哈希。
#[must_use]
pub fn sha1(&self, path: &str) -> Option<&str> {
self.files.get(path).map(String::as_str)
}
/// 记一条。
pub fn insert(&mut self, path: impl Into<String>, sha1: impl Into<String>) {
self.files.insert(path.into(), sha1.into());
}
/// 序列化成落盘字节(pretty:这文件是给人排障看的)。
///
/// # Errors
///
/// 序列化失败(纯理论,`BTreeMap<String, String>` 不会失败)。
pub fn to_bytes(&self) -> Result<Vec<u8>, String> {
serde_json::to_vec_pretty(self).map_err(|error| format!("基线序列化失败:{error}"))
}
}
+204
View File
@@ -0,0 +1,204 @@
//! 导入链路的**致命**失败分类(能容忍的缺文件不在这里,它们在
//! [`super::report::MissingFile`] 里逐条记账)。
//!
//! # 为什么要跟 `MissingFile` 分开
//!
//! D10 §2 的失败分级只有一条判据:**这个错误是不是"某个文件搞不到"**。
//!
//! | 情形 | 处置 | 落在哪 |
//! |------|------|--------|
//! | 某个远端文件下载失败 / 补全不出地址 / 没有网络 | 容忍,实例照装,状态降级 `Partial` | [`super::report::MissingFile`] |
//! | 路径穿越 / 符号链接 / 超限额 / 清单坏 / 本地 IO / 摘要不符 | **整包回滚**,目标目录零残留 | 本模块 |
//!
//! 因此 [`ImportError`] 只描述**必须回滚**的失败;能容忍的东西一旦也塞进来,调用方就再也
//! 分不清"该重试"和"该报恶意包"了。
//!
//! # 稳定码
//!
//! 包相关六类**直接沿用 M110 的码**(`pack_unsupported` / `pack_metadata` /
//! `pack_traversal` / `pack_symlink` / `pack_limits` / `pack_io`),不另起一套同义词:
//! 同一个事实在 CLI、日志、事件流里只能有一个字符串。导入独有的三类加 `import_` 前缀。
use std::fmt;
use super::super::pack::{PackError, PackErrorKind};
use super::fetch::{FetchError, FetchErrorKind};
/// 导入失败大类(稳定码进 `--json` 的 `error.code` 与日志,**不要改名**)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ImportErrorKind {
/// 透传 M110 的六类包错误(稳定码沿用 `pack_*`)。
Pack(PackErrorKind),
/// 下载到的内容与清单声明的摘要/大小**不符**(`import_integrity`)。
Integrity,
/// 远端补全/下载在**致命**情形下失败(`import_fetch`;本地 IO 归 `pack_io`)。
Fetch,
/// 清单里的加载器信息不足以组出安装计划(`import_loader`)。
Loader,
/// 实例登记失败(`import_instance`)。
Instance,
}
impl ImportErrorKind {
/// 稳定字符串 id(进 `--json` 与日志)。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Pack(kind) => kind.as_str(),
Self::Integrity => "import_integrity",
Self::Fetch => "import_fetch",
Self::Loader => "import_loader",
Self::Instance => "import_instance",
}
}
/// 人读大类名(报告表格用)。
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::Pack(kind) => pack_label(kind),
Self::Integrity => "内容与清单不符",
Self::Fetch => "远端补全失败",
Self::Loader => "加载器信息不足",
Self::Instance => "实例登记失败",
}
}
}
/// M110 六个大类的人读名(与 `PackErrorKind` 的稳定码一一对应,不另起一套枚举)。
const fn pack_label(kind: PackErrorKind) -> &'static str {
match kind {
PackErrorKind::Unsupported => "格式不支持",
PackErrorKind::Metadata => "清单损坏",
PackErrorKind::Traversal => "路径穿越",
PackErrorKind::Symlink => "符号链接条目",
PackErrorKind::Limits => "超出安全限额",
PackErrorKind::Io => "本地 IO 失败",
}
}
/// 一次**必须回滚**的导入失败。
#[derive(Debug, Clone)]
pub struct ImportError {
kind: ImportErrorKind,
message: String,
hint: Option<String>,
}
impl ImportError {
/// 构造。
#[must_use]
pub fn new(kind: ImportErrorKind, message: impl Into<String>) -> Self {
Self {
kind,
message: message.into(),
hint: None,
}
}
/// 内容与清单声明不符。
#[must_use]
pub fn integrity(message: impl Into<String>) -> Self {
Self::new(ImportErrorKind::Integrity, message)
}
/// 远端补全/下载的致命失败。
#[must_use]
pub fn fetch(message: impl Into<String>) -> Self {
Self::new(ImportErrorKind::Fetch, message)
}
/// 加载器信息不足。
#[must_use]
pub fn loader(message: impl Into<String>) -> Self {
Self::new(ImportErrorKind::Loader, message)
}
/// 实例登记失败。
#[must_use]
pub fn instance(message: impl Into<String>) -> Self {
Self::new(ImportErrorKind::Instance, message)
}
/// 补一句提示。
#[must_use]
pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
self.hint = Some(hint.into());
self
}
/// 大类。
#[must_use]
pub const fn kind(&self) -> ImportErrorKind {
self.kind
}
/// 稳定码。
#[must_use]
pub const fn code(&self) -> &'static str {
self.kind.as_str()
}
/// 人读说明。
#[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 ImportError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.message)
}
}
impl std::error::Error for ImportError {}
impl From<PackError> for ImportError {
fn from(error: PackError) -> Self {
Self {
kind: ImportErrorKind::Pack(error.kind()),
message: error.message().to_string(),
hint: error.hint().map(str::to_string),
}
}
}
impl From<FetchError> for ImportError {
fn from(error: FetchError) -> Self {
// 分级判据只有一处:`Integrity` 与 `Io` 必须回滚,其余三类是"搞不到"。
let kind = match error.kind() {
FetchErrorKind::Integrity => ImportErrorKind::Integrity,
FetchErrorKind::Io => ImportErrorKind::Pack(PackErrorKind::Io),
FetchErrorKind::NotFound | FetchErrorKind::Network | FetchErrorKind::Unsupported => {
ImportErrorKind::Fetch
}
};
Self {
kind,
message: error.message().to_string(),
hint: None,
}
}
}
impl serde::Serialize for ImportError {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
use serde::ser::SerializeMap;
let mut map = serializer.serialize_map(None)?;
map.serialize_entry("code", self.code())?;
map.serialize_entry("kind", self.kind.label())?;
map.serialize_entry("message", &self.message)?;
if let Some(hint) = &self.hint {
map.serialize_entry("hint", hint)?;
}
map.end()
}
}
+234
View File
@@ -0,0 +1,234 @@
//! 远端补全与下载的**注入点**:导入管线自己不碰网络,只认这个 trait。
//!
//! # 为什么是 trait 而不是直接调 `DownloadEngine`
//!
//! 三条理由,每条都对应一个真实麻烦:
//!
//! 1. **测试不能联网**:三种格式的端到端用例必须离线跑(CI 没有网,也不该有);
//! 2. **CurseForge 的 `files[]` 补全还没有客户端**:CF 官方清单只有 `projectID/fileID`,
//! 要 `x-api-key` 的 `api.curseforge.com/v1/mods/<id>/files/<fid>` 才能换出文件名与地址。
//! 仓库里没有这个客户端(D10 §5 规划中),而 [`crate::download::DownloadRequest`] **没有
//! 自定义 header 字段**——硬塞进去会把"补不出来"伪装成"网络失败"。所以默认实现诚实地
//! 返回 [`FetchErrorKind::Unsupported`](= 可容忍的"搞不到",进报告的 `missing`),
//! **绝不猜下载 URL**:猜出来的地址一旦 404,用户看到的是"导入失败"而不是"这条暂时补不了";
//! 3. **依赖方向**:`addon` 属于更高层的领域逻辑,让它直接持有一个 `DownloadEngine` 会把
//! 镜像/健康度/地区档案这些下载侧策略涌进导入链路。
//!
//! # 生产实现只走 `core::download`
//!
//! [`DownloadEngineFetcher`] 是本 crate 里**唯一**的生产实现,它只做一件事:把
//! [`FetchRequest`] 翻译成 [`crate::download::DownloadRequest`] 交给引擎(镜像展开、退避、
//! 续传、边下边算都在引擎里)。导入链路里**没有**第二个 `reqwest::Client`。
use std::future::Future;
use std::path::Path;
use std::pin::Pin;
use crate::download::{
DownloadEngine, DownloadError, DownloadErrorKind, DownloadRequest, Integrity, ResourceKind,
};
/// "搞不到"的成因(**分级判据只在这里**)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FetchErrorKind {
/// 源上确实没有(404 / 文件被删)。
NotFound,
/// 网络侧失败(超时、连不上、所有候选源都失败)。
Network,
/// 本地 IO(写盘失败、磁盘满)。
Io,
/// 内容与声明的摘要不符。
Integrity,
/// 这条路走不通(CF 无客户端 / 没有可用下载地址)。
Unsupported,
}
impl FetchErrorKind {
/// 稳定字符串 id。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::NotFound => "fetch_not_found",
Self::Network => "fetch_network",
Self::Io => "fetch_io",
Self::Integrity => "fetch_integrity",
Self::Unsupported => "fetch_unsupported",
}
}
/// 是否属于"某个文件搞不到"(可容忍,实例照装)。
#[must_use]
pub const fn is_tolerable(self) -> bool {
matches!(self, Self::NotFound | Self::Network | Self::Unsupported)
}
}
/// 一次取数失败。
#[derive(Debug, Clone)]
pub struct FetchError {
kind: FetchErrorKind,
message: String,
}
impl FetchError {
/// 构造。
#[must_use]
pub fn new(kind: FetchErrorKind, message: impl Into<String>) -> Self {
Self {
kind,
message: message.into(),
}
}
/// 这条路走不通。
#[must_use]
pub fn unsupported(message: impl Into<String>) -> Self {
Self::new(FetchErrorKind::Unsupported, message)
}
/// 内容与声明不符。
#[must_use]
pub fn integrity(message: impl Into<String>) -> Self {
Self::new(FetchErrorKind::Integrity, message)
}
/// 大类。
#[must_use]
pub const fn kind(&self) -> FetchErrorKind {
self.kind
}
/// 人读说明。
#[must_use]
pub fn message(&self) -> &str {
&self.message
}
}
impl std::fmt::Display for FetchError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(&self.message)
}
}
impl std::error::Error for FetchError {}
impl From<DownloadError> for FetchError {
fn from(error: DownloadError) -> Self {
let kind = match error.kind {
DownloadErrorKind::NotFound => FetchErrorKind::NotFound,
DownloadErrorKind::Integrity => FetchErrorKind::Integrity,
DownloadErrorKind::Io => FetchErrorKind::Io,
DownloadErrorKind::InvalidRequest => FetchErrorKind::Unsupported,
DownloadErrorKind::ServerError
| DownloadErrorKind::Timeout
| DownloadErrorKind::Network
| DownloadErrorKind::RangeUnsupported
| DownloadErrorKind::AllSourcesFailed => FetchErrorKind::Network,
};
Self::new(kind, error.message)
}
}
/// trait 方法返回的装箱 future(core 不引 `async-trait`,手写 `Pin<Box<dyn Future>>` 即可)。
pub type FetchFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
/// CurseForge 一个文件的元数据(`files[]` 补全的产物)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CurseFileMeta {
/// 文件名(`jei-1.20.1-15.2.0.27.jar`)。
pub file_name: String,
/// 下载地址;`None` = 作者关了 API 分发(CF 官方允许),需要用户手动提供。
pub url: Option<String>,
/// 官方给的 SHA-1。
pub sha1: Option<String>,
/// 字节数。
pub size: Option<u64>,
/// 项目类别(CF `classId`:6 = mods、12 = resource packs、6945 = shaders …)。
pub class_id: Option<i64>,
}
/// 一次文件下载请求。
#[derive(Debug, Clone, Copy)]
pub struct FetchRequest<'a> {
/// 逻辑下载地址(官方源)。
pub url: &'a str,
/// 落盘路径(导入器给的**暂存**路径,不是最终实例路径)。
pub dest: &'a Path,
/// 期望 SHA-1(清单声明的)。
pub sha1: Option<&'a str>,
/// 期望字节数。
pub size: Option<u64>,
}
/// 一次下载的产物。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FetchedFile {
/// 落盘字节数。
pub bytes: u64,
}
/// 远端补全 + 下载(测试注入假件,生产走 [`DownloadEngineFetcher`])。
pub trait PackFetcher: Send + Sync {
/// CurseForge `projectID/fileID` → 文件名 / 下载地址 / 摘要。
///
/// 默认实现返回 [`FetchErrorKind::Unsupported`]:见模块文档第 2 条(**不许猜 URL**)。
fn curse_file<'a>(
&'a self,
project_id: i64,
file_id: i64,
) -> FetchFuture<'a, Result<CurseFileMeta, FetchError>> {
Box::pin(async move {
Err(FetchError::unsupported(format!(
"CurseForge files[] 补全需要 CF API 客户端(projectID={project_id} fileID={file_id});当前版本没有,本条记为缺文件"
)))
})
}
/// 把 `request.url` 下到 `request.dest`。
fn fetch_file<'a>(
&'a self,
request: FetchRequest<'a>,
) -> FetchFuture<'a, Result<FetchedFile, FetchError>>;
}
/// 生产实现:把请求交给 `core::download` 的引擎。
#[derive(Debug, Clone, Copy)]
pub struct DownloadEngineFetcher<'a> {
engine: &'a DownloadEngine,
}
impl<'a> DownloadEngineFetcher<'a> {
/// 绑定引擎。
#[must_use]
pub const fn new(engine: &'a DownloadEngine) -> Self {
Self { engine }
}
}
impl PackFetcher for DownloadEngineFetcher<'_> {
fn fetch_file<'a>(
&'a self,
request: FetchRequest<'a>,
) -> FetchFuture<'a, Result<FetchedFile, FetchError>> {
Box::pin(async move {
let mut download = DownloadRequest::new(
ResourceKind::ModFile,
request.url,
request.dest.to_path_buf(),
);
// 摘要长度不对就当"没给":引擎白跑一趟网络比这更贵。
if let Some(sha1) = request.sha1 {
let integrity = Integrity::sha1(sha1);
if integrity.is_well_formed() {
download.integrity = Some(integrity);
}
}
download.expected_size = request.size;
let outcome = self.engine.download(download).await?;
Ok(FetchedFile {
bytes: outcome.bytes,
})
})
}
}
+139
View File
@@ -0,0 +1,139 @@
//! overrides 三方合并的**判据**(纯函数,无 IO):包内原值 / 磁盘现状 / 基线哈希。
//!
//! # 一张表看完全部情形
//!
//! 记 `T` = 包内声明(theirs)、`B` = 基线(base,上次导入的包声明)、`D` = 磁盘现状(ours)。
//! `D` 为 `None` 表示文件不存在。
//!
//! | `D` | `B` | 关系 | 结论 | 理由 |
//! |-----|-----|------|------|------|
//! | 无 | 无 | — | [`MergeAction::Install`] | 全新文件,没有可保护的东西 |
//! | 无 | 有 | `B` 任意 | [`MergeAction::KeepUser`] + `UserDeleted` | 用户删过:**不许**偷偷装回来 |
//! | 有 | 无 | `D == T` | [`MergeAction::SkipIdentical`] | 内容一样,写盘是浪费 |
//! | 有 | 无 | `D != T` | [`MergeAction::KeepUser`] + `PreExisting` | 没有基线可判 ⇒ 当用户已有内容 |
//! | 有 | 有 | `D == T` | [`MergeAction::SkipIdentical`] | 已经是最新 |
//! | 有 | 有 | `D == B` | [`MergeAction::Install`] | 用户没动过 ⇒ 整合包升级,覆盖是对的 |
//! | 有 | 有 | `D != B` | [`MergeAction::KeepUser`] + `UserModified` | 用户改过:**不许**静默覆盖 |
//!
//! 两条"不许"就是任务里的硬要求("不得直接盖掉用户改过的文件")。判据只此一处:
//! `ImportRequest::conflict_policy = Overwrite` 时,`KeepUser` 才会被判据的调用方翻成
//! [`MergeAction::Install`],**判据本身不读策略**——这样"哪些情形算冲突"与"冲突怎么处置"
//! 是两个可独立验证的事实。
//!
//! # `.disabled` / `.old` 不参与判据
//!
//! 后缀是路径的一部分(`mods/x.jar.disabled` 与 `mods/x.jar` 是**两个文件**),所以它们
//! 天然按不同键各自走上面的表。不给 `.old` 附加"清理/回滚"语义(tower 定案:只识别与保留)。
use super::report::ConflictReason;
/// 判据给出的动作(策略开关在调用方)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MergeAction {
/// 写入包内内容(新建或覆盖)。
Install,
/// 磁盘内容已经与包内一致,什么都不做。
SkipIdentical,
/// 保留用户那一侧(不写盘)。
KeepUser,
}
/// 一次判定。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MergeDecision {
/// 动作。
pub action: MergeAction,
/// 算冲突时的成因(`None` = 不是冲突)。
pub reason: Option<ConflictReason>,
}
impl MergeDecision {
const fn plain(action: MergeAction) -> Self {
Self {
action,
reason: None,
}
}
const fn conflict(reason: ConflictReason) -> Self {
Self {
action: MergeAction::KeepUser,
reason: Some(reason),
}
}
/// 是否冲突(与最终处置无关)。
#[must_use]
pub const fn is_conflict(self) -> bool {
self.reason.is_some()
}
}
/// 三方合并判据。
#[must_use]
pub fn decide(pack_sha1: &str, base_sha1: Option<&str>, disk_sha1: Option<&str>) -> MergeDecision {
match (disk_sha1, base_sha1) {
// 磁盘上没有这个文件。
(None, None) => MergeDecision::plain(MergeAction::Install),
(None, Some(_)) => MergeDecision::conflict(ConflictReason::UserDeleted),
// 磁盘上有。
(Some(disk), _) if disk == pack_sha1 => MergeDecision::plain(MergeAction::SkipIdentical),
(Some(_), None) => MergeDecision::conflict(ConflictReason::PreExisting),
(Some(disk), Some(base)) if disk == base => MergeDecision::plain(MergeAction::Install),
(Some(_), Some(_)) => MergeDecision::conflict(ConflictReason::UserModified),
}
}
#[cfg(test)]
mod tests {
use super::*;
const T: &str = "aaaa";
const B: &str = "bbbb";
#[test]
fn fresh_file_is_installed() {
let decision = decide(T, None, None);
assert_eq!(decision.action, MergeAction::Install);
assert!(!decision.is_conflict());
}
#[test]
fn untouched_file_is_replaced_on_upgrade() {
// 磁盘 == 基线(用户没动过)⇒ 整合包升级,覆盖。
let decision = decide(T, Some(B), Some(B));
assert_eq!(decision.action, MergeAction::Install);
assert!(!decision.is_conflict());
}
#[test]
fn user_modification_is_a_conflict() {
let decision = decide(T, Some(B), Some("cccc"));
assert_eq!(decision.action, MergeAction::KeepUser);
assert_eq!(decision.reason, Some(ConflictReason::UserModified));
}
#[test]
fn user_deletion_is_a_conflict() {
let decision = decide(T, Some(B), None);
assert_eq!(decision.action, MergeAction::KeepUser);
assert_eq!(decision.reason, Some(ConflictReason::UserDeleted));
}
#[test]
fn pre_existing_without_baseline_is_a_conflict() {
let decision = decide(T, None, Some("dddd"));
assert_eq!(decision.action, MergeAction::KeepUser);
assert_eq!(decision.reason, Some(ConflictReason::PreExisting));
}
#[test]
fn identical_content_is_skipped() {
// 无论有没有基线,磁盘与包内一致就不写。
assert_eq!(decide(T, None, Some(T)).action, MergeAction::SkipIdentical);
assert_eq!(
decide(T, Some(B), Some(T)).action,
MergeAction::SkipIdentical
);
}
}
+389
View File
@@ -0,0 +1,389 @@
//! 两个落盘阶段的实现:**覆盖内容解压** 与 **远端补全下载**(共用同一张三方合并表)。
//!
//! 拆出来是为了让 `pipeline.rs` 只剩"顺序与收尾":阶段实现里看得见"每个文件怎么判",
//! 编排里看得见"什么时候回滚"。
use std::path::Path;
use super::super::pack::{PackArchive, PackError};
use super::baseline::PackBaseline;
use super::error::ImportError;
use super::fetch::{FetchError, FetchRequest};
use super::merge::{self, MergeAction};
use super::pipeline::tell;
use super::plan::{curse_class_dir, ImportPlan, PlanRemote};
use super::report::{
ConflictChoice, ConflictFile, ConflictReason, FileAction, ImportReport, ImportStage,
ImportedFile, MissingFile, MissingReason,
};
use super::stage::Staging;
use super::ImportRequest;
use crate::download::verify::{self, digest_bytes};
use crate::download::HashAlgo;
/// 阶段一:覆盖条目落暂存 + 三方合并。
///
/// 被同包另一条声明遮蔽的条目([`super::plan::PlanOverride::shadowed`])**不读字节、不落地**:
/// 它的结论已经由 `plan::layer::resolve` 算成一条 `NOTE_OVERRIDE_SHADOWED` 注记
/// (进 `report.warnings`),而这条路径的行由真正的胜者给出——一路径一行,不再出现
/// "两条 Installed、前一条的哈希其实没落地"。
pub(crate) async fn extract_overrides(
request: &ImportRequest<'_>,
archive: &PackArchive,
plan: &ImportPlan,
baseline: &PackBaseline,
staging: &Staging,
report: &mut ImportReport,
next: &mut PackBaseline,
) -> Result<(), ImportError> {
let live: Vec<&super::plan::PlanOverride> = plan
.overrides
.iter()
.filter(|entry| entry.shadowed.is_none())
.collect();
let total = live.len();
let target = staging.target().to_path_buf();
for (index, entry) in live.iter().enumerate() {
tell(
&request.progress,
ImportStage::Extract,
index,
total,
Some(entry.path.clone()),
);
let name = format!("{}/{}", entry.dir, entry.path);
let bytes = archive.read(&name)?.ok_or_else(|| {
ImportError::from(PackError::metadata(format!(
"清单列了 {name},但包里读不到这个条目(声明与实际不符)"
)))
})?;
// 用**实测**摘要而不是模型里记的那个:模型可能来自外部构造。
let pack_sha1 = digest_bytes(HashAlgo::Sha1, &bytes);
next.insert(entry.path.clone(), pack_sha1.clone());
let disk = disk_sha1(&target.join(&entry.path))?;
let decision = merge::decide(&pack_sha1, baseline.sha1(&entry.path), disk.as_deref());
let (action, choice) = settle(decision, disk.is_some(), request.conflict_policy);
if let Some(reason) = decision.reason {
report.conflicts.push(conflict(
entry.path.clone(),
reason,
choice,
&pack_sha1,
bytes.len() as u64,
baseline.sha1(&entry.path).map(str::to_string),
disk,
));
}
if action.wrote() {
staging.write(&entry.path, &bytes)?;
report.bytes_written += bytes.len() as u64;
}
report.files.push(ImportedFile {
path: entry.path.clone(),
source: entry.source,
sha1: Some(pack_sha1),
bytes: bytes.len() as u64,
disabled: entry.disabled,
action,
note: entry.note.clone(),
});
}
Ok(())
}
/// 阶段二:远端条目补全 + 下载 + 同一张合并表。
pub(crate) async fn fetch_remote(
request: &ImportRequest<'_>,
plan: &ImportPlan,
baseline: &PackBaseline,
staging: &Staging,
report: &mut ImportReport,
next: &mut PackBaseline,
) -> Result<(), ImportError> {
let total = plan.remote.len();
let target = staging.target().to_path_buf();
for (index, entry) in plan.remote.iter().enumerate() {
let display = entry.path.clone().unwrap_or_else(|| entry.file.key());
tell(
&request.progress,
ImportStage::Fetch,
index,
total,
Some(display),
);
let Some(resolved) = resolve(request, entry).await? else {
report
.missing
.push(missing_for(entry, request.fetcher.is_some()));
continue;
};
let disk = disk_sha1(&target.join(&resolved.path))?;
// 有声明摘要就能先判合并:命中"保留用户 / 已一致"时**不必下载**(省一次网络)。
if let Some(declared) = resolved.sha1.clone() {
let decision = merge::decide(&declared, baseline.sha1(&resolved.path), disk.as_deref());
let (action, choice) = settle(decision, disk.is_some(), request.conflict_policy);
if !action.wrote() {
next.insert(resolved.path.clone(), declared.clone());
if let Some(reason) = decision.reason {
report.conflicts.push(conflict(
resolved.path.clone(),
reason,
choice,
&declared,
resolved.size.unwrap_or(0),
baseline.sha1(&resolved.path).map(str::to_string),
disk.clone(),
));
}
report.files.push(ImportedFile {
path: resolved.path,
source: entry.source,
sha1: Some(declared),
bytes: resolved.size.unwrap_or(0),
disabled: entry.disabled,
action,
note: None,
});
continue;
}
}
let dest = staging.dest_for(&resolved.path)?;
let fetched = match request.fetcher {
Some(fetcher) => {
fetcher
.fetch_file(FetchRequest {
url: &resolved.url,
dest: &dest,
sha1: resolved.sha1.as_deref(),
size: resolved.size,
})
.await
}
None => Err(FetchError::unsupported("本次导入没有 fetcher(离线)")),
};
let fetched = match fetched {
Ok(fetched) => fetched,
Err(error) if error.kind().is_tolerable() => {
report.missing.push(missing_from_error(
&resolved.path,
entry,
MissingReason::FetchFailed,
error.message(),
));
continue;
}
Err(error) => return Err(error.into()),
};
let pack_sha1 = digest_file(&dest)?;
if let Some(declared) = resolved.sha1.as_deref() {
if !verify::matches(declared, &pack_sha1) {
// 摘要不符 = 内容坏了 / 被换过,**不许**当成"暂时搞不到"混过去。
return Err(ImportError::integrity(format!(
"{} 的摘要与清单声明不符(声明 {declared},实际 {pack_sha1})",
resolved.path
)));
}
}
let decision = merge::decide(&pack_sha1, baseline.sha1(&resolved.path), disk.as_deref());
let (action, choice) = settle(decision, disk.is_some(), request.conflict_policy);
if let Some(reason) = decision.reason {
report.conflicts.push(conflict(
resolved.path.clone(),
reason,
choice,
&pack_sha1,
fetched.bytes,
baseline.sha1(&resolved.path).map(str::to_string),
disk,
));
}
if action.wrote() {
report.bytes_written += fetched.bytes;
} else {
staging.discard(&resolved.path)?;
}
next.insert(resolved.path.clone(), pack_sha1.clone());
report.files.push(ImportedFile {
path: resolved.path,
source: entry.source,
sha1: Some(pack_sha1),
bytes: fetched.bytes,
disabled: entry.disabled,
action,
note: None,
});
}
Ok(())
}
/// 一个已解析出"路径 + 地址"的远端条目。
struct Resolved {
path: String,
url: String,
sha1: Option<String>,
size: Option<u64>,
}
/// 把条目补全成"有路径、有地址"。
///
/// `Ok(None)` = 这条补不出来(进 `missing`);`Err` = 致命(本地 IO / 内容不符)。
async fn resolve(
request: &ImportRequest<'_>,
entry: &PlanRemote,
) -> Result<Option<Resolved>, ImportError> {
if let Some(path) = &entry.path {
// mrpack:路径与地址都在清单里,取第一个非空地址。
let Some(url) = entry
.file
.downloads
.iter()
.find(|url| !url.trim().is_empty())
.cloned()
else {
return Ok(None);
};
return Ok(Some(Resolved {
path: path.clone(),
url,
sha1: entry.file.sha1().map(str::to_string),
size: entry.file.size,
}));
}
// CurseForge:只有 projectID/fileID,必须靠 CF API 补全(当前默认不支持 ⇒ 如实记缺)。
let (Some(project_id), Some(file_id)) = (entry.file.project_id, entry.file.file_id) else {
return Ok(None);
};
let Some(fetcher) = request.fetcher else {
return Ok(None);
};
let meta = match fetcher.curse_file(project_id, file_id).await {
Ok(meta) => meta,
Err(error) if error.kind().is_tolerable() => return Ok(None),
Err(error) => return Err(error.into()),
};
let (Some(url), Some(dir)) = (
meta.url.clone().filter(|url| !url.trim().is_empty()),
meta.class_id.and_then(curse_class_dir),
) else {
// 作者关了 API 分发 / classId 认不出 ⇒ **不猜**目录、不猜 URL。
return Ok(None);
};
Ok(Some(Resolved {
path: format!("{dir}/{}", meta.file_name),
url,
sha1: meta.sha1,
size: meta.size,
}))
}
/// 合并判定 + 策略 → 实际动作。
fn settle(
decision: merge::MergeDecision,
disk_exists: bool,
policy: super::ConflictPolicy,
) -> (FileAction, ConflictChoice) {
let overwrite = policy == super::ConflictPolicy::Overwrite;
match decision.action {
MergeAction::Install => (
if disk_exists {
FileAction::Replaced
} else {
FileAction::Installed
},
ConflictChoice::Overwrite,
),
MergeAction::SkipIdentical => (FileAction::SkippedIdentical, ConflictChoice::KeepUser),
MergeAction::KeepUser if overwrite => (
if disk_exists {
FileAction::Replaced
} else {
FileAction::Installed
},
ConflictChoice::Overwrite,
),
MergeAction::KeepUser => (FileAction::KeptUser, ConflictChoice::KeepUser),
}
}
/// 组装一条冲突记录。
fn conflict(
path: String,
reason: ConflictReason,
choice: ConflictChoice,
pack_sha1: &str,
pack_size: u64,
base_sha1: Option<String>,
disk_sha1: Option<String>,
) -> ConflictFile {
ConflictFile {
path,
reason,
choice,
message_key: reason.message_key(),
pack_sha1: pack_sha1.to_string(),
pack_size,
base_sha1,
disk_sha1,
}
}
/// 组装一条缺失记录(条目本身补不出来)。
fn missing_for(entry: &PlanRemote, fetcher_present: bool) -> MissingFile {
let reason = if fetcher_present {
MissingReason::NoSource
} else {
MissingReason::Offline
};
let detail = if fetcher_present {
"清单里没有可用下载地址,或 CurseForge 元数据补不出来(不猜 URL)"
} else {
"本次导入没有接 fetcher(离线),远端文件一律跳过"
};
missing_from_error(
&entry.path.clone().unwrap_or_else(|| entry.file.key()),
entry,
reason,
detail,
)
}
/// 组装一条缺失记录(已知原因与细节)。
fn missing_from_error(
display: &str,
entry: &PlanRemote,
reason: MissingReason,
detail: &str,
) -> MissingFile {
MissingFile {
path: entry.path.clone(),
display: display.to_string(),
project_id: entry.file.project_id,
file_id: entry.file.file_id,
reason,
message_key: reason.message_key(),
detail: detail.to_string(),
}
}
/// 磁盘现状的 SHA-1(不存在 ⇒ `None`)。
fn disk_sha1(path: &Path) -> Result<Option<String>, ImportError> {
match std::fs::metadata(path) {
Ok(metadata) if metadata.is_file() => verify::digest_file(path, HashAlgo::Sha1)
.map(Some)
.map_err(|error| pack_io(format!("算 {} 的摘要失败:{error}", path.display()))),
Ok(_) => Ok(None),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(pack_io(format!("看 {} 失败:{error}", path.display()))),
}
}
/// 文件的 SHA-1。
fn digest_file(path: &Path) -> Result<String, ImportError> {
verify::digest_file(path, HashAlgo::Sha1)
.map_err(|error| pack_io(format!("算 {} 的摘要失败:{error}", path.display())))
}
/// `pack_io` 便捷构造(本地 IO 复用 M110 的稳定码,不再造新码)。
fn pack_io(message: impl Into<String>) -> ImportError {
PackError::io(message).into()
}
+389
View File
@@ -0,0 +1,389 @@
//! 导入编排:**读取清单 → 解压覆盖内容 → 补全下载 → 落地 → 登记**,每步都可中断、可失败、可重试。
//!
//! # 两阶段落盘(任务硬要求)
//!
//! 先落 `overrides/`(离线、包内自带),再去补远端文件。顺序不是审美问题:
//!
//! - 覆盖内容是最可能被用户改过的那部分——先判定冲突,才能在"用户改过"时**既不覆盖**也不
//! 白下载同名文件(远端条目走同一张三方合并表,冲突就直接跳过下载);
//! - 远端补全是唯一会失败于网络的一步,放最后意味着"网络不通"最多让结果变成 `Partial`,
//! 不会让已经解好的覆盖内容白干。
//!
//! # 失败分级(判据只在一处:是不是"某个文件搞不到")
//!
//! | 失败 | 结果 |
//! |------|------|
//! | 开包/探测/读清单/组计划失败(穿越、符号链接、限额、清单坏) | `Failed`,临时目录删掉,目标目录零残留 |
//! | 某条远端文件摸不到(离线 / CF 无客户端 / 404) | `Partial`,逐条进 `missing`,实例照装 |
//! | 下载到的内容与声明摘要不符 | `Failed`(这是**损坏或被换过**,不是"暂时搞不到") |
//! | 本地写盘失败 | `Failed` |
//! | 实例登记失败 | `Failed`(连内容一起回滚,不留"有目录没档案"的半个实例;回滚不完整时如实列出) |
//!
//! **回滚也要如实**:回滚本身可能失败(文件被占用、权限),这时 `rolled_back` 为 `false`
//! 并在 `rollback.failed` 里逐项列出——不许在"可能没回干净"时宣称一致。
//!
//! # 可重试
//!
//! 失败后目标目录与导入前一致(或明确告诉你哪几项没回去),重跑同一个包是安全的;成功后再跑
//! 一遍会靠基线里的哈希走"跳过 / 已一致"路径,不会重复写盘。
use super::super::pack::{PackArchive, PackError, PackRegistry};
use super::baseline::PackBaseline;
use super::error::ImportError;
use super::phase;
use super::plan::ImportPlan;
use super::report::{
keys, ImportIssue, ImportProgress, ImportReport, ImportStage, RegisteredInstance,
};
use super::stage::{RollbackOutcome, Staging};
use super::ImportRequest;
/// 跑一次导入。**永不返回 `Err`**:所有失败都在报告的三态里。
pub async fn run(request: &ImportRequest<'_>) -> ImportReport {
let mut report = ImportReport::empty(request.target.to_path_buf());
tell(&request.progress, ImportStage::Probe, 0, 0, None);
let (archive, plan) = match probe_and_plan(request) {
Ok(pair) => pair,
Err(error) => return abandoned(report, error),
};
account_plan(&mut report, &plan);
let baseline = load_baseline(request.target, &plan, &mut report);
let staging = match Staging::begin(request.target) {
Ok(staging) => staging,
Err(error) => return abandoned(report, error),
};
let mut next = baseline.clone();
let staged = async {
phase::extract_overrides(
request,
&archive,
&plan,
&baseline,
&staging,
&mut report,
&mut next,
)
.await?;
phase::fetch_remote(request, &plan, &baseline, &staging, &mut report, &mut next).await?;
write_baseline(&staging, &plan, &next)
}
.await;
if let Err(error) = staged {
staging.abort();
return abandoned(report, error);
}
// 引擎的续传残片(`.ullpart-*`)与 meta 不是整合包内容,提交前剔掉。
match staging.purge_engine_artifacts() {
Ok(0) => {}
Ok(removed) => report.warnings.push(ImportIssue::new(
keys::NOTE_ENGINE_ARTIFACTS_DROPPED,
format!("提交前剔除了 {removed} 个下载引擎的中间产物(.ullpart-* / .ullmeta.json)"),
)),
Err(error) => {
staging.abort();
return abandoned(report, error);
}
}
tell(&request.progress, ImportStage::Commit, 0, 0, None);
let guard = match staging.commit() {
Ok(guard) => guard,
Err(failure) => return record_rollback(failed(report, failure.error), failure.rollback),
};
if let Some(layout) = request.register {
tell(&request.progress, ImportStage::Register, 0, 0, None);
let name = request
.instance_name
.filter(|name| !name.trim().is_empty())
.unwrap_or(plan.manifest.name.as_str());
match crate::game::install::register(layout, name, &plan.version_id, None) {
Ok(registration) => {
report.registered = Some(RegisteredInstance {
id: registration.profile.id.as_str().to_string(),
name: registration.profile.name.clone(),
version_id: registration.profile.version_id.clone(),
created: registration.created,
});
report.warnings.push(ImportIssue::new(
keys::NOTE_REGISTERED,
format!(
"已登记实例 {:?}({})",
registration.profile.name, plan.version_id
),
));
}
Err(error) => {
// 内容落盘了但档案没建起来 = 用户看不见的半个实例 ⇒ 连内容一起回滚。
let outcome = guard.rollback();
return record_rollback(
failed(report, ImportError::instance(error.to_string())),
outcome,
);
}
}
}
guard.finish();
report.finish_status();
report
}
/// 开包 + 探测 + 组计划(任何失败都是 `Failed`)。
fn probe_and_plan(request: &ImportRequest<'_>) -> Result<(PackArchive, ImportPlan), ImportError> {
// 路径穿越双检 + 符号链接 + 条数/体积限额都在 `open` 里一次做完(M110 的 `archive`)。
let archive = PackArchive::open(request.pack, request.limits)?;
let (format, manifest) = PackRegistry::builtin().read(&archive)?;
let plan = ImportPlan::build(&archive, format.id(), manifest)?;
Ok((archive, plan))
}
/// 把计划里的静态信息抄进报告。
fn account_plan(report: &mut ImportReport, plan: &ImportPlan) {
report.format = Some(plan.format.clone());
report.pack_name = Some(plan.manifest.name.clone());
report.pack_version = plan.manifest.version.clone();
report.game_version = Some(plan.mc_version.clone());
report.loader = plan.loader.clone();
report.version_id = Some(plan.version_id.clone());
report.max_memory_mb = plan.max_memory_mb;
report.rejected_components.clone_from(&plan.rejected);
report.warnings.extend(plan.notes.iter().cloned());
}
/// 读三方合并的 base 侧:没有 / 读不出来 / **属于别的包** 三条路都不致命。
///
/// "属于别的包"要单独说一句:同一目录先后导入两个整合包时,若拿别人的基线当 base,用户改过
/// 的文件会被判成"整合包升级"而**被静默覆盖**。所以身份不符时降级成"没有基线"并告警。
fn load_baseline(
target: &std::path::Path,
plan: &ImportPlan,
report: &mut ImportReport,
) -> PackBaseline {
match PackBaseline::load(target) {
Ok(baseline) => {
if baseline.matches_pack(&plan.format, &plan.manifest.name) {
return baseline;
}
report.warnings.push(ImportIssue::new(
keys::NOTE_BASELINE_PACK_MISMATCH,
format!(
"基线属于另一个整合包({} / {:?}),本次按「没有基线」处理:磁盘上同名文件一律算用户已有内容",
if baseline.format.is_empty() {
"?"
} else {
&baseline.format
},
baseline.pack_name
),
));
PackBaseline::default()
}
Err(reason) => {
report.warnings.push(ImportIssue::new(
keys::NOTE_BASELINE_UNREADABLE,
format!(
"基线读不出来,本次按「没有基线」处理(磁盘上同名文件一律算用户已有内容):{reason}"
),
));
PackBaseline::default()
}
}
}
/// 把一次回滚的结果写进报告:**文案与机器位同源**,只有完整回滚才宣称"与导入前一致"。
fn record_rollback(mut report: ImportReport, outcome: RollbackOutcome) -> ImportReport {
if outcome.is_complete() {
report.rolled_back = true;
report.warnings.push(ImportIssue::new(
keys::ROLLBACK_DONE,
format!(
"已回滚 {} 项:目标目录恢复为导入前的样子({})",
outcome.attempted,
report.target.display()
),
));
} else {
report.rolled_back = false;
report.warnings.push(ImportIssue::new(
keys::NOTE_ROLLBACK_INCOMPLETE,
format!(
"有 {} 项未能回滚(目标目录可能与导入前不同):{}",
outcome.failure_count(),
outcome.failed.join(";")
),
));
}
report.rollback = Some(outcome);
report
}
/// 基线作为**最后一个暂存文件**写进去:它跟同一个事务一起提交 / 回滚。
fn write_baseline(
staging: &Staging,
plan: &ImportPlan,
next: &PackBaseline,
) -> Result<(), ImportError> {
let mut baseline = next.clone();
baseline.pack_name.clone_from(&plan.manifest.name);
baseline.format.clone_from(&plan.format);
let bytes = baseline.to_bytes().map_err(PackError::io)?;
staging.write(&baseline_relative(), &bytes)?;
Ok(())
}
/// 基线相对实例根的路径(`<根>/.ull/pack-baseline.json`;与读侧同源,见
/// [`PackBaseline::relative_path`])。
#[must_use]
pub fn baseline_relative() -> String {
PackBaseline::relative_path()
.to_string_lossy()
.replace('\\', "/")
}
/// 发一条进度事件(`sink` 为 `None` 时什么都不做)。
pub(super) fn tell(
sink: &Option<super::report::ImportProgressSink>,
stage: ImportStage,
done: usize,
total: usize,
current: Option<String>,
) {
if let Some(sink) = sink {
sink(&ImportProgress {
stage,
done,
total,
current,
});
}
}
/// 失败收尾:**只落错误,不宣称目标目录状态**。
///
/// 口径由调用方补:从未写过目标目录 ⇒ [`abandoned`];已经写过 ⇒ [`record_rollback`]。
/// 早先这里无条件推"目标目录保持导入前的样子",而随后的 `record_rollback` 可能再推
/// "有 N 项未能回滚"——同一份 warnings 自相矛盾(复审 r6 抓到)。
fn failed(mut report: ImportReport, error: ImportError) -> ImportReport {
report.rolled_back = false;
report.fail(error);
report
}
/// 失败发生在**碰目标目录之前**(暂存目录已清):这时才可以宣称"目标目录未被写入"。
fn abandoned(report: ImportReport, error: ImportError) -> ImportReport {
let mut report = failed(report, error);
report.rolled_back = true;
report.warnings.push(ImportIssue::new(
keys::ROLLBACK_DONE,
format!(
"导入失败,已清理临时目录;目标目录未被写入({})",
report.target.display()
),
));
report
}
#[cfg(test)]
mod tests {
use super::*;
use crate::addon::import::ImportStatus;
fn base() -> ImportReport {
ImportReport::empty(std::path::PathBuf::from("instance"))
}
fn messages(report: &ImportReport) -> Vec<&str> {
report
.warnings
.iter()
.map(|note| note.message.as_str())
.collect()
}
fn incomplete() -> RollbackOutcome {
RollbackOutcome {
attempted: 3,
failed: vec!["删除 config/a.toml 失败:拒绝访问".to_string()],
}
}
#[test]
fn incomplete_rollback_never_claims_the_target_is_untouched() {
// 复审 r6 第 1 条:机器位(rolled_back=false)诚实,文案位也必须诚实——同一份 warnings
// 里不许同时出现"保持导入前的样子"与"有 N 项未能回滚"。
let report = record_rollback(failed(base(), ImportError::fetch("提交失败")), incomplete());
assert_eq!(report.status, ImportStatus::Failed);
assert!(!report.rolled_back, "回滚不完整时不许宣称一致");
assert_eq!(
report.rollback.as_ref().map(RollbackOutcome::failure_count),
Some(1)
);
let messages = messages(&report);
assert!(
messages
.iter()
.any(|line| line.contains("未能回滚") && line.contains("config/a.toml")),
"失败项要逐条列出:{messages:?}"
);
assert!(
!messages.iter().any(|line| line.contains("保持导入前的样子")
|| line.contains("未被写入")
|| line.contains("恢复为导入前的样子")),
"回滚不完整却宣称一致(自相矛盾):{messages:?}"
);
}
#[test]
fn complete_rollback_claims_consistency_exactly_once() {
let report = record_rollback(
failed(base(), ImportError::fetch("提交失败")),
RollbackOutcome {
attempted: 2,
failed: Vec::new(),
},
);
assert!(report.rolled_back);
assert_eq!(
report.rollback.as_ref().map(RollbackOutcome::failure_count),
Some(0)
);
let messages = messages(&report);
assert!(
messages
.iter()
.any(|line| line.contains("恢复为导入前的样子")),
"{messages:?}"
);
assert!(
!messages.iter().any(|line| line.contains("未能回滚")),
"{messages:?}"
);
}
#[test]
fn pre_commit_failure_claims_untouched_only_via_abandoned() {
// `failed()` 本身不宣称任何目标目录状态;"从未写过目标目录"这条口径只由 `abandoned()` 给。
let bare = failed(base(), ImportError::fetch("开包失败"));
assert!(!bare.rolled_back);
assert!(bare.rollback.is_none());
assert!(!messages(&bare)
.iter()
.any(|line| line.contains("保持导入前的样子") || line.contains("未被写入")));
let report = abandoned(base(), ImportError::fetch("开包失败"));
assert!(report.rolled_back);
assert!(report.rollback.is_none());
let messages = messages(&report);
assert!(
messages.iter().any(|line| line.contains("未被写入")),
"{messages:?}"
);
}
}
+371
View File
@@ -0,0 +1,371 @@
//! 清单 → **导入计划**:把三种格式的差异在进解压器之前全部拍平。
//!
//! 计划是纯计算(只额外读一次 `instance.cfg`),产出四张表:覆盖条目、远端条目、
//! 加载器计划输入、被拒绝的 component。落盘与合并都不在这里。
//!
//! # 三种格式的语义在这层定死
//!
//! | 事 | CurseForge | Modrinth | MultiMC / Prism |
//! |----|-----------|----------|-----------------|
//! | 覆盖目录 | 清单点名的**一个**目录(任意名字) | `overrides/` + `client-overrides/`(`server-overrides/` **不解压**) | `.minecraft/` |
//! | 同名冲突 | 不存在(单目录) | `client-overrides` **胜** `overrides`(Modrinth 官方客户端语义;与 HMCL 相反) | 不存在 |
//! | 远端条目 | `projectID/fileID`,**无路径无地址** ⇒ 必须补全 | 自带 `path`/`downloads`/`hashes` | 没有远端条目(内容就是目录) |
//! | 加载器 | `modLoaders[].id`(`neoforge-1.20.1-47.1.106` 的版本段**带 MC 前缀**) | `dependencies` 的键 | `components[].uid` |
//! # 三处"不许自创"的边界
//!
//! 1. **覆盖目录的优先级只按侧**:`overrides/` 先、`client-overrides/` 后(后者胜)。同一
//! 目录内的条目顺序**不重要**——M110 的 [`crate::addon::pack::PackOverrides::files`] 按路径
//! 排序,zip 条目顺序没有保留;而同一目录内路径唯一,所以"按 zip 条目顺序落盘"与"按路径
//! 排序落盘"在无重复时等价。有重复(跨目录)时由侧优先级决定,不靠条目顺序。
//! **同一实例路径被多份声明命中时逐条算清楚**([`PlanOverride::shadowed`]):胜者按上面
//! 这条规则定,被遮蔽的那条不再读字节、只留一条 `NOTE_OVERRIDE_SHADOWED` 注记说明
//! "谁赢了"和"两份内容是否逐字节相同"。这是"整包一个策略"到"逐文件事实"的那一步。
//! 2. **`.disabled` 只认路径后缀**(官方 Modrinth 表达),`.old` **只识别与保留**(不清不删
//! 不改名)。`env.client=optional` **不是**禁用("可选"=安装时可取消勾选)。判据在
//! [`paths`]。
//! 3. **MultiMC 的非加载器 component 逐项进报告**(`org.lwjgl3` / `net.fabricmc.intermediary`
//! / 社区自定义 uid):静默丢它们等于"看起来装好了、其实少了补丁层"。
mod layer;
pub mod paths;
pub use layer::Shadowed;
pub use paths::{curse_class_dir, strip_mc_prefix, suffix_note, suffix_variant, SuffixVariant};
use super::super::pack::{
curseforge, multimc, EnvSupport, PackArchive, PackError, PackFile, PackLoader, PackManifest,
PackSide,
};
use super::error::ImportError;
use super::report::{keys, FileSource, ImportIssue, LoaderTarget, RejectedComponent};
/// 一条要落地的覆盖条目。
#[derive(Debug, Clone)]
pub struct PlanOverride {
/// 包内目录前缀(读包时拼 `<dir>/<path>`)。
pub dir: String,
/// 实例内相对路径。
pub path: String,
/// 来源侧。
pub source: FileSource,
/// 包内实际 SHA-1(M110 读包时现算)。
pub sha1: String,
/// 字节数。
pub size: u64,
/// 是否禁用态(路径后缀)。
pub disabled: bool,
/// 可选注记(`.disabled` / `.old`)。
pub note: Option<ImportIssue>,
/// 被同包另一条声明遮蔽时的事实(`None` = 本条目就是这条路径的最终胜者)。
pub shadowed: Option<Shadowed>,
}
/// 一条要补全/下载的远端条目。
#[derive(Debug, Clone)]
pub struct PlanRemote {
/// 原清单条目(摘要表 / downloads / env / project/file id 都在里面)。
pub file: PackFile,
/// 已知的实例内相对路径(mrpack 有;CF 需要补全才有)。
pub path: Option<String>,
/// 来源。
pub source: FileSource,
/// 是否禁用态(路径后缀)。
pub disabled: bool,
}
/// 一次导入的计划。
#[derive(Debug, Clone)]
pub struct ImportPlan {
/// 命中的格式 id。
pub format: String,
/// 原始模型(`extra` 等诊断信息留着)。
pub manifest: PackManifest,
/// 游戏版本。
pub mc_version: String,
/// 加载器安装计划输入(`None` = 纯原版)。
pub loader: Option<LoaderTarget>,
/// 实例绑定版本 id。
pub version_id: String,
/// 不支持的 component(逐项)。
pub rejected: Vec<RejectedComponent>,
/// 注记。
pub notes: Vec<ImportIssue>,
/// 覆盖条目(已按侧优先级排好、已剔除 `server-overrides`)。
pub overrides: Vec<PlanOverride>,
/// 远端条目。
pub remote: Vec<PlanRemote>,
/// `instance.cfg` 里受开关门控的内存上限(MB)。
pub max_memory_mb: Option<u64>,
}
impl ImportPlan {
/// 组计划。
///
/// # Errors
///
/// 清单缺游戏版本、加载器条目缺版本号、MultiMC 的 `formatVersion != 1` 等"清单自身非法"
/// 的情形返回 [`ImportError`](调用方据此整包回滚)。"某条文件缺地址"**不是**错误。
pub fn build(
pack: &PackArchive,
format_id: &str,
manifest: PackManifest,
) -> Result<Self, ImportError> {
let mut notes: Vec<ImportIssue> = Vec::new();
let mut rejected: Vec<RejectedComponent> = Vec::new();
// Prism 的规矩(A 级来源 `PackProfile.cpp:207-210`):`formatVersion` 必须恰为 1,
// 否则**整份丢弃**——不是降级解析。M110 的读者把缺省值当 1(老包不写这一行),
// 因此这里只拒"写了非 1 的值"。
if format_id == multimc::ID && manifest.format_version != Some(1) {
return Err(ImportError::from(PackError::metadata(format!(
"{} 的 formatVersion 是 {:?},必须恰为 1(Prism 自己也是整份丢弃,不降级解析)",
multimc::METADATA_ENTRY,
manifest.format_version
))));
}
let mc_version = manifest
.minecraft
.as_deref()
.map(str::trim)
.filter(|version| !version.is_empty())
.map(str::to_string)
.ok_or_else(|| {
ImportError::from(PackError::metadata(
"清单里没有游戏版本(CF minecraft.version / mrpack dependencies.minecraft / MultiMC net.minecraft 组件)",
))
})?;
let loader = select_loader(&manifest, &mc_version, format_id, &mut notes, &mut rejected)?;
let version_id = loader
.as_ref()
.map_or_else(|| mc_version.clone(), |target| target.version_id.clone());
let mut overrides: Vec<PlanOverride> = Vec::new();
let mut skipped_server_files = 0_usize;
for group in layer::ordered(&manifest) {
if group.side == PackSide::Server {
// Modrinth 官方客户端安装器就不解压 server-overrides/(A 级来源:
// install_mrpack.rs 的 strip_prefix 链只有 overrides/ 与 client-overrides/)。
skipped_server_files += group.files.len();
continue;
}
for file in &group.files {
let variant = suffix_variant(&file.path);
overrides.push(PlanOverride {
dir: group.dir.clone(),
path: file.path.clone(),
source: FileSource::from_side(group.side),
sha1: file.sha1.clone(),
size: file.size,
disabled: variant.is_disabled(),
note: suffix_note(variant, &file.path),
shadowed: None,
});
}
}
layer::resolve(&mut overrides, &mut notes);
if skipped_server_files > 0 {
notes.push(ImportIssue::new(
keys::NOTE_SERVER_OVERRIDES_SKIPPED,
format!(
"server-overrides/ 里的 {skipped_server_files} 个文件是服务端内容,客户端导入不解压(Modrinth 官方语义)"
),
));
}
let mut remote: Vec<PlanRemote> = Vec::new();
let source = if format_id == curseforge::ID {
FileSource::CurseForge
} else {
FileSource::Modrinth
};
for file in &manifest.files {
if file.client == EnvSupport::Unsupported {
notes.push(
ImportIssue::new(
keys::NOTE_ENV_UNSUPPORTED,
"env.client=unsupported:客户端不需要这个文件,跳过".to_string(),
)
.with_path(file.path.clone().unwrap_or_else(|| file.key())),
);
continue;
}
let variant = file
.path
.as_deref()
.map_or(SuffixVariant::Plain, suffix_variant);
if file.client == EnvSupport::Optional {
notes.push(
ImportIssue::new(
keys::NOTE_OPTIONAL_FILE,
"env.client=optional:可选条目照常安装(「可选」不等于「禁用」)"
.to_string(),
)
.with_path(file.path.clone().unwrap_or_else(|| file.key())),
);
}
if let Some(note) = file
.path
.as_deref()
.and_then(|path| suffix_note(variant, path))
{
notes.push(note);
}
remote.push(PlanRemote {
path: file.path.clone(),
source,
disabled: variant.is_disabled(),
file: file.clone(),
});
}
let max_memory_mb = if format_id == multimc::ID {
instance_memory(pack, &mut notes)?
} else {
None
};
Ok(Self {
format: format_id.to_string(),
manifest,
mc_version,
loader,
version_id,
rejected,
notes,
overrides,
remote,
max_memory_mb,
})
}
}
/// 选一个加载器并翻译成 [`LoaderTarget`]。
fn select_loader(
manifest: &PackManifest,
mc_version: &str,
format_id: &str,
notes: &mut Vec<ImportIssue>,
rejected: &mut Vec<RejectedComponent>,
) -> Result<Option<LoaderTarget>, ImportError> {
let recognized: Vec<&PackLoader> = manifest
.loaders
.iter()
.filter(|loader| loader.kind.is_some())
.collect();
// 认不出的条目:MultiMC 是"不支持的 component"(逐项列),其它格式是"不认识的依赖键"。
for loader in manifest
.loaders
.iter()
.filter(|loader| loader.kind.is_none())
{
if format_id == multimc::ID {
rejected.push(RejectedComponent {
uid: loader.raw.clone(),
version: loader.version.clone(),
message_key: keys::REJECTED_COMPONENT,
});
} else {
notes.push(ImportIssue::new(
keys::NOTE_UNKNOWN_DEPENDENCY,
format!("认不出的加载器/依赖键 {:?}:本启动器不安装它", loader.raw),
));
}
}
if recognized.len() > 1 {
notes.push(ImportIssue::new(
keys::NOTE_LOADER_MULTIPLE,
format!(
"清单里有 {} 个可识别的加载器,只按 primary/第一条组计划:{}",
recognized.len(),
recognized
.iter()
.map(|loader| loader.raw.as_str())
.collect::<Vec<_>>()
.join(", ")
),
));
}
let Some(loader) = recognized
.iter()
.find(|loader| loader.primary)
.or_else(|| recognized.first())
.copied()
else {
return Ok(None);
};
let Some(kind) = loader.kind else {
return Ok(None);
};
let raw_version = loader
.version
.as_deref()
.map(str::trim)
.filter(|version| !version.is_empty())
.ok_or_else(|| {
ImportError::loader(format!(
"加载器条目 {:?} 没有版本号,组不出安装计划(清单自身非法)",
loader.raw
))
})?;
let loader_version = strip_mc_prefix(mc_version, raw_version);
Ok(Some(LoaderTarget {
kind,
mc_version: mc_version.to_string(),
version_id: kind.version_id(mc_version, &loader_version),
loader_version,
raw: loader.raw.clone(),
}))
}
/// 从 `instance.cfg` 取受开关门控的内存上限(MB)。
fn instance_memory(
pack: &PackArchive,
notes: &mut Vec<ImportIssue>,
) -> Result<Option<u64>, ImportError> {
let Some(text) = pack.read_text(multimc::INSTANCE_ENTRY)? else {
return Ok(None);
};
let mut values: Vec<(&str, &str)> = Vec::new();
for line in text.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
continue;
}
// `[General]` 这类小节标记直接跳过:instance.cfg 平铺,真出现也不该把它们当键。
if line.starts_with('[') {
continue;
}
// 值里含 `:` 与 `\`(Windows 路径)是常态,所以只按**第一个** `=` 切。
if let Some((key, value)) = line.split_once('=') {
values.push((key.trim(), value.trim()));
}
}
let get = |name: &str| -> Option<&str> {
values
.iter()
.find(|(key, _)| key.eq_ignore_ascii_case(name))
.map(|(_, value)| *value)
};
let Some(max) = get("MaxMemAlloc").and_then(|value| value.parse::<u64>().ok()) else {
return Ok(None);
};
// Prism 的规矩:这些值只在开关为 true 时生效(只看值不看开关会误判)。
let enabled = get("OverrideMemory").is_some_and(|value| value.eq_ignore_ascii_case("true"));
if !enabled {
notes.push(ImportIssue::new(
keys::NOTE_INSTANCE_CFG_MEMORY,
format!("instance.cfg 写了 MaxMemAlloc={max},但 OverrideMemory 未开:不采用"),
));
return Ok(None);
}
notes.push(ImportIssue::new(
keys::NOTE_INSTANCE_CFG_MEMORY,
format!("从 instance.cfg 取到内存上限 {max} MB(OverrideMemory=true)"),
));
Ok(Some(max))
}
+208
View File
@@ -0,0 +1,208 @@
//! 覆盖目录的**叠层顺序**与**同路径遮蔽**:导入计划里"逐文件"那一半的判据(纯计算,无 IO)。
//!
//! 拆成单独一个文件有两个原因:①`plan.rs` 要守 400 行;②这一层的两条规则(先按侧排、
//! 再按路径结算胜负)是导入器里唯一"我们自己定的确定性规则",值得放在一处被单独读完。
use std::collections::HashMap;
use super::super::report::{keys, FileSource, ImportIssue};
use super::PlanOverride;
use crate::addon::pack::{PackManifest, PackOverrides, PackSide};
/// 「这条覆盖条目被同一份包里的另一条声明遮蔽」。
///
/// 为什么不是新的 `FileAction` 变体:`FileAction` / `ConflictReason` / `ConflictChoice` 三个
/// 枚举被 `crates/presenter/src/host/addon/pack/convert.rs` **穷尽 match**(那个 crate 不在
/// `addon` 的作用域里)。所以"遮蔽"这件事走 [`ImportIssue`] 的 note 通道,与
/// `server-overrides/ 被跳过` 完全同一条通道。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Shadowed {
/// 胜出那条的来源侧。
pub winner: FileSource,
/// 胜出那条的包内目录名(人读用,例如 `client-overrides`)。
pub winner_dir: String,
/// 两条声明的字节是否完全相同(`true` = 遮蔽不丢内容,只是重复了一份)。
pub same_bytes: bool,
}
/// 覆盖目录按侧排序:`Both`(`overrides/`)先、`Client`(`client-overrides/`)后 = 后者胜。
pub(super) fn ordered(manifest: &PackManifest) -> Vec<&PackOverrides> {
let mut groups: Vec<&PackOverrides> = manifest.overrides.iter().collect();
groups.sort_by_key(|group| match group.side {
PackSide::Both => 0_u8,
PackSide::Client => 1,
PackSide::Server => 2,
});
groups
}
/// 同一实例路径被本包多份声明命中时:**叠层顺序里最后一条胜**,其余标记为遮蔽并逐条注记。
///
/// # 这是"逐文件"而不是"整包一个策略"的那一步
///
/// research/18 §3.3 的 A 级结论:mrpack 的 `overrides/` 与 `client-overrides/` 之间,
/// **规范层面只有极少数固定路径有优先级**,其余按 zip 条目顺序落盘;HMCL 连顺序都相反。
/// 上一版导入器对此的处理是"按侧排序后逐条写"——结果是对的,但**报告是错的**:同一路径
/// 会出现两条 `Installed`,其中前一条的哈希根本没落地,也没人说得清谁遮蔽了谁。
///
/// 现在把这层算清楚,逐条给出两个事实:
///
/// 1. **赢了的是哪一侧**(叠层顺序 = [`ordered`] 的 `Both → Client`,同组内按路径排序,
/// 所以与 zip 条目顺序无关——那是我们自己定死的确定性规则,不是规范);
/// 2. **两份内容是否逐字节相同**(相同 = 重复声明,遮蔽不丢内容;不同 = 真的换了一份)。
///
/// 判据只看模型里的 `sha1`:M110 扫包时是**现算**的真实内容哈希;手工构造的模型可能撒谎,
/// 但那最多让 `same_bytes` 这句注记不准,**谁落地不受影响**(胜者永远按叠层顺序定)。
pub(super) fn resolve(overrides: &mut [PlanOverride], notes: &mut Vec<ImportIssue>) {
let mut last: HashMap<String, usize> = HashMap::new();
for (index, entry) in overrides.iter().enumerate() {
last.insert(entry.path.clone(), index);
}
for index in 0..overrides.len() {
let path = overrides[index].path.clone();
let Some(&winner_index) = last.get(&path) else {
continue;
};
if winner_index == index {
continue;
}
let winner_dir = overrides[winner_index].dir.clone();
let loser_dir = overrides[index].dir.clone();
let same_bytes = overrides[winner_index].sha1 == overrides[index].sha1;
let conclusion = if same_bytes {
"两份内容逐字节相同,只落地一次"
} else {
"两份内容不同,以这一条为准"
};
notes.push(
ImportIssue::new(
keys::NOTE_OVERRIDE_SHADOWED,
format!(
"{loser_dir}/{path} 与 {winner_dir}/{path} 声明同一实例路径:叠层顺序 \
overrides/ 在前、client-overrides/ 在后(Modrinth 官方客户端语义),\
{conclusion}"
),
)
.with_path(path),
);
overrides[index].shadowed = Some(Shadowed {
winner: overrides[winner_index].source,
winner_dir,
same_bytes,
});
}
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(dir: &str, path: &str, source: FileSource, sha1: &str) -> PlanOverride {
PlanOverride {
dir: dir.to_string(),
path: path.to_string(),
source,
sha1: sha1.to_string(),
size: 1,
disabled: false,
note: None,
shadowed: None,
}
}
#[test]
fn the_later_layer_wins_and_the_earlier_one_is_shadowed() {
// 叠层顺序:overrides/(Both)在前、client-overrides/(Client)在后 ⇒ 后者胜。
let mut overrides = vec![
entry(
"overrides",
"config/dup.toml",
FileSource::Overrides,
"aaaa",
),
entry(
"client-overrides",
"config/dup.toml",
FileSource::ClientOverrides,
"bbbb",
),
];
let mut notes = Vec::new();
resolve(&mut overrides, &mut notes);
let shadowed = overrides[0].shadowed.as_ref().expect("前一条要被标记");
assert_eq!(shadowed.winner, FileSource::ClientOverrides);
assert_eq!(shadowed.winner_dir, "client-overrides");
assert!(!shadowed.same_bytes, "同路径不同哈希");
assert!(overrides[1].shadowed.is_none(), "胜者不标遮蔽");
assert_eq!(notes.len(), 1);
assert_eq!(notes[0].message_key, keys::NOTE_OVERRIDE_SHADOWED);
assert_eq!(notes[0].path.as_deref(), Some("config/dup.toml"));
let message = &notes[0].message;
for expected in [
"overrides/config/dup.toml",
"client-overrides/config/dup.toml",
"内容不同",
] {
assert!(message.contains(expected), "缺 {expected}:{message}");
}
}
#[test]
fn identical_bytes_are_reported_as_same_bytes() {
let mut overrides = vec![
entry(
"overrides",
"config/same.toml",
FileSource::Overrides,
"aaaa",
),
entry(
"client-overrides",
"config/same.toml",
FileSource::ClientOverrides,
"aaaa",
),
];
let mut notes = Vec::new();
resolve(&mut overrides, &mut notes);
assert!(overrides[0].shadowed.as_ref().expect("标记").same_bytes);
assert!(
notes[0].message.contains("逐字节相同"),
"内容一致要说清楚,别让人以为丢了东西:{}",
notes[0].message
);
}
#[test]
fn different_paths_with_the_same_bytes_are_never_shadowed() {
// 同哈希不同路径 = 两份文件,不是重复声明。按哈希去重会**静默丢一个文件**。
let mut overrides = vec![
entry("overrides", "mods/a.jar", FileSource::Overrides, "aaaa"),
entry("overrides", "mods/b.jar", FileSource::Overrides, "aaaa"),
];
let mut notes = Vec::new();
resolve(&mut overrides, &mut notes);
assert!(overrides.iter().all(|entry| entry.shadowed.is_none()));
assert!(notes.is_empty());
}
#[test]
fn the_same_path_twice_inside_one_group_is_shadowed_too() {
// 同侧重复(模型被合并 / 手造时才会出现):照样只落地一次,且注记两份来自同一目录。
let mut overrides = vec![
entry("overrides", "mods/x.jar", FileSource::Overrides, "aaaa"),
entry("overrides", "mods/x.jar", FileSource::Overrides, "bbbb"),
];
let mut notes = Vec::new();
resolve(&mut overrides, &mut notes);
assert_eq!(
overrides[0].shadowed.as_ref().expect("标记").winner,
FileSource::Overrides
);
assert_eq!(notes.len(), 1);
}
}
+160
View File
@@ -0,0 +1,160 @@
//! 路径与版本号的小判据(纯函数):`.disabled` / `.old` 后缀变体与注记、CF 类别目录、MC 版本前缀。
//!
//! 拆出来是因为它们是**格式事实**而非管线逻辑:`.disabled` 只按路径后缀(tower 定案,不用
//! `env.client=optional`)、`.old` 只识别与保留(不清不删)、CF 的 `classId` 认不出就不猜。
use super::super::report::{keys, ImportIssue};
/// 路径后缀变体(`.disabled` / `.old` 的四种组合)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SuffixVariant {
/// `mods/x.jar`。
Plain,
/// `mods/x.jar.disabled`(官方 Modrinth 的禁用表达)。
Disabled,
/// `mods/x.jar.old`(HMCL 生态常见:旧版本残留)。
Old,
/// `mods/x.jar.disabled.old`。
DisabledOld,
}
impl SuffixVariant {
/// 是否禁用态。
#[must_use]
pub const fn is_disabled(self) -> bool {
matches!(self, Self::Disabled | Self::DisabledOld)
}
/// 是否带 `.old`(只用于报告,不附加清理语义)。
#[must_use]
pub const fn is_old(self) -> bool {
matches!(self, Self::Old | Self::DisabledOld)
}
}
/// 识别四种路径后缀变体。
#[must_use]
pub fn suffix_variant(path: &str) -> SuffixVariant {
let lowered = path.trim_end().to_ascii_lowercase();
let stripped = lowered.strip_suffix(".old").unwrap_or(&lowered);
let old = stripped.len() != lowered.len();
let disabled = stripped.ends_with(".disabled");
match (disabled, old) {
(false, false) => SuffixVariant::Plain,
(true, false) => SuffixVariant::Disabled,
(false, true) => SuffixVariant::Old,
(true, true) => SuffixVariant::DisabledOld,
}
}
/// 后缀注记(`.disabled` / `.old`):进报告,告诉用户"这个名字我们是认得的"。
#[must_use]
pub fn suffix_note(variant: SuffixVariant, path: &str) -> Option<ImportIssue> {
match variant {
SuffixVariant::Plain => None,
SuffixVariant::Disabled => Some(
ImportIssue::new(
keys::NOTE_DISABLED_SUFFIX,
"路径以 .disabled 结尾:按禁用态安装,后缀原样保留".to_string(),
)
.with_path(path),
),
SuffixVariant::Old => Some(
ImportIssue::new(
keys::NOTE_OLD_SUFFIX,
"路径以 .old 结尾:识别并原样保留(不清除、不改名)".to_string(),
)
.with_path(path),
),
SuffixVariant::DisabledOld => Some(
ImportIssue::new(
keys::NOTE_DISABLED_SUFFIX,
"路径以 .disabled.old 结尾:禁用态的旧文件,原样保留".to_string(),
)
.with_path(path),
),
}
}
/// CurseForge `classId` → 实例内目录(**认不出的类别返回 `None`**,由调用方告警)。
///
/// 依据:CF 官方 `classId` 枚举(`6` mods / `12` resource packs / `6945` shaders /
/// `17` worlds / `4546` customization / `4471` modpacks)。未知类别**不猜**:
/// 计划层返回 `None`,导入时进 `missing`/告警,而不是"随手塞进 mods/"。
#[must_use]
pub const fn curse_class_dir(class_id: i64) -> Option<&'static str> {
match class_id {
6 => Some("mods"),
12 => Some("resourcepacks"),
6945 => Some("shaderpacks"),
17 => Some("saves"),
4546 => Some("customize"),
_ => None,
}
}
/// 剥掉 CF 在版本段里又写一遍的 MC 前缀(`neoforge-1.20.1-47.1.106` 的坑)。
///
/// 只在"版本段以 `<mc>-` 开头"时剥——这是 CF 导出器在 NeoForge/Forge 上会写出的形状
/// (A 级来源 `FlamePackExportTask.cpp:404-410`)。不按 `-` 硬切第三段:`47.1.106` 本身
/// 也有不同风格,切错会把版本号喂成 `1.20.1`。
#[must_use]
pub fn strip_mc_prefix(mc_version: &str, loader_version: &str) -> String {
let prefix = format!("{mc_version}-");
loader_version
.strip_prefix(&prefix)
.filter(|rest| !rest.is_empty())
.unwrap_or(loader_version)
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn recognises_four_suffix_variants() {
assert_eq!(suffix_variant("mods/x.jar"), SuffixVariant::Plain);
assert_eq!(
suffix_variant("mods/x.jar.disabled"),
SuffixVariant::Disabled
);
assert_eq!(suffix_variant("mods/x.jar.old"), SuffixVariant::Old);
assert_eq!(
suffix_variant("mods/x.jar.disabled.old"),
SuffixVariant::DisabledOld
);
assert!(suffix_variant("mods/x.jar.disabled").is_disabled());
assert!(!suffix_variant("mods/x.jar.old").is_disabled());
assert!(suffix_variant("mods/x.jar.old").is_old());
}
#[test]
fn suffix_notes_only_for_recognised_variants() {
assert!(suffix_note(SuffixVariant::Plain, "mods/x.jar").is_none());
let disabled = suffix_note(SuffixVariant::Disabled, "mods/x.jar.disabled").expect("有注记");
assert_eq!(disabled.message_key, keys::NOTE_DISABLED_SUFFIX);
assert_eq!(disabled.path.as_deref(), Some("mods/x.jar.disabled"));
let old = suffix_note(SuffixVariant::Old, "mods/x.jar.old").expect("有注记");
assert_eq!(old.message_key, keys::NOTE_OLD_SUFFIX);
}
#[test]
fn strips_mc_prefix_from_loader_version() {
// NeoForge 在 1.20.1 上的真实形状。
assert_eq!(strip_mc_prefix("1.20.1", "1.20.1-47.1.106"), "47.1.106");
// 没有前缀的照原样(Fabric / 正常 Forge)。
assert_eq!(strip_mc_prefix("1.20.1", "0.15.11"), "0.15.11");
// 只剥一层,且剥完不能是空串。
assert_eq!(strip_mc_prefix("1.20.1", "1.20.1-"), "1.20.1-");
}
#[test]
fn knows_curse_class_directories() {
assert_eq!(curse_class_dir(6), Some("mods"));
assert_eq!(curse_class_dir(12), Some("resourcepacks"));
assert_eq!(curse_class_dir(6945), Some("shaderpacks"));
// 认不出的类别不猜。
assert_eq!(curse_class_dir(999_999), None);
}
}
+243
View File
@@ -0,0 +1,243 @@
//! 导入结果:**三态 + 逐项原因**,给 CLI 的 `--json` 与 M109/M113 的页面直接消费。
//!
//! # 三态不是"成功/失败"两态(M111 任务原文要求)
//!
//! | 状态 | 判据 | 退出码(CLI) |
//! |------|------|---------------|
//! | [`ImportStatus::Success`] | 清单里每条文件都落地、没有冲突 | `0` |
//! | [`ImportStatus::Partial`] | 有"搞不到"的文件([`MissingFile`])或有保留用户文件的冲突([`ConflictFile`]) | `3` |
//! | [`ImportStatus::Failed`] | 致命错误([`ImportError`]),**已整包回滚** | `1` |
//!
//! 把"部分成功"记成 `Success` 是这条管线最容易犯的错:用户会以为 mods 齐了,启动才发现缺
//! 依赖。所以 [`ImportReport::finish_status`] 是**唯一**算状态的地方,字段私有语义由它收敛。
//!
//! # 分文件
//!
//! - [`keys`]:文案 key 常量表(稳定字符串,含 `ALL` 守门表);
//! - [`items`]:条目类型(一条文件 / 一条冲突 / 一条缺失 / 一个 component / 加载器目标);
//! - 本文件:三态、阶段、进度、以及总的 [`ImportReport`]。
pub mod items;
pub mod keys;
pub use items::{
ConflictChoice, ConflictFile, ConflictReason, FileAction, FileSource, ImportIssue,
ImportedFile, LoaderTarget, MissingFile, MissingReason, RegisteredInstance, RejectedComponent,
};
use std::path::PathBuf;
use serde::Serialize;
use super::error::ImportError;
use super::stage::RollbackOutcome;
/// 导入三态。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ImportStatus {
/// 全部落地、无冲突。
Success,
/// 有缺文件或有冲突(内容不完整,但实例可用)。
Partial,
/// 致命失败,已整包回滚。
Failed,
}
impl ImportStatus {
/// 稳定字符串 id。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Success => "success",
Self::Partial => "partial",
Self::Failed => "failed",
}
}
/// 文案 key。
#[must_use]
pub const fn message_key(self) -> &'static str {
match self {
Self::Success => keys::STATUS_SUCCESS,
Self::Partial => keys::STATUS_PARTIAL,
Self::Failed => keys::STATUS_FAILED,
}
}
/// CLI 退出码:`0` / `3` / `1`(`2` 留给内部错误,`1` 与用法错误同码但 JSON 里可区分)。
#[must_use]
pub const fn exit_code(self) -> i32 {
match self {
Self::Success => 0,
Self::Partial => 3,
Self::Failed => 1,
}
}
}
/// 导入阶段(进度回调按这个分组)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ImportStage {
/// 读取清单(开包 + 安全四检 + 探测 + 读成模型)。
Probe,
/// 解压 / 三方合并覆盖内容。
Extract,
/// 补全并下载远端文件。
Fetch,
/// 落地(临时目录 rename 或 journal 合并)。
Commit,
/// 登记实例。
Register,
}
impl ImportStage {
/// 稳定字符串 id。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Probe => "probe",
Self::Extract => "extract",
Self::Fetch => "fetch",
Self::Commit => "commit",
Self::Register => "register",
}
}
/// 文案 key。
#[must_use]
pub const fn message_key(self) -> &'static str {
match self {
Self::Probe => keys::STAGE_PROBE,
Self::Extract => keys::STAGE_EXTRACT,
Self::Fetch => keys::STAGE_FETCH,
Self::Commit => keys::STAGE_COMMIT,
Self::Register => keys::STAGE_REGISTER,
}
}
}
/// 一条进度事件(同步回调,跨线程安全)。
#[derive(Debug, Clone, Serialize)]
pub struct ImportProgress {
/// 当前阶段。
pub stage: ImportStage,
/// 阶段内已完成条目数。
pub done: usize,
/// 阶段内总条目数(未知时为 0)。
pub total: usize,
/// 当前条目的实例内路径(阶段刚开始时为 `None`)。
pub current: Option<String>,
}
/// 进度回调。
pub type ImportProgressSink = std::sync::Arc<dyn Fn(&ImportProgress) + Send + Sync>;
/// 一次导入的完整结果。
#[derive(Debug, Clone, Serialize)]
pub struct ImportReport {
/// 三态。
pub status: ImportStatus,
/// 命中的格式 id(`curseforge` / `modrinth` / `multimc`)。
pub format: Option<String>,
/// 包名。
pub pack_name: Option<String>,
/// 包版本。
pub pack_version: Option<String>,
/// 游戏版本。
pub game_version: Option<String>,
/// 加载器安装计划输入(没有加载器时 `None` = 纯原版)。
pub loader: Option<LoaderTarget>,
/// 实例绑定的版本 id。
pub version_id: Option<String>,
/// 目标实例目录。
pub target: PathBuf,
/// 落地的文件(含 `SkippedIdentical`)。
pub files: Vec<ImportedFile>,
/// 冲突清单。
pub conflicts: Vec<ConflictFile>,
/// 缺文件清单(容忍)。
pub missing: Vec<MissingFile>,
/// MultiMC 不支持的 component(逐项)。
pub rejected_components: Vec<RejectedComponent>,
/// 注记。
pub warnings: Vec<ImportIssue>,
/// 致命错误(`status == Failed` 时必有)。
pub error: Option<ImportError>,
/// 登记的实例。
pub registered: Option<RegisteredInstance>,
/// 回滚结果(只有真的执行过回滚才有)。[`ImportReport::rolled_back`] 只在它**完整**时为真。
pub rollback: Option<RollbackOutcome>,
/// `instance.cfg` 里受开关门控的内存上限(MB;仅 MultiMC / Prism 有)。
pub max_memory_mb: Option<u64>,
/// 本次写入的字节数。
pub bytes_written: u64,
/// 是否已确认"目标目录与导入前一致":回滚**完整**完成、或从未写过目标目录时为 `true`;
/// 回滚有失败项时为 `false`(失败项在 [`ImportReport::rollback`] 里)。
pub rolled_back: bool,
}
impl ImportReport {
/// 一次尚未发生任何事的空报告(失败路径的起点)。
#[must_use]
pub fn empty(target: PathBuf) -> Self {
Self {
status: ImportStatus::Success,
format: None,
pack_name: None,
pack_version: None,
game_version: None,
loader: None,
version_id: None,
target,
files: Vec::new(),
conflicts: Vec::new(),
missing: Vec::new(),
rejected_components: Vec::new(),
warnings: Vec::new(),
error: None,
registered: None,
rollback: None,
max_memory_mb: None,
bytes_written: 0,
rolled_back: false,
}
}
/// 真正写了盘的文件数。
#[must_use]
pub fn installed(&self) -> usize {
self.files.iter().filter(|file| file.action.wrote()).count()
}
/// 是否"部分成功"。
///
/// 判据是**结果对用户是否干净**:有"搞不到"的文件,或有保留了用户文件的冲突(说明整合包
/// 与磁盘内容不一致)都不是干净的成功。用户选了"覆盖"的冲突不算——那侧的意图已落实。
#[must_use]
pub fn is_partial(&self) -> bool {
!self.missing.is_empty()
|| self
.conflicts
.iter()
.any(|conflict| conflict.choice == ConflictChoice::KeepUser)
}
/// 按 `error` / `missing` / `conflicts` 收敛三态(**唯一**算状态的地方)。
pub fn finish_status(&mut self) {
self.status = if self.error.is_some() {
ImportStatus::Failed
} else if self.is_partial() {
ImportStatus::Partial
} else {
ImportStatus::Success
};
}
/// 记一条致命错误并把状态落到 `Failed`。
pub fn fail(&mut self, error: ImportError) {
self.error = Some(error);
self.status = ImportStatus::Failed;
}
}
@@ -0,0 +1,265 @@
//! 报告里的**条目类型**:一条文件、一条冲突、一条缺失、一个 component、一个加载器目标。
use serde::Serialize;
use super::keys;
use crate::addon::loader::LoaderKind;
use crate::addon::pack::PackSide;
/// 一条文件来自哪里。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum FileSource {
/// 包内 `overrides/`(或 CF 清单点名的那个目录 / MultiMC 的 `.minecraft/`)。
Overrides,
/// 包内 `client-overrides/`。
ClientOverrides,
/// CurseForge `files[]` 条目(靠 projectID/fileID 补全)。
CurseForge,
/// Modrinth `files[]` 条目(清单自带 path + downloads)。
Modrinth,
}
impl FileSource {
/// 从覆盖目录的侧别取来源。
#[must_use]
pub const fn from_side(side: PackSide) -> Self {
match side {
PackSide::Both => Self::Overrides,
PackSide::Client => Self::ClientOverrides,
// 服务端覆盖在客户端导入里不会落地(见 `plan` 的侧别优先级)。
PackSide::Server => Self::Overrides,
}
}
}
/// 一个文件本次被怎么处理。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum FileAction {
/// 新写入(磁盘上原本没有)。
Installed,
/// 覆盖写入(用户没改过,整合包升级)。
Replaced,
/// 磁盘内容与包内一致,没动。
SkippedIdentical,
/// 用户改过 / 删过,按策略保留了用户那一侧。
KeptUser,
}
impl FileAction {
/// 是否真的写了盘。
#[must_use]
pub const fn wrote(self) -> bool {
matches!(self, Self::Installed | Self::Replaced)
}
}
/// 冲突的成因。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ConflictReason {
/// 基线里记过这个文件,磁盘内容与基线不同 ⇒ 用户改过。
UserModified,
/// 基线里记过这个文件,磁盘上却没了 ⇒ 用户删过。
UserDeleted,
/// 没有基线可判,磁盘上本来就有同名文件。
PreExisting,
}
impl ConflictReason {
/// 文案 key。
#[must_use]
pub const fn message_key(self) -> &'static str {
match self {
Self::UserModified => keys::CONFLICT_USER_MODIFIED,
Self::UserDeleted => keys::CONFLICT_USER_DELETED,
Self::PreExisting => keys::CONFLICT_PRE_EXISTING,
}
}
}
/// 冲突实际采用的处置(由 [`super::super::ConflictPolicy`] 决定)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ConflictChoice {
/// 保留用户侧(不写盘)。
KeepUser,
/// 用包内内容覆盖(用户选了"覆盖")。
Overwrite,
}
/// 一条冲突("无法判定时给可选择的冲突清单"就是这张表)。
#[derive(Debug, Clone, Serialize)]
pub struct ConflictFile {
/// 实例内相对路径。
pub path: String,
/// 成因。
pub reason: ConflictReason,
/// 本次实际采用的一侧。
pub choice: ConflictChoice,
/// 文案 key(= `reason.message_key()`)。
pub message_key: &'static str,
/// 包内声明的 SHA-1。
pub pack_sha1: String,
/// 包内声明的字节数。
pub pack_size: u64,
/// 上次导入写下的 SHA-1(没有基线时 `None`)。
pub base_sha1: Option<String>,
/// 磁盘现状的 SHA-1(文件不存在时 `None`)。
pub disk_sha1: Option<String>,
}
/// 一个"搞不到"的文件(**容忍**,但必须逐条列出)。
#[derive(Debug, Clone, Serialize)]
pub struct MissingFile {
/// 实例内相对路径(mrpack 有;CF 补全失败时没有)。
pub path: Option<String>,
/// 人读标识(CF 条目是 `projectID:fileID`,其余是路径)。
pub display: String,
/// CF `projectID`。
pub project_id: Option<i64>,
/// CF `fileID`。
pub file_id: Option<i64>,
/// 为什么搞不到。
pub reason: MissingReason,
/// 文案 key。
pub message_key: &'static str,
/// 细节(错误原文)。
pub detail: String,
}
/// "搞不到"的成因。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum MissingReason {
/// 调用方没给 fetcher(离线)。
Offline,
/// 条目补不出下载地址(CF 缺 API 客户端 / `downloads` 为空)。
NoSource,
/// 尝试过但失败(404 / 网络)。
FetchFailed,
}
impl MissingReason {
/// 文案 key。
#[must_use]
pub const fn message_key(self) -> &'static str {
match self {
Self::Offline => keys::MISSING_OFFLINE,
Self::NoSource => keys::MISSING_NO_SOURCE,
Self::FetchFailed => keys::MISSING_FETCH_FAILED,
}
}
}
/// 一个 MultiMC / Prism component 被识别为"本启动器不支持"(**逐项**列出,不静默丢)。
#[derive(Debug, Clone, Serialize)]
pub struct RejectedComponent {
/// 原始 uid(`org.lwjgl3` / `net.fabricmc.intermediary` / 社区自定义)。
pub uid: String,
/// 该组件声明的版本。
pub version: Option<String>,
/// 文案 key(恒为 [`keys::REJECTED_COMPONENT`])。
pub message_key: &'static str,
}
/// 加载器安装计划的输入:**只需要这三个参数**就能接上 `addon::loader` 的既有能力。
#[derive(Debug, Clone)]
pub struct LoaderTarget {
/// 加载器种类。
pub kind: LoaderKind,
/// 目标原版版本。
pub mc_version: String,
/// 加载器版本(已剥掉 CF 的 MC 版本前缀)。
pub loader_version: String,
/// 清单里的原始标识(诊断用)。
pub raw: String,
/// 实例绑定的版本 id(`1.20.1-fabric-0.15.11` 或纯原版 `1.20.1`)。
pub version_id: String,
}
impl LoaderTarget {
/// 直接喂给 `LoaderSource::resolve(kind, mc_version, Some(loader_version))`。
#[must_use]
pub fn resolve_args(&self) -> (LoaderKind, &str, &str) {
(self.kind, &self.mc_version, &self.loader_version)
}
}
impl Serialize for LoaderTarget {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
use serde::ser::SerializeMap;
let mut map = serializer.serialize_map(None)?;
map.serialize_entry("kind", self.kind.as_str())?;
map.serialize_entry("label", self.kind.label())?;
map.serialize_entry("mc_version", &self.mc_version)?;
map.serialize_entry("loader_version", &self.loader_version)?;
map.serialize_entry("version_id", &self.version_id)?;
map.serialize_entry("raw", &self.raw)?;
map.end()
}
}
/// 一条落地的文件。
#[derive(Debug, Clone, Serialize)]
pub struct ImportedFile {
/// 实例内相对路径(`.disabled` 后缀原样保留)。
pub path: String,
/// 来源。
pub source: FileSource,
/// 实际写入/比对用的 SHA-1。
pub sha1: Option<String>,
/// 字节数。
pub bytes: u64,
/// 是否禁用态(路径后缀表达,不附加语义)。
pub disabled: bool,
/// 处理方式。
pub action: FileAction,
/// 可选注记(`.disabled` / `optional` 之类)。
pub note: Option<ImportIssue>,
}
/// 一条人读注记(带稳定 key)。
#[derive(Debug, Clone, Serialize)]
pub struct ImportIssue {
/// 文案 key。
pub message_key: &'static str,
/// 人读细节。
pub message: String,
/// 相关实例内路径。
pub path: Option<String>,
}
impl ImportIssue {
/// 构造。
#[must_use]
pub fn new(message_key: &'static str, message: impl Into<String>) -> Self {
Self {
message_key,
message: message.into(),
path: None,
}
}
/// 挂一条路径。
#[must_use]
pub fn with_path(mut self, path: impl Into<String>) -> Self {
self.path = Some(path.into());
self
}
}
/// 登记出来的实例(给 UI 显示"装到哪了")。
#[derive(Debug, Clone, Serialize)]
pub struct RegisteredInstance {
/// 实例 id(稳定 UUID)。
pub id: String,
/// 展示名。
pub name: String,
/// 绑定版本 id。
pub version_id: String,
/// 是否本次新建(`false` = 复用已有档案)。
pub created: bool,
}
+108
View File
@@ -0,0 +1,108 @@
//! 文案 key 常量表(**稳定字符串**,M109/M113 的页面按这些字符串查本地化文案)。
//!
//! core 不出现面向用户的成品文案(诊断信息除外);三态/阶段/缺失/冲突/注记各自带一个 key,
//! 逐条原因也在报告条目里(`message_key` 字段)。
//!
//! `ALL` 是"没有死常量"的守门用表:`addon/tests/import_report.rs` 会核对
//! ①常量定义集合 == `ALL`(不多不少);②每个常量除了定义与 `ALL` 里的列举之外,至少还有
//! 一处真实引用。任何一条不满足就失败——避免这张表腐烂成"看着齐全、其实没人用"。
/// 阶段:读取清单。
pub const STAGE_PROBE: &str = "pack.import.stage.probe";
/// 阶段:解压覆盖内容。
pub const STAGE_EXTRACT: &str = "pack.import.stage.extract";
/// 阶段:补全缺失文件。
pub const STAGE_FETCH: &str = "pack.import.stage.fetch";
/// 阶段:落地(rename / journal)。
pub const STAGE_COMMIT: &str = "pack.import.stage.commit";
/// 阶段:登记实例。
pub const STAGE_REGISTER: &str = "pack.import.stage.register";
/// 状态:成功。
pub const STATUS_SUCCESS: &str = "pack.import.status.success";
/// 状态:部分成功。
pub const STATUS_PARTIAL: &str = "pack.import.status.partial";
/// 状态:失败(是否回滚干净看 `ImportReport::rolled_back`)。
pub const STATUS_FAILED: &str = "pack.import.status.failed";
/// 注记:`server-overrides/` 客户端不解压。
pub const NOTE_SERVER_OVERRIDES_SKIPPED: &str = "pack.import.note.server_overrides_skipped";
/// 注记:`dependencies` 里不认识的键(`parchment` 之类)。
pub const NOTE_UNKNOWN_DEPENDENCY: &str = "pack.import.note.unknown_dependency";
/// 注记:`env.client=unsupported` 的条目被跳过。
pub const NOTE_ENV_UNSUPPORTED: &str = "pack.import.note.env_unsupported";
/// 注记:路径以 `.disabled` 结尾(禁用态,按原后缀落地)。
pub const NOTE_DISABLED_SUFFIX: &str = "pack.import.note.disabled_suffix";
/// 注记:路径以 `.old` 结尾(只识别与保留,不加语义)。
pub const NOTE_OLD_SUFFIX: &str = "pack.import.note.old_suffix";
/// 注记:`env.client=optional`("可选"不等于"禁用",照装)。
pub const NOTE_OPTIONAL_FILE: &str = "pack.import.note.optional_file";
/// 注记:清单里有多个加载器,只按 primary/第一条组计划。
pub const NOTE_LOADER_MULTIPLE: &str = "pack.import.note.loader_multiple";
/// 注记:实例已登记。
pub const NOTE_REGISTERED: &str = "pack.import.note.registered";
/// 注记:从 `instance.cfg` 取到了内存设置。
pub const NOTE_INSTANCE_CFG_MEMORY: &str = "pack.import.note.instance_cfg_memory";
/// 注记:基线文件读不出来(本次按"没有基线"处理)。
pub const NOTE_BASELINE_UNREADABLE: &str = "pack.import.note.baseline_unreadable";
/// 注记:基线属于**另一个整合包**(本次按"没有基线"处理)。
pub const NOTE_BASELINE_PACK_MISMATCH: &str = "pack.import.note.baseline_pack_mismatch";
/// 注记:提交前清掉了下载引擎的中间产物(`.ullpart-*` / `.ullmeta.json`)。
pub const NOTE_ENGINE_ARTIFACTS_DROPPED: &str = "pack.import.note.engine_artifacts_dropped";
/// 注记:回滚**未完整完成**(逐项列失败,`rolled_back` 为 false)。
pub const NOTE_ROLLBACK_INCOMPLETE: &str = "pack.import.note.rollback_incomplete";
/// 注记:同包另一条声明遮蔽了这个路径(说清谁赢、两份内容是否相同)。
pub const NOTE_OVERRIDE_SHADOWED: &str = "pack.import.note.override_shadowed";
/// 缺失:调用方没给 fetcher(离线导入)。
pub const MISSING_OFFLINE: &str = "pack.import.missing.offline";
/// 缺失:条目本身补不出下载地址(CF `files[]` 缺 API 客户端 / `downloads` 为空)。
pub const MISSING_NO_SOURCE: &str = "pack.import.missing.no_source";
/// 缺失:下载/补全尝试过但失败(404 / 网络)。
pub const MISSING_FETCH_FAILED: &str = "pack.import.missing.fetch_failed";
/// 冲突:用户改过这个文件。
pub const CONFLICT_USER_MODIFIED: &str = "pack.import.conflict.user_modified";
/// 冲突:用户删过这个文件。
pub const CONFLICT_USER_DELETED: &str = "pack.import.conflict.user_deleted";
/// 冲突:磁盘上本来就有同名内容(没有基线可判)。
pub const CONFLICT_PRE_EXISTING: &str = "pack.import.conflict.pre_existing";
/// MultiMC 不支持的 component(逐项列出)。
pub const REJECTED_COMPONENT: &str = "pack.import.rejected.component";
/// 致命失败后已完成回滚(目标目录零残留)。
pub const ROLLBACK_DONE: &str = "pack.import.rollback.done";
/// 全部 key(守门断言"没有死常量"用)。
pub const ALL: &[&str] = &[
STAGE_PROBE,
STAGE_EXTRACT,
STAGE_FETCH,
STAGE_COMMIT,
STAGE_REGISTER,
STATUS_SUCCESS,
STATUS_PARTIAL,
STATUS_FAILED,
NOTE_SERVER_OVERRIDES_SKIPPED,
NOTE_UNKNOWN_DEPENDENCY,
NOTE_ENV_UNSUPPORTED,
NOTE_DISABLED_SUFFIX,
NOTE_OLD_SUFFIX,
NOTE_OPTIONAL_FILE,
NOTE_LOADER_MULTIPLE,
NOTE_REGISTERED,
NOTE_INSTANCE_CFG_MEMORY,
NOTE_BASELINE_UNREADABLE,
NOTE_BASELINE_PACK_MISMATCH,
NOTE_ENGINE_ARTIFACTS_DROPPED,
NOTE_ROLLBACK_INCOMPLETE,
NOTE_OVERRIDE_SHADOWED,
MISSING_OFFLINE,
MISSING_NO_SOURCE,
MISSING_FETCH_FAILED,
CONFLICT_USER_MODIFIED,
CONFLICT_USER_DELETED,
CONFLICT_PRE_EXISTING,
REJECTED_COMPONENT,
ROLLBACK_DONE,
];
+334
View File
@@ -0,0 +1,334 @@
//! 落地事务:**临时目录 + rename**,合并态用 journal 回滚(回滚细节在 [`commit`])。
//!
//! # 两种起点,两条事务路径
//!
//! | 起点 | 做法 | 失败后的样子 |
//! |------|------|--------------|
//! | 目标目录不存在 / 是空目录(全新导入) | 在**同卷兄弟目录**里把整个实例建好,最后一次 `rename` 到位 | 目标目录**从未出现**(rename 是原子的) |
//! | 目标目录已有内容(升级 / 重导) | 逐文件 `rename` 进目标;被替换的旧文件先改名成 `<dest>.ullbak-<pid>` 当备份 | journal 逆序回滚:新文件删掉、旧备份改回原名 |
//!
//! 为什么全新导入不逐文件搬:逐文件搬意味着"搬到一半失败"会留下一个**看起来存在**的
//! 半个实例,而 `rename` 一个目录是单步原子操作——失败就只有"没有"这一种结果。
//!
//! # 两道路径检查都在这层再走一遍
//!
//! 第一道是 M110 的 [`normalize_file`](七条写死规则:空名 / `\0` / `\` / `:` / 前导 `/` /
//! 任何 `..` 段 / 规整后为空),第二道是**落盘前的 `starts_with` 复核**(Modrinth 官方安装器
//! 同款做法:`install_mrpack.rs:169-170` 按 `strip_prefix(instance_root)` 再判一次)。
//! 模型可能来自外部构造,`zip` 侧的双检挡不住"调用方直接塞了个 `../x` 进来"。
pub mod commit;
pub use commit::{CommitFailure, CommitGuard, RollbackOutcome};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use super::super::pack::path::normalize_file;
use super::super::pack::PackError;
use super::error::ImportError;
use crate::platform::atomic::write_atomic;
use crate::download::atomic::{META_SUFFIX, PART_SUFFIX};
use commit::{rollback_merge, Applied};
/// 临时目录名里的进程内序号(同一进程并发导入不撞名)。
static SEQ: AtomicU64 = AtomicU64::new(0);
/// 目标目录的起点形态。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum Mode {
/// 全新:整目录 rename。
Fresh,
/// 合并:逐文件 rename + journal。
Merge,
}
/// 一个进行中的落地事务。
#[derive(Debug)]
pub struct Staging {
target: PathBuf,
temp: PathBuf,
mode: Mode,
/// 本次是否由我们创建了目标目录的父目录(回滚时顺手清掉,避免"空壳目录"残留)。
created_parent: bool,
}
impl Staging {
/// 开一个事务(临时目录立刻建好,目标目录此刻一个字节都没动)。
///
/// # Errors
///
/// 目标路径是文件、父目录建不出来、临时目录建不出来时返回 `pack_io`。
pub fn begin(target: &Path) -> Result<Self, ImportError> {
if target.is_file() {
return Err(io_error(format!(
"目标路径是个文件,不是目录:{}",
target.display()
)));
}
let parent = target
.parent()
.filter(|path| !path.as_os_str().is_empty())
.map_or_else(|| PathBuf::from("."), Path::to_path_buf);
let created_parent = !parent.exists();
std::fs::create_dir_all(&parent)
.map_err(|error| io_error(format!("建 {} 失败:{error}", parent.display())))?;
let name = target.file_name().map_or_else(
|| "instance".to_string(),
|name| name.to_string_lossy().to_string(),
);
let seq = SEQ.fetch_add(1, Ordering::Relaxed);
let temp = parent.join(format!(".{name}.ull-import-{}-{seq}", std::process::id()));
if temp.exists() {
std::fs::remove_dir_all(&temp).map_err(|error| {
io_error(format!("清旧临时目录 {} 失败:{error}", temp.display()))
})?;
}
std::fs::create_dir_all(&temp)
.map_err(|error| io_error(format!("建临时目录 {} 失败:{error}", temp.display())))?;
let mode = match std::fs::read_dir(target) {
Ok(mut entries) => {
if entries.next().is_some() {
Mode::Merge
} else {
Mode::Fresh
}
}
Err(_) => Mode::Fresh,
};
Ok(Self {
target: target.to_path_buf(),
temp,
mode,
created_parent,
})
}
/// 临时目录根(调试 / 测试断言用)。
#[must_use]
pub fn temp(&self) -> &Path {
&self.temp
}
/// 目标目录。
#[must_use]
pub fn target(&self) -> &Path {
&self.target
}
/// 是否合并态(目标目录已有内容)。
#[must_use]
pub const fn is_merge(&self) -> bool {
matches!(self.mode, Mode::Merge)
}
/// 规划一个暂存路径:走**两道**路径检查后返回可写的绝对路径。
///
/// # Errors
///
/// 路径穿越 / 空路径 / 规整后越出临时目录时返回 `pack_traversal`。
pub fn dest_for(&self, relative: &str) -> Result<PathBuf, ImportError> {
let normalized = normalize_file(relative)?;
let path = self.temp.join(&normalized);
// 第二检:规整之后仍要确认它真的在临时目录下面(不靠 `join` 的语义"讲道理")。
if !path.starts_with(&self.temp) {
return Err(PackError::traversal(format!("暂存路径越出临时目录:{relative:?}")).into());
}
Ok(path)
}
/// 写一个暂存文件(自动建父目录)。
///
/// # Errors
///
/// 路径不安全、写盘失败。
pub fn write(&self, relative: &str, bytes: &[u8]) -> Result<PathBuf, ImportError> {
let dest = self.dest_for(relative)?;
write_atomic(&dest, bytes)
.map_err(|error| io_error(format!("写 {} 失败:{error}", dest.display())))?;
Ok(dest)
}
/// 删掉一个暂存文件(例如发现内容与磁盘一致,不必落地)。
pub fn discard(&self, relative: &str) -> Result<(), ImportError> {
let dest = self.dest_for(relative)?;
match std::fs::remove_file(&dest) {
Ok(()) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(io_error(format!("删 {} 失败:{error}", dest.display()))),
}
}
/// 放弃事务:临时目录整个删掉,目标目录保持导入前的样子。
pub fn abort(self) {
self.abort_inner();
}
fn abort_inner(&self) {
let _ = std::fs::remove_dir_all(&self.temp);
if self.created_parent {
// 只删空目录:非空说明有别人用了,别动。
let _ = std::fs::remove_dir(self.temp.parent().unwrap_or(&self.temp));
}
}
/// 提交事务:返回一个**必须显式 `finish`** 的守卫(析构即回滚)。
///
/// # Errors
///
/// `rename` 失败、合并态逐文件搬动失败。**回滚已经在这条路径上做过**,其结果连同错误
/// 一起返回([`CommitFailure::rollback`]):调用方必须把它写进报告,不许假设"失败 = 已回滚干净"。
pub fn commit(self) -> Result<CommitGuard, CommitFailure> {
match self.mode {
Mode::Fresh => match std::fs::rename(&self.temp, &self.target) {
Ok(()) => Ok(CommitGuard::fresh(self.target.clone(), self.temp.clone())),
Err(error) => {
self.abort_inner();
Err(CommitFailure::io(
format!(
"把 {} 改名成 {} 失败:{error}",
self.temp.display(),
self.target.display()
),
RollbackOutcome::default(),
))
}
},
Mode::Merge => {
let mut applied: Vec<Applied> = Vec::new();
match self.apply_merge(&mut applied) {
Ok(()) => Ok(CommitGuard::merge(
self.target.clone(),
self.temp.clone(),
applied,
)),
Err(error) => {
let rollback = rollback_merge(&mut applied);
self.abort_inner();
Err(CommitFailure::new(error, rollback))
}
}
}
}
}
/// 删掉暂存目录里的**下载引擎中间产物**(续传 part 与 meta),返回删掉的个数。
///
/// 为什么必须在提交前删:引擎在下载失败时**故意**保留 `<name>.ullpart-<session>-<seq>`
/// 与 `<name>.ullmeta.json` 供下次续传(`core::download::atomic` 的设计)。这些文件躺在
/// 暂存目录里,提交阶段会跟内容一起搬进实例——实例里冒出 `.ullpart-…` 既不是整合包内容,
/// 又会被下次导入的基线/冲突判定当成"用户文件",从此谁也说不清。
///
/// 冲突口径:整合包里真有一个叫 `x.ullpart` 的文件几乎不可能;真出现时以"不入实例"为准。
///
/// # Errors
///
/// 遍历或删除失败返回 `pack_io`。
pub fn purge_engine_artifacts(&self) -> Result<usize, ImportError> {
let mut files: Vec<PathBuf> = Vec::new();
collect_files(&self.temp, &mut files)?;
let mut removed = 0_usize;
for path in files {
if !is_engine_artifact(&path) {
continue;
}
std::fs::remove_file(&path).map_err(|error| {
io_error(format!("删中间产物 {} 失败:{error}", path.display()))
})?;
removed += 1;
}
Ok(removed)
}
/// 合并态:把临时目录里的文件逐个搬进目标目录(每个都带备份)。
fn apply_merge(&self, applied: &mut Vec<Applied>) -> Result<(), ImportError> {
let mut staged: Vec<PathBuf> = Vec::new();
collect_files(&self.temp, &mut staged)?;
staged.sort();
for source in staged {
let relative = source
.strip_prefix(&self.temp)
.map_err(|_| io_error(format!("暂存路径不在临时目录里:{}", source.display())))?;
let relative_text = relative.to_string_lossy().replace('\\', "/");
// 搬动前再规整一次(第三道,防"临时目录里被塞了奇怪的名字")。
let normalized = normalize_file(&relative_text)?;
let dest = self.target.join(&normalized);
if !dest.starts_with(&self.target) {
return Err(PackError::traversal(format!(
"落地路径越出实例目录:{relative_text:?}"
))
.into());
}
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)
.map_err(|error| io_error(format!("建 {} 失败:{error}", parent.display())))?;
}
let backup = if dest.exists() {
let backup = commit::backup_path(&dest);
std::fs::rename(&dest, &backup).map_err(|error| {
io_error(format!(
"备份 {} → {} 失败:{error}",
dest.display(),
backup.display()
))
})?;
Some(backup)
} else {
None
};
applied.push(Applied {
dest: dest.clone(),
backup,
moved: false,
});
std::fs::rename(&source, &dest).map_err(|error| {
io_error(format!(
"把 {} 搬到 {} 失败:{error}",
source.display(),
dest.display()
))
})?;
if let Some(last) = applied.last_mut() {
last.moved = true;
}
}
Ok(())
}
}
/// 引擎中间产物的文件名判据(`.ullpart` 在中段、`.ullmeta.json` 在结尾)。
#[must_use]
pub fn is_engine_artifact(path: &Path) -> bool {
let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
return false;
};
name.ends_with(META_SUFFIX) || name.contains(PART_SUFFIX)
}
/// 递归收集目录下的全部文件(绝对路径)。
fn collect_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<(), ImportError> {
let entries = std::fs::read_dir(dir)
.map_err(|error| io_error(format!("读 {} 失败:{error}", dir.display())))?;
for entry in entries {
let entry =
entry.map_err(|error| io_error(format!("读 {} 失败:{error}", dir.display())))?;
let path = entry.path();
let file_type = entry
.file_type()
.map_err(|error| io_error(format!("读 {} 类型失败:{error}", path.display())))?;
if file_type.is_dir() {
collect_files(&path, out)?;
} else {
out.push(path);
}
}
Ok(())
}
/// 本地 IO 失败(统一包成 M110 的 `pack_io`,不再造新码)。
pub(super) fn io_error(message: impl Into<String>) -> ImportError {
PackError::io(message).into()
}
@@ -0,0 +1,290 @@
//! 提交守卫、回滚与**回滚本身失败的记账**:`Drop` 即回滚,除非显式 `finish`。
//!
//! 靠调用方在每个错误分支手动清理,早晚会漏一条——所以把"回滚"绑在析构上:任何提前
//! `return`、`?` 冒泡、panic 展开都不会留下半个实例。
//!
//! 回滚分两种:
//!
//! - **全新导入**:目标目录整个是本事务建的 ⇒ 删干净就是零残留;
//! - **合并态**:按 journal **逆序**撤回——新文件删掉、被替换的旧文件从 `.ullbak-<pid>` 改回原名。
//!
//! # 回滚也可能失败(复审 r5 P2-2)
//!
//! 早期实现把每一步写成 `let _ = …`,失败被吞,报告却照样宣称"目标目录与导入前一致"。
//! 现在每次回滚都返回 [`RollbackOutcome`]:调用的**显式**回滚路径(`CommitGuard::rollback`)
//! 由导入管线把结果写进报告(`rolled_back` 只在**完整**回滚时为 `true`,否则列出失败项);
//! `Drop` 那条兜底路径拿不到调用方,只能尽力而为——所以它是"防漏"而不是"承诺",文档与
//! 报告口径都按这个写。
use std::path::{Path, PathBuf};
use super::io_error;
use crate::addon::import::ImportError;
/// 备份后缀(合并态的 journal 备份文件)。
const BACKUP_TAG: &str = "ullbak";
/// 一次回滚的结果(**失败项要能被报告出来**)。
#[derive(Debug, Clone, Default, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RollbackOutcome {
/// 尝试过的回滚动作数(删新文件 + 恢复备份)。
pub attempted: usize,
/// 失败项的人读描述(路径 + 原因);空表 = 完整回滚。
pub failed: Vec<String>,
}
impl RollbackOutcome {
/// 是否完整回滚(没有失败项)。
#[must_use]
pub fn is_complete(&self) -> bool {
self.failed.is_empty()
}
/// 失败项数。
#[must_use]
pub fn failure_count(&self) -> usize {
self.failed.len()
}
}
/// 一条已应用的搬动记录。
#[derive(Debug)]
pub struct Applied {
/// 目标路径。
pub(super) dest: PathBuf,
/// 被替换下来的旧文件备份。
pub(super) backup: Option<PathBuf>,
/// 新文件是否已经搬到位。
pub(super) moved: bool,
}
/// 提交守卫:**析构即回滚**,除非显式 [`CommitGuard::finish`]。
#[derive(Debug)]
pub struct CommitGuard {
inner: Option<Inner>,
}
#[derive(Debug)]
struct Inner {
target: PathBuf,
temp: PathBuf,
/// 本次是否**新建**了目标目录(全新导入)。
created_target: bool,
applied: Vec<Applied>,
finished: bool,
}
impl CommitGuard {
/// 全新导入留下的守卫(目标目录已是 rename 后的成品)。
pub(super) fn fresh(target: PathBuf, temp: PathBuf) -> Self {
Self {
inner: Some(Inner {
target,
temp,
created_target: true,
applied: Vec::new(),
finished: false,
}),
}
}
/// 合并态留下的守卫(`applied` 是已搬进去的 journal)。
pub(super) fn merge(target: PathBuf, temp: PathBuf, applied: Vec<Applied>) -> Self {
Self {
inner: Some(Inner {
target,
temp,
created_target: false,
applied,
finished: false,
}),
}
}
/// 目标目录。
#[must_use]
pub fn target(&self) -> &Path {
self.inner
.as_ref()
.map_or(Path::new(""), |inner| inner.target.as_path())
}
/// 确认提交:删备份、清临时目录。
pub fn finish(mut self) {
if let Some(inner) = self.inner.as_mut() {
for applied in &inner.applied {
if let Some(backup) = &applied.backup {
let _ = std::fs::remove_file(backup);
}
}
let _ = std::fs::remove_dir_all(&inner.temp);
inner.finished = true;
}
}
/// 显式回滚,并**返回结果**(失败的项由调用方写进报告)。
///
/// `Drop` 也会回滚,但那条路径没法上报——因此真正需要"宣称一致"的地方必须调这个。
pub fn rollback(mut self) -> RollbackOutcome {
match self.inner.as_mut() {
Some(inner) => rollback_inner(inner),
None => RollbackOutcome::default(),
}
}
}
impl Drop for CommitGuard {
fn drop(&mut self) {
if let Some(inner) = self.inner.as_mut() {
if !inner.finished {
// 兜底:拿不到调用方,失败只能丢在这里(所以别依赖 Drop 去做"宣称")。
let _ = rollback_inner(inner);
}
}
}
}
/// 回滚一次提交。
fn rollback_inner(inner: &mut Inner) -> RollbackOutcome {
let mut outcome = RollbackOutcome::default();
if inner.created_target {
// 全新导入:目标目录整个是本事务建的,删干净 = 零残留。
outcome.attempted += 1;
if let Err(error) = std::fs::remove_dir_all(&inner.target) {
if error.kind() != std::io::ErrorKind::NotFound {
outcome.failed.push(format!(
"删除本次新建的实例目录 {} 失败:{error}",
inner.target.display()
));
}
}
} else {
outcome = rollback_merge(&mut inner.applied);
}
let _ = std::fs::remove_dir_all(&inner.temp);
inner.finished = true;
outcome
}
/// 合并态回滚:新文件删掉、备份改回原名(**失败逐项记下来**)。
pub(super) fn rollback_merge(applied: &mut [Applied]) -> RollbackOutcome {
let mut outcome = RollbackOutcome::default();
for entry in applied.iter().rev() {
if entry.moved {
outcome.attempted += 1;
match std::fs::remove_file(&entry.dest) {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => outcome
.failed
.push(format!("删除 {} 失败:{error}", entry.dest.display())),
}
}
if let Some(backup) = &entry.backup {
if backup.exists() {
outcome.attempted += 1;
if let Err(error) = std::fs::rename(backup, &entry.dest) {
outcome.failed.push(format!(
"把备份 {} 改回 {} 失败:{error}",
backup.display(),
entry.dest.display()
));
}
}
}
}
outcome
}
/// 备份路径:`<dest>.ullbak-<pid>`。
pub(super) fn backup_path(dest: &Path) -> PathBuf {
let mut name = dest.file_name().map_or_else(
|| std::ffi::OsString::from("file"),
std::ffi::OsStr::to_os_string,
);
name.push(format!(".{BACKUP_TAG}-{}", std::process::id()));
dest.with_file_name(name)
}
/// 一次**失败的回滚**(提交阶段失败时连同错误一起交回调用方)。
///
/// `io_error` 让"把一个 IO 原因包成 `pack_io`"只在一处定义。
#[derive(Debug)]
pub struct CommitFailure {
/// 提交阶段本身的错误。
pub error: ImportError,
/// 回滚结果(可能不完整)。
pub rollback: RollbackOutcome,
}
impl CommitFailure {
/// 用一条 IO 原因建失败结果。
#[must_use]
pub(super) fn io(message: impl Into<String>, rollback: RollbackOutcome) -> Self {
Self {
error: io_error(message),
rollback,
}
}
/// 用已有的导入错误建失败结果。
#[must_use]
pub(super) fn new(error: ImportError, rollback: RollbackOutcome) -> Self {
Self { error, rollback }
}
}
#[cfg(test)]
mod tests {
use super::*;
fn applied(dest: PathBuf, backup: Option<PathBuf>, moved: bool) -> Applied {
Applied {
dest,
backup,
moved,
}
}
#[test]
fn complete_rollback_has_no_failures() {
let root = std::env::temp_dir().join(format!("ull-rb-ok-{}", std::process::id()));
std::fs::create_dir_all(root.join("config")).expect("建目录");
let dest = root.join("config/a.toml");
let backup = root.join("config/a.toml.ullbak-test");
std::fs::write(&dest, b"new").expect("写新文件");
std::fs::write(&backup, b"old").expect("写备份");
let mut journal = vec![applied(dest.clone(), Some(backup.clone()), true)];
let outcome = rollback_merge(&mut journal);
assert!(outcome.is_complete(), "{outcome:?}");
assert_eq!(outcome.attempted, 2);
assert_eq!(
std::fs::read(&dest).expect("读回"),
b"old",
"备份要改回原名"
);
assert!(!backup.exists());
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn rollback_failures_are_reported_not_swallowed() {
// 目标位置上是个**非空目录**:`remove_file` 与随后的备份改回都会失败
// (Windows / Unix 一致),于是"回滚失败"这条路径被确定性地打到。
let root = std::env::temp_dir().join(format!("ull-rb-fail-{}", std::process::id()));
let dest = root.join("a.toml");
std::fs::create_dir_all(dest.join("occupied")).expect("用非空目录占住目标");
let backup = root.join("a.toml.ullbak-test");
std::fs::write(&backup, b"old").expect("写备份");
let mut journal = vec![applied(dest.clone(), Some(backup), true)];
let outcome = rollback_merge(&mut journal);
assert!(!outcome.is_complete(), "失败必须被记下来:{outcome:?}");
assert_eq!(outcome.failure_count(), 2, "{outcome:?}");
let _ = std::fs::remove_dir_all(&root);
}
}
+247
View File
@@ -0,0 +1,247 @@
//! Fabric 系(Fabric / Quilt):官方 meta → loader 版本 JSON。
//!
//! 依据 `DevDocs/analyse/05-modloader-modpack.md` §1.4:这两个加载器**没有 installer**,
//! `meta.fabricmc.net` / `meta.quiltmc.org` 直接给 launcher 可用的 profile JSON,
//! 所以安装 = "把 profile 收成我们的版本 JSON + 补库",没有任何子进程。
//!
//! 本文件是**纯函数**(不进网络):URL 拼装与 JSON 归一化分开写,测试直接喂离线夹具。
//!
//! # 归一化做了三件容易被上游坑的事
//!
//! 1. **`jar` 钉成原版 id**:profile 通常不写 `jar`,而 `game::inherit::resolve` 在缺 `jar`
//! 时会把 `jar` 兜底成**自身 id**——那会让启动去找 `versions/<loader>/<loader>.jar`
//! (不存在)。这里显式写 `jar = <原版 id>`,让继承链正确地回到原版 jar。
//! 2. **`mainClass` 断言**:缺主类就不是一份能启动的清单,宁可在安装期报 metadata,
//! 也不要装出一个起不来的实例(HMCL 同款防御,analyse/05 §1.4)。
//! 3. **扁平 `sha1`/`size` 提升成 `downloads.artifact`**:Fabric profile 的库把摘要写在
//! 库对象顶层,M27 的库展开只认 `downloads.artifact`。不提上来的后果是"没有校验值 →
//! 存在即信",重装时无法识别被截断的库文件。
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use super::plan::{LoaderRelease, LoaderSupport};
use super::{LoaderError, LoaderKind};
use crate::game::library::{Library, LibraryDownloads};
use crate::game::manifest::{DownloadInfo, VersionJson};
/// Fabric 官方 meta 根(BMCLAPI 有对应镜像规则 `bmclapi-fabric-meta`)。
pub const FABRIC_META: &str = "https://meta.fabricmc.net/v2";
/// Quilt 官方 meta 根(同上,`bmclapi-quilt-meta`)。
pub const QUILT_META: &str = "https://meta.quiltmc.org/v3";
/// meta 根地址(按加载器)。
#[must_use]
pub fn meta_base(kind: LoaderKind) -> &'static str {
match kind {
LoaderKind::Quilt => QUILT_META,
_ => FABRIC_META,
}
}
/// 该原版版本可用的加载器版本列表地址。
#[must_use]
pub fn loader_list_url(kind: LoaderKind, mc_version: &str) -> String {
format!("{}/versions/loader/{mc_version}", meta_base(kind))
}
/// 指定加载器版本的 launcher profile 地址(官方直出可用的版本 JSON)。
#[must_use]
pub fn profile_url(kind: LoaderKind, mc_version: &str, loader_version: &str) -> String {
format!(
"{}/versions/loader/{mc_version}/{loader_version}/profile/json",
meta_base(kind)
)
}
/// 列表里的一个加载器版本。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LoaderEntry {
/// 版本号(`0.15.11`)。
#[serde(default)]
pub version: String,
/// 是否稳定版(Quilt 的列表不带这个键,缺省按稳定处理)。
#[serde(default = "default_true")]
pub stable: bool,
/// Maven 坐标(`net.fabricmc:fabric-loader:0.15.11`)。
#[serde(default)]
pub maven: String,
}
const fn default_true() -> bool {
true
}
/// 列表响应的一行(`[{ "loader": {…}, "intermediary": {…}, … }]`)。
#[derive(Debug, Deserialize)]
struct ListRow {
#[serde(default)]
loader: Option<LoaderEntry>,
}
/// 解析版本列表(最新的排在前,与官方顺序一致——**不重排**)。
///
/// # Errors
///
/// JSON 非法、或列表形状完全不符(顶层不是数组 / 一条都认不出)时返回 metadata 错误。
pub fn parse_loader_list(kind: LoaderKind, json: &str) -> Result<Vec<LoaderEntry>, LoaderError> {
let rows: Vec<ListRow> = serde_json::from_str(json).map_err(|error| {
LoaderError::metadata(format!("{} 版本列表解析失败:{error}", kind.label()))
})?;
let entries: Vec<LoaderEntry> = rows
.into_iter()
.filter_map(|row| row.loader)
.filter(|entry| !entry.version.is_empty())
.collect();
if entries.is_empty() {
return Err(LoaderError::not_found(format!(
"{} 版本列表为空(该原版版本可能还没有对应加载器)",
kind.label()
)));
}
Ok(entries)
}
/// 从列表里挑一个默认版本:优先第一个稳定版,否则取第一个(列表最新在前)。
#[must_use]
pub fn pick_default(entries: &[LoaderEntry]) -> Option<&LoaderEntry> {
entries
.iter()
.find(|entry| entry.stable)
.or_else(|| entries.first())
}
/// 把官方 profile JSON 归一化成 `<mc>-<kind>-<loader>` 的 loader 版本 JSON。
///
/// # Errors
///
/// profile 不是合法版本 JSON、缺主类时返回 metadata 错误。
pub fn build_release(
kind: LoaderKind,
mc_version: &str,
loader_version: &str,
profile_json: &str,
) -> Result<LoaderRelease, LoaderError> {
let mut version_json = VersionJson::from_json_str(profile_json).map_err(|error| {
LoaderError::metadata(format!(
"{} {loader_version} 的 profile JSON 解析失败:{error}",
kind.label()
))
})?;
let mut warnings = Vec::new();
let version_id = kind.version_id(mc_version, loader_version);
version_json.id = Some(version_id.clone());
version_json.name = Some(format!("{mc_version} · {} {loader_version}", kind.label()));
version_json.inherits_from = Some(mc_version.to_string());
// 启动用原版 jar(见模块文档第 1 条)。
version_json.jar = Some(mc_version.to_string());
if version_json
.main_class
.as_deref()
.is_none_or(|value| value.trim().is_empty())
{
return Err(LoaderError::metadata(format!(
"{} {loader_version} 的 profile 没有 mainClass:这份清单装上去也起不来",
kind.label()
))
.with_hint("换一个加载器版本,或用官方启动器核对"));
}
let libraries = version_json.libraries.get_or_insert_with(Vec::new);
for library in libraries.iter_mut() {
lift_flat_digest(library);
}
ensure_loader_library(kind, loader_version, libraries, &mut warnings);
Ok(LoaderRelease {
kind,
mc_version: mc_version.to_string(),
loader_version: loader_version.to_string(),
version_id,
version_json,
support: LoaderSupport::JsonRoute,
installer: None,
install_profile: None,
warnings,
})
}
/// 把库对象顶层的 `sha1`/`size` 提升成 `downloads.artifact`(Fabric profile 的写法)。
fn lift_flat_digest(library: &mut Library) {
if library.downloads.is_some() {
return;
}
let Some(base) = library
.url
.as_deref()
.filter(|url| !url.is_empty())
.map(str::to_string)
else {
return;
};
let Ok(relative) = library.relative_path() else {
return;
};
let sha1 = library
.extra
.get("sha1")
.and_then(serde_json::Value::as_str)
.map(str::to_string);
let size = library
.extra
.get("size")
.and_then(serde_json::Value::as_u64);
let path = relative.to_string_lossy().replace('\\', "/");
let url = if base.ends_with('/') {
format!("{base}{path}")
} else {
format!("{base}/{path}")
};
library.downloads = Some(LibraryDownloads {
artifact: Some(DownloadInfo {
id: None,
sha1,
size,
url: Some(url),
path: Some(path),
}),
classifiers: BTreeMap::new(),
});
}
/// loader 本体库必须在列表里:缺了就补一条(并用告警说明),别装出个"没有 loader"的实例。
fn ensure_loader_library(
kind: LoaderKind,
loader_version: &str,
libraries: &mut Vec<Library>,
warnings: &mut Vec<String>,
) {
let (group, artifact) = match kind {
LoaderKind::Quilt => ("org.quiltmc", "quilt-loader"),
_ => ("net.fabricmc", "fabric-loader"),
};
let present = libraries.iter().any(|library| {
library
.coordinate()
.is_ok_and(|coordinate| coordinate.group == group && coordinate.artifact == artifact)
});
if present {
return;
}
let maven_base = match kind {
LoaderKind::Quilt => "https://maven.quiltmc.org/repository/release/",
_ => "https://maven.fabricmc.net/",
};
libraries.push(Library {
name: format!("{group}:{artifact}:{loader_version}"),
url: Some(maven_base.to_string()),
..Library::default()
});
warnings.push(format!(
"{} {loader_version} 的 profile 没带 {group}:{artifact}:已按 maven 坐标补一条(缺库会让游戏起不来)",
kind.label()
));
}
+280
View File
@@ -0,0 +1,280 @@
//! NeoForge / Forge:installer 内的 install profile → loader 版本 JSON。
//!
//! 依据 `DevDocs/analyse/05-modloader-modpack.md` §1.2/§1.3:Forge 有两代 installer 协议,
//! NeoForge 是 fork(早期与 Forge 完全同构)。判据也在那儿:install profile 有没有
//! `spec` / processors、有没有独立的 `version.json`。maven 坐标与版本表在 [`super::maven`]。
//!
//! # 本模块的取舍(为什么只做一部分)
//!
//! 这两家都**不发**launcher 直接可用的 profile,只发 installer jar;能纯 json 化到什么程度
//! 取决于 installer 声明的处理器:
//!
//! | install profile 形状 | 实测样例 | 本模块结论 |
//! |----------------------|----------|-----------|
//! | `spec: 1` + 有 client 侧 processors | Forge 1.20.1-47.2.0、NeoForge 21.1.80 | [`LoaderSupport::RequiresInstaller`] |
//! | `spec: 0` + 无 processors + 内嵌 `version.json` | Forge 1.12.2-14.23.5.2860 | [`LoaderSupport::JsonRoute`] |
//! | 只有内联 `versionInfo`(+ `install.filePath` 解包) | 1.12.2 之前的老重建版 | 明确 unsupported |
//!
//! 第一类必须跑 `java -cp … installertools …` 才能产出 `client-slim/extra` 等打补丁产物
//! (processor 的 `data` 变量表里有 `MC_SLIM`/`MC_EXTRA`/`PATCHED`),解析结果因此带上
//! [`LoaderSupport::RequiresInstaller`] 标记 + install profile 本体,交给
//! [`super::processors`](M35 执行器)真跑;这里的**判定**仍只管「要不要跑 installer」。
//!
//! 第二类的本体 jar 在 version.json 里 `url` 是空串,且**官方 maven 只发布 `-universal`
//! classifier**(无 classifier 路径官方 404,只有 BMCLAPI 的 302 别名兜底)——
//! 补救见 [`fill_missing_urls`],URL 带 classifier、磁盘落点保持官方 installer 的原样。
//!
//! # 一条容易踩的坑:processor 的库不进启动 classpath
//!
//! install profile 自带的 `libraries` 是**processor 依赖**(installertools/srgutils…),
//! 它们不属于游戏 classpath。把它们并进 loader 版本 JSON 会让启动多加载一堆无关 jar,
//! 因此这里只记数量进告警、绝不合并;真下载它们的是 [`super::processors`]。
use std::collections::BTreeMap;
use serde::Deserialize;
use super::maven;
use super::plan::{LoaderRelease, LoaderSupport};
use super::{LoaderError, LoaderKind};
use crate::game::library::Library;
use crate::game::manifest::VersionJson;
pub use super::processors::spec::{Datum, Processor};
/// 老协议 installer 的 `install` 段(`filePath` 指向内嵌的 universal jar)。
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LegacyInstall {
/// 内嵌 jar 在 installer 里的相对路径。
#[serde(default)]
pub file_path: Option<String>,
}
/// installer 里的 `install_profile.json`(只解析我们需要的键,其余忽略)。
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InstallProfile {
/// 协议代号(`1` = processor 时代)。
#[serde(default)]
pub spec: Option<i64>,
/// 组件名(`forge` / `NeoForge`)。
#[serde(default)]
pub profile: Option<String>,
/// installer 自己的版本串(`1.20.1-forge-47.2.0`)。
#[serde(default)]
pub version: Option<String>,
/// 目标原版版本。
#[serde(default)]
pub minecraft: Option<String>,
/// 内嵌版本 JSON 在 installer 里的路径(通常是 `/version.json`)。
#[serde(default, rename = "json")]
pub version_json_path: Option<String>,
/// universal jar 的 maven 坐标(`net.minecraftforge:forge:…`,spec 0 才有)。
#[serde(default)]
pub path: Option<String>,
/// processor 列表。
#[serde(default)]
pub processors: Vec<Processor>,
/// processor 的变量表(`{MAPPINGS}` / `{MC_SLIM}` 之类占位的数据源)。
#[serde(default)]
pub data: BTreeMap<String, Datum>,
/// processor / universal jar 依赖(**不是**启动 classpath,别合并)。
#[serde(default)]
pub libraries: Vec<Library>,
/// 老协议:内嵌 jar 解包信息。
#[serde(default)]
pub install: Option<LegacyInstall>,
/// 老协议:内联的版本 JSON 片段。
#[serde(default, rename = "versionInfo")]
pub version_info: Option<VersionJson>,
}
impl InstallProfile {
/// client 侧 processor 数量。
#[must_use]
pub fn client_processors(&self) -> usize {
self.processors
.iter()
.filter(|processor| processor.applies_to_client())
.count()
}
/// 是否是老协议 installer(内联 `versionInfo` + 解包 universal jar)。
#[must_use]
pub fn is_legacy_protocol(&self) -> bool {
self.version_json_path.is_none() && (self.install.is_some() || self.version_info.is_some())
}
}
/// 用 install profile + 内嵌版本 JSON 组 loader 版本。
///
/// `embedded` 是 installer 内 `version.json` 的内容(读不到就是 `None`)。
///
/// # Errors
///
/// 原版版本对不上、installer 里没有 `version.json`、生成的清单缺主类时返回错误。
pub fn build_release(
kind: LoaderKind,
mc_version: &str,
maven_version: &str,
profile: &InstallProfile,
embedded: Option<&VersionJson>,
) -> Result<LoaderRelease, LoaderError> {
if let Some(declared) = profile.minecraft.as_deref() {
if declared != mc_version {
return Err(LoaderError::metadata(format!(
"installer 声明的原版是 {declared},与目标的 {mc_version} 不符(选错了加载器版本)"
))
.with_hint("`--loader-version` 要和 `install <version>` 的原版版本配套"));
}
}
let loader_version = maven::display_loader_version(kind, mc_version, maven_version);
let version_id = kind.version_id(mc_version, &loader_version);
let mut warnings = Vec::new();
let Some(embedded) = embedded else {
let reason = if profile.is_legacy_protocol() {
format!(
"{} {} 走老协议 installer(内联 versionInfo + 解包 universal jar),本期不做解包路线",
kind.label(),
loader_version
)
} else {
format!(
"{} {} 的 installer 里没有可读的 version.json",
kind.label(),
loader_version
)
};
return Err(LoaderError::unsupported(reason).with_hint(
"这段协议要动 installer 内的 jar,属后续 mission;可先用官方启动器装好该加载器",
));
};
let mut version_json = embedded.clone();
let mut support = LoaderSupport::JsonRoute;
let client_processors = profile.client_processors();
if client_processors > 0 {
support = LoaderSupport::RequiresInstaller {
reason: format!(
"{} {} 的 installer 声明了 {client_processors} 个 client 侧 processor(拆包/映射/打补丁),必须先外调 java 执行",
kind.label(),
loader_version
),
};
warnings.push(format!(
"install profile 里另有 {} 个 processor 依赖(installertools 等):它们不进启动 classpath,只在安装期给执行器用",
profile.libraries.len()
));
}
version_json.id = Some(version_id.clone());
version_json.name = Some(format!("{mc_version} · {} {loader_version}", kind.label()));
version_json.inherits_from = Some(mc_version.to_string());
version_json.jar = Some(mc_version.to_string());
if version_json
.main_class
.as_deref()
.is_none_or(|value| value.trim().is_empty())
{
return Err(LoaderError::metadata(format!(
"{} {} 的 version.json 没有 mainClass",
kind.label(),
loader_version
)));
}
let libraries = version_json.libraries.get_or_insert_with(Vec::new);
let installer_body = profile.path.as_deref();
for library in libraries.iter_mut() {
fill_missing_urls(library, maven::base(kind), installer_body);
}
Ok(LoaderRelease {
kind,
mc_version: mc_version.to_string(),
loader_version,
version_id,
version_json,
support,
installer: None,
install_profile: Some(profile.clone()),
warnings,
})
}
/// spec 0 的 version.json 里,forge 本体库的 `url` 常是空串(jar 原本内嵌在 installer 里),
/// 于是"补一个 maven 地址"就能直取,省掉解包 installer。
///
/// ⚠️ 本体 jar 的**发布 classifier 是 `universal`**:installer 内嵌的条目叫
/// `maven/…/forge-<ver>.jar`(无 classifier),但官方 maven 只托管
/// `forge-<ver>-universal.jar`——无 classifier 的那条路径**官方 404**
/// (实测 1.12.2-14.23.5.2860:`-universal.jar` 200 / 4466148 字节,sha1 与 version.json 一致;
/// 无 classifier 路径仅靠 BMCLAPI 的 302 别名兜底)。
/// 因此这里**只改 URL**:给 installer 声明的本体构件追加 `-universal`;
/// `artifact.path`(磁盘落点)保持官方 installer 的原样,启动 classpath 与官方布局一致。
fn fill_missing_urls(library: &mut Library, base: &str, installer_body: Option<&str>) {
if library
.url
.as_deref()
.is_none_or(|url| url.trim().is_empty())
{
library.url = Some(if base.ends_with('/') {
base.to_string()
} else {
format!("{base}/")
});
}
// 先算"是不是 installer 声明的本体"(要读 library 的坐标),再拿 artifact 的可变借用。
let is_body = is_installer_body(library, installer_body);
let root = library.url.clone().unwrap_or_else(|| base.to_string());
let Some(artifact) = library
.downloads
.as_mut()
.and_then(|downloads| downloads.artifact.as_mut())
else {
return;
};
if artifact.has_url() {
return;
}
let Some(path) = artifact.path.clone() else {
return;
};
let remote_path = if is_body {
with_universal_classifier(&path)
} else {
path
};
artifact.url = Some(if root.ends_with('/') {
format!("{root}{remote_path}")
} else {
format!("{root}/{remote_path}")
});
}
/// 这条库是不是 install profile 的 `path` 声明的本体构件(`group:artifact:version`,无 classifier)。
fn is_installer_body(library: &Library, installer_body: Option<&str>) -> bool {
let Some(body) = installer_body else {
return false;
};
let mut parts = body.split(':');
let (Some(group), Some(artifact)) = (parts.next(), parts.next()) else {
return false;
};
library.coordinate().is_ok_and(|coordinate| {
coordinate.classifier.is_none()
&& coordinate.group == group
&& coordinate.artifact == artifact
})
}
/// `…/forge-1.12.2-14.23.5.2860.jar` → `…/forge-1.12.2-14.23.5.2860-universal.jar`。
fn with_universal_classifier(path: &str) -> String {
match path.strip_suffix(".jar") {
Some(stem) => format!("{stem}-universal.jar"),
None => path.to_string(),
}
}
+173
View File
@@ -0,0 +1,173 @@
//! Maven 坐标与版本表(Forge / NeoForge):版本列表地址、版本选择、installer 地址。
//!
//! 与 [`super::forge`] 的分工:这里只管"maven 上有什么版本、jar 在哪",
//! 那边只管"installer 里的 profile 怎么变成版本 JSON"。
//!
//! 镜像:两家 maven 都在 D7 镜像表里有 BMCLAPI 规则(`bmclapi-neoforge` /
//! `bmclapi-forge-maven-net`),所以这里一律**写官方地址**,重写交给 `core::download`
//! 的镜像展开(与 M27 的"不拼镜像 URL"纪律一致)。
use super::LoaderKind;
/// NeoForge maven 根(规则 `bmclapi-neoforge` 把 `/releases` 重写到 `/maven`)。
pub const NEOFORGE_MAVEN: &str = "https://maven.neoforged.net/releases";
/// Forge maven 根(规则 `bmclapi-forge-maven-net`)。
pub const FORGE_MAVEN: &str = "https://maven.minecraftforge.net";
/// NeoForge 唯一走 Forge 布局的原版版本(1.20.1 是"NeoForge 还叫 forge 的时候")。
pub const NEOFORGE_FORGE_LAYOUT_MC: &str = "1.20.1";
/// maven 根地址。
#[must_use]
pub fn base(kind: LoaderKind) -> &'static str {
match kind {
LoaderKind::NeoForge => NEOFORGE_MAVEN,
_ => FORGE_MAVEN,
}
}
/// 该原版版本对应的 `maven-metadata.xml` 地址。
#[must_use]
pub fn metadata_url(kind: LoaderKind, mc_version: &str) -> String {
let (root, path) = match kind {
LoaderKind::NeoForge if mc_version == NEOFORGE_FORGE_LAYOUT_MC => {
(NEOFORGE_MAVEN, "net/neoforged/forge")
}
LoaderKind::NeoForge => (NEOFORGE_MAVEN, "net/neoforged/neoforge"),
_ => (FORGE_MAVEN, "net/minecraftforge/forge"),
};
format!("{root}/{path}/maven-metadata.xml")
}
/// installer jar 地址。
#[must_use]
pub fn installer_url(kind: LoaderKind, mc_version: &str, maven_version: &str) -> String {
let (root, path, file) = match kind {
LoaderKind::NeoForge if mc_version == NEOFORGE_FORGE_LAYOUT_MC => (
NEOFORGE_MAVEN,
"net/neoforged/forge",
format!("forge-{maven_version}-installer.jar"),
),
LoaderKind::NeoForge => (
NEOFORGE_MAVEN,
"net/neoforged/neoforge",
format!("neoforge-{maven_version}-installer.jar"),
),
_ => (
FORGE_MAVEN,
"net/minecraftforge/forge",
format!("forge-{maven_version}-installer.jar"),
),
};
format!("{root}/{path}/{maven_version}/{file}")
}
/// 扫 maven-metadata.xml 里的 `<version>` 值。
///
/// 故意不引 XML 库:maven 元数据是**极简且稳定**的格式(只有一层 `<version>` 列表),
/// 为它拉一个 XML 解析器不划算。解析不出就返回空表,由上层报"没有可用版本"。
#[must_use]
pub fn parse_metadata_versions(xml: &str) -> Vec<String> {
const OPEN: &str = "<version>";
const CLOSE: &str = "</version>";
let mut out = Vec::new();
let mut rest = xml;
while let Some(start) = rest.find(OPEN) {
let after = &rest[start + OPEN.len()..];
let Some(end) = after.find(CLOSE) else {
break;
};
let value = after[..end].trim();
if !value.is_empty() {
out.push(value.to_string());
}
rest = &after[end + CLOSE.len()..];
}
out
}
/// NeoForge 版本号 → 原版版本号(`21.1.80` → `1.21.1`,analyse/05 §1.3 的反解规则)。
///
/// - 主版本 `0` 是快照版(命名不带原版版本,返回 `None`);
/// - 主版本 `≥26` 是 MC 新命名方案(`26.1.2.125` → `26.1`);
/// - 其余是 `1.<主>.<次>`(`20.4.237` → `1.20.4`)。
#[must_use]
pub fn neoforge_mc_version(version: &str) -> Option<String> {
let core = version.split(['-', '+']).next().unwrap_or(version);
let segments: Vec<u64> = core
.split('.')
.map(str::parse::<u64>)
.collect::<Result<_, _>>()
.ok()?;
let (major, minor) = (*segments.first()?, *segments.get(1)?);
match major {
0 => None,
26.. => Some(format!("{major}.{minor}")),
_ => Some(format!("1.{major}.{minor}")),
}
}
/// maven 版本是否属于该原版版本。
#[must_use]
pub fn matches_mc(kind: LoaderKind, mc_version: &str, version: &str) -> bool {
match kind {
LoaderKind::NeoForge if mc_version == NEOFORGE_FORGE_LAYOUT_MC => {
version.starts_with(&format!("{mc_version}-"))
}
LoaderKind::NeoForge => neoforge_mc_version(version).as_deref() == Some(mc_version),
_ => version.starts_with(&format!("{mc_version}-")),
}
}
/// 从 maven 版本表里挑出属于该原版版本的加载器版本(**升序**,末尾最好)。
#[must_use]
pub fn select_versions(kind: LoaderKind, mc_version: &str, versions: &[String]) -> Vec<String> {
let mut picked: Vec<String> = versions
.iter()
.filter(|version| matches_mc(kind, mc_version, version))
.cloned()
.collect();
picked.sort_by(|left, right| {
numeric_key(left)
.cmp(&numeric_key(right))
.then_with(|| left.cmp(right))
});
picked
}
/// 用户给的版本号归一成 maven 版本号(Forge 的 maven 版本带原版前缀)。
///
/// `--loader-version 47.2.0` 与 `--loader-version 1.20.1-47.2.0` 都接受;
/// NeoForge(非 1.20.1 布局)本身就是完整 maven 版本,原样返回。
#[must_use]
pub fn normalize_maven_version(kind: LoaderKind, mc_version: &str, raw: &str) -> String {
let raw = raw.trim();
let needs_prefix = match kind {
LoaderKind::NeoForge => mc_version == NEOFORGE_FORGE_LAYOUT_MC,
_ => true,
};
if needs_prefix && !raw.starts_with(&format!("{mc_version}-")) {
format!("{mc_version}-{raw}")
} else {
raw.to_string()
}
}
/// 用户视角的加载器版本号(Forge 去掉原版前缀,NeoForge 原样)。
#[must_use]
pub fn display_loader_version(kind: LoaderKind, mc_version: &str, maven_version: &str) -> String {
match kind {
LoaderKind::NeoForge if mc_version != NEOFORGE_FORGE_LAYOUT_MC => maven_version.to_string(),
_ => maven_version
.strip_prefix(&format!("{mc_version}-"))
.unwrap_or(maven_version)
.to_string(),
}
}
/// 版本号 → 可比较的数字键(非数字段按 0,保证不 panic)。
fn numeric_key(version: &str) -> Vec<u64> {
version
.split(['-', '.', '+'])
.map(|segment| segment.parse::<u64>().unwrap_or(0))
.collect()
}
+242
View File
@@ -0,0 +1,242 @@
//! 加载器元数据的网络层:版本列表 + 官方 profile/installer → [`LoaderRelease`]。
//!
//! 分工与 M27 的 [`crate::game::install::RemoteSource`] 一样:**只发请求、只解析**,
//! 不写游戏目录(installer jar 落 `.ULL/cache/meta/` 作离线复用),镜像/退避/health 全在
//! `core::download` 的引擎里(我们一律递**官方地址**,重写由镜像表负责)。
//!
//! 两族的取数路径完全不同:
//!
//! ```text
//! Fabric / Quilt : meta API → profile/json(官方直出可用版本 JSON)
//! NeoForge/Forge : maven-metadata.xml → installer jar → 读 install_profile.json + version.json
//! ```
use std::fs::File;
use std::path::{Path, PathBuf};
use zip::ZipArchive;
use super::fabric;
use super::forge::{self, InstallProfile};
use super::maven;
use super::plan::LoaderRelease;
use super::{LoaderError, LoaderKind};
use crate::download::{DownloadEngine, DownloadRequest, ResourceKind};
use crate::game::manifest::VersionJson;
/// installer jar 的缓存文件名前缀(超长/含 `+` 的版本串会被规整成安全文件名)。
const INSTALLER_TAG: &str = "installer";
/// 远端加载器元数据来源。
pub struct LoaderSource<'a> {
engine: &'a DownloadEngine,
meta_cache: &'a Path,
}
impl<'a> LoaderSource<'a> {
/// 绑定下载引擎与元数据缓存目录(`.ULL/cache/meta/`)。
#[must_use]
pub fn new(engine: &'a DownloadEngine, meta_cache: &'a Path) -> Self {
Self { engine, meta_cache }
}
/// 取该原版版本可用的加载器版本列表(**最新在前**)。
///
/// # Errors
///
/// 网络失败、元数据解析失败、该原版版本没有加载器版本时返回 [`LoaderError`]。
pub async fn loader_versions(
&self,
kind: LoaderKind,
mc_version: &str,
) -> Result<Vec<String>, LoaderError> {
if kind.is_fabric_family() {
let url = fabric::loader_list_url(kind, mc_version);
let body = self.fetch_text(&url).await?;
let entries = fabric::parse_loader_list(kind, &body)?;
return Ok(entries.into_iter().map(|entry| entry.version).collect());
}
let url = maven::metadata_url(kind, mc_version);
let body = self.fetch_text(&url).await?;
let all = maven::parse_metadata_versions(&body);
// maven 表是升序的,统一成"最新在前"。
let mut picked = maven::select_versions(kind, mc_version, &all);
picked.reverse();
if picked.is_empty() {
return Err(LoaderError::not_found(format!(
"{} 在 {} 上没有可用版本(maven 表 {} 条,全部与该原版版本不匹配)",
kind.label(),
mc_version,
all.len()
)));
}
Ok(picked)
}
/// 解析一个加载器版本(`loader_version` 为 `None` 时取官方默认:Fabric 系取首个稳定版,
/// maven 系取该原版版本下最新的一版)。
///
/// # Errors
///
/// 网络失败、版本不存在、installer 缺 profile、协议不受支持时返回 [`LoaderError`]。
pub async fn resolve(
&self,
kind: LoaderKind,
mc_version: &str,
loader_version: Option<&str>,
) -> Result<LoaderRelease, LoaderError> {
let requested = loader_version
.map(str::trim)
.filter(|value| !value.is_empty());
if kind.is_fabric_family() {
let version = match requested {
Some(version) => version.to_string(),
None => {
let url = fabric::loader_list_url(kind, mc_version);
let body = self.fetch_text(&url).await?;
let entries = fabric::parse_loader_list(kind, &body)?;
fabric::pick_default(&entries)
.map(|entry| entry.version.clone())
.ok_or_else(|| {
LoaderError::not_found(format!("{} 没有可用的加载器版本", kind.label()))
})?
}
};
let url = fabric::profile_url(kind, mc_version, &version);
let body = self.fetch_text(&url).await?;
return fabric::build_release(kind, mc_version, &version, &body);
}
let maven_version = match requested {
Some(raw) => maven::normalize_maven_version(kind, mc_version, raw),
None => self
.loader_versions(kind, mc_version)
.await?
.into_iter()
.next()
.ok_or_else(|| {
LoaderError::not_found(format!("{} 没有可用的加载器版本", kind.label()))
})?,
};
let url = maven::installer_url(kind, mc_version, &maven_version);
let path = self.installer_path(kind, &maven_version);
let jar = self.fetch_installer(&url, &path).await?;
let (profile, embedded) = read_installer(&jar)?;
let mut release = forge::build_release(
kind,
mc_version,
&maven_version,
&profile,
embedded.as_ref(),
)?;
// M35 执行器要的两样东西:installer 本体(跑 processor 与解包)与它声明的 profile。
release.installer = Some(jar);
Ok(release)
}
/// installer jar 的本地缓存路径(`+`/非法字符规整成 `_`)。
fn installer_path(&self, kind: LoaderKind, maven_version: &str) -> PathBuf {
let safe: String = maven_version
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_') {
ch
} else {
'_'
}
})
.collect();
self.meta_cache
.join(format!("{}-{safe}-{INSTALLER_TAG}.jar", kind.as_str()))
}
/// 取 installer jar:本地缓存里有可读的 zip 就直接用(离线复用),否则下载。
async fn fetch_installer(&self, url: &str, path: &Path) -> Result<PathBuf, LoaderError> {
if path.is_file() && ZipArchive::new(File::open(path).map_err(io_error(path))?).is_ok() {
return Ok(path.to_path_buf());
}
let request = DownloadRequest::new(ResourceKind::Library, url, path.to_path_buf());
let outcome = self.engine.download(request).await?;
Ok(outcome.path)
}
/// 取一小段文本元数据(版本列表 / profile JSON / maven metadata)。
async fn fetch_text(&self, url: &str) -> Result<String, LoaderError> {
let dest = self.meta_cache.join("loader-meta.bin");
let bytes = self
.engine
.download_bytes(DownloadRequest::new(
ResourceKind::VersionManifest,
url,
dest,
))
.await?;
String::from_utf8(bytes)
.map_err(|_| LoaderError::metadata(format!("元数据不是 UTF-8:{url}")))
}
}
/// 读 installer jar 里的 `install_profile.json` 与内嵌的版本 JSON。
///
/// 内嵌版本 JSON 的路径来自 profile 的 `json` 字段(通常 `/version.json`);
/// 该条目不存在时返回 `None`,由 [`forge::build_release`] 判定为 unsupported。
///
/// # Errors
///
/// 不是合法 zip、缺 `install_profile.json`、profile/版本 JSON 无法解析时返回 metadata 错误。
pub fn read_installer(path: &Path) -> Result<(InstallProfile, Option<VersionJson>), LoaderError> {
let file = File::open(path).map_err(io_error(path))?;
let mut archive = ZipArchive::new(file).map_err(|error| {
LoaderError::metadata(format!("{} 不是合法 zip:{error}", path.display()))
})?;
let profile_text = read_entry(&mut archive, "install_profile.json")?.ok_or_else(|| {
LoaderError::metadata(format!(
"{} 里没有 install_profile.json(不是 Forge/NeoForge installer?)",
path.display()
))
})?;
let profile: InstallProfile = serde_json::from_str(&profile_text).map_err(|error| {
LoaderError::metadata(format!("install_profile.json 解析失败:{error}"))
})?;
let embedded = match profile
.version_json_path
.as_deref()
.map(|entry| entry.trim_start_matches('/'))
.filter(|entry| !entry.is_empty())
{
Some(entry) => match read_entry(&mut archive, entry)? {
Some(text) => Some(VersionJson::from_json_str(&text).map_err(|error| {
LoaderError::metadata(format!("installer 内 {entry} 解析失败:{error}"))
})?),
None => None,
},
None => None,
};
Ok((profile, embedded))
}
/// 读一个 zip 条目成字符串;条目不存在返回 `Ok(None)`。
fn read_entry(archive: &mut ZipArchive<File>, name: &str) -> Result<Option<String>, LoaderError> {
use std::io::Read;
let mut entry = match archive.by_name(name) {
Ok(entry) => entry,
Err(zip::result::ZipError::FileNotFound) => return Ok(None),
Err(error) => {
return Err(LoaderError::metadata(format!(
"读 installer 条目 {name} 失败:{error}"
)))
}
};
let mut text = String::new();
entry
.read_to_string(&mut text)
.map_err(|error| LoaderError::io(format!("installer 条目 {name} 不是可读文本:{error}")))?;
Ok(Some(text))
}
fn io_error(path: &Path) -> impl FnOnce(std::io::Error) -> LoaderError + '_ {
move |error| LoaderError::io(format!("{} 读写失败:{error}", path.display()))
}
+272
View File
@@ -0,0 +1,272 @@
//! 加载器安装(Fabric / Quilt / NeoForge / Forge):官方元数据 → loader 版本 JSON → 补库清单。
//!
//! 设计输入:`DevDocs/PlanDoc/08-mod-and-recommend.md` §1(D10 加载器安装)、
//! `DevDocs/analyse/05-modloader-modpack.md` §1(五加载器安装协议、`patch + priority` 主线)。
//!
//! # 与 M27 安装层的关系(不复制粘贴,只叠一层)
//!
//! 加载器不是"另一个版本",而是**叠在已装原版之上的一个 `inheritsFrom` 层**:
//!
//! ```text
//! versions/1.20.1/1.20.1.json 原版(M27 负责装)
//! versions/1.20.1-fabric-0.15.11/…json loader 层:inheritsFrom=1.20.1 + 主类 + loader 库
//! ```
//!
//! 因此本模块**只做两件事**:①把官方元数据翻译成一份 loader 版本 JSON;
//! ②把它的库列表交给 [`crate::game::install::libraries`] 展开成下载计划。
//! 落盘、跳过判定、实例登记一律复用 M27([`crate::game::install::execute`] / `register`),
//! 不另起一套下载/写盘逻辑。
//!
//! # 三条路线(按"能不能不跑 java 就装出来"分类,而不是按加载器名字)
//!
//! | 路线 | 加载器 | 做法 |
//! |------|--------|------|
//! | [`LoaderSupport::JsonRoute`] | Fabric / Quilt | 官方 meta 直接给 launcher 用的 profile JSON |
//! | [`LoaderSupport::JsonRoute`] | Forge spec-0(如 1.12.2 重建版) | installer 里的 `version.json` + 库清单,universal jar 走 maven 直取 |
//! | [`LoaderSupport::RequiresInstaller`] | Forge spec≥1 / NeoForge | installer 声明了 client 侧 processors(拆包/映射),**必须外调 java** |
//!
//! 第三类本期只出**明确 unsupported**:版本 JSON 与库清单仍照常生成(`--dry-run` 可看),
//! 但真装会被拒——半成品实例比明确报错更糟(沿 M27 §7「如实告知」的纪律)。
//! 老协议 installer(内联 `versionInfo` + universal jar 解包,≈1.12.2 之前的重建版)同样归 unsupported。
pub mod fabric;
pub mod forge;
pub mod maven;
pub mod meta;
pub mod plan;
pub mod processors;
#[cfg(test)]
mod tests;
pub use meta::LoaderSource;
pub use plan::{LoaderInstallPlan, LoaderPlanInput, LoaderRelease, LoaderSupport};
use std::fmt;
use crate::download::{DownloadError, DownloadErrorKind};
/// 支持的加载器种类。
///
/// `vanilla` / `none` **不在这里**:那是"不装加载器",由 CLI 走 M27 的原版路径。
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum LoaderKind {
/// Fabric(`meta.fabricmc.net`,纯 meta,无 installer)。
Fabric,
/// Quilt(`meta.quiltmc.org`,与 Fabric 同构)。
Quilt,
/// NeoForge(`maven.neoforged.net`,installer 内嵌 install profile)。
NeoForge,
/// Forge(`maven.minecraftforge.net`,两代 installer 协议)。
Forge,
}
impl LoaderKind {
/// 解析命令行 / 整合包清单里的加载器名(大小写不敏感)。
///
/// 七个 id 的匹配**委派**给 [`crate::game::LoaderKind::parse`](词表的源头,M126),
/// 本方法只多收一个**输入别名** `neo-forge`:那是 Modrinth 的拼法(mrpack
/// `dependencies` 的键、CF 包里也有这种写法),**只进不出**——[`Self::as_str`] 绝不产出
/// 它。识别得出但本启动器装不了的几家(原版 / OptiFine / LiteLoader)返回 `None`。
#[must_use]
pub fn parse(raw: &str) -> Option<Self> {
let wanted = raw.trim();
if wanted.eq_ignore_ascii_case("neo-forge") {
return Some(Self::NeoForge);
}
Self::from_core(crate::game::LoaderKind::parse(wanted)?)
}
/// core 的宽种类 → 本模块的四家(`None` = 识别得出但本启动器装不了)。
///
/// 穷尽 match:core 加一支就编不过,逼作者决定「它能不能装」。
const fn from_core(kind: crate::game::LoaderKind) -> Option<Self> {
match kind {
crate::game::LoaderKind::Fabric => Some(Self::Fabric),
crate::game::LoaderKind::Quilt => Some(Self::Quilt),
crate::game::LoaderKind::NeoForge => Some(Self::NeoForge),
crate::game::LoaderKind::Forge => Some(Self::Forge),
crate::game::LoaderKind::Vanilla
| crate::game::LoaderKind::OptiFine
| crate::game::LoaderKind::LiteLoader => None,
}
}
/// 稳定字符串 id(进版本 id、JSON 输出与日志)。
///
/// 词表**不在本模块**:[`crate::game::LoaderKind::as_str`] 是「种类 → 对外稳定 id」的源头
/// (M126)。这里只做**结构映射**(一个 id 字面量都没有)——安装侧拼出来的
/// `<mc>-<kind>-<loader>` 必须能被 [`crate::game::InstanceLoader::parse`] 认回来,委派让
/// 「两边各写一份、改一边忘了另一边」这件事物理上做不到。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Fabric => crate::game::LoaderKind::Fabric.as_str(),
Self::Quilt => crate::game::LoaderKind::Quilt.as_str(),
Self::NeoForge => crate::game::LoaderKind::NeoForge.as_str(),
Self::Forge => crate::game::LoaderKind::Forge.as_str(),
}
}
/// 人读名称。
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::Fabric => "Fabric",
Self::Quilt => "Quilt",
Self::NeoForge => "NeoForge",
Self::Forge => "Forge",
}
}
/// 是否属于 Fabric 系(纯 meta、profile JSON 直接可用)。
#[must_use]
pub const fn is_fabric_family(self) -> bool {
matches!(self, Self::Fabric | Self::Quilt)
}
/// 全部加载器(CLI 的 `--loader` 枚举提示用)。
#[must_use]
pub const fn all() -> &'static [Self] {
&[Self::Fabric, Self::Quilt, Self::NeoForge, Self::Forge]
}
/// loader 版本 id 的词根:`<mc>-<kind>-<loader>`(`versions/<id>/` 与实例 `versionId` 都用它)。
#[must_use]
pub fn version_id(self, mc_version: &str, loader_version: &str) -> String {
format!("{mc_version}-{}-{loader_version}", self.as_str())
}
}
impl fmt::Display for LoaderKind {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.label())
}
}
/// 加载器安装失败的大类(CLI 据此给稳定错误码 `loader_*`)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LoaderErrorKind {
/// 该加载器/该版本走不通我们的路线(需要跑 installer 或属未支持的老协议)。
Unsupported,
/// 官方元数据里没有这个加载器版本(或没有匹配该原版版本的版本)。
NotFound,
/// 元数据解析失败(profile JSON / install profile / maven metadata 坏了)。
Metadata,
/// 网络侧失败(所有候选源都失败 / 超时)。
Network,
/// 本地 IO(读 installer 缓存、写盘失败)。
Io,
}
impl LoaderErrorKind {
/// 稳定字符串 id(进 `--json` 的 `error.code`)。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Unsupported => "loader_unsupported",
Self::NotFound => "loader_not_found",
Self::Metadata => "loader_metadata",
Self::Network => "loader_network",
Self::Io => "loader_io",
}
}
}
/// 加载器安装错误:大类 + 人读说明 + 可选提示。
#[derive(Debug, Clone)]
pub struct LoaderError {
kind: LoaderErrorKind,
message: String,
hint: Option<String>,
}
impl LoaderError {
/// 构造。
#[must_use]
pub fn new(kind: LoaderErrorKind, message: impl Into<String>) -> Self {
Self {
kind,
message: message.into(),
hint: None,
}
}
/// 走不通我们的路线(需要外调 java installer / 老协议)。
#[must_use]
pub fn unsupported(message: impl Into<String>) -> Self {
Self::new(LoaderErrorKind::Unsupported, message)
}
/// 元数据里查不到。
#[must_use]
pub fn not_found(message: impl Into<String>) -> Self {
Self::new(LoaderErrorKind::NotFound, message)
}
/// 元数据坏了/形状不符。
#[must_use]
pub fn metadata(message: impl Into<String>) -> Self {
Self::new(LoaderErrorKind::Metadata, message)
}
/// 本地 IO。
#[must_use]
pub fn io(message: impl Into<String>) -> Self {
Self::new(LoaderErrorKind::Io, message)
}
/// 补一句提示。
#[must_use]
pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
self.hint = Some(hint.into());
self
}
/// 大类。
#[must_use]
pub const fn kind(&self) -> LoaderErrorKind {
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 LoaderError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.message)
}
}
impl std::error::Error for LoaderError {}
impl From<DownloadError> for LoaderError {
fn from(error: DownloadError) -> Self {
let kind = match error.kind {
DownloadErrorKind::Io => LoaderErrorKind::Io,
DownloadErrorKind::InvalidRequest => LoaderErrorKind::Metadata,
DownloadErrorKind::Integrity
| DownloadErrorKind::NotFound
| DownloadErrorKind::ServerError
| DownloadErrorKind::Timeout
| DownloadErrorKind::Network
| DownloadErrorKind::RangeUnsupported
| DownloadErrorKind::AllSourcesFailed => LoaderErrorKind::Network,
};
let mut message = error.message.clone();
if let Some(source) = &error.source {
message.push_str(&format!("(源 {source})"));
}
Self::new(kind, message)
}
}
+235
View File
@@ -0,0 +1,235 @@
//! 加载器安装计划:一份 loader 版本 JSON + 需要补下载的库清单。
//!
//! 计划层与网络层分开:网络在 [`super::meta`],这里只做**纯计算**——把已经解析好的
//! loader 版本 JSON 交给 [`crate::game::install::libraries`] 展开成待下载文件,再包成
//! M27 的 [`InstallPlan`](落盘/跳过判定/原子写版本 JSON 全部沿用同一套实现)。
//!
//! 为什么不直接调 [`InstallPlan::build`]:那是"装一个完整原版版本"的入口(客户端 jar +
//! 资源索引 + 逐对象),而 loader 层**只有库**——jar 与资源都由 `inheritsFrom` 的原版提供。
//! 直接组 [`InstallPlan`] 的字段既省掉一堆"没有 jar"的告警,又不复制任何路径规则。
use std::path::PathBuf;
use serde_json::{json, Value};
use super::forge::InstallProfile;
use super::LoaderKind;
use crate::game::arguments::Environment;
use crate::game::install::libraries;
use crate::game::install::{InstallPlan, PlanFile};
use crate::game::layout::GameLayout;
use crate::game::manifest::VersionJson;
/// 加载器版本的可用路线(判据是"能不能不跑 java 就装出来",不是加载器名字)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LoaderSupport {
/// 纯元数据/纯下载路线:不跑任何外部进程。
JsonRoute,
/// 官方 installer 声明了必须先跑的 processors(拆包/映射),走 M35 执行器外调 java。
RequiresInstaller {
/// 人读原因(含该版本声明的 processors 数量与作用)。
reason: String,
},
}
impl LoaderSupport {
/// 稳定字符串 id(JSON 输出用)。
#[must_use]
pub const fn as_str(&self) -> &'static str {
match self {
Self::JsonRoute => "json_route",
Self::RequiresInstaller { .. } => "requires_installer",
}
}
/// 是否可真正安装(两条路线都走到了:processors 走 M35 执行器)。
#[must_use]
pub const fn is_installable(&self) -> bool {
true
}
/// 是否要外调 java 跑 installer processors。
#[must_use]
pub const fn requires_installer(&self) -> bool {
matches!(self, Self::RequiresInstaller { .. })
}
/// JSON 视图。
#[must_use]
pub fn to_json(&self) -> Value {
match self {
Self::JsonRoute => json!({"mode": self.as_str(), "requires_installer": false}),
Self::RequiresInstaller { reason } => {
json!({"mode": self.as_str(), "requires_installer": true, "reason": reason})
}
}
}
}
/// 一次解析出来的加载器版本(网络层产物,计划层的输入)。
#[derive(Debug, Clone)]
pub struct LoaderRelease {
/// 加载器种类。
pub kind: LoaderKind,
/// 目标原版版本(`inheritsFrom` 的父版本 id)。
pub mc_version: String,
/// 加载器自身版本号(`0.15.11` / `47.2.0` / `21.1.80`)。
pub loader_version: String,
/// 生成的 loader 版本 id(`1.20.1-fabric-0.15.11`,即 `versions/<id>/` 的目录名)。
pub version_id: String,
/// loader 版本 JSON(`inheritsFrom` 指向原版)。
pub version_json: VersionJson,
/// 可走哪条路线。
pub support: LoaderSupport,
/// installer jar 的磁盘路径(M35 执行器的输入;Fabric 系为 `None`)。
pub installer: Option<PathBuf>,
/// installer 里读出来的 install profile(执行器要 `processors` / `data` / `libraries`)。
pub install_profile: Option<InstallProfile>,
/// 解析期告警(不致命:例如 profile 缺 loader 库时我们补了一条)。
pub warnings: Vec<String>,
}
impl LoaderRelease {
/// 是否可真正安装。
#[must_use]
pub const fn is_installable(&self) -> bool {
self.support.is_installable()
}
/// 是否要外调 java 跑 installer processors。
#[must_use]
pub const fn requires_installer(&self) -> bool {
self.support.requires_installer()
}
/// 需要 installer 时的原因(`JsonRoute` 返回 `None`)。
#[must_use]
pub fn installer_reason(&self) -> Option<&str> {
match &self.support {
LoaderSupport::JsonRoute => None,
LoaderSupport::RequiresInstaller { reason } => Some(reason),
}
}
/// JSON 视图(`--dry-run` 与诊断共用)。
#[must_use]
pub fn to_json(&self) -> Value {
json!({
"kind": self.kind.as_str(),
"label": self.kind.label(),
"mc_version": self.mc_version,
"loader_version": self.loader_version,
"version_id": self.version_id,
"inherits_from": self.version_json.inherits_from,
"jar": self.version_json.jar,
"main_class": self.version_json.main_class,
"libraries": self.version_json.libraries.as_ref().map_or(0, Vec::len),
"support": self.support.to_json(),
"warnings": self.warnings,
})
}
}
/// [`LoaderInstallPlan::build`] 的输入。
pub struct LoaderPlanInput<'a> {
/// 游戏目录布局(`versions/` `libraries/` 的路径规则)。
pub layout: &'a GameLayout,
/// 已解析的加载器版本。
pub release: &'a LoaderRelease,
/// 规则求值环境(OS / 架构 / 特性开关)。
pub environment: &'a Environment,
/// 原版的 `versions/<mc>/<mc>.json` 是否已在盘上。
pub vanilla_installed: bool,
}
/// 加载器安装计划:M27 的 [`InstallPlan`] + 加载器自己的上下文。
///
/// 执行期直接 `execute(&engine, &plan.install, …)`:那份计划里的 `version_json` 就是
/// 带 `inheritsFrom` 的 loader 层,`execute` 会在**全部库就绪后**原子写到
/// `versions/<loader-id>/<loader-id>.json`(半成品不写,沿 M27 的纪律)。
#[derive(Debug, Clone)]
pub struct LoaderInstallPlan {
/// 加载器版本上下文。
pub release: LoaderRelease,
/// 待执行的下载计划(库 + 版本 JSON 落盘)。
pub install: InstallPlan,
/// 是否还需要先装原版(原版已装则 `false`,真装时会跳过)。
pub vanilla_install_needed: bool,
}
impl LoaderInstallPlan {
/// 组计划。
///
/// 缺下载地址的库降级成告警(见 [`Self::summary_json`] 的 `warnings`),不硬失败——
/// 与 M27「宁可给出可修复的骨架,也不静默假装成功」一致。
#[must_use]
pub fn build(input: LoaderPlanInput<'_>) -> Self {
let release = input.release;
let (files, library_warnings) = libraries::plan(
input.layout,
&release.version_id,
release
.version_json
.libraries
.as_deref()
.unwrap_or_default(),
input.environment,
);
let mut warnings = release.warnings.clone();
warnings.extend(library_warnings);
if let Some(reason) = release.installer_reason() {
warnings.push(format!("需要外调 java 跑 installer processors:{reason}"));
}
if !input.vanilla_installed {
warnings.push(format!(
"原版 {} 尚未安装:执行时会先装原版再叠加载器",
release.mc_version
));
}
let install = InstallPlan {
version_id: release.version_id.clone(),
// jar 来自原版层(生成的 loader JSON 已把 `jar` 钉成原版 id)。
jar_id: release.mc_version.clone(),
game_root: input.layout.root().to_path_buf(),
version_json_path: input.layout.version_json(&release.version_id),
files,
version_json: release.version_json.clone(),
prefetched: Vec::new(),
warnings,
};
Self {
release: release.clone(),
install,
vanilla_install_needed: !input.vanilla_installed,
}
}
/// 计划里的待下载文件。
#[must_use]
pub fn files(&self) -> &[PlanFile] {
&self.install.files
}
/// 告警(含"原版未装""本期不能真装")。
#[must_use]
pub fn warnings(&self) -> &[String] {
&self.install.warnings
}
/// 汇总 JSON(`--dry-run` 的主载荷)。
#[must_use]
pub fn summary_json(&self) -> Value {
let mut summary = self.install.summary_json();
if let Some(object) = summary.as_object_mut() {
object.insert("loader".to_string(), self.release.to_json());
object.insert(
"vanilla_install_needed".to_string(),
json!(self.vanilla_install_needed),
);
}
summary
}
}
@@ -0,0 +1,83 @@
//! processor 依赖库的下载:把 [`ProcessorPlan::libraries`] 交给 M27 的下载引擎。
//!
//! 为什么不直接复用 `game::install::execute`:那个入口在**全量就绪后写版本 JSON**,
//! 而 processors 的产物要先于版本清单落盘(版本 JSON 存在 = 这版真装完了)。
//! 所以这里只借它的**逐文件跳过判定**(`current_bytes`:摘要 → 大小 → 存在即信)
//! 与结果类型,下载本身仍走同一个引擎——镜像、健康度、退避、续传一条不少。
use futures_util::stream::{self, StreamExt};
use crate::download::DownloadEngine;
use crate::game::install::execute::current_bytes;
use crate::game::install::{
FileFailure, FileOutcome, FileStatus, InstallReport, PlanFile, ProgressSink,
};
/// 并发下载 processor 依赖库(`concurrency` 上限,引擎内部另有信号量)。
///
/// 失败**不**中断:与 M27 一致,逐文件结构化返回,由调用方决定是否继续。
pub async fn download_libraries(
engine: &DownloadEngine,
files: &[PlanFile],
concurrency: usize,
sink: Option<ProgressSink>,
) -> InstallReport {
let outcomes: Vec<FileOutcome> = stream::iter(files.iter())
.map(|file| {
let sink = sink.clone();
async move {
let outcome = install_one(engine, file).await;
if let Some(sink) = &sink {
sink(&outcome);
}
outcome
}
})
.buffer_unordered(concurrency.max(1))
.collect()
.await;
InstallReport {
files: outcomes,
version_json: None,
warnings: Vec::new(),
}
}
/// 单文件:先判「已就绪」,否则走引擎下载。
async fn install_one(engine: &DownloadEngine, file: &PlanFile) -> FileOutcome {
if let Some(bytes) = current_bytes(file) {
return FileOutcome {
role: file.role,
name: file.name.clone(),
dest: file.dest.clone(),
status: FileStatus::Skipped,
bytes,
source: None,
failure: None,
};
}
match engine.download(file.request()).await {
Ok(outcome) => FileOutcome {
role: file.role,
name: file.name.clone(),
dest: file.dest.clone(),
status: FileStatus::Downloaded,
bytes: outcome.bytes,
source: Some(outcome.source),
failure: None,
},
Err(error) => FileOutcome {
role: file.role,
name: file.name.clone(),
dest: file.dest.clone(),
status: FileStatus::Failed,
bytes: 0,
source: None,
failure: Some(FileFailure {
kind: error.kind,
message: error.message.clone(),
}),
},
}
}
@@ -0,0 +1,181 @@
//! processors 执行器的错误分类(CLI 据此给稳定错误码 `processor_*`)。
//!
//! 与 [`crate::addon::loader::LoaderError`] 分开是刻意的:加载器**解析**阶段的失败
//! (元数据坏了、版本不存在)与**执行**阶段的失败(java 跑挂了、产物没落盘)对用户
//! 是完全不同的两件事——前者换版本/换线路,后者多半是 Java 或磁盘问题。
//! 失败分流也是 mission 的硬要求:`processor_failed` 必须能单独被脚本识别。
use std::fmt;
/// processor 执行失败的大类。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProcessorErrorKind {
/// 规划期:install profile 形状不对(缺 `jar`、坐标不合法、变量缺定义)。
Plan,
/// 找不到可用的 Java(M33 的解析链一个候选都没有)。
Java,
/// processor 自己要用的 jar/依赖不在盘上(下载没覆盖到)。
Library,
/// 外调 java 失败(非零退出),消息里带脱敏后的 stderr 尾部。
Failed,
/// processor 跑完了但产物没落盘。
Output,
/// 本地 IO(解包 installer 条目、建目录、复制 MC jar、起进程失败)。
Io,
}
impl ProcessorErrorKind {
/// 稳定字符串 id(进 `--json` 的 `error.code`)。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Plan => "processor_plan",
Self::Java => "processor_java_missing",
Self::Library => "processor_library_missing",
Self::Failed => "processor_failed",
Self::Output => "processor_output_missing",
Self::Io => "processor_io",
}
}
}
/// processor 执行错误:大类 + 人读说明 + 可选提示 + 可选 stderr 尾部(已脱敏)。
#[derive(Debug, Clone)]
pub struct ProcessorError {
kind: ProcessorErrorKind,
message: String,
hint: Option<String>,
stderr_tail: Option<String>,
}
impl ProcessorError {
/// 构造。
#[must_use]
pub fn new(kind: ProcessorErrorKind, message: impl Into<String>) -> Self {
Self {
kind,
message: message.into(),
hint: None,
stderr_tail: None,
}
}
/// 规划期失败(install profile 形状/变量问题)。
#[must_use]
pub fn plan(message: impl Into<String>) -> Self {
Self::new(ProcessorErrorKind::Plan, message)
}
/// 找不到可用的 Java。
#[must_use]
pub fn java_missing(message: impl Into<String>) -> Self {
Self::new(ProcessorErrorKind::Java, message)
}
/// processor 依赖不在盘上。
#[must_use]
pub fn library_missing(message: impl Into<String>) -> Self {
Self::new(ProcessorErrorKind::Library, message)
}
/// processor 产物没落盘。
#[must_use]
pub fn output_missing(message: impl Into<String>) -> Self {
Self::new(ProcessorErrorKind::Output, message)
}
/// 本地 IO 失败。
#[must_use]
pub fn io(message: impl Into<String>) -> Self {
Self::new(ProcessorErrorKind::Io, message)
}
/// 外调 java 非零退出:消息 = 说明 + 脱敏后的 stderr 尾部。
#[must_use]
pub fn failed(label: &str, exit: &str, stderr_tail: &str) -> Self {
let mut message = format!("processor {label} 执行失败(退出码 {exit})");
if !stderr_tail.trim().is_empty() {
message.push_str(";stderr 尾部:\n");
message.push_str(stderr_tail.trim_end());
}
let mut error = Self::new(ProcessorErrorKind::Failed, message);
error.stderr_tail = Some(stderr_tail.trim_end().to_string());
error
}
/// 补一句提示。
#[must_use]
pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
self.hint = Some(hint.into());
self
}
/// 大类。
#[must_use]
pub const fn kind(&self) -> ProcessorErrorKind {
self.kind
}
/// 人读说明。
#[must_use]
pub fn message(&self) -> &str {
&self.message
}
/// 下一步提示。
#[must_use]
pub fn hint(&self) -> Option<&str> {
self.hint.as_deref()
}
/// 脱敏后的 stderr 尾部(只有 `processor_failed` 会有)。
#[must_use]
pub fn stderr_tail(&self) -> Option<&str> {
self.stderr_tail.as_deref()
}
}
impl fmt::Display for ProcessorError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.message)
}
}
impl std::error::Error for ProcessorError {}
#[cfg(test)]
mod tests {
use super::{ProcessorError, ProcessorErrorKind};
#[test]
fn stable_codes_are_distinct_and_prefixed() {
let kinds = [
ProcessorErrorKind::Plan,
ProcessorErrorKind::Java,
ProcessorErrorKind::Library,
ProcessorErrorKind::Failed,
ProcessorErrorKind::Output,
ProcessorErrorKind::Io,
];
for kind in kinds {
assert!(kind.as_str().starts_with("processor_"), "{}", kind.as_str());
}
assert_eq!(ProcessorErrorKind::Failed.as_str(), "processor_failed");
}
#[test]
fn failed_carries_the_stderr_tail_in_message_and_accessor() {
let error = ProcessorError::failed("g:a:1", "1", "boom\nCaused by: x");
assert_eq!(error.kind(), ProcessorErrorKind::Failed);
assert!(error.message().contains("退出码 1"), "{}", error.message());
assert!(error.message().contains("Caused by: x"));
assert_eq!(error.stderr_tail(), Some("boom\nCaused by: x"));
}
#[test]
fn empty_stderr_keeps_the_message_clean() {
let error = ProcessorError::failed("g:a:1", "255", "");
assert!(!error.message().contains("stderr 尾部"));
assert_eq!(error.stderr_tail(), Some(""));
}
}
@@ -0,0 +1,363 @@
//! 逐个外调 java 执行 processor:`java -cp <依赖…>:<jar> <主类> <参数…>`。
//!
//! 依据 `DevDocs/analyse/05-modloader-modpack.md` §1.2(Forge 新式 installer 的 processors
//! 流水线)与 HMCL `ForgeNewInstallTask.ProcessorTask` 的实现,四条硬规矩:
//!
//! 1. **串行**:后面的 processor 吃前面的产物(拆包 → 映射 → 打补丁),并行必炸;
//! 2. **幂等**:每步先按 `outputs` 的 SHA-1 判定,全部命中就整步跳过(重跑 install 零成本);
//! 3. **主类来自 jar 的 MANIFEST**:官方 install profile 不写主类(少数 fork 给 `mainClass`);
//! 4. **失败要能定位**:非零退出带脱敏后的 stderr 尾部(路径/用户名不能泄漏到日志外)。
//!
//! # 关于产物 SHA-1 与 zlib 变体(HMCL 踩过的坑)
//!
//! processor 动态生成的 jar 是用**本机 JDK 的 zlib** 压缩的。zlib-ng 之类非标准实现会把
//! 同样的内容压出不同字节 → SHA-1 与 profile 里的期望值不符。HMCL 的处置是「哈希不符时
//! 退化成 ZIP 结构校验」。我们的处置更保守:**存在性强制、摘要不符只告警**,
//! 既不误杀真实装,也不假装校验通过。
use std::path::Path;
use std::process::Command;
use std::sync::Arc;
use std::time::Instant;
use serde_json::{json, Value};
use super::error::ProcessorError;
use super::jar::read_main_class;
use super::plan::{ProcessorPlan, ProcessorStep};
use super::prepare::prepare;
use super::text::{host_redactor, non_empty, tail};
use crate::download::verify;
use crate::download::HashAlgo;
use crate::game::java::{self, JavaRequirement, JavaRuntime};
use crate::launch::redact::Redactor;
/// 一步的结局。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StepStatus {
/// 产物已就绪(SHA-1 命中),整步跳过。
Skipped,
/// 真的起了一次 java。
Executed,
}
impl StepStatus {
/// 稳定字符串 id(`--json` 用)。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Skipped => "skipped",
Self::Executed => "executed",
}
}
}
/// 一步的执行结果。
#[derive(Debug, Clone)]
pub struct StepOutcome {
/// 序号(从 1 起)。
pub index: usize,
/// jar 的 maven 坐标。
pub label: String,
/// 结局。
pub status: StepStatus,
/// 产物个数。
pub outputs: usize,
/// 耗时(毫秒;跳过时为 0)。
pub elapsed_ms: u128,
/// stdout 尾巴(已脱敏,可能为空)。
pub stdout_tail: Option<String>,
/// stderr 尾巴(已脱敏,可能为空)。
pub stderr_tail: Option<String>,
}
impl StepOutcome {
/// JSON 视图。
#[must_use]
pub fn to_json(&self) -> Value {
json!({
"index": self.index,
"jar": self.label,
"status": self.status.as_str(),
"outputs": self.outputs,
"elapsed_ms": self.elapsed_ms,
"stderr_tail": self.stderr_tail,
})
}
}
/// 一次 processors 执行的汇总。
#[derive(Debug, Clone)]
pub struct ProcessorRun {
/// 实际用的 Java(含来源与主版本)。
pub java: JavaRuntime,
/// 逐步骤结果(顺序即执行顺序)。
pub steps: Vec<StepOutcome>,
/// 不致命的告警(摘要不符、没有 outputs 的步骤等)。
pub warnings: Vec<String>,
}
impl ProcessorRun {
/// 真正跑过的步数。
#[must_use]
pub fn executed(&self) -> usize {
self.steps
.iter()
.filter(|step| step.status == StepStatus::Executed)
.count()
}
/// 跳过的步数(幂等命中)。
#[must_use]
pub fn skipped(&self) -> usize {
self.steps
.iter()
.filter(|step| step.status == StepStatus::Skipped)
.count()
}
/// JSON 视图。
#[must_use]
pub fn to_json(&self) -> Value {
json!({
"java": {
"binary": self.java.binary.display().to_string(),
"source": self.java.origin.as_str(),
"major": self.java.major,
"version": self.java.version,
},
"executed": self.executed(),
"skipped": self.skipped(),
"steps": self.steps.iter().map(StepOutcome::to_json).collect::<Vec<_>>(),
"warnings": self.warnings,
})
}
}
/// 每步完成的回调(CLI 往 stderr 打实时进度用)。
pub type StepSink = Arc<dyn Fn(&StepOutcome) + Send + Sync>;
/// 按 M33 的解析链挑一个能跑 installer 的 Java:托管 > `JAVA_HOME` > `PATH` > 扫盘。
///
/// 与启动路径的差异只有一点:**扫盘只在前面都不满足时才做**(别为了一次安装白跑
/// N 次 `java -version`),这里复用 [`crate::game::java::pick`] 的同一套优先序。
#[must_use]
pub fn select_java(managed_root: &Path, requirement: &JavaRequirement) -> Option<JavaRuntime> {
let mut candidates = java::discover_managed(managed_root);
candidates.extend(java::system_candidates());
if let Some(runtime) = java::pick(&candidates, requirement) {
if usable(runtime) {
return Some(runtime.clone());
}
}
candidates.extend(java::standard_candidates());
let deduped = java::discover::dedupe(candidates);
java::pick(&deduped, requirement).cloned().filter(usable)
}
/// 候选是否真的能用:探测到主版本,或者那个文件确实在盘上。
fn usable(runtime: &JavaRuntime) -> bool {
runtime.major.is_some() || runtime.binary.is_file()
}
/// 顺序执行所有 processor。
///
/// 全部产物已就绪时走快路径:不复制 client jar、不解包 installer、不起任何进程。
///
/// # Errors
///
/// 临时目录/解包/复制失败(IO)、依赖缺失、`java -cp` 起不来或非零退出、
/// 产物没落盘时返回 [`ProcessorError`]。
pub fn run(
plan: &ProcessorPlan,
runtime: &JavaRuntime,
sink: Option<StepSink>,
) -> Result<ProcessorRun, ProcessorError> {
let redactor = host_redactor();
let mut warnings = Vec::new();
let mut outcomes = Vec::with_capacity(plan.steps.len());
let satisfied: Vec<bool> = plan
.steps
.iter()
.map(|step| outputs_satisfied(step, &mut warnings))
.collect();
if satisfied.iter().any(|done| !done) {
prepare(plan)?;
}
for (step, done) in plan.steps.iter().zip(satisfied) {
let outcome = if done {
StepOutcome {
index: step.index,
label: step.label.clone(),
status: StepStatus::Skipped,
outputs: step.outputs.len(),
elapsed_ms: 0,
stdout_tail: None,
stderr_tail: None,
}
} else {
run_step(plan, runtime, step, &redactor, &mut warnings)?
};
if let Some(sink) = &sink {
sink(&outcome);
}
outcomes.push(outcome);
}
Ok(ProcessorRun {
java: runtime.clone(),
steps: outcomes,
warnings,
})
}
/// 起一次 java,跑完校验产物。
fn run_step(
plan: &ProcessorPlan,
runtime: &JavaRuntime,
step: &ProcessorStep,
redactor: &Redactor,
warnings: &mut Vec<String>,
) -> Result<StepOutcome, ProcessorError> {
ensure_dependencies(step)?;
let main_class = match &step.main_class {
Some(main_class) => main_class.clone(),
None => read_main_class(&step.jar)?,
};
let classpath = step.classpath_join()?;
let started = Instant::now();
let output = Command::new(&runtime.binary)
.arg("-cp")
.arg(&classpath)
.arg(&main_class)
.args(&step.args)
.current_dir(&plan.scratch)
.output()
.map_err(|error| {
ProcessorError::io(format!(
"起 java 失败({}):{error}",
runtime.binary.display()
))
.with_hint(
"确认 `ull java list` 里的路径可用,或用 `ull java install <major>` 装一个托管 JDK",
)
})?;
let elapsed_ms = started.elapsed().as_millis();
let stdout = redactor
.scrub(&String::from_utf8_lossy(&output.stdout))
.into_owned();
let stderr = redactor
.scrub(&String::from_utf8_lossy(&output.stderr))
.into_owned();
if !output.status.success() {
let exit = output
.status
.code()
.map_or_else(|| "signal".to_string(), |code| code.to_string());
return Err(
ProcessorError::failed(&step.label, &exit, &tail(&stderr)).with_hint(
"installer 的 processor 是官方产物,失败多半是 Java 版本/磁盘权限/网络(部分步骤要联网取映射)",
),
);
}
verify_outputs(step, warnings)?;
Ok(StepOutcome {
index: step.index,
label: step.label.clone(),
status: StepStatus::Executed,
outputs: step.outputs.len(),
elapsed_ms,
stdout_tail: non_empty(tail(&stdout)),
stderr_tail: non_empty(tail(&stderr)),
})
}
/// 依赖 jar 是否都在盘上(缺了直接报错:起了 java 也只会得到 `NoClassDefFoundError`)。
fn ensure_dependencies(step: &ProcessorStep) -> Result<(), ProcessorError> {
if !step.jar.is_file() {
return Err(ProcessorError::library_missing(format!(
"processor {} 的可执行 jar 不在盘上:{}",
step.label,
step.jar.display()
)));
}
let missing: Vec<String> = step
.classpath
.iter()
.filter(|path| !path.is_file())
.map(|path| path.display().to_string())
.collect();
if missing.is_empty() {
return Ok(());
}
Err(ProcessorError::library_missing(format!(
"processor {} 有 {} 个依赖 jar 不在盘上:{}",
step.label,
missing.len(),
missing.join(";")
))
.with_hint("重跑一次 `ull install` 会补齐依赖(下载走镜像/健康度引擎)"))
}
/// 产物存在性(硬)与摘要(软)校验。
fn verify_outputs(step: &ProcessorStep, warnings: &mut Vec<String>) -> Result<(), ProcessorError> {
let missing: Vec<String> = step
.outputs
.iter()
.filter(|output| !output.path.is_file())
.map(|output| output.path.display().to_string())
.collect();
if !missing.is_empty() {
return Err(ProcessorError::output_missing(format!(
"processor {} 跑完了但产物没落盘:{}",
step.label,
missing.join(";")
))
.with_hint("磁盘权限/杀软拦截是常见原因;也可以删掉 versions/ 下这版后重装"));
}
for output in &step.outputs {
let Some(expected) = &output.sha1 else {
continue;
};
let Ok(actual) = verify::digest_file(&output.path, HashAlgo::Sha1) else {
continue;
};
if !verify::matches(expected, &actual) {
warnings.push(format!(
"processor {} 的产物摘要与 profile 不符(期望 {expected} 实际 {actual}):非标准 zlib 实现会压出不同字节,按可用处理",
step.label
));
}
}
Ok(())
}
/// 产物是否都已就绪(幂等判据:存在 + 摘要命中;profile 没给摘要就只认存在)。
fn outputs_satisfied(step: &ProcessorStep, warnings: &mut Vec<String>) -> bool {
if step.outputs.is_empty() {
// 没有产物声明 → 无法判断是否跑过,只能每次都跑(HMCL 同样处理)。
return false;
}
for output in &step.outputs {
if !output.path.is_file() {
return false;
}
let Some(expected) = &output.sha1 else {
continue;
};
let Ok(actual) = verify::digest_file(&output.path, HashAlgo::Sha1) else {
return false;
};
if !verify::matches(expected, &actual) {
warnings.push(format!(
"{} 的摘要与 profile 不符,重新执行该 processor",
output.path.display()
));
return false;
}
}
true
}
@@ -0,0 +1,83 @@
//! 从 processor jar 的 `MANIFEST.MF` 里取 `Main-Class`。
//!
//! 官方 install profile **不写主类**(`processors[].jar` 只给坐标),主类在 jar 的清单里;
//! 少数 fork 会额外给 `mainClass`(那条路走 [`super::plan::ProcessorStep::main_class`],
//! 不到这里)。清单按 RFC 822 折行:续行以**单个空格**开头。
use std::path::Path;
use super::error::ProcessorError;
/// 读 jar 的 `Main-Class`。
///
/// # Errors
///
/// jar 打不开/不是 zip(IO/规划错误)、没有 `META-INF/MANIFEST.MF` 或清单里没有
/// `Main-Class`(规划错误)。
pub(super) fn read_main_class(jar: &Path) -> Result<String, ProcessorError> {
use std::io::Read;
let file = std::fs::File::open(jar).map_err(|error| {
ProcessorError::io(format!(
"打开 processor jar 失败({}):{error}",
jar.display()
))
})?;
let mut archive = zip::ZipArchive::new(file).map_err(|error| {
ProcessorError::plan(format!("{} 不是合法 jar:{error}", jar.display()))
})?;
let mut manifest = String::new();
{
let mut entry = archive.by_name("META-INF/MANIFEST.MF").map_err(|error| {
ProcessorError::plan(format!(
"{} 里没有 META-INF/MANIFEST.MF(拿不到主类):{error}",
jar.display()
))
})?;
entry.read_to_string(&mut manifest).map_err(|error| {
ProcessorError::io(format!("读 {} 的 MANIFEST 失败:{error}", jar.display()))
})?;
}
parse_main_class(&manifest).ok_or_else(|| {
ProcessorError::plan(format!(
"{} 的 MANIFEST 里没有 Main-Class(install profile 也没给 mainClass)",
jar.display()
))
})
}
/// 从 MANIFEST 文本里取 `Main-Class`(续行以单个空格开头)。
fn parse_main_class(manifest: &str) -> Option<String> {
let mut lines = manifest.lines().peekable();
while let Some(line) = lines.next() {
let Some(rest) = line.strip_prefix("Main-Class:") else {
continue;
};
let mut value = rest.trim().to_string();
while let Some(next) = lines.peek() {
let Some(continuation) = next.strip_prefix(' ') else {
break;
};
value.push_str(continuation.trim());
lines.next();
}
return (!value.is_empty()).then_some(value);
}
None
}
#[cfg(test)]
mod tests {
use super::parse_main_class;
#[test]
fn parses_main_class_with_continuation_lines() {
let manifest = "Manifest-Version: 1.0\r\nMain-Class: com.example.\r\n Tool\r\n\r\n";
assert_eq!(
parse_main_class(manifest).as_deref(),
Some("com.example.Tool")
);
assert!(parse_main_class("Manifest-Version: 1.0\n").is_none());
assert!(parse_main_class("Main-Class: \n").is_none());
}
}
@@ -0,0 +1,56 @@
//! Installer Processors 执行器(M35):把 install profile 的 `processors[]` 跑起来。
//!
//! 设计输入:`DevDocs/analyse/05-modloader-modpack.md` §1.2(Forge 新式 installer 的
//! processor 流水线)与 HMCL `ForgeNewInstallTask` / `ProcessorTask` 的实现。
//!
//! # 覆盖范围
//!
//! | 版本段 | 有无客户端 processor | 本模块 |
//! |--------|----------------------|--------|
//! | Forge 1.13 ~ 1.20.x(spec 1) | 有(拆包/映射/打补丁) | ✅ 真跑 |
//! | NeoForge 1.20.5+(26.x 只剩 1 个 `PROCESS_MINECRAFT_JAR`) | 有 | ✅ 真跑 |
//! | Forge spec 0(1.12.2 重建版)/ Fabric / Quilt | 无 | 不需要(走 M32 的 json 路线) |
//!
//! # 流水线
//!
//! ```text
//! plan install profile 的 data 表 → 变量表({MINECRAFT_JAR}/{SIDE}/[坐标]/'字面量')
//! → 每步 java -cp 参数 + 产物/摘要 + processor 依赖库待下清单
//! download 依赖库(走 M27 引擎:镜像/健康度/退避/校验)
//! exec 串行外调 java(主类取自 jar 的 MANIFEST)→ 产物存在性(硬)+ 摘要(软)
//! ```
//!
//! # 三条边界
//!
//! 1. **不写版本 JSON**:那是 M27 `execute` 的事,且必须等 processors 全部成功
//! (版本清单存在 = 这一版真的装完了,半成品不许留);
//! 2. **不改共享层**:原版 client jar 先复制到临时目录再交给 processor,
//! processor 只会改临时副本;
//! 3. **不猜**:`data` 表里缺变量、processor 缺 `jar`、产物没落盘,一律明确报错,
//! 不拿半截参数去试(那种失败的 stderr 更难查)。
//!
//! # 幂等
//!
//! 两级:①每步按 `outputs` 的 SHA-1 命中整步跳过(重跑零成本、不碰文件系统);
//! ②调用方以「版本 JSON 已落盘」为粗粒度判据整段跳过。
pub mod download;
pub mod error;
pub mod exec;
pub mod jar;
pub mod plan;
pub mod prepare;
pub mod spec;
pub mod text;
pub mod vars;
#[cfg(test)]
mod tests;
pub use download::download_libraries;
pub use error::{ProcessorError, ProcessorErrorKind};
pub use exec::{run, select_java, ProcessorRun, StepOutcome, StepSink, StepStatus};
pub use plan::{ProcessorPlan, ProcessorPlanInput, ProcessorStep, StepOutput};
pub use spec::{Datum, Processor};
pub use text::{host_redactor, tail};
pub use vars::{EmbeddedFile, Variables, VariablesInput, MINECRAFT_JAR_NAME};
@@ -0,0 +1,366 @@
//! processor 执行计划:`data` 表展开 → 每步的 `java -cp …` 命令行 → 依赖库待下载清单。
//!
//! 纯计算(不下载、不起进程),因此 `--dry-run` 能直接把「将执行的 processor 清单 +
//! 所需 Java + 依赖库」打出来。唯一读盘的动作是 [`crate::game::java::requirement_for_version`]
//! 读原版版本 JSON 判 Java 要求——那是诊断信息,读不到就按静态表兜底。
//!
//! # 为什么依赖库与启动 classpath 要分开
//!
//! install profile 的 `libraries` 是 **processor 依赖**(installertools / jarsplitter /
//! srgutils…),它们不进游戏 classpath。M32 只把数量记进告警,M35 起真正下载它们
//! (否则 `java -cp` 一排文件都不在盘上),但仍然**绝不**并进 loader 版本 JSON。
use std::path::{Path, PathBuf};
use serde_json::{json, Value};
use super::error::ProcessorError;
use super::spec::Processor;
use super::vars::{absolute, Variables, VariablesInput, MINECRAFT_JAR_NAME};
use crate::addon::loader::forge::InstallProfile;
use crate::addon::loader::{maven, LoaderKind};
use crate::download::ResourceKind;
use crate::game::arguments::Environment;
use crate::game::install::libraries;
use crate::game::install::{FileRole, PlanFile};
use crate::game::java::{requirement_for_version, JavaRequirement};
use crate::game::layout::GameLayout;
use crate::game::library::MavenCoordinate;
/// 一个 processor 的某个产物。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StepOutput {
/// 产物绝对路径。
pub path: PathBuf,
/// 期望 SHA-1(profile 没给或给的不是摘要时为 `None`,只能按存在性判定)。
pub sha1: Option<String>,
}
/// 一个待执行的 processor(已展开参数,不再依赖 install profile)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProcessorStep {
/// 序号(从 1 起,顺序即执行顺序)。
pub index: usize,
/// 人读标识(jar 的 maven 坐标)。
pub label: String,
/// 可执行 jar 的磁盘路径。
pub jar: PathBuf,
/// 主类(profile 没写就留 `None`,执行期读 jar 的 MANIFEST)。
pub main_class: Option<String>,
/// `-cp` 的依赖 jar(不含 `jar` 自己)。
pub classpath: Vec<PathBuf>,
/// 已展开的参数。
pub args: Vec<String>,
/// 产物与期望摘要。
pub outputs: Vec<StepOutput>,
}
impl ProcessorStep {
/// 一步的 `-cp` 全串(依赖在前、可执行 jar 在后,与 HMCL 一致)。
///
/// # Errors
///
/// 路径里含分隔符时返回 IO 错误(Windows 的 `;` 不可能出现在合法路径里,
/// 真出现说明路径是拼错的)。
pub fn classpath_join(&self) -> Result<String, ProcessorError> {
let paths = self
.classpath
.iter()
.chain(std::iter::once(&self.jar))
.map(|path| path.as_path());
std::env::join_paths(paths)
.map(|joined| joined.to_string_lossy().into_owned())
.map_err(|error| ProcessorError::io(format!("classpath 拼不起来:{error}")))
}
}
/// [`ProcessorPlan::build`] 的输入。
pub struct ProcessorPlanInput<'a> {
/// 加载器种类(只影响补库时的 maven 基址兜底)。
pub kind: LoaderKind,
/// 目标原版版本。
pub mc_version: &'a str,
/// loader 版本 id(依赖库 `hint=local` 的落点)。
pub version_id: &'a str,
/// installer 里读出来的 install profile。
pub profile: &'a InstallProfile,
/// 游戏目录布局。
pub layout: &'a GameLayout,
/// installer jar 的磁盘路径。
pub installer: &'a Path,
/// 临时目录(解包/`MINECRAFT_JAR` 副本都落这里)。
pub scratch: &'a Path,
/// 规则求值环境。
pub environment: &'a Environment,
}
/// processor 执行计划。
#[derive(Debug, Clone)]
pub struct ProcessorPlan {
/// loader 版本 id。
pub version_id: String,
/// installer jar 路径。
pub installer: PathBuf,
/// 临时目录。
pub scratch: PathBuf,
/// 原版 client jar(复制源)。
pub minecraft_jar: PathBuf,
/// 交给 processor 的 client jar 副本(`{MINECRAFT_JAR}`)。
pub minecraft_jar_copy: PathBuf,
/// 展开后的变量表。
pub variables: Variables,
/// 按顺序待执行的 processor。
pub steps: Vec<ProcessorStep>,
/// processor 依赖库(走 M27 引擎下载)。
pub libraries: Vec<PlanFile>,
/// 跑 installer 需要的 Java 要求。
pub java_requirement: JavaRequirement,
/// 规划期告警(不致命)。
pub warnings: Vec<String>,
}
impl ProcessorPlan {
/// 组计划。
///
/// # Errors
///
/// `data` 表变量缺定义、构件坐标不合法、processor 缺 `jar` 字段时返回规划错误。
pub fn build(input: ProcessorPlanInput<'_>) -> Result<Self, ProcessorError> {
let scratch = absolute(input.scratch);
let variables = Variables::build(VariablesInput {
data: &input.profile.data,
layout: input.layout,
mc_version: input.mc_version,
installer: input.installer,
scratch: &scratch,
})?;
let mut warnings = Vec::new();
let mut steps = Vec::new();
let mut needed: Vec<(String, PathBuf)> = Vec::new();
for processor in input
.profile
.processors
.iter()
.filter(|processor| processor.applies_to_client())
{
let coordinate = processor.jar_coordinate().ok_or_else(|| {
ProcessorError::plan("install profile 里有 processor 缺 `jar` 字段")
})?;
let jar = library_path(input.layout, coordinate)?;
needed.push((coordinate.to_string(), jar.clone()));
let mut classpath = Vec::with_capacity(processor.classpath.len());
for entry in &processor.classpath {
let path = library_path(input.layout, entry)?;
needed.push((entry.trim().to_string(), path.clone()));
classpath.push(path);
}
let mut args = Vec::with_capacity(processor.args.len());
for arg in &processor.args {
args.push(variables.expand(arg)?);
}
let outputs = step_outputs(&variables, processor, coordinate, &scratch, &mut warnings)?;
steps.push(ProcessorStep {
index: steps.len() + 1,
label: coordinate.to_string(),
jar,
main_class: processor.main_class().map(str::to_string),
classpath,
args,
outputs,
});
}
let (mut libraries, library_warnings) = libraries::plan(
input.layout,
input.version_id,
&input.profile.libraries,
input.environment,
);
warnings.extend(library_warnings);
let base = library_base(input.kind, input.profile);
for (coordinate, dest) in &needed {
if libraries.iter().any(|file| &file.dest == dest) {
continue;
}
warnings.push(format!(
"processor 依赖 {coordinate} 不在 install profile 的 libraries 节里,按 maven 基址补一条"
));
let Ok(parsed) = MavenCoordinate::parse(coordinate) else {
continue;
};
libraries.push(PlanFile {
role: FileRole::Library,
name: coordinate.clone(),
url: join_url(&base, &parsed.relative_path()),
sha1: None,
size: None,
dest: dest.clone(),
resource: ResourceKind::Library,
});
}
// NeoForge 的 install profile 会把同一条依赖**声明多次**(实测 21.1.80:
// cli-utils / srgutils / jopt-simple / gson 各 2~3 次)。不下重:同一落点被下两次
// 会在同一个 `.part` 中间文件上互相抢(真机现场:原子落盘 os error 2)。
let (libraries, duplicated) = dedupe_by_dest(libraries);
if duplicated > 0 {
warnings.push(format!(
"install profile 重复声明了 {duplicated} 个 processor 依赖,已按落点去重"
));
}
let java_requirement = requirement_for_version(input.layout, input.mc_version);
let minecraft_jar_copy = variables
.get("MINECRAFT_JAR")
.map_or_else(|| scratch.join(MINECRAFT_JAR_NAME), PathBuf::from);
Ok(Self {
version_id: input.version_id.to_string(),
installer: absolute(input.installer),
scratch,
minecraft_jar: input.layout.version_jar(input.mc_version),
minecraft_jar_copy,
variables,
steps,
libraries,
java_requirement,
warnings,
})
}
/// 是否一个 processor 都没有。
#[must_use]
pub fn is_empty(&self) -> bool {
self.steps.is_empty()
}
/// 汇总行数(CLI 人读输出用)。
#[must_use]
pub fn step_count(&self) -> usize {
self.steps.len()
}
/// `--dry-run` / 报告里的 processors 载荷。
#[must_use]
pub fn to_json(&self) -> Value {
json!({
"count": self.steps.len(),
"installer": self.installer.display().to_string(),
"scratch": self.scratch.display().to_string(),
"minecraft_jar": self.minecraft_jar.display().to_string(),
"libraries": self.libraries.len(),
"library_files": self.libraries.iter().map(|file| json!({
"name": file.name,
"url": file.url,
"dest": file.dest.display().to_string(),
"sha1": file.sha1,
"size": file.size,
})).collect::<Vec<_>>(),
"variables": self.variables.len(),
"embedded_files": self.variables.embedded().len(),
"java_requirement": {
"minimum": self.java_requirement.minimum,
"maximum": self.java_requirement.maximum,
"describe": self.java_requirement.describe(),
"source": self.java_requirement.source.as_str(),
},
"steps": self.steps.iter().map(step_json).collect::<Vec<_>>(),
"warnings": self.warnings,
})
}
}
/// 一条 processor 的 JSON 视图。
fn step_json(step: &ProcessorStep) -> Value {
json!({
"index": step.index,
"jar": step.label,
"jar_path": step.jar.display().to_string(),
"main_class": step.main_class,
"classpath": step.classpath.len(),
"args": step.args,
"outputs": step.outputs.iter().map(|output| json!({
"path": output.path.display().to_string(),
"sha1": output.sha1,
})).collect::<Vec<_>>(),
})
}
/// 展开一步的 `outputs` 表(路径 + 期望摘要)。
fn step_outputs(
variables: &Variables,
processor: &Processor,
coordinate: &str,
scratch: &Path,
warnings: &mut Vec<String>,
) -> Result<Vec<StepOutput>, ProcessorError> {
let mut outputs = Vec::with_capacity(processor.outputs.len());
for (raw_path, raw_sha1) in &processor.outputs {
let expanded = variables.expand(raw_path)?;
let path = PathBuf::from(&expanded);
let path = if path.is_absolute() {
path
} else {
scratch.join(path)
};
let expanded_sha1 = variables.expand(raw_sha1).unwrap_or_default();
let sha1 = is_sha1(&expanded_sha1).then(|| expanded_sha1.to_ascii_lowercase());
if sha1.is_none() {
warnings.push(format!(
"processor {coordinate} 的输出 {} 没给 SHA-1,只能按存在性判定",
path.display()
));
}
outputs.push(StepOutput { path, sha1 });
}
Ok(outputs)
}
/// 40 位十六进制(SHA-1 的样子)。
fn is_sha1(value: &str) -> bool {
value.len() == 40 && value.chars().all(|ch| ch.is_ascii_hexdigit())
}
/// 坐标 → `libraries/` 下的路径。
fn library_path(layout: &GameLayout, raw: &str) -> Result<PathBuf, ProcessorError> {
let coordinate = MavenCoordinate::parse(raw.trim()).map_err(|error| {
ProcessorError::plan(format!("processor 依赖坐标不合法:`{raw}`({error})"))
})?;
Ok(layout.libraries_dir().join(coordinate.relative_path()))
}
/// 补库时用的 maven 基址:install profile 自己声明的 `url` 优先,否则按加载器兜底。
fn library_base(kind: LoaderKind, profile: &InstallProfile) -> String {
profile
.libraries
.iter()
.filter_map(|library| library.url.as_deref())
.map(str::trim)
.find(|url| !url.is_empty())
.map_or_else(|| maven::base(kind).to_string(), str::to_string)
}
/// `基址 + 相对路径`(保证中间恰好一个 `/`)。
fn join_url(base: &str, relative: &Path) -> String {
let path = relative.to_string_lossy().replace('\\', "/");
if base.ends_with('/') {
format!("{base}{path}")
} else {
format!("{base}/{path}")
}
}
/// 按落点去重(保留首次出现的条目),返回(去重后的清单,丢掉几条)。
fn dedupe_by_dest(files: Vec<PlanFile>) -> (Vec<PlanFile>, usize) {
let total = files.len();
let mut seen: Vec<PathBuf> = Vec::with_capacity(total);
let mut kept: Vec<PlanFile> = Vec::with_capacity(total);
for file in files {
if seen.contains(&file.dest) {
continue;
}
seen.push(file.dest.clone());
kept.push(file);
}
let dropped = total - kept.len();
(kept, dropped)
}
@@ -0,0 +1,201 @@
//! 临时目录准备:建目录、解包 installer 内的文件、复制原版 client jar。
//!
//! 三条都是「起 java 之前的脏活」,失败一律归 `processor_io`/`processor_plan`
//! (区别于 processor 自己跑挂的 `processor_failed`)。
use super::error::ProcessorError;
use super::plan::ProcessorPlan;
/// 单条目的**预分配**上限(64 MiB):installer jar 下载后只校验「是合法 zip」,
/// 没有校验和(`meta.rs` 的 `fetch_installer`),因此 zip header 里声明的解压尺寸
/// 是不可信输入——损坏或镜像投毒的 installer 可以声明天文数字,让一次小文件解包直接
/// 申请多 GB 内存。`Vec` 仍按实际读入增长,合法条目行为不变。
const PREALLOC_MAX: u64 = 64 << 20;
/// 单条目的**实际字节**上限(256 MiB):预分配封顶挡不住「声明小、解压极大」的
/// zip bomb,所以读入过程本身也要有硬顶。超过就明确报错(不静默截断)。
const MAX_EMBEDDED_BYTES: u64 = 256 << 20;
/// 执行前的准备:建临时目录、解包 installer 内的文件、复制原版 client jar。
pub(super) fn prepare(plan: &ProcessorPlan) -> Result<(), ProcessorError> {
std::fs::create_dir_all(&plan.scratch).map_err(|error| {
ProcessorError::io(format!(
"建临时目录失败({}):{error}",
plan.scratch.display()
))
})?;
extract_embedded(plan)?;
copy_minecraft_jar(plan)
}
/// 把 install profile 声明要解包的文件从 installer zip 里抽到临时目录。
fn extract_embedded(plan: &ProcessorPlan) -> Result<(), ProcessorError> {
let embedded = plan.variables.embedded();
if embedded.is_empty() {
return Ok(());
}
use std::io::Read;
let installer = &plan.installer;
let file = std::fs::File::open(installer).map_err(|error| {
ProcessorError::io(format!(
"打开 installer 失败({}):{error}",
installer.display()
))
})?;
let mut archive = zip::ZipArchive::new(file).map_err(|error| {
ProcessorError::plan(format!("{} 不是合法 zip:{error}", installer.display()))
})?;
for item in embedded {
let mut entry = archive.by_name(&item.entry).map_err(|error| {
ProcessorError::plan(format!(
"installer 里没有 {}(data 表声明要解包):{error}",
item.entry
))
})?;
let mut bytes = Vec::with_capacity(prealloc_hint(entry.size()));
let read = (&mut entry)
.take(MAX_EMBEDDED_BYTES + 1)
.read_to_end(&mut bytes)
.map_err(|error| {
ProcessorError::io(format!("读 installer 条目 {} 失败:{error}", item.entry))
})?;
if read as u64 > MAX_EMBEDDED_BYTES {
return Err(ProcessorError::plan(format!(
"installer 条目 {} 解压后超过 {} MiB,拒绝继续(installer 可能损坏或被投毒)",
item.entry,
MAX_EMBEDDED_BYTES >> 20
)));
}
crate::platform::atomic::write_atomic(&item.dest, &bytes).map_err(|error| {
ProcessorError::io(format!(
"写临时文件失败({}):{error}",
item.dest.display()
))
})?;
}
Ok(())
}
/// 预分配提示:**不信任** header 声明的尺寸,封顶 [`PREALLOC_MAX`]。
fn prealloc_hint(declared: u64) -> usize {
declared.min(PREALLOC_MAX) as usize
}
/// 复制原版 client jar 到临时目录(processor 会改写输入,不能让它碰共享层)。
fn copy_minecraft_jar(plan: &ProcessorPlan) -> Result<(), ProcessorError> {
let source = &plan.minecraft_jar;
let target = &plan.minecraft_jar_copy;
let fresh = match (std::fs::metadata(source), std::fs::metadata(target)) {
(Ok(left), Ok(right)) => left.len() != right.len(),
(Ok(_), Err(_)) => true,
(Err(error), _) => {
return Err(ProcessorError::io(format!(
"原版 client jar 不在盘上({}):{error}",
source.display()
))
.with_hint("先装原版:`ull install <version>`"))
}
};
if !fresh {
return Ok(());
}
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent).map_err(|error| {
ProcessorError::io(format!("建目录失败({}):{error}", parent.display()))
})?;
}
std::fs::copy(source, target).map_err(|error| {
ProcessorError::io(format!(
"复制 {} → {} 失败:{error}",
source.display(),
target.display()
))
})?;
Ok(())
}
#[cfg(test)]
mod tests {
use std::path::Path;
use super::{prealloc_hint, prepare, PREALLOC_MAX};
use crate::addon::loader::forge::InstallProfile;
use crate::addon::loader::processors::{ProcessorPlan, ProcessorPlanInput};
use crate::addon::loader::LoaderKind;
use crate::game::arguments::Environment;
use crate::game::layout::GameLayout;
/// 一步 processor + 一个要从 installer 里解包的 data 值。
const PROFILE: &str = r#"{
"spec": 1, "minecraft": "1.20.1", "json": "/version.json",
"processors": [{"jar": "net.minecraftforge:binarypatcher:1.1.1", "args": ["--apply", "{BINPATCH}"]}],
"libraries": [],
"data": {"BINPATCH": {"client": "/data/client.lzma"}}
}"#;
#[test]
fn preallocation_is_capped_so_a_forged_header_cannot_oom() {
// installer jar 没有校验和:header 可以声称 16 EiB 的解压尺寸。
assert_eq!(prealloc_hint(u64::MAX), PREALLOC_MAX as usize);
assert_eq!(prealloc_hint(1 << 40), PREALLOC_MAX as usize);
// 合法条目照旧按实际尺寸预分配(封顶不影响常见大小)。
assert_eq!(prealloc_hint(0), 0);
assert_eq!(prealloc_hint(4096), 4096);
assert_eq!(prealloc_hint(PREALLOC_MAX), PREALLOC_MAX as usize);
assert_eq!(prealloc_hint(PREALLOC_MAX - 1), PREALLOC_MAX as usize - 1);
}
#[test]
fn prepare_extracts_the_declared_entry_and_copies_the_client_jar() {
let root = super::super::tests::temp_root("prepare-extract");
let layout = GameLayout::new(&root);
let profile: InstallProfile = serde_json::from_str(PROFILE).expect("夹具应可解析");
let installer = root.join("cache/installer.jar");
write_zip(&installer, "data/client.lzma", b"patch-bytes");
let vanilla = root.join("versions/1.20.1/1.20.1.jar");
std::fs::create_dir_all(vanilla.parent().expect("有父目录")).expect("建目录");
std::fs::write(&vanilla, b"client-jar").expect("写原版 jar");
let plan = ProcessorPlan::build(ProcessorPlanInput {
kind: LoaderKind::Forge,
mc_version: "1.20.1",
version_id: "1.20.1-forge-47.2.0",
profile: &profile,
layout: &layout,
installer: &installer,
scratch: &root.join("scratch"),
environment: &Environment::host(),
})
.expect("计划应能组出来");
assert_eq!(plan.variables.embedded().len(), 1);
prepare(&plan).expect("准备应成功");
let dest = &plan.variables.embedded()[0].dest;
assert_eq!(std::fs::read(dest).expect("读解包产物"), b"patch-bytes");
// processor 拿到的是副本,共享层的原版 jar 内容不受影响。
assert_eq!(
std::fs::read(&plan.minecraft_jar_copy).expect("读 client jar 副本"),
b"client-jar"
);
assert_ne!(plan.minecraft_jar_copy, plan.minecraft_jar);
// 幂等:重跑按大小判定,不重复复制、不报错。
prepare(&plan).expect("重跑应成功");
}
/// 写一个只含一条 Stored 条目的 zip(installer 夹具)。
fn write_zip(path: &Path, entry: &str, bytes: &[u8]) {
use std::io::Write;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("建目录");
}
let file = std::fs::File::create(path).expect("建 zip");
let mut writer = zip::ZipWriter::new(file);
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Stored);
writer.start_file(entry, options).expect("写条目");
writer.write_all(bytes).expect("写内容");
writer.finish().expect("收尾 zip");
}
}
@@ -0,0 +1,121 @@
//! install profile 的 `processors[]` 与 `data` 表:**纯解析,不动作**。
//!
//! 字段含义照 Forge installer spec(与 HMCL `ForgeNewInstallProfile.Processor/Datum` 一致):
//!
//! ```json
//! "processors": [{
//! "sides": ["client"], // 缺省 = 两侧都跑
//! "jar": "net.minecraftforge:installertools:1.3.0", // 可执行 jar(主类在 MANIFEST 里)
//! "classpath": ["net.minecraftforge:jarsplitter:1.1.4"], // 依赖 jar(jar 自己不在里面)
//! "args": ["--task", "MCP_DATA", "--output", "{MAPPINGS}"],
//! "outputs": {"{MAPPINGS}": "ab12…(期望 SHA-1)"}
//! }],
//! "data": {"MAPPINGS": {"client": "[de.oceanlabs.mcp:mcp_config:1.20.1@zip]", "server": "…"}}
//! ```
//!
//! 三条容易看错的点:
//!
//! 1. `data` 的每条是**按侧**的(`client` / `server`),client 侧只取 `.client`;
//! 2. 官方只写 `jar`,**不写主类**——主类在 jar 的 `META-INF/MANIFEST.MF`(`Main-Class`);
//! 少数 fork 会额外给 `mainClass`,所以这里留了口子;
//! 3. `classpath` **不含** `jar` 自己(Forge 的约定),拼 `-cp` 时要补上。
use std::collections::BTreeMap;
use serde::Deserialize;
/// install profile 里的一个 processor。
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
pub struct Processor {
/// 生效侧(`client` / `server`;空表示两侧都要)。
#[serde(default)]
pub sides: Vec<String>,
/// 可执行 jar 的 maven 坐标。
#[serde(default)]
pub jar: Option<String>,
/// 主类覆盖(官方 profile 不写,留给少数 fork)。
#[serde(default, rename = "mainClass")]
pub main_class: Option<String>,
/// 依赖 jar 的 maven 坐标(**不含** `jar` 自己)。
#[serde(default)]
pub classpath: Vec<String>,
/// 命令行参数(可含 `{VAR}` / `[坐标]` / `'字面量'` 三种占位)。
#[serde(default)]
pub args: Vec<String>,
/// 产物表:路径占位 → 期望 SHA-1(既有产物可用于幂等跳过)。
#[serde(default)]
pub outputs: BTreeMap<String, String>,
}
impl Processor {
/// 是否在 client 侧生效。
#[must_use]
pub fn applies_to_client(&self) -> bool {
self.sides.is_empty() || self.sides.iter().any(|side| side == "client")
}
/// `jar` 坐标(去掉首尾空白;缺失或空串返回 `None`)。
#[must_use]
pub fn jar_coordinate(&self) -> Option<&str> {
self.jar
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
}
/// 主类覆盖(缺失或空串返回 `None`)。
#[must_use]
pub fn main_class(&self) -> Option<&str> {
self.main_class
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
}
}
/// `data` 表的一条:按侧给值(client 侧只用 `.client`)。
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
pub struct Datum {
/// client 侧的值(`[坐标]` / `'字面量'` / installer 内的文件路径)。
#[serde(default)]
pub client: Option<String>,
/// server 侧的值(本期不跑 server 安装,只解析不动作)。
#[serde(default)]
pub server: Option<String>,
}
impl Datum {
/// client 侧的值(空串视为没有)。
#[must_use]
pub fn client_value(&self) -> Option<&str> {
self.client
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
}
}
#[cfg(test)]
mod tests {
use super::Processor;
#[test]
fn sides_empty_means_both_and_client_subset_is_recognised() {
let both: Processor = serde_json::from_str(r#"{"jar": "g:a:1"}"#).expect("应可解析");
assert!(both.applies_to_client());
let server: Processor =
serde_json::from_str(r#"{"sides": ["server"], "jar": "g:a:1"}"#).expect("应可解析");
assert!(!server.applies_to_client());
let client: Processor =
serde_json::from_str(r#"{"sides": ["client"], "jar": "g:a:1"}"#).expect("应可解析");
assert!(client.applies_to_client());
}
#[test]
fn blank_jar_and_main_class_are_treated_as_absent() {
let processor: Processor =
serde_json::from_str(r#"{"jar": " ", "mainClass": ""}"#).expect("应可解析");
assert!(processor.jar_coordinate().is_none());
assert!(processor.main_class().is_none());
}
}
@@ -0,0 +1,185 @@
//! 执行器:幂等跳过(不碰文件系统)、依赖缺失分流、起不来 java 的分类、脱敏器。
use std::path::{Path, PathBuf};
use crate::addon::loader::forge::InstallProfile;
use crate::addon::loader::processors::{host_redactor, run};
use crate::addon::loader::processors::{ProcessorErrorKind, ProcessorPlan, ProcessorPlanInput};
use crate::addon::loader::LoaderKind;
use crate::game::arguments::Environment;
use crate::game::java::{JavaOrigin, JavaRuntime};
use crate::game::layout::GameLayout;
use super::temp_root;
/// 一个最小 profile:一步 processor,产物是临时目录里的 `built.jar`(只按存在性判定)。
const MINIMAL_PROFILE: &str = r#"{
"spec": 1,
"minecraft": "1.20.1",
"json": "/version.json",
"processors": [{
"jar": "net.minecraftforge:installertools:1.3.0",
"classpath": ["net.minecraftforge:jarsplitter:1.1.4"],
"args": ["--task", "SPLIT"],
"outputs": {"{OUT}": "'not-a-sha1'"}
}],
"libraries": [],
"data": {"OUT": {"client": "'built.jar'"}}
}"#;
fn minimal_profile() -> InstallProfile {
serde_json::from_str(MINIMAL_PROFILE).expect("夹具应可解析")
}
/// 组装计划(依赖库不落盘:执行器不负责下载,缺失由用例自己摆)。
fn plan(root: &Path) -> ProcessorPlan {
let profile = minimal_profile();
let layout = GameLayout::new(root);
ProcessorPlan::build(ProcessorPlanInput {
kind: LoaderKind::Forge,
mc_version: "1.20.1",
version_id: "1.20.1-forge-47.2.0",
profile: &profile,
layout: &layout,
installer: &root.join("installer.jar"),
scratch: &root.join("scratch"),
environment: &Environment::host(),
})
.expect("计划应能组出来")
}
/// 一个「必然起不来」的 java:这些用例只验证分类与顺序,不验证 java 行为。
fn missing_java() -> JavaRuntime {
JavaRuntime::unknown(
PathBuf::from("/nonexistent/ull-test-java"),
JavaOrigin::Path,
)
}
fn touch(path: &Path) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("建目录");
}
std::fs::write(path, b"stub").expect("写夹具文件");
}
/// 写一个最小 jar(可选带 `Main-Class`),给"主类从 MANIFEST 读"的用例用。
fn write_jar(path: &Path, main_class: Option<&str>) {
use std::io::Write;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("建目录");
}
let file = std::fs::File::create(path).expect("建 jar");
let mut writer = zip::ZipWriter::new(file);
let options =
zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
if let Some(main_class) = main_class {
writer
.start_file("META-INF/MANIFEST.MF", options)
.expect("写 manifest 条目");
write!(
writer,
"Manifest-Version: 1.0\r\nMain-Class: {main_class}\r\n\r\n"
)
.expect("写 manifest");
}
writer.finish().expect("收尾 jar");
}
#[test]
fn ready_outputs_skip_the_whole_step_without_touching_the_disk() {
let root = temp_root("exec-idempotent");
let plan = plan(&root);
// 产物已就绪:原版 client jar 压根不存在也无所谓——幂等快路径不该去复制它。
touch(&plan.steps[0].outputs[0].path);
let run = run(&plan, &missing_java(), None).expect("产物齐备时不该跑任何进程");
assert_eq!(run.executed(), 0);
assert_eq!(run.skipped(), 1);
assert_eq!(run.steps[0].status.as_str(), "skipped");
assert_eq!(run.to_json()["steps"][0]["status"], "skipped");
// prepare 不该跑:原版 jar 压根不存在、client jar 副本没被复制。
assert!(!plan.minecraft_jar_copy.exists());
}
#[test]
fn missing_minecraft_jar_is_reported_before_any_java_runs() {
let root = temp_root("exec-no-vanilla");
let plan = plan(&root);
let error = run(&plan, &missing_java(), None).expect_err("缺原版 jar 必须报错");
assert_eq!(error.kind(), ProcessorErrorKind::Io);
assert!(
error.message().contains("原版 client jar"),
"{}",
error.message()
);
}
#[test]
fn missing_processor_jar_is_a_library_error_naming_the_file() {
let root = temp_root("exec-no-lib");
let plan = plan(&root);
touch(&plan.minecraft_jar);
let error = run(&plan, &missing_java(), None).expect_err("缺依赖必须报错");
assert_eq!(error.kind(), ProcessorErrorKind::Library);
assert!(
error.message().contains("installertools-1.3.0.jar"),
"{}",
error.message()
);
}
#[test]
fn a_java_that_cannot_start_is_an_io_error_with_a_hint() {
let root = temp_root("exec-no-java");
let plan = plan(&root);
touch(&plan.minecraft_jar);
// 可执行 jar 得是真的 zip + MANIFEST:主类只从那儿来(profile 没写 mainClass)。
write_jar(
&plan.steps[0].jar,
Some("net.minecraftforge.installertools.Main"),
);
touch(&plan.steps[0].classpath[0]);
let error = run(&plan, &missing_java(), None).expect_err("java 起不来必须报错");
assert_eq!(error.kind(), ProcessorErrorKind::Io);
assert!(
error.message().contains("起 java 失败"),
"{}",
error.message()
);
assert!(error.hint().is_some());
// 走到这一步说明临时目录、MC jar 副本都准备好了(顺序:prepare → 起进程)。
assert!(plan.minecraft_jar_copy.is_file());
}
#[test]
fn a_jar_without_a_main_class_is_a_plan_error() {
let root = temp_root("exec-no-main");
let plan = plan(&root);
touch(&plan.minecraft_jar);
write_jar(&plan.steps[0].jar, None);
touch(&plan.steps[0].classpath[0]);
let error = run(&plan, &missing_java(), None).expect_err("拿不到主类必须报错");
assert_eq!(error.kind(), ProcessorErrorKind::Plan);
assert!(error.message().contains("主类"), "{}", error.message());
}
#[test]
fn host_redactor_masks_the_user_home_directory() {
let redactor = host_redactor();
let home = std::env::var_os("USERPROFILE").or_else(|| std::env::var_os("HOME"));
let Some(home) = home else {
return; // 极简环境(无 HOME/USERPROFILE)时跳过
};
let text = format!("{}/game/libraries/x.jar", home.to_string_lossy());
let scrubbed = redactor.scrub(&text);
assert!(scrubbed.contains("<home>"), "{scrubbed}");
assert!(
!scrubbed.contains(&home.to_string_lossy().to_string()),
"{scrubbed}"
);
}
@@ -0,0 +1,71 @@
//! `addon::loader::processors` 的单元测试:占位替换 / 计划形状 / 执行与失败分流 / 幂等跳过。
//!
//! 刻意**全部离线**:installer 用夹具 JSON 描述,依赖库的"在不在盘上"由测试自己摆,
//! 真正外调 java 的用例只走「起不来」这一条确定性分支(假 java 路径不存在),
//! 真机执行由验收(Forge 1.20.1 / NeoForge 21.1.80)覆盖。
mod exec;
mod plan;
mod vars;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use crate::addon::loader::forge::InstallProfile;
/// 实测 Forge 1.20.1-47.2.0 的形状(裁剪到 3 个 processor:2 个 client 侧 + 1 个仅 server)。
pub(crate) const FORGE_PROFILE: &str = r#"{
"spec": 1,
"profile": "forge",
"version": "1.20.1-forge-47.2.0",
"minecraft": "1.20.1",
"json": "/version.json",
"processors": [
{
"sides": ["client"],
"jar": "net.minecraftforge:installertools:1.3.0",
"classpath": ["net.minecraftforge:jarsplitter:1.1.4"],
"args": ["--task", "MCP_DATA", "--input", "{MAPPINGS}", "--output", "{MC_SLIM}", "--side", "{SIDE}"],
"outputs": {"{MC_SLIM}": "aa11bb22cc33dd44ee55ff660011223344556677"}
},
{
"sides": ["server"],
"jar": "net.minecraftforge:installertools:1.3.0",
"classpath": [],
"args": ["--task", "SERVER"],
"outputs": {}
},
{
"jar": "net.minecraftforge:jarsplitter:1.1.4",
"classpath": ["net.minecraftforge:installertools:1.3.0"],
"args": ["--input", "{MINECRAFT_JAR}", "--output", "{MC_EXTRA}", "--singleThreaded"],
"outputs": {"{MC_EXTRA}": "'not-a-sha1'"}
}
],
"libraries": [
{"name": "net.minecraftforge:installertools:1.3.0", "url": "https://maven.minecraftforge.net/"},
{"name": "net.minecraftforge:jarsplitter:1.1.4", "url": "https://maven.minecraftforge.net/"}
],
"data": {
"MAPPINGS": {"client": "[de.oceanlabs.mcp:mcp_config:1.20.1-20230612.114412@zip]", "server": "[de.oceanlabs.mcp:mcp_config:1.20.1-20230612.114412@zip]"},
"MC_SLIM": {"client": "[net.minecraft:client:1.20.1-20230612.114412:slim]", "server": "[net.minecraft:server:1.20.1-20230612.114412:slim]"},
"MC_EXTRA": {"client": "[net.minecraft:client:1.20.1-20230612.114412:extra]", "server": "[net.minecraft:server:1.20.1-20230612.114412:extra]"},
"BINPATCH": {"client": "/data/client.lzma", "server": "/data/server.lzma"},
"SERVER_ONLY": {"server": "'server'"}
}
}"#;
/// 夹具 profile。
pub(crate) fn profile() -> InstallProfile {
serde_json::from_str(FORGE_PROFILE).expect("夹具 install profile 应可解析")
}
/// 一次性临时目录(用例结束不清理,落在系统 temp 下)。
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-processors-{}-{tag}-{unique}",
std::process::id()
))
}
@@ -0,0 +1,224 @@
//! 执行计划:client 侧过滤、参数展开、产物/摘要、依赖库补齐、Java 要求。
use crate::addon::loader::forge::InstallProfile;
use crate::addon::loader::processors::{ProcessorErrorKind, ProcessorPlan, ProcessorPlanInput};
use crate::addon::loader::LoaderKind;
use crate::game::arguments::Environment;
use crate::game::layout::GameLayout;
use super::{profile, temp_root};
fn plan_for(root: &std::path::Path, profile: &InstallProfile) -> ProcessorPlan {
let layout = GameLayout::new(root);
ProcessorPlan::build(ProcessorPlanInput {
kind: LoaderKind::Forge,
mc_version: "1.20.1",
version_id: "1.20.1-forge-47.2.0",
profile,
layout: &layout,
installer: &root.join("cache/forge-installer.jar"),
scratch: &root.join("cache/processors"),
environment: &Environment::host(),
})
.expect("计划应能组出来")
}
#[test]
fn only_client_side_processors_become_steps_in_declared_order() {
let root = temp_root("plan-order");
let plan = plan_for(&root, &profile());
assert_eq!(plan.step_count(), 2, "server-only 的 processor 必须被过滤");
assert_eq!(plan.steps[0].index, 1);
assert_eq!(
plan.steps[0].label,
"net.minecraftforge:installertools:1.3.0"
);
assert_eq!(plan.steps[1].index, 2);
assert_eq!(plan.steps[1].label, "net.minecraftforge:jarsplitter:1.1.4");
assert_eq!(plan.steps[1].classpath.len(), 1);
assert!(!plan.is_empty());
}
#[test]
fn args_are_expanded_with_paths_and_the_client_side() {
let root = temp_root("plan-args");
let plan = plan_for(&root, &profile());
let args = &plan.steps[0].args;
assert_eq!(args[0], "--task");
assert_eq!(args[1], "MCP_DATA");
assert!(
args[3].ends_with("mcp_config-1.20.1-20230612.114412.zip"),
"输入构件应展开成 libraries 路径:{}",
args[3]
);
assert!(
args[5].ends_with("client-1.20.1-20230612.114412-slim.jar"),
"产物路径应按 data 表展开:{}",
args[5]
);
assert_eq!(args[7], "client");
// 第二步用的是临时目录里的 client jar 副本,不是共享层的原版 jar。
assert_eq!(
plan.steps[1].args[1],
plan.minecraft_jar_copy.to_string_lossy()
);
assert!(plan
.minecraft_jar_copy
.to_string_lossy()
.ends_with("minecraft.jar"));
assert!(!plan.minecraft_jar_copy.starts_with(root.join("versions")));
}
#[test]
fn outputs_carry_paths_and_only_real_sha1s() {
let root = temp_root("plan-outputs");
let plan = plan_for(&root, &profile());
let first = &plan.steps[0].outputs;
assert_eq!(first.len(), 1);
assert_eq!(
first[0].sha1.as_deref(),
Some("aa11bb22cc33dd44ee55ff660011223344556677")
);
assert!(first[0].path.to_string_lossy().ends_with("-slim.jar"));
// 非 SHA-1 的 values 只算"存在性校验",并留一条告警。
assert!(plan.steps[1].outputs[0].sha1.is_none());
assert!(
plan.warnings
.iter()
.any(|warning| warning.contains("没给 SHA-1")),
"{:?}",
plan.warnings
);
}
#[test]
fn processor_dependencies_are_planned_once_even_when_libraries_omit_them() {
let root = temp_root("plan-libs");
let lean = serde_json::from_str(
r#"{"spec": 1, "minecraft": "1.20.1", "json": "/version.json",
"processors": [{"jar": "net.minecraftforge:installertools:1.3.0",
"classpath": ["net.minecraftforge:jarsplitter:1.1.4"],
"args": [], "outputs": {}}],
"libraries": [],
"data": {}}"#,
)
.expect("夹具应可解析");
let layout = GameLayout::new(&root);
let plan = ProcessorPlan::build(ProcessorPlanInput {
kind: LoaderKind::NeoForge,
mc_version: "1.20.1",
version_id: "1.20.1-neoforge-21.1.80",
profile: &lean,
layout: &layout,
installer: &root.join("installer.jar"),
scratch: &root.join("tmp"),
environment: &Environment::host(),
})
.expect("计划应能组出来");
assert_eq!(plan.libraries.len(), 2, "jar 与 classpath 各补一条");
for file in &plan.libraries {
assert!(
file.url.contains("neoforged") || file.url.contains("minecraftforge"),
"补库应落在加载器的 maven 上:{}",
file.url
);
assert!(file.dest.starts_with(root.join("libraries")));
}
assert!(plan
.warnings
.iter()
.any(|warning| warning.contains("不在 install profile 的 libraries 节里")));
}
#[test]
fn java_requirement_follows_the_target_minecraft_version() {
let root = temp_root("plan-java");
let plan = plan_for(&root, &profile());
assert_eq!(plan.java_requirement.minimum, 17);
assert_eq!(plan.java_requirement.maximum, None);
assert_eq!(plan.java_requirement.describe(), "Java 17+");
}
#[test]
fn json_view_lists_every_step_for_dry_run() {
let root = temp_root("plan-json");
let plan = plan_for(&root, &profile());
let json = plan.to_json();
assert_eq!(json["count"], 2);
assert_eq!(json["java_requirement"]["describe"], "Java 17+");
assert_eq!(
json["steps"][0]["jar"],
"net.minecraftforge:installertools:1.3.0"
);
assert_eq!(json["steps"][0]["classpath"], 1);
assert_eq!(
json["steps"][1]["outputs"][0]["sha1"],
serde_json::Value::Null
);
}
#[test]
fn duplicated_library_declarations_are_deduped_by_destination() {
// NeoForge 21.1.80 实测:install profile 把 cli-utils / srgutils 等声明了 2~3 次。
// 同一落点下两次会在同一个 `.part` 中间文件上互相抢(真机 os error 2),必须去重。
let root = temp_root("plan-dedup");
let duplicated: InstallProfile = serde_json::from_str(
r#"{"spec": 1, "minecraft": "1.20.1", "json": "/version.json",
"processors": [{"jar": "net.neoforged.installertools:cli-utils:2.1.2@jar"}],
"libraries": [
{"name": "net.neoforged.installertools:cli-utils:2.1.2@jar", "url": "https://maven.neoforged.net/releases/"},
{"name": "net.neoforged.installertools:cli-utils:2.1.2@jar", "url": "https://maven.neoforged.net/releases/"},
{"name": "net.neoforged.installertools:cli-utils:2.1.2@jar", "url": "https://maven.neoforged.net/releases/"}
],
"data": {}}"#,
)
.expect("夹具应可解析");
let layout = GameLayout::new(&root);
let plan = ProcessorPlan::build(ProcessorPlanInput {
kind: LoaderKind::NeoForge,
mc_version: "1.20.1",
version_id: "1.20.1-neoforge-21.1.80",
profile: &duplicated,
layout: &layout,
installer: &root.join("installer.jar"),
scratch: &root.join("tmp"),
environment: &Environment::host(),
})
.expect("计划应能组出来");
assert_eq!(plan.libraries.len(), 1, "同一落点只应留一条");
assert!(
plan.warnings.iter().any(|warning| warning.contains("去重")),
"{:?}",
plan.warnings
);
}
#[test]
fn processor_without_jar_is_a_plan_error() {
let root = temp_root("plan-nojar");
let broken: InstallProfile = serde_json::from_str(
r#"{"spec": 1, "minecraft": "1.20.1", "json": "/version.json",
"processors": [{"args": ["--task", "X"]}], "libraries": [], "data": {}}"#,
)
.expect("夹具应可解析");
let layout = GameLayout::new(&root);
let error = ProcessorPlan::build(ProcessorPlanInput {
kind: LoaderKind::Forge,
mc_version: "1.20.1",
version_id: "1.20.1-forge-47.2.0",
profile: &broken,
layout: &layout,
installer: &root.join("installer.jar"),
scratch: &root.join("tmp"),
environment: &Environment::host(),
})
.expect_err("缺 jar 必须报错");
assert_eq!(error.kind(), ProcessorErrorKind::Plan);
assert!(error.message().contains("jar"), "{}", error.message());
}
@@ -0,0 +1,181 @@
//! 变量表:三种字面量、标准变量、token 替换的边界。
use std::collections::BTreeMap;
use std::path::Path;
use crate::addon::loader::processors::spec::Datum;
use crate::addon::loader::processors::vars::{Variables, VariablesInput};
use crate::addon::loader::processors::ProcessorErrorKind;
use crate::game::layout::GameLayout;
use super::{profile, temp_root};
fn data(entries: &[(&str, &str)]) -> BTreeMap<String, Datum> {
entries
.iter()
.map(|(key, value)| {
(
(*key).to_string(),
Datum {
client: Some((*value).to_string()),
server: None,
},
)
})
.collect()
}
fn build<'a>(
layout: &'a GameLayout,
data: &'a BTreeMap<String, Datum>,
installer: &'a Path,
scratch: &'a Path,
) -> Variables {
Variables::build(VariablesInput {
data,
layout,
mc_version: "1.20.1",
installer,
scratch,
})
.expect("变量表应能组装")
}
#[test]
fn artifact_literals_resolve_into_the_libraries_directory() {
let root = temp_root("vars-artifact");
let layout = GameLayout::new(&root);
let installer = root.join("installer.jar");
let scratch = root.join("tmp");
let table = data(&[("MAPPINGS", "[de.oceanlabs.mcp:mcp_config:1.20.1@zip]")]);
let variables = build(&layout, &table, &installer, &scratch);
let value = variables.get("MAPPINGS").expect("应有 MAPPINGS");
assert!(
Path::new(value).ends_with("de/oceanlabs/mcp/mcp_config/1.20.1/mcp_config-1.20.1.zip"),
"{value}"
);
assert!(Path::new(value).is_absolute(), "{value}");
assert!(
Path::new(value).starts_with(layout.libraries_dir()),
"{value}"
);
// 官方 profile 也把坐标直接写在 `args` 里(`--input [de.oceanlabs.mcp:…@zip]`),
// 整段是坐标时同样要展开成路径。
let arg = variables
.expand("[de.oceanlabs.mcp:mcp_config:1.20.1@zip]")
.expect("整段坐标应能展开");
assert_eq!(arg, value);
}
#[test]
fn quoted_literals_lose_the_quotes() {
let root = temp_root("vars-literal");
let layout = GameLayout::new(&root);
let table = data(&[("SIDE_HINT", "'client'")]);
let variables = build(&layout, &table, &root.join("i.jar"), &root.join("tmp"));
assert_eq!(variables.get("SIDE_HINT"), Some("client"));
}
#[test]
fn plain_paths_are_extracted_from_the_installer_into_scratch() {
let root = temp_root("vars-embedded");
let layout = GameLayout::new(&root);
let installer = root.join("installer.jar");
let scratch = root.join("tmp");
let table = data(&[("BINPATCH", "/data/client.lzma")]);
let variables = build(&layout, &table, &installer, &scratch);
let value = variables.get("BINPATCH").expect("应有 BINPATCH");
assert!(value.ends_with("data-client.lzma"), "{value}");
assert!(
value.starts_with(&scratch.to_string_lossy().to_string()),
"{value}"
);
assert_eq!(variables.embedded().len(), 1);
assert_eq!(variables.embedded()[0].entry, "data/client.lzma");
}
#[test]
fn server_only_entries_are_ignored_for_client_installs() {
let root = temp_root("vars-server");
let layout = GameLayout::new(&root);
let variables = build(
&layout,
&profile().data,
&root.join("i.jar"),
&root.join("tmp"),
);
assert!(variables.get("SERVER_ONLY").is_none());
assert!(variables.get("MAPPINGS").is_some());
}
#[test]
fn standard_variables_are_absolute_and_side_is_client() {
let root = temp_root("vars-standard");
let layout = GameLayout::new(&root);
let installer = root.join("installer.jar");
let scratch = root.join("tmp");
let variables = build(&layout, &BTreeMap::new(), &installer, &scratch);
assert_eq!(variables.get("SIDE"), Some("client"));
assert_eq!(variables.get("MINECRAFT_VERSION"), Some("1.20.1"));
for key in ["MINECRAFT_JAR", "ROOT", "INSTALLER", "LIBRARY_DIR"] {
let value = variables.get(key).unwrap_or_else(|| panic!("缺 {key}"));
assert!(
Path::new(value).is_absolute(),
"{key} 不是绝对路径:{value}"
);
}
assert!(variables
.get("MINECRAFT_JAR")
.unwrap_or_default()
.ends_with("minecraft.jar"));
}
#[test]
fn token_replacement_handles_quotes_and_escapes() {
let root = temp_root("vars-expand");
let layout = GameLayout::new(&root);
let variables = build(
&layout,
&BTreeMap::new(),
&root.join("i.jar"),
&root.join("tmp"),
);
assert_eq!(
variables.expand("--side '{SIDE}'").expect("应能展开"),
"--side {SIDE}"
);
assert_eq!(variables.expand("a\\{b").expect("应能展开"), "a{b");
assert_eq!(
variables.expand("--side {SIDE}").expect("应能展开"),
"--side client"
);
}
#[test]
fn missing_or_unclosed_placeholders_are_plan_errors() {
let root = temp_root("vars-error");
let layout = GameLayout::new(&root);
let variables = build(
&layout,
&BTreeMap::new(),
&root.join("i.jar"),
&root.join("tmp"),
);
let missing = variables
.expand("--output {NOPE}")
.expect_err("缺变量必须报错");
assert_eq!(missing.kind(), ProcessorErrorKind::Plan);
assert!(missing.message().contains("NOPE"), "{}", missing.message());
let unclosed = variables.expand("{SIDE").expect_err("未闭合必须报错");
assert_eq!(unclosed.kind(), ProcessorErrorKind::Plan);
let bad_escape = variables.expand("abc\\").expect_err("悬空转义必须报错");
assert_eq!(bad_escape.kind(), ProcessorErrorKind::Plan);
}
@@ -0,0 +1,71 @@
//! 进程输出的尾部截取与脱敏。
//!
//! processor 的 stdout/stderr 是外部程序(而且部分步骤要联网)写的,内容不可控:
//! 可能带绝对路径、用户名、甚至一大段堆栈。进错误消息/`--json` 前必须做两件事:
//! **只留尾巴**(够定位、不灌满日志)与**抹掉主目录**(`C:\Users\<名>` → `<home>`)。
use std::path::PathBuf;
use crate::launch::redact::Redactor;
/// 尾巴保留的行数(够看异常栈头)。
const TAIL_LINES: usize = 12;
/// 尾巴保留的字符上限。
const TAIL_CHARS: usize = 1200;
/// 宿主脱敏器:把用户主目录换成 `<home>`。
///
/// 复用 [`crate::launch::redact::Redactor`](与 M15/M31 落地的同一套规则:token、
/// `<home>`、长不透明串、带点 token/JWT)。注册 `USERPROFILE`(Windows)/`HOME`(macOS、Linux)
/// 两个来源,因为 MSYS/CI 环境下两者可能只有一个在场。
#[must_use]
pub fn host_redactor() -> Redactor {
let mut redactor = Redactor::new();
for key in ["USERPROFILE", "HOME"] {
if let Some(value) = std::env::var_os(key) {
redactor = redactor.with_home(PathBuf::from(value));
}
}
redactor
}
/// 取文本尾部(末 [`TAIL_LINES`] 行,再截到 [`TAIL_CHARS`] 字符)。
#[must_use]
pub fn tail(text: &str) -> String {
let trimmed = text.trim_end();
if trimmed.is_empty() {
return String::new();
}
let lines: Vec<&str> = trimmed.lines().collect();
let start = lines.len().saturating_sub(TAIL_LINES);
let mut joined = lines[start..].join("\n");
let length = joined.chars().count();
if length > TAIL_CHARS {
joined = joined.chars().skip(length - TAIL_CHARS).collect();
joined.insert(0, '…');
}
joined
}
/// 空串 → `None`(JSON 里不吐空字段)。
pub(super) fn non_empty(value: String) -> Option<String> {
(!value.is_empty()).then_some(value)
}
#[cfg(test)]
mod tests {
use super::tail;
#[test]
fn tail_keeps_the_last_lines_and_truncates_long_ones() {
let text: String = (1..=40).map(|index| format!("line {index}\n")).collect();
let tailed = tail(&text);
assert!(tailed.starts_with("line 29"), "{tailed}");
assert!(tailed.ends_with("line 40"));
let long = "x".repeat(5000);
let tailed = tail(&long);
assert!(tailed.starts_with('…'));
assert_eq!(tailed.chars().count(), 1201);
assert_eq!(tail(" \n"), "");
}
}
@@ -0,0 +1,324 @@
//! 占位替换:install profile 的 `data` 表 + 标准变量 → 一张 `{键 → 值}` 表。
//!
//! 这是 Forge processor 协议里最容易出错的一层,三种字面量语义(照 installer spec,
//! 与 HMCL `ForgeNewInstallTask.parseLiteral` 同构):
//!
//! | 写法 | 含义 | 例 |
//! |------|------|----|
//! | `[group:artifact:version(:classifier)(@ext)]` | maven 构件 → `libraries/` 下的绝对路径 | `[de.oceanlabs.mcp:mcp_config:1.20.1@zip]` |
//! | `'literal'` | 去掉引号的常量 | `'client'` |
//! | 其它 | **installer 里的文件路径** → 解包到临时目录后的绝对路径 | `/data/client.lzma` |
//!
//! 展开(`{KEY}`)的规则与 HMCL 的 `replaceTokens` 一致:`\` 转义下一个字符、
//! `'…'` 原样输出、`{KEY}` 查表替换(**查不到就报错**,不静默留空——留空会让
//! processor 拿着半截参数跑出更难查的错)。
//!
//! # 为什么 `MINECRAFT_JAR` 指向临时目录的副本
//!
//! HMCL 在起 processor 前把原版 client jar **复制到临时目录**再用,注释写得很直白:
//! `External processors must not receive the shared cache path`。processor 会改写输入的
//! jar(拆包/打补丁),直接把 `versions/<mc>/<mc>.jar` 递过去等于让外挂程序改共享层。
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use super::error::ProcessorError;
use super::spec::Datum;
use crate::game::layout::GameLayout;
use crate::game::library::MavenCoordinate;
/// 临时目录里那份原版 client jar 的文件名。
pub const MINECRAFT_JAR_NAME: &str = "minecraft.jar";
/// 一个需要从 installer zip 里解出来的文件。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EmbeddedFile {
/// installer 内的条目路径(`data/client.lzma`)。
pub entry: String,
/// 解包落点(临时目录下的扁平文件名)。
pub dest: PathBuf,
}
/// 变量表的构造输入。
pub struct VariablesInput<'a> {
/// install profile 的 `data` 表。
pub data: &'a BTreeMap<String, Datum>,
/// 游戏目录布局(`libraries/` 与 `versions/` 的路径规则)。
pub layout: &'a GameLayout,
/// 目标原版版本(`net.minecraft:client:<ver>` 这类坐标回落用)。
pub mc_version: &'a str,
/// installer jar 的磁盘路径(`{INSTALLER}`,也是解包来源)。
pub installer: &'a Path,
/// 临时目录(`{MINECRAFT_JAR}` 与解包文件都落这里)。
pub scratch: &'a Path,
}
/// 展开后的变量表。
#[derive(Debug, Clone, Default)]
pub struct Variables {
values: BTreeMap<String, String>,
embedded: Vec<EmbeddedFile>,
/// 参数里 `[坐标]` 的解析上下文(官方 profile 会在 `args` 里直接写坐标,不只写在 `data` 里)。
artifacts: Option<ArtifactRoots>,
}
/// `[坐标]` 的解析上下文。
#[derive(Debug, Clone)]
struct ArtifactRoots {
libraries: PathBuf,
vanilla_jar: PathBuf,
}
impl Variables {
/// 组装变量表:先展开 `data` 表,再注入标准变量。
///
/// # Errors
///
/// 构件坐标不合法、`data` 表里的值是空路径、变量引用缺定义时返回
/// [`ProcessorErrorKind::Plan`](super::error::ProcessorErrorKind::Plan) 错误。
pub fn build(input: VariablesInput<'_>) -> Result<Self, ProcessorError> {
let mut variables = Self {
artifacts: Some(ArtifactRoots {
libraries: input.layout.libraries_dir(),
vanilla_jar: input.layout.version_jar(input.mc_version),
}),
..Self::default()
};
for (key, datum) in input.data {
let Some(raw) = datum.client_value() else {
continue;
};
let value = variables.resolve_literal(raw, &input)?;
variables.values.insert(key.clone(), value);
}
variables.inject_standard(&input);
Ok(variables)
}
/// 标准变量(installer spec 没有写死,但各家启动器都注入同一套)。
fn inject_standard(&mut self, input: &VariablesInput<'_>) {
let scratch = absolute(input.scratch);
let standard = [
("SIDE", "client".to_string()),
("MINECRAFT_VERSION", input.mc_version.to_string()),
(
"MINECRAFT_JAR",
absolute(&scratch.join(MINECRAFT_JAR_NAME))
.to_string_lossy()
.into_owned(),
),
(
"ROOT",
absolute(input.layout.root()).to_string_lossy().into_owned(),
),
(
"INSTALLER",
absolute(input.installer).to_string_lossy().into_owned(),
),
(
"LIBRARY_DIR",
absolute(&input.layout.libraries_dir())
.to_string_lossy()
.into_owned(),
),
];
for (key, value) in standard {
self.values.insert(key.to_string(), value);
}
}
/// 把一个 `data` 表的值展开成实际字符串。
fn resolve_literal(
&mut self,
raw: &str,
input: &VariablesInput<'_>,
) -> Result<String, ProcessorError> {
let trimmed = raw.trim();
if let Some(inner) = trim_brackets(trimmed) {
return self.resolve_artifact(inner);
}
if let Some(inner) = trimmed
.strip_prefix('\'')
.and_then(|rest| rest.strip_suffix('\''))
{
return Ok(inner.to_string());
}
if trimmed.starts_with('{') {
// data 表自己引用别的键:官方 profile 不这么写,但展开一次成本为零。
return self.expand(trimmed);
}
let entry = trimmed.trim_start_matches('/').to_string();
if entry.is_empty() {
return Err(ProcessorError::plan(format!(
"install profile 的 data 表里有空路径:`{raw}`"
)));
}
// 其余一律当 installer 内的文件:解到临时目录(执行期才真正解包)。
let dest = absolute(&absolute(input.scratch).join(embedded_name(&entry)));
self.embedded.push(EmbeddedFile {
entry,
dest: dest.clone(),
});
Ok(dest.to_string_lossy().into_owned())
}
/// `[坐标]` → `libraries/` 下的绝对路径。
fn resolve_artifact(&self, descriptor: &str) -> Result<String, ProcessorError> {
let Some(roots) = &self.artifacts else {
return Err(ProcessorError::plan(format!(
"构件坐标 `{descriptor}` 需要游戏目录上下文才能解析"
)));
};
artifact_path(&roots.libraries, &roots.vanilla_jar, descriptor)
}
/// 展开一段含占位的文本(`{KEY}` / `'字面量'` / `[坐标]` / `\` 转义)。
///
/// `[坐标]` 只在**整段就是坐标**时生效(与 HMCL `parseLiteral` 一致):
/// official profile 的 `args` 里常直接写 `--input [de.oceanlabs.mcp:…@zip]`。
///
/// # Errors
///
/// `{KEY}` 在表里查不到、`\` 结尾无字符可转义、`{`/`'` 未闭合、坐标不合法时返回规划错误。
pub fn expand(&self, pattern: &str) -> Result<String, ProcessorError> {
let trimmed = pattern.trim();
if let Some(inner) = trim_brackets(trimmed) {
return self.resolve_artifact(inner);
}
let mut out = String::with_capacity(pattern.len());
let mut chars = pattern.chars();
while let Some(ch) = chars.next() {
match ch {
'\\' => match chars.next() {
Some(next) => out.push(next),
None => {
return Err(ProcessorError::plan(format!(
"占位文本以 `\\` 结尾(无法转义):`{pattern}`"
)))
}
},
'{' => {
let key = collect_until(&mut chars, '}', pattern)?;
let value = self.values.get(&key).ok_or_else(|| {
ProcessorError::plan(format!(
"占位 {{{key}}} 在 install profile 的 data 表里没有定义(原文 `{pattern}`)"
))
})?;
out.push_str(value);
}
'\'' => out.push_str(&collect_until(&mut chars, '\'', pattern)?),
other => out.push(other),
}
}
Ok(out)
}
/// 某个变量的值。
#[must_use]
pub fn get(&self, key: &str) -> Option<&str> {
self.values.get(key).map(String::as_str)
}
/// 需要从 installer 里解包的文件(执行期用)。
#[must_use]
pub fn embedded(&self) -> &[EmbeddedFile] {
&self.embedded
}
/// 变量条数(`--dry-run` 只报数量,不吐全部路径)。
#[must_use]
pub fn len(&self) -> usize {
self.values.len()
}
/// 是否一条变量都没有(实际不可能:标准变量恒在)。
#[must_use]
pub fn is_empty(&self) -> bool {
self.values.is_empty()
}
}
/// 读到 `closing` 为止(跳过 `\` 转义),返回中间文本。
fn collect_until(
chars: &mut impl Iterator<Item = char>,
closing: char,
pattern: &str,
) -> Result<String, ProcessorError> {
let mut out = String::new();
while let Some(ch) = chars.next() {
if ch == '\\' {
match chars.next() {
Some(next) => out.push(next),
None => {
return Err(ProcessorError::plan(format!(
"占位文本以 `\\` 结尾(无法转义):`{pattern}`"
)))
}
}
} else if ch == closing {
return Ok(out);
} else {
out.push(ch);
}
}
Err(ProcessorError::plan(format!(
"占位文本缺少收尾的 `{closing}`:`{pattern}`"
)))
}
/// 整段是 `[坐标]` 时取出坐标(不到首尾就返回 `None`)。
fn trim_brackets(raw: &str) -> Option<&str> {
raw.strip_prefix('[')
.and_then(|rest| rest.strip_suffix(']'))
}
/// `[坐标]` → `libraries/` 下的绝对路径。
///
/// 少数坐标进不了 `libraries/`:`net.minecraft:client:<ver>`(无 classifier)对应的是
/// `versions/<ver>/<ver>.jar`。官方 installer 会把 client jar 塞进本地 maven 布局,
/// 我们不复制(磁盘上原版 jar 只有一份),因此按目标版本回落。
fn artifact_path(
libraries: &Path,
vanilla_jar: &Path,
descriptor: &str,
) -> Result<String, ProcessorError> {
let coordinate = MavenCoordinate::parse(descriptor).map_err(|error| {
ProcessorError::plan(format!(
"install profile 里的构件坐标不合法:`{descriptor}`({error})"
))
})?;
let path = libraries.join(coordinate.relative_path());
if coordinate.group == "net.minecraft"
&& coordinate.artifact == "client"
&& coordinate.classifier.is_none()
&& vanilla_jar.is_file()
{
return Ok(absolute(vanilla_jar).to_string_lossy().into_owned());
}
Ok(absolute(&path).to_string_lossy().into_owned())
}
/// installer 内的条目 → 临时目录里的扁平文件名(`data/client.lzma` → `data-client.lzma`)。
///
/// 压平是为了防路径穿越:`../` 与绝对路径在拼进临时目录时都会失去意义。
fn embedded_name(entry: &str) -> String {
entry
.chars()
.map(|ch| {
if matches!(ch, '/' | '\\' | ':') {
'-'
} else {
ch
}
})
.collect()
}
/// 尽量转绝对路径(相对路径按当前工作目录;失败就原样返回)。
#[must_use]
pub fn absolute(path: &Path) -> PathBuf {
if path.is_absolute() {
return path.to_path_buf();
}
std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf())
}
@@ -0,0 +1,135 @@
//! Fabric / Quilt:profile 归一化的形状对拍。
use super::{FABRIC_PROFILE, QUILT_PROFILE};
use crate::addon::loader::fabric::{build_release, parse_loader_list, pick_default};
use crate::addon::loader::{LoaderErrorKind, LoaderKind, LoaderSupport};
#[test]
fn fabric_profile_becomes_inheriting_loader_version() {
let release = build_release(LoaderKind::Fabric, "1.20.1", "0.15.11", FABRIC_PROFILE)
.expect("fabric profile 应可归一化");
assert_eq!(release.version_id, "1.20.1-fabric-0.15.11");
let json = &release.version_json;
assert_eq!(json.id.as_deref(), Some("1.20.1-fabric-0.15.11"));
assert_eq!(json.inherits_from.as_deref(), Some("1.20.1"));
// ★ 关键:jar 必须钉成原版 id,否则继承解析会把 jar 兜底成 loader 自身 id。
assert_eq!(json.jar.as_deref(), Some("1.20.1"));
assert_eq!(
json.main_class.as_deref(),
Some("net.fabricmc.loader.impl.launch.knot.KnotClient")
);
// jvm 参数(`-DFabricMcEmu`)必须原样保留:老版本启动靠它。
let jvm = json
.arguments
.as_ref()
.map(|arguments| arguments.jvm.len())
.unwrap_or_default();
assert_eq!(jvm, 1, "profile 的 jvm 参数丢了");
assert_eq!(release.support, LoaderSupport::JsonRoute);
assert!(release.is_installable());
assert!(release.warnings.is_empty(), "{:?}", release.warnings);
}
#[test]
fn fabric_flat_digest_is_lifted_into_artifact() {
let release = build_release(LoaderKind::Fabric, "1.20.1", "0.15.11", FABRIC_PROFILE)
.expect("fabric profile 应可归一化");
let libraries = release.version_json.libraries.expect("应有库");
let asm = libraries
.iter()
.find(|library| library.name.starts_with("org.ow2.asm:asm:"))
.expect("应有 asm");
let artifact = asm
.downloads
.as_ref()
.and_then(|downloads| downloads.artifact.as_ref())
.expect("扁平 sha1/size 应提升成 downloads.artifact");
assert_eq!(
artifact.sha1.as_deref(),
Some("aa205cf0a06dbd8e04ece91c0b37c3f5d567546a")
);
assert_eq!(artifact.size, Some(123_598));
assert_eq!(
artifact.url.as_deref(),
Some("https://maven.fabricmc.net/org/ow2/asm/asm/9.6/asm-9.6.jar")
);
}
#[test]
fn quilt_profile_is_isomorphic() {
let release = build_release(LoaderKind::Quilt, "1.20.1", "0.20.0-beta.9", QUILT_PROFILE)
.expect("quilt profile 应可归一化");
assert_eq!(release.version_id, "1.20.1-quilt-0.20.0-beta.9");
assert_eq!(release.version_json.jar.as_deref(), Some("1.20.1"));
assert_eq!(
release.version_json.main_class.as_deref(),
Some("org.quiltmc.loader.impl.launch.knot.KnotClient")
);
assert_eq!(release.support, LoaderSupport::JsonRoute);
// Quilt 的 profile 没有 loading 库时不该编造;这里有 quilt-loader,因此无告警。
assert!(release.warnings.is_empty(), "{:?}", release.warnings);
}
#[test]
fn missing_main_class_is_metadata_error() {
let profile = r#"{"id":"x","inheritsFrom":"1.20.1","libraries":[]}"#;
let error = build_release(LoaderKind::Fabric, "1.20.1", "0.15.11", profile)
.expect_err("缺主类必须报错");
assert_eq!(error.kind(), LoaderErrorKind::Metadata);
}
#[test]
fn missing_loader_library_is_appended_with_warning() {
// 去掉 fabric-loader 那条,看归一化会不会补。
let profile = r#"{
"id": "fabric-loader-0.15.11-1.20.1",
"inheritsFrom": "1.20.1",
"mainClass": "net.fabricmc.loader.impl.launch.knot.KnotClient",
"libraries": [{"name": "org.ow2.asm:asm:9.6", "url": "https://maven.fabricmc.net/"}]
}"#;
let release = build_release(LoaderKind::Fabric, "1.20.1", "0.15.11", profile)
.expect("缺 loader 库应降级为告警 + 补一条");
assert_eq!(release.warnings.len(), 1);
let libraries = release.version_json.libraries.expect("应有库");
assert!(libraries
.iter()
.any(|library| library.name == "net.fabricmc:fabric-loader:0.15.11"));
}
#[test]
fn loader_list_parses_and_prefers_stable() {
let list = r#"[
{"loader": {"version": "0.16.0-beta.1", "stable": false, "maven": "net.fabricmc:fabric-loader:0.16.0-beta.1"}},
{"loader": {"version": "0.15.11", "stable": true, "maven": "net.fabricmc:fabric-loader:0.15.11"}},
{"loader": {"version": "0.15.10", "stable": true, "maven": "net.fabricmc:fabric-loader:0.15.10"}}
]"#;
let entries = parse_loader_list(LoaderKind::Fabric, list).expect("列表应可解析");
assert_eq!(entries.len(), 3);
assert_eq!(
pick_default(&entries).map(|entry| entry.version.as_str()),
Some("0.15.11")
);
}
#[test]
fn quilt_loader_list_without_stable_flag_still_picks_first() {
// 实测 Quilt 的 loader 条目没有 stable 键。
let list = r#"[
{"loader": {"version": "0.20.0-beta.9", "maven": "org.quiltmc:quilt-loader:0.20.0-beta.9"}},
{"loader": {"version": "0.19.2", "maven": "org.quiltmc:quilt-loader:0.19.2"}}
]"#;
let entries = parse_loader_list(LoaderKind::Quilt, list).expect("列表应可解析");
assert!(entries.iter().all(|entry| entry.stable));
assert_eq!(
pick_default(&entries).map(|entry| entry.version.as_str()),
Some("0.20.0-beta.9")
);
}
#[test]
fn empty_loader_list_is_not_found() {
let error = parse_loader_list(LoaderKind::Fabric, "[]").expect_err("空列表应报错");
assert_eq!(error.kind(), LoaderErrorKind::NotFound);
}
+258
View File
@@ -0,0 +1,258 @@
//! NeoForge / Forge:install profile 解析与三条路线的判定。
use crate::addon::loader::forge::{build_release, InstallProfile};
use crate::addon::loader::{LoaderErrorKind, LoaderKind, LoaderSupport};
use crate::game::manifest::VersionJson;
/// spec 1 + client 侧 processor(实测 NeoForge 21.1.80 / Forge 1.20.1 的形状,裁剪过)。
pub(crate) const NEOFORGE_PROFILE: &str = r#"{
"spec": 1,
"profile": "NeoForge",
"version": "neoforge-21.1.80",
"minecraft": "1.21.1",
"json": "/version.json",
"path": null,
"processors": [
{"sides": ["server"], "jar": "net.neoforged.installertools:installertools:2.1.2"},
{"sides": ["client"], "jar": "net.neoforged.installertools:installertools:2.1.2"},
{"jar": "net.neoforged.installertools:installertools:2.1.2"},
{"sides": ["client"], "jar": "net.neoforged.installertools:installertools:2.1.2"}
],
"libraries": [
{"name": "com.google.code.gson:gson:2.8.9@jar"},
{"name": "org.apache.commons:commons-text:1.3@jar"}
],
"data": {"MC_SLIM": {"client": "[net.neoforged:neoforge:21.1.80:client-slim]"}}
}"#;
/// spec 1 版本 JSON 的裁剪版(内容形状照 21.1.80)。
pub(crate) const NEOFORGE_VERSION_JSON: &str = r#"{
"id": "neoforge-21.1.80",
"inheritsFrom": "1.21.1",
"type": "release",
"mainClass": "cpw.mods.bootstraplauncher.BootstrapLauncher",
"arguments": {"game": ["--launchTarget", "forgeclient"], "jvm": ["-DignoreList=bootstraplauncher"]},
"libraries": [
{"name": "cpw.mods:securejarhandler:2.1.10",
"downloads": {"artifact": {"path": "cpw/mods/securejarhandler/2.1.10/securejarhandler-2.1.10.jar",
"url": "https://maven.neoforged.net/releases/cpw/mods/securejarhandler/2.1.10/securejarhandler-2.1.10.jar",
"sha1": "51e6a22c6c716beb11e244bf5b8be480f51dd6b5", "size": 88749}}}
]
}"#;
/// spec 0 + 无 processors(实测 Forge 1.12.2-14.23.5.2860),universal jar 的 url 是空串。
pub(crate) const FORGE_LEGACY_PROFILE: &str = r#"{
"spec": 0,
"profile": "forge",
"version": "1.12.2-forge-14.23.5.2860",
"minecraft": "1.12.2",
"json": "/version.json",
"path": "net.minecraftforge:forge:1.12.2-14.23.5.2860",
"processors": [],
"libraries": [{"name": "net.minecraftforge:forge:1.12.2-14.23.5.2860"}]
}"#;
pub(crate) const FORGE_LEGACY_VERSION_JSON: &str = r#"{
"id": "1.12.2-forge-14.23.5.2860",
"inheritsFrom": "1.12.2",
"mainClass": "net.minecraft.launchwrapper.Launch",
"minecraftArguments": "--username ${auth_player_name} --tweakClass net.minecraftforge.fml.common.launcher.FMLTweaker",
"libraries": [
{"name": "net.minecraftforge:forge:1.12.2-14.23.5.2860",
"downloads": {"artifact": {"path": "net/minecraftforge/forge/1.12.2-14.23.5.2860/forge-1.12.2-14.23.5.2860.jar",
"url": "", "sha1": "029250575d3aa2cf80b56dffb66238a1eeaea2ac", "size": 4466148}}}
]
}"#;
fn profile(json: &str) -> InstallProfile {
serde_json::from_str(json).expect("夹具 profile 应可解析")
}
fn version_json(json: &str) -> VersionJson {
VersionJson::from_json_str(json).expect("夹具版本 JSON 应可解析")
}
#[test]
fn client_processors_mark_version_as_installer_required() {
let release = build_release(
LoaderKind::NeoForge,
"1.21.1",
"21.1.80",
&profile(NEOFORGE_PROFILE),
Some(&version_json(NEOFORGE_VERSION_JSON)),
)
.expect("版本 JSON 齐备时应能生成清单");
// 2 条 sides 含 client + 1 条无 sides(两侧都要)= 3 条 client 侧。
assert_eq!(profile(NEOFORGE_PROFILE).client_processors(), 3);
// M35 起 RequiresInstaller 是可真装的路线(外调 java 跑 processors),
// 但"要跑 installer"这个事实仍要能被上层看见。
assert!(release.is_installable());
assert!(release.requires_installer());
let reason = release
.installer_reason()
.expect("应给需要 installer 的原因");
assert!(reason.contains("3 个 client 侧 processor"), "{reason}");
// 版本清单本身仍然正确生成(--dry-run 能看到它)。
assert_eq!(release.version_id, "1.21.1-neoforge-21.1.80");
assert_eq!(
release.version_json.inherits_from.as_deref(),
Some("1.21.1")
);
assert_eq!(release.version_json.jar.as_deref(), Some("1.21.1"));
// processor 依赖不进启动 classpath,只记数量进告警。
assert_eq!(release.version_json.libraries.map_or(0, |l| l.len()), 1);
assert!(release
.warnings
.iter()
.any(|warning| warning.contains("processor 依赖")));
}
#[test]
fn spec_zero_profile_is_json_route_and_fills_empty_url() {
let release = build_release(
LoaderKind::Forge,
"1.12.2",
"1.12.2-14.23.5.2860",
&profile(FORGE_LEGACY_PROFILE),
Some(&version_json(FORGE_LEGACY_VERSION_JSON)),
)
.expect("spec 0 应走 json 路线");
assert_eq!(release.support, LoaderSupport::JsonRoute);
assert_eq!(release.loader_version, "14.23.5.2860");
assert_eq!(release.version_id, "1.12.2-forge-14.23.5.2860");
assert_eq!(
release.version_json.main_class.as_deref(),
Some("net.minecraft.launchwrapper.Launch")
);
// 空 url 必须补成 maven 地址:本体 jar 在官方 maven 上**只发布在 -universal classifier 下**
// (无 classifier 路径官方 404,仅靠 BMCLAPI 302 别名兜底),所以 URL 要带 -universal。
let library = release
.version_json
.libraries
.as_ref()
.and_then(|libraries| libraries.first())
.expect("应有 forge 本体库");
let artifact = library
.downloads
.as_ref()
.and_then(|downloads| downloads.artifact.as_ref())
.expect("应有 artifact");
assert_eq!(
artifact.url.as_deref(),
Some(
"https://maven.minecraftforge.net/net/minecraftforge/forge/1.12.2-14.23.5.2860/forge-1.12.2-14.23.5.2860-universal.jar"
),
"URL 必须带 -universal(官方 maven 只托管这个 classifier)"
);
assert_eq!(artifact.size, Some(4_466_148));
assert!(library
.url
.as_deref()
.unwrap_or_default()
.contains("minecraftforge"));
// 磁盘落点仍用原 path(与官方 installer 内嵌条目同名,启动 classpath 布局不变)。
assert_eq!(
artifact.path.as_deref(),
Some("net/minecraftforge/forge/1.12.2-14.23.5.2860/forge-1.12.2-14.23.5.2860.jar")
);
let dest = library.relative_path().expect("落点应可推导");
assert!(
dest.ends_with("forge-1.12.2-14.23.5.2860.jar"),
"落点不该带 classifier:{}",
dest.display()
);
}
#[test]
fn legacy_inline_protocol_is_unsupported() {
// 老协议:没有独立 version.json,只有内联 versionInfo + 解包信息。
let profile = profile(
r#"{"spec": 1, "minecraft": "1.7.10",
"install": {"filePath": "forge-1.7.10-10.13.4.1614-universal.jar"},
"versionInfo": {"id": "1.7.10-Forge10.13.4.1614", "mainClass": "net.minecraft.launchwrapper.Launch"},
"processors": []}"#,
);
assert!(profile.is_legacy_protocol());
let error = build_release(
LoaderKind::Forge,
"1.7.10",
"1.7.10-10.13.4.1614",
&profile,
None,
)
.expect_err("老协议必须明确 unsupported");
assert_eq!(error.kind(), LoaderErrorKind::Unsupported);
assert!(error.message().contains("老协议"), "{}", error.message());
assert!(error.hint().is_some());
}
#[test]
fn universal_classifier_rewrite_is_scoped_to_the_installer_body() {
// 只有 install profile 的 `path` 声明的本体构件走 -universal;
// 其它同样"空 url + 有 path"的库必须保持原路径(乱加 classifier 会 404)。
let embedded = version_json(
r#"{"id": "1.12.2-forge-14.23.5.2860", "inheritsFrom": "1.12.2",
"mainClass": "net.minecraft.launchwrapper.Launch",
"libraries": [
{"name": "net.minecraftforge:forge:1.12.2-14.23.5.2860",
"downloads": {"artifact": {"path": "net/minecraftforge/forge/1.12.2-14.23.5.2860/forge-1.12.2-14.23.5.2860.jar", "url": ""}}},
{"name": "net.minecraftforge:mergetool:1.1.5",
"downloads": {"artifact": {"path": "net/minecraftforge/mergetool/1.1.5/mergetool-1.1.5.jar", "url": ""}}}
]}"#,
);
let release = build_release(
LoaderKind::Forge,
"1.12.2",
"1.12.2-14.23.5.2860",
&profile(FORGE_LEGACY_PROFILE),
Some(&embedded),
)
.expect("spec 0 应走 json 路线");
let libraries = release.version_json.libraries.expect("应有库");
let url_of = |name: &str| {
libraries
.iter()
.find(|library| library.name == name)
.and_then(|library| library.downloads.as_ref())
.and_then(|downloads| downloads.artifact.as_ref())
.and_then(|artifact| artifact.url.clone())
.unwrap_or_default()
};
assert!(url_of("net.minecraftforge:forge:1.12.2-14.23.5.2860").ends_with("-universal.jar"));
assert_eq!(
url_of("net.minecraftforge:mergetool:1.1.5"),
"https://maven.minecraftforge.net/net/minecraftforge/mergetool/1.1.5/mergetool-1.1.5.jar"
);
}
#[test]
fn version_mismatch_between_installer_and_target_is_metadata_error() {
let error = build_release(
LoaderKind::NeoForge,
"1.20.1",
"21.1.80",
&profile(NEOFORGE_PROFILE),
Some(&version_json(NEOFORGE_VERSION_JSON)),
)
.expect_err("原版版本不符必须报错");
assert_eq!(error.kind(), LoaderErrorKind::Metadata);
assert!(error.message().contains("1.21.1"), "{}", error.message());
}
#[test]
fn missing_main_class_is_metadata_error() {
let embedded = version_json(r#"{"id": "x", "inheritsFrom": "1.12.2", "libraries": []}"#);
let error = build_release(
LoaderKind::Forge,
"1.12.2",
"1.12.2-14.23.5.2860",
&profile(FORGE_LEGACY_PROFILE),
Some(&embedded),
)
.expect_err("缺主类必须报错");
assert_eq!(error.kind(), LoaderErrorKind::Metadata);
}
+134
View File
@@ -0,0 +1,134 @@
//! maven 版本表:XML 扫描、NeoForge 版本反解、版本挑选、installer 地址。
use crate::addon::loader::maven::{
display_loader_version, installer_url, matches_mc, metadata_url, neoforge_mc_version,
normalize_maven_version, parse_metadata_versions, select_versions,
};
use crate::addon::loader::LoaderKind;
/// 裁剪过的 maven-metadata.xml(真实结构,只留 `<version>` 列表)。
const METADATA: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<metadata>
<groupId>net.neoforged</groupId>
<artifactId>neoforge</artifactId>
<versioning>
<latest>26.1.2.125</latest>
<versions>
<version>20.4.237</version>
<version>21.1.9</version>
<version>21.1.80</version>
<version>26.1.2.125</version>
<version>0.0.1-snapshot-1</version>
</versions>
</versioning>
</metadata>"#;
#[test]
fn metadata_versions_are_scanned() {
let versions = parse_metadata_versions(METADATA);
assert_eq!(
versions,
vec![
"20.4.237",
"21.1.9",
"21.1.80",
"26.1.2.125",
"0.0.1-snapshot-1"
]
);
assert!(parse_metadata_versions("not xml").is_empty());
}
#[test]
fn neoforge_versions_map_back_to_minecraft() {
// analyse/05 §1.3 的三条规则。
assert_eq!(neoforge_mc_version("21.1.80").as_deref(), Some("1.21.1"));
assert_eq!(neoforge_mc_version("20.4.237").as_deref(), Some("1.20.4"));
assert_eq!(neoforge_mc_version("26.1.2.125").as_deref(), Some("26.1"));
// 快照(主版本 0)不挂原版版本号。
assert_eq!(neoforge_mc_version("0.0.1-snapshot-1"), None);
}
#[test]
fn select_versions_filters_and_sorts_numerically() {
let versions = parse_metadata_versions(METADATA);
let picked = select_versions(LoaderKind::NeoForge, "1.21.1", &versions);
// 数字序:9 < 80(字符串序会反过来)。
assert_eq!(picked, vec!["21.1.9", "21.1.80"]);
assert!(select_versions(LoaderKind::NeoForge, "1.19.2", &versions).is_empty());
}
#[test]
fn forge_versions_carry_the_minecraft_prefix() {
let versions = vec![
"1.20.1-47.3.0".to_string(),
"1.20.1-47.2.0".to_string(),
"1.12.2-14.23.5.2860".to_string(),
];
let picked = select_versions(LoaderKind::Forge, "1.20.1", &versions);
assert_eq!(picked, vec!["1.20.1-47.2.0", "1.20.1-47.3.0"]);
assert!(matches_mc(LoaderKind::Forge, "1.20.1", "1.20.1-47.3.0"));
assert!(!matches_mc(LoaderKind::Forge, "1.20.2", "1.20.1-47.3.0"));
}
#[test]
fn neoforge_1201_uses_forge_layout() {
// 1.20.1 的 NeoForge 早产版挂在 net/neoforged/forge 下,版本串带原版前缀。
assert!(metadata_url(LoaderKind::NeoForge, "1.20.1")
.ends_with("net/neoforged/forge/maven-metadata.xml"));
assert!(metadata_url(LoaderKind::NeoForge, "1.21.1")
.ends_with("net/neoforged/neoforge/maven-metadata.xml"));
assert!(metadata_url(LoaderKind::Forge, "1.20.1")
.ends_with("net/minecraftforge/forge/maven-metadata.xml"));
let versions = vec!["1.20.1-47.1.106".to_string(), "1.20.1-47.1.90".to_string()];
let picked = select_versions(LoaderKind::NeoForge, "1.20.1", &versions);
assert_eq!(picked.len(), 2);
assert_eq!(
display_loader_version(LoaderKind::NeoForge, "1.20.1", "1.20.1-47.1.106"),
"47.1.106"
);
}
#[test]
fn versions_are_normalized_and_displayed() {
// Forge:用户给 "47.2.0",maven 上是 "1.20.1-47.2.0"。
assert_eq!(
normalize_maven_version(LoaderKind::Forge, "1.20.1", "47.2.0"),
"1.20.1-47.2.0"
);
assert_eq!(
normalize_maven_version(LoaderKind::Forge, "1.20.1", "1.20.1-47.2.0"),
"1.20.1-47.2.0"
);
assert_eq!(
display_loader_version(LoaderKind::Forge, "1.20.1", "1.20.1-47.2.0"),
"47.2.0"
);
// NeoForge(非 1.20.1 布局)本身就是完整 maven 版本。
assert_eq!(
normalize_maven_version(LoaderKind::NeoForge, "1.21.1", "21.1.80"),
"21.1.80"
);
assert_eq!(
display_loader_version(LoaderKind::NeoForge, "1.21.1", "21.1.80"),
"21.1.80"
);
}
#[test]
fn installer_urls_point_at_official_maven() {
assert_eq!(
installer_url(LoaderKind::Forge, "1.20.1", "1.20.1-47.2.0"),
"https://maven.minecraftforge.net/net/minecraftforge/forge/1.20.1-47.2.0/forge-1.20.1-47.2.0-installer.jar"
);
assert_eq!(
installer_url(LoaderKind::NeoForge, "1.21.1", "21.1.80"),
"https://maven.neoforged.net/releases/net/neoforged/neoforge/21.1.80/neoforge-21.1.80-installer.jar"
);
// 1.20.1 的 NeoForge 走 forge 布局(文件名也是 forge-)。
assert_eq!(
installer_url(LoaderKind::NeoForge, "1.20.1", "1.20.1-47.1.106"),
"https://maven.neoforged.net/releases/net/neoforged/forge/1.20.1-47.1.106/forge-1.20.1-47.1.106-installer.jar"
);
}
+57
View File
@@ -0,0 +1,57 @@
//! `addon::loader` 的单元测试:离线夹具对拍 + 镜像选择 + unsupported 判定。
//!
//! 刻意**不打真实网络**:JSON 归一化、版本表挑选、镜像候选、计划形状全是纯计算。
//! 夹具是官方响应的**裁剪版**(保留形状与关键字段,去掉几百行库与 base64 图标)。
mod fabric;
mod forge;
mod maven;
mod plan;
mod vocabulary;
/// 裁剪过的 Fabric `/profile/json`(1.20.1 + fabric-loader 0.15.11)。
pub(crate) const FABRIC_PROFILE: &str = r#"{
"id": "fabric-loader-0.15.11-1.20.1",
"inheritsFrom": "1.20.1",
"releaseTime": "2026-10-29T10:25:18+0000",
"time": "2026-10-29T10:25:18+0000",
"type": "release",
"mainClass": "net.fabricmc.loader.impl.launch.knot.KnotClient",
"arguments": {"game": [], "jvm": ["-DFabricMcEmu= net.minecraft.client.main.Main "]},
"libraries": [
{"name": "org.ow2.asm:asm:9.6", "url": "https://maven.fabricmc.net/",
"sha1": "aa205cf0a06dbd8e04ece91c0b37c3f5d567546a", "size": 123598},
{"name": "net.fabricmc:sponge-mixin:0.13.3+mixin.0.8.5", "url": "https://maven.fabricmc.net/"},
{"name": "net.fabricmc:intermediary:1.20.1", "url": "https://maven.fabricmc.net/"},
{"name": "net.fabricmc:fabric-loader:0.15.11", "url": "https://maven.fabricmc.net/"}
]
}"#;
/// 裁剪过的 Quilt `/profile/json`(1.20.1 + quilt-loader 0.20.0-beta.9)。
pub(crate) const QUILT_PROFILE: &str = r#"{
"id": "quilt-loader-0.20.0-beta.9-1.20.1",
"inheritsFrom": "1.20.1",
"type": "release",
"mainClass": "org.quiltmc.loader.impl.launch.knot.KnotClient",
"arguments": {"game": []},
"libraries": [
{"name": "net.fabricmc:tiny-mappings-parser:0.3.0+build.17", "url": "https://maven.fabricmc.net/"},
{"name": "org.quiltmc:quilt-json5:1.0.4+final", "url": "https://maven.quiltmc.org/repository/release/"},
{"name": "org.quiltmc:quilt-loader:0.20.0-beta.9", "url": "https://maven.quiltmc.org/repository/release/"}
]
}"#;
/// 原版 1.20.1 的**已裁剪**版本 JSON(继承链解析的父层)。
pub(crate) const VANILLA_1201: &str = r#"{
"id": "1.20.1",
"type": "release",
"mainClass": "net.minecraft.client.main.Main",
"assets": "5",
"assetIndex": {"id": "5", "sha1": "0000", "size": 1,
"url": "https://piston-meta.mojang.com/v1/packages/5.json"},
"downloads": {"client": {"sha1": "1111", "size": 2,
"url": "https://piston-data.mojang.com/v1/objects/1/client.jar"}},
"libraries": [
{"name": "com.mojang:brigadier:1.0.18", "url": "https://libraries.minecraft.net/"}
]
}"#;
+192
View File
@@ -0,0 +1,192 @@
//! 计划形状 / 继承链两级解析 / 镜像选择。
use std::collections::BTreeMap;
use super::{FABRIC_PROFILE, VANILLA_1201};
use crate::addon::loader::fabric::{build_release, loader_list_url, profile_url};
use crate::addon::loader::maven;
use crate::addon::loader::{
LoaderInstallPlan, LoaderKind, LoaderPlanInput, LoaderRelease, LoaderSupport,
};
use crate::download::mirrors::MirrorTable;
use crate::download::region::RegionProfile;
use crate::download::types::ResourceKind;
use crate::game::arguments::Environment;
use crate::game::install::FileRole;
use crate::game::{inherit, layout::GameLayout, VersionJson};
fn fabric_release() -> LoaderRelease {
build_release(LoaderKind::Fabric, "1.20.1", "0.15.11", FABRIC_PROFILE).expect("夹具应可归一化")
}
fn environment() -> Environment {
Environment {
os: "windows".to_string(),
os_version: String::new(),
arch: "x86_64".to_string(),
..Environment::host()
}
}
#[test]
fn plan_contains_only_loader_libraries() {
let layout = GameLayout::new("/g/.minecraft");
let release = fabric_release();
let plan = LoaderInstallPlan::build(LoaderPlanInput {
layout: &layout,
release: &release,
environment: &environment(),
vanilla_installed: false,
});
assert_eq!(plan.install.version_id, "1.20.1-fabric-0.15.11");
// jar 与资源归原版层,loader 计划里只有库。
assert_eq!(plan.install.jar_id, "1.20.1");
assert!(plan
.files()
.iter()
.all(|file| file.role == FileRole::Library));
assert!(
plan.files()
.iter()
.any(|file| file.name.contains("fabric-loader")),
"缺 fabric-loader:{:?}",
plan.files().iter().map(|f| &f.name).collect::<Vec<_>>()
);
assert!(plan.install.version_json_path.ends_with(
std::path::Path::new("versions")
.join("1.20.1-fabric-0.15.11")
.join("1.20.1-fabric-0.15.11.json")
));
// 只下 loader 那几个库,不该冒出资产/客户端 jar。
assert!(plan.install.file_count() >= 4);
assert!(plan.install.client_jar().is_none());
}
#[test]
fn plan_marks_missing_vanilla_and_unsupported() {
let layout = GameLayout::new("/g/.minecraft");
let release = fabric_release();
let plan = LoaderInstallPlan::build(LoaderPlanInput {
layout: &layout,
release: &release,
environment: &environment(),
vanilla_installed: false,
});
assert!(plan.vanilla_install_needed);
assert!(plan
.warnings()
.iter()
.any(|warning| warning.contains("原版 1.20.1 尚未安装")));
let installed = LoaderInstallPlan::build(LoaderPlanInput {
layout: &layout,
release: &release,
environment: &environment(),
vanilla_installed: true,
});
assert!(!installed.vanilla_install_needed);
// 把 support 改成"需要 installer",计划里要出现"要外调 java"的告警(M35 起可真装)。
let mut blocked = fabric_release();
blocked.support = LoaderSupport::RequiresInstaller {
reason: "需要 processors".to_string(),
};
let plan = LoaderInstallPlan::build(LoaderPlanInput {
layout: &layout,
release: &blocked,
environment: &environment(),
vanilla_installed: true,
});
assert!(plan
.warnings()
.iter()
.any(|warning| warning.contains("需要外调 java 跑 installer processors")));
}
#[test]
fn summary_json_carries_loader_block() {
let layout = GameLayout::new("/g/.minecraft");
let release = fabric_release();
let plan = LoaderInstallPlan::build(LoaderPlanInput {
layout: &layout,
release: &release,
environment: &environment(),
vanilla_installed: true,
});
let summary = plan.summary_json();
assert_eq!(summary["loader"]["kind"], "fabric");
assert_eq!(summary["loader"]["version_id"], "1.20.1-fabric-0.15.11");
assert_eq!(summary["loader"]["support"]["mode"], "json_route");
assert_eq!(summary["loader"]["inherits_from"], "1.20.1");
assert_eq!(summary["vanilla_install_needed"], false);
}
#[test]
fn loader_json_resolves_two_level_chain() {
// loader → 原版 两级继承:解析后必须是扁平的、jar 指原版、库两侧合并。
let vanilla = VersionJson::from_json_str(VANILLA_1201).expect("原版夹具");
let loader = fabric_release().version_json;
let mut table: BTreeMap<String, VersionJson> = BTreeMap::new();
table.insert("1.20.1".to_string(), vanilla);
table.insert("1.20.1-fabric-0.15.11".to_string(), loader.clone());
let resolved = inherit::resolve(&table, &loader).expect("两级链应可解析");
assert!(resolved.is_resolved());
assert_eq!(resolved.inherits_from, None);
assert_eq!(resolved.jar.as_deref(), Some("1.20.1"));
assert_eq!(
resolved.main_class.as_deref(),
Some("net.fabricmc.loader.impl.launch.knot.KnotClient")
);
// 资源来自原版层。
assert_eq!(resolved.assets.as_deref(), Some("5"));
assert!(resolved.client_jar().is_some());
// 库两层的并集(父在前,子在后)。
let libraries = resolved.libraries.expect("应有库");
assert_eq!(libraries.len(), 5);
assert_eq!(libraries[0].name, "com.mojang:brigadier:1.0.18");
}
#[test]
fn loader_meta_and_maven_urls_hit_bmclapi_mirrors() {
let table = MirrorTable::builtin();
let mirror_first = RegionProfile::mirror_first(0);
let fabric_meta = loader_list_url(LoaderKind::Fabric, "1.20.1");
let candidates = table.expand(&fabric_meta, ResourceKind::VersionManifest, &mirror_first);
let sources: Vec<&str> = candidates.iter().map(|c| c.source.as_str()).collect();
assert_eq!(
sources.first(),
Some(&"bmclapi"),
"镜像优先时应先走 BMCLAPI"
);
assert!(candidates.iter().any(|candidate| candidate
.url
.contains("/fabric-meta/v2/versions/loader/1.20.1")));
let quilt_profile = profile_url(LoaderKind::Quilt, "1.20.1", "0.20.0-beta.9");
let candidates = table.expand(&quilt_profile, ResourceKind::VersionManifest, &mirror_first);
assert_eq!(candidates[0].source, "bmclapi");
assert!(candidates[0].url.contains("/quilt-meta/"));
let neoforge_installer = maven::installer_url(LoaderKind::NeoForge, "1.21.1", "21.1.80");
let candidates = table.expand(&neoforge_installer, ResourceKind::Library, &mirror_first);
assert_eq!(candidates[0].source, "bmclapi");
assert!(candidates[0]
.url
.starts_with("https://bmclapi2.bangbang93.com/maven/net/neoforged/neoforge/"));
let forge_meta = maven::metadata_url(LoaderKind::Forge, "1.20.1");
let candidates = table.expand(&forge_meta, ResourceKind::VersionManifest, &mirror_first);
assert_eq!(candidates[0].source, "bmclapi");
assert!(candidates[0]
.url
.starts_with("https://bmclapi2.bangbang93.com/maven/net/minecraftforge/forge/"));
// 直连模式:官方在前,镜像只兜底。
let direct = RegionProfile::direct(0);
let candidates = table.expand(&fabric_meta, ResourceKind::VersionManifest, &direct);
assert_eq!(candidates[0].source, "official");
assert_eq!(candidates[0].url, fabric_meta);
}
@@ -0,0 +1,84 @@
//! 安装侧词表与 `crate::game` 词表的对接:委派有没有接错、别名有没有漏进产出。
//!
//! 词表本体(七个对外 id)在 `crate::game::loader`,这里只验**安装侧这一半**:
//! [`LoaderKind::as_str`] 委派给 core,[`LoaderKind::parse`] 委派 core 的解析并只多收一个
//! Modrinth 输入别名。为什么放在安装侧的测试目录:这一半的存在理由是「装出来的版本 id 得
//! 能被认回来」,那正好是生成端与解析端的接缝。
use crate::addon::loader::LoaderKind;
use crate::game::{InstanceLoader, LoaderKind as Wide};
/// `as_str` 是 core 词表的委派:四家装得上的一一对应,且绝不产出别名。
///
/// 抓得住:委派接错支(`Fabric` 映到 `Quilt`);有人把 `as_str` 改回手写字面量并写错;
/// 有人让 `as_str` 产出 `neo-forge`(别名只进不出);生成端 `<mc>-<kind>-<loader>` 与解析端
/// 脱节(装出来的 id 认不回同一家)。
/// 抓不住:core 词表的字面值(那是 `game::loader` 的往返测试与 presenter 契约测试的职责)。
#[test]
fn install_side_ids_are_delegated_to_the_core_vocabulary() {
let cases = [
(LoaderKind::Fabric, Wide::Fabric),
(LoaderKind::Quilt, Wide::Quilt),
(LoaderKind::NeoForge, Wide::NeoForge),
(LoaderKind::Forge, Wide::Forge),
];
for (install, wide) in cases {
assert_eq!(
install.as_str(),
wide.as_str(),
"{install:?} 的 id 没走 core 词表"
);
assert_ne!(install.as_str(), "neo-forge", "别名只进不出");
assert_eq!(LoaderKind::parse(install.as_str()), Some(install), "反查");
let id = install.version_id("1.20.4", "0.16.5");
let parsed = InstanceLoader::parse(&id);
assert_eq!(parsed.kind.as_str(), install.as_str(), "`{id}` 认错了家");
assert_eq!(parsed.version, "0.16.5", "`{id}` 的版本段");
}
}
/// 别名策略:`neo-forge` 只作为**输入**被接受(Modrinth 的拼法),产出永远是 `neoforge`。
///
/// 抓得住:别名被搬进词表(`as_str` 开始产出 `neo-forge`);别名只认小写;安装侧顺手接受了
/// 本启动器装不了的几家(原版 / OptiFine / LiteLoader)。
/// 抓不住:`neo-forge` 之外 Modrinth 以后可能再发明的拼法——没有样本可写。
#[test]
fn modrinth_alias_is_input_only() {
for raw in ["neo-forge", "NEO-FORGE", " neo-forge "] {
assert_eq!(
LoaderKind::parse(raw),
Some(LoaderKind::NeoForge),
"`{raw}`"
);
}
assert_eq!(
LoaderKind::NeoForge.as_str(),
"neoforge",
"产出不许带连字符"
);
for raw in ["vanilla", "optifine", "liteloader", "", "rift"] {
assert_eq!(LoaderKind::parse(raw), None, "`{raw}` 不该被安装侧接受");
}
}
/// [`LoaderKind::all`] 是安装侧唯一还手写的清单(CF id 前缀匹配、CLI `--loader` 提示用),
/// 它与 [`LoaderKind::from_core`] 认可的集合必须一致——不重抄字面量,直接对映射本身。
///
/// 抓得住:往 core 加一家**能装**的加载器,`from_core` 补了却忘了 `all()`(CLI 提示会漏一家、
/// CF id 不认识它的前缀);反过来 `all()` 里多一家而 `from_core` 说装不了。
/// 抓不住:`all()` 的顺序(那是 CLI 提示的展示序,没有对外契约意义)。
#[test]
fn installable_list_matches_the_from_core_mapping() {
let mut derived: Vec<LoaderKind> = Wide::ALL
.into_iter()
.filter_map(LoaderKind::from_core)
.collect();
derived.sort_unstable();
let mut listed = LoaderKind::all().to_vec();
listed.sort_unstable();
assert_eq!(
listed, derived,
"`LoaderKind::all()` 必须正好是 `from_core` 认可的那几家"
);
}
+48
View File
@@ -0,0 +1,48 @@
//! Mod 与整合包:加载器安装(Fabric/Quilt/NeoForge/Forge)、整合包双格式导入导出、
//! 本地 Mod 管理、CurseForge / Modrinth 客户端。
//!
//! 设计定稿:`DevDocs/PlanDoc/08-mod-and-recommend.md`(决策 D10)。
//! 安全要点:整合包路径穿越双检;AI 推荐走「结构化过滤在前 + LLM 排序在后 + 来源链接防幻觉」。
//!
//! # 当前公共面
//!
//! - [`loader`](P2 · M32):加载器安装——官方元数据 → 带 `inheritsFrom` 的 loader 版本 JSON
//! → 补库清单,落盘与实例登记复用 `core::game::install`(M27)。
//! - [`pack`](P2 · M110):整合包的**格式无关模型**与**格式扩展点**——CF / Modrinth / MultiMC
//! 三种格式的探测与读取、CF / mrpack 的渲染与导出、zip 安全读取(穿越双检 / 符号链接 /
//! 条数体积上限)。
//! - [`import`](P2 · M111):**导入管线**——三种格式 → 实例目录:跨段落地、overrides 三方
//! 合并(保用户改动)、两阶段补全与失败分级、临时目录 + rename/journal 回滚、结构化报告。
//! 导出(M112)另起模块。
//! - [`export`](P2 · M112):**导出管线**——实例目录 → `.mrpack`(优先)/ CF `manifest.json`。
//! 逐文件判定「能否被远程替代」(Modrinth SHA-1 + CF 指纹),判定不了的**内联进 `overrides/`**
//! 并逐条进报告;`.disabled` 用路径后缀;`game`/`dependencies` 从实例快照取。
//! MultiMC/Prism **只导入不导出**(有意省略)。
//!
//! 规划中:本地 mod 管理(D10 §3)、CF/Modrinth 客户端(§5)。
pub mod export;
pub mod import;
pub mod loader;
pub mod pack;
pub use export::{
export_instance, ExportFile, ExportReport, ExportRequest, FileClass, FileReason, RemoteCatalog,
RemoteHit,
};
pub use import::{
import_pack, ConflictFile, ConflictPolicy, ImportError, ImportErrorKind, ImportProgress,
ImportProgressSink, ImportReport, ImportRequest, ImportStage, ImportStatus, ImportedFile,
MissingFile, PackBaseline, PackFetcher, RejectedComponent,
};
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,
};
#[cfg(test)]
mod tests;
+71
View File
@@ -0,0 +1,71 @@
//! 整合包的**格式无关模型**与**格式扩展点**(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`,**只读**)。
//!
//! # 导出面的边界(r1 之后写死)
//!
//! 默认 [`format::PackFormat::write`] **只保证同格式往返**:覆盖目录按模型原样搬,
//! 不做目录映射 / `side` 分派 / 加载器 id 规范化 / `extra` 分桶(都属于 M112)。
//! 表达不了的时候由 [`format::PackFormat::check_overrides`] 直接报 `pack_unsupported`,
//! 而不是产出一个"成功但内容为空"的包。
//!
//! # 刻意不做的事(分工边界)
//!
//! - **不发网络请求**: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, ReadBudget};
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;
+298
View File
@@ -0,0 +1,298 @@
//! 打包输入的安全读取层:[`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` 规整后撞车) | 规整后的名字重复即拒绝 |
//!
//! **两条检查各补一条对方没有的规则**(谁也不能省):
//!
//! - 第一检管**平台无关**的形状:空名、`\0`、`\`、`:`、前导 `/`、任何 `..` 段。
//! `enclosed_name()` 在这一点上更松——它允许"深度 0 处的根目录",也就是 `"/etc/passwd"`
//! 会返回 `Some("/etc/passwd")`,绝对路径必须由我们先干掉。
//! - 第二检管 **Win32 命名空间**:DOS 设备名(`CON` / `NUL` / `AUX` / `COM1..9` / `LPT1..9`)、
//! 结尾的点或空格(`a.`、`a `)、`< > " | ? *` 与控制字符。这些第一检全放行,而它们会让
//! 落盘时撞到设备、或与另一个名字等价。第二检只在 **Windows 宿主**上生效(`zip` 的
//! `cfg(windows)` 分支),所以 `tests/safety.rs` 里对应的用例也是 `#[cfg(windows)]`。
//!
//! 也就是说:`CON` / `NUL` 这类在 Windows 上**是拦的**(由第二检),非 Windows 宿主上不拦
//! (那里它们只是普通文件名,拦了反而拒掉合法包)。
use std::cell::RefCell;
use std::collections::HashMap;
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, ReadBudget, 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<ZipArchive<File>>),
}
/// 一个已校验的整合包输入(目录或 zip)。
pub struct PackArchive {
root: PathBuf,
source: Source,
entries: Vec<PackEntryInfo>,
index: HashMap<String, usize>,
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<Path>) -> PackResult<Self> {
Self::open(path, PackLimits::default())
}
/// 打开一个目录或 zip,按给定限额校验。
///
/// 校验是**全量**的:打开成功就说明整包的条目名都过了两道穿越检查、没有符号链接、
/// 条数与体积都在限额内。后续读取不再需要重复判断来源是否恶意。
pub fn open(path: impl AsRef<Path>, limits: PackLimits) -> PackResult<Self> {
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<Option<Vec<u8>>> {
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, None, name)?;
Ok(Some(bytes))
}
}
}
/// 按 UTF-8 读文本条目。
pub fn read_text(&self, name: &str) -> PackResult<Option<String>> {
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<Option<Value>> {
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)`。
///
/// 单条读取只受 [`PackLimits::max_entry_bytes`] 约束;要跨条目累计限额的批量读取
/// 请用 [`Self::override_files`],或自己建 [`ReadBudget`] 交给 [`Self::override_files_with`]。
pub fn sha1(&self, name: &str) -> PackResult<Option<String>> {
self.sha1_with(name, None)
}
/// 算 SHA-1 并从 `budget` 里扣减实际读出的字节(批量读取用)。
fn sha1_with(&self, name: &str, budget: Option<&ReadBudget>) -> PackResult<Option<String>> {
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
)));
}
if let Some(budget) = budget {
budget.spend(read as u64, name)?;
}
hasher.update(&buffer[..read]);
}
Ok(Some(hasher.finish()))
}
}
}
/// 收集一个覆盖目录下的文件(相对该目录的路径 + SHA-1 + 字节数),按路径排序。
///
/// 目录不存在时返回空表——"包没带 overrides"不是错误。
///
/// 这是**唯一会批量解压的 API**,所以它自己持有一个累计预算
/// (`PackLimits::max_uncompressed_bytes`),本次调用内逐条按实际读出的字节扣减:
/// 声明大小撒谎的包不会靠"很多条各自合规"把 CPU 时间耗光(r1 p3-4)。
///
/// **额度是"每次调用一份",不是"整包一份"**(r2 p3-b):格式读者若对多个覆盖目录各调一次
/// (mrpack 会连调三次:`overrides` / `client-overrides` / `server-overrides`),理论上限
/// 是 `max_uncompressed_bytes × 调用次数`。要整包共用一份额度,就自己建 [`ReadBudget`]
/// 并走 [`Self::override_files_with`];`open` 阶段的"声明总量"仍然是整包只判一次。
pub fn override_files(&self, dir: &str) -> PackResult<Vec<OverrideFile>> {
let budget = ReadBudget::new(self.limits.max_uncompressed_bytes);
self.override_files_with(dir, &budget)
}
/// 同 [`Self::override_files`],但用调用方给的累计预算(M111 想与别处的解压共用额度时用)。
pub fn override_files_with(
&self,
dir: &str,
budget: &ReadBudget,
) -> PackResult<Vec<OverrideFile>> {
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_with(&entry.name, Some(budget))?
.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)
}
}
@@ -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
}
}
@@ -0,0 +1,167 @@
//! 读取限额:条目数 / 解压总体积 / 单条目,以及两处封顶实现。
//!
//! # 两阶段限额(打开 vs 读取)
//!
//! 打开阶段([`check_limits`])只能拿**中央目录声明的大小**做判断——它会撒谎。
//! 因此读取阶段还有第二道:单条目按实际读出字节封顶([`read_capped`]),批量读取
//! ([`super::PackArchive::override_files`])再按**累计实际字节**从 [`ReadBudget`] 里扣减。
//! 没有预算的批量读取等于把 `max_uncompressed_bytes` 降级成"每条单独算"——一个 2 万条目、
//! 每条声明 1 字节却解出几十 MiB 的包能把 CPU/时间耗在这里(r1 p3-4)。
use std::cell::Cell;
use std::io::Read;
use super::super::error::{PackError, PackResult};
/// 默认条目数上限(一个巨型整合包 ≈ 数千条,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,
}
}
}
/// 一次批量读取的**累计**预算(读取阶段,按实际读出的字节扣减)。
///
/// 作用域是"拿到它的那次扫描":`PackArchive::override_files` 每次调用新建一份,
/// 所以要跨多次调用共享额度(例如整包只给一份 `max_uncompressed_bytes`),
/// 必须由调用方自建一个并交给 `override_files_with`(r2 p3-b)。
///
/// 为什么需要它:中央目录声明的大小可以撒谎,单个条目也被限制在
/// [`PackLimits::max_entry_bytes`],但"很多条各自合规"累起来仍可能远超
/// `max_uncompressed_bytes`。批量读取(`override_files`)持有一个预算,边读边扣。
#[derive(Debug)]
pub struct ReadBudget {
remaining: Cell<u64>,
}
impl ReadBudget {
/// 按总预算新建。
#[must_use]
pub fn new(limit: u64) -> Self {
Self {
remaining: Cell::new(limit),
}
}
/// 还剩多少字节。
#[must_use]
pub fn remaining(&self) -> u64 {
self.remaining.get()
}
/// 扣减一次实际读出的字节数;超额报 `pack_limits`。
///
/// 超额时预算**照样扣到 0**(这些字节已经从流里读出来了),后续调用会继续失败——
/// 不给"失败一次、下次又能读一点"的机会。
pub(super) fn spend(&self, bytes: u64, name: &str) -> PackResult<()> {
let remaining = self.remaining.get();
self.remaining.set(remaining.saturating_sub(bytes));
if bytes > remaining {
return Err(PackError::limits(format!(
"{name} 之后累计解压超过上限(本次还需 {bytes} 字节,预算已用尽)"
)));
}
Ok(())
}
}
/// 累计与单条目限额检查(打开阶段)。
pub(super) fn check_limits(
limits: &PackLimits,
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(())
}
/// 带封顶的读:中央目录里声明的大小可以撒谎,这里按**实际读出的字节**封顶。
///
/// 给了 `budget` 就同时从累计预算里扣(批量读取);单条读取给 `None`,
/// 只受 [`PackLimits::max_entry_bytes`] 约束。
pub(super) fn read_capped(
reader: &mut impl Read,
cap: u64,
budget: Option<&ReadBudget>,
name: &str,
) -> PackResult<Vec<u8>> {
let mut bytes = Vec::new();
let mut buffer = [0_u8; 64 * 1024];
let mut total = 0_u64;
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 > cap {
return Err(PackError::limits(format!(
"{name} 实际解压超过单条目上限 {cap}"
)));
}
if let Some(budget) = budget {
budget.spend(read as u64, name)?;
}
bytes.extend_from_slice(&buffer[..read]);
}
Ok(bytes)
}
+158
View File
@@ -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<Self> {
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::<Result<Vec<_>, _>>()
.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<Self> {
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<PackEntryInfo>,
limits: PackLimits,
) -> PackResult<Self> {
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,
})
}
}
+339
View File
@@ -0,0 +1,339 @@
//! 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 专属)。
//! - **跨格式导出不完整**(M112 的活):`modLoaders[].id` 只对 CF 来源是合法形状;
//! MultiMC 的 `net.fabricmc.fabric-loader` / 非加载器组件会被逐字写进去(CF 不认);
//! `overrides` 字段会照抄模型里的目录名(从 MultiMC 转过来是 `.minecraft`)。
//! 按 CF 语义 `overrides` 只是 zip 内**前缀**,导入器把该前缀下的内容铺到游戏目录,
//! 所以内容不会丢;但"前缀目录本身要不要跟着拷一层"取决于导入器实现(Prism/ULL 的实例根
//! 本身就叫 `.minecraft` 时可能多出一层)。同一份模型只有 `read` 出来的那个格式才保证往返。
//!
//! CF 的**文件指纹**(`MurmurHash2-32` + 空白过滤)是导出侧「能否被远程替代」的判据,见
//! [`fingerprint`]:它是"CF 格式知识",不属于导出流程,所以留在这里而不是 `addon::export`。
pub mod fingerprint;
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<String>,
#[serde(rename = "manifestVersion", default)]
manifest_version: Option<i64>,
#[serde(default)]
name: Option<String>,
#[serde(default)]
version: Option<String>,
#[serde(default)]
author: Option<String>,
#[serde(default)]
overrides: Option<String>,
#[serde(default)]
minecraft: Option<RawMinecraft>,
#[serde(default)]
files: Vec<RawFile>,
#[serde(flatten)]
extra: Map<String, Value>,
}
/// `minecraft` 对象。
#[derive(Debug, Default, Deserialize)]
struct RawMinecraft {
#[serde(default)]
version: Option<String>,
#[serde(rename = "modLoaders", default)]
mod_loaders: Vec<RawLoader>,
#[serde(flatten)]
extra: Map<String, Value>,
}
/// `minecraft.modLoaders[]` 的一条。
#[derive(Debug, Default, Deserialize)]
struct RawLoader {
#[serde(default)]
id: Option<String>,
#[serde(default)]
primary: bool,
#[serde(flatten)]
extra: Map<String, Value>,
}
/// `files[]` 的一条。
#[derive(Debug, Default, Deserialize)]
struct RawFile {
#[serde(rename = "projectID", default)]
project_id: Option<i64>,
#[serde(rename = "fileID", default)]
file_id: Option<i64>,
#[serde(default = "default_required")]
required: bool,
#[serde(flatten)]
extra: Map<String, Value>,
}
/// 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) -> PackResult<Option<&'static str>> {
// 文件不存在 ≠ 包坏了:没有 `manifest.json` 就是"不是本格式"。
let Some(value) = pack.read_json(METADATA_ENTRY)? else {
return Ok(None);
};
// 文件在、但不是对象 = 形状坏了(`pack_metadata`),不是"不认识"(r1 p3-2)。
let object = value
.as_object()
.ok_or_else(|| PackError::metadata(format!("{METADATA_ENTRY} 存在但不是 JSON 对象")))?;
let matched = match object.get("manifestType").and_then(Value::as_str) {
// 有标记:只认固定值(`manifest.json` 这名字太泛,光凭名字不能认领)。
Some(kind) => kind == MARKER,
// 没有标记的老包:要求形状对得上(有 minecraft 对象 + files 数组)。
None => {
object.get("minecraft").is_some_and(Value::is_object)
&& object.get("files").is_some_and(Value::is_array)
}
};
Ok(matched.then_some(METADATA_ENTRY))
}
fn check_overrides(&self, manifest: &PackManifest) -> PackResult<()> {
// CF 只有**一个** `overrides` 目录,且清单里只有一个字段能声明它 ⇒ 只表达得了
// 一个 Both 侧组。Client/Server 分侧是 mrpack 的东西,这里宁可拒也不猜(r1 p2-1)。
let mut both = 0_usize;
for group in &manifest.overrides {
if group.side != PackSide::Both {
return Err(PackError::unsupported(format!(
"CurseForge 只有一个 overrides 目录,表达不了 {} 侧的覆盖内容({} 个文件)",
group.side.as_str(),
group.files.len()
))
.with_hint("先按侧合并(或去掉不该带的组)再导出;分侧目录映射属 M112"));
}
both += 1;
}
if both > 1 {
return Err(PackError::unsupported(
"CurseForge 只有一个 overrides 目录字段,模型里却有多个 Both 侧覆盖组",
)
.with_hint("把它们合成一个组再导出;目录名映射属 M112"));
}
Ok(())
}
fn read(&self, pack: &PackArchive) -> PackResult<PackManifest> {
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();
// CF 的 `overrides` 字段缺省就是 `"overrides"`(规范默认值),`render` 也总会写它;
// 读侧因此**永远**产出一个 Both 侧组,读写才对称(dir 不存在时 files 为空表)。
let overrides_dir = raw
.overrides
.as_deref()
.filter(|dir| !dir.trim().is_empty())
.unwrap_or(DEFAULT_OVERRIDES);
let overrides_dir = normalize_dir(overrides_dir)?;
let overrides = vec![PackOverrides {
files: pack.override_files(&overrides_dir)?,
dir: overrides_dir,
side: PackSide::Both,
}];
Ok(PackManifest {
name,
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<String> {
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_for(Some(game_version)).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)
}
}
@@ -0,0 +1,262 @@
//! CurseForge 文件指纹(`MurmurHash2-32` + 空白字节过滤),导出侧的「能否被远程替代」判据之一。
//!
//! # 算法(A 级规范缺失,这里是 B 级实现 + 双向验证)
//!
//! ```text
//! data = 原文件去掉所有 0x09 / 0x0A / 0x0D / 0x20 字节
//! fingerprint = MurmurHash2_32(data, seed = 1, len = data 的长度)
//! ```
//!
//! 三条容易写错的点,逐条钉住:
//!
//! 1. **长度用过滤后的长度**参与初始态(`h = seed ^ len`),不是原文件长度;
//! 2. **seed 恒为 1**(MurmurHash2 的默认 seed 是 `0x9747b28c`,那是别的调用方的约定);
//! 3. 过滤是**逐字节**的,只在 0x09/0x0A/0x0D/0x20 上生效——不是"去空白"(`0x0B` 竖tab、
//! `0x0C` 换页都保留)。
//!
//! # 来源与验证
//!
//! CurseForge 官方只公开 `POST /v1/fingerprints/432` 端点,**没有公开算法**(A 级规范不存在)。
//! 这里的实现对齐 B 级参考 `HMCL CurseForgeRemoteAddonRepository.calculateFingerprint`
//! 与 `MurmurHash2.hash32(byte[], int, int)`(SMHasher `MurmurHash2.cpp` 的忠实转写)。
//!
//! **双向验证**(`tests` 模块):一组固定输入的金标准值由一份**独立的 Java 实现**(HMCL 两份
//! 源码的逐行移植,不在本仓库内)算出后写死在这里;Rust 侧同时跑「一次性字节序」与「流式文件」
//! 两条路径,两条都要与金标准相等。所以这一层是"两套实现 + 两个入口"的四向对齐,而不是自证。
//! 仍未验的部分:**真实 CF 文件**的官方指纹(要网络 + `x-api-key`,本任务环境离线)。
//!
//! # 哨兵值 `811513880`
//!
//! 这个值会误命中(HMCL issue #4597 的临时规避):无论文件是什么,只要算出这个数就不能当成
//! "已找到"。判据写成 [`is_sentinel`],由调用方(导出管线)在查表前短路。
use std::fs::File;
use std::io::{self, Read};
use std::path::Path;
/// 误命中的哨兵指纹(HMCL issue #4597)。命中时按「查不到」处理。
pub const SENTINEL: u32 = 811_513_880;
/// 被过滤掉的四个字节:水平制表、换行、回车、空格。
pub const FILTERED: [u8; 4] = [0x09, 0x0a, 0x0d, 0x20];
/// 指纹查表时是否该按「没找到」处理。
#[must_use]
pub const fn is_sentinel(fingerprint: u32) -> bool {
fingerprint == SENTINEL
}
/// 这个字节参与计算吗(`0x09` / `0x0A` / `0x0D` / `0x20` 不参与)。
#[must_use]
pub const fn keeps(byte: u8) -> bool {
!matches!(byte, 0x09 | 0x0a | 0x0d | 0x20)
}
/// 对**内存里的一份内容**算指纹(过滤 + 一次性哈希)。
///
/// 小文件与测试用;大文件请用 [`fingerprint_file`](两遍流式读,不把整份读进内存)。
#[must_use]
pub fn fingerprint_bytes(bytes: &[u8]) -> u32 {
let filtered: Vec<u8> = bytes.iter().copied().filter(|byte| keeps(*byte)).collect();
murmur2(&filtered)
}
/// 对一个**文件**算指纹:两遍流式读(先数过滤后长度,再喂哈希)。
///
/// MurmurHash2 的初始态要事先知道总长度,所以必须两遍;这里不把文件读进内存(mod jar 可以
/// 有几百 MB,整包导出要对每个文件算一次)。
pub fn fingerprint_file(path: &Path) -> io::Result<u32> {
let mut filtered_length = 0_u64;
let mut buffer = vec![0_u8; 1024 * 1024];
let mut file = File::open(path)?;
loop {
let read = file.read(&mut buffer)?;
if read == 0 {
break;
}
filtered_length += buffer[..read].iter().filter(|byte| keeps(**byte)).count() as u64;
}
// 长度参数是 u32(MurmurHash2-32 的初始态),超了要报错而不是截断。
let length = u32::try_from(filtered_length).map_err(|_| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("文件过滤后仍有 {filtered_length} 字节,超出指纹长度参数(u32)上限"),
)
})?;
let mut hasher = Stream::new(length, 1);
file = File::open(path)?;
loop {
let read = file.read(&mut buffer)?;
if read == 0 {
break;
}
hasher.update(&buffer[..read]);
}
Ok(hasher.finish())
}
/// MurmurHash2 32 位,seed 固定 1(CurseForge 的约定)。
///
/// 实现是 SMHasher `MurmurHash2.cpp` 的形状:按 4 字节小端块混合,尾部 1–3 字节按小端补齐后
/// 再乘一次;收尾两次移位混合。**不在这里做字节过滤**(调用方先过滤,见 [`fingerprint_bytes`])。
#[must_use]
pub fn murmur2(data: &[u8]) -> u32 {
const M: u32 = 0x5bd1_e995;
const R: u32 = 24;
let mut h = 1_u32 ^ (data.len() as u32);
let blocks = data.len() / 4;
for index in 0..blocks {
let mut k = u32::from_le_bytes([
data[index * 4],
data[index * 4 + 1],
data[index * 4 + 2],
data[index * 4 + 3],
]);
k = k.wrapping_mul(M);
k ^= k >> R;
k = k.wrapping_mul(M);
h = h.wrapping_mul(M);
h ^= k;
}
let tail = &data[blocks * 4..];
if !tail.is_empty() {
let mut packed = 0_u32;
for (shift, byte) in tail.iter().enumerate() {
packed |= u32::from(*byte) << (8 * shift);
}
h ^= packed;
h = h.wrapping_mul(M);
}
h ^= h >> 13;
h = h.wrapping_mul(M);
h ^= h >> 15;
h
}
/// 流式 MurmurHash2:长度先知道,字节可以分任意多段喂进来。
struct Stream {
hash: u32,
tail: u32,
tail_len: u32,
}
impl Stream {
const fn new(length: u32, seed: u32) -> Self {
Self {
hash: seed ^ length,
tail: 0,
tail_len: 0,
}
}
fn update(&mut self, bytes: &[u8]) {
const M: u32 = 0x5bd1_e995;
const R: u32 = 24;
for byte in bytes {
if !keeps(*byte) {
continue;
}
self.tail |= u32::from(*byte) << (8 * self.tail_len);
self.tail_len += 1;
if self.tail_len == 4 {
let mut k = self.tail;
k = k.wrapping_mul(M);
k ^= k >> R;
k = k.wrapping_mul(M);
self.hash = self.hash.wrapping_mul(M);
self.hash ^= k;
self.tail = 0;
self.tail_len = 0;
}
}
}
fn finish(mut self) -> u32 {
const M: u32 = 0x5bd1_e995;
if self.tail_len != 0 {
self.hash ^= self.tail;
self.hash = self.hash.wrapping_mul(M);
}
self.hash ^= self.hash >> 13;
self.hash = self.hash.wrapping_mul(M);
self.hash ^= self.hash >> 15;
self.hash
}
}
#[cfg(test)]
mod tests {
use super::*;
/// 金标准值由一份独立的 Java 实现(HMCL `MurmurHash2.hash32` + `calculateFingerprint` 的
/// 逐行移植)对同一批输入算出。改了算法这两列会同时红——这就是"双向验证"的用处。
const GOLDEN: &[(&str, &[u8], u32)] = &[
("空文件", b"", 1_540_447_798),
("只有空白", b" \t\r\n \t\r\n", 1_540_447_798),
(
"夹具 jar 存根",
b"PK\x03\x04 hand-made jar stub",
489_715_614,
),
("混入空白", b"\x00\t\n\r AB\tC\n\r \x7f\xff", 3_734_724_774),
(
"ascii 36",
b"abcdefghijklmnopqrstuvwxyz0123456789",
3_026_742_571,
),
];
fn pattern(length: usize) -> Vec<u8> {
(0..length).map(|index| (index & 0xff) as u8).collect()
}
#[test]
fn matches_the_independent_implementation() {
for (label, bytes, expected) in GOLDEN {
assert_eq!(fingerprint_bytes(bytes), *expected, "{label}(一次性路径)");
}
assert_eq!(
fingerprint_bytes(&pattern(512)),
3_451_065_282,
"pattern-512"
);
assert_eq!(fingerprint_bytes(&pattern(5)), 813_985_283, "pattern-5");
assert_eq!(fingerprint_bytes(&pattern(7)), 2_174_351_703, "pattern-7");
}
#[test]
fn filtered_length_enters_the_initial_state() {
// 原文件长度 8、过滤后长度 0:与空文件同值。用原长度当 len 会得到另一个数(这里不是)。
assert_eq!(fingerprint_bytes(b" \t\r\n \t\r\n"), fingerprint_bytes(b""),);
assert_ne!(fingerprint_bytes(b"aaaaaaaa"), fingerprint_bytes(b"aaaa"),);
}
#[test]
fn only_the_four_documented_bytes_are_dropped() {
assert!(!keeps(0x09) && !keeps(0x0a) && !keeps(0x0d) && !keeps(0x20));
// 竖 tab、换页、NUL 都保留:过滤是四个字节,不是"去空白"。
assert!(keeps(0x0b) && keeps(0x0c) && keeps(0x00));
}
#[test]
fn streaming_and_one_shot_agree_at_every_split() {
let data = pattern(64);
let expected = fingerprint_bytes(&data);
for split in 0..=data.len() {
let mut hasher =
Stream::new(data.iter().filter(|byte| keeps(**byte)).count() as u32, 1);
hasher.update(&data[..split]);
hasher.update(&data[split..]);
assert_eq!(hasher.finish(), expected, "split={split}");
}
}
#[test]
fn sentinel_is_treated_as_missing() {
assert!(is_sentinel(SENTINEL));
assert!(!is_sentinel(0));
assert!(!is_sentinel(SENTINEL + 1));
}
}
+161
View File
@@ -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<String>,
}
impl PackError {
/// 构造。
#[must_use]
pub fn new(kind: PackErrorKind, message: impl Into<String>) -> Self {
Self {
kind,
message: message.into(),
hint: None,
}
}
/// 没格式认领 / 模型表达不了该格式。
#[must_use]
pub fn unsupported(message: impl Into<String>) -> Self {
Self::new(PackErrorKind::Unsupported, message)
}
/// 清单形状不对 / 必填字段缺失。
#[must_use]
pub fn metadata(message: impl Into<String>) -> Self {
Self::new(PackErrorKind::Metadata, message)
}
/// 路径穿越。
#[must_use]
pub fn traversal(message: impl Into<String>) -> Self {
Self::new(PackErrorKind::Traversal, message)
}
/// 符号链接条目。
#[must_use]
pub fn symlink(message: impl Into<String>) -> Self {
Self::new(PackErrorKind::Symlink, message)
}
/// 超过限额。
#[must_use]
pub fn limits(message: impl Into<String>) -> Self {
Self::new(PackErrorKind::Limits, message)
}
/// 本地 IO。
#[must_use]
pub fn io(message: impl Into<String>) -> Self {
Self::new(PackErrorKind::Io, message)
}
/// 补一句提示。
#[must_use]
pub fn with_hint(mut self, hint: impl Into<String>) -> 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<std::io::Error> for PackError {
fn from(error: std::io::Error) -> Self {
Self::io(error.to_string())
}
}
impl From<zip::result::ZipError> 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<serde_json::Error> for PackError {
fn from(error: serde_json::Error) -> Self {
Self::metadata(format!("JSON 解析失败:{error}"))
}
}
/// `pack` 模块内的结果别名。
pub type PackResult<T> = Result<T, PackError>;
+323
View File
@@ -0,0 +1,323 @@
//! 格式扩展点:[`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` 有一条反序注册的用例)。
//!
//! # 写:默认实现**只保证同格式往返**(这条是给 M111/M112 的边界声明)
//!
//! 默认实现([`PackFormat::write`])替所有格式做同一件事:写清单 entry([`PackFormat::render`]
//! 的产物)+ 把 `manifest.overrides` 的 `(dir, side, files)` **原样**搬进 zip。因此新格式只要
//! 实现 `metadata_entry` + `render` 就同时获得导出能力;只读格式(MultiMC)不实现
//! `metadata_entry`,写会得到明确的 `crate::addon::pack::error::PackErrorKind::Unsupported`。
//!
//! **它不做目标格式映射**,具体缺四件事(都属于 M112,别以为这一层已经做了):
//!
//! 1. **目录名映射**:mrpack 的覆盖目录名由 `side` 决定(`overrides` / `client-overrides` /
//! `server-overrides`),CF 只有一个 `overrides`(名字由清单给)。`check_overrides` 只保证
//! "布局表达不了就报错",不负责改名;
//! 2. **`side` 合并/分派**:CF 表达不了 Client/Server 分侧,现在是**拒**而不是合并;
//! 3. **加载器 id 规范化**:CF 要 `forge-47.2.0` / `fabric-0.15.11`,mrpack 要
//! `forge` / `fabric-loader` 这类键。`render` 只按模型里的 `raw`/`kind` 写,跨格式转出来的
//! id 形态可能不被目标平台接受(尤其 MultiMC 的 `net.fabricmc.fabric-loader`);
//! 4. **`extra` 不按格式分桶**:模型只有一份未知字段集合,跨格式导出会把别的格式的未知键
//! 一起带过去。
//!
//! 换句话说:`X.read` → `X.write` 是可靠往返(有逐字段相等的测试);`X.read` → `Y.write`
//! 要么被 `check_overrides` 拒掉,要么"结构合法但语义不完整"。**要跨格式导出,先做映射。**
//!
//! # 映射层已经在了(M112)
//!
//! [`crate::addon::export`] 就是那层映射:它把实例目录扫成模型(覆盖目录统一到 `overrides/`、
//! 禁用位落成路径后缀 `.disabled`、`dependencies` 从实例快照取),再用
//! [`PackPayload::with_sources`] 把"包内条目名 → 实例里的源文件"交给本模块写盘。所以本文件
//! 仍然不做映射(那是格式之上的事),但"跨格式导出无路可走"已经不再成立。
use std::fmt::Debug;
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;
mod payload;
mod write;
/// 导出时的覆盖内容来源在 [`payload`] 子模块里(`payload.rs`,与本文件拆开是为了守 400 行):
/// [`PackPayload::metadata_only`] 只写清单、[`PackPayload::from_root`] 按
/// `<root>/<dir>/<path>` 取文件、[`PackPayload::with_sources`] 按**包内条目名**查表
/// (跨格式导出 / 从实例导出用,M112)。
pub use payload::PackPayload;
/// 一种整合包格式(导入 + 导出的全部能力都在这里)。
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` 与本次命中的证据)。
///
/// **清单在、但读不出来**时必须返回 `Err`(`pack_metadata` 一类),不要用
/// `.ok().flatten()` 吞掉:那样"包坏了"会被上层报成"没有格式认领",M111 的失败分级
/// 就会把可以重下文件的包当成不支持(r1 p3-2)。
fn detect(&self, pack: &PackArchive) -> PackResult<Option<&'static str>>;
/// 读成中间模型。
fn read(&self, pack: &PackArchive) -> PackResult<PackManifest>;
/// 把模型渲染成清单 JSON(未知字段在这里回写)。
fn render(&self, manifest: &PackManifest) -> PackResult<String>;
/// 这份模型的**覆盖目录集合**能不能被本格式表达(默认 `write` 在写第一个字节前调用)。
///
/// 默认实现不限制("目录名原样写进包里"对同格式往返是对的)。凡是有**固定目录布局**
/// 的格式必须收紧:mrpack 的三个目录由 `side` 决定,CF 只有一个 `overrides` 目录。
/// 不收紧的后果不是报错而是**静默丢内容**——把一份 MultiMC 实例写成 `.mrpack` 时,
/// 文件会落在 `.minecraft/...`,而 mrpack 导入器只认
/// `overrides/` / `client-overrides/` / `server-overrides/`,读出来 mods 数量为 0(r1 p2-1)。
fn check_overrides(&self, manifest: &PackManifest) -> PackResult<()> {
let _ = manifest;
Ok(())
}
/// 导出一个整包(清单 + 覆盖内容)。
///
/// 默认实现做四件事:①只读格式直接拒;②[`Self::check_overrides`] 挡跨格式导出;
/// ③`render` 出清单;④**先按声明尺寸预检 ZIP64 阈值**,再写进**临时文件**并 `rename` 到
/// `target`(失败不留半个包)。第④步的细节见 `format::write` 模块文档。
///
/// # 只保证同格式往返(写给 M111/M112)
///
/// 默认 `write` 的覆盖目录策略是"把 `manifest.overrides` 里 `(dir, side, files)`
/// 原样再打一遍",也就是"读出来的包按原样打包"。它**不做**目标格式的目录映射、`side`
/// 分派、加载器 id 规范化,也**不按格式分桶** `extra`。所以:
///
/// - 同格式往返(`X.read` → `X.write`)可靠,有逐字段相等的测试;
/// - 跨格式导出(`X.read` → `Y.write`)在布局不同时被 [`Self::check_overrides`] 拒掉;
/// 但"布局恰好兼容、语义不兼容"的部分(加载器 id 形态、`extra` 里别的格式的未知键)
/// **仍会被原样带过去**——那是 M112 的活,这一层不替谁做映射。
fn write(
&self,
manifest: &PackManifest,
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 格式"));
};
// 先判"能不能表达",再碰磁盘:跨格式导出宁可报错,也不产出空包。
self.check_overrides(manifest)?;
// 模型可能来自外部构造:搬内容前把每条路径重查一遍。
manifest.check_paths()?;
let json = self.render(manifest)?;
// 临时文件 + rename:中途失败时目标路径上不会留下"看起来能用"的半个 zip(r1 p3-1)。
let temp = write::temporary_path(target);
match write::container(
self.id(),
entry,
&json,
manifest,
payload,
&temp,
zip::ZIP64_BYTES_THR,
) {
Ok(()) => std::fs::rename(&temp, target).map_err(|error| {
let _ = std::fs::remove_file(&temp);
PackError::io(format!(
"把 {} 改名成 {} 失败:{error}",
temp.display(),
target.display()
))
}),
Err(error) => {
let _ = std::fs::remove_file(&temp);
Err(error)
}
}
}
}
/// 一次探测的结果。
#[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(诊断"这包不标准"用;**只看命中**,单个格式的读错误被忽略——
/// 要区分"包坏了"与"不认识"请用 [`Self::detect`])。
#[must_use]
pub fn detect_all(&self, pack: &PackArchive) -> Vec<&'static str> {
self.formats
.iter()
.filter(|format| matches!(format.detect(pack), Ok(Some(_))))
.map(|format| format.id())
.collect()
}
/// 按优先级选第一个命中的格式。
///
/// 错误语义(M111 的失败分级依赖这两条的区别):
///
/// - **一个都不命中、但某个格式的清单读不出来** → 返回那个格式的错误
/// (`pack_metadata` 一类:包坏了,换格式也没用);
/// - **一个都不命中、也没有任何格式的清单在场** → `pack_unsupported`
/// (真的不认识这份包);
/// - 命中优先于失败:包里躺着一份别人的坏 `manifest.json` 不影响真的 `.mrpack` 被认出来。
pub fn detect(&self, pack: &PackArchive) -> PackResult<PackMatch> {
let mut matched: Vec<(&'static dyn PackFormat, &'static str)> = Vec::new();
// 命中优先于"某个格式的清单坏了":包里躺着一份别人的坏 manifest.json 不该毁掉它。
let mut failures: Vec<PackError> = Vec::new();
for format in self.formats.iter().copied() {
match format.detect(pack) {
Ok(Some(evidence)) => matched.push((format, evidence)),
Ok(None) => {}
// 清单在、但读不出来 = 包坏了(`pack_metadata`),不是"不认识这个格式"。
Err(error) => failures.push(error),
}
}
if matched.is_empty() {
if let Some(error) = failures.into_iter().next() {
return Err(error);
}
let tried = self.ids().join(" / ");
return Err(
PackError::unsupported(format!("没有格式认领这份整合包(试过:{tried})"))
.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))
}
}
@@ -0,0 +1,70 @@
//! 导出时的覆盖内容来源:[`PackPayload`]。
//!
//! # 两种来源
//!
//! | 构造 | 用在哪 |
//! |------|--------|
//! | [`PackPayload::from_root`] | 同格式往返:包内 `overrides/mods/x.jar` 就是 `<root>/overrides/mods/x.jar` |
//! | [`PackPayload::with_sources`] | 跨格式 / 从实例导出:包内路径由模型决定,源文件在实例目录的任意位置 |
//!
//! 第二种是 M112 需要的形状:实例里的 `mods/x.jar` 要落到包里的 `overrides/mods/x.jar`
//! (CF 与 mrpack 都是这个前缀),路径**不能靠拼**(拼接只能表达"目录同名")。所以来源表
//! 以**包内条目名**为键:`{"overrides/mods/x.jar": <实例>/mods/x.jar}`。
//!
//! 查不到映射时退回 `root/dir/path`;两者都没有就报 `pack_unsupported`(不写空内容、不猜)。
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
/// 导出时的覆盖内容来源。
#[derive(Debug, Clone, Copy)]
pub struct PackPayload<'a> {
root: Option<&'a Path>,
sources: Option<&'a BTreeMap<String, PathBuf>>,
}
impl<'a> PackPayload<'a> {
/// 只写清单,不搬覆盖内容。
#[must_use]
pub const fn metadata_only() -> Self {
Self {
root: None,
sources: None,
}
}
/// 覆盖内容从 `root` 取(`<root>/<dir>/<path>`,同格式往返用)。
#[must_use]
pub const fn from_root(root: &'a Path) -> Self {
Self {
root: Some(root),
sources: None,
}
}
/// 覆盖内容按**包内条目名**查表(`overrides/mods/x.jar` → 实例里的绝对/相对路径)。
///
/// `root` 仍然要给:映射没覆盖到的条目退回 `<root>/<dir>/<path>`,
/// 这样"大部分文件原位、少数文件改名"的混合场景不必手写全表。
#[must_use]
pub const fn with_sources(root: &'a Path, sources: &'a BTreeMap<String, PathBuf>) -> Self {
Self {
root: Some(root),
sources: Some(sources),
}
}
/// 来源根目录。
#[must_use]
pub const fn root(&self) -> Option<&'a Path> {
self.root
}
/// 包内条目名 → 源文件(没有来源表或表里没这条时返回 `None`)。
#[must_use]
pub fn source_for(&self, entry_name: &str) -> Option<&'a Path> {
self.sources
.and_then(|sources| sources.get(entry_name))
.map(PathBuf::as_path)
}
}
+346
View File
@@ -0,0 +1,346 @@
//! 把模型写成 zip:**声明尺寸预检 → 写清单 → 逐条搬覆盖内容**(`format.rs` 只留 trait 与注册表)。
//!
//! # 为什么预检必须在读第一个字节之前
//!
//! 一条写死的边界(research/18-p2 §8.6,A 级 `export_mrpack.rs:305-311`):**不写 ZIP64 包**。
//! Modrinth 官方导出撞到阈值直接报错,HMCL 那边 `(int)` 截断只打 warning 是反面教材。
//!
//! 早先的实现是「读一个文件 → 累加 → 比较」:真要导出一个 4 GiB 覆盖文件的实例,
//! `std::fs::read` 先把它整份读进内存,闸门**永远够不着**——用户看到的是内存打爆或 IO 错,
//! 而不是"包太大,把这个文件移出实例"。所以现在先跑一遍 `metadata().len()` 累加
//! (`preflight`),超阈值在任何内容被读进内存**之前**就报 `pack_limits`。
//!
//! 写循环里那份增量检查保留,当第二道:预检与写入之间文件可能变大(实例目录不是快照)。
//! **第二道自己从清单长度起算**,绝不拿预检的返回值当基数——两者合起来会重复累计,
//! 于是真实总量只要过半阈值(~2.15 GiB)就被误拒,而且拒在内容已经读出来之后(P2-1)。
//!
//! # 内容按块搬运,不整份读进内存
//!
//! 覆盖文件用 1 MiB 缓冲流式写进 zip(`COPY_CHUNK`)。这不是优化癖:`std::fs::read` 会把
//! 一份 2.5 GiB 的 mod 整份摊在内存里,恰恰破坏"闸在内容进内存之前"这条性质——即使判据
//! 后来修对了,误拒也发生在把几个 GiB 读进内存之后。流式之后第二道闸看的是**已写字节数**,
//! 与内存占用无关。
//!
//! # 阈值与声明口径
//!
//! 判据是**未压缩声明的总量**(清单 JSON + 每个覆盖文件的字节数)≥ `zip::ZIP64_BYTES_THR`
//! (≈4 GiB)。zip 条目头与中央目录的几十字节开销不计——这个常数本身就是近似边界,
//! 官方实现同样按这个粗粒度判。
use std::fs::File;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use super::super::error::{PackError, PackResult};
use super::super::manifest::PackManifest;
use super::super::path::{normalize_dir, normalize_file};
use super::payload::PackPayload;
/// 搬覆盖内容时的缓冲大小(流式,不把文件整份读进内存)。
const COPY_CHUNK: usize = 1024 * 1024;
/// 与目标同目录的临时文件路径(同卷 `rename` 才能原子替换)。
pub(super) fn temporary_path(target: &Path) -> PathBuf {
let mut name = target.file_name().map_or_else(
|| std::ffi::OsString::from("pack"),
std::ffi::OsStr::to_os_string,
);
name.push(format!(".{}.part", std::process::id()));
target.with_file_name(name)
}
/// 真正写 zip 的那一段(失败了由调用方删临时文件)。
pub(super) fn container(
format_id: &str,
entry: &'static str,
json: &str,
manifest: &PackManifest,
payload: PackPayload<'_>,
temp: &Path,
limit: u64,
) -> PackResult<()> {
// 先判总量再碰磁盘:超限时临时文件都不建,更不会读进一份 4 GiB 的内容。
// **返回值只属于预检**:写循环有自己的计数器(`Written`),拿它当基数会把全量声明尺寸
// 再累加一遍 ⇒ 过拒窗口(P2-1)。
let _ = preflight(json.len() as u64, manifest, payload, limit)?;
let file = File::create(temp)
.map_err(|error| PackError::io(format!("创建 {} 失败:{error}", temp.display())))?;
let mut writer = zip::ZipWriter::new(file);
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated)
.unix_permissions(0o644);
writer
.start_file(entry, options)
.map_err(|error| PackError::io(format!("写 {entry} 失败:{error}")))?;
writer
.write_all(json.as_bytes())
.map_err(|error| PackError::io(format!("写 {entry} 失败:{error}")))?;
// 第二道闸的计数器:**从清单长度重新起算**,与预检的结果无关(见 `Written`)。
let mut written = Written::new(json.len() as u64, limit);
let mut buffer = vec![0_u8; COPY_CHUNK];
for group in &manifest.overrides {
let dir = normalize_dir(&group.dir)?;
for override_file in &group.files {
let path = normalize_file(&override_file.path)?;
let name = format!("{dir}/{path}");
let source = source_for(payload, &dir, &path)?;
let mut source_file = File::open(&source).map_err(|error| {
PackError::io(format!("读覆盖文件 {} 失败:{error}", source.display()))
})?;
writer
.start_file(name.clone(), options)
.map_err(|error| PackError::io(format!("写 {name} 失败:{error}")))?;
loop {
let read = source_file.read(&mut buffer).map_err(|error| {
PackError::io(format!("读覆盖文件 {} 失败:{error}", source.display()))
})?;
if read == 0 {
break;
}
writer
.write_all(&buffer[..read])
.map_err(|error| PackError::io(format!("写 {name} 失败:{error}")))?;
written.spend(read as u64)?;
}
}
}
writer
.finish()
.map_err(|error| PackError::io(format!("收尾 zip 失败:{error}(格式 {format_id})")))?;
Ok(())
}
/// 写盘前的**声明尺寸预检**:只量 `metadata().len()`,不读内容。
///
/// 返回值是"清单 + 全部覆盖文件的声明总量",**只给测试与诊断看**。写循环有自己的计数器
/// (从清单长度起算)——曾经把它直接当累加基数,结果每个文件的字节被算两遍,真实总量过半
/// 阈值(~2.15 GiB)就被误拒(P2-1,已有 2.5 GB 的端到端用例钉住这个窗口)。
fn preflight(
json_len: u64,
manifest: &PackManifest,
payload: PackPayload<'_>,
limit: u64,
) -> PackResult<u64> {
let mut total = json_len;
guard_with(total, limit)?;
for group in &manifest.overrides {
let dir = normalize_dir(&group.dir)?;
for override_file in &group.files {
let path = normalize_file(&override_file.path)?;
let source = source_for(payload, &dir, &path)?;
// 缺文件 / 读不到元数据在这里就报(与"读内容失败"同一个 `pack_io` 口径:
// 目标路径上不会留下半个包,临时文件由调用方删)。
let length = std::fs::metadata(&source)
.map_err(|error| {
PackError::io(format!("读覆盖文件 {} 失败:{error}", source.display()))
})?
.len();
total += length;
guard_with(total, limit)?;
}
}
Ok(total)
}
/// 单条覆盖文件的来源:来源表优先(跨格式导出用),否则按 `<root>/<dir>/<path>` 拼。
fn source_for(payload: PackPayload<'_>, dir: &str, path: &str) -> PackResult<PathBuf> {
let name = format!("{dir}/{path}");
if let Some(source) = payload.source_for(&name) {
return Ok(source.to_path_buf());
}
let Some(root) = payload.root() else {
return Err(PackError::unsupported(format!(
"导出需要覆盖内容({name}),但既没有来源映射也没有 payload 根目录"
)));
};
Ok(root.join(dir).join(path))
}
/// 写循环的第二道闸(**已写出**的未压缩字节数)。
///
/// 起点是**清单 JSON 的长度**,与 `preflight` 的返回值无关:两者混用会把每个文件的字节算
/// 两遍,真实总量过半阈值(~2.15 GiB)就被误拒(P2-1)。类型上也没法混用——`Written`
/// 由 `u64` 构造,而预检的返回值是"全量声明尺寸",只有显式取值才拿得到。
struct Written {
total: u64,
limit: u64,
}
impl Written {
const fn new(json_len: u64, limit: u64) -> Self {
Self {
total: json_len,
limit,
}
}
/// 记一笔实际写出的字节,并在**每一块之后**过闸(大文件因此能在中途被拦下)。
fn spend(&mut self, bytes: u64) -> PackResult<()> {
self.total = self.total.saturating_add(bytes);
guard_with(self.total, self.limit)
}
}
/// ZIP64 阈值闸(`pack_limits`):`total` **达到**阈值就拒(`>=` 不是 `>`,官方实现也是
/// "撞上就不写")。
///
/// 生产路径的 `limit` 恒为 `zip::ZIP64_BYTES_THR`(由 `format.rs` 传下来);把它做成参数是为了
/// 能用几十字节的小数值钉住"边界在 `>=` 上"和"写循环不许重复累计"这两件事——真造 4 GiB 的
/// 包要几十秒 CPU 与一大片内存/页面文件,本机资源纪律下不经默认套件跑(塔台 2026-11-07)。
fn guard_with(total: u64, limit: u64) -> PackResult<()> {
if total >= limit {
return Err(PackError::limits(format!(
"包体未压缩总量已超过 {limit} 字节(ZIP64 阈值),拒绝写出一个需要 ZIP64 的整合包"
))
.with_hint("把超大的覆盖文件移出实例后再导出;mrpack/CF 都不接受 ZIP64"));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::addon::pack::manifest::{OverrideFile, PackOverrides, PackSide};
#[test]
fn the_threshold_is_reached_not_exceeded() {
// `>=` 而不是 `>`:恰好等于阈值就已经需要 ZIP64 了。
assert!(guard_with(99, 100).is_ok());
let error = guard_with(100, 100).expect_err("等于阈值必须拒");
assert_eq!(
error.kind(),
super::super::super::error::PackErrorKind::Limits
);
assert!(error.message().contains("100"), "{error}");
assert!(guard_with(101, 100).is_err());
}
#[test]
fn the_second_gate_counts_from_the_manifest_length_not_the_preflight_total() {
// P2-1 的窗口:真实总量在 [阈值一半, 阈值) 之间的包**不许**被拒。这一条不真写
// 2.5 GB(那要 ~70 秒 deflate 与一大片内存/页面文件),而是直接把窗口里的字节数
// 喂给第二道闸的计数器——它的起点是清单长度,所以只有"把预检的全量 total 当基数"
// 那种写法才会在这里红(红法是 `spend` 返回 Limits)。
let half = zip::ZIP64_BYTES_THR / 2;
let mut written = Written::new(28, zip::ZIP64_BYTES_THR);
written
.spend(2_500_000_000)
.expect("2.5 GB 没到 4 GiB,第二道闸不许拒");
assert!(2_500_000_000 > half, "样本要落在过拒窗口里");
// 对照:撞上阈值必须拒,且正好在阈值处(不是超一点点才拒)。
let mut written = Written::new(28, zip::ZIP64_BYTES_THR);
let error = written
.spend(zip::ZIP64_BYTES_THR)
.expect_err("撞阈值必须拒");
assert_eq!(
error.kind(),
super::super::super::error::PackErrorKind::Limits
);
}
#[test]
fn the_container_does_not_double_count_the_preflight_total() {
// 容器层的窗口回归钉(P2-1),**用几十字节的 limit 把窗口缩小到可测尺寸**:
// `json` 30 字节 + 覆盖文件 40 字节 = 声明总量 70。
// - 正确实现:第二道闸从 30 起算,写到 70 也不到 100 ⇒ 导出成功;
// - P2-1 的写法(把预检返回的全量 70 当基数):写循环再累加 40 ⇒ 110 ≥ 100 ⇒ 误拒。
// 真造 2.5 GB 才能暴露同一个 bug,本机资源纪律下不跑(那条留成 `zip64.rs` 里的
// `#[ignore]` 用例)。
let root = std::env::temp_dir().join(format!("ull-double-count-{}", std::process::id()));
std::fs::create_dir_all(root.join("overrides/mods")).expect("建目录");
std::fs::write(root.join("overrides/mods/a.bin"), [0_u8; 40]).expect("写覆盖文件");
// 清单很短,limit 取"声明总量 + 20 字节"的余量:正确实现只写到 70,重复累计会写到 110。
let json = "{\"name\":\"0123456789012345\"}";
let declared = json.len() as u64 + 40;
let limit = declared + 20;
let mut manifest = PackManifest::new("double count");
manifest.overrides.push(PackOverrides {
dir: "overrides".to_string(),
side: PackSide::Both,
files: vec![OverrideFile {
path: "mods/a.bin".to_string(),
sha1: String::new(),
size: 40,
}],
});
let target = root.join("out.zip");
container(
"curseforge",
"manifest.json",
json,
&manifest,
PackPayload::from_root(&root),
&target,
limit,
)
.expect("声明总量没到 limit:写循环不许把预检的 total 重复累计");
// 对照:limit 降到声明总量本身 ⇒ 预检就该拒,且临时文件都没建。
let refused = root.join("refused.zip");
let error = container(
"curseforge",
"manifest.json",
json,
&manifest,
PackPayload::from_root(&root),
&refused,
declared,
)
.expect_err("声明总量达到 limit 必须拒");
assert_eq!(
error.kind(),
super::super::super::error::PackErrorKind::Limits
);
assert!(!refused.exists(), "预检失败时目标路径不许出现");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn the_preflight_measures_declared_sizes_not_content() {
// 预检必须只量 `metadata().len()`:这一条用"小文件 + 3 GiB 稀疏文件"钉住,
// 真读内容的话要么慢要么直接吃 3 GiB 内存。
let root = std::env::temp_dir().join(format!("ull-preflight-{}", std::process::id()));
std::fs::create_dir_all(root.join("overrides/mods")).expect("建目录");
std::fs::write(root.join("overrides/mods/small.jar"), b"0123456789").expect("写小文件");
let huge = root.join("overrides/mods/huge.jar");
let file = std::fs::File::create(&huge).expect("建稀疏文件");
file.set_len(3 * 1024 * 1024 * 1024).expect("拉长(稀疏)");
drop(file);
let mut manifest = PackManifest::new("preflight");
manifest.overrides.push(PackOverrides {
dir: "overrides".to_string(),
side: PackSide::Both,
files: vec![
OverrideFile {
path: "mods/small.jar".to_string(),
sha1: String::new(),
size: 10,
},
OverrideFile {
path: "mods/huge.jar".to_string(),
sha1: String::new(),
size: 0,
},
],
});
let total = preflight(
7,
&manifest,
PackPayload::from_root(&root),
zip::ZIP64_BYTES_THR,
)
.expect("3 GiB 总量仍在阈值之下,预检不许误伤");
assert_eq!(
total,
7 + 10 + 3 * 1024 * 1024 * 1024,
"清单 + 每个覆盖文件的声明尺寸"
);
let _ = std::fs::remove_dir_all(&root);
}
}
+395
View File
@@ -0,0 +1,395 @@
//! 与格式无关的中间模型 [`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` 后缀(官方,本仓库采这条) | [`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`)。
///
/// **不是禁用位**:真实语义是"安装时用户可取消勾选",装回来默认仍然启用。
/// 禁用是两回事,在 mrpack 里表达为路径后缀 `.disabled`(见 [`super::PackFile::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` 拼。等于
/// `curse_id_for(None)`——跨格式导出请用带游戏版本的那个,见 [`Self::curse_id_for`]。
#[must_use]
pub fn curse_id(&self) -> Option<String> {
self.curse_id_for(None)
}
/// 写成 CF 的 `id`,并按 CF 官方导出器的写法处理 **NeoForge 的 MC 前缀特例**。
///
/// 证据(A 级 `FlamePackExportTask.cpp:404-410`,转述见 research/18 §8.2):Prism 的 CF
/// 导出在 MC **1.20.1** 上写 `neoforge-1.20.1-<ver>`,其它版本写 `neoforge-<ver>`。
/// 原因是 NeoForge 在 1.20.1 上沿用 Forge 的 `47.x` 版本号,不写 MC 版本就分不清代际。
/// 读侧(`split_curse_id`)不剥这个前缀——那是 M111 的 `strip_mc_prefix` 的活。
#[must_use]
pub fn curse_id_for(&self, mc_version: Option<&str>) -> Option<String> {
if !self.raw.is_empty() {
return Some(self.raw.clone());
}
let kind = self.kind?;
let version = self.version.as_deref()?;
if kind == LoaderKind::NeoForge && mc_version == Some("1.20.1") {
return Some(format!("{}-1.20.1-{version}", kind.as_str()));
}
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(())
}
/// 撞同一实例路径的覆盖条目:`(路径, 涉及的侧, 条目份数)`,按路径排序。
///
/// **两种冲突都要报**(r2 p2-2:上一版按侧去重,把"同侧撞车"这条能力换掉了):
///
/// - **跨侧**:`overrides/`(Both)与 `client-overrides/`(Client)写同一个实例路径——
/// mrpack 唯一的真实冲突。按 `(side, path)` 计数会恒返回空表(两侧键天然不同)。
/// - **同侧**:两个同侧组撞同一路径,或一个组里同一路径列了两次(模型被合并时才出现)。
/// 按侧去重会把这份重复折叠掉,于是同样"查不出来"。
///
/// 判据:`侧数 > 1 || 条份数 > 侧数`(后者即同侧重复)。冲突由导入器决定优先级,
/// 本模块只报告、不选边。
#[must_use]
pub fn duplicate_override_paths(&self) -> Vec<(String, Vec<PackSide>, usize)> {
struct Slot {
sides: Vec<PackSide>,
occurrences: usize,
}
let mut slots: HashMap<&str, Slot> = HashMap::new();
for (side, file) in self.override_files() {
let slot = slots.entry(file.path.as_str()).or_insert(Slot {
sides: Vec::new(),
occurrences: 0,
});
slot.occurrences += 1;
if !slot.sides.contains(&side) {
slot.sides.push(side);
}
}
let mut duplicated: Vec<(String, Vec<PackSide>, usize)> = slots
.into_iter()
.filter(|(_, slot)| slot.sides.len() > 1 || slot.occurrences > slot.sides.len())
.map(|(path, mut slot)| {
slot.sides.sort_by_key(|side| side.as_str());
(path.to_string(), slot.sides, slot.occurrences)
})
.collect();
duplicated.sort_by(|left, right| left.0.cmp(&right.0));
duplicated
}
}
@@ -0,0 +1,155 @@
//! 条目级模型:远端文件、覆盖目录、以及"未知字段并回"的唯一实现。
//!
//! 拆在这里是为了守 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<String>,
/// CF `projectID`。
pub project_id: Option<i64>,
/// CF `fileID`。
pub file_id: Option<i64>,
/// 摘要表(键名小写:`sha1` / `sha512` / `sha256` / `md5`)。mrpack 要求有 `sha512`。
pub hashes: BTreeMap<String, String>,
/// 下载候选(mrpack `downloads`,多源;CF 官方清单没有,要靠 CDN 规则或 API 拼)。
pub downloads: Vec<String>,
/// 文件字节数(mrpack `fileSize`)。
pub size: Option<u64>,
/// 是否"以禁用态安装"(`.disabled`)。
///
/// 社区里 `.disabled` 有三种表达(M115 调研 §字段缺口 2 / §8.1):
/// - 官方 mrpack 导出器:**路径本身带 `.disabled` 后缀**(A 级);
/// - HMCL/D10 旧措辞:去掉后缀,用 `env.client = optional`(B 级,**跨启动器不保真**);
/// - CF:没有原生表达,Prism 借 `required: false`。
///
/// **本仓库的定案(tower,M112)**:一律走路径后缀。`env.client=optional` 只是"安装时可
/// 取消勾选",装回来默认仍然启用,不能当禁用;CF 表达不了禁用位,所以禁用文件只能内联进
/// `overrides/`(导出管线负责,见 `crate::addon::export`)。
pub disabled: bool,
/// 客户端支持度。
pub client: EnvSupport,
/// 服务端支持度。
pub server: EnvSupport,
/// 该条目的未知字段。
pub extra: Map<String, Value>,
/// `env` 对象里的未知字段。
pub env_extra: Map<String, Value>,
}
impl PackFile {
/// 只带路径的条目(mrpack 侧最简形态)。
#[must_use]
pub fn with_path(path: impl Into<String>) -> 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:<projectID>:<fileID>` / `modrinth:<sha512>:<path>`,
/// 两者都没有时退回路径。
#[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<OverrideFile>,
}
impl PackOverrides {
/// 建一个空目录清单。
#[must_use]
pub fn new(dir: impl Into<String>, 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<String, Value>, extra: &Map<String, Value>) {
for (key, value) in extra {
if !target.contains_key(key) {
target.insert(key.clone(), value.clone());
}
}
}
+394
View File
@@ -0,0 +1,394 @@
//! 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)。
//!
//! 这三条是**读**的硬门槛;写(导出)另有四条:
//!
//! 1. 条目缺 `path` 或缺 `downloads` 一律报 `PackErrorKind::Unsupported`(CF 的裸
//! `projectID/fileID` 条目转不成 mrpack,必须先补全——这就是 M111/M112 的分工边界);
//! 2. 禁用位([`PackFile::disabled`])落成 **`path` 结尾的 `.disabled`**,不是
//! `env.client=optional`(tower 定案;后者跨启动器不保真,见 [`DISABLED_SUFFIX`]);
//! 3. `fileSize` 按规范的 u32 校验,超了报 `pack_limits`(HMCL 那边 `(int)` 截断只打 warning
//! 是反面教材);
//! 4. 整包未压缩总量撞 ZIP64 阈值时由 `format::write` 拒绝(`pack_limits`),不写半成品。
//!
//! `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";
/// 禁用文件的路径后缀(Modrinth 官方导出用它表达"装进来但禁用")。
pub const DISABLED_SUFFIX: &str = ".disabled";
/// 单例(进注册表用)。
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<String>,
#[serde(rename = "formatVersion", default)]
format_version: Option<i64>,
#[serde(rename = "versionId", default)]
version_id: Option<String>,
#[serde(default)]
name: Option<String>,
#[serde(default)]
summary: Option<String>,
#[serde(default)]
files: Vec<RawFile>,
#[serde(default)]
dependencies: BTreeMap<String, String>,
#[serde(flatten)]
extra: Map<String, Value>,
}
/// `files[]` 的一条。
#[derive(Debug, Default, Deserialize)]
struct RawFile {
#[serde(default)]
path: Option<String>,
#[serde(default)]
hashes: BTreeMap<String, String>,
#[serde(default)]
env: Option<RawEnv>,
#[serde(default)]
downloads: Vec<String>,
#[serde(rename = "fileSize", default)]
file_size: Option<u64>,
#[serde(flatten)]
extra: Map<String, Value>,
}
/// `files[].env`。
#[derive(Debug, Default, Deserialize)]
struct RawEnv {
#[serde(default)]
client: Option<String>,
#[serde(default)]
server: Option<String>,
#[serde(flatten)]
extra: Map<String, Value>,
}
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) -> PackResult<Option<&'static str>> {
let Some(value) = pack.read_json(METADATA_ENTRY)? else {
return Ok(None);
};
let object = value
.as_object()
.ok_or_else(|| PackError::metadata(format!("{METADATA_ENTRY} 存在但不是 JSON 对象")))?;
// 有 formatVersion 数字即可认领;没有则要求 game + files 的形状对得上。
let matched = object.get("formatVersion").is_some_and(Value::is_number)
|| (object.get("game").and_then(Value::as_str) == Some(GAME)
&& object.get("files").is_some_and(Value::is_array));
Ok(matched.then_some(METADATA_ENTRY))
}
fn check_overrides(&self, manifest: &PackManifest) -> PackResult<()> {
// mrpack 的覆盖目录名由 side 决定,且每侧最多一个:不符合就别写(r1 p2-1)。
let mut seen: Vec<PackSide> = Vec::new();
for group in &manifest.overrides {
let expected = group.side.mrpack_dir();
if group.dir != expected {
return Err(PackError::unsupported(format!(
"Modrinth 的覆盖目录名由侧决定:{} 侧必须是 {expected:?},模型里是 {:?}",
group.side.as_str(),
group.dir
))
.with_hint(
"按 side 分派到 overrides / client-overrides / server-overrides 属 M112;\
不同格式的实例目录(如 MultiMC 的 .minecraft)不能直接搬过来",
));
}
if seen.contains(&group.side) {
return Err(PackError::unsupported(format!(
"Modrinth 每个侧只能有一个覆盖目录,{} 侧出现了多次",
group.side.as_str()
)));
}
seen.push(group.side);
}
Ok(())
}
fn read(&self, pack: &PackArchive) -> PackResult<PackManifest> {
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<PackLoader> = 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<String> {
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);
env.insert("client".into(), json!(file.client.as_str()));
env.insert("server".into(), json!(file.server.as_str()));
// 禁用位用**路径后缀**表达(Modrinth 官方导出同款,`export_mrpack.rs:653-658`),
// 不用 `env.client=optional`:后者只是"安装时可取消勾选",装回来默认仍然启用,
// 跨启动器不保真(tower 定案,D10 §2 的旧措辞已作废)。
let path = if file.disabled && !path.ends_with(DISABLED_SUFFIX) {
format!("{path}{DISABLED_SUFFIX}")
} else {
path
};
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 {
// 规范里 `fileSize` 是 u32(≈4 GiB):超了要具名报错,不静默截断。
let size = u32::try_from(size).map_err(|_| {
PackError::limits(format!(
"文件 {path} 有 {size} 字节,超过 mrpack 的 fileSize 上限(u32,4294967295)"
))
.with_hint("这种尺寸只能靠 overrides/ 内联,而内联又会撞 ZIP64 上限;先缩减实例")
})?;
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<String, String>) -> 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> {
EnvSupport::parse(raw).ok_or_else(|| {
PackError::metadata(format!(
"files[] 条目 {path} 的 env.{field} 是 {raw:?},只认 required/optional/unsupported"
))
})
}
+183
View File
@@ -0,0 +1,183 @@
//! 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<i64>,
#[serde(default)]
components: Vec<RawComponent>,
#[serde(flatten)]
extra: Map<String, Value>,
}
/// `components[]` 的一条。
#[derive(Debug, Default, Deserialize)]
struct RawComponent {
#[serde(default)]
uid: Option<String>,
#[serde(default)]
version: Option<String>,
#[serde(flatten)]
extra: Map<String, Value>,
}
impl PackFormat for MultiMc {
fn id(&self) -> &'static str {
ID
}
fn label(&self) -> &'static str {
"MultiMC / Prism"
}
fn detect(&self, pack: &PackArchive) -> PackResult<Option<&'static str>> {
let Some(value) = pack.read_json(METADATA_ENTRY)? else {
return Ok(None);
};
let object = value
.as_object()
.ok_or_else(|| PackError::metadata(format!("{METADATA_ENTRY} 存在但不是 JSON 对象")))?;
// 形状校验:必须有非空 components 数组(MultiMC 的组件图是它的身份证)。
let matched = object
.get("components")
.and_then(Value::as_array)
.is_some_and(|components| !components.is_empty());
Ok(matched.then_some(METADATA_ENTRY))
}
fn read(&self, pack: &PackArchive) -> PackResult<PackManifest> {
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<PackLoader> = 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<String> {
Err(PackError::unsupported(
"MultiMC 格式只支持导入:写回需要重组组件图与 patches/libraries,不在模型职责内",
)
.with_hint("导出请选 Modrinth(.mrpack)或 CurseForge 格式"))
}
}
/// 从 `instance.cfg` 的 `name=` 行取实例名(MultiMC 的 ini 很宽松,只认这一行)。
fn instance_name(pack: &PackArchive) -> Option<String> {
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)
}
+140
View File
@@ -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<String> {
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<String> {
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<String> {
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()
);
}
}
+223
View File
@@ -0,0 +1,223 @@
//! 探测优先级:顺序写死在注册表里,用例把"谁赢"和"为什么赢"都钉住。
//!
//! 判据分三层,缺一层都可能被"看起来对"糊过去:
//! 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).expect("mmc-pack.json 可读"),
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());
}
#[test]
fn a_corrupt_manifest_is_a_metadata_error_not_unsupported() {
// r1 p3-2:清单在、但读不出来 = 包坏了(`pack_metadata`)。之前三个 detect 都用
// `.ok().flatten()` 吞掉解析错误,结果被报成「没有格式认领」——M111 的失败分级会据此
// 把"可以重下文件"的包判成"不支持"。
let target = temp_root("corrupt-manifest").join("bad.zip");
write_zip(&target, &[("manifest.json", &br#"{"name": "#[..])]);
let pack = PackArchive::open_default(&target).expect("zip 本身可读");
let error = PackRegistry::builtin()
.detect(&pack)
.expect_err("坏清单必须报错");
assert_eq!(error.kind(), PackErrorKind::Metadata, "{error}");
assert!(
!error.message().contains("没有格式认领"),
"不许把「包坏了」说成「不认识」:{}",
error.message()
);
}
#[test]
fn a_corrupt_foreign_manifest_does_not_hide_a_valid_mrpack() {
// 命中优先于失败:包里躺着一份别人的坏 manifest.json,不该让真 mrpack 也变得不可用。
let target = temp_root("corrupt-foreign").join("mixed.zip");
write_zip(
&target,
&[
("manifest.json", &br#"{"name": "#[..]),
("modrinth.index.json", MR_INDEX),
],
);
let pack = PackArchive::open_default(&target).expect("zip 本身可读");
let matched = PackRegistry::builtin()
.detect(&pack)
.expect("mrpack 命中应当优先于其它格式的坏文件");
assert_eq!(matched.format().id(), "modrinth");
assert!(matched.also_matched().is_empty(), "CF 没命中(它坏了)");
}
#[test]
fn a_missing_manifest_is_not_an_error() {
// 反向对照:**没有** `manifest.json` 是"不是 CF",不是"包坏了"。
let target = temp_root("no-manifest").join("mmc.zip");
write_zip(
&target,
&[(
"mmc-pack.json",
&br#"{"formatVersion":1,"components":[{"uid":"net.minecraft","version":"1.20.1"}]}"#[..],
)],
);
let pack = PackArchive::open_default(&target).expect("zip 本身可读");
assert_eq!(CURSEFORGE.detect(&pack).expect("缺清单不是错误"), None);
assert_eq!(
PackRegistry::builtin()
.detect(&pack)
.expect("MultiMC 应当命中")
.format()
.id(),
"multimc"
);
}
+156
View File
@@ -0,0 +1,156 @@
//! 导出面的安全闸(r1 p2-1 / p3-1):**跨格式导出必须被拒,失败不留半个包**。
//!
//! 这一组不测"映射是否做对"(那是 M112),只测"表达不了的时候会不会静默产出空包"。
use super::{fixtures, temp_root};
use crate::addon::pack::archive::PackArchive;
use crate::addon::pack::curseforge::CURSEFORGE;
use crate::addon::pack::error::PackErrorKind;
use crate::addon::pack::format::{PackFormat, PackPayload};
use crate::addon::pack::manifest::{OverrideFile, PackManifest, PackOverrides, PackSide};
use crate::addon::pack::modrinth::MODRINTH;
use crate::addon::pack::multimc::MULTIMC;
#[test]
fn a_multimc_instance_cannot_be_written_as_mrpack() {
// r1 p2-1 实测过的那条:MultiMC 模型写 mrpack 会返回 Ok,但覆盖内容落在 `.minecraft/...`,
// mrpack 导入器只解压 overrides / client-overrides / server-overrides ⇒ mods 数量为 0。
// 现在必须在写第一个字节之前就拒。
let root = fixtures::multimc("export-mmc-mr");
let pack = PackArchive::open_default(&root).expect("打开实例目录");
let mut manifest = MULTIMC.read(&pack).expect("读 MultiMC 清单");
manifest.version = Some("1.0.0".to_string()); // mrpack 需要 versionId,别让别的错误顶包
let target = root.with_file_name("cross.mrpack");
let error = MODRINTH
.write(&manifest, PackPayload::from_root(&root), &target)
.expect_err("跨格式导出必须被拒,不能产出 mods 为 0 的包");
assert_eq!(error.kind(), PackErrorKind::Unsupported, "{error}");
assert!(
error.message().contains(".minecraft"),
"错误要说清是哪个目录表达不了:{}",
error.message()
);
assert!(error.hint().is_some(), "要给下一步提示(目录映射属 M112)");
assert!(!target.exists(), "被拒时不许留下目标文件");
}
#[test]
fn an_mrpack_with_split_sides_cannot_be_written_as_curseforge() {
// CF 只有一个 `overrides` 目录字段,表达不了 client/server 分侧;现在是拒,不是猜。
let fixture = fixtures::modrinth("export-mr-cf");
let pack = PackArchive::open_default(&fixture.zip).expect("打开 mrpack 夹具");
let manifest = MODRINTH.read(&pack).expect("读 mrpack 清单");
let target = fixture.zip.with_file_name("cross.zip");
let error = CURSEFORGE
.write(&manifest, PackPayload::from_root(&fixture.dir), &target)
.expect_err("CF 表达不了分侧覆盖目录");
assert_eq!(error.kind(), PackErrorKind::Unsupported, "{error}");
assert!(
error.message().contains("client"),
"错误要指出被拒的是哪一侧:{}",
error.message()
);
assert!(!target.exists(), "被拒时不许留下目标文件");
}
#[test]
fn curseforge_refuses_more_than_one_both_side_group() {
// CF 的 `overrides` 只有一个字段:两个 Both 组必然有一个不被清单声明(静默丢内容)。
let mut manifest = PackManifest::new("两个 Both 组");
manifest.minecraft = Some("1.20.1".to_string());
manifest
.overrides
.push(PackOverrides::new("overrides", PackSide::Both));
manifest
.overrides
.push(PackOverrides::new("extra-overrides", PackSide::Both));
let target = temp_root("export-cf-two").join("out.zip");
let error = CURSEFORGE
.write(&manifest, PackPayload::metadata_only(), &target)
.expect_err("多出来的 Both 组会被清单漏掉,必须拒");
assert_eq!(error.kind(), PackErrorKind::Unsupported, "{error}");
assert!(!target.exists(), "被拒时不许留下目标文件");
}
#[test]
fn a_failed_export_leaves_neither_a_half_pack_nor_a_temp_file() {
// r1 p3-1:清单写完了、覆盖文件读不出来 → 目标路径与临时文件都不许留。
let mut manifest = PackManifest::new("缺覆盖文件的模型");
manifest.minecraft = Some("1.20.1".to_string());
manifest.overrides.push(PackOverrides {
dir: "overrides".to_string(),
side: PackSide::Both,
files: vec![OverrideFile {
path: "mods/missing.jar".to_string(),
sha1: "0".repeat(40),
size: 1,
}],
});
let root = temp_root("export-half");
std::fs::create_dir_all(&root).expect("建目录");
let target = root.join("out.zip");
let error = CURSEFORGE
.write(&manifest, PackPayload::from_root(&root), &target)
.expect_err("覆盖文件不存在时必须失败");
assert_eq!(error.kind(), PackErrorKind::Io, "{error}");
assert!(!target.exists(), "失败后目标路径不许存在");
let leftovers: Vec<String> = std::fs::read_dir(&root)
.expect("列目录")
.filter_map(Result::ok)
.map(|entry| entry.file_name().to_string_lossy().to_string())
.collect();
assert!(
leftovers.is_empty(),
"临时文件没清干净(p3-1 回退):{leftovers:?}"
);
}
#[test]
fn same_format_export_is_still_allowed() {
// 反向对照:这两条闸不能把同格式往返也挡掉(那是 r1 明确要保住的)。
let fixture = fixtures::curseforge("export-same-cf");
let pack = PackArchive::open_default(&fixture.zip).expect("打开 CF 夹具");
let manifest = CURSEFORGE.read(&pack).expect("读 CF 清单");
CURSEFORGE
.check_overrides(&manifest)
.expect("CF 自己读出来的模型必须能被 CF 表达");
let fixture = fixtures::modrinth("export-same-mr");
let pack = PackArchive::open_default(&fixture.zip).expect("打开 mrpack 夹具");
let manifest = MODRINTH.read(&pack).expect("读 mrpack 清单");
MODRINTH
.check_overrides(&manifest)
.expect("mrpack 自己读出来的模型必须能被 mrpack 表达");
}
#[test]
fn mrpack_refuses_two_override_groups_for_the_same_side() {
// r2 p3-a:mrpack 的「每侧只能有一个覆盖目录」分支此前零测试(删掉它 59 条全绿)。
// 目录名检查管不到这里:两个组的 `dir` 都合法,区别只在于同一侧出现了两次。
let mut manifest = PackManifest::new("同侧两个覆盖目录");
manifest.version = Some("1.0.0".to_string());
manifest.minecraft = Some("1.20.1".to_string());
manifest
.overrides
.push(PackOverrides::new("overrides", PackSide::Both));
manifest
.overrides
.push(PackOverrides::new("overrides", PackSide::Both));
let target = temp_root("export-mr-same-side").join("out.mrpack");
let error = MODRINTH
.write(&manifest, PackPayload::metadata_only(), &target)
.expect_err("同一侧两个覆盖目录必须拒");
assert_eq!(error.kind(), PackErrorKind::Unsupported, "{error}");
assert!(
error.message().contains("每个侧只能有一个覆盖目录"),
"错误要指出是「同侧重复」而不是「目录名不对」:{}",
error.message()
);
assert!(!target.exists(), "被拒时不许留下目标文件");
}
@@ -0,0 +1,121 @@
//! 导出测试的共用夹具(M112):实例目录、`versions/` 版本层、假的远端目录。
//!
//! 这里的东西**只给测试用**:本仓库没有 Modrinth/CF 客户端(要网络与 key),所以远端查表一律
//! 由 [`StubCatalog`] 注入;实例目录手造成"M111 导入产物"的形状(内容目录 + 版本 JSON)。
use std::cell::RefCell;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use super::{temp_root, write_file};
use crate::addon::export::catalog::{RemoteCatalog, RemoteHit};
use crate::addon::export::scan::{scan_instance, DEFAULT_CONTENT_ROOTS};
use crate::addon::pack::curseforge::fingerprint;
pub(super) const VANILLA_LAYER: &str = r#"{"id": "1.20.1", "libraries": []}"#;
pub(super) const FABRIC_LAYER: &str = r#"{
"id": "1.20.1-fabric-0.15.11",
"inheritsFrom": "1.20.1",
"libraries": [{"name": "net.fabricmc:fabric-loader:0.15.11"}]
}"#;
pub(super) const NEOFORGE_LAYER: &str = r#"{
"id": "1.20.1-neoforge-47.1.106",
"inheritsFrom": "1.20.1",
"libraries": [
{"name": "net.neoforged:neoforge:1.20.1-47.1.106"},
{"name": "net.fancymodloader:loader:3.0.5"}
]
}"#;
pub(super) const FORGE_LAYER: &str = r#"{
"id": "1.20.1-forge-47.2.0",
"inheritsFrom": "1.20.1",
"libraries": [{"name": "net.minecraftforge:forge:1.20.1-47.2.0:universal"}]
}"#;
/// 内容目录清单(与导出默认值同一份)。
pub(super) fn defaults() -> Vec<String> {
DEFAULT_CONTENT_ROOTS.map(str::to_string).to_vec()
}
/// 写一份 `<id>/<id>.json`。
pub(super) fn write_layer(game: &Path, id: &str, json: &str) {
write_file(
&game.join(format!("versions/{id}/{id}.json")),
json.as_bytes(),
);
}
/// 一个 Fabric 实例目录:两个 mod(其中一个禁用)+ 一份 config + 一份白名单外的日志。
pub(super) fn fabric_instance(tag: &str) -> PathBuf {
let root = temp_root(tag).join("game");
write_layer(&root, "1.20.1", VANILLA_LAYER);
write_layer(&root, "1.20.1-fabric-0.15.11", FABRIC_LAYER);
write_file(&root.join("mods/jei.jar"), b"PK\x03\x04 jei body");
write_file(&root.join("mods/off.jar.disabled"), b"PK\x03\x04 off body");
write_file(&root.join("config/known.toml"), b"key = \"value\"\n");
write_file(&root.join("logs/latest.log"), b"not pack content\n");
root
}
/// 一个 NeoForge 1.20.1 实例目录(CF `modLoaders[].id` 的 MC 前缀特例用它)。
pub(super) fn neoforge_instance(tag: &str) -> PathBuf {
let root = temp_root(tag).join("game");
write_layer(&root, "1.20.1", VANILLA_LAYER);
write_layer(&root, "1.20.1-neoforge-47.1.106", NEOFORGE_LAYER);
write_file(&root.join("mods/jei.jar"), b"PK\x03\x04 jei body");
root
}
/// 目录里的一个文件(路径 + SHA-1 + CF 指纹),与 `scan_instance` 同源。
pub(super) fn digests(root: &Path, path: &str) -> (String, u32) {
let (locals, _) = scan_instance(root, &defaults());
let local = locals
.iter()
.find(|file| file.path == path)
.unwrap_or_else(|| panic!("夹具里没有 {path}"));
let value = fingerprint::fingerprint_file(&local.disk).expect("算指纹");
(local.sha1.clone(), value)
}
/// 一个能写进 mrpack 的 Modrinth 命中(sha1 + sha512 + 直链)。
pub(super) fn modrinth_hit(sha1: &str, url: &str) -> RemoteHit {
let mut hashes = BTreeMap::new();
hashes.insert("sha1".to_string(), sha1.to_string());
hashes.insert("sha512".to_string(), "a".repeat(128));
RemoteHit::modrinth(vec![url.to_string()], hashes, Some(11))
}
/// 一个**假**的远端目录:查表结果由测试塞,调用过的指纹记下来(哨兵值那条分支靠它断言)。
#[derive(Default)]
pub(super) struct StubCatalog {
modrinth: BTreeMap<String, RemoteHit>,
curse: BTreeMap<u32, RemoteHit>,
curse_calls: RefCell<Vec<u32>>,
}
impl StubCatalog {
pub(super) fn with_modrinth(mut self, sha1: &str, hit: RemoteHit) -> Self {
self.modrinth.insert(sha1.to_string(), hit);
self
}
pub(super) fn with_curse(mut self, value: u32, hit: RemoteHit) -> Self {
self.curse.insert(value, hit);
self
}
pub(super) fn curse_calls(&self) -> Vec<u32> {
self.curse_calls.borrow().clone()
}
}
impl RemoteCatalog for StubCatalog {
fn modrinth_by_sha1(&self, sha1: &str) -> Option<RemoteHit> {
self.modrinth.get(sha1).cloned()
}
fn curseforge_by_fingerprint(&self, value: u32) -> Option<RemoteHit> {
self.curse_calls.borrow_mut().push(value);
self.curse.get(&value).cloned()
}
}
@@ -0,0 +1,370 @@
//! 导出管线(M112):三分类、`.disabled` 路径后缀、`game`/`dependencies` 来自实例快照。
//!
//! 夹具在 [`super::export_harness`]:手造的实例目录(形状与 M111 导入产物一致)+
//! 注入式假远端目录(本仓库没有 Modrinth/CF 客户端,测试也不许联网)。
use std::collections::BTreeMap;
use std::io::Read;
use std::path::Path;
use super::export_harness::{
defaults, digests, fabric_instance, modrinth_hit, neoforge_instance, StubCatalog,
};
use super::{temp_root, write_file};
use crate::addon::export::classify::classify_with;
use crate::addon::export::scan::{is_disabled_path, scan_instance};
use crate::addon::export::{export_instance, ExportRequest, FileClass, FileReason, RemoteHit};
use crate::addon::pack::archive::PackArchive;
use crate::addon::pack::curseforge::{fingerprint, CURSEFORGE};
use crate::addon::pack::error::PackErrorKind;
use crate::addon::pack::format::PackFormat;
use crate::addon::pack::manifest::{PackFile, PackManifest};
use crate::addon::pack::modrinth::MODRINTH;
use crate::addon::LoaderKind;
fn entry_names(path: &Path) -> Vec<String> {
let pack = PackArchive::open_default(path).expect("打开 zip");
pack.entries()
.iter()
.map(|entry| entry.name().to_string())
.collect()
}
#[test]
fn offline_mrpack_export_inlines_every_file_with_its_suffix() {
let root = fabric_instance("export-offline-mr");
let target = root.with_file_name("offline.mrpack");
let report =
export_instance(&ExportRequest::new(&root, &target, &MODRINTH)).expect("离线导出必须成功");
assert_eq!(report.format, "modrinth");
assert_eq!(report.remote_count(), 0);
assert_eq!(report.inline_count(), 3, "{:?}", report.files);
assert_eq!(report.skipped_count(), 0);
assert!(report.is_complete());
assert!(report.bytes > 0, "包路径与字节数必须结构化给出");
assert_eq!(report.minecraft.as_deref(), Some("1.20.1"));
assert_eq!(report.loaders, vec!["fabric-loader 0.15.11".to_string()]);
let pack = PackArchive::open_default(&target).expect("打开导出的包");
let manifest = MODRINTH.read(&pack).expect("读回 mrpack");
assert_eq!(manifest.name, "game");
assert_eq!(manifest.version.as_deref(), Some("1.0.0"));
assert_eq!(manifest.minecraft.as_deref(), Some("1.20.1"));
let loader = manifest.loader(LoaderKind::Fabric).expect("加载器不能漏");
assert_eq!(loader.version.as_deref(), Some("0.15.11"));
let paths: Vec<&str> = manifest
.override_files()
.map(|(_, file)| file.path.as_str())
.collect();
assert_eq!(
paths,
vec!["config/known.toml", "mods/jei.jar", "mods/off.jar.disabled"],
"三个内容文件都要在 overrides/ 里,禁用位保留后缀"
);
assert!(pack.has_entry("overrides/mods/off.jar.disabled"));
assert!(
!pack.has_entry("overrides/logs/latest.log"),
"白名单外(logs/)不打包"
);
}
#[test]
fn mrpack_index_declares_game_and_every_dependency() {
let root = fabric_instance("export-index-mr");
let target = root.with_file_name("index.mrpack");
export_instance(&ExportRequest::new(&root, &target, &MODRINTH)).expect("导出");
let pack = PackArchive::open_default(&target).expect("打开");
let index = pack
.read_json("modrinth.index.json")
.expect("读清单")
.expect("有清单");
assert_eq!(index["game"], "minecraft");
assert_eq!(index["formatVersion"], 1);
assert_eq!(index["name"], "game");
assert_eq!(index["dependencies"]["minecraft"], "1.20.1");
assert_eq!(
index["dependencies"]["fabric-loader"], "0.15.11",
"只写 minecraft 漏加载器是本任务点名的不许"
);
assert!(index["files"].as_array().is_some_and(Vec::is_empty));
}
#[test]
fn a_modrinth_hit_becomes_a_files_entry_with_both_catalogs_urls() {
let root = fabric_instance("export-remote-mr");
let (sha1, value) = digests(&root, "mods/jei.jar");
let catalog = StubCatalog::default()
.with_modrinth(
&sha1,
modrinth_hit(&sha1, "https://cdn.modrinth.com/data/x/jei.jar"),
)
.with_curse(
value,
RemoteHit::curseforge(238_222, 5_102_025)
.with_download("https://edge.forgecdn.net/files/5102/025/jei.jar"),
);
let target = root.with_file_name("remote.mrpack");
let report =
export_instance(&ExportRequest::new(&root, &target, &MODRINTH).with_catalog(&catalog))
.expect("导出");
assert_eq!(report.remote_count(), 1);
let entry = report
.files
.iter()
.find(|file| file.path == "mods/jei.jar")
.expect("jei 在报告里");
assert_eq!(entry.class, FileClass::Remote);
assert_eq!(entry.reason, FileReason::ModrinthSha1);
assert_eq!(
entry.downloads.len(),
2,
"两条目录的直链都要写进 downloads[]"
);
let pack = PackArchive::open_default(&target).expect("打开");
let manifest = MODRINTH.read(&pack).expect("读回");
assert_eq!(manifest.files.len(), 1);
let file = &manifest.files[0];
assert_eq!(file.path.as_deref(), Some("mods/jei.jar"));
assert_eq!(file.downloads.len(), 2);
assert_eq!(file.sha512().map(str::len), Some(128));
assert_eq!(
file.sha1(),
Some(sha1.as_str()),
"本地实测的 sha1 要覆盖目录给的值"
);
assert_eq!(file.size, Some(13), "字节数按磁盘实测");
assert_eq!(manifest.override_files().count(), 2, "另外两个文件仍然内联");
}
#[test]
fn a_hit_without_sha512_is_inlined_with_a_reason() {
let root = fabric_instance("export-no-sha512");
let (sha1, _) = digests(&root, "mods/jei.jar");
let mut hashes = BTreeMap::new();
hashes.insert("sha1".to_string(), sha1.clone());
let catalog = StubCatalog::default().with_modrinth(
&sha1,
RemoteHit::modrinth(
vec!["https://cdn.modrinth.com/data/x/jei.jar".into()],
hashes,
None,
),
);
let target = root.with_file_name("no-sha512.mrpack");
let report =
export_instance(&ExportRequest::new(&root, &target, &MODRINTH).with_catalog(&catalog))
.expect("导出");
let entry = report
.files
.iter()
.find(|file| file.path == "mods/jei.jar")
.expect("jei");
assert_eq!(entry.class, FileClass::Inline);
assert_eq!(entry.reason, FileReason::MissingSha512);
assert_eq!(report.remote_count(), 0);
}
#[test]
fn a_curseforge_only_hit_cannot_be_written_into_an_mrpack() {
let root = fabric_instance("export-curse-only");
let (_, value) = digests(&root, "mods/jei.jar");
let catalog =
StubCatalog::default().with_curse(value, RemoteHit::curseforge(238_222, 5_102_025));
let target = root.with_file_name("curse-only.mrpack");
let report =
export_instance(&ExportRequest::new(&root, &target, &MODRINTH).with_catalog(&catalog))
.expect("导出");
let entry = report
.files
.iter()
.find(|file| file.path == "mods/jei.jar")
.expect("jei");
assert_eq!(entry.reason, FileReason::CurseOnlyMatch);
assert_eq!(entry.class, FileClass::Inline);
}
#[test]
fn curseforge_export_writes_only_known_project_and_file_ids() {
let root = fabric_instance("export-cf");
let (_, value) = digests(&root, "mods/jei.jar");
let catalog =
StubCatalog::default().with_curse(value, RemoteHit::curseforge(238_222, 5_102_025));
let target = root.with_file_name("pack.zip");
let report =
export_instance(&ExportRequest::new(&root, &target, &CURSEFORGE).with_catalog(&catalog))
.expect("导出");
assert_eq!(report.format, "curseforge");
let reasons: Vec<(String, FileReason)> = report
.files
.iter()
.map(|file| (file.path.clone(), file.reason))
.collect();
assert_eq!(
reasons,
vec![
("config/known.toml".to_string(), FileReason::NoRemoteMatch),
(
"mods/jei.jar".to_string(),
FileReason::CurseForgeFingerprint
),
(
"mods/off.jar.disabled".to_string(),
FileReason::DisabledNotExpressible
),
]
);
let pack = PackArchive::open_default(&target).expect("打开");
let manifest = CURSEFORGE.read(&pack).expect("读回 CF 清单");
assert_eq!(manifest.files.len(), 1, "files[] 只写能确定 id 的项");
assert_eq!(manifest.files[0].project_id, Some(238_222));
assert_eq!(manifest.files[0].file_id, Some(5_102_025));
let names = entry_names(&target);
assert!(names.contains(&"overrides/mods/off.jar.disabled".to_string()));
assert!(names.contains(&"overrides/config/known.toml".to_string()));
}
#[test]
fn curseforge_modloaders_use_the_official_id_shape() {
let root = neoforge_instance("export-cf-neo");
let target = root.with_file_name("neo.zip");
export_instance(&ExportRequest::new(&root, &target, &CURSEFORGE)).expect("导出");
let pack = PackArchive::open_default(&target).expect("打开");
let manifest = pack
.read_json("manifest.json")
.expect("读清单")
.expect("有清单");
assert_eq!(manifest["minecraft"]["version"], "1.20.1");
assert_eq!(
manifest["minecraft"]["modLoaders"][0]["id"], "neoforge-1.20.1-47.1.106",
"CF 官方导出在 1.20.1 上把 MC 版本拼回 NeoForge 版本号"
);
assert_eq!(manifest["minecraft"]["modLoaders"][0]["primary"], true);
let root = fabric_instance("export-cf-fabric");
let target = root.with_file_name("fabric.zip");
export_instance(&ExportRequest::new(&root, &target, &CURSEFORGE)).expect("导出");
let pack = PackArchive::open_default(&target).expect("打开");
let manifest = pack
.read_json("manifest.json")
.expect("读清单")
.expect("有清单");
assert_eq!(
manifest["minecraft"]["modLoaders"][0]["id"],
"fabric-0.15.11"
);
assert_eq!(manifest["overrides"], "overrides");
}
#[test]
fn the_sentinel_fingerprint_never_reaches_the_catalog() {
let root = fabric_instance("export-sentinel");
let (locals, _) = scan_instance(&root, &defaults());
let local = locals
.iter()
.find(|file| file.path == "mods/jei.jar")
.expect("jei");
let catalog = StubCatalog::default()
.with_curse(fingerprint::SENTINEL, RemoteHit::curseforge(1, 2))
.with_curse(12345, RemoteHit::curseforge(1, 2));
let (reason, hit) = classify_with(local, &catalog, true, Some(fingerprint::SENTINEL));
assert_eq!(reason, FileReason::NoRemoteMatch);
assert!(hit.is_none());
assert!(
catalog.curse_calls().is_empty(),
"哨兵值必须短路,不能拿去查表(HMCL issue #4597 的误命中)"
);
// 对照:真实指纹会去查表。
let (reason, hit) = classify_with(local, &catalog, true, Some(12345));
assert_eq!(catalog.curse_calls(), vec![12345]);
assert_eq!(reason, FileReason::CurseForgeFingerprint, "塞了 id 就命中");
assert!(hit.is_some());
}
#[test]
fn a_disabled_file_is_still_disabled_after_a_round_trip_through_the_package() {
// 任务要求的那条:禁用文件导出后仍能被"重新导入"且保持禁用。
// 本轮没有 M111 的导入器(M111 未合并),所以"重新导入"= 把包里的 overrides 解出来 +
// 重新扫一遍实例目录:扫出来的 disabled 位必须是 true。
let root = fabric_instance("export-disabled");
let target = root.with_file_name("disabled.mrpack");
export_instance(&ExportRequest::new(&root, &target, &MODRINTH)).expect("导出");
let again = temp_root("export-disabled-reimport");
let file = std::fs::File::open(&target).expect("打开包");
let mut archive = zip::ZipArchive::new(file).expect("读 zip");
for index in 0..archive.len() {
let mut entry = archive.by_index(index).expect("条目");
let name = entry.name().expect("条目名").to_string();
let Some(relative) = name.strip_prefix("overrides/") else {
continue;
};
let mut bytes = Vec::new();
entry.read_to_end(&mut bytes).expect("读条目内容");
write_file(&again.join(relative), &bytes);
}
let (locals, skipped) = scan_instance(&again, &defaults());
assert!(skipped.is_empty(), "{skipped:?}");
let disabled: Vec<&str> = locals
.iter()
.filter(|file| file.disabled)
.map(|file| file.path.as_str())
.collect();
assert_eq!(disabled, vec!["mods/off.jar.disabled"]);
assert!(is_disabled_path("mods/off.jar.disabled.old"));
assert!(
!is_disabled_path("mods/off.jar.old"),
"`.old` 只是旧残留,不是禁用"
);
}
#[test]
fn a_file_bigger_than_u32_is_a_named_limits_error() {
// 真造一个 4 GiB 文件不现实:直接测格式层的那条闸(它是唯一写 `fileSize` 的地方)。
let mut manifest = PackManifest::new("超大文件");
manifest.version = Some("1.0.0".to_string());
manifest.minecraft = Some("1.20.1".to_string());
let mut hashes = BTreeMap::new();
hashes.insert("sha512".to_string(), "b".repeat(128));
manifest.files.push(PackFile {
path: Some("mods/huge.jar".to_string()),
hashes,
downloads: vec!["https://cdn.modrinth.com/data/x/huge.jar".to_string()],
size: Some(u64::from(u32::MAX) + 1),
..PackFile::default()
});
let error = MODRINTH.render(&manifest).expect_err("超限必须报错");
assert_eq!(error.kind(), PackErrorKind::Limits);
assert!(error.message().contains("fileSize"), "{error}");
}
#[test]
fn content_roots_can_be_extended_without_touching_the_pipeline() {
// 「社区标准的扩展性」的最小证明:加一个目录不用改管线。
let root = fabric_instance("export-extra-root");
write_file(&root.join("custom/extra.txt"), b"extra content\n");
let target = root.with_file_name("extra.mrpack");
let mut roots = defaults();
roots.push("custom".to_string());
let report =
export_instance(&ExportRequest::new(&root, &target, &MODRINTH).with_content_roots(roots))
.expect("导出");
assert!(report
.files
.iter()
.any(|file| file.path == "custom/extra.txt"));
let pack = PackArchive::open_default(&target).expect("打开");
assert!(pack.has_entry("overrides/custom/extra.txt"));
}
@@ -0,0 +1,280 @@
//! 往返保真(M112):用 M110 的三种格式夹具做「读出来 → 摆成实例 → 导出 → 再读」。
//!
//! # 为什么不是 `import_pack → export → import_pack`
//!
//! M111 的导入管线**还没合并进本分支**(它在复审中)。所以这里的"导入"是 M110 的
//! `PackFormat::read` + 一个**形状与 M111 导入产物一致**的实例目录(`mods/`、`config/`、
//! `versions/<id>/<id>.json`)。真端到端欠账在 M112 报告里登记:M111 合并后要补一条
//! `import_pack → export → import_pack`。
//!
//! # 允许的差异(逐条显式列出,别让后来人以为是 bug)
//!
//! | 方向 | 差异 | 原因 |
//! |------|------|------|
//! | CF → mrpack | `files[]` 的 `projectID/fileID` 条目全丢 | CF 的 `files[]` **没有路径**,磁盘上无法复原成文件;导出侧只能看到磁盘,看不见"清单里说该有但没落地"的条目 |
//! | CF → mrpack | `author` / `minecraft.recommendedRam` 等扩展字段丢 | mrpack 没有这些字段(`extra` 不跨格式搬) |
//! | mrpack → mrpack | 认不出的 dependency 键(`parchment`)丢 | 快照只从版本 JSON 的库坐标来;`parchment` 没有对应库 |
//! | mrpack → mrpack | `files[]` 的条目要靠"文件真的在磁盘上"才会再写回去 | 读出来的模型里那两条是远程条目、本地没有字节;再导出时以磁盘为准 |
//! | MultiMC → mrpack | 非加载器组件(`org.lwjgl3`)不进 `dependencies` | 它不是加载器;mrpack 的 `dependencies` 只表达 loader |
use std::path::{Path, PathBuf};
use super::export_harness::{
defaults, modrinth_hit, write_layer, StubCatalog, FORGE_LAYER, VANILLA_LAYER,
};
use super::{fixtures, temp_root, write_file};
use crate::addon::export::snapshot::{InstanceSnapshot, SnapshotLoader};
use crate::addon::export::{export_instance, ExportRequest};
use crate::addon::pack::archive::PackArchive;
use crate::addon::pack::curseforge::CURSEFORGE;
use crate::addon::pack::format::PackFormat;
use crate::addon::pack::manifest::{PackManifest, PackSide};
use crate::addon::pack::modrinth::MODRINTH;
use crate::addon::pack::multimc::MULTIMC;
use crate::addon::LoaderKind;
/// 把夹具里的覆盖内容铺成一个实例目录(`<game>/<path>`),并写一份版本 JSON。
fn instance_from(fixture_dir: &Path, manifest: &PackManifest, tag: &str) -> PathBuf {
let game = temp_root(tag).join("game");
for (_, file) in manifest.override_files() {
let group = manifest
.overrides
.iter()
.find(|group| group.files.iter().any(|each| each.path == file.path))
.expect("找到文件所属的组");
let source = fixture_dir.join(&group.dir).join(&file.path);
let bytes = std::fs::read(&source).unwrap_or_else(|error| {
panic!("读夹具 {} 失败:{error}", source.display());
});
write_file(&game.join(&file.path), &bytes);
}
write_layer(&game, "1.20.1", VANILLA_LAYER);
game
}
/// 一个「读出来的 mrpack」的文件集合,按路径排序(比较用)。
fn override_paths(manifest: &PackManifest) -> Vec<String> {
let mut paths: Vec<String> = manifest
.override_files()
.map(|(_, file)| file.path.clone())
.collect();
paths.sort();
paths
}
#[test]
fn curseforge_fixture_round_trips_through_mrpack() {
let fixture = fixtures::curseforge("rt-cf");
let pack = PackArchive::open_default(&fixture.zip).expect("打开 CF 夹具");
let original = CURSEFORGE.read(&pack).expect("读 CF 清单");
let game = instance_from(&fixture.dir, &original, "rt-cf-mr");
write_layer(&game, "1.20.1-forge-47.2.0", FORGE_LAYER);
let target = game.with_file_name("cf-to-mr.mrpack");
let request = ExportRequest::new(&game, &target, &MODRINTH)
.with_name(original.name.clone())
.with_version(original.version.clone().unwrap_or_default())
.with_version_id("1.20.1-forge-47.2.0");
let report = export_instance(&request).expect("导出");
assert_eq!(report.inline_count(), 3, "{:?}", report.files);
let pack = PackArchive::open_default(&target).expect("打开导出的包");
let again = MODRINTH.read(&pack).expect("再读回来");
// 关键字段:名称 / 版本 / 游戏版本 / 加载器 / 文件集合。
assert_eq!(again.name, original.name);
assert_eq!(again.version, original.version);
assert_eq!(again.minecraft, original.minecraft);
let loader = again.loader(LoaderKind::Forge).expect("Forge 加载器");
assert_eq!(loader.version.as_deref(), Some("47.2.0"));
assert_eq!(override_paths(&again), override_paths(&original));
// 允许的差异①:CF 的 files[] 没有路径,磁盘上复原不出来 ⇒ 往返后 files[] 为空。
assert!(
again.files.is_empty(),
"CF 的 projectID/fileID 条目没有路径,无法从磁盘复原(已在文件头登记)"
);
}
#[test]
fn mrpack_fixture_round_trips_with_a_catalog_and_keeps_the_disabled_suffix() {
let fixture = fixtures::modrinth("rt-mr");
let pack = PackArchive::open_default(&fixture.zip).expect("打开 mrpack 夹具");
let original = MODRINTH.read(&pack).expect("读 mrpack 清单");
// 三个覆盖目录都摊到同一个实例里(实例目录不分侧)。
let game = instance_from(&fixture.dir, &original, "rt-mr-mr");
let fabric = r#"{
"id": "1.20.1-fabric-0.15.11",
"inheritsFrom": "1.20.1",
"libraries": [{"name": "net.fabricmc:fabric-loader:0.15.11"}]
}"#;
write_layer(&game, "1.20.1", VANILLA_LAYER);
write_layer(&game, "1.20.1-fabric-0.15.11", fabric);
// 夹具里那两条远程条目在磁盘上不存在;这里补一条**真**文件,让"远程引用"这条路径被往返到。
write_file(&game.join("mods/jei.jar"), b"PK\x03\x04 jei body");
write_file(&game.join("mods/disabled.jar.disabled"), b"PK\x03\x04 off");
let (locals, _) = crate::addon::export::scan_instance(&game, &defaults());
let jei = locals
.iter()
.find(|file| file.path == "mods/jei.jar")
.expect("jei");
let catalog = StubCatalog::default().with_modrinth(
&jei.sha1,
modrinth_hit(&jei.sha1, "https://cdn.modrinth.com/data/x/jei.jar"),
);
let target = game.with_file_name("mr-to-mr.mrpack");
let request = ExportRequest::new(&game, &target, &MODRINTH)
.with_name(original.name.clone())
.with_version(original.version.clone().unwrap_or_default())
.with_version_id("1.20.1-fabric-0.15.11")
.with_catalog(&catalog);
let report = export_instance(&request).expect("导出");
assert_eq!(report.remote_count(), 1);
assert!(report.is_complete());
let pack = PackArchive::open_default(&target).expect("打开导出的包");
let again = MODRINTH.read(&pack).expect("再读回来");
assert_eq!(again.name, original.name);
assert_eq!(again.version, original.version);
assert_eq!(again.minecraft, original.minecraft);
let loader = again.loader(LoaderKind::Fabric).expect("Fabric 加载器");
assert_eq!(loader.version.as_deref(), Some("0.15.11"));
// 文件集合:远程那条回来了(同一个 path),其余内联。
assert_eq!(again.files.len(), 1);
assert_eq!(again.files[0].path.as_deref(), Some("mods/jei.jar"));
let mut files = override_paths(&again);
files.sort();
assert_eq!(
files,
vec![
"config/client-only.toml".to_string(),
"config/known.toml".to_string(),
"config/server.properties".to_string(),
"mods/disabled.jar.disabled".to_string(),
"mods/half.jar".to_string(),
],
"夹具自带的四个覆盖文件 + 新增的禁用文件"
);
// 禁用位仍然靠路径后缀表达。
assert!(files
.iter()
.any(|path| path == "mods/disabled.jar.disabled"));
assert!(pack.has_entry("overrides/mods/disabled.jar.disabled"));
// 允许的差异②:认不出的 dependency 键(夹具里的 `parchment`)不复存在——
// 快照只来自版本 JSON 的库坐标,`parchment` 没有对应库。
assert!(
again.loaders.iter().all(|loader| loader.kind.is_some()),
"非加载器依赖不回写(已在文件头登记)"
);
}
#[test]
fn multimc_fixture_exports_as_mrpack_from_its_game_directory() {
// MultiMC/Prism **格式**的导出不做(整实例快照包,成本极高),但"把 Prism 实例的游戏目录
// 导出成 mrpack"是正当用例:快照从 `mmc-pack.json` 的组件表来(调用方用 `pack::multimc` 读)。
let instance = fixtures::multimc("rt-mmc");
let pack = PackArchive::open_default(&instance).expect("打开 MultiMC 实例");
let original = MULTIMC.read(&pack).expect("读 mmc-pack.json");
let snapshot = InstanceSnapshot {
game_version: original.minecraft.clone().expect("net.minecraft 组件"),
loaders: original
.loaders
.iter()
.filter_map(|loader| {
Some(SnapshotLoader {
kind: loader.kind?,
version: loader.version.clone()?,
})
})
.collect(),
};
let game = instance.join(".minecraft");
let target = instance.with_file_name("mmc.mrpack");
let report = export_instance(
&ExportRequest::new(&game, &target, &MODRINTH)
.with_name("MultiMC 实例导出")
.with_snapshot(&snapshot),
)
.expect("导出");
assert_eq!(report.minecraft.as_deref(), Some("1.20.1"));
assert_eq!(report.loaders, vec!["fabric-loader 0.15.11".to_string()]);
assert_eq!(report.inline_count(), 3, "{:?}", report.files);
let pack = PackArchive::open_default(&target).expect("打开导出的包");
let again = MODRINTH.read(&pack).expect("再读回来");
assert_eq!(again.minecraft.as_deref(), Some("1.20.1"));
assert_eq!(
again
.loader(LoaderKind::Fabric)
.and_then(|loader| loader.version.clone()),
Some("0.15.11".to_string())
);
assert_eq!(
override_paths(&again),
vec![
"config/known.toml".to_string(),
"mods/broken.jar".to_string(),
"mods/example.jar".to_string(),
]
);
// 有意的省略:MultiMC 格式本身仍然只读不写。
assert!(MULTIMC.metadata_entry().is_none(), "MultiMC 导出不做");
// 重新打成 MultiMC 形状的目录以确认没有丢内容(把 overrides 解出来再比)。
let unpacked = temp_root("rt-mmc-unpacked");
for entry in pack.entries() {
let name = entry.name();
let Some(relative) = name.strip_prefix("overrides/") else {
continue;
};
let bytes = pack.read(name).expect("读条目").expect("有条目");
write_file(&unpacked.join(relative), &bytes);
}
for relative in ["config/known.toml", "mods/broken.jar", "mods/example.jar"] {
assert!(
unpacked.join(relative).is_file(),
"{relative} 没进包(内容丢了)"
);
}
}
#[test]
fn a_same_format_export_is_still_allowed_and_byte_stable() {
// 反向对照:新管线不能把 M110 的同格式往返弄坏(r1 明确要保住的能力)。
let fixture = fixtures::curseforge("rt-same-cf");
let pack = PackArchive::open_default(&fixture.zip).expect("打开 CF 夹具");
let original = CURSEFORGE.read(&pack).expect("读 CF 清单");
let game = instance_from(&fixture.dir, &original, "rt-same-cf-game");
write_layer(&game, "1.20.1-forge-47.2.0", FORGE_LAYER);
let target = game.with_file_name("same.zip");
export_instance(
&ExportRequest::new(&game, &target, &CURSEFORGE)
.with_name(original.name.clone())
.with_version(original.version.clone().unwrap_or_default())
.with_version_id("1.20.1-forge-47.2.0"),
)
.expect("导出");
let pack = PackArchive::open_default(&target).expect("打开导出结果");
let again = CURSEFORGE.read(&pack).expect("再读回来");
assert_eq!(again.name, original.name);
assert_eq!(again.minecraft, original.minecraft);
assert_eq!(
again
.loader(LoaderKind::Forge)
.and_then(|loader| loader.version.clone()),
Some("47.2.0".to_string())
);
assert_eq!(override_paths(&again), override_paths(&original));
assert_eq!(
again
.overrides_for(PackSide::Both)
.map(|group| group.dir.clone()),
Some("overrides".to_string())
);
}
+172
View File
@@ -0,0 +1,172 @@
//! 扩展点判据:加一个假格式(**一个文件、一条注册**)就能被探测与读写,
//! 且不碰模型、不碰 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) -> PackResult<Option<&'static str>> {
let Some(value) = pack.read_json("toy.json")? else {
return Ok(None);
};
Ok(value.is_object().then_some("toy.json"))
}
fn read(&self, pack: &PackArchive) -> PackResult<PackManifest> {
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<String> {
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"]);
}
@@ -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
}
+108
View File
@@ -0,0 +1,108 @@
//! `addon::pack` 的单元测试:夹具读回、未知字段往返、探测优先级、扩展点、恶意样本。
//!
//! **全部离线**:没有网络调用,也没有真实整合包样本(见 `fixtures` 的来源声明)。
mod detect;
mod export;
mod export_harness;
mod export_pipeline;
mod export_roundtrip;
mod extend;
mod fixtures;
mod model;
mod roundtrip;
mod safety;
mod unknown_fields;
mod zip64;
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::<Result<Vec<_>, _>>()
.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");
}
+111
View File
@@ -0,0 +1,111 @@
//! 模型级辅助与读取预算:r1 p2-2(跨侧重名)与 p3-4(批量读取的累计限额)。
use super::fixtures;
use crate::addon::pack::archive::{PackArchive, ReadBudget};
use crate::addon::pack::error::PackErrorKind;
use crate::addon::pack::manifest::{OverrideFile, PackManifest, PackOverrides, PackSide};
fn file(path: &str, sha1: &str, size: u64) -> OverrideFile {
OverrideFile {
path: path.to_string(),
sha1: sha1.to_string(),
size,
}
}
#[test]
fn duplicate_override_paths_counts_across_sides_not_per_side() {
// r1 p2-2:mrpack 的真实冲突是 `overrides`(Both) 与 `client-overrides`(Client) 撞同一路径,
// 按 `(side, path)` 计数时两侧键天然不同 ⇒ 永远返回空表。
let mut both = PackOverrides::new("overrides", PackSide::Both);
both.files.push(file("config/x.toml", "a", 1));
both.files.push(file("config/only-both.toml", "b", 1));
let mut client = PackOverrides::new("client-overrides", PackSide::Client);
client.files.push(file("config/x.toml", "c", 2));
let mut server = PackOverrides::new("server-overrides", PackSide::Server);
server.files.push(file("config/only-server.toml", "d", 3));
let manifest = PackManifest {
overrides: vec![both, client, server],
..PackManifest::default()
};
assert_eq!(
manifest.duplicate_override_paths(),
vec![(
"config/x.toml".to_string(),
vec![PackSide::Both, PackSide::Client],
2
)],
"只报真正撞车的路径,并给出涉及的侧与条目份数"
);
}
#[test]
fn duplicate_override_paths_still_reports_two_groups_on_the_same_side() {
// r2 p2-2:上一版按侧去重,把这半能力换掉了 —— 两个 Both 组撞同一路径实测返回 `[]`。
// 现在判据是「侧数 > 1 || 条份数 > 侧数」,两种冲突同时报得出来。
let mut first = PackOverrides::new("overrides", PackSide::Both);
first.files.push(file("config/x.toml", "a", 1));
let mut second = PackOverrides::new("extra-overrides", PackSide::Both);
second.files.push(file("config/x.toml", "b", 2));
let manifest = PackManifest {
overrides: vec![first, second],
..PackManifest::default()
};
assert_eq!(
manifest.duplicate_override_paths(),
vec![("config/x.toml".to_string(), vec![PackSide::Both], 2)],
"同侧两个组撞同一路径必须报(侧集合只有一个,份数是 2)"
);
}
#[test]
fn duplicate_override_paths_reports_the_same_path_twice_inside_one_group() {
// 同侧撞车的另一种形态:一个组里同一路径列了两次(模型被合并出来时会出现)。
let mut group = PackOverrides::new("overrides", PackSide::Both);
group.files.push(file("config/x.toml", "a", 1));
group.files.push(file("config/x.toml", "b", 2));
let manifest = PackManifest {
overrides: vec![group],
..PackManifest::default()
};
assert_eq!(
manifest.duplicate_override_paths(),
vec![("config/x.toml".to_string(), vec![PackSide::Both], 2)]
);
}
#[test]
fn duplicate_override_paths_is_empty_when_nothing_collides() {
let mut both = PackOverrides::new("overrides", PackSide::Both);
both.files.push(file("config/a.toml", "a", 1));
let mut client = PackOverrides::new("client-overrides", PackSide::Client);
client.files.push(file("config/b.toml", "b", 1));
let manifest = PackManifest {
overrides: vec![both, client],
..PackManifest::default()
};
assert!(manifest.duplicate_override_paths().is_empty());
}
#[test]
fn a_batch_scan_spends_one_cumulative_budget() {
// r1 p3-4:打开阶段只能看声明大小;批量读取(override_files)必须按**实际字节**累计扣减,
// 否则"很多条各自合规"累起来就能绕过 `max_uncompressed_bytes`(CPU/时间 DoS)。
let fixture = fixtures::modrinth("budget");
let pack = PackArchive::open_default(&fixture.zip).expect("打开 mrpack 夹具");
let generous = ReadBudget::new(1024 * 1024);
let files = pack
.override_files_with("overrides", &generous)
.expect("预算够时必须成功");
assert_eq!(files.len(), 2);
// 预算只有 4 字节:第一个文件(config/known.toml,15 字节)就该把预算吃穿。
let tight = ReadBudget::new(4);
let error = pack
.override_files_with("overrides", &tight)
.expect_err("累计预算必须生效");
assert_eq!(error.kind(), PackErrorKind::Limits, "{error}");
assert_eq!(tight.remaining(), 0, "失败后预算不该被退回");
}
@@ -0,0 +1,306 @@
//! 读回模型 + "导出后再读"的往返一致性:**未知字段不丢**就在这些断言里被钉住。
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).expect("清单可读"),
Some("manifest.json"),
"形状对得上就该被探测到"
);
let error = CURSEFORGE.read(&pack).expect_err("缺 name 必须报错");
assert_eq!(error.kind(), PackErrorKind::Metadata);
}
#[test]
fn disabled_entries_export_as_path_suffix_on_mrpack_and_required_false_on_curseforge() {
// 禁用位(M115 调研 §字段缺口 2 / §8.1)在两边的落法不同,且都不该靠"猜":
// mrpack → `files[].path` 结尾 `.disabled`(官方导出的表达,**不是** env.client=optional:
// 后者只是"安装时可取消勾选",装回来默认仍然启用 ⇒ 跨启动器不保真);
// CF → 没有原生表达,格式层只能写 `required: false`(Prism 同款);导出**管线**更严:
// 禁用文件直接内联进 overrides/ 并保留后缀(见 export::pipeline 的 DisabledNotExpressible)。
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].path.as_deref(),
Some("mods/jei.jar.disabled"),
"禁用靠路径后缀表达(官方导出的做法,跨启动器保真)"
);
assert_eq!(
reread.files[0].client,
EnvSupport::Required,
"禁用不再被翻译成 env.client=optional(tower 定案)"
);
}
+312
View File
@@ -0,0 +1,312 @@
//! 恶意样本:每条安全判据都配一条"会失败的包",而不是靠注释保证。
//!
//! 覆盖:路径穿越四类形状(`..` / 绝对路径 / 反斜杠 / 盘符)、符号链接条目、条目数上限、
//! 解压总体积上限、单条目上限、规整后重名、篡改载荷(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 侧共用同一条判据)"
);
}
}
#[test]
#[cfg(windows)]
fn the_second_check_catches_windows_device_names_and_dot_suffixes() {
// r1 p2-3:第一检(平台无关的七条)对这些名字是**放行**的,拦下它们的是第二检
// `enclosed_name()` 的 Win32 命名空间规则(DOS 设备名、结尾点/空格、`< > " | ? *`)。
// 删掉第二检,这条用例必须红——这是「双检各有一条恶意样本」的第二条。
for (tag, name) in [
("dev-con", "CON"),
("dev-nul-nested", "mods/NUL"),
("dev-com1", "COM1.txt"),
("dev-trailing-dot", "a."),
("dev-trailing-space", "mods/b "),
("dev-wildcard", "mods/*.jar"),
] {
let target = single_entry(tag, name);
let error = open_error(&target);
assert_eq!(
error.kind(),
PackErrorKind::Traversal,
"{name}: {}",
error.message()
);
assert!(
error.message().contains("enclosed_name"),
"{name} 必须是被第二检拦下的(不然这条用例钉错了地方):{}",
error.message()
);
}
// 对照:第一检对这些名字没有任何意见(所以它们只能靠第二检)。
for name in ["CON", "mods/NUL", "a.", "mods/b ", "mods/*.jar"] {
assert!(
crate::addon::pack::path::normalize_file(name).is_ok(),
"第一检不该拦 {name}(拦了这条用例就失去意义)"
);
}
}
@@ -0,0 +1,166 @@
//! 「未知字段不丢」的三处条目级 flatten(r1 p3-3):`env_extra`、CF `files[].extra`、
//! `PackLoader::extra`。三处实现本来是对的,但此前一条断言都没有——而它们恰好是 M115 点名的
//! HMCL 回写 `fileName`/`url` 的位置。每条都走「读 → render → 写整包 → 再读逐字段相等」。
use serde_json::json;
use crate::addon::pack::archive::PackArchive;
use crate::addon::pack::curseforge::CURSEFORGE;
use crate::addon::pack::format::{PackFormat, PackPayload};
use crate::addon::pack::modrinth::MODRINTH;
#[test]
fn mrpack_env_unknown_keys_survive_read_write_reread() {
// r1 p3-3 的一处:`files[].env` 里的未知键(`env_extra`)此前没有任何断言。
let root = super::temp_root("env-extra");
let dir = root.join("src");
super::write_file(
&dir.join("modrinth.index.json"),
br#"{"game":"minecraft","formatVersion":1,"versionId":"1.0","name":"env extra keys",
"files":[{"path":"mods/a.jar",
"hashes":{"sha512":"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"},
"env":{"client":"required","server":"unsupported","customEnvKey":{"k":1}},
"downloads":["https://example.invalid/a.jar"]}],
"dependencies":{"minecraft":"1.20.1"}}"#,
);
let zip = root.join("pack.mrpack");
super::zip_dir(&dir, &zip);
let pack = PackArchive::open_default(&zip).expect("打开包");
let manifest = MODRINTH.read(&pack).expect("读 mrpack 清单");
assert_eq!(manifest.files[0].env_extra["customEnvKey"], json!({"k": 1}));
let target = root.join("out.mrpack");
MODRINTH
.write(&manifest, PackPayload::metadata_only(), &target)
.expect("导出 mrpack");
let reread = MODRINTH
.read(&PackArchive::open_default(&target).expect("打开导出包"))
.expect("再读");
assert_eq!(reread, manifest, "env 里的未知键必须原样回写");
assert_eq!(reread.files[0].env_extra["customEnvKey"], json!({"k": 1}));
}
#[test]
fn curseforge_file_unknown_keys_survive_read_write_reread() {
// r1 p3-3 的另一处:CF `files[].fileName` / `url` 是 HMCL 补全后回写的扩展点。
let root = super::temp_root("cf-file-extra");
let dir = root.join("src");
super::write_file(
&dir.join("manifest.json"),
br#"{"manifestType":"minecraftModpack","manifestVersion":1,"name":"files extra keys",
"version":"1.0","minecraft":{"version":"1.20.1","modLoaders":[{"id":"forge-47.2.0","primary":true}]},
"files":[{"projectID":238222,"fileID":5102025,"required":true,
"fileName":"jei-1.20.1-15.2.0.27.jar",
"url":"https://edge.forgecdn.net/files/5102/25/jei.jar"}]}"#,
);
let zip = root.join("pack.zip");
super::zip_dir(&dir, &zip);
let pack = PackArchive::open_default(&zip).expect("打开包");
let manifest = CURSEFORGE.read(&pack).expect("读 CF 清单");
assert_eq!(
manifest.files[0].extra["fileName"],
json!("jei-1.20.1-15.2.0.27.jar")
);
assert_eq!(
manifest.files[0].extra["url"],
json!("https://edge.forgecdn.net/files/5102/25/jei.jar")
);
let target = root.join("out.zip");
CURSEFORGE
.write(&manifest, PackPayload::metadata_only(), &target)
.expect("导出 CF");
let reread = CURSEFORGE
.read(&PackArchive::open_default(&target).expect("打开导出包"))
.expect("再读");
assert_eq!(reread, manifest, "files[] 里的未知键必须原样回写");
}
#[test]
fn curseforge_loader_unknown_keys_survive_read_write_reread() {
// r1 p3-3 的第三处:`modLoaders[]` 里的未知键(`PackLoader::extra`)。
let root = super::temp_root("cf-loader-extra");
let dir = root.join("src");
super::write_file(
&dir.join("manifest.json"),
br#"{"manifestType":"minecraftModpack","manifestVersion":1,"name":"loader extra keys",
"version":"1.0",
"minecraft":{"version":"1.20.1",
"modLoaders":[{"id":"forge-47.2.0","primary":true,"cachedName":"Forge 47.2.0"}]},
"files":[]}"#,
);
let zip = root.join("pack.zip");
super::zip_dir(&dir, &zip);
let pack = PackArchive::open_default(&zip).expect("打开包");
let manifest = CURSEFORGE.read(&pack).expect("读 CF 清单");
assert_eq!(
manifest.loaders[0].extra["cachedName"],
json!("Forge 47.2.0")
);
let target = root.join("out.zip");
CURSEFORGE
.write(&manifest, PackPayload::metadata_only(), &target)
.expect("导出 CF");
let reread = CURSEFORGE
.read(&PackArchive::open_default(&target).expect("打开导出包"))
.expect("再读");
assert_eq!(reread, manifest, "modLoaders[] 里的未知键必须原样回写");
assert_eq!(reread.loaders[0].extra["cachedName"], json!("Forge 47.2.0"));
}
#[test]
fn mrpack_file_unknown_keys_survive_read_write_reread() {
// 第四个假边(M152 补):`files[]` 这一层。上面三条覆盖了 `env` / CF `files[]` /
// CF `modLoaders[]`,而 mrpack 的文件级未知键一直没人钉——它恰恰是"社区给某条文件加
// 一个私有字段"最常出现的位置(例如镜像站塞的 `mirror` / 作者写的 `optionalReason`)。
let root = super::temp_root("mr-file-extra");
let dir = root.join("src");
super::write_file(
&dir.join("modrinth.index.json"),
br#"{"game":"minecraft","formatVersion":1,"versionId":"1.0","name":"file extra keys",
"files":[{"path":"mods/a.jar",
"hashes":{"sha1":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"sha512":"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"md5":"d41d8cd98f00b204e9800998ecf8427e"},
"downloads":["https://cdn.modrinth.com/data/abc/versions/x/a.jar"],
"fileSize":42,
"mirror":"https://mirror.example.invalid/a.jar",
"optionalReason":{"why":"kept for the community"}}],
"dependencies":{"minecraft":"1.20.1"}}"#,
);
let zip = root.join("pack.mrpack");
super::zip_dir(&dir, &zip);
let pack = PackArchive::open_default(&zip).expect("打开包");
let manifest = MODRINTH.read(&pack).expect("读 mrpack 清单");
assert_eq!(
manifest.files[0].extra["mirror"],
json!("https://mirror.example.invalid/a.jar")
);
assert_eq!(
manifest.files[0].extra["optionalReason"],
json!({"why": "kept for the community"})
);
// 未知**哈希算法**也不丢:`hashes` 是整张表,不是两个字段。
assert_eq!(
manifest.files[0].hashes.get("md5").map(String::as_str),
Some("d41d8cd98f00b204e9800998ecf8427e")
);
let target = root.join("out.mrpack");
MODRINTH
.write(&manifest, PackPayload::metadata_only(), &target)
.expect("导出 mrpack");
let reread = MODRINTH
.read(&PackArchive::open_default(&target).expect("打开导出包"))
.expect("再读");
assert_eq!(reread, manifest, "files[] 里的未知键必须原样回写");
assert_eq!(
reread.files[0].extra["optionalReason"],
json!({"why": "kept for the community"})
);
}
+182
View File
@@ -0,0 +1,182 @@
//! ZIP64 拒写(research/18-p2 §8.6 / 表 B #17 的导出侧那条)。
//!
//! # 为什么这个文件以前是空的
//!
//! M112 的完成报告写明:「ZIP64 阈值那条分支只有代码没有端到端测试(要造 4 GiB 数据)」。
//! 而更要紧的是——**那条分支当时根本够不着**:实现先 `std::fs::read` 整个覆盖文件进内存,
//! 再累加比较,所以真要导出一个 4 GiB 文件的实例,内存先炸,用户永远看不到那句
//! `pack_limits`。现在写侧改成「先按 `metadata().len()` 预检」,两个问题一起解决:
//!
//! - 边界是真的会在读第一个字节之前拦下来的(本文件的用例就断言这一点:失败后临时目录里
//! 没有 `.part`、目标路径不存在);
//! - 测试可以用**稀疏文件**造出 4 GiB 的"声明尺寸"而几乎不占磁盘(`set_len` 只改 EOF,
//! NTFS / ext4 都不会真写零)。这也是唯一能在不塞满硬盘的前提下打通这条分支的办法。
use std::path::Path;
use super::temp_root;
use crate::addon::pack::curseforge::CURSEFORGE;
use crate::addon::pack::error::PackErrorKind;
use crate::addon::pack::format::{PackFormat, PackPayload};
use crate::addon::pack::manifest::{OverrideFile, PackManifest, PackOverrides, PackSide};
/// 一个覆盖文件的声明(内容由调用方提供)。
fn override_file(path: &str) -> OverrideFile {
OverrideFile {
path: path.to_string(),
sha1: "0".repeat(40),
size: 0,
}
}
fn manifest_of(files: Vec<OverrideFile>) -> PackManifest {
let mut manifest = PackManifest::new("超大包");
manifest.version = Some("1.0.0".to_string());
manifest.minecraft = Some("1.20.1".to_string());
manifest.overrides.push(PackOverrides {
dir: "overrides".to_string(),
side: PackSide::Both,
files,
});
manifest
}
/// 造一个只占元数据的 `length` 字节文件(`set_len` 不写零)。
fn sparse_file(path: &Path, length: u64) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("建目录");
}
let file = std::fs::File::create(path).expect("建稀疏文件");
file.set_len(length).expect("拉长文件(稀疏)");
}
/// 临时目录里除 `keep` 之外的文件名(断言"没留下 .part"用)。
fn leftovers(root: &Path, keep: &[&str]) -> Vec<String> {
let mut names: Vec<String> = std::fs::read_dir(root)
.expect("列目录")
.filter_map(Result::ok)
.map(|entry| entry.file_name().to_string_lossy().to_string())
.filter(|name| !keep.contains(&name.as_str()))
.collect();
names.sort();
names
}
#[test]
fn one_file_over_the_threshold_is_refused_before_a_single_byte_is_read() {
let root = temp_root("zip64-single");
// 恰好一个字节超过阈值:预检必须在这里就报,不许读内容(真读会先要 4 GiB 内存)。
sparse_file(
&root.join("overrides/mods/huge.jar"),
zip::ZIP64_BYTES_THR + 1,
);
let manifest = manifest_of(vec![override_file("mods/huge.jar")]);
let target = root.join("out.zip");
let error = CURSEFORGE
.write(&manifest, PackPayload::from_root(&root), &target)
.expect_err("超 ZIP64 阈值必须拒写");
assert_eq!(error.kind(), PackErrorKind::Limits, "{error}");
assert!(error.message().contains("ZIP64"), "{error}");
assert!(
error.hint().is_some_and(|hint| hint.contains("移出实例")),
"要给「把大文件移出实例」这种可执行的提示:{error:?}"
);
assert!(!target.exists(), "失败后目标路径不许存在");
// 临时文件也不许留(预检在建临时文件之前就失败 ⇒ 目录里只有源文件)。
assert_eq!(leftovers(&root, &["overrides"]), Vec::<String>::new());
}
#[test]
fn the_threshold_is_cumulative_not_per_file() {
// 三个文件各自都远小于阈值,加起来超了:判据是**整包未压缩总量**,不是单文件。
let root = temp_root("zip64-cumulative");
let each = 1_610_612_736_u64; // 1.5 GiB
assert!(each < zip::ZIP64_BYTES_THR, "单条必须都在阈值以下");
assert!(each * 3 > zip::ZIP64_BYTES_THR, "三条加起来必须超标");
let mut files = Vec::new();
for index in 0..3 {
let relative = format!("mods/part-{index}.jar");
sparse_file(&root.join("overrides").join(&relative), each);
files.push(override_file(&relative));
}
let target = root.join("out.zip");
let error = CURSEFORGE
.write(&manifest_of(files), PackPayload::from_root(&root), &target)
.expect_err("累计超阈值同样必须拒写");
assert_eq!(error.kind(), PackErrorKind::Limits, "{error}");
assert!(error.message().contains("ZIP64"), "{error}");
assert!(!target.exists());
}
#[test]
fn an_export_under_the_threshold_still_writes_a_normal_pack() {
// 对照:不超阈值时预检不许误伤(否则这条护栏就变成"导出全挂")。
let root = temp_root("zip64-under");
let content = b"a normal sized override file";
std::fs::create_dir_all(root.join("overrides/mods")).expect("建目录");
std::fs::write(root.join("overrides/mods/jei.jar"), content).expect("写覆盖文件");
let manifest = manifest_of(vec![override_file("mods/jei.jar")]);
let target = root.join("out.zip");
CURSEFORGE
.write(&manifest, PackPayload::from_root(&root), &target)
.expect("正常尺寸必须写得出来");
assert!(target.exists());
let written = crate::addon::pack::PackArchive::open_default(&target).expect("打开导出的包");
assert!(written.has_entry("overrides/mods/jei.jar"));
}
/// 端到端版的窗口回归钉(**不进默认套件**:真写 2.5 GB 要 ~70 秒 deflate,且会把机器内存 /
/// 页面文件顶上去;塔台 2026-11-07 的资源配置令要求避免)。
///
/// 默认套件里由 `format::write` 的单测 `the_second_gate_counts_from_the_manifest_length_not_
/// the_preflight_total` 便宜地钉住同一个窗口(把窗口里的字节数直接喂给第二道闸的计数器)。
/// 本条留给"真跑一遍确认容器层接线没错"的场合:
///
/// ```text
/// cargo test -p unlucky-core --lib -- zip64 --ignored --nocapture
/// ```
///
/// 交付时的实测(M152 r2):单文件 2 500 000 000 字节导出**成功**,包内条目声明尺寸原样为
/// 2.5 GB,耗时 ~70 秒、源文件稀疏(占盘 0),产物是压缩后的零(几 MB)。
#[test]
#[ignore = "真写 2.5 GB(~70 秒 deflate + 一片内存):默认套件用 Written 的便宜单测钉同一窗口"]
fn a_single_file_inside_the_double_count_window_still_exports() {
// P2-1 的回归钉:预检返回的是**全量**声明尺寸,写循环曾经直接拿它当累加基数,于是每个
// 文件的字节被算两遍 —— 真实总量只要过半阈值(≈2.15 GiB)就在内容读完**之后**被误拒。
// 2.5 GB 正落在这个窗口里:它必须能导出,且中途不许把整个人内容摊进内存。
let root = temp_root("zip64-window");
let length = 2_500_000_000_u64;
assert!(length < zip::ZIP64_BYTES_THR, "样本必须在阈值之下");
assert!(
length > zip::ZIP64_BYTES_THR / 2,
"样本必须落在过拒窗口里(≥ 阈值一半)"
);
sparse_file(&root.join("overrides/mods/big.jar"), length);
let manifest = manifest_of(vec![override_file("mods/big.jar")]);
let target = root.join("out.zip");
let result = CURSEFORGE.write(&manifest, PackPayload::from_root(&root), &target);
assert!(
result.is_ok(),
"2.5 GB 的覆盖文件没超 ZIP64 阈值,不许误拒:{:?}",
result.err()
);
// 包内条目的**声明未压缩尺寸**就是 2.5 GB(真写出来的是压缩后的零,只有几 MB)。
// 这里直接用 `zip` 的读者,而不是 `PackArchive::open_default`:后者对**不可信输入**有
// 单条目 64 MiB 的上限(`PackLimits`),拿它读我们自己刚写的包会撞限额,与本次判据无关。
let file = std::fs::File::open(&target).expect("打开导出包");
let mut archive = zip::ZipArchive::new(file).expect("读 zip");
let entry = archive
.by_name("overrides/mods/big.jar")
.expect("覆盖条目要在包里");
assert_eq!(entry.size(), length, "声明尺寸要原样带过去");
// 稀疏源文件只占元数据,但产物与目录都清掉,别让 temp 越积越多。
let _ = std::fs::remove_dir_all(&root);
}
@@ -0,0 +1,134 @@
//! CurseForge `manifest.json` 端到端导入(离线,stub fetcher 充当 CF API + CDN)。
//!
//! 夹具是 M110 的手造包(`fixtures::curseforge`):`overrides/` 里有一个**刻意的截断 TOML**
//! (`config/broken.toml`)与一个假 jar。导入器不许因为坏内容崩,也不许悄悄丢掉它们。
use crate::addon::import::{import_pack, ImportRequest, ImportStatus};
use crate::addon::loader::LoaderKind;
use crate::download::blocking::block_on;
use super::support::{curse_meta, StubFetcher, CURSE_URL};
use super::{fixtures, list_tree, read_text, temp_root};
/// CF 夹具里那两个条目补全出来的内容。
const CURSE_BYTES: &[u8] = b"stub curse file bytes";
#[test]
fn imports_curseforge_pack_end_to_end() {
let fixture = fixtures::curseforge("cf-e2e");
let target = temp_root("cf-e2e-out").join("instance");
let fetcher = StubFetcher::new()
.with_curse(238222, 5102025, curse_meta("jei.jar", CURSE_BYTES))
.with_curse(306612, 4587454, curse_meta("fabric-api.jar", CURSE_BYTES))
.with_file(CURSE_URL, CURSE_BYTES);
let request = ImportRequest::new(&fixture.zip, &target).with_fetcher(&fetcher);
let report = block_on(import_pack(&request));
assert_eq!(
report.status,
ImportStatus::Success,
"状态 {:?},缺文件 {:?},冲突 {:?},错误 {:?}",
report.status,
report.missing,
report.conflicts,
report.error
);
assert_eq!(report.format.as_deref(), Some("curseforge"));
assert_eq!(report.pack_name.as_deref(), Some(fixtures::CF_NAME));
assert_eq!(report.pack_version.as_deref(), Some("1.0.0"));
assert_eq!(report.game_version.as_deref(), Some("1.20.1"));
assert!(!report.rolled_back);
// 加载器被翻译成 addon::loader 的三元组(不另写一套安装逻辑)。
let loader = report.loader.as_ref().expect("CF 夹具带 forge");
assert_eq!(loader.kind, LoaderKind::Forge);
assert_eq!(loader.loader_version, "47.2.0");
assert_eq!(loader.version_id, "1.20.1-forge-47.2.0");
assert_eq!(
loader.resolve_args(),
(LoaderKind::Forge, "1.20.1", "47.2.0")
);
assert_eq!(report.version_id.as_deref(), Some("1.20.1-forge-47.2.0"));
// 覆盖内容(`overrides/` 的内容铺到实例根,不多一层目录)。
assert!(read_text(&target.join("config/known.toml")).contains("key"));
// 刻意的截断 TOML 必须原样落地:导入器不是校验器,不许因为内容坏就丢文件。
assert_eq!(
read_text(&target.join("config/broken.toml")),
String::from_utf8_lossy(fixtures::CF_BROKEN)
);
// 两个 CF 条目都被补全 + 下载(文件名与目录都来自 CF 元数据)。
assert_eq!(
std::fs::read(target.join("mods/jei.jar")).expect("jei.jar"),
CURSE_BYTES
);
assert_eq!(
std::fs::read(target.join("mods/fabric-api.jar")).expect("fabric-api.jar"),
CURSE_BYTES
);
assert_eq!(
fetcher.calls(),
vec![CURSE_URL.to_string(), CURSE_URL.to_string()]
);
// 基线写进去了(下次导入的 base 侧)。
let baseline = read_text(&target.join(super::baseline_rel()));
assert!(baseline.contains("mods/jei.jar"), "基线:{baseline}");
assert!(baseline.contains("config/known.toml"), "基线:{baseline}");
assert_eq!(
list_tree(&target),
vec![
super::baseline_rel(),
"config/broken.toml".to_string(),
"config/known.toml".to_string(),
"mods/example.jar".to_string(),
"mods/fabric-api.jar".to_string(),
"mods/jei.jar".to_string(),
]
);
}
#[test]
fn offline_curseforge_import_is_partial_not_success() {
// 没有 fetcher ⇒ CF 的 files[] 一条都补不出来。这**不是**失败,也**不许**记成成功。
let fixture = fixtures::curseforge("cf-offline");
let target = temp_root("cf-offline-out").join("instance");
let request = ImportRequest::new(&fixture.zip, &target);
let report = block_on(import_pack(&request));
assert_eq!(report.status, ImportStatus::Partial);
assert_eq!(report.missing.len(), 2, "{:?}", report.missing);
assert!(report
.missing
.iter()
.all(|missing| missing.message_key == crate::addon::import::keys::MISSING_OFFLINE));
// 覆盖内容照装(离线不影响包内自带的内容)。
assert!(target.join("config/known.toml").is_file());
assert!(!target.join("mods/jei.jar").exists());
// 3 = Partial 的退出码(CLI 契约)。
assert_eq!(report.status.exit_code(), 3);
}
#[test]
fn curseforge_meta_without_download_url_is_missing_not_guessed() {
// CF 作者可以关掉 API 分发(`downloadUrl` 为 null):我们**不猜** URL,只如实记缺。
let fixture = fixtures::curseforge("cf-nourl");
let target = temp_root("cf-nourl-out").join("instance");
let mut meta = curse_meta("jei.jar", CURSE_BYTES);
meta.url = None;
let fetcher = StubFetcher::new()
.with_curse(238222, 5102025, meta)
.with_file(CURSE_URL, CURSE_BYTES);
let request = ImportRequest::new(&fixture.zip, &target).with_fetcher(&fetcher);
let report = block_on(import_pack(&request));
assert_eq!(report.status, ImportStatus::Partial);
assert!(report
.missing
.iter()
.any(|missing| missing.message_key == crate::addon::import::keys::MISSING_NO_SOURCE));
// 没有发出任何下载请求。
assert!(fetcher.calls().is_empty(), "{:?}", fetcher.calls());
}
@@ -0,0 +1,254 @@
//! **必须先失败**的样本:四条恶意/坏包 + 两条"证明双检真的在导入链上跑"的用例。
//!
//! # 复审要的那张表
//!
//! | 样本 | 期望结果 | 断言在哪 |
//! |------|----------|----------|
//! | 路径穿越条目(`../evil.jar`) | `Failed` + `pack_traversal` + 目标目录零残留 | [`traversal_entry_rolls_back_with_zero_residue`] |
//! | 符号链接条目 | `Failed` + `pack_symlink` + 零残留 | [`symlink_entry_rolls_back_with_zero_residue`] |
//! | 损坏清单(非法 JSON) | `Failed` + `pack_metadata` + 零残留 | [`corrupt_manifest_rolls_back_with_zero_residue`] |
//! | 声明与实际不符(sha1 不符) | `Failed` + `import_integrity` + 零残留 | [`declared_hash_mismatch_rolls_back_everything`] |
//!
//! # 零残留的验证方式
//!
//! [`assert_clean`]:①目标目录不存在;②目标目录的父目录里没有 `.ull-import-*` 临时目录
//! (临时目录是"事务没收拾干净"的唯一痕迹);③合并态用例另外断言目标目录内容与导入前
//! **逐项相同**。三条合起来就是"失败后既没有半个实例,也没有残留垃圾"。
//!
//! # 两条双检证明
//!
//! 路径穿越是**两条独立检查**(M110 `pack::path::normalize_file` + `zip` 的
//! `enclosed_name()`)。要证明两条都真的接在导入链上,得各给一条**只有其中一条能拦**的样本:
//!
//! - 绝对路径 `/etc/passwd`:`enclosed_name()` 在深度 0 处**放行**(它允许根目录),只有第一检
//! 能拦 ⇒ 它失败就说明第一检活着;
//! - Windows 设备名 `NUL`:第一检七条规则全放行,只有第二检(按 Win32 命名空间解析)能拦
//! ⇒ 它失败就说明第二检活着(该用例仅在 Windows 宿主上有效,与 M110 的用例同构)。
use std::path::Path;
use crate::addon::import::{import_pack, ImportRequest, ImportStatus};
use crate::download::blocking::block_on;
use super::support::{StubFetcher, JEI_BYTES, JEI_PLACEHOLDER_URL, OPTIONAL_PLACEHOLDER_URL};
use super::{
fixtures, list_tree, read_text, temp_root, write_file, write_zip, write_zip_with_symlink,
};
/// 一份形状合法的最小 CF 清单(恶意包要能被"探测成功",失败必须发生在它该发生的阶段)。
const CF_MANIFEST: &[u8] = br#"{"manifestType": "minecraftModpack", "manifestVersion": 1,
"name": "evil", "version": "1.0.0", "overrides": "overrides",
"minecraft": {"version": "1.20.1", "modLoaders": []}, "files": []}"#;
/// 断言"目标目录零残留":目录不存在、父目录里没有临时目录。
fn assert_clean(target: &Path) {
assert!(
!target.exists(),
"目标目录必须不存在(零残留),实际:{:?}",
list_tree(target)
);
let parent = target.parent().expect("target 有父目录");
if let Ok(entries) = std::fs::read_dir(parent) {
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
assert!(!name.contains(".ull-import-"), "临时目录没清掉:{name}");
}
}
}
fn failing(tag: &str, entries: &[(&str, &[u8])]) -> (std::path::PathBuf, std::path::PathBuf) {
let root = temp_root(tag);
let pack = root.join("evil.zip");
write_zip(&pack, entries);
let target = root.join("instance");
(pack, target)
}
#[test]
fn traversal_entry_rolls_back_with_zero_residue() {
let (pack, target) = failing(
"fail-traversal",
&[("manifest.json", CF_MANIFEST), ("../evil.jar", b"payload")],
);
let report = block_on(import_pack(&ImportRequest::new(&pack, &target)));
assert_eq!(report.status, ImportStatus::Failed);
assert_eq!(
report.error.as_ref().expect("具名错误").code(),
"pack_traversal"
);
assert!(report.rolled_back);
assert_clean(&target);
assert_eq!(report.status.exit_code(), 1);
}
#[test]
fn absolute_entry_proves_the_first_check_is_live() {
// `enclosed_name()` 会放行深度 0 的绝对路径,只有 `normalize_file` 能拦。
let (pack, target) = failing(
"fail-absolute",
&[
("manifest.json", CF_MANIFEST),
("/etc/passwd", b"root::0:0"),
],
);
let report = block_on(import_pack(&ImportRequest::new(&pack, &target)));
assert_eq!(report.status, ImportStatus::Failed);
assert_eq!(
report.error.as_ref().expect("具名错误").code(),
"pack_traversal"
);
assert_clean(&target);
}
#[cfg(windows)]
#[test]
fn windows_device_name_proves_the_second_check_is_live() {
// `NUL` 过得了第一检的七条规则,只有 `zip` 的 `enclosed_name()`(Win32 命名空间)会拦。
let (pack, target) = failing(
"fail-device",
&[("manifest.json", CF_MANIFEST), ("mods/NUL", b"x")],
);
let report = block_on(import_pack(&ImportRequest::new(&pack, &target)));
assert_eq!(report.status, ImportStatus::Failed);
assert_eq!(
report.error.as_ref().expect("具名错误").code(),
"pack_traversal"
);
assert_clean(&target);
}
#[test]
fn symlink_entry_rolls_back_with_zero_residue() {
let root = temp_root("fail-symlink");
let pack = root.join("evil.zip");
let entries: [(&str, &[u8]); 1] = [("manifest.json", CF_MANIFEST)];
write_zip_with_symlink(&pack, &entries, "mods/evil.jar", "../../../etc/passwd");
let target = root.join("instance");
let report = block_on(import_pack(&ImportRequest::new(&pack, &target)));
assert_eq!(report.status, ImportStatus::Failed);
assert_eq!(
report.error.as_ref().expect("具名错误").code(),
"pack_symlink"
);
assert_clean(&target);
}
#[test]
fn corrupt_manifest_rolls_back_with_zero_residue() {
let (pack, target) = failing(
"fail-json",
&[
("manifest.json", b"{\"manifestType\": \"minecraftModpack\","),
("overrides/config/a.toml", b"k = 1\n"),
],
);
let report = block_on(import_pack(&ImportRequest::new(&pack, &target)));
assert_eq!(report.status, ImportStatus::Failed);
let code = report.error.as_ref().expect("具名错误").code();
assert!(
code == "pack_metadata" || code == "pack_unsupported",
"坏 JSON 要报成因,实际 {code}"
);
assert_clean(&target);
}
#[test]
fn declared_hash_mismatch_rolls_back_everything() {
// 原始 M110 夹具:`hashes.sha1` 是形状正确的**占位值**,必然不等于 stub 给的字节。
// 这是"声明与实际不符"——它**不是**"暂时搞不到",必须整包回滚。
let fixture = fixtures::modrinth("fail-integrity");
let root = temp_root("fail-integrity-out");
let target = root.join("instance");
let fetcher = StubFetcher::new()
.with_file(JEI_PLACEHOLDER_URL, JEI_BYTES)
.with_file(OPTIONAL_PLACEHOLDER_URL, b"stub optional");
let report = block_on(import_pack(
&ImportRequest::new(&fixture.zip, &target).with_fetcher(&fetcher),
));
assert_eq!(
report.status,
ImportStatus::Failed,
"缺文件 {:?}",
report.missing
);
assert_eq!(
report.error.as_ref().expect("具名错误").code(),
"import_integrity"
);
assert!(report.rolled_back);
assert_clean(&target);
}
#[test]
fn fatal_error_in_merge_mode_leaves_the_target_untouched() {
// 目标目录已有内容(升级/重导):致命错误时**只**清临时目录,不动目标目录。
let (pack, target) = failing(
"fail-merge",
&[("manifest.json", CF_MANIFEST), ("../evil.jar", b"payload")],
);
write_file(&target.join("keep.txt"), b"untouched");
write_file(&target.join("mods/mine.jar"), b"mine");
let before = list_tree(&target);
let report = block_on(import_pack(&ImportRequest::new(&pack, &target)));
assert_eq!(report.status, ImportStatus::Failed);
assert!(report.rolled_back);
assert_eq!(list_tree(&target), before, "目标目录必须逐项不变");
assert_eq!(
std::fs::read(target.join("keep.txt")).expect("keep.txt"),
b"untouched"
);
}
#[test]
fn commit_failure_rolls_back_partially_applied_files() {
// 让合并态的**提交阶段**在中途失败:目标文件要被替换时必须先备份成
// `<dest>.ullbak-<pid>`,而那个位置已被一个**非空目录**占住 ⇒ `rename(dest → backup)`
// 必然失败(文件改不成非空目录,Windows / Unix 一致)。
// 这覆盖 journal 回滚 + `Drop` 兜底:前半截已经搬进去的文件(基线排最前)必须被撤回。
let root = temp_root("fail-commit");
let source = fixtures::multimc("fail-commit-src");
let target = root.join("instance");
let first = block_on(import_pack(&ImportRequest::new(&source, &target)));
assert_eq!(first.status, ImportStatus::Success, "{:?}", first.error);
// 新版本包内容变了(用户没动过 ⇒ 判据是 Install,会被暂存并尝试替换)。
write_file(
&source.join(".minecraft/config/known.toml"),
b"key = \"v2\"\n",
);
// 备份位置被非空目录占住。
let blocker = target.join(format!("config/known.toml.ullbak-{}", std::process::id()));
write_file(&blocker.join("blocker.txt"), b"block");
let before = list_tree(&target);
let second = block_on(import_pack(&ImportRequest::new(&source, &target)));
assert_eq!(
second.status,
ImportStatus::Failed,
"提交阶段失败必须整包回滚"
);
assert_eq!(
second.error.as_ref().expect("具名错误").code(),
"pack_io",
"{}",
second.error.as_ref().expect("具名错误").message()
);
assert!(second.rolled_back);
assert_eq!(list_tree(&target), before, "目标目录必须逐项回到导入前");
// 提交阶段排在最前面的基线被成功搬入又被撤回:内容仍是第一次导入的那份。
assert!(target.join(super::baseline_rel()).is_file());
assert_eq!(
read_text(&target.join("config/known.toml")),
"key = \"value\"\n",
"旧内容必须还在(新内容没落地)"
);
}
@@ -0,0 +1,211 @@
//! 逐文件冲突策略的**边界样本**(research/18 §3.3 / §6 表 B #1–#2 的那几条)。
//!
//! 三条样本各自钉一个事实,缺一条就会复现不同的 bug:
//!
//! | 样本 | 必须成立的事实 | 写错的后果 |
//! |------|----------------|-----------|
//! | 同路径不同哈希 | 叠层顺序里**最后一条**胜;被遮蔽的那条不落地、只留一条具名注记 | 报告里两条 `Installed`,前一条的哈希根本没落地 |
//! | 同哈希不同路径 | **两个文件都落地**(按路径判重,不许按哈希判重) | 静默丢一个文件(内容一样不等于同一个文件) |
//! | overrides 叠层顺序 | `overrides/` 先、`client-overrides/` 后(= 后者胜,Modrinth 官方语义) | 与 HMCL 的相反语义混进来,同一包在不同启动器装出不同内容 |
//!
//! 判据的纯函数版本(`plan::layer::resolve`)在那边有单元测试;这里是**接在管线上的行为**:
//! 真打 zip、真导入、真读回磁盘内容与报告字段。
use std::sync::{Arc, Mutex};
use crate::addon::import::{
import_pack, keys, ConflictReason, FileAction, ImportProgress, ImportRequest, ImportStage,
ImportStatus,
};
use crate::download::blocking::block_on;
use super::support::sha1;
use super::{read_text, temp_root, write_file, zip_dir};
/// 打一个只有 `files: []` 的 mrpack(覆盖内容全在 overrides 系列目录里)。
fn index_json(name: &str) -> String {
format!(
r#"{{"game": "minecraft", "formatVersion": 1, "versionId": "1.0.0",
"name": "{name}", "files": [], "dependencies": {{"minecraft": "1.20.1"}}}}"#
)
}
/// 报告里所有 `NOTE_OVERRIDE_SHADOWED` 注记的消息。
fn shadow_notes(report: &crate::addon::import::ImportReport) -> Vec<&str> {
report
.warnings
.iter()
.filter(|note| note.message_key == keys::NOTE_OVERRIDE_SHADOWED)
.map(|note| note.message.as_str())
.collect()
}
#[test]
fn same_path_with_different_bytes_lands_the_later_side_only() {
let root = temp_root("layer-same-path");
let dir = root.join("src");
write_file(
&dir.join("modrinth.index.json"),
index_json("dup conflict").as_bytes(),
);
write_file(&dir.join("overrides/config/dup.toml"), b"from-overrides\n");
write_file(
&dir.join("client-overrides/config/dup.toml"),
b"from-client\n",
);
let pack = root.join("pack.mrpack");
zip_dir(&dir, &pack);
let target = root.join("instance");
// Extract 阶段的总条数就是"要落地的条目数":两点声明 ⇒ 只算 1 条(被遮蔽的不读字节)。
let totals: Arc<Mutex<Vec<usize>>> = Arc::new(Mutex::new(Vec::new()));
let sink = {
let totals = Arc::clone(&totals);
Arc::new(move |progress: &ImportProgress| {
if progress.stage == ImportStage::Extract {
totals.lock().expect("锁").push(progress.total);
}
})
};
let report = block_on(import_pack(
&ImportRequest::new(&pack, &target).with_progress(sink),
));
assert_eq!(report.status, ImportStatus::Success, "{:?}", report.error);
// Modrinth 官方语义:client-overrides/ 后写 = 胜。
assert_eq!(read_text(&target.join("config/dup.toml")), "from-client\n");
// 一路径一行,且那一行的哈希必须是**真落地**的那份内容。
let rows: Vec<_> = report
.files
.iter()
.filter(|file| file.path == "config/dup.toml")
.collect();
assert_eq!(rows.len(), 1, "同一路径只许一行:{:?}", report.files);
assert_eq!(
rows[0].sha1.as_deref(),
Some(sha1(b"from-client\n").as_str())
);
assert_eq!(rows[0].action, FileAction::Installed);
// 遮蔽要留下具名注记:谁赢了、两份内容不同。
let notes = shadow_notes(&report);
assert_eq!(notes.len(), 1, "{:?}", report.warnings);
assert!(notes[0].contains("内容不同"), "{}", notes[0]);
assert!(notes[0].contains("client-overrides"), "{}", notes[0]);
assert!(
report.conflicts.is_empty(),
"包内重复声明不是用户冲突:{:?}",
report.conflicts
);
// 被遮蔽的条目不再进 Extract 的总数(1 条真落地)。
assert_eq!(totals.lock().expect("锁").last().copied(), Some(1));
}
#[test]
fn the_same_bytes_at_two_paths_are_two_files() {
let root = temp_root("layer-same-bytes");
let dir = root.join("src");
write_file(
&dir.join("modrinth.index.json"),
index_json("same bytes").as_bytes(),
);
let content = b"identical payload\n";
write_file(&dir.join("overrides/mods/a.jar"), content);
write_file(&dir.join("overrides/mods/b.jar"), content);
let pack = root.join("pack.mrpack");
zip_dir(&dir, &pack);
let target = root.join("instance");
let report = block_on(import_pack(&ImportRequest::new(&pack, &target)));
assert_eq!(report.status, ImportStatus::Success, "{:?}", report.error);
for path in ["mods/a.jar", "mods/b.jar"] {
assert_eq!(
read_text(&target.join(path)),
"identical payload\n",
"{path}"
);
}
let rows: Vec<_> = report
.files
.iter()
.filter(|file| file.path.ends_with(".jar"))
.collect();
assert_eq!(rows.len(), 2, "同哈希不同路径不许折叠:{:?}", report.files);
assert!(rows
.iter()
.all(|row| row.sha1.as_deref() == Some(sha1(content).as_str())));
assert!(
shadow_notes(&report).is_empty(),
"不同路径不是重复声明:{:?}",
report.warnings
);
}
#[test]
fn identical_bytes_on_both_sides_shadow_without_claiming_a_conflict() {
let root = temp_root("layer-same-content");
let dir = root.join("src");
write_file(
&dir.join("modrinth.index.json"),
index_json("dup identical").as_bytes(),
);
let content = b"shared = true\n";
write_file(&dir.join("overrides/config/shared.toml"), content);
write_file(&dir.join("client-overrides/config/shared.toml"), content);
let pack = root.join("pack.mrpack");
zip_dir(&dir, &pack);
let target = root.join("instance");
let report = block_on(import_pack(&ImportRequest::new(&pack, &target)));
assert_eq!(report.status, ImportStatus::Success, "{:?}", report.error);
assert_eq!(
read_text(&target.join("config/shared.toml")),
"shared = true\n"
);
let notes = shadow_notes(&report);
assert_eq!(notes.len(), 1, "{:?}", report.warnings);
assert!(
notes[0].contains("逐字节相同"),
"内容一致必须说清楚,别让人以为丢了东西:{}",
notes[0]
);
}
#[test]
fn the_baseline_remembers_the_winning_bytes() {
// 叠层顺序的**后果**:基线要记胜者那份。记错的话,下一次升级会把"用户没动过"判成
// "用户改过"(或反过来静默覆盖用户改动)。
let root = temp_root("layer-baseline");
let dir = root.join("src");
write_file(
&dir.join("modrinth.index.json"),
index_json("baseline dup").as_bytes(),
);
write_file(&dir.join("overrides/config/dup.toml"), b"from-overrides\n");
write_file(
&dir.join("client-overrides/config/dup.toml"),
b"from-client\n",
);
let pack = root.join("pack.mrpack");
zip_dir(&dir, &pack);
let target = root.join("instance");
let first = block_on(import_pack(&ImportRequest::new(&pack, &target)));
assert_eq!(first.status, ImportStatus::Success, "{:?}", first.error);
// 同一份包再导一次:磁盘内容 == 胜者内容 == 基线 ⇒ 既不该报冲突,也不该重写盘。
let second = block_on(import_pack(&ImportRequest::new(&pack, &target)));
assert_eq!(second.status, ImportStatus::Success, "{:?}", second.error);
assert!(
second.conflicts.is_empty(),
"基线记的必须是胜者那份,否则这里会冒出一条假的 user_modified:{:?}",
second.conflicts
);
assert_eq!(read_text(&target.join("config/dup.toml")), "from-client\n");
// 反向确认:冲突清单里不该出现这条路径的**任何**成因。
assert!(!second
.conflicts
.iter()
.any(|conflict| conflict.path == "config/dup.toml"
&& conflict.reason != ConflictReason::PreExisting));
}
+293
View File
@@ -0,0 +1,293 @@
//! overrides 三方合并的**行为**测试:用户改过 / 用户删过 / 没动过 / 没有基线 / 侧优先级。
//!
//! 判据本身(纯函数)在 `import::merge` 的单元测试里;这里验证的是"判据真的接在管线上了":
//! 真写盘、真重导、真读回内容。
use crate::addon::import::{
import_pack, ConflictChoice, ConflictPolicy, ConflictReason, FileAction, ImportRequest,
ImportStatus,
};
use crate::download::blocking::block_on;
use super::support::mrpack_stub;
use super::{list_tree, read_text, temp_root, write_file, zip_dir};
use crate::addon::import::{PackBaseline, BASELINE_FILE};
#[test]
fn user_modified_file_is_kept_and_reported() {
let (fixture, fetcher) = mrpack_stub("merge-modified");
let target = temp_root("merge-modified-out").join("instance");
let first = block_on(import_pack(
&ImportRequest::new(&fixture.zip, &target).with_fetcher(&fetcher),
));
assert_eq!(first.status, ImportStatus::Success, "{:?}", first.error);
// 用户动手改了一个包内文件。
write_file(&target.join("config/known.toml"), b"shared = false\n");
let second = block_on(import_pack(
&ImportRequest::new(&fixture.zip, &target).with_fetcher(&fetcher),
));
assert_eq!(
second.status,
ImportStatus::Partial,
"保留用户文件 ⇒ 部分成功"
);
assert_eq!(second.conflicts.len(), 1, "{:?}", second.conflicts);
let conflict = &second.conflicts[0];
assert_eq!(conflict.path, "config/known.toml");
assert_eq!(conflict.reason, ConflictReason::UserModified);
assert_eq!(conflict.choice, ConflictChoice::KeepUser);
assert_eq!(
conflict.message_key,
crate::addon::import::keys::CONFLICT_USER_MODIFIED
);
assert!(conflict.base_sha1.is_some(), "基线里应有上次的哈希");
assert!(conflict.disk_sha1.is_some());
// 磁盘内容没被盖掉。
assert_eq!(
read_text(&target.join("config/known.toml")),
"shared = false\n"
);
// 报告里这条的结局是"保留用户"。
assert!(second
.files
.iter()
.any(|file| file.path == "config/known.toml" && file.action == FileAction::KeptUser));
assert_eq!(second.status.exit_code(), 3);
}
#[test]
fn user_deleted_file_is_not_restored() {
let (fixture, fetcher) = mrpack_stub("merge-deleted");
let target = temp_root("merge-deleted-out").join("instance");
let first = block_on(import_pack(
&ImportRequest::new(&fixture.zip, &target).with_fetcher(&fetcher),
));
assert_eq!(first.status, ImportStatus::Success, "{:?}", first.error);
// 用户删了一个包内文件。
std::fs::remove_file(target.join("mods/half.jar")).expect("删夹具文件");
let second = block_on(import_pack(
&ImportRequest::new(&fixture.zip, &target).with_fetcher(&fetcher),
));
assert_eq!(second.status, ImportStatus::Partial);
let conflict = second
.conflicts
.iter()
.find(|conflict| conflict.path == "mods/half.jar")
.expect("删除也要进冲突清单");
assert_eq!(conflict.reason, ConflictReason::UserDeleted);
assert_eq!(conflict.choice, ConflictChoice::KeepUser);
assert_eq!(conflict.disk_sha1, None);
assert!(
!target.join("mods/half.jar").exists(),
"用户删掉的文件不许偷偷装回来"
);
}
#[test]
fn untouched_file_is_upgraded_by_a_newer_pack() {
let (fixture, fetcher) = mrpack_stub("merge-upgrade");
let target = temp_root("merge-upgrade-out").join("instance");
let first = block_on(import_pack(
&ImportRequest::new(&fixture.zip, &target).with_fetcher(&fetcher),
));
assert_eq!(first.status, ImportStatus::Success, "{:?}", first.error);
// 新版本包:同一个路径,内容变了;用户没动过。
write_file(
&fixture.dir.join("overrides/config/known.toml"),
b"shared = 2\n",
);
zip_dir(&fixture.dir, &fixture.zip);
let second = block_on(import_pack(
&ImportRequest::new(&fixture.zip, &target).with_fetcher(&fetcher),
));
assert_eq!(
second.status,
ImportStatus::Success,
"未改过的文件升级不算冲突"
);
assert!(second.conflicts.is_empty(), "{:?}", second.conflicts);
assert_eq!(read_text(&target.join("config/known.toml")), "shared = 2\n");
assert!(second
.files
.iter()
.any(|file| file.path == "config/known.toml" && file.action == FileAction::Replaced));
}
#[test]
fn pre_existing_file_without_baseline_is_kept() {
let (fixture, fetcher) = mrpack_stub("merge-existing");
let target = temp_root("merge-existing-out").join("instance");
// 首次导入前磁盘上就有同名文件:没有基线可判 ⇒ 最安全的一侧(不覆盖)。
write_file(&target.join("config/known.toml"), b"mine\n");
let report = block_on(import_pack(
&ImportRequest::new(&fixture.zip, &target).with_fetcher(&fetcher),
));
assert_eq!(report.status, ImportStatus::Partial);
let conflict = report
.conflicts
.iter()
.find(|conflict| conflict.path == "config/known.toml")
.expect("要有冲突清单");
assert_eq!(conflict.reason, ConflictReason::PreExisting);
assert!(conflict.base_sha1.is_none());
assert_eq!(read_text(&target.join("config/known.toml")), "mine\n");
}
#[test]
fn overwrite_policy_replaces_user_file_and_reports_the_choice() {
let (fixture, fetcher) = mrpack_stub("merge-overwrite");
let target = temp_root("merge-overwrite-out").join("instance");
let first = block_on(import_pack(
&ImportRequest::new(&fixture.zip, &target).with_fetcher(&fetcher),
));
assert_eq!(first.status, ImportStatus::Success, "{:?}", first.error);
write_file(&target.join("config/known.toml"), b"shared = false\n");
let report = block_on(import_pack(
&ImportRequest::new(&fixture.zip, &target)
.with_fetcher(&fetcher)
.with_conflict_policy(ConflictPolicy::Overwrite),
));
assert_eq!(report.status, ImportStatus::Success, "覆盖后结果是干净的");
let conflict = report
.conflicts
.iter()
.find(|conflict| conflict.path == "config/known.toml")
.expect("冲突仍要记账");
assert_eq!(conflict.reason, ConflictReason::UserModified);
assert_eq!(conflict.choice, ConflictChoice::Overwrite);
assert_eq!(
read_text(&target.join("config/known.toml")),
"shared = true\n"
);
}
#[test]
fn client_overrides_wins_over_overrides() {
// Modrinth 官方客户端语义:`client-overrides/` 后写 = 胜(HMCL 恰好相反)。
let root = temp_root("merge-priority");
let dir = root.join("src");
write_file(
&dir.join("modrinth.index.json"),
br#"{"game": "minecraft", "formatVersion": 1, "versionId": "1.0.0",
"name": "dup", "files": [], "dependencies": {"minecraft": "1.20.1"}}"#,
);
write_file(&dir.join("overrides/config/dup.toml"), b"from-overrides\n");
write_file(
&dir.join("client-overrides/config/dup.toml"),
b"from-client\n",
);
write_file(
&dir.join("server-overrides/config/dup.toml"),
b"from-server\n",
);
let pack = root.join("pack.mrpack");
zip_dir(&dir, &pack);
let target = root.join("instance");
let report = block_on(import_pack(&ImportRequest::new(&pack, &target)));
assert_eq!(report.status, ImportStatus::Success, "{:?}", report.error);
assert_eq!(read_text(&target.join("config/dup.toml")), "from-client\n");
assert!(
report.conflicts.is_empty(),
"侧优先级不是冲突:{:?}",
report.conflicts
);
}
#[test]
fn baseline_read_and_write_paths_are_the_same_file() {
// 复审 r5 P1-1 的回归钉:读侧(`PackBaseline::path`)与写侧(`relative_path` 拼 root)
// **必须解析到同一个路径**。只测"读得到"在大小写不敏感的系统上会假绿。
let root = temp_root("baseline-path").join("instance");
let read = PackBaseline::path(&root);
let write = root.join(PackBaseline::relative_path());
assert_eq!(read, write, "读写必须是同一个文件:{read:?} vs {write:?}");
// 相对路径的唯一来源是 `GameLayout::ull_root()`(游戏根内的 `.ull/`),不是启动器数据根。
assert_eq!(
PackBaseline::relative_path(),
std::path::PathBuf::from(".ull").join(BASELINE_FILE)
);
assert_eq!(
read.parent(),
Some(
crate::game::layout::GameLayout::new(&root)
.ull_root()
.as_path()
)
);
// 写侧(pipeline 提交时用的相对路径)必须同源;`.ULL` 是**启动器数据根**的名字,
// 出现在实例内路径里就是 P1-1 那个 bug(大小写敏感的系统上会变成两个文件)。
let write_side = crate::addon::import::baseline_relative();
assert_eq!(
write_side,
PackBaseline::relative_path()
.to_string_lossy()
.replace('\\', "/"),
"写侧必须从同一个来源派生"
);
assert!(
!write_side.contains(".ULL"),
"实例内路径不许用启动器数据根的 `.ULL`:{write_side}"
);
}
#[test]
fn baseline_of_another_pack_is_not_used_as_base() {
// 同一目录先后导入两个整合包:别人的基线当 base 会把"用户改过"误判成"整合包升级"而静默覆盖。
let root = temp_root("merge-identity");
let source = super::fixtures::multimc("merge-identity-a");
let target = root.join("instance");
let first = block_on(import_pack(&ImportRequest::new(&source, &target)));
assert_eq!(first.status, ImportStatus::Success, "{:?}", first.error);
// 用户改了一个包内文件。
write_file(&target.join("mods/example.jar"), b"mine");
// 另一个包(名字不同 + 同名文件内容不同)导进同一目录。
let other = root.join("other");
write_file(
&other.join("mmc-pack.json"),
br#"{"formatVersion": 1, "components": [
{"uid": "net.minecraft", "version": "1.20.1"},
{"uid": "net.fabricmc.fabric-loader", "version": "0.15.11"}]}"#,
);
write_file(
&other.join("instance.cfg"),
"InstanceType=OneSix\nname=另一个包\n".as_bytes(),
);
write_file(&other.join(".minecraft/mods/example.jar"), b"pack B jar");
let report = block_on(import_pack(&ImportRequest::new(&other, &target)));
assert!(
report
.warnings
.iter()
.any(|note| note.message_key == crate::addon::import::keys::NOTE_BASELINE_PACK_MISMATCH),
"跨包基线要具名告警:{:?}",
report.warnings
);
let conflict = report
.conflicts
.iter()
.find(|conflict| conflict.path == "mods/example.jar")
.expect("要有冲突");
assert_eq!(
conflict.reason,
ConflictReason::PreExisting,
"别人的基线不能拿来判 user_modified"
);
assert!(conflict.base_sha1.is_none(), "身份不符时基线应被忽略");
assert_eq!(read_text(&target.join("mods/example.jar")), "mine");
assert_eq!(report.status, ImportStatus::Partial);
assert!(list_tree(&target).contains(&"mods/example.jar".to_string()));
}
@@ -0,0 +1,178 @@
//! Modrinth `.mrpack` 端到端导入:三目录优先级、`.disabled` 后缀、依赖翻译。
//!
//! 成功路径吃 [`super::support::mrpack_stub`](同一份 M110 夹具,只把 `sha1`/`downloads`
//! 换成真实值);"声明与实际不符"那条用例用**原始**夹具,见 `import_failures.rs`。
use crate::addon::import::{import_pack, keys, ImportRequest, ImportStatus};
use crate::addon::loader::LoaderKind;
use crate::download::blocking::block_on;
use super::support::{mrpack_stub, JEI_BYTES, OPTIONAL_BYTES};
use super::{fixtures, list_tree, read_text, temp_root};
#[test]
fn imports_mrpack_end_to_end() {
let (fixture, fetcher) = mrpack_stub("mr-e2e");
let target = temp_root("mr-e2e-out").join("instance");
let request = ImportRequest::new(&fixture.zip, &target).with_fetcher(&fetcher);
let report = block_on(import_pack(&request));
assert_eq!(
report.status,
ImportStatus::Success,
"缺文件 {:?} 冲突 {:?} 错误 {:?}",
report.missing,
report.conflicts,
report.error
);
assert_eq!(report.format.as_deref(), Some("modrinth"));
assert_eq!(report.pack_name.as_deref(), Some(fixtures::MR_NAME));
assert_eq!(report.pack_version.as_deref(), Some("2.0.0"));
assert_eq!(report.game_version.as_deref(), Some("1.20.1"));
let loader = report.loader.as_ref().expect("mrpack 带 fabric-loader");
assert_eq!(loader.kind, LoaderKind::Fabric);
assert_eq!(loader.loader_version, "0.15.11");
assert_eq!(loader.version_id, "1.20.1-fabric-0.15.11");
// 覆盖内容:`overrides/` + `client-overrides/` 落地,`server-overrides/` **不落地**。
assert_eq!(
std::fs::read(target.join("mods/half.jar")).expect("half.jar"),
fixtures::MR_HALF_JAR
);
assert!(target.join("config/known.toml").is_file());
assert!(target.join("config/client-only.toml").is_file());
assert!(
!target.join("config/server.properties").exists(),
"server-overrides/ 客户端不许解压(Modrinth 官方语义)"
);
// 远端条目:路径与地址都在清单里,内容来自 stub。
assert_eq!(
std::fs::read(target.join("mods/jei.jar")).expect("jei.jar"),
JEI_BYTES
);
assert_eq!(
std::fs::read(target.join("mods/optional.jar")).expect("optional.jar"),
OPTIONAL_BYTES
);
// 三条注记:服务端覆盖被跳过 / 不认识的依赖键 / 可选条目不等于禁用。
let note_keys: Vec<&str> = report
.warnings
.iter()
.map(|note| note.message_key)
.collect();
assert!(
note_keys.contains(&keys::NOTE_SERVER_OVERRIDES_SKIPPED),
"{note_keys:?}"
);
assert!(
note_keys.contains(&keys::NOTE_UNKNOWN_DEPENDENCY),
"parchment 这类键要如实告警:{note_keys:?}"
);
assert!(
note_keys.contains(&keys::NOTE_OPTIONAL_FILE),
"env.client=optional 要提示但照装:{note_keys:?}"
);
assert_eq!(
list_tree(&target),
vec![
super::baseline_rel(),
"config/client-only.toml".to_string(),
"config/known.toml".to_string(),
"mods/half.jar".to_string(),
"mods/jei.jar".to_string(),
"mods/optional.jar".to_string(),
]
);
}
#[test]
fn engine_artifacts_never_reach_the_instance() {
// 引擎在下载失败时故意留下 `<name>.ullpart-<session>-<seq>` 与 `<name>.ullmeta.json`
// 供下次续传;它们躺在暂存目录里,提交前必须剔掉,否则会变成实例里的"内容"。
let (fixture, fetcher) = mrpack_stub("mr-artifacts");
let fetcher = fetcher.with_engine_artifacts();
let target = temp_root("mr-artifacts-out").join("instance");
let report = block_on(import_pack(
&ImportRequest::new(&fixture.zip, &target).with_fetcher(&fetcher),
));
assert_eq!(report.status, ImportStatus::Success, "{:?}", report.error);
let tree = list_tree(&target);
assert!(
tree.iter()
.all(|path| !path.contains(".ullpart") && !path.contains(".ullmeta")),
"中间产物不许进实例:{tree:?}"
);
assert!(
report
.warnings
.iter()
.any(|note| note.message_key == keys::NOTE_ENGINE_ARTIFACTS_DROPPED),
"剔除了就该留一条注记:{:?}",
report.warnings
);
}
#[test]
fn disabled_suffix_is_preserved_verbatim() {
// 四种 `.disabled` / `.old` 变体都按原后缀落地,不改名、不清理(tower 定案)。
let (fixture, fetcher) = mrpack_stub("mr-disabled");
super::write_file(
&fixture.dir.join("overrides/mods/variant.jar.disabled"),
b"disabled bytes",
);
super::write_file(
&fixture.dir.join("overrides/mods/variant.jar.disabled.old"),
b"disabled old bytes",
);
super::write_file(
&fixture.dir.join("overrides/mods/variant.jar.old"),
b"old bytes",
);
super::zip_dir(&fixture.dir, &fixture.zip);
let target = temp_root("mr-disabled-out").join("instance");
let request = ImportRequest::new(&fixture.zip, &target).with_fetcher(&fetcher);
let report = block_on(import_pack(&request));
assert_eq!(report.status, ImportStatus::Success, "{:?}", report.error);
for name in [
"mods/variant.jar.disabled",
"mods/variant.jar.disabled.old",
"mods/variant.jar.old",
] {
assert!(target.join(name).is_file(), "{name} 应当按原后缀落地");
}
// 禁用位从后缀读出来,`.old` 不被当成禁用。
let disabled: Vec<(&str, bool)> = report
.files
.iter()
.filter(|file| file.path.starts_with("mods/variant"))
.map(|file| (file.path.as_str(), file.disabled))
.collect();
assert_eq!(
disabled,
vec![
("mods/variant.jar.disabled", true),
("mods/variant.jar.disabled.old", true),
("mods/variant.jar.old", false),
]
);
let note_keys: Vec<&str> = report
.files
.iter()
.filter(|file| file.path.starts_with("mods/variant"))
.filter_map(|file| file.note.as_ref().map(|note| note.message_key))
.collect();
assert!(
note_keys.contains(&keys::NOTE_DISABLED_SUFFIX),
"{note_keys:?}"
);
assert!(note_keys.contains(&keys::NOTE_OLD_SUFFIX), "{note_keys:?}");
// 报告里的内容仍是原文(没有"读出来再写一遍"导致的空白变化)。
assert_eq!(read_text(&target.join("mods/variant.jar.old")), "old bytes");
}
@@ -0,0 +1,210 @@
//! MultiMC / Prism 实例目录导入:`.minecraft/` 直拷、component 逐项交代、`instance.cfg` 取内存。
use serde_json::Value;
use crate::addon::import::{import_pack, keys, ImportRequest, ImportStatus};
use crate::addon::loader::LoaderKind;
use crate::download::blocking::block_on;
use super::{fixtures, list_tree, read_text, temp_root, write_file};
#[test]
fn imports_multimc_instance_end_to_end() {
let source = fixtures::multimc("mmc-e2e");
let target = temp_root("mmc-e2e-out").join("instance");
let request = ImportRequest::new(&source, &target);
let report = block_on(import_pack(&request));
assert_eq!(report.status, ImportStatus::Success, "{:?}", report.error);
assert_eq!(report.format.as_deref(), Some("multimc"));
assert_eq!(report.pack_name.as_deref(), Some(fixtures::MMC_NAME));
assert_eq!(report.game_version.as_deref(), Some("1.20.1"));
let loader = report.loader.as_ref().expect("fabric-loader 组件");
assert_eq!(loader.kind, LoaderKind::Fabric);
assert_eq!(loader.loader_version, "0.15.11");
assert_eq!(loader.version_id, "1.20.1-fabric-0.15.11");
// MultiMC 没有远端条目:内容就是目录。
assert!(report.missing.is_empty());
assert!(report
.files
.iter()
.all(|file| file.source == crate::addon::import::FileSource::Overrides));
// `.minecraft/` 直拷到实例根(不多一层 `.minecraft/`),零字节 jar 也照拷。
assert_eq!(
std::fs::read(target.join("mods/example.jar")).expect("example.jar"),
b"PK\x03\x04 hand-made jar stub"
);
assert_eq!(
std::fs::metadata(target.join("mods/broken.jar"))
.expect("broken.jar")
.len(),
0,
"零字节 jar 是夹具刻意造的:导入器不许悄悄丢掉它"
);
assert!(target.join("config/known.toml").is_file());
assert_eq!(
list_tree(&target),
vec![
super::baseline_rel(),
"config/known.toml".to_string(),
"mods/broken.jar".to_string(),
"mods/example.jar".to_string(),
]
);
}
#[test]
fn unsupported_components_are_listed_one_by_one_not_dropped() {
let source = fixtures::multimc("mmc-components");
let target = temp_root("mmc-components-out").join("instance");
let request = ImportRequest::new(&source, &target);
let report = block_on(import_pack(&request));
// 夹具里的 `org.lwjgl3` 不是加载器 ⇒ 必须逐项进"未支持清单"。
assert_eq!(
report.rejected_components.len(),
1,
"{:?}",
report.rejected_components
);
let rejected = &report.rejected_components[0];
assert_eq!(rejected.uid, "org.lwjgl3");
assert_eq!(rejected.version.as_deref(), Some("3.3.2"));
assert_eq!(rejected.message_key, keys::REJECTED_COMPONENT);
// **不静默丢**的断言:`mmc-pack.json` 里每一个 component 都能在报告里找到归宿。
let pack: Value =
serde_json::from_str(&read_text(&source.join("mmc-pack.json"))).expect("解析 mmc-pack");
let components = pack["components"].as_array().expect("components 数组");
assert!(!components.is_empty());
for component in components {
let uid = component["uid"].as_str().expect("uid");
let version = component["version"].as_str().expect("version");
if uid == "net.minecraft" {
assert_eq!(
report.game_version.as_deref(),
Some(version),
"{uid} 要变成游戏版本"
);
continue;
}
let as_loader = report
.loader
.as_ref()
.is_some_and(|loader| loader.raw == uid);
let as_rejected = report
.rejected_components
.iter()
.any(|rejected| rejected.uid == uid);
assert!(
as_loader || as_rejected,
"component {uid} 既没成为加载器、也没进未支持清单(= 静默丢了)"
);
}
}
#[test]
fn reads_memory_from_instance_cfg_only_when_override_memory_is_on() {
let root = temp_root("mmc-mem");
let source = root.join("instance");
write_file(
&source.join("mmc-pack.json"),
br#"{"formatVersion": 1, "components": [
{"uid": "net.minecraft", "version": "1.20.1"},
{"uid": "net.fabricmc.fabric-loader", "version": "0.15.11"}]}"#,
);
write_file(
&source.join("instance.cfg"),
b"InstanceType=OneSix\nname=mem pack\nOverrideMemory=true\nMaxMemAlloc=6144\nMinMemAlloc=1024\n",
);
write_file(&source.join(".minecraft/mods/a.jar"), b"jar");
let target = temp_root("mmc-mem-out").join("instance");
let report = block_on(import_pack(&ImportRequest::new(&source, &target)));
assert_eq!(report.max_memory_mb, Some(6144), "{:?}", report.error);
assert!(report
.warnings
.iter()
.any(|note| note.message_key == keys::NOTE_INSTANCE_CFG_MEMORY));
// 开关没开时不采用(只看值不看开关会误判)。
let gated = root.join("gated");
write_file(
&gated.join("mmc-pack.json"),
br#"{"formatVersion": 1, "components": [
{"uid": "net.minecraft", "version": "1.20.1"}]}"#,
);
write_file(
&gated.join("instance.cfg"),
b"InstanceType=OneSix\nname=gated\nOverrideMemory=false\nMaxMemAlloc=6144\n",
);
write_file(&gated.join(".minecraft/mods/a.jar"), b"jar");
let gated_target = temp_root("mmc-mem-gated-out").join("instance");
let gated_report = block_on(import_pack(&ImportRequest::new(&gated, &gated_target)));
assert_eq!(gated_report.max_memory_mb, None);
assert!(gated_report
.warnings
.iter()
.any(|note| note.message_key == keys::NOTE_INSTANCE_CFG_MEMORY));
}
#[test]
fn registers_the_instance_when_a_launcher_layout_is_given() {
// 登记阶段(读取清单 → 解压 → 补全 → 落地 → **注册实例**)的 happy path:
// 内容落盘后写 profiles.json + config/instances/<id>.json。
let root = temp_root("mmc-register");
let source = fixtures::multimc("mmc-register-src");
let target = root.join("instance");
let home = root.join("data");
let layout = crate::platform::layout::LauncherLayout::new(&home);
let request = ImportRequest::new(&source, &target).with_registration(&layout, Some("my pack"));
let report = block_on(import_pack(&request));
assert_eq!(report.status, ImportStatus::Success, "{:?}", report.error);
let registered = report.registered.as_ref().expect("要登记实例");
assert_eq!(registered.name, "my pack");
assert_eq!(registered.version_id, "1.20.1-fabric-0.15.11");
assert!(registered.created);
// profiles.json 真的写出来了,且指回同一个版本 id。
let profiles = crate::game::install::instance::read_profiles(&layout).expect("读档案");
assert_eq!(profiles.profiles.len(), 1);
assert_eq!(profiles.profiles[0].version_id, "1.20.1-fabric-0.15.11");
assert_eq!(profiles.profiles[0].name, "my pack");
assert!(report
.warnings
.iter()
.any(|note| note.message_key == keys::NOTE_REGISTERED));
assert!(report.rollback.is_none(), "成功路径不该有回滚记录");
}
#[test]
fn rejects_mmc_pack_with_other_format_version() {
// Prism 的规矩:`formatVersion` 必须恰为 1,否则整份丢弃(不是降级解析)。
let root = temp_root("mmc-v2");
let source = root.join("instance");
write_file(
&source.join("mmc-pack.json"),
br#"{"formatVersion": 2, "components": [
{"uid": "net.minecraft", "version": "1.20.1"}]}"#,
);
write_file(
&source.join("instance.cfg"),
b"InstanceType=OneSix\nname=v2\n",
);
write_file(&source.join(".minecraft/mods/a.jar"), b"jar");
let target = temp_root("mmc-v2-out").join("instance");
let report = block_on(import_pack(&ImportRequest::new(&source, &target)));
assert_eq!(report.status, ImportStatus::Failed);
let error = report.error.as_ref().expect("必须有具名错误");
assert_eq!(error.code(), "pack_metadata");
assert!(
error.message().contains("formatVersion"),
"{}",
error.message()
);
assert!(report.rolled_back);
super::assert_absent(&target);
}
@@ -0,0 +1,159 @@
//! 报告的**契约**测试:三态退出码、JSON 形状、文案 key 表没有死常量。
use serde_json::Value;
use crate::addon::import::{
import_pack, keys, ImportRequest, ImportStage, ImportStatus, MissingReason,
};
use crate::download::blocking::block_on;
use super::support::mrpack_stub;
use super::temp_root;
#[test]
fn status_maps_to_three_distinct_exit_codes() {
// CLI 契约:0 = Success,3 = Partial,1 = Failed(2 留给内部错误)。
assert_eq!(ImportStatus::Success.exit_code(), 0);
assert_eq!(ImportStatus::Partial.exit_code(), 3);
assert_eq!(ImportStatus::Failed.exit_code(), 1);
// 每个状态都有独立文案 key。
let mut keyed = vec![
ImportStatus::Success.message_key(),
ImportStatus::Partial.message_key(),
ImportStatus::Failed.message_key(),
];
keyed.sort_unstable();
keyed.dedup();
assert_eq!(keyed.len(), 3);
// 五个阶段也各有 key,且都能进报告。
let stages = [
ImportStage::Probe,
ImportStage::Extract,
ImportStage::Fetch,
ImportStage::Commit,
ImportStage::Register,
];
assert_eq!(stages.len(), 5);
for stage in stages {
assert!(keys::ALL.contains(&stage.message_key()), "{:?}", stage);
}
}
#[test]
fn report_serialises_to_stable_json_with_structured_error() {
// 失败也要输出结构化 JSON(不抛栈):`status` / `error.code` / `rolled_back` 一个不少。
let root = temp_root("report-json");
let pack = root.join("evil.zip");
super::write_zip(&pack, &[("manifest.json", b"{\"manifestType\":")]);
let target = root.join("instance");
let report = block_on(import_pack(&ImportRequest::new(&pack, &target)));
let value: Value = serde_json::to_value(&report).expect("报告要能序列化");
assert_eq!(value["status"], Value::String("failed".to_string()));
assert_eq!(value["rolled_back"], Value::Bool(true));
let error = value["error"].as_object().expect("error 是对象");
assert!(error["code"].is_string(), "{error:?}");
assert!(error["message"].is_string(), "{error:?}");
assert_eq!(report.status.exit_code(), 1);
}
#[test]
fn successful_report_serialises_counts_and_loader() {
let (fixture, fetcher) = mrpack_stub("report-ok");
let target = temp_root("report-ok-out").join("instance");
let report = block_on(import_pack(
&ImportRequest::new(&fixture.zip, &target).with_fetcher(&fetcher),
));
assert_eq!(report.status, ImportStatus::Success, "{:?}", report.error);
let value: Value = serde_json::to_value(&report).expect("序列化");
assert_eq!(value["status"], Value::String("success".to_string()));
assert_eq!(value["format"], Value::String("modrinth".to_string()));
assert_eq!(value["loader"]["kind"], Value::String("fabric".to_string()));
assert_eq!(
value["loader"]["version_id"],
Value::String("1.20.1-fabric-0.15.11".to_string())
);
assert!(value["files"]
.as_array()
.is_some_and(|files| !files.is_empty()));
assert!(value["conflicts"].as_array().is_some_and(Vec::is_empty));
assert!(value["missing"].as_array().is_some_and(Vec::is_empty));
}
#[test]
fn every_message_key_is_referenced_somewhere() {
// 防"死常量表":keys 模块里每个常量除了**定义**与 `ALL` 里的**一处**列举,至少还要有
// 一处真实引用(`keys::X` 或同模块里的 `X`)。少于此数就说明它没人用。
let table = key_table();
let mut values: Vec<&str> = table.iter().map(|(_, value)| value.as_str()).collect();
values.sort_unstable();
let mut declared = keys::ALL.to_vec();
declared.sort_unstable();
assert_eq!(values, declared, "keys::ALL 与常量定义不一致");
let sources = SOURCES.concat();
let mut dead: Vec<&str> = Vec::new();
for (name, value) in &table {
let hits = sources.matches(name.as_str()).count();
assert!(
hits >= 3,
"key {name}({value})只出现 {hits} 次,像是死常量"
);
if hits < 3 {
dead.push(name);
}
}
assert!(dead.is_empty(), "{dead:?}");
}
#[test]
fn reason_enums_carry_their_own_keys() {
for reason in [
MissingReason::Offline,
MissingReason::NoSource,
MissingReason::FetchFailed,
] {
assert!(keys::ALL.contains(&reason.message_key()), "{reason:?}");
}
}
/// 导入模块的全部源码(key 引用计数用)。
const SOURCES: &[&str] = &[
include_str!("../import.rs"),
include_str!("../import/error.rs"),
include_str!("../import/report.rs"),
include_str!("../import/report/keys.rs"),
include_str!("../import/report/items.rs"),
include_str!("../import/plan/paths.rs"),
include_str!("../import/stage/commit.rs"),
include_str!("../import/baseline.rs"),
include_str!("../import/fetch.rs"),
include_str!("../import/merge.rs"),
include_str!("../import/phase.rs"),
include_str!("../import/pipeline.rs"),
include_str!("../import/plan.rs"),
include_str!("../import/stage.rs"),
include_str!("../mod.rs"),
];
/// 从 `keys` 模块的源码里解析出 `(常量名, 常量值)` 表(不手抄第二份清单)。
fn key_table() -> Vec<(String, String)> {
let source = include_str!("../import/report/keys.rs");
let mut out = Vec::new();
for line in source.lines() {
let line = line.trim();
let Some(rest) = line.strip_prefix("pub const ") else {
continue;
};
let Some((name, tail)) = rest.split_once(": &str = \"") else {
continue;
};
let Some((value, _)) = tail.split_once('"') else {
continue;
};
out.push((name.to_string(), value.to_string()));
}
assert!(!out.is_empty(), "没有解析到任何 key 常量");
out
}
+176
View File
@@ -0,0 +1,176 @@
//! `addon` 的**导入管线**测试(M111):三格式端到端 + 三方合并 + 必须先失败的恶意样本。
//!
//! # 夹具来源
//!
//! [`fixtures`] 不是本目录的文件,而是用 `#[path]` 直接吃 M110 的
//! `addon/pack/tests/fixtures.rs`——**同一份手造包只维护一处**。它的来源声明也在那边:
//! 三份 CF / mrpack / MultiMC 包全部手造(字段取自 analyse/05 的 HMCL 解析器形状),
//! 没有一个来自真实导出;`mods/half.jar` / `config/broken.toml` / `mods/broken.jar`
//! 是刻意造的坏文件(导入器不许崩,也不许悄悄丢)。
//!
//! 为了让 `mod` 路径上的 `use super::{temp_root, write_file, zip_dir}` 解析得到,本文件把
//! 这三个辅助函数放在与 `addon/pack/tests/mod.rs` 同名同形的位置。
//!
//! # 全部离线
//!
//! 网络一律走 [`support::StubFetcher`](内存里的假文件),没有一个用例发请求。
// `fixtures.rs` 被 `addon::pack::tests` 与本目录**同时**编译(同一份手造包只维护一处),
// 所以 clippy 的 `duplicate_mod` 在这里是刻意为之:改成两份副本才是真的错。
#[allow(clippy::duplicate_mod)]
#[path = "../pack/tests/fixtures.rs"]
mod fixtures;
mod import_curseforge;
mod import_failures;
mod import_layering;
mod import_merge;
mod import_modrinth;
mod import_multimc;
mod import_report;
mod support;
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-import-{}-{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::<Result<Vec<_>, _>>()
.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");
}
/// 递归列出目录下的文件(`/` 分隔的相对路径,排序)。
///
/// "失败后目标目录零残留"就是靠它断言的:`Failed` 之后这里必须返回空表。
pub(crate) fn list_tree(root: &Path) -> Vec<String> {
fn walk(base: &Path, dir: &Path, out: &mut Vec<String>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
walk(base, &path, out);
} else {
out.push(
path.strip_prefix(base)
.unwrap_or(&path)
.to_string_lossy()
.replace('\\', "/"),
);
}
}
}
let mut out = Vec::new();
walk(root, root, &mut out);
out.sort();
out
}
/// 基线相对实例根的路径(`/` 分隔,给 `list_tree` 断言用)。
///
/// 从 `PackBaseline::relative_path()` 派生,**不在测试里手抄**目录名——手抄的那一份正是
/// 复审 r5 P1-1 的成因(读写两条路径各写各的)。
pub(crate) fn baseline_rel() -> String {
crate::addon::import::PackBaseline::relative_path()
.to_string_lossy()
.replace('\\', "/")
}
/// 读一个文件成字符串(断言覆盖内容用)。
pub(crate) fn read_text(path: &Path) -> String {
let bytes =
std::fs::read(path).unwrap_or_else(|error| panic!("读 {} 失败:{error}", path.display()));
String::from_utf8_lossy(&bytes).to_string()
}
/// 断言目录不存在(= 零残留)。
pub(crate) fn assert_absent(path: &Path) {
assert!(
!path.exists(),
"目标目录应当不存在(零残留),实际:{:?}",
list_tree(path)
);
}
+208
View File
@@ -0,0 +1,208 @@
//! 测试替身:内存里的远端(零网络)+ 把 M110 的 mrpack 夹具改成"声明与实际一致"的帮助器。
//!
//! # 为什么要改哈希
//!
//! M110 的 mrpack 夹具里 `hashes` 是**形状正确的占位值**(`0123456789abcdef…`),不对应任何
//! 真实字节——那份夹具的用途是"读得出、写得回",不是"能装上"。导入链要校验"声明与实际
//! 是否相符",所以:
//!
//! - **成功路径**用 [`mrpack_stub`]:沿用同一份夹具目录,只把 `sha1` 换成真实摘要、把
//! `downloads` 换成 `stub://` 地址(`sha512` 是占位值不影响:导入只校验 `sha1`,
//! 而 M110 的读者只校验 `sha512` 的**形状**);
//! - **声明与实际不符**用原始夹具:占位 `sha1` 一定不等于实际字节 ⇒ 必须 `Failed`。
//!
//! 两件事用同一份夹具,比手搓两个包更能说明"判据真的在跑"。
use std::collections::BTreeMap;
use std::sync::Mutex;
use serde_json::json;
use super::super::import::{CurseFileMeta, FetchFuture, FetchRequest, FetchedFile, PackFetcher};
use super::fixtures;
use super::fixtures::Fixture;
use super::{write_file, zip_dir};
use crate::download::verify::digest_bytes;
use crate::download::HashAlgo;
/// 成功路径上 `mods/jei.jar` 的内容。
pub(crate) const JEI_BYTES: &[u8] = b"stub jei jar: not a real jar, just bytes";
/// 成功路径上 `mods/optional.jar` 的内容。
pub(crate) const OPTIONAL_BYTES: &[u8] = b"stub optional";
/// 成功路径上两个文件的下载地址。
pub(crate) const JEI_URL: &str = "stub://mods/jei.jar";
/// 第二个文件的下载地址。
pub(crate) const OPTIONAL_URL: &str = "stub://mods/optional.jar";
/// CF 夹具里两个条目的补全地址(stub)。
pub(crate) const CURSE_URL: &str = "stub://curse/jei.jar";
/// M110 mrpack 夹具里 `mods/jei.jar` 的**占位**下载地址(原始夹具用,配的是假哈希)。
pub(crate) const JEI_PLACEHOLDER_URL: &str =
"https://cdn.modrinth.com/data/AANobbMI/versions/abc/jei.jar";
/// M110 mrpack 夹具里 `mods/optional.jar` 的**占位**下载地址。
pub(crate) const OPTIONAL_PLACEHOLDER_URL: &str =
"https://cdn.modrinth.com/data/opt/versions/x/optional.jar";
/// 一个字节的 SHA-1(小写十六进制)。
pub(crate) fn sha1(bytes: &[u8]) -> String {
digest_bytes(HashAlgo::Sha1, bytes)
}
/// 内存 fetcher:按 URL / (project, file) 查表,写盘后回成功。
pub(crate) struct StubFetcher {
files: BTreeMap<String, Vec<u8>>,
curse: BTreeMap<(i64, i64), CurseFileMeta>,
/// 被请求过的 URL(断言"没白下载"用)。
calls: Mutex<Vec<String>>,
/// 是否像真引擎那样在目标旁边留下续传残片(`.ullpart-*` / `.ullmeta.json`)。
artifacts: bool,
}
impl StubFetcher {
/// 空表。
pub(crate) fn new() -> Self {
Self {
files: BTreeMap::new(),
curse: BTreeMap::new(),
calls: Mutex::new(Vec::new()),
artifacts: false,
}
}
/// 挂一个可下载的地址。
pub(crate) fn with_file(mut self, url: &str, bytes: &[u8]) -> Self {
self.files.insert(url.to_string(), bytes.to_vec());
self
}
/// 挂一条 CurseForge 元数据。
pub(crate) fn with_curse(mut self, project_id: i64, file_id: i64, meta: CurseFileMeta) -> Self {
self.curse.insert((project_id, file_id), meta);
self
}
/// 像真引擎那样在每次"下载"后留下续传残片与 meta(用来钉"中间产物不许进实例")。
pub(crate) fn with_engine_artifacts(mut self) -> Self {
self.artifacts = true;
self
}
/// 被请求过的 URL 列表。
pub(crate) fn calls(&self) -> Vec<String> {
self.calls.lock().expect("calls 锁").clone()
}
}
impl PackFetcher for StubFetcher {
fn curse_file<'a>(
&'a self,
project_id: i64,
file_id: i64,
) -> FetchFuture<'a, Result<CurseFileMeta, super::super::import::FetchError>> {
Box::pin(async move {
self.curse
.get(&(project_id, file_id))
.cloned()
.ok_or_else(|| {
super::super::import::FetchError::new(
super::super::import::FetchErrorKind::NotFound,
format!("stub 里没有 {project_id}:{file_id}"),
)
})
})
}
fn fetch_file<'a>(
&'a self,
request: FetchRequest<'a>,
) -> FetchFuture<'a, Result<FetchedFile, super::super::import::FetchError>> {
Box::pin(async move {
self.calls
.lock()
.expect("calls 锁")
.push(request.url.to_string());
let bytes = self.files.get(request.url).cloned().ok_or_else(|| {
super::super::import::FetchError::new(
super::super::import::FetchErrorKind::NotFound,
format!("stub 里没有 {}", request.url),
)
})?;
if let Some(parent) = request.dest.parent() {
std::fs::create_dir_all(parent).map_err(|error| {
super::super::import::FetchError::new(
super::super::import::FetchErrorKind::Io,
error.to_string(),
)
})?;
}
std::fs::write(request.dest, &bytes).map_err(|error| {
super::super::import::FetchError::new(
super::super::import::FetchErrorKind::Io,
error.to_string(),
)
})?;
if self.artifacts {
leave_engine_artifacts(request.dest);
}
Ok(FetchedFile {
bytes: bytes.len() as u64,
})
})
}
}
/// 把 M110 的 mrpack 夹具改成"声明与实际一致",并配好 stub fetcher。
///
/// 只改两处:`files[].hashes.sha1`(换成真实摘要)与 `files[].downloads`(换成 `stub://`)。
pub(crate) fn mrpack_stub(tag: &str) -> (Fixture, StubFetcher) {
let fixture = fixtures::modrinth(tag);
let index_path = fixture.dir.join("modrinth.index.json");
let text = std::fs::read_to_string(&index_path).expect("读夹具 index");
let mut value: serde_json::Value = serde_json::from_str(&text).expect("解析夹具 index");
let entries = [
("mods/jei.jar", JEI_BYTES, JEI_URL),
("mods/optional.jar", OPTIONAL_BYTES, OPTIONAL_URL),
];
let files = value["files"].as_array_mut().expect("夹具 files 是数组");
for (entry, (path, bytes, url)) in files.iter_mut().zip(entries) {
assert_eq!(
entry["path"],
json!(path),
"夹具条目顺序变了,本帮助器要跟着改"
);
entry["hashes"]["sha1"] = json!(sha1(bytes));
entry["downloads"] = json!([url]);
entry["fileSize"] = json!(bytes.len());
}
write_file(&index_path, value.to_string().as_bytes());
zip_dir(&fixture.dir, &fixture.zip);
let fetcher = StubFetcher::new()
.with_file(JEI_URL, JEI_BYTES)
.with_file(OPTIONAL_URL, OPTIONAL_BYTES);
(fixture, fetcher)
}
/// CF 夹具的 stub 元数据(两个条目都补得出来,落在 `mods/`)。
pub(crate) fn curse_meta(file_name: &str, bytes: &[u8]) -> CurseFileMeta {
CurseFileMeta {
file_name: file_name.to_string(),
url: Some(CURSE_URL.to_string()),
sha1: Some(sha1(bytes)),
size: Some(bytes.len() as u64),
class_id: Some(6),
}
}
/// 在目标旁边留下与 `core::download::atomic` 同名的续传残片与 meta。
fn leave_engine_artifacts(dest: &std::path::Path) {
let Some(name) = dest
.file_name()
.map(|name| name.to_string_lossy().to_string())
else {
return;
};
let part = dest.with_file_name(format!("{name}.ullpart-{}-0", std::process::id()));
let meta = dest.with_file_name(format!("{name}.ullmeta.json"));
std::fs::write(part, b"partial bytes").expect("写续传残片");
std::fs::write(meta, b"{}").expect("写续传 meta");
}
+339
View File
@@ -0,0 +1,339 @@
//! 账户模型:**条目 id 与 profileID 解耦**的三轨统一抽象。
//!
//! 承 `analyse/04` §2.3 / §3.2 / §12.1(1) 的三条硬教训:
//! 1. 条目 id(`account:<32hex>`)**不得**等于 profileID / 登录名 / 服务器 URL——
//! 这些值可能被多个条目共享(同一角色导入两次、服务器换域名);
//! 2. **元数据 / 凭据双写分离**:`AccountMeta` 只装「能公开」的字段(列表渲染不需要解密);
//! 3. 反序列化**允许不完整对象**(`profile_name` 可空 = 「有档案但名字待刷新」),
//! 补齐推迟到登录时——不能因为名字未知就整条丢弃。
use serde::{Deserialize, Serialize};
use crate::auth::authlib::AuthlibSession;
use crate::auth::error::{AuthError, AuthResult};
use crate::auth::microsoft::MicrosoftSession;
use crate::auth::secret::random_hex32;
/// 条目 id 前缀(防被误当 UUID;承 HMCL `AccountID.PREFIX`)。
pub const ACCOUNT_ID_PREFIX: &str = "account:";
/// 账户条目 id:`account:<32 位十六进制>`。
///
/// 与 profileID 无关:改服务器地址、改玩家名、同角色多条目都不影响它。
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct AccountId(String);
impl AccountId {
/// 生成一个新条目 id。
#[must_use]
pub fn generate() -> Self {
Self(format!("{ACCOUNT_ID_PREFIX}{}", random_hex32()))
}
/// 从磁盘字符串解析(宽容:坏数据返回 `None`,不 panic)。
#[must_use]
pub fn parse(raw: &str) -> Option<Self> {
let body = raw.strip_prefix(ACCOUNT_ID_PREFIX)?;
if body.len() != 32 || !body.chars().all(|ch| ch.is_ascii_hexdigit()) {
return None;
}
Some(Self(raw.to_ascii_lowercase()))
}
/// 字符串视图。
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for AccountId {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(&self.0)
}
}
/// 三条轨道。
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum AccountKind {
/// 离线(本地生成的 UUID,不联网)。
Offline,
/// 微软(device-code / 授权码浏览器)。
Microsoft,
/// authlib-injector 第三方认证服务器(标准 Yggdrasil 协议)。
AuthlibInjector,
}
impl AccountKind {
/// 落盘用的稳定名(承 HMCL 的 `offline` / `microsoft` / `authlibInjector`)。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Offline => "offline",
Self::Microsoft => "microsoft",
Self::AuthlibInjector => "authlibInjector",
}
}
/// 解析(未知值返回 `None`,让上层跳过该条记录)。
#[must_use]
pub fn parse(raw: &str) -> Option<Self> {
match raw {
"offline" => Some(Self::Offline),
"microsoft" => Some(Self::Microsoft),
// 兼容两种写法:HMCL 用 camelCase,别的地方有人写 kebab。
"authlibInjector" | "authlib-injector" => Some(Self::AuthlibInjector),
_ => None,
}
}
/// 人读标签(CLI/JSON 展示用)。
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::Offline => "离线",
Self::Microsoft => "微软",
Self::AuthlibInjector => "authlib-injector",
}
}
}
/// 账户元数据(**可公开**,不含任何凭据)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountMeta {
/// 条目 id。
pub id: AccountId,
/// 轨道。
pub kind: AccountKind,
/// Minecraft 档案 UUID(32 位无连字符)。**不可变**。
pub profile_id: String,
/// 展示名/玩家名;空串 = 「有档案但名字待刷新」(合法中间态)。
#[serde(default)]
pub profile_name: String,
/// 登录名(authlib 轨的邮箱/用户名;其余轨为 `None`)。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub login_name: Option<String>,
/// authlib 认证服务器 API 根(其余轨为 `None`;凭据之外唯一需要持久化的服务器信息)。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub server_url: Option<String>,
/// 创建时刻(Unix 毫秒;仅用于排序/展示)。
pub created_at_ms: u64,
}
impl AccountMeta {
/// 构造一条元数据(`profile_name` 可后补)。
#[must_use]
pub fn new(
kind: AccountKind,
profile_id: impl Into<String>,
profile_name: impl Into<String>,
created_at_ms: u64,
) -> Self {
Self {
id: AccountId::generate(),
kind,
profile_id: profile_id.into(),
profile_name: profile_name.into(),
login_name: None,
server_url: None,
created_at_ms,
}
}
/// 展示名:优先玩家名,其次登录名,最后条目 id(永不 panic、永不空串)。
#[must_use]
pub fn display_name(&self) -> &str {
if !self.profile_name.trim().is_empty() {
return &self.profile_name;
}
if let Some(login_name) = &self.login_name {
if !login_name.trim().is_empty() {
return login_name;
}
}
self.id.as_str()
}
/// 该条目是否匹配一个查找键(名字 / 登录名 / 条目 id)。
///
/// 大小写不敏感:玩家名在 MC 里大小写不敏感,CLI 用户不该为此折腾。
#[must_use]
pub fn matches(&self, key: &str) -> bool {
let key = key.trim();
if key.is_empty() {
return false;
}
self.profile_name.eq_ignore_ascii_case(key)
|| self
.login_name
.as_deref()
.is_some_and(|name| name.eq_ignore_ascii_case(key))
|| self.id.as_str().eq_ignore_ascii_case(key)
}
/// 该条目的凭据是否与轨道匹配(防止磁盘上元数据被换成另一轨)。
///
/// # Errors
///
/// 轨道与凭据种类不一致时返回 [`AuthError::Malformed`]。
pub fn check_credentials(&self, credentials: &Credentials) -> AuthResult<()> {
if credentials.kind() == self.kind {
return Ok(());
}
Err(AuthError::Malformed(format!(
"账户 {} 的元数据轨道是 {},凭据却是 {}",
self.id,
self.kind.as_str(),
credentials.kind().as_str()
)))
}
/// 从 JSON 值宽容解析(单条坏记录只返回 `Err`,由调用方跳过并记 warning)。
///
/// # Errors
///
/// 缺字段/类型不符/轨道未知时返回 [`AuthError::Malformed`]。
pub fn from_value(value: &serde_json::Value) -> AuthResult<Self> {
serde_json::from_value(value.clone())
.map_err(|error| AuthError::Malformed(format!("账户元数据不合法:{error}")))
}
}
/// 凭据载荷(**私有**,只落 `credentials.dat`,永不进列表/日志)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum Credentials {
/// 离线账户没有凭据(accessToken 每次登录随机生成,不落盘)。
Offline,
/// 微软会话(access + refresh + `notAfter`)。
Microsoft(MicrosoftSession),
/// authlib 会话(accessToken + clientToken)。
AuthlibInjector(AuthlibSession),
}
impl Credentials {
/// 所属轨道。
#[must_use]
pub const fn kind(&self) -> AccountKind {
match self {
Self::Offline => AccountKind::Offline,
Self::Microsoft(_) => AccountKind::Microsoft,
Self::AuthlibInjector(_) => AccountKind::AuthlibInjector,
}
}
/// 该凭据里的所有明文敏感值(武装脱敏器用;离线轨为空)。
#[must_use]
pub fn secrets(&self) -> Vec<&str> {
match self {
Self::Offline => Vec::new(),
Self::Microsoft(session) => session
.secrets()
.into_iter()
.map(|secret| secret.expose())
.collect(),
Self::AuthlibInjector(session) => session
.secrets()
.into_iter()
.map(|secret| secret.expose())
.collect(),
}
}
}
#[cfg(test)]
mod tests {
use super::{AccountId, AccountKind, AccountMeta, Credentials};
use crate::auth::microsoft::MicrosoftSession;
use crate::auth::secret::Secret;
#[test]
fn account_id_shape_and_parse() {
let id = AccountId::generate();
assert!(id.as_str().starts_with("account:"));
assert_eq!(id.as_str().len(), "account:".len() + 32);
assert_eq!(AccountId::parse(id.as_str()), Some(id.clone()));
assert_eq!(AccountId::parse("account:xyz"), None);
assert_eq!(AccountId::parse("0192-blah"), None);
}
#[test]
fn kind_roundtrip() {
for kind in [
AccountKind::Offline,
AccountKind::Microsoft,
AccountKind::AuthlibInjector,
] {
assert_eq!(AccountKind::parse(kind.as_str()), Some(kind));
assert!(!kind.label().is_empty());
}
assert_eq!(AccountKind::parse("nope"), None);
}
#[test]
fn meta_matches_name_login_and_id() {
let mut meta = AccountMeta::new(AccountKind::Offline, "uuid32", "Steve", 1);
meta.login_name = Some("steve@example.com".to_string());
assert!(meta.matches("Steve"));
assert!(meta.matches("steve"));
assert!(meta.matches("steve@example.com"));
assert!(meta.matches(meta.id.as_str()));
assert!(!meta.matches("Alex"));
assert!(!meta.matches(" "));
}
#[test]
fn display_name_falls_back() {
let mut meta = AccountMeta::new(AccountKind::Microsoft, "uuid", "", 1);
assert_eq!(meta.display_name(), meta.id.as_str());
meta.login_name = Some("me@example.com".to_string());
assert_eq!(meta.display_name(), "me@example.com");
meta.profile_name = "Steve".to_string();
assert_eq!(meta.display_name(), "Steve");
}
#[test]
fn credential_kind_must_match_meta() {
let meta = AccountMeta::new(AccountKind::Microsoft, "uuid", "Steve", 1);
let session = MicrosoftSession::new(
"uuid",
"Steve",
Secret::new("a"),
Secret::new("r"),
60,
0,
None,
);
assert!(meta
.check_credentials(&Credentials::Microsoft(session))
.is_ok());
assert!(meta.check_credentials(&Credentials::Offline).is_err());
}
#[test]
fn secrets_only_expose_real_credentials() {
assert!(Credentials::Offline.secrets().is_empty());
let session = MicrosoftSession::new(
"uuid",
"Steve",
Secret::new("mc-token"),
Secret::new("live-refresh"),
60,
0,
None,
);
assert_eq!(
Credentials::Microsoft(session).secrets(),
vec!["mc-token", "live-refresh"]
);
}
#[test]
fn malformed_record_is_an_error_not_a_panic() {
let value = serde_json::json!({ "id": "account:zz", "kind": "offline" });
assert!(AccountMeta::from_value(&value).is_err());
}
}
+347
View File
@@ -0,0 +1,347 @@
//! authlib-injector 轨(**账户侧**):标准 Yggdrasil 协议的登录/刷新/校验/注销。
//!
//! 边界(mission 明示):本模块只做「账户」;`-javaagent` 三件套(jar 下载、
//! `-Dauthlibinjector.side=client`、`yggdrasil.prefetched` 的 Base64)归后续 mission。
//! 依据 `analyse/04` §6.1–§6.2(端点布局与四动作报文)与 §6.3(服务发现约定)。
//!
//! 三条照抄要点:
//! - 端点 = `<apiRoot>authserver/{authenticate,refresh,validate,invalidate}`,`apiRoot`
//! 必须带尾斜杠(`https://littleskin.cn/api/yggdrasil/` 这种目录布局是规范要求);
//! - `clientToken` 是**启动器侧生成的随机值**,服务端回传必须一致(防会话被替换/重放);
//! - 「token 无效」用**返回值 `false`** 表达(`validate` 的 `ForbiddenOperationException`),
//! 「服务器抽风」才抛错——调用方据此决定「刷新」还是「重试」。
pub mod server_meta;
pub use server_meta::AuthlibServerMeta;
use reqwest::Client;
use serde_json::json;
use crate::auth::error::{AuthError, AuthResult};
use crate::auth::http::{post_json, Reply};
use crate::auth::secret::Secret;
use crate::download::config::EngineConfig;
use crate::download::http::build_client;
use crate::launch::request::AuthPlaceholders;
/// authlib 轨的 launch 用户类型(承 HMCL `YggdrasilSession.toAuthInfo`:也是 `msa`)。
pub const USER_TYPE_MSA: &str = "msa";
/// 把用户输入规范成 API 根:补 `https://`、补尾斜杠、去空白。
///
/// # Errors
///
/// 空串或只剩协议头时返回 [`AuthError::Malformed`]。
pub fn normalize_api_root(input: &str) -> AuthResult<String> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Err(AuthError::Malformed(
"认证服务器地址不能为空(例:https://littleskin.cn/api/yggdrasil/)".to_string(),
));
}
let with_scheme = if trimmed.starts_with("https://") || trimmed.starts_with("http://") {
trimmed.to_string()
} else {
format!("https://{trimmed}")
};
let host = with_scheme
.split_once("://")
.map(|(_, rest)| rest)
.unwrap_or("");
if host.trim_matches('/').is_empty() {
return Err(AuthError::Malformed(format!(
"认证服务器地址不完整:`{input}`"
)));
}
Ok(if with_scheme.ends_with('/') {
with_scheme
} else {
format!("{with_scheme}/")
})
}
/// Yggdrasil 四个端点(都从 API 根派生)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AuthlibEndpoints {
/// `authserver/authenticate`。
pub authenticate: String,
/// `authserver/refresh`。
pub refresh: String,
/// `authserver/validate`。
pub validate: String,
/// `authserver/invalidate`。
pub invalidate: String,
}
impl AuthlibEndpoints {
/// 从 API 根派生(先 [`normalize_api_root`])。
///
/// # Errors
///
/// 地址不合法时返回 [`AuthError::Malformed`]。
pub fn from_api_root(api_root: &str) -> AuthResult<Self> {
let root = normalize_api_root(api_root)?;
let join = |path: &str| format!("{root}{path}");
Ok(Self {
authenticate: join("authserver/authenticate"),
refresh: join("authserver/refresh"),
validate: join("authserver/validate"),
invalidate: join("authserver/invalidate"),
})
}
}
/// 一个 Yggdrasil 角色(`selectedProfile`)。
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct AuthlibProfile {
/// 32 位无连字符 UUID。
pub id: String,
/// 角色名。
pub name: String,
}
/// authlib 会话 = 该账户的凭据载荷。
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AuthlibSession {
/// Yggdrasil accessToken。
pub access_token: Secret,
/// 启动器生成的 clientToken(刷新/校验都要带)。
pub client_token: Secret,
/// 当前选中的角色(多角色服务器里由 refresh 的 `selectedProfile` 决定)。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub selected_profile: Option<AuthlibProfile>,
}
impl AuthlibSession {
/// 该会话的全部凭据值(武装脱敏器)。
#[must_use]
pub fn secrets(&self) -> Vec<&Secret> {
vec![&self.access_token, &self.client_token]
}
/// 有没有角色(没角色的会话不能启动游戏)。
#[must_use]
pub fn has_profile(&self) -> bool {
self.selected_profile
.as_ref()
.is_some_and(|profile| !profile.name.trim().is_empty() && !profile.id.is_empty())
}
/// 转成 launch 占位符。
///
/// `user_properties` 用 `{}`:纹理由游戏进程经 authlib-injector 向服务器取
/// (注入参数属后续 mission),启动参数不需要它。
///
/// # Errors
///
/// 会话没有角色时返回 [`AuthError::CredentialExpired`](需要重新选角色/登录)。
pub fn to_placeholders(&self) -> AuthResult<AuthPlaceholders> {
let profile = self
.selected_profile
.as_ref()
.filter(|profile| !profile.name.trim().is_empty())
.ok_or(AuthError::CredentialExpired)?;
Ok(AuthPlaceholders {
player_name: profile.name.clone(),
uuid: profile.id.clone(),
access_token: self.access_token.expose().to_string(),
user_type: USER_TYPE_MSA.to_string(),
user_properties: "{}".to_string(),
})
}
}
/// authlib 认证客户端。
#[derive(Debug, Clone)]
pub struct AuthlibAuth {
client: Client,
}
impl AuthlibAuth {
/// 构造(HTTP 基座复用 `core::download`)。
///
/// # Errors
///
/// 客户端建不起来时返回 [`AuthError::Network`]。
pub fn new() -> AuthResult<Self> {
let client = build_client(&EngineConfig::default())
.map_err(|error| AuthError::network("http-client", error.to_string()))?;
Ok(Self { client })
}
/// 注入客户端(测试)。
#[must_use]
pub fn with_client(mut self, client: Client) -> Self {
self.client = client;
self
}
/// 密码登录(**新账户**)。密码只在本调用栈里出现,不落盘、不入日志。
///
/// # Errors
///
/// 密码错/服务器拒绝 → [`AuthError::Denied`];网络与结构问题各有分流。
pub async fn authenticate(
&self,
api_root: &str,
login_name: &str,
password: &Secret,
client_token: &Secret,
) -> AuthResult<AuthlibSession> {
let endpoints = AuthlibEndpoints::from_api_root(api_root)?;
let body = json!({
"agent": { "name": "Minecraft", "version": 1 },
"username": login_name,
"password": password.expose(),
"clientToken": client_token.expose(),
"requestUser": true,
});
let reply = post_json(&self.client, &endpoints.authenticate, &body, None).await?;
if !reply.is_success() {
return Err(rejected(&reply, &endpoints.authenticate, "登录"));
}
let session = session_from(&reply, client_token, &endpoints.authenticate)?;
if !session.has_profile() {
// 服务端没自动选角色(多角色服务器):交给调用方走 refresh + selectedProfile。
return Ok(session);
}
Ok(session)
}
/// 刷新(可顺带 `selectedProfile` 换角色)。
///
/// # Errors
///
/// token 失效 → [`AuthError::CredentialExpired`];换角色失败 → [`AuthError::Malformed`]。
pub async fn refresh(
&self,
api_root: &str,
session: &AuthlibSession,
select: Option<&AuthlibProfile>,
) -> AuthResult<AuthlibSession> {
let endpoints = AuthlibEndpoints::from_api_root(api_root)?;
let mut body = json!({
"accessToken": session.access_token.expose(),
"clientToken": session.client_token.expose(),
"requestUser": true,
});
if let Some(profile) = select {
body["selectedProfile"] = json!({ "id": profile.id, "name": profile.name });
}
let reply = post_json(&self.client, &endpoints.refresh, &body, None).await?;
if !reply.is_success() {
if is_forbidden(&reply) {
return Err(AuthError::CredentialExpired);
}
return Err(rejected(&reply, &endpoints.refresh, "刷新"));
}
let refreshed = session_from(&reply, &session.client_token, &endpoints.refresh)?;
if let Some(wanted) = select {
let got = refreshed.selected_profile.as_ref();
if got.map(|profile| profile.id.as_str()) != Some(wanted.id.as_str()) {
return Err(AuthError::Malformed(format!(
"服务器没有切到角色 {}(refresh 未生效)",
wanted.name
)));
}
}
Ok(refreshed)
}
/// 校验 token 是否仍有效(`ForbiddenOperationException` = `Ok(false)`)。
///
/// # Errors
///
/// 服务器 5xx/网络失败时返回错误(调用方据此区分「要刷新」与「要重试」)。
pub async fn validate(&self, api_root: &str, session: &AuthlibSession) -> AuthResult<bool> {
let endpoints = AuthlibEndpoints::from_api_root(api_root)?;
let body = json!({
"accessToken": session.access_token.expose(),
"clientToken": session.client_token.expose(),
});
let reply = post_json(&self.client, &endpoints.validate, &body, None).await?;
if is_forbidden(&reply) {
return Ok(false);
}
if !reply.is_success() {
return Err(rejected(&reply, &endpoints.validate, "校验"));
}
// 成功时 body 为空或 `{}`,都算有效。
Ok(true)
}
/// 主动注销(删账户时顺手清理服务端会话;失败按「尽力而为」返回错误,调用方可不理)。
///
/// # Errors
///
/// 网络/服务端失败时返回错误。
pub async fn invalidate(&self, api_root: &str, session: &AuthlibSession) -> AuthResult<()> {
let endpoints = AuthlibEndpoints::from_api_root(api_root)?;
let body = json!({
"accessToken": session.access_token.expose(),
"clientToken": session.client_token.expose(),
});
let reply = post_json(&self.client, &endpoints.invalidate, &body, None).await?;
if reply.is_success() || is_forbidden(&reply) {
return Ok(());
}
Err(rejected(&reply, &endpoints.invalidate, "注销"))
}
}
/// 响应 → 会话(`clientToken` 回传必须一致;缺失时保留原值)。
fn session_from(reply: &Reply, client_token: &Secret, url: &str) -> AuthResult<AuthlibSession> {
let access_token = reply
.text("accessToken")
.ok_or_else(|| AuthError::Malformed(format!("{url} 响应缺 accessToken")))?;
if let Some(returned) = reply.text("clientToken") {
if returned != client_token.expose() {
return Err(AuthError::Malformed(
"服务器回传的 clientToken 与本地不一致(会话可能被替换)".to_string(),
));
}
}
Ok(AuthlibSession {
access_token: Secret::new(access_token),
client_token: client_token.clone(),
selected_profile: selected_profile(reply),
})
}
fn selected_profile(reply: &Reply) -> Option<AuthlibProfile> {
let profile = reply
.body
.as_ref()
.and_then(|body| body.get("selectedProfile"))?;
let id = profile.get("id")?.as_str()?;
let name = profile.get("name")?.as_str()?;
Some(AuthlibProfile {
id: id.to_string(),
name: name.to_string(),
})
}
fn is_forbidden(reply: &Reply) -> bool {
reply.text("error") == Some("ForbiddenOperationException")
}
fn rejected(reply: &Reply, url: &str, what: &str) -> AuthError {
match reply.text("error") {
Some(code) => AuthError::Denied {
code: code.to_string(),
description: reply
.text("errorMessage")
.unwrap_or("服务器未给描述")
.to_string(),
},
None => AuthError::network(
url.split('?').next().unwrap_or(url),
format!("{what}失败(HTTP {})", reply.status),
),
}
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,86 @@
//! 认证服务器元数据里我们真正需要的字段(服务发现的产物)。
//!
//! authlib-injector 规范里根路径 `/` 返回 `{ meta, skinDomains, signaturePublickey }`
//! (`analyse/04` §6.3)。本期**只保存**名字/非邮箱登录/skinDomains:
//! `signaturePublickey` 与 `prefetched` 的 Base64 属于注入参数(后续 mission),
//! 现在存了也没人消费,不如不存。
use serde_json::Value;
/// 服务器元数据(缺失字段一律回落默认值)。
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AuthlibServerMeta {
/// 服务器显示名(缺省回落 URL)。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
/// 是否允许非邮箱登录名(决定输入框校验)。
#[serde(default)]
pub non_email_login: bool,
/// `skinDomains`(纹理白名单,注入参数会用;本期只保存)。
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub skin_domains: Vec<String>,
}
impl AuthlibServerMeta {
/// 从根元数据 JSON 解析(容错:缺字段就用默认值)。
#[must_use]
pub fn from_value(value: &Value) -> Self {
let meta = value.get("meta");
let name = meta
.and_then(|meta| meta.get("serverName"))
.and_then(Value::as_str)
.map(str::to_string);
let non_email_login = meta
.and_then(|meta| meta.get("feature.non_email_login"))
.and_then(Value::as_bool)
.unwrap_or(false);
let skin_domains = value
.get("skinDomains")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(Value::as_str)
.map(str::to_string)
.collect()
})
.unwrap_or_default();
Self {
name,
non_email_login,
skin_domains,
}
}
}
#[cfg(test)]
mod tests {
use super::AuthlibServerMeta;
use serde_json::json;
#[test]
fn parses_full_metadata() {
let value = json!({
"meta": {
"serverName": "Example Skin Site",
"feature.non_email_login": true
},
"skinDomains": ["example.com", "*.example.com"],
"signaturePublickey": "-----BEGIN PUBLIC KEY-----"
});
let meta = AuthlibServerMeta::from_value(&value);
assert_eq!(meta.name.as_deref(), Some("Example Skin Site"));
assert!(meta.non_email_login);
assert_eq!(meta.skin_domains.len(), 2);
}
#[test]
fn tolerates_missing_fields() {
let meta = AuthlibServerMeta::from_value(&json!({}));
assert_eq!(meta, AuthlibServerMeta::default());
assert!(meta.name.is_none());
assert!(!meta.non_email_login);
assert!(meta.skin_domains.is_empty());
}
}
+97
View File
@@ -0,0 +1,97 @@
//! authlib 轨的纯函数/模型测试(HTTP 部分在 `tests/auth/authlib_api.rs`)。
use super::{normalize_api_root, AuthlibEndpoints, AuthlibProfile, AuthlibSession, USER_TYPE_MSA};
use crate::auth::secret::Secret;
#[test]
fn api_root_normalization() {
assert_eq!(
normalize_api_root("littleskin.cn/api/yggdrasil").expect("补协议"),
"https://littleskin.cn/api/yggdrasil/"
);
assert_eq!(
normalize_api_root(" http://127.0.0.1:8080/api/yggdrasil/ ").expect("保留协议"),
"http://127.0.0.1:8080/api/yggdrasil/"
);
assert_eq!(
normalize_api_root("https://littleskin.cn").expect("根域名"),
"https://littleskin.cn/"
);
assert!(normalize_api_root(" ").is_err());
assert!(normalize_api_root("https://").is_err());
}
#[test]
fn endpoints_follow_directory_layout() {
let endpoints =
AuthlibEndpoints::from_api_root("https://example.invalid/api/yggdrasil").expect("端点");
assert_eq!(
endpoints.authenticate,
"https://example.invalid/api/yggdrasil/authserver/authenticate"
);
assert_eq!(
endpoints.refresh,
"https://example.invalid/api/yggdrasil/authserver/refresh"
);
assert_eq!(
endpoints.validate,
"https://example.invalid/api/yggdrasil/authserver/validate"
);
assert_eq!(
endpoints.invalidate,
"https://example.invalid/api/yggdrasil/authserver/invalidate"
);
}
#[test]
fn placeholders_require_a_profile() {
let without = AuthlibSession {
access_token: Secret::new("token"),
client_token: Secret::new("client"),
selected_profile: None,
};
assert!(!without.has_profile());
assert!(without.to_placeholders().is_err());
let with = AuthlibSession {
selected_profile: Some(AuthlibProfile {
id: "0123456789abcdef0123456789abcdef".to_string(),
name: "Steve".to_string(),
}),
..without
};
assert!(with.has_profile());
let placeholders = with.to_placeholders().expect("占位符");
assert_eq!(placeholders.player_name, "Steve");
assert_eq!(placeholders.uuid, "0123456789abcdef0123456789abcdef");
assert_eq!(placeholders.user_type, USER_TYPE_MSA);
assert_eq!(placeholders.access_token, "token");
}
#[test]
fn session_debug_never_leaks_tokens() {
let session = AuthlibSession {
access_token: Secret::new("access-token-value"),
client_token: Secret::new("client-token-value"),
selected_profile: None,
};
let debug = format!("{session:?}");
assert!(!debug.contains("access-token-value"));
assert!(!debug.contains("client-token-value"));
assert_eq!(session.secrets().len(), 2);
}
#[test]
fn session_survives_json_roundtrip() {
let session = AuthlibSession {
access_token: Secret::new("access-token-value"),
client_token: Secret::new("client-token-value"),
selected_profile: Some(AuthlibProfile {
id: "0123456789abcdef0123456789abcdef".to_string(),
name: "Steve".to_string(),
}),
};
let text = serde_json::to_string(&session).expect("序列化");
let back: AuthlibSession = serde_json::from_str(&text).expect("反序列化");
assert_eq!(back, session);
}
+279
View File
@@ -0,0 +1,279 @@
//! 非 2xx 应答的**正文诊断**(M38):把 M31「响应体一律丢掉」这个盲区补上。
//!
//! 起因是 `findings/20261030-tower-improve-login-with-xbox-403.md`:MSA 真机验收两轮都是
//! OAuth→XBL→XSTS 全通、`login_with_xbox` 稳定 403,而旧 `rejected()` 只报状态码,
//! 于是「区域/网络拦截页」和「服务端拒绝(许可/区域)」在文案上分不开。
//!
//! 这里只做三件事:
//! 1. **分类**:JSON(真实服务应答)/ HTML 或纯文本(多为网关、风控或代理拦截页)/ 空体;
//! 2. **提取**:JSON 里的 `error` / `errorMessage` / `reason` / `Message` 等服务端文案直出;
//! 3. **摘要**:正文压成 ≤[`DIGEST_CHARS`] 字符,**先过 [`Redactor`] 再进错误消息**。
//!
//! 凭据红线(M31 的 `Secret`/脱敏体系不破):
//! - 摘要只从**非 2xx** 响应构造——OAuth 成功响应体(含 token)永远不会走到这里;
//! - `Reply` 的原文快照只在 JSON 解析失败时保留(见 [`crate::auth::http`]),
//! 解析成功的应答不留原文,避免把 token 文本复制进第二个字段;
//! - 摘要与提取出的服务端文案都过同一套脱敏:[`Redactor::scrub`](已登记 token 整串替换、
//! ≥64 不透明串打码、JWT 形状的带点 token 打码——第三条 M40 已上提到 `launch::redact`,
//! 本模块不再自己补规则)。
use serde_json::Value;
use crate::auth::error::AuthError;
use crate::auth::http::Reply;
use crate::launch::redact::Redactor;
#[cfg(test)]
mod tests;
/// 错误文案 / 事件里正文摘要的上限(字符)。
pub const DIGEST_CHARS: usize = 200;
/// 服务端文案的取值字段(按优先级,命中即收;`Message` 是 XSTS 的写法)。
const SERVER_MESSAGE_KEYS: [&str; 6] = [
"errorMessage",
"error_message",
"error",
"errorType",
"reason",
"Message",
];
/// 一个非 2xx 应答的正文形状。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BodyKind {
/// 合法 JSON(真实服务应答)。
Json,
/// HTML 页面(网关/风控/代理拦截页的典型形状)。
Html,
/// 其它非 JSON 文本。
Text,
/// 空体。
Empty,
}
impl BodyKind {
/// 稳定名(错误文案与 JSONL 事件共用)。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Json => "json",
Self::Html => "html",
Self::Text => "text",
Self::Empty => "empty",
}
}
/// 是否是「拦截页」形状(HTML / 纯文本):这一类先怀疑网络,而不是账号。
#[must_use]
pub const fn is_interception(self) -> bool {
matches!(self, Self::Html | Self::Text)
}
}
/// 一次非 2xx 的诊断结果([`AuthError::Response`] 的载荷,也是 JSONL 事件的字段来源)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResponseDetail {
/// 链上的步骤(`xbl` / `xsts` / `login_with_xbox` / `entitlements` / `profile`)。
pub step: String,
/// 这一步在做什么(人话前缀,如「Minecraft 登录被拒」)。
pub what: String,
/// HTTP 状态码。
pub status: u16,
/// `Content-Type`(服务端没给时为 `None`)。
pub content_type: Option<String>,
/// 正文形状。
pub kind: BodyKind,
/// 服务端文案(已脱敏、已截断)。
pub server_message: Option<String>,
/// 正文摘要(已脱敏,≤[`DIGEST_CHARS`] 字符)。
pub digest: String,
}
impl ResponseDetail {
/// 细分机读码(**只增不改**):JSON 拒绝沿用 M31 的 `<step>_<status>`;
/// 非 JSON 用新增后缀 `<step>_non_json`;空体用 `<step>_empty_body`。
#[must_use]
pub fn detail_code(&self) -> String {
let suffix = match self.kind {
BodyKind::Json => self.status.to_string(),
BodyKind::Html | BodyKind::Text => "non_json".to_string(),
BodyKind::Empty => "empty_body".to_string(),
};
format!("{}_{suffix}", self.step)
}
/// 是否是「疑似网络拦截页」(HTML / 纯文本)。
#[must_use]
pub const fn is_interception(&self) -> bool {
self.kind.is_interception()
}
/// 「;服务端文案…;响应摘要…」片段(都没有时为空串,方便复用文案拼装)。
#[must_use]
pub fn note(&self) -> String {
let mut note = String::new();
if let Some(message) = &self.server_message {
note.push_str(&format!(";服务端文案「{message}」"));
}
if !self.digest.is_empty() {
note.push_str(&format!(";响应摘要:{}", self.digest));
}
note
}
/// 人读一句话(**不含凭据**),403 的两种分流在这里分开。
#[must_use]
pub fn message(&self) -> String {
let mut text = format!("{}(HTTP {}", self.what, self.status);
if let Some(content_type) = &self.content_type {
text.push_str(&format!(",Content-Type: {content_type}"));
}
text.push(')');
match self.kind {
BodyKind::Json => text.push_str(":服务端返回了 JSON 拒绝"),
BodyKind::Html | BodyKind::Text => {
text.push_str(":响应非 JSON(可能网络拦截/网关页)");
}
BodyKind::Empty => text.push_str(":响应体为空(可能网络拦截/网关页)"),
}
text.push(';');
text.push_str(advice(&self.step, self.kind));
text.push_str(&self.note());
text
}
}
/// 下一步该往哪查(403 分流的核心:拦截页查网络/代理,JSON 拒绝查账号许可)。
#[must_use]
pub fn advice(step: &str, kind: BodyKind) -> &'static str {
if kind.is_interception() {
return "请检查网络或代理(关掉/换掉代理后再试,必要时用镜像方案)";
}
match step {
"login_with_xbox" => "若网络正常,请检查该账号的 Minecraft 许可与所在区域",
"entitlements" => "若网络正常,请检查该账号是否拥有 Minecraft Java 版",
"profile" => "若网络正常,请去 minecraft.net 检查档案状态",
_ => "请按服务端文案处理;如反复失败,检查网络代理是否生效",
}
}
/// 分类一个非 2xx 应答。
pub(crate) fn classify(reply: &Reply) -> BodyKind {
if reply.body.is_some() {
return BodyKind::Json;
}
let Some(raw) = reply
.raw
.as_deref()
.map(str::trim)
.filter(|raw| !raw.is_empty())
else {
return BodyKind::Empty;
};
let content_type = reply
.content_type
.as_deref()
.unwrap_or_default()
.to_ascii_lowercase();
if content_type.contains("html") || looks_like_html(raw) {
BodyKind::Html
} else {
BodyKind::Text
}
}
/// 正文是不是 HTML 文档(没给 `Content-Type` 时的兜底判断)。
fn looks_like_html(raw: &str) -> bool {
raw.trim_start().starts_with('<')
}
/// 组装一条诊断(分类 + 服务端文案 + 摘要)。
pub(crate) fn rejection(
step: &str,
reply: &Reply,
what: &str,
redactor: &Redactor,
) -> ResponseDetail {
ResponseDetail {
step: step.to_string(),
what: what.to_string(),
status: reply.status,
content_type: reply.content_type.clone(),
kind: classify(reply),
server_message: server_message(reply, redactor),
digest: digest(reply, redactor),
}
}
/// 从 JSON 体里挑出服务端文案(去重后以 ` / ` 连接)。
fn server_message(reply: &Reply, redactor: &Redactor) -> Option<String> {
let body = reply.body.as_ref()?;
let mut parts: Vec<String> = Vec::new();
for key in SERVER_MESSAGE_KEYS {
let Some(text) = body.get(key).and_then(Value::as_str) else {
continue;
};
let text = one_line(text);
if text.is_empty() || parts.iter().any(|part| part == &text) {
continue;
}
parts.push(text);
}
if parts.is_empty() {
return None;
}
Some(truncate(&scrub(&parts.join(" / "), redactor), DIGEST_CHARS))
}
/// 正文摘要:JSON 用紧凑文本,非 JSON 用原文快照;先脱敏再截断。
fn digest(reply: &Reply, redactor: &Redactor) -> String {
let raw = match (&reply.body, &reply.raw) {
(Some(body), _) => body.to_string(),
(None, Some(raw)) => raw.to_string(),
(None, None) => return String::new(),
};
truncate(&scrub(&one_line(&raw), redactor), DIGEST_CHARS)
}
/// 走完整套脱敏:[`Redactor::scrub`] 的三条规则(已登记 token + ≥64 不透明串 + 带点 token)。
///
/// M40 之前这里自己补了一条「带点 token」规则:`Redactor` 只按「连续不透明串 ≥64」判形状,
/// 而 JWT(`header.payload.signature`)里有点号,把连续段切短,恰好躲过那条规则——服务端
/// 把 token 回显在错误体里时就会原样进摘要。现在规则已上提到 `launch::redact`(全局底座),
/// 这里只调共用实现,两处规则不会再漂。
fn scrub(text: &str, redactor: &Redactor) -> String {
redactor.scrub(text).into_owned()
}
/// 压成一行(HTML 的分行会让错误消息难以阅读)。
fn one_line(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut space = false;
for ch in text.chars() {
if ch.is_whitespace() {
space = !out.is_empty();
continue;
}
if std::mem::take(&mut space) {
out.push(' ');
}
out.push(ch);
}
out
}
/// 按字符截断(超长补 `…`)。
fn truncate(text: &str, limit: usize) -> String {
if text.chars().count() <= limit {
return text.to_string();
}
let mut out: String = text.chars().take(limit.saturating_sub(1)).collect();
out.push('…');
out
}
/// 把诊断包成 [`AuthError::Response`](链上各步骤的统一出口)。
pub(crate) fn response(step: &str, reply: &Reply, what: &str, redactor: &Redactor) -> AuthError {
AuthError::Response(Box::new(rejection(step, reply, what, redactor)))
}
+168
View File
@@ -0,0 +1,168 @@
//! [`super`] 的单测:分类、服务端文案提取、摘要脱敏与 403 分流文案。
use super::{
advice, classify, digest, one_line, response, server_message, truncate, BodyKind, DIGEST_CHARS,
};
use crate::auth::error::AuthError;
use crate::auth::http::Reply;
use crate::auth::secret::{redactor_for, Secret};
use crate::launch::redact::Redactor;
use serde_json::json;
const JWT: &str = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.c2lnbmF0dXJlLXZhbHVlLXRoYXQtaXMtbG9uZy1lbm91Z2g";
fn json_reply(status: u16, body: serde_json::Value) -> Reply {
Reply {
status,
body: Some(body),
content_type: Some("application/json".to_string()),
raw: None,
}
}
fn text_reply(status: u16, content_type: Option<&str>, raw: &str) -> Reply {
Reply {
status,
body: None,
content_type: content_type.map(str::to_string),
raw: Some(raw.to_string()),
}
}
#[test]
fn classification_splits_json_html_text_and_empty() {
assert_eq!(
classify(&json_reply(403, json!({ "error": "x" }))),
BodyKind::Json
);
assert_eq!(
classify(&text_reply(403, Some("text/html"), "<!DOCTYPE html>")),
BodyKind::Html
);
// 没给 Content-Type 时靠正文形状兜底。
assert_eq!(
classify(&text_reply(403, None, "<html><body>blocked")),
BodyKind::Html
);
assert_eq!(
classify(&text_reply(502, Some("text/plain"), "Bad Gateway")),
BodyKind::Text
);
assert_eq!(classify(&text_reply(403, None, " ")), BodyKind::Empty);
}
#[test]
fn server_message_prefers_error_fields_and_dedups() {
let redactor = Redactor::new();
let reply = json_reply(
403,
json!({ "error": "UNAUTHORIZED", "errorMessage": "no license", "path": "/x" }),
);
let message = server_message(&reply, &redactor).expect("应有服务端文案");
assert!(message.contains("no license"), "{message}");
assert!(message.contains("UNAUTHORIZED"), "{message}");
let same = json_reply(403, json!({ "error": "DENIED", "errorMessage": "DENIED" }));
assert_eq!(server_message(&same, &redactor).as_deref(), Some("DENIED"));
assert!(server_message(&json_reply(403, json!({ "path": "/x" })), &redactor).is_none());
assert!(server_message(&text_reply(500, None, "boom"), &redactor).is_none());
}
#[test]
fn digest_is_redacted_and_capped() {
let token = Secret::new("mc-access-token-value");
let redactor = redactor_for([&token]);
let reply = json_reply(
403,
json!({ "error": "DENIED", "echo": format!("Bearer {}", token.expose()) }),
);
let masked_digest = digest(&reply, &redactor);
assert!(
!masked_digest.contains("mc-access-token-value"),
"{masked_digest}"
);
let long = "blocked ".repeat(60);
let capped = digest(&text_reply(500, Some("text/plain"), &long), &redactor);
assert!(capped.chars().count() <= DIGEST_CHARS);
assert!(capped.ends_with('…'));
}
#[test]
fn opaque_runs_are_masked_even_without_known_secret() {
let masked = digest(&text_reply(401, Some("text/plain"), JWT), &Redactor::new());
assert!(!masked.contains("eyJhbGci"), "{masked}");
assert!(masked.contains("redacted"), "{masked}");
}
#[test]
fn dotted_tokens_are_masked_by_the_shared_redactor() {
// 规则本体在 `launch::redact`(M40 上提),这里只验「auth 摘要走的是同一份实现」。
let redactor = Redactor::new();
assert!(!redactor.scrub(JWT).contains("eyJhbGci"), "JWT 应被打码");
// 普通文案里的点不会触发(分段太短)。
assert_eq!(
redactor.scrub("Service.Unavailable.today"),
"Service.Unavailable.today"
);
let masked = redactor.scrub(&format!("token={JWT}&end")).into_owned();
assert!(!masked.contains("eyJhbGci"), "{masked}");
assert!(masked.ends_with("&end"), "{masked}");
}
#[test]
fn one_line_collapses_whitespace() {
assert_eq!(one_line(" a\n\n b\tc "), "a b c");
assert_eq!(truncate("abcdef", 4), "abc…");
assert_eq!(truncate("abc", 4), "abc");
}
#[test]
fn detail_codes_and_advice_split_network_from_license() {
let redactor = Redactor::new();
let blocked = response(
"login_with_xbox",
&text_reply(403, Some("text/html"), "<html>blocked</html>"),
"Minecraft 登录被拒",
&redactor,
);
let AuthError::Response(detail) = &blocked else {
panic!("应是 Response,实际 {blocked:?}");
};
assert_eq!(detail.detail_code(), "login_with_xbox_non_json");
assert!(
detail.message().contains("网络拦截"),
"{}",
detail.message()
);
assert!(detail.message().contains("代理"), "{}", detail.message());
let denied = response(
"login_with_xbox",
&json_reply(
403,
json!({ "error": "UNAUTHORIZED", "errorMessage": "nope" }),
),
"Minecraft 登录被拒",
&redactor,
);
let AuthError::Response(detail) = &denied else {
panic!("应是 Response,实际 {denied:?}");
};
assert_eq!(detail.detail_code(), "login_with_xbox_403");
assert!(detail.message().contains("许可"), "{}", detail.message());
assert!(!detail.message().contains("代理"), "{}", detail.message());
let empty = response(
"profile",
&text_reply(500, None, ""),
"拉取档案被拒",
&redactor,
);
let AuthError::Response(detail) = &empty else {
panic!("应是 Response,实际 {empty:?}");
};
assert_eq!(detail.detail_code(), "profile_empty_body");
assert!(!advice("xbl", BodyKind::Html).is_empty());
assert!(!advice("xbl", BodyKind::Json).is_empty());
}
+256
View File
@@ -0,0 +1,256 @@
//! 认证错误:把 HMCL 的异常树压成「调用方能分流」的一层(`analyse/04` §2.6 / §4.5)。
//!
//! 三条设计:
//! 1. **`CredentialExpired` 是唯一「要人再登录」的信号**(承 HMCL `CredentialExpiredException`),
//! 其余错误都不该弹交互;
//! 2. **取消 / 超时 / 被拒 三分**(mission 明示的错误分流):用户关窗口 = `Canceled`,
//! 设备码 15 分钟硬超时 = `Timeout`,`access_denied` = `Denied`;
//! 3. **文案里永不含凭据**:错误只描述发生了什么,token 一律不进 message。
use std::fmt;
use crate::auth::body::ResponseDetail;
/// Xbox Live 错误码:账号被封(`MicrosoftService.BANNED`)。
pub const XERR_BANNED: u32 = 2_148_916_227;
/// 没有 Xbox 账号(需先建档案)。
pub const XERR_MISSING_XBOX_ACCOUNT: u32 = 2_148_916_233;
/// 地区不可用。
pub const XERR_COUNTRY_UNAVAILABLE: u32 = 2_148_916_235;
/// 儿童账号未加入家庭组。
pub const XERR_ADD_FAMILY: u32 = 2_148_916_238;
/// 认证失败。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AuthError {
/// 网络层失败(DNS/连接/TLS/超时以外的 IO)。
Network {
/// 目标 URL(不含 query 里的凭据)。
url: String,
/// 底层描述。
detail: String,
},
/// 超时:连接超时、整体超时,或设备码流程等满 `expires_in`/15 分钟。
Timeout {
/// 在等什么。
what: String,
},
/// 用户主动取消(关窗口、Ctrl+C、`access_denied` 也归这里)。
Canceled,
/// 凭据过期/被撤销,必须重新交互式登录(`AADSTS70000` 族)。
CredentialExpired,
/// 服务端明确拒绝(OAuth `error` + `error_description`)。
Denied {
/// OAuth 错误码(`invalid_grant` / `authorization_declined` …)。
code: String,
/// 服务端描述。
description: String,
},
/// 链上某一步拿到非 2xx,且带回了正文诊断(M38)。
///
/// 与 [`Self::Denied`] 同一分流层(`code()` 仍是 `denied`,向后兼容 M31 的调用方),
/// 细分码看 [`AuthError::detail_code`]:JSON 拒绝沿用 `<step>_<status>`,
/// 拦截页(HTML/纯文本)是新增的 `<step>_non_json`,空体是 `<step>_empty_body`。
///
/// 载荷装箱:`ResponseDetail` 有 7 个字段(含 4 个字符串),不装箱会把整个枚举撑到
/// 128 字节以上,`Result<T, AuthError>` 的每个 Ok 也跟着变胖(clippy `result_large_err`)。
Response(Box<ResponseDetail>),
/// Xbox Live / XSTS 业务错误码(`XErr`)。
Xbox {
/// `XErr` 数值。
code: u32,
},
/// 响应结构不合法(JSON 缺字段、类型不对、profile 被换掉)。
Malformed(String),
/// 凭据/元数据落盘或读取失败。
Store {
/// 涉及的路径。
path: String,
/// 底层描述。
detail: String,
},
/// 找不到指定账户。
NotFound {
/// 用户给的查找键。
account: String,
},
/// 账户重复(同名同轨已存在)。
Conflict {
/// 冲突的账户名。
account: String,
},
/// 该轨/该能力当前不可用:**未配置**(如缺 Azure client id)或本期未实现
/// (如 CLI 上的 authlib 密码登录)。
///
/// 载荷必须**自带完整的人读句子**(「未配置 + 怎么配」/「未实现 + 为什么」),
/// 本层不再统一拼「本期未支持:」这类前缀——同一层里两种情况完全不同的事,
/// 硬拼一个前缀会让「没配置」被读成「没实现」(M117 用户实跑踩到)。
/// 机读码仍是 `unsupported`([`Self::code`]),调用方分流不受影响。
Unsupported(String),
}
impl AuthError {
/// 稳定机读码(CLI `--json` 与日志都用它,不随文案变)。
#[must_use]
pub const fn code(&self) -> &'static str {
match self {
Self::Network { .. } => "network",
Self::Timeout { .. } => "timeout",
Self::Canceled => "canceled",
Self::CredentialExpired => "credential_expired",
Self::Denied { .. } => "denied",
// 与 `Denied` 同一粗分流层:M31 时代的调用方(CLI 提示、launch 身份解析)无需改。
Self::Response(_) => "denied",
Self::Xbox { .. } => "xbox",
Self::Malformed(_) => "malformed",
Self::Store { .. } => "store",
Self::NotFound { .. } => "not_found",
Self::Conflict { .. } => "conflict",
Self::Unsupported(_) => "unsupported",
}
}
/// 是否是「必须重新登录」的信号(唯一该弹交互的一类)。
#[must_use]
pub const fn is_credential_expired(&self) -> bool {
matches!(self, Self::CredentialExpired)
}
/// 是否属于「网络/服务端抽风,可重试或跳过」的一类。
#[must_use]
pub const fn is_transient(&self) -> bool {
match self {
Self::Network { .. } | Self::Timeout { .. } | Self::Xbox { .. } => true,
// 拦截页/空体多半是网络侧问题;JSON 拒绝是服务端明确表态,重试无意义。
Self::Response(detail) => detail.kind.is_interception(),
_ => false,
}
}
/// 细分机读码(M38 新增):OAuth 拒绝返回 `<step>` 里带的错误码,链上非 2xx 返回
/// `<step>_<status>` / `<step>_non_json` / `<step>_empty_body`,其余回落到 [`Self::code`]。
///
/// 与 [`Self::code`] 的分工:`code()` 是**分流层**(决定弹不弹交互、能不能重试),
/// 这个方法给日志与 `--json` 的**排障层**用。既有码一律不改,只增。
#[must_use]
pub fn detail_code(&self) -> String {
match self {
Self::Response(detail) => detail.detail_code(),
Self::Denied { code, .. } => code.clone(),
other => other.code().to_string(),
}
}
/// 网络错误的构造(`url` 只放 host+path,别把 query 里的 code 带进来)。
#[must_use]
pub fn network(url: impl Into<String>, detail: impl Into<String>) -> Self {
Self::Network {
url: url.into(),
detail: detail.into(),
}
}
/// 落盘错误的构造。
#[must_use]
pub fn store(path: impl fmt::Display, detail: impl Into<String>) -> Self {
Self::Store {
path: path.to_string(),
detail: detail.into(),
}
}
/// 人读一句话(**不含凭据**)。
#[must_use]
pub fn message(&self) -> String {
match self {
Self::Network { url, detail } => format!("网络请求失败({url}):{detail}"),
Self::Timeout { what } => format!("等待超时:{what}"),
Self::Canceled => "登录已取消".to_string(),
Self::CredentialExpired => "凭据已过期,需要重新登录".to_string(),
Self::Denied { code, description } => {
format!("服务端拒绝({code}):{description}")
}
Self::Response(detail) => detail.message(),
Self::Xbox { code } => format!("Xbox 授权失败(XErr {code}):{}", xbox_hint(*code)),
Self::Malformed(detail) => format!("响应不符合预期:{detail}"),
Self::Store { path, detail } => format!("账户数据读写失败({path}):{detail}"),
Self::NotFound { account } => format!("找不到账户 `{account}`"),
Self::Conflict { account } => format!("账户 `{account}` 已存在"),
Self::Unsupported(detail) => detail.clone(),
}
}
}
impl fmt::Display for AuthError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.message())
}
}
impl std::error::Error for AuthError {}
/// `XErr` 码 → 中文说明(承 `analyse/04` §5.2 的四码表)。
#[must_use]
pub fn xbox_hint(code: u32) -> &'static str {
match code {
XERR_BANNED => "该微软账号已被 Xbox Live 封禁",
XERR_MISSING_XBOX_ACCOUNT => "该微软账号还没有 Xbox 档案,请先去 xbox.com 创建",
XERR_COUNTRY_UNAVAILABLE => "该账号所在地区不被 Xbox Live 支持",
XERR_ADD_FAMILY => "儿童账号需要先加入 Microsoft 家庭组",
_ => "Xbox Live 拒绝了本次授权(可能无 Xbox 档案或账号受限)",
}
}
/// 结果别名。
pub type AuthResult<T> = Result<T, AuthError>;
#[cfg(test)]
mod tests {
use super::{xbox_hint, AuthError, XERR_ADD_FAMILY};
#[test]
fn codes_are_stable() {
assert_eq!(AuthError::Canceled.code(), "canceled");
assert_eq!(AuthError::CredentialExpired.code(), "credential_expired");
assert!(AuthError::Xbox {
code: XERR_ADD_FAMILY
}
.is_transient());
assert!(!AuthError::Canceled.is_transient());
}
#[test]
fn messages_carry_no_secret_shape() {
let error = AuthError::Denied {
code: "invalid_grant".to_string(),
description: "AADSTS70000: bad redirect".to_string(),
};
assert!(error.message().contains("AADSTS70000"));
assert!(AuthError::Xbox { code: 2148916238 }
.message()
.contains("家庭组"));
assert!(!xbox_hint(0).is_empty());
}
#[test]
fn response_shares_denied_coarse_code_but_has_its_own_detail_code() {
let error = AuthError::Response(Box::new(super::ResponseDetail {
step: "login_with_xbox".to_string(),
what: "Minecraft 登录被拒".to_string(),
status: 403,
content_type: Some("text/html".to_string()),
kind: crate::auth::body::BodyKind::Html,
server_message: None,
digest: String::new(),
}));
// 粗分流层向后兼容:仍是 `denied`;排障层才是细分码。
assert_eq!(error.code(), "denied");
assert_eq!(error.detail_code(), "login_with_xbox_non_json");
assert!(error.is_transient(), "拦截页可以重试");
assert_eq!(
AuthError::Canceled.detail_code(),
"canceled",
"没有细分的错误回落到 code()"
);
}
}
+208
View File
@@ -0,0 +1,208 @@
//! 认证用的 HTTP 薄封装(三条轨共用):**复用既有 download 引擎的客户端基座**
//! (mission Context:网络操作走既有 HTTP 基座),只补三件认证需要的东西:
//! 表单/JSON 体、Bearer 头、单请求整体超时。
//!
//! 三条纪律:
//! 1. **响应体不原样进错误文案**——OAuth 的 token 响应体本身就是凭据,把它塞进
//! `Malformed` 的 message 就等于把 token 写进日志(`analyse/04` §11 观察项 9 的坑);
//! 因此解析失败只说「不是合法 JSON」,贴原文的唯一通道是
//! [`crate::auth::body`] 的脱敏摘要(M38:仅非 2xx、≤200 字符、过 Redactor)。
//! 2. 状态码不做归类,**原样交给调用方**:XSTS 的 401 带 `XErr`、`/minecraft/profile` 的
//! 404 要复判 license,都是「非 200 但有语义」的分支,提前归一化会丢掉信息。
//! 3. **诊断留证,但不留凭据**(M38):`Reply` 额外携带 `Content-Type` 与非 JSON 正文的快照
//! ([`RAW_PREVIEW_BYTES`] 字节封顶),诊断由 [`crate::auth::body`] 分类/脱敏后再进错误消息。
//! 原文快照**只在 JSON 解析失败时**保留,所以 OAuth 成功响应体不会被复制进第二个字段。
use std::time::Duration;
use reqwest::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE};
use reqwest::Client;
use serde_json::Value;
use crate::auth::error::{AuthError, AuthResult};
use crate::auth::secret::Secret;
/// 单请求整体超时(设备码轮询的每个请求都远快于此)。
pub(crate) const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
/// 非 JSON 正文保留的快照上限(字节):够判断「是不是 HTML」并抓前几句文案。
pub(crate) const RAW_PREVIEW_BYTES: usize = 512;
/// 一次应答:状态码 + 可解析的 JSON 体 + 诊断用的头/正文快照。
#[derive(Debug, Clone)]
pub(crate) struct Reply {
/// HTTP 状态码。
pub status: u16,
/// 解析成功时的 JSON 体(空体/非 JSON 为 `None`)。
pub body: Option<Value>,
/// `Content-Type` 原样(诊断文案与分流判断用;缺失为 `None`)。
pub content_type: Option<String>,
/// 非 JSON 正文的前 [`RAW_PREVIEW_BYTES`] 字节(有损 UTF-8;JSON 解析成功时为 `None`)。
pub raw: Option<String>,
}
impl Reply {
/// 是否 2xx。
pub(crate) fn is_success(&self) -> bool {
(200..300).contains(&self.status)
}
/// 取一个字符串字段。
pub(crate) fn text(&self, path: &str) -> Option<&str> {
self.body.as_ref()?.get(path)?.as_str()
}
/// 取一个 u64 字段(数字或数字字符串都接受)。
pub(crate) fn number(&self, path: &str) -> Option<u64> {
let value = self.body.as_ref()?.get(path)?;
value
.as_u64()
.or_else(|| value.as_str().and_then(|raw| raw.trim().parse().ok()))
}
}
/// 表单 `POST`(OAuth 的设备码/token 端点)。
pub(crate) async fn post_form(
client: &Client,
url: &str,
form: Vec<(&str, String)>,
) -> AuthResult<Reply> {
let request = client
.post(url)
.header(ACCEPT, "application/json")
.timeout(REQUEST_TIMEOUT)
.form(&form);
send(request, url).await
}
/// JSON `POST`(XBL / XSTS / login_with_xbox)。
pub(crate) async fn post_json(
client: &Client,
url: &str,
body: &Value,
bearer: Option<&Secret>,
) -> AuthResult<Reply> {
let text = serde_json::to_string(body)
.map_err(|error| AuthError::Malformed(format!("请求体序列化失败:{error}")))?;
let mut request = client
.post(url)
.header(CONTENT_TYPE, "application/json")
.header(ACCEPT, "application/json")
.timeout(REQUEST_TIMEOUT)
.body(text);
if let Some(token) = bearer {
request = request.header(AUTHORIZATION, format!("Bearer {}", token.expose()));
}
send(request, url).await
}
/// 带 Bearer 的 `GET`(entitlements / profile)。
pub(crate) async fn get_json(client: &Client, url: &str, bearer: &Secret) -> AuthResult<Reply> {
let request = client
.get(url)
.header(ACCEPT, "application/json")
.header(AUTHORIZATION, format!("Bearer {}", bearer.expose()))
.timeout(REQUEST_TIMEOUT);
send(request, url).await
}
/// 发请求并解析 JSON(无凭据泄漏的错误映射)。
async fn send(request: reqwest::RequestBuilder, url: &str) -> AuthResult<Reply> {
let response = request
.send()
.await
.map_err(|error| http_error(url, &error))?;
let status = response.status().as_u16();
let content_type = response
.headers()
.get(CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.map(str::to_string);
let bytes = response
.bytes()
.await
.map_err(|error| http_error(url, &error))?;
let body = if bytes.is_empty() {
None
} else {
serde_json::from_slice::<Value>(&bytes).ok()
};
// 只有解析失败(HTML 拦截页 / 纯文本 / 坏 JSON)才留原文快照:
// 解析成功的响应体可能含 token,不留第二份副本。
let raw = match (&body, bytes.is_empty()) {
(None, false) => Some(preview(&bytes)),
_ => None,
};
Ok(Reply {
status,
body,
content_type,
raw,
})
}
/// 取正文前 [`RAW_PREVIEW_BYTES`] 字节(有损解码,截断时补 `…`)。
fn preview(bytes: &[u8]) -> String {
let truncated = bytes.len() > RAW_PREVIEW_BYTES;
let head = &bytes[..bytes.len().min(RAW_PREVIEW_BYTES)];
let mut text = String::from_utf8_lossy(head).into_owned();
if truncated {
text.push('…');
}
text
}
/// `reqwest::Error` → [`AuthError`](超时与非超时分开,文案只带 host+path)。
fn http_error(url: &str, error: &reqwest::Error) -> AuthError {
let target = url.split('?').next().unwrap_or(url).to_string();
if error.is_timeout() {
AuthError::Timeout {
what: format!("请求 {target}"),
}
} else {
AuthError::network(target, error.to_string())
}
}
#[cfg(test)]
mod tests {
use super::{preview, Reply, RAW_PREVIEW_BYTES};
use serde_json::json;
fn reply(status: u16, body: Option<serde_json::Value>) -> Reply {
Reply {
status,
body,
content_type: Some("application/json".to_string()),
raw: None,
}
}
#[test]
fn reply_reads_text_and_numbers() {
let sample = reply(
200,
Some(json!({ "user_code": "ABC", "expires_in": 900, "interval": "5" })),
);
assert!(sample.is_success());
assert_eq!(sample.text("user_code"), Some("ABC"));
assert_eq!(sample.number("expires_in"), Some(900));
assert_eq!(sample.number("interval"), Some(5));
assert_eq!(sample.number("missing"), None);
assert_eq!(sample.text("missing"), None);
}
#[test]
fn non_success_is_not_success() {
assert!(!reply(401, None).is_success());
}
#[test]
fn preview_caps_and_marks_truncation() {
let short = preview(b"Bad Gateway");
assert_eq!(short, "Bad Gateway");
let long = preview(&vec![b'x'; RAW_PREVIEW_BYTES + 64]);
assert!(long.ends_with('…'));
assert_eq!(long.trim_end_matches('…').len(), RAW_PREVIEW_BYTES);
}
}
+266
View File
@@ -0,0 +1,266 @@
//! auth 失败事件的落盘(`.ULL/logs/auth.jsonl`,M38)。
//!
//! 起因:MSA 真机验收两轮 403,`ull accounts add msa` 失败后终端只有一行文案,
//! 验收人拿不到状态码/正文摘要去对账。这里把**失败**写成一行一条 JSONL:
//! 便携模式数据根还没初始化时会先补建 `logs/` 分区,403 的细节不再随终端滚走。
//!
//! 三条纪律:
//! 1. **只记失败**,不记成功(成功路径的凭据载荷一律不落日志);
//! 2. **记的是诊断字段**(`code` / `detail_code` / `status` / `content_type` / `body_kind` /
//! 已脱敏的 `server_message`、`digest`),没有任何 token/密码字段;
//! 3. **落盘是尽力而为**:写日志失败绝不让登录本身失败(调用方忽略返回值)。
//!
//! 数据根从哪来:默认走 `platform::data_dir` 的 **Portable** 解析(exe 同目录的 `.ULL`,
//! 跟随 `redirect.json`)。宿主若用 `--data-dir` 等非便携模式,应显式
//! [`AuthJournal::in_logs_dir`] 后交给 [`crate::auth::MicrosoftAuth::with_journal`]。
use std::io::Write;
use std::path::{Path, PathBuf};
use serde_json::{json, Value};
use crate::auth::body::BodyKind;
use crate::auth::error::AuthError;
use crate::launch::redact::Redactor;
use crate::platform::data_dir::{DataDirRequest, Resolver};
use crate::platform::layout::LauncherLayout;
use crate::platform::paths::Platform;
/// 认证失败事件文件名(在 `.ULL/logs/` 下)。
pub const AUTH_LOG_FILE: &str = "auth.jsonl";
/// 一行认证失败事件。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AuthFailureEvent {
/// Unix 毫秒。
pub ts_ms: u64,
/// 触发入口(`device_code_login` / `refresh` / `validate`)。
pub scope: String,
/// 链上的步骤(`AuthError::Response` 才有)。
pub step: Option<String>,
/// 粗分流码([`AuthError::code`])。
pub code: String,
/// 细分码([`AuthError::detail_code`])。
pub detail_code: String,
/// HTTP 状态码(链上拒绝才有)。
pub status: Option<u16>,
/// `Content-Type`(链上拒绝才有)。
pub content_type: Option<String>,
/// 正文形状(链上拒绝才有)。
pub body_kind: Option<BodyKind>,
/// 服务端文案(已脱敏)。
pub server_message: Option<String>,
/// 正文摘要(已脱敏,≤200 字符)。
pub digest: Option<String>,
/// 是否可重试([`AuthError::is_transient`])。
pub transient: bool,
/// 人读一句话(已脱敏,不含凭据)。
pub message: String,
}
impl AuthFailureEvent {
/// 从一条错误构造(`scope` 说明是哪个入口失败的)。
#[must_use]
pub fn of(scope: &str, error: &AuthError) -> Self {
let detail = match error {
AuthError::Response(detail) => Some(detail.as_ref()),
_ => None,
};
let scrub = |text: &str| Redactor::new().scrub(text).into_owned();
Self {
ts_ms: crate::auth::microsoft::now_ms(),
scope: scope.to_string(),
step: detail.map(|detail| detail.step.clone()),
code: error.code().to_string(),
detail_code: error.detail_code(),
status: detail.map(|detail| detail.status),
content_type: detail.and_then(|detail| detail.content_type.clone()),
body_kind: detail.map(|detail| detail.kind),
server_message: detail
.and_then(|detail| detail.server_message.as_deref())
.map(scrub),
digest: detail.map(|detail| scrub(&detail.digest)),
transient: error.is_transient(),
message: scrub(&error.message()),
}
}
/// JSON 形状(字段名稳定,给验收/排障工具读)。
#[must_use]
pub fn to_json(&self) -> Value {
json!({
"ts_ms": self.ts_ms,
"level": "error",
"event": "auth_failure",
"scope": self.scope,
"step": self.step,
"code": self.code,
"detail_code": self.detail_code,
"status": self.status,
"content_type": self.content_type,
"body_kind": self.body_kind.map(BodyKind::as_str),
"server_message": self.server_message,
"digest": self.digest,
"transient": self.transient,
"message": self.message,
})
}
}
/// 认证失败日志(JSONL,一行一条)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AuthJournal {
path: PathBuf,
}
impl AuthJournal {
/// 落在某个 `logs/` 分区下(`<logs>/auth.jsonl`)。
#[must_use]
pub fn in_logs_dir(logs: impl Into<PathBuf>) -> Self {
Self {
path: logs.into().join(AUTH_LOG_FILE),
}
}
/// 直接指定文件路径。
#[must_use]
pub fn at(path: impl Into<PathBuf>) -> Self {
Self { path: path.into() }
}
/// 便携模式默认位置(exe 同目录 `.ULL/logs/auth.jsonl`,跟随 `redirect.json`)。
///
/// 拿不到 exe 目录(极少数嵌入场景)时返回 `None`——没有可写的日志根就不写。
#[must_use]
pub fn portable_default() -> Option<Self> {
let exe_dir = std::env::current_exe()
.ok()
.and_then(|path| path.parent().map(Path::to_path_buf))?;
let resolver = Resolver::new(Platform::current(), Some(exe_dir));
let resolved = resolver.resolve_default(&DataDirRequest::default()).ok()?;
Some(Self::in_logs_dir(
LauncherLayout::new(resolved.launcher_home).logs(),
))
}
/// 日志文件路径。
#[must_use]
pub fn path(&self) -> &Path {
&self.path
}
/// 追加一条事件(父目录不存在时先建)。
///
/// # Errors
///
/// 建目录或追加失败;调用方按「尽力而为」处理即可。
pub fn record(&self, event: &AuthFailureEvent) -> std::io::Result<()> {
if let Some(parent) = self.path.parent() {
std::fs::create_dir_all(parent)?;
}
let mut line = serde_json::to_string(&event.to_json()).unwrap_or_default();
line.push('\n');
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&self.path)?;
file.write_all(line.as_bytes())?;
file.flush()
}
}
#[cfg(test)]
mod tests {
use super::{AuthFailureEvent, AuthJournal};
use crate::auth::body::{BodyKind, ResponseDetail};
use crate::auth::error::AuthError;
fn temp_dir(tag: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!(
"ull-auth-journal-{tag}-{}-{:?}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|since| since.as_nanos())
.unwrap_or(0)
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("建临时目录");
dir
}
fn blocked_error() -> AuthError {
AuthError::Response(Box::new(ResponseDetail {
step: "login_with_xbox".to_string(),
what: "Minecraft 登录被拒".to_string(),
status: 403,
content_type: Some("text/html".to_string()),
kind: BodyKind::Html,
server_message: None,
digest: "<html>blocked</html>".to_string(),
}))
}
#[test]
fn event_carries_structured_diagnostics() {
let event = AuthFailureEvent::of("device_code_login", &blocked_error());
assert_eq!(event.scope, "device_code_login");
assert_eq!(event.step.as_deref(), Some("login_with_xbox"));
assert_eq!(event.code, "denied");
assert_eq!(event.detail_code, "login_with_xbox_non_json");
assert_eq!(event.status, Some(403));
assert_eq!(event.content_type.as_deref(), Some("text/html"));
assert_eq!(event.body_kind, Some(BodyKind::Html));
assert!(event.transient);
let value = event.to_json();
assert_eq!(value["event"], "auth_failure");
assert_eq!(value["level"], "error");
assert_eq!(value["body_kind"], "html");
assert!(value["message"]
.as_str()
.is_some_and(|text| !text.is_empty()));
}
#[test]
fn non_chain_errors_still_record() {
let event = AuthFailureEvent::of("refresh", &AuthError::Canceled);
assert_eq!(event.detail_code, "canceled");
assert_eq!(event.step, None);
assert_eq!(event.status, None);
assert!(!event.transient);
}
#[test]
fn record_initializes_the_logs_dir_and_appends_one_line() {
let root = temp_dir("init");
let logs = root.join("logs");
let journal = AuthJournal::in_logs_dir(&logs);
assert!(!logs.exists(), "初始不该存在(模拟未初始化的数据根)");
journal
.record(&AuthFailureEvent::of("device_code_login", &blocked_error()))
.expect("写日志");
journal
.record(&AuthFailureEvent::of("refresh", &AuthError::Canceled))
.expect("写日志");
let text = std::fs::read_to_string(journal.path()).expect("读日志");
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines.len(), 2, "一行一条:{text}");
for line in lines {
let value: serde_json::Value = serde_json::from_str(line).expect("每行都是合法 JSON");
assert_eq!(value["event"], "auth_failure");
}
// 日志里不该出现任何凭据字段名。
for forbidden in ["access_token", "refresh_token", "password", "token"] {
assert!(!text.contains(forbidden), "日志出现了 {forbidden}:{text}");
}
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn journal_paths_are_layout_based() {
let journal = AuthJournal::in_logs_dir("/tmp/ull-logs");
assert!(journal.path().ends_with("auth.jsonl"));
let explicit = AuthJournal::at("/tmp/x/custom.jsonl");
assert_eq!(explicit.path(), std::path::Path::new("/tmp/x/custom.jsonl"));
}
}
+284
View File
@@ -0,0 +1,284 @@
//! 授权码流(`research/08` §3 方案 A):授权 URL 构造 + code 换 token + 整链编排。
//!
//! 与设备码流的分工:**只多一跳**——拿 MS access_token 的方式不同,往后的
//! XBL → XSTS → login_with_xbox → entitlements → profile 与刷新链完全复用
//! ([`super::chain::complete_chain`]),所以两条流的凭据存储与刷新语义天然一致。
//!
//! # 三个硬性细节(都是「抄错就失败」的那一类)
//!
//! 1. **租户 `consumers`**:授权页与 token 端点都走 `login.microsoftonline.com/consumers/…`,
//! 换成 `common`/AAD 租户 id 时 `XboxLive.signin` 直接被拒(`research/08` §2 约束 1)。
//! 2. **重定向 URI 严格一致**:`http://localhost:{port}/auth` —— 授权 URL 里的、
//! token 请求体里的、本地服务器实际监听的必须是**同一个字符串**(含端口与路径),
//! 所以本模块只从 [`RedirectUri`] 取这一个来源。
//! 3. **`prompt=select_account`**:不带这个参数,用户会被静默塞进上一次的账号(坑 6)。
//!
//! scope 与设备码流共用 [`super::flow::SCOPE`](`XboxLive.signin offline_access`)。
use std::fmt;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use reqwest::Client;
use crate::auth::error::AuthResult;
use crate::auth::http::post_form;
use crate::auth::secret::Secret;
use super::callback::{CallbackServer, REDIRECT_PATH};
use super::chain::complete_chain;
use super::endpoints::MicrosoftEndpoints;
use super::flow::{oauth_error, tokens_from, OAuthTokens, SCOPE};
use super::now_ms;
use super::session::MicrosoftSession;
/// 授权码 grant 类型。
pub const AUTHORIZATION_CODE_GRANT: &str = "authorization_code";
/// 默认等回调的时间预算(用户要开浏览器、登录、点同意)。
pub const DEFAULT_CALLBACK_TIMEOUT: Duration = Duration::from_secs(300);
/// 默认的端口绑定尝试次数。
pub const DEFAULT_BIND_ATTEMPTS: u8 = 3;
/// 回填到授权 URL 与 token 请求里的重定向 URI(**唯一来源**)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RedirectUri {
port: u16,
url: String,
}
impl RedirectUri {
/// 按实际绑定的端口构造 `http://localhost:{port}/auth`。
///
/// 用 `localhost` 而不是 `127.0.0.1`:微软对两者处理不一致(`research/08` 坑 5)。
#[must_use]
pub fn new(port: u16) -> Self {
Self {
port,
url: format!("http://localhost:{port}{REDIRECT_PATH}"),
}
}
/// 端口。
#[must_use]
pub fn port(&self) -> u16 {
self.port
}
/// URI 字面量(授权 URL 与 token 请求共用,保证逐字节一致)。
#[must_use]
pub fn as_str(&self) -> &str {
&self.url
}
}
impl fmt::Display for RedirectUri {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.url)
}
}
/// 授权码流的运行参数(生产用 [`AuthCodePolicy::default`],测试压短超时/固定端口)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AuthCodePolicy {
/// 等浏览器回调的上限。
pub callback_timeout: Duration,
/// 端口绑定尝试次数(`>= 1`)。
pub bind_attempts: u8,
/// 固定端口(`None` = 由内核分配,生产路径)。
pub port: Option<u16>,
/// CSRF 用的 `state`(`None` = 每次随机生成)。
pub state: Option<String>,
}
impl Default for AuthCodePolicy {
fn default() -> Self {
Self {
callback_timeout: DEFAULT_CALLBACK_TIMEOUT,
bind_attempts: DEFAULT_BIND_ATTEMPTS,
port: None,
state: None,
}
}
}
/// 要宿主做的事:打开浏览器(core 不碰 UI:谁来开由 CLI/GUI 决定)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AuthCodePrompt {
/// 授权页 URL(宿主交给系统默认浏览器;也可展示给用户手动打开)。
pub authorization_url: String,
/// 本地回调地址(展示/排障用;与授权 URL 里的 `redirect_uri` 同源)。
pub redirect_uri: String,
/// 最多等多久。
pub timeout: Duration,
}
/// 拼授权 URL(`response_type=code` + `prompt=select_account` + 严格编码的重定向 URI)。
#[must_use]
pub fn authorization_url(
endpoints: &MicrosoftEndpoints,
client_id: &str,
redirect_uri: &RedirectUri,
state: &str,
) -> String {
format!(
"{}?client_id={}&response_type=code&redirect_uri={}&scope={}&prompt=select_account&state={}",
endpoints.authorize,
encode(client_id),
encode(redirect_uri.as_str()),
encode(SCOPE),
encode(state),
)
}
/// 授权码换 token(`grant_type=authorization_code`)。
///
/// # Errors
///
/// OAuth 拒绝([`crate::auth::AuthError::Denied`] /
/// [`crate::auth::AuthError::CredentialExpired`])、网络失败、响应缺 token。
pub(crate) async fn exchange_code(
client: &Client,
endpoints: &MicrosoftEndpoints,
client_id: &str,
code: &Secret,
redirect_uri: &RedirectUri,
) -> AuthResult<OAuthTokens> {
let reply = post_form(
client,
&endpoints.token,
vec![
("client_id", client_id.to_string()),
("grant_type", AUTHORIZATION_CODE_GRANT.to_string()),
("code", code.expose().to_string()),
("redirect_uri", redirect_uri.as_str().to_string()),
("scope", SCOPE.to_string()),
],
)
.await?;
if reply.text("error").is_some() || !reply.is_success() {
return Err(oauth_error(&reply, &endpoints.token));
}
tokens_from(&reply, &endpoints.token)
}
/// 授权码登录主体:绑端口 → 出授权 URL(交给 `present`)→ 等回调 → 换 token → 整链。
///
/// `present` 在**端口绑定之后、等待之前**被调用:宿主此时才知道端口,也只有这时
/// 打开浏览器才不会让用户对着还没起来的服务端登录。
pub(crate) async fn login<F>(
client: &Client,
endpoints: &MicrosoftEndpoints,
client_id: &str,
policy: AuthCodePolicy,
present: F,
) -> AuthResult<MicrosoftSession>
where
F: FnOnce(&AuthCodePrompt),
{
let state = policy.state.clone().unwrap_or_else(nonce);
let server = CallbackServer::bind(policy.port, policy.bind_attempts, state.clone()).await?;
let redirect_uri = RedirectUri::new(server.port());
present(&AuthCodePrompt {
authorization_url: authorization_url(endpoints, client_id, &redirect_uri, &state),
redirect_uri: redirect_uri.as_str().to_string(),
timeout: policy.callback_timeout,
});
let code = server.wait_for_code(policy.callback_timeout).await?;
let tokens = exchange_code(
client,
endpoints,
client_id,
&Secret::new(code),
&redirect_uri,
)
.await?;
complete_chain(client, endpoints, tokens, now_ms()).await
}
/// 每次登录一个 `state`(不用于加密,只用来把迟到的旧回调挡掉)。
///
/// 128 位(两个独立随机种子的 SipHash 输出拼接):RFC 6749 §10.10 建议 `state` 至少
/// 128 位熵,挡住「猜到 state 就能伪造回调」这类攻击面。
fn nonce() -> String {
use std::collections::hash_map::RandomState;
use std::hash::{BuildHasher, Hasher};
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|since| since.as_nanos())
.unwrap_or(0);
let mut halves = [String::new(), String::new()];
for half in &mut halves {
// 每次 `RandomState::new()` 都是独立随机种子 → 两段各 64 位、彼此不可推。
let mut hasher = RandomState::new().build_hasher();
hasher.write_u128(nanos);
hasher.write_u32(std::process::id());
*half = format!("{:016x}", hasher.finish());
}
format!("{}{}", halves[0], halves[1])
}
/// 百分号编码(RFC 3986 unreserved 之外一律转义;空格用 `%20` 而不是 `+`)。
fn encode(raw: &str) -> String {
let mut out = String::with_capacity(raw.len());
for byte in raw.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
out.push(char::from(byte));
}
_ => out.push_str(&format!("%{byte:02X}")),
}
}
out
}
#[cfg(test)]
mod tests {
use super::{authorization_url, encode, RedirectUri, AUTHORIZATION_CODE_GRANT};
use crate::auth::microsoft::MicrosoftEndpoints;
#[test]
fn redirect_uri_is_localhost_with_exact_port_and_path() {
let uri = RedirectUri::new(51_234);
assert_eq!(uri.as_str(), "http://localhost:51234/auth");
assert_eq!(uri.port(), 51_234);
assert_eq!(uri.to_string(), uri.as_str());
}
#[test]
fn authorization_url_matches_research_08_vector() {
let endpoints = MicrosoftEndpoints::consumers();
let uri = RedirectUri::new(8_080);
let url = authorization_url(&endpoints, "0000-client", &uri, "state-123");
assert_eq!(
url,
"https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize\
?client_id=0000-client&response_type=code\
&redirect_uri=http%3A%2F%2Flocalhost%3A8080%2Fauth\
&scope=XboxLive.signin%20offline_access&prompt=select_account&state=state-123"
);
// 三个硬性细节各断言一遍,改坏了立刻知道是哪一条。
assert!(url.contains("/consumers/oauth2/v2.0/authorize"));
assert!(url.contains("prompt=select_account"));
assert!(url.contains("scope=XboxLive.signin%20offline_access"));
}
#[test]
fn encode_escapes_everything_but_unreserved() {
assert_eq!(encode("abc-._~09"), "abc-._~09");
assert_eq!(encode("a b/c:"), "a%20b%2Fc%3A");
}
#[test]
fn grant_type_is_the_standard_one() {
assert_eq!(AUTHORIZATION_CODE_GRANT, "authorization_code");
}
#[test]
fn generated_state_has_128_bits_of_entropy() {
let first = super::nonce();
let second = super::nonce();
assert_eq!(first.len(), 32, "128 位 = 32 个 hex 字符:{first}");
assert!(first.chars().all(|c| c.is_ascii_hexdigit()), "{first}");
assert_ne!(first, second, "每次登录的 state 必须不同");
}
}
+346
View File
@@ -0,0 +1,346 @@
//! 授权码流的本地回调服务器(`research/08` §3 方案 A:HMCL 主流做法)。
//!
//! 只干一件事:等浏览器把 `?code=` 送到 `http://localhost:{port}/auth`,把 code 交出去。
//! 协议面故意压到最小(只认一个 GET 路径、只回一个静态页),这样它能被单测直接驱动。
//!
//! # 四条容易踩的细节
//!
//! 1. **重定向 URI 用 `localhost`,监听绑 `127.0.0.1`**:微软对 `localhost` 与 `127.0.0.1`
//! 处理不一致(坑 5),URI 只能写前者;浏览器把 `localhost` 解析到 `::1` 时会被拒一次
//! 再回落 IPv4,Chrome/Edge 都走这条回退,所以只绑 v4 就够。
//! 2. **端口由内核分配**(绑定 `:0`),端口被占时按 `attempts` 重试;授权 URL 里的
//! `redirect_uri` 必须用**最终绑到的端口**回填,换 token 时再原样带上(URI 严格一致)。
//! 3. **非 `/auth` 的请求(`/favicon.ico`、浏览器预连)回 404 后继续等**:让一个 favicon
//! 把整次登录判成失败是最容易犯的错。
//! 4. **`state` 不匹配的请求一律回 400 后继续等**——包括带 `error=` 的那些:旧标签页的
//! 迟到回调不该打断新的登录,更要紧的是 RFC 6749 §4.1.2.1 / RFC 9207 要求**错误响应同样
//! 要校验 `state`**;否则任何人 `GET /auth?error=access_denied` 就能静默中止别人的登录。
//! 真正的超时由调用方的时间预算兜底。
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use crate::auth::error::{AuthError, AuthResult};
/// 回调路径(重定向 URI 的路径段)。
pub const REDIRECT_PATH: &str = "/auth";
/// 一次回调请求最多读这么多字节(超了就丢弃连接,防被灌内存)。
pub const MAX_REQUEST_HEAD: usize = 8 * 1024;
/// 端口被占用时的重试间隔。
const RETRY_DELAY: Duration = Duration::from_millis(20);
/// 单条连接读完请求头的上限(浏览器/预连不把请求头发完时不能把整个登录卡死)。
const REQUEST_READ_TIMEOUT: Duration = Duration::from_secs(5);
const SUCCESS_PAGE: &str = "<!DOCTYPE html><html lang=\"zh-CN\"><head><meta charset=\"utf-8\">\
<title>登录完成</title></head><body style=\"font-family:system-ui;padding:40px\">\
<h1>登录完成</h1><p>可以关闭此窗口,回到启动器继续。</p></body></html>";
const DENIED_PAGE: &str = "<!DOCTYPE html><html lang=\"zh-CN\"><head><meta charset=\"utf-8\">\
<title>登录未完成</title></head><body style=\"font-family:system-ui;padding:40px\">\
<h1>登录未完成</h1><p>授权没有完成,可以回到启动器重试。</p></body></html>";
const BAD_REQUEST_PAGE: &str = "<!DOCTYPE html><html lang=\"zh-CN\"><head><meta charset=\"utf-8\">\
<title>回调无效</title></head><body style=\"font-family:system-ui;padding:40px\">\
<h1>回调无效</h1><p>这个链接不属于本次登录。</p></body></html>";
const NOT_FOUND_PAGE: &str = "<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"utf-8\">\
<title>Not Found</title></head><body><h1>Not Found</h1></body></html>";
/// 本地回调服务器(一个实例只服务一次登录)。
#[derive(Debug)]
pub struct CallbackServer {
listener: TcpListener,
port: u16,
state: String,
}
impl CallbackServer {
/// 绑定回调端口。
///
/// `port` 为 `None` 时由内核分配(生产路径);给定端口时按 `attempts` 重试
/// (CLI/测试里固定端口用,端口被占会退避再试)。
///
/// # Errors
///
/// 全部尝试都绑定失败时返回 [`AuthError::Network`](文案带端口与重试次数)。
pub async fn bind(
port: Option<u16>,
attempts: u8,
state: impl Into<String>,
) -> AuthResult<Self> {
let wanted = port.unwrap_or(0);
let attempts = attempts.max(1);
let mut last = String::new();
for attempt in 0..attempts {
match TcpListener::bind(("127.0.0.1", wanted)).await {
Ok(listener) => {
let port = listener
.local_addr()
.map_err(|error| AuthError::network("localhost", error.to_string()))?
.port();
return Ok(Self {
listener,
port,
state: state.into(),
});
}
Err(error) => {
last = error.to_string();
if attempt + 1 < attempts {
tokio::time::sleep(RETRY_DELAY).await;
}
}
}
}
let what = if wanted == 0 {
"本地回调端口分配失败".to_string()
} else {
format!("本地回调端口 {wanted} 绑定失败(重试 {attempts} 次)")
};
Err(AuthError::network("localhost", format!("{what}:{last}")))
}
/// 实际端口(回填 `redirect_uri` 用)。
#[must_use]
pub fn port(&self) -> u16 {
self.port
}
/// 本次登录的 `state`(授权 URL 与回调校验共用)。
#[must_use]
pub fn state(&self) -> &str {
&self.state
}
/// 等浏览器把 code 送到 `/auth`,最多等 `timeout`。
///
/// # Errors
///
/// 超时([`AuthError::Timeout`])、用户在授权页点了取消([`AuthError::Canceled`])、
/// 授权页回了别的 `error`([`AuthError::Denied`])、socket 出错([`AuthError::Network`])。
pub async fn wait_for_code(self, timeout: Duration) -> AuthResult<String> {
let deadline = tokio::time::Instant::now() + timeout;
loop {
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
if remaining.is_zero() {
return Err(wait_timeout(timeout));
}
let accepted = tokio::time::timeout(remaining, self.listener.accept()).await;
let (mut stream, _) = match accepted {
Ok(Ok(pair)) => pair,
Ok(Err(error)) => {
return Err(AuthError::network("localhost", error.to_string()));
}
Err(_) => return Err(wait_timeout(timeout)),
};
// 一条连接最多读这么久请求头:慢客户端/半开连接不能把整个登录拖过时间预算。
// 预算按 **accept 之后** 的剩余时间重算(用 accept 前那份会让总时长超发最多 5s)。
let budget = REQUEST_READ_TIMEOUT
.min(deadline.saturating_duration_since(tokio::time::Instant::now()));
if budget.is_zero() {
return Err(wait_timeout(timeout));
}
match tokio::time::timeout(budget, self.handle(&mut stream)).await {
Ok(result) => {
if let Some(code) = result? {
return Ok(code);
}
}
Err(_) => continue,
}
}
}
/// 处理一条连接:`Some(code)` = 登录完成,`None` = 与本次登录无关,继续等。
async fn handle(&self, stream: &mut TcpStream) -> AuthResult<Option<String>> {
let Some(target) = read_request_target(stream).await else {
return Ok(None);
};
let (path, query) = match target.split_once('?') {
Some((path, query)) => (path, query),
None => (target.as_str(), ""),
};
if path != REDIRECT_PATH {
respond(stream, 404, NOT_FOUND_PAGE).await;
return Ok(None);
}
let params = Query::parse(query);
// state 必须先校验,且**先于** error:错误响应同样要认 state(RFC 6749 §4.1.2.1 /
// RFC 9207)。否则任意一次 `GET /auth?error=access_denied`(state 随便给或不给)就能
// 把这台机器上正在进行的登录静默中止 —— 不需要知道随机 state、不需要 code。
if params.get("state") != Some(self.state.as_str()) {
respond(stream, 400, BAD_REQUEST_PAGE).await;
return Ok(None);
}
if let Some(error) = params.get("error") {
respond(stream, 200, DENIED_PAGE).await;
return Err(oauth_error(error, params.get("error_description")));
}
let Some(code) = params.get("code").filter(|code| !code.is_empty()) else {
respond(stream, 400, BAD_REQUEST_PAGE).await;
return Ok(None);
};
respond(stream, 200, SUCCESS_PAGE).await;
Ok(Some(code.to_string()))
}
}
fn wait_timeout(timeout: Duration) -> AuthError {
AuthError::Timeout {
what: format!("等待浏览器回调(上限 {} 秒)", timeout.as_secs()),
}
}
/// 授权页回的 `error` → 错误分流(`access_denied` 归取消,其余归拒绝)。
fn oauth_error(code: &str, description: Option<&str>) -> AuthError {
if matches!(code, "access_denied" | "authorization_declined") {
return AuthError::Canceled;
}
AuthError::Denied {
code: code.to_string(),
description: description.unwrap_or("授权页没有给出原因").to_string(),
}
}
/// 读请求行里的 target(`/auth?code=...`)。非 GET/HEAD、超长头、连接中断都给 `None`。
async fn read_request_target(stream: &mut TcpStream) -> Option<String> {
let mut buffer = Vec::new();
let mut chunk = [0_u8; 1024];
loop {
if let Some(end) = find_headers_end(&buffer) {
let head = String::from_utf8_lossy(&buffer[..end]).into_owned();
let mut parts = head.lines().next()?.split(' ');
let method = parts.next()?;
if !method.eq_ignore_ascii_case("GET") && !method.eq_ignore_ascii_case("HEAD") {
return None;
}
return parts.next().map(str::to_string);
}
if buffer.len() > MAX_REQUEST_HEAD {
return None;
}
match stream.read(&mut chunk).await {
Ok(0) | Err(_) => return None,
Ok(read) => buffer.extend_from_slice(&chunk[..read]),
}
}
}
fn find_headers_end(buffer: &[u8]) -> Option<usize> {
buffer
.windows(4)
.position(|window| window == b"\r\n\r\n")
.map(|position| position + 4)
}
async fn respond(stream: &mut TcpStream, status: u16, page: &str) {
let response = format!(
"HTTP/1.1 {status} {}\r\nContent-Type: text/html; charset=utf-8\r\n\
Content-Length: {}\r\nConnection: close\r\n\r\n{page}",
reason(status),
page.len()
);
let _ = stream.write_all(response.as_bytes()).await;
let _ = stream.flush().await;
}
fn reason(status: u16) -> &'static str {
match status {
200 => "OK",
400 => "Bad Request",
404 => "Not Found",
_ => "Status",
}
}
/// 回调 query 的最小解析(`k=v` 对,percent 解码)。
struct Query(Vec<(String, String)>);
impl Query {
fn parse(raw: &str) -> Self {
Self(
raw.split('&')
.filter(|pair| !pair.is_empty())
.filter_map(|pair| {
let (key, value) = pair.split_once('=')?;
Some((decode(key), decode(value)))
})
.collect(),
)
}
fn get(&self, key: &str) -> Option<&str> {
self.0
.iter()
.find(|(name, _)| name == key)
.map(|(_, value)| value.as_str())
}
}
/// `percent` 解码(`+` 视为空格;坏转义原样保留)。
fn decode(raw: &str) -> String {
let bytes = raw.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut index = 0;
while index < bytes.len() {
match bytes[index] {
b'%' if index + 2 < bytes.len() => {
let parsed = std::str::from_utf8(&bytes[index + 1..index + 3])
.ok()
.and_then(|hex| u8::from_str_radix(hex, 16).ok());
match parsed {
Some(byte) => {
out.push(byte);
index += 3;
}
None => {
out.push(b'%');
index += 1;
}
}
}
b'+' => {
out.push(b' ');
index += 1;
}
byte => {
out.push(byte);
index += 1;
}
}
}
String::from_utf8_lossy(&out).into_owned()
}
#[cfg(test)]
mod tests {
use super::{decode, oauth_error, Query};
use crate::auth::error::AuthError;
#[test]
fn query_decodes_percent_and_plus() {
let query = Query::parse("code=ab%2Fcd&state=s+1&empty=");
assert_eq!(query.get("code"), Some("ab/cd"));
assert_eq!(query.get("state"), Some("s 1"));
assert_eq!(query.get("empty"), Some(""));
assert_eq!(query.get("missing"), None);
}
#[test]
fn decode_keeps_broken_escapes() {
assert_eq!(decode("100%"), "100%");
assert_eq!(decode("%zz"), "%zz");
assert_eq!(decode("a%20b"), "a b");
}
#[test]
fn access_denied_maps_to_canceled() {
assert_eq!(oauth_error("access_denied", None), AuthError::Canceled);
match oauth_error("invalid_request", Some("bad scope")) {
AuthError::Denied { code, description } => {
assert_eq!(code, "invalid_request");
assert_eq!(description, "bad scope");
}
other => panic!("应是 Denied,实际 {other:?}"),
}
}
}
+377
View File
@@ -0,0 +1,377 @@
//! 微软五步链:Live token → **XBL** → **XSTS** → **login_with_xbox** → **entitlements** → **profile**。
//!
//! 逐条承 `analyse/04` §5.2(报文级别),四处容易抄错的地方都在注释里点名:
//! 1. XBL 的 `RpsTicket` 必须带 **`d=` 前缀**(MSA 消费版票据标记);
//! 2. `identityToken` 是 **`XBL3.0 x=<uhs>;<xsts>`**(分号分隔,`x` 后紧跟 `=`);
//! 3. XSTS 在「无 Xbox 档案/儿童账号/地区不支持」时回 **401**(不是 200+JSON),
//! 必须读 body 里的 `XErr` 才能给出准确原因;
//! 4. `/minecraft/profile` 的 **404 二义**:没买游戏 vs 买了没建档案,
//! 要用 `entitlements/license` 复判一次才分得清。
//!
//! **XSTS/MC 令牌不缓存**:刷新就是整链重跑(`analyse/04` §5.1),所以本模块只做无状态串联。
use reqwest::Client;
use serde_json::json;
use crate::auth::body;
use crate::auth::error::AuthError;
use crate::auth::secret::{redactor_for, Secret};
use super::endpoints::MicrosoftEndpoints;
use super::flow::OAuthTokens;
use super::session::MicrosoftSession;
use crate::auth::http::{get_json, post_json, Reply};
/// Xbox Live 用户票据 + 用户哈希。
#[derive(Debug, Clone)]
pub(crate) struct XboxTicket {
/// JWT 票据(凭据形状)。
pub token: Secret,
/// `uhs`(`XBL3.0 x=<uhs>;...` 的 userhash;本身不是凭据但只在本链内部用)。
pub uhs: String,
}
/// MC 侧换到的令牌。
#[derive(Debug, Clone)]
pub(crate) struct MinecraftToken {
/// MC access token(落盘)。
pub access_token: Secret,
/// 有效期(秒)。
pub expires_in: u64,
}
/// Minecraft 档案(不可变 id + 可变 name)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct GameProfile {
/// 32 位无连字符 UUID。
pub id: String,
/// 玩家名。
pub name: String,
}
/// 第 1 步:Xbox Live User Authenticate。
pub(crate) async fn xbl_authenticate(
client: &Client,
endpoints: &MicrosoftEndpoints,
live_access_token: &Secret,
) -> Result<XboxTicket, AuthError> {
let body = json!({
"Properties": {
"AuthMethod": "RPS",
"SiteName": "user.auth.xboxlive.com",
"RpsTicket": format!("d={}", live_access_token.expose()),
},
"RelyingParty": "http://auth.xboxlive.com",
"TokenType": "JWT",
});
let reply = post_json(client, &endpoints.xbl_authenticate, &body, None).await?;
if let Some(code) = xerr(&reply) {
return Err(AuthError::Xbox { code });
}
if !reply.is_success() {
// M38:XErr 优先(业务码才是真原因),没有 XErr 时把正文诊断补进文案。
return Err(body::response(
"xbl",
&reply,
"Xbox Live 用户认证被拒",
&redactor_for([live_access_token]),
));
}
let token = reply
.text("Token")
.ok_or_else(|| AuthError::Malformed("XBL 响应缺 Token".to_string()))?;
let uhs = uhs_of(&reply)
.ok_or_else(|| AuthError::Malformed("XBL 响应缺 DisplayClaims.xui[0].uhs".to_string()))?;
Ok(XboxTicket {
token: Secret::new(token),
uhs,
})
}
/// 第 2 步:XSTS Authorize(401 也读 body,`XErr` 才是真原因)。
pub(crate) async fn xsts_authorize(
client: &Client,
endpoints: &MicrosoftEndpoints,
xbl: &XboxTicket,
) -> Result<XboxTicket, AuthError> {
let body = json!({
"Properties": { "SandboxId": "RETAIL", "UserTokens": [xbl.token.expose()] },
"RelyingParty": "rp://api.minecraftservices.com/",
"TokenType": "JWT",
});
let reply = post_json(client, &endpoints.xsts_authorize, &body, None).await?;
if let Some(code) = xerr(&reply) {
return Err(AuthError::Xbox { code });
}
if !reply.is_success() {
return Err(body::response(
"xsts",
&reply,
"XSTS 授权被拒",
&redactor_for([&xbl.token]),
));
}
let token = reply
.text("Token")
.ok_or_else(|| AuthError::Malformed("XSTS 响应缺 Token".to_string()))?;
let uhs = uhs_of(&reply).ok_or_else(|| AuthError::Malformed("XSTS 响应缺 uhs".to_string()))?;
// 一致性断言:同一账号在 XBL 与 XSTS 两次返回的 userhash 必须相同。
if uhs != xbl.uhs {
return Err(AuthError::Malformed(
"XBL 与 XSTS 返回的 userhash 不一致".to_string(),
));
}
Ok(XboxTicket {
token: Secret::new(token),
uhs,
})
}
/// 第 3 步:`login_with_xbox` 换 Minecraft 令牌。
pub(crate) async fn login_with_xbox(
client: &Client,
endpoints: &MicrosoftEndpoints,
xsts: &XboxTicket,
) -> Result<MinecraftToken, AuthError> {
let body = json!({
"identityToken": format!("XBL3.0 x={};{}", xsts.uhs, xsts.token.expose()),
});
let reply = post_json(client, &endpoints.login_with_xbox, &body, None).await?;
if !reply.is_success() {
// M38 的核心一行:403 到底是「拦截页」还是「服务端拒绝」,文案要分开引导。
return Err(body::response(
"login_with_xbox",
&reply,
"Minecraft 登录被拒",
&redactor_for([&xsts.token]),
));
}
let access_token = reply
.text("access_token")
.ok_or_else(|| AuthError::Malformed("login_with_xbox 缺 access_token".to_string()))?;
Ok(MinecraftToken {
access_token: Secret::new(access_token),
expires_in: reply.number("expires_in").unwrap_or(0),
})
}
/// 第 4 步:拥有权校验(必须 200,否则后面启动时也会被服务端踢)。
pub(crate) async fn check_entitlements(
client: &Client,
endpoints: &MicrosoftEndpoints,
mc_token: &Secret,
) -> Result<(), AuthError> {
let reply = get_json(client, &endpoints.entitlements_mcstore, mc_token).await?;
if reply.is_success() {
return Ok(());
}
let detail = body::rejection(
"entitlements",
&reply,
"拥有权校验未通过",
&redactor_for([mc_token]),
);
// 拦截页(HTML/纯文本)与非 404 的空体先当网络问题报,别让用户去查许可;
// JSON 拒绝与 404 沿用 M31 的 `no_entitlement`(稳定码不改)。
let interception = detail.kind.is_interception()
|| (detail.kind == body::BodyKind::Empty && reply.status != 404);
if interception {
return Err(AuthError::Response(Box::new(detail)));
}
let what = if reply.status == 404 {
"该账号没有 Minecraft Java 版拥有权"
} else {
detail.what.as_str()
};
Err(AuthError::Denied {
code: "no_entitlement".to_string(),
description: format!(
"{what}(entitlements/mcstore 返回 HTTP {}){}",
reply.status,
detail.note()
),
})
}
/// 第 5 步:拉档案(404 用 license 复判二义)。
pub(crate) async fn fetch_profile(
client: &Client,
endpoints: &MicrosoftEndpoints,
mc_token: &Secret,
) -> Result<GameProfile, AuthError> {
let reply = get_json(client, &endpoints.profile, mc_token).await?;
// 只有「像服务端应答」的 404 才走二义复判:HTML/纯文本的 404 是网关页,
// 拿它去复判 license 会得出错误的「没买游戏」结论。
if reply.status == 404 && !body::classify(&reply).is_interception() {
return Err(profile_missing(client, endpoints, mc_token).await);
}
if !reply.is_success() {
return Err(body::response(
"profile",
&reply,
"拉取 Minecraft 档案被拒",
&redactor_for([mc_token]),
));
}
let id = reply
.text("id")
.ok_or_else(|| AuthError::Malformed("档案响应缺 id".to_string()))?;
let name = reply
.text("name")
.ok_or_else(|| AuthError::Malformed("档案响应缺 name".to_string()))?;
Ok(GameProfile {
id: id.to_string(),
name: name.to_string(),
})
}
/// 404 之后的二义消解:没买游戏 vs 买了没建档案。
async fn profile_missing(
client: &Client,
endpoints: &MicrosoftEndpoints,
mc_token: &Secret,
) -> AuthError {
let reply = match get_json(client, &endpoints.entitlements_license, mc_token).await {
Ok(reply) => reply,
// 复判本身失败时,退回「档案不存在」这个更保守的说法。
Err(_) => {
return AuthError::Denied {
code: "no_profile".to_string(),
description: "该账号没有 Minecraft 档案(复判 license 也失败)".to_string(),
}
}
};
if has_minecraft_license(&reply) {
AuthError::Denied {
code: "no_profile".to_string(),
description: "账号已拥有 Minecraft,但还没有创建档案(去 minecraft.net 起个名字)"
.to_string(),
}
} else {
AuthError::Denied {
code: "no_license".to_string(),
description: "该账号没有购买 Minecraft Java 版".to_string(),
}
}
}
/// `entitlements/license` 响应里是否有 `game_minecraft`。
fn has_minecraft_license(reply: &Reply) -> bool {
reply
.body
.as_ref()
.and_then(|body| body.get("items"))
.and_then(|items| items.as_array())
.is_some_and(|items| {
items.iter().any(|item| {
item.get("name")
.and_then(|name| name.as_str())
.is_some_and(|name| name == "game_minecraft")
})
})
}
/// 整链:Live token → [`MicrosoftSession`](刷新与新登录共用这一段)。
pub(crate) async fn complete_chain(
client: &Client,
endpoints: &MicrosoftEndpoints,
tokens: OAuthTokens,
now_ms: u64,
) -> Result<MicrosoftSession, AuthError> {
let xbl = xbl_authenticate(client, endpoints, &tokens.access_token).await?;
let xsts = xsts_authorize(client, endpoints, &xbl).await?;
let mc = login_with_xbox(client, endpoints, &xsts).await?;
check_entitlements(client, endpoints, &mc.access_token).await?;
let profile = fetch_profile(client, endpoints, &mc.access_token).await?;
Ok(MicrosoftSession::new(
profile.id,
profile.name,
mc.access_token,
tokens.refresh_token,
mc.expires_in,
now_ms,
None,
))
}
/// 从响应里取 `XErr`(XSTS 的 401 业务码)。
fn xerr(reply: &Reply) -> Option<u32> {
let body = reply.body.as_ref()?;
for key in ["XErr", "errorCode"] {
if let Some(code) = body.get(key).and_then(|value| value.as_u64()) {
if code != 0 {
return Some(code as u32);
}
}
}
None
}
fn uhs_of(reply: &Reply) -> Option<String> {
reply
.body
.as_ref()?
.get("DisplayClaims")?
.get("xui")?
.as_array()?
.first()?
.get("uhs")?
.as_str()
.map(str::to_string)
}
#[cfg(test)]
mod tests {
use super::{has_minecraft_license, uhs_of, xerr, Reply};
use serde_json::{json, Value};
fn reply(status: u16, body: Option<Value>) -> Reply {
Reply {
status,
body,
content_type: Some("application/json".to_string()),
raw: None,
}
}
#[test]
fn xerr_is_read_from_401_body() {
let sample = reply(
401,
Some(json!({ "Identity": "0", "XErr": 2_148_916_238_u32, "Message": "" })),
);
assert_eq!(xerr(&sample), Some(2_148_916_238));
assert_eq!(xerr(&reply(200, Some(json!({ "XErr": 0 })))), None);
assert_eq!(xerr(&reply(200, None)), None);
}
#[test]
fn uhs_comes_from_first_xui_entry() {
let sample = reply(
200,
Some(json!({ "DisplayClaims": { "xui": [{ "uhs": "1234" }] } })),
);
assert_eq!(uhs_of(&sample).as_deref(), Some("1234"));
assert_eq!(
uhs_of(&reply(200, Some(json!({ "DisplayClaims": {} })))),
None
);
}
#[test]
fn license_detection() {
let with = reply(
200,
Some(
json!({ "items": [{ "name": "product_minecraft" }, { "name": "game_minecraft" }] }),
),
);
let without = reply(
200,
Some(json!({ "items": [{ "name": "product_minecraft" }] })),
);
assert!(has_minecraft_license(&with));
assert!(!has_minecraft_license(&without));
assert!(!has_minecraft_license(&reply(500, None)));
}
}
Loaded 100 of 1128 files, more files were not shown because too many files have changed in this diff. Show more