merge: origin/dev(1623f68)合入 M197 分支——解 golden/tail.rs 冲突
基线:合的是 **origin/dev**(`1623f68`)。`origin/main` 是 D41 的孤儿发布快照 (`d616d65` parents 为空),不参与合并。用 merge 而不是 rebase,保住三轮复审 clean 的 tip。 冲突只有一个:`crates/i18n/src/catalog/golden/tail.rs`(热点文件,多条 mission 都往这里加 key)。 解法(不重排别人的行、不改别人的译文): 1. 以 **dev 侧 398 行**为底稿(含其它 mission 后加的 key); 2. 把 M197 的 68 行(`SettingsJavaTooNew` … `SettingsJavaReasonInstallRootTooLong`) 整块插在 `SettingsJavaReasonWriteFailed` 之后、M174 助手域(`SettingsGroupAssistant`)之前 —— 即 `settings_java_entries` 那段之后,装配链的正确位置; 3. 两侧合起来 463 行会越过 400 行硬线,于是按既有先例把**M174 助手域起的那半截** (dev 的 260 行)整段纯搬移到 `golden/tail_ai.rs`:`tail.rs` 211 行 / `tail_ai.rs` 274 行, 链序 `golden → tail → tail_ai → tail2` 不变,逐字节不删不改; 4. 解冲突时发现 dev 的 golden 行还是 M197 之前的旧文案(`SettingsJavaEmptyBody` / `SettingsJavaAddHint` 的反引号、`SettingsJavaNeverScannedBody` 的旧说法),已把这三条的 金表行同步回域文件里的现行文案(`split_keeps_every_translation` 逐位置比对当场抓出来的)。 顺带(合并导致的越线):`crates/core/src/download/engine.rs` 两侧各 400 行、合并后 401 (我 +1 行 `kind: err.kind`,dev +1 行),把模块文档里那三行散文重排成两行(一字未删)→ 400 行。 同一次还把这文件里对 `MirrorTable::set_gh_proxy` 的旧描述改成 M193 的现实(那两个函数已被删)。 验证:fmt 0 · check(app-iced+core+i18n all-targets)0 · i18n 36 · engine_mock 8 · app-iced java 45 · core lib java 71 · setting 116 · app-iced lib 全量 832;全仓 file-size 守门脚本复制执行 → 超限 0。
This commit is contained in:
commit
6e80fd3563
283 files changed
+22601
-2188
No files matched your search
@@ -7,6 +7,7 @@
|
||||
//! - `microsoft_authcode`:授权码流(本地回调服务器 / 授权 URL 向量 / code 换链 / 403 诊断);
|
||||
//! - `microsoft_diagnostics`:非 2xx 正文诊断(JSON/HTML/空体分流)+ 失败日志落盘(M38);
|
||||
//! - `microsoft_refresh`:refresh 轮换、profile 变脸防线、validate 语义、404 二义;
|
||||
//! - `microsoft_msalogin`:回环重定向 URI 没登记时的回退(M178:默认设备码、不白开监听口);
|
||||
//! - `authlib_api`:Yggdrasil 四动作的报文级对接。
|
||||
|
||||
mod support;
|
||||
@@ -16,6 +17,7 @@ mod authlib_api;
|
||||
mod microsoft_authcode;
|
||||
mod microsoft_diagnostics;
|
||||
mod microsoft_flow;
|
||||
mod microsoft_msalogin;
|
||||
mod microsoft_refresh;
|
||||
mod offline_uuid;
|
||||
mod store_separation;
|
||||
@@ -0,0 +1,274 @@
|
||||
//! M178 守卫:微软登录在**官方产物**(应用注册里没有任何重定向 URI)上的行为。
|
||||
//!
|
||||
//! 用户 2026-11-08 实测的现象:GUI 直接走授权码流 → 授权端点回
|
||||
//! `invalid_request ... 'redirect_uri' is not valid` → 浏览器里一张 AAD 原始错误页,
|
||||
//! 启动器干等超时 ⇒ 官方产物上「微软登录」整体不可用。
|
||||
//!
|
||||
//! 本文件钉四件事(都是能给答案「能不能变红」的):
|
||||
//!
|
||||
//! | 守卫 | 用例 | 抓到什么 |
|
||||
//! |---|---|---|
|
||||
//! | ① 回退且提示可读 | [`unregistered_redirect_falls_back_to_the_device_code`] | 回退没发生 / 原因码丢了(页面就只能画原文) |
|
||||
//! | ② 不白开监听口 | 同上 + [`device_code_default_never_opens_a_listening_port`] | 预检被挪到绑定之后(`listeners_opened` 会变 1) |
|
||||
//! | ③ 注册过就仍能走浏览器 | [`registered_loopback_still_runs_the_auth_code_flow`] | 把加速路彻底焊死(一刀切回退) |
|
||||
//! | ④ 取消不被偷偷换流 | [`user_cancel_on_the_browser_page_does_not_fall_back`] | 取消被当成失败的后退理由 |
|
||||
//!
|
||||
//! 另:**真微软登录没有在这里验**(要真人拿自己账号点一次),这里只证错误处理与回退路径。
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use unlucky_core::auth::microsoft::{
|
||||
AuthCodePolicy, BrowserFallbackReason, MicrosoftAuth, MicrosoftEndpoints,
|
||||
};
|
||||
use unlucky_core::auth::AuthError;
|
||||
|
||||
use crate::support::{full_routes, AuthMock, MockReply};
|
||||
|
||||
const CLIENT_ID: &str = "test-client-id";
|
||||
/// 授权端点对**没登记**的回环 redirect_uri 的应答(2026-11-08 实测那张页面的形状)。
|
||||
const INVALID_REDIRECT_PAGE: &str =
|
||||
"error=invalid_request&error_description=The+provided+value+for+the+input+\
|
||||
parameter+%27redirect_uri%27+is+not+valid.+The+expected+value+is+a+URI+which+matches+a+\
|
||||
+redirect+URI+registered+for+this+client+application.";
|
||||
/// 授权端点对**已登记**回环 URI 的正常应答(登录页,无拒绝字样)。
|
||||
const SIGN_IN_PAGE: &str = "<!DOCTYPE html><html><head><title>Sign in to your account</title>\
|
||||
</head><body><form id=\"loginForm\"></form></body></html>";
|
||||
|
||||
/// 设备码面板里看到的 `(用户码, 回退原因码)`(跨闭包共享)。
|
||||
type Seen = Arc<Mutex<Option<(String, Option<BrowserFallbackReason>)>>>;
|
||||
|
||||
fn policy(state: &str) -> AuthCodePolicy {
|
||||
AuthCodePolicy {
|
||||
callback_timeout: Duration::from_millis(2_000),
|
||||
bind_attempts: 1,
|
||||
port: None,
|
||||
state: Some(state.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 路由表 + 定向的 `/authorize` 应答(预检打的就是这个 path)。
|
||||
fn routes_with_authorize(reply: MockReply) -> Vec<(&'static str, Vec<MockReply>)> {
|
||||
let mut routes = full_routes();
|
||||
routes.push(("/authorize", vec![reply]));
|
||||
routes
|
||||
}
|
||||
|
||||
fn auth(base: &str, browser_first: bool) -> MicrosoftAuth {
|
||||
MicrosoftAuth::new(CLIENT_ID)
|
||||
.expect("构造")
|
||||
.with_endpoints(MicrosoftEndpoints::all_on(base))
|
||||
.with_poll_policy(crate::support::microsoft::fast_poll())
|
||||
.with_authcode_policy(policy("state-abc"))
|
||||
.with_browser_first(browser_first)
|
||||
}
|
||||
|
||||
/// 守卫 ① + ②:没登记回环 → 回退设备码、原因码是「没登记」、**监听口一次都不开**。
|
||||
#[tokio::test]
|
||||
async fn unregistered_redirect_falls_back_to_the_device_code() {
|
||||
let mock = AuthMock::start(routes_with_authorize(MockReply::text(
|
||||
400,
|
||||
INVALID_REDIRECT_PAGE,
|
||||
)))
|
||||
.await;
|
||||
|
||||
let seen: Seen = Arc::new(Mutex::new(None));
|
||||
let seen_for_device = Arc::clone(&seen);
|
||||
let outcome = auth(&mock.base(), true)
|
||||
.browser_then_device_login(
|
||||
|_prompt| panic!("没登记回环时**不许**弹浏览器(用户只会看到 AAD 错误页)"),
|
||||
move |prompt, fallback| {
|
||||
*seen_for_device.lock().unwrap() =
|
||||
Some((prompt.user_code.clone(), fallback.map(|note| note.reason)));
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("必须回退到设备码并成功");
|
||||
|
||||
// ① 回退确实发生,且宿主拿到的是**结构化原因码**(页面据此出可读文案,而不是画原文)
|
||||
let (user_code, reason) = seen.lock().unwrap().clone().expect("设备码必须被展示");
|
||||
assert_eq!(user_code, "ABCD-1234");
|
||||
assert_eq!(reason, Some(BrowserFallbackReason::RedirectNotRegistered));
|
||||
assert_eq!(
|
||||
reason.map(BrowserFallbackReason::code),
|
||||
Some("redirect_not_registered")
|
||||
);
|
||||
assert_eq!(outcome.session.profile_name, "Steve");
|
||||
let note = outcome.browser.expect("必须带回退说明");
|
||||
assert_eq!(note.reason, BrowserFallbackReason::RedirectNotRegistered);
|
||||
// 原文只进日志:说明里的 detail 有内容,但页面只拿码(i18n 在 app-iced 侧)。
|
||||
assert!(!note.detail.is_empty());
|
||||
|
||||
// ② 预检在绑定之前:这次登录一个监听口都没开(用户机上不会出现「占了端口却没人回调」)
|
||||
assert_eq!(
|
||||
outcome.listeners_opened, 0,
|
||||
"没登记回环时不许绑本地端口(预检必须在绑定之前)"
|
||||
);
|
||||
// ③ **报文级证据**:预检真的发出去了(授权端点收到 1 次 GET),而不是被跳过。
|
||||
// 这条也是复审 P1-1 的回执:只要生产路径把 `browser_first` 置位,这条 GET 就会发生。
|
||||
assert_eq!(
|
||||
mock.bodies_on("/authorize").len(),
|
||||
1,
|
||||
"浏览器优先路必须真的发一次回环注册预检"
|
||||
);
|
||||
mock.stop();
|
||||
}
|
||||
|
||||
/// 守卫 ②(默认路那一半):设备码是默认,浏览器流根本没被启用 ⇒ 监听计数不涨。
|
||||
#[tokio::test]
|
||||
async fn device_code_default_never_opens_a_listening_port() {
|
||||
let mock = AuthMock::start(full_routes()).await;
|
||||
let device_seen = Arc::new(Mutex::new(None));
|
||||
let device_flag = Arc::clone(&device_seen);
|
||||
|
||||
let outcome = auth(&mock.base(), false)
|
||||
.browser_then_device_login(
|
||||
|_| panic!("默认路不许碰浏览器回调"),
|
||||
move |prompt, fallback| {
|
||||
*device_flag.lock().unwrap() = Some(prompt.user_code.clone());
|
||||
assert!(fallback.is_none(), "没走浏览器就不该有回退说明");
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("设备码登录成功");
|
||||
|
||||
assert_eq!(
|
||||
device_seen.lock().unwrap().clone().as_deref(),
|
||||
Some("ABCD-1234")
|
||||
);
|
||||
assert!(outcome.browser.is_none());
|
||||
assert_eq!(
|
||||
outcome.session.profile_id,
|
||||
crate::support::microsoft::PROFILE_ID
|
||||
);
|
||||
assert_eq!(outcome.listeners_opened, 0, "设备码流不许开任何本地监听口");
|
||||
// 授权端点一次都没被请求(那正是会返回 invalid_request 的那一跳)
|
||||
assert!(mock.bodies_on("/authorize").is_empty());
|
||||
mock.stop();
|
||||
}
|
||||
|
||||
/// 守卫 ③:回环**真的登记了**的时候,加速路必须还能走(否则就是把功能一刀切焊死)。
|
||||
#[tokio::test]
|
||||
async fn registered_loopback_still_runs_the_auth_code_flow() {
|
||||
let mock = AuthMock::start(routes_with_authorize(MockReply::html(200, SIGN_IN_PAGE))).await;
|
||||
|
||||
let outcome = auth(&mock.base(), true)
|
||||
.browser_then_device_login(
|
||||
|prompt| {
|
||||
let redirect = prompt.redirect_uri.clone();
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
let url = format!("{redirect}?code=the-auth-code&state=state-abc");
|
||||
let response = reqwest::get(&url).await.expect("浏览器回调");
|
||||
assert_eq!(response.status(), 200);
|
||||
});
|
||||
},
|
||||
|_prompt, _note| panic!("登记了回环就不该回退设备码"),
|
||||
)
|
||||
.await
|
||||
.expect("授权码流应当走通");
|
||||
|
||||
assert_eq!(outcome.session.profile_name, "Steve");
|
||||
assert!(outcome.browser.is_none(), "走通了就不该有回退说明");
|
||||
assert_eq!(
|
||||
outcome.listeners_opened, 1,
|
||||
"登记了回环就应当真的绑端口(否则加速路名存实亡)"
|
||||
);
|
||||
// 换 token 用的是**授权码** grant(不是设备码那条)。
|
||||
let token_bodies = mock.bodies_on("/token");
|
||||
assert_eq!(token_bodies.len(), 1, "{token_bodies:?}");
|
||||
assert!(token_bodies[0].contains("grant_type=authorization_code"));
|
||||
mock.stop();
|
||||
}
|
||||
|
||||
/// 守卫 ④:用户自己取消**不算**失败——不许偷偷换一条流继续要码。
|
||||
#[tokio::test]
|
||||
async fn user_cancel_on_the_browser_page_does_not_fall_back() {
|
||||
let mock = AuthMock::start(routes_with_authorize(MockReply::html(200, SIGN_IN_PAGE))).await;
|
||||
let error = auth(&mock.base(), true)
|
||||
.browser_then_device_login(
|
||||
|prompt| {
|
||||
let redirect = prompt.redirect_uri.clone();
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
let url = format!("{redirect}?error=access_denied&state=state-abc");
|
||||
let _ = reqwest::get(&url).await;
|
||||
});
|
||||
},
|
||||
|_prompt, _note| panic!("用户取消后不许自动换流"),
|
||||
)
|
||||
.await
|
||||
.expect_err("取消应当是最终结果");
|
||||
assert_eq!(error, AuthError::Canceled);
|
||||
assert!(
|
||||
mock.bodies_on("/devicecode").is_empty(),
|
||||
"取消之后不该再去要设备码"
|
||||
);
|
||||
mock.stop();
|
||||
}
|
||||
|
||||
/// 预检问不出结论(连不上)也要回退,且原因码是「没确认」而不是「没登记」。
|
||||
#[tokio::test]
|
||||
async fn an_unreachable_precheck_falls_back_with_the_unverified_reason() {
|
||||
// 只有授权端点指到一个必然关闭的端口:预检必然失败,而设备码那条链照常打 mock。
|
||||
let mock = AuthMock::start(full_routes()).await;
|
||||
let mut endpoints = MicrosoftEndpoints::all_on(&mock.base());
|
||||
endpoints.authorize = "http://127.0.0.1:1/authorize".to_string();
|
||||
let reason = Arc::new(Mutex::new(None));
|
||||
let reason_flag = Arc::clone(&reason);
|
||||
let outcome = MicrosoftAuth::new(CLIENT_ID)
|
||||
.expect("构造")
|
||||
.with_endpoints(endpoints)
|
||||
.with_poll_policy(crate::support::microsoft::fast_poll())
|
||||
.with_authcode_policy(policy("state-abc"))
|
||||
.with_browser_first(true)
|
||||
.browser_then_device_login(
|
||||
|_prompt| panic!("预检失败时不许弹浏览器"),
|
||||
move |_prompt, fallback| {
|
||||
*reason_flag.lock().unwrap() = fallback.map(|note| note.reason);
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("预检失败不该拦住设备码");
|
||||
assert_eq!(
|
||||
*reason.lock().unwrap(),
|
||||
Some(BrowserFallbackReason::PreflightUnavailable)
|
||||
);
|
||||
assert_eq!(
|
||||
outcome.browser.map(|note| note.reason),
|
||||
Some(BrowserFallbackReason::PreflightUnavailable)
|
||||
);
|
||||
assert_eq!(outcome.session.profile_name, "Steve");
|
||||
assert_eq!(outcome.listeners_opened, 0, "预检失败时不该绑端口");
|
||||
mock.stop();
|
||||
}
|
||||
|
||||
/// 与上两例配对:`/authorize` 回 200 但正文是 AADSTS50011 的拒绝页,也不许当「已登记」。
|
||||
#[tokio::test]
|
||||
async fn aadsts50011_page_is_not_treated_as_registered() {
|
||||
let mock = AuthMock::start(routes_with_authorize(MockReply::text(
|
||||
200,
|
||||
"AADSTS50011: The redirect URI specified in the request does not match the redirect URIs configured",
|
||||
)))
|
||||
.await;
|
||||
let note = Arc::new(Mutex::new(None));
|
||||
let note_flag = Arc::clone(¬e);
|
||||
let outcome = auth(&mock.base(), true)
|
||||
.browser_then_device_login(
|
||||
|_| panic!("AADSTS50011 的页面不许当已登记"),
|
||||
move |_prompt, fallback| {
|
||||
*note_flag.lock().unwrap() = fallback.map(|note| note.reason);
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("回退设备码后应当成功");
|
||||
assert_eq!(
|
||||
*note.lock().unwrap(),
|
||||
Some(BrowserFallbackReason::RedirectNotRegistered)
|
||||
);
|
||||
assert_eq!(
|
||||
outcome.browser.map(|note| note.reason),
|
||||
Some(BrowserFallbackReason::RedirectNotRegistered)
|
||||
);
|
||||
mock.stop();
|
||||
}
|
||||
@@ -122,8 +122,13 @@ fn pinned_or_disabled_gh_proxy_is_respected() {
|
||||
.expect("自定义前缀未生效");
|
||||
assert_eq!(proxy.url, format!("https://proxy.example/{GITHUB_URL}"));
|
||||
|
||||
// 清单给设置做默认值。
|
||||
assert_eq!(table.default_gh_proxy(), Some("https://gh-proxy.com/"));
|
||||
// 「默认前缀」的唯一真相源是**地区档案**(M193:`MirrorTable::set_gh_proxy` /
|
||||
// `default_gh_proxy` 已删除——它们改/读的是表里的前缀目录,而 `expand()` 读档案,
|
||||
// 属于「看起来能改、其实没人读」。这条断言从此钉在档案上)。
|
||||
assert_eq!(
|
||||
RegionProfile::mirror_first(0).gh_proxy.as_deref(),
|
||||
Some(unlucky_core::download::region::DEFAULT_GH_PROXY)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in new issue
Block a user