Unlucky Launcher v0.0.1-alpha
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
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
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
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Rust 原生单文件 Minecraft 启动器:CLI 完整可用(install/launch/accounts/java/diagnose/agent), MCP server 接入外部 Agent 工具,GUI 开发中。
This commit is contained in:
commit
bc41dfcf7d
517 files changed
+92844
No files matched your search
@@ -0,0 +1,247 @@
|
||||
//! Fabric 系(Fabric / Quilt):官方 meta → loader 版本 JSON。
|
||||
//!
|
||||
//! 依据 `DevDocs/analyse/05-modloader-modpack.md` §1.4:这两个加载器**没有 installer**,
|
||||
//! `meta.fabricmc.net` / `meta.quiltmc.org` 直接给 launcher 可用的 profile JSON,
|
||||
//! 所以安装 = "把 profile 收成我们的版本 JSON + 补库",没有任何子进程。
|
||||
//!
|
||||
//! 本文件是**纯函数**(不进网络):URL 拼装与 JSON 归一化分开写,测试直接喂离线夹具。
|
||||
//!
|
||||
//! # 归一化做了三件容易被上游坑的事
|
||||
//!
|
||||
//! 1. **`jar` 钉成原版 id**:profile 通常不写 `jar`,而 `game::inherit::resolve` 在缺 `jar`
|
||||
//! 时会把 `jar` 兜底成**自身 id**——那会让启动去找 `versions/<loader>/<loader>.jar`
|
||||
//! (不存在)。这里显式写 `jar = <原版 id>`,让继承链正确地回到原版 jar。
|
||||
//! 2. **`mainClass` 断言**:缺主类就不是一份能启动的清单,宁可在安装期报 metadata,
|
||||
//! 也不要装出一个起不来的实例(HMCL 同款防御,analyse/05 §1.4)。
|
||||
//! 3. **扁平 `sha1`/`size` 提升成 `downloads.artifact`**:Fabric profile 的库把摘要写在
|
||||
//! 库对象顶层,M27 的库展开只认 `downloads.artifact`。不提上来的后果是"没有校验值 →
|
||||
//! 存在即信",重装时无法识别被截断的库文件。
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::plan::{LoaderRelease, LoaderSupport};
|
||||
use super::{LoaderError, LoaderKind};
|
||||
use crate::game::library::{Library, LibraryDownloads};
|
||||
use crate::game::manifest::{DownloadInfo, VersionJson};
|
||||
|
||||
/// Fabric 官方 meta 根(BMCLAPI 有对应镜像规则 `bmclapi-fabric-meta`)。
|
||||
pub const FABRIC_META: &str = "https://meta.fabricmc.net/v2";
|
||||
/// Quilt 官方 meta 根(同上,`bmclapi-quilt-meta`)。
|
||||
pub const QUILT_META: &str = "https://meta.quiltmc.org/v3";
|
||||
|
||||
/// meta 根地址(按加载器)。
|
||||
#[must_use]
|
||||
pub fn meta_base(kind: LoaderKind) -> &'static str {
|
||||
match kind {
|
||||
LoaderKind::Quilt => QUILT_META,
|
||||
_ => FABRIC_META,
|
||||
}
|
||||
}
|
||||
|
||||
/// 该原版版本可用的加载器版本列表地址。
|
||||
#[must_use]
|
||||
pub fn loader_list_url(kind: LoaderKind, mc_version: &str) -> String {
|
||||
format!("{}/versions/loader/{mc_version}", meta_base(kind))
|
||||
}
|
||||
|
||||
/// 指定加载器版本的 launcher profile 地址(官方直出可用的版本 JSON)。
|
||||
#[must_use]
|
||||
pub fn profile_url(kind: LoaderKind, mc_version: &str, loader_version: &str) -> String {
|
||||
format!(
|
||||
"{}/versions/loader/{mc_version}/{loader_version}/profile/json",
|
||||
meta_base(kind)
|
||||
)
|
||||
}
|
||||
|
||||
/// 列表里的一个加载器版本。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LoaderEntry {
|
||||
/// 版本号(`0.15.11`)。
|
||||
#[serde(default)]
|
||||
pub version: String,
|
||||
/// 是否稳定版(Quilt 的列表不带这个键,缺省按稳定处理)。
|
||||
#[serde(default = "default_true")]
|
||||
pub stable: bool,
|
||||
/// Maven 坐标(`net.fabricmc:fabric-loader:0.15.11`)。
|
||||
#[serde(default)]
|
||||
pub maven: String,
|
||||
}
|
||||
|
||||
const fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// 列表响应的一行(`[{ "loader": {…}, "intermediary": {…}, … }]`)。
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ListRow {
|
||||
#[serde(default)]
|
||||
loader: Option<LoaderEntry>,
|
||||
}
|
||||
|
||||
/// 解析版本列表(最新的排在前,与官方顺序一致——**不重排**)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// JSON 非法、或列表形状完全不符(顶层不是数组 / 一条都认不出)时返回 metadata 错误。
|
||||
pub fn parse_loader_list(kind: LoaderKind, json: &str) -> Result<Vec<LoaderEntry>, LoaderError> {
|
||||
let rows: Vec<ListRow> = serde_json::from_str(json).map_err(|error| {
|
||||
LoaderError::metadata(format!("{} 版本列表解析失败:{error}", kind.label()))
|
||||
})?;
|
||||
let entries: Vec<LoaderEntry> = rows
|
||||
.into_iter()
|
||||
.filter_map(|row| row.loader)
|
||||
.filter(|entry| !entry.version.is_empty())
|
||||
.collect();
|
||||
if entries.is_empty() {
|
||||
return Err(LoaderError::not_found(format!(
|
||||
"{} 版本列表为空(该原版版本可能还没有对应加载器)",
|
||||
kind.label()
|
||||
)));
|
||||
}
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
/// 从列表里挑一个默认版本:优先第一个稳定版,否则取第一个(列表最新在前)。
|
||||
#[must_use]
|
||||
pub fn pick_default(entries: &[LoaderEntry]) -> Option<&LoaderEntry> {
|
||||
entries
|
||||
.iter()
|
||||
.find(|entry| entry.stable)
|
||||
.or_else(|| entries.first())
|
||||
}
|
||||
|
||||
/// 把官方 profile JSON 归一化成 `<mc>-<kind>-<loader>` 的 loader 版本 JSON。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// profile 不是合法版本 JSON、缺主类时返回 metadata 错误。
|
||||
pub fn build_release(
|
||||
kind: LoaderKind,
|
||||
mc_version: &str,
|
||||
loader_version: &str,
|
||||
profile_json: &str,
|
||||
) -> Result<LoaderRelease, LoaderError> {
|
||||
let mut version_json = VersionJson::from_json_str(profile_json).map_err(|error| {
|
||||
LoaderError::metadata(format!(
|
||||
"{} {loader_version} 的 profile JSON 解析失败:{error}",
|
||||
kind.label()
|
||||
))
|
||||
})?;
|
||||
let mut warnings = Vec::new();
|
||||
|
||||
let version_id = kind.version_id(mc_version, loader_version);
|
||||
version_json.id = Some(version_id.clone());
|
||||
version_json.name = Some(format!("{mc_version} · {} {loader_version}", kind.label()));
|
||||
version_json.inherits_from = Some(mc_version.to_string());
|
||||
// 启动用原版 jar(见模块文档第 1 条)。
|
||||
version_json.jar = Some(mc_version.to_string());
|
||||
|
||||
if version_json
|
||||
.main_class
|
||||
.as_deref()
|
||||
.is_none_or(|value| value.trim().is_empty())
|
||||
{
|
||||
return Err(LoaderError::metadata(format!(
|
||||
"{} {loader_version} 的 profile 没有 mainClass:这份清单装上去也起不来",
|
||||
kind.label()
|
||||
))
|
||||
.with_hint("换一个加载器版本,或用官方启动器核对"));
|
||||
}
|
||||
|
||||
let libraries = version_json.libraries.get_or_insert_with(Vec::new);
|
||||
for library in libraries.iter_mut() {
|
||||
lift_flat_digest(library);
|
||||
}
|
||||
ensure_loader_library(kind, loader_version, libraries, &mut warnings);
|
||||
|
||||
Ok(LoaderRelease {
|
||||
kind,
|
||||
mc_version: mc_version.to_string(),
|
||||
loader_version: loader_version.to_string(),
|
||||
version_id,
|
||||
version_json,
|
||||
support: LoaderSupport::JsonRoute,
|
||||
installer: None,
|
||||
install_profile: None,
|
||||
warnings,
|
||||
})
|
||||
}
|
||||
|
||||
/// 把库对象顶层的 `sha1`/`size` 提升成 `downloads.artifact`(Fabric profile 的写法)。
|
||||
fn lift_flat_digest(library: &mut Library) {
|
||||
if library.downloads.is_some() {
|
||||
return;
|
||||
}
|
||||
let Some(base) = library
|
||||
.url
|
||||
.as_deref()
|
||||
.filter(|url| !url.is_empty())
|
||||
.map(str::to_string)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Ok(relative) = library.relative_path() else {
|
||||
return;
|
||||
};
|
||||
let sha1 = library
|
||||
.extra
|
||||
.get("sha1")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::to_string);
|
||||
let size = library
|
||||
.extra
|
||||
.get("size")
|
||||
.and_then(serde_json::Value::as_u64);
|
||||
let path = relative.to_string_lossy().replace('\\', "/");
|
||||
let url = if base.ends_with('/') {
|
||||
format!("{base}{path}")
|
||||
} else {
|
||||
format!("{base}/{path}")
|
||||
};
|
||||
library.downloads = Some(LibraryDownloads {
|
||||
artifact: Some(DownloadInfo {
|
||||
id: None,
|
||||
sha1,
|
||||
size,
|
||||
url: Some(url),
|
||||
path: Some(path),
|
||||
}),
|
||||
classifiers: BTreeMap::new(),
|
||||
});
|
||||
}
|
||||
|
||||
/// loader 本体库必须在列表里:缺了就补一条(并用告警说明),别装出个"没有 loader"的实例。
|
||||
fn ensure_loader_library(
|
||||
kind: LoaderKind,
|
||||
loader_version: &str,
|
||||
libraries: &mut Vec<Library>,
|
||||
warnings: &mut Vec<String>,
|
||||
) {
|
||||
let (group, artifact) = match kind {
|
||||
LoaderKind::Quilt => ("org.quiltmc", "quilt-loader"),
|
||||
_ => ("net.fabricmc", "fabric-loader"),
|
||||
};
|
||||
let present = libraries.iter().any(|library| {
|
||||
library
|
||||
.coordinate()
|
||||
.is_ok_and(|coordinate| coordinate.group == group && coordinate.artifact == artifact)
|
||||
});
|
||||
if present {
|
||||
return;
|
||||
}
|
||||
let maven_base = match kind {
|
||||
LoaderKind::Quilt => "https://maven.quiltmc.org/repository/release/",
|
||||
_ => "https://maven.fabricmc.net/",
|
||||
};
|
||||
libraries.push(Library {
|
||||
name: format!("{group}:{artifact}:{loader_version}"),
|
||||
url: Some(maven_base.to_string()),
|
||||
..Library::default()
|
||||
});
|
||||
warnings.push(format!(
|
||||
"{} {loader_version} 的 profile 没带 {group}:{artifact}:已按 maven 坐标补一条(缺库会让游戏起不来)",
|
||||
kind.label()
|
||||
));
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
//! NeoForge / Forge:installer 内的 install profile → loader 版本 JSON。
|
||||
//!
|
||||
//! 依据 `DevDocs/analyse/05-modloader-modpack.md` §1.2/§1.3:Forge 有两代 installer 协议,
|
||||
//! NeoForge 是 fork(早期与 Forge 完全同构)。判据也在那儿:install profile 有没有
|
||||
//! `spec` / processors、有没有独立的 `version.json`。maven 坐标与版本表在 [`super::maven`]。
|
||||
//!
|
||||
//! # 本模块的取舍(为什么只做一部分)
|
||||
//!
|
||||
//! 这两家都**不发**launcher 直接可用的 profile,只发 installer jar;能纯 json 化到什么程度
|
||||
//! 取决于 installer 声明的处理器:
|
||||
//!
|
||||
//! | install profile 形状 | 实测样例 | 本模块结论 |
|
||||
//! |----------------------|----------|-----------|
|
||||
//! | `spec: 1` + 有 client 侧 processors | Forge 1.20.1-47.2.0、NeoForge 21.1.80 | [`LoaderSupport::RequiresInstaller`] |
|
||||
//! | `spec: 0` + 无 processors + 内嵌 `version.json` | Forge 1.12.2-14.23.5.2860 | [`LoaderSupport::JsonRoute`] |
|
||||
//! | 只有内联 `versionInfo`(+ `install.filePath` 解包) | 1.12.2 之前的老重建版 | 明确 unsupported |
|
||||
//!
|
||||
//! 第一类必须跑 `java -cp … installertools …` 才能产出 `client-slim/extra` 等打补丁产物
|
||||
//! (processor 的 `data` 变量表里有 `MC_SLIM`/`MC_EXTRA`/`PATCHED`),解析结果因此带上
|
||||
//! [`LoaderSupport::RequiresInstaller`] 标记 + install profile 本体,交给
|
||||
//! [`super::processors`](M35 执行器)真跑;这里的**判定**仍只管「要不要跑 installer」。
|
||||
//!
|
||||
//! 第二类的本体 jar 在 version.json 里 `url` 是空串,且**官方 maven 只发布 `-universal`
|
||||
//! classifier**(无 classifier 路径官方 404,只有 BMCLAPI 的 302 别名兜底)——
|
||||
//! 补救见 [`fill_missing_urls`],URL 带 classifier、磁盘落点保持官方 installer 的原样。
|
||||
//!
|
||||
//! # 一条容易踩的坑:processor 的库不进启动 classpath
|
||||
//!
|
||||
//! install profile 自带的 `libraries` 是**processor 依赖**(installertools/srgutils…),
|
||||
//! 它们不属于游戏 classpath。把它们并进 loader 版本 JSON 会让启动多加载一堆无关 jar,
|
||||
//! 因此这里只记数量进告警、绝不合并;真下载它们的是 [`super::processors`]。
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::maven;
|
||||
use super::plan::{LoaderRelease, LoaderSupport};
|
||||
use super::{LoaderError, LoaderKind};
|
||||
use crate::game::library::Library;
|
||||
use crate::game::manifest::VersionJson;
|
||||
|
||||
pub use super::processors::spec::{Datum, Processor};
|
||||
|
||||
/// 老协议 installer 的 `install` 段(`filePath` 指向内嵌的 universal jar)。
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LegacyInstall {
|
||||
/// 内嵌 jar 在 installer 里的相对路径。
|
||||
#[serde(default)]
|
||||
pub file_path: Option<String>,
|
||||
}
|
||||
|
||||
/// installer 里的 `install_profile.json`(只解析我们需要的键,其余忽略)。
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InstallProfile {
|
||||
/// 协议代号(`1` = processor 时代)。
|
||||
#[serde(default)]
|
||||
pub spec: Option<i64>,
|
||||
/// 组件名(`forge` / `NeoForge`)。
|
||||
#[serde(default)]
|
||||
pub profile: Option<String>,
|
||||
/// installer 自己的版本串(`1.20.1-forge-47.2.0`)。
|
||||
#[serde(default)]
|
||||
pub version: Option<String>,
|
||||
/// 目标原版版本。
|
||||
#[serde(default)]
|
||||
pub minecraft: Option<String>,
|
||||
/// 内嵌版本 JSON 在 installer 里的路径(通常是 `/version.json`)。
|
||||
#[serde(default, rename = "json")]
|
||||
pub version_json_path: Option<String>,
|
||||
/// universal jar 的 maven 坐标(`net.minecraftforge:forge:…`,spec 0 才有)。
|
||||
#[serde(default)]
|
||||
pub path: Option<String>,
|
||||
/// processor 列表。
|
||||
#[serde(default)]
|
||||
pub processors: Vec<Processor>,
|
||||
/// processor 的变量表(`{MAPPINGS}` / `{MC_SLIM}` 之类占位的数据源)。
|
||||
#[serde(default)]
|
||||
pub data: BTreeMap<String, Datum>,
|
||||
/// processor / universal jar 依赖(**不是**启动 classpath,别合并)。
|
||||
#[serde(default)]
|
||||
pub libraries: Vec<Library>,
|
||||
/// 老协议:内嵌 jar 解包信息。
|
||||
#[serde(default)]
|
||||
pub install: Option<LegacyInstall>,
|
||||
/// 老协议:内联的版本 JSON 片段。
|
||||
#[serde(default, rename = "versionInfo")]
|
||||
pub version_info: Option<VersionJson>,
|
||||
}
|
||||
|
||||
impl InstallProfile {
|
||||
/// client 侧 processor 数量。
|
||||
#[must_use]
|
||||
pub fn client_processors(&self) -> usize {
|
||||
self.processors
|
||||
.iter()
|
||||
.filter(|processor| processor.applies_to_client())
|
||||
.count()
|
||||
}
|
||||
|
||||
/// 是否是老协议 installer(内联 `versionInfo` + 解包 universal jar)。
|
||||
#[must_use]
|
||||
pub fn is_legacy_protocol(&self) -> bool {
|
||||
self.version_json_path.is_none() && (self.install.is_some() || self.version_info.is_some())
|
||||
}
|
||||
}
|
||||
|
||||
/// 用 install profile + 内嵌版本 JSON 组 loader 版本。
|
||||
///
|
||||
/// `embedded` 是 installer 内 `version.json` 的内容(读不到就是 `None`)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 原版版本对不上、installer 里没有 `version.json`、生成的清单缺主类时返回错误。
|
||||
pub fn build_release(
|
||||
kind: LoaderKind,
|
||||
mc_version: &str,
|
||||
maven_version: &str,
|
||||
profile: &InstallProfile,
|
||||
embedded: Option<&VersionJson>,
|
||||
) -> Result<LoaderRelease, LoaderError> {
|
||||
if let Some(declared) = profile.minecraft.as_deref() {
|
||||
if declared != mc_version {
|
||||
return Err(LoaderError::metadata(format!(
|
||||
"installer 声明的原版是 {declared},与目标的 {mc_version} 不符(选错了加载器版本)"
|
||||
))
|
||||
.with_hint("`--loader-version` 要和 `install <version>` 的原版版本配套"));
|
||||
}
|
||||
}
|
||||
let loader_version = maven::display_loader_version(kind, mc_version, maven_version);
|
||||
let version_id = kind.version_id(mc_version, &loader_version);
|
||||
let mut warnings = Vec::new();
|
||||
|
||||
let Some(embedded) = embedded else {
|
||||
let reason = if profile.is_legacy_protocol() {
|
||||
format!(
|
||||
"{} {} 走老协议 installer(内联 versionInfo + 解包 universal jar),本期不做解包路线",
|
||||
kind.label(),
|
||||
loader_version
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"{} {} 的 installer 里没有可读的 version.json",
|
||||
kind.label(),
|
||||
loader_version
|
||||
)
|
||||
};
|
||||
return Err(LoaderError::unsupported(reason).with_hint(
|
||||
"这段协议要动 installer 内的 jar,属后续 mission;可先用官方启动器装好该加载器",
|
||||
));
|
||||
};
|
||||
|
||||
let mut version_json = embedded.clone();
|
||||
let mut support = LoaderSupport::JsonRoute;
|
||||
let client_processors = profile.client_processors();
|
||||
if client_processors > 0 {
|
||||
support = LoaderSupport::RequiresInstaller {
|
||||
reason: format!(
|
||||
"{} {} 的 installer 声明了 {client_processors} 个 client 侧 processor(拆包/映射/打补丁),必须先外调 java 执行",
|
||||
kind.label(),
|
||||
loader_version
|
||||
),
|
||||
};
|
||||
warnings.push(format!(
|
||||
"install profile 里另有 {} 个 processor 依赖(installertools 等):它们不进启动 classpath,只在安装期给执行器用",
|
||||
profile.libraries.len()
|
||||
));
|
||||
}
|
||||
|
||||
version_json.id = Some(version_id.clone());
|
||||
version_json.name = Some(format!("{mc_version} · {} {loader_version}", kind.label()));
|
||||
version_json.inherits_from = Some(mc_version.to_string());
|
||||
version_json.jar = Some(mc_version.to_string());
|
||||
|
||||
if version_json
|
||||
.main_class
|
||||
.as_deref()
|
||||
.is_none_or(|value| value.trim().is_empty())
|
||||
{
|
||||
return Err(LoaderError::metadata(format!(
|
||||
"{} {} 的 version.json 没有 mainClass",
|
||||
kind.label(),
|
||||
loader_version
|
||||
)));
|
||||
}
|
||||
|
||||
let libraries = version_json.libraries.get_or_insert_with(Vec::new);
|
||||
let installer_body = profile.path.as_deref();
|
||||
for library in libraries.iter_mut() {
|
||||
fill_missing_urls(library, maven::base(kind), installer_body);
|
||||
}
|
||||
|
||||
Ok(LoaderRelease {
|
||||
kind,
|
||||
mc_version: mc_version.to_string(),
|
||||
loader_version,
|
||||
version_id,
|
||||
version_json,
|
||||
support,
|
||||
installer: None,
|
||||
install_profile: Some(profile.clone()),
|
||||
warnings,
|
||||
})
|
||||
}
|
||||
|
||||
/// spec 0 的 version.json 里,forge 本体库的 `url` 常是空串(jar 原本内嵌在 installer 里),
|
||||
/// 于是"补一个 maven 地址"就能直取,省掉解包 installer。
|
||||
///
|
||||
/// ⚠️ 本体 jar 的**发布 classifier 是 `universal`**:installer 内嵌的条目叫
|
||||
/// `maven/…/forge-<ver>.jar`(无 classifier),但官方 maven 只托管
|
||||
/// `forge-<ver>-universal.jar`——无 classifier 的那条路径**官方 404**
|
||||
/// (实测 1.12.2-14.23.5.2860:`-universal.jar` 200 / 4466148 字节,sha1 与 version.json 一致;
|
||||
/// 无 classifier 路径仅靠 BMCLAPI 的 302 别名兜底)。
|
||||
/// 因此这里**只改 URL**:给 installer 声明的本体构件追加 `-universal`;
|
||||
/// `artifact.path`(磁盘落点)保持官方 installer 的原样,启动 classpath 与官方布局一致。
|
||||
fn fill_missing_urls(library: &mut Library, base: &str, installer_body: Option<&str>) {
|
||||
if library
|
||||
.url
|
||||
.as_deref()
|
||||
.is_none_or(|url| url.trim().is_empty())
|
||||
{
|
||||
library.url = Some(if base.ends_with('/') {
|
||||
base.to_string()
|
||||
} else {
|
||||
format!("{base}/")
|
||||
});
|
||||
}
|
||||
// 先算"是不是 installer 声明的本体"(要读 library 的坐标),再拿 artifact 的可变借用。
|
||||
let is_body = is_installer_body(library, installer_body);
|
||||
let root = library.url.clone().unwrap_or_else(|| base.to_string());
|
||||
let Some(artifact) = library
|
||||
.downloads
|
||||
.as_mut()
|
||||
.and_then(|downloads| downloads.artifact.as_mut())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if artifact.has_url() {
|
||||
return;
|
||||
}
|
||||
let Some(path) = artifact.path.clone() else {
|
||||
return;
|
||||
};
|
||||
let remote_path = if is_body {
|
||||
with_universal_classifier(&path)
|
||||
} else {
|
||||
path
|
||||
};
|
||||
artifact.url = Some(if root.ends_with('/') {
|
||||
format!("{root}{remote_path}")
|
||||
} else {
|
||||
format!("{root}/{remote_path}")
|
||||
});
|
||||
}
|
||||
|
||||
/// 这条库是不是 install profile 的 `path` 声明的本体构件(`group:artifact:version`,无 classifier)。
|
||||
fn is_installer_body(library: &Library, installer_body: Option<&str>) -> bool {
|
||||
let Some(body) = installer_body else {
|
||||
return false;
|
||||
};
|
||||
let mut parts = body.split(':');
|
||||
let (Some(group), Some(artifact)) = (parts.next(), parts.next()) else {
|
||||
return false;
|
||||
};
|
||||
library.coordinate().is_ok_and(|coordinate| {
|
||||
coordinate.classifier.is_none()
|
||||
&& coordinate.group == group
|
||||
&& coordinate.artifact == artifact
|
||||
})
|
||||
}
|
||||
|
||||
/// `…/forge-1.12.2-14.23.5.2860.jar` → `…/forge-1.12.2-14.23.5.2860-universal.jar`。
|
||||
fn with_universal_classifier(path: &str) -> String {
|
||||
match path.strip_suffix(".jar") {
|
||||
Some(stem) => format!("{stem}-universal.jar"),
|
||||
None => path.to_string(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
//! Maven 坐标与版本表(Forge / NeoForge):版本列表地址、版本选择、installer 地址。
|
||||
//!
|
||||
//! 与 [`super::forge`] 的分工:这里只管"maven 上有什么版本、jar 在哪",
|
||||
//! 那边只管"installer 里的 profile 怎么变成版本 JSON"。
|
||||
//!
|
||||
//! 镜像:两家 maven 都在 D7 镜像表里有 BMCLAPI 规则(`bmclapi-neoforge` /
|
||||
//! `bmclapi-forge-maven-net`),所以这里一律**写官方地址**,重写交给 `core::download`
|
||||
//! 的镜像展开(与 M27 的"不拼镜像 URL"纪律一致)。
|
||||
|
||||
use super::LoaderKind;
|
||||
|
||||
/// NeoForge maven 根(规则 `bmclapi-neoforge` 把 `/releases` 重写到 `/maven`)。
|
||||
pub const NEOFORGE_MAVEN: &str = "https://maven.neoforged.net/releases";
|
||||
/// Forge maven 根(规则 `bmclapi-forge-maven-net`)。
|
||||
pub const FORGE_MAVEN: &str = "https://maven.minecraftforge.net";
|
||||
/// NeoForge 唯一走 Forge 布局的原版版本(1.20.1 是"NeoForge 还叫 forge 的时候")。
|
||||
pub const NEOFORGE_FORGE_LAYOUT_MC: &str = "1.20.1";
|
||||
|
||||
/// maven 根地址。
|
||||
#[must_use]
|
||||
pub fn base(kind: LoaderKind) -> &'static str {
|
||||
match kind {
|
||||
LoaderKind::NeoForge => NEOFORGE_MAVEN,
|
||||
_ => FORGE_MAVEN,
|
||||
}
|
||||
}
|
||||
|
||||
/// 该原版版本对应的 `maven-metadata.xml` 地址。
|
||||
#[must_use]
|
||||
pub fn metadata_url(kind: LoaderKind, mc_version: &str) -> String {
|
||||
let (root, path) = match kind {
|
||||
LoaderKind::NeoForge if mc_version == NEOFORGE_FORGE_LAYOUT_MC => {
|
||||
(NEOFORGE_MAVEN, "net/neoforged/forge")
|
||||
}
|
||||
LoaderKind::NeoForge => (NEOFORGE_MAVEN, "net/neoforged/neoforge"),
|
||||
_ => (FORGE_MAVEN, "net/minecraftforge/forge"),
|
||||
};
|
||||
format!("{root}/{path}/maven-metadata.xml")
|
||||
}
|
||||
|
||||
/// installer jar 地址。
|
||||
#[must_use]
|
||||
pub fn installer_url(kind: LoaderKind, mc_version: &str, maven_version: &str) -> String {
|
||||
let (root, path, file) = match kind {
|
||||
LoaderKind::NeoForge if mc_version == NEOFORGE_FORGE_LAYOUT_MC => (
|
||||
NEOFORGE_MAVEN,
|
||||
"net/neoforged/forge",
|
||||
format!("forge-{maven_version}-installer.jar"),
|
||||
),
|
||||
LoaderKind::NeoForge => (
|
||||
NEOFORGE_MAVEN,
|
||||
"net/neoforged/neoforge",
|
||||
format!("neoforge-{maven_version}-installer.jar"),
|
||||
),
|
||||
_ => (
|
||||
FORGE_MAVEN,
|
||||
"net/minecraftforge/forge",
|
||||
format!("forge-{maven_version}-installer.jar"),
|
||||
),
|
||||
};
|
||||
format!("{root}/{path}/{maven_version}/{file}")
|
||||
}
|
||||
|
||||
/// 扫 maven-metadata.xml 里的 `<version>` 值。
|
||||
///
|
||||
/// 故意不引 XML 库:maven 元数据是**极简且稳定**的格式(只有一层 `<version>` 列表),
|
||||
/// 为它拉一个 XML 解析器不划算。解析不出就返回空表,由上层报"没有可用版本"。
|
||||
#[must_use]
|
||||
pub fn parse_metadata_versions(xml: &str) -> Vec<String> {
|
||||
const OPEN: &str = "<version>";
|
||||
const CLOSE: &str = "</version>";
|
||||
let mut out = Vec::new();
|
||||
let mut rest = xml;
|
||||
while let Some(start) = rest.find(OPEN) {
|
||||
let after = &rest[start + OPEN.len()..];
|
||||
let Some(end) = after.find(CLOSE) else {
|
||||
break;
|
||||
};
|
||||
let value = after[..end].trim();
|
||||
if !value.is_empty() {
|
||||
out.push(value.to_string());
|
||||
}
|
||||
rest = &after[end + CLOSE.len()..];
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// NeoForge 版本号 → 原版版本号(`21.1.80` → `1.21.1`,analyse/05 §1.3 的反解规则)。
|
||||
///
|
||||
/// - 主版本 `0` 是快照版(命名不带原版版本,返回 `None`);
|
||||
/// - 主版本 `≥26` 是 MC 新命名方案(`26.1.2.125` → `26.1`);
|
||||
/// - 其余是 `1.<主>.<次>`(`20.4.237` → `1.20.4`)。
|
||||
#[must_use]
|
||||
pub fn neoforge_mc_version(version: &str) -> Option<String> {
|
||||
let core = version.split(['-', '+']).next().unwrap_or(version);
|
||||
let segments: Vec<u64> = core
|
||||
.split('.')
|
||||
.map(str::parse::<u64>)
|
||||
.collect::<Result<_, _>>()
|
||||
.ok()?;
|
||||
let (major, minor) = (*segments.first()?, *segments.get(1)?);
|
||||
match major {
|
||||
0 => None,
|
||||
26.. => Some(format!("{major}.{minor}")),
|
||||
_ => Some(format!("1.{major}.{minor}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// maven 版本是否属于该原版版本。
|
||||
#[must_use]
|
||||
pub fn matches_mc(kind: LoaderKind, mc_version: &str, version: &str) -> bool {
|
||||
match kind {
|
||||
LoaderKind::NeoForge if mc_version == NEOFORGE_FORGE_LAYOUT_MC => {
|
||||
version.starts_with(&format!("{mc_version}-"))
|
||||
}
|
||||
LoaderKind::NeoForge => neoforge_mc_version(version).as_deref() == Some(mc_version),
|
||||
_ => version.starts_with(&format!("{mc_version}-")),
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 maven 版本表里挑出属于该原版版本的加载器版本(**升序**,末尾最好)。
|
||||
#[must_use]
|
||||
pub fn select_versions(kind: LoaderKind, mc_version: &str, versions: &[String]) -> Vec<String> {
|
||||
let mut picked: Vec<String> = versions
|
||||
.iter()
|
||||
.filter(|version| matches_mc(kind, mc_version, version))
|
||||
.cloned()
|
||||
.collect();
|
||||
picked.sort_by(|left, right| {
|
||||
numeric_key(left)
|
||||
.cmp(&numeric_key(right))
|
||||
.then_with(|| left.cmp(right))
|
||||
});
|
||||
picked
|
||||
}
|
||||
|
||||
/// 用户给的版本号归一成 maven 版本号(Forge 的 maven 版本带原版前缀)。
|
||||
///
|
||||
/// `--loader-version 47.2.0` 与 `--loader-version 1.20.1-47.2.0` 都接受;
|
||||
/// NeoForge(非 1.20.1 布局)本身就是完整 maven 版本,原样返回。
|
||||
#[must_use]
|
||||
pub fn normalize_maven_version(kind: LoaderKind, mc_version: &str, raw: &str) -> String {
|
||||
let raw = raw.trim();
|
||||
let needs_prefix = match kind {
|
||||
LoaderKind::NeoForge => mc_version == NEOFORGE_FORGE_LAYOUT_MC,
|
||||
_ => true,
|
||||
};
|
||||
if needs_prefix && !raw.starts_with(&format!("{mc_version}-")) {
|
||||
format!("{mc_version}-{raw}")
|
||||
} else {
|
||||
raw.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// 用户视角的加载器版本号(Forge 去掉原版前缀,NeoForge 原样)。
|
||||
#[must_use]
|
||||
pub fn display_loader_version(kind: LoaderKind, mc_version: &str, maven_version: &str) -> String {
|
||||
match kind {
|
||||
LoaderKind::NeoForge if mc_version != NEOFORGE_FORGE_LAYOUT_MC => maven_version.to_string(),
|
||||
_ => maven_version
|
||||
.strip_prefix(&format!("{mc_version}-"))
|
||||
.unwrap_or(maven_version)
|
||||
.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 版本号 → 可比较的数字键(非数字段按 0,保证不 panic)。
|
||||
fn numeric_key(version: &str) -> Vec<u64> {
|
||||
version
|
||||
.split(['-', '.', '+'])
|
||||
.map(|segment| segment.parse::<u64>().unwrap_or(0))
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
//! 加载器元数据的网络层:版本列表 + 官方 profile/installer → [`LoaderRelease`]。
|
||||
//!
|
||||
//! 分工与 M27 的 [`crate::game::install::RemoteSource`] 一样:**只发请求、只解析**,
|
||||
//! 不写游戏目录(installer jar 落 `.ULL/cache/meta/` 作离线复用),镜像/退避/health 全在
|
||||
//! `core::download` 的引擎里(我们一律递**官方地址**,重写由镜像表负责)。
|
||||
//!
|
||||
//! 两族的取数路径完全不同:
|
||||
//!
|
||||
//! ```text
|
||||
//! Fabric / Quilt : meta API → profile/json(官方直出可用版本 JSON)
|
||||
//! NeoForge/Forge : maven-metadata.xml → installer jar → 读 install_profile.json + version.json
|
||||
//! ```
|
||||
|
||||
use std::fs::File;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use zip::ZipArchive;
|
||||
|
||||
use super::fabric;
|
||||
use super::forge::{self, InstallProfile};
|
||||
use super::maven;
|
||||
use super::plan::LoaderRelease;
|
||||
use super::{LoaderError, LoaderKind};
|
||||
use crate::download::{DownloadEngine, DownloadRequest, ResourceKind};
|
||||
use crate::game::manifest::VersionJson;
|
||||
|
||||
/// installer jar 的缓存文件名前缀(超长/含 `+` 的版本串会被规整成安全文件名)。
|
||||
const INSTALLER_TAG: &str = "installer";
|
||||
|
||||
/// 远端加载器元数据来源。
|
||||
pub struct LoaderSource<'a> {
|
||||
engine: &'a DownloadEngine,
|
||||
meta_cache: &'a Path,
|
||||
}
|
||||
|
||||
impl<'a> LoaderSource<'a> {
|
||||
/// 绑定下载引擎与元数据缓存目录(`.ULL/cache/meta/`)。
|
||||
#[must_use]
|
||||
pub fn new(engine: &'a DownloadEngine, meta_cache: &'a Path) -> Self {
|
||||
Self { engine, meta_cache }
|
||||
}
|
||||
|
||||
/// 取该原版版本可用的加载器版本列表(**最新在前**)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 网络失败、元数据解析失败、该原版版本没有加载器版本时返回 [`LoaderError`]。
|
||||
pub async fn loader_versions(
|
||||
&self,
|
||||
kind: LoaderKind,
|
||||
mc_version: &str,
|
||||
) -> Result<Vec<String>, LoaderError> {
|
||||
if kind.is_fabric_family() {
|
||||
let url = fabric::loader_list_url(kind, mc_version);
|
||||
let body = self.fetch_text(&url).await?;
|
||||
let entries = fabric::parse_loader_list(kind, &body)?;
|
||||
return Ok(entries.into_iter().map(|entry| entry.version).collect());
|
||||
}
|
||||
let url = maven::metadata_url(kind, mc_version);
|
||||
let body = self.fetch_text(&url).await?;
|
||||
let all = maven::parse_metadata_versions(&body);
|
||||
// maven 表是升序的,统一成"最新在前"。
|
||||
let mut picked = maven::select_versions(kind, mc_version, &all);
|
||||
picked.reverse();
|
||||
if picked.is_empty() {
|
||||
return Err(LoaderError::not_found(format!(
|
||||
"{} 在 {} 上没有可用版本(maven 表 {} 条,全部与该原版版本不匹配)",
|
||||
kind.label(),
|
||||
mc_version,
|
||||
all.len()
|
||||
)));
|
||||
}
|
||||
Ok(picked)
|
||||
}
|
||||
|
||||
/// 解析一个加载器版本(`loader_version` 为 `None` 时取官方默认:Fabric 系取首个稳定版,
|
||||
/// maven 系取该原版版本下最新的一版)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 网络失败、版本不存在、installer 缺 profile、协议不受支持时返回 [`LoaderError`]。
|
||||
pub async fn resolve(
|
||||
&self,
|
||||
kind: LoaderKind,
|
||||
mc_version: &str,
|
||||
loader_version: Option<&str>,
|
||||
) -> Result<LoaderRelease, LoaderError> {
|
||||
let requested = loader_version
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
if kind.is_fabric_family() {
|
||||
let version = match requested {
|
||||
Some(version) => version.to_string(),
|
||||
None => {
|
||||
let url = fabric::loader_list_url(kind, mc_version);
|
||||
let body = self.fetch_text(&url).await?;
|
||||
let entries = fabric::parse_loader_list(kind, &body)?;
|
||||
fabric::pick_default(&entries)
|
||||
.map(|entry| entry.version.clone())
|
||||
.ok_or_else(|| {
|
||||
LoaderError::not_found(format!("{} 没有可用的加载器版本", kind.label()))
|
||||
})?
|
||||
}
|
||||
};
|
||||
let url = fabric::profile_url(kind, mc_version, &version);
|
||||
let body = self.fetch_text(&url).await?;
|
||||
return fabric::build_release(kind, mc_version, &version, &body);
|
||||
}
|
||||
|
||||
let maven_version = match requested {
|
||||
Some(raw) => maven::normalize_maven_version(kind, mc_version, raw),
|
||||
None => self
|
||||
.loader_versions(kind, mc_version)
|
||||
.await?
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| {
|
||||
LoaderError::not_found(format!("{} 没有可用的加载器版本", kind.label()))
|
||||
})?,
|
||||
};
|
||||
let url = maven::installer_url(kind, mc_version, &maven_version);
|
||||
let path = self.installer_path(kind, &maven_version);
|
||||
let jar = self.fetch_installer(&url, &path).await?;
|
||||
let (profile, embedded) = read_installer(&jar)?;
|
||||
let mut release = forge::build_release(
|
||||
kind,
|
||||
mc_version,
|
||||
&maven_version,
|
||||
&profile,
|
||||
embedded.as_ref(),
|
||||
)?;
|
||||
// M35 执行器要的两样东西:installer 本体(跑 processor 与解包)与它声明的 profile。
|
||||
release.installer = Some(jar);
|
||||
Ok(release)
|
||||
}
|
||||
|
||||
/// installer jar 的本地缓存路径(`+`/非法字符规整成 `_`)。
|
||||
fn installer_path(&self, kind: LoaderKind, maven_version: &str) -> PathBuf {
|
||||
let safe: String = maven_version
|
||||
.chars()
|
||||
.map(|ch| {
|
||||
if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_') {
|
||||
ch
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
self.meta_cache
|
||||
.join(format!("{}-{safe}-{INSTALLER_TAG}.jar", kind.as_str()))
|
||||
}
|
||||
|
||||
/// 取 installer jar:本地缓存里有可读的 zip 就直接用(离线复用),否则下载。
|
||||
async fn fetch_installer(&self, url: &str, path: &Path) -> Result<PathBuf, LoaderError> {
|
||||
if path.is_file() && ZipArchive::new(File::open(path).map_err(io_error(path))?).is_ok() {
|
||||
return Ok(path.to_path_buf());
|
||||
}
|
||||
let request = DownloadRequest::new(ResourceKind::Library, url, path.to_path_buf());
|
||||
let outcome = self.engine.download(request).await?;
|
||||
Ok(outcome.path)
|
||||
}
|
||||
|
||||
/// 取一小段文本元数据(版本列表 / profile JSON / maven metadata)。
|
||||
async fn fetch_text(&self, url: &str) -> Result<String, LoaderError> {
|
||||
let dest = self.meta_cache.join("loader-meta.bin");
|
||||
let bytes = self
|
||||
.engine
|
||||
.download_bytes(DownloadRequest::new(
|
||||
ResourceKind::VersionManifest,
|
||||
url,
|
||||
dest,
|
||||
))
|
||||
.await?;
|
||||
String::from_utf8(bytes)
|
||||
.map_err(|_| LoaderError::metadata(format!("元数据不是 UTF-8:{url}")))
|
||||
}
|
||||
}
|
||||
|
||||
/// 读 installer jar 里的 `install_profile.json` 与内嵌的版本 JSON。
|
||||
///
|
||||
/// 内嵌版本 JSON 的路径来自 profile 的 `json` 字段(通常 `/version.json`);
|
||||
/// 该条目不存在时返回 `None`,由 [`forge::build_release`] 判定为 unsupported。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 不是合法 zip、缺 `install_profile.json`、profile/版本 JSON 无法解析时返回 metadata 错误。
|
||||
pub fn read_installer(path: &Path) -> Result<(InstallProfile, Option<VersionJson>), LoaderError> {
|
||||
let file = File::open(path).map_err(io_error(path))?;
|
||||
let mut archive = ZipArchive::new(file).map_err(|error| {
|
||||
LoaderError::metadata(format!("{} 不是合法 zip:{error}", path.display()))
|
||||
})?;
|
||||
|
||||
let profile_text = read_entry(&mut archive, "install_profile.json")?.ok_or_else(|| {
|
||||
LoaderError::metadata(format!(
|
||||
"{} 里没有 install_profile.json(不是 Forge/NeoForge installer?)",
|
||||
path.display()
|
||||
))
|
||||
})?;
|
||||
let profile: InstallProfile = serde_json::from_str(&profile_text).map_err(|error| {
|
||||
LoaderError::metadata(format!("install_profile.json 解析失败:{error}"))
|
||||
})?;
|
||||
|
||||
let embedded = match profile
|
||||
.version_json_path
|
||||
.as_deref()
|
||||
.map(|entry| entry.trim_start_matches('/'))
|
||||
.filter(|entry| !entry.is_empty())
|
||||
{
|
||||
Some(entry) => match read_entry(&mut archive, entry)? {
|
||||
Some(text) => Some(VersionJson::from_json_str(&text).map_err(|error| {
|
||||
LoaderError::metadata(format!("installer 内 {entry} 解析失败:{error}"))
|
||||
})?),
|
||||
None => None,
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
Ok((profile, embedded))
|
||||
}
|
||||
|
||||
/// 读一个 zip 条目成字符串;条目不存在返回 `Ok(None)`。
|
||||
fn read_entry(archive: &mut ZipArchive<File>, name: &str) -> Result<Option<String>, LoaderError> {
|
||||
use std::io::Read;
|
||||
|
||||
let mut entry = match archive.by_name(name) {
|
||||
Ok(entry) => entry,
|
||||
Err(zip::result::ZipError::FileNotFound) => return Ok(None),
|
||||
Err(error) => {
|
||||
return Err(LoaderError::metadata(format!(
|
||||
"读 installer 条目 {name} 失败:{error}"
|
||||
)))
|
||||
}
|
||||
};
|
||||
let mut text = String::new();
|
||||
entry
|
||||
.read_to_string(&mut text)
|
||||
.map_err(|error| LoaderError::io(format!("installer 条目 {name} 不是可读文本:{error}")))?;
|
||||
Ok(Some(text))
|
||||
}
|
||||
|
||||
fn io_error(path: &Path) -> impl FnOnce(std::io::Error) -> LoaderError + '_ {
|
||||
move |error| LoaderError::io(format!("{} 读写失败:{error}", path.display()))
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
//! 加载器安装(Fabric / Quilt / NeoForge / Forge):官方元数据 → loader 版本 JSON → 补库清单。
|
||||
//!
|
||||
//! 设计输入:`DevDocs/PlanDoc/08-mod-and-recommend.md` §1(D10 加载器安装)、
|
||||
//! `DevDocs/analyse/05-modloader-modpack.md` §1(五加载器安装协议、`patch + priority` 主线)。
|
||||
//!
|
||||
//! # 与 M27 安装层的关系(不复制粘贴,只叠一层)
|
||||
//!
|
||||
//! 加载器不是"另一个版本",而是**叠在已装原版之上的一个 `inheritsFrom` 层**:
|
||||
//!
|
||||
//! ```text
|
||||
//! versions/1.20.1/1.20.1.json 原版(M27 负责装)
|
||||
//! versions/1.20.1-fabric-0.15.11/…json loader 层:inheritsFrom=1.20.1 + 主类 + loader 库
|
||||
//! ```
|
||||
//!
|
||||
//! 因此本模块**只做两件事**:①把官方元数据翻译成一份 loader 版本 JSON;
|
||||
//! ②把它的库列表交给 [`crate::game::install::libraries`] 展开成下载计划。
|
||||
//! 落盘、跳过判定、实例登记一律复用 M27([`crate::game::install::execute`] / `register`),
|
||||
//! 不另起一套下载/写盘逻辑。
|
||||
//!
|
||||
//! # 三条路线(按"能不能不跑 java 就装出来"分类,而不是按加载器名字)
|
||||
//!
|
||||
//! | 路线 | 加载器 | 做法 |
|
||||
//! |------|--------|------|
|
||||
//! | [`LoaderSupport::JsonRoute`] | Fabric / Quilt | 官方 meta 直接给 launcher 用的 profile JSON |
|
||||
//! | [`LoaderSupport::JsonRoute`] | Forge spec-0(如 1.12.2 重建版) | installer 里的 `version.json` + 库清单,universal jar 走 maven 直取 |
|
||||
//! | [`LoaderSupport::RequiresInstaller`] | Forge spec≥1 / NeoForge | installer 声明了 client 侧 processors(拆包/映射),**必须外调 java** |
|
||||
//!
|
||||
//! 第三类本期只出**明确 unsupported**:版本 JSON 与库清单仍照常生成(`--dry-run` 可看),
|
||||
//! 但真装会被拒——半成品实例比明确报错更糟(沿 M27 §7「如实告知」的纪律)。
|
||||
//! 老协议 installer(内联 `versionInfo` + universal jar 解包,≈1.12.2 之前的重建版)同样归 unsupported。
|
||||
|
||||
pub mod fabric;
|
||||
pub mod forge;
|
||||
pub mod maven;
|
||||
pub mod meta;
|
||||
pub mod plan;
|
||||
pub mod processors;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub use meta::LoaderSource;
|
||||
pub use plan::{LoaderInstallPlan, LoaderPlanInput, LoaderRelease, LoaderSupport};
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use crate::download::{DownloadError, DownloadErrorKind};
|
||||
|
||||
/// 支持的加载器种类。
|
||||
///
|
||||
/// `vanilla` / `none` **不在这里**:那是"不装加载器",由 CLI 走 M27 的原版路径。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum LoaderKind {
|
||||
/// Fabric(`meta.fabricmc.net`,纯 meta,无 installer)。
|
||||
Fabric,
|
||||
/// Quilt(`meta.quiltmc.org`,与 Fabric 同构)。
|
||||
Quilt,
|
||||
/// NeoForge(`maven.neoforged.net`,installer 内嵌 install profile)。
|
||||
NeoForge,
|
||||
/// Forge(`maven.minecraftforge.net`,两代 installer 协议)。
|
||||
Forge,
|
||||
}
|
||||
|
||||
impl LoaderKind {
|
||||
/// 解析命令行里的加载器名(大小写不敏感;`neo-forge` 是 Modrinth 的另一种拼法)。
|
||||
#[must_use]
|
||||
pub fn parse(raw: &str) -> Option<Self> {
|
||||
match raw.trim().to_ascii_lowercase().as_str() {
|
||||
"fabric" => Some(Self::Fabric),
|
||||
"quilt" => Some(Self::Quilt),
|
||||
"neoforge" | "neo-forge" => Some(Self::NeoForge),
|
||||
"forge" => Some(Self::Forge),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 稳定字符串 id(进版本 id、JSON 输出与日志,**不要改名**)。
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Fabric => "fabric",
|
||||
Self::Quilt => "quilt",
|
||||
Self::NeoForge => "neoforge",
|
||||
Self::Forge => "forge",
|
||||
}
|
||||
}
|
||||
|
||||
/// 人读名称。
|
||||
#[must_use]
|
||||
pub const fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Fabric => "Fabric",
|
||||
Self::Quilt => "Quilt",
|
||||
Self::NeoForge => "NeoForge",
|
||||
Self::Forge => "Forge",
|
||||
}
|
||||
}
|
||||
|
||||
/// 是否属于 Fabric 系(纯 meta、profile JSON 直接可用)。
|
||||
#[must_use]
|
||||
pub const fn is_fabric_family(self) -> bool {
|
||||
matches!(self, Self::Fabric | Self::Quilt)
|
||||
}
|
||||
|
||||
/// 全部加载器(CLI 的 `--loader` 枚举提示用)。
|
||||
#[must_use]
|
||||
pub const fn all() -> &'static [Self] {
|
||||
&[Self::Fabric, Self::Quilt, Self::NeoForge, Self::Forge]
|
||||
}
|
||||
|
||||
/// loader 版本 id 的词根:`<mc>-<kind>-<loader>`(`versions/<id>/` 与实例 `versionId` 都用它)。
|
||||
#[must_use]
|
||||
pub fn version_id(self, mc_version: &str, loader_version: &str) -> String {
|
||||
format!("{mc_version}-{}-{loader_version}", self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for LoaderKind {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str(self.label())
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载器安装失败的大类(CLI 据此给稳定错误码 `loader_*`)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LoaderErrorKind {
|
||||
/// 该加载器/该版本走不通我们的路线(需要跑 installer 或属未支持的老协议)。
|
||||
Unsupported,
|
||||
/// 官方元数据里没有这个加载器版本(或没有匹配该原版版本的版本)。
|
||||
NotFound,
|
||||
/// 元数据解析失败(profile JSON / install profile / maven metadata 坏了)。
|
||||
Metadata,
|
||||
/// 网络侧失败(所有候选源都失败 / 超时)。
|
||||
Network,
|
||||
/// 本地 IO(读 installer 缓存、写盘失败)。
|
||||
Io,
|
||||
}
|
||||
|
||||
impl LoaderErrorKind {
|
||||
/// 稳定字符串 id(进 `--json` 的 `error.code`)。
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Unsupported => "loader_unsupported",
|
||||
Self::NotFound => "loader_not_found",
|
||||
Self::Metadata => "loader_metadata",
|
||||
Self::Network => "loader_network",
|
||||
Self::Io => "loader_io",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载器安装错误:大类 + 人读说明 + 可选提示。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LoaderError {
|
||||
kind: LoaderErrorKind,
|
||||
message: String,
|
||||
hint: Option<String>,
|
||||
}
|
||||
|
||||
impl LoaderError {
|
||||
/// 构造。
|
||||
#[must_use]
|
||||
pub fn new(kind: LoaderErrorKind, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
kind,
|
||||
message: message.into(),
|
||||
hint: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 走不通我们的路线(需要外调 java installer / 老协议)。
|
||||
#[must_use]
|
||||
pub fn unsupported(message: impl Into<String>) -> Self {
|
||||
Self::new(LoaderErrorKind::Unsupported, message)
|
||||
}
|
||||
|
||||
/// 元数据里查不到。
|
||||
#[must_use]
|
||||
pub fn not_found(message: impl Into<String>) -> Self {
|
||||
Self::new(LoaderErrorKind::NotFound, message)
|
||||
}
|
||||
|
||||
/// 元数据坏了/形状不符。
|
||||
#[must_use]
|
||||
pub fn metadata(message: impl Into<String>) -> Self {
|
||||
Self::new(LoaderErrorKind::Metadata, message)
|
||||
}
|
||||
|
||||
/// 本地 IO。
|
||||
#[must_use]
|
||||
pub fn io(message: impl Into<String>) -> Self {
|
||||
Self::new(LoaderErrorKind::Io, message)
|
||||
}
|
||||
|
||||
/// 补一句提示。
|
||||
#[must_use]
|
||||
pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
|
||||
self.hint = Some(hint.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// 大类。
|
||||
#[must_use]
|
||||
pub const fn kind(&self) -> LoaderErrorKind {
|
||||
self.kind
|
||||
}
|
||||
|
||||
/// 人读说明。
|
||||
#[must_use]
|
||||
pub fn message(&self) -> &str {
|
||||
&self.message
|
||||
}
|
||||
|
||||
/// 下一步提示。
|
||||
#[must_use]
|
||||
pub fn hint(&self) -> Option<&str> {
|
||||
self.hint.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for LoaderError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str(&self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for LoaderError {}
|
||||
|
||||
impl From<DownloadError> for LoaderError {
|
||||
fn from(error: DownloadError) -> Self {
|
||||
let kind = match error.kind {
|
||||
DownloadErrorKind::Io => LoaderErrorKind::Io,
|
||||
DownloadErrorKind::InvalidRequest => LoaderErrorKind::Metadata,
|
||||
DownloadErrorKind::Integrity
|
||||
| DownloadErrorKind::NotFound
|
||||
| DownloadErrorKind::ServerError
|
||||
| DownloadErrorKind::Timeout
|
||||
| DownloadErrorKind::Network
|
||||
| DownloadErrorKind::RangeUnsupported
|
||||
| DownloadErrorKind::AllSourcesFailed => LoaderErrorKind::Network,
|
||||
};
|
||||
let mut message = error.message.clone();
|
||||
if let Some(source) = &error.source {
|
||||
message.push_str(&format!("(源 {source})"));
|
||||
}
|
||||
Self::new(kind, message)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
//! 加载器安装计划:一份 loader 版本 JSON + 需要补下载的库清单。
|
||||
//!
|
||||
//! 计划层与网络层分开:网络在 [`super::meta`],这里只做**纯计算**——把已经解析好的
|
||||
//! loader 版本 JSON 交给 [`crate::game::install::libraries`] 展开成待下载文件,再包成
|
||||
//! M27 的 [`InstallPlan`](落盘/跳过判定/原子写版本 JSON 全部沿用同一套实现)。
|
||||
//!
|
||||
//! 为什么不直接调 [`InstallPlan::build`]:那是"装一个完整原版版本"的入口(客户端 jar +
|
||||
//! 资源索引 + 逐对象),而 loader 层**只有库**——jar 与资源都由 `inheritsFrom` 的原版提供。
|
||||
//! 直接组 [`InstallPlan`] 的字段既省掉一堆"没有 jar"的告警,又不复制任何路径规则。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::forge::InstallProfile;
|
||||
use super::LoaderKind;
|
||||
use crate::game::arguments::Environment;
|
||||
use crate::game::install::libraries;
|
||||
use crate::game::install::{InstallPlan, PlanFile};
|
||||
use crate::game::layout::GameLayout;
|
||||
use crate::game::manifest::VersionJson;
|
||||
|
||||
/// 加载器版本的可用路线(判据是"能不能不跑 java 就装出来",不是加载器名字)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum LoaderSupport {
|
||||
/// 纯元数据/纯下载路线:不跑任何外部进程。
|
||||
JsonRoute,
|
||||
/// 官方 installer 声明了必须先跑的 processors(拆包/映射),走 M35 执行器外调 java。
|
||||
RequiresInstaller {
|
||||
/// 人读原因(含该版本声明的 processors 数量与作用)。
|
||||
reason: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl LoaderSupport {
|
||||
/// 稳定字符串 id(JSON 输出用)。
|
||||
#[must_use]
|
||||
pub const fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::JsonRoute => "json_route",
|
||||
Self::RequiresInstaller { .. } => "requires_installer",
|
||||
}
|
||||
}
|
||||
|
||||
/// 是否可真正安装(两条路线都走到了:processors 走 M35 执行器)。
|
||||
#[must_use]
|
||||
pub const fn is_installable(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// 是否要外调 java 跑 installer processors。
|
||||
#[must_use]
|
||||
pub const fn requires_installer(&self) -> bool {
|
||||
matches!(self, Self::RequiresInstaller { .. })
|
||||
}
|
||||
|
||||
/// JSON 视图。
|
||||
#[must_use]
|
||||
pub fn to_json(&self) -> Value {
|
||||
match self {
|
||||
Self::JsonRoute => json!({"mode": self.as_str(), "requires_installer": false}),
|
||||
Self::RequiresInstaller { reason } => {
|
||||
json!({"mode": self.as_str(), "requires_installer": true, "reason": reason})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次解析出来的加载器版本(网络层产物,计划层的输入)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LoaderRelease {
|
||||
/// 加载器种类。
|
||||
pub kind: LoaderKind,
|
||||
/// 目标原版版本(`inheritsFrom` 的父版本 id)。
|
||||
pub mc_version: String,
|
||||
/// 加载器自身版本号(`0.15.11` / `47.2.0` / `21.1.80`)。
|
||||
pub loader_version: String,
|
||||
/// 生成的 loader 版本 id(`1.20.1-fabric-0.15.11`,即 `versions/<id>/` 的目录名)。
|
||||
pub version_id: String,
|
||||
/// loader 版本 JSON(`inheritsFrom` 指向原版)。
|
||||
pub version_json: VersionJson,
|
||||
/// 可走哪条路线。
|
||||
pub support: LoaderSupport,
|
||||
/// installer jar 的磁盘路径(M35 执行器的输入;Fabric 系为 `None`)。
|
||||
pub installer: Option<PathBuf>,
|
||||
/// installer 里读出来的 install profile(执行器要 `processors` / `data` / `libraries`)。
|
||||
pub install_profile: Option<InstallProfile>,
|
||||
/// 解析期告警(不致命:例如 profile 缺 loader 库时我们补了一条)。
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
impl LoaderRelease {
|
||||
/// 是否可真正安装。
|
||||
#[must_use]
|
||||
pub const fn is_installable(&self) -> bool {
|
||||
self.support.is_installable()
|
||||
}
|
||||
|
||||
/// 是否要外调 java 跑 installer processors。
|
||||
#[must_use]
|
||||
pub const fn requires_installer(&self) -> bool {
|
||||
self.support.requires_installer()
|
||||
}
|
||||
|
||||
/// 需要 installer 时的原因(`JsonRoute` 返回 `None`)。
|
||||
#[must_use]
|
||||
pub fn installer_reason(&self) -> Option<&str> {
|
||||
match &self.support {
|
||||
LoaderSupport::JsonRoute => None,
|
||||
LoaderSupport::RequiresInstaller { reason } => Some(reason),
|
||||
}
|
||||
}
|
||||
|
||||
/// JSON 视图(`--dry-run` 与诊断共用)。
|
||||
#[must_use]
|
||||
pub fn to_json(&self) -> Value {
|
||||
json!({
|
||||
"kind": self.kind.as_str(),
|
||||
"label": self.kind.label(),
|
||||
"mc_version": self.mc_version,
|
||||
"loader_version": self.loader_version,
|
||||
"version_id": self.version_id,
|
||||
"inherits_from": self.version_json.inherits_from,
|
||||
"jar": self.version_json.jar,
|
||||
"main_class": self.version_json.main_class,
|
||||
"libraries": self.version_json.libraries.as_ref().map_or(0, Vec::len),
|
||||
"support": self.support.to_json(),
|
||||
"warnings": self.warnings,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// [`LoaderInstallPlan::build`] 的输入。
|
||||
pub struct LoaderPlanInput<'a> {
|
||||
/// 游戏目录布局(`versions/` `libraries/` 的路径规则)。
|
||||
pub layout: &'a GameLayout,
|
||||
/// 已解析的加载器版本。
|
||||
pub release: &'a LoaderRelease,
|
||||
/// 规则求值环境(OS / 架构 / 特性开关)。
|
||||
pub environment: &'a Environment,
|
||||
/// 原版的 `versions/<mc>/<mc>.json` 是否已在盘上。
|
||||
pub vanilla_installed: bool,
|
||||
}
|
||||
|
||||
/// 加载器安装计划:M27 的 [`InstallPlan`] + 加载器自己的上下文。
|
||||
///
|
||||
/// 执行期直接 `execute(&engine, &plan.install, …)`:那份计划里的 `version_json` 就是
|
||||
/// 带 `inheritsFrom` 的 loader 层,`execute` 会在**全部库就绪后**原子写到
|
||||
/// `versions/<loader-id>/<loader-id>.json`(半成品不写,沿 M27 的纪律)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LoaderInstallPlan {
|
||||
/// 加载器版本上下文。
|
||||
pub release: LoaderRelease,
|
||||
/// 待执行的下载计划(库 + 版本 JSON 落盘)。
|
||||
pub install: InstallPlan,
|
||||
/// 是否还需要先装原版(原版已装则 `false`,真装时会跳过)。
|
||||
pub vanilla_install_needed: bool,
|
||||
}
|
||||
|
||||
impl LoaderInstallPlan {
|
||||
/// 组计划。
|
||||
///
|
||||
/// 缺下载地址的库降级成告警(见 [`Self::summary_json`] 的 `warnings`),不硬失败——
|
||||
/// 与 M27「宁可给出可修复的骨架,也不静默假装成功」一致。
|
||||
#[must_use]
|
||||
pub fn build(input: LoaderPlanInput<'_>) -> Self {
|
||||
let release = input.release;
|
||||
let (files, library_warnings) = libraries::plan(
|
||||
input.layout,
|
||||
&release.version_id,
|
||||
release
|
||||
.version_json
|
||||
.libraries
|
||||
.as_deref()
|
||||
.unwrap_or_default(),
|
||||
input.environment,
|
||||
);
|
||||
|
||||
let mut warnings = release.warnings.clone();
|
||||
warnings.extend(library_warnings);
|
||||
if let Some(reason) = release.installer_reason() {
|
||||
warnings.push(format!("需要外调 java 跑 installer processors:{reason}"));
|
||||
}
|
||||
if !input.vanilla_installed {
|
||||
warnings.push(format!(
|
||||
"原版 {} 尚未安装:执行时会先装原版再叠加载器",
|
||||
release.mc_version
|
||||
));
|
||||
}
|
||||
|
||||
let install = InstallPlan {
|
||||
version_id: release.version_id.clone(),
|
||||
// jar 来自原版层(生成的 loader JSON 已把 `jar` 钉成原版 id)。
|
||||
jar_id: release.mc_version.clone(),
|
||||
game_root: input.layout.root().to_path_buf(),
|
||||
version_json_path: input.layout.version_json(&release.version_id),
|
||||
files,
|
||||
version_json: release.version_json.clone(),
|
||||
prefetched: Vec::new(),
|
||||
warnings,
|
||||
};
|
||||
|
||||
Self {
|
||||
release: release.clone(),
|
||||
install,
|
||||
vanilla_install_needed: !input.vanilla_installed,
|
||||
}
|
||||
}
|
||||
|
||||
/// 计划里的待下载文件。
|
||||
#[must_use]
|
||||
pub fn files(&self) -> &[PlanFile] {
|
||||
&self.install.files
|
||||
}
|
||||
|
||||
/// 告警(含"原版未装""本期不能真装")。
|
||||
#[must_use]
|
||||
pub fn warnings(&self) -> &[String] {
|
||||
&self.install.warnings
|
||||
}
|
||||
|
||||
/// 汇总 JSON(`--dry-run` 的主载荷)。
|
||||
#[must_use]
|
||||
pub fn summary_json(&self) -> Value {
|
||||
let mut summary = self.install.summary_json();
|
||||
if let Some(object) = summary.as_object_mut() {
|
||||
object.insert("loader".to_string(), self.release.to_json());
|
||||
object.insert(
|
||||
"vanilla_install_needed".to_string(),
|
||||
json!(self.vanilla_install_needed),
|
||||
);
|
||||
}
|
||||
summary
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
//! processor 依赖库的下载:把 [`ProcessorPlan::libraries`] 交给 M27 的下载引擎。
|
||||
//!
|
||||
//! 为什么不直接复用 `game::install::execute`:那个入口在**全量就绪后写版本 JSON**,
|
||||
//! 而 processors 的产物要先于版本清单落盘(版本 JSON 存在 = 这版真装完了)。
|
||||
//! 所以这里只借它的**逐文件跳过判定**(`current_bytes`:摘要 → 大小 → 存在即信)
|
||||
//! 与结果类型,下载本身仍走同一个引擎——镜像、健康度、退避、续传一条不少。
|
||||
|
||||
use futures_util::stream::{self, StreamExt};
|
||||
|
||||
use crate::download::DownloadEngine;
|
||||
use crate::game::install::execute::current_bytes;
|
||||
use crate::game::install::{
|
||||
FileFailure, FileOutcome, FileStatus, InstallReport, PlanFile, ProgressSink,
|
||||
};
|
||||
|
||||
/// 并发下载 processor 依赖库(`concurrency` 上限,引擎内部另有信号量)。
|
||||
///
|
||||
/// 失败**不**中断:与 M27 一致,逐文件结构化返回,由调用方决定是否继续。
|
||||
pub async fn download_libraries(
|
||||
engine: &DownloadEngine,
|
||||
files: &[PlanFile],
|
||||
concurrency: usize,
|
||||
sink: Option<ProgressSink>,
|
||||
) -> InstallReport {
|
||||
let outcomes: Vec<FileOutcome> = stream::iter(files.iter())
|
||||
.map(|file| {
|
||||
let sink = sink.clone();
|
||||
async move {
|
||||
let outcome = install_one(engine, file).await;
|
||||
if let Some(sink) = &sink {
|
||||
sink(&outcome);
|
||||
}
|
||||
outcome
|
||||
}
|
||||
})
|
||||
.buffer_unordered(concurrency.max(1))
|
||||
.collect()
|
||||
.await;
|
||||
|
||||
InstallReport {
|
||||
files: outcomes,
|
||||
version_json: None,
|
||||
warnings: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 单文件:先判「已就绪」,否则走引擎下载。
|
||||
async fn install_one(engine: &DownloadEngine, file: &PlanFile) -> FileOutcome {
|
||||
if let Some(bytes) = current_bytes(file) {
|
||||
return FileOutcome {
|
||||
role: file.role,
|
||||
name: file.name.clone(),
|
||||
dest: file.dest.clone(),
|
||||
status: FileStatus::Skipped,
|
||||
bytes,
|
||||
source: None,
|
||||
failure: None,
|
||||
};
|
||||
}
|
||||
match engine.download(file.request()).await {
|
||||
Ok(outcome) => FileOutcome {
|
||||
role: file.role,
|
||||
name: file.name.clone(),
|
||||
dest: file.dest.clone(),
|
||||
status: FileStatus::Downloaded,
|
||||
bytes: outcome.bytes,
|
||||
source: Some(outcome.source),
|
||||
failure: None,
|
||||
},
|
||||
Err(error) => FileOutcome {
|
||||
role: file.role,
|
||||
name: file.name.clone(),
|
||||
dest: file.dest.clone(),
|
||||
status: FileStatus::Failed,
|
||||
bytes: 0,
|
||||
source: None,
|
||||
failure: Some(FileFailure {
|
||||
kind: error.kind,
|
||||
message: error.message.clone(),
|
||||
}),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
//! processors 执行器的错误分类(CLI 据此给稳定错误码 `processor_*`)。
|
||||
//!
|
||||
//! 与 [`crate::addon::loader::LoaderError`] 分开是刻意的:加载器**解析**阶段的失败
|
||||
//! (元数据坏了、版本不存在)与**执行**阶段的失败(java 跑挂了、产物没落盘)对用户
|
||||
//! 是完全不同的两件事——前者换版本/换线路,后者多半是 Java 或磁盘问题。
|
||||
//! 失败分流也是 mission 的硬要求:`processor_failed` 必须能单独被脚本识别。
|
||||
|
||||
use std::fmt;
|
||||
|
||||
/// processor 执行失败的大类。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ProcessorErrorKind {
|
||||
/// 规划期:install profile 形状不对(缺 `jar`、坐标不合法、变量缺定义)。
|
||||
Plan,
|
||||
/// 找不到可用的 Java(M33 的解析链一个候选都没有)。
|
||||
Java,
|
||||
/// processor 自己要用的 jar/依赖不在盘上(下载没覆盖到)。
|
||||
Library,
|
||||
/// 外调 java 失败(非零退出),消息里带脱敏后的 stderr 尾部。
|
||||
Failed,
|
||||
/// processor 跑完了但产物没落盘。
|
||||
Output,
|
||||
/// 本地 IO(解包 installer 条目、建目录、复制 MC jar、起进程失败)。
|
||||
Io,
|
||||
}
|
||||
|
||||
impl ProcessorErrorKind {
|
||||
/// 稳定字符串 id(进 `--json` 的 `error.code`)。
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Plan => "processor_plan",
|
||||
Self::Java => "processor_java_missing",
|
||||
Self::Library => "processor_library_missing",
|
||||
Self::Failed => "processor_failed",
|
||||
Self::Output => "processor_output_missing",
|
||||
Self::Io => "processor_io",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// processor 执行错误:大类 + 人读说明 + 可选提示 + 可选 stderr 尾部(已脱敏)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProcessorError {
|
||||
kind: ProcessorErrorKind,
|
||||
message: String,
|
||||
hint: Option<String>,
|
||||
stderr_tail: Option<String>,
|
||||
}
|
||||
|
||||
impl ProcessorError {
|
||||
/// 构造。
|
||||
#[must_use]
|
||||
pub fn new(kind: ProcessorErrorKind, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
kind,
|
||||
message: message.into(),
|
||||
hint: None,
|
||||
stderr_tail: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 规划期失败(install profile 形状/变量问题)。
|
||||
#[must_use]
|
||||
pub fn plan(message: impl Into<String>) -> Self {
|
||||
Self::new(ProcessorErrorKind::Plan, message)
|
||||
}
|
||||
|
||||
/// 找不到可用的 Java。
|
||||
#[must_use]
|
||||
pub fn java_missing(message: impl Into<String>) -> Self {
|
||||
Self::new(ProcessorErrorKind::Java, message)
|
||||
}
|
||||
|
||||
/// processor 依赖不在盘上。
|
||||
#[must_use]
|
||||
pub fn library_missing(message: impl Into<String>) -> Self {
|
||||
Self::new(ProcessorErrorKind::Library, message)
|
||||
}
|
||||
|
||||
/// processor 产物没落盘。
|
||||
#[must_use]
|
||||
pub fn output_missing(message: impl Into<String>) -> Self {
|
||||
Self::new(ProcessorErrorKind::Output, message)
|
||||
}
|
||||
|
||||
/// 本地 IO 失败。
|
||||
#[must_use]
|
||||
pub fn io(message: impl Into<String>) -> Self {
|
||||
Self::new(ProcessorErrorKind::Io, message)
|
||||
}
|
||||
|
||||
/// 外调 java 非零退出:消息 = 说明 + 脱敏后的 stderr 尾部。
|
||||
#[must_use]
|
||||
pub fn failed(label: &str, exit: &str, stderr_tail: &str) -> Self {
|
||||
let mut message = format!("processor {label} 执行失败(退出码 {exit})");
|
||||
if !stderr_tail.trim().is_empty() {
|
||||
message.push_str(";stderr 尾部:\n");
|
||||
message.push_str(stderr_tail.trim_end());
|
||||
}
|
||||
let mut error = Self::new(ProcessorErrorKind::Failed, message);
|
||||
error.stderr_tail = Some(stderr_tail.trim_end().to_string());
|
||||
error
|
||||
}
|
||||
|
||||
/// 补一句提示。
|
||||
#[must_use]
|
||||
pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
|
||||
self.hint = Some(hint.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// 大类。
|
||||
#[must_use]
|
||||
pub const fn kind(&self) -> ProcessorErrorKind {
|
||||
self.kind
|
||||
}
|
||||
|
||||
/// 人读说明。
|
||||
#[must_use]
|
||||
pub fn message(&self) -> &str {
|
||||
&self.message
|
||||
}
|
||||
|
||||
/// 下一步提示。
|
||||
#[must_use]
|
||||
pub fn hint(&self) -> Option<&str> {
|
||||
self.hint.as_deref()
|
||||
}
|
||||
|
||||
/// 脱敏后的 stderr 尾部(只有 `processor_failed` 会有)。
|
||||
#[must_use]
|
||||
pub fn stderr_tail(&self) -> Option<&str> {
|
||||
self.stderr_tail.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ProcessorError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str(&self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ProcessorError {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{ProcessorError, ProcessorErrorKind};
|
||||
|
||||
#[test]
|
||||
fn stable_codes_are_distinct_and_prefixed() {
|
||||
let kinds = [
|
||||
ProcessorErrorKind::Plan,
|
||||
ProcessorErrorKind::Java,
|
||||
ProcessorErrorKind::Library,
|
||||
ProcessorErrorKind::Failed,
|
||||
ProcessorErrorKind::Output,
|
||||
ProcessorErrorKind::Io,
|
||||
];
|
||||
for kind in kinds {
|
||||
assert!(kind.as_str().starts_with("processor_"), "{}", kind.as_str());
|
||||
}
|
||||
assert_eq!(ProcessorErrorKind::Failed.as_str(), "processor_failed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_carries_the_stderr_tail_in_message_and_accessor() {
|
||||
let error = ProcessorError::failed("g:a:1", "1", "boom\nCaused by: x");
|
||||
assert_eq!(error.kind(), ProcessorErrorKind::Failed);
|
||||
assert!(error.message().contains("退出码 1"), "{}", error.message());
|
||||
assert!(error.message().contains("Caused by: x"));
|
||||
assert_eq!(error.stderr_tail(), Some("boom\nCaused by: x"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_stderr_keeps_the_message_clean() {
|
||||
let error = ProcessorError::failed("g:a:1", "255", "");
|
||||
assert!(!error.message().contains("stderr 尾部"));
|
||||
assert_eq!(error.stderr_tail(), Some(""));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
//! 逐个外调 java 执行 processor:`java -cp <依赖…>:<jar> <主类> <参数…>`。
|
||||
//!
|
||||
//! 依据 `DevDocs/analyse/05-modloader-modpack.md` §1.2(Forge 新式 installer 的 processors
|
||||
//! 流水线)与 HMCL `ForgeNewInstallTask.ProcessorTask` 的实现,四条硬规矩:
|
||||
//!
|
||||
//! 1. **串行**:后面的 processor 吃前面的产物(拆包 → 映射 → 打补丁),并行必炸;
|
||||
//! 2. **幂等**:每步先按 `outputs` 的 SHA-1 判定,全部命中就整步跳过(重跑 install 零成本);
|
||||
//! 3. **主类来自 jar 的 MANIFEST**:官方 install profile 不写主类(少数 fork 给 `mainClass`);
|
||||
//! 4. **失败要能定位**:非零退出带脱敏后的 stderr 尾部(路径/用户名不能泄漏到日志外)。
|
||||
//!
|
||||
//! # 关于产物 SHA-1 与 zlib 变体(HMCL 踩过的坑)
|
||||
//!
|
||||
//! processor 动态生成的 jar 是用**本机 JDK 的 zlib** 压缩的。zlib-ng 之类非标准实现会把
|
||||
//! 同样的内容压出不同字节 → SHA-1 与 profile 里的期望值不符。HMCL 的处置是「哈希不符时
|
||||
//! 退化成 ZIP 结构校验」。我们的处置更保守:**存在性强制、摘要不符只告警**,
|
||||
//! 既不误杀真实装,也不假装校验通过。
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::error::ProcessorError;
|
||||
use super::jar::read_main_class;
|
||||
use super::plan::{ProcessorPlan, ProcessorStep};
|
||||
use super::prepare::prepare;
|
||||
use super::text::{host_redactor, non_empty, tail};
|
||||
use crate::download::verify;
|
||||
use crate::download::HashAlgo;
|
||||
use crate::game::java::{self, JavaRequirement, JavaRuntime};
|
||||
use crate::launch::redact::Redactor;
|
||||
|
||||
/// 一步的结局。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum StepStatus {
|
||||
/// 产物已就绪(SHA-1 命中),整步跳过。
|
||||
Skipped,
|
||||
/// 真的起了一次 java。
|
||||
Executed,
|
||||
}
|
||||
|
||||
impl StepStatus {
|
||||
/// 稳定字符串 id(`--json` 用)。
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Skipped => "skipped",
|
||||
Self::Executed => "executed",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一步的执行结果。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StepOutcome {
|
||||
/// 序号(从 1 起)。
|
||||
pub index: usize,
|
||||
/// jar 的 maven 坐标。
|
||||
pub label: String,
|
||||
/// 结局。
|
||||
pub status: StepStatus,
|
||||
/// 产物个数。
|
||||
pub outputs: usize,
|
||||
/// 耗时(毫秒;跳过时为 0)。
|
||||
pub elapsed_ms: u128,
|
||||
/// stdout 尾巴(已脱敏,可能为空)。
|
||||
pub stdout_tail: Option<String>,
|
||||
/// stderr 尾巴(已脱敏,可能为空)。
|
||||
pub stderr_tail: Option<String>,
|
||||
}
|
||||
|
||||
impl StepOutcome {
|
||||
/// JSON 视图。
|
||||
#[must_use]
|
||||
pub fn to_json(&self) -> Value {
|
||||
json!({
|
||||
"index": self.index,
|
||||
"jar": self.label,
|
||||
"status": self.status.as_str(),
|
||||
"outputs": self.outputs,
|
||||
"elapsed_ms": self.elapsed_ms,
|
||||
"stderr_tail": self.stderr_tail,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次 processors 执行的汇总。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProcessorRun {
|
||||
/// 实际用的 Java(含来源与主版本)。
|
||||
pub java: JavaRuntime,
|
||||
/// 逐步骤结果(顺序即执行顺序)。
|
||||
pub steps: Vec<StepOutcome>,
|
||||
/// 不致命的告警(摘要不符、没有 outputs 的步骤等)。
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
impl ProcessorRun {
|
||||
/// 真正跑过的步数。
|
||||
#[must_use]
|
||||
pub fn executed(&self) -> usize {
|
||||
self.steps
|
||||
.iter()
|
||||
.filter(|step| step.status == StepStatus::Executed)
|
||||
.count()
|
||||
}
|
||||
|
||||
/// 跳过的步数(幂等命中)。
|
||||
#[must_use]
|
||||
pub fn skipped(&self) -> usize {
|
||||
self.steps
|
||||
.iter()
|
||||
.filter(|step| step.status == StepStatus::Skipped)
|
||||
.count()
|
||||
}
|
||||
|
||||
/// JSON 视图。
|
||||
#[must_use]
|
||||
pub fn to_json(&self) -> Value {
|
||||
json!({
|
||||
"java": {
|
||||
"binary": self.java.binary.display().to_string(),
|
||||
"source": self.java.origin.as_str(),
|
||||
"major": self.java.major,
|
||||
"version": self.java.version,
|
||||
},
|
||||
"executed": self.executed(),
|
||||
"skipped": self.skipped(),
|
||||
"steps": self.steps.iter().map(StepOutcome::to_json).collect::<Vec<_>>(),
|
||||
"warnings": self.warnings,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 每步完成的回调(CLI 往 stderr 打实时进度用)。
|
||||
pub type StepSink = Arc<dyn Fn(&StepOutcome) + Send + Sync>;
|
||||
|
||||
/// 按 M33 的解析链挑一个能跑 installer 的 Java:托管 > `JAVA_HOME` > `PATH` > 扫盘。
|
||||
///
|
||||
/// 与启动路径的差异只有一点:**扫盘只在前面都不满足时才做**(别为了一次安装白跑
|
||||
/// N 次 `java -version`),这里复用 [`crate::game::java::pick`] 的同一套优先序。
|
||||
#[must_use]
|
||||
pub fn select_java(managed_root: &Path, requirement: &JavaRequirement) -> Option<JavaRuntime> {
|
||||
let mut candidates = java::discover_managed(managed_root);
|
||||
candidates.extend(java::system_candidates());
|
||||
if let Some(runtime) = java::pick(&candidates, requirement) {
|
||||
if usable(runtime) {
|
||||
return Some(runtime.clone());
|
||||
}
|
||||
}
|
||||
candidates.extend(java::standard_candidates());
|
||||
let deduped = java::discover::dedupe(candidates);
|
||||
java::pick(&deduped, requirement).cloned().filter(usable)
|
||||
}
|
||||
|
||||
/// 候选是否真的能用:探测到主版本,或者那个文件确实在盘上。
|
||||
fn usable(runtime: &JavaRuntime) -> bool {
|
||||
runtime.major.is_some() || runtime.binary.is_file()
|
||||
}
|
||||
|
||||
/// 顺序执行所有 processor。
|
||||
///
|
||||
/// 全部产物已就绪时走快路径:不复制 client jar、不解包 installer、不起任何进程。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 临时目录/解包/复制失败(IO)、依赖缺失、`java -cp` 起不来或非零退出、
|
||||
/// 产物没落盘时返回 [`ProcessorError`]。
|
||||
pub fn run(
|
||||
plan: &ProcessorPlan,
|
||||
runtime: &JavaRuntime,
|
||||
sink: Option<StepSink>,
|
||||
) -> Result<ProcessorRun, ProcessorError> {
|
||||
let redactor = host_redactor();
|
||||
let mut warnings = Vec::new();
|
||||
let mut outcomes = Vec::with_capacity(plan.steps.len());
|
||||
let satisfied: Vec<bool> = plan
|
||||
.steps
|
||||
.iter()
|
||||
.map(|step| outputs_satisfied(step, &mut warnings))
|
||||
.collect();
|
||||
if satisfied.iter().any(|done| !done) {
|
||||
prepare(plan)?;
|
||||
}
|
||||
for (step, done) in plan.steps.iter().zip(satisfied) {
|
||||
let outcome = if done {
|
||||
StepOutcome {
|
||||
index: step.index,
|
||||
label: step.label.clone(),
|
||||
status: StepStatus::Skipped,
|
||||
outputs: step.outputs.len(),
|
||||
elapsed_ms: 0,
|
||||
stdout_tail: None,
|
||||
stderr_tail: None,
|
||||
}
|
||||
} else {
|
||||
run_step(plan, runtime, step, &redactor, &mut warnings)?
|
||||
};
|
||||
if let Some(sink) = &sink {
|
||||
sink(&outcome);
|
||||
}
|
||||
outcomes.push(outcome);
|
||||
}
|
||||
|
||||
Ok(ProcessorRun {
|
||||
java: runtime.clone(),
|
||||
steps: outcomes,
|
||||
warnings,
|
||||
})
|
||||
}
|
||||
|
||||
/// 起一次 java,跑完校验产物。
|
||||
fn run_step(
|
||||
plan: &ProcessorPlan,
|
||||
runtime: &JavaRuntime,
|
||||
step: &ProcessorStep,
|
||||
redactor: &Redactor,
|
||||
warnings: &mut Vec<String>,
|
||||
) -> Result<StepOutcome, ProcessorError> {
|
||||
ensure_dependencies(step)?;
|
||||
let main_class = match &step.main_class {
|
||||
Some(main_class) => main_class.clone(),
|
||||
None => read_main_class(&step.jar)?,
|
||||
};
|
||||
let classpath = step.classpath_join()?;
|
||||
|
||||
let started = Instant::now();
|
||||
let output = Command::new(&runtime.binary)
|
||||
.arg("-cp")
|
||||
.arg(&classpath)
|
||||
.arg(&main_class)
|
||||
.args(&step.args)
|
||||
.current_dir(&plan.scratch)
|
||||
.output()
|
||||
.map_err(|error| {
|
||||
ProcessorError::io(format!(
|
||||
"起 java 失败({}):{error}",
|
||||
runtime.binary.display()
|
||||
))
|
||||
.with_hint(
|
||||
"确认 `ull java list` 里的路径可用,或用 `ull java install <major>` 装一个托管 JDK",
|
||||
)
|
||||
})?;
|
||||
let elapsed_ms = started.elapsed().as_millis();
|
||||
let stdout = redactor
|
||||
.scrub(&String::from_utf8_lossy(&output.stdout))
|
||||
.into_owned();
|
||||
let stderr = redactor
|
||||
.scrub(&String::from_utf8_lossy(&output.stderr))
|
||||
.into_owned();
|
||||
|
||||
if !output.status.success() {
|
||||
let exit = output
|
||||
.status
|
||||
.code()
|
||||
.map_or_else(|| "signal".to_string(), |code| code.to_string());
|
||||
return Err(
|
||||
ProcessorError::failed(&step.label, &exit, &tail(&stderr)).with_hint(
|
||||
"installer 的 processor 是官方产物,失败多半是 Java 版本/磁盘权限/网络(部分步骤要联网取映射)",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
verify_outputs(step, warnings)?;
|
||||
Ok(StepOutcome {
|
||||
index: step.index,
|
||||
label: step.label.clone(),
|
||||
status: StepStatus::Executed,
|
||||
outputs: step.outputs.len(),
|
||||
elapsed_ms,
|
||||
stdout_tail: non_empty(tail(&stdout)),
|
||||
stderr_tail: non_empty(tail(&stderr)),
|
||||
})
|
||||
}
|
||||
|
||||
/// 依赖 jar 是否都在盘上(缺了直接报错:起了 java 也只会得到 `NoClassDefFoundError`)。
|
||||
fn ensure_dependencies(step: &ProcessorStep) -> Result<(), ProcessorError> {
|
||||
if !step.jar.is_file() {
|
||||
return Err(ProcessorError::library_missing(format!(
|
||||
"processor {} 的可执行 jar 不在盘上:{}",
|
||||
step.label,
|
||||
step.jar.display()
|
||||
)));
|
||||
}
|
||||
let missing: Vec<String> = step
|
||||
.classpath
|
||||
.iter()
|
||||
.filter(|path| !path.is_file())
|
||||
.map(|path| path.display().to_string())
|
||||
.collect();
|
||||
if missing.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
Err(ProcessorError::library_missing(format!(
|
||||
"processor {} 有 {} 个依赖 jar 不在盘上:{}",
|
||||
step.label,
|
||||
missing.len(),
|
||||
missing.join(";")
|
||||
))
|
||||
.with_hint("重跑一次 `ull install` 会补齐依赖(下载走镜像/健康度引擎)"))
|
||||
}
|
||||
|
||||
/// 产物存在性(硬)与摘要(软)校验。
|
||||
fn verify_outputs(step: &ProcessorStep, warnings: &mut Vec<String>) -> Result<(), ProcessorError> {
|
||||
let missing: Vec<String> = step
|
||||
.outputs
|
||||
.iter()
|
||||
.filter(|output| !output.path.is_file())
|
||||
.map(|output| output.path.display().to_string())
|
||||
.collect();
|
||||
if !missing.is_empty() {
|
||||
return Err(ProcessorError::output_missing(format!(
|
||||
"processor {} 跑完了但产物没落盘:{}",
|
||||
step.label,
|
||||
missing.join(";")
|
||||
))
|
||||
.with_hint("磁盘权限/杀软拦截是常见原因;也可以删掉 versions/ 下这版后重装"));
|
||||
}
|
||||
for output in &step.outputs {
|
||||
let Some(expected) = &output.sha1 else {
|
||||
continue;
|
||||
};
|
||||
let Ok(actual) = verify::digest_file(&output.path, HashAlgo::Sha1) else {
|
||||
continue;
|
||||
};
|
||||
if !verify::matches(expected, &actual) {
|
||||
warnings.push(format!(
|
||||
"processor {} 的产物摘要与 profile 不符(期望 {expected} 实际 {actual}):非标准 zlib 实现会压出不同字节,按可用处理",
|
||||
step.label
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 产物是否都已就绪(幂等判据:存在 + 摘要命中;profile 没给摘要就只认存在)。
|
||||
fn outputs_satisfied(step: &ProcessorStep, warnings: &mut Vec<String>) -> bool {
|
||||
if step.outputs.is_empty() {
|
||||
// 没有产物声明 → 无法判断是否跑过,只能每次都跑(HMCL 同样处理)。
|
||||
return false;
|
||||
}
|
||||
for output in &step.outputs {
|
||||
if !output.path.is_file() {
|
||||
return false;
|
||||
}
|
||||
let Some(expected) = &output.sha1 else {
|
||||
continue;
|
||||
};
|
||||
let Ok(actual) = verify::digest_file(&output.path, HashAlgo::Sha1) else {
|
||||
return false;
|
||||
};
|
||||
if !verify::matches(expected, &actual) {
|
||||
warnings.push(format!(
|
||||
"{} 的摘要与 profile 不符,重新执行该 processor",
|
||||
output.path.display()
|
||||
));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
//! 从 processor jar 的 `MANIFEST.MF` 里取 `Main-Class`。
|
||||
//!
|
||||
//! 官方 install profile **不写主类**(`processors[].jar` 只给坐标),主类在 jar 的清单里;
|
||||
//! 少数 fork 会额外给 `mainClass`(那条路走 [`super::plan::ProcessorStep::main_class`],
|
||||
//! 不到这里)。清单按 RFC 822 折行:续行以**单个空格**开头。
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use super::error::ProcessorError;
|
||||
|
||||
/// 读 jar 的 `Main-Class`。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// jar 打不开/不是 zip(IO/规划错误)、没有 `META-INF/MANIFEST.MF` 或清单里没有
|
||||
/// `Main-Class`(规划错误)。
|
||||
pub(super) fn read_main_class(jar: &Path) -> Result<String, ProcessorError> {
|
||||
use std::io::Read;
|
||||
|
||||
let file = std::fs::File::open(jar).map_err(|error| {
|
||||
ProcessorError::io(format!(
|
||||
"打开 processor jar 失败({}):{error}",
|
||||
jar.display()
|
||||
))
|
||||
})?;
|
||||
let mut archive = zip::ZipArchive::new(file).map_err(|error| {
|
||||
ProcessorError::plan(format!("{} 不是合法 jar:{error}", jar.display()))
|
||||
})?;
|
||||
let mut manifest = String::new();
|
||||
{
|
||||
let mut entry = archive.by_name("META-INF/MANIFEST.MF").map_err(|error| {
|
||||
ProcessorError::plan(format!(
|
||||
"{} 里没有 META-INF/MANIFEST.MF(拿不到主类):{error}",
|
||||
jar.display()
|
||||
))
|
||||
})?;
|
||||
entry.read_to_string(&mut manifest).map_err(|error| {
|
||||
ProcessorError::io(format!("读 {} 的 MANIFEST 失败:{error}", jar.display()))
|
||||
})?;
|
||||
}
|
||||
parse_main_class(&manifest).ok_or_else(|| {
|
||||
ProcessorError::plan(format!(
|
||||
"{} 的 MANIFEST 里没有 Main-Class(install profile 也没给 mainClass)",
|
||||
jar.display()
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// 从 MANIFEST 文本里取 `Main-Class`(续行以单个空格开头)。
|
||||
fn parse_main_class(manifest: &str) -> Option<String> {
|
||||
let mut lines = manifest.lines().peekable();
|
||||
while let Some(line) = lines.next() {
|
||||
let Some(rest) = line.strip_prefix("Main-Class:") else {
|
||||
continue;
|
||||
};
|
||||
let mut value = rest.trim().to_string();
|
||||
while let Some(next) = lines.peek() {
|
||||
let Some(continuation) = next.strip_prefix(' ') else {
|
||||
break;
|
||||
};
|
||||
value.push_str(continuation.trim());
|
||||
lines.next();
|
||||
}
|
||||
return (!value.is_empty()).then_some(value);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::parse_main_class;
|
||||
|
||||
#[test]
|
||||
fn parses_main_class_with_continuation_lines() {
|
||||
let manifest = "Manifest-Version: 1.0\r\nMain-Class: com.example.\r\n Tool\r\n\r\n";
|
||||
assert_eq!(
|
||||
parse_main_class(manifest).as_deref(),
|
||||
Some("com.example.Tool")
|
||||
);
|
||||
assert!(parse_main_class("Manifest-Version: 1.0\n").is_none());
|
||||
assert!(parse_main_class("Main-Class: \n").is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//! Installer Processors 执行器(M35):把 install profile 的 `processors[]` 跑起来。
|
||||
//!
|
||||
//! 设计输入:`DevDocs/analyse/05-modloader-modpack.md` §1.2(Forge 新式 installer 的
|
||||
//! processor 流水线)与 HMCL `ForgeNewInstallTask` / `ProcessorTask` 的实现。
|
||||
//!
|
||||
//! # 覆盖范围
|
||||
//!
|
||||
//! | 版本段 | 有无客户端 processor | 本模块 |
|
||||
//! |--------|----------------------|--------|
|
||||
//! | Forge 1.13 ~ 1.20.x(spec 1) | 有(拆包/映射/打补丁) | ✅ 真跑 |
|
||||
//! | NeoForge 1.20.5+(26.x 只剩 1 个 `PROCESS_MINECRAFT_JAR`) | 有 | ✅ 真跑 |
|
||||
//! | Forge spec 0(1.12.2 重建版)/ Fabric / Quilt | 无 | 不需要(走 M32 的 json 路线) |
|
||||
//!
|
||||
//! # 流水线
|
||||
//!
|
||||
//! ```text
|
||||
//! plan install profile 的 data 表 → 变量表({MINECRAFT_JAR}/{SIDE}/[坐标]/'字面量')
|
||||
//! → 每步 java -cp 参数 + 产物/摘要 + processor 依赖库待下清单
|
||||
//! download 依赖库(走 M27 引擎:镜像/健康度/退避/校验)
|
||||
//! exec 串行外调 java(主类取自 jar 的 MANIFEST)→ 产物存在性(硬)+ 摘要(软)
|
||||
//! ```
|
||||
//!
|
||||
//! # 三条边界
|
||||
//!
|
||||
//! 1. **不写版本 JSON**:那是 M27 `execute` 的事,且必须等 processors 全部成功
|
||||
//! (版本清单存在 = 这一版真的装完了,半成品不许留);
|
||||
//! 2. **不改共享层**:原版 client jar 先复制到临时目录再交给 processor,
|
||||
//! processor 只会改临时副本;
|
||||
//! 3. **不猜**:`data` 表里缺变量、processor 缺 `jar`、产物没落盘,一律明确报错,
|
||||
//! 不拿半截参数去试(那种失败的 stderr 更难查)。
|
||||
//!
|
||||
//! # 幂等
|
||||
//!
|
||||
//! 两级:①每步按 `outputs` 的 SHA-1 命中整步跳过(重跑零成本、不碰文件系统);
|
||||
//! ②调用方以「版本 JSON 已落盘」为粗粒度判据整段跳过。
|
||||
|
||||
pub mod download;
|
||||
pub mod error;
|
||||
pub mod exec;
|
||||
pub mod jar;
|
||||
pub mod plan;
|
||||
pub mod prepare;
|
||||
pub mod spec;
|
||||
pub mod text;
|
||||
pub mod vars;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub use download::download_libraries;
|
||||
pub use error::{ProcessorError, ProcessorErrorKind};
|
||||
pub use exec::{run, select_java, ProcessorRun, StepOutcome, StepSink, StepStatus};
|
||||
pub use plan::{ProcessorPlan, ProcessorPlanInput, ProcessorStep, StepOutput};
|
||||
pub use spec::{Datum, Processor};
|
||||
pub use text::{host_redactor, tail};
|
||||
pub use vars::{EmbeddedFile, Variables, VariablesInput, MINECRAFT_JAR_NAME};
|
||||
@@ -0,0 +1,366 @@
|
||||
//! processor 执行计划:`data` 表展开 → 每步的 `java -cp …` 命令行 → 依赖库待下载清单。
|
||||
//!
|
||||
//! 纯计算(不下载、不起进程),因此 `--dry-run` 能直接把「将执行的 processor 清单 +
|
||||
//! 所需 Java + 依赖库」打出来。唯一读盘的动作是 [`crate::game::java::requirement_for_version`]
|
||||
//! 读原版版本 JSON 判 Java 要求——那是诊断信息,读不到就按静态表兜底。
|
||||
//!
|
||||
//! # 为什么依赖库与启动 classpath 要分开
|
||||
//!
|
||||
//! install profile 的 `libraries` 是 **processor 依赖**(installertools / jarsplitter /
|
||||
//! srgutils…),它们不进游戏 classpath。M32 只把数量记进告警,M35 起真正下载它们
|
||||
//! (否则 `java -cp` 一排文件都不在盘上),但仍然**绝不**并进 loader 版本 JSON。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::error::ProcessorError;
|
||||
use super::spec::Processor;
|
||||
use super::vars::{absolute, Variables, VariablesInput, MINECRAFT_JAR_NAME};
|
||||
use crate::addon::loader::forge::InstallProfile;
|
||||
use crate::addon::loader::{maven, LoaderKind};
|
||||
use crate::download::ResourceKind;
|
||||
use crate::game::arguments::Environment;
|
||||
use crate::game::install::libraries;
|
||||
use crate::game::install::{FileRole, PlanFile};
|
||||
use crate::game::java::{requirement_for_version, JavaRequirement};
|
||||
use crate::game::layout::GameLayout;
|
||||
use crate::game::library::MavenCoordinate;
|
||||
|
||||
/// 一个 processor 的某个产物。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct StepOutput {
|
||||
/// 产物绝对路径。
|
||||
pub path: PathBuf,
|
||||
/// 期望 SHA-1(profile 没给或给的不是摘要时为 `None`,只能按存在性判定)。
|
||||
pub sha1: Option<String>,
|
||||
}
|
||||
|
||||
/// 一个待执行的 processor(已展开参数,不再依赖 install profile)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProcessorStep {
|
||||
/// 序号(从 1 起,顺序即执行顺序)。
|
||||
pub index: usize,
|
||||
/// 人读标识(jar 的 maven 坐标)。
|
||||
pub label: String,
|
||||
/// 可执行 jar 的磁盘路径。
|
||||
pub jar: PathBuf,
|
||||
/// 主类(profile 没写就留 `None`,执行期读 jar 的 MANIFEST)。
|
||||
pub main_class: Option<String>,
|
||||
/// `-cp` 的依赖 jar(不含 `jar` 自己)。
|
||||
pub classpath: Vec<PathBuf>,
|
||||
/// 已展开的参数。
|
||||
pub args: Vec<String>,
|
||||
/// 产物与期望摘要。
|
||||
pub outputs: Vec<StepOutput>,
|
||||
}
|
||||
|
||||
impl ProcessorStep {
|
||||
/// 一步的 `-cp` 全串(依赖在前、可执行 jar 在后,与 HMCL 一致)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 路径里含分隔符时返回 IO 错误(Windows 的 `;` 不可能出现在合法路径里,
|
||||
/// 真出现说明路径是拼错的)。
|
||||
pub fn classpath_join(&self) -> Result<String, ProcessorError> {
|
||||
let paths = self
|
||||
.classpath
|
||||
.iter()
|
||||
.chain(std::iter::once(&self.jar))
|
||||
.map(|path| path.as_path());
|
||||
std::env::join_paths(paths)
|
||||
.map(|joined| joined.to_string_lossy().into_owned())
|
||||
.map_err(|error| ProcessorError::io(format!("classpath 拼不起来:{error}")))
|
||||
}
|
||||
}
|
||||
|
||||
/// [`ProcessorPlan::build`] 的输入。
|
||||
pub struct ProcessorPlanInput<'a> {
|
||||
/// 加载器种类(只影响补库时的 maven 基址兜底)。
|
||||
pub kind: LoaderKind,
|
||||
/// 目标原版版本。
|
||||
pub mc_version: &'a str,
|
||||
/// loader 版本 id(依赖库 `hint=local` 的落点)。
|
||||
pub version_id: &'a str,
|
||||
/// installer 里读出来的 install profile。
|
||||
pub profile: &'a InstallProfile,
|
||||
/// 游戏目录布局。
|
||||
pub layout: &'a GameLayout,
|
||||
/// installer jar 的磁盘路径。
|
||||
pub installer: &'a Path,
|
||||
/// 临时目录(解包/`MINECRAFT_JAR` 副本都落这里)。
|
||||
pub scratch: &'a Path,
|
||||
/// 规则求值环境。
|
||||
pub environment: &'a Environment,
|
||||
}
|
||||
|
||||
/// processor 执行计划。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProcessorPlan {
|
||||
/// loader 版本 id。
|
||||
pub version_id: String,
|
||||
/// installer jar 路径。
|
||||
pub installer: PathBuf,
|
||||
/// 临时目录。
|
||||
pub scratch: PathBuf,
|
||||
/// 原版 client jar(复制源)。
|
||||
pub minecraft_jar: PathBuf,
|
||||
/// 交给 processor 的 client jar 副本(`{MINECRAFT_JAR}`)。
|
||||
pub minecraft_jar_copy: PathBuf,
|
||||
/// 展开后的变量表。
|
||||
pub variables: Variables,
|
||||
/// 按顺序待执行的 processor。
|
||||
pub steps: Vec<ProcessorStep>,
|
||||
/// processor 依赖库(走 M27 引擎下载)。
|
||||
pub libraries: Vec<PlanFile>,
|
||||
/// 跑 installer 需要的 Java 要求。
|
||||
pub java_requirement: JavaRequirement,
|
||||
/// 规划期告警(不致命)。
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
impl ProcessorPlan {
|
||||
/// 组计划。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// `data` 表变量缺定义、构件坐标不合法、processor 缺 `jar` 字段时返回规划错误。
|
||||
pub fn build(input: ProcessorPlanInput<'_>) -> Result<Self, ProcessorError> {
|
||||
let scratch = absolute(input.scratch);
|
||||
let variables = Variables::build(VariablesInput {
|
||||
data: &input.profile.data,
|
||||
layout: input.layout,
|
||||
mc_version: input.mc_version,
|
||||
installer: input.installer,
|
||||
scratch: &scratch,
|
||||
})?;
|
||||
|
||||
let mut warnings = Vec::new();
|
||||
let mut steps = Vec::new();
|
||||
let mut needed: Vec<(String, PathBuf)> = Vec::new();
|
||||
for processor in input
|
||||
.profile
|
||||
.processors
|
||||
.iter()
|
||||
.filter(|processor| processor.applies_to_client())
|
||||
{
|
||||
let coordinate = processor.jar_coordinate().ok_or_else(|| {
|
||||
ProcessorError::plan("install profile 里有 processor 缺 `jar` 字段")
|
||||
})?;
|
||||
let jar = library_path(input.layout, coordinate)?;
|
||||
needed.push((coordinate.to_string(), jar.clone()));
|
||||
let mut classpath = Vec::with_capacity(processor.classpath.len());
|
||||
for entry in &processor.classpath {
|
||||
let path = library_path(input.layout, entry)?;
|
||||
needed.push((entry.trim().to_string(), path.clone()));
|
||||
classpath.push(path);
|
||||
}
|
||||
let mut args = Vec::with_capacity(processor.args.len());
|
||||
for arg in &processor.args {
|
||||
args.push(variables.expand(arg)?);
|
||||
}
|
||||
let outputs = step_outputs(&variables, processor, coordinate, &scratch, &mut warnings)?;
|
||||
steps.push(ProcessorStep {
|
||||
index: steps.len() + 1,
|
||||
label: coordinate.to_string(),
|
||||
jar,
|
||||
main_class: processor.main_class().map(str::to_string),
|
||||
classpath,
|
||||
args,
|
||||
outputs,
|
||||
});
|
||||
}
|
||||
|
||||
let (mut libraries, library_warnings) = libraries::plan(
|
||||
input.layout,
|
||||
input.version_id,
|
||||
&input.profile.libraries,
|
||||
input.environment,
|
||||
);
|
||||
warnings.extend(library_warnings);
|
||||
let base = library_base(input.kind, input.profile);
|
||||
for (coordinate, dest) in &needed {
|
||||
if libraries.iter().any(|file| &file.dest == dest) {
|
||||
continue;
|
||||
}
|
||||
warnings.push(format!(
|
||||
"processor 依赖 {coordinate} 不在 install profile 的 libraries 节里,按 maven 基址补一条"
|
||||
));
|
||||
let Ok(parsed) = MavenCoordinate::parse(coordinate) else {
|
||||
continue;
|
||||
};
|
||||
libraries.push(PlanFile {
|
||||
role: FileRole::Library,
|
||||
name: coordinate.clone(),
|
||||
url: join_url(&base, &parsed.relative_path()),
|
||||
sha1: None,
|
||||
size: None,
|
||||
dest: dest.clone(),
|
||||
resource: ResourceKind::Library,
|
||||
});
|
||||
}
|
||||
// NeoForge 的 install profile 会把同一条依赖**声明多次**(实测 21.1.80:
|
||||
// cli-utils / srgutils / jopt-simple / gson 各 2~3 次)。不下重:同一落点被下两次
|
||||
// 会在同一个 `.part` 中间文件上互相抢(真机现场:原子落盘 os error 2)。
|
||||
let (libraries, duplicated) = dedupe_by_dest(libraries);
|
||||
if duplicated > 0 {
|
||||
warnings.push(format!(
|
||||
"install profile 重复声明了 {duplicated} 个 processor 依赖,已按落点去重"
|
||||
));
|
||||
}
|
||||
|
||||
let java_requirement = requirement_for_version(input.layout, input.mc_version);
|
||||
let minecraft_jar_copy = variables
|
||||
.get("MINECRAFT_JAR")
|
||||
.map_or_else(|| scratch.join(MINECRAFT_JAR_NAME), PathBuf::from);
|
||||
Ok(Self {
|
||||
version_id: input.version_id.to_string(),
|
||||
installer: absolute(input.installer),
|
||||
scratch,
|
||||
minecraft_jar: input.layout.version_jar(input.mc_version),
|
||||
minecraft_jar_copy,
|
||||
variables,
|
||||
steps,
|
||||
libraries,
|
||||
java_requirement,
|
||||
warnings,
|
||||
})
|
||||
}
|
||||
|
||||
/// 是否一个 processor 都没有。
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.steps.is_empty()
|
||||
}
|
||||
|
||||
/// 汇总行数(CLI 人读输出用)。
|
||||
#[must_use]
|
||||
pub fn step_count(&self) -> usize {
|
||||
self.steps.len()
|
||||
}
|
||||
|
||||
/// `--dry-run` / 报告里的 processors 载荷。
|
||||
#[must_use]
|
||||
pub fn to_json(&self) -> Value {
|
||||
json!({
|
||||
"count": self.steps.len(),
|
||||
"installer": self.installer.display().to_string(),
|
||||
"scratch": self.scratch.display().to_string(),
|
||||
"minecraft_jar": self.minecraft_jar.display().to_string(),
|
||||
"libraries": self.libraries.len(),
|
||||
"library_files": self.libraries.iter().map(|file| json!({
|
||||
"name": file.name,
|
||||
"url": file.url,
|
||||
"dest": file.dest.display().to_string(),
|
||||
"sha1": file.sha1,
|
||||
"size": file.size,
|
||||
})).collect::<Vec<_>>(),
|
||||
"variables": self.variables.len(),
|
||||
"embedded_files": self.variables.embedded().len(),
|
||||
"java_requirement": {
|
||||
"minimum": self.java_requirement.minimum,
|
||||
"maximum": self.java_requirement.maximum,
|
||||
"describe": self.java_requirement.describe(),
|
||||
"source": self.java_requirement.source.as_str(),
|
||||
},
|
||||
"steps": self.steps.iter().map(step_json).collect::<Vec<_>>(),
|
||||
"warnings": self.warnings,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 一条 processor 的 JSON 视图。
|
||||
fn step_json(step: &ProcessorStep) -> Value {
|
||||
json!({
|
||||
"index": step.index,
|
||||
"jar": step.label,
|
||||
"jar_path": step.jar.display().to_string(),
|
||||
"main_class": step.main_class,
|
||||
"classpath": step.classpath.len(),
|
||||
"args": step.args,
|
||||
"outputs": step.outputs.iter().map(|output| json!({
|
||||
"path": output.path.display().to_string(),
|
||||
"sha1": output.sha1,
|
||||
})).collect::<Vec<_>>(),
|
||||
})
|
||||
}
|
||||
|
||||
/// 展开一步的 `outputs` 表(路径 + 期望摘要)。
|
||||
fn step_outputs(
|
||||
variables: &Variables,
|
||||
processor: &Processor,
|
||||
coordinate: &str,
|
||||
scratch: &Path,
|
||||
warnings: &mut Vec<String>,
|
||||
) -> Result<Vec<StepOutput>, ProcessorError> {
|
||||
let mut outputs = Vec::with_capacity(processor.outputs.len());
|
||||
for (raw_path, raw_sha1) in &processor.outputs {
|
||||
let expanded = variables.expand(raw_path)?;
|
||||
let path = PathBuf::from(&expanded);
|
||||
let path = if path.is_absolute() {
|
||||
path
|
||||
} else {
|
||||
scratch.join(path)
|
||||
};
|
||||
let expanded_sha1 = variables.expand(raw_sha1).unwrap_or_default();
|
||||
let sha1 = is_sha1(&expanded_sha1).then(|| expanded_sha1.to_ascii_lowercase());
|
||||
if sha1.is_none() {
|
||||
warnings.push(format!(
|
||||
"processor {coordinate} 的输出 {} 没给 SHA-1,只能按存在性判定",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
outputs.push(StepOutput { path, sha1 });
|
||||
}
|
||||
Ok(outputs)
|
||||
}
|
||||
|
||||
/// 40 位十六进制(SHA-1 的样子)。
|
||||
fn is_sha1(value: &str) -> bool {
|
||||
value.len() == 40 && value.chars().all(|ch| ch.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
/// 坐标 → `libraries/` 下的路径。
|
||||
fn library_path(layout: &GameLayout, raw: &str) -> Result<PathBuf, ProcessorError> {
|
||||
let coordinate = MavenCoordinate::parse(raw.trim()).map_err(|error| {
|
||||
ProcessorError::plan(format!("processor 依赖坐标不合法:`{raw}`({error})"))
|
||||
})?;
|
||||
Ok(layout.libraries_dir().join(coordinate.relative_path()))
|
||||
}
|
||||
|
||||
/// 补库时用的 maven 基址:install profile 自己声明的 `url` 优先,否则按加载器兜底。
|
||||
fn library_base(kind: LoaderKind, profile: &InstallProfile) -> String {
|
||||
profile
|
||||
.libraries
|
||||
.iter()
|
||||
.filter_map(|library| library.url.as_deref())
|
||||
.map(str::trim)
|
||||
.find(|url| !url.is_empty())
|
||||
.map_or_else(|| maven::base(kind).to_string(), str::to_string)
|
||||
}
|
||||
|
||||
/// `基址 + 相对路径`(保证中间恰好一个 `/`)。
|
||||
fn join_url(base: &str, relative: &Path) -> String {
|
||||
let path = relative.to_string_lossy().replace('\\', "/");
|
||||
if base.ends_with('/') {
|
||||
format!("{base}{path}")
|
||||
} else {
|
||||
format!("{base}/{path}")
|
||||
}
|
||||
}
|
||||
|
||||
/// 按落点去重(保留首次出现的条目),返回(去重后的清单,丢掉几条)。
|
||||
fn dedupe_by_dest(files: Vec<PlanFile>) -> (Vec<PlanFile>, usize) {
|
||||
let total = files.len();
|
||||
let mut seen: Vec<PathBuf> = Vec::with_capacity(total);
|
||||
let mut kept: Vec<PlanFile> = Vec::with_capacity(total);
|
||||
for file in files {
|
||||
if seen.contains(&file.dest) {
|
||||
continue;
|
||||
}
|
||||
seen.push(file.dest.clone());
|
||||
kept.push(file);
|
||||
}
|
||||
let dropped = total - kept.len();
|
||||
(kept, dropped)
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
//! 临时目录准备:建目录、解包 installer 内的文件、复制原版 client jar。
|
||||
//!
|
||||
//! 三条都是「起 java 之前的脏活」,失败一律归 `processor_io`/`processor_plan`
|
||||
//! (区别于 processor 自己跑挂的 `processor_failed`)。
|
||||
|
||||
use super::error::ProcessorError;
|
||||
use super::plan::ProcessorPlan;
|
||||
|
||||
/// 单条目的**预分配**上限(64 MiB):installer jar 下载后只校验「是合法 zip」,
|
||||
/// 没有校验和(`meta.rs` 的 `fetch_installer`),因此 zip header 里声明的解压尺寸
|
||||
/// 是不可信输入——损坏或镜像投毒的 installer 可以声明天文数字,让一次小文件解包直接
|
||||
/// 申请多 GB 内存。`Vec` 仍按实际读入增长,合法条目行为不变。
|
||||
const PREALLOC_MAX: u64 = 64 << 20;
|
||||
|
||||
/// 单条目的**实际字节**上限(256 MiB):预分配封顶挡不住「声明小、解压极大」的
|
||||
/// zip bomb,所以读入过程本身也要有硬顶。超过就明确报错(不静默截断)。
|
||||
const MAX_EMBEDDED_BYTES: u64 = 256 << 20;
|
||||
|
||||
/// 执行前的准备:建临时目录、解包 installer 内的文件、复制原版 client jar。
|
||||
pub(super) fn prepare(plan: &ProcessorPlan) -> Result<(), ProcessorError> {
|
||||
std::fs::create_dir_all(&plan.scratch).map_err(|error| {
|
||||
ProcessorError::io(format!(
|
||||
"建临时目录失败({}):{error}",
|
||||
plan.scratch.display()
|
||||
))
|
||||
})?;
|
||||
extract_embedded(plan)?;
|
||||
copy_minecraft_jar(plan)
|
||||
}
|
||||
|
||||
/// 把 install profile 声明要解包的文件从 installer zip 里抽到临时目录。
|
||||
fn extract_embedded(plan: &ProcessorPlan) -> Result<(), ProcessorError> {
|
||||
let embedded = plan.variables.embedded();
|
||||
if embedded.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
use std::io::Read;
|
||||
|
||||
let installer = &plan.installer;
|
||||
let file = std::fs::File::open(installer).map_err(|error| {
|
||||
ProcessorError::io(format!(
|
||||
"打开 installer 失败({}):{error}",
|
||||
installer.display()
|
||||
))
|
||||
})?;
|
||||
let mut archive = zip::ZipArchive::new(file).map_err(|error| {
|
||||
ProcessorError::plan(format!("{} 不是合法 zip:{error}", installer.display()))
|
||||
})?;
|
||||
for item in embedded {
|
||||
let mut entry = archive.by_name(&item.entry).map_err(|error| {
|
||||
ProcessorError::plan(format!(
|
||||
"installer 里没有 {}(data 表声明要解包):{error}",
|
||||
item.entry
|
||||
))
|
||||
})?;
|
||||
let mut bytes = Vec::with_capacity(prealloc_hint(entry.size()));
|
||||
let read = (&mut entry)
|
||||
.take(MAX_EMBEDDED_BYTES + 1)
|
||||
.read_to_end(&mut bytes)
|
||||
.map_err(|error| {
|
||||
ProcessorError::io(format!("读 installer 条目 {} 失败:{error}", item.entry))
|
||||
})?;
|
||||
if read as u64 > MAX_EMBEDDED_BYTES {
|
||||
return Err(ProcessorError::plan(format!(
|
||||
"installer 条目 {} 解压后超过 {} MiB,拒绝继续(installer 可能损坏或被投毒)",
|
||||
item.entry,
|
||||
MAX_EMBEDDED_BYTES >> 20
|
||||
)));
|
||||
}
|
||||
crate::platform::atomic::write_atomic(&item.dest, &bytes).map_err(|error| {
|
||||
ProcessorError::io(format!(
|
||||
"写临时文件失败({}):{error}",
|
||||
item.dest.display()
|
||||
))
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 预分配提示:**不信任** header 声明的尺寸,封顶 [`PREALLOC_MAX`]。
|
||||
fn prealloc_hint(declared: u64) -> usize {
|
||||
declared.min(PREALLOC_MAX) as usize
|
||||
}
|
||||
|
||||
/// 复制原版 client jar 到临时目录(processor 会改写输入,不能让它碰共享层)。
|
||||
fn copy_minecraft_jar(plan: &ProcessorPlan) -> Result<(), ProcessorError> {
|
||||
let source = &plan.minecraft_jar;
|
||||
let target = &plan.minecraft_jar_copy;
|
||||
let fresh = match (std::fs::metadata(source), std::fs::metadata(target)) {
|
||||
(Ok(left), Ok(right)) => left.len() != right.len(),
|
||||
(Ok(_), Err(_)) => true,
|
||||
(Err(error), _) => {
|
||||
return Err(ProcessorError::io(format!(
|
||||
"原版 client jar 不在盘上({}):{error}",
|
||||
source.display()
|
||||
))
|
||||
.with_hint("先装原版:`ull install <version>`"))
|
||||
}
|
||||
};
|
||||
if !fresh {
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(parent) = target.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|error| {
|
||||
ProcessorError::io(format!("建目录失败({}):{error}", parent.display()))
|
||||
})?;
|
||||
}
|
||||
std::fs::copy(source, target).map_err(|error| {
|
||||
ProcessorError::io(format!(
|
||||
"复制 {} → {} 失败:{error}",
|
||||
source.display(),
|
||||
target.display()
|
||||
))
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::Path;
|
||||
|
||||
use super::{prealloc_hint, prepare, PREALLOC_MAX};
|
||||
use crate::addon::loader::forge::InstallProfile;
|
||||
use crate::addon::loader::processors::{ProcessorPlan, ProcessorPlanInput};
|
||||
use crate::addon::loader::LoaderKind;
|
||||
use crate::game::arguments::Environment;
|
||||
use crate::game::layout::GameLayout;
|
||||
|
||||
/// 一步 processor + 一个要从 installer 里解包的 data 值。
|
||||
const PROFILE: &str = r#"{
|
||||
"spec": 1, "minecraft": "1.20.1", "json": "/version.json",
|
||||
"processors": [{"jar": "net.minecraftforge:binarypatcher:1.1.1", "args": ["--apply", "{BINPATCH}"]}],
|
||||
"libraries": [],
|
||||
"data": {"BINPATCH": {"client": "/data/client.lzma"}}
|
||||
}"#;
|
||||
|
||||
#[test]
|
||||
fn preallocation_is_capped_so_a_forged_header_cannot_oom() {
|
||||
// installer jar 没有校验和:header 可以声称 16 EiB 的解压尺寸。
|
||||
assert_eq!(prealloc_hint(u64::MAX), PREALLOC_MAX as usize);
|
||||
assert_eq!(prealloc_hint(1 << 40), PREALLOC_MAX as usize);
|
||||
// 合法条目照旧按实际尺寸预分配(封顶不影响常见大小)。
|
||||
assert_eq!(prealloc_hint(0), 0);
|
||||
assert_eq!(prealloc_hint(4096), 4096);
|
||||
assert_eq!(prealloc_hint(PREALLOC_MAX), PREALLOC_MAX as usize);
|
||||
assert_eq!(prealloc_hint(PREALLOC_MAX - 1), PREALLOC_MAX as usize - 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_extracts_the_declared_entry_and_copies_the_client_jar() {
|
||||
let root = super::super::tests::temp_root("prepare-extract");
|
||||
let layout = GameLayout::new(&root);
|
||||
let profile: InstallProfile = serde_json::from_str(PROFILE).expect("夹具应可解析");
|
||||
let installer = root.join("cache/installer.jar");
|
||||
write_zip(&installer, "data/client.lzma", b"patch-bytes");
|
||||
let vanilla = root.join("versions/1.20.1/1.20.1.jar");
|
||||
std::fs::create_dir_all(vanilla.parent().expect("有父目录")).expect("建目录");
|
||||
std::fs::write(&vanilla, b"client-jar").expect("写原版 jar");
|
||||
|
||||
let plan = ProcessorPlan::build(ProcessorPlanInput {
|
||||
kind: LoaderKind::Forge,
|
||||
mc_version: "1.20.1",
|
||||
version_id: "1.20.1-forge-47.2.0",
|
||||
profile: &profile,
|
||||
layout: &layout,
|
||||
installer: &installer,
|
||||
scratch: &root.join("scratch"),
|
||||
environment: &Environment::host(),
|
||||
})
|
||||
.expect("计划应能组出来");
|
||||
assert_eq!(plan.variables.embedded().len(), 1);
|
||||
|
||||
prepare(&plan).expect("准备应成功");
|
||||
let dest = &plan.variables.embedded()[0].dest;
|
||||
assert_eq!(std::fs::read(dest).expect("读解包产物"), b"patch-bytes");
|
||||
// processor 拿到的是副本,共享层的原版 jar 内容不受影响。
|
||||
assert_eq!(
|
||||
std::fs::read(&plan.minecraft_jar_copy).expect("读 client jar 副本"),
|
||||
b"client-jar"
|
||||
);
|
||||
assert_ne!(plan.minecraft_jar_copy, plan.minecraft_jar);
|
||||
// 幂等:重跑按大小判定,不重复复制、不报错。
|
||||
prepare(&plan).expect("重跑应成功");
|
||||
}
|
||||
|
||||
/// 写一个只含一条 Stored 条目的 zip(installer 夹具)。
|
||||
fn write_zip(path: &Path, entry: &str, bytes: &[u8]) {
|
||||
use std::io::Write;
|
||||
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).expect("建目录");
|
||||
}
|
||||
let file = std::fs::File::create(path).expect("建 zip");
|
||||
let mut writer = zip::ZipWriter::new(file);
|
||||
let options = zip::write::SimpleFileOptions::default()
|
||||
.compression_method(zip::CompressionMethod::Stored);
|
||||
writer.start_file(entry, options).expect("写条目");
|
||||
writer.write_all(bytes).expect("写内容");
|
||||
writer.finish().expect("收尾 zip");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
//! install profile 的 `processors[]` 与 `data` 表:**纯解析,不动作**。
|
||||
//!
|
||||
//! 字段含义照 Forge installer spec(与 HMCL `ForgeNewInstallProfile.Processor/Datum` 一致):
|
||||
//!
|
||||
//! ```json
|
||||
//! "processors": [{
|
||||
//! "sides": ["client"], // 缺省 = 两侧都跑
|
||||
//! "jar": "net.minecraftforge:installertools:1.3.0", // 可执行 jar(主类在 MANIFEST 里)
|
||||
//! "classpath": ["net.minecraftforge:jarsplitter:1.1.4"], // 依赖 jar(jar 自己不在里面)
|
||||
//! "args": ["--task", "MCP_DATA", "--output", "{MAPPINGS}"],
|
||||
//! "outputs": {"{MAPPINGS}": "ab12…(期望 SHA-1)"}
|
||||
//! }],
|
||||
//! "data": {"MAPPINGS": {"client": "[de.oceanlabs.mcp:mcp_config:1.20.1@zip]", "server": "…"}}
|
||||
//! ```
|
||||
//!
|
||||
//! 三条容易看错的点:
|
||||
//!
|
||||
//! 1. `data` 的每条是**按侧**的(`client` / `server`),client 侧只取 `.client`;
|
||||
//! 2. 官方只写 `jar`,**不写主类**——主类在 jar 的 `META-INF/MANIFEST.MF`(`Main-Class`);
|
||||
//! 少数 fork 会额外给 `mainClass`,所以这里留了口子;
|
||||
//! 3. `classpath` **不含** `jar` 自己(Forge 的约定),拼 `-cp` 时要补上。
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
/// install profile 里的一个 processor。
|
||||
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
|
||||
pub struct Processor {
|
||||
/// 生效侧(`client` / `server`;空表示两侧都要)。
|
||||
#[serde(default)]
|
||||
pub sides: Vec<String>,
|
||||
/// 可执行 jar 的 maven 坐标。
|
||||
#[serde(default)]
|
||||
pub jar: Option<String>,
|
||||
/// 主类覆盖(官方 profile 不写,留给少数 fork)。
|
||||
#[serde(default, rename = "mainClass")]
|
||||
pub main_class: Option<String>,
|
||||
/// 依赖 jar 的 maven 坐标(**不含** `jar` 自己)。
|
||||
#[serde(default)]
|
||||
pub classpath: Vec<String>,
|
||||
/// 命令行参数(可含 `{VAR}` / `[坐标]` / `'字面量'` 三种占位)。
|
||||
#[serde(default)]
|
||||
pub args: Vec<String>,
|
||||
/// 产物表:路径占位 → 期望 SHA-1(既有产物可用于幂等跳过)。
|
||||
#[serde(default)]
|
||||
pub outputs: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl Processor {
|
||||
/// 是否在 client 侧生效。
|
||||
#[must_use]
|
||||
pub fn applies_to_client(&self) -> bool {
|
||||
self.sides.is_empty() || self.sides.iter().any(|side| side == "client")
|
||||
}
|
||||
|
||||
/// `jar` 坐标(去掉首尾空白;缺失或空串返回 `None`)。
|
||||
#[must_use]
|
||||
pub fn jar_coordinate(&self) -> Option<&str> {
|
||||
self.jar
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
/// 主类覆盖(缺失或空串返回 `None`)。
|
||||
#[must_use]
|
||||
pub fn main_class(&self) -> Option<&str> {
|
||||
self.main_class
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
}
|
||||
|
||||
/// `data` 表的一条:按侧给值(client 侧只用 `.client`)。
|
||||
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
|
||||
pub struct Datum {
|
||||
/// client 侧的值(`[坐标]` / `'字面量'` / installer 内的文件路径)。
|
||||
#[serde(default)]
|
||||
pub client: Option<String>,
|
||||
/// server 侧的值(本期不跑 server 安装,只解析不动作)。
|
||||
#[serde(default)]
|
||||
pub server: Option<String>,
|
||||
}
|
||||
|
||||
impl Datum {
|
||||
/// client 侧的值(空串视为没有)。
|
||||
#[must_use]
|
||||
pub fn client_value(&self) -> Option<&str> {
|
||||
self.client
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::Processor;
|
||||
|
||||
#[test]
|
||||
fn sides_empty_means_both_and_client_subset_is_recognised() {
|
||||
let both: Processor = serde_json::from_str(r#"{"jar": "g:a:1"}"#).expect("应可解析");
|
||||
assert!(both.applies_to_client());
|
||||
let server: Processor =
|
||||
serde_json::from_str(r#"{"sides": ["server"], "jar": "g:a:1"}"#).expect("应可解析");
|
||||
assert!(!server.applies_to_client());
|
||||
let client: Processor =
|
||||
serde_json::from_str(r#"{"sides": ["client"], "jar": "g:a:1"}"#).expect("应可解析");
|
||||
assert!(client.applies_to_client());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_jar_and_main_class_are_treated_as_absent() {
|
||||
let processor: Processor =
|
||||
serde_json::from_str(r#"{"jar": " ", "mainClass": ""}"#).expect("应可解析");
|
||||
assert!(processor.jar_coordinate().is_none());
|
||||
assert!(processor.main_class().is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
//! 执行器:幂等跳过(不碰文件系统)、依赖缺失分流、起不来 java 的分类、脱敏器。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::addon::loader::forge::InstallProfile;
|
||||
use crate::addon::loader::processors::{host_redactor, run};
|
||||
use crate::addon::loader::processors::{ProcessorErrorKind, ProcessorPlan, ProcessorPlanInput};
|
||||
use crate::addon::loader::LoaderKind;
|
||||
use crate::game::arguments::Environment;
|
||||
use crate::game::java::{JavaOrigin, JavaRuntime};
|
||||
use crate::game::layout::GameLayout;
|
||||
|
||||
use super::temp_root;
|
||||
|
||||
/// 一个最小 profile:一步 processor,产物是临时目录里的 `built.jar`(只按存在性判定)。
|
||||
const MINIMAL_PROFILE: &str = r#"{
|
||||
"spec": 1,
|
||||
"minecraft": "1.20.1",
|
||||
"json": "/version.json",
|
||||
"processors": [{
|
||||
"jar": "net.minecraftforge:installertools:1.3.0",
|
||||
"classpath": ["net.minecraftforge:jarsplitter:1.1.4"],
|
||||
"args": ["--task", "SPLIT"],
|
||||
"outputs": {"{OUT}": "'not-a-sha1'"}
|
||||
}],
|
||||
"libraries": [],
|
||||
"data": {"OUT": {"client": "'built.jar'"}}
|
||||
}"#;
|
||||
|
||||
fn minimal_profile() -> InstallProfile {
|
||||
serde_json::from_str(MINIMAL_PROFILE).expect("夹具应可解析")
|
||||
}
|
||||
|
||||
/// 组装计划(依赖库不落盘:执行器不负责下载,缺失由用例自己摆)。
|
||||
fn plan(root: &Path) -> ProcessorPlan {
|
||||
let profile = minimal_profile();
|
||||
let layout = GameLayout::new(root);
|
||||
ProcessorPlan::build(ProcessorPlanInput {
|
||||
kind: LoaderKind::Forge,
|
||||
mc_version: "1.20.1",
|
||||
version_id: "1.20.1-forge-47.2.0",
|
||||
profile: &profile,
|
||||
layout: &layout,
|
||||
installer: &root.join("installer.jar"),
|
||||
scratch: &root.join("scratch"),
|
||||
environment: &Environment::host(),
|
||||
})
|
||||
.expect("计划应能组出来")
|
||||
}
|
||||
|
||||
/// 一个「必然起不来」的 java:这些用例只验证分类与顺序,不验证 java 行为。
|
||||
fn missing_java() -> JavaRuntime {
|
||||
JavaRuntime::unknown(
|
||||
PathBuf::from("/nonexistent/ull-test-java"),
|
||||
JavaOrigin::Path,
|
||||
)
|
||||
}
|
||||
|
||||
fn touch(path: &Path) {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).expect("建目录");
|
||||
}
|
||||
std::fs::write(path, b"stub").expect("写夹具文件");
|
||||
}
|
||||
|
||||
/// 写一个最小 jar(可选带 `Main-Class`),给"主类从 MANIFEST 读"的用例用。
|
||||
fn write_jar(path: &Path, main_class: Option<&str>) {
|
||||
use std::io::Write;
|
||||
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).expect("建目录");
|
||||
}
|
||||
let file = std::fs::File::create(path).expect("建 jar");
|
||||
let mut writer = zip::ZipWriter::new(file);
|
||||
let options =
|
||||
zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||
if let Some(main_class) = main_class {
|
||||
writer
|
||||
.start_file("META-INF/MANIFEST.MF", options)
|
||||
.expect("写 manifest 条目");
|
||||
write!(
|
||||
writer,
|
||||
"Manifest-Version: 1.0\r\nMain-Class: {main_class}\r\n\r\n"
|
||||
)
|
||||
.expect("写 manifest");
|
||||
}
|
||||
writer.finish().expect("收尾 jar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ready_outputs_skip_the_whole_step_without_touching_the_disk() {
|
||||
let root = temp_root("exec-idempotent");
|
||||
let plan = plan(&root);
|
||||
// 产物已就绪:原版 client jar 压根不存在也无所谓——幂等快路径不该去复制它。
|
||||
touch(&plan.steps[0].outputs[0].path);
|
||||
|
||||
let run = run(&plan, &missing_java(), None).expect("产物齐备时不该跑任何进程");
|
||||
assert_eq!(run.executed(), 0);
|
||||
assert_eq!(run.skipped(), 1);
|
||||
assert_eq!(run.steps[0].status.as_str(), "skipped");
|
||||
assert_eq!(run.to_json()["steps"][0]["status"], "skipped");
|
||||
// prepare 不该跑:原版 jar 压根不存在、client jar 副本没被复制。
|
||||
assert!(!plan.minecraft_jar_copy.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_minecraft_jar_is_reported_before_any_java_runs() {
|
||||
let root = temp_root("exec-no-vanilla");
|
||||
let plan = plan(&root);
|
||||
let error = run(&plan, &missing_java(), None).expect_err("缺原版 jar 必须报错");
|
||||
assert_eq!(error.kind(), ProcessorErrorKind::Io);
|
||||
assert!(
|
||||
error.message().contains("原版 client jar"),
|
||||
"{}",
|
||||
error.message()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_processor_jar_is_a_library_error_naming_the_file() {
|
||||
let root = temp_root("exec-no-lib");
|
||||
let plan = plan(&root);
|
||||
touch(&plan.minecraft_jar);
|
||||
|
||||
let error = run(&plan, &missing_java(), None).expect_err("缺依赖必须报错");
|
||||
assert_eq!(error.kind(), ProcessorErrorKind::Library);
|
||||
assert!(
|
||||
error.message().contains("installertools-1.3.0.jar"),
|
||||
"{}",
|
||||
error.message()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_java_that_cannot_start_is_an_io_error_with_a_hint() {
|
||||
let root = temp_root("exec-no-java");
|
||||
let plan = plan(&root);
|
||||
touch(&plan.minecraft_jar);
|
||||
// 可执行 jar 得是真的 zip + MANIFEST:主类只从那儿来(profile 没写 mainClass)。
|
||||
write_jar(
|
||||
&plan.steps[0].jar,
|
||||
Some("net.minecraftforge.installertools.Main"),
|
||||
);
|
||||
touch(&plan.steps[0].classpath[0]);
|
||||
|
||||
let error = run(&plan, &missing_java(), None).expect_err("java 起不来必须报错");
|
||||
assert_eq!(error.kind(), ProcessorErrorKind::Io);
|
||||
assert!(
|
||||
error.message().contains("起 java 失败"),
|
||||
"{}",
|
||||
error.message()
|
||||
);
|
||||
assert!(error.hint().is_some());
|
||||
// 走到这一步说明临时目录、MC jar 副本都准备好了(顺序:prepare → 起进程)。
|
||||
assert!(plan.minecraft_jar_copy.is_file());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_jar_without_a_main_class_is_a_plan_error() {
|
||||
let root = temp_root("exec-no-main");
|
||||
let plan = plan(&root);
|
||||
touch(&plan.minecraft_jar);
|
||||
write_jar(&plan.steps[0].jar, None);
|
||||
touch(&plan.steps[0].classpath[0]);
|
||||
|
||||
let error = run(&plan, &missing_java(), None).expect_err("拿不到主类必须报错");
|
||||
assert_eq!(error.kind(), ProcessorErrorKind::Plan);
|
||||
assert!(error.message().contains("主类"), "{}", error.message());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_redactor_masks_the_user_home_directory() {
|
||||
let redactor = host_redactor();
|
||||
let home = std::env::var_os("USERPROFILE").or_else(|| std::env::var_os("HOME"));
|
||||
let Some(home) = home else {
|
||||
return; // 极简环境(无 HOME/USERPROFILE)时跳过
|
||||
};
|
||||
let text = format!("{}/game/libraries/x.jar", home.to_string_lossy());
|
||||
let scrubbed = redactor.scrub(&text);
|
||||
assert!(scrubbed.contains("<home>"), "{scrubbed}");
|
||||
assert!(
|
||||
!scrubbed.contains(&home.to_string_lossy().to_string()),
|
||||
"{scrubbed}"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
//! `addon::loader::processors` 的单元测试:占位替换 / 计划形状 / 执行与失败分流 / 幂等跳过。
|
||||
//!
|
||||
//! 刻意**全部离线**:installer 用夹具 JSON 描述,依赖库的"在不在盘上"由测试自己摆,
|
||||
//! 真正外调 java 的用例只走「起不来」这一条确定性分支(假 java 路径不存在),
|
||||
//! 真机执行由验收(Forge 1.20.1 / NeoForge 21.1.80)覆盖。
|
||||
|
||||
mod exec;
|
||||
mod plan;
|
||||
mod vars;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use crate::addon::loader::forge::InstallProfile;
|
||||
|
||||
/// 实测 Forge 1.20.1-47.2.0 的形状(裁剪到 3 个 processor:2 个 client 侧 + 1 个仅 server)。
|
||||
pub(crate) const FORGE_PROFILE: &str = r#"{
|
||||
"spec": 1,
|
||||
"profile": "forge",
|
||||
"version": "1.20.1-forge-47.2.0",
|
||||
"minecraft": "1.20.1",
|
||||
"json": "/version.json",
|
||||
"processors": [
|
||||
{
|
||||
"sides": ["client"],
|
||||
"jar": "net.minecraftforge:installertools:1.3.0",
|
||||
"classpath": ["net.minecraftforge:jarsplitter:1.1.4"],
|
||||
"args": ["--task", "MCP_DATA", "--input", "{MAPPINGS}", "--output", "{MC_SLIM}", "--side", "{SIDE}"],
|
||||
"outputs": {"{MC_SLIM}": "aa11bb22cc33dd44ee55ff660011223344556677"}
|
||||
},
|
||||
{
|
||||
"sides": ["server"],
|
||||
"jar": "net.minecraftforge:installertools:1.3.0",
|
||||
"classpath": [],
|
||||
"args": ["--task", "SERVER"],
|
||||
"outputs": {}
|
||||
},
|
||||
{
|
||||
"jar": "net.minecraftforge:jarsplitter:1.1.4",
|
||||
"classpath": ["net.minecraftforge:installertools:1.3.0"],
|
||||
"args": ["--input", "{MINECRAFT_JAR}", "--output", "{MC_EXTRA}", "--singleThreaded"],
|
||||
"outputs": {"{MC_EXTRA}": "'not-a-sha1'"}
|
||||
}
|
||||
],
|
||||
"libraries": [
|
||||
{"name": "net.minecraftforge:installertools:1.3.0", "url": "https://maven.minecraftforge.net/"},
|
||||
{"name": "net.minecraftforge:jarsplitter:1.1.4", "url": "https://maven.minecraftforge.net/"}
|
||||
],
|
||||
"data": {
|
||||
"MAPPINGS": {"client": "[de.oceanlabs.mcp:mcp_config:1.20.1-20230612.114412@zip]", "server": "[de.oceanlabs.mcp:mcp_config:1.20.1-20230612.114412@zip]"},
|
||||
"MC_SLIM": {"client": "[net.minecraft:client:1.20.1-20230612.114412:slim]", "server": "[net.minecraft:server:1.20.1-20230612.114412:slim]"},
|
||||
"MC_EXTRA": {"client": "[net.minecraft:client:1.20.1-20230612.114412:extra]", "server": "[net.minecraft:server:1.20.1-20230612.114412:extra]"},
|
||||
"BINPATCH": {"client": "/data/client.lzma", "server": "/data/server.lzma"},
|
||||
"SERVER_ONLY": {"server": "'server'"}
|
||||
}
|
||||
}"#;
|
||||
|
||||
/// 夹具 profile。
|
||||
pub(crate) fn profile() -> InstallProfile {
|
||||
serde_json::from_str(FORGE_PROFILE).expect("夹具 install profile 应可解析")
|
||||
}
|
||||
|
||||
/// 一次性临时目录(用例结束不清理,落在系统 temp 下)。
|
||||
pub(crate) fn temp_root(tag: &str) -> PathBuf {
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
std::env::temp_dir().join(format!(
|
||||
"ull-processors-{}-{tag}-{unique}",
|
||||
std::process::id()
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
//! 执行计划:client 侧过滤、参数展开、产物/摘要、依赖库补齐、Java 要求。
|
||||
|
||||
use crate::addon::loader::forge::InstallProfile;
|
||||
use crate::addon::loader::processors::{ProcessorErrorKind, ProcessorPlan, ProcessorPlanInput};
|
||||
use crate::addon::loader::LoaderKind;
|
||||
use crate::game::arguments::Environment;
|
||||
use crate::game::layout::GameLayout;
|
||||
|
||||
use super::{profile, temp_root};
|
||||
|
||||
fn plan_for(root: &std::path::Path, profile: &InstallProfile) -> ProcessorPlan {
|
||||
let layout = GameLayout::new(root);
|
||||
ProcessorPlan::build(ProcessorPlanInput {
|
||||
kind: LoaderKind::Forge,
|
||||
mc_version: "1.20.1",
|
||||
version_id: "1.20.1-forge-47.2.0",
|
||||
profile,
|
||||
layout: &layout,
|
||||
installer: &root.join("cache/forge-installer.jar"),
|
||||
scratch: &root.join("cache/processors"),
|
||||
environment: &Environment::host(),
|
||||
})
|
||||
.expect("计划应能组出来")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_client_side_processors_become_steps_in_declared_order() {
|
||||
let root = temp_root("plan-order");
|
||||
let plan = plan_for(&root, &profile());
|
||||
|
||||
assert_eq!(plan.step_count(), 2, "server-only 的 processor 必须被过滤");
|
||||
assert_eq!(plan.steps[0].index, 1);
|
||||
assert_eq!(
|
||||
plan.steps[0].label,
|
||||
"net.minecraftforge:installertools:1.3.0"
|
||||
);
|
||||
assert_eq!(plan.steps[1].index, 2);
|
||||
assert_eq!(plan.steps[1].label, "net.minecraftforge:jarsplitter:1.1.4");
|
||||
assert_eq!(plan.steps[1].classpath.len(), 1);
|
||||
assert!(!plan.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn args_are_expanded_with_paths_and_the_client_side() {
|
||||
let root = temp_root("plan-args");
|
||||
let plan = plan_for(&root, &profile());
|
||||
let args = &plan.steps[0].args;
|
||||
|
||||
assert_eq!(args[0], "--task");
|
||||
assert_eq!(args[1], "MCP_DATA");
|
||||
assert!(
|
||||
args[3].ends_with("mcp_config-1.20.1-20230612.114412.zip"),
|
||||
"输入构件应展开成 libraries 路径:{}",
|
||||
args[3]
|
||||
);
|
||||
assert!(
|
||||
args[5].ends_with("client-1.20.1-20230612.114412-slim.jar"),
|
||||
"产物路径应按 data 表展开:{}",
|
||||
args[5]
|
||||
);
|
||||
assert_eq!(args[7], "client");
|
||||
// 第二步用的是临时目录里的 client jar 副本,不是共享层的原版 jar。
|
||||
assert_eq!(
|
||||
plan.steps[1].args[1],
|
||||
plan.minecraft_jar_copy.to_string_lossy()
|
||||
);
|
||||
assert!(plan
|
||||
.minecraft_jar_copy
|
||||
.to_string_lossy()
|
||||
.ends_with("minecraft.jar"));
|
||||
assert!(!plan.minecraft_jar_copy.starts_with(root.join("versions")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outputs_carry_paths_and_only_real_sha1s() {
|
||||
let root = temp_root("plan-outputs");
|
||||
let plan = plan_for(&root, &profile());
|
||||
|
||||
let first = &plan.steps[0].outputs;
|
||||
assert_eq!(first.len(), 1);
|
||||
assert_eq!(
|
||||
first[0].sha1.as_deref(),
|
||||
Some("aa11bb22cc33dd44ee55ff660011223344556677")
|
||||
);
|
||||
assert!(first[0].path.to_string_lossy().ends_with("-slim.jar"));
|
||||
// 非 SHA-1 的 values 只算"存在性校验",并留一条告警。
|
||||
assert!(plan.steps[1].outputs[0].sha1.is_none());
|
||||
assert!(
|
||||
plan.warnings
|
||||
.iter()
|
||||
.any(|warning| warning.contains("没给 SHA-1")),
|
||||
"{:?}",
|
||||
plan.warnings
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn processor_dependencies_are_planned_once_even_when_libraries_omit_them() {
|
||||
let root = temp_root("plan-libs");
|
||||
let lean = serde_json::from_str(
|
||||
r#"{"spec": 1, "minecraft": "1.20.1", "json": "/version.json",
|
||||
"processors": [{"jar": "net.minecraftforge:installertools:1.3.0",
|
||||
"classpath": ["net.minecraftforge:jarsplitter:1.1.4"],
|
||||
"args": [], "outputs": {}}],
|
||||
"libraries": [],
|
||||
"data": {}}"#,
|
||||
)
|
||||
.expect("夹具应可解析");
|
||||
let layout = GameLayout::new(&root);
|
||||
let plan = ProcessorPlan::build(ProcessorPlanInput {
|
||||
kind: LoaderKind::NeoForge,
|
||||
mc_version: "1.20.1",
|
||||
version_id: "1.20.1-neoforge-21.1.80",
|
||||
profile: &lean,
|
||||
layout: &layout,
|
||||
installer: &root.join("installer.jar"),
|
||||
scratch: &root.join("tmp"),
|
||||
environment: &Environment::host(),
|
||||
})
|
||||
.expect("计划应能组出来");
|
||||
|
||||
assert_eq!(plan.libraries.len(), 2, "jar 与 classpath 各补一条");
|
||||
for file in &plan.libraries {
|
||||
assert!(
|
||||
file.url.contains("neoforged") || file.url.contains("minecraftforge"),
|
||||
"补库应落在加载器的 maven 上:{}",
|
||||
file.url
|
||||
);
|
||||
assert!(file.dest.starts_with(root.join("libraries")));
|
||||
}
|
||||
assert!(plan
|
||||
.warnings
|
||||
.iter()
|
||||
.any(|warning| warning.contains("不在 install profile 的 libraries 节里")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn java_requirement_follows_the_target_minecraft_version() {
|
||||
let root = temp_root("plan-java");
|
||||
let plan = plan_for(&root, &profile());
|
||||
assert_eq!(plan.java_requirement.minimum, 17);
|
||||
assert_eq!(plan.java_requirement.maximum, None);
|
||||
assert_eq!(plan.java_requirement.describe(), "Java 17+");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_view_lists_every_step_for_dry_run() {
|
||||
let root = temp_root("plan-json");
|
||||
let plan = plan_for(&root, &profile());
|
||||
let json = plan.to_json();
|
||||
|
||||
assert_eq!(json["count"], 2);
|
||||
assert_eq!(json["java_requirement"]["describe"], "Java 17+");
|
||||
assert_eq!(
|
||||
json["steps"][0]["jar"],
|
||||
"net.minecraftforge:installertools:1.3.0"
|
||||
);
|
||||
assert_eq!(json["steps"][0]["classpath"], 1);
|
||||
assert_eq!(
|
||||
json["steps"][1]["outputs"][0]["sha1"],
|
||||
serde_json::Value::Null
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicated_library_declarations_are_deduped_by_destination() {
|
||||
// NeoForge 21.1.80 实测:install profile 把 cli-utils / srgutils 等声明了 2~3 次。
|
||||
// 同一落点下两次会在同一个 `.part` 中间文件上互相抢(真机 os error 2),必须去重。
|
||||
let root = temp_root("plan-dedup");
|
||||
let duplicated: InstallProfile = serde_json::from_str(
|
||||
r#"{"spec": 1, "minecraft": "1.20.1", "json": "/version.json",
|
||||
"processors": [{"jar": "net.neoforged.installertools:cli-utils:2.1.2@jar"}],
|
||||
"libraries": [
|
||||
{"name": "net.neoforged.installertools:cli-utils:2.1.2@jar", "url": "https://maven.neoforged.net/releases/"},
|
||||
{"name": "net.neoforged.installertools:cli-utils:2.1.2@jar", "url": "https://maven.neoforged.net/releases/"},
|
||||
{"name": "net.neoforged.installertools:cli-utils:2.1.2@jar", "url": "https://maven.neoforged.net/releases/"}
|
||||
],
|
||||
"data": {}}"#,
|
||||
)
|
||||
.expect("夹具应可解析");
|
||||
let layout = GameLayout::new(&root);
|
||||
let plan = ProcessorPlan::build(ProcessorPlanInput {
|
||||
kind: LoaderKind::NeoForge,
|
||||
mc_version: "1.20.1",
|
||||
version_id: "1.20.1-neoforge-21.1.80",
|
||||
profile: &duplicated,
|
||||
layout: &layout,
|
||||
installer: &root.join("installer.jar"),
|
||||
scratch: &root.join("tmp"),
|
||||
environment: &Environment::host(),
|
||||
})
|
||||
.expect("计划应能组出来");
|
||||
|
||||
assert_eq!(plan.libraries.len(), 1, "同一落点只应留一条");
|
||||
assert!(
|
||||
plan.warnings.iter().any(|warning| warning.contains("去重")),
|
||||
"{:?}",
|
||||
plan.warnings
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn processor_without_jar_is_a_plan_error() {
|
||||
let root = temp_root("plan-nojar");
|
||||
let broken: InstallProfile = serde_json::from_str(
|
||||
r#"{"spec": 1, "minecraft": "1.20.1", "json": "/version.json",
|
||||
"processors": [{"args": ["--task", "X"]}], "libraries": [], "data": {}}"#,
|
||||
)
|
||||
.expect("夹具应可解析");
|
||||
let layout = GameLayout::new(&root);
|
||||
let error = ProcessorPlan::build(ProcessorPlanInput {
|
||||
kind: LoaderKind::Forge,
|
||||
mc_version: "1.20.1",
|
||||
version_id: "1.20.1-forge-47.2.0",
|
||||
profile: &broken,
|
||||
layout: &layout,
|
||||
installer: &root.join("installer.jar"),
|
||||
scratch: &root.join("tmp"),
|
||||
environment: &Environment::host(),
|
||||
})
|
||||
.expect_err("缺 jar 必须报错");
|
||||
assert_eq!(error.kind(), ProcessorErrorKind::Plan);
|
||||
assert!(error.message().contains("jar"), "{}", error.message());
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
//! 变量表:三种字面量、标准变量、token 替换的边界。
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::addon::loader::processors::spec::Datum;
|
||||
use crate::addon::loader::processors::vars::{Variables, VariablesInput};
|
||||
use crate::addon::loader::processors::ProcessorErrorKind;
|
||||
use crate::game::layout::GameLayout;
|
||||
|
||||
use super::{profile, temp_root};
|
||||
|
||||
fn data(entries: &[(&str, &str)]) -> BTreeMap<String, Datum> {
|
||||
entries
|
||||
.iter()
|
||||
.map(|(key, value)| {
|
||||
(
|
||||
(*key).to_string(),
|
||||
Datum {
|
||||
client: Some((*value).to_string()),
|
||||
server: None,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn build<'a>(
|
||||
layout: &'a GameLayout,
|
||||
data: &'a BTreeMap<String, Datum>,
|
||||
installer: &'a Path,
|
||||
scratch: &'a Path,
|
||||
) -> Variables {
|
||||
Variables::build(VariablesInput {
|
||||
data,
|
||||
layout,
|
||||
mc_version: "1.20.1",
|
||||
installer,
|
||||
scratch,
|
||||
})
|
||||
.expect("变量表应能组装")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artifact_literals_resolve_into_the_libraries_directory() {
|
||||
let root = temp_root("vars-artifact");
|
||||
let layout = GameLayout::new(&root);
|
||||
let installer = root.join("installer.jar");
|
||||
let scratch = root.join("tmp");
|
||||
let table = data(&[("MAPPINGS", "[de.oceanlabs.mcp:mcp_config:1.20.1@zip]")]);
|
||||
let variables = build(&layout, &table, &installer, &scratch);
|
||||
|
||||
let value = variables.get("MAPPINGS").expect("应有 MAPPINGS");
|
||||
assert!(
|
||||
Path::new(value).ends_with("de/oceanlabs/mcp/mcp_config/1.20.1/mcp_config-1.20.1.zip"),
|
||||
"{value}"
|
||||
);
|
||||
assert!(Path::new(value).is_absolute(), "{value}");
|
||||
assert!(
|
||||
Path::new(value).starts_with(layout.libraries_dir()),
|
||||
"{value}"
|
||||
);
|
||||
|
||||
// 官方 profile 也把坐标直接写在 `args` 里(`--input [de.oceanlabs.mcp:…@zip]`),
|
||||
// 整段是坐标时同样要展开成路径。
|
||||
let arg = variables
|
||||
.expand("[de.oceanlabs.mcp:mcp_config:1.20.1@zip]")
|
||||
.expect("整段坐标应能展开");
|
||||
assert_eq!(arg, value);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quoted_literals_lose_the_quotes() {
|
||||
let root = temp_root("vars-literal");
|
||||
let layout = GameLayout::new(&root);
|
||||
let table = data(&[("SIDE_HINT", "'client'")]);
|
||||
let variables = build(&layout, &table, &root.join("i.jar"), &root.join("tmp"));
|
||||
assert_eq!(variables.get("SIDE_HINT"), Some("client"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_paths_are_extracted_from_the_installer_into_scratch() {
|
||||
let root = temp_root("vars-embedded");
|
||||
let layout = GameLayout::new(&root);
|
||||
let installer = root.join("installer.jar");
|
||||
let scratch = root.join("tmp");
|
||||
let table = data(&[("BINPATCH", "/data/client.lzma")]);
|
||||
let variables = build(&layout, &table, &installer, &scratch);
|
||||
|
||||
let value = variables.get("BINPATCH").expect("应有 BINPATCH");
|
||||
assert!(value.ends_with("data-client.lzma"), "{value}");
|
||||
assert!(
|
||||
value.starts_with(&scratch.to_string_lossy().to_string()),
|
||||
"{value}"
|
||||
);
|
||||
assert_eq!(variables.embedded().len(), 1);
|
||||
assert_eq!(variables.embedded()[0].entry, "data/client.lzma");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_only_entries_are_ignored_for_client_installs() {
|
||||
let root = temp_root("vars-server");
|
||||
let layout = GameLayout::new(&root);
|
||||
let variables = build(
|
||||
&layout,
|
||||
&profile().data,
|
||||
&root.join("i.jar"),
|
||||
&root.join("tmp"),
|
||||
);
|
||||
assert!(variables.get("SERVER_ONLY").is_none());
|
||||
assert!(variables.get("MAPPINGS").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standard_variables_are_absolute_and_side_is_client() {
|
||||
let root = temp_root("vars-standard");
|
||||
let layout = GameLayout::new(&root);
|
||||
let installer = root.join("installer.jar");
|
||||
let scratch = root.join("tmp");
|
||||
let variables = build(&layout, &BTreeMap::new(), &installer, &scratch);
|
||||
|
||||
assert_eq!(variables.get("SIDE"), Some("client"));
|
||||
assert_eq!(variables.get("MINECRAFT_VERSION"), Some("1.20.1"));
|
||||
for key in ["MINECRAFT_JAR", "ROOT", "INSTALLER", "LIBRARY_DIR"] {
|
||||
let value = variables.get(key).unwrap_or_else(|| panic!("缺 {key}"));
|
||||
assert!(
|
||||
Path::new(value).is_absolute(),
|
||||
"{key} 不是绝对路径:{value}"
|
||||
);
|
||||
}
|
||||
assert!(variables
|
||||
.get("MINECRAFT_JAR")
|
||||
.unwrap_or_default()
|
||||
.ends_with("minecraft.jar"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_replacement_handles_quotes_and_escapes() {
|
||||
let root = temp_root("vars-expand");
|
||||
let layout = GameLayout::new(&root);
|
||||
let variables = build(
|
||||
&layout,
|
||||
&BTreeMap::new(),
|
||||
&root.join("i.jar"),
|
||||
&root.join("tmp"),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
variables.expand("--side '{SIDE}'").expect("应能展开"),
|
||||
"--side {SIDE}"
|
||||
);
|
||||
assert_eq!(variables.expand("a\\{b").expect("应能展开"), "a{b");
|
||||
assert_eq!(
|
||||
variables.expand("--side {SIDE}").expect("应能展开"),
|
||||
"--side client"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_or_unclosed_placeholders_are_plan_errors() {
|
||||
let root = temp_root("vars-error");
|
||||
let layout = GameLayout::new(&root);
|
||||
let variables = build(
|
||||
&layout,
|
||||
&BTreeMap::new(),
|
||||
&root.join("i.jar"),
|
||||
&root.join("tmp"),
|
||||
);
|
||||
|
||||
let missing = variables
|
||||
.expand("--output {NOPE}")
|
||||
.expect_err("缺变量必须报错");
|
||||
assert_eq!(missing.kind(), ProcessorErrorKind::Plan);
|
||||
assert!(missing.message().contains("NOPE"), "{}", missing.message());
|
||||
|
||||
let unclosed = variables.expand("{SIDE").expect_err("未闭合必须报错");
|
||||
assert_eq!(unclosed.kind(), ProcessorErrorKind::Plan);
|
||||
|
||||
let bad_escape = variables.expand("abc\\").expect_err("悬空转义必须报错");
|
||||
assert_eq!(bad_escape.kind(), ProcessorErrorKind::Plan);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
//! 进程输出的尾部截取与脱敏。
|
||||
//!
|
||||
//! processor 的 stdout/stderr 是外部程序(而且部分步骤要联网)写的,内容不可控:
|
||||
//! 可能带绝对路径、用户名、甚至一大段堆栈。进错误消息/`--json` 前必须做两件事:
|
||||
//! **只留尾巴**(够定位、不灌满日志)与**抹掉主目录**(`C:\Users\<名>` → `<home>`)。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::launch::redact::Redactor;
|
||||
|
||||
/// 尾巴保留的行数(够看异常栈头)。
|
||||
const TAIL_LINES: usize = 12;
|
||||
/// 尾巴保留的字符上限。
|
||||
const TAIL_CHARS: usize = 1200;
|
||||
|
||||
/// 宿主脱敏器:把用户主目录换成 `<home>`。
|
||||
///
|
||||
/// 复用 [`crate::launch::redact::Redactor`](与 M15/M31 落地的同一套规则:token、
|
||||
/// `<home>`、长不透明串、带点 token/JWT)。注册 `USERPROFILE`(Windows)/`HOME`(macOS、Linux)
|
||||
/// 两个来源,因为 MSYS/CI 环境下两者可能只有一个在场。
|
||||
#[must_use]
|
||||
pub fn host_redactor() -> Redactor {
|
||||
let mut redactor = Redactor::new();
|
||||
for key in ["USERPROFILE", "HOME"] {
|
||||
if let Some(value) = std::env::var_os(key) {
|
||||
redactor = redactor.with_home(PathBuf::from(value));
|
||||
}
|
||||
}
|
||||
redactor
|
||||
}
|
||||
|
||||
/// 取文本尾部(末 [`TAIL_LINES`] 行,再截到 [`TAIL_CHARS`] 字符)。
|
||||
#[must_use]
|
||||
pub fn tail(text: &str) -> String {
|
||||
let trimmed = text.trim_end();
|
||||
if trimmed.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let lines: Vec<&str> = trimmed.lines().collect();
|
||||
let start = lines.len().saturating_sub(TAIL_LINES);
|
||||
let mut joined = lines[start..].join("\n");
|
||||
let length = joined.chars().count();
|
||||
if length > TAIL_CHARS {
|
||||
joined = joined.chars().skip(length - TAIL_CHARS).collect();
|
||||
joined.insert(0, '…');
|
||||
}
|
||||
joined
|
||||
}
|
||||
|
||||
/// 空串 → `None`(JSON 里不吐空字段)。
|
||||
pub(super) fn non_empty(value: String) -> Option<String> {
|
||||
(!value.is_empty()).then_some(value)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::tail;
|
||||
|
||||
#[test]
|
||||
fn tail_keeps_the_last_lines_and_truncates_long_ones() {
|
||||
let text: String = (1..=40).map(|index| format!("line {index}\n")).collect();
|
||||
let tailed = tail(&text);
|
||||
assert!(tailed.starts_with("line 29"), "{tailed}");
|
||||
assert!(tailed.ends_with("line 40"));
|
||||
let long = "x".repeat(5000);
|
||||
let tailed = tail(&long);
|
||||
assert!(tailed.starts_with('…'));
|
||||
assert_eq!(tailed.chars().count(), 1201);
|
||||
assert_eq!(tail(" \n"), "");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
//! 占位替换:install profile 的 `data` 表 + 标准变量 → 一张 `{键 → 值}` 表。
|
||||
//!
|
||||
//! 这是 Forge processor 协议里最容易出错的一层,三种字面量语义(照 installer spec,
|
||||
//! 与 HMCL `ForgeNewInstallTask.parseLiteral` 同构):
|
||||
//!
|
||||
//! | 写法 | 含义 | 例 |
|
||||
//! |------|------|----|
|
||||
//! | `[group:artifact:version(:classifier)(@ext)]` | maven 构件 → `libraries/` 下的绝对路径 | `[de.oceanlabs.mcp:mcp_config:1.20.1@zip]` |
|
||||
//! | `'literal'` | 去掉引号的常量 | `'client'` |
|
||||
//! | 其它 | **installer 里的文件路径** → 解包到临时目录后的绝对路径 | `/data/client.lzma` |
|
||||
//!
|
||||
//! 展开(`{KEY}`)的规则与 HMCL 的 `replaceTokens` 一致:`\` 转义下一个字符、
|
||||
//! `'…'` 原样输出、`{KEY}` 查表替换(**查不到就报错**,不静默留空——留空会让
|
||||
//! processor 拿着半截参数跑出更难查的错)。
|
||||
//!
|
||||
//! # 为什么 `MINECRAFT_JAR` 指向临时目录的副本
|
||||
//!
|
||||
//! HMCL 在起 processor 前把原版 client jar **复制到临时目录**再用,注释写得很直白:
|
||||
//! `External processors must not receive the shared cache path`。processor 会改写输入的
|
||||
//! jar(拆包/打补丁),直接把 `versions/<mc>/<mc>.jar` 递过去等于让外挂程序改共享层。
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::error::ProcessorError;
|
||||
use super::spec::Datum;
|
||||
use crate::game::layout::GameLayout;
|
||||
use crate::game::library::MavenCoordinate;
|
||||
|
||||
/// 临时目录里那份原版 client jar 的文件名。
|
||||
pub const MINECRAFT_JAR_NAME: &str = "minecraft.jar";
|
||||
|
||||
/// 一个需要从 installer zip 里解出来的文件。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct EmbeddedFile {
|
||||
/// installer 内的条目路径(`data/client.lzma`)。
|
||||
pub entry: String,
|
||||
/// 解包落点(临时目录下的扁平文件名)。
|
||||
pub dest: PathBuf,
|
||||
}
|
||||
|
||||
/// 变量表的构造输入。
|
||||
pub struct VariablesInput<'a> {
|
||||
/// install profile 的 `data` 表。
|
||||
pub data: &'a BTreeMap<String, Datum>,
|
||||
/// 游戏目录布局(`libraries/` 与 `versions/` 的路径规则)。
|
||||
pub layout: &'a GameLayout,
|
||||
/// 目标原版版本(`net.minecraft:client:<ver>` 这类坐标回落用)。
|
||||
pub mc_version: &'a str,
|
||||
/// installer jar 的磁盘路径(`{INSTALLER}`,也是解包来源)。
|
||||
pub installer: &'a Path,
|
||||
/// 临时目录(`{MINECRAFT_JAR}` 与解包文件都落这里)。
|
||||
pub scratch: &'a Path,
|
||||
}
|
||||
|
||||
/// 展开后的变量表。
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Variables {
|
||||
values: BTreeMap<String, String>,
|
||||
embedded: Vec<EmbeddedFile>,
|
||||
/// 参数里 `[坐标]` 的解析上下文(官方 profile 会在 `args` 里直接写坐标,不只写在 `data` 里)。
|
||||
artifacts: Option<ArtifactRoots>,
|
||||
}
|
||||
|
||||
/// `[坐标]` 的解析上下文。
|
||||
#[derive(Debug, Clone)]
|
||||
struct ArtifactRoots {
|
||||
libraries: PathBuf,
|
||||
vanilla_jar: PathBuf,
|
||||
}
|
||||
|
||||
impl Variables {
|
||||
/// 组装变量表:先展开 `data` 表,再注入标准变量。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 构件坐标不合法、`data` 表里的值是空路径、变量引用缺定义时返回
|
||||
/// [`ProcessorErrorKind::Plan`](super::error::ProcessorErrorKind::Plan) 错误。
|
||||
pub fn build(input: VariablesInput<'_>) -> Result<Self, ProcessorError> {
|
||||
let mut variables = Self {
|
||||
artifacts: Some(ArtifactRoots {
|
||||
libraries: input.layout.libraries_dir(),
|
||||
vanilla_jar: input.layout.version_jar(input.mc_version),
|
||||
}),
|
||||
..Self::default()
|
||||
};
|
||||
for (key, datum) in input.data {
|
||||
let Some(raw) = datum.client_value() else {
|
||||
continue;
|
||||
};
|
||||
let value = variables.resolve_literal(raw, &input)?;
|
||||
variables.values.insert(key.clone(), value);
|
||||
}
|
||||
variables.inject_standard(&input);
|
||||
Ok(variables)
|
||||
}
|
||||
|
||||
/// 标准变量(installer spec 没有写死,但各家启动器都注入同一套)。
|
||||
fn inject_standard(&mut self, input: &VariablesInput<'_>) {
|
||||
let scratch = absolute(input.scratch);
|
||||
let standard = [
|
||||
("SIDE", "client".to_string()),
|
||||
("MINECRAFT_VERSION", input.mc_version.to_string()),
|
||||
(
|
||||
"MINECRAFT_JAR",
|
||||
absolute(&scratch.join(MINECRAFT_JAR_NAME))
|
||||
.to_string_lossy()
|
||||
.into_owned(),
|
||||
),
|
||||
(
|
||||
"ROOT",
|
||||
absolute(input.layout.root()).to_string_lossy().into_owned(),
|
||||
),
|
||||
(
|
||||
"INSTALLER",
|
||||
absolute(input.installer).to_string_lossy().into_owned(),
|
||||
),
|
||||
(
|
||||
"LIBRARY_DIR",
|
||||
absolute(&input.layout.libraries_dir())
|
||||
.to_string_lossy()
|
||||
.into_owned(),
|
||||
),
|
||||
];
|
||||
for (key, value) in standard {
|
||||
self.values.insert(key.to_string(), value);
|
||||
}
|
||||
}
|
||||
|
||||
/// 把一个 `data` 表的值展开成实际字符串。
|
||||
fn resolve_literal(
|
||||
&mut self,
|
||||
raw: &str,
|
||||
input: &VariablesInput<'_>,
|
||||
) -> Result<String, ProcessorError> {
|
||||
let trimmed = raw.trim();
|
||||
if let Some(inner) = trim_brackets(trimmed) {
|
||||
return self.resolve_artifact(inner);
|
||||
}
|
||||
if let Some(inner) = trimmed
|
||||
.strip_prefix('\'')
|
||||
.and_then(|rest| rest.strip_suffix('\''))
|
||||
{
|
||||
return Ok(inner.to_string());
|
||||
}
|
||||
if trimmed.starts_with('{') {
|
||||
// data 表自己引用别的键:官方 profile 不这么写,但展开一次成本为零。
|
||||
return self.expand(trimmed);
|
||||
}
|
||||
let entry = trimmed.trim_start_matches('/').to_string();
|
||||
if entry.is_empty() {
|
||||
return Err(ProcessorError::plan(format!(
|
||||
"install profile 的 data 表里有空路径:`{raw}`"
|
||||
)));
|
||||
}
|
||||
// 其余一律当 installer 内的文件:解到临时目录(执行期才真正解包)。
|
||||
let dest = absolute(&absolute(input.scratch).join(embedded_name(&entry)));
|
||||
self.embedded.push(EmbeddedFile {
|
||||
entry,
|
||||
dest: dest.clone(),
|
||||
});
|
||||
Ok(dest.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
/// `[坐标]` → `libraries/` 下的绝对路径。
|
||||
fn resolve_artifact(&self, descriptor: &str) -> Result<String, ProcessorError> {
|
||||
let Some(roots) = &self.artifacts else {
|
||||
return Err(ProcessorError::plan(format!(
|
||||
"构件坐标 `{descriptor}` 需要游戏目录上下文才能解析"
|
||||
)));
|
||||
};
|
||||
artifact_path(&roots.libraries, &roots.vanilla_jar, descriptor)
|
||||
}
|
||||
|
||||
/// 展开一段含占位的文本(`{KEY}` / `'字面量'` / `[坐标]` / `\` 转义)。
|
||||
///
|
||||
/// `[坐标]` 只在**整段就是坐标**时生效(与 HMCL `parseLiteral` 一致):
|
||||
/// official profile 的 `args` 里常直接写 `--input [de.oceanlabs.mcp:…@zip]`。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// `{KEY}` 在表里查不到、`\` 结尾无字符可转义、`{`/`'` 未闭合、坐标不合法时返回规划错误。
|
||||
pub fn expand(&self, pattern: &str) -> Result<String, ProcessorError> {
|
||||
let trimmed = pattern.trim();
|
||||
if let Some(inner) = trim_brackets(trimmed) {
|
||||
return self.resolve_artifact(inner);
|
||||
}
|
||||
let mut out = String::with_capacity(pattern.len());
|
||||
let mut chars = pattern.chars();
|
||||
while let Some(ch) = chars.next() {
|
||||
match ch {
|
||||
'\\' => match chars.next() {
|
||||
Some(next) => out.push(next),
|
||||
None => {
|
||||
return Err(ProcessorError::plan(format!(
|
||||
"占位文本以 `\\` 结尾(无法转义):`{pattern}`"
|
||||
)))
|
||||
}
|
||||
},
|
||||
'{' => {
|
||||
let key = collect_until(&mut chars, '}', pattern)?;
|
||||
let value = self.values.get(&key).ok_or_else(|| {
|
||||
ProcessorError::plan(format!(
|
||||
"占位 {{{key}}} 在 install profile 的 data 表里没有定义(原文 `{pattern}`)"
|
||||
))
|
||||
})?;
|
||||
out.push_str(value);
|
||||
}
|
||||
'\'' => out.push_str(&collect_until(&mut chars, '\'', pattern)?),
|
||||
other => out.push(other),
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// 某个变量的值。
|
||||
#[must_use]
|
||||
pub fn get(&self, key: &str) -> Option<&str> {
|
||||
self.values.get(key).map(String::as_str)
|
||||
}
|
||||
|
||||
/// 需要从 installer 里解包的文件(执行期用)。
|
||||
#[must_use]
|
||||
pub fn embedded(&self) -> &[EmbeddedFile] {
|
||||
&self.embedded
|
||||
}
|
||||
|
||||
/// 变量条数(`--dry-run` 只报数量,不吐全部路径)。
|
||||
#[must_use]
|
||||
pub fn len(&self) -> usize {
|
||||
self.values.len()
|
||||
}
|
||||
|
||||
/// 是否一条变量都没有(实际不可能:标准变量恒在)。
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.values.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// 读到 `closing` 为止(跳过 `\` 转义),返回中间文本。
|
||||
fn collect_until(
|
||||
chars: &mut impl Iterator<Item = char>,
|
||||
closing: char,
|
||||
pattern: &str,
|
||||
) -> Result<String, ProcessorError> {
|
||||
let mut out = String::new();
|
||||
while let Some(ch) = chars.next() {
|
||||
if ch == '\\' {
|
||||
match chars.next() {
|
||||
Some(next) => out.push(next),
|
||||
None => {
|
||||
return Err(ProcessorError::plan(format!(
|
||||
"占位文本以 `\\` 结尾(无法转义):`{pattern}`"
|
||||
)))
|
||||
}
|
||||
}
|
||||
} else if ch == closing {
|
||||
return Ok(out);
|
||||
} else {
|
||||
out.push(ch);
|
||||
}
|
||||
}
|
||||
Err(ProcessorError::plan(format!(
|
||||
"占位文本缺少收尾的 `{closing}`:`{pattern}`"
|
||||
)))
|
||||
}
|
||||
|
||||
/// 整段是 `[坐标]` 时取出坐标(不到首尾就返回 `None`)。
|
||||
fn trim_brackets(raw: &str) -> Option<&str> {
|
||||
raw.strip_prefix('[')
|
||||
.and_then(|rest| rest.strip_suffix(']'))
|
||||
}
|
||||
|
||||
/// `[坐标]` → `libraries/` 下的绝对路径。
|
||||
///
|
||||
/// 少数坐标进不了 `libraries/`:`net.minecraft:client:<ver>`(无 classifier)对应的是
|
||||
/// `versions/<ver>/<ver>.jar`。官方 installer 会把 client jar 塞进本地 maven 布局,
|
||||
/// 我们不复制(磁盘上原版 jar 只有一份),因此按目标版本回落。
|
||||
fn artifact_path(
|
||||
libraries: &Path,
|
||||
vanilla_jar: &Path,
|
||||
descriptor: &str,
|
||||
) -> Result<String, ProcessorError> {
|
||||
let coordinate = MavenCoordinate::parse(descriptor).map_err(|error| {
|
||||
ProcessorError::plan(format!(
|
||||
"install profile 里的构件坐标不合法:`{descriptor}`({error})"
|
||||
))
|
||||
})?;
|
||||
let path = libraries.join(coordinate.relative_path());
|
||||
if coordinate.group == "net.minecraft"
|
||||
&& coordinate.artifact == "client"
|
||||
&& coordinate.classifier.is_none()
|
||||
&& vanilla_jar.is_file()
|
||||
{
|
||||
return Ok(absolute(vanilla_jar).to_string_lossy().into_owned());
|
||||
}
|
||||
Ok(absolute(&path).to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
/// installer 内的条目 → 临时目录里的扁平文件名(`data/client.lzma` → `data-client.lzma`)。
|
||||
///
|
||||
/// 压平是为了防路径穿越:`../` 与绝对路径在拼进临时目录时都会失去意义。
|
||||
fn embedded_name(entry: &str) -> String {
|
||||
entry
|
||||
.chars()
|
||||
.map(|ch| {
|
||||
if matches!(ch, '/' | '\\' | ':') {
|
||||
'-'
|
||||
} else {
|
||||
ch
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 尽量转绝对路径(相对路径按当前工作目录;失败就原样返回)。
|
||||
#[must_use]
|
||||
pub fn absolute(path: &Path) -> PathBuf {
|
||||
if path.is_absolute() {
|
||||
return path.to_path_buf();
|
||||
}
|
||||
std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf())
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
//! Fabric / Quilt:profile 归一化的形状对拍。
|
||||
|
||||
use super::{FABRIC_PROFILE, QUILT_PROFILE};
|
||||
use crate::addon::loader::fabric::{build_release, parse_loader_list, pick_default};
|
||||
use crate::addon::loader::{LoaderErrorKind, LoaderKind, LoaderSupport};
|
||||
|
||||
#[test]
|
||||
fn fabric_profile_becomes_inheriting_loader_version() {
|
||||
let release = build_release(LoaderKind::Fabric, "1.20.1", "0.15.11", FABRIC_PROFILE)
|
||||
.expect("fabric profile 应可归一化");
|
||||
|
||||
assert_eq!(release.version_id, "1.20.1-fabric-0.15.11");
|
||||
let json = &release.version_json;
|
||||
assert_eq!(json.id.as_deref(), Some("1.20.1-fabric-0.15.11"));
|
||||
assert_eq!(json.inherits_from.as_deref(), Some("1.20.1"));
|
||||
// ★ 关键:jar 必须钉成原版 id,否则继承解析会把 jar 兜底成 loader 自身 id。
|
||||
assert_eq!(json.jar.as_deref(), Some("1.20.1"));
|
||||
assert_eq!(
|
||||
json.main_class.as_deref(),
|
||||
Some("net.fabricmc.loader.impl.launch.knot.KnotClient")
|
||||
);
|
||||
// jvm 参数(`-DFabricMcEmu`)必须原样保留:老版本启动靠它。
|
||||
let jvm = json
|
||||
.arguments
|
||||
.as_ref()
|
||||
.map(|arguments| arguments.jvm.len())
|
||||
.unwrap_or_default();
|
||||
assert_eq!(jvm, 1, "profile 的 jvm 参数丢了");
|
||||
assert_eq!(release.support, LoaderSupport::JsonRoute);
|
||||
assert!(release.is_installable());
|
||||
assert!(release.warnings.is_empty(), "{:?}", release.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fabric_flat_digest_is_lifted_into_artifact() {
|
||||
let release = build_release(LoaderKind::Fabric, "1.20.1", "0.15.11", FABRIC_PROFILE)
|
||||
.expect("fabric profile 应可归一化");
|
||||
let libraries = release.version_json.libraries.expect("应有库");
|
||||
let asm = libraries
|
||||
.iter()
|
||||
.find(|library| library.name.starts_with("org.ow2.asm:asm:"))
|
||||
.expect("应有 asm");
|
||||
let artifact = asm
|
||||
.downloads
|
||||
.as_ref()
|
||||
.and_then(|downloads| downloads.artifact.as_ref())
|
||||
.expect("扁平 sha1/size 应提升成 downloads.artifact");
|
||||
assert_eq!(
|
||||
artifact.sha1.as_deref(),
|
||||
Some("aa205cf0a06dbd8e04ece91c0b37c3f5d567546a")
|
||||
);
|
||||
assert_eq!(artifact.size, Some(123_598));
|
||||
assert_eq!(
|
||||
artifact.url.as_deref(),
|
||||
Some("https://maven.fabricmc.net/org/ow2/asm/asm/9.6/asm-9.6.jar")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quilt_profile_is_isomorphic() {
|
||||
let release = build_release(LoaderKind::Quilt, "1.20.1", "0.20.0-beta.9", QUILT_PROFILE)
|
||||
.expect("quilt profile 应可归一化");
|
||||
|
||||
assert_eq!(release.version_id, "1.20.1-quilt-0.20.0-beta.9");
|
||||
assert_eq!(release.version_json.jar.as_deref(), Some("1.20.1"));
|
||||
assert_eq!(
|
||||
release.version_json.main_class.as_deref(),
|
||||
Some("org.quiltmc.loader.impl.launch.knot.KnotClient")
|
||||
);
|
||||
assert_eq!(release.support, LoaderSupport::JsonRoute);
|
||||
// Quilt 的 profile 没有 loading 库时不该编造;这里有 quilt-loader,因此无告警。
|
||||
assert!(release.warnings.is_empty(), "{:?}", release.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_main_class_is_metadata_error() {
|
||||
let profile = r#"{"id":"x","inheritsFrom":"1.20.1","libraries":[]}"#;
|
||||
let error = build_release(LoaderKind::Fabric, "1.20.1", "0.15.11", profile)
|
||||
.expect_err("缺主类必须报错");
|
||||
assert_eq!(error.kind(), LoaderErrorKind::Metadata);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_loader_library_is_appended_with_warning() {
|
||||
// 去掉 fabric-loader 那条,看归一化会不会补。
|
||||
let profile = r#"{
|
||||
"id": "fabric-loader-0.15.11-1.20.1",
|
||||
"inheritsFrom": "1.20.1",
|
||||
"mainClass": "net.fabricmc.loader.impl.launch.knot.KnotClient",
|
||||
"libraries": [{"name": "org.ow2.asm:asm:9.6", "url": "https://maven.fabricmc.net/"}]
|
||||
}"#;
|
||||
let release = build_release(LoaderKind::Fabric, "1.20.1", "0.15.11", profile)
|
||||
.expect("缺 loader 库应降级为告警 + 补一条");
|
||||
assert_eq!(release.warnings.len(), 1);
|
||||
let libraries = release.version_json.libraries.expect("应有库");
|
||||
assert!(libraries
|
||||
.iter()
|
||||
.any(|library| library.name == "net.fabricmc:fabric-loader:0.15.11"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loader_list_parses_and_prefers_stable() {
|
||||
let list = r#"[
|
||||
{"loader": {"version": "0.16.0-beta.1", "stable": false, "maven": "net.fabricmc:fabric-loader:0.16.0-beta.1"}},
|
||||
{"loader": {"version": "0.15.11", "stable": true, "maven": "net.fabricmc:fabric-loader:0.15.11"}},
|
||||
{"loader": {"version": "0.15.10", "stable": true, "maven": "net.fabricmc:fabric-loader:0.15.10"}}
|
||||
]"#;
|
||||
let entries = parse_loader_list(LoaderKind::Fabric, list).expect("列表应可解析");
|
||||
assert_eq!(entries.len(), 3);
|
||||
assert_eq!(
|
||||
pick_default(&entries).map(|entry| entry.version.as_str()),
|
||||
Some("0.15.11")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quilt_loader_list_without_stable_flag_still_picks_first() {
|
||||
// 实测 Quilt 的 loader 条目没有 stable 键。
|
||||
let list = r#"[
|
||||
{"loader": {"version": "0.20.0-beta.9", "maven": "org.quiltmc:quilt-loader:0.20.0-beta.9"}},
|
||||
{"loader": {"version": "0.19.2", "maven": "org.quiltmc:quilt-loader:0.19.2"}}
|
||||
]"#;
|
||||
let entries = parse_loader_list(LoaderKind::Quilt, list).expect("列表应可解析");
|
||||
assert!(entries.iter().all(|entry| entry.stable));
|
||||
assert_eq!(
|
||||
pick_default(&entries).map(|entry| entry.version.as_str()),
|
||||
Some("0.20.0-beta.9")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_loader_list_is_not_found() {
|
||||
let error = parse_loader_list(LoaderKind::Fabric, "[]").expect_err("空列表应报错");
|
||||
assert_eq!(error.kind(), LoaderErrorKind::NotFound);
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
//! NeoForge / Forge:install profile 解析与三条路线的判定。
|
||||
|
||||
use crate::addon::loader::forge::{build_release, InstallProfile};
|
||||
use crate::addon::loader::{LoaderErrorKind, LoaderKind, LoaderSupport};
|
||||
use crate::game::manifest::VersionJson;
|
||||
|
||||
/// spec 1 + client 侧 processor(实测 NeoForge 21.1.80 / Forge 1.20.1 的形状,裁剪过)。
|
||||
pub(crate) const NEOFORGE_PROFILE: &str = r#"{
|
||||
"spec": 1,
|
||||
"profile": "NeoForge",
|
||||
"version": "neoforge-21.1.80",
|
||||
"minecraft": "1.21.1",
|
||||
"json": "/version.json",
|
||||
"path": null,
|
||||
"processors": [
|
||||
{"sides": ["server"], "jar": "net.neoforged.installertools:installertools:2.1.2"},
|
||||
{"sides": ["client"], "jar": "net.neoforged.installertools:installertools:2.1.2"},
|
||||
{"jar": "net.neoforged.installertools:installertools:2.1.2"},
|
||||
{"sides": ["client"], "jar": "net.neoforged.installertools:installertools:2.1.2"}
|
||||
],
|
||||
"libraries": [
|
||||
{"name": "com.google.code.gson:gson:2.8.9@jar"},
|
||||
{"name": "org.apache.commons:commons-text:1.3@jar"}
|
||||
],
|
||||
"data": {"MC_SLIM": {"client": "[net.neoforged:neoforge:21.1.80:client-slim]"}}
|
||||
}"#;
|
||||
|
||||
/// spec 1 版本 JSON 的裁剪版(内容形状照 21.1.80)。
|
||||
pub(crate) const NEOFORGE_VERSION_JSON: &str = r#"{
|
||||
"id": "neoforge-21.1.80",
|
||||
"inheritsFrom": "1.21.1",
|
||||
"type": "release",
|
||||
"mainClass": "cpw.mods.bootstraplauncher.BootstrapLauncher",
|
||||
"arguments": {"game": ["--launchTarget", "forgeclient"], "jvm": ["-DignoreList=bootstraplauncher"]},
|
||||
"libraries": [
|
||||
{"name": "cpw.mods:securejarhandler:2.1.10",
|
||||
"downloads": {"artifact": {"path": "cpw/mods/securejarhandler/2.1.10/securejarhandler-2.1.10.jar",
|
||||
"url": "https://maven.neoforged.net/releases/cpw/mods/securejarhandler/2.1.10/securejarhandler-2.1.10.jar",
|
||||
"sha1": "51e6a22c6c716beb11e244bf5b8be480f51dd6b5", "size": 88749}}}
|
||||
]
|
||||
}"#;
|
||||
|
||||
/// spec 0 + 无 processors(实测 Forge 1.12.2-14.23.5.2860),universal jar 的 url 是空串。
|
||||
pub(crate) const FORGE_LEGACY_PROFILE: &str = r#"{
|
||||
"spec": 0,
|
||||
"profile": "forge",
|
||||
"version": "1.12.2-forge-14.23.5.2860",
|
||||
"minecraft": "1.12.2",
|
||||
"json": "/version.json",
|
||||
"path": "net.minecraftforge:forge:1.12.2-14.23.5.2860",
|
||||
"processors": [],
|
||||
"libraries": [{"name": "net.minecraftforge:forge:1.12.2-14.23.5.2860"}]
|
||||
}"#;
|
||||
|
||||
pub(crate) const FORGE_LEGACY_VERSION_JSON: &str = r#"{
|
||||
"id": "1.12.2-forge-14.23.5.2860",
|
||||
"inheritsFrom": "1.12.2",
|
||||
"mainClass": "net.minecraft.launchwrapper.Launch",
|
||||
"minecraftArguments": "--username ${auth_player_name} --tweakClass net.minecraftforge.fml.common.launcher.FMLTweaker",
|
||||
"libraries": [
|
||||
{"name": "net.minecraftforge:forge:1.12.2-14.23.5.2860",
|
||||
"downloads": {"artifact": {"path": "net/minecraftforge/forge/1.12.2-14.23.5.2860/forge-1.12.2-14.23.5.2860.jar",
|
||||
"url": "", "sha1": "029250575d3aa2cf80b56dffb66238a1eeaea2ac", "size": 4466148}}}
|
||||
]
|
||||
}"#;
|
||||
|
||||
fn profile(json: &str) -> InstallProfile {
|
||||
serde_json::from_str(json).expect("夹具 profile 应可解析")
|
||||
}
|
||||
|
||||
fn version_json(json: &str) -> VersionJson {
|
||||
VersionJson::from_json_str(json).expect("夹具版本 JSON 应可解析")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_processors_mark_version_as_installer_required() {
|
||||
let release = build_release(
|
||||
LoaderKind::NeoForge,
|
||||
"1.21.1",
|
||||
"21.1.80",
|
||||
&profile(NEOFORGE_PROFILE),
|
||||
Some(&version_json(NEOFORGE_VERSION_JSON)),
|
||||
)
|
||||
.expect("版本 JSON 齐备时应能生成清单");
|
||||
|
||||
// 2 条 sides 含 client + 1 条无 sides(两侧都要)= 3 条 client 侧。
|
||||
assert_eq!(profile(NEOFORGE_PROFILE).client_processors(), 3);
|
||||
// M35 起 RequiresInstaller 是可真装的路线(外调 java 跑 processors),
|
||||
// 但"要跑 installer"这个事实仍要能被上层看见。
|
||||
assert!(release.is_installable());
|
||||
assert!(release.requires_installer());
|
||||
let reason = release
|
||||
.installer_reason()
|
||||
.expect("应给需要 installer 的原因");
|
||||
assert!(reason.contains("3 个 client 侧 processor"), "{reason}");
|
||||
// 版本清单本身仍然正确生成(--dry-run 能看到它)。
|
||||
assert_eq!(release.version_id, "1.21.1-neoforge-21.1.80");
|
||||
assert_eq!(
|
||||
release.version_json.inherits_from.as_deref(),
|
||||
Some("1.21.1")
|
||||
);
|
||||
assert_eq!(release.version_json.jar.as_deref(), Some("1.21.1"));
|
||||
// processor 依赖不进启动 classpath,只记数量进告警。
|
||||
assert_eq!(release.version_json.libraries.map_or(0, |l| l.len()), 1);
|
||||
assert!(release
|
||||
.warnings
|
||||
.iter()
|
||||
.any(|warning| warning.contains("processor 依赖")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spec_zero_profile_is_json_route_and_fills_empty_url() {
|
||||
let release = build_release(
|
||||
LoaderKind::Forge,
|
||||
"1.12.2",
|
||||
"1.12.2-14.23.5.2860",
|
||||
&profile(FORGE_LEGACY_PROFILE),
|
||||
Some(&version_json(FORGE_LEGACY_VERSION_JSON)),
|
||||
)
|
||||
.expect("spec 0 应走 json 路线");
|
||||
|
||||
assert_eq!(release.support, LoaderSupport::JsonRoute);
|
||||
assert_eq!(release.loader_version, "14.23.5.2860");
|
||||
assert_eq!(release.version_id, "1.12.2-forge-14.23.5.2860");
|
||||
assert_eq!(
|
||||
release.version_json.main_class.as_deref(),
|
||||
Some("net.minecraft.launchwrapper.Launch")
|
||||
);
|
||||
|
||||
// 空 url 必须补成 maven 地址:本体 jar 在官方 maven 上**只发布在 -universal classifier 下**
|
||||
// (无 classifier 路径官方 404,仅靠 BMCLAPI 302 别名兜底),所以 URL 要带 -universal。
|
||||
let library = release
|
||||
.version_json
|
||||
.libraries
|
||||
.as_ref()
|
||||
.and_then(|libraries| libraries.first())
|
||||
.expect("应有 forge 本体库");
|
||||
let artifact = library
|
||||
.downloads
|
||||
.as_ref()
|
||||
.and_then(|downloads| downloads.artifact.as_ref())
|
||||
.expect("应有 artifact");
|
||||
assert_eq!(
|
||||
artifact.url.as_deref(),
|
||||
Some(
|
||||
"https://maven.minecraftforge.net/net/minecraftforge/forge/1.12.2-14.23.5.2860/forge-1.12.2-14.23.5.2860-universal.jar"
|
||||
),
|
||||
"URL 必须带 -universal(官方 maven 只托管这个 classifier)"
|
||||
);
|
||||
assert_eq!(artifact.size, Some(4_466_148));
|
||||
assert!(library
|
||||
.url
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.contains("minecraftforge"));
|
||||
|
||||
// 磁盘落点仍用原 path(与官方 installer 内嵌条目同名,启动 classpath 布局不变)。
|
||||
assert_eq!(
|
||||
artifact.path.as_deref(),
|
||||
Some("net/minecraftforge/forge/1.12.2-14.23.5.2860/forge-1.12.2-14.23.5.2860.jar")
|
||||
);
|
||||
let dest = library.relative_path().expect("落点应可推导");
|
||||
assert!(
|
||||
dest.ends_with("forge-1.12.2-14.23.5.2860.jar"),
|
||||
"落点不该带 classifier:{}",
|
||||
dest.display()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_inline_protocol_is_unsupported() {
|
||||
// 老协议:没有独立 version.json,只有内联 versionInfo + 解包信息。
|
||||
let profile = profile(
|
||||
r#"{"spec": 1, "minecraft": "1.7.10",
|
||||
"install": {"filePath": "forge-1.7.10-10.13.4.1614-universal.jar"},
|
||||
"versionInfo": {"id": "1.7.10-Forge10.13.4.1614", "mainClass": "net.minecraft.launchwrapper.Launch"},
|
||||
"processors": []}"#,
|
||||
);
|
||||
assert!(profile.is_legacy_protocol());
|
||||
let error = build_release(
|
||||
LoaderKind::Forge,
|
||||
"1.7.10",
|
||||
"1.7.10-10.13.4.1614",
|
||||
&profile,
|
||||
None,
|
||||
)
|
||||
.expect_err("老协议必须明确 unsupported");
|
||||
assert_eq!(error.kind(), LoaderErrorKind::Unsupported);
|
||||
assert!(error.message().contains("老协议"), "{}", error.message());
|
||||
assert!(error.hint().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn universal_classifier_rewrite_is_scoped_to_the_installer_body() {
|
||||
// 只有 install profile 的 `path` 声明的本体构件走 -universal;
|
||||
// 其它同样"空 url + 有 path"的库必须保持原路径(乱加 classifier 会 404)。
|
||||
let embedded = version_json(
|
||||
r#"{"id": "1.12.2-forge-14.23.5.2860", "inheritsFrom": "1.12.2",
|
||||
"mainClass": "net.minecraft.launchwrapper.Launch",
|
||||
"libraries": [
|
||||
{"name": "net.minecraftforge:forge:1.12.2-14.23.5.2860",
|
||||
"downloads": {"artifact": {"path": "net/minecraftforge/forge/1.12.2-14.23.5.2860/forge-1.12.2-14.23.5.2860.jar", "url": ""}}},
|
||||
{"name": "net.minecraftforge:mergetool:1.1.5",
|
||||
"downloads": {"artifact": {"path": "net/minecraftforge/mergetool/1.1.5/mergetool-1.1.5.jar", "url": ""}}}
|
||||
]}"#,
|
||||
);
|
||||
let release = build_release(
|
||||
LoaderKind::Forge,
|
||||
"1.12.2",
|
||||
"1.12.2-14.23.5.2860",
|
||||
&profile(FORGE_LEGACY_PROFILE),
|
||||
Some(&embedded),
|
||||
)
|
||||
.expect("spec 0 应走 json 路线");
|
||||
let libraries = release.version_json.libraries.expect("应有库");
|
||||
let url_of = |name: &str| {
|
||||
libraries
|
||||
.iter()
|
||||
.find(|library| library.name == name)
|
||||
.and_then(|library| library.downloads.as_ref())
|
||||
.and_then(|downloads| downloads.artifact.as_ref())
|
||||
.and_then(|artifact| artifact.url.clone())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
assert!(url_of("net.minecraftforge:forge:1.12.2-14.23.5.2860").ends_with("-universal.jar"));
|
||||
assert_eq!(
|
||||
url_of("net.minecraftforge:mergetool:1.1.5"),
|
||||
"https://maven.minecraftforge.net/net/minecraftforge/mergetool/1.1.5/mergetool-1.1.5.jar"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_mismatch_between_installer_and_target_is_metadata_error() {
|
||||
let error = build_release(
|
||||
LoaderKind::NeoForge,
|
||||
"1.20.1",
|
||||
"21.1.80",
|
||||
&profile(NEOFORGE_PROFILE),
|
||||
Some(&version_json(NEOFORGE_VERSION_JSON)),
|
||||
)
|
||||
.expect_err("原版版本不符必须报错");
|
||||
assert_eq!(error.kind(), LoaderErrorKind::Metadata);
|
||||
assert!(error.message().contains("1.21.1"), "{}", error.message());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_main_class_is_metadata_error() {
|
||||
let embedded = version_json(r#"{"id": "x", "inheritsFrom": "1.12.2", "libraries": []}"#);
|
||||
let error = build_release(
|
||||
LoaderKind::Forge,
|
||||
"1.12.2",
|
||||
"1.12.2-14.23.5.2860",
|
||||
&profile(FORGE_LEGACY_PROFILE),
|
||||
Some(&embedded),
|
||||
)
|
||||
.expect_err("缺主类必须报错");
|
||||
assert_eq!(error.kind(), LoaderErrorKind::Metadata);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
//! maven 版本表:XML 扫描、NeoForge 版本反解、版本挑选、installer 地址。
|
||||
|
||||
use crate::addon::loader::maven::{
|
||||
display_loader_version, installer_url, matches_mc, metadata_url, neoforge_mc_version,
|
||||
normalize_maven_version, parse_metadata_versions, select_versions,
|
||||
};
|
||||
use crate::addon::loader::LoaderKind;
|
||||
|
||||
/// 裁剪过的 maven-metadata.xml(真实结构,只留 `<version>` 列表)。
|
||||
const METADATA: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<metadata>
|
||||
<groupId>net.neoforged</groupId>
|
||||
<artifactId>neoforge</artifactId>
|
||||
<versioning>
|
||||
<latest>26.1.2.125</latest>
|
||||
<versions>
|
||||
<version>20.4.237</version>
|
||||
<version>21.1.9</version>
|
||||
<version>21.1.80</version>
|
||||
<version>26.1.2.125</version>
|
||||
<version>0.0.1-snapshot-1</version>
|
||||
</versions>
|
||||
</versioning>
|
||||
</metadata>"#;
|
||||
|
||||
#[test]
|
||||
fn metadata_versions_are_scanned() {
|
||||
let versions = parse_metadata_versions(METADATA);
|
||||
assert_eq!(
|
||||
versions,
|
||||
vec![
|
||||
"20.4.237",
|
||||
"21.1.9",
|
||||
"21.1.80",
|
||||
"26.1.2.125",
|
||||
"0.0.1-snapshot-1"
|
||||
]
|
||||
);
|
||||
assert!(parse_metadata_versions("not xml").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn neoforge_versions_map_back_to_minecraft() {
|
||||
// analyse/05 §1.3 的三条规则。
|
||||
assert_eq!(neoforge_mc_version("21.1.80").as_deref(), Some("1.21.1"));
|
||||
assert_eq!(neoforge_mc_version("20.4.237").as_deref(), Some("1.20.4"));
|
||||
assert_eq!(neoforge_mc_version("26.1.2.125").as_deref(), Some("26.1"));
|
||||
// 快照(主版本 0)不挂原版版本号。
|
||||
assert_eq!(neoforge_mc_version("0.0.1-snapshot-1"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_versions_filters_and_sorts_numerically() {
|
||||
let versions = parse_metadata_versions(METADATA);
|
||||
let picked = select_versions(LoaderKind::NeoForge, "1.21.1", &versions);
|
||||
// 数字序:9 < 80(字符串序会反过来)。
|
||||
assert_eq!(picked, vec!["21.1.9", "21.1.80"]);
|
||||
assert!(select_versions(LoaderKind::NeoForge, "1.19.2", &versions).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forge_versions_carry_the_minecraft_prefix() {
|
||||
let versions = vec![
|
||||
"1.20.1-47.3.0".to_string(),
|
||||
"1.20.1-47.2.0".to_string(),
|
||||
"1.12.2-14.23.5.2860".to_string(),
|
||||
];
|
||||
let picked = select_versions(LoaderKind::Forge, "1.20.1", &versions);
|
||||
assert_eq!(picked, vec!["1.20.1-47.2.0", "1.20.1-47.3.0"]);
|
||||
assert!(matches_mc(LoaderKind::Forge, "1.20.1", "1.20.1-47.3.0"));
|
||||
assert!(!matches_mc(LoaderKind::Forge, "1.20.2", "1.20.1-47.3.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn neoforge_1201_uses_forge_layout() {
|
||||
// 1.20.1 的 NeoForge 早产版挂在 net/neoforged/forge 下,版本串带原版前缀。
|
||||
assert!(metadata_url(LoaderKind::NeoForge, "1.20.1")
|
||||
.ends_with("net/neoforged/forge/maven-metadata.xml"));
|
||||
assert!(metadata_url(LoaderKind::NeoForge, "1.21.1")
|
||||
.ends_with("net/neoforged/neoforge/maven-metadata.xml"));
|
||||
assert!(metadata_url(LoaderKind::Forge, "1.20.1")
|
||||
.ends_with("net/minecraftforge/forge/maven-metadata.xml"));
|
||||
|
||||
let versions = vec!["1.20.1-47.1.106".to_string(), "1.20.1-47.1.90".to_string()];
|
||||
let picked = select_versions(LoaderKind::NeoForge, "1.20.1", &versions);
|
||||
assert_eq!(picked.len(), 2);
|
||||
assert_eq!(
|
||||
display_loader_version(LoaderKind::NeoForge, "1.20.1", "1.20.1-47.1.106"),
|
||||
"47.1.106"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn versions_are_normalized_and_displayed() {
|
||||
// Forge:用户给 "47.2.0",maven 上是 "1.20.1-47.2.0"。
|
||||
assert_eq!(
|
||||
normalize_maven_version(LoaderKind::Forge, "1.20.1", "47.2.0"),
|
||||
"1.20.1-47.2.0"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_maven_version(LoaderKind::Forge, "1.20.1", "1.20.1-47.2.0"),
|
||||
"1.20.1-47.2.0"
|
||||
);
|
||||
assert_eq!(
|
||||
display_loader_version(LoaderKind::Forge, "1.20.1", "1.20.1-47.2.0"),
|
||||
"47.2.0"
|
||||
);
|
||||
// NeoForge(非 1.20.1 布局)本身就是完整 maven 版本。
|
||||
assert_eq!(
|
||||
normalize_maven_version(LoaderKind::NeoForge, "1.21.1", "21.1.80"),
|
||||
"21.1.80"
|
||||
);
|
||||
assert_eq!(
|
||||
display_loader_version(LoaderKind::NeoForge, "1.21.1", "21.1.80"),
|
||||
"21.1.80"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installer_urls_point_at_official_maven() {
|
||||
assert_eq!(
|
||||
installer_url(LoaderKind::Forge, "1.20.1", "1.20.1-47.2.0"),
|
||||
"https://maven.minecraftforge.net/net/minecraftforge/forge/1.20.1-47.2.0/forge-1.20.1-47.2.0-installer.jar"
|
||||
);
|
||||
assert_eq!(
|
||||
installer_url(LoaderKind::NeoForge, "1.21.1", "21.1.80"),
|
||||
"https://maven.neoforged.net/releases/net/neoforged/neoforge/21.1.80/neoforge-21.1.80-installer.jar"
|
||||
);
|
||||
// 1.20.1 的 NeoForge 走 forge 布局(文件名也是 forge-)。
|
||||
assert_eq!(
|
||||
installer_url(LoaderKind::NeoForge, "1.20.1", "1.20.1-47.1.106"),
|
||||
"https://maven.neoforged.net/releases/net/neoforged/forge/1.20.1-47.1.106/forge-1.20.1-47.1.106-installer.jar"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//! `addon::loader` 的单元测试:离线夹具对拍 + 镜像选择 + unsupported 判定。
|
||||
//!
|
||||
//! 刻意**不打真实网络**:JSON 归一化、版本表挑选、镜像候选、计划形状全是纯计算。
|
||||
//! 夹具是官方响应的**裁剪版**(保留形状与关键字段,去掉几百行库与 base64 图标)。
|
||||
|
||||
mod fabric;
|
||||
mod forge;
|
||||
mod maven;
|
||||
mod plan;
|
||||
|
||||
/// 裁剪过的 Fabric `/profile/json`(1.20.1 + fabric-loader 0.15.11)。
|
||||
pub(crate) const FABRIC_PROFILE: &str = r#"{
|
||||
"id": "fabric-loader-0.15.11-1.20.1",
|
||||
"inheritsFrom": "1.20.1",
|
||||
"releaseTime": "2026-10-29T10:25:18+0000",
|
||||
"time": "2026-10-29T10:25:18+0000",
|
||||
"type": "release",
|
||||
"mainClass": "net.fabricmc.loader.impl.launch.knot.KnotClient",
|
||||
"arguments": {"game": [], "jvm": ["-DFabricMcEmu= net.minecraft.client.main.Main "]},
|
||||
"libraries": [
|
||||
{"name": "org.ow2.asm:asm:9.6", "url": "https://maven.fabricmc.net/",
|
||||
"sha1": "aa205cf0a06dbd8e04ece91c0b37c3f5d567546a", "size": 123598},
|
||||
{"name": "net.fabricmc:sponge-mixin:0.13.3+mixin.0.8.5", "url": "https://maven.fabricmc.net/"},
|
||||
{"name": "net.fabricmc:intermediary:1.20.1", "url": "https://maven.fabricmc.net/"},
|
||||
{"name": "net.fabricmc:fabric-loader:0.15.11", "url": "https://maven.fabricmc.net/"}
|
||||
]
|
||||
}"#;
|
||||
|
||||
/// 裁剪过的 Quilt `/profile/json`(1.20.1 + quilt-loader 0.20.0-beta.9)。
|
||||
pub(crate) const QUILT_PROFILE: &str = r#"{
|
||||
"id": "quilt-loader-0.20.0-beta.9-1.20.1",
|
||||
"inheritsFrom": "1.20.1",
|
||||
"type": "release",
|
||||
"mainClass": "org.quiltmc.loader.impl.launch.knot.KnotClient",
|
||||
"arguments": {"game": []},
|
||||
"libraries": [
|
||||
{"name": "net.fabricmc:tiny-mappings-parser:0.3.0+build.17", "url": "https://maven.fabricmc.net/"},
|
||||
{"name": "org.quiltmc:quilt-json5:1.0.4+final", "url": "https://maven.quiltmc.org/repository/release/"},
|
||||
{"name": "org.quiltmc:quilt-loader:0.20.0-beta.9", "url": "https://maven.quiltmc.org/repository/release/"}
|
||||
]
|
||||
}"#;
|
||||
|
||||
/// 原版 1.20.1 的**已裁剪**版本 JSON(继承链解析的父层)。
|
||||
pub(crate) const VANILLA_1201: &str = r#"{
|
||||
"id": "1.20.1",
|
||||
"type": "release",
|
||||
"mainClass": "net.minecraft.client.main.Main",
|
||||
"assets": "5",
|
||||
"assetIndex": {"id": "5", "sha1": "0000", "size": 1,
|
||||
"url": "https://piston-meta.mojang.com/v1/packages/5.json"},
|
||||
"downloads": {"client": {"sha1": "1111", "size": 2,
|
||||
"url": "https://piston-data.mojang.com/v1/objects/1/client.jar"}},
|
||||
"libraries": [
|
||||
{"name": "com.mojang:brigadier:1.0.18", "url": "https://libraries.minecraft.net/"}
|
||||
]
|
||||
}"#;
|
||||
@@ -0,0 +1,192 @@
|
||||
//! 计划形状 / 继承链两级解析 / 镜像选择。
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use super::{FABRIC_PROFILE, VANILLA_1201};
|
||||
use crate::addon::loader::fabric::{build_release, loader_list_url, profile_url};
|
||||
use crate::addon::loader::maven;
|
||||
use crate::addon::loader::{
|
||||
LoaderInstallPlan, LoaderKind, LoaderPlanInput, LoaderRelease, LoaderSupport,
|
||||
};
|
||||
use crate::download::mirrors::MirrorTable;
|
||||
use crate::download::region::RegionProfile;
|
||||
use crate::download::types::ResourceKind;
|
||||
use crate::game::arguments::Environment;
|
||||
use crate::game::install::FileRole;
|
||||
use crate::game::{inherit, layout::GameLayout, VersionJson};
|
||||
|
||||
fn fabric_release() -> LoaderRelease {
|
||||
build_release(LoaderKind::Fabric, "1.20.1", "0.15.11", FABRIC_PROFILE).expect("夹具应可归一化")
|
||||
}
|
||||
|
||||
fn environment() -> Environment {
|
||||
Environment {
|
||||
os: "windows".to_string(),
|
||||
os_version: String::new(),
|
||||
arch: "x86_64".to_string(),
|
||||
..Environment::host()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_contains_only_loader_libraries() {
|
||||
let layout = GameLayout::new("/g/.minecraft");
|
||||
let release = fabric_release();
|
||||
let plan = LoaderInstallPlan::build(LoaderPlanInput {
|
||||
layout: &layout,
|
||||
release: &release,
|
||||
environment: &environment(),
|
||||
vanilla_installed: false,
|
||||
});
|
||||
|
||||
assert_eq!(plan.install.version_id, "1.20.1-fabric-0.15.11");
|
||||
// jar 与资源归原版层,loader 计划里只有库。
|
||||
assert_eq!(plan.install.jar_id, "1.20.1");
|
||||
assert!(plan
|
||||
.files()
|
||||
.iter()
|
||||
.all(|file| file.role == FileRole::Library));
|
||||
assert!(
|
||||
plan.files()
|
||||
.iter()
|
||||
.any(|file| file.name.contains("fabric-loader")),
|
||||
"缺 fabric-loader:{:?}",
|
||||
plan.files().iter().map(|f| &f.name).collect::<Vec<_>>()
|
||||
);
|
||||
assert!(plan.install.version_json_path.ends_with(
|
||||
std::path::Path::new("versions")
|
||||
.join("1.20.1-fabric-0.15.11")
|
||||
.join("1.20.1-fabric-0.15.11.json")
|
||||
));
|
||||
// 只下 loader 那几个库,不该冒出资产/客户端 jar。
|
||||
assert!(plan.install.file_count() >= 4);
|
||||
assert!(plan.install.client_jar().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_marks_missing_vanilla_and_unsupported() {
|
||||
let layout = GameLayout::new("/g/.minecraft");
|
||||
let release = fabric_release();
|
||||
let plan = LoaderInstallPlan::build(LoaderPlanInput {
|
||||
layout: &layout,
|
||||
release: &release,
|
||||
environment: &environment(),
|
||||
vanilla_installed: false,
|
||||
});
|
||||
assert!(plan.vanilla_install_needed);
|
||||
assert!(plan
|
||||
.warnings()
|
||||
.iter()
|
||||
.any(|warning| warning.contains("原版 1.20.1 尚未安装")));
|
||||
|
||||
let installed = LoaderInstallPlan::build(LoaderPlanInput {
|
||||
layout: &layout,
|
||||
release: &release,
|
||||
environment: &environment(),
|
||||
vanilla_installed: true,
|
||||
});
|
||||
assert!(!installed.vanilla_install_needed);
|
||||
|
||||
// 把 support 改成"需要 installer",计划里要出现"要外调 java"的告警(M35 起可真装)。
|
||||
let mut blocked = fabric_release();
|
||||
blocked.support = LoaderSupport::RequiresInstaller {
|
||||
reason: "需要 processors".to_string(),
|
||||
};
|
||||
let plan = LoaderInstallPlan::build(LoaderPlanInput {
|
||||
layout: &layout,
|
||||
release: &blocked,
|
||||
environment: &environment(),
|
||||
vanilla_installed: true,
|
||||
});
|
||||
assert!(plan
|
||||
.warnings()
|
||||
.iter()
|
||||
.any(|warning| warning.contains("需要外调 java 跑 installer processors")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_json_carries_loader_block() {
|
||||
let layout = GameLayout::new("/g/.minecraft");
|
||||
let release = fabric_release();
|
||||
let plan = LoaderInstallPlan::build(LoaderPlanInput {
|
||||
layout: &layout,
|
||||
release: &release,
|
||||
environment: &environment(),
|
||||
vanilla_installed: true,
|
||||
});
|
||||
let summary = plan.summary_json();
|
||||
assert_eq!(summary["loader"]["kind"], "fabric");
|
||||
assert_eq!(summary["loader"]["version_id"], "1.20.1-fabric-0.15.11");
|
||||
assert_eq!(summary["loader"]["support"]["mode"], "json_route");
|
||||
assert_eq!(summary["loader"]["inherits_from"], "1.20.1");
|
||||
assert_eq!(summary["vanilla_install_needed"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loader_json_resolves_two_level_chain() {
|
||||
// loader → 原版 两级继承:解析后必须是扁平的、jar 指原版、库两侧合并。
|
||||
let vanilla = VersionJson::from_json_str(VANILLA_1201).expect("原版夹具");
|
||||
let loader = fabric_release().version_json;
|
||||
let mut table: BTreeMap<String, VersionJson> = BTreeMap::new();
|
||||
table.insert("1.20.1".to_string(), vanilla);
|
||||
table.insert("1.20.1-fabric-0.15.11".to_string(), loader.clone());
|
||||
|
||||
let resolved = inherit::resolve(&table, &loader).expect("两级链应可解析");
|
||||
assert!(resolved.is_resolved());
|
||||
assert_eq!(resolved.inherits_from, None);
|
||||
assert_eq!(resolved.jar.as_deref(), Some("1.20.1"));
|
||||
assert_eq!(
|
||||
resolved.main_class.as_deref(),
|
||||
Some("net.fabricmc.loader.impl.launch.knot.KnotClient")
|
||||
);
|
||||
// 资源来自原版层。
|
||||
assert_eq!(resolved.assets.as_deref(), Some("5"));
|
||||
assert!(resolved.client_jar().is_some());
|
||||
// 库两层的并集(父在前,子在后)。
|
||||
let libraries = resolved.libraries.expect("应有库");
|
||||
assert_eq!(libraries.len(), 5);
|
||||
assert_eq!(libraries[0].name, "com.mojang:brigadier:1.0.18");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loader_meta_and_maven_urls_hit_bmclapi_mirrors() {
|
||||
let table = MirrorTable::builtin();
|
||||
let mirror_first = RegionProfile::mirror_first(0);
|
||||
|
||||
let fabric_meta = loader_list_url(LoaderKind::Fabric, "1.20.1");
|
||||
let candidates = table.expand(&fabric_meta, ResourceKind::VersionManifest, &mirror_first);
|
||||
let sources: Vec<&str> = candidates.iter().map(|c| c.source.as_str()).collect();
|
||||
assert_eq!(
|
||||
sources.first(),
|
||||
Some(&"bmclapi"),
|
||||
"镜像优先时应先走 BMCLAPI"
|
||||
);
|
||||
assert!(candidates.iter().any(|candidate| candidate
|
||||
.url
|
||||
.contains("/fabric-meta/v2/versions/loader/1.20.1")));
|
||||
|
||||
let quilt_profile = profile_url(LoaderKind::Quilt, "1.20.1", "0.20.0-beta.9");
|
||||
let candidates = table.expand(&quilt_profile, ResourceKind::VersionManifest, &mirror_first);
|
||||
assert_eq!(candidates[0].source, "bmclapi");
|
||||
assert!(candidates[0].url.contains("/quilt-meta/"));
|
||||
|
||||
let neoforge_installer = maven::installer_url(LoaderKind::NeoForge, "1.21.1", "21.1.80");
|
||||
let candidates = table.expand(&neoforge_installer, ResourceKind::Library, &mirror_first);
|
||||
assert_eq!(candidates[0].source, "bmclapi");
|
||||
assert!(candidates[0]
|
||||
.url
|
||||
.starts_with("https://bmclapi2.bangbang93.com/maven/net/neoforged/neoforge/"));
|
||||
|
||||
let forge_meta = maven::metadata_url(LoaderKind::Forge, "1.20.1");
|
||||
let candidates = table.expand(&forge_meta, ResourceKind::VersionManifest, &mirror_first);
|
||||
assert_eq!(candidates[0].source, "bmclapi");
|
||||
assert!(candidates[0]
|
||||
.url
|
||||
.starts_with("https://bmclapi2.bangbang93.com/maven/net/minecraftforge/forge/"));
|
||||
|
||||
// 直连模式:官方在前,镜像只兜底。
|
||||
let direct = RegionProfile::direct(0);
|
||||
let candidates = table.expand(&fabric_meta, ResourceKind::VersionManifest, &direct);
|
||||
assert_eq!(candidates[0].source, "official");
|
||||
assert_eq!(candidates[0].url, fabric_meta);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//! 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)。
|
||||
//!
|
||||
//! 规划中:整合包导入导出(D10 §2)、本地 mod 管理(§3)、CF/Modrinth 客户端(§5)。
|
||||
|
||||
pub mod loader;
|
||||
|
||||
pub use loader::{
|
||||
LoaderError, LoaderErrorKind, LoaderInstallPlan, LoaderKind, LoaderPlanInput, LoaderRelease,
|
||||
LoaderSource, LoaderSupport,
|
||||
};
|
||||
@@ -0,0 +1,339 @@
|
||||
//! 账户模型:**条目 id 与 profileID 解耦**的三轨统一抽象。
|
||||
//!
|
||||
//! 承 `analyse/04` §2.3 / §3.2 / §12.1(1) 的三条硬教训:
|
||||
//! 1. 条目 id(`account:<32hex>`)**不得**等于 profileID / 登录名 / 服务器 URL——
|
||||
//! 这些值可能被多个条目共享(同一角色导入两次、服务器换域名);
|
||||
//! 2. **元数据 / 凭据双写分离**:`AccountMeta` 只装「能公开」的字段(列表渲染不需要解密);
|
||||
//! 3. 反序列化**允许不完整对象**(`profile_name` 可空 = 「有档案但名字待刷新」),
|
||||
//! 补齐推迟到登录时——不能因为名字未知就整条丢弃。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::auth::authlib::AuthlibSession;
|
||||
use crate::auth::error::{AuthError, AuthResult};
|
||||
use crate::auth::microsoft::MicrosoftSession;
|
||||
use crate::auth::secret::random_hex32;
|
||||
|
||||
/// 条目 id 前缀(防被误当 UUID;承 HMCL `AccountID.PREFIX`)。
|
||||
pub const ACCOUNT_ID_PREFIX: &str = "account:";
|
||||
|
||||
/// 账户条目 id:`account:<32 位十六进制>`。
|
||||
///
|
||||
/// 与 profileID 无关:改服务器地址、改玩家名、同角色多条目都不影响它。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct AccountId(String);
|
||||
|
||||
impl AccountId {
|
||||
/// 生成一个新条目 id。
|
||||
#[must_use]
|
||||
pub fn generate() -> Self {
|
||||
Self(format!("{ACCOUNT_ID_PREFIX}{}", random_hex32()))
|
||||
}
|
||||
|
||||
/// 从磁盘字符串解析(宽容:坏数据返回 `None`,不 panic)。
|
||||
#[must_use]
|
||||
pub fn parse(raw: &str) -> Option<Self> {
|
||||
let body = raw.strip_prefix(ACCOUNT_ID_PREFIX)?;
|
||||
if body.len() != 32 || !body.chars().all(|ch| ch.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
Some(Self(raw.to_ascii_lowercase()))
|
||||
}
|
||||
|
||||
/// 字符串视图。
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AccountId {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// 三条轨道。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum AccountKind {
|
||||
/// 离线(本地生成的 UUID,不联网)。
|
||||
Offline,
|
||||
/// 微软(device-code)。
|
||||
Microsoft,
|
||||
/// authlib-injector 第三方认证服务器(标准 Yggdrasil 协议)。
|
||||
AuthlibInjector,
|
||||
}
|
||||
|
||||
impl AccountKind {
|
||||
/// 落盘用的稳定名(承 HMCL 的 `offline` / `microsoft` / `authlibInjector`)。
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Offline => "offline",
|
||||
Self::Microsoft => "microsoft",
|
||||
Self::AuthlibInjector => "authlibInjector",
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析(未知值返回 `None`,让上层跳过该条记录)。
|
||||
#[must_use]
|
||||
pub fn parse(raw: &str) -> Option<Self> {
|
||||
match raw {
|
||||
"offline" => Some(Self::Offline),
|
||||
"microsoft" => Some(Self::Microsoft),
|
||||
// 兼容两种写法:HMCL 用 camelCase,别的地方有人写 kebab。
|
||||
"authlibInjector" | "authlib-injector" => Some(Self::AuthlibInjector),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 人读标签(CLI/JSON 展示用)。
|
||||
#[must_use]
|
||||
pub const fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Offline => "离线",
|
||||
Self::Microsoft => "微软",
|
||||
Self::AuthlibInjector => "authlib-injector",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 账户元数据(**可公开**,不含任何凭据)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountMeta {
|
||||
/// 条目 id。
|
||||
pub id: AccountId,
|
||||
/// 轨道。
|
||||
pub kind: AccountKind,
|
||||
/// Minecraft 档案 UUID(32 位无连字符)。**不可变**。
|
||||
pub profile_id: String,
|
||||
/// 展示名/玩家名;空串 = 「有档案但名字待刷新」(合法中间态)。
|
||||
#[serde(default)]
|
||||
pub profile_name: String,
|
||||
/// 登录名(authlib 轨的邮箱/用户名;其余轨为 `None`)。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub login_name: Option<String>,
|
||||
/// authlib 认证服务器 API 根(其余轨为 `None`;凭据之外唯一需要持久化的服务器信息)。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub server_url: Option<String>,
|
||||
/// 创建时刻(Unix 毫秒;仅用于排序/展示)。
|
||||
pub created_at_ms: u64,
|
||||
}
|
||||
|
||||
impl AccountMeta {
|
||||
/// 构造一条元数据(`profile_name` 可后补)。
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
kind: AccountKind,
|
||||
profile_id: impl Into<String>,
|
||||
profile_name: impl Into<String>,
|
||||
created_at_ms: u64,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: AccountId::generate(),
|
||||
kind,
|
||||
profile_id: profile_id.into(),
|
||||
profile_name: profile_name.into(),
|
||||
login_name: None,
|
||||
server_url: None,
|
||||
created_at_ms,
|
||||
}
|
||||
}
|
||||
|
||||
/// 展示名:优先玩家名,其次登录名,最后条目 id(永不 panic、永不空串)。
|
||||
#[must_use]
|
||||
pub fn display_name(&self) -> &str {
|
||||
if !self.profile_name.trim().is_empty() {
|
||||
return &self.profile_name;
|
||||
}
|
||||
if let Some(login_name) = &self.login_name {
|
||||
if !login_name.trim().is_empty() {
|
||||
return login_name;
|
||||
}
|
||||
}
|
||||
self.id.as_str()
|
||||
}
|
||||
|
||||
/// 该条目是否匹配一个查找键(名字 / 登录名 / 条目 id)。
|
||||
///
|
||||
/// 大小写不敏感:玩家名在 MC 里大小写不敏感,CLI 用户不该为此折腾。
|
||||
#[must_use]
|
||||
pub fn matches(&self, key: &str) -> bool {
|
||||
let key = key.trim();
|
||||
if key.is_empty() {
|
||||
return false;
|
||||
}
|
||||
self.profile_name.eq_ignore_ascii_case(key)
|
||||
|| self
|
||||
.login_name
|
||||
.as_deref()
|
||||
.is_some_and(|name| name.eq_ignore_ascii_case(key))
|
||||
|| self.id.as_str().eq_ignore_ascii_case(key)
|
||||
}
|
||||
|
||||
/// 该条目的凭据是否与轨道匹配(防止磁盘上元数据被换成另一轨)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 轨道与凭据种类不一致时返回 [`AuthError::Malformed`]。
|
||||
pub fn check_credentials(&self, credentials: &Credentials) -> AuthResult<()> {
|
||||
if credentials.kind() == self.kind {
|
||||
return Ok(());
|
||||
}
|
||||
Err(AuthError::Malformed(format!(
|
||||
"账户 {} 的元数据轨道是 {},凭据却是 {}",
|
||||
self.id,
|
||||
self.kind.as_str(),
|
||||
credentials.kind().as_str()
|
||||
)))
|
||||
}
|
||||
|
||||
/// 从 JSON 值宽容解析(单条坏记录只返回 `Err`,由调用方跳过并记 warning)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 缺字段/类型不符/轨道未知时返回 [`AuthError::Malformed`]。
|
||||
pub fn from_value(value: &serde_json::Value) -> AuthResult<Self> {
|
||||
serde_json::from_value(value.clone())
|
||||
.map_err(|error| AuthError::Malformed(format!("账户元数据不合法:{error}")))
|
||||
}
|
||||
}
|
||||
|
||||
/// 凭据载荷(**私有**,只落 `credentials.dat`,永不进列表/日志)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum Credentials {
|
||||
/// 离线账户没有凭据(accessToken 每次登录随机生成,不落盘)。
|
||||
Offline,
|
||||
/// 微软会话(access + refresh + `notAfter`)。
|
||||
Microsoft(MicrosoftSession),
|
||||
/// authlib 会话(accessToken + clientToken)。
|
||||
AuthlibInjector(AuthlibSession),
|
||||
}
|
||||
|
||||
impl Credentials {
|
||||
/// 所属轨道。
|
||||
#[must_use]
|
||||
pub const fn kind(&self) -> AccountKind {
|
||||
match self {
|
||||
Self::Offline => AccountKind::Offline,
|
||||
Self::Microsoft(_) => AccountKind::Microsoft,
|
||||
Self::AuthlibInjector(_) => AccountKind::AuthlibInjector,
|
||||
}
|
||||
}
|
||||
|
||||
/// 该凭据里的所有明文敏感值(武装脱敏器用;离线轨为空)。
|
||||
#[must_use]
|
||||
pub fn secrets(&self) -> Vec<&str> {
|
||||
match self {
|
||||
Self::Offline => Vec::new(),
|
||||
Self::Microsoft(session) => session
|
||||
.secrets()
|
||||
.into_iter()
|
||||
.map(|secret| secret.expose())
|
||||
.collect(),
|
||||
Self::AuthlibInjector(session) => session
|
||||
.secrets()
|
||||
.into_iter()
|
||||
.map(|secret| secret.expose())
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{AccountId, AccountKind, AccountMeta, Credentials};
|
||||
use crate::auth::microsoft::MicrosoftSession;
|
||||
use crate::auth::secret::Secret;
|
||||
|
||||
#[test]
|
||||
fn account_id_shape_and_parse() {
|
||||
let id = AccountId::generate();
|
||||
assert!(id.as_str().starts_with("account:"));
|
||||
assert_eq!(id.as_str().len(), "account:".len() + 32);
|
||||
assert_eq!(AccountId::parse(id.as_str()), Some(id.clone()));
|
||||
assert_eq!(AccountId::parse("account:xyz"), None);
|
||||
assert_eq!(AccountId::parse("0192-blah"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kind_roundtrip() {
|
||||
for kind in [
|
||||
AccountKind::Offline,
|
||||
AccountKind::Microsoft,
|
||||
AccountKind::AuthlibInjector,
|
||||
] {
|
||||
assert_eq!(AccountKind::parse(kind.as_str()), Some(kind));
|
||||
assert!(!kind.label().is_empty());
|
||||
}
|
||||
assert_eq!(AccountKind::parse("nope"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn meta_matches_name_login_and_id() {
|
||||
let mut meta = AccountMeta::new(AccountKind::Offline, "uuid32", "Steve", 1);
|
||||
meta.login_name = Some("steve@example.com".to_string());
|
||||
assert!(meta.matches("Steve"));
|
||||
assert!(meta.matches("steve"));
|
||||
assert!(meta.matches("steve@example.com"));
|
||||
assert!(meta.matches(meta.id.as_str()));
|
||||
assert!(!meta.matches("Alex"));
|
||||
assert!(!meta.matches(" "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_name_falls_back() {
|
||||
let mut meta = AccountMeta::new(AccountKind::Microsoft, "uuid", "", 1);
|
||||
assert_eq!(meta.display_name(), meta.id.as_str());
|
||||
meta.login_name = Some("me@example.com".to_string());
|
||||
assert_eq!(meta.display_name(), "me@example.com");
|
||||
meta.profile_name = "Steve".to_string();
|
||||
assert_eq!(meta.display_name(), "Steve");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credential_kind_must_match_meta() {
|
||||
let meta = AccountMeta::new(AccountKind::Microsoft, "uuid", "Steve", 1);
|
||||
let session = MicrosoftSession::new(
|
||||
"uuid",
|
||||
"Steve",
|
||||
Secret::new("a"),
|
||||
Secret::new("r"),
|
||||
60,
|
||||
0,
|
||||
None,
|
||||
);
|
||||
assert!(meta
|
||||
.check_credentials(&Credentials::Microsoft(session))
|
||||
.is_ok());
|
||||
assert!(meta.check_credentials(&Credentials::Offline).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secrets_only_expose_real_credentials() {
|
||||
assert!(Credentials::Offline.secrets().is_empty());
|
||||
let session = MicrosoftSession::new(
|
||||
"uuid",
|
||||
"Steve",
|
||||
Secret::new("mc-token"),
|
||||
Secret::new("live-refresh"),
|
||||
60,
|
||||
0,
|
||||
None,
|
||||
);
|
||||
assert_eq!(
|
||||
Credentials::Microsoft(session).secrets(),
|
||||
vec!["mc-token", "live-refresh"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_record_is_an_error_not_a_panic() {
|
||||
let value = serde_json::json!({ "id": "account:zz", "kind": "offline" });
|
||||
assert!(AccountMeta::from_value(&value).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
//! authlib-injector 轨(**账户侧**):标准 Yggdrasil 协议的登录/刷新/校验/注销。
|
||||
//!
|
||||
//! 边界(mission 明示):本模块只做「账户」;`-javaagent` 三件套(jar 下载、
|
||||
//! `-Dauthlibinjector.side=client`、`yggdrasil.prefetched` 的 Base64)归后续 mission。
|
||||
//! 依据 `analyse/04` §6.1–§6.2(端点布局与四动作报文)与 §6.3(服务发现约定)。
|
||||
//!
|
||||
//! 三条照抄要点:
|
||||
//! - 端点 = `<apiRoot>authserver/{authenticate,refresh,validate,invalidate}`,`apiRoot`
|
||||
//! 必须带尾斜杠(`https://littleskin.cn/api/yggdrasil/` 这种目录布局是规范要求);
|
||||
//! - `clientToken` 是**启动器侧生成的随机值**,服务端回传必须一致(防会话被替换/重放);
|
||||
//! - 「token 无效」用**返回值 `false`** 表达(`validate` 的 `ForbiddenOperationException`),
|
||||
//! 「服务器抽风」才抛错——调用方据此决定「刷新」还是「重试」。
|
||||
|
||||
pub mod server_meta;
|
||||
|
||||
pub use server_meta::AuthlibServerMeta;
|
||||
|
||||
use reqwest::Client;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::auth::error::{AuthError, AuthResult};
|
||||
use crate::auth::http::{post_json, Reply};
|
||||
use crate::auth::secret::Secret;
|
||||
use crate::download::config::EngineConfig;
|
||||
use crate::download::http::build_client;
|
||||
use crate::launch::request::AuthPlaceholders;
|
||||
|
||||
/// authlib 轨的 launch 用户类型(承 HMCL `YggdrasilSession.toAuthInfo`:也是 `msa`)。
|
||||
pub const USER_TYPE_MSA: &str = "msa";
|
||||
|
||||
/// 把用户输入规范成 API 根:补 `https://`、补尾斜杠、去空白。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 空串或只剩协议头时返回 [`AuthError::Malformed`]。
|
||||
pub fn normalize_api_root(input: &str) -> AuthResult<String> {
|
||||
let trimmed = input.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(AuthError::Malformed(
|
||||
"认证服务器地址不能为空(例:https://littleskin.cn/api/yggdrasil/)".to_string(),
|
||||
));
|
||||
}
|
||||
let with_scheme = if trimmed.starts_with("https://") || trimmed.starts_with("http://") {
|
||||
trimmed.to_string()
|
||||
} else {
|
||||
format!("https://{trimmed}")
|
||||
};
|
||||
let host = with_scheme
|
||||
.split_once("://")
|
||||
.map(|(_, rest)| rest)
|
||||
.unwrap_or("");
|
||||
if host.trim_matches('/').is_empty() {
|
||||
return Err(AuthError::Malformed(format!(
|
||||
"认证服务器地址不完整:`{input}`"
|
||||
)));
|
||||
}
|
||||
Ok(if with_scheme.ends_with('/') {
|
||||
with_scheme
|
||||
} else {
|
||||
format!("{with_scheme}/")
|
||||
})
|
||||
}
|
||||
|
||||
/// Yggdrasil 四个端点(都从 API 根派生)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AuthlibEndpoints {
|
||||
/// `authserver/authenticate`。
|
||||
pub authenticate: String,
|
||||
/// `authserver/refresh`。
|
||||
pub refresh: String,
|
||||
/// `authserver/validate`。
|
||||
pub validate: String,
|
||||
/// `authserver/invalidate`。
|
||||
pub invalidate: String,
|
||||
}
|
||||
|
||||
impl AuthlibEndpoints {
|
||||
/// 从 API 根派生(先 [`normalize_api_root`])。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 地址不合法时返回 [`AuthError::Malformed`]。
|
||||
pub fn from_api_root(api_root: &str) -> AuthResult<Self> {
|
||||
let root = normalize_api_root(api_root)?;
|
||||
let join = |path: &str| format!("{root}{path}");
|
||||
Ok(Self {
|
||||
authenticate: join("authserver/authenticate"),
|
||||
refresh: join("authserver/refresh"),
|
||||
validate: join("authserver/validate"),
|
||||
invalidate: join("authserver/invalidate"),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个 Yggdrasil 角色(`selectedProfile`)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct AuthlibProfile {
|
||||
/// 32 位无连字符 UUID。
|
||||
pub id: String,
|
||||
/// 角色名。
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// authlib 会话 = 该账户的凭据载荷。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AuthlibSession {
|
||||
/// Yggdrasil accessToken。
|
||||
pub access_token: Secret,
|
||||
/// 启动器生成的 clientToken(刷新/校验都要带)。
|
||||
pub client_token: Secret,
|
||||
/// 当前选中的角色(多角色服务器里由 refresh 的 `selectedProfile` 决定)。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub selected_profile: Option<AuthlibProfile>,
|
||||
}
|
||||
|
||||
impl AuthlibSession {
|
||||
/// 该会话的全部凭据值(武装脱敏器)。
|
||||
#[must_use]
|
||||
pub fn secrets(&self) -> Vec<&Secret> {
|
||||
vec![&self.access_token, &self.client_token]
|
||||
}
|
||||
|
||||
/// 有没有角色(没角色的会话不能启动游戏)。
|
||||
#[must_use]
|
||||
pub fn has_profile(&self) -> bool {
|
||||
self.selected_profile
|
||||
.as_ref()
|
||||
.is_some_and(|profile| !profile.name.trim().is_empty() && !profile.id.is_empty())
|
||||
}
|
||||
|
||||
/// 转成 launch 占位符。
|
||||
///
|
||||
/// `user_properties` 用 `{}`:纹理由游戏进程经 authlib-injector 向服务器取
|
||||
/// (注入参数属后续 mission),启动参数不需要它。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 会话没有角色时返回 [`AuthError::CredentialExpired`](需要重新选角色/登录)。
|
||||
pub fn to_placeholders(&self) -> AuthResult<AuthPlaceholders> {
|
||||
let profile = self
|
||||
.selected_profile
|
||||
.as_ref()
|
||||
.filter(|profile| !profile.name.trim().is_empty())
|
||||
.ok_or(AuthError::CredentialExpired)?;
|
||||
Ok(AuthPlaceholders {
|
||||
player_name: profile.name.clone(),
|
||||
uuid: profile.id.clone(),
|
||||
access_token: self.access_token.expose().to_string(),
|
||||
user_type: USER_TYPE_MSA.to_string(),
|
||||
user_properties: "{}".to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// authlib 认证客户端。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AuthlibAuth {
|
||||
client: Client,
|
||||
}
|
||||
|
||||
impl AuthlibAuth {
|
||||
/// 构造(HTTP 基座复用 `core::download`)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 客户端建不起来时返回 [`AuthError::Network`]。
|
||||
pub fn new() -> AuthResult<Self> {
|
||||
let client = build_client(&EngineConfig::default())
|
||||
.map_err(|error| AuthError::network("http-client", error.to_string()))?;
|
||||
Ok(Self { client })
|
||||
}
|
||||
|
||||
/// 注入客户端(测试)。
|
||||
#[must_use]
|
||||
pub fn with_client(mut self, client: Client) -> Self {
|
||||
self.client = client;
|
||||
self
|
||||
}
|
||||
|
||||
/// 密码登录(**新账户**)。密码只在本调用栈里出现,不落盘、不入日志。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 密码错/服务器拒绝 → [`AuthError::Denied`];网络与结构问题各有分流。
|
||||
pub async fn authenticate(
|
||||
&self,
|
||||
api_root: &str,
|
||||
login_name: &str,
|
||||
password: &Secret,
|
||||
client_token: &Secret,
|
||||
) -> AuthResult<AuthlibSession> {
|
||||
let endpoints = AuthlibEndpoints::from_api_root(api_root)?;
|
||||
let body = json!({
|
||||
"agent": { "name": "Minecraft", "version": 1 },
|
||||
"username": login_name,
|
||||
"password": password.expose(),
|
||||
"clientToken": client_token.expose(),
|
||||
"requestUser": true,
|
||||
});
|
||||
let reply = post_json(&self.client, &endpoints.authenticate, &body, None).await?;
|
||||
if !reply.is_success() {
|
||||
return Err(rejected(&reply, &endpoints.authenticate, "登录"));
|
||||
}
|
||||
let session = session_from(&reply, client_token, &endpoints.authenticate)?;
|
||||
if !session.has_profile() {
|
||||
// 服务端没自动选角色(多角色服务器):交给调用方走 refresh + selectedProfile。
|
||||
return Ok(session);
|
||||
}
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
/// 刷新(可顺带 `selectedProfile` 换角色)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// token 失效 → [`AuthError::CredentialExpired`];换角色失败 → [`AuthError::Malformed`]。
|
||||
pub async fn refresh(
|
||||
&self,
|
||||
api_root: &str,
|
||||
session: &AuthlibSession,
|
||||
select: Option<&AuthlibProfile>,
|
||||
) -> AuthResult<AuthlibSession> {
|
||||
let endpoints = AuthlibEndpoints::from_api_root(api_root)?;
|
||||
let mut body = json!({
|
||||
"accessToken": session.access_token.expose(),
|
||||
"clientToken": session.client_token.expose(),
|
||||
"requestUser": true,
|
||||
});
|
||||
if let Some(profile) = select {
|
||||
body["selectedProfile"] = json!({ "id": profile.id, "name": profile.name });
|
||||
}
|
||||
let reply = post_json(&self.client, &endpoints.refresh, &body, None).await?;
|
||||
if !reply.is_success() {
|
||||
if is_forbidden(&reply) {
|
||||
return Err(AuthError::CredentialExpired);
|
||||
}
|
||||
return Err(rejected(&reply, &endpoints.refresh, "刷新"));
|
||||
}
|
||||
let refreshed = session_from(&reply, &session.client_token, &endpoints.refresh)?;
|
||||
if let Some(wanted) = select {
|
||||
let got = refreshed.selected_profile.as_ref();
|
||||
if got.map(|profile| profile.id.as_str()) != Some(wanted.id.as_str()) {
|
||||
return Err(AuthError::Malformed(format!(
|
||||
"服务器没有切到角色 {}(refresh 未生效)",
|
||||
wanted.name
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(refreshed)
|
||||
}
|
||||
|
||||
/// 校验 token 是否仍有效(`ForbiddenOperationException` = `Ok(false)`)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 服务器 5xx/网络失败时返回错误(调用方据此区分「要刷新」与「要重试」)。
|
||||
pub async fn validate(&self, api_root: &str, session: &AuthlibSession) -> AuthResult<bool> {
|
||||
let endpoints = AuthlibEndpoints::from_api_root(api_root)?;
|
||||
let body = json!({
|
||||
"accessToken": session.access_token.expose(),
|
||||
"clientToken": session.client_token.expose(),
|
||||
});
|
||||
let reply = post_json(&self.client, &endpoints.validate, &body, None).await?;
|
||||
if is_forbidden(&reply) {
|
||||
return Ok(false);
|
||||
}
|
||||
if !reply.is_success() {
|
||||
return Err(rejected(&reply, &endpoints.validate, "校验"));
|
||||
}
|
||||
// 成功时 body 为空或 `{}`,都算有效。
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// 主动注销(删账户时顺手清理服务端会话;失败按「尽力而为」返回错误,调用方可不理)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 网络/服务端失败时返回错误。
|
||||
pub async fn invalidate(&self, api_root: &str, session: &AuthlibSession) -> AuthResult<()> {
|
||||
let endpoints = AuthlibEndpoints::from_api_root(api_root)?;
|
||||
let body = json!({
|
||||
"accessToken": session.access_token.expose(),
|
||||
"clientToken": session.client_token.expose(),
|
||||
});
|
||||
let reply = post_json(&self.client, &endpoints.invalidate, &body, None).await?;
|
||||
if reply.is_success() || is_forbidden(&reply) {
|
||||
return Ok(());
|
||||
}
|
||||
Err(rejected(&reply, &endpoints.invalidate, "注销"))
|
||||
}
|
||||
}
|
||||
|
||||
/// 响应 → 会话(`clientToken` 回传必须一致;缺失时保留原值)。
|
||||
fn session_from(reply: &Reply, client_token: &Secret, url: &str) -> AuthResult<AuthlibSession> {
|
||||
let access_token = reply
|
||||
.text("accessToken")
|
||||
.ok_or_else(|| AuthError::Malformed(format!("{url} 响应缺 accessToken")))?;
|
||||
if let Some(returned) = reply.text("clientToken") {
|
||||
if returned != client_token.expose() {
|
||||
return Err(AuthError::Malformed(
|
||||
"服务器回传的 clientToken 与本地不一致(会话可能被替换)".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(AuthlibSession {
|
||||
access_token: Secret::new(access_token),
|
||||
client_token: client_token.clone(),
|
||||
selected_profile: selected_profile(reply),
|
||||
})
|
||||
}
|
||||
|
||||
fn selected_profile(reply: &Reply) -> Option<AuthlibProfile> {
|
||||
let profile = reply
|
||||
.body
|
||||
.as_ref()
|
||||
.and_then(|body| body.get("selectedProfile"))?;
|
||||
let id = profile.get("id")?.as_str()?;
|
||||
let name = profile.get("name")?.as_str()?;
|
||||
Some(AuthlibProfile {
|
||||
id: id.to_string(),
|
||||
name: name.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn is_forbidden(reply: &Reply) -> bool {
|
||||
reply.text("error") == Some("ForbiddenOperationException")
|
||||
}
|
||||
|
||||
fn rejected(reply: &Reply, url: &str, what: &str) -> AuthError {
|
||||
match reply.text("error") {
|
||||
Some(code) => AuthError::Denied {
|
||||
code: code.to_string(),
|
||||
description: reply
|
||||
.text("errorMessage")
|
||||
.unwrap_or("服务器未给描述")
|
||||
.to_string(),
|
||||
},
|
||||
None => AuthError::network(
|
||||
url.split('?').next().unwrap_or(url),
|
||||
format!("{what}失败(HTTP {})", reply.status),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,86 @@
|
||||
//! 认证服务器元数据里我们真正需要的字段(服务发现的产物)。
|
||||
//!
|
||||
//! authlib-injector 规范里根路径 `/` 返回 `{ meta, skinDomains, signaturePublickey }`
|
||||
//! (`analyse/04` §6.3)。本期**只保存**名字/非邮箱登录/skinDomains:
|
||||
//! `signaturePublickey` 与 `prefetched` 的 Base64 属于注入参数(后续 mission),
|
||||
//! 现在存了也没人消费,不如不存。
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
/// 服务器元数据(缺失字段一律回落默认值)。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AuthlibServerMeta {
|
||||
/// 服务器显示名(缺省回落 URL)。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
/// 是否允许非邮箱登录名(决定输入框校验)。
|
||||
#[serde(default)]
|
||||
pub non_email_login: bool,
|
||||
/// `skinDomains`(纹理白名单,注入参数会用;本期只保存)。
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub skin_domains: Vec<String>,
|
||||
}
|
||||
|
||||
impl AuthlibServerMeta {
|
||||
/// 从根元数据 JSON 解析(容错:缺字段就用默认值)。
|
||||
#[must_use]
|
||||
pub fn from_value(value: &Value) -> Self {
|
||||
let meta = value.get("meta");
|
||||
let name = meta
|
||||
.and_then(|meta| meta.get("serverName"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string);
|
||||
let non_email_login = meta
|
||||
.and_then(|meta| meta.get("feature.non_email_login"))
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
let skin_domains = value
|
||||
.get("skinDomains")
|
||||
.and_then(Value::as_array)
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
Self {
|
||||
name,
|
||||
non_email_login,
|
||||
skin_domains,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::AuthlibServerMeta;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn parses_full_metadata() {
|
||||
let value = json!({
|
||||
"meta": {
|
||||
"serverName": "Example Skin Site",
|
||||
"feature.non_email_login": true
|
||||
},
|
||||
"skinDomains": ["example.com", "*.example.com"],
|
||||
"signaturePublickey": "-----BEGIN PUBLIC KEY-----"
|
||||
});
|
||||
let meta = AuthlibServerMeta::from_value(&value);
|
||||
assert_eq!(meta.name.as_deref(), Some("Example Skin Site"));
|
||||
assert!(meta.non_email_login);
|
||||
assert_eq!(meta.skin_domains.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tolerates_missing_fields() {
|
||||
let meta = AuthlibServerMeta::from_value(&json!({}));
|
||||
assert_eq!(meta, AuthlibServerMeta::default());
|
||||
assert!(meta.name.is_none());
|
||||
assert!(!meta.non_email_login);
|
||||
assert!(meta.skin_domains.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
//! authlib 轨的纯函数/模型测试(HTTP 部分在 `tests/auth/authlib_api.rs`)。
|
||||
|
||||
use super::{normalize_api_root, AuthlibEndpoints, AuthlibProfile, AuthlibSession, USER_TYPE_MSA};
|
||||
use crate::auth::secret::Secret;
|
||||
|
||||
#[test]
|
||||
fn api_root_normalization() {
|
||||
assert_eq!(
|
||||
normalize_api_root("littleskin.cn/api/yggdrasil").expect("补协议"),
|
||||
"https://littleskin.cn/api/yggdrasil/"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_api_root(" http://127.0.0.1:8080/api/yggdrasil/ ").expect("保留协议"),
|
||||
"http://127.0.0.1:8080/api/yggdrasil/"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_api_root("https://littleskin.cn").expect("根域名"),
|
||||
"https://littleskin.cn/"
|
||||
);
|
||||
assert!(normalize_api_root(" ").is_err());
|
||||
assert!(normalize_api_root("https://").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoints_follow_directory_layout() {
|
||||
let endpoints =
|
||||
AuthlibEndpoints::from_api_root("https://example.invalid/api/yggdrasil").expect("端点");
|
||||
assert_eq!(
|
||||
endpoints.authenticate,
|
||||
"https://example.invalid/api/yggdrasil/authserver/authenticate"
|
||||
);
|
||||
assert_eq!(
|
||||
endpoints.refresh,
|
||||
"https://example.invalid/api/yggdrasil/authserver/refresh"
|
||||
);
|
||||
assert_eq!(
|
||||
endpoints.validate,
|
||||
"https://example.invalid/api/yggdrasil/authserver/validate"
|
||||
);
|
||||
assert_eq!(
|
||||
endpoints.invalidate,
|
||||
"https://example.invalid/api/yggdrasil/authserver/invalidate"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placeholders_require_a_profile() {
|
||||
let without = AuthlibSession {
|
||||
access_token: Secret::new("token"),
|
||||
client_token: Secret::new("client"),
|
||||
selected_profile: None,
|
||||
};
|
||||
assert!(!without.has_profile());
|
||||
assert!(without.to_placeholders().is_err());
|
||||
|
||||
let with = AuthlibSession {
|
||||
selected_profile: Some(AuthlibProfile {
|
||||
id: "0123456789abcdef0123456789abcdef".to_string(),
|
||||
name: "Steve".to_string(),
|
||||
}),
|
||||
..without
|
||||
};
|
||||
assert!(with.has_profile());
|
||||
let placeholders = with.to_placeholders().expect("占位符");
|
||||
assert_eq!(placeholders.player_name, "Steve");
|
||||
assert_eq!(placeholders.uuid, "0123456789abcdef0123456789abcdef");
|
||||
assert_eq!(placeholders.user_type, USER_TYPE_MSA);
|
||||
assert_eq!(placeholders.access_token, "token");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_debug_never_leaks_tokens() {
|
||||
let session = AuthlibSession {
|
||||
access_token: Secret::new("access-token-value"),
|
||||
client_token: Secret::new("client-token-value"),
|
||||
selected_profile: None,
|
||||
};
|
||||
let debug = format!("{session:?}");
|
||||
assert!(!debug.contains("access-token-value"));
|
||||
assert!(!debug.contains("client-token-value"));
|
||||
assert_eq!(session.secrets().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_survives_json_roundtrip() {
|
||||
let session = AuthlibSession {
|
||||
access_token: Secret::new("access-token-value"),
|
||||
client_token: Secret::new("client-token-value"),
|
||||
selected_profile: Some(AuthlibProfile {
|
||||
id: "0123456789abcdef0123456789abcdef".to_string(),
|
||||
name: "Steve".to_string(),
|
||||
}),
|
||||
};
|
||||
let text = serde_json::to_string(&session).expect("序列化");
|
||||
let back: AuthlibSession = serde_json::from_str(&text).expect("反序列化");
|
||||
assert_eq!(back, session);
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
//! 非 2xx 应答的**正文诊断**(M38):把 M31「响应体一律丢掉」这个盲区补上。
|
||||
//!
|
||||
//! 起因是 `findings/20261030-tower-improve-login-with-xbox-403.md`:MSA 真机验收两轮都是
|
||||
//! OAuth→XBL→XSTS 全通、`login_with_xbox` 稳定 403,而旧 `rejected()` 只报状态码,
|
||||
//! 于是「区域/网络拦截页」和「服务端拒绝(许可/区域)」在文案上分不开。
|
||||
//!
|
||||
//! 这里只做三件事:
|
||||
//! 1. **分类**:JSON(真实服务应答)/ HTML 或纯文本(多为网关、风控或代理拦截页)/ 空体;
|
||||
//! 2. **提取**:JSON 里的 `error` / `errorMessage` / `reason` / `Message` 等服务端文案直出;
|
||||
//! 3. **摘要**:正文压成 ≤[`DIGEST_CHARS`] 字符,**先过 [`Redactor`] 再进错误消息**。
|
||||
//!
|
||||
//! 凭据红线(M31 的 `Secret`/脱敏体系不破):
|
||||
//! - 摘要只从**非 2xx** 响应构造——OAuth 成功响应体(含 token)永远不会走到这里;
|
||||
//! - `Reply` 的原文快照只在 JSON 解析失败时保留(见 [`crate::auth::http`]),
|
||||
//! 解析成功的应答不留原文,避免把 token 文本复制进第二个字段;
|
||||
//! - 摘要与提取出的服务端文案都过同一套脱敏:[`Redactor::scrub`](已登记 token 整串替换、
|
||||
//! ≥64 不透明串打码、JWT 形状的带点 token 打码——第三条 M40 已上提到 `launch::redact`,
|
||||
//! 本模块不再自己补规则)。
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::auth::error::AuthError;
|
||||
use crate::auth::http::Reply;
|
||||
use crate::launch::redact::Redactor;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
/// 错误文案 / 事件里正文摘要的上限(字符)。
|
||||
pub const DIGEST_CHARS: usize = 200;
|
||||
|
||||
/// 服务端文案的取值字段(按优先级,命中即收;`Message` 是 XSTS 的写法)。
|
||||
const SERVER_MESSAGE_KEYS: [&str; 6] = [
|
||||
"errorMessage",
|
||||
"error_message",
|
||||
"error",
|
||||
"errorType",
|
||||
"reason",
|
||||
"Message",
|
||||
];
|
||||
|
||||
/// 一个非 2xx 应答的正文形状。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BodyKind {
|
||||
/// 合法 JSON(真实服务应答)。
|
||||
Json,
|
||||
/// HTML 页面(网关/风控/代理拦截页的典型形状)。
|
||||
Html,
|
||||
/// 其它非 JSON 文本。
|
||||
Text,
|
||||
/// 空体。
|
||||
Empty,
|
||||
}
|
||||
|
||||
impl BodyKind {
|
||||
/// 稳定名(错误文案与 JSONL 事件共用)。
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Json => "json",
|
||||
Self::Html => "html",
|
||||
Self::Text => "text",
|
||||
Self::Empty => "empty",
|
||||
}
|
||||
}
|
||||
|
||||
/// 是否是「拦截页」形状(HTML / 纯文本):这一类先怀疑网络,而不是账号。
|
||||
#[must_use]
|
||||
pub const fn is_interception(self) -> bool {
|
||||
matches!(self, Self::Html | Self::Text)
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次非 2xx 的诊断结果([`AuthError::Response`] 的载荷,也是 JSONL 事件的字段来源)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ResponseDetail {
|
||||
/// 链上的步骤(`xbl` / `xsts` / `login_with_xbox` / `entitlements` / `profile`)。
|
||||
pub step: String,
|
||||
/// 这一步在做什么(人话前缀,如「Minecraft 登录被拒」)。
|
||||
pub what: String,
|
||||
/// HTTP 状态码。
|
||||
pub status: u16,
|
||||
/// `Content-Type`(服务端没给时为 `None`)。
|
||||
pub content_type: Option<String>,
|
||||
/// 正文形状。
|
||||
pub kind: BodyKind,
|
||||
/// 服务端文案(已脱敏、已截断)。
|
||||
pub server_message: Option<String>,
|
||||
/// 正文摘要(已脱敏,≤[`DIGEST_CHARS`] 字符)。
|
||||
pub digest: String,
|
||||
}
|
||||
|
||||
impl ResponseDetail {
|
||||
/// 细分机读码(**只增不改**):JSON 拒绝沿用 M31 的 `<step>_<status>`;
|
||||
/// 非 JSON 用新增后缀 `<step>_non_json`;空体用 `<step>_empty_body`。
|
||||
#[must_use]
|
||||
pub fn detail_code(&self) -> String {
|
||||
let suffix = match self.kind {
|
||||
BodyKind::Json => self.status.to_string(),
|
||||
BodyKind::Html | BodyKind::Text => "non_json".to_string(),
|
||||
BodyKind::Empty => "empty_body".to_string(),
|
||||
};
|
||||
format!("{}_{suffix}", self.step)
|
||||
}
|
||||
|
||||
/// 是否是「疑似网络拦截页」(HTML / 纯文本)。
|
||||
#[must_use]
|
||||
pub const fn is_interception(&self) -> bool {
|
||||
self.kind.is_interception()
|
||||
}
|
||||
|
||||
/// 「;服务端文案…;响应摘要…」片段(都没有时为空串,方便复用文案拼装)。
|
||||
#[must_use]
|
||||
pub fn note(&self) -> String {
|
||||
let mut note = String::new();
|
||||
if let Some(message) = &self.server_message {
|
||||
note.push_str(&format!(";服务端文案「{message}」"));
|
||||
}
|
||||
if !self.digest.is_empty() {
|
||||
note.push_str(&format!(";响应摘要:{}", self.digest));
|
||||
}
|
||||
note
|
||||
}
|
||||
|
||||
/// 人读一句话(**不含凭据**),403 的两种分流在这里分开。
|
||||
#[must_use]
|
||||
pub fn message(&self) -> String {
|
||||
let mut text = format!("{}(HTTP {}", self.what, self.status);
|
||||
if let Some(content_type) = &self.content_type {
|
||||
text.push_str(&format!(",Content-Type: {content_type}"));
|
||||
}
|
||||
text.push(')');
|
||||
match self.kind {
|
||||
BodyKind::Json => text.push_str(":服务端返回了 JSON 拒绝"),
|
||||
BodyKind::Html | BodyKind::Text => {
|
||||
text.push_str(":响应非 JSON(可能网络拦截/网关页)");
|
||||
}
|
||||
BodyKind::Empty => text.push_str(":响应体为空(可能网络拦截/网关页)"),
|
||||
}
|
||||
text.push(';');
|
||||
text.push_str(advice(&self.step, self.kind));
|
||||
text.push_str(&self.note());
|
||||
text
|
||||
}
|
||||
}
|
||||
|
||||
/// 下一步该往哪查(403 分流的核心:拦截页查网络/代理,JSON 拒绝查账号许可)。
|
||||
#[must_use]
|
||||
pub fn advice(step: &str, kind: BodyKind) -> &'static str {
|
||||
if kind.is_interception() {
|
||||
return "请检查网络或代理(关掉/换掉代理后再试,必要时用镜像方案)";
|
||||
}
|
||||
match step {
|
||||
"login_with_xbox" => "若网络正常,请检查该账号的 Minecraft 许可与所在区域",
|
||||
"entitlements" => "若网络正常,请检查该账号是否拥有 Minecraft Java 版",
|
||||
"profile" => "若网络正常,请去 minecraft.net 检查档案状态",
|
||||
_ => "请按服务端文案处理;如反复失败,检查网络代理是否生效",
|
||||
}
|
||||
}
|
||||
|
||||
/// 分类一个非 2xx 应答。
|
||||
pub(crate) fn classify(reply: &Reply) -> BodyKind {
|
||||
if reply.body.is_some() {
|
||||
return BodyKind::Json;
|
||||
}
|
||||
let Some(raw) = reply
|
||||
.raw
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|raw| !raw.is_empty())
|
||||
else {
|
||||
return BodyKind::Empty;
|
||||
};
|
||||
let content_type = reply
|
||||
.content_type
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase();
|
||||
if content_type.contains("html") || looks_like_html(raw) {
|
||||
BodyKind::Html
|
||||
} else {
|
||||
BodyKind::Text
|
||||
}
|
||||
}
|
||||
|
||||
/// 正文是不是 HTML 文档(没给 `Content-Type` 时的兜底判断)。
|
||||
fn looks_like_html(raw: &str) -> bool {
|
||||
raw.trim_start().starts_with('<')
|
||||
}
|
||||
|
||||
/// 组装一条诊断(分类 + 服务端文案 + 摘要)。
|
||||
pub(crate) fn rejection(
|
||||
step: &str,
|
||||
reply: &Reply,
|
||||
what: &str,
|
||||
redactor: &Redactor,
|
||||
) -> ResponseDetail {
|
||||
ResponseDetail {
|
||||
step: step.to_string(),
|
||||
what: what.to_string(),
|
||||
status: reply.status,
|
||||
content_type: reply.content_type.clone(),
|
||||
kind: classify(reply),
|
||||
server_message: server_message(reply, redactor),
|
||||
digest: digest(reply, redactor),
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 JSON 体里挑出服务端文案(去重后以 ` / ` 连接)。
|
||||
fn server_message(reply: &Reply, redactor: &Redactor) -> Option<String> {
|
||||
let body = reply.body.as_ref()?;
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
for key in SERVER_MESSAGE_KEYS {
|
||||
let Some(text) = body.get(key).and_then(Value::as_str) else {
|
||||
continue;
|
||||
};
|
||||
let text = one_line(text);
|
||||
if text.is_empty() || parts.iter().any(|part| part == &text) {
|
||||
continue;
|
||||
}
|
||||
parts.push(text);
|
||||
}
|
||||
if parts.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(truncate(&scrub(&parts.join(" / "), redactor), DIGEST_CHARS))
|
||||
}
|
||||
|
||||
/// 正文摘要:JSON 用紧凑文本,非 JSON 用原文快照;先脱敏再截断。
|
||||
fn digest(reply: &Reply, redactor: &Redactor) -> String {
|
||||
let raw = match (&reply.body, &reply.raw) {
|
||||
(Some(body), _) => body.to_string(),
|
||||
(None, Some(raw)) => raw.to_string(),
|
||||
(None, None) => return String::new(),
|
||||
};
|
||||
truncate(&scrub(&one_line(&raw), redactor), DIGEST_CHARS)
|
||||
}
|
||||
|
||||
/// 走完整套脱敏:[`Redactor::scrub`] 的三条规则(已登记 token + ≥64 不透明串 + 带点 token)。
|
||||
///
|
||||
/// M40 之前这里自己补了一条「带点 token」规则:`Redactor` 只按「连续不透明串 ≥64」判形状,
|
||||
/// 而 JWT(`header.payload.signature`)里有点号,把连续段切短,恰好躲过那条规则——服务端
|
||||
/// 把 token 回显在错误体里时就会原样进摘要。现在规则已上提到 `launch::redact`(全局底座),
|
||||
/// 这里只调共用实现,两处规则不会再漂。
|
||||
fn scrub(text: &str, redactor: &Redactor) -> String {
|
||||
redactor.scrub(text).into_owned()
|
||||
}
|
||||
|
||||
/// 压成一行(HTML 的分行会让错误消息难以阅读)。
|
||||
fn one_line(text: &str) -> String {
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut space = false;
|
||||
for ch in text.chars() {
|
||||
if ch.is_whitespace() {
|
||||
space = !out.is_empty();
|
||||
continue;
|
||||
}
|
||||
if std::mem::take(&mut space) {
|
||||
out.push(' ');
|
||||
}
|
||||
out.push(ch);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 按字符截断(超长补 `…`)。
|
||||
fn truncate(text: &str, limit: usize) -> String {
|
||||
if text.chars().count() <= limit {
|
||||
return text.to_string();
|
||||
}
|
||||
let mut out: String = text.chars().take(limit.saturating_sub(1)).collect();
|
||||
out.push('…');
|
||||
out
|
||||
}
|
||||
|
||||
/// 把诊断包成 [`AuthError::Response`](链上各步骤的统一出口)。
|
||||
pub(crate) fn response(step: &str, reply: &Reply, what: &str, redactor: &Redactor) -> AuthError {
|
||||
AuthError::Response(Box::new(rejection(step, reply, what, redactor)))
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
//! [`super`] 的单测:分类、服务端文案提取、摘要脱敏与 403 分流文案。
|
||||
|
||||
use super::{
|
||||
advice, classify, digest, one_line, response, server_message, truncate, BodyKind, DIGEST_CHARS,
|
||||
};
|
||||
use crate::auth::error::AuthError;
|
||||
use crate::auth::http::Reply;
|
||||
use crate::auth::secret::{redactor_for, Secret};
|
||||
use crate::launch::redact::Redactor;
|
||||
use serde_json::json;
|
||||
|
||||
const JWT: &str = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.c2lnbmF0dXJlLXZhbHVlLXRoYXQtaXMtbG9uZy1lbm91Z2g";
|
||||
|
||||
fn json_reply(status: u16, body: serde_json::Value) -> Reply {
|
||||
Reply {
|
||||
status,
|
||||
body: Some(body),
|
||||
content_type: Some("application/json".to_string()),
|
||||
raw: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn text_reply(status: u16, content_type: Option<&str>, raw: &str) -> Reply {
|
||||
Reply {
|
||||
status,
|
||||
body: None,
|
||||
content_type: content_type.map(str::to_string),
|
||||
raw: Some(raw.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classification_splits_json_html_text_and_empty() {
|
||||
assert_eq!(
|
||||
classify(&json_reply(403, json!({ "error": "x" }))),
|
||||
BodyKind::Json
|
||||
);
|
||||
assert_eq!(
|
||||
classify(&text_reply(403, Some("text/html"), "<!DOCTYPE html>")),
|
||||
BodyKind::Html
|
||||
);
|
||||
// 没给 Content-Type 时靠正文形状兜底。
|
||||
assert_eq!(
|
||||
classify(&text_reply(403, None, "<html><body>blocked")),
|
||||
BodyKind::Html
|
||||
);
|
||||
assert_eq!(
|
||||
classify(&text_reply(502, Some("text/plain"), "Bad Gateway")),
|
||||
BodyKind::Text
|
||||
);
|
||||
assert_eq!(classify(&text_reply(403, None, " ")), BodyKind::Empty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_message_prefers_error_fields_and_dedups() {
|
||||
let redactor = Redactor::new();
|
||||
let reply = json_reply(
|
||||
403,
|
||||
json!({ "error": "UNAUTHORIZED", "errorMessage": "no license", "path": "/x" }),
|
||||
);
|
||||
let message = server_message(&reply, &redactor).expect("应有服务端文案");
|
||||
assert!(message.contains("no license"), "{message}");
|
||||
assert!(message.contains("UNAUTHORIZED"), "{message}");
|
||||
|
||||
let same = json_reply(403, json!({ "error": "DENIED", "errorMessage": "DENIED" }));
|
||||
assert_eq!(server_message(&same, &redactor).as_deref(), Some("DENIED"));
|
||||
assert!(server_message(&json_reply(403, json!({ "path": "/x" })), &redactor).is_none());
|
||||
assert!(server_message(&text_reply(500, None, "boom"), &redactor).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn digest_is_redacted_and_capped() {
|
||||
let token = Secret::new("mc-access-token-value");
|
||||
let redactor = redactor_for([&token]);
|
||||
let reply = json_reply(
|
||||
403,
|
||||
json!({ "error": "DENIED", "echo": format!("Bearer {}", token.expose()) }),
|
||||
);
|
||||
let masked_digest = digest(&reply, &redactor);
|
||||
assert!(
|
||||
!masked_digest.contains("mc-access-token-value"),
|
||||
"{masked_digest}"
|
||||
);
|
||||
|
||||
let long = "blocked ".repeat(60);
|
||||
let capped = digest(&text_reply(500, Some("text/plain"), &long), &redactor);
|
||||
assert!(capped.chars().count() <= DIGEST_CHARS);
|
||||
assert!(capped.ends_with('…'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opaque_runs_are_masked_even_without_known_secret() {
|
||||
let masked = digest(&text_reply(401, Some("text/plain"), JWT), &Redactor::new());
|
||||
assert!(!masked.contains("eyJhbGci"), "{masked}");
|
||||
assert!(masked.contains("redacted"), "{masked}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dotted_tokens_are_masked_by_the_shared_redactor() {
|
||||
// 规则本体在 `launch::redact`(M40 上提),这里只验「auth 摘要走的是同一份实现」。
|
||||
let redactor = Redactor::new();
|
||||
assert!(!redactor.scrub(JWT).contains("eyJhbGci"), "JWT 应被打码");
|
||||
// 普通文案里的点不会触发(分段太短)。
|
||||
assert_eq!(
|
||||
redactor.scrub("Service.Unavailable.today"),
|
||||
"Service.Unavailable.today"
|
||||
);
|
||||
let masked = redactor.scrub(&format!("token={JWT}&end")).into_owned();
|
||||
assert!(!masked.contains("eyJhbGci"), "{masked}");
|
||||
assert!(masked.ends_with("&end"), "{masked}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_line_collapses_whitespace() {
|
||||
assert_eq!(one_line(" a\n\n b\tc "), "a b c");
|
||||
assert_eq!(truncate("abcdef", 4), "abc…");
|
||||
assert_eq!(truncate("abc", 4), "abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detail_codes_and_advice_split_network_from_license() {
|
||||
let redactor = Redactor::new();
|
||||
let blocked = response(
|
||||
"login_with_xbox",
|
||||
&text_reply(403, Some("text/html"), "<html>blocked</html>"),
|
||||
"Minecraft 登录被拒",
|
||||
&redactor,
|
||||
);
|
||||
let AuthError::Response(detail) = &blocked else {
|
||||
panic!("应是 Response,实际 {blocked:?}");
|
||||
};
|
||||
assert_eq!(detail.detail_code(), "login_with_xbox_non_json");
|
||||
assert!(
|
||||
detail.message().contains("网络拦截"),
|
||||
"{}",
|
||||
detail.message()
|
||||
);
|
||||
assert!(detail.message().contains("代理"), "{}", detail.message());
|
||||
|
||||
let denied = response(
|
||||
"login_with_xbox",
|
||||
&json_reply(
|
||||
403,
|
||||
json!({ "error": "UNAUTHORIZED", "errorMessage": "nope" }),
|
||||
),
|
||||
"Minecraft 登录被拒",
|
||||
&redactor,
|
||||
);
|
||||
let AuthError::Response(detail) = &denied else {
|
||||
panic!("应是 Response,实际 {denied:?}");
|
||||
};
|
||||
assert_eq!(detail.detail_code(), "login_with_xbox_403");
|
||||
assert!(detail.message().contains("许可"), "{}", detail.message());
|
||||
assert!(!detail.message().contains("代理"), "{}", detail.message());
|
||||
|
||||
let empty = response(
|
||||
"profile",
|
||||
&text_reply(500, None, ""),
|
||||
"拉取档案被拒",
|
||||
&redactor,
|
||||
);
|
||||
let AuthError::Response(detail) = &empty else {
|
||||
panic!("应是 Response,实际 {empty:?}");
|
||||
};
|
||||
assert_eq!(detail.detail_code(), "profile_empty_body");
|
||||
assert!(!advice("xbl", BodyKind::Html).is_empty());
|
||||
assert!(!advice("xbl", BodyKind::Json).is_empty());
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
//! 认证错误:把 HMCL 的异常树压成「调用方能分流」的一层(`analyse/04` §2.6 / §4.5)。
|
||||
//!
|
||||
//! 三条设计:
|
||||
//! 1. **`CredentialExpired` 是唯一「要人再登录」的信号**(承 HMCL `CredentialExpiredException`),
|
||||
//! 其余错误都不该弹交互;
|
||||
//! 2. **取消 / 超时 / 被拒 三分**(mission 明示的错误分流):用户关窗口 = `Canceled`,
|
||||
//! 设备码 15 分钟硬超时 = `Timeout`,`access_denied` = `Denied`;
|
||||
//! 3. **文案里永不含凭据**:错误只描述发生了什么,token 一律不进 message。
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use crate::auth::body::ResponseDetail;
|
||||
|
||||
/// Xbox Live 错误码:账号被封(`MicrosoftService.BANNED`)。
|
||||
pub const XERR_BANNED: u32 = 2_148_916_227;
|
||||
/// 没有 Xbox 账号(需先建档案)。
|
||||
pub const XERR_MISSING_XBOX_ACCOUNT: u32 = 2_148_916_233;
|
||||
/// 地区不可用。
|
||||
pub const XERR_COUNTRY_UNAVAILABLE: u32 = 2_148_916_235;
|
||||
/// 儿童账号未加入家庭组。
|
||||
pub const XERR_ADD_FAMILY: u32 = 2_148_916_238;
|
||||
|
||||
/// 认证失败。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AuthError {
|
||||
/// 网络层失败(DNS/连接/TLS/超时以外的 IO)。
|
||||
Network {
|
||||
/// 目标 URL(不含 query 里的凭据)。
|
||||
url: String,
|
||||
/// 底层描述。
|
||||
detail: String,
|
||||
},
|
||||
/// 超时:连接超时、整体超时,或设备码流程等满 `expires_in`/15 分钟。
|
||||
Timeout {
|
||||
/// 在等什么。
|
||||
what: String,
|
||||
},
|
||||
/// 用户主动取消(关窗口、Ctrl+C、`access_denied` 也归这里)。
|
||||
Canceled,
|
||||
/// 凭据过期/被撤销,必须重新交互式登录(`AADSTS70000` 族)。
|
||||
CredentialExpired,
|
||||
/// 服务端明确拒绝(OAuth `error` + `error_description`)。
|
||||
Denied {
|
||||
/// OAuth 错误码(`invalid_grant` / `authorization_declined` …)。
|
||||
code: String,
|
||||
/// 服务端描述。
|
||||
description: String,
|
||||
},
|
||||
/// 链上某一步拿到非 2xx,且带回了正文诊断(M38)。
|
||||
///
|
||||
/// 与 [`Self::Denied`] 同一分流层(`code()` 仍是 `denied`,向后兼容 M31 的调用方),
|
||||
/// 细分码看 [`AuthError::detail_code`]:JSON 拒绝沿用 `<step>_<status>`,
|
||||
/// 拦截页(HTML/纯文本)是新增的 `<step>_non_json`,空体是 `<step>_empty_body`。
|
||||
///
|
||||
/// 载荷装箱:`ResponseDetail` 有 7 个字段(含 4 个字符串),不装箱会把整个枚举撑到
|
||||
/// 128 字节以上,`Result<T, AuthError>` 的每个 Ok 也跟着变胖(clippy `result_large_err`)。
|
||||
Response(Box<ResponseDetail>),
|
||||
/// Xbox Live / XSTS 业务错误码(`XErr`)。
|
||||
Xbox {
|
||||
/// `XErr` 数值。
|
||||
code: u32,
|
||||
},
|
||||
/// 响应结构不合法(JSON 缺字段、类型不对、profile 被换掉)。
|
||||
Malformed(String),
|
||||
/// 凭据/元数据落盘或读取失败。
|
||||
Store {
|
||||
/// 涉及的路径。
|
||||
path: String,
|
||||
/// 底层描述。
|
||||
detail: String,
|
||||
},
|
||||
/// 找不到指定账户。
|
||||
NotFound {
|
||||
/// 用户给的查找键。
|
||||
account: String,
|
||||
},
|
||||
/// 账户重复(同名同轨已存在)。
|
||||
Conflict {
|
||||
/// 冲突的账户名。
|
||||
account: String,
|
||||
},
|
||||
/// 该轨/该能力本期未实现(如 CLI 上的 authlib 密码登录)。
|
||||
Unsupported(String),
|
||||
}
|
||||
|
||||
impl AuthError {
|
||||
/// 稳定机读码(CLI `--json` 与日志都用它,不随文案变)。
|
||||
#[must_use]
|
||||
pub const fn code(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Network { .. } => "network",
|
||||
Self::Timeout { .. } => "timeout",
|
||||
Self::Canceled => "canceled",
|
||||
Self::CredentialExpired => "credential_expired",
|
||||
Self::Denied { .. } => "denied",
|
||||
// 与 `Denied` 同一粗分流层:M31 时代的调用方(CLI 提示、launch 身份解析)无需改。
|
||||
Self::Response(_) => "denied",
|
||||
Self::Xbox { .. } => "xbox",
|
||||
Self::Malformed(_) => "malformed",
|
||||
Self::Store { .. } => "store",
|
||||
Self::NotFound { .. } => "not_found",
|
||||
Self::Conflict { .. } => "conflict",
|
||||
Self::Unsupported(_) => "unsupported",
|
||||
}
|
||||
}
|
||||
|
||||
/// 是否是「必须重新登录」的信号(唯一该弹交互的一类)。
|
||||
#[must_use]
|
||||
pub const fn is_credential_expired(&self) -> bool {
|
||||
matches!(self, Self::CredentialExpired)
|
||||
}
|
||||
|
||||
/// 是否属于「网络/服务端抽风,可重试或跳过」的一类。
|
||||
#[must_use]
|
||||
pub const fn is_transient(&self) -> bool {
|
||||
match self {
|
||||
Self::Network { .. } | Self::Timeout { .. } | Self::Xbox { .. } => true,
|
||||
// 拦截页/空体多半是网络侧问题;JSON 拒绝是服务端明确表态,重试无意义。
|
||||
Self::Response(detail) => detail.kind.is_interception(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 细分机读码(M38 新增):OAuth 拒绝返回 `<step>` 里带的错误码,链上非 2xx 返回
|
||||
/// `<step>_<status>` / `<step>_non_json` / `<step>_empty_body`,其余回落到 [`Self::code`]。
|
||||
///
|
||||
/// 与 [`Self::code`] 的分工:`code()` 是**分流层**(决定弹不弹交互、能不能重试),
|
||||
/// 这个方法给日志与 `--json` 的**排障层**用。既有码一律不改,只增。
|
||||
#[must_use]
|
||||
pub fn detail_code(&self) -> String {
|
||||
match self {
|
||||
Self::Response(detail) => detail.detail_code(),
|
||||
Self::Denied { code, .. } => code.clone(),
|
||||
other => other.code().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 网络错误的构造(`url` 只放 host+path,别把 query 里的 code 带进来)。
|
||||
#[must_use]
|
||||
pub fn network(url: impl Into<String>, detail: impl Into<String>) -> Self {
|
||||
Self::Network {
|
||||
url: url.into(),
|
||||
detail: detail.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 落盘错误的构造。
|
||||
#[must_use]
|
||||
pub fn store(path: impl fmt::Display, detail: impl Into<String>) -> Self {
|
||||
Self::Store {
|
||||
path: path.to_string(),
|
||||
detail: detail.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 人读一句话(**不含凭据**)。
|
||||
#[must_use]
|
||||
pub fn message(&self) -> String {
|
||||
match self {
|
||||
Self::Network { url, detail } => format!("网络请求失败({url}):{detail}"),
|
||||
Self::Timeout { what } => format!("等待超时:{what}"),
|
||||
Self::Canceled => "登录已取消".to_string(),
|
||||
Self::CredentialExpired => "凭据已过期,需要重新登录".to_string(),
|
||||
Self::Denied { code, description } => {
|
||||
format!("服务端拒绝({code}):{description}")
|
||||
}
|
||||
Self::Response(detail) => detail.message(),
|
||||
Self::Xbox { code } => format!("Xbox 授权失败(XErr {code}):{}", xbox_hint(*code)),
|
||||
Self::Malformed(detail) => format!("响应不符合预期:{detail}"),
|
||||
Self::Store { path, detail } => format!("账户数据读写失败({path}):{detail}"),
|
||||
Self::NotFound { account } => format!("找不到账户 `{account}`"),
|
||||
Self::Conflict { account } => format!("账户 `{account}` 已存在"),
|
||||
Self::Unsupported(detail) => format!("本期未支持:{detail}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for AuthError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str(&self.message())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for AuthError {}
|
||||
|
||||
/// `XErr` 码 → 中文说明(承 `analyse/04` §5.2 的四码表)。
|
||||
#[must_use]
|
||||
pub fn xbox_hint(code: u32) -> &'static str {
|
||||
match code {
|
||||
XERR_BANNED => "该微软账号已被 Xbox Live 封禁",
|
||||
XERR_MISSING_XBOX_ACCOUNT => "该微软账号还没有 Xbox 档案,请先去 xbox.com 创建",
|
||||
XERR_COUNTRY_UNAVAILABLE => "该账号所在地区不被 Xbox Live 支持",
|
||||
XERR_ADD_FAMILY => "儿童账号需要先加入 Microsoft 家庭组",
|
||||
_ => "Xbox Live 拒绝了本次授权(可能无 Xbox 档案或账号受限)",
|
||||
}
|
||||
}
|
||||
|
||||
/// 结果别名。
|
||||
pub type AuthResult<T> = Result<T, AuthError>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{xbox_hint, AuthError, XERR_ADD_FAMILY};
|
||||
|
||||
#[test]
|
||||
fn codes_are_stable() {
|
||||
assert_eq!(AuthError::Canceled.code(), "canceled");
|
||||
assert_eq!(AuthError::CredentialExpired.code(), "credential_expired");
|
||||
assert!(AuthError::Xbox {
|
||||
code: XERR_ADD_FAMILY
|
||||
}
|
||||
.is_transient());
|
||||
assert!(!AuthError::Canceled.is_transient());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn messages_carry_no_secret_shape() {
|
||||
let error = AuthError::Denied {
|
||||
code: "invalid_grant".to_string(),
|
||||
description: "AADSTS70000: bad redirect".to_string(),
|
||||
};
|
||||
assert!(error.message().contains("AADSTS70000"));
|
||||
assert!(AuthError::Xbox { code: 2148916238 }
|
||||
.message()
|
||||
.contains("家庭组"));
|
||||
assert!(!xbox_hint(0).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_shares_denied_coarse_code_but_has_its_own_detail_code() {
|
||||
let error = AuthError::Response(Box::new(super::ResponseDetail {
|
||||
step: "login_with_xbox".to_string(),
|
||||
what: "Minecraft 登录被拒".to_string(),
|
||||
status: 403,
|
||||
content_type: Some("text/html".to_string()),
|
||||
kind: crate::auth::body::BodyKind::Html,
|
||||
server_message: None,
|
||||
digest: String::new(),
|
||||
}));
|
||||
// 粗分流层向后兼容:仍是 `denied`;排障层才是细分码。
|
||||
assert_eq!(error.code(), "denied");
|
||||
assert_eq!(error.detail_code(), "login_with_xbox_non_json");
|
||||
assert!(error.is_transient(), "拦截页可以重试");
|
||||
assert_eq!(
|
||||
AuthError::Canceled.detail_code(),
|
||||
"canceled",
|
||||
"没有细分的错误回落到 code()"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
//! 认证用的 HTTP 薄封装(三条轨共用):**复用既有 download 引擎的客户端基座**
|
||||
//! (mission Context:网络操作走既有 HTTP 基座),只补三件认证需要的东西:
|
||||
//! 表单/JSON 体、Bearer 头、单请求整体超时。
|
||||
//!
|
||||
//! 三条纪律:
|
||||
//! 1. **响应体不原样进错误文案**——OAuth 的 token 响应体本身就是凭据,把它塞进
|
||||
//! `Malformed` 的 message 就等于把 token 写进日志(`analyse/04` §11 观察项 9 的坑);
|
||||
//! 因此解析失败只说「不是合法 JSON」,贴原文的唯一通道是
|
||||
//! [`crate::auth::body`] 的脱敏摘要(M38:仅非 2xx、≤200 字符、过 Redactor)。
|
||||
//! 2. 状态码不做归类,**原样交给调用方**:XSTS 的 401 带 `XErr`、`/minecraft/profile` 的
|
||||
//! 404 要复判 license,都是「非 200 但有语义」的分支,提前归一化会丢掉信息。
|
||||
//! 3. **诊断留证,但不留凭据**(M38):`Reply` 额外携带 `Content-Type` 与非 JSON 正文的快照
|
||||
//! ([`RAW_PREVIEW_BYTES`] 字节封顶),诊断由 [`crate::auth::body`] 分类/脱敏后再进错误消息。
|
||||
//! 原文快照**只在 JSON 解析失败时**保留,所以 OAuth 成功响应体不会被复制进第二个字段。
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use reqwest::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE};
|
||||
use reqwest::Client;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::auth::error::{AuthError, AuthResult};
|
||||
use crate::auth::secret::Secret;
|
||||
|
||||
/// 单请求整体超时(设备码轮询的每个请求都远快于此)。
|
||||
pub(crate) const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
/// 非 JSON 正文保留的快照上限(字节):够判断「是不是 HTML」并抓前几句文案。
|
||||
pub(crate) const RAW_PREVIEW_BYTES: usize = 512;
|
||||
|
||||
/// 一次应答:状态码 + 可解析的 JSON 体 + 诊断用的头/正文快照。
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct Reply {
|
||||
/// HTTP 状态码。
|
||||
pub status: u16,
|
||||
/// 解析成功时的 JSON 体(空体/非 JSON 为 `None`)。
|
||||
pub body: Option<Value>,
|
||||
/// `Content-Type` 原样(诊断文案与分流判断用;缺失为 `None`)。
|
||||
pub content_type: Option<String>,
|
||||
/// 非 JSON 正文的前 [`RAW_PREVIEW_BYTES`] 字节(有损 UTF-8;JSON 解析成功时为 `None`)。
|
||||
pub raw: Option<String>,
|
||||
}
|
||||
|
||||
impl Reply {
|
||||
/// 是否 2xx。
|
||||
pub(crate) fn is_success(&self) -> bool {
|
||||
(200..300).contains(&self.status)
|
||||
}
|
||||
|
||||
/// 取一个字符串字段。
|
||||
pub(crate) fn text(&self, path: &str) -> Option<&str> {
|
||||
self.body.as_ref()?.get(path)?.as_str()
|
||||
}
|
||||
|
||||
/// 取一个 u64 字段(数字或数字字符串都接受)。
|
||||
pub(crate) fn number(&self, path: &str) -> Option<u64> {
|
||||
let value = self.body.as_ref()?.get(path)?;
|
||||
value
|
||||
.as_u64()
|
||||
.or_else(|| value.as_str().and_then(|raw| raw.trim().parse().ok()))
|
||||
}
|
||||
}
|
||||
|
||||
/// 表单 `POST`(OAuth 的设备码/token 端点)。
|
||||
pub(crate) async fn post_form(
|
||||
client: &Client,
|
||||
url: &str,
|
||||
form: Vec<(&str, String)>,
|
||||
) -> AuthResult<Reply> {
|
||||
let request = client
|
||||
.post(url)
|
||||
.header(ACCEPT, "application/json")
|
||||
.timeout(REQUEST_TIMEOUT)
|
||||
.form(&form);
|
||||
send(request, url).await
|
||||
}
|
||||
|
||||
/// JSON `POST`(XBL / XSTS / login_with_xbox)。
|
||||
pub(crate) async fn post_json(
|
||||
client: &Client,
|
||||
url: &str,
|
||||
body: &Value,
|
||||
bearer: Option<&Secret>,
|
||||
) -> AuthResult<Reply> {
|
||||
let text = serde_json::to_string(body)
|
||||
.map_err(|error| AuthError::Malformed(format!("请求体序列化失败:{error}")))?;
|
||||
let mut request = client
|
||||
.post(url)
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.header(ACCEPT, "application/json")
|
||||
.timeout(REQUEST_TIMEOUT)
|
||||
.body(text);
|
||||
if let Some(token) = bearer {
|
||||
request = request.header(AUTHORIZATION, format!("Bearer {}", token.expose()));
|
||||
}
|
||||
send(request, url).await
|
||||
}
|
||||
|
||||
/// 带 Bearer 的 `GET`(entitlements / profile)。
|
||||
pub(crate) async fn get_json(client: &Client, url: &str, bearer: &Secret) -> AuthResult<Reply> {
|
||||
let request = client
|
||||
.get(url)
|
||||
.header(ACCEPT, "application/json")
|
||||
.header(AUTHORIZATION, format!("Bearer {}", bearer.expose()))
|
||||
.timeout(REQUEST_TIMEOUT);
|
||||
send(request, url).await
|
||||
}
|
||||
|
||||
/// 发请求并解析 JSON(无凭据泄漏的错误映射)。
|
||||
async fn send(request: reqwest::RequestBuilder, url: &str) -> AuthResult<Reply> {
|
||||
let response = request
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| http_error(url, &error))?;
|
||||
let status = response.status().as_u16();
|
||||
let content_type = response
|
||||
.headers()
|
||||
.get(CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::to_string);
|
||||
let bytes = response
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|error| http_error(url, &error))?;
|
||||
let body = if bytes.is_empty() {
|
||||
None
|
||||
} else {
|
||||
serde_json::from_slice::<Value>(&bytes).ok()
|
||||
};
|
||||
// 只有解析失败(HTML 拦截页 / 纯文本 / 坏 JSON)才留原文快照:
|
||||
// 解析成功的响应体可能含 token,不留第二份副本。
|
||||
let raw = match (&body, bytes.is_empty()) {
|
||||
(None, false) => Some(preview(&bytes)),
|
||||
_ => None,
|
||||
};
|
||||
Ok(Reply {
|
||||
status,
|
||||
body,
|
||||
content_type,
|
||||
raw,
|
||||
})
|
||||
}
|
||||
|
||||
/// 取正文前 [`RAW_PREVIEW_BYTES`] 字节(有损解码,截断时补 `…`)。
|
||||
fn preview(bytes: &[u8]) -> String {
|
||||
let truncated = bytes.len() > RAW_PREVIEW_BYTES;
|
||||
let head = &bytes[..bytes.len().min(RAW_PREVIEW_BYTES)];
|
||||
let mut text = String::from_utf8_lossy(head).into_owned();
|
||||
if truncated {
|
||||
text.push('…');
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
/// `reqwest::Error` → [`AuthError`](超时与非超时分开,文案只带 host+path)。
|
||||
fn http_error(url: &str, error: &reqwest::Error) -> AuthError {
|
||||
let target = url.split('?').next().unwrap_or(url).to_string();
|
||||
if error.is_timeout() {
|
||||
AuthError::Timeout {
|
||||
what: format!("请求 {target}"),
|
||||
}
|
||||
} else {
|
||||
AuthError::network(target, error.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{preview, Reply, RAW_PREVIEW_BYTES};
|
||||
use serde_json::json;
|
||||
|
||||
fn reply(status: u16, body: Option<serde_json::Value>) -> Reply {
|
||||
Reply {
|
||||
status,
|
||||
body,
|
||||
content_type: Some("application/json".to_string()),
|
||||
raw: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reply_reads_text_and_numbers() {
|
||||
let sample = reply(
|
||||
200,
|
||||
Some(json!({ "user_code": "ABC", "expires_in": 900, "interval": "5" })),
|
||||
);
|
||||
assert!(sample.is_success());
|
||||
assert_eq!(sample.text("user_code"), Some("ABC"));
|
||||
assert_eq!(sample.number("expires_in"), Some(900));
|
||||
assert_eq!(sample.number("interval"), Some(5));
|
||||
assert_eq!(sample.number("missing"), None);
|
||||
assert_eq!(sample.text("missing"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_success_is_not_success() {
|
||||
assert!(!reply(401, None).is_success());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_caps_and_marks_truncation() {
|
||||
let short = preview(b"Bad Gateway");
|
||||
assert_eq!(short, "Bad Gateway");
|
||||
let long = preview(&vec![b'x'; RAW_PREVIEW_BYTES + 64]);
|
||||
assert!(long.ends_with('…'));
|
||||
assert_eq!(long.trim_end_matches('…').len(), RAW_PREVIEW_BYTES);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
//! auth 失败事件的落盘(`.ULL/logs/auth.jsonl`,M38)。
|
||||
//!
|
||||
//! 起因:MSA 真机验收两轮 403,`ull accounts add msa` 失败后终端只有一行文案,
|
||||
//! 验收人拿不到状态码/正文摘要去对账。这里把**失败**写成一行一条 JSONL:
|
||||
//! 便携模式数据根还没初始化时会先补建 `logs/` 分区,403 的细节不再随终端滚走。
|
||||
//!
|
||||
//! 三条纪律:
|
||||
//! 1. **只记失败**,不记成功(成功路径的凭据载荷一律不落日志);
|
||||
//! 2. **记的是诊断字段**(`code` / `detail_code` / `status` / `content_type` / `body_kind` /
|
||||
//! 已脱敏的 `server_message`、`digest`),没有任何 token/密码字段;
|
||||
//! 3. **落盘是尽力而为**:写日志失败绝不让登录本身失败(调用方忽略返回值)。
|
||||
//!
|
||||
//! 数据根从哪来:默认走 `platform::data_dir` 的 **Portable** 解析(exe 同目录的 `.ULL`,
|
||||
//! 跟随 `redirect.json`)。宿主若用 `--data-dir` 等非便携模式,应显式
|
||||
//! [`AuthJournal::in_logs_dir`] 后交给 [`crate::auth::MicrosoftAuth::with_journal`]。
|
||||
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::auth::body::BodyKind;
|
||||
use crate::auth::error::AuthError;
|
||||
use crate::launch::redact::Redactor;
|
||||
use crate::platform::data_dir::{DataDirRequest, Resolver};
|
||||
use crate::platform::layout::LauncherLayout;
|
||||
use crate::platform::paths::Platform;
|
||||
|
||||
/// 认证失败事件文件名(在 `.ULL/logs/` 下)。
|
||||
pub const AUTH_LOG_FILE: &str = "auth.jsonl";
|
||||
|
||||
/// 一行认证失败事件。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AuthFailureEvent {
|
||||
/// Unix 毫秒。
|
||||
pub ts_ms: u64,
|
||||
/// 触发入口(`device_code_login` / `refresh` / `validate`)。
|
||||
pub scope: String,
|
||||
/// 链上的步骤(`AuthError::Response` 才有)。
|
||||
pub step: Option<String>,
|
||||
/// 粗分流码([`AuthError::code`])。
|
||||
pub code: String,
|
||||
/// 细分码([`AuthError::detail_code`])。
|
||||
pub detail_code: String,
|
||||
/// HTTP 状态码(链上拒绝才有)。
|
||||
pub status: Option<u16>,
|
||||
/// `Content-Type`(链上拒绝才有)。
|
||||
pub content_type: Option<String>,
|
||||
/// 正文形状(链上拒绝才有)。
|
||||
pub body_kind: Option<BodyKind>,
|
||||
/// 服务端文案(已脱敏)。
|
||||
pub server_message: Option<String>,
|
||||
/// 正文摘要(已脱敏,≤200 字符)。
|
||||
pub digest: Option<String>,
|
||||
/// 是否可重试([`AuthError::is_transient`])。
|
||||
pub transient: bool,
|
||||
/// 人读一句话(已脱敏,不含凭据)。
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl AuthFailureEvent {
|
||||
/// 从一条错误构造(`scope` 说明是哪个入口失败的)。
|
||||
#[must_use]
|
||||
pub fn of(scope: &str, error: &AuthError) -> Self {
|
||||
let detail = match error {
|
||||
AuthError::Response(detail) => Some(detail.as_ref()),
|
||||
_ => None,
|
||||
};
|
||||
let scrub = |text: &str| Redactor::new().scrub(text).into_owned();
|
||||
Self {
|
||||
ts_ms: crate::auth::microsoft::now_ms(),
|
||||
scope: scope.to_string(),
|
||||
step: detail.map(|detail| detail.step.clone()),
|
||||
code: error.code().to_string(),
|
||||
detail_code: error.detail_code(),
|
||||
status: detail.map(|detail| detail.status),
|
||||
content_type: detail.and_then(|detail| detail.content_type.clone()),
|
||||
body_kind: detail.map(|detail| detail.kind),
|
||||
server_message: detail
|
||||
.and_then(|detail| detail.server_message.as_deref())
|
||||
.map(scrub),
|
||||
digest: detail.map(|detail| scrub(&detail.digest)),
|
||||
transient: error.is_transient(),
|
||||
message: scrub(&error.message()),
|
||||
}
|
||||
}
|
||||
|
||||
/// JSON 形状(字段名稳定,给验收/排障工具读)。
|
||||
#[must_use]
|
||||
pub fn to_json(&self) -> Value {
|
||||
json!({
|
||||
"ts_ms": self.ts_ms,
|
||||
"level": "error",
|
||||
"event": "auth_failure",
|
||||
"scope": self.scope,
|
||||
"step": self.step,
|
||||
"code": self.code,
|
||||
"detail_code": self.detail_code,
|
||||
"status": self.status,
|
||||
"content_type": self.content_type,
|
||||
"body_kind": self.body_kind.map(BodyKind::as_str),
|
||||
"server_message": self.server_message,
|
||||
"digest": self.digest,
|
||||
"transient": self.transient,
|
||||
"message": self.message,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 认证失败日志(JSONL,一行一条)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AuthJournal {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl AuthJournal {
|
||||
/// 落在某个 `logs/` 分区下(`<logs>/auth.jsonl`)。
|
||||
#[must_use]
|
||||
pub fn in_logs_dir(logs: impl Into<PathBuf>) -> Self {
|
||||
Self {
|
||||
path: logs.into().join(AUTH_LOG_FILE),
|
||||
}
|
||||
}
|
||||
|
||||
/// 直接指定文件路径。
|
||||
#[must_use]
|
||||
pub fn at(path: impl Into<PathBuf>) -> Self {
|
||||
Self { path: path.into() }
|
||||
}
|
||||
|
||||
/// 便携模式默认位置(exe 同目录 `.ULL/logs/auth.jsonl`,跟随 `redirect.json`)。
|
||||
///
|
||||
/// 拿不到 exe 目录(极少数嵌入场景)时返回 `None`——没有可写的日志根就不写。
|
||||
#[must_use]
|
||||
pub fn portable_default() -> Option<Self> {
|
||||
let exe_dir = std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|path| path.parent().map(Path::to_path_buf))?;
|
||||
let resolver = Resolver::new(Platform::current(), Some(exe_dir));
|
||||
let resolved = resolver.resolve_default(&DataDirRequest::default()).ok()?;
|
||||
Some(Self::in_logs_dir(
|
||||
LauncherLayout::new(resolved.launcher_home).logs(),
|
||||
))
|
||||
}
|
||||
|
||||
/// 日志文件路径。
|
||||
#[must_use]
|
||||
pub fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
|
||||
/// 追加一条事件(父目录不存在时先建)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 建目录或追加失败;调用方按「尽力而为」处理即可。
|
||||
pub fn record(&self, event: &AuthFailureEvent) -> std::io::Result<()> {
|
||||
if let Some(parent) = self.path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let mut line = serde_json::to_string(&event.to_json()).unwrap_or_default();
|
||||
line.push('\n');
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&self.path)?;
|
||||
file.write_all(line.as_bytes())?;
|
||||
file.flush()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{AuthFailureEvent, AuthJournal};
|
||||
use crate::auth::body::{BodyKind, ResponseDetail};
|
||||
use crate::auth::error::AuthError;
|
||||
|
||||
fn temp_dir(tag: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"ull-auth-journal-{tag}-{}-{:?}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|since| since.as_nanos())
|
||||
.unwrap_or(0)
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("建临时目录");
|
||||
dir
|
||||
}
|
||||
|
||||
fn blocked_error() -> AuthError {
|
||||
AuthError::Response(Box::new(ResponseDetail {
|
||||
step: "login_with_xbox".to_string(),
|
||||
what: "Minecraft 登录被拒".to_string(),
|
||||
status: 403,
|
||||
content_type: Some("text/html".to_string()),
|
||||
kind: BodyKind::Html,
|
||||
server_message: None,
|
||||
digest: "<html>blocked</html>".to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_carries_structured_diagnostics() {
|
||||
let event = AuthFailureEvent::of("device_code_login", &blocked_error());
|
||||
assert_eq!(event.scope, "device_code_login");
|
||||
assert_eq!(event.step.as_deref(), Some("login_with_xbox"));
|
||||
assert_eq!(event.code, "denied");
|
||||
assert_eq!(event.detail_code, "login_with_xbox_non_json");
|
||||
assert_eq!(event.status, Some(403));
|
||||
assert_eq!(event.content_type.as_deref(), Some("text/html"));
|
||||
assert_eq!(event.body_kind, Some(BodyKind::Html));
|
||||
assert!(event.transient);
|
||||
let value = event.to_json();
|
||||
assert_eq!(value["event"], "auth_failure");
|
||||
assert_eq!(value["level"], "error");
|
||||
assert_eq!(value["body_kind"], "html");
|
||||
assert!(value["message"]
|
||||
.as_str()
|
||||
.is_some_and(|text| !text.is_empty()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_chain_errors_still_record() {
|
||||
let event = AuthFailureEvent::of("refresh", &AuthError::Canceled);
|
||||
assert_eq!(event.detail_code, "canceled");
|
||||
assert_eq!(event.step, None);
|
||||
assert_eq!(event.status, None);
|
||||
assert!(!event.transient);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_initializes_the_logs_dir_and_appends_one_line() {
|
||||
let root = temp_dir("init");
|
||||
let logs = root.join("logs");
|
||||
let journal = AuthJournal::in_logs_dir(&logs);
|
||||
assert!(!logs.exists(), "初始不该存在(模拟未初始化的数据根)");
|
||||
journal
|
||||
.record(&AuthFailureEvent::of("device_code_login", &blocked_error()))
|
||||
.expect("写日志");
|
||||
journal
|
||||
.record(&AuthFailureEvent::of("refresh", &AuthError::Canceled))
|
||||
.expect("写日志");
|
||||
let text = std::fs::read_to_string(journal.path()).expect("读日志");
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
assert_eq!(lines.len(), 2, "一行一条:{text}");
|
||||
for line in lines {
|
||||
let value: serde_json::Value = serde_json::from_str(line).expect("每行都是合法 JSON");
|
||||
assert_eq!(value["event"], "auth_failure");
|
||||
}
|
||||
// 日志里不该出现任何凭据字段名。
|
||||
for forbidden in ["access_token", "refresh_token", "password", "token"] {
|
||||
assert!(!text.contains(forbidden), "日志出现了 {forbidden}:{text}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn journal_paths_are_layout_based() {
|
||||
let journal = AuthJournal::in_logs_dir("/tmp/ull-logs");
|
||||
assert!(journal.path().ends_with("auth.jsonl"));
|
||||
let explicit = AuthJournal::at("/tmp/x/custom.jsonl");
|
||||
assert_eq!(explicit.path(), std::path::Path::new("/tmp/x/custom.jsonl"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
//! 微软五步链:Live token → **XBL** → **XSTS** → **login_with_xbox** → **entitlements** → **profile**。
|
||||
//!
|
||||
//! 逐条承 `analyse/04` §5.2(报文级别),四处容易抄错的地方都在注释里点名:
|
||||
//! 1. XBL 的 `RpsTicket` 必须带 **`d=` 前缀**(MSA 消费版票据标记);
|
||||
//! 2. `identityToken` 是 **`XBL3.0 x=<uhs>;<xsts>`**(分号分隔,`x` 后紧跟 `=`);
|
||||
//! 3. XSTS 在「无 Xbox 档案/儿童账号/地区不支持」时回 **401**(不是 200+JSON),
|
||||
//! 必须读 body 里的 `XErr` 才能给出准确原因;
|
||||
//! 4. `/minecraft/profile` 的 **404 二义**:没买游戏 vs 买了没建档案,
|
||||
//! 要用 `entitlements/license` 复判一次才分得清。
|
||||
//!
|
||||
//! **XSTS/MC 令牌不缓存**:刷新就是整链重跑(`analyse/04` §5.1),所以本模块只做无状态串联。
|
||||
|
||||
use reqwest::Client;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::auth::body;
|
||||
use crate::auth::error::AuthError;
|
||||
use crate::auth::secret::{redactor_for, Secret};
|
||||
|
||||
use super::endpoints::MicrosoftEndpoints;
|
||||
use super::flow::OAuthTokens;
|
||||
use super::session::MicrosoftSession;
|
||||
use crate::auth::http::{get_json, post_json, Reply};
|
||||
|
||||
/// Xbox Live 用户票据 + 用户哈希。
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct XboxTicket {
|
||||
/// JWT 票据(凭据形状)。
|
||||
pub token: Secret,
|
||||
/// `uhs`(`XBL3.0 x=<uhs>;...` 的 userhash;本身不是凭据但只在本链内部用)。
|
||||
pub uhs: String,
|
||||
}
|
||||
|
||||
/// MC 侧换到的令牌。
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct MinecraftToken {
|
||||
/// MC access token(落盘)。
|
||||
pub access_token: Secret,
|
||||
/// 有效期(秒)。
|
||||
pub expires_in: u64,
|
||||
}
|
||||
|
||||
/// Minecraft 档案(不可变 id + 可变 name)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct GameProfile {
|
||||
/// 32 位无连字符 UUID。
|
||||
pub id: String,
|
||||
/// 玩家名。
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// 第 1 步:Xbox Live User Authenticate。
|
||||
pub(crate) async fn xbl_authenticate(
|
||||
client: &Client,
|
||||
endpoints: &MicrosoftEndpoints,
|
||||
live_access_token: &Secret,
|
||||
) -> Result<XboxTicket, AuthError> {
|
||||
let body = json!({
|
||||
"Properties": {
|
||||
"AuthMethod": "RPS",
|
||||
"SiteName": "user.auth.xboxlive.com",
|
||||
"RpsTicket": format!("d={}", live_access_token.expose()),
|
||||
},
|
||||
"RelyingParty": "http://auth.xboxlive.com",
|
||||
"TokenType": "JWT",
|
||||
});
|
||||
let reply = post_json(client, &endpoints.xbl_authenticate, &body, None).await?;
|
||||
if let Some(code) = xerr(&reply) {
|
||||
return Err(AuthError::Xbox { code });
|
||||
}
|
||||
if !reply.is_success() {
|
||||
// M38:XErr 优先(业务码才是真原因),没有 XErr 时把正文诊断补进文案。
|
||||
return Err(body::response(
|
||||
"xbl",
|
||||
&reply,
|
||||
"Xbox Live 用户认证被拒",
|
||||
&redactor_for([live_access_token]),
|
||||
));
|
||||
}
|
||||
let token = reply
|
||||
.text("Token")
|
||||
.ok_or_else(|| AuthError::Malformed("XBL 响应缺 Token".to_string()))?;
|
||||
let uhs = uhs_of(&reply)
|
||||
.ok_or_else(|| AuthError::Malformed("XBL 响应缺 DisplayClaims.xui[0].uhs".to_string()))?;
|
||||
Ok(XboxTicket {
|
||||
token: Secret::new(token),
|
||||
uhs,
|
||||
})
|
||||
}
|
||||
|
||||
/// 第 2 步:XSTS Authorize(401 也读 body,`XErr` 才是真原因)。
|
||||
pub(crate) async fn xsts_authorize(
|
||||
client: &Client,
|
||||
endpoints: &MicrosoftEndpoints,
|
||||
xbl: &XboxTicket,
|
||||
) -> Result<XboxTicket, AuthError> {
|
||||
let body = json!({
|
||||
"Properties": { "SandboxId": "RETAIL", "UserTokens": [xbl.token.expose()] },
|
||||
"RelyingParty": "rp://api.minecraftservices.com/",
|
||||
"TokenType": "JWT",
|
||||
});
|
||||
let reply = post_json(client, &endpoints.xsts_authorize, &body, None).await?;
|
||||
if let Some(code) = xerr(&reply) {
|
||||
return Err(AuthError::Xbox { code });
|
||||
}
|
||||
if !reply.is_success() {
|
||||
return Err(body::response(
|
||||
"xsts",
|
||||
&reply,
|
||||
"XSTS 授权被拒",
|
||||
&redactor_for([&xbl.token]),
|
||||
));
|
||||
}
|
||||
let token = reply
|
||||
.text("Token")
|
||||
.ok_or_else(|| AuthError::Malformed("XSTS 响应缺 Token".to_string()))?;
|
||||
let uhs = uhs_of(&reply).ok_or_else(|| AuthError::Malformed("XSTS 响应缺 uhs".to_string()))?;
|
||||
// 一致性断言:同一账号在 XBL 与 XSTS 两次返回的 userhash 必须相同。
|
||||
if uhs != xbl.uhs {
|
||||
return Err(AuthError::Malformed(
|
||||
"XBL 与 XSTS 返回的 userhash 不一致".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(XboxTicket {
|
||||
token: Secret::new(token),
|
||||
uhs,
|
||||
})
|
||||
}
|
||||
|
||||
/// 第 3 步:`login_with_xbox` 换 Minecraft 令牌。
|
||||
pub(crate) async fn login_with_xbox(
|
||||
client: &Client,
|
||||
endpoints: &MicrosoftEndpoints,
|
||||
xsts: &XboxTicket,
|
||||
) -> Result<MinecraftToken, AuthError> {
|
||||
let body = json!({
|
||||
"identityToken": format!("XBL3.0 x={};{}", xsts.uhs, xsts.token.expose()),
|
||||
});
|
||||
let reply = post_json(client, &endpoints.login_with_xbox, &body, None).await?;
|
||||
if !reply.is_success() {
|
||||
// M38 的核心一行:403 到底是「拦截页」还是「服务端拒绝」,文案要分开引导。
|
||||
return Err(body::response(
|
||||
"login_with_xbox",
|
||||
&reply,
|
||||
"Minecraft 登录被拒",
|
||||
&redactor_for([&xsts.token]),
|
||||
));
|
||||
}
|
||||
let access_token = reply
|
||||
.text("access_token")
|
||||
.ok_or_else(|| AuthError::Malformed("login_with_xbox 缺 access_token".to_string()))?;
|
||||
Ok(MinecraftToken {
|
||||
access_token: Secret::new(access_token),
|
||||
expires_in: reply.number("expires_in").unwrap_or(0),
|
||||
})
|
||||
}
|
||||
|
||||
/// 第 4 步:拥有权校验(必须 200,否则后面启动时也会被服务端踢)。
|
||||
pub(crate) async fn check_entitlements(
|
||||
client: &Client,
|
||||
endpoints: &MicrosoftEndpoints,
|
||||
mc_token: &Secret,
|
||||
) -> Result<(), AuthError> {
|
||||
let reply = get_json(client, &endpoints.entitlements_mcstore, mc_token).await?;
|
||||
if reply.is_success() {
|
||||
return Ok(());
|
||||
}
|
||||
let detail = body::rejection(
|
||||
"entitlements",
|
||||
&reply,
|
||||
"拥有权校验未通过",
|
||||
&redactor_for([mc_token]),
|
||||
);
|
||||
// 拦截页(HTML/纯文本)与非 404 的空体先当网络问题报,别让用户去查许可;
|
||||
// JSON 拒绝与 404 沿用 M31 的 `no_entitlement`(稳定码不改)。
|
||||
let interception = detail.kind.is_interception()
|
||||
|| (detail.kind == body::BodyKind::Empty && reply.status != 404);
|
||||
if interception {
|
||||
return Err(AuthError::Response(Box::new(detail)));
|
||||
}
|
||||
let what = if reply.status == 404 {
|
||||
"该账号没有 Minecraft Java 版拥有权"
|
||||
} else {
|
||||
detail.what.as_str()
|
||||
};
|
||||
Err(AuthError::Denied {
|
||||
code: "no_entitlement".to_string(),
|
||||
description: format!(
|
||||
"{what}(entitlements/mcstore 返回 HTTP {}){}",
|
||||
reply.status,
|
||||
detail.note()
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
/// 第 5 步:拉档案(404 用 license 复判二义)。
|
||||
pub(crate) async fn fetch_profile(
|
||||
client: &Client,
|
||||
endpoints: &MicrosoftEndpoints,
|
||||
mc_token: &Secret,
|
||||
) -> Result<GameProfile, AuthError> {
|
||||
let reply = get_json(client, &endpoints.profile, mc_token).await?;
|
||||
// 只有「像服务端应答」的 404 才走二义复判:HTML/纯文本的 404 是网关页,
|
||||
// 拿它去复判 license 会得出错误的「没买游戏」结论。
|
||||
if reply.status == 404 && !body::classify(&reply).is_interception() {
|
||||
return Err(profile_missing(client, endpoints, mc_token).await);
|
||||
}
|
||||
if !reply.is_success() {
|
||||
return Err(body::response(
|
||||
"profile",
|
||||
&reply,
|
||||
"拉取 Minecraft 档案被拒",
|
||||
&redactor_for([mc_token]),
|
||||
));
|
||||
}
|
||||
let id = reply
|
||||
.text("id")
|
||||
.ok_or_else(|| AuthError::Malformed("档案响应缺 id".to_string()))?;
|
||||
let name = reply
|
||||
.text("name")
|
||||
.ok_or_else(|| AuthError::Malformed("档案响应缺 name".to_string()))?;
|
||||
Ok(GameProfile {
|
||||
id: id.to_string(),
|
||||
name: name.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// 404 之后的二义消解:没买游戏 vs 买了没建档案。
|
||||
async fn profile_missing(
|
||||
client: &Client,
|
||||
endpoints: &MicrosoftEndpoints,
|
||||
mc_token: &Secret,
|
||||
) -> AuthError {
|
||||
let reply = match get_json(client, &endpoints.entitlements_license, mc_token).await {
|
||||
Ok(reply) => reply,
|
||||
// 复判本身失败时,退回「档案不存在」这个更保守的说法。
|
||||
Err(_) => {
|
||||
return AuthError::Denied {
|
||||
code: "no_profile".to_string(),
|
||||
description: "该账号没有 Minecraft 档案(复判 license 也失败)".to_string(),
|
||||
}
|
||||
}
|
||||
};
|
||||
if has_minecraft_license(&reply) {
|
||||
AuthError::Denied {
|
||||
code: "no_profile".to_string(),
|
||||
description: "账号已拥有 Minecraft,但还没有创建档案(去 minecraft.net 起个名字)"
|
||||
.to_string(),
|
||||
}
|
||||
} else {
|
||||
AuthError::Denied {
|
||||
code: "no_license".to_string(),
|
||||
description: "该账号没有购买 Minecraft Java 版".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `entitlements/license` 响应里是否有 `game_minecraft`。
|
||||
fn has_minecraft_license(reply: &Reply) -> bool {
|
||||
reply
|
||||
.body
|
||||
.as_ref()
|
||||
.and_then(|body| body.get("items"))
|
||||
.and_then(|items| items.as_array())
|
||||
.is_some_and(|items| {
|
||||
items.iter().any(|item| {
|
||||
item.get("name")
|
||||
.and_then(|name| name.as_str())
|
||||
.is_some_and(|name| name == "game_minecraft")
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// 整链:Live token → [`MicrosoftSession`](刷新与新登录共用这一段)。
|
||||
pub(crate) async fn complete_chain(
|
||||
client: &Client,
|
||||
endpoints: &MicrosoftEndpoints,
|
||||
tokens: OAuthTokens,
|
||||
now_ms: u64,
|
||||
) -> Result<MicrosoftSession, AuthError> {
|
||||
let xbl = xbl_authenticate(client, endpoints, &tokens.access_token).await?;
|
||||
let xsts = xsts_authorize(client, endpoints, &xbl).await?;
|
||||
let mc = login_with_xbox(client, endpoints, &xsts).await?;
|
||||
check_entitlements(client, endpoints, &mc.access_token).await?;
|
||||
let profile = fetch_profile(client, endpoints, &mc.access_token).await?;
|
||||
Ok(MicrosoftSession::new(
|
||||
profile.id,
|
||||
profile.name,
|
||||
mc.access_token,
|
||||
tokens.refresh_token,
|
||||
mc.expires_in,
|
||||
now_ms,
|
||||
None,
|
||||
))
|
||||
}
|
||||
|
||||
/// 从响应里取 `XErr`(XSTS 的 401 业务码)。
|
||||
fn xerr(reply: &Reply) -> Option<u32> {
|
||||
let body = reply.body.as_ref()?;
|
||||
for key in ["XErr", "errorCode"] {
|
||||
if let Some(code) = body.get(key).and_then(|value| value.as_u64()) {
|
||||
if code != 0 {
|
||||
return Some(code as u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn uhs_of(reply: &Reply) -> Option<String> {
|
||||
reply
|
||||
.body
|
||||
.as_ref()?
|
||||
.get("DisplayClaims")?
|
||||
.get("xui")?
|
||||
.as_array()?
|
||||
.first()?
|
||||
.get("uhs")?
|
||||
.as_str()
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{has_minecraft_license, uhs_of, xerr, Reply};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
fn reply(status: u16, body: Option<Value>) -> Reply {
|
||||
Reply {
|
||||
status,
|
||||
body,
|
||||
content_type: Some("application/json".to_string()),
|
||||
raw: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xerr_is_read_from_401_body() {
|
||||
let sample = reply(
|
||||
401,
|
||||
Some(json!({ "Identity": "0", "XErr": 2_148_916_238_u32, "Message": "" })),
|
||||
);
|
||||
assert_eq!(xerr(&sample), Some(2_148_916_238));
|
||||
assert_eq!(xerr(&reply(200, Some(json!({ "XErr": 0 })))), None);
|
||||
assert_eq!(xerr(&reply(200, None)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uhs_comes_from_first_xui_entry() {
|
||||
let sample = reply(
|
||||
200,
|
||||
Some(json!({ "DisplayClaims": { "xui": [{ "uhs": "1234" }] } })),
|
||||
);
|
||||
assert_eq!(uhs_of(&sample).as_deref(), Some("1234"));
|
||||
assert_eq!(
|
||||
uhs_of(&reply(200, Some(json!({ "DisplayClaims": {} })))),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn license_detection() {
|
||||
let with = reply(
|
||||
200,
|
||||
Some(
|
||||
json!({ "items": [{ "name": "product_minecraft" }, { "name": "game_minecraft" }] }),
|
||||
),
|
||||
);
|
||||
let without = reply(
|
||||
200,
|
||||
Some(json!({ "items": [{ "name": "product_minecraft" }] })),
|
||||
);
|
||||
assert!(has_minecraft_license(&with));
|
||||
assert!(!has_minecraft_license(&without));
|
||||
assert!(!has_minecraft_license(&reply(500, None)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
//! 微软端点表:**直连**(不走镜像,承 mission Context 与 `analyse/04` §4 的端点族区分)。
|
||||
//!
|
||||
//! 两个端点族不能混用(`analyse/04` §4 的「第一个坑」):
|
||||
//! - 设备码流:`login.microsoftonline.com/consumers/oauth2/v2.0/*`;
|
||||
//! - HMCL 的浏览器授权码流走 `login.live.com`(本期不实现,故不在此表)。
|
||||
//!
|
||||
//! 端点集中成一张表是为了**测试可替换**:整表指向一个进程内 mock 服务器即可覆盖全链,
|
||||
//! 生产默认走 [`MicrosoftEndpoints::consumers`]。
|
||||
|
||||
/// 微软账户 OAuth 与 Xbox / Minecraft 服务端点。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MicrosoftEndpoints {
|
||||
/// 设备码申请(`POST`,表单 `client_id` + `scope`)。
|
||||
pub device_code: String,
|
||||
/// 换 token / 刷新 token(`POST`,表单)。
|
||||
pub token: String,
|
||||
/// Xbox Live 用户认证(`POST` JSON)。
|
||||
pub xbl_authenticate: String,
|
||||
/// XSTS 授权(`POST` JSON;无 Xbox 档案时回 **401** 带 `XErr`)。
|
||||
pub xsts_authorize: String,
|
||||
/// `login_with_xbox`(`POST` JSON,`identityToken = "XBL3.0 x=<uhs>;<xsts>"`)。
|
||||
pub login_with_xbox: String,
|
||||
/// 拥有权校验 `entitlements/mcstore`(`GET`,必须 200)。
|
||||
pub entitlements_mcstore: String,
|
||||
/// 拥有权细分 `entitlements/license`(`GET`,`/minecraft/profile` 404 时复判用)。
|
||||
pub entitlements_license: String,
|
||||
/// 档案 `minecraft/profile`(`GET`,带 Bearer)。
|
||||
pub profile: String,
|
||||
}
|
||||
|
||||
impl MicrosoftEndpoints {
|
||||
/// 生产端点(微软消费版账户,直连)。
|
||||
#[must_use]
|
||||
pub fn consumers() -> Self {
|
||||
Self {
|
||||
device_code: "https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode"
|
||||
.to_string(),
|
||||
token: "https://login.microsoftonline.com/consumers/oauth2/v2.0/token".to_string(),
|
||||
xbl_authenticate: "https://user.auth.xboxlive.com/user/authenticate".to_string(),
|
||||
xsts_authorize: "https://xsts.auth.xboxlive.com/xsts/authorize".to_string(),
|
||||
login_with_xbox: "https://api.minecraftservices.com/authentication/login_with_xbox"
|
||||
.to_string(),
|
||||
entitlements_mcstore: "https://api.minecraftservices.com/entitlements/mcstore"
|
||||
.to_string(),
|
||||
entitlements_license: "https://api.minecraftservices.com/entitlements/license"
|
||||
.to_string(),
|
||||
profile: "https://api.minecraftservices.com/minecraft/profile".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 把整表指向同一个基址(测试用;各端点用自己的路径后缀区分)。
|
||||
#[must_use]
|
||||
pub fn all_on(base: &str) -> Self {
|
||||
let join = |path: &str| format!("{}/{path}", base.trim_end_matches('/'));
|
||||
Self {
|
||||
device_code: join("devicecode"),
|
||||
token: join("token"),
|
||||
xbl_authenticate: join("xbl"),
|
||||
xsts_authorize: join("xsts"),
|
||||
login_with_xbox: join("login_with_xbox"),
|
||||
entitlements_mcstore: join("entitlements/mcstore"),
|
||||
entitlements_license: join("entitlements/license"),
|
||||
profile: join("profile"),
|
||||
}
|
||||
}
|
||||
|
||||
/// 所有端点(调试/诊断用)。
|
||||
#[must_use]
|
||||
pub fn all(&self) -> [&str; 8] {
|
||||
[
|
||||
&self.device_code,
|
||||
&self.token,
|
||||
&self.xbl_authenticate,
|
||||
&self.xsts_authorize,
|
||||
&self.login_with_xbox,
|
||||
&self.entitlements_mcstore,
|
||||
&self.entitlements_license,
|
||||
&self.profile,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for MicrosoftEndpoints {
|
||||
fn default() -> Self {
|
||||
Self::consumers()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::MicrosoftEndpoints;
|
||||
|
||||
#[test]
|
||||
fn consumers_endpoints_stay_on_two_families() {
|
||||
let endpoints = MicrosoftEndpoints::consumers();
|
||||
assert!(endpoints.device_code.contains("login.microsoftonline.com"));
|
||||
assert!(endpoints.token.contains("/consumers/oauth2/v2.0/token"));
|
||||
assert!(endpoints
|
||||
.xbl_authenticate
|
||||
.contains("user.auth.xboxlive.com"));
|
||||
assert!(endpoints.xsts_authorize.contains("xsts.auth.xboxlive.com"));
|
||||
// 授权码流的 login.live.com 不在这张表里(本期不实现)。
|
||||
assert!(endpoints
|
||||
.all()
|
||||
.iter()
|
||||
.all(|url| !url.contains("login.live.com")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_on_keeps_paths_distinct() {
|
||||
let endpoints = MicrosoftEndpoints::all_on("http://127.0.0.1:1234/");
|
||||
assert_eq!(endpoints.device_code, "http://127.0.0.1:1234/devicecode");
|
||||
assert_eq!(
|
||||
endpoints.entitlements_license,
|
||||
"http://127.0.0.1:1234/entitlements/license"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
//! device-code 流:发起(拿 `user_code` + `verification_uri`)→ 轮询换 token →(刷新同构)。
|
||||
//!
|
||||
//! 逐条承 `analyse/04` §4.3 / §4.4 / §4.5:
|
||||
//! - **硬超时**:只等 `min(expires_in, 900s)`,防止后台任务永久挂起;
|
||||
//! - **`slow_down` 是流程内状态**,不是失败:轮询间隔 +5 秒继续;
|
||||
//! - **每次轮询前先 sleep**(别密集打服务端);
|
||||
//! - **refresh 同时返回新 refresh_token**,调用方必须整体覆盖(轮换);
|
||||
//! - 收紧 HMCL 的观察项 1:**只要 `error` 非空就失败**,不再「描述为空就静默放行」。
|
||||
//!
|
||||
//! 打印 `user_code`/URL 不在这里:core 不做 UI,宿主拿 [`DeviceCodePrompt`] 自己决定
|
||||
//! (CLI 打到 stderr,GUI 弹对话框 + 二维码)。
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use reqwest::Client;
|
||||
|
||||
use crate::auth::error::{AuthError, AuthResult};
|
||||
use crate::auth::secret::Secret;
|
||||
|
||||
use super::endpoints::MicrosoftEndpoints;
|
||||
use crate::auth::http::{post_form, Reply};
|
||||
|
||||
/// 申请的 scope(`XboxLive.signin` + 长期刷新)。
|
||||
pub const SCOPE: &str = "XboxLive.signin offline_access";
|
||||
/// 设备码流程的硬上限:无论服务端给多长,最多等这么久。
|
||||
pub const DEVICE_CODE_MAX_WAIT: Duration = Duration::from_secs(900);
|
||||
/// 默认轮询间隔下限(服务端 `interval` 更小时按下限走)。
|
||||
pub const DEFAULT_MIN_INTERVAL: Duration = Duration::from_secs(1);
|
||||
/// `slow_down` 的退避步长(承 HMCL:+5 秒)。
|
||||
pub const DEFAULT_SLOW_DOWN_STEP: Duration = Duration::from_secs(5);
|
||||
/// device_code grant 类型。
|
||||
pub const DEVICE_CODE_GRANT: &str = "urn:ietf:params:oauth:grant-type:device_code";
|
||||
|
||||
/// 轮询策略(生产用 [`PollPolicy::default`],测试用小间隔跑同一套状态机)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct PollPolicy {
|
||||
/// 轮询间隔下限。
|
||||
pub min_interval: Duration,
|
||||
/// `slow_down` 的退避步长。
|
||||
pub slow_down_step: Duration,
|
||||
/// 整个流程最多等多久(还会与 `expires_in` 取小)。
|
||||
pub max_wait: Duration,
|
||||
}
|
||||
|
||||
impl Default for PollPolicy {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
min_interval: DEFAULT_MIN_INTERVAL,
|
||||
slow_down_step: DEFAULT_SLOW_DOWN_STEP,
|
||||
max_wait: DEVICE_CODE_MAX_WAIT,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 要展示给用户的信息(URL + 用户码)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DeviceCodePrompt {
|
||||
/// 用户在验证页输入的短码(如 `ABCD1234`)。**不是凭据**,可打印。
|
||||
pub user_code: String,
|
||||
/// 验证地址(`https://www.microsoft.com/link` 等)。
|
||||
pub verification_uri: String,
|
||||
/// 服务端给的有效期。
|
||||
pub expires_in: Duration,
|
||||
/// 服务端建议的轮询间隔。
|
||||
pub interval: Duration,
|
||||
}
|
||||
|
||||
impl DeviceCodePrompt {
|
||||
/// 人读两行(CLI 打到 stderr,GUI 直接展示)。
|
||||
#[must_use]
|
||||
pub fn lines(&self) -> (String, String) {
|
||||
(
|
||||
format!("请在任意设备打开 {} 并输入代码:", self.verification_uri),
|
||||
format!(
|
||||
"用户码 {}({} 分钟内有效)",
|
||||
self.user_code,
|
||||
self.expires_in.as_secs() / 60
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 设备码申请的中间态(`device_code` 是凭据形状,不进日志)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct DeviceCodeGrant {
|
||||
/// 展示信息。
|
||||
pub prompt: DeviceCodePrompt,
|
||||
/// 轮询用的长码(脱敏 Debug,永不打印)。
|
||||
pub device_code: Secret,
|
||||
}
|
||||
|
||||
/// OAuth 换到的 token 对。
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct OAuthTokens {
|
||||
/// Live access token(只在链内用,不落盘)。
|
||||
pub access_token: Secret,
|
||||
/// Live refresh token(落盘、轮换)。
|
||||
pub refresh_token: Secret,
|
||||
}
|
||||
|
||||
/// 申请设备码。
|
||||
pub(crate) async fn request_device_code(
|
||||
client: &Client,
|
||||
endpoints: &MicrosoftEndpoints,
|
||||
client_id: &str,
|
||||
) -> AuthResult<DeviceCodeGrant> {
|
||||
let reply = post_form(
|
||||
client,
|
||||
&endpoints.device_code,
|
||||
vec![
|
||||
("client_id", client_id.to_string()),
|
||||
("scope", SCOPE.to_string()),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
if !reply.is_success() {
|
||||
return Err(oauth_error(&reply, &endpoints.device_code));
|
||||
}
|
||||
let user_code = reply
|
||||
.text("user_code")
|
||||
.ok_or_else(|| AuthError::Malformed("设备码响应缺 user_code".to_string()))?;
|
||||
let device_code = reply
|
||||
.text("device_code")
|
||||
.ok_or_else(|| AuthError::Malformed("设备码响应缺 device_code".to_string()))?;
|
||||
let verification_uri = reply
|
||||
.text("verification_uri")
|
||||
.or_else(|| reply.text("verification_url"))
|
||||
.ok_or_else(|| AuthError::Malformed("设备码响应缺 verification_uri".to_string()))?;
|
||||
let expires_in = reply.number("expires_in").unwrap_or(900).max(1);
|
||||
// 服务端间隔原样收下,下限交给 [`PollPolicy::min_interval`](生产 1 秒,测试可压到毫秒)。
|
||||
let interval = reply.number("interval").unwrap_or(5);
|
||||
Ok(DeviceCodeGrant {
|
||||
prompt: DeviceCodePrompt {
|
||||
user_code: user_code.to_string(),
|
||||
verification_uri: verification_uri.to_string(),
|
||||
expires_in: Duration::from_secs(expires_in),
|
||||
interval: Duration::from_secs(interval),
|
||||
},
|
||||
device_code: Secret::new(device_code),
|
||||
})
|
||||
}
|
||||
|
||||
/// 轮询直到用户完成授权(或超时/被拒/取消)。
|
||||
pub(crate) async fn poll_device_token(
|
||||
client: &Client,
|
||||
endpoints: &MicrosoftEndpoints,
|
||||
client_id: &str,
|
||||
grant: &DeviceCodeGrant,
|
||||
policy: PollPolicy,
|
||||
) -> AuthResult<OAuthTokens> {
|
||||
let limit = grant.prompt.expires_in.min(policy.max_wait);
|
||||
let mut interval = grant.prompt.interval.max(policy.min_interval);
|
||||
let started = Instant::now();
|
||||
loop {
|
||||
if started.elapsed() >= limit {
|
||||
return Err(AuthError::Timeout {
|
||||
what: format!("等待设备码授权(上限 {} 秒)", limit.as_secs()),
|
||||
});
|
||||
}
|
||||
tokio::time::sleep(interval).await;
|
||||
let reply = post_form(
|
||||
client,
|
||||
&endpoints.token,
|
||||
vec![
|
||||
("client_id", client_id.to_string()),
|
||||
("grant_type", DEVICE_CODE_GRANT.to_string()),
|
||||
("code", grant.device_code.expose().to_string()),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Some(error) = reply.text("error") {
|
||||
match error {
|
||||
"authorization_pending" => continue,
|
||||
// 服务端压力反馈:不是失败,退避后继续(承 HMCL)。
|
||||
"slow_down" => {
|
||||
interval += policy.slow_down_step;
|
||||
continue;
|
||||
}
|
||||
"expired_token" => {
|
||||
return Err(AuthError::Timeout {
|
||||
what: "设备码已过期(需重新发起登录)".to_string(),
|
||||
})
|
||||
}
|
||||
"access_denied" | "authorization_declined" => return Err(AuthError::Canceled),
|
||||
_ => return Err(oauth_error(&reply, &endpoints.token)),
|
||||
}
|
||||
}
|
||||
return tokens_from(&reply, &endpoints.token);
|
||||
}
|
||||
}
|
||||
|
||||
/// 用 refresh_token 换新的 token 对(**刷新也会返回新 refresh_token**)。
|
||||
pub(crate) async fn refresh_tokens(
|
||||
client: &Client,
|
||||
endpoints: &MicrosoftEndpoints,
|
||||
client_id: &str,
|
||||
refresh_token: &Secret,
|
||||
) -> AuthResult<OAuthTokens> {
|
||||
let reply = post_form(
|
||||
client,
|
||||
&endpoints.token,
|
||||
vec![
|
||||
("client_id", client_id.to_string()),
|
||||
("grant_type", "refresh_token".to_string()),
|
||||
("refresh_token", refresh_token.expose().to_string()),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
if let Some(error) = reply.text("error") {
|
||||
// `invalid_grant` + `AADSTS70000` = 授权被撤销/过期 → 唯一该重新登录的信号。
|
||||
if error == "invalid_grant" {
|
||||
let description = reply.text("error_description").unwrap_or("");
|
||||
if description.contains("AADSTS70000") {
|
||||
return Err(AuthError::CredentialExpired);
|
||||
}
|
||||
}
|
||||
return Err(oauth_error(&reply, &endpoints.token));
|
||||
}
|
||||
if !reply.is_success() {
|
||||
return Err(oauth_error(&reply, &endpoints.token));
|
||||
}
|
||||
tokens_from(&reply, &endpoints.token)
|
||||
}
|
||||
|
||||
fn tokens_from(reply: &Reply, url: &str) -> AuthResult<OAuthTokens> {
|
||||
let access_token = reply
|
||||
.text("access_token")
|
||||
.ok_or_else(|| AuthError::Malformed(format!("{url} 响应缺 access_token")))?;
|
||||
let refresh_token = reply
|
||||
.text("refresh_token")
|
||||
.ok_or_else(|| AuthError::Malformed(format!("{url} 响应缺 refresh_token")))?;
|
||||
Ok(OAuthTokens {
|
||||
access_token: Secret::new(access_token),
|
||||
refresh_token: Secret::new(refresh_token),
|
||||
})
|
||||
}
|
||||
|
||||
/// OAuth 错误归一:**`error` 非空即失败**(HMCL 只在两个字段都非空时处理,我们收紧)。
|
||||
fn oauth_error(reply: &Reply, url: &str) -> AuthError {
|
||||
let Some(code) = reply.text("error") else {
|
||||
return AuthError::network(
|
||||
url.split('?').next().unwrap_or(url),
|
||||
format!("HTTP {}(响应不是 OAuth 错误体)", reply.status),
|
||||
);
|
||||
};
|
||||
let description = reply
|
||||
.text("error_description")
|
||||
.unwrap_or("服务端未给描述")
|
||||
.to_string();
|
||||
if code == "invalid_grant" && description.contains("AADSTS70000") {
|
||||
return AuthError::CredentialExpired;
|
||||
}
|
||||
AuthError::Denied {
|
||||
code: code.to_string(),
|
||||
description,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{oauth_error, PollPolicy, DEFAULT_MIN_INTERVAL, DEVICE_CODE_MAX_WAIT};
|
||||
use crate::auth::error::AuthError;
|
||||
use crate::auth::http::Reply;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn default_policy_matches_hmcl_bounds() {
|
||||
let policy = PollPolicy::default();
|
||||
assert_eq!(policy.min_interval, DEFAULT_MIN_INTERVAL);
|
||||
assert_eq!(policy.max_wait, DEVICE_CODE_MAX_WAIT);
|
||||
assert_eq!(policy.max_wait.as_secs(), 900);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_without_description_still_fails() {
|
||||
let reply = reply(400, Some(json!({ "error": "invalid_request" })));
|
||||
match oauth_error(&reply, "https://example.invalid/token") {
|
||||
AuthError::Denied { code, .. } => assert_eq!(code, "invalid_request"),
|
||||
other => panic!("应是 Denied,实际 {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aadsts70000_becomes_credential_expired() {
|
||||
let reply = reply(
|
||||
400,
|
||||
Some(json!({
|
||||
"error": "invalid_grant",
|
||||
"error_description": "AADSTS70000: The grant is expired or revoked"
|
||||
})),
|
||||
);
|
||||
assert!(oauth_error(&reply, "https://example.invalid/token").is_credential_expired());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_oauth_body_is_network_error() {
|
||||
let reply = reply(500, None);
|
||||
assert_eq!(
|
||||
oauth_error(&reply, "https://example.invalid/token").code(),
|
||||
"network"
|
||||
);
|
||||
}
|
||||
|
||||
fn reply(status: u16, body: Option<serde_json::Value>) -> Reply {
|
||||
Reply {
|
||||
status,
|
||||
body,
|
||||
content_type: Some("application/json".to_string()),
|
||||
raw: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
//! 微软轨:device-code 全流程 + 会话/刷新语义(`PlanDoc/07-accounts.md` §1、`analyse/04` §4–§5)。
|
||||
//!
|
||||
//! 分四块,各管一件事:
|
||||
//! - [`session`]:`MicrosoftSession`(凭据载荷)与**刷新判定**(纯函数,可脱离网络单测);
|
||||
//! - [`endpoints`]:端点表(生产 = 微软消费版直连;测试 = 整表指向 mock);
|
||||
//! - [`flow`]:device-code 发起 / 轮询 / refresh(OAuth 层);
|
||||
//! - [`chain`]:XBL → XSTS → login_with_xbox → entitlements → profile(Xbox/MC 层)。
|
||||
//!
|
||||
//! # client id 从哪来
|
||||
//!
|
||||
//! device-code 流必须带一个**已在 Azure 注册的公共客户端 id**。HMCL 把它烘进 jar 的
|
||||
//! manifest(`hmcl.microsoft.auth.id`),我们同理:二进制里不硬编码,改由
|
||||
//! `ULL_MSA_CLIENT_ID`(运行期)或构建期 `option_env!` 提供。没有配时
|
||||
//! [`MicrosoftAuth::from_env`] 返回 [`AuthError::Unsupported`] 并说清要配什么——
|
||||
//! 这比编一个假 id 让用户看到 `invalid_client` 诚实。
|
||||
|
||||
pub mod chain;
|
||||
pub mod endpoints;
|
||||
pub mod flow;
|
||||
|
||||
pub mod session;
|
||||
|
||||
pub use endpoints::MicrosoftEndpoints;
|
||||
pub use flow::{DeviceCodePrompt, PollPolicy};
|
||||
pub use session::{MicrosoftSession, RefreshDecision, RefreshReason, USER_TYPE_MSA};
|
||||
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use reqwest::Client;
|
||||
|
||||
use crate::auth::error::{AuthError, AuthResult};
|
||||
use crate::auth::journal::{AuthFailureEvent, AuthJournal};
|
||||
use crate::download::config::EngineConfig;
|
||||
use crate::download::http::build_client;
|
||||
|
||||
/// 运行期环境变量名(配 client id)。
|
||||
pub const CLIENT_ID_ENV: &str = "ULL_MSA_CLIENT_ID";
|
||||
/// 构建期可注入的 client id(`ULL_MSA_CLIENT_ID=... cargo build`)。
|
||||
pub const CLIENT_ID_BUILD: Option<&str> = option_env!("ULL_MSA_CLIENT_ID");
|
||||
|
||||
/// 微软账户登录/刷新。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MicrosoftAuth {
|
||||
client: Client,
|
||||
client_id: String,
|
||||
endpoints: MicrosoftEndpoints,
|
||||
poll: PollPolicy,
|
||||
journal: Option<AuthJournal>,
|
||||
}
|
||||
|
||||
impl MicrosoftAuth {
|
||||
/// 按显式 client id 构造(HTTP 基座复用 `core::download` 的客户端构造)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// HTTP 客户端建不起来时返回 [`AuthError::Network`]。
|
||||
pub fn new(client_id: impl Into<String>) -> AuthResult<Self> {
|
||||
let client = build_client(&EngineConfig::default())
|
||||
.map_err(|error| AuthError::network("http-client", error.to_string()))?;
|
||||
Ok(Self {
|
||||
client,
|
||||
client_id: client_id.into(),
|
||||
endpoints: MicrosoftEndpoints::consumers(),
|
||||
poll: PollPolicy::default(),
|
||||
journal: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// 从环境/构建期取 client id(CLI 与 GUI 的默认入口)。
|
||||
///
|
||||
/// 与 [`Self::new`] 的唯一差别:这条**生产入口**默认挂上便携数据根的失败日志
|
||||
/// (`.ULL/logs/auth.jsonl`,见 [`AuthJournal::portable_default`]),
|
||||
/// 交互式命令失败时 403 的状态码/正文摘要不会再随终端滚走。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 两处都没配时返回 [`AuthError::Unsupported`]。
|
||||
pub fn from_env() -> AuthResult<Self> {
|
||||
let client_id = std::env::var(CLIENT_ID_ENV)
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.or_else(|| CLIENT_ID_BUILD.map(str::to_string))
|
||||
.ok_or_else(|| {
|
||||
AuthError::Unsupported(format!(
|
||||
"微软登录需要 Azure 公共客户端 id:设置环境变量 {CLIENT_ID_ENV}(或构建时注入)"
|
||||
))
|
||||
})?;
|
||||
let mut auth = Self::new(client_id)?;
|
||||
auth.journal = AuthJournal::portable_default();
|
||||
Ok(auth)
|
||||
}
|
||||
|
||||
/// 换失败日志落点(`None` = 不落盘;CI/单测用)。
|
||||
#[must_use]
|
||||
pub fn with_journal(mut self, journal: Option<AuthJournal>) -> Self {
|
||||
self.journal = journal;
|
||||
self
|
||||
}
|
||||
|
||||
/// 关掉失败日志。
|
||||
#[must_use]
|
||||
pub fn without_journal(mut self) -> Self {
|
||||
self.journal = None;
|
||||
self
|
||||
}
|
||||
|
||||
/// 失败日志落点(测试断言/宿主展示用)。
|
||||
#[must_use]
|
||||
pub fn journal(&self) -> Option<&AuthJournal> {
|
||||
self.journal.as_ref()
|
||||
}
|
||||
|
||||
/// 换 HTTP 客户端(测试注入 / 复用宿主连接池)。
|
||||
#[must_use]
|
||||
pub fn with_client(mut self, client: Client) -> Self {
|
||||
self.client = client;
|
||||
self
|
||||
}
|
||||
|
||||
/// 换端点表(测试整表指向 mock 服务器)。
|
||||
#[must_use]
|
||||
pub fn with_endpoints(mut self, endpoints: MicrosoftEndpoints) -> Self {
|
||||
self.endpoints = endpoints;
|
||||
self
|
||||
}
|
||||
|
||||
/// 换轮询策略(测试用小间隔)。
|
||||
#[must_use]
|
||||
pub fn with_poll_policy(mut self, poll: PollPolicy) -> Self {
|
||||
self.poll = poll;
|
||||
self
|
||||
}
|
||||
|
||||
/// 当前端点表。
|
||||
#[must_use]
|
||||
pub fn endpoints(&self) -> &MicrosoftEndpoints {
|
||||
&self.endpoints
|
||||
}
|
||||
|
||||
/// device-code 登录全流程。
|
||||
///
|
||||
/// `present` 在拿到用户码后**立刻**被调用(CLI 打到 stderr、GUI 弹框/二维码),
|
||||
/// 然后才开始轮询——用户有整整 `expires_in` 的时间去另一台设备上输码。
|
||||
///
|
||||
/// 失败时除返回错误外,还会往失败日志(若配了)追加一条结构化记录。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 网络/超时/取消/被拒/无拥有权/无档案,见 [`AuthError`]。
|
||||
pub async fn device_code_login<F>(&self, mut present: F) -> AuthResult<MicrosoftSession>
|
||||
where
|
||||
F: FnMut(&DeviceCodePrompt),
|
||||
{
|
||||
let result = self.login_and_chain(&mut present).await;
|
||||
if let Err(error) = &result {
|
||||
self.record_failure("device_code_login", error);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// 登录主体(`device_code_login` 的失败日志由外层统一记)。
|
||||
async fn login_and_chain(
|
||||
&self,
|
||||
present: &mut impl FnMut(&DeviceCodePrompt),
|
||||
) -> AuthResult<MicrosoftSession> {
|
||||
let grant =
|
||||
flow::request_device_code(&self.client, &self.endpoints, &self.client_id).await?;
|
||||
present(&grant.prompt);
|
||||
let tokens = flow::poll_device_token(
|
||||
&self.client,
|
||||
&self.endpoints,
|
||||
&self.client_id,
|
||||
&grant,
|
||||
self.poll,
|
||||
)
|
||||
.await?;
|
||||
chain::complete_chain(&self.client, &self.endpoints, tokens, now_ms()).await
|
||||
}
|
||||
|
||||
/// 刷新:Live refresh → 整链重跑(**原样覆盖 refresh_token**)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 凭据被撤销时返回 [`AuthError::CredentialExpired`](唯一该重新交互的信号)。
|
||||
pub async fn refresh(&self, session: &MicrosoftSession) -> AuthResult<MicrosoftSession> {
|
||||
let result = self.refresh_and_chain(session).await;
|
||||
if let Err(error) = &result {
|
||||
self.record_failure("refresh", error);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// 刷新主体(失败日志由外层统一记)。
|
||||
async fn refresh_and_chain(&self, session: &MicrosoftSession) -> AuthResult<MicrosoftSession> {
|
||||
let tokens = flow::refresh_tokens(
|
||||
&self.client,
|
||||
&self.endpoints,
|
||||
&self.client_id,
|
||||
&session.refresh_token,
|
||||
)
|
||||
.await?;
|
||||
let refreshed =
|
||||
chain::complete_chain(&self.client, &self.endpoints, tokens, now_ms()).await?;
|
||||
if !session.same_profile(&refreshed) {
|
||||
// 防「刷出来是另一个账号」(承 HMCL WrongAccountException)。
|
||||
return Err(AuthError::Malformed(format!(
|
||||
"刷新后档案变了({} → {})",
|
||||
session.profile_id, refreshed.profile_id
|
||||
)));
|
||||
}
|
||||
Ok(refreshed)
|
||||
}
|
||||
|
||||
/// 尽力而为地记一条失败事件(写日志失败绝不影响登录本身的结论)。
|
||||
fn record_failure(&self, scope: &str, error: &AuthError) {
|
||||
if let Some(journal) = &self.journal {
|
||||
let _ = journal.record(&AuthFailureEvent::of(scope, error));
|
||||
}
|
||||
}
|
||||
|
||||
/// 网络侧校验:`GET /minecraft/profile` 是否还认这个 token。
|
||||
///
|
||||
/// 404 的二义在这里**不吞**:档案被删是真实错误,不该被当成「token 失效」静默刷新
|
||||
/// (`analyse/04` §11 观察项 3 那个隐式约定,我们写明)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 网络失败、无拥有权、无档案等。
|
||||
pub async fn validate(&self, session: &MicrosoftSession) -> AuthResult<bool> {
|
||||
if session.is_expired(now_ms()) {
|
||||
return Ok(false);
|
||||
}
|
||||
match chain::fetch_profile(&self.client, &self.endpoints, &session.access_token).await {
|
||||
Ok(_) => Ok(true),
|
||||
// 「档案被删 / 没买游戏」是真实错误,穿透上抛(别静默当成 token 失效去刷新)。
|
||||
Err(error @ AuthError::Denied { .. }) => {
|
||||
let fatal = matches!(
|
||||
&error,
|
||||
AuthError::Denied { code, .. } if code == "no_profile" || code == "no_license"
|
||||
);
|
||||
if fatal {
|
||||
Err(error)
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
// 链上非 2xx:拦截页(HTML/纯文本)说明网络有问题,别伪装成「token 失效」;
|
||||
// 服务端的 JSON 拒绝 / 空体仍按「token 不被接受」处理(与 M31 语义一致)。
|
||||
Err(error @ AuthError::Response(_)) => {
|
||||
let interception = matches!(
|
||||
&error,
|
||||
AuthError::Response(detail) if detail.is_interception()
|
||||
);
|
||||
if interception {
|
||||
Err(error)
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
Err(other) => Err(other),
|
||||
}
|
||||
}
|
||||
|
||||
/// 需要就刷新(本地判定,不打网络),返回 `(会话, 是否刷新过)`。
|
||||
///
|
||||
/// 这是 GUI/CLI 的「静默续期」入口:`Reuse` 时零网络,`Refresh` 时整链重跑。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 刷新失败时原样上抛(含 [`AuthError::CredentialExpired`])。
|
||||
pub async fn ensure_fresh(
|
||||
&self,
|
||||
session: &MicrosoftSession,
|
||||
) -> AuthResult<(MicrosoftSession, Option<RefreshReason>)> {
|
||||
match session.refresh_decision(now_ms()) {
|
||||
RefreshDecision::Reuse => Ok((session.clone(), None)),
|
||||
RefreshDecision::Refresh(reason) => {
|
||||
let refreshed = self.refresh(session).await?;
|
||||
Ok((refreshed, Some(reason)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前 Unix 毫秒。
|
||||
#[must_use]
|
||||
pub fn now_ms() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_millis() as u64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{now_ms, MicrosoftAuth, CLIENT_ID_ENV};
|
||||
use crate::auth::error::AuthError;
|
||||
|
||||
#[test]
|
||||
fn explicit_client_id_builds() {
|
||||
let auth = MicrosoftAuth::new("0000-test-client").expect("构造");
|
||||
assert!(auth
|
||||
.endpoints()
|
||||
.profile
|
||||
.contains("api.minecraftservices.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_client_id_is_explained() {
|
||||
// 测试进程里也可能被外部配了环境变量,先摘掉再验。
|
||||
let saved = std::env::var(CLIENT_ID_ENV).ok();
|
||||
std::env::remove_var(CLIENT_ID_ENV);
|
||||
let result = MicrosoftAuth::from_env();
|
||||
if let Some(value) = saved {
|
||||
std::env::set_var(CLIENT_ID_ENV, value);
|
||||
}
|
||||
match result {
|
||||
Err(AuthError::Unsupported(message)) => assert!(message.contains(CLIENT_ID_ENV)),
|
||||
// 构建期注入过 client id 时唯一合法,跳过断言。
|
||||
Ok(_) => assert!(super::CLIENT_ID_BUILD.is_some()),
|
||||
Err(other) => panic!("应是 Unsupported,实际 {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn now_ms_is_sane() {
|
||||
// 2020-01-01 之后、2100 之前(防止本地时钟/单位抄错)。
|
||||
let now = now_ms();
|
||||
assert!(now > 1_577_836_800_000);
|
||||
assert!(now < 4_102_444_800_000);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
//! 微软会话与刷新语义:**HMCL 三层判定的类型化版本**(`analyse/04` §5.3 / §3.5 / §8.4)。
|
||||
//!
|
||||
//! 三层判定原样平移:
|
||||
//! 1. `has_profile_name()`:没有玩家名就没法启动(`--username` 不能为空);
|
||||
//! 2. `not_after`:本地时间戳过期即视为未登录(省一次网络);
|
||||
//! 3. 进程内 `authenticated` 标记归宿主(GUI/CLI)持有,不在本类型里。
|
||||
//!
|
||||
//! 关键语义(照 HMCL,别自己发明):
|
||||
//! - 存下来的 `refresh_token` 是 **Live(MSA)** 的,`access_token` 是 **Minecraft** 的;
|
||||
//! - 刷新 = 整条链重跑(Live refresh → XBL → XSTS → login_with_xbox → entitlements → profile),
|
||||
//! 不存在「只刷 MC token」的捷径;
|
||||
//! - `profile_name` 属于**可变的私有缓存**(2022 年后允许改名),元数据里只留不可变的 profileID。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::auth::error::{AuthError, AuthResult};
|
||||
use crate::auth::secret::Secret;
|
||||
use crate::launch::request::AuthPlaceholders;
|
||||
|
||||
/// 微软账户的 launch 用户类型(`AuthInfo.USER_TYPE_MSA`)。
|
||||
pub const USER_TYPE_MSA: &str = "msa";
|
||||
|
||||
/// 一次成功登录后的会话(= 该账户的凭据载荷)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MicrosoftSession {
|
||||
/// Minecraft 档案 UUID(32 位无连字符)。**不可变**。
|
||||
pub profile_id: String,
|
||||
/// 玩家名(可空串:磁盘恢复允许「有档案但名字未知」,登录时补齐)。
|
||||
#[serde(default)]
|
||||
pub profile_name: String,
|
||||
/// MC token 类型(`Bearer`)。
|
||||
pub token_type: String,
|
||||
/// Minecraft access token。
|
||||
pub access_token: Secret,
|
||||
/// Live refresh token(轮换:每次刷新都会被新值整体覆盖)。
|
||||
pub refresh_token: Secret,
|
||||
/// MC token 过期时刻(Unix 毫秒;0 = 未知 → 强制刷新)。
|
||||
pub not_after_ms: u64,
|
||||
/// Xbox user id(`XUID`,诊断/排障用,非凭据)。
|
||||
pub user_id: Option<String>,
|
||||
}
|
||||
|
||||
/// 过期判定的结论。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RefreshDecision {
|
||||
/// 本地认为可用(有玩家名且未过期):直接复用,不打网络。
|
||||
Reuse,
|
||||
/// 需要整链重跑。
|
||||
Refresh(RefreshReason),
|
||||
}
|
||||
|
||||
/// 为什么要刷新。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RefreshReason {
|
||||
/// 玩家名缺失(改名或历史档案)——启动参数拿不到 `--username`。
|
||||
MissingProfileName,
|
||||
/// 本地时间戳已过期。
|
||||
Expired,
|
||||
/// 名字缺失**且**过期(两条理由都成立)。
|
||||
MissingProfileNameAndExpired,
|
||||
}
|
||||
|
||||
impl RefreshReason {
|
||||
/// 人读一行。
|
||||
#[must_use]
|
||||
pub const fn message(self) -> &'static str {
|
||||
match self {
|
||||
Self::MissingProfileName => "会话缺少玩家名,需要刷新",
|
||||
Self::Expired => "Minecraft 令牌已过期,需要刷新",
|
||||
Self::MissingProfileNameAndExpired => "会话缺少玩家名且令牌已过期,需要刷新",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MicrosoftSession {
|
||||
/// 构造(`expires_in` 是秒,`now_ms` 是当前 Unix 毫秒)。
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
profile_id: impl Into<String>,
|
||||
profile_name: impl Into<String>,
|
||||
access_token: Secret,
|
||||
refresh_token: Secret,
|
||||
expires_in_secs: u64,
|
||||
now_ms: u64,
|
||||
user_id: Option<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
profile_id: profile_id.into(),
|
||||
profile_name: profile_name.into(),
|
||||
token_type: "Bearer".to_string(),
|
||||
access_token,
|
||||
refresh_token,
|
||||
not_after_ms: now_ms.saturating_add(expires_in_secs.saturating_mul(1000)),
|
||||
user_id,
|
||||
}
|
||||
}
|
||||
|
||||
/// 有没有玩家名(空串/全空白都算没有)。
|
||||
#[must_use]
|
||||
pub fn has_profile_name(&self) -> bool {
|
||||
!self.profile_name.trim().is_empty()
|
||||
}
|
||||
|
||||
/// 本地是否过期(`not_after_ms == 0` 视为过期,承 HMCL「坏数据强制刷新」)。
|
||||
#[must_use]
|
||||
pub fn is_expired(&self, now_ms: u64) -> bool {
|
||||
self.not_after_ms == 0 || now_ms >= self.not_after_ms
|
||||
}
|
||||
|
||||
/// 刷新判定(纯函数,可单测;不打网络)。
|
||||
#[must_use]
|
||||
pub fn refresh_decision(&self, now_ms: u64) -> RefreshDecision {
|
||||
match (self.has_profile_name(), self.is_expired(now_ms)) {
|
||||
(true, false) => RefreshDecision::Reuse,
|
||||
(false, false) => RefreshDecision::Refresh(RefreshReason::MissingProfileName),
|
||||
(true, true) => RefreshDecision::Refresh(RefreshReason::Expired),
|
||||
(false, true) => RefreshDecision::Refresh(RefreshReason::MissingProfileNameAndExpired),
|
||||
}
|
||||
}
|
||||
|
||||
/// 该会话所有凭据值(武装脱敏器用)。
|
||||
#[must_use]
|
||||
pub fn secrets(&self) -> Vec<&Secret> {
|
||||
vec![&self.access_token, &self.refresh_token]
|
||||
}
|
||||
|
||||
/// 转成 launch 的占位符集合(`user_properties` 为 `{}`,承 HMCL)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 会话缺少玩家名/UUID 时返回 [`AuthError::CredentialExpired`]——此时必须刷新,
|
||||
/// 不能拿空名字去拼命令行(`--username` 空串会导致游戏直接退出)。
|
||||
pub fn to_placeholders(&self) -> AuthResult<AuthPlaceholders> {
|
||||
if !self.has_profile_name() {
|
||||
return Err(AuthError::CredentialExpired);
|
||||
}
|
||||
if self.profile_id.is_empty() {
|
||||
return Err(AuthError::Malformed("会话缺少 profileID".to_string()));
|
||||
}
|
||||
Ok(AuthPlaceholders {
|
||||
player_name: self.profile_name.clone(),
|
||||
uuid: self.profile_id.clone(),
|
||||
access_token: self.access_token.expose().to_string(),
|
||||
user_type: USER_TYPE_MSA.to_string(),
|
||||
user_properties: "{}".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// 刷新后守住两条不变量(`analyse/04` §5.3 的 `WrongAccountException` / `hasProfileName`)。
|
||||
///
|
||||
/// 返回 `false` 表示「刷出来的不是同一个档案」,调用方必须当错误处理。
|
||||
#[must_use]
|
||||
pub fn same_profile(&self, other: &Self) -> bool {
|
||||
self.profile_id == other.profile_id
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{MicrosoftSession, RefreshDecision, RefreshReason};
|
||||
use crate::auth::secret::Secret;
|
||||
|
||||
fn session(name: &str, not_after_ms: u64) -> MicrosoftSession {
|
||||
MicrosoftSession {
|
||||
profile_id: "0f8fad5bd9cb469fa16570867728950e".to_string(),
|
||||
profile_name: name.to_string(),
|
||||
token_type: "Bearer".to_string(),
|
||||
access_token: Secret::new("mc-token-value"),
|
||||
refresh_token: Secret::new("live-refresh-value"),
|
||||
not_after_ms,
|
||||
user_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decision_matrix() {
|
||||
let now = 1_000_000;
|
||||
assert_eq!(
|
||||
session("Steve", now + 1).refresh_decision(now),
|
||||
RefreshDecision::Reuse
|
||||
);
|
||||
assert_eq!(
|
||||
session("", now + 1).refresh_decision(now),
|
||||
RefreshDecision::Refresh(RefreshReason::MissingProfileName)
|
||||
);
|
||||
assert_eq!(
|
||||
session("Steve", now - 1).refresh_decision(now),
|
||||
RefreshDecision::Refresh(RefreshReason::Expired)
|
||||
);
|
||||
assert_eq!(
|
||||
session(" ", now).refresh_decision(now),
|
||||
RefreshDecision::Refresh(RefreshReason::MissingProfileNameAndExpired)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_not_after_forces_refresh() {
|
||||
assert_eq!(
|
||||
session("Steve", 0).refresh_decision(5),
|
||||
RefreshDecision::Refresh(RefreshReason::Expired)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_computes_not_after_from_expires_in() {
|
||||
let built = MicrosoftSession::new(
|
||||
"uuid",
|
||||
"Steve",
|
||||
Secret::new("a"),
|
||||
Secret::new("r"),
|
||||
86_400,
|
||||
1_000,
|
||||
None,
|
||||
);
|
||||
assert_eq!(built.not_after_ms, 1_000 + 86_400_000);
|
||||
assert_eq!(built.token_type, "Bearer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placeholders_need_a_name() {
|
||||
assert!(session("", 10).to_placeholders().is_err());
|
||||
let placeholders = session("Steve", 10).to_placeholders().expect("占位符");
|
||||
assert_eq!(placeholders.player_name, "Steve");
|
||||
assert_eq!(placeholders.user_type, "msa");
|
||||
assert_eq!(placeholders.user_properties, "{}");
|
||||
// token 进了占位符(启动需要),但它自己的 Debug 是脱敏的。
|
||||
assert_eq!(placeholders.access_token, "mc-token-value");
|
||||
assert!(!format!("{:?}", session("Steve", 10)).contains("mc-token-value"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
//! 账户体系:三轨(离线 UUID / 微软 device-code / authlib-injector)、凭据存储边界。
|
||||
//!
|
||||
//! 设计定稿:`DevDocs/PlanDoc/07-accounts.md`(决策 D9/Q21),协议细节来自
|
||||
//! `DevDocs/analyse/04-authentication.md`(HMCL v3.17 拆解,逐报文对照)。
|
||||
//!
|
||||
//! # 三条铁律
|
||||
//!
|
||||
//! 1. **凭据永不外泄**:token/密码不落日志、不进事件流、不打印(`AGENTS.md` §6、D4)。
|
||||
//! 类型层保证:[`Secret`] 的 `Debug`/`Display` 恒为 `<redacted>`,取明文只能 `.expose()`;
|
||||
//! 对外展示一律走元数据([`AccountMeta`],不含凭据)。
|
||||
//! 2. **账户操作永不 YOLO**:本模块不向 AI 暴露任何账户读写面(07 §3);
|
||||
//! CLI 也只开 `list / add offline / add msa / remove` 四条(authlib 密码登录走 GUI 输入框,
|
||||
//! 避免密码进 argv)。
|
||||
//! 3. **元数据 / 凭据分文件**:`data/auth/accounts.json` + `data/auth/credentials.dat`,
|
||||
//! 先写凭据后写元数据,原子写,单条坏记录只跳过不整体失败。
|
||||
//!
|
||||
//! # 公共面
|
||||
//!
|
||||
//! - [`account`]:`AccountId` / `AccountKind` / `AccountMeta` / `Credentials`(三轨统一抽象);
|
||||
//! - [`store`]:`AccountStore`(加载/增删/落盘,`data/auth/`);
|
||||
//! - [`offline`]:HMCL 兼容离线 UUID + 启动占位;
|
||||
//! - [`microsoft`]:device-code 全链(XBL→XSTS→login_with_xbox→entitlements→profile)+ 刷新判定;
|
||||
//! - [`authlib`]:标准 Yggdrasil 协议(authenticate/refresh/validate/invalidate,账户侧);
|
||||
//! - [`error`]:错误分流(取消 / 超时 / 被拒 / 凭据过期);
|
||||
//! - [`body`]:非 2xx 应答的正文诊断(分类 JSON/HTML/空体 + 脱敏摘要,M38);
|
||||
//! - [`journal`]:认证失败事件落盘(`.ULL/logs/auth.jsonl`,M38);
|
||||
//! - [`secret`]:凭据值包装与脱敏。
|
||||
//!
|
||||
//! # 本期不做(后续 mission)
|
||||
//!
|
||||
//! OS keychain / AES-256-GCM 凭据加密(07 §2 分级 1–2)、authlib-injector 的
|
||||
//! `-javaagent` 三件套与 jar 下载、皮肤与多角色选择、离线 UUID 的「官方风格」双模式
|
||||
//! (07 §1 升级 2,口径待定案)。接口约定见 `DevDocs/SubAgentsTalk/auth-接口约定.md`。
|
||||
|
||||
pub mod account;
|
||||
pub mod authlib;
|
||||
pub mod body;
|
||||
pub mod error;
|
||||
pub mod http;
|
||||
pub mod journal;
|
||||
pub mod microsoft;
|
||||
pub mod offline;
|
||||
pub mod secret;
|
||||
pub mod store;
|
||||
|
||||
pub use account::{AccountId, AccountKind, AccountMeta, Credentials};
|
||||
pub use body::{BodyKind, ResponseDetail};
|
||||
pub use error::{AuthError, AuthResult};
|
||||
pub use journal::{AuthFailureEvent, AuthJournal};
|
||||
pub use microsoft::{MicrosoftAuth, MicrosoftSession, RefreshDecision, RefreshReason};
|
||||
pub use secret::Secret;
|
||||
pub use store::{AccountStore, AuthPaths};
|
||||
@@ -0,0 +1,197 @@
|
||||
//! 离线轨:**HMCL / Minecraft 服务端同款离线 UUID** + 启动占位字段。
|
||||
//!
|
||||
//! UUID 算法(`analyse/04` §7.1):`UUID.nameUUIDFromBytes("OfflinePlayer:" + name)`
|
||||
//! = 对 UTF-8 字节做 MD5,再按 RFC 4122 改写成 **v3** 版本位与 **IETF 变体**位。
|
||||
//! 必须逐字节一致:离线服务器的 `ops.json` / 白名单 / 领地数据全按这个 UUID 存,
|
||||
//! 算错就对不上(皮肤、权限、存档全乱)。因此有与 JDK `UUID.nameUUIDFromBytes`
|
||||
//! 对拍的已知向量测试(见 `crates/core/tests/auth/offline_uuid.rs`)。
|
||||
//!
|
||||
//! 启动占位(`analyse/04` §7.2,承 `OfflineAccount.logInWithoutSkin`):
|
||||
//! - `accessToken` = **每次登录随机生成的 32 位十六进制**(游戏客户端不接受空 token);
|
||||
//! - `userType` = `msa`(HMCL 现版本如此;历史注释提过 `legacy`,但那是旧结论);
|
||||
//! - `userProperties` = `{}`;
|
||||
//! - `uuid` 用**无连字符** 32 位形式(`--uuid` 参数要求)。
|
||||
|
||||
use md5::{Digest, Md5};
|
||||
|
||||
use crate::auth::account::{AccountKind, AccountMeta};
|
||||
use crate::auth::error::{AuthError, AuthResult};
|
||||
use crate::auth::secret::{random_hex32, Secret};
|
||||
use crate::launch::request::AuthPlaceholders;
|
||||
|
||||
/// 离线 UUID 的固定前缀(改一个字节就会与所有服务端对不上)。
|
||||
pub const OFFLINE_PREFIX: &str = "OfflinePlayer:";
|
||||
/// 离线账户的 launch 用户类型(`AuthInfo.USER_TYPE_MSA`)。
|
||||
pub const OFFLINE_USER_TYPE: &str = "msa";
|
||||
/// 玩家名长度上限(MC 客户端 `--username` 的实际上限)。
|
||||
pub const MAX_NAME_CHARS: usize = 16;
|
||||
|
||||
/// 算离线 UUID(**32 位无连字符小写十六进制**)。
|
||||
#[must_use]
|
||||
pub fn offline_uuid(name: &str) -> String {
|
||||
let digest = Md5::digest(format!("{OFFLINE_PREFIX}{name}").as_bytes());
|
||||
let mut bytes = [0_u8; 16];
|
||||
bytes.copy_from_slice(&digest);
|
||||
|
||||
// RFC 4122 §4.3:版本位 3(MD5 based)、变体位 IETF(10xx)。
|
||||
bytes[6] = (bytes[6] & 0x0F) | 0x30;
|
||||
bytes[8] = (bytes[8] & 0x3F) | 0x80;
|
||||
|
||||
let mut hex = String::with_capacity(32);
|
||||
for byte in bytes {
|
||||
hex.push_str(&format!("{byte:02x}"));
|
||||
}
|
||||
hex
|
||||
}
|
||||
|
||||
/// 带连字符的离线 UUID(展示/诊断用;启动参数要的是无连字符版)。
|
||||
#[must_use]
|
||||
pub fn offline_uuid_dashed(name: &str) -> String {
|
||||
let raw = offline_uuid(name);
|
||||
format!(
|
||||
"{}-{}-{}-{}-{}",
|
||||
&raw[0..8],
|
||||
&raw[8..12],
|
||||
&raw[12..16],
|
||||
&raw[16..20],
|
||||
&raw[20..32]
|
||||
)
|
||||
}
|
||||
|
||||
/// 校验并规范化离线玩家名。
|
||||
///
|
||||
/// 规则:非空、去掉首尾空白后 ≤16 字符、不含控制字符。
|
||||
/// **允许非 ASCII**(中文名在离线服里很常见,UUID 按 UTF-8 字节算即可)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 空名/过长/含控制字符时返回 [`AuthError::Malformed`]。
|
||||
pub fn normalize_name(name: &str) -> AuthResult<String> {
|
||||
let trimmed = name.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(AuthError::Malformed("玩家名不能为空".to_string()));
|
||||
}
|
||||
if trimmed.chars().count() > MAX_NAME_CHARS {
|
||||
return Err(AuthError::Malformed(format!(
|
||||
"玩家名最长 {MAX_NAME_CHARS} 个字符,收到 {} 个",
|
||||
trimmed.chars().count()
|
||||
)));
|
||||
}
|
||||
if trimmed.chars().any(char::is_control) {
|
||||
return Err(AuthError::Malformed("玩家名不能含控制字符".to_string()));
|
||||
}
|
||||
Ok(trimmed.to_string())
|
||||
}
|
||||
|
||||
/// 建一条离线账户元数据(名字已规范化)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 名字不合法时返回 [`AuthError::Malformed`]。
|
||||
pub fn offline_meta(name: &str, created_at_ms: u64) -> AuthResult<AccountMeta> {
|
||||
let name = normalize_name(name)?;
|
||||
Ok(AccountMeta::new(
|
||||
AccountKind::Offline,
|
||||
offline_uuid(&name),
|
||||
name,
|
||||
created_at_ms,
|
||||
))
|
||||
}
|
||||
|
||||
/// 生成一次「登录」的启动占位(不联网、不落盘)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 元数据不是离线轨或名字为空时返回 [`AuthError::Malformed`] / [`AuthError::CredentialExpired`]。
|
||||
pub fn offline_login(meta: &AccountMeta) -> AuthResult<AuthPlaceholders> {
|
||||
if meta.kind != AccountKind::Offline {
|
||||
return Err(AuthError::Malformed(format!(
|
||||
"账户 {} 不是离线轨({})",
|
||||
meta.id,
|
||||
meta.kind.as_str()
|
||||
)));
|
||||
}
|
||||
if meta.profile_name.trim().is_empty() {
|
||||
return Err(AuthError::CredentialExpired);
|
||||
}
|
||||
Ok(AuthPlaceholders {
|
||||
player_name: meta.profile_name.clone(),
|
||||
uuid: meta.profile_id.clone(),
|
||||
// 随机占位:每次登录都变(与 HMCL 一致),不落盘、不进日志。
|
||||
access_token: Secret::new(random_hex32()).expose().to_string(),
|
||||
user_type: OFFLINE_USER_TYPE.to_string(),
|
||||
user_properties: "{}".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
normalize_name, offline_login, offline_meta, offline_uuid, offline_uuid_dashed,
|
||||
OFFLINE_USER_TYPE,
|
||||
};
|
||||
use crate::auth::account::AccountKind;
|
||||
|
||||
#[test]
|
||||
fn uuid_matches_jdk_name_uuid_from_bytes() {
|
||||
// 向量由 JDK 25 的 UUID.nameUUIDFromBytes 生成(见 tests/auth/offline_uuid.rs 的说明)。
|
||||
assert_eq!(
|
||||
offline_uuid("Notch"),
|
||||
"b50ad385829d3141a2167e7d7539ba7f".to_string()
|
||||
);
|
||||
assert_eq!(
|
||||
offline_uuid_dashed("Notch"),
|
||||
"b50ad385-829d-3141-a216-7e7d7539ba7f"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uuid_has_v3_and_ietf_bits() {
|
||||
let raw = offline_uuid("AnyPlayer");
|
||||
let version = u8::from_str_radix(&raw[12..13], 16).expect("版本位");
|
||||
assert_eq!(version, 3, "必须标记为 v3(MD5)");
|
||||
let variant = u8::from_str_radix(&raw[16..17], 16).expect("变体位");
|
||||
assert!(
|
||||
(0x8..=0xb).contains(&variant),
|
||||
"变体位必须是 IETF(10xx),实际 {variant:x}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uuid_is_stable_and_case_sensitive() {
|
||||
assert_eq!(offline_uuid("Steve"), offline_uuid("Steve"));
|
||||
assert_ne!(offline_uuid("Steve"), offline_uuid("steve"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_validation() {
|
||||
assert_eq!(normalize_name(" Steve ").expect("合法"), "Steve");
|
||||
assert_eq!(normalize_name("小明").expect("中文合法"), "小明");
|
||||
assert!(normalize_name(" ").is_err());
|
||||
assert!(normalize_name("0123456789abcdefg").is_err());
|
||||
assert!(normalize_name("bad\u{7}name").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offline_meta_and_login_shape() {
|
||||
let meta = offline_meta("小明", 1_000).expect("建元数据");
|
||||
assert_eq!(meta.kind, AccountKind::Offline);
|
||||
assert_eq!(meta.profile_id, offline_uuid("小明"));
|
||||
let placeholders = offline_login(&meta).expect("占位符");
|
||||
assert_eq!(placeholders.player_name, "小明");
|
||||
assert_eq!(placeholders.uuid, offline_uuid("小明"));
|
||||
assert_eq!(placeholders.user_type, OFFLINE_USER_TYPE);
|
||||
assert_eq!(placeholders.user_properties, "{}");
|
||||
assert_eq!(placeholders.access_token.len(), 32);
|
||||
// 每次登录的占位 token 都不同(HMCL 行为)。
|
||||
let again = offline_login(&meta).expect("占位符");
|
||||
assert_ne!(placeholders.access_token, again.access_token);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offline_login_rejects_other_tracks() {
|
||||
let mut meta = offline_meta("Steve", 1).expect("建元数据");
|
||||
meta.kind = AccountKind::Microsoft;
|
||||
assert!(offline_login(&meta).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
//! 凭据值包装:**打印出来永远是 `<redacted>`**。
|
||||
//!
|
||||
//! 依据 `DevDocs/PlanDoc/07-accounts.md` §3 与 `DevDocs/AGENTS.md` §6 的凭据红线:
|
||||
//! token/密码永不进日志、事件流、stdout、测试快照。HMCL 靠「往日志过滤器注册 token」
|
||||
//! (`analyse/04` §11 观察项 9:绕开 Logger 就失效),我们在**类型层**堵:
|
||||
//! 只要某值被包成 [`Secret`],`Debug`/`Display`/`{:?}`/`{}` 都拿不到明文,
|
||||
//! 取明文必须显式写 `.expose()`(一眼可搜、CR 可审)。
|
||||
//!
|
||||
//! 落盘仍然需要明文(凭据文件),因此保留 serde 透明序列化——序列化不是打印。
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::fmt;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::launch::redact::{Redactor, TOKEN_PLACEHOLDER};
|
||||
|
||||
/// `Debug`/`Display` 与脱敏替换的统一标记。
|
||||
pub const REDACTED: &str = "<redacted>";
|
||||
|
||||
/// 一个凭据值(access token / refresh token / client token / 密码)。
|
||||
///
|
||||
/// 相等性按明文比较(刷新后判断「token 有没有变」要用到),哈希同理。
|
||||
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct Secret(String);
|
||||
|
||||
impl Secret {
|
||||
/// 包住一个明文值。
|
||||
pub fn new(value: impl Into<String>) -> Self {
|
||||
Self(value.into())
|
||||
}
|
||||
|
||||
/// 取明文。**唯一取明文入口**:出现处都应能说清「这是发请求,不是打印」。
|
||||
#[must_use]
|
||||
pub fn expose(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
|
||||
/// 是否为空(空 token 等价于「没有凭据」)。
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.0.is_empty()
|
||||
}
|
||||
|
||||
/// 明文字节长度(判空/判占位长度用,不泄露内容)。
|
||||
#[must_use]
|
||||
pub fn len(&self) -> usize {
|
||||
self.0.len()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for Secret {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str(REDACTED)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Secret {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str(REDACTED)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for Secret {
|
||||
fn from(value: String) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for Secret {
|
||||
fn from(value: &str) -> Self {
|
||||
Self(value.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// 生成 32 个十六进制字符的随机串(离线 accessToken 占位、条目 id)。
|
||||
///
|
||||
/// **不是密码学随机**(同 `game::ProfileId::generate` 的取舍):混合纳秒时间戳、进程 id、
|
||||
/// 进程内计数器后过 SplitMix64。这些值不参与任何服务端校验(HMCL 同样用
|
||||
/// `UUID.randomUUID().toString()` 的紧凑形式当占位),唯一性够用即可。
|
||||
#[must_use]
|
||||
pub fn random_hex32() -> String {
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |duration| duration.as_nanos() as u64);
|
||||
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
let mut seed = nanos ^ u64::from(std::process::id()).rotate_left(17) ^ seq.rotate_left(41);
|
||||
|
||||
let mut out = String::with_capacity(32);
|
||||
let mut state = seed;
|
||||
while out.len() < 32 {
|
||||
// 两个 u64 混合出 16 个 hex 字符一轮。
|
||||
state = split_mix64(state);
|
||||
seed ^= split_mix64(seed.rotate_left(29) ^ state);
|
||||
out.push_str(&format!("{:016x}", seed));
|
||||
}
|
||||
out.truncate(32);
|
||||
out
|
||||
}
|
||||
|
||||
fn split_mix64(seed: u64) -> u64 {
|
||||
let mut z = seed.wrapping_add(0x9E37_79B9_7F4A_7C15);
|
||||
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||
z ^ (z >> 31)
|
||||
}
|
||||
|
||||
/// 用一组凭据武装一个日志脱敏器(`launch::redact::Redactor` 的唯一构造入口)。
|
||||
///
|
||||
/// 太短的值(< 6 字节)会被 `Redactor` 自动忽略,避免把正常文本搅乱。
|
||||
#[must_use]
|
||||
pub fn redactor_for<'a>(secrets: impl IntoIterator<Item = &'a Secret>) -> Redactor {
|
||||
let mut redactor = Redactor::new();
|
||||
for secret in secrets {
|
||||
if !secret.is_empty() {
|
||||
redactor = redactor.with_secret(secret.expose().to_string());
|
||||
}
|
||||
}
|
||||
redactor
|
||||
}
|
||||
|
||||
/// 对一段文本做凭据脱敏(走 `launch::redact` 的三条规则:已登记凭据、长度 ≥64 不透明串、
|
||||
/// 带点 token/JWT)。
|
||||
#[must_use]
|
||||
pub fn scrub<'a>(text: &'a str, secrets: &[&Secret]) -> Cow<'a, str> {
|
||||
let owned: Vec<Secret> = secrets.iter().map(|secret| (*secret).clone()).collect();
|
||||
redactor_for(owned.iter()).scrub(text)
|
||||
}
|
||||
|
||||
/// 占位常量转出(给调用方断言 / 拼提示用)。
|
||||
#[must_use]
|
||||
pub const fn token_placeholder() -> &'static str {
|
||||
TOKEN_PLACEHOLDER
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{random_hex32, scrub, Secret, REDACTED, TOKEN_PLACEHOLDER};
|
||||
|
||||
#[test]
|
||||
fn debug_and_display_never_leak() {
|
||||
let secret = Secret::new("SUPER-SECRET-TOKEN-VALUE");
|
||||
assert_eq!(format!("{secret:?}"), REDACTED);
|
||||
assert_eq!(format!("{secret}"), REDACTED);
|
||||
assert!(!format!("{secret:?}").contains("SUPER"));
|
||||
let bundle = format!("{:?}", vec![secret.clone()]);
|
||||
assert!(!bundle.contains("SECRET"), "{bundle}");
|
||||
assert_eq!(secret.expose(), "SUPER-SECRET-TOKEN-VALUE");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_roundtrip_keeps_plaintext() {
|
||||
let secret = Secret::new("abc123");
|
||||
let text = serde_json::to_string(&secret).expect("序列化");
|
||||
assert_eq!(text, "\"abc123\"");
|
||||
let back: Secret = serde_json::from_str(&text).expect("反序列化");
|
||||
assert_eq!(back, secret);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrub_replaces_registered_secret() {
|
||||
let secret = Secret::new("0f8fad5bd9cb469fa16570867728950e");
|
||||
let text = format!("--accessToken {} --uuid abc", secret.expose());
|
||||
let scrubbed = scrub(&text, &[&secret]).into_owned();
|
||||
assert!(!scrubbed.contains("0f8fad"));
|
||||
assert!(scrubbed.contains(TOKEN_PLACEHOLDER));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_hex32_is_32_hex_and_unique() {
|
||||
let first = random_hex32();
|
||||
let second = random_hex32();
|
||||
assert_eq!(first.len(), 32);
|
||||
assert!(first.chars().all(|ch| ch.is_ascii_hexdigit()));
|
||||
assert_ne!(first, second);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
//! 账户存储:`data/auth/` 下的**元数据 / 凭据分文件**落盘(内存视图与增删)。
|
||||
//!
|
||||
//! 契约(`PlanDoc/07-accounts.md` §2、`PlanDoc/04-data-and-config.md`):
|
||||
//! 1. **分文件**:`accounts.json`(可公开)+ `credentials.dat`(凭据)。列账户不碰凭据文件;
|
||||
//! 2. **先写凭据、再写元数据**(HMCL 安全顺序)——元数据永不指向不存在的凭据;
|
||||
//! 3. **原子写**:走 `platform::atomic::write_atomic`(tmp → fsync → rename);
|
||||
//! 4. **容错**:单条坏记录只跳过该条并留 warning,绝不整体加载失败;
|
||||
//! 5. **孤儿清理**:`save()` 只写「元数据里还存在」的凭据。
|
||||
//!
|
||||
//! 落盘细节(payload 形状、权限收紧)在同目录的 [`payload`] 子模块,本文件只管模型与不变量。
|
||||
//!
|
||||
//! 保护级别:本期 `plain-v1`(分文件 + 权限 + 全链脱敏)。OS keychain / AES-256-GCM
|
||||
//! 分级是 07 §2 的第 1–2 条,需要 keyring/aes-gcm/argon2 的依赖决策,属后续 mission。
|
||||
|
||||
mod payload;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::auth::account::{AccountId, AccountMeta, Credentials};
|
||||
use crate::auth::error::{AuthError, AuthResult};
|
||||
|
||||
/// 元数据文件名(`data/auth/accounts.json`)。
|
||||
pub const ACCOUNTS_FILE: &str = "accounts.json";
|
||||
/// 凭据文件名(`data/auth/credentials.dat`)。
|
||||
pub const CREDENTIALS_FILE: &str = "credentials.dat";
|
||||
/// 元数据 schema 版本。
|
||||
pub const AUTH_SCHEMA_VERSION: u32 = 1;
|
||||
/// 本期凭据保护级别(明文 + 分文件 + 权限 + 脱敏)。
|
||||
pub const PROTECTION_PLAIN_V1: &str = "plain-v1";
|
||||
|
||||
/// `data/auth/` 的两个文件位置。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AuthPaths {
|
||||
root: PathBuf,
|
||||
}
|
||||
|
||||
impl AuthPaths {
|
||||
/// 以数据分区为根(`LauncherLayout::auth_data()`)。
|
||||
#[must_use]
|
||||
pub fn new(root: impl Into<PathBuf>) -> Self {
|
||||
Self { root: root.into() }
|
||||
}
|
||||
|
||||
/// 根目录。
|
||||
#[must_use]
|
||||
pub fn root(&self) -> &Path {
|
||||
&self.root
|
||||
}
|
||||
|
||||
/// 元数据文件。
|
||||
#[must_use]
|
||||
pub fn accounts_file(&self) -> PathBuf {
|
||||
self.root.join(ACCOUNTS_FILE)
|
||||
}
|
||||
|
||||
/// 凭据文件。
|
||||
#[must_use]
|
||||
pub fn credentials_file(&self) -> PathBuf {
|
||||
self.root.join(CREDENTIALS_FILE)
|
||||
}
|
||||
}
|
||||
|
||||
/// 账户库(内存视图 + 落盘)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AccountStore {
|
||||
paths: AuthPaths,
|
||||
accounts: Vec<AccountMeta>,
|
||||
credentials: BTreeMap<String, Credentials>,
|
||||
warnings: Vec<String>,
|
||||
}
|
||||
|
||||
impl AccountStore {
|
||||
/// 打开(不存在就是空库;坏记录进 warnings)。
|
||||
#[must_use]
|
||||
pub fn open(root: impl Into<PathBuf>) -> Self {
|
||||
Self::open_paths(AuthPaths::new(root))
|
||||
}
|
||||
|
||||
/// 打开指定文件位置。
|
||||
#[must_use]
|
||||
pub fn open_paths(paths: AuthPaths) -> Self {
|
||||
let mut warnings = Vec::new();
|
||||
let mut accounts = payload::read_accounts(&paths.accounts_file(), &mut warnings);
|
||||
accounts.sort_by_key(|meta| meta.created_at_ms);
|
||||
let known: Vec<String> = accounts
|
||||
.iter()
|
||||
.map(|meta| meta.id.as_str().to_string())
|
||||
.collect();
|
||||
let credentials =
|
||||
payload::read_credentials(&paths.credentials_file(), &known, &mut warnings);
|
||||
Self {
|
||||
paths,
|
||||
accounts,
|
||||
credentials,
|
||||
warnings,
|
||||
}
|
||||
}
|
||||
|
||||
/// 文件位置。
|
||||
#[must_use]
|
||||
pub fn paths(&self) -> &AuthPaths {
|
||||
&self.paths
|
||||
}
|
||||
|
||||
/// 账户列表(按创建时间排序,展示顺序稳定)。
|
||||
#[must_use]
|
||||
pub fn accounts(&self) -> &[AccountMeta] {
|
||||
&self.accounts
|
||||
}
|
||||
|
||||
/// 加载过程中的告警(坏记录、孤儿凭据等)。
|
||||
#[must_use]
|
||||
pub fn warnings(&self) -> &[String] {
|
||||
&self.warnings
|
||||
}
|
||||
|
||||
/// 是否没有任何账户。
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.accounts.is_empty()
|
||||
}
|
||||
|
||||
/// 按名字 / 登录名 / 条目 id 查找(大小写不敏感)。
|
||||
#[must_use]
|
||||
pub fn find(&self, key: &str) -> Option<&AccountMeta> {
|
||||
self.accounts.iter().find(|meta| meta.matches(key))
|
||||
}
|
||||
|
||||
/// 取某条目的凭据。
|
||||
#[must_use]
|
||||
pub fn credentials(&self, id: &AccountId) -> Option<&Credentials> {
|
||||
self.credentials.get(id.as_str())
|
||||
}
|
||||
|
||||
/// 取某条目的「元数据 + 凭据」(找不到凭据时报错)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 条目不存在 → [`AuthError::NotFound`];条目在但凭据缺失 → [`AuthError::Malformed`]
|
||||
/// (说明文件被手工删过,别静默当成「没登录」)。
|
||||
pub fn entry(&self, id: &AccountId) -> AuthResult<(&AccountMeta, &Credentials)> {
|
||||
let meta = self
|
||||
.accounts
|
||||
.iter()
|
||||
.find(|meta| meta.id == *id)
|
||||
.ok_or_else(|| AuthError::NotFound {
|
||||
account: id.to_string(),
|
||||
})?;
|
||||
let credentials = self
|
||||
.credentials
|
||||
.get(id.as_str())
|
||||
.ok_or_else(|| AuthError::Malformed(format!("账户 {id} 的凭据缺失")))?;
|
||||
Ok((meta, credentials))
|
||||
}
|
||||
|
||||
/// 新增账户(内存)。同名同轨冲突时报错——`--account <名字>` 才有唯一解。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 重复、条目 id 重复、轨道与凭据不符都返回错误。
|
||||
pub fn insert(&mut self, meta: AccountMeta, credentials: Credentials) -> AuthResult<()> {
|
||||
meta.check_credentials(&credentials)?;
|
||||
if self.accounts.iter().any(|existing| existing.id == meta.id) {
|
||||
return Err(AuthError::Conflict {
|
||||
account: meta.id.to_string(),
|
||||
});
|
||||
}
|
||||
let duplicate = self
|
||||
.accounts
|
||||
.iter()
|
||||
.any(|existing| existing.kind == meta.kind && existing.matches(meta.display_name()));
|
||||
if duplicate {
|
||||
return Err(AuthError::Conflict {
|
||||
account: meta.display_name().to_string(),
|
||||
});
|
||||
}
|
||||
self.credentials
|
||||
.insert(meta.id.as_str().to_string(), credentials);
|
||||
self.accounts.push(meta);
|
||||
self.accounts.sort_by_key(|meta| meta.created_at_ms);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 删除账户(连同凭据)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 找不到时返回 [`AuthError::NotFound`]。
|
||||
pub fn remove(&mut self, key: &str) -> AuthResult<AccountMeta> {
|
||||
let index = self
|
||||
.accounts
|
||||
.iter()
|
||||
.position(|meta| meta.matches(key))
|
||||
.ok_or_else(|| AuthError::NotFound {
|
||||
account: key.to_string(),
|
||||
})?;
|
||||
let meta = self.accounts.remove(index);
|
||||
self.credentials.remove(meta.id.as_str());
|
||||
Ok(meta)
|
||||
}
|
||||
|
||||
/// 更新某条目的凭据(刷新后轮换 token 用)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 条目不存在或轨道不符时返回错误。
|
||||
pub fn update_credentials(
|
||||
&mut self,
|
||||
id: &AccountId,
|
||||
credentials: Credentials,
|
||||
) -> AuthResult<()> {
|
||||
let meta = self
|
||||
.accounts
|
||||
.iter()
|
||||
.find(|meta| meta.id == *id)
|
||||
.ok_or_else(|| AuthError::NotFound {
|
||||
account: id.to_string(),
|
||||
})?;
|
||||
meta.check_credentials(&credentials)?;
|
||||
self.credentials
|
||||
.insert(id.as_str().to_string(), credentials);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 同步某条目的元数据字段(刷新补齐了玩家名之后写回)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 条目不存在时返回 [`AuthError::NotFound`]。
|
||||
pub fn update_meta(&mut self, meta: AccountMeta) -> AuthResult<()> {
|
||||
let slot = self
|
||||
.accounts
|
||||
.iter_mut()
|
||||
.find(|existing| existing.id == meta.id)
|
||||
.ok_or_else(|| AuthError::NotFound {
|
||||
account: meta.id.to_string(),
|
||||
})?;
|
||||
*slot = meta;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 落盘:**先凭据、后元数据**。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 两个文件任一写失败都返回 [`AuthError::Store`]。
|
||||
pub fn save(&self) -> AuthResult<()> {
|
||||
payload::write(&self.paths, &self.accounts, &self.credentials)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
//! 落盘细节:payload 形状、宽容解析、凭据文件权限。
|
||||
//!
|
||||
//! 两个文件的形状(都带 `schemaVersion`,便于以后演进):
|
||||
//!
|
||||
//! ```json
|
||||
//! // accounts.json —— 可公开
|
||||
//! { "schemaVersion": 1, "accounts": [ { "id": "account:…", "kind": "offline",
|
||||
//! "profileId": "…", "profileName": "Steve", "createdAtMs": 1700000000000 } ] }
|
||||
//!
|
||||
//! // credentials.dat —— 凭据
|
||||
//! { "schemaVersion": 1, "protection": "plain-v1",
|
||||
//! "entries": [ { "accountId": "account:…", "data": { "kind": "microsoft", … } } ] }
|
||||
//! ```
|
||||
//!
|
||||
//! `entries[].data` 里是 [`Credentials`] 的序列化结果(由 serde 自己定义形状),
|
||||
//! 本模块只负责「哪条属于哪个账户」。
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::auth::account::{AccountMeta, Credentials};
|
||||
use crate::auth::error::{AuthError, AuthResult};
|
||||
use crate::platform::atomic::write_atomic;
|
||||
|
||||
use super::{AuthPaths, AUTH_SCHEMA_VERSION, PROTECTION_PLAIN_V1};
|
||||
|
||||
/// 写两处文件:**先凭据、后元数据**。
|
||||
pub(super) fn write(
|
||||
paths: &AuthPaths,
|
||||
accounts: &[AccountMeta],
|
||||
credentials: &BTreeMap<String, Credentials>,
|
||||
) -> AuthResult<()> {
|
||||
let credentials_payload = credentials_payload(accounts, credentials);
|
||||
let path = paths.credentials_file();
|
||||
write_atomic(&path, credentials_payload.as_bytes())
|
||||
.map_err(|error| AuthError::store(path.display(), error.to_string()))?;
|
||||
restrict(&path)?;
|
||||
|
||||
let accounts_payload = accounts_payload(accounts);
|
||||
let path = paths.accounts_file();
|
||||
write_atomic(&path, accounts_payload.as_bytes())
|
||||
.map_err(|error| AuthError::store(path.display(), error.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn credentials_payload(
|
||||
accounts: &[AccountMeta],
|
||||
credentials: &BTreeMap<String, Credentials>,
|
||||
) -> String {
|
||||
let mut entries = Vec::new();
|
||||
for meta in accounts {
|
||||
// 孤儿凭据(元数据已删)不写回。
|
||||
let Some(data) = credentials.get(meta.id.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
entries.push(json!({
|
||||
"accountId": meta.id.as_str(),
|
||||
"data": data,
|
||||
}));
|
||||
}
|
||||
let payload = json!({
|
||||
"schemaVersion": AUTH_SCHEMA_VERSION,
|
||||
"protection": PROTECTION_PLAIN_V1,
|
||||
"entries": entries,
|
||||
});
|
||||
serde_json::to_string_pretty(&payload).unwrap_or_else(|_| "{}".to_string())
|
||||
}
|
||||
|
||||
fn accounts_payload(accounts: &[AccountMeta]) -> String {
|
||||
let payload = json!({
|
||||
"schemaVersion": AUTH_SCHEMA_VERSION,
|
||||
"accounts": accounts,
|
||||
});
|
||||
serde_json::to_string_pretty(&payload).unwrap_or_else(|_| "{}".to_string())
|
||||
}
|
||||
|
||||
/// 读元数据(坏记录跳过并记 warning)。
|
||||
pub(super) fn read_accounts(path: &Path, warnings: &mut Vec<String>) -> Vec<AccountMeta> {
|
||||
let mut accounts = Vec::new();
|
||||
let Some(text) = read_to_string(path, warnings) else {
|
||||
return accounts;
|
||||
};
|
||||
let Ok(value) = serde_json::from_str::<Value>(&text) else {
|
||||
warnings.push(format!("{} 不是合法 JSON,按空账户库处理", path.display()));
|
||||
return accounts;
|
||||
};
|
||||
let Some(items) = value.get("accounts").and_then(Value::as_array) else {
|
||||
warnings.push(format!("{} 缺 accounts 数组", path.display()));
|
||||
return accounts;
|
||||
};
|
||||
for item in items {
|
||||
match AccountMeta::from_value(item) {
|
||||
Ok(meta) => accounts.push(meta),
|
||||
Err(error) => warnings.push(format!("跳过一条账户元数据:{}", error.message())),
|
||||
}
|
||||
}
|
||||
accounts
|
||||
}
|
||||
|
||||
/// 读凭据(只保留 `known` 里存在的账户,孤儿丢弃并记 warning)。
|
||||
pub(super) fn read_credentials(
|
||||
path: &Path,
|
||||
known: &[String],
|
||||
warnings: &mut Vec<String>,
|
||||
) -> BTreeMap<String, Credentials> {
|
||||
let mut credentials = BTreeMap::new();
|
||||
let Some(text) = read_to_string(path, warnings) else {
|
||||
return credentials;
|
||||
};
|
||||
let Ok(value) = serde_json::from_str::<Value>(&text) else {
|
||||
warnings.push(format!(
|
||||
"{} 不是合法 JSON,按「无凭据」处理(账户列表仍可用)",
|
||||
path.display()
|
||||
));
|
||||
return credentials;
|
||||
};
|
||||
if let Some(protection) = value.get("protection").and_then(Value::as_str) {
|
||||
if protection != PROTECTION_PLAIN_V1 {
|
||||
warnings.push(format!(
|
||||
"{} 的保护级别是 `{protection}`,本期只认 `{PROTECTION_PLAIN_V1}`",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
let Some(entries) = value.get("entries").and_then(Value::as_array) else {
|
||||
return credentials;
|
||||
};
|
||||
for entry in entries {
|
||||
let Some(account_id) = entry.get("accountId").and_then(Value::as_str) else {
|
||||
continue;
|
||||
};
|
||||
let account_id = account_id.to_ascii_lowercase();
|
||||
if !known.contains(&account_id) {
|
||||
warnings.push(format!("凭据 {account_id} 没有对应账户,已忽略"));
|
||||
continue;
|
||||
}
|
||||
let Some(data) = entry.get("data") else {
|
||||
continue;
|
||||
};
|
||||
match serde_json::from_value::<Credentials>(data.clone()) {
|
||||
Ok(parsed) => {
|
||||
credentials.insert(account_id, parsed);
|
||||
}
|
||||
Err(error) => warnings.push(format!("跳过一条凭据({account_id}):{error}")),
|
||||
}
|
||||
}
|
||||
credentials
|
||||
}
|
||||
|
||||
fn read_to_string(path: &Path, warnings: &mut Vec<String>) -> Option<String> {
|
||||
match std::fs::read_to_string(path) {
|
||||
Ok(text) => Some(text),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
|
||||
Err(error) => {
|
||||
warnings.push(format!("读 {} 失败:{error}", path.display()));
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 把凭据文件收紧到「仅本人可读写」。
|
||||
///
|
||||
/// POSIX 侧显式 `0600`;Windows 侧 `set_permissions` 只能表达只读位,凭据保护依赖
|
||||
/// 用户目录默认 ACL(`data/auth/` 位于用户数据根下)。**这是已知缺口**:
|
||||
/// 补一个 `platform::atomic::write_atomic_private` 比在 auth 里散写 cfg 更对,
|
||||
/// 已作为 finding 报给 tower(见完成报告)。
|
||||
fn restrict(path: &Path) -> AuthResult<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let permissions = std::fs::Permissions::from_mode(0o600);
|
||||
std::fs::set_permissions(path, permissions)
|
||||
.map_err(|error| AuthError::store(path.display(), error.to_string()))?;
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let _ = path;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use super::{accounts_payload, credentials_payload};
|
||||
use crate::auth::account::{AccountKind, AccountMeta, Credentials};
|
||||
use crate::auth::microsoft::MicrosoftSession;
|
||||
use crate::auth::secret::Secret;
|
||||
|
||||
fn microsoft_meta() -> (AccountMeta, Credentials) {
|
||||
let meta = AccountMeta::new(
|
||||
AccountKind::Microsoft,
|
||||
"0f8fad5bd9cb469fa16570867728950e",
|
||||
"Steve",
|
||||
7,
|
||||
);
|
||||
let session = MicrosoftSession::new(
|
||||
"0f8fad5bd9cb469fa16570867728950e",
|
||||
"Steve",
|
||||
Secret::new("mc-access-token"),
|
||||
Secret::new("live-refresh-token"),
|
||||
3600,
|
||||
1_000,
|
||||
None,
|
||||
);
|
||||
(meta, Credentials::Microsoft(session))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_payload_never_contains_tokens() {
|
||||
let (meta, creds) = microsoft_meta();
|
||||
let payload = accounts_payload(std::slice::from_ref(&meta));
|
||||
assert!(payload.contains("0f8fad5bd9cb469fa16570867728950e"));
|
||||
assert!(payload.contains("\"kind\": \"microsoft\""));
|
||||
assert!(!payload.contains("mc-access-token"));
|
||||
assert!(!payload.contains("live-refresh-token"));
|
||||
// 凭据对象的 Debug 也是脱敏的,别把 token 带进 payload。
|
||||
assert!(!format!("{creds:?}").contains("mc-access-token"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credentials_payload_carries_protection_and_entries() {
|
||||
let (meta, creds) = microsoft_meta();
|
||||
let mut map = BTreeMap::new();
|
||||
map.insert(meta.id.as_str().to_string(), creds);
|
||||
let payload = credentials_payload(std::slice::from_ref(&meta), &map);
|
||||
assert!(payload.contains("\"protection\": \"plain-v1\""));
|
||||
assert!(payload.contains(meta.id.as_str()));
|
||||
assert!(payload.contains("mc-access-token"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orphan_credentials_are_not_written() {
|
||||
let (meta, creds) = microsoft_meta();
|
||||
let mut map = BTreeMap::new();
|
||||
map.insert(
|
||||
"account:deadbeefdeadbeefdeadbeefdeadbeef".to_string(),
|
||||
creds,
|
||||
);
|
||||
let payload = credentials_payload(std::slice::from_ref(&meta), &map);
|
||||
assert!(!payload.contains("mc-access-token"));
|
||||
assert!(payload.contains("\"entries\": []"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
//! 五类信号采集接口:退出码 / 日志环形缓冲 / crash-reports / hs_err / 环境快照(+ mod 清单)。
|
||||
//!
|
||||
//! 分工(与 `launch-接口约定.md` 对齐):
|
||||
//! - **推送类**([`ExitCollector`] / [`EnvironmentCollector`]):launch 已经算出事实,直接塞进 [`SignalBundle`];
|
||||
//! - **磁盘类**([`CrashReportCollector`] / [`HsErrCollector`] / [`LatestLogCollector`]):本模块自己按
|
||||
//! mtime 取最新一份,并可用 [`CollectionContext::started_at`] 过滤历史崩溃;
|
||||
//! - 日志行由 launch 逐行喂 [`LogBuffer::push`],diagnose 不解析会话 JSONL(两边零类型耦合)。
|
||||
//!
|
||||
//! 采集是**尽力而为**:[`collect_all`] 把单个来源的失败收集成 [`CollectError`] 而不是中断整体——
|
||||
//! 崩溃现场本来就残缺,少一个来源不该失去全部结论。
|
||||
|
||||
use std::fmt;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::SystemTime;
|
||||
|
||||
#[path = "environment.rs"]
|
||||
pub mod environment;
|
||||
#[path = "signals.rs"]
|
||||
pub mod signals;
|
||||
#[path = "sources.rs"]
|
||||
pub mod sources;
|
||||
|
||||
pub use environment::{ComponentVersion, EnvironmentSnapshot, ModRecord};
|
||||
pub use signals::{
|
||||
ExitSignal, ExitType, LogBuffer, LogLine, SignalOrigin, CRASH_KEYWORDS, EXIT_ACCESS_VIOLATION,
|
||||
EXIT_DLL_NOT_FOUND, EXIT_SIGKILL, EXIT_SIGTERM, EXIT_STACK_BUFFER_OVERRUN, JVM_ERROR_KEYWORDS,
|
||||
};
|
||||
pub use sources::{
|
||||
CrashReportCollector, EnvironmentCollector, ExitCollector, HsErrCollector, LatestLogCollector,
|
||||
};
|
||||
|
||||
use super::engine::DiagnosisInput;
|
||||
use super::hs_err::HsErrReport;
|
||||
|
||||
/// 信号种类(可观测性 / 采集计划编排用)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum SignalKind {
|
||||
/// 退出码 + 退出类型。
|
||||
Exit,
|
||||
/// stdout/stderr 环形缓冲。
|
||||
Log,
|
||||
/// `crash-reports/crash-*.txt`。
|
||||
CrashReport,
|
||||
/// `hs_err_pid*.log`。
|
||||
HsErr,
|
||||
/// 环境快照。
|
||||
Environment,
|
||||
/// mod 清单。
|
||||
Mods,
|
||||
}
|
||||
|
||||
impl SignalKind {
|
||||
/// 稳定字符串。
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Exit => "exit",
|
||||
Self::Log => "log",
|
||||
Self::CrashReport => "crash_report",
|
||||
Self::HsErr => "hs_err",
|
||||
Self::Environment => "environment",
|
||||
Self::Mods => "mods",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 采集上下文。
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct CollectionContext {
|
||||
/// 游戏工作目录(`crash-reports/`、`hs_err_pid*.log`、`logs/latest.log` 都在它下面)。
|
||||
pub run_dir: PathBuf,
|
||||
/// 进程启动时刻:只采集修改时间 ≥ 它的文件,避免把上次崩溃算进本次。
|
||||
pub started_at: Option<SystemTime>,
|
||||
}
|
||||
|
||||
impl CollectionContext {
|
||||
/// 以工作目录构造。
|
||||
#[must_use]
|
||||
pub fn new(run_dir: impl Into<PathBuf>) -> Self {
|
||||
Self {
|
||||
run_dir: run_dir.into(),
|
||||
started_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 带「只认本次会话文件」的时间下界。
|
||||
#[must_use]
|
||||
pub fn since(mut self, started_at: SystemTime) -> Self {
|
||||
self.started_at = Some(started_at);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// 采集失败(尽力而为:单源失败不影响其它来源)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CollectError {
|
||||
/// 出错的信号种类。
|
||||
pub kind: SignalKind,
|
||||
/// 相关路径(可能为空,例如纯逻辑错误)。
|
||||
pub path: Option<PathBuf>,
|
||||
/// 人类可读原因。
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl CollectError {
|
||||
fn io(kind: SignalKind, path: &Path, error: &io::Error) -> Self {
|
||||
Self {
|
||||
kind,
|
||||
path: Some(path.to_path_buf()),
|
||||
message: error.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for CollectError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let path = self
|
||||
.path
|
||||
.as_deref()
|
||||
.map_or_else(String::new, |path| format!("({})", path.display()));
|
||||
write!(f, "{} 采集失败{path}:{}", self.kind.as_str(), self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for CollectError {}
|
||||
|
||||
/// 采到的源文件列表(诊断包导出 / 可观测性)。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct CollectedSources {
|
||||
/// 文件路径。
|
||||
pub paths: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
impl CollectedSources {
|
||||
/// 空。
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// 记一个源文件。
|
||||
pub fn push(&mut self, path: impl Into<PathBuf>) {
|
||||
self.paths.push(path.into());
|
||||
}
|
||||
|
||||
/// 是否什么都没采到。
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.paths.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个来源的采到的文本(含来源文件与时间)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CapturedText {
|
||||
/// 来源文件。
|
||||
pub path: PathBuf,
|
||||
/// 文本。
|
||||
pub text: String,
|
||||
/// 修改时间。
|
||||
pub modified: Option<SystemTime>,
|
||||
}
|
||||
|
||||
/// 采集到的全部信号。
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SignalBundle {
|
||||
/// 退出信号。
|
||||
pub exit: Option<ExitSignal>,
|
||||
/// 日志环形缓冲(launch 逐行喂;`latest.log` 兜底也进这里)。
|
||||
pub log: LogBuffer,
|
||||
/// 崩溃报告全文。
|
||||
pub crash_report: Option<CapturedText>,
|
||||
/// hs_err 解析结果。
|
||||
pub hs_err: Option<HsErrReport>,
|
||||
/// 环境快照。
|
||||
pub environment: Option<EnvironmentSnapshot>,
|
||||
/// mod 清单。
|
||||
pub mods: Vec<ModRecord>,
|
||||
/// 本次采到的源文件。
|
||||
pub sources: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
impl SignalBundle {
|
||||
/// 空包(日志缓冲默认 2000 行)。
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// 是否没有任何信号。
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.exit.is_none()
|
||||
&& self.log.is_empty()
|
||||
&& self.crash_report.is_none()
|
||||
&& self.hs_err.is_none()
|
||||
&& self.environment.is_none()
|
||||
&& self.mods.is_empty()
|
||||
}
|
||||
|
||||
/// 转成规则引擎的输入。
|
||||
///
|
||||
/// 日志按来源分组成多份待匹配文本(stdout/stderr 分开),这样证据里能保留来源;
|
||||
/// crash-report 与 hs_err 各占一份,取证优先级由 `SignalOrigin::evidence_rank` 决定。
|
||||
#[must_use]
|
||||
pub fn diagnosis_input(&self) -> DiagnosisInput {
|
||||
let mut input = DiagnosisInput::new();
|
||||
input.exit = self.exit.clone();
|
||||
for (origin, text) in grouped_log_text(&self.log) {
|
||||
input.push_text(origin, text);
|
||||
}
|
||||
if let Some(crash_report) = &self.crash_report {
|
||||
input.push_text(SignalOrigin::CrashReport, crash_report.text.clone());
|
||||
}
|
||||
if let Some(report) = &self.hs_err {
|
||||
input.push_hs_err(report.clone());
|
||||
}
|
||||
input
|
||||
}
|
||||
}
|
||||
|
||||
fn grouped_log_text(log: &LogBuffer) -> Vec<(SignalOrigin, String)> {
|
||||
let mut groups: Vec<(SignalOrigin, String)> = Vec::new();
|
||||
for line in log.lines() {
|
||||
match groups.iter_mut().find(|(origin, _)| *origin == line.origin) {
|
||||
Some((_, text)) => {
|
||||
text.push_str(&line.text);
|
||||
text.push('\n');
|
||||
}
|
||||
None => groups.push((line.origin, format!("{}\n", line.text))),
|
||||
}
|
||||
}
|
||||
groups
|
||||
}
|
||||
|
||||
/// 信号采集器。
|
||||
pub trait SignalCollector {
|
||||
/// 采哪种信号。
|
||||
fn kind(&self) -> SignalKind;
|
||||
|
||||
/// 采集并写入 `out`;返回本次采到的源文件。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 读盘失败 / 解析失败时返回 [`CollectError`];调用方([`collect_all`])会收集错误继续。
|
||||
fn collect(
|
||||
&self,
|
||||
ctx: &CollectionContext,
|
||||
out: &mut SignalBundle,
|
||||
) -> Result<CollectedSources, CollectError>;
|
||||
}
|
||||
|
||||
/// 按顺序跑一批采集器,单源失败只记录不中断。
|
||||
pub fn collect_all(
|
||||
collectors: &[&dyn SignalCollector],
|
||||
ctx: &CollectionContext,
|
||||
) -> (SignalBundle, Vec<CollectError>) {
|
||||
let mut bundle = SignalBundle::new();
|
||||
let mut errors = Vec::new();
|
||||
for collector in collectors {
|
||||
if let Err(error) = collector.collect(ctx, &mut bundle) {
|
||||
errors.push(error);
|
||||
}
|
||||
}
|
||||
(bundle, errors)
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
//! 规则引擎:全量匹配 → 去重 → 抑制关系 → 置信度排序 → 结构化报告。
|
||||
//!
|
||||
//! 与 HMCL `CrashReportAnalyzer.analyze` + `GameCrashWindow.merge` 的差异(analyse/02 §7.6 §7.10):
|
||||
//! 1. 同一条规则只留**最优证据来源**的那次命中(崩溃报告 > hs_err > 日志 > stdout > stderr);
|
||||
//! 2. 结论带 `priority`/`confidence`,主因在 `findings[0]`,被抑制的次生结论进 `secondary`;
|
||||
//! 3. 输出是纯数据 [`DiagnosisRecord`](可序列化、可跨线程),不再泄漏匹配器状态;
|
||||
//! 4. 退出码 / hs_err 这类**结构化信号**由内置结论补位(见 `synthetic.rs`)。
|
||||
//!
|
||||
//! 性能取舍:`scope: line` 逐行扫(59 条 × 行数,弱机可接受);`scope: file` 的跨行规则
|
||||
//! 只在 ≤1 MiB 文本上跑,超长日志取首尾各 512 KiB。
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::diagnose::collectors::{ExitSignal, SignalOrigin};
|
||||
use crate::diagnose::hs_err::HsErrReport;
|
||||
use crate::diagnose::rules::{CompiledRule, MatchScope};
|
||||
|
||||
#[path = "finding.rs"]
|
||||
mod finding;
|
||||
#[path = "synthetic.rs"]
|
||||
mod synthetic;
|
||||
|
||||
pub use finding::{
|
||||
Candidate, DiagnosisRecord, DiagnosisReport, Evidence, ExitRecord, Finding,
|
||||
CONCLUSIVE_CONFIDENCE,
|
||||
};
|
||||
pub use synthetic::synthetic_findings;
|
||||
|
||||
/// 超过这个字节数就不再整份扫描 `scope: file` 规则(取首尾窗口)。
|
||||
const FILE_SCOPE_LIMIT: usize = 1024 * 1024;
|
||||
/// 超长文本的扫描窗口(首尾各一份)。
|
||||
const FILE_SCOPE_WINDOW: usize = 512 * 1024;
|
||||
/// 证据行截断长度(避免把整份栈塞进诊断输出)。
|
||||
const EVIDENCE_MAX_CHARS: usize = 300;
|
||||
|
||||
/// 待匹配的一段文本(一个信号来源一份)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Haystack {
|
||||
/// 来源(决定证据前缀与取证优先级)。
|
||||
pub origin: SignalOrigin,
|
||||
/// 文本。
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
impl Haystack {
|
||||
/// 构造。
|
||||
#[must_use]
|
||||
pub fn new(origin: SignalOrigin, text: impl Into<String>) -> Self {
|
||||
Self {
|
||||
origin,
|
||||
text: text.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 引擎输入:退出信号 + 若干待匹配文本 +(可选)解析好的 hs_err。
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct DiagnosisInput {
|
||||
/// 退出信号。
|
||||
pub exit: Option<ExitSignal>,
|
||||
/// 待匹配文本(按 `SignalOrigin::evidence_rank` 优先取证据)。
|
||||
pub haystacks: Vec<Haystack>,
|
||||
/// 解析好的 `hs_err_pid*.log`(产生内置结论,其摘要也已进 `haystacks`)。
|
||||
pub hs_err: Option<HsErrReport>,
|
||||
}
|
||||
|
||||
impl DiagnosisInput {
|
||||
/// 空输入。
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// 带退出信号。
|
||||
#[must_use]
|
||||
pub fn with_exit(mut self, exit: ExitSignal) -> Self {
|
||||
self.exit = Some(exit);
|
||||
self
|
||||
}
|
||||
|
||||
/// 追加一段待匹配文本。
|
||||
pub fn push_text(&mut self, origin: SignalOrigin, text: impl Into<String>) {
|
||||
self.haystacks.push(Haystack::new(origin, text));
|
||||
}
|
||||
|
||||
/// 追加解析好的 hs_err:摘要进待匹配文本,结构留给内置结论。
|
||||
pub fn push_hs_err(&mut self, report: HsErrReport) {
|
||||
self.haystacks
|
||||
.push(Haystack::new(SignalOrigin::HsErr, report.digest()));
|
||||
self.hs_err = Some(report);
|
||||
}
|
||||
|
||||
/// 是否什么信号都没有。
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.exit.is_none() && self.haystacks.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// 规则引擎(规则只编译一次,可跨线程共享)。
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Diagnoser {
|
||||
rules: Vec<CompiledRule>,
|
||||
}
|
||||
|
||||
impl Diagnoser {
|
||||
/// 用一组已编译的规则构造。
|
||||
#[must_use]
|
||||
pub fn new(rules: Vec<CompiledRule>) -> Self {
|
||||
Self { rules }
|
||||
}
|
||||
|
||||
/// 规则表。
|
||||
#[must_use]
|
||||
pub fn rules(&self) -> &[CompiledRule] {
|
||||
&self.rules
|
||||
}
|
||||
|
||||
/// 跑一遍诊断。
|
||||
#[must_use]
|
||||
pub fn analyze(&self, input: &DiagnosisInput) -> DiagnosisReport {
|
||||
let mut ordered = input.haystacks.iter().collect::<Vec<_>>();
|
||||
ordered.sort_by_key(|haystack| haystack.origin.evidence_rank());
|
||||
|
||||
let mut findings = Vec::new();
|
||||
for rule in &self.rules {
|
||||
for haystack in &ordered {
|
||||
if let Some(found) = scan(rule, haystack) {
|
||||
findings.push(build_finding(rule, haystack, &found));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
findings.extend(synthetic_findings(input));
|
||||
|
||||
let (mut findings, mut secondary) = split_suppressed(findings);
|
||||
findings.sort_by(Finding::rank_cmp);
|
||||
secondary.sort_by(Finding::rank_cmp);
|
||||
|
||||
DiagnosisReport {
|
||||
findings,
|
||||
secondary,
|
||||
exit: input.exit.clone(),
|
||||
rules_evaluated: self.rules.len(),
|
||||
hs_err: input.hs_err.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次正则命中(绝对偏移 + 命名捕获组)。
|
||||
#[derive(Debug)]
|
||||
struct RawMatch {
|
||||
groups: BTreeMap<String, String>,
|
||||
offset: usize,
|
||||
}
|
||||
|
||||
fn scan(rule: &CompiledRule, haystack: &Haystack) -> Option<RawMatch> {
|
||||
match rule.scope() {
|
||||
MatchScope::Line => scan_lines(rule, &haystack.text),
|
||||
MatchScope::Block => scan_blocks(rule, &haystack.text),
|
||||
MatchScope::File => scan_file(rule, &haystack.text),
|
||||
}
|
||||
}
|
||||
|
||||
/// 逐行匹配(返回命中行的起始偏移,行号即证据行号)。
|
||||
fn scan_lines(rule: &CompiledRule, text: &str) -> Option<RawMatch> {
|
||||
let mut offset = 0;
|
||||
for chunk in text.split_inclusive('\n') {
|
||||
let line = chunk.trim_end_matches(['\r', '\n']);
|
||||
if let Some(caps) = rule.regex().captures(line) {
|
||||
return Some(capture_groups(rule, &caps, offset));
|
||||
}
|
||||
offset += chunk.len();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 按空行切块匹配(一段异常 + 其上下文)。
|
||||
///
|
||||
/// 分隔符是**空行**而不是字面 `"\n\n"`:日志文件可能是 CRLF(Windows 上 `latest.log` 常见),
|
||||
/// 按字节找 `\n\n` 会把整份日志当成一个块,`scope: block` 的规则就退化成整份扫描。
|
||||
fn scan_blocks(rule: &CompiledRule, text: &str) -> Option<RawMatch> {
|
||||
let mut block_start = 0;
|
||||
let mut offset = 0;
|
||||
for chunk in text.split_inclusive('\n') {
|
||||
offset += chunk.len();
|
||||
if chunk.trim().is_empty() {
|
||||
if let Some(caps) = rule.regex().captures(&text[block_start..offset]) {
|
||||
return Some(capture_groups(rule, &caps, block_start));
|
||||
}
|
||||
block_start = offset;
|
||||
}
|
||||
}
|
||||
if block_start < text.len() {
|
||||
let caps = rule.regex().captures(&text[block_start..])?;
|
||||
return Some(capture_groups(rule, &caps, block_start));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 整份匹配;超长文本只看首尾窗口(跨行贪婪规则的性能边界)。
|
||||
fn scan_file(rule: &CompiledRule, text: &str) -> Option<RawMatch> {
|
||||
if text.len() <= FILE_SCOPE_LIMIT {
|
||||
let caps = rule.regex().captures(text)?;
|
||||
let offset = caps.get(0).map_or(0, |found| found.start());
|
||||
return Some(capture_groups(rule, &caps, offset));
|
||||
}
|
||||
let head_end = ceil_boundary(text, FILE_SCOPE_WINDOW);
|
||||
if let Some(caps) = rule.regex().captures(&text[..head_end]) {
|
||||
let offset = caps.get(0).map_or(0, |found| found.start());
|
||||
return Some(capture_groups(rule, &caps, offset));
|
||||
}
|
||||
let tail_start = floor_boundary(text, text.len() - FILE_SCOPE_WINDOW);
|
||||
let caps = rule.regex().captures(&text[tail_start..])?;
|
||||
let offset = tail_start + caps.get(0).map_or(0, |found| found.start());
|
||||
Some(capture_groups(rule, &caps, offset))
|
||||
}
|
||||
|
||||
fn capture_groups(rule: &CompiledRule, caps: ®ex::Captures<'_>, offset: usize) -> RawMatch {
|
||||
let mut groups = BTreeMap::new();
|
||||
for name in rule.regex().capture_names().flatten() {
|
||||
if let Some(found) = caps.name(name) {
|
||||
groups.insert(name.to_owned(), found.as_str().trim().to_owned());
|
||||
}
|
||||
}
|
||||
RawMatch { groups, offset }
|
||||
}
|
||||
|
||||
fn build_finding(compiled: &CompiledRule, haystack: &Haystack, found: &RawMatch) -> Finding {
|
||||
let rule = compiled.rule();
|
||||
let (message, detail) = rule.cause.render(&found.groups);
|
||||
Finding {
|
||||
rule_id: rule.id.clone(),
|
||||
category: compiled.category(),
|
||||
severity: rule.severity,
|
||||
confidence: rule.confidence.clamp(0.0, 1.0),
|
||||
priority: rule.priority,
|
||||
message_key: rule.cause.key.clone(),
|
||||
message,
|
||||
detail,
|
||||
evidence: vec![Evidence::new(
|
||||
haystack.origin,
|
||||
line_of(&haystack.text, found.offset),
|
||||
snippet(&haystack.text, found.offset),
|
||||
)],
|
||||
fix_actions: rule.fix_action.clone(),
|
||||
groups: found.groups.clone(),
|
||||
suppress: rule.suppress.clone(),
|
||||
suppressed_by: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 把被 suppress 的结论拆到次生列表(不丢弃,UI 折叠展示)。
|
||||
fn split_suppressed(findings: Vec<Finding>) -> (Vec<Finding>, Vec<Finding>) {
|
||||
let suppressers = findings
|
||||
.iter()
|
||||
.map(|finding| {
|
||||
(
|
||||
finding.rule_id.clone(),
|
||||
finding.priority,
|
||||
finding.suppress.clone(),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let mut kept = Vec::new();
|
||||
let mut suppressed = Vec::new();
|
||||
for mut finding in findings {
|
||||
let mut best: Option<(&str, u8)> = None;
|
||||
for (id, priority, suppress) in &suppressers {
|
||||
if *id == finding.rule_id || !suppress.iter().any(|item| item == &finding.rule_id) {
|
||||
continue;
|
||||
}
|
||||
if best.is_none_or(|(_, best_priority)| *priority > best_priority) {
|
||||
best = Some((id.as_str(), *priority));
|
||||
}
|
||||
}
|
||||
match best {
|
||||
Some((id, _)) => {
|
||||
finding.suppressed_by = Some(id.to_owned());
|
||||
suppressed.push(finding);
|
||||
}
|
||||
None => kept.push(finding),
|
||||
}
|
||||
}
|
||||
(kept, suppressed)
|
||||
}
|
||||
|
||||
fn line_of(text: &str, offset: usize) -> usize {
|
||||
let offset = offset.min(text.len());
|
||||
1 + text[..offset].matches('\n').count()
|
||||
}
|
||||
|
||||
fn snippet(text: &str, offset: usize) -> String {
|
||||
let offset = offset.min(text.len());
|
||||
let start = text[..offset].rfind('\n').map_or(0, |index| index + 1);
|
||||
let end = text[start..]
|
||||
.find('\n')
|
||||
.map_or(text.len(), |index| start + index);
|
||||
let line = text[start..end].trim();
|
||||
let mut out = line.chars().take(EVIDENCE_MAX_CHARS).collect::<String>();
|
||||
if line.chars().count() > EVIDENCE_MAX_CHARS {
|
||||
out.push('…');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn ceil_boundary(text: &str, mut index: usize) -> usize {
|
||||
index = index.min(text.len());
|
||||
while index < text.len() && !text.is_char_boundary(index) {
|
||||
index += 1;
|
||||
}
|
||||
index
|
||||
}
|
||||
|
||||
fn floor_boundary(text: &str, mut index: usize) -> usize {
|
||||
index = index.min(text.len());
|
||||
while index > 0 && !text.is_char_boundary(index) {
|
||||
index -= 1;
|
||||
}
|
||||
index
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
//! 环境快照与 mod 清单(analyse/02 §7.1 信号 E)。
|
||||
//!
|
||||
//! HMCL 只在崩溃窗口里散着展示这些信息;我们做成结构化数据,
|
||||
//! 既进诊断输出(`environment.json` 一样的机器可读上下文),也让规则能按版本号做前置判断。
|
||||
//! 由 `collectors.rs` 用 `#[path]` 声明并统一导出。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// 一个已装组件的版本(Forge/Fabric/OptiFine…)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ComponentVersion {
|
||||
/// 组件名(`forge`/`fabric`/`optifine`/`minecraft`…)。
|
||||
pub name: String,
|
||||
/// 版本串。
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
impl ComponentVersion {
|
||||
/// 构造。
|
||||
#[must_use]
|
||||
pub fn new(name: impl Into<String>, version: impl Into<String>) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
version: version.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个已装 mod(供交叉验证「冲突/mixin 失败」类结论,analyse/02 §8.4-10)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct ModRecord {
|
||||
/// modid(解析不出时为空)。
|
||||
pub id: String,
|
||||
/// 展示名。
|
||||
pub name: Option<String>,
|
||||
/// 版本。
|
||||
pub version: Option<String>,
|
||||
/// jar 文件名(诊断包里给人看)。
|
||||
pub file: Option<String>,
|
||||
/// 是否启用(`.disabled` 视为未启用)。
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
impl ModRecord {
|
||||
/// 从文件名构造(不解 jar,P0 足够:冲突类结论要的是「装了哪些文件」)。
|
||||
#[must_use]
|
||||
pub fn from_file_name(file: &str) -> Self {
|
||||
let enabled = !file.ends_with(".disabled");
|
||||
let stem = file
|
||||
.trim_end_matches(".disabled")
|
||||
.trim_end_matches(".jar")
|
||||
.to_owned();
|
||||
let (name, version) = stem
|
||||
.rsplit_once('-')
|
||||
.filter(|(_, version)| version.chars().next().is_some_and(|ch| ch.is_ascii_digit()))
|
||||
.map_or((stem.clone(), None), |(name, version)| {
|
||||
(name.to_owned(), Some(version.to_owned()))
|
||||
});
|
||||
Self {
|
||||
id: name.to_lowercase(),
|
||||
name: Some(name),
|
||||
version,
|
||||
file: Some(file.to_owned()),
|
||||
enabled,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动环境快照(Java/内存/OS/组件/mod 清单)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct EnvironmentSnapshot {
|
||||
/// 启动器版本(`ull 0.1.0`)。
|
||||
pub launcher: Option<String>,
|
||||
/// 实例 id。
|
||||
pub instance_id: Option<String>,
|
||||
/// 游戏版本。
|
||||
pub game_version: Option<String>,
|
||||
/// 操作系统名(`Windows 10.0.19045`)。
|
||||
pub os_name: Option<String>,
|
||||
/// 架构(`x86_64` / `aarch64`)。
|
||||
pub os_arch: Option<String>,
|
||||
/// 物理内存(MB)。
|
||||
pub physical_memory_mb: Option<u64>,
|
||||
/// Java 可执行文件。
|
||||
pub java_path: Option<PathBuf>,
|
||||
/// Java 大版本。
|
||||
pub java_major: Option<u32>,
|
||||
/// Java 位数(32 / 64)。
|
||||
pub java_bits: Option<u32>,
|
||||
/// `-Xmx`(MB)。
|
||||
pub xmx_mb: Option<u32>,
|
||||
/// `-Xms`(MB)。
|
||||
pub xms_mb: Option<u32>,
|
||||
/// 工作目录(跑游戏的 cwd)。
|
||||
pub run_dir: Option<PathBuf>,
|
||||
/// 已装组件版本。
|
||||
pub components: Vec<ComponentVersion>,
|
||||
/// 已装 mod。
|
||||
pub mods: Vec<ModRecord>,
|
||||
}
|
||||
|
||||
impl EnvironmentSnapshot {
|
||||
/// 一行人类可读摘要(证据行 / UI 头部)。
|
||||
#[must_use]
|
||||
pub fn summary(&self) -> String {
|
||||
let os = format!(
|
||||
"{} {}",
|
||||
self.os_name.as_deref().unwrap_or("未知系统"),
|
||||
self.os_arch.as_deref().unwrap_or("")
|
||||
);
|
||||
let java = self.java_major.map_or_else(
|
||||
|| "Java 未知".to_owned(),
|
||||
|major| match self.java_bits {
|
||||
Some(bits) => format!("Java {major}({bits} 位)"),
|
||||
None => format!("Java {major}"),
|
||||
},
|
||||
);
|
||||
let memory = match (self.xmx_mb, self.physical_memory_mb) {
|
||||
(Some(xmx), Some(physical)) => format!("-Xmx {xmx} MB / 物理 {physical} MB"),
|
||||
(Some(xmx), None) => format!("-Xmx {xmx} MB"),
|
||||
(None, Some(physical)) => format!("物理 {physical} MB"),
|
||||
(None, None) => "内存未知".to_owned(),
|
||||
};
|
||||
let mods = self.mods.len();
|
||||
format!("{} / {} / {} / {} 个 mod", os.trim(), java, memory, mods)
|
||||
}
|
||||
|
||||
/// 是不是 32 位 Java(`-Xmx` 超过 ~1.5 GB 必崩的直接证据)。
|
||||
#[must_use]
|
||||
pub fn is_java_32bit(&self) -> bool {
|
||||
self.java_bits == Some(32)
|
||||
}
|
||||
|
||||
/// 组件版本(按名字查,`optifine` 之类的冲突规则用)。
|
||||
#[must_use]
|
||||
pub fn component(&self, name: &str) -> Option<&str> {
|
||||
self.components
|
||||
.iter()
|
||||
.find(|component| component.name.eq_ignore_ascii_case(name))
|
||||
.map(|component| component.version.as_str())
|
||||
}
|
||||
|
||||
/// 是否装了某个 mod(按 id 或文件名模糊匹配)。
|
||||
#[must_use]
|
||||
pub fn has_mod(&self, id: &str) -> bool {
|
||||
let needle = id.to_ascii_lowercase();
|
||||
self.mods.iter().any(|record| {
|
||||
record.id.contains(&needle)
|
||||
|| record
|
||||
.file
|
||||
.as_deref()
|
||||
.is_some_and(|file| file.to_ascii_lowercase().contains(&needle))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
//! 结论与报告的数据结构(纯数据,可序列化,不持有匹配器状态)。
|
||||
//!
|
||||
//! HMCL 的 `CrashReportAnalyzer.Result` 直接把 `Matcher` 传出去(不可序列化、不能跨线程),
|
||||
//! 这里改成「证据 + 已渲染文案 + 物化动作」的结构化记录(analyse/02 §7.10-6)。
|
||||
//!
|
||||
//! 由 `engine.rs` 用 `#[path]` 声明并统一导出;拆分只为守单文件 400 行上限。
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::diagnose::collectors::{ExitSignal, SignalOrigin};
|
||||
use crate::diagnose::hs_err::HsErrReport;
|
||||
use crate::diagnose::rules::{FixAction, MaterializedFixAction, RuleCategory, Severity};
|
||||
|
||||
/// 「结论足够确定」的可信度门槛:低于它就该交给 AI 深化(PlanDoc/06 §2.3)。
|
||||
pub const CONCLUSIVE_CONFIDENCE: f32 = 0.8;
|
||||
|
||||
/// 一条证据(哪个来源的第几行的哪段文本)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Evidence {
|
||||
/// 来源。
|
||||
pub origin: SignalOrigin,
|
||||
/// 行号(1 起;结构化信号为 0)。
|
||||
pub line: usize,
|
||||
/// 文本片段(≤300 字符)。
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
impl Evidence {
|
||||
/// 构造。
|
||||
#[must_use]
|
||||
pub fn new(origin: SignalOrigin, line: usize, text: impl Into<String>) -> Self {
|
||||
Self {
|
||||
origin,
|
||||
line,
|
||||
text: text.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 诊断输出里的字符串形式:`"<origin>:<line> <text>"`。
|
||||
#[must_use]
|
||||
pub fn render(&self) -> String {
|
||||
format!("{}:{} {}", self.origin, self.line, self.text)
|
||||
}
|
||||
}
|
||||
|
||||
/// 一条结论。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Finding {
|
||||
/// 规则 id(内置结论用 `signal.*` / `hs_err.*`)。
|
||||
pub rule_id: String,
|
||||
/// 分类。
|
||||
pub category: RuleCategory,
|
||||
/// 严重度。
|
||||
pub severity: Severity,
|
||||
/// 可信度。
|
||||
pub confidence: f32,
|
||||
/// 主因权重。
|
||||
pub priority: u8,
|
||||
/// i18n / AI 引用 id。
|
||||
pub message_key: String,
|
||||
/// 渲染后的中文结论(M16 `diagnose_crash` 直接给模型看)。
|
||||
pub message: String,
|
||||
/// 细化说明 / 处理建议。
|
||||
pub detail: Option<String>,
|
||||
/// 证据。
|
||||
pub evidence: Vec<Evidence>,
|
||||
/// 六维修复动作。
|
||||
pub fix_actions: Vec<FixAction>,
|
||||
/// 规则捕获组(供动作模板替换)。
|
||||
pub groups: BTreeMap<String, String>,
|
||||
/// 本结论会抑制哪些规则 id。
|
||||
pub suppress: Vec<String>,
|
||||
/// 被哪条结论抑制(`Some` 即次生结论)。
|
||||
pub suppressed_by: Option<String>,
|
||||
}
|
||||
|
||||
impl Finding {
|
||||
/// 一条只有骨架的结论(内置结论的构造起点,其余字段由调用方填)。
|
||||
#[must_use]
|
||||
pub fn skeleton(
|
||||
rule_id: &str,
|
||||
category: RuleCategory,
|
||||
severity: Severity,
|
||||
confidence: f32,
|
||||
priority: u8,
|
||||
message_key: &str,
|
||||
message: &str,
|
||||
) -> Self {
|
||||
Self {
|
||||
rule_id: rule_id.to_owned(),
|
||||
category,
|
||||
severity,
|
||||
confidence,
|
||||
priority,
|
||||
message_key: message_key.to_owned(),
|
||||
message: message.to_owned(),
|
||||
detail: None,
|
||||
evidence: Vec::new(),
|
||||
fix_actions: Vec::new(),
|
||||
groups: BTreeMap::new(),
|
||||
suppress: Vec::new(),
|
||||
suppressed_by: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 物化后的修复动作(模板已替换)。
|
||||
#[must_use]
|
||||
pub fn materialized_actions(&self) -> Vec<MaterializedFixAction> {
|
||||
self.fix_actions
|
||||
.iter()
|
||||
.map(|action| action.materialize(&self.groups))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 转成诊断输出里的候选。
|
||||
#[must_use]
|
||||
pub fn to_candidate(&self) -> Candidate {
|
||||
Candidate {
|
||||
id: self.rule_id.clone(),
|
||||
severity: self.severity.as_str(),
|
||||
category: self.category.as_str(),
|
||||
confidence: self.confidence,
|
||||
message_key: self.message_key.clone(),
|
||||
message: self.message.clone(),
|
||||
detail: self.detail.clone(),
|
||||
evidence: self.evidence.iter().map(Evidence::render).collect(),
|
||||
fix_actions: self.materialized_actions(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 排序:priority 高 → confidence 高 → id 字典序(保证确定性)。
|
||||
#[must_use]
|
||||
pub fn rank_cmp(&self, other: &Self) -> std::cmp::Ordering {
|
||||
other
|
||||
.priority
|
||||
.cmp(&self.priority)
|
||||
.then_with(|| other.confidence.total_cmp(&self.confidence))
|
||||
.then_with(|| self.rule_id.cmp(&other.rule_id))
|
||||
}
|
||||
}
|
||||
|
||||
/// 诊断输出里的一条候选结论(字段名与 `launch-接口约定.md` §3 `diagnosis` 对齐)。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Candidate {
|
||||
/// 规则 id。
|
||||
pub id: String,
|
||||
/// 严重度。
|
||||
pub severity: &'static str,
|
||||
/// 分类。
|
||||
pub category: &'static str,
|
||||
/// 可信度。
|
||||
pub confidence: f32,
|
||||
/// i18n / AI 引用 id。
|
||||
pub message_key: String,
|
||||
/// 渲染后的中文结论。
|
||||
pub message: String,
|
||||
/// 细化说明。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub detail: Option<String>,
|
||||
/// 证据字符串。
|
||||
pub evidence: Vec<String>,
|
||||
/// 六维修复动作(M16/UI 一键修复入口)。
|
||||
pub fix_actions: Vec<MaterializedFixAction>,
|
||||
}
|
||||
|
||||
/// 退出信号摘要(诊断输出用)。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ExitRecord {
|
||||
/// 退出码。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub code: Option<i32>,
|
||||
/// 退出类型(与 launch 约定同值)。
|
||||
pub exit_type: &'static str,
|
||||
/// 疑似静默失败。
|
||||
pub silent_failure: bool,
|
||||
/// 存活毫秒数。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub uptime_ms: Option<u64>,
|
||||
}
|
||||
|
||||
/// 交给 launch 写入 `diagnosis` 事件的结构化结果。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct DiagnosisRecord {
|
||||
/// 结论来源(`rules` / `ai`)。
|
||||
pub source: &'static str,
|
||||
/// 规则没把握、需要 AI 或用户补充信息。
|
||||
pub needs_more_info: bool,
|
||||
/// 结论(`candidates[0]` 是主因)。
|
||||
pub candidates: Vec<Candidate>,
|
||||
/// 被抑制的次生结论。
|
||||
pub secondary: Vec<Candidate>,
|
||||
/// 本次判定用的退出信号摘要。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub exit: Option<ExitRecord>,
|
||||
}
|
||||
|
||||
/// 一次诊断的完整结果。
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct DiagnosisReport {
|
||||
/// 结论(已排序,主因在前)。
|
||||
pub findings: Vec<Finding>,
|
||||
/// 被抑制的次生结论。
|
||||
pub secondary: Vec<Finding>,
|
||||
/// 本次判定用的退出信号。
|
||||
pub exit: Option<ExitSignal>,
|
||||
/// 评估了多少条规则(可观测性)。
|
||||
pub rules_evaluated: usize,
|
||||
/// 解析出的 hs_err(若有)。
|
||||
pub hs_err: Option<HsErrReport>,
|
||||
}
|
||||
|
||||
impl DiagnosisReport {
|
||||
/// 主因(可能没有)。
|
||||
#[must_use]
|
||||
pub fn primary(&self) -> Option<&Finding> {
|
||||
self.findings.first()
|
||||
}
|
||||
|
||||
/// 规则层有没有把握:没有任何结论,或主因可信度 < [`CONCLUSIVE_CONFIDENCE`]。
|
||||
#[must_use]
|
||||
pub fn needs_more_info(&self) -> bool {
|
||||
self.primary()
|
||||
.is_none_or(|finding| finding.confidence < CONCLUSIVE_CONFIDENCE)
|
||||
}
|
||||
|
||||
/// 去重后的修复动作(主因优先,供 UI 一键修复 / agent 直接执行)。
|
||||
#[must_use]
|
||||
pub fn fix_actions(&self) -> Vec<MaterializedFixAction> {
|
||||
let mut out: Vec<MaterializedFixAction> = Vec::new();
|
||||
for action in self.findings.iter().flat_map(Finding::materialized_actions) {
|
||||
if !out.contains(&action) {
|
||||
out.push(action);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 转成可序列化的诊断记录。
|
||||
#[must_use]
|
||||
pub fn to_record(&self) -> DiagnosisRecord {
|
||||
DiagnosisRecord {
|
||||
source: "rules",
|
||||
needs_more_info: self.needs_more_info(),
|
||||
candidates: self.findings.iter().map(Finding::to_candidate).collect(),
|
||||
secondary: self.secondary.iter().map(Finding::to_candidate).collect(),
|
||||
exit: self.exit.as_ref().map(|exit| ExitRecord {
|
||||
code: exit.code,
|
||||
exit_type: exit.exit_type.as_str(),
|
||||
silent_failure: exit.silent_failure,
|
||||
uptime_ms: exit.uptime_ms,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
//! 六维修复动作本体:枚举、维度、以及「代入捕获组 → agent 工具参数」的物化。
|
||||
//!
|
||||
//! 六个维度与 analyse/02 §7.7 的动作分类一一对应,也是 M16 `ToolSpec` 的参数来源
|
||||
//! (契约见同目录 `CONTRACT.md` §3)。
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::fix_params::{AdjustMemory, ChangeGraphics, ChangeJava, Reinstall, RemoveMod, SeekHelp};
|
||||
use super::substitute;
|
||||
|
||||
/// 六维修复动作(YAML 里 `kind:` 选维度)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum FixAction {
|
||||
/// 换 Java。
|
||||
ChangeJava(ChangeJava),
|
||||
/// 调内存。
|
||||
AdjustMemory(AdjustMemory),
|
||||
/// 改图形设置。
|
||||
ChangeGraphics(ChangeGraphics),
|
||||
/// 删/禁/换 mod。
|
||||
RemoveMod(RemoveMod),
|
||||
/// 重装/修组件。
|
||||
Reinstall(Reinstall),
|
||||
/// 求助上报。
|
||||
SeekHelp(SeekHelp),
|
||||
}
|
||||
|
||||
/// 六维动作的维度枚举(不含参数,便于判等/去重/映射工具)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum FixActionKind {
|
||||
/// 换 Java。
|
||||
ChangeJava,
|
||||
/// 调内存。
|
||||
AdjustMemory,
|
||||
/// 改图形设置。
|
||||
ChangeGraphics,
|
||||
/// 删/禁/换 mod。
|
||||
RemoveMod,
|
||||
/// 重装/修组件。
|
||||
Reinstall,
|
||||
/// 求助上报。
|
||||
SeekHelp,
|
||||
}
|
||||
|
||||
impl FixActionKind {
|
||||
/// 稳定字符串(诊断输出的 `kind` 字段,与 YAML 的 `kind:` 一致)。
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::ChangeJava => "change_java",
|
||||
Self::AdjustMemory => "adjust_memory",
|
||||
Self::ChangeGraphics => "change_graphics",
|
||||
Self::RemoveMod => "remove_mod",
|
||||
Self::Reinstall => "reinstall",
|
||||
Self::SeekHelp => "seek_help",
|
||||
}
|
||||
}
|
||||
|
||||
/// 建议的 agent 工具 id。
|
||||
///
|
||||
/// M16 的 P0 工具只有 5 个(`get_config`/`set_config`/`list_game_sessions`/`read_game_log`/
|
||||
/// `diagnose_crash`),因此它按 `kind` 桥接;这里的 id 是 P1 落地细分工具时的直接映射。
|
||||
#[must_use]
|
||||
pub const fn agent_tool(self) -> &'static str {
|
||||
match self {
|
||||
Self::ChangeJava => "java.set",
|
||||
Self::AdjustMemory => "memory.set",
|
||||
Self::ChangeGraphics => "graphics.set",
|
||||
Self::RemoveMod => "mod.remove",
|
||||
Self::Reinstall => "component.reinstall",
|
||||
Self::SeekHelp => "diagnostics.report",
|
||||
}
|
||||
}
|
||||
|
||||
/// 全部六维(测试与 UI 遍历用)。
|
||||
#[must_use]
|
||||
pub const fn all() -> [Self; 6] {
|
||||
[
|
||||
Self::ChangeJava,
|
||||
Self::AdjustMemory,
|
||||
Self::ChangeGraphics,
|
||||
Self::RemoveMod,
|
||||
Self::Reinstall,
|
||||
Self::SeekHelp,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// 已做模板替换/单位换算的动作,可直接当 agent 工具参数。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct MaterializedFixAction {
|
||||
/// 维度。
|
||||
pub kind: FixActionKind,
|
||||
/// agent 工具 id(见 [`FixActionKind::agent_tool`])。
|
||||
pub tool: &'static str,
|
||||
/// 扁平字符串参数表。
|
||||
pub args: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl FixAction {
|
||||
/// 维度。
|
||||
#[must_use]
|
||||
pub const fn kind(&self) -> FixActionKind {
|
||||
match self {
|
||||
Self::ChangeJava(_) => FixActionKind::ChangeJava,
|
||||
Self::AdjustMemory(_) => FixActionKind::AdjustMemory,
|
||||
Self::ChangeGraphics(_) => FixActionKind::ChangeGraphics,
|
||||
Self::RemoveMod(_) => FixActionKind::RemoveMod,
|
||||
Self::Reinstall(_) => FixActionKind::Reinstall,
|
||||
Self::SeekHelp(_) => FixActionKind::SeekHelp,
|
||||
}
|
||||
}
|
||||
|
||||
/// 代入规则命名捕获组,产出 agent 工具可直接用的参数表。
|
||||
#[must_use]
|
||||
pub fn materialize(&self, groups: &BTreeMap<String, String>) -> MaterializedFixAction {
|
||||
let kind = self.kind();
|
||||
let mut args = BTreeMap::new();
|
||||
match self {
|
||||
Self::ChangeJava(p) => {
|
||||
let derived = p
|
||||
.class_file_version_group
|
||||
.as_ref()
|
||||
.and_then(|group| groups.get(group))
|
||||
.and_then(|raw| raw.trim().parse::<u32>().ok())
|
||||
.and_then(class_file_to_java_major);
|
||||
put_opt(&mut args, "required_major", p.required_major.or(derived));
|
||||
put_opt(&mut args, "max_major", p.max_major);
|
||||
put_str(&mut args, "vendor", p.vendor.as_str());
|
||||
put_opt_str(&mut args, "note", render_opt(&p.note, groups));
|
||||
}
|
||||
Self::AdjustMemory(p) => {
|
||||
put_opt(&mut args, "suggested_xmx_mb", p.suggested_xmx_mb);
|
||||
put_opt(&mut args, "min_xmx_mb", p.min_xmx_mb);
|
||||
put_bool(&mut args, "need_64bit_java", p.need_64bit_java);
|
||||
put_bool(&mut args, "increase_system_swap", p.increase_system_swap);
|
||||
put_opt_str(&mut args, "note", render_opt(&p.note, groups));
|
||||
}
|
||||
Self::ChangeGraphics(p) => {
|
||||
put_str(&mut args, "setting", p.setting.as_str());
|
||||
put_bool(&mut args, "update_driver", p.update_driver);
|
||||
put_opt_str(&mut args, "suggested_value", p.suggested_value.clone());
|
||||
put_opt_str(&mut args, "note", render_opt(&p.note, groups));
|
||||
}
|
||||
Self::RemoveMod(p) => {
|
||||
let ids = p
|
||||
.mod_ids
|
||||
.iter()
|
||||
.map(|id| substitute(id, groups))
|
||||
.collect::<Vec<_>>();
|
||||
if !ids.is_empty() {
|
||||
args.insert("mod_ids".to_owned(), ids.join(","));
|
||||
}
|
||||
put_str(&mut args, "action", p.action.as_str());
|
||||
put_opt_str(&mut args, "note", render_opt(&p.note, groups));
|
||||
}
|
||||
Self::Reinstall(p) => {
|
||||
put_str(&mut args, "component", p.component.as_str());
|
||||
put_opt_str(&mut args, "path_hint", render_opt(&p.path_hint, groups));
|
||||
put_opt_str(&mut args, "note", render_opt(&p.note, groups));
|
||||
}
|
||||
Self::SeekHelp(p) => {
|
||||
put_bool(&mut args, "export_diagnostics", p.export_diagnostics);
|
||||
if !p.needs_more_info.is_empty() {
|
||||
args.insert("needs_more_info".to_owned(), p.needs_more_info.join("; "));
|
||||
}
|
||||
put_opt_str(&mut args, "note", render_opt(&p.note, groups));
|
||||
}
|
||||
}
|
||||
MaterializedFixAction {
|
||||
kind,
|
||||
tool: kind.agent_tool(),
|
||||
args,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 可选的模板字段:`Some` 时做 `{group}` 替换,`None` 保持 `None`。
|
||||
fn render_opt(value: &Option<String>, groups: &BTreeMap<String, String>) -> Option<String> {
|
||||
value.as_ref().map(|text| substitute(text, groups))
|
||||
}
|
||||
|
||||
fn put_str(args: &mut BTreeMap<String, String>, key: &str, value: &str) {
|
||||
args.insert(key.to_owned(), value.to_owned());
|
||||
}
|
||||
|
||||
fn put_opt_str(args: &mut BTreeMap<String, String>, key: &str, value: Option<String>) {
|
||||
if let Some(value) = value {
|
||||
args.insert(key.to_owned(), value);
|
||||
}
|
||||
}
|
||||
|
||||
fn put_opt(args: &mut BTreeMap<String, String>, key: &str, value: Option<u32>) {
|
||||
if let Some(value) = value {
|
||||
args.insert(key.to_owned(), value.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
fn put_bool(args: &mut BTreeMap<String, String>, key: &str, value: bool) {
|
||||
args.insert(key.to_owned(), value.to_string());
|
||||
}
|
||||
|
||||
/// `class file version 52` → `Java 8`(HMCL `getJavaVersionFromMajorVersion` 同式)。
|
||||
#[must_use]
|
||||
pub fn class_file_to_java_major(class_file_version: u32) -> Option<u32> {
|
||||
class_file_version
|
||||
.checked_sub(44)
|
||||
.filter(|major| *major > 0)
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
//! 六维修复动作的参数类型(`fix_action.rs` 的载荷 + 几个小枚举)。
|
||||
//!
|
||||
//! 由 `rules.rs` 用 `#[path]` 声明并统一导出;文件拆分只为守 400 行上限。
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
/// 建议的 JVM 实现。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum JavaVendor {
|
||||
/// 不限定(HotSpot 优先)。
|
||||
#[default]
|
||||
Any,
|
||||
/// 必须 HotSpot(OpenJ9 等不兼容实现的反面)。
|
||||
Hotspot,
|
||||
}
|
||||
|
||||
impl JavaVendor {
|
||||
/// 稳定字符串。
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Any => "any",
|
||||
Self::Hotspot => "hotspot",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 图形侧要修的那一类设置。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum GraphicsSetting {
|
||||
/// 不指定具体项,只表达「图形相关」。
|
||||
#[default]
|
||||
Any,
|
||||
/// 渲染器(OpenGL 核心/兼容、ANGLE、软渲染)。
|
||||
Renderer,
|
||||
/// 显卡驱动。
|
||||
Driver,
|
||||
/// 图形 API(MC 26.2+ 的「默认 / 优先 Vulkan / 优先 OpenGL」三选一)。
|
||||
GraphicsApi,
|
||||
/// 分辨率 / 资源包分辨率。
|
||||
Resolution,
|
||||
/// 覆盖层类软件(RTSS、Discord 等)。
|
||||
Overlay,
|
||||
/// 软件渲染兜底。
|
||||
SoftwareRenderer,
|
||||
}
|
||||
|
||||
impl GraphicsSetting {
|
||||
/// 稳定字符串。
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Any => "any",
|
||||
Self::Renderer => "renderer",
|
||||
Self::Driver => "driver",
|
||||
Self::GraphicsApi => "graphics_api",
|
||||
Self::Resolution => "resolution",
|
||||
Self::Overlay => "overlay",
|
||||
Self::SoftwareRenderer => "software_renderer",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 对 mod 的处理方式。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ModAction {
|
||||
/// 移出 mods 目录。
|
||||
#[default]
|
||||
Remove,
|
||||
/// 暂时禁用(挪到 `.disabled`)。
|
||||
Disable,
|
||||
/// 换一个版本 / 换替代品。
|
||||
Replace,
|
||||
/// 升级到兼容版本。
|
||||
Upgrade,
|
||||
}
|
||||
|
||||
impl ModAction {
|
||||
/// 稳定字符串。
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Remove => "remove",
|
||||
Self::Disable => "disable",
|
||||
Self::Replace => "replace",
|
||||
Self::Upgrade => "upgrade",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 要重装/修复的组件。粒度对齐 download/game 侧的组件概念,便于 agent 直接调用。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ComponentKind {
|
||||
/// Java 运行时本身。
|
||||
Java,
|
||||
/// 游戏主 jar。
|
||||
GameJar,
|
||||
/// 版本清单(version JSON)。
|
||||
GameJson,
|
||||
/// 未指定具体 loader。
|
||||
Loader,
|
||||
/// Fabric loader。
|
||||
Fabric,
|
||||
/// Forge。
|
||||
Forge,
|
||||
/// NeoForge。
|
||||
#[serde(rename = "neoforge")]
|
||||
NeoForge,
|
||||
/// OptiFine。
|
||||
#[serde(rename = "optifine")]
|
||||
OptiFine,
|
||||
/// natives 原生库。
|
||||
Natives,
|
||||
/// mods 目录(含「jar 被解压成目录」的修复)。
|
||||
Mods,
|
||||
/// libraries 库文件。
|
||||
Libraries,
|
||||
/// assets 资源。
|
||||
Assets,
|
||||
/// MixinBootstrap 等前置。
|
||||
MixinBootstrap,
|
||||
}
|
||||
|
||||
impl ComponentKind {
|
||||
/// 稳定字符串。
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Java => "java",
|
||||
Self::GameJar => "game_jar",
|
||||
Self::GameJson => "game_json",
|
||||
Self::Loader => "loader",
|
||||
Self::Fabric => "fabric",
|
||||
Self::Forge => "forge",
|
||||
Self::NeoForge => "neoforge",
|
||||
Self::OptiFine => "optifine",
|
||||
Self::Natives => "natives",
|
||||
Self::Mods => "mods",
|
||||
Self::Libraries => "libraries",
|
||||
Self::Assets => "assets",
|
||||
Self::MixinBootstrap => "mixin_bootstrap",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 换 Java 的参数。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ChangeJava {
|
||||
/// 建议的大版本(17 / 21 …)。
|
||||
#[serde(default)]
|
||||
pub required_major: Option<u32>,
|
||||
/// 允许的最高大版本(老 Forge 只能用 Java 8 时给 `8`)。
|
||||
#[serde(default)]
|
||||
pub max_major: Option<u32>,
|
||||
/// 从该命名捕获组取 `class file version N`,按 `N - 44` 换算成 Java 大版本。
|
||||
#[serde(default)]
|
||||
pub class_file_version_group: Option<String>,
|
||||
/// JVM 实现偏好。
|
||||
#[serde(default)]
|
||||
pub vendor: JavaVendor,
|
||||
/// 给用户看的补充说明。
|
||||
#[serde(default)]
|
||||
pub note: Option<String>,
|
||||
}
|
||||
|
||||
/// 调内存的参数。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct AdjustMemory {
|
||||
/// 建议的 `-Xmx`(MB);规则不确定时留空,由 UI 按物理内存算。
|
||||
#[serde(default)]
|
||||
pub suggested_xmx_mb: Option<u32>,
|
||||
/// `-Xmx` 的下限(MB)。
|
||||
#[serde(default)]
|
||||
pub min_xmx_mb: Option<u32>,
|
||||
/// 需要 32 位 → 64 位 Java(「Could not reserve enough space」类)。
|
||||
#[serde(default)]
|
||||
pub need_64bit_java: bool,
|
||||
/// 需要用户扩大系统虚拟内存 / 关掉占内存的程序。
|
||||
#[serde(default)]
|
||||
pub increase_system_swap: bool,
|
||||
/// 补充说明。
|
||||
#[serde(default)]
|
||||
pub note: Option<String>,
|
||||
}
|
||||
|
||||
/// 改图形设置的参数。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ChangeGraphics {
|
||||
/// 要改的设置类别。
|
||||
#[serde(default)]
|
||||
pub setting: GraphicsSetting,
|
||||
/// 建议更新显卡驱动。
|
||||
#[serde(default)]
|
||||
pub update_driver: bool,
|
||||
/// 建议的目标值(如 `854x480`、`OpenGL 兼容模式`)。
|
||||
#[serde(default)]
|
||||
pub suggested_value: Option<String>,
|
||||
/// 补充说明。
|
||||
#[serde(default)]
|
||||
pub note: Option<String>,
|
||||
}
|
||||
|
||||
/// 删/禁/换 mod 的参数。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RemoveMod {
|
||||
/// modid 列表,允许 `{group}` 模板(由规则命名捕获组代入)。
|
||||
#[serde(default)]
|
||||
pub mod_ids: Vec<String>,
|
||||
/// 处理方式。
|
||||
#[serde(default)]
|
||||
pub action: ModAction,
|
||||
/// 补充说明。
|
||||
#[serde(default)]
|
||||
pub note: Option<String>,
|
||||
}
|
||||
|
||||
/// 重装/修组件的参数。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Reinstall {
|
||||
/// 要修的组件。
|
||||
pub component: ComponentKind,
|
||||
/// 出问题的路径提示(来自规则捕获组或原文)。
|
||||
#[serde(default)]
|
||||
pub path_hint: Option<String>,
|
||||
/// 补充说明。
|
||||
#[serde(default)]
|
||||
pub note: Option<String>,
|
||||
}
|
||||
|
||||
/// 求助/上报的参数。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SeekHelp {
|
||||
/// 建议导出诊断包(日志 + 崩溃报告 + 清单 + 环境)。
|
||||
#[serde(default)]
|
||||
pub export_diagnostics: bool,
|
||||
/// 还需要用户补充什么(「把日志上限调到 20000 行再复现」)。
|
||||
#[serde(default)]
|
||||
pub needs_more_info: Vec<String>,
|
||||
/// 补充说明。
|
||||
#[serde(default)]
|
||||
pub note: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
//! `hs_err_pid*.log`(JVM 致命错误日志)解析器。
|
||||
//!
|
||||
//! HMCL 完全不解析它(analyse/02 §7.10-8),而 `EXCEPTION_ACCESS_VIOLATION` 这类问题
|
||||
//! 只有这份文件能给出权威证据(失败帧落在显卡驱动 DLL 里就是驱动问题)。
|
||||
//! 格式稳定:**标题行 + 线程段 + 内存段**,本模块只做容错抽取,字段全部 `Option`,永不失败。
|
||||
//!
|
||||
//! 用法(launch 对接见 `CONTRACT.md` §5):
|
||||
//! - `parse(&text)`:已有全文时用(首选,避免二次截断);
|
||||
//! - `parse_file(&path)`:给路径时用;
|
||||
//! - [`HsErrReport::digest`] 产出的摘要既是规则匹配的文本,也是给它人看的证据。
|
||||
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
|
||||
#[path = "hs_err_report.rs"]
|
||||
mod report;
|
||||
|
||||
pub use report::{FailedFrame, FrameKind, HsErrKind, HsErrReport, MemorySummary};
|
||||
|
||||
/// 解析文本(永不失败;无法识别的行直接跳过)。
|
||||
#[must_use]
|
||||
pub fn parse(text: &str) -> HsErrReport {
|
||||
let mut report = HsErrReport::default();
|
||||
let lines = text.lines().collect::<Vec<_>>();
|
||||
|
||||
for (index, raw) in lines.iter().enumerate() {
|
||||
let line = raw.trim_end_matches('\r');
|
||||
let body = line.trim_start_matches('#').trim();
|
||||
if report.header.is_none() && is_header(body) {
|
||||
report.header = Some(body.to_owned());
|
||||
}
|
||||
if report.exception.is_none() {
|
||||
report.exception = extract_exception(body);
|
||||
}
|
||||
if report.siginfo.is_none() && body.starts_with("siginfo:") {
|
||||
report.siginfo = Some(body.to_owned());
|
||||
}
|
||||
if report.signal.is_none() {
|
||||
report.signal = body
|
||||
.contains("si_signo:")
|
||||
.then(|| body.to_owned())
|
||||
.or_else(|| extract_signal(body));
|
||||
}
|
||||
if report.jre_version.is_none() && body.contains("JRE version:") {
|
||||
report.jre_version = Some(body.to_owned());
|
||||
}
|
||||
if report.java_vm.is_none() && body.contains("Java VM:") {
|
||||
report.java_vm = Some(body.to_owned());
|
||||
}
|
||||
if report.os.is_none() && body.starts_with("OS:") {
|
||||
report.os = Some(body.to_owned());
|
||||
}
|
||||
if report.command_line.is_none() && body.starts_with("java_command:") {
|
||||
report.command_line = Some(body.to_owned());
|
||||
}
|
||||
if report.current_thread.is_none() && body.starts_with("Current thread (") {
|
||||
report.current_thread = Some(body.to_owned());
|
||||
}
|
||||
if report.memory.is_none() && body.starts_with("Memory:") {
|
||||
report.memory = parse_memory(body);
|
||||
}
|
||||
if report.failed_frame.is_none() && body.eq_ignore_ascii_case("Problematic frame:") {
|
||||
report.failed_frame = lines
|
||||
.iter()
|
||||
.skip(index + 1)
|
||||
.map(|next| next.trim_end_matches('\r'))
|
||||
.find(|next| !next.trim().is_empty() && next.trim() != "#")
|
||||
.map(|next| parse_frame(next.trim_start_matches('#').trim()));
|
||||
}
|
||||
if report.pc.is_none() && body.contains("pc=") {
|
||||
report.pc = field(body, "pc=");
|
||||
}
|
||||
if report.pid.is_none() && body.contains("pid=") {
|
||||
report.pid = field(body, "pid=").and_then(|raw| raw.parse::<u32>().ok());
|
||||
}
|
||||
if report.tid.is_none() && body.contains("tid=") {
|
||||
report.tid = field(body, "tid=");
|
||||
}
|
||||
if body.eq_ignore_ascii_case("VM Arguments:") {
|
||||
// 真实 hs_err 里 jvm_args/java_command 是顶格写的,不缩进
|
||||
report.vm_arguments = collect_unindented(&lines, index + 1, 32);
|
||||
}
|
||||
if body.starts_with("Heap:") {
|
||||
// "Heap:" 之后的缩进行才是内容(garbage-first heap total … used …)
|
||||
report.heap = collect_block(&lines, index + 1, 6);
|
||||
}
|
||||
}
|
||||
|
||||
report.header_lines = collect_header_block(&lines);
|
||||
report.pc = report
|
||||
.exception
|
||||
.as_deref()
|
||||
.and_then(|line| field(line, "pc="))
|
||||
.or(report.pc);
|
||||
// pid/tid 在「异常行」和「Out of Memory Error (os_*.cpp:NNN), pid=…」两种标题里都出现,
|
||||
// 所以两种形态都认,先到先得。
|
||||
if report.pid.is_none() {
|
||||
report.pid = report
|
||||
.exception
|
||||
.as_deref()
|
||||
.and_then(|line| field(line, "pid="))
|
||||
.and_then(|raw| raw.parse::<u32>().ok());
|
||||
}
|
||||
if report.tid.is_none() {
|
||||
report.tid = report
|
||||
.exception
|
||||
.as_deref()
|
||||
.and_then(|line| field(line, "tid="));
|
||||
}
|
||||
report.kind = classify(&report);
|
||||
report
|
||||
}
|
||||
|
||||
/// 读文件并解析(非 UTF-8 字节按 U+FFFD 替换,不报错)。
|
||||
pub fn parse_file(path: impl AsRef<Path>) -> io::Result<HsErrReport> {
|
||||
let path = path.as_ref();
|
||||
let bytes = fs::read(path)?;
|
||||
let text = String::from_utf8_lossy(&bytes);
|
||||
let mut report = parse(&text);
|
||||
report.path = Some(path.to_path_buf());
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
fn is_header(body: &str) -> bool {
|
||||
const MARKERS: [&str; 5] = [
|
||||
"insufficient memory",
|
||||
"A fatal error has been detected",
|
||||
"Out of Memory Error",
|
||||
"Internal Error",
|
||||
"SIGSEGV",
|
||||
];
|
||||
MARKERS.iter().any(|marker| body.contains(marker))
|
||||
}
|
||||
|
||||
fn extract_exception(body: &str) -> Option<String> {
|
||||
let marker = "EXCEPTION_";
|
||||
let start = body.find(marker)?;
|
||||
let rest = &body[start..];
|
||||
let end = rest
|
||||
.find(" at pc=")
|
||||
.or_else(|| rest.find(", pid="))
|
||||
.unwrap_or(rest.len());
|
||||
Some(rest[..end].trim().to_owned())
|
||||
}
|
||||
|
||||
fn extract_signal(body: &str) -> Option<String> {
|
||||
let start = body.find("SIG")?;
|
||||
let rest = &body[start..];
|
||||
let end = rest.find(')').map_or(rest.len(), |index| index + 1);
|
||||
Some(rest[..end].trim().to_owned())
|
||||
}
|
||||
|
||||
/// 从 `key=value` 形态里取值(到下一个空格/逗号为止)。
|
||||
fn field(line: &str, key: &str) -> Option<String> {
|
||||
let start = line.find(key)? + key.len();
|
||||
let rest = &line[start..];
|
||||
let end = rest.find([' ', ',', ']']).unwrap_or(rest.len());
|
||||
let value = rest[..end].trim();
|
||||
(!value.is_empty()).then(|| value.to_owned())
|
||||
}
|
||||
|
||||
fn parse_frame(raw: &str) -> FailedFrame {
|
||||
let kind = match raw.chars().next() {
|
||||
Some('C') => FrameKind::Native,
|
||||
Some('J' | 'j') => FrameKind::Java,
|
||||
Some('V') => FrameKind::VmStub,
|
||||
_ => FrameKind::Unknown,
|
||||
};
|
||||
let (module, offset) = raw
|
||||
.find('[')
|
||||
.and_then(|start| {
|
||||
let inner = &raw[start + 1..];
|
||||
let inner = &inner[..inner.find(']')?];
|
||||
let mut parts = inner.split('+');
|
||||
let module = parts.next().map(str::trim).filter(|m| !m.is_empty())?;
|
||||
Some((
|
||||
Some(module.to_owned()),
|
||||
parts.next().map(|o| o.trim().to_owned()),
|
||||
))
|
||||
})
|
||||
.unwrap_or((None, None));
|
||||
FailedFrame {
|
||||
raw: raw.to_owned(),
|
||||
kind,
|
||||
module,
|
||||
offset,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_memory(body: &str) -> Option<MemorySummary> {
|
||||
let mut summary = MemorySummary {
|
||||
raw: body.to_owned(),
|
||||
..MemorySummary::default()
|
||||
};
|
||||
for part in body.split(',') {
|
||||
let part = part.trim();
|
||||
if part.contains("page") {
|
||||
summary.page_size = part
|
||||
.trim_start_matches("Memory:")
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.map(|value| value.trim_end_matches(',').to_owned());
|
||||
} else if part.starts_with("physical") {
|
||||
let (total, free) = parse_kb_pair(part);
|
||||
summary.physical_kb = total;
|
||||
summary.physical_free_kb = free;
|
||||
} else if part.starts_with("swap") {
|
||||
let (total, free) = parse_kb_pair(part);
|
||||
summary.swap_kb = total;
|
||||
summary.swap_free_kb = free;
|
||||
}
|
||||
}
|
||||
Some(summary)
|
||||
}
|
||||
|
||||
/// `physical 16741480k(4716856k free)` → (16741480, 4716856)。
|
||||
fn parse_kb_pair(part: &str) -> (Option<u64>, Option<u64>) {
|
||||
let numbers = part
|
||||
.split(|ch: char| !ch.is_ascii_digit())
|
||||
.filter(|token| !token.is_empty())
|
||||
.filter_map(|token| token.parse::<u64>().ok())
|
||||
.collect::<Vec<_>>();
|
||||
(numbers.first().copied(), numbers.get(1).copied())
|
||||
}
|
||||
|
||||
/// 取从 `start` 开始、到第一个空行为止的连续行(VM Arguments / java_command 段)。
|
||||
fn collect_unindented(lines: &[&str], start: usize, limit: usize) -> Vec<String> {
|
||||
lines
|
||||
.iter()
|
||||
.skip(start)
|
||||
.map(|line| line.trim_end_matches('\r'))
|
||||
.take_while(|line| !line.trim().is_empty())
|
||||
.take(limit)
|
||||
.map(|line| line.trim().to_owned())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 取从 `start` 开始、以空白/制表缩进的连续行(最多 `limit` 行)。
|
||||
fn collect_block(lines: &[&str], start: usize, limit: usize) -> Vec<String> {
|
||||
lines
|
||||
.iter()
|
||||
.skip(start)
|
||||
.map(|line| line.trim_end_matches('\r'))
|
||||
.skip_while(|line| line.trim().is_empty())
|
||||
.take_while(|line| line.starts_with([' ', '\t']))
|
||||
.take(limit)
|
||||
.map(|line| line.trim().to_owned())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 文件开头的 `#` 注释块(真实 hs_err 的标题/异常/失败帧都在这里)。
|
||||
fn collect_header_block(lines: &[&str]) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
for line in lines {
|
||||
let line = line.trim_end_matches('\r');
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() || trimmed.starts_with('#') {
|
||||
if trimmed.starts_with('#') {
|
||||
out.push(trimmed.to_owned());
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
if out.len() >= 64 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn classify(report: &HsErrReport) -> HsErrKind {
|
||||
let haystack = [
|
||||
report.header.as_deref(),
|
||||
report.exception.as_deref(),
|
||||
report.siginfo.as_deref(),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
if haystack.contains("EXCEPTION_ACCESS_VIOLATION") || haystack.contains("SIGSEGV") {
|
||||
HsErrKind::AccessViolation
|
||||
} else if haystack.contains("insufficient memory")
|
||||
|| haystack.contains("Out of Memory")
|
||||
|| haystack.contains("OutOfMemory")
|
||||
{
|
||||
HsErrKind::OutOfMemory
|
||||
} else if haystack.contains("StackOverflow") || haystack.contains("stack overflow") {
|
||||
HsErrKind::StackOverflow
|
||||
} else if haystack.contains("Internal Error")
|
||||
|| haystack.contains("A fatal error has been detected")
|
||||
{
|
||||
HsErrKind::InternalError
|
||||
} else {
|
||||
HsErrKind::Other
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
//! `hs_err_pid*.log` 解析结果的数据结构(标题行 / 线程段 / 内存段 / 失败帧)。
|
||||
//!
|
||||
//! 由 `hs_err.rs` 用 `#[path]` 声明并重新导出;拆分只为守单文件 400 行上限。
|
||||
|
||||
use std::fmt;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// hs_err 的崩溃类别(标题行/异常名归并成少数几类,供内置结论判定)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum HsErrKind {
|
||||
/// `EXCEPTION_ACCESS_VIOLATION` / `SIGSEGV`:野指针,显卡驱动/原生库最常见。
|
||||
AccessViolation,
|
||||
/// 内存不足(`insufficient memory` / `Out of Memory Error`)。
|
||||
OutOfMemory,
|
||||
/// 栈溢出。
|
||||
StackOverflow,
|
||||
/// JVM 内部致命错误(`Internal Error` / `A fatal error has been detected`)。
|
||||
InternalError,
|
||||
/// 其它 / 无法归类。
|
||||
#[default]
|
||||
Other,
|
||||
}
|
||||
|
||||
impl HsErrKind {
|
||||
/// 稳定字符串。
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::AccessViolation => "access_violation",
|
||||
Self::OutOfMemory => "out_of_memory",
|
||||
Self::StackOverflow => "stack_overflow",
|
||||
Self::InternalError => "internal_error",
|
||||
Self::Other => "other",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for HsErrKind {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// 失败帧的类型(`# C` 原生码、`# J`/`# j` Java 帧、`# V` VM 生成桩帧)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum FrameKind {
|
||||
/// 原生(C/C++)帧:出现在显卡驱动/OpenGL 里基本可判定驱动问题。
|
||||
Native,
|
||||
/// Java 帧(`# J` / `# j`,二者区别是解释执行还是编译执行,诊断不需要再细分)。
|
||||
Java,
|
||||
/// VM 生成桩帧(`# V`):JIT/解释器生成的运行时代码,不是用户代码。
|
||||
VmStub,
|
||||
/// 未识别。
|
||||
#[default]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl FrameKind {
|
||||
/// 稳定字符串。
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Native => "native",
|
||||
Self::Java => "java",
|
||||
Self::VmStub => "vm_stub",
|
||||
Self::Unknown => "unknown",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `# Problematic frame:` 之后的那一行。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct FailedFrame {
|
||||
/// 原始文本(`C [ig8d64.dll+0x9b3c4d]`)。
|
||||
pub raw: String,
|
||||
/// 帧类型。
|
||||
pub kind: FrameKind,
|
||||
/// 命中的模块/DLL(`ig8d64.dll`)。
|
||||
pub module: Option<String>,
|
||||
/// 模块内偏移(`0x9b3c4d`)。
|
||||
pub offset: Option<String>,
|
||||
}
|
||||
|
||||
/// 内存段摘要(`Memory: 4k page, physical …, swap …`)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct MemorySummary {
|
||||
/// 页大小(`4k`)。
|
||||
pub page_size: Option<String>,
|
||||
/// 物理内存 kB。
|
||||
pub physical_kb: Option<u64>,
|
||||
/// 物理可用 kB。
|
||||
pub physical_free_kb: Option<u64>,
|
||||
/// 交换区 kB。
|
||||
pub swap_kb: Option<u64>,
|
||||
/// 交换区可用 kB。
|
||||
pub swap_free_kb: Option<u64>,
|
||||
/// 原始行。
|
||||
pub raw: String,
|
||||
}
|
||||
|
||||
impl MemorySummary {
|
||||
/// 一行人类可读摘要(GB 保留一位小数)。
|
||||
#[must_use]
|
||||
pub fn summary(&self) -> String {
|
||||
let gb = |kb: Option<u64>| {
|
||||
kb.map(|kb| format!("{:.1} GB", kb as f64 / 1024.0 / 1024.0))
|
||||
.unwrap_or_else(|| "?".to_owned())
|
||||
};
|
||||
format!(
|
||||
"物理内存 {}(可用 {}),交换区 {}(可用 {})",
|
||||
gb(self.physical_kb),
|
||||
gb(self.physical_free_kb),
|
||||
gb(self.swap_kb),
|
||||
gb(self.swap_free_kb)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析结果。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct HsErrReport {
|
||||
/// 来源文件(`parse` 时为 `None`)。
|
||||
pub path: Option<PathBuf>,
|
||||
/// 标题行(`#[derive(Default)]` 之外的语义见 `parse`)。
|
||||
pub header: Option<String>,
|
||||
/// 崩溃类别。
|
||||
pub kind: HsErrKind,
|
||||
/// 异常名(`EXCEPTION_ACCESS_VIOLATION (0xc0000005)`)。
|
||||
pub exception: Option<String>,
|
||||
/// `siginfo:` 行(Unix 侧带 si_signo / si_addr)。
|
||||
pub siginfo: Option<String>,
|
||||
/// 信号名(`SIGSEGV (0xb)`)。
|
||||
pub signal: Option<String>,
|
||||
/// 出错时的指令指针。
|
||||
pub pc: Option<String>,
|
||||
/// 进程 pid。
|
||||
pub pid: Option<u32>,
|
||||
/// 线程 tid(保留原始十六进制/十进制串)。
|
||||
pub tid: Option<String>,
|
||||
/// 失败帧。
|
||||
pub failed_frame: Option<FailedFrame>,
|
||||
/// 当前线程(`JavaThread "Render thread" [_thread_in_native, id=…]`)。
|
||||
pub current_thread: Option<String>,
|
||||
/// JRE 版本行内容。
|
||||
pub jre_version: Option<String>,
|
||||
/// JVM 实现行内容。
|
||||
pub java_vm: Option<String>,
|
||||
/// 操作系统行内容。
|
||||
pub os: Option<String>,
|
||||
/// `java_command` 行。
|
||||
pub command_line: Option<String>,
|
||||
/// `VM Arguments:` 段的参数行。
|
||||
pub vm_arguments: Vec<String>,
|
||||
/// 内存段摘要。
|
||||
pub memory: Option<MemorySummary>,
|
||||
/// `Heap:` 段的前几行(原样,含 total/used)。
|
||||
pub heap: Vec<String>,
|
||||
/// 标题块原文(逐行保留 `#` 前缀,规则可以直接在上面匹配)。
|
||||
pub header_lines: Vec<String>,
|
||||
}
|
||||
|
||||
impl HsErrReport {
|
||||
/// 供规则匹配 / 证据展示的摘要文本。
|
||||
///
|
||||
/// 保留标题块原文(`EXCEPTION_ACCESS_VIOLATION` 与 `# C [ig…dll]` 的相对位置不变,
|
||||
/// 因此 `graphics.graphics_driver` 这类跨行规则可以直接命中),再补上线程/内存段要点。
|
||||
#[must_use]
|
||||
pub fn digest(&self) -> String {
|
||||
let mut out = String::new();
|
||||
for line in &self.header_lines {
|
||||
out.push_str(line);
|
||||
out.push('\n');
|
||||
}
|
||||
for line in [
|
||||
self.siginfo.as_deref(),
|
||||
self.current_thread.as_deref(),
|
||||
self.jre_version.as_deref(),
|
||||
self.java_vm.as_deref(),
|
||||
self.os.as_deref(),
|
||||
self.command_line.as_deref(),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
out.push_str(line);
|
||||
out.push('\n');
|
||||
}
|
||||
for line in &self.heap {
|
||||
out.push_str(line);
|
||||
out.push('\n');
|
||||
}
|
||||
if let Some(memory) = &self.memory {
|
||||
out.push_str(&memory.raw);
|
||||
out.push('\n');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 一句话摘要(诊断输出 / UI 头部)。
|
||||
#[must_use]
|
||||
pub fn summary(&self) -> String {
|
||||
let kind = self.kind.as_str();
|
||||
let frame = self
|
||||
.failed_frame
|
||||
.as_ref()
|
||||
.map_or_else(String::new, |frame| format!(",失败帧 {}", frame.raw));
|
||||
let header = self.header.as_deref().unwrap_or("JVM 致命错误");
|
||||
format!("{header}({kind}{frame})")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
//! 规则库加载:YAML 分组文件 + 内嵌兜底 + 热重载。
|
||||
//!
|
||||
//! 对应 PlanDoc/06 §2.2「规则文件 YAML 外置可热更」(修 HMCL 编译期枚举不可热更的债):
|
||||
//! 1. 内嵌一份(`include_str!`):任何情况下都能诊断,不依赖安装目录完整;
|
||||
//! 2. 用户目录 `data/diagnose/rules.d/*.yaml` 覆盖内嵌的同名文件(缺哪个用内嵌补哪个);
|
||||
//! 3. 热重载是 **mtime+size 指纹轮询**([`RuleLoader::refresh`]),不引 `notify` 依赖;
|
||||
//! 解析失败时**保留上一份好规则**并返回错误列表——用户手改错 YAML 不会让诊断能力归零。
|
||||
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::collections::HashSet;
|
||||
use std::fmt;
|
||||
use std::fs;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::rules::{CompiledRule, Rule, RuleCategory};
|
||||
|
||||
/// 规则目录(相对数据根,由 platform 模块解析)。
|
||||
pub const RULES_DIR: &str = "diagnose/rules.d";
|
||||
|
||||
/// 规则 schema 版本(文件里 `version: 1`)。
|
||||
pub const SCHEMA_VERSION: u32 = 1;
|
||||
|
||||
/// 内嵌规则库:`(文件名, 内容)`。按分类拆文件,每文件守 400 行上限。
|
||||
pub const EMBEDDED_RULES: &[(&str, &str)] = &[
|
||||
("jvm.yaml", include_str!("rules.d/jvm.yaml")),
|
||||
("memory.yaml", include_str!("rules.d/memory.yaml")),
|
||||
("graphics.yaml", include_str!("rules.d/graphics.yaml")),
|
||||
("integrity.yaml", include_str!("rules.d/integrity.yaml")),
|
||||
("fabric.yaml", include_str!("rules.d/fabric.yaml")),
|
||||
("forge.yaml", include_str!("rules.d/forge.yaml")),
|
||||
("mod_runtime.yaml", include_str!("rules.d/mod_runtime.yaml")),
|
||||
(
|
||||
"mod_conflict.yaml",
|
||||
include_str!("rules.d/mod_conflict.yaml"),
|
||||
),
|
||||
("world.yaml", include_str!("rules.d/world.yaml")),
|
||||
("other.yaml", include_str!("rules.d/other.yaml")),
|
||||
];
|
||||
|
||||
/// 一个 YAML 文件的 schema。
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct RuleFile {
|
||||
#[serde(default)]
|
||||
version: u32,
|
||||
category: RuleCategory,
|
||||
rules: Vec<Rule>,
|
||||
}
|
||||
|
||||
/// 编译好的规则集(字段私有,只能经 loader 构造,保证「id 唯一 + 正则可编译」)。
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct RuleSet {
|
||||
rules: Vec<CompiledRule>,
|
||||
}
|
||||
|
||||
impl RuleSet {
|
||||
/// 规则表。
|
||||
#[must_use]
|
||||
pub fn rules(&self) -> &[CompiledRule] {
|
||||
&self.rules
|
||||
}
|
||||
|
||||
/// 规则条数。
|
||||
#[must_use]
|
||||
pub fn len(&self) -> usize {
|
||||
self.rules.len()
|
||||
}
|
||||
|
||||
/// 是否空规则集。
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.rules.is_empty()
|
||||
}
|
||||
|
||||
/// 按 id 查规则(测试 / 调试用)。
|
||||
#[must_use]
|
||||
pub fn get(&self, id: &str) -> Option<&CompiledRule> {
|
||||
self.rules.iter().find(|rule| rule.id() == id)
|
||||
}
|
||||
|
||||
/// 从 `(文件名, 内容)` 列表编译;有任何错误就整体失败(调用方决定是否回退)。
|
||||
pub fn from_sources(sources: &[(String, String)]) -> Result<Self, Vec<LoadError>> {
|
||||
let mut errors = Vec::new();
|
||||
let mut rules: Vec<CompiledRule> = Vec::new();
|
||||
let mut seen: HashSet<String> = HashSet::new();
|
||||
for (file, text) in sources {
|
||||
let parsed = match serde_yaml_ng::from_str::<RuleFile>(text) {
|
||||
Ok(parsed) => parsed,
|
||||
Err(error) => {
|
||||
errors.push(LoadError::yaml(file, &error));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if parsed.version > SCHEMA_VERSION {
|
||||
errors.push(LoadError {
|
||||
file: file.clone(),
|
||||
kind: LoadErrorKind::UnsupportedVersion,
|
||||
message: format!(
|
||||
"规则文件 version={} 高于本程序支持的 {}",
|
||||
parsed.version, SCHEMA_VERSION
|
||||
),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
for rule in parsed.rules {
|
||||
if !seen.insert(rule.id.clone()) {
|
||||
errors.push(LoadError {
|
||||
file: file.clone(),
|
||||
kind: LoadErrorKind::DuplicateId,
|
||||
message: format!("规则 id 重复:{}", rule.id),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
match CompiledRule::compile(rule, parsed.category) {
|
||||
Ok(compiled) => rules.push(compiled),
|
||||
Err(error) => errors.push(LoadError {
|
||||
file: file.clone(),
|
||||
kind: LoadErrorKind::Regex,
|
||||
message: error.to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
if errors.is_empty() {
|
||||
Ok(Self { rules })
|
||||
} else {
|
||||
Err(errors)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载错误分类。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LoadErrorKind {
|
||||
/// 读盘失败。
|
||||
Io,
|
||||
/// YAML 语法/schema 错误(`serde_yaml_ng` 的报错带行号)。
|
||||
Yaml,
|
||||
/// 正则编译失败。
|
||||
Regex,
|
||||
/// 规则 id 跨文件重复。
|
||||
DuplicateId,
|
||||
/// 规则文件版本高于本程序。
|
||||
UnsupportedVersion,
|
||||
}
|
||||
|
||||
impl LoadErrorKind {
|
||||
/// 稳定字符串。
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Io => "io",
|
||||
Self::Yaml => "yaml",
|
||||
Self::Regex => "regex",
|
||||
Self::DuplicateId => "duplicate_id",
|
||||
Self::UnsupportedVersion => "unsupported_version",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一条加载错误(带文件名,用户改 YAML 时能定位)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct LoadError {
|
||||
/// 文件名(磁盘目录加载时是完整路径)。
|
||||
pub file: String,
|
||||
/// 分类。
|
||||
pub kind: LoadErrorKind,
|
||||
/// 报错原文(YAML 错误含行号)。
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl LoadError {
|
||||
fn yaml(file: &str, error: &serde_yaml_ng::Error) -> Self {
|
||||
Self {
|
||||
file: file.to_owned(),
|
||||
kind: LoadErrorKind::Yaml,
|
||||
message: error.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn io(file: &Path, error: &std::io::Error) -> Self {
|
||||
Self {
|
||||
file: file.display().to_string(),
|
||||
kind: LoadErrorKind::Io,
|
||||
message: error.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for LoadError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"[{}] {}:{}",
|
||||
self.kind.as_str(),
|
||||
self.file,
|
||||
self.message
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for LoadError {}
|
||||
|
||||
/// 热重载结果。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum ReloadOutcome {
|
||||
/// 指纹没变,规则未动。
|
||||
Unchanged,
|
||||
/// 重新加载成功。
|
||||
Reloaded {
|
||||
/// 新规则条数。
|
||||
rules: usize,
|
||||
},
|
||||
/// 新文件有错:**保留旧规则**,错误交给调用方展示。
|
||||
Rejected {
|
||||
/// 错误列表。
|
||||
errors: Vec<LoadError>,
|
||||
},
|
||||
}
|
||||
|
||||
/// 规则库加载器(持有当前生效的规则集 + 目录指纹)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RuleLoader {
|
||||
dir: Option<PathBuf>,
|
||||
set: RuleSet,
|
||||
fingerprint: u64,
|
||||
last_errors: Vec<LoadError>,
|
||||
}
|
||||
|
||||
impl Default for RuleLoader {
|
||||
fn default() -> Self {
|
||||
Self::embedded()
|
||||
}
|
||||
}
|
||||
|
||||
impl RuleLoader {
|
||||
/// 只用内嵌规则(无磁盘目录)。
|
||||
#[must_use]
|
||||
pub fn embedded() -> Self {
|
||||
let sources = embedded_sources();
|
||||
let (set, errors) = compile_sources(&sources);
|
||||
Self {
|
||||
dir: None,
|
||||
set,
|
||||
fingerprint: 0,
|
||||
last_errors: errors,
|
||||
}
|
||||
}
|
||||
|
||||
/// 以磁盘目录为准、内嵌补齐缺文件。
|
||||
///
|
||||
/// 磁盘规则有任何错误时**退回内嵌规则**并记录错误(保证永远有可用规则集)。
|
||||
#[must_use]
|
||||
pub fn from_dir(dir: impl Into<PathBuf>) -> Self {
|
||||
let dir = dir.into();
|
||||
let (sources, mut errors) = merged_sources(Some(&dir));
|
||||
let set = match RuleSet::from_sources(&sources) {
|
||||
Ok(set) => set,
|
||||
Err(mut load_errors) => {
|
||||
errors.append(&mut load_errors);
|
||||
RuleSet::from_sources(&embedded_sources()).unwrap_or_default()
|
||||
}
|
||||
};
|
||||
Self {
|
||||
fingerprint: dir_fingerprint(&dir),
|
||||
dir: Some(dir),
|
||||
set,
|
||||
last_errors: errors,
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前生效的规则。
|
||||
#[must_use]
|
||||
pub fn rules(&self) -> &[CompiledRule] {
|
||||
self.set.rules()
|
||||
}
|
||||
|
||||
/// 当前生效的规则集。
|
||||
#[must_use]
|
||||
pub fn rule_set(&self) -> &RuleSet {
|
||||
&self.set
|
||||
}
|
||||
|
||||
/// 上次加载留下的错误(UI 可提示「你的规则文件有错,已用内置规则」)。
|
||||
#[must_use]
|
||||
pub fn last_errors(&self) -> &[LoadError] {
|
||||
&self.last_errors
|
||||
}
|
||||
|
||||
/// 规则目录(内嵌模式为 `None`)。
|
||||
#[must_use]
|
||||
pub fn dir(&self) -> Option<&Path> {
|
||||
self.dir.as_deref()
|
||||
}
|
||||
|
||||
/// 目录指纹(测试用:确认 mtime/size 变化真的改变了指纹)。
|
||||
#[must_use]
|
||||
pub fn fingerprint(&self) -> u64 {
|
||||
self.fingerprint
|
||||
}
|
||||
|
||||
/// 轮询热重载:指纹变了才重新解析。
|
||||
pub fn refresh(&mut self) -> ReloadOutcome {
|
||||
let Some(dir) = self.dir.clone() else {
|
||||
return ReloadOutcome::Unchanged;
|
||||
};
|
||||
let fingerprint = dir_fingerprint(&dir);
|
||||
if fingerprint == self.fingerprint {
|
||||
return ReloadOutcome::Unchanged;
|
||||
}
|
||||
self.fingerprint = fingerprint;
|
||||
let (sources, mut errors) = merged_sources(Some(&dir));
|
||||
match RuleSet::from_sources(&sources) {
|
||||
Ok(set) => {
|
||||
self.set = set;
|
||||
self.last_errors = errors;
|
||||
ReloadOutcome::Reloaded {
|
||||
rules: self.set.len(),
|
||||
}
|
||||
}
|
||||
Err(mut load_errors) => {
|
||||
errors.append(&mut load_errors);
|
||||
self.last_errors = errors.clone();
|
||||
ReloadOutcome::Rejected { errors }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn embedded_sources() -> Vec<(String, String)> {
|
||||
EMBEDDED_RULES
|
||||
.iter()
|
||||
.map(|(name, text)| ((*name).to_owned(), (*text).to_owned()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 磁盘文件覆盖内嵌:磁盘有的用磁盘,没的用内嵌。
|
||||
fn merged_sources(dir: Option<&Path>) -> (Vec<(String, String)>, Vec<LoadError>) {
|
||||
let mut sources = Vec::new();
|
||||
let mut errors = Vec::new();
|
||||
for (name, embedded) in EMBEDDED_RULES {
|
||||
let path = dir.map(|dir| dir.join(name));
|
||||
match path {
|
||||
Some(path) if path.is_file() => match fs::read_to_string(&path) {
|
||||
Ok(text) => sources.push((path.display().to_string(), text)),
|
||||
Err(error) => {
|
||||
errors.push(LoadError::io(&path, &error));
|
||||
sources.push(((*name).to_owned(), (*embedded).to_owned()));
|
||||
}
|
||||
},
|
||||
_ => sources.push(((*name).to_owned(), (*embedded).to_owned())),
|
||||
}
|
||||
}
|
||||
(sources, errors)
|
||||
}
|
||||
|
||||
fn compile_sources(sources: &[(String, String)]) -> (RuleSet, Vec<LoadError>) {
|
||||
match RuleSet::from_sources(sources) {
|
||||
Ok(set) => (set, Vec::new()),
|
||||
Err(errors) => (RuleSet::default(), errors),
|
||||
}
|
||||
}
|
||||
|
||||
/// 目录指纹:所有 `*.yaml` 的(文件名, 长度, mtime)之和。
|
||||
///
|
||||
/// 长度 + mtime 足以发现「保存了但秒级 mtime 未变」之外的一切编辑;漏检时用户点刷新即可。
|
||||
fn dir_fingerprint(dir: &Path) -> u64 {
|
||||
let mut entries = fs::read_dir(dir)
|
||||
.map(|entries| {
|
||||
entries
|
||||
.flatten()
|
||||
.map(|entry| entry.path())
|
||||
.filter(|path| {
|
||||
path.extension()
|
||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("yaml"))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
entries.sort();
|
||||
let mut hasher = DefaultHasher::new();
|
||||
for path in entries {
|
||||
path.file_name().unwrap_or_default().hash(&mut hasher);
|
||||
if let Ok(metadata) = fs::metadata(&path) {
|
||||
metadata.len().hash(&mut hasher);
|
||||
metadata
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|duration| duration.as_nanos())
|
||||
.hash(&mut hasher);
|
||||
}
|
||||
}
|
||||
hasher.finish()
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//! 崩溃诊断:五类信号采集(含 hs_err_pid 解析)、YAML 规则引擎(59 条规则起步,可热更新)、
|
||||
//! exit0 静默失败检测、结构化诊断结论。
|
||||
//!
|
||||
//! 设计定稿:`DevDocs/PlanDoc/06-launch-and-diagnostics.md` §2(采集 / 规则 / 流水线)。
|
||||
//! 定位:规则引擎为主、AI 补位;AI 拿到的应是「规则结论 + 相关片段」而非裸日志
|
||||
//! (`DevDocs/PlanDoc/02-ai-agent-design.md` §3.2)。
|
||||
//!
|
||||
//! 规划中的公共面(M15):信号采集、规则匹配、诊断报告、规则热加载。
|
||||
|
||||
// 只有这五个顶层模块;子模块(collectors 下的 signals/sources/environment、engine 下的
|
||||
// finding/synthetic、hs_err 下的 hs_err_report、rules 下的 fix_action/fix_params)由各自的
|
||||
// 父文件用 #[path] 声明。这里**不要**重复声明,否则同一文件会被编译成两份不同类型。
|
||||
pub mod collectors;
|
||||
pub mod engine;
|
||||
pub mod hs_err;
|
||||
pub mod loader;
|
||||
pub mod rules;
|
||||
@@ -0,0 +1,141 @@
|
||||
# 规则库 · Fabric mod 解析(8 条)
|
||||
# 移植自 HMCL CrashReportAnalyzer.Rule 的 Fabric 组(analyse/02 §7.3 第 5 组)。
|
||||
# Fabric 的 ModResolutionException 家族特征:异常后面跟一段以 " - " 开头的列表,
|
||||
# 因此这些规则用 file scope(跨行)。
|
||||
version: 1
|
||||
category: fabric
|
||||
rules:
|
||||
- id: fabric.mod_resolution
|
||||
severity: critical
|
||||
scope: file
|
||||
pattern: 'ModResolutionException: (?<reason>(.*)[\n\r]*( - (.*)[\n\r]*)+)'
|
||||
confidence: 0.75
|
||||
priority: 70
|
||||
cause:
|
||||
key: diagnose.cause.fabric.mod_resolution
|
||||
summary: 'Fabric 解析 mod 依赖失败(缺依赖/版本冲突/模组不配套)'
|
||||
detail: '按下方列出的 mod 名单逐个处理:缺依赖就补装,冲突就删一个'
|
||||
fix_action:
|
||||
- kind: remove_mod
|
||||
action: disable
|
||||
note: '按 Fabric 报的 mod 名单逐个禁用排查'
|
||||
sample: samples/fabric/mod_resolution.log
|
||||
source: 'HMCL CrashReportAnalyzer.Rule.MOD_RESOLUTION'
|
||||
|
||||
- id: fabric.mod_resolution_conflict
|
||||
severity: critical
|
||||
pattern: 'ModResolutionException: Found conflicting mods: (?<sourcemod>.*) conflicts with (?<destmod>.*)'
|
||||
confidence: 0.92
|
||||
priority: 86
|
||||
suppress:
|
||||
- integrity.no_class_def_found
|
||||
- integrity.no_such_method
|
||||
cause:
|
||||
key: diagnose.cause.fabric.mod_resolution_conflict
|
||||
summary: 'Fabric 报告 mod 冲突:{sourcemod} 与 {destmod} 不能共存'
|
||||
detail: '两个只能留一个(或都升级到互相兼容的版本)'
|
||||
fix_action:
|
||||
- kind: remove_mod
|
||||
mod_ids:
|
||||
- '{sourcemod}'
|
||||
- '{destmod}'
|
||||
action: remove
|
||||
sample: samples/fabric/mod_resolution_conflict.log
|
||||
source: 'HMCL CrashReportAnalyzer.Rule.MOD_RESOLUTION_CONFLICT'
|
||||
|
||||
- id: fabric.mod_resolution_missing
|
||||
severity: critical
|
||||
pattern: 'ModResolutionException: Could not find required mod: (?<sourcemod>.*) requires (?<destmod>.*)'
|
||||
confidence: 0.9
|
||||
priority: 85
|
||||
cause:
|
||||
key: diagnose.cause.fabric.mod_resolution_missing
|
||||
summary: '缺少前置 mod:{sourcemod} 需要 {destmod}'
|
||||
detail: '装上 {destmod} 的对应版本;装不上就删掉 {sourcemod}'
|
||||
fix_action:
|
||||
- kind: remove_mod
|
||||
mod_ids:
|
||||
- '{sourcemod}'
|
||||
action: replace
|
||||
note: '补装前置 {destmod},或换掉 {sourcemod}'
|
||||
sample: samples/fabric/mod_resolution_missing.log
|
||||
source: 'HMCL CrashReportAnalyzer.Rule.MOD_RESOLUTION_MISSING'
|
||||
|
||||
- id: fabric.mod_resolution_missing_minecraft
|
||||
severity: critical
|
||||
pattern: 'ModResolutionException: Could not find required mod: (?<mod>.*) requires \{minecraft @ (?<version>.*)\}'
|
||||
confidence: 0.9
|
||||
priority: 85
|
||||
cause:
|
||||
key: diagnose.cause.fabric.mod_resolution_missing_minecraft
|
||||
summary: '{mod} 要求 Minecraft {version},与当前实例版本不一致'
|
||||
fix_action:
|
||||
- kind: remove_mod
|
||||
mod_ids:
|
||||
- '{mod}'
|
||||
action: replace
|
||||
note: '换该 mod 的 {version} 版本,或改实例版本'
|
||||
sample: samples/fabric/mod_resolution_missing_minecraft.log
|
||||
source: 'HMCL CrashReportAnalyzer.Rule.MOD_RESOLUTION_MISSING_MINECRAFT'
|
||||
|
||||
- id: fabric.mod_resolution_collection
|
||||
severity: critical
|
||||
pattern: 'ModResolutionException: Could not resolve valid mod collection \(at: (?<sourcemod>.*) requires (?<destmod>.*)\)'
|
||||
confidence: 0.88
|
||||
priority: 84
|
||||
cause:
|
||||
key: diagnose.cause.fabric.mod_resolution_collection
|
||||
summary: 'mod 集合无法解析:{sourcemod} 与 {destmod} 的版本组合不成立'
|
||||
fix_action:
|
||||
- kind: remove_mod
|
||||
mod_ids:
|
||||
- '{sourcemod}'
|
||||
action: upgrade
|
||||
sample: samples/fabric/mod_resolution_collection.log
|
||||
source: 'HMCL CrashReportAnalyzer.Rule.MOD_RESOLUTION_COLLECTION'
|
||||
|
||||
- id: fabric.mod_resolution_details
|
||||
severity: warning
|
||||
pattern: '(\tMod File:|-- MOD |\tFailure message:)'
|
||||
confidence: 0.5
|
||||
priority: 45
|
||||
cause:
|
||||
key: diagnose.cause.fabric.mod_resolution_details
|
||||
summary: 'Fabric 打印了 mod 解析明细(Mod File / Failure message):具体结论见上方规则'
|
||||
detail: '这条只表示「日志里有解析失败明细」,用于把证据片段指给 AI/用户'
|
||||
fix_action:
|
||||
- kind: seek_help
|
||||
export_diagnostics: true
|
||||
sample: samples/fabric/mod_resolution_details.log
|
||||
source: 'HMCL CrashReportAnalyzer.Rule.MOD_RESOLUTION0'
|
||||
|
||||
- id: fabric.loader_0_12
|
||||
severity: critical
|
||||
pattern: 'java\.lang\.NoClassDefFoundError: org/spongepowered/asm/mixin/transformer/FabricMixinTransformerProxy'
|
||||
confidence: 0.85
|
||||
priority: 80
|
||||
cause:
|
||||
key: diagnose.cause.fabric.loader_0_12
|
||||
summary: 'Fabric Loader 0.12 与旧 mixin 类不兼容(loader 删掉了 deprecated 类)'
|
||||
fix_action:
|
||||
- kind: reinstall
|
||||
component: fabric
|
||||
note: '换匹配的 Fabric Loader 版本'
|
||||
sample: samples/fabric/loader_0_12.log
|
||||
source: 'HMCL CrashReportAnalyzer.Rule.FABRIC_VERSION_0_12'
|
||||
|
||||
- id: fabric.warnings
|
||||
severity: warning
|
||||
scope: file
|
||||
pattern: '(Warnings were found!|Incompatible mod set!|Incompatible mods found!)(.*?)[\n\r]+(?<reason>[^\[]+)\['
|
||||
confidence: 0.7
|
||||
priority: 62
|
||||
cause:
|
||||
key: diagnose.cause.fabric.warnings
|
||||
summary: 'Fabric 报告 mod 集合不兼容:{reason}'
|
||||
fix_action:
|
||||
- kind: remove_mod
|
||||
action: disable
|
||||
note: '按警告列表逐个禁用'
|
||||
sample: samples/fabric/warnings.log
|
||||
source: 'HMCL CrashReportAnalyzer.Rule.FABRIC_WARNINGS'
|
||||
@@ -0,0 +1,136 @@
|
||||
# 规则库 · Forge mod 解析(8 条)
|
||||
# 移植自 HMCL CrashReportAnalyzer.Rule 的 Forge 组(analyse/02 §7.3 第 6 组)。
|
||||
version: 1
|
||||
category: forge
|
||||
rules:
|
||||
- id: forge.missing_dependencies
|
||||
severity: critical
|
||||
scope: file
|
||||
pattern: 'Missing or unsupported mandatory dependencies:(?<reason>(.*)[\n\r]*(\t(.*)[\n\r]*)+)'
|
||||
confidence: 0.85
|
||||
priority: 80
|
||||
cause:
|
||||
key: diagnose.cause.forge.missing_dependencies
|
||||
summary: 'Forge 报「缺少或不支持强制依赖」:有 mod 的前置没装或版本不对'
|
||||
detail: '按报错里的 mod 名补齐前置;装不上就删掉依赖它的 mod'
|
||||
fix_action:
|
||||
- kind: remove_mod
|
||||
action: disable
|
||||
note: '按报错逐条补齐/禁用'
|
||||
sample: samples/forge/missing_dependencies.log
|
||||
source: 'HMCL CrashReportAnalyzer.Rule.FORGEMOD_RESOLUTION'
|
||||
|
||||
- id: forge.duplicate_mods
|
||||
severity: critical
|
||||
scope: file
|
||||
pattern: 'Found duplicate mods:(?<reason>(.*)(?:\r\n|\n|\r)*(\t(.*)(?:\r\n|\n|\r)*)+)'
|
||||
confidence: 0.9
|
||||
priority: 86
|
||||
cause:
|
||||
key: diagnose.cause.forge.duplicate_mods
|
||||
summary: '同一个 mod 装了两份(mods 目录里重复的 jar)'
|
||||
detail: '删掉重复的那一份,只留一个版本'
|
||||
fix_action:
|
||||
- kind: remove_mod
|
||||
action: remove
|
||||
note: '保留一个版本'
|
||||
sample: samples/forge/duplicate_mods.log
|
||||
source: 'HMCL CrashReportAnalyzer.Rule.FORGE_FOUND_DUPLICATE_MODS'
|
||||
|
||||
- id: forge.duplicated_mod
|
||||
severity: critical
|
||||
pattern: 'Found a duplicate mod (?<name>.*) at (?<path>.*)'
|
||||
confidence: 0.92
|
||||
priority: 88
|
||||
cause:
|
||||
key: diagnose.cause.forge.duplicated_mod
|
||||
summary: 'mod 重复:{name} 同时出现在 {path}'
|
||||
fix_action:
|
||||
- kind: remove_mod
|
||||
mod_ids:
|
||||
- '{name}'
|
||||
action: remove
|
||||
note: '删除路径 {path} 那一份'
|
||||
sample: samples/forge/duplicated_mod.log
|
||||
source: 'HMCL CrashReportAnalyzer.Rule.DUPLICATED_MOD'
|
||||
|
||||
- id: forge.repeat_installation
|
||||
severity: critical
|
||||
pattern: 'MultipleArgumentsForOptionException: Found multiple arguments for option (.*?), but you asked for only one'
|
||||
confidence: 0.85
|
||||
priority: 80
|
||||
cause:
|
||||
key: diagnose.cause.forge.repeat_installation
|
||||
summary: 'Forge 被装了两次(同一版本出现多个安装参数)'
|
||||
detail: '删掉重复的 Forge 安装/版本 JSON,再重装一次'
|
||||
fix_action:
|
||||
- kind: reinstall
|
||||
component: forge
|
||||
sample: samples/forge/repeat_installation.log
|
||||
source: 'HMCL CrashReportAnalyzer.Rule.FORGE_REPEAT_INSTALLATION'
|
||||
|
||||
- id: forge.optifine_repeat_installation
|
||||
severity: critical
|
||||
pattern: 'ResolutionException: Module optifine reads another module named optifine'
|
||||
confidence: 0.88
|
||||
priority: 82
|
||||
cause:
|
||||
key: diagnose.cause.forge.optifine_repeat_installation
|
||||
summary: 'OptiFine 装了两份(mods 里一个 + 自动安装一个)'
|
||||
fix_action:
|
||||
- kind: remove_mod
|
||||
mod_ids:
|
||||
- optifine
|
||||
action: remove
|
||||
note: '只保留一种安装方式'
|
||||
sample: samples/forge/optifine_repeat_installation.log
|
||||
source: 'HMCL CrashReportAnalyzer.Rule.OPTIFINE_REPEAT_INSTALLATION'
|
||||
|
||||
- id: forge.too_many_mods
|
||||
severity: critical
|
||||
pattern: 'maximum id range exceeded'
|
||||
confidence: 0.9
|
||||
priority: 80
|
||||
cause:
|
||||
key: diagnose.cause.forge.too_many_mods
|
||||
summary: '装的 mod 太多,方块/物品 ID 超出上限(1.12 及更早的经典问题)'
|
||||
detail: '删掉一部分 mod,或换用 1.13+ 的版本(不再有 ID 上限)'
|
||||
fix_action:
|
||||
- kind: remove_mod
|
||||
action: disable
|
||||
note: '减量或换版本'
|
||||
sample: samples/forge/too_many_mods.log
|
||||
source: 'HMCL CrashReportAnalyzer.Rule.TOO_MANY_MODS_LEAD_TO_EXCEEDING_THE_ID_LIMIT'
|
||||
|
||||
- id: forge.module_name_invalid
|
||||
severity: warning
|
||||
pattern: >-
|
||||
Invalid module name: '' is not a Java identifier
|
||||
confidence: 0.8
|
||||
priority: 70
|
||||
cause:
|
||||
key: diagnose.cause.forge.module_name_invalid
|
||||
summary: '某个 mod 的 jar 文件名不含英文字母/数字,Forge 把它当成非法 JPMS 模块名(如「拔刀剑.jar」)'
|
||||
detail: '把该 jar 改成英文文件名(保留 .jar 后缀)再启动'
|
||||
fix_action:
|
||||
- kind: remove_mod
|
||||
action: replace
|
||||
note: '把 jar 改成英文文件名'
|
||||
sample: samples/forge/module_name_invalid.log
|
||||
source: 'HMCL CrashReportAnalyzer.Rule.MOD_NAME'
|
||||
|
||||
- id: forge.bootstrap_failed
|
||||
severity: critical
|
||||
pattern: 'Failed to create mod instance\. ModID: (?<id>.*?),'
|
||||
confidence: 0.85
|
||||
priority: 80
|
||||
cause:
|
||||
key: diagnose.cause.forge.bootstrap_failed
|
||||
summary: 'mod {id} 的实例创建失败(初始化就抛异常)'
|
||||
fix_action:
|
||||
- kind: remove_mod
|
||||
mod_ids:
|
||||
- '{id}'
|
||||
action: disable
|
||||
sample: samples/forge/bootstrap_failed.log
|
||||
source: 'HMCL CrashReportAnalyzer.Rule.BOOTSTRAP_FAILED'
|
||||
@@ -0,0 +1,221 @@
|
||||
# 规则库 · 显卡 / 图形(11 条 = HMCL 移植 6 + Vulkan 组 5)
|
||||
# 移植自 HMCL CrashReportAnalyzer.Rule 的图形组(analyse/02 §7.3 第 3 组);
|
||||
# Vulkan 组见文件末尾(依据 research/05-mc-vulkan.md §3.4 的 renderpearl 原因码)。
|
||||
version: 1
|
||||
category: graphics
|
||||
rules:
|
||||
- id: graphics.gl_operation_failure
|
||||
severity: warning
|
||||
pattern: '(1282: Invalid operation|Maybe try a lower resolution resourcepack\?)'
|
||||
confidence: 0.7
|
||||
priority: 60
|
||||
cause:
|
||||
key: diagnose.cause.graphics.gl_operation_failure
|
||||
summary: '某个 mod/光影包做了非法的 OpenGL 操作(GL_INVALID_OPERATION)'
|
||||
detail: '换掉最近装的光影/资源包,或把资源包分辨率降下来'
|
||||
fix_action:
|
||||
- kind: change_graphics
|
||||
setting: renderer
|
||||
suggested_value: 'OpenGL 兼容模式'
|
||||
note: '先低分辨率资源包验证'
|
||||
sample: samples/graphics/gl_operation_failure.log
|
||||
source: 'HMCL CrashReportAnalyzer.Rule.GL_OPERATION_FAILURE'
|
||||
|
||||
- id: graphics.opengl_not_supported
|
||||
severity: critical
|
||||
pattern: 'The driver does not appear to support OpenGL'
|
||||
confidence: 0.85
|
||||
priority: 75
|
||||
cause:
|
||||
key: diagnose.cause.graphics.opengl_not_supported
|
||||
summary: '显卡驱动不支持 OpenGL(多半是没装显卡驱动,走了软件渲染)'
|
||||
detail: '装/更新显卡厂商驱动;虚拟机里需要开 3D 加速'
|
||||
fix_action:
|
||||
- kind: change_graphics
|
||||
setting: driver
|
||||
update_driver: true
|
||||
sample: samples/graphics/opengl_not_supported.log
|
||||
source: 'HMCL CrashReportAnalyzer.Rule.OPENGL_NOT_SUPPORTED'
|
||||
|
||||
- id: graphics.graphics_driver
|
||||
severity: critical
|
||||
# HMCL 的跨行贪婪模式(EXCEPTION_ACCESS_VIOLATION … # C [ig/nvoglv/atio)→ file scope。
|
||||
# 末四段是 SDL 变体:26.3-snapshot-4 起窗口层 GLFW→SDL(lwjgl-glfw 消失、lwjgl-sdl 出现),
|
||||
# 同类故障的原文会变成 SDL_GetError 的文本;旧 GLFW 模式保留(老版本日志仍需要)。
|
||||
scope: file
|
||||
pattern: '(Pixel format not accelerated|GLX: Failed to create context: GLXBadFBConfig|Couldn''t set pixel format|net\.minecraftforge\.fml\.client\.SplashProgress|org\.lwjgl\.LWJGLException|EXCEPTION_ACCESS_VIOLATION(.|\n|\r)+# C {2}\[(ig|atio|nvoglv)|SDL_CreateWindow failed|Couldn''t find matching render driver|No available video device)'
|
||||
confidence: 0.8
|
||||
priority: 78
|
||||
cause:
|
||||
key: diagnose.cause.graphics.graphics_driver
|
||||
summary: '显卡驱动侧创建 OpenGL 上下文失败(像素格式/驱动崩溃)'
|
||||
detail: '更新显卡驱动并重启;老显卡可用软渲染或换 OpenGL 兼容模式'
|
||||
fix_action:
|
||||
- kind: change_graphics
|
||||
setting: driver
|
||||
update_driver: true
|
||||
- kind: change_graphics
|
||||
setting: software_renderer
|
||||
note: '驱动更新无效时用软渲染兜底(性能会掉)'
|
||||
sample: samples/graphics/graphics_driver.log
|
||||
source: 'HMCL CrashReportAnalyzer.Rule.GRAPHICS_DRIVER'
|
||||
|
||||
- id: graphics.macos_display_port
|
||||
severity: critical
|
||||
# 原 GLFW 模式保留。后两段是 SDL 变体(26.3 起窗口层换 SDL,无显示器/远程会话在 SDL 侧
|
||||
# 报 SDL_Init 失败)——**但它必须和 `cocoa` 同时出现才命中**:SDL 在 macOS 上的视频驱动
|
||||
# 就叫 `cocoa`,用它把本规则的 macOS 特异性钉住,免得 Windows/Linux 的 SDL_Init 失败被
|
||||
# 误归因成「macOS 显示器问题」。⚠️ SDL 侧原文未在 26.3 实机验证(预测模式),拿到真实
|
||||
# 日志后按实际文本收紧;宁可漏诊也不要跨平台误归因。
|
||||
pattern: '(java\.lang\.IllegalStateException: GLFW error before init: \[0x10008\]Cocoa: Failed to find service port for display|(?i:cocoa).*SDL_Init.*failed|SDL_Init.*failed.*(?i:cocoa))'
|
||||
confidence: 0.85
|
||||
priority: 78
|
||||
cause:
|
||||
key: diagnose.cause.graphics.macos_display_port
|
||||
summary: 'macOS 上 GLFW 初始化窗口失败(Cocoa 找不到 display service port)'
|
||||
detail: '多见于远程桌面/无显示器会话;换成本机登录或升级 macOS 后再试'
|
||||
fix_action:
|
||||
- kind: change_graphics
|
||||
setting: any
|
||||
note: 'macOS 会话/显示器问题'
|
||||
sample: samples/graphics/macos_display_port.log
|
||||
source: 'HMCL CrashReportAnalyzer.Rule.MACOS_FAILED_TO_FIND_SERVICE_PORT_FOR_DISPLAY'
|
||||
|
||||
- id: graphics.resolution_too_high
|
||||
severity: warning
|
||||
pattern: 'Maybe try a (lower resolution|lowerresolution) (resourcepack|texturepack)\?'
|
||||
confidence: 0.8
|
||||
priority: 65
|
||||
cause:
|
||||
key: diagnose.cause.graphics.resolution_too_high
|
||||
summary: '资源包分辨率过高(游戏直接提示换低分辨率资源包)'
|
||||
fix_action:
|
||||
- kind: change_graphics
|
||||
setting: resolution
|
||||
suggested_value: '低分辨率资源包'
|
||||
sample: samples/graphics/resolution_too_high.log
|
||||
source: 'HMCL CrashReportAnalyzer.Rule.RESOLUTION_TOO_HIGH'
|
||||
|
||||
- id: graphics.rtss_sodium
|
||||
severity: warning
|
||||
pattern: 'RivaTuner Statistics Server \(RTSS\) is not compatible with Sodium'
|
||||
confidence: 0.9
|
||||
priority: 70
|
||||
cause:
|
||||
key: diagnose.cause.graphics.rtss_sodium
|
||||
summary: 'RTSS(微星小飞机/帧数显示)与 Sodium 不兼容'
|
||||
detail: '关掉或卸载 RTSS 的 OSD 覆盖层,或改用 Sodium 兼容的帧数显示'
|
||||
fix_action:
|
||||
- kind: change_graphics
|
||||
setting: overlay
|
||||
suggested_value: '关闭 RTSS 覆盖层'
|
||||
sample: samples/graphics/rtss_sodium.log
|
||||
source: 'HMCL CrashReportAnalyzer.Rule.RTSS_FOREST_SODIUM'
|
||||
|
||||
# ── Vulkan 后端组(MC 26.2+ 的实验性 Vulkan 渲染)────────────────────────────
|
||||
# 证据:research/05-mc-vulkan.md §3.4。renderpearl 的
|
||||
# `BackendCreationException$Reason` 有五个稳定原因码(四个 vulkan_* + opengl_missing),
|
||||
# 它们同时出现在 latest.log 的 `Backend failure reason: <码>` 与崩溃报告的
|
||||
# `Backend failure reason` 行,是最可靠的落点(比英文正文稳,正文会随版本改)。
|
||||
# 注意语义:26.2/26.3 的 Vulkan 首选失败会**自动回落 OpenGL**,所以单条 vulkan_* 命中
|
||||
# 通常不是崩溃主因(warning);只有五个后端全挂才是 critical(见最后一条)。
|
||||
|
||||
- id: graphics.vulkan_loader_missing
|
||||
severity: warning
|
||||
pattern: '(Vulkan loader library is missing|vulkan_loader_missing)'
|
||||
confidence: 0.85
|
||||
priority: 80
|
||||
cause:
|
||||
key: diagnose.cause.graphics.vulkan_loader_missing
|
||||
summary: '机器上没有 Vulkan loader(Vulkan 后端起不来,游戏已回落 OpenGL)'
|
||||
detail: '装/更新显卡驱动(Windows 需要 vulkan-1.dll,Linux 需要 libvulkan.so.1);装不上就改用 OpenGL 后端'
|
||||
fix_action:
|
||||
- kind: change_graphics
|
||||
setting: graphics_api
|
||||
suggested_value: 'opengl'
|
||||
note: 'Vulkan loader 缺失时强制 OpenGL,避免每次启动都白试一遍'
|
||||
- kind: change_graphics
|
||||
setting: driver
|
||||
update_driver: true
|
||||
sample: samples/graphics/vulkan_loader_missing.log
|
||||
source: 'research/05-mc-vulkan.md §3.4(renderpearl BackendCreationException.Reason)'
|
||||
|
||||
- id: graphics.vulkan_device_version_too_low
|
||||
severity: warning
|
||||
pattern: '(vulkan_device_version_too_low|Vulkan [0-9]\.[0-9].{0,40}at least [0-9]\.[0-9] required)'
|
||||
confidence: 0.85
|
||||
priority: 78
|
||||
cause:
|
||||
key: diagnose.cause.graphics.vulkan_device_version_too_low
|
||||
summary: '显卡/驱动支持的 Vulkan 版本低于 1.2(Vulkan 后端起不来)'
|
||||
detail: '更新显卡驱动;驱动停更的老卡(10 年以上)没有 Vulkan 1.2,只能继续用 OpenGL'
|
||||
fix_action:
|
||||
- kind: change_graphics
|
||||
setting: graphics_api
|
||||
suggested_value: 'opengl'
|
||||
- kind: change_graphics
|
||||
setting: driver
|
||||
update_driver: true
|
||||
sample: samples/graphics/vulkan_device_version_too_low.log
|
||||
source: 'research/05-mc-vulkan.md §3.4'
|
||||
|
||||
- id: graphics.vulkan_missing_extension
|
||||
severity: warning
|
||||
pattern: '(vulkan_missing_extension|missing: .*VK_KHR_(dynamic_rendering|push_descriptor))'
|
||||
confidence: 0.85
|
||||
priority: 76
|
||||
cause:
|
||||
key: diagnose.cause.graphics.vulkan_missing_extension
|
||||
summary: '驱动缺 Vulkan 必需扩展(VK_KHR_dynamic_rendering / VK_KHR_push_descriptor)'
|
||||
detail: 'MC 26.2+ 的 Vulkan 后端要求 VK 1.2 + dynamic_rendering + push_descriptor,三者缺一就跑不了;Mesa Dozen 也不支持 push_descriptor'
|
||||
fix_action:
|
||||
- kind: change_graphics
|
||||
setting: graphics_api
|
||||
suggested_value: 'opengl'
|
||||
- kind: change_graphics
|
||||
setting: driver
|
||||
update_driver: true
|
||||
note: '只有在驱动有新版本时才可能补上扩展'
|
||||
sample: samples/graphics/vulkan_missing_extension.log
|
||||
source: 'research/05-mc-vulkan.md §3.4 + §4.4(Dozen 反例)'
|
||||
|
||||
- id: graphics.vulkan_missing_feature
|
||||
severity: warning
|
||||
pattern: '(vulkan_missing_feature|missing capabilities: .*VULKAN_CORE_1_2)'
|
||||
confidence: 0.8
|
||||
priority: 74
|
||||
cause:
|
||||
key: diagnose.cause.graphics.vulkan_missing_feature
|
||||
summary: '驱动缺 Vulkan 必需特性集(VULKAN_CORE_1_2)'
|
||||
detail: '同上,属于驱动能力不足;老核显/虚拟机里常见,用 OpenGL 后端即可'
|
||||
fix_action:
|
||||
- kind: change_graphics
|
||||
setting: graphics_api
|
||||
suggested_value: 'opengl'
|
||||
sample: samples/graphics/vulkan_missing_feature.log
|
||||
source: 'research/05-mc-vulkan.md §3.4'
|
||||
|
||||
- id: graphics.no_graphics_backend
|
||||
severity: critical
|
||||
pattern: 'No supported graphics backend was found'
|
||||
confidence: 0.9
|
||||
priority: 82
|
||||
suppress:
|
||||
- graphics.vulkan_loader_missing
|
||||
- graphics.vulkan_device_version_too_low
|
||||
- graphics.vulkan_missing_extension
|
||||
- graphics.vulkan_missing_feature
|
||||
cause:
|
||||
key: diagnose.cause.graphics.no_graphics_backend
|
||||
summary: 'Vulkan 与 OpenGL 两个后端都创建失败(26.2+ 只有这两条路)'
|
||||
detail: '硬件/驱动两侧都不满足:先更新显卡驱动;虚拟机需要开 3D 加速;实在不行只能换机器'
|
||||
fix_action:
|
||||
- kind: change_graphics
|
||||
setting: graphics_api
|
||||
suggested_value: 'opengl'
|
||||
note: '先确认 Vulkan 失败原因再决定是否强推 OpenGL'
|
||||
- kind: change_graphics
|
||||
setting: software_renderer
|
||||
note: '软渲染兜底能起来但基本没法玩,只用于导数据/排查'
|
||||
sample: samples/graphics/no_graphics_backend.log
|
||||
source: 'research/05-mc-vulkan.md §1.5(两个后端全失败才崩)'
|
||||
@@ -0,0 +1,138 @@
|
||||
# 规则库 · 文件与安装完整性(8 条)
|
||||
# 移植自 HMCL CrashReportAnalyzer.Rule 的完整性组(analyse/02 §7.3 第 4 组)。
|
||||
# 组内约定:通用异常(NoSuchMethodError / NoClassDefFoundError)优先级低,
|
||||
# 让「安装不完整 / jar 被解压 / mod 冲突」这类能定位根因的规则通过 suppress 把它们降级为次生结论。
|
||||
version: 1
|
||||
category: integrity
|
||||
rules:
|
||||
- id: integrity.file_changed
|
||||
severity: critical
|
||||
pattern: 'java\.lang\.SecurityException: SHA1 digest error for (?<file>.*)|signer information does not match signer information of other classes in the same package'
|
||||
confidence: 0.85
|
||||
priority: 85
|
||||
cause:
|
||||
key: diagnose.cause.integrity.file_changed
|
||||
summary: '游戏文件被改过(SHA1 校验不一致):jar 内容与清单哈希对不上'
|
||||
detail: '重新下载/校验该文件;不要手改 jar 里的内容'
|
||||
fix_action:
|
||||
- kind: reinstall
|
||||
component: game_jar
|
||||
path_hint: '{file}'
|
||||
sample: samples/integrity/file_changed.log
|
||||
source: 'HMCL CrashReportAnalyzer.Rule.FILE_CHANGED'
|
||||
|
||||
- id: integrity.no_such_method
|
||||
severity: warning
|
||||
pattern: 'java\.lang\.NoSuchMethodError: (?<class>.*?)'
|
||||
confidence: 0.6
|
||||
priority: 40
|
||||
cause:
|
||||
key: diagnose.cause.integrity.no_such_method
|
||||
summary: 'NoSuchMethodError:加载器/coremod 注入的东西与当前库版本不匹配'
|
||||
detail: '常见于 mod 与游戏版本/Forge 版本不配套;重装加载器或删掉冲突 mod'
|
||||
fix_action:
|
||||
- kind: reinstall
|
||||
component: loader
|
||||
note: '先重装加载器;仍失败再逐个禁用 mod'
|
||||
sample: samples/integrity/no_such_method.log
|
||||
source: 'HMCL CrashReportAnalyzer.Rule.NO_SUCH_METHOD_ERROR'
|
||||
|
||||
- id: integrity.no_class_def_found
|
||||
severity: warning
|
||||
pattern: 'java\.lang\.NoClassDefFoundError: (?<class>.*)'
|
||||
confidence: 0.6
|
||||
priority: 40
|
||||
cause:
|
||||
key: diagnose.cause.integrity.no_class_def_found
|
||||
summary: 'NoClassDefFoundError:缺少类文件(库没下全 / jar 被解压 / mod 不配套)'
|
||||
detail: '先检查 libraries 是否完整,再排查 mods 里的解压目录'
|
||||
fix_action:
|
||||
- kind: reinstall
|
||||
component: libraries
|
||||
sample: samples/integrity/no_class_def_found.log
|
||||
source: 'HMCL CrashReportAnalyzer.Rule.NO_CLASS_DEF_FOUND_ERROR'
|
||||
|
||||
- id: integrity.illegal_access
|
||||
severity: warning
|
||||
pattern: 'java\.lang\.IllegalAccessError: tried to access class (.*?) from class (?<class>.*?)'
|
||||
confidence: 0.55
|
||||
priority: 45
|
||||
cause:
|
||||
key: diagnose.cause.integrity.illegal_access
|
||||
summary: 'IllegalAccessError:某个 coremod 想反射访问别的类的内部成员(在新 JDK 上被封了)'
|
||||
fix_action:
|
||||
- kind: reinstall
|
||||
component: loader
|
||||
note: '或换该 coremod/mod 的兼容版本'
|
||||
sample: samples/integrity/illegal_access.log
|
||||
source: 'HMCL CrashReportAnalyzer.Rule.ILLEGAL_ACCESS_ERROR'
|
||||
|
||||
- id: integrity.file_already_exists
|
||||
severity: warning
|
||||
pattern: 'java\.nio\.file\.FileAlreadyExistsException: (?<file>.*)'
|
||||
confidence: 0.75
|
||||
priority: 60
|
||||
cause:
|
||||
key: diagnose.cause.integrity.file_already_exists
|
||||
summary: '某个 mod 要求一个「不该存在」的文件/目录,导致写入失败'
|
||||
detail: '按提示删掉该文件(通常是玩家手建的空目录/unzip 残留)'
|
||||
fix_action:
|
||||
- kind: reinstall
|
||||
component: mods
|
||||
path_hint: '{file}'
|
||||
note: '按路径删掉多余文件'
|
||||
sample: samples/integrity/file_already_exists.log
|
||||
source: 'HMCL CrashReportAnalyzer.Rule.FILE_ALREADY_EXISTS'
|
||||
|
||||
- id: integrity.mod_files_decompressed
|
||||
severity: critical
|
||||
pattern: '(The directories below appear to be extracted jar files\. Fix this before you continue|Extracted mod jars found, loading will NOT continue)'
|
||||
confidence: 0.95
|
||||
priority: 90
|
||||
suppress:
|
||||
- integrity.no_class_def_found
|
||||
- integrity.no_such_method
|
||||
cause:
|
||||
key: diagnose.cause.integrity.mod_files_decompressed
|
||||
summary: 'mods 目录里有被解压成文件夹的 jar:加载器会直接拒绝启动'
|
||||
detail: '把解压出来的目录删掉,只保留 .jar 文件(很多压缩软件双击就解压了)'
|
||||
fix_action:
|
||||
- kind: reinstall
|
||||
component: mods
|
||||
note: '删掉解压出来的目录'
|
||||
sample: samples/integrity/mod_files_decompressed.log
|
||||
source: 'HMCL CrashReportAnalyzer.Rule.MOD_FILES_ARE_DECOMPRESSED'
|
||||
|
||||
- id: integrity.incomplete_forge
|
||||
severity: critical
|
||||
pattern: '(java\.io\.UncheckedIOException: java\.io\.IOException: Invalid paths argument, contained no existing paths: \[(.*?)(forge-(.*?)-client\.jar|fmlcore-(.*?)\.jar)\]|Failed to find Minecraft resource version (.*?) at (.*?)forge-(.*?)-client\.jar|Cannot find launch target fmlclient, unable to launch|java\.lang\.IllegalStateException: Could not find net/minecraft/client/Minecraft\.class in classloader SecureModuleClassLoader)'
|
||||
confidence: 0.9
|
||||
priority: 88
|
||||
suppress:
|
||||
- integrity.no_class_def_found
|
||||
- integrity.no_such_method
|
||||
cause:
|
||||
key: diagnose.cause.integrity.incomplete_forge
|
||||
summary: 'Forge 安装不完整(缺 forge-*-client.jar / fmlcore)'
|
||||
detail: '重装该版本的 Forge,并检查 libraries 是否下全'
|
||||
fix_action:
|
||||
- kind: reinstall
|
||||
component: forge
|
||||
sample: samples/integrity/incomplete_forge.log
|
||||
source: 'HMCL CrashReportAnalyzer.Rule.INCOMPLETE_FORGE_INSTALLATION'
|
||||
|
||||
- id: integrity.unsatisfied_link
|
||||
severity: critical
|
||||
pattern: 'java\.lang\.UnsatisfiedLinkError: Failed to locate library: (?<name>.*)'
|
||||
confidence: 0.85
|
||||
priority: 82
|
||||
cause:
|
||||
key: diagnose.cause.integrity.unsatisfied_link
|
||||
summary: '找不到原生库(natives):{name} 没解压出来或被杀软删了'
|
||||
detail: '重装组件(重新解压 natives);Windows 上检查杀软隔离区'
|
||||
fix_action:
|
||||
- kind: reinstall
|
||||
component: natives
|
||||
path_hint: '{name}'
|
||||
sample: samples/integrity/unsatisfied_link.log
|
||||
source: 'HMCL CrashReportAnalyzer.Rule.UNSATISFIED_LINK_ERROR'
|
||||
@@ -0,0 +1,142 @@
|
||||
# 规则库 · JVM / Java 版本(8 条)
|
||||
# 移植自 HMCL CrashReportAnalyzer.Rule(analyse/02 §7.3 第 1 组)。
|
||||
# schema 见 ../CONTRACT.md §2:pattern(regex) -> {cause, confidence, fix_action[]}
|
||||
version: 1
|
||||
category: jvm
|
||||
rules:
|
||||
- id: jvm.openj9
|
||||
severity: critical
|
||||
pattern: '(Open J9 is not supported|OpenJ9 is incompatible|\.J9VMInternals\.)'
|
||||
confidence: 0.9
|
||||
priority: 88
|
||||
cause:
|
||||
key: diagnose.cause.jvm.openj9
|
||||
summary: '当前用的是 OpenJ9 虚拟机:Minecraft 与绝大多数 mod 只支持 HotSpot(Oracle/OpenJDK)'
|
||||
detail: '请换成 HotSpot 的 Java 8/17/21(Adoptium、Zulu、Microsoft OpenJDK 的 hotspot 版本都可以)'
|
||||
fix_action:
|
||||
- kind: change_java
|
||||
vendor: hotspot
|
||||
note: 'OpenJ9 不兼容,换 HotSpot'
|
||||
sample: samples/jvm/openj9.log
|
||||
source: 'HMCL CrashReportAnalyzer.Rule.OPENJ9'
|
||||
|
||||
- id: jvm.need_jdk11
|
||||
severity: critical
|
||||
pattern: '(no such method: sun\.misc\.Unsafe\.defineAnonymousClass\(Class,byte\[\],Object\[\]\)Class/invokeVirtual|java\.lang\.UnsupportedClassVersionError: icyllis/modernui/forge/MixinConnector has been compiled by a more recent version of the Java Runtime \(class file version 55\.0\), this version of the Java Runtime only recognizes class file versions up to 52\.0|java\.lang\.IllegalArgumentException: The requested compatibility level JAVA_11 could not be set\. Level is not supported by the active JRE or ASM version)'
|
||||
confidence: 0.92
|
||||
priority: 90
|
||||
cause:
|
||||
key: diagnose.cause.jvm.need_jdk11
|
||||
summary: '模组/加载器需要 Java 11,当前 Java 版本不够'
|
||||
detail: '把实例的 Java 换到 11 或更高(1.17+ 的游戏通常要 17/21)'
|
||||
fix_action:
|
||||
- kind: change_java
|
||||
required_major: 11
|
||||
sample: samples/jvm/need_jdk11.log
|
||||
source: 'HMCL CrashReportAnalyzer.Rule.NEED_JDK11'
|
||||
|
||||
- id: jvm.too_old_java
|
||||
severity: critical
|
||||
pattern: 'java\.lang\.UnsupportedClassVersionError: (.*?) version (?<expected>\d+)\.0'
|
||||
confidence: 0.92
|
||||
priority: 88
|
||||
cause:
|
||||
key: diagnose.cause.jvm.too_old_java
|
||||
summary: 'Java 版本过低:无法加载用 class file version {expected} 编译的类,请换用更高版本的 Java'
|
||||
detail: '这个 class 版本号减去 44 就是所需的 Java 大版本(52→Java 8、61→Java 17)'
|
||||
fix_action:
|
||||
- kind: change_java
|
||||
class_file_version_group: expected
|
||||
sample: samples/jvm/too_old_java.log
|
||||
source: 'HMCL CrashReportAnalyzer.Rule.TOO_OLD_JAVA'
|
||||
|
||||
- id: jvm.32bit
|
||||
severity: critical
|
||||
pattern: '(Could not reserve enough space for (.*?)KB object heap|The specified size exceeds the maximum representable size|Invalid maximum heap size)'
|
||||
scope: line
|
||||
confidence: 0.9
|
||||
priority: 92
|
||||
suppress:
|
||||
- memory.out_of_memory
|
||||
cause:
|
||||
key: diagnose.cause.jvm.32bit
|
||||
summary: '分配堆内存失败:多半是 32 位 Java 或 -Xmx 超过系统可用内存'
|
||||
detail: '32 位 Java 最多只能用约 1.5 GB;换 64 位 Java 或把 -Xmx 降到 2048 MB 以下'
|
||||
fix_action:
|
||||
- kind: adjust_memory
|
||||
need_64bit_java: true
|
||||
min_xmx_mb: 1024
|
||||
- kind: change_java
|
||||
required_major: 17
|
||||
note: '32 位换 64 位'
|
||||
sample: samples/jvm/jvm_32bit.log
|
||||
source: 'HMCL CrashReportAnalyzer.Rule.JVM_32BIT'
|
||||
|
||||
- id: jvm.jdk_9
|
||||
severity: critical
|
||||
pattern: 'java\.lang\.ClassCastException: (java\.base/jdk|class jdk)'
|
||||
confidence: 0.8
|
||||
priority: 80
|
||||
cause:
|
||||
key: diagnose.cause.jvm.jdk_9
|
||||
summary: '游戏只能在 Java 8 上跑(Forge/旧核心库不兼容 Java 9+),当前 Java 版本太高'
|
||||
fix_action:
|
||||
- kind: change_java
|
||||
max_major: 8
|
||||
sample: samples/jvm/jdk_9.log
|
||||
source: 'HMCL CrashReportAnalyzer.Rule.JDK_9'
|
||||
|
||||
- id: jvm.version_too_high
|
||||
severity: critical
|
||||
pattern: '(Unable to make protected final java\.lang\.Class java\.lang\.ClassLoader\.defineClass|java\.lang\.NoSuchFieldException: ucp|Unsupported class file major version|because module java\.base does not export|java\.lang\.ClassNotFoundException: jdk\.nashorn\.api\.scripting\.NashornScriptEngineFactory|java\.lang\.ClassNotFoundException: java\.lang\.invoke\.LambdaMetafactory|Exception in thread "main" java\.lang\.NullPointerException: Cannot read the array length because "urls" is null)'
|
||||
confidence: 0.85
|
||||
priority: 84
|
||||
cause:
|
||||
key: diagnose.cause.jvm.version_too_high
|
||||
summary: 'Java 版本过高:老版本 Forge/核心库用了被新 JDK 移除或封装的接口'
|
||||
detail: '1.16 及更早优先用 Java 8;1.17–1.20.4 用 Java 17;1.20.5+ 用 Java 21'
|
||||
fix_action:
|
||||
- kind: change_java
|
||||
vendor: hotspot
|
||||
note: '降到该游戏版本要求的 Java'
|
||||
- kind: reinstall
|
||||
component: loader
|
||||
note: '或把 Forge/加载器升级到支持新 Java 的版本'
|
||||
sample: samples/jvm/java_version_is_too_high.log
|
||||
source: 'HMCL CrashReportAnalyzer.Rule.JAVA_VERSION_IS_TOO_HIGH'
|
||||
|
||||
- id: jvm.modlauncher_8
|
||||
severity: critical
|
||||
pattern: 'java\.lang\.NoSuchMethodError: (''void sun\.security\.util\.ManifestEntryVerifier\.<init>\(java\.util\.jar\.Manifest\)''|sun\.security\.util\.ManifestEntryVerifier\.<init>\(Ljava/util/jar/Manifest;\)V)'
|
||||
confidence: 0.9
|
||||
priority: 86
|
||||
cause:
|
||||
key: diagnose.cause.jvm.modlauncher_8
|
||||
summary: 'ModLauncher 8 与当前 Java 不兼容(JDK-8273826):1.16+ Forge 在新 JDK 上会这样崩'
|
||||
fix_action:
|
||||
- kind: change_java
|
||||
max_major: 8
|
||||
- kind: reinstall
|
||||
component: forge
|
||||
note: '或升级 Forge 到新版'
|
||||
sample: samples/jvm/modlauncher_8.log
|
||||
source: 'HMCL CrashReportAnalyzer.Rule.MODLAUNCHER_8'
|
||||
|
||||
- id: jvm.mac_jdk8u261
|
||||
severity: critical
|
||||
pattern: >-
|
||||
Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'NSWindow drag regions should only be invalidated on the Main Thread!'
|
||||
confidence: 0.85
|
||||
priority: 82
|
||||
cause:
|
||||
key: diagnose.cause.jvm.mac_jdk8u261
|
||||
summary: 'macOS 上 JDK 8u261+ 用新 Xcode 编译导致的 Forge/OptiFine 崩溃(sp614x/optifine#4824)'
|
||||
detail: '换 Java 8u252 及更早,或升级 Forge/OptiFine'
|
||||
fix_action:
|
||||
- kind: change_java
|
||||
max_major: 8
|
||||
note: '优先试 8u252'
|
||||
- kind: reinstall
|
||||
component: optifine
|
||||
sample: samples/jvm/mac_jdk8u261.log
|
||||
source: 'HMCL CrashReportAnalyzer.Rule.MAC_JDK_8U261'
|
||||
@@ -0,0 +1,39 @@
|
||||
# 规则库 · 内存(2 条)
|
||||
# 移植自 HMCL CrashReportAnalyzer.Rule 的内存组(analyse/02 §7.3 第 2 组)。
|
||||
version: 1
|
||||
category: memory
|
||||
rules:
|
||||
- id: memory.out_of_memory
|
||||
severity: critical
|
||||
# 最后一条是跨行组合("Error occurred during initialization of VM" + "Too small maximum heap"),
|
||||
# 因此整条规则用 file scope;HMCL 的 \R 在 Rust regex 里写成 (?:\r\n|\n|\r)。
|
||||
scope: file
|
||||
pattern: '(java\.lang\.OutOfMemoryError|The system is out of physical RAM or swap space|Out of Memory Error|Error occurred during initialization of VM(?:\r\n|\n|\r)Too small maximum heap)'
|
||||
confidence: 0.9
|
||||
priority: 80
|
||||
cause:
|
||||
key: diagnose.cause.memory.out_of_memory
|
||||
summary: '游戏内存不足(OutOfMemoryError):-Xmx 太小,或装的 mod 太多/整合包太大'
|
||||
detail: '把 -Xmx 调到物理内存的一半左右(一般 4096–8192 MB),装光影/大量 mod 时适度加大'
|
||||
fix_action:
|
||||
- kind: adjust_memory
|
||||
min_xmx_mb: 2048
|
||||
note: '优先加大 -Xmx;若已经是 32 位 Java,先换 64 位'
|
||||
sample: samples/memory/out_of_memory.log
|
||||
source: 'HMCL CrashReportAnalyzer.Rule.OUT_OF_MEMORY'
|
||||
|
||||
- id: memory.memory_exceeded
|
||||
severity: critical
|
||||
pattern: 'There is insufficient memory for the Java Runtime Environment to continue'
|
||||
confidence: 0.88
|
||||
priority: 82
|
||||
cause:
|
||||
key: diagnose.cause.memory.memory_exceeded
|
||||
summary: '系统物理内存/交换区耗尽:JVM 连续分配失败,整机内存不够'
|
||||
detail: '关掉占内存的程序、降低 -Xmx,或扩大系统虚拟内存(页面文件)'
|
||||
fix_action:
|
||||
- kind: adjust_memory
|
||||
increase_system_swap: true
|
||||
note: '整机内存不足,不只是 -Xmx 的问题'
|
||||
sample: samples/memory/memory_exceeded.log
|
||||
source: 'HMCL CrashReportAnalyzer.Rule.MEMORY_EXCEEDED'
|
||||
@@ -0,0 +1,152 @@
|
||||
# 规则库 · 模组互斥(8 条)
|
||||
# 移植自 HMCL CrashReportAnalyzer.Rule 的互斥组(analyse/02 §7.3 第 8 组):
|
||||
# 具体到某个知名 mod 与 OptiFine / Sodium 的成名冲突,修复动作几乎都是「删 OptiFine 或其对手」。
|
||||
version: 1
|
||||
category: mod_conflict
|
||||
rules:
|
||||
- id: conflict.optifine_forge
|
||||
severity: critical
|
||||
scope: file
|
||||
# 移植说明:HMCL 原文里的 \> / \< 在这里改成裸 > / <(正则里 \< \> 是词边界断言,要字面量得写裸字符)。
|
||||
# 末尾 alternative 保持 upstream 语义:`\(Reflector\.java)` 的前置 \( 是**字面括号**,尾部裸 ) 才是**闭合外层分组**,
|
||||
# 因此它是前缀匹配。这里显式写成 `\(Reflector\.java(?::\d+)?\))`:裸 `(Reflector.java)` 与带行号的
|
||||
# `(Reflector.java:455)` 两种真实栈帧形态都能命中(P2-1 回归,见 tests/diagnose/engine_rank.rs)。
|
||||
pattern: >-
|
||||
(java\.lang\.NoSuchMethodError: 'java\.lang\.Class sun\.misc\.Unsafe\.defineAnonymousClass\(java\.lang\.Class, byte\[\], java\.lang\.Object\[\]\)'|java\.lang\.NoSuchMethodError: 'void net\.minecraft\.client\.renderer\.texture\.SpriteContents\.<init>\(net\.minecraft\.resources\.ResourceLocation, |java\.lang\.NoSuchMethodError: 'void net\.minecraftforge\.client\.gui\.overlay\.ForgeGui\.renderSelectedItemName\(net\.minecraft\.client\.gui\.GuiGraphics, int\)'|java\.lang\.NoSuchMethodError: 'java\.lang\.String com\.mojang\.blaze3d\.systems\.RenderSystem\.getBackendDescription\(\)'|java\.lang\.NoSuchMethodError: 'net\.minecraft\.network\.chat\.FormattedText net\.minecraft\.client\.gui\.Font\.ellipsize\(net\.minecraft\.network\.chat\.FormattedText, int\)'|java\.lang\.NoSuchMethodError: 'void net\.minecraft\.server\.level\.DistanceManager\.(.*?)\(net\.minecraft\.server\.level\.TicketType, net\.minecraft\.world\.level\.ChunkPos, int, java\.lang\.Object, boolean\)'|java\.lang\.NoSuchMethodError: 'void net\.minecraft\.client\.renderer\.block\.model\.BakedQuad\.<init>\(int\[\], int, net\.minecraft\.core\.Direction, net\.minecraft\.client\.renderer\.texture\.TextureAtlasSprite, boolean, boolean\)'|TRANSFORMER/net\.optifine/net\.optifine\.reflect\.Reflector\.<clinit>\(Reflector\.java(?::\d+)?\))
|
||||
confidence: 0.9
|
||||
priority: 87
|
||||
suppress:
|
||||
- integrity.no_such_method
|
||||
- integrity.no_class_def_found
|
||||
cause:
|
||||
key: diagnose.cause.conflict.optifine_forge
|
||||
summary: 'OptiFine 与 Forge(或某个 Forge mod)不兼容:NoSuchMethodError 指向被 OptiFine 改写的原版方法'
|
||||
detail: '删掉 OptiFine,改用同类替代(Sodium/Iris、Embeddium、Oculus 等),或换 OptiFine 的匹配版本'
|
||||
fix_action:
|
||||
- kind: remove_mod
|
||||
mod_ids:
|
||||
- optifine
|
||||
action: remove
|
||||
note: 'OptiFine 与 Forge 冲突,删掉其一'
|
||||
sample: samples/mod_conflict/optifine_forge.log
|
||||
source: 'HMCL CrashReportAnalyzer.Rule.OPTIFINE_IS_NOT_COMPATIBLE_WITH_FORGE'
|
||||
|
||||
- id: conflict.shaders_mod
|
||||
severity: critical
|
||||
pattern: 'java\.lang\.RuntimeException: Shaders Mod detected\. Please remove it, OptiFine has built-in support for shaders\.'
|
||||
confidence: 0.95
|
||||
priority: 85
|
||||
cause:
|
||||
key: diagnose.cause.conflict.shaders_mod
|
||||
summary: '装了独立的 Shaders Mod,但 OptiFine 自带光影支持,两者不能共存'
|
||||
fix_action:
|
||||
- kind: remove_mod
|
||||
mod_ids:
|
||||
- shadersmod
|
||||
action: remove
|
||||
sample: samples/mod_conflict/shaders_mod.log
|
||||
source: 'HMCL CrashReportAnalyzer.Rule.SHADERS_MOD'
|
||||
|
||||
- id: conflict.multipart_optifine
|
||||
severity: critical
|
||||
pattern: 'Error occurred applying transform of coremod META-INF/asm/multipart\.js function render'
|
||||
confidence: 0.8
|
||||
priority: 80
|
||||
cause:
|
||||
key: diagnose.cause.conflict.multipart_optifine
|
||||
summary: 'Forge Multipart 的 coremod 转换失败(多为 OptiFine 同时存在导致)'
|
||||
fix_action:
|
||||
- kind: remove_mod
|
||||
mod_ids:
|
||||
- optifine
|
||||
action: remove
|
||||
note: '删 OptiFine 或 ForgeMultipart 之一'
|
||||
sample: samples/mod_conflict/multipart_optifine.log
|
||||
source: 'HMCL CrashReportAnalyzer.Rule.MOD_FOREST_OPTIFINE'
|
||||
|
||||
- id: conflict.performant_optifine
|
||||
severity: critical
|
||||
pattern: 'org\.spongepowered\.asm\.mixin\.injection\.throwables\.InjectionError: Critical injection failure: Redirector OnisOnLadder\(Lnet/minecraft/block/BlockState;Lnet/minecraft/world/World;Lnet/minecraft/util/math/BlockPos;Lnet/minecraft/entity/LivingEntity;\)Z in performant\.mixins\.json:entity\.LivingEntityMixin failed injection check, \(0/1\) succeeded\. Scanned 1 target\(s\)\. Using refmap performant\.refmap\.json'
|
||||
confidence: 0.9
|
||||
priority: 86
|
||||
cause:
|
||||
key: diagnose.cause.conflict.performant_optifine
|
||||
summary: 'Performant 与 OptiFine 的 mixin 冲突(LivingEntityMixin 注入失败)'
|
||||
fix_action:
|
||||
- kind: remove_mod
|
||||
mod_ids:
|
||||
- performant
|
||||
- optifine
|
||||
action: remove
|
||||
note: '两者只能留一个'
|
||||
sample: samples/mod_conflict/performant_optifine.log
|
||||
source: 'HMCL CrashReportAnalyzer.Rule.PERFORMANT_FOREST_OPTIFINE'
|
||||
|
||||
- id: conflict.twilight_optifine
|
||||
severity: warning
|
||||
pattern: 'java\.lang\.IllegalArgumentException: (.*) outside of image bounds (.*)'
|
||||
confidence: 0.6
|
||||
priority: 70
|
||||
cause:
|
||||
key: diagnose.cause.conflict.twilight_optifine
|
||||
summary: '贴图越界(outside of image bounds):1.16 上 Twilight Forest 与 OptiFine 的经典冲突'
|
||||
fix_action:
|
||||
- kind: remove_mod
|
||||
mod_ids:
|
||||
- optifine
|
||||
action: remove
|
||||
note: '删 OptiFine 或 Twilight Forest 的兼容版本'
|
||||
sample: samples/mod_conflict/twilight_optifine.log
|
||||
source: 'HMCL CrashReportAnalyzer.Rule.TWILIGHT_FOREST_OPTIFINE'
|
||||
|
||||
- id: conflict.jade_optifine
|
||||
severity: critical
|
||||
pattern: 'Critical injection failure: LVT in net/minecraft/client/renderer/GameRenderer::m_109093_\(FJZ\)V has incompatible changes at opcode 760 in callback jade\.mixins\.json:GameRendererMixin->@Inject::jade\$runTick\(FJZLorg/spongepowered/asm/mixin/injection/callback/CallbackInfo;IILcom/mojang/blaze3d/platform/Window;Lorg/joml/Matrix4f;Lcom/mojang/blaze3d/vertex/PoseStack;Lnet/minecraft/client/gui/GuiGraphics;\)V\.'
|
||||
confidence: 0.9
|
||||
priority: 86
|
||||
cause:
|
||||
key: diagnose.cause.conflict.jade_optifine
|
||||
summary: 'Jade 与 OptiFine 在 1.20+ 冲突(GameRendererMixin 注入失败)'
|
||||
fix_action:
|
||||
- kind: remove_mod
|
||||
mod_ids:
|
||||
- optifine
|
||||
- jade
|
||||
action: remove
|
||||
note: '两者只能留一个'
|
||||
sample: samples/mod_conflict/jade_optifine.log
|
||||
source: 'HMCL CrashReportAnalyzer.Rule.JADE_FOREST_OPTIFINE'
|
||||
|
||||
- id: conflict.neoforge_optifine
|
||||
severity: critical
|
||||
pattern: 'cpw\.mods\.modlauncher\.InvalidLauncherSetupException: Invalid Services found OptiFine'
|
||||
confidence: 0.9
|
||||
priority: 86
|
||||
cause:
|
||||
key: diagnose.cause.conflict.neoforge_optifine
|
||||
summary: 'NeoForge 与 OptiFine 不兼容(launcher setup 阶段就拒绝)'
|
||||
fix_action:
|
||||
- kind: remove_mod
|
||||
mod_ids:
|
||||
- optifine
|
||||
action: remove
|
||||
sample: samples/mod_conflict/neoforge_optifine.log
|
||||
source: 'HMCL CrashReportAnalyzer.Rule.NEOFORGE_FOREST_OPTIFINE'
|
||||
|
||||
- id: conflict.optifine_world_load
|
||||
severity: critical
|
||||
# 移植修正:HMCL 原文写的是 ChunkManager$ProxyTicketManager,Java 正则里 $ 是行尾锚点,
|
||||
# 那条规则实际上永远命中不了。这里转义成字面量 $,规则才能真正生效。
|
||||
pattern: 'java\.lang\.NoSuchMethodError: net\.minecraft\.world\.server\.ChunkManager\$ProxyTicketManager\.shouldForceTicks\(J\)Z'
|
||||
confidence: 0.85
|
||||
priority: 84
|
||||
cause:
|
||||
key: diagnose.cause.conflict.optifine_world_load
|
||||
summary: 'OptiFine 导致世界无法加载(ChunkManager$ProxyTicketManager 方法被改写)'
|
||||
fix_action:
|
||||
- kind: remove_mod
|
||||
mod_ids:
|
||||
- optifine
|
||||
action: remove
|
||||
sample: samples/mod_conflict/optifine_world_load.log
|
||||
source: 'HMCL CrashReportAnalyzer.Rule.OPTIFINE_CAUSES_THE_WORLD_TO_FAIL_TO_LOAD'
|
||||
@@ -0,0 +1,136 @@
|
||||
# 规则库 · mod 运行期崩(8 条)
|
||||
# 移植自 HMCL CrashReportAnalyzer.Rule 的运行期组(analyse/02 §7.3 第 7 组)。
|
||||
version: 1
|
||||
category: mod_runtime
|
||||
rules:
|
||||
- id: mod.loading_crashed_forge
|
||||
severity: critical
|
||||
pattern: 'LoaderExceptionModCrash: Caught exception from (?<name>.*?) \((?<id>.*)\)'
|
||||
confidence: 0.9
|
||||
priority: 85
|
||||
cause:
|
||||
key: diagnose.cause.mod.loading_crashed_forge
|
||||
summary: 'Forge 抓到 {name}({id})在加载时抛异常'
|
||||
detail: '禁用该 mod 或换它的兼容版本;同一条栈里往往还有具体异常类型'
|
||||
fix_action:
|
||||
- kind: remove_mod
|
||||
mod_ids:
|
||||
- '{id}'
|
||||
action: disable
|
||||
sample: samples/mod_runtime/loading_crashed_forge.log
|
||||
source: 'HMCL CrashReportAnalyzer.Rule.LOADING_CRASHED_FORGE'
|
||||
|
||||
- id: mod.loading_crashed_fabric
|
||||
severity: critical
|
||||
pattern: >-
|
||||
Could not execute entrypoint stage '(.*?)' due to errors, provided by '(?<id>.*)'!
|
||||
confidence: 0.9
|
||||
priority: 85
|
||||
cause:
|
||||
key: diagnose.cause.mod.loading_crashed_fabric
|
||||
summary: 'Fabric 在入口点阶段执行 mod {id} 时出错'
|
||||
fix_action:
|
||||
- kind: remove_mod
|
||||
mod_ids:
|
||||
- '{id}'
|
||||
action: disable
|
||||
sample: samples/mod_runtime/loading_crashed_fabric.log
|
||||
source: 'HMCL CrashReportAnalyzer.Rule.LOADING_CRASHED_FABRIC'
|
||||
|
||||
- id: mod.config_failed
|
||||
severity: warning
|
||||
pattern: 'Failed loading config file (?<file>.*?) of type (.*?) for modid (?<id>.*)'
|
||||
confidence: 0.7
|
||||
priority: 60
|
||||
cause:
|
||||
key: diagnose.cause.mod.config_failed
|
||||
summary: 'mod {id} 的配置文件 {file} 解析失败(多半是被手改坏或版本升级后格式变了)'
|
||||
detail: '删掉该配置文件让 mod 重新生成默认值'
|
||||
fix_action:
|
||||
- kind: remove_mod
|
||||
mod_ids:
|
||||
- '{id}'
|
||||
action: disable
|
||||
note: '删配置文件 {file} 后重试(不一定要禁用 mod)'
|
||||
sample: samples/mod_runtime/config_failed.log
|
||||
source: 'HMCL CrashReportAnalyzer.Rule.CONFIG'
|
||||
|
||||
- id: mod.mixin_failure
|
||||
severity: critical
|
||||
pattern: '(MixinApplyError|Mixin prepare failed |Mixin apply failed |mixin\.injection\.throwables\.|\.mixins\.json\] FAILED during \))'
|
||||
confidence: 0.7
|
||||
priority: 65
|
||||
cause:
|
||||
key: diagnose.cause.mod.mixin_failure
|
||||
summary: 'Mixin 注入失败:某个 mod 的 mixin 与当前游戏/其他 mod 的代码不匹配'
|
||||
detail: '常见于 mod 与游戏版本不配套;看日志里 mixin 的 target 类名能定位到 mod'
|
||||
fix_action:
|
||||
- kind: remove_mod
|
||||
action: disable
|
||||
note: '按 mixin 配置文件名(xxx.mixins.json)定位 mod'
|
||||
sample: samples/mod_runtime/mixin_failure.log
|
||||
source: 'HMCL CrashReportAnalyzer.Rule.MODMIXIN_FAILURE'
|
||||
|
||||
- id: mod.mixin_apply_failed
|
||||
severity: critical
|
||||
pattern: 'Mixin apply for mod (?<id>.*) failed'
|
||||
confidence: 0.85
|
||||
priority: 82
|
||||
cause:
|
||||
key: diagnose.cause.mod.mixin_apply_failed
|
||||
summary: 'mod {id} 的 mixin 应用失败'
|
||||
fix_action:
|
||||
- kind: remove_mod
|
||||
mod_ids:
|
||||
- '{id}'
|
||||
action: disable
|
||||
sample: samples/mod_runtime/mixin_apply_failed.log
|
||||
source: 'HMCL CrashReportAnalyzer.Rule.MIXIN_APPLY_MOD_FAILED'
|
||||
|
||||
- id: mod.forge_error
|
||||
severity: warning
|
||||
scope: file
|
||||
pattern: 'An exception was thrown, the game will display an error screen and halt\.(?:\r\n|\n|\r)*(?<reason>.*(?:\r\n|\n|\r)*(\s*at .*(?:\r\n|\n|\r))+)'
|
||||
confidence: 0.6
|
||||
priority: 55
|
||||
cause:
|
||||
key: diagnose.cause.mod.forge_error
|
||||
summary: 'Forge 捕获到未处理异常并已弹出错误界面(Forge 自己给了栈)'
|
||||
detail: '栈顶的 at 行指向哪个 mod 的包名,就去禁用/升级那个 mod'
|
||||
fix_action:
|
||||
- kind: seek_help
|
||||
export_diagnostics: true
|
||||
sample: samples/mod_runtime/forge_error.log
|
||||
source: 'HMCL CrashReportAnalyzer.Rule.FORGE_ERROR'
|
||||
|
||||
- id: mod.night_config_fixes
|
||||
severity: warning
|
||||
pattern: 'com\.electronwill\.nightconfig\.core\.io\.ParsingException: Not enough data available'
|
||||
confidence: 0.85
|
||||
priority: 75
|
||||
cause:
|
||||
key: diagnose.cause.mod.night_config_fixes
|
||||
summary: 'night-config 配置文件读坏了(常见于 nightconfigfixes 相关 mod 组合)'
|
||||
detail: '删掉对应 mod 的配置文件,或移除 nightconfigfixes'
|
||||
fix_action:
|
||||
- kind: remove_mod
|
||||
mod_ids:
|
||||
- nightconfigfixes
|
||||
action: remove
|
||||
sample: samples/mod_runtime/night_config_fixes.log
|
||||
source: 'HMCL CrashReportAnalyzer.Rule.NIGHT_CONFIG_FIXES'
|
||||
|
||||
- id: mod.install_mixinbootstrap
|
||||
severity: critical
|
||||
pattern: 'java\.lang\.ClassNotFoundException: org\.spongepowered\.asm\.launch\.MixinTweaker'
|
||||
confidence: 0.8
|
||||
priority: 75
|
||||
cause:
|
||||
key: diagnose.cause.mod.install_mixinbootstrap
|
||||
summary: '缺少 MixinBootstrap 前置(Forge 1.12.2 上很多 mod 需要它)'
|
||||
fix_action:
|
||||
- kind: reinstall
|
||||
component: mixin_bootstrap
|
||||
note: '装上 MixinBootstrap 0.8+'
|
||||
sample: samples/mod_runtime/install_mixinbootstrap.log
|
||||
source: 'HMCL CrashReportAnalyzer.Rule.INSTALL_MIXINBOOTSTRAP'
|
||||
@@ -0,0 +1,22 @@
|
||||
# 规则库 · 其它(1 条)
|
||||
# 移植自 HMCL CrashReportAnalyzer.Rule 的杂项组(analyse/02 §7.3 第 10 组)。
|
||||
# 将来「用户反馈沉淀的新规则」如果归不了类,也先落这里(标记 source 与需要复核)。
|
||||
version: 1
|
||||
category: other
|
||||
rules:
|
||||
- id: other.debug_crash
|
||||
severity: info
|
||||
pattern: 'Manually triggered debug crash'
|
||||
confidence: 0.95
|
||||
priority: 70
|
||||
cause:
|
||||
key: diagnose.cause.other.debug_crash
|
||||
summary: '这次崩溃是手动触发的(游戏里按 F3+C),不是真的故障'
|
||||
detail: '如果用户点了「导出诊断包」做测试,可以忽略这条'
|
||||
fix_action:
|
||||
- kind: seek_help
|
||||
export_diagnostics: false
|
||||
needs_more_info:
|
||||
- '确认是否手动触发的调试崩溃'
|
||||
sample: samples/other/debug_crash.log
|
||||
source: 'HMCL CrashReportAnalyzer.Rule.DEBUG_CRASH'
|
||||
@@ -0,0 +1,2 @@
|
||||
[12:00:40] [main/ERROR]: Failed to start Fabric loader
|
||||
java.lang.NoClassDefFoundError: org/spongepowered/asm/mixin/transformer/FabricMixinTransformerProxy
|
||||
@@ -0,0 +1,3 @@
|
||||
net.fabricmc.loader.impl.FormattedException: net.fabricmc.loader.impl.discovery.ModResolutionException: Mod resolution encountered an incompatible mod set!
|
||||
- Mod 'Sodium' (sodium) 0.5.8 requires version 0.6.0 or later of mod 'Iris' (iris)
|
||||
- Mod 'Iris' (iris) 0.5.0 is present but is incompatible
|
||||
@@ -0,0 +1 @@
|
||||
net.fabricmc.loader.impl.discovery.ModResolutionException: Could not resolve valid mod collection (at: iris requires sodium)
|
||||
@@ -0,0 +1 @@
|
||||
net.fabricmc.loader.impl.discovery.ModResolutionException: Found conflicting mods: sodium conflicts with sodium-extra
|
||||
@@ -0,0 +1,3 @@
|
||||
Mod File: /mods/sodium-fabric-0.5.0.jar
|
||||
Failure message: Mod resolution failed
|
||||
net.fabricmc.loader.impl.discovery.ModResolutionException: see above
|
||||
@@ -0,0 +1 @@
|
||||
net.fabricmc.loader.impl.discovery.ModResolutionException: Could not find required mod: sodium requires iris
|
||||
@@ -0,0 +1 @@
|
||||
net.fabricmc.loader.impl.discovery.ModResolutionException: Could not find required mod: sodium requires {minecraft @ 1.20.1}
|
||||
@@ -0,0 +1,3 @@
|
||||
Warnings were found!
|
||||
- Sodium 0.4.10 is incompatible with Iris 1.6.0
|
||||
[
|
||||
@@ -0,0 +1 @@
|
||||
[12:00:55] [main/ERROR]: Failed to create mod instance. ModID: alexsmobs, Class: com.github.alexthe666.alexsmobs.AlexsMobs
|
||||
@@ -0,0 +1,3 @@
|
||||
Found duplicate mods:
|
||||
jei-1.20.1-15.2.0.27.jar
|
||||
jei-1.20.1-15.2.0.28.jar
|
||||
@@ -0,0 +1 @@
|
||||
[12:00:50] [main/ERROR]: Found a duplicate mod jei at /mods/jei-1.20.1-15.2.0.28.jar
|
||||
@@ -0,0 +1,2 @@
|
||||
Missing or unsupported mandatory dependencies:
|
||||
Mod ID: citadel, Requested by: alexsmobs, Expected range: [1.20.1-2.4.0,), Actual version: MISSING
|
||||
@@ -0,0 +1 @@
|
||||
[12:00:54] [main/ERROR]: java.lang.IllegalArgumentException: Invalid module name: '' is not a Java identifier
|
||||
@@ -0,0 +1 @@
|
||||
[12:00:52] [main/ERROR]: org.spongepowered.asm.launch.platform.MixinPlatformManager threw ResolutionException: Module optifine reads another module named optifine
|
||||
@@ -0,0 +1 @@
|
||||
[12:00:51] [main/ERROR]: joptsimple.MultipleArgumentsForOptionException: Found multiple arguments for option net.minecraftforge, but you asked for only one
|
||||
@@ -0,0 +1 @@
|
||||
[12:00:53] [main/ERROR]: java.lang.RuntimeException: maximum id range exceeded
|
||||
@@ -0,0 +1,3 @@
|
||||
[12:00:20] [Render thread/ERROR]: ########## GL ERROR ##########
|
||||
[12:00:20] [Render thread/ERROR]: @ Pre render
|
||||
[12:00:20] [Render thread/ERROR]: 1282: Invalid operation
|
||||
@@ -0,0 +1,7 @@
|
||||
#
|
||||
# A fatal error has been detected by the Java Runtime Environment:
|
||||
#
|
||||
# EXCEPTION_ACCESS_VIOLATION (0xc0000005) at pc=0x00007ffb1a2b3c4d, pid=12344, tid=0x0000000000002a1c
|
||||
#
|
||||
# Problematic frame:
|
||||
# C [ig8d64.dll+0x9b3c4d]
|
||||
@@ -0,0 +1 @@
|
||||
[12:00:22] [main/ERROR]: java.lang.IllegalStateException: GLFW error before init: [0x10008]Cocoa: Failed to find service port for display
|
||||
@@ -0,0 +1,15 @@
|
||||
---- Minecraft Crash Report ----
|
||||
// I let you down. Sorry :(
|
||||
|
||||
Time: 2026-10-30 12:00:21
|
||||
Description: Initializing game
|
||||
|
||||
net.minecraft.client.Minecraft$GameLoadException: No supported graphics backend was found.
|
||||
at net.minecraft.client.Minecraft.<init>(Minecraft.java:612)
|
||||
at net.minecraft.client.main.Main.main(Main.java:184)
|
||||
|
||||
-- Graphics Backend --
|
||||
Backend: VULKAN
|
||||
Backend failure reason: vulkan_loader_missing
|
||||
Backend: OPENGL
|
||||
Backend failure reason: opengl_missing
|
||||
@@ -0,0 +1 @@
|
||||
[12:00:21] [main/ERROR]: The driver does not appear to support OpenGL
|
||||
@@ -0,0 +1 @@
|
||||
[12:00:23] [Render thread/WARN]: Maybe try a lower resolution resourcepack?
|
||||
@@ -0,0 +1 @@
|
||||
[12:00:24] [main/ERROR]: RivaTuner Statistics Server (RTSS) is not compatible with Sodium
|
||||
@@ -0,0 +1,5 @@
|
||||
[12:00:21] [Render thread/INFO]: Using graphics backend VULKAN, using drivers: Intel proprietary driver
|
||||
[12:00:21] [Render thread/ERROR]: Backend failure reason: vulkan_device_version_too_low
|
||||
[12:00:21] [Render thread/ERROR]: Backend failure message: Device NVIDIA GeForce GT 710 reports Vulkan 1.0.0, at least 1.2 required
|
||||
[12:00:21] [Render thread/ERROR]: Failed to create backend VULKAN
|
||||
[12:00:21] [Render thread/INFO]: Falling back to OpenGL
|
||||
@@ -0,0 +1,5 @@
|
||||
[12:00:21] [Render thread/INFO]: Backend library: LWJGL version 3.4.3+13
|
||||
[12:00:21] [Render thread/INFO]: Using graphics backend VULKAN, using drivers: AMD proprietary driver
|
||||
[12:00:21] [Render thread/ERROR]: Vulkan loader library is missing
|
||||
[12:00:21] [Render thread/ERROR]: Failed to create backend VULKAN: Backend failure reason: vulkan_loader_missing
|
||||
[12:00:21] [Render thread/INFO]: Falling back to OpenGL
|
||||
@@ -0,0 +1,5 @@
|
||||
[12:00:21] [Render thread/INFO]: Using graphics backend VULKAN, using drivers: Mesa (zink)
|
||||
[12:00:21] [Render thread/ERROR]: Backend failure reason: vulkan_missing_extension
|
||||
[12:00:21] [Render thread/ERROR]: Device [AMD Radeon RX 580] does not support required extensions from FeatureSet [VULKAN_12], missing: [VK_KHR_dynamic_rendering, VK_KHR_push_descriptor]
|
||||
[12:00:21] [Render thread/ERROR]: Failed to create backend VULKAN
|
||||
[12:00:21] [Render thread/INFO]: Falling back to OpenGL
|
||||
@@ -0,0 +1,5 @@
|
||||
[12:00:21] [Render thread/INFO]: Using graphics backend VULKAN, using drivers: llvmpipe (Mesa)
|
||||
[12:00:21] [Render thread/ERROR]: Backend failure reason: vulkan_missing_feature
|
||||
[12:00:21] [Render thread/ERROR]: Backend failure missing capabilities: VULKAN_CORE_1_2
|
||||
[12:00:21] [Render thread/ERROR]: Failed to create backend VULKAN
|
||||
[12:00:21] [Render thread/INFO]: Falling back to OpenGL
|
||||
@@ -0,0 +1,34 @@
|
||||
#
|
||||
# A fatal error has been detected by the Java Runtime Environment:
|
||||
#
|
||||
# EXCEPTION_ACCESS_VIOLATION (0xc0000005) at pc=0x00007ffb1a2b3c4d, pid=12344, tid=0x0000000000002a1c
|
||||
#
|
||||
# JRE version: Java(TM) SE Runtime Environment (8.0_51-b16) (build 1.8.0_51-b16)
|
||||
# Java VM: Java HotSpot(TM) 64-Bit Server VM (25.51-b03 mixed mode windows-amd64 compressed oops)
|
||||
# Problematic frame:
|
||||
# C [ig8d64.dll+0x9b3c4d]
|
||||
#
|
||||
# Failed to write core dump. Minidumps are not enabled by default on client versions of Windows
|
||||
#
|
||||
# If you would like to submit a bug report, please visit:
|
||||
# http://bugreport.java.com/bugreport/crash.jsp
|
||||
# The crash happened outside the Java Virtual Machine in native code.
|
||||
# See problematic frame for where to report the bug.
|
||||
#
|
||||
|
||||
--------------- T H R E A D ---------------
|
||||
|
||||
Current thread (0x000000001f3b8000): JavaThread "Render thread" [_thread_in_native, id=10780, stack(0x000000001a000000,0x000000001a100000)]
|
||||
|
||||
siginfo: ExceptionCode=0xc0000005, ExceptionInformation=0x0000000000000000 0x0000000000000000 0x0000000000000000
|
||||
|
||||
VM Arguments:
|
||||
jvm_args: -Xmx4096m -Xms1024m -Djava.library.path=C:\Users\player\.minecraft\natives
|
||||
java_command: net.minecraft.launchwrapper.Launch --gameDir . --assetsDir assets
|
||||
|
||||
--------------- H E A P ---------------
|
||||
|
||||
Heap:
|
||||
garbage-first heap total 4194304K, used 1048576K [0x0000000700000000, 0x0000000800000000)
|
||||
|
||||
Memory: 4k page, physical 16741480k(4716856k free), swap 18672384k(9433608k free)
|
||||
@@ -0,0 +1,19 @@
|
||||
#
|
||||
# There is insufficient memory for the Java Runtime Environment to continue.
|
||||
# Native memory allocation (mmap) failed to map 1073741824 bytes for committing reserved memory.
|
||||
#
|
||||
# Out of Memory Error (os_windows.cpp:3521), pid=9876, tid=0x0000000000001f44
|
||||
#
|
||||
# JRE version: Java(TM) SE Runtime Environment (17.0.9+9) (build 17.0.9+9-LTS-211)
|
||||
# Java VM: Java HotSpot(TM) 64-Bit Server VM (17.0.9+9-LTS-211, mixed mode, sharing, tiered, compressed oops, compressed class ptrs, g1 gc, windows-amd64)
|
||||
#
|
||||
|
||||
--------------- T H R E A D ---------------
|
||||
|
||||
Current thread (0x0000021d6f82b1a0): JavaThread "Render thread" [_thread_in_vm, id=14120, stack(0x000000a1b0000000,0x000000a1b0100000)]
|
||||
|
||||
VM Arguments:
|
||||
jvm_args: -Xmx8192m -Xms2048m
|
||||
java_command: net.fabricmc.loader.impl.launch.knot.KnotClient --gameDir .
|
||||
|
||||
Memory: 4k page, physical 8134564k(120944k free), swap 9723904k(550664k free)
|
||||
@@ -0,0 +1 @@
|
||||
[12:00:34] [main/ERROR]: java.nio.file.FileAlreadyExistsException: config/example-mod.toml
|
||||
@@ -0,0 +1,2 @@
|
||||
[12:00:30] [main/ERROR]: Failed to verify class file
|
||||
java.lang.SecurityException: SHA1 digest error for net/minecraft/client/main/Main.class
|
||||
@@ -0,0 +1,2 @@
|
||||
[12:00:33] [main/ERROR]: coremod failed
|
||||
java.lang.IllegalAccessError: tried to access class net.minecraft.client.Minecraft from class com.example.coremod.Hook
|
||||
@@ -0,0 +1 @@
|
||||
[12:00:36] [main/ERROR]: java.io.UncheckedIOException: java.io.IOException: Invalid paths argument, contained no existing paths: [/home/user/.minecraft/libraries/net/minecraftforge/forge/1.20.1-47.2.0/forge-1.20.1-47.2.0-client.jar]
|
||||
@@ -0,0 +1,2 @@
|
||||
[12:00:35] [main/ERROR]: Extracted mod jars found, loading will NOT continue
|
||||
[12:00:35] [main/ERROR]: The directories below appear to be extracted jar files. Fix this before you continue
|
||||
@@ -0,0 +1,2 @@
|
||||
[12:00:32] [main/ERROR]: A mod crashed on startup
|
||||
java.lang.NoClassDefFoundError: net/minecraftforge/fml/loading/FMLEnvironment
|
||||
@@ -0,0 +1,3 @@
|
||||
[12:00:31] [main/ERROR]: Failed to start
|
||||
java.lang.NoSuchMethodError: net.minecraft.world.World.getBlockState(Lnet/minecraft/core/BlockPos;)Lnet/minecraft/world/level/block/state/BlockState;
|
||||
at com.example.mod.Foo.bar(Foo.java:42)
|
||||
Loaded 100 of 517 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user