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}