Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/platform_session.rs
T
kdletters 9070c0760b 修复登录服务器切换与本地资源请求
- 允许 release 客户端安全访问 custom HTTPS 与本机 loopback HTTP

- 修复 Tauri WebView 请求传输与无效 URLPattern

- 按服务器 origin 隔离本机 External Editor 凭据

- 保留登录请求底层错误并补充路由与凭据隔离测试
2026-08-18 18:32:49 +08:00

390 lines
12 KiB
Rust

use std::sync::{Mutex, OnceLock};
#[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) generation: u64,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum EditorApiMode {
PlatformAccount,
ExternalDeveloper,
}
pub(crate) fn editor_api_mode_for_build(
debug_assertions: bool,
game_chat_release_feature: bool,
) -> EditorApiMode {
if !debug_assertions && game_chat_release_feature {
EditorApiMode::ExternalDeveloper
} else {
EditorApiMode::PlatformAccount
}
}
pub(crate) fn editor_api_mode() -> EditorApiMode {
editor_api_mode_for_build(cfg!(debug_assertions), cfg!(feature = "game-chat-release"))
}
#[derive(Default)]
struct PlatformSessionState {
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,
generation: u64,
) {
if generation < current.generation {
return;
}
if generation == current.generation {
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
}) {
return;
}
// Equal-generation retries may only repeat the exact committed snapshot. In
// particular, a late install cannot revive a generation that was cleared.
return;
}
current.generation = 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(),
generation,
});
}
fn clear_platform_session_in(current: &mut PlatformSessionState, generation: u64) {
if generation < current.generation {
return;
}
current.generation = generation;
current.snapshot = None;
}
pub(crate) fn install_platform_session(
user_id: &str,
access_token: &str,
api_base_url: &str,
generation: u64,
) -> Result<(), String> {
if editor_api_mode() == EditorApiMode::ExternalDeveloper {
return Err("独立 game-chat 高级模式不接受陶泥儿网站登录态".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());
}
let mut current = platform_session()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
install_platform_session_in(
&mut current,
user_id,
access_token,
&api_base_url,
generation,
);
Ok(())
}
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(generation: u64) {
let mut current = platform_session()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
clear_platform_session_in(&mut current, generation);
}
pub(crate) fn current_platform_session() -> Option<PlatformSessionSnapshot> {
platform_session()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.snapshot
.clone()
}
pub(crate) fn validate_platform_session_snapshot(
expected: &PlatformSessionSnapshot,
) -> Result<(), String> {
if platform_session_snapshot_matches(current_platform_session().as_ref(), expected) {
Ok(())
} else {
Err(
"authentication-required: 陶泥儿登录态已变化,旧账号请求已停止,请使用当前账号重试"
.to_string(),
)
}
}
fn platform_session_snapshot_matches(
current: Option<&PlatformSessionSnapshot>,
expected: &PlatformSessionSnapshot,
) -> bool {
current == Some(expected)
}
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 {
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(),
generation: 1,
}),
};
drop(current);
TestPlatformSessionGuard {
_isolation: isolation,
previous: Some(previous),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cleared_generation_rejects_late_install_and_older_clear() {
let mut state = PlatformSessionState::default();
install_platform_session_in(
&mut state,
"user-a",
"token-a",
"https://dev.genarrative.world",
1,
);
clear_platform_session_in(&mut state, 2);
install_platform_session_in(
&mut state,
"user-a",
"late-token-a",
"https://dev.genarrative.world",
1,
);
install_platform_session_in(
&mut state,
"user-a",
"same-generation-token",
"https://dev.genarrative.world",
2,
);
assert!(state.snapshot.is_none());
assert_eq!(state.generation, 2);
install_platform_session_in(
&mut state,
"user-b",
"token-b",
"https://dev.genarrative.world",
3,
);
clear_platform_session_in(&mut state, 2);
assert_eq!(
state.snapshot.as_ref().map(|value| value.user_id.as_str()),
Some("user-b")
);
assert_eq!(state.generation, 3);
}
#[test]
fn equal_generation_only_accepts_the_exact_idempotent_snapshot() {
let mut state = PlatformSessionState::default();
install_platform_session_in(
&mut state,
"user-a",
"token-a",
"https://dev.genarrative.world",
4,
);
install_platform_session_in(
&mut state,
"user-a",
"token-a",
"https://dev.genarrative.world",
4,
);
install_platform_session_in(
&mut state,
"user-b",
"token-b",
"https://dev.genarrative.world",
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 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_account_switch_and_token_rotation() {
let expected = PlatformSessionSnapshot {
user_id: "user-a".to_string(),
access_token: "token-a".to_string(),
api_base_url: "https://dev.genarrative.world".to_string(),
generation: 4,
};
assert!(platform_session_snapshot_matches(
Some(&expected),
&expected
));
for current in [
None,
Some(PlatformSessionSnapshot {
user_id: "user-b".to_string(),
..expected.clone()
}),
Some(PlatformSessionSnapshot {
access_token: "token-b".to_string(),
generation: 5,
..expected.clone()
}),
] {
assert!(!platform_session_snapshot_matches(
current.as_ref(),
&expected
));
}
}
}