1mod outcome;
4#[cfg(test)]
5mod tests;
6
7use std::{
8 collections::HashMap,
9 marker::PhantomData,
10 net::IpAddr,
11 panic::{AssertUnwindSafe, catch_unwind},
12 sync::{
13 Arc, Mutex, PoisonError,
14 atomic::{AtomicBool, Ordering},
15 },
16 time::Duration,
17};
18
19use chrono::{DateTime, TimeDelta, Utc};
20use uuid::Uuid;
21use zeroize::Zeroizing;
22
23pub use outcome::{
24 AuthSession, Authenticated, IssuedToken, NewSession, Outcome, PurgeReport, Rejection, SessionInfo, StepUpOutcome,
25};
26
27use crate::{
28 checks::{Check, Checks, Decision},
29 client::{ClientContext, HeaderSource, resolve_client_ip},
30 clock::{Clock, SystemClock},
31 config::{GuardConfig, Mode, StepUpPolicy},
32 error::GuardError,
33 event::{EventKind, SessionEvent},
34 fingerprint::{Baseline, Fingerprint, UaCache},
35 intel::{IpIntel, NoIpIntel},
36 store::{Rotation, SessionRecord, SessionStore, SessionUpdate, Supersede},
37 subject::Subject,
38 token::{SessionToken, TokenHash},
39};
40
41use secrecy::SecretString;
42
43type Hook = Arc<dyn Fn(&SessionEvent) + Send + Sync>;
44
45pub struct Guard<St, S = Uuid> {
49 inner: Arc<Inner<St>>,
50 _subject: PhantomData<fn() -> S>,
51}
52
53struct Inner<St> {
54 store: St,
55 config: GuardConfig,
56 intel: Box<dyn IpIntel>,
57 clock: Box<dyn Clock>,
58 hook: Option<Hook>,
59 ua_cache: UaCache,
60 xff_warned: AtomicBool,
61 shadow_seen: Mutex<ShadowSeen>,
62}
63
64struct ShadowSeen {
66 seen: HashMap<(Uuid, EventKind), DateTime<Utc>>,
67 pruned_at: DateTime<Utc>,
68}
69
70impl<St, S> Clone for Guard<St, S> {
71 fn clone(&self) -> Self {
72 Self {
73 inner: Arc::clone(&self.inner),
74 _subject: PhantomData,
75 }
76 }
77}
78
79impl<St, S> std::fmt::Debug for Guard<St, S> {
80 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81 f.debug_struct("Guard")
82 .field("config", &self.inner.config)
83 .finish_non_exhaustive()
84 }
85}
86
87#[must_use]
89pub struct GuardBuilder<St, S = Uuid> {
90 store: St,
91 config: GuardConfig,
92 intel: Box<dyn IpIntel>,
93 clock: Box<dyn Clock>,
94 hook: Option<Hook>,
95 _subject: PhantomData<fn() -> S>,
96}
97
98impl<St: SessionStore> Guard<St> {
99 pub fn new(store: St) -> Self {
101 Self::builder(store, GuardConfig::default()).build()
102 }
103
104 pub fn builder(store: St, config: GuardConfig) -> GuardBuilder<St> {
107 GuardBuilder {
108 store,
109 config,
110 intel: Box::new(NoIpIntel),
111 clock: Box::new(SystemClock),
112 hook: None,
113 _subject: PhantomData,
114 }
115 }
116}
117
118impl<St: SessionStore, S: Subject> GuardBuilder<St, S> {
119 pub fn subject<T: Subject>(self) -> GuardBuilder<St, T> {
121 GuardBuilder {
122 store: self.store,
123 config: self.config,
124 intel: self.intel,
125 clock: self.clock,
126 hook: self.hook,
127 _subject: PhantomData,
128 }
129 }
130
131 pub fn ip_intel(mut self, intel: impl IpIntel) -> Self {
133 self.intel = Box::new(intel);
134 self
135 }
136
137 pub fn on_event(mut self, hook: impl Fn(&SessionEvent) + Send + Sync + 'static) -> Self {
140 self.hook = Some(Arc::new(hook));
141 self
142 }
143
144 pub fn clock(mut self, clock: impl Clock) -> Self {
146 self.clock = Box::new(clock);
147 self
148 }
149
150 pub fn build(self) -> Guard<St, S> {
152 Guard {
153 inner: Arc::new(Inner {
154 store: self.store,
155 config: self.config,
156 intel: self.intel,
157 clock: self.clock,
158 hook: self.hook,
159 ua_cache: UaCache::new(UA_CACHE_CAPACITY),
160 xff_warned: AtomicBool::new(false),
161 shadow_seen: Mutex::new(ShadowSeen {
162 seen: HashMap::new(),
163 pruned_at: DateTime::<Utc>::MIN_UTC,
164 }),
165 }),
166 _subject: PhantomData,
167 }
168 }
169}
170
171const UA_CACHE_CAPACITY: usize = 1024;
172
173const SHADOW_SEEN_CAPACITY: usize = 100_000;
175
176#[derive(Clone, Copy, Debug, PartialEq, Eq)]
178enum TokenState {
179 Current,
181 Grace,
183 Orphaned,
185 Reuse,
187}
188
189struct Resolved<S> {
191 lookup: crate::store::TokenLookup,
192 hash: TokenHash,
193 subject: S,
194 state: TokenState,
195}
196
197impl<St: SessionStore, S: Subject> Guard<St, S> {
198 pub async fn login(
208 &self,
209 subject: &S,
210 ctx: &ClientContext,
211 previous_token: Option<&str>,
212 ) -> Result<NewSession, GuardError> {
213 let inner = &*self.inner;
214 if let Some(hash) = previous_token.and_then(|t| self.presented_hash(t))
215 && let Some(old) = inner.store.find_by_token(&hash).await?
216 && inner.store.delete_session(old.session.id).await?
217 {
218 self.emit(old.session.id, &old.session.subject, EventKind::Logout, true, ctx, None)
219 .await;
220 }
221
222 let encoded = crate::subject::encode(subject)?;
223 let token = SessionToken::generate()?;
224 let now = self.now();
225 let fingerprint = self.fingerprint(ctx);
226 let record = SessionRecord {
227 id: Uuid::new_v4(),
228 subject: encoded,
229 created_at: now,
230 authenticated_at: now,
231 last_seen_at: now,
232 expires_at: now + inner.config.session_lifetime,
233 step_up_required: false,
234 baseline: Baseline::new(fingerprint),
235 };
236 inner.store.insert_session(&record, &token.hash()).await?;
237 self.emit(record.id, &record.subject, EventKind::Login, true, ctx, None)
238 .await;
239 Ok(NewSession {
240 session_id: record.id,
241 token: self.issue(&token, record.expires_at),
242 })
243 }
244
245 pub async fn authenticate(&self, token: &str, ctx: &ClientContext) -> Result<Outcome<S>, GuardError> {
254 self.authenticate_with(token, ctx, &self.inner.config.checks).await
255 }
256
257 pub async fn authenticate_with(
264 &self,
265 token: &str,
266 ctx: &ClientContext,
267 checks: &Checks,
268 ) -> Result<Outcome<S>, GuardError> {
269 self.authenticate_inner(token, ctx, checks).await
270 }
271
272 fn plan_rotation(
274 &self,
275 state: TokenState,
276 issued_at: DateTime<Utc>,
277 hash: TokenHash,
278 now: DateTime<Utc>,
279 ) -> Result<Option<(Rotation, SessionToken)>, GuardError> {
280 let config = &self.inner.config;
281 let supersede = match state {
282 TokenState::Current if issued_at <= now - config.rotation_interval => Supersede::Exact(hash),
283 TokenState::Orphaned => Supersede::CurrentIssuedBefore(now - config.rotation_grace),
284 _ => return Ok(None),
285 };
286 let token = SessionToken::generate()?;
287 let rotation = Rotation {
288 new_token: token.hash(),
289 supersede,
290 prune_superseded_before: now - config.reuse_detection,
291 };
292 Ok(Some((rotation, token)))
293 }
294
295 fn assess(
297 &self,
298 baseline: &Baseline,
299 fingerprint: &Fingerprint,
300 state: TokenState,
301 now: DateTime<Utc>,
302 checks: &Checks,
303 ) -> crate::risk::Assessment {
304 let window = self.inner.config.ping_pong_window;
305 let mut assessment = crate::risk::assess(baseline, fingerprint, now, window, checks);
306 if state == TokenState::Orphaned {
307 assessment.add(Check::OrphanedToken, checks);
308 }
309 assessment
310 }
311
312 async fn authenticate_inner(
313 &self,
314 token: &str,
315 ctx: &ClientContext,
316 checks: &Checks,
317 ) -> Result<Outcome<S>, GuardError> {
318 let inner = &*self.inner;
319 let config = &inner.config;
320 let now = self.now();
321 let resolved = match self.resolve(token, now).await? {
322 Ok(resolved) => resolved,
323 Err(rejection) => return Ok(Outcome::Rejected(rejection)),
324 };
325 let Resolved {
326 lookup,
327 hash,
328 subject,
329 state,
330 } = resolved;
331 let session = &lookup.session;
332
333 if state == TokenState::Reuse {
334 self.revoke_for_reuse(session, ctx).await?;
335 return Ok(Outcome::Rejected(Rejection::Revoked));
336 }
337
338 let fingerprint = self.fingerprint(ctx);
339 let assessment = self.assess(&session.baseline, &fingerprint, state, now, checks);
340 let decision = config.thresholds.decide(assessment.score);
341 crate::telemetry::assessed(decision, &assessment.signals);
342 let shadow = config.mode == Mode::Shadow;
343 let step_up = decision == Decision::StepUp;
344
345 if decision == Decision::Revoke || (step_up && config.step_up_policy == StepUpPolicy::Revoke) {
346 self.emit(
347 session.id,
348 &session.subject,
349 EventKind::RiskRevoked,
350 !shadow,
351 ctx,
352 Some(&assessment),
353 )
354 .await;
355 if !shadow {
356 inner.store.delete_session(session.id).await?;
357 return Ok(Outcome::Rejected(Rejection::Revoked));
358 }
359 } else if session.step_up_required && !shadow {
360 return Ok(Outcome::Rejected(Rejection::StepUpRequired));
361 } else if step_up {
362 let enforced = config.step_up_policy == StepUpPolicy::Require && !shadow;
363 self.emit(
364 session.id,
365 &session.subject,
366 EventKind::StepUpRequired,
367 enforced,
368 ctx,
369 Some(&assessment),
370 )
371 .await;
372 if enforced {
373 let mut update = SessionUpdate::touch(session.id, now);
374 update.step_up_required = Some(true);
375 update.mark_used =
376 (state == TokenState::Current && lookup.token.first_used_at.is_none()).then_some(hash);
377 inner.store.apply(&update).await?;
378 return Ok(Outcome::Rejected(Rejection::StepUpRequired));
379 }
380 }
381
382 let mut update = SessionUpdate::touch(session.id, now);
383 if decision == Decision::Allow {
384 let mut baseline = session.baseline.clone();
385 if baseline.absorb(&fingerprint, now, checks) {
386 update.baseline = Some(baseline);
387 }
388 }
389
390 let new_token = match self.plan_rotation(state, lookup.token.issued_at, hash, now)? {
391 Some((rotation, token)) => {
392 update.rotation = Some(rotation);
393 Some(token)
394 }
395 None => None,
396 };
397 update.mark_used = match (state, &update.rotation) {
401 (TokenState::Current, _) if lookup.token.first_used_at.is_none() => Some(hash),
402 (TokenState::Orphaned, Some(rotation)) => Some(rotation.new_token),
403 _ => None,
404 };
405
406 let stale = now - session.last_seen_at >= config.last_seen_write_interval;
407 let mut rotated = None;
408 if update.rotation.is_some() || update.mark_used.is_some() || update.baseline.is_some() || stale {
409 let did_rotate = inner.store.apply(&update).await?;
410 if did_rotate && let Some(token) = &new_token {
411 rotated = Some(self.issue(token, session.expires_at));
412 if state == TokenState::Orphaned {
413 self.emit(session.id, &session.subject, EventKind::TokenReissued, true, ctx, None)
414 .await;
415 }
416 }
417 }
418
419 Ok(Outcome::Authenticated(Authenticated {
420 session: AuthSession::new(session, subject),
421 rotated,
422 assessment,
423 }))
424 }
425
426 pub async fn peek(&self, token: &str) -> Result<Option<SessionInfo<S>>, GuardError> {
434 Ok(match self.resolve(token, self.now()).await? {
435 Ok(resolved) if matches!(resolved.state, TokenState::Current | TokenState::Grace) => {
436 SessionInfo::from_record(&resolved.lookup.session)
437 }
438 _ => None,
439 })
440 }
441
442 pub async fn complete_step_up(
453 &self,
454 token: &str,
455 ctx: &ClientContext,
456 verified_subject: &S,
457 ) -> Result<StepUpOutcome, GuardError> {
458 let inner = &*self.inner;
459 let now = self.now();
460 let resolved = match self.resolve(token, now).await? {
461 Ok(resolved) => resolved,
462 Err(rejection) => return Ok(StepUpOutcome::Rejected(rejection)),
463 };
464 let session = &resolved.lookup.session;
465 if resolved.state == TokenState::Reuse {
466 self.revoke_for_reuse(session, ctx).await?;
467 return Ok(StepUpOutcome::Rejected(Rejection::Revoked));
468 }
469 if resolved.subject != *verified_subject {
470 return Ok(StepUpOutcome::SubjectMismatch);
471 }
472
473 let token = SessionToken::generate()?;
474 let mut update = SessionUpdate::touch(session.id, now);
475 update.baseline = Some(Baseline::new(self.fingerprint(ctx)));
476 update.step_up_required = Some(false);
477 update.authenticated_at = Some(now);
478 update.rotation = Some(Rotation {
479 new_token: token.hash(),
480 supersede: Supersede::Current,
481 prune_superseded_before: now - inner.config.reuse_detection,
482 });
483 if !inner.store.apply(&update).await? {
484 return Ok(StepUpOutcome::Rejected(Rejection::Revoked));
486 }
487 self.emit(
488 session.id,
489 &session.subject,
490 EventKind::StepUpCompleted,
491 true,
492 ctx,
493 None,
494 )
495 .await;
496 Ok(StepUpOutcome::Completed(self.issue(&token, session.expires_at)))
497 }
498
499 pub async fn logout(&self, token: &str, ctx: &ClientContext) -> Result<bool, GuardError> {
505 let inner = &*self.inner;
506 let Some(hash) = self.presented_hash(token) else {
507 return Ok(false);
508 };
509 let Some(lookup) = inner.store.find_by_token(&hash).await? else {
510 return Ok(false);
511 };
512 let deleted = inner.store.delete_session(lookup.session.id).await?;
513 if deleted {
514 self.emit(
515 lookup.session.id,
516 &lookup.session.subject,
517 EventKind::Logout,
518 true,
519 ctx,
520 None,
521 )
522 .await;
523 }
524 Ok(deleted)
525 }
526
527 pub async fn revoke_session(&self, session_id: Uuid) -> Result<bool, GuardError> {
533 Ok(self.inner.store.delete_session(session_id).await?)
534 }
535
536 pub async fn revoke_all_for_subject(&self, subject: &S) -> Result<u64, GuardError> {
542 let encoded = crate::subject::encode(subject)?;
543 Ok(self.inner.store.delete_subject_sessions(&encoded).await?)
544 }
545
546 pub async fn list_sessions(&self, subject: &S) -> Result<Vec<SessionInfo<S>>, GuardError> {
552 let encoded = crate::subject::encode(subject)?;
553 let now = self.now();
554 let records = self.inner.store.list_subject_sessions(&encoded).await?;
555 Ok(records
556 .iter()
557 .filter(|r| self.is_live(r, now))
558 .filter_map(SessionInfo::from_record)
559 .collect())
560 }
561
562 pub async fn purge_expired(&self) -> Result<PurgeReport, GuardError> {
569 let config = &self.inner.config;
570 let now = self.now();
571 let sessions = self.inner.store.delete_expired(now, now - config.idle_timeout).await?;
572 let events = self
573 .inner
574 .store
575 .delete_events_before(now - config.event_retention)
576 .await?;
577 Ok(PurgeReport { sessions, events })
578 }
579
580 #[cfg(feature = "tokio")]
588 #[cfg_attr(docsrs, doc(cfg(feature = "tokio")))]
589 pub fn spawn_purge(&self, every: Duration) -> tokio::task::JoinHandle<()> {
590 let guard = self.clone();
591 tokio::spawn(async move {
592 let mut ticker = tokio::time::interval(every);
593 ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
594 loop {
595 ticker.tick().await;
596 match guard.purge_expired().await {
597 Ok(report) => tracing::info!(
598 sessions = report.sessions,
599 events = report.events,
600 "purged expired sessions"
601 ),
602 Err(error) => tracing::warn!(%error, "purging expired sessions failed"),
603 }
604 }
605 })
606 }
607
608 pub fn client_context<H: HeaderSource + ?Sized>(&self, peer: IpAddr, headers: &H) -> ClientContext {
614 let config = &self.inner.config;
615 let forwarded: Vec<&str> = headers
618 .header_values("x-forwarded-for")
619 .map(|v| std::str::from_utf8(v).unwrap_or(""))
620 .collect();
621 if config.trusted_proxies.is_empty()
622 && !forwarded.is_empty()
623 && !self.inner.xff_warned.swap(true, Ordering::Relaxed)
624 {
625 tracing::warn!(
626 "X-Forwarded-For received but no trusted proxies are configured; using the peer address. \
627 Configure trusted_proxies if the app runs behind a reverse proxy."
628 );
629 }
630 let ip = resolve_client_ip(peer, forwarded, &config.trusted_proxies);
631 ClientContext::from_headers(
632 ip,
633 headers,
634 config.tls_fingerprint_header.as_deref(),
635 config.max_header_len,
636 )
637 }
638
639 pub fn cookie_token<'a, H: HeaderSource + ?Sized>(&self, headers: &'a H) -> Option<&'a str> {
641 headers
642 .header_values("cookie")
643 .filter_map(|v| std::str::from_utf8(v).ok())
644 .find_map(|v| self.inner.config.cookie.find_in(v))
645 }
646
647 pub fn clear_cookie(&self) -> String {
649 self.inner.config.cookie.clear_cookie()
650 }
651
652 #[cfg(feature = "aide")]
653 pub(crate) fn cookie_name(&self) -> &str {
654 self.inner.config.cookie.cookie_name()
655 }
656
657 pub fn requires_reauth(&self, session: &AuthSession<S>, max_age: Duration) -> bool {
663 self.now() - session.authenticated_at > td(max_age)
664 }
665
666 pub fn is_session_token(&self, value: &str) -> bool {
669 self.presented_hash(value).is_some()
670 }
671
672 fn now(&self) -> DateTime<Utc> {
673 self.inner.clock.now()
674 }
675
676 fn fingerprint(&self, ctx: &ClientContext) -> Fingerprint {
677 Fingerprint::capture_cached(ctx, self.inner.intel.lookup(ctx.ip), &self.inner.ua_cache)
678 }
679
680 fn presented_hash(&self, presented: &str) -> Option<TokenHash> {
681 let bare = match &self.inner.config.token_prefix {
682 Some(prefix) => presented.strip_prefix(prefix.as_str())?,
683 None => presented,
684 };
685 TokenHash::from_presented(bare)
686 }
687
688 fn issue(&self, token: &SessionToken, expires_at: DateTime<Utc>) -> IssuedToken {
689 let prefix = self.inner.config.token_prefix.as_deref().unwrap_or("");
692 let mut value = String::with_capacity(prefix.len() + token.expose().len());
693 value.push_str(prefix);
694 value.push_str(token.expose());
695 let max_age = (expires_at - self.now()).num_seconds();
696 let set_cookie = Zeroizing::new(self.inner.config.cookie.set_cookie(&value, max_age));
697 IssuedToken {
698 value: SecretString::from(value),
699 expires_at,
700 set_cookie,
701 }
702 }
703
704 fn is_live(&self, record: &SessionRecord, now: DateTime<Utc>) -> bool {
705 now < record.expires_at && now - record.last_seen_at < self.inner.config.idle_timeout
706 }
707
708 fn classify(&self, lookup: &crate::store::TokenLookup, now: DateTime<Utc>) -> TokenState {
709 match lookup.token.superseded_at {
710 None => TokenState::Current,
711 Some(at) if now - at <= self.inner.config.rotation_grace => TokenState::Grace,
712 Some(_) if !lookup.newer_token_used => TokenState::Orphaned,
713 Some(_) => TokenState::Reuse,
714 }
715 }
716
717 fn orphan_too_old(&self, lookup: &crate::store::TokenLookup, now: DateTime<Utc>) -> bool {
719 match (self.inner.config.orphan_reissue_window, lookup.token.superseded_at) {
720 (Some(window), Some(at)) => now - at > window,
721 _ => false,
722 }
723 }
724
725 async fn resolve(&self, token: &str, now: DateTime<Utc>) -> Result<Result<Resolved<S>, Rejection>, GuardError> {
727 let store = &self.inner.store;
728 let Some(hash) = self.presented_hash(token) else {
729 return Ok(Err(Rejection::NotAuthenticated));
730 };
731 let Some(lookup) = store.find_by_token(&hash).await? else {
732 return Ok(Err(Rejection::NotAuthenticated));
733 };
734 if !self.is_live(&lookup.session, now) {
735 store.delete_session(lookup.session.id).await?;
736 return Ok(Err(Rejection::Expired));
737 }
738 let Some(subject) = crate::subject::decode::<S>(&lookup.session.subject) else {
739 tracing::warn!(session_id = %lookup.session.id, "stored subject does not decode as the guard's subject type");
740 return Ok(Err(Rejection::NotAuthenticated));
741 };
742 let state = self.classify(&lookup, now);
743 if state == TokenState::Orphaned && self.orphan_too_old(&lookup, now) {
744 return Ok(Err(Rejection::NotAuthenticated));
746 }
747 Ok(Ok(Resolved {
748 lookup,
749 hash,
750 subject,
751 state,
752 }))
753 }
754
755 async fn revoke_for_reuse(&self, session: &SessionRecord, ctx: &ClientContext) -> Result<(), GuardError> {
756 self.inner.store.delete_session(session.id).await?;
757 self.emit(session.id, &session.subject, EventKind::TokenReuse, true, ctx, None)
758 .await;
759 Ok(())
760 }
761
762 async fn emit(
766 &self,
767 session_id: Uuid,
768 subject: &str,
769 kind: EventKind,
770 enforced: bool,
771 ctx: &ClientContext,
772 assessment: Option<&crate::risk::Assessment>,
773 ) {
774 let inner = &*self.inner;
775 let config = &inner.config;
776 let now = self.now();
777 if !enforced {
778 if self.seen_recently(session_id, kind, now) {
779 return;
780 }
781 match inner
782 .store
783 .claim_event(session_id, kind, now, config.shadow_dedup_window)
784 .await
785 {
786 Ok(true) => {}
787 Ok(false) => return,
788 Err(error) => {
790 tracing::error!(%session_id, kind = kind.as_str(), %error, "failed to claim session event");
791 }
792 }
793 }
794
795 let ip = if config.minimize_event_ip {
796 mask_ip(ctx.ip)
797 } else {
798 ctx.ip
799 };
800 let event = SessionEvent {
801 session_id,
802 subject: subject.to_owned(),
803 kind,
804 enforced,
805 score: assessment.map(|a| a.score),
806 signals: assessment.map(|a| a.signals.clone()).unwrap_or_default(),
807 ip,
808 user_agent: ctx
809 .user_agent
810 .as_deref()
811 .map(|ua| crate::client::truncate(ua, config.max_header_len).to_owned()),
812 at: now,
813 };
814 if kind.is_security_relevant() {
815 tracing::warn!(%session_id, kind = kind.as_str(), enforced, score = ?event.score, "session event");
816 } else {
817 tracing::debug!(%session_id, kind = kind.as_str(), "session event");
818 }
819 if let Err(error) = inner.store.record_event(&event).await {
820 tracing::error!(%session_id, kind = kind.as_str(), %error, "failed to record session event");
821 }
822 if let Some(hook) = &inner.hook
823 && catch_unwind(AssertUnwindSafe(|| hook(&event))).is_err()
824 {
825 tracing::error!(%session_id, kind = kind.as_str(), "on_event hook panicked");
826 }
827 crate::telemetry::event(kind, enforced);
828 }
829
830 fn seen_recently(&self, session_id: Uuid, kind: EventKind, now: DateTime<Utc>) -> bool {
834 let window = self.inner.config.shadow_dedup_window;
835 let mut state = self.inner.shadow_seen.lock().unwrap_or_else(PoisonError::into_inner);
836 if now - state.pruned_at >= window || state.seen.len() >= SHADOW_SEEN_CAPACITY {
837 state.seen.retain(|_, at| now - *at < window);
838 state.pruned_at = now;
839 if state.seen.len() >= SHADOW_SEEN_CAPACITY {
840 state.seen.clear();
842 }
843 }
844 match state.seen.get(&(session_id, kind)) {
845 Some(at) if now - *at < window => true,
846 _ => {
847 state.seen.insert((session_id, kind), now);
848 false
849 }
850 }
851 }
852}
853
854fn mask_ip(ip: IpAddr) -> IpAddr {
856 let len = if ip.is_ipv4() { 24 } else { 48 };
857 ipnet::IpNet::new(ip, len).map_or(ip, |net| net.trunc().addr())
858}
859
860fn td(duration: Duration) -> TimeDelta {
861 TimeDelta::from_std(duration).unwrap_or(TimeDelta::MAX)
862}