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:
1 parent
f82bd395f8
commit
a40b3a5940
15 files changed
+745
-294
No files matched your search
@@ -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
|
||||
|
||||
@@ -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\";"
|
||||
));
|
||||
}
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user