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, the soonest-to-expire existing record for that key is evicted to make
38    /// room (a fresher/longer-lived record is preferred over a stale one).
39    pub max_providers_per_key: usize,
40    /// Maximum total records across **all** content keys. When a `put` for a genuinely new
41    /// (content_key, provider) pair would exceed this, the request is rejected outright (no
42    /// eviction across keys — that would let one attacker evict another key's legitimate holders).
43    pub max_total_records: usize,
44}
45
46impl Default for ProviderStoreLimits {
47    /// Conservative defaults: `k` (20, the Kademlia replication parameter) providers per key is
48    /// already generous replication, and a global ceiling that comfortably covers a node
49    /// participating in many lookups while still bounding worst-case memory from a single
50    /// misbehaving peer.
51    fn default() -> Self {
52        ProviderStoreLimits {
53            max_providers_per_key: 20,
54            max_total_records: 100_000,
55        }
56    }
57}
58
59/// The outcome of a [`ProviderStore::put`] — whether the record was admitted.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum PutOutcome {
62    /// The record was stored (fresh insert or refresh of an existing provider's record).
63    Accepted,
64    /// The record was rejected: the store is at capacity and the record did not qualify for
65    /// eviction-based admission (a new provider would exceed
66    /// [`ProviderStoreLimits::max_total_records`], or the per-key cap is full of records that all
67    /// expire no sooner than the incoming one).
68    RejectedOverCapacity,
69}
70
71/// A node's local provider records + the set of content keys it announces itself.
72#[derive(Debug)]
73pub struct ProviderStore {
74    /// content_key (64-hex) → provider_peer_id (64-hex) → record.
75    by_key: HashMap<String, HashMap<String, ProviderRecord>>,
76    /// content keys (64-hex) this node holds + announces (for republish).
77    announced: HashSet<String>,
78    /// Admission-control bounds enforced by [`put`](Self::put).
79    limits: ProviderStoreLimits,
80}
81
82impl Default for ProviderStore {
83    fn default() -> Self {
84        ProviderStore::new()
85    }
86}
87
88impl ProviderStore {
89    /// A new empty store with the default [`ProviderStoreLimits`].
90    pub fn new() -> Self {
91        ProviderStore::with_limits(ProviderStoreLimits::default())
92    }
93
94    /// A new empty store enforcing `limits` on every [`put`](Self::put).
95    pub fn with_limits(limits: ProviderStoreLimits) -> Self {
96        ProviderStore {
97            by_key: HashMap::new(),
98            announced: HashSet::new(),
99            limits,
100        }
101    }
102
103    /// Store (or refresh) a provider record, subject to [`ProviderStoreLimits`].
104    ///
105    /// Keyed by (content_key, provider_peer_id): a second record from the same provider for the
106    /// same key REPLACES the first (refreshes expiry + addresses) rather than duplicating — this
107    /// always succeeds regardless of capacity, since it does not grow the store.
108    ///
109    /// A genuinely new (content_key, provider) pair is admission-controlled:
110    /// - if the key already holds [`ProviderStoreLimits::max_providers_per_key`] *other* providers,
111    ///   the soonest-to-expire one is evicted to make room (soonest-to-expire is the least valuable
112    ///   record to keep);
113    /// - if the store is at [`ProviderStoreLimits::max_total_records`] globally, the new record is
114    ///   rejected — [`PutOutcome::RejectedOverCapacity`] — rather than evicting another key's
115    ///   records (which would let one attacker's flood evict another key's legitimate holders).
116    pub fn put(&mut self, record: ProviderRecord) -> PutOutcome {
117        let is_new_provider = !self
118            .by_key
119            .get(&record.content_key)
120            .is_some_and(|providers| providers.contains_key(&record.provider_peer_id));
121
122        if is_new_provider {
123            // Global ceiling check FIRST, before touching this key's entry, so a rejected record
124            // never leaves a stray empty entry behind and so the check reads the true pre-insert
125            // total (not skewed by an entry we are about to create).
126            if self.len() >= self.limits.max_total_records {
127                return PutOutcome::RejectedOverCapacity;
128            }
129            if let Some(providers) = self.by_key.get_mut(&record.content_key) {
130                if providers.len() >= self.limits.max_providers_per_key {
131                    // Evict the soonest-to-expire record in this key's set to make room.
132                    if let Some(evict_id) = providers
133                        .iter()
134                        .min_by_key(|(_, r)| r.expires_at)
135                        .map(|(pid, _)| pid.clone())
136                    {
137                        providers.remove(&evict_id);
138                    }
139                }
140            }
141        }
142
143        self.by_key
144            .entry(record.content_key.clone())
145            .or_default()
146            .insert(record.provider_peer_id.clone(), record);
147        PutOutcome::Accepted
148    }
149
150    /// Remove exactly the record for `(content_key, provider_peer_id)`, if present. Returns whether
151    /// a record was removed.
152    ///
153    /// This is the store half of an **authenticated retract** (SPEC §6.6): a caller that has
154    /// verified a signed retract from `provider_peer_id` removes only that provider's record for
155    /// that key. It MUST NOT touch any OTHER provider of the same key — a retract signed by one
156    /// holder can never evict another holder's record (censorship-resistance). A content key left
157    /// with no remaining providers is dropped so the store does not accumulate empty entries.
158    pub fn remove(&mut self, content_key: &str, provider_peer_id: &str) -> bool {
159        let Some(providers) = self.by_key.get_mut(content_key) else {
160            return false;
161        };
162        let removed = providers.remove(provider_peer_id).is_some();
163        if providers.is_empty() {
164            self.by_key.remove(content_key);
165        }
166        removed
167    }
168
169    /// The live (non-expired at `now`) provider records for `content_key`. Expired records are
170    /// skipped (and cleaned up by [`gc`](Self::gc)); returns an empty vec if none are known/live.
171    pub fn get(&self, content_key: &str, now: u64) -> Vec<ProviderRecord> {
172        self.by_key
173            .get(content_key)
174            .map(|providers| {
175                providers
176                    .values()
177                    .filter(|r| !r.is_expired(now))
178                    .cloned()
179                    .collect()
180            })
181            .unwrap_or_default()
182    }
183
184    /// Drop every expired record (and any content key left with no live providers) as of `now`.
185    /// Returns the number of records removed. Call periodically from the maintenance loop.
186    pub fn gc(&mut self, now: u64) -> usize {
187        let mut removed = 0;
188        self.by_key.retain(|_key, providers| {
189            let before = providers.len();
190            providers.retain(|_pid, r| !r.is_expired(now));
191            removed += before - providers.len();
192            !providers.is_empty()
193        });
194        removed
195    }
196
197    /// Record that this node holds + announces `content_key` (so the maintenance loop republishes
198    /// it). Idempotent.
199    pub fn mark_announced(&mut self, content_key: String) {
200        self.announced.insert(content_key);
201    }
202
203    /// Stop announcing `content_key` (this node no longer holds the content). Returns whether it was
204    /// being announced.
205    pub fn unmark_announced(&mut self, content_key: &str) -> bool {
206        self.announced.remove(content_key)
207    }
208
209    /// The content keys this node announces (holds) — the republish work list.
210    pub fn local_announcements(&self) -> Vec<String> {
211        self.announced.iter().cloned().collect()
212    }
213
214    /// Total live+stale records across all keys (diagnostics / tests).
215    pub fn len(&self) -> usize {
216        self.by_key.values().map(|p| p.len()).sum()
217    }
218
219    /// Whether the store holds no records.
220    pub fn is_empty(&self) -> bool {
221        self.len() == 0
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228    use crate::key::Key;
229    use crate::record::CandidateAddr;
230    use dig_nat::PeerId;
231
232    fn rec(content: &Key, provider: u8, expires_at: u64) -> ProviderRecord {
233        ProviderRecord::new(
234            content,
235            &PeerId::from_bytes([provider; 32]),
236            vec![CandidateAddr::direct("h", 9444)],
237            expires_at,
238        )
239    }
240
241    #[test]
242    fn put_then_get_returns_live_record() {
243        let mut s = ProviderStore::new();
244        let key = Key::from_bytes([0xAA; 32]);
245        s.put(rec(&key, 1, 100));
246        let got = s.get(&key.to_hex(), 50);
247        assert_eq!(got.len(), 1);
248        assert_eq!(
249            got[0].provider_peer_id,
250            PeerId::from_bytes([1u8; 32]).to_hex()
251        );
252    }
253
254    #[test]
255    fn get_hides_expired_records() {
256        let mut s = ProviderStore::new();
257        let key = Key::from_bytes([0xAA; 32]);
258        s.put(rec(&key, 1, 100));
259        assert!(
260            s.get(&key.to_hex(), 100).is_empty(),
261            "expired at exactly TTL"
262        );
263        assert!(s.get(&key.to_hex(), 200).is_empty());
264    }
265
266    #[test]
267    fn same_provider_dedups_and_refreshes() {
268        let mut s = ProviderStore::new();
269        let key = Key::from_bytes([0xAA; 32]);
270        s.put(rec(&key, 1, 100));
271        s.put(rec(&key, 1, 500)); // same provider, later expiry
272        assert_eq!(s.len(), 1, "same provider must not duplicate");
273        // The refreshed expiry wins.
274        assert_eq!(s.get(&key.to_hex(), 300).len(), 1);
275    }
276
277    #[test]
278    fn distinct_providers_for_same_key_coexist() {
279        let mut s = ProviderStore::new();
280        let key = Key::from_bytes([0xAA; 32]);
281        s.put(rec(&key, 1, 100));
282        s.put(rec(&key, 2, 100));
283        assert_eq!(s.get(&key.to_hex(), 50).len(), 2);
284    }
285
286    // ---- Admission control (HIGH #1: unbounded provider store, SECURITY_AUDIT_P2P.md #179) ----
287
288    #[test]
289    fn put_returns_accepted_under_capacity() {
290        let mut s = ProviderStore::new();
291        let key = Key::from_bytes([0xAA; 32]);
292        assert_eq!(s.put(rec(&key, 1, 100)), PutOutcome::Accepted);
293    }
294
295    #[test]
296    fn refreshing_same_provider_always_succeeds_even_at_per_key_cap() {
297        // A refresh (same provider, same key) never counts as "new" so it must never be blocked by
298        // the per-key cap even when the key is already full.
299        let mut s = ProviderStore::with_limits(ProviderStoreLimits {
300            max_providers_per_key: 1,
301            max_total_records: 1000,
302        });
303        let key = Key::from_bytes([0xAA; 32]);
304        assert_eq!(s.put(rec(&key, 1, 100)), PutOutcome::Accepted);
305        assert_eq!(s.put(rec(&key, 1, 999)), PutOutcome::Accepted, "refresh");
306        assert_eq!(s.len(), 1);
307    }
308
309    #[test]
310    fn per_key_cap_evicts_soonest_to_expire_to_make_room() {
311        // One malicious/heavy peer announcing many DISTINCT providers for the SAME content key must
312        // not grow that key's provider set past `max_providers_per_key` — the audit's "no cap on
313        // providers-per-key" finding.
314        let mut s = ProviderStore::with_limits(ProviderStoreLimits {
315            max_providers_per_key: 2,
316            max_total_records: 1000,
317        });
318        let key = Key::from_bytes([0xAA; 32]);
319        assert_eq!(s.put(rec(&key, 1, 100)), PutOutcome::Accepted); // expires soonest
320        assert_eq!(s.put(rec(&key, 2, 500)), PutOutcome::Accepted);
321        // A third distinct provider must evict the soonest-to-expire (provider 1), not grow past 2.
322        assert_eq!(s.put(rec(&key, 3, 900)), PutOutcome::Accepted);
323        assert_eq!(
324            s.get(&key.to_hex(), 0).len(),
325            2,
326            "per-key cap must not be exceeded"
327        );
328        let ids: std::collections::HashSet<String> = s
329            .get(&key.to_hex(), 0)
330            .into_iter()
331            .map(|r| r.provider_peer_id)
332            .collect();
333        assert!(
334            !ids.contains(&PeerId::from_bytes([1u8; 32]).to_hex()),
335            "soonest-to-expire provider must be the one evicted"
336        );
337    }
338
339    #[test]
340    fn global_cap_rejects_new_content_keys_over_ceiling() {
341        // Many DISTINCT content keys (not just many providers per key) must also be bounded — the
342        // audit's "no cap on distinct content keys ... no global record ceiling" finding.
343        let mut s = ProviderStore::with_limits(ProviderStoreLimits {
344            max_providers_per_key: 20,
345            max_total_records: 2,
346        });
347        let k1 = Key::from_bytes([0x01; 32]);
348        let k2 = Key::from_bytes([0x02; 32]);
349        let k3 = Key::from_bytes([0x03; 32]);
350        assert_eq!(s.put(rec(&k1, 1, 100)), PutOutcome::Accepted);
351        assert_eq!(s.put(rec(&k2, 1, 100)), PutOutcome::Accepted);
352        assert_eq!(
353            s.put(rec(&k3, 1, 100)),
354            PutOutcome::RejectedOverCapacity,
355            "third distinct record must be rejected once the global ceiling is hit"
356        );
357        assert_eq!(s.len(), 2, "rejected record must not be stored");
358        assert!(
359            s.get(&k3.to_hex(), 0).is_empty(),
360            "rejected key must not appear in the store at all"
361        );
362    }
363
364    #[test]
365    fn global_cap_does_not_evict_a_different_key_to_make_room() {
366        // A single attacker flooding new keys must not be able to evict a DIFFERENT (legitimate)
367        // key's providers just by hitting the global ceiling.
368        let mut s = ProviderStore::with_limits(ProviderStoreLimits {
369            max_providers_per_key: 20,
370            max_total_records: 1,
371        });
372        let legit = Key::from_bytes([0xAA; 32]);
373        s.put(rec(&legit, 1, 100));
374        let attacker_key = Key::from_bytes([0xBB; 32]);
375        assert_eq!(
376            s.put(rec(&attacker_key, 2, 100)),
377            PutOutcome::RejectedOverCapacity
378        );
379        assert_eq!(
380            s.get(&legit.to_hex(), 0).len(),
381            1,
382            "the legitimate key's record must survive"
383        );
384    }
385
386    #[test]
387    fn remove_deletes_only_the_named_provider_record() {
388        // Authenticated retract (SPEC §6.6): removing (key, provider-1) must leave provider-2 of the
389        // SAME key untouched — a retract signed by one holder cannot censor another holder.
390        let mut s = ProviderStore::new();
391        let key = Key::from_bytes([0xAA; 32]);
392        s.put(rec(&key, 1, 100));
393        s.put(rec(&key, 2, 100));
394        let pid1 = PeerId::from_bytes([1u8; 32]).to_hex();
395        let pid2 = PeerId::from_bytes([2u8; 32]).to_hex();
396        assert!(
397            s.remove(&key.to_hex(), &pid1),
398            "the named record was removed"
399        );
400        let survivors: std::collections::HashSet<String> = s
401            .get(&key.to_hex(), 0)
402            .into_iter()
403            .map(|r| r.provider_peer_id)
404            .collect();
405        assert_eq!(survivors.len(), 1, "the other provider must survive");
406        assert!(survivors.contains(&pid2));
407        assert!(!survivors.contains(&pid1));
408    }
409
410    #[test]
411    fn remove_of_absent_record_returns_false() {
412        let mut s = ProviderStore::new();
413        let key = Key::from_bytes([0xAA; 32]);
414        s.put(rec(&key, 1, 100));
415        let absent = PeerId::from_bytes([9u8; 32]).to_hex();
416        assert!(!s.remove(&key.to_hex(), &absent), "no such provider");
417        assert!(!s.remove(&"00".repeat(32), &absent), "no such content key");
418        assert_eq!(s.len(), 1, "nothing removed");
419    }
420
421    #[test]
422    fn remove_drops_content_key_when_last_provider_leaves() {
423        let mut s = ProviderStore::new();
424        let key = Key::from_bytes([0xAA; 32]);
425        s.put(rec(&key, 1, 100));
426        let pid1 = PeerId::from_bytes([1u8; 32]).to_hex();
427        assert!(s.remove(&key.to_hex(), &pid1));
428        assert!(
429            s.is_empty(),
430            "the now-empty content key must be dropped entirely"
431        );
432    }
433
434    #[test]
435    fn gc_removes_expired_and_empty_keys() {
436        let mut s = ProviderStore::new();
437        let k1 = Key::from_bytes([0x01; 32]);
438        let k2 = Key::from_bytes([0x02; 32]);
439        s.put(rec(&k1, 1, 100)); // expires at 100
440        s.put(rec(&k2, 1, 500)); // expires at 500
441        let removed = s.gc(200);
442        assert_eq!(removed, 1);
443        assert!(s.get(&k1.to_hex(), 200).is_empty());
444        assert_eq!(s.get(&k2.to_hex(), 200).len(), 1);
445    }
446
447    #[test]
448    fn announcements_track_and_untrack() {
449        let mut s = ProviderStore::new();
450        let key = Key::from_bytes([0x07; 32]).to_hex();
451        s.mark_announced(key.clone());
452        s.mark_announced(key.clone()); // idempotent
453        assert_eq!(s.local_announcements(), vec![key.clone()]);
454        assert!(s.unmark_announced(&key));
455        assert!(!s.unmark_announced(&key));
456        assert!(s.local_announcements().is_empty());
457    }
458}