roboticus_api/
ws_ticket.rs1use std::collections::HashMap;
13use std::sync::{Arc, Mutex};
14use std::time::Instant;
15
16use rand::RngCore;
17
18const TICKET_TTL: std::time::Duration = std::time::Duration::from_secs(30);
20
21const MAX_OUTSTANDING: usize = 1000;
23
24const CLEANUP_INTERVAL: std::time::Duration = std::time::Duration::from_secs(60);
26
27struct TicketEntry {
28 issued_at: Instant,
29}
30
31#[derive(Clone)]
37pub struct TicketStore {
38 inner: Arc<Mutex<TicketStoreInner>>,
39}
40
41struct TicketStoreInner {
42 tickets: HashMap<String, TicketEntry>,
43 last_cleanup: Instant,
44}
45
46fn lock_or_recover<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
47 m.lock().unwrap_or_else(|poisoned| {
48 tracing::warn!("ticket store mutex poisoned; recovering state");
49 poisoned.into_inner()
50 })
51}
52
53impl Default for TicketStore {
54 fn default() -> Self {
55 Self::new()
56 }
57}
58
59impl TicketStore {
60 pub fn new() -> Self {
61 Self {
62 inner: Arc::new(Mutex::new(TicketStoreInner {
63 tickets: HashMap::new(),
64 last_cleanup: Instant::now(),
65 })),
66 }
67 }
68
69 pub fn issue(&self) -> String {
71 let mut bytes = [0u8; 32];
72 rand::rngs::OsRng.fill_bytes(&mut bytes);
73 let ticket = format!("wst_{}", hex::encode(bytes));
74
75 let mut inner = lock_or_recover(&self.inner);
76
77 if inner.tickets.len() >= MAX_OUTSTANDING
79 || inner.last_cleanup.elapsed() >= CLEANUP_INTERVAL
80 {
81 let now = Instant::now();
82 inner
83 .tickets
84 .retain(|_, e| now.duration_since(e.issued_at) < TICKET_TTL);
85 inner.last_cleanup = now;
86 }
87
88 inner.tickets.insert(
89 ticket.clone(),
90 TicketEntry {
91 issued_at: Instant::now(),
92 },
93 );
94 ticket
95 }
96
97 pub fn redeem(&self, ticket: &str) -> bool {
100 let mut inner = lock_or_recover(&self.inner);
101 match inner.tickets.remove(ticket) {
102 Some(entry) => entry.issued_at.elapsed() < TICKET_TTL,
103 None => false,
104 }
105 }
106}
107
108#[cfg(test)]
109mod tests {
110 use super::*;
111 use std::thread;
112 use std::time::Duration;
113
114 #[test]
115 fn issue_format() {
116 let store = TicketStore::new();
117 let ticket = store.issue();
118 assert!(ticket.starts_with("wst_"), "ticket should have wst_ prefix");
119 assert_eq!(ticket.len(), 68, "ticket should be 68 chars total");
121 assert!(
123 hex::decode(&ticket[4..]).is_ok(),
124 "suffix should be valid hex"
125 );
126 }
127
128 #[test]
129 fn issue_unique() {
130 let store = TicketStore::new();
131 let t1 = store.issue();
132 let t2 = store.issue();
133 assert_ne!(t1, t2, "tickets should be unique");
134 }
135
136 #[test]
137 fn redeem_valid() {
138 let store = TicketStore::new();
139 let ticket = store.issue();
140 assert!(store.redeem(&ticket), "valid ticket should redeem");
141 }
142
143 #[test]
144 fn redeem_invalid() {
145 let store = TicketStore::new();
146 assert!(
147 !store.redeem("wst_0000000000000000000000000000000000000000000000000000000000000000"),
148 "unknown ticket should not redeem"
149 );
150 }
151
152 #[test]
153 fn redeem_single_use() {
154 let store = TicketStore::new();
155 let ticket = store.issue();
156 assert!(store.redeem(&ticket), "first redeem should succeed");
157 assert!(!store.redeem(&ticket), "second redeem should fail");
158 }
159
160 #[test]
161 fn redeem_expired() {
162 let store = TicketStore::new();
164 {
165 let mut inner = store.inner.lock().unwrap();
166 inner.tickets.insert(
167 "wst_expired".to_string(),
168 TicketEntry {
169 issued_at: Instant::now() - Duration::from_secs(60),
170 },
171 );
172 }
173 assert!(
174 !store.redeem("wst_expired"),
175 "expired ticket should not redeem"
176 );
177 }
178
179 #[test]
180 fn cleanup_evicts_expired() {
181 let store = TicketStore::new();
182 {
184 let mut inner = store.inner.lock().unwrap();
185 inner.tickets.insert(
186 "wst_old".to_string(),
187 TicketEntry {
188 issued_at: Instant::now() - Duration::from_secs(60),
189 },
190 );
191 inner.last_cleanup = Instant::now() - Duration::from_secs(120);
193 }
194 let _new = store.issue();
196 let inner = store.inner.lock().unwrap();
197 assert!(
198 !inner.tickets.contains_key("wst_old"),
199 "expired ticket should be cleaned up"
200 );
201 }
202
203 #[test]
204 fn empty_string_not_redeemable() {
205 let store = TicketStore::new();
206 assert!(!store.redeem(""), "empty string should not redeem");
207 }
208
209 #[test]
210 fn concurrent_issue_and_redeem() {
211 let store = TicketStore::new();
212 let tickets: Vec<String> = (0..100).map(|_| store.issue()).collect();
213
214 let store_clone = store.clone();
215 let tickets_clone = tickets.clone();
216 let handle = thread::spawn(move || {
217 tickets_clone
218 .iter()
219 .filter(|t| store_clone.redeem(t))
220 .count()
221 });
222
223 let count_main = tickets.iter().filter(|t| store.redeem(t)).count();
224 let count_thread = handle.join().unwrap();
225
226 assert_eq!(
228 count_main + count_thread,
229 100,
230 "all tickets should be redeemed exactly once"
231 );
232 }
233}