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    /// The live (non-expired at `now`) provider records for `content_key`. Expired records are
151    /// skipped (and cleaned up by [`gc`](Self::gc)); returns an empty vec if none are known/live.
152    pub fn get(&self, content_key: &str, now: u64) -> Vec<ProviderRecord> {
153        self.by_key
154            .get(content_key)
155            .map(|providers| {
156                providers
157                    .values()
158                    .filter(|r| !r.is_expired(now))
159                    .cloned()
160                    .collect()
161            })
162            .unwrap_or_default()
163    }
164
165    /// Drop every expired record (and any content key left with no live providers) as of `now`.
166    /// Returns the number of records removed. Call periodically from the maintenance loop.
167    pub fn gc(&mut self, now: u64) -> usize {
168        let mut removed = 0;
169        self.by_key.retain(|_key, providers| {
170            let before = providers.len();
171            providers.retain(|_pid, r| !r.is_expired(now));
172            removed += before - providers.len();
173            !providers.is_empty()
174        });
175        removed
176    }
177
178    /// Record that this node holds + announces `content_key` (so the maintenance loop republishes
179    /// it). Idempotent.
180    pub fn mark_announced(&mut self, content_key: String) {
181        self.announced.insert(content_key);
182    }
183
184    /// Stop announcing `content_key` (this node no longer holds the content). Returns whether it was
185    /// being announced.
186    pub fn unmark_announced(&mut self, content_key: &str) -> bool {
187        self.announced.remove(content_key)
188    }
189
190    /// The content keys this node announces (holds) — the republish work list.
191    pub fn local_announcements(&self) -> Vec<String> {
192        self.announced.iter().cloned().collect()
193    }
194
195    /// Total live+stale records across all keys (diagnostics / tests).
196    pub fn len(&self) -> usize {
197        self.by_key.values().map(|p| p.len()).sum()
198    }
199
200    /// Whether the store holds no records.
201    pub fn is_empty(&self) -> bool {
202        self.len() == 0
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209    use crate::key::Key;
210    use crate::record::CandidateAddr;
211    use dig_nat::PeerId;
212
213    fn rec(content: &Key, provider: u8, expires_at: u64) -> ProviderRecord {
214        ProviderRecord::new(
215            content,
216            &PeerId::from_bytes([provider; 32]),
217            vec![CandidateAddr::direct("h", 9444)],
218            expires_at,
219        )
220    }
221
222    #[test]
223    fn put_then_get_returns_live_record() {
224        let mut s = ProviderStore::new();
225        let key = Key::from_bytes([0xAA; 32]);
226        s.put(rec(&key, 1, 100));
227        let got = s.get(&key.to_hex(), 50);
228        assert_eq!(got.len(), 1);
229        assert_eq!(
230            got[0].provider_peer_id,
231            PeerId::from_bytes([1u8; 32]).to_hex()
232        );
233    }
234
235    #[test]
236    fn get_hides_expired_records() {
237        let mut s = ProviderStore::new();
238        let key = Key::from_bytes([0xAA; 32]);
239        s.put(rec(&key, 1, 100));
240        assert!(
241            s.get(&key.to_hex(), 100).is_empty(),
242            "expired at exactly TTL"
243        );
244        assert!(s.get(&key.to_hex(), 200).is_empty());
245    }
246
247    #[test]
248    fn same_provider_dedups_and_refreshes() {
249        let mut s = ProviderStore::new();
250        let key = Key::from_bytes([0xAA; 32]);
251        s.put(rec(&key, 1, 100));
252        s.put(rec(&key, 1, 500)); // same provider, later expiry
253        assert_eq!(s.len(), 1, "same provider must not duplicate");
254        // The refreshed expiry wins.
255        assert_eq!(s.get(&key.to_hex(), 300).len(), 1);
256    }
257
258    #[test]
259    fn distinct_providers_for_same_key_coexist() {
260        let mut s = ProviderStore::new();
261        let key = Key::from_bytes([0xAA; 32]);
262        s.put(rec(&key, 1, 100));
263        s.put(rec(&key, 2, 100));
264        assert_eq!(s.get(&key.to_hex(), 50).len(), 2);
265    }
266
267    // ---- Admission control (HIGH #1: unbounded provider store, SECURITY_AUDIT_P2P.md #179) ----
268
269    #[test]
270    fn put_returns_accepted_under_capacity() {
271        let mut s = ProviderStore::new();
272        let key = Key::from_bytes([0xAA; 32]);
273        assert_eq!(s.put(rec(&key, 1, 100)), PutOutcome::Accepted);
274    }
275
276    #[test]
277    fn refreshing_same_provider_always_succeeds_even_at_per_key_cap() {
278        // A refresh (same provider, same key) never counts as "new" so it must never be blocked by
279        // the per-key cap even when the key is already full.
280        let mut s = ProviderStore::with_limits(ProviderStoreLimits {
281            max_providers_per_key: 1,
282            max_total_records: 1000,
283        });
284        let key = Key::from_bytes([0xAA; 32]);
285        assert_eq!(s.put(rec(&key, 1, 100)), PutOutcome::Accepted);
286        assert_eq!(s.put(rec(&key, 1, 999)), PutOutcome::Accepted, "refresh");
287        assert_eq!(s.len(), 1);
288    }
289
290    #[test]
291    fn per_key_cap_evicts_soonest_to_expire_to_make_room() {
292        // One malicious/heavy peer announcing many DISTINCT providers for the SAME content key must
293        // not grow that key's provider set past `max_providers_per_key` — the audit's "no cap on
294        // providers-per-key" finding.
295        let mut s = ProviderStore::with_limits(ProviderStoreLimits {
296            max_providers_per_key: 2,
297            max_total_records: 1000,
298        });
299        let key = Key::from_bytes([0xAA; 32]);
300        assert_eq!(s.put(rec(&key, 1, 100)), PutOutcome::Accepted); // expires soonest
301        assert_eq!(s.put(rec(&key, 2, 500)), PutOutcome::Accepted);
302        // A third distinct provider must evict the soonest-to-expire (provider 1), not grow past 2.
303        assert_eq!(s.put(rec(&key, 3, 900)), PutOutcome::Accepted);
304        assert_eq!(
305            s.get(&key.to_hex(), 0).len(),
306            2,
307            "per-key cap must not be exceeded"
308        );
309        let ids: std::collections::HashSet<String> = s
310            .get(&key.to_hex(), 0)
311            .into_iter()
312            .map(|r| r.provider_peer_id)
313            .collect();
314        assert!(
315            !ids.contains(&PeerId::from_bytes([1u8; 32]).to_hex()),
316            "soonest-to-expire provider must be the one evicted"
317        );
318    }
319
320    #[test]
321    fn global_cap_rejects_new_content_keys_over_ceiling() {
322        // Many DISTINCT content keys (not just many providers per key) must also be bounded — the
323        // audit's "no cap on distinct content keys ... no global record ceiling" finding.
324        let mut s = ProviderStore::with_limits(ProviderStoreLimits {
325            max_providers_per_key: 20,
326            max_total_records: 2,
327        });
328        let k1 = Key::from_bytes([0x01; 32]);
329        let k2 = Key::from_bytes([0x02; 32]);
330        let k3 = Key::from_bytes([0x03; 32]);
331        assert_eq!(s.put(rec(&k1, 1, 100)), PutOutcome::Accepted);
332        assert_eq!(s.put(rec(&k2, 1, 100)), PutOutcome::Accepted);
333        assert_eq!(
334            s.put(rec(&k3, 1, 100)),
335            PutOutcome::RejectedOverCapacity,
336            "third distinct record must be rejected once the global ceiling is hit"
337        );
338        assert_eq!(s.len(), 2, "rejected record must not be stored");
339        assert!(
340            s.get(&k3.to_hex(), 0).is_empty(),
341            "rejected key must not appear in the store at all"
342        );
343    }
344
345    #[test]
346    fn global_cap_does_not_evict_a_different_key_to_make_room() {
347        // A single attacker flooding new keys must not be able to evict a DIFFERENT (legitimate)
348        // key's providers just by hitting the global ceiling.
349        let mut s = ProviderStore::with_limits(ProviderStoreLimits {
350            max_providers_per_key: 20,
351            max_total_records: 1,
352        });
353        let legit = Key::from_bytes([0xAA; 32]);
354        s.put(rec(&legit, 1, 100));
355        let attacker_key = Key::from_bytes([0xBB; 32]);
356        assert_eq!(
357            s.put(rec(&attacker_key, 2, 100)),
358            PutOutcome::RejectedOverCapacity
359        );
360        assert_eq!(
361            s.get(&legit.to_hex(), 0).len(),
362            1,
363            "the legitimate key's record must survive"
364        );
365    }
366
367    #[test]
368    fn gc_removes_expired_and_empty_keys() {
369        let mut s = ProviderStore::new();
370        let k1 = Key::from_bytes([0x01; 32]);
371        let k2 = Key::from_bytes([0x02; 32]);
372        s.put(rec(&k1, 1, 100)); // expires at 100
373        s.put(rec(&k2, 1, 500)); // expires at 500
374        let removed = s.gc(200);
375        assert_eq!(removed, 1);
376        assert!(s.get(&k1.to_hex(), 200).is_empty());
377        assert_eq!(s.get(&k2.to_hex(), 200).len(), 1);
378    }
379
380    #[test]
381    fn announcements_track_and_untrack() {
382        let mut s = ProviderStore::new();
383        let key = Key::from_bytes([0x07; 32]).to_hex();
384        s.mark_announced(key.clone());
385        s.mark_announced(key.clone()); // idempotent
386        assert_eq!(s.local_announcements(), vec![key.clone()]);
387        assert!(s.unmark_announced(&key));
388        assert!(!s.unmark_announced(&key));
389        assert!(s.local_announcements().is_empty());
390    }
391}