use anyhow::Result;
use std::collections::HashMap;
use std::fs::{File, remove_file};
use std::io::Write;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use thiserror::Error;
use uuid::Uuid;
use tempfile::Builder;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct BridgeSessionId(Uuid);
impl BridgeSessionId {
pub fn new() -> Self {
BridgeSessionId(Uuid::new_v4())
}
pub fn as_u64(&self) -> u64 {
let bytes = self.0.as_bytes();
let mut arr = [0u8; 8];
arr.copy_from_slice(&bytes[..8]);
u64::from_be_bytes(arr)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SessionState {
Open,
Finalized,
Expired,
}
#[derive(Debug, Clone)]
pub struct BridgeSessionConfig {
pub max_bytes: u64,
pub ttl: Duration,
pub idle_exit: Duration,
}
#[derive(Debug, Error, PartialEq, Eq)]
pub enum SessionError {
#[error("session not found")]
NotFound,
#[error("session expired")]
Expired,
#[error("session already finalized")]
AlreadyFinalized,
#[error("payload too large: limit {limit} bytes")]
TooLarge { limit: u64 },
#[error("invalid state")]
InvalidState,
}
#[derive(Debug)]
pub struct BridgeSession {
pub id: BridgeSessionId,
pub state: SessionState,
pub config: BridgeSessionConfig,
pub created_at: Instant,
pub last_activity_at: Instant,
pub bytes_used: u64,
payload: PayloadStorage,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SessionPayload {
InMemory(Vec<u8>),
TempFile(PathBuf),
}
#[derive(Debug)]
enum PayloadStorage {
InMemory(Vec<u8>),
TempFile { path: PathBuf, file: File },
}
impl BridgeSession {
fn new(config: BridgeSessionConfig, now: Instant) -> Self {
Self {
id: BridgeSessionId::new(),
state: SessionState::Open,
config,
created_at: now,
last_activity_at: now,
bytes_used: 0,
payload: PayloadStorage::InMemory(Vec::new()),
}
}
fn expired(&self, now: Instant) -> bool {
now.duration_since(self.created_at) >= self.config.ttl
|| now.duration_since(self.last_activity_at) >= self.config.idle_exit
}
}
#[derive(Debug, Clone)]
pub struct SessionStoreConfig {
pub default_max_bytes: u64,
pub default_ttl: Duration,
pub default_idle_exit: Duration,
pub spill_threshold: u64,
}
pub struct SessionStore {
sessions: HashMap<BridgeSessionId, BridgeSession>,
pub config: SessionStoreConfig,
clipboard_applied: u64,
pub size_violations: u64,
pub ttl_violations: u64,
pub last_abort_reason: Option<String>,
pub start_instant: Instant,
clock: Box<dyn Fn() -> Instant + Send + Sync>,
}
impl SessionStore {
pub fn new(config: SessionStoreConfig) -> Arc<Mutex<Self>> {
Arc::new(Mutex::new(Self {
sessions: HashMap::new(),
config,
clipboard_applied: 0,
size_violations: 0,
ttl_violations: 0,
last_abort_reason: None,
start_instant: Instant::now(),
clock: Box::new(Instant::now),
}))
}
pub fn with_clock<F>(config: SessionStoreConfig, clock: F) -> Arc<Mutex<Self>>
where
F: Fn() -> Instant + Send + Sync + 'static,
{
Arc::new(Mutex::new(Self {
sessions: HashMap::new(),
config,
clipboard_applied: 0,
size_violations: 0,
ttl_violations: 0,
last_abort_reason: None,
start_instant: (clock)(),
clock: Box::new(clock),
}))
}
pub fn open_session(&mut self, override_cfg: Option<BridgeSessionConfig>) -> BridgeSessionId {
let now = (self.clock)();
let cfg = override_cfg.unwrap_or(BridgeSessionConfig {
max_bytes: self.config.default_max_bytes,
ttl: self.config.default_ttl,
idle_exit: self.config.default_idle_exit,
});
let session = BridgeSession::new(cfg, now);
let id = session.id;
self.sessions.insert(id, session);
id
}
pub fn put_chunk(&mut self, id: BridgeSessionId, data: &[u8]) -> Result<(), SessionError> {
let now = (self.clock)();
let session = self.sessions.get_mut(&id).ok_or(SessionError::NotFound)?;
if session.expired(now) {
session.state = SessionState::Expired;
return Err(SessionError::Expired);
}
match session.state {
SessionState::Open => {
let new_total = session.bytes_used + data.len() as u64;
if new_total > session.config.max_bytes {
return Err(SessionError::TooLarge {
limit: session.config.max_bytes,
});
}
match &mut session.payload {
PayloadStorage::InMemory(buf) => {
if self.config.spill_threshold > 0 && new_total > self.config.spill_threshold {
let tmp = Builder::new()
.prefix("wsl-clip-session-")
.tempfile()
.map_err(|_| SessionError::InvalidState)?;
let (mut file, path) = tmp.keep().map_err(|_| SessionError::InvalidState)?;
file.write_all(buf).map_err(|_| SessionError::InvalidState)?;
file.write_all(data).map_err(|_| SessionError::InvalidState)?;
session.payload = PayloadStorage::TempFile { path, file };
} else {
buf.extend_from_slice(data);
}
}
PayloadStorage::TempFile { file, .. } => {
file.write_all(data).map_err(|_| SessionError::InvalidState)?;
}
}
session.bytes_used = new_total;
session.last_activity_at = now;
Ok(())
}
SessionState::Finalized => Err(SessionError::AlreadyFinalized),
SessionState::Expired => Err(SessionError::Expired),
}
}
pub fn finalize(&mut self, id: BridgeSessionId) -> Result<(), SessionError> {
let now = (self.clock)();
let session = self.sessions.get_mut(&id).ok_or(SessionError::NotFound)?;
if session.expired(now) {
session.state = SessionState::Expired;
return Err(SessionError::Expired);
}
match session.state {
SessionState::Open => {
session.state = SessionState::Finalized;
session.last_activity_at = now;
Ok(())
}
SessionState::Finalized => Err(SessionError::AlreadyFinalized),
SessionState::Expired => Err(SessionError::Expired),
}
}
pub fn get_payload(&mut self, id: BridgeSessionId) -> Result<SessionPayload, SessionError> {
let now = (self.clock)();
let session = self.sessions.get_mut(&id).ok_or(SessionError::NotFound)?;
if session.expired(now) {
session.state = SessionState::Expired;
return Err(SessionError::Expired);
}
if session.state != SessionState::Finalized {
return Err(SessionError::InvalidState);
}
session.last_activity_at = now;
match &session.payload {
PayloadStorage::InMemory(buf) => Ok(SessionPayload::InMemory(buf.clone())),
PayloadStorage::TempFile { path, .. } => Ok(SessionPayload::TempFile(path.clone())),
}
}
pub fn expire_session(&mut self, id: BridgeSessionId) -> bool {
if let Some(sess) = self.sessions.remove(&id) {
if let PayloadStorage::TempFile { path, .. } = sess.payload {
let _ = remove_file(path);
}
true
} else {
false
}
}
pub fn mark_clipboard_applied(&mut self, _id: BridgeSessionId) {
self.clipboard_applied += 1;
}
pub fn record_size_violation(&mut self, reason: &str) {
self.size_violations += 1;
self.last_abort_reason = Some(reason.to_string());
}
pub fn record_ttl_violation(&mut self, reason: &str) {
self.ttl_violations += 1;
self.last_abort_reason = Some(reason.to_string());
}
pub fn record_abort_reason(&mut self, reason: &str) {
self.last_abort_reason = Some(reason.to_string());
}
pub fn cleanup_expired(&mut self) -> usize {
let now = (self.clock)();
let before = self.sessions.len();
self.sessions.retain(|_, s| {
if s.expired(now) {
self.ttl_violations += 1;
if let PayloadStorage::TempFile { path, .. } = &s.payload {
let _ = remove_file(path);
}
false
} else {
true
}
});
before - self.sessions.len()
}
pub fn summary(&self) -> SessionStoreSummary {
let now = (self.clock)();
let mut last_activity = None;
for s in self.sessions.values() {
if s.expired(now) {
continue;
}
match last_activity {
Some(ts) if ts >= s.last_activity_at => {}
_ => last_activity = Some(s.last_activity_at),
}
}
SessionStoreSummary {
active: self.sessions.len(),
last_activity,
clipboard_applied: self.clipboard_applied,
size_violations: self.size_violations,
ttl_violations: self.ttl_violations,
last_abort_reason: self.last_abort_reason.clone(),
start_instant: self.start_instant,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SessionStoreSummary {
pub active: usize,
pub last_activity: Option<Instant>,
pub clipboard_applied: u64,
pub size_violations: u64,
pub ttl_violations: u64,
pub last_abort_reason: Option<String>,
pub start_instant: Instant,
}
#[cfg(test)]
mod tests {
use super::*;
fn fixed_clock() -> impl Fn() -> Instant {
let start = Instant::now();
move || start
}
fn advanceable_clock(start: Instant, step: Duration) -> impl Fn() -> Instant {
use std::sync::{Arc, Mutex};
let current = Arc::new(Mutex::new(start));
move || {
let mut guard = current.lock().unwrap();
let now = *guard;
*guard += step;
now
}
}
#[test]
fn open_put_finalize_payload() {
let cfg = SessionStoreConfig {
default_max_bytes: 10,
default_ttl: Duration::from_secs(60),
default_idle_exit: Duration::from_secs(60),
spill_threshold: 1_000,
};
let store = SessionStore::with_clock(cfg, fixed_clock());
let mut store = store.lock().unwrap();
let id = store.open_session(None);
store.put_chunk(id, b"hi").unwrap();
store.finalize(id).unwrap();
let payload = store.get_payload(id).unwrap();
assert_eq!(payload, SessionPayload::InMemory(b"hi".to_vec()));
}
#[test]
fn rejects_over_max_bytes() {
let cfg = SessionStoreConfig {
default_max_bytes: 5,
default_ttl: Duration::from_secs(60),
default_idle_exit: Duration::from_secs(60),
spill_threshold: 1_000,
};
let store = SessionStore::with_clock(cfg, fixed_clock());
let mut store = store.lock().unwrap();
let id = store.open_session(None);
let err = store.put_chunk(id, b"012345").unwrap_err();
assert!(matches!(err, SessionError::TooLarge { .. }));
}
#[test]
fn expires_on_ttl() {
let cfg = SessionStoreConfig {
default_max_bytes: 10,
default_ttl: Duration::from_secs(1),
default_idle_exit: Duration::from_secs(10),
spill_threshold: 1_000,
};
let start = Instant::now();
let store = SessionStore::with_clock(cfg, advanceable_clock(start, Duration::from_secs(2)));
let mut store = store.lock().unwrap();
let id = store.open_session(None);
let err = store.put_chunk(id, b"hi").unwrap_err();
assert_eq!(err, SessionError::Expired);
}
#[test]
fn payload_reader_in_memory() {
let cfg = SessionStoreConfig {
default_max_bytes: 20,
default_ttl: Duration::from_secs(60),
default_idle_exit: Duration::from_secs(60),
spill_threshold: 1_000,
};
let store = SessionStore::with_clock(cfg, fixed_clock());
let mut store = store.lock().unwrap();
let id = store.open_session(None);
store.put_chunk(id, b"abc").unwrap();
store.put_chunk(id, b"123").unwrap();
store.finalize(id).unwrap();
let payload = store.get_payload(id).unwrap();
assert_eq!(payload, SessionPayload::InMemory(b"abc123".to_vec()));
}
#[test]
fn idle_exit_triggers_expiry() {
let cfg = SessionStoreConfig {
default_max_bytes: 10,
default_ttl: Duration::from_secs(100),
default_idle_exit: Duration::from_secs(1),
spill_threshold: 1_000,
};
let start = Instant::now();
let store = SessionStore::with_clock(cfg, advanceable_clock(start, Duration::from_secs(2)));
let mut store = store.lock().unwrap();
let id = store.open_session(None);
let err = store.put_chunk(id, b"hi").unwrap_err();
assert_eq!(err, SessionError::Expired);
}
#[test]
fn cleanup_removes_expired() {
let cfg = SessionStoreConfig {
default_max_bytes: 10,
default_ttl: Duration::from_secs(1),
default_idle_exit: Duration::from_secs(1),
spill_threshold: 1_000,
};
let start = Instant::now();
let store = SessionStore::with_clock(cfg, advanceable_clock(start, Duration::from_secs(2)));
let mut store = store.lock().unwrap();
let id = store.open_session(None);
let _ = store.put_chunk(id, b"hi").err();
let removed = store.cleanup_expired();
assert_eq!(removed, 1);
let err = store.put_chunk(id, b"x").unwrap_err();
assert_eq!(err, SessionError::NotFound);
}
#[test]
fn spills_to_temp_file_when_threshold_exceeded() {
let cfg = SessionStoreConfig {
default_max_bytes: 50,
default_ttl: Duration::from_secs(60),
default_idle_exit: Duration::from_secs(60),
spill_threshold: 8,
};
let store = SessionStore::with_clock(cfg, fixed_clock());
let mut store = store.lock().unwrap();
let id = store.open_session(None);
store.put_chunk(id, b"1234").unwrap();
store.put_chunk(id, b"567890").unwrap(); store.finalize(id).unwrap();
let payload = store.get_payload(id).unwrap();
match payload {
SessionPayload::TempFile(path) => {
let data = std::fs::read(path).unwrap();
assert_eq!(data, b"1234567890");
}
_ => panic!("expected tempfile"),
}
}
#[test]
fn cleanup_removes_temp_file_on_expire() {
let cfg = SessionStoreConfig {
default_max_bytes: 50,
default_ttl: Duration::from_secs(1),
default_idle_exit: Duration::from_secs(1),
spill_threshold: 8,
};
let start = Instant::now();
let store = SessionStore::with_clock(cfg, advanceable_clock(start, Duration::from_millis(500)));
let mut store = store.lock().unwrap();
let id = store.open_session(None);
store.put_chunk(id, b"1234567890").unwrap(); let removed = store.cleanup_expired();
assert_eq!(removed, 1);
}
}