Skip to main content

rns_core/transport/
dedup.rs

1use alloc::boxed::Box;
2use alloc::collections::BTreeMap;
3use alloc::vec;
4use alloc::vec::Vec;
5use core::mem::MaybeUninit;
6
7use super::types::PacketHashlistAllocation;
8
9/// Bounded FIFO packet-hash deduplication.
10///
11/// Retains at most `max_size` unique packet hashes. New unique hashes are
12/// appended in insertion order; when full, the oldest retained hash is evicted.
13/// Re-inserting a retained hash is a no-op and does not refresh its recency.
14pub struct PacketHashlist {
15    queue: PacketHashQueue,
16    set: PacketHashSet,
17    max_size: usize,
18}
19
20impl PacketHashlist {
21    pub fn new(max_size: usize) -> Self {
22        Self::with_allocation(max_size, PacketHashlistAllocation::Eager)
23    }
24
25    pub fn with_allocation(max_size: usize, allocation: PacketHashlistAllocation) -> Self {
26        // Index buckets store a 32-bit slot number.
27        let max_size = max_size.min(MAX_ENTRIES);
28        let initial = if allocation == PacketHashlistAllocation::Eager {
29            max_size
30        } else {
31            max_size.min(64)
32        };
33        Self {
34            queue: PacketHashQueue::new(initial, allocation),
35            set: PacketHashSet::new(initial, None),
36            max_size,
37        }
38    }
39
40    /// Key bucket placement with a secret `seed`, so that senders cannot
41    /// grind packets whose hashes share a probe sequence. Unseeded tables
42    /// index buckets by the hashes' leading bits.
43    pub fn seed_hashing(&mut self, seed: u64) {
44        self.set.key_seed = Some(seed);
45        // An empty table's buckets are already clear; rewriting them would
46        // fault in every page of a table allocated up front.
47        if self.set.len > 0 {
48            self.set.rebuild(&self.queue);
49        }
50    }
51
52    pub(crate) fn hashing_seed(&self) -> Option<u64> {
53        self.set.key_seed
54    }
55
56    /// Check if a hash is currently retained.
57    pub fn is_duplicate(&self, hash: &[u8; 32]) -> bool {
58        self.set.contains(hash, &self.queue)
59    }
60
61    /// Start loading the bucket a lookup of `hash` probes first, so a later
62    /// [`is_duplicate`](Self::is_duplicate) or [`add`](Self::add) for it can
63    /// overlap that cache miss with other work.
64    pub fn prefetch(&self, hash: &[u8; 32]) {
65        self.set.prefetch(self.set.key(hash));
66    }
67
68    /// Retain a hash. If the dedup table is full, evict the oldest unique hash.
69    pub fn add(&mut self, hash: [u8; 32]) {
70        if self.max_size == 0 || self.set.contains(&hash, &self.queue) {
71            return;
72        }
73        if self.queue.len() == self.max_size {
74            let oldest = *self.queue.entries.get(self.queue.head);
75            let removed = self.set.remove(&oldest, &self.queue);
76            debug_assert!(removed, "oldest hash must exist in index");
77            self.queue.pop_front();
78        }
79        if self.queue.len() == self.queue.capacity() {
80            let capacity = self
81                .queue
82                .capacity()
83                .saturating_mul(2)
84                .max(1)
85                .min(self.max_size);
86            self.queue.grow(capacity);
87            self.set = PacketHashSet::new(capacity, self.set.key_seed);
88            self.set.rebuild(&self.queue);
89        }
90        let slot = (self.queue.head + self.queue.len) % self.queue.capacity();
91        self.queue.push_back(hash);
92        self.set.insert(slot, &self.queue);
93        if self.queue.len() == self.max_size {
94            // The next new hash evicts the current oldest; start loading its
95            // bucket now instead of stalling on it then.
96            let oldest = self.queue.entries.get(self.queue.head);
97            self.set.prefetch(self.set.key(oldest));
98        }
99    }
100
101    /// Stop retaining a hash, preserving the FIFO order of all other entries.
102    pub fn remove(&mut self, hash: &[u8; 32]) -> bool {
103        if !self.set.contains(hash, &self.queue) {
104            return false;
105        }
106        let removed = self.queue.remove(hash);
107        debug_assert!(removed, "indexed hash must exist in FIFO queue");
108        // Queue compaction moves slots. Rebuild their index without allocation.
109        self.set.rebuild(&self.queue);
110        true
111    }
112
113    /// Total number of retained packet hashes.
114    pub fn len(&self) -> usize {
115        debug_assert_eq!(self.queue.len(), self.set.len());
116        self.queue.len()
117    }
118
119    pub fn is_empty(&self) -> bool {
120        self.len() == 0
121    }
122
123    /// Iterate retained hashes from oldest to newest.
124    pub fn iter(&self) -> impl Iterator<Item = &[u8; 32]> {
125        (0..self.queue.len).map(|offset| {
126            let index = (self.queue.head + offset) % self.queue.capacity();
127            self.queue.entries.get(index)
128        })
129    }
130}
131
132/// Hash payload slots initialized only within the queue's logical FIFO range.
133/// The index stores queue offsets, never a second copy of a full hash.
134struct RawHashSlots {
135    slots: Box<[MaybeUninit<[u8; 32]>]>,
136}
137
138impl RawHashSlots {
139    fn new(capacity: usize, allocation: PacketHashlistAllocation) -> Self {
140        let mut slots = Box::<[[u8; 32]]>::new_uninit_slice(capacity);
141        if allocation == PacketHashlistAllocation::Eager {
142            for slot in &mut slots {
143                // Volatile writes make eager page prefaulting an observable side
144                // effect that release-mode optimization cannot remove.
145                unsafe { core::ptr::write_volatile(slot.as_mut_ptr(), [0; 32]) };
146            }
147        }
148        Self { slots }
149    }
150
151    fn len(&self) -> usize {
152        self.slots.len()
153    }
154
155    fn write(&mut self, index: usize, hash: [u8; 32]) {
156        self.slots[index].write(hash);
157    }
158
159    fn read(&self, index: usize) -> [u8; 32] {
160        // SAFETY: callers establish initialization through the queue's logical
161        // range before calling this method.
162        unsafe { self.slots[index].assume_init_read() }
163    }
164
165    fn get(&self, index: usize) -> &[u8; 32] {
166        // SAFETY: callers establish initialization through the queue's logical
167        // range before calling this method.
168        unsafe { self.slots[index].assume_init_ref() }
169    }
170}
171
172/// Bounded TTL cache for announce signature verification results.
173///
174/// Stores hashes of recently verified (destination_hash, signature) pairs so
175/// that duplicate announces from multiple peers skip redundant Ed25519
176/// verification. Entries expire after `ttl_secs` and are culled periodically.
177/// When `max_entries` is 0 the cache is disabled and all methods are no-ops.
178pub struct AnnounceSignatureCache {
179    entries: BTreeMap<[u8; 32], f64>,
180    insertion_order: Vec<[u8; 32]>,
181    max_entries: usize,
182    ttl_secs: f64,
183}
184
185impl AnnounceSignatureCache {
186    pub fn new(max_entries: usize, ttl_secs: f64) -> Self {
187        Self {
188            entries: BTreeMap::new(),
189            insertion_order: Vec::new(),
190            max_entries,
191            ttl_secs,
192        }
193    }
194
195    /// Check if a cache key is present (i.e., already verified).
196    pub fn contains(&self, key: &[u8; 32]) -> bool {
197        if self.max_entries == 0 {
198            return false;
199        }
200        self.entries.contains_key(key)
201    }
202
203    /// Insert a verified cache key with the current timestamp.
204    pub fn insert(&mut self, key: [u8; 32], now: f64) {
205        if self.max_entries == 0 {
206            return;
207        }
208        if self.entries.contains_key(&key) {
209            return;
210        }
211        // FIFO eviction if at capacity
212        while self.entries.len() >= self.max_entries {
213            if let Some(oldest) = self.insertion_order.first().copied() {
214                self.entries.remove(&oldest);
215                self.insertion_order.remove(0);
216            } else {
217                break;
218            }
219        }
220        self.entries.insert(key, now);
221        self.insertion_order.push(key);
222    }
223
224    /// Remove entries older than TTL. Returns the number of entries removed.
225    pub fn cull(&mut self, now: f64) -> usize {
226        if self.max_entries == 0 {
227            return 0;
228        }
229        let cutoff = now - self.ttl_secs;
230        let before = self.entries.len();
231        self.entries.retain(|_, ts| *ts > cutoff);
232        self.insertion_order
233            .retain(|key| self.entries.contains_key(key));
234        before - self.entries.len()
235    }
236
237    pub fn len(&self) -> usize {
238        self.entries.len()
239    }
240
241    pub fn is_empty(&self) -> bool {
242        self.entries.is_empty()
243    }
244}
245
246struct PacketHashQueue {
247    entries: RawHashSlots,
248    head: usize,
249    len: usize,
250}
251
252impl PacketHashQueue {
253    fn new(capacity: usize, allocation: PacketHashlistAllocation) -> Self {
254        Self {
255            entries: RawHashSlots::new(capacity, allocation),
256            head: 0,
257            len: 0,
258        }
259    }
260
261    fn grow(&mut self, capacity: usize) {
262        debug_assert!(capacity > self.capacity());
263        let mut entries = RawHashSlots::new(capacity, PacketHashlistAllocation::Lazy);
264        for offset in 0..self.len {
265            let old = (self.head + offset) % self.capacity();
266            entries.write(offset, self.entries.read(old));
267        }
268        self.entries = entries;
269        self.head = 0;
270    }
271
272    fn capacity(&self) -> usize {
273        self.entries.len()
274    }
275
276    fn len(&self) -> usize {
277        self.len
278    }
279
280    fn push_back(&mut self, hash: [u8; 32]) {
281        debug_assert!(self.len < self.capacity());
282        if self.capacity() == 0 {
283            return;
284        }
285        let tail = (self.head + self.len) % self.capacity();
286        self.entries.write(tail, hash);
287        self.len += 1;
288    }
289
290    fn pop_front(&mut self) -> Option<[u8; 32]> {
291        if self.len == 0 || self.capacity() == 0 {
292            return None;
293        }
294        let hash = self.entries.read(self.head);
295        self.head = (self.head + 1) % self.capacity();
296        self.len -= 1;
297        if self.len == 0 {
298            self.head = 0;
299        }
300        Some(hash)
301    }
302
303    fn remove(&mut self, hash: &[u8; 32]) -> bool {
304        let Some(offset) = (0..self.len).find(|offset| {
305            let index = (self.head + offset) % self.capacity();
306            self.entries.get(index) == hash
307        }) else {
308            return false;
309        };
310
311        for current in offset..self.len - 1 {
312            let next_index = (self.head + current + 1) % self.capacity();
313            let current_index = (self.head + current) % self.capacity();
314            let next = self.entries.read(next_index);
315            self.entries.write(current_index, next);
316        }
317        self.len -= 1;
318        if self.len == 0 {
319            self.head = 0;
320        }
321        true
322    }
323}
324
325/// Open-addressed lookup of queue slots. Zero is empty. Otherwise the low 32
326/// bits are the physical queue slot + 1 and the high 32 bits are the low 32
327/// bits of the hash's bucket key. At most half the buckets are occupied.
328///
329/// The key bits let lookups skip the queue entry of every bucket whose key
330/// differs, so a miss usually touches only buckets, and let deletion find an
331/// entry's home bucket without reading its hash.
332struct PacketHashSet {
333    buckets: Box<[u64]>,
334    len: usize,
335    /// Seed of the keyed bucket hash; `None` uses the hashes' leading bits.
336    key_seed: Option<u64>,
337}
338
339impl PacketHashSet {
340    fn new(max_entries: usize, key_seed: Option<u64>) -> Self {
341        Self {
342            buckets: vec![0; bucket_capacity(max_entries)].into_boxed_slice(),
343            len: 0,
344            key_seed,
345        }
346    }
347
348    /// Bucket key of a packet hash.
349    fn key(&self, hash: &[u8; 32]) -> u64 {
350        let leading = leading_bits(hash);
351        match self.key_seed {
352            None => leading,
353            Some(seed) => {
354                use core::hash::BuildHasher;
355                foldhash::fast::FixedState::with_seed(seed).hash_one(leading)
356            }
357        }
358    }
359
360    fn len(&self) -> usize {
361        self.len
362    }
363
364    fn position(&self, hash: &[u8; 32], queue: &PacketHashQueue) -> Option<usize> {
365        if self.buckets.is_empty() {
366            return None;
367        }
368        let key = self.key(hash);
369        let tag = key as u32;
370        let mut idx = self.bucket_index(key);
371        loop {
372            let entry = self.buckets[idx];
373            if entry == 0 {
374                return None;
375            }
376            if bucket_tag(entry) == tag && queue.entries.get(bucket_slot(entry)) == hash {
377                return Some(idx);
378            }
379            idx = (idx + 1) & (self.buckets.len() - 1);
380        }
381    }
382
383    fn contains(&self, hash: &[u8; 32], queue: &PacketHashQueue) -> bool {
384        self.position(hash, queue).is_some()
385    }
386
387    fn insert(&mut self, slot: usize, queue: &PacketHashQueue) {
388        let key = self.key(queue.entries.get(slot));
389        let mut idx = self.bucket_index(key);
390        while self.buckets[idx] != 0 {
391            idx = (idx + 1) & (self.buckets.len() - 1);
392        }
393        self.buckets[idx] = (u64::from(key as u32) << 32) | (slot as u64 + 1);
394        self.len += 1;
395    }
396
397    fn remove(&mut self, hash: &[u8; 32], queue: &PacketHashQueue) -> bool {
398        let Some(idx) = self.position(hash, queue) else {
399            return false;
400        };
401        self.buckets[idx] = 0;
402        self.len -= 1;
403        // Repair the probe cluster before the evicted queue slot is reused,
404        // by backward-shift deletion: walk the cluster after the hole and move
405        // each entry whose home bucket does not lie cyclically in
406        // (hole, current] back into the hole. Every remaining entry stays
407        // reachable from its home bucket, and each moves at most once.
408        let mask = self.buckets.len() - 1;
409        let mut hole = idx;
410        let mut next = (idx + 1) & mask;
411        while self.buckets[next] != 0 {
412            let home = self.home(self.buckets[next], queue);
413            let reachable_without_hole = if hole <= next {
414                hole < home && home <= next
415            } else {
416                hole < home || home <= next
417            };
418            if !reachable_without_hole {
419                self.buckets[hole] = self.buckets[next];
420                self.buckets[next] = 0;
421                hole = next;
422            }
423            next = (next + 1) & mask;
424        }
425        true
426    }
427
428    fn rebuild(&mut self, queue: &PacketHashQueue) {
429        self.buckets.fill(0);
430        self.len = 0;
431        for offset in 0..queue.len {
432            self.insert((queue.head + offset) % queue.capacity(), queue);
433        }
434    }
435
436    fn prefetch(&self, key: u64) {
437        if !self.buckets.is_empty() {
438            prefetch_read(&self.buckets[self.bucket_index(key)]);
439        }
440    }
441
442    fn bucket_index(&self, key: u64) -> usize {
443        debug_assert!(!self.buckets.is_empty());
444        (key as usize) & (self.buckets.len() - 1)
445    }
446
447    /// Home bucket of an occupied entry. The stored key bits cover every
448    /// table of up to 2^32 buckets; larger tables read the queued hash.
449    fn home(&self, entry: u64, queue: &PacketHashQueue) -> usize {
450        if self.buckets.len() as u64 <= 1 << 32 {
451            self.bucket_index(u64::from(bucket_tag(entry)))
452        } else {
453            self.bucket_index(self.key(queue.entries.get(bucket_slot(entry))))
454        }
455    }
456}
457
458/// Hint the CPU to load the cache line holding `value`. It has no effect on
459/// program state, and is a no-op on architectures without a stable hint.
460#[inline(always)]
461fn prefetch_read(value: &u64) {
462    #[cfg(target_arch = "x86_64")]
463    // SAFETY: SSE is part of the x86_64 baseline, and a prefetch never faults
464    // or changes memory.
465    unsafe {
466        core::arch::x86_64::_mm_prefetch::<{ core::arch::x86_64::_MM_HINT_T0 }>(
467            (value as *const u64).cast(),
468        );
469    }
470    #[cfg(target_arch = "aarch64")]
471    // SAFETY: PRFM is a hint that never faults or changes memory or flags.
472    unsafe {
473        core::arch::asm!(
474            "prfm pldl1keep, [{0}]",
475            in(reg) value as *const u64,
476            options(nostack, readonly, preserves_flags)
477        );
478    }
479    #[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))]
480    let _ = value;
481}
482
483fn bucket_slot(entry: u64) -> usize {
484    (entry as u32) as usize - 1
485}
486
487fn bucket_tag(entry: u64) -> u32 {
488    (entry >> 32) as u32
489}
490
491/// Largest retention the 32-bit index buckets can address.
492const MAX_ENTRIES: usize = u32::MAX as usize - 1;
493
494fn bucket_capacity(max_entries: usize) -> usize {
495    if max_entries == 0 {
496        return 0;
497    }
498
499    let min_capacity = max_entries.saturating_mul(2).max(1);
500    min_capacity.next_power_of_two()
501}
502
503/// Leading 64 bits of a packet hash. Packet hashes are SHA-256 digests,
504/// already uniformly distributed for packets nobody chose, so an unseeded
505/// table indexes buckets with these bits directly.
506fn leading_bits(hash: &[u8; 32]) -> u64 {
507    let mut leading = [0u8; 8];
508    leading.copy_from_slice(&hash[..8]);
509    u64::from_le_bytes(leading)
510}
511
512#[cfg(test)]
513mod tests {
514    use super::*;
515
516    fn make_hash(seed: u8) -> [u8; 32] {
517        let mut h = [0u8; 32];
518        h[0] = seed;
519        h
520    }
521
522    /// Every allocation policy, unseeded and with a keyed bucket hash.
523    fn seeded_policies() -> impl Iterator<Item = (PacketHashlistAllocation, Option<u64>)> {
524        policies()
525            .into_iter()
526            .flat_map(|policy| [(policy, None), (policy, Some(0x9e37_79b9_7f4a_7c15))])
527    }
528
529    #[test]
530    fn seeding_keeps_retained_hashes_and_fifo_order() {
531        for policy in policies() {
532            let mut list = PacketHashlist::with_allocation(16, policy);
533            let hashes: Vec<[u8; 32]> = (0..12).map(make_hash).collect();
534            for hash in &hashes {
535                list.add(*hash);
536            }
537            list.seed_hashing(0x0123_4567_89ab_cdef);
538            assert!(hashes.iter().all(|hash| list.is_duplicate(hash)));
539            assert!(list.iter().eq(hashes.iter()));
540            assert!(!list.is_duplicate(&make_hash(200)));
541        }
542    }
543
544    fn policies() -> [PacketHashlistAllocation; 2] {
545        [
546            PacketHashlistAllocation::Eager,
547            PacketHashlistAllocation::Lazy,
548        ]
549    }
550
551    #[test]
552    fn test_new_hash_not_duplicate() {
553        for policy in policies() {
554            let hl = PacketHashlist::with_allocation(100, policy);
555            assert!(!hl.is_duplicate(&make_hash(1)));
556        }
557    }
558
559    #[test]
560    fn test_added_hash_is_duplicate() {
561        for policy in policies() {
562            let mut hl = PacketHashlist::with_allocation(100, policy);
563            let h = make_hash(1);
564            hl.add(h);
565            assert!(hl.is_duplicate(&h));
566        }
567    }
568
569    #[test]
570    fn test_duplicate_insert_does_not_increase_len() {
571        for policy in policies() {
572            let mut hl = PacketHashlist::with_allocation(2, policy);
573            let h = make_hash(1);
574            hl.add(h);
575            hl.add(h);
576            assert_eq!(hl.len(), 1);
577            assert!(hl.is_duplicate(&h));
578        }
579    }
580
581    #[test]
582    fn test_full_hashlist_evicts_oldest_unique_hash() {
583        for policy in policies() {
584            let mut hl = PacketHashlist::with_allocation(3, policy);
585            let hashes = [make_hash(1), make_hash(2), make_hash(3), make_hash(4)];
586            for hash in hashes {
587                hl.add(hash);
588            }
589            assert!(!hl.is_duplicate(&hashes[0]));
590            assert!(hashes[1..].iter().all(|hash| hl.is_duplicate(hash)));
591            assert_eq!(hl.len(), 3);
592        }
593    }
594
595    #[test]
596    fn test_duplicate_does_not_refresh_recency() {
597        for policy in policies() {
598            let mut hl = PacketHashlist::with_allocation(2, policy);
599            let h1 = make_hash(1);
600            let h2 = make_hash(2);
601            let h3 = make_hash(3);
602            hl.add(h1);
603            hl.add(h2);
604            hl.add(h2);
605            hl.add(h3);
606            assert!(!hl.is_duplicate(&h1));
607            assert!(hl.is_duplicate(&h2));
608            assert!(hl.is_duplicate(&h3));
609        }
610    }
611
612    #[test]
613    fn removal_preserves_fifo_order_after_queue_wraps() {
614        for policy in policies() {
615            let mut hl = PacketHashlist::with_allocation(3, policy);
616            for seed in 1..=4 {
617                hl.add(make_hash(seed));
618            }
619
620            assert!(hl.remove(&make_hash(3)));
621            assert!(!hl.remove(&make_hash(1)));
622            assert_eq!(
623                hl.iter().copied().collect::<Vec<_>>(),
624                vec![make_hash(2), make_hash(4)]
625            );
626
627            hl.add(make_hash(5));
628            assert_eq!(
629                hl.iter().copied().collect::<Vec<_>>(),
630                vec![make_hash(2), make_hash(4), make_hash(5)]
631            );
632        }
633    }
634
635    #[test]
636    fn test_fifo_eviction_order_is_exact_across_multiple_inserts() {
637        for policy in policies() {
638            let mut hl = PacketHashlist::with_allocation(3, policy);
639            for seed in 1..=9 {
640                hl.add(make_hash(seed));
641            }
642            assert_eq!(
643                hl.iter().copied().collect::<Vec<_>>(),
644                vec![make_hash(7), make_hash(8), make_hash(9)]
645            );
646        }
647    }
648
649    #[test]
650    fn test_zero_capacity_hashlist_is_noop() {
651        for policy in policies() {
652            let mut hl = PacketHashlist::with_allocation(0, policy);
653            let h = make_hash(1);
654            hl.add(h);
655            assert_eq!(hl.len(), 0);
656            assert!(!hl.is_duplicate(&h));
657            assert_eq!(hl.iter().count(), 0);
658        }
659    }
660
661    /// Random adds, duplicates and removals against a FIFO-plus-set model,
662    /// with keys confined to a few buckets so probe clusters, wraparound and
663    /// backward-shift deletion are exercised heavily.
664    #[test]
665    fn hashlist_matches_fifo_model_under_collisions() {
666        use alloc::collections::VecDeque;
667        for (policy, seed) in seeded_policies() {
668            for capacity in [1usize, 2, 3, 7, 16, 33] {
669                let mut list = PacketHashlist::with_allocation(capacity, policy);
670                if let Some(seed) = seed {
671                    list.seed_hashing(seed);
672                }
673                let mut model: VecDeque<[u8; 32]> = VecDeque::new();
674                let mut rng = 0x2545_f491_4f6c_dd1du64 ^ capacity as u64;
675                for _ in 0..20_000 {
676                    rng ^= rng << 13;
677                    rng ^= rng >> 7;
678                    rng ^= rng << 17;
679                    let mut key = [0u8; 32];
680                    // Few distinct leading bytes: many keys share a home bucket.
681                    key[0] = (rng % 5) as u8;
682                    key[8] = (rng >> 8) as u8 % (capacity as u8 * 3 + 1);
683                    match (rng >> 20) % 4 {
684                        0 | 1 => {
685                            list.add(key);
686                            if !model.contains(&key) {
687                                if model.len() == capacity {
688                                    model.pop_front();
689                                }
690                                model.push_back(key);
691                            }
692                        }
693                        2 => {
694                            let removed = list.remove(&key);
695                            let position = model.iter().position(|k| *k == key);
696                            assert_eq!(removed, position.is_some());
697                            if let Some(position) = position {
698                                model.remove(position);
699                            }
700                        }
701                        _ => {}
702                    }
703                    assert_eq!(list.len(), model.len());
704                    assert_eq!(list.is_duplicate(&key), model.contains(&key));
705                    if rng.is_multiple_of(97) {
706                        for retained in &model {
707                            assert!(list.is_duplicate(retained));
708                        }
709                        assert!(list.iter().eq(model.iter()));
710                    }
711                }
712            }
713        }
714    }
715
716    #[test]
717    fn collision_cluster_removal_preserves_remaining_entries() {
718        for policy in policies() {
719            let mut set = PacketHashSet::new(3, None);
720            let mut queue = PacketHashQueue::new(3, policy);
721            let mut colliding = Vec::new();
722            for seed in 0..=u8::MAX {
723                let hash = make_hash(seed);
724                if leading_bits(&hash) & 7 == 0 {
725                    colliding.push(hash);
726                    if colliding.len() == 3 {
727                        break;
728                    }
729                }
730            }
731            assert_eq!(colliding.len(), 3);
732            for hash in &colliding {
733                queue.push_back(*hash);
734                set.insert(queue.len - 1, &queue);
735            }
736            assert!(set.remove(&colliding[0], &queue));
737            assert!(set.contains(&colliding[1], &queue));
738            assert!(set.contains(&colliding[2], &queue));
739        }
740    }
741
742    #[test]
743    fn raw_slots_read_only_after_write() {
744        for policy in policies() {
745            let mut slots = RawHashSlots::new(2, policy);
746            slots.write(1, make_hash(42));
747            assert_eq!(slots.read(1), make_hash(42));
748        }
749    }
750
751    // --- AnnounceSignatureCache tests ---
752
753    #[test]
754    fn lazy_storage_grows_with_occupancy_not_retention_limit() {
755        let mut table = PacketHashlist::with_allocation(250_000, PacketHashlistAllocation::Lazy);
756        assert_eq!(table.queue.capacity(), 64);
757        assert_eq!(table.set.buckets.len(), 128);
758        for n in 0u64..1000 {
759            let mut hash = [0; 32];
760            hash[..8].copy_from_slice(&n.to_le_bytes());
761            table.add(hash);
762        }
763        assert_eq!(table.len(), 1000);
764        assert_eq!(table.queue.capacity(), 1024);
765        assert_eq!(table.set.buckets.len(), 2048);
766        assert_eq!(table.max_size, 250_000);
767    }
768
769    #[test]
770    fn mixed_operations_match_fifo_model_through_growth_and_wraparound() {
771        use alloc::collections::VecDeque;
772        for (policy, seed) in seeded_policies() {
773            for limit in [0, 1, 3, 63, 64, 65, 127, 129, 257] {
774                let mut table = PacketHashlist::with_allocation(limit, policy);
775                if let Some(seed) = seed {
776                    table.seed_hashing(seed);
777                }
778                let mut model = VecDeque::new();
779                let mut random = 0x123456789abcdefu64;
780                for step in 0..10_000 {
781                    random ^= random << 13;
782                    random ^= random >> 7;
783                    random ^= random << 17;
784                    let id = random % 400;
785                    let mut hash = [0; 32];
786                    hash[..8].copy_from_slice(&id.to_le_bytes());
787                    if step % 5 == 0 {
788                        let position = model.iter().position(|h| h == &hash);
789                        assert_eq!(table.remove(&hash), position.is_some());
790                        if let Some(position) = position {
791                            model.remove(position);
792                        }
793                    } else {
794                        table.add(hash);
795                        if limit > 0 && !model.contains(&hash) {
796                            if model.len() == limit {
797                                model.pop_front();
798                            }
799                            model.push_back(hash);
800                        }
801                    }
802                    assert_eq!(table.len(), model.len());
803                    assert_eq!(table.is_duplicate(&hash), model.contains(&hash));
804                    assert!(table.iter().eq(model.iter()));
805                    if step % 100 == 0 {
806                        for id in 0u64..400 {
807                            let mut hash = [0; 32];
808                            hash[..8].copy_from_slice(&id.to_le_bytes());
809                            assert_eq!(table.is_duplicate(&hash), model.contains(&hash));
810                        }
811                    }
812                }
813            }
814        }
815    }
816
817    #[test]
818    fn test_sig_cache_insert_and_contains() {
819        let mut cache = AnnounceSignatureCache::new(100, 60.0);
820        let k = make_hash(1);
821        assert!(!cache.contains(&k));
822        cache.insert(k, 100.0);
823        assert!(cache.contains(&k));
824        assert_eq!(cache.len(), 1);
825    }
826
827    #[test]
828    fn test_sig_cache_duplicate_insert_is_noop() {
829        let mut cache = AnnounceSignatureCache::new(100, 60.0);
830        let k = make_hash(1);
831        cache.insert(k, 100.0);
832        cache.insert(k, 200.0);
833        assert_eq!(cache.len(), 1);
834    }
835
836    #[test]
837    fn test_sig_cache_ttl_expiry() {
838        let mut cache = AnnounceSignatureCache::new(100, 60.0);
839        cache.insert(make_hash(1), 100.0);
840        cache.insert(make_hash(2), 150.0);
841
842        // At t=155, entry 1 (age=55) is still within TTL, entry 2 (age=5) too
843        assert_eq!(cache.cull(155.0), 0);
844        assert_eq!(cache.len(), 2);
845
846        // At t=161, entry 1 (age=61) expired, entry 2 (age=11) still valid
847        assert_eq!(cache.cull(161.0), 1);
848        assert_eq!(cache.len(), 1);
849        assert!(!cache.contains(&make_hash(1)));
850        assert!(cache.contains(&make_hash(2)));
851    }
852
853    #[test]
854    fn test_sig_cache_capacity_eviction() {
855        let mut cache = AnnounceSignatureCache::new(2, 600.0);
856        cache.insert(make_hash(1), 100.0);
857        cache.insert(make_hash(2), 101.0);
858        cache.insert(make_hash(3), 102.0); // should evict hash(1)
859
860        assert_eq!(cache.len(), 2);
861        assert!(!cache.contains(&make_hash(1)));
862        assert!(cache.contains(&make_hash(2)));
863        assert!(cache.contains(&make_hash(3)));
864    }
865
866    #[test]
867    fn test_sig_cache_disabled_when_zero_capacity() {
868        let mut cache = AnnounceSignatureCache::new(0, 60.0);
869        let k = make_hash(1);
870        cache.insert(k, 100.0);
871        assert!(!cache.contains(&k));
872        assert_eq!(cache.len(), 0);
873        assert_eq!(cache.cull(200.0), 0);
874    }
875}