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
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:
commit
a5d1dec495
1580 files changed
+320938
No files matched your search
@@ -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 = []
|
||||
@@ -0,0 +1,65 @@
|
||||
//! 整合包**导出**(M112):实例目录 → `.mrpack`(优先)/ CurseForge `manifest.json`(次之)。
|
||||
//!
|
||||
//! 设计依据:`DevDocs/PlanDoc/08-mod-and-recommend.md` §2(D10)、
|
||||
//! `DevDocs/SubAgentsTalk/addon-整合包导入导出对接要点.md` §4(导出八条),格式模型与安全闸
|
||||
//! 是 M110 的 [`crate::addon::pack`],本模块**只消费,不重写**。
|
||||
//!
|
||||
//! # 一句话口径:不认识的 mod 不能丢、不能编
|
||||
//!
|
||||
//! 用户原话(M112 任务书):「要么能证明它可被远程替代,要么就必须内联进 `overrides/` 并在
|
||||
//! 结果里逐条报告」。落到代码就是三分类,**没有第四类**:
|
||||
//!
|
||||
//! | 分类 | 判据 | 产物 |
|
||||
//! |------|------|------|
|
||||
//! | 远程引用([`FileClass::Remote`]) | 目标格式**能表达**它 + 某个目录([`RemoteCatalog`])**能证明**它的身份 | 清单 `files[]` 一条 |
|
||||
//! | 内联([`FileClass::Inline`]) | 证明不了,或证明了但目标格式表达不了 | 进 `overrides/`,原字节照搬 |
|
||||
//! | 跳过([`FileClass::Skipped`]) | 读不了 / 是符号链接 / 路径不安全 | 不写进包,**逐条**进报告 |
|
||||
//!
|
||||
//! 「能证明」用两条独立查询(契约 §4.4 的顺序):**Modrinth SHA-1** →
|
||||
//! [`RemoteCatalog::modrinth_by_sha1`];**CurseForge 指纹**([`crate::addon::pack::curseforge::fingerprint`])
|
||||
//! → [`RemoteCatalog::curseforge_by_fingerprint`]。两条都命中时两条直链都进 `downloads[]`
|
||||
//! (HMCL 同款,兼容面更大)。**没有网络时**用 [`OfflineCatalog`]:所有文件都内联,包照样完整可导入
|
||||
//! ——这不是降级失败,是"证明不了就内联"的正面行为。
|
||||
//!
|
||||
//! # 每条格式决策落在哪(tower 定案,别按任务书原话改回去)
|
||||
//!
|
||||
//! 1. **`.disabled` 走路径后缀**(`mods/x.jar.disabled`),不是 `env.client=optional`:后者只是
|
||||
//! "安装时可取消勾选",装回来默认仍然启用 ⇒ 跨启动器不保真。官方 Modrinth 导出也是后缀。
|
||||
//! 2. **覆盖目录一律 `overrides/`**(不用 HMCL 那套 `client-overrides/`):Modrinth 官方与
|
||||
//! Prism 都用它,兼容面最大。分侧语义在本轮导出里不产生(实例目录本身不分侧)。
|
||||
//! 3. **`game` + `dependencies` 从实例快照取**,绝不只写 `minecraft` 漏加载器:快照来自
|
||||
//! `versions/<id>/<id>.json` 的继承链 + 库坐标([`snapshot`]),**不解析**
|
||||
//! `<mc>-<loader>-<ver>` 这类实例 id 字符串(社区实例名不统一,那是猜)。
|
||||
//! 4. **CF `files[]` 只写能确定 `projectID`/`fileID` 的项**:CF 没有"内联到 files[]"的概念,
|
||||
//! 证明不了的(以及**禁用文件**:CF 根本没有禁用表达)一律进 `overrides/` 并进报告。
|
||||
//! 5. **尺寸护栏**:`fileSize` 是 u32(超了报 `pack_limits`,不静默截断)、整包撞 ZIP64 阈值由
|
||||
//! `format::write` 拒绝(`pack_limits`,不留半个包)。
|
||||
//!
|
||||
//! # 刻意不做的事(分工边界,别以为是漏做)
|
||||
//!
|
||||
//! - **MultiMC / Prism 导出**:不做(P1 只做导入)。它的包是"整实例快照",要先把所有远程文件
|
||||
//! 下载下来再打包,成本极高;导入侧 M111 已覆盖。**这是有意省略。**
|
||||
//! - **不装加载器 / 不补全清单**:加载器元数据解析与库下载是 `addon::loader` + `game::install`;
|
||||
//! CF 的 `projectID/fileID → 文件名/URL` 补全需要 CF 客户端(D10 §5 规划中),本轮由调用方注入
|
||||
//! [`RemoteCatalog`],本模块不发网络请求。
|
||||
//! - **不碰 UI 类型**:core 零 UI 依赖(`core-no-ui` 门禁),报告只出现 `String` / `&'static str`。
|
||||
//! - **不改实例**:导出只读实例目录。
|
||||
//!
|
||||
//! # 没验的部分(报告里同样登记)
|
||||
//!
|
||||
//! 真实 Modrinth / CurseForge API 查询(要网络与 key,`RemoteCatalog` 目前只有离线实现)、
|
||||
//! 超大实例(GB 级、上千文件——指纹要对每个文件多读两遍)、Windows 之外的平台、
|
||||
//! 以及「M111 合并后的 `import_pack → export → import_pack` 真端到端」。
|
||||
|
||||
pub mod catalog;
|
||||
pub(crate) mod classify;
|
||||
pub mod pipeline;
|
||||
pub mod report;
|
||||
pub mod scan;
|
||||
pub mod snapshot;
|
||||
|
||||
pub use catalog::{OfflineCatalog, RemoteCatalog, RemoteHit, RemoteSource};
|
||||
pub use pipeline::{export_instance, ExportRequest};
|
||||
pub use report::{ExportFile, ExportReport, FileClass, FileReason};
|
||||
pub use scan::{scan_instance, LocalFile, SkippedLocal, DEFAULT_CONTENT_ROOTS, OVERRIDES_DIR};
|
||||
pub use snapshot::{read_instance_snapshot, InstanceSnapshot, SnapshotLoader};
|
||||
@@ -0,0 +1,147 @@
|
||||
//! 「能否被远程替代」的**注入点**:两条查询 + 一个离线默认实现。
|
||||
//!
|
||||
//! # 为什么是 trait 而不是直接调 API
|
||||
//!
|
||||
//! 与 M111 的 `PackFetcher` 同一条理由:测试不能联网;CF 的查表要 `x-api-key`(D10 §5 的客户端
|
||||
//! 还没写);而"证明不了"必须能被如实表达——查表失败 ≠ 文件有问题。所以导出管线只认这个 trait,
|
||||
//! 生产实现将来接 Modrinth `/v2/version_file/{sha1}` 与 CF `/v1/fingerprints/432`。
|
||||
//!
|
||||
//! # 两条查询的语义差别(别合并成一个)
|
||||
//!
|
||||
//! | 查询 | 键 | 命中能给出什么 | 谁用得上 |
|
||||
//! |------|-----|----------------|----------|
|
||||
//! | [`RemoteCatalog::modrinth_by_sha1`] | 文件 SHA-1 | `downloads[]` + `hashes`(含 `sha512`)+ `fileSize` | mrpack(它要求 sha512) |
|
||||
//! | [`RemoteCatalog::curseforge_by_fingerprint`] | CF 指纹 | `projectID`/`fileID`(+ 可选直链) | CF 清单(它只认这两个 id) |
|
||||
//!
|
||||
//! 只命中一条时,另一格式通常用不上它——这正是 [`crate::addon::export::report::FileReason`]
|
||||
//! 要区分 `CurseOnlyMatch` / `ModrinthOnlyMatch` 的原因:报告要说清"证明了,但目标格式表达不了",
|
||||
//! 而不是笼统地写"找不到"。
|
||||
//!
|
||||
//! # 哨兵值
|
||||
//!
|
||||
//! CF 指纹 `811513880` 会误命中(HMCL issue #4597),由**调用方**在建表前短路:
|
||||
//! 见 `curseforge::fingerprint::is_sentinel`。
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// 这条命中来自哪个目录。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RemoteSource {
|
||||
/// Modrinth(按 SHA-1 查)。
|
||||
Modrinth,
|
||||
/// CurseForge(按文件指纹查)。
|
||||
CurseForge,
|
||||
}
|
||||
|
||||
/// 一次远程命中的结果。
|
||||
///
|
||||
/// 字段刻意按"能写进目标清单"组织,而不是按某个 API 的响应形状:
|
||||
/// mrpack 要 `downloads` + `hashes`,CF 要 `projectID`/`fileID`,两个都给了就是两份清单都能写。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RemoteHit {
|
||||
/// 来源目录。
|
||||
pub source: RemoteSource,
|
||||
/// 直链候选(多源;mrpack 写进 `downloads[]`,CF 清单没有这个字段但也留着做日志)。
|
||||
pub downloads: Vec<String>,
|
||||
/// 摘要表(键名小写)。Modrinth 侧必须含 `sha1` 与 `sha512`——后者是 mrpack 的硬要求。
|
||||
pub hashes: BTreeMap<String, String>,
|
||||
/// 服务端记录的文件字节数(与本地实际字节数不一致时以本地为准,见 `pipeline`)。
|
||||
pub size: Option<u64>,
|
||||
/// CurseForge `projectID`。
|
||||
pub project_id: Option<i64>,
|
||||
/// CurseForge `fileID`。
|
||||
pub file_id: Option<i64>,
|
||||
}
|
||||
|
||||
impl RemoteHit {
|
||||
/// 一次 Modrinth 命中:直链 + 摘要表(必须有 `sha512`,否则 mrpack 写不出来)。
|
||||
#[must_use]
|
||||
pub fn modrinth(
|
||||
downloads: Vec<String>,
|
||||
hashes: BTreeMap<String, String>,
|
||||
size: Option<u64>,
|
||||
) -> Self {
|
||||
Self {
|
||||
source: RemoteSource::Modrinth,
|
||||
downloads,
|
||||
hashes,
|
||||
size,
|
||||
project_id: None,
|
||||
file_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次 CurseForge 命中:`projectID`/`fileID` 是 CF 清单的**全部**必需信息。
|
||||
#[must_use]
|
||||
pub fn curseforge(project_id: i64, file_id: i64) -> Self {
|
||||
Self {
|
||||
source: RemoteSource::CurseForge,
|
||||
downloads: Vec::new(),
|
||||
hashes: BTreeMap::new(),
|
||||
size: None,
|
||||
project_id: Some(project_id),
|
||||
file_id: Some(file_id),
|
||||
}
|
||||
}
|
||||
|
||||
/// 顺带给一条 CF 直链(有就写进 `downloads[]`,没有也不影响 CF 清单)。
|
||||
#[must_use]
|
||||
pub fn with_download(mut self, url: impl Into<String>) -> Self {
|
||||
self.downloads.push(url.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// `hashes.sha512`。
|
||||
#[must_use]
|
||||
pub fn sha512(&self) -> Option<&str> {
|
||||
self.hashes.get("sha512").map(String::as_str)
|
||||
}
|
||||
|
||||
/// 能不能写进 CF 的 `files[]`。
|
||||
#[must_use]
|
||||
pub fn curse_ids(&self) -> Option<(i64, i64)> {
|
||||
match (self.project_id, self.file_id) {
|
||||
(Some(project), Some(file)) => Some((project, file)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 有没有能写进 mrpack 的 `downloads[]` 的非空直链。
|
||||
#[must_use]
|
||||
pub fn has_download(&self) -> bool {
|
||||
self.downloads.iter().any(|url| !url.trim().is_empty())
|
||||
}
|
||||
}
|
||||
|
||||
/// 「这个文件在远端是谁」的查询接口。
|
||||
///
|
||||
/// 两个方法都必须**只做本地判据 + 调用方给的缓存**:实现里如果联网,那就是生产的
|
||||
/// Modrinth/CF 客户端(D10 §5),本模块不关心它怎么拿到答案。
|
||||
pub trait RemoteCatalog {
|
||||
/// 按 SHA-1 查 Modrinth 版本文件;查不到返回 `None`(**不要**用 `None` 表示网络失败以外的
|
||||
/// 「本地构造不了查询」——那种情况应当返回 `None` 并让文件内联)。
|
||||
fn modrinth_by_sha1(&self, sha1: &str) -> Option<RemoteHit>;
|
||||
|
||||
/// 按 CurseForge 指纹查项目/文件;查不到返回 `None`。
|
||||
///
|
||||
/// 调用方已按哨兵值(`811513880`)短路,实现不必重复判断。
|
||||
fn curseforge_by_fingerprint(&self, fingerprint: u32) -> Option<RemoteHit>;
|
||||
}
|
||||
|
||||
/// 离线:什么都证明不了 ⇒ 所有文件内联。
|
||||
///
|
||||
/// 这是**默认**行为,也是本仓库当前的唯一实现:没有 CF 客户端(要 key)、没有 Modrinth 客户端
|
||||
/// (D10 §5 规划中),联网查询在测试里也不允许。用它的导出结果是"一个完整、可导入、全部走
|
||||
/// `overrides/` 的包"——不是残废包。
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct OfflineCatalog;
|
||||
|
||||
impl RemoteCatalog for OfflineCatalog {
|
||||
fn modrinth_by_sha1(&self, _sha1: &str) -> Option<RemoteHit> {
|
||||
None
|
||||
}
|
||||
|
||||
fn curseforge_by_fingerprint(&self, _fingerprint: u32) -> Option<RemoteHit> {
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
//! 逐文件判定(导出管线的第 ③ 步):这份本地文件在目标格式里能不能被远程替代。
|
||||
//!
|
||||
//! 判定要**两个条件同时成立**才算远程引用:
|
||||
//!
|
||||
//! 1. 某个目录([`RemoteCatalog`])能**证明**它的身份——Modrinth 按 SHA-1、CurseForge 按指纹;
|
||||
//! 2. 目标格式**表达得了**这个证明——mrpack 要 `path` + `sha512` + `downloads`,
|
||||
//! CF 清单要 `projectID` + `fileID`。
|
||||
//!
|
||||
//! 只满足第一条时就是"证明了但用不上",它有自己的原因(`CurseOnlyMatch` /
|
||||
//! `ModrinthOnlyMatch`),不能和"两个目录都不认识"混成一句话。
|
||||
//!
|
||||
//! 分成单独一个文件是为了让 [`super::pipeline`] 只讲流程;这里全是判据,可以逐条对着测
|
||||
//! (`classify_with` 甚至允许测试直接喂指纹——哨兵值 `811513880` 用真实文件测不出来)。
|
||||
|
||||
use super::catalog::{RemoteCatalog, RemoteHit};
|
||||
use super::report::{ExportFile, FileReason};
|
||||
use super::scan::LocalFile;
|
||||
use crate::addon::pack::curseforge::fingerprint;
|
||||
use crate::addon::pack::manifest::{EnvSupport, PackFile};
|
||||
|
||||
/// 判定一条本地文件属于哪一类(外加命中,供组 `files[]` 用)。
|
||||
pub(super) fn classify(
|
||||
local: &LocalFile,
|
||||
catalog: &dyn RemoteCatalog,
|
||||
target_is_curse: bool,
|
||||
) -> (FileReason, Option<RemoteHit>) {
|
||||
// 指纹要读两遍盘;读不出来就按"没有 CF 侧证据"处理(文件本身仍会内联进包)。
|
||||
let value = fingerprint::fingerprint_file(&local.disk).ok();
|
||||
classify_with(local, catalog, target_is_curse, value)
|
||||
}
|
||||
|
||||
/// 同 [`classify`],但指纹由调用方给(`None` = 读不出来)。
|
||||
///
|
||||
/// `pub(crate)` 只有一个理由:哨兵值 `811513880` 那条分支**没法用真实文件测**——它是误命中,
|
||||
/// 找不到一个能算出它的样本(要 2^32 次穷举)。测试直接喂这个数,验证它被当成"查不到"。
|
||||
pub(crate) fn classify_with(
|
||||
local: &LocalFile,
|
||||
catalog: &dyn RemoteCatalog,
|
||||
target_is_curse: bool,
|
||||
fingerprint: Option<u32>,
|
||||
) -> (FileReason, Option<RemoteHit>) {
|
||||
let modrinth = catalog.modrinth_by_sha1(&local.sha1);
|
||||
let curseforge = match fingerprint {
|
||||
Some(value) if !fingerprint::is_sentinel(value) => catalog.curseforge_by_fingerprint(value),
|
||||
Some(_) | None => None,
|
||||
};
|
||||
if target_is_curse {
|
||||
if local.disabled {
|
||||
// CF 没有禁用表达:`required:false` 是"可选"不是"禁用",硬写就等于把它变成启用。
|
||||
return (FileReason::DisabledNotExpressible, None);
|
||||
}
|
||||
return match curseforge {
|
||||
Some(hit) if hit.curse_ids().is_some() => {
|
||||
(FileReason::CurseForgeFingerprint, Some(hit))
|
||||
}
|
||||
Some(_) => (FileReason::MissingCurseIds, None),
|
||||
None if modrinth.is_some() => (FileReason::ModrinthOnlyMatch, None),
|
||||
None => (FileReason::NoRemoteMatch, None),
|
||||
};
|
||||
}
|
||||
match modrinth {
|
||||
Some(hit) if hit.sha512().is_some() && hit.has_download() => {
|
||||
let mut merged = hit;
|
||||
// 两条都命中:两条直链都写进 downloads[](HMCL 同款,兼容面更大)。
|
||||
if let Some(curse) = curseforge {
|
||||
for url in curse.downloads {
|
||||
if !url.trim().is_empty() && !merged.downloads.iter().any(|seen| seen == &url) {
|
||||
merged.downloads.push(url);
|
||||
}
|
||||
}
|
||||
merged.project_id = merged.project_id.or(curse.project_id);
|
||||
merged.file_id = merged.file_id.or(curse.file_id);
|
||||
}
|
||||
(FileReason::ModrinthSha1, Some(merged))
|
||||
}
|
||||
Some(_) => (FileReason::MissingSha512, None),
|
||||
None if curseforge.is_some() => (FileReason::CurseOnlyMatch, None),
|
||||
None => (FileReason::NoRemoteMatch, None),
|
||||
}
|
||||
}
|
||||
|
||||
/// 远程条目:`path` 用实例内路径(禁用文件已带 `.disabled` 后缀),`size` 用本地实测字节数。
|
||||
pub(super) fn remote_entry(local: &LocalFile, hit: &RemoteHit) -> PackFile {
|
||||
let mut hashes = hit.hashes.clone();
|
||||
hashes.insert("sha1".to_string(), local.sha1.clone());
|
||||
PackFile {
|
||||
path: Some(local.path.clone()),
|
||||
project_id: hit.project_id,
|
||||
file_id: hit.file_id,
|
||||
hashes,
|
||||
downloads: hit.downloads.clone(),
|
||||
size: Some(local.bytes),
|
||||
disabled: local.disabled,
|
||||
client: EnvSupport::Required,
|
||||
server: EnvSupport::Required,
|
||||
..PackFile::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// 报告里的一条。
|
||||
pub(super) fn entry(local: &LocalFile, reason: FileReason, hit: Option<&RemoteHit>) -> ExportFile {
|
||||
ExportFile {
|
||||
path: local.path.clone(),
|
||||
class: reason.class(),
|
||||
reason,
|
||||
bytes: local.bytes,
|
||||
downloads: hit.map(|hit| hit.downloads.clone()).unwrap_or_default(),
|
||||
project_id: hit.and_then(|hit| hit.project_id),
|
||||
file_id: hit.and_then(|hit| hit.file_id),
|
||||
disabled: local.disabled,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
//! 导出管线:实例目录 → 三分类 → 清单 + `overrides/` → 包。
|
||||
//!
|
||||
//! 顺序是写死的,每一步都可单独测:
|
||||
//!
|
||||
//! ```text
|
||||
//! ① 实例快照(snapshot) versions/<id>/<id>.json 的继承链 + 库坐标 ⇒ game / dependencies
|
||||
//! ② 扫描(scan) 内容目录 → 路径 + 字节数 + SHA-1 + 禁用位;读不了的进跳过表
|
||||
//! ③ 逐文件判定(classify) Modrinth SHA-1 → CF 指纹 → 目标格式能不能表达 ⇒ 远程 / 内联 / 跳过
|
||||
//! ④ 组模型 files[] = 远程;overrides = 内联(统一 `overrides/`)
|
||||
//! ⑤ 写包 走 M110 的 `PackFormat::write`(临时文件 + rename,失败不留半个包)
|
||||
//! ```
|
||||
//!
|
||||
//! # 判定为什么要"目标格式能力"参与
|
||||
//!
|
||||
//! 「能被远程替代」不是文件自己的属性,是**文件 + 目标格式**的属性:同一个文件,CF 认得
|
||||
//! `projectID/fileID` 就能写进 CF 的 `files[]`,但 mrpack 要 `sha512` 才写得出来;反过来
|
||||
//! mrpack 认可 Modrinth 直链,CF 清单却只认两个整数 id。所以
|
||||
//! [`crate::addon::export::report::FileReason`] 里 `CurseOnlyMatch` 与 `ModrinthOnlyMatch`
|
||||
//! 是两个不同的事实,不能合并成"没找到"。
|
||||
//!
|
||||
//! # 一条禁令
|
||||
//!
|
||||
//! **不猜**:目录没给命中就内联;命中缺必需字段(`sha512` / `projectID`)也内联;只有
|
||||
//! "两条硬要求都齐"才写进 `files[]`。这样导出的包在任何启动器里都能装出**同一份内容**,
|
||||
//! 最多是"该远程的走了内联"(体积大一点,内容不会变)。
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde_json::Map;
|
||||
|
||||
use super::catalog::{OfflineCatalog, RemoteCatalog};
|
||||
use super::classify::{classify, entry, remote_entry};
|
||||
use super::report::{ExportFile, ExportReport, FileClass};
|
||||
use super::scan::{scan_instance, DEFAULT_CONTENT_ROOTS, OVERRIDES_DIR};
|
||||
use super::snapshot::{read_instance_snapshot, InstanceSnapshot};
|
||||
use crate::addon::pack::curseforge;
|
||||
use crate::addon::pack::error::{PackError, PackResult};
|
||||
use crate::addon::pack::format::{PackFormat, PackPayload};
|
||||
use crate::addon::pack::manifest::{
|
||||
OverrideFile, PackLoader, PackManifest, PackOverrides, PackSide,
|
||||
};
|
||||
|
||||
/// 包版本缺省值(CF `version` / mrpack `versionId` 都必填,调用方没给就用它)。
|
||||
pub const DEFAULT_VERSION: &str = "1.0.0";
|
||||
|
||||
/// 默认目录(离线):所有文件内联。
|
||||
static OFFLINE: OfflineCatalog = OfflineCatalog;
|
||||
|
||||
/// 一次导出的输入。
|
||||
pub struct ExportRequest<'a> {
|
||||
root: &'a Path,
|
||||
target: &'a Path,
|
||||
format: &'static dyn PackFormat,
|
||||
name: Option<String>,
|
||||
version: Option<String>,
|
||||
version_id: Option<&'a str>,
|
||||
content_roots: Option<Vec<String>>,
|
||||
catalog: &'a dyn RemoteCatalog,
|
||||
snapshot: Option<&'a InstanceSnapshot>,
|
||||
}
|
||||
|
||||
impl<'a> ExportRequest<'a> {
|
||||
/// 从 `root`(实例游戏目录)导出到 `target`,用 `format` 的清单形状。
|
||||
///
|
||||
/// 默认:包名取目录名、版本 [`DEFAULT_VERSION`]、内容目录取 [`DEFAULT_CONTENT_ROOTS`]、
|
||||
/// 目录用 [`OfflineCatalog`](= 全部内联)。
|
||||
#[must_use]
|
||||
pub fn new(root: &'a Path, target: &'a Path, format: &'static dyn PackFormat) -> Self {
|
||||
Self {
|
||||
root,
|
||||
target,
|
||||
format,
|
||||
name: None,
|
||||
version: None,
|
||||
version_id: None,
|
||||
content_roots: None,
|
||||
catalog: &OFFLINE,
|
||||
snapshot: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 包名(写进清单 `name`)。
|
||||
#[must_use]
|
||||
pub fn with_name(mut self, name: impl Into<String>) -> Self {
|
||||
self.name = Some(name.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// 包版本(写进 CF `version` / mrpack `versionId`)。
|
||||
#[must_use]
|
||||
pub fn with_version(mut self, version: impl Into<String>) -> Self {
|
||||
self.version = Some(version.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// 实例绑定的版本 id;不给就要求 `versions/` 下只有一个版本。
|
||||
#[must_use]
|
||||
pub fn with_version_id(mut self, version_id: &'a str) -> Self {
|
||||
self.version_id = Some(version_id);
|
||||
self
|
||||
}
|
||||
|
||||
/// 覆盖要扫的内容目录(默认 [`DEFAULT_CONTENT_ROOTS`])。
|
||||
#[must_use]
|
||||
pub fn with_content_roots(mut self, roots: Vec<String>) -> Self {
|
||||
self.content_roots = Some(roots);
|
||||
self
|
||||
}
|
||||
|
||||
/// 注入远端查表(默认离线:全部内联)。
|
||||
#[must_use]
|
||||
pub fn with_catalog(mut self, catalog: &'a dyn RemoteCatalog) -> Self {
|
||||
self.catalog = catalog;
|
||||
self
|
||||
}
|
||||
|
||||
/// 直接给实例快照,跳过 `versions/<id>/<id>.json` 的读取。
|
||||
///
|
||||
/// 用于两种实例:①**Prism / MultiMC 实例**(游戏目录里没有 `versions/`,快照只能从
|
||||
/// `mmc-pack.json` 的组件表来,调用方用 `pack::multimc` 读出来再传);②调用方已经解析过
|
||||
/// 快照(UI 侧手里就有 profile + 加载器信息),不必再读一遍盘。
|
||||
#[must_use]
|
||||
pub fn with_snapshot(mut self, snapshot: &'a InstanceSnapshot) -> Self {
|
||||
self.snapshot = Some(snapshot);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// 导出一个实例。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// - `pack_unsupported`:实例里找不到唯一的版本 JSON / 目标格式表达不了布局;
|
||||
/// - `pack_metadata`:版本 JSON 坏了(继承成环、不是 JSON);
|
||||
/// - `pack_limits`:单文件 `fileSize` 超 u32、或整包撞 ZIP64 阈值;
|
||||
/// - `pack_io`:读写盘失败(失败时目标路径不留半个包)。
|
||||
pub fn export_instance(request: &ExportRequest<'_>) -> PackResult<ExportReport> {
|
||||
let snapshot = match request.snapshot {
|
||||
Some(snapshot) => snapshot.clone(),
|
||||
None => read_instance_snapshot(request.root, request.version_id)?,
|
||||
};
|
||||
let roots = request
|
||||
.content_roots
|
||||
.clone()
|
||||
.unwrap_or_else(|| DEFAULT_CONTENT_ROOTS.map(str::to_string).to_vec());
|
||||
let (locals, skipped) = scan_instance(request.root, &roots);
|
||||
|
||||
// CF 清单只认 projectID/fileID;mrpack 要 path + sha512 + downloads。
|
||||
let target_is_curse = request.format.id() == curseforge::ID;
|
||||
let mut manifest = build_manifest(request, &snapshot);
|
||||
let mut entries: Vec<ExportFile> = Vec::with_capacity(locals.len() + skipped.len());
|
||||
let mut overrides: Vec<OverrideFile> = Vec::new();
|
||||
let mut sources: BTreeMap<String, PathBuf> = BTreeMap::new();
|
||||
|
||||
for local in &locals {
|
||||
let (reason, hit) = classify(local, request.catalog, target_is_curse);
|
||||
match reason.class() {
|
||||
FileClass::Remote => {
|
||||
let Some(hit) = hit else {
|
||||
// 判定逻辑的自我约束:Remote 必带命中。真走到这里说明分类函数被改坏了。
|
||||
return Err(PackError::unsupported(format!(
|
||||
"内部不一致:{} 被判为远程引用却没有命中",
|
||||
local.path
|
||||
)));
|
||||
};
|
||||
manifest.files.push(remote_entry(local, &hit));
|
||||
entries.push(entry(local, reason, Some(&hit)));
|
||||
}
|
||||
FileClass::Inline => {
|
||||
overrides.push(OverrideFile {
|
||||
path: local.path.clone(),
|
||||
sha1: local.sha1.clone(),
|
||||
size: local.bytes,
|
||||
});
|
||||
sources.insert(
|
||||
format!("{OVERRIDES_DIR}/{}", local.path),
|
||||
local.disk.clone(),
|
||||
);
|
||||
entries.push(entry(local, reason, None));
|
||||
}
|
||||
FileClass::Skipped => unreachable!("扫描阶段不会产出可导出项"),
|
||||
}
|
||||
}
|
||||
// 跳过项照原样进报告(逐条给原因),不进包。
|
||||
for item in &skipped {
|
||||
entries.push(ExportFile {
|
||||
path: item.path.clone(),
|
||||
class: FileClass::Skipped,
|
||||
reason: item.reason,
|
||||
bytes: 0,
|
||||
downloads: Vec::new(),
|
||||
project_id: None,
|
||||
file_id: None,
|
||||
disabled: false,
|
||||
});
|
||||
}
|
||||
|
||||
if !overrides.is_empty() {
|
||||
// 一律 `overrides/`(Modrinth 官方 + Prism 的写法)。实例目录不分侧,所以只有一个 Both 组。
|
||||
manifest.overrides.push(PackOverrides {
|
||||
dir: OVERRIDES_DIR.to_string(),
|
||||
side: PackSide::Both,
|
||||
files: overrides,
|
||||
});
|
||||
}
|
||||
|
||||
request.format.write(
|
||||
&manifest,
|
||||
PackPayload::with_sources(request.root, &sources),
|
||||
request.target,
|
||||
)?;
|
||||
let bytes = std::fs::metadata(request.target)
|
||||
.map_err(|error| {
|
||||
PackError::io(format!(
|
||||
"量 {} 的字节数失败:{error}",
|
||||
request.target.display()
|
||||
))
|
||||
})?
|
||||
.len();
|
||||
|
||||
entries.sort_by(|left, right| left.path.cmp(&right.path));
|
||||
let notes = notes(target_is_curse, &manifest);
|
||||
Ok(ExportReport {
|
||||
format: request.format.id(),
|
||||
label: request.format.label(),
|
||||
path: request.target.to_path_buf(),
|
||||
bytes,
|
||||
name: manifest.name.clone(),
|
||||
version: manifest.version.clone(),
|
||||
minecraft: manifest.minecraft.clone(),
|
||||
loaders: manifest
|
||||
.loaders
|
||||
.iter()
|
||||
.map(|loader| {
|
||||
format!(
|
||||
"{} {}",
|
||||
loader.dependency_key(),
|
||||
loader.version.clone().unwrap_or_default()
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
files: entries,
|
||||
notes,
|
||||
})
|
||||
}
|
||||
|
||||
/// 组清单:`game` / `minecraft` / `loaders` 全部来自实例快照。
|
||||
fn build_manifest(request: &ExportRequest<'_>, snapshot: &InstanceSnapshot) -> PackManifest {
|
||||
let name = request
|
||||
.name
|
||||
.clone()
|
||||
.or_else(|| {
|
||||
request
|
||||
.root
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy().to_string())
|
||||
})
|
||||
.unwrap_or_else(|| "整合包".to_string());
|
||||
let mut manifest = PackManifest::new(name);
|
||||
manifest.version = Some(
|
||||
request
|
||||
.version
|
||||
.clone()
|
||||
.unwrap_or_else(|| DEFAULT_VERSION.to_string()),
|
||||
);
|
||||
manifest.format_version = Some(1);
|
||||
manifest.game = Some(crate::addon::pack::modrinth::GAME.to_string());
|
||||
manifest.minecraft = Some(snapshot.game_version.clone());
|
||||
manifest.loaders = snapshot
|
||||
.loaders
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, loader)| PackLoader {
|
||||
raw: String::new(),
|
||||
kind: Some(loader.kind),
|
||||
version: Some(loader.version.clone()),
|
||||
primary: index == 0,
|
||||
extra: Map::new(),
|
||||
})
|
||||
.collect();
|
||||
manifest
|
||||
}
|
||||
|
||||
/// 格式层面表达不了的东西(已知损失)——必须显式列出来,别让用户以为往返无损。
|
||||
fn notes(target_is_curse: bool, manifest: &PackManifest) -> Vec<String> {
|
||||
let mut notes = vec![
|
||||
"覆盖内容统一写进 `overrides/`(Modrinth 官方与 Prism 的写法);实例目录不分侧,\
|
||||
所以不产生 client-overrides / server-overrides。"
|
||||
.to_string(),
|
||||
"禁用文件用**路径后缀 `.disabled`** 表达(不是 `env.client=optional`:后者装回来默认启用)。"
|
||||
.to_string(),
|
||||
"MultiMC / Prism 导出本轮不做(只有导入),这是有意省略,不是遗漏。".to_string(),
|
||||
];
|
||||
if target_is_curse {
|
||||
notes.push("mrpack 的 `summary` 在 CF 清单里没有字段;CF 也只认 projectID/fileID,禁用文件只能内联。".to_string());
|
||||
} else {
|
||||
notes.push(
|
||||
"CF 清单的 `author` / `manifestVersion` 之外的扩展字段在 mrpack 里没有对应位置。"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
if !manifest.files.is_empty() {
|
||||
notes.push(format!(
|
||||
"{} 条写进 files[](远程引用),其余在 overrides/ 里;每条的原因见 files[].reason。",
|
||||
manifest.files.len()
|
||||
));
|
||||
}
|
||||
notes
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
//! 导出结果:**三分类 + 逐条原因**,给 CLI 的 `--json` 与 M113 的页面直接消费。
|
||||
//!
|
||||
//! # 为什么每条都要有原因
|
||||
//!
|
||||
//! 用户要的是「不认识的 mod 不能丢、不能编」。光说"导出了 120 个文件"会掩盖真正的信息:
|
||||
//! 其中多少条**能证明**可被远程替代、多少条**证明不了所以内联**、多少条**干脆没进包**。
|
||||
//! 所以 [`ExportFile`] 的 `class` 与 `reason` 是两个独立维度,且 `reason` 决定 `class`
|
||||
//! ([`FileReason::class`] 是唯一映射处,写死在这里,别在别处再判一遍)。
|
||||
//!
|
||||
//! # 「允许的差异」也进报告
|
||||
//!
|
||||
//! [`ExportReport::notes`] 收的是**格式层面表达不了**的东西(CF 的 `author`、mrpack 的
|
||||
//! `summary`、CF 表达不了禁用位……)。它们是已知损失,必须显式列出,不能让用户以为"往返无损"。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::{Serialize, Serializer};
|
||||
|
||||
/// 一条文件在导出结果里的归类。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FileClass {
|
||||
/// 写进清单 `files[]`(靠远端直链/项目 id 代替本地字节)。
|
||||
Remote,
|
||||
/// 写进 `overrides/`(原字节照搬)。
|
||||
Inline,
|
||||
/// 没进包(读不了 / 符号链接 / 路径不安全)。
|
||||
Skipped,
|
||||
}
|
||||
|
||||
impl FileClass {
|
||||
/// 稳定字符串 id(进 `--json` 与日志,**不要改名**)。
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Remote => "remote",
|
||||
Self::Inline => "inline",
|
||||
Self::Skipped => "skipped",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for FileClass {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
serializer.serialize_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// 一条文件为什么归到那个类里。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FileReason {
|
||||
/// 远程引用:Modrinth SHA-1 查到了版本文件。
|
||||
ModrinthSha1,
|
||||
/// 远程引用:CurseForge 指纹查到了项目/文件。
|
||||
CurseForgeFingerprint,
|
||||
/// 内联:两个目录都不认识它。
|
||||
NoRemoteMatch,
|
||||
/// 内联:CF 认识,但目标格式(mrpack)用不上——CF 命中不含 `sha512`。
|
||||
CurseOnlyMatch,
|
||||
/// 内联:Modrinth 认识,但目标格式(CF 清单)只认 `projectID`/`fileID`。
|
||||
ModrinthOnlyMatch,
|
||||
/// 内联:目录给了命中但缺 `hashes.sha512`(mrpack 的硬要求)。
|
||||
MissingSha512,
|
||||
/// 内联:目录给了命中但缺 `projectID`/`fileID`(CF 清单的硬要求)。
|
||||
MissingCurseIds,
|
||||
/// 内联:文件是禁用的(`.disabled`),而目标格式表达不了禁用位(CF)。
|
||||
DisabledNotExpressible,
|
||||
/// 跳过:符号链接。
|
||||
SymbolicLink,
|
||||
/// 跳过:文件名过不了包内路径规整(`:` / `\` / `..` / 绝对路径…)。
|
||||
UnsafePath,
|
||||
/// 跳过:读盘或算摘要失败。
|
||||
Unreadable,
|
||||
}
|
||||
|
||||
impl FileReason {
|
||||
/// 稳定字符串 id(进 `--json` 与日志,**不要改名**)。
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::ModrinthSha1 => "modrinth_sha1",
|
||||
Self::CurseForgeFingerprint => "curseforge_fingerprint",
|
||||
Self::NoRemoteMatch => "no_remote_match",
|
||||
Self::CurseOnlyMatch => "curse_only_match",
|
||||
Self::ModrinthOnlyMatch => "modrinth_only_match",
|
||||
Self::MissingSha512 => "missing_sha512",
|
||||
Self::MissingCurseIds => "missing_curse_ids",
|
||||
Self::DisabledNotExpressible => "disabled_not_expressible",
|
||||
Self::SymbolicLink => "symbolic_link",
|
||||
Self::UnsafePath => "unsafe_path",
|
||||
Self::Unreadable => "unreadable",
|
||||
}
|
||||
}
|
||||
|
||||
/// 人读说明(中文,进报告与日志;UI 文案后续按 key 本地化)。
|
||||
#[must_use]
|
||||
pub const fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::ModrinthSha1 => "Modrinth 命中(SHA-1)",
|
||||
Self::CurseForgeFingerprint => "CurseForge 命中(文件指纹)",
|
||||
Self::NoRemoteMatch => "两个目录都不认识,已内联",
|
||||
Self::CurseOnlyMatch => "只有 CF 认识,目标格式用不上,已内联",
|
||||
Self::ModrinthOnlyMatch => "只有 Modrinth 认识,CF 清单用不上,已内联",
|
||||
Self::MissingSha512 => "命中缺 sha512,mrpack 写不出,已内联",
|
||||
Self::MissingCurseIds => "命中缺 projectID/fileID,CF 清单写不出,已内联",
|
||||
Self::DisabledNotExpressible => "禁用文件 CF 表达不了,已内联(保留 .disabled 后缀)",
|
||||
Self::SymbolicLink => "符号链接,已跳过",
|
||||
Self::UnsafePath => "路径过不了包内规整,已跳过",
|
||||
Self::Unreadable => "读不出来,已跳过",
|
||||
}
|
||||
}
|
||||
|
||||
/// 这个原因属于哪一类(**唯一映射处**)。
|
||||
#[must_use]
|
||||
pub const fn class(self) -> FileClass {
|
||||
match self {
|
||||
Self::ModrinthSha1 | Self::CurseForgeFingerprint => FileClass::Remote,
|
||||
Self::NoRemoteMatch
|
||||
| Self::CurseOnlyMatch
|
||||
| Self::ModrinthOnlyMatch
|
||||
| Self::MissingSha512
|
||||
| Self::MissingCurseIds
|
||||
| Self::DisabledNotExpressible => FileClass::Inline,
|
||||
Self::SymbolicLink | Self::UnsafePath | Self::Unreadable => FileClass::Skipped,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for FileReason {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
serializer.serialize_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// 一条文件的导出结果。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct ExportFile {
|
||||
/// 实例内相对路径(含 `.disabled` 后缀,与磁盘上一致)。
|
||||
pub path: String,
|
||||
/// 归类。
|
||||
pub class: FileClass,
|
||||
/// 原因。
|
||||
pub reason: FileReason,
|
||||
/// 字节数(跳过项记磁盘上的大小,读不到就是 0)。
|
||||
pub bytes: u64,
|
||||
/// 远程引用给的直链(内联/跳过为空)。
|
||||
pub downloads: Vec<String>,
|
||||
/// CurseForge `projectID`(远程引用到 CF 时才有)。
|
||||
pub project_id: Option<i64>,
|
||||
/// CurseForge `fileID`。
|
||||
pub file_id: Option<i64>,
|
||||
/// 是否禁用态(`.disabled`)。
|
||||
pub disabled: bool,
|
||||
}
|
||||
|
||||
/// 一次导出的结构化结果。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct ExportReport {
|
||||
/// 目标格式 id(`modrinth` / `curseforge`)。
|
||||
pub format: &'static str,
|
||||
/// 目标格式人读名。
|
||||
pub label: &'static str,
|
||||
/// 产出的包路径。
|
||||
pub path: PathBuf,
|
||||
/// 包的字节数(写完之后量的,不是估算)。
|
||||
pub bytes: u64,
|
||||
/// 包名(写进清单的 `name`)。
|
||||
pub name: String,
|
||||
/// 包版本(`versionId` / `version`)。
|
||||
pub version: Option<String>,
|
||||
/// 游戏版本(来自实例快照)。
|
||||
pub minecraft: Option<String>,
|
||||
/// 加载器(`fabric-loader 0.15.11` 这种人读串,来自实例快照)。
|
||||
pub loaders: Vec<String>,
|
||||
/// 逐文件结果(按路径排序)。
|
||||
pub files: Vec<ExportFile>,
|
||||
/// 已知的表达不了的差异 / 值得用户知道的取舍。
|
||||
pub notes: Vec<String>,
|
||||
}
|
||||
|
||||
impl ExportReport {
|
||||
/// 远程引用的条数。
|
||||
#[must_use]
|
||||
pub fn remote_count(&self) -> usize {
|
||||
self.count(FileClass::Remote)
|
||||
}
|
||||
|
||||
/// 内联的条数。
|
||||
#[must_use]
|
||||
pub fn inline_count(&self) -> usize {
|
||||
self.count(FileClass::Inline)
|
||||
}
|
||||
|
||||
/// 跳过的条数。
|
||||
#[must_use]
|
||||
pub fn skipped_count(&self) -> usize {
|
||||
self.count(FileClass::Skipped)
|
||||
}
|
||||
|
||||
fn count(&self, class: FileClass) -> usize {
|
||||
self.files.iter().filter(|file| file.class == class).count()
|
||||
}
|
||||
|
||||
/// 有没有文件没进包(`false` = 包内容完整,只是有些走了内联)。
|
||||
#[must_use]
|
||||
pub fn is_complete(&self) -> bool {
|
||||
self.skipped_count() == 0
|
||||
}
|
||||
|
||||
/// 内联文件(`overrides/` 里的那些)。
|
||||
pub fn inline_files(&self) -> impl Iterator<Item = &ExportFile> {
|
||||
self.files
|
||||
.iter()
|
||||
.filter(|file| file.class == FileClass::Inline)
|
||||
}
|
||||
|
||||
/// 不可导出的项(逐条给 `path` + `reason` 用)。
|
||||
pub fn skipped_files(&self) -> impl Iterator<Item = &ExportFile> {
|
||||
self.files
|
||||
.iter()
|
||||
.filter(|file| file.class == FileClass::Skipped)
|
||||
}
|
||||
|
||||
/// 一句话摘要(日志/CLI 一行输出用)。
|
||||
#[must_use]
|
||||
pub fn summary(&self) -> String {
|
||||
format!(
|
||||
"{}:{} 个远程引用 / {} 个内联 / {} 个跳过,{} 字节",
|
||||
self.label,
|
||||
self.remote_count(),
|
||||
self.inline_count(),
|
||||
self.skipped_count(),
|
||||
self.bytes
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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)
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
//! 实例快照:`game` 与 `dependencies` 的唯一来源(**不解析实例 id 字符串**)。
|
||||
//!
|
||||
//! # 为什么要读版本 JSON 而不是拆 `versions/<id>` 的名字
|
||||
//!
|
||||
//! 实例 id 的形状是社区约定,不是规范:`1.20.1-fabric-0.15.11`(本仓库 `LoaderKind::version_id`
|
||||
//! 的拼法)、`1.20.1-forge-47.2.0`、`Fabric 1.20.1`、`我的整合包 v3`……按 `-` 切串去猜
|
||||
//! "哪段是加载器、哪段是版本"必然在真实实例上翻车。**证据只能在版本 JSON 里**:
|
||||
//!
|
||||
//! - 游戏版本 = `inheritsFrom` 链**最深的那个祖先**的 `id`(加载器层都叠在原版之上);
|
||||
//! - 加载器 = 各层 `libraries[]` 里的 Maven 坐标:`net.fabricmc:fabric-loader:<ver>`、
|
||||
//! `org.quiltmc:quilt-loader:<ver>`、`net.minecraftforge:forge:<ver>`、
|
||||
//! `net.neoforged:neoforge:<ver>`(`fmlloader` 也认,它是 Forge 的拆分包)。
|
||||
//!
|
||||
//! 这条口径由 tower 批准(M112):"解析 `<mc>-<loader>-<ver>` 字符串是猜"。
|
||||
//!
|
||||
//! # 版本号里的 MC 前缀
|
||||
//!
|
||||
//! NeoForge 在 MC 1.20.1 上的库坐标版本是 `1.20.1-47.1.106`(CF 导出的
|
||||
//! `neoforge-1.20.1-47.1.106` 同源)。这里统一剥成 `47.1.106`:mrpack 的
|
||||
//! `dependencies["neoforge"]` 要裸版本,CF 的 `modLoaders[].id` 由
|
||||
//! `PackLoader::curse_id_for` 在 1.20.1 上再把前缀拼回去。
|
||||
//!
|
||||
//! # 没有加载器是合法的
|
||||
//!
|
||||
//! 原版实例的 `dependencies` 只有 `minecraft`——那不是"漏了加载器",是真的没装。任务书说的
|
||||
//! 「不得只写 `minecraft` 漏加载器」针对的是**装了加载器却漏写**,两者靠这条链区分得清清楚楚。
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use crate::addon::pack::error::{PackError, PackResult};
|
||||
use crate::addon::LoaderKind;
|
||||
use crate::game::library::MavenCoordinate;
|
||||
use crate::game::manifest::VersionJson;
|
||||
|
||||
/// 实例里认出的一条加载器依赖。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SnapshotLoader {
|
||||
/// 加载器种类。
|
||||
pub kind: LoaderKind,
|
||||
/// 加载器版本(已剥掉可能存在的 `<mc>-` 前缀)。
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
/// 实例快照:游戏版本 + 加载器集合(顺序即 `libraries[]` 里出现的顺序)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct InstanceSnapshot {
|
||||
/// 原版游戏版本(`1.20.1`)。
|
||||
pub game_version: String,
|
||||
/// 加载器依赖(原版实例为空)。
|
||||
pub loaders: Vec<SnapshotLoader>,
|
||||
}
|
||||
|
||||
/// 读一个实例目录的快照。
|
||||
///
|
||||
/// `version_id` 给定时只读 `versions/<id>/<id>.json`;`None` 时取 `versions/` 里**继承链的叶子**
|
||||
/// (真实实例有"原版 + 加载器层"两个目录,叶子才是实例自己)。叶子多于一个时无法判断,
|
||||
/// 宁可报错也不猜。
|
||||
pub fn read_instance_snapshot(
|
||||
root: &Path,
|
||||
version_id: Option<&str>,
|
||||
) -> PackResult<InstanceSnapshot> {
|
||||
let versions_dir = root.join("versions");
|
||||
let id = match version_id {
|
||||
Some(id) => id.to_string(),
|
||||
None => instance_version_id(&versions_dir)?,
|
||||
};
|
||||
let layers = load_chain(&versions_dir, &id)?;
|
||||
|
||||
let deepest = layers
|
||||
.last()
|
||||
.and_then(|layer| layer.id.clone())
|
||||
.ok_or_else(|| {
|
||||
PackError::metadata(format!("versions/{id}/{id}.json 的继承链末端没有 id"))
|
||||
})?;
|
||||
|
||||
let mut loaders: Vec<SnapshotLoader> = Vec::new();
|
||||
for layer in &layers {
|
||||
for library in layer.libraries.iter().flatten() {
|
||||
let Ok(coordinate) = MavenCoordinate::parse(&library.name) else {
|
||||
continue;
|
||||
};
|
||||
let Some(kind) = loader_kind_of(&coordinate.group, &coordinate.artifact) else {
|
||||
continue;
|
||||
};
|
||||
if loaders.iter().any(|seen| seen.kind == kind) {
|
||||
continue;
|
||||
}
|
||||
loaders.push(SnapshotLoader {
|
||||
kind,
|
||||
version: strip_game_prefix(&deepest, &coordinate.version),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(InstanceSnapshot {
|
||||
game_version: deepest,
|
||||
loaders,
|
||||
})
|
||||
}
|
||||
|
||||
/// `versions/` 下"这个实例"的版本 id:**继承链的叶子**(没有被别的版本当作 `inheritsFrom`)。
|
||||
///
|
||||
/// 为什么不是"只有一个版本目录":真实实例必然有"原版 + 加载器层"两个目录(`1.20.1` 与
|
||||
/// `1.20.1-fabric-0.15.11`),叶子才是实例自己。这条判据不看目录名形状,只看 JSON 里的
|
||||
/// `inheritsFrom` 关系——与"不解析实例 id 字符串"是同一条纪律。
|
||||
fn instance_version_id(versions_dir: &Path) -> PackResult<String> {
|
||||
let entries = std::fs::read_dir(versions_dir).map_err(|error| {
|
||||
PackError::unsupported(format!(
|
||||
"实例里没有可读的 {}:{error}",
|
||||
versions_dir.display()
|
||||
))
|
||||
.with_hint("导出需要实例的版本 JSON(versions/<id>/<id>.json 给出游戏版本与加载器)")
|
||||
})?;
|
||||
let mut versions: Vec<(String, Option<String>)> = Vec::new();
|
||||
for entry in entries.flatten() {
|
||||
let Ok(file_type) = entry.file_type() else {
|
||||
continue;
|
||||
};
|
||||
if !file_type.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
let path = entry.path().join(format!("{name}.json"));
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
let text = std::fs::read_to_string(&path).map_err(|error| {
|
||||
PackError::metadata(format!("读版本 JSON {} 失败:{error}", path.display()))
|
||||
})?;
|
||||
let layer: VersionJson = serde_json::from_str(&text).map_err(|error| {
|
||||
PackError::metadata(format!("{} 不是合法版本 JSON:{error}", path.display()))
|
||||
})?;
|
||||
versions.push((name, layer.inherits_from));
|
||||
}
|
||||
if versions.is_empty() {
|
||||
return Err(
|
||||
PackError::unsupported("实例的 versions/ 下没有 <id>/<id>.json".to_string()).with_hint(
|
||||
"先装好游戏/加载器再导出;导出需要版本 JSON 才能写 game 与 dependencies",
|
||||
),
|
||||
);
|
||||
}
|
||||
let referenced: Vec<&str> = versions
|
||||
.iter()
|
||||
.filter_map(|(_, parent)| parent.as_deref())
|
||||
.collect();
|
||||
let mut leaves: Vec<String> = versions
|
||||
.iter()
|
||||
.filter(|(id, _)| !referenced.contains(&id.as_str()))
|
||||
.map(|(id, _)| id.clone())
|
||||
.collect();
|
||||
leaves.sort();
|
||||
match leaves.len() {
|
||||
1 => Ok(leaves.remove(0)),
|
||||
_ => Err(PackError::unsupported(format!(
|
||||
"实例的 versions/ 下有 {} 条继承链末端({}),无法判断哪个属于这个实例",
|
||||
leaves.len(),
|
||||
leaves.join(", ")
|
||||
))
|
||||
.with_hint("调用方应显式给出 versionId(实例档案的 version_id)")),
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 `id` 起沿 `inheritsFrom` 收集各层(第一层是实例自己,最后一层是最深的祖先)。
|
||||
fn load_chain(versions_dir: &Path, id: &str) -> PackResult<Vec<VersionJson>> {
|
||||
let mut layers: Vec<VersionJson> = Vec::new();
|
||||
let mut visited: Vec<String> = Vec::new();
|
||||
let mut current = Some(id.to_string());
|
||||
while let Some(next) = current {
|
||||
if visited.iter().any(|seen| seen == &next) {
|
||||
return Err(PackError::metadata(format!(
|
||||
"版本 JSON 的继承链成环:{} → {next}",
|
||||
visited.join(" → ")
|
||||
)));
|
||||
}
|
||||
visited.push(next.clone());
|
||||
let path = versions_dir.join(&next).join(format!("{next}.json"));
|
||||
let text = std::fs::read_to_string(&path).map_err(|error| {
|
||||
PackError::metadata(format!("读版本 JSON {} 失败:{error}", path.display()))
|
||||
.with_hint("继承链上的父版本也必须装在实例里")
|
||||
})?;
|
||||
let layer: VersionJson = serde_json::from_str(&text).map_err(|error| {
|
||||
PackError::metadata(format!("{} 不是合法版本 JSON:{error}", path.display()))
|
||||
})?;
|
||||
current = layer.inherits_from.clone();
|
||||
layers.push(layer);
|
||||
}
|
||||
Ok(layers)
|
||||
}
|
||||
|
||||
/// Maven 坐标 → 加载器种类(认不出的返回 `None`,不影响别的库)。
|
||||
fn loader_kind_of(group: &str, artifact: &str) -> Option<LoaderKind> {
|
||||
match (group, artifact) {
|
||||
("net.fabricmc", "fabric-loader") => Some(LoaderKind::Fabric),
|
||||
("org.quiltmc", "quilt-loader") => Some(LoaderKind::Quilt),
|
||||
("net.minecraftforge", "forge" | "fmlloader") => Some(LoaderKind::Forge),
|
||||
("net.neoforged", "neoforge") => Some(LoaderKind::NeoForge),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 剥掉版本号里又写了一遍的 `<mc>-` 前缀(`1.20.1-47.1.106` → `47.1.106`)。
|
||||
fn strip_game_prefix(game_version: &str, version: &str) -> String {
|
||||
let prefix = format!("{game_version}-");
|
||||
version
|
||||
.strip_prefix(&prefix)
|
||||
.filter(|rest| !rest.is_empty())
|
||||
.unwrap_or(version)
|
||||
.to_string()
|
||||
}
|
||||
@@ -0,0 +1,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;
|
||||
@@ -0,0 +1,162 @@
|
||||
//! 三方合并的 **base 侧**:上次导入写下的"包给的哈希"。
|
||||
//!
|
||||
//! # 为什么必须落盘
|
||||
//!
|
||||
//! 三方合并的三个输入是:`theirs`(这次包里给的)、`ours`(磁盘现状)、`base`(**上次**包里
|
||||
//! 给的)。`base` 不能从这次的包现算——那是 `theirs`;也不能从磁盘现算——那是 `ours`。少了
|
||||
//! `base` 就分不清两种截然不同的事实:
|
||||
//!
|
||||
//! - 磁盘内容 ≠ 新包内容,且磁盘内容 == 上次包内容 ⇒ **整合包升级**,该覆盖(用户没动过);
|
||||
//! - 磁盘内容 ≠ 新包内容,且磁盘内容 ≠ 上次包内容 ⇒ **用户改过**,不许静默覆盖。
|
||||
//!
|
||||
//! 所以第一次导入就在实例根写一份基线;没有基线时(首次导入 / 用户手动塞进来的文件 / 基线
|
||||
//! 属于别的整合包)一律按"磁盘上已有同名文件"处理 —— 不覆盖、进冲突清单(最安全的一侧)。
|
||||
//!
|
||||
//! # 落点:**唯一来源是 [`crate::game::layout::GameLayout::ull_root`]**
|
||||
//!
|
||||
//! `<实例根>/.ull/pack-baseline.json`(游戏根内的启动器私有目录)。
|
||||
//!
|
||||
//! 这里有个踩过的坑(复审 r5 P1-1):基线最初写成 `<实例根>/.ULL/…`(用了
|
||||
//! [`crate::platform::layout::LAUNCHER_HOME_DIR`],那是**启动器数据根**的名字,在 exe/portable
|
||||
//! 那一层),而读侧用 `GameLayout::ull_root()`(`<实例根>/.ull`,小写,实例**游戏根**内的
|
||||
//! 私有目录)。Windows / macOS 的大小写不敏感文件系统上两者是同一个文件,本机全绿;ext4 上
|
||||
//! 是两个文件 ⇒ 基线永远读不到(`UserModified`/`UserDeleted` 永不出现)。
|
||||
//!
|
||||
//! 现在**读写都从 `ull_root()` 派生**:绝对路径 [`PackBaseline::path`],相对路径
|
||||
//! [`PackBaseline::relative_path`],两者由 `addon/tests/import_merge.rs` 的一条断言钉死
|
||||
//! (`path(root) == root.join(relative_path())`)——这条在大小写敏感的系统上也抓得住。
|
||||
//!
|
||||
//! # 包身份
|
||||
//!
|
||||
//! 基线记包名与格式;下次导入发现"基线属于另一个包"时**不拿它当 base**(否则跨包导入同一
|
||||
//! 目录会把别人的哈希当成本次包的历史),而是降级成"无基线"并如实告警。
|
||||
//!
|
||||
//! 基线里存的是**包声明的哈希**(不是磁盘实际哈希):用户改过的文件在基线里仍是包的值,
|
||||
//! 下次导入才会继续判成"用户改过",而不会被"上次也是改动值"洗白。`.disabled` / `.old`
|
||||
//! 后缀按原样进键名(后缀是路径的一部分,不附加语义)。
|
||||
//!
|
||||
//! 读失败**不是**致命错误:基线坏了最多退化成"全部按 pre-existing 冲突处理"(保留用户文件),
|
||||
//! 比"因为读不出基线就整包回滚"合理。
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::game::layout::GameLayout;
|
||||
|
||||
/// 基线文件名。
|
||||
pub const BASELINE_FILE: &str = "pack-baseline.json";
|
||||
/// 基线 schema 版本。
|
||||
pub const BASELINE_SCHEMA: u32 = 1;
|
||||
|
||||
/// 上次导入写下的包声明哈希表。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PackBaseline {
|
||||
/// schema 版本(迁移链入口)。
|
||||
#[serde(default = "default_schema")]
|
||||
pub schema_version: u32,
|
||||
/// 上次导入的包名(诊断 + 包身份判据)。
|
||||
#[serde(default)]
|
||||
pub pack_name: String,
|
||||
/// 上次导入命中的格式 id(包身份判据的另一半)。
|
||||
#[serde(default)]
|
||||
pub format: String,
|
||||
/// 实例内相对路径 → 包声明的 SHA-1。
|
||||
#[serde(default)]
|
||||
pub files: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
const fn default_schema() -> u32 {
|
||||
BASELINE_SCHEMA
|
||||
}
|
||||
|
||||
impl Default for PackBaseline {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
schema_version: BASELINE_SCHEMA,
|
||||
pack_name: String::new(),
|
||||
format: String::new(),
|
||||
files: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PackBaseline {
|
||||
/// 实例根下的基线**绝对**路径。
|
||||
///
|
||||
/// 唯一来源是 `GameLayout::ull_root()`:实例的游戏根里那个启动器私有目录。
|
||||
#[must_use]
|
||||
pub fn path(game_root: &Path) -> PathBuf {
|
||||
GameLayout::new(game_root).ull_root().join(BASELINE_FILE)
|
||||
}
|
||||
|
||||
/// 基线**相对实例根**的路径(暂存期与写盘用;与 [`Self::path`] 同源)。
|
||||
#[must_use]
|
||||
pub fn relative_path() -> PathBuf {
|
||||
// 用空根求 `ull_root()` 就得到那一段相对目录名(`.ull`),不另抄一份字符串。
|
||||
GameLayout::new(Path::new(""))
|
||||
.ull_root()
|
||||
.join(BASELINE_FILE)
|
||||
}
|
||||
|
||||
/// 基线是否属于这份包(空基线 / 没有身份信息的老基线一律放行)。
|
||||
#[must_use]
|
||||
pub fn matches_pack(&self, format: &str, pack_name: &str) -> bool {
|
||||
if self.pack_name.is_empty() && self.format.is_empty() {
|
||||
return true;
|
||||
}
|
||||
self.format == format && self.pack_name == pack_name
|
||||
}
|
||||
|
||||
/// 读基线;文件不存在返回空表(**不是错误**),形状不符返回 `Err`。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 非 UTF-8 / JSON 非法 / 版本号比本进程新(未来版本写的)时返回人读原因。
|
||||
pub fn load(game_root: &Path) -> Result<Self, String> {
|
||||
let path = Self::path(game_root);
|
||||
let bytes = match std::fs::read(&path) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Ok(Self::default())
|
||||
}
|
||||
Err(error) => {
|
||||
return Err(format!("读 {} 失败:{error}", path.display()));
|
||||
}
|
||||
};
|
||||
let text = String::from_utf8(bytes)
|
||||
.map_err(|error| format!("{} 不是 UTF-8:{error}", path.display()))?;
|
||||
let baseline: Self = serde_json::from_str(&text)
|
||||
.map_err(|error| format!("{} 形状不符:{error}", path.display()))?;
|
||||
if baseline.schema_version > BASELINE_SCHEMA {
|
||||
return Err(format!(
|
||||
"{} 的 schemaVersion={} 比本进程({BASELINE_SCHEMA})新",
|
||||
path.display(),
|
||||
baseline.schema_version
|
||||
));
|
||||
}
|
||||
Ok(baseline)
|
||||
}
|
||||
|
||||
/// 查上次给这个路径的哈希。
|
||||
#[must_use]
|
||||
pub fn sha1(&self, path: &str) -> Option<&str> {
|
||||
self.files.get(path).map(String::as_str)
|
||||
}
|
||||
|
||||
/// 记一条。
|
||||
pub fn insert(&mut self, path: impl Into<String>, sha1: impl Into<String>) {
|
||||
self.files.insert(path.into(), sha1.into());
|
||||
}
|
||||
|
||||
/// 序列化成落盘字节(pretty:这文件是给人排障看的)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 序列化失败(纯理论,`BTreeMap<String, String>` 不会失败)。
|
||||
pub fn to_bytes(&self) -> Result<Vec<u8>, String> {
|
||||
serde_json::to_vec_pretty(self).map_err(|error| format!("基线序列化失败:{error}"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
//! 清单 → **导入计划**:把三种格式的差异在进解压器之前全部拍平。
|
||||
//!
|
||||
//! 计划是纯计算(只额外读一次 `instance.cfg`),产出四张表:覆盖条目、远端条目、
|
||||
//! 加载器计划输入、被拒绝的 component。落盘与合并都不在这里。
|
||||
//!
|
||||
//! # 三种格式的语义在这层定死
|
||||
//!
|
||||
//! | 事 | CurseForge | Modrinth | MultiMC / Prism |
|
||||
//! |----|-----------|----------|-----------------|
|
||||
//! | 覆盖目录 | 清单点名的**一个**目录(任意名字) | `overrides/` + `client-overrides/`(`server-overrides/` **不解压**) | `.minecraft/` |
|
||||
//! | 同名冲突 | 不存在(单目录) | `client-overrides` **胜** `overrides`(Modrinth 官方客户端语义;与 HMCL 相反) | 不存在 |
|
||||
//! | 远端条目 | `projectID/fileID`,**无路径无地址** ⇒ 必须补全 | 自带 `path`/`downloads`/`hashes` | 没有远端条目(内容就是目录) |
|
||||
//! | 加载器 | `modLoaders[].id`(`neoforge-1.20.1-47.1.106` 的版本段**带 MC 前缀**) | `dependencies` 的键 | `components[].uid` |
|
||||
//! # 三处"不许自创"的边界
|
||||
//!
|
||||
//! 1. **覆盖目录的优先级只按侧**:`overrides/` 先、`client-overrides/` 后(后者胜)。同一
|
||||
//! 目录内的条目顺序**不重要**——M110 的 [`crate::addon::pack::PackOverrides::files`] 按路径
|
||||
//! 排序,zip 条目顺序没有保留;而同一目录内路径唯一,所以"按 zip 条目顺序落盘"与"按路径
|
||||
//! 排序落盘"在无重复时等价。有重复(跨目录)时由侧优先级决定,不靠条目顺序。
|
||||
//! **同一实例路径被多份声明命中时逐条算清楚**([`PlanOverride::shadowed`]):胜者按上面
|
||||
//! 这条规则定,被遮蔽的那条不再读字节、只留一条 `NOTE_OVERRIDE_SHADOWED` 注记说明
|
||||
//! "谁赢了"和"两份内容是否逐字节相同"。这是"整包一个策略"到"逐文件事实"的那一步。
|
||||
//! 2. **`.disabled` 只认路径后缀**(官方 Modrinth 表达),`.old` **只识别与保留**(不清不删
|
||||
//! 不改名)。`env.client=optional` **不是**禁用("可选"=安装时可取消勾选)。判据在
|
||||
//! [`paths`]。
|
||||
//! 3. **MultiMC 的非加载器 component 逐项进报告**(`org.lwjgl3` / `net.fabricmc.intermediary`
|
||||
//! / 社区自定义 uid):静默丢它们等于"看起来装好了、其实少了补丁层"。
|
||||
|
||||
mod layer;
|
||||
pub mod paths;
|
||||
|
||||
pub use layer::Shadowed;
|
||||
pub use paths::{curse_class_dir, strip_mc_prefix, suffix_note, suffix_variant, SuffixVariant};
|
||||
|
||||
use super::super::pack::{
|
||||
curseforge, multimc, EnvSupport, PackArchive, PackError, PackFile, PackLoader, PackManifest,
|
||||
PackSide,
|
||||
};
|
||||
use super::error::ImportError;
|
||||
use super::report::{keys, FileSource, ImportIssue, LoaderTarget, RejectedComponent};
|
||||
|
||||
/// 一条要落地的覆盖条目。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PlanOverride {
|
||||
/// 包内目录前缀(读包时拼 `<dir>/<path>`)。
|
||||
pub dir: String,
|
||||
/// 实例内相对路径。
|
||||
pub path: String,
|
||||
/// 来源侧。
|
||||
pub source: FileSource,
|
||||
/// 包内实际 SHA-1(M110 读包时现算)。
|
||||
pub sha1: String,
|
||||
/// 字节数。
|
||||
pub size: u64,
|
||||
/// 是否禁用态(路径后缀)。
|
||||
pub disabled: bool,
|
||||
/// 可选注记(`.disabled` / `.old`)。
|
||||
pub note: Option<ImportIssue>,
|
||||
/// 被同包另一条声明遮蔽时的事实(`None` = 本条目就是这条路径的最终胜者)。
|
||||
pub shadowed: Option<Shadowed>,
|
||||
}
|
||||
|
||||
/// 一条要补全/下载的远端条目。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PlanRemote {
|
||||
/// 原清单条目(摘要表 / downloads / env / project/file id 都在里面)。
|
||||
pub file: PackFile,
|
||||
/// 已知的实例内相对路径(mrpack 有;CF 需要补全才有)。
|
||||
pub path: Option<String>,
|
||||
/// 来源。
|
||||
pub source: FileSource,
|
||||
/// 是否禁用态(路径后缀)。
|
||||
pub disabled: bool,
|
||||
}
|
||||
|
||||
/// 一次导入的计划。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ImportPlan {
|
||||
/// 命中的格式 id。
|
||||
pub format: String,
|
||||
/// 原始模型(`extra` 等诊断信息留着)。
|
||||
pub manifest: PackManifest,
|
||||
/// 游戏版本。
|
||||
pub mc_version: String,
|
||||
/// 加载器安装计划输入(`None` = 纯原版)。
|
||||
pub loader: Option<LoaderTarget>,
|
||||
/// 实例绑定版本 id。
|
||||
pub version_id: String,
|
||||
/// 不支持的 component(逐项)。
|
||||
pub rejected: Vec<RejectedComponent>,
|
||||
/// 注记。
|
||||
pub notes: Vec<ImportIssue>,
|
||||
/// 覆盖条目(已按侧优先级排好、已剔除 `server-overrides`)。
|
||||
pub overrides: Vec<PlanOverride>,
|
||||
/// 远端条目。
|
||||
pub remote: Vec<PlanRemote>,
|
||||
/// `instance.cfg` 里受开关门控的内存上限(MB)。
|
||||
pub max_memory_mb: Option<u64>,
|
||||
}
|
||||
|
||||
impl ImportPlan {
|
||||
/// 组计划。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 清单缺游戏版本、加载器条目缺版本号、MultiMC 的 `formatVersion != 1` 等"清单自身非法"
|
||||
/// 的情形返回 [`ImportError`](调用方据此整包回滚)。"某条文件缺地址"**不是**错误。
|
||||
pub fn build(
|
||||
pack: &PackArchive,
|
||||
format_id: &str,
|
||||
manifest: PackManifest,
|
||||
) -> Result<Self, ImportError> {
|
||||
let mut notes: Vec<ImportIssue> = Vec::new();
|
||||
let mut rejected: Vec<RejectedComponent> = Vec::new();
|
||||
|
||||
// Prism 的规矩(A 级来源 `PackProfile.cpp:207-210`):`formatVersion` 必须恰为 1,
|
||||
// 否则**整份丢弃**——不是降级解析。M110 的读者把缺省值当 1(老包不写这一行),
|
||||
// 因此这里只拒"写了非 1 的值"。
|
||||
if format_id == multimc::ID && manifest.format_version != Some(1) {
|
||||
return Err(ImportError::from(PackError::metadata(format!(
|
||||
"{} 的 formatVersion 是 {:?},必须恰为 1(Prism 自己也是整份丢弃,不降级解析)",
|
||||
multimc::METADATA_ENTRY,
|
||||
manifest.format_version
|
||||
))));
|
||||
}
|
||||
|
||||
let mc_version = manifest
|
||||
.minecraft
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|version| !version.is_empty())
|
||||
.map(str::to_string)
|
||||
.ok_or_else(|| {
|
||||
ImportError::from(PackError::metadata(
|
||||
"清单里没有游戏版本(CF minecraft.version / mrpack dependencies.minecraft / MultiMC net.minecraft 组件)",
|
||||
))
|
||||
})?;
|
||||
|
||||
let loader = select_loader(&manifest, &mc_version, format_id, &mut notes, &mut rejected)?;
|
||||
let version_id = loader
|
||||
.as_ref()
|
||||
.map_or_else(|| mc_version.clone(), |target| target.version_id.clone());
|
||||
|
||||
let mut overrides: Vec<PlanOverride> = Vec::new();
|
||||
let mut skipped_server_files = 0_usize;
|
||||
for group in layer::ordered(&manifest) {
|
||||
if group.side == PackSide::Server {
|
||||
// Modrinth 官方客户端安装器就不解压 server-overrides/(A 级来源:
|
||||
// install_mrpack.rs 的 strip_prefix 链只有 overrides/ 与 client-overrides/)。
|
||||
skipped_server_files += group.files.len();
|
||||
continue;
|
||||
}
|
||||
for file in &group.files {
|
||||
let variant = suffix_variant(&file.path);
|
||||
overrides.push(PlanOverride {
|
||||
dir: group.dir.clone(),
|
||||
path: file.path.clone(),
|
||||
source: FileSource::from_side(group.side),
|
||||
sha1: file.sha1.clone(),
|
||||
size: file.size,
|
||||
disabled: variant.is_disabled(),
|
||||
note: suffix_note(variant, &file.path),
|
||||
shadowed: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
layer::resolve(&mut overrides, &mut notes);
|
||||
if skipped_server_files > 0 {
|
||||
notes.push(ImportIssue::new(
|
||||
keys::NOTE_SERVER_OVERRIDES_SKIPPED,
|
||||
format!(
|
||||
"server-overrides/ 里的 {skipped_server_files} 个文件是服务端内容,客户端导入不解压(Modrinth 官方语义)"
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
let mut remote: Vec<PlanRemote> = Vec::new();
|
||||
let source = if format_id == curseforge::ID {
|
||||
FileSource::CurseForge
|
||||
} else {
|
||||
FileSource::Modrinth
|
||||
};
|
||||
for file in &manifest.files {
|
||||
if file.client == EnvSupport::Unsupported {
|
||||
notes.push(
|
||||
ImportIssue::new(
|
||||
keys::NOTE_ENV_UNSUPPORTED,
|
||||
"env.client=unsupported:客户端不需要这个文件,跳过".to_string(),
|
||||
)
|
||||
.with_path(file.path.clone().unwrap_or_else(|| file.key())),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let variant = file
|
||||
.path
|
||||
.as_deref()
|
||||
.map_or(SuffixVariant::Plain, suffix_variant);
|
||||
if file.client == EnvSupport::Optional {
|
||||
notes.push(
|
||||
ImportIssue::new(
|
||||
keys::NOTE_OPTIONAL_FILE,
|
||||
"env.client=optional:可选条目照常安装(「可选」不等于「禁用」)"
|
||||
.to_string(),
|
||||
)
|
||||
.with_path(file.path.clone().unwrap_or_else(|| file.key())),
|
||||
);
|
||||
}
|
||||
if let Some(note) = file
|
||||
.path
|
||||
.as_deref()
|
||||
.and_then(|path| suffix_note(variant, path))
|
||||
{
|
||||
notes.push(note);
|
||||
}
|
||||
remote.push(PlanRemote {
|
||||
path: file.path.clone(),
|
||||
source,
|
||||
disabled: variant.is_disabled(),
|
||||
file: file.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
let max_memory_mb = if format_id == multimc::ID {
|
||||
instance_memory(pack, &mut notes)?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
format: format_id.to_string(),
|
||||
manifest,
|
||||
mc_version,
|
||||
loader,
|
||||
version_id,
|
||||
rejected,
|
||||
notes,
|
||||
overrides,
|
||||
remote,
|
||||
max_memory_mb,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 选一个加载器并翻译成 [`LoaderTarget`]。
|
||||
fn select_loader(
|
||||
manifest: &PackManifest,
|
||||
mc_version: &str,
|
||||
format_id: &str,
|
||||
notes: &mut Vec<ImportIssue>,
|
||||
rejected: &mut Vec<RejectedComponent>,
|
||||
) -> Result<Option<LoaderTarget>, ImportError> {
|
||||
let recognized: Vec<&PackLoader> = manifest
|
||||
.loaders
|
||||
.iter()
|
||||
.filter(|loader| loader.kind.is_some())
|
||||
.collect();
|
||||
|
||||
// 认不出的条目:MultiMC 是"不支持的 component"(逐项列),其它格式是"不认识的依赖键"。
|
||||
for loader in manifest
|
||||
.loaders
|
||||
.iter()
|
||||
.filter(|loader| loader.kind.is_none())
|
||||
{
|
||||
if format_id == multimc::ID {
|
||||
rejected.push(RejectedComponent {
|
||||
uid: loader.raw.clone(),
|
||||
version: loader.version.clone(),
|
||||
message_key: keys::REJECTED_COMPONENT,
|
||||
});
|
||||
} else {
|
||||
notes.push(ImportIssue::new(
|
||||
keys::NOTE_UNKNOWN_DEPENDENCY,
|
||||
format!("认不出的加载器/依赖键 {:?}:本启动器不安装它", loader.raw),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if recognized.len() > 1 {
|
||||
notes.push(ImportIssue::new(
|
||||
keys::NOTE_LOADER_MULTIPLE,
|
||||
format!(
|
||||
"清单里有 {} 个可识别的加载器,只按 primary/第一条组计划:{}",
|
||||
recognized.len(),
|
||||
recognized
|
||||
.iter()
|
||||
.map(|loader| loader.raw.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
let Some(loader) = recognized
|
||||
.iter()
|
||||
.find(|loader| loader.primary)
|
||||
.or_else(|| recognized.first())
|
||||
.copied()
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(kind) = loader.kind else {
|
||||
return Ok(None);
|
||||
};
|
||||
let raw_version = loader
|
||||
.version
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|version| !version.is_empty())
|
||||
.ok_or_else(|| {
|
||||
ImportError::loader(format!(
|
||||
"加载器条目 {:?} 没有版本号,组不出安装计划(清单自身非法)",
|
||||
loader.raw
|
||||
))
|
||||
})?;
|
||||
let loader_version = strip_mc_prefix(mc_version, raw_version);
|
||||
Ok(Some(LoaderTarget {
|
||||
kind,
|
||||
mc_version: mc_version.to_string(),
|
||||
version_id: kind.version_id(mc_version, &loader_version),
|
||||
loader_version,
|
||||
raw: loader.raw.clone(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// 从 `instance.cfg` 取受开关门控的内存上限(MB)。
|
||||
fn instance_memory(
|
||||
pack: &PackArchive,
|
||||
notes: &mut Vec<ImportIssue>,
|
||||
) -> Result<Option<u64>, ImportError> {
|
||||
let Some(text) = pack.read_text(multimc::INSTANCE_ENTRY)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let mut values: Vec<(&str, &str)> = Vec::new();
|
||||
for line in text.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
|
||||
continue;
|
||||
}
|
||||
// `[General]` 这类小节标记直接跳过:instance.cfg 平铺,真出现也不该把它们当键。
|
||||
if line.starts_with('[') {
|
||||
continue;
|
||||
}
|
||||
// 值里含 `:` 与 `\`(Windows 路径)是常态,所以只按**第一个** `=` 切。
|
||||
if let Some((key, value)) = line.split_once('=') {
|
||||
values.push((key.trim(), value.trim()));
|
||||
}
|
||||
}
|
||||
let get = |name: &str| -> Option<&str> {
|
||||
values
|
||||
.iter()
|
||||
.find(|(key, _)| key.eq_ignore_ascii_case(name))
|
||||
.map(|(_, value)| *value)
|
||||
};
|
||||
let Some(max) = get("MaxMemAlloc").and_then(|value| value.parse::<u64>().ok()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
// Prism 的规矩:这些值只在开关为 true 时生效(只看值不看开关会误判)。
|
||||
let enabled = get("OverrideMemory").is_some_and(|value| value.eq_ignore_ascii_case("true"));
|
||||
if !enabled {
|
||||
notes.push(ImportIssue::new(
|
||||
keys::NOTE_INSTANCE_CFG_MEMORY,
|
||||
format!("instance.cfg 写了 MaxMemAlloc={max},但 OverrideMemory 未开:不采用"),
|
||||
));
|
||||
return Ok(None);
|
||||
}
|
||||
notes.push(ImportIssue::new(
|
||||
keys::NOTE_INSTANCE_CFG_MEMORY,
|
||||
format!("从 instance.cfg 取到内存上限 {max} MB(OverrideMemory=true)"),
|
||||
));
|
||||
Ok(Some(max))
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
//! 覆盖目录的**叠层顺序**与**同路径遮蔽**:导入计划里"逐文件"那一半的判据(纯计算,无 IO)。
|
||||
//!
|
||||
//! 拆成单独一个文件有两个原因:①`plan.rs` 要守 400 行;②这一层的两条规则(先按侧排、
|
||||
//! 再按路径结算胜负)是导入器里唯一"我们自己定的确定性规则",值得放在一处被单独读完。
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::super::report::{keys, FileSource, ImportIssue};
|
||||
use super::PlanOverride;
|
||||
use crate::addon::pack::{PackManifest, PackOverrides, PackSide};
|
||||
|
||||
/// 「这条覆盖条目被同一份包里的另一条声明遮蔽」。
|
||||
///
|
||||
/// 为什么不是新的 `FileAction` 变体:`FileAction` / `ConflictReason` / `ConflictChoice` 三个
|
||||
/// 枚举被 `crates/presenter/src/host/addon/pack/convert.rs` **穷尽 match**(那个 crate 不在
|
||||
/// `addon` 的作用域里)。所以"遮蔽"这件事走 [`ImportIssue`] 的 note 通道,与
|
||||
/// `server-overrides/ 被跳过` 完全同一条通道。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Shadowed {
|
||||
/// 胜出那条的来源侧。
|
||||
pub winner: FileSource,
|
||||
/// 胜出那条的包内目录名(人读用,例如 `client-overrides`)。
|
||||
pub winner_dir: String,
|
||||
/// 两条声明的字节是否完全相同(`true` = 遮蔽不丢内容,只是重复了一份)。
|
||||
pub same_bytes: bool,
|
||||
}
|
||||
|
||||
/// 覆盖目录按侧排序:`Both`(`overrides/`)先、`Client`(`client-overrides/`)后 = 后者胜。
|
||||
pub(super) fn ordered(manifest: &PackManifest) -> Vec<&PackOverrides> {
|
||||
let mut groups: Vec<&PackOverrides> = manifest.overrides.iter().collect();
|
||||
groups.sort_by_key(|group| match group.side {
|
||||
PackSide::Both => 0_u8,
|
||||
PackSide::Client => 1,
|
||||
PackSide::Server => 2,
|
||||
});
|
||||
groups
|
||||
}
|
||||
|
||||
/// 同一实例路径被本包多份声明命中时:**叠层顺序里最后一条胜**,其余标记为遮蔽并逐条注记。
|
||||
///
|
||||
/// # 这是"逐文件"而不是"整包一个策略"的那一步
|
||||
///
|
||||
/// research/18 §3.3 的 A 级结论:mrpack 的 `overrides/` 与 `client-overrides/` 之间,
|
||||
/// **规范层面只有极少数固定路径有优先级**,其余按 zip 条目顺序落盘;HMCL 连顺序都相反。
|
||||
/// 上一版导入器对此的处理是"按侧排序后逐条写"——结果是对的,但**报告是错的**:同一路径
|
||||
/// 会出现两条 `Installed`,其中前一条的哈希根本没落地,也没人说得清谁遮蔽了谁。
|
||||
///
|
||||
/// 现在把这层算清楚,逐条给出两个事实:
|
||||
///
|
||||
/// 1. **赢了的是哪一侧**(叠层顺序 = [`ordered`] 的 `Both → Client`,同组内按路径排序,
|
||||
/// 所以与 zip 条目顺序无关——那是我们自己定死的确定性规则,不是规范);
|
||||
/// 2. **两份内容是否逐字节相同**(相同 = 重复声明,遮蔽不丢内容;不同 = 真的换了一份)。
|
||||
///
|
||||
/// 判据只看模型里的 `sha1`:M110 扫包时是**现算**的真实内容哈希;手工构造的模型可能撒谎,
|
||||
/// 但那最多让 `same_bytes` 这句注记不准,**谁落地不受影响**(胜者永远按叠层顺序定)。
|
||||
pub(super) fn resolve(overrides: &mut [PlanOverride], notes: &mut Vec<ImportIssue>) {
|
||||
let mut last: HashMap<String, usize> = HashMap::new();
|
||||
for (index, entry) in overrides.iter().enumerate() {
|
||||
last.insert(entry.path.clone(), index);
|
||||
}
|
||||
for index in 0..overrides.len() {
|
||||
let path = overrides[index].path.clone();
|
||||
let Some(&winner_index) = last.get(&path) else {
|
||||
continue;
|
||||
};
|
||||
if winner_index == index {
|
||||
continue;
|
||||
}
|
||||
let winner_dir = overrides[winner_index].dir.clone();
|
||||
let loser_dir = overrides[index].dir.clone();
|
||||
let same_bytes = overrides[winner_index].sha1 == overrides[index].sha1;
|
||||
let conclusion = if same_bytes {
|
||||
"两份内容逐字节相同,只落地一次"
|
||||
} else {
|
||||
"两份内容不同,以这一条为准"
|
||||
};
|
||||
notes.push(
|
||||
ImportIssue::new(
|
||||
keys::NOTE_OVERRIDE_SHADOWED,
|
||||
format!(
|
||||
"{loser_dir}/{path} 与 {winner_dir}/{path} 声明同一实例路径:叠层顺序 \
|
||||
overrides/ 在前、client-overrides/ 在后(Modrinth 官方客户端语义),\
|
||||
{conclusion}"
|
||||
),
|
||||
)
|
||||
.with_path(path),
|
||||
);
|
||||
overrides[index].shadowed = Some(Shadowed {
|
||||
winner: overrides[winner_index].source,
|
||||
winner_dir,
|
||||
same_bytes,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn entry(dir: &str, path: &str, source: FileSource, sha1: &str) -> PlanOverride {
|
||||
PlanOverride {
|
||||
dir: dir.to_string(),
|
||||
path: path.to_string(),
|
||||
source,
|
||||
sha1: sha1.to_string(),
|
||||
size: 1,
|
||||
disabled: false,
|
||||
note: None,
|
||||
shadowed: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_later_layer_wins_and_the_earlier_one_is_shadowed() {
|
||||
// 叠层顺序:overrides/(Both)在前、client-overrides/(Client)在后 ⇒ 后者胜。
|
||||
let mut overrides = vec![
|
||||
entry(
|
||||
"overrides",
|
||||
"config/dup.toml",
|
||||
FileSource::Overrides,
|
||||
"aaaa",
|
||||
),
|
||||
entry(
|
||||
"client-overrides",
|
||||
"config/dup.toml",
|
||||
FileSource::ClientOverrides,
|
||||
"bbbb",
|
||||
),
|
||||
];
|
||||
let mut notes = Vec::new();
|
||||
resolve(&mut overrides, &mut notes);
|
||||
|
||||
let shadowed = overrides[0].shadowed.as_ref().expect("前一条要被标记");
|
||||
assert_eq!(shadowed.winner, FileSource::ClientOverrides);
|
||||
assert_eq!(shadowed.winner_dir, "client-overrides");
|
||||
assert!(!shadowed.same_bytes, "同路径不同哈希");
|
||||
assert!(overrides[1].shadowed.is_none(), "胜者不标遮蔽");
|
||||
assert_eq!(notes.len(), 1);
|
||||
assert_eq!(notes[0].message_key, keys::NOTE_OVERRIDE_SHADOWED);
|
||||
assert_eq!(notes[0].path.as_deref(), Some("config/dup.toml"));
|
||||
let message = ¬es[0].message;
|
||||
for expected in [
|
||||
"overrides/config/dup.toml",
|
||||
"client-overrides/config/dup.toml",
|
||||
"内容不同",
|
||||
] {
|
||||
assert!(message.contains(expected), "缺 {expected}:{message}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identical_bytes_are_reported_as_same_bytes() {
|
||||
let mut overrides = vec![
|
||||
entry(
|
||||
"overrides",
|
||||
"config/same.toml",
|
||||
FileSource::Overrides,
|
||||
"aaaa",
|
||||
),
|
||||
entry(
|
||||
"client-overrides",
|
||||
"config/same.toml",
|
||||
FileSource::ClientOverrides,
|
||||
"aaaa",
|
||||
),
|
||||
];
|
||||
let mut notes = Vec::new();
|
||||
resolve(&mut overrides, &mut notes);
|
||||
|
||||
assert!(overrides[0].shadowed.as_ref().expect("标记").same_bytes);
|
||||
assert!(
|
||||
notes[0].message.contains("逐字节相同"),
|
||||
"内容一致要说清楚,别让人以为丢了东西:{}",
|
||||
notes[0].message
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_paths_with_the_same_bytes_are_never_shadowed() {
|
||||
// 同哈希不同路径 = 两份文件,不是重复声明。按哈希去重会**静默丢一个文件**。
|
||||
let mut overrides = vec![
|
||||
entry("overrides", "mods/a.jar", FileSource::Overrides, "aaaa"),
|
||||
entry("overrides", "mods/b.jar", FileSource::Overrides, "aaaa"),
|
||||
];
|
||||
let mut notes = Vec::new();
|
||||
resolve(&mut overrides, &mut notes);
|
||||
|
||||
assert!(overrides.iter().all(|entry| entry.shadowed.is_none()));
|
||||
assert!(notes.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_same_path_twice_inside_one_group_is_shadowed_too() {
|
||||
// 同侧重复(模型被合并 / 手造时才会出现):照样只落地一次,且注记两份来自同一目录。
|
||||
let mut overrides = vec![
|
||||
entry("overrides", "mods/x.jar", FileSource::Overrides, "aaaa"),
|
||||
entry("overrides", "mods/x.jar", FileSource::Overrides, "bbbb"),
|
||||
];
|
||||
let mut notes = Vec::new();
|
||||
resolve(&mut overrides, &mut notes);
|
||||
|
||||
assert_eq!(
|
||||
overrides[0].shadowed.as_ref().expect("标记").winner,
|
||||
FileSource::Overrides
|
||||
);
|
||||
assert_eq!(notes.len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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);
|
||||
}
|
||||
}
|
||||
@@ -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('\\', "/")
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
));
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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, ¤t)?;
|
||||
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}"))
|
||||
})
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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/"}
|
||||
]
|
||||
}"#;
|
||||
@@ -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` 认可的那几家"
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 {}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}"#;
|
||||
@@ -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;
|
||||
@@ -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(",")
|
||||
)
|
||||
}
|
||||
@@ -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}")))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
//! 打开与首轮校验:目录遍历 / zip 中央目录扫描,两道穿越检查、符号链接、限额都在这里。
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::fs::File;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use zip::ZipArchive;
|
||||
|
||||
use super::entry::PackEntryInfo;
|
||||
use super::limits::{check_limits, PackLimits};
|
||||
use super::{PackArchive, Source};
|
||||
use crate::addon::pack::error::{PackError, PackResult};
|
||||
use crate::addon::pack::path::{has_trailing_slash, normalize_file};
|
||||
|
||||
impl PackArchive {
|
||||
pub(super) fn open_dir(root: PathBuf, limits: PackLimits) -> PackResult<Self> {
|
||||
let mut entries = Vec::new();
|
||||
let mut total: u64 = 0;
|
||||
// 栈式遍历:不递归,避免深层目录把栈打穿。
|
||||
let mut pending = vec![String::new()];
|
||||
while let Some(relative) = pending.pop() {
|
||||
let dir = if relative.is_empty() {
|
||||
root.clone()
|
||||
} else {
|
||||
root.join(&relative)
|
||||
};
|
||||
let mut children = std::fs::read_dir(&dir)
|
||||
.map_err(|error| PackError::io(format!("读目录 {} 失败:{error}", dir.display())))?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(PackError::from)?;
|
||||
children.sort_by_key(std::fs::DirEntry::file_name);
|
||||
for child in children {
|
||||
let file_name =
|
||||
child
|
||||
.file_name()
|
||||
.to_str()
|
||||
.map(str::to_string)
|
||||
.ok_or_else(|| {
|
||||
PackError::metadata(format!(
|
||||
"包内文件名不是 UTF-8:{}",
|
||||
child.path().display()
|
||||
))
|
||||
})?;
|
||||
let raw = if relative.is_empty() {
|
||||
file_name
|
||||
} else {
|
||||
format!("{relative}/{file_name}")
|
||||
};
|
||||
let file_type = child
|
||||
.file_type()
|
||||
.map_err(|error| PackError::io(format!("读 {} 类型失败:{error}", raw)))?;
|
||||
if file_type.is_symlink() {
|
||||
return Err(PackError::symlink(format!("整合包里有符号链接条目:{raw}")));
|
||||
}
|
||||
let name = normalize_file(&raw)?;
|
||||
if file_type.is_dir() {
|
||||
check_limits(&limits, &mut total, 0, &name, entries.len())?;
|
||||
pending.push(name.clone());
|
||||
entries.push(PackEntryInfo {
|
||||
name,
|
||||
raw,
|
||||
size: 0,
|
||||
is_dir: true,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if !file_type.is_file() {
|
||||
continue;
|
||||
}
|
||||
let size = child
|
||||
.metadata()
|
||||
.map_err(|error| PackError::io(format!("读 {raw} 大小失败:{error}")))?
|
||||
.len();
|
||||
check_limits(&limits, &mut total, size, &name, entries.len())?;
|
||||
entries.push(PackEntryInfo {
|
||||
name,
|
||||
raw,
|
||||
size,
|
||||
is_dir: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
entries.sort_by(|left, right| left.name.cmp(&right.name));
|
||||
Self::finish(root, Source::Dir, entries, limits)
|
||||
}
|
||||
|
||||
pub(super) fn open_zip(root: PathBuf, limits: PackLimits) -> PackResult<Self> {
|
||||
let file = File::open(&root)
|
||||
.map_err(|error| PackError::io(format!("打开 {} 失败:{error}", root.display())))?;
|
||||
let mut archive = ZipArchive::new(file)
|
||||
.map_err(|error| PackError::metadata(format!("不是可读的 zip:{error}")))?;
|
||||
if archive.len() > limits.max_entries {
|
||||
return Err(PackError::limits(format!(
|
||||
"zip 条目数 {} 超过上限 {}",
|
||||
archive.len(),
|
||||
limits.max_entries
|
||||
)));
|
||||
}
|
||||
let mut entries = Vec::with_capacity(archive.len());
|
||||
let mut total: u64 = 0;
|
||||
for index in 0..archive.len() {
|
||||
let entry = archive.by_index(index).map_err(|error| {
|
||||
PackError::metadata(format!("读第 {index} 个条目失败:{error}"))
|
||||
})?;
|
||||
let Ok(raw) = entry.name() else {
|
||||
return Err(PackError::metadata(format!(
|
||||
"第 {index} 个条目名编码不合法"
|
||||
)));
|
||||
};
|
||||
let raw = raw.to_string();
|
||||
let is_dir = entry.is_dir() || has_trailing_slash(&raw);
|
||||
if entry.is_symlink() {
|
||||
return Err(PackError::symlink(format!("zip 里有符号链接条目:{raw}")));
|
||||
}
|
||||
let name = normalize_file(&raw)?;
|
||||
// 第二检:zip crate 按 Windows 规则再解析一遍(盘符 / ADS / 保留设备名)。
|
||||
if entry.enclosed_name().is_none() {
|
||||
return Err(PackError::traversal(format!(
|
||||
"zip 条目名过不了 enclosed_name 检查:{raw}"
|
||||
)));
|
||||
}
|
||||
let size = if is_dir { 0 } else { entry.size() };
|
||||
check_limits(&limits, &mut total, size, &name, entries.len())?;
|
||||
entries.push(PackEntryInfo {
|
||||
name,
|
||||
raw,
|
||||
size,
|
||||
is_dir,
|
||||
});
|
||||
}
|
||||
Self::finish(root, Source::Zip(RefCell::new(archive)), entries, limits)
|
||||
}
|
||||
|
||||
fn finish(
|
||||
root: PathBuf,
|
||||
source: Source,
|
||||
entries: Vec<PackEntryInfo>,
|
||||
limits: PackLimits,
|
||||
) -> PackResult<Self> {
|
||||
let mut index = HashMap::with_capacity(entries.len());
|
||||
for (slot, entry) in entries.iter().enumerate() {
|
||||
if index.insert(entry.name.clone(), slot).is_some() {
|
||||
return Err(PackError::metadata(format!(
|
||||
"包里有规整后重名的条目:{}",
|
||||
entry.name
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(Self {
|
||||
root,
|
||||
source,
|
||||
entries,
|
||||
index,
|
||||
limits,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
//! CurseForge 格式:`manifest.json` + `overrides/`。
|
||||
//!
|
||||
//! # 形状(A 级来源:HMCL `CurseManifest.java` / `CurseManifestFile.java`,见 analyse/05 §2.2)
|
||||
//!
|
||||
//! ```jsonc
|
||||
//! {
|
||||
//! "manifestType": "minecraftModpack", "manifestVersion": 1,
|
||||
//! "name": "…", "version": "1.0.0", "author": "…",
|
||||
//! "overrides": "overrides",
|
||||
//! "minecraft": {"version": "1.20.1", "modLoaders": [{"id": "forge-47.2.0", "primary": true}]},
|
||||
//! "files": [{"projectID": 238222, "fileID": 5102025, "required": true}]
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! 关键缺口(**模型如实记录,不假装有**):
|
||||
//!
|
||||
//! - `files[]` **没有路径、没有 URL、没有哈希**。路径要按项目 `classId` 猜(`mods/` /
|
||||
//! `resourcepacks/` / `shaderpacks/`),文件名要按 ForgeCDN 规则或 API 补——这正是 D10 §2
|
||||
//! 两阶段补全要补的东西,属于 M111,不在本模块。所以 [`PackFile::path`] 读出来是 `None`。
|
||||
//! - `minecraft.modLoaders[].id` 只认 `forge-` / `neoforge-` / `fabric-` / `quilt-` 前缀
|
||||
//! (CurseForge 没有 Quilt 的正规表达方式);不认识的 id **原样保留**在
|
||||
//! [`PackLoader::raw`],写回时逐字还原。
|
||||
//! - 只有一个 `required` 布尔 → 客户端/服务端同值;导出时 `client == Optional` 或
|
||||
//! `disabled` 即 `required: false`。`client`/`server` 分侧语义 CF 表达不了(mrpack 专属)。
|
||||
//! - **跨格式导出不完整**(M112 的活):`modLoaders[].id` 只对 CF 来源是合法形状;
|
||||
//! MultiMC 的 `net.fabricmc.fabric-loader` / 非加载器组件会被逐字写进去(CF 不认);
|
||||
//! `overrides` 字段会照抄模型里的目录名(从 MultiMC 转过来是 `.minecraft`)。
|
||||
//! 按 CF 语义 `overrides` 只是 zip 内**前缀**,导入器把该前缀下的内容铺到游戏目录,
|
||||
//! 所以内容不会丢;但"前缀目录本身要不要跟着拷一层"取决于导入器实现(Prism/ULL 的实例根
|
||||
//! 本身就叫 `.minecraft` 时可能多出一层)。同一份模型只有 `read` 出来的那个格式才保证往返。
|
||||
//!
|
||||
//! CF 的**文件指纹**(`MurmurHash2-32` + 空白过滤)是导出侧「能否被远程替代」的判据,见
|
||||
//! [`fingerprint`]:它是"CF 格式知识",不属于导出流程,所以留在这里而不是 `addon::export`。
|
||||
|
||||
pub mod fingerprint;
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
use super::archive::PackArchive;
|
||||
use super::error::{PackError, PackResult};
|
||||
use super::format::PackFormat;
|
||||
use super::manifest::{
|
||||
merge_unknown, EnvSupport, PackFile, PackLoader, PackManifest, PackOverrides, PackSide,
|
||||
};
|
||||
use super::path::normalize_dir;
|
||||
|
||||
/// 格式 id。
|
||||
pub const ID: &str = "curseforge";
|
||||
/// 清单入口名。
|
||||
pub const METADATA_ENTRY: &str = "manifest.json";
|
||||
/// `manifestType` 的固定值。
|
||||
pub const MARKER: &str = "minecraftModpack";
|
||||
/// 默认覆盖目录名。
|
||||
pub const DEFAULT_OVERRIDES: &str = "overrides";
|
||||
|
||||
/// 单例(进注册表用)。
|
||||
pub static CURSEFORGE: CurseForge = CurseForge;
|
||||
|
||||
/// CurseForge 格式的值类型实现。
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct CurseForge;
|
||||
|
||||
/// `manifest.json` 的原始形状(未知字段落 `extra`)。
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct RawManifest {
|
||||
#[serde(rename = "manifestType", default)]
|
||||
manifest_type: Option<String>,
|
||||
#[serde(rename = "manifestVersion", default)]
|
||||
manifest_version: Option<i64>,
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
#[serde(default)]
|
||||
version: Option<String>,
|
||||
#[serde(default)]
|
||||
author: Option<String>,
|
||||
#[serde(default)]
|
||||
overrides: Option<String>,
|
||||
#[serde(default)]
|
||||
minecraft: Option<RawMinecraft>,
|
||||
#[serde(default)]
|
||||
files: Vec<RawFile>,
|
||||
#[serde(flatten)]
|
||||
extra: Map<String, Value>,
|
||||
}
|
||||
|
||||
/// `minecraft` 对象。
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct RawMinecraft {
|
||||
#[serde(default)]
|
||||
version: Option<String>,
|
||||
#[serde(rename = "modLoaders", default)]
|
||||
mod_loaders: Vec<RawLoader>,
|
||||
#[serde(flatten)]
|
||||
extra: Map<String, Value>,
|
||||
}
|
||||
|
||||
/// `minecraft.modLoaders[]` 的一条。
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct RawLoader {
|
||||
#[serde(default)]
|
||||
id: Option<String>,
|
||||
#[serde(default)]
|
||||
primary: bool,
|
||||
#[serde(flatten)]
|
||||
extra: Map<String, Value>,
|
||||
}
|
||||
|
||||
/// `files[]` 的一条。
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct RawFile {
|
||||
#[serde(rename = "projectID", default)]
|
||||
project_id: Option<i64>,
|
||||
#[serde(rename = "fileID", default)]
|
||||
file_id: Option<i64>,
|
||||
#[serde(default = "default_required")]
|
||||
required: bool,
|
||||
#[serde(flatten)]
|
||||
extra: Map<String, Value>,
|
||||
}
|
||||
|
||||
/// CF 的 `required` 缺省是 `true`(老包可能不写)。
|
||||
const fn default_required() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl PackFormat for CurseForge {
|
||||
fn id(&self) -> &'static str {
|
||||
ID
|
||||
}
|
||||
|
||||
fn label(&self) -> &'static str {
|
||||
"CurseForge"
|
||||
}
|
||||
|
||||
fn metadata_entry(&self) -> Option<&'static str> {
|
||||
Some(METADATA_ENTRY)
|
||||
}
|
||||
|
||||
fn detect(&self, pack: &PackArchive) -> PackResult<Option<&'static str>> {
|
||||
// 文件不存在 ≠ 包坏了:没有 `manifest.json` 就是"不是本格式"。
|
||||
let Some(value) = pack.read_json(METADATA_ENTRY)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
// 文件在、但不是对象 = 形状坏了(`pack_metadata`),不是"不认识"(r1 p3-2)。
|
||||
let object = value
|
||||
.as_object()
|
||||
.ok_or_else(|| PackError::metadata(format!("{METADATA_ENTRY} 存在但不是 JSON 对象")))?;
|
||||
let matched = match object.get("manifestType").and_then(Value::as_str) {
|
||||
// 有标记:只认固定值(`manifest.json` 这名字太泛,光凭名字不能认领)。
|
||||
Some(kind) => kind == MARKER,
|
||||
// 没有标记的老包:要求形状对得上(有 minecraft 对象 + files 数组)。
|
||||
None => {
|
||||
object.get("minecraft").is_some_and(Value::is_object)
|
||||
&& object.get("files").is_some_and(Value::is_array)
|
||||
}
|
||||
};
|
||||
Ok(matched.then_some(METADATA_ENTRY))
|
||||
}
|
||||
|
||||
fn check_overrides(&self, manifest: &PackManifest) -> PackResult<()> {
|
||||
// CF 只有**一个** `overrides` 目录,且清单里只有一个字段能声明它 ⇒ 只表达得了
|
||||
// 一个 Both 侧组。Client/Server 分侧是 mrpack 的东西,这里宁可拒也不猜(r1 p2-1)。
|
||||
let mut both = 0_usize;
|
||||
for group in &manifest.overrides {
|
||||
if group.side != PackSide::Both {
|
||||
return Err(PackError::unsupported(format!(
|
||||
"CurseForge 只有一个 overrides 目录,表达不了 {} 侧的覆盖内容({} 个文件)",
|
||||
group.side.as_str(),
|
||||
group.files.len()
|
||||
))
|
||||
.with_hint("先按侧合并(或去掉不该带的组)再导出;分侧目录映射属 M112"));
|
||||
}
|
||||
both += 1;
|
||||
}
|
||||
if both > 1 {
|
||||
return Err(PackError::unsupported(
|
||||
"CurseForge 只有一个 overrides 目录字段,模型里却有多个 Both 侧覆盖组",
|
||||
)
|
||||
.with_hint("把它们合成一个组再导出;目录名映射属 M112"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read(&self, pack: &PackArchive) -> PackResult<PackManifest> {
|
||||
let value = pack
|
||||
.read_json(METADATA_ENTRY)?
|
||||
.ok_or_else(|| PackError::metadata(format!("包里没有 {METADATA_ENTRY}")))?;
|
||||
let raw: RawManifest = serde_json::from_value(value)
|
||||
.map_err(|error| PackError::metadata(format!("{METADATA_ENTRY} 形状不符:{error}")))?;
|
||||
if let Some(kind) = &raw.manifest_type {
|
||||
if kind != MARKER {
|
||||
return Err(PackError::unsupported(format!(
|
||||
"{METADATA_ENTRY} 的 manifestType 是 {kind:?},不是 {MARKER:?}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
let name = raw
|
||||
.name
|
||||
.filter(|name| !name.trim().is_empty())
|
||||
.ok_or_else(|| PackError::metadata(format!("{METADATA_ENTRY} 缺 name")))?;
|
||||
let minecraft = raw.minecraft.unwrap_or_default();
|
||||
let game_version = minecraft
|
||||
.version
|
||||
.filter(|version| !version.trim().is_empty())
|
||||
.ok_or_else(|| PackError::metadata(format!("{METADATA_ENTRY} 缺 minecraft.version")))?;
|
||||
|
||||
let loaders = minecraft
|
||||
.mod_loaders
|
||||
.into_iter()
|
||||
.map(|loader| {
|
||||
let mut parsed = PackLoader::parse_curse(
|
||||
loader.id.as_deref().unwrap_or_default(),
|
||||
loader.primary,
|
||||
);
|
||||
parsed.extra = loader.extra;
|
||||
parsed
|
||||
})
|
||||
.collect();
|
||||
|
||||
let files = raw
|
||||
.files
|
||||
.into_iter()
|
||||
.map(|file| {
|
||||
let env = if file.required {
|
||||
EnvSupport::Required
|
||||
} else {
|
||||
EnvSupport::Optional
|
||||
};
|
||||
PackFile {
|
||||
project_id: file.project_id,
|
||||
file_id: file.file_id,
|
||||
client: env,
|
||||
server: env,
|
||||
extra: file.extra,
|
||||
..PackFile::default()
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// CF 的 `overrides` 字段缺省就是 `"overrides"`(规范默认值),`render` 也总会写它;
|
||||
// 读侧因此**永远**产出一个 Both 侧组,读写才对称(dir 不存在时 files 为空表)。
|
||||
let overrides_dir = raw
|
||||
.overrides
|
||||
.as_deref()
|
||||
.filter(|dir| !dir.trim().is_empty())
|
||||
.unwrap_or(DEFAULT_OVERRIDES);
|
||||
let overrides_dir = normalize_dir(overrides_dir)?;
|
||||
let overrides = vec![PackOverrides {
|
||||
files: pack.override_files(&overrides_dir)?,
|
||||
dir: overrides_dir,
|
||||
side: PackSide::Both,
|
||||
}];
|
||||
|
||||
Ok(PackManifest {
|
||||
name,
|
||||
version: raw.version,
|
||||
author: raw.author,
|
||||
summary: None,
|
||||
format_version: raw.manifest_version,
|
||||
game: None,
|
||||
minecraft: Some(game_version),
|
||||
loaders,
|
||||
files,
|
||||
overrides,
|
||||
extra: raw.extra,
|
||||
minecraft_extra: minecraft.extra,
|
||||
})
|
||||
}
|
||||
|
||||
fn render(&self, manifest: &PackManifest) -> PackResult<String> {
|
||||
let game_version = manifest.minecraft.as_deref().ok_or_else(|| {
|
||||
PackError::metadata("CurseForge 清单需要 minecraft.version,模型里没有")
|
||||
})?;
|
||||
let mut root = Map::new();
|
||||
root.insert("manifestType".into(), json!(MARKER));
|
||||
root.insert(
|
||||
"manifestVersion".into(),
|
||||
json!(manifest.format_version.unwrap_or(1)),
|
||||
);
|
||||
root.insert("name".into(), json!(manifest.name));
|
||||
if let Some(version) = &manifest.version {
|
||||
root.insert("version".into(), json!(version));
|
||||
}
|
||||
if let Some(author) = &manifest.author {
|
||||
root.insert("author".into(), json!(author));
|
||||
}
|
||||
let overrides_dir = manifest
|
||||
.overrides_for(PackSide::Both)
|
||||
.map_or(DEFAULT_OVERRIDES, |group| group.dir.as_str());
|
||||
root.insert("overrides".into(), json!(overrides_dir));
|
||||
|
||||
let mut minecraft = Map::new();
|
||||
minecraft.insert("version".into(), json!(game_version));
|
||||
let mut mod_loaders = Vec::with_capacity(manifest.loaders.len());
|
||||
for loader in &manifest.loaders {
|
||||
let id = loader.curse_id_for(Some(game_version)).ok_or_else(|| {
|
||||
PackError::unsupported(format!(
|
||||
"CurseForge 表达不了这条加载器依赖:raw={:?} kind={:?} version={:?}",
|
||||
loader.raw, loader.kind, loader.version
|
||||
))
|
||||
})?;
|
||||
let mut object = Map::new();
|
||||
object.insert("id".into(), json!(id));
|
||||
object.insert("primary".into(), json!(loader.primary));
|
||||
merge_unknown(&mut object, &loader.extra);
|
||||
mod_loaders.push(Value::Object(object));
|
||||
}
|
||||
minecraft.insert("modLoaders".into(), Value::Array(mod_loaders));
|
||||
merge_unknown(&mut minecraft, &manifest.minecraft_extra);
|
||||
root.insert("minecraft".into(), Value::Object(minecraft));
|
||||
|
||||
let mut files = Vec::with_capacity(manifest.files.len());
|
||||
for file in &manifest.files {
|
||||
let (Some(project_id), Some(file_id)) = (file.project_id, file.file_id) else {
|
||||
return Err(PackError::unsupported(format!(
|
||||
"CurseForge 的 files[] 需要 projectID/fileID,这条没有(path={:?})",
|
||||
file.path
|
||||
))
|
||||
.with_hint("先用 CurseForge API 补全条目再导出,或改用 Modrinth 格式"));
|
||||
};
|
||||
let mut object = Map::new();
|
||||
object.insert("projectID".into(), json!(project_id));
|
||||
object.insert("fileID".into(), json!(file_id));
|
||||
// CF 只有一个 `required` 布尔:`optional` 与"禁用"都只能落到 `false`
|
||||
// (Prism 也是用 `required: false` 表达禁用的,见 M115 调研 §6)。
|
||||
object.insert(
|
||||
"required".into(),
|
||||
json!(!(file.client.is_optional() || file.disabled)),
|
||||
);
|
||||
merge_unknown(&mut object, &file.extra);
|
||||
files.push(Value::Object(object));
|
||||
}
|
||||
root.insert("files".into(), Value::Array(files));
|
||||
|
||||
merge_unknown(&mut root, &manifest.extra);
|
||||
serde_json::to_string_pretty(&Value::Object(root)).map_err(Into::into)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
//! CurseForge 文件指纹(`MurmurHash2-32` + 空白字节过滤),导出侧的「能否被远程替代」判据之一。
|
||||
//!
|
||||
//! # 算法(A 级规范缺失,这里是 B 级实现 + 双向验证)
|
||||
//!
|
||||
//! ```text
|
||||
//! data = 原文件去掉所有 0x09 / 0x0A / 0x0D / 0x20 字节
|
||||
//! fingerprint = MurmurHash2_32(data, seed = 1, len = data 的长度)
|
||||
//! ```
|
||||
//!
|
||||
//! 三条容易写错的点,逐条钉住:
|
||||
//!
|
||||
//! 1. **长度用过滤后的长度**参与初始态(`h = seed ^ len`),不是原文件长度;
|
||||
//! 2. **seed 恒为 1**(MurmurHash2 的默认 seed 是 `0x9747b28c`,那是别的调用方的约定);
|
||||
//! 3. 过滤是**逐字节**的,只在 0x09/0x0A/0x0D/0x20 上生效——不是"去空白"(`0x0B` 竖tab、
|
||||
//! `0x0C` 换页都保留)。
|
||||
//!
|
||||
//! # 来源与验证
|
||||
//!
|
||||
//! CurseForge 官方只公开 `POST /v1/fingerprints/432` 端点,**没有公开算法**(A 级规范不存在)。
|
||||
//! 这里的实现对齐 B 级参考 `HMCL CurseForgeRemoteAddonRepository.calculateFingerprint`
|
||||
//! 与 `MurmurHash2.hash32(byte[], int, int)`(SMHasher `MurmurHash2.cpp` 的忠实转写)。
|
||||
//!
|
||||
//! **双向验证**(`tests` 模块):一组固定输入的金标准值由一份**独立的 Java 实现**(HMCL 两份
|
||||
//! 源码的逐行移植,不在本仓库内)算出后写死在这里;Rust 侧同时跑「一次性字节序」与「流式文件」
|
||||
//! 两条路径,两条都要与金标准相等。所以这一层是"两套实现 + 两个入口"的四向对齐,而不是自证。
|
||||
//! 仍未验的部分:**真实 CF 文件**的官方指纹(要网络 + `x-api-key`,本任务环境离线)。
|
||||
//!
|
||||
//! # 哨兵值 `811513880`
|
||||
//!
|
||||
//! 这个值会误命中(HMCL issue #4597 的临时规避):无论文件是什么,只要算出这个数就不能当成
|
||||
//! "已找到"。判据写成 [`is_sentinel`],由调用方(导出管线)在查表前短路。
|
||||
|
||||
use std::fs::File;
|
||||
use std::io::{self, Read};
|
||||
use std::path::Path;
|
||||
|
||||
/// 误命中的哨兵指纹(HMCL issue #4597)。命中时按「查不到」处理。
|
||||
pub const SENTINEL: u32 = 811_513_880;
|
||||
|
||||
/// 被过滤掉的四个字节:水平制表、换行、回车、空格。
|
||||
pub const FILTERED: [u8; 4] = [0x09, 0x0a, 0x0d, 0x20];
|
||||
|
||||
/// 指纹查表时是否该按「没找到」处理。
|
||||
#[must_use]
|
||||
pub const fn is_sentinel(fingerprint: u32) -> bool {
|
||||
fingerprint == SENTINEL
|
||||
}
|
||||
|
||||
/// 这个字节参与计算吗(`0x09` / `0x0A` / `0x0D` / `0x20` 不参与)。
|
||||
#[must_use]
|
||||
pub const fn keeps(byte: u8) -> bool {
|
||||
!matches!(byte, 0x09 | 0x0a | 0x0d | 0x20)
|
||||
}
|
||||
|
||||
/// 对**内存里的一份内容**算指纹(过滤 + 一次性哈希)。
|
||||
///
|
||||
/// 小文件与测试用;大文件请用 [`fingerprint_file`](两遍流式读,不把整份读进内存)。
|
||||
#[must_use]
|
||||
pub fn fingerprint_bytes(bytes: &[u8]) -> u32 {
|
||||
let filtered: Vec<u8> = bytes.iter().copied().filter(|byte| keeps(*byte)).collect();
|
||||
murmur2(&filtered)
|
||||
}
|
||||
|
||||
/// 对一个**文件**算指纹:两遍流式读(先数过滤后长度,再喂哈希)。
|
||||
///
|
||||
/// MurmurHash2 的初始态要事先知道总长度,所以必须两遍;这里不把文件读进内存(mod jar 可以
|
||||
/// 有几百 MB,整包导出要对每个文件算一次)。
|
||||
pub fn fingerprint_file(path: &Path) -> io::Result<u32> {
|
||||
let mut filtered_length = 0_u64;
|
||||
let mut buffer = vec![0_u8; 1024 * 1024];
|
||||
let mut file = File::open(path)?;
|
||||
loop {
|
||||
let read = file.read(&mut buffer)?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
filtered_length += buffer[..read].iter().filter(|byte| keeps(**byte)).count() as u64;
|
||||
}
|
||||
|
||||
// 长度参数是 u32(MurmurHash2-32 的初始态),超了要报错而不是截断。
|
||||
let length = u32::try_from(filtered_length).map_err(|_| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("文件过滤后仍有 {filtered_length} 字节,超出指纹长度参数(u32)上限"),
|
||||
)
|
||||
})?;
|
||||
let mut hasher = Stream::new(length, 1);
|
||||
file = File::open(path)?;
|
||||
loop {
|
||||
let read = file.read(&mut buffer)?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
hasher.update(&buffer[..read]);
|
||||
}
|
||||
Ok(hasher.finish())
|
||||
}
|
||||
|
||||
/// MurmurHash2 32 位,seed 固定 1(CurseForge 的约定)。
|
||||
///
|
||||
/// 实现是 SMHasher `MurmurHash2.cpp` 的形状:按 4 字节小端块混合,尾部 1–3 字节按小端补齐后
|
||||
/// 再乘一次;收尾两次移位混合。**不在这里做字节过滤**(调用方先过滤,见 [`fingerprint_bytes`])。
|
||||
#[must_use]
|
||||
pub fn murmur2(data: &[u8]) -> u32 {
|
||||
const M: u32 = 0x5bd1_e995;
|
||||
const R: u32 = 24;
|
||||
|
||||
let mut h = 1_u32 ^ (data.len() as u32);
|
||||
let blocks = data.len() / 4;
|
||||
for index in 0..blocks {
|
||||
let mut k = u32::from_le_bytes([
|
||||
data[index * 4],
|
||||
data[index * 4 + 1],
|
||||
data[index * 4 + 2],
|
||||
data[index * 4 + 3],
|
||||
]);
|
||||
k = k.wrapping_mul(M);
|
||||
k ^= k >> R;
|
||||
k = k.wrapping_mul(M);
|
||||
h = h.wrapping_mul(M);
|
||||
h ^= k;
|
||||
}
|
||||
let tail = &data[blocks * 4..];
|
||||
if !tail.is_empty() {
|
||||
let mut packed = 0_u32;
|
||||
for (shift, byte) in tail.iter().enumerate() {
|
||||
packed |= u32::from(*byte) << (8 * shift);
|
||||
}
|
||||
h ^= packed;
|
||||
h = h.wrapping_mul(M);
|
||||
}
|
||||
h ^= h >> 13;
|
||||
h = h.wrapping_mul(M);
|
||||
h ^= h >> 15;
|
||||
h
|
||||
}
|
||||
|
||||
/// 流式 MurmurHash2:长度先知道,字节可以分任意多段喂进来。
|
||||
struct Stream {
|
||||
hash: u32,
|
||||
tail: u32,
|
||||
tail_len: u32,
|
||||
}
|
||||
|
||||
impl Stream {
|
||||
const fn new(length: u32, seed: u32) -> Self {
|
||||
Self {
|
||||
hash: seed ^ length,
|
||||
tail: 0,
|
||||
tail_len: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, bytes: &[u8]) {
|
||||
const M: u32 = 0x5bd1_e995;
|
||||
const R: u32 = 24;
|
||||
for byte in bytes {
|
||||
if !keeps(*byte) {
|
||||
continue;
|
||||
}
|
||||
self.tail |= u32::from(*byte) << (8 * self.tail_len);
|
||||
self.tail_len += 1;
|
||||
if self.tail_len == 4 {
|
||||
let mut k = self.tail;
|
||||
k = k.wrapping_mul(M);
|
||||
k ^= k >> R;
|
||||
k = k.wrapping_mul(M);
|
||||
self.hash = self.hash.wrapping_mul(M);
|
||||
self.hash ^= k;
|
||||
self.tail = 0;
|
||||
self.tail_len = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(mut self) -> u32 {
|
||||
const M: u32 = 0x5bd1_e995;
|
||||
if self.tail_len != 0 {
|
||||
self.hash ^= self.tail;
|
||||
self.hash = self.hash.wrapping_mul(M);
|
||||
}
|
||||
self.hash ^= self.hash >> 13;
|
||||
self.hash = self.hash.wrapping_mul(M);
|
||||
self.hash ^= self.hash >> 15;
|
||||
self.hash
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// 金标准值由一份独立的 Java 实现(HMCL `MurmurHash2.hash32` + `calculateFingerprint` 的
|
||||
/// 逐行移植)对同一批输入算出。改了算法这两列会同时红——这就是"双向验证"的用处。
|
||||
const GOLDEN: &[(&str, &[u8], u32)] = &[
|
||||
("空文件", b"", 1_540_447_798),
|
||||
("只有空白", b" \t\r\n \t\r\n", 1_540_447_798),
|
||||
(
|
||||
"夹具 jar 存根",
|
||||
b"PK\x03\x04 hand-made jar stub",
|
||||
489_715_614,
|
||||
),
|
||||
("混入空白", b"\x00\t\n\r AB\tC\n\r \x7f\xff", 3_734_724_774),
|
||||
(
|
||||
"ascii 36",
|
||||
b"abcdefghijklmnopqrstuvwxyz0123456789",
|
||||
3_026_742_571,
|
||||
),
|
||||
];
|
||||
|
||||
fn pattern(length: usize) -> Vec<u8> {
|
||||
(0..length).map(|index| (index & 0xff) as u8).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_the_independent_implementation() {
|
||||
for (label, bytes, expected) in GOLDEN {
|
||||
assert_eq!(fingerprint_bytes(bytes), *expected, "{label}(一次性路径)");
|
||||
}
|
||||
assert_eq!(
|
||||
fingerprint_bytes(&pattern(512)),
|
||||
3_451_065_282,
|
||||
"pattern-512"
|
||||
);
|
||||
assert_eq!(fingerprint_bytes(&pattern(5)), 813_985_283, "pattern-5");
|
||||
assert_eq!(fingerprint_bytes(&pattern(7)), 2_174_351_703, "pattern-7");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filtered_length_enters_the_initial_state() {
|
||||
// 原文件长度 8、过滤后长度 0:与空文件同值。用原长度当 len 会得到另一个数(这里不是)。
|
||||
assert_eq!(fingerprint_bytes(b" \t\r\n \t\r\n"), fingerprint_bytes(b""),);
|
||||
assert_ne!(fingerprint_bytes(b"aaaaaaaa"), fingerprint_bytes(b"aaaa"),);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_four_documented_bytes_are_dropped() {
|
||||
assert!(!keeps(0x09) && !keeps(0x0a) && !keeps(0x0d) && !keeps(0x20));
|
||||
// 竖 tab、换页、NUL 都保留:过滤是四个字节,不是"去空白"。
|
||||
assert!(keeps(0x0b) && keeps(0x0c) && keeps(0x00));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streaming_and_one_shot_agree_at_every_split() {
|
||||
let data = pattern(64);
|
||||
let expected = fingerprint_bytes(&data);
|
||||
for split in 0..=data.len() {
|
||||
let mut hasher =
|
||||
Stream::new(data.iter().filter(|byte| keeps(**byte)).count() as u32, 1);
|
||||
hasher.update(&data[..split]);
|
||||
hasher.update(&data[split..]);
|
||||
assert_eq!(hasher.finish(), expected, "split={split}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sentinel_is_treated_as_missing() {
|
||||
assert!(is_sentinel(SENTINEL));
|
||||
assert!(!is_sentinel(0));
|
||||
assert!(!is_sentinel(SENTINEL + 1));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
//! 整合包失败分类:**可诊断**比"报了个错"重要。
|
||||
//!
|
||||
//! 每一类都对应导入链路上一个**不同的处置动作**(M111 据此分级):
|
||||
//!
|
||||
//! | 大类 | 稳定码 | 谁该负责 |
|
||||
//! |------|--------|----------|
|
||||
//! | [`PackErrorKind::Unsupported`] | `pack_unsupported` | 换格式 / 让用户手工处理(没格式认领、或模型表达不了该格式) |
|
||||
//! | [`PackErrorKind::Metadata`] | `pack_metadata` | 包本身坏了(JSON 形状不符、哈希缺失),重试无意义 |
|
||||
//! | [`PackErrorKind::Traversal`] | `pack_traversal` | **拒绝**,且不写任何文件(恶意包) |
|
||||
//! | [`PackErrorKind::Symlink`] | `pack_symlink` | **拒绝**(符号链接条目在 Windows 上无合法用途) |
|
||||
//! | [`PackErrorKind::Limits`] | `pack_limits` | **拒绝**(条数/体积超限,zip 炸弹防线) |
|
||||
//! | [`PackErrorKind::Io`] | `pack_io` | 环境问题,可重试 |
|
||||
//!
|
||||
//! 文案与日志分离:`message()` 给人看,`kind().as_str()` 进事件流与 `--json`。
|
||||
|
||||
use std::fmt;
|
||||
|
||||
/// 整合包失败的大类。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PackErrorKind {
|
||||
/// 没有格式认领这份包,或模型里的信息不足以写成该格式。
|
||||
Unsupported,
|
||||
/// 清单 JSON 坏了 / 形状不符 / 必需字段缺失。
|
||||
Metadata,
|
||||
/// 包内路径穿越(绝对路径、`..` 上跳、盘符、ADS、反斜杠分隔)。
|
||||
Traversal,
|
||||
/// zip 里有符号链接条目。
|
||||
Symlink,
|
||||
/// 条目数 / 解压体积超过限额。
|
||||
Limits,
|
||||
/// 本地 IO。
|
||||
Io,
|
||||
}
|
||||
|
||||
impl PackErrorKind {
|
||||
/// 稳定字符串 id(进 `--json` 的 `error.code` 与日志,**不要改名**)。
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Unsupported => "pack_unsupported",
|
||||
Self::Metadata => "pack_metadata",
|
||||
Self::Traversal => "pack_traversal",
|
||||
Self::Symlink => "pack_symlink",
|
||||
Self::Limits => "pack_limits",
|
||||
Self::Io => "pack_io",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 整合包错误:大类 + 人读说明 + 可选下一步提示。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PackError {
|
||||
kind: PackErrorKind,
|
||||
message: String,
|
||||
hint: Option<String>,
|
||||
}
|
||||
|
||||
impl PackError {
|
||||
/// 构造。
|
||||
#[must_use]
|
||||
pub fn new(kind: PackErrorKind, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
kind,
|
||||
message: message.into(),
|
||||
hint: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 没格式认领 / 模型表达不了该格式。
|
||||
#[must_use]
|
||||
pub fn unsupported(message: impl Into<String>) -> Self {
|
||||
Self::new(PackErrorKind::Unsupported, message)
|
||||
}
|
||||
|
||||
/// 清单形状不对 / 必填字段缺失。
|
||||
#[must_use]
|
||||
pub fn metadata(message: impl Into<String>) -> Self {
|
||||
Self::new(PackErrorKind::Metadata, message)
|
||||
}
|
||||
|
||||
/// 路径穿越。
|
||||
#[must_use]
|
||||
pub fn traversal(message: impl Into<String>) -> Self {
|
||||
Self::new(PackErrorKind::Traversal, message)
|
||||
}
|
||||
|
||||
/// 符号链接条目。
|
||||
#[must_use]
|
||||
pub fn symlink(message: impl Into<String>) -> Self {
|
||||
Self::new(PackErrorKind::Symlink, message)
|
||||
}
|
||||
|
||||
/// 超过限额。
|
||||
#[must_use]
|
||||
pub fn limits(message: impl Into<String>) -> Self {
|
||||
Self::new(PackErrorKind::Limits, message)
|
||||
}
|
||||
|
||||
/// 本地 IO。
|
||||
#[must_use]
|
||||
pub fn io(message: impl Into<String>) -> Self {
|
||||
Self::new(PackErrorKind::Io, message)
|
||||
}
|
||||
|
||||
/// 补一句提示。
|
||||
#[must_use]
|
||||
pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
|
||||
self.hint = Some(hint.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// 大类。
|
||||
#[must_use]
|
||||
pub const fn kind(&self) -> PackErrorKind {
|
||||
self.kind
|
||||
}
|
||||
|
||||
/// 人读说明。
|
||||
#[must_use]
|
||||
pub fn message(&self) -> &str {
|
||||
&self.message
|
||||
}
|
||||
|
||||
/// 下一步提示。
|
||||
#[must_use]
|
||||
pub fn hint(&self) -> Option<&str> {
|
||||
self.hint.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for PackError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str(&self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for PackError {}
|
||||
|
||||
impl From<std::io::Error> for PackError {
|
||||
fn from(error: std::io::Error) -> Self {
|
||||
Self::io(error.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<zip::result::ZipError> for PackError {
|
||||
fn from(error: zip::result::ZipError) -> Self {
|
||||
match error {
|
||||
zip::result::ZipError::FileNotFound => Self::io("包内条目不存在"),
|
||||
other => Self::metadata(format!("zip 读取失败:{other}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for PackError {
|
||||
fn from(error: serde_json::Error) -> Self {
|
||||
Self::metadata(format!("JSON 解析失败:{error}"))
|
||||
}
|
||||
}
|
||||
|
||||
/// `pack` 模块内的结果别名。
|
||||
pub type PackResult<T> = Result<T, PackError>;
|
||||
@@ -0,0 +1,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
Reference in new issue
Block a user