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