release: v0.2.4 里程碑快照(孤儿提交,无父;树 = dev 68362e5)
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
CI / update-e2e (windows x64) (push) Canceled after 0s
GPU evidence / GPU 判据自测(不需 GPU;跳过 ≠ 已验证) (push) Canceled after 0s
GPU evidence / GPU 证据 — 探测可用产物与硬件适配器 (push) Canceled after 0s
GPU evidence / GPU 证据 — 真实产物判据 (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 guard / 发布标记语义守门(push tag → 稳定版) (push) Canceled after 0s
Signing status(播报,不是门禁) / 判据自测(三行/退出码/变异重放/接线) (push) Canceled after 0s
Signing status(播报,不是门禁) / 签名就绪度(播报) (push) Canceled after 0s
Release / 选槽位(默认四个:Windows 两档 + Linux 两档) (push) Canceled after 0s
Release / build (${{ matrix.slot }}) (push) Canceled after 0s
Release / GitHub Release (push) Canceled after 0s

本批内容见 CHANGELOG.md 的 0.2.4 段。按 D41 用孤儿快照推 main,保留「公开史只有里程碑」的模型。
This commit is contained in:
AskaEth committed 2026-11-11 21:41:38 +08:00
commit a5d1dec495
1580 files changed
+320938

No files matched your search

+90
View File
@@ -0,0 +1,90 @@
[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
# M190(core::update::ed25519):清单签名验签(官方实现,见 workspace 依赖处的取舍说明)。
ed25519-dalek.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
[features]
# M169:把「本地更新源」这条测试通道**编进二进制**。**默认关**——官方发布构建不带它,
# `core::update::job::default_source` 里那条分支根本不存在(编译期事实,不靠运行时判据)。
# 验收用法见 `crates/cli/tests/update_e2e_test.rs` 的文件头;`packaging/check_test_source_off.py`
# 守门「发布流水线与打包脚本都不许打开它」。
update-test-source = []
# M190:把**测试公钥**并进信任表(`trust::TEST_ONLY`)。它只加一把**公开**的钥,**不**开任何数据源
# 开关(没有环境变量、没有 `DirSource`)。唯一使用者是 `crates/presenter` 的 `[dev-dependencies]`:
# presenter 的更新链路用例要构造「本构建认得」的签名清单,而它们编译 core 时既没有 `cfg(test)`,
# 也不该去打开 `update-test-source`(那条同时给出「本地目录当更新源」这条路)。
# 守门:`packaging/check_test_key_off.py`(只许出现在这里与 presenter 的 dev-dependencies)。
update-test-key = []
+65
View File
@@ -0,0 +1,65 @@
//! 整合包**导出**(M112):实例目录 → `.mrpack`(优先)/ CurseForge `manifest.json`(次之)。
//!
//! 设计依据:`DevDocs/PlanDoc/08-mod-and-recommend.md` §2(D10)、
//! `DevDocs/SubAgentsTalk/addon-整合包导入导出对接要点.md` §4(导出八条),格式模型与安全闸
//! 是 M110 的 [`crate::addon::pack`],本模块**只消费,不重写**。
//!
//! # 一句话口径:不认识的 mod 不能丢、不能编
//!
//! 用户原话(M112 任务书):「要么能证明它可被远程替代,要么就必须内联进 `overrides/` 并在
//! 结果里逐条报告」。落到代码就是三分类,**没有第四类**:
//!
//! | 分类 | 判据 | 产物 |
//! |------|------|------|
//! | 远程引用([`FileClass::Remote`]) | 目标格式**能表达**它 + 某个目录([`RemoteCatalog`])**能证明**它的身份 | 清单 `files[]` 一条 |
//! | 内联([`FileClass::Inline`]) | 证明不了,或证明了但目标格式表达不了 | 进 `overrides/`,原字节照搬 |
//! | 跳过([`FileClass::Skipped`]) | 读不了 / 是符号链接 / 路径不安全 | 不写进包,**逐条**进报告 |
//!
//! 「能证明」用两条独立查询(契约 §4.4 的顺序):**Modrinth SHA-1** →
//! [`RemoteCatalog::modrinth_by_sha1`];**CurseForge 指纹**([`crate::addon::pack::curseforge::fingerprint`])
//! → [`RemoteCatalog::curseforge_by_fingerprint`]。两条都命中时两条直链都进 `downloads[]`
//! (HMCL 同款,兼容面更大)。**没有网络时**用 [`OfflineCatalog`]:所有文件都内联,包照样完整可导入
//! ——这不是降级失败,是"证明不了就内联"的正面行为。
//!
//! # 每条格式决策落在哪(tower 定案,别按任务书原话改回去)
//!
//! 1. **`.disabled` 走路径后缀**(`mods/x.jar.disabled`),不是 `env.client=optional`:后者只是
//! "安装时可取消勾选",装回来默认仍然启用 ⇒ 跨启动器不保真。官方 Modrinth 导出也是后缀。
//! 2. **覆盖目录一律 `overrides/`**(不用 HMCL 那套 `client-overrides/`):Modrinth 官方与
//! Prism 都用它,兼容面最大。分侧语义在本轮导出里不产生(实例目录本身不分侧)。
//! 3. **`game` + `dependencies` 从实例快照取**,绝不只写 `minecraft` 漏加载器:快照来自
//! `versions/<id>/<id>.json` 的继承链 + 库坐标([`snapshot`]),**不解析**
//! `<mc>-<loader>-<ver>` 这类实例 id 字符串(社区实例名不统一,那是猜)。
//! 4. **CF `files[]` 只写能确定 `projectID`/`fileID` 的项**:CF 没有"内联到 files[]"的概念,
//! 证明不了的(以及**禁用文件**:CF 根本没有禁用表达)一律进 `overrides/` 并进报告。
//! 5. **尺寸护栏**:`fileSize` 是 u32(超了报 `pack_limits`,不静默截断)、整包撞 ZIP64 阈值由
//! `format::write` 拒绝(`pack_limits`,不留半个包)。
//!
//! # 刻意不做的事(分工边界,别以为是漏做)
//!
//! - **MultiMC / Prism 导出**:不做(P1 只做导入)。它的包是"整实例快照",要先把所有远程文件
//! 下载下来再打包,成本极高;导入侧 M111 已覆盖。**这是有意省略。**
//! - **不装加载器 / 不补全清单**:加载器元数据解析与库下载是 `addon::loader` + `game::install`;
//! CF 的 `projectID/fileID → 文件名/URL` 补全需要 CF 客户端(D10 §5 规划中),本轮由调用方注入
//! [`RemoteCatalog`],本模块不发网络请求。
//! - **不碰 UI 类型**:core 零 UI 依赖(`core-no-ui` 门禁),报告只出现 `String` / `&'static str`。
//! - **不改实例**:导出只读实例目录。
//!
//! # 没验的部分(报告里同样登记)
//!
//! 真实 Modrinth / CurseForge API 查询(要网络与 key,`RemoteCatalog` 目前只有离线实现)、
//! 超大实例(GB 级、上千文件——指纹要对每个文件多读两遍)、Windows 之外的平台、
//! 以及「M111 合并后的 `import_pack → export → import_pack` 真端到端」。
pub mod catalog;
pub(crate) mod classify;
pub mod pipeline;
pub mod report;
pub mod scan;
pub mod snapshot;
pub use catalog::{OfflineCatalog, RemoteCatalog, RemoteHit, RemoteSource};
pub use pipeline::{export_instance, ExportRequest};
pub use report::{ExportFile, ExportReport, FileClass, FileReason};
pub use scan::{scan_instance, LocalFile, SkippedLocal, DEFAULT_CONTENT_ROOTS, OVERRIDES_DIR};
pub use snapshot::{read_instance_snapshot, InstanceSnapshot, SnapshotLoader};
+147
View File
@@ -0,0 +1,147 @@
//! 「能否被远程替代」的**注入点**:两条查询 + 一个离线默认实现。
//!
//! # 为什么是 trait 而不是直接调 API
//!
//! 与 M111 的 `PackFetcher` 同一条理由:测试不能联网;CF 的查表要 `x-api-key`(D10 §5 的客户端
//! 还没写);而"证明不了"必须能被如实表达——查表失败 ≠ 文件有问题。所以导出管线只认这个 trait,
//! 生产实现将来接 Modrinth `/v2/version_file/{sha1}` 与 CF `/v1/fingerprints/432`。
//!
//! # 两条查询的语义差别(别合并成一个)
//!
//! | 查询 | 键 | 命中能给出什么 | 谁用得上 |
//! |------|-----|----------------|----------|
//! | [`RemoteCatalog::modrinth_by_sha1`] | 文件 SHA-1 | `downloads[]` + `hashes`(含 `sha512`)+ `fileSize` | mrpack(它要求 sha512) |
//! | [`RemoteCatalog::curseforge_by_fingerprint`] | CF 指纹 | `projectID`/`fileID`(+ 可选直链) | CF 清单(它只认这两个 id) |
//!
//! 只命中一条时,另一格式通常用不上它——这正是 [`crate::addon::export::report::FileReason`]
//! 要区分 `CurseOnlyMatch` / `ModrinthOnlyMatch` 的原因:报告要说清"证明了,但目标格式表达不了",
//! 而不是笼统地写"找不到"。
//!
//! # 哨兵值
//!
//! CF 指纹 `811513880` 会误命中(HMCL issue #4597),由**调用方**在建表前短路:
//! 见 `curseforge::fingerprint::is_sentinel`。
use std::collections::BTreeMap;
/// 这条命中来自哪个目录。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RemoteSource {
/// Modrinth(按 SHA-1 查)。
Modrinth,
/// CurseForge(按文件指纹查)。
CurseForge,
}
/// 一次远程命中的结果。
///
/// 字段刻意按"能写进目标清单"组织,而不是按某个 API 的响应形状:
/// mrpack 要 `downloads` + `hashes`,CF 要 `projectID`/`fileID`,两个都给了就是两份清单都能写。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RemoteHit {
/// 来源目录。
pub source: RemoteSource,
/// 直链候选(多源;mrpack 写进 `downloads[]`,CF 清单没有这个字段但也留着做日志)。
pub downloads: Vec<String>,
/// 摘要表(键名小写)。Modrinth 侧必须含 `sha1` 与 `sha512`——后者是 mrpack 的硬要求。
pub hashes: BTreeMap<String, String>,
/// 服务端记录的文件字节数(与本地实际字节数不一致时以本地为准,见 `pipeline`)。
pub size: Option<u64>,
/// CurseForge `projectID`。
pub project_id: Option<i64>,
/// CurseForge `fileID`。
pub file_id: Option<i64>,
}
impl RemoteHit {
/// 一次 Modrinth 命中:直链 + 摘要表(必须有 `sha512`,否则 mrpack 写不出来)。
#[must_use]
pub fn modrinth(
downloads: Vec<String>,
hashes: BTreeMap<String, String>,
size: Option<u64>,
) -> Self {
Self {
source: RemoteSource::Modrinth,
downloads,
hashes,
size,
project_id: None,
file_id: None,
}
}
/// 一次 CurseForge 命中:`projectID`/`fileID` 是 CF 清单的**全部**必需信息。
#[must_use]
pub fn curseforge(project_id: i64, file_id: i64) -> Self {
Self {
source: RemoteSource::CurseForge,
downloads: Vec::new(),
hashes: BTreeMap::new(),
size: None,
project_id: Some(project_id),
file_id: Some(file_id),
}
}
/// 顺带给一条 CF 直链(有就写进 `downloads[]`,没有也不影响 CF 清单)。
#[must_use]
pub fn with_download(mut self, url: impl Into<String>) -> Self {
self.downloads.push(url.into());
self
}
/// `hashes.sha512`。
#[must_use]
pub fn sha512(&self) -> Option<&str> {
self.hashes.get("sha512").map(String::as_str)
}
/// 能不能写进 CF 的 `files[]`。
#[must_use]
pub fn curse_ids(&self) -> Option<(i64, i64)> {
match (self.project_id, self.file_id) {
(Some(project), Some(file)) => Some((project, file)),
_ => None,
}
}
/// 有没有能写进 mrpack 的 `downloads[]` 的非空直链。
#[must_use]
pub fn has_download(&self) -> bool {
self.downloads.iter().any(|url| !url.trim().is_empty())
}
}
/// 「这个文件在远端是谁」的查询接口。
///
/// 两个方法都必须**只做本地判据 + 调用方给的缓存**:实现里如果联网,那就是生产的
/// Modrinth/CF 客户端(D10 §5),本模块不关心它怎么拿到答案。
pub trait RemoteCatalog {
/// 按 SHA-1 查 Modrinth 版本文件;查不到返回 `None`(**不要**用 `None` 表示网络失败以外的
/// 「本地构造不了查询」——那种情况应当返回 `None` 并让文件内联)。
fn modrinth_by_sha1(&self, sha1: &str) -> Option<RemoteHit>;
/// 按 CurseForge 指纹查项目/文件;查不到返回 `None`。
///
/// 调用方已按哨兵值(`811513880`)短路,实现不必重复判断。
fn curseforge_by_fingerprint(&self, fingerprint: u32) -> Option<RemoteHit>;
}
/// 离线:什么都证明不了 ⇒ 所有文件内联。
///
/// 这是**默认**行为,也是本仓库当前的唯一实现:没有 CF 客户端(要 key)、没有 Modrinth 客户端
/// (D10 §5 规划中),联网查询在测试里也不允许。用它的导出结果是"一个完整、可导入、全部走
/// `overrides/` 的包"——不是残废包。
#[derive(Debug, Clone, Copy, Default)]
pub struct OfflineCatalog;
impl RemoteCatalog for OfflineCatalog {
fn modrinth_by_sha1(&self, _sha1: &str) -> Option<RemoteHit> {
None
}
fn curseforge_by_fingerprint(&self, _fingerprint: u32) -> Option<RemoteHit> {
None
}
}
+112
View File
@@ -0,0 +1,112 @@
//! 逐文件判定(导出管线的第 ③ 步):这份本地文件在目标格式里能不能被远程替代。
//!
//! 判定要**两个条件同时成立**才算远程引用:
//!
//! 1. 某个目录([`RemoteCatalog`])能**证明**它的身份——Modrinth 按 SHA-1、CurseForge 按指纹;
//! 2. 目标格式**表达得了**这个证明——mrpack 要 `path` + `sha512` + `downloads`,
//! CF 清单要 `projectID` + `fileID`。
//!
//! 只满足第一条时就是"证明了但用不上",它有自己的原因(`CurseOnlyMatch` /
//! `ModrinthOnlyMatch`),不能和"两个目录都不认识"混成一句话。
//!
//! 分成单独一个文件是为了让 [`super::pipeline`] 只讲流程;这里全是判据,可以逐条对着测
//! (`classify_with` 甚至允许测试直接喂指纹——哨兵值 `811513880` 用真实文件测不出来)。
use super::catalog::{RemoteCatalog, RemoteHit};
use super::report::{ExportFile, FileReason};
use super::scan::LocalFile;
use crate::addon::pack::curseforge::fingerprint;
use crate::addon::pack::manifest::{EnvSupport, PackFile};
/// 判定一条本地文件属于哪一类(外加命中,供组 `files[]` 用)。
pub(super) fn classify(
local: &LocalFile,
catalog: &dyn RemoteCatalog,
target_is_curse: bool,
) -> (FileReason, Option<RemoteHit>) {
// 指纹要读两遍盘;读不出来就按"没有 CF 侧证据"处理(文件本身仍会内联进包)。
let value = fingerprint::fingerprint_file(&local.disk).ok();
classify_with(local, catalog, target_is_curse, value)
}
/// 同 [`classify`],但指纹由调用方给(`None` = 读不出来)。
///
/// `pub(crate)` 只有一个理由:哨兵值 `811513880` 那条分支**没法用真实文件测**——它是误命中,
/// 找不到一个能算出它的样本(要 2^32 次穷举)。测试直接喂这个数,验证它被当成"查不到"。
pub(crate) fn classify_with(
local: &LocalFile,
catalog: &dyn RemoteCatalog,
target_is_curse: bool,
fingerprint: Option<u32>,
) -> (FileReason, Option<RemoteHit>) {
let modrinth = catalog.modrinth_by_sha1(&local.sha1);
let curseforge = match fingerprint {
Some(value) if !fingerprint::is_sentinel(value) => catalog.curseforge_by_fingerprint(value),
Some(_) | None => None,
};
if target_is_curse {
if local.disabled {
// CF 没有禁用表达:`required:false` 是"可选"不是"禁用",硬写就等于把它变成启用。
return (FileReason::DisabledNotExpressible, None);
}
return match curseforge {
Some(hit) if hit.curse_ids().is_some() => {
(FileReason::CurseForgeFingerprint, Some(hit))
}
Some(_) => (FileReason::MissingCurseIds, None),
None if modrinth.is_some() => (FileReason::ModrinthOnlyMatch, None),
None => (FileReason::NoRemoteMatch, None),
};
}
match modrinth {
Some(hit) if hit.sha512().is_some() && hit.has_download() => {
let mut merged = hit;
// 两条都命中:两条直链都写进 downloads[](HMCL 同款,兼容面更大)。
if let Some(curse) = curseforge {
for url in curse.downloads {
if !url.trim().is_empty() && !merged.downloads.iter().any(|seen| seen == &url) {
merged.downloads.push(url);
}
}
merged.project_id = merged.project_id.or(curse.project_id);
merged.file_id = merged.file_id.or(curse.file_id);
}
(FileReason::ModrinthSha1, Some(merged))
}
Some(_) => (FileReason::MissingSha512, None),
None if curseforge.is_some() => (FileReason::CurseOnlyMatch, None),
None => (FileReason::NoRemoteMatch, None),
}
}
/// 远程条目:`path` 用实例内路径(禁用文件已带 `.disabled` 后缀),`size` 用本地实测字节数。
pub(super) fn remote_entry(local: &LocalFile, hit: &RemoteHit) -> PackFile {
let mut hashes = hit.hashes.clone();
hashes.insert("sha1".to_string(), local.sha1.clone());
PackFile {
path: Some(local.path.clone()),
project_id: hit.project_id,
file_id: hit.file_id,
hashes,
downloads: hit.downloads.clone(),
size: Some(local.bytes),
disabled: local.disabled,
client: EnvSupport::Required,
server: EnvSupport::Required,
..PackFile::default()
}
}
/// 报告里的一条。
pub(super) fn entry(local: &LocalFile, reason: FileReason, hit: Option<&RemoteHit>) -> ExportFile {
ExportFile {
path: local.path.clone(),
class: reason.class(),
reason,
bytes: local.bytes,
downloads: hit.map(|hit| hit.downloads.clone()).unwrap_or_default(),
project_id: hit.and_then(|hit| hit.project_id),
file_id: hit.and_then(|hit| hit.file_id),
disabled: local.disabled,
}
}
+310
View File
@@ -0,0 +1,310 @@
//! 导出管线:实例目录 → 三分类 → 清单 + `overrides/` → 包。
//!
//! 顺序是写死的,每一步都可单独测:
//!
//! ```text
//! ① 实例快照(snapshot) versions/<id>/<id>.json 的继承链 + 库坐标 ⇒ game / dependencies
//! ② 扫描(scan) 内容目录 → 路径 + 字节数 + SHA-1 + 禁用位;读不了的进跳过表
//! ③ 逐文件判定(classify) Modrinth SHA-1 → CF 指纹 → 目标格式能不能表达 ⇒ 远程 / 内联 / 跳过
//! ④ 组模型 files[] = 远程;overrides = 内联(统一 `overrides/`)
//! ⑤ 写包 走 M110 的 `PackFormat::write`(临时文件 + rename,失败不留半个包)
//! ```
//!
//! # 判定为什么要"目标格式能力"参与
//!
//! 「能被远程替代」不是文件自己的属性,是**文件 + 目标格式**的属性:同一个文件,CF 认得
//! `projectID/fileID` 就能写进 CF 的 `files[]`,但 mrpack 要 `sha512` 才写得出来;反过来
//! mrpack 认可 Modrinth 直链,CF 清单却只认两个整数 id。所以
//! [`crate::addon::export::report::FileReason`] 里 `CurseOnlyMatch` 与 `ModrinthOnlyMatch`
//! 是两个不同的事实,不能合并成"没找到"。
//!
//! # 一条禁令
//!
//! **不猜**:目录没给命中就内联;命中缺必需字段(`sha512` / `projectID`)也内联;只有
//! "两条硬要求都齐"才写进 `files[]`。这样导出的包在任何启动器里都能装出**同一份内容**,
//! 最多是"该远程的走了内联"(体积大一点,内容不会变)。
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde_json::Map;
use super::catalog::{OfflineCatalog, RemoteCatalog};
use super::classify::{classify, entry, remote_entry};
use super::report::{ExportFile, ExportReport, FileClass};
use super::scan::{scan_instance, DEFAULT_CONTENT_ROOTS, OVERRIDES_DIR};
use super::snapshot::{read_instance_snapshot, InstanceSnapshot};
use crate::addon::pack::curseforge;
use crate::addon::pack::error::{PackError, PackResult};
use crate::addon::pack::format::{PackFormat, PackPayload};
use crate::addon::pack::manifest::{
OverrideFile, PackLoader, PackManifest, PackOverrides, PackSide,
};
/// 包版本缺省值(CF `version` / mrpack `versionId` 都必填,调用方没给就用它)。
pub const DEFAULT_VERSION: &str = "1.0.0";
/// 默认目录(离线):所有文件内联。
static OFFLINE: OfflineCatalog = OfflineCatalog;
/// 一次导出的输入。
pub struct ExportRequest<'a> {
root: &'a Path,
target: &'a Path,
format: &'static dyn PackFormat,
name: Option<String>,
version: Option<String>,
version_id: Option<&'a str>,
content_roots: Option<Vec<String>>,
catalog: &'a dyn RemoteCatalog,
snapshot: Option<&'a InstanceSnapshot>,
}
impl<'a> ExportRequest<'a> {
/// 从 `root`(实例游戏目录)导出到 `target`,用 `format` 的清单形状。
///
/// 默认:包名取目录名、版本 [`DEFAULT_VERSION`]、内容目录取 [`DEFAULT_CONTENT_ROOTS`]、
/// 目录用 [`OfflineCatalog`](= 全部内联)。
#[must_use]
pub fn new(root: &'a Path, target: &'a Path, format: &'static dyn PackFormat) -> Self {
Self {
root,
target,
format,
name: None,
version: None,
version_id: None,
content_roots: None,
catalog: &OFFLINE,
snapshot: None,
}
}
/// 包名(写进清单 `name`)。
#[must_use]
pub fn with_name(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
/// 包版本(写进 CF `version` / mrpack `versionId`)。
#[must_use]
pub fn with_version(mut self, version: impl Into<String>) -> Self {
self.version = Some(version.into());
self
}
/// 实例绑定的版本 id;不给就要求 `versions/` 下只有一个版本。
#[must_use]
pub fn with_version_id(mut self, version_id: &'a str) -> Self {
self.version_id = Some(version_id);
self
}
/// 覆盖要扫的内容目录(默认 [`DEFAULT_CONTENT_ROOTS`])。
#[must_use]
pub fn with_content_roots(mut self, roots: Vec<String>) -> Self {
self.content_roots = Some(roots);
self
}
/// 注入远端查表(默认离线:全部内联)。
#[must_use]
pub fn with_catalog(mut self, catalog: &'a dyn RemoteCatalog) -> Self {
self.catalog = catalog;
self
}
/// 直接给实例快照,跳过 `versions/<id>/<id>.json` 的读取。
///
/// 用于两种实例:①**Prism / MultiMC 实例**(游戏目录里没有 `versions/`,快照只能从
/// `mmc-pack.json` 的组件表来,调用方用 `pack::multimc` 读出来再传);②调用方已经解析过
/// 快照(UI 侧手里就有 profile + 加载器信息),不必再读一遍盘。
#[must_use]
pub fn with_snapshot(mut self, snapshot: &'a InstanceSnapshot) -> Self {
self.snapshot = Some(snapshot);
self
}
}
/// 导出一个实例。
///
/// # Errors
///
/// - `pack_unsupported`:实例里找不到唯一的版本 JSON / 目标格式表达不了布局;
/// - `pack_metadata`:版本 JSON 坏了(继承成环、不是 JSON);
/// - `pack_limits`:单文件 `fileSize` 超 u32、或整包撞 ZIP64 阈值;
/// - `pack_io`:读写盘失败(失败时目标路径不留半个包)。
pub fn export_instance(request: &ExportRequest<'_>) -> PackResult<ExportReport> {
let snapshot = match request.snapshot {
Some(snapshot) => snapshot.clone(),
None => read_instance_snapshot(request.root, request.version_id)?,
};
let roots = request
.content_roots
.clone()
.unwrap_or_else(|| DEFAULT_CONTENT_ROOTS.map(str::to_string).to_vec());
let (locals, skipped) = scan_instance(request.root, &roots);
// CF 清单只认 projectID/fileID;mrpack 要 path + sha512 + downloads。
let target_is_curse = request.format.id() == curseforge::ID;
let mut manifest = build_manifest(request, &snapshot);
let mut entries: Vec<ExportFile> = Vec::with_capacity(locals.len() + skipped.len());
let mut overrides: Vec<OverrideFile> = Vec::new();
let mut sources: BTreeMap<String, PathBuf> = BTreeMap::new();
for local in &locals {
let (reason, hit) = classify(local, request.catalog, target_is_curse);
match reason.class() {
FileClass::Remote => {
let Some(hit) = hit else {
// 判定逻辑的自我约束:Remote 必带命中。真走到这里说明分类函数被改坏了。
return Err(PackError::unsupported(format!(
"内部不一致:{} 被判为远程引用却没有命中",
local.path
)));
};
manifest.files.push(remote_entry(local, &hit));
entries.push(entry(local, reason, Some(&hit)));
}
FileClass::Inline => {
overrides.push(OverrideFile {
path: local.path.clone(),
sha1: local.sha1.clone(),
size: local.bytes,
});
sources.insert(
format!("{OVERRIDES_DIR}/{}", local.path),
local.disk.clone(),
);
entries.push(entry(local, reason, None));
}
FileClass::Skipped => unreachable!("扫描阶段不会产出可导出项"),
}
}
// 跳过项照原样进报告(逐条给原因),不进包。
for item in &skipped {
entries.push(ExportFile {
path: item.path.clone(),
class: FileClass::Skipped,
reason: item.reason,
bytes: 0,
downloads: Vec::new(),
project_id: None,
file_id: None,
disabled: false,
});
}
if !overrides.is_empty() {
// 一律 `overrides/`(Modrinth 官方 + Prism 的写法)。实例目录不分侧,所以只有一个 Both 组。
manifest.overrides.push(PackOverrides {
dir: OVERRIDES_DIR.to_string(),
side: PackSide::Both,
files: overrides,
});
}
request.format.write(
&manifest,
PackPayload::with_sources(request.root, &sources),
request.target,
)?;
let bytes = std::fs::metadata(request.target)
.map_err(|error| {
PackError::io(format!(
"量 {} 的字节数失败:{error}",
request.target.display()
))
})?
.len();
entries.sort_by(|left, right| left.path.cmp(&right.path));
let notes = notes(target_is_curse, &manifest);
Ok(ExportReport {
format: request.format.id(),
label: request.format.label(),
path: request.target.to_path_buf(),
bytes,
name: manifest.name.clone(),
version: manifest.version.clone(),
minecraft: manifest.minecraft.clone(),
loaders: manifest
.loaders
.iter()
.map(|loader| {
format!(
"{} {}",
loader.dependency_key(),
loader.version.clone().unwrap_or_default()
)
})
.collect(),
files: entries,
notes,
})
}
/// 组清单:`game` / `minecraft` / `loaders` 全部来自实例快照。
fn build_manifest(request: &ExportRequest<'_>, snapshot: &InstanceSnapshot) -> PackManifest {
let name = request
.name
.clone()
.or_else(|| {
request
.root
.file_name()
.map(|name| name.to_string_lossy().to_string())
})
.unwrap_or_else(|| "整合包".to_string());
let mut manifest = PackManifest::new(name);
manifest.version = Some(
request
.version
.clone()
.unwrap_or_else(|| DEFAULT_VERSION.to_string()),
);
manifest.format_version = Some(1);
manifest.game = Some(crate::addon::pack::modrinth::GAME.to_string());
manifest.minecraft = Some(snapshot.game_version.clone());
manifest.loaders = snapshot
.loaders
.iter()
.enumerate()
.map(|(index, loader)| PackLoader {
raw: String::new(),
kind: Some(loader.kind),
version: Some(loader.version.clone()),
primary: index == 0,
extra: Map::new(),
})
.collect();
manifest
}
/// 格式层面表达不了的东西(已知损失)——必须显式列出来,别让用户以为往返无损。
fn notes(target_is_curse: bool, manifest: &PackManifest) -> Vec<String> {
let mut notes = vec![
"覆盖内容统一写进 `overrides/`(Modrinth 官方与 Prism 的写法);实例目录不分侧,\
所以不产生 client-overrides / server-overrides。"
.to_string(),
"禁用文件用**路径后缀 `.disabled`** 表达(不是 `env.client=optional`:后者装回来默认启用)。"
.to_string(),
"MultiMC / Prism 导出本轮不做(只有导入),这是有意省略,不是遗漏。".to_string(),
];
if target_is_curse {
notes.push("mrpack 的 `summary` 在 CF 清单里没有字段;CF 也只认 projectID/fileID,禁用文件只能内联。".to_string());
} else {
notes.push(
"CF 清单的 `author` / `manifestVersion` 之外的扩展字段在 mrpack 里没有对应位置。"
.to_string(),
);
}
if !manifest.files.is_empty() {
notes.push(format!(
"{} 条写进 files[](远程引用),其余在 overrides/ 里;每条的原因见 files[].reason。",
manifest.files.len()
));
}
notes
}
+235
View File
@@ -0,0 +1,235 @@
//! 导出结果:**三分类 + 逐条原因**,给 CLI 的 `--json` 与 M113 的页面直接消费。
//!
//! # 为什么每条都要有原因
//!
//! 用户要的是「不认识的 mod 不能丢、不能编」。光说"导出了 120 个文件"会掩盖真正的信息:
//! 其中多少条**能证明**可被远程替代、多少条**证明不了所以内联**、多少条**干脆没进包**。
//! 所以 [`ExportFile`] 的 `class` 与 `reason` 是两个独立维度,且 `reason` 决定 `class`
//! ([`FileReason::class`] 是唯一映射处,写死在这里,别在别处再判一遍)。
//!
//! # 「允许的差异」也进报告
//!
//! [`ExportReport::notes`] 收的是**格式层面表达不了**的东西(CF 的 `author`、mrpack 的
//! `summary`、CF 表达不了禁用位……)。它们是已知损失,必须显式列出,不能让用户以为"往返无损"。
use std::path::PathBuf;
use serde::{Serialize, Serializer};
/// 一条文件在导出结果里的归类。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FileClass {
/// 写进清单 `files[]`(靠远端直链/项目 id 代替本地字节)。
Remote,
/// 写进 `overrides/`(原字节照搬)。
Inline,
/// 没进包(读不了 / 符号链接 / 路径不安全)。
Skipped,
}
impl FileClass {
/// 稳定字符串 id(进 `--json` 与日志,**不要改名**)。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Remote => "remote",
Self::Inline => "inline",
Self::Skipped => "skipped",
}
}
}
impl Serialize for FileClass {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(self.as_str())
}
}
/// 一条文件为什么归到那个类里。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FileReason {
/// 远程引用:Modrinth SHA-1 查到了版本文件。
ModrinthSha1,
/// 远程引用:CurseForge 指纹查到了项目/文件。
CurseForgeFingerprint,
/// 内联:两个目录都不认识它。
NoRemoteMatch,
/// 内联:CF 认识,但目标格式(mrpack)用不上——CF 命中不含 `sha512`。
CurseOnlyMatch,
/// 内联:Modrinth 认识,但目标格式(CF 清单)只认 `projectID`/`fileID`。
ModrinthOnlyMatch,
/// 内联:目录给了命中但缺 `hashes.sha512`(mrpack 的硬要求)。
MissingSha512,
/// 内联:目录给了命中但缺 `projectID`/`fileID`(CF 清单的硬要求)。
MissingCurseIds,
/// 内联:文件是禁用的(`.disabled`),而目标格式表达不了禁用位(CF)。
DisabledNotExpressible,
/// 跳过:符号链接。
SymbolicLink,
/// 跳过:文件名过不了包内路径规整(`:` / `\` / `..` / 绝对路径…)。
UnsafePath,
/// 跳过:读盘或算摘要失败。
Unreadable,
}
impl FileReason {
/// 稳定字符串 id(进 `--json` 与日志,**不要改名**)。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::ModrinthSha1 => "modrinth_sha1",
Self::CurseForgeFingerprint => "curseforge_fingerprint",
Self::NoRemoteMatch => "no_remote_match",
Self::CurseOnlyMatch => "curse_only_match",
Self::ModrinthOnlyMatch => "modrinth_only_match",
Self::MissingSha512 => "missing_sha512",
Self::MissingCurseIds => "missing_curse_ids",
Self::DisabledNotExpressible => "disabled_not_expressible",
Self::SymbolicLink => "symbolic_link",
Self::UnsafePath => "unsafe_path",
Self::Unreadable => "unreadable",
}
}
/// 人读说明(中文,进报告与日志;UI 文案后续按 key 本地化)。
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::ModrinthSha1 => "Modrinth 命中(SHA-1)",
Self::CurseForgeFingerprint => "CurseForge 命中(文件指纹)",
Self::NoRemoteMatch => "两个目录都不认识,已内联",
Self::CurseOnlyMatch => "只有 CF 认识,目标格式用不上,已内联",
Self::ModrinthOnlyMatch => "只有 Modrinth 认识,CF 清单用不上,已内联",
Self::MissingSha512 => "命中缺 sha512,mrpack 写不出,已内联",
Self::MissingCurseIds => "命中缺 projectID/fileID,CF 清单写不出,已内联",
Self::DisabledNotExpressible => "禁用文件 CF 表达不了,已内联(保留 .disabled 后缀)",
Self::SymbolicLink => "符号链接,已跳过",
Self::UnsafePath => "路径过不了包内规整,已跳过",
Self::Unreadable => "读不出来,已跳过",
}
}
/// 这个原因属于哪一类(**唯一映射处**)。
#[must_use]
pub const fn class(self) -> FileClass {
match self {
Self::ModrinthSha1 | Self::CurseForgeFingerprint => FileClass::Remote,
Self::NoRemoteMatch
| Self::CurseOnlyMatch
| Self::ModrinthOnlyMatch
| Self::MissingSha512
| Self::MissingCurseIds
| Self::DisabledNotExpressible => FileClass::Inline,
Self::SymbolicLink | Self::UnsafePath | Self::Unreadable => FileClass::Skipped,
}
}
}
impl Serialize for FileReason {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(self.as_str())
}
}
/// 一条文件的导出结果。
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ExportFile {
/// 实例内相对路径(含 `.disabled` 后缀,与磁盘上一致)。
pub path: String,
/// 归类。
pub class: FileClass,
/// 原因。
pub reason: FileReason,
/// 字节数(跳过项记磁盘上的大小,读不到就是 0)。
pub bytes: u64,
/// 远程引用给的直链(内联/跳过为空)。
pub downloads: Vec<String>,
/// CurseForge `projectID`(远程引用到 CF 时才有)。
pub project_id: Option<i64>,
/// CurseForge `fileID`。
pub file_id: Option<i64>,
/// 是否禁用态(`.disabled`)。
pub disabled: bool,
}
/// 一次导出的结构化结果。
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ExportReport {
/// 目标格式 id(`modrinth` / `curseforge`)。
pub format: &'static str,
/// 目标格式人读名。
pub label: &'static str,
/// 产出的包路径。
pub path: PathBuf,
/// 包的字节数(写完之后量的,不是估算)。
pub bytes: u64,
/// 包名(写进清单的 `name`)。
pub name: String,
/// 包版本(`versionId` / `version`)。
pub version: Option<String>,
/// 游戏版本(来自实例快照)。
pub minecraft: Option<String>,
/// 加载器(`fabric-loader 0.15.11` 这种人读串,来自实例快照)。
pub loaders: Vec<String>,
/// 逐文件结果(按路径排序)。
pub files: Vec<ExportFile>,
/// 已知的表达不了的差异 / 值得用户知道的取舍。
pub notes: Vec<String>,
}
impl ExportReport {
/// 远程引用的条数。
#[must_use]
pub fn remote_count(&self) -> usize {
self.count(FileClass::Remote)
}
/// 内联的条数。
#[must_use]
pub fn inline_count(&self) -> usize {
self.count(FileClass::Inline)
}
/// 跳过的条数。
#[must_use]
pub fn skipped_count(&self) -> usize {
self.count(FileClass::Skipped)
}
fn count(&self, class: FileClass) -> usize {
self.files.iter().filter(|file| file.class == class).count()
}
/// 有没有文件没进包(`false` = 包内容完整,只是有些走了内联)。
#[must_use]
pub fn is_complete(&self) -> bool {
self.skipped_count() == 0
}
/// 内联文件(`overrides/` 里的那些)。
pub fn inline_files(&self) -> impl Iterator<Item = &ExportFile> {
self.files
.iter()
.filter(|file| file.class == FileClass::Inline)
}
/// 不可导出的项(逐条给 `path` + `reason` 用)。
pub fn skipped_files(&self) -> impl Iterator<Item = &ExportFile> {
self.files
.iter()
.filter(|file| file.class == FileClass::Skipped)
}
/// 一句话摘要(日志/CLI 一行输出用)。
#[must_use]
pub fn summary(&self) -> String {
format!(
"{}:{} 个远程引用 / {} 个内联 / {} 个跳过,{} 字节",
self.label,
self.remote_count(),
self.inline_count(),
self.skipped_count(),
self.bytes
)
}
}
+190
View File
@@ -0,0 +1,190 @@
//! 实例目录扫描:把"磁盘上有什么"变成 [`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` 这种"禁用 + 旧残留"组合)。
///
/// M224 收敛:判据只有 [`crate::addon::mods::suffix`] 一处(启停也要用它,见
/// `DevDocs/PlanDoc/08-mod-and-recommend.md` §3)。这里保留函数名与签名,行为逐字不变。
#[must_use]
pub fn is_disabled_path(path: &str) -> bool {
crate::addon::mods::is_disabled_path(path)
}
+209
View File
@@ -0,0 +1,209 @@
//! 实例快照:`game` 与 `dependencies` 的唯一来源(**不解析实例 id 字符串**)。
//!
//! # 为什么要读版本 JSON 而不是拆 `versions/<id>` 的名字
//!
//! 实例 id 的形状是社区约定,不是规范:`1.20.1-fabric-0.15.11`(本仓库 `LoaderKind::version_id`
//! 的拼法)、`1.20.1-forge-47.2.0`、`Fabric 1.20.1`、`我的整合包 v3`……按 `-` 切串去猜
//! "哪段是加载器、哪段是版本"必然在真实实例上翻车。**证据只能在版本 JSON 里**:
//!
//! - 游戏版本 = `inheritsFrom` 链**最深的那个祖先**的 `id`(加载器层都叠在原版之上);
//! - 加载器 = 各层 `libraries[]` 里的 Maven 坐标:`net.fabricmc:fabric-loader:<ver>`、
//! `org.quiltmc:quilt-loader:<ver>`、`net.minecraftforge:forge:<ver>`、
//! `net.neoforged:neoforge:<ver>`(`fmlloader` 也认,它是 Forge 的拆分包)。
//!
//! 这条口径由 tower 批准(M112):"解析 `<mc>-<loader>-<ver>` 字符串是猜"。
//!
//! # 版本号里的 MC 前缀
//!
//! NeoForge 在 MC 1.20.1 上的库坐标版本是 `1.20.1-47.1.106`(CF 导出的
//! `neoforge-1.20.1-47.1.106` 同源)。这里统一剥成 `47.1.106`:mrpack 的
//! `dependencies["neoforge"]` 要裸版本,CF 的 `modLoaders[].id` 由
//! `PackLoader::curse_id_for` 在 1.20.1 上再把前缀拼回去。
//!
//! # 没有加载器是合法的
//!
//! 原版实例的 `dependencies` 只有 `minecraft`——那不是"漏了加载器",是真的没装。任务书说的
//! 「不得只写 `minecraft` 漏加载器」针对的是**装了加载器却漏写**,两者靠这条链区分得清清楚楚。
use std::path::Path;
use crate::addon::pack::error::{PackError, PackResult};
use crate::addon::LoaderKind;
use crate::game::library::MavenCoordinate;
use crate::game::manifest::VersionJson;
/// 实例里认出的一条加载器依赖。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SnapshotLoader {
/// 加载器种类。
pub kind: LoaderKind,
/// 加载器版本(已剥掉可能存在的 `<mc>-` 前缀)。
pub version: String,
}
/// 实例快照:游戏版本 + 加载器集合(顺序即 `libraries[]` 里出现的顺序)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InstanceSnapshot {
/// 原版游戏版本(`1.20.1`)。
pub game_version: String,
/// 加载器依赖(原版实例为空)。
pub loaders: Vec<SnapshotLoader>,
}
/// 读一个实例目录的快照。
///
/// `version_id` 给定时只读 `versions/<id>/<id>.json`;`None` 时取 `versions/` 里**继承链的叶子**
/// (真实实例有"原版 + 加载器层"两个目录,叶子才是实例自己)。叶子多于一个时无法判断,
/// 宁可报错也不猜。
pub fn read_instance_snapshot(
root: &Path,
version_id: Option<&str>,
) -> PackResult<InstanceSnapshot> {
let versions_dir = root.join("versions");
let id = match version_id {
Some(id) => id.to_string(),
None => instance_version_id(&versions_dir)?,
};
let layers = load_chain(&versions_dir, &id)?;
let deepest = layers
.last()
.and_then(|layer| layer.id.clone())
.ok_or_else(|| {
PackError::metadata(format!("versions/{id}/{id}.json 的继承链末端没有 id"))
})?;
let mut loaders: Vec<SnapshotLoader> = Vec::new();
for layer in &layers {
for library in layer.libraries.iter().flatten() {
let Ok(coordinate) = MavenCoordinate::parse(&library.name) else {
continue;
};
let Some(kind) = loader_kind_of(&coordinate.group, &coordinate.artifact) else {
continue;
};
if loaders.iter().any(|seen| seen.kind == kind) {
continue;
}
loaders.push(SnapshotLoader {
kind,
version: strip_game_prefix(&deepest, &coordinate.version),
});
}
}
Ok(InstanceSnapshot {
game_version: deepest,
loaders,
})
}
/// `versions/` 下"这个实例"的版本 id:**继承链的叶子**(没有被别的版本当作 `inheritsFrom`)。
///
/// 为什么不是"只有一个版本目录":真实实例必然有"原版 + 加载器层"两个目录(`1.20.1` 与
/// `1.20.1-fabric-0.15.11`),叶子才是实例自己。这条判据不看目录名形状,只看 JSON 里的
/// `inheritsFrom` 关系——与"不解析实例 id 字符串"是同一条纪律。
fn instance_version_id(versions_dir: &Path) -> PackResult<String> {
let entries = std::fs::read_dir(versions_dir).map_err(|error| {
PackError::unsupported(format!(
"实例里没有可读的 {}:{error}",
versions_dir.display()
))
.with_hint("导出需要实例的版本 JSON(versions/<id>/<id>.json 给出游戏版本与加载器)")
})?;
let mut versions: Vec<(String, Option<String>)> = Vec::new();
for entry in entries.flatten() {
let Ok(file_type) = entry.file_type() else {
continue;
};
if !file_type.is_dir() {
continue;
}
let name = entry.file_name().to_string_lossy().to_string();
let path = entry.path().join(format!("{name}.json"));
if !path.is_file() {
continue;
}
let text = std::fs::read_to_string(&path).map_err(|error| {
PackError::metadata(format!("读版本 JSON {} 失败:{error}", path.display()))
})?;
let layer: VersionJson = serde_json::from_str(&text).map_err(|error| {
PackError::metadata(format!("{} 不是合法版本 JSON:{error}", path.display()))
})?;
versions.push((name, layer.inherits_from));
}
if versions.is_empty() {
return Err(
PackError::unsupported("实例的 versions/ 下没有 <id>/<id>.json".to_string()).with_hint(
"先装好游戏/加载器再导出;导出需要版本 JSON 才能写 game 与 dependencies",
),
);
}
let referenced: Vec<&str> = versions
.iter()
.filter_map(|(_, parent)| parent.as_deref())
.collect();
let mut leaves: Vec<String> = versions
.iter()
.filter(|(id, _)| !referenced.contains(&id.as_str()))
.map(|(id, _)| id.clone())
.collect();
leaves.sort();
match leaves.len() {
1 => Ok(leaves.remove(0)),
_ => Err(PackError::unsupported(format!(
"实例的 versions/ 下有 {} 条继承链末端({}),无法判断哪个属于这个实例",
leaves.len(),
leaves.join(", ")
))
.with_hint("调用方应显式给出 versionId(实例档案的 version_id)")),
}
}
/// 从 `id` 起沿 `inheritsFrom` 收集各层(第一层是实例自己,最后一层是最深的祖先)。
fn load_chain(versions_dir: &Path, id: &str) -> PackResult<Vec<VersionJson>> {
let mut layers: Vec<VersionJson> = Vec::new();
let mut visited: Vec<String> = Vec::new();
let mut current = Some(id.to_string());
while let Some(next) = current {
if visited.iter().any(|seen| seen == &next) {
return Err(PackError::metadata(format!(
"版本 JSON 的继承链成环:{} → {next}",
visited.join(" → ")
)));
}
visited.push(next.clone());
let path = versions_dir.join(&next).join(format!("{next}.json"));
let text = std::fs::read_to_string(&path).map_err(|error| {
PackError::metadata(format!("读版本 JSON {} 失败:{error}", path.display()))
.with_hint("继承链上的父版本也必须装在实例里")
})?;
let layer: VersionJson = serde_json::from_str(&text).map_err(|error| {
PackError::metadata(format!("{} 不是合法版本 JSON:{error}", path.display()))
})?;
current = layer.inherits_from.clone();
layers.push(layer);
}
Ok(layers)
}
/// Maven 坐标 → 加载器种类(认不出的返回 `None`,不影响别的库)。
fn loader_kind_of(group: &str, artifact: &str) -> Option<LoaderKind> {
match (group, artifact) {
("net.fabricmc", "fabric-loader") => Some(LoaderKind::Fabric),
("org.quiltmc", "quilt-loader") => Some(LoaderKind::Quilt),
("net.minecraftforge", "forge" | "fmlloader") => Some(LoaderKind::Forge),
("net.neoforged", "neoforge") => Some(LoaderKind::NeoForge),
_ => None,
}
}
/// 剥掉版本号里又写了一遍的 `<mc>-` 前缀(`1.20.1-47.1.106` → `47.1.106`)。
fn strip_game_prefix(game_version: &str, version: &str) -> String {
let prefix = format!("{game_version}-");
version
.strip_prefix(&prefix)
.filter(|rest| !rest.is_empty())
.unwrap_or(version)
.to_string()
}
+271
View File
@@ -0,0 +1,271 @@
//! 整合包**导入**(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`]:本模块**只消费,不重写**。
//!
//! # 逐文件冲突决策(M160):预检 → 用户点头 → 导入
//!
//! | 步骤 | 入口 | 写盘吗 |
//! |------|------|--------|
//! | 预检:算出"哪几个文件会盖掉用户的" | [`scan_pack`] | **不写**(只读盘算摘要) |
//! | 用户点「全部保留 / 全部覆盖 / 逐项」 | [`ConflictDecisions`] | — |
//! | 导入 | [`import_pack`] | 写(按决策处置冲突) |
//!
//! 三条硬口径(改它们之前先看 [`scan`] 与 [`decision`] 的模块文档):
//!
//! 1. 进冲突清单的**只有**"目标里已存在同名普通文件且内容(SHA-1)不同"的路径;内容相同、
//! 目标不存在、以及用户删过的文件都不打扰用户;
//! 2. 决策按**路径**给,逐项优先于整批档,整批档缺省 = 保留;
//! 3. 缺省**绝不覆盖**:没收到决策的冲突一律保留用户那一侧([`ConflictChoice::default`])。
//!
//! # 三条硬要求落在哪
//!
//! | 要求 | 落点 |
//! |------|------|
//! | overrides 哈希**三方合并**(不静默覆盖用户改动) | [`merge`] 的判据 + [`baseline`] 的 base 侧 |
//! | 两阶段补全 + **失败分级**(仅"缺文件"可容忍) | [`pipeline`] 的顺序 + [`report::MissingFile`] 与 [`error::ImportError`] 的分工 |
//! | 路径穿越**双检** | 开包时 M110 [`crate::addon::pack::PackArchive::open`],落地时 [`stage`] 的 `normalize_file` + `starts_with` 复核 |
//!
//! # 取消(M195):每条停下路线都回滚,且**取消不是失败**
//!
//! 用户点「取消」之后管线真的停手:每个阶段边界与长循环内都有检查点,取消后目标目录与导入前
//! 一致(先写临时目录、最后 `rename` / journal 合并),**不登记实例**。结果类型单列一档
//! [`ImportStatus::Cancelled`](`error` 为空、CLI 退出码 `130`、UI 不弹红),与
//! [`ImportStatus::Failed`] 是两条路。逐段检查点表(含"哪几段没有检查点、为什么")在
//! [`cancel`] 模块文档里。
//!
//! # 三种格式的语义(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 cancel = ImportCancel::new(); // 交给「取消」按钮:cancel.cancel()
//! let request = ImportRequest::new(pack_path, instance_dir)
//! .with_fetcher(&fetcher)
//! .with_cancel(&cancel)
//! .with_registration(&launcher_layout, None);
//! let report = download::blocking::block_on(import_pack(&request));
//! match report.status { /* Success / Partial / Failed / Cancelled */ }
//! ```
//!
//! # 刻意不做的事(分工边界)
//!
//! - **不删除**整合包升级后不再包含的文件:磁盘上的 `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`] 的说明)。
//!
//! 取消这一条另有三个欠账(M195 报告同样登记):①超大整合包(几千文件 / 数 GB)在真实磁盘
//! 上的**取消响应延迟**没测——最坏要等"当前这一条文件读完/下完 + 一次回滚";②**单条下载在飞
//! 不可中断**([`fetch::PackFetcher`] 没有取消参数),只能等它返回;③macOS / Linux 未验。
pub mod baseline;
pub mod cancel;
pub(crate) mod complete;
pub mod decision;
pub mod error;
pub mod fetch;
pub mod merge;
pub mod phase;
pub mod pipeline;
pub mod plan;
pub(crate) mod prepare;
pub mod report;
pub mod scan;
pub mod stage;
pub use baseline::{PackBaseline, BASELINE_FILE};
pub use cancel::ImportCancel;
pub use decision::ConflictDecisions;
pub use error::{ImportError, ImportErrorKind};
pub use fetch::{
CurseFileMeta, DownloadEngineFetcher, FetchError, FetchErrorKind, FetchFuture, FetchRequest,
FetchedFile, ModrinthFileMeta, PackFetcher,
};
pub use merge::{MergeAction, MergeDecision};
pub use plan::{ImportPlan, PlanOverride, PlanRemote, Shadowed, SuffixVariant};
pub use prepare::baseline_relative;
pub use report::{
keys, ConflictChoice, ConflictFile, ConflictReason, FileAction, FileSource, ImportIssue,
ImportProgress, ImportProgressSink, ImportReport, ImportStage, ImportStatus, ImportedFile,
LoaderTarget, MissingFile, MissingReason, RegisteredInstance, RejectedComponent,
};
pub use scan::{scan_pack, scan_pack_online, ImportConflict, ImportScan};
pub use stage::{RollbackOutcome, Staging};
use std::path::Path;
use crate::addon::pack::PackLimits;
use crate::platform::layout::LauncherLayout;
/// 整次导入的**缺省**冲突档(逐项决策见 [`ConflictDecisions`])。
///
/// 保留这个名字是因为 CLI 已经按它调用(`pack import --overwrite`);语义上它就是
/// [`ConflictDecisions::default_choice`] 的构造器:
///
/// - [`ConflictPolicy::KeepUser`](缺省)= 逐项决策的缺省档是"保留";
/// - [`ConflictPolicy::Overwrite`] = 逐项决策的缺省档是"覆盖"。
///
/// **逐项决策优先于它**,所以"整批覆盖 + 某几条保留"是可表达的。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ConflictPolicy {
/// 保留用户那一侧(不覆盖、不补回被删的文件),冲突逐条进报告。
#[default]
KeepUser,
/// 用包内内容覆盖(用户明确要求"整合包说了算")。
Overwrite,
}
impl ConflictPolicy {
/// 对应的逐项决策缺省档。
#[must_use]
const fn choice(self) -> ConflictChoice {
match self {
Self::KeepUser => ConflictChoice::KeepUser,
Self::Overwrite => ConflictChoice::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 decisions: ConflictDecisions,
/// 取消令牌;缺省是永不取消的独立令牌(没挂令牌的调用方不必写 `Option`)。
///
/// 语义与检查点位置见 [`cancel`] 模块文档:取消后**不得**留下半包 / 覆盖已有实例,也不许
/// 登记实例;判据是报告里的 [`report::ImportStatus::Cancelled`](**不是**失败)。
pub cancel: ImportCancel,
/// 包读取限额(条数 / 单条体积 / 解压总量)。
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,
decisions: ConflictDecisions::default(),
cancel: ImportCancel::default(),
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.decisions = std::mem::take(&mut self.decisions).with_default(policy.choice());
self
}
/// 整表逐文件决策(「全部保留 / 全部覆盖 / 逐项」三条入口的产物)。
#[must_use]
pub fn with_decisions(mut self, decisions: ConflictDecisions) -> Self {
self.decisions = decisions;
self
}
/// 挂一条逐项决策(链式,便于测试与命令行逐条给)。
#[must_use]
pub fn with_decision(mut self, path: impl Into<String>, choice: ConflictChoice) -> Self {
self.decisions.set(path, choice);
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
}
/// 挂取消令牌(与调用方手里的那份**共享同一个开关**)。
///
/// 取消的结果是 [`ImportStatus`] 里的 `Cancelled`(不是失败);停在哪一段、哪些段落没有
/// 检查点,见 [`cancel`] 模块文档的逐段表。
#[must_use]
pub fn with_cancel(mut self, token: &ImportCancel) -> Self {
self.cancel = token.clone();
self
}
}
/// 导入一个整合包。**永不返回 `Err`**:三态 + 取消 + 逐项原因都在 [`ImportReport`] 里。
pub async fn import_pack(request: &ImportRequest<'_>) -> ImportReport {
pipeline::run(request).await
}
#[cfg(test)]
mod cancel_tests;
+162
View File
@@ -0,0 +1,162 @@
//! 三方合并的 **base 侧**:上次导入写下的"包给的哈希"。
//!
//! # 为什么必须落盘
//!
//! 三方合并的三个输入是:`theirs`(这次包里给的)、`ours`(磁盘现状)、`base`(**上次**包里
//! 给的)。`base` 不能从这次的包现算——那是 `theirs`;也不能从磁盘现算——那是 `ours`。少了
//! `base` 就分不清两种截然不同的事实:
//!
//! - 磁盘内容 ≠ 新包内容,且磁盘内容 == 上次包内容 ⇒ **整合包升级**,该覆盖(用户没动过);
//! - 磁盘内容 ≠ 新包内容,且磁盘内容 ≠ 上次包内容 ⇒ **用户改过**,不许静默覆盖。
//!
//! 所以第一次导入就在实例根写一份基线;没有基线时(首次导入 / 用户手动塞进来的文件 / 基线
//! 属于别的整合包)一律按"磁盘上已有同名文件"处理 —— 不覆盖、进冲突清单(最安全的一侧)。
//!
//! # 落点:**唯一来源是 [`crate::game::layout::GameLayout::ull_root`]**
//!
//! `<实例根>/.ull/pack-baseline.json`(游戏根内的启动器私有目录)。
//!
//! 这里有个踩过的坑(复审 r5 P1-1):基线最初写成 `<实例根>/.ULL/…`(用了
//! [`crate::platform::layout::LAUNCHER_HOME_DIR`],那是**启动器数据根**的名字,在 exe/portable
//! 那一层),而读侧用 `GameLayout::ull_root()`(`<实例根>/.ull`,小写,实例**游戏根**内的
//! 私有目录)。Windows / macOS 的大小写不敏感文件系统上两者是同一个文件,本机全绿;ext4 上
//! 是两个文件 ⇒ 基线永远读不到(`UserModified`/`UserDeleted` 永不出现)。
//!
//! 现在**读写都从 `ull_root()` 派生**:绝对路径 [`PackBaseline::path`],相对路径
//! [`PackBaseline::relative_path`],两者由 `addon/tests/import_merge.rs` 的一条断言钉死
//! (`path(root) == root.join(relative_path())`)——这条在大小写敏感的系统上也抓得住。
//!
//! # 包身份
//!
//! 基线记包名与格式;下次导入发现"基线属于另一个包"时**不拿它当 base**(否则跨包导入同一
//! 目录会把别人的哈希当成本次包的历史),而是降级成"无基线"并如实告警。
//!
//! 基线里存的是**包声明的哈希**(不是磁盘实际哈希):用户改过的文件在基线里仍是包的值,
//! 下次导入才会继续判成"用户改过",而不会被"上次也是改动值"洗白。`.disabled` / `.old`
//! 后缀按原样进键名(后缀是路径的一部分,不附加语义)。
//!
//! 读失败**不是**致命错误:基线坏了最多退化成"全部按 pre-existing 冲突处理"(保留用户文件),
//! 比"因为读不出基线就整包回滚"合理。
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::game::layout::GameLayout;
/// 基线文件名。
pub const BASELINE_FILE: &str = "pack-baseline.json";
/// 基线 schema 版本。
pub const BASELINE_SCHEMA: u32 = 1;
/// 上次导入写下的包声明哈希表。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PackBaseline {
/// schema 版本(迁移链入口)。
#[serde(default = "default_schema")]
pub schema_version: u32,
/// 上次导入的包名(诊断 + 包身份判据)。
#[serde(default)]
pub pack_name: String,
/// 上次导入命中的格式 id(包身份判据的另一半)。
#[serde(default)]
pub format: String,
/// 实例内相对路径 → 包声明的 SHA-1。
#[serde(default)]
pub files: BTreeMap<String, String>,
}
const fn default_schema() -> u32 {
BASELINE_SCHEMA
}
impl Default for PackBaseline {
fn default() -> Self {
Self {
schema_version: BASELINE_SCHEMA,
pack_name: String::new(),
format: String::new(),
files: BTreeMap::new(),
}
}
}
impl PackBaseline {
/// 实例根下的基线**绝对**路径。
///
/// 唯一来源是 `GameLayout::ull_root()`:实例的游戏根里那个启动器私有目录。
#[must_use]
pub fn path(game_root: &Path) -> PathBuf {
GameLayout::new(game_root).ull_root().join(BASELINE_FILE)
}
/// 基线**相对实例根**的路径(暂存期与写盘用;与 [`Self::path`] 同源)。
#[must_use]
pub fn relative_path() -> PathBuf {
// 用空根求 `ull_root()` 就得到那一段相对目录名(`.ull`),不另抄一份字符串。
GameLayout::new(Path::new(""))
.ull_root()
.join(BASELINE_FILE)
}
/// 基线是否属于这份包(空基线 / 没有身份信息的老基线一律放行)。
#[must_use]
pub fn matches_pack(&self, format: &str, pack_name: &str) -> bool {
if self.pack_name.is_empty() && self.format.is_empty() {
return true;
}
self.format == format && self.pack_name == pack_name
}
/// 读基线;文件不存在返回空表(**不是错误**),形状不符返回 `Err`。
///
/// # Errors
///
/// 非 UTF-8 / JSON 非法 / 版本号比本进程新(未来版本写的)时返回人读原因。
pub fn load(game_root: &Path) -> Result<Self, String> {
let path = Self::path(game_root);
let bytes = match std::fs::read(&path) {
Ok(bytes) => bytes,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Ok(Self::default())
}
Err(error) => {
return Err(format!("读 {} 失败:{error}", path.display()));
}
};
let text = String::from_utf8(bytes)
.map_err(|error| format!("{} 不是 UTF-8:{error}", path.display()))?;
let baseline: Self = serde_json::from_str(&text)
.map_err(|error| format!("{} 形状不符:{error}", path.display()))?;
if baseline.schema_version > BASELINE_SCHEMA {
return Err(format!(
"{} 的 schemaVersion={} 比本进程({BASELINE_SCHEMA})新",
path.display(),
baseline.schema_version
));
}
Ok(baseline)
}
/// 查上次给这个路径的哈希。
#[must_use]
pub fn sha1(&self, path: &str) -> Option<&str> {
self.files.get(path).map(String::as_str)
}
/// 记一条。
pub fn insert(&mut self, path: impl Into<String>, sha1: impl Into<String>) {
self.files.insert(path.into(), sha1.into());
}
/// 序列化成落盘字节(pretty:这文件是给人排障看的)。
///
/// # Errors
///
/// 序列化失败(纯理论,`BTreeMap<String, String>` 不会失败)。
pub fn to_bytes(&self) -> Result<Vec<u8>, String> {
serde_json::to_vec_pretty(self).map_err(|error| format!("基线序列化失败:{error}"))
}
}
+152
View File
@@ -0,0 +1,152 @@
//! 导入的**取消令牌**(M195):用户点「取消」之后,后台真的停手并回滚,而不是跑完再假装停了。
//!
//! # 取消不是失败(这是本模块存在的唯一理由)
//!
//! | 现象 | 取消 | 失败 |
//! |------|------|------|
//! | 结果类型 | [`super::report::ImportStatus::Cancelled`] | [`super::report::ImportStatus::Failed`] |
//! | `ImportReport::error` | `None` | 必有 |
//! | CLI 退出码 | `130` | `1` |
//! | UI | 面板收回 / 「已取消」,**不弹红** | 红色结果块 |
//!
//! 管线内部因此有**两条**停止路线:[`Stopped`]。它故意不是 [`super::error::ImportError`] 的
//! 第 N 个大类——把两者并成一个变体,调用方就再也分不清"用户改主意"与"这包装坏了",
//! 而这两件事的收尾文案、退出码、重试语义全都不同。
//!
//! # 取消到底停在哪一段(逐段点名,没有检查点的段落要说明为什么)
//!
//! | 段 | 代码 | 检查点 | 为什么是这个粒度 |
//! |----|------|--------|------------------|
//! | 读包 | `PackArchive::open` → `PackRegistry::read` → `ImportPlan::build` | 入口前、**解析后**各一次 | 一次 `open` 是**不可分割**的(zip 目录 + 四检 + 限额一次扫完),取消等它返回;解析完立刻有一次,所以"用户刚点取消时包已经读完"也不会白写盘 |
//! | 校验 / 补全 | `pipeline::load_baseline`、`complete::complete` | 补全循环**每条目**一次 | 补全是逐条网络请求(按 sha512 反查 sha1),条目之间是天然的取消点 |
//! | 解压 / 写入(暂存目录) | `phase::extract_overrides` | **每条**一次 | 只写临时目录,取消 = 临时目录整个删掉,目标目录一个字节都没动 |
//! | 远端下载(暂存目录) | `phase::fetch_remote` | **每条**一次 | 单条 `PackFetcher::fetch_file` **在飞不可中断**(fetcher 接口没有取消参数),取消在它返回后的下一次检查生效 |
//! | 落盘 | `Staging::commit` | 提交前一次;合并态**逐文件**一次 | 全新导入是一次 `rename`(单步原子,之后无可取消);合并态是逐文件 `rename` + journal,中途取消**逆序回滚**(新文件删掉、旧备份改回原名) |
//! | 登记实例 | `game::install::register` | 落盘后、登记前一次 | 内容已进目标目录,取消必须**连内容一起回滚**,否则留下"有目录没档案"的半个实例 |
//! | 提交前清中间产物 | `Staging::purge_engine_artifacts` | 与"提交前"共用一次 | 它只是暂存目录里的一次本地遍历,与 `commit` 同一条边界 |
//!
//! **预检([`super::scan`])不接取消**:它只读盘、不写一个字节,取消了也不会留下任何东西;
//! 给它加令牌只是让"用户点完取消还得等预检跑完"这件事更难看。
//!
//! # 与 M186 的下载取消不是一套(不许照搬)
//!
//! M186 的口径是"解析后查一次 + 下载循环每次迭代前查一次",因为那边是**一批独立文件**的下载
//! 队列,随时可以在文件之间停。导入是**单包解压 + 两阶段落盘**:真正的风险不是"少下几个文件",
//! 而是"临时目录里的半包被提交进目标目录"。所以这里多了两处 M186 没有的检查点——**落盘逐文件**
//! 与**登记前**——它们对应两种只能靠回滚才能收场的状态(合并态已搬了一半 / 内容已落盘但档案
//! 还没建)。照搬 M186 的两处检查会漏掉这两段,正是"取消后仍写盘"的原始 finding 形态。
//!
//! # 目标的形态(取消后的机器位)
//!
//! 报告的收尾在 [`super::report::ImportReport::cancel`](三档注记与 `rolled_back` 都在那里):
//!
//! - 取消发生在**落盘之前** ⇒ 临时目录已删,`rolled_back = true`、`rollback` 为 `None`;
//! - 取消发生在**落盘之中/之后** ⇒ 回滚已做,`rollback` 里有结果,`rolled_back` 只在**完整**
//! 回滚时为 `true`(回滚本身失败时如实为 `false` 并逐项列出,与失败路径同一套口径)。
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use super::error::ImportError;
/// 取消令牌:一个可跨线程共享的布尔开关。
///
/// [`Default`] 造出的是**永不取消**的独立令牌(没挂令牌的调用方零成本,不必到处写 `Option`)。
/// 要用既有 `Arc<AtomicBool>`(presenter 的 `cancel_pack` 就是这么存的)时走
/// [`ImportCancel::from_flag`],两边共享同一个开关。
#[derive(Debug, Clone, Default)]
pub struct ImportCancel {
flag: Arc<AtomicBool>,
}
impl ImportCancel {
/// 一个新的、未取消的令牌。
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// 接管一个既有开关(共享同一份状态:外面置位,这里立刻看得见)。
#[must_use]
pub fn from_flag(flag: Arc<AtomicBool>) -> Self {
Self { flag }
}
/// 底层开关(要交给别的线程持有时用)。
#[must_use]
pub fn flag(&self) -> Arc<AtomicBool> {
Arc::clone(&self.flag)
}
/// 置位(幂等)。
pub fn cancel(&self) {
self.flag.store(true, Ordering::SeqCst);
}
/// 是否已被取消。
#[must_use]
pub fn is_cancelled(&self) -> bool {
self.flag.load(Ordering::SeqCst)
}
/// 一个检查点:已取消 ⇒ [`Stopped::Cancelled`](由 `?` 冒泡到编排层)。
pub(crate) fn stopped(&self) -> Result<(), Stopped> {
if self.is_cancelled() {
Err(Stopped::Cancelled)
} else {
Ok(())
}
}
}
/// 管线内部的两条停止路线:**取消**与**失败**。
///
/// 分开的理由见模块文档:"用户改主意"不是"这包装坏了"。`From<ImportError>` 让所有既有的
/// `?`(IO / 穿越 / 摘要不符 / 限额)继续直接冒泡成 [`Stopped::Failed`],不用把每个调用点
/// 都改成手写映射。
#[derive(Debug)]
pub(crate) enum Stopped {
/// 用户在检查点之前点了取消。
Cancelled,
/// 一次必须回滚的失败。
Failed(ImportError),
}
impl From<ImportError> for Stopped {
fn from(error: ImportError) -> Self {
Self::Failed(error)
}
}
impl From<crate::addon::pack::PackError> for Stopped {
fn from(error: crate::addon::pack::PackError) -> Self {
Self::Failed(error.into())
}
}
impl From<super::fetch::FetchError> for Stopped {
fn from(error: super::fetch::FetchError) -> Self {
Self::Failed(error.into())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_token_is_shared_only_with_the_flags_it_was_built_from() {
let token = ImportCancel::new();
assert!(!token.is_cancelled());
token.cancel();
assert!(token.is_cancelled(), "自己置位自己要看得见");
let foreign = ImportCancel::new();
assert!(!foreign.is_cancelled(), "默认令牌之间不共享状态");
let flag = Arc::new(AtomicBool::new(false));
let shared = ImportCancel::from_flag(Arc::clone(&flag));
flag.store(true, Ordering::SeqCst);
assert!(shared.is_cancelled(), "外部置位要被看见");
}
}
@@ -0,0 +1,400 @@
//! 导入取消(M195)的守门用例:**取消不是失败**、**取消不留半包 / 不覆盖已有实例**、
//! **取消后不登记实例**。
//!
//! # 每条用例挡哪种回归(变异证明见 mission 报告)
//!
//! | 用例 | 删掉哪一处就红 |
//! |------|----------------|
//! | `a_cancel_before_the_first_write_touches_nothing` | 入口检查点 |
//! | `a_cancel_inside_the_extract_loop_leaves_no_half_unpacked_pack` | 解压循环检查点(判据 = 解压进度条数:有 = 1、删掉 = 3) |
//! | `a_cancel_in_the_middle_of_the_merge_restores_the_users_file` | 取消时的 `rollback_merge` |
//! | `a_cancel_right_before_registering_rolls_the_whole_instance_back` | 登记前的检查点 |
//! | `cancel_and_failure_are_two_different_result_types` | 把取消并进 `fail()` 那条路 |
//!
//! # 解压那条必须数事件,其余没有真守卫的检查点
//!
//! 解压只写**暂存目录**,所以"status == Cancelled / 目标目录不存在 / `files` 为空 / 无残留暂存"
//! 在删掉循环检查点后**依然成立**(提交前检查点会替它停一次)——只有"解了几条"能区分两者。
//!
//! 有变异证明的只有上表五处。**没有**:检查点②解析后 / ③补全后 / ④开暂存目录前 / ⑤提交前(删掉
//! 任一处,别的检查点会接住同一次取消,`pipeline.rs` 的断言仍绿)、全新导入 `commit` 的取消分支、
//! [`super::phase`] 远端下载逐条检查点、[`super::complete`] 补全循环的 `break`——补它们要两段式
//! 夹具或更多计数探针,本轮没做。
//!
//! 触发点全部走**进度回调**(置位 = 用户此刻点了取消,无 sleep、无竞态);全部离线。
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use super::cancel::ImportCancel;
use super::report::{ImportProgress, ImportProgressSink, ImportStage, ImportStatus};
use super::{import_pack, ImportRequest};
use crate::addon::temp::TempRoot;
use crate::download::blocking::block_on;
/// 一份只含覆盖内容的 mrpack 清单(`files` 为空 ⇒ 全部内容来自 `overrides/`,无远端条目)。
const INDEX: &str = r#"{
"game": "minecraft",
"formatVersion": 1,
"versionId": "1.0.0",
"name": "Cancelable",
"files": [],
"dependencies": {"minecraft": "1.20.1", "fabric-loader": "0.15.11"}
}
"#;
/// 手造 mrpack(`entries` 是 `overrides/` 下的实例内路径与字节)。
fn mrpack(tag: &str, entries: &[(&str, &[u8])]) -> (TempRoot, PathBuf) {
let root = TempRoot::new(tag);
let source = root.join("src");
write_file(&source.join("modrinth.index.json"), INDEX.as_bytes());
for (path, bytes) in entries {
write_file(&source.join("overrides").join(path), bytes);
}
let pack = root.join("pack.mrpack");
zip_dir(&source, &pack);
(root, pack)
}
/// 三个文件的包。
fn three_files(tag: &str) -> (TempRoot, PathBuf) {
mrpack(
tag,
&[
("mods/a.jar", b"pack a"),
("mods/b.jar", b"pack b"),
("mods/c.jar", b"pack c"),
],
)
}
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("写文件");
}
fn zip_dir(source: &Path, target: &Path) {
let file = std::fs::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");
}
/// 在**指定阶段的第一条进度事件**上置位取消(= 用户此刻点了取消)。
fn cancel_on(token: &ImportCancel, stage: ImportStage) -> ImportProgressSink {
let token = token.clone();
Arc::new(move |progress: &ImportProgress| {
if progress.stage == stage {
token.cancel();
}
})
}
/// 数**解压阶段**的进度条数,并在第一条上置位取消(这是解压循环检查点唯一的可观测判据:
/// 有它 ⇒ 第一条之后立刻停 = 1;删掉 ⇒ 一路解完 = 文件条数)。
fn cancel_on_first_extract_counting(
token: &ImportCancel,
) -> (ImportProgressSink, Arc<AtomicUsize>) {
let token = token.clone();
let count = Arc::new(AtomicUsize::new(0));
let seen = Arc::clone(&count);
let sink: ImportProgressSink = Arc::new(move |progress: &ImportProgress| {
if progress.stage != ImportStage::Extract {
return;
}
if seen.fetch_add(1, Ordering::SeqCst) == 0 {
token.cancel();
}
});
(sink, count)
}
/// 在**合并态第二条逐文件进度**上置位取消。合并是排序后逐文件搬:第 0 条是基线
/// (`.ull/pack-baseline.json`,`.` 排最前),第 1 条才是 `mods/a.jar`——`done == 1` 正好是
/// "用户那个被覆盖的文件已经搬进去、后面还有"的窗口,journal 回滚要接住的正是这一刻。
fn cancel_on_second_merged_file(token: &ImportCancel) -> ImportProgressSink {
let token = token.clone();
Arc::new(move |progress: &ImportProgress| {
if progress.stage == ImportStage::Commit && progress.done == 1 {
token.cancel();
}
})
}
/// 目标目录里全部文件的实例内相对路径(排序后):钉"取消后与导入前逐文件相同"。
fn files_under(root: &Path) -> Vec<String> {
let mut out = Vec::new();
let mut pending = vec![root.to_path_buf()];
while let Some(dir) = pending.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for entry in entries.filter_map(Result::ok) {
let path = entry.path();
if entry.file_type().map(|kind| kind.is_dir()).unwrap_or(false) {
pending.push(path);
continue;
}
let relative = path
.strip_prefix(root)
.map(|tail| tail.to_string_lossy().replace('\\', "/"))
.unwrap_or_default();
out.push(relative);
}
}
out.sort();
out
}
/// 父目录里有没有遗留的暂存目录(`.<name>.ull-import-<pid>-<seq>`)。
fn stray_staging(parent: &Path) -> Vec<String> {
std::fs::read_dir(parent)
.map(|entries| {
entries
.filter_map(Result::ok)
.map(|entry| entry.file_name().to_string_lossy().to_string())
.filter(|name| name.contains(".ull-import-"))
.collect()
})
.unwrap_or_default()
}
#[test]
fn a_cancel_before_the_first_write_touches_nothing() {
// 入口检查点:包还没读,目标目录还没建。
let (root, pack) = three_files("cancel-entry");
let target = root.join("instance");
let token = ImportCancel::new();
token.cancel();
let report = block_on(import_pack(
&ImportRequest::new(&pack, &target).with_cancel(&token),
));
assert_eq!(report.status, ImportStatus::Cancelled);
assert!(
report.error.is_none(),
"取消不是失败:{}",
report.status.as_str()
);
assert!(report.pack_name.is_none(), "停在入口 = 包都没读");
assert!(report.files.is_empty());
assert!(report.rolled_back, "一个字节都没写,目标目录与导入前一致");
assert!(!target.exists(), "目标目录不许出现");
assert!(
stray_staging(root.path()).is_empty(),
"{:?}",
stray_staging(root.path())
);
}
#[test]
fn a_cancel_inside_the_extract_loop_leaves_no_half_unpacked_pack() {
// 解压循环里的检查点:第一条进度事件上取消 ⇒ 第一条写进临时目录,第二条之前停下,
// 临时目录整个删掉 ⇒ 目标目录**不出现半成品**,报告里也不许有"已落地 N 条"的假账。
let (root, pack) = three_files("cancel-extract");
let target = root.join("instance");
let token = ImportCancel::new();
let (sink, extract_events) = cancel_on_first_extract_counting(&token);
let report = block_on(import_pack(
&ImportRequest::new(&pack, &target)
.with_cancel(&token)
.with_progress(sink),
));
assert_eq!(report.status, ImportStatus::Cancelled, "{:?}", report.error);
// **这一条才是解压循环检查点的判据**:有它 ⇒ 第一条之后立刻停(3 个文件的包里只报了 1 条
// 解压进度);删掉它 ⇒ 一路解完(3 条)。其余断言在删掉检查点后仍然成立,挡不住回归。
assert_eq!(
extract_events.load(Ordering::SeqCst),
1,
"取消必须在第一条解压之后立刻生效(包里有 3 个文件,解完再停 = 解压段没有检查点)"
);
assert!(report.error.is_none(), "取消不许带错误");
assert!(!target.exists(), "不许留下半包:{}", target.display());
assert!(
report.files.is_empty(),
"取消前的处理记录一条都没落地,报告不许冒充已装:{:?}",
report.files
);
assert_eq!(report.bytes_written, 0, "写入量要归零");
assert!(report.registered.is_none());
assert!(report.rolled_back);
assert!(
stray_staging(root.path()).is_empty(),
"{:?}",
stray_staging(root.path())
);
}
#[test]
fn a_cancel_in_the_middle_of_the_merge_restores_the_users_file() {
// 合并态(目标目录已有内容):提交是逐文件 rename + journal。取消落在两条搬动之间 ⇒
// **逆序回滚**必须把用户被覆盖的文件改回来,且不留备份、不留暂存目录。
let (root, pack) = three_files("cancel-merge");
let target = root.join("instance");
write_file(&target.join("mods/a.jar"), b"user a");
write_file(&target.join("mods/user.jar"), b"user only");
let token = ImportCancel::new();
let report = block_on(import_pack(
&ImportRequest::new(&pack, &target)
.with_cancel(&token)
// 覆盖档:让 a.jar 真的走"备份 + 覆盖"那条路,取消后必须恢复用户内容。
.with_conflict_policy(super::ConflictPolicy::Overwrite)
.with_progress(cancel_on_second_merged_file(&token)),
));
assert_eq!(report.status, ImportStatus::Cancelled, "{:?}", report.error);
assert!(report.error.is_none());
assert_eq!(
std::fs::read(target.join("mods/a.jar")).expect("用户文件要还在"),
b"user a",
"取消后必须恢复被覆盖的用户文件(回滚被删掉就会读到包内字节)"
);
assert_eq!(
std::fs::read(target.join("mods/user.jar")).expect("用户文件要还在"),
b"user only"
);
assert!(!target.join("mods/b.jar").exists(), "取消后不许有半包");
assert!(!target.join("mods/c.jar").exists(), "取消后不许有半包");
assert_eq!(
files_under(&target),
vec!["mods/a.jar".to_string(), "mods/user.jar".to_string()],
"目标目录必须与导入前**逐文件相同**(多出基线 / 少掉用户文件都是错)"
);
assert!(
std::fs::read_dir(target.join("mods"))
.expect("读 mods")
.filter_map(Result::ok)
.all(|entry| !entry.file_name().to_string_lossy().contains(".ullbak-")),
"备份必须被清掉,不许在实例里留 .ullbak-*"
);
assert!(
stray_staging(root.path()).is_empty(),
"{:?}",
stray_staging(root.path())
);
// 回滚做过,且完整 ⇒ 机器位与文案位都说"与导入前一致"。
assert!(report.rolled_back, "{:?}", report.rollback);
assert_eq!(
report
.rollback
.as_ref()
.map(super::stage::RollbackOutcome::failure_count),
Some(0)
);
}
#[test]
fn a_cancel_right_before_registering_rolls_the_whole_instance_back() {
// 登记前的检查点(**取消后不得登记实例**):取消在"内容已落盘、档案还没建"的窗口里落下
// ⇒ 连内容一起回滚。删掉这条检查,实例就会被登记上(本用例立刻红)。
let (root, pack) = three_files("cancel-register");
let target = root.join("instance");
let home = root.join("data");
let layout = crate::platform::layout::LauncherLayout::new(&home);
let token = ImportCancel::new();
let report = block_on(import_pack(
&ImportRequest::new(&pack, &target)
.with_cancel(&token)
.with_registration(&layout, Some("cancelled pack"))
.with_progress(cancel_on(&token, ImportStage::Register)),
));
assert_eq!(report.status, ImportStatus::Cancelled, "{:?}", report.error);
assert!(report.error.is_none());
assert!(report.registered.is_none(), "取消后不许登记实例");
let profiles = crate::game::install::instance::read_profiles(&layout).expect("读档案");
assert!(
profiles.profiles.is_empty(),
"profiles.json 里不许出现被取消的实例:{:?}",
profiles.profiles
);
assert!(!target.exists(), "内容已提交也要连内容一起回滚");
assert!(report.rolled_back);
assert!(
stray_staging(root.path()).is_empty(),
"{:?}",
stray_staging(root.path())
);
}
#[test]
fn cancel_and_failure_are_two_different_result_types() {
// 条款③:取消与真失败必须产生**不同的结果类型**(状态变体、错误位、退出码、文案 key、
// JSON 的 status 全不同)。把两条路合并成同一个错误,这里就红。
let (root, pack) = three_files("cancel-vs-fail");
let token = ImportCancel::new();
token.cancel();
let cancelled = block_on(import_pack(
&ImportRequest::new(&pack, &root.join("cancelled")).with_cancel(&token),
));
// 真失败:清单坏掉的包(开包成功、读清单失败)。
let broken = root.join("broken.zip");
{
let file = std::fs::File::create(&broken).expect("建 zip");
let mut writer = zip::ZipWriter::new(file);
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Stored);
writer.start_file("manifest.json", options).expect("写条目");
writer.write_all(b"{\"manifestType\":").expect("写内容");
writer.finish().expect("收尾");
}
let failed = block_on(import_pack(&ImportRequest::new(
&broken,
&root.join("failed"),
)));
assert_eq!(cancelled.status, ImportStatus::Cancelled);
assert_eq!(failed.status, ImportStatus::Failed);
assert!(cancelled.error.is_none(), "取消不是错误");
assert!(failed.error.is_some(), "失败必须带错误");
assert_ne!(
cancelled.status.exit_code(),
failed.status.exit_code(),
"退出码必须能区分「用户取消」与「装坏了」"
);
assert_eq!(cancelled.status.exit_code(), 130);
assert_eq!(failed.status.exit_code(), 1);
assert_ne!(cancelled.status.message_key(), failed.status.message_key());
let cancelled_json = serde_json::to_value(&cancelled).expect("序列化");
let failed_json = serde_json::to_value(&failed).expect("序列化");
assert_eq!(cancelled_json["status"], serde_json::json!("cancelled"));
assert_eq!(failed_json["status"], serde_json::json!("failed"));
assert!(
cancelled_json["error"].is_null(),
"取消的 JSON 里不许有 error:{}",
cancelled_json["error"]
);
}
mod report_shaping;
@@ -0,0 +1,111 @@
//! 取消报告的**三档诚实口径**:`ImportReport::cancel` 的直接用例(不需要跑管线)。
//!
//! 从 `cancel_tests.rs` 拆出来只为行数:那边装下五个端到端用例就够满了。
use crate::addon::import::report::{FileAction, FileSource, ImportReport, ImportedFile};
use crate::addon::import::stage::RollbackOutcome;
use crate::addon::import::ImportStatus;
fn base() -> ImportReport {
ImportReport::empty(std::path::PathBuf::from("instance"))
}
fn notes(report: &ImportReport) -> Vec<&str> {
report
.warnings
.iter()
.map(|note| note.message.as_str())
.collect()
}
#[test]
fn a_cancel_before_writing_never_claims_the_target_was_touched() {
let mut report = base();
report.cancel(RollbackOutcome::default());
assert_eq!(report.status, ImportStatus::Cancelled);
assert!(report.error.is_none(), "取消不许带错误");
assert!(report.rolled_back);
assert!(report.rollback.is_none(), "没做过回滚就不该有回滚记录");
let notes = notes(&report);
assert!(
notes.iter().any(|line| line.contains("未被写入")),
"{notes:?}"
);
assert!(
!notes.iter().any(|line| line.contains("恢复为导入前")),
"{notes:?}"
);
}
#[test]
fn a_cancel_after_commit_says_it_rolled_back_exactly_once() {
let mut report = base();
report.cancel(RollbackOutcome {
attempted: 2,
failed: Vec::new(),
});
assert_eq!(report.status, ImportStatus::Cancelled);
assert!(report.rolled_back);
assert_eq!(
report.rollback.as_ref().map(RollbackOutcome::failure_count),
Some(0)
);
let notes = notes(&report);
assert!(
notes.iter().any(|line| line.contains("恢复为导入前")),
"{notes:?}"
);
assert!(
!notes.iter().any(|line| line.contains("未被写入")),
"{notes:?}"
);
}
#[test]
fn an_incomplete_cancel_rollback_never_claims_consistency() {
let mut report = base();
report.cancel(RollbackOutcome {
attempted: 3,
failed: vec!["删除 config/a.toml 失败:拒绝访问".to_string()],
});
assert_eq!(report.status, ImportStatus::Cancelled);
assert!(!report.rolled_back, "回滚不完整时不许宣称一致");
let notes = notes(&report);
assert!(
notes
.iter()
.any(|line| line.contains("未能回滚") || line.contains("未完成")),
"{notes:?}"
);
assert!(
!notes
.iter()
.any(|line| line.contains("恢复为导入前的样子") || line.contains("未被写入")),
"{notes:?}"
);
}
#[test]
fn cancelling_clears_the_per_file_ledger_it_never_landed() {
// 取消前的 `files` 是"处理过"而不是"落地过":留着就是假账(UI 会照它画"已装 N 个")。
let mut report = base();
report.bytes_written = 4096;
report.files.push(ImportedFile {
path: "mods/a.jar".to_string(),
source: FileSource::Overrides,
sha1: None,
bytes: 4096,
disabled: false,
action: FileAction::Installed,
note: None,
});
report.cancel(RollbackOutcome::default());
assert!(report.files.is_empty(), "取消后不许留下「已落地」的账");
assert_eq!(report.bytes_written, 0);
assert_eq!(report.installed(), 0);
}
+333
View File
@@ -0,0 +1,333 @@
//! 远端条目补全(M162):**有 `path` 但缺 `hashes.sha1` 的 mrpack 条目,按 sha512 反查 sha1**。
//!
//! # 补什么、为什么补
//!
//! M160 的预检用 SHA-1 判「会不会覆盖用户文件」;mrpack 规范里 `sha1` 是**可选**的,只有
//! `sha512` 必填。于是「只有 path+downloads+hashes(sha512)」的条目在预检里判不了,只能记进
//! `ImportScan::unknown_remote`。现在拿 sha512 去 Modrinth 反查:站点认得这个文件就把**它自己
//! 记的 sha1** 填回来(同一个文件在同一站点上的官方摘要),补不出**保持缺**、照旧计数。
//!
//! # 三条边界
//!
//! 1. **只补 sha1 / 字节数 / 缺的直链**,绝不改写清单里已有的值(清单说多大就是多大);
//! 2. **CurseForge 条目不走这里**:它们连路径都没有,补全要 `projectID/fileID` 端点(需要 key),
//! 那条路仍是 `PackFetcher::curse_file` 的活;
//! 3. **只有「没联网」才立刻停**:第一条 [`FetchErrorKind::Offline`] 之后剩下的条目一律算
//! 「补不出」,不拿 N 次超时惩罚用户;**404 / 5xx 不停**——真实 mrpack 的文件大多托管在
//! CurseForge CDN,Modrinth 的 `version_file` 对它们必然 404,把「补不出」当「没联网」
//! 会让「能补就补」只在第一条恰好托管在 Modrinth 时成立(r1 P1-1 后果 B)。
//!
//! # 为什么要改 `plan.remote` 而不是 `manifest.files`
//!
//! 导入与预检都只读 `plan.remote`(`manifest` 保留原样作诊断),改一处即可两条路都生效,
//! 也不会让人误以为「清单被回写了」。
use super::fetch::FetchErrorKind;
use super::plan::ImportPlan;
use super::report::{keys, ImportIssue, ImportReport};
use super::ImportRequest;
/// 一次补全的结果。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub(crate) struct Completion {
/// 补出了 sha1 的条目数
pub(crate) filled: usize,
/// 仍然补不出的条目数(缺 sha512 / 站点查不到 / 没接源)
pub(crate) unfilled: usize,
/// 是否因为「没联网」提前收工
pub(crate) offline: bool,
}
impl Completion {
/// 补全结果 → 报告注记(`None` = 既没补到、也没因断网停下,不必占一行)。
///
/// `lead` 是场景前缀(导入用空串、预检用「预检前」):**文案只此一处**,两个入口不许
/// 各写一句——r1 抓到的「404 却写因未联网提前停止」正是两处文案各自演进的后果。
/// 现在 `offline` 只由真 `Offline` 置位,这句话只会在真断网时出现。
pub(crate) fn note(&self, lead: &str) -> Option<ImportIssue> {
if self.filled == 0 && !self.offline {
return None;
}
let mut message = if self.filled == 0 {
// 一条也没补到(只可能是断网):说「补全了 0 条」读起来像成功。
format!(
"{lead}按 sha512 从 Modrinth 补全 sha1:一条也没补到(补不出 {} 条)",
self.unfilled
)
} else {
format!(
"{lead}按 sha512 从 Modrinth 补全了 {} 条远端条目的 sha1(补不出 {} 条)",
self.filled, self.unfilled
)
};
if self.offline {
message.push_str(if self.filled == 0 {
";补全因未联网提前停止"
} else {
";补全因未联网提前停止,其余条目照旧按「缺省保留」处置"
});
}
Some(ImportIssue::new(keys::NOTE_REMOTE_COMPLETED, message))
}
}
/// 补全 + 把注记写进报告(导入管线唯一的入口:一行调用,文案与计数都在本模块)。
pub(crate) async fn complete_and_note(
request: &ImportRequest<'_>,
plan: &mut ImportPlan,
report: &mut ImportReport,
) {
let completion = complete(request, plan).await;
// 取消时这条注记会撒谎(剩下的条目**没试过**,不是"补不出"):取消收尾的注记已经说清
// 停在哪一步,这里不再补一句计数。
if request.cancel.is_cancelled() {
return;
}
if let Some(note) = completion.note("") {
report.warnings.push(note);
}
}
/// 跑一次补全(就地改 `plan.remote`)。
pub(crate) async fn complete(request: &ImportRequest<'_>, plan: &mut ImportPlan) -> Completion {
let mut state = Completion::default();
let Some(fetcher) = request.fetcher else {
state.unfilled = plan
.remote
.iter()
.filter(|entry| entry.path.is_some() && entry.file.sha1().is_none())
.count();
return state;
};
for entry in plan.remote.iter_mut() {
// 取消检查点:补全是**逐条网络请求**,条目之间是天然的落点;已补的只改内存,不写盘。
if request.cancel.is_cancelled() {
break;
}
if entry.file.sha1().is_some() {
continue;
}
// CF 条目(没有路径)不归这里:那是 `curse_file` 的活。
if entry.path.is_none() {
continue;
}
let Some(sha512) = entry.file.sha512().map(str::to_string) else {
state.unfilled += 1;
continue;
};
if state.offline {
state.unfilled += 1;
continue;
}
match fetcher.modrinth_file_by_sha512(&sha512).await {
Ok(meta) => {
// 站点给的 sha1 也要过形状检查:长度不对的「摘要」比没有更危险。
match meta.sha1.filter(|hex| is_sha1(hex)) {
Some(sha1) => {
entry.file.hashes.insert("sha1".to_string(), sha1);
if entry.file.size.is_none() {
entry.file.size = meta.size;
}
if entry.file.downloads.iter().all(|url| url.trim().is_empty()) {
if let Some(url) = meta.url {
entry.file.downloads.push(url);
}
}
state.filled += 1;
}
None => state.unfilled += 1,
}
}
Err(error) => {
// 只有**真正的「没联网」**才提前收工:404(这条不在 Modrinth 上,或托管在
// CF CDN)与 5xx 都只影响这一条,后面那些仍然可能补得出来。
if error.kind() == FetchErrorKind::Offline {
state.offline = true;
}
state.unfilled += 1;
}
}
}
state
}
/// 40 位十六进制。
fn is_sha1(hex: &str) -> bool {
hex.len() == 40 && hex.bytes().all(|byte| byte.is_ascii_hexdigit())
}
#[cfg(test)]
mod tests {
//! 覆盖 **pack 用例走不到**的两条分支(r3 复审指出的缺口):
//! `sha512` 缺失臂与「已有 downloads 不许追加第二个直链」。
//!
//! mrpack 的读者要求 `hashes.sha512` 必填,所以「有 path 但没有 sha512」在真实文件里进不来
//! ——它是**防御分支**。直接构造 `ImportPlan` 是唯一能让它变红的办法(不是放宽断言)。
use std::path::Path;
use std::sync::atomic::{AtomicU32, Ordering};
use super::{complete, Completion};
use crate::addon::import::fetch::{
FetchError, FetchFuture, FetchRequest, FetchedFile, ModrinthFileMeta, PackFetcher,
};
use crate::addon::import::plan::{ImportPlan, PlanRemote};
use crate::addon::import::report::FileSource;
use crate::addon::import::ImportRequest;
use crate::addon::pack::{PackFile, PackManifest};
use crate::download::blocking::block_on;
/// 只回答 `modrinth_file_by_sha512` 的假 fetcher(并记下被问了几次)。
struct Lookup {
meta: Result<ModrinthFileMeta, FetchError>,
asked: AtomicU32,
}
impl PackFetcher for Lookup {
fn modrinth_file_by_sha512<'a>(
&'a self,
_sha512: &'a str,
) -> FetchFuture<'a, Result<ModrinthFileMeta, FetchError>> {
Box::pin(async move {
self.asked.fetch_add(1, Ordering::SeqCst);
match &self.meta {
Ok(meta) => Ok(meta.clone()),
Err(error) => Err(error.clone()),
}
})
}
fn fetch_file<'a>(
&'a self,
_request: FetchRequest<'a>,
) -> FetchFuture<'a, Result<FetchedFile, FetchError>> {
Box::pin(async move { Err(FetchError::unsupported("本用例不下载")) })
}
}
fn plan_with(file: PackFile) -> ImportPlan {
ImportPlan {
format: "modrinth".to_string(),
manifest: PackManifest::default(),
mc_version: "1.20.1".to_string(),
loader: None,
version_id: "1.20.1".to_string(),
rejected: Vec::new(),
notes: Vec::new(),
overrides: Vec::new(),
remote: vec![PlanRemote {
file,
path: Some("mods/no-sha1.jar".to_string()),
source: FileSource::Modrinth,
disabled: false,
}],
max_memory_mb: None,
}
}
fn entry(sha512: Option<&str>, downloads: &[&str]) -> PackFile {
let mut file = PackFile::with_path("mods/no-sha1.jar");
file.downloads = downloads.iter().map(|url| (*url).to_string()).collect();
if let Some(sha512) = sha512 {
file.hashes.insert("sha512".to_string(), sha512.to_string());
}
file
}
fn meta(sha1: &str, url: &str) -> ModrinthFileMeta {
ModrinthFileMeta {
sha1: Some(sha1.to_string()),
size: Some(16),
url: Some(url.to_string()),
file_name: Some("no-sha1.jar".to_string()),
}
}
#[test]
fn an_entry_without_sha512_is_counted_not_guessed() {
// 防御分支:有 path、有 downloads、**没有 sha512** ⇒ 无从反查,如实计数、不发请求。
let lookup = Lookup {
meta: Ok(meta(&"a".repeat(40), "stub://from-source")),
asked: AtomicU32::new(0),
};
let mut plan = plan_with(entry(None, &["stub://original"]));
let request = ImportRequest::new(Path::new("pack.mrpack"), Path::new("instance"))
.with_fetcher(&lookup);
let summary = block_on(complete(&request, &mut plan));
assert_eq!(summary.filled, 0);
assert_eq!(summary.unfilled, 1, "缺 sha512 要计入「补不出」");
assert_eq!(
lookup.asked.load(Ordering::SeqCst),
0,
"没有摘要就不许发请求"
);
assert_eq!(plan.remote[0].file.sha1(), None, "补不出就是补不出,不许猜");
}
#[test]
fn an_existing_download_list_is_never_extended() {
// 已有直链 ⇒ 站点给的直链**不许**再追加一条(列表必须逐字相同)。
let lookup = Lookup {
meta: Ok(meta(&"a".repeat(40), "stub://from-source")),
asked: AtomicU32::new(0),
};
let mut plan = plan_with(entry(Some(&"c".repeat(128)), &["stub://original"]));
let request = ImportRequest::new(Path::new("pack.mrpack"), Path::new("instance"))
.with_fetcher(&lookup);
let summary = block_on(complete(&request, &mut plan));
assert_eq!(summary.filled, 1);
assert_eq!(plan.remote[0].file.sha1(), Some("a".repeat(40).as_str()));
assert_eq!(
plan.remote[0].file.downloads,
vec!["stub://original".to_string()],
"已有直链时不许追加第二个(长度与内容都要逐字相同)"
);
assert_eq!(lookup.asked.load(Ordering::SeqCst), 1);
}
#[test]
fn offline_with_nothing_filled_does_not_say_zero_items_were_filled() {
let note = Completion {
filled: 0,
unfilled: 3,
offline: true,
}
.note("预检前")
.expect("断网要留一条注记");
assert!(
note.message.contains("一条也没补到"),
"断网且一条没补到时不许说「补全了 0 条」:{}",
note.message
);
assert!(
!note.message.contains("补全了 0 条"),
"文案读起来像成功:{}",
note.message
);
assert!(
!note.message.contains("缺省保留"),
"预检场景没有「缺省保留」这回事:{}",
note.message
);
// 真的补到过东西时,仍是原来那句(并带上「其余条目按缺省保留」)。
let note = Completion {
filled: 2,
unfilled: 1,
offline: true,
}
.note("")
.expect("补到过就要留注记");
assert!(note.message.contains("补全了 2 条"));
assert!(note.message.contains("缺省保留"));
// 没补到、也没断网(例如所有摘要站点都不认得)⇒ 不占一行。
assert!(Completion {
filled: 0,
unfilled: 2,
offline: false
}
.note("")
.is_none());
}
}
+182
View File
@@ -0,0 +1,182 @@
//! 逐文件冲突决策:`path → Keep / Overwrite`,以及"没收到决策就等于保留"这条硬线。
//!
//! # 三个入口,一个通道
//!
//! | 用户在界面上点的 | 落到这里的形状 |
//! |---|---|
//! | 全部保留 | [`ConflictDecisions::keep_all`](= [`ConflictDecisions::default`]) |
//! | 全部覆盖 | [`ConflictDecisions::overwrite_all`] |
//! | 逐项 | [`ConflictDecisions::with`] 一条一条挂上去,整批档仍是"保留" |
//!
//! 三者共用一个类型,是因为它们要回答的是同一个问题:「这个路径上的冲突该怎么处置」。
//! 两条独立的落盘规则迟早会打架(一处按整批档、一处按逐项表),所以合并成一处。
//!
//! # 缺省为什么必须保留
//!
//! [`ConflictChoice::default`] 是 `KeepUser`:**表里没有这个路径**、或者调用方根本没设过决策时,
//! 结论是"用户那份文件留着"。这不是保守,是正确性——导入的输入是别人做的整合包,用户的
//! 配置/存档/自己改过的 mod 不能因为"我们没问"就消失。要看"覆盖"只有一个来路:调用方
//! **显式**说了覆盖(整批档或逐项决策)。
//!
//! # 决策只作用于**冲突**
//!
//! 非冲突路径(磁盘上没有、或内容与包内一致)根本不看这张表:没有冲突就没有"保留/覆盖"这个
//! 问题,`merge::decide` 的结论照旧。于是"表里没有这条"永远不会把一份该装的文件漏掉。
use std::collections::BTreeMap;
use super::report::ConflictChoice;
/// 一次导入的逐文件决策表。
///
/// 顺序稳定(`BTreeMap`),所以同一份决策序列化出来的形状是确定的——诊断与测试都能直接比。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConflictDecisions {
/// 表里没点名的冲突按它处置。
default: ConflictChoice,
/// 逐项决策(实例内相对路径 → 处置)。
entries: BTreeMap<String, ConflictChoice>,
}
impl Default for ConflictDecisions {
fn default() -> Self {
Self {
// **不许改**:这是"没收到决策不覆盖"的落点。
default: ConflictChoice::KeepUser,
entries: BTreeMap::new(),
}
}
}
impl ConflictDecisions {
/// 全部保留(= 缺省)。
#[must_use]
pub fn keep_all() -> Self {
Self::default()
}
/// 全部覆盖(用户显式点了「整合包说了算」)。
#[must_use]
pub fn overwrite_all() -> Self {
Self {
default: ConflictChoice::Overwrite,
entries: BTreeMap::new(),
}
}
/// 改整批缺省档(逐项决策优先于它)。
#[must_use]
pub fn with_default(mut self, choice: ConflictChoice) -> Self {
self.default = choice;
self
}
/// 挂一条逐项决策(链式)。
#[must_use]
pub fn with(mut self, path: impl Into<String>, choice: ConflictChoice) -> Self {
self.set(path, choice);
self
}
/// 挂一条逐项决策(就地)。
pub fn set(&mut self, path: impl Into<String>, choice: ConflictChoice) {
self.entries.insert(path.into(), choice);
}
/// 整批缺省档。
#[must_use]
pub const fn default_choice(&self) -> ConflictChoice {
self.default
}
/// 这个路径的处置:逐项决策优先,其次整批档(**整批档缺省是保留**)。
#[must_use]
pub fn choice_for(&self, path: &str) -> ConflictChoice {
self.entries.get(path).copied().unwrap_or(self.default)
}
/// 这个路径有没有被逐项点名(诊断用:区分"用户选的"与"缺省档")。
#[must_use]
pub fn explicit(&self, path: &str) -> Option<ConflictChoice> {
self.entries.get(path).copied()
}
/// 逐项决策的条数(不含整批缺省档)。
#[must_use]
pub fn len(&self) -> usize {
self.entries.len()
}
/// 有没有逐项决策。
#[must_use]
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
/// 逐项决策(按路径排序;进报告/日志用)。
pub fn iter(&self) -> impl Iterator<Item = (&str, ConflictChoice)> {
self.entries
.iter()
.map(|(path, choice)| (path.as_str(), *choice))
}
/// 逐项决策里"覆盖"的条数。
#[must_use]
pub fn overwrite_count(&self) -> usize {
self.entries
.values()
.filter(|choice| **choice == ConflictChoice::Overwrite)
.count()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn missing_decision_never_overwrites() {
// 这张表是"没收到决策就覆盖"的唯一防线:默认档与空表的答案都必须是保留。
assert_eq!(ConflictChoice::default(), ConflictChoice::KeepUser);
let empty = ConflictDecisions::default();
assert_eq!(empty.choice_for("config/a.toml"), ConflictChoice::KeepUser);
assert_eq!(
ConflictDecisions::keep_all().default_choice(),
ConflictChoice::KeepUser
);
assert!(empty.is_empty());
}
#[test]
fn per_item_wins_over_the_batch_default() {
let decisions =
ConflictDecisions::overwrite_all().with("mods/pin.jar", ConflictChoice::KeepUser);
assert_eq!(
decisions.choice_for("mods/pin.jar"),
ConflictChoice::KeepUser
);
assert_eq!(
decisions.choice_for("mods/other.jar"),
ConflictChoice::Overwrite
);
assert_eq!(
decisions.explicit("mods/pin.jar"),
Some(ConflictChoice::KeepUser)
);
assert_eq!(decisions.explicit("mods/other.jar"), None);
assert_eq!(decisions.len(), 1);
assert_eq!(decisions.overwrite_count(), 0);
}
#[test]
fn set_is_idempotent_per_path() {
let mut decisions = ConflictDecisions::keep_all();
decisions.set("a.toml", ConflictChoice::Overwrite);
decisions.set("a.toml", ConflictChoice::KeepUser);
assert_eq!(decisions.len(), 1);
assert_eq!(decisions.choice_for("a.toml"), ConflictChoice::KeepUser);
assert_eq!(decisions.overwrite_count(), 0);
let paths: Vec<&str> = decisions.iter().map(|(path, _)| path).collect();
assert_eq!(paths, vec!["a.toml"]);
}
}
+205
View File
@@ -0,0 +1,205 @@
//! 导入链路的**致命**失败分类(能容忍的缺文件不在这里,它们在
//! [`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::Offline
| 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()
}
}
+324
View File
@@ -0,0 +1,324 @@
//! 远端补全与下载的**注入点**:导入管线自己不碰网络,只认这个 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 里**唯一**的生产实现,做两件事:
//!
//! 1. **[`PackFetcher::fetch_file`]** —— 交给 [`crate::addon::online::download_verified`]:
//! sha1 由引擎流式校验、sha512 由 addon 侧落盘后复核(引擎的 `Integrity` 只认 Sha1/Sha256),
//! 失败按 [`crate::addon::online::RETRY_ATTEMPTS`] 重试且不留半截文件;
//! 2. **[`PackFetcher::modrinth_file_by_sha512`]** —— M162 的补全路径:按 sha512 反查
//! Modrinth 拿同一个文件的官方 sha1(站点不认得就如实报 `NotFound`,由调用方计数)。
//!
//! 两条都经同一个引擎(`EngineTransport` / `DownloadRequest`),导入链路里**没有**第二个
//! `reqwest::Client`,也没有第二个重试循环。
use std::future::Future;
use std::path::Path;
use std::pin::Pin;
use crate::addon::online::complete::identity_of;
use crate::addon::online::download::download_verified;
use crate::addon::online::modrinth::HashAlgorithm;
use crate::addon::online::{
EngineArtifactFetcher, EngineTransport, Modrinth, OnlineError, OnlineErrorKind,
};
use crate::download::{DownloadEngine, DownloadError, DownloadErrorKind};
/// "搞不到"的成因(**分级判据只在这里**)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FetchErrorKind {
/// 源上确实没有(404 / 文件被删)。
NotFound,
/// 网络侧失败(超时、连不上、站点 5xx 退避后仍失败)。
Network,
/// **没联网**:连不上任何候选源(与 `Network` 分开,是因为「补全」见到它要立刻停,
/// 而见到 404 / 5xx 不该停 —— 下一条可能补得出来)。
Offline,
/// 本地 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::Offline => "fetch_offline",
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::Offline | 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 {
// 用**叶子成因**(`effective_kind`):候选源循环结束时只有一条 `AllSourcesFailed`,
// 直接按它判定会把「这个文件不存在」也算成「网络失败」。
let kind = match error.effective_kind() {
DownloadErrorKind::NotFound => FetchErrorKind::NotFound,
DownloadErrorKind::Integrity => FetchErrorKind::Integrity,
DownloadErrorKind::Io => FetchErrorKind::Io,
DownloadErrorKind::InvalidRequest => FetchErrorKind::Unsupported,
DownloadErrorKind::Timeout | DownloadErrorKind::Network => FetchErrorKind::Offline,
DownloadErrorKind::ServerError
| DownloadErrorKind::RangeUnsupported
| DownloadErrorKind::AllSourcesFailed => FetchErrorKind::Network,
};
Self::new(kind, error.message)
}
}
impl From<OnlineError> for FetchError {
fn from(error: OnlineError) -> Self {
let kind = match error.kind() {
OnlineErrorKind::NotFound => FetchErrorKind::NotFound,
OnlineErrorKind::Integrity | OnlineErrorKind::Malformed => FetchErrorKind::Integrity,
OnlineErrorKind::Io => FetchErrorKind::Io,
OnlineErrorKind::Unsupported => FetchErrorKind::Unsupported,
// 「没联网」要能被「补全立刻停」认出来;站点 5xx 只是这一条补不出。
OnlineErrorKind::NotConnected => FetchErrorKind::Offline,
OnlineErrorKind::Server => FetchErrorKind::Network,
};
Self::new(kind, error.message().to_string())
}
}
/// 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>,
}
/// Modrinth 一个文件的元数据(`files[]` 补全的产物,按 sha512 反查得到)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ModrinthFileMeta {
/// 站点给的 SHA-1(**同一个文件**的官方摘要,不是本地推算)
pub sha1: Option<String>,
/// 站点给的字节数
pub size: Option<u64>,
/// 站点给的直链(清单里没有 downloads 时才用得上)
pub url: Option<String>,
/// 站点给的文件名
pub file_name: Option<String>,
}
/// 一次文件下载请求。
#[derive(Debug, Clone, Copy)]
pub struct FetchRequest<'a> {
/// 逻辑下载地址(官方源)。
pub url: &'a str,
/// 落盘路径(导入器给的**暂存**路径,不是最终实例路径)。
pub dest: &'a Path,
/// 期望 SHA-1(清单声明的)。
pub sha1: Option<&'a str>,
/// 期望 SHA-512(mrpack 的必填摘要;引擎不认这一档,由 `online::download` 落盘后复核)。
pub sha512: 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});当前版本没有,本条记为缺文件"
)))
})
}
/// 按 SHA-512 反查 Modrinth 上这个文件的元数据(**sha1 补全用**)。
///
/// 默认实现返回 [`FetchErrorKind::Unsupported`]:没有远程源(离线导入、测试的 `StubFetcher`)
/// 就补不出——这与 M160 的 `unknown_remote` 口径一致:补不出仍要如实计数,**不许猜**。
fn modrinth_file_by_sha512<'a>(
&'a self,
sha512: &'a str,
) -> FetchFuture<'a, Result<ModrinthFileMeta, FetchError>> {
Box::pin(async move {
Err(FetchError::unsupported(format!(
"没有接 Modrinth 源,无法按 sha512 补全这一条({sha512})"
)))
})
}
/// 把 `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 modrinth_file_by_sha512<'a>(
&'a self,
sha512: &'a str,
) -> FetchFuture<'a, Result<ModrinthFileMeta, FetchError>> {
Box::pin(async move {
let transport = EngineTransport::new(self.engine);
let client = Modrinth::new(&transport);
let version = client
.version_by_hash(HashAlgorithm::Sha512, sha512)
.await
.map_err(FetchError::from)?;
let identity = identity_of(&version, sha512).ok_or_else(|| {
FetchError::new(
FetchErrorKind::NotFound,
format!("站点返回的版本里没有 sha512 与 {sha512} 相同的文件"),
)
})?;
Ok(ModrinthFileMeta {
sha1: Some(identity.sha1),
size: identity.size,
url: identity.url,
file_name: version
.primary_file()
.map(|file| file.filename.clone())
.filter(|name| !name.trim().is_empty()),
})
})
}
fn fetch_file<'a>(
&'a self,
request: FetchRequest<'a>,
) -> FetchFuture<'a, Result<FetchedFile, FetchError>> {
Box::pin(async move {
// sha1 交给引擎流式校验、sha512 由 `download_verified` 落盘后复核;
// 失败重试与「不留半截文件」也在那一层(见 `online::download` 的模块文档)。
let fetcher = EngineArtifactFetcher::new(self.engine);
let done = download_verified(
&fetcher,
request.url,
request.sha1,
request.sha512,
request.size,
request.dest,
)
.await
.map_err(FetchError::from)?;
Ok(FetchedFile { bytes: done.bytes })
})
}
}
+143
View File
@@ -0,0 +1,143 @@
//! 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` | 用户改过:**不许**静默覆盖 |
//!
//! 两条"不许"就是任务里的硬要求("不得直接盖掉用户改过的文件")。判据只此一处:决策
//! (逐项 [`super::decision::ConflictDecisions`],或整批 [`super::ConflictPolicy`])为
//! `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),
// 磁盘上有、内容与包内**逐字节相同**(同一份 SHA-1)。
//
// 这是"内容相同"的**唯一判据**:预检(`super::scan::judge`)刻意不再自己写一遍
// `disk == pack` 的早退(复审 r1 P3-2),而是走这里的 `reason == None`。改这一行
// 会同时影响"导入怎么处置"与"预检列不列这条",所以两边的测试都会红。
(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
);
}
}
+364
View File
@@ -0,0 +1,364 @@
//! 两个落盘阶段的实现:**覆盖内容解压** 与 **远端补全下载**(共用同一张三方合并表)。
//!
//! 拆出来是为了让 `pipeline.rs` 只剩"顺序与收尾":阶段实现里看得见"每个文件怎么判",
//! 编排里看得见"什么时候回滚"。
use std::path::Path;
use super::super::pack::{PackArchive, PackError};
use super::baseline::PackBaseline;
use super::cancel::Stopped;
use super::error::ImportError;
use super::fetch::{FetchError, FetchRequest};
use super::merge;
use super::plan::{curse_class_dir, ImportPlan, PlanRemote};
use super::report::tell;
use super::report::{ImportReport, ImportStage, ImportedFile, MissingFile, MissingReason};
use super::stage::Staging;
use super::ImportRequest;
use crate::download::verify::{self, digest_bytes};
use crate::download::HashAlgo;
mod settle;
use settle::{conflict, settle};
/// 阶段一:覆盖条目落暂存 + 三方合并。
///
/// 被同包另一条声明遮蔽的条目([`super::plan::PlanOverride::shadowed`])**不读字节、不落地**:
/// 它的结论已经由 `plan::layer::resolve` 算成一条 `NOTE_OVERRIDE_SHADOWED` 注记
/// (进 `report.warnings`),而这条路径的行由真正的胜者给出——一路径一行,不再出现
/// "两条 Installed、前一条的哈希其实没落地"。
///
/// # 取消检查点
///
/// 解压阶段**每条一次**(循环开头):只写临时目录,取消 = 临时目录整个删掉,目标目录一个
/// 字节都没动。被 `?` 冒泡出去的 [`Stopped::Cancelled`] 与失败**不是同一条路**。
pub(crate) async fn extract_overrides(
request: &ImportRequest<'_>,
archive: &PackArchive,
plan: &ImportPlan,
baseline: &PackBaseline,
staging: &Staging,
report: &mut ImportReport,
next: &mut PackBaseline,
) -> Result<(), Stopped> {
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() {
// 取消检查点(每条一次):只写临时目录,停下就是"什么都没发生"。
request.cancel.stopped()?;
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());
// 逐项决策优先,其次整批档(缺省是"保留"):见 `decision::ConflictDecisions`。
let picked = request.decisions.choice_for(&entry.path);
let (action, choice) = settle(decision, disk.is_some(), picked);
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(())
}
/// 阶段二:远端条目补全 + 下载 + 同一张合并表。
///
/// # 取消检查点
///
/// 每条远端条目一次(循环开头)。**单条下载在飞不可中断**([`super::fetch::PackFetcher`] 没有
/// 取消参数),所以取消在最坏情况下要等"当前这一条下载返回";返回后下一次检查立刻生效,
/// 已下到临时目录的字节随临时目录一起删掉。
pub(crate) async fn fetch_remote(
request: &ImportRequest<'_>,
plan: &ImportPlan,
baseline: &PackBaseline,
staging: &Staging,
report: &mut ImportReport,
next: &mut PackBaseline,
) -> Result<(), Stopped> {
let total = plan.remote.len();
let target = staging.target().to_path_buf();
for (index, entry) in plan.remote.iter().enumerate() {
// 取消检查点(每条一次):上一条下载返回之后立刻生效。
request.cancel.stopped()?;
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 picked = request.decisions.choice_for(&resolved.path);
let (action, choice) = settle(decision, disk.is_some(), picked);
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(),
sha512: resolved.sha512.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
))
.into());
}
}
let decision = merge::decide(&pack_sha1, baseline.sha1(&resolved.path), disk.as_deref());
let picked = request.decisions.choice_for(&resolved.path);
let (action, choice) = settle(decision, disk.is_some(), picked);
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>, // 两档摘要各自可能缺(CF 只有 sha1、mrpack 可能只有 sha512)
sha512: 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),
sha512: entry.file.sha512().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,
// CF 官方只给 sha1,这一档没有可核的期望值。
sha1: meta.sha1,
sha512: None,
size: meta.size,
}))
}
/// 组装一条缺失记录(条目本身补不出来)。
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,61 @@
//! 合并判定 + 决策 → 实际动作,以及冲突记录的组装。
//!
//! 从 [`super`] 拆出来的唯一原因是行数上限(400 行),行为逐字未变:这两个函数是**纯函数**,
//! 不碰盘、不看时钟,所以搬动它们不影响任何判据。
use super::super::merge::{self, MergeAction};
use super::super::report::{ConflictChoice, ConflictFile, ConflictReason, FileAction};
/// 合并判定 + 决策 → 实际动作。
///
/// `picked` 是这一次的处置(逐项决策或整批档,见 [`crate::addon::import::decision::ConflictDecisions`]):
/// 它**只对冲突生效**——`Install` / `SkipIdentical` 不受它影响,所以"用户点错一条保留"不会漏装。
pub(super) fn settle(
decision: merge::MergeDecision,
disk_exists: bool,
picked: ConflictChoice,
) -> (FileAction, ConflictChoice) {
let overwrite = picked == ConflictChoice::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),
}
}
/// 组装一条冲突记录。
pub(super) 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,
}
}
+389
View File
@@ -0,0 +1,389 @@
//! 导入编排:**读取清单 → 解压覆盖内容 → 补全下载 → 落地 → 登记**,每步都可中断、可失败、可重试。
//!
//! # 两阶段落盘(任务硬要求)
//!
//! 先落 `overrides/`(离线、包内自带),再去补远端文件。顺序不是审美问题:
//!
//! - 覆盖内容是最可能被用户改过的那部分——先判定冲突,才能在"用户改过"时**既不覆盖**也不
//! 白下载同名文件(远端条目走同一张三方合并表,冲突就直接跳过下载);
//! - 远端补全是唯一会失败于网络的一步,放最后意味着"网络不通"最多让结果变成 `Partial`,
//! 不会让已经解好的覆盖内容白干。
//!
//! # 失败分级(判据只在一处:是不是"某个文件搞不到")
//!
//! | 失败 | 结果 |
//! |------|------|
//! | 开包/探测/读清单/组计划失败(穿越、符号链接、限额、清单坏) | `Failed`,临时目录删掉,目标目录零残留 |
//! | 某条远端文件摸不到(离线 / CF 无客户端 / 404) | `Partial`,逐条进 `missing`,实例照装 |
//! | 下载到的内容与声明摘要不符 | `Failed`(这是**损坏或被换过**,不是"暂时搞不到") |
//! | 本地写盘失败 | `Failed` |
//! | 实例登记失败 | `Failed`(连内容一起回滚,不留"有目录没档案"的半个实例;回滚不完整时如实列出) |
//!
//! **回滚也要如实**:回滚本身可能失败(文件被占用、权限),这时 `rolled_back` 为 `false`
//! 并在 `rollback.failed` 里逐项列出——不许在"可能没回干净"时宣称一致。
//!
//! # 可重试
//!
//! 失败后目标目录与导入前一致(或明确告诉你哪几项没回去),重跑同一个包是安全的;成功后再跑
//! 一遍会靠基线里的哈希走"跳过 / 已一致"路径,不会重复写盘。
//!
//! # 取消(M195):七个检查点
//!
//! 编排层负责**阶段边界**的检查点(入口 → 解析后 → 补全后 → 开暂存目录前 → 提交前 → 登记前),
//! 长循环里的**逐条**检查在各自实现里(解压/下载在 [`super::phase`],合并落盘在
//! [`super::stage`])。逐段表(含"哪几段没有检查点、为什么")见 [`super::cancel`]。
//! 每次停下都按"回滚做过什么"收尾:没写盘 ⇒ 删暂存目录;写过 ⇒ 回滚结果如实进报告。
//! **取消不走 [`failed`]**:它走 [`ImportReport::cancel`],结果类型是 `Cancelled`
//! 而不是 `Failed`。
use super::baseline::PackBaseline;
use super::cancel::Stopped;
use super::error::ImportError;
use super::phase;
use super::plan::ImportPlan;
use super::prepare::{account_plan, probe_and_plan, write_baseline};
use super::report::{keys, tell, ImportIssue, ImportReport, ImportStage, RegisteredInstance};
use super::stage::{CommitStop, RollbackOutcome, Staging};
use super::ImportRequest;
/// 跑一次导入。**永不返回 `Err`**:成功 / 部分成功 / 失败 / 取消全在报告里。
pub async fn run(request: &ImportRequest<'_>) -> ImportReport {
let mut report = ImportReport::empty(request.target.to_path_buf());
// 检查点①:入口(用户可能在上一次点开面板之后、线程起来之前就取消了)。
if request.cancel.is_cancelled() {
return cancelled(report, RollbackOutcome::default());
}
tell(&request.progress, ImportStage::Probe, 0, 0, None);
let (archive, mut plan) = match probe_and_plan(request) {
Ok(pair) => pair,
Err(error) => return abandoned(report, error),
};
account_plan(&mut report, &plan);
// 检查点②:解析后(包读完了,目标目录还一个字节都没写——停下最便宜)。
if request.cancel.is_cancelled() {
return cancelled(report, RollbackOutcome::default());
}
super::complete::complete_and_note(request, &mut plan, &mut report).await;
// 检查点③:补全后(补全是逐条网络请求,条目之间已经在 `complete` 里停过一次)。
if request.cancel.is_cancelled() {
return cancelled(report, RollbackOutcome::default());
}
let baseline = {
let (baseline, warnings) = load_baseline(request.target, &plan);
report.warnings.extend(warnings);
baseline
};
// 检查点④:开暂存目录前(此后磁盘上就有临时目录了)。
if request.cancel.is_cancelled() {
return cancelled(report, RollbackOutcome::default());
}
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)?;
Ok::<(), Stopped>(())
}
.await;
match staged {
Ok(()) => {}
// 停在暂存阶段:临时目录整个删掉,目标目录与导入前一致(一个字节都没写过)。
Err(Stopped::Cancelled) => {
staging.abort();
return cancelled(report, RollbackOutcome::default());
}
Err(Stopped::Failed(error)) => {
staging.abort();
return abandoned(report, error);
}
}
// 检查点⑤:提交前(与"清中间产物"同一条边界)。
if request.cancel.is_cancelled() {
staging.abort();
return cancelled(report, RollbackOutcome::default());
}
// 引擎的续传残片(`.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);
// 检查点⑥:落盘(全新导入只在 rename 之前;合并态在 `apply_merge` 里逐文件)。
let token = request.cancel.clone();
let guard = match staging.commit(&token, &request.progress) {
Ok(guard) => guard,
Err(CommitStop::Cancelled(outcome)) => return cancelled(report, outcome),
Err(CommitStop::Failed(failure)) => {
return record_rollback(failed(report, failure.error), failure.rollback);
}
};
if let Some(layout) = request.register {
tell(&request.progress, ImportStage::Register, 0, 0, None);
// 检查点⑦:登记前。内容已经落进目标目录,所以取消要**连内容一起回滚**,否则留下
// "有目录没档案"的半个实例——这正是"取消后不得登记实例"的判据。
if request.cancel.is_cancelled() {
let outcome = guard.rollback();
return cancelled(report, outcome);
}
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
}
/// 取消收尾:把「用户取消」写进报告(**不是失败**),返回收好尾的报告。
///
/// 三档注记与 `rolled_back` 的判据在 [`ImportReport::cancel`](那里也是失败路径的同形三档)。
fn cancelled(mut report: ImportReport, outcome: RollbackOutcome) -> ImportReport {
report.cancel(outcome);
report
}
/// 读三方合并的 base 侧:没有 / 读不出来 / **属于别的包** 三条路都不致命。
///
/// "属于别的包"要单独说一句:同一目录先后导入两个整合包时,若拿别人的基线当 base,用户改过
/// 的文件会被判成"整合包升级"而**被静默覆盖**。所以身份不符时降级成"没有基线"并告警。
///
/// 返回的告警由调用方写进报告(预检也是同一批文案——两侧对"有没有基线"必须有同一个答案)。
pub(super) fn load_baseline(
target: &std::path::Path,
plan: &ImportPlan,
) -> (PackBaseline, Vec<ImportIssue>) {
match PackBaseline::load(target) {
Ok(baseline) => {
if baseline.matches_pack(&plan.format, &plan.manifest.name) {
return (baseline, Vec::new());
}
let warning = ImportIssue::new(
keys::NOTE_BASELINE_PACK_MISMATCH,
format!(
"基线属于另一个整合包({} / {:?}),本次按「没有基线」处理:磁盘上同名文件一律算用户已有内容",
if baseline.format.is_empty() {
"?"
} else {
&baseline.format
},
baseline.pack_name
),
);
(PackBaseline::default(), vec![warning])
}
Err(reason) => {
let warning = ImportIssue::new(
keys::NOTE_BASELINE_UNREADABLE,
format!(
"基线读不出来,本次按「没有基线」处理(磁盘上同名文件一律算用户已有内容):{reason}"
),
);
(PackBaseline::default(), vec![warning])
}
}
}
/// 把一次回滚的结果写进报告:**文案与机器位同源**,只有完整回滚才宣称"与导入前一致"。
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
}
/// 失败收尾:**只落错误,不宣称目标目录状态**。
///
/// 口径由调用方补:从未写过目标目录 ⇒ [`abandoned`];已经写过 ⇒ [`record_rollback`]。
/// 早先这里无条件推"目标目录保持导入前的样子",而随后的 `record_rollback` 可能再推
/// "有 N 项未能回滚"——同一份 warnings 自相矛盾(复审 r6 抓到)。
fn failed(mut report: ImportReport, error: ImportError) -> ImportReport {
report.rolled_back = false;
report.fail(error);
report
}
/// 失败发生在**碰目标目录之前**(暂存目录已清):这时才可以宣称"目标目录未被写入"。
fn abandoned(report: ImportReport, error: ImportError) -> ImportReport {
let mut report = failed(report, error);
report.rolled_back = true;
report.warnings.push(ImportIssue::new(
keys::ROLLBACK_DONE,
format!(
"导入失败,已清理临时目录;目标目录未被写入({})",
report.target.display()
),
));
report
}
#[cfg(test)]
mod tests {
use super::*;
use crate::addon::import::ImportStatus;
fn base() -> ImportReport {
ImportReport::empty(std::path::PathBuf::from("instance"))
}
fn messages(report: &ImportReport) -> Vec<&str> {
report
.warnings
.iter()
.map(|note| note.message.as_str())
.collect()
}
fn incomplete() -> RollbackOutcome {
RollbackOutcome {
attempted: 3,
failed: vec!["删除 config/a.toml 失败:拒绝访问".to_string()],
}
}
#[test]
fn incomplete_rollback_never_claims_the_target_is_untouched() {
// 复审 r6 第 1 条:机器位(rolled_back=false)诚实,文案位也必须诚实——同一份 warnings
// 里不许同时出现"保持导入前的样子"与"有 N 项未能回滚"。
let report = record_rollback(failed(base(), ImportError::fetch("提交失败")), incomplete());
assert_eq!(report.status, ImportStatus::Failed);
assert!(!report.rolled_back, "回滚不完整时不许宣称一致");
assert_eq!(
report.rollback.as_ref().map(RollbackOutcome::failure_count),
Some(1)
);
let messages = messages(&report);
assert!(
messages
.iter()
.any(|line| line.contains("未能回滚") && line.contains("config/a.toml")),
"失败项要逐条列出:{messages:?}"
);
assert!(
!messages.iter().any(|line| line.contains("保持导入前的样子")
|| line.contains("未被写入")
|| line.contains("恢复为导入前的样子")),
"回滚不完整却宣称一致(自相矛盾):{messages:?}"
);
}
#[test]
fn complete_rollback_claims_consistency_exactly_once() {
let report = record_rollback(
failed(base(), ImportError::fetch("提交失败")),
RollbackOutcome {
attempted: 2,
failed: Vec::new(),
},
);
assert!(report.rolled_back);
assert_eq!(
report.rollback.as_ref().map(RollbackOutcome::failure_count),
Some(0)
);
let messages = messages(&report);
assert!(
messages
.iter()
.any(|line| line.contains("恢复为导入前的样子")),
"{messages:?}"
);
assert!(
!messages.iter().any(|line| line.contains("未能回滚")),
"{messages:?}"
);
}
#[test]
fn pre_commit_failure_claims_untouched_only_via_abandoned() {
// `failed()` 本身不宣称任何目标目录状态;"从未写过目标目录"这条口径只由 `abandoned()` 给。
let bare = failed(base(), ImportError::fetch("开包失败"));
assert!(!bare.rolled_back);
assert!(bare.rollback.is_none());
assert!(!messages(&bare)
.iter()
.any(|line| line.contains("保持导入前的样子") || line.contains("未被写入")));
let report = abandoned(base(), ImportError::fetch("开包失败"));
assert!(report.rolled_back);
assert!(report.rollback.is_none());
let messages = messages(&report);
assert!(
messages.iter().any(|line| line.contains("未被写入")),
"{messages:?}"
);
}
}
+371
View File
@@ -0,0 +1,371 @@
//! 清单 → **导入计划**:把三种格式的差异在进解压器之前全部拍平。
//!
//! 计划是纯计算(只额外读一次 `instance.cfg`),产出四张表:覆盖条目、远端条目、
//! 加载器计划输入、被拒绝的 component。落盘与合并都不在这里。
//!
//! # 三种格式的语义在这层定死
//!
//! | 事 | CurseForge | Modrinth | MultiMC / Prism |
//! |----|-----------|----------|-----------------|
//! | 覆盖目录 | 清单点名的**一个**目录(任意名字) | `overrides/` + `client-overrides/`(`server-overrides/` **不解压**) | `.minecraft/` |
//! | 同名冲突 | 不存在(单目录) | `client-overrides` **胜** `overrides`(Modrinth 官方客户端语义;与 HMCL 相反) | 不存在 |
//! | 远端条目 | `projectID/fileID`,**无路径无地址** ⇒ 必须补全 | 自带 `path`/`downloads`/`hashes` | 没有远端条目(内容就是目录) |
//! | 加载器 | `modLoaders[].id`(`neoforge-1.20.1-47.1.106` 的版本段**带 MC 前缀**) | `dependencies` 的键 | `components[].uid` |
//! # 三处"不许自创"的边界
//!
//! 1. **覆盖目录的优先级只按侧**:`overrides/` 先、`client-overrides/` 后(后者胜)。同一
//! 目录内的条目顺序**不重要**——M110 的 [`crate::addon::pack::PackOverrides::files`] 按路径
//! 排序,zip 条目顺序没有保留;而同一目录内路径唯一,所以"按 zip 条目顺序落盘"与"按路径
//! 排序落盘"在无重复时等价。有重复(跨目录)时由侧优先级决定,不靠条目顺序。
//! **同一实例路径被多份声明命中时逐条算清楚**([`PlanOverride::shadowed`]):胜者按上面
//! 这条规则定,被遮蔽的那条不再读字节、只留一条 `NOTE_OVERRIDE_SHADOWED` 注记说明
//! "谁赢了"和"两份内容是否逐字节相同"。这是"整包一个策略"到"逐文件事实"的那一步。
//! 2. **`.disabled` 只认路径后缀**(官方 Modrinth 表达),`.old` **只识别与保留**(不清不删
//! 不改名)。`env.client=optional` **不是**禁用("可选"=安装时可取消勾选)。判据在
//! [`paths`]。
//! 3. **MultiMC 的非加载器 component 逐项进报告**(`org.lwjgl3` / `net.fabricmc.intermediary`
//! / 社区自定义 uid):静默丢它们等于"看起来装好了、其实少了补丁层"。
mod layer;
pub mod paths;
pub use layer::Shadowed;
pub use paths::{curse_class_dir, strip_mc_prefix, suffix_note, suffix_variant, SuffixVariant};
use super::super::pack::{
curseforge, multimc, EnvSupport, PackArchive, PackError, PackFile, PackLoader, PackManifest,
PackSide,
};
use super::error::ImportError;
use super::report::{keys, FileSource, ImportIssue, LoaderTarget, RejectedComponent};
/// 一条要落地的覆盖条目。
#[derive(Debug, Clone)]
pub struct PlanOverride {
/// 包内目录前缀(读包时拼 `<dir>/<path>`)。
pub dir: String,
/// 实例内相对路径。
pub path: String,
/// 来源侧。
pub source: FileSource,
/// 包内实际 SHA-1(M110 读包时现算)。
pub sha1: String,
/// 字节数。
pub size: u64,
/// 是否禁用态(路径后缀)。
pub disabled: bool,
/// 可选注记(`.disabled` / `.old`)。
pub note: Option<ImportIssue>,
/// 被同包另一条声明遮蔽时的事实(`None` = 本条目就是这条路径的最终胜者)。
pub shadowed: Option<Shadowed>,
}
/// 一条要补全/下载的远端条目。
#[derive(Debug, Clone)]
pub struct PlanRemote {
/// 原清单条目(摘要表 / downloads / env / project/file id 都在里面)。
pub file: PackFile,
/// 已知的实例内相对路径(mrpack 有;CF 需要补全才有)。
pub path: Option<String>,
/// 来源。
pub source: FileSource,
/// 是否禁用态(路径后缀)。
pub disabled: bool,
}
/// 一次导入的计划。
#[derive(Debug, Clone)]
pub struct ImportPlan {
/// 命中的格式 id。
pub format: String,
/// 原始模型(`extra` 等诊断信息留着)。
pub manifest: PackManifest,
/// 游戏版本。
pub mc_version: String,
/// 加载器安装计划输入(`None` = 纯原版)。
pub loader: Option<LoaderTarget>,
/// 实例绑定版本 id。
pub version_id: String,
/// 不支持的 component(逐项)。
pub rejected: Vec<RejectedComponent>,
/// 注记。
pub notes: Vec<ImportIssue>,
/// 覆盖条目(已按侧优先级排好、已剔除 `server-overrides`)。
pub overrides: Vec<PlanOverride>,
/// 远端条目。
pub remote: Vec<PlanRemote>,
/// `instance.cfg` 里受开关门控的内存上限(MB)。
pub max_memory_mb: Option<u64>,
}
impl ImportPlan {
/// 组计划。
///
/// # Errors
///
/// 清单缺游戏版本、加载器条目缺版本号、MultiMC 的 `formatVersion != 1` 等"清单自身非法"
/// 的情形返回 [`ImportError`](调用方据此整包回滚)。"某条文件缺地址"**不是**错误。
pub fn build(
pack: &PackArchive,
format_id: &str,
manifest: PackManifest,
) -> Result<Self, ImportError> {
let mut notes: Vec<ImportIssue> = Vec::new();
let mut rejected: Vec<RejectedComponent> = Vec::new();
// Prism 的规矩(A 级来源 `PackProfile.cpp:207-210`):`formatVersion` 必须恰为 1,
// 否则**整份丢弃**——不是降级解析。M110 的读者把缺省值当 1(老包不写这一行),
// 因此这里只拒"写了非 1 的值"。
if format_id == multimc::ID && manifest.format_version != Some(1) {
return Err(ImportError::from(PackError::metadata(format!(
"{} 的 formatVersion 是 {:?},必须恰为 1(Prism 自己也是整份丢弃,不降级解析)",
multimc::METADATA_ENTRY,
manifest.format_version
))));
}
let mc_version = manifest
.minecraft
.as_deref()
.map(str::trim)
.filter(|version| !version.is_empty())
.map(str::to_string)
.ok_or_else(|| {
ImportError::from(PackError::metadata(
"清单里没有游戏版本(CF minecraft.version / mrpack dependencies.minecraft / MultiMC net.minecraft 组件)",
))
})?;
let loader = select_loader(&manifest, &mc_version, format_id, &mut notes, &mut rejected)?;
let version_id = loader
.as_ref()
.map_or_else(|| mc_version.clone(), |target| target.version_id.clone());
let mut overrides: Vec<PlanOverride> = Vec::new();
let mut skipped_server_files = 0_usize;
for group in layer::ordered(&manifest) {
if group.side == PackSide::Server {
// Modrinth 官方客户端安装器就不解压 server-overrides/(A 级来源:
// install_mrpack.rs 的 strip_prefix 链只有 overrides/ 与 client-overrides/)。
skipped_server_files += group.files.len();
continue;
}
for file in &group.files {
let variant = suffix_variant(&file.path);
overrides.push(PlanOverride {
dir: group.dir.clone(),
path: file.path.clone(),
source: FileSource::from_side(group.side),
sha1: file.sha1.clone(),
size: file.size,
disabled: variant.is_disabled(),
note: suffix_note(variant, &file.path),
shadowed: None,
});
}
}
layer::resolve(&mut overrides, &mut notes);
if skipped_server_files > 0 {
notes.push(ImportIssue::new(
keys::NOTE_SERVER_OVERRIDES_SKIPPED,
format!(
"server-overrides/ 里的 {skipped_server_files} 个文件是服务端内容,客户端导入不解压(Modrinth 官方语义)"
),
));
}
let mut remote: Vec<PlanRemote> = Vec::new();
let source = if format_id == curseforge::ID {
FileSource::CurseForge
} else {
FileSource::Modrinth
};
for file in &manifest.files {
if file.client == EnvSupport::Unsupported {
notes.push(
ImportIssue::new(
keys::NOTE_ENV_UNSUPPORTED,
"env.client=unsupported:客户端不需要这个文件,跳过".to_string(),
)
.with_path(file.path.clone().unwrap_or_else(|| file.key())),
);
continue;
}
let variant = file
.path
.as_deref()
.map_or(SuffixVariant::Plain, suffix_variant);
if file.client == EnvSupport::Optional {
notes.push(
ImportIssue::new(
keys::NOTE_OPTIONAL_FILE,
"env.client=optional:可选条目照常安装(「可选」不等于「禁用」)"
.to_string(),
)
.with_path(file.path.clone().unwrap_or_else(|| file.key())),
);
}
if let Some(note) = file
.path
.as_deref()
.and_then(|path| suffix_note(variant, path))
{
notes.push(note);
}
remote.push(PlanRemote {
path: file.path.clone(),
source,
disabled: variant.is_disabled(),
file: file.clone(),
});
}
let max_memory_mb = if format_id == multimc::ID {
instance_memory(pack, &mut notes)?
} else {
None
};
Ok(Self {
format: format_id.to_string(),
manifest,
mc_version,
loader,
version_id,
rejected,
notes,
overrides,
remote,
max_memory_mb,
})
}
}
/// 选一个加载器并翻译成 [`LoaderTarget`]。
fn select_loader(
manifest: &PackManifest,
mc_version: &str,
format_id: &str,
notes: &mut Vec<ImportIssue>,
rejected: &mut Vec<RejectedComponent>,
) -> Result<Option<LoaderTarget>, ImportError> {
let recognized: Vec<&PackLoader> = manifest
.loaders
.iter()
.filter(|loader| loader.kind.is_some())
.collect();
// 认不出的条目:MultiMC 是"不支持的 component"(逐项列),其它格式是"不认识的依赖键"。
for loader in manifest
.loaders
.iter()
.filter(|loader| loader.kind.is_none())
{
if format_id == multimc::ID {
rejected.push(RejectedComponent {
uid: loader.raw.clone(),
version: loader.version.clone(),
message_key: keys::REJECTED_COMPONENT,
});
} else {
notes.push(ImportIssue::new(
keys::NOTE_UNKNOWN_DEPENDENCY,
format!("认不出的加载器/依赖键 {:?}:本启动器不安装它", loader.raw),
));
}
}
if recognized.len() > 1 {
notes.push(ImportIssue::new(
keys::NOTE_LOADER_MULTIPLE,
format!(
"清单里有 {} 个可识别的加载器,只按 primary/第一条组计划:{}",
recognized.len(),
recognized
.iter()
.map(|loader| loader.raw.as_str())
.collect::<Vec<_>>()
.join(", ")
),
));
}
let Some(loader) = recognized
.iter()
.find(|loader| loader.primary)
.or_else(|| recognized.first())
.copied()
else {
return Ok(None);
};
let Some(kind) = loader.kind else {
return Ok(None);
};
let raw_version = loader
.version
.as_deref()
.map(str::trim)
.filter(|version| !version.is_empty())
.ok_or_else(|| {
ImportError::loader(format!(
"加载器条目 {:?} 没有版本号,组不出安装计划(清单自身非法)",
loader.raw
))
})?;
let loader_version = strip_mc_prefix(mc_version, raw_version);
Ok(Some(LoaderTarget {
kind,
mc_version: mc_version.to_string(),
version_id: kind.version_id(mc_version, &loader_version),
loader_version,
raw: loader.raw.clone(),
}))
}
/// 从 `instance.cfg` 取受开关门控的内存上限(MB)。
fn instance_memory(
pack: &PackArchive,
notes: &mut Vec<ImportIssue>,
) -> Result<Option<u64>, ImportError> {
let Some(text) = pack.read_text(multimc::INSTANCE_ENTRY)? else {
return Ok(None);
};
let mut values: Vec<(&str, &str)> = Vec::new();
for line in text.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
continue;
}
// `[General]` 这类小节标记直接跳过:instance.cfg 平铺,真出现也不该把它们当键。
if line.starts_with('[') {
continue;
}
// 值里含 `:` 与 `\`(Windows 路径)是常态,所以只按**第一个** `=` 切。
if let Some((key, value)) = line.split_once('=') {
values.push((key.trim(), value.trim()));
}
}
let get = |name: &str| -> Option<&str> {
values
.iter()
.find(|(key, _)| key.eq_ignore_ascii_case(name))
.map(|(_, value)| *value)
};
let Some(max) = get("MaxMemAlloc").and_then(|value| value.parse::<u64>().ok()) else {
return Ok(None);
};
// Prism 的规矩:这些值只在开关为 true 时生效(只看值不看开关会误判)。
let enabled = get("OverrideMemory").is_some_and(|value| value.eq_ignore_ascii_case("true"));
if !enabled {
notes.push(ImportIssue::new(
keys::NOTE_INSTANCE_CFG_MEMORY,
format!("instance.cfg 写了 MaxMemAlloc={max},但 OverrideMemory 未开:不采用"),
));
return Ok(None);
}
notes.push(ImportIssue::new(
keys::NOTE_INSTANCE_CFG_MEMORY,
format!("从 instance.cfg 取到内存上限 {max} MB(OverrideMemory=true)"),
));
Ok(Some(max))
}
+208
View File
@@ -0,0 +1,208 @@
//! 覆盖目录的**叠层顺序**与**同路径遮蔽**:导入计划里"逐文件"那一半的判据(纯计算,无 IO)。
//!
//! 拆成单独一个文件有两个原因:①`plan.rs` 要守 400 行;②这一层的两条规则(先按侧排、
//! 再按路径结算胜负)是导入器里唯一"我们自己定的确定性规则",值得放在一处被单独读完。
use std::collections::HashMap;
use super::super::report::{keys, FileSource, ImportIssue};
use super::PlanOverride;
use crate::addon::pack::{PackManifest, PackOverrides, PackSide};
/// 「这条覆盖条目被同一份包里的另一条声明遮蔽」。
///
/// 为什么不是新的 `FileAction` 变体:`FileAction` / `ConflictReason` / `ConflictChoice` 三个
/// 枚举被 `crates/presenter/src/host/addon/pack/convert.rs` **穷尽 match**(那个 crate 不在
/// `addon` 的作用域里)。所以"遮蔽"这件事走 [`ImportIssue`] 的 note 通道,与
/// `server-overrides/ 被跳过` 完全同一条通道。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Shadowed {
/// 胜出那条的来源侧。
pub winner: FileSource,
/// 胜出那条的包内目录名(人读用,例如 `client-overrides`)。
pub winner_dir: String,
/// 两条声明的字节是否完全相同(`true` = 遮蔽不丢内容,只是重复了一份)。
pub same_bytes: bool,
}
/// 覆盖目录按侧排序:`Both`(`overrides/`)先、`Client`(`client-overrides/`)后 = 后者胜。
pub(super) fn ordered(manifest: &PackManifest) -> Vec<&PackOverrides> {
let mut groups: Vec<&PackOverrides> = manifest.overrides.iter().collect();
groups.sort_by_key(|group| match group.side {
PackSide::Both => 0_u8,
PackSide::Client => 1,
PackSide::Server => 2,
});
groups
}
/// 同一实例路径被本包多份声明命中时:**叠层顺序里最后一条胜**,其余标记为遮蔽并逐条注记。
///
/// # 这是"逐文件"而不是"整包一个策略"的那一步
///
/// research/18 §3.3 的 A 级结论:mrpack 的 `overrides/` 与 `client-overrides/` 之间,
/// **规范层面只有极少数固定路径有优先级**,其余按 zip 条目顺序落盘;HMCL 连顺序都相反。
/// 上一版导入器对此的处理是"按侧排序后逐条写"——结果是对的,但**报告是错的**:同一路径
/// 会出现两条 `Installed`,其中前一条的哈希根本没落地,也没人说得清谁遮蔽了谁。
///
/// 现在把这层算清楚,逐条给出两个事实:
///
/// 1. **赢了的是哪一侧**(叠层顺序 = [`ordered`] 的 `Both → Client`,同组内按路径排序,
/// 所以与 zip 条目顺序无关——那是我们自己定死的确定性规则,不是规范);
/// 2. **两份内容是否逐字节相同**(相同 = 重复声明,遮蔽不丢内容;不同 = 真的换了一份)。
///
/// 判据只看模型里的 `sha1`:M110 扫包时是**现算**的真实内容哈希;手工构造的模型可能撒谎,
/// 但那最多让 `same_bytes` 这句注记不准,**谁落地不受影响**(胜者永远按叠层顺序定)。
pub(super) fn resolve(overrides: &mut [PlanOverride], notes: &mut Vec<ImportIssue>) {
let mut last: HashMap<String, usize> = HashMap::new();
for (index, entry) in overrides.iter().enumerate() {
last.insert(entry.path.clone(), index);
}
for index in 0..overrides.len() {
let path = overrides[index].path.clone();
let Some(&winner_index) = last.get(&path) else {
continue;
};
if winner_index == index {
continue;
}
let winner_dir = overrides[winner_index].dir.clone();
let loser_dir = overrides[index].dir.clone();
let same_bytes = overrides[winner_index].sha1 == overrides[index].sha1;
let conclusion = if same_bytes {
"两份内容逐字节相同,只落地一次"
} else {
"两份内容不同,以这一条为准"
};
notes.push(
ImportIssue::new(
keys::NOTE_OVERRIDE_SHADOWED,
format!(
"{loser_dir}/{path} 与 {winner_dir}/{path} 声明同一实例路径:叠层顺序 \
overrides/ 在前、client-overrides/ 在后(Modrinth 官方客户端语义),\
{conclusion}"
),
)
.with_path(path),
);
overrides[index].shadowed = Some(Shadowed {
winner: overrides[winner_index].source,
winner_dir,
same_bytes,
});
}
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(dir: &str, path: &str, source: FileSource, sha1: &str) -> PlanOverride {
PlanOverride {
dir: dir.to_string(),
path: path.to_string(),
source,
sha1: sha1.to_string(),
size: 1,
disabled: false,
note: None,
shadowed: None,
}
}
#[test]
fn the_later_layer_wins_and_the_earlier_one_is_shadowed() {
// 叠层顺序:overrides/(Both)在前、client-overrides/(Client)在后 ⇒ 后者胜。
let mut overrides = vec![
entry(
"overrides",
"config/dup.toml",
FileSource::Overrides,
"aaaa",
),
entry(
"client-overrides",
"config/dup.toml",
FileSource::ClientOverrides,
"bbbb",
),
];
let mut notes = Vec::new();
resolve(&mut overrides, &mut notes);
let shadowed = overrides[0].shadowed.as_ref().expect("前一条要被标记");
assert_eq!(shadowed.winner, FileSource::ClientOverrides);
assert_eq!(shadowed.winner_dir, "client-overrides");
assert!(!shadowed.same_bytes, "同路径不同哈希");
assert!(overrides[1].shadowed.is_none(), "胜者不标遮蔽");
assert_eq!(notes.len(), 1);
assert_eq!(notes[0].message_key, keys::NOTE_OVERRIDE_SHADOWED);
assert_eq!(notes[0].path.as_deref(), Some("config/dup.toml"));
let message = &notes[0].message;
for expected in [
"overrides/config/dup.toml",
"client-overrides/config/dup.toml",
"内容不同",
] {
assert!(message.contains(expected), "缺 {expected}:{message}");
}
}
#[test]
fn identical_bytes_are_reported_as_same_bytes() {
let mut overrides = vec![
entry(
"overrides",
"config/same.toml",
FileSource::Overrides,
"aaaa",
),
entry(
"client-overrides",
"config/same.toml",
FileSource::ClientOverrides,
"aaaa",
),
];
let mut notes = Vec::new();
resolve(&mut overrides, &mut notes);
assert!(overrides[0].shadowed.as_ref().expect("标记").same_bytes);
assert!(
notes[0].message.contains("逐字节相同"),
"内容一致要说清楚,别让人以为丢了东西:{}",
notes[0].message
);
}
#[test]
fn different_paths_with_the_same_bytes_are_never_shadowed() {
// 同哈希不同路径 = 两份文件,不是重复声明。按哈希去重会**静默丢一个文件**。
let mut overrides = vec![
entry("overrides", "mods/a.jar", FileSource::Overrides, "aaaa"),
entry("overrides", "mods/b.jar", FileSource::Overrides, "aaaa"),
];
let mut notes = Vec::new();
resolve(&mut overrides, &mut notes);
assert!(overrides.iter().all(|entry| entry.shadowed.is_none()));
assert!(notes.is_empty());
}
#[test]
fn the_same_path_twice_inside_one_group_is_shadowed_too() {
// 同侧重复(模型被合并 / 手造时才会出现):照样只落地一次,且注记两份来自同一目录。
let mut overrides = vec![
entry("overrides", "mods/x.jar", FileSource::Overrides, "aaaa"),
entry("overrides", "mods/x.jar", FileSource::Overrides, "bbbb"),
];
let mut notes = Vec::new();
resolve(&mut overrides, &mut notes);
assert_eq!(
overrides[0].shadowed.as_ref().expect("标记").winner,
FileSource::Overrides
);
assert_eq!(notes.len(), 1);
}
}
+132
View File
@@ -0,0 +1,132 @@
//! 路径与版本号的小判据(纯函数):`.disabled` / `.old` 后缀变体与注记、CF 类别目录、MC 版本前缀。
//!
//! 拆出来是因为它们是**格式事实**而非管线逻辑:`.disabled` 只按路径后缀(tower 定案,不用
//! `env.client=optional`)、`.old` 只识别与保留(不清不删)、CF 的 `classId` 认不出就不猜。
use super::super::report::{keys, ImportIssue};
/// 路径后缀变体(`.disabled` / `.old` 的四种组合)。
///
/// M224 收敛:类型本体只有 [`crate::addon::mods::suffix`] 一处,这里原样再导出,于是
/// `paths::SuffixVariant` 与启停用的是**同一个类型**(零行为变化,调用点不用改)。
pub use crate::addon::mods::SuffixVariant;
/// 识别四种路径后缀变体。
///
/// 委派到唯一判据(见模块文档);保留函数名与签名。
#[must_use]
pub fn suffix_variant(path: &str) -> SuffixVariant {
crate::addon::mods::suffix_variant(path)
}
/// 后缀注记(`.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);
}
}
+65
View File
@@ -0,0 +1,65 @@
//! 导入管线的**准备段**:读包 → 组计划 → 抄进报告 → 写基线(一个字节都还没进目标目录)。
//!
//! 从 [`super::pipeline`] 拆出来的是"碰盘之前"的那一段,行为逐字未变:编排文件(`pipeline.rs`)
//! 要装下取消的七个检查点与回滚收尾,把这一段移到同级模块是为了让它仍在一屏之内。
//!
//! 这里的函数**都不查取消令牌**:整段落在"解析后一次检查"之前/之后的两侧,而 `open` 是
//! 不可分割的(zip 目录 + 四检 + 限额一次扫完)——见 [`super::cancel`] 的逐段表。
use super::baseline::PackBaseline;
use super::error::ImportError;
use super::plan::ImportPlan;
use super::report::ImportReport;
use super::stage::Staging;
use super::ImportRequest;
use crate::addon::pack::{PackArchive, PackError, PackRegistry};
/// 开包 + 探测 + 组计划(任何失败都是 `Failed`)。
///
/// 预检([`super::scan`])走的是**同一段代码**:两边对"这包是什么、要装什么"必须完全一致,
/// 否则"预检说没冲突、导入说有冲突"这种事就会发生。
pub(super) 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))
}
/// 把计划里的静态信息抄进报告。
pub(super) 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());
}
/// 基线作为**最后一个暂存文件**写进去:它跟同一个事务一起提交 / 回滚。
pub(super) 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('\\', "/")
}
+337
View File
@@ -0,0 +1,337 @@
//! 导入结果:**三态 + 逐项原因**,给 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;
/// 导入结果(三态 + 一档"用户取消")。
///
/// 取消**单列一档、不是第四种失败**(M195):它有自己的变体、自己的文案 key、自己的退出码,
/// 且 `ImportReport::error` 恒为 `None`。把两者并成一个错误变体,UI 就会把"用户自己点的"
/// 画成红的,脚本也会把"我改主意了"当成"装坏了"。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ImportStatus {
/// 全部落地、无冲突。
Success,
/// 有缺文件或有冲突(内容不完整,但实例可用)。
Partial,
/// 致命失败,已整包回滚。
Failed,
/// 用户主动取消:管线在检查点停下,目标目录与导入前一致(`error` 为 `None`)。
Cancelled,
}
impl ImportStatus {
/// 稳定字符串 id。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Success => "success",
Self::Partial => "partial",
Self::Failed => "failed",
Self::Cancelled => "cancelled",
}
}
/// 文案 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,
Self::Cancelled => keys::STATUS_CANCELLED,
}
}
/// CLI 退出码:`0` / `3` / `1` / `130`(`2` 留给内部错误,`1` 与用法错误同码但 JSON 里可区分)。
///
/// `130` 是「被用户中断」的惯例码(`128 + SIGINT`):**与失败不同码**,脚本不会把
/// 「用户改主意」记成「装坏了」,也不会误判成成功。
#[must_use]
pub const fn exit_code(self) -> i32 {
match self {
Self::Success => 0,
Self::Partial => 3,
Self::Failed => 1,
Self::Cancelled => 130,
}
}
}
/// 导入阶段(进度回调按这个分组)。
#[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>;
/// 发一条进度事件(`sink` 为 `None` 时什么都不做)。
///
/// 放在这里是因为它是 [`ImportProgress`] 的唯一出口:编排、阶段实现、提交事务都走它,
/// 谁都不许自己拼一条进度事件。
pub(crate) fn tell(
sink: &Option<ImportProgressSink>,
stage: ImportStage,
done: usize,
total: usize,
current: Option<String>,
) {
if let Some(sink) = sink {
sink(&ImportProgress {
stage,
done,
total,
current,
});
}
}
/// 一次导入的完整结果。
#[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;
}
/// 取消收尾:状态落到 [`ImportStatus::Cancelled`],并说准**目标目录现在的样子**。
///
/// 与 [`ImportReport::fail`] 是**两条路**(M195 的守门条款③):取消不是失败,`error` 保持
/// `None`,UI 不弹红、CLI 退出码 `130`。把两者并成一个错误变体,这一条就守不住。
///
/// `outcome` 是取消之前已经做过的回滚动作(落盘之前取消 ⇒ `attempted == 0`,即"没写过")。
/// 三档文案与失败路径**同形**:没写过 / 回滚干净 / 回滚没做完——回滚不完整时同样不许宣称
/// "与导入前一致"。
pub fn cancel(&mut self, outcome: RollbackOutcome) {
// 取消前"处理过"的条目一条都没进目标目录(暂存目录已删 / 已回滚):留着它们会让
// `installed()` / `bytes_written` 谎报"装了多少",所以清空并把这笔账写进注记。
let handled = self.files.len();
self.files.clear();
self.conflicts.clear();
self.missing.clear();
self.bytes_written = 0;
self.error = None;
self.status = ImportStatus::Cancelled;
if outcome.attempted == 0 {
// 落盘之前就停了:目标目录一个字节都没动。
self.rolled_back = true;
self.warnings.push(ImportIssue::new(
keys::NOTE_CANCELLED,
format!(
"已取消:目标目录未被写入({});已处理的 {handled} 条文件未落地",
self.target.display()
),
));
return;
}
if outcome.is_complete() {
self.rolled_back = true;
self.warnings.push(ImportIssue::new(
keys::NOTE_CANCELLED,
format!(
"已取消:已回滚 {} 项,目标目录恢复为导入前的样子({})",
outcome.attempted,
self.target.display()
),
));
} else {
// 回滚本身失败:与失败路径同一口径——不许在"可能没回干净"时宣称一致。
self.rolled_back = false;
self.warnings.push(ImportIssue::new(
keys::NOTE_ROLLBACK_INCOMPLETE,
format!(
"取消时回滚有 {} 项未完成(目标目录可能与导入前不同):{}",
outcome.failure_count(),
outcome.failed.join(";")
),
));
self.warnings.push(ImportIssue::new(
keys::NOTE_CANCELLED,
format!("已取消:已处理 {handled} 条文件、回滚未完整(详见 rollback.failed)"),
));
}
self.rollback = Some(outcome);
}
}
@@ -0,0 +1,272 @@
//! 报告里的**条目类型**:一条文件、一条冲突、一条缺失、一个 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`] / [`super::super::ConflictDecisions`] 决定)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ConflictChoice {
/// 保留用户侧(不写盘)。
KeepUser,
/// 用包内内容覆盖(用户选了"覆盖")。
Overwrite,
}
impl Default for ConflictChoice {
/// **缺省是保留**:没有决策时绝不覆盖用户已有文件(M160 的硬线,见 `import::decision`)。
fn default() -> Self {
Self::KeepUser
}
}
/// 一条冲突("无法判定时给可选择的冲突清单"就是这张表)。
#[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,
}
+120
View File
@@ -0,0 +1,120 @@
//! 文案 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";
/// 状态:**用户取消**(不是失败,`error` 为空)。
pub const STATUS_CANCELLED: &str = "pack.import.status.cancelled";
/// 注记:`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_CANCELLED: &str = "pack.import.note.cancelled";
/// 注记:同包另一条声明遮蔽了这个路径(说清谁赢、两份内容是否相同)。
pub const NOTE_OVERRIDE_SHADOWED: &str = "pack.import.note.override_shadowed";
/// 注记:这些远端条目预检判不了(补全前没有路径 / 有路径但缺 sha1 声明),无法预判冲突。
pub const NOTE_REMOTE_PATH_UNKNOWN: &str = "pack.import.note.remote_path_unknown";
/// 注记:这些远端条目按 sha512 从 Modrinth 补全了 sha1(补不出的另有计数)。
pub const NOTE_REMOTE_COMPLETED: &str = "pack.import.note.remote_completed";
/// 缺失:调用方没给 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,
STATUS_CANCELLED,
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_CANCELLED,
NOTE_OVERRIDE_SHADOWED,
NOTE_REMOTE_PATH_UNKNOWN,
NOTE_REMOTE_COMPLETED,
MISSING_OFFLINE,
MISSING_NO_SOURCE,
MISSING_FETCH_FAILED,
CONFLICT_USER_MODIFIED,
CONFLICT_USER_DELETED,
CONFLICT_PRE_EXISTING,
REJECTED_COMPONENT,
ROLLBACK_DONE,
];
+337
View File
@@ -0,0 +1,337 @@
//! 导入**预检**(M160):只读地算出「冲突清单」——**只列真正需要用户点头的文件**。
//!
//! # 什么进清单,什么不进(口径只此一处)
//!
//! 一个路径进清单**当且仅当**:目标实例里同名路径是一个普通文件,且它的内容与包内**不同**。
//! 等值判据是 **SHA-1**(包内那条来自 M110 读包时现算的摘要,磁盘那条现算);体积只作展示,
//! 不参与判定——同一份内容不可能有两个哈希,而"体积相同"并不说明内容相同。
//!
//! 不进清单的四种情形,每一种都有理由:
//!
//! | 情形 | 为什么不必打扰用户 |
//! |------|--------------------|
//! | 目标里没有这个文件 | 全新文件,装就是了 |
//! | 内容与包内一致 | 已经是最新,写盘是浪费 |
//! | 用户删过(基线有、磁盘没有) | 删是用户的决定,**不偷偷装回来**(`merge::decide` 的 `UserDeleted`) |
//! | 包内被同包另一条声明遮蔽(`shadowed`) | 它不落地,用户看不到它,问了也无从选 |
//!
//! # 叠层优先级不在这层重判
//!
//! `client-overrides/` 胜 `overrides/`、`server-overrides/` 客户端不解压——这些**已由**
//! [`super::plan::ImportPlan::build`] 与 `plan::layer::resolve` 算定(一路径一行,胜者才在
//! `overrides` 表里且 `shadowed.is_none()`)。本模块只按胜者取 `source`,从不自己再排一次。
//!
//! # 远端条目的两种命运
//!
//! mrpack 的 `files[]` 自带 `path` + `hashes.sha1`,所以能在这里判;判的是"**声明的**摘要 vs
//! 磁盘",与导入时"下载下来再校验"是同一份期望值。CurseForge 的 `files[]` 只有
//! `projectID/fileID`,补全前**没有路径**,无从判起 ⇒ 不猜、不进清单,只把条数记进
//! [`ImportScan::unknown_remote`](界面据此说"还有 N 条补全后才能知道会不会撞车")。计数与
//! 文案同一口径:**判不了**的两种来路(补全前没有路径 / 有路径但缺 `hashes.sha1`)都算。
//!
//! # 便宜在哪、贵在哪
//!
//! 预检不读包内字节(摘要来自 M110 的读包结果),但要给每条冲突填"目标现有体积与哈希",
//! 所以**磁盘上那些同名文件要真读一遍**。于是 `scan` + `import` 会把它们读两次——这是本轮
//! 的已知代价(要省就得让导入直接吃预检的判定结果,那是另一件事,报告里登记)。
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use super::baseline::PackBaseline;
use super::decision::ConflictDecisions;
use super::error::ImportError;
use super::merge;
use super::pipeline::load_baseline;
use super::plan::ImportPlan;
use super::prepare::probe_and_plan;
use super::report::{keys, ConflictReason, FileSource, ImportIssue, LoaderTarget};
use super::ImportRequest;
use crate::download::verify;
use crate::download::HashAlgo;
/// 一条**要用户点头**的冲突(目标里已有同名文件且内容不同)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ImportConflict {
/// 实例内相对路径。
pub path: String,
/// 来源(包内 `overrides/` / `client-overrides/` / mrpack 或 CF 的 `files[]`)。
pub source: FileSource,
/// 成因(`PreExisting` = 没有基线可判;`UserModified` = 基线里记过、用户改过)。
pub reason: ConflictReason,
/// 包内那条是不是禁用态(`.disabled` 后缀原样保留)。
pub disabled: bool,
/// 包内声明的 SHA-1。
pub pack_sha1: String,
/// 包内声明的字节数。
pub pack_size: u64,
/// 目标现有文件的 SHA-1。
pub disk_sha1: String,
/// 目标现有文件的字节数。
pub disk_size: u64,
/// 上次导入写下的 SHA-1(没有基线时 `None`)。
pub base_sha1: Option<String>,
}
impl ImportConflict {
/// 覆盖它会丢掉多少字节(界面上"覆盖会丢掉 12.4 MB"就是它)。
#[must_use]
pub const fn lost_bytes(&self) -> u64 {
self.disk_size
}
}
/// 一次预检的结果。
///
/// `error` 为 `Some` 时其余字段是"没算出来":探测/开包/组计划失败与导入管线同一条口径
/// (同一个 [`PackError`] → [`ImportError`] 的映射),不去新造一套。
#[derive(Debug, Clone)]
pub struct ImportScan {
/// 目标实例目录。
pub target: PathBuf,
/// 命中的格式 id。
pub format: Option<String>,
/// 包名。
pub pack_name: Option<String>,
/// 包版本。
pub pack_version: Option<String>,
/// 游戏版本。
pub game_version: Option<String>,
/// 加载器计划输入(`None` = 纯原版)。
pub loader: Option<LoaderTarget>,
/// 冲突清单(按路径排序)。
pub conflicts: Vec<ImportConflict>,
/// 预检阶段**判不了**的远端条目数。
///
/// 判不了有两种来路,计数把两者都算上(口径与注记文案一致,复审 r1 P3-4):
/// ①CurseForge `files[]` 补全前**没有路径**(常态);②极少数 mrpack 条目**有路径但缺
/// `hashes.sha1`**,没有声明摘要就没法与磁盘现状比对。
pub unknown_remote: usize,
/// 注记(`.disabled` / 可选条目 / 基线口径 / 被遮蔽的条目……与导入报告同源)。
pub notes: Vec<ImportIssue>,
/// 开包或组计划失败(`Failed` 口径,目标目录一个字节都没动)。
pub error: Option<ImportError>,
}
impl ImportScan {
fn empty(target: PathBuf) -> Self {
Self {
target,
format: None,
pack_name: None,
pack_version: None,
game_version: None,
loader: None,
conflicts: Vec::new(),
unknown_remote: 0,
notes: Vec::new(),
error: None,
}
}
/// 预检成功了吗(`false` = 看 [`Self::error`])。
#[must_use]
pub const fn is_ok(&self) -> bool {
self.error.is_none()
}
/// 有没有需要用户点头的项(`false` = 可以直接导入,不必打扰用户)。
#[must_use]
pub fn needs_decision(&self) -> bool {
!self.conflicts.is_empty()
}
/// 「全部保留」的决策(= 缺省档,不覆盖任何用户文件)。
#[must_use]
pub fn keep_all(&self) -> ConflictDecisions {
ConflictDecisions::keep_all()
}
/// 「全部覆盖」的决策(用户显式要求"整合包说了算")。
#[must_use]
pub fn overwrite_all(&self) -> ConflictDecisions {
ConflictDecisions::overwrite_all()
}
/// 覆盖会丢掉的字节总量(界面上的计数摘要)。
#[must_use]
pub fn lost_bytes(&self) -> u64 {
self.conflicts
.iter()
.map(ImportConflict::lost_bytes)
.fold(0, u64::saturating_add)
}
/// 包内那些条目声明了多大(= 覆盖后会占用的字节数)。
#[must_use]
pub fn incoming_bytes(&self) -> u64 {
self.conflicts
.iter()
.map(|conflict| conflict.pack_size)
.fold(0, u64::saturating_add)
}
}
/// 跑一次预检:**只读**(不建目录、不写暂存、不改基线、**不联网**)。
#[must_use]
pub fn scan_pack(request: &ImportRequest<'_>) -> ImportScan {
let mut scan = ImportScan::empty(request.target.to_path_buf());
let plan = match probe_and_plan(request) {
Ok((_, plan)) => plan,
Err(error) => {
scan.error = Some(error);
return scan;
}
};
fill(request, &plan, &mut scan);
scan
}
/// 跑一次预检,**先补全再判**(M162):缺 `hashes.sha1` 的 mrpack 条目按 `sha512` 反查 Modrinth。
///
/// 补出来的条目与清单里本来就带 `sha1` 的条目走**同一个** [`judge`](判据一行没改);
/// 补不出的仍进 [`ImportScan::unknown_remote`] 并留同一条注记——计数口径不变,只是「能判的变多了」。
///
/// 没有挂 fetcher(离线导入)时等价于 [`scan_pack`]:不发任何请求,计数照旧。
#[must_use]
pub async fn scan_pack_online(request: &ImportRequest<'_>) -> ImportScan {
let mut scan = ImportScan::empty(request.target.to_path_buf());
let mut plan = match probe_and_plan(request) {
Ok((_, plan)) => plan,
Err(error) => {
scan.error = Some(error);
return scan;
}
};
let completion = super::complete::complete(request, &mut plan).await;
fill(request, &plan, &mut scan);
if let Some(note) = completion.note("预检前") {
scan.notes.push(note);
}
scan
}
/// 预检的主体:把「已组好的计划」判成冲突清单(两个入口共用,判据只此一处)。
fn fill(request: &ImportRequest<'_>, plan: &ImportPlan, scan: &mut ImportScan) {
let (baseline, warnings) = load_baseline(request.target, plan);
scan.notes = warnings;
scan.format = Some(plan.format.clone());
scan.pack_name = Some(plan.manifest.name.clone());
scan.pack_version = plan.manifest.version.clone();
scan.game_version = Some(plan.mc_version.clone());
scan.loader = plan.loader.clone();
// 一路径一行:同一路径被覆盖条目与远端条目同时声明时,只留一条(决策是按路径给的)。
let mut seen: BTreeMap<String, ()> = BTreeMap::new();
for entry in plan
.overrides
.iter()
.filter(|entry| entry.shadowed.is_none())
{
let Some(conflict) = judge(
&entry.path,
entry.source,
&entry.sha1,
entry.size,
entry.disabled,
&baseline,
request.target,
) else {
continue;
};
seen.insert(entry.path.clone(), ());
scan.conflicts.push(conflict);
}
for entry in &plan.remote {
let (Some(path), Some(declared)) = (entry.path.as_deref(), entry.file.sha1()) else {
// 两种来路都算「判不了」:没有路径(CF 待补全)或没有声明摘要(缺 `hashes.sha1`)。
scan.unknown_remote += 1;
continue;
};
if seen.contains_key(path) {
continue;
}
let Some(conflict) = judge(
path,
entry.source,
declared,
entry.file.size.unwrap_or(0),
entry.disabled,
&baseline,
request.target,
) else {
continue;
};
seen.insert(path.to_string(), ());
scan.conflicts.push(conflict);
}
if scan.unknown_remote > 0 {
scan.notes.push(ImportIssue::new(
keys::NOTE_REMOTE_PATH_UNKNOWN,
format!(
"有 {} 条远端条目预检判不了:CurseForge files[] 补全前没有路径,个别条目有路径但没有 sha1 声明;这些条目会不会撞上已有文件只能等补全/下载后才知道,导入时仍按「缺省保留」处置",
scan.unknown_remote
),
));
}
scan.conflicts
.sort_by(|left, right| left.path.cmp(&right.path));
}
/// 判一个路径:`Some` = 要用户点头的冲突。
///
/// # 「内容相同」的判据**只此一处**:[`merge::decide`]
///
/// 这里**故意不再写** `if disk.sha1 == pack_sha1 { return None }`(复审 r1 P3-2 抓到的重复判据):
/// 那种早退与 `merge::decide` 的 `SkipIdentical` 分支是同一件事的两次实现,改一处漏一处的后果
/// 就是"预检说没冲突、导入却判 SkipIdentical"或者反过来。
///
/// 现在只有一条链路:`decide` 给出 `MergeDecision`,**没有 `reason` 就不是冲突**
/// (`SkipIdentical` 与 `Install` 都是 `reason == None`),`?` 把它一步交代掉。
/// 于是"内容相同 ⇒ 不进清单"这条结论的源头是 `merge.rs` 的那张表——与导入用的是同一张。
fn judge(
path: &str,
source: FileSource,
pack_sha1: &str,
pack_size: u64,
disabled: bool,
baseline: &PackBaseline,
target: &Path,
) -> Option<ImportConflict> {
let disk = disk_state(&target.join(path))?;
// 包内那条的摘要来自 M110 读包(`override_files` 边读边算),与导入时现算的是同一份字节。
let base_sha1 = baseline.sha1(path).map(str::to_string);
// 唯一判据:`reason` 为 `None` 就是"不是冲突"(内容相同 → `SkipIdentical`;
// 磁盘上没有 → `Install`,而那一条 `disk_state` 已经先返回 `None` 了)。
let decision = merge::decide(pack_sha1, base_sha1.as_deref(), Some(&disk.sha1));
let reason = decision.reason?;
Some(ImportConflict {
path: path.to_string(),
source,
reason,
disabled,
pack_sha1: pack_sha1.to_string(),
pack_size,
disk_sha1: disk.sha1,
disk_size: disk.size,
base_sha1,
})
}
/// 磁盘现状:`None` = 那里没有普通文件(不存在或是目录)。
struct DiskState {
size: u64,
sha1: String,
}
fn disk_state(path: &Path) -> Option<DiskState> {
let metadata = std::fs::metadata(path).ok()?;
if !metadata.is_file() {
return None;
}
let sha1 = verify::digest_file(path, HashAlgo::Sha1).ok()?;
Some(DiskState {
size: metadata.len(),
sha1,
})
}
+387
View File
@@ -0,0 +1,387 @@
//! 落地事务:**临时目录 + rename**,合并态用 journal 回滚(回滚细节在 [`commit`])。
//!
//! # 两种起点,两条事务路径
//!
//! | 起点 | 做法 | 失败后的样子 |
//! |------|------|--------------|
//! | 目标目录不存在 / 是空目录(全新导入) | 在**同卷兄弟目录**里把整个实例建好,最后一次 `rename` 到位 | 目标目录**从未出现**(rename 是原子的) |
//! | 目标目录已有内容(升级 / 重导) | 逐文件 `rename` 进目标;被替换的旧文件先改名成 `<dest>.ullbak-<pid>` 当备份 | journal 逆序回滚:新文件删掉、旧备份改回原名 |
//!
//! 为什么全新导入不逐文件搬:逐文件搬意味着"搬到一半失败"会留下一个**看起来存在**的
//! 半个实例,而 `rename` 一个目录是单步原子操作——失败就只有"没有"这一种结果。
//!
//! # 取消落在这层怎么办(M195)
//!
//! | 起点 | 取消点 | 收场 |
//! |------|--------|------|
//! | 全新导入 | **只在 `rename` 之前** | 删暂存目录,目标目录从未出现 |
//! | 合并态 | 每条搬动之前 | journal **逆序**撤回(新文件删掉、旧备份改回原名) |
//!
//! 合并态逐文件报进度(`ImportStage::Commit`):既是界面该有的动静,也是取消能落下的地方。
//!
//! # 两道路径检查都在这层再走一遍
//!
//! 第一道是 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, CommitStop, 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::cancel::{ImportCancel, Stopped};
use super::error::ImportError;
use super::report::tell;
use super::report::{ImportProgressSink, ImportStage};
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`** 的守卫(析构即回滚)。
///
/// `cancel` 是取消令牌,`progress` 是进度回调(合并态**逐文件**报一条:几千文件的合并本来
/// 就该看得见动静,而"取消"也正需要文件之间的落点)。
///
/// # Errors
///
/// - [`CommitStop::Cancelled`]:取消落在提交前 / 合并中途。**回滚已经在这条路径上做过**,
/// 结果一并返回(调用方把它写进报告的 `rollback` 机器位);
/// - [`CommitStop::Failed`]:`rename` 失败、合并态逐文件搬动失败。回滚同样已经做过,其结果
/// 随错误返回([`CommitFailure::rollback`]):调用方不许假设"失败 = 已回滚干净"。
///
/// 全新导入只有一次 `rename`:那是单步原子操作,**取消点只在它之前**——之后不存在"半个
/// 实例"这种中间态,也就没有"取消"这一说。
pub fn commit(
self,
cancel: &ImportCancel,
progress: &Option<ImportProgressSink>,
) -> Result<CommitGuard, CommitStop> {
match self.mode {
Mode::Fresh => {
if cancel.is_cancelled() {
// 还没 rename:目标目录一个字节都没动,删掉暂存目录就是零残留。
self.abort_inner();
return Err(CommitStop::Cancelled(RollbackOutcome::default()));
}
match std::fs::rename(&self.temp, &self.target) {
Ok(()) => Ok(CommitGuard::fresh(self.target.clone(), self.temp.clone())),
Err(error) => {
self.abort_inner();
Err(CommitStop::Failed(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, cancel, progress) {
Ok(()) => Ok(CommitGuard::merge(
self.target.clone(),
self.temp.clone(),
applied,
)),
Err(Stopped::Cancelled) => {
let rollback = rollback_merge(&mut applied);
self.abort_inner();
Err(CommitStop::Cancelled(rollback))
}
Err(Stopped::Failed(error)) => {
let rollback = rollback_merge(&mut applied);
self.abort_inner();
Err(CommitStop::Failed(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)
}
/// 合并态:把临时目录里的文件逐个搬进目标目录(每个都带备份)。
///
/// **逐文件取消点**:每条搬动之前查一次令牌,已取消 ⇒ `Err(Stopped::Cancelled)`,调用方
/// 按 journal 逆序回滚(新文件删掉、旧备份改回原名)。
fn apply_merge(
&self,
applied: &mut Vec<Applied>,
cancel: &ImportCancel,
progress: &Option<ImportProgressSink>,
) -> Result<(), Stopped> {
let mut staged: Vec<PathBuf> = Vec::new();
collect_files(&self.temp, &mut staged)?;
staged.sort();
let total = staged.len();
for (index, source) in staged.into_iter().enumerate() {
cancel.stopped()?;
let relative = source
.strip_prefix(&self.temp)
.map_err(|_| io_error(format!("暂存路径不在临时目录里:{}", source.display())))?;
let relative_text = relative.to_string_lossy().replace('\\', "/");
tell(
progress,
ImportStage::Commit,
index,
total,
Some(relative_text.clone()),
);
// 搬动前再规整一次(第三道,防"临时目录里被塞了奇怪的名字")。
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,302 @@
//! 提交守卫、回滚与**回滚本身失败的记账**:`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)
}
/// 提交阶段的两条停止路线(M195):**取消不是失败**,所以不共用 [`CommitFailure`]。
///
/// 两条路都**已经把回滚做过**(全新导入是 `abort`,合并态是 journal 逆序撤回):
/// 调用方拿到它们只管写报告,不必再自己清一遍。
#[derive(Debug)]
pub enum CommitStop {
/// 用户取消(回滚结果一并交回,可能不完整)。
Cancelled(RollbackOutcome),
/// 真失败(回滚结果连同错误一起交回)。
Failed(CommitFailure),
}
/// 一次**失败的回滚**(提交阶段失败时连同错误一起交回调用方)。
///
/// `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);
}
}
+259
View File
@@ -0,0 +1,259 @@
//! 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 根地址(按加载器;非 fabric 系给空串,调用方已按 [`LoaderKind::is_fabric_family`] 分流)。
#[must_use]
pub fn meta_base(kind: LoaderKind) -> &'static str {
match kind {
LoaderKind::Fabric => FABRIC_META,
LoaderKind::Quilt => QUILT_META,
// Forge / NeoForge 走 maven,LiteLoader 走官方版本表——都不能回落到 fabric meta。
LoaderKind::NeoForge | LoaderKind::Forge | LoaderKind::LiteLoader => "",
}
}
/// 该原版版本可用的加载器版本列表地址。
#[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(),
parent_version_id: 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"的实例。
///
/// 只有 fabric 系会走到这里(调用方 [`build_release`] 的入口已按
/// [`LoaderKind::is_fabric_family`] 分流);这里仍然**穷尽 match**,因为过去那对 `_ =>`
/// 会在新加一支加载器时把它静默当成 `fabric-loader` 补进库表。
fn ensure_loader_library(
kind: LoaderKind,
loader_version: &str,
libraries: &mut Vec<Library>,
warnings: &mut Vec<String>,
) {
let (group, artifact, maven_base) = match kind {
LoaderKind::Fabric => (
"net.fabricmc",
"fabric-loader",
"https://maven.fabricmc.net/",
),
LoaderKind::Quilt => (
"org.quiltmc",
"quilt-loader",
"https://maven.quiltmc.org/repository/release/",
),
LoaderKind::NeoForge | LoaderKind::Forge | LoaderKind::LiteLoader => return,
};
let present = libraries.iter().any(|library| {
library
.coordinate()
.is_ok_and(|coordinate| coordinate.group == group && coordinate.artifact == artifact)
});
if present {
return;
}
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()
));
}
+281
View File
@@ -0,0 +1,281 @@
//! 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(),
parent_version_id: 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(),
}
}
+229
View File
@@ -0,0 +1,229 @@
//! LiteLoader:官方 `versions.json` → **从零合成**一份 launchwrapper 版本 JSON。
//!
//! 依据(A 级:官方数据 + 官方构建脚本,不查二手转述):
//! - 数据源 `https://dl.liteloader.com/versions/versions.json`(2026-11 实测 200 / 49 KB,
//! 16 个原版版本);
//! - HMCL 的同类实现只做一件事(`HMCLCore/.../liteloader/LiteLoaderInstallTask.java`):
//! `mainClass = launchwrapper` + `--tweakClass <record.tweakClass>` + 「记录自带库 + 本体 jar」,
//! **不跑任何外部进程**;本模块按同样的协议独立实现,没有抄它的代码。
//!
//! # 四条与上游形状有关的硬事实(都实测过,别按直觉改)
//!
//! 1. **分支键是 md5,不是版本号**:`artefacts."com.mumfrey:liteloader"` 下的键是 md5,
//! 真正的版本号在记录的 `version` 字段(`1.7.10_04`)。按 `version` 去重(同一版本会挂
//! 两个 md5 键),`latest` 分支要跳过。
//! 2. **目录名是原版版本,不是 loader 版本**:release 直链形如
//! `<repo>/com/mumfrey/liteloader/1.7.10/liteloader-1.7.10_04.jar`(`1.7.10` 是原版)。
//! 3. **1.12.2 这类版本只有 `snapshots` 分支**(上游 16 个版本里 8 个如此,含最常用的 1.12.2)。
//! 记录的 `file`(`liteloader-1.12.2-SNAPSHOT.jar`)**下不动**,仓库里只有带时间戳的
//! `liteloader-1.12.2-<timestamp>-<build>-release.jar`;要拼出直链必须再读一次
//! `maven-metadata.xml`(`<timestamp>` + `<buildNumber>`)。见 [`snapshot_artifact_url`]。
//! 4. **分支级 `libraries` 不是要装的库**:那是 ForgeGradle 开发环境用的(1.12.2 的
//! `snapshots.libraries` 里挂着 `org.spongepowered:mixin`)。要进游戏 classpath 的是
//! **记录级** `libraries`(`launchwrapper` + `asm-all`),与 HMCL 的取法一致。
//!
//! # 库基址为什么按 group 分流
//!
//! 记录里的库大多不写 `url`,上游默认(Mojang 库仓)**实测 404**:`org.ow2.asm:asm-all:5.2`
//! 在 `libraries.minecraft.net` 上不存在(5.0.3 存在),而 `net.minecraft:launchwrapper:*`
//! 只在 Mojang 库仓有(Central / Forge maven 都 404)。所以 `net.minecraft` 走 Mojang 默认基址、
//! 其余走 Maven Central(实测 206)。这条分流有单测钉住,别把两侧弄反。
use std::collections::BTreeMap;
use super::plan::{LoaderRelease, LoaderSupport};
use super::{LoaderError, LoaderKind};
use crate::game::arguments::{Argument, Arguments};
use crate::game::library::{Library, LibraryDownloads};
use crate::game::manifest::{DownloadInfo, VersionJson};
use crate::game::version::ReleaseType;
/// 官方版本表。
pub const VERSION_LIST_URL: &str = "https://dl.liteloader.com/versions/versions.json";
/// 本体 jar 与 release 分支的仓库根:**直连官方**——`bmclapi-liteloader` 改写规则已
/// `enabled: false`(镜像侧实测 404,关闭依据见 `download/mirrors.json`)。库走 [`CENTRAL_REPO`]。
pub const DEFAULT_REPO: &str = "https://dl.liteloader.com/versions/";
/// 非 Mojang 库的基址:Mojang 库仓里没有 `asm-all:5.2`(实测 404),Central 有(实测 206)。
pub const CENTRAL_REPO: &str = "https://repo1.maven.org/maven2/";
/// 快照分支的仓库根——**固定这一个**,不跟着 `repo.url` 走。
///
/// 上游每个原版版本的 `repo.url` 有两种形态(`…/versions/` 与 `…/repo/`),而**快照只发布在
/// snapshots 仓**:1.10.2 的 `repo.url` 是 `http://dl.liteloader.com/repo/`,按它拼
/// `…/repo/com/mumfrey/liteloader/1.10.2-SNAPSHOT/maven-metadata.xml` 实测会 302 到
/// `…/repositories/liteloader/…` 再 404;而
/// `https://repo.mumfrey.com/content/repositories/snapshots/com/mumfrey/liteloader/1.10.2-SNAPSHOT/maven-metadata.xml`
/// 是 200。HMCL 的构建脚本同样把这个快照仓**写死**(`UpdateLiteLoaderVersionList.SNAPSHOT_FILE`,
/// 注释还引了它自己的 issue #3147)。1.12.2 的 `repo.url` 恰好就是这条路径,所以只测 1.12.2
/// 看不出这个问题——单测专门钉了 1.10.2。
pub const SNAPSHOT_REPO: &str = "https://repo.mumfrey.com/content/repositories/snapshots/";
/// launchwrapper 主类(LiteLoader 靠它做 tweak 注入)。
pub const LAUNCHWRAPPER_MAIN: &str = "net.minecraft.launchwrapper.Launch";
/// 记录没带 launchwrapper 时的兜底版本(1.12.2 的记录带的是 1.12)。
const LAUNCHWRAPPER_FALLBACK: &str = "1.12";
/// 本体库坐标的 group / artifact。
const LOADER_GROUP: &str = "com.mumfrey";
/// 本体库坐标的 artifact 名。
const LOADER_ARTIFACT: &str = "liteloader";
mod versions;
pub use versions::{parse_versions, snapshot_artifact_url};
/// 一条可安装的 LiteLoader 版本(已归一化,不含 `latest` 别名分支)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LiteLoaderEntry {
/// 版本号(`1.7.10_04` / `1.12.2-SNAPSHOT`)
pub version: String,
/// 记录所属的原版版本(拼目录与文件名用)
pub mc_version: String,
/// 是否快照分支(只有快照时要再读 maven-metadata 才拼得出直链)
pub snapshot: bool,
/// tweak 类(`com.mumfrey.liteloader.launch.LiteLoaderTweaker`)
pub tweak_class: String,
/// 记录级库(**不含**本体 jar,本体由 [`build_release`] 补)
pub libraries: Vec<Library>,
/// release 分支的直链(快照分支为 `None`)
pub url: Option<String>,
/// 快照分支的 maven 目录(`…/com/mumfrey/liteloader/1.12.2-SNAPSHOT/`)
pub snapshot_dir: Option<String>,
}
/// 默认版本:优先非快照(稳定),没有则取最新的那条。
#[must_use]
pub fn default_entry(entries: &[LiteLoaderEntry]) -> Option<&LiteLoaderEntry> {
entries
.iter()
.find(|entry| !entry.snapshot)
.or_else(|| entries.first())
}
/// 按版本号找一条(大小写不敏感)。
#[must_use]
pub fn find_entry<'a>(
entries: &'a [LiteLoaderEntry],
version: &str,
) -> Option<&'a LiteLoaderEntry> {
entries
.iter()
.find(|entry| entry.version.eq_ignore_ascii_case(version.trim()))
}
/// 合成 loader 版本 JSON(**纯函数**:直链由调用方给,快照的直链要先去查 maven-metadata)。
///
/// 拼出来的清单只有四件事:主类 = launchwrapper、`--tweakClass` 进 `arguments.game`、
/// 库 = 记录库 + 本体 jar、`inheritsFrom` = 父版本(缺省原版)。
///
/// **`arguments.game` 而不是 `minecraftArguments`**:继承合并里 `minecraftArguments` 是
/// **子覆盖父**([`crate::game::inherit::merge`]),写它会把父链(Forge 1.12.2 的
/// `--tweakClass FMLTweaker`)整串顶掉,Forge 当场不加载;`arguments.game` 才是父在前、子在后
/// **追加**,且启动时排在 legacy 参数之后([`crate::launch::game_args`] 第 24/25 项)。
///
/// # Errors
///
/// 记录缺 `tweakClass`、或没有本体直链时返回 metadata 错误(没有它这份清单装上也起不来)。
pub fn build_release(
mc_version: &str,
parent_version_id: &str,
entry: &LiteLoaderEntry,
artifact_url: &str,
) -> Result<LoaderRelease, LoaderError> {
if entry.tweak_class.trim().is_empty() {
return Err(LoaderError::metadata(format!(
"LiteLoader {} 的记录没有 tweakClass:这份清单装上也起不来",
entry.version
))
.with_hint("换一个 LiteLoader 版本,或核对 dl.liteloader.com 的版本表"));
}
if artifact_url.trim().is_empty() {
return Err(LoaderError::metadata(format!(
"LiteLoader {} 没有可下载的本体 jar 地址",
entry.version
)));
}
let version_id = LoaderKind::LiteLoader.version_id(mc_version, &entry.version);
let mut libraries = entry.libraries.clone();
libraries.push(loader_library(entry, artifact_url));
let mut warnings = Vec::new();
if parent_version_id != mc_version {
warnings.push(format!(
"LiteLoader {} 叠在 {parent_version_id} 上(父链不是原版 {mc_version}):tweakClass 追加在父链参数之后,父链自己的 tweakClass 不动",
entry.version
));
}
if !libraries
.iter()
.any(|library| library.name.starts_with("net.minecraft:launchwrapper:"))
{
libraries.push(Library {
name: format!("net.minecraft:launchwrapper:{LAUNCHWRAPPER_FALLBACK}"),
..Library::default()
});
warnings.push(
"LiteLoader 的记录没带 launchwrapper:已补 1.12(Launch 主类就靠它)".to_string(),
);
}
let version_json = VersionJson {
id: Some(version_id.clone()),
name: Some(format!("{mc_version} · LiteLoader {}", entry.version)),
inherits_from: Some(parent_version_id.to_string()),
// 客户端 jar 恒在原版那层(父链与 loader 层都不带 jar)。
jar: Some(mc_version.to_string()),
main_class: Some(LAUNCHWRAPPER_MAIN.to_string()),
minecraft_arguments: None,
arguments: Some(Arguments {
game: vec![
Argument::Plain("--tweakClass".to_string()),
Argument::Plain(entry.tweak_class.clone()),
],
jvm: Vec::new(),
}),
version_type: Some(if entry.snapshot {
ReleaseType::Snapshot
} else {
ReleaseType::Release
}),
libraries: Some(libraries),
..VersionJson::default()
};
Ok(LoaderRelease {
kind: LoaderKind::LiteLoader,
mc_version: mc_version.to_string(),
parent_version_id: parent_version_id.to_string(),
loader_version: entry.version.clone(),
version_id,
version_json,
support: LoaderSupport::JsonRoute,
installer: None,
install_profile: None,
warnings,
})
}
/// 本体 jar 的那一条库:直链绝对地址(快照的路径与 maven 坐标不一致,不能靠坐标推导)。
fn loader_library(entry: &LiteLoaderEntry, artifact_url: &str) -> Library {
Library {
name: format!("{LOADER_GROUP}:{LOADER_ARTIFACT}:{}", entry.version),
url: Some(DEFAULT_REPO.to_string()),
downloads: Some(LibraryDownloads {
artifact: Some(DownloadInfo {
id: None,
sha1: None,
size: None,
url: Some(artifact_url.to_string()),
path: None,
}),
classifiers: BTreeMap::new(),
}),
..Library::default()
}
}
@@ -0,0 +1,219 @@
//! 官方版本表(`dl.liteloader.com/versions/versions.json`)的解析:原版版本 → 条目列表。
//!
//! 上游形状的四条硬事实写在父模块的文档里([`super`]);这里只放解析与 URL 拼装。
//! 单独一个文件的理由是行数:父模块要装「解析」与「合成清单」两件事,一起写会破 400 行硬线。
use std::collections::{BTreeMap, BTreeSet};
use serde::Deserialize;
use super::{LiteLoaderEntry, LoaderError, CENTRAL_REPO, DEFAULT_REPO, SNAPSHOT_REPO};
use crate::game::library::Library;
#[derive(Debug, Deserialize)]
struct Root {
#[serde(default)]
versions: BTreeMap<String, GameVersions>,
}
#[derive(Debug, Deserialize)]
struct GameVersions {
#[serde(default)]
repo: Option<Repo>,
#[serde(default)]
artefacts: Option<Branch>,
#[serde(default)]
snapshots: Option<Branch>,
}
#[derive(Debug, Deserialize)]
struct Repo {
#[serde(default)]
url: String,
}
#[derive(Debug, Deserialize)]
struct Branch {
#[serde(rename = "com.mumfrey:liteloader", default)]
liteloader: BTreeMap<String, Record>,
}
#[derive(Debug, Deserialize)]
struct Record {
#[serde(default, rename = "tweakClass")]
tweak_class: String,
#[serde(default)]
libraries: Vec<Library>,
#[serde(default)]
file: String,
#[serde(default)]
version: String,
#[serde(default)]
stream: String,
}
/// 解析官方版本表:原版版本 → 该原版下的 LiteLoader 版本(**新在前**)。
///
/// # Errors
///
/// 顶层不是 `{"versions": {…}}`、或一个版本都认不出时返回 metadata 错误。
pub fn parse_versions(json: &str) -> Result<BTreeMap<String, Vec<LiteLoaderEntry>>, LoaderError> {
let root: Root = serde_json::from_str(json)
.map_err(|error| LoaderError::metadata(format!("LiteLoader 版本表解析失败:{error}")))?;
let mut out: BTreeMap<String, Vec<LiteLoaderEntry>> = BTreeMap::new();
for (mc_version, game) in &root.versions {
let repo = game
.repo
.as_ref()
.map(|repo| https_repo(&repo.url))
.filter(|url| !url.is_empty())
.unwrap_or_else(|| DEFAULT_REPO.to_string());
let mut entries: Vec<LiteLoaderEntry> = Vec::new();
let mut seen: BTreeSet<String> = BTreeSet::new();
if let Some(branch) = &game.artefacts {
collect(branch, mc_version, &repo, false, &mut entries, &mut seen);
}
if let Some(branch) = &game.snapshots {
collect(branch, mc_version, &repo, true, &mut entries, &mut seen);
}
if entries.is_empty() {
continue;
}
entries.sort_by(|left, right| cmp_versions(&right.version, &left.version));
out.insert(mc_version.clone(), entries);
}
if out.is_empty() {
return Err(LoaderError::not_found(
"LiteLoader 版本表里没有任何可用版本(数据源形状变了?)",
));
}
Ok(out)
}
/// 收一个分支的记录(`latest` 别名与重复版本跳过)。
fn collect(
branch: &Branch,
mc_version: &str,
repo: &str,
snapshot_branch: bool,
out: &mut Vec<LiteLoaderEntry>,
seen: &mut BTreeSet<String>,
) {
for (key, record) in &branch.liteloader {
if key.eq_ignore_ascii_case("latest") || record.version.is_empty() {
continue;
}
if !seen.insert(record.version.clone()) {
continue;
}
// 记录自己标 SNAPSHOT 的也走快照路线(release 分支里出现过混编)。
let snapshot = snapshot_branch || record.stream.eq_ignore_ascii_case("SNAPSHOT");
let dir = format!("{repo}com/mumfrey/liteloader/{mc_version}/");
let (url, snapshot_dir) = if snapshot {
// 快照仓是**固定**的(见 `SNAPSHOT_REPO` 的注释),不跟 `repo.url` 走。
let snap_dir = format!("{SNAPSHOT_REPO}com/mumfrey/liteloader/{mc_version}-SNAPSHOT/");
(None, Some(snap_dir))
} else {
(
(!record.file.is_empty()).then(|| format!("{dir}{}", record.file)),
None,
)
};
out.push(LiteLoaderEntry {
version: record.version.clone(),
mc_version: mc_version.to_string(),
snapshot,
tweak_class: record.tweak_class.clone(),
libraries: record.libraries.iter().map(rebased).collect(),
url,
snapshot_dir,
});
}
}
/// `http://…` → `https://…`(上游表里混着 http;HMCL 同样改写)。
fn https_repo(url: &str) -> String {
if let Some(rest) = url.strip_prefix("http://") {
return format!("https://{rest}");
}
url.to_string()
}
/// 记录级库:保留上游字段(`http` → `https`),只给「没写 url」的库补基址(见模块文档的 group 分流)。
fn rebased(library: &Library) -> Library {
let mut out = library.clone();
match out
.url
.as_deref()
.map(str::trim)
.filter(|url| !url.is_empty())
{
Some(url) => out.url = Some(https_repo(url)),
None => {
let group = out.name.split(':').next().unwrap_or_default();
if group != "net.minecraft" {
out.url = Some(CENTRAL_REPO.to_string());
}
}
}
out
}
/// 版本号比较:按 `.`/`-`/`_` 切段,纯数字段按数值比,其余按字面量比。
fn cmp_versions(left: &str, right: &str) -> std::cmp::Ordering {
let lhs: Vec<&str> = left.split(['.', '-', '_']).collect();
let rhs: Vec<&str> = right.split(['.', '-', '_']).collect();
for (a, b) in lhs.iter().zip(rhs.iter()) {
let order = match (a.parse::<u64>(), b.parse::<u64>()) {
(Ok(a), Ok(b)) => a.cmp(&b),
_ => a.cmp(b),
};
if order != std::cmp::Ordering::Equal {
return order;
}
}
lhs.len().cmp(&rhs.len())
}
/// 从 `maven-metadata.xml` 拼出快照产物的直链(`<dir>liteloader-<mc>-<ts>-<build>-release.jar`)。
///
/// # Errors
///
/// 缺 `<timestamp>`/`<buildNumber>` 时返回 metadata 错误(宁可报错,也不猜一个 404 直链)。
pub fn snapshot_artifact_url(
entry: &LiteLoaderEntry,
metadata_xml: &str,
) -> Result<String, LoaderError> {
let dir = entry.snapshot_dir.as_deref().ok_or_else(|| {
LoaderError::metadata(format!(
"LiteLoader {} 不是快照版本,没有 maven 目录",
entry.version
))
})?;
let timestamp = tag_value(metadata_xml, "timestamp").ok_or_else(|| {
LoaderError::metadata(format!(
"{} 的 maven-metadata.xml 里没有 <timestamp>:拿不到快照直链",
entry.version
))
})?;
let build = tag_value(metadata_xml, "buildNumber").ok_or_else(|| {
LoaderError::metadata(format!(
"{} 的 maven-metadata.xml 里没有 <buildNumber>",
entry.version
))
})?;
Ok(format!(
"{dir}liteloader-{}-{timestamp}-{build}-release.jar",
entry.mc_version
))
}
/// 取 `<tag>…</tag>` 里的文本(maven 元数据只有一层,不引 XML 库,与 [`super::maven`] 同口径)。
fn tag_value(xml: &str, tag: &str) -> Option<String> {
let open = format!("<{tag}>");
let close = format!("</{tag}>");
let start = xml.find(&open)? + open.len();
let end = xml[start..].find(&close)? + start;
let value = xml[start..end].trim();
(!value.is_empty()).then(|| value.to_string())
}
+199
View File
@@ -0,0 +1,199 @@
//! Maven 坐标与版本表(Forge / NeoForge):版本列表地址、版本选择、installer 地址。
//!
//! 与 [`super::forge`] 的分工:这里只管"maven 上有什么版本、jar 在哪",
//! 那边只管"installer 里的 profile 怎么变成版本 JSON"。
//!
//! 镜像:两家 maven 都在 D7 镜像表里有 BMCLAPI 规则(`bmclapi-neoforge` /
//! `bmclapi-forge-maven-net`),所以这里一律**写官方地址**,重写交给 `core::download`
//! 的镜像展开(与 M27 的"不拼镜像 URL"纪律一致)。
//!
//! # 非 maven 族一律「没有答案」,不再回落 Forge
//!
//! 这里每个函数过去都用 `_ =>` 把「不是 NeoForge」的全当 Forge。加了第三个数据源
//! (LiteLoader:官方版本表 + 从零合成,不是 maven)之后,那个通配会**静默拼出 Forge 的
//! URL**:请求 404 或被当成 Forge 装成一个"像模像样"的错实例。现在:
//!
//! - 地址类函数返回 `Option`,非 maven 族给 `None`;
//! - 版本判定类函数对非 maven 族给「不匹配 / 放行原样」,语义上本就如此。
//!
//! 真正的入口分流在 [`super::meta::LoaderSource`](按
//! [`LoaderKind::is_fabric_family`] / [`LoaderKind::is_maven_family`] 三路派发)。
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 根地址(非 maven 族给空串;调用方已按 [`LoaderKind::is_maven_family`] 分流)。
#[must_use]
pub fn base(kind: LoaderKind) -> &'static str {
match kind {
LoaderKind::NeoForge => NEOFORGE_MAVEN,
LoaderKind::Forge => FORGE_MAVEN,
// fabric 系与 LiteLoader 都不是 maven 族——绝不能回落到 Forge 的仓库。
LoaderKind::Fabric | LoaderKind::Quilt | LoaderKind::LiteLoader => "",
}
}
/// maven 路径段(`group/artifact`)与该原版版本的匹配规则。
fn layout(kind: LoaderKind, mc_version: &str) -> Option<(&'static str, &'static str)> {
match kind {
LoaderKind::NeoForge if mc_version == NEOFORGE_FORGE_LAYOUT_MC => {
Some((NEOFORGE_MAVEN, "net/neoforged/forge"))
}
LoaderKind::NeoForge => Some((NEOFORGE_MAVEN, "net/neoforged/neoforge")),
LoaderKind::Forge => Some((FORGE_MAVEN, "net/minecraftforge/forge")),
LoaderKind::Fabric | LoaderKind::Quilt | LoaderKind::LiteLoader => None,
}
}
/// 该原版版本对应的 `maven-metadata.xml` 地址;非 maven 族给 `None`。
#[must_use]
pub fn metadata_url(kind: LoaderKind, mc_version: &str) -> Option<String> {
let (root, path) = layout(kind, mc_version)?;
Some(format!("{root}/{path}/maven-metadata.xml"))
}
/// installer jar 地址;非 maven 族给 `None`。
#[must_use]
pub fn installer_url(kind: LoaderKind, mc_version: &str, maven_version: &str) -> Option<String> {
let (root, path) = layout(kind, mc_version)?;
let file = match kind {
LoaderKind::NeoForge if mc_version != NEOFORGE_FORGE_LAYOUT_MC => {
format!("neoforge-{maven_version}-installer.jar")
}
LoaderKind::NeoForge | LoaderKind::Forge => {
format!("forge-{maven_version}-installer.jar")
}
LoaderKind::Fabric | LoaderKind::Quilt | LoaderKind::LiteLoader => return None,
};
Some(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 版本是否属于该原版版本(非 maven 族恒 `false`:它们的版本号不是 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),
LoaderKind::Forge => version.starts_with(&format!("{mc_version}-")),
LoaderKind::Fabric | LoaderKind::Quilt | LoaderKind::LiteLoader => false,
}
}
/// 从 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 版本,原样返回;
/// 非 maven 族(fabric 系 / LiteLoader)的版本号原样返回——它们的版本号本就自带原版口径。
#[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::Forge => true,
LoaderKind::NeoForge => mc_version == NEOFORGE_FORGE_LAYOUT_MC,
LoaderKind::Fabric | LoaderKind::Quilt | LoaderKind::LiteLoader => false,
};
if needs_prefix && !raw.starts_with(&format!("{mc_version}-")) {
format!("{mc_version}-{raw}")
} else {
raw.to_string()
}
}
/// 用户视角的加载器版本号(带原版前缀的 maven 版本要去掉前缀)。
///
/// 谁带前缀是**按加载器 + 布局**定的:Forge 恒带;NeoForge 只有「1.20.1 走 forge 布局」
/// 那一段带(`1.20.1-47.1.106` → `47.1.106`),其余 NeoForge 版本本身就是完整 maven 版本;
/// fabric 系与 LiteLoader 的版本号本来就是用户视角的。
#[must_use]
pub fn display_loader_version(kind: LoaderKind, mc_version: &str, maven_version: &str) -> String {
let strip = match kind {
LoaderKind::Forge => true,
LoaderKind::NeoForge => mc_version == NEOFORGE_FORGE_LAYOUT_MC,
LoaderKind::Fabric | LoaderKind::Quilt | LoaderKind::LiteLoader => false,
};
if strip {
maven_version
.strip_prefix(&format!("{mc_version}-"))
.unwrap_or(maven_version)
.to_string()
} else {
maven_version.to_string()
}
}
/// 版本号 → 可比较的数字键(非数字段按 0,保证不 panic)。
fn numeric_key(version: &str) -> Vec<u64> {
version
.split(['-', '.', '+'])
.map(|segment| segment.parse::<u64>().unwrap_or(0))
.collect()
}
+368
View File
@@ -0,0 +1,368 @@
//! 加载器元数据的网络层:版本列表 + 官方 profile/installer → [`LoaderRelease`]。
//!
//! 分工与 M27 的 [`crate::game::install::RemoteSource`] 一样:**只发请求、只解析**,
//! 不写游戏目录(installer jar 落 `.ULL/cache/meta/` 作离线复用),镜像/退避/health 全在
//! `core::download` 的引擎里(我们一律递**官方地址**,重写由镜像表负责)。
//!
//! 三个数据源的取数路径完全不同(**三路显式派发**,不许再有「不是 fabric 就是 maven」):
//!
//! ```text
//! Fabric / Quilt : meta API → profile/json(官方直出可用版本 JSON)
//! NeoForge/Forge : maven-metadata.xml → installer jar → 读 install_profile.json + version.json
//! LiteLoader : dl.liteloader.com/versions.json →(快照再读 maven-metadata.xml)→ 从零合成
//! ```
use std::fs::File;
use std::path::{Path, PathBuf};
use zip::ZipArchive;
use super::fabric;
use super::forge::{self, InstallProfile};
use super::liteloader;
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";
/// LiteLoader 官方版本表的缓存文件名(整表 49 KB,一次取全量再本地筛)。
const LITELOADER_LIST_TAG: &str = "liteloader-versions.json";
/// 远端加载器元数据来源。
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());
}
if kind == LoaderKind::LiteLoader {
let table = self.liteloader_table().await?;
let entries = table.get(mc_version.trim()).ok_or_else(|| {
LoaderError::not_found(format!(
"LiteLoader 没有适配原版 {mc_version} 的版本(官方版本表 {} 条原版版本)",
table.len()
))
})?;
return Ok(entries.iter().map(|entry| entry.version.clone()).collect());
}
let url = maven::metadata_url(kind, mc_version).ok_or_else(|| non_maven(kind))?;
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 系取该原版版本下最新的一版,LiteLoader 取首个 release / 否则最新)。
///
/// 等价于 [`Self::resolve_on`],父版本 = 原版本身。
///
/// # Errors
///
/// 网络失败、版本不存在、installer 缺 profile、协议不受支持时返回 [`LoaderError`]。
pub async fn resolve(
&self,
kind: LoaderKind,
mc_version: &str,
loader_version: Option<&str>,
) -> Result<LoaderRelease, LoaderError> {
self.resolve_on(kind, mc_version, loader_version, None)
.await
}
/// 解析一个加载器版本,并**指定父版本**(`parent` 为 `None` 时等于 `mc_version`)。
///
/// 目前只有 LiteLoader 接受「父版本不是原版」:它靠 `--tweakClass` 追加生效,叠在
/// Forge 1.12.2 上是官方主推组合。其它加载器声明自己的主类/启动目标,叠在别人的 loader
/// 层上没有装配路径,因此**具名拒绝**,而不是默默装成「叠在原版上」。
///
/// # Errors
///
/// 同上;另外「非 LiteLoader 却指定了非原版父版本」返回 `unsupported`。
pub async fn resolve_on(
&self,
kind: LoaderKind,
mc_version: &str,
loader_version: Option<&str>,
parent: Option<&str>,
) -> Result<LoaderRelease, LoaderError> {
let requested = loader_version
.map(str::trim)
.filter(|value| !value.is_empty());
let parent_version_id = parent
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(mc_version);
if parent_version_id != mc_version && kind != LoaderKind::LiteLoader {
return Err(LoaderError::unsupported(format!(
"{} 没有「叠在 {parent_version_id} 上」的装配路线:它自己声明主类与启动目标,只能叠在原版 {mc_version} 上",
kind.label()
))
.with_hint("先装原版,再在其上安装这一家加载器"));
}
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);
}
if kind == LoaderKind::LiteLoader {
return self
.resolve_liteloader(mc_version, requested, parent_version_id)
.await;
}
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)
.ok_or_else(|| non_maven(kind))?;
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);
release.parent_version_id = parent_version_id.to_string();
Ok(release)
}
/// LiteLoader:整表 → 选条 →(快照再查 maven-metadata)→ 合成清单。
async fn resolve_liteloader(
&self,
mc_version: &str,
requested: Option<&str>,
parent_version_id: &str,
) -> Result<LoaderRelease, LoaderError> {
let table = self.liteloader_table().await?;
let entries = table.get(mc_version.trim()).ok_or_else(|| {
LoaderError::not_found(format!(
"LiteLoader 没有适配原版 {mc_version} 的版本(官方版本表 {} 条原版版本)",
table.len()
))
})?;
let entry = match requested {
Some(raw) => liteloader::find_entry(entries, raw).ok_or_else(|| {
LoaderError::not_found(format!(
"LiteLoader {} 没有版本 `{raw}`(该原版下有:{})",
mc_version,
entries
.iter()
.map(|entry| entry.version.as_str())
.collect::<Vec<_>>()
.join(" / ")
))
})?,
None => liteloader::default_entry(entries).ok_or_else(|| {
LoaderError::not_found(format!("LiteLoader 没有适配原版 {mc_version} 的版本"))
})?,
};
// 直链:release 分支记录里就有;快照分支必须再读一次 maven-metadata.xml。
let artifact_url = match (&entry.url, &entry.snapshot_dir) {
(Some(url), _) => url.clone(),
(None, Some(dir)) => {
let metadata = self.fetch_text(&format!("{dir}maven-metadata.xml")).await?;
liteloader::snapshot_artifact_url(entry, &metadata)?
}
(None, None) => {
return Err(LoaderError::metadata(format!(
"LiteLoader {} 的记录既没有直链也没有快照目录",
entry.version
)))
}
};
liteloader::build_release(mc_version, parent_version_id, entry, &artifact_url)
}
/// 取 LiteLoader 整张官方版本表(按原版版本分组)。
async fn liteloader_table(
&self,
) -> Result<std::collections::BTreeMap<String, Vec<liteloader::LiteLoaderEntry>>, LoaderError>
{
let body = self
.fetch_text_named(liteloader::VERSION_LIST_URL, LITELOADER_LIST_TAG)
.await?;
liteloader::parse_versions(&body)
}
/// 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> {
self.fetch_text_named(url, "loader-meta.bin").await
}
/// 同上,缓存文件名按调用方给(LiteLoader 整表单独一档,别把小响应和 49 KB 的表混存)。
async fn fetch_text_named(&self, url: &str, tag: &str) -> Result<String, LoaderError> {
let dest = self.meta_cache.join(tag);
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}")))
}
}
/// 非 maven 族被送进 maven 分支时的统一报错(入口已分流,走到这里说明调用链有 bug)。
fn non_maven(kind: LoaderKind) -> LoaderError {
LoaderError::unsupported(format!(
"{} 不是 maven 族加载器,没有 maven 元数据/installer 地址",
kind.label()
))
}
/// 读 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()))
}
+297
View File
@@ -0,0 +1,297 @@
//! 加载器安装(Fabric / Quilt / NeoForge / Forge / LiteLoader):官方元数据 → 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`] | LiteLoader | 官方版本表 → **从零合成**清单(见 [`liteloader`]) |
//! | [`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。
//!
//! OptiFine 仍**认得出但装不了**([`LoaderKind::from_core`] 返回 `None`):它要跑外部
//! `optifine.Patcher` 并原地改 zip,不在本期(见 `DevDocs/artifacts/m218/SURVEY-p1.md` §3)。
pub mod fabric;
pub mod forge;
pub mod liteloader;
pub mod maven;
pub mod meta;
pub mod parent;
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 的原版路径。
/// OptiFine 也**不在**这里:它要跑外部 `optifine.Patcher` + 原地改 zip,本启动器仍只识别不安装。
#[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,
/// LiteLoader(`dl.liteloader.com`,官方版本表 + 从零合成 launchwrapper 清单,无 installer)。
LiteLoader,
}
impl LoaderKind {
/// 解析命令行 / 整合包清单里的加载器名(大小写不敏感)。
///
/// 七个 id 的匹配**委派**给 [`crate::game::LoaderKind::parse`](词表的源头,M126),
/// 本方法只多收一个**输入别名** `neo-forge`:那是 Modrinth 的拼法(mrpack
/// `dependencies` 的键、CF 包里也有这种写法),**只进不出**——[`Self::as_str`] 绝不产出
/// 它。识别得出但本启动器装不了的(原版 / OptiFine)返回 `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::LiteLoader => Some(Self::LiteLoader),
crate::game::LoaderKind::Vanilla | crate::game::LoaderKind::OptiFine => 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(),
Self::LiteLoader => crate::game::LoaderKind::LiteLoader.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",
Self::LiteLoader => "LiteLoader",
}
}
/// 是否属于 Fabric 系(纯 meta、profile JSON 直接可用)。
#[must_use]
pub const fn is_fabric_family(self) -> bool {
matches!(self, Self::Fabric | Self::Quilt)
}
/// 是否走 maven 族(installer jar + install profile)。
///
/// 第三个数据源(LiteLoader)既不是 fabric 系也不是 maven 族,所以这条路**必须显式问**,
/// 不能再用「不是 fabric 就是 maven」的二分——那会把 LiteLoader 静默送进 Forge 的 URL 拼装。
#[must_use]
pub const fn is_maven_family(self) -> bool {
matches!(self, Self::NeoForge | Self::Forge)
}
/// 全部加载器(CLI 的 `--loader` 枚举提示用)。
#[must_use]
pub const fn all() -> &'static [Self] {
&[
Self::Fabric,
Self::Quilt,
Self::NeoForge,
Self::Forge,
Self::LiteLoader,
]
}
/// 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)
}
}
+64
View File
@@ -0,0 +1,64 @@
//! 父版本链:把「叠在哪一层上」翻译成原版版本 id(LiteLoader 叠在 Forge 上时要用)。
//!
//! 加载器层只声明 `inheritsFrom`,客户端 jar、资源索引都在**根**那一层(原版)。所以当用户
//! 指定的父版本不是原版(`1.12.2-forge-14.23.5.2860`)时,我们既要记住父版本 id(写进
//! 合成清单的 `inheritsFrom`),又要知道根的原版 id(拼 loader 版本 id、定位 jar)。
//!
//! 判据只有一条:**读盘**。父版本在盘上就必须读得出它的 `inheritsFrom`;读不出来说明父版本
//! 压根没装好,这时报错比猜一个原版版本号强(猜错会装出 `inheritsFrom` 指向不存在版本、
//! 启动必崩的实例)。沿链走最多 [`MAX_DEPTH`] 跳,成环即报错(与
//! [`crate::game::inherit::resolve`] 同口径)。
use crate::game::layout::GameLayout;
use crate::game::manifest::VersionJson;
use super::LoaderError;
/// 继承链深度上限(现实里 loader + patch 不超过 5 层)。
pub const MAX_DEPTH: usize = 32;
/// 从盘上的版本 JSON 沿 `inheritsFrom` 走到根,返回根版本 id(原版)。
///
/// # Errors
///
/// 版本 JSON 不存在 → `not_found`(父版本还没装);读不出来 / 成环 → `metadata`。
pub fn root_version_id(layout: &GameLayout, version_id: &str) -> Result<String, LoaderError> {
let mut chain = vec![version_id.to_string()];
let mut current = version_id.to_string();
for _ in 0..MAX_DEPTH {
let manifest = read_manifest(layout, &current)?;
match manifest.inherits_from.as_deref().map(str::trim) {
Some(parent) if !parent.is_empty() => {
if chain.iter().any(|id| id == parent) {
chain.push(parent.to_string());
return Err(LoaderError::metadata(format!(
"父版本链成环:{}",
chain.join(" → ")
)));
}
chain.push(parent.to_string());
current = parent.to_string();
}
_ => return Ok(current),
}
}
Err(LoaderError::metadata(format!(
"父版本链超过 {MAX_DEPTH} 层,拒绝继续解析:{}",
chain.join(" → ")
)))
}
/// 读一份版本 JSON;文件不存在时给出「先装父版本」的可执行提示。
fn read_manifest(layout: &GameLayout, version_id: &str) -> Result<VersionJson, LoaderError> {
let path = layout.version_json(version_id);
let text = std::fs::read_to_string(&path).map_err(|error| {
LoaderError::not_found(format!(
"父版本 {version_id} 还没装好(读不到 {}:{error})",
path.display()
))
.with_hint("先用同一个父版本装一次它自己的加载器(例如 `install <原版> --loader forge`),再在它上面叠")
})?;
VersionJson::from_json_str(&text).map_err(|error| {
LoaderError::metadata(format!("父版本 {version_id} 的清单解析失败:{error}"))
})
}
+293
View File
@@ -0,0 +1,293 @@
//! 加载器安装计划:一份 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::{LoaderError, 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,
/// 目标原版版本(客户端 jar 与资源都在这一层)。
pub mc_version: String,
/// **父版本 id**:合成清单的 `inheritsFrom`。默认等于 [`Self::mc_version`](叠在原版上),
/// 叠在别的加载器层上时是那一层的版本 id(`1.12.2-forge-14.23.5.2860`)。
pub parent_version_id: String,
/// 加载器自身版本号(`0.15.11` / `47.2.0` / `21.1.80` / `1.12.2-SNAPSHOT`)。
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 系与 LiteLoader 为 `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()
}
/// 父版本是不是原版本身(`false` = 叠在别的加载器层上)。
#[must_use]
pub fn parent_is_vanilla(&self) -> bool {
self.parent_version_id == self.mc_version
}
/// 是否要外调 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,
"parent_version_id": self.parent_version_id,
"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,
/// **父版本**([`LoaderRelease::parent_version_id`])的清单是否已在盘上。
///
/// 父版本是原版时缺了能自动补装([`LoaderInstallPlan::vanilla_install_needed`]);
/// 父版本是别的加载器层(Forge)时缺了**直接报错**——我们不能替用户决定装哪一版 Forge。
pub parent_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「宁可给出可修复的骨架,也不静默假装成功」一致。
///
/// # Errors
///
/// 父版本不是原版、而它又没装在盘上时返回 [`LoaderError::not_found`](具名说清是哪个
/// 父版本缺了)。**不能默默回落成叠原版**:那会装出一份和用户意图不同的实例。
pub fn build(input: LoaderPlanInput<'_>) -> Result<Self, LoaderError> {
let release = input.release;
// 同一个 loader 版本 id 不能同时表示两条父链(`<原版>-liteloader-<ver>` 与
// 「叠在 Forge 上」撞同一个目录):盘上那份的 `inheritsFrom` 与本次请求不符就具名报错,
// 而不是把旧清单覆盖掉——旧实例会静默换父链,启动行为跟着变。
if let Some(existing) = existing_parent(input.layout, &release.version_id) {
if existing != release.parent_version_id {
return Err(LoaderError::unsupported(format!(
"{} 已装在盘上,父版本是 {existing},与本次请求的 {} 不同",
release.version_id, release.parent_version_id
))
.with_hint(format!(
"loader 版本 id 由「原版版本 + 加载器 + 加载器版本」决定,叠在别的层上会撞同一个目录:先删掉 versions/{}(旧实例也会失去这一层),或换一个加载器版本",
release.version_id
)));
}
}
if !input.parent_installed && !release.parent_is_vanilla() {
return Err(LoaderError::not_found(format!(
"父版本 {} 还没装好:{} {} 要叠在它上面",
release.parent_version_id,
release.kind.label(),
release.loader_version
))
.with_hint(format!(
"先把父版本装上(同一个原版版本先装一次它自己的加载器),再重跑;本次不会替你猜一个 {} 的版本",
release.parent_version_id
)));
}
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.parent_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,
};
Ok(Self {
release: release.clone(),
install,
vanilla_install_needed: !input.parent_installed && release.parent_is_vanilla(),
})
}
/// 计划里的待下载文件。
#[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
}
}
/// 盘上那份 `versions/<id>/<id>.json` 的 `inheritsFrom`(不存在 / 读不出 / 没写 → `None`)。
///
/// 只用来挡「同一版本 id 撞两条父链」,不参与其它判定:读失败不该让安装失败,
/// 所以错误一律吞成 `None`(真正的父子链校验由 [`crate::game::inherit::resolve`] 与
/// [`LoaderPlanInput::parent_installed`] 负责)。
fn existing_parent(layout: &GameLayout, version_id: &str) -> Option<String> {
let text = std::fs::read_to_string(layout.version_json(version_id)).ok()?;
let manifest = VersionJson::from_json_str(&text).ok()?;
manifest
.inherits_from
.map(|parent| parent.trim().to_string())
.filter(|parent| !parent.is_empty())
}
@@ -0,0 +1,84 @@
//! 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,
manifest: 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::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;
use crate::platform::child::quiet;
/// 一步的结局。
#[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 = quiet(&runtime.binary)
.arg("-cp")
.arg(&classpath)
.arg(&main_class)
.args(&step.args)
.current_dir(&plan.scratch)
.output()
.map_err(|error| {
ProcessorError::io(format!(
"起 java 失败({}):{error}",
runtime.binary.display()
))
.with_hint(
"确认 `ull java list` 里的路径可用,或用 `ull java install <major>` 装一个托管 JDK",
)
})?;
let elapsed_ms = started.elapsed().as_millis();
let stdout = redactor
.scrub(&String::from_utf8_lossy(&output.stdout))
.into_owned();
let stderr = redactor
.scrub(&String::from_utf8_lossy(&output.stderr))
.into_owned();
if !output.status.success() {
let exit = output
.status
.code()
.map_or_else(|| "signal".to_string(), |code| code.to_string());
return Err(
ProcessorError::failed(&step.label, &exit, &tail(&stderr)).with_hint(
"installer 的 processor 是官方产物,失败多半是 Java 版本/磁盘权限/网络(部分步骤要联网取映射)",
),
);
}
verify_outputs(step, warnings)?;
Ok(StepOutcome {
index: step.index,
label: step.label.clone(),
status: StepStatus::Executed,
outputs: step.outputs.len(),
elapsed_ms,
stdout_tail: non_empty(tail(&stdout)),
stderr_tail: non_empty(tail(&stderr)),
})
}
/// 依赖 jar 是否都在盘上(缺了直接报错:起了 java 也只会得到 `NoClassDefFoundError`)。
fn ensure_dependencies(step: &ProcessorStep) -> Result<(), ProcessorError> {
if !step.jar.is_file() {
return Err(ProcessorError::library_missing(format!(
"processor {} 的可执行 jar 不在盘上:{}",
step.label,
step.jar.display()
)));
}
let missing: Vec<String> = step
.classpath
.iter()
.filter(|path| !path.is_file())
.map(|path| path.display().to_string())
.collect();
if missing.is_empty() {
return Ok(());
}
Err(ProcessorError::library_missing(format!(
"processor {} 有 {} 个依赖 jar 不在盘上:{}",
step.label,
missing.len(),
missing.join(";")
))
.with_hint("重跑一次 `ull install` 会补齐依赖(下载走镜像/健康度引擎)"))
}
/// 产物存在性(硬)与摘要(软)校验。
fn verify_outputs(step: &ProcessorStep, warnings: &mut Vec<String>) -> Result<(), ProcessorError> {
let missing: Vec<String> = step
.outputs
.iter()
.filter(|output| !output.path.is_file())
.map(|output| output.path.display().to_string())
.collect();
if !missing.is_empty() {
return Err(ProcessorError::output_missing(format!(
"processor {} 跑完了但产物没落盘:{}",
step.label,
missing.join(";")
))
.with_hint("磁盘权限/杀软拦截是常见原因;也可以删掉 versions/ 下这版后重装"));
}
for output in &step.outputs {
let Some(expected) = &output.sha1 else {
continue;
};
let Ok(actual) = verify::digest_file(&output.path, HashAlgo::Sha1) else {
continue;
};
if !verify::matches(expected, &actual) {
warnings.push(format!(
"processor {} 的产物摘要与 profile 不符(期望 {expected} 实际 {actual}):非标准 zlib 实现会压出不同字节,按可用处理",
step.label
));
}
}
Ok(())
}
/// 产物是否都已就绪(幂等判据:存在 + 摘要命中;profile 没给摘要就只认存在)。
fn outputs_satisfied(step: &ProcessorStep, warnings: &mut Vec<String>) -> bool {
if step.outputs.is_empty() {
// 没有产物声明 → 无法判断是否跑过,只能每次都跑(HMCL 同样处理)。
return false;
}
for output in &step.outputs {
if !output.path.is_file() {
return false;
}
let Some(expected) = &output.sha1 else {
continue;
};
let Ok(actual) = verify::digest_file(&output.path, HashAlgo::Sha1) else {
return false;
};
if !verify::matches(expected, &actual) {
warnings.push(format!(
"{} 的摘要与 profile 不符,重新执行该 processor",
output.path.display()
));
return false;
}
}
true
}
@@ -0,0 +1,83 @@
//! 从 processor jar 的 `MANIFEST.MF` 里取 `Main-Class`。
//!
//! 官方 install profile **不写主类**(`processors[].jar` 只给坐标),主类在 jar 的清单里;
//! 少数 fork 会额外给 `mainClass`(那条路走 [`super::plan::ProcessorStep::main_class`],
//! 不到这里)。清单按 RFC 822 折行:续行以**单个空格**开头。
use std::path::Path;
use super::error::ProcessorError;
/// 读 jar 的 `Main-Class`。
///
/// # Errors
///
/// jar 打不开/不是 zip(IO/规划错误)、没有 `META-INF/MANIFEST.MF` 或清单里没有
/// `Main-Class`(规划错误)。
pub(super) fn read_main_class(jar: &Path) -> Result<String, ProcessorError> {
use std::io::Read;
let file = std::fs::File::open(jar).map_err(|error| {
ProcessorError::io(format!(
"打开 processor jar 失败({}):{error}",
jar.display()
))
})?;
let mut archive = zip::ZipArchive::new(file).map_err(|error| {
ProcessorError::plan(format!("{} 不是合法 jar:{error}", jar.display()))
})?;
let mut manifest = String::new();
{
let mut entry = archive.by_name("META-INF/MANIFEST.MF").map_err(|error| {
ProcessorError::plan(format!(
"{} 里没有 META-INF/MANIFEST.MF(拿不到主类):{error}",
jar.display()
))
})?;
entry.read_to_string(&mut manifest).map_err(|error| {
ProcessorError::io(format!("读 {} 的 MANIFEST 失败:{error}", jar.display()))
})?;
}
parse_main_class(&manifest).ok_or_else(|| {
ProcessorError::plan(format!(
"{} 的 MANIFEST 里没有 Main-Class(install profile 也没给 mainClass)",
jar.display()
))
})
}
/// 从 MANIFEST 文本里取 `Main-Class`(续行以单个空格开头)。
fn parse_main_class(manifest: &str) -> Option<String> {
let mut lines = manifest.lines().peekable();
while let Some(line) = lines.next() {
let Some(rest) = line.strip_prefix("Main-Class:") else {
continue;
};
let mut value = rest.trim().to_string();
while let Some(next) = lines.peek() {
let Some(continuation) = next.strip_prefix(' ') else {
break;
};
value.push_str(continuation.trim());
lines.next();
}
return (!value.is_empty()).then_some(value);
}
None
}
#[cfg(test)]
mod tests {
use super::parse_main_class;
#[test]
fn parses_main_class_with_continuation_lines() {
let manifest = "Manifest-Version: 1.0\r\nMain-Class: com.example.\r\n Tool\r\n\r\n";
assert_eq!(
parse_main_class(manifest).as_deref(),
Some("com.example.Tool")
);
assert!(parse_main_class("Manifest-Version: 1.0\n").is_none());
assert!(parse_main_class("Main-Class: \n").is_none());
}
}
@@ -0,0 +1,56 @@
//! Installer Processors 执行器(M35):把 install profile 的 `processors[]` 跑起来。
//!
//! 设计输入:`DevDocs/analyse/05-modloader-modpack.md` §1.2(Forge 新式 installer 的
//! processor 流水线)与 HMCL `ForgeNewInstallTask` / `ProcessorTask` 的实现。
//!
//! # 覆盖范围
//!
//! | 版本段 | 有无客户端 processor | 本模块 |
//! |--------|----------------------|--------|
//! | Forge 1.13 ~ 1.20.x(spec 1) | 有(拆包/映射/打补丁) | ✅ 真跑 |
//! | NeoForge 1.20.5+(26.x 只剩 1 个 `PROCESS_MINECRAFT_JAR`) | 有 | ✅ 真跑 |
//! | Forge spec 0(1.12.2 重建版)/ Fabric / Quilt | 无 | 不需要(走 M32 的 json 路线) |
//!
//! # 流水线
//!
//! ```text
//! plan install profile 的 data 表 → 变量表({MINECRAFT_JAR}/{SIDE}/[坐标]/'字面量')
//! → 每步 java -cp 参数 + 产物/摘要 + processor 依赖库待下清单
//! download 依赖库(走 M27 引擎:镜像/健康度/退避/校验)
//! exec 串行外调 java(主类取自 jar 的 MANIFEST)→ 产物存在性(硬)+ 摘要(软)
//! ```
//!
//! # 三条边界
//!
//! 1. **不写版本 JSON**:那是 M27 `execute` 的事,且必须等 processors 全部成功
//! (版本清单存在 = 这一版真的装完了,半成品不许留);
//! 2. **不改共享层**:原版 client jar 先复制到临时目录再交给 processor,
//! processor 只会改临时副本;
//! 3. **不猜**:`data` 表里缺变量、processor 缺 `jar`、产物没落盘,一律明确报错,
//! 不拿半截参数去试(那种失败的 stderr 更难查)。
//!
//! # 幂等
//!
//! 两级:①每步按 `outputs` 的 SHA-1 命中整步跳过(重跑零成本、不碰文件系统);
//! ②调用方以「版本 JSON 已落盘」为粗粒度判据整段跳过。
pub mod download;
pub mod error;
pub mod exec;
pub mod jar;
pub mod plan;
pub mod prepare;
pub mod spec;
pub mod text;
pub mod vars;
#[cfg(test)]
mod tests;
pub use download::download_libraries;
pub use error::{ProcessorError, ProcessorErrorKind};
pub use exec::{run, select_java, ProcessorRun, StepOutcome, StepSink, StepStatus};
pub use plan::{ProcessorPlan, ProcessorPlanInput, ProcessorStep, StepOutput};
pub use spec::{Datum, Processor};
pub use text::{host_redactor, tail};
pub use vars::{EmbeddedFile, Variables, VariablesInput, MINECRAFT_JAR_NAME};
@@ -0,0 +1,366 @@
//! processor 执行计划:`data` 表展开 → 每步的 `java -cp …` 命令行 → 依赖库待下载清单。
//!
//! 纯计算(不下载、不起进程),因此 `--dry-run` 能直接把「将执行的 processor 清单 +
//! 所需 Java + 依赖库」打出来。唯一读盘的动作是 [`crate::game::java::requirement_for_version`]
//! 读原版版本 JSON 判 Java 要求——那是诊断信息,读不到就按静态表兜底。
//!
//! # 为什么依赖库与启动 classpath 要分开
//!
//! install profile 的 `libraries` 是 **processor 依赖**(installertools / jarsplitter /
//! srgutils…),它们不进游戏 classpath。M32 只把数量记进告警,M35 起真正下载它们
//! (否则 `java -cp` 一排文件都不在盘上),但仍然**绝不**并进 loader 版本 JSON。
use std::path::{Path, PathBuf};
use serde_json::{json, Value};
use super::error::ProcessorError;
use super::spec::Processor;
use super::vars::{absolute, Variables, VariablesInput, MINECRAFT_JAR_NAME};
use crate::addon::loader::forge::InstallProfile;
use crate::addon::loader::{maven, LoaderKind};
use crate::download::ResourceKind;
use crate::game::arguments::Environment;
use crate::game::install::libraries;
use crate::game::install::{FileRole, PlanFile};
use crate::game::java::{requirement_for_version, JavaRequirement};
use crate::game::layout::GameLayout;
use crate::game::library::MavenCoordinate;
/// 一个 processor 的某个产物。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StepOutput {
/// 产物绝对路径。
pub path: PathBuf,
/// 期望 SHA-1(profile 没给或给的不是摘要时为 `None`,只能按存在性判定)。
pub sha1: Option<String>,
}
/// 一个待执行的 processor(已展开参数,不再依赖 install profile)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProcessorStep {
/// 序号(从 1 起,顺序即执行顺序)。
pub index: usize,
/// 人读标识(jar 的 maven 坐标)。
pub label: String,
/// 可执行 jar 的磁盘路径。
pub jar: PathBuf,
/// 主类(profile 没写就留 `None`,执行期读 jar 的 MANIFEST)。
pub main_class: Option<String>,
/// `-cp` 的依赖 jar(不含 `jar` 自己)。
pub classpath: Vec<PathBuf>,
/// 已展开的参数。
pub args: Vec<String>,
/// 产物与期望摘要。
pub outputs: Vec<StepOutput>,
}
impl ProcessorStep {
/// 一步的 `-cp` 全串(依赖在前、可执行 jar 在后,与 HMCL 一致)。
///
/// # Errors
///
/// 路径里含分隔符时返回 IO 错误(Windows 的 `;` 不可能出现在合法路径里,
/// 真出现说明路径是拼错的)。
pub fn classpath_join(&self) -> Result<String, ProcessorError> {
let paths = self
.classpath
.iter()
.chain(std::iter::once(&self.jar))
.map(|path| path.as_path());
std::env::join_paths(paths)
.map(|joined| joined.to_string_lossy().into_owned())
.map_err(|error| ProcessorError::io(format!("classpath 拼不起来:{error}")))
}
}
/// [`ProcessorPlan::build`] 的输入。
pub struct ProcessorPlanInput<'a> {
/// 加载器种类(只影响补库时的 maven 基址兜底)。
pub kind: LoaderKind,
/// 目标原版版本。
pub mc_version: &'a str,
/// loader 版本 id(依赖库 `hint=local` 的落点)。
pub version_id: &'a str,
/// installer 里读出来的 install profile。
pub profile: &'a InstallProfile,
/// 游戏目录布局。
pub layout: &'a GameLayout,
/// installer jar 的磁盘路径。
pub installer: &'a Path,
/// 临时目录(解包/`MINECRAFT_JAR` 副本都落这里)。
pub scratch: &'a Path,
/// 规则求值环境。
pub environment: &'a Environment,
}
/// processor 执行计划。
#[derive(Debug, Clone)]
pub struct ProcessorPlan {
/// loader 版本 id。
pub version_id: String,
/// installer jar 路径。
pub installer: PathBuf,
/// 临时目录。
pub scratch: PathBuf,
/// 原版 client jar(复制源)。
pub minecraft_jar: PathBuf,
/// 交给 processor 的 client jar 副本(`{MINECRAFT_JAR}`)。
pub minecraft_jar_copy: PathBuf,
/// 展开后的变量表。
pub variables: Variables,
/// 按顺序待执行的 processor。
pub steps: Vec<ProcessorStep>,
/// processor 依赖库(走 M27 引擎下载)。
pub libraries: Vec<PlanFile>,
/// 跑 installer 需要的 Java 要求。
pub java_requirement: JavaRequirement,
/// 规划期告警(不致命)。
pub warnings: Vec<String>,
}
impl ProcessorPlan {
/// 组计划。
///
/// # Errors
///
/// `data` 表变量缺定义、构件坐标不合法、processor 缺 `jar` 字段时返回规划错误。
pub fn build(input: ProcessorPlanInput<'_>) -> Result<Self, ProcessorError> {
let scratch = absolute(input.scratch);
let variables = Variables::build(VariablesInput {
data: &input.profile.data,
layout: input.layout,
mc_version: input.mc_version,
installer: input.installer,
scratch: &scratch,
})?;
let mut warnings = Vec::new();
let mut steps = Vec::new();
let mut needed: Vec<(String, PathBuf)> = Vec::new();
for processor in input
.profile
.processors
.iter()
.filter(|processor| processor.applies_to_client())
{
let coordinate = processor.jar_coordinate().ok_or_else(|| {
ProcessorError::plan("install profile 里有 processor 缺 `jar` 字段")
})?;
let jar = library_path(input.layout, coordinate)?;
needed.push((coordinate.to_string(), jar.clone()));
let mut classpath = Vec::with_capacity(processor.classpath.len());
for entry in &processor.classpath {
let path = library_path(input.layout, entry)?;
needed.push((entry.trim().to_string(), path.clone()));
classpath.push(path);
}
let mut args = Vec::with_capacity(processor.args.len());
for arg in &processor.args {
args.push(variables.expand(arg)?);
}
let outputs = step_outputs(&variables, processor, coordinate, &scratch, &mut warnings)?;
steps.push(ProcessorStep {
index: steps.len() + 1,
label: coordinate.to_string(),
jar,
main_class: processor.main_class().map(str::to_string),
classpath,
args,
outputs,
});
}
let (mut libraries, library_warnings) = libraries::plan(
input.layout,
input.version_id,
&input.profile.libraries,
input.environment,
);
warnings.extend(library_warnings);
let base = library_base(input.kind, input.profile);
for (coordinate, dest) in &needed {
if libraries.iter().any(|file| &file.dest == dest) {
continue;
}
warnings.push(format!(
"processor 依赖 {coordinate} 不在 install profile 的 libraries 节里,按 maven 基址补一条"
));
let Ok(parsed) = MavenCoordinate::parse(coordinate) else {
continue;
};
libraries.push(PlanFile {
role: FileRole::Library,
name: coordinate.clone(),
url: join_url(&base, &parsed.relative_path()),
sha1: None,
size: None,
dest: dest.clone(),
resource: ResourceKind::Library,
});
}
// NeoForge 的 install profile 会把同一条依赖**声明多次**(实测 21.1.80:
// cli-utils / srgutils / jopt-simple / gson 各 2~3 次)。不下重:同一落点被下两次
// 会在同一个 `.part` 中间文件上互相抢(真机现场:原子落盘 os error 2)。
let (libraries, duplicated) = dedupe_by_dest(libraries);
if duplicated > 0 {
warnings.push(format!(
"install profile 重复声明了 {duplicated} 个 processor 依赖,已按落点去重"
));
}
let java_requirement = requirement_for_version(input.layout, input.mc_version);
let minecraft_jar_copy = variables
.get("MINECRAFT_JAR")
.map_or_else(|| scratch.join(MINECRAFT_JAR_NAME), PathBuf::from);
Ok(Self {
version_id: input.version_id.to_string(),
installer: absolute(input.installer),
scratch,
minecraft_jar: input.layout.version_jar(input.mc_version),
minecraft_jar_copy,
variables,
steps,
libraries,
java_requirement,
warnings,
})
}
/// 是否一个 processor 都没有。
#[must_use]
pub fn is_empty(&self) -> bool {
self.steps.is_empty()
}
/// 汇总行数(CLI 人读输出用)。
#[must_use]
pub fn step_count(&self) -> usize {
self.steps.len()
}
/// `--dry-run` / 报告里的 processors 载荷。
#[must_use]
pub fn to_json(&self) -> Value {
json!({
"count": self.steps.len(),
"installer": self.installer.display().to_string(),
"scratch": self.scratch.display().to_string(),
"minecraft_jar": self.minecraft_jar.display().to_string(),
"libraries": self.libraries.len(),
"library_files": self.libraries.iter().map(|file| json!({
"name": file.name,
"url": file.url,
"dest": file.dest.display().to_string(),
"sha1": file.sha1,
"size": file.size,
})).collect::<Vec<_>>(),
"variables": self.variables.len(),
"embedded_files": self.variables.embedded().len(),
"java_requirement": {
"minimum": self.java_requirement.minimum,
"maximum": self.java_requirement.maximum,
"describe": self.java_requirement.describe(),
"source": self.java_requirement.source.as_str(),
},
"steps": self.steps.iter().map(step_json).collect::<Vec<_>>(),
"warnings": self.warnings,
})
}
}
/// 一条 processor 的 JSON 视图。
fn step_json(step: &ProcessorStep) -> Value {
json!({
"index": step.index,
"jar": step.label,
"jar_path": step.jar.display().to_string(),
"main_class": step.main_class,
"classpath": step.classpath.len(),
"args": step.args,
"outputs": step.outputs.iter().map(|output| json!({
"path": output.path.display().to_string(),
"sha1": output.sha1,
})).collect::<Vec<_>>(),
})
}
/// 展开一步的 `outputs` 表(路径 + 期望摘要)。
fn step_outputs(
variables: &Variables,
processor: &Processor,
coordinate: &str,
scratch: &Path,
warnings: &mut Vec<String>,
) -> Result<Vec<StepOutput>, ProcessorError> {
let mut outputs = Vec::with_capacity(processor.outputs.len());
for (raw_path, raw_sha1) in &processor.outputs {
let expanded = variables.expand(raw_path)?;
let path = PathBuf::from(&expanded);
let path = if path.is_absolute() {
path
} else {
scratch.join(path)
};
let expanded_sha1 = variables.expand(raw_sha1).unwrap_or_default();
let sha1 = is_sha1(&expanded_sha1).then(|| expanded_sha1.to_ascii_lowercase());
if sha1.is_none() {
warnings.push(format!(
"processor {coordinate} 的输出 {} 没给 SHA-1,只能按存在性判定",
path.display()
));
}
outputs.push(StepOutput { path, sha1 });
}
Ok(outputs)
}
/// 40 位十六进制(SHA-1 的样子)。
fn is_sha1(value: &str) -> bool {
value.len() == 40 && value.chars().all(|ch| ch.is_ascii_hexdigit())
}
/// 坐标 → `libraries/` 下的路径。
fn library_path(layout: &GameLayout, raw: &str) -> Result<PathBuf, ProcessorError> {
let coordinate = MavenCoordinate::parse(raw.trim()).map_err(|error| {
ProcessorError::plan(format!("processor 依赖坐标不合法:`{raw}`({error})"))
})?;
Ok(layout.libraries_dir().join(coordinate.relative_path()))
}
/// 补库时用的 maven 基址:install profile 自己声明的 `url` 优先,否则按加载器兜底。
fn library_base(kind: LoaderKind, profile: &InstallProfile) -> String {
profile
.libraries
.iter()
.filter_map(|library| library.url.as_deref())
.map(str::trim)
.find(|url| !url.is_empty())
.map_or_else(|| maven::base(kind).to_string(), str::to_string)
}
/// `基址 + 相对路径`(保证中间恰好一个 `/`)。
fn join_url(base: &str, relative: &Path) -> String {
let path = relative.to_string_lossy().replace('\\', "/");
if base.ends_with('/') {
format!("{base}{path}")
} else {
format!("{base}/{path}")
}
}
/// 按落点去重(保留首次出现的条目),返回(去重后的清单,丢掉几条)。
fn dedupe_by_dest(files: Vec<PlanFile>) -> (Vec<PlanFile>, usize) {
let total = files.len();
let mut seen: Vec<PathBuf> = Vec::with_capacity(total);
let mut kept: Vec<PlanFile> = Vec::with_capacity(total);
for file in files {
if seen.contains(&file.dest) {
continue;
}
seen.push(file.dest.clone());
kept.push(file);
}
let dropped = total - kept.len();
(kept, dropped)
}
@@ -0,0 +1,201 @@
//! 临时目录准备:建目录、解包 installer 内的文件、复制原版 client jar。
//!
//! 三条都是「起 java 之前的脏活」,失败一律归 `processor_io`/`processor_plan`
//! (区别于 processor 自己跑挂的 `processor_failed`)。
use super::error::ProcessorError;
use super::plan::ProcessorPlan;
/// 单条目的**预分配**上限(64 MiB):installer jar 下载后只校验「是合法 zip」,
/// 没有校验和(`meta.rs` 的 `fetch_installer`),因此 zip header 里声明的解压尺寸
/// 是不可信输入——损坏或镜像投毒的 installer 可以声明天文数字,让一次小文件解包直接
/// 申请多 GB 内存。`Vec` 仍按实际读入增长,合法条目行为不变。
const PREALLOC_MAX: u64 = 64 << 20;
/// 单条目的**实际字节**上限(256 MiB):预分配封顶挡不住「声明小、解压极大」的
/// zip bomb,所以读入过程本身也要有硬顶。超过就明确报错(不静默截断)。
const MAX_EMBEDDED_BYTES: u64 = 256 << 20;
/// 执行前的准备:建临时目录、解包 installer 内的文件、复制原版 client jar。
pub(super) fn prepare(plan: &ProcessorPlan) -> Result<(), ProcessorError> {
std::fs::create_dir_all(&plan.scratch).map_err(|error| {
ProcessorError::io(format!(
"建临时目录失败({}):{error}",
plan.scratch.display()
))
})?;
extract_embedded(plan)?;
copy_minecraft_jar(plan)
}
/// 把 install profile 声明要解包的文件从 installer zip 里抽到临时目录。
fn extract_embedded(plan: &ProcessorPlan) -> Result<(), ProcessorError> {
let embedded = plan.variables.embedded();
if embedded.is_empty() {
return Ok(());
}
use std::io::Read;
let installer = &plan.installer;
let file = std::fs::File::open(installer).map_err(|error| {
ProcessorError::io(format!(
"打开 installer 失败({}):{error}",
installer.display()
))
})?;
let mut archive = zip::ZipArchive::new(file).map_err(|error| {
ProcessorError::plan(format!("{} 不是合法 zip:{error}", installer.display()))
})?;
for item in embedded {
let mut entry = archive.by_name(&item.entry).map_err(|error| {
ProcessorError::plan(format!(
"installer 里没有 {}(data 表声明要解包):{error}",
item.entry
))
})?;
let mut bytes = Vec::with_capacity(prealloc_hint(entry.size()));
let read = (&mut entry)
.take(MAX_EMBEDDED_BYTES + 1)
.read_to_end(&mut bytes)
.map_err(|error| {
ProcessorError::io(format!("读 installer 条目 {} 失败:{error}", item.entry))
})?;
if read as u64 > MAX_EMBEDDED_BYTES {
return Err(ProcessorError::plan(format!(
"installer 条目 {} 解压后超过 {} MiB,拒绝继续(installer 可能损坏或被投毒)",
item.entry,
MAX_EMBEDDED_BYTES >> 20
)));
}
crate::platform::atomic::write_atomic(&item.dest, &bytes).map_err(|error| {
ProcessorError::io(format!(
"写临时文件失败({}):{error}",
item.dest.display()
))
})?;
}
Ok(())
}
/// 预分配提示:**不信任** header 声明的尺寸,封顶 [`PREALLOC_MAX`]。
fn prealloc_hint(declared: u64) -> usize {
declared.min(PREALLOC_MAX) as usize
}
/// 复制原版 client jar 到临时目录(processor 会改写输入,不能让它碰共享层)。
fn copy_minecraft_jar(plan: &ProcessorPlan) -> Result<(), ProcessorError> {
let source = &plan.minecraft_jar;
let target = &plan.minecraft_jar_copy;
let fresh = match (std::fs::metadata(source), std::fs::metadata(target)) {
(Ok(left), Ok(right)) => left.len() != right.len(),
(Ok(_), Err(_)) => true,
(Err(error), _) => {
return Err(ProcessorError::io(format!(
"原版 client jar 不在盘上({}):{error}",
source.display()
))
.with_hint("先装原版:`ull install <version>`"))
}
};
if !fresh {
return Ok(());
}
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent).map_err(|error| {
ProcessorError::io(format!("建目录失败({}):{error}", parent.display()))
})?;
}
std::fs::copy(source, target).map_err(|error| {
ProcessorError::io(format!(
"复制 {} → {} 失败:{error}",
source.display(),
target.display()
))
})?;
Ok(())
}
#[cfg(test)]
mod tests {
use std::path::Path;
use super::{prealloc_hint, prepare, PREALLOC_MAX};
use crate::addon::loader::forge::InstallProfile;
use crate::addon::loader::processors::{ProcessorPlan, ProcessorPlanInput};
use crate::addon::loader::LoaderKind;
use crate::game::arguments::Environment;
use crate::game::layout::GameLayout;
/// 一步 processor + 一个要从 installer 里解包的 data 值。
const PROFILE: &str = r#"{
"spec": 1, "minecraft": "1.20.1", "json": "/version.json",
"processors": [{"jar": "net.minecraftforge:binarypatcher:1.1.1", "args": ["--apply", "{BINPATCH}"]}],
"libraries": [],
"data": {"BINPATCH": {"client": "/data/client.lzma"}}
}"#;
#[test]
fn preallocation_is_capped_so_a_forged_header_cannot_oom() {
// installer jar 没有校验和:header 可以声称 16 EiB 的解压尺寸。
assert_eq!(prealloc_hint(u64::MAX), PREALLOC_MAX as usize);
assert_eq!(prealloc_hint(1 << 40), PREALLOC_MAX as usize);
// 合法条目照旧按实际尺寸预分配(封顶不影响常见大小)。
assert_eq!(prealloc_hint(0), 0);
assert_eq!(prealloc_hint(4096), 4096);
assert_eq!(prealloc_hint(PREALLOC_MAX), PREALLOC_MAX as usize);
assert_eq!(prealloc_hint(PREALLOC_MAX - 1), PREALLOC_MAX as usize - 1);
}
#[test]
fn prepare_extracts_the_declared_entry_and_copies_the_client_jar() {
let root = super::super::tests::temp_root("prepare-extract");
let layout = GameLayout::new(&root);
let profile: InstallProfile = serde_json::from_str(PROFILE).expect("夹具应可解析");
let installer = root.join("cache/installer.jar");
write_zip(&installer, "data/client.lzma", b"patch-bytes");
let vanilla = root.join("versions/1.20.1/1.20.1.jar");
std::fs::create_dir_all(vanilla.parent().expect("有父目录")).expect("建目录");
std::fs::write(&vanilla, b"client-jar").expect("写原版 jar");
let plan = ProcessorPlan::build(ProcessorPlanInput {
kind: LoaderKind::Forge,
mc_version: "1.20.1",
version_id: "1.20.1-forge-47.2.0",
profile: &profile,
layout: &layout,
installer: &installer,
scratch: &root.join("scratch"),
environment: &Environment::host(),
})
.expect("计划应能组出来");
assert_eq!(plan.variables.embedded().len(), 1);
prepare(&plan).expect("准备应成功");
let dest = &plan.variables.embedded()[0].dest;
assert_eq!(std::fs::read(dest).expect("读解包产物"), b"patch-bytes");
// processor 拿到的是副本,共享层的原版 jar 内容不受影响。
assert_eq!(
std::fs::read(&plan.minecraft_jar_copy).expect("读 client jar 副本"),
b"client-jar"
);
assert_ne!(plan.minecraft_jar_copy, plan.minecraft_jar);
// 幂等:重跑按大小判定,不重复复制、不报错。
prepare(&plan).expect("重跑应成功");
}
/// 写一个只含一条 Stored 条目的 zip(installer 夹具)。
fn write_zip(path: &Path, entry: &str, bytes: &[u8]) {
use std::io::Write;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("建目录");
}
let file = std::fs::File::create(path).expect("建 zip");
let mut writer = zip::ZipWriter::new(file);
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Stored);
writer.start_file(entry, options).expect("写条目");
writer.write_all(bytes).expect("写内容");
writer.finish().expect("收尾 zip");
}
}
@@ -0,0 +1,121 @@
//! install profile 的 `processors[]` 与 `data` 表:**纯解析,不动作**。
//!
//! 字段含义照 Forge installer spec(与 HMCL `ForgeNewInstallProfile.Processor/Datum` 一致):
//!
//! ```json
//! "processors": [{
//! "sides": ["client"], // 缺省 = 两侧都跑
//! "jar": "net.minecraftforge:installertools:1.3.0", // 可执行 jar(主类在 MANIFEST 里)
//! "classpath": ["net.minecraftforge:jarsplitter:1.1.4"], // 依赖 jar(jar 自己不在里面)
//! "args": ["--task", "MCP_DATA", "--output", "{MAPPINGS}"],
//! "outputs": {"{MAPPINGS}": "ab12…(期望 SHA-1)"}
//! }],
//! "data": {"MAPPINGS": {"client": "[de.oceanlabs.mcp:mcp_config:1.20.1@zip]", "server": "…"}}
//! ```
//!
//! 三条容易看错的点:
//!
//! 1. `data` 的每条是**按侧**的(`client` / `server`),client 侧只取 `.client`;
//! 2. 官方只写 `jar`,**不写主类**——主类在 jar 的 `META-INF/MANIFEST.MF`(`Main-Class`);
//! 少数 fork 会额外给 `mainClass`,所以这里留了口子;
//! 3. `classpath` **不含** `jar` 自己(Forge 的约定),拼 `-cp` 时要补上。
use std::collections::BTreeMap;
use serde::Deserialize;
/// install profile 里的一个 processor。
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
pub struct Processor {
/// 生效侧(`client` / `server`;空表示两侧都要)。
#[serde(default)]
pub sides: Vec<String>,
/// 可执行 jar 的 maven 坐标。
#[serde(default)]
pub jar: Option<String>,
/// 主类覆盖(官方 profile 不写,留给少数 fork)。
#[serde(default, rename = "mainClass")]
pub main_class: Option<String>,
/// 依赖 jar 的 maven 坐标(**不含** `jar` 自己)。
#[serde(default)]
pub classpath: Vec<String>,
/// 命令行参数(可含 `{VAR}` / `[坐标]` / `'字面量'` 三种占位)。
#[serde(default)]
pub args: Vec<String>,
/// 产物表:路径占位 → 期望 SHA-1(既有产物可用于幂等跳过)。
#[serde(default)]
pub outputs: BTreeMap<String, String>,
}
impl Processor {
/// 是否在 client 侧生效。
#[must_use]
pub fn applies_to_client(&self) -> bool {
self.sides.is_empty() || self.sides.iter().any(|side| side == "client")
}
/// `jar` 坐标(去掉首尾空白;缺失或空串返回 `None`)。
#[must_use]
pub fn jar_coordinate(&self) -> Option<&str> {
self.jar
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
}
/// 主类覆盖(缺失或空串返回 `None`)。
#[must_use]
pub fn main_class(&self) -> Option<&str> {
self.main_class
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
}
}
/// `data` 表的一条:按侧给值(client 侧只用 `.client`)。
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
pub struct Datum {
/// client 侧的值(`[坐标]` / `'字面量'` / installer 内的文件路径)。
#[serde(default)]
pub client: Option<String>,
/// server 侧的值(本期不跑 server 安装,只解析不动作)。
#[serde(default)]
pub server: Option<String>,
}
impl Datum {
/// client 侧的值(空串视为没有)。
#[must_use]
pub fn client_value(&self) -> Option<&str> {
self.client
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
}
}
#[cfg(test)]
mod tests {
use super::Processor;
#[test]
fn sides_empty_means_both_and_client_subset_is_recognised() {
let both: Processor = serde_json::from_str(r#"{"jar": "g:a:1"}"#).expect("应可解析");
assert!(both.applies_to_client());
let server: Processor =
serde_json::from_str(r#"{"sides": ["server"], "jar": "g:a:1"}"#).expect("应可解析");
assert!(!server.applies_to_client());
let client: Processor =
serde_json::from_str(r#"{"sides": ["client"], "jar": "g:a:1"}"#).expect("应可解析");
assert!(client.applies_to_client());
}
#[test]
fn blank_jar_and_main_class_are_treated_as_absent() {
let processor: Processor =
serde_json::from_str(r#"{"jar": " ", "mainClass": ""}"#).expect("应可解析");
assert!(processor.jar_coordinate().is_none());
assert!(processor.main_class().is_none());
}
}
@@ -0,0 +1,185 @@
//! 执行器:幂等跳过(不碰文件系统)、依赖缺失分流、起不来 java 的分类、脱敏器。
use std::path::{Path, PathBuf};
use crate::addon::loader::forge::InstallProfile;
use crate::addon::loader::processors::{host_redactor, run};
use crate::addon::loader::processors::{ProcessorErrorKind, ProcessorPlan, ProcessorPlanInput};
use crate::addon::loader::LoaderKind;
use crate::game::arguments::Environment;
use crate::game::java::{JavaOrigin, JavaRuntime};
use crate::game::layout::GameLayout;
use super::temp_root;
/// 一个最小 profile:一步 processor,产物是临时目录里的 `built.jar`(只按存在性判定)。
const MINIMAL_PROFILE: &str = r#"{
"spec": 1,
"minecraft": "1.20.1",
"json": "/version.json",
"processors": [{
"jar": "net.minecraftforge:installertools:1.3.0",
"classpath": ["net.minecraftforge:jarsplitter:1.1.4"],
"args": ["--task", "SPLIT"],
"outputs": {"{OUT}": "'not-a-sha1'"}
}],
"libraries": [],
"data": {"OUT": {"client": "'built.jar'"}}
}"#;
fn minimal_profile() -> InstallProfile {
serde_json::from_str(MINIMAL_PROFILE).expect("夹具应可解析")
}
/// 组装计划(依赖库不落盘:执行器不负责下载,缺失由用例自己摆)。
fn plan(root: &Path) -> ProcessorPlan {
let profile = minimal_profile();
let layout = GameLayout::new(root);
ProcessorPlan::build(ProcessorPlanInput {
kind: LoaderKind::Forge,
mc_version: "1.20.1",
version_id: "1.20.1-forge-47.2.0",
profile: &profile,
layout: &layout,
installer: &root.join("installer.jar"),
scratch: &root.join("scratch"),
environment: &Environment::host(),
})
.expect("计划应能组出来")
}
/// 一个「必然起不来」的 java:这些用例只验证分类与顺序,不验证 java 行为。
fn missing_java() -> JavaRuntime {
JavaRuntime::unknown(
PathBuf::from("/nonexistent/ull-test-java"),
JavaOrigin::Path,
)
}
fn touch(path: &Path) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("建目录");
}
std::fs::write(path, b"stub").expect("写夹具文件");
}
/// 写一个最小 jar(可选带 `Main-Class`),给"主类从 MANIFEST 读"的用例用。
fn write_jar(path: &Path, main_class: Option<&str>) {
use std::io::Write;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("建目录");
}
let file = std::fs::File::create(path).expect("建 jar");
let mut writer = zip::ZipWriter::new(file);
let options =
zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
if let Some(main_class) = main_class {
writer
.start_file("META-INF/MANIFEST.MF", options)
.expect("写 manifest 条目");
write!(
writer,
"Manifest-Version: 1.0\r\nMain-Class: {main_class}\r\n\r\n"
)
.expect("写 manifest");
}
writer.finish().expect("收尾 jar");
}
#[test]
fn ready_outputs_skip_the_whole_step_without_touching_the_disk() {
let root = temp_root("exec-idempotent");
let plan = plan(&root);
// 产物已就绪:原版 client jar 压根不存在也无所谓——幂等快路径不该去复制它。
touch(&plan.steps[0].outputs[0].path);
let run = run(&plan, &missing_java(), None).expect("产物齐备时不该跑任何进程");
assert_eq!(run.executed(), 0);
assert_eq!(run.skipped(), 1);
assert_eq!(run.steps[0].status.as_str(), "skipped");
assert_eq!(run.to_json()["steps"][0]["status"], "skipped");
// prepare 不该跑:原版 jar 压根不存在、client jar 副本没被复制。
assert!(!plan.minecraft_jar_copy.exists());
}
#[test]
fn missing_minecraft_jar_is_reported_before_any_java_runs() {
let root = temp_root("exec-no-vanilla");
let plan = plan(&root);
let error = run(&plan, &missing_java(), None).expect_err("缺原版 jar 必须报错");
assert_eq!(error.kind(), ProcessorErrorKind::Io);
assert!(
error.message().contains("原版 client jar"),
"{}",
error.message()
);
}
#[test]
fn missing_processor_jar_is_a_library_error_naming_the_file() {
let root = temp_root("exec-no-lib");
let plan = plan(&root);
touch(&plan.minecraft_jar);
let error = run(&plan, &missing_java(), None).expect_err("缺依赖必须报错");
assert_eq!(error.kind(), ProcessorErrorKind::Library);
assert!(
error.message().contains("installertools-1.3.0.jar"),
"{}",
error.message()
);
}
#[test]
fn a_java_that_cannot_start_is_an_io_error_with_a_hint() {
let root = temp_root("exec-no-java");
let plan = plan(&root);
touch(&plan.minecraft_jar);
// 可执行 jar 得是真的 zip + MANIFEST:主类只从那儿来(profile 没写 mainClass)。
write_jar(
&plan.steps[0].jar,
Some("net.minecraftforge.installertools.Main"),
);
touch(&plan.steps[0].classpath[0]);
let error = run(&plan, &missing_java(), None).expect_err("java 起不来必须报错");
assert_eq!(error.kind(), ProcessorErrorKind::Io);
assert!(
error.message().contains("起 java 失败"),
"{}",
error.message()
);
assert!(error.hint().is_some());
// 走到这一步说明临时目录、MC jar 副本都准备好了(顺序:prepare → 起进程)。
assert!(plan.minecraft_jar_copy.is_file());
}
#[test]
fn a_jar_without_a_main_class_is_a_plan_error() {
let root = temp_root("exec-no-main");
let plan = plan(&root);
touch(&plan.minecraft_jar);
write_jar(&plan.steps[0].jar, None);
touch(&plan.steps[0].classpath[0]);
let error = run(&plan, &missing_java(), None).expect_err("拿不到主类必须报错");
assert_eq!(error.kind(), ProcessorErrorKind::Plan);
assert!(error.message().contains("主类"), "{}", error.message());
}
#[test]
fn host_redactor_masks_the_user_home_directory() {
let redactor = host_redactor();
let home = std::env::var_os("USERPROFILE").or_else(|| std::env::var_os("HOME"));
let Some(home) = home else {
return; // 极简环境(无 HOME/USERPROFILE)时跳过
};
let text = format!("{}/game/libraries/x.jar", home.to_string_lossy());
let scrubbed = redactor.scrub(&text);
assert!(scrubbed.contains("<home>"), "{scrubbed}");
assert!(
!scrubbed.contains(&home.to_string_lossy().to_string()),
"{scrubbed}"
);
}
@@ -0,0 +1,71 @@
//! `addon::loader::processors` 的单元测试:占位替换 / 计划形状 / 执行与失败分流 / 幂等跳过。
//!
//! 刻意**全部离线**:installer 用夹具 JSON 描述,依赖库的"在不在盘上"由测试自己摆,
//! 真正外调 java 的用例只走「起不来」这一条确定性分支(假 java 路径不存在),
//! 真机执行由验收(Forge 1.20.1 / NeoForge 21.1.80)覆盖。
mod exec;
mod plan;
mod vars;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use crate::addon::loader::forge::InstallProfile;
/// 实测 Forge 1.20.1-47.2.0 的形状(裁剪到 3 个 processor:2 个 client 侧 + 1 个仅 server)。
pub(crate) const FORGE_PROFILE: &str = r#"{
"spec": 1,
"profile": "forge",
"version": "1.20.1-forge-47.2.0",
"minecraft": "1.20.1",
"json": "/version.json",
"processors": [
{
"sides": ["client"],
"jar": "net.minecraftforge:installertools:1.3.0",
"classpath": ["net.minecraftforge:jarsplitter:1.1.4"],
"args": ["--task", "MCP_DATA", "--input", "{MAPPINGS}", "--output", "{MC_SLIM}", "--side", "{SIDE}"],
"outputs": {"{MC_SLIM}": "aa11bb22cc33dd44ee55ff660011223344556677"}
},
{
"sides": ["server"],
"jar": "net.minecraftforge:installertools:1.3.0",
"classpath": [],
"args": ["--task", "SERVER"],
"outputs": {}
},
{
"jar": "net.minecraftforge:jarsplitter:1.1.4",
"classpath": ["net.minecraftforge:installertools:1.3.0"],
"args": ["--input", "{MINECRAFT_JAR}", "--output", "{MC_EXTRA}", "--singleThreaded"],
"outputs": {"{MC_EXTRA}": "'not-a-sha1'"}
}
],
"libraries": [
{"name": "net.minecraftforge:installertools:1.3.0", "url": "https://maven.minecraftforge.net/"},
{"name": "net.minecraftforge:jarsplitter:1.1.4", "url": "https://maven.minecraftforge.net/"}
],
"data": {
"MAPPINGS": {"client": "[de.oceanlabs.mcp:mcp_config:1.20.1-20230612.114412@zip]", "server": "[de.oceanlabs.mcp:mcp_config:1.20.1-20230612.114412@zip]"},
"MC_SLIM": {"client": "[net.minecraft:client:1.20.1-20230612.114412:slim]", "server": "[net.minecraft:server:1.20.1-20230612.114412:slim]"},
"MC_EXTRA": {"client": "[net.minecraft:client:1.20.1-20230612.114412:extra]", "server": "[net.minecraft:server:1.20.1-20230612.114412:extra]"},
"BINPATCH": {"client": "/data/client.lzma", "server": "/data/server.lzma"},
"SERVER_ONLY": {"server": "'server'"}
}
}"#;
/// 夹具 profile。
pub(crate) fn profile() -> InstallProfile {
serde_json::from_str(FORGE_PROFILE).expect("夹具 install profile 应可解析")
}
/// 一次性临时目录(用例结束不清理,落在系统 temp 下)。
pub(crate) fn temp_root(tag: &str) -> PathBuf {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!(
"ull-processors-{}-{tag}-{unique}",
std::process::id()
))
}
@@ -0,0 +1,224 @@
//! 执行计划:client 侧过滤、参数展开、产物/摘要、依赖库补齐、Java 要求。
use crate::addon::loader::forge::InstallProfile;
use crate::addon::loader::processors::{ProcessorErrorKind, ProcessorPlan, ProcessorPlanInput};
use crate::addon::loader::LoaderKind;
use crate::game::arguments::Environment;
use crate::game::layout::GameLayout;
use super::{profile, temp_root};
fn plan_for(root: &std::path::Path, profile: &InstallProfile) -> ProcessorPlan {
let layout = GameLayout::new(root);
ProcessorPlan::build(ProcessorPlanInput {
kind: LoaderKind::Forge,
mc_version: "1.20.1",
version_id: "1.20.1-forge-47.2.0",
profile,
layout: &layout,
installer: &root.join("cache/forge-installer.jar"),
scratch: &root.join("cache/processors"),
environment: &Environment::host(),
})
.expect("计划应能组出来")
}
#[test]
fn only_client_side_processors_become_steps_in_declared_order() {
let root = temp_root("plan-order");
let plan = plan_for(&root, &profile());
assert_eq!(plan.step_count(), 2, "server-only 的 processor 必须被过滤");
assert_eq!(plan.steps[0].index, 1);
assert_eq!(
plan.steps[0].label,
"net.minecraftforge:installertools:1.3.0"
);
assert_eq!(plan.steps[1].index, 2);
assert_eq!(plan.steps[1].label, "net.minecraftforge:jarsplitter:1.1.4");
assert_eq!(plan.steps[1].classpath.len(), 1);
assert!(!plan.is_empty());
}
#[test]
fn args_are_expanded_with_paths_and_the_client_side() {
let root = temp_root("plan-args");
let plan = plan_for(&root, &profile());
let args = &plan.steps[0].args;
assert_eq!(args[0], "--task");
assert_eq!(args[1], "MCP_DATA");
assert!(
args[3].ends_with("mcp_config-1.20.1-20230612.114412.zip"),
"输入构件应展开成 libraries 路径:{}",
args[3]
);
assert!(
args[5].ends_with("client-1.20.1-20230612.114412-slim.jar"),
"产物路径应按 data 表展开:{}",
args[5]
);
assert_eq!(args[7], "client");
// 第二步用的是临时目录里的 client jar 副本,不是共享层的原版 jar。
assert_eq!(
plan.steps[1].args[1],
plan.minecraft_jar_copy.to_string_lossy()
);
assert!(plan
.minecraft_jar_copy
.to_string_lossy()
.ends_with("minecraft.jar"));
assert!(!plan.minecraft_jar_copy.starts_with(root.join("versions")));
}
#[test]
fn outputs_carry_paths_and_only_real_sha1s() {
let root = temp_root("plan-outputs");
let plan = plan_for(&root, &profile());
let first = &plan.steps[0].outputs;
assert_eq!(first.len(), 1);
assert_eq!(
first[0].sha1.as_deref(),
Some("aa11bb22cc33dd44ee55ff660011223344556677")
);
assert!(first[0].path.to_string_lossy().ends_with("-slim.jar"));
// 非 SHA-1 的 values 只算"存在性校验",并留一条告警。
assert!(plan.steps[1].outputs[0].sha1.is_none());
assert!(
plan.warnings
.iter()
.any(|warning| warning.contains("没给 SHA-1")),
"{:?}",
plan.warnings
);
}
#[test]
fn processor_dependencies_are_planned_once_even_when_libraries_omit_them() {
let root = temp_root("plan-libs");
let lean = serde_json::from_str(
r#"{"spec": 1, "minecraft": "1.20.1", "json": "/version.json",
"processors": [{"jar": "net.minecraftforge:installertools:1.3.0",
"classpath": ["net.minecraftforge:jarsplitter:1.1.4"],
"args": [], "outputs": {}}],
"libraries": [],
"data": {}}"#,
)
.expect("夹具应可解析");
let layout = GameLayout::new(&root);
let plan = ProcessorPlan::build(ProcessorPlanInput {
kind: LoaderKind::NeoForge,
mc_version: "1.20.1",
version_id: "1.20.1-neoforge-21.1.80",
profile: &lean,
layout: &layout,
installer: &root.join("installer.jar"),
scratch: &root.join("tmp"),
environment: &Environment::host(),
})
.expect("计划应能组出来");
assert_eq!(plan.libraries.len(), 2, "jar 与 classpath 各补一条");
for file in &plan.libraries {
assert!(
file.url.contains("neoforged") || file.url.contains("minecraftforge"),
"补库应落在加载器的 maven 上:{}",
file.url
);
assert!(file.dest.starts_with(root.join("libraries")));
}
assert!(plan
.warnings
.iter()
.any(|warning| warning.contains("不在 install profile 的 libraries 节里")));
}
#[test]
fn java_requirement_follows_the_target_minecraft_version() {
let root = temp_root("plan-java");
let plan = plan_for(&root, &profile());
assert_eq!(plan.java_requirement.minimum, 17);
assert_eq!(plan.java_requirement.maximum, None);
assert_eq!(plan.java_requirement.describe(), "Java 17+");
}
#[test]
fn json_view_lists_every_step_for_dry_run() {
let root = temp_root("plan-json");
let plan = plan_for(&root, &profile());
let json = plan.to_json();
assert_eq!(json["count"], 2);
assert_eq!(json["java_requirement"]["describe"], "Java 17+");
assert_eq!(
json["steps"][0]["jar"],
"net.minecraftforge:installertools:1.3.0"
);
assert_eq!(json["steps"][0]["classpath"], 1);
assert_eq!(
json["steps"][1]["outputs"][0]["sha1"],
serde_json::Value::Null
);
}
#[test]
fn duplicated_library_declarations_are_deduped_by_destination() {
// NeoForge 21.1.80 实测:install profile 把 cli-utils / srgutils 等声明了 2~3 次。
// 同一落点下两次会在同一个 `.part` 中间文件上互相抢(真机 os error 2),必须去重。
let root = temp_root("plan-dedup");
let duplicated: InstallProfile = serde_json::from_str(
r#"{"spec": 1, "minecraft": "1.20.1", "json": "/version.json",
"processors": [{"jar": "net.neoforged.installertools:cli-utils:2.1.2@jar"}],
"libraries": [
{"name": "net.neoforged.installertools:cli-utils:2.1.2@jar", "url": "https://maven.neoforged.net/releases/"},
{"name": "net.neoforged.installertools:cli-utils:2.1.2@jar", "url": "https://maven.neoforged.net/releases/"},
{"name": "net.neoforged.installertools:cli-utils:2.1.2@jar", "url": "https://maven.neoforged.net/releases/"}
],
"data": {}}"#,
)
.expect("夹具应可解析");
let layout = GameLayout::new(&root);
let plan = ProcessorPlan::build(ProcessorPlanInput {
kind: LoaderKind::NeoForge,
mc_version: "1.20.1",
version_id: "1.20.1-neoforge-21.1.80",
profile: &duplicated,
layout: &layout,
installer: &root.join("installer.jar"),
scratch: &root.join("tmp"),
environment: &Environment::host(),
})
.expect("计划应能组出来");
assert_eq!(plan.libraries.len(), 1, "同一落点只应留一条");
assert!(
plan.warnings.iter().any(|warning| warning.contains("去重")),
"{:?}",
plan.warnings
);
}
#[test]
fn processor_without_jar_is_a_plan_error() {
let root = temp_root("plan-nojar");
let broken: InstallProfile = serde_json::from_str(
r#"{"spec": 1, "minecraft": "1.20.1", "json": "/version.json",
"processors": [{"args": ["--task", "X"]}], "libraries": [], "data": {}}"#,
)
.expect("夹具应可解析");
let layout = GameLayout::new(&root);
let error = ProcessorPlan::build(ProcessorPlanInput {
kind: LoaderKind::Forge,
mc_version: "1.20.1",
version_id: "1.20.1-forge-47.2.0",
profile: &broken,
layout: &layout,
installer: &root.join("installer.jar"),
scratch: &root.join("tmp"),
environment: &Environment::host(),
})
.expect_err("缺 jar 必须报错");
assert_eq!(error.kind(), ProcessorErrorKind::Plan);
assert!(error.message().contains("jar"), "{}", error.message());
}
@@ -0,0 +1,181 @@
//! 变量表:三种字面量、标准变量、token 替换的边界。
use std::collections::BTreeMap;
use std::path::Path;
use crate::addon::loader::processors::spec::Datum;
use crate::addon::loader::processors::vars::{Variables, VariablesInput};
use crate::addon::loader::processors::ProcessorErrorKind;
use crate::game::layout::GameLayout;
use super::{profile, temp_root};
fn data(entries: &[(&str, &str)]) -> BTreeMap<String, Datum> {
entries
.iter()
.map(|(key, value)| {
(
(*key).to_string(),
Datum {
client: Some((*value).to_string()),
server: None,
},
)
})
.collect()
}
fn build<'a>(
layout: &'a GameLayout,
data: &'a BTreeMap<String, Datum>,
installer: &'a Path,
scratch: &'a Path,
) -> Variables {
Variables::build(VariablesInput {
data,
layout,
mc_version: "1.20.1",
installer,
scratch,
})
.expect("变量表应能组装")
}
#[test]
fn artifact_literals_resolve_into_the_libraries_directory() {
let root = temp_root("vars-artifact");
let layout = GameLayout::new(&root);
let installer = root.join("installer.jar");
let scratch = root.join("tmp");
let table = data(&[("MAPPINGS", "[de.oceanlabs.mcp:mcp_config:1.20.1@zip]")]);
let variables = build(&layout, &table, &installer, &scratch);
let value = variables.get("MAPPINGS").expect("应有 MAPPINGS");
assert!(
Path::new(value).ends_with("de/oceanlabs/mcp/mcp_config/1.20.1/mcp_config-1.20.1.zip"),
"{value}"
);
assert!(Path::new(value).is_absolute(), "{value}");
assert!(
Path::new(value).starts_with(layout.libraries_dir()),
"{value}"
);
// 官方 profile 也把坐标直接写在 `args` 里(`--input [de.oceanlabs.mcp:…@zip]`),
// 整段是坐标时同样要展开成路径。
let arg = variables
.expand("[de.oceanlabs.mcp:mcp_config:1.20.1@zip]")
.expect("整段坐标应能展开");
assert_eq!(arg, value);
}
#[test]
fn quoted_literals_lose_the_quotes() {
let root = temp_root("vars-literal");
let layout = GameLayout::new(&root);
let table = data(&[("SIDE_HINT", "'client'")]);
let variables = build(&layout, &table, &root.join("i.jar"), &root.join("tmp"));
assert_eq!(variables.get("SIDE_HINT"), Some("client"));
}
#[test]
fn plain_paths_are_extracted_from_the_installer_into_scratch() {
let root = temp_root("vars-embedded");
let layout = GameLayout::new(&root);
let installer = root.join("installer.jar");
let scratch = root.join("tmp");
let table = data(&[("BINPATCH", "/data/client.lzma")]);
let variables = build(&layout, &table, &installer, &scratch);
let value = variables.get("BINPATCH").expect("应有 BINPATCH");
assert!(value.ends_with("data-client.lzma"), "{value}");
assert!(
value.starts_with(&scratch.to_string_lossy().to_string()),
"{value}"
);
assert_eq!(variables.embedded().len(), 1);
assert_eq!(variables.embedded()[0].entry, "data/client.lzma");
}
#[test]
fn server_only_entries_are_ignored_for_client_installs() {
let root = temp_root("vars-server");
let layout = GameLayout::new(&root);
let variables = build(
&layout,
&profile().data,
&root.join("i.jar"),
&root.join("tmp"),
);
assert!(variables.get("SERVER_ONLY").is_none());
assert!(variables.get("MAPPINGS").is_some());
}
#[test]
fn standard_variables_are_absolute_and_side_is_client() {
let root = temp_root("vars-standard");
let layout = GameLayout::new(&root);
let installer = root.join("installer.jar");
let scratch = root.join("tmp");
let variables = build(&layout, &BTreeMap::new(), &installer, &scratch);
assert_eq!(variables.get("SIDE"), Some("client"));
assert_eq!(variables.get("MINECRAFT_VERSION"), Some("1.20.1"));
for key in ["MINECRAFT_JAR", "ROOT", "INSTALLER", "LIBRARY_DIR"] {
let value = variables.get(key).unwrap_or_else(|| panic!("缺 {key}"));
assert!(
Path::new(value).is_absolute(),
"{key} 不是绝对路径:{value}"
);
}
assert!(variables
.get("MINECRAFT_JAR")
.unwrap_or_default()
.ends_with("minecraft.jar"));
}
#[test]
fn token_replacement_handles_quotes_and_escapes() {
let root = temp_root("vars-expand");
let layout = GameLayout::new(&root);
let variables = build(
&layout,
&BTreeMap::new(),
&root.join("i.jar"),
&root.join("tmp"),
);
assert_eq!(
variables.expand("--side '{SIDE}'").expect("应能展开"),
"--side {SIDE}"
);
assert_eq!(variables.expand("a\\{b").expect("应能展开"), "a{b");
assert_eq!(
variables.expand("--side {SIDE}").expect("应能展开"),
"--side client"
);
}
#[test]
fn missing_or_unclosed_placeholders_are_plan_errors() {
let root = temp_root("vars-error");
let layout = GameLayout::new(&root);
let variables = build(
&layout,
&BTreeMap::new(),
&root.join("i.jar"),
&root.join("tmp"),
);
let missing = variables
.expand("--output {NOPE}")
.expect_err("缺变量必须报错");
assert_eq!(missing.kind(), ProcessorErrorKind::Plan);
assert!(missing.message().contains("NOPE"), "{}", missing.message());
let unclosed = variables.expand("{SIDE").expect_err("未闭合必须报错");
assert_eq!(unclosed.kind(), ProcessorErrorKind::Plan);
let bad_escape = variables.expand("abc\\").expect_err("悬空转义必须报错");
assert_eq!(bad_escape.kind(), ProcessorErrorKind::Plan);
}
@@ -0,0 +1,71 @@
//! 进程输出的尾部截取与脱敏。
//!
//! processor 的 stdout/stderr 是外部程序(而且部分步骤要联网)写的,内容不可控:
//! 可能带绝对路径、用户名、甚至一大段堆栈。进错误消息/`--json` 前必须做两件事:
//! **只留尾巴**(够定位、不灌满日志)与**抹掉主目录**(`C:\Users\<名>` → `<home>`)。
use std::path::PathBuf;
use crate::launch::redact::Redactor;
/// 尾巴保留的行数(够看异常栈头)。
const TAIL_LINES: usize = 12;
/// 尾巴保留的字符上限。
const TAIL_CHARS: usize = 1200;
/// 宿主脱敏器:把用户主目录换成 `<home>`。
///
/// 复用 [`crate::launch::redact::Redactor`](与 M15/M31 落地的同一套规则:token、
/// `<home>`、长不透明串、带点 token/JWT)。注册 `USERPROFILE`(Windows)/`HOME`(macOS、Linux)
/// 两个来源,因为 MSYS/CI 环境下两者可能只有一个在场。
#[must_use]
pub fn host_redactor() -> Redactor {
let mut redactor = Redactor::new();
for key in ["USERPROFILE", "HOME"] {
if let Some(value) = std::env::var_os(key) {
redactor = redactor.with_home(PathBuf::from(value));
}
}
redactor
}
/// 取文本尾部(末 [`TAIL_LINES`] 行,再截到 [`TAIL_CHARS`] 字符)。
#[must_use]
pub fn tail(text: &str) -> String {
let trimmed = text.trim_end();
if trimmed.is_empty() {
return String::new();
}
let lines: Vec<&str> = trimmed.lines().collect();
let start = lines.len().saturating_sub(TAIL_LINES);
let mut joined = lines[start..].join("\n");
let length = joined.chars().count();
if length > TAIL_CHARS {
joined = joined.chars().skip(length - TAIL_CHARS).collect();
joined.insert(0, '…');
}
joined
}
/// 空串 → `None`(JSON 里不吐空字段)。
pub(super) fn non_empty(value: String) -> Option<String> {
(!value.is_empty()).then_some(value)
}
#[cfg(test)]
mod tests {
use super::tail;
#[test]
fn tail_keeps_the_last_lines_and_truncates_long_ones() {
let text: String = (1..=40).map(|index| format!("line {index}\n")).collect();
let tailed = tail(&text);
assert!(tailed.starts_with("line 29"), "{tailed}");
assert!(tailed.ends_with("line 40"));
let long = "x".repeat(5000);
let tailed = tail(&long);
assert!(tailed.starts_with('…'));
assert_eq!(tailed.chars().count(), 1201);
assert_eq!(tail(" \n"), "");
}
}
@@ -0,0 +1,324 @@
//! 占位替换:install profile 的 `data` 表 + 标准变量 → 一张 `{键 → 值}` 表。
//!
//! 这是 Forge processor 协议里最容易出错的一层,三种字面量语义(照 installer spec,
//! 与 HMCL `ForgeNewInstallTask.parseLiteral` 同构):
//!
//! | 写法 | 含义 | 例 |
//! |------|------|----|
//! | `[group:artifact:version(:classifier)(@ext)]` | maven 构件 → `libraries/` 下的绝对路径 | `[de.oceanlabs.mcp:mcp_config:1.20.1@zip]` |
//! | `'literal'` | 去掉引号的常量 | `'client'` |
//! | 其它 | **installer 里的文件路径** → 解包到临时目录后的绝对路径 | `/data/client.lzma` |
//!
//! 展开(`{KEY}`)的规则与 HMCL 的 `replaceTokens` 一致:`\` 转义下一个字符、
//! `'…'` 原样输出、`{KEY}` 查表替换(**查不到就报错**,不静默留空——留空会让
//! processor 拿着半截参数跑出更难查的错)。
//!
//! # 为什么 `MINECRAFT_JAR` 指向临时目录的副本
//!
//! HMCL 在起 processor 前把原版 client jar **复制到临时目录**再用,注释写得很直白:
//! `External processors must not receive the shared cache path`。processor 会改写输入的
//! jar(拆包/打补丁),直接把 `versions/<mc>/<mc>.jar` 递过去等于让外挂程序改共享层。
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use super::error::ProcessorError;
use super::spec::Datum;
use crate::game::layout::GameLayout;
use crate::game::library::MavenCoordinate;
/// 临时目录里那份原版 client jar 的文件名。
pub const MINECRAFT_JAR_NAME: &str = "minecraft.jar";
/// 一个需要从 installer zip 里解出来的文件。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EmbeddedFile {
/// installer 内的条目路径(`data/client.lzma`)。
pub entry: String,
/// 解包落点(临时目录下的扁平文件名)。
pub dest: PathBuf,
}
/// 变量表的构造输入。
pub struct VariablesInput<'a> {
/// install profile 的 `data` 表。
pub data: &'a BTreeMap<String, Datum>,
/// 游戏目录布局(`libraries/` 与 `versions/` 的路径规则)。
pub layout: &'a GameLayout,
/// 目标原版版本(`net.minecraft:client:<ver>` 这类坐标回落用)。
pub mc_version: &'a str,
/// installer jar 的磁盘路径(`{INSTALLER}`,也是解包来源)。
pub installer: &'a Path,
/// 临时目录(`{MINECRAFT_JAR}` 与解包文件都落这里)。
pub scratch: &'a Path,
}
/// 展开后的变量表。
#[derive(Debug, Clone, Default)]
pub struct Variables {
values: BTreeMap<String, String>,
embedded: Vec<EmbeddedFile>,
/// 参数里 `[坐标]` 的解析上下文(官方 profile 会在 `args` 里直接写坐标,不只写在 `data` 里)。
artifacts: Option<ArtifactRoots>,
}
/// `[坐标]` 的解析上下文。
#[derive(Debug, Clone)]
struct ArtifactRoots {
libraries: PathBuf,
vanilla_jar: PathBuf,
}
impl Variables {
/// 组装变量表:先展开 `data` 表,再注入标准变量。
///
/// # Errors
///
/// 构件坐标不合法、`data` 表里的值是空路径、变量引用缺定义时返回
/// [`ProcessorErrorKind::Plan`](super::error::ProcessorErrorKind::Plan) 错误。
pub fn build(input: VariablesInput<'_>) -> Result<Self, ProcessorError> {
let mut variables = Self {
artifacts: Some(ArtifactRoots {
libraries: input.layout.libraries_dir(),
vanilla_jar: input.layout.version_jar(input.mc_version),
}),
..Self::default()
};
for (key, datum) in input.data {
let Some(raw) = datum.client_value() else {
continue;
};
let value = variables.resolve_literal(raw, &input)?;
variables.values.insert(key.clone(), value);
}
variables.inject_standard(&input);
Ok(variables)
}
/// 标准变量(installer spec 没有写死,但各家启动器都注入同一套)。
fn inject_standard(&mut self, input: &VariablesInput<'_>) {
let scratch = absolute(input.scratch);
let standard = [
("SIDE", "client".to_string()),
("MINECRAFT_VERSION", input.mc_version.to_string()),
(
"MINECRAFT_JAR",
absolute(&scratch.join(MINECRAFT_JAR_NAME))
.to_string_lossy()
.into_owned(),
),
(
"ROOT",
absolute(input.layout.root()).to_string_lossy().into_owned(),
),
(
"INSTALLER",
absolute(input.installer).to_string_lossy().into_owned(),
),
(
"LIBRARY_DIR",
absolute(&input.layout.libraries_dir())
.to_string_lossy()
.into_owned(),
),
];
for (key, value) in standard {
self.values.insert(key.to_string(), value);
}
}
/// 把一个 `data` 表的值展开成实际字符串。
fn resolve_literal(
&mut self,
raw: &str,
input: &VariablesInput<'_>,
) -> Result<String, ProcessorError> {
let trimmed = raw.trim();
if let Some(inner) = trim_brackets(trimmed) {
return self.resolve_artifact(inner);
}
if let Some(inner) = trimmed
.strip_prefix('\'')
.and_then(|rest| rest.strip_suffix('\''))
{
return Ok(inner.to_string());
}
if trimmed.starts_with('{') {
// data 表自己引用别的键:官方 profile 不这么写,但展开一次成本为零。
return self.expand(trimmed);
}
let entry = trimmed.trim_start_matches('/').to_string();
if entry.is_empty() {
return Err(ProcessorError::plan(format!(
"install profile 的 data 表里有空路径:`{raw}`"
)));
}
// 其余一律当 installer 内的文件:解到临时目录(执行期才真正解包)。
let dest = absolute(&absolute(input.scratch).join(embedded_name(&entry)));
self.embedded.push(EmbeddedFile {
entry,
dest: dest.clone(),
});
Ok(dest.to_string_lossy().into_owned())
}
/// `[坐标]` → `libraries/` 下的绝对路径。
fn resolve_artifact(&self, descriptor: &str) -> Result<String, ProcessorError> {
let Some(roots) = &self.artifacts else {
return Err(ProcessorError::plan(format!(
"构件坐标 `{descriptor}` 需要游戏目录上下文才能解析"
)));
};
artifact_path(&roots.libraries, &roots.vanilla_jar, descriptor)
}
/// 展开一段含占位的文本(`{KEY}` / `'字面量'` / `[坐标]` / `\` 转义)。
///
/// `[坐标]` 只在**整段就是坐标**时生效(与 HMCL `parseLiteral` 一致):
/// official profile 的 `args` 里常直接写 `--input [de.oceanlabs.mcp:…@zip]`。
///
/// # Errors
///
/// `{KEY}` 在表里查不到、`\` 结尾无字符可转义、`{`/`'` 未闭合、坐标不合法时返回规划错误。
pub fn expand(&self, pattern: &str) -> Result<String, ProcessorError> {
let trimmed = pattern.trim();
if let Some(inner) = trim_brackets(trimmed) {
return self.resolve_artifact(inner);
}
let mut out = String::with_capacity(pattern.len());
let mut chars = pattern.chars();
while let Some(ch) = chars.next() {
match ch {
'\\' => match chars.next() {
Some(next) => out.push(next),
None => {
return Err(ProcessorError::plan(format!(
"占位文本以 `\\` 结尾(无法转义):`{pattern}`"
)))
}
},
'{' => {
let key = collect_until(&mut chars, '}', pattern)?;
let value = self.values.get(&key).ok_or_else(|| {
ProcessorError::plan(format!(
"占位 {{{key}}} 在 install profile 的 data 表里没有定义(原文 `{pattern}`)"
))
})?;
out.push_str(value);
}
'\'' => out.push_str(&collect_until(&mut chars, '\'', pattern)?),
other => out.push(other),
}
}
Ok(out)
}
/// 某个变量的值。
#[must_use]
pub fn get(&self, key: &str) -> Option<&str> {
self.values.get(key).map(String::as_str)
}
/// 需要从 installer 里解包的文件(执行期用)。
#[must_use]
pub fn embedded(&self) -> &[EmbeddedFile] {
&self.embedded
}
/// 变量条数(`--dry-run` 只报数量,不吐全部路径)。
#[must_use]
pub fn len(&self) -> usize {
self.values.len()
}
/// 是否一条变量都没有(实际不可能:标准变量恒在)。
#[must_use]
pub fn is_empty(&self) -> bool {
self.values.is_empty()
}
}
/// 读到 `closing` 为止(跳过 `\` 转义),返回中间文本。
fn collect_until(
chars: &mut impl Iterator<Item = char>,
closing: char,
pattern: &str,
) -> Result<String, ProcessorError> {
let mut out = String::new();
while let Some(ch) = chars.next() {
if ch == '\\' {
match chars.next() {
Some(next) => out.push(next),
None => {
return Err(ProcessorError::plan(format!(
"占位文本以 `\\` 结尾(无法转义):`{pattern}`"
)))
}
}
} else if ch == closing {
return Ok(out);
} else {
out.push(ch);
}
}
Err(ProcessorError::plan(format!(
"占位文本缺少收尾的 `{closing}`:`{pattern}`"
)))
}
/// 整段是 `[坐标]` 时取出坐标(不到首尾就返回 `None`)。
fn trim_brackets(raw: &str) -> Option<&str> {
raw.strip_prefix('[')
.and_then(|rest| rest.strip_suffix(']'))
}
/// `[坐标]` → `libraries/` 下的绝对路径。
///
/// 少数坐标进不了 `libraries/`:`net.minecraft:client:<ver>`(无 classifier)对应的是
/// `versions/<ver>/<ver>.jar`。官方 installer 会把 client jar 塞进本地 maven 布局,
/// 我们不复制(磁盘上原版 jar 只有一份),因此按目标版本回落。
fn artifact_path(
libraries: &Path,
vanilla_jar: &Path,
descriptor: &str,
) -> Result<String, ProcessorError> {
let coordinate = MavenCoordinate::parse(descriptor).map_err(|error| {
ProcessorError::plan(format!(
"install profile 里的构件坐标不合法:`{descriptor}`({error})"
))
})?;
let path = libraries.join(coordinate.relative_path());
if coordinate.group == "net.minecraft"
&& coordinate.artifact == "client"
&& coordinate.classifier.is_none()
&& vanilla_jar.is_file()
{
return Ok(absolute(vanilla_jar).to_string_lossy().into_owned());
}
Ok(absolute(&path).to_string_lossy().into_owned())
}
/// installer 内的条目 → 临时目录里的扁平文件名(`data/client.lzma` → `data-client.lzma`)。
///
/// 压平是为了防路径穿越:`../` 与绝对路径在拼进临时目录时都会失去意义。
fn embedded_name(entry: &str) -> String {
entry
.chars()
.map(|ch| {
if matches!(ch, '/' | '\\' | ':') {
'-'
} else {
ch
}
})
.collect()
}
/// 尽量转绝对路径(相对路径按当前工作目录;失败就原样返回)。
#[must_use]
pub fn absolute(path: &Path) -> PathBuf {
if path.is_absolute() {
return path.to_path_buf();
}
std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf())
}
@@ -0,0 +1,135 @@
//! Fabric / Quilt:profile 归一化的形状对拍。
use super::{FABRIC_PROFILE, QUILT_PROFILE};
use crate::addon::loader::fabric::{build_release, parse_loader_list, pick_default};
use crate::addon::loader::{LoaderErrorKind, LoaderKind, LoaderSupport};
#[test]
fn fabric_profile_becomes_inheriting_loader_version() {
let release = build_release(LoaderKind::Fabric, "1.20.1", "0.15.11", FABRIC_PROFILE)
.expect("fabric profile 应可归一化");
assert_eq!(release.version_id, "1.20.1-fabric-0.15.11");
let json = &release.version_json;
assert_eq!(json.id.as_deref(), Some("1.20.1-fabric-0.15.11"));
assert_eq!(json.inherits_from.as_deref(), Some("1.20.1"));
// ★ 关键:jar 必须钉成原版 id,否则继承解析会把 jar 兜底成 loader 自身 id。
assert_eq!(json.jar.as_deref(), Some("1.20.1"));
assert_eq!(
json.main_class.as_deref(),
Some("net.fabricmc.loader.impl.launch.knot.KnotClient")
);
// jvm 参数(`-DFabricMcEmu`)必须原样保留:老版本启动靠它。
let jvm = json
.arguments
.as_ref()
.map(|arguments| arguments.jvm.len())
.unwrap_or_default();
assert_eq!(jvm, 1, "profile 的 jvm 参数丢了");
assert_eq!(release.support, LoaderSupport::JsonRoute);
assert!(release.is_installable());
assert!(release.warnings.is_empty(), "{:?}", release.warnings);
}
#[test]
fn fabric_flat_digest_is_lifted_into_artifact() {
let release = build_release(LoaderKind::Fabric, "1.20.1", "0.15.11", FABRIC_PROFILE)
.expect("fabric profile 应可归一化");
let libraries = release.version_json.libraries.expect("应有库");
let asm = libraries
.iter()
.find(|library| library.name.starts_with("org.ow2.asm:asm:"))
.expect("应有 asm");
let artifact = asm
.downloads
.as_ref()
.and_then(|downloads| downloads.artifact.as_ref())
.expect("扁平 sha1/size 应提升成 downloads.artifact");
assert_eq!(
artifact.sha1.as_deref(),
Some("aa205cf0a06dbd8e04ece91c0b37c3f5d567546a")
);
assert_eq!(artifact.size, Some(123_598));
assert_eq!(
artifact.url.as_deref(),
Some("https://maven.fabricmc.net/org/ow2/asm/asm/9.6/asm-9.6.jar")
);
}
#[test]
fn quilt_profile_is_isomorphic() {
let release = build_release(LoaderKind::Quilt, "1.20.1", "0.20.0-beta.9", QUILT_PROFILE)
.expect("quilt profile 应可归一化");
assert_eq!(release.version_id, "1.20.1-quilt-0.20.0-beta.9");
assert_eq!(release.version_json.jar.as_deref(), Some("1.20.1"));
assert_eq!(
release.version_json.main_class.as_deref(),
Some("org.quiltmc.loader.impl.launch.knot.KnotClient")
);
assert_eq!(release.support, LoaderSupport::JsonRoute);
// Quilt 的 profile 没有 loading 库时不该编造;这里有 quilt-loader,因此无告警。
assert!(release.warnings.is_empty(), "{:?}", release.warnings);
}
#[test]
fn missing_main_class_is_metadata_error() {
let profile = r#"{"id":"x","inheritsFrom":"1.20.1","libraries":[]}"#;
let error = build_release(LoaderKind::Fabric, "1.20.1", "0.15.11", profile)
.expect_err("缺主类必须报错");
assert_eq!(error.kind(), LoaderErrorKind::Metadata);
}
#[test]
fn missing_loader_library_is_appended_with_warning() {
// 去掉 fabric-loader 那条,看归一化会不会补。
let profile = r#"{
"id": "fabric-loader-0.15.11-1.20.1",
"inheritsFrom": "1.20.1",
"mainClass": "net.fabricmc.loader.impl.launch.knot.KnotClient",
"libraries": [{"name": "org.ow2.asm:asm:9.6", "url": "https://maven.fabricmc.net/"}]
}"#;
let release = build_release(LoaderKind::Fabric, "1.20.1", "0.15.11", profile)
.expect("缺 loader 库应降级为告警 + 补一条");
assert_eq!(release.warnings.len(), 1);
let libraries = release.version_json.libraries.expect("应有库");
assert!(libraries
.iter()
.any(|library| library.name == "net.fabricmc:fabric-loader:0.15.11"));
}
#[test]
fn loader_list_parses_and_prefers_stable() {
let list = r#"[
{"loader": {"version": "0.16.0-beta.1", "stable": false, "maven": "net.fabricmc:fabric-loader:0.16.0-beta.1"}},
{"loader": {"version": "0.15.11", "stable": true, "maven": "net.fabricmc:fabric-loader:0.15.11"}},
{"loader": {"version": "0.15.10", "stable": true, "maven": "net.fabricmc:fabric-loader:0.15.10"}}
]"#;
let entries = parse_loader_list(LoaderKind::Fabric, list).expect("列表应可解析");
assert_eq!(entries.len(), 3);
assert_eq!(
pick_default(&entries).map(|entry| entry.version.as_str()),
Some("0.15.11")
);
}
#[test]
fn quilt_loader_list_without_stable_flag_still_picks_first() {
// 实测 Quilt 的 loader 条目没有 stable 键。
let list = r#"[
{"loader": {"version": "0.20.0-beta.9", "maven": "org.quiltmc:quilt-loader:0.20.0-beta.9"}},
{"loader": {"version": "0.19.2", "maven": "org.quiltmc:quilt-loader:0.19.2"}}
]"#;
let entries = parse_loader_list(LoaderKind::Quilt, list).expect("列表应可解析");
assert!(entries.iter().all(|entry| entry.stable));
assert_eq!(
pick_default(&entries).map(|entry| entry.version.as_str()),
Some("0.20.0-beta.9")
);
}
#[test]
fn empty_loader_list_is_not_found() {
let error = parse_loader_list(LoaderKind::Fabric, "[]").expect_err("空列表应报错");
assert_eq!(error.kind(), LoaderErrorKind::NotFound);
}
+258
View File
@@ -0,0 +1,258 @@
//! NeoForge / Forge:install profile 解析与三条路线的判定。
use crate::addon::loader::forge::{build_release, InstallProfile};
use crate::addon::loader::{LoaderErrorKind, LoaderKind, LoaderSupport};
use crate::game::manifest::VersionJson;
/// spec 1 + client 侧 processor(实测 NeoForge 21.1.80 / Forge 1.20.1 的形状,裁剪过)。
pub(crate) const NEOFORGE_PROFILE: &str = r#"{
"spec": 1,
"profile": "NeoForge",
"version": "neoforge-21.1.80",
"minecraft": "1.21.1",
"json": "/version.json",
"path": null,
"processors": [
{"sides": ["server"], "jar": "net.neoforged.installertools:installertools:2.1.2"},
{"sides": ["client"], "jar": "net.neoforged.installertools:installertools:2.1.2"},
{"jar": "net.neoforged.installertools:installertools:2.1.2"},
{"sides": ["client"], "jar": "net.neoforged.installertools:installertools:2.1.2"}
],
"libraries": [
{"name": "com.google.code.gson:gson:2.8.9@jar"},
{"name": "org.apache.commons:commons-text:1.3@jar"}
],
"data": {"MC_SLIM": {"client": "[net.neoforged:neoforge:21.1.80:client-slim]"}}
}"#;
/// spec 1 版本 JSON 的裁剪版(内容形状照 21.1.80)。
pub(crate) const NEOFORGE_VERSION_JSON: &str = r#"{
"id": "neoforge-21.1.80",
"inheritsFrom": "1.21.1",
"type": "release",
"mainClass": "cpw.mods.bootstraplauncher.BootstrapLauncher",
"arguments": {"game": ["--launchTarget", "forgeclient"], "jvm": ["-DignoreList=bootstraplauncher"]},
"libraries": [
{"name": "cpw.mods:securejarhandler:2.1.10",
"downloads": {"artifact": {"path": "cpw/mods/securejarhandler/2.1.10/securejarhandler-2.1.10.jar",
"url": "https://maven.neoforged.net/releases/cpw/mods/securejarhandler/2.1.10/securejarhandler-2.1.10.jar",
"sha1": "51e6a22c6c716beb11e244bf5b8be480f51dd6b5", "size": 88749}}}
]
}"#;
/// spec 0 + 无 processors(实测 Forge 1.12.2-14.23.5.2860),universal jar 的 url 是空串。
pub(crate) const FORGE_LEGACY_PROFILE: &str = r#"{
"spec": 0,
"profile": "forge",
"version": "1.12.2-forge-14.23.5.2860",
"minecraft": "1.12.2",
"json": "/version.json",
"path": "net.minecraftforge:forge:1.12.2-14.23.5.2860",
"processors": [],
"libraries": [{"name": "net.minecraftforge:forge:1.12.2-14.23.5.2860"}]
}"#;
pub(crate) const FORGE_LEGACY_VERSION_JSON: &str = r#"{
"id": "1.12.2-forge-14.23.5.2860",
"inheritsFrom": "1.12.2",
"mainClass": "net.minecraft.launchwrapper.Launch",
"minecraftArguments": "--username ${auth_player_name} --tweakClass net.minecraftforge.fml.common.launcher.FMLTweaker",
"libraries": [
{"name": "net.minecraftforge:forge:1.12.2-14.23.5.2860",
"downloads": {"artifact": {"path": "net/minecraftforge/forge/1.12.2-14.23.5.2860/forge-1.12.2-14.23.5.2860.jar",
"url": "", "sha1": "029250575d3aa2cf80b56dffb66238a1eeaea2ac", "size": 4466148}}}
]
}"#;
fn profile(json: &str) -> InstallProfile {
serde_json::from_str(json).expect("夹具 profile 应可解析")
}
fn version_json(json: &str) -> VersionJson {
VersionJson::from_json_str(json).expect("夹具版本 JSON 应可解析")
}
#[test]
fn client_processors_mark_version_as_installer_required() {
let release = build_release(
LoaderKind::NeoForge,
"1.21.1",
"21.1.80",
&profile(NEOFORGE_PROFILE),
Some(&version_json(NEOFORGE_VERSION_JSON)),
)
.expect("版本 JSON 齐备时应能生成清单");
// 2 条 sides 含 client + 1 条无 sides(两侧都要)= 3 条 client 侧。
assert_eq!(profile(NEOFORGE_PROFILE).client_processors(), 3);
// M35 起 RequiresInstaller 是可真装的路线(外调 java 跑 processors),
// 但"要跑 installer"这个事实仍要能被上层看见。
assert!(release.is_installable());
assert!(release.requires_installer());
let reason = release
.installer_reason()
.expect("应给需要 installer 的原因");
assert!(reason.contains("3 个 client 侧 processor"), "{reason}");
// 版本清单本身仍然正确生成(--dry-run 能看到它)。
assert_eq!(release.version_id, "1.21.1-neoforge-21.1.80");
assert_eq!(
release.version_json.inherits_from.as_deref(),
Some("1.21.1")
);
assert_eq!(release.version_json.jar.as_deref(), Some("1.21.1"));
// processor 依赖不进启动 classpath,只记数量进告警。
assert_eq!(release.version_json.libraries.map_or(0, |l| l.len()), 1);
assert!(release
.warnings
.iter()
.any(|warning| warning.contains("processor 依赖")));
}
#[test]
fn spec_zero_profile_is_json_route_and_fills_empty_url() {
let release = build_release(
LoaderKind::Forge,
"1.12.2",
"1.12.2-14.23.5.2860",
&profile(FORGE_LEGACY_PROFILE),
Some(&version_json(FORGE_LEGACY_VERSION_JSON)),
)
.expect("spec 0 应走 json 路线");
assert_eq!(release.support, LoaderSupport::JsonRoute);
assert_eq!(release.loader_version, "14.23.5.2860");
assert_eq!(release.version_id, "1.12.2-forge-14.23.5.2860");
assert_eq!(
release.version_json.main_class.as_deref(),
Some("net.minecraft.launchwrapper.Launch")
);
// 空 url 必须补成 maven 地址:本体 jar 在官方 maven 上**只发布在 -universal classifier 下**
// (无 classifier 路径官方 404,仅靠 BMCLAPI 302 别名兜底),所以 URL 要带 -universal。
let library = release
.version_json
.libraries
.as_ref()
.and_then(|libraries| libraries.first())
.expect("应有 forge 本体库");
let artifact = library
.downloads
.as_ref()
.and_then(|downloads| downloads.artifact.as_ref())
.expect("应有 artifact");
assert_eq!(
artifact.url.as_deref(),
Some(
"https://maven.minecraftforge.net/net/minecraftforge/forge/1.12.2-14.23.5.2860/forge-1.12.2-14.23.5.2860-universal.jar"
),
"URL 必须带 -universal(官方 maven 只托管这个 classifier)"
);
assert_eq!(artifact.size, Some(4_466_148));
assert!(library
.url
.as_deref()
.unwrap_or_default()
.contains("minecraftforge"));
// 磁盘落点仍用原 path(与官方 installer 内嵌条目同名,启动 classpath 布局不变)。
assert_eq!(
artifact.path.as_deref(),
Some("net/minecraftforge/forge/1.12.2-14.23.5.2860/forge-1.12.2-14.23.5.2860.jar")
);
let dest = library.relative_path().expect("落点应可推导");
assert!(
dest.ends_with("forge-1.12.2-14.23.5.2860.jar"),
"落点不该带 classifier:{}",
dest.display()
);
}
#[test]
fn legacy_inline_protocol_is_unsupported() {
// 老协议:没有独立 version.json,只有内联 versionInfo + 解包信息。
let profile = profile(
r#"{"spec": 1, "minecraft": "1.7.10",
"install": {"filePath": "forge-1.7.10-10.13.4.1614-universal.jar"},
"versionInfo": {"id": "1.7.10-Forge10.13.4.1614", "mainClass": "net.minecraft.launchwrapper.Launch"},
"processors": []}"#,
);
assert!(profile.is_legacy_protocol());
let error = build_release(
LoaderKind::Forge,
"1.7.10",
"1.7.10-10.13.4.1614",
&profile,
None,
)
.expect_err("老协议必须明确 unsupported");
assert_eq!(error.kind(), LoaderErrorKind::Unsupported);
assert!(error.message().contains("老协议"), "{}", error.message());
assert!(error.hint().is_some());
}
#[test]
fn universal_classifier_rewrite_is_scoped_to_the_installer_body() {
// 只有 install profile 的 `path` 声明的本体构件走 -universal;
// 其它同样"空 url + 有 path"的库必须保持原路径(乱加 classifier 会 404)。
let embedded = version_json(
r#"{"id": "1.12.2-forge-14.23.5.2860", "inheritsFrom": "1.12.2",
"mainClass": "net.minecraft.launchwrapper.Launch",
"libraries": [
{"name": "net.minecraftforge:forge:1.12.2-14.23.5.2860",
"downloads": {"artifact": {"path": "net/minecraftforge/forge/1.12.2-14.23.5.2860/forge-1.12.2-14.23.5.2860.jar", "url": ""}}},
{"name": "net.minecraftforge:mergetool:1.1.5",
"downloads": {"artifact": {"path": "net/minecraftforge/mergetool/1.1.5/mergetool-1.1.5.jar", "url": ""}}}
]}"#,
);
let release = build_release(
LoaderKind::Forge,
"1.12.2",
"1.12.2-14.23.5.2860",
&profile(FORGE_LEGACY_PROFILE),
Some(&embedded),
)
.expect("spec 0 应走 json 路线");
let libraries = release.version_json.libraries.expect("应有库");
let url_of = |name: &str| {
libraries
.iter()
.find(|library| library.name == name)
.and_then(|library| library.downloads.as_ref())
.and_then(|downloads| downloads.artifact.as_ref())
.and_then(|artifact| artifact.url.clone())
.unwrap_or_default()
};
assert!(url_of("net.minecraftforge:forge:1.12.2-14.23.5.2860").ends_with("-universal.jar"));
assert_eq!(
url_of("net.minecraftforge:mergetool:1.1.5"),
"https://maven.minecraftforge.net/net/minecraftforge/mergetool/1.1.5/mergetool-1.1.5.jar"
);
}
#[test]
fn version_mismatch_between_installer_and_target_is_metadata_error() {
let error = build_release(
LoaderKind::NeoForge,
"1.20.1",
"21.1.80",
&profile(NEOFORGE_PROFILE),
Some(&version_json(NEOFORGE_VERSION_JSON)),
)
.expect_err("原版版本不符必须报错");
assert_eq!(error.kind(), LoaderErrorKind::Metadata);
assert!(error.message().contains("1.21.1"), "{}", error.message());
}
#[test]
fn missing_main_class_is_metadata_error() {
let embedded = version_json(r#"{"id": "x", "inheritsFrom": "1.12.2", "libraries": []}"#);
let error = build_release(
LoaderKind::Forge,
"1.12.2",
"1.12.2-14.23.5.2860",
&profile(FORGE_LEGACY_PROFILE),
Some(&embedded),
)
.expect_err("缺主类必须报错");
assert_eq!(error.kind(), LoaderErrorKind::Metadata);
}
@@ -0,0 +1,338 @@
//! LiteLoader 数据源与合成:官方版本表(**真实响应的裁剪版**)→ 版本条目 → 合成清单。
//!
//! 夹具 `LITELOADER_VERSIONS` 是 2026-11 从 `https://dl.liteloader.com/versions/versions.json`
//! 真取的一份响应(200 / 49129 字节 / 16 个原版版本)里挑三个原版版本原样留下:
//! `1.7.10`(只有 release 分支)、`1.10.2`(release + snapshots,且 `repo.url` 是 `…/repo/`
//! 形态——专门用来钉「快照仓固定」这条)、`1.12.2`(只有 snapshots 分支)。删掉的是另外 13 个原版版本与
//! 每个版本下的多余分支;**字段名与结构一个没改**(键仍是 md5、`latest` 别名也留着,用来
//! 钉「跳过别名 + 按 version 去重」)。`LITELOADER_METADATA` 是同一次探测里取的
//! `repo.mumfrey.com/…/liteloader/1.12.2-SNAPSHOT/maven-metadata.xml`(200 / 358 字节)。
use crate::addon::loader::liteloader::{
build_release, default_entry, find_entry, parse_versions, snapshot_artifact_url, CENTRAL_REPO,
};
use crate::addon::loader::{LoaderErrorKind, LoaderKind};
use crate::game::loader::{InstanceLoader, LoaderKind as Wide};
use crate::game::version::ReleaseType;
/// 真实响应的裁剪版(见文件头)。
const LITELOADER_VERSIONS: &str = r#"{
"meta": {
"description": "LiteLoader is a lightweight mod bootstrap designed to provide basic loader functionality for mods which don't need to modify game mechanics.",
"authors": "Mumfrey",
"url": "http://dl.liteloader.com",
"updated": "2017-11-29T16:31:41+00:00",
"updatedTime": 1511973101
},
"versions": {
"1.7.10": {
"repo": {"stream": "RELEASE", "type": "ivy", "url": "http://dl.liteloader.com/versions/", "classifier": "mcpnames"},
"artefacts": {"com.mumfrey:liteloader": {
"63ada46e033d0cb6782bada09ad5ca4e": {
"tweakClass": "com.mumfrey.liteloader.launch.LiteLoaderTweaker",
"libraries": [{"name": "net.minecraft:launchwrapper:1.11"}, {"name": "org.ow2.asm:asm-all:5.0.3"}],
"srcJar": "liteloader-1.7.10_04-mcpnames-sources.jar",
"mcpJar": "liteloader-1.7.10_04-mcpnames.jar",
"stream": "RELEASE",
"file": "liteloader-1.7.10_04.jar",
"version": "1.7.10_04",
"md5": "63ada46e033d0cb6782bada09ad5ca4e",
"timestamp": "1414368553"
},
"db7235aefd407ac1fde09a7baba50839": {
"tweakClass": "com.mumfrey.liteloader.launch.LiteLoaderTweaker",
"libraries": [
{"name": "net.minecraft:launchwrapper:1.9"},
{"name": "org.ow2.asm:asm-all:5.0.3", "url": "http://repo.maven.apache.org/maven2/"}
],
"stream": "RELEASE",
"file": "liteloader-1.7.10_00.jar",
"version": "1.7.10_00",
"md5": "db7235aefd407ac1fde09a7baba50839",
"timestamp": "1404330030"
}
}}
},
"1.10.2": {
"repo": {"stream": "RELEASE", "type": "m2", "url": "http://dl.liteloader.com/repo/", "classifier": ""},
"artefacts": {"com.mumfrey:liteloader": {
"8a7c21f32d77ee08b393dd3921ced8eb": {
"tweakClass": "com.mumfrey.liteloader.launch.LiteLoaderTweaker",
"libraries": [{"name": "net.minecraft:launchwrapper:1.12"}, {"name": "org.ow2.asm:asm-all:5.0.3"}],
"stream": "RELEASE",
"file": "liteloader-1.10.2.jar",
"version": "1.10.2",
"md5": "8a7c21f32d77ee08b393dd3921ced8eb",
"timestamp": "1479473570"
}
}},
"snapshots": {
"libraries": [
{"name": "net.minecraft:launchwrapper:1.12"},
{"name": "org.spongepowered:mixin:0.5.17-SNAPSHOT", "url": "https://repo.spongepowered.org/maven/"}
],
"com.mumfrey:liteloader": {
"1cdd72f7232e45551f16cc8ffd27ccf3": {
"tweakClass": "com.mumfrey.liteloader.launch.LiteLoaderTweaker",
"libraries": [{"name": "net.minecraft:launchwrapper:1.12"}, {"name": "org.ow2.asm:asm-all:5.0.3"}],
"stream": "SNAPSHOT",
"file": "liteloader-1.10.2-SNAPSHOT.jar",
"version": "1.10.2-SNAPSHOT",
"build": "1.10.2-SNAPSHOT-rE85C8AE-b11-11",
"md5": "1cdd72f7232e45551f16cc8ffd27ccf3",
"timestamp": "1485987183",
"lastSuccessfulBuild": 11
}
}
}
},
"1.12.2": {
"repo": {"stream": "SNAPSHOT", "type": "m2", "url": "http://repo.mumfrey.com/content/repositories/snapshots/", "classifier": ""},
"snapshots": {
"libraries": [
{"name": "net.minecraft:launchwrapper:1.12"},
{"name": "org.spongepowered:mixin:0.7.4-SNAPSHOT", "url": "https://repo.spongepowered.org/maven/"}
],
"com.mumfrey:liteloader": {
"1420785ecbfed5aff4a586c5c9dd97eb": {
"tweakClass": "com.mumfrey.liteloader.launch.LiteLoaderTweaker",
"libraries": [{"name": "net.minecraft:launchwrapper:1.12"}, {"name": "org.ow2.asm:asm-all:5.2"}],
"stream": "SNAPSHOT",
"file": "liteloader-1.12.2-SNAPSHOT.jar",
"version": "1.12.2-SNAPSHOT",
"build": "1.12.2-SNAPSHOT-r4CC2BB0-b4-4",
"md5": "1420785ecbfed5aff4a586c5c9dd97eb",
"timestamp": "1511880271",
"lastSuccessfulBuild": 4
},
"latest": {
"tweakClass": "com.mumfrey.liteloader.launch.LiteLoaderTweaker",
"libraries": [{"name": "net.minecraft:launchwrapper:1.12"}, {"name": "org.ow2.asm:asm-all:5.2"}],
"stream": "SNAPSHOT",
"file": "liteloader-1.12.2-SNAPSHOT.jar",
"version": "1.12.2-SNAPSHOT",
"md5": "1420785ecbfed5aff4a586c5c9dd97eb",
"timestamp": "1511880271",
"lastSuccessfulBuild": 4
}
}
}
}
}
}"#;
/// 同一次探测取的 maven-metadata(真实 358 字节响应的原样)。
const LITELOADER_METADATA: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<metadata>
<groupId>com.mumfrey</groupId>
<artifactId>liteloader</artifactId>
<version>1.12.2-SNAPSHOT</version>
<versioning>
<snapshot>
<timestamp>20171128.144431</timestamp>
<buildNumber>4</buildNumber>
</snapshot>
<lastUpdated>20171128144431</lastUpdated>
</versioning>
</metadata>"#;
#[test]
fn parses_the_real_version_table() {
let table = parse_versions(LITELOADER_VERSIONS).expect("真实响应必须能解析");
assert_eq!(table.len(), 3, "只留了三个原版版本");
// release 分支:按 version 去重、新的排前面。
let releases = &table["1.7.10"];
assert_eq!(
releases
.iter()
.map(|e| e.version.as_str())
.collect::<Vec<_>>(),
["1.7.10_04", "1.7.10_00"]
);
// 目录名是**原版**版本(1.7.10),不是 loader 版本(1.7.10_04)。
assert_eq!(
releases[0].url.as_deref(),
Some("https://dl.liteloader.com/versions/com/mumfrey/liteloader/1.7.10/liteloader-1.7.10_04.jar")
);
assert!(!releases[0].snapshot);
// 记录级库:没 url 的非 Mojang 库补 Central,Mojang 的库保持默认基址。
let names: Vec<&str> = releases[0]
.libraries
.iter()
.map(|library| library.name.as_str())
.collect();
assert_eq!(
names,
[
"net.minecraft:launchwrapper:1.11",
"org.ow2.asm:asm-all:5.0.3"
]
);
assert_eq!(
releases[0].libraries[0].url, None,
"Mojang 库仓才有 launchwrapper"
);
assert_eq!(releases[0].libraries[1].url.as_deref(), Some(CENTRAL_REPO));
// 记录自己写了 url 的,保留但 http → https。
assert_eq!(
releases[1].libraries[1].url.as_deref(),
Some("https://repo.maven.apache.org/maven2/")
);
// snapshot 分支:`latest` 别名跳过,直链留给 maven-metadata 现拼。
let snapshots = &table["1.12.2"];
assert_eq!(snapshots.len(), 1, "latest 别名不算一个版本");
assert!(snapshots[0].snapshot);
assert!(snapshots[0].url.is_none());
assert_eq!(
snapshots[0].snapshot_dir.as_deref(),
Some("https://repo.mumfrey.com/content/repositories/snapshots/com/mumfrey/liteloader/1.12.2-SNAPSHOT/")
);
// 分支级 libraries 里那条 mixin 不该混进记录(那是 ForgeGradle 开发环境用的)。
assert!(!snapshots[0]
.libraries
.iter()
.any(|library| library.name.contains("mixin")));
}
#[test]
fn snapshot_url_comes_from_maven_metadata() {
let table = parse_versions(LITELOADER_VERSIONS).expect("解析");
let entry = &table["1.12.2"][0];
assert_eq!(
snapshot_artifact_url(entry, LITELOADER_METADATA).expect("应拼得出直链"),
"https://repo.mumfrey.com/content/repositories/snapshots/com/mumfrey/liteloader/1.12.2-SNAPSHOT/liteloader-1.12.2-20171128.144431-4-release.jar"
);
// 缺 <buildNumber>:报错,不猜一个指向 404 的直链。
let broken = LITELOADER_METADATA.replace("<buildNumber>4</buildNumber>", "");
let error = snapshot_artifact_url(entry, &broken).expect_err("缺字段必须报错");
assert_eq!(error.kind(), LoaderErrorKind::Metadata);
}
#[test]
fn build_release_synthesizes_launchwrapper_manifest() {
let table = parse_versions(LITELOADER_VERSIONS).expect("解析");
let entry = find_entry(&table["1.12.2"], "1.12.2-snapshot").expect("版本号大小写不敏感");
let url = snapshot_artifact_url(entry, LITELOADER_METADATA).expect("直链");
let release = build_release("1.12.2", "1.12.2", entry, &url).expect("合成");
assert_eq!(release.version_id, "1.12.2-liteloader-1.12.2-SNAPSHOT");
let json = &release.version_json;
assert_eq!(
json.main_class.as_deref(),
Some("net.minecraft.launchwrapper.Launch")
);
// 父子链靠 arguments.game 追加,不写 minecraftArguments(写了会顶掉 Forge 的 tweakClass)。
assert!(json.minecraft_arguments.is_none());
let game: Vec<String> = json
.arguments
.as_ref()
.expect("arguments 必须有")
.expand(&Default::default())
.game;
assert_eq!(
game,
[
"--tweakClass",
"com.mumfrey.liteloader.launch.LiteLoaderTweaker"
]
);
assert_eq!(json.inherits_from.as_deref(), Some("1.12.2"));
assert_eq!(json.jar.as_deref(), Some("1.12.2"), "jar 恒在原版那层");
assert_eq!(json.version_type, Some(ReleaseType::Snapshot));
// 本体 jar 那一条:绝对直链 + maven 坐标(快照路径与坐标不一致,所以必须给 url)。
let loader = json
.libraries
.as_ref()
.expect("libraries")
.iter()
.find(|library| library.name == "com.mumfrey:liteloader:1.12.2-SNAPSHOT")
.expect("缺本体库");
assert_eq!(
loader
.downloads
.as_ref()
.and_then(|downloads| downloads.artifact.as_ref())
.and_then(|artifact| artifact.url.as_deref()),
Some(url.as_str())
);
assert!(!release.requires_installer(), "LiteLoader 不跑任何外部进程");
assert!(release.warnings.is_empty(), "{:?}", release.warnings);
}
#[test]
fn synthesized_version_id_parses_back_to_liteloader() {
for (mc, loader_version) in [("1.12.2", "1.12.2-SNAPSHOT"), ("1.7.10", "1.7.10_04")] {
let id = LoaderKind::LiteLoader.version_id(mc, loader_version);
let parsed = InstanceLoader::parse(&id);
assert_eq!(parsed.kind, Wide::LiteLoader, "`{id}` 认错了家");
assert_eq!(parsed.version, loader_version, "`{id}` 的版本段");
}
}
#[test]
fn missing_tweak_class_is_a_metadata_error() {
let table = parse_versions(LITELOADER_VERSIONS).expect("解析");
let mut entry = table["1.7.10"][0].clone();
entry.tweak_class = " ".to_string();
let error = build_release("1.7.10", "1.7.10", &entry, "https://example.invalid/x.jar")
.expect_err("缺 tweakClass 必须报错");
assert_eq!(error.kind(), LoaderErrorKind::Metadata);
}
#[test]
fn default_entry_prefers_release_builds() {
let table = parse_versions(LITELOADER_VERSIONS).expect("解析");
// 1.7.10 只有 release:取最新那条。
assert_eq!(
default_entry(&table["1.7.10"]).map(|entry| entry.version.as_str()),
Some("1.7.10_04")
);
// 1.12.2 只有快照:没有 release 可选时退回最新。
assert_eq!(
default_entry(&table["1.12.2"]).map(|entry| entry.version.as_str()),
Some("1.12.2-SNAPSHOT")
);
}
#[test]
fn unknown_version_does_not_fall_back() {
let table = parse_versions(LITELOADER_VERSIONS).expect("解析");
assert!(find_entry(&table["1.7.10"], "1.7.10_99").is_none());
// 形状不符的表(顶层不是 versions)也要报 metadata,而不是当空表。
let error = parse_versions(r#"{"versions": []}"#).expect_err("形状不符必须报错");
assert_eq!(error.kind(), LoaderErrorKind::Metadata);
}
/// 快照仓**固定**在 `repo.mumfrey.com/content/repositories/snapshots/`,不跟 `repo.url` 走。
///
/// 抓得住:1.10.2 的 `repo.url` 是 `…/repo/`,照它拼快照路径会 302 到
/// `…/repositories/liteloader/…` 再 404(实测)。1.12.2 的 `repo.url` 恰好就是快照仓,
/// 所以只测 1.12.2 会漏掉这个 bug。
#[test]
fn snapshot_repo_is_fixed_not_taken_from_repo_url() {
let table = parse_versions(LITELOADER_VERSIONS).expect("解析");
let entries = &table["1.10.2"];
let snapshot = entries
.iter()
.find(|entry| entry.snapshot)
.expect("1.10.2 应有快照条目");
assert_eq!(
snapshot.snapshot_dir.as_deref(),
Some("https://repo.mumfrey.com/content/repositories/snapshots/com/mumfrey/liteloader/1.10.2-SNAPSHOT/")
);
// release 条目仍走该版本自己的 `repo.url`(上游 `/repo/` 会 302 到真地址,引擎跟重定向)。
let release = entries
.iter()
.find(|entry| !entry.snapshot)
.expect("1.10.2 应有 release 条目");
assert_eq!(
release.url.as_deref(),
Some("https://dl.liteloader.com/repo/com/mumfrey/liteloader/1.10.2/liteloader-1.10.2.jar")
);
}
+137
View File
@@ -0,0 +1,137 @@
//! 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")
.expect("maven 族应有地址")
.ends_with("net/neoforged/forge/maven-metadata.xml"));
assert!(metadata_url(LoaderKind::NeoForge, "1.21.1")
.expect("maven 族应有地址")
.ends_with("net/neoforged/neoforge/maven-metadata.xml"));
assert!(metadata_url(LoaderKind::Forge, "1.20.1")
.expect("maven 族应有地址")
.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").expect("maven 族应有地址"),
"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").expect("maven 族应有地址"),
"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").expect("maven 族应有地址"),
"https://maven.neoforged.net/releases/net/neoforged/forge/1.20.1-47.1.106/forge-1.20.1-47.1.106-installer.jar"
);
}
+58
View File
@@ -0,0 +1,58 @@
//! `addon::loader` 的单元测试:离线夹具对拍 + 镜像选择 + unsupported 判定。
//!
//! 刻意**不打真实网络**:JSON 归一化、版本表挑选、镜像候选、计划形状全是纯计算。
//! 夹具是官方响应的**裁剪版**(保留形状与关键字段,去掉几百行库与 base64 图标)。
mod fabric;
mod forge;
mod liteloader;
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/"}
]
}"#;
+198
View File
@@ -0,0 +1,198 @@
//! 计划形状 / 继承链两级解析 / 镜像选择。
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(),
parent_installed: false,
})
.expect("计划应可组");
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(),
parent_installed: false,
})
.expect("计划应可组");
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(),
parent_installed: true,
})
.expect("计划应可组");
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(),
parent_installed: true,
})
.expect("计划应可组");
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(),
parent_installed: true,
})
.expect("计划应可组");
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").expect("maven 族应有地址");
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").expect("maven 族应有地址");
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,85 @@
//! 安装侧词表与 `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),
(LoaderKind::LiteLoader, Wide::LiteLoader),
];
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)。
/// 抓不住:`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", "", "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` 认可的那几家"
);
}
+72
View File
@@ -0,0 +1,72 @@
//! 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 **只导入不导出**(有意省略)。
//!
//! - [`online`](P2 · M162):**在线内容源**——先只做匿名可用的 Modrinth(`/v2/search` /
//! `/v2/project` / `/v2/project/{id}/version` / `/v2/version_file/{hash}`)。网络出口只有
//! `Transport` 一个 trait(生产实现走 `core::download` 的引擎);下载走引擎并**复核
//! sha1 + sha512**;远端条目补全按 sha512 反查 sha1,补不出照旧如实计数。
//! CurseForge 需要 `x-api-key`,本轮不碰(见模块文档的「留的缝」)。
//!
//! - [`mods`](P2 · M224):**本地 Mod 列表与启停**——`.disabled` / `.old` 后缀的唯一判据
//! (导出扫描与导入计划两侧委派到这里)、`<实例运行目录>/mods/` 的单根列表、启停 = 在
//! `mods/` 里改名(五条守卫、幂等、不覆盖)。
//!
//! 规划中:CurseForge 客户端(§5)。
pub mod export;
pub mod import;
pub mod loader;
pub mod mods;
pub mod online;
pub mod pack;
pub use export::{
export_instance, ExportFile, ExportReport, ExportRequest, FileClass, FileReason, RemoteCatalog,
RemoteHit,
};
pub use import::{
import_pack, scan_pack, scan_pack_online, ConflictChoice, ConflictDecisions, ConflictFile,
ConflictPolicy, ImportConflict, ImportError, ImportErrorKind, ImportProgress,
ImportProgressSink, ImportReport, ImportRequest, ImportScan, ImportStage, ImportStatus,
ImportedFile, MissingFile, PackBaseline, PackFetcher, RejectedComponent,
};
pub use loader::{
LoaderError, LoaderErrorKind, LoaderInstallPlan, LoaderKind, LoaderPlanInput, LoaderRelease,
LoaderSource, LoaderSupport,
};
pub use mods::{
ModEntry, ModError, ModScan, ModSkip, ModToggle, SkipReason, SuffixVariant as ModSuffixVariant,
MODS_DIR,
};
pub use online::{
download_verified, ArtifactFetcher, ContentType, Downloaded, EngineArtifactFetcher,
EngineTransport, Modrinth, OnlineError, OnlineErrorKind, ProjectDetail, ProjectHit, SearchPage,
SearchQuery, SortIndex, Transport, Version, VersionFile, VersionQuery, MODRINTH_API,
};
pub use pack::{
EnvSupport, PackArchive, PackError, PackErrorKind, PackFile, PackFormat, PackLimits,
PackLoader, PackManifest, PackOverrides, PackRegistry, PackResult, PackSide,
};
#[cfg(test)]
pub(crate) mod temp;
#[cfg(test)]
mod tests;
+92
View File
@@ -0,0 +1,92 @@
//! 本地 Mod 读写的失败形状(CLI/UI 据此给具名原因,不折叠成一句「失败」)。
use std::fmt;
use std::path::PathBuf;
/// 一次列目录 / 启停失败。
#[derive(Debug)]
pub enum ModError {
/// 文件名不是**裸文件名**(含分隔符 / `..` / 盘符 / NUL)。
///
/// 判据直接复用整合包侧的七条路径规则([`crate::addon::pack::path::normalize_file`]),
/// 再要求结果里没有 `/`:启停只接受 `mods/` 下**直接**的一个文件,
/// 不收相对路径——那等于让 UI 决定落到哪个子目录。
UnsafeName {
/// 调用方给的名字。
file: String,
/// 人读原因。
detail: String,
},
/// `mods` 目录本身不可用:是符号链接 / 不是目录 / 读不了。
///
/// 符号链接**不跟随**:`<run>/mods` 指到别处时,改名会落到实例目录之外。
ModsDirUnusable {
/// 那个目录。
dir: PathBuf,
/// 人读原因。
detail: String,
},
/// 要启停的文件不在 `mods/` 里。
NotFound {
/// 文件名。
file: String,
/// 找过的目录。
dir: PathBuf,
},
/// 目标存在但不是普通文件(目录 / 符号链接 / 设备)。
NotARegularFile {
/// 文件名。
file: String,
/// 人读原因。
detail: String,
},
/// 目标名字已被占用(**不覆盖**:那可能是另一个 mod)。
Collision {
/// 原名字。
file: String,
/// 想要的(已存在的)目标名。
target: String,
/// 目录。
dir: PathBuf,
},
/// 改名本身失败(占用 / 权限 / 磁盘异常)。
Rename {
/// 原名字。
file: String,
/// 目标名。
target: String,
/// 底层原因。
detail: String,
},
}
impl fmt::Display for ModError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::UnsafeName { file, detail } => {
write!(f, "文件名不可用于启停({file:?}):{detail}")
}
Self::ModsDirUnusable { dir, detail } => {
write!(f, "mods 目录不可用({}):{detail}", dir.display())
}
Self::NotFound { file, dir } => {
write!(f, "mods 目录里没有 {file}({})", dir.display())
}
Self::NotARegularFile { file, detail } => {
write!(f, "{file} 不是普通文件:{detail}")
}
Self::Collision { file, target, dir } => write!(
f,
"{file} 改不成 {target}:同名文件已存在({})——不覆盖,请先处理那个文件",
dir.join(target).display()
),
Self::Rename {
file,
target,
detail,
} => write!(f, "{file} → {target} 改名失败:{detail}"),
}
}
}
impl std::error::Error for ModError {}
+204
View File
@@ -0,0 +1,204 @@
//! 列一个实例 `mods/` 目录(**只读**、只看这一个目录)。
//!
//! # 单根语义(D20)
//!
//! 入口是**已经派生好的运行目录**(`game::isolation::derive_game_dir` 的结果),本模块只往下
//! 取一层 `mods/`:不自己去猜游戏根、不做「隔离侧 + 共享侧合并」。派生是启动与列表**同一个**
//! 答案的唯一来源,第二处派生就是第二套真相。
//!
//! # 收哪些条目
//!
//! 与导出扫描同一条纪律:`mods/` 下**不按扩展名过滤**(`.jar` / `.jar.disabled` / `.zip` /
//! 认不出的后缀一律列出)。丢掉"我不认识的东西"正是用户点名不许的——启停开关对认不出的
//! 文件同样有意义(禁用它 = 让游戏别加载它)。
//!
//! 不收的东西只有三类,且都**逐条登记原因**(不是静默跳过):
//! 符号链接(不跟随)、非普通文件(目录 / 设备)、名字过不了路径规则([`crate::addon::pack::path`])。
use std::path::{Path, PathBuf};
use super::error::ModError;
use super::suffix::{display_name, suffix_variant, SuffixVariant};
/// `mods` 目录名(游戏与社区一致的口径)。
pub const MODS_DIR: &str = "mods";
/// 某个运行目录下的 `mods/`(不建、不检查——纯路径拼接)。
#[must_use]
pub fn mods_dir(run_dir: &Path) -> PathBuf {
run_dir.join(MODS_DIR)
}
/// 列表里的一条。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ModEntry {
/// 磁盘上的文件名(含 `.disabled` / `.old` 后缀,原样)。
pub file: String,
/// 展示名(去掉两个后缀,不做 `-版本号` 猜解)。
pub name: String,
/// 是否启用(`.disabled` 缺席即启用)。
pub enabled: bool,
/// 字节数。
pub bytes: u64,
/// 后缀变体(UI 用它区分"旧残留")。
pub variant: SuffixVariant,
}
/// 没能进列表的一条。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ModSkip {
/// 文件名(读不到时给能拿到的原样串)。
pub file: String,
/// 为什么没列。
pub reason: SkipReason,
}
/// 没列出来的原因。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SkipReason {
/// 符号链接(不跟随:跟随会把实例外的东西算成实例内容)。
SymbolicLink,
/// 不是普通文件(目录 / 设备 / 管道)。
NotAFile,
/// 名字过不了路径规则(含 `\`、`:`、`..` 之类)。
UnsafeName,
/// 元数据读不出来。
Unreadable,
}
/// 一次列目录的结果。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ModScan {
/// 被列的那个 `mods/` 目录。
pub dir: PathBuf,
/// 目录存不存在(`false` = 这个实例还没装过 mod,不是错误)。
pub exists: bool,
/// 列表(按展示名排序,大小写不敏感)。
pub entries: Vec<ModEntry>,
/// 跳过的条目与原因。
pub skipped: Vec<ModSkip>,
}
impl ModScan {
/// 启用中的条数。
#[must_use]
pub fn enabled_count(&self) -> usize {
self.entries.iter().filter(|entry| entry.enabled).count()
}
/// 禁用中的条数。
#[must_use]
pub fn disabled_count(&self) -> usize {
self.entries.len() - self.enabled_count()
}
}
/// 列一个运行目录下的 `mods/`。
///
/// 目录不存在 → `exists = false` 的空结果(空实例不是错误)。
///
/// # Errors
///
/// `mods` 存在但不可用(符号链接 / 不是目录 / 读不了)时给 [`ModError::ModsDirUnusable`]。
pub fn scan(run_dir: &Path) -> Result<ModScan, ModError> {
let dir = mods_dir(run_dir);
let meta = match std::fs::symlink_metadata(&dir) {
Ok(meta) => meta,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Ok(ModScan {
dir,
exists: false,
entries: Vec::new(),
skipped: Vec::new(),
});
}
Err(error) => {
return Err(ModError::ModsDirUnusable {
dir,
detail: error.to_string(),
});
}
};
if meta.file_type().is_symlink() {
return Err(ModError::ModsDirUnusable {
dir,
detail: "是符号链接(不跟随:改名会落到实例目录之外)".to_string(),
});
}
if !meta.is_dir() {
return Err(ModError::ModsDirUnusable {
dir,
detail: "不是目录".to_string(),
});
}
let read = std::fs::read_dir(&dir).map_err(|error| ModError::ModsDirUnusable {
dir: dir.clone(),
detail: error.to_string(),
})?;
let mut children: Vec<_> =
read.collect::<Result<Vec<_>, _>>()
.map_err(|error| ModError::ModsDirUnusable {
dir: dir.clone(),
detail: error.to_string(),
})?;
children.sort_by_key(std::fs::DirEntry::file_name);
let mut entries = Vec::new();
let mut skipped = Vec::new();
for child in children {
let raw = child.file_name().to_string_lossy().to_string();
let Ok(file_type) = child.file_type() else {
skipped.push(ModSkip {
file: raw,
reason: SkipReason::Unreadable,
});
continue;
};
if file_type.is_symlink() {
skipped.push(ModSkip {
file: raw,
reason: SkipReason::SymbolicLink,
});
continue;
}
if !file_type.is_file() {
skipped.push(ModSkip {
file: raw,
reason: SkipReason::NotAFile,
});
continue;
}
if crate::addon::pack::path::normalize_file(&raw).is_err() || raw.contains('/') {
skipped.push(ModSkip {
file: raw,
reason: SkipReason::UnsafeName,
});
continue;
}
let bytes = child.metadata().map(|meta| meta.len()).unwrap_or(0);
entries.push(ModEntry {
name: display_name(&raw).to_string(),
enabled: !suffix_variant(&raw).is_disabled(),
variant: suffix_variant(&raw),
file: raw,
bytes,
});
}
// 展示名排序(大小写不敏感;同名时按磁盘名字典序兜底,保证两帧之间不抖)。
entries.sort_by(|left, right| {
left.name
.to_lowercase()
.cmp(&right.name.to_lowercase())
.then_with(|| left.file.cmp(&right.file))
});
skipped.sort_by(|left, right| left.file.cmp(&right.file));
Ok(ModScan {
dir,
exists: true,
entries,
skipped,
})
}
+60
View File
@@ -0,0 +1,60 @@
//! 本地 Mod 的**列表**与**启停**(M224 · MODTOGGLE)。
//!
//! 设计定稿:`DevDocs/PlanDoc/08-mod-and-recommend.md` §3(三条结论原样落地)。
//!
//! # 三层里的第一层
//!
//! §3 的结论是「Mod 启停 = 新建三层(core 读写目录 + presenter DTO + 实例详情页),不是给
//! 内容页接一个开关」,并特别点名**不要**给内容页加第五个 `ContentKind`(会打断 4 处穷尽 match)。
//! 本模块就是那第一层,另外两层见 `crates/presenter/src/dto/mods.rs` 与
//! `crates/app-iced/src/pages/instances/mods.rs`。
//!
//! # 唯一 `.disabled` 判据
//!
//! 收敛前仓里有两份逐字同构的后缀判据(`addon::export::scan::is_disabled_path` 与
//! `addon::import::plan::paths::suffix_variant`)。M218 的要求是「做这块时第一步先收敛成一份,
//! 别让第三份出生」,所以判据只有 [`suffix`] 一处,导出/导入两侧改为**委派**(签名与行为不变,
//! 收敛 diff 为零行为变化)。启停要改写文件名,比"判一下"多两件事——保住原始大小写、保住
//! `.old` 的位置——这两件也只能和判据同源,否则会出现「判禁用用一套、改名字用另一套」。
//!
//! # 单根语义(D20)
//!
//! 入口一律是**派生好的运行目录**([`crate::game::isolation::derive_game_dir`] 的结果),
//! 本模块只碰 `<run_dir>/mods/`:
//!
//! - 不自己去猜游戏根,也不做「隔离侧 `versions/<id>/mods` + 共享侧 `.minecraft/mods` 合并」
//! 这种双根视图——派生是启动与列表**同一个**答案的唯一来源;
//! - 只往下取一层:`mods/` 下的子目录不递归(游戏也不递归);
//! - `mods/` 本身是符号链接时**拒绝**(不跟随),否则改名会落到实例目录之外。
//!
//! # 模块
//!
//! | 文件 | 职责 |
//! |---|---|
//! | [`suffix`] | `.disabled` / `.old` 的唯一判据与改名目标名(含四态枚举) |
//! | [`list`] | 列 `<run_dir>/mods/`(只读、逐条登记跳过原因) |
//! | [`toggle`] | 启停 = 在 `mods/` 里改名(五条守卫、幂等、不覆盖) |
//!
//! # 已知边界
//!
//! - **不判断游戏是否在跑**:进程正持有这些 jar(Windows 上 rename 会失败),失败按
//! [`error::ModError::Rename`] 如实报出来。要不要在上层挡「运行中不许改」是宿主层的策略
//! (presenter 侧已挡,见 `host::mods`)。
//! - **不解析 jar**:列表给的是文件名,`-版本号` 猜解仍归 `diagnose::environment::ModRecord`,
//! 这里不复制第二份启发式。
mod error;
mod list;
mod suffix;
mod toggle;
pub use error::ModError;
pub use list::{mods_dir, scan, ModEntry, ModScan, ModSkip, SkipReason, MODS_DIR};
pub use suffix::{
disabled_name, display_name, enabled_name, is_disabled_path, renamed_for, suffix_variant,
SuffixVariant, DISABLED_SUFFIX, OLD_SUFFIX,
};
pub use toggle::{ensure_dir, set_enabled, ModToggle};
#[cfg(test)]
mod tests;
+263
View File
@@ -0,0 +1,263 @@
//! `.disabled` / `.old` 后缀的**唯一判据**(M224 · MODTOGGLE)。
//!
//! # 为什么这里是一份而不是三份
//!
//! 收敛前仓里有两份同构实现,各自 lowercase → 剥 `.old` → `ends_with(".disabled")`:
//!
//! | 位置 | 形状 | 用途 |
//! |---|---|---|
//! | `addon::export::scan::is_disabled_path` | 只给 bool | 导出扫描判禁用位 |
//! | `addon::import::plan::paths::suffix_variant` | 四态枚举 | 导入计划打注记 |
//!
//! 两份逐字同构,而 M218 的结论是「做启停时第一步先收敛成一份,别让第三份出生」
//! (`DevDocs/PlanDoc/08-mod-and-recommend.md` §3)。启停要**改写**文件名,比"判一下"多两件事:
//! ①必须保住原始大小写(`X.JAR.DISABLED` 禁用后不能变全小写);②必须保住 `.old` 的位置
//! (`.disabled` 只能出现在 `.old` **之前**,否则 [`suffix_variant`] 认不出来)。
//!
//! 所以本模块是唯一出口:判据([`suffix_variant`] / [`is_disabled_path`])与改写目标名
//! ([`renamed_for`])用**同一套剥离逻辑**,不会出现「判禁用用一套、改名字用另一套」。
//! 导出与导入两侧改为委派到本模块(函数名与签名保持不变)。
//!
//! # 认哪些形状(写死四种)
//!
//! | 磁盘上的名字 | 变体 | 禁用 | `.old` |
//! |---|---|---|---|
//! | `x.jar` | [`SuffixVariant::Plain`] | ❌ | ❌ |
//! | `x.jar.disabled` | [`SuffixVariant::Disabled`] | ✅ | ❌ |
//! | `x.jar.old` | [`SuffixVariant::Old`] | ❌ | ✅ |
//! | `x.jar.disabled.old` | [`SuffixVariant::DisabledOld`] | ✅ | ✅ |
//!
//! 大小写不敏感(Windows 上 `X.JAR.DISABLED` 与 `x.jar.disabled` 是同一个文件);`.old` 只
//! 识别与保留(不清除、不改名——它是 HMCL 生态的旧版本残留,删掉等于替用户做决定)。
/// 禁用位的路径后缀(官方 Modrinth 导出的表达,也是唯一跨启动器保真的那条)。
pub const DISABLED_SUFFIX: &str = ".disabled";
/// 旧版本残留的路径后缀(只识别与保留)。
pub const OLD_SUFFIX: &str = ".old";
/// 路径后缀变体(`.disabled` / `.old` 的四种组合)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
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_SUFFIX).unwrap_or(&lowered);
let old = stripped.len() != lowered.len();
let disabled = stripped.ends_with(DISABLED_SUFFIX);
match (disabled, old) {
(false, false) => SuffixVariant::Plain,
(true, false) => SuffixVariant::Disabled,
(false, true) => SuffixVariant::Old,
(true, true) => SuffixVariant::DisabledOld,
}
}
/// 路径后缀是否带 `.disabled`(允许 `x.jar.disabled.old` 这种"禁用 + 旧残留"组合)。
#[must_use]
pub fn is_disabled_path(path: &str) -> bool {
suffix_variant(path).is_disabled()
}
/// `enabled = true` 时该改成什么名字(`None` = 已经是启用态,不用动)。
///
/// `x.jar.disabled` → `x.jar`;`x.jar.disabled.old` → `x.jar.old`(`.old` 原位保留)。
/// 原始大小写按**逐字符比较**剥掉后缀,所以 `X.JAR.DISABLED` → `X.JAR`,不会被整串小写化。
#[must_use]
pub fn enabled_name(name: &str) -> Option<String> {
let trimmed = name.trim_end();
// **先剥 `.old` 再剥 `.disabled`**:反过来会漏 `x.jar.disabled.old`(结尾是 `.old`,
// 直接找结尾的 `.disabled` 找不到 ⇒ 明明禁用着却说"已是启用态")。这一条由
// `toggling_round_trips_every_variant` / `enabling_only_strips_the_disabled_suffix` 钉住。
let (body, old) = match strip_ascii_ci(trimmed, OLD_SUFFIX) {
Some(base) => (base, Some(&trimmed[base.len()..])),
None => (trimmed, None),
};
let stripped = strip_ascii_ci(body, DISABLED_SUFFIX)?;
// 剥完只剩空串或只剩 `.old` 都不是文件名(`.disabled` / `.old.disabled` 这种畸形)。
if stripped.is_empty() || stripped.eq_ignore_ascii_case(OLD_SUFFIX) {
return None;
}
Some(match old {
Some(old) => format!("{stripped}{old}"),
None => stripped.to_string(),
})
}
/// `enabled = false` 时该改成什么名字(`None` = 已经是禁用态,不用动)。
///
/// `x.jar` → `x.jar.disabled`;`x.jar.old` → `x.jar.disabled.old`——`.disabled` **插在** `.old`
/// 之前,否则 [`suffix_variant`] 读回来是 `Old`("有旧残留但没禁用"),禁用位就丢了。
#[must_use]
pub fn disabled_name(name: &str) -> Option<String> {
let trimmed = name.trim_end();
if trimmed.is_empty() {
return None;
}
if suffix_variant(trimmed).is_disabled() {
return None;
}
// 只认原串里那个 `.old`(大小写可能不同),按字节位置插进去,保住其余部分原样。
if let Some(base) = strip_ascii_ci(trimmed, OLD_SUFFIX) {
if base.is_empty() {
return None;
}
return Some(format!("{base}{DISABLED_SUFFIX}{OLD_SUFFIX}"));
}
Some(format!("{trimmed}{DISABLED_SUFFIX}"))
}
/// 启停后应当落成的文件名(`None` = 已经是这个状态,无需 rename)。
#[must_use]
pub fn renamed_for(name: &str, enabled: bool) -> Option<String> {
if enabled {
enabled_name(name)
} else {
disabled_name(name)
}
}
/// 展示名(去掉 `.disabled` / `.old` 两个后缀,其余原样)。
///
/// **不**做 `-版本号` 的猜解:那是 `diagnose::environment::ModRecord` 的活,这里再猜一次就是
/// 第二份启发式。列表要的是"这个文件叫什么",不是"它可能是哪个 mod"。
#[must_use]
pub fn display_name(name: &str) -> &str {
let trimmed = name.trim_end();
// 与 [`enabled_name`] 同一个顺序:先 `.old`、再 `.disabled`(`x.jar.disabled.old` 两个都要剥)。
let without_old = strip_ascii_ci(trimmed, OLD_SUFFIX).unwrap_or(trimmed);
strip_ascii_ci(without_old, DISABLED_SUFFIX).unwrap_or(without_old)
}
/// 去掉**结尾**的 ASCII 后缀(大小写不敏感),保住前半段原始大小写。
fn strip_ascii_ci<'a>(text: &'a str, suffix: &str) -> Option<&'a str> {
let cut = text.len().checked_sub(suffix.len())?;
if !text.is_char_boundary(cut) {
return None;
}
text.get(cut..)
.filter(|tail| tail.eq_ignore_ascii_case(suffix))
.map(|_| &text[..cut])
}
#[cfg(test)]
mod tests {
use super::*;
/// 四种形状都认得,且禁用位只看 `.disabled`(`.old` 单独出现不是禁用)。
#[test]
fn recognises_four_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!(is_disabled_path("mods/x.jar.disabled"));
assert!(is_disabled_path("mods/x.jar.disabled.old"));
assert!(!is_disabled_path("mods/x.jar.old"));
assert!(!is_disabled_path("mods/x.jar"));
}
/// 大小写不敏感(Windows 上 `X.JAR.DISABLED` 就是禁用),尾随空白不算名字的一部分。
#[test]
fn suffix_matching_ignores_ascii_case() {
assert_eq!(suffix_variant("X.JAR.DISABLED"), SuffixVariant::Disabled);
assert_eq!(suffix_variant("X.Disabled.Old"), SuffixVariant::DisabledOld);
assert_eq!(suffix_variant("x.jar.disabled \n"), SuffixVariant::Disabled);
// 不是子串匹配:`.disabled` 必须真在结尾。
assert_eq!(suffix_variant("x.disabled.jar"), SuffixVariant::Plain);
assert_eq!(suffix_variant("x.jar.notdisabled"), SuffixVariant::Plain);
}
/// 启用 = 剥 `.disabled`,`.old` 原位保留,**原始大小写不动**。
#[test]
fn enabling_only_strips_the_disabled_suffix() {
assert_eq!(enabled_name("x.jar.disabled").as_deref(), Some("x.jar"));
assert_eq!(
enabled_name("x.jar.disabled.old").as_deref(),
Some("x.jar.old")
);
assert_eq!(enabled_name("X.JAR.DISABLED").as_deref(), Some("X.JAR"));
// 已经是启用态 / 只剩畸形后缀 ⇒ None(不猜、不改)。
assert_eq!(enabled_name("x.jar"), None);
assert_eq!(enabled_name(".disabled"), None);
assert_eq!(enabled_name(".old.disabled"), None);
}
/// 禁用 = 把 `.disabled` **插在** `.old` 之前(插在后面判据读不回来)。
#[test]
fn disabling_inserts_before_the_old_suffix() {
assert_eq!(disabled_name("x.jar").as_deref(), Some("x.jar.disabled"));
assert_eq!(
disabled_name("x.jar.old").as_deref(),
Some("x.jar.disabled.old")
);
assert_eq!(
disabled_name("X.JAR.OLD").as_deref(),
Some("X.JAR.disabled.old")
);
assert_eq!(disabled_name("x.jar.disabled"), None);
assert_eq!(disabled_name("x.jar.disabled.old"), None);
assert_eq!(disabled_name(""), None);
assert_eq!(disabled_name(".old"), None);
}
/// 两个方向互为逆(同一条剥离逻辑的自证):往返回来名字一模一样。
#[test]
fn toggling_round_trips_every_variant() {
for plain in ["x.jar", "X.JAR", "风.jar", "a.b.c"] {
let off = renamed_for(plain, false).expect("禁用目标名");
assert_eq!(suffix_variant(&off), SuffixVariant::Disabled);
assert_eq!(renamed_for(&off, true).as_deref(), Some(plain));
}
// `.old` 变体往返也不掉 `.old`。
let off = renamed_for("x.jar.old", false).expect("禁用目标名");
assert_eq!(off, "x.jar.disabled.old");
assert_eq!(renamed_for(&off, true).as_deref(), Some("x.jar.old"));
// 已经在目标状态 ⇒ None(幂等,不产生多余 rename)。
assert_eq!(renamed_for("x.jar", true), None);
assert_eq!(renamed_for("x.jar.disabled", false), None);
}
/// 展示名剥两个后缀,但**不**猜 `-版本号`。
#[test]
fn display_name_strips_suffixes_only() {
assert_eq!(
display_name("sodium-0.5.8.jar.disabled"),
"sodium-0.5.8.jar"
);
assert_eq!(display_name("x.jar.disabled.old"), "x.jar");
assert_eq!(display_name("jei.jar"), "jei.jar");
assert_eq!(display_name("X.JAR.DISABLED"), "X.JAR");
}
}
+254
View File
@@ -0,0 +1,254 @@
//! 列表与启停的用例:**真的建盘上文件**再读回来(不 mock 文件系统)。
//!
//! 每条用例都写在「抓得住什么 / 抓不住什么」的口径里:抓得住的是判据与守卫本身,
//! 抓不住的是 Windows 与 Linux 上 rename 的细节差异(那条靠真实运行,见 handoff)。
use std::path::Path;
use super::suffix::SuffixVariant;
use super::*;
use crate::addon::temp::TempRoot;
/// 建一个运行目录 + `mods/`,并把给定文件写进去。
fn fixture(tag: &str, files: &[(&str, &[u8])]) -> TempRoot {
let root = TempRoot::under(tag, "run");
let mods = mods_dir(&root);
std::fs::create_dir_all(&mods).expect("建 mods");
for (name, body) in files {
std::fs::write(mods.join(name), body).expect("写 mod 文件");
}
root
}
/// 列目录:四种后缀变体都认、禁用位只看 `.disabled`、目录不进列表。
#[test]
fn scan_lists_every_variant_and_skips_directories() {
let root = fixture(
"mods-scan",
&[
("sodium.jar", b"a"),
("jei.jar.disabled", b"bb"),
("opt.jar.old", b"ccc"),
("aether.jar.disabled.old", b"dddd"),
("notes.txt", b"whatever"),
],
);
std::fs::create_dir(root.join("mods/sub")).expect("建子目录");
let scan = scan(&root).expect("列目录");
assert!(scan.exists);
assert_eq!(scan.dir, root.join("mods"));
let files: Vec<&str> = scan
.entries
.iter()
.map(|entry| entry.file.as_str())
.collect();
assert_eq!(
files,
[
"aether.jar.disabled.old",
"jei.jar.disabled",
"notes.txt",
"opt.jar.old",
"sodium.jar"
]
);
let entry = |file: &str| {
scan.entries
.iter()
.find(|entry| entry.file == file)
.unwrap_or_else(|| panic!("{file} 不在列表里"))
};
assert!(entry("sodium.jar").enabled);
assert!(!entry("jei.jar.disabled").enabled);
assert!(entry("opt.jar.old").enabled, "`.old` 单独出现不是禁用");
assert!(!entry("aether.jar.disabled.old").enabled);
// 展示名剥两个后缀(不做 -版本号 猜解)。
assert_eq!(entry("aether.jar.disabled.old").name, "aether.jar");
assert_eq!(entry("sodium.jar").variant, SuffixVariant::Plain);
assert_eq!(entry("jei.jar.disabled").bytes, 2);
assert_eq!(scan.skipped.len(), 1);
assert_eq!(scan.skipped[0].file, "sub");
assert_eq!(scan.skipped[0].reason, SkipReason::NotAFile);
assert_eq!(scan.enabled_count(), 3);
assert_eq!(scan.disabled_count(), 2);
}
/// 空实例(没有 `mods/`)不是错误:`exists = false` 的空结果。
#[test]
fn scan_treats_a_missing_mods_dir_as_empty() {
let root = TempRoot::under("mods-empty", "run");
let scan = scan(&root).expect("列目录");
assert!(!scan.exists);
assert!(scan.entries.is_empty());
assert!(scan.skipped.is_empty());
}
/// `mods` 是符号链接就拒(不跟随)。Windows 上建目录链接要特权,建不出来就跳过这一条。
#[test]
fn scan_refuses_a_symlinked_mods_dir() {
let root = TempRoot::under("mods-link", "run");
let outside = root.join("elsewhere");
std::fs::create_dir_all(&outside).expect("建外部目录");
std::fs::write(outside.join("x.jar"), b"x").expect("写外部文件");
let linked = link_dir(&outside, &mods_dir(&root));
if !linked {
eprintln!("[skip] 本机建不出目录符号链接(Windows 需要开发者模式/特权)");
return;
}
let error = scan(&root).expect_err("必须拒绝符号链接");
assert!(matches!(error, ModError::ModsDirUnusable { .. }), "{error}");
// 启停同样拒(同一条守卫,不另记一遍)。
let error = set_enabled(&root, "x.jar", false).expect_err("必须拒绝符号链接");
assert!(matches!(error, ModError::ModsDirUnusable { .. }), "{error}");
}
/// 启停往返:改名真的发生、读回来状态对、重复点幂等。
#[test]
fn toggling_renames_on_disk_and_is_idempotent() {
let root = fixture("mods-toggle", &[("sodium.jar", b"jar")]);
let off = set_enabled(&root, "sodium.jar", false).expect("禁用");
assert!(off.renamed);
assert_eq!(off.to, "sodium.jar.disabled");
assert!(!off.enabled);
assert!(root.join("mods/sodium.jar.disabled").is_file());
assert!(!root.join("mods/sodium.jar").exists());
// 再禁一次:不是错误,也不改名。
let again = set_enabled(&root, "sodium.jar.disabled", false).expect("幂等");
assert!(!again.renamed);
assert_eq!(again.from, again.to);
assert!(!again.enabled);
// 启用回来:名字与字节都回到原样。
let on = set_enabled(&root, "sodium.jar.disabled", true).expect("启用");
assert!(on.renamed);
assert_eq!(on.to, "sodium.jar");
assert!(on.enabled);
assert_eq!(
std::fs::read(root.join("mods/sodium.jar")).expect("读回"),
b"jar"
);
let scan = scan(&root).expect("列目录");
assert_eq!(scan.entries.len(), 1);
assert!(scan.entries[0].enabled);
}
/// `.disabled` 只能插在 `.old` **之前**,否则读回来丢禁用位(往返用例抓的就是这个)。
#[test]
fn toggling_keeps_the_old_suffix_in_place() {
let root = fixture("mods-old", &[("jei.jar.old", b"old")]);
let off = set_enabled(&root, "jei.jar.old", false).expect("禁用");
assert_eq!(off.to, "jei.jar.disabled.old");
let listed = scan(&root).expect("列目录");
assert_eq!(listed.entries[0].variant, SuffixVariant::DisabledOld);
assert!(!listed.entries[0].enabled, "禁用位被 `.old` 吃掉了");
let on = set_enabled(&root, "jei.jar.disabled.old", true).expect("启用");
assert_eq!(on.to, "jei.jar.old");
assert_eq!(
scan(&root).expect("列目录").entries[0].variant,
SuffixVariant::Old
);
}
/// 目标名已被占用 ⇒ 报冲突,**不覆盖**那个文件。
#[test]
fn toggling_refuses_to_overwrite_an_existing_file() {
let root = fixture(
"mods-collision",
&[("x.jar", b"one"), ("x.jar.disabled", b"two")],
);
let error = set_enabled(&root, "x.jar", false).expect_err("必须报冲突");
assert!(matches!(error, ModError::Collision { .. }), "{error}");
// 两个文件都还在、内容没被踩。
assert_eq!(
std::fs::read(root.join("mods/x.jar")).expect("读 a"),
b"one"
);
assert_eq!(
std::fs::read(root.join("mods/x.jar.disabled")).expect("读 b"),
b"two"
);
}
/// 路径类名字一律拒:启停只接受 `mods/` 下的裸文件名。
#[test]
fn toggling_rejects_anything_that_is_not_a_bare_file_name() {
let root = fixture("mods-names", &[("x.jar", b"x")]);
for bad in [
"",
"../x.jar",
"mods/x.jar",
"sub/x.jar",
r"mods\x.jar",
"C:/x.jar",
".hidden/../x.jar",
] {
let error = set_enabled(&root, bad, false)
.err()
.unwrap_or_else(|| panic!("{bad:?} 必须被拒"));
assert!(
matches!(
error,
ModError::UnsafeName { .. } | ModError::NotFound { .. }
),
"{bad:?} → {error}"
);
}
// 越界改名一次都没发生。
assert!(root.join("mods/x.jar").is_file());
}
/// 不存在的文件、目录、符号链接都不是"可启停的 mod"。
#[test]
fn toggling_refuses_missing_and_non_file_targets() {
let root = fixture("mods-targets", &[("x.jar", b"x")]);
std::fs::create_dir(root.join("mods/adir")).expect("建目录");
let missing = set_enabled(&root, "nope.jar", false).expect_err("不存在的必须报");
assert!(matches!(missing, ModError::NotFound { .. }), "{missing}");
let dir = set_enabled(&root, "adir", false).expect_err("目录必须报");
assert!(matches!(dir, ModError::NotARegularFile { .. }), "{dir}");
assert!(root.join("mods/adir").is_dir(), "目录被改名了");
}
/// 没有 `mods/` 时启停报「目录不可用」(不替用户建目录——建目录是装 mod 的事)。
#[test]
fn toggling_requires_an_existing_mods_dir() {
let root = TempRoot::under("mods-nodir", "run");
let error = set_enabled(&root, "x.jar", false).expect_err("没有 mods 目录");
assert!(matches!(error, ModError::ModsDirUnusable { .. }), "{error}");
assert!(!root.join("mods").exists(), "启停不该顺手建目录");
// `ensure_dir` 才是建目录那条路(装 mod 用)。
ensure_dir(&root).expect("建 mods");
assert!(root.join("mods").is_dir());
}
/// 建目录链接:Windows 上可能没特权,返回是否建成(用例据此决定跳不跳)。
fn link_dir(target: &Path, link: &Path) -> bool {
#[cfg(unix)]
{
std::os::unix::fs::symlink(target, link).is_ok()
}
#[cfg(windows)]
{
std::os::windows::fs::symlink_dir(target, link).is_ok()
}
#[cfg(not(any(unix, windows)))]
{
let _ = (target, link);
false
}
}
+166
View File
@@ -0,0 +1,166 @@
//! 启停一个 mod:**在 `mods/` 里改名**(`.disabled` 后缀就是禁用位)。
//!
//! # 为什么是改名而不是写配置文件
//!
//! 游戏本身不读任何"禁用列表"——它只加载 `mods/*.jar`。`.disabled` 后缀让文件**不再匹配**
//! 加载器的 glob,于是"禁用"是**文件系统状态**:换启动器、换机器、手动 `git checkout` 之后
//! 依然成立。这也是 Modrinth 官方导出表达禁用位的唯一方式(见
//! [`crate::addon::pack::manifest::PackFile::disabled`])。
//!
//! # 五条守卫(都不猜、都不覆盖)
//!
//! | 守卫 | 理由 |
//! |---|---|
//! | 只收**裸文件名**(过七条路径规则且不含 `/`) | 启停不接受相对路径,否则 UI 能指挥落点 |
//! | `mods/` 是符号链接就拒 | 改名会落到实例目录之外 |
//! | 目标必须是**普通文件**(非目录 / 非链接) | 改一个目录或链接不是"启停一个 mod" |
//! | 目标名已存在就拒([`ModError::Collision`]) | 覆盖掉的是**另一个 mod**,用户没同意 |
//! | rename 不做跨盘兜底(不 copy+delete) | copy 半截失败会留下两个文件,语义比报错更差 |
//!
//! # 幂等
//!
//! 「已经启用还要启用」不是错误:返回 `renamed = false` 的成功结果(UI 的开关重复点不会
//! 变成红字)。这一条由 [`super::suffix::renamed_for`] 的 `None` 表达。
use std::path::{Path, PathBuf};
use super::error::ModError;
use super::list::mods_dir;
use super::suffix::{is_disabled_path, renamed_for};
/// 一次启停的结果(`from == to` 且 `renamed == false` ⇒ 本来就是那个状态)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ModToggle {
/// 原文件名的磁盘名(含后缀)。
pub from: String,
/// 改名后的磁盘名(没改名时与 `from` 相同)。
pub to: String,
/// 这一步之后的启用状态。
pub enabled: bool,
/// 是否真的在磁盘上改了名。
pub renamed: bool,
}
/// 把 `mods/` 下的某个文件切到 `enabled` 状态(改名)。
///
/// # Errors
///
/// 见模块文档的五条守卫:[`ModError::UnsafeName`] / [`ModError::ModsDirUnusable`] /
/// [`ModError::NotFound`] / [`ModError::NotARegularFile`] / [`ModError::Collision`] /
/// [`ModError::Rename`]。
pub fn set_enabled(run_dir: &Path, file: &str, enabled: bool) -> Result<ModToggle, ModError> {
let name = bare_name(file)?;
let dir = mods_dir(run_dir);
usable_dir(&dir)?;
let from_path = dir.join(&name);
let meta = std::fs::symlink_metadata(&from_path).map_err(|error| {
if error.kind() == std::io::ErrorKind::NotFound {
ModError::NotFound {
file: name.clone(),
dir: dir.clone(),
}
} else {
ModError::NotARegularFile {
file: name.clone(),
detail: error.to_string(),
}
}
})?;
if meta.file_type().is_symlink() || !meta.is_file() {
return Err(ModError::NotARegularFile {
file: name.clone(),
detail: "不是普通文件(符号链接 / 目录 / 设备都不动)".to_string(),
});
}
let Some(target) = renamed_for(&name, enabled) else {
// 已经是目标状态:幂等返回,不产生多余 rename(Windows 上同大小写改名还可能失败)。
return Ok(ModToggle {
enabled: !is_disabled_path(&name),
from: name.clone(),
to: name,
renamed: false,
});
};
let to_path = dir.join(&target);
if std::fs::symlink_metadata(&to_path).is_ok() {
return Err(ModError::Collision {
file: name,
target,
dir,
});
}
std::fs::rename(&from_path, &to_path).map_err(|error| ModError::Rename {
file: name.clone(),
target: target.clone(),
detail: error.to_string(),
})?;
Ok(ModToggle {
from: name,
to: target,
enabled,
renamed: true,
})
}
/// 文件名必须是**裸文件名**:先过整合包侧的七条路径规则,再要求没有 `/`。
fn bare_name(file: &str) -> Result<String, ModError> {
let trimmed = file.trim();
let normalized = crate::addon::pack::path::normalize_file(trimmed).map_err(|error| {
ModError::UnsafeName {
file: file.to_string(),
detail: error.to_string(),
}
})?;
if normalized.contains('/') || normalized != trimmed {
return Err(ModError::UnsafeName {
file: file.to_string(),
detail: "只接受 mods/ 下的裸文件名(不含路径分隔符、不做归一)".to_string(),
});
}
Ok(normalized)
}
/// `mods/` 必须存在、是目录、且**不是符号链接**。
fn usable_dir(dir: &Path) -> Result<(), ModError> {
let meta = std::fs::symlink_metadata(dir).map_err(|error| ModError::ModsDirUnusable {
dir: dir.to_path_buf(),
detail: if error.kind() == std::io::ErrorKind::NotFound {
"不存在(这个实例还没有 mods 目录)".to_string()
} else {
error.to_string()
},
})?;
if meta.file_type().is_symlink() {
return Err(ModError::ModsDirUnusable {
dir: dir.to_path_buf(),
detail: "是符号链接(不跟随:改名会落到实例目录之外)".to_string(),
});
}
if !meta.is_dir() {
return Err(ModError::ModsDirUnusable {
dir: dir.to_path_buf(),
detail: "不是目录".to_string(),
});
}
Ok(())
}
/// 建 `mods/` 目录(**只在装 mod 时用**;列目录/启停不建)。
///
/// # Errors
///
/// 建不出来 / 建出来是符号链接以外的怪形状时给 [`ModError::ModsDirUnusable`]。
pub fn ensure_dir(run_dir: &Path) -> Result<PathBuf, ModError> {
let dir = mods_dir(run_dir);
std::fs::create_dir_all(&dir).map_err(|error| ModError::ModsDirUnusable {
dir: dir.clone(),
detail: error.to_string(),
})?;
usable_dir(&dir)?;
Ok(dir)
}
+119
View File
@@ -0,0 +1,119 @@
//! 按摘要认文件:[`identity_of`] 判断「站点返回的这个版本里,哪个文件才是我们要的那个」。
//!
//! # 为什么只留这一件
//!
//! 生产上的补全循环只有**一处**:`crate::addon::import::complete`(它走 `PackFetcher`,
//! 因此能区分「站点不认得这个摘要(404)」与「没联网(Offline)」,这决定了要不要提前收工)。
//! 这里曾经还有一份 `complete_files` 循环,r2 复审指出「看着像生产代码其实没人调、两套循环
//! 迟早漂移」⇒ 已删除。本文件只留**判定**,两个调用点共用:
//!
//! | 调用方 | 用它做什么 |
//! |--------|------------|
//! | `addon::import::fetch`(生产补全) | 按 sha512 反查回来的版本 → 取出那份文件的官方 sha1/大小/直链 |
//! | `online::complete` 的用例 | 钉住「对不上就不许用」 |
//!
//! # 一致性比「有值」重要
//!
//! 站点返回的版本里可能有多个文件(`primary` 之外还有 `sources.jar`)。只在
//! **`hashes.sha512` 与请求值逐字相同**的那条上取 `sha1`;一个都对不上就返回 `None`
//! ——宁可让上游记「补不出」,也不把别的东西的摘要装进来。
use super::model::Version;
/// 从版本里找出**摘要对得上**的那个文件,取出它的 sha1/大小/地址。
///
/// `None` = 这个版本里没有任何文件的 sha512 与请求值相同(站点数据不一致 / 版本被换过),
/// 或者对上的那条没有 sha1 可用。
#[must_use]
pub fn identity_of(version: &Version, sha512: &str) -> Option<Identity> {
let file = version.files.iter().find(|file| {
file.sha512()
.is_some_and(|hex| hex.eq_ignore_ascii_case(sha512))
})?;
let sha1 = file.sha1().map(str::to_string)?;
Some(Identity {
sha1,
size: (file.size > 0).then_some(file.size),
url: (!file.url.trim().is_empty()).then(|| file.url.clone()),
})
}
/// 补全出来的一份身份。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Identity {
/// 站点给的 SHA-1
pub sha1: String,
/// 站点给的字节数(`0` = 没给)
pub size: Option<u64>,
/// 站点给的直链(`None` = 也没给)
pub url: Option<String>,
}
#[cfg(test)]
mod tests {
use super::identity_of;
use crate::addon::online::model::{Version, VersionFile};
fn version(files: Vec<VersionFile>) -> Version {
Version {
id: "v1".to_string(),
project_id: "p1".to_string(),
files,
..Version::default()
}
}
fn file(sha1: Option<&str>, sha512: &str, primary: bool) -> VersionFile {
let mut file = VersionFile {
url: "https://cdn.modrinth.com/data/p1/v1/a.jar".to_string(),
filename: "a.jar".to_string(),
size: 2048,
primary,
..VersionFile::default()
};
file.hashes.insert("sha512".to_string(), sha512.to_string());
if let Some(sha1) = sha1 {
file.hashes.insert("sha1".to_string(), sha1.to_string());
}
file
}
#[test]
fn the_matching_file_wins_even_when_it_is_not_the_primary() {
let wanted = "a".repeat(128);
let other = "b".repeat(128);
let version = version(vec![
file(Some(&"1".repeat(40)), &other, true),
file(Some(&"2".repeat(40)), &wanted, false),
]);
let identity = identity_of(&version, &wanted).expect("摘要对得上,必须给出身份");
assert_eq!(identity.sha1, "2".repeat(40));
assert_eq!(identity.size, Some(2048));
assert!(identity.url.is_some());
}
#[test]
fn a_version_without_that_hash_yields_nothing() {
let version = version(vec![file(Some(&"1".repeat(40)), &"b".repeat(128), true)]);
assert_eq!(identity_of(&version, &"c".repeat(128)), None);
}
#[test]
fn a_matching_file_without_a_sha1_is_not_usable() {
// 站点只给了 sha512:这时没有可填的 sha1,宁可「补不出」也不许拿 sha512 冒充。
let wanted = "a".repeat(128);
let version = version(vec![file(None, &wanted, true)]);
assert_eq!(identity_of(&version, &wanted), None);
}
#[test]
fn hex_case_does_not_matter() {
let wanted = "AbCd".repeat(32);
let version = version(vec![file(
Some(&"1".repeat(40)),
&wanted.to_ascii_lowercase(),
true,
)]);
assert!(identity_of(&version, &wanted).is_some());
}
}
+330
View File
@@ -0,0 +1,330 @@
//! 依赖图谱的**形状**:一次解析要什么([`ResolveRequest`])、出什么([`DependencyPlan`])。
//!
//! 算法在 [`super::resolve`];本文件只放类型与不变量,于是「两档失败从类型上可分辨」这件事
//! 不必读算法也能核对。
//!
//! # 三份名单,一份都不许丢
//!
//! | 名单 | 含义 | 上层该做什么 |
//! |------|------|--------------|
//! | [`DependencyPlan::nodes`] | 要下的(BFS 顺序、父子关系齐全) | 逐个下载 |
//! | [`DependencyPlan::skipped`] | 目标目录里已经有同名文件 | 提示「已存在,跳过」 |
//! | [`DependencyPlan::failures`] | 装不了的,**每条自带 [`FailureKind`]** | 两档分开说;进可重试名单 |
//!
//! `truncated` 是第四样东西:撞到 [`DEFAULT_MAX_DEPTH`] 而没展开的项目。它既不是成功也不是
//! 失败(站点没拒绝,我们主动停),但**必须**露出来——静默截断会让用户看着「装好了」而缺前置。
//!
//! # 两档为什么不能合成一句
//!
//! 「所选版本/加载器没有匹配项」与「网络异常 / 项目不存在」要用户做的事完全不同:
//! 前者换版本或换加载器,后者检查网络再试。字符串错误只能靠 `contains("网络")` 猜,
//! 一改文案就失效;[`FailureKind`] 把判据放在类型上,叶子成因(404 / 连不上 / 5xx)
//! 另存在 [`DependencyFailure::cause`] 上,允许上层再细分而不用重新判。
use super::model::{VersionFile, VersionQuery};
/// 默认最大深度(根 = 0)。六层足够覆盖真实套娃(前置的前置的前置……),
/// 同时给环与异常链一个硬边界——撞上就记 [`DependencyPlan::truncated`],**不静默截断**。
pub const DEFAULT_MAX_DEPTH: u8 = 6;
/// 目标目录里已存在的文件(调用方扫描后给;core 不读盘)。
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct PresentFile {
/// 文件名(可以带路径,比对时会砍掉)
pub name: String,
}
impl PresentFile {
/// 记一个已存在的文件名。
#[must_use]
pub fn new(name: impl Into<String>) -> Self {
Self { name: name.into() }
}
/// 归一化后的比对键(见 [`present_key`])。
#[must_use]
pub fn key(&self) -> String {
present_key(&self.name)
}
}
/// 文件名 → 比对键:只留文件名、小写、去掉 `.disabled` 后缀。
///
/// 为什么带 `.disabled`:本仓用**路径后缀**表示禁用(`mods/x.jar.disabled`,见
/// `core::addon::export`),禁用中的 jar 也是「装过」,再下一份会和它并存。
#[must_use]
pub fn present_key(name: &str) -> String {
let base = name
.rsplit(['/', '\\'])
.next()
.unwrap_or(name)
.trim()
.to_ascii_lowercase();
base.strip_suffix(".disabled").unwrap_or(&base).to_string()
}
/// 一次解析请求。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolveRequest {
/// 起点(项目 id 或 slug);同层处理时按项目键字典序,与这里给的顺序无关
pub roots: Vec<String>,
/// 目标游戏版本(选版本用;`None` = 不限)
pub game_version: Option<String>,
/// 目标加载器(选版本用;`None` = 不限)
pub loader: Option<String>,
/// 最大深度(根 = 0,见 [`DEFAULT_MAX_DEPTH`])
pub max_depth: u8,
/// 目标目录里已存在的文件(盘上去重的依据;空 = 目录是空的)
pub present: Vec<PresentFile>,
}
impl Default for ResolveRequest {
fn default() -> Self {
Self {
roots: Vec::new(),
game_version: None,
loader: None,
max_depth: DEFAULT_MAX_DEPTH,
present: Vec::new(),
}
}
}
impl ResolveRequest {
/// 单根请求(内容库「装进实例」的入口形状)。
#[must_use]
pub fn single(project: impl Into<String>) -> Self {
Self {
roots: vec![project.into()],
..Self::default()
}
}
/// 这个项目的版本请求(过滤条件相同,只换项目)。
#[must_use]
pub fn version_query(&self, project: &str) -> VersionQuery {
VersionQuery {
project: project.to_string(),
game_version: self.game_version.clone(),
loader: self.loader.clone(),
}
}
}
/// 计划里的一条「要装什么」。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PlannedNode {
/// 规范项目 id(站点回的 `project_id`;空则用请求时的键)
pub project: String,
/// 当初请求它用的键(slug 或 id)——重试要用同一个
pub requested_as: String,
/// 版本 id
pub version_id: String,
/// 版本名
pub version_name: String,
/// 版本号
pub version_number: String,
/// 要下载的那个文件(`primary` 优先,见 `Version::primary_file`)
pub file: VersionFile,
/// 距根的层数(根 = 0)
pub depth: u8,
/// 谁要求装它(规范项目 id,排序去重;根为空 = 用户自己点的)
pub required_by: Vec<String>,
}
impl PlannedNode {
/// 是用户点的那个项目吗(根 = 层数 0)。
///
/// 判据是 `depth` 而不是 `required_by.is_empty()`:环里根也会被别人「要求」
/// (A ⇄ B 时 A 的 `required_by` 里有 B),但它在计划里仍是起点。
#[must_use]
pub fn is_root(&self) -> bool {
self.depth == 0
}
}
/// 为什么跳过。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SkipReason {
/// 目标目录里已经有同名文件(含 `.disabled`)
AlreadyPresent,
}
impl SkipReason {
/// 稳定字符串 id(日志 / DTO 用,**不要改名**)。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::AlreadyPresent => "already_present",
}
}
}
/// 一条被跳过的(本来要下载的)项目。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SkippedNode {
/// 规范项目 id
pub project: String,
/// 请求它用的键
pub requested_as: String,
/// 计划里的文件名(在目录里找到了同名的那个)
pub file_name: String,
/// 距根的层数
pub depth: u8,
/// 谁要求装它
pub required_by: Vec<String>,
/// 跳过原因
pub reason: SkipReason,
}
/// 失败的两档(**类型上就分开,不许拼成一句「下载失败」**)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FailureKind {
/// 站点有这个项目,但所选游戏版本 / 加载器下没有可装的条目
/// (版本列表为空,或选中的版本没有可下载的主文件)。
///
/// 用户该做的动作是**换版本或换加载器**;原样重试得到的还是这一档。
Incompatible,
/// 取不到:没联网 / 项目不存在 / 站点出错 / 响应坏。
///
/// 用户该做的动作是**检查网络后再试**;具体叶子成因保留在
/// [`DependencyFailure::cause`] 上(`NotFound` 与 `NotConnected` 不能混为一谈)。
Unreachable,
}
impl FailureKind {
/// 稳定字符串 id(日志 / 命令行输出用,**不要改名**)。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Incompatible => "incompatible",
Self::Unreachable => "unreachable",
}
}
}
/// 一条解析失败(进「可重试名单」的就是它们)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DependencyFailure {
/// 请求它用的键(重试也用它)
pub requested_as: String,
/// 当初因为谁而被要求(规范项目 id;根为空)
pub required_by: Vec<String>,
/// 距根的层数
pub depth: u8,
/// 哪一档
pub kind: FailureKind,
/// 叶子成因(只有 [`FailureKind::Unreachable`] 有)
pub cause: Option<super::transport::OnlineErrorKind>,
/// 人读说明(站点/引擎原话 + 上下文,不是界面文案)
pub message: String,
}
/// 声明了 `required`、但**只给了 `version_id`**(没有 `project_id`)的依赖。
///
/// BFS 需要项目 id 才能取版本(`/v2/project/{id}/version`),所以这条既装不了、也不该被
/// 假装没看见:留在计划里,上层才能说「有 N 条前置没法解析」而不是默默少装一个。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UnresolvedRequired {
/// 声明它的项目(规范 id)
pub parent: String,
/// 站点给的版本 id(`None` = 连版本 id 都没有,只有文件名)
pub version_id: Option<String>,
/// 站点给的文件名
pub file_name: Option<String>,
}
/// 一次 BFS 的完整结果(成功、跳过、失败三份名单,都**不丢**)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DependencyPlan {
/// 这次用的请求(重试要沿用同样的过滤条件与同一份 `present`)
pub request: ResolveRequest,
/// 要装的(BFS 顺序:层号小的在前,同层按项目键字典序)
pub nodes: Vec<PlannedNode>,
/// 目录里已经有了、不用再下的
pub skipped: Vec<SkippedNode>,
/// 装不了的(两档混在同一个列表里,但每条自带 [`FailureKind`])
pub failures: Vec<DependencyFailure>,
/// 撞到 `max_depth` 而没展开的项目键(排序去重)
pub truncated: Vec<String>,
/// 有 `required` 但没有项目 id、解析不了的前置(排序去重)
pub unresolved_required: Vec<UnresolvedRequired>,
}
impl DependencyPlan {
/// 空计划(`resolve` 从这里开始填)。
#[must_use]
pub fn new(request: &ResolveRequest) -> Self {
Self {
request: request.clone(),
nodes: Vec::new(),
skipped: Vec::new(),
failures: Vec::new(),
truncated: Vec::new(),
unresolved_required: Vec::new(),
}
}
/// 一条失败都没有吗(空循环也算干净)。
#[must_use]
pub fn is_clean(&self) -> bool {
self.failures.is_empty()
}
/// 某一档的失败(两档分开看时用)。
pub fn failures_of(&self, kind: FailureKind) -> impl Iterator<Item = &DependencyFailure> {
self.failures.iter().filter(move |item| item.kind == kind)
}
/// 记一条失败——**按 `requested_as` 去重**,父边合并。
///
/// 为什么要有这道去重:同一个项目可能被两条路径要求(A→GONE、B→C→GONE)。若不去重,
/// 「可只重试这 N 个」会写出 `GONE、GONE`,用户按名单重试等于发两次一样的请求。
/// 解析阶段与下载阶段的失败都走这里,于是名单里一个项目只出现一次。
pub fn record_failure(&mut self, failure: DependencyFailure) {
if let Some(existing) = self
.failures
.iter_mut()
.find(|item| item.requested_as == failure.requested_as)
{
existing.required_by.extend(failure.required_by);
existing.required_by.sort();
existing.required_by.dedup();
// 深度取更浅的那条:越靠近根越该先修
existing.depth = existing.depth.min(failure.depth);
return;
}
self.failures.push(failure);
}
/// 「可重试名单」:失败项原样重启一次解析的请求。
///
/// 只含失败项 ⇒ 用户只重试这一部分,**不重下**已经装好的文件(`present` 沿用)。
/// 两档都在名单里,但每条自带 [`FailureKind`]:不兼容那一档重试前得换版本/加载器,
/// 换完再调它才有意义。**这不是「后台自动重试」**——本模块只给请求,什么时候发由上层决定。
///
/// 调用方如果刚跑完一轮下载,重试前应**重新扫一遍目录**再填 `present`:这一轮新落盘的
/// 文件不在旧的 `present` 里,不重扫就会把它们当「还没有」再下一份。
#[must_use]
pub fn retry_request(&self) -> ResolveRequest {
let mut roots: Vec<String> = Vec::new();
for failure in &self.failures {
if !roots.contains(&failure.requested_as) {
roots.push(failure.requested_as.clone());
}
}
ResolveRequest {
roots,
..self.request.clone()
}
}
/// 这个项目为什么在计划里(规范 id → 要求装它的项目)。`None` = 不在计划里。
#[must_use]
pub fn required_by(&self, project: &str) -> Option<&[String]> {
self.nodes
.iter()
.find(|node| node.project == project)
.map(|node| node.required_by.as_slice())
}
}
+334
View File
@@ -0,0 +1,334 @@
//! 「取到下载地址」之后的最后一步:**下载 → 复核 sha1/sha512 → 才算数**。
//!
//! # 三条硬要求(任务书原文)
//!
//! 1. **走既有分块下载引擎与镜像策略**:生产实现 [`EngineArtifactFetcher`] 只把请求翻译成
//! [`DownloadRequest`](`ResourceKind::ModFile` ⇒ 引擎按大文件分块下载,镜像展开、地区模式、
//! 健康度、退避全在引擎里;Modrinth 的 CDN 不在 `mirrors.json` 的规则里,所以只有国内环境
//! 真的命中规则时才会换源,不会瞎改写);
//! 2. **校验 sha1/sha512 后再写入**:引擎只认 sha1(`Integrity` 只有 Sha1/Sha256 两档),
//! 所以 sha1 交给引擎流式校验、sha512 由本模块**落盘后复核**;任何一项不符都删掉目标文件
//! (`download` 模块的 `HashAlgo` 没有 Sha512 是事实,改它超出 M162 的 scope,已登记 finding);
//! 3. **失败可重试且不留半截文件**:每次尝试前先删目标文件,尝试次数 [`RETRY_ATTEMPTS`];
//! 半截文件本身由引擎的 `PartFile` 守卫(`<name>.ullpart-<pid>-<seq>` + 原子改名)兜住,
//! 本模块不另造临时目录范式。
//!
//! # 为什么摘要复核不信任 fetcher
//!
//! [`ArtifactFetcher`] 是注入点(测试喂假字节)。如果「校验」放在某个具体实现里,测试注入的
//! 实现就成了「不校验也能过」的后门——而生产实现与测试实现走的是同一段 [`download_verified`]。
//! 所以复核在**外层**:读一遍落盘文件,同时算 sha1 与 sha512,与声明逐字比对。
use std::io::Read;
use std::path::{Path, PathBuf};
use sha1::Digest as _;
use sha2::Sha512;
use crate::download::config::DEFAULT_MAX_ATTEMPTS_PER_SOURCE;
use crate::download::{DownloadEngine, DownloadRequest, Integrity, ResourceKind};
use super::modrinth::HashAlgorithm;
use super::transport::{OnlineError, OnlineErrorKind, OnlineFuture};
/// 本模块外层「整条下载」的预算(含首次):最多取几遍文件。
///
/// # 它**不是**引擎的每源尝试次数,所以不引用 `download::config` 的常量(M205 判定)
///
/// 数值恰好都是 3,但数的不是同一件事。三条判据,任一条都足够说明「合并成一处」会把语义拍平:
///
/// 1. **层级不同,而且是相乘**:引擎那层在 [`ArtifactFetcher`] 里面,按**源**数(镜像展开后每个候选
/// 各有一份预算,见 `DownloadEngine::attempts_for`;源在冷却期时还会缩成 1 次恢复探测)。本模块这
/// 层只数「取一遍 + 复核一遍」,每数一次都要重新走一遍引擎 —— 最坏情况下的真实请求数 ≈
/// [`RETRY_ATTEMPTS`] × 引擎每源预算,合并成一个数等于把最坏请求数变成平方级。
/// 2. **管的失败类型不同**:sha512 不符与字节数不符只有本模块知道(引擎只认 sha1,`Integrity` 没有
/// Sha512 档),这类失败引擎**不会**重试,必须由这一层重取 —— 这一层的预算里有一档是引擎看不见的。
/// 3. **改它的性质不同**:`download.retry_limit` 是用户可见的设置(0~10、进 JSON schema,也就是说引擎
/// 那个默认值会因为**用户的**配置而变);这里的 3 是内容库元数据请求的产品口径,没有设置键,也不该
/// 被某个设置项的出厂默认牵着走。
///
/// **边界**:真实请求数 = [`RETRY_ATTEMPTS`](外层)× 引擎每源预算(`download.retry_limit`,出厂
/// [`DEFAULT_MAX_ATTEMPTS_PER_SOURCE`])。**出厂档**是 3 × 3 = 9;用户把 `retry_limit` 调到 N 时是
/// 3 × N(schema 上限 `MAX_ATTEMPTS_PER_SOURCE` = 10 ⇒ 30)——**9 只是出厂档的数,不是事实上限**,
/// 这正是本仓反复吃亏的「把出厂值说成上限」。下面两条注解级断言(编译期求值)钉的也只是**出厂档**:
/// 出厂默认想改,就得先读到这段判据,而不是被一次「统一常量」的顺带改动悄悄翻倍。
pub const RETRY_ATTEMPTS: u32 = 3;
/// 外层预算被改了吗(改之前先读 [`RETRY_ATTEMPTS`] 的 doc:它不是引擎的每源次数)。
const _: () = assert!(
RETRY_ATTEMPTS == 3,
"外层下载预算(出厂档 3)变了:它不是 download::config 的每源尝试次数,改它要单独一个决定"
);
/// **出厂档**最坏请求数(外层 3 × 引擎每源出厂 3)——两个「3」不共享来源,乘积才是事实。
/// 用户把 `download.retry_limit` 调大后真实最坏是 3 × N,与本断言无关(那头是运行时配置,编译期钉不了)。
const _: () = assert!(
RETRY_ATTEMPTS as u64 * DEFAULT_MAX_ATTEMPTS_PER_SOURCE as u64 == 9,
"出厂档最坏真实请求数变了(外层 3 × 引擎每源出厂 3 = 9;用户把 retry_limit 调到 N 时是 3 × N):先读 doc"
);
/// 一次下载请求(只含「引擎需要什么」,不含任何站点字段)。
#[derive(Debug, Clone, Copy)]
pub struct FetchArgs<'a> {
/// 逻辑下载地址(官方源;镜像展开在引擎里)
pub url: &'a str,
/// 落盘路径(父目录由调用方保证,或由本模块建)
pub dest: &'a Path,
/// 期望 SHA-1(形状不对当「没给」,见 [`well_formed`])
pub sha1: Option<&'a str>,
/// 期望字节数
pub size: Option<u64>,
}
/// 「把 URL 下到 dest」的注入点(测试喂假字节)。
pub trait ArtifactFetcher: Send + Sync {
/// 下到 `dest`,返回落盘字节数。
fn fetch<'a>(&'a self, args: FetchArgs<'a>) -> OnlineFuture<'a, Result<u64, OnlineError>>;
}
/// 生产实现:交给 `core::download` 的引擎。
#[derive(Debug, Clone, Copy)]
pub struct EngineArtifactFetcher<'a> {
engine: &'a DownloadEngine,
}
impl<'a> EngineArtifactFetcher<'a> {
/// 绑定引擎。
#[must_use]
pub const fn new(engine: &'a DownloadEngine) -> Self {
Self { engine }
}
}
impl ArtifactFetcher for EngineArtifactFetcher<'_> {
fn fetch<'a>(&'a self, args: FetchArgs<'a>) -> OnlineFuture<'a, Result<u64, OnlineError>> {
Box::pin(async move {
let mut request =
DownloadRequest::new(ResourceKind::ModFile, args.url, args.dest.to_path_buf());
if let Some(sha1) = args.sha1.filter(|hex| HashAlgorithm::Sha1.accepts(hex)) {
request.integrity = Some(Integrity::sha1(sha1));
}
request.expected_size = args.size;
let outcome = self.engine.download(request).await?;
Ok(outcome.bytes)
})
}
}
/// 一个文件的摘要(读一遍盘同时算两个)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Digest {
/// SHA-1(小写十六进制)
pub sha1: String,
/// SHA-512(小写十六进制)
pub sha512: String,
/// 字节数
pub bytes: u64,
}
/// 一次成功下载的产物。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Downloaded {
/// 落盘路径
pub path: PathBuf,
/// 字节数
pub bytes: u64,
/// 落盘内容的摘要(**实测**,不是声明)
pub digest: Digest,
}
/// 下载并复核(sha1 **与** sha512,给了哪个就校验哪个)。
///
/// # Errors
///
/// - 声明的摘要是 `Some` 但形状不对 ⇒ [`OnlineErrorKind::Unsupported`],**不发请求**;
/// - 没有下载地址 ⇒ `Unsupported`;
/// - 重试用尽仍是网络/站点/摘要问题 ⇒ 最后一次的错误(且目标文件已被删掉);
/// - 本地 IO ⇒ `Io`。
pub async fn download_verified(
fetcher: &dyn ArtifactFetcher,
url: &str,
sha1: Option<&str>,
sha512: Option<&str>,
size: Option<u64>,
dest: &Path,
) -> Result<Downloaded, OnlineError> {
if url.trim().is_empty() {
return Err(OnlineError::unsupported("没有可用下载地址"));
}
// 两档摘要**各自**过形状检查:坏掉的那一档丢掉,用另一档校验(mrpack 的权威摘要是
// sha512,CLI/整合包都可能给出一个人写坏的 sha1——为此整条不装是过分惩罚)。
// 但如果**声明过**摘要却一档都用不上,就必须失败:那是「没有任何校验」的伪装。
let declared = sha1.is_some() || sha512.is_some();
let sha1 = usable_hash(sha1, HashAlgorithm::Sha1);
let sha512 = usable_hash(sha512, HashAlgorithm::Sha512);
if declared && sha1.is_none() && sha512.is_none() {
return Err(OnlineError::unsupported(
"声明的 sha1/sha512 都不是合法摘要,无法校验(拒绝在「没有校验」的情况下落盘)",
));
}
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent).map_err(|error| {
OnlineError::new(
OnlineErrorKind::Io,
format!("建目录 {} 失败:{error}", parent.display()),
)
})?;
}
let mut last: Option<OnlineError> = None;
for attempt in 0..RETRY_ATTEMPTS {
// 每次尝试前清掉目标:上一轮的半截/坏内容不许参与本轮判定。
remove_quiet(dest);
let fetched = fetcher
.fetch(FetchArgs {
url,
dest,
sha1,
size,
})
.await;
let bytes = match fetched {
Ok(bytes) => bytes,
Err(error) if retryable(error.kind()) && attempt + 1 < RETRY_ATTEMPTS => {
last = Some(error);
continue;
}
Err(error) => {
remove_quiet(dest);
return Err(error);
}
};
if let Some(expected) = size {
if expected != bytes {
last = Some(OnlineError::integrity(format!(
"字节数与声明不符:声明 {expected},实际 {bytes}"
)));
remove_quiet(dest);
continue;
}
}
let digest = match digest_file(dest) {
Ok(digest) => digest,
Err(error) => {
// 与其它失败路径一致:读不出来就不许把文件留在盘上。
remove_quiet(dest);
return Err(error);
}
};
if let Some(expected) = sha1 {
if !equals_hex(expected, &digest.sha1) {
last = Some(OnlineError::integrity(format!(
"SHA-1 与声明不符(声明 {expected},实际 {})",
digest.sha1
)));
remove_quiet(dest);
continue;
}
}
if let Some(expected) = sha512 {
if !equals_hex(expected, &digest.sha512) {
last = Some(OnlineError::integrity(format!(
"SHA-512 与声明不符(声明 {expected},实际 {})",
digest.sha512
)));
remove_quiet(dest);
continue;
}
}
return Ok(Downloaded {
path: dest.to_path_buf(),
bytes,
digest,
});
}
remove_quiet(dest);
Err(last.unwrap_or_else(|| {
OnlineError::new(
OnlineErrorKind::NotConnected,
"下载重试用尽(没有拿到任何字节)",
)
}))
}
/// 读一个文件同时算 sha1 与 sha512。
///
/// # Errors
/// 读盘失败返回 [`OnlineErrorKind::Io`]。
pub fn digest_file(path: &Path) -> Result<Digest, OnlineError> {
let mut file = std::fs::File::open(path).map_err(|error| {
OnlineError::new(
OnlineErrorKind::Io,
format!("读 {} 失败:{error}", path.display()),
)
})?;
let mut sha1 = sha1::Sha1::new();
let mut sha512 = Sha512::new();
let mut buffer = [0u8; 64 * 1024];
let mut bytes = 0u64;
loop {
let read = file.read(&mut buffer).map_err(|error| {
OnlineError::new(
OnlineErrorKind::Io,
format!("读 {} 失败:{error}", path.display()),
)
})?;
if read == 0 {
break;
}
bytes += read as u64;
sha1.update(&buffer[..read]);
sha512.update(&buffer[..read]);
}
Ok(Digest {
sha1: to_hex(&sha1.finalize()),
sha512: to_hex(&sha512.finalize()),
bytes,
})
}
/// 摘要形状合法吗(长度 + 十六进制)。
///
/// 这一档摘要能用吗:形状合法就原样返回(不复制、不归一,大小写不敏感比较交给
/// [`equals_hex`]),形状不合法就当「没给」。
///
/// 为什么丢掉而不是报错:`sha1` 与 `sha512` 是同一个文件的两档校验,**任一**都能证明内容;
/// 一档写坏不该让整条装不上(r1 顺手项)。两档都不可用时由 `download_verified` 统一拒绝。
fn usable_hash(raw: Option<&str>, algorithm: HashAlgorithm) -> Option<&str> {
raw.map(str::trim)
.filter(|hex| !hex.is_empty())
.filter(|hex| algorithm.accepts(hex))
}
/// 十六进制比较(大小写不敏感:声明侧可能大写)。
fn equals_hex(expected: &str, actual: &str) -> bool {
expected.len() == actual.len() && expected.eq_ignore_ascii_case(actual)
}
/// 这个错误值得再试一次吗(网络/站点/内容不符;**404 与自身不自洽一律不重试**)。
fn retryable(kind: OnlineErrorKind) -> bool {
matches!(
kind,
OnlineErrorKind::NotConnected | OnlineErrorKind::Server | OnlineErrorKind::Integrity
)
}
fn remove_quiet(path: &Path) {
if let Err(error) = std::fs::remove_file(path) {
if error.kind() != std::io::ErrorKind::NotFound {
eprintln!("[online] 删 {} 失败:{error}", path.display());
}
}
}
/// 小写十六进制(不引 `hex` 依赖;与 `download::verify::to_hex` 同一写法)。
#[must_use]
pub fn to_hex(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(bytes.len() * 2);
for byte in bytes {
out.push(char::from(HEX[usize::from(byte >> 4)]));
out.push(char::from(HEX[usize::from(byte & 0x0f)]));
}
out
}
+89
View File
@@ -0,0 +1,89 @@
//! 在线内容源(M162):**只做匿名可用的那半**——Modrinth v2。
//!
//! # 为什么形状是 Modrinth 的,而不是「内容源通用抽象」
//!
//! CurseForge 的检索/文件端点要 `x-api-key`(D10 §5 的客户端还没写),而它的 JSON 方言与
//! Modrinth 几乎没有一处同名(`project_id` vs `id`、`description` vs `summary`、
//! `downloads` vs `downloadCount`、版本列表还要再套一层 `latestFilesIndexes`)。为了让两者
//! 共用而发明一套谁都不像的抽象,代价是两边都得翻译两次、错一处两边都错。
//!
//! 所以本模块**按 Modrinth 的真实形状建模**(`/v2/search` 的 facets、`/v2/project/{id}`、
//! `/v2/project/{id}/version`、`/v2/version_file/{hash}`),把「平台差异」留到将来真要加第二个
//! 源时再抽。留的缝只有两处:
//!
//! | 缝 | 在哪 | 换源时要动什么 |
//! |----|------|----------------|
//! | [`Transport`] | 唯一的 HTTP 出口(trait) | 换/加一个实现;测试注入录制 JSON |
//! | [`Modrinth::new`] | `Modrinth<'a, T: Transport + ?Sized>` | 传 `&dyn Transport` 就能换站点客户端,一行业务代码不改 |
//!
//! # 网络层(零新依赖)
//!
//! `Cargo.lock` 里已有的 `reqwest` 0.12 + `tokio` + `serde_json` 就是全部。生产实现
//! [`EngineTransport`] 走 `DownloadEngine::download_bytes`,于是**信号量限流、源健康度、
//! 指数退避、错误分类**全由 `core::download` 提供,本模块不写第二个重试循环,也不持有第二个
//! `reqwest::Client`(与 `addon::import::fetch` 同一条纪律)。那个引擎是宿主侧的进程级共享实例
//! (M165:`download::shared::engine_for`),所以元数据请求与文件下载共用同一条连接池与限流。
//!
//! 没有 POST:Modrinth 的批量接口(`POST /v2/version_files`)需要 POST 请求体,而引擎的
//! `DownloadRequest` 没有请求体字段。补全路径因此按**单摘要 GET** 逐条查(循环在
//! `crate::addon::import::complete`,本模块只用 [`identity_of`] 认文件),
//! mrpack 里缺 `sha1` 的条目本来就是少数,不值得为它引入第二条 HTTP 路径。
//!
//! # 离线不是「空结果」
//!
//! 无网时上层必须能说清「未联网」而不是「没有匹配的内容」,所以错误是枚举不是字符串:
//! 引擎把叶子成因(404 / 5xx / 网络)放在 `DownloadError::leaf` 上,[`OnlineError`] 按它映射
//! 成 `NotFound` / `Server` / `NotConnected`(见 `transport::From<DownloadError>` 的映射表),
//! presenter 再翻成 DTO 的 `ContentFailureKind::{Offline,NotFound,Server}`。
//! 没有叶子时保守归到 `NotConnected`——宁可说「检查网络」,也不声称「没有匹配的内容」。
//!
//! # 模块
//!
//! | 文件 | 职责 |
//! |------|------|
//! | [`model`] | Modrinth 的响应形状 + 检索请求(facets / 排序 / 分页) |
//! | [`transport`] | HTTP 出口 trait + 生产实现(走下载引擎)+ 错误分类 |
//! | [`modrinth`] | 端点拼装与响应解析(纯逻辑,测试直接喂录制 JSON) |
//! | [`download`] | 取下载地址之后那一步:引擎下载 + **sha1/sha512 复核** + 重试 + 不留半截文件 |
//! | [`complete`] | 按摘要认文件([`identity_of`]):站点返回的版本里哪条才是我们要的那个 |
//! | [`dependency`] | 依赖图谱的形状(请求 / 计划 / **两档失败**) |
//! | [`resolve`] | BFS 解析(只跟 `required`):去重、按项目缓存、深度上限、确定性顺序 |
//!
//! # 一次「装一个模组」到底发几个请求
//!
//! M186 之前的实测口径(也是现在仍然成立的基座):`GET /v2/project/{id}/version` 一次
//! (带 `game_versions` / `loaders` 两个数组参数)+ 文件下载一次 = **2 个请求**,
//! 其中**没有任何**依赖解析——`dependencies` 字段根本没进模型,所以「装了模组但进游戏崩」
//! 这条路只能靠用户自己发现缺前置。
//!
//! M186 之后:根项目同样是 2 个请求(版本 + 下载),每个 `required` 前置再各加 1 次版本
//! 请求 + 1 次下载。与参考实现相比省掉了「每个前置再查一次 `/v2/project/{id}` 换 slug」,
//! 因为依赖里给的就是项目 id,而 `/project/{id}/version` 认 id。
pub mod complete;
pub mod dependency;
pub mod download;
pub mod model;
pub mod modrinth;
pub mod resolve;
pub mod transport;
pub use complete::identity_of;
pub use dependency::{
present_key, DependencyFailure, DependencyPlan, FailureKind, PlannedNode, PresentFile,
ResolveRequest, SkipReason, SkippedNode, UnresolvedRequired, DEFAULT_MAX_DEPTH,
};
pub use download::{
digest_file, download_verified, ArtifactFetcher, Digest, Downloaded, EngineArtifactFetcher,
FetchArgs, RETRY_ATTEMPTS,
};
pub use model::{
ContentType, Dependency, DependencyType, FileHashes, ProjectDetail, ProjectHit, SearchPage,
SearchQuery, SortIndex, Version, VersionFile, VersionQuery, VersionType,
};
pub use modrinth::{HashAlgorithm, Modrinth, MODRINTH_API};
pub use resolve::resolve;
pub use transport::{EngineTransport, OnlineError, OnlineErrorKind, OnlineFuture, Transport};
#[cfg(test)]
mod tests;
+37
View File
@@ -0,0 +1,37 @@
//! Modrinth v2 的响应形状与检索请求(**按站点原文建模,不翻译成「通用内容模型」**)。
//!
//! 每个公开类型都对着一个真实端点:
//!
//! | 类型 | 端点 |
//! |------|------|
//! | [`SearchQuery`] → [`SearchPage`] | `GET /v2/search` |
//! | [`ProjectDetail`] | `GET /v2/project/{id or slug}` |
//! | [`VersionQuery`] → [`Vec<Version>`] | `GET /v2/project/{id}/version` |
//! | [`Version`] | `GET /v2/version_file/{hash}?algorithm=sha1\|sha512` |
//!
//! # 解析纪律
//!
//! 站点会加字段、也会给 `""` 表示「没有」——所以:
//!
//! - 一律 `#[serde(default)]`,**没给就是空的**,缺字段不炸整页;
//! - 只把**形状**当硬要求(`hits` 必须是数组、project 必须是对象),形状不对就是
//! [`super::OnlineErrorKind::Malformed`],不静默给空页冒充「没结果」;
//! - 未知字段不保留(这里不是导入导出的无损通道,是只读检索)。
//!
//! # 为什么拆成三个文件
//!
//! 一个 600+ 行的 `model.rs` 已经越过本仓的 400 行硬线,而且三种东西混在一起时最容易看漏
//! 「站点字段名与 DTO 字段名不是一回事」:请求形状([`query`])、响应形状([`types`])、
//! 以及把前者读成后者的解析([`parse`])。拆开后 `parse` 里每一处 `#[serde(rename)]`/
//! `alias` 都能单独读。
mod parse;
mod query;
mod types;
pub use parse::{known_loaders, parse_project, parse_search, parse_version, parse_versions};
pub use query::{ContentType, SearchQuery, SortIndex, KNOWN_LOADERS};
pub use types::{
Dependency, DependencyType, FileHashes, ProjectDetail, ProjectHit, SearchPage, Version,
VersionFile, VersionQuery, VersionType,
};
+289
View File
@@ -0,0 +1,289 @@
//! 站点的原始 JSON → 上面那些类型(**唯一**读字段名的地方)。
//!
//! 每个 `#[serde(default)]` 都在说「这个字段站点可能不给」;`alias` 只在有历史原因时用
//! (例如检索命中的日期字段曾按 `updated` 读,站点实际叫 `date_modified`)。
use std::collections::BTreeMap;
use serde::Deserialize;
use super::query::KNOWN_LOADERS;
use super::types::{
Dependency, DependencyType, ProjectDetail, ProjectHit, SearchPage, Version, VersionFile,
VersionType,
};
use crate::addon::online::transport::{OnlineError, OnlineErrorKind};
// --------------------------------------------------------------- 解析
#[derive(Debug, Default, Deserialize)]
struct RawSearch {
#[serde(default)]
hits: Vec<RawHit>,
#[serde(default)]
total_hits: u64,
#[serde(default)]
offset: u32,
#[serde(default)]
limit: u32,
}
#[derive(Debug, Default, Deserialize)]
struct RawHit {
#[serde(default)]
project_id: String,
#[serde(default)]
slug: String,
#[serde(default)]
title: String,
#[serde(default)]
description: String,
#[serde(default)]
author: String,
#[serde(default)]
downloads: u64,
#[serde(default)]
follows: u64,
// 站点的字段名是 `date_modified`(旧实现的 `updated` 永远取不到值,别再加回去当主名)。
#[serde(default, alias = "updated")]
date_modified: String,
#[serde(default)]
versions: Vec<String>,
#[serde(default)]
categories: Vec<String>,
#[serde(default)]
project_type: String,
#[serde(default)]
icon_url: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
struct RawProject {
#[serde(default)]
id: String,
#[serde(default)]
slug: String,
#[serde(default)]
title: String,
#[serde(default)]
description: String,
#[serde(default)]
body: String,
#[serde(default)]
project_type: String,
#[serde(default)]
downloads: u64,
#[serde(default)]
followers: u64,
#[serde(default)]
updated: String,
#[serde(default)]
published: String,
#[serde(default)]
game_versions: Vec<String>,
#[serde(default)]
loaders: Vec<String>,
#[serde(default)]
categories: Vec<String>,
#[serde(default)]
icon_url: Option<String>,
#[serde(default)]
source_url: Option<String>,
#[serde(default)]
issues_url: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
struct RawVersion {
#[serde(default)]
id: String,
#[serde(default)]
project_id: String,
#[serde(default)]
name: String,
#[serde(default)]
version_number: String,
#[serde(default)]
version_type: String,
#[serde(default)]
loaders: Vec<String>,
#[serde(default)]
game_versions: Vec<String>,
#[serde(default)]
date_published: String,
#[serde(default)]
downloads: u64,
#[serde(default)]
files: Vec<RawFile>,
#[serde(default)]
dependencies: Vec<RawDependency>,
}
#[derive(Debug, Default, Deserialize)]
struct RawDependency {
#[serde(default)]
project_id: Option<String>,
#[serde(default)]
version_id: Option<String>,
#[serde(default)]
file_name: Option<String>,
// 站点给的是 `required` / `optional` / `incompatible` / `embedded`。
// 必须是 `Option<String>`:`#[serde(default)]` 只兜「字段缺失」,兜不住 `null`
// (`"dependency_type": null` 用 `String` 会整份响应报 Malformed,于是整个项目都取不到版本)。
#[serde(default)]
dependency_type: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
struct RawFile {
#[serde(default)]
hashes: BTreeMap<String, String>,
#[serde(default)]
url: String,
#[serde(default)]
filename: String,
#[serde(default)]
size: u64,
#[serde(default)]
primary: bool,
}
/// 解析 `/v2/search`。
///
/// # Errors
/// JSON 不合法、或没有 `hits` 数组时返回 [`OnlineErrorKind::Malformed`]。
pub fn parse_search(body: &str, offset: u32, limit: u32) -> Result<SearchPage, OnlineError> {
let raw: RawSearch = parse_json(body, "search")?;
Ok(SearchPage {
hits: raw.hits.into_iter().map(hit_of).collect(),
total_hits: raw.total_hits,
// 站点会回显 offset/limit;缺省用请求里的(分页游标不能靠回显才成立)
offset: raw.offset.max(offset),
limit: if raw.limit == 0 { limit } else { raw.limit },
})
}
/// 解析 `/v2/project/{id}`。
///
/// # Errors
/// JSON 不合法时返回 [`OnlineErrorKind::Malformed`]。
pub fn parse_project(body: &str) -> Result<ProjectDetail, OnlineError> {
let raw: RawProject = parse_json(body, "project")?;
Ok(ProjectDetail {
id: raw.id,
slug: raw.slug,
title: raw.title,
description: raw.description,
body: raw.body,
project_type: raw.project_type,
downloads: raw.downloads,
follows: raw.followers,
updated: raw.updated,
published: raw.published,
game_versions: raw.game_versions,
loaders: raw.loaders,
categories: raw.categories,
icon_url: non_empty(raw.icon_url),
source_url: non_empty(raw.source_url),
issues_url: non_empty(raw.issues_url),
})
}
/// 解析 `/v2/project/{id}/version`(一个数组)。
///
/// # Errors
/// JSON 不合法或不是数组时返回 [`OnlineErrorKind::Malformed`]。
pub fn parse_versions(body: &str) -> Result<Vec<Version>, OnlineError> {
let raw: Vec<RawVersion> =
serde_json::from_str(body).map_err(|error| malformed("versions", error))?;
Ok(raw.into_iter().map(version_of).collect())
}
/// 解析 `/v2/version_file/{hash}`(单个版本对象)。
///
/// # Errors
/// JSON 不合法时返回 [`OnlineErrorKind::Malformed`]。
pub fn parse_version(body: &str) -> Result<Version, OnlineError> {
let raw: RawVersion = parse_json(body, "version_file")?;
Ok(version_of(raw))
}
fn parse_json<T: for<'de> Deserialize<'de>>(body: &str, what: &str) -> Result<T, OnlineError> {
serde_json::from_str(body).map_err(|error| malformed(what, error))
}
fn malformed(what: &str, error: serde_json::Error) -> OnlineError {
OnlineError::new(
OnlineErrorKind::Malformed,
format!("Modrinth {what} 响应不是期望形状:{error}"),
)
}
fn hit_of(raw: RawHit) -> ProjectHit {
ProjectHit {
project_id: raw.project_id,
slug: raw.slug,
title: raw.title,
description: raw.description,
author: raw.author,
downloads: raw.downloads,
follows: raw.follows,
updated: raw.date_modified,
loaders: known_loaders(&raw.categories),
game_versions: raw.versions,
project_type: raw.project_type,
icon_url: non_empty(raw.icon_url),
}
}
fn version_of(raw: RawVersion) -> Version {
Version {
id: raw.id,
project_id: raw.project_id,
name: raw.name,
version_number: raw.version_number,
version_type: VersionType::parse(&raw.version_type),
loaders: raw.loaders,
game_versions: raw.game_versions,
date_published: raw.date_published,
downloads: raw.downloads,
files: raw
.files
.into_iter()
.map(|file| VersionFile {
hashes: file.hashes,
url: file.url,
filename: file.filename,
size: file.size,
primary: file.primary,
})
.collect(),
dependencies: raw
.dependencies
.into_iter()
.map(|dependency| Dependency {
project_id: non_empty(dependency.project_id),
version_id: non_empty(dependency.version_id),
file_name: non_empty(dependency.file_name),
dependency_type: DependencyType::parse(
dependency.dependency_type.as_deref().unwrap_or_default(),
),
})
.collect(),
}
}
/// 只留已知加载器(`categories` 里混着分类标签)。
#[must_use]
pub fn known_loaders(categories: &[String]) -> Vec<String> {
categories
.iter()
.filter(|tag| KNOWN_LOADERS.contains(&tag.as_str()))
.cloned()
.collect()
}
/// 空串当「没有」(站点用 `""` 表示没图 / 没链接)。
fn non_empty(value: Option<String>) -> Option<String> {
value.filter(|item| !item.trim().is_empty())
}
+165
View File
@@ -0,0 +1,165 @@
//! 检索请求的形状(facet / 排序 / 分页)——站点要什么就写什么,不预先「通用化」。
//!
//! 两个容易踩的点:
//!
//! 1. `facets` 是**二维数组的 JSON 文本**(外层「与」、内层「或」),要再整体百分号编码;
//! 2. `categories` 里混着分类标签(`optimization`),只有 [`KNOWN_LOADERS`] 那几个才是加载器。
/// Modrinth 的 `project_type` 取值(检索 facet 与项目详情的同名字段)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ContentType {
/// 整合包
Modpack,
/// Mod
Mod,
/// 资源包
Resourcepack,
/// 光影
Shader,
/// 服务端插件
Plugin,
/// 数据包
Datapack,
}
impl ContentType {
/// 站点原文(`project_type` facet 的值)。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Modpack => "modpack",
Self::Mod => "mod",
Self::Resourcepack => "resourcepack",
Self::Shader => "shader",
Self::Plugin => "plugin",
Self::Datapack => "datapack",
}
}
/// 从站点原文解析;认不出返回 `None`(调用方决定当「未知」还是报错)。
#[must_use]
pub fn parse(raw: &str) -> Option<Self> {
Some(match raw {
"modpack" => Self::Modpack,
"mod" => Self::Mod,
"resourcepack" => Self::Resourcepack,
"shader" => Self::Shader,
"plugin" => Self::Plugin,
"datapack" => Self::Datapack,
_ => return None,
})
}
}
/// `/v2/search` 的 `index` 参数。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SortIndex {
/// 相关度(站点默认)
#[default]
Relevance,
/// 下载量
Downloads,
/// 关注数
Follows,
/// 最新发布
Newest,
/// 最近更新
Updated,
}
impl SortIndex {
/// 站点原文。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Relevance => "relevance",
Self::Downloads => "downloads",
Self::Follows => "follows",
Self::Newest => "newest",
Self::Updated => "updated",
}
}
}
/// 站点认的加载器 facet 值(`categories` 里混着 `optimization` 这种**分类标签**,
/// 直接整份当加载器会画出假加载器)。
///
/// 后两项是**光影**的加载器(Modrinth 把 shader 的 `iris` / `optifine` 也放在 `categories`
/// 里):不登记它们,光影检索结果的 `loaders` 就会恒为空,页面既画不出「支持 Iris」,
/// 也用不上「只看 Iris」这种判断。
pub const KNOWN_LOADERS: [&str; 8] = [
"fabric",
"forge",
"neoforge",
"quilt",
"liteloader",
"rift",
"iris",
"optifine",
];
/// 一次检索请求(分页游标也在里面:换页就是换一份 query)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SearchQuery {
/// 关键词(空 = 不带 `query` 参数,即「按排序浏览」)
pub text: String,
/// 内容类型过滤
pub project_type: Option<ContentType>,
/// 游戏版本过滤(精确值,如 `1.20.1`)
pub game_version: Option<String>,
/// 加载器过滤(站点 facet 值,如 `fabric`)
pub loader: Option<String>,
/// 排序
pub index: SortIndex,
/// 每页条数(站点上限 100,构造时钳住)
pub limit: u32,
/// 偏移
pub offset: u32,
}
impl Default for SearchQuery {
fn default() -> Self {
Self {
text: String::new(),
project_type: None,
game_version: None,
loader: None,
index: SortIndex::Relevance,
limit: 20,
offset: 0,
}
}
}
impl SearchQuery {
/// 站点上限(超过会被站点拒,钳住更省一次往返)。
pub const MAX_LIMIT: u32 = 100;
/// 钳过界的分页参数。
#[must_use]
pub fn normalized(&self) -> Self {
let mut query = self.clone();
query.text = query.text.trim().to_string();
query.limit = query.limit.clamp(1, Self::MAX_LIMIT);
query
}
/// `facets` 参数(站点要的是**二维数组的 JSON**,每一项是一组「或」)。
///
/// 形状:`[["project_type:mod"],["versions:1.20.1"],["categories:fabric"]]`——不同组之间是
/// 「与」,同组内是「或」。这里每组只有一项。
#[must_use]
pub fn facets(&self) -> String {
let mut groups: Vec<Vec<String>> = Vec::new();
if let Some(kind) = self.project_type {
groups.push(vec![format!("project_type:{}", kind.as_str())]);
}
if let Some(version) = self.game_version.as_deref().filter(|v| !v.is_empty()) {
groups.push(vec![format!("versions:{version}")]);
}
if let Some(loader) = self.loader.as_deref().filter(|v| !v.is_empty()) {
groups.push(vec![format!("categories:{loader}")]);
}
serde_json::to_string(&groups).unwrap_or_else(|_| "[]".to_string())
}
}
+333
View File
@@ -0,0 +1,333 @@
//! 响应形状(`/v2/search`、`/v2/project`、`/v2/project/{id}/version`、`/v2/version_file`)。
use std::collections::BTreeMap;
/// 一页检索结果。
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct SearchPage {
/// 命中项(顺序 = 站点顺序)
pub hits: Vec<ProjectHit>,
/// 站点给的总数(**估算值**:站点文档不保证精确)
pub total_hits: u64,
/// 请求的偏移
pub offset: u32,
/// 请求的每页条数
pub limit: u32,
}
impl SearchPage {
/// 还有下一页吗。
///
/// 判据是「本页装满了」,不是 `offset + len < total`:`total_hits` 是估算值,
/// 用「装满」最多多请求一次空页,不会出现「明明还有却翻不到」。
#[must_use]
pub fn has_more(&self) -> bool {
self.limit > 0 && self.hits.len() as u32 >= self.limit
}
}
/// 一条检索命中(`/v2/search` 的 `hits[]`)。
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ProjectHit {
/// 项目 id(base62)
pub project_id: String,
/// 人读 slug
pub slug: String,
/// 标题
pub title: String,
/// 简介
pub description: String,
/// 作者(站点给的是团队名;没有时为空)
pub author: String,
/// 下载量
pub downloads: u64,
/// 关注数
pub follows: u64,
/// 最近更新(站点原文 ISO-8601)
pub updated: String,
/// 支持的加载器(已按 [`KNOWN_LOADERS`] 过滤)
pub loaders: Vec<String>,
/// 支持的游戏版本
pub game_versions: Vec<String>,
/// 项目类型原文
pub project_type: String,
/// 图标 URL(`None` = 没有)
pub icon_url: Option<String>,
}
impl ProjectHit {
/// 支持某个加载器吗(空列表 = 站点没给,按「未知」处理 ⇒ 不拦)。
#[must_use]
pub fn supports_loader(&self, loader: &str) -> bool {
self.loaders.is_empty() || self.loaders.iter().any(|item| item == loader)
}
/// 支持某个游戏版本吗(空列表 = 未知 ⇒ 不拦)。
#[must_use]
pub fn supports_version(&self, version: &str) -> bool {
self.game_versions.is_empty() || self.game_versions.iter().any(|item| item == version)
}
}
/// 项目详情(`/v2/project/{id or slug}`)。
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ProjectDetail {
/// 项目 id
pub id: String,
/// slug
pub slug: String,
/// 标题
pub title: String,
/// 简介
pub description: String,
/// 长文(markdown 原文;站点可能给 `""`)
pub body: String,
/// 项目类型原文
pub project_type: String,
/// 下载量
pub downloads: u64,
/// 关注数
pub follows: u64,
/// 最近更新
pub updated: String,
/// 首次发布
pub published: String,
/// 游戏版本
pub game_versions: Vec<String>,
/// 加载器
pub loaders: Vec<String>,
/// 分类标签
pub categories: Vec<String>,
/// 图标
pub icon_url: Option<String>,
/// 项目主页
pub source_url: Option<String>,
/// 问题追踪
pub issues_url: Option<String>,
}
/// 版本类型。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum VersionType {
/// 正式版
#[default]
Release,
/// 测试版
Beta,
/// 早期预览
Alpha,
}
impl VersionType {
/// 站点原文。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Release => "release",
Self::Beta => "beta",
Self::Alpha => "alpha",
}
}
/// 从站点原文解析;认不出按正式版(**不**拿它做安装判据)。
#[must_use]
pub fn parse(raw: &str) -> Self {
match raw {
"beta" => Self::Beta,
"alpha" => Self::Alpha,
_ => Self::Release,
}
}
}
/// 前置依赖的类型(`/version` 的 `dependencies[].dependency_type`)。
///
/// # 为什么要留 `Unknown` 而不是「认不出就当可选」
///
/// 站点以后加新取值时,**只有 `Required` 才该触发递归**。认不出的值落进 `Unknown`,
/// 于是「站点改名」的下场是「少装一个前置」(可观察、可补),而不是「把所有建议项都当必备项
/// 递归拉一遍」(一次装模组变成几十个请求,还装了一堆不兼容的东西)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum DependencyType {
/// 必须装(BFS **只跟这一档**)
Required,
/// 可选(装不装都能跑)
#[default]
Optional,
/// 明确不兼容(**不要**和它一起装)
Incompatible,
/// 已内嵌在被依赖的文件里(不用另外装)
Embedded,
/// 站点给了个我们不认识的取值
Unknown,
}
impl DependencyType {
/// 站点原文。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Required => "required",
Self::Optional => "optional",
Self::Incompatible => "incompatible",
Self::Embedded => "embedded",
Self::Unknown => "unknown",
}
}
/// 从站点原文解析;认不出(含空串)返回 [`Self::Unknown`]。
#[must_use]
pub fn parse(raw: &str) -> Self {
match raw.trim() {
"required" => Self::Required,
"optional" => Self::Optional,
"incompatible" => Self::Incompatible,
"embedded" => Self::Embedded,
_ => Self::Unknown,
}
}
}
/// 版本声明的一条前置(`dependencies[]`)。
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Dependency {
/// 依赖的**项目** id(BFS 的地址;有了它就不用像参考实现那样再查一次 slug)
pub project_id: Option<String>,
/// 依赖的版本 id(站点基本只对 `embedded` 给)
pub version_id: Option<String>,
/// 站点给的文件名(只对 `embedded` / `incompatible` 有意义)
pub file_name: Option<String>,
/// 依赖类型
pub dependency_type: DependencyType,
}
/// 一个版本(`/v2/project/{id}/version[]` 与 `/v2/version_file/{hash}` 同一形状)。
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Version {
/// 版本 id
pub id: String,
/// 所属项目 id
pub project_id: String,
/// 版本名
pub name: String,
/// 版本号(`1.2.3+fabric`)
pub version_number: String,
/// 版本类型
pub version_type: VersionType,
/// 支持的加载器
pub loaders: Vec<String>,
/// 支持的游戏版本
pub game_versions: Vec<String>,
/// 发布日期
pub date_published: String,
/// 该版本总下载量
pub downloads: u64,
/// 文件列表
pub files: Vec<VersionFile>,
/// 前置声明(站点原文顺序;空 = 站点没说 / 没有)
pub dependencies: Vec<Dependency>,
}
impl Version {
/// 拿这个文件:优先 `primary: true`(Modrinth 官方口径),否则第一个。
///
/// 不能无脑取第一个:很多项目同时发 `sources.jar` / `-dev.jar`,取第一个会装错东西。
#[must_use]
pub fn primary_file(&self) -> Option<&VersionFile> {
self.files
.iter()
.find(|file| file.primary)
.or_else(|| self.files.first())
}
/// 要跟下去的**前置项目 id**:只取 `required`,去掉空值,**排序去重**。
///
/// 排序在这里做(不是让调用方排):BFS 的展开顺序必须是数据本身决定的,
/// 否则站点把 `dependencies` 换个顺序、或者用 `HashMap` 收一遍,测试就会随机红。
#[must_use]
pub fn required_project_ids(&self) -> Vec<&str> {
let mut ids: Vec<&str> = self
.dependencies
.iter()
.filter(|dependency| dependency.dependency_type == DependencyType::Required)
.filter_map(|dependency| dependency.project_id.as_deref())
.map(str::trim)
.filter(|id| !id.is_empty())
.collect();
ids.sort_unstable();
ids.dedup();
ids
}
/// 声明了 `required`、却**没有项目 id** 的前置(站点只给了版本 id / 文件名)。
///
/// 这些条目 BFS 跟不下去(取版本要项目 id),但也不能当成「没有这条依赖」——
/// 上层据此说「有 N 条前置没法解析」,见 `DependencyPlan::unresolved_required`。
#[must_use]
pub fn unresolved_required(&self) -> Vec<&Dependency> {
self.dependencies
.iter()
.filter(|dependency| dependency.dependency_type == DependencyType::Required)
.filter(|dependency| {
dependency
.project_id
.as_deref()
.map(str::trim)
.map(str::is_empty)
.unwrap_or(true)
})
.collect()
}
}
/// 版本里的一个文件。
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct VersionFile {
/// 摘要表(键名小写:`sha1` / `sha512` / `md5`;站点给几个就有几个)
pub hashes: BTreeMap<String, String>,
/// 下载地址
pub url: String,
/// 文件名
pub filename: String,
/// 字节数
pub size: u64,
/// 是否主文件
pub primary: bool,
}
impl VersionFile {
/// 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)
}
}
/// 摘要表(给需要「按算法取」的调用方用;与 [`VersionFile::hashes`] 同源)。
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct FileHashes(pub BTreeMap<String, String>);
impl FileHashes {
/// 按算法取。
#[must_use]
pub fn get(&self, algorithm: &str) -> Option<&str> {
self.0.get(algorithm).map(String::as_str)
}
}
/// 版本列表请求(`/v2/project/{id}/version` 的过滤条件)。
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct VersionQuery {
/// 项目 id 或 slug
pub project: String,
/// 游戏版本过滤(单个精确值)
pub game_version: Option<String>,
/// 加载器过滤(站点 facet 值)
pub loader: Option<String>,
}
+231
View File
@@ -0,0 +1,231 @@
//! Modrinth v2 的四个端点(**只有一个站点,所以不做「源分派」那一层**)。
//!
//! | 方法 | 端点 | 拿什么 |
//! |------|------|--------|
//! | [`Modrinth::search`] | `GET /v2/search` | 一页命中项(分页游标在 [`SearchQuery`] 里) |
//! | [`Modrinth::project`] | `GET /v2/project/{id or slug}` | 项目详情(含长文与链接) |
//! | [`Modrinth::versions`] | `GET /v2/project/{id}/version` | 版本列表(可按版本/加载器过滤) |
//! | [`Modrinth::version_by_hash`] | `GET /v2/version_file/{hash}` | 某个文件的所属版本(**补全路径用的那条**) |
//!
//! # 端点拼装与解析都分离出来
//!
//! `*_url` 是纯函数(单测直接比字符串),`search/project/...` 只做「取文本 → 解析」。
//! 于是录制 JSON 的离线夹具不需要任何 HTTP 桩就能覆盖全部分支。
//!
//! # 为什么按摘要查是 GET
//!
//! Modrinth 的批量接口是 `POST /v2/version_files`(请求体是一个 JSON 对象)。引擎的
//! `DownloadRequest` 没有请求体字段,为它单开一条直发 `client()` 的路径就得自己写退避与限流
//! (presenter 里为 CF 的 header 已经付过这笔账)。补全路径一次只看一两个摘要,逐条 GET 更划算。
//!
//! # 百分号编码
//!
//! 只为 query 串写一个最小实现(`A-Za-z0-9-._~` 不编码),**不引第三方 crate**:
//! `facets` 与 `game_versions` 都是 JSON 文本,方括号引号逗号全要编码,否则站点会当成
//! 空过滤条件静默忽略——那会让「筛选条说 Fabric、结果里有 Forge」这种谎话成立。
use super::model::{
parse_project, parse_search, parse_version, parse_versions, ProjectDetail, SearchPage,
SearchQuery, Version, VersionQuery,
};
use super::transport::{OnlineError, Transport};
/// Modrinth API 的根(匿名可用,无需 key)。
pub const MODRINTH_API: &str = "https://api.modrinth.com/v2";
/// 按哪个摘要查文件。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum HashAlgorithm {
/// SHA-1(mrpack 的 `files[].hashes.sha1`)
#[default]
Sha1,
/// SHA-512(mrpack 的必填摘要)
Sha512,
}
impl HashAlgorithm {
/// 站点 `algorithm` 参数值。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Sha1 => "sha1",
Self::Sha512 => "sha512",
}
}
/// 十六进制长度(格式自检用;长度不对就别发请求)。
#[must_use]
pub const fn hex_len(self) -> usize {
match self {
Self::Sha1 => 40,
Self::Sha512 => 128,
}
}
/// 摘要是否**形状**合法(小写归一 + 十六进制 + 长度)。
#[must_use]
pub fn accepts(self, hex: &str) -> bool {
hex.len() == self.hex_len() && hex.bytes().all(|byte| byte.is_ascii_hexdigit())
}
}
/// Modrinth 客户端(只持有 [`Transport`],不持有客户端/连接池)。
#[derive(Debug, Clone, Copy)]
pub struct Modrinth<'a, T: Transport + ?Sized> {
transport: &'a T,
}
impl<'a, T: Transport + ?Sized> Modrinth<'a, T> {
/// 绑定传输层。
#[must_use]
pub const fn new(transport: &'a T) -> Self {
Self { transport }
}
/// 检索的 URL(纯函数,单测直接钉)。
#[must_use]
pub fn search_url(&self, query: &SearchQuery) -> String {
let query = query.normalized();
let mut url = format!(
"{MODRINTH_API}/search?limit={}&offset={}&index={}",
query.limit,
query.offset,
query.index.as_str()
);
let facets = query.facets();
if facets != "[]" {
url.push_str(&format!("&facets={}", encode(&facets)));
}
if !query.text.is_empty() {
url.push_str(&format!("&query={}", encode(&query.text)));
}
url
}
/// 项目详情的 URL。
#[must_use]
pub fn project_url(&self, id_or_slug: &str) -> String {
format!("{MODRINTH_API}/project/{}", encode(id_or_slug))
}
/// 版本列表的 URL。
#[must_use]
pub fn versions_url(&self, query: &VersionQuery) -> String {
let mut url = format!("{MODRINTH_API}/project/{}/version", encode(&query.project));
let mut first = true;
if let Some(version) = query
.game_version
.as_deref()
.filter(|item| !item.is_empty())
{
url.push_str(&format!(
"{}game_versions={}",
separator(&mut first),
encode(&format!("[\"{version}\"]"))
));
}
if let Some(loader) = query.loader.as_deref().filter(|item| !item.is_empty()) {
url.push_str(&format!(
"{}loaders={}",
separator(&mut first),
encode(&format!("[\"{loader}\"]"))
));
}
url
}
/// 按文件摘要查所属版本的 URL。
#[must_use]
pub fn version_file_url(&self, algorithm: HashAlgorithm, hex: &str) -> String {
format!(
"{MODRINTH_API}/version_file/{}?algorithm={}",
encode(&hex.to_ascii_lowercase()),
algorithm.as_str()
)
}
/// 检索一页。
///
/// # Errors
/// 传输层失败(见 [`OnlineErrorKind`])或响应形状不符。
pub async fn search(&self, query: &SearchQuery) -> Result<SearchPage, OnlineError> {
let normalized = query.normalized();
let url = self.search_url(&normalized);
let body = self.transport.get_text(&url).await?;
let mut page = parse_search(&body, normalized.offset, normalized.limit)?;
// 站点没回显时用请求值补齐,分页游标才不会因为缺字段而卡死。
if page.limit == 0 {
page.limit = normalized.limit;
}
Ok(page)
}
/// 项目详情。
///
/// # Errors
/// 传输层失败或响应形状不符。
pub async fn project(&self, id_or_slug: &str) -> Result<ProjectDetail, OnlineError> {
let url = self.project_url(id_or_slug);
let body = self.transport.get_text(&url).await?;
parse_project(&body)
}
/// 版本列表(站点的顺序 = 最新在前)。
///
/// # Errors
/// 传输层失败或响应形状不符。
pub async fn versions(&self, query: &VersionQuery) -> Result<Vec<Version>, OnlineError> {
let url = self.versions_url(query);
let body = self.transport.get_text(&url).await?;
parse_versions(&body)
}
/// 按文件摘要查所属版本。
///
/// # Errors
/// 摘要形状不对时**不发请求**([`OnlineErrorKind::Unsupported`]);站点 404 是
/// [`OnlineErrorKind::NotFound`](= 这个摘要不在 Modrinth 上,补全要如实记「补不出」)。
pub async fn version_by_hash(
&self,
algorithm: HashAlgorithm,
hex: &str,
) -> Result<Version, OnlineError> {
let hex = hex.trim();
if !algorithm.accepts(hex) {
return Err(OnlineError::unsupported(format!(
"摘要不是合法的 {}:长度 {},要求 {}",
algorithm.as_str(),
hex.len(),
algorithm.hex_len()
)));
}
let url = self.version_file_url(algorithm, hex);
let body = self.transport.get_text(&url).await?;
parse_version(&body)
}
}
/// 第一个参数用 `?`,后续用 `&`。
fn separator(first: &mut bool) -> &'static str {
if *first {
*first = false;
"?"
} else {
"&"
}
}
/// 最小百分号编码(query 串用;`A-Za-z0-9-._~` 原样,其余按字节编码)。
#[must_use]
pub 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
}
+303
View File
@@ -0,0 +1,303 @@
//! BFS 依赖解析(**只跟 `required`**)。形状在 [`super::dependency`],这里只有算法。
//!
//! # 与参考实现(Python 版 Modrinth 下载器)的四处差别
//!
//! | 参考实现 | 这里 | 为什么 |
//! |---|---|---|
//! | 每个前置再 `GET /v2/project/{id}` 换 slug | **不查**,直接拿 `dependencies[].project_id` 当地址 | 依赖里给的就是项目 id,`/project/{id}/version` 认它;一次安装省掉每个前置一个请求 |
//! | 失败名单是字符串,靠括号里的中文区分「不兼容」与「网络异常」 | [`FailureKind`] 枚举 | 「哪一档」要能从类型上判,不能靠 `contains("网络")` |
//! | 一个 dict 兼做缓存与去重 | 键 = **规范项目 id**,别名单设一张表 | 同一项目可能一次以 slug、一次以 id 出现;只按请求串去重会把它装两遍 |
//! | 队列按 `dependencies` 原文顺序展开 | 每层按项目键**字典序**处理 | 顺序必须是数据决定的,否则测试靠 HashMap 运气 |
//!
//! # 失败是**数据**,不是 `Err`
//!
//! [`resolve`] 永远返回一个 [`DependencyPlan`]:某个前置取不到版本时,它进 `failures`,
//! 解析继续做别的分支。若中途 `return Err`,用户看到的是「装这个模组失败」,而真正该看到的
//! 是「主文件装上了,缺 fabric-api(不兼容 1.20.1/fabric)」+一份可单独重试的名单。
//!
//! # 两张去重网
//!
//! 1. **图内**:同一项目只解一次版本(`nodes` 里每个项目最多一条),多条依赖路径折进
//! [`PlannedNode::required_by`];
//! 2. **盘上**:目标目录里已经有同名文件(`.disabled` 也算)就进 `skipped`、**不再下载**。
//! 这是「已装过的同一项目跳过并提示」里我们真能判的那一半;另一半(同项目**另一个版本**
//! 已装)需要实例侧的已装清单,本模块判不出来(见报告)。
//!
//! 盘上判定只比文件名(`present` 由调用方扫目录得到,core 不读盘):内容级去重需要先算一份
//! sha1,为了「跳过」把整个 `mods/` 哈希一遍不划算;同名文件就是同一份 jar 的最常见形态
//! (我们落盘用站点给的文件名,不重命名)。
use std::collections::{BTreeMap, BTreeSet};
use super::dependency::{
present_key, DependencyFailure, DependencyPlan, FailureKind, PlannedNode, PresentFile,
ResolveRequest, SkipReason, SkippedNode, UnresolvedRequired,
};
use super::model::Version;
use super::modrinth::Modrinth;
use super::transport::{OnlineErrorKind, Transport};
/// 一层里还没处理的一项。
struct Wave {
key: String,
depth: u8,
parents: Vec<String>,
}
/// BFS 解析。**不返回 `Err`**:失败进 [`DependencyPlan::failures`](见模块文档)。
pub async fn resolve<T: Transport + ?Sized>(
client: &Modrinth<'_, T>,
request: &ResolveRequest,
) -> DependencyPlan {
let mut plan = DependencyPlan::new(request);
let present: BTreeSet<String> = request.present.iter().map(PresentFile::key).collect();
// 请求键 → 规范 id(同一项目可能一次给 slug、一次给 id)
let mut alias: BTreeMap<String, String> = BTreeMap::new();
let mut resolved: BTreeSet<String> = BTreeSet::new();
let mut layer: Vec<Wave> = Vec::new();
for root in &request.roots {
let key = root.trim();
if !key.is_empty() {
wave_in(&mut layer, key, 0, None);
}
}
while !layer.is_empty() {
// 同层按项目键字典序:顺序由数据决定,不靠插入顺序或 HashMap 的运气。
layer.sort_by(|left, right| left.key.cmp(&right.key));
let mut next: Vec<Wave> = Vec::new();
for mut item in layer.drain(..) {
normalize(&mut item.parents);
// ① 已经解过(含「A 依赖 B、B 又依赖 A」的环)⇒ 只把父边折进已有节点
if let Some(canonical) = alias.get(&item.key).cloned() {
fold_parents(&mut plan, &canonical, &item.parents);
continue;
}
let version = match client.versions(&request.version_query(&item.key)).await {
Ok(versions) => match versions.into_iter().next() {
Some(version) => version,
None => {
let message = filter_message(request);
fail(
&mut plan,
&mut alias,
item,
FailureKind::Incompatible,
None,
message,
);
continue;
}
},
Err(error) => {
let message = format!(
"取版本失败({}):{}",
error.kind().as_str(),
error.message()
);
fail(
&mut plan,
&mut alias,
item,
FailureKind::Unreachable,
Some(error.kind()),
message,
);
continue;
}
};
let Some(file) = version
.primary_file()
.filter(|file| !file.url.trim().is_empty())
.cloned()
else {
let message = format!(
"{} 的版本 {} 里没有可下载的主文件",
item.key,
version_label(&version)
);
fail(
&mut plan,
&mut alias,
item,
FailureKind::Incompatible,
None,
message,
);
continue;
};
let canonical = canonical_of(&item.key, &version);
alias.insert(item.key.clone(), canonical.clone());
alias
.entry(canonical.clone())
.or_insert_with(|| canonical.clone());
// ② 同一项目走了两条路(slug 与 id 各一次)⇒ 折边,不重复装
if !resolved.insert(canonical.clone()) {
fold_parents(&mut plan, &canonical, &item.parents);
continue;
}
let file_name = file.filename.clone();
if present.contains(&present_key(&file_name)) {
plan.skipped.push(SkippedNode {
project: canonical.clone(),
requested_as: item.key.clone(),
file_name,
depth: item.depth,
required_by: item.parents.clone(),
reason: SkipReason::AlreadyPresent,
});
} else {
plan.nodes.push(PlannedNode {
project: canonical.clone(),
requested_as: item.key.clone(),
version_id: version.id.clone(),
version_name: version.name.clone(),
version_number: version.version_number.clone(),
file,
depth: item.depth,
required_by: item.parents.clone(),
});
}
// 子项:只跟 required(排序去重由 `required_project_ids` 保证)
for dependency in version.required_project_ids() {
if item.depth >= request.max_depth {
plan.truncated.push(dependency.to_string());
} else {
wave_in(&mut next, dependency, item.depth + 1, Some(&canonical));
}
}
// 只有版本 id、没有项目 id 的 required:没有地址可取,记下来不假装没看见
for dependency in version.unresolved_required() {
plan.unresolved_required.push(UnresolvedRequired {
parent: canonical.clone(),
version_id: dependency.version_id.clone(),
file_name: dependency.file_name.clone(),
});
}
}
plan.truncated.sort();
plan.truncated.dedup();
layer = next;
}
plan.unresolved_required.sort_by(|left, right| {
(&left.parent, &left.version_id).cmp(&(&right.parent, &right.version_id))
});
plan.unresolved_required.dedup();
plan
}
/// 记一条失败,并把这个请求键标成「已处理」。
///
/// 标键这一步是**失败侧的去重网**:成功路径靠 `alias` 短路,失败路径以前直接 `continue`,
/// 于是同一个项目被两条路径要求时会各取一次版本、各记一条失败,重试名单里出现两次。
/// 标记之后,后续同键请求在①处短路,走现成的 [`fold_parents`] 把父边折进那条失败。
///
/// # 这道去重按**请求键**,不是规范 id(已知边界,别当成全覆盖)
///
/// 规范 id(`project_id`)要拿到响应才知道;失败时没有响应,能用的只有请求键。所以同一个
/// 失败项目如果**先以 slug、后以 project_id** 被两个不同的父项目要求,仍会被取两次、名单里
/// 出现两个名字。真实载荷里 `dependencies[].project_id` 与用户点的 slug 混用的情况没有证据,
/// 且这一档本来就是「取不到」,重复一条的代价是名单多一行、不是多装一个东西——故本轮不消掉,
/// 只把边界写在这里(复审 r2 nit③)。
fn fail(
plan: &mut DependencyPlan,
alias: &mut BTreeMap<String, String>,
item: Wave,
kind: FailureKind,
cause: Option<OnlineErrorKind>,
message: String,
) {
plan.record_failure(DependencyFailure {
requested_as: item.key.clone(),
required_by: item.parents,
depth: item.depth,
kind,
cause,
message,
});
alias.insert(item.key.clone(), item.key);
}
/// 把一项放进这一层的待处理表(同一个键已经排队 ⇒ 只追加父项目)。
fn wave_in(layer: &mut Vec<Wave>, key: &str, depth: u8, parent: Option<&str>) {
if let Some(existing) = layer.iter_mut().find(|item| item.key == key) {
if let Some(parent) = parent {
existing.parents.push(parent.to_string());
}
return;
}
layer.push(Wave {
key: key.to_string(),
depth,
parents: parent
.map(|parent| vec![parent.to_string()])
.unwrap_or_default(),
});
}
/// 往已经建好的节点上折一条父边(节点可能在被跳过的那一档,也可能只出现在失败名单里)。
fn fold_parents(plan: &mut DependencyPlan, project: &str, parents: &[String]) {
if parents.is_empty() {
return;
}
let node = plan
.nodes
.iter_mut()
.find(|node| node.project == project)
.map(|node| &mut node.required_by);
let node = node.or_else(|| {
plan.skipped
.iter_mut()
.find(|node| node.project == project)
.map(|node| &mut node.required_by)
});
if let Some(required_by) = node {
required_by.extend(parents.iter().cloned());
normalize(required_by);
return;
}
if let Some(failure) = plan
.failures
.iter_mut()
.find(|failure| failure.requested_as == project)
{
failure.required_by.extend(parents.iter().cloned());
normalize(&mut failure.required_by);
}
}
fn normalize(list: &mut Vec<String>) {
list.sort();
list.dedup();
}
/// 规范身份:站点回的 `project_id` 才是身份;没给就退回请求键。
fn canonical_of(key: &str, version: &Version) -> String {
let project_id = version.project_id.trim();
if project_id.is_empty() {
key.to_string()
} else {
project_id.to_string()
}
}
/// 「哪里没有匹配项」这句话(第一档的固定说法,带出用的是什么过滤条件)。
fn filter_message(request: &ResolveRequest) -> String {
let game_version = request.game_version.as_deref().unwrap_or("不限");
let loader = request.loader.as_deref().unwrap_or("不限");
format!("没有匹配所选游戏版本/加载器({game_version} / {loader})的版本")
}
fn version_label(version: &Version) -> String {
let number = version.version_number.trim();
if number.is_empty() {
version.id.clone()
} else {
number.to_string()
}
}
+205
View File
@@ -0,0 +1,205 @@
//! 端点拼装 + 响应解析 + 离线分类(纯离线)。
use crate::addon::online::modrinth::{encode, HashAlgorithm, MODRINTH_API};
use crate::addon::online::{
ContentType, Modrinth, OnlineErrorKind, SearchQuery, SortIndex, VersionQuery,
};
use crate::download::blocking::block_on;
use super::fixtures;
use super::support::FixtureTransport;
fn query(text: &str) -> SearchQuery {
SearchQuery {
text: text.to_string(),
project_type: Some(ContentType::Mod),
game_version: Some("1.20.1".to_string()),
loader: Some("fabric".to_string()),
index: SortIndex::Downloads,
limit: 2,
offset: 0,
}
}
#[test]
fn search_url_carries_facets_and_query_in_the_sites_own_shape() {
let transport = FixtureTransport::new();
let client = Modrinth::new(&transport);
let url = client.search_url(&query("sodium edge"));
assert!(url.starts_with(&format!(
"{MODRINTH_API}/search?limit=2&offset=0&index=downloads"
)));
// 站点要的是二维数组的 JSON,再整体百分号编码
let facets = encode(&query("").facets());
assert!(url.contains(&format!("&facets={facets}")), "url={url}");
assert!(facets.contains("%5B%5B%22project_type%3Amod%22%5D"));
assert!(url.ends_with("&query=sodium%20edge"));
}
#[test]
fn empty_text_omits_the_query_parameter() {
let transport = FixtureTransport::new();
let client = Modrinth::new(&transport);
let url = client.search_url(&SearchQuery::default());
assert!(!url.contains("&query="), "空关键词不该带 query:{url}");
assert!(
!url.contains("&facets="),
"没有过滤条件不该带 facets:{url}"
);
}
#[test]
fn search_parses_hits_and_filters_fake_loaders() {
let transport =
FixtureTransport::new().with(&format!("{MODRINTH_API}/search"), fixtures::SEARCH);
let client = Modrinth::new(&transport);
let page = block_on(client.search(&query("sodium"))).expect("解析应当成功");
assert_eq!(page.total_hits, 1234);
assert_eq!(page.hits.len(), 2);
let sodium = &page.hits[0];
assert_eq!(sodium.project_id, "AANobbMI");
assert_eq!(sodium.title, "Sodium");
// `optimization` 不是加载器:只留 fabric / quilt
assert_eq!(
sodium.loaders,
vec!["fabric".to_string(), "quilt".to_string()]
);
assert!(sodium.supports_version("1.20.1"));
assert!(!sodium.supports_version("1.19.2"));
// 日期读的是 `date_modified`(旧实现读 `updated`,永远是空串)
assert_eq!(sodium.updated, "2026-02-03T10:11:12Z");
// 空 icon_url 当「没有」
assert_eq!(page.hits[1].icon_url, None);
// limit=2 且回了 2 条 ⇒ 本页装满 ⇒ 还有下一页(判据不是 total_hits)
assert!(page.has_more());
}
#[test]
fn has_more_is_decided_by_a_full_page() {
let mut page = crate::addon::online::SearchPage {
limit: 2,
hits: vec![Default::default(), Default::default()],
..Default::default()
};
assert!(page.has_more());
page.hits.pop();
assert!(!page.has_more());
}
#[test]
fn project_detail_parses_body_links_and_loader_list() {
let transport =
FixtureTransport::new().with(&format!("{MODRINTH_API}/project/sodium"), fixtures::PROJECT);
let client = Modrinth::new(&transport);
let project = block_on(client.project("sodium")).expect("解析应当成功");
assert_eq!(project.id, "AANobbMI");
assert_eq!(project.follows, 9912);
assert!(project.body.contains("modern rendering engine"));
assert_eq!(
project.loaders,
vec!["fabric".to_string(), "quilt".to_string()]
);
assert_eq!(
project.source_url.as_deref(),
Some("https://github.com/CaffeineMC/sodium")
);
}
#[test]
fn version_list_prefers_the_primary_file() {
let transport = FixtureTransport::new().with(
&format!("{MODRINTH_API}/project/sodium/version"),
fixtures::VERSIONS,
);
let client = Modrinth::new(&transport);
let versions = block_on(client.versions(&VersionQuery {
project: "sodium".to_string(),
game_version: Some("1.21.1".to_string()),
loader: Some("fabric".to_string()),
}))
.expect("解析应当成功");
assert_eq!(versions.len(), 1);
let file = versions[0].primary_file().expect("必须有文件");
// 取第一个会拿到 sources.jar:必须是 primary 的那个
assert_eq!(file.filename, "sodium-fabric-0.6.0.jar");
assert_eq!(file.size, 2048);
// 版本列表请求带了两个数组参数(不是被静默丢掉的空过滤)
let seen = transport.seen();
assert!(
seen[0].contains("game_versions=%5B%221.21.1%22%5D"),
"{}",
seen[0]
);
assert!(
seen[0].contains("loaders=%5B%22fabric%22%5D"),
"{}",
seen[0]
);
}
#[test]
fn a_hash_shape_mismatch_never_hits_the_network() {
let transport = FixtureTransport::new();
let client = Modrinth::new(&transport);
let error = block_on(client.version_by_hash(HashAlgorithm::Sha512, "not-a-hash"))
.expect_err("形状不对必须报错");
assert_eq!(error.kind(), OnlineErrorKind::Unsupported);
assert!(
transport.seen().is_empty(),
"不该发请求:{:?}",
transport.seen()
);
}
#[test]
fn offline_is_reported_as_not_connected_not_as_an_empty_page() {
let transport = FixtureTransport::new().offline(&format!("{MODRINTH_API}/search"));
let client = Modrinth::new(&transport);
let error = block_on(client.search(&query("sodium"))).expect_err("没网必须报错");
assert_eq!(error.kind(), OnlineErrorKind::NotConnected);
assert!(error.is_offline());
}
#[test]
fn a_broken_payload_is_malformed_not_empty() {
let transport =
FixtureTransport::new().with(&format!("{MODRINTH_API}/search"), "{\"hits\": 3}");
let client = Modrinth::new(&transport);
let error = block_on(client.search(&query("sodium"))).expect_err("形状不对必须报错");
assert_eq!(error.kind(), OnlineErrorKind::Malformed);
}
// ------------------------------------------------ 按摘要反查(补全路径的那两个端点行为)
#[test]
fn version_file_lookup_parses_the_recorded_payload() {
let transport = FixtureTransport::new().with(
&format!("{MODRINTH_API}/version_file/"),
fixtures::VERSION_FILE_A,
);
let client = Modrinth::new(&transport);
let version = block_on(client.version_by_hash(HashAlgorithm::Sha512, fixtures::LOOKUP_SHA512))
.expect("录制响应应当解析成功");
assert_eq!(version.id, "ver-a");
assert_eq!(version.game_versions, vec!["1.20.1".to_string()]);
// 身份判定只认「sha512 逐字相同」的那条:同版本里的 sources.jar 不许被当成它
let identity = crate::addon::online::complete::identity_of(&version, fixtures::LOOKUP_SHA512)
.expect("摘要对得上就该给出身份");
assert_eq!(identity.sha1, fixtures::LOOKUP_SHA1);
assert_eq!(identity.size, Some(4096));
let other = crate::addon::online::complete::identity_of(&version, fixtures::OTHER_SHA512)
.expect("另一个文件也认得出来");
assert_ne!(other.sha1, fixtures::LOOKUP_SHA1, "两档摘要不许串");
}
#[test]
fn a_hash_the_source_does_not_know_is_not_found_and_not_offline() {
// 真实 mrpack 大多托管在 CurseForge CDN:Modrinth 对这个 sha512 必然 404。
let transport = FixtureTransport::new().missing(&format!("{MODRINTH_API}/version_file/"));
let client = Modrinth::new(&transport);
let error = block_on(client.version_by_hash(HashAlgorithm::Sha512, &"a1".repeat(64)))
.expect_err("站点不认得就该报错");
assert_eq!(error.kind(), OnlineErrorKind::NotFound);
assert!(!error.is_offline(), "404 不是「没联网」");
}
@@ -0,0 +1,89 @@
//! 引擎错误 → 在线内容源错误分类(r1 P1-1 的守卫)。
//!
//! 这一族的价值全在「经过真的 `From<DownloadError>`」:直接给 `OnlineError` 造一个
//! `NotFound` 什么也证明不了——本轮修的是**引擎把叶子成因折叠掉**之后上层的映射,
//! 所以输入必须是引擎真会产出的那种错误(`AllSourcesFailed` + `leaf`)。
use crate::addon::online::{OnlineError, OnlineErrorKind};
use crate::download::{DownloadError, DownloadErrorKind};
#[test]
fn an_all_sources_failed_with_a_404_leaf_is_not_found() {
// 引擎在「所有候选源都回 404」时的真实形状(`download::bytes` 的收尾)。
let error = DownloadError::new(
DownloadErrorKind::AllSourcesFailed,
"所有候选源都取不到文本资源:https://api.modrinth.com/v2/version_file/...",
)
.with_suppressed(vec!["official: HTTP 404".to_string()])
.with_leaf(DownloadErrorKind::NotFound);
let online: OnlineError = error.into();
assert_eq!(
online.kind(),
OnlineErrorKind::NotFound,
"项目不存在 / id 拼错必须报 NotFound,不能画成「未联网」"
);
assert!(!online.is_offline());
}
#[test]
fn an_all_sources_failed_without_a_leaf_stays_conservatively_offline() {
let online: OnlineError =
DownloadError::new(DownloadErrorKind::AllSourcesFailed, "全部候选源都失败").into();
assert_eq!(online.kind(), OnlineErrorKind::NotConnected);
assert!(online.is_offline());
}
#[test]
fn leaf_kinds_map_one_to_one() {
let cases = [
(DownloadErrorKind::NotFound, OnlineErrorKind::NotFound),
(DownloadErrorKind::ServerError, OnlineErrorKind::Server),
(DownloadErrorKind::Timeout, OnlineErrorKind::NotConnected),
(DownloadErrorKind::Network, OnlineErrorKind::NotConnected),
(DownloadErrorKind::Integrity, OnlineErrorKind::Integrity),
(DownloadErrorKind::Io, OnlineErrorKind::Io),
(
DownloadErrorKind::InvalidRequest,
OnlineErrorKind::Unsupported,
),
(
DownloadErrorKind::RangeUnsupported,
OnlineErrorKind::Malformed,
),
];
for (leaf, expected) in cases {
let error =
DownloadError::new(DownloadErrorKind::AllSourcesFailed, "全部失败").with_leaf(leaf);
let online: OnlineError = error.into();
assert_eq!(online.kind(), expected, "叶子 {} 映射错了", leaf.as_str());
}
}
#[test]
fn a_single_source_failure_uses_its_own_kind() {
// 单源直失败(没有 AllSourcesFailed 这一层):不该看 leaf,本来就没有。
let online: OnlineError = DownloadError::new(DownloadErrorKind::NotFound, "HTTP 404").into();
assert_eq!(online.kind(), OnlineErrorKind::NotFound);
}
#[test]
fn a_404_leaf_is_not_treated_as_offline_by_the_importer_too() {
// 导入侧(补全)用的是另一套分类:这里只钉「404 与没联网不同类」。
use crate::addon::import::FetchErrorKind;
let not_found: crate::addon::import::FetchError =
DownloadError::new(DownloadErrorKind::AllSourcesFailed, "全部失败")
.with_leaf(DownloadErrorKind::NotFound)
.into();
assert_eq!(not_found.kind(), FetchErrorKind::NotFound);
assert!(not_found.kind().is_tolerable());
let offline: crate::addon::import::FetchError =
DownloadError::new(DownloadErrorKind::AllSourcesFailed, "全部失败")
.with_leaf(DownloadErrorKind::Network)
.into();
assert_eq!(offline.kind(), FetchErrorKind::Offline);
assert!(
offline.kind().is_tolerable(),
"没联网仍然是「这条文件搞不到」,实例照装"
);
}
@@ -0,0 +1,306 @@
//! 依赖解析的离线用例(录制 JSON 夹具,**不打真实网络**)。
//!
//! 三条守门对应任务书的三条:
//! 1. [`two_nested_levels_are_pulled_in_a_predictable_order`] —— 嵌套两层能拉全,顺序确定;
//! 2. [`a_dependency_cycle_terminates_and_each_project_appears_once`] —— 环不无限递归;
//! 3. [`an_incompatible_dependency_lands_in_the_retry_list`] —— 不兼容进重试名单,不静默丢。
//!
//! 每条都配过一次「把实现改坏就该红」的变异(过程写在 M186 报告里),所以断言不是装饰。
use crate::addon::online::dependency::{
present_key, DependencyPlan, FailureKind, PresentFile, ResolveRequest, SkipReason,
};
use crate::addon::online::model::parse_versions;
use crate::addon::online::{resolve, Modrinth, OnlineErrorKind, MODRINTH_API};
use crate::download::blocking::block_on;
use super::fixtures_deps as deps;
use super::support::FixtureTransport;
const SODIUM_VERSIONS: &str = "https://api.modrinth.com/v2/project/sodium/version";
const FABRIC_API_VERSIONS: &str = "https://api.modrinth.com/v2/project/P7dR8mSH/version";
const YACL_VERSIONS: &str = "https://api.modrinth.com/v2/project/1eAoo2KR/version";
fn request(root: &str) -> ResolveRequest {
ResolveRequest {
roots: vec![root.to_string()],
game_version: Some("1.20.1".to_string()),
loader: Some("fabric".to_string()),
..ResolveRequest::default()
}
}
/// 三层链的传输层(sodium → fabric-api → yacl)。
fn chain_transport() -> FixtureTransport {
FixtureTransport::new()
.with(SODIUM_VERSIONS, deps::SODIUM_WITH_DEPENDENCIES)
.with(FABRIC_API_VERSIONS, deps::FABRIC_API_WITH_DEPENDENCIES)
.with(YACL_VERSIONS, deps::YACL)
}
fn resolve_with(transport: &FixtureTransport, request: &ResolveRequest) -> DependencyPlan {
let client = Modrinth::new(transport);
block_on(resolve(&client, request))
}
// ---------------------------------------------------------------- ① 嵌套两层
#[test]
fn two_nested_levels_are_pulled_in_a_predictable_order() {
let transport = chain_transport();
let plan = resolve_with(&transport, &request("sodium"));
assert!(plan.is_clean(), "不该有失败:{:?}", plan.failures);
// 顺序 = BFS 层序,同层按项目键字典序:根 → fabric-api → yacl
let order: Vec<(&str, u8)> = plan
.nodes
.iter()
.map(|node| (node.project.as_str(), node.depth))
.collect();
assert_eq!(
order,
vec![("AANobbMI", 0), ("P7dR8mSH", 1), ("1eAoo2KR", 2)]
);
// 「为什么装」:父子关系逐条对得上
assert!(plan.required_by("AANobbMI").expect("根在计划里").is_empty());
assert_eq!(
plan.required_by("P7dR8mSH").expect("fabric-api 在计划里"),
["AANobbMI".to_string()].as_slice()
);
assert_eq!(
plan.required_by("1eAoo2KR").expect("yacl 在计划里"),
["P7dR8mSH".to_string()].as_slice()
);
// 只跟 required:optional / incompatible / embedded 三个 id 一次请求都没发
let seen = transport.seen();
assert_eq!(seen.len(), 3, "只该有 3 次版本请求:{seen:?}");
for quiet in ["OCS3z7f6", "AAAABBBB", "embedded-lib"] {
assert!(
!seen.iter().any(|url| url.contains(quiet)),
"{quiet} 不该被跟:{seen:?}"
);
}
// 主文件取 primary(第一个是 sources.jar)
assert_eq!(plan.nodes[0].file.filename, "sodium-fabric-0.6.0.jar");
assert_eq!(plan.nodes[1].file.filename, "fabric-api-0.92.2+1.20.1.jar");
}
#[test]
fn only_required_dependencies_are_followed() {
// 模型层:四值都解析得出来,`required_project_ids` 只吐 required 那一条。
let versions = parse_versions(deps::SODIUM_WITH_DEPENDENCIES).expect("夹具该解析成功");
let version = &versions[0];
assert_eq!(version.dependencies.len(), 4);
let kinds: Vec<&str> = version
.dependencies
.iter()
.map(|dependency| dependency.dependency_type.as_str())
.collect();
assert_eq!(kinds, ["required", "optional", "incompatible", "embedded"]);
assert_eq!(version.required_project_ids(), ["P7dR8mSH"]);
// embedded 那条只有 file_name、没有 project_id,不该冒出一个空 id
assert!(version.dependencies[3].project_id.is_none());
}
#[test]
fn the_depth_limit_is_reported_instead_of_silently_cutting_the_chain() {
let transport = chain_transport();
let mut request = request("sodium");
request.max_depth = 1;
let plan = resolve_with(&transport, &request);
assert_eq!(plan.nodes.len(), 2, "根 + 第一层");
assert_eq!(plan.truncated, ["1eAoo2KR"], "截断必须写在计划里");
}
// ---------------------------------------------------------------- ② 环
#[test]
fn a_dependency_cycle_terminates_and_each_project_appears_once() {
let transport = FixtureTransport::new()
.with(
"https://api.modrinth.com/v2/project/cycle-a/version",
deps::CYCLE_A,
)
.with(
"https://api.modrinth.com/v2/project/cycBid01/version",
deps::CYCLE_B,
);
let plan = resolve_with(&transport, &request("cycle-a"));
let projects: Vec<&str> = plan
.nodes
.iter()
.map(|node| node.project.as_str())
.collect();
assert_eq!(projects, ["cycAid01", "cycBid01"], "每个项目只出现一次");
assert_eq!(transport.seen().len(), 2, "环不许把请求数放大");
// B 是被 A 要来的;A 又被 B 要(环)——这条边折回已有节点,不新建节点
assert_eq!(
plan.required_by("cycBid01").expect("B 在计划里"),
["cycAid01".to_string()].as_slice()
);
assert_eq!(
plan.required_by("cycAid01").expect("A 在计划里"),
["cycBid01".to_string()].as_slice()
);
assert!(plan.nodes[0].is_root(), "根判据是 depth,不是 required_by");
assert!(!plan.nodes[1].is_root());
}
// ---------------------------------------------------------------- ③ 两档失败
#[test]
fn an_incompatible_dependency_lands_in_the_retry_list() {
// fabric-api 在 1.20.1/fabric 下没有条目(站点回空数组)
let transport = FixtureTransport::new()
.with(SODIUM_VERSIONS, deps::SODIUM_WITH_DEPENDENCIES)
.with(FABRIC_API_VERSIONS, "[]");
let plan = resolve_with(&transport, &request("sodium"));
// 根还在计划里(失败不连坐),缺的那条单独立案
assert_eq!(plan.nodes.len(), 1);
assert_eq!(plan.failures.len(), 1, "不许静默丢:{:?}", plan.failures);
let failure = &plan.failures[0];
assert_eq!(failure.kind, FailureKind::Incompatible);
assert_eq!(failure.requested_as, "P7dR8mSH");
assert_eq!(failure.depth, 1);
assert_eq!(failure.required_by, ["AANobbMI".to_string()]);
assert!(failure.cause.is_none(), "这一档不是网络成因");
assert!(failure.message.contains("1.20.1"), "文案要带过滤条件");
assert!(plan.failures_of(FailureKind::Unreachable).next().is_none());
// 可重试名单只含失败项,过滤条件沿用
let retry = plan.retry_request();
assert_eq!(retry.roots, ["P7dR8mSH"]);
assert_eq!(retry.game_version.as_deref(), Some("1.20.1"));
assert_eq!(retry.loader.as_deref(), Some("fabric"));
}
#[test]
fn a_missing_project_is_unreachable_and_keeps_its_leaf_cause() {
let transport = FixtureTransport::new()
.with(SODIUM_VERSIONS, deps::SODIUM_WITH_DEPENDENCIES)
.missing(FABRIC_API_VERSIONS);
let plan = resolve_with(&transport, &request("sodium"));
let failure = &plan.failures[0];
assert_eq!(failure.kind, FailureKind::Unreachable);
assert_eq!(failure.cause, Some(OnlineErrorKind::NotFound));
// 两档从类型上就分得开:同一个计划里两种成因不会挤进一条
assert_eq!(plan.failures_of(FailureKind::Incompatible).count(), 0);
}
#[test]
fn a_version_without_a_usable_file_is_incompatible_not_a_network_error() {
let transport = FixtureTransport::new()
.with(SODIUM_VERSIONS, deps::SODIUM_WITH_DEPENDENCIES)
.with(
FABRIC_API_VERSIONS,
r#"[{"id": "ver-empty", "project_id": "P7dR8mSH",
"version_number": "0.0", "files": []}]"#,
);
let plan = resolve_with(&transport, &request("sodium"));
assert_eq!(plan.failures[0].kind, FailureKind::Incompatible);
assert!(plan.failures[0].message.contains("没有可下载的主文件"));
}
// ---------------------------------------------------------------- 两道去重
#[test]
fn one_project_required_by_two_parents_is_planned_once_with_both_parents() {
let transport = FixtureTransport::new()
.with(
"https://api.modrinth.com/v2/project/fanin-root/version",
deps::FANIN_ROOT,
)
.with(
"https://api.modrinth.com/v2/project/BRANCHAA/version",
deps::FANIN_A,
)
.with(
"https://api.modrinth.com/v2/project/BRANCHBB/version",
deps::FANIN_B,
)
.with(
"https://api.modrinth.com/v2/project/SHARED01/version",
deps::SHARED_LIB,
);
let plan = resolve_with(&transport, &request("fanin-root"));
let projects: Vec<&str> = plan
.nodes
.iter()
.map(|node| node.project.as_str())
.collect();
assert_eq!(
projects,
["ROOT0001", "BRANCHAA", "BRANCHBB", "SHARED01"],
"同层按项目键字典序,公共前置只出现一次"
);
assert_eq!(
plan.required_by("SHARED01").expect("公共前置在计划里"),
["BRANCHAA".to_string(), "BRANCHBB".to_string()].as_slice(),
"两条依赖路径都要留下"
);
let shared_requests = transport
.seen()
.iter()
.filter(|url| url.contains("SHARED01"))
.count();
assert_eq!(shared_requests, 1, "一个项目只解一次版本");
}
#[test]
fn a_file_that_already_exists_is_skipped_but_its_dependencies_are_still_walked() {
let mut request = request("sodium");
// 禁用中的 jar 也算「装过」(本仓用 `.disabled` 后缀表示禁用)
request.present = vec![PresentFile::new("fabric-api-0.92.2+1.20.1.jar.disabled")];
let transport = chain_transport();
let plan = resolve_with(&transport, &request);
assert_eq!(plan.skipped.len(), 1);
let skipped = &plan.skipped[0];
assert_eq!(skipped.project, "P7dR8mSH");
assert_eq!(skipped.reason, SkipReason::AlreadyPresent);
assert_eq!(skipped.file_name, "fabric-api-0.92.2+1.20.1.jar");
// 跳过的是**下载**,不是图:fabric-api 的前置照样解析出来
let projects: Vec<&str> = plan
.nodes
.iter()
.map(|node| node.project.as_str())
.collect();
assert_eq!(projects, ["AANobbMI", "1eAoo2KR"]);
}
#[test]
fn present_keys_normalize_paths_case_and_the_disabled_suffix() {
assert_eq!(present_key("mods/Sodium-0.6.0.jar"), "sodium-0.6.0.jar");
assert_eq!(present_key("a\\b\\x.jar.disabled"), "x.jar");
assert_eq!(present_key(" y.jar "), "y.jar");
assert_eq!(present_key(""), "");
}
// ---------------------------------------------------------------- 光影过滤器
#[test]
fn shader_loaders_are_recognised_in_search_hits() {
let transport =
FixtureTransport::new().with(&format!("{MODRINTH_API}/search"), deps::SEARCH_SHADER);
let client = Modrinth::new(&transport);
let page = block_on(client.search(&crate::addon::online::SearchQuery {
text: String::new(),
project_type: Some(crate::addon::online::ContentType::Shader),
limit: 1,
..crate::addon::online::SearchQuery::default()
}))
.expect("解析应当成功");
assert_eq!(
page.hits[0].loaders,
vec!["iris".to_string(), "optifine".to_string()],
"光影的加载器不该被当成分类标签丢掉"
);
}
@@ -0,0 +1,187 @@
//! 下载复核:sha1/sha512 对不对、失败重试几次、会不会留下半截文件。
use crate::addon::online::download::{download_verified, RETRY_ATTEMPTS};
use crate::addon::online::OnlineErrorKind;
use crate::addon::temp::TempRoot;
use crate::download::blocking::block_on;
use super::support::{hashes_of, FakeArtifact};
const BYTES: &[u8] = b"PK\x03\x04 fake mod jar bytes for offline fixtures";
fn spec() -> (String, String) {
hashes_of(BYTES)
}
#[test]
fn a_matching_download_is_written_and_reported() {
let root = TempRoot::new("online-ok");
let dest = root.join("sodium.jar");
let fetcher = FakeArtifact::new(BYTES);
let (sha1, sha512) = spec();
let done = block_on(download_verified(
&fetcher,
"https://cdn.modrinth.com/data/AANobbMI/sodium.jar",
Some(&sha1),
Some(&sha512),
Some(BYTES.len() as u64),
&dest,
))
.expect("摘要对得上就该成功");
assert_eq!(done.bytes, BYTES.len() as u64);
assert_eq!(done.digest.sha512, sha512);
assert_eq!(std::fs::read(&dest).expect("文件应当落盘"), BYTES);
assert_eq!(fetcher.attempts(), 1);
}
#[test]
fn a_sha512_mismatch_deletes_the_file_and_retries_then_fails() {
let root = TempRoot::new("online-bad-sha512");
let dest = root.join("sodium.jar");
let fetcher = FakeArtifact::new(BYTES);
let (sha1, _) = spec();
let wrong = "0".repeat(128);
let error = block_on(download_verified(
&fetcher,
"https://cdn.modrinth.com/data/AANobbMI/sodium.jar",
Some(&sha1),
Some(&wrong),
None,
&dest,
))
.expect_err("摘要不符必须失败");
assert_eq!(error.kind(), OnlineErrorKind::Integrity);
assert_eq!(fetcher.attempts(), RETRY_ATTEMPTS, "摘要不符要重试到上限");
assert!(!dest.exists(), "不符的内容不许留在盘上");
}
#[test]
fn a_sha1_mismatch_is_caught_even_when_the_fetcher_skips_verification() {
let root = TempRoot::new("online-bad-sha1");
let dest = root.join("sodium.jar");
let fetcher = FakeArtifact::new(BYTES);
let (_, sha512) = spec();
let wrong = "1".repeat(40);
let error = block_on(download_verified(
&fetcher,
"https://cdn.modrinth.com/data/AANobbMI/sodium.jar",
Some(&wrong),
Some(&sha512),
None,
&dest,
))
.expect_err("sha1 不符必须失败");
assert_eq!(error.kind(), OnlineErrorKind::Integrity);
assert!(error.message().contains("SHA-1"), "{}", error.message());
assert!(!dest.exists());
}
#[test]
fn a_transient_network_failure_is_retried_and_succeeds() {
let root = TempRoot::new("online-retry");
let dest = root.join("sodium.jar");
let fetcher = FakeArtifact::new(BYTES).failing(2);
let (sha1, sha512) = spec();
let done = block_on(download_verified(
&fetcher,
"https://cdn.modrinth.com/data/AANobbMI/sodium.jar",
Some(&sha1),
Some(&sha512),
Some(BYTES.len() as u64),
&dest,
))
.expect("第三次该成功");
assert_eq!(done.bytes, BYTES.len() as u64);
assert_eq!(fetcher.attempts(), 3);
assert!(dest.exists());
}
#[test]
fn exhausting_the_retries_leaves_no_half_file() {
let root = TempRoot::new("online-exhausted");
let dest = root.join("sodium.jar");
let fetcher = FakeArtifact::new(BYTES).failing(RETRY_ATTEMPTS + 5);
let (sha1, sha512) = spec();
let error = block_on(download_verified(
&fetcher,
"https://cdn.modrinth.com/data/AANobbMI/sodium.jar",
Some(&sha1),
Some(&sha512),
None,
&dest,
))
.expect_err("一直断网必须失败");
assert_eq!(error.kind(), OnlineErrorKind::NotConnected);
assert_eq!(fetcher.attempts(), RETRY_ATTEMPTS);
assert!(!dest.exists(), "失败后不许留文件");
}
#[test]
fn a_truncated_body_is_rejected_by_the_declared_size() {
let root = TempRoot::new("online-truncated");
let dest = root.join("sodium.jar");
let fetcher = FakeArtifact::new(BYTES).truncated();
// 声明的是全量大小,实际只写了一半 ⇒ 按字节数就该拒
let (sha1, sha512) = spec();
let error = block_on(download_verified(
&fetcher,
"https://cdn.modrinth.com/data/AANobbMI/sodium.jar",
Some(&sha1),
Some(&sha512),
Some(BYTES.len() as u64),
&dest,
))
.expect_err("半截文件必须失败");
assert_eq!(error.kind(), OnlineErrorKind::Integrity);
assert!(!dest.exists());
}
#[test]
fn a_malformed_declared_hash_never_touches_the_disk_or_the_network() {
let root = TempRoot::new("online-bad-decl");
let dest = root.join("sodium.jar");
let fetcher = FakeArtifact::new(BYTES);
let error = block_on(download_verified(
&fetcher,
"https://cdn.modrinth.com/data/AANobbMI/sodium.jar",
None,
Some("abc"),
None,
&dest,
))
.expect_err("摘要形状不对必须失败");
assert_eq!(error.kind(), OnlineErrorKind::Unsupported);
assert_eq!(fetcher.attempts(), 0);
assert!(!dest.exists());
}
#[test]
fn a_malformed_sha1_is_dropped_and_the_sha512_still_guards_the_file() {
// r1 顺手项:sha1 形状写坏不该让整条装不上——sha512 才是 mrpack 的权威摘要。
let root = TempRoot::new("online-bad-sha1-good-sha512");
let dest = root.join("sodium.jar");
let fetcher = FakeArtifact::new(BYTES);
let (_, sha512) = spec();
let done = block_on(download_verified(
&fetcher,
"https://cdn.modrinth.com/data/AANobbMI/sodium.jar",
Some("not-a-sha1-at-all"),
Some(&sha512),
Some(BYTES.len() as u64),
&dest,
))
.expect("sha512 合法就该能校验并装下");
assert_eq!(done.digest.sha512, sha512);
assert!(dest.exists());
}
#[test]
fn an_empty_url_is_unsupported() {
let root = TempRoot::new("online-no-url");
let dest = root.join("sodium.jar");
let fetcher = FakeArtifact::new(BYTES);
let error = block_on(download_verified(&fetcher, " ", None, None, None, &dest))
.expect_err("没有地址必须失败");
assert_eq!(error.kind(), OnlineErrorKind::Unsupported);
assert_eq!(fetcher.attempts(), 0);
}
@@ -0,0 +1,143 @@
//! 录制下来的 Modrinth v2 响应(**形状按站点原文,字段抄真**;数值是示意,不是某天的真值)。
//!
//! 三条刻意的细节:
//! - 检索命中的日期字段是 `date_modified`(不是 `updated`)——旧实现读错了字段;
//! - `categories` 里混着 `optimization` 这种分类标签,只有 `fabric` 是真加载器;
//! - 版本里的文件有 `primary: false` 的 `sources.jar`,取第一个会装错。
/// `/v2/search?query=sodium`(两页里的第一页)。
pub(super) const SEARCH: &str = r#"{
"hits": [
{
"project_id": "AANobbMI",
"project_type": "mod",
"slug": "sodium",
"author": "jellysquid3",
"title": "Sodium",
"description": "Modern rendering engine and client-side optimization mod",
"categories": ["fabric", "optimization", "quilt"],
"display_categories": ["optimization"],
"versions": ["1.20.1", "1.21.1"],
"downloads": 42123456,
"follows": 9912,
"icon_url": "https://cdn.modrinth.com/data/AANobbMI/icon.png",
"date_created": "2020-12-01T00:00:00Z",
"date_modified": "2026-02-03T10:11:12Z",
"latest_version": "mc1.21.1-0.6.0",
"license": "LGPL-3.0-only",
"client_side": "required",
"server_side": "unsupported",
"gallery": []
},
{
"project_id": "P7dR8mSH",
"project_type": "mod",
"slug": "fabric-api",
"author": "modmuss50",
"title": "Fabric API",
"description": "Lightweight and modular API providing common hooks and intercompatibility measures",
"categories": ["fabric", "library"],
"versions": ["1.20.1", "1.21.1"],
"downloads": 99000000,
"follows": 12000,
"icon_url": "",
"date_modified": "2026-01-30T08:00:00Z"
}
],
"offset": 0,
"limit": 2,
"total_hits": 1234
}"#;
/// `/v2/project/sodium`。
pub(super) const PROJECT: &str = r###"{
"id": "AANobbMI",
"slug": "sodium",
"project_type": "mod",
"team": "team-id",
"title": "Sodium",
"description": "Modern rendering engine and client-side optimization mod",
"body": "## Sodium\n\nA modern rendering engine for Minecraft which greatly improves performance.",
"published": "2020-12-01T00:00:00Z",
"updated": "2026-02-03T10:11:12Z",
"downloads": 42123456,
"followers": 9912,
"categories": ["fabric", "optimization"],
"game_versions": ["1.20.1", "1.21.1"],
"loaders": ["fabric", "quilt"],
"versions": ["ver-1", "ver-2"],
"icon_url": "https://cdn.modrinth.com/data/AANobbMI/icon.png",
"source_url": "https://github.com/CaffeineMC/sodium",
"issues_url": "https://github.com/CaffeineMC/sodium/issues"
}"###;
/// `/v2/project/sodium/version`(站点顺序 = 最新在前)。
pub(super) const VERSIONS: &str = r#"[
{
"id": "ver-2",
"project_id": "AANobbMI",
"name": "Sodium 0.6.0 for 1.21.1",
"version_number": "mc1.21.1-0.6.0",
"version_type": "release",
"loaders": ["fabric"],
"game_versions": ["1.21.1"],
"date_published": "2026-02-01T12:00:00Z",
"downloads": 4321,
"files": [
{
"hashes": {"sha1": "1111111111111111111111111111111111111111", "sha512": "aa"},
"url": "https://cdn.modrinth.com/data/AANobbMI/versions/ver-2/sodium-sources.jar",
"filename": "sodium-sources.jar",
"size": 1000,
"primary": false
},
{
"hashes": {"sha1": "2222222222222222222222222222222222222222", "sha512": "bb"},
"url": "https://cdn.modrinth.com/data/AANobbMI/versions/ver-2/sodium-fabric-0.6.0.jar",
"filename": "sodium-fabric-0.6.0.jar",
"size": 2048,
"primary": true
}
]
}
]"#;
/// `GET /v2/version_file/{sha512}?algorithm=sha512` 的响应(一个版本对象)。
///
/// 里面有两个文件:只有 `hashes.sha512` 与请求值相同的那条能取 sha1(另一个是 `sources.jar`)。
pub(super) const VERSION_FILE_A: &str = r#"{
"id": "ver-a",
"project_id": "AANobbMI",
"name": "Sodium 0.5.11 for 1.20.1",
"version_number": "mc1.20.1-0.5.11",
"version_type": "release",
"loaders": ["fabric"],
"game_versions": ["1.20.1"],
"date_published": "2025-06-01T00:00:00Z",
"downloads": 9999,
"files": [
{
"hashes": {"sha1": "3333333333333333333333333333333333333333", "sha512": "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1"},
"url": "https://cdn.modrinth.com/data/AANobbMI/versions/ver-a/sodium-fabric-0.5.11.jar",
"filename": "sodium-fabric-0.5.11.jar",
"size": 4096,
"primary": true
},
{
"hashes": {"sha1": "4444444444444444444444444444444444444444", "sha512": "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2"},
"url": "https://cdn.modrinth.com/data/AANobbMI/versions/ver-a/sodium-sources.jar",
"filename": "sodium-sources.jar",
"size": 512,
"primary": false
}
]
}"#;
/// 补全时请求的那个 sha512(128 位十六进制;形状不对客户端根本不发请求)。
pub(super) const LOOKUP_SHA512: &str = "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1";
/// 同一版本里另一个文件的 sha512(`sources.jar`,不该被拿来当身份)。
pub(super) const OTHER_SHA512: &str = "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2";
/// 补全应该拿到的 sha1(`VERSION_FILE_A` 主文件那条)。
pub(super) const LOOKUP_SHA1: &str = "3333333333333333333333333333333333333333";
@@ -0,0 +1,277 @@
//! 依赖解析用的录制响应(形状按站点原文;数值是示意,不是某天的真值)。
//!
//! 从 `fixtures.rs` 分出来的原因很实在:那里已经在钉「字段名与 `primary`」这类解析细节,
//! 再往里塞四五个带 `dependencies` 的版本对象就会贴到 400 行硬线。
//!
//! 三条刻意的布置:
//! - 根项目(`sodium`)的版本里**同时有四类依赖**:只有 `required` 该被跟;
//! - 每个版本都有 `primary: false` 的 `sources.jar`,取第一个就会装错;
//! - 环(`cycle-a` ⇄ `cycle-b`)与扇入(两个分支都要 `shared-lib`)各有一组独立夹具。
/// 根项目 `sodium` 的版本:一条 required + 一条 optional + 一条 incompatible + 一条 embedded。
pub(super) const SODIUM_WITH_DEPENDENCIES: &str = r#"[
{
"id": "ver-sodium",
"project_id": "AANobbMI",
"name": "Sodium 0.6.0 for 1.20.1",
"version_number": "mc1.20.1-0.6.0",
"version_type": "release",
"loaders": ["fabric"],
"game_versions": ["1.20.1"],
"date_published": "2026-02-01T12:00:00Z",
"downloads": 4321,
"files": [
{
"hashes": {"sha1": "1111111111111111111111111111111111111111", "sha512": "aa"},
"url": "https://cdn.modrinth.com/data/AANobbMI/versions/ver-sodium/sodium-sources.jar",
"filename": "sodium-sources.jar",
"size": 1000,
"primary": false
},
{
"hashes": {"sha1": "2222222222222222222222222222222222222222", "sha512": "bb"},
"url": "https://cdn.modrinth.com/data/AANobbMI/versions/ver-sodium/sodium-fabric-0.6.0.jar",
"filename": "sodium-fabric-0.6.0.jar",
"size": 2048,
"primary": true
}
],
"dependencies": [
{"version_id": null, "project_id": "P7dR8mSH", "file_name": null, "dependency_type": "required"},
{"version_id": null, "project_id": "OCS3z7f6", "file_name": null, "dependency_type": "optional"},
{"version_id": null, "project_id": "AAAABBBB", "file_name": null, "dependency_type": "incompatible"},
{"version_id": "ver-embedded", "project_id": null, "file_name": "embedded-lib.jar", "dependency_type": "embedded"}
]
}
]"#;
/// `fabric-api`(`P7dR8mSH`):它自己又要求 `yacl`(`1eAoo2KR`)⇒ 第二层嵌套。
pub(super) const FABRIC_API_WITH_DEPENDENCIES: &str = r#"[
{
"id": "ver-fabric-api",
"project_id": "P7dR8mSH",
"name": "Fabric API 0.92.2 for 1.20.1",
"version_number": "0.92.2+1.20.1",
"version_type": "release",
"loaders": ["fabric"],
"game_versions": ["1.20.1"],
"date_published": "2025-12-01T00:00:00Z",
"downloads": 9999,
"files": [
{
"hashes": {"sha1": "3333333333333333333333333333333333333333", "sha512": "cc"},
"url": "https://cdn.modrinth.com/data/P7dR8mSH/versions/ver-fabric-api/fabric-api-0.92.2-sources.jar",
"filename": "fabric-api-0.92.2-sources.jar",
"size": 512,
"primary": false
},
{
"hashes": {"sha1": "4444444444444444444444444444444444444444", "sha512": "dd"},
"url": "https://cdn.modrinth.com/data/P7dR8mSH/versions/ver-fabric-api/fabric-api-0.92.2+1.20.1.jar",
"filename": "fabric-api-0.92.2+1.20.1.jar",
"size": 4096,
"primary": true
}
],
"dependencies": [
{"version_id": null, "project_id": "1eAoo2KR", "file_name": null, "dependency_type": "required"}
]
}
]"#;
/// `yacl`(`1eAoo2KR`):链的末端,没有依赖。
pub(super) const YACL: &str = r#"[
{
"id": "ver-yacl",
"project_id": "1eAoo2KR",
"name": "YetAnotherConfigLib 3.3.2",
"version_number": "3.3.2+1.20.1",
"version_type": "release",
"loaders": ["fabric"],
"game_versions": ["1.20.1"],
"date_published": "2025-11-01T00:00:00Z",
"downloads": 1234,
"files": [
{
"hashes": {"sha1": "5555555555555555555555555555555555555555", "sha512": "ee"},
"url": "https://cdn.modrinth.com/data/1eAoo2KR/versions/ver-yacl/yacl-3.3.2.jar",
"filename": "yacl-3.3.2+1.20.1.jar",
"size": 8192,
"primary": true
}
],
"dependencies": []
}
]"#;
/// 扇入的根:两个 required 分支。
pub(super) const FANIN_ROOT: &str = r#"[
{
"id": "ver-root",
"project_id": "ROOT0001",
"name": "Fan-in Root 1.0",
"version_number": "1.0",
"version_type": "release",
"loaders": ["fabric"],
"game_versions": ["1.20.1"],
"files": [
{
"hashes": {"sha1": "6666666666666666666666666666666666666666", "sha512": "ff"},
"url": "https://cdn.modrinth.com/data/ROOT0001/versions/ver-root/fanin-root-1.0.jar",
"filename": "fanin-root-1.0.jar",
"size": 1024,
"primary": true
}
],
"dependencies": [
{"project_id": "BRANCHAA", "dependency_type": "required"},
{"project_id": "BRANCHBB", "dependency_type": "required"}
]
}
]"#;
/// 分支 A:也要 `shared-lib`。
pub(super) const FANIN_A: &str = r#"[
{
"id": "ver-branch-a",
"project_id": "BRANCHAA",
"name": "Branch A 2.0",
"version_number": "2.0",
"version_type": "release",
"loaders": ["fabric"],
"game_versions": ["1.20.1"],
"files": [
{
"hashes": {"sha1": "7777777777777777777777777777777777777777", "sha512": "ab"},
"url": "https://cdn.modrinth.com/data/BRANCHAA/versions/ver-branch-a/branch-a-2.0.jar",
"filename": "branch-a-2.0.jar",
"size": 1024,
"primary": true
}
],
"dependencies": [
{"project_id": "SHARED01", "dependency_type": "required"}
]
}
]"#;
/// 分支 B:也要 `shared-lib`(同一项目两条路径 ⇒ 只装一次)。
pub(super) const FANIN_B: &str = r#"[
{
"id": "ver-branch-b",
"project_id": "BRANCHBB",
"name": "Branch B 3.0",
"version_number": "3.0",
"version_type": "release",
"loaders": ["fabric"],
"game_versions": ["1.20.1"],
"files": [
{
"hashes": {"sha1": "8888888888888888888888888888888888888888", "sha512": "ac"},
"url": "https://cdn.modrinth.com/data/BRANCHBB/versions/ver-branch-b/branch-b-3.0.jar",
"filename": "branch-b-3.0.jar",
"size": 1024,
"primary": true
}
],
"dependencies": [
{"project_id": "SHARED01", "dependency_type": "required"}
]
}
]"#;
/// 扇入的公共前置。
pub(super) const SHARED_LIB: &str = r#"[
{
"id": "ver-shared",
"project_id": "SHARED01",
"name": "Shared Lib 1.5",
"version_number": "1.5",
"version_type": "release",
"loaders": ["fabric"],
"game_versions": ["1.20.1"],
"files": [
{
"hashes": {"sha1": "9999999999999999999999999999999999999999", "sha512": "ad"},
"url": "https://cdn.modrinth.com/data/SHARED01/versions/ver-shared/shared-lib-1.5.jar",
"filename": "shared-lib-1.5.jar",
"size": 2048,
"primary": true
}
],
"dependencies": []
}
]"#;
/// 环的一端:`cycle-a` 要求 `cycle-b`。
pub(super) const CYCLE_A: &str = r#"[
{
"id": "ver-cycle-a",
"project_id": "cycAid01",
"name": "Cycle A 1.0",
"version_number": "1.0",
"version_type": "release",
"loaders": ["fabric"],
"game_versions": ["1.20.1"],
"files": [
{
"hashes": {"sha1": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "sha512": "a1"},
"url": "https://cdn.modrinth.com/data/cycAid01/versions/ver-cycle-a/cycle-a-1.0.jar",
"filename": "cycle-a-1.0.jar",
"size": 1024,
"primary": true
}
],
"dependencies": [
{"project_id": "cycBid01", "dependency_type": "required"}
]
}
]"#;
/// 环的另一端:`cycle-b` 又要求 `cycle-a`(不设防就是无限递归)。
pub(super) const CYCLE_B: &str = r#"[
{
"id": "ver-cycle-b",
"project_id": "cycBid01",
"name": "Cycle B 1.0",
"version_number": "1.0",
"version_type": "release",
"loaders": ["fabric"],
"game_versions": ["1.20.1"],
"files": [
{
"hashes": {"sha1": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "sha512": "b1"},
"url": "https://cdn.modrinth.com/data/cycBid01/versions/ver-cycle-b/cycle-b-1.0.jar",
"filename": "cycle-b-1.0.jar",
"size": 1024,
"primary": true
}
],
"dependencies": [
{"project_id": "cycAid01", "dependency_type": "required"}
]
}
]"#;
/// 光影检索的一页:`categories` 里是 `iris` / `optifine`(光影的加载器)。
pub(super) const SEARCH_SHADER: &str = r#"{
"hits": [
{
"project_id": "shader01",
"project_type": "shader",
"slug": "complementary-reimagined",
"author": "EminGT",
"title": "Complementary Reimagined",
"description": "A shader pack",
"categories": ["iris", "optifine", "shaders"],
"versions": ["1.20.1"],
"downloads": 1234567,
"follows": 4242,
"icon_url": "",
"date_modified": "2026-01-02T03:04:05Z"
}
],
"offset": 0,
"limit": 1,
"total_hits": 1
}"#;
+17
View File
@@ -0,0 +1,17 @@
//! 在线内容源的离线用例(**不打真实网络**)。
//!
//! 全部素材是录制好的 Modrinth v2 响应(`fixtures.rs`)+ 假字节(`support::FakeArtifact`):
//! 覆盖检索、项目详情、版本列表、按摘要反查、下载复核、补全路径与离线分类。
//!
//! 口径提醒:这些用例证明的是**解析与判定**,不证明「真实站点今天回什么」——后者写在
//! M162 报告的「没验的部分」里。
mod api;
mod classify;
mod dependency;
mod download;
mod fixtures;
mod fixtures_deps;
mod order;
mod regressions;
mod support;
+137
View File
@@ -0,0 +1,137 @@
//! 顺序确定性(r1 P2-4):同层 ≥2 个前置、夹具数组故意逆序、多根乱序,结果必须一样。
//!
//! # 这条用例守的是什么
//!
//! `resolve` 在每层处理前把队列按**项目键字典序**排一遍。把那一行删掉,原来那 11 条用例
//! (以及 core 的全部用例)**仍然全绿**——因为三跳链每层只有一个前置、扇入用例里两个同层
//! 节点的先后恰好等于夹具数组顺序。也就是说「顺序由数据决定」这条性质当时没有守门。
//!
//! # 归因(实测过,别写成「都靠这两条守门」)
//!
//! 删掉 `resolve` 里那行 `layer.sort_by` ⇒
//! [`prerequisites_from_two_roots_are_ordered_by_project_key`] 与
//! [`multiple_roots_are_processed_in_key_order_whatever_the_argument_order`] 红,
//! [`same_root_prerequisites_follow_key_order_not_payload_order`] 仍绿——因为那两个前置
//! 是**同一个根**入队的,顺序已经被 `Version::required_project_ids()` 内部的排序排过了。
//! 反过来,只删 `required_project_ids()` 的排序时三条都绿:`layer.sort_by` 会把结果重排一遍。
//!
//! 也就是说:跨层顺序由 `layer.sort_by` 兜底(这两条用例钉它),同一个根内的顺序**两层保护**
//! 冗余存在;`required_project_ids()` 自己的「排序去重」契约由 `tests/regressions.rs` 的
//! `required_project_ids_are_sorted_and_deduplicated_on_their_own` 单独钉(那条只删排序就会红)。
use crate::addon::online::dependency::{DependencyPlan, ResolveRequest};
use crate::addon::online::{resolve, Modrinth};
use crate::download::blocking::block_on;
use super::support::{version_json, FixtureTransport};
const API: &str = "https://api.modrinth.com/v2/project";
fn request(roots: &[&str]) -> ResolveRequest {
ResolveRequest {
roots: roots.iter().map(|root| (*root).to_string()).collect(),
game_version: Some("1.20.1".to_string()),
loader: Some("fabric".to_string()),
..ResolveRequest::default()
}
}
fn plan_of(transport: &FixtureTransport, roots: &[&str]) -> DependencyPlan {
let client = Modrinth::new(transport);
block_on(resolve(&client, &request(roots)))
}
fn projects(plan: &DependencyPlan) -> Vec<&str> {
plan.nodes
.iter()
.map(|node| node.project.as_str())
.collect()
}
#[test]
fn same_root_prerequisites_follow_key_order_not_payload_order() {
// 夹具里 ZEBRA01 在前、ALPHA01 在后(站点原文顺序反过来是常态)。
// 这一条守的是 `Version::required_project_ids()` 里的排序:靠「入队顺序」展开的话,
// 站点换个数组顺序,安装顺序就跟着变。
let transport = FixtureTransport::new()
.with(
&format!("{API}/order-root/version"),
&version_json(
"order-root",
"order-root.jar",
&[("ZEBRA01", "required"), ("ALPHA01", "required")],
),
)
.with(
&format!("{API}/ALPHA01/version"),
&version_json("ALPHA01", "alpha.jar", &[]),
)
.with(
&format!("{API}/ZEBRA01/version"),
&version_json("ZEBRA01", "zebra.jar", &[]),
);
let plan = plan_of(&transport, &["order-root"]);
assert!(plan.is_clean(), "{:?}", plan.failures);
assert_eq!(
projects(&plan),
["order-root", "ALPHA01", "ZEBRA01"],
"同层按项目键字典序,与 `dependencies` 数组顺序无关"
);
}
/// 两个根各带一个前置 ⇒ 它们在**同一层**相遇,且项目键的顺序与根的先后相反。
///
/// 这一条守的是 `resolve` 里每层的 `sort_by`:不排的话,层内顺序就是「谁先被入队」——
/// 也就是参数顺序与站点响应顺序的混合,删掉那行 `sort_by` 这条会红
/// (实测:`left: ["zebra-branch","alpha-branch"]` 之类的参数序)。
#[test]
fn prerequisites_from_two_roots_are_ordered_by_project_key() {
let transport = FixtureTransport::new()
.with(
&format!("{API}/branch-a/version"),
&version_json("branch-a", "branch-a.jar", &[("ZEBRA01", "required")]),
)
.with(
&format!("{API}/branch-b/version"),
&version_json("branch-b", "branch-b.jar", &[("ALPHA01", "required")]),
)
.with(
&format!("{API}/ALPHA01/version"),
&version_json("ALPHA01", "alpha.jar", &[]),
)
.with(
&format!("{API}/ZEBRA01/version"),
&version_json("ZEBRA01", "zebra.jar", &[]),
);
let plan = plan_of(&transport, &["branch-a", "branch-b"]);
assert!(plan.is_clean(), "{:?}", plan.failures);
assert_eq!(
projects(&plan),
["branch-a", "branch-b", "ALPHA01", "ZEBRA01"],
"第二层两个前置按项目键排,不按「谁先入队」"
);
}
#[test]
fn multiple_roots_are_processed_in_key_order_whatever_the_argument_order() {
let transport = FixtureTransport::new()
.with(
&format!("{API}/zebra-root/version"),
&version_json("zebra-root", "zebra-root.jar", &[]),
)
.with(
&format!("{API}/alpha-root/version"),
&version_json("alpha-root", "alpha-root.jar", &[]),
);
let forward = plan_of(&transport, &["zebra-root", "alpha-root"]);
let backward = plan_of(&transport, &["alpha-root", "zebra-root"]);
assert_eq!(
projects(&forward),
["alpha-root", "zebra-root"],
"参数逆序也要按项目键处理"
);
assert_eq!(projects(&backward), projects(&forward), "两次结果必须一样");
}
@@ -0,0 +1,232 @@
//! M186 复审(r1)补的用例:失败去重、null 载荷、版本级去重。
//!
//! 三条都对应一轮真实缺陷或真实缺口,而不是「补覆盖率」:
//! 1. [`the_same_failing_project_is_recorded_once_however_many_paths_ask_for_it`] — P2-1:
//! 失败分支以前直接 `continue`,同一项目被两条路径要求时取两次版本、记两条失败、
//! 重试名单里写两遍同一个名字;
//! 2. [`a_null_dependency_type_does_not_poison_the_whole_version_list`] — P2-2:
//! `"dependency_type": null` 曾让整份 `/version` 报 Malformed,于是整个项目都装不上;
//! 3. [`another_version_of_the_same_mod_in_the_directory_still_downloads`] — nit③:
//! 归一化一旦改松,同 mod 的另一个版本会被误判成「已装」而跳过。
use crate::addon::online::dependency::{present_key, DependencyPlan, ResolveRequest};
use crate::addon::online::model::parse_versions;
use crate::addon::online::{resolve, DependencyType, Modrinth, PresentFile, UnresolvedRequired};
use crate::download::blocking::block_on;
use super::fixtures_deps as deps;
use super::support::{version_json, FixtureTransport};
const API: &str = "https://api.modrinth.com/v2/project";
const SODIUM_VERSIONS: &str = "https://api.modrinth.com/v2/project/sodium/version";
const FABRIC_API_VERSIONS: &str = "https://api.modrinth.com/v2/project/P7dR8mSH/version";
const YACL_VERSIONS: &str = "https://api.modrinth.com/v2/project/1eAoo2KR/version";
fn request(root: &str) -> ResolveRequest {
ResolveRequest {
roots: vec![root.to_string()],
game_version: Some("1.20.1".to_string()),
loader: Some("fabric".to_string()),
..ResolveRequest::default()
}
}
fn resolve_with(transport: &FixtureTransport, request: &ResolveRequest) -> DependencyPlan {
let client = Modrinth::new(transport);
block_on(resolve(&client, request))
}
fn chain_transport() -> FixtureTransport {
FixtureTransport::new()
.with(SODIUM_VERSIONS, deps::SODIUM_WITH_DEPENDENCIES)
.with(FABRIC_API_VERSIONS, deps::FABRIC_API_WITH_DEPENDENCIES)
.with(YACL_VERSIONS, deps::YACL)
}
/// 同一个项目被两条路径要求、且它 404:**只请求一次、只记一条失败**,父边合起来。
///
/// r1 P2-1 的探针:`ROOT0001 → {BRANCH01, MIDDLE01}`、`BRANCH01 → GONE0001`(404)、
/// `MIDDLE01 → LATE0001 → GONE0001`。修之前 GONE0001 被请求 2 次、失败 2 条、
/// 重试名单是 `["GONE0001","GONE0001"]`(用户会看到「可只重试这 2 个:GONE0001、GONE0001」)。
#[test]
fn the_same_failing_project_is_recorded_once_however_many_paths_ask_for_it() {
let transport = FixtureTransport::new()
.with(
&format!("{API}/root/version"),
&version_json(
"ROOT0001",
"root.jar",
&[("BRANCH01", "required"), ("MIDDLE01", "required")],
),
)
.with(
&format!("{API}/BRANCH01/version"),
&version_json("BRANCH01", "branch.jar", &[("GONE0001", "required")]),
)
.with(
&format!("{API}/MIDDLE01/version"),
&version_json("MIDDLE01", "middle.jar", &[("LATE0001", "required")]),
)
.with(
&format!("{API}/LATE0001/version"),
&version_json("LATE0001", "late.jar", &[("GONE0001", "required")]),
)
.missing(&format!("{API}/GONE0001/version"));
let plan = resolve_with(&transport, &request("root"));
let gone_requests = transport
.seen()
.iter()
.filter(|url| url.contains("GONE0001"))
.count();
assert_eq!(
gone_requests,
1,
"同一个失败项目不许取两次:{:?}",
transport.seen()
);
assert_eq!(plan.failures.len(), 1, "{:?}", plan.failures);
assert_eq!(plan.failures[0].requested_as, "GONE0001");
assert_eq!(
plan.failures[0].required_by,
["BRANCH01".to_string(), "LATE0001".to_string()],
"两条路径的父边都要留下"
);
assert_eq!(plan.retry_request().roots, ["GONE0001"], "名单不许重复");
}
/// `"dependency_type": null` 不许把整份版本响应打成 Malformed(r1 P2-2 的回归)。
///
/// 以前 `RawDependency.dependency_type` 是 `String`:`#[serde(default)]` 只兜「字段缺失」,
/// 兜不住 null ⇒ 整份 `/version` 解析失败 ⇒ 根项目记 `Malformed`、`nodes` 为空 ⇒ 一个都装不上。
#[test]
fn a_null_dependency_type_does_not_poison_the_whole_version_list() {
let body = r#"[{"id": "ver-null", "project_id": "NULLTYPE", "version_number": "1.0",
"files": [{"url": "https://cdn.modrinth.com/data/NULLTYPE/1.0/null-type.jar",
"filename": "null-type.jar", "size": 16, "primary": true}],
"dependencies": [{"project_id": "X1", "dependency_type": null}]}]"#;
// 解析层:null 与缺失一样落 `Unknown`
let versions = parse_versions(body).expect("null 不该让整份响应失败");
assert_eq!(
versions[0].dependencies[0].dependency_type,
DependencyType::Unknown
);
assert_eq!(versions[0].required_project_ids(), Vec::<&str>::new());
// 解析流程:根照样装得上,X1 一次请求都不发
let transport = FixtureTransport::new().with(&format!("{API}/null-type/version"), body);
let plan = resolve_with(&transport, &request("null-type"));
assert!(plan.is_clean(), "{:?}", plan.failures);
assert_eq!(plan.nodes.len(), 1, "根项目要还在计划里");
assert_eq!(transport.seen().len(), 1, "未知类型不许递归");
}
/// 目录里是**同 mod 的另一个版本** ⇒ 文件名不同,仍然要下载(r1 nit③ 的负向用例)。
///
/// 归一化如果改松(例如「砍到第一个 `-` 前 + 砍扩展名」),`sodium-fabric-0.5.11.jar` 与
/// `sodium-fabric-0.6.0.jar` 会被判成同一个文件,该装的被跳过。
#[test]
fn another_version_of_the_same_mod_in_the_directory_still_downloads() {
let mut request = request("sodium");
request.present = vec![PresentFile::new("sodium-fabric-0.5.11.jar")];
let transport = chain_transport();
let plan = resolve_with(&transport, &request);
assert!(plan.skipped.is_empty(), "{:?}", plan.skipped);
assert_eq!(plan.nodes[0].file.filename, "sodium-fabric-0.6.0.jar");
assert_ne!(
present_key("sodium-fabric-0.5.11.jar"),
present_key("sodium-fabric-0.6.0.jar"),
"不同版本的文件名归一化后不许相等"
);
}
/// 未知/大小写不同的取值一律 `Unknown`,**绝不**猜成 required(这条设计不许被 r1 的修法改坏)。
#[test]
fn unknown_dependency_types_never_trigger_recursion() {
for raw in [
"required_by_default",
"Required ",
"Recommended",
"",
"REQUIRED",
"req",
] {
assert_eq!(
DependencyType::parse(raw),
DependencyType::Unknown,
"{raw:?} 不该被当成 required"
);
}
assert_eq!(DependencyType::parse("required"), DependencyType::Required);
}
/// `required_project_ids()` 的「排序 + 去重」契约单独钉住。
///
/// 为什么要单独钉:resolver 里 `layer.sort_by` 会把它重排一遍、`wave_in` 也会按同键合并,
/// 于是**这条契约在端到端用例里被兜住了**(删掉这里的 `sort_unstable` 三条顺序用例仍绿)。
/// 但 `dedup()` 只去相邻重复,不排序就留下 `["ZEBRA01","ALPHA01","ZEBRA01"]`——
/// 公开 API 说好了「排序去重」,就得有自己的一条。
#[test]
fn required_project_ids_are_sorted_and_deduplicated_on_their_own() {
let body = version_json(
"dup-root",
"dup-root.jar",
&[
("ZEBRA01", "required"),
("ALPHA01", "required"),
("ZEBRA01", "required"),
],
);
let versions = parse_versions(&body).expect("夹具该解析成功");
assert_eq!(
versions[0].required_project_ids(),
["ALPHA01", "ZEBRA01"],
"既要去重(非相邻)也要排序"
);
}
/// 只有 `version_id` 的必备前置:**core 自己**要把它们收进 `unresolved_required`(r2 nit①)。
///
/// presenter 那条用例是直接赋值 `plan.unresolved_required`,绕过了 core;这里从夹具走一遍解析,
/// 顺带钉住「别为了收集把正常的 required 也吞掉」——同一版本里带 `project_id` 的那条仍要被跟。
#[test]
fn a_required_dependency_with_only_a_version_id_is_collected_by_the_resolver() {
let body = r#"[{"id": "ver-mixed", "project_id": "MIXED001", "version_number": "1.0",
"files": [{"url": "https://cdn.modrinth.com/data/MIXED001/1.0/mixed.jar",
"filename": "mixed.jar", "size": 16, "primary": true}],
"dependencies": [
{"version_id": "vid-only", "dependency_type": "required"},
{"project_id": "FOLLOW01", "dependency_type": "required"}
]}]"#;
let transport = FixtureTransport::new()
.with(&format!("{API}/mixed/version"), body)
.with(
&format!("{API}/FOLLOW01/version"),
&version_json("FOLLOW01", "follow.jar", &[]),
);
let plan = resolve_with(&transport, &request("mixed"));
assert_eq!(
plan.unresolved_required,
[UnresolvedRequired {
parent: "MIXED001".to_string(),
version_id: Some("vid-only".to_string()),
file_name: None,
}],
"只给版本 id 的 required 必须被记下来"
);
let projects: Vec<&str> = plan
.nodes
.iter()
.map(|node| node.project.as_str())
.collect();
assert_eq!(
projects,
["MIXED001", "FOLLOW01"],
"同一版本里带 project_id 的 required 仍要被跟"
);
}
@@ -0,0 +1,189 @@
//! 测试替身:录制响应传输层 + 假字节下载器。
//!
//! 两个替身都**只做注入点该做的事**:不校验、不重试、不判断——那些必须在被测代码里,
//! 否则测试就只是「验证桩自己」。
use std::collections::BTreeMap;
use std::io::Write;
use std::path::Path;
use std::sync::Mutex;
use sha1::Digest as _;
use crate::addon::online::download::{ArtifactFetcher, FetchArgs};
use crate::addon::online::{OnlineError, OnlineErrorKind, OnlineFuture, Transport};
/// 按 URL 前缀路由的传输层(把录制 JSON 喂给客户端)。
pub(super) struct FixtureTransport {
routes: BTreeMap<String, Result<String, OnlineError>>,
seen: Mutex<Vec<String>>,
}
impl FixtureTransport {
/// 建一个空的路由表。
pub(super) fn new() -> Self {
Self {
routes: BTreeMap::new(),
seen: Mutex::new(Vec::new()),
}
}
/// 挂一条路由(`prefix` 是 URL 前缀)。
pub(super) fn with(mut self, prefix: &str, body: &str) -> Self {
self.routes.insert(prefix.to_string(), Ok(body.to_string()));
self
}
/// 挂一条「没联网」的路由。
pub(super) fn offline(mut self, prefix: &str) -> Self {
self.routes.insert(
prefix.to_string(),
Err(OnlineError::offline("连不上 api.modrinth.com")),
);
self
}
/// 挂一条「站点说没有」的路由。
pub(super) fn missing(mut self, prefix: &str) -> Self {
self.routes.insert(
prefix.to_string(),
Err(OnlineError::new(OnlineErrorKind::NotFound, "HTTP 404")),
);
self
}
/// 实际请求过的 URL(按顺序)。
pub(super) fn seen(&self) -> Vec<String> {
self.seen.lock().expect("锁没中毒").clone()
}
}
impl Transport for FixtureTransport {
fn get_text<'a>(&'a self, url: &'a str) -> OnlineFuture<'a, Result<String, OnlineError>> {
Box::pin(async move {
self.seen.lock().expect("锁没中毒").push(url.to_string());
// 最长前缀优先:`/v2/project/sodium` 与 `/v2/project/sodium/version` 不能互相抢。
let best = self
.routes
.iter()
.filter(|(prefix, _)| url.starts_with(prefix.as_str()))
.max_by_key(|(prefix, _)| prefix.len())
.map(|(_, result)| result);
match best {
Some(Ok(body)) => Ok(body.clone()),
Some(Err(error)) => Err(error.clone()),
None => Err(OnlineError::new(
OnlineErrorKind::NotFound,
format!("测试没为 {url} 挂路由"),
)),
}
})
}
}
/// 假字节下载器:把给定字节写到 `dest`,可以安排前几次失败。
pub(super) struct FakeArtifact {
bytes: Vec<u8>,
/// 还欠几次「没联网」失败(每尝试一次减一)
failures: Mutex<u32>,
/// 实际尝试次数
attempts: Mutex<u32>,
/// 故意只写一半(模拟半截文件)
truncate: bool,
}
impl FakeArtifact {
/// 正常一份字节。
pub(super) fn new(bytes: &[u8]) -> Self {
Self {
bytes: bytes.to_vec(),
failures: Mutex::new(0),
attempts: Mutex::new(0),
truncate: false,
}
}
/// 前 `count` 次尝试没联网。
pub(super) fn failing(mut self, count: u32) -> Self {
*self.failures.get_mut().expect("锁没中毒") = count;
self
}
/// 只写一半字节(半截文件场景)。
pub(super) fn truncated(mut self) -> Self {
self.truncate = true;
self
}
/// 尝试次数。
pub(super) fn attempts(&self) -> u32 {
*self.attempts.lock().expect("锁没中毒")
}
}
impl ArtifactFetcher for FakeArtifact {
fn fetch<'a>(&'a self, args: FetchArgs<'a>) -> OnlineFuture<'a, Result<u64, OnlineError>> {
Box::pin(async move {
*self.attempts.lock().expect("锁没中毒") += 1;
let mut failures = self.failures.lock().expect("锁没中毒");
if *failures > 0 {
*failures -= 1;
return Err(OnlineError::offline("假装的断网"));
}
drop(failures);
write_bytes(args.dest, &self.bytes, self.truncate)?;
Ok(if self.truncate {
self.bytes.len() as u64 / 2
} else {
self.bytes.len() as u64
})
})
}
}
fn write_bytes(dest: &Path, bytes: &[u8], truncated: bool) -> Result<(), OnlineError> {
let mut file = std::fs::File::create(dest).map_err(|error| {
OnlineError::new(
OnlineErrorKind::Io,
format!("建 {} 失败:{error}", dest.display()),
)
})?;
let half = bytes.len() / 2;
let slice = if truncated { &bytes[..half] } else { bytes };
file.write_all(slice).map_err(|error| {
OnlineError::new(
OnlineErrorKind::Io,
format!("写 {} 失败:{error}", dest.display()),
)
})
}
/// 一段字节的 `(sha1, sha512)`(小写十六进制)。
pub(super) fn hashes_of(bytes: &[u8]) -> (String, String) {
let sha1 = sha1::Sha1::digest(bytes);
let sha512 = sha2::Sha512::digest(bytes);
(
crate::addon::online::download::to_hex(&sha1),
crate::addon::online::download::to_hex(&sha512),
)
}
/// 拼一份 `/v2/project/{id}/version` 响应:一个版本、一个主文件、按给定顺序声明依赖。
///
/// `deps` 的每一项是 `(project_id, dependency_type)`,**顺序照抄站点原文**——顺序无关性由
/// `resolve` 保证(`tests/order.rs` 用它做「夹具逆序也得到同一顺序」的守门)。
pub(super) fn version_json(project: &str, file: &str, deps: &[(&str, &str)]) -> String {
let dependencies: Vec<String> = deps
.iter()
.map(|(id, kind)| format!(r#"{{"project_id":"{id}","dependency_type":"{kind}"}}"#))
.collect();
format!(
r#"[{{"id":"ver-{project}","project_id":"{project}","name":"{project}",
"version_number":"1.0","version_type":"release","loaders":["fabric"],
"game_versions":["1.20.1"],
"files":[{{"hashes":{{}},"url":"https://cdn.modrinth.com/data/{project}/1.0/{file}",
"filename":"{file}","size":16,"primary":true}}],
"dependencies":[{}]}}]"#,
dependencies.join(",")
)
}
+227
View File
@@ -0,0 +1,227 @@
//! 唯一的 HTTP 出口 + 错误分类。
//!
//! # 为什么是 trait
//!
//! 三条理由,每条都对应一个真实麻烦:
//!
//! 1. **测试不能联网**:本模块的解析/端点/补全全部要用录制 JSON 跑(CI 没有网,也不该有);
//! 2. **换源不改业务**:将来加第二个内容源时,只要它也能 GET 出 JSON,就能复用这套
//! `Transport`(现在的实现按「一个 URL → 一段文本」抽象,不假设站点);
//! 3. **错误必须可分辨**:「没联网」和「站点说没有这个项目」是两件事(前者该提示检查网络,
//! 后者该提示换关键词),字符串错误串会让上层只能靠 `contains` 猜。
//!
//! # 生产实现只有一条路:`core::download` 的引擎
//!
//! [`EngineTransport`] 调 [`DownloadEngine::download_bytes`],于是信号量限流、源健康度、
//! 指数退避、错误分类全在引擎里。本模块**不**新建 `reqwest::Client`、**不**写重试循环
//! (与 `addon::import::fetch` 的纪律同源)。镜像策略同理:`mirrors.json` 里没有
//! `api.modrinth.com` 的前缀规则 ⇒ 元数据请求永远是直连,只有真正的文件下载才会被镜像
//! 规则命中(国内 gh-proxy 那条也只看 GitHub host)。
//!
//! 引擎本身由宿主提供**且是进程级共享的**(M165:`download::shared::engine_for`,一个数据根
//! 一个):`EngineTransport` 只借一个 `&DownloadEngine`,不负责建也不负责销毁。于是
//! 「检索 → 详情 → 装文件」这一串请求复用同一条连接池、同一个限流闸门、同一份镜像健康度——
//! 这正是本模块当初拒绝自己造 client 的同一个理由,只是从「模块内不重复造」扩到了「跨请求不重复造」。
use std::future::Future;
use std::pin::Pin;
use crate::download::{
DownloadEngine, DownloadError, DownloadErrorKind, DownloadRequest, ResourceKind,
};
/// trait 方法返回的装箱 future(core 不引 `async-trait`,手写与 `import::fetch` 同一个范式)。
pub type OnlineFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
/// 「拿不到」的成因(**分级判据只在这里**)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OnlineErrorKind {
/// 没联网 / 连不上 / 所有候选源都失败 —— 上层要明确说「未联网」。
NotConnected,
/// 站点说没有(404 / 项目被删)。
NotFound,
/// 站点出错(5xx / 429 退避后仍失败)。
Server,
/// 响应不是期望形状(JSON 坏 / 字段类型不对)。
Malformed,
/// 本地 IO(写缓存失败等)。
Io,
/// 内容与声明的摘要不符(下载到的字节坏了 / 镜像给的其实是别的东西)。
Integrity,
/// 这条路走不通(URL 非法、摘要格式不对、没有可用下载地址)。
Unsupported,
}
impl OnlineErrorKind {
/// 稳定字符串 id(日志 / DTO 用,**不要改名**)。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::NotConnected => "online_not_connected",
Self::NotFound => "online_not_found",
Self::Server => "online_server",
Self::Malformed => "online_malformed",
Self::Io => "online_io",
Self::Integrity => "online_integrity",
Self::Unsupported => "online_unsupported",
}
}
/// 是不是「网络这条路没通」(决定上层画「未联网」还是「查无此项」)。
#[must_use]
pub const fn is_offline(self) -> bool {
matches!(self, Self::NotConnected)
}
}
/// 一次取数失败。
#[derive(Debug, Clone)]
pub struct OnlineError {
kind: OnlineErrorKind,
message: String,
}
impl OnlineError {
/// 构造。
#[must_use]
pub fn new(kind: OnlineErrorKind, message: impl Into<String>) -> Self {
Self {
kind,
message: message.into(),
}
}
/// 没联网。
#[must_use]
pub fn offline(message: impl Into<String>) -> Self {
Self::new(OnlineErrorKind::NotConnected, message)
}
/// 形状不对 / 自身不自洽。
#[must_use]
pub fn malformed(message: impl Into<String>) -> Self {
Self::new(OnlineErrorKind::Malformed, message)
}
/// 内容与声明不符。
#[must_use]
pub fn integrity(message: impl Into<String>) -> Self {
Self::new(OnlineErrorKind::Integrity, message)
}
/// 这条路走不通。
#[must_use]
pub fn unsupported(message: impl Into<String>) -> Self {
Self::new(OnlineErrorKind::Unsupported, message)
}
/// 大类。
#[must_use]
pub const fn kind(&self) -> OnlineErrorKind {
self.kind
}
/// 人读说明。
#[must_use]
pub fn message(&self) -> &str {
&self.message
}
/// 没联网吗(上层据此决定降级文案)。
#[must_use]
pub const fn is_offline(&self) -> bool {
self.kind.is_offline()
}
}
impl std::fmt::Display for OnlineError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(formatter, "{}:{}", self.kind.as_str(), self.message)
}
}
impl std::error::Error for OnlineError {}
/// `DownloadEngine` 的错误 → 本模块的错误分类。
///
/// # 为什么读 `effective_kind()` 而不是 `kind`
///
/// 引擎的候选源循环结束时只留下一条 [`DownloadErrorKind::AllSourcesFailed`],404 / 429 / 5xx /
/// 超时这些**叶子**成因会被折叠掉(`crates/core/src/download/bytes.rs` 的收尾)。
/// 如果直接按 `kind` 判定,`NotFound` / `Server` / `Integrity` 三档在本模块的生产路径上
/// **永远不可达**:项目被删、id 拼错、被限速都会画成「未联网」(r1 P1-1 后果 A)。
///
/// 引擎现在会把汇总出来的叶子 kind 挂在 `DownloadError::leaf` 上,这里按叶子映射;
/// 没有叶子(单源直失败 / 手工构造)就按自身 kind。**不从 `suppressed` 文本里猜状态码。**
///
/// # 分类口径
///
/// | 引擎侧 | 本模块 | 上层该怎么说 |
/// |--------|--------|--------------|
/// | `NotFound`(404/410/403,全源都是) | [`OnlineErrorKind::NotFound`] | 「查无此项 / 换个关键词」 |
/// | `ServerError`(5xx / 429) | [`OnlineErrorKind::Server`] | 「站点那边出错了」 |
/// | `Network` / `Timeout` | [`OnlineErrorKind::NotConnected`] | 「未联网,检查网络」 |
/// | `AllSourcesFailed`(没有叶子) | [`OnlineErrorKind::NotConnected`] | 同上(保守) |
/// | `Integrity` | [`OnlineErrorKind::Integrity`] | 「内容不对」 |
impl From<DownloadError> for OnlineError {
fn from(error: DownloadError) -> Self {
let kind = match error.effective_kind() {
DownloadErrorKind::NotFound => OnlineErrorKind::NotFound,
DownloadErrorKind::ServerError => OnlineErrorKind::Server,
DownloadErrorKind::Timeout | DownloadErrorKind::Network => {
OnlineErrorKind::NotConnected
}
DownloadErrorKind::Io => OnlineErrorKind::Io,
DownloadErrorKind::Integrity => OnlineErrorKind::Integrity,
DownloadErrorKind::RangeUnsupported => OnlineErrorKind::Malformed,
DownloadErrorKind::InvalidRequest => OnlineErrorKind::Unsupported,
// 没有叶子 ⇒ 分不清「没网」与「站点挂了」,按可重试的「没联网」保守处理:
// 上层至少会提示「检查网络后重试」,而不是宣称「没有匹配的内容」。
DownloadErrorKind::AllSourcesFailed => OnlineErrorKind::NotConnected,
};
Self::new(kind, error.message)
}
}
/// 一个 URL → 一段文本(JSON 原文)。
///
/// 实现方只负责「取回来」:状态码分类、退避、限流都在实现里(生产实现在引擎里)。
pub trait Transport: Send + Sync {
/// GET 一段文本。
fn get_text<'a>(&'a self, url: &'a str) -> OnlineFuture<'a, Result<String, OnlineError>>;
}
/// 生产实现:把 GET 交给 `core::download` 的引擎(内存下载路径)。
#[derive(Debug, Clone, Copy)]
pub struct EngineTransport<'a> {
engine: &'a DownloadEngine,
}
impl<'a> EngineTransport<'a> {
/// 绑定引擎。
#[must_use]
pub const fn new(engine: &'a DownloadEngine) -> Self {
Self { engine }
}
}
impl Transport for EngineTransport<'_> {
fn get_text<'a>(&'a self, url: &'a str) -> OnlineFuture<'a, Result<String, OnlineError>> {
Box::pin(async move {
// `download_bytes` 不落盘(`dest` 只是请求形状的一部分),所以这里给一个中性路径;
// 内容寻址缓存按 URL 规划,不看它。
let request = DownloadRequest::new(
ResourceKind::Other,
url,
std::env::temp_dir().join("ull-online-metadata.json"),
);
let bytes = self
.engine
.download_bytes(request)
.await
.map_err(OnlineError::from)?;
String::from_utf8(bytes)
.map_err(|error| OnlineError::malformed(format!("{url} 的响应不是 UTF-8:{error}")))
})
}
}
+74
View File
@@ -0,0 +1,74 @@
//! 整合包的**格式无关模型**与**格式扩展点**(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, PACK_MAX_ENTRIES, PACK_MAX_ENTRY_BYTES,
PACK_MAX_UNCOMPRESSED_BYTES, PACK_ZIP64_BYTES_THR,
};
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;
+299
View File
@@ -0,0 +1,299 @@
//! 打包输入的安全读取层:[`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, PACK_MAX_ENTRIES, PACK_MAX_ENTRY_BYTES,
PACK_MAX_UNCOMPRESSED_BYTES, PACK_ZIP64_BYTES_THR,
};
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,196 @@
//! 读取限额:条目数 / 解压总体积 / 单条目,以及两处封顶实现。
//!
//! # 只此一份口径(导入读取 **与** 导出写入共用)
//!
//! [`PACK_MAX_ENTRIES`] / [`PACK_MAX_UNCOMPRESSED_BYTES`] / [`PACK_MAX_ENTRY_BYTES`] 是**唯一**
//! 的体积口径:读侧 [`PackLimits::default`] 从这里取,写侧 `pack::format::write` 的预检也从
//! 这里取(以前写侧直接拿 `zip::ZIP64_BYTES_THR`,于是"导得出来、导不回去"——导出允许 ~4 GiB,
//! 导入只允许 512 MiB)。现在的关系是硬约束:
//!
//! ```text
//! PACK_MAX_ENTRY_BYTES ≤ PACK_MAX_UNCOMPRESSED_BYTES < PACK_ZIP64_BYTES_THR ≤ u32::MAX
//! ```
//!
//! 所以「我们能写出的包一定能被自己读回来」,而 ZIP64 阈值退化成一条**不会再被触碰的**
//! 物理上限(`tests/limits.rs` 钉住这条不等式,也钉住两侧用的是同一批常数)。
//!
//! 为什么不让写侧跟着 ZIP64 放宽:读侧把单条目整条读进内存([`read_capped`]),64 MiB 是**内存**
//! 护栏;放宽它等于用一个看不见的内存峰值换一个几乎用不到的大包能力。
//!
//! # 两阶段限额(打开 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 PACK_MAX_ENTRIES: usize = 20_000;
/// 解压/打包总体积上限(512 MiB;`.mrpack` 的 `overrides` 通常几十 MiB)。
pub const PACK_MAX_UNCOMPRESSED_BYTES: u64 = 512 * 1024 * 1024;
/// 单条目上限(64 MiB;同时是读侧的**内存**护栏与写侧的单文件护栏)。
pub const PACK_MAX_ENTRY_BYTES: u64 = 64 * 1024 * 1024;
/// ZIP64 的物理阈值(`zip` crate 的口径,≈4 GiB)。
///
/// 生产路径**不再**用它当判据:PACK_MAX_UNCOMPRESSED_BYTES 远低于它(0.5 GiB ≪ 4 GiB),
/// 所以 `tests/limits.rs` 只需钉住"统一上限 < 本阈值",ZIP64 就永远不会被需要。
pub const PACK_ZIP64_BYTES_THR: u64 = zip::ZIP64_BYTES_THR;
/// 默认条目数上限(= [`PACK_MAX_ENTRIES`],保留旧名以免破坏既有调用点)。
pub const DEFAULT_MAX_ENTRIES: usize = PACK_MAX_ENTRIES;
/// 默认解压总体积上限(= [`PACK_MAX_UNCOMPRESSED_BYTES`])。
pub const DEFAULT_MAX_UNCOMPRESSED_BYTES: u64 = PACK_MAX_UNCOMPRESSED_BYTES;
/// 默认单条目上限(= [`PACK_MAX_ENTRY_BYTES`])。
pub const DEFAULT_MAX_ENTRY_BYTES: u64 = PACK_MAX_ENTRY_BYTES;
/// 包读取限额。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PackLimits {
/// 条目数上限。
pub max_entries: usize,
/// 解压总体积上限。
pub max_uncompressed_bytes: u64,
/// 单条目上限。
pub max_entry_bytes: u64,
}
impl PackLimits {
/// 三档显式给值(测试与"可疑包"复查用)。
#[must_use]
pub const fn new(
max_entries: usize,
max_uncompressed_bytes: u64,
max_entry_bytes: u64,
) -> Self {
Self {
max_entries,
max_uncompressed_bytes,
max_entry_bytes,
}
}
}
impl Default for PackLimits {
fn default() -> Self {
Self {
max_entries: DEFAULT_MAX_ENTRIES,
max_uncompressed_bytes: DEFAULT_MAX_UNCOMPRESSED_BYTES,
max_entry_bytes: DEFAULT_MAX_ENTRY_BYTES,
}
}
}
/// 一次批量读取的**累计**预算(读取阶段,按实际读出的字节扣减)。
///
/// 作用域是"拿到它的那次扫描":`PackArchive::override_files` 每次调用新建一份,
/// 所以要跨多次调用共享额度(例如整包只给一份 `max_uncompressed_bytes`),
/// 必须由调用方自建一个并交给 `override_files_with`(r2 p3-b)。
///
/// 为什么需要它:中央目录声明的大小可以撒谎,单个条目也被限制在
/// [`PackLimits::max_entry_bytes`],但"很多条各自合规"累起来仍可能远超
/// `max_uncompressed_bytes`。批量读取(`override_files`)持有一个预算,边读边扣。
#[derive(Debug)]
pub struct ReadBudget {
remaining: Cell<u64>,
}
impl ReadBudget {
/// 按总预算新建。
#[must_use]
pub fn new(limit: u64) -> Self {
Self {
remaining: Cell::new(limit),
}
}
/// 还剩多少字节。
#[must_use]
pub fn remaining(&self) -> u64 {
self.remaining.get()
}
/// 扣减一次实际读出的字节数;超额报 `pack_limits`。
///
/// 超额时预算**照样扣到 0**(这些字节已经从流里读出来了),后续调用会继续失败——
/// 不给"失败一次、下次又能读一点"的机会。
pub(super) fn spend(&self, bytes: u64, name: &str) -> PackResult<()> {
let remaining = self.remaining.get();
self.remaining.set(remaining.saturating_sub(bytes));
if bytes > remaining {
return Err(PackError::limits(format!(
"{name} 之后累计解压超过上限(本次还需 {bytes} 字节,预算已用尽)"
)));
}
Ok(())
}
}
/// 累计与单条目限额检查(打开阶段)。
pub(super) fn check_limits(
limits: &PackLimits,
total: &mut u64,
size: u64,
name: &str,
seen: usize,
) -> PackResult<()> {
if size > limits.max_entry_bytes {
return Err(PackError::limits(format!(
"条目 {name} 声明 {} 字节,超过单条目上限 {}",
size, limits.max_entry_bytes
)));
}
*total = total.saturating_add(size);
if *total > limits.max_uncompressed_bytes {
return Err(PackError::limits(format!(
"解压总体积超过上限 {} 字节(累计到 {total})",
limits.max_uncompressed_bytes
)));
}
if seen >= limits.max_entries {
return Err(PackError::limits(format!(
"条目数超过上限 {}",
limits.max_entries
)));
}
Ok(())
}
/// 带封顶的读:中央目录里声明的大小可以撒谎,这里按**实际读出的字节**封顶。
///
/// 给了 `budget` 就同时从累计预算里扣(批量读取);单条读取给 `None`,
/// 只受 [`PackLimits::max_entry_bytes`] 约束。
pub(super) fn read_capped(
reader: &mut impl Read,
cap: u64,
budget: Option<&ReadBudget>,
name: &str,
) -> PackResult<Vec<u8>> {
let mut bytes = Vec::new();
let mut buffer = [0_u8; 64 * 1024];
let mut total = 0_u64;
loop {
let read = reader
.read(&mut buffer)
.map_err(|error| PackError::io(format!("读 {name} 失败:{error}")))?;
if read == 0 {
break;
}
total += read as u64;
if total > cap {
return Err(PackError::limits(format!(
"{name} 实际解压超过单条目上限 {cap}"
)));
}
if let Some(budget) = budget {
budget.spend(read as u64, name)?;
}
bytes.extend_from_slice(&buffer[..read]);
}
Ok(bytes)
}
+158
View File
@@ -0,0 +1,158 @@
//! 打开与首轮校验:目录遍历 / zip 中央目录扫描,两道穿越检查、符号链接、限额都在这里。
use std::cell::RefCell;
use std::collections::HashMap;
use std::fs::File;
use std::path::PathBuf;
use zip::ZipArchive;
use super::entry::PackEntryInfo;
use super::limits::{check_limits, PackLimits};
use super::{PackArchive, Source};
use crate::addon::pack::error::{PackError, PackResult};
use crate::addon::pack::path::{has_trailing_slash, normalize_file};
impl PackArchive {
pub(super) fn open_dir(root: PathBuf, limits: PackLimits) -> PackResult<Self> {
let mut entries = Vec::new();
let mut total: u64 = 0;
// 栈式遍历:不递归,避免深层目录把栈打穿。
let mut pending = vec![String::new()];
while let Some(relative) = pending.pop() {
let dir = if relative.is_empty() {
root.clone()
} else {
root.join(&relative)
};
let mut children = std::fs::read_dir(&dir)
.map_err(|error| PackError::io(format!("读目录 {} 失败:{error}", dir.display())))?
.collect::<Result<Vec<_>, _>>()
.map_err(PackError::from)?;
children.sort_by_key(std::fs::DirEntry::file_name);
for child in children {
let file_name =
child
.file_name()
.to_str()
.map(str::to_string)
.ok_or_else(|| {
PackError::metadata(format!(
"包内文件名不是 UTF-8:{}",
child.path().display()
))
})?;
let raw = if relative.is_empty() {
file_name
} else {
format!("{relative}/{file_name}")
};
let file_type = child
.file_type()
.map_err(|error| PackError::io(format!("读 {} 类型失败:{error}", raw)))?;
if file_type.is_symlink() {
return Err(PackError::symlink(format!("整合包里有符号链接条目:{raw}")));
}
let name = normalize_file(&raw)?;
if file_type.is_dir() {
check_limits(&limits, &mut total, 0, &name, entries.len())?;
pending.push(name.clone());
entries.push(PackEntryInfo {
name,
raw,
size: 0,
is_dir: true,
});
continue;
}
if !file_type.is_file() {
continue;
}
let size = child
.metadata()
.map_err(|error| PackError::io(format!("读 {raw} 大小失败:{error}")))?
.len();
check_limits(&limits, &mut total, size, &name, entries.len())?;
entries.push(PackEntryInfo {
name,
raw,
size,
is_dir: false,
});
}
}
entries.sort_by(|left, right| left.name.cmp(&right.name));
Self::finish(root, Source::Dir, entries, limits)
}
pub(super) fn open_zip(root: PathBuf, limits: PackLimits) -> PackResult<Self> {
let file = File::open(&root)
.map_err(|error| PackError::io(format!("打开 {} 失败:{error}", root.display())))?;
let mut archive = ZipArchive::new(file)
.map_err(|error| PackError::metadata(format!("不是可读的 zip:{error}")))?;
if archive.len() > limits.max_entries {
return Err(PackError::limits(format!(
"zip 条目数 {} 超过上限 {}",
archive.len(),
limits.max_entries
)));
}
let mut entries = Vec::with_capacity(archive.len());
let mut total: u64 = 0;
for index in 0..archive.len() {
let entry = archive.by_index(index).map_err(|error| {
PackError::metadata(format!("读第 {index} 个条目失败:{error}"))
})?;
let Ok(raw) = entry.name() else {
return Err(PackError::metadata(format!(
"第 {index} 个条目名编码不合法"
)));
};
let raw = raw.to_string();
let is_dir = entry.is_dir() || has_trailing_slash(&raw);
if entry.is_symlink() {
return Err(PackError::symlink(format!("zip 里有符号链接条目:{raw}")));
}
let name = normalize_file(&raw)?;
// 第二检:zip crate 按 Windows 规则再解析一遍(盘符 / ADS / 保留设备名)。
if entry.enclosed_name().is_none() {
return Err(PackError::traversal(format!(
"zip 条目名过不了 enclosed_name 检查:{raw}"
)));
}
let size = if is_dir { 0 } else { entry.size() };
check_limits(&limits, &mut total, size, &name, entries.len())?;
entries.push(PackEntryInfo {
name,
raw,
size,
is_dir,
});
}
Self::finish(root, Source::Zip(RefCell::new(archive)), entries, limits)
}
fn finish(
root: PathBuf,
source: Source,
entries: Vec<PackEntryInfo>,
limits: PackLimits,
) -> PackResult<Self> {
let mut index = HashMap::with_capacity(entries.len());
for (slot, entry) in entries.iter().enumerate() {
if index.insert(entry.name.clone(), slot).is_some() {
return Err(PackError::metadata(format!(
"包里有规整后重名的条目:{}",
entry.name
)));
}
}
Ok(Self {
root,
source,
entries,
index,
limits,
})
}
}
+339
View File
@@ -0,0 +1,339 @@
//! CurseForge 格式:`manifest.json` + `overrides/`。
//!
//! # 形状(A 级来源:HMCL `CurseManifest.java` / `CurseManifestFile.java`,见 analyse/05 §2.2)
//!
//! ```jsonc
//! {
//! "manifestType": "minecraftModpack", "manifestVersion": 1,
//! "name": "…", "version": "1.0.0", "author": "…",
//! "overrides": "overrides",
//! "minecraft": {"version": "1.20.1", "modLoaders": [{"id": "forge-47.2.0", "primary": true}]},
//! "files": [{"projectID": 238222, "fileID": 5102025, "required": true}]
//! }
//! ```
//!
//! 关键缺口(**模型如实记录,不假装有**):
//!
//! - `files[]` **没有路径、没有 URL、没有哈希**。路径要按项目 `classId` 猜(`mods/` /
//! `resourcepacks/` / `shaderpacks/`),文件名要按 ForgeCDN 规则或 API 补——这正是 D10 §2
//! 两阶段补全要补的东西,属于 M111,不在本模块。所以 [`PackFile::path`] 读出来是 `None`。
//! - `minecraft.modLoaders[].id` 只认 `forge-` / `neoforge-` / `fabric-` / `quilt-` 前缀
//! (CurseForge 没有 Quilt 的正规表达方式);不认识的 id **原样保留**在
//! [`PackLoader::raw`],写回时逐字还原。
//! - 只有一个 `required` 布尔 → 客户端/服务端同值;导出时 `client == Optional` 或
//! `disabled` 即 `required: false`。`client`/`server` 分侧语义 CF 表达不了(mrpack 专属)。
//! - **跨格式导出不完整**(M112 的活):`modLoaders[].id` 只对 CF 来源是合法形状;
//! MultiMC 的 `net.fabricmc.fabric-loader` / 非加载器组件会被逐字写进去(CF 不认);
//! `overrides` 字段会照抄模型里的目录名(从 MultiMC 转过来是 `.minecraft`)。
//! 按 CF 语义 `overrides` 只是 zip 内**前缀**,导入器把该前缀下的内容铺到游戏目录,
//! 所以内容不会丢;但"前缀目录本身要不要跟着拷一层"取决于导入器实现(Prism/ULL 的实例根
//! 本身就叫 `.minecraft` 时可能多出一层)。同一份模型只有 `read` 出来的那个格式才保证往返。
//!
//! CF 的**文件指纹**(`MurmurHash2-32` + 空白过滤)是导出侧「能否被远程替代」的判据,见
//! [`fingerprint`]:它是"CF 格式知识",不属于导出流程,所以留在这里而不是 `addon::export`。
pub mod fingerprint;
use serde::Deserialize;
use serde_json::{json, Map, Value};
use super::archive::PackArchive;
use super::error::{PackError, PackResult};
use super::format::PackFormat;
use super::manifest::{
merge_unknown, EnvSupport, PackFile, PackLoader, PackManifest, PackOverrides, PackSide,
};
use super::path::normalize_dir;
/// 格式 id。
pub const ID: &str = "curseforge";
/// 清单入口名。
pub const METADATA_ENTRY: &str = "manifest.json";
/// `manifestType` 的固定值。
pub const MARKER: &str = "minecraftModpack";
/// 默认覆盖目录名。
pub const DEFAULT_OVERRIDES: &str = "overrides";
/// 单例(进注册表用)。
pub static CURSEFORGE: CurseForge = CurseForge;
/// CurseForge 格式的值类型实现。
#[derive(Debug, Clone, Copy, Default)]
pub struct CurseForge;
/// `manifest.json` 的原始形状(未知字段落 `extra`)。
#[derive(Debug, Default, Deserialize)]
struct RawManifest {
#[serde(rename = "manifestType", default)]
manifest_type: Option<String>,
#[serde(rename = "manifestVersion", default)]
manifest_version: Option<i64>,
#[serde(default)]
name: Option<String>,
#[serde(default)]
version: Option<String>,
#[serde(default)]
author: Option<String>,
#[serde(default)]
overrides: Option<String>,
#[serde(default)]
minecraft: Option<RawMinecraft>,
#[serde(default)]
files: Vec<RawFile>,
#[serde(flatten)]
extra: Map<String, Value>,
}
/// `minecraft` 对象。
#[derive(Debug, Default, Deserialize)]
struct RawMinecraft {
#[serde(default)]
version: Option<String>,
#[serde(rename = "modLoaders", default)]
mod_loaders: Vec<RawLoader>,
#[serde(flatten)]
extra: Map<String, Value>,
}
/// `minecraft.modLoaders[]` 的一条。
#[derive(Debug, Default, Deserialize)]
struct RawLoader {
#[serde(default)]
id: Option<String>,
#[serde(default)]
primary: bool,
#[serde(flatten)]
extra: Map<String, Value>,
}
/// `files[]` 的一条。
#[derive(Debug, Default, Deserialize)]
struct RawFile {
#[serde(rename = "projectID", default)]
project_id: Option<i64>,
#[serde(rename = "fileID", default)]
file_id: Option<i64>,
#[serde(default = "default_required")]
required: bool,
#[serde(flatten)]
extra: Map<String, Value>,
}
/// CF 的 `required` 缺省是 `true`(老包可能不写)。
const fn default_required() -> bool {
true
}
impl PackFormat for CurseForge {
fn id(&self) -> &'static str {
ID
}
fn label(&self) -> &'static str {
"CurseForge"
}
fn metadata_entry(&self) -> Option<&'static str> {
Some(METADATA_ENTRY)
}
fn detect(&self, pack: &PackArchive) -> PackResult<Option<&'static str>> {
// 文件不存在 ≠ 包坏了:没有 `manifest.json` 就是"不是本格式"。
let Some(value) = pack.read_json(METADATA_ENTRY)? else {
return Ok(None);
};
// 文件在、但不是对象 = 形状坏了(`pack_metadata`),不是"不认识"(r1 p3-2)。
let object = value
.as_object()
.ok_or_else(|| PackError::metadata(format!("{METADATA_ENTRY} 存在但不是 JSON 对象")))?;
let matched = match object.get("manifestType").and_then(Value::as_str) {
// 有标记:只认固定值(`manifest.json` 这名字太泛,光凭名字不能认领)。
Some(kind) => kind == MARKER,
// 没有标记的老包:要求形状对得上(有 minecraft 对象 + files 数组)。
None => {
object.get("minecraft").is_some_and(Value::is_object)
&& object.get("files").is_some_and(Value::is_array)
}
};
Ok(matched.then_some(METADATA_ENTRY))
}
fn check_overrides(&self, manifest: &PackManifest) -> PackResult<()> {
// CF 只有**一个** `overrides` 目录,且清单里只有一个字段能声明它 ⇒ 只表达得了
// 一个 Both 侧组。Client/Server 分侧是 mrpack 的东西,这里宁可拒也不猜(r1 p2-1)。
let mut both = 0_usize;
for group in &manifest.overrides {
if group.side != PackSide::Both {
return Err(PackError::unsupported(format!(
"CurseForge 只有一个 overrides 目录,表达不了 {} 侧的覆盖内容({} 个文件)",
group.side.as_str(),
group.files.len()
))
.with_hint("先按侧合并(或去掉不该带的组)再导出;分侧目录映射属 M112"));
}
both += 1;
}
if both > 1 {
return Err(PackError::unsupported(
"CurseForge 只有一个 overrides 目录字段,模型里却有多个 Both 侧覆盖组",
)
.with_hint("把它们合成一个组再导出;目录名映射属 M112"));
}
Ok(())
}
fn read(&self, pack: &PackArchive) -> PackResult<PackManifest> {
let value = pack
.read_json(METADATA_ENTRY)?
.ok_or_else(|| PackError::metadata(format!("包里没有 {METADATA_ENTRY}")))?;
let raw: RawManifest = serde_json::from_value(value)
.map_err(|error| PackError::metadata(format!("{METADATA_ENTRY} 形状不符:{error}")))?;
if let Some(kind) = &raw.manifest_type {
if kind != MARKER {
return Err(PackError::unsupported(format!(
"{METADATA_ENTRY} 的 manifestType 是 {kind:?},不是 {MARKER:?}"
)));
}
}
let name = raw
.name
.filter(|name| !name.trim().is_empty())
.ok_or_else(|| PackError::metadata(format!("{METADATA_ENTRY} 缺 name")))?;
let minecraft = raw.minecraft.unwrap_or_default();
let game_version = minecraft
.version
.filter(|version| !version.trim().is_empty())
.ok_or_else(|| PackError::metadata(format!("{METADATA_ENTRY} 缺 minecraft.version")))?;
let loaders = minecraft
.mod_loaders
.into_iter()
.map(|loader| {
let mut parsed = PackLoader::parse_curse(
loader.id.as_deref().unwrap_or_default(),
loader.primary,
);
parsed.extra = loader.extra;
parsed
})
.collect();
let files = raw
.files
.into_iter()
.map(|file| {
let env = if file.required {
EnvSupport::Required
} else {
EnvSupport::Optional
};
PackFile {
project_id: file.project_id,
file_id: file.file_id,
client: env,
server: env,
extra: file.extra,
..PackFile::default()
}
})
.collect();
// CF 的 `overrides` 字段缺省就是 `"overrides"`(规范默认值),`render` 也总会写它;
// 读侧因此**永远**产出一个 Both 侧组,读写才对称(dir 不存在时 files 为空表)。
let overrides_dir = raw
.overrides
.as_deref()
.filter(|dir| !dir.trim().is_empty())
.unwrap_or(DEFAULT_OVERRIDES);
let overrides_dir = normalize_dir(overrides_dir)?;
let overrides = vec![PackOverrides {
files: pack.override_files(&overrides_dir)?,
dir: overrides_dir,
side: PackSide::Both,
}];
Ok(PackManifest {
name,
version: raw.version,
author: raw.author,
summary: None,
format_version: raw.manifest_version,
game: None,
minecraft: Some(game_version),
loaders,
files,
overrides,
extra: raw.extra,
minecraft_extra: minecraft.extra,
})
}
fn render(&self, manifest: &PackManifest) -> PackResult<String> {
let game_version = manifest.minecraft.as_deref().ok_or_else(|| {
PackError::metadata("CurseForge 清单需要 minecraft.version,模型里没有")
})?;
let mut root = Map::new();
root.insert("manifestType".into(), json!(MARKER));
root.insert(
"manifestVersion".into(),
json!(manifest.format_version.unwrap_or(1)),
);
root.insert("name".into(), json!(manifest.name));
if let Some(version) = &manifest.version {
root.insert("version".into(), json!(version));
}
if let Some(author) = &manifest.author {
root.insert("author".into(), json!(author));
}
let overrides_dir = manifest
.overrides_for(PackSide::Both)
.map_or(DEFAULT_OVERRIDES, |group| group.dir.as_str());
root.insert("overrides".into(), json!(overrides_dir));
let mut minecraft = Map::new();
minecraft.insert("version".into(), json!(game_version));
let mut mod_loaders = Vec::with_capacity(manifest.loaders.len());
for loader in &manifest.loaders {
let id = loader.curse_id_for(Some(game_version)).ok_or_else(|| {
PackError::unsupported(format!(
"CurseForge 表达不了这条加载器依赖:raw={:?} kind={:?} version={:?}",
loader.raw, loader.kind, loader.version
))
})?;
let mut object = Map::new();
object.insert("id".into(), json!(id));
object.insert("primary".into(), json!(loader.primary));
merge_unknown(&mut object, &loader.extra);
mod_loaders.push(Value::Object(object));
}
minecraft.insert("modLoaders".into(), Value::Array(mod_loaders));
merge_unknown(&mut minecraft, &manifest.minecraft_extra);
root.insert("minecraft".into(), Value::Object(minecraft));
let mut files = Vec::with_capacity(manifest.files.len());
for file in &manifest.files {
let (Some(project_id), Some(file_id)) = (file.project_id, file.file_id) else {
return Err(PackError::unsupported(format!(
"CurseForge 的 files[] 需要 projectID/fileID,这条没有(path={:?})",
file.path
))
.with_hint("先用 CurseForge API 补全条目再导出,或改用 Modrinth 格式"));
};
let mut object = Map::new();
object.insert("projectID".into(), json!(project_id));
object.insert("fileID".into(), json!(file_id));
// CF 只有一个 `required` 布尔:`optional` 与"禁用"都只能落到 `false`
// (Prism 也是用 `required: false` 表达禁用的,见 M115 调研 §6)。
object.insert(
"required".into(),
json!(!(file.client.is_optional() || file.disabled)),
);
merge_unknown(&mut object, &file.extra);
files.push(Value::Object(object));
}
root.insert("files".into(), Value::Array(files));
merge_unknown(&mut root, &manifest.extra);
serde_json::to_string_pretty(&Value::Object(root)).map_err(Into::into)
}
}
@@ -0,0 +1,262 @@
//! CurseForge 文件指纹(`MurmurHash2-32` + 空白字节过滤),导出侧的「能否被远程替代」判据之一。
//!
//! # 算法(A 级规范缺失,这里是 B 级实现 + 双向验证)
//!
//! ```text
//! data = 原文件去掉所有 0x09 / 0x0A / 0x0D / 0x20 字节
//! fingerprint = MurmurHash2_32(data, seed = 1, len = data 的长度)
//! ```
//!
//! 三条容易写错的点,逐条钉住:
//!
//! 1. **长度用过滤后的长度**参与初始态(`h = seed ^ len`),不是原文件长度;
//! 2. **seed 恒为 1**(MurmurHash2 的默认 seed 是 `0x9747b28c`,那是别的调用方的约定);
//! 3. 过滤是**逐字节**的,只在 0x09/0x0A/0x0D/0x20 上生效——不是"去空白"(`0x0B` 竖tab、
//! `0x0C` 换页都保留)。
//!
//! # 来源与验证
//!
//! CurseForge 官方只公开 `POST /v1/fingerprints/432` 端点,**没有公开算法**(A 级规范不存在)。
//! 这里的实现对齐 B 级参考 `HMCL CurseForgeRemoteAddonRepository.calculateFingerprint`
//! 与 `MurmurHash2.hash32(byte[], int, int)`(SMHasher `MurmurHash2.cpp` 的忠实转写)。
//!
//! **双向验证**(`tests` 模块):一组固定输入的金标准值由一份**独立的 Java 实现**(HMCL 两份
//! 源码的逐行移植,不在本仓库内)算出后写死在这里;Rust 侧同时跑「一次性字节序」与「流式文件」
//! 两条路径,两条都要与金标准相等。所以这一层是"两套实现 + 两个入口"的四向对齐,而不是自证。
//! 仍未验的部分:**真实 CF 文件**的官方指纹(要网络 + `x-api-key`,本任务环境离线)。
//!
//! # 哨兵值 `811513880`
//!
//! 这个值会误命中(HMCL issue #4597 的临时规避):无论文件是什么,只要算出这个数就不能当成
//! "已找到"。判据写成 [`is_sentinel`],由调用方(导出管线)在查表前短路。
use std::fs::File;
use std::io::{self, Read};
use std::path::Path;
/// 误命中的哨兵指纹(HMCL issue #4597)。命中时按「查不到」处理。
pub const SENTINEL: u32 = 811_513_880;
/// 被过滤掉的四个字节:水平制表、换行、回车、空格。
pub const FILTERED: [u8; 4] = [0x09, 0x0a, 0x0d, 0x20];
/// 指纹查表时是否该按「没找到」处理。
#[must_use]
pub const fn is_sentinel(fingerprint: u32) -> bool {
fingerprint == SENTINEL
}
/// 这个字节参与计算吗(`0x09` / `0x0A` / `0x0D` / `0x20` 不参与)。
#[must_use]
pub const fn keeps(byte: u8) -> bool {
!matches!(byte, 0x09 | 0x0a | 0x0d | 0x20)
}
/// 对**内存里的一份内容**算指纹(过滤 + 一次性哈希)。
///
/// 小文件与测试用;大文件请用 [`fingerprint_file`](两遍流式读,不把整份读进内存)。
#[must_use]
pub fn fingerprint_bytes(bytes: &[u8]) -> u32 {
let filtered: Vec<u8> = bytes.iter().copied().filter(|byte| keeps(*byte)).collect();
murmur2(&filtered)
}
/// 对一个**文件**算指纹:两遍流式读(先数过滤后长度,再喂哈希)。
///
/// MurmurHash2 的初始态要事先知道总长度,所以必须两遍;这里不把文件读进内存(mod jar 可以
/// 有几百 MB,整包导出要对每个文件算一次)。
pub fn fingerprint_file(path: &Path) -> io::Result<u32> {
let mut filtered_length = 0_u64;
let mut buffer = vec![0_u8; 1024 * 1024];
let mut file = File::open(path)?;
loop {
let read = file.read(&mut buffer)?;
if read == 0 {
break;
}
filtered_length += buffer[..read].iter().filter(|byte| keeps(**byte)).count() as u64;
}
// 长度参数是 u32(MurmurHash2-32 的初始态),超了要报错而不是截断。
let length = u32::try_from(filtered_length).map_err(|_| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("文件过滤后仍有 {filtered_length} 字节,超出指纹长度参数(u32)上限"),
)
})?;
let mut hasher = Stream::new(length, 1);
file = File::open(path)?;
loop {
let read = file.read(&mut buffer)?;
if read == 0 {
break;
}
hasher.update(&buffer[..read]);
}
Ok(hasher.finish())
}
/// MurmurHash2 32 位,seed 固定 1(CurseForge 的约定)。
///
/// 实现是 SMHasher `MurmurHash2.cpp` 的形状:按 4 字节小端块混合,尾部 1–3 字节按小端补齐后
/// 再乘一次;收尾两次移位混合。**不在这里做字节过滤**(调用方先过滤,见 [`fingerprint_bytes`])。
#[must_use]
pub fn murmur2(data: &[u8]) -> u32 {
const M: u32 = 0x5bd1_e995;
const R: u32 = 24;
let mut h = 1_u32 ^ (data.len() as u32);
let blocks = data.len() / 4;
for index in 0..blocks {
let mut k = u32::from_le_bytes([
data[index * 4],
data[index * 4 + 1],
data[index * 4 + 2],
data[index * 4 + 3],
]);
k = k.wrapping_mul(M);
k ^= k >> R;
k = k.wrapping_mul(M);
h = h.wrapping_mul(M);
h ^= k;
}
let tail = &data[blocks * 4..];
if !tail.is_empty() {
let mut packed = 0_u32;
for (shift, byte) in tail.iter().enumerate() {
packed |= u32::from(*byte) << (8 * shift);
}
h ^= packed;
h = h.wrapping_mul(M);
}
h ^= h >> 13;
h = h.wrapping_mul(M);
h ^= h >> 15;
h
}
/// 流式 MurmurHash2:长度先知道,字节可以分任意多段喂进来。
struct Stream {
hash: u32,
tail: u32,
tail_len: u32,
}
impl Stream {
const fn new(length: u32, seed: u32) -> Self {
Self {
hash: seed ^ length,
tail: 0,
tail_len: 0,
}
}
fn update(&mut self, bytes: &[u8]) {
const M: u32 = 0x5bd1_e995;
const R: u32 = 24;
for byte in bytes {
if !keeps(*byte) {
continue;
}
self.tail |= u32::from(*byte) << (8 * self.tail_len);
self.tail_len += 1;
if self.tail_len == 4 {
let mut k = self.tail;
k = k.wrapping_mul(M);
k ^= k >> R;
k = k.wrapping_mul(M);
self.hash = self.hash.wrapping_mul(M);
self.hash ^= k;
self.tail = 0;
self.tail_len = 0;
}
}
}
fn finish(mut self) -> u32 {
const M: u32 = 0x5bd1_e995;
if self.tail_len != 0 {
self.hash ^= self.tail;
self.hash = self.hash.wrapping_mul(M);
}
self.hash ^= self.hash >> 13;
self.hash = self.hash.wrapping_mul(M);
self.hash ^= self.hash >> 15;
self.hash
}
}
#[cfg(test)]
mod tests {
use super::*;
/// 金标准值由一份独立的 Java 实现(HMCL `MurmurHash2.hash32` + `calculateFingerprint` 的
/// 逐行移植)对同一批输入算出。改了算法这两列会同时红——这就是"双向验证"的用处。
const GOLDEN: &[(&str, &[u8], u32)] = &[
("空文件", b"", 1_540_447_798),
("只有空白", b" \t\r\n \t\r\n", 1_540_447_798),
(
"夹具 jar 存根",
b"PK\x03\x04 hand-made jar stub",
489_715_614,
),
("混入空白", b"\x00\t\n\r AB\tC\n\r \x7f\xff", 3_734_724_774),
(
"ascii 36",
b"abcdefghijklmnopqrstuvwxyz0123456789",
3_026_742_571,
),
];
fn pattern(length: usize) -> Vec<u8> {
(0..length).map(|index| (index & 0xff) as u8).collect()
}
#[test]
fn matches_the_independent_implementation() {
for (label, bytes, expected) in GOLDEN {
assert_eq!(fingerprint_bytes(bytes), *expected, "{label}(一次性路径)");
}
assert_eq!(
fingerprint_bytes(&pattern(512)),
3_451_065_282,
"pattern-512"
);
assert_eq!(fingerprint_bytes(&pattern(5)), 813_985_283, "pattern-5");
assert_eq!(fingerprint_bytes(&pattern(7)), 2_174_351_703, "pattern-7");
}
#[test]
fn filtered_length_enters_the_initial_state() {
// 原文件长度 8、过滤后长度 0:与空文件同值。用原长度当 len 会得到另一个数(这里不是)。
assert_eq!(fingerprint_bytes(b" \t\r\n \t\r\n"), fingerprint_bytes(b""),);
assert_ne!(fingerprint_bytes(b"aaaaaaaa"), fingerprint_bytes(b"aaaa"),);
}
#[test]
fn only_the_four_documented_bytes_are_dropped() {
assert!(!keeps(0x09) && !keeps(0x0a) && !keeps(0x0d) && !keeps(0x20));
// 竖 tab、换页、NUL 都保留:过滤是四个字节,不是"去空白"。
assert!(keeps(0x0b) && keeps(0x0c) && keeps(0x00));
}
#[test]
fn streaming_and_one_shot_agree_at_every_split() {
let data = pattern(64);
let expected = fingerprint_bytes(&data);
for split in 0..=data.len() {
let mut hasher =
Stream::new(data.iter().filter(|byte| keeps(**byte)).count() as u32, 1);
hasher.update(&data[..split]);
hasher.update(&data[split..]);
assert_eq!(hasher.finish(), expected, "split={split}");
}
}
#[test]
fn sentinel_is_treated_as_missing() {
assert!(is_sentinel(SENTINEL));
assert!(!is_sentinel(0));
assert!(!is_sentinel(SENTINEL + 1));
}
}
+161
View File
@@ -0,0 +1,161 @@
//! 整合包失败分类:**可诊断**比"报了个错"重要。
//!
//! 每一类都对应导入链路上一个**不同的处置动作**(M111 据此分级):
//!
//! | 大类 | 稳定码 | 谁该负责 |
//! |------|--------|----------|
//! | [`PackErrorKind::Unsupported`] | `pack_unsupported` | 换格式 / 让用户手工处理(没格式认领、或模型表达不了该格式) |
//! | [`PackErrorKind::Metadata`] | `pack_metadata` | 包本身坏了(JSON 形状不符、哈希缺失),重试无意义 |
//! | [`PackErrorKind::Traversal`] | `pack_traversal` | **拒绝**,且不写任何文件(恶意包) |
//! | [`PackErrorKind::Symlink`] | `pack_symlink` | **拒绝**(符号链接条目在 Windows 上无合法用途) |
//! | [`PackErrorKind::Limits`] | `pack_limits` | **拒绝**(条数/体积超限,zip 炸弹防线) |
//! | [`PackErrorKind::Io`] | `pack_io` | 环境问题,可重试 |
//!
//! 文案与日志分离:`message()` 给人看,`kind().as_str()` 进事件流与 `--json`。
use std::fmt;
/// 整合包失败的大类。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PackErrorKind {
/// 没有格式认领这份包,或模型里的信息不足以写成该格式。
Unsupported,
/// 清单 JSON 坏了 / 形状不符 / 必需字段缺失。
Metadata,
/// 包内路径穿越(绝对路径、`..` 上跳、盘符、ADS、反斜杠分隔)。
Traversal,
/// zip 里有符号链接条目。
Symlink,
/// 条目数 / 解压体积超过限额。
Limits,
/// 本地 IO。
Io,
}
impl PackErrorKind {
/// 稳定字符串 id(进 `--json` 的 `error.code` 与日志,**不要改名**)。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Unsupported => "pack_unsupported",
Self::Metadata => "pack_metadata",
Self::Traversal => "pack_traversal",
Self::Symlink => "pack_symlink",
Self::Limits => "pack_limits",
Self::Io => "pack_io",
}
}
}
/// 整合包错误:大类 + 人读说明 + 可选下一步提示。
#[derive(Debug, Clone)]
pub struct PackError {
kind: PackErrorKind,
message: String,
hint: Option<String>,
}
impl PackError {
/// 构造。
#[must_use]
pub fn new(kind: PackErrorKind, message: impl Into<String>) -> Self {
Self {
kind,
message: message.into(),
hint: None,
}
}
/// 没格式认领 / 模型表达不了该格式。
#[must_use]
pub fn unsupported(message: impl Into<String>) -> Self {
Self::new(PackErrorKind::Unsupported, message)
}
/// 清单形状不对 / 必填字段缺失。
#[must_use]
pub fn metadata(message: impl Into<String>) -> Self {
Self::new(PackErrorKind::Metadata, message)
}
/// 路径穿越。
#[must_use]
pub fn traversal(message: impl Into<String>) -> Self {
Self::new(PackErrorKind::Traversal, message)
}
/// 符号链接条目。
#[must_use]
pub fn symlink(message: impl Into<String>) -> Self {
Self::new(PackErrorKind::Symlink, message)
}
/// 超过限额。
#[must_use]
pub fn limits(message: impl Into<String>) -> Self {
Self::new(PackErrorKind::Limits, message)
}
/// 本地 IO。
#[must_use]
pub fn io(message: impl Into<String>) -> Self {
Self::new(PackErrorKind::Io, message)
}
/// 补一句提示。
#[must_use]
pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
self.hint = Some(hint.into());
self
}
/// 大类。
#[must_use]
pub const fn kind(&self) -> PackErrorKind {
self.kind
}
/// 人读说明。
#[must_use]
pub fn message(&self) -> &str {
&self.message
}
/// 下一步提示。
#[must_use]
pub fn hint(&self) -> Option<&str> {
self.hint.as_deref()
}
}
impl fmt::Display for PackError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.message)
}
}
impl std::error::Error for PackError {}
impl From<std::io::Error> for PackError {
fn from(error: std::io::Error) -> Self {
Self::io(error.to_string())
}
}
impl From<zip::result::ZipError> for PackError {
fn from(error: zip::result::ZipError) -> Self {
match error {
zip::result::ZipError::FileNotFound => Self::io("包内条目不存在"),
other => Self::metadata(format!("zip 读取失败:{other}")),
}
}
}
impl From<serde_json::Error> for PackError {
fn from(error: serde_json::Error) -> Self {
Self::metadata(format!("JSON 解析失败:{error}"))
}
}
/// `pack` 模块内的结果别名。
pub type PackResult<T> = Result<T, PackError>;
+355
View File
@@ -0,0 +1,355 @@
//! 格式扩展点:[`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;
/// 测试用的限额记录钩子(`probe.rs`,**只在 `cfg(test)` 里编译**):把"生产 `write()`
/// 到底传了哪份限额"变成可观测的,见该模块文档。
#[cfg(test)]
pub(in crate::addon::pack) mod probe;
/// 导出时的覆盖内容来源在 [`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<()> {
// 生产口径只有一处来源:导入读取用的也是这三个数(见 `archive::limits`)。
write_with_limits(
self,
manifest,
payload,
target,
write::WriteLimits::production(),
)
}
}
/// [`PackFormat::write`] 的实体:限额由调用方给。
///
/// # 为什么把 limits 做成参数
///
/// 预检的判据是「**声明尺寸** ≥ 上限」,与夹具真实大小无关。生产路径只传
/// [`write::WriteLimits::production`];测试传几百字节的小值,走的是**同一条**
/// `write::preflight` 分支——这样"闸在碰内容之前"不必再造 GiB 级夹具来证明。
/// (旧版测试用 `set_len` 造"稀疏"大文件,实测在 NTFS 上**不是稀疏的**:可用空间真的
/// 掉了那么多,见 `tests/zip64.rs` 的模块文档。)
pub(in crate::addon::pack) fn write_with_limits<F: PackFormat + ?Sized>(
format: &F,
manifest: &PackManifest,
payload: PackPayload<'_>,
target: &Path,
limits: write::WriteLimits,
) -> PackResult<()> {
// 测试构建里记一笔"这次传的是哪份限额":夹具缩小之后,只有它能把
// 「生产 `write()` 传的就是 `WriteLimits::production()`」这件事变成可观测的(见 `probe`)。
#[cfg(test)]
probe::record(limits);
let Some(entry) = format.metadata_entry() else {
return Err(PackError::unsupported(format!(
"格式 {}({})只支持读取,不支持导出",
format.id(),
format.label()
))
.with_hint("导出请选 Modrinth(.mrpack)或 CurseForge 格式"));
};
// 先判"能不能表达",再碰磁盘:跨格式导出宁可报错,也不产出空包。
format.check_overrides(manifest)?;
// 模型可能来自外部构造:搬内容前把每条路径重查一遍。
manifest.check_paths()?;
let json = format.render(manifest)?;
// 临时文件 + rename:中途失败时目标路径上不会留下"看起来能用"的半个 zip(r1 p3-1)。
let temp = write::temporary_path(target);
match write::container(format.id(), entry, &json, manifest, payload, &temp, limits) {
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)
}
}
}
/// 测试要能自己给 limits:把 `write` 子模块里的类型借出来(生产代码不走这条)。
#[cfg(test)]
pub(in crate::addon::pack) use write::WriteLimits;
/// 一次探测的结果。
#[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,45 @@
//! **只在测试构建里存在**的限额记录钩子(p2-1,复审 r1)。
//!
//! # 为什么需要它
//!
//! M166 把夹具从 GiB 缩到 KiB 之后,「生产路径 + 真超限内容」那种顺带守住生产限额的证法没了。
//! 于是「[`super::PackFormat::write`] 到底把哪份限额交给 [`super::write_with_limits`]」
//! **没有任何测试能观测**:把它换成 `WriteLimits::new(u64::MAX, u64::MAX, …)` 会全绿
//! (复审 r1 实测 606 passed)。`the_production_limits_are_the_shared_constants` 只钉了
//! `production()` 的**返回值**,没钉 `write()` 的**调用点**。
//!
//! 所以这里加一条捕获缝:`write_with_limits` 进门时记一笔,测试走**生产**的
//! `CURSEFORGE.write(…)` 之后断言记到的就是 [`super::write::WriteLimits::production`]。
//!
//! # 三条纪律
//!
//! 1. **只在 `cfg(test)` 里编译**:发布构建里这个模块不存在,`write_with_limits` 的签名也没动;
//! 2. 记录是**线程局部**的:`cargo test` 每个用例一个线程,并行跑也不会串味;
//! 3. 这是**测试设施**,不是给生产留的"放宽限额"开关——生产路径只有
//! [`super::write::WriteLimits::production`] 一个来源。
use std::cell::Cell;
use super::write::WriteLimits;
thread_local! {
/// 本线程最近一次 [`super::write_with_limits`] 收到的限额。
///
/// `#[allow]` 贴在**这个 `static`** 上(M170):clippy 1.99 的
/// `missing_const_for_thread_local` 在初始化**已经是** `const { … }` 的写法上仍然报误报
/// (最小复现 `static A: Cell<Option<u64>> = const { Cell::new(None) };` 一样红),
/// 所以要压掉。放在这里是最窄的作用域;反过来,外挂在 `thread_local!` **本身**上的
/// `#[allow]` 才是无效的(被宏展开吞掉,报成 unused attribute)。
#[allow(clippy::missing_const_for_thread_local)]
static LAST_LIMITS: Cell<Option<WriteLimits>> = const { Cell::new(None) };
}
/// 记一笔(由 [`super::write_with_limits`] 进门时调用)。
pub(super) fn record(limits: WriteLimits) {
LAST_LIMITS.with(|slot| slot.set(Some(limits)));
}
/// 本线程最近一次记到的限额(`None` = 还没走过 `write_with_limits`)。
pub(in crate::addon::pack) fn last_limits() -> Option<WriteLimits> {
LAST_LIMITS.with(Cell::get)
}
Loaded 100 of 1580 files, more files were not shown because too many files have changed in this diff. Show more