fix(msa): 浏览器登录按钮在生产路径真的置位 browser_first(复审 r1 收口)

复审 r1 的 P1-1:「用浏览器登录」这颗按钮在生产二进制里是哑的——`start_browser_login` 用的是
共享的 `MicrosoftAuth::from_env()`(`browser_first=false`),于是 `browser_then_device_login`
里整段预检 / 回退 / 说明编排不执行,按钮静默退化成设备码,任务③(authcode 可选加速路)
实际未交付。

- P1-1:入口改成 `msa_auth(env, MsaEntry::{DeviceCode,Browser})`,浏览器入口走
  `.with_browser_first(true)`。守卫钉两段:①调用点(按 `LoginJob` 分桶记下生产入口实际请求的
  模式,避开同进程并发用例串台)②模式真的落到 `MicrosoftAuth::browser_first`。变异实测:
  把浏览器入口改回 `DeviceCode` ⇒ 该用例红。核心侧另有报文级证据:浏览器优先路必然对
  `/authorize` 发一次预检(`unregistered_redirect_falls_back_to_the_device_code` 断言 GET 计数 == 1)。
- P2-1:`listeners_opened` 改成**绑定现场**计数——`CallbackServer::bind_counting` 在
  `TcpListener::bind` 前 +1,`login_counting` 把 per-login 的 `AtomicUsize` 传下去;
  删掉「进了授权码分支就记 1」的代理指标。复做变异(预检之前偷绑一个监听口)⇒ 3 条
  msalogin 用例红,包括两条 `listeners_opened == 0`。两处 stale 文档(引用不存在的
  `callback::bind_attempts()`)改成引用 `listeners_opened` / `bind_counting`。
- P2-2:预检加整体 deadline(10s,`PREFLIGHT_TIMEOUT`),超时归 `PreflightUnavailable` 并
  回退设备码;新增「接了连接却不出响应」的假服务器单测(200ms deadline 内必须收场)。
  (下载引擎的 HTTP 客户端只设了 connect_timeout,这条兜住「header 之后停住」。)
- P2-3:client_id 守卫加严到三种拼法(`concat!` / 跨行两段 / `format!` 逐段拼),做法是
  「先收字符串字面量再拼」+「连续纯 hex 字面量累计 ≥32 且 ≥2 段」,并跳过注释(否则本模块
  文档里的示例会自指假红);每条一个自检向量,且不误杀 `session.rs` 的 profile UUID。
- P2-4:页面级断言补中文(`Lang::ZhCn` 走 `Page::build`),并把回退说明的文案出口收敛成
  视图自己的 `login::browser_note_text`(视图与取样共用,不再测试另写一份映射)。
- P2-5:主按钮文案改成「使用 Microsoft 账户登录」/「Sign in with your Microsoft account」,
  下面补一行「浏览器打不开就用这个:它会给一串用户码,在任何设备上输入即可。」。

门禁:`cargo fmt --check`、`cargo clippy --workspace --all-targets -- -D warnings`、
`cargo test --workspace --all-targets`(51 个 test 目标全绿)通过;`client_id.rs` 的判据拆到
`client_id/tests.rs` 守 400 行红线。截图三张(入口 / 设备码 / 回退)已重出。
This commit is contained in:
AskaEth committed 2026-11-08 20:23:44 +08:00
1 parent f82bd395f8
commit a40b3a5940
15 files changed
+745 -294

No files matched your search

@@ -216,6 +216,11 @@ pub(super) fn force_account_state(app: &mut App) {
"closed" => {
let _ = app.on_account(crate::pages::account::Message::Close);
}
// M178:登录入口面板(两个按钮 + 一行「没有浏览器时用这个」)。复审 r1 P2-5 的文案改动
// 要有图可看:`device_login` 是 Idle ⇒ 面板落在空闲态(主按钮=设备码,次按钮=浏览器)。
"msa-entry" => {
app.account_form.panel = crate::pages::account::Panel::Microsoft;
}
// 有账号时「Add an account」展开态(P2-4 的收敛后形态)
"add" => {
app.account_form.section_open = true;
+27 -18
View File
@@ -2,9 +2,8 @@
//!
//! ## 为什么不是「一个转圈」
//!
//! 设备码流的等待期可以长到 15 分钟。只画转圈的话,用户既不知道要做什么、也不知道还剩多久,
//! 更不知道已经过期了——M114 把这三件事都做成可见状态:用户码(可复制)、链接(可复制/打开)、
//! 倒计时(`15:00 left`)与过期后的「已过期,重试」。授权码流同理,另外必须能**取消**。
//! 设备码的等待期可以长到 15 分钟。只画转圈的话,用户既不知道要做什么、也不知道还剩多久,
//! 更不知道已经过期了——M114 把这三件事都做成可见状态:用户码、链接、倒计时与「已过期,重试」。
//!
//! ## 文案一律**借用模型里的串**(M178)
//!
@@ -60,12 +59,15 @@ pub fn panel<'a>(
/// 空闲:两条流的入口。**主按钮是设备码**(M178):它不依赖应用注册里的任何一项,是官方
/// 产物上唯一开箱可用的路;浏览器流是右手的次要按钮(要注册 `http://localhost/auth`,
/// 没登记时在预检处直接回退设备码)。
///
/// 文案(复审 r1 P2-5):主按钮写「使用 Microsoft 账户登录」而不是「改用设备码」;
/// 「没有浏览器时用这个」放在按钮下面一行说明里。
fn idle<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, PageMessage> {
let body = column![row![
buttons::filled(
ui,
Button::new(
catalog.text(Key::AccountDeviceUseCode),
catalog.text(Key::AccountDeviceSignIn),
Account::StartDevice.into_page(),
)
),
@@ -86,7 +88,14 @@ fn idle<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, PageMessage> {
]
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center),]
.spacing(ui.space(Space::X3));
.spacing(ui.space(Space::X3))
.push(frame::text_role(
ui,
TypeRole::BodySmall,
false,
catalog.text(Key::AccountDeviceFallbackHint),
ui.color(Role::OnSurfaceVariant),
));
frame::card(ui, body)
}
@@ -115,7 +124,7 @@ fn requesting<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, PageMessage> {
frame::card(ui, body)
}
/// 设备码等待态:**码 + 链接 + 倒计时**(`note` = 浏览器登录没走成时的回退说明)。
/// 设备码等待态:码 + 链接 + 倒计时(`note` = 回退说明)。
fn device_code<'a>(
ui: &'a Ui,
code: &'a str,
@@ -257,9 +266,7 @@ fn browser<'a>(
frame::card(ui, body)
}
/// 倒计时 / 过期提示(**这一行是「不得只转圈」的落点**)。
///
/// 文案由模型拼好(`panels::device_panel` 用 catalog 渲染 `{time}`),这里只决定颜色与强调。
/// 倒计时 / 过期提示(**这一行是「不得只转圈」的落点**;文案由模型拼好,这里只定颜色与强调)。
fn countdown_line<'a>(ui: &'a Ui, text: &'a str, expired: bool) -> Element<'a, PageMessage> {
if expired {
return frame::text_role(ui, TypeRole::LabelMedium, true, text, ui.color(Role::Error));
@@ -273,6 +280,11 @@ fn countdown_line<'a>(ui: &'a Ui, text: &'a str, expired: bool) -> Element<'a, P
)
}
/// 回退说明的**唯一文案出口**(视图与页面级断言共用:断言取样走这里,才是「视图会画的那句」)。
pub(super) fn browser_note_text(note: BrowserLoginNote, catalog: Catalog) -> &'static str {
catalog.text(super::model::note_key(note))
}
/// 回退说明那一行(`None` = 没有这回事)。
fn note_text<'a>(
ui: &'a Ui,
@@ -285,20 +297,17 @@ fn note_text<'a>(
ui,
TypeRole::BodySmall,
false,
catalog.text(super::model::note_key(note)),
browser_note_text(note, catalog),
ui.color(Role::OnSurfaceVariant),
))
}
/// 失败态:**M3 内联 alert**(左侧 `ErrorContainer` 图标块 + 标题/正文/Code + 重试/关闭)。
///
/// 四点修订叠在这里:
/// - **语言泄漏(r1 p1)**:只画原因码对应的 i18n + 一行 `Code: …`,后端中文原文不上屏;
/// - **不许通栏实心红(tower r2)**:面板底走 `SurfaceContainerHigh` + 描边,
/// `ErrorContainer`/`OnErrorContainer` 只作左侧图标块的强调(角色不变,面积收小);
/// - **可操作的失败要有可读说明(M132)**:缺 client id 换专属标题 + 「要设哪个环境变量」的
/// 正文,且**不给「重试」**——进程内的环境变量改不了,重试必然还是同一个错;
/// - **M178**:`note`(浏览器登录没走成)另起一行画在正文下面,两者都能看见。
/// - 语言泄漏(r1 p1):只画原因码对应的 i18n + 一行 `Code: …`,后端中文原文不上屏;
/// - 不许通栏实心红(tower r2):面板底 `SurfaceContainerHigh` + 描边,`ErrorContainer` 只做图标块强调;
/// - 可操作的失败要有可读说明(M132):缺 client id 换专属标题 + 「要设哪个环境变量」,且不给「重试」;
/// - M178:`note`(浏览器登录没走成)另起一行画在正文下面,两者都能看见。
fn failed<'a>(
ui: &'a Ui,
failure: LoginFailure,
@@ -308,7 +317,7 @@ fn failed<'a>(
let mut body = catalog.text(failure_body_key(failure)).to_string();
if let Some(note) = note {
body.push_str("\n\n");
body.push_str(catalog.text(super::model::note_key(note)));
body.push_str(browser_note_text(note, catalog));
}
let code = catalog.render(Key::AccountFailureCode, &[("code", failure.code())]);
let mut actions: Vec<Element<'a, PageMessage>> = Vec::new();
+40 -1
View File
@@ -4,15 +4,23 @@
//! i18n 那句「为什么 + 怎么修」,而不是 AAD 的原始报文。core 那一半(真的回退、不白开监听口)
//! 在 `crates/core/tests/auth/microsoft_msalogin.rs`。
use unlucky_i18n::Lang;
use unlucky_presenter::dto::{
AppEvent, BrowserLoginNote, CoreEvent, DeviceLoginView, LoginFailure,
};
use unlucky_presenter::state::AppState;
use super::model::{DevicePanel, Form};
use super::avatar::Cache;
use super::login::browser_note_text;
use super::model::{DevicePanel, Form, Page};
use super::tests::build;
use super::tests_text::{has_cjk, on_screen};
/// 中文界面的同一页(`build` 钉死英文,这里要中文那一套)。
fn build_zh(state: &AppState, form: &Form) -> Page {
Page::build(state, form, &mut Cache::default(), Lang::ZhCn, 1180.0)
}
/// **M178 守卫(页面这一半)**:浏览器登录没走成、已回退设备码时,页面上要有一句**可读的**
/// 中文/英文说明(含「怎么修」),且**不是** AAD 的原始报文。
///
@@ -60,6 +68,37 @@ application."
);
assert!(!text.contains("AADSTS"), "原始报文不许上屏:{text}");
assert!(!text.contains("is not valid"), "原始报文不许上屏:{text}");
// 3. 中文界面同样走一遍(复审 r1 P2-4):文案来自**视图自己的出口**
// `login::browser_note_text`,中文要有、可照抄的修法要有、AAD 原始码不许有。
let zh = build_zh(&state, &Form::new());
let zh_text = on_screen(&zh);
assert!(has_cjk(&zh_text), "中文界面要真有中文:{zh_text}");
let note_text = browser_note_text(
BrowserLoginNote::RedirectNotRegistered,
Lang::ZhCn.catalog(),
);
assert!(
note_text.contains("http://localhost/auth"),
"中文文案也要给出可照抄的重定向 URI:{note_text}"
);
assert!(note_text.contains("重定向 URI"), "{note_text}");
assert!(note_text.contains("设备码"), "{note_text}");
for forbidden in ["invalid_request", "AADSTS", "is not valid", "{"] {
assert!(
!zh_text.contains(forbidden),
"中文界面混进了原始报文:{zh_text}"
);
assert!(
!note_text.contains(forbidden),
"词条混进了原始报文:{note_text}"
);
}
// 视图那一行的来源就是这一条文案:取样函数与视图共用同一个出口(不是测试另写映射)。
assert_eq!(
browser_note_text(BrowserLoginNote::PreflightUnavailable, Lang::En.catalog()),
"Could not confirm that browser sign-in is usable, so it switched to a device code."
);
}
/// 回退之后设备码又失败:失败面板上**两者都要在**(失败原因 + 为什么没走浏览器)。
@@ -16,7 +16,7 @@
use unlucky_i18n::{Key, Lang};
use unlucky_presenter::dto::AccountNoticeCode;
use super::login::{failure_body_key, failure_title_key};
use super::login::{browser_note_text, failure_body_key, failure_title_key};
use super::model::{AuthlibPanel, DevicePanel, Hint, Page};
/// 页面**会画什么**:模型里的数据 + 视图层对码的 i18n 查表结果。
@@ -68,9 +68,10 @@ pub(super) fn on_screen(page: &Page) -> String {
out.push_str(link);
// 倒计时文案由模型拼好(M178:owned 文字路径画不出中文)。
out.push_str(countdown);
// M178:浏览器登录没走成时那一句「为什么 + 怎么修」也在这条取样线上。
// M178:回退说明走**视图自己的**文案出口(`login::browser_note_text`),
// 不是测试另写一份映射——否则视图哪天不画这一行,取样仍会绿。
if let Some(note) = note {
out.push_str(catalog.text(super::model::note_key(*note)));
out.push_str(browser_note_text(*note, catalog));
}
}
DevicePanel::Browser {
@@ -84,7 +85,7 @@ pub(super) fn on_screen(page: &Page) -> String {
out.push_str(catalog.text(failure_body_key(*failure)));
out.push_str(&catalog.render(Key::AccountFailureCode, &[("code", failure.code())]));
if let Some(note) = note {
out.push_str(catalog.text(super::model::note_key(*note)));
out.push_str(browser_note_text(*note, catalog));
}
}
DevicePanel::Succeeded { name } => {
+23 -4
View File
@@ -173,11 +173,28 @@ pub(crate) async fn login<F>(
policy: AuthCodePolicy,
present: F,
) -> AuthResult<MicrosoftSession>
where
F: FnOnce(&AuthCodePrompt),
{
login_counting(client, endpoints, client_id, policy, None, present).await
}
/// 同 [`login`],但把**绑定现场**的监听口次数记进 `counter`(M178 的取证计数)。
pub(crate) async fn login_counting<F>(
client: &Client,
endpoints: &MicrosoftEndpoints,
client_id: &str,
policy: AuthCodePolicy,
counter: Option<&std::sync::atomic::AtomicUsize>,
present: F,
) -> AuthResult<MicrosoftSession>
where
F: FnOnce(&AuthCodePrompt),
{
let state = policy.state.clone().unwrap_or_else(nonce);
let server = CallbackServer::bind(policy.port, policy.bind_attempts, state.clone()).await?;
let server =
CallbackServer::bind_counting(policy.port, policy.bind_attempts, state.clone(), counter)
.await?;
let redirect_uri = RedirectUri::new(server.port());
present(&AuthCodePrompt {
authorization_url: authorization_url(endpoints, client_id, &redirect_uri, &state),
@@ -206,14 +223,15 @@ pub(crate) enum AuthCodeAttempt {
/// 带预检的授权码登录:注册里有这个回环 URI 才真去绑端口、弹浏览器。
///
/// 顺序是刻意的——**预检在绑定之前**:没登记的机器上连监听口都不开(守卫断言
/// [`super::callback::bind_attempts`] 不涨),也就不会出现「浏览器里一张 AAD 错误页 +
/// 顺序是刻意的——**预检在绑定之前**:没登记的机器上连监听口都不开(`counter` 是绑定现场的
/// 真计数,见 [`CallbackServer::bind_counting`]),也就不会出现「浏览器里一张 AAD 错误页 +
/// 启动器干等 5 分钟」的那套现象(M178 用户实测的那个 bug)。
pub(crate) async fn login_checked<F>(
client: &Client,
endpoints: &MicrosoftEndpoints,
client_id: &str,
policy: AuthCodePolicy,
counter: &std::sync::atomic::AtomicUsize,
present: F,
) -> AuthResult<AuthCodeAttempt>
where
@@ -235,7 +253,8 @@ where
)))
}
}
let session = login(client, endpoints, client_id, policy, present).await?;
let session =
login_counting(client, endpoints, client_id, policy, Some(counter), present).await?;
Ok(AuthCodeAttempt::LoggedIn(session))
}
@@ -66,11 +66,29 @@ impl CallbackServer {
port: Option<u16>,
attempts: u8,
state: impl Into<String>,
) -> AuthResult<Self> {
Self::bind_counting(port, attempts, state, None).await
}
/// 同 [`Self::bind`],但把**每一次真正调用 `TcpListener::bind`** 记进 `counter`(M178)。
///
/// 为什么不把计数写在外层:`LoginOutcome::listeners_opened` 是「这次登录有没有偷开监听口」
/// 的唯一证据,它必须是**绑定现场**的数,不能是「进没进授权码分支」的代理指标
/// (复审 r1 P2-1 的变异 D 实测:代理指标对「真的多绑了一次」不敏感)。
/// 计数发生在 `TcpListener::bind` 调用之前:要数的是「去开了没」,不是「开成功了没」。
pub(crate) async fn bind_counting(
port: Option<u16>,
attempts: u8,
state: impl Into<String>,
counter: Option<&std::sync::atomic::AtomicUsize>,
) -> AuthResult<Self> {
let wanted = port.unwrap_or(0);
let attempts = attempts.max(1);
let mut last = String::new();
for attempt in 0..attempts {
if let Some(counter) = counter {
counter.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
}
match TcpListener::bind(("127.0.0.1", wanted)).await {
Ok(listener) => {
let port = listener
+2 -235
View File
@@ -116,238 +116,5 @@ pub fn client_id_status() -> ClientIdStatus {
}
#[cfg(test)]
mod tests {
use std::sync::Mutex;
use super::{
client_id_status, resolve_client_id, ClientIdSource, CLIENT_ID_BUILD, CLIENT_ID_ENV,
MISSING_CLIENT_ID_MESSAGE,
};
use crate::auth::error::AuthError;
use crate::auth::microsoft::MicrosoftAuth;
/// 环境变量是**进程级**的:动它的用例必须串行,否则并行跑会互相摘/设,结论随机。
static ENV_LOCK: Mutex<()> = Mutex::new(());
/// 用例结束后把环境变量还原(panic 也还原:`Drop` 跑得到)。
struct EnvGuard {
saved: Option<String>,
}
impl EnvGuard {
fn clear() -> Self {
let saved = std::env::var(CLIENT_ID_ENV).ok();
std::env::remove_var(CLIENT_ID_ENV);
Self { saved }
}
fn set(value: &str) -> Self {
let saved = std::env::var(CLIENT_ID_ENV).ok();
std::env::set_var(CLIENT_ID_ENV, value);
Self { saved }
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
match self.saved.take() {
Some(value) => std::env::set_var(CLIENT_ID_ENV, value),
None => std::env::remove_var(CLIENT_ID_ENV),
}
}
}
#[test]
fn missing_client_id_message_says_unconfigured_not_unimplemented() {
// 抓得住:文案退回「本期未支持」这类说法(用户实跑到的那个 bug),
// 或者只说「需要 id」却不给怎么配(用户下一步就卡在这里)。
// 抓不住:文案好不好读——那要靠 review。
assert!(MISSING_CLIENT_ID_MESSAGE.contains("客户端 id"));
assert!(MISSING_CLIENT_ID_MESSAGE.contains("未配置"));
assert!(!MISSING_CLIENT_ID_MESSAGE.contains("未支持"));
assert!(!MISSING_CLIENT_ID_MESSAGE.contains("未实现"));
assert!(MISSING_CLIENT_ID_MESSAGE.contains(CLIENT_ID_ENV));
assert!(MISSING_CLIENT_ID_MESSAGE.contains("cargo build"));
// 硬约束:这句话进日志、进横幅、进错误,**示例一律是 `<id>` 占位**,不出现真 id。
assert!(MISSING_CLIENT_ID_MESSAGE.contains("<id>"));
}
#[test]
fn from_env_without_client_id_is_a_config_error_not_a_feature_gap() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|error| error.into_inner());
let _guard = EnvGuard::clear();
let result = MicrosoftAuth::from_env();
match result {
Err(AuthError::Unsupported(message)) => {
// 机读码保持 `unsupported`(CLI `--json` 与既有调用方靠它分流),
// 但**人读文案**必须说「未配置」。
assert_eq!(message, MISSING_CLIENT_ID_MESSAGE);
assert!(!message.contains("未支持"));
}
// 构建期注入过 id 时唯一合法(`ULL_MSA_CLIENT_ID` 也设进 `cargo test` 的环境就会走到这里;
// 官方发布构建即烘了 id,但门禁里的 `cargo test` 不带该变量,故常态走上面那条。
// 注意:把该变量导出到跑测试的 shell 会让 presenter 的 M132 入口锚**故意红**
// (它要求「两处都没有 id」的构建,见 crates/presenter/src/host/accounts/tests.rs)。
Ok(_) => assert!(
CLIENT_ID_BUILD.is_some_and(|value| !value.trim().is_empty()),
"没配 id 却构造成功"
),
Err(other) => panic!("应是 Unsupported,实际 {other:?}"),
}
}
#[test]
fn env_client_id_wins_over_build_and_the_log_says_so() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|error| error.into_inner());
let _guard = EnvGuard::set("0000-test-env-id");
let (id, source) = resolve_client_id().expect("环境变量给了 id");
assert_eq!(id, "0000-test-env-id");
// 两者都存在时环境变量优先,且**不告警**,但来源必须是 env。
assert_eq!(source, ClientIdSource::Env);
let status = client_id_status();
assert!(status.is_configured());
assert_eq!(status.source(), Some(ClientIdSource::Env));
assert_eq!(
status.log_line(),
"[auth] msa client_id=configured source=env"
);
// 有 id 时**不再报配置错误**:只验到「进得了登录流程的入口」为止,
// `from_env` 本身零网络(它只建 reqwest 客户端,不发请求)。
let auth = MicrosoftAuth::from_env().expect("配了 id 就该构造成功");
assert!(auth.endpoints().profile.contains("minecraftservices.com"));
}
#[test]
fn missing_client_id_log_line_is_machine_readable() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|error| error.into_inner());
let _guard = EnvGuard::clear();
if CLIENT_ID_BUILD.is_some_and(|value| !value.trim().is_empty()) {
// 构建期注入过 id:这条没有「缺配置」可言,跳过。
return;
}
let status = client_id_status();
assert!(!status.is_configured());
assert_eq!(status.source(), None);
assert_eq!(
status.log_line(),
"[auth] msa client_id=missing source=none"
);
}
#[test]
fn blank_env_value_counts_as_missing() {
// 抓得住:`ULL_MSA_CLIENT_ID=""`(空串/全空白)被当成配好了,进而拿空 id 去联网。
let _lock = ENV_LOCK.lock().unwrap_or_else(|error| error.into_inner());
let _guard = EnvGuard::set(" ");
if CLIENT_ID_BUILD.is_some_and(|value| !value.trim().is_empty()) {
assert_eq!(
resolve_client_id().map(|(_, source)| source),
Some(ClientIdSource::Build),
"空白环境变量必须让位给构建期注入"
);
} else {
assert!(resolve_client_id().is_none());
}
}
/// **M178 守卫**:client id 只能构建期/运行期注入,**微软轨源码里不许出现任何真实 id**。
///
/// 抓得住:为了让登录「开箱可用」而把一个真 client id 写进源码(或写进本模块的默认值)。
/// 抓不住:`.github/workflows` 里 Secret 的接线(那不在本 crate 的范围内,靠发布链自己的
/// `packaging/check_release_semantics.py` 与 Actions 日志看)。
#[test]
fn no_client_id_is_hardcoded_in_the_microsoft_sources() {
// 判据一:本模块的 id 只能来自 `option_env!`(构建期注入)。
let manifest = env!("CARGO_MANIFEST_DIR");
let source = std::fs::read_to_string(format!("{manifest}/src/auth/microsoft/client_id.rs"))
.expect("读 client_id.rs");
assert!(
source.contains("option_env!(\"ULL_MSA_CLIENT_ID\")"),
"client id 的构建期注入点不见了"
);
// 判据二:`auth/microsoft/` 下的任何源码都不许出现 UUID 形状的字面量
// (Azure 客户端 id 就是 UUID)。扫**全部文件**,所以把 id 挪到别的模块同样会红。
// 范围只到 `auth/microsoft/`:`auth/offline.rs` 里那个带连字符的 UUID 是离线名字的
// 测试向量,与 client id 无关。
let mut offenders = Vec::new();
let mut stack = vec![format!("{manifest}/src/auth/microsoft")];
while let Some(dir) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path.display().to_string());
continue;
}
if path.extension().is_some_and(|ext| ext == "rs") {
let text = std::fs::read_to_string(&path).unwrap_or_default();
if contains_uuid_literal(&text) {
offenders.push(path.display().to_string());
}
}
}
}
assert!(
offenders.is_empty(),
"这些源文件里出现了 UUID 形状的字面量(client id 只能构建期注入):{offenders:?}"
);
}
/// 源码里有没有 `8-4-4-4-12` 的十六进制字面量(Azure 客户端 id 的形状)。
fn contains_uuid_literal(text: &str) -> bool {
fn hex(value: &[u8]) -> bool {
!value.is_empty() && value.iter().all(u8::is_ascii_hexdigit)
}
let bytes = text.as_bytes();
let dashes: Vec<usize> = bytes
.iter()
.enumerate()
.filter(|(_, byte)| **byte == b'-')
.map(|(index, _)| index)
.collect();
dashes.windows(4).any(|window| {
let (first, last) = (window[0], window[3]);
let gaps = [
window[1] - first,
window[2] - window[1],
window[3] - window[2],
];
// 4-4-4-12 的段间距(连字符本身没算进 gap):5-5-5-13。
if gaps != [5, 5, 5] || last + 13 > bytes.len() || first < 8 {
return false;
}
hex(&bytes[first - 8..first])
&& hex(&bytes[first + 1..window[1]])
&& hex(&bytes[window[1] + 1..window[2]])
&& hex(&bytes[window[2] + 1..last])
&& hex(&bytes[last + 1..last + 13])
})
}
/// 判据自检:`contains_uuid_literal` 认得出 UUID,也不会把普通代码当 UUID。
///
/// 没有这条,「源码里没有 UUID」可能因为判据本身坏掉而永远为真(假绿)。
/// 样本**运行时拼**:字面量写在这里会被上面的扫描抓成 offender(自指陷阱)。
#[test]
fn the_uuid_literal_detector_bites() {
let sample = format!(
"\"{}-{}-{}-{}-{}\"",
"83fd9b2f", "da89", "41f7", "b41b", "13742a73841c"
);
assert!(contains_uuid_literal(&sample), "{sample}");
assert!(!contains_uuid_literal("// 0000-test-client"));
assert!(!contains_uuid_literal("a-b-c-d"));
assert!(!contains_uuid_literal(
"http://localhost/auth?client_id=not-a-uuid"
));
// 段长不对的连字符串不算(`8-4-4-4-12` 之外一律放行)。
assert!(!contains_uuid_literal(
"abcdefgh-1234-5678-9012-12345678901"
));
}
}
#[path = "client_id/tests.rs"]
mod tests;
@@ -0,0 +1,358 @@
//! `client_id` 的判据(从 `client_id.rs` 拆出来守 400 行硬线)。
//!
//! 两块:①「缺 id」文案与日志行的措辞纪律(M117);②**M178 守卫**——client id 只能构建期注入,
//! 微软轨源码里不许出现 UUID 形状的字面量(复审 r1 P2-3 加严:`concat!` / 跨行 / `format!`
//! 三种拼法都要抓得住,且不许误杀 `session.rs` 里那个 profile UUID)。
use std::sync::Mutex;
use super::{
client_id_status, resolve_client_id, ClientIdSource, CLIENT_ID_BUILD, CLIENT_ID_ENV,
MISSING_CLIENT_ID_MESSAGE,
};
use crate::auth::error::AuthError;
use crate::auth::microsoft::MicrosoftAuth;
/// 环境变量是**进程级**的:动它的用例必须串行,否则并行跑会互相摘/设,结论随机。
static ENV_LOCK: Mutex<()> = Mutex::new(());
/// 用例结束后把环境变量还原(panic 也还原:`Drop` 跑得到)。
struct EnvGuard {
saved: Option<String>,
}
impl EnvGuard {
fn clear() -> Self {
let saved = std::env::var(CLIENT_ID_ENV).ok();
std::env::remove_var(CLIENT_ID_ENV);
Self { saved }
}
fn set(value: &str) -> Self {
let saved = std::env::var(CLIENT_ID_ENV).ok();
std::env::set_var(CLIENT_ID_ENV, value);
Self { saved }
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
match self.saved.take() {
Some(value) => std::env::set_var(CLIENT_ID_ENV, value),
None => std::env::remove_var(CLIENT_ID_ENV),
}
}
}
#[test]
fn missing_client_id_message_says_unconfigured_not_unimplemented() {
// 抓得住:文案退回「本期未支持」这类说法(用户实跑到的那个 bug),
// 或者只说「需要 id」却不给怎么配(用户下一步就卡在这里)。
// 抓不住:文案好不好读——那要靠 review。
assert!(MISSING_CLIENT_ID_MESSAGE.contains("客户端 id"));
assert!(MISSING_CLIENT_ID_MESSAGE.contains("未配置"));
assert!(!MISSING_CLIENT_ID_MESSAGE.contains("未支持"));
assert!(!MISSING_CLIENT_ID_MESSAGE.contains("未实现"));
assert!(MISSING_CLIENT_ID_MESSAGE.contains(CLIENT_ID_ENV));
assert!(MISSING_CLIENT_ID_MESSAGE.contains("cargo build"));
// 硬约束:这句话进日志、进横幅、进错误,**示例一律是 `<id>` 占位**,不出现真 id。
assert!(MISSING_CLIENT_ID_MESSAGE.contains("<id>"));
}
#[test]
fn from_env_without_client_id_is_a_config_error_not_a_feature_gap() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|error| error.into_inner());
let _guard = EnvGuard::clear();
let result = MicrosoftAuth::from_env();
match result {
Err(AuthError::Unsupported(message)) => {
// 机读码保持 `unsupported`(CLI `--json` 与既有调用方靠它分流),
// 但**人读文案**必须说「未配置」。
assert_eq!(message, MISSING_CLIENT_ID_MESSAGE);
assert!(!message.contains("未支持"));
}
// 构建期注入过 id 时唯一合法(`ULL_MSA_CLIENT_ID` 也设进 `cargo test` 的环境就会走到这里;
// 官方发布构建即烘了 id,但门禁里的 `cargo test` 不带该变量,故常态走上面那条。
// 注意:把该变量导出到跑测试的 shell 会让 presenter 的 M132 入口锚**故意红**
// (它要求「两处都没有 id」的构建,见 crates/presenter/src/host/accounts/tests.rs)。
Ok(_) => assert!(
CLIENT_ID_BUILD.is_some_and(|value| !value.trim().is_empty()),
"没配 id 却构造成功"
),
Err(other) => panic!("应是 Unsupported,实际 {other:?}"),
}
}
#[test]
fn env_client_id_wins_over_build_and_the_log_says_so() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|error| error.into_inner());
let _guard = EnvGuard::set("0000-test-env-id");
let (id, source) = resolve_client_id().expect("环境变量给了 id");
assert_eq!(id, "0000-test-env-id");
// 两者都存在时环境变量优先,且**不告警**,但来源必须是 env。
assert_eq!(source, ClientIdSource::Env);
let status = client_id_status();
assert!(status.is_configured());
assert_eq!(status.source(), Some(ClientIdSource::Env));
assert_eq!(
status.log_line(),
"[auth] msa client_id=configured source=env"
);
// 有 id 时**不再报配置错误**:只验到「进得了登录流程的入口」为止,
// `from_env` 本身零网络(它只建 reqwest 客户端,不发请求)。
let auth = MicrosoftAuth::from_env().expect("配了 id 就该构造成功");
assert!(auth.endpoints().profile.contains("minecraftservices.com"));
}
#[test]
fn missing_client_id_log_line_is_machine_readable() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|error| error.into_inner());
let _guard = EnvGuard::clear();
if CLIENT_ID_BUILD.is_some_and(|value| !value.trim().is_empty()) {
// 构建期注入过 id:这条没有「缺配置」可言,跳过。
return;
}
let status = client_id_status();
assert!(!status.is_configured());
assert_eq!(status.source(), None);
assert_eq!(
status.log_line(),
"[auth] msa client_id=missing source=none"
);
}
#[test]
fn blank_env_value_counts_as_missing() {
// 抓得住:`ULL_MSA_CLIENT_ID=""`(空串/全空白)被当成配好了,进而拿空 id 去联网。
let _lock = ENV_LOCK.lock().unwrap_or_else(|error| error.into_inner());
let _guard = EnvGuard::set(" ");
if CLIENT_ID_BUILD.is_some_and(|value| !value.trim().is_empty()) {
assert_eq!(
resolve_client_id().map(|(_, source)| source),
Some(ClientIdSource::Build),
"空白环境变量必须让位给构建期注入"
);
} else {
assert!(resolve_client_id().is_none());
}
}
/// **M178 守卫**:client id 只能构建期/运行期注入,**微软轨源码里不许出现任何真实 id**。
///
/// 抓得住:为了让登录「开箱可用」而把一个真 client id 写进源码(或写进本模块的默认值)。
/// 抓不住:`.github/workflows` 里 Secret 的接线(那不在本 crate 的范围内,靠发布链自己的
/// `packaging/check_release_semantics.py` 与 Actions 日志看)。
#[test]
fn no_client_id_is_hardcoded_in_the_microsoft_sources() {
// 判据一:本模块的 id 只能来自 `option_env!`(构建期注入)。
let manifest = env!("CARGO_MANIFEST_DIR");
let source = std::fs::read_to_string(format!("{manifest}/src/auth/microsoft/client_id.rs"))
.expect("读 client_id.rs");
assert!(
source.contains("option_env!(\"ULL_MSA_CLIENT_ID\")"),
"client id 的构建期注入点不见了"
);
// 判据二:`auth/microsoft/` 下的任何源码都不许出现 UUID 形状的字面量
// (Azure 客户端 id 就是 UUID)。扫**全部文件**,所以把 id 挪到别的模块同样会红。
// 范围只到 `auth/microsoft/`:`auth/offline.rs` 里那个带连字符的 UUID 是离线名字的
// 测试向量,与 client id 无关。
let mut offenders = Vec::new();
let mut stack = vec![format!("{manifest}/src/auth/microsoft")];
while let Some(dir) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path.display().to_string());
continue;
}
if path.extension().is_some_and(|ext| ext == "rs") {
let text = std::fs::read_to_string(&path).unwrap_or_default();
if contains_uuid_literal(&text) {
offenders.push(path.display().to_string());
}
}
}
}
assert!(
offenders.is_empty(),
"这些源文件里出现了 UUID 形状的字面量(client id 只能构建期注入):{offenders:?}"
);
}
/// 源码里有没有「Azure 客户端 id 形状」的字面量(复审 r1 P2-3 加严)。
///
/// 两条判据,覆盖复审点名的三种拼法:
/// 1. **带连字符**(`8-4-4-4-12`):把全部字面量首尾相接后扫,于是
/// `concat!("83fd9b2f-", "da89-…")`、跨行相邻两段、单段完整 UUID 都能抓到;
/// 2. **无连字符的 32 位十六进制、且由 ≥2 个**连续**的「纯十六进制字面量**拼起来
/// (`format!("{}-{}-{}-{}", "83fd9b2f", "da89", …)` 这类逐段拼法)。
///
/// 为什么判据 2 要求 ≥2 段:`session.rs` 的测试数据里有 profile UUID
/// (`"0f8fad5bd9cb469fa16570867728950e"`)——单个字面量的 32 位 hex 是**档案 id**,
/// 不是 client id;只有「拼出来的」才可疑。也不能把字面量无分隔地全部相接去数 hex 段:
/// 那样下一个字面量的开头若恰好是 hex 字符(如 `"Bearer"` 的 `Bea`)会把上一段续长造成误杀。
fn contains_uuid_literal(text: &str) -> bool {
let literals = string_literals(text);
let joined: String = literals.iter().map(String::as_str).collect();
if has_dashed_uuid(&joined) {
return true;
}
// 判据 2:连续的「纯 hex 字面量」累计长度 ≥32 且至少两段。
let mut run_len = 0;
let mut run_parts = 0;
for literal in &literals {
let pure_hex = !literal.is_empty() && literal.bytes().all(|b| b.is_ascii_hexdigit());
if pure_hex {
run_len += literal.len();
run_parts += 1;
if run_len >= 32 && run_parts >= 2 {
return true;
}
} else {
run_len = 0;
run_parts = 0;
}
}
false
}
/// 收集源码里的字符串字面量内容(跳过注释;`\` 转义按「跳一个字符」处理)。
///
/// 为什么跳注释:本模块的文档里就写着 `concat!("83fd9b2f-", "da89-…")` 这类**说明性**例子,
/// 不跳的话自检样本会被自己的文档抓成 offender(实测踩过)。注释里出现 id 不构成「代码里
/// 硬编码 id」,判据只管可执行的字面量。
///
/// 近似处(够用即可):字符字面量 `'a'` 不当字符串;`r#"…"#` 按普通字面量近似(前缀 `r`
/// 不在引号内,不影响内容提取)。
fn string_literals(source: &str) -> Vec<String> {
let bytes = source.as_bytes();
let mut out = Vec::new();
let mut index = 0;
while index < bytes.len() {
match bytes[index] {
// 行注释:跳到行尾
b'/' if bytes.get(index + 1) == Some(&b'/') => {
while index < bytes.len() && bytes[index] != b'\n' {
index += 1;
}
}
// 块注释:跳到 `*/`
b'/' if bytes.get(index + 1) == Some(&b'*') => {
index += 2;
while index + 1 < bytes.len() && !(bytes[index] == b'*' && bytes[index + 1] == b'/')
{
index += 1;
}
index = (index + 2).min(bytes.len());
}
b'"' => {
index += 1;
let start = index;
let mut escaped = false;
while index < bytes.len() {
let byte = bytes[index];
if escaped {
escaped = false;
index += 1;
continue;
}
match byte {
b'\\' => {
escaped = true;
index += 1;
}
b'"' => break,
_ => index += 1,
}
}
let end = index.min(bytes.len());
out.push(String::from_utf8_lossy(&bytes[start..end]).into_owned());
index += 1;
}
_ => index += 1,
}
}
out
}
/// 拼接后的串里有没有 `8-4-4-4-12` 的十六进制段(间距 5-5-5-13)。
fn has_dashed_uuid(text: &str) -> bool {
fn hex(value: &[u8]) -> bool {
!value.is_empty() && value.iter().all(u8::is_ascii_hexdigit)
}
let bytes = text.as_bytes();
let dashes: Vec<usize> = bytes
.iter()
.enumerate()
.filter(|(_, byte)| **byte == b'-')
.map(|(index, _)| index)
.collect();
dashes.windows(4).any(|window| {
let (first, last) = (window[0], window[3]);
let gaps = [
window[1] - first,
window[2] - window[1],
window[3] - window[2],
];
if gaps != [5, 5, 5] || last + 13 > bytes.len() || first < 8 {
return false;
}
hex(&bytes[first - 8..first])
&& hex(&bytes[first + 1..window[1]])
&& hex(&bytes[window[1] + 1..window[2]])
&& hex(&bytes[window[2] + 1..last])
&& hex(&bytes[last + 1..last + 13])
})
}
/// 判据自检:三种拼法都要抓得住,且不能把普通代码当 client id。
///
/// 没有这条,扫描可能因为判据本身坏掉而永远为真(假绿)。
/// 样本**运行时拼、且源码里不出现十六进制字面量**(用 `char` 数组拼):否则这条自检的
/// 样本本身会被扫描抓成 offender——自指陷阱(实测踩过一次)。
#[test]
fn the_uuid_literal_detector_bites_three_shapes() {
let part = |chars: &[char]| chars.iter().collect::<String>();
let head = part(&['8', '3', 'f', 'd', '9', 'b', '2', 'f']);
let p2 = part(&['d', 'a', '8', '9']);
let p3 = part(&['4', '1', 'f', '7']);
let p4 = part(&['b', '4', '1', 'b']);
let tail = part(&['1', '3', '7', '4', '2', 'a', '7', '3', '8', '4', '1', 'c']);
let uuid = format!("{head}-{p2}-{p3}-{p4}-{tail}");
// ① 单个完整字面量(基线)。
let plain = format!("let id = \"{uuid}\";");
assert!(contains_uuid_literal(&plain), "{plain}");
// ② `concat!` 拼出来(复审点名的形态之一)。
let concatenated = format!("const ID: &str = concat!(\"{head}-\", \"{p2}-{p3}-{p4}-{tail}\");");
assert!(contains_uuid_literal(&concatenated), "{concatenated}");
// ③ 跨行两段字面量。
let split_lines =
format!("let id = concat!(\n \"{head}-{p2}-\",\n \"{p3}-{p4}-{tail}\"\n);");
assert!(contains_uuid_literal(&split_lines), "{split_lines}");
// ④ `format!` 逐段拼(无连字符落点,靠「连续纯 hex 字面量累计 ≥32 且 ≥2 段」抓)。
let formatted = format!(
"let id = format!(\"{{}}-{{}}-{{}}-{{}}-{{}}\", \"{head}\", \"{p2}\", \"{p3}\", \"{p4}\", \"{tail}\");"
);
assert!(contains_uuid_literal(&formatted), "{formatted}");
// 反例:这些都不许误杀。
assert!(!contains_uuid_literal("// 0000-test-client"));
assert!(!contains_uuid_literal("a-b-c-d"));
assert!(!contains_uuid_literal(
"let uri = \"http://localhost/auth?client_id=not-a-uuid\";"
));
// 单个字面量的 32 位 hex = 既有测试数据(`session.rs` 的 profile UUID),不算 client id。
let profile = format!("let profile_id = \"{head}{p2}{p3}{p4}{tail}\";");
assert!(!contains_uuid_literal(&profile), "{profile}");
// 段长不对的连字符串不算(`8-4-4-4-12` 之外一律放行)。
assert!(!contains_uuid_literal(
"let x = \"abcdefgh-1234-5678-9012-12345678901\";"
));
}
+13 -9
View File
@@ -22,6 +22,8 @@
//! 回退说明有两层:结构化码 [`BrowserFallbackReason`](宿主据它出 i18n,**能说清怎么修**)
//! 与原始诊断 [`BrowserFallback::detail`](只进日志,不上屏)。
use std::sync::atomic::{AtomicUsize, Ordering};
use crate::auth::error::{AuthError, AuthResult};
use super::authcode::{self, AuthCodeAttempt, AuthCodePrompt};
@@ -85,9 +87,10 @@ impl BrowserFallback {
/// 一次「浏览器优先」登录的完整结果(M178)。
///
/// `listeners_opened` 是**这次登录**为本地回调开的监听口个数(0 或 1)。它存在的唯一理由是
/// 守卫能直接数:「没登记回环」「走设备码」这两条路上必须是 0——用进程级全局计数器做不到
/// 这一点(并行跑的测试会互相搅动),所以计数跟着结果走。
/// `listeners_opened` 是**这次登录**真的去开本地监听口的次数(0 或 1,`bind_counting` 在
/// `TcpListener::bind` 现场 +1)。它存在的唯一理由是守卫能直接数:「没登记回环」「走设备码」
/// 这两条路上必须是 0。用进程级全局计数器做不到这一点(并行跑的测试会互相搅动),所以计数
/// 是 per-login 的 `AtomicUsize`,但**增长点在绑定现场**,不在「进没进某个分支」。
#[derive(Debug, Clone)]
pub struct LoginOutcome {
/// 拿到的会话(两条流任意一条走通)
@@ -135,14 +138,16 @@ impl MicrosoftAuth {
FD: FnMut(&DeviceCodePrompt, Option<&BrowserFallback>),
{
let mut fallback: Option<BrowserFallback> = None;
// 预检通过之后才会绑端口(`login_checked` 的顺序),所以只有「真去试了」才记 1。
let mut bound_listener = false;
// 绑定现场的**真计数**(`CallbackServer::bind_counting` 每次调 `TcpListener::bind` 前 +1):
// 不是「进没进授权码分支」的代理指标——那正是复审 r1 P2-1 变异 D 打穿的地方。
let listeners = AtomicUsize::new(0);
if self.browser_first {
match authcode::login_checked(
&self.client,
&self.endpoints,
&self.client_id,
self.authcode.clone(),
&listeners,
present_browser,
)
.await
@@ -151,14 +156,13 @@ impl MicrosoftAuth {
return Ok(LoginOutcome {
session,
browser: None,
listeners_opened: 1,
listeners_opened: listeners.load(Ordering::Relaxed),
})
}
// 跳过:**没绑端口**(预检在绑定之前),所以计数仍是 0。
// 跳过:**没绑端口**(预检在绑定之前),计数仍是 0。
Ok(AuthCodeAttempt::Skipped(note)) => fallback = Some(note),
Err(AuthError::Canceled) => return Err(AuthError::Canceled),
Err(error) => {
bound_listener = true;
self.record_failure("authcode_login", &error);
let reason = if preflight::error_means_unregistered(&error) {
BrowserFallbackReason::RedirectNotRegistered
@@ -185,7 +189,7 @@ impl MicrosoftAuth {
Ok(LoginOutcome {
session,
browser: fallback,
listeners_opened: usize::from(bound_listener),
listeners_opened: listeners.load(Ordering::Relaxed),
})
}
}
+98 -3
View File
@@ -23,10 +23,13 @@
//! 做:拿**即将使用的那个** `redirect_uri`(只有端口可以是任意的,所以用固定探测端口拼形状)
//! 去授权端点发一次**不带凭据的 GET**,看它是否当场报 `invalid_request` / `AADSTS50011`。
//! 不注册 → 不绑本地端口、不弹浏览器,直接回退设备码。这样「没登记回调地址」的机器上
//! 监听口一次都不开(守卫断言 `callback::bind_attempts()` 不涨)。
//! 监听口一次都不开(守卫断言 `LoginOutcome::listeners_opened == 0`,那个数是
//! [`super::callback::CallbackServer::bind_counting`] 在 `TcpListener::bind` 现场加的)。
//!
//! 不做:预检失败**不等于**登录被拒——它只决定「要不要试浏览器」。
//! `Unknown`(网络问题)同样按「别试」处理:设备码流不依赖任何注册项,永远更稳。
//! `Unknown`(网络问题/整体超时)同样按「别试」处理:设备码流不依赖任何注册项,永远更稳。
//! 整次预检有 [`PREFLIGHT_TIMEOUT`] 的上限:服务器接了连接却不出响应时,不能让「浏览器登录」
//! 按钮把界面卡在 Requesting(取消按钮只收面板、停不掉已经跑起来的线程)。
use reqwest::Client;
@@ -44,6 +47,14 @@ pub const PROBE_PORT: u16 = 53_691;
/// 预检请求里的 `state`(不参与任何校验,只为拼出形状完整的授权 URL)。
const PROBE_STATE: &str = "ull-redirect-probe";
/// 整次预检的时间上限(复审 r1 P2-2)。
///
/// 为什么必须有:下载引擎那个 HTTP 客户端只设了 `connect_timeout`(10s),**没有**整体超时
/// ——`read_timeout` 从未应用到 reqwest 客户端。授权端点接了连接却不出响应(或 header 后停住)时,
/// 这一次「用浏览器登录」会把界面卡在 Requesting,而取消按钮只收面板、停不掉已经跑起来的线程。
/// 超时按 [`RedirectRegistration::Unknown`] 处理:回退设备码,原因码「没确认」。
pub const PREFLIGHT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
/// 授权端点对 `redirect_uri` 的态度。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RedirectRegistration {
@@ -84,6 +95,42 @@ pub(super) async fn check(
endpoints: &MicrosoftEndpoints,
client_id: &str,
redirect_uri: &RedirectUri,
) -> RedirectRegistration {
check_with_deadline(
client,
endpoints,
client_id,
redirect_uri,
PREFLIGHT_TIMEOUT,
)
.await
}
/// [`check`] 的可注入 deadline 版本(单测用短 deadline,不必等 10 秒)。
pub(super) async fn check_with_deadline(
client: &Client,
endpoints: &MicrosoftEndpoints,
client_id: &str,
redirect_uri: &RedirectUri,
deadline: std::time::Duration,
) -> RedirectRegistration {
match tokio::time::timeout(deadline, probe(client, endpoints, client_id, redirect_uri)).await {
Ok(verdict) => verdict,
Err(_) => RedirectRegistration::Unknown {
detail: format!(
"预检 {} 毫秒内没有结论,按「没确认」处理",
deadline.as_millis()
),
},
}
}
/// 真正的预检请求(不含超时;由 [`check_with_deadline`] 套 deadline)。
async fn probe(
client: &Client,
endpoints: &MicrosoftEndpoints,
client_id: &str,
redirect_uri: &RedirectUri,
) -> RedirectRegistration {
let url = authorization_url(endpoints, client_id, redirect_uri, PROBE_STATE);
let response = client.get(&url).send().await;
@@ -175,9 +222,13 @@ fn snippet(body: &str) -> String {
#[cfg(test)]
mod tests {
use super::{error_means_unregistered, rejects_redirect_uri, snippet, RedirectRegistration};
use super::{
check_with_deadline, error_means_unregistered, rejects_redirect_uri, snippet,
RedirectRegistration,
};
use crate::auth::body::ResponseDetail;
use crate::auth::error::AuthError;
use crate::auth::microsoft::{MicrosoftEndpoints, RedirectUri};
/// 用户 2026-11-08 实测的那张错误页(原文截取)必须被判成「没登记」。
#[test]
@@ -234,6 +285,50 @@ redirect+URI+registered+for+this+client+application.";
.is_registered());
}
/// **复审 r1 P2-2**:服务器接了连接却永不出响应时,预检必须靠**整体 deadline** 收场
/// (不是 connect_timeout:连接早就建好了),结论是「没确认」⇒ 上层回退设备码。
///
/// 抓得住:把 `tokio::time::timeout` 去掉(那条路会一直挂着,本用例在 deadline 后
/// 断言超时不出来,必然失败)。
/// 抓不住:真机上网关的其它卡法(这里只有一个「不回字」的假服务器)。
#[tokio::test]
async fn a_silent_authorize_server_hits_the_overall_deadline() {
// 假授权端点:accept 之后一个字节都不写(connect 成功、读永远等不到)。
let silent = tokio::net::TcpListener::bind(("127.0.0.1", 0))
.await
.expect("假服务器起不来");
let port = silent.local_addr().expect("地址").port();
tokio::spawn(async move {
let mut held = Vec::new();
loop {
let Ok((stream, _)) = silent.accept().await else {
break;
};
held.push(stream);
}
});
let client =
crate::download::http::build_client(&crate::download::config::EngineConfig::default())
.expect("HTTP 客户端");
let mut endpoints = MicrosoftEndpoints::all_on("http://127.0.0.1:1");
endpoints.authorize = format!("http://127.0.0.1:{port}/authorize");
let verdict = check_with_deadline(
&client,
&endpoints,
"test-client-id",
&RedirectUri::new(53_691),
std::time::Duration::from_millis(200),
)
.await;
match verdict {
RedirectRegistration::Unknown { detail } => {
assert!(detail.contains("200"), "文案要带 deadline:{detail}");
}
other => panic!("静默服务器必须走 Unknown,实际 {other:?}"),
}
}
#[test]
fn snippet_is_single_line_and_capped() {
let long = format!("a\nb\t{}", "x".repeat(400));
@@ -104,6 +104,13 @@ async fn unregistered_redirect_falls_back_to_the_device_code() {
outcome.listeners_opened, 0,
"没登记回环时不许绑本地端口(预检必须在绑定之前)"
);
// ③ **报文级证据**:预检真的发出去了(授权端点收到 1 次 GET),而不是被跳过。
// 这条也是复审 P1-1 的回执:只要生产路径把 `browser_first` 置位,这条 GET 就会发生。
assert_eq!(
mock.bodies_on("/authorize").len(),
1,
"浏览器优先路必须真的发一次回环注册预检"
);
mock.stop();
}
+3 -1
View File
@@ -57,7 +57,9 @@ macro_rules! account_entries {
AccountDeviceRetry => ("Try again", "重试"),
AccountDeviceCancel => ("Cancel", "取消"),
AccountDeviceBrowser => ("Sign in with browser", "用浏览器登录"),
AccountDeviceUseCode => ("Use a device code instead", "改用设备码"),
// M178 复审 r1 P2-5:这是**主按钮**(设备码流),不能读起来像备选。
AccountDeviceSignIn => ("Sign in with your Microsoft account", "使用 Microsoft 账户登录"),
AccountDeviceFallbackHint => ("No browser handy? This way shows a code you can type on any device.", "浏览器打不开就用这个:它会给一串用户码,在任何设备上输入即可。"),
// ── 第三方站表单
AccountServerLabel => ("Server address", "服务器地址"),
AccountServerHint => ("API root, e.g. https://littleskin.cn/api/yggdrasil/", "API 根,例:https://littleskin.cn/api/yggdrasil/"),
+2 -1
View File
@@ -135,7 +135,8 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[
("AccountDeviceRetry", "Try again", "重试"),
("AccountDeviceCancel", "Cancel", "取消"),
("AccountDeviceBrowser", "Sign in with browser", "用浏览器登录"),
("AccountDeviceUseCode", "Use a device code instead", "改用设备码"),
("AccountDeviceSignIn", "Sign in with your Microsoft account", "使用 Microsoft 账户登录"),
("AccountDeviceFallbackHint", "No browser handy? This way shows a code you can type on any device.", "浏览器打不开就用这个:它会给一串用户码,在任何设备上输入即可。"),
("AccountServerLabel", "Server address", "服务器地址"),
("AccountServerHint", "API root, e.g. https://littleskin.cn/api/yggdrasil/", "API 根,例:https://littleskin.cn/api/yggdrasil/"),
("AccountServerProbe", "Check server", "校验服务器"),
+73 -18
View File
@@ -188,12 +188,11 @@ impl LoginJob {
/// 起一次微软登录(M178:**GUI 的默认路**——设备码)。
///
/// 设备码流不依赖任何应用注册项(Azure 那边只需要一个公共客户端 id),所以它是唯一
/// 「在官方产物上开箱可用」的路。它**一个本地监听口都不开**,也就不会撞上
/// `redirect_uri is not valid` 那张 AAD 错误页(守卫断言见 `callback::bind_attempts`)。
/// 设备码流不依赖任何应用注册项,是唯一「在官方产物上开箱可用」的路;它**一个本地监听口都不开**,
/// 也就不会撞上 `redirect_uri is not valid`(守卫断言 `listeners_opened == 0`,在 bind 现场加)。
pub fn start_login(env: HostEnv, tx: JobSender, job: LoginJob) {
spawn("msa-login", move || {
let auth = match microsoft_auth(&env) {
let auth = match msa_auth(&env, MsaEntry::DeviceCode, &job) {
Ok(auth) => auth,
Err(error) => return fail(&tx, auth_failure(&error), error.to_string()),
};
@@ -212,16 +211,13 @@ pub fn start_login(env: HostEnv, tx: JobSender, job: LoginJob) {
/// 起一次「浏览器优先、失败自动回退设备码」的登录(M178:可选加速路)。
///
/// 只在用户**显式要求**时才走到这里(账户页的「用浏览器登录」)。即使如此也不盲试:
/// `core` 先做回环重定向 URI 的注册预检,没登记就**不绑端口、不弹浏览器**,直接设备码;
/// 试了但失败(超时/被拒/端口绑不上)同样回退。两条路都不满意时用户还能在面板上看到
/// 一句「为什么改用设备码 + 怎么修」(结构化码 → i18n,原文只进日志)。
///
/// 与设备码流共用同一个 [`LoginJob`] 取消槽位、同一段账户入库逻辑,所以两条流的
/// 凭据/刷新语义完全一致(`core` 侧也共用 [`unlucky_core::auth::MicrosoftSession`])。
/// 只在用户**显式要求**时才走到这里(账户页的「用浏览器登录」)。即使如此也不盲试:`core` 先做
/// 回环注册预检,没登记就**不绑端口、不弹浏览器**,直接设备码;试了但失败同样回退并给一句说明。
pub fn start_browser_login(env: HostEnv, tx: JobSender, job: LoginJob) {
spawn("msa-browser", move || {
let auth = match microsoft_auth(&env) {
// **P1-1 的那一行**:这里是「用户点了用浏览器登录」,必须把 browser_first 置位,
// 否则下面这段预检/回退编排整段不执行(复审 r1 实测的洞)。
let auth = match msa_auth(&env, MsaEntry::Browser, &job) {
Ok(auth) => auth,
Err(error) => return fail(&tx, auth_failure(&error), error.to_string()),
};
@@ -273,16 +269,75 @@ fn browser_note(reason: BrowserFallbackReason) -> BrowserLoginNote {
}
}
/// 生产入口的 `MicrosoftAuth`:client id 取环境/构建期,失败日志落 `<数据根>/logs/`。
fn microsoft_auth(env: &HostEnv) -> AuthResult<MicrosoftAuth> {
/// 微软登录的两个入口(决定 `MicrosoftAuth::browser_first`,M178)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MsaEntry {
/// 默认路:设备码。**一个监听口都不开**,也永远不做回环预检。
DeviceCode,
/// 用户显式点了「用浏览器登录」:浏览器优先(预检 → 授权码 → 失败自动回退设备码)。
Browser,
}
impl MsaEntry {
/// 这个入口要不要把 `browser_first` 置位。
const fn browser_first(self) -> bool {
matches!(self, Self::Browser)
}
}
/// 生产入口的 `MicrosoftAuth`(client id 取环境/构建期,失败日志落 `<数据根>/logs/`)。
///
/// **`browser_first` 必须在这里按入口置位**(复审 r1 P1-1):只构造默认 `MicrosoftAuth` 的话,
/// 浏览器按钮会静默退化成设备码——预检/回退码/说明全不可达。
fn msa_auth(env: &HostEnv, entry: MsaEntry, _job: &LoginJob) -> AuthResult<MicrosoftAuth> {
#[cfg(test)]
test_seam::record(_job, entry);
MicrosoftAuth::from_env().map(|auth| {
auth.with_journal(Some(unlucky_core::auth::AuthJournal::in_logs_dir(
env.layout.logs(),
)))
auth.with_browser_first(entry.browser_first())
.with_journal(Some(unlucky_core::auth::AuthJournal::in_logs_dir(
env.layout.logs(),
)))
})
}
/// `microsoft_auth` 失败 → 原因码。
/// 测试缝:记下生产入口**实际请求**的模式,让守卫能钉住「按钮 → browser_first=true」这条线。
///
/// 为什么需要它:两条入口在没有 client id 的测试构建里都会在网络之前早退,拿不到
/// `MicrosoftAuth` 实例去问 `browser_first()`;而 P1-1 的 bug 恰恰在**调用点**。这里在被调用
/// 的一刻就把模式记下来,把调用点变成可断言的事实。
///
/// 为什么按 `LoginJob` 的取消槽指针分桶:同一进程里别的用例也在调这两个入口,一个全局
/// 「最后一次」记录会串台(实测过:并行跑时读到别人的 Browser ⇒ 假红)。
#[cfg(test)]
mod test_seam {
use std::sync::{Arc, Mutex};
use super::{LoginJob, MsaEntry};
static ENTRIES: Mutex<Vec<(usize, bool)>> = Mutex::new(Vec::new());
fn key(job: &LoginJob) -> usize {
Arc::as_ptr(&job.cancelled) as usize
}
pub(super) fn record(job: &LoginJob, entry: MsaEntry) {
let key = key(job);
let mut entries = ENTRIES.lock().unwrap_or_else(|error| error.into_inner());
entries.push((key, entry.browser_first()));
}
/// 这个 job 的生产构造请求的模式(`None` = 还没调用过)。
pub(super) fn mode_for(job: &LoginJob) -> Option<bool> {
let key = key(job);
let entries = ENTRIES.lock().unwrap_or_else(|error| error.into_inner());
entries
.iter()
.find(|(recorded, _)| *recorded == key)
.map(|(_, browser_first)| *browser_first)
}
}
/// `msa_auth` 失败 → 原因码。
///
/// **只有「两处都没配 client id」单独成码**(M132):`from_env` 的 `Unsupported` 是它唯一的
/// 产出点,且这条路径零网络。上屏的正文是 catalog 里的固定文案(原文照旧只进日志),理由是
@@ -186,6 +186,77 @@ fn login_job_cancel_flag_is_shared() {
assert!(clone.is_cancelled());
}
/// **M178 复审 r1 P1-1 的守卫**:生产路径上,「用浏览器登录」这颗按钮必须真的把
/// `browser_first` 置位,设备码那颗必须不置位。
///
/// 修复前实测过的形态:`start_browser_login` 用共享的 `microsoft_auth(&env)`(默认
/// `browser_first=false`),于是 `browser_then_device_login` 里整段预检/回退编排不执行——
/// 按钮静默退化成设备码、连一句说明都不出,`preflight` / `BrowserFallbackReason` /
/// `BrowserLoginNote` / 三条 i18n 文案全成不可达代码。
///
/// 抓得住(本用例就是为此写的):把 `msa_auth(&env, MsaEntry::Browser)` 改回
/// `MsaEntry::DeviceCode`(或删掉 `.with_browser_first(...)` 那一跳以外的模式选择),
/// 记录会变成 `false` ⇒ 红。
/// 抓不住:真的去连微软(无 client id 的测试构建里两条入口都在网络之前早退)。
#[test]
fn the_browser_button_actually_turns_on_browser_first() {
let dir = std::env::temp_dir().join(format!("ull-msa-entry-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let env = HostEnv::at_root(&dir);
std::fs::create_dir_all(env.layout.auth_data()).expect("auth 目录");
// 设备码入口:不许碰浏览器(也不许碰回环预检)。
let device_job = LoginJob::new();
let (tx, _rx) = super::super::job::channel();
start_login(env.clone(), tx, device_job.clone());
assert_eq!(
wait_for_entry(&device_job),
Some(false),
"设备码入口把 browser_first 置成了 true:默认路会去开监听口/弹浏览器"
);
// 浏览器入口:**必须**置位,否则预检与回退整段不可达。
let browser_job = LoginJob::new();
let (tx, _rx) = super::super::job::channel();
start_browser_login(env, tx, browser_job.clone());
assert_eq!(
wait_for_entry(&browser_job),
Some(true),
"「用浏览器登录」没有置位 browser_first:按钮会静默退化成设备码(复审 r1 P1-1 的洞)"
);
}
/// 等登录线程把「这个 job 的入口模式」记下来(线程是 `spawn` 出去的,最多等这么久)。
///
/// 按 job 分桶而不是读「最后一次」:同一进程里别的用例也在调这两个入口(实测会串台假红)。
fn wait_for_entry(job: &LoginJob) -> Option<bool> {
for _ in 0..200 {
if let Some(flag) = super::test_seam::mode_for(job) {
return Some(flag);
}
std::thread::sleep(Duration::from_millis(10));
}
None
}
/// 置位不是「记了个数」:`msa_auth` 必须把模式真的落到 `MicrosoftAuth::browser_first` 上。
///
/// 前半段(入口 → 模式)由上一个用例守;这里守后半段(模式 → 实例字段)。两者缺一,
/// 就会出现「日志说置位了、实际没置位」这类假绿。
#[test]
fn the_entry_mode_lands_on_the_auth_instance() {
assert!(!MsaEntry::DeviceCode.browser_first());
assert!(MsaEntry::Browser.browser_first());
// 无 client id 时 `from_env` 早退,所以这里用显式 id 走同一个 builder 链。
let auth = unlucky_core::auth::microsoft::MicrosoftAuth::new("0000-entry-test")
.expect("构造")
.with_browser_first(MsaEntry::Browser.browser_first());
assert!(
auth.browser_first(),
"with_browser_first 没把字段置上(core 里 browser_then_device_login 会整段跳过)"
);
}
/// **M178**:core 的回退原因码 → 页面的说明码。两边的 ascii 码必须逐字相同——
/// 日志里看到的码就是页面上 `Code:` 那一行,对不上就没法用用户回报的码定位。
///