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

+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();
}