1 parent
96a32ea8fb
commit
a643e7e3ab
106 files changed
+2223
-4669
No files matched your search
+8
-55
@@ -37,13 +37,6 @@ pub struct AccountSession {
|
||||
pub user_agent: String,
|
||||
}
|
||||
|
||||
/// Thread-safe, in-memory session store backed by `DashMap`.
|
||||
///
|
||||
/// Shared across all Axum handlers via `Arc`. Sessions expire after
|
||||
/// `timeout` of inactivity (sliding window).
|
||||
///
|
||||
/// Browser binding allows multiple accounts to be associated with a single
|
||||
/// browser session, enabling account switching without re-authentication.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SessionStore {
|
||||
sessions: Arc<DashMap<SessionId, AccountSession>>,
|
||||
@@ -81,7 +74,10 @@ impl SessionStore {
|
||||
if let Some(value) = self.tls_verify_override {
|
||||
return value;
|
||||
}
|
||||
let normalized = host.trim().trim_matches(|ch| ch == '[' || ch == ']').to_ascii_lowercase();
|
||||
let normalized = host
|
||||
.trim()
|
||||
.trim_matches(|ch| ch == '[' || ch == ']')
|
||||
.to_ascii_lowercase();
|
||||
!matches!(normalized.as_str(), "localhost" | "127.0.0.1" | "::1")
|
||||
}
|
||||
|
||||
@@ -220,7 +216,6 @@ impl SessionStore {
|
||||
|
||||
pub fn remove_account(&self, account_id: &str) -> bool {
|
||||
if let Some((removed_account_id, session_id)) = self.account_to_session.remove(account_id) {
|
||||
// Remove from sessions; restore account_to_session on panic.
|
||||
if let Err(payload) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
self.sessions.remove(&session_id);
|
||||
})) {
|
||||
@@ -229,7 +224,6 @@ impl SessionStore {
|
||||
std::panic::resume_unwind(payload);
|
||||
}
|
||||
|
||||
// Use reverse index for O(1) browser lookup instead of iterating.
|
||||
if let Some((_, browser_id)) = self.account_to_browser.remove(account_id)
|
||||
&& let Some(mut entry) = self.browsers.get_mut(&browser_id)
|
||||
&& let Some(pos) = entry.value().iter().position(|id| id == account_id)
|
||||
@@ -242,8 +236,6 @@ impl SessionStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// Store a display-only User-Agent string for active-session diagnostics.
|
||||
/// It is never used for authentication or authorization decisions.
|
||||
pub fn set_user_agent(&self, account_id: &str, user_agent: String) {
|
||||
let Some(session_id) = self
|
||||
.account_to_session
|
||||
@@ -257,8 +249,6 @@ impl SessionStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// Return all currently live account sessions belonging to the same
|
||||
/// RustBox user, including sessions created by other browsers.
|
||||
pub fn get_user_sessions(&self, user_hash: &str) -> Vec<AccountSession> {
|
||||
let now = Instant::now();
|
||||
let mut result: Vec<AccountSession> = self
|
||||
@@ -280,7 +270,6 @@ impl SessionStore {
|
||||
result
|
||||
}
|
||||
|
||||
/// Revoke one session only when it belongs to the requested user.
|
||||
pub fn remove_user_session(&self, user_hash: &str, account_id: &str) -> bool {
|
||||
let Some(session_id) = self
|
||||
.account_to_session
|
||||
@@ -299,7 +288,6 @@ impl SessionStore {
|
||||
self.remove_account(account_id)
|
||||
}
|
||||
|
||||
/// Revoke every session for a user except the current account session.
|
||||
pub fn remove_other_user_sessions(&self, user_hash: &str, current_account_id: &str) -> usize {
|
||||
let account_ids: Vec<String> = self
|
||||
.sessions
|
||||
@@ -321,10 +309,6 @@ impl SessionStore {
|
||||
|
||||
pub fn remove_browser(&self, browser_id: &str) {
|
||||
if let Some((_, account_ids)) = self.browsers.remove(browser_id) {
|
||||
// Clean up each account. Individual failures are tolerated --
|
||||
// the browser entry is already gone, so the accounts become
|
||||
// unreachable via browser lookups. They will be reaped by
|
||||
// purge_expired or a subsequent remove_account call.
|
||||
for account_id in account_ids {
|
||||
self.account_to_browser.remove(&account_id);
|
||||
if let Some((_, session_id)) = self.account_to_session.remove(&account_id) {
|
||||
@@ -389,17 +373,13 @@ impl SessionStore {
|
||||
self.sessions.remove(token).is_some()
|
||||
}
|
||||
|
||||
/// Remove an account's entries from `account_to_session` and `browsers`.
|
||||
/// If a browser's account list becomes empty, the browser entry is removed
|
||||
/// too -- keeping it would serve no purpose and would itself be a leak.
|
||||
fn evict_account(&self, account_id: &str) {
|
||||
self.account_to_session.remove(account_id);
|
||||
self.account_to_browser.remove(account_id);
|
||||
|
||||
// We cannot predict which browser owns this account, so scan all.
|
||||
self.browsers.retain(|_, accounts| {
|
||||
accounts.retain(|id| id != account_id);
|
||||
// Keep the browser entry only if it still has accounts.
|
||||
|
||||
!accounts.is_empty()
|
||||
});
|
||||
}
|
||||
@@ -408,7 +388,6 @@ impl SessionStore {
|
||||
pub fn purge_expired(&self) {
|
||||
let now = Instant::now();
|
||||
|
||||
// Collect expired account IDs while retaining live sessions.
|
||||
let mut expired_accounts = Vec::new();
|
||||
self.sessions.retain(|_, state| {
|
||||
let effective_timeout = state.timeout_override.unwrap_or(self.timeout);
|
||||
@@ -424,14 +403,10 @@ impl SessionStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify internal consistency across all four maps. Returns a list of
|
||||
/// inconsistency descriptions. An empty vec means the store is consistent.
|
||||
/// Intended for testing and diagnostics.
|
||||
#[allow(dead_code)]
|
||||
pub fn check_consistency(&self) -> Vec<String> {
|
||||
let mut issues = Vec::new();
|
||||
|
||||
// Every session's account_id should have a matching account_to_session entry.
|
||||
for entry in self.sessions.iter() {
|
||||
let token = entry.key();
|
||||
let account_id = &entry.value().account_id;
|
||||
@@ -451,7 +426,6 @@ impl SessionStore {
|
||||
}
|
||||
}
|
||||
|
||||
// Every account_to_session entry should point to an existing session.
|
||||
for entry in self.account_to_session.iter() {
|
||||
let account_id = entry.key();
|
||||
let session_id = entry.value();
|
||||
@@ -462,7 +436,6 @@ impl SessionStore {
|
||||
}
|
||||
}
|
||||
|
||||
// Every account_id in a browser list should exist in account_to_session.
|
||||
for entry in self.browsers.iter() {
|
||||
let browser_id = entry.key();
|
||||
for account_id in entry.value().iter() {
|
||||
@@ -474,7 +447,6 @@ impl SessionStore {
|
||||
}
|
||||
}
|
||||
|
||||
// Every account_to_browser entry should reference a valid account and browser.
|
||||
for entry in self.account_to_browser.iter() {
|
||||
let account_id = entry.key();
|
||||
let browser_id = entry.value();
|
||||
@@ -878,8 +850,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn sliding_window_refreshes_expiry() {
|
||||
// Use generous margins so the test is reliable under CI / Nix sandbox
|
||||
// CPU pressure where sleeps can overshoot significantly.
|
||||
let store = SessionStore::new(Duration::from_millis(800));
|
||||
let browser_id = store.create_browser();
|
||||
|
||||
@@ -911,8 +881,6 @@ mod tests {
|
||||
assert_eq!(session.imap_port, 993);
|
||||
}
|
||||
|
||||
// --- Consistency tests ---
|
||||
|
||||
fn add_test_account(
|
||||
store: &SessionStore,
|
||||
browser_id: &str,
|
||||
@@ -996,26 +964,23 @@ mod tests {
|
||||
);
|
||||
assert_eq!(store.sessions.len(), 0);
|
||||
assert_eq!(store.account_to_session.len(), 0);
|
||||
// Browser entry is removed when its account list becomes empty.
|
||||
|
||||
assert!(!store.browser_exists(&browser));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn purge_expired_respects_timeout_override() {
|
||||
let store = short_lived_store(); // 50ms default
|
||||
let store = short_lived_store();
|
||||
let browser = store.create_browser();
|
||||
|
||||
// Add one account with a long timeout override
|
||||
let (token, account_id) = add_test_account(&store, &browser, "long@example.com");
|
||||
store.sessions.get_mut(&token).unwrap().timeout_override = Some(Duration::from_secs(3600));
|
||||
|
||||
// Add one with default (short) timeout
|
||||
add_test_account(&store, &browser, "short@example.com");
|
||||
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
store.purge_expired();
|
||||
|
||||
// The long-timeout session should survive.
|
||||
assert!(store.get(&token).is_some());
|
||||
assert!(store.account_to_session.contains_key(&account_id));
|
||||
assert_eq!(store.sessions.len(), 1);
|
||||
@@ -1051,18 +1016,15 @@ mod tests {
|
||||
issues.is_empty(),
|
||||
"store should be consistent after mixed operations, got: {issues:?}"
|
||||
);
|
||||
// Only b@example.com should remain.
|
||||
|
||||
assert_eq!(store.sessions.len(), 1);
|
||||
assert_eq!(store.account_to_session.len(), 1);
|
||||
}
|
||||
|
||||
// --- Reverse index tests ---
|
||||
|
||||
#[test]
|
||||
fn remove_account_uses_reverse_index() {
|
||||
let store = long_lived_store();
|
||||
|
||||
// Create many browsers to demonstrate the O(1) lookup
|
||||
let mut other_browsers = Vec::new();
|
||||
for _ in 0..100 {
|
||||
let bid = store.create_browser();
|
||||
@@ -1095,23 +1057,19 @@ mod tests {
|
||||
true,
|
||||
);
|
||||
|
||||
// Verify reverse index was populated
|
||||
assert!(store.account_to_browser.contains_key(&account_id));
|
||||
assert_eq!(
|
||||
store.account_to_browser.get(&account_id).unwrap().value(),
|
||||
&target_browser
|
||||
);
|
||||
|
||||
// Remove the account -- uses O(1) reverse index, not O(n) iteration
|
||||
assert!(store.remove_account(&account_id));
|
||||
|
||||
// Verify full cleanup
|
||||
assert!(store.get(&token).is_none());
|
||||
assert!(!store.account_to_session.contains_key(&account_id));
|
||||
assert!(!store.account_to_browser.contains_key(&account_id));
|
||||
assert!(store.get_browser_accounts(&target_browser).is_empty());
|
||||
|
||||
// Other browsers are untouched
|
||||
for bid in &other_browsers {
|
||||
assert_eq!(store.get_browser_accounts(bid).len(), 1);
|
||||
}
|
||||
@@ -1184,8 +1142,6 @@ mod tests {
|
||||
assert!(store.get_browser_accounts(&browser_id).is_empty());
|
||||
}
|
||||
|
||||
// --- Cascade eviction tests ---
|
||||
|
||||
#[test]
|
||||
fn purge_expired_cascades_to_browsers_and_account_map() {
|
||||
let store = short_lived_store();
|
||||
@@ -1224,7 +1180,6 @@ mod tests {
|
||||
let store = SessionStore::new(Duration::from_millis(150));
|
||||
let browser_id = store.create_browser();
|
||||
|
||||
// First account -- will expire.
|
||||
let (_token1, account1) = store.add_account_to_browser(
|
||||
&browser_id,
|
||||
"old@example.com".into(),
|
||||
@@ -1240,7 +1195,6 @@ mod tests {
|
||||
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
|
||||
// Second account -- added later, still alive after sleep.
|
||||
let (_token2, account2) = store.add_account_to_browser(
|
||||
&browser_id,
|
||||
"new@example.com".into(),
|
||||
@@ -1297,7 +1251,6 @@ mod tests {
|
||||
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
|
||||
// Accessing the expired session should trigger cascade cleanup.
|
||||
assert!(store.get(&token).is_none());
|
||||
|
||||
assert!(
|
||||
|
||||
Reference in new issue
Block a user