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;
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/// One content key in a [`ProviderSnapshot`]: the key, and how many live providers this node knows
95/// for it. Deliberately carries NO provider identity — see [`ProviderStore::snapshot`].
96#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct ProviderSnapshotEntry {
98    /// The 64-hex content key.
99    pub content_key: String,
100    /// How many non-expired providers this node holds a record for.
101    pub providers: usize,
102}
103
104/// A bounded, aggregated view of a node's provider store — see [`ProviderStore::snapshot`].
105#[derive(Debug, Clone, PartialEq, Eq)]
106pub struct ProviderSnapshot {
107    /// Content keys with at least one live provider, sorted by key, capped at the requested maximum.
108    pub entries: Vec<ProviderSnapshotEntry>,
109    /// How many keys had a live provider BEFORE the cap was applied, so a consumer can report
110    /// "showing N of M" rather than presenting a truncated view as complete.
111    pub total_keys: usize,
112    /// Whether the cap dropped entries.
113    pub truncated: bool,
114}
115
116/// A node's local provider records + the set of content keys it announces itself.
117#[derive(Debug)]
118pub struct ProviderStore {
119    /// content_key (64-hex) → provider_peer_id (64-hex) → entry.
120    by_key: HashMap<String, HashMap<String, ProviderEntry>>,
121    /// content keys (64-hex) this node holds + announces (for republish), each mapped to the
122    /// UNTRUSTED mirror-coin pointer to re-publish with it (`None` = no pointer, the normal case).
123    ///
124    /// A map rather than a set because the pointer is per-CONTENT: a mirror coin bonds a
125    /// `(store, root, owner, epoch)` tuple, so one node announcing two stores has two different
126    /// pointers. Holding it here is what stops [`republish`](crate::DhtService::republish) from
127    /// silently dropping the pointer on the first TTL rollover.
128    announced: HashMap<String, Option<String>>,
129    /// Admission-control bounds enforced by [`put`](Self::put).
130    limits: ProviderStoreLimits,
131    /// Monotonic source of [`ProviderEntry::admitted_seq`] — the next admission's ordinal.
132    next_admitted_seq: u64,
133}
134
135impl Default for ProviderStore {
136    fn default() -> Self {
137        ProviderStore::new()
138    }
139}
140
141impl ProviderStore {
142    /// A new empty store with the default [`ProviderStoreLimits`].
143    pub fn new() -> Self {
144        ProviderStore::with_limits(ProviderStoreLimits::default())
145    }
146
147    /// A new empty store enforcing `limits` on every [`put`](Self::put).
148    pub fn with_limits(limits: ProviderStoreLimits) -> Self {
149        ProviderStore {
150            by_key: HashMap::new(),
151            announced: HashMap::new(),
152            limits,
153            next_admitted_seq: 0,
154        }
155    }
156
157    /// Store (or refresh) a provider record, subject to [`ProviderStoreLimits`].
158    ///
159    /// Keyed by (content_key, provider_peer_id): a second record from the same provider for the
160    /// same key REPLACES the first (refreshes expiry + addresses) rather than duplicating — this
161    /// always succeeds regardless of capacity, since it does not grow the store.
162    ///
163    /// A genuinely new (content_key, provider) pair is admission-controlled:
164    /// - if the key already holds [`ProviderStoreLimits::max_providers_per_key`] *other* providers,
165    ///   one is evicted to make room — chosen by [`eviction_victim`], which reserves the key's
166    ///   longest-established slots so a Sybil flood cannot displace an incumbent holder (#1434);
167    /// - if the store is at [`ProviderStoreLimits::max_total_records`] globally, the new record is
168    ///   rejected — [`PutOutcome::RejectedOverCapacity`] — rather than evicting another key's
169    ///   records (which would let one attacker's flood evict another key's legitimate holders).
170    ///
171    /// [`eviction_victim`]: Self::eviction_victim
172    pub fn put(&mut self, record: ProviderRecord) -> PutOutcome {
173        self.put_at(record, crate::clock::now_secs())
174    }
175
176    /// [`put`](Self::put) with an explicit `now` (absolute Unix seconds) — the same admission
177    /// decision, taking the caller's clock instead of reading the system one.
178    ///
179    /// `now` is what lets eviction tell a LIVE provider from an expired one, which is the difference
180    /// between reclaiming a dead slot and evicting a real holder (see [`eviction_victim`]). A caller
181    /// that already has a timestamp — the serving side computes one for the TTL clamp — SHOULD pass
182    /// it, so the clamp and the admission decision are made against a single instant.
183    ///
184    /// [`eviction_victim`]: Self::eviction_victim
185    pub fn put_at(&mut self, record: ProviderRecord, now: u64) -> PutOutcome {
186        if let Some(existing) = self
187            .by_key
188            .get_mut(&record.content_key)
189            .and_then(|providers| providers.get_mut(&record.provider_peer_id))
190        {
191            // Refresh: same provider, same key. It does not grow the store, so no capacity check —
192            // and `admitted_seq` is deliberately left untouched (see [`ProviderEntry`]).
193            existing.record = record;
194            return PutOutcome::Accepted;
195        }
196
197        // Global ceiling check FIRST, before touching this key's entry, so a rejected record never
198        // leaves a stray empty entry behind and so the check reads the true pre-insert total (not
199        // skewed by an entry we are about to create).
200        if self.len() >= self.limits.max_total_records {
201            return PutOutcome::RejectedOverCapacity;
202        }
203        if let Some(providers) = self.by_key.get_mut(&record.content_key) {
204            if providers.len() >= self.limits.max_providers_per_key {
205                let Some(evict_id) =
206                    Self::eviction_victim(providers, self.limits.max_providers_per_key, now)
207                else {
208                    // Every slot is established — admitting would breach the per-key cap, so the
209                    // cap wins. Unreachable while the floor stays a strict fraction of the cap; kept
210                    // as the explicit guard that the per-key invariant is never violated.
211                    return PutOutcome::RejectedOverCapacity;
212                };
213                providers.remove(&evict_id);
214            }
215        }
216
217        let admitted_seq = self.next_admitted_seq;
218        self.next_admitted_seq += 1;
219        self.by_key
220            .entry(record.content_key.clone())
221            .or_default()
222            .insert(
223                record.provider_peer_id.clone(),
224                ProviderEntry {
225                    record,
226                    admitted_seq,
227                },
228            );
229        PutOutcome::Accepted
230    }
231
232    /// Pick which of a full key's providers to evict, or `None` if none may be.
233    ///
234    /// **Why not simply soonest-to-expire (#1434).** Every inbound record has its `expires_at`
235    /// clamped to `now + provider_ttl` at admission, so a provider that announces LATER necessarily
236    /// carries a strictly LATER expiry. Pure soonest-to-expire eviction therefore made the honest
237    /// incumbent the deterministic victim of anyone announcing after it: `max_providers_per_key`
238    /// Sybil identities — free, since a `ProviderRecord` is unsigned self-assertion — could evict
239    /// the ONLY real holder of a capsule and replace it with peers that fail the fetch, making that
240    /// content undiscoverable through this node. Repeated across the k-closest nodes that is
241    /// network-wide censorship of a key.
242    ///
243    /// **The policy, in two steps.**
244    ///
245    /// 1. **An EXPIRED record is the victim, wherever it sits — the floor included.** A record past
246    ///    its `expires_at` is already invisible to [`get`](Self::get) and merely awaits the next
247    ///    [`gc`](Self::gc), so reclaiming its slot costs nothing. Liveness therefore OUTRANKS
248    ///    establishment. Were the floor allowed to protect a dead record, a live holder in the churn
249    ///    zone would be evicted to keep a corpse — and that needs no attacker, because a node's GC
250    ///    tick is coarser than the provider TTL: a key whose earliest providers have gone offline
251    ///    (ordinary churn — shutdown, cache eviction) carries expired records inside its floor for a
252    ///    whole GC period, and during that window every new announcement would evict a LIVE
253    ///    provider, making a capsule LESS discoverable the more holders announce it. That is the
254    ///    replication flywheel running backwards.
255    /// 2. **Otherwise every record is live, and the establishment floor governs.** The
256    ///    `max_providers_per_key / ESTABLISHED_FLOOR_DIVISOR` longest-established providers are
257    ///    RESERVED; the victim is the soonest-to-expire among the newest slots (the churn zone),
258    ///    that being the least valuable LIVE record to keep. This mirrors the k-bucket policy this
259    ///    crate already applies to contacts — long-lived entries resist eviction attacks — and
260    ///    bounds what a flood can achieve: an attacker may churn the unreserved slots at will but
261    ///    cannot displace an ALREADY-ESTABLISHED holder, however many identities it spends or
262    ///    however it times its expiries.
263    ///
264    /// Ties break on `admitted_seq` in both steps, so the choice is deterministic rather than
265    /// hash-order dependent.
266    ///
267    /// **Residual, NOT closed here.** The floor protects an incumbent, not a latecomer: an attacker
268    /// that establishes BEFORE the honest holder retains the full pre-#1434 eviction primitive, and
269    /// because this store is in-memory only, every restart resets the floor to first-come. See the
270    /// caveat in `SPEC.md` §6.3/§14 — closing it needs signed provider records (#1573).
271    fn eviction_victim(
272        providers: &HashMap<String, ProviderEntry>,
273        max_providers_per_key: usize,
274        now: u64,
275    ) -> Option<String> {
276        let mut by_establishment: Vec<&ProviderEntry> = providers.values().collect();
277        by_establishment.sort_by_key(|e| e.admitted_seq);
278
279        // Step 1 — reclaim a dead slot in preference to ANY live record, the floor included.
280        let expired = by_establishment
281            .iter()
282            .filter(|e| e.record.is_expired(now))
283            .min_by_key(|e| (e.record.expires_at, e.admitted_seq));
284        if let Some(dead) = expired {
285            return Some(dead.record.provider_peer_id.clone());
286        }
287
288        // Step 2 — every record is live: reserve the established floor, evict inside the churn zone.
289        let established_floor = max_providers_per_key / ESTABLISHED_FLOOR_DIVISOR;
290        by_establishment
291            .into_iter()
292            .skip(established_floor)
293            .min_by_key(|e| (e.record.expires_at, e.admitted_seq))
294            .map(|e| e.record.provider_peer_id.clone())
295    }
296
297    /// Remove exactly the record for `(content_key, provider_peer_id)`, if present. Returns whether
298    /// a record was removed.
299    ///
300    /// This is the store half of an **authenticated retract** (SPEC §6.6): a caller that has
301    /// verified a signed retract from `provider_peer_id` removes only that provider's record for
302    /// that key. It MUST NOT touch any OTHER provider of the same key — a retract signed by one
303    /// holder can never evict another holder's record (censorship-resistance). A content key left
304    /// with no remaining providers is dropped so the store does not accumulate empty entries.
305    pub fn remove(&mut self, content_key: &str, provider_peer_id: &str) -> bool {
306        let Some(providers) = self.by_key.get_mut(content_key) else {
307            return false;
308        };
309        let removed = providers.remove(provider_peer_id).is_some();
310        if providers.is_empty() {
311            self.by_key.remove(content_key);
312        }
313        removed
314    }
315
316    /// Drop EVERY record for `content_key`, returning how many were removed.
317    ///
318    /// Unlike [`remove`](Self::remove) — the authenticated per-holder retract — this is a
319    /// whole-key wipe, so it MUST NOT be reachable from any wire path: a peer able to drive it
320    /// against the authoritative store would hold a censorship primitive over any key it names.
321    /// Its one caller is the node's own decision to forget a DISCOVERY-CACHE entry whose holders
322    /// all turned out to be undialable (`DhtService::forget_discovered`), where the records being
323    /// dropped are this node's own unverified hearsay and nobody else can see them.
324    pub fn remove_key(&mut self, content_key: &str) -> usize {
325        self.by_key
326            .remove(content_key)
327            .map(|providers| providers.len())
328            .unwrap_or(0)
329    }
330
331    /// The live (non-expired at `now`) provider records for `content_key`. Expired records are
332    /// skipped (and cleaned up by [`gc`](Self::gc)); returns an empty vec if none are known/live.
333    pub fn get(&self, content_key: &str, now: u64) -> Vec<ProviderRecord> {
334        self.by_key
335            .get(content_key)
336            .map(|providers| {
337                providers
338                    .values()
339                    .map(|e| &e.record)
340                    .filter(|r| !r.is_expired(now))
341                    .cloned()
342                    .collect()
343            })
344            .unwrap_or_default()
345    }
346
347    /// Drop every expired record (and any content key left with no live providers) as of `now`.
348    /// Returns the number of records removed. Call periodically from the maintenance loop.
349    pub fn gc(&mut self, now: u64) -> usize {
350        let mut removed = 0;
351        self.by_key.retain(|_key, providers| {
352            let before = providers.len();
353            providers.retain(|_pid, e| !e.record.is_expired(now));
354            removed += before - providers.len();
355            !providers.is_empty()
356        });
357        removed
358    }
359
360    /// Record that this node holds + announces `content_key` (so the maintenance loop republishes
361    /// it), with no collateral pointer. Idempotent.
362    pub fn mark_announced(&mut self, content_key: String) {
363        self.announced.insert(content_key, None);
364    }
365
366    /// As [`mark_announced`](Self::mark_announced), but remembering the publisher's own
367    /// mirror-coin pointer so every republish of this key re-attaches it.
368    ///
369    /// The pointer is stored verbatim as this node's own claim; it is untrusted only when it
370    /// arrives from a peer. Idempotent, and last write wins — re-announcing with a fresh coin id
371    /// after an epoch rollover is the intended way to update it.
372    pub fn mark_announced_with_collateral(
373        &mut self,
374        content_key: String,
375        unverified_mirror_coin_id: Option<String>,
376    ) {
377        self.announced
378            .insert(content_key, unverified_mirror_coin_id);
379    }
380
381    /// The collateral pointer remembered for `content_key`, or `None` if the key is not announced
382    /// or was announced without one.
383    pub fn announced_collateral(&self, content_key: &str) -> Option<&str> {
384        self.announced.get(content_key).and_then(Option::as_deref)
385    }
386
387    /// Stop announcing `content_key` (this node no longer holds the content). Returns whether it was
388    /// being announced.
389    pub fn unmark_announced(&mut self, content_key: &str) -> bool {
390        self.announced.remove(content_key).is_some()
391    }
392
393    /// The content keys this node announces (holds) — the republish work list.
394    pub fn local_announcements(&self) -> Vec<String> {
395        self.announced.keys().cloned().collect()
396    }
397
398    /// A bounded, AGGREGATED view of what this node holds in its DHT provider store — content keys
399    /// and how many live providers each has, with no provider identities (dig_ecosystem #1935).
400    ///
401    /// This is what lets the relay show the network's content layer without joining the DHT: a
402    /// Kademlia node stores records for keys near its OWN `peer_id`, so these are records about
403    /// MANY OTHER peers' content, not a self-report of what this node caches. The union across
404    /// several nodes is a broad slice of the real DHT.
405    ///
406    /// # Why counts and not identities
407    ///
408    /// A provider record IS a `(peer_id, content_key)` pair — exactly the linkage the relay's `/map`
409    /// refuses to publish (its tests assert no `peer_id` and no raw IP ever appear). Returning
410    /// counts keeps that contract intact rather than carving an exception into it. A caller that
411    /// genuinely needs identities can still use [`get`](Self::get) per key.
412    ///
413    /// Expired records are excluded as of `now`, so the counts match what [`get`](Self::get) would
414    /// return rather than including records the store has not GC'd yet.
415    ///
416    /// `max_keys` bounds the result: the store is attacker-influenced (any peer can announce), so an
417    /// unbounded snapshot would let a Sybil dictate the response size. When the cap truncates,
418    /// [`ProviderSnapshot::truncated`] is set and `total_keys` still reports the true total, so a
419    /// consumer can say "showing N of M" instead of silently presenting a partial view as complete.
420    /// `max_keys == 0` yields no entries but still reports `total_keys`.
421    pub fn snapshot(&self, now: u64, max_keys: usize) -> ProviderSnapshot {
422        let mut entries: Vec<ProviderSnapshotEntry> = self
423            .by_key
424            .iter()
425            .filter_map(|(content_key, providers)| {
426                let live = providers
427                    .values()
428                    .filter(|e| !e.record.is_expired(now))
429                    .count();
430                // A key whose every record has expired is not part of the view.
431                (live > 0).then(|| ProviderSnapshotEntry {
432                    content_key: content_key.clone(),
433                    providers: live,
434                })
435            })
436            .collect();
437
438        // Deterministic order so the same store yields the same snapshot, and so truncation takes a
439        // stable subset rather than an arbitrary one from HashMap iteration order.
440        entries.sort_by(|a, b| a.content_key.cmp(&b.content_key));
441
442        let total_keys = entries.len();
443        let truncated = total_keys > max_keys;
444        entries.truncate(max_keys);
445
446        ProviderSnapshot {
447            entries,
448            total_keys,
449            truncated,
450        }
451    }
452
453    /// Total live+stale records across all keys (diagnostics / tests).
454    pub fn len(&self) -> usize {
455        self.by_key.values().map(|p| p.len()).sum()
456    }
457
458    /// Whether the store holds no records.
459    pub fn is_empty(&self) -> bool {
460        self.len() == 0
461    }
462}
463
464#[cfg(test)]
465mod tests {
466    use super::*;
467    use crate::key::Key;
468    use crate::record::CandidateAddr;
469    use dig_nat::PeerId;
470
471    /// The instant the eviction tests reason at. Every `expires_at` they use is in the FUTURE
472    /// relative to this, so their records are LIVE and the assertions are about establishment —
473    /// not about a record that had silently already expired.
474    const NOW: u64 = 0;
475
476    fn rec(content: &Key, provider: u8, expires_at: u64) -> ProviderRecord {
477        ProviderRecord::new(
478            content,
479            &PeerId::from_bytes([provider; 32]),
480            vec![CandidateAddr::direct("h", 9444)],
481            expires_at,
482        )
483    }
484
485    // -- #1935: the aggregated snapshot the relay's /dht endpoint is built on -----------------
486
487    #[test]
488    fn snapshot_counts_live_providers_per_key_and_never_leaks_an_identity() {
489        // The privacy property is the point: a provider record IS (peer_id, content_key), which is
490        // exactly the linkage the relay's /map refuses to publish. The snapshot must carry counts.
491        let mut s = ProviderStore::new();
492        let k1 = Key::from_bytes([1u8; 32]);
493        let k2 = Key::from_bytes([2u8; 32]);
494        s.put(rec(&k1, 10, NOW + 100));
495        s.put(rec(&k1, 11, NOW + 100));
496        s.put(rec(&k2, 12, NOW + 100));
497
498        let snap = s.snapshot(NOW, 100);
499
500        assert_eq!(snap.total_keys, 2);
501        assert!(!snap.truncated);
502        let counts: Vec<usize> = snap.entries.iter().map(|e| e.providers).collect();
503        assert_eq!(counts, vec![2, 1], "two providers for k1, one for k2");
504
505        // Nothing in the snapshot may be a provider peer_id. Assert structurally rather than by
506        // string-matching, so the property cannot rot when a field is added.
507        let rendered = format!("{snap:?}");
508        for provider in [10u8, 11, 12] {
509            let pid = PeerId::from_bytes([provider; 32]).to_hex();
510            assert!(
511                !rendered.contains(&pid),
512                "provider identity {pid} must never appear in a snapshot"
513            );
514        }
515    }
516
517    #[test]
518    fn snapshot_excludes_expired_records_and_keys_left_with_none() {
519        // Must agree with `get`, which also filters on expiry — otherwise the relay would advertise
520        // providers the node would not actually return.
521        let mut s = ProviderStore::new();
522        let live = Key::from_bytes([1u8; 32]);
523        let dead = Key::from_bytes([2u8; 32]);
524        s.put(rec(&live, 10, NOW + 100));
525        s.put(rec(&dead, 11, NOW + 1));
526
527        let snap = s.snapshot(NOW + 50, 100);
528
529        assert_eq!(
530            snap.total_keys, 1,
531            "the fully-expired key drops out entirely"
532        );
533        assert_eq!(snap.entries[0].providers, 1);
534        assert_eq!(
535            snap.entries[0].content_key,
536            live.to_hex(),
537            "the surviving key is the live one"
538        );
539    }
540
541    #[test]
542    fn snapshot_is_bounded_and_reports_the_true_total_when_truncated() {
543        // The store is attacker-influenced — any peer can announce — so an unbounded snapshot would
544        // let a Sybil dictate the response size. Truncation must be VISIBLE, not silent.
545        let mut s = ProviderStore::new();
546        for i in 0..10u8 {
547            s.put(rec(&Key::from_bytes([i; 32]), 100 + i, NOW + 100));
548        }
549
550        let snap = s.snapshot(NOW, 3);
551
552        assert_eq!(snap.entries.len(), 3);
553        assert!(snap.truncated);
554        assert_eq!(snap.total_keys, 10, "the true total survives truncation");
555    }
556
557    #[test]
558    fn snapshot_is_deterministic_so_truncation_takes_a_stable_subset() {
559        // HashMap iteration order is arbitrary; without sorting, two calls could return different
560        // subsets and a consumer polling the relay would see content flicker in and out.
561        let mut s = ProviderStore::new();
562        for i in 0..8u8 {
563            s.put(rec(&Key::from_bytes([i; 32]), 100 + i, NOW + 100));
564        }
565        assert_eq!(s.snapshot(NOW, 4), s.snapshot(NOW, 4));
566    }
567
568    #[test]
569    fn a_zero_cap_yields_no_entries_but_still_reports_the_total() {
570        let mut s = ProviderStore::new();
571        s.put(rec(&Key::from_bytes([1u8; 32]), 10, NOW + 100));
572        let snap = s.snapshot(NOW, 0);
573        assert!(snap.entries.is_empty());
574        assert!(snap.truncated);
575        assert_eq!(snap.total_keys, 1);
576    }
577
578    #[test]
579    fn put_then_get_returns_live_record() {
580        let mut s = ProviderStore::new();
581        let key = Key::from_bytes([0xAA; 32]);
582        s.put(rec(&key, 1, 100));
583        let got = s.get(&key.to_hex(), 50);
584        assert_eq!(got.len(), 1);
585        assert_eq!(
586            got[0].provider_peer_id,
587            PeerId::from_bytes([1u8; 32]).to_hex()
588        );
589    }
590
591    #[test]
592    fn get_hides_expired_records() {
593        let mut s = ProviderStore::new();
594        let key = Key::from_bytes([0xAA; 32]);
595        s.put(rec(&key, 1, 100));
596        assert!(
597            s.get(&key.to_hex(), 100).is_empty(),
598            "expired at exactly TTL"
599        );
600        assert!(s.get(&key.to_hex(), 200).is_empty());
601    }
602
603    #[test]
604    fn same_provider_dedups_and_refreshes() {
605        let mut s = ProviderStore::new();
606        let key = Key::from_bytes([0xAA; 32]);
607        s.put(rec(&key, 1, 100));
608        s.put(rec(&key, 1, 500)); // same provider, later expiry
609        assert_eq!(s.len(), 1, "same provider must not duplicate");
610        // The refreshed expiry wins.
611        assert_eq!(s.get(&key.to_hex(), 300).len(), 1);
612    }
613
614    #[test]
615    fn distinct_providers_for_same_key_coexist() {
616        let mut s = ProviderStore::new();
617        let key = Key::from_bytes([0xAA; 32]);
618        s.put(rec(&key, 1, 100));
619        s.put(rec(&key, 2, 100));
620        assert_eq!(s.get(&key.to_hex(), 50).len(), 2);
621    }
622
623    // ---- Admission control (HIGH #1: unbounded provider store, SECURITY_AUDIT_P2P.md #179) ----
624
625    #[test]
626    fn put_returns_accepted_under_capacity() {
627        let mut s = ProviderStore::new();
628        let key = Key::from_bytes([0xAA; 32]);
629        assert_eq!(s.put(rec(&key, 1, 100)), PutOutcome::Accepted);
630    }
631
632    #[test]
633    fn refreshing_same_provider_always_succeeds_even_at_per_key_cap() {
634        // A refresh (same provider, same key) never counts as "new" so it must never be blocked by
635        // the per-key cap even when the key is already full.
636        let mut s = ProviderStore::with_limits(ProviderStoreLimits {
637            max_providers_per_key: 1,
638            max_total_records: 1000,
639        });
640        let key = Key::from_bytes([0xAA; 32]);
641        assert_eq!(s.put(rec(&key, 1, 100)), PutOutcome::Accepted);
642        assert_eq!(s.put(rec(&key, 1, 999)), PutOutcome::Accepted, "refresh");
643        assert_eq!(s.len(), 1);
644    }
645
646    #[test]
647    fn per_key_cap_evicts_soonest_to_expire_within_the_churn_zone() {
648        // One malicious/heavy peer announcing many DISTINCT providers for the SAME content key must
649        // not grow that key's provider set past `max_providers_per_key` — the audit's "no cap on
650        // providers-per-key" finding.
651        // Cap 4 → the two longest-established slots are reserved (#1434), so the eviction choice
652        // is made among the two newest — the churn zone. Within that zone the soonest-to-expire
653        // record is still the least valuable one to keep.
654        let mut s = ProviderStore::with_limits(ProviderStoreLimits {
655            max_providers_per_key: 4,
656            max_total_records: 1000,
657        });
658        let key = Key::from_bytes([0xAA; 32]);
659        assert_eq!(s.put_at(rec(&key, 1, 100), NOW), PutOutcome::Accepted); // established
660        assert_eq!(s.put_at(rec(&key, 2, 200), NOW), PutOutcome::Accepted); // established
661        assert_eq!(s.put_at(rec(&key, 3, 900), NOW), PutOutcome::Accepted); // churn zone
662        assert_eq!(s.put_at(rec(&key, 4, 800), NOW), PutOutcome::Accepted); // churn zone, expires sooner
663        assert_eq!(s.put_at(rec(&key, 5, 999), NOW), PutOutcome::Accepted);
664        assert_eq!(
665            s.get(&key.to_hex(), 0).len(),
666            4,
667            "per-key cap must not be exceeded"
668        );
669        assert!(
670            !live_provider_ids(&s, &key).contains(&PeerId::from_bytes([4u8; 32]).to_hex()),
671            "the soonest-to-expire record in the churn zone must be the one evicted"
672        );
673    }
674
675    /// The live provider peer_ids for `key` (order-independent membership assertions).
676    fn live_provider_ids(s: &ProviderStore, key: &Key) -> std::collections::HashSet<String> {
677        s.get(&key.to_hex(), 0)
678            .into_iter()
679            .map(|r| r.provider_peer_id)
680            .collect()
681    }
682
683    // ---- Sybil-resistant eviction (#1434) ----
684
685    #[test]
686    fn sustained_sybil_flood_cannot_evict_the_lone_established_holder() {
687        // #1434: every record clamps its expiry to `now + provider_ttl` at put time, so an attacker
688        // who announces LATER always holds a strictly-later `expires_at` than an honest incumbent.
689        // Under pure soonest-to-expire eviction that made the honest holder the deterministic
690        // victim, and 20 Sybil identities could make the only real holder of a capsule
691        // undiscoverable at this node — content-discovery censorship. Stated over the CLASS: no
692        // volume of later-expiring newcomers may evict a provider inside the established floor.
693        let mut s = ProviderStore::with_limits(ProviderStoreLimits {
694            max_providers_per_key: 20,
695            max_total_records: 100_000,
696        });
697        let key = Key::from_bytes([0xAA; 32]);
698        let honest = PeerId::from_bytes([1u8; 32]).to_hex();
699        assert_eq!(s.put_at(rec(&key, 1, 100), NOW), PutOutcome::Accepted);
700
701        // A sustained flood of distinct Sybil providers, each expiring strictly later than the last
702        // — the worst case for expiry-ordered eviction.
703        for i in 0..500u64 {
704            let sybil = ProviderRecord::new(
705                &key,
706                &PeerId::from_bytes(sybil_id(i)),
707                vec![CandidateAddr::direct("h", 9444)],
708                1_000 + i,
709            );
710            s.put_at(sybil, NOW);
711        }
712
713        assert!(
714            live_provider_ids(&s, &key).contains(&honest),
715            "the lone honest holder must survive a sustained Sybil flood"
716        );
717        assert_eq!(
718            s.get(&key.to_hex(), 0).len(),
719            20,
720            "the per-key cap still bounds the set"
721        );
722    }
723
724    #[test]
725    fn established_floor_protects_the_earliest_admitted_providers() {
726        // The one-off variant: exactly one provider beyond the cap. Eviction must fall inside the
727        // churn zone and never touch the reserved, longest-established slots.
728        let mut s = ProviderStore::with_limits(ProviderStoreLimits {
729            max_providers_per_key: 4,
730            max_total_records: 1000,
731        });
732        let key = Key::from_bytes([0xAA; 32]);
733        // Established slots deliberately hold the SOONEST expiries — under the old policy they
734        // would have been evicted first.
735        s.put_at(rec(&key, 1, 10), NOW);
736        s.put_at(rec(&key, 2, 20), NOW);
737        s.put_at(rec(&key, 3, 900), NOW);
738        s.put_at(rec(&key, 4, 800), NOW);
739        s.put_at(rec(&key, 5, 999), NOW);
740
741        let live = live_provider_ids(&s, &key);
742        assert!(
743            live.contains(&PeerId::from_bytes([1u8; 32]).to_hex()),
744            "the first-admitted provider is inside the established floor"
745        );
746        assert!(
747            live.contains(&PeerId::from_bytes([2u8; 32]).to_hex()),
748            "the second-admitted provider is inside the established floor"
749        );
750    }
751
752    #[test]
753    fn republish_does_not_reset_a_holders_establishment() {
754        // A holder stays findable by republishing before its TTL elapses. If a refresh reset the
755        // record's establishment, republishing — the very act that keeps an honest holder alive —
756        // would drop it into the churn zone and hand the attacker the eviction it wanted.
757        let mut s = ProviderStore::with_limits(ProviderStoreLimits {
758            max_providers_per_key: 4,
759            max_total_records: 1000,
760        });
761        let key = Key::from_bytes([0xAA; 32]);
762        let honest = PeerId::from_bytes([1u8; 32]).to_hex();
763        s.put_at(rec(&key, 1, 100), NOW);
764        for i in 0..3u64 {
765            s.put_at(rec(&key, 10 + i as u8, 500 + i), NOW);
766        }
767        s.put_at(rec(&key, 1, 5_000), NOW); // the honest holder republishes
768        for i in 0..50u64 {
769            s.put_at(
770                ProviderRecord::new(
771                    &key,
772                    &PeerId::from_bytes(sybil_id(i)),
773                    vec![CandidateAddr::direct("h", 9444)],
774                    9_000 + i,
775                ),
776                NOW,
777            );
778        }
779        assert!(
780            live_provider_ids(&s, &key).contains(&honest),
781            "a republished record keeps its establishment"
782        );
783    }
784
785    // ---- Liveness outranks establishment (#1434 follow-up) ----
786
787    #[test]
788    fn an_expired_record_in_the_floor_is_evicted_before_a_live_one() {
789        // The pre-#1434 policy evicted the soonest-to-expire record, so an EXPIRED record was always
790        // the first victim. The establishment floor must not invert that: a dead record inside the
791        // reserved floor cannot outrank a live provider in the churn zone. Without a liveness check
792        // this needs NO attacker — a node's GC tick is coarser than the provider TTL, so whenever the
793        // earliest-admitted half of a key goes offline, every new announcement for that key evicts a
794        // LIVE holder and announcing more holders makes the capsule LESS discoverable.
795        let mut s = ProviderStore::with_limits(ProviderStoreLimits {
796            max_providers_per_key: 4,
797            max_total_records: 1000,
798        });
799        let key = Key::from_bytes([0xAA; 32]);
800        let now = 10_000;
801        // The reserved floor (seq 0, 1) is long expired...
802        s.put_at(rec(&key, 1, 100), now);
803        s.put_at(rec(&key, 2, 200), now);
804        // ...while the churn zone (seq 2, 3) holds two LIVE honest providers.
805        s.put_at(rec(&key, 3, now + 5_000), now);
806        s.put_at(rec(&key, 4, now + 6_000), now);
807
808        s.put_at(rec(&key, 5, now + 7_000), now);
809
810        let live = live_provider_ids_at(&s, &key, now);
811        assert!(
812            live.contains(&PeerId::from_bytes([3u8; 32]).to_hex())
813                && live.contains(&PeerId::from_bytes([4u8; 32]).to_hex()),
814            "both LIVE providers must survive; an expired record in the floor is the victim"
815        );
816    }
817
818    #[test]
819    fn one_expired_record_anywhere_is_the_victim_before_any_live_record() {
820        // The one-off variant: exactly ONE expired record, sitting inside the reserved floor, with
821        // every other slot live. It must still be the one evicted.
822        let mut s = ProviderStore::with_limits(ProviderStoreLimits {
823            max_providers_per_key: 4,
824            max_total_records: 1000,
825        });
826        let key = Key::from_bytes([0xAA; 32]);
827        let now = 10_000;
828        s.put_at(rec(&key, 1, 100), now); // expired, seq 0 → inside the floor
829        s.put_at(rec(&key, 2, now + 1_000), now);
830        s.put_at(rec(&key, 3, now + 2_000), now);
831        s.put_at(rec(&key, 4, now + 3_000), now);
832
833        s.put_at(rec(&key, 5, now + 4_000), now);
834
835        assert_eq!(
836            live_provider_ids_at(&s, &key, now).len(),
837            4,
838            "reclaiming the dead slot leaves every live provider intact"
839        );
840    }
841
842    #[test]
843    fn the_floor_still_protects_an_established_holder_when_every_record_is_live() {
844        // Liveness must take precedence WITHOUT weakening #1434: with no dead slot to reclaim, the
845        // establishment floor governs again and a sustained flood cannot displace the incumbent.
846        let mut s = ProviderStore::with_limits(ProviderStoreLimits {
847            max_providers_per_key: 20,
848            max_total_records: 100_000,
849        });
850        let key = Key::from_bytes([0xAA; 32]);
851        let now = 10_000;
852        let honest = PeerId::from_bytes([1u8; 32]).to_hex();
853        s.put_at(rec(&key, 1, now + 1_000), now);
854        for i in 0..500u64 {
855            s.put_at(
856                ProviderRecord::new(
857                    &key,
858                    &PeerId::from_bytes(sybil_id(i)),
859                    vec![CandidateAddr::direct("h", 9444)],
860                    now + 2_000 + i,
861                ),
862                now,
863            );
864        }
865        assert!(
866            live_provider_ids_at(&s, &key, now).contains(&honest),
867            "an all-live key keeps the #1434 protection"
868        );
869    }
870
871    #[test]
872    fn put_delegates_to_put_at_with_the_wall_clock() {
873        // `put` is the compatibility wrapper (its signature is public API): same admission decision,
874        // with `now` read from the system clock.
875        let mut wall = ProviderStore::new();
876        let key = Key::from_bytes([0xAA; 32]);
877        assert_eq!(wall.put(rec(&key, 1, u64::MAX)), PutOutcome::Accepted);
878        assert_eq!(wall.len(), 1);
879    }
880
881    /// The live provider peer_ids for `key` as of `now`.
882    fn live_provider_ids_at(
883        s: &ProviderStore,
884        key: &Key,
885        now: u64,
886    ) -> std::collections::HashSet<String> {
887        s.get(&key.to_hex(), now)
888            .into_iter()
889            .map(|r| r.provider_peer_id)
890            .collect()
891    }
892
893    /// A distinct Sybil peer_id per index (varying the high bytes so ids stay distinct past 255).
894    fn sybil_id(i: u64) -> [u8; 32] {
895        let mut b = [0xEE; 32];
896        b[0..8].copy_from_slice(&i.to_be_bytes());
897        b
898    }
899
900    #[test]
901    fn global_cap_rejects_new_content_keys_over_ceiling() {
902        // Many DISTINCT content keys (not just many providers per key) must also be bounded — the
903        // audit's "no cap on distinct content keys ... no global record ceiling" finding.
904        let mut s = ProviderStore::with_limits(ProviderStoreLimits {
905            max_providers_per_key: 20,
906            max_total_records: 2,
907        });
908        let k1 = Key::from_bytes([0x01; 32]);
909        let k2 = Key::from_bytes([0x02; 32]);
910        let k3 = Key::from_bytes([0x03; 32]);
911        assert_eq!(s.put(rec(&k1, 1, 100)), PutOutcome::Accepted);
912        assert_eq!(s.put(rec(&k2, 1, 100)), PutOutcome::Accepted);
913        assert_eq!(
914            s.put(rec(&k3, 1, 100)),
915            PutOutcome::RejectedOverCapacity,
916            "third distinct record must be rejected once the global ceiling is hit"
917        );
918        assert_eq!(s.len(), 2, "rejected record must not be stored");
919        assert!(
920            s.get(&k3.to_hex(), 0).is_empty(),
921            "rejected key must not appear in the store at all"
922        );
923    }
924
925    #[test]
926    fn global_cap_does_not_evict_a_different_key_to_make_room() {
927        // A single attacker flooding new keys must not be able to evict a DIFFERENT (legitimate)
928        // key's providers just by hitting the global ceiling.
929        let mut s = ProviderStore::with_limits(ProviderStoreLimits {
930            max_providers_per_key: 20,
931            max_total_records: 1,
932        });
933        let legit = Key::from_bytes([0xAA; 32]);
934        s.put(rec(&legit, 1, 100));
935        let attacker_key = Key::from_bytes([0xBB; 32]);
936        assert_eq!(
937            s.put(rec(&attacker_key, 2, 100)),
938            PutOutcome::RejectedOverCapacity
939        );
940        assert_eq!(
941            s.get(&legit.to_hex(), 0).len(),
942            1,
943            "the legitimate key's record must survive"
944        );
945    }
946
947    #[test]
948    fn remove_deletes_only_the_named_provider_record() {
949        // Authenticated retract (SPEC §6.6): removing (key, provider-1) must leave provider-2 of the
950        // SAME key untouched — a retract signed by one holder cannot censor another holder.
951        let mut s = ProviderStore::new();
952        let key = Key::from_bytes([0xAA; 32]);
953        s.put(rec(&key, 1, 100));
954        s.put(rec(&key, 2, 100));
955        let pid1 = PeerId::from_bytes([1u8; 32]).to_hex();
956        let pid2 = PeerId::from_bytes([2u8; 32]).to_hex();
957        assert!(
958            s.remove(&key.to_hex(), &pid1),
959            "the named record was removed"
960        );
961        let survivors: std::collections::HashSet<String> = s
962            .get(&key.to_hex(), 0)
963            .into_iter()
964            .map(|r| r.provider_peer_id)
965            .collect();
966        assert_eq!(survivors.len(), 1, "the other provider must survive");
967        assert!(survivors.contains(&pid2));
968        assert!(!survivors.contains(&pid1));
969    }
970
971    #[test]
972    fn remove_of_absent_record_returns_false() {
973        let mut s = ProviderStore::new();
974        let key = Key::from_bytes([0xAA; 32]);
975        s.put(rec(&key, 1, 100));
976        let absent = PeerId::from_bytes([9u8; 32]).to_hex();
977        assert!(!s.remove(&key.to_hex(), &absent), "no such provider");
978        assert!(!s.remove(&"00".repeat(32), &absent), "no such content key");
979        assert_eq!(s.len(), 1, "nothing removed");
980    }
981
982    #[test]
983    fn remove_drops_content_key_when_last_provider_leaves() {
984        let mut s = ProviderStore::new();
985        let key = Key::from_bytes([0xAA; 32]);
986        s.put(rec(&key, 1, 100));
987        let pid1 = PeerId::from_bytes([1u8; 32]).to_hex();
988        assert!(s.remove(&key.to_hex(), &pid1));
989        assert!(
990            s.is_empty(),
991            "the now-empty content key must be dropped entirely"
992        );
993    }
994
995    #[test]
996    fn gc_removes_expired_and_empty_keys() {
997        let mut s = ProviderStore::new();
998        let k1 = Key::from_bytes([0x01; 32]);
999        let k2 = Key::from_bytes([0x02; 32]);
1000        s.put(rec(&k1, 1, 100)); // expires at 100
1001        s.put(rec(&k2, 1, 500)); // expires at 500
1002        let removed = s.gc(200);
1003        assert_eq!(removed, 1);
1004        assert!(s.get(&k1.to_hex(), 200).is_empty());
1005        assert_eq!(s.get(&k2.to_hex(), 200).len(), 1);
1006    }
1007
1008    #[test]
1009    fn announcements_track_and_untrack() {
1010        let mut s = ProviderStore::new();
1011        let key = Key::from_bytes([0x07; 32]).to_hex();
1012        s.mark_announced(key.clone());
1013        s.mark_announced(key.clone()); // idempotent
1014        assert_eq!(s.local_announcements(), vec![key.clone()]);
1015        assert!(s.unmark_announced(&key));
1016        assert!(!s.unmark_announced(&key));
1017        assert!(s.local_announcements().is_empty());
1018    }
1019}