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

This commit is contained in:
Mateusz Gruszczyński committed 2026-10-30 17:22:17 +01:00
1 parent 0421994fe6
commit 425ea7449b
138 files changed
+5705 -1012

No files matched your search

+371 -38
View File
@@ -6,12 +6,29 @@ use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use dashmap::DashMap;
use rand::RngCore;
use crate::session_store::crypto::SessionCrypto;
use crate::session_store::{PersistedSession, SessionPersistence};
pub type SessionId = String;
pub type BrowserId = String;
pub type AccountId = String;
pub type SessionState = AccountSession;
#[derive(Debug, Clone)]
pub struct NewAccountSession {
pub email: String,
pub password: String,
pub user_hash: String,
pub imap_host: String,
pub imap_port: u16,
pub imap_tls: bool,
pub smtp_host: String,
pub smtp_port: u16,
pub smtp_tls: bool,
pub timeout_override: Option<Duration>,
}
#[derive(Debug, Clone)]
pub struct AccountSession {
pub account_id: AccountId,
@@ -37,7 +54,7 @@ pub struct AccountSession {
pub user_agent: String,
}
#[derive(Debug, Clone)]
#[derive(Clone)]
pub struct SessionStore {
sessions: Arc<DashMap<SessionId, AccountSession>>,
browsers: Arc<DashMap<BrowserId, Vec<AccountId>>>,
@@ -45,6 +62,8 @@ pub struct SessionStore {
account_to_browser: Arc<DashMap<AccountId, BrowserId>>,
timeout: Duration,
tls_verify_override: Option<bool>,
persistence: Option<Arc<dyn SessionPersistence>>,
crypto: Option<SessionCrypto>,
}
impl SessionStore {
@@ -55,10 +74,12 @@ impl SessionStore {
.as_secs()
}
#[cfg(test)]
pub fn new(timeout: Duration) -> Self {
Self::new_with_tls_verify(timeout, None)
}
#[cfg(test)]
pub fn new_with_tls_verify(timeout: Duration, tls_verify_override: Option<bool>) -> Self {
Self {
sessions: Arc::new(DashMap::new()),
@@ -67,6 +88,26 @@ impl SessionStore {
account_to_browser: Arc::new(DashMap::new()),
timeout,
tls_verify_override,
persistence: None,
crypto: None,
}
}
pub fn new_persistent(
timeout: Duration,
tls_verify_override: Option<bool>,
persistence: Arc<dyn SessionPersistence>,
crypto: SessionCrypto,
) -> Self {
Self {
sessions: Arc::new(DashMap::new()),
browsers: Arc::new(DashMap::new()),
account_to_session: Arc::new(DashMap::new()),
account_to_browser: Arc::new(DashMap::new()),
timeout,
tls_verify_override,
persistence: Some(persistence),
crypto: Some(crypto),
}
}
@@ -99,6 +140,7 @@ impl SessionStore {
browser_id
}
#[cfg(test)]
#[allow(clippy::too_many_arguments)]
pub fn add_account_to_browser(
&self,
@@ -270,43 +312,6 @@ impl SessionStore {
result
}
pub fn remove_user_session(&self, user_hash: &str, account_id: &str) -> bool {
let Some(session_id) = self
.account_to_session
.get(account_id)
.map(|entry| entry.clone())
else {
return false;
};
let belongs_to_user = self
.sessions
.get(&session_id)
.is_some_and(|session| session.user_hash == user_hash);
if !belongs_to_user {
return false;
}
self.remove_account(account_id)
}
pub fn remove_other_user_sessions(&self, user_hash: &str, current_account_id: &str) -> usize {
let account_ids: Vec<String> = self
.sessions
.iter()
.filter(|entry| {
entry.value().user_hash == user_hash
&& entry.value().account_id != current_account_id
})
.map(|entry| entry.value().account_id.clone())
.collect();
let mut removed = 0;
for account_id in account_ids {
if self.remove_user_session(user_hash, &account_id) {
removed += 1;
}
}
removed
}
pub fn remove_browser(&self, browser_id: &str) {
if let Some((_, account_ids)) = self.browsers.remove(browser_id) {
for account_id in account_ids {
@@ -474,6 +479,282 @@ impl SessionStore {
pub fn is_empty(&self) -> bool {
self.sessions.is_empty()
}
fn cache_persisted(&self, record: &PersistedSession) -> Result<AccountSession, String> {
let crypto = self
.crypto
.as_ref()
.ok_or("session crypto is not configured")?;
let password = crypto.decrypt(&record.password_ciphertext)?;
let age = Self::unix_now().saturating_sub(record.last_accessed_unix);
let now = Instant::now();
let last_accessed = now.checked_sub(Duration::from_secs(age)).unwrap_or(now);
let timeout_override = if record.timeout_secs == self.timeout.as_secs() {
None
} else {
Some(Duration::from_secs(record.timeout_secs))
};
let session = AccountSession {
account_id: record.account_id.clone(),
email: record.email.clone(),
password,
user_hash: record.user_hash.clone(),
imap_host: record.imap_host.clone(),
imap_port: record.imap_port,
imap_tls: record.imap_tls,
imap_tls_verify: record.imap_tls_verify,
smtp_host: record.smtp_host.clone(),
smtp_port: record.smtp_port,
smtp_tls: record.smtp_tls,
smtp_tls_verify: record.smtp_tls_verify,
last_accessed,
timeout_override,
created_at_unix: record.created_at_unix,
last_accessed_unix: record.last_accessed_unix,
user_agent: record.user_agent.clone(),
};
self.sessions.insert(record.token.clone(), session.clone());
self.account_to_session
.insert(record.account_id.clone(), record.token.clone());
self.account_to_browser
.insert(record.account_id.clone(), record.browser_id.clone());
let mut entry = self.browsers.entry(record.browser_id.clone()).or_default();
if !entry.contains(&record.account_id) {
entry.push(record.account_id.clone());
}
Ok(session)
}
fn persisted_from_memory(
&self,
token: &str,
browser_id: &str,
session: &AccountSession,
) -> Result<PersistedSession, String> {
let crypto = self
.crypto
.as_ref()
.ok_or("session crypto is not configured")?;
Ok(PersistedSession {
token: token.to_string(),
account_id: session.account_id.clone(),
browser_id: browser_id.to_string(),
email: session.email.clone(),
password_ciphertext: crypto.encrypt(&session.password)?,
user_hash: session.user_hash.clone(),
imap_host: session.imap_host.clone(),
imap_port: session.imap_port,
imap_tls: session.imap_tls,
imap_tls_verify: session.imap_tls_verify,
smtp_host: session.smtp_host.clone(),
smtp_port: session.smtp_port,
smtp_tls: session.smtp_tls,
smtp_tls_verify: session.smtp_tls_verify,
timeout_secs: session.timeout_override.unwrap_or(self.timeout).as_secs(),
created_at_unix: session.created_at_unix,
last_accessed_unix: session.last_accessed_unix,
user_agent: session.user_agent.clone(),
})
}
async fn persist_account(&self, browser_id: &str, account_id: &str) {
let (Some(persistence), Some(token)) = (
self.persistence.as_ref(),
self.account_to_session.get(account_id).map(|v| v.clone()),
) else {
return;
};
let Some(session) = self.sessions.get(&token).map(|v| v.clone()) else {
return;
};
match self.persisted_from_memory(&token, browser_id, &session) {
Ok(record) => {
if let Err(error) = persistence.save(&record).await {
tracing::error!(%error, "failed to persist session");
}
}
Err(error) => tracing::error!(%error, "failed to encrypt session"),
}
}
pub async fn add_account_to_browser_shared(
&self,
browser_id: &str,
input: NewAccountSession,
) -> (SessionId, AccountId) {
let result = self.add_account_to_browser_with_timeout(
browser_id,
input.email,
input.password,
input.user_hash,
input.imap_host,
input.imap_port,
input.imap_tls,
input.smtp_host,
input.smtp_port,
input.smtp_tls,
input.timeout_override,
);
self.persist_account(browser_id, &result.1).await;
result
}
pub async fn get_browser_accounts_shared(&self, browser_id: &str) -> Vec<AccountSession> {
let Some(persistence) = self.persistence.as_ref() else {
return self.get_browser_accounts(browser_id);
};
match persistence.load_browser(browser_id).await {
Ok(records) => records
.into_iter()
.filter(|r| !r.expired_at(Self::unix_now()))
.filter_map(|r| {
self.cache_persisted(&r)
.map_err(|e| tracing::error!(error=%e, "failed to restore session"))
.ok()
})
.collect(),
Err(error) => {
tracing::error!(%error, "failed to load browser sessions");
self.get_browser_accounts(browser_id)
}
}
}
pub async fn browser_exists_shared(&self, browser_id: &str) -> bool {
let Some(persistence) = self.persistence.as_ref() else {
return self.browser_exists(browser_id);
};
persistence
.browser_exists(browser_id)
.await
.unwrap_or_else(|error| {
tracing::error!(%error, "failed to query browser session");
false
})
}
pub async fn get_shared(&self, token: &str) -> Option<AccountSession> {
let Some(persistence) = self.persistence.as_ref() else {
return self.get(token);
};
let record = persistence.load_token(token).await.ok().flatten()?;
if record.expired_at(Self::unix_now()) {
let _ = persistence.delete_account(&record.account_id).await;
return None;
}
self.cache_persisted(&record).ok()?;
let refreshed = self.get(token)?;
self.persist_account(&record.browser_id, &record.account_id)
.await;
Some(refreshed)
}
pub async fn get_account_session_shared(
&self,
browser_id: &str,
account_id: &str,
token: &str,
) -> Option<AccountSession> {
let Some(persistence) = self.persistence.as_ref() else {
return self.get_account_session(browser_id, account_id, token);
};
let record = persistence.load_token(token).await.ok().flatten()?;
if record.browser_id != browser_id
|| record.account_id != account_id
|| record.expired_at(Self::unix_now())
{
return None;
}
self.cache_persisted(&record).ok()?;
let session = self.get(token)?;
self.persist_account(browser_id, account_id).await;
Some(session)
}
pub async fn set_user_agent_shared(&self, account_id: &str, user_agent: String) {
self.set_user_agent(account_id, user_agent);
if let Some(browser_id) = self.account_to_browser.get(account_id).map(|v| v.clone()) {
self.persist_account(&browser_id, account_id).await;
}
}
pub async fn remove_account_shared(&self, account_id: &str) -> bool {
let local = self.remove_account(account_id);
let Some(persistence) = self.persistence.as_ref() else {
return local;
};
match persistence.delete_account(account_id).await {
Ok(remote) => local || remote,
Err(error) => {
tracing::error!(%error, "failed to delete persisted session");
local
}
}
}
pub async fn remove_browser_shared(&self, browser_id: &str) {
self.remove_browser(browser_id);
if let Some(persistence) = self.persistence.as_ref()
&& let Err(error) = persistence.delete_browser(browser_id).await
{
tracing::error!(%error, "failed to delete persisted browser sessions");
}
}
pub async fn get_user_sessions_shared(&self, user_hash: &str) -> Vec<AccountSession> {
let Some(persistence) = self.persistence.as_ref() else {
return self.get_user_sessions(user_hash);
};
match persistence.load_user(user_hash).await {
Ok(records) => records
.into_iter()
.filter(|r| !r.expired_at(Self::unix_now()))
.filter_map(|r| self.cache_persisted(&r).ok())
.collect(),
Err(error) => {
tracing::error!(%error, "failed to load user sessions");
self.get_user_sessions(user_hash)
}
}
}
pub async fn remove_user_session_shared(&self, user_hash: &str, account_id: &str) -> bool {
let sessions = self.get_user_sessions_shared(user_hash).await;
if !sessions.iter().any(|s| s.account_id == account_id) {
return false;
}
self.remove_account_shared(account_id).await
}
pub async fn remove_other_user_sessions_shared(
&self,
user_hash: &str,
current_account_id: &str,
) -> usize {
let ids: Vec<String> = self
.get_user_sessions_shared(user_hash)
.await
.into_iter()
.filter(|s| s.account_id != current_account_id)
.map(|s| s.account_id)
.collect();
let mut removed = 0;
for id in ids {
if self.remove_account_shared(&id).await {
removed += 1;
}
}
removed
}
pub async fn purge_expired_shared(&self) {
self.purge_expired();
if let Some(persistence) = self.persistence.as_ref()
&& let Err(error) = persistence.purge_expired(Self::unix_now()).await
{
tracing::error!(%error, "failed to purge persisted sessions");
}
}
}
#[cfg(test)]
@@ -1318,4 +1599,56 @@ mod tests {
"remembered session should outlive default timeout"
);
}
#[tokio::test]
async fn persistent_sqlite_session_survives_store_restart() {
let temp = tempfile::tempdir().expect("tempdir");
let config = crate::config::AppConfig {
data_dir: temp.path().to_string_lossy().into_owned(),
database_backend: "sqlite".to_string(),
session_backend: "database".to_string(),
..Default::default()
};
let persistence = crate::session_store::from_config(&config)
.await
.expect("persistence");
let crypto =
crate::session_store::crypto::SessionCrypto::load_or_create(&config).expect("crypto");
let store =
SessionStore::new_persistent(Duration::from_secs(3600), None, persistence, crypto);
let browser_id = store.create_browser();
let (token, account_id) = store
.add_account_to_browser_shared(
&browser_id,
NewAccountSession {
email: "restart@example.com".to_string(),
password: "secret-password".to_string(),
user_hash: "user-hash".to_string(),
imap_host: "imap.example.com".to_string(),
imap_port: 993,
imap_tls: true,
smtp_host: "smtp.example.com".to_string(),
smtp_port: 587,
smtp_tls: true,
timeout_override: None,
},
)
.await;
drop(store);
let persistence = crate::session_store::from_config(&config)
.await
.expect("persistence after restart");
let crypto = crate::session_store::crypto::SessionCrypto::load_or_create(&config)
.expect("crypto after restart");
let restarted =
SessionStore::new_persistent(Duration::from_secs(3600), None, persistence, crypto);
let restored = restarted
.get_account_session_shared(&browser_id, &account_id, &token)
.await
.expect("session should survive restart");
assert_eq!(restored.email, "restart@example.com");
assert_eq!(restored.password, "secret-password");
}
}