两条 M230 台账判定「真开着」的测试债: 1. accounts 测试缝的 key 是 Arc::as_ptr(&job.cancelled)。job 一 drop,控制块 立刻可能分给下一个 LoginJob,于是新 job 读到旧 job 的记录(ABA),并行跑的 「设备码入口不许置位 browser_first」因此偶发假红。改成 LoginJob 自带的 稳定身份 JobId(cfg(test) 字段,进程内单调递增、永不复用);ENTRIES 从 Vec<(usize,bool)> 改成带 Weak 的 Entry:record 时 retain 收走已 drop 的 job, 同一 job 覆写成「最后一次」(消掉 mode_for 取第一条的歧义)。 反向证据:身份退回堆地址 ⇒ the_test_seam_does_not_reuse_a_heap_address_as_identity 红(mode_for(&younger) 读到 Some(true))。 2. probe 的 silent_server_times_out 断 `elapsed < 3s`,跟真实预算(400ms)无关。 改成 预算 + SLACK(400ms + 1s):抓「预算没生效」(那要等假服务器睡满 5s), 放开毫秒级精度;并新增两条判据——detail 里的时限就是本次预算(证明走的是 预算分支)、假服务器真的收到 1 次请求(不是没连上就早退)。 反向证据:摘掉 tokio::time::timeout ⇒ 5.08s / unreachable 红;把计时值硬编码 3s ⇒ 3.006s > 1.4s 红。
359 lines
14 KiB
Rust
359 lines
14 KiB
Rust
//! 账户:三轨统一列表(只读公开字段)+ 离线新增 + 删除/切换 + 微软登录(两条流)。
|
||
//!
|
||
//! 凭据红线(D9):`Credentials` / `MicrosoftSession` 只在本模块与 `launch.rs` 里出现,
|
||
//! 出口一律是 [`AccountView`] / [`DeviceLoginView`]。
|
||
//!
|
||
//! 两条登录流(M178 改了默认):**设备码是默认**([`start_login`]:不依赖任何应用注册项),
|
||
//! 授权码([`start_browser_login`],浏览器 + 本地回调)降级成「用户显式要求才试」的加速路,
|
||
//! 且带回环注册预检 + 失败自动回退设备码。除了「怎么拿到 MS access_token」之外完全共用:
|
||
//! 同一个取消槽位、同一段账户入库、同一个刷新链。
|
||
//!
|
||
//! 取消语义:轮询/等回调都跑在 `core` 里、没有取消钩子,所以取消只把面板收回;线程收尾时
|
||
//! 若发现「已取消」就不入库(避免用户点了取消却凭空多出一个账户)。
|
||
//!
|
||
//! M132:缺 client id 只在**登录入口**失败(`auth_failure`),所以说明接在账户页的登录面板,
|
||
//! 不接启动流程——没配 id 的发布版根本建不出微软账户,启动那条路用户撞不到。
|
||
//!
|
||
//! M236 拆分:两条流的编排搬到 `accounts/login.rs`(纯搬移,路径靠下面的 `pub use` 保住),
|
||
//! 本文件只留列表/增删/句柄/测试缝——原文件正好 400 行零余量,测试缝换身份前必须先拆。
|
||
|
||
use std::sync::atomic::{AtomicBool, Ordering};
|
||
use std::sync::Arc;
|
||
|
||
use unlucky_core::auth::account::Credentials;
|
||
use unlucky_core::auth::microsoft::{now_ms, BrowserFallbackReason};
|
||
use unlucky_core::auth::offline::offline_meta;
|
||
use unlucky_core::auth::{AccountStore, AuthError};
|
||
|
||
use crate::dto::CoreEvent;
|
||
use crate::dto::{
|
||
valid_offline_name, AccountKind, AccountNotice, AccountNoticeCode, AccountView,
|
||
BrowserLoginNote, LoginFailure,
|
||
};
|
||
|
||
use super::env::HostEnv;
|
||
|
||
/// 第三方轨(服务发现 / 密码登录 / 皮肤抓取)。
|
||
pub mod authlib;
|
||
|
||
mod login;
|
||
|
||
mod refresh;
|
||
|
||
mod failure;
|
||
|
||
pub use failure::AccountFailure;
|
||
|
||
pub use login::{start_browser_login, start_login};
|
||
|
||
pub use authlib::{fetch_skin, spawn_probe, start_login as start_authlib_login};
|
||
|
||
use failure::classify;
|
||
|
||
impl super::CoreHost {
|
||
/// 推一条账户页横幅(一次性操作结果;页面渲染后由用户关掉)。
|
||
pub(super) fn notice(&mut self, code: AccountNoticeCode, subject: &str, detail: &str) {
|
||
self.send(CoreEvent::AccountNoticeChanged(Some(AccountNotice::new(
|
||
code, subject, detail,
|
||
))));
|
||
}
|
||
|
||
/// 账户写操作失败:**原文进日志**,页面只拿结构化码 + 主题名。
|
||
pub(super) fn notice_failure(&mut self, failure: AccountFailure, subject: &str) {
|
||
eprintln!(
|
||
"[accounts] 操作失败({},subject={subject}):{}",
|
||
failure.code.code(),
|
||
failure.detail
|
||
);
|
||
self.notice(failure.code, subject, &failure.detail);
|
||
}
|
||
}
|
||
|
||
/// 账户列表(公开字段)。
|
||
pub fn accounts(env: &HostEnv, active: Option<&str>) -> Vec<AccountView> {
|
||
let store = AccountStore::open(env.layout.auth_data());
|
||
store
|
||
.accounts()
|
||
.iter()
|
||
.map(|meta| AccountView {
|
||
id: meta.id.as_str().to_string(),
|
||
name: meta.display_name().to_string(),
|
||
kind: AccountKind::parse(meta.kind.as_str()),
|
||
profile_id: meta.profile_id.clone(),
|
||
login_name: meta.login_name.clone().unwrap_or_default(),
|
||
created: display_ms(meta.created_at_ms),
|
||
active: active.is_some_and(|id| id == meta.id.as_str()),
|
||
server_url: meta.server_url.clone().unwrap_or_default(),
|
||
// 皮肤是网络产物:列表先给 `None`,host 抓到之后按账户 id 盖回去(见 `CoreHost`)
|
||
skin: None,
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// 毫秒时间戳 → `2026-10-31 20:12`(UTC;账户列表只用来排序/辨认)。
|
||
pub fn display_ms(ms: u64) -> String {
|
||
if ms == 0 {
|
||
return String::new();
|
||
}
|
||
let total_secs = ms / 1000;
|
||
let days = total_secs / 86_400;
|
||
let rem = total_secs % 86_400;
|
||
let (year, month, day) = civil_from_days(i64::try_from(days).unwrap_or(0));
|
||
format!(
|
||
"{year:04}-{month:02}-{day:02} {:02}:{:02}",
|
||
rem / 3_600,
|
||
(rem % 3_600) / 60
|
||
)
|
||
}
|
||
|
||
/// Howard Hinnant 的 `civil_from_days`(1970-01-01 为 0)。
|
||
fn civil_from_days(days: i64) -> (i64, u32, u32) {
|
||
let z = days + 719_468;
|
||
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
|
||
let doe = z - era * 146_097;
|
||
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
|
||
let y = yoe + era * 400;
|
||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||
let mp = (5 * doy + 2) / 153;
|
||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||
(if m <= 2 { y + 1 } else { y }, m as u32, d as u32)
|
||
}
|
||
|
||
/// 加一个离线账户。
|
||
///
|
||
/// # Errors
|
||
///
|
||
/// 名字不合法、同名冲突、落盘失败时返回 [`AccountFailure`](带结构化码)。
|
||
pub fn add_offline(env: &HostEnv, name: &str) -> Result<String, AccountFailure> {
|
||
let name = name.trim();
|
||
if !valid_offline_name(name) {
|
||
return Err(AccountFailure {
|
||
code: AccountNoticeCode::OfflineNameInvalid,
|
||
detail: format!("离线名字必须是 3–16 位字母/数字/下划线,收到 {name:?}"),
|
||
});
|
||
}
|
||
let meta = offline_meta(name, now_ms())
|
||
.map_err(|error| AccountFailure::classify(error, AccountNoticeCode::OfflineNameInvalid))?;
|
||
let display = meta.display_name().to_string();
|
||
let mut store = AccountStore::open(env.layout.auth_data());
|
||
store
|
||
.insert(meta, Credentials::Offline)
|
||
.map_err(|error| AccountFailure::classify(error, AccountNoticeCode::AccountExists))?;
|
||
store
|
||
.save()
|
||
.map_err(|error| AccountFailure::classify(error, AccountNoticeCode::StoreFailed))?;
|
||
Ok(display)
|
||
}
|
||
|
||
/// 删除账户(按 id 或名字)。
|
||
///
|
||
/// # Errors
|
||
///
|
||
/// 找不到、落盘失败时返回 [`AccountFailure`]。
|
||
pub fn remove(env: &HostEnv, key: &str) -> Result<String, AccountFailure> {
|
||
let mut store = AccountStore::open(env.layout.auth_data());
|
||
let meta = store
|
||
.remove(key)
|
||
.map_err(|error| AccountFailure::classify(error, AccountNoticeCode::NotFound))?;
|
||
store
|
||
.save()
|
||
.map_err(|error| AccountFailure::classify(error, AccountNoticeCode::StoreFailed))?;
|
||
Ok(meta.display_name().to_string())
|
||
}
|
||
|
||
/// 微软登录任务句柄(取消标志 + 账户名回执;两条流共用)。
|
||
#[derive(Debug, Clone)]
|
||
pub struct LoginJob {
|
||
/// 用户点了取消(线程收尾时据此丢弃结果)
|
||
pub cancelled: Arc<AtomicBool>,
|
||
/// 测试缝的**稳定身份**(见 `test_seam`):进程内单调递增、永不复用。
|
||
///
|
||
/// 只在测试构建里存在——发布产物不需要它。它会存在,是因为「这条记录属于哪个 job」
|
||
/// 不能用堆地址回答:地址是会被复用的资源,不是身份(M236 的 ABA 假红)。
|
||
#[cfg(test)]
|
||
id: JobId,
|
||
}
|
||
|
||
impl Default for LoginJob {
|
||
/// 与 [`LoginJob::new`] 同一个东西:`id` 不许走 `Default` 的零值,否则两个 job 撞身份。
|
||
fn default() -> Self {
|
||
Self::new()
|
||
}
|
||
}
|
||
|
||
impl LoginJob {
|
||
/// 新建一个未取消的句柄。
|
||
pub fn new() -> Self {
|
||
Self {
|
||
cancelled: Arc::new(AtomicBool::new(false)),
|
||
#[cfg(test)]
|
||
id: JobId::next(),
|
||
}
|
||
}
|
||
|
||
/// 取消。
|
||
pub fn cancel(&self) {
|
||
self.cancelled.store(true, Ordering::SeqCst);
|
||
}
|
||
|
||
/// 是否已取消。
|
||
pub fn is_cancelled(&self) -> bool {
|
||
self.cancelled.load(Ordering::SeqCst)
|
||
}
|
||
}
|
||
|
||
/// [`LoginJob`] 的稳定身份:进程内单调递增,**永不复用**。
|
||
///
|
||
/// 为什么不用 `Arc::as_ptr(&job.cancelled)`:job 一 drop,它的控制块就可以被下一个
|
||
/// `LoginJob` 复用,于是「新 job」会读到「旧 job」记下的模式(ABA)——并行跑的用例因此
|
||
/// 偶发假红(实测形态:设备码入口读到别人的 `browser_first=true`)。
|
||
#[cfg(test)]
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
struct JobId(u64);
|
||
|
||
#[cfg(test)]
|
||
impl JobId {
|
||
/// 取下一个身份。`Relaxed` 够用:只要求唯一与单调,不要求跨线程的可见性顺序。
|
||
fn next() -> Self {
|
||
use std::sync::atomic::AtomicU64;
|
||
|
||
static NEXT: AtomicU64 = AtomicU64::new(1);
|
||
Self(NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed))
|
||
}
|
||
}
|
||
|
||
/// 回退原因码 → 页面说明码(两边各留一个枚举:presenter 的 DTO 不依赖 core 的中文诊断)。
|
||
fn browser_note(reason: BrowserFallbackReason) -> BrowserLoginNote {
|
||
match reason {
|
||
BrowserFallbackReason::RedirectNotRegistered => BrowserLoginNote::RedirectNotRegistered,
|
||
BrowserFallbackReason::PreflightUnavailable => BrowserLoginNote::PreflightUnavailable,
|
||
BrowserFallbackReason::AttemptFailed => BrowserLoginNote::AttemptFailed,
|
||
}
|
||
}
|
||
|
||
/// 微软登录的两个入口(决定 `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)
|
||
}
|
||
}
|
||
|
||
/// 测试缝:记下生产入口**实际请求**的模式,让守卫能钉住「按钮 → browser_first=true」这条线。
|
||
///
|
||
/// 为什么需要它:两条入口在没有 client id 的测试构建里都会在网络之前早退,拿不到
|
||
/// `MicrosoftAuth` 实例去问 `browser_first()`;而 P1-1 的 bug 恰恰在**调用点**。这里在被调用
|
||
/// 的一刻就把模式记下来,把调用点变成可断言的事实。
|
||
///
|
||
/// 为什么按 job 分桶:同一进程里别的用例也在调这两个入口,一个全局「最后一次」记录会串台
|
||
/// (实测过:并行跑时读到别人的 Browser ⇒ 假红)。
|
||
///
|
||
/// **身份是 [`JobId`],不是堆地址**(M236):`Arc::as_ptr` 那种做法会被地址复用骗到——job A
|
||
/// drop 之后控制块立刻分给 job B,B 于是读到 A 的记录(ABA 假红)。
|
||
#[cfg(test)]
|
||
mod test_seam {
|
||
use std::sync::atomic::AtomicBool;
|
||
use std::sync::{Arc, Mutex, Weak};
|
||
|
||
use super::{JobId, LoginJob, MsaEntry};
|
||
|
||
/// 表里的一条记录。
|
||
///
|
||
/// `alive` 只用来回收「job 已经 drop」的条目,**不参与身份判定**(身份是 `job`)。它同时把
|
||
/// 控制块钉住:`Weak` 还在时控制块不会释放,`strong_count() == 0` 因此读得准。
|
||
pub(super) struct Entry {
|
||
job: JobId,
|
||
alive: Weak<AtomicBool>,
|
||
browser_first: bool,
|
||
}
|
||
|
||
impl Entry {
|
||
pub(super) fn new(job: JobId, cancelled: &Arc<AtomicBool>, browser_first: bool) -> Self {
|
||
Self {
|
||
job,
|
||
alive: Arc::downgrade(cancelled),
|
||
browser_first,
|
||
}
|
||
}
|
||
|
||
/// job 还活着吗(`false` ⇒ 这条只是还没被收走的死条目)。
|
||
pub(super) fn is_alive(&self) -> bool {
|
||
self.alive.strong_count() > 0
|
||
}
|
||
|
||
pub(super) fn job(&self) -> JobId {
|
||
self.job
|
||
}
|
||
|
||
pub(super) fn browser_first(&self) -> bool {
|
||
self.browser_first
|
||
}
|
||
}
|
||
|
||
static ENTRIES: Mutex<Vec<Entry>> = Mutex::new(Vec::new());
|
||
|
||
/// 写一条记录:**同一个 job 只留最后一次**,顺手收走已 drop 的 job。
|
||
///
|
||
/// 「多条时取第一条」的歧义在这里被消掉:表里每个 job 恒为一条,所以 [`mode_for`] 取到的
|
||
/// 那条就是它最后一次请求的模式。收死条目的 `retain` 也在这里——表只随活 job 增长。
|
||
pub(super) fn remember(entries: &mut Vec<Entry>, entry: Entry) {
|
||
entries.retain(Entry::is_alive);
|
||
match entries.iter_mut().find(|known| known.job == entry.job) {
|
||
Some(slot) => slot.browser_first = entry.browser_first,
|
||
None => entries.push(entry),
|
||
}
|
||
}
|
||
|
||
/// 生产入口被调用时记一笔。
|
||
pub(super) fn record(job: &LoginJob, entry: MsaEntry) {
|
||
let mut entries = ENTRIES.lock().unwrap_or_else(|error| error.into_inner());
|
||
remember(
|
||
&mut entries,
|
||
Entry::new(job.id, &job.cancelled, entry.browser_first()),
|
||
);
|
||
}
|
||
|
||
/// 这个 job 的生产构造请求的模式(`None` = 还没调用过)。
|
||
pub(super) fn mode_for(job: &LoginJob) -> Option<bool> {
|
||
let entries = ENTRIES.lock().unwrap_or_else(|error| error.into_inner());
|
||
entries
|
||
.iter()
|
||
.find(|known| known.job == job.id)
|
||
.map(Entry::browser_first)
|
||
}
|
||
|
||
/// 造一个「共用同一个取消槽、但确定是另一个 job」的句柄(ABA 用例的构造件)。
|
||
///
|
||
/// 两个不同 job 指向同一个控制块:这正是 `Arc::as_ptr` 做 key 时无法区分的情形。
|
||
pub(super) fn job_around(cancelled: &Arc<AtomicBool>) -> LoginJob {
|
||
LoginJob {
|
||
cancelled: Arc::clone(cancelled),
|
||
id: JobId::next(),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// `msa_auth` 失败 → 原因码。
|
||
///
|
||
/// **只有「两处都没配 client id」单独成码**(M132):`from_env` 的 `Unsupported` 是它唯一的
|
||
/// 产出点,且这条路径零网络。上屏的正文是 catalog 里的固定文案(原文照旧只进日志),理由是
|
||
/// 这一类的原因就是用户的下一步(设 `ULL_MSA_CLIENT_ID`);其余失败仍走 [`classify`]。
|
||
fn auth_failure(error: &AuthError) -> LoginFailure {
|
||
match error {
|
||
AuthError::Unsupported(_) => LoginFailure::MsaNotConfigured,
|
||
other => classify(other),
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests;
|