Unlucky Launcher v0.2.0
CI / runtime smoke (macos arm64, probe) (push) Skipped
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
CI / runtime smoke (ubuntu x64, xvfb) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
CI / runtime smoke (macos arm64, probe) (push) Skipped
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
CI / runtime smoke (ubuntu x64, xvfb) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
首个 iced + Material 3 Expressive 版本的源码快照。 完整历史与日常提交在 dev 分支;main 只放阶段性快照。
This commit is contained in:
commit
8a8e5ca0c0
1079 files changed
+208035
No files matched your search
@@ -0,0 +1,136 @@
|
||||
//! 微软轨的共用夹具:`MicrosoftAuth` 构造、五步链路由表、诊断断言助手。
|
||||
//!
|
||||
//! 从 `support/mod.rs` 拆出来(M38):mock 服务器本体与「业务路由/断言」各自长大,
|
||||
//! 分文件后两边都不顶 400 行。路由表默认用 `[0]/[3]/[5]/[6]` 下标对应
|
||||
//! `xsts` / `login_with_xbox` / `entitlements/mcstore` / `profile`,用例按需替换。
|
||||
|
||||
use serde_json::json;
|
||||
use unlucky_core::auth::microsoft::{
|
||||
MicrosoftAuth, MicrosoftEndpoints, MicrosoftSession, PollPolicy,
|
||||
};
|
||||
use unlucky_core::auth::{AuthError, ResponseDetail, Secret};
|
||||
|
||||
use super::MockReply;
|
||||
|
||||
/// 测试用档案 UUID(32 位无连字符)。
|
||||
pub const PROFILE_ID: &str = "0f8fad5bd9cb469fa16570867728950e";
|
||||
/// 测试用 client id。
|
||||
pub const CLIENT_ID: &str = "test-client-id";
|
||||
/// 网关/风控拦截页(HTML 403 的典型正文)。
|
||||
pub const HTML_BLOCK_PAGE: &str = "<!DOCTYPE html><html><head><title>Service unavailable</title></head><body>blocked by gateway</body></html>";
|
||||
|
||||
/// 小间隔轮询策略(设备码轮询在测试里要秒级跑完)。
|
||||
pub fn fast_poll() -> PollPolicy {
|
||||
PollPolicy {
|
||||
min_interval: std::time::Duration::from_millis(1),
|
||||
slow_down_step: std::time::Duration::from_millis(1),
|
||||
max_wait: std::time::Duration::from_secs(5),
|
||||
}
|
||||
}
|
||||
|
||||
/// 一台整表指向 mock 的 `MicrosoftAuth`。
|
||||
pub fn auth_for(base: &str) -> MicrosoftAuth {
|
||||
MicrosoftAuth::new(CLIENT_ID)
|
||||
.expect("构造")
|
||||
.with_endpoints(MicrosoftEndpoints::all_on(base))
|
||||
.with_poll_policy(fast_poll())
|
||||
}
|
||||
|
||||
/// 一路通到 `login_with_xbox` 的路由表(后续步骤留给用例替换)。
|
||||
pub fn routes_until_login() -> Vec<(&'static str, Vec<MockReply>)> {
|
||||
vec![
|
||||
(
|
||||
"/devicecode",
|
||||
vec![MockReply::json(json!({
|
||||
"user_code": "ABCD-1234",
|
||||
"device_code": "device-code-secret-value",
|
||||
"verification_uri": "https://www.microsoft.com/link",
|
||||
"expires_in": 900,
|
||||
"interval": 0
|
||||
}))],
|
||||
),
|
||||
(
|
||||
"/token",
|
||||
vec![MockReply::json(json!({
|
||||
"access_token": "live-access-token-value",
|
||||
"refresh_token": "live-refresh-token-value"
|
||||
}))],
|
||||
),
|
||||
(
|
||||
"/xbl",
|
||||
vec![MockReply::json(json!({
|
||||
"Token": "xbl-token-value",
|
||||
"DisplayClaims": { "xui": [{ "uhs": "uhs-1" }] }
|
||||
}))],
|
||||
),
|
||||
(
|
||||
"/xsts",
|
||||
vec![MockReply::json(json!({
|
||||
"Token": "xsts-token-value",
|
||||
"DisplayClaims": { "xui": [{ "uhs": "uhs-1" }] }
|
||||
}))],
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// 整链(含 entitlements 与 profile),便于替换中间任意一步。
|
||||
pub fn full_routes() -> Vec<(&'static str, Vec<MockReply>)> {
|
||||
let mut routes = routes_until_login();
|
||||
routes.push((
|
||||
"/login_with_xbox",
|
||||
vec![MockReply::json(json!({
|
||||
"username": "Steve",
|
||||
"access_token": "mc-access-token-value",
|
||||
"expires_in": 86_400
|
||||
}))],
|
||||
));
|
||||
routes.push((
|
||||
"/entitlements/mcstore",
|
||||
vec![MockReply::json(json!({ "items": [] }))],
|
||||
));
|
||||
routes.push((
|
||||
"/profile",
|
||||
vec![MockReply::json(
|
||||
json!({ "id": PROFILE_ID, "name": "Steve" }),
|
||||
)],
|
||||
));
|
||||
routes
|
||||
}
|
||||
|
||||
/// 一个已过期的旧会话(刷新/校验用例用)。
|
||||
pub fn expired_session() -> MicrosoftSession {
|
||||
MicrosoftSession::new(
|
||||
PROFILE_ID,
|
||||
"Steve",
|
||||
Secret::new("mc-access-token-1"),
|
||||
Secret::new("live-refresh-token-1"),
|
||||
3_600,
|
||||
0,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
/// 取 `AuthError::Response` 的诊断(不是这个变体就让用例炸掉,报出真实值)。
|
||||
pub fn response_of(error: &AuthError) -> &ResponseDetail {
|
||||
match error {
|
||||
AuthError::Response(detail) => detail,
|
||||
other => panic!("应是 Response,实际 {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// 断言一段文案/事件里没有任何测试 token。
|
||||
pub fn assert_no_credentials(text: &str) {
|
||||
for forbidden in [
|
||||
"xsts-token-value",
|
||||
"xbl-token-value",
|
||||
"live-access-token-value",
|
||||
"live-refresh-token-value",
|
||||
"device-code-secret-value",
|
||||
"mc-access-token-value",
|
||||
] {
|
||||
assert!(
|
||||
!text.contains(forbidden),
|
||||
"文案泄漏了凭据 {forbidden}:{text}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
//! `core::auth` 集成测试夹具:临时目录 + 进程内 HTTP 服务器(零额外依赖)。
|
||||
//!
|
||||
//! 为什么要 mock 服务器:微软/authlib 两条轨的正确性全在**报文级**(`XBL3.0 x=<uhs>;<xsts>`
|
||||
//! 的拼接、XSTS 401 带 `XErr`、`/minecraft/profile` 404 的二义复判、Yggdrasil 的
|
||||
//! `clientToken` 回传校验)——只有把整条链打到一个真实 socket 上,这些分支才被真的跑到。
|
||||
//!
|
||||
//! 服务器按 **path + 顺序队列**回应答:同一 path 的第 N 次请求用第 N 个应答
|
||||
//! (设备码轮询要靠这个:`authorization_pending` → `slow_down` → 成功)。
|
||||
//! 微软轨的 `MicrosoftAuth` / 路由表 / 断言助手拆在 [`microsoft`] 子模块里(M38)。
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
|
||||
pub mod microsoft;
|
||||
pub use microsoft::{
|
||||
assert_no_credentials, auth_for, expired_session, full_routes, response_of, routes_until_login,
|
||||
HTML_BLOCK_PAGE,
|
||||
};
|
||||
|
||||
/// 唯一临时目录(进程内计数 + 时间戳,避免并行测试互踩)。
|
||||
pub fn temp_dir(tag: &str) -> std::path::PathBuf {
|
||||
static COUNTER: AtomicUsize = AtomicUsize::new(0);
|
||||
let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
let stamp = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_or(0, |since| since.as_nanos());
|
||||
let dir = std::env::temp_dir().join(format!("ull-auth-test-{tag}-{stamp}-{unique}"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("创建临时目录失败");
|
||||
dir
|
||||
}
|
||||
|
||||
/// 一次 mock 应答。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MockReply {
|
||||
/// HTTP 状态码。
|
||||
pub status: u16,
|
||||
/// 响应体(JSON 文本或空)。
|
||||
pub body: String,
|
||||
/// `Content-Type`(默认 `application/json`;拦截页场景可改成 `text/html`)。
|
||||
pub content_type: &'static str,
|
||||
}
|
||||
|
||||
impl MockReply {
|
||||
/// 200 + JSON。
|
||||
pub fn json(value: serde_json::Value) -> Self {
|
||||
Self {
|
||||
status: 200,
|
||||
body: value.to_string(),
|
||||
content_type: "application/json",
|
||||
}
|
||||
}
|
||||
|
||||
/// 指定状态码 + JSON。
|
||||
pub fn json_status(status: u16, value: serde_json::Value) -> Self {
|
||||
Self {
|
||||
status,
|
||||
body: value.to_string(),
|
||||
content_type: "application/json",
|
||||
}
|
||||
}
|
||||
|
||||
/// 指定状态码 + 空体。
|
||||
pub fn status(status: u16) -> Self {
|
||||
Self {
|
||||
status,
|
||||
body: String::new(),
|
||||
content_type: "application/json",
|
||||
}
|
||||
}
|
||||
|
||||
/// 指定状态码 + HTML 拦截页(`Content-Type: text/html`)——M38 的网络分流用。
|
||||
pub fn html(status: u16, body: &str) -> Self {
|
||||
Self {
|
||||
status,
|
||||
body: body.to_string(),
|
||||
content_type: "text/html; charset=utf-8",
|
||||
}
|
||||
}
|
||||
|
||||
/// 指定状态码 + 纯文本(网关页的另一种常见形状)。
|
||||
pub fn text(status: u16, body: &str) -> Self {
|
||||
Self {
|
||||
status,
|
||||
body: body.to_string(),
|
||||
content_type: "text/plain",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 收到的一次请求(断言报文形状用)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Recorded {
|
||||
/// 方法(大写)。
|
||||
pub method: String,
|
||||
/// 路径(不含 query)。
|
||||
pub path: String,
|
||||
/// 请求体(表单或 JSON 原文)。
|
||||
pub body: String,
|
||||
}
|
||||
|
||||
/// 进程内 HTTP/1.1 服务器(按 path 排队回应答)。
|
||||
pub struct AuthMock {
|
||||
addr: SocketAddr,
|
||||
requests: Arc<Mutex<Vec<Recorded>>>,
|
||||
handle: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl AuthMock {
|
||||
/// 启动服务器。`routes` 是 `(path, 该 path 依次使用的应答队列)`。
|
||||
pub async fn start(routes: Vec<(&str, Vec<MockReply>)>) -> Self {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.expect("绑定端口");
|
||||
let addr = listener.local_addr().expect("取地址");
|
||||
let requests: Arc<Mutex<Vec<Recorded>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
let queues: Arc<Mutex<HashMap<String, VecDeque<MockReply>>>> = Arc::new(Mutex::new(
|
||||
routes
|
||||
.into_iter()
|
||||
.map(|(path, replies)| (path.to_string(), replies.into()))
|
||||
.collect(),
|
||||
));
|
||||
|
||||
let recorded = Arc::clone(&requests);
|
||||
let queues_for_task = Arc::clone(&queues);
|
||||
let handle = tokio::spawn(async move {
|
||||
loop {
|
||||
let Ok((stream, _)) = listener.accept().await else {
|
||||
break;
|
||||
};
|
||||
let recorded = Arc::clone(&recorded);
|
||||
let queues = Arc::clone(&queues_for_task);
|
||||
tokio::spawn(async move {
|
||||
let _ = serve(stream, recorded, queues).await;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
addr,
|
||||
requests,
|
||||
handle,
|
||||
}
|
||||
}
|
||||
|
||||
/// 基址(喂给 `MicrosoftEndpoints::all_on` / `AuthlibEndpoints` 的根)。
|
||||
pub fn base(&self) -> String {
|
||||
format!("http://{}", self.addr)
|
||||
}
|
||||
|
||||
/// 端口。
|
||||
pub fn port(&self) -> u16 {
|
||||
self.addr.port()
|
||||
}
|
||||
|
||||
/// 已收到的请求(按到达顺序)。
|
||||
pub fn requests(&self) -> Vec<Recorded> {
|
||||
self.requests
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner())
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// 找某 path 的请求体列表。
|
||||
pub fn bodies_on(&self, path: &str) -> Vec<String> {
|
||||
self.requests()
|
||||
.into_iter()
|
||||
.filter(|request| request.path == path)
|
||||
.map(|request| request.body)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 关停。
|
||||
pub fn stop(self) {
|
||||
self.handle.abort();
|
||||
}
|
||||
}
|
||||
|
||||
async fn serve(
|
||||
mut stream: TcpStream,
|
||||
recorded: Arc<Mutex<Vec<Recorded>>>,
|
||||
queues: Arc<Mutex<HashMap<String, VecDeque<MockReply>>>>,
|
||||
) -> std::io::Result<()> {
|
||||
let mut buffer = Vec::new();
|
||||
let mut chunk = [0_u8; 2048];
|
||||
// 读头
|
||||
let header_end = loop {
|
||||
let read = stream.read(&mut chunk).await?;
|
||||
if read == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
buffer.extend_from_slice(&chunk[..read]);
|
||||
if let Some(position) = find_headers_end(&buffer) {
|
||||
break position;
|
||||
}
|
||||
};
|
||||
let head = String::from_utf8_lossy(&buffer[..header_end]).to_string();
|
||||
let content_length = head
|
||||
.lines()
|
||||
.find_map(|line| {
|
||||
let (name, value) = line.split_once(':')?;
|
||||
if name.trim().eq_ignore_ascii_case("content-length") {
|
||||
value.trim().parse::<usize>().ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap_or(0);
|
||||
while buffer.len() < header_end + content_length {
|
||||
let read = stream.read(&mut chunk).await?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
buffer.extend_from_slice(&chunk[..read]);
|
||||
}
|
||||
let body = String::from_utf8_lossy(
|
||||
&buffer[header_end..(header_end + content_length).min(buffer.len())],
|
||||
)
|
||||
.to_string();
|
||||
|
||||
let mut first_line = head.lines().next().unwrap_or_default().split(' ');
|
||||
let method = first_line.next().unwrap_or_default().to_ascii_uppercase();
|
||||
let raw_path = first_line.next().unwrap_or("/");
|
||||
let path = raw_path.split('?').next().unwrap_or("/").to_string();
|
||||
|
||||
recorded
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner())
|
||||
.push(Recorded {
|
||||
method,
|
||||
path: path.clone(),
|
||||
body,
|
||||
});
|
||||
|
||||
let reply = queues
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner())
|
||||
.get_mut(&path)
|
||||
.and_then(VecDeque::pop_front)
|
||||
.unwrap_or_else(|| MockReply::status(500));
|
||||
|
||||
let mut response = format!(
|
||||
"HTTP/1.1 {} {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
reply.status,
|
||||
reason(reply.status),
|
||||
reply.content_type,
|
||||
reply.body.len()
|
||||
)
|
||||
.into_bytes();
|
||||
response.extend_from_slice(reply.body.as_bytes());
|
||||
stream.write_all(&response).await?;
|
||||
stream.flush().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn find_headers_end(buffer: &[u8]) -> Option<usize> {
|
||||
buffer
|
||||
.windows(4)
|
||||
.position(|window| window == b"\r\n\r\n")
|
||||
.map(|position| position + 4)
|
||||
}
|
||||
|
||||
fn reason(status: u16) -> &'static str {
|
||||
match status {
|
||||
200 => "OK",
|
||||
400 => "Bad Request",
|
||||
401 => "Unauthorized",
|
||||
403 => "Forbidden",
|
||||
404 => "Not Found",
|
||||
500 => "Internal Server Error",
|
||||
_ => "Status",
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user