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
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:
commit
1105a86448
1128 files changed
+217164
No files matched your search
@@ -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
|
||||
@@ -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};
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}"))
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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 = ¬es[0].message;
|
||||
for expected in [
|
||||
"overrides/config/dup.toml",
|
||||
"client-overrides/config/dup.toml",
|
||||
"内容不同",
|
||||
] {
|
||||
assert!(message.contains(expected), "缺 {expected}:{message}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identical_bytes_are_reported_as_same_bytes() {
|
||||
let mut overrides = vec![
|
||||
entry(
|
||||
"overrides",
|
||||
"config/same.toml",
|
||||
FileSource::Overrides,
|
||||
"aaaa",
|
||||
),
|
||||
entry(
|
||||
"client-overrides",
|
||||
"config/same.toml",
|
||||
FileSource::ClientOverrides,
|
||||
"aaaa",
|
||||
),
|
||||
];
|
||||
let mut notes = Vec::new();
|
||||
resolve(&mut overrides, &mut notes);
|
||||
|
||||
assert!(overrides[0].shadowed.as_ref().expect("标记").same_bytes);
|
||||
assert!(
|
||||
notes[0].message.contains("逐字节相同"),
|
||||
"内容一致要说清楚,别让人以为丢了东西:{}",
|
||||
notes[0].message
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_paths_with_the_same_bytes_are_never_shadowed() {
|
||||
// 同哈希不同路径 = 两份文件,不是重复声明。按哈希去重会**静默丢一个文件**。
|
||||
let mut overrides = vec![
|
||||
entry("overrides", "mods/a.jar", FileSource::Overrides, "aaaa"),
|
||||
entry("overrides", "mods/b.jar", FileSource::Overrides, "aaaa"),
|
||||
];
|
||||
let mut notes = Vec::new();
|
||||
resolve(&mut overrides, &mut notes);
|
||||
|
||||
assert!(overrides.iter().all(|entry| entry.shadowed.is_none()));
|
||||
assert!(notes.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_same_path_twice_inside_one_group_is_shadowed_too() {
|
||||
// 同侧重复(模型被合并 / 手造时才会出现):照样只落地一次,且注记两份来自同一目录。
|
||||
let mut overrides = vec![
|
||||
entry("overrides", "mods/x.jar", FileSource::Overrides, "aaaa"),
|
||||
entry("overrides", "mods/x.jar", FileSource::Overrides, "bbbb"),
|
||||
];
|
||||
let mut notes = Vec::new();
|
||||
resolve(&mut overrides, &mut notes);
|
||||
|
||||
assert_eq!(
|
||||
overrides[0].shadowed.as_ref().expect("标记").winner,
|
||||
FileSource::Overrides
|
||||
);
|
||||
assert_eq!(notes.len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
));
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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/"}
|
||||
]
|
||||
}"#;
|
||||
@@ -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` 认可的那几家"
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
))
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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())
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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 定案)"
|
||||
);
|
||||
}
|
||||
@@ -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"})
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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()"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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 必须不同");
|
||||
}
|
||||
}
|
||||
@@ -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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user