Files
Unlucky-Launcher/crates/core/src/auth/journal.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

267 lines
10 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.
//! 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"));
}
}