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,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"));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user