Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/platform_session.rs
T
kdletters b48293fb1f 收敛平台会话 epoch 重装语义,避免原生计数与渲染层漂移
- GUI owner 重装与清除改为按 authority 快照重定基准,保持原生计数与渲染层认知一致
- 注释写明 epoch 交接的授权前提,以及写入单调性由渲染层 reserve 与 durable session revision 保证
- 同主体换凭据的重装保持身份代次,在途 operation 的冻结会话继续有效
- 补充 epoch 重装与换主体失效的回归用例,替换原先错误假设原生计数器单调的用例
2026-09-16 17:08:47 +08:00

987 lines
37 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(&current)
.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 的精确相等校验。写入的持续单调性由渲染层
// reservemax(本地 + 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())
);
}
}