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