b48293fb1f
- GUI owner 重装与清除改为按 authority 快照重定基准,保持原生计数与渲染层认知一致 - 注释写明 epoch 交接的授权前提,以及写入单调性由渲染层 reserve 与 durable session revision 保证 - 同主体换凭据的重装保持身份代次,在途 operation 的冻结会话继续有效 - 补充 epoch 重装与换主体失效的回归用例,替换原先错误假设原生计数器单调的用例
987 lines
37 KiB
Rust
987 lines
37 KiB
Rust
use serde::Deserialize;
|
||
use std::fs::{self, OpenOptions};
|
||
use std::io::Read;
|
||
use std::path::{Path, PathBuf};
|
||
use std::sync::{Mutex, OnceLock};
|
||
|
||
/// Debug-only fixture hook used by the deterministic AGC E2E. The hook takes
|
||
/// a path, rather than credentials on argv, so a child Runner can inherit the
|
||
/// test identity without putting the bearer token in process listings.
|
||
pub(crate) const PLATFORM_SESSION_FIXTURE_ENV: &str = "GENARRATIVE_AGC_PLATFORM_SESSION_FIXTURE";
|
||
const PLATFORM_SESSION_FIXTURE_SCHEMA_VERSION: &str = "genarrative-agc-platform-session-fixture.v1";
|
||
const PLATFORM_SESSION_FIXTURE_MAX_BYTES: u64 = 16 * 1024;
|
||
|
||
/// 平台会话快照 = 身份(登录主体 + 服务 origin)+ 凭据(当前 access token)。
|
||
///
|
||
/// `identity_generation` 只在登录主体、服务 origin 或登出状态变化时推进;同一身份的
|
||
/// access token 轮换(长回合保活、401 续期、同账号重新登录)必须保持它不变。
|
||
/// `revision` 只用于 native 写入顺序判定,防止迟到 install / clear 复活旧状态,
|
||
/// 不表达身份归属。
|
||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||
pub(crate) struct PlatformSessionSnapshot {
|
||
pub(crate) user_id: String,
|
||
pub(crate) access_token: String,
|
||
pub(crate) api_base_url: String,
|
||
pub(crate) identity_generation: u64,
|
||
pub(crate) revision: u64,
|
||
}
|
||
|
||
/// 冻结会话的身份判据。
|
||
///
|
||
/// 只包含登录主体、服务 origin 和身份代次,不包含 token 字节:同一身份的凭据轮换
|
||
/// 不得让在途生成、编辑、上传、确认或下载 operation 失效;换号、退出或 origin
|
||
/// 变化必须让它失配。
|
||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||
pub(crate) struct PlatformSessionIdentity {
|
||
pub(crate) user_id: String,
|
||
pub(crate) api_base_url: String,
|
||
pub(crate) identity_generation: u64,
|
||
}
|
||
|
||
impl PlatformSessionSnapshot {
|
||
pub(crate) fn identity(&self) -> PlatformSessionIdentity {
|
||
PlatformSessionIdentity {
|
||
user_id: self.user_id.clone(),
|
||
api_base_url: self.api_base_url.clone(),
|
||
identity_generation: self.identity_generation,
|
||
}
|
||
}
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||
pub(crate) enum EditorApiMode {
|
||
PlatformAccount,
|
||
ExternalDeveloper,
|
||
}
|
||
|
||
pub(crate) fn editor_api_mode_for_build(
|
||
debug_assertions: bool,
|
||
external_developer_release_feature: bool,
|
||
) -> EditorApiMode {
|
||
if !debug_assertions && external_developer_release_feature {
|
||
EditorApiMode::ExternalDeveloper
|
||
} else {
|
||
EditorApiMode::PlatformAccount
|
||
}
|
||
}
|
||
|
||
pub(crate) fn editor_api_mode() -> EditorApiMode {
|
||
editor_api_mode_for_build(cfg!(debug_assertions), cfg!(debug_assertions))
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||
struct PlatformSessionFixture {
|
||
schema_version: String,
|
||
user_id: String,
|
||
access_token: String,
|
||
api_base_url: String,
|
||
generation: u64,
|
||
}
|
||
|
||
fn metadata_is_link_or_reparse(metadata: &fs::Metadata) -> bool {
|
||
if metadata.file_type().is_symlink() {
|
||
return true;
|
||
}
|
||
#[cfg(windows)]
|
||
{
|
||
use std::os::windows::fs::MetadataExt;
|
||
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
|
||
if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
|
||
return true;
|
||
}
|
||
}
|
||
false
|
||
}
|
||
|
||
fn path_component_is_inside(root: &Path, candidate: &Path) -> bool {
|
||
#[cfg(windows)]
|
||
{
|
||
// Windows paths are case-insensitive. Comparing normalized UTF-16
|
||
// strings would introduce an unnecessary lossy conversion; component
|
||
// comparison with ASCII-folding covers the drive/normal path forms
|
||
// used by the fixture while retaining separators as boundaries.
|
||
let root = root.components().collect::<Vec<_>>();
|
||
let candidate = candidate.components().collect::<Vec<_>>();
|
||
return candidate.len() > root.len()
|
||
&& root.iter().zip(candidate.iter()).all(|(left, right)| {
|
||
left.as_os_str()
|
||
.to_string_lossy()
|
||
.eq_ignore_ascii_case(&right.as_os_str().to_string_lossy())
|
||
});
|
||
}
|
||
#[cfg(not(windows))]
|
||
{
|
||
candidate.starts_with(root) && candidate != root
|
||
}
|
||
}
|
||
|
||
fn validate_fixture_path(config_dir: &Path, fixture_path: &Path) -> Result<PathBuf, String> {
|
||
if !config_dir.is_absolute() {
|
||
return Err("平台登录态 fixture 所在 AppData 必须是绝对路径".to_string());
|
||
}
|
||
if !fixture_path.is_absolute() {
|
||
return Err("平台登录态 fixture 路径必须是绝对路径".to_string());
|
||
}
|
||
|
||
// Check the directory entry before canonicalization so a symlink/junction
|
||
// cannot be silently followed into an unrelated credential location.
|
||
let config_entry = fs::symlink_metadata(config_dir)
|
||
.map_err(|_| "平台登录态 fixture AppData 不可读取".to_string())?;
|
||
if metadata_is_link_or_reparse(&config_entry) || !config_entry.is_dir() {
|
||
return Err("平台登录态 fixture AppData 必须是普通目录".to_string());
|
||
}
|
||
let canonical_config = fs::canonicalize(config_dir)
|
||
.map_err(|_| "平台登录态 fixture AppData 不可解析".to_string())?;
|
||
|
||
let fixture_entry = fs::symlink_metadata(fixture_path)
|
||
.map_err(|_| "平台登录态 fixture 文件不可读取".to_string())?;
|
||
if metadata_is_link_or_reparse(&fixture_entry) || !fixture_entry.is_file() {
|
||
return Err("平台登录态 fixture 必须是普通文件".to_string());
|
||
}
|
||
if fixture_entry.len() > PLATFORM_SESSION_FIXTURE_MAX_BYTES {
|
||
return Err("平台登录态 fixture 过大,已拒绝读取".to_string());
|
||
}
|
||
let canonical_fixture = fs::canonicalize(fixture_path)
|
||
.map_err(|_| "平台登录态 fixture 文件不可解析".to_string())?;
|
||
if !path_component_is_inside(&canonical_config, &canonical_fixture) {
|
||
return Err("平台登录态 fixture 必须位于 --config-dir 内".to_string());
|
||
}
|
||
|
||
// Walk the path below AppData and reject links/reparse points in every
|
||
// ancestor as well as at the leaf. This keeps the check useful on
|
||
// platforms where canonicalize otherwise follows a junction.
|
||
let relative = canonical_fixture
|
||
.strip_prefix(&canonical_config)
|
||
.map_err(|_| "平台登录态 fixture 必须位于 --config-dir 内".to_string())?;
|
||
let mut current = canonical_config;
|
||
for component in relative.components() {
|
||
current.push(component.as_os_str());
|
||
let metadata = fs::symlink_metadata(¤t)
|
||
.map_err(|_| "平台登录态 fixture 路径不可读取".to_string())?;
|
||
if metadata_is_link_or_reparse(&metadata) {
|
||
return Err("平台登录态 fixture 路径不能包含链接或 reparse point".to_string());
|
||
}
|
||
if current != canonical_fixture && !metadata.is_dir() {
|
||
return Err("平台登录态 fixture 父路径必须是普通目录".to_string());
|
||
}
|
||
}
|
||
Ok(canonical_fixture)
|
||
}
|
||
|
||
fn read_fixture_file(path: &Path) -> Result<Vec<u8>, String> {
|
||
let before =
|
||
fs::symlink_metadata(path).map_err(|_| "平台登录态 fixture 文件不可读取".to_string())?;
|
||
if metadata_is_link_or_reparse(&before) || !before.is_file() {
|
||
return Err("平台登录态 fixture 必须是普通文件".to_string());
|
||
}
|
||
if before.len() > PLATFORM_SESSION_FIXTURE_MAX_BYTES {
|
||
return Err("平台登录态 fixture 过大,已拒绝读取".to_string());
|
||
}
|
||
|
||
let mut options = OpenOptions::new();
|
||
options.read(true);
|
||
#[cfg(unix)]
|
||
{
|
||
use std::os::unix::fs::OpenOptionsExt;
|
||
options.custom_flags(libc::O_NOFOLLOW);
|
||
}
|
||
let mut file = options
|
||
.open(path)
|
||
.map_err(|_| "平台登录态 fixture 文件不可读取".to_string())?;
|
||
let opened = file
|
||
.metadata()
|
||
.map_err(|_| "平台登录态 fixture 文件不可读取".to_string())?;
|
||
if metadata_is_link_or_reparse(&opened) || !opened.is_file() || opened.len() > before.len() {
|
||
return Err("平台登录态 fixture 文件身份校验失败".to_string());
|
||
}
|
||
let mut bytes =
|
||
Vec::with_capacity(opened.len().min(PLATFORM_SESSION_FIXTURE_MAX_BYTES) as usize);
|
||
file.take(PLATFORM_SESSION_FIXTURE_MAX_BYTES + 1)
|
||
.read_to_end(&mut bytes)
|
||
.map_err(|_| "读取平台登录态 fixture 失败".to_string())?;
|
||
if bytes.len() as u64 > PLATFORM_SESSION_FIXTURE_MAX_BYTES {
|
||
return Err("平台登录态 fixture 过大,已拒绝读取".to_string());
|
||
}
|
||
Ok(bytes)
|
||
}
|
||
|
||
fn parse_platform_session_fixture(bytes: &[u8]) -> Result<PlatformSessionFixture, String> {
|
||
let fixture = serde_json::from_slice::<PlatformSessionFixture>(bytes)
|
||
.map_err(|_| "平台登录态 fixture 格式无效".to_string())?;
|
||
if fixture.schema_version != PLATFORM_SESSION_FIXTURE_SCHEMA_VERSION {
|
||
return Err("平台登录态 fixture 版本不受支持".to_string());
|
||
}
|
||
if fixture.generation == 0 {
|
||
return Err("平台登录态 fixture generation 无效".to_string());
|
||
}
|
||
if fixture
|
||
.user_id
|
||
.chars()
|
||
.any(|character| character.is_control())
|
||
{
|
||
return Err("平台登录态 fixture 用户身份无效".to_string());
|
||
}
|
||
if fixture
|
||
.access_token
|
||
.chars()
|
||
.any(|character| character.is_control())
|
||
{
|
||
return Err("平台登录态 fixture 凭据无效".to_string());
|
||
}
|
||
Ok(fixture)
|
||
}
|
||
|
||
/// Loads a deliberately narrow, file-backed account fixture for Debug E2E
|
||
/// processes. Release binaries fail closed if the hook is present. The
|
||
/// fixture path must be a regular file below the process' explicit
|
||
/// `--config-dir`; credentials are never accepted on argv or emitted in an
|
||
/// error string.
|
||
pub(crate) fn load_platform_session_fixture_from_env(config_dir: &Path) -> Result<(), String> {
|
||
load_platform_session_fixture_from_env_for_build(config_dir, cfg!(debug_assertions))
|
||
}
|
||
|
||
pub(crate) fn load_platform_session_fixture_from_env_for_build(
|
||
config_dir: &Path,
|
||
debug_build: bool,
|
||
) -> Result<(), String> {
|
||
let Some(raw_path) = std::env::var_os(PLATFORM_SESSION_FIXTURE_ENV) else {
|
||
return Ok(());
|
||
};
|
||
if !debug_build {
|
||
return Err("当前发行版拒绝使用平台登录态测试 fixture".to_string());
|
||
}
|
||
let raw_path = raw_path
|
||
.to_str()
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.ok_or_else(|| "平台登录态 fixture 路径无效".to_string())?;
|
||
let fixture_path = validate_fixture_path(config_dir, Path::new(raw_path))?;
|
||
let bytes = read_fixture_file(&fixture_path)?;
|
||
let fixture = parse_platform_session_fixture(&bytes)?;
|
||
// fixture 的 generation 同时充当身份代次与写入 revision:一个 fixture 只表达
|
||
// “从零安装一次确定的会话”,不表达同一身份的凭据续期。
|
||
let snapshot = validated_platform_session_snapshot(
|
||
&fixture.user_id,
|
||
&fixture.access_token,
|
||
&fixture.api_base_url,
|
||
fixture.generation,
|
||
fixture.generation,
|
||
)?;
|
||
let mut current = platform_session()
|
||
.lock()
|
||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||
// A fresh CLI/Runner normally starts at revision zero. Replacing the
|
||
// state here also makes a Debug GUI fixture deterministic without relaxing
|
||
// the normal account-switch rules.
|
||
current.revision = snapshot.revision;
|
||
current.identity_generation = snapshot.identity_generation;
|
||
current.snapshot = Some(snapshot);
|
||
Ok(())
|
||
}
|
||
|
||
#[derive(Default)]
|
||
struct PlatformSessionState {
|
||
revision: u64,
|
||
identity_generation: u64,
|
||
snapshot: Option<PlatformSessionSnapshot>,
|
||
}
|
||
|
||
static PLATFORM_SESSION: OnceLock<Mutex<PlatformSessionState>> = OnceLock::new();
|
||
|
||
fn platform_session() -> &'static Mutex<PlatformSessionState> {
|
||
PLATFORM_SESSION.get_or_init(|| Mutex::new(PlatformSessionState::default()))
|
||
}
|
||
|
||
fn install_platform_session_in(
|
||
current: &mut PlatformSessionState,
|
||
user_id: &str,
|
||
access_token: &str,
|
||
api_base_url: &str,
|
||
identity_generation: u64,
|
||
revision: u64,
|
||
) {
|
||
if revision < current.revision {
|
||
return;
|
||
}
|
||
if revision == current.revision {
|
||
if current.snapshot.as_ref().is_some_and(|snapshot| {
|
||
snapshot.user_id == user_id
|
||
&& snapshot.access_token == access_token
|
||
&& snapshot.api_base_url == api_base_url
|
||
&& snapshot.identity_generation == identity_generation
|
||
}) {
|
||
return;
|
||
}
|
||
// 同一 revision 只允许逐字段重复已提交的会话。尤其地:迟到写入不能复活已清除的
|
||
// 会话,也不能在同一个 revision 上偷偷换掉主体或 token。
|
||
return;
|
||
}
|
||
if identity_generation < current.identity_generation {
|
||
return;
|
||
}
|
||
if current.snapshot.as_ref().is_some_and(|snapshot| {
|
||
snapshot.identity_generation == identity_generation
|
||
&& (snapshot.user_id != user_id || snapshot.api_base_url != api_base_url)
|
||
}) {
|
||
// 同一个身份代次不允许更换登录主体或服务 origin:换号必须先推进身份代次,
|
||
// 否则旧账号的在途 operation 可能拿到新账号的凭据。
|
||
return;
|
||
}
|
||
current.revision = revision;
|
||
current.identity_generation = identity_generation;
|
||
current.snapshot = Some(PlatformSessionSnapshot {
|
||
user_id: user_id.to_string(),
|
||
access_token: access_token.to_string(),
|
||
api_base_url: api_base_url.to_string(),
|
||
identity_generation,
|
||
revision,
|
||
});
|
||
}
|
||
|
||
fn clear_platform_session_in(
|
||
current: &mut PlatformSessionState,
|
||
identity_generation: u64,
|
||
revision: u64,
|
||
) {
|
||
if revision <= current.revision {
|
||
return;
|
||
}
|
||
if identity_generation < current.identity_generation {
|
||
return;
|
||
}
|
||
current.revision = revision;
|
||
current.identity_generation = identity_generation;
|
||
current.snapshot = None;
|
||
}
|
||
|
||
pub(crate) fn install_platform_session(
|
||
user_id: &str,
|
||
access_token: &str,
|
||
api_base_url: &str,
|
||
identity_generation: u64,
|
||
revision: u64,
|
||
) -> Result<(), String> {
|
||
let snapshot = validated_platform_session_snapshot(
|
||
user_id,
|
||
access_token,
|
||
api_base_url,
|
||
identity_generation,
|
||
revision,
|
||
)?;
|
||
let mut current = platform_session()
|
||
.lock()
|
||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||
install_platform_session_in(
|
||
&mut current,
|
||
&snapshot.user_id,
|
||
&snapshot.access_token,
|
||
&snapshot.api_base_url,
|
||
snapshot.identity_generation,
|
||
snapshot.revision,
|
||
);
|
||
Ok(())
|
||
}
|
||
|
||
fn validated_platform_session_snapshot(
|
||
user_id: &str,
|
||
access_token: &str,
|
||
api_base_url: &str,
|
||
identity_generation: u64,
|
||
revision: u64,
|
||
) -> Result<PlatformSessionSnapshot, String> {
|
||
if editor_api_mode() == EditorApiMode::ExternalDeveloper {
|
||
return Err("独立外部开发发行版不接受陶泥儿网站登录态".to_string());
|
||
}
|
||
let user_id = user_id.trim();
|
||
let access_token = access_token.trim();
|
||
let api_base_url = normalize_platform_api_base_url(api_base_url)?;
|
||
if user_id.is_empty() || user_id.len() > 256 {
|
||
return Err("陶泥儿登录用户身份无效".to_string());
|
||
}
|
||
if access_token.is_empty() || access_token.len() > 16 * 1024 {
|
||
return Err("陶泥儿登录凭据无效".to_string());
|
||
}
|
||
Ok(PlatformSessionSnapshot {
|
||
user_id: user_id.to_string(),
|
||
access_token: access_token.to_string(),
|
||
api_base_url,
|
||
identity_generation,
|
||
revision,
|
||
})
|
||
}
|
||
|
||
pub(crate) fn validate_platform_session_input(
|
||
user_id: &str,
|
||
access_token: &str,
|
||
api_base_url: &str,
|
||
identity_generation: u64,
|
||
revision: u64,
|
||
) -> Result<(), String> {
|
||
validated_platform_session_snapshot(
|
||
user_id,
|
||
access_token,
|
||
api_base_url,
|
||
identity_generation,
|
||
revision,
|
||
)
|
||
.map(|_| ())
|
||
}
|
||
|
||
pub(crate) fn replace_platform_session_for_gui_owner(
|
||
user_id: &str,
|
||
access_token: &str,
|
||
api_base_url: &str,
|
||
identity_generation: u64,
|
||
revision: u64,
|
||
) -> Result<(), String> {
|
||
let snapshot = validated_platform_session_snapshot(
|
||
user_id,
|
||
access_token,
|
||
api_base_url,
|
||
identity_generation,
|
||
revision,
|
||
)?;
|
||
let mut current = platform_session()
|
||
.lock()
|
||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||
// 这条路径是 GUI authority epoch 的重定性入口:只有 durable claim 的 epoch + session
|
||
// revision 与登记完全一致时才会走到这里,新 epoch 可以替换旧进程留下的任意计数器。
|
||
// 因此按调用方快照重定基准,让原生计数与渲染层认知严格一致;Runner 同 epoch 的幂等
|
||
// 重挂仍走 install_platform_session_checked 的精确相等校验。写入的持续单调性由渲染层
|
||
// reserve(max(本地 + 1, 原生下限 + 1))和 durable session revision 保证。
|
||
current.revision = snapshot.revision;
|
||
current.identity_generation = snapshot.identity_generation;
|
||
current.snapshot = Some(snapshot);
|
||
Ok(())
|
||
}
|
||
|
||
pub(crate) fn install_platform_session_checked(
|
||
user_id: &str,
|
||
access_token: &str,
|
||
api_base_url: &str,
|
||
identity_generation: u64,
|
||
revision: u64,
|
||
) -> Result<(), String> {
|
||
let snapshot = validated_platform_session_snapshot(
|
||
user_id,
|
||
access_token,
|
||
api_base_url,
|
||
identity_generation,
|
||
revision,
|
||
)?;
|
||
let mut current = platform_session()
|
||
.lock()
|
||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||
install_platform_session_in(
|
||
&mut current,
|
||
&snapshot.user_id,
|
||
&snapshot.access_token,
|
||
&snapshot.api_base_url,
|
||
snapshot.identity_generation,
|
||
snapshot.revision,
|
||
);
|
||
if current.snapshot.as_ref() == Some(&snapshot) {
|
||
Ok(())
|
||
} else {
|
||
Err("authentication-required: 平台登录态写入已过期或主体冲突".to_string())
|
||
}
|
||
}
|
||
|
||
fn normalize_platform_api_base_url(value: &str) -> Result<String, String> {
|
||
let value = value.trim().trim_end_matches('/');
|
||
let parsed = url::Url::parse(value).map_err(|_| "陶泥儿服务地址无效".to_string())?;
|
||
if !matches!(parsed.scheme(), "http" | "https")
|
||
|| !parsed.username().is_empty()
|
||
|| parsed.password().is_some()
|
||
|| parsed.query().is_some()
|
||
|| parsed.fragment().is_some()
|
||
|| parsed.path() != "/"
|
||
{
|
||
return Err("陶泥儿服务地址必须是纯 HTTP(S) origin".to_string());
|
||
}
|
||
let host = parsed
|
||
.host_str()
|
||
.ok_or_else(|| "陶泥儿服务地址缺少 host".to_string())?;
|
||
let loopback = host == "localhost"
|
||
|| host == "127.0.0.1"
|
||
|| host
|
||
.parse::<std::net::IpAddr>()
|
||
.is_ok_and(|ip| ip.is_loopback());
|
||
if parsed.scheme() == "http" && !loopback {
|
||
return Err("陶泥儿服务地址不在受信任白名单内".to_string());
|
||
}
|
||
Ok(value.to_string())
|
||
}
|
||
|
||
pub(crate) fn clear_platform_session(identity_generation: u64, revision: u64) {
|
||
let mut current = platform_session()
|
||
.lock()
|
||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||
clear_platform_session_in(&mut current, identity_generation, revision);
|
||
}
|
||
|
||
pub(crate) fn clear_platform_session_for_gui_owner(identity_generation: u64, revision: u64) {
|
||
let mut current = platform_session()
|
||
.lock()
|
||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||
// 与 replace 同一口径:epoch 交接按调用方快照重定基准,避免原生计数与渲染层认知漂移。
|
||
current.revision = revision;
|
||
current.identity_generation = identity_generation;
|
||
current.snapshot = None;
|
||
}
|
||
|
||
pub(crate) fn clear_platform_session_checked(
|
||
identity_generation: u64,
|
||
revision: u64,
|
||
) -> Result<(), String> {
|
||
let mut current = platform_session()
|
||
.lock()
|
||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||
clear_platform_session_in(&mut current, identity_generation, revision);
|
||
if current.revision >= revision
|
||
&& current.identity_generation >= identity_generation
|
||
&& current.snapshot.is_none()
|
||
{
|
||
Ok(())
|
||
} else {
|
||
Err("authentication-required: 平台登出写入已过期".to_string())
|
||
}
|
||
}
|
||
|
||
pub(crate) fn current_platform_session() -> Option<PlatformSessionSnapshot> {
|
||
platform_session()
|
||
.lock()
|
||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||
.snapshot
|
||
.clone()
|
||
}
|
||
|
||
/// native 写入顺序 revision。渲染层用它作为只增不减的下限,避免新 WebView 的本地计数
|
||
/// 复位后写出比现存会话更旧的 install / clear。
|
||
/// 原生写入下限,供渲染层reserve新的身份代次与 revision。
|
||
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub(crate) struct PlatformSessionWriteState {
|
||
pub(crate) identity_generation: u64,
|
||
pub(crate) revision: u64,
|
||
}
|
||
|
||
pub(crate) fn current_platform_session_write_state() -> PlatformSessionWriteState {
|
||
let current = platform_session()
|
||
.lock()
|
||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||
PlatformSessionWriteState {
|
||
identity_generation: current.identity_generation,
|
||
revision: current.revision,
|
||
}
|
||
}
|
||
|
||
/// 冻结会话校验:只比较身份,不比较 token 字节。
|
||
pub(crate) fn validate_frozen_platform_session(
|
||
expected: &PlatformSessionSnapshot,
|
||
) -> Result<(), String> {
|
||
validate_platform_session_identity(&expected.identity())
|
||
}
|
||
|
||
pub(crate) fn validate_platform_session_identity(
|
||
expected: &PlatformSessionIdentity,
|
||
) -> Result<(), String> {
|
||
let matches = current_platform_session()
|
||
.as_ref()
|
||
.map(PlatformSessionSnapshot::identity)
|
||
.as_ref()
|
||
== Some(expected);
|
||
if matches {
|
||
Ok(())
|
||
} else {
|
||
Err(
|
||
"authentication-required: 陶泥儿登录态已变化,旧账号请求已停止,请使用当前账号重试"
|
||
.to_string(),
|
||
)
|
||
}
|
||
}
|
||
|
||
pub(crate) fn with_validated_platform_session_identity<T>(
|
||
expected: &PlatformSessionIdentity,
|
||
action: impl FnOnce() -> Result<T, String>,
|
||
) -> Result<T, String> {
|
||
let lease = acquire_platform_session_identity_lease(expected)?;
|
||
let result = action();
|
||
drop(lease);
|
||
result
|
||
}
|
||
|
||
pub(crate) struct ValidatedPlatformSessionLease {
|
||
_guard: std::sync::MutexGuard<'static, PlatformSessionState>,
|
||
}
|
||
|
||
/// 取得身份租约:持锁期间换号 / 退出无法落地,调用方可以安全地用当前凭据完成一次
|
||
/// 本地提交。凭据续期不改变身份,因此不会被这个租约挡住。
|
||
pub(crate) fn acquire_platform_session_identity_lease(
|
||
expected: &PlatformSessionIdentity,
|
||
) -> Result<ValidatedPlatformSessionLease, String> {
|
||
let current = platform_session()
|
||
.lock()
|
||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||
let matches = current
|
||
.snapshot
|
||
.as_ref()
|
||
.map(PlatformSessionSnapshot::identity)
|
||
.as_ref()
|
||
== Some(expected);
|
||
if !matches {
|
||
return Err(
|
||
"authentication-required: 陶泥儿登录态已变化,旧账号请求已停止,请使用当前账号重试"
|
||
.to_string(),
|
||
);
|
||
}
|
||
Ok(ValidatedPlatformSessionLease { _guard: current })
|
||
}
|
||
|
||
pub(crate) fn platform_session_is_available() -> bool {
|
||
current_platform_session().is_some()
|
||
}
|
||
|
||
pub(crate) fn platform_session_service_identity() -> Option<String> {
|
||
current_platform_session()
|
||
.map(|snapshot| format!("{}\nuser:{}", snapshot.api_base_url, snapshot.user_id))
|
||
}
|
||
|
||
#[cfg(test)]
|
||
static PLATFORM_SESSION_TEST_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||
|
||
#[cfg(test)]
|
||
pub(crate) struct TestPlatformSessionGuard {
|
||
_isolation: std::sync::MutexGuard<'static, ()>,
|
||
previous: Option<PlatformSessionState>,
|
||
}
|
||
|
||
#[cfg(test)]
|
||
impl Drop for TestPlatformSessionGuard {
|
||
fn drop(&mut self) {
|
||
let mut current = platform_session()
|
||
.lock()
|
||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||
*current = self
|
||
.previous
|
||
.take()
|
||
.expect("platform session test guard must restore its prior state");
|
||
}
|
||
}
|
||
|
||
/// Installs a scoped platform-account session for tests that must exercise the
|
||
/// production-default client authentication path. The previous process state
|
||
/// is restored on drop so the fixture cannot leak credentials or account
|
||
/// identity to another test.
|
||
#[cfg(test)]
|
||
pub(crate) fn install_test_platform_session(
|
||
user_id: &str,
|
||
access_token: &str,
|
||
api_base_url: &str,
|
||
) -> TestPlatformSessionGuard {
|
||
let isolation = PLATFORM_SESSION_TEST_LOCK
|
||
.get_or_init(|| Mutex::new(()))
|
||
.lock()
|
||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||
let mut current = platform_session()
|
||
.lock()
|
||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||
let previous = std::mem::take(&mut *current);
|
||
*current = PlatformSessionState {
|
||
revision: 1,
|
||
identity_generation: 1,
|
||
snapshot: Some(PlatformSessionSnapshot {
|
||
user_id: user_id.to_string(),
|
||
access_token: access_token.to_string(),
|
||
api_base_url: api_base_url.to_string(),
|
||
identity_generation: 1,
|
||
revision: 1,
|
||
}),
|
||
};
|
||
drop(current);
|
||
TestPlatformSessionGuard {
|
||
_isolation: isolation,
|
||
previous: Some(previous),
|
||
}
|
||
}
|
||
|
||
/// Holds the shared test-session lock while presenting an explicit logged-out
|
||
/// state. Tests that assert missing-login behavior must use this guard so they
|
||
/// cannot observe a platform session installed by another parallel test.
|
||
#[cfg(test)]
|
||
pub(crate) fn clear_test_platform_session() -> TestPlatformSessionGuard {
|
||
let isolation = PLATFORM_SESSION_TEST_LOCK
|
||
.get_or_init(|| Mutex::new(()))
|
||
.lock()
|
||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||
let mut current = platform_session()
|
||
.lock()
|
||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||
let previous = std::mem::take(&mut *current);
|
||
*current = PlatformSessionState::default();
|
||
drop(current);
|
||
TestPlatformSessionGuard {
|
||
_isolation: isolation,
|
||
previous: Some(previous),
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
const TEST_ORIGIN: &str = "https://dev.genarrative.world";
|
||
|
||
#[test]
|
||
fn cleared_revision_rejects_late_install_and_older_clear() {
|
||
let mut state = PlatformSessionState::default();
|
||
install_platform_session_in(&mut state, "user-a", "token-a", TEST_ORIGIN, 1, 1);
|
||
clear_platform_session_in(&mut state, 2, 2);
|
||
install_platform_session_in(&mut state, "user-a", "late-token-a", TEST_ORIGIN, 1, 1);
|
||
install_platform_session_in(
|
||
&mut state,
|
||
"user-a",
|
||
"same-revision-token",
|
||
TEST_ORIGIN,
|
||
2,
|
||
2,
|
||
);
|
||
assert!(state.snapshot.is_none());
|
||
assert_eq!(state.revision, 2);
|
||
|
||
install_platform_session_in(&mut state, "user-b", "token-b", TEST_ORIGIN, 3, 3);
|
||
clear_platform_session_in(&mut state, 2, 2);
|
||
assert_eq!(
|
||
state.snapshot.as_ref().map(|value| value.user_id.as_str()),
|
||
Some("user-b")
|
||
);
|
||
assert_eq!(state.revision, 3);
|
||
assert_eq!(state.identity_generation, 3);
|
||
}
|
||
|
||
#[test]
|
||
fn equal_revision_only_accepts_the_exact_idempotent_snapshot() {
|
||
let mut state = PlatformSessionState::default();
|
||
install_platform_session_in(&mut state, "user-a", "token-a", TEST_ORIGIN, 4, 4);
|
||
install_platform_session_in(&mut state, "user-a", "token-a", TEST_ORIGIN, 4, 4);
|
||
install_platform_session_in(&mut state, "user-b", "token-b", TEST_ORIGIN, 4, 4);
|
||
install_platform_session_in(&mut state, "user-a", "token-b", TEST_ORIGIN, 4, 4);
|
||
assert_eq!(
|
||
state.snapshot.as_ref().map(|value| value.user_id.as_str()),
|
||
Some("user-a")
|
||
);
|
||
assert_eq!(
|
||
state
|
||
.snapshot
|
||
.as_ref()
|
||
.map(|value| value.access_token.as_str()),
|
||
Some("token-a")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn same_identity_credential_refresh_keeps_identity_and_frozen_session() {
|
||
let _session = install_test_platform_session("refresh-user", "token-a", TEST_ORIGIN);
|
||
let frozen = current_platform_session().expect("frozen platform session");
|
||
let identity = frozen.identity();
|
||
|
||
install_platform_session("refresh-user", "token-b", TEST_ORIGIN, 1, 2)
|
||
.expect("refresh credential for the same identity");
|
||
|
||
assert_eq!(
|
||
current_platform_session().map(|session| session.access_token),
|
||
Some("token-b".to_string())
|
||
);
|
||
assert_eq!(
|
||
current_platform_session_write_state().identity_generation,
|
||
1
|
||
);
|
||
validate_frozen_platform_session(&frozen)
|
||
.expect("same-identity token rotation must keep the frozen session valid");
|
||
validate_platform_session_identity(&identity)
|
||
.expect("same-identity token rotation must keep the identity valid");
|
||
}
|
||
|
||
#[test]
|
||
fn identity_change_invalidates_frozen_session_and_needs_a_new_identity_generation() {
|
||
let _session = install_test_platform_session("identity-user-a", "token-a", TEST_ORIGIN);
|
||
let frozen = current_platform_session().expect("frozen platform session");
|
||
install_platform_session("identity-user-a", "token-b", TEST_ORIGIN, 1, 2)
|
||
.expect("credential refresh for the same identity");
|
||
|
||
// 同身份代次不允许换主体:否则旧账号在途请求会拿到新账号凭据。
|
||
install_platform_session("identity-user-b", "token-b", TEST_ORIGIN, 1, 3)
|
||
.expect("conflicting subject at the same identity generation is ignored");
|
||
assert_eq!(
|
||
current_platform_session().map(|session| session.user_id),
|
||
Some("identity-user-a".to_string())
|
||
);
|
||
validate_frozen_platform_session(&frozen)
|
||
.expect("ignored conflicting write must not disturb the frozen session");
|
||
|
||
install_platform_session("identity-user-b", "token-b", TEST_ORIGIN, 2, 4)
|
||
.expect("account switch advances the identity generation");
|
||
assert!(validate_frozen_platform_session(&frozen).is_err());
|
||
assert!(current_platform_session().is_some());
|
||
}
|
||
|
||
#[test]
|
||
fn gui_owner_replacement_rebases_to_the_authority_and_only_subject_change_fences() {
|
||
let _session = clear_test_platform_session();
|
||
replace_platform_session_for_gui_owner("gui-owner-a", "token-a", TEST_ORIGIN, 5, 5)
|
||
.expect("install gui owner A");
|
||
let installed = current_platform_session().expect("gui owner A session");
|
||
assert_eq!(installed.identity_generation, 5);
|
||
assert_eq!(installed.revision, 5);
|
||
|
||
// 同一主体只换凭据(续期后 Runner 重挂走的就是这条 replace 路径):身份代次保持、
|
||
// 写入 revision 前进,在途 operation 的冻结会话仍然有效。
|
||
replace_platform_session_for_gui_owner("gui-owner-a", "token-a2", TEST_ORIGIN, 5, 6)
|
||
.expect("refresh gui owner A credential");
|
||
let refreshed = current_platform_session().expect("gui owner A refreshed session");
|
||
assert_eq!(refreshed.identity_generation, 5);
|
||
assert_eq!(refreshed.revision, 6);
|
||
validate_frozen_platform_session(&installed)
|
||
.expect("same-subject credential replacement keeps the frozen session valid");
|
||
|
||
// 换主体必须推进身份代次,旧身份的在途 operation 失败关闭。
|
||
replace_platform_session_for_gui_owner("gui-owner-b", "token-b", TEST_ORIGIN, 6, 7)
|
||
.expect("switch gui owner");
|
||
let switched = current_platform_session().expect("gui owner B session");
|
||
assert_eq!(switched.user_id, "gui-owner-b");
|
||
assert_eq!(switched.identity_generation, 6);
|
||
assert!(validate_frozen_platform_session(&installed).is_err());
|
||
assert!(validate_frozen_platform_session(&refreshed).is_err());
|
||
|
||
// epoch 交接后的清除同样按调用方快照重定基准,让原生计数与渲染层认知一致。
|
||
clear_platform_session_for_gui_owner(7, 8);
|
||
let cleared = current_platform_session_write_state();
|
||
assert_eq!(cleared.revision, 8);
|
||
assert_eq!(cleared.identity_generation, 7);
|
||
assert!(current_platform_session().is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn older_identity_generation_cannot_restore_a_replaced_subject() {
|
||
let mut state = PlatformSessionState::default();
|
||
install_platform_session_in(&mut state, "user-a", "token-a", TEST_ORIGIN, 5, 5);
|
||
install_platform_session_in(&mut state, "user-b", "token-b", TEST_ORIGIN, 6, 6);
|
||
install_platform_session_in(&mut state, "user-a", "token-a", TEST_ORIGIN, 5, 7);
|
||
assert_eq!(
|
||
state.snapshot.as_ref().map(|value| value.user_id.as_str()),
|
||
Some("user-b")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn current_revision_preserves_the_floor_after_session_clear() {
|
||
let _session = clear_test_platform_session();
|
||
install_platform_session(
|
||
"revision-floor-user",
|
||
"revision-floor-token",
|
||
TEST_ORIGIN,
|
||
41,
|
||
41,
|
||
)
|
||
.expect("install session revision floor");
|
||
clear_platform_session(42, 42);
|
||
|
||
let state = current_platform_session_write_state();
|
||
assert_eq!(state.revision, 42);
|
||
assert_eq!(state.identity_generation, 42);
|
||
assert!(current_platform_session().is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn editor_api_mode_is_fixed_by_the_trusted_build_flavor() {
|
||
assert_eq!(
|
||
editor_api_mode_for_build(true, false),
|
||
EditorApiMode::PlatformAccount
|
||
);
|
||
assert_eq!(
|
||
editor_api_mode_for_build(true, true),
|
||
EditorApiMode::PlatformAccount
|
||
);
|
||
assert_eq!(
|
||
editor_api_mode_for_build(false, false),
|
||
EditorApiMode::PlatformAccount
|
||
);
|
||
assert_eq!(
|
||
editor_api_mode_for_build(false, true),
|
||
EditorApiMode::ExternalDeveloper
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn custom_server_selection_accepts_https_origins_and_rejects_plain_http() {
|
||
assert_eq!(
|
||
normalize_platform_api_base_url("https://staging.example.com/"),
|
||
Ok("https://staging.example.com".to_string())
|
||
);
|
||
assert_eq!(
|
||
normalize_platform_api_base_url("http://127.0.0.1:8080/"),
|
||
Ok("http://127.0.0.1:8080".to_string())
|
||
);
|
||
assert!(normalize_platform_api_base_url("http://staging.example.com").is_err());
|
||
assert!(normalize_platform_api_base_url("https://staging.example.com/api").is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn frozen_platform_session_rejects_logout_and_account_switch_but_allows_token_rotation() {
|
||
let _session = install_test_platform_session("frozen-user-a", "token-a", TEST_ORIGIN);
|
||
let identity = current_platform_session()
|
||
.expect("frozen platform session")
|
||
.identity();
|
||
validate_platform_session_identity(&identity).expect("matching identity is valid");
|
||
|
||
install_platform_session("frozen-user-a", "token-b", TEST_ORIGIN, 1, 2)
|
||
.expect("same-identity credential rotation");
|
||
validate_platform_session_identity(&identity)
|
||
.expect("token rotation must not invalidate the frozen identity");
|
||
|
||
install_platform_session("frozen-user-b", "token-c", TEST_ORIGIN, 2, 3)
|
||
.expect("account switch");
|
||
assert!(validate_platform_session_identity(&identity).is_err());
|
||
|
||
clear_platform_session(3, 4);
|
||
assert!(validate_platform_session_identity(&identity).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn validated_session_lease_linearizes_local_commit_with_account_switch() {
|
||
let _session = install_test_platform_session("lease-user-a", "lease-token-a", TEST_ORIGIN);
|
||
let expected = current_platform_session()
|
||
.expect("current lease session")
|
||
.identity();
|
||
let lease = acquire_platform_session_identity_lease(&expected)
|
||
.expect("acquire validated session lease");
|
||
let (started_sender, started_receiver) = std::sync::mpsc::channel();
|
||
let (finished_sender, finished_receiver) = std::sync::mpsc::channel();
|
||
let switcher = std::thread::spawn(move || {
|
||
started_sender.send(()).expect("signal account switch");
|
||
install_platform_session("lease-user-b", "lease-token-b", TEST_ORIGIN, 2, 2)
|
||
.expect("switch account after lease release");
|
||
finished_sender.send(()).expect("signal switched account");
|
||
});
|
||
started_receiver
|
||
.recv_timeout(std::time::Duration::from_secs(1))
|
||
.expect("switcher started");
|
||
assert!(
|
||
finished_receiver
|
||
.recv_timeout(std::time::Duration::from_millis(100))
|
||
.is_err(),
|
||
"account switch must wait until the synchronous local commit lease is released"
|
||
);
|
||
drop(lease);
|
||
finished_receiver
|
||
.recv_timeout(std::time::Duration::from_secs(1))
|
||
.expect("account switch completed after lease release");
|
||
switcher.join().expect("join account switcher");
|
||
assert_eq!(
|
||
current_platform_session().map(|session| session.user_id),
|
||
Some("lease-user-b".to_string())
|
||
);
|
||
}
|
||
}
|