use std::collections::HashMap;
use std::sync::Mutex;
use std::time::{Duration, Instant};
pub const TICKET_TTL: Duration = Duration::from_secs(30);
#[derive(Default)]
pub struct SseTicketStore {
inner: Mutex<HashMap<String, Instant>>,
}
impl SseTicketStore {
pub fn new() -> Self {
Self {
inner: Mutex::new(HashMap::new()),
}
}
pub fn issue(&self) -> String {
use rand::RngCore;
let mut bytes = [0u8; 16];
rand::rng().fill_bytes(&mut bytes);
let ticket: String = bytes.iter().map(|b| format!("{b:02x}")).collect();
let now = Instant::now();
let mut map = self.inner.lock().unwrap_or_else(|e| e.into_inner());
map.retain(|_, exp| *exp > now);
map.insert(ticket.clone(), now + TICKET_TTL);
ticket
}
pub fn consume(&self, ticket: &str) -> bool {
if ticket.is_empty() {
return false;
}
let now = Instant::now();
let mut map = self.inner.lock().unwrap_or_else(|e| e.into_inner());
match map.remove(ticket) {
Some(exp) => exp > now,
None => false,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn issued_ticket_can_be_consumed_once() {
let store = SseTicketStore::new();
let t = store.issue();
assert!(store.consume(&t), "first consume should succeed");
assert!(!store.consume(&t), "second consume must fail (single-use)");
}
#[test]
fn unknown_or_empty_ticket_rejected() {
let store = SseTicketStore::new();
assert!(!store.consume(""));
assert!(!store.consume("deadbeef"));
}
#[test]
fn expired_ticket_rejected() {
let store = SseTicketStore::new();
let t = store.issue();
{
let mut map = store.inner.lock().unwrap();
let exp = map.get_mut(&t).unwrap();
*exp = Instant::now() - Duration::from_secs(1);
}
assert!(!store.consume(&t), "expired ticket must be rejected");
}
#[test]
fn tickets_are_distinct_and_high_entropy() {
let store = SseTicketStore::new();
let a = store.issue();
let b = store.issue();
assert_ne!(a, b);
assert_eq!(a.len(), 32, "16 bytes -> 32 hex chars");
}
}