v0.0.5
RustBox CI / rustbox (push) Canceled after 0s

This commit is contained in:
Mateusz Gruszczyński committed 2026-10-30 12:06:44 +01:00
1 parent 96a32ea8fb
commit a643e7e3ab
106 files changed
+2223 -4669

No files matched your search

+8 -55
View File
@@ -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!(