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

Rust 原生单文件 Minecraft 启动器:CLI 完整可用(install/launch/accounts/java/diagnose/agent),
MCP server 接入外部 Agent 工具,GUI 开发中。
This commit is contained in:
AskaEth committed 2026-10-31 21:54:10 +08:00
commit bc41dfcf7d
517 files changed
+92844

No files matched your search

+279
View File
@@ -0,0 +1,279 @@
//! 非 2xx 应答的**正文诊断**(M38):把 M31「响应体一律丢掉」这个盲区补上。
//!
//! 起因是 `findings/20261030-tower-improve-login-with-xbox-403.md`:MSA 真机验收两轮都是
//! OAuth→XBL→XSTS 全通、`login_with_xbox` 稳定 403,而旧 `rejected()` 只报状态码,
//! 于是「区域/网络拦截页」和「服务端拒绝(许可/区域)」在文案上分不开。
//!
//! 这里只做三件事:
//! 1. **分类**:JSON(真实服务应答)/ HTML 或纯文本(多为网关、风控或代理拦截页)/ 空体;
//! 2. **提取**:JSON 里的 `error` / `errorMessage` / `reason` / `Message` 等服务端文案直出;
//! 3. **摘要**:正文压成 ≤[`DIGEST_CHARS`] 字符,**先过 [`Redactor`] 再进错误消息**。
//!
//! 凭据红线(M31 的 `Secret`/脱敏体系不破):
//! - 摘要只从**非 2xx** 响应构造——OAuth 成功响应体(含 token)永远不会走到这里;
//! - `Reply` 的原文快照只在 JSON 解析失败时保留(见 [`crate::auth::http`]),
//! 解析成功的应答不留原文,避免把 token 文本复制进第二个字段;
//! - 摘要与提取出的服务端文案都过同一套脱敏:[`Redactor::scrub`](已登记 token 整串替换、
//! ≥64 不透明串打码、JWT 形状的带点 token 打码——第三条 M40 已上提到 `launch::redact`,
//! 本模块不再自己补规则)。
use serde_json::Value;
use crate::auth::error::AuthError;
use crate::auth::http::Reply;
use crate::launch::redact::Redactor;
#[cfg(test)]
mod tests;
/// 错误文案 / 事件里正文摘要的上限(字符)。
pub const DIGEST_CHARS: usize = 200;
/// 服务端文案的取值字段(按优先级,命中即收;`Message` 是 XSTS 的写法)。
const SERVER_MESSAGE_KEYS: [&str; 6] = [
"errorMessage",
"error_message",
"error",
"errorType",
"reason",
"Message",
];
/// 一个非 2xx 应答的正文形状。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BodyKind {
/// 合法 JSON(真实服务应答)。
Json,
/// HTML 页面(网关/风控/代理拦截页的典型形状)。
Html,
/// 其它非 JSON 文本。
Text,
/// 空体。
Empty,
}
impl BodyKind {
/// 稳定名(错误文案与 JSONL 事件共用)。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Json => "json",
Self::Html => "html",
Self::Text => "text",
Self::Empty => "empty",
}
}
/// 是否是「拦截页」形状(HTML / 纯文本):这一类先怀疑网络,而不是账号。
#[must_use]
pub const fn is_interception(self) -> bool {
matches!(self, Self::Html | Self::Text)
}
}
/// 一次非 2xx 的诊断结果([`AuthError::Response`] 的载荷,也是 JSONL 事件的字段来源)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResponseDetail {
/// 链上的步骤(`xbl` / `xsts` / `login_with_xbox` / `entitlements` / `profile`)。
pub step: String,
/// 这一步在做什么(人话前缀,如「Minecraft 登录被拒」)。
pub what: String,
/// HTTP 状态码。
pub status: u16,
/// `Content-Type`(服务端没给时为 `None`)。
pub content_type: Option<String>,
/// 正文形状。
pub kind: BodyKind,
/// 服务端文案(已脱敏、已截断)。
pub server_message: Option<String>,
/// 正文摘要(已脱敏,≤[`DIGEST_CHARS`] 字符)。
pub digest: String,
}
impl ResponseDetail {
/// 细分机读码(**只增不改**):JSON 拒绝沿用 M31 的 `<step>_<status>`;
/// 非 JSON 用新增后缀 `<step>_non_json`;空体用 `<step>_empty_body`。
#[must_use]
pub fn detail_code(&self) -> String {
let suffix = match self.kind {
BodyKind::Json => self.status.to_string(),
BodyKind::Html | BodyKind::Text => "non_json".to_string(),
BodyKind::Empty => "empty_body".to_string(),
};
format!("{}_{suffix}", self.step)
}
/// 是否是「疑似网络拦截页」(HTML / 纯文本)。
#[must_use]
pub const fn is_interception(&self) -> bool {
self.kind.is_interception()
}
/// 「;服务端文案…;响应摘要…」片段(都没有时为空串,方便复用文案拼装)。
#[must_use]
pub fn note(&self) -> String {
let mut note = String::new();
if let Some(message) = &self.server_message {
note.push_str(&format!(";服务端文案「{message}」"));
}
if !self.digest.is_empty() {
note.push_str(&format!(";响应摘要:{}", self.digest));
}
note
}
/// 人读一句话(**不含凭据**),403 的两种分流在这里分开。
#[must_use]
pub fn message(&self) -> String {
let mut text = format!("{}(HTTP {}", self.what, self.status);
if let Some(content_type) = &self.content_type {
text.push_str(&format!(",Content-Type: {content_type}"));
}
text.push(')');
match self.kind {
BodyKind::Json => text.push_str(":服务端返回了 JSON 拒绝"),
BodyKind::Html | BodyKind::Text => {
text.push_str(":响应非 JSON(可能网络拦截/网关页)");
}
BodyKind::Empty => text.push_str(":响应体为空(可能网络拦截/网关页)"),
}
text.push(';');
text.push_str(advice(&self.step, self.kind));
text.push_str(&self.note());
text
}
}
/// 下一步该往哪查(403 分流的核心:拦截页查网络/代理,JSON 拒绝查账号许可)。
#[must_use]
pub fn advice(step: &str, kind: BodyKind) -> &'static str {
if kind.is_interception() {
return "请检查网络或代理(关掉/换掉代理后再试,必要时用镜像方案)";
}
match step {
"login_with_xbox" => "若网络正常,请检查该账号的 Minecraft 许可与所在区域",
"entitlements" => "若网络正常,请检查该账号是否拥有 Minecraft Java 版",
"profile" => "若网络正常,请去 minecraft.net 检查档案状态",
_ => "请按服务端文案处理;如反复失败,检查网络代理是否生效",
}
}
/// 分类一个非 2xx 应答。
pub(crate) fn classify(reply: &Reply) -> BodyKind {
if reply.body.is_some() {
return BodyKind::Json;
}
let Some(raw) = reply
.raw
.as_deref()
.map(str::trim)
.filter(|raw| !raw.is_empty())
else {
return BodyKind::Empty;
};
let content_type = reply
.content_type
.as_deref()
.unwrap_or_default()
.to_ascii_lowercase();
if content_type.contains("html") || looks_like_html(raw) {
BodyKind::Html
} else {
BodyKind::Text
}
}
/// 正文是不是 HTML 文档(没给 `Content-Type` 时的兜底判断)。
fn looks_like_html(raw: &str) -> bool {
raw.trim_start().starts_with('<')
}
/// 组装一条诊断(分类 + 服务端文案 + 摘要)。
pub(crate) fn rejection(
step: &str,
reply: &Reply,
what: &str,
redactor: &Redactor,
) -> ResponseDetail {
ResponseDetail {
step: step.to_string(),
what: what.to_string(),
status: reply.status,
content_type: reply.content_type.clone(),
kind: classify(reply),
server_message: server_message(reply, redactor),
digest: digest(reply, redactor),
}
}
/// 从 JSON 体里挑出服务端文案(去重后以 ` / ` 连接)。
fn server_message(reply: &Reply, redactor: &Redactor) -> Option<String> {
let body = reply.body.as_ref()?;
let mut parts: Vec<String> = Vec::new();
for key in SERVER_MESSAGE_KEYS {
let Some(text) = body.get(key).and_then(Value::as_str) else {
continue;
};
let text = one_line(text);
if text.is_empty() || parts.iter().any(|part| part == &text) {
continue;
}
parts.push(text);
}
if parts.is_empty() {
return None;
}
Some(truncate(&scrub(&parts.join(" / "), redactor), DIGEST_CHARS))
}
/// 正文摘要:JSON 用紧凑文本,非 JSON 用原文快照;先脱敏再截断。
fn digest(reply: &Reply, redactor: &Redactor) -> String {
let raw = match (&reply.body, &reply.raw) {
(Some(body), _) => body.to_string(),
(None, Some(raw)) => raw.to_string(),
(None, None) => return String::new(),
};
truncate(&scrub(&one_line(&raw), redactor), DIGEST_CHARS)
}
/// 走完整套脱敏:[`Redactor::scrub`] 的三条规则(已登记 token + ≥64 不透明串 + 带点 token)。
///
/// M40 之前这里自己补了一条「带点 token」规则:`Redactor` 只按「连续不透明串 ≥64」判形状,
/// 而 JWT(`header.payload.signature`)里有点号,把连续段切短,恰好躲过那条规则——服务端
/// 把 token 回显在错误体里时就会原样进摘要。现在规则已上提到 `launch::redact`(全局底座),
/// 这里只调共用实现,两处规则不会再漂。
fn scrub(text: &str, redactor: &Redactor) -> String {
redactor.scrub(text).into_owned()
}
/// 压成一行(HTML 的分行会让错误消息难以阅读)。
fn one_line(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut space = false;
for ch in text.chars() {
if ch.is_whitespace() {
space = !out.is_empty();
continue;
}
if std::mem::take(&mut space) {
out.push(' ');
}
out.push(ch);
}
out
}
/// 按字符截断(超长补 `…`)。
fn truncate(text: &str, limit: usize) -> String {
if text.chars().count() <= limit {
return text.to_string();
}
let mut out: String = text.chars().take(limit.saturating_sub(1)).collect();
out.push('…');
out
}
/// 把诊断包成 [`AuthError::Response`](链上各步骤的统一出口)。
pub(crate) fn response(step: &str, reply: &Reply, what: &str, redactor: &Redactor) -> AuthError {
AuthError::Response(Box::new(rejection(step, reply, what, redactor)))
}
+168
View File
@@ -0,0 +1,168 @@
//! [`super`] 的单测:分类、服务端文案提取、摘要脱敏与 403 分流文案。
use super::{
advice, classify, digest, one_line, response, server_message, truncate, BodyKind, DIGEST_CHARS,
};
use crate::auth::error::AuthError;
use crate::auth::http::Reply;
use crate::auth::secret::{redactor_for, Secret};
use crate::launch::redact::Redactor;
use serde_json::json;
const JWT: &str = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.c2lnbmF0dXJlLXZhbHVlLXRoYXQtaXMtbG9uZy1lbm91Z2g";
fn json_reply(status: u16, body: serde_json::Value) -> Reply {
Reply {
status,
body: Some(body),
content_type: Some("application/json".to_string()),
raw: None,
}
}
fn text_reply(status: u16, content_type: Option<&str>, raw: &str) -> Reply {
Reply {
status,
body: None,
content_type: content_type.map(str::to_string),
raw: Some(raw.to_string()),
}
}
#[test]
fn classification_splits_json_html_text_and_empty() {
assert_eq!(
classify(&json_reply(403, json!({ "error": "x" }))),
BodyKind::Json
);
assert_eq!(
classify(&text_reply(403, Some("text/html"), "<!DOCTYPE html>")),
BodyKind::Html
);
// 没给 Content-Type 时靠正文形状兜底。
assert_eq!(
classify(&text_reply(403, None, "<html><body>blocked")),
BodyKind::Html
);
assert_eq!(
classify(&text_reply(502, Some("text/plain"), "Bad Gateway")),
BodyKind::Text
);
assert_eq!(classify(&text_reply(403, None, " ")), BodyKind::Empty);
}
#[test]
fn server_message_prefers_error_fields_and_dedups() {
let redactor = Redactor::new();
let reply = json_reply(
403,
json!({ "error": "UNAUTHORIZED", "errorMessage": "no license", "path": "/x" }),
);
let message = server_message(&reply, &redactor).expect("应有服务端文案");
assert!(message.contains("no license"), "{message}");
assert!(message.contains("UNAUTHORIZED"), "{message}");
let same = json_reply(403, json!({ "error": "DENIED", "errorMessage": "DENIED" }));
assert_eq!(server_message(&same, &redactor).as_deref(), Some("DENIED"));
assert!(server_message(&json_reply(403, json!({ "path": "/x" })), &redactor).is_none());
assert!(server_message(&text_reply(500, None, "boom"), &redactor).is_none());
}
#[test]
fn digest_is_redacted_and_capped() {
let token = Secret::new("mc-access-token-value");
let redactor = redactor_for([&token]);
let reply = json_reply(
403,
json!({ "error": "DENIED", "echo": format!("Bearer {}", token.expose()) }),
);
let masked_digest = digest(&reply, &redactor);
assert!(
!masked_digest.contains("mc-access-token-value"),
"{masked_digest}"
);
let long = "blocked ".repeat(60);
let capped = digest(&text_reply(500, Some("text/plain"), &long), &redactor);
assert!(capped.chars().count() <= DIGEST_CHARS);
assert!(capped.ends_with('…'));
}
#[test]
fn opaque_runs_are_masked_even_without_known_secret() {
let masked = digest(&text_reply(401, Some("text/plain"), JWT), &Redactor::new());
assert!(!masked.contains("eyJhbGci"), "{masked}");
assert!(masked.contains("redacted"), "{masked}");
}
#[test]
fn dotted_tokens_are_masked_by_the_shared_redactor() {
// 规则本体在 `launch::redact`(M40 上提),这里只验「auth 摘要走的是同一份实现」。
let redactor = Redactor::new();
assert!(!redactor.scrub(JWT).contains("eyJhbGci"), "JWT 应被打码");
// 普通文案里的点不会触发(分段太短)。
assert_eq!(
redactor.scrub("Service.Unavailable.today"),
"Service.Unavailable.today"
);
let masked = redactor.scrub(&format!("token={JWT}&end")).into_owned();
assert!(!masked.contains("eyJhbGci"), "{masked}");
assert!(masked.ends_with("&end"), "{masked}");
}
#[test]
fn one_line_collapses_whitespace() {
assert_eq!(one_line(" a\n\n b\tc "), "a b c");
assert_eq!(truncate("abcdef", 4), "abc…");
assert_eq!(truncate("abc", 4), "abc");
}
#[test]
fn detail_codes_and_advice_split_network_from_license() {
let redactor = Redactor::new();
let blocked = response(
"login_with_xbox",
&text_reply(403, Some("text/html"), "<html>blocked</html>"),
"Minecraft 登录被拒",
&redactor,
);
let AuthError::Response(detail) = &blocked else {
panic!("应是 Response,实际 {blocked:?}");
};
assert_eq!(detail.detail_code(), "login_with_xbox_non_json");
assert!(
detail.message().contains("网络拦截"),
"{}",
detail.message()
);
assert!(detail.message().contains("代理"), "{}", detail.message());
let denied = response(
"login_with_xbox",
&json_reply(
403,
json!({ "error": "UNAUTHORIZED", "errorMessage": "nope" }),
),
"Minecraft 登录被拒",
&redactor,
);
let AuthError::Response(detail) = &denied else {
panic!("应是 Response,实际 {denied:?}");
};
assert_eq!(detail.detail_code(), "login_with_xbox_403");
assert!(detail.message().contains("许可"), "{}", detail.message());
assert!(!detail.message().contains("代理"), "{}", detail.message());
let empty = response(
"profile",
&text_reply(500, None, ""),
"拉取档案被拒",
&redactor,
);
let AuthError::Response(detail) = &empty else {
panic!("应是 Response,实际 {empty:?}");
};
assert_eq!(detail.detail_code(), "profile_empty_body");
assert!(!advice("xbl", BodyKind::Html).is_empty());
assert!(!advice("xbl", BodyKind::Json).is_empty());
}