Files
Unlucky-Launcher/crates/core/src/auth/http.rs
T
AskaEth bc41dfcf7d
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
Unlucky Launcher v0.0.1-alpha
Rust 原生单文件 Minecraft 启动器:CLI 完整可用(install/launch/accounts/java/diagnose/agent),
MCP server 接入外部 Agent 工具,GUI 开发中。
2026-10-31 21:54:10 +08:00

209 lines
7.3 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 认证用的 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);
}
}