From a40b3a5940fc7cf44f780c004f52887a262e0b0c Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 8 Nov 2026 12:08:12 +0800 Subject: [PATCH] =?UTF-8?q?fix(msa):=20=E6=B5=8F=E8=A7=88=E5=99=A8?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E6=8C=89=E9=92=AE=E5=9C=A8=E7=94=9F=E4=BA=A7?= =?UTF-8?q?=E8=B7=AF=E5=BE=84=E7=9C=9F=E7=9A=84=E7=BD=AE=E4=BD=8D=20browse?= =?UTF-8?q?r=5Ffirst=EF=BC=88=E5=A4=8D=E5=AE=A1=20r1=20=E6=94=B6=E5=8F=A3?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复审 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 行红线。截图三张(入口 / 设备码 / 回退)已重出。 --- crates/app-iced/src/app/update/account.rs | 5 + crates/app-iced/src/pages/account/login.rs | 45 ++- .../app-iced/src/pages/account/tests_msa.rs | 41 +- .../app-iced/src/pages/account/tests_text.rs | 9 +- crates/core/src/auth/microsoft/authcode.rs | 27 +- crates/core/src/auth/microsoft/callback.rs | 18 + crates/core/src/auth/microsoft/client_id.rs | 237 +----------- .../src/auth/microsoft/client_id/tests.rs | 358 ++++++++++++++++++ crates/core/src/auth/microsoft/login.rs | 22 +- crates/core/src/auth/microsoft/preflight.rs | 101 ++++- crates/core/tests/auth/microsoft_msalogin.rs | 7 + crates/i18n/src/catalog/account.rs | 4 +- crates/i18n/src/catalog/golden/tail2.rs | 3 +- crates/presenter/src/host/accounts.rs | 91 ++++- crates/presenter/src/host/accounts/tests.rs | 71 ++++ 15 files changed, 745 insertions(+), 294 deletions(-) create mode 100644 crates/core/src/auth/microsoft/client_id/tests.rs diff --git a/crates/app-iced/src/app/update/account.rs b/crates/app-iced/src/app/update/account.rs index b6f2f18..0851445 100644 --- a/crates/app-iced/src/app/update/account.rs +++ b/crates/app-iced/src/app/update/account.rs @@ -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; diff --git a/crates/app-iced/src/pages/account/login.rs b/crates/app-iced/src/pages/account/login.rs index b967c34..a11eaad 100644 --- a/crates/app-iced/src/pages/account/login.rs +++ b/crates/app-iced/src/pages/account/login.rs @@ -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> = Vec::new(); diff --git a/crates/app-iced/src/pages/account/tests_msa.rs b/crates/app-iced/src/pages/account/tests_msa.rs index c5f4987..3439e19 100644 --- a/crates/app-iced/src/pages/account/tests_msa.rs +++ b/crates/app-iced/src/pages/account/tests_msa.rs @@ -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." + ); } /// 回退之后设备码又失败:失败面板上**两者都要在**(失败原因 + 为什么没走浏览器)。 diff --git a/crates/app-iced/src/pages/account/tests_text.rs b/crates/app-iced/src/pages/account/tests_text.rs index 5e73a7a..6afa153 100644 --- a/crates/app-iced/src/pages/account/tests_text.rs +++ b/crates/app-iced/src/pages/account/tests_text.rs @@ -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 } => { diff --git a/crates/core/src/auth/microsoft/authcode.rs b/crates/core/src/auth/microsoft/authcode.rs index 16ce8f5..51ed053 100644 --- a/crates/core/src/auth/microsoft/authcode.rs +++ b/crates/core/src/auth/microsoft/authcode.rs @@ -173,11 +173,28 @@ pub(crate) async fn login( policy: AuthCodePolicy, present: F, ) -> AuthResult +where + F: FnOnce(&AuthCodePrompt), +{ + login_counting(client, endpoints, client_id, policy, None, present).await +} + +/// 同 [`login`],但把**绑定现场**的监听口次数记进 `counter`(M178 的取证计数)。 +pub(crate) async fn login_counting( + client: &Client, + endpoints: &MicrosoftEndpoints, + client_id: &str, + policy: AuthCodePolicy, + counter: Option<&std::sync::atomic::AtomicUsize>, + present: F, +) -> AuthResult 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( client: &Client, endpoints: &MicrosoftEndpoints, client_id: &str, policy: AuthCodePolicy, + counter: &std::sync::atomic::AtomicUsize, present: F, ) -> AuthResult 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)) } diff --git a/crates/core/src/auth/microsoft/callback.rs b/crates/core/src/auth/microsoft/callback.rs index 2e8a27c..400a6a3 100644 --- a/crates/core/src/auth/microsoft/callback.rs +++ b/crates/core/src/auth/microsoft/callback.rs @@ -66,11 +66,29 @@ impl CallbackServer { port: Option, attempts: u8, state: impl Into, + ) -> AuthResult { + 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, + attempts: u8, + state: impl Into, + counter: Option<&std::sync::atomic::AtomicUsize>, ) -> AuthResult { 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 diff --git a/crates/core/src/auth/microsoft/client_id.rs b/crates/core/src/auth/microsoft/client_id.rs index a8ebda7..67a1d32 100644 --- a/crates/core/src/auth/microsoft/client_id.rs +++ b/crates/core/src/auth/microsoft/client_id.rs @@ -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, - } - - 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。 - assert!(MISSING_CLIENT_ID_MESSAGE.contains("")); - } - - #[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 = 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; diff --git a/crates/core/src/auth/microsoft/client_id/tests.rs b/crates/core/src/auth/microsoft/client_id/tests.rs new file mode 100644 index 0000000..c9ccad6 --- /dev/null +++ b/crates/core/src/auth/microsoft/client_id/tests.rs @@ -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, +} + +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。 + assert!(MISSING_CLIENT_ID_MESSAGE.contains("")); +} + +#[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 { + 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 = 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::(); + 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\";" + )); +} diff --git a/crates/core/src/auth/microsoft/login.rs b/crates/core/src/auth/microsoft/login.rs index d474e2e..ad50f47 100644 --- a/crates/core/src/auth/microsoft/login.rs +++ b/crates/core/src/auth/microsoft/login.rs @@ -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 = 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), }) } } diff --git a/crates/core/src/auth/microsoft/preflight.rs b/crates/core/src/auth/microsoft/preflight.rs index dac1e6c..f2ef0c6 100644 --- a/crates/core/src/auth/microsoft/preflight.rs +++ b/crates/core/src/auth/microsoft/preflight.rs @@ -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)); diff --git a/crates/core/tests/auth/microsoft_msalogin.rs b/crates/core/tests/auth/microsoft_msalogin.rs index b48bfd9..141da54 100644 --- a/crates/core/tests/auth/microsoft_msalogin.rs +++ b/crates/core/tests/auth/microsoft_msalogin.rs @@ -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(); } diff --git a/crates/i18n/src/catalog/account.rs b/crates/i18n/src/catalog/account.rs index c902306..56bcdd1 100644 --- a/crates/i18n/src/catalog/account.rs +++ b/crates/i18n/src/catalog/account.rs @@ -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/"), diff --git a/crates/i18n/src/catalog/golden/tail2.rs b/crates/i18n/src/catalog/golden/tail2.rs index 577af2f..fab5ef2 100644 --- a/crates/i18n/src/catalog/golden/tail2.rs +++ b/crates/i18n/src/catalog/golden/tail2.rs @@ -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", "校验服务器"), diff --git a/crates/presenter/src/host/accounts.rs b/crates/presenter/src/host/accounts.rs index 3398d8f..e98095a 100644 --- a/crates/presenter/src/host/accounts.rs +++ b/crates/presenter/src/host/accounts.rs @@ -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::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 { + #[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> = 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 { + 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 里的固定文案(原文照旧只进日志),理由是 diff --git a/crates/presenter/src/host/accounts/tests.rs b/crates/presenter/src/host/accounts/tests.rs index e7679ac..ba5558e 100644 --- a/crates/presenter/src/host/accounts/tests.rs +++ b/crates/presenter/src/host/accounts/tests.rs @@ -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 { + 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:` 那一行,对不上就没法用用户回报的码定位。 ///