1use std::collections::HashMap;
24use std::sync::Arc;
25use std::time::{Duration, Instant};
26
27use serde::{Deserialize, Serialize};
28use tokio::sync::RwLock;
29use uuid::Uuid;
30
31use crate::stickiness::{StickinessPolicy, VendorStickinessMap};
32use crate::types::VendorId;
33
34const DEFAULT_TTL_SECS: u64 = 300;
36
37#[derive(Debug, Clone, Default, Serialize, Deserialize)]
51#[serde(rename_all = "snake_case", tag = "mode")]
52#[non_exhaustive]
53pub enum StickyPolicy {
54 #[default]
56 Disabled,
57 Domain {
59 #[serde(with = "serde_duration_secs")]
61 ttl: Duration,
62 },
63}
64
65impl StickyPolicy {
66 #[must_use]
68 pub const fn domain(ttl: Duration) -> Self {
69 Self::Domain { ttl }
70 }
71
72 #[must_use]
74 pub const fn domain_default() -> Self {
75 Self::Domain {
76 ttl: Duration::from_secs(DEFAULT_TTL_SECS),
77 }
78 }
79
80 #[must_use]
82 pub const fn is_disabled(&self) -> bool {
83 matches!(self, Self::Disabled)
84 }
85}
86
87#[derive(Debug, Clone)]
91struct ProxySession {
92 proxy_id: Uuid,
94 bound_at: Instant,
96 ttl: Duration,
98}
99
100impl ProxySession {
101 fn is_expired(&self) -> bool {
103 self.bound_at.elapsed() >= self.ttl
104 }
105}
106
107#[derive(Debug, Clone)]
126pub struct SessionMap {
127 inner: Arc<RwLock<HashMap<String, ProxySession>>>,
128}
129
130impl Default for SessionMap {
131 fn default() -> Self {
132 Self::new()
133 }
134}
135
136impl SessionMap {
137 #[must_use]
139 pub fn new() -> Self {
140 Self {
141 inner: Arc::new(RwLock::new(HashMap::new())),
142 }
143 }
144
145 #[must_use]
151 pub fn lookup(&self, key: &str) -> Option<Uuid> {
152 let guard = self.inner.try_read().ok()?;
154 guard
155 .get(key)
156 .filter(|s| !s.is_expired())
157 .map(|s| s.proxy_id)
158 }
159
160 pub fn bind(&self, key: &str, proxy_id: Uuid, ttl: Duration) {
163 let session = ProxySession {
164 proxy_id,
165 bound_at: Instant::now(),
166 ttl,
167 };
168 if let Ok(mut guard) = self.inner.try_write() {
169 guard.insert(key.to_string(), session);
170 }
171 }
172
173 #[must_use]
175 pub fn purge_expired(&self) -> usize {
176 let Ok(mut guard) = self.inner.try_write() else {
177 return 0;
178 };
179 let before = guard.len();
180 guard.retain(|_, s| !s.is_expired());
181 before - guard.len()
182 }
183
184 pub fn unbind(&self, key: &str) {
186 if let Ok(mut guard) = self.inner.try_write() {
187 guard.remove(key);
188 }
189 }
190
191 #[must_use]
193 pub fn active_count(&self) -> usize {
194 let Ok(guard) = self.inner.try_read() else {
195 return 0;
196 };
197 guard.values().filter(|s| !s.is_expired()).count()
198 }
199
200 #[must_use]
258 pub fn acquire_session(
259 &self,
260 domain: &str,
261 vendor: VendorId,
262 policy_map: &VendorStickinessMap,
263 ) -> SessionDecision {
264 let policy = policy_map.for_vendor(vendor);
265 let ttl = match policy {
266 StickinessPolicy::StickyForever => Some(Duration::MAX),
267 StickinessPolicy::StickyForTtl { ttl } => Some(ttl),
268 StickinessPolicy::StickyForRequestCount { .. }
269 | StickinessPolicy::FreshPerRequest
270 | StickinessPolicy::FreshPerDomain => None,
271 };
272
273 let evict_for_fresh_domain = matches!(policy, StickinessPolicy::FreshPerDomain);
280 ttl.map_or_else(
281 || {
282 if evict_for_fresh_domain {
283 self.unbind(domain);
284 }
285 SessionDecision::AcquireFresh
286 },
287 |ttl| {
288 self.lookup(domain).map_or(
289 SessionDecision::AcquireAndBind(ttl),
290 SessionDecision::UseSticky,
291 )
292 },
293 )
294 }
295}
296
297#[derive(Debug, Clone, PartialEq, Eq)]
321pub enum SessionDecision {
322 UseSticky(Uuid),
325 AcquireFresh,
329 AcquireAndBind(Duration),
333}
334
335mod serde_duration_secs {
338 use serde::{Deserialize, Deserializer, Serialize, Serializer};
339 use std::time::Duration;
340
341 pub fn serialize<S: Serializer>(d: &Duration, s: S) -> Result<S::Ok, S::Error> {
342 d.as_secs().serialize(s)
343 }
344
345 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Duration, D::Error> {
346 Ok(Duration::from_secs(u64::deserialize(d)?))
347 }
348}
349
350#[cfg(test)]
353#[allow(clippy::unwrap_used)]
354mod tests {
355 use super::*;
356
357 #[test]
358 fn same_domain_returns_same_proxy() {
359 let map = SessionMap::new();
360 let id = Uuid::new_v4();
361 map.bind("example.com", id, Duration::from_mins(1));
362 assert_eq!(map.lookup("example.com"), Some(id));
363 assert_eq!(map.lookup("example.com"), Some(id));
364 }
365
366 #[test]
367 fn different_domains_independent() {
368 let map = SessionMap::new();
369 let id_a = Uuid::new_v4();
370 let id_b = Uuid::new_v4();
371 map.bind("a.com", id_a, Duration::from_mins(1));
372 map.bind("b.com", id_b, Duration::from_mins(1));
373 assert_eq!(map.lookup("a.com"), Some(id_a));
374 assert_eq!(map.lookup("b.com"), Some(id_b));
375 }
376
377 #[test]
378 fn expired_session_returns_none() {
379 let map = SessionMap::new();
380 let id = Uuid::new_v4();
381 map.bind("example.com", id, Duration::ZERO);
383 std::thread::sleep(Duration::from_millis(1));
385 assert_eq!(map.lookup("example.com"), None);
386 }
387
388 #[test]
389 fn purge_removes_expired() {
390 let map = SessionMap::new();
391 map.bind("expired.com", Uuid::new_v4(), Duration::ZERO);
392 map.bind("active.com", Uuid::new_v4(), Duration::from_mins(5));
393 std::thread::sleep(Duration::from_millis(1));
394
395 let removed = map.purge_expired();
396 assert_eq!(removed, 1);
397 assert_eq!(map.active_count(), 1);
398 }
399
400 #[test]
401 fn unbind_removes_session() {
402 let map = SessionMap::new();
403 map.bind("example.com", Uuid::new_v4(), Duration::from_mins(1));
404 map.unbind("example.com");
405 assert_eq!(map.lookup("example.com"), None);
406 }
407
408 #[test]
409 fn rebind_overwrites_previous() {
410 let map = SessionMap::new();
411 let old_id = Uuid::new_v4();
412 let new_id = Uuid::new_v4();
413 map.bind("example.com", old_id, Duration::from_mins(1));
414 map.bind("example.com", new_id, Duration::from_mins(1));
415 assert_eq!(map.lookup("example.com"), Some(new_id));
416 }
417
418 #[test]
419 fn policy_domain_default_ttl() {
420 let policy = StickyPolicy::domain_default();
421 assert!(matches!(policy, StickyPolicy::Domain { ttl } if ttl == Duration::from_mins(5)));
422 }
423
424 #[test]
425 fn policy_disabled_by_default() {
426 let policy = StickyPolicy::default();
427 assert!(policy.is_disabled());
428 }
429
430 #[test]
431 fn policy_serde_roundtrip() -> std::result::Result<(), Box<dyn std::error::Error>> {
432 let policy = StickyPolicy::domain(Duration::from_mins(2));
433 let json = serde_json::to_string(&policy)?;
434 let back: StickyPolicy = serde_json::from_str(&json)?;
435 assert!(matches!(back, StickyPolicy::Domain { ttl } if ttl == Duration::from_mins(2)));
436 Ok(())
437 }
438
439 use crate::stickiness::{StickinessPolicy, VendorStickinessMap};
442
443 fn vendor_policy_map() -> VendorStickinessMap {
444 VendorStickinessMap::with_builtin_defaults()
445 }
446
447 #[test]
448 fn acquire_session_akamai_no_binding_returns_acquire_and_bind_30min() {
449 let map = SessionMap::new();
450 let policy = vendor_policy_map();
451
452 let decision = map.acquire_session("example.com", VendorId::Akamai, &policy);
453 assert_eq!(
454 decision,
455 SessionDecision::AcquireAndBind(Duration::from_mins(30))
456 );
457 }
458
459 #[test]
460 fn acquire_session_akamai_with_existing_binding_returns_sticky() {
461 let map = SessionMap::new();
462 let policy = vendor_policy_map();
463 let proxy_id = Uuid::new_v4();
464
465 map.bind("example.com", proxy_id, Duration::from_mins(30));
467
468 let decision = map.acquire_session("example.com", VendorId::Akamai, &policy);
469 assert_eq!(decision, SessionDecision::UseSticky(proxy_id));
470 }
471
472 #[test]
473 fn acquire_session_akamai_100_calls_within_ttl_return_same_proxy() {
474 let map = SessionMap::new();
475 let policy = vendor_policy_map();
476 let proxy_id = Uuid::new_v4();
477 map.bind("example.com", proxy_id, Duration::from_mins(30));
478
479 for _ in 0..100 {
480 assert_eq!(
481 map.acquire_session("example.com", VendorId::Akamai, &policy),
482 SessionDecision::UseSticky(proxy_id)
483 );
484 }
485 }
486
487 #[test]
488 fn acquire_session_akamai_expired_binding_returns_acquire_and_bind() {
489 let map = SessionMap::new();
490 let policy = vendor_policy_map();
491 let stale_id = Uuid::new_v4();
492
493 map.bind("example.com", stale_id, Duration::ZERO);
495 std::thread::sleep(Duration::from_millis(1));
496
497 let decision = map.acquire_session("example.com", VendorId::Akamai, &policy);
498 assert_eq!(
499 decision,
500 SessionDecision::AcquireAndBind(Duration::from_mins(30))
501 );
502 }
503
504 #[test]
505 fn acquire_session_cloudflare_no_binding_returns_acquire_and_bind_5min() {
506 let map = SessionMap::new();
507 let policy = vendor_policy_map();
508
509 let decision = map.acquire_session("example.com", VendorId::Cloudflare, &policy);
510 assert_eq!(
511 decision,
512 SessionDecision::AcquireAndBind(Duration::from_mins(5))
513 );
514 }
515
516 #[test]
517 fn acquire_session_imperva_no_binding_returns_acquire_and_bind_15min() {
518 let map = SessionMap::new();
519 let policy = vendor_policy_map();
520
521 let decision = map.acquire_session("example.com", VendorId::Imperva, &policy);
522 assert_eq!(
523 decision,
524 SessionDecision::AcquireAndBind(Duration::from_mins(15))
525 );
526 }
527
528 #[test]
529 fn acquire_session_data_dome_always_returns_acquire_fresh() {
530 let map = SessionMap::new();
531 let policy = vendor_policy_map();
532
533 map.bind("example.com", Uuid::new_v4(), Duration::from_hours(1));
535 let decision = map.acquire_session("example.com", VendorId::DataDome, &policy);
536 assert_eq!(decision, SessionDecision::AcquireFresh);
537 }
538
539 #[test]
540 fn acquire_session_perimeter_x_evicts_existing_binding() {
541 let map = SessionMap::new();
542 let policy = vendor_policy_map();
543 map.bind("example.com", Uuid::new_v4(), Duration::from_hours(1));
544
545 let decision = map.acquire_session("example.com", VendorId::PerimeterX, &policy);
546 assert_eq!(decision, SessionDecision::AcquireFresh);
547 assert_eq!(map.lookup("example.com"), None);
550 }
551
552 #[test]
553 fn acquire_session_perimeter_x_no_existing_binding_returns_fresh() {
554 let map = SessionMap::new();
555 let policy = vendor_policy_map();
556 let decision = map.acquire_session("example.com", VendorId::PerimeterX, &policy);
557 assert_eq!(decision, SessionDecision::AcquireFresh);
558 }
559
560 #[test]
561 fn acquire_session_kasada_evicts_existing_binding() {
562 let map = SessionMap::new();
563 let policy = vendor_policy_map();
564 map.bind("example.com", Uuid::new_v4(), Duration::from_hours(1));
565
566 let decision = map.acquire_session("example.com", VendorId::Kasada, &policy);
567 assert_eq!(decision, SessionDecision::AcquireFresh);
568 assert_eq!(map.lookup("example.com"), None);
569 }
570
571 #[test]
572 fn acquire_session_unknown_vendor_defaults_to_fresh() {
573 let map = SessionMap::new();
574 let policy = vendor_policy_map();
575 map.bind("example.com", Uuid::new_v4(), Duration::from_hours(1));
576
577 let decision = map.acquire_session("example.com", VendorId::Unknown, &policy);
582 assert_eq!(decision, SessionDecision::AcquireFresh);
583 assert!(map.lookup("example.com").is_some());
585 }
586
587 #[test]
588 fn acquire_session_sticky_forever_uses_max_duration() {
589 let map = SessionMap::new();
590 let custom = VendorStickinessMap::new()
591 .with_override(VendorId::Akamai, StickinessPolicy::StickyForever);
592
593 let decision = map.acquire_session("example.com", VendorId::Akamai, &custom);
594 assert_eq!(decision, SessionDecision::AcquireAndBind(Duration::MAX));
595 }
596
597 #[test]
598 fn acquire_session_sticky_for_request_count_treated_as_fresh() {
599 let map = SessionMap::new();
600 let custom = VendorStickinessMap::new().with_override(
601 VendorId::Akamai,
602 StickinessPolicy::StickyForRequestCount { max_requests: 5 },
603 );
604
605 let decision = map.acquire_session("example.com", VendorId::Akamai, &custom);
606 assert_eq!(decision, SessionDecision::AcquireFresh);
607 }
608
609 #[test]
610 fn acquire_session_sticky_forever_uses_existing_binding() {
611 let map = SessionMap::new();
612 let custom = VendorStickinessMap::new()
613 .with_override(VendorId::Akamai, StickinessPolicy::StickyForever);
614 let proxy_id = Uuid::new_v4();
615 map.bind("example.com", proxy_id, Duration::from_hours(1));
616
617 let decision = map.acquire_session("example.com", VendorId::Akamai, &custom);
618 assert_eq!(decision, SessionDecision::UseSticky(proxy_id));
619 }
620
621 #[test]
622 fn acquire_session_override_replaces_builtin_akamai_policy() {
623 let map = SessionMap::new();
624 let policy = vendor_policy_map().with_override(
625 VendorId::Akamai,
626 StickinessPolicy::StickyForTtl {
627 ttl: Duration::from_mins(2),
628 },
629 );
630
631 let decision = map.acquire_session("example.com", VendorId::Akamai, &policy);
632 assert_eq!(
633 decision,
634 SessionDecision::AcquireAndBind(Duration::from_mins(2))
635 );
636 }
637
638 #[test]
639 fn acquire_session_empty_map_defaults_all_to_fresh() {
640 let map = SessionMap::new();
644 let empty = VendorStickinessMap::new();
645 map.bind("example.com", Uuid::new_v4(), Duration::from_hours(1));
646
647 for vendor in [
648 VendorId::Akamai,
649 VendorId::Cloudflare,
650 VendorId::DataDome,
651 VendorId::PerimeterX,
652 VendorId::Kasada,
653 VendorId::Imperva,
654 VendorId::Unknown,
655 VendorId::Hcaptcha,
656 ] {
657 assert_eq!(
658 map.acquire_session("example.com", vendor, &empty),
659 SessionDecision::AcquireFresh,
660 "{vendor:?} should default to fresh when no entry exists"
661 );
662 }
663 }
664}