Skip to main content

hopr_protocol_hopr/
surb_store.rs

1use std::{
2    collections::VecDeque,
3    sync::{
4        Arc,
5        atomic::{AtomicU64, Ordering},
6    },
7    time::{Duration, Instant},
8};
9
10use hopr_api::types::internal::{prelude::HoprPseudonym, routing::SurbMatcher};
11use hopr_crypto_packet::prelude::*;
12use moka::notification::RemovalCause;
13use validator::ValidationError;
14
15use crate::{FoundSurb, SurbInsertOutcome, traits::SurbStore};
16
17/// Lower bound on [`SurbStoreConfig::pseudonyms_lifetime`], enforced by the config validator.
18///
19/// Public so that callers applying their own override can floor it identically, rather than
20/// reaching a value the config file itself would have been rejected for.
21pub const MINIMUM_SURB_LIFETIME: Duration = Duration::from_secs(30);
22const MINIMUM_OPENER_PSEUDONYMS: usize = 1000;
23const MINIMUM_OPENERS_PER_PSEUDONYM: usize = 1000;
24const MINIMUM_SURBS_PER_PSEUDONYM: usize = 1000;
25const MINIMUM_OPENER_LIFETIME: Duration = Duration::from_secs(60);
26const MIN_SURB_RB_CAPACITY: usize = 1024;
27
28fn validate_pseudonyms_lifetime(lifetime: &Duration) -> Result<(), ValidationError> {
29    if lifetime < &MINIMUM_SURB_LIFETIME {
30        Err(ValidationError::new("pseudonyms_lifetime is too low"))
31    } else {
32        Ok(())
33    }
34}
35
36fn validate_reply_opener_lifetime(lifetime: &Duration) -> Result<(), ValidationError> {
37    if lifetime < &MINIMUM_OPENER_LIFETIME {
38        Err(ValidationError::new("reply_opener_lifetime is too low"))
39    } else {
40        Ok(())
41    }
42}
43
44fn default_rb_capacity() -> usize {
45    15_000
46}
47
48fn default_distress_threshold() -> usize {
49    500
50}
51
52fn default_max_openers_per_pseudonym() -> usize {
53    100_000
54}
55
56fn default_max_pseudonyms() -> usize {
57    10_000
58}
59
60fn default_pseudonyms_lifetime() -> Duration {
61    Duration::from_secs(600)
62}
63
64fn default_reply_opener_lifetime() -> Duration {
65    Duration::from_secs(3600)
66}
67
68/// Which end of the per-pseudonym buffer a pop consumes from. Replying side only.
69///
70/// Overflow always evicts the oldest SURB, in either order.
71#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, strum::EnumString, strum::Display)]
72#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
73#[strum(serialize_all = "lowercase")]
74#[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))]
75pub enum SurbPopOrder {
76    /// Oldest first. Default; preserves the historical behaviour.
77    #[default]
78    Fifo,
79    /// Newest first, so a return-path change applies immediately instead of only after the
80    /// buffered SURBs drain. Stale ones are shed from the other end on overflow.
81    Lifo,
82}
83
84/// Configuration for the SURB cache.
85///
86/// The configuration options affect both the sending side (SURB creator) and the
87/// replying side (SURB consumer).
88///
89/// In the classical scenario (`Entry - Relay 1 -... - Exit`), the sending side is
90/// the `Entry` and the replying side is the `Exit`.
91#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, smart_default::SmartDefault, validator::Validate)]
92#[cfg_attr(
93    feature = "serde",
94    derive(serde::Deserialize, serde::Serialize),
95    serde(deny_unknown_fields)
96)]
97pub struct SurbStoreConfig {
98    /// Size of the SURB ring buffer per pseudonym.
99    ///
100    /// Affects only the replying side.
101    ///
102    /// This indicates how many SURBs can be at most held to be used to send a reply
103    /// back to the sending side.
104    ///
105    /// Default is 15 000.
106    #[default(default_rb_capacity())]
107    #[validate(range(min = 1024, message = "rb_capacity must be at least 1024"))]
108    #[cfg_attr(feature = "serde", serde(default = "default_rb_capacity"))]
109    pub rb_capacity: usize,
110    /// Which end of the per-pseudonym buffer a pop consumes from; see [`SurbPopOrder`].
111    ///
112    /// Affects only the replying side. Default is [`SurbPopOrder::Fifo`].
113    #[cfg_attr(feature = "serde", serde(default))]
114    pub pop_order: SurbPopOrder,
115    /// Threshold for the number of SURBs in the ring buffer, below which it is
116    /// considered low ("SURB distress").
117    ///
118    /// Default is 500.
119    #[default(default_distress_threshold())]
120    #[validate(range(min = 10, message = "distress_threshold must be at least 10"))]
121    #[cfg_attr(feature = "serde", serde(default = "default_distress_threshold"))]
122    pub distress_threshold: usize,
123    /// Maximum number of reply openers (SURB counterparts) per pseudonym.
124    ///
125    /// Affects only the sending side when decrypting a received reply.
126    ///
127    /// This mostly affects Sessions, as they use a fixed pseudonym.
128    /// It reflects how many reply openers the initiator-side of a Session can hold,
129    /// until the oldest ones are dropped. If the other party uses a SURB corresponding
130    /// to a dropped reply opener, the reply message will be undecryptable by the initiator-side.
131    ///
132    /// Default is 100 000.
133    #[default(default_max_openers_per_pseudonym())]
134    #[validate(range(min = 100, message = "max_openers_per_pseudonym must be at least 100"))]
135    #[cfg_attr(feature = "serde", serde(default = "default_max_openers_per_pseudonym"))]
136    pub max_openers_per_pseudonym: usize,
137    /// The maximum number of distinct pseudonyms for which we hold a SURB ringbuffer.
138    ///
139    /// Affects only the replying side.
140    ///
141    /// For each pseudonym, there is a ring-buffer with capacity `rb_capacity`.
142    ///
143    /// Default is 10 000.
144    #[default(default_max_pseudonyms())]
145    #[validate(range(min = 100, message = "max_pseudonyms must be at least 100"))]
146    #[cfg_attr(feature = "serde", serde(default = "default_max_pseudonyms"))]
147    pub max_pseudonyms: usize,
148    /// Maximum lifetime of ring-buffer for each pseudonym.
149    ///
150    /// # Effects on sending side
151    /// This is the period for which we hold all reply openers for a pseudonym.
152    /// If no more messages carrying SURBs are sent during this period, the entire stash of
153    /// reply openers is dropped. Preventing receiving any more replies for that pseudonym.
154    ///
155    /// # Effects on replying side
156    /// If a pseudonym has not received any SURBs for this period,
157    /// the entire ring buffer with `rb_capacity` (= all SURBs for this pseudonym) is dropped.
158    /// Preventing from sending any more replies for that pseudonym.
159    ///
160    /// Default is 600 seconds.
161    #[default(default_pseudonyms_lifetime())]
162    #[validate(custom(function = "validate_pseudonyms_lifetime"))]
163    #[cfg_attr(
164        feature = "serde",
165        serde(default = "default_pseudonyms_lifetime", with = "humantime_serde")
166    )]
167    pub pseudonyms_lifetime: Duration,
168    /// Maximum lifetime of a reply opener.
169    ///
170    /// Affects only the sending side.
171    ///
172    /// A reply opener is distinguished using [`HoprSurbId`] and a pseudonym it belongs to.
173    /// If a reply opener is not used to decrypt the received packet within this period,
174    /// it is dropped. If the replying side uses the corresponding SURB to send a reply,
175    /// it won't be possible to decrypt it when received.
176    ///
177    /// Default is 3600 seconds.
178    #[default(default_reply_opener_lifetime())]
179    #[validate(custom(function = "validate_reply_opener_lifetime"))]
180    #[cfg_attr(
181        feature = "serde",
182        serde(default = "default_reply_opener_lifetime", with = "humantime_serde")
183    )]
184    pub reply_opener_lifetime: Duration,
185}
186
187/// Shortest gap between two capacity-eviction warnings from the same cache.
188const SIZE_EVICTION_WARN_INTERVAL: Duration = Duration::from_secs(60);
189
190/// Keeps capacity evictions observable without letting them flood the log.
191///
192/// Routine causes (expiry, explicit invalidation, replacement) are the steady state and stay at
193/// DEBUG. [`RemovalCause::Size`] is different: it means the cache is too small for the offered
194/// load, entries are lost that nothing asked to drop, and only an operator can fix it. Those
195/// evictions also arrive in bursts of thousands, so they are summarised rather than logged one by
196/// one - every drop is counted, and at most one WARN per [`SIZE_EVICTION_WARN_INTERVAL`] reports
197/// how many were shed since the previous one.
198struct SizeEvictionReporter {
199    /// Which cache this reports for, as it appears in the warning.
200    cache: &'static str,
201    dropped: AtomicU64,
202    /// `None` until the first warning, so the first capacity eviction is reported immediately.
203    last_warned: parking_lot::Mutex<Option<Instant>>,
204}
205
206impl SizeEvictionReporter {
207    fn new(cache: &'static str) -> Self {
208        Self {
209            cache,
210            dropped: AtomicU64::new(0),
211            last_warned: parking_lot::Mutex::new(None),
212        }
213    }
214
215    /// Records one eviction, warning about capacity pressure at most once per interval.
216    fn record(&self, cause: RemovalCause) {
217        if cause != RemovalCause::Size {
218            return;
219        }
220
221        self.dropped.fetch_add(1, Ordering::Relaxed);
222
223        let mut last_warned = self.last_warned.lock();
224        if last_warned.is_some_and(|at| at.elapsed() < SIZE_EVICTION_WARN_INTERVAL) {
225            return;
226        }
227        *last_warned = Some(Instant::now());
228        drop(last_warned);
229
230        tracing::warn!(
231            cache = self.cache,
232            dropped = self.dropped.swap(0, Ordering::Relaxed),
233            "SURB store cache is at capacity, dropping entries"
234        );
235    }
236}
237
238/// The capacity-eviction reporters of a single [`MemorySurbStore`], one per cache.
239///
240/// Shared by all the store's eviction listeners, including those of the per-pseudonym opener
241/// caches: a reporter per pseudonym would rate-limit nothing under a pseudonym flood.
242struct EvictionReporters {
243    opener_pseudonyms: SizeEvictionReporter,
244    surb_pseudonyms: SizeEvictionReporter,
245    openers: SizeEvictionReporter,
246}
247
248impl Default for EvictionReporters {
249    fn default() -> Self {
250        Self {
251            opener_pseudonyms: SizeEvictionReporter::new("reply openers by pseudonym"),
252            surb_pseudonyms: SizeEvictionReporter::new("surbs by pseudonym"),
253            openers: SizeEvictionReporter::new("reply openers"),
254        }
255    }
256}
257
258/// Basic [`SurbStore`] implementation based on an in-memory cache.
259///
260/// This SURB store offers no persistence, and all SURBs and Reply Openers are lost once dropped.
261///
262/// The instance can be cheaply cloned.
263#[derive(Clone)]
264pub struct MemorySurbStore {
265    pseudonym_openers: moka::sync::Cache<HoprPseudonym, moka::sync::Cache<HoprSurbId, ReplyOpener>>,
266    surbs_per_pseudonym: moka::sync::Cache<HoprPseudonym, SurbRingBuffer<HoprSurb>>,
267    /// Relayers this node can no longer pay. Holds at most a handful of entries (our own closing
268    /// channels), so a plain set behind an `RwLock` beats a concurrent map on this read-heavy path.
269    invalidated_relayers: Arc<parking_lot::RwLock<std::collections::HashSet<HoprKeyIdent>>>,
270    reporters: Arc<EvictionReporters>,
271    cfg: Arc<SurbStoreConfig>,
272}
273
274impl MemorySurbStore {
275    /// Creates a new instance with the given configuration.
276    pub fn new(cfg: SurbStoreConfig) -> Self {
277        let reporters = Arc::new(EvictionReporters::default());
278        let opener_pseudonym_reporter = reporters.clone();
279        let surb_pseudonym_reporter = reporters.clone();
280
281        Self {
282            // Reply openers are indexed by entire Sender IDs (Pseudonym + SURB ID)
283            // in a cascade fashion, allowing the entire batches (by Pseudonym) to be evicted
284            // if not used.
285            pseudonym_openers: moka::sync::Cache::builder()
286                .time_to_idle(cfg.pseudonyms_lifetime.max(MINIMUM_SURB_LIFETIME))
287                .eviction_policy(moka::policy::EvictionPolicy::lru())
288                .eviction_listener(move |sender_id, _reply_opener, cause| {
289                    tracing::debug!(?sender_id, ?cause, "evicting reply opener for pseudonym");
290                    opener_pseudonym_reporter.opener_pseudonyms.record(cause);
291                })
292                .max_capacity(cfg.max_openers_per_pseudonym.max(MINIMUM_OPENER_PSEUDONYMS) as u64)
293                .build(),
294            // SURBs are indexed only by Pseudonyms, which have longer lifetimes.
295            // For each Pseudonym, there's an RB of SURBs and their IDs.
296            surbs_per_pseudonym: moka::sync::Cache::builder()
297                .time_to_idle(cfg.pseudonyms_lifetime.max(MINIMUM_SURB_LIFETIME))
298                .eviction_policy(moka::policy::EvictionPolicy::lru())
299                .eviction_listener(move |pseudonym, _reply_opener, cause| {
300                    tracing::debug!(%pseudonym, ?cause, "evicting surb for pseudonym");
301                    surb_pseudonym_reporter.surb_pseudonyms.record(cause);
302                })
303                .max_capacity(cfg.max_pseudonyms.max(MINIMUM_SURBS_PER_PSEUDONYM) as u64)
304                .build(),
305            invalidated_relayers: Default::default(),
306            reporters,
307            cfg: cfg.into(),
308        }
309    }
310
311    /// Whether `relayer` is currently unusable as a return path's first hop.
312    pub fn is_relayer_invalidated(&self, relayer: &HoprKeyIdent) -> bool {
313        self.invalidated_relayers.read().contains(relayer)
314    }
315
316    /// Whether a stored SURB can still be used to reply: its first relayer must still be payable.
317    ///
318    /// A direct return path is exempt — its "first relayer" is the final recipient, which needs no
319    /// channel (RFC-0003 §3.2, RFC-0006 §6.1). Without that exemption, closing an unrelated channel
320    /// to a session's originator would discard perfectly good SURBs.
321    fn is_surb_usable(&self, surb: &HoprSurb) -> bool {
322        match surb.additional_data_receiver.proof_of_relay_values().chain_length() {
323            // Direct return path: the "first relayer" is the final recipient, which needs no channel.
324            1 => true,
325            // A chain length is hops + 1, so 0 cannot occur on a well-formed SURB. Refuse it rather
326            // than let a malformed value pass as "direct" and bypass the check below.
327            //
328            // The length is an unvalidated byte off a SURB minted by the counterparty, so this is a
329            // statement about that peer, not a local fault we could act on: `warn`, not `error`.
330            0 => {
331                tracing::warn!(
332                    first_relayer = %surb.first_relayer,
333                    "refusing a malformed SURB declaring a zero-length return path"
334                );
335                false
336            }
337            _ => {
338                let usable = !self.invalidated_relayers.read().contains(&surb.first_relayer);
339                if !usable {
340                    tracing::trace!(
341                        first_relayer = %surb.first_relayer,
342                        "refusing a SURB whose first relayer is invalidated"
343                    );
344                }
345                usable
346            }
347        }
348    }
349}
350
351impl Default for MemorySurbStore {
352    fn default() -> Self {
353        Self::new(SurbStoreConfig::default())
354    }
355}
356
357#[async_trait::async_trait]
358impl SurbStore for MemorySurbStore {
359    #[tracing::instrument(skip_all, level = "trace", fields(?matcher), ret)]
360    fn find_surb(&self, matcher: SurbMatcher) -> Option<FoundSurb> {
361        let pseudonym = matcher.pseudonym();
362        let surbs_for_pseudonym = self.surbs_per_pseudonym.get(&pseudonym)?;
363
364        match matcher {
365            // SURBs whose return path no longer has a usable first edge are dropped on the way,
366            // rather than handed out only to have the reply fail to be paid for.
367            SurbMatcher::Pseudonym(_) => surbs_for_pseudonym
368                .pop_next_valid(|_, surb| self.is_surb_usable(surb))
369                .map(|popped_surb| FoundSurb {
370                    sender_id: HoprSenderId::from_pseudonym_and_id(&pseudonym, popped_surb.id),
371                    surb: popped_surb.surb,
372                    remaining: popped_surb.remaining,
373                }),
374            // The following code intentionally only checks the SURB at the popping end of the
375            // ring buffer and does not search the entire RB.
376            // This is because the exact match use-case is suited only for situations
377            // when there is a single SURB in the RB.
378            SurbMatcher::Exact(id) => {
379                surbs_for_pseudonym
380                    .pop_one_if_has_id(&id.surb_id())
381                    .map(|popped_surb| FoundSurb {
382                        sender_id: HoprSenderId::from_pseudonym_and_id(&pseudonym, popped_surb.id),
383                        surb: popped_surb.surb,
384                        remaining: popped_surb.remaining, // = likely 0
385                    })
386            }
387        }
388    }
389
390    #[tracing::instrument(skip_all, level = "trace", fields(%pseudonym, num_surbs = surbs.len()))]
391    fn insert_surbs(&self, pseudonym: HoprPseudonym, surbs: Vec<(HoprSurbId, HoprSurb)>) -> SurbInsertOutcome {
392        self.surbs_per_pseudonym
393            .entry_by_ref(&pseudonym)
394            .or_insert_with(|| SurbRingBuffer::new(self.cfg.rb_capacity.max(MIN_SURB_RB_CAPACITY), self.cfg.pop_order))
395            .value()
396            .push(surbs)
397    }
398
399    #[tracing::instrument(skip_all, level = "trace", fields(?sender_id))]
400    fn insert_reply_opener(&self, sender_id: HoprSenderId, opener: ReplyOpener) {
401        let opener_lifetime = self.cfg.reply_opener_lifetime.max(MINIMUM_OPENER_LIFETIME);
402        let max_openers_per_pseudonym = self.cfg.max_openers_per_pseudonym.max(MINIMUM_OPENERS_PER_PSEUDONYM);
403        let reporters = self.reporters.clone();
404        self.pseudonym_openers
405            .get_with(sender_id.pseudonym(), move || {
406                moka::sync::Cache::builder()
407                    .time_to_live(opener_lifetime)
408                    // Keep the newest openers, not the stalest. Reply openers are written once and
409                    // never read before they are used, so under the default TinyLFU policy every
410                    // entry ties at frequency zero and incumbents win admission: a full cache then
411                    // freezes the oldest openers and drops every newer one. But the counterparty's
412                    // SURB ring always hands back the newest SURBs, whose openers are exactly those
413                    // dropped — so replies stop decrypting once the cache fills. LRU on this
414                    // write-only workload evicts by insertion order, i.e. it sheds the oldest and
415                    // keeps the newest, mirroring the SURB ring buffer (and the outer cache).
416                    .eviction_policy(moka::policy::EvictionPolicy::lru())
417                    .eviction_listener(move |id: Arc<HoprSurbId>, _, cause| {
418                        if cause != RemovalCause::Explicit {
419                            tracing::debug!(
420                                pseudonym = %sender_id.pseudonym(),
421                                surb_id = const_hex::encode(id.as_slice()),
422                                ?cause,
423                                "evicting reply opener for sender id"
424                            );
425                            reporters.openers.record(cause);
426                        }
427                    })
428                    .max_capacity(max_openers_per_pseudonym as u64)
429                    .build()
430            })
431            .insert(sender_id.surb_id(), opener);
432    }
433
434    #[tracing::instrument(skip_all, level = "trace")]
435    fn invalidate_relayer(&self, relayer: &HoprKeyIdent) {
436        if self.invalidated_relayers.write().insert(*relayer) {
437            tracing::info!(
438                %relayer,
439                "invalidating stored SURBs whose return path starts with this relayer"
440            );
441        }
442    }
443
444    #[tracing::instrument(skip_all, level = "trace")]
445    fn revalidate_relayer(&self, relayer: &HoprKeyIdent) {
446        if self.invalidated_relayers.write().remove(relayer) {
447            tracing::info!(%relayer, "relayer is usable again for SURB return paths");
448        }
449    }
450
451    #[tracing::instrument(skip_all, level = "trace", fields(?sender_id), ret)]
452    fn find_reply_opener(&self, sender_id: &HoprSenderId) -> Option<ReplyOpener> {
453        self.pseudonym_openers
454            .get(&sender_id.pseudonym())
455            .and_then(|cache| cache.remove(&sender_id.surb_id()))
456    }
457}
458
459/// Represents a single SURB along with its ID popped from the [`SurbRingBuffer`].
460#[derive(Debug, Clone)]
461pub struct PoppedSurb<S> {
462    /// Complete SURB sender ID.
463    pub id: HoprSurbId,
464    /// The popped SURB.
465    pub surb: S,
466    /// Number of SURBs left in the RB after the pop.
467    pub remaining: usize,
468}
469
470/// Ring buffer of SURBs and their IDs, all normally belonging to one pseudonym and therefore
471/// identified only by [`HoprSurbId`].
472///
473/// Backed by a [`VecDeque`] pre-allocated to `capacity` and never allowed to exceed it, so it
474/// never reallocates: a push into a full buffer evicts the oldest element first. [`SurbPopOrder`]
475/// picks which end a pop consumes from; overflow always evicts the oldest, in either order.
476#[derive(Clone, Debug)]
477pub struct SurbRingBuffer<S> {
478    surbs: Arc<parking_lot::Mutex<VecDeque<(HoprSurbId, S)>>>,
479    capacity: usize,
480    pop_order: SurbPopOrder,
481}
482
483impl<S> SurbRingBuffer<S> {
484    /// Creates a buffer holding at most `capacity` (min 1, so a push is never a no-op) SURBs,
485    /// popped in the given order.
486    pub fn new(capacity: usize, pop_order: SurbPopOrder) -> Self {
487        let capacity = capacity.max(1);
488        Self {
489            surbs: Arc::new(parking_lot::Mutex::new(VecDeque::with_capacity(capacity))),
490            capacity,
491            pop_order,
492        }
493    }
494
495    /// Pushes all SURBs with their IDs, evicting the oldest ones past capacity.
496    ///
497    /// Returns what the push did; the eviction count is what lets a caller notice the overflow at
498    /// all, since dropping the oldest entry is otherwise indistinguishable from a clean insert.
499    pub fn push<I: IntoIterator<Item = (HoprSurbId, S)>>(&self, surbs: I) -> SurbInsertOutcome {
500        let mut rb = self.surbs.lock();
501        let mut evicted = 0;
502        for surb in surbs {
503            // Evict before inserting, so that the length never exceeds the pre-allocated
504            // capacity and the backing allocation stays put.
505            if rb.len() == self.capacity {
506                rb.pop_front();
507                evicted += 1;
508            }
509            rb.push_back(surb);
510        }
511        SurbInsertOutcome {
512            retained: rb.len(),
513            evicted,
514        }
515    }
516
517    /// Pops the next SURB that `is_valid` accepts, in the buffer's [`SurbPopOrder`].
518    ///
519    /// **Destructive:** rejected entries are discarded, not skipped, so an unusable SURB neither is
520    /// handed out nor blocks those behind it. Pass only a validity test — a selective predicate
521    /// (say, a routing preference) would drain the buffer. `None` once it is exhausted without a
522    /// match.
523    ///
524    /// `is_valid` runs *outside* the lock: it is caller-supplied and may take locks of its own, so
525    /// calling it inside the critical section would invite lock-order inversion.
526    pub fn pop_next_valid<F>(&self, is_valid: F) -> Option<PoppedSurb<S>>
527    where
528        F: Fn(&HoprSurbId, &S) -> bool,
529    {
530        loop {
531            let (id, surb, remaining) = {
532                let mut rb = self.surbs.lock();
533                let (id, surb) = match self.pop_order {
534                    SurbPopOrder::Fifo => rb.pop_front()?,
535                    SurbPopOrder::Lifo => rb.pop_back()?,
536                };
537                (id, surb, rb.len())
538            };
539
540            if is_valid(&id, &surb) {
541                return Some(PoppedSurb { id, surb, remaining });
542            }
543        }
544    }
545
546    /// Pops the next SURB (in the buffer's [`SurbPopOrder`]) only if it has the given ID.
547    pub fn pop_one_if_has_id(&self, id: &HoprSurbId) -> Option<PoppedSurb<S>> {
548        let mut rb = self.surbs.lock();
549
550        let next = match self.pop_order {
551            SurbPopOrder::Fifo => rb.front(),
552            SurbPopOrder::Lifo => rb.back(),
553        };
554
555        if next.is_some_and(|(surb_id, _)| surb_id == id) {
556            let (id, surb) = match self.pop_order {
557                SurbPopOrder::Fifo => rb.pop_front()?,
558                SurbPopOrder::Lifo => rb.pop_back()?,
559            };
560            Some(PoppedSurb {
561                id,
562                surb,
563                remaining: rb.len(),
564            })
565        } else {
566            None
567        }
568    }
569}
570
571#[cfg(test)]
572mod tests {
573    use hopr_api::types::crypto::{crypto_traits::Randomizable, prelude::SecretKey16};
574    use hopr_crypto_packet::sphinx::prelude::SphinxHeaderSpec;
575    use rstest::rstest;
576
577    use super::*;
578
579    impl<S> SurbRingBuffer<S> {
580        /// Pops the next SURB regardless of validity — the buffer-ordering tests below are about
581        /// which end is consumed, not about which SURBs are usable.
582        fn pop_any(&self) -> Option<PoppedSurb<S>> {
583            self.pop_next_valid(|_, _| true)
584        }
585    }
586
587    /// Builds a SURB with the given first relayer and PoR chain length (= return path length).
588    ///
589    /// Only those two fields are read by the store, so the SURB is assembled straight from its
590    /// wire layout — `first_relayer | alpha | header | sender_key | additional_data_receiver` —
591    /// whose parser performs no cryptographic validation. That avoids a full Sphinx key exchange
592    /// per fixture and keeps the chain length exactly controllable.
593    fn surb_via(first_relayer: HoprKeyIdent, chain_length: u8) -> anyhow::Result<HoprSurb> {
594        let mut bytes = vec![0u8; HoprSurb::SIZE];
595
596        let key_id_size = HoprSphinxHeaderSpec::KEY_ID_SIZE.get();
597        bytes[..key_id_size].copy_from_slice(first_relayer.as_ref());
598
599        // The chain length is the leading byte of the receiver's proof-of-relay values, which
600        // in turn lead the trailing `additional_data_receiver` block.
601        bytes[HoprSurb::SIZE - HoprSphinxHeaderSpec::SURB_RECEIVER_DATA_SIZE] = chain_length;
602
603        let surb = HoprSurb::try_from(bytes.as_slice())?;
604
605        // Guard the hand-rolled layout: a wrong offset would silently yield chain length 0 and
606        // make the assertions below pass for the wrong reason.
607        assert_eq!(first_relayer, surb.first_relayer, "fixture: wrong first relayer");
608        assert_eq!(
609            chain_length,
610            surb.additional_data_receiver.proof_of_relay_values().chain_length(),
611            "fixture: wrong chain length"
612        );
613
614        Ok(surb)
615    }
616
617    /// A return path with one intermediate relayer: `me -> relayer -> recipient`.
618    const TWO_HOP: u8 = 2;
619    /// A return path straight to the recipient, which needs no payment channel.
620    const DIRECT: u8 = 1;
621
622    #[test]
623    fn memory_surb_store_should_skip_surbs_whose_first_relayer_was_invalidated() -> anyhow::Result<()> {
624        let (dead, alive) = (HoprKeyIdent::from(1u32), HoprKeyIdent::from(2u32));
625
626        let store = MemorySurbStore::default();
627        let pseudonym = HoprPseudonym::random();
628
629        // Two SURBs return via the dead relay, one via a healthy one; all are two-hop.
630        store.insert_surbs(
631            pseudonym,
632            vec![
633                ([1u8; 8], surb_via(dead, TWO_HOP)?),
634                ([2u8; 8], surb_via(dead, TWO_HOP)?),
635                ([3u8; 8], surb_via(alive, TWO_HOP)?),
636            ],
637        );
638
639        store.invalidate_relayer(&dead);
640
641        let found = store
642            .find_surb(SurbMatcher::Pseudonym(pseudonym))
643            .ok_or(anyhow::anyhow!("expected a usable SURB"))?;
644        assert_eq!([3u8; 8], found.sender_id.surb_id(), "must skip past the dead relayer");
645        assert_eq!(
646            0, found.remaining,
647            "the invalidated SURBs must be discarded, not left behind"
648        );
649
650        assert!(
651            store.find_surb(SurbMatcher::Pseudonym(pseudonym)).is_none(),
652            "no usable SURB should remain"
653        );
654
655        Ok(())
656    }
657
658    #[test]
659    fn memory_surb_store_should_not_invalidate_surbs_with_a_direct_return_path() -> anyhow::Result<()> {
660        // A single-element path means the "first relayer" is the final recipient, which needs no
661        // payment channel — closing a channel to it must not discard the SURB.
662        let recipient = HoprKeyIdent::from(1u32);
663
664        let store = MemorySurbStore::default();
665        let pseudonym = HoprPseudonym::random();
666
667        store.insert_surbs(pseudonym, vec![([7u8; 8], surb_via(recipient, DIRECT)?)]);
668        store.invalidate_relayer(&recipient);
669
670        let found = store
671            .find_surb(SurbMatcher::Pseudonym(pseudonym))
672            .ok_or(anyhow::anyhow!("a direct-return-path SURB must stay usable"))?;
673        assert_eq!([7u8; 8], found.sender_id.surb_id());
674
675        Ok(())
676    }
677
678    #[test]
679    fn memory_surb_store_should_reject_a_surb_with_a_malformed_chain_length() -> anyhow::Result<()> {
680        // A chain length is hops + 1, so 0 is malformed. It must not pass as "direct" and thereby
681        // skip the invalidation check.
682        let relayer = HoprKeyIdent::from(1u32);
683
684        let store = MemorySurbStore::default();
685        let pseudonym = HoprPseudonym::random();
686
687        store.insert_surbs(pseudonym, vec![([5u8; 8], surb_via(relayer, 0)?)]);
688        store.invalidate_relayer(&relayer);
689
690        assert!(store.find_surb(SurbMatcher::Pseudonym(pseudonym)).is_none());
691
692        Ok(())
693    }
694
695    #[test]
696    fn memory_surb_store_should_make_a_relayer_usable_again_after_revalidation() -> anyhow::Result<()> {
697        let relayer = HoprKeyIdent::from(1u32);
698
699        let store = MemorySurbStore::default();
700        let pseudonym = HoprPseudonym::random();
701
702        store.insert_surbs(pseudonym, vec![([9u8; 8], surb_via(relayer, TWO_HOP)?)]);
703
704        store.invalidate_relayer(&relayer);
705        store.revalidate_relayer(&relayer);
706
707        let found = store
708            .find_surb(SurbMatcher::Pseudonym(pseudonym))
709            .ok_or(anyhow::anyhow!("expected the revalidated SURB"))?;
710        assert_eq!([9u8; 8], found.sender_id.surb_id());
711
712        Ok(())
713    }
714
715    #[test]
716    fn surb_store_config_should_default_to_fifo() {
717        assert_eq!(SurbPopOrder::Fifo, SurbStoreConfig::default().pop_order);
718        assert_eq!(SurbPopOrder::Fifo, SurbPopOrder::default());
719    }
720
721    /// Eviction always removes the oldest, so both orders see the same surviving set {2,3,4},
722    /// but consume it from opposite ends.
723    #[rstest]
724    #[case::fifo(SurbPopOrder::Fifo, [[2u8; 8], [3u8; 8], [4u8; 8]])]
725    #[case::lifo(SurbPopOrder::Lifo, [[4u8; 8], [3u8; 8], [2u8; 8]])]
726    fn surb_ring_buffer_should_drop_oldest_items_when_capacity_is_reached(
727        #[case] order: SurbPopOrder,
728        #[case] expected: [HoprSurbId; 3],
729    ) -> anyhow::Result<()> {
730        let rb = SurbRingBuffer::new(3, order);
731        rb.push([([1u8; 8], 0)]);
732        rb.push([([2u8; 8], 0)]);
733        rb.push([([3u8; 8], 0)]);
734        rb.push([([4u8; 8], 0)]);
735
736        for (i, expected_id) in expected.into_iter().enumerate() {
737            let popped = rb.pop_any().ok_or(anyhow::anyhow!("expected pop"))?;
738            assert_eq!(expected_id, popped.id, "unexpected id at index {i}");
739            assert_eq!(expected.len() - 1 - i, popped.remaining, "unexpected remaining");
740        }
741
742        assert!(rb.pop_any().is_none(), "buffer should be drained");
743
744        Ok(())
745    }
746
747    #[rstest]
748    #[case::fifo(SurbPopOrder::Fifo)]
749    #[case::lifo(SurbPopOrder::Lifo)]
750    fn surb_ring_buffer_should_report_no_eviction_below_capacity(#[case] order: SurbPopOrder) {
751        let rb = SurbRingBuffer::new(4, order);
752
753        assert_eq!(
754            SurbInsertOutcome {
755                retained: 2,
756                evicted: 0
757            },
758            rb.push([([1u8; 8], 0), ([2u8; 8], 0)])
759        );
760        assert_eq!(
761            SurbInsertOutcome {
762                retained: 4,
763                evicted: 0
764            },
765            rb.push([([3u8; 8], 0), ([4u8; 8], 0)])
766        );
767    }
768
769    /// Overflow is otherwise entirely silent — the buffer drops its oldest entry and the caller sees
770    /// only a successful push. The count is what lets the layers above notice that SURBs are being
771    /// destroyed on arrival, so it has to be exact.
772    #[rstest]
773    #[case::fifo(SurbPopOrder::Fifo)]
774    #[case::lifo(SurbPopOrder::Lifo)]
775    fn surb_ring_buffer_should_count_evictions_past_capacity(#[case] order: SurbPopOrder) -> anyhow::Result<()> {
776        let rb = SurbRingBuffer::new(2, order);
777
778        let outcome = rb.push([([1u8; 8], 0), ([2u8; 8], 0), ([3u8; 8], 0)]);
779        assert_eq!(
780            SurbInsertOutcome {
781                retained: 2,
782                evicted: 1
783            },
784            outcome,
785            "a 3-element push into a 2-slot buffer drops exactly one"
786        );
787
788        // The *oldest* is the one gone, in either pop order.
789        let ids: Vec<_> = std::iter::from_fn(|| rb.pop_any().map(|p| p.id)).collect();
790        assert!(
791            !ids.contains(&[1u8; 8]),
792            "the oldest entry must be the evicted one, got {ids:?}"
793        );
794
795        Ok(())
796    }
797
798    /// A buffer already at capacity evicts one per element pushed, however the pushes are grouped —
799    /// the steady-state overflow that a counterparty producing faster than this side drains creates.
800    #[rstest]
801    #[case::fifo(SurbPopOrder::Fifo)]
802    #[case::lifo(SurbPopOrder::Lifo)]
803    fn surb_ring_buffer_should_count_evictions_across_separate_pushes(#[case] order: SurbPopOrder) {
804        let rb = SurbRingBuffer::new(2, order);
805        assert_eq!(0, rb.push([([1u8; 8], 0), ([2u8; 8], 0)]).evicted, "precondition: full");
806
807        assert_eq!(
808            SurbInsertOutcome {
809                retained: 2,
810                evicted: 1
811            },
812            rb.push([([3u8; 8], 0)])
813        );
814        assert_eq!(
815            SurbInsertOutcome {
816                retained: 2,
817                evicted: 2
818            },
819            rb.push([([4u8; 8], 0), ([5u8; 8], 0)])
820        );
821    }
822
823    #[test]
824    fn surb_ring_buffer_should_pop_fifo_by_default() -> anyhow::Result<()> {
825        let rb = SurbRingBuffer::new(5, SurbPopOrder::default());
826
827        let len = rb.push([([1u8; 8], 0)]).retained;
828        assert_eq!(1, len);
829
830        let len = rb.push([([2u8; 8], 0)]).retained;
831        assert_eq!(2, len);
832
833        let popped = rb.pop_any().ok_or(anyhow::anyhow!("expected pop"))?;
834        assert_eq!([1u8; 8], popped.id);
835        assert_eq!(1, popped.remaining);
836
837        let popped = rb.pop_any().ok_or(anyhow::anyhow!("expected pop"))?;
838        assert_eq!([2u8; 8], popped.id);
839        assert_eq!(0, popped.remaining);
840
841        let len = rb.push([([1u8; 8], 0), ([2u8; 8], 0)]).retained;
842        assert_eq!(2, len);
843
844        assert_eq!([1u8; 8], rb.pop_any().ok_or(anyhow::anyhow!("expected pop"))?.id);
845        assert_eq!([2u8; 8], rb.pop_any().ok_or(anyhow::anyhow!("expected pop"))?.id);
846
847        Ok(())
848    }
849
850    #[test]
851    fn surb_ring_buffer_should_pop_lifo_when_configured() -> anyhow::Result<()> {
852        let rb = SurbRingBuffer::new(5, SurbPopOrder::Lifo);
853
854        let len = rb.push([([1u8; 8], 0)]).retained;
855        assert_eq!(1, len);
856
857        let len = rb.push([([2u8; 8], 0)]).retained;
858        assert_eq!(2, len);
859
860        let popped = rb.pop_any().ok_or(anyhow::anyhow!("expected pop"))?;
861        assert_eq!([2u8; 8], popped.id);
862        assert_eq!(1, popped.remaining);
863
864        let popped = rb.pop_any().ok_or(anyhow::anyhow!("expected pop"))?;
865        assert_eq!([1u8; 8], popped.id);
866        assert_eq!(0, popped.remaining);
867
868        let len = rb.push([([1u8; 8], 0), ([2u8; 8], 0)]).retained;
869        assert_eq!(2, len);
870
871        assert_eq!([2u8; 8], rb.pop_any().ok_or(anyhow::anyhow!("expected pop"))?.id);
872        assert_eq!([1u8; 8], rb.pop_any().ok_or(anyhow::anyhow!("expected pop"))?.id);
873
874        Ok(())
875    }
876
877    #[rstest]
878    #[case::fifo(SurbPopOrder::Fifo)]
879    #[case::lifo(SurbPopOrder::Lifo)]
880    fn surb_ring_buffer_should_skip_entries_failing_the_predicate(#[case] order: SurbPopOrder) -> anyhow::Result<()> {
881        let rb = SurbRingBuffer::new(5, order);
882        rb.push([([1u8; 8], 0), ([2u8; 8], 0), ([3u8; 8], 0)]);
883
884        // Only the middle entry is acceptable, so the two rejected ones must be discarded.
885        let popped = rb
886            .pop_next_valid(|id, _| id == &[2u8; 8])
887            .ok_or(anyhow::anyhow!("expected pop"))?;
888        assert_eq!([2u8; 8], popped.id);
889
890        // The rejected entries are gone, not merely skipped over.
891        assert_eq!(1, popped.remaining);
892        assert!(rb.pop_next_valid(|id, _| id == &[2u8; 8]).is_none());
893
894        Ok(())
895    }
896
897    #[test]
898    fn surb_ring_buffer_should_return_none_when_no_entry_satisfies_the_predicate() -> anyhow::Result<()> {
899        let rb = SurbRingBuffer::new(5, SurbPopOrder::Lifo);
900        rb.push([([1u8; 8], 0), ([2u8; 8], 0)]);
901
902        assert!(rb.pop_next_valid(|_, _| false).is_none());
903        // The buffer is fully drained by the exhaustive search.
904        assert!(rb.pop_any().is_none());
905
906        Ok(())
907    }
908
909    #[rstest]
910    #[case::fifo(SurbPopOrder::Fifo)]
911    #[case::lifo(SurbPopOrder::Lifo)]
912    fn surb_ring_buffer_should_not_reallocate_under_steady_overflow(#[case] order: SurbPopOrder) -> anyhow::Result<()> {
913        let rb = SurbRingBuffer::new(8, order);
914        let initial_capacity = rb.surbs.lock().capacity();
915
916        for i in 0..1_000u32 {
917            rb.push([(((i as u64).to_be_bytes()), 0)]);
918            if i % 3 == 0 {
919                rb.pop_any();
920            }
921            assert!(rb.surbs.lock().len() <= 8, "length exceeded capacity");
922        }
923
924        assert_eq!(initial_capacity, rb.surbs.lock().capacity(), "buffer reallocated");
925
926        Ok(())
927    }
928
929    #[rstest]
930    #[case::fifo(SurbPopOrder::Fifo)]
931    #[case::lifo(SurbPopOrder::Lifo)]
932    fn surb_ring_buffer_should_not_pop_if_id_does_not_match(#[case] order: SurbPopOrder) -> anyhow::Result<()> {
933        let rb = SurbRingBuffer::new(5, order);
934
935        rb.push([([1u8; 8], 0)]);
936
937        assert!(rb.pop_one_if_has_id(&[2u8; 8]).is_none());
938        assert_eq!(
939            [1u8; 8],
940            rb.pop_one_if_has_id(&[1u8; 8])
941                .ok_or(anyhow::anyhow!("expected pop"))?
942                .id
943        );
944
945        Ok(())
946    }
947
948    /// A reply opener whose contents don't matter — the tests only ask whether one is *present* —
949    /// so it is built once and cloned for every SURB.
950    fn cheap_opener() -> ReplyOpener {
951        ReplyOpener {
952            sender_key: SecretKey16::random(),
953            shared_secrets: Vec::new(),
954        }
955    }
956
957    /// Floods `flood` reply openers (ids `0..flood`) for one pseudonym into a fresh client store
958    /// capped at `max_openers`, then forces moka's lazy size-driven evictions to land so the
959    /// retained set is observable. Returns the store and its pseudonym.
960    fn flooded_client(max_openers: usize, flood: u64) -> (MemorySurbStore, HoprPseudonym) {
961        let client = MemorySurbStore::new(SurbStoreConfig {
962            max_openers_per_pseudonym: max_openers,
963            ..Default::default()
964        });
965        let pseudonym = HoprPseudonym::random();
966        let opener = cheap_opener();
967        for i in 0..flood {
968            let id: HoprSurbId = i.to_be_bytes();
969            client.insert_reply_opener(HoprSenderId::from_pseudonym_and_id(&pseudonym, id), opener.clone());
970        }
971        client.pseudonym_openers.run_pending_tasks();
972        if let Some(inner) = client.pseudonym_openers.get(&pseudonym) {
973            inner.run_pending_tasks();
974        }
975        (client, pseudonym)
976    }
977
978    /// Counts events per level; `enabled` is always true so the logging macros' bodies actually run.
979    #[derive(Default)]
980    struct RecordingSubscriber {
981        warnings: AtomicU64,
982        debugs: AtomicU64,
983    }
984
985    impl tracing::Subscriber for RecordingSubscriber {
986        fn enabled(&self, _: &tracing::Metadata<'_>) -> bool {
987            true
988        }
989
990        fn new_span(&self, _: &tracing::span::Attributes<'_>) -> tracing::span::Id {
991            tracing::span::Id::from_u64(1)
992        }
993
994        fn record(&self, _: &tracing::span::Id, _: &tracing::span::Record<'_>) {}
995
996        fn record_follows_from(&self, _: &tracing::span::Id, _: &tracing::span::Id) {}
997
998        fn event(&self, event: &tracing::Event<'_>) {
999            match *event.metadata().level() {
1000                tracing::Level::WARN => self.warnings.fetch_add(1, Ordering::Relaxed),
1001                tracing::Level::DEBUG => self.debugs.fetch_add(1, Ordering::Relaxed),
1002                _ => 0,
1003            };
1004        }
1005
1006        fn enter(&self, _: &tracing::span::Id) {}
1007
1008        fn exit(&self, _: &tracing::span::Id) {}
1009    }
1010
1011    #[test]
1012    fn expiry_style_evictions_should_not_warn() {
1013        let recorder = Arc::new(RecordingSubscriber::default());
1014        tracing::subscriber::with_default(recorder.clone(), || {
1015            let reporter = SizeEvictionReporter::new("test");
1016            for _ in 0..1000 {
1017                reporter.record(RemovalCause::Expired);
1018                reporter.record(RemovalCause::Explicit);
1019                reporter.record(RemovalCause::Replaced);
1020            }
1021        });
1022
1023        assert_eq!(0, recorder.warnings.load(Ordering::Relaxed));
1024    }
1025
1026    #[test]
1027    fn capacity_evictions_should_warn_once_per_interval() {
1028        let recorder = Arc::new(RecordingSubscriber::default());
1029        tracing::subscriber::with_default(recorder.clone(), || {
1030            let reporter = SizeEvictionReporter::new("test");
1031            for _ in 0..1000 {
1032                reporter.record(RemovalCause::Size);
1033            }
1034        });
1035
1036        // A thousand drops inside one interval must still be a single, summarising warning.
1037        assert_eq!(1, recorder.warnings.load(Ordering::Relaxed));
1038    }
1039
1040    #[test]
1041    fn overflowing_caches_should_log_evictions_at_debug_and_warn_about_capacity() -> anyhow::Result<()> {
1042        let recorder = Arc::new(RecordingSubscriber::default());
1043        tracing::subscriber::with_default(recorder.clone(), || -> anyhow::Result<()> {
1044            // Overflow an inner reply-opener cache.
1045            let _ = flooded_client(MINIMUM_OPENERS_PER_PSEUDONYM, 3 * MINIMUM_OPENERS_PER_PSEUDONYM as u64);
1046
1047            // Overflow both outer caches with distinct pseudonyms.
1048            let store = MemorySurbStore::new(SurbStoreConfig {
1049                max_openers_per_pseudonym: MINIMUM_OPENER_PSEUDONYMS,
1050                max_pseudonyms: MINIMUM_SURBS_PER_PSEUDONYM,
1051                ..Default::default()
1052            });
1053            let opener = cheap_opener();
1054            let surb = surb_via(HoprKeyIdent::from(1u32), DIRECT)?;
1055            for _ in 0..1500 {
1056                let pseudonym = HoprPseudonym::random();
1057                store.insert_reply_opener(
1058                    HoprSenderId::from_pseudonym_and_id(&pseudonym, [0u8; 8]),
1059                    opener.clone(),
1060                );
1061                store.insert_surbs(pseudonym, vec![([0u8; 8], surb.clone())]);
1062            }
1063            store.pseudonym_openers.run_pending_tasks();
1064            store.surbs_per_pseudonym.run_pending_tasks();
1065            Ok(())
1066        })?;
1067
1068        // Thousands of evictions, but capacity pressure is reported once per cache at most: two
1069        // stores are flooded here, so at most four reporters can have fired.
1070        let warnings = recorder.warnings.load(Ordering::Relaxed);
1071        assert!(
1072            (1..=4).contains(&warnings),
1073            "expected a rate-limited capacity warning, got {warnings}"
1074        );
1075        assert!(
1076            recorder.debugs.load(Ordering::Relaxed) > 0,
1077            "expected debug eviction events"
1078        );
1079        Ok(())
1080    }
1081
1082    /// Upload-only return-path model.
1083    ///
1084    /// One session pseudonym uploads hard: it keeps minting SURBs (one reply opener stored per SURB
1085    /// on the *client*, one SURB stored on the *exit*) while the exit barely replies, so nothing is
1086    /// consumed and both stores run to capacity and start evicting. `flood` pairs are pushed, then
1087    /// the exit answers `replies` times — each answer pops a SURB the way the exit would and tries
1088    /// to open it on the client. Returns how many of those replies land on a reply opener the client
1089    /// has already evicted, i.e. how many replies the client cannot decrypt.
1090    ///
1091    /// The two stores are separate instances with their own configs, mirroring the two ends: the
1092    /// client cares only about `max_openers` (its reply-opener cache), the exit only about
1093    /// `rb_capacity` + `order` (its SURB ring).
1094    fn undecryptable_replies_after_upload_flood(
1095        max_openers: usize,
1096        rb_capacity: usize,
1097        order: SurbPopOrder,
1098        flood: usize,
1099        replies: usize,
1100    ) -> usize {
1101        let (client, pseudonym) = flooded_client(max_openers, flood as u64);
1102        let exit = MemorySurbStore::new(SurbStoreConfig {
1103            rb_capacity,
1104            pop_order: order,
1105            ..Default::default()
1106        });
1107
1108        // A direct return path (chain length 1) is always usable, so `find_surb` never skips one for
1109        // an unrelated reason — the only thing under test is the opener's presence. The SURB value is
1110        // invariant across the flood, so build it once and clone (`HoprSurb` is a memcpy).
1111        let surb = surb_via(HoprKeyIdent::from(1u32), DIRECT).expect("valid surb fixture");
1112        for i in 0..flood as u64 {
1113            exit.insert_surbs(pseudonym, vec![(i.to_be_bytes(), surb.clone())]);
1114        }
1115
1116        (0..replies)
1117            .filter(|_| {
1118                let found = exit
1119                    .find_surb(SurbMatcher::Pseudonym(pseudonym))
1120                    .expect("the exit still holds SURBs to reply with");
1121                client.find_reply_opener(&found.sender_id).is_none()
1122            })
1123            .count()
1124    }
1125
1126    /// Baseline: while the reply-opener cache has not overflowed, every reply opens. Confirms the
1127    /// two-store plumbing (matching pseudonym + SURB id) before the overflow tests read anything
1128    /// into an eviction.
1129    #[rstest]
1130    #[case::fifo(SurbPopOrder::Fifo)]
1131    #[case::lifo(SurbPopOrder::Lifo)]
1132    fn replies_stay_decryptable_while_the_opener_cache_has_not_overflowed(#[case] order: SurbPopOrder) {
1133        const MAX_OPENERS: usize = 7000;
1134        // Flood == capacity: nothing is evicted.
1135        let undecryptable = undecryptable_replies_after_upload_flood(MAX_OPENERS, 1050, order, MAX_OPENERS, 200);
1136        assert_eq!(
1137            0, undecryptable,
1138            "no reply should be undecryptable before the cache overflows"
1139        );
1140    }
1141
1142    /// The fix, at the store level: on overflow the reply-opener cache sheds the stalest openers and
1143    /// keeps the newest, matching the exit's newest-SURB ring (the `lru()` rationale is on
1144    /// [`MemorySurbStore::insert_reply_opener`]). This asserts that directly — after overflowing a
1145    /// 7 000-cap cache with 21 000 openers, the oldest ids are gone and the newest are present.
1146    #[test]
1147    fn a_sustained_upload_keeps_the_newest_reply_openers_and_sheds_the_stalest() {
1148        const MAX_OPENERS: usize = 7000;
1149        const FLOOD: u64 = 21_000;
1150        const EDGE: u64 = 1_050; // a slice at each end of the id range
1151
1152        let (client, pseudonym) = flooded_client(MAX_OPENERS, FLOOD);
1153        let inner = client
1154            .pseudonym_openers
1155            .get(&pseudonym)
1156            .expect("the pseudonym's opener cache exists");
1157
1158        let present =
1159            |lo: u64, hi: u64| -> u64 { (lo..hi).filter(|i| inner.contains_key(&i.to_be_bytes())).count() as u64 };
1160
1161        assert_eq!(0, present(0, EDGE), "the stalest openers are shed");
1162        assert_eq!(
1163            EDGE,
1164            present(FLOOD - EDGE, FLOOD),
1165            "the freshest openers — the ones the exit's newest SURBs need — are kept"
1166        );
1167    }
1168
1169    /// End-to-end at the store level: with the opener cache keeping its newest entries, a sustained
1170    /// upload no longer strands the return path at the deployed configuration. At production
1171    /// proportions (opener cache larger than the exit's SURB ring) every reply opens under both pop
1172    /// orders, and under LIFO — which hoprd pins on the exit — it opens at any proportion.
1173    ///
1174    /// The one case that still strands replies is a misconfiguration: a FIFO exit whose SURB ring is
1175    /// *larger* than the opener cache, so it pops the oldest SURBs whose openers fall outside the
1176    /// smaller opener window. It is neither deployed nor sane, and is asserted here to document the
1177    /// boundary of the fix rather than to endorse it. (Before the fix, every one of these cases
1178    /// stranded all `REPLIES`; see this test's history.)
1179    ///
1180    /// In the inverted case the exit retains the newest `rb_capacity` SURBs and the client the newest
1181    /// `max_openers` openers (`flood` overflows both), so the two ranges overlap only on the newest
1182    /// `max_openers` ids. FIFO pops from the oldest end, so the first `rb_capacity - max_openers` pops
1183    /// fall outside that opener window; since `REPLIES <= rb_capacity - max_openers` (200 ≤ 14 000),
1184    /// every tested FIFO reply is undecryptable.
1185    #[rstest]
1186    #[case::production_fifo(7000, 1050, 21_000, SurbPopOrder::Fifo, 0)]
1187    #[case::production_lifo(7000, 1050, 21_000, SurbPopOrder::Lifo, 0)]
1188    #[case::inverted_lifo(1000, 15_000, 20_000, SurbPopOrder::Lifo, 0)]
1189    #[case::inverted_fifo(1000, 15_000, 20_000, SurbPopOrder::Fifo, 200)]
1190    fn a_sustained_upload_keeps_the_return_path_alive_at_the_deployed_config(
1191        #[case] max_openers: usize,
1192        #[case] rb_capacity: usize,
1193        #[case] flood: usize,
1194        #[case] order: SurbPopOrder,
1195        #[case] expected_undecryptable: usize,
1196    ) {
1197        const REPLIES: usize = 200;
1198        let undecryptable = undecryptable_replies_after_upload_flood(max_openers, rb_capacity, order, flood, REPLIES);
1199        assert_eq!(
1200            expected_undecryptable, undecryptable,
1201            "unexpected undecryptable-reply count for {order:?} at openers={max_openers}, rb={rb_capacity}"
1202        );
1203    }
1204
1205    #[test]
1206    fn surb_ring_buffer_should_check_the_popping_end_for_an_exact_id() -> anyhow::Result<()> {
1207        let fifo = SurbRingBuffer::new(5, SurbPopOrder::Fifo);
1208        fifo.push([([1u8; 8], 0), ([2u8; 8], 0)]);
1209        assert!(fifo.pop_one_if_has_id(&[2u8; 8]).is_none());
1210        assert_eq!(
1211            [1u8; 8],
1212            fifo.pop_one_if_has_id(&[1u8; 8])
1213                .ok_or(anyhow::anyhow!("expected pop"))?
1214                .id
1215        );
1216
1217        let lifo = SurbRingBuffer::new(5, SurbPopOrder::Lifo);
1218        lifo.push([([1u8; 8], 0), ([2u8; 8], 0)]);
1219        assert!(lifo.pop_one_if_has_id(&[1u8; 8]).is_none());
1220        assert_eq!(
1221            [2u8; 8],
1222            lifo.pop_one_if_has_id(&[2u8; 8])
1223                .ok_or(anyhow::anyhow!("expected pop"))?
1224                .id
1225        );
1226
1227        Ok(())
1228    }
1229}