Skip to main content

dig_dht/
provider_store.rs

1//! [`ProviderStore`] — the local key→providers map a node serves on `find_providers` / `add_provider`.
2//!
3//! Every DHT node keeps a small store of provider records it has been told about (via
4//! `add_provider`, because it is one of the `k` closest to those content keys) plus the records for
5//! content **it itself holds and announces**. The store is:
6//!
7//! - **keyed by content key** (the 64-hex [`Key`](crate::Key)) → a set of [`ProviderRecord`]s (one
8//!   per distinct provider `peer_id`);
9//! - **TTL'd** — [`get`](ProviderStore::get) never returns expired records, and
10//!   [`gc`](ProviderStore::gc) drops them so the store does not grow without bound;
11//! - **dedup-on-provider** — re-announcing from the same provider replaces that provider's record
12//!   (refreshing its `expires_at` + addresses), it does not accumulate duplicates;
13//! - **bounded** — [`put`](ProviderStore::put) enforces a per-content-key cap
14//!   ([`ProviderStoreLimits::max_providers_per_key`]) and a global record ceiling
15//!   ([`ProviderStoreLimits::max_total_records`]); an inbound record from an untrusted peer can
16//!   never grow the store without bound (SPEC §6.3, §14).
17//!
18//! It also tracks the set of content keys **this node announces** (content it holds) so the
19//! maintenance loop can republish them before their TTL elapses ([`local_announcements`]).
20//!
21//! [`local_announcements`]: ProviderStore::local_announcements
22
23use std::collections::{HashMap, HashSet};
24
25use crate::record::ProviderRecord;
26
27/// Bounds enforced by [`ProviderStore::put`] — the admission control that keeps the store from
28/// growing without bound under inbound `add_provider` traffic from untrusted peers.
29///
30/// Both caps are enforced **on every `put`**, not just at GC time: a single misbehaving peer that
31/// floods `add_provider` for many distinct content keys (or many distinct providers per key) is
32/// rejected once a cap is hit, rather than accepted and relying on TTL expiry to eventually free
33/// memory (SPEC §6.3, §14 "Unbounded provider store").
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub struct ProviderStoreLimits {
36    /// Maximum distinct provider records kept **per content key**. When a `put` for a new provider
37    /// would exceed this, an existing record is evicted to make room: an EXPIRED record if the key
38    /// holds one, otherwise the soonest-to-expire among the key's NEWEST slots, leaving its
39    /// longest-established LIVE providers reserved (see [`ProviderStore::eviction_victim`]).
40    pub max_providers_per_key: usize,
41    /// Maximum total records across **all** content keys. When a `put` for a genuinely new
42    /// (content_key, provider) pair would exceed this, the request is rejected outright (no
43    /// eviction across keys — that would let one attacker evict another key's legitimate holders).
44    pub max_total_records: usize,
45}
46
47impl Default for ProviderStoreLimits {
48    /// Conservative defaults: `k` (20, the Kademlia replication parameter) providers per key is
49    /// already generous replication, and a global ceiling that comfortably covers a node
50    /// participating in many lookups while still bounding worst-case memory from a single
51    /// misbehaving peer.
52    fn default() -> Self {
53        ProviderStoreLimits {
54            max_providers_per_key: 20,
55            max_total_records: 100_000,
56        }
57    }
58}
59
60/// The outcome of a [`ProviderStore::put`] — whether the record was admitted.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum PutOutcome {
63    /// The record was stored (fresh insert or refresh of an existing provider's record).
64    Accepted,
65    /// The record was rejected: the store is at capacity and the record did not qualify for
66    /// eviction-based admission (a new provider would exceed
67    /// [`ProviderStoreLimits::max_total_records`], or the per-key cap is full of records that all
68    /// expire no sooner than the incoming one).
69    RejectedOverCapacity,
70}
71
72/// Share of a content key's slots reserved for its longest-established providers — the divisor is
73/// applied to [`ProviderStoreLimits::max_providers_per_key`], so half the slots are protected from
74/// eviction and the newest half form the "churn zone" where eviction happens (#1434).
75///
76/// Half is chosen so the floor is always strictly smaller than the cap: a newcomer can therefore
77/// ALWAYS be admitted by evicting inside the churn zone, and the protection never turns into a
78/// refusal to learn about new honest holders.
79const ESTABLISHED_FLOOR_DIVISOR: usize = 2;
80
81/// One stored provider record plus **when this node first admitted it** — its establishment.
82///
83/// Establishment is an admission SEQUENCE number, not a timestamp: the store needs only the relative
84/// order in which providers were first learned, and an ordinal cannot be manipulated by an attacker
85/// choosing when to announce, nor does it need a clock threaded through [`ProviderStore::put`].
86#[derive(Debug)]
87struct ProviderEntry {
88    record: ProviderRecord,
89    /// Admission order — assigned once, on first admission, and PRESERVED across refreshes so
90    /// republishing (how an honest holder stays findable) never costs a holder its establishment.
91    admitted_seq: u64,
92}
93
94/// A node's local provider records + the set of content keys it announces itself.
95#[derive(Debug)]
96pub struct ProviderStore {
97    /// content_key (64-hex) → provider_peer_id (64-hex) → entry.
98    by_key: HashMap<String, HashMap<String, ProviderEntry>>,
99    /// content keys (64-hex) this node holds + announces (for republish).
100    announced: HashSet<String>,
101    /// Admission-control bounds enforced by [`put`](Self::put).
102    limits: ProviderStoreLimits,
103    /// Monotonic source of [`ProviderEntry::admitted_seq`] — the next admission's ordinal.
104    next_admitted_seq: u64,
105}
106
107impl Default for ProviderStore {
108    fn default() -> Self {
109        ProviderStore::new()
110    }
111}
112
113impl ProviderStore {
114    /// A new empty store with the default [`ProviderStoreLimits`].
115    pub fn new() -> Self {
116        ProviderStore::with_limits(ProviderStoreLimits::default())
117    }
118
119    /// A new empty store enforcing `limits` on every [`put`](Self::put).
120    pub fn with_limits(limits: ProviderStoreLimits) -> Self {
121        ProviderStore {
122            by_key: HashMap::new(),
123            announced: HashSet::new(),
124            limits,
125            next_admitted_seq: 0,
126        }
127    }
128
129    /// Store (or refresh) a provider record, subject to [`ProviderStoreLimits`].
130    ///
131    /// Keyed by (content_key, provider_peer_id): a second record from the same provider for the
132    /// same key REPLACES the first (refreshes expiry + addresses) rather than duplicating — this
133    /// always succeeds regardless of capacity, since it does not grow the store.
134    ///
135    /// A genuinely new (content_key, provider) pair is admission-controlled:
136    /// - if the key already holds [`ProviderStoreLimits::max_providers_per_key`] *other* providers,
137    ///   one is evicted to make room — chosen by [`eviction_victim`], which reserves the key's
138    ///   longest-established slots so a Sybil flood cannot displace an incumbent holder (#1434);
139    /// - if the store is at [`ProviderStoreLimits::max_total_records`] globally, the new record is
140    ///   rejected — [`PutOutcome::RejectedOverCapacity`] — rather than evicting another key's
141    ///   records (which would let one attacker's flood evict another key's legitimate holders).
142    ///
143    /// [`eviction_victim`]: Self::eviction_victim
144    pub fn put(&mut self, record: ProviderRecord) -> PutOutcome {
145        self.put_at(record, crate::clock::now_secs())
146    }
147
148    /// [`put`](Self::put) with an explicit `now` (absolute Unix seconds) — the same admission
149    /// decision, taking the caller's clock instead of reading the system one.
150    ///
151    /// `now` is what lets eviction tell a LIVE provider from an expired one, which is the difference
152    /// between reclaiming a dead slot and evicting a real holder (see [`eviction_victim`]). A caller
153    /// that already has a timestamp — the serving side computes one for the TTL clamp — SHOULD pass
154    /// it, so the clamp and the admission decision are made against a single instant.
155    ///
156    /// [`eviction_victim`]: Self::eviction_victim
157    pub fn put_at(&mut self, record: ProviderRecord, now: u64) -> PutOutcome {
158        if let Some(existing) = self
159            .by_key
160            .get_mut(&record.content_key)
161            .and_then(|providers| providers.get_mut(&record.provider_peer_id))
162        {
163            // Refresh: same provider, same key. It does not grow the store, so no capacity check —
164            // and `admitted_seq` is deliberately left untouched (see [`ProviderEntry`]).
165            existing.record = record;
166            return PutOutcome::Accepted;
167        }
168
169        // Global ceiling check FIRST, before touching this key's entry, so a rejected record never
170        // leaves a stray empty entry behind and so the check reads the true pre-insert total (not
171        // skewed by an entry we are about to create).
172        if self.len() >= self.limits.max_total_records {
173            return PutOutcome::RejectedOverCapacity;
174        }
175        if let Some(providers) = self.by_key.get_mut(&record.content_key) {
176            if providers.len() >= self.limits.max_providers_per_key {
177                let Some(evict_id) =
178                    Self::eviction_victim(providers, self.limits.max_providers_per_key, now)
179                else {
180                    // Every slot is established — admitting would breach the per-key cap, so the
181                    // cap wins. Unreachable while the floor stays a strict fraction of the cap; kept
182                    // as the explicit guard that the per-key invariant is never violated.
183                    return PutOutcome::RejectedOverCapacity;
184                };
185                providers.remove(&evict_id);
186            }
187        }
188
189        let admitted_seq = self.next_admitted_seq;
190        self.next_admitted_seq += 1;
191        self.by_key
192            .entry(record.content_key.clone())
193            .or_default()
194            .insert(
195                record.provider_peer_id.clone(),
196                ProviderEntry {
197                    record,
198                    admitted_seq,
199                },
200            );
201        PutOutcome::Accepted
202    }
203
204    /// Pick which of a full key's providers to evict, or `None` if none may be.
205    ///
206    /// **Why not simply soonest-to-expire (#1434).** Every inbound record has its `expires_at`
207    /// clamped to `now + provider_ttl` at admission, so a provider that announces LATER necessarily
208    /// carries a strictly LATER expiry. Pure soonest-to-expire eviction therefore made the honest
209    /// incumbent the deterministic victim of anyone announcing after it: `max_providers_per_key`
210    /// Sybil identities — free, since a `ProviderRecord` is unsigned self-assertion — could evict
211    /// the ONLY real holder of a capsule and replace it with peers that fail the fetch, making that
212    /// content undiscoverable through this node. Repeated across the k-closest nodes that is
213    /// network-wide censorship of a key.
214    ///
215    /// **The policy, in two steps.**
216    ///
217    /// 1. **An EXPIRED record is the victim, wherever it sits — the floor included.** A record past
218    ///    its `expires_at` is already invisible to [`get`](Self::get) and merely awaits the next
219    ///    [`gc`](Self::gc), so reclaiming its slot costs nothing. Liveness therefore OUTRANKS
220    ///    establishment. Were the floor allowed to protect a dead record, a live holder in the churn
221    ///    zone would be evicted to keep a corpse — and that needs no attacker, because a node's GC
222    ///    tick is coarser than the provider TTL: a key whose earliest providers have gone offline
223    ///    (ordinary churn — shutdown, cache eviction) carries expired records inside its floor for a
224    ///    whole GC period, and during that window every new announcement would evict a LIVE
225    ///    provider, making a capsule LESS discoverable the more holders announce it. That is the
226    ///    replication flywheel running backwards.
227    /// 2. **Otherwise every record is live, and the establishment floor governs.** The
228    ///    `max_providers_per_key / ESTABLISHED_FLOOR_DIVISOR` longest-established providers are
229    ///    RESERVED; the victim is the soonest-to-expire among the newest slots (the churn zone),
230    ///    that being the least valuable LIVE record to keep. This mirrors the k-bucket policy this
231    ///    crate already applies to contacts — long-lived entries resist eviction attacks — and
232    ///    bounds what a flood can achieve: an attacker may churn the unreserved slots at will but
233    ///    cannot displace an ALREADY-ESTABLISHED holder, however many identities it spends or
234    ///    however it times its expiries.
235    ///
236    /// Ties break on `admitted_seq` in both steps, so the choice is deterministic rather than
237    /// hash-order dependent.
238    ///
239    /// **Residual, NOT closed here.** The floor protects an incumbent, not a latecomer: an attacker
240    /// that establishes BEFORE the honest holder retains the full pre-#1434 eviction primitive, and
241    /// because this store is in-memory only, every restart resets the floor to first-come. See the
242    /// caveat in `SPEC.md` §6.3/§14 — closing it needs signed provider records (#1573).
243    fn eviction_victim(
244        providers: &HashMap<String, ProviderEntry>,
245        max_providers_per_key: usize,
246        now: u64,
247    ) -> Option<String> {
248        let mut by_establishment: Vec<&ProviderEntry> = providers.values().collect();
249        by_establishment.sort_by_key(|e| e.admitted_seq);
250
251        // Step 1 — reclaim a dead slot in preference to ANY live record, the floor included.
252        let expired = by_establishment
253            .iter()
254            .filter(|e| e.record.is_expired(now))
255            .min_by_key(|e| (e.record.expires_at, e.admitted_seq));
256        if let Some(dead) = expired {
257            return Some(dead.record.provider_peer_id.clone());
258        }
259
260        // Step 2 — every record is live: reserve the established floor, evict inside the churn zone.
261        let established_floor = max_providers_per_key / ESTABLISHED_FLOOR_DIVISOR;
262        by_establishment
263            .into_iter()
264            .skip(established_floor)
265            .min_by_key(|e| (e.record.expires_at, e.admitted_seq))
266            .map(|e| e.record.provider_peer_id.clone())
267    }
268
269    /// Remove exactly the record for `(content_key, provider_peer_id)`, if present. Returns whether
270    /// a record was removed.
271    ///
272    /// This is the store half of an **authenticated retract** (SPEC §6.6): a caller that has
273    /// verified a signed retract from `provider_peer_id` removes only that provider's record for
274    /// that key. It MUST NOT touch any OTHER provider of the same key — a retract signed by one
275    /// holder can never evict another holder's record (censorship-resistance). A content key left
276    /// with no remaining providers is dropped so the store does not accumulate empty entries.
277    pub fn remove(&mut self, content_key: &str, provider_peer_id: &str) -> bool {
278        let Some(providers) = self.by_key.get_mut(content_key) else {
279            return false;
280        };
281        let removed = providers.remove(provider_peer_id).is_some();
282        if providers.is_empty() {
283            self.by_key.remove(content_key);
284        }
285        removed
286    }
287
288    /// The live (non-expired at `now`) provider records for `content_key`. Expired records are
289    /// skipped (and cleaned up by [`gc`](Self::gc)); returns an empty vec if none are known/live.
290    pub fn get(&self, content_key: &str, now: u64) -> Vec<ProviderRecord> {
291        self.by_key
292            .get(content_key)
293            .map(|providers| {
294                providers
295                    .values()
296                    .map(|e| &e.record)
297                    .filter(|r| !r.is_expired(now))
298                    .cloned()
299                    .collect()
300            })
301            .unwrap_or_default()
302    }
303
304    /// Drop every expired record (and any content key left with no live providers) as of `now`.
305    /// Returns the number of records removed. Call periodically from the maintenance loop.
306    pub fn gc(&mut self, now: u64) -> usize {
307        let mut removed = 0;
308        self.by_key.retain(|_key, providers| {
309            let before = providers.len();
310            providers.retain(|_pid, e| !e.record.is_expired(now));
311            removed += before - providers.len();
312            !providers.is_empty()
313        });
314        removed
315    }
316
317    /// Record that this node holds + announces `content_key` (so the maintenance loop republishes
318    /// it). Idempotent.
319    pub fn mark_announced(&mut self, content_key: String) {
320        self.announced.insert(content_key);
321    }
322
323    /// Stop announcing `content_key` (this node no longer holds the content). Returns whether it was
324    /// being announced.
325    pub fn unmark_announced(&mut self, content_key: &str) -> bool {
326        self.announced.remove(content_key)
327    }
328
329    /// The content keys this node announces (holds) — the republish work list.
330    pub fn local_announcements(&self) -> Vec<String> {
331        self.announced.iter().cloned().collect()
332    }
333
334    /// Total live+stale records across all keys (diagnostics / tests).
335    pub fn len(&self) -> usize {
336        self.by_key.values().map(|p| p.len()).sum()
337    }
338
339    /// Whether the store holds no records.
340    pub fn is_empty(&self) -> bool {
341        self.len() == 0
342    }
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348    use crate::key::Key;
349    use crate::record::CandidateAddr;
350    use dig_nat::PeerId;
351
352    /// The instant the eviction tests reason at. Every `expires_at` they use is in the FUTURE
353    /// relative to this, so their records are LIVE and the assertions are about establishment —
354    /// not about a record that had silently already expired.
355    const NOW: u64 = 0;
356
357    fn rec(content: &Key, provider: u8, expires_at: u64) -> ProviderRecord {
358        ProviderRecord::new(
359            content,
360            &PeerId::from_bytes([provider; 32]),
361            vec![CandidateAddr::direct("h", 9444)],
362            expires_at,
363        )
364    }
365
366    #[test]
367    fn put_then_get_returns_live_record() {
368        let mut s = ProviderStore::new();
369        let key = Key::from_bytes([0xAA; 32]);
370        s.put(rec(&key, 1, 100));
371        let got = s.get(&key.to_hex(), 50);
372        assert_eq!(got.len(), 1);
373        assert_eq!(
374            got[0].provider_peer_id,
375            PeerId::from_bytes([1u8; 32]).to_hex()
376        );
377    }
378
379    #[test]
380    fn get_hides_expired_records() {
381        let mut s = ProviderStore::new();
382        let key = Key::from_bytes([0xAA; 32]);
383        s.put(rec(&key, 1, 100));
384        assert!(
385            s.get(&key.to_hex(), 100).is_empty(),
386            "expired at exactly TTL"
387        );
388        assert!(s.get(&key.to_hex(), 200).is_empty());
389    }
390
391    #[test]
392    fn same_provider_dedups_and_refreshes() {
393        let mut s = ProviderStore::new();
394        let key = Key::from_bytes([0xAA; 32]);
395        s.put(rec(&key, 1, 100));
396        s.put(rec(&key, 1, 500)); // same provider, later expiry
397        assert_eq!(s.len(), 1, "same provider must not duplicate");
398        // The refreshed expiry wins.
399        assert_eq!(s.get(&key.to_hex(), 300).len(), 1);
400    }
401
402    #[test]
403    fn distinct_providers_for_same_key_coexist() {
404        let mut s = ProviderStore::new();
405        let key = Key::from_bytes([0xAA; 32]);
406        s.put(rec(&key, 1, 100));
407        s.put(rec(&key, 2, 100));
408        assert_eq!(s.get(&key.to_hex(), 50).len(), 2);
409    }
410
411    // ---- Admission control (HIGH #1: unbounded provider store, SECURITY_AUDIT_P2P.md #179) ----
412
413    #[test]
414    fn put_returns_accepted_under_capacity() {
415        let mut s = ProviderStore::new();
416        let key = Key::from_bytes([0xAA; 32]);
417        assert_eq!(s.put(rec(&key, 1, 100)), PutOutcome::Accepted);
418    }
419
420    #[test]
421    fn refreshing_same_provider_always_succeeds_even_at_per_key_cap() {
422        // A refresh (same provider, same key) never counts as "new" so it must never be blocked by
423        // the per-key cap even when the key is already full.
424        let mut s = ProviderStore::with_limits(ProviderStoreLimits {
425            max_providers_per_key: 1,
426            max_total_records: 1000,
427        });
428        let key = Key::from_bytes([0xAA; 32]);
429        assert_eq!(s.put(rec(&key, 1, 100)), PutOutcome::Accepted);
430        assert_eq!(s.put(rec(&key, 1, 999)), PutOutcome::Accepted, "refresh");
431        assert_eq!(s.len(), 1);
432    }
433
434    #[test]
435    fn per_key_cap_evicts_soonest_to_expire_within_the_churn_zone() {
436        // One malicious/heavy peer announcing many DISTINCT providers for the SAME content key must
437        // not grow that key's provider set past `max_providers_per_key` — the audit's "no cap on
438        // providers-per-key" finding.
439        // Cap 4 → the two longest-established slots are reserved (#1434), so the eviction choice
440        // is made among the two newest — the churn zone. Within that zone the soonest-to-expire
441        // record is still the least valuable one to keep.
442        let mut s = ProviderStore::with_limits(ProviderStoreLimits {
443            max_providers_per_key: 4,
444            max_total_records: 1000,
445        });
446        let key = Key::from_bytes([0xAA; 32]);
447        assert_eq!(s.put_at(rec(&key, 1, 100), NOW), PutOutcome::Accepted); // established
448        assert_eq!(s.put_at(rec(&key, 2, 200), NOW), PutOutcome::Accepted); // established
449        assert_eq!(s.put_at(rec(&key, 3, 900), NOW), PutOutcome::Accepted); // churn zone
450        assert_eq!(s.put_at(rec(&key, 4, 800), NOW), PutOutcome::Accepted); // churn zone, expires sooner
451        assert_eq!(s.put_at(rec(&key, 5, 999), NOW), PutOutcome::Accepted);
452        assert_eq!(
453            s.get(&key.to_hex(), 0).len(),
454            4,
455            "per-key cap must not be exceeded"
456        );
457        assert!(
458            !live_provider_ids(&s, &key).contains(&PeerId::from_bytes([4u8; 32]).to_hex()),
459            "the soonest-to-expire record in the churn zone must be the one evicted"
460        );
461    }
462
463    /// The live provider peer_ids for `key` (order-independent membership assertions).
464    fn live_provider_ids(s: &ProviderStore, key: &Key) -> std::collections::HashSet<String> {
465        s.get(&key.to_hex(), 0)
466            .into_iter()
467            .map(|r| r.provider_peer_id)
468            .collect()
469    }
470
471    // ---- Sybil-resistant eviction (#1434) ----
472
473    #[test]
474    fn sustained_sybil_flood_cannot_evict_the_lone_established_holder() {
475        // #1434: every record clamps its expiry to `now + provider_ttl` at put time, so an attacker
476        // who announces LATER always holds a strictly-later `expires_at` than an honest incumbent.
477        // Under pure soonest-to-expire eviction that made the honest holder the deterministic
478        // victim, and 20 Sybil identities could make the only real holder of a capsule
479        // undiscoverable at this node — content-discovery censorship. Stated over the CLASS: no
480        // volume of later-expiring newcomers may evict a provider inside the established floor.
481        let mut s = ProviderStore::with_limits(ProviderStoreLimits {
482            max_providers_per_key: 20,
483            max_total_records: 100_000,
484        });
485        let key = Key::from_bytes([0xAA; 32]);
486        let honest = PeerId::from_bytes([1u8; 32]).to_hex();
487        assert_eq!(s.put_at(rec(&key, 1, 100), NOW), PutOutcome::Accepted);
488
489        // A sustained flood of distinct Sybil providers, each expiring strictly later than the last
490        // — the worst case for expiry-ordered eviction.
491        for i in 0..500u64 {
492            let sybil = ProviderRecord::new(
493                &key,
494                &PeerId::from_bytes(sybil_id(i)),
495                vec![CandidateAddr::direct("h", 9444)],
496                1_000 + i,
497            );
498            s.put_at(sybil, NOW);
499        }
500
501        assert!(
502            live_provider_ids(&s, &key).contains(&honest),
503            "the lone honest holder must survive a sustained Sybil flood"
504        );
505        assert_eq!(
506            s.get(&key.to_hex(), 0).len(),
507            20,
508            "the per-key cap still bounds the set"
509        );
510    }
511
512    #[test]
513    fn established_floor_protects_the_earliest_admitted_providers() {
514        // The one-off variant: exactly one provider beyond the cap. Eviction must fall inside the
515        // churn zone and never touch the reserved, longest-established slots.
516        let mut s = ProviderStore::with_limits(ProviderStoreLimits {
517            max_providers_per_key: 4,
518            max_total_records: 1000,
519        });
520        let key = Key::from_bytes([0xAA; 32]);
521        // Established slots deliberately hold the SOONEST expiries — under the old policy they
522        // would have been evicted first.
523        s.put_at(rec(&key, 1, 10), NOW);
524        s.put_at(rec(&key, 2, 20), NOW);
525        s.put_at(rec(&key, 3, 900), NOW);
526        s.put_at(rec(&key, 4, 800), NOW);
527        s.put_at(rec(&key, 5, 999), NOW);
528
529        let live = live_provider_ids(&s, &key);
530        assert!(
531            live.contains(&PeerId::from_bytes([1u8; 32]).to_hex()),
532            "the first-admitted provider is inside the established floor"
533        );
534        assert!(
535            live.contains(&PeerId::from_bytes([2u8; 32]).to_hex()),
536            "the second-admitted provider is inside the established floor"
537        );
538    }
539
540    #[test]
541    fn republish_does_not_reset_a_holders_establishment() {
542        // A holder stays findable by republishing before its TTL elapses. If a refresh reset the
543        // record's establishment, republishing — the very act that keeps an honest holder alive —
544        // would drop it into the churn zone and hand the attacker the eviction it wanted.
545        let mut s = ProviderStore::with_limits(ProviderStoreLimits {
546            max_providers_per_key: 4,
547            max_total_records: 1000,
548        });
549        let key = Key::from_bytes([0xAA; 32]);
550        let honest = PeerId::from_bytes([1u8; 32]).to_hex();
551        s.put_at(rec(&key, 1, 100), NOW);
552        for i in 0..3u64 {
553            s.put_at(rec(&key, 10 + i as u8, 500 + i), NOW);
554        }
555        s.put_at(rec(&key, 1, 5_000), NOW); // the honest holder republishes
556        for i in 0..50u64 {
557            s.put_at(
558                ProviderRecord::new(
559                    &key,
560                    &PeerId::from_bytes(sybil_id(i)),
561                    vec![CandidateAddr::direct("h", 9444)],
562                    9_000 + i,
563                ),
564                NOW,
565            );
566        }
567        assert!(
568            live_provider_ids(&s, &key).contains(&honest),
569            "a republished record keeps its establishment"
570        );
571    }
572
573    // ---- Liveness outranks establishment (#1434 follow-up) ----
574
575    #[test]
576    fn an_expired_record_in_the_floor_is_evicted_before_a_live_one() {
577        // The pre-#1434 policy evicted the soonest-to-expire record, so an EXPIRED record was always
578        // the first victim. The establishment floor must not invert that: a dead record inside the
579        // reserved floor cannot outrank a live provider in the churn zone. Without a liveness check
580        // this needs NO attacker — a node's GC tick is coarser than the provider TTL, so whenever the
581        // earliest-admitted half of a key goes offline, every new announcement for that key evicts a
582        // LIVE holder and announcing more holders makes the capsule LESS discoverable.
583        let mut s = ProviderStore::with_limits(ProviderStoreLimits {
584            max_providers_per_key: 4,
585            max_total_records: 1000,
586        });
587        let key = Key::from_bytes([0xAA; 32]);
588        let now = 10_000;
589        // The reserved floor (seq 0, 1) is long expired...
590        s.put_at(rec(&key, 1, 100), now);
591        s.put_at(rec(&key, 2, 200), now);
592        // ...while the churn zone (seq 2, 3) holds two LIVE honest providers.
593        s.put_at(rec(&key, 3, now + 5_000), now);
594        s.put_at(rec(&key, 4, now + 6_000), now);
595
596        s.put_at(rec(&key, 5, now + 7_000), now);
597
598        let live = live_provider_ids_at(&s, &key, now);
599        assert!(
600            live.contains(&PeerId::from_bytes([3u8; 32]).to_hex())
601                && live.contains(&PeerId::from_bytes([4u8; 32]).to_hex()),
602            "both LIVE providers must survive; an expired record in the floor is the victim"
603        );
604    }
605
606    #[test]
607    fn one_expired_record_anywhere_is_the_victim_before_any_live_record() {
608        // The one-off variant: exactly ONE expired record, sitting inside the reserved floor, with
609        // every other slot live. It must still be the one evicted.
610        let mut s = ProviderStore::with_limits(ProviderStoreLimits {
611            max_providers_per_key: 4,
612            max_total_records: 1000,
613        });
614        let key = Key::from_bytes([0xAA; 32]);
615        let now = 10_000;
616        s.put_at(rec(&key, 1, 100), now); // expired, seq 0 → inside the floor
617        s.put_at(rec(&key, 2, now + 1_000), now);
618        s.put_at(rec(&key, 3, now + 2_000), now);
619        s.put_at(rec(&key, 4, now + 3_000), now);
620
621        s.put_at(rec(&key, 5, now + 4_000), now);
622
623        assert_eq!(
624            live_provider_ids_at(&s, &key, now).len(),
625            4,
626            "reclaiming the dead slot leaves every live provider intact"
627        );
628    }
629
630    #[test]
631    fn the_floor_still_protects_an_established_holder_when_every_record_is_live() {
632        // Liveness must take precedence WITHOUT weakening #1434: with no dead slot to reclaim, the
633        // establishment floor governs again and a sustained flood cannot displace the incumbent.
634        let mut s = ProviderStore::with_limits(ProviderStoreLimits {
635            max_providers_per_key: 20,
636            max_total_records: 100_000,
637        });
638        let key = Key::from_bytes([0xAA; 32]);
639        let now = 10_000;
640        let honest = PeerId::from_bytes([1u8; 32]).to_hex();
641        s.put_at(rec(&key, 1, now + 1_000), now);
642        for i in 0..500u64 {
643            s.put_at(
644                ProviderRecord::new(
645                    &key,
646                    &PeerId::from_bytes(sybil_id(i)),
647                    vec![CandidateAddr::direct("h", 9444)],
648                    now + 2_000 + i,
649                ),
650                now,
651            );
652        }
653        assert!(
654            live_provider_ids_at(&s, &key, now).contains(&honest),
655            "an all-live key keeps the #1434 protection"
656        );
657    }
658
659    #[test]
660    fn put_delegates_to_put_at_with_the_wall_clock() {
661        // `put` is the compatibility wrapper (its signature is public API): same admission decision,
662        // with `now` read from the system clock.
663        let mut wall = ProviderStore::new();
664        let key = Key::from_bytes([0xAA; 32]);
665        assert_eq!(wall.put(rec(&key, 1, u64::MAX)), PutOutcome::Accepted);
666        assert_eq!(wall.len(), 1);
667    }
668
669    /// The live provider peer_ids for `key` as of `now`.
670    fn live_provider_ids_at(
671        s: &ProviderStore,
672        key: &Key,
673        now: u64,
674    ) -> std::collections::HashSet<String> {
675        s.get(&key.to_hex(), now)
676            .into_iter()
677            .map(|r| r.provider_peer_id)
678            .collect()
679    }
680
681    /// A distinct Sybil peer_id per index (varying the high bytes so ids stay distinct past 255).
682    fn sybil_id(i: u64) -> [u8; 32] {
683        let mut b = [0xEE; 32];
684        b[0..8].copy_from_slice(&i.to_be_bytes());
685        b
686    }
687
688    #[test]
689    fn global_cap_rejects_new_content_keys_over_ceiling() {
690        // Many DISTINCT content keys (not just many providers per key) must also be bounded — the
691        // audit's "no cap on distinct content keys ... no global record ceiling" finding.
692        let mut s = ProviderStore::with_limits(ProviderStoreLimits {
693            max_providers_per_key: 20,
694            max_total_records: 2,
695        });
696        let k1 = Key::from_bytes([0x01; 32]);
697        let k2 = Key::from_bytes([0x02; 32]);
698        let k3 = Key::from_bytes([0x03; 32]);
699        assert_eq!(s.put(rec(&k1, 1, 100)), PutOutcome::Accepted);
700        assert_eq!(s.put(rec(&k2, 1, 100)), PutOutcome::Accepted);
701        assert_eq!(
702            s.put(rec(&k3, 1, 100)),
703            PutOutcome::RejectedOverCapacity,
704            "third distinct record must be rejected once the global ceiling is hit"
705        );
706        assert_eq!(s.len(), 2, "rejected record must not be stored");
707        assert!(
708            s.get(&k3.to_hex(), 0).is_empty(),
709            "rejected key must not appear in the store at all"
710        );
711    }
712
713    #[test]
714    fn global_cap_does_not_evict_a_different_key_to_make_room() {
715        // A single attacker flooding new keys must not be able to evict a DIFFERENT (legitimate)
716        // key's providers just by hitting the global ceiling.
717        let mut s = ProviderStore::with_limits(ProviderStoreLimits {
718            max_providers_per_key: 20,
719            max_total_records: 1,
720        });
721        let legit = Key::from_bytes([0xAA; 32]);
722        s.put(rec(&legit, 1, 100));
723        let attacker_key = Key::from_bytes([0xBB; 32]);
724        assert_eq!(
725            s.put(rec(&attacker_key, 2, 100)),
726            PutOutcome::RejectedOverCapacity
727        );
728        assert_eq!(
729            s.get(&legit.to_hex(), 0).len(),
730            1,
731            "the legitimate key's record must survive"
732        );
733    }
734
735    #[test]
736    fn remove_deletes_only_the_named_provider_record() {
737        // Authenticated retract (SPEC §6.6): removing (key, provider-1) must leave provider-2 of the
738        // SAME key untouched — a retract signed by one holder cannot censor another holder.
739        let mut s = ProviderStore::new();
740        let key = Key::from_bytes([0xAA; 32]);
741        s.put(rec(&key, 1, 100));
742        s.put(rec(&key, 2, 100));
743        let pid1 = PeerId::from_bytes([1u8; 32]).to_hex();
744        let pid2 = PeerId::from_bytes([2u8; 32]).to_hex();
745        assert!(
746            s.remove(&key.to_hex(), &pid1),
747            "the named record was removed"
748        );
749        let survivors: std::collections::HashSet<String> = s
750            .get(&key.to_hex(), 0)
751            .into_iter()
752            .map(|r| r.provider_peer_id)
753            .collect();
754        assert_eq!(survivors.len(), 1, "the other provider must survive");
755        assert!(survivors.contains(&pid2));
756        assert!(!survivors.contains(&pid1));
757    }
758
759    #[test]
760    fn remove_of_absent_record_returns_false() {
761        let mut s = ProviderStore::new();
762        let key = Key::from_bytes([0xAA; 32]);
763        s.put(rec(&key, 1, 100));
764        let absent = PeerId::from_bytes([9u8; 32]).to_hex();
765        assert!(!s.remove(&key.to_hex(), &absent), "no such provider");
766        assert!(!s.remove(&"00".repeat(32), &absent), "no such content key");
767        assert_eq!(s.len(), 1, "nothing removed");
768    }
769
770    #[test]
771    fn remove_drops_content_key_when_last_provider_leaves() {
772        let mut s = ProviderStore::new();
773        let key = Key::from_bytes([0xAA; 32]);
774        s.put(rec(&key, 1, 100));
775        let pid1 = PeerId::from_bytes([1u8; 32]).to_hex();
776        assert!(s.remove(&key.to_hex(), &pid1));
777        assert!(
778            s.is_empty(),
779            "the now-empty content key must be dropped entirely"
780        );
781    }
782
783    #[test]
784    fn gc_removes_expired_and_empty_keys() {
785        let mut s = ProviderStore::new();
786        let k1 = Key::from_bytes([0x01; 32]);
787        let k2 = Key::from_bytes([0x02; 32]);
788        s.put(rec(&k1, 1, 100)); // expires at 100
789        s.put(rec(&k2, 1, 500)); // expires at 500
790        let removed = s.gc(200);
791        assert_eq!(removed, 1);
792        assert!(s.get(&k1.to_hex(), 200).is_empty());
793        assert_eq!(s.get(&k2.to_hex(), 200).len(), 1);
794    }
795
796    #[test]
797    fn announcements_track_and_untrack() {
798        let mut s = ProviderStore::new();
799        let key = Key::from_bytes([0x07; 32]).to_hex();
800        s.mark_announced(key.clone());
801        s.mark_announced(key.clone()); // idempotent
802        assert_eq!(s.local_announcements(), vec![key.clone()]);
803        assert!(s.unmark_announced(&key));
804        assert!(!s.unmark_announced(&key));
805        assert!(s.local_announcements().is_empty());
806    }
807}